GPU 기반 인터랙티브 잔디 시스템


Unity URP 환경에서 Compute Shader와 DrawMeshInstancedIndirect를 활용한 GPU Driven Grass Rendering R&D
About this project
이 프로젝트는 Unity 6 URP 환경에서 Compute Shader와 DrawMeshInstancedIndirect를 활용해 구현한 GPU 기반 인터랙티브 잔디 렌더링 R&D입니다.
대량의 잔디 인스턴스를 CPU에서 개별적으로 갱신하거나 컬링하지 않고, GPU 상에서 상태 갱신, 프러스텀 컬링, 가시 인스턴스 리스트 생성, Indirect Draw까지 이어지는 구조를 설계했습니다.
사용자의 입력에 따라 잔디가 불타고, 완전히 탄 뒤 일정 시간이 지나면 다시 자라나는 상태 머신을 Compute Shader에서 처리했으며, 렌더 셰이더에서는 Burn / Char / Regrow 상태를 디졸브, 색상 밴드, 높이 스케일로 시각화했습니다.
RenderDoc을 통해 ExecuteIndirect 호출과 instanceCount 변화를 확인하여, Compute 기반 컬링과 DrawMeshInstancedIndirect 파이프라인이 정상적으로 동작하는 것을 검증했습니다.
System Overview
GPU Driven Pipeline
이 시스템은 CPU가 모든 잔디 인스턴스를 직접 순회하며 상태 갱신과 컬링을 처리하는 구조가 아니라, GPU에서 보이는 잔디만 선별하고 그 결과를 Indirect Draw에 전달하는 구조로 설계했습니다.
CPU는 인스턴스별 연산을 직접 수행하지 않고, Compute Shader Dispatch, 파라미터 전달, Draw 호출을 담당하도록 역할을 분리했습니다.
각 단계의 역할은 다음과 같습니다.
| 단계 | 역할 |
|---|---|
| Update | 잔디의 Alive / Burning / Burnt / Regrowing 상태 갱신 |
| Cull | 카메라 프러스텀 기준으로 보이는 잔디 인스턴스만 선별 |
| CopyCount | Visible Buffer에 쌓인 개수를 Indirect Args의 instanceCount로 복사 |
| Draw | DrawMeshInstancedIndirect로 가시 인스턴스만 렌더링 |
GrassUpdate.compute에서는 잔디의 상태를 갱신하고,GrassCull.compute에서는 카메라 프러스텀 기준으로 보이는 인스턴스만VisibleAppend Buffer에 기록합니다.
이후CopyCount로 Visible Buffer의 count를 Indirect Args Buffer의instanceCount에 복사하고,DrawMeshInstancedIndirect로 가시 인스턴스만 렌더링합니다.

Buffer Layout
GPU Buffer Structure
이 프로젝트에서 중요한 부분은 CPU 데이터와 GPU 데이터의 역할 분리입니다.
잔디별 위치, 회전, 스케일, 상태, 타이머 정보를 GPU 버퍼에 유지하고, 렌더링 시 SV_InstanceID를 이용해 실제 인스턴스 데이터를 역참조하는 구조로 만들었습니다.
| Buffer | Type | 역할 |
|---|---|---|
| Instances | StructuredBuffer | 잔디 위치, 스케일, 회전, seed 저장 |
| States | StructuredBuffer | 상태값, burn/regrow 타이머 저장 |
| Visible | AppendStructuredBuffer | 컬링 후 보이는 인스턴스 인덱스 저장 |
| Args | IndirectArguments Buffer | Indirect Draw에 필요한 인자 저장 |
Visible 버퍼에는 실제 인스턴스 데이터가 아니라 보이는 원본 인덱스만 저장했습니다.
렌더 셰이더에서는 SV_InstanceID로 Visible Buffer를 읽고, 그 값으로 Instances와 States를 다시 참조합니다.
컬링 결과는 별도의 Visible Append Buffer에 저장하고, 렌더 셰이더에서는 SV_InstanceID를 통해 Visible Buffer를 먼저 참조한 뒤 실제 원본 인덱스를 역참조했습니다.
이 구조를 통해 컬링된 인스턴스만 렌더링하면서도, 인스턴스별 Transform과 State 데이터는 GPU 상에서 유지할 수 있도록 구성했습니다.


GPU Frustum Culling
Compute Shader 기반 가시성 판별
대량의 잔디 인스턴스를 CPU에서 매 프레임 개별 컬링하면 CPU 부하가 커질 수 있습니다.
이 프로젝트에서는 카메라 프러스텀 평면 6개를 Compute Shader로 전달하고, 각 잔디 인스턴스의 위치와 patchRadius를 기준으로 GPU에서 가시성 판별을 수행했습니다.
Cull Dispatch 전에 Visible Buffer의 counter를 초기화하고, 이번 프레임에 보이는 인스턴스만 Append하도록 구성했습니다.
이후CopyCount를 통해 Append된 개수를 Args Buffer의instanceCount로 복사하여, CPU가 렌더링할 인스턴스 수를 직접 계산하지 않고 GPU에서 계산된 결과를 Draw 호출에 반영했습니다.

