ParticleSystem

이 엔진의 파티클 시스템은 DirectX 11의 Stream-Out 기능을 활용한 GPU 기반 구현입니다. 파티클의 생성, 업데이트, 렌더링이 모두 GPU에서 처리되며, 핑퐁 버퍼링을 통해 CPU의 개입 없이 파티클 시뮬레이션이 이루어집니다.

파티클 데이터 구조

// ParticleSystem.cpp에서 사용하는 파티클 정점 구조체
// HLSL 셰이더에서는 'Particle'로 정의됨
struct VertexParticle {
    Vec3 position;   // InitialPosW
    Vec3 velocity;   // InitialVelW
    Vec2 size;       // SizeW
    float age;       // Age
    uint32 type;     // Type (0=에미터, 1=일반 파티클)
};

파티클 시스템 초기화

ParticleSystem 클래스 초기화 시 세 종류의 버퍼를 생성합니다:

// ParticleSystem.cpp
ParticleSystem::ParticleSystem()
    : Super(ComponentType::Particle)
{
    _firstRun = true;

    ComPtr<ID3D11ShaderResourceView> texArraySRV = RESOURCE.GetResource<Texture>(L"Fire_Particle")->GetShaderResourceView();
    ComPtr<ID3D11ShaderResourceView> randomTexSRV = RESOURCE.GetResource<Texture>(L"Random_Texture")->GetShaderResourceView();
    uint32 maxParticles = 500;

    _maxParticles = maxParticles;
    _texArraySRV = texArraySRV;
    _randomTexSRV = randomTexSRV;

    BuildVB();
}

이어서 BuildVB 메서드에서 필요한 버퍼들을 생성합니다:

// ParticleSystem.cpp
void ParticleSystem::BuildVB()
{
    // 1. 초기 에미터 버퍼 (_initVB) - 파티클 1개만 포함
    D3D11_BUFFER_DESC vbd;
    vbd.Usage = D3D11_USAGE_DEFAULT;
    vbd.ByteWidth = sizeof(VertexParticle) * 1; // _initVB는 정점 1개짜리
    vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
    vbd.CPUAccessFlags = 0;
    vbd.MiscFlags = 0;
    vbd.StructureByteStride = 0;

    // 에미터 파티클 초기화
    VertexParticle p;
    ZeroMemory(&p, sizeof(VertexParticle));
    p.age = 0.0f;
    p.type = 0; // 첫번째 정점 Emitter Flag 설정

    D3D11_SUBRESOURCE_DATA vinitData;
    vinitData.pSysMem = &p;

    HRESULT hr = DEVICE->CreateBuffer(&vbd, &vinitData, _initVB.GetAddressOf());
    CHECK(hr);

    // 2. 핑퐁 버퍼 (_drawVB, _streamOutVB) - 최대 파티클 수만큼 공간 확보
    vbd.ByteWidth = sizeof(VertexParticle) * _maxParticles; // _drawVB는 최대 파티클 개수만큼(500개), 데이터 없이생성(GPU에서 채워짐)
    vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER | D3D11_BIND_STREAM_OUTPUT; // 버퍼를 SO로 바인딩하겠다는 플래그

    hr = DEVICE->CreateBuffer(&vbd, 0, _drawVB.GetAddressOf()); // 초기 데이터는 비워둠
    CHECK(hr);

    hr = DEVICE->CreateBuffer(&vbd, 0, _streamOutVB.GetAddressOf());
    CHECK(hr);
}

애미터 파티클은 실제 렌더링 될 파티클을 생성하기 위한 선봉대장의 역할을 하며 Geometry Shader에서 입력된 정점 버퍼의 타입이 애미터라면 새로운 파티클 정점을 생성시킵니다. 이로써 시간이 지남에 따라 점점 많은 수의 파티클 정점(최대 개수를 넘기지 않은)이 StreamOut 버퍼에 저장이 되며 이렇게 GPU에서 생성된 버퍼를 기반으로 파티클을 렌더링 합니다.

파티클 업데이트 과정 (Stream-Out)

파티클 업데이트는 InitParticleSystem.hlsl의 지오메트리 셰이더에서 처리됩니다:

