DirectX11

[DX11] 안개 구현: Render Path 분리 (Full Quad Rendering)

yeoul0714 2025. 4. 5. 02:09

1. 개요


이번엔 Fog를 구현하는 방법에 대해 알아보도록 하겠습니다.

아래는 Epic Games에서 제공하는 이미지입니다.

Fog를 이용하면 아주 자연스럽고 멋진 3D 세상을 구현할 수 있습니다.

Exponential Height Fog

 


 

2. Full Quad Rendering을 통한 구현


우선은 우리가 기본적으로 Rendering하던 방식에서 벗어나 화면을 덮는 Full Quad를 통해 전달해야 합니다.

그런데 어째서 Full Quad라는 매개체를 이용해야 하는 것일까요?

비유를 통해 그 이유를 알아보도록 합시다.

우리가 지금 쓰는 방식인 Forward Rendering은 각 객체들을 화면에 직접 그리는 방식입니다.

만약 안개를 그리고 싶다면 화면에 물체들과 안개를 같이 그려야 합니다.

그러나 이 방식보다는 우리가 그리고 싶은 물체를 우선적으로 그린 뒤 안개는 따로 그려서 겹치는 방식으로

 

그리는 것이 좀 더 효율적인 방식입니다.

상상해 보세요. 맑은 아크릴판에 풍경을 그린 후, 그 위에 반투명한 안개 무늬가 있는 두 번째 아크릴판을 올립니다.

이렇게 하면 전체 장면에 한 번에 일관된 안개 효과를 줄 수 있습니다.

 

이것이 바로 Full Quad Rendering의 원리입니다.

 

만약 물체마다 안개 효과를 계산하면 불필요하게 많은 연산이 발생할 수 있습니다.

 

대신 Full Quad를 사용하면, 렌더링된 장면 전체의 깊이 정보(Depth Buffer)를 활용하여

 

각 픽셀 단위로 안개 농도를 계산하게 되므로 GPU 연산이 단순화됩니다.  

 

또한 Post Processing 단계에서 안개를 적용하면

 

안개의 색상, 농도, 범위 등 다양한 파라미터를 실시간으로 쉽게 변경할 수 있습니다.

 


 

 

3. 개발 순서


3-1. SwapChain에서 가져온 Buffer로 RTV생성 - 최종 Rendering RTV

 

마지막에 그려질 Backbuffer로 Render Target View 만들기 (마지막에 그려질 RTV생성) 

 SwapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&BackBufferTexture);

 D3D11_RENDER_TARGET_VIEW_DESC FinalFrameRTVDesc = {};
 FinalFrameRTVDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM_SRGB; // 색상 포맷
 FinalFrameRTVDesc.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2D; // 2D 텍스처

 HRESULT hr = Device->CreateRenderTargetView(BackBufferTexture, &FinalFrameRTVDesc, &BackRTV);
 if (FAILED(hr))
 {  

     return ;
 }



3-2. SceneTexture, RTV, 그리고 SRV 생성

  • SceneTexture 생성:
    우리가 현재 Rendering 하는 세계를 담은 Texture를 만들어야 합니다.
    이 Texture에는 매 프레임 장면의 결과물이 저장될 버퍼 역할을 합니다.

  • Render Target View (RTV) 생성:
    SceneTexture와 연결된 RTV를 생성하여, 렌더링 파이프라인이 이 텍스처에 직접 장면을 렌더링하도록 설정합니다.

  • Shader Resource View (SRV) 생성:
    위에서 생성한 SceneTexture로부터 SRV를 생성합니다.
    이 SRV는 Post Processing에서(Full Quad를 그릴 때) Shader가 SceneTexture의 내용을 Sampling할 수 있도록 하는 역할을 합니다.

https://yeoul0714.tistory.com/12 (Shader Resource View 참고)

 

[DX11] Shader Resource View(SRV), Sampler에 대해서

SRV (Shader Resource View) - Pixel Shader에게 어떠한 텍스처를 보내줄 것인가? - 파이프라인 과정에서 픽셀 쉐이더에 어떠한 텍스처를 넘겨줄 것인가? 기본적으로 GPU는 Raw texture 데이터를 바로 읽지 못

yeoul0714.tistory.com

 

바로 우리가 현재 Render 하고 있는 세계를 Shader Resource View로 뽑아내야 합니다.

 

그렇게 해서 나온 SRV를 Quad Rendering 시에 Texture로 넘겨주도록 할 것입니다.

 

SceneRenderTexture 생성하고 (ID3D11Texture2D*) 그것을 이용해 Render Target View 만들기  

 - 우리가 Rendering하는 세계가 담길 SRV생성

