1 of 63

GPU ComputeShader를 활용한

실시간 시뮬레이션

Unity 6 + URP | 4 Sessions

2 of 63

학습 목표

GPU ComputeShader — 4 세션 과정

1. ComputeShader의 기본 구조와 GPU 병렬 처리 원리 이해

2. CPU vs GPU 성능 비교를 통한 병렬 처리의 이점 확인

3. Procedural Mesh 파이프라인: Compute → Buffer → Shader

4. CPU-GPU 협력 패턴: 부력 + Gerstner Wave

최종 결과물: GPU 파도 위에서 항해하는 보트 시뮬레이션

3 of 63

과정 개요 — 4 세션

세션 1: ComputeShader란?

GPU 병렬 처리 기초 + Color Grid 데모

세션 2: CPU vs GPU 성능 비교

같은 Gerstner Wave, 극적인 성능 차이

세션 3: Procedural Mesh

Compute → Buffer → Shader 풀 파이프라인

세션 4: CPU와 GPU의 협력

부력 시뮬레이션 + 파라미터 동기화

4 of 63

1

ComputeShader란?

GPU 병렬 처리의 기초와 Color Grid 데모

5 of 63

셰이더(Shader)란?

그래픽스 셰이더 — 렌더링 전용

• Vertex Shader: 정점 위치를 변환

• Fragment Shader: 픽셀 색상을 결정

• 렌더링 파이프라인에 종속 — 입력/출력이 고정됨

ComputeShader — 범용 GPU 계산 (GPGPU)

• 렌더링 파이프라인과 무관하게 독립 실행

• 입력/출력을 자유롭게 정의 가능

• 물리, 시뮬레이션, 데이터 처리 등 범용 활용

6 of 63

GPU vs CPU — 왜 GPU를 쓰는가?

CPU (Central Processing Unit)

• 4~16코어 — 복잡한 로직에 최적화

• 분기(if/else), 재귀 등 순차 처리에 강함

GPU (Graphics Processing Unit)

• 수천 개 코어 — 단순하지만 대량 병렬 처리

• 같은 연산을 수만 개 데이터에 동시 적용

핵심: 같은 작업을 반복하는 대량 데이터 → GPU가 수십~수백 배 빠름

CPU: 4~16코어, 복잡한 로직 | GPU: 수천 코어, 단순하지만 대량 병렬

7 of 63

ComputeShader — 기본 구조

세 가지 핵심 요소:

1. Kernel — GPU에서 실행되는 함수

#pragma kernel CSMain 으로 선언

2. Thread — Kernel을 실행하는 최소 단위

numthreads(x, y, z)로 그룹당 스레드 수 지정

3. ThreadGroup — 스레드의 묶음

Dispatch(gX, gY, gZ)로 그룹 수 지정

총 스레드 수 = numthreads × Dispatch

8 of 63

numthreads 시각화

numthreads(8, 8, 1) × Dispatch(8, 8, 1)

• 그룹당 스레드: 8 × 8 = 64

• 총 그룹 수: 8 × 8 = 64

• 총 스레드: 64 × 64 = 4,096

numthreads(256, 1, 1) × Dispatch(256, 1, 1)

• 1D 배열: 256 × 256 = 65,536 스레드 동시 실행

데이터 형태에 맞게 차원 선택: 1D(배열), 2D(그리드), 3D(볼륨)

numthreads(256,1,1) × Dispatch(256,1,1) = 65,536 스레드 동시 실행

9 of 63

Thread ID → Grid 좌표 변환

SV_DispatchThreadID — 전역 스레드 ID

• uint3 id : SV_DispatchThreadID

• 1D 데이터: index = id.x

• 2D 그리드: (x, y) = (id.x, id.y)

1D → 2D 변환 (수동)

• int x = index % resolution;

• int z = index / resolution;

이 변환은 Procedural Mesh, 텍스처 등 2D 데이터 처리에 핵심

10 of 63

StructuredBuffer — CPU ↔ GPU 데이터 교환

