Compute Shader Study 1 - Niagara로 Compute Shader에 접근해보기
본 내용은 추후 수정될 수 있음.
나는 Shader 위주의 업무, 그리고 요 근래는 툴 제작만 하던 사람이라 Niagara, Compute Shader 이해도가 높은 사람은 아니지만 "Niagara로도 Compute Shader를 쓸 수 있다"는 정보를 접했을 때 그 말이 꽤 솔깃했다. Niagara는 파티클/VFX 툴이라고만 생각하고 있었는데, Compute Shader까지 사용하면 어떤 결과물을 만들어서 게임에 적용할 수 있을지에 대한 호기심이 강하게 들었다.
그런데 막상 접근하려고 보니 바로 Compute Shader부터 볼 수 있는 문제가 아니었다. 먼저 Niagara가 어떤 구조로 돌아가는지 파악해야 했다. 그 다음에야 Niagara 안에서 말하는 GPU Sim, GPUCompute Sim, Custom HLSL, Grid2D, Render Target, Readback 같은 단어들이 조금씩 연결되기 시작했다.
그래서 이 글은 Niagara부터 시작한다. Niagara System이 무엇인지, Emitter와 Module이 어떤 관계인지, Attribute가 어떤 데이터인지 먼저 훑어본다. 그 다음 CPU Sim과 GPU Sim의 차이를 보고, 마지막으로 Compute Shader적인 사고방식이 Niagara의 GPU 경로와 어떻게 맞닿는지 정리해보려고 한다.
목표는 처음부터 HLSL이나 렌더링 파이프라인을 깊게 파는 것이 아니다. Niagara를 출발점으로 삼아서, "아, 이런 이유로 GPU 계산 이야기가 나오는구나" 정도의 감각을 먼저 잡는 것이다.
왜 Niagara에서 Compute Shader를 이야기하는가
먼저 큰 그림부터 잡아보자.
Compute Shader는 GPU에게 계산 일을 시키는 작은 프로그램이고, Niagara System은 언리얼에서 파티클/VFX를 만드는 설계도다.
Niagara는 설정에 따라 파티클 계산을 CPU에서 실행할 수도 있고 GPU에서 실행할 수도 있다. 이때 GPU에서 실행되는 Niagara Simulation은 내부적으로 GPU용 HLSL과 Compute Shader 경로에 가까운 방식으로 실행된다.
다만 여기서 중요한 구분이 있다.
Niagara System 전체가 그대로 Compute Shader가 되는 것은 아니다. 정확히는 Niagara System 안의 GPU Simulation 대상 계산이 Niagara Compiler를 거쳐 GPU에서 실행 가능한 코드로 만들어진다.
UE 5.8 엔진 소스에서도 Niagara의 시뮬레이션 대상은 CPUSim과 GPUComputeSim으로 나뉜다. GPU 경로에서는 ParticleGPUComputeScript와 HLSL 생성/컴파일 흐름이 확인된다.
정리하면 Niagara System 안의 Emitter, Module, Graph가 Niagara Compiler를 거치고, 결과적으로 CPU VM에서 실행되거나 GPU용 HLSL 경로로 넘어간다고 볼 수 있다.
그래서 Niagara에서 Compute Shader를 이야기한다는 것은 꼭 "직접 Compute Shader 파일을 작성한다"는 뜻만은 아니었다. 내가 Niagara에서 만든 Graph가 GPU에서 대량 병렬 계산으로 실행될 때, 그 계산을 어떤 식으로 바라봐야 하는지 이해하는 쪽에 더 가까웠다.
Compute Shader란 무엇인가
보통 shader라고 하면 화면에 무언가를 그리는 코드를 떠올리기 쉽다.
Vertex Shader는 점 위치를 계산하고, Pixel Shader는 화면 픽셀의 색을 계산한다. Compute Shader는 이 둘과 달리 GPU로 일반 계산을 하기 위한 셰이더에 가깝다.
Compute Shader는 꼭 화면에 바로 무언가를 그리지 않아도 된다. GPU에게 일반적인 병렬 계산을 시킬 수 있다.
예를 들면 이런 작업이다.
- 배열 100만 개 업데이트하기
- 이미지 픽셀 전체를 흐리게 만들기
- 물결 시뮬레이션 계산하기
- 파티클 위치 50만 개 갱신하기
- 2D 그리드 셀 값을 매 프레임 계산하기
CPU는 복잡한 판단과 순차 처리에 강하고, GPU는 비슷한 계산을 아주 많이 동시에 처리하는 데 강하다.
파티클 100만 개가 있다고 해보자. CPU 관점에서는 개념적으로 Particle 0의 위치를 계산하고, Particle 1의 위치를 계산하고, 이런 식으로 많은 데이터를 차례대로 처리하는 모델에 가깝다.
실제 엔진 내부에서는 멀티스레드 처리, 작업 배치, 캐시 최적화가 섞인다. 그래도 큰 그림은 같다. CPU는 개별 데이터를 질서 있게 처리하고, GPU는 비슷한 계산을 대량으로 병렬 처리한다.
Compute Shader를 배울 때 처음 잡아야 할 감각은 이것이다.
스레드 하나가 데이터 하나를 맡고, 같은 계산을 아주 많이 동시에 실행한다.
예를 들어 256x256 물결 시뮬레이션이면 셀이 65,536개다. Compute Shader 관점에서는 각 셀을 GPU 스레드들이 나눠서 계산한다고 볼 수 있다.
HLSL에서는 이런 식의 형태가 나온다.
[numthreads(8, 8, 1)]
void Main(uint3 id : SV_DispatchThreadID)
{
uint2 cell = id.xy;
}numthreads(8, 8, 1)은 한 그룹에 64개 스레드가 있다는 뜻이고, SV_DispatchThreadID는 현재 스레드가 전체 작업 중 어느 좌표를 담당하는지 알려준다.
처음부터 모든 세부를 외울 필요는 없다. 지금은 "GPU가 많은 데이터 조각에 같은 계산을 나눠서 실행한다" 정도로 이해하면 충분하다.
Niagara System은 무엇인가
Niagara System은 언리얼에서 VFX를 만드는 컨테이너다.
불꽃, 연기, 마법, 먼지, 물보라, 에너지장 같은 효과를 만들 때 Niagara System을 만든다.
기본 구조는 Niagara System 안에 하나 이상의 Emitter가 있고, 각 Emitter가 자기 파티클 흐름과 Renderer를 가진다고 보면 된다.
다만 여기서 주의할 점이 있다. Niagara 에디터에서 보이는 Spawn과 Update는 하나의 의미가 아니라, 실행 대상에 따라 System, Emitter, Particle 단계로 나뉜다.
UE 5.8 소스 기준으로 스크립트 사용처는 System Spawn, System Update, Emitter Spawn, Emitter Update, Particle Spawn, Particle Update처럼 분리되어 있다. 에디터 스택도 이 이름으로 그룹을 만든다.
구조를 조금 더 정확히 쓰면 다음과 같다.
Niagara System
System Spawn
System Update
Emitter
Emitter Spawn
Emitter Update
Particle Spawn
Particle Update
Simulation Stage, optional
Render각 단계의 의미는 다르다.
- Niagara System은 하나의 완성된 이펙트다.
- Emitter는 그 이펙트를 구성하는 파티클 발생기다.
- System Spawn은 System이 생성될 때 한 번 실행되는 초기화 단계다.
- System Update는 System 단위로 매 tick 실행되는 단계다.
- Emitter Spawn은 Emitter가 생성될 때 한 번 실행되는 초기화 단계다.
- Emitter Update는 Emitter 단위로 매 tick 실행되는 단계다.
- Particle Spawn은 새로 만들어진 particle마다 한 번 실행되는 초기값 설정 단계다.
- Particle Update는 살아있는 particle마다 매 프레임 실행되는 갱신 단계다.
- Render는 계산 스크립트라기보다 particle attribute를 화면에 어떻게 표시할지 정하는 Renderer 설정 묶음이다.
그래서 단순히 "Spawn은 파티클이 태어날 때 하는 계산"이라고만 말하면 조금 애매하다. Particle Spawn은 그 설명이 맞지만, Emitter Spawn은 파티클 하나가 아니라 Emitter instance 자체의 초기화에 가깝다.
마찬가지로 Update도 대상이 다르다. Emitter Update는 Emitter 하나에 대해 한 번 실행되며 이번 프레임의 spawn 수나 emitter 단위 파라미터를 준비하는 쪽이고, Particle Update는 각 particle의 위치, 색, 속도 같은 값을 갱신하는 쪽이다.
예를 들어 Fire Explosion이라는 Niagara System 안에 불꽃 입자 Emitter, 연기 Emitter, 불똥 Emitter, 충격파 링 Emitter가 함께 들어갈 수 있다.
System은 완성된 효과이고, Emitter는 그 효과를 구성하는 파티클 흐름이다.
Emitter, Module, Attribute의 관계
Niagara를 코드 관점으로 이해하려면 Attribute, Parameter, Module, Graph를 나눠서 보는 것이 좋다.
가장 중요한 구분은 이것이다.
Attribute는 데이터이고, Module은 그 데이터를 읽고 바꾸는 로직이다. Graph는 모듈들이 실행되는 흐름이다.
파티클 하나는 대표적으로 Particles.Position, Particles.Velocity, Particles.Color, Particles.Age, Particles.SpriteSize 같은 값을 가질 수 있다.
이런 것들이 Particle Attribute다. 코드로 비유하면 파티클이 들고 있는 변수에 가깝다.
반면 Module은 그 데이터를 바꾸는 계산 블록이다.
예를 들어 Niagara Stack에서 자주 보는 이런 것들이 Module이다.
- Initialize Particle
- Add Velocity
- Solve Forces and Velocity
- Scale Color
- Scale Sprite Size
- Kill Particles
모듈 하나는 보통 다음 일을 한다.
입력값을 읽고, 계산하고, 결과를 Particle Attribute에 쓴다.
예를 들어 Scale Color 모듈은 파티클의 나이나 Normalized Age를 읽고, 시간에 따른 색/투명도를 계산한 뒤 Particles.Color에 쓴다고 볼 수 있다.
Niagara에서 이런 계산을 만든다고 해보자.
Particles.Position += Particles.Velocity * Engine.DeltaTime
여기서 각 요소의 역할은 이렇다.
- Particles.Position은 Attribute다.
- Particles.Velocity도 Attribute다.
- Engine.DeltaTime은 엔진이 제공하는 값 또는 Parameter에 가깝다.
- 이 계산이 들어있는 곳이 Module이다.
- 전체 실행 순서가 Graph 또는 Stack이다.
그래서 Niagara를 처음 배울 때는 "Module이 뭔가?"보다 먼저 "이 Module이 어떤 Attribute를 읽고, 어떤 Attribute에 쓰는가?"를 보는 편이 좋다.
Niagara는 어떻게 CPU/GPU 경로로 실행되는가
Niagara의 파티클 계산은 CPU에서 실행될 수도 있고 GPU에서 실행될 수도 있다.
UE 5.8 기준 엔진 코드에서는 Sim Target이 CPUSim과 GPUComputeSim으로 나뉜다. CPUSim은 CPU 쪽 실행 경로이고, GPUComputeSim은 GPU compute 실행 경로다.
CPU 경로에서는 Niagara Graph의 계산이 CPU에서 실행되고, 파티클 위치나 색, 크기 같은 데이터가 업데이트된 뒤, 최종적으로 GPU가 화면에 렌더링한다.
반대로 GPU 경로에서는 Niagara Graph가 GPU용 코드로 변환되고, GPU에서 병렬로 실행된다. 파티클 위치나 색, 크기 같은 값도 GPU 안에서 갱신되고, 그 결과를 GPU가 바로 렌더링에 사용한다.
GPU Path에서는 CPU가 모든 파티클을 하나하나 계산하지 않는다. GPU가 파티클 데이터를 들고 있고, GPU 안에서 직접 업데이트한다.
즉 CPU는 시작, 정지, 파라미터 전달 같은 관리 역할에 가깝고, GPU는 대량 파티클 계산과 렌더링을 맡는 쪽에 가깝다.
이 구분이 중요한 이유는 간단하다. CPU와 GPU는 잘하는 일이 다르고, Niagara에서 선택한 Sim Target에 따라 쓸 수 있는 기능과 디버깅 방식이 달라진다.
CPU Sim과 GPU Sim의 차이
CPU Sim의 장점은 게임 로직과 연결하기 쉽다는 점이다.
CPU에서 파티클 계산이 돌기 때문에 Blueprint, Actor, UObject, 이벤트, 충돌 콜백 같은 게임플레이 요소와 이어 붙이기 상대적으로 좋다.
CPU Sim에 어울리는 예는 이런 것이다.
- 캐릭터 주변에 떠다니는 작은 마법 입자 200개
- 총알 궤적 50개
- 적에게 닿으면 데미지를 주는 특수 파티클
- 충돌 지점마다 사운드나 이벤트를 발생시키는 효과
대신 파티클 수가 매우 많아지면 CPU Sim은 부담이 커진다.
GPU Sim은 반대 성격을 가진다. 게임 로직과 직접 연결하기는 어렵지만, 많은 파티클이나 그리드 계산에는 강하다.
GPU Sim에 어울리는 예는 이런 것이다.
- 수십만 개 먼지 입자
- 대량의 불꽃
- GPU 물결
- Grid2D 기반 워터 인터랙션
- Render Target에 시뮬레이션 결과 쓰기
- Niagara Fluids 계열 효과
하나의 Niagara System 안에 CPU Emitter와 GPU Emitter를 같이 둘 수도 있다.
예를 들어 하나의 System 안에 CPU Sim Emitter 하나와 GPUCompute Sim Emitter 두 개를 함께 둘 수 있다.
예를 들어 폭발 효과라면 CPU Emitter는 핵심 충돌 이벤트를 만들고, GPU Emitter는 대량의 불꽃과 먼지를 맡는 식으로 나눌 수 있다.
다만 하나의 일반 Emitter 안에서 같은 Particle Update가 CPU와 GPU 양쪽으로 동시에 도는 식으로 생각하면 곤란하다. 보통 Emitter 하나는 하나의 Sim Target을 가진다고 보는 편이 안전하다.
정리하면 다음과 같다.
- System 안에서 CPU/GPU Emitter는 공존할 수 있다.
- Emitter 하나는 보통 CPU 또는 GPUCompute 중 하나의 Sim Target을 가진다.
- GPU Emitter라도 관리와 파라미터 전달에는 CPU가 관여한다.
- 대량 파티클 계산은 GPU가 담당한다.
GPUCompute Sim에서 가능한 것과 어려운 것
GPUCompute Sim의 장점은 대량 병렬 계산이다. 하지만 그 장점 때문에 생기는 제한도 있다.
한 문장으로 말하면 이렇다.
GPU 파티클은 게임 월드의 모든 정보를 자유롭게 만질 수 없다.
CPU 파티클은 CPU에서 돌기 때문에 게임 로직과 연결하기 쉽다. GPU 파티클은 GPU에서 대량 병렬 계산을 하므로, 개별 파티클마다 Blueprint 함수를 호출하거나 UObject를 자유롭게 읽는 식의 흐름이 어렵다.
대표적인 제한은 다음과 같다.
- Blueprint 함수를 파티클마다 직접 호출하기 어렵다
- Actor/UObject 정보를 파티클마다 자유롭게 읽기 어렵다
- 파티클마다 게임플레이 이벤트를 발생시키기 어렵다
- CPU 충돌 이벤트처럼 정밀한 콜백을 받기 어렵다
- CPU로 파티클 데이터를 즉시 가져오기 어렵다
- GPU 지원이 없는 Niagara Module/Data Interface는 사용할 수 없다
- 디버깅 중 파티클 하나의 값을 추적하기 어렵다
여기서 특히 중요한 주제가 GPU Readback이다.
GPU에서 계산한 데이터를 CPU로 가져오는 것을 Readback이라고 한다. 이것이 비싼 이유는 단순히 데이터 복사량 때문만은 아니다. 더 큰 문제는 기다림이다.
GPU는 보통 CPU보다 몇 프레임 뒤에서 자기 일을 계속 처리하고 있다.
CPU는 다음 프레임을 준비하고 있는데, GPU는 아직 이전 프레임의 렌더링이나 시뮬레이션을 처리하고 있을 수 있다.
그런데 CPU가 갑자기 "방금 계산한 파티클 위치를 지금 당장 줘"라고 요청하면 GPU는 하던 일을 마치고 데이터를 준비해야 한다. CPU는 그동안 기다릴 수 있고, 이것이 프레임 hitch나 stall로 이어질 수 있다.
감각적으로는 이렇게 보면 된다.
- 파티클 1개의 값 몇 개를 읽는 정도는 구조를 잘 짜면 가능할 수 있다.
- 매 프레임 수천~수만 개를 읽는 것은 조심해야 한다.
- 매 프레임 수십만~수백만 파티클 위치를 읽는 것은 보통 피하는 설계다.
그래서 GPU Niagara에서는 보통 GPU에서 계산한 것은 GPU 안에서 소비하는 식으로 설계한다.
예를 들어 Grid2D 계산 결과를 Render Target에 쓰고, Material이 그 결과를 읽어서 화면에 표현하는 식이다.
반대로 이런 흐름은 피하는 편이 좋다.
GPU particle 위치를 CPU로 전부 읽어온 뒤 Blueprint에서 하나하나 처리하는 방식은 GPU Sim의 장점을 잃기 쉽다.
GPU Collision도 비슷하게 이해해야 한다. GPU Path에서도 충돌은 가능하지만, CPU Collision처럼 게임 로직과 촘촘하게 연결된 충돌 이벤트로 생각하면 안 된다. 보통은 시각 효과용 충돌 근사에 가깝다.
UE 5.8 Niagara 콘텐츠와 소스 기준으로는 Scene Depth, Distance Field, Ray Trace, Async GPU Trace 계열의 충돌/추적 모듈과 리소스 요구가 확인된다.
대략적인 성격은 이렇다.
- Depth Buffer는 화면에 보이는 깊이 정보를 이용한다. 비교적 싸지만 화면 밖이나 가려진 물체에는 약하다.
- Distance Field는 월드의 거리장 정보를 이용한다. 더 넓게 처리할 수 있지만 프로젝트 설정과 메시 지원의 영향을 받는다.
- GPU Ray Tracing이나 Async GPU Trace는 더 정확한 추적이 가능할 수 있지만, 하드웨어와 프로젝트 설정, 성능 영향을 크게 받는다.
디버깅도 CPU Sim보다 어렵다. GPU 안에서 대량 병렬로 계산되기 때문에 특정 파티클 하나를 print하듯 따라가기 어렵다.
GPU Simulation을 디버깅할 때는 보통 이런 방식으로 접근한다.
- 값을 색으로 시각화한다
- Render Target에 중간 결과를 출력한다
- 작은 파티클 수나 낮은 해상도에서 먼저 본다
- 단계를 나눠서 하나씩 켠다
- 필요한 경우에만 GPU particle readback을 사용한다
요약
지금 단계에서 기억할 것은 많지 않다.
- Compute Shader는 GPU에게 "그리기 말고 계산"을 시키는 프로그램이다.
- Niagara System은 언리얼에서 VFX를 만드는 컨테이너다.
- Module은 파티클 속성을 읽고 계산해서 다시 쓰는 계산 블록이다.
- CPU Path는 파티클 계산과 게임 로직 연동에 좋다.
- GPU Path는 파티클/그리드의 대량 병렬 처리에 좋다.
- Niagara GPU Simulation은 Niagara 그래프가 GPU용 코드로 바뀌어 실행되는 방식이다.
가장 중요한 감각은 이것이다.
CPU는 관리자에 가깝고, GPU는 대량 계산 처리기에 가깝다. Niagara는 이 둘 중 어디서 계산할지 정하고 VFX를 구성하는 시스템이며, Compute Shader는 GPU에게 나눠주는 계산 지시서에 가깝다.
레퍼런스
공식 문서
- Unreal Engine Niagara VFX 문서: https://dev.epicgames.com/documentation/unreal-engine/creating-visual-effects-in-niagara-for-unreal-engine
- Unreal Engine Niagara Getting Started: https://dev.epicgames.com/documentation/en-us/unreal-engine/getting-started-in-niagara-effects-for-unreal-engine
- Niagara System and Emitter Module Reference: https://dev.epicgames.com/documentation/en-us/unreal-engine/system-and-emitter-module-reference-for-niagara-effects-in-unreal-engine
- Niagara GPU Sprite Effect: https://dev.epicgames.com/documentation/en-us/unreal-engine/how-to-create-a-gpu-sprite-effect-in-niagara-for-unreal-engine
- Niagara Debugger: https://dev.epicgames.com/documentation/en-us/unreal-engine/niagara-debugger-for-unreal-engine
- Niagara Fluids: https://dev.epicgames.com/documentation/en-us/unreal-engine/niagara-fluids-in-unreal-engine
- Microsoft Compute Shader Overview: https://learn.microsoft.com/en-us/windows/win32/direct3d11/direct3d-11-advanced-stages-compute-shader
UE 5.8 로컬 확인 경로
- UE5.8\Engine\Plugins\FX\Niagara\Source\Niagara\Public\NiagaraCommon.h
- UE5.8\Engine\Plugins\FX\Niagara\Source\Niagara\Private\NiagaraScript.cpp
- UE5.8\Engine\Plugins\FX\Niagara\Source\Niagara\Public\NiagaraGpuReadbackManager.h
- UE5.8\Engine\Plugins\FX\Niagara\Source\NiagaraEditor\Private\Widgets\SNiagaraDebugger.cpp
- UE5.8\Engine\Plugins\FX\Niagara\Content\Modules\Spawn\Location\SpawnParticlesInGrid.uasset
- UE5.8\Engine\Plugins\FX\Niagara\Content\Modules\Spawn\Location\GridLocation.uasset
- UE5.8\Engine\Plugins\FX\Niagara\Content\Modules\Update\Utility\AsyncGPUTrace.uasset
- UE5.8\Engine\Plugins\FX\Niagara\Content\Modules\Collision\SceneDepthTest.uasset
- UE5.8\Engine\Plugins\FX\Niagara\Content\Modules\Collision\RayTrace.uasset
- UE5.8\Engine\Plugins\FX\Niagara\Content\Modules\Collision\NiagaraDistanceFieldCollisions.uasset