D3D11_TEXTURE2D_DESC texDesc = {};
texDesc.Width = ScreenWidth;
texDesc.Height = ScreenHeight;
texDesc.MipLevels = 1;
texDesc.ArraySize = 1;
texDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;  // 색상 포맷
texDesc.SampleDesc.Count = 1;
texDesc.Usage = D3D11_USAGE_DEFAULT;
texDesc.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE;
Device->CreateTexture2D(&texDesc, nullptr, &SceneRenderTexture);

texDesc.Format = DXGI_FORMAT_R32_TYPELESS;  // 깊이 포맷
texDesc.BindFlags = D3D11_BIND_DEPTH_STENCIL | D3D11_BIND_SHADER_RESOURCE;
Device->CreateTexture2D(&texDesc, nullptr, &SceneRenderTexture);

Device->CreateRenderTargetView(SceneRenderTexture, nullptr, &SceneRTV);

// 셰이더 리소스 뷰 생성 (색상)
Device->CreateShaderResourceView(SceneRenderTexture, nullptr, &SceneSRV);

 

기본적으로 Render Target View(출력 대상) Shader Resource View(무엇을 출력할지)를 만들려면 ID3D11Texture2D가 필요합니다.

 

3-3. Depth Stencil View, Depth Stencil SRV 생성

  • Depth Stencil Texture 생성: 3D 장면의 깊이 정보를 저장하는 전용 텍스처를 생성합니다. 이 텍스처는 각 픽셀의 카메라로부터의 거리(깊이) 정보를 저장하는 버퍼 역할을 합니다.

  • Depth Stencil View (DSV) 생성: Depth Stencil Texture와 연결된 DSV를 생성하여, 렌더링 파이프라인이 3D 장면을 그릴 때 깊이 정보를 이 텍스처에 기록하고 깊이 테스트를 수행할 수 있도록 설정합니다.

  • Depth Stencil SRV 생성: 위에서 생성한 Depth Stencil Texture로부터 SRV를 생성합니다. 이 SRV는 Post Processing 단계에서(Full Quad를 그릴 때) 셰이더가 DSSRV에 접근하여 안개등의 깊이 기반 효과를 계산할 수 있도록 하는 역할을 합니다.

3-4. Binding 해주기, 필요한 것들 생성(Shader)

DeviceContext->OMSetRenderTargets(1, &SceneRTV, DepthStencilView);

이런식으로 Render Target View, DepthStencilView를 Binding 해준다.

 

그리고 또 중요한 점은 이렇게 생성된 아이들을 마지막에 Quad에 그려줄 때에도 새로운 Shader가 필요하다는 것이다.

 

아래의 함수를 실행해서 Quad Shader를 생성해 준다.