CPU 측 (C#)

• ComputeBuffer buffer = new ComputeBuffer(count, stride);

• shader.SetBuffer(kernelID, "_Buffer", buffer);

GPU 측 (HLSL)

• StructuredBuffer<T> — 읽기 전용

• RWStructuredBuffer<T> — 읽기/쓰기 모두 가능

RW = Read-Write; 계산 결과를 GPU에서 직접 기록

11 of 63

SV_VertexID — 셰이더에서 버퍼 직접 읽기

모든 메쉬 버텍스에는 고유 Vertex ID가 있음

• Surface Shader의 Vertex 함수에서:

StructuredBuffer<float3> _Vertices;

float3 pos = _Vertices[vertexID];

GPU-only 파이프라인의 핵심

• ComputeShader → StructuredBuffer → Surface Shader

• CPU가 데이터를 다시 읽을 필요 없음

데이터가 GPU를 떠나지 않음 → 대역폭 절약, 지연 없음

12 of 63

ComputeShader 파이프라인 — 전체 흐름

1. C# Start() — 초기화

ComputeBuffer 생성 + 초기 데이터 전송

2. C# Update() — 매 프레임

파라미터 설정 → Dispatch(커널 실행)

3. ComputeShader — GPU에서 병렬 계산

RWStructuredBuffer에 결과 기록

4. Surface Shader — 렌더링

SV_VertexID로 버퍼에서 위치/색상 읽기

13 of 63

데모 — GPU Color Grid

씬: Session1_ComputeShader

• 64×64 Flat Mesh에 GPU 계산된 색상을 실시간 적용

• UV 좌표 + Time → HSV 색상 변환

• 마우스 클릭 → GPU에서 Ripple 효과 생성

프로젝트 에셋

• ColorGrid.compute — GPU 색상 계산 커널

• ColorGridCompute.cs — C# 컨트롤러

Play → 색상 그리드 확인 → 클릭하여 리플 생성

14 of 63

ColorGrid.compute — 색상 계산

#pragma kernel CSMain

RWStructuredBuffer<float4> _Colors;

int _Resolution;

float _Time;

[numthreads(64, 1, 1)]

void CSMain(uint3 id : SV_DispatchThreadID)

{

int x = id.x % _Resolution;

int z = id.x / _Resolution;

float u = (float)x / _Resolution;

float v = (float)z / _Resolution;

// UV + Time → HSV → RGB

float hue = frac(u + v + _Time * 0.1);

float3 rgb = HSVtoRGB(hue, 0.8, 1.0);

_Colors[id.x] = float4(rgb, 1);

}

핵심 포인트

• numthreads(64,1,1)

→ 1D 스레드 배치

• id.x → 2D 좌표 변환

x = id.x % res

z = id.x / res

• UV로 Hue 계산

+ Time으로 애니메이션

HSVtoRGB는 커스텀 함수

15 of 63

ColorGrid.compute — Ripple Effect

struct RippleData {

float2 center;

float birthTime;

int active;

};

StructuredBuffer<RippleData> _Ripples;

int _RippleCount;

// CSMain 내부 — 리플 누적

for (int i = 0; i < _RippleCount; i++) {

if (!_Ripples[i].active) continue;

float dist = distance(uv, _Ripples[i].center);

float age = _Time - _Ripples[i].birthTime;

float wave = sin(dist*30 - age*5);

float fade = saturate(1 - age * 0.5);

rgb += wave * fade * 0.3;

}

리플 구조

• RippleData 구조체로

중심, 생성 시간, 활성 여부 전달

• distance → sin → fade

파문이 퍼지며 감쇠

최대 리플 수를 제한하여

GPU 부하 관리

16 of 63

C# 컨트롤러 — ColorGridCompute.cs (Setup)

void Start() {

// 메쉬 생성

mesh = GenerateFlatMesh(resolution);

// ComputeBuffer 할당

colorBuffer = new ComputeBuffer(

resolution * resolution,

sizeof(float) * 4);

rippleBuffer = new ComputeBuffer(

maxRipples, Marshal.SizeOf<RippleData>());

// 셰이더에 버퍼 바인딩

compute.SetBuffer(kernel, "_Colors", colorBuffer);

compute.SetBuffer(kernel, "_Ripples", rippleBuffer);

material.SetBuffer("_Colors", colorBuffer);

}

초기화 순서

1. Flat Mesh 생성

(코드로 정점/삼각형)

2. ComputeBuffer 할당

(GPU 메모리)

3. 버퍼 바인딩

Compute + Material 양쪽에

OnDestroy에서 반드시

buffer.Release() 호출!

17 of 63

C# 컨트롤러 — 매 프레임 Dispatch

void Update() {

// 파라미터 업데이트

compute.SetFloat("_Time", Time.time);

compute.SetInt("_Resolution", resolution);

// 리플 데이터 업로드

rippleBuffer.SetData(ripples);

compute.SetInt("_RippleCount", activeCount);

// GPU 실행!

int groups = Mathf.CeilToInt(

resolution * resolution / 64f);

compute.Dispatch(kernel, groups, 1, 1);

}

매 프레임 순서

1. 시간/해상도 전달

2. 리플 배열 업로드

CPU → GPU

3. Dispatch 호출

groups = 총스레드/64

Dispatch 후 GPU가

비동기로 계산 수행

18 of 63

마우스 클릭 → GPU Ripple

클릭 처리 흐름:

1. Input.GetMouseButtonDown(0) 감지

2. Physics.Raycast → 히트 지점의 UV 좌표 추출

3. RippleData 구조체에 center, birthTime 기록

4. 다음 Update에서 GPU로 전송 → 리플 렌더링

핵심: CPU는 좌표만 전달, 연산은 전부 GPU

마우스 입력 → UV 좌표 → GPU 리플 = 인터랙티브 ComputeShader

19 of 63

세션 1 핵심 정리

ComputeShader

• 렌더링과 무관한 GPU 범용 계산

numthreads × Dispatch

• 총 스레드 수 = 그룹당 스레드 × 그룹 수

StructuredBuffer

• CPU ↔ GPU 데이터 교환의 핵심

SV_VertexID

• Surface Shader에서 GPU 버퍼 직접 읽기

다음 세션: 같은 연산을 CPU vs GPU로 비교 → 성능 차이 확인

20 of 63

2

CPU vs GPU 성능 비교

같은 Gerstner Wave 연산, 극적인 성능 차이

21 of 63

세션 1 복습

배운 것:

• ComputeShader = GPU 범용 계산

• Kernel, numthreads, Dispatch로 스레드 구성

• StructuredBuffer로 데이터 교환

• SV_VertexID로 GPU-only 렌더링

이번 세션의 질문:

"같은 연산을 CPU와 GPU에서 하면 성능 차이가 얼마나 날까?"

22 of 63

연산 내용 — Gerstner Wave

각 버텍스가 수행하는 계산:

• Gerstner Wave 공식으로 XYZ 변위 계산

• 여러 파도를 누적 (Superposition)

• 인접 버텍스와의 차이로 법선 벡터 계산

Gerstner vs Sine Wave

• Sine: Y축만 이동 (둥근 꼭대기)

• Gerstner: XYZ 모두 이동 (뾰족한 꼭대기, 현실적)

버텍스 수: 256×256 = 65,536개 — 모두 같은 공식 적용

Sine: Y축만 이동 | Gerstner: XYZ 모두 이동 (현실적)

23 of 63

CPU 코드 — 순차 루프 (CPUWaveSimulator.cs)

void Update() {

var sw = Stopwatch.StartNew();

for (int i = 0; i < vertexCount; i++) {

Vector3 pos = basePositions[i];

Vector3 offset = Vector3.zero;

for (int w = 0; w < waveCount; w++) {

offset += GerstnerWave(pos, waves[w]);

}

vertices[i] = pos + offset;

}

mesh.SetVertices(vertices);

cpuTimeMs = sw.Elapsed.TotalMilliseconds;

}

CPU 한계

• 65,536번 순차 반복

• 각 버텍스마다 파도 누적

• Stopwatch로 시간 측정

해상도 ↑ → 시간 급증

256×256에서 수십 ms

24 of 63

GPU 코드 — 병렬 Dispatch (GPUWaveSimulator.cs)

void Update() {

compute.SetFloat("_Time", Time.time);

compute.SetBuffer(kernel, "_Vertices", vertBuf);

compute.SetBuffer(kernel, "_BasePos", baseBuf);

compute.SetBuffer(kernel, "_Waves", waveBuf);

int groups = Mathf.CeilToInt(

vertexCount / 256f);

compute.Dispatch(kernel, groups, 1, 1);

// GPU에서 계산 + 렌더링

// CPU에 데이터 복사 없음!

}

GPU 장점

• 65,536 스레드 동시 실행

• CPU는 Dispatch 한 번만 호출

• 데이터가 GPU에 머무름

→ 대역폭 절약

GPU: 0.1ms 이하

CPU 대비 100배+ 빠름

25 of 63

핵심 차이 — 데이터 흐름

CPU 방식

• CPU에서 계산 → mesh.SetVertices()로 GPU 업로드

• 매 프레임 CPU→GPU 전송 발생 (대역폭 병목)

GPU 방식

• GPU에서 계산 → GPU에서 렌더링

• 데이터가 GPU를 떠나지 않음

GPU-only 파이프라인: 대역폭 47MB/s 절약, 지연 없음

26 of 63

데모 — 나란히 비교 (Side-by-Side)

씬: Session2_CPUvsGPU

• 왼쪽: CPU Wave Simulator

• 오른쪽: GPU Wave Simulator

• 동일한 Gerstner Wave 파라미터

UI 표시

• CPU 처리 시간 (ms)

• GPU 처리 시간 (ms)

• FPS

• 해상도 슬라이더 — 실시간 비교

해상도를 올려보면 CPU는 급격히 느려지고, GPU는 거의 변하지 않음

27 of 63

성능 스케일링 — 해상도별 결과

해상도 × 해상도 → CPU (ms) GPU (ms)

64 × 64 (4K) ~0.5ms ~0.05ms

128 × 128 (16K) ~3ms ~0.05ms

256 × 256 (65K) ~15ms ~0.1ms

512 × 512 (262K) ~60ms+ ~0.2ms

CPU: O(n) 선형 증가 | GPU: 거의 일정 (코어가 충분할 때)

데이터 규모가 클수록 GPU의 이점이 극대화됨

28 of 63

CPU vs GPU — 언제 무엇을 쓸까?

GPU를 쓸 때

• 같은 연산을 수천~수만 개 데이터에 적용

• 결과를 GPU에서 바로 렌더링

• 예: 파도, 파티클, 군중 시뮬레이션

CPU를 쓸 때

• 복잡한 분기/재귀 로직

• 결과를 CPU에서 사용해야 할 때 (AI, 네트워크)

• 소량의 데이터

핵심: "많은 데이터, 같은 연산" → GPU | "복잡한 로직" → CPU

29 of 63

성능 측정 방법 — Unity에서

CPU 측정

• System.Diagnostics.Stopwatch

• sw.Start() → 연산 → sw.Stop()

• sw.Elapsed.TotalMilliseconds

GPU 측정 (간접)

• Frame Debugger — GPU 커맨드 확인

• Profiler → Rendering 모듈

FPS

• 1f / Time.deltaTime

정확한 GPU 시간은 프로파일러를 사용해야 함 (세션 4에서 다룸)

30 of 63

세션 2 핵심 정리

순차 vs 병렬

• CPU: for 루프로 하나씩 처리

• GPU: 수만 스레드가 동시 처리

성능 스케일링

• CPU: O(n) — 데이터 증가 시 선형 증가

• GPU: 거의 일정 (코어 포화 전까지)

데이터 흐름

• GPU-only 파이프라인이 가장 빠름

다음 세션: Procedural Mesh — Compute→Buffer→Shader 풀 파이프라인

31 of 63

3

Procedural Mesh

Compute → Buffer → Shader 풀 파이프라인 구축

32 of 63

세션 1-2 복습

세션 1: ComputeShader 기초

• Kernel, numthreads, Dispatch, StructuredBuffer

• Color Grid 데모로 실습

세션 2: CPU vs GPU 성능 비교

• 같은 Gerstner Wave 연산에서 GPU가 100배+ 빠름

• GPU-only 파이프라인의 이점

이번 세션:

코드로 메쉬를 생성하고, GPU로 실시간 변형하는 풀 파이프라인

33 of 63

Procedural Mesh란?

미리 만든 에셋이 아닌, 코드로 런타임에 메쉬를 생성

왜 필요한가?

• 해상도를 자유롭게 조절 가능

• 지형, 수면, 파티클 등 동적 형태에 적합

• GPU와 결합 시 수만 정점을 실시간 애니메이션

필요한 데이터:

• Vertices (정점 위치) — float3 배열

• Triangles (삼각형 인덱스) — int 배열

• UVs (텍스처 좌표) — float2 배열

34 of 63

3파일 아키텍처

1. SineWaveDemo.cs (C#)

메쉬 생성 + 버퍼 관리 + Dispatch

2. SineWave.compute (HLSL)

GPU에서 정점 변위 + 법선 계산

3. Surface Shader (URP)

SV_VertexID로 버퍼에서 위치/법선 읽기

C# → ComputeShader → Surface Shader: 세 파일이 하나의 파이프라인

35 of 63

Step 1 — Flat Mesh 생성 (C#)

void GenerateMesh() {

vertices = new Vector3[res * res];

var uvs = new Vector2[res * res];

for (int z = 0; z < res; z++)

for (int x = 0; x < res; x++) {

int i = z * res + x;

float px = (float)x / (res-1) * size;

float pz = (float)z / (res-1) * size;

vertices[i] = new Vector3(px, 0, pz);

uvs[i] = new Vector2(px/size, pz/size);

}

// 삼각형 인덱스 생성 (Quad → 2 tri)

mesh.SetVertices(vertices);

mesh.SetUVs(0, uvs);

mesh.SetTriangles(triangles, 0);

}

핵심

• 128×128 = 16,384 정점

• 2D 그리드를 1D 배열로

index = z × res + x

• Quad마다 삼각형 2개

(127×127×6 인덱스)

이 초기 위치가 basePos로

GPU에 업로드됨

36 of 63

Step 2 — ComputeBuffer 생성 및 바인딩

// 세 개의 버퍼 필요

vertexBuffer = new ComputeBuffer(

res*res, sizeof(float)*3);

normalBuffer = new ComputeBuffer(

res*res, sizeof(float)*3);

baseBuffer = new ComputeBuffer(

res*res, sizeof(float)*3);

// 초기 위치 업로드 (한 번만)

baseBuffer.SetData(vertices);

// ComputeShader에 바인딩

compute.SetBuffer(displace, "_Vertices", vertBuf);

compute.SetBuffer(displace, "_BasePos", baseBuf);

compute.SetBuffer(normals, "_Vertices", vertBuf);

compute.SetBuffer(normals, "_Normals", normBuf);

버퍼 역할

• _BasePos: 초기 flat 위치

(변경 안 됨)

• _Vertices: 매 프레임

GPU가 변위 결과 기록

• _Normals: 법선 벡터

(조명 계산용)

Surface Shader에도

같은 버퍼를 바인딩!

37 of 63

Step 3 — Sine Wave 변위 (ComputeShader)

#pragma kernel CSSineDisplace

RWStructuredBuffer<float3> _Vertices;

StructuredBuffer<float3> _BasePos;

float _Time, _Amplitude, _Frequency, _Speed;

[numthreads(256, 1, 1)]

void CSSineDisplace(uint3 id : SV_DispatchThreadID)

{

float3 base = _BasePos[id.x];

float y = _Amplitude * sin(

base.x * _Frequency

+ base.z * _Frequency * 0.7

+ _Time * _Speed);

_Vertices[id.x] = float3(base.x, y, base.z);

}

Sine Wave 공식

y = A × sin(x×f + z×f×0.7 + t×s)

• A: 진폭 (Amplitude)

• f: 주파수 (Frequency)

• s: 속도 (Speed)

• base.x와 base.z 모두 사용

→ 대각선 파도

256 스레드 × 64 그룹

= 16,384 동시 계산

38 of 63

Step 3b — 법선 계산 (ComputeShader)

#pragma kernel CSCalcNormals

RWStructuredBuffer<float3> _Normals;

StructuredBuffer<float3> _Vertices;

int _Resolution;

[numthreads(256, 1, 1)]

void CSCalcNormals(uint3 id : SV_DispatchThreadID)

{

int x = id.x % _Resolution;

int z = id.x / _Resolution;

float3 right = _Vertices[idx(x+1,z)]

- _Vertices[idx(x-1,z)];

float3 fwd = _Vertices[idx(x,z+1)]

- _Vertices[idx(x,z-1)];

_Normals[id.x] = normalize(cross(fwd, right));

}

법선이 필요한 이유

• 조명(Lighting) 계산에 필수

• GPU가 버텍스를 이동시키면

법선도 재계산 필요

중앙 차분법

• 좌우 차이 → right 벡터

• 앞뒤 차이 → forward 벡터

• cross(fwd, right) → 법선

두 번째 커널로 별도 Dispatch

39 of 63

Step 4 — 매 프레임 Dispatch (C#)

void Update() {

// 파라미터 전달

compute.SetFloat("_Time", Time.time);

compute.SetFloat("_Amplitude", amplitude);

compute.SetFloat("_Frequency", frequency);

compute.SetFloat("_Speed", speed);

compute.SetInt("_Resolution", resolution);

int groups = Mathf.CeilToInt(

resolution * resolution / 256f);

// 커널 1: 변위

compute.Dispatch(displaceKernel, groups, 1, 1);

// 커널 2: 법선

compute.Dispatch(normalsKernel, groups, 1, 1);

}

2-Pass Dispatch

1. CSSineDisplace

→ _Vertices 업데이트

2. CSCalcNormals

→ _Normals 업데이트

(_Vertices 읽기 필요)

순서 중요!

변위가 끝나야 법선 계산 가능

40 of 63

Step 5 — Surface Shader에서 버퍼 읽기

StructuredBuffer<float3> _Vertices;

StructuredBuffer<float3> _Normals;

Varyings vert(Attributes input) {

uint vid = input.vertexID; // SV_VertexID

float3 pos = _Vertices[vid];

float3 nrm = _Normals[vid];

// Unity 변환

output.positionCS =

TransformObjectToHClip(pos);

output.normalWS =

TransformObjectToWorldNormal(nrm);

return output;

}

SV_VertexID 패턴

• 세션 1에서 배운 패턴

동일하게 적용

• ComputeShader 결과를

Surface Shader가 직접 읽음

• CPU 개입 없음

GPU-only 파이프라인 완성!

41 of 63

전체 파이프라인 — 정리

C# (Start)

Mesh 생성 → ComputeBuffer 할당 → 바인딩

C# (Update)

파라미터 설정 → Dispatch(변위) → Dispatch(법선)

ComputeShader

CSSineDisplace: basePos → 변위된 위치 기록

CSCalcNormals: 인접 정점으로 법선 재계산

Surface Shader

SV_VertexID → 위치/법선 읽기 → 렌더링

Compute → Buffer → Shader = GPU-only 풀 파이프라인

42 of 63

데모 — Interactive Sine Wave

씬: Session3_ProceduralMesh

• 128×128 Procedural Mesh

• GPU에서 실시간 Sine Wave 변위

• 법선 재계산 → 올바른 조명

파라미터 조절

• Amplitude: 파도 높이

• Frequency: 파도 밀도

• Speed: 애니메이션 속도

세션 2에서 봤듯이 128×128에서도 GPU는 거뜬 — CPU는 이미 힘들어함

43 of 63

세션 3 핵심 정리

Procedural Mesh

• 코드로 정점/삼각형/UV 생성

3파일 아키텍처

• C# (관리) + Compute (계산) + Shader (렌더링)

2-Pass Dispatch

• 변위 → 법선 순서로 실행

Mesh Bounds 주의

• GPU가 정점을 이동시키면 bounds 수동 설정 필요

(그렇지 않으면 카메라 회전 시 메쉬가 사라짐)

다음 세션: CPU와 GPU 협력 — 부력 시뮬레이션

44 of 63

4

CPU와 GPU의 협력

부력, Gerstner Wave, 그리고 동기화

45 of 63

세션 1-3 복습

세션 1: ComputeShader 기초

• Kernel, numthreads, Dispatch, StructuredBuffer

세션 2: CPU vs GPU 성능 비교

• 같은 연산에서 GPU가 100배+ 빠름

세션 3: Procedural Mesh

• Compute → Buffer → Shader 풀 파이프라인

이번 세션의 문제:

GPU가 파도를 계산하는데, CPU의 물리 엔진이 파도 높이가 필요하다면?

46 of 63

Sine Wave → Gerstner Wave 업그레이드

세션 3: 단순한 Sine Wave

• y = A × sin(x × f + t × s)

• Y축만 이동 → 비현실적

세션 4: Gerstner Wave

• XYZ 모두 이동 → 뾰족한 파도 꼭대기

• 심해파 분산 관계: c = √(g/k)

• 여러 파도를 중첩 → 자연스러운 바다

Gerstner Wave는 세션 2에서 이미 성능 비교에 사용했던 공식!

47 of 63

문제 — CPU에서 파도 높이가 필요한 이유

시나리오: 보트가 GPU 파도 위에 떠 있어야 함

• 파도 = GPU의 ComputeShader에서 계산

• 부력 = Rigidbody.AddForceAtPosition() → CPU 물리 엔진

• 부력을 계산하려면 특정 지점의 파도 높이가 필요

문제:

• GPU 데이터를 CPU로 읽어오면? → 1~3프레임 지연

• 매 프레임 수만 개 버텍스를 복사하면? → 대역폭 병목

해결: CPU에서 같은 공식을 복제하여 필요한 지점만 계산

48 of 63

핵심 인사이트 — 같은 공식, 다른 스케일

GPU 측: OceanWave.compute

• 65,536개 버텍스 × 파도 공식 → 대량 병렬

• 렌더링용 (시각적 바다)

CPU 측: GetWaveHeight()

• 6개 포인트 × 같은 파도 공식 → 순차 계산

• 물리용 (부력 계산)

같은 Gerstner 공식을 GPU(HLSL)와 CPU(C#)에 각각 구현

주의: 파라미터가 반드시 동기화되어야 보트가 파도와 일치!

49 of 63

GPU 측 — Gerstner Wave (OceanWave.compute)

float3 GerstnerWave(float3 pos, WaveParams w) {

float k = 2 * PI / w.wavelength;

float c = sqrt(9.8 / k); // 분산 관계

float a = w.steepness / k;

float2 d = normalize(float2(

w.directionX, w.directionY));

float f = k * dot(d, pos.xz) - c * _Time;

return float3(

d.x * a * cos(f),

w.amplitude * sin(f),

d.y * a * cos(f));

}

Gerstner 파라미터

• wavelength: 파장

• amplitude: 진폭

• steepness: 가파름

• direction: 방향

분산 관계

• c = √(g/k)

• 긴 파도가 빨리 이동

이 공식을 C#에서 복제

50 of 63

CPU 측 — GetWaveHeight() (C#)

float GetWaveHeight(Vector3 worldPos) {

float height = 0;

foreach (var w in waves) {

float k = 2f * Mathf.PI / w.wavelength;

float c = Mathf.Sqrt(9.8f / k);

Vector2 d = new Vector2(

w.directionX, w.directionY).normalized;

float f = k * Vector2.Dot(

d, new Vector2(worldPos.x, worldPos.z))

- c * Time.time;

height += w.amplitude * Mathf.Sin(f);

}

return height;

}

CPU 복제 규칙

• 공식이 GPU와 동일

• Mathf.PI ↔ PI

• Mathf.Sqrt ↔ sqrt

• Mathf.Sin ↔ sin

• 6개 포인트만 계산

→ CPU 부하 미미

파라미터 동기화가 핵심!

51 of 63

부력 물리학 — 아르키메데스 원리

물에 잠긴 부분만큼 위로 미는 힘이 작용

• 물 위의 점: 힘 없음

• 물 아래의 점: 깊이에 비례하는 위로의 힘

• F = ρ × g × V (밀도 × 중력 × 잠긴 부피)

게임에서의 근사:

• 정확한 부피 대신, 샘플 포인트의 잠긴 깊이 사용

• depth = waterHeight - pointY

• force = depth × buoyancyForce × Vector3.up

52 of 63

6-Point 부력 시스템

보트 선체의 6개 포인트에서 부력 계산

• 앞(2) + 중간(2) + 뒤(2) = 6개

• 각 포인트에서 독립적으로 힘 적용

표현 가능한 움직임:

• Heave (상하) — 전체 높이 변화

• Pitch (전후 기울기) — 앞뒤 깊이 차이

• Roll (좌우 기울기) — 좌우 깊이 차이

AddForceAtPosition이 핵심 — 힘의 위치가 회전을 만듦

53 of 63

SimpleBuoyancy.cs — FixedUpdate 루프

void FixedUpdate() {

foreach (var point in samplePoints) {

Vector3 wp = transform.TransformPoint(point);

float waterY = waveSimulator

.GetWaveHeight(wp);

float depth = waterY - wp.y;

if (depth > 0) { // 물 아래

Vector3 force = Vector3.up

* depth * buoyancyForce;

rb.AddForceAtPosition(

force, wp,

ForceMode.Acceleration);

}

}

// 감쇠 (Damping)

rb.AddForce(-rb.velocity * damping);

}

FixedUpdate 사용 이유

• 물리 연산은 고정 시간 간격

(기본 0.02초 = 50Hz)

AddForceAtPosition

• 위치에 따른 토크 자동 생성

• → 기울기(Pitch/Roll) 발생

Damping

• 과도한 진동 방지

• 실제 물의 저항을 근사

54 of 63

보트 컨트롤러 + 카메라 팔로우

SimpleBoatController.cs

• WASD 입력 → Rigidbody.AddForce / AddTorque

• 물리 기반 조작 → 파도와 자연스럽게 상호작용

카메라 팔로우

• Lerp로 부드러운 추적

• 보트의 흔들림을 부분적으로 반영

물리 기반 이동이므로 파도에 의한 자연스러운 움직임이 나옴

55 of 63

전체 아키텍처 — 역할 분담

GPU (대량 병렬)

• 65,536개 버텍스 Gerstner 변위

• 법선 재계산

• 렌더링 (SV_VertexID)

CPU (소량 정밀)

• 6개 포인트 파도 높이 계산

• Rigidbody 물리 (부력, 감쇠)

• 입력 처리, 카메라

GPU는 시각적 바다, CPU는 물리적 바다 — 같은 공식, 다른 역할

56 of 63

대안: AsyncGPUReadback — GPU → CPU 데이터 전송

GPU 데이터를 CPU로 읽어오는 방법

• AsyncGPUReadback.Request(buffer, callback)

• 1~3 프레임 지연 (비동기)

• 전체 버퍼를 복사하면 대역폭 부담

언제 사용?

• GPU 계산 결과를 CPU에서 분석해야 할 때

• 프레임 지연을 허용할 수 있을 때

부력에 적합하지 않은 이유

• 지연 → 보트가 파도와 어긋남

• 6개 포인트를 위해 전체 버퍼 복사는 낭비

CPU 복제 방식이 부력에는 더 효율적이고 정확함

57 of 63

접근 방식 선택 기준

CPU에서 공식 복제

• 소수의 포인트만 필요할 때

• 지연 없이 정확한 값이 필요할 때

• 예: 부력, 발자국, 충돌 감지

AsyncGPUReadback

• GPU 전체 결과가 필요할 때

• 1~3 프레임 지연이 허용될 때

• 예: 히트맵 분석, AI 의사결정

GPU-only (Readback 없음)

• CPU에서 결과가 불필요할 때

• 예: 순수 시각 효과, 파티클

58 of 63

데모 — Gerstner Wave 위 항해

씬: Session4_Buoyancy

• Gerstner Wave 바다 (GPU)

• 6-Point 부력 시스템 (CPU)

• WASD 보트 조작

관찰 포인트

• 파도에 따른 보트의 기울기 변화

• 큰 파도에서의 Pitch/Roll

• 파라미터 변경 시 즉시 동기화

GPU 시각 + CPU 물리의 완벽한 협력을 확인

59 of 63

세션 4 핵심 정리

CPU-GPU 협력 패턴

• 같은 공식을 GPU(HLSL)와 CPU(C#)에 복제

• 파라미터 동기화가 핵심

부력 시스템

• 6-Point 샘플링 + AddForceAtPosition

• FixedUpdate에서 물리 처리

접근 방식 선택

• 소수 포인트 → CPU 복제

• 전체 데이터 → AsyncGPUReadback

• 시각 전용 → GPU-only

60 of 63

전체 과정 정리 — 가져갈 것

1. ComputeShader = GPU 범용 계산

numthreads, Dispatch, SV_DispatchThreadID

2. StructuredBuffer = CPU ↔ GPU 데이터 교환

ComputeBuffer + SetBuffer

3. GPU-only 파이프라인

ComputeShader → StructuredBuffer → Surface Shader (SV_VertexID)

4. CPU-GPU 협력 패턴

같은 공식 복제 vs AsyncGPUReadback

5. Procedural Mesh

코드로 생성 → GPU로 변형 → 실시간 렌더링

61 of 63

참고 자료

Unity 공식 문서

• docs.unity3d.com → ComputeShader

• docs.unity3d.com → AsyncGPUReadback

Gerstner Wave

• GPU Gems Ch.1 — Effective Water Simulation

• Jerry Tessendorf — Simulating Ocean Water

성능 프로파일링

• Unity Profiler → Rendering Module

• Frame Debugger → GPU 커맨드 분석

62 of 63

Q & A

질문과 답변

발표 자료와 프로젝트 에셋은 배포 예정입니다.

63 of 63

과정 완료!

GPU ComputeShader로 실시간 시뮬레이션 구현 완료�4 Sessions | Unity 6 + URP