Particle은 특수효과를 표현한다. 예를 들어 스파크가 튈 때 수많은 입자들이 사방으로 퍼지는 것을 표현해야 하는데 이것을 전부 우리가 Mesh를 표현했던 것처럼 Input , 여러 Shader, Rasterize 등을 거쳐서 표현하는 것은 굉장히 많은 연산을 필요로 할 것이다. 그래서 사용하는 것이 Instancing이다.
void Mesh::Render()
{
GRAPHICS_CMD_LIST->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
GRAPHICS_CMD_LIST->IASetVertexBuffers(0, 1, &_vertexBufferView); // Slot: (0~15)
GRAPHICS_CMD_LIST->IASetIndexBuffer(&_indexBufferView);
GEngine->GetGraphicsDescHeap()->CommitTable();
GRAPHICS_CMD_LIST->DrawIndexedInstanced(_indexCount, 1, 0, 0, 0);
}
Instancing은 이미 사용하고 있었는데 Mesh::Render에서 DrawIndexedInstanced에 indexCount를 통해 물체의 개수를 조절할 수 있다. 그러면 파티클 1000개가 필요할 경우 indexCount만 1000으로 조정하면 Input Assembler 과정을 1000번 반복할 필요없이 한번만 진행하고 훨씬 빠르게 계산할 수 있다. 그리고 파티클 1000개의 좌표가 모두 다르므로 하나마다 id를 만들어 관리한다.
cbuffer GLOBAL_PARAMS : register(b0)
{
int g_lightCount;
float3 g_lightPadding;
LightInfo g_light[50];
}
또한 파티클은 어디를 향하여 가는지 방향, 속도 등을 추가로 가지고 있어야 하므로 이러한 정보를 담을 것이 필요하다.
그래서 생각나는건 빛 정보를 담는 ConstantBuffer인데, 여기에 담기에는 최대 개수가 정해져 있다(light 최대 50개)는 문제와 CPU에서 GPU로 데이터를 넘기는 속도가 느리다는 단점이 있다.
struct Particle
{
float3 worldPos;
float curTime;
float3 worldDir;
float lifeTime;
int alive;
float3 padding;
};
StructuredBuffer<Particle> g_data : register(t9);
그래서 사용하는 것이 StructuredBuffer이다. ConstantBuffer와 달리 크기를 지정해줄 필요가 없이 가변적이다. 그리고 GPU에 있는 버퍼이므로 CPU로 느리게 넘길 필요 없고 Compute Shader을 이용해 빠르게 처리할 수 있다. 파티클은 효과가 끝난 후 사라지므로 alive를 보고 그릴지 말지 결정한다. Geometry Shader 단계에서 그려야 할 필요 없는 것들은 중단하고 그려야 할 것들은 과정을 계속 진행한다.
[maxvertexcount(6)]
void GS_Main(point VS_OUT input[1], inout TriangleStream<GS_OUT> outputStream)
{
GS_OUT output[4] =
{
(GS_OUT)0.f, (GS_OUT)0.f, (GS_OUT)0.f, (GS_OUT)0.f
};
VS_OUT vtx = input[0];
uint id = (uint)vtx.id;
if (0 == g_data[id].alive)
return;
float ratio = g_data[id].curTime / g_data[id].lifeTime;
float scale = ((g_float_1 - g_float_0) * ratio + g_float_0) / 2.f;
// View Space
output[0].position = vtx.viewPos + float4(-scale, scale, 0.f, 0.f);
...
// Projection Space
output[0].position = mul(output[0].position, g_matProjection);
...
output[0].uv = float2(0.f, 0.f);
...
output[0].id = id;
...
outputStream.Append(output[0]);
outputStream.Append(output[1]);
outputStream.Append(output[2]);
outputStream.RestartStrip();
outputStream.Append(output[0]);
outputStream.Append(output[2]);
outputStream.Append(output[3]);
outputStream.RestartStrip();
}
GS_Main에서 g_data[id]의 alive가 0일 때 밑의 과정을 진행도 안하고 그냥 return 시켜버린다. alive가 1이라면 input에 대해 계산을 하고 outputStream에 정점들을 추가한다. 지금까지 정리해보면, Input Assembler 전에 Compute Shader 단계에서 Particle의 좌표, 스폰 여부 등을 계산하고 Input Assembler을 시작한다. Instancing으로 입력은 한번만 하면 되고 이후
Particle 개수 만큼 렌더링을 진행하는데 Geometry Shader 단계에서 alive라면 정점을 그려 계산한다.
RWStructuredBuffer<Particle> g_particle : register(u0);
RWStructuredBuffer<ComputeShared> g_shared : register(u1);
// CS_Main
// g_vec2_1 : DeltaTime / AccTime
// g_int_0 : Particle Max Count
// g_int_1 : AddCount
// g_vec4_0 : MinLifeTime / MaxLifeTime / MinSpeed / MaxSpeed
[numthreads(1024, 1, 1)]
void CS_Main(int3 threadIndex : SV_DispatchThreadID)
{
if (threadIndex.x >= g_int_0)
return;
int maxCount = g_int_0;
int addCount = g_int_1;
int frameNumber = g_int_2;
float deltaTime = g_vec2_1.x;
float accTime = g_vec2_1.y;
float minLifeTime = g_vec4_0.x;
float maxLifeTime = g_vec4_0.y;
float minSpeed = g_vec4_0.z;
float maxSpeed = g_vec4_0.w;
g_shared[0].addCount = addCount;
GroupMemoryBarrierWithGroupSync();
먼저 순서대로 Compute Shader 단계부터 알아보자. g_particle은 particle들을 담는 배열이고 g_shared는 공유하는 하나의 데이터이다. 그런데 파티클들은 순서대로가 아닌 모두 병렬적으로 계산되기 때문에 문제가 발생할 수 있다. g_shared[0].addCount = addCount 에서 공유하는 데이터의 addCount를 변경하는데 어떤 Particle이 이미 addCount를 세팅하고 add를 한 다음 Count가 줄었는데 그걸 모르고 다른 Particle에서 addCount를 초기화한 상태에서 실행할 수 있다.
그래서 GroupMemoryBarrirerWithGroupSync로 모든 Particle들이 addCount를 초기화한 후 다음 코드를 실행한다.
if (g_particle[threadIndex.x].alive == 0)
{
while (true)
{
int remaining = g_shared[0].addCount;
if (remaining <= 0)
break;
int expected = remaining;
int desired = remaining - 1;
int originalValue;
InterlockedCompareExchange(g_shared[0].addCount, expected, desired, originalValue);
if (originalValue == expected)
{
g_particle[threadIndex.x].alive = 1;
break;
}
}
그 후 addCount 개수에 따라 alive가 0으로 설정된 Particle들을 부활시킨다. 그런데 또 병렬 계산에서 문제가 발생할 수 있기 때문에 방지하기 위해 Interlocked...함수를 실행한다. 위 코드의 함수를 해석하면 g_shared[0].addCount가 expected 와 같을 경우 g_shared[0].addCount는 desired가 되고 뭐가 됐든 originalValue는 g_shared[0].addCount가 된다. g_shared[0].addCount가 expected와 같다면 1을 빼게 되는데 다른 Particle에서 이미 실행되었다면 g_shared[0].addCount와 expected와 다를 것이고 1을 빼지 않고 실패할 것이다. 그래서 선착순으로 도착한 Particle들을 부활시킨다.
float Rand(float2 co)
{
return 0.5 + (frac(sin(dot(co.xy, float2(12.9898, 78.233))) * 43758.5453)) * 0.5;
}
if (g_particle[threadIndex.x].alive == 1)
{
float x = ((float)threadIndex.x / (float)maxCount) + accTime;
float r1 = Rand(float2(x, accTime));
float r2 = Rand(float2(x * accTime, accTime));
float r3 = Rand(float2(x * accTime * accTime, accTime * accTime));
// [0.5~1] -> [0~1]
float3 noise =
{
2 * r1 - 1,
2 * r2 - 1,
2 * r3 - 1
};
// [0~1] -> [-1~1]
float3 dir = (noise - 0.5f) * 2.f;
g_particle[threadIndex.x].worldDir = normalize(dir);
g_particle[threadIndex.x].worldPos = (noise.xyz - 0.5f) * 25;
g_particle[threadIndex.x].lifeTime = ((maxLifeTime - minLifeTime) * noise.x) + minLifeTime;
g_particle[threadIndex.x].curTime = 0.f;
}
}
그리고 Particle들의 좌표를 설정하는데 랜덤하게 표현하기 위해 Rand라는 임의의 함수를 만들어 무작위 값을 구하고
(hlsl에는 rand()가 없음) 구한 값이 0.5~1이기 때문에 2 곱하고 1 빼서 0~1 사이 수로 만든다. 그리고 다시 0.5 빼고 2를 곱해 -1~1 사이 수로 만들고 방향, 좌표, lifetime 등을 설정한다. 실제로는 noise texture을 이용해 랜덤값을 구하기도 한다.
else
{
g_particle[threadIndex.x].curTime += deltaTime;
if (g_particle[threadIndex.x].lifeTime < g_particle[threadIndex.x].curTime)
{
g_particle[threadIndex.x].alive = 0;
return;
}
float ratio = g_particle[threadIndex.x].curTime / g_particle[threadIndex.x].lifeTime;
float speed = (maxSpeed - minSpeed) * ratio + minSpeed;
g_particle[threadIndex.x].worldPos += g_particle[threadIndex.x].worldDir * speed * deltaTime;
}
그리고 else 문은 원래 살아있었던 (alive = 1) 것들에 대해 좌표를 이동하고 lifeTime이 지난 것들을 죽인다. 이렇게 ComputeShader이 끝나면 언급했었던 GS_Main이 실행되고 PS_Main에서 그려지게 된다.
C++에서는 StructuredBuffer을 만들어야 한다. ConstantBuffer과 유사하게 만들고 여러가지 레지스터에 연결될 수 있기 때문에 SRV와 UAV를 받도록 만든다.
_particleBuffer = make_shared<StructuredBuffer>();
_particleBuffer->Init(sizeof(ParticleInfo), _maxParticle);
_computeSharedBuffer = make_shared<StructuredBuffer>();
_computeSharedBuffer->Init(sizeof(ComputeSharedInfo), 1);
void ParticleSystem::FinalUpdate()
{
_accTime += DELTA_TIME;
int32 add = 0;
if (_createInterval < _accTime)
{
_accTime = _accTime - _createInterval;
add = 1;
}
_particleBuffer->PushComputeUAVData(UAV_REGISTER::u0);
_computeSharedBuffer->PushComputeUAVData(UAV_REGISTER::u1);
_computeMaterial->SetInt(0, _maxParticle);
_computeMaterial->SetInt(1, add);
_computeMaterial->SetVec2(1, Vec2(DELTA_TIME, _accTime));
_computeMaterial->SetVec4(0, Vec4(_minLifeTime, _maxLifeTime, _minSpeed, _maxSpeed));
_computeMaterial->Dispatch(1, 1, 1);
}
Particle System에서는 레지스터 u0,u1에 데이터를 매핑시키고 particleBuffer와 computeSharedBuffer을 만들고 FinalUpdate에서는 인터벌이 지날 때 마다 add를 1로 설정해 하나를 부활시키라는 명령을 내린다. Dispatch에서는
세팅한 값들로 Compute Shader (CS_Main)을 실행한다.
그리고 Shader에서 Shader Type에 Particle을 추가하고 새로 추가된 Geometry Shader(GS_MAIN)를 함수에 추가한다.
Camera에서는 deferred 도 아니고 forward도 아니기 때문에 particle 이라는 gameObject 벡터를 만든다.
void ParticleSystem::Render()
{
GetTransform()->PushData();
_particleBuffer->PushGraphicsData(SRV_REGISTER::t9);
_material->SetFloat(0, _startScale);
_material->SetFloat(1, _endScale);
_material->PushGraphicsData();
_mesh->Render(_maxParticle);
}
최종 순서는 ParticleSystem::FinalUpdate에서 Compute Shader을 실행하고 ParticleSystem::Render에서 particleBuffer을 t9 레지스터에 매핑한뒤 VS_Main, GS_Main, PS_Main을 순서대로 실행한다.
'그래픽스' 카테고리의 다른 글
| [DX12] Shadow Mapping (0) | 2022.07.30 |
|---|---|
| [DX12] Instancing (0) | 2022.07.30 |
| [DX12] Compute Shader (0) | 2022.07.29 |
| [DX12] Deferred Rendering (0) | 2022.07.28 |
| [DX12] Render Target (0) | 2022.07.28 |