// InitParticleSystem.hlsl
Texture1D gRandomTex : register(t0);
SamplerState gSampler : register(s0);

float3 RandUnitVec3(float offset)
{
    float u = (gGameTime + offset);
    float3 v = gRandomTex.SampleLevel(gSampler, u, 0).xyz;
    return normalize(v);
}

[maxvertexcount(2)]
void GS(point Particle gin[1], inout PointStream<Particle> ptStream)
{
    gin[0].Age += gTimeStep;  // 나이 증가

    if (gin[0].Type == PT_EMITTER)  // 에미터 파티클인 경우
    {
        if (gEndParticle > 0.0f)  // 파티클 생성 활성화 확인
        {
            // 새 파티클을 생성할 시간인지 확인
            if (gin[0].Age > 0.005f)
            {
                float3 vRandom = RandUnitVec3(0.0f);
                vRandom.x *= 0.5f;
                vRandom.z *= 0.5f;

                // 새 파티클 생성
                Particle p;
                p.InitialPosW = gEmitPosW.xyz;
                p.InitialVelW = 4.0f * vRandom;
                p.SizeW = float2(3.0f, 3.0f);
                p.Age = 0.0f;
                p.Type = PT_FLARE;  // 일반 파티클 타입 설정

                // 스트림에 추가
                ptStream.Append(p);

                // 에미터 나이 리셋
                gin[0].Age = 0.0f;
            }
        }

        // 에미터는 항상 스트림에 유지
        ptStream.Append(gin[0]);
    }
    else  // 일반 파티클인 경우
    {
        // 수명이 다하지 않은 파티클만 유지
        if (gin[0].Age <= 1.0f)
            ptStream.Append(gin[0]);
        // 수명이 다한 파티클은 스트림에 추가하지 않음 (자동 소멸)
    }
}

셰이더 코드에선 랜덤 한 값을 생성할 수 없기 때문에 랜덤 데이터가 입력되어 있는 Random 텍스처를 활용하여 파티클의 초기 속도와 방향을 결정할 때 사용합니다.

폭발형 파티클 업데이트

폭발형 파티클은 InitParticleSystem_Bomb.hlsl에서 처리되며, 일반 파티클과 달리 한 번에 다수의 파티클을 생성합니다:

// InitParticleSystem_Bomb.hlsl
[maxvertexcount(31)]
void GS(point Particle gin[1], inout PointStream<Particle> ptStream)
{
    // 나이 증가
    gin[0].Age += gTimeStep;

    if (gin[0].Type == PT_EMITTER)
    {
        // 파티클 생성 활성화 확인
        if (gEndParticle > 0.0f)
        {
            // 1초가 지나면 폭발 시작
            if (gin[0].Age > 1.0f)
            {
                // 한 번에 30개 파티클 생성
                for (int i = 0; i < 30; i++)
                {
                    float3 vRandom = RandUnitVec3(i * 10.17f);
                    float speedRand = 4.0f + 2.0f * abs(vRandom.y);

                    Particle p;
                    p.InitialPosW = gEmitPosW;
                    p.InitialVelW = speedRand * vRandom;
                    p.SizeW = float2(3.0f, 3.0f);
                    p.Age = 0.0f;
                    p.Type = PT_FLARE;

                    ptStream.Append(p);
                }

                // 폭발 후 타이머 리셋
                gin[0].Age = 0.0f;
            }
        }

        // 에미터는 항상 유지
        ptStream.Append(gin[0]);
    }
    else
    {
        // 일반 파티클 유지 조건
        if (gin[0].Age <= 1.0f)
        {
            ptStream.Append(gin[0]);
        }
    }
}

파티클 렌더링 과정

파티클 렌더링은 RenderParticleSystem.hlsl에서 처리됩니다:

// RenderParticleSystem.hlsl - 버텍스 셰이더
VertexOut VS(Particle vin)
{
    VertexOut vout;

    float t = vin.Age;
    vout.PosW = 0.5f * t * t * gAccelW + t * vin.InitialVelW + vin.InitialPosW;

    float opacity = 1.0f - smoothstep(0.0f, 1.0f, t / 1.0f);
    vout.Color = float4(1.0f, 1.0f, 1.0f, opacity);

    vout.SizeW = vin.SizeW;
    vout.Type = vin.Type;

    return vout;
}