DrawMeshInstancedIndirect
Indirect Draw 구조 이해 및 구현
일반적인 DrawMeshInstanced는 CPU가 인스턴스 배치를 만들고, 한 번에 그릴 수 있는 개수도 제한됩니다.
반면 DrawMeshInstancedIndirect는 Args Buffer에 저장된 값을 기반으로 GPU가 인스턴스 수를 결정해 그릴 수 있습니다.
Indirect Args Buffer 구조는 다음과 같습니다.
이 프로젝트에서는 초기화 단계에서 메쉬의 index 정보를 Args Buffer에 세팅하고, 매 프레임 Visible Buffer의 Count를 Args[1], 즉 instanceCount에 복사했습니다.
DrawMeshInstancedIndirect의 Args Buffer 구조를 직접 구성하고, Visible Buffer의 Append Count를 Args[1]에 복사하여 instanceCount를 실시간으로 갱신했습니다.
이 방식으로 카메라에 보이는 잔디 개수만 Indirect Draw에 반영되도록 만들었습니다.

RenderDoc Verification
Frame Debugger에서는 Draw가 한 줄로 보일 수 있기 때문에, 실제 GPU 호출과 instanceCount 변화를 확인하기 위해 RenderDoc으로 검증했습니다.
테스트 세팅은 잔디 300,000개였고, RenderDoc에서 ExecuteIndirect 호출을 확인했습니다.
카메라 상태에 따라 ExecuteIndirect(DrawIndexed, instanceCount = 105,896) 또는 instanceCount = 300,000으로 표시되며, Compute 기반 프러스텀 컬링과 DrawMeshInstancedIndirect 파이프라인이 정상 동작 확인하였습니다.
RenderDoc을 사용해 ExecuteIndirect 호출과 instanceCount 변화를 확인했습니다.
카메라 프러스텀에 들어온 잔디만 Visible Buffer에 기록되고, 해당 개수가 Indirect Args Buffer로 전달되는 것을 확인하여 Compute 기반 컬링과 DrawMeshInstancedIndirect 파이프라인이 정상 동작하는 것을 검증했습니다.



Interactive Grass State System
Burn / Burnt / Regrow 상태 머신
잔디는 단순히 렌더링만 되는 오브젝트가 아니라, 사용자의 입력에 반응하는 상태 머신을 갖도록 설계했습니다.
상태는 다음과 같이 구성했습니다.
| State | 설명 |
|---|---|
| Alive | 정상 상태 |
| Burning | 불타는 중 |
| Burnt | 완전히 탄 상태 |
| Regrowing | 다시 자라는 중 |
입력 위치와 잔디 인스턴스 간 거리를 계산해 반경 안에 들어온 잔디를 Burning 상태로 변경합니다.
이후 시간에 따라 burnT가 증가하고, 완전히 타면 Burnt 상태로 전환됩니다. 일정 시간이 지나면 Regrowing 상태가 되고, regrowT가 증가하면서 다시 Alive 상태로 돌아옵니다.
잔디의 상호작용은 Compute Shader 내부의 상태 머신으로 처리했습니다.
입력 위치와 반경을 기준으로 잔디를 Burning 상태로 전환하고, burnT와 regrowT 타이머를 갱신해 Burnt, Regrowing, Alive 상태로 순환하도록 구성했습니다.
이 상태값은 렌더 셰이더로 전달되어 잔디의 높이, 디졸브, Fire/Char 표현에 사용됩니다.

