GPU ComputeShader를 활용한
실시간 시뮬레이션
Unity 6 + URP | 4 Sessions
학습 목표
GPU ComputeShader — 4 세션 과정
1. ComputeShader의 기본 구조와 GPU 병렬 처리 원리 이해
2. CPU vs GPU 성능 비교를 통한 병렬 처리의 이점 확인
3. Procedural Mesh 파이프라인: Compute → Buffer → Shader
4. CPU-GPU 협력 패턴: 부력 + Gerstner Wave
최종 결과물: GPU 파도 위에서 항해하는 보트 시뮬레이션
과정 개요 — 4 세션
세션 1: ComputeShader란?
GPU 병렬 처리 기초 + Color Grid 데모
세션 2: CPU vs GPU 성능 비교
같은 Gerstner Wave, 극적인 성능 차이
세션 3: Procedural Mesh
Compute → Buffer → Shader 풀 파이프라인
세션 4: CPU와 GPU의 협력
부력 시뮬레이션 + 파라미터 동기화
1
ComputeShader란?
GPU 병렬 처리의 기초와 Color Grid 데모
셰이더(Shader)란?
그래픽스 셰이더 — 렌더링 전용
• Vertex Shader: 정점 위치를 변환
• Fragment Shader: 픽셀 색상을 결정
• 렌더링 파이프라인에 종속 — 입력/출력이 고정됨
ComputeShader — 범용 GPU 계산 (GPGPU)
• 렌더링 파이프라인과 무관하게 독립 실행
• 입력/출력을 자유롭게 정의 가능
• 물리, 시뮬레이션, 데이터 처리 등 범용 활용
GPU vs CPU — 왜 GPU를 쓰는가?
CPU (Central Processing Unit)
• 4~16코어 — 복잡한 로직에 최적화
• 분기(if/else), 재귀 등 순차 처리에 강함
GPU (Graphics Processing Unit)
• 수천 개 코어 — 단순하지만 대량 병렬 처리
• 같은 연산을 수만 개 데이터에 동시 적용
핵심: 같은 작업을 반복하는 대량 데이터 → GPU가 수십~수백 배 빠름
CPU: 4~16코어, 복잡한 로직 | GPU: 수천 코어, 단순하지만 대량 병렬
ComputeShader — 기본 구조
세 가지 핵심 요소:
1. Kernel — GPU에서 실행되는 함수
#pragma kernel CSMain 으로 선언
2. Thread — Kernel을 실행하는 최소 단위
numthreads(x, y, z)로 그룹당 스레드 수 지정
3. ThreadGroup — 스레드의 묶음
Dispatch(gX, gY, gZ)로 그룹 수 지정
총 스레드 수 = numthreads × Dispatch
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 스레드 동시 실행
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 데이터 처리에 핵심
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에서 직접 기록
SV_VertexID — 셰이더에서 버퍼 직접 읽기
모든 메쉬 버텍스에는 고유 Vertex ID가 있음
• Surface Shader의 Vertex 함수에서:
StructuredBuffer<float3> _Vertices;
float3 pos = _Vertices[vertexID];
GPU-only 파이프라인의 핵심
• ComputeShader → StructuredBuffer → Surface Shader
• CPU가 데이터를 다시 읽을 필요 없음
데이터가 GPU를 떠나지 않음 → 대역폭 절약, 지연 없음
ComputeShader 파이프라인 — 전체 흐름
1. C# Start() — 초기화
ComputeBuffer 생성 + 초기 데이터 전송
2. C# Update() — 매 프레임
파라미터 설정 → Dispatch(커널 실행)
3. ComputeShader — GPU에서 병렬 계산
RWStructuredBuffer에 결과 기록
4. Surface Shader — 렌더링
SV_VertexID로 버퍼에서 위치/색상 읽기
데모 — GPU Color Grid
씬: Session1_ComputeShader
• 64×64 Flat Mesh에 GPU 계산된 색상을 실시간 적용
• UV 좌표 + Time → HSV 색상 변환
• 마우스 클릭 → GPU에서 Ripple 효과 생성
프로젝트 에셋
• ColorGrid.compute — GPU 색상 계산 커널
• ColorGridCompute.cs — C# 컨트롤러
Play → 색상 그리드 확인 → 클릭하여 리플 생성
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는 커스텀 함수
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 부하 관리
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() 호출!
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가
비동기로 계산 수행
마우스 클릭 → GPU Ripple
클릭 처리 흐름:
1. Input.GetMouseButtonDown(0) 감지
2. Physics.Raycast → 히트 지점의 UV 좌표 추출
3. RippleData 구조체에 center, birthTime 기록
4. 다음 Update에서 GPU로 전송 → 리플 렌더링
핵심: CPU는 좌표만 전달, 연산은 전부 GPU
마우스 입력 → UV 좌표 → GPU 리플 = 인터랙티브 ComputeShader
세션 1 핵심 정리
ComputeShader
• 렌더링과 무관한 GPU 범용 계산
numthreads × Dispatch
• 총 스레드 수 = 그룹당 스레드 × 그룹 수
StructuredBuffer
• CPU ↔ GPU 데이터 교환의 핵심
SV_VertexID
• Surface Shader에서 GPU 버퍼 직접 읽기
다음 세션: 같은 연산을 CPU vs GPU로 비교 → 성능 차이 확인
2
CPU vs GPU 성능 비교
같은 Gerstner Wave 연산, 극적인 성능 차이
세션 1 복습
배운 것:
• ComputeShader = GPU 범용 계산
• Kernel, numthreads, Dispatch로 스레드 구성
• StructuredBuffer로 데이터 교환
• SV_VertexID로 GPU-only 렌더링
이번 세션의 질문:
"같은 연산을 CPU와 GPU에서 하면 성능 차이가 얼마나 날까?"
연산 내용 — Gerstner Wave
각 버텍스가 수행하는 계산:
• Gerstner Wave 공식으로 XYZ 변위 계산
• 여러 파도를 누적 (Superposition)
• 인접 버텍스와의 차이로 법선 벡터 계산
Gerstner vs Sine Wave
• Sine: Y축만 이동 (둥근 꼭대기)
• Gerstner: XYZ 모두 이동 (뾰족한 꼭대기, 현실적)
버텍스 수: 256×256 = 65,536개 — 모두 같은 공식 적용
Sine: Y축만 이동 | Gerstner: XYZ 모두 이동 (현실적)
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
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배+ 빠름
핵심 차이 — 데이터 흐름
CPU 방식
• CPU에서 계산 → mesh.SetVertices()로 GPU 업로드
• 매 프레임 CPU→GPU 전송 발생 (대역폭 병목)
GPU 방식
• GPU에서 계산 → GPU에서 렌더링
• 데이터가 GPU를 떠나지 않음
GPU-only 파이프라인: 대역폭 47MB/s 절약, 지연 없음
데모 — 나란히 비교 (Side-by-Side)
씬: Session2_CPUvsGPU
• 왼쪽: CPU Wave Simulator
• 오른쪽: GPU Wave Simulator
• 동일한 Gerstner Wave 파라미터
UI 표시
• CPU 처리 시간 (ms)
• GPU 처리 시간 (ms)
• FPS
• 해상도 슬라이더 — 실시간 비교
해상도를 올려보면 CPU는 급격히 느려지고, GPU는 거의 변하지 않음
성능 스케일링 — 해상도별 결과
해상도 × 해상도 → 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의 이점이 극대화됨
CPU vs GPU — 언제 무엇을 쓸까?
GPU를 쓸 때
• 같은 연산을 수천~수만 개 데이터에 적용
• 결과를 GPU에서 바로 렌더링
• 예: 파도, 파티클, 군중 시뮬레이션
CPU를 쓸 때
• 복잡한 분기/재귀 로직
• 결과를 CPU에서 사용해야 할 때 (AI, 네트워크)
• 소량의 데이터
핵심: "많은 데이터, 같은 연산" → GPU | "복잡한 로직" → CPU
성능 측정 방법 — Unity에서
CPU 측정
• System.Diagnostics.Stopwatch
• sw.Start() → 연산 → sw.Stop()
• sw.Elapsed.TotalMilliseconds
GPU 측정 (간접)
• Frame Debugger — GPU 커맨드 확인
• Profiler → Rendering 모듈
FPS
• 1f / Time.deltaTime
정확한 GPU 시간은 프로파일러를 사용해야 함 (세션 4에서 다룸)
세션 2 핵심 정리
순차 vs 병렬
• CPU: for 루프로 하나씩 처리
• GPU: 수만 스레드가 동시 처리
성능 스케일링
• CPU: O(n) — 데이터 증가 시 선형 증가
• GPU: 거의 일정 (코어 포화 전까지)
데이터 흐름
• GPU-only 파이프라인이 가장 빠름
다음 세션: Procedural Mesh — Compute→Buffer→Shader 풀 파이프라인
3
Procedural Mesh
Compute → Buffer → Shader 풀 파이프라인 구축
세션 1-2 복습
세션 1: ComputeShader 기초
• Kernel, numthreads, Dispatch, StructuredBuffer
• Color Grid 데모로 실습
세션 2: CPU vs GPU 성능 비교
• 같은 Gerstner Wave 연산에서 GPU가 100배+ 빠름
• GPU-only 파이프라인의 이점
이번 세션:
코드로 메쉬를 생성하고, GPU로 실시간 변형하는 풀 파이프라인
Procedural Mesh란?
미리 만든 에셋이 아닌, 코드로 런타임에 메쉬를 생성
왜 필요한가?
• 해상도를 자유롭게 조절 가능
• 지형, 수면, 파티클 등 동적 형태에 적합
• GPU와 결합 시 수만 정점을 실시간 애니메이션
필요한 데이터:
• Vertices (정점 위치) — float3 배열
• Triangles (삼각형 인덱스) — int 배열
• UVs (텍스처 좌표) — float2 배열
3파일 아키텍처
1. SineWaveDemo.cs (C#)
메쉬 생성 + 버퍼 관리 + Dispatch
2. SineWave.compute (HLSL)
GPU에서 정점 변위 + 법선 계산
3. Surface Shader (URP)
SV_VertexID로 버퍼에서 위치/법선 읽기
C# → ComputeShader → Surface Shader: 세 파일이 하나의 파이프라인
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에 업로드됨
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에도
같은 버퍼를 바인딩!
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 동시 계산
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
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 읽기 필요)
순서 중요!
변위가 끝나야 법선 계산 가능
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 파이프라인 완성!
전체 파이프라인 — 정리
C# (Start)
Mesh 생성 → ComputeBuffer 할당 → 바인딩
C# (Update)
파라미터 설정 → Dispatch(변위) → Dispatch(법선)
ComputeShader
CSSineDisplace: basePos → 변위된 위치 기록
CSCalcNormals: 인접 정점으로 법선 재계산
Surface Shader
SV_VertexID → 위치/법선 읽기 → 렌더링
Compute → Buffer → Shader = GPU-only 풀 파이프라인
데모 — Interactive Sine Wave
씬: Session3_ProceduralMesh
• 128×128 Procedural Mesh
• GPU에서 실시간 Sine Wave 변위
• 법선 재계산 → 올바른 조명
파라미터 조절
• Amplitude: 파도 높이
• Frequency: 파도 밀도
• Speed: 애니메이션 속도
세션 2에서 봤듯이 128×128에서도 GPU는 거뜬 — CPU는 이미 힘들어함
세션 3 핵심 정리
Procedural Mesh
• 코드로 정점/삼각형/UV 생성
3파일 아키텍처
• C# (관리) + Compute (계산) + Shader (렌더링)
2-Pass Dispatch
• 변위 → 법선 순서로 실행
Mesh Bounds 주의
• GPU가 정점을 이동시키면 bounds 수동 설정 필요
(그렇지 않으면 카메라 회전 시 메쉬가 사라짐)
다음 세션: CPU와 GPU 협력 — 부력 시뮬레이션
4
CPU와 GPU의 협력
부력, Gerstner Wave, 그리고 동기화
세션 1-3 복습
세션 1: ComputeShader 기초
• Kernel, numthreads, Dispatch, StructuredBuffer
세션 2: CPU vs GPU 성능 비교
• 같은 연산에서 GPU가 100배+ 빠름
세션 3: Procedural Mesh
• Compute → Buffer → Shader 풀 파이프라인
이번 세션의 문제:
GPU가 파도를 계산하는데, CPU의 물리 엔진이 파도 높이가 필요하다면?
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에서 이미 성능 비교에 사용했던 공식!
문제 — CPU에서 파도 높이가 필요한 이유
시나리오: 보트가 GPU 파도 위에 떠 있어야 함
• 파도 = GPU의 ComputeShader에서 계산
• 부력 = Rigidbody.AddForceAtPosition() → CPU 물리 엔진
• 부력을 계산하려면 특정 지점의 파도 높이가 필요
문제:
• GPU 데이터를 CPU로 읽어오면? → 1~3프레임 지연
• 매 프레임 수만 개 버텍스를 복사하면? → 대역폭 병목
해결: CPU에서 같은 공식을 복제하여 필요한 지점만 계산
핵심 인사이트 — 같은 공식, 다른 스케일
GPU 측: OceanWave.compute
• 65,536개 버텍스 × 파도 공식 → 대량 병렬
• 렌더링용 (시각적 바다)
CPU 측: GetWaveHeight()
• 6개 포인트 × 같은 파도 공식 → 순차 계산
• 물리용 (부력 계산)
같은 Gerstner 공식을 GPU(HLSL)와 CPU(C#)에 각각 구현
주의: 파라미터가 반드시 동기화되어야 보트가 파도와 일치!
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#에서 복제
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 부하 미미
파라미터 동기화가 핵심!
부력 물리학 — 아르키메데스 원리
물에 잠긴 부분만큼 위로 미는 힘이 작용
• 물 위의 점: 힘 없음
• 물 아래의 점: 깊이에 비례하는 위로의 힘
• F = ρ × g × V (밀도 × 중력 × 잠긴 부피)
게임에서의 근사:
• 정확한 부피 대신, 샘플 포인트의 잠긴 깊이 사용
• depth = waterHeight - pointY
• force = depth × buoyancyForce × Vector3.up
6-Point 부력 시스템
보트 선체의 6개 포인트에서 부력 계산
• 앞(2) + 중간(2) + 뒤(2) = 6개
• 각 포인트에서 독립적으로 힘 적용
표현 가능한 움직임:
• Heave (상하) — 전체 높이 변화
• Pitch (전후 기울기) — 앞뒤 깊이 차이
• Roll (좌우 기울기) — 좌우 깊이 차이
AddForceAtPosition이 핵심 — 힘의 위치가 회전을 만듦
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
• 과도한 진동 방지
• 실제 물의 저항을 근사
보트 컨트롤러 + 카메라 팔로우
SimpleBoatController.cs
• WASD 입력 → Rigidbody.AddForce / AddTorque
• 물리 기반 조작 → 파도와 자연스럽게 상호작용
카메라 팔로우
• Lerp로 부드러운 추적
• 보트의 흔들림을 부분적으로 반영
물리 기반 이동이므로 파도에 의한 자연스러운 움직임이 나옴
전체 아키텍처 — 역할 분담
GPU (대량 병렬)
• 65,536개 버텍스 Gerstner 변위
• 법선 재계산
• 렌더링 (SV_VertexID)
CPU (소량 정밀)
• 6개 포인트 파도 높이 계산
• Rigidbody 물리 (부력, 감쇠)
• 입력 처리, 카메라
GPU는 시각적 바다, CPU는 물리적 바다 — 같은 공식, 다른 역할
대안: AsyncGPUReadback — GPU → CPU 데이터 전송
GPU 데이터를 CPU로 읽어오는 방법
• AsyncGPUReadback.Request(buffer, callback)
• 1~3 프레임 지연 (비동기)
• 전체 버퍼를 복사하면 대역폭 부담
언제 사용?
• GPU 계산 결과를 CPU에서 분석해야 할 때
• 프레임 지연을 허용할 수 있을 때
부력에 적합하지 않은 이유
• 지연 → 보트가 파도와 어긋남
• 6개 포인트를 위해 전체 버퍼 복사는 낭비
CPU 복제 방식이 부력에는 더 효율적이고 정확함
접근 방식 선택 기준
CPU에서 공식 복제
• 소수의 포인트만 필요할 때
• 지연 없이 정확한 값이 필요할 때
• 예: 부력, 발자국, 충돌 감지
AsyncGPUReadback
• GPU 전체 결과가 필요할 때
• 1~3 프레임 지연이 허용될 때
• 예: 히트맵 분석, AI 의사결정
GPU-only (Readback 없음)
• CPU에서 결과가 불필요할 때
• 예: 순수 시각 효과, 파티클
데모 — Gerstner Wave 위 항해
씬: Session4_Buoyancy
• Gerstner Wave 바다 (GPU)
• 6-Point 부력 시스템 (CPU)
• WASD 보트 조작
관찰 포인트
• 파도에 따른 보트의 기울기 변화
• 큰 파도에서의 Pitch/Roll
• 파라미터 변경 시 즉시 동기화
GPU 시각 + CPU 물리의 완벽한 협력을 확인
세션 4 핵심 정리
CPU-GPU 협력 패턴
• 같은 공식을 GPU(HLSL)와 CPU(C#)에 복제
• 파라미터 동기화가 핵심
부력 시스템
• 6-Point 샘플링 + AddForceAtPosition
• FixedUpdate에서 물리 처리
접근 방식 선택
• 소수 포인트 → CPU 복제
• 전체 데이터 → AsyncGPUReadback
• 시각 전용 → GPU-only
전체 과정 정리 — 가져갈 것
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로 변형 → 실시간 렌더링
참고 자료
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 커맨드 분석
Q & A
질문과 답변
발표 자료와 프로젝트 에셋은 배포 예정입니다.
과정 완료!
GPU ComputeShader로 실시간 시뮬레이션 구현 완료�4 Sessions | Unity 6 + URP