// 지오메트리 셰이더에서 점을 사각형으로 확장
[maxvertexcount(4)]
void GS(point VertexOut gin[1], inout TriangleStream<GeoOut> triStream)
{
    // 에미터는 렌더링하지 않음
    if (gin[0].Type != PT_EMITTER)
    {
        float4 worldPosW = mul(float4(gin[0].PosW, 1.0f), worldMatrix);
        
        // 카메라를 향하는 빌보드 행렬 계산
        float3 look = normalize(gEyePosW - worldPosW.xyz);
        float3 right = normalize(cross(float3(0, 1, 0), look));
        float3 up = cross(look, right);

        // 사각형 정점 계산
        float halfWidth = 0.5f * gin[0].SizeW.x;
        float halfHeight = 0.5f * gin[0].SizeW.y;

        float4 v[4];
        v[0] = float4(worldPosW.xyz + halfWidth * right - halfHeight * up, 1.0f);
        v[1] = float4(worldPosW.xyz + halfWidth * right + halfHeight * up, 1.0f);
        v[2] = float4(worldPosW.xyz - halfWidth * right - halfHeight * up, 1.0f);
        v[3] = float4(worldPosW.xyz - halfWidth * right + halfHeight * up, 1.0f);

        // 4개의 정점을 출력하여 사각형 생성
        GeoOut gout;
        [unroll]
        for (int i = 0; i < 4; ++i)
        {
            float4 viewPos = mul(v[i], gView);
            gout.PosH = mul(viewPos, gProj);
            gout.Tex = gQuadTexC[i];
            gout.Color = gin[0].Color;
            triStream.Append(gout);
        }
    }
}

// 픽셀 셰이더
float4 PS(GeoOut pin) : SV_TARGET
{
    float4 texColor = gTexArray.Sample(gSampler, pin.Tex) * pin.Color;
    return texColor * pin.Color;
}

InitParticleSystem.hlsl에서 생성된 정점 데이터를 사용하여 실제 파티클 렌더링을 실행합니다. 빌보드 렌더링 기법을 통해 2D인 파티클 입자들이 항상 카메라를 바라보게 하여 입체감 있는 효과를 표현합니다.

RenderPass에서 파티클 처리 방식

  • 초기 설정 및 데이터 준비

auto transformPtr = _transform.lock();
shared_ptr<ParticleSystem> particleComponent = transformPtr->GetGameObject()->GetComponent<ParticleSystem>();

shared_ptr<Shader> initParticleShader;

if (particleComponent->GetParticleType() == ParticleType::FLARE)
    initParticleShader = RESOURCE.GetResource<Shader>(L"InitParticle_Shader");
else
    initParticleShader = RESOURCE.GetResource<Shader>(L"InitParticleBomb_Shader");

먼저 파티클 시스템 컴포넌트를 가져오고, 파티클 타입에 따라 적절한 초기화 셰이더를 선택합니다. FLARE 타입은 일반적인 파티클 분출을, BOMB 타입은 폭발 효과를 위한 것입니다.

  • 파티클 시뮬레이션 데이터 설정

ParticleBuffer pBuffer;
pBuffer.gView = viewMat;
pBuffer.gProj = projMat;
pBuffer.gGameTime = TIME.GetTotalTime();
pBuffer.gTimeStep = TIME.GetDeltaTime() / 7.0f * particleComponent->GetSpeed();
pBuffer.gEndParticle = !particleComponent->GetEndParticleFlag();
pBuffer.gEyePosW = mainCamera->transform()->GetWorldPosition();
pBuffer.gEmitPosW = _emitPosW;
pBuffer.gEmitDirW = _emitDirW;

shared_ptr<Buffer> particleBuffer = make_shared<Buffer>();
particleBuffer->CreateConstantBuffer<ParticleBuffer>();
particleBuffer->CopyData(pBuffer);

