파티클 시스템(Particle System)
파티클 시스템(Particle System)은 3D 컴퓨터 그래픽에서 특정 불명확한 현상을 시뮬레이션하는 기술로, 이러한 현상들은 다른 전통적인 렌더링 기술로는 물리적 움직임의 사실감을 구현하기 어렵습니다. 파티클 시스템은 주로 불, 폭발, 연기, 물줄기, 불꽃, 낙엽, 구름, 안개, 눈, 먼지, 유성의 꼬리나 빛의 궤적과 같은 추상적 시각 효과 등을 시뮬레이션하는 데 자주 사용됩니다.
원리
파티클 시스템은 주로 대량의 움직이는 물체를 렌더링하는 데 적합합니다. 그 핵심 작업 흐름은 복잡하지 않습니다. 그래픽 API에 익숙한 분들은 알고 계시겠지만, GPU로 물체를 그릴 때는 DrawCall을 호출해야 합니다. 파티클 시스템에는 매우 많은 작은 파티클이 존재하기 때문에 일반적인 방법은 다음과 같을 수 있습니다:
for(int i = 0; i < particles.num(); i++){ drawParticle(particles[i]); }
이 방법으로도 효과를 얻을 수 있지만, 대량의 DrawCall 호출과 파티클 간의 많은 중복 데이터로 인해 파티클 시스템의 렌더링 비용이 매우 높아지게 되어 렌더링할 수 있는 파티클 수가 매우 제한적입니다.
파티클 데이터를 최대한 재사용하고 DrawCall을 줄이기 위해 많은 분들이 생각할 수 있는 방법은 바로 **인스턴싱(Instancing)**입니다.
인스턴싱의 개념과 사용에 대한 자세한 내용은 다음을 참조하세요:
인스턴싱을 통해 파티클 시스템의 DrawCall을 병합하고 파티클 간의 동일한 데이터를 재사용할 수 있습니다.
이러한 개념을 이해하면 파티클 시스템이 단지 인스턴싱 렌더링을 사용하는 작업 흐름임을 알 수 있습니다. 이 관점에서 파티클 효과를 만드는 방법을 생각해볼 때 우리가 고려해야 할 점은 다음과 같습니다:
- 파티클 시스템에 필요한 인스턴싱 데이터(Instance Data)를 어떻게 구축할 것인가.
- 인스턴싱 데이터를 어떻게 활용하여 파티클 효과를 렌더링할 것인가.
이 두 가지 관점에서 출발하면 파티클 시스템의 작업 파이프라인을 쉽게 구축할 수 있습니다. 아래에서는 주로 아키텍처 측면에서 파티클 시스템의 세부 사항에 대해 설명하겠습니다:
간단한 파티클의 경우, 다음과 같은 데이터 구조를 사용할 수 있습니다:
struct Particle{ //파티클의 속성 구조
vec3 position;
vec3 rotation;
float size;
float age;
//...
}
대부분의 파티클 시스템 아키텍처에서는 파티클 이미터(Particle Emitter)라는 구조체를 사용합니다. 이는 데이터를 저장하는 파티클 풀(Particle Pool)을 유지하고, 파티클의 생성(Spawn), 업데이트(Update) 및 회수(Recycle)를 담당하며, 파티클 렌더러(Particle Renderer)를 사용하여 파티클 풀의 데이터를 렌더링하여 파티클 효과를 얻습니다. 이 과정은 다음과 같이 볼 수 있습니다:
class ParticleEmitter{
protected:
void Tick() override{
Spawn(); //생성 단계: 새 파티클 생성
UpdateAndRecycle(); //업데이트 단계: 각 파티클의 상태 데이터 업데이트 및 죽은 파티클 회수
Render(); //렌더링 단계: 파티클 렌더러를 사용하여 파티클 효과 렌더링
}
private:
vector<Particle> mParticlePool;
ParticleRenderer mParticleRenderer;
};
파티클 풀
컴퓨터의 메모리 자원은 한정되어 있기 때문에 파티클 시스템은 파티클 풀의 메모리 사용량이 프로그램 실행 중에 계속 증가하지 않도록 보장해야 합니다. 이를 위해 파티클 시스템은 주로 다음과 같은 방법을 사용합니다:
- 파티클의 방출 수량을 제한합니다.
- 파티클에 수명 주기를 설정하여 수명이 다한 파티클을 적시에 회수합니다.
이는 파티클 풀의 메모리 최적화 기반을 제공합니다:
- 파티클의 방출 수량과 수명 주기를 알고 있고, 현재 컴퓨터의 최대 프레임 수를 결합하면, 해당 이미터의 최대 파티클 수량을 쉽게 추정할 수 있습니다. 이 수량의 메모리는 보통 처음부터 파티클 풀에 할당되어, 파티클 풀의 메모리 재할당으로 인한 오버헤드를 방지합니다.
- 파티클 풀이 병렬로 회수될 수 있도록(배열에서 요소를 제거할 때 뒤에 있는 요소를 이전 위치로 이동시키는 것을 피하기 위해), 보통 동일한 크기의 두 개의 파티클 풀을 번갈아 가며 반복합니다.
파티클을 업데이트하고 회수하려면 다음과 같은 코드를 작성할 수 있습니다:
vector<Particle> ParticlePool;
ParticlePool.reserve(10000); //10000개의 파티클 데이터를 저장할 수 있는 메모리 미리 할당
int index = -1;
for(int i = 0; i < ParticlePool.size(); i++){
if(ParticlePool[i].isAlive()){
index++;
ParticlePool[index] = ParticlePool[i];
ParticlePool[index].position = ...;
ParticlePool[index].size = ...;
...;
}
}
ParticlePool.reseize(index);
이 로직의 복잡도는 O(n)입니다. 더 최적화하려면 for 루프를 병렬로 처리할 수 있지만, 위의 코드 구조로는 불가능합니다. 그 이유는:
- 내부 루프 로직이 외부 공유 변수 index를 읽고 쓰기 때문입니다.
- ParticlePool의 다른 영역을 동시에 읽고 쓰면 병렬 처리 시 실행 순서가 뒤섞여 데이터가 혼란스러워질 수 있습니다.
이 문제를 해결하기 위해 일반적으로 두 개의 ParticlePool을 번갈아 처리하고, 로컬 스레드에서 원자 연산을 통해 index를 읽고 쓰게 됩니다. 따라서 ParticleEmitter의 구조 코드는 다음과 같이 변경될 수 있습니다:
class ParticleEmitter{
protected:
void InitPool(){
mParticlePool[0].reserve(...);
mParticlePool[1].reserve(...);
mCurrentPoolIndex = 0;
mNextPoolIndex = 1;
mCurrentNumOfParticle = 0;
}
void Tick() override{
Spawn();
UpdateAndRecycle();
Render();
}
private:
vector<Particle> mParticlePool[2];
int mCurrentPoolIndex;
int mNextPoolIndex;
int mCurrentNumOfParticle;
ParticleRenderer mParticleRenderer;
};
생성 단계
파티클의 생성 단계는 주로 새로운 파티클을 생성하여 파티클 풀에 저장하고 초기화하는 과정입니다. 이 과정은 다음과 같이 볼 수 있습니다:
void Spwan(){
SpwanPerFrame(mParticlePool[mCurrentPoolIndex],mCurrentNumOfParticle,100); //프레임당 100개의 파티클 생성
}
// 방출 메커니즘 - 사용자 정의 가능
void SpwanPerFrame(vector<Particle>& ParticlePool,int& NumOfParticle, int NumOfNewParticle){
for(int i = 0; i < NumOfNewParticle ; i++ ){ //NumOfNewParticle은 새로 추가되는 파티클의 수
Particle& NewParticle = ParticlePool[NumOfParticle];
NumOfParticle++;
InitializeParticle(NewParticle);
}
}
// 초기화 메커니즘 - 사용자 정의 가능
void InitializeParticle(Particle& particle){
particle.position = vec3(0.0f,0.0f,0.0f);
//particle...
}
여기서 주의할 점은 Spawn 함수가 매 프레임 호출된다는 것입니다. 이미터는 '프레임당' 고정 수량을 방출하는 메커니즘 외에도 '초당' "고정" 수량을 방출하는 메커니즘도 있을 수 있습니다. 그러나 서로 다른 시스템 환경에서 게임의 초당 프레임 수는 예측할 수 없으므로, 이런 종류의 시간 간격에 따라 방출 수량을 결정하는 메커니즘에서는 파티클 수량을 정확하게 제어하기가 매우 어렵습니다.
업데이트 및 회수 단계
이 단계의 의사 코드는 다음과 같이 볼 수 있습니다:
void UpdateAndRecycle(){
int IndexOfNextBuffer = -1; //초기 인덱스
for_each_thread(int i = 0 ; i < mCurrentNumOfParticle ; i++){ //병렬 for문
const Particle& CurrentParticle = mParticlePool[mCurrentPoolIndex][i];
if(CurrentParticle.isActive()){
int CurrentIndex = atomicAdd(IndexOfNextPool,1); //원자 연산을 사용하여 외부 공유 변수 읽고 쓰기
Particle& NextParticle = mParticlePool[mNextPoolIndex][CurrentIndex];
UpdateParticleStatus(CurrentParticle,NextParticle);
}
}
CurrentParticlesBufferSize = IndexOfNextBuffer; //현재 살아있는 파티클 수량 업데이트
swap(mCurrentPoolIndex,mNextPoolIndex); //현재 파티클 풀의 인덱스 교환
}
// 파티클 상태 업데이트 메커니즘 - 사용자 정의 가능
void UpdateParticleStatus(const Particle& CurrentParticle, Particle& NextParticle){
NextParticle.position = CurrentParticle.position;
//NextParticle...
}
렌더링 단계
렌더링은 단순히 파티클 데이터를 이용하여 그리는 것입니다. 그 기본 과정은 다음과 같이 볼 수 있습니다:
void Render(){
mParticleRenderer.BindAttribute("position",mParticlePool[mCurrentPoolIndex],offset0,stride0);
mParticleRenderer.BindAttribute("color",mParticlePool[mCurrentPoolIndex],offset1,stride1);
...;
mParticleRenderer.drawInstancing();
}
이 단계는 파티클의 본질을 보여줍니다: 파티클은 단지 데이터 모음이며, 파티클 시스템(이미터)은 이러한 데이터를 일괄적으로 처리하여 파티클 렌더러의 속성 슬롯에 바인딩함으로써 파티클 효과의 렌더링을 완성합니다.
CPU VS GPU
위에서 언급한 파티클 시스템은 본질적으로 다량의 파티클 데이터를 처리하는 것이며, 주로 다음과 같이 나눌 수 있습니다:
- 생성 단계와 업데이트 및 회수 단계: 이 두 단계는 주로 파티클 데이터를 처리하는 것이며, Niagara는 시각적 스크립트를 제공하여 이 두 단계에 대한 사용자 정의 작업을 지원합니다. 최종 스크립트는 다음과 같이 처리됩니다:
- CPU: 노드로 연결된 C++ 코드는 Niagara의 가상 머신에서 실행됩니다.
- GPU: Niagara는 노드 그래프를 기반으로 NiagaraHlslTranslator를 통해 이를 HLSL 코드로 변환하여 ComputeShader에서 호출합니다.
- 렌더링 단계: CPU 파티클이든 GPU 파티클이든 동일한 렌더러를 사용할 수 있으며, 차이점은 CPU 파티클은 데이터를 GPU로 업로드해야 하지만, GPU 파티클의 데이터는 이미 GPU에 있어 전송이 필요 없다는 것입니다.
위 과정은 논리적으로 매우 유사합니다. 여기서 주의할 점은 GPU 파티클의 회수입니다. GPU 파티클은 ComputeShader를 호출하여 각 파티클을 병렬로 처리하지만, 업데이트 및 회수 단계에서는 데이터 재배치가 필요합니다. GPU가 효율적으로 병렬 처리할 수 있는 이유는 각 분기의 데이터가 서로 독립적이고 영향을 주지 않기 때문입니다. 그러나 GPU 파티클 회수를 구현하려면 이 규칙을 "깨야" 합니다. 현재 이러한 효과를 달성하기 위한 주요 두 가지 방법이 있습니다:
- 그래픽 렌더링 파이프라인: 래스터화를 비활성화하고, TransformFeedback(OpenGL) / Stream Output(DX)을 사용하여 Geometry Shader에서 죽은 파티클을 회수(제출하지 않음)
Geometry Shader 자체는 선형 작업을 포함합니다. 이 과정에서 불확정 수의 새 정점이 생성되기 때문에, GPU가 메모리에 데이터를 쓸 때 반드시 잠금을 걸고 재배치합니다. 이것이 위에서 왜 일부 개발자들이 TransformFeedback과 Stream Output을 사용하여 GPU 파티클을 만드는 이유입니다. 물론 이것은 게임에서 GS를 가능한 피해야 하는 이유이기도 합니다. 부가적으로, Stream Output은 DX의 정통 기능인 반면, TransformFeedback은 Khronos에서 좋은 대우를 받지 못하고 있으며, Vulkan에서는 공식 지원조차 없고, Metal은 해당 기능의 API조차 없습니다.
- 범용 컴퓨팅 파이프라인: SSBO Atomic: 하드웨어 발전에 따라 현재 대부분의 고급 렌더링 드라이버는 SSBO(Shader Storage Buffer Object)를 지원합니다. 여기서 우리의 주요 관심사는 SSBO가 Atomic 연산을 지원한다는 것입니다. 이를 통해 CS에서 파티클을 병렬로 처리하고 Atomic 연산을 사용하여 파티클 데이터를 재배치할 수 있습니다(위 의사 코드의 NewIndex). 새 파티클 데이터의 경우, 또 다른 기능인 간접 렌더링(Indirect Draw)을 통해 GPU 상의 데이터를 직접 사용하여 DrawCall을 호출할 수 있으므로, CPU와 GPU 간의 파티클 데이터 전송을 완전히 피할 수 있습니다. 아래는 간단한 GLSL 코드 예시입니다:
layout (local_size_x = LOCAL_SIZE) in;
struct Particle {
vec3 position;
vec3 rotation;
vec3 scaling;
vec3 velocity;
float life;
};
layout(std140,binding = 0) buffer InputParticlesBuffer{
Particle inputParticles[PARTICLE_MAX_SIZE];
};
layout(std140,binding = 1) buffer OutputParticlesBuffer{
Particle outputParticles[PARTICLE_MAX_SIZE];
};
layout(std140,binding = 2) buffer ParticleRunContext{
int inputCounter; //초기값은 현재 파티클 수
int outputCounter; //초기화는 -1
float duration;
float lifetime;
};
#define inID gl_GlobalInvocationID.x
#define inParticle inputParticles[inID]
void main(){ //주의: GPU의 병렬 처리는 순서가 없음
if(inID >= inputCounter||inParticle.life>lifetime) //이 부분은 계단 함수 step으로 최적화 가능
return;
uint outID = atomicAdd(outputCounter,1); //atomicAdd는 GLSL의 네이티브 함수, 조작 대상은 SSBO에서 가져와야 함
outputParticles[outID].life = inParticle.life + duration;
}
셰이더 저장 버퍼 객체(Shader Storage Buffer Object)는 읽고 쓸 수 있는 버퍼 데이터를 제공합니다.
사용법은 UniformBuffer와 거의 동일하지만, 주요 차이점은 다음과 같습니다:
- Storage Buffer는 매우 큰 비디오 메모리를 요청할 수 있습니다: OpenGL 사양은 Uniform Buffer 크기가 16KB까지 가능하지만, Storage Buffer는 128MB까지 가능합니다.
- Storage Buffer는 쓰기가 가능하며, 원자(Atomic) 연산까지 지원합니다: Storage Buffer의 읽기와 쓰기는 순서가 뒤섞일 수 있어 동기화를 위한 메모리 배리어가 필요합니다.
- Storage Buffer는 가변 저장소를 지원합니다: Storage Buffer의 블록에서 무한 배열을 정의할 수 있습니다 (예: int arr[];). 셰이더에서 arr.length로 배열 길이를 얻을 수 있지만, Uniform Buffer의 블록에서는 배열 크기를 명시해야 합니다.
- 동일 조건에서 SSBO 접근은 Uniform Buffer보다 느립니다: Storage Buffer는 일반적으로 버퍼 텍스처처럼 접근하지만, Uniform Buffer 데이터는 내부 셰이더가 접근 가능한 메모리를 통해 읽습니다.
기능적으로, Image Load Store를 통해 접근할 때 SSBO는 버퍼 텍스처보다 더 나은 인터페이스로 볼 수 있습니다.
OpenGL은 AtomicCounter 기능을 제공하는데, 본질적으로 이 특성과 같습니다.
위에서 GPU 파티클 회수를 완료했지만, 실제로는 심각한 문제가 있습니다. 매번 회수할 때마다 파티클 수가 변하는데, 데이터는 GPU에 있고 CPU에서 DrawCall을 호출할 때 이 파라미터를 알아야 합니다.
GPU에서 데이터를 다시 읽어오는 것은 불가능합니다. 현대 그래픽 API는 명령 버퍼를 통해 렌더링을 가속화하기 때문에, 특정 시점에서 GPU의 명령 버퍼에는 아직 처리되지 않은 두 프레임 이상의 명령이 있을 수 있습니다. 데이터를 다시 읽으려면 현재 명령이 즉시 실행되도록 해야 하며, 이는 렌더링 스레드를 차단하고 데이터 반환을 기다려야 합니다. 이 작업은 전체 엔진에 치명적인 영향을 미치지만, 다행히 해결책이 있습니다.
OpenGL 4.0 업데이트에서는 Indirect Rendering(간접 렌더링, Indirect Draw라고도 함)이라는 새로운 기능을 제공합니다. 이를 통해 GPU 상의 데이터를 사용하여 DrawCall을 호출할 수 있습니다. 다음은 공식 정의입니다:
Indirect Rendering
간접 렌더링은 GPU 상의 데이터를 DrawCall 파라미터로 직접 사용할 수 있게 합니다. 예를 들어, glDrawArrays의 파라미터는 기본 유형, 정점 수, 시작 정점을 포함하는데, 간접 그리기 명령인 glDrawArraysIndirect를 사용하면 버퍼 객체에서 해당 파라미터를 가져옵니다.
이 기능은 GPU->CPU->GPU 왕복을 피하기 위한 것으로, GPU가 렌더링 파라미터를 결정하고 CPU는 그리기 명령을 언제 발행할지와 어떤 Primitive를 사용할지만 결정합니다.
Niagara 소스 코드에서 Indirect를 검색하면 관련 사용법 정의를 찾을 수 있으며, 주요 구조는 다음 위치에 있습니다:
- NiagaraVertexFactories\\Public\\NiagaraDrawIndirect.h
- Niagara\\Private\\NiagaraGPUInstanceCountManager.cpp
장단점
- CPU 파티클
- 장점
- CPU에서 완전한 게임 환경에 접근할 수 있어 다른 파티클이나 게임 로직과 상호작용이 가능합니다.
- 높은 하드웨어 사양이 필요 없으며, 프로그래머블 파이프라인과 인스턴스 렌더링을 지원하는 그래픽 카드면 충분합니다.
- 단점
- CPU에서 파티클 데이터를 선형적으로만 반복할 수 있어 CPU 성능에 큰 부담을 줍니다.
- 파티클 데이터가 CPU 메모리에 있어 렌더링 시 모든 데이터를 GPU로 업로드해야 하며, 이 전송 과정은 매우 비효율적입니다.
- GPU 파티클
- 장점
- 파티클 데이터가 전체 과정에서 GPU에서 생성되고 처리되어 CPU와 GPU 간 데이터 전송이 필요 없습니다.
- GPU의 병렬 유닛을 활용하여 파티클 처리 시간을 크게 줄일 수 있습니다.
- 단점
- GPU 파티클 데이터는 비디오 메모리에 있어 경계 상자를 실시간으로 계산할 수 없으므로, GPU 파티클에 고정 경계를 수동으로 지정해야 합니다.
- 게임 환경과의 상호작용이 어렵지만, 깊이, 씬 캡처, 거리 필드 등을 통해 효과를 근사화할 수 있습니다.
- 하드웨어 요구사항이 있으며, CS, SSBO Atomic, Indirect Draw를 지원해야 합니다(PC에서는 독립 그래픽 카드가 있으면 문제 없지만, 모바일 지원은 완벽하지 않음)
파티클 시스템 아키텍처 예제
본 튜토리얼의 코드에서는 비교적 간단한 파티클 아키텍처를 구축했습니다. 자세한 내용은 다음을 참조하세요:
현재 코드 예제에는 몇 가지 문제가 있습니다. QRhi는 Indirect Rendering을 직접 지원하지 않아, 필자는 일부 네이티브 Vulkan 코드를 삽입했습니다. 현재는 메모리 배리어로 동기화하는 대신 직접 서브 커맨드 버퍼를 제출하고 mRhi->finish()로 차단하고 기다리는 방식을 사용하고 있습니다.
- 코드: https://github.com/Italink/QEngineUtilities/blob/main/Source/Core/Source/Public/Asset/QParticleEmitter.h
- 예제1 — CPU 및 GPU 파티클
ModernGraphicsEngineGuide/Source/3-GraphicsTechnology/05-GPUParticles/Source/main.cpp at main · Italink/ModernGraphicsEngineGui
现代图形引擎入门指南. Contribute to Italink/ModernGraphicsEngineGuide development by creating an account on GitHub.
github.com
예제2 — 피사계 심도
Niagara
언리얼 엔진의 Niagara 시스템은 매우 강력하며, 본질적으로 프로그래밍 가능한 데이터 프로세서(CPU 또는 GPU)와 인스턴스 렌더러를 제공합니다. 이러한 관점에서 생각하면 매우 다양한 놀라운 효과를 구현할 수 있습니다.
스코프
대부분의 문서에서는 여기서의 스코프를 "네임스페이스"라고 부릅니다. 비록 이것이 변수 이름을 구분하는 역할을 하지만, 개발자에게 더 중요한 것은 이러한 변수 스코프가 나타내는 생명주기입니다.
다음은 UE에서 Niagara의 전체적인 구조이며, 노란색 부분은 Niagara에서 관리할 수 있는 변수의 스코프를 나타냅니다:
Niagara 에디터에서 파라미터 패널을 통해 이러한 스코프의 변수를 추가, 삭제, 수정할 수 있습니다.
- EngineProvided: 이 스코프는 주로 엔진에서 변수를 읽기 위해 사용됩니다. 일반적인 예로는 DeltaTime이 있습니다
- System Attribute: 단일 파티클 시스템이 보유한 속성 변수
- Emitter Attribute: 단일 파티클 이미터가 보유한 속성 변수
- Particle Attribute: 단일 파티클이 보유한 속성 변수
- Module Inputs: 모듈 입력 변수, 즉 모듈이 외부에 설정을 위해 공개한 변수로, 모듈의 설정 패널에 표시됩니다:
- Static Switch Inputs: 모듈 정적 분기 입력 변수, 컴파일 시간에 스크립트 로직 분기를 최적화하는 데 사용됩니다.
- Module Locals: 모듈 로컬 변수
- Module Outputs: 모듈 출력 변수
- Stage Transients: 단계 임시 변수
- User Expose: 사용자 공개 변수, 이 변수들은 주로 Niagara 외부 모듈(C++ 또는 블루프린트)에 호출을 위해 공개되며, 내부적으로는 다른 변수들과 본질적인 차이가 있습니다. 자세한 내용은 아래에서 설명합니다.
이러한 스코프가 나타내는 자세한 의미는 다음을 참조하세요: UE4: Niagara의 변수와 HLSL - 지후 (zhihu.com)
핵심 구조—FNiagaraDataSet
Niagara 속성은 본질적으로 데이터 모음이며, 코드상에서 해당하는 구조체는 FNiagaraDataSet입니다(User Expose 제외). 구조는 다음과 같습니다:
class FNiagaraDataSet{
/** Table of free IDs available to allocate next tick. */
TArray<int32> FreeIDsTable;
/** Number of free IDs in FreeIDTable. */
int32 NumFreeIDs;
/** Max ID seen in last execution. Allows us to shrink the IDTable. */
int32 MaxUsedID;
/** Tag to use when new IDs are acquired. Should be unique per tick. */
int32 IDAcquireTag;
/** Table of IDs spawned in the last tick (just the index part, the acquire tag is IDAcquireTag for all of them). */
TArray<int32> SpawnedIDsTable;
/** GPU buffer of free IDs available on the next tick. */
FRWBuffer GPUFreeIDs;
/** NUmber of IDs allocated for the GPU simulation. */
uint32 GPUNumAllocatedIDs;
/**
Actual data storage. These are passed to and read directly by the RT.
This is effectively a pool of buffers for this simulation.
Typically this should only be two or three entries and we search for a free buffer to write into on BeginSimulate();
We keep track of the Current and Previous buffers which move with each simulate.
Additional buffers may be in here if they are currently being used by the render thread.
*/
TArray<FNiagaraDataBuffer*, TInlineAllocator<2>> Data;
/** Buffer containing the current simulation state. */
FNiagaraDataBuffer* CurrentData;
/** Buffer we're currently simulating into. Only valid while we're simulating i.e between PrepareForSimulate and EndSimulate calls.*/
FNiagaraDataBuffer* DestinationData;
/* Max instance count is the maximum number of instances we allow. */
uint32 MaxInstanceCount;
/* Max allocation couns it eh maximum number of instances we can allocate which can be > MaxInstanceCount due to rounding. */
uint32 MaxAllocationCount;
}
위 구조에서 볼 수 있듯이, FNiagaraDataSet은 앞서 언급한 파티클 이미터의 기본 과정에 필요한 데이터 구조를 제공합니다:
- 최대 할당 값 저장(용량, ID)
- TArray<FNiagaraDataBuffer*, TInlineAllocator<2>> Data는 두 개의 교대 버퍼이며, FNiagaraDataBuffer* CurrentData, FNiagaraDataBuffer* DestinationData는 빠른 스왑을 위한 버퍼 포인터입니다
핵심 저장 구조는 FNiagaraDataBuffer이며, 이는 메모리 데이터에만 집중합니다. 구조는 다음과 같습니다:
class FNiagaraDataBuffer : public FNiagaraSharedObject{
//////////////////////////////////////////////////////////////////////////
//CPU Data
/** Float components of simulation data. */
TArray<uint8> FloatData;
/** Int32 components of simulation data. */
TArray<uint8> Int32Data;
/** Half components of simulation data. */
TArray<uint8> HalfData;
/** Table of IDs to real buffer indices. */
TArray<int32> IDToIndexTable;
//////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////
// GPU Data
/** Location in the frame where GPU data will be ready, for CPU this is always the first group, for GPU is depends on the features used as to which phase. */
ENiagaraGpuComputeTickStage::Type GPUDataReadyStage = ENiagaraGpuComputeTickStage::First;
/** The buffer offset where the instance count is accumulated. */
uint32 GPUInstanceCountBufferOffset;
/** GPU Buffer containing floating point values for GPU simulations. */
FRWBuffer GPUBufferFloat;
/** GPU Buffer containing integer values for GPU simulations. */
FRWBuffer GPUBufferInt;
/** GPU table which maps particle ID to index. */
FRWBuffer GPUIDToIndexTable;
/** GPU Buffer containing half values for GPU simulations. */
FRWBuffer GPUBufferHalf;
#if NIAGARA_MEMORY_TRACKING
int32 AllocationSizeBytes = 0;
#endif
//////////////////////////////////////////////////////////////////////////
/** Number of instances in data. */
uint32 NumInstances;
/** Number of instances the buffer has been allocated for. */
uint32 NumInstancesAllocated;
/** Stride between components in the float buffer. */
uint32 FloatStride;
/** Stride between components in the int32 buffer. */
uint32 Int32Stride;
/** Stride between components in the half buffer. */
uint32 HalfStride;
/** Number of instances spawned in the last tick. */
uint32 NumSpawnedInstances;
/** ID acquire tag used in the last tick. */
uint32 IDAcquireTag;
/** Table containing current base locations for all registers in this dataset. */
TArray<uint8*> RegisterTable;//TODO: Should make inline? Feels like a useful size to keep local would be too big.
RegisterTypeOffsetType RegisterTypeOffsets;
};
Niagara에서 FNiagaraDataSet을 가지고 있는 클래스들은 다음과 같습니다:
- FNiagaraEmitterInstance: Niagara 이미터 인스턴스
class FNiagaraEmitterInstance{
struct FEventInstanceData
{
TArray<FNiagaraScriptExecutionContext> EventExecContexts;
TArray<FNiagaraParameterDirectBinding<int32>> EventExecCountBindings;
TArray<FNiagaraDataSet*> UpdateScriptEventDataSets; //업데이트 단계의 데이터 셋
TArray<FNiagaraDataSet*> SpawnScriptEventDataSets; //생성 단계의 데이터 셋
TArray<bool> UpdateEventGeneratorIsSharedByIndex;
TArray<bool> SpawnEventGeneratorIsSharedByIndex;
/** Data required for handling events. */
TArray<FNiagaraEventHandlingInfo> EventHandlingInfo;
int32 EventSpawnTotal = 0;
};
/** particle simulation data. Must be a shared ref as various things on the RT can have direct ref to it. */
FNiagaraDataSet* ParticleDataSet = nullptr; //파티클 데이터
};
- FNiagaraSystemInstance: Niagara 시스템 인스턴스
class FNiagaraSystemInstance{
// registered events for each of the emitters
typedef TPair<FName, FName> EmitterEventKey;
typedef TMap<EmitterEventKey, FNiagaraDataSet*> EventDataSetMap;
EventDataSetMap EmitterEventDataSetMap; //Niagara 시스템 이벤트 셋
};
- FNiagaraDICollisionQueryBatch: 충돌 이벤트 포함
class FNiagaraDICollisionQueryBatch{
FNiagaraDataSet *CollisionEventDataSet = nullptr;
};
- FNiagaraScriptExecutionContextBase: Niagara 스크립트(모듈) 실행 컨텍스트
struct FNiagaraDataSetExecutionInfo{
FNiagaraDataSet* DataSet;
FNiagaraDataBuffer* Input;
FNiagaraDataBuffer* Output;
}
struct FNiagaraScriptExecutionContextBase{
UNiagaraScript* Script;
TArray<FNiagaraDataSetExecutionInfo, TInlineAllocator<2>> DataSetInfo;
}
이를 통해 Niagara의 파티클 데이터와 이벤트가 모두 FNiagaraDataSet을 기반으로 함을 알 수 있습니다. 단, 앞서 언급한 User Expose는 예외로, FNiagaraParameterStore에 저장되며 모듈에서는 보이지 않고 Niagara 시스템에서는 읽기만 가능합니다. 즉, FNiagaraParameterStoreBinding을 통해 다른 속성에 바인딩되며, 이는 Niagara와 외부 데이터 간의 상호작용 수단을 제공하기 위함입니다.
Niagara\\Classes\\NiagaraDataSetAccessor.h에서는 FNiagaraDataSet을 내부적으로 읽고 쓰는 다양한 템플릿 함수를 제공합니다. Int32를 예로 들면:
template<typename TType>
struct FNiagaraDataSetAccessorInt32
{
static FNiagaraDataSetReaderInt32<TType> CreateReader(const FNiagaraDataSet& DataSet, const FName VariableName) { }
static FNiagaraDataSetWriterInt32<TType> CreateWriter(const FNiagaraDataSet& DataSet, const FName VariableName) { }
};
Niagara의 에디터 단계에서는 FNiagaraDataSet을 직접 처리하지 않고, FNiagaraVariable을 관리 편집하여 FNiagaraDataSetCompiledData에 저장한 후 최종적으로 컴파일합니다:
struct FNiagaraDataSetCompiledData
{
GENERATED_BODY()
/** Variables in the data set. */
UPROPERTY()
TArray<FNiagaraVariable> Variables;
/** Data describing the layout of variable data. */
UPROPERTY()
TArray<FNiagaraVariableLayoutInfo> VariableLayouts;
//...
};
이것으로 Niagara의 핵심 구조에 대한 설명을 마칩니다. 세부 사항에 대해서는 더 깊이 들어갈 필요가 없습니다.
데이터 인터페이스 - Data Interface
기본 데이터 타입(숫자, 벡터...)은 모든 애플리케이션 시나리오를 충족시키기에 충분하지 않습니다. 어떤 경우에는 파티클 시스템이 뼈대, 텍스처, 거리 필드, 오디오와 같은 많은 복잡한 데이터 구조에 접근해야 할 수도 있습니다.
이를 위해 Niagara는 Data Interface라는 메커니즘을 제공하여 복잡한 데이터 구조를 처리합니다. 어떤 범위에서든 속성을 새로 만들 때 DateInterface 아래에서 다음 항목들을 볼 수 있습니다:
이는 코드 하부 구조에서 UNiagaraDataInterface에 해당합니다
위의 모든 인터페이스는 이것에서 파생되었으며, 여기서 언급하는 주요 이유는: 우리도 플러그인에서 이 클래스를 파생시켜 커스터마이징할 수 있기 때문입니다.
Niagara 소스 코드에는 많은 파생 예제가 있습니다:
이벤트 메커니즘
Niagara의 이벤트 메커니즘 역시 앞서 언급한 FNiagaraDataSet을 기반으로 합니다
GenerateLocationEvent를 예로 들면
스크립트를 열면 다음과 같이 볼 수 있습니다:
이 스크립트의 핵심은 LocationEvent_V2 Write 노드를 호출하는 것입니다. 이 노드는 직접 생성할 수 없으며, 엔진에 의해 직렬화되어 생성됩니다. 이 노드의 원형은 UNiagaraNodeWriteDataSet이며, 그 구조는 다음과 같습니다:
class UNiagaraNodeDataSetBase : public UNiagaraNode
{
public:
FNiagaraDataSetID DataSet;
TArray<FNiagaraVariable> Variables;
TArray<FString> VariableFriendlyNames;
};
class UNiagaraNodeWriteDataSet : public UNiagaraNodeDataSetBase{
FName EventName;
};
이제 이벤트 핸들러 Receive Location Event를 살펴보겠습니다.
LocationEvent_V2_Read가 이전의 LocationEvent_V2 Write와 대응하는 것을 볼 수 있으며, 노드 원형은 UNiagaraNodeWriteDataSet입니다
Niagara에서 직접 생성할 수 있는 이벤트는 다음과 같습니다:
이들은 UNiagaraNodeDataSetRead와 UNiagaraNodeDataSetWrite를 통해 FNiagaraDataSet에 대한 읽기 및 쓰기를 수행합니다. 실행 로직은 간단하지만, 우리가 명확히 알아야 할 것은:
- 이 두 노드가 어디에 있는 FNiagaraDataSet에 대해 읽기/쓰기를 수행하는가?
- 이벤트가 언제 처리되는가?
이벤트 데이터의 소유자
FNiagaraSystemInstance의 정의에는 다음과 같은 함수가 있습니다:
class FNiagaraSystemInstance{
//...
typedef TPair<FName, FName> EmitterEventKey;
typedef TMap<EmitterEventKey, FNiagaraDataSet*> EventDataSetMap;
EventDataSetMap EmitterEventDataSetMap; //Niagara系统事件集
};
FNiagaraDataSet* FNiagaraSystemInstance::CreateEventDataSet(FName EmitterName, FName EventName)
{
FNiagaraDataSet*& OutSet = EmitterEventDataSetMap.FindOrAdd(EmitterEventKey(EmitterName, EventName));
if (!OutSet) {
OutSet = new FNiagaraDataSet();
}
return OutSet;
}
Niagara 이미터는 초기화 시(재설정 또는 재컴파일로 인한 경우), 다음 함수를 호출합니다:
void FNiagaraEmitterInstance::Init(int32 InEmitterIdx, FNiagaraSystemInstanceID InSystemInstanceID){
const int32 UpdateEventGeneratorCount = CachedEmitter->UpdateScriptProps.EventGenerators.Num();
const int32 SpawnEventGeneratorCount = CachedEmitter->SpawnScriptProps.EventGenerators.Num();
const int32 NumEvents = CachedEmitter->GetEventHandlers().Num();
if (UpdateEventGeneratorCount || SpawnEventGeneratorCount || NumEvents)
{
EventInstanceData = MakeUnique<FEventInstanceData>();
EventInstanceData->UpdateScriptEventDataSets.Empty(UpdateEventGeneratorCount);
EventInstanceData->UpdateEventGeneratorIsSharedByIndex.SetNumZeroed(UpdateEventGeneratorCount);
int32 UpdateEventGeneratorIndex = 0;
for (const FNiagaraEventGeneratorProperties &GeneratorProps : CachedEmitter->UpdateScriptProps.EventGenerators)
{
FNiagaraDataSet *Set = ParentSystemInstance->CreateEventDataSet(EmitterHandle.GetIdName(), GeneratorProps.ID);
Set->Init(&GeneratorProps.DataSetCompiledData);
EventInstanceData->UpdateScriptEventDataSets.Add(Set);
EventInstanceData->UpdateEventGeneratorIsSharedByIndex[UpdateEventGeneratorIndex] = CachedEmitter->IsEventGeneratorShared(GeneratorProps.ID);
++UpdateEventGeneratorIndex;
}
EventInstanceData->SpawnScriptEventDataSets.Empty(SpawnEventGeneratorCount);
EventInstanceData->SpawnEventGeneratorIsSharedByIndex.SetNumZeroed(SpawnEventGeneratorCount);
int32 SpawnEventGeneratorIndex = 0;
for (const FNiagaraEventGeneratorProperties &GeneratorProps : CachedEmitter->SpawnScriptProps.EventGenerators)
{
FNiagaraDataSet *Set = ParentSystemInstance->CreateEventDataSet(EmitterHandle.GetIdName(), GeneratorProps.ID);
Set->Init(&GeneratorProps.DataSetCompiledData);
EventInstanceData->SpawnScriptEventDataSets.Add(Set);
EventInstanceData->SpawnEventGeneratorIsSharedByIndex[SpawnEventGeneratorIndex] = CachedEmitter->IsEventGeneratorShared(GeneratorProps.ID);
++SpawnEventGeneratorIndex;
}
EventInstanceData->EventExecContexts.SetNum(NumEvents);
EventInstanceData->EventExecCountBindings.SetNum(NumEvents);
for (int32 i = 0; i < NumEvents; i++)
{
ensure(CachedEmitter->GetEventHandlers()[i].DataSetAccessSynchronized());
UNiagaraScript* EventScript = CachedEmitter->GetEventHandlers()[i].Script;
//This is cpu explicitly? Are we doing event handlers on GPU?
EventInstanceData->EventExecContexts[i].Init(EventScript, ENiagaraSimTarget::CPUSim);
EventInstanceData->EventExecCountBindings[i].Init(EventInstanceData->EventExecContexts[i].Parameters, SYS_PARAM_ENGINE_EXEC_COUNT);
}
}
}
이 함수에서 주요 작업은 다음과 같습니다:
- 이미터에 이벤트 생성기가 존재하는 경우, FEventInstanceData를 생성합니다
- 마지막에 모든 이벤트 핸들러를 초기화합니다
- 코드에서는 CachedEmitter->UpdateScriptProps.EventGenerators와 CachedEmitter->SpawnScriptProps.EventGenerators를 순회하며 EventDataSet을 생성합니다. 정의를 추적하면 다음 코드를 찾을 수 있습니다:
void FNiagaraEmitterScriptProperties::InitDataSetAccess()
{
EventReceivers.Empty();
EventGenerators.Empty();
if (Script && Script->IsReadyToRun(ENiagaraSimTarget::CPUSim))
{
//UE_LOG(LogNiagara, Log, TEXT("InitDataSetAccess: %s %d %d"), *Script->GetPathName(), Script->ReadDataSets.Num(), Script->WriteDataSets.Num());
// TODO: add event receiver and generator lists to the script properties here
//
for (FNiagaraDataSetID &ReadID : Script->GetVMExecutableData().ReadDataSets)
{
EventReceivers.Add( FNiagaraEventReceiverProperties(ReadID.Name, NAME_None, NAME_None) );
}
for (FNiagaraDataSetProperties &WriteID : Script->GetVMExecutableData().WriteDataSets)
{
FNiagaraEventGeneratorProperties Props(WriteID, NAME_None);
EventGenerators.Add(Props);
}
}
}
그리고 정의는 다음과 같습니다:
class FNiagaraVMExecutableData{
UPROPERTY()
TArray<FNiagaraDataSetProperties> WriteDataSets;
};
여기서 WriteDataSets는 FHlslNiagaraTranslator에 의해 스크립트 노드 컴파일 시 생성됩니다. 호출 스택은 다음과 같습니다:
void UNiagaraNodeWriteDataSet::Compile(...)
{
//...
Translator->WriteDataSet(AlteredDataSet, Variables, ENiagaraDataSetAccessMode::AppendConsume, Inputs, Outputs);
}
void FHlslNiagaraTranslator::WriteDataSet(...)
{
//...
TMap<int32, FDataSetAccessInfo>& Writes = DataSetWriteInfo[(int32)AccessMode].FindOrAdd(DataSet);
}
FNiagaraTranslateResults FHlslNiagaraTranslator::Translate(...){
for (TPair <FNiagaraDataSetID, TMap<int32, FDataSetAccessInfo>> InfoPair : DataSetWriteInfo[0]){
CompilationOutput.ScriptData.WriteDataSets.Add(SetProps);
}
}
이를 통해 다음과 같은 결론을 내릴 수 있습니다:
- Niagara 모듈 스크립트에서 UNiagaraNodeWriteDataSet을 포함하면 이벤트 생성기로 정의됩니다
- 이벤트 데이터의 소유자는 FNiagaraSystemInstance입니다
이벤트 처리 시점
FNiagaraEmitterInstance::Tick에서 다음과 같은 코드가 있습니다:
void FNiagaraEmitterInstance::Tick(float DeltaSeconds)
{
//...
if (EventInstanceData.IsValid())
{
// Set up the spawn counts and source datasets for the events. The system ensures that we will run after any emitters
// we're receiving from, so we can use the data buffers that our sources have computed this tick.
const int32 NumEventHandlers = CachedEmitter->GetEventHandlers().Num();
EventInstanceData->EventSpawnTotal = 0;
for (int32 i = 0; i < NumEventHandlers; i++)
{
const FNiagaraEventScriptProperties& EventHandlerProps = CachedEmitter->GetEventHandlers()[i];
FNiagaraEventHandlingInfo& Info = EventInstanceData->EventHandlingInfo[i];
Info.TotalSpawnCount = 0;//This was being done every frame but should be done in init?
Info.SpawnCounts.Reset();
//TODO: We can move this lookup into the init and just store a ptr to the other set?
if (FNiagaraDataSet* EventSet = ParentSystemInstance->GetEventDataSet(Info.SourceEmitterName, EventHandlerProps.SourceEventName))
{
Info.SetEventData(&EventSet->GetCurrentDataChecked());
uint32 EventSpawnNum = CalculateEventSpawnCount(EventHandlerProps, Info.SpawnCounts, EventSet);
Info.TotalSpawnCount += EventSpawnNum;
EventInstanceData->EventSpawnTotal += EventSpawnNum;
}
}
}
}
위 코드는 이벤트 처리 로직을 보여줍니다:
- 이미터는 Tick 시 모든 이벤트 핸들러를 반복하며, 시스템 인스턴스 ParentSystemInstance에서 이벤트 DataSet을 읽어 FNiagaraEventHandlingInfo에 기록하여 스크립트가 읽을 수 있게 합니다.
사용 권장사항
이펙트는 예술적 효과를 추구하는 동시에 다음을 보장해야 합니다:
- 파티클 시스템에 올바른 바운딩 박스와 확장성을 설정해야 합니다. 그렇지 않으면 장면에서 파티클이 이유 없이 사라지는 문제가 발생할 수 있습니다.
- Spawn 이펙트의 수명 주기를 엄격하게 관리하여 Pool Method와 수명 주기 처리 작업(사전 클리핑, 확장성 컬링, AutoDestroy) 간의 충돌을 방지해야 합니다.
최적화에 관해서는 다음 사항을 알아야 합니다:
- 파티클 이미터의 수를 어떻게 줄일 수 있을까요?
이상적인 경우: 두 개의 이미터가 완전히 동일한 렌더러 매개변수를 사용한다면, 동일한 이미터를 사용해야 합니다이미터 내의 하나의 렌더러는 하나의 그래픽 렌더링 파이프라인에 해당하며, 파티클 시스템은 본질적으로 그래픽 렌더링 파이프라인에 필요한 인스턴스화 데이터를 처리합니다이미터가 하나 더 추가되면 렌더링 데이터가 더 많아지고 파티클 버퍼를 추가로 유지 관리해야 합니다파티클 움직임 메커니즘만 조정하고 싶다면 사용자 정의 Module 방식으로 확장할 수 있습니다
- CPU 파티클과 GPU 파티클 중 어느 것을 사용해야 하는지 어떻게 결정할까요?
CPU 파티클은 바운딩 박스 경계를 동적으로 계산할 수 있지만 소량의 파티클 렌더링에만 적합합니다GPU 파티클은 대량의 파티클 데이터를 렌더링하는 데 사용할 수 있지만 정확한 바운딩 박스 경계를 설정해야 합니다구분 기준은 주로 파티클 수량입니다. 천 단위, 만 단위 파티클이라면 반드시 GPU 파티클을 사용해야 합니다소량의 파티클, 예를 들어 몇 개 정도라면 CPU 파티클 사용을 권장합니다
- 효과를 위해 CPU 파티클을 사용해야 할 경우, 주로 두 가지를 고려해야 합니다:
- CPU 파티클의 실행은 직렬적이므로 파티클 시스템의 복잡성이 증가함에 따라 성능 손실이 기하급수적으로 증가합니다.
- CPU 파티클의 렌더링 스레드 내 성능 병목 현상은 주로 파티클 데이터 전송(제한된 대역폭)으로, 이것이 대량 파티클 그리기에 적합하지 않은 이유입니다.
- 파티클 시스템의 작동 방식은 일반적인 Pipeline과 다르므로, 장면에 배치된 단일 개체의 경우 파티클 시스템이 아닌 Actor(Game Object)를 사용하는 것이 좋습니다.
- 파티클 시스템의 복잡성과 수량을 제어하는 것 외에도 다음과 같은 방법으로 파티클의 성능 소모를 줄일 수 있습니다:
- 파티클 과밀을 피하고 Overdraw 감소
- 투명도 제한
- ...
- Niagara 시뮬레이션에 필요한 데이터를 오프라인(에디터 시간)으로 생성합니다.
- Niagara 디버거와 Insight를 잘 활용하세요
관련 문서
- UE4 Niagara 시리즈 튜토리얼 - https://www.zhihu.com/column/c_1323697556422369280
'TECH.ART.FLOW.IO' 카테고리의 다른 글
[번역] 버젯,언리얼 엔진 게임 최적화 (7) | 2025.08.08 |
---|---|
[번역][따로 정리추가] 모바일에서의 언리얼 엔진 "Panner" 노드 정확도 및 퍼포먼스 문제 분석 (0) | 2025.06.12 |
앙상블 스타즈 IP 구축 전략은? (8) | 2025.06.10 |
Message part 1 (0) | 2025.06.09 |
[번역][연재물] 언리얼 엔진 개발 가이드. 플러그인 개발 (0) | 2025.06.06 |