컴퓨트 셰이더를 이용한 수천 개 오브젝트의 물리 시뮬레이션
GPU의 병렬 연산 능력을 극대화하여 CPU의 한계를 넘어서는 수만 개의 파티클 및 강체 물리 시뮬레이션을 구현하는 기술적 접근법을 다룹니다.
GPU의 강력한 병렬 연산력을 활용한 물리 시뮬레이션
현대 게임 엔진에서 물리 시뮬레이션은 핵심적인 요소입니다. 하지만 전통적인 물리 엔진 방식은 CPU의 메인 스레드나 멀티 스레드 성능에 크게 의존합니다. CPU는 복잡한 로직 처리에 특화되어 있지만, 수천에서 수만 개에 달하는 독립적인 데이터(파티클 위치, 속도 등)를 동시에 처리하기에는 코어 수의 한계가 명확합니다. 여기서 등장하는 것이 바로 GPGPU(General-Purpose computing on Graphics Processing Units) 기술이며, 그 중심에는 **컴퓨트 셰이더(Compute Shader)**가 있습니다.
컴퓨트 셰이더는 그래픽 파이프라인의 고정된 단계를 거치지 않고, GPU를 거대한 수학 연산 장치로 활용할 수 있게 해줍니다. GPU는 수천 개의 작은 코어로 이루어져 있어, 각 코어가 하나의 파티클 연산을 담당하게 함으로써 전체 시뮬레이션 속도를 수십 배에서 수백 배까지 끌어올릴 수 있습니다. 이는 단순히 숫자가 많아지는 것을 넘어, 이전에는 불가능했던 복잡한 유체 역학이나 대규모 군중 시뮬레이션을 실시간 프레임 내에서 가능하게 합니다.
Kernel, Buffer, 그리고 Dispatch의 이해
컴퓨트 셰이더를 작성할 때는 일반적인 렌더링 셰이더나 C# 스크립트와는 다른 프로그래밍 모델을 이해해야 합니다. 핵심 개념은 **Kernel**, **StructuredBuffer**, 그리고 **Dispatch**입니다.
- Kernel: GPU에서 실행되는 최소 단위의 함수입니다. 하나의 컴퓨트 셰이더 파일 내에 여러 개의 커널을 정의할 수 있습니다.
- StructuredBuffer: CPU와 GPU 간에 데이터를 주고받는 통로입니다. 구조체(struct) 형태의 데이터를 대량으로 담을 수 있으며, 읽기 전용(ReadOnly) 혹은 읽기/쓰기 가능(RW) 버퍼로 구분됩니다.
- Dispatch: CPU(C#) 쪽에서 GPU에게 연산 시작을 알리는 명령입니다. 스레드 그룹의 개수를 지정하여 실행 범위를 결정합니다.
아래는 유니티(Unity) 환경에서 HLSL로 작성된 기본적인 파티클 업데이트 컴퓨트 셰이더 예제입니다.
// Particle.compute
#pragma kernel UpdateParticles
struct Particle {
float3 position;
float3 velocity;
};
RWStructuredBuffer<Particle> particleBuffer;
float deltaTime;
[numthreads(64, 1, 1)]
void UpdateParticles (uint3 id : SV_DispatchThreadID) {
// 버퍼의 인덱스 범위를 벗어나지 않는지 확인
uint index = id.x;
// 물리 연산: 위치 = 위치 + 속도 * 시간
particleBuffer[index].position += particleBuffer[index].velocity * deltaTime;
// 바닥 충돌 처리 (단순 예제)
if (particleBuffer[index].position.y < 0) {
particleBuffer[index].position.y = 0;
particleBuffer[index].velocity.y *= -0.5f; // 반발 계수 적용
}
}
이 셰이더를 실행하기 위한 C# 코드의 구성은 다음과 같습니다.
// ParticleManager.cs
public class ParticleManager : MonoBehaviour {
public ComputeShader particleShader;
ComputeBuffer buffer;
int kernelHandle;
void Start() {
kernelHandle = particleShader.FindKernel("UpdateParticles");
// 파티클 10,000개 생성 및 버퍼 할당
buffer = new ComputeBuffer(10000, sizeof(float) * 6);
particleShader.SetBuffer(kernelHandle, "particleBuffer", buffer);
}
void Update() {
particleShader.SetFloat("deltaTime", Time.deltaTime);
// 10,000 / 64 = 157개의 그룹을 디스패치
particleShader.Dispatch(kernelHandle, Mathf.CeilToInt(10000 / 64f), 1, 1);
}
void OnDestroy() {
buffer.Release();
}
}
수만 개의 오브젝트를 위한 격자 기반 충돌 검사 (Spatial Partitioning)
단순한 이동 연산은 O(N)의 복잡도를 가지므로 수만 개라도 매우 빠릅니다. 하지만 진짜 난관은 **충돌 검사**입니다. 모든 파티클을 서로 비교하면 O(N^2)이 되어, 파티클이 1만 개만 되어도 매 프레임 1억 번의 연산이 필요합니다. 이는 GPU라 할지라도 감당하기 어려운 수치입니다.
이를 해결하기 위해 **공간 분할(Spatial Partitioning)** 기법을 사용합니다. 전체 공간을 일정한 크기의 격자(Grid)로 나누고, 각 파티클이 어느 격자에 속해 있는지를 먼저 계산합니다. 충돌 검사 시에는 자신이 속한 격자와 주변 26개(3D 기준) 격자에 있는 파티클들과만 비교하면 됩니다. GPU에서 이 과정을 수행하기 위해 'Bitonic Sort'와 같은 병렬 정렬 알고리즘을 사용하거나, 'Atomic Operations'를 활용하여 동적으로 격자 리스트를 구축합니다.
성능 최적화의 핵심: 데이터 전송 최소화
컴퓨트 셰이더의 가장 큰 적은 **CPU와 GPU 간의 데이터 전송(Readback)**입니다. GPU에서 연산한 결과를 화면에 그리기 위해 다시 CPU로 가져오는 과정은 매우 느립니다. 진정한 최적화는 연산 결과 버퍼를 그대로 **Graphics Shader(Vertex/Fragment Shader)**의 입력으로 전달하여, 데이터가 GPU 메모리 밖으로 나가지 않게 하는 것입니다.
Unity의 `Graphics.DrawMeshInstancedIndirect` 함수를 사용하면 CPU 쪽에서는 인스턴스 개수만 알려주고, 실제 위치 데이터는 GPU 버퍼에서 직접 읽어와 렌더링할 수 있습니다. 이를 통해 수백만 개의 파티클을 부드럽게 렌더링하는 진정한 GPU 시뮬레이션을 완성할 수 있습니다.
시각적 풍요로움과 기술적 성취의 결합
컴퓨트 셰이더 기반 물리는 단순히 성능만 좋은 것이 아니라, 비주얼적으로도 엄청난 임팩트를 줍니다. 물의 흐름을 표현하는 SPH(Smoothed Particle Hydrodynamics) 시뮬레이션이나, 바람에 흩날리는 수백만 개의 모래 입자 등은 기존 방식으로는 불가능했던 표현들입니다. 이러한 기술을 실제 게임에 적용하기 위해서는 타겟 하드웨어의 GPU 사양을 고려한 스케일링 전략이 필요하며, 이는 기술적 한계에 도전하는 개발자에게 매우 가치 있는 경험이 될 것입니다.
결론적으로, 컴퓨트 셰이더는 현대 게임 개발자가 반드시 익혀야 할 강력한 무기입니다. 연산의 패러다임을 바꿈으로써 우리는 유저들에게 더 생동감 있고 거대한 세계를 선사할 수 있게 되었습니다.