파티클 시뮬레이션에 필요한 데이터를 ParticleBuffer 구조체에 설정합니다:

  • 뷰 및 프로젝션 행렬

  • 게임 시간과 시간 간격(델타 타임)

  • 파티클 생성 종료 여부

  • 카메라 위치

  • 파티클 방출 위치와 방향

gGameTime은 게임이 시작된 이후 경과한 총 시간(초 단위)을 나타냅니다. 이는 계속 증가하는 값으로, 게임 실행 중 누적된 절대 시간을 의미합니다. 이를 통해 셰이더에서 매 프레임마다 다른 랜덤 값을 얻어 사용할 수 있습니다.

gTimeStep은 이전 프레임과 현재 프레임 사이의 경과 시간(델타 타임)을 나타냅니다. 이 값을 통해 파티클 시뮬레이션 속도 제어를 위한 상대적 시간 간격을 설정할 수 있습니다.

  • 파티클 업데이트 단계 (Stream-Out)

// 파이프라인 상태 설정
DEVICECONTEXT->IASetInputLayout(initParticleShader->GetInputLayout()->GetInputLayout().Get());
DEVICECONTEXT->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_POINTLIST);

uint32 stride = sizeof(VertexParticle);
uint32 offset = 0;

// 입력 버퍼 선택 (첫 실행이냐 이후 실행이냐에 따라)
if (particleComponent->GetFirstRunFlag())
    DEVICECONTEXT->IASetVertexBuffers(0, 1, particleComponent->GetinitVB().GetAddressOf(), &stride, &offset);
else
    DEVICECONTEXT->IASetVertexBuffers(0, 1, particleComponent->GetDrawVB().GetAddressOf(), &stride, &offset);

// 셰이더 설정
DEVICECONTEXT->VSSetShader(initParticleShader->GetVertexShader().Get(), nullptr, 0);
DEVICECONTEXT->GSSetShader(initParticleShader->GetOutputStreamGeometryShader().Get(), nullptr, 0);
DEVICECONTEXT->PSSetShader(nullptr, nullptr, 0); // 픽셀 셰이더는 사용하지 않음

// Stream-Out 타겟 설정
DEVICECONTEXT->SOSetTargets(1, particleComponent->GetStreamOutVB().GetAddressOf(), &offset);

// 파티클 업데이트 실행
if (particleComponent->GetFirstRunFlag())
{
    DEVICECONTEXT->Draw(1, 0);
    particleComponent->SetFirstRunFlag(false);
}
else
{
    DEVICECONTEXT->DrawAuto();
}

// Stream-Out 타겟 해제
ID3D11Buffer* bufferArray[1] = { 0 };
DEVICECONTEXT->SOSetTargets(1, bufferArray, &offset);

// 버퍼 스왑
std::swap(particleComponent->GetDrawVB(), particleComponent->GetStreamOutVB());

이 단계는 파티클의 위치, 속도, 나이 등을 업데이트합니다:

  1. 첫 실행 시에는 _initVB(에미터 파티클 1개만 포함)를 입력으로 사용

  2. 이후 실행에서는 _drawVB(이전 프레임의 모든 파티클)를 입력으로 사용

  3. 지오메트리 셰이더는 Stream-Out 기능을 통해 업데이트된 파티클 데이터를 _streamOutVB에 기록

  4. Draw(1, 0) 또는 DrawAuto()를 호출하여 파티클 업데이트 실행

  5. 버퍼를 스왑하여 업데이트된 파티클 데이터를 다음 단계의 입력으로 사용

  • 파티클 렌더링 단계

// 렌더 상태 설정
shared_ptr<RasterizerState> rasterizerState = make_shared<RasterizerState>();
RasterizerStateInfo states;
states.fillMode = D3D11_FILL_SOLID;
states.cullMode = D3D11_CULL_BACK;
states.frontCounterClockwise = false;
rasterizerState->CreateRasterizerState(states);

shared_ptr<DepthStencilState> depthStencilState = make_shared<DepthStencilState>();
depthStencilState->SetDepthStencilState(_dsStateType);