void FRenderer::CreateQuadShader()
{
    // 1. 쉐이더 컴파일
    ID3DBlob* VertexShaderCSO = nullptr;
    ID3DBlob* PixelShaderCSO = nullptr;
    HRESULT hr;

    // 버텍스 쉐이더 컴파일
    hr = D3DCompileFromFile(L"Shaders/QuadVertexShader.hlsl", nullptr, nullptr, "VS_Main", "vs_5_0", 0, 0, &VertexShaderCSO, nullptr);
    if (FAILED(hr))
    {
        Console::GetInstance().AddLog(LogLevel::Warning, "Quad VertexShader Compilation Error");
        return;
    }

    // 버텍스 쉐이더 생성
    hr = Graphics->Device->CreateVertexShader(
        VertexShaderCSO->GetBufferPointer(),
        VertexShaderCSO->GetBufferSize(),
        nullptr,
        &QuadVertexShader
    );
    if (FAILED(hr))
    {
        Console::GetInstance().AddLog(LogLevel::Warning, "Quad VertexShader Creation Error");
        VertexShaderCSO->Release();
        return;
    }

    // 픽셀 쉐이더 컴파일
    hr = D3DCompileFromFile(L"Shaders/QuadPixelShader.hlsl", nullptr, nullptr, "PS_Main", "ps_5_0", 0, 0, &PixelShaderCSO, nullptr);
    if (FAILED(hr))
    {
        Console::GetInstance().AddLog(LogLevel::Warning, "Quad PixelShader Compilation Error");
        VertexShaderCSO->Release();
        return;
    }

    // 픽셀 쉐이더 생성
    hr = Graphics->Device->CreatePixelShader(
        PixelShaderCSO->GetBufferPointer(),
        PixelShaderCSO->GetBufferSize(),
        nullptr,
        &QuadPixelShader
    );
    if (FAILED(hr))
    {
        Console::GetInstance().AddLog(LogLevel::Warning, "Quad PixelShader Creation Error");
        VertexShaderCSO->Release();
        PixelShaderCSO->Release();
        return;
    }

    // 입력 레이아웃 생성
    D3D11_INPUT_ELEMENT_DESC layout[] = {
        {"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0},
        {"COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0},
        {"NORMAL", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 28, D3D11_INPUT_PER_VERTEX_DATA, 0},
        {"TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 40, D3D11_INPUT_PER_VERTEX_DATA, 0},
        {"BLENDINDICES", 0, DXGI_FORMAT_R32_UINT, 0, 48, D3D11_INPUT_PER_VERTEX_DATA, 0}
    };

    hr = Graphics->Device->CreateInputLayout(
        layout,
        ARRAYSIZE(layout),
        VertexShaderCSO->GetBufferPointer(),
        VertexShaderCSO->GetBufferSize(),
        &QuadInputLayout
    );
    if (FAILED(hr))
    {
        Console::GetInstance().AddLog(LogLevel::Warning, "Quad InputLayout Creation Error");
        VertexShaderCSO->Release();
        PixelShaderCSO->Release();
        return;
    }

    // 리소스 해제
    VertexShaderCSO->Release();
    PixelShaderCSO->Release();
}

 

Quad Shader는 Texture로 앞선 Render에서 넘어온 Shader Resource View, Depth Stencil SRV를 받는다.

 

아래는 Quad Pixel Shader 코드이다. 

 

당장 볼 곳은 Texture2D SceneTexture, Texture2D DepthTexture이다. 

 

여기서 Texture가 위에서 우리가 만들어준 SRV, DSSRV이다. (이곳엔 우리가 기존에 Rendering하던 World와 그 깊이값이 담겨있다.)

 

Render모드에 따라서 SRV를 이용해 일반적인 화면을 그리기도 하고

 

DSSRV를 이용해서 Depth View를 그리기도 한다.

// 입력 구조체
struct VS_OUTPUT
{
    float4 Position : SV_POSITION;
    float2 TexCoord : TEXCOORD0;
};

// 텍스처 및 샘플러
Texture2D SceneTexture : register(t0);
Texture2D DepthTexture : register(t1);
SamplerState SampleType : register(s0);

// 상수 버퍼 추가
cbuffer RenderModeBuffer : register(b0)
{
    int RenderMode; // 0: 일반 모드, 1: 깊이 뷰 모드
    float NearPlane; // 근거리 평면
    float FarPlane; // 원거리 평면
    float Padding; // 16바이트 정렬을 위한 패딩
};

// 픽셀 쉐이더 진입점
float4 PS_Main(VS_OUTPUT input) : SV_TARGET
{
    // 깊이 텍스처에서 깊이 값 샘플링
    float depth = DepthTexture.Sample(SampleType, input.TexCoord).r;
    
      
    // 하드웨어 깊이값 선형화
    float linearDepth = (2.0 * NearPlane * FarPlane) /
                       (FarPlane + NearPlane - depth * (FarPlane - NearPlane));
    
    // 0~1 범위로 정규화 후 클램핑
    float normalizedDepth = saturate((linearDepth - NearPlane) / (FarPlane - NearPlane));
    
    // 깊이 뷰 모드인 경우
    if (RenderMode == 1)
    {
        return float4(normalizedDepth, normalizedDepth, normalizedDepth, 1.0);
    }
    
    // 일반 모드일 경우
    // 씬 텍스처에서 색상 샘플링
    float4 color = SceneTexture.Sample(SampleType, input.TexCoord);
    
   
    // 안개 효과 적용
    return ApplyFog(color, depth, input.TexCoord);
}

 


 

4. 전체적인 Rendering 흐름


 

우선 if 문은 Multi View Port를 구분 해 주는 코드이고 기본적으로 지금은 단일 view port이기 때문에

 

else에 있는 Render만 보면 되겠다.

 

else에 있는  render는 우리가 1번에서 만들어줬던 RTV에 Rendering 되고 그 정보는

 

우리가 만들어 주었던 SRV에 저장된다.

동시에, 같은 렌더링 과정에서 3D 장면의 깊이 정보는 DepthStencilView(DSV)에 자동으로 저장됩니다.

 

이 깊이 정보는 원래 렌더링 과정에서 깊이 테스트를 위해 사용되지만,

 

우리가 만든 Depth Stencil SRV를 통해 나중에 post process 단계에서도 접근할 수 있게 됩니다.

 

렌더링이 완료된 후, 색상 정보(SRV)깊이 정보(DSSRV) 모두 Quad Shader에 전달되어

 

최종적인 화면 효과를 적용하는 데 사용됩니다.

 
void FEngineLoop::Render()
{
    graphicDevice.Prepare();

    if (LevelEditor->IsMultiViewport())
    {
        std::shared_ptr<FEditorViewportClient> viewportClient = GetLevelEditor()->GetActiveViewportClient();
        for (int i = 0; i < 4; ++i)
        {
            LevelEditor->SetViewportClient(i);
            renderer.PrepareRender(GLevel);
            renderer.Render(GetLevel(),LevelEditor->GetActiveViewportClient());
        }
        GetLevelEditor()->SetViewportClient(viewportClient);
    }
    else
    {
        renderer.PrepareRender(GLevel);
        renderer.Render(GetLevel(),LevelEditor->GetActiveViewportClient());
    }

    renderer.RenderToBackBuffer(LevelEditor->GetActiveViewportClient());
}

 

 

 

저장된 아이들은 RenderToBackBuffer에서 전부 그려지게 된다. (Post Processing)

 

코드를 보면 우선 RTV는 우리가 0번에서 만들어준 Backbuffer로 설정해 주고 있다.

 

그리고 PSSetShaderResources를 통해서 우리가 Render 한 내용이 저장되어 있는 SceneSRV, DepthSRV

Binding 해주고 있다.

그리고 Shader도 Set 해주고 Quad 그리는데 필요한 정점, 인덱스 정보를 넘겨서 Rendering을 하고 있다

void FRenderer::RenderToBackBuffer(std::shared_ptr<FEditorViewportClient> ActiveViewport)
{
    // 백버퍼를 렌더 타겟으로 설정
    Graphics->DeviceContext->OMSetRenderTargets(1, &Graphics->BackRTV, nullptr);

    // 렌더 모드를 constant buffer를 통해 픽셀 쉐이더에 전달
    FRenderModeConstants cbData;
   
    cbData.NearPlane = 0.1f; // 실제 Near Plane 값으로 수정
    cbData.FarPlane = 100.f; // 실제 Far Plane 값으로 수정
    cbData.Padding = 0.0f;

    if (ActiveViewport->GetShowFlag() & static_cast<uint64>(EEngineShowFlags::Scene_Depth))
    {
        cbData.RenderMode = 1;
        Graphics->DeviceContext->PSSetShaderResources(1, 1, &Graphics->DepthSRV);
    }
    else
    {
        cbData.RenderMode = 0;
        Graphics->DeviceContext->PSSetShaderResources(0, 1, &Graphics->SceneSRV);
        Graphics->DeviceContext->PSSetShaderResources(1, 1, &Graphics->DepthSRV);
    }
      
  
    D3D11_MAPPED_SUBRESOURCE mappedResource;
    Graphics->DeviceContext->Map(RenderModeConstantBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &mappedResource);
    memcpy(mappedResource.pData, &cbData, sizeof(FRenderModeConstants));
    Graphics->DeviceContext->Unmap(RenderModeConstantBuffer, 0);
  
    Graphics->DeviceContext->PSSetConstantBuffers(0, 1, &RenderModeConstantBuffer);

    // 쉐이더 설정
    Graphics->DeviceContext->VSSetShader(QuadVertexShader, nullptr, 0);
    Graphics->DeviceContext->PSSetShader(QuadPixelShader, nullptr, 0);

    // 입력 레이아웃 설정
    Graphics->DeviceContext->IASetInputLayout(QuadInputLayout);

    // 정점 및 인덱스 버퍼 설정
    UINT stride = sizeof(FVertexSimple);
    UINT offset = 0;
    Graphics->DeviceContext->IASetVertexBuffers(0, 1, &QuadVertexBuffer, &stride, &offset);
    Graphics->DeviceContext->IASetIndexBuffer(QuadIndexBuffer, DXGI_FORMAT_R32_UINT, 0);
    Graphics->DeviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);

    // Full Screen Quad 그리기
    Graphics->DeviceContext->DrawIndexed(6, 0, 0);
}

 

여기까지 하면 우리는 Quad를 통해서 Rendering 하고 있는 것이 된다!!

굉장히 복잡하지만.. 사실 바로 그리던 것을 Quad를 한 번 더 통과하고 있을 뿐입니다.

원래 Back Buffer로 바로 갈 내용을 SRV, DSSRV에 담고 그것을 이용해서 Quad를 그리는 방식이라고 생각하시면 됩니다.

 

이렇게 SRVDSSRV 로 받아야 Quad Shader에서 Post Processing이 가능해집니다.

 

특히 안개와 같은 효과는 깊이 정보가 필수적인데, DSSRV를 통해 깊이 정보에 접근함으로써

 

거리에 따른 안개 농도 변화나 높이에 따른 안개 효과 등을 계산할 수 있습니다.

 

Post Process의 핵심은 3D 장면을 직접 화면에 그리는 대신 중간 단계의 텍스처에 그린 후

 

그 결과물을 사용하여 추가적인 효과를 적용할 수 있다는 점입니다.

 

색상 정보와 함께 깊이 정보까지 활용함으로써 훨씬 더 풍부하고 사실적인 효과를 구현할 수 있게 됩니다.

 

모르시는 용어는 개념을 확실히 하시면서 읽으시면 이해가 빠르실 것이라 생각됩니다.