Burn / Regrow Visual Shader
상태값 기반 시각 표현
렌더 셰이더에서는 Compute Shader에서 갱신된 상태값을 기반으로 잔디의 시각 상태를 표현했습니다.
주요 표현은 다음과 같습니다.
| 요소 | 설명 |
|---|---|
| burnAmount | 타는 진행도 |
| regrowAmount | 다시 자라는 진행도 |
| height01 | 잔디의 UV.y 기반 높이값 |
| Fire Band | 타는 경계의 주황색 Emissive |
| Char Band | 탄 부분의 검은 그을림 표현 |
| Dissolve | 타거나 사라지는 영역을 clip으로 처리 |
잔디가 불탈 때는 상단/하단 방향으로 디졸브가 진행되도록 처리하고, 경계에는 Fire Band를 넣어 불타는 느낌을 만들었습니다.
완전히 탄 영역은 Char Band로 어둡게 표현하고, Regrow 상태에서는 높이 스케일을 다시 증가시켜 잔디가 자라나는 것처럼 보이게 했습니다.
Compute Shader에서 계산된 burnAmount와 regrowAmount를 렌더 셰이더에서 받아 잔디의 시각 상태를 표현했습니다.
burnAmount는 디졸브 마스크와 Fire/Char 밴드에 사용하고, regrowAmount는 잔디 높이 스케일에 반영하여 불탄 잔디가 다시 자라나는 과정을 시각화했습니다.
알파 블렌딩 대신 AlphaTest 기반 clip을 사용해 잔디 형태를 표현했습니다.
Wind Noise
저비용 바람 흔들림 노이즈
잔디의 바람 흔들림은 Perlin/Simplex Noise 같은 복잡한 절차적 노이즈 대신, 월드 좌표와 인스턴스별 seed를 섞은 좌표 해시 기반 의사 난수로 구현했습니다.
저해상도 노이즈 텍스처를 사용하는 방식도 가능하지만, 이번 R&D에서는 별도 텍스처 의존 없이 함수 기반 바람 변조를 테스트했습니다.
참고\
- 좌표 해시 기반 일라인 난수 기법의 기원/변형 논의
shader - What's the origin of this GLSL rand() one-liner? - Stack Overflow\ - GLSL을 위한 랜덤 / 노이즈 함수
shader - Random / noise functions for GLSL - Stack Overflow
노이즈 R&D
https://gksrudtlr2.tistory.com/335
https://thebookofshaders.com/11/?lan=kr
생성된 랜덤 값에 시간값과 인스턴스별 seed를 섞어 잔디마다 흔들림의 위상과 강도가 다르게 보이도록 했습니다.
또한 UV.y 기반 height01 값을 가중치로 사용해 기저부는 적게 움직이고, 잎 끝부분으로 갈수록 더 크게 흔들리도록 처리했습니다.
URP Render Pass
ForwardLit / ShadowCaster 패스 구성
렌더 셰이더는 URP 환경에 맞춰 ForwardLit 패스와 ShadowCaster 패스를 구성했습니다.
두 패스는 동일한 _Instances, _States, _Visible 버퍼를 참조하도록 구성하여, 화면 렌더링과 그림자 렌더링에서 같은 인스턴스 상태를 사용하도록 했습니다.
잔디 형태는 Alpha Blending이 아니라 AlphaTest 기반 clip으로 처리했습니다. 이를 통해 Transparent Queue 정렬과 알파 블렌딩 비용을 피하고, 대량 잔디 렌더링에서 투명 렌더링 부담을 줄이는 방향으로 구성했습니다.

Result
구현 결과
- Unity 6 URP 환경에서 GPU 기반 인터랙티브 잔디 시스템 구현
- 300,000개 잔디 인스턴스 테스트
- Compute Shader 기반 상태 갱신 구현
- Compute Shader 기반 프러스텀 컬링 구현
- AppendStructuredBuffer + CopyCount + Indirect Args 구조 구현
- DrawMeshInstancedIndirect 기반 GPU Driven Rendering 파이프라인 구현
- RenderDoc으로 ExecuteIndirect 호출 및 instanceCount 변화 검증
- Burn / Burnt / Regrow 상태 기반 상호작용 구현
- 좌표 해시 기반 저비용 Wind Noise 구현
- UV.y 기반 height01 가중치로 잎 끝 흔들림 표현
- URP ForwardLit / ShadowCaster 패스 구성
- AlphaTest 기반 잔디 표현으로 Transparent 정렬 비용 회피
이 프로젝트는 Unity 6 URP 환경에서 Compute Shader와 DrawMeshInstancedIndirect를 활용해 구현한 GPU 기반 인터랙티브 잔디 렌더링 R&D입니다.
대량의 잔디 인스턴스를 CPU에서 개별 제어하지 않고, GPU 상에서 상태 갱신, 프러스텀 컬링, 가시 인스턴스 리스트 생성, Indirect Draw까지 처리하는 구조를 설계했습니다.
사용자의 입력에 따라 잔디가 불타고, 완전히 탄 뒤 일정 시간이 지나면 다시 자라나는 상태 머신을 Compute Shader에서 처리했으며, 렌더 셰이더에서는 Burn / Char / Regrow 상태를 디졸브와 높이 스케일로 시각화했습니다.
바람 흔들림은 Perlin/Simplex 같은 무거운 노이즈 대신 좌표 해시 기반 의사 난수와 인스턴스별 seed를 사용해 저비용으로 구현했고, UV.y 기반 height01 가중치를 적용해 잎 끝부분이 더 크게 흔들리도록 처리했습니다.
RenderDoc을 통해 ExecuteIndirect 호출과 instanceCount 변화를 확인하여, Compute 기반 컬링과 DrawMeshInstancedIndirect 파이프라인이 정상 동작하는 것을 검증했습니다.