shared_ptr<BlendState> blendState = make_shared<BlendState>();
blendState->CreateAdditiveBlendState(); // 가산 블렌딩 사용

// 파이프라인 상태 설정
DEVICECONTEXT->IASetVertexBuffers(0, 1, particleComponent->GetDrawVB().GetAddressOf(), &stride, &offset);
DEVICECONTEXT->IASetInputLayout(renderParticleShader->GetInputLayout()->GetInputLayout().Get());
DEVICECONTEXT->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_POINTLIST);

// 셰이더 설정
DEVICECONTEXT->VSSetShader(renderParticleShader->GetVertexShader().Get(), nullptr, 0);
DEVICECONTEXT->GSSetShader(renderParticleShader->GetGeometryShader().Get(), nullptr, 0);
DEVICECONTEXT->RSSetState(rasterizerState->GetRasterizerState().Get());
DEVICECONTEXT->PSSetShader(renderParticleShader->GetPixelShader().Get(), nullptr, 0);

// 셰이더 리소스 설정
shared_ptr<Texture> fireParticleTexture = _meshRenderer->GetMaterial()->GetTexture();
renderParticleShader->PushConstantBufferToShader(ShaderType::GEOMETRY_SHADER, L"ParticleBuffer", 1, particleBuffer);
renderParticleShader->PushConstantBufferToShader(ShaderType::GEOMETRY_SHADER, L"TransformBuffer", 1, transformPtr->GetTransformBuffer());
renderParticleShader->PushShaderResourceToShader(ShaderType::PIXEL_SHADER, L"gTexArray", 1, fireParticleTexture->GetShaderResourceView());

// 출력 병합 상태 설정
DEVICECONTEXT->OMSetBlendState(blendState->GetBlendState().Get(), nullptr, 0xFFFFFFFF);
DEVICECONTEXT->OMSetDepthStencilState(depthStencilState->GetDepthStecilState().Get(), 1);

// 파티클 그리기
DEVICECONTEXT->DrawAuto();

// 정리
renderParticleShader->ResetShaderResources();
DEVICECONTEXT->GSSetShader(nullptr, nullptr, 0);

이 단계는 업데이트된 파티클을 화면에 렌더링합니다:

  1. 렌더 상태(래스터라이저, 깊이/스텐실, 블렌드) 설정

  2. 파티클 데이터를 포함한 버퍼를 입력으로 설정

  3. 렌더링용 셰이더 설정

  4. 파티클 텍스처와 변환 정보를 셰이더에 전달

  5. 특히 가산 블렌딩(Additive Blending)을 사용하여 파티클의 빛 효과 구현

  6. DrawAuto()를 호출하여 파티클 그리기

주요 특징 및 최적화 요소

  1. GPU 기반 파티클 시뮬레이션:

    1. 파티클의 생성, 업데이트, 소멸이 모두 GPU에서 처리됨

    2. CPU는 초기 설정과 렌더링 명령만 담당하므로 부하 감소

  2. Stream-Out 기능 활용:

    1. DirectX의 Stream-Out 기능으로 셰이더 처리 결과를 버퍼에 직접 기록

    2. 이전 프레임의 결과가 다음 프레임의 입력으로 즉시 활용

  3. 핑퐁 버퍼링:

    1. _drawVB와 _streamOutVB 버퍼를 교대로 사용하여 데이터 흐름 최적화

    2. 매 프레임마다 버퍼를 스왑하여 효율적인 메모리 관리

  4. 파티클 타입별 최적화:

    1. FLARE 타입과 BOMB 타입에 따라 다른 셰이더 사용

    2. 각 타입에 맞는 파티클 행동 패턴 구현

  5. 빌보드 기법:

    1. 지오메트리 셰이더에서 포인트를 카메라 방향을 향하는 사각형으로 확장

    2. 이를 통해 3D 공간에서 효율적으로 2D 텍스처 표현

  6. 가산 블렌딩:

    1. blendState->CreateAdditiveBlendState() 호출로 파티클의 빛 효과 구현

    2. 여러 파티클이 겹칠 때 더 밝게 보이는 효과 생성

Last updated