티스토리 뷰

이번 시간에는 DirectX12를 활용해서 PostProcessing을 하는 예제를 살펴보겠습니다.

 

여기서 Post란 "~이후에"를 뜻하고, Processing이란 "처리"를 뜻합니다, 따라서 합쳐서 "후처리"라고 표현을 합니다.

그래픽스에서는 보통 씬을 렌더링한 후 생성된 이미지에 별도 효과를 주는 것을 말합니다.

PostProcessing으로 줄 수 있는 효과는 매우 다양하지만 이번 시간에는 가장 기본적인 GrayScale에 대해서만 다루겠습니다.

 

먼저 예제에서 생성된 이미지는 아래와 같습니다.

원본 및 GrayScale 이미지

PostProcessing 개요

PostProcessing은 일반적으로 2개의 Pass를 사용합니다.

1) 3D World를 Texture에 렌더링하는 Pass

2) 만들어진 Texture를 input으로 별도 효과를 주어 BackBuffer에 렌더링하는 Pass

각 Pass 마다 별도의 VertexShader와 PixelShader를 사용하며, Rendering하는 Geometry또한 다릅니다.

 

1)번 Pass에서는 3D 공간에 를 렌더링하고

2)번 Pass에서는 삼각형 2개를 이용해 화면 전체를 채우는 FullScreenQuad를 랜더링합니다.

Mesh 예시

 

PostProcessing 효과는 보통 FullScreenQuad를 그리는 2)번 Pass의 PixelShader에서 구현하게 됩니다.

PixelShader에서 어떤 로직을 짜냐에 따라 여러 가지 효과가 나오게 됩니다.

 

과정을 대략적으로 살펴봤으니 이제 본격적으로 어떻게 구현을 하였는지 살펴보도록 하겠습니다.

 

3D World를 Texture에 렌더링하는 Pass (RenderToTexture)

3D World에 있는 구를 화면에 그리는 로직은 이미 구현했다고 가정합니다.

3D World에 있는 구를 그리는 로직을 간단하게 설명하자면 아래와 같습니다.

 

1) Sphere의 MVP(Model, View, Projection Matrix) 정보와 Lighting 정보를 Constant Buffer에 올려 둡니다.

2) Sphere Geometry 정보를 VertexBuffer와 IndexBuffer에 쌓아둔 뒤 Draw Command를 통해 렌더링합니다.

3) VertexShader에서는 입력 받은 Sphere의 Vertex들을 MVP를 이용해서 ClipSpace로 옮깁니다.

4) PixelShader에서는 넘어온 Pixel 정보들과 Lighting 정보를 이용해서 PhongShading을 해서 Pixel의 색을 정합니다.

 

이 과정에서 사용된 RootSignature를 도식화 하면 아래와 같습니다.

2개의 별도 ConstantBuffer를 사용할 예정이기 때문에 CBV(ConstantBufferView)를 2개 만들었고, ConstantBuffer만 사용할 예정이기에 ConstantBuffer를 담는 DescriptorRange(CBV)를 1개 만들어서 연결해 두었습니다. 

마지막으로는 DescriptorTable(CBV, SRV, UAV)을 하나 만들어서 DescriptorRange(CBV)를 연결해두었습니다.

DescriptorTable에는 CBV, SRV, UAV를 담는 타입과, Sampler를 담는 타입이 별도로 있는데, 현재 상태에서는 CBV만 사용하기 때문에 1개만 만들어 두었습니다.

 

RenderToTexture를 하기 위해서는 크게 3가지 과정이 필요합니다.

1) 렌더링 결과를 담아둘 Texture 리소스 생성

2) 해당 TextureRenderTarget으로 쓰일 예정이라고 설명해줄 RenderTargetView Descriptor Heap생성

3) 렌더링 코드에서 RenderTarget을 Backbuffer가 아닌 Texture로 설정

 

각 과정에 대한 자세한 내용을 살펴보도록 하겠습니다.

1) 가장 먼저 RenderTarget으로 쓰이게 될 Texture 리소스를 만듭니다.

ComPtr<ID3D12Resource> m_PostProcessRenderTarget;
D3D12_HEAP_PROPERTIES heapProperties;
heapProperties.CPUPageProperty = D3D12_CPU_PAGE_PROPERTY_UNKNOWN;
heapProperties.MemoryPoolPreference = D3D12_MEMORY_POOL_UNKNOWN;
heapProperties.Type = D3D12_HEAP_TYPE_DEFAULT;
heapProperties.CreationNodeMask = 1;
heapProperties.VisibleNodeMask = 1;

D3D12_RESOURCE_DESC renderTargetDesc = {};
renderTargetDesc.Width = GetClientWidth();
renderTargetDesc.Height = GetClientHeight();
renderTargetDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
renderTargetDesc.Dimension = D3D12_RESOURCE_DIMENSION_TEXTURE2D;
renderTargetDesc.MipLevels = 1;
renderTargetDesc.DepthOrArraySize = 1;
renderTargetDesc.SampleDesc.Count = 1;
renderTargetDesc.Flags = D3D12_RESOURCE_FLAG_ALLOW_RENDER_TARGET;

device->CreateCommittedResource(
    &heapProperties,
    D3D12_HEAP_FLAG_NONE,
    &renderTargetDesc,
    D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE,
    nullptr,
    IID_PPV_ARGS(&m_PostProcessRenderTarget)
);

Resource는 ComPtr<ID3D12Resource> 타입의 m_PostProcessRenderTarget 변수로 참조하도록 합니다.

 

2) 다음으로 RenderTargetView를 생성하기 위해 먼저 RenderTargetView용 힙을 생성합니다.

ComPtr<ID3D12DescriptorHeap> 타입의 변수 m_RTV_Heap을 만들어서 생성합니다.

ComPtr<ID3D12DescriptorHeap> m_RTV_Heap;
D3D12_DESCRIPTOR_HEAP_DESC rtvHeapDesc = {};
rtvHeapDesc.NumDescriptors = 1;
rtvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV;
rtvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE;
device->CreateDescriptorHeap(&rtvHeapDesc, IID_PPV_ARGS(&m_RTV_Heap));
D3D12_RENDER_TARGET_VIEW_DESC rtvDesc = {};
rtvDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
rtvDesc.ViewDimension = D3D12_RTV_DIMENSION_TEXTURE2D;
rtvDesc.Texture2D.MipSlice = 0;
rtvDesc.Texture2D.PlaneSlice = 0;
D3D12_CPU_DESCRIPTOR_HANDLE rtvHandle = m_RTV_Heap->GetCPUDescriptorHandleForHeapStart();
device->CreateRenderTargetView(m_PostProcessRenderTarget.Get(), &rtvDesc, rtvHandle);

DescriptorHeap이 만들어진 이후에는 RenderTargetView를 생성합니다. 

RenderTargetView가 가리키는 리소스로는 아까 만들어 두었던 m_PostProcessRenderTarget으로 설정합니다.

위와 같이 코드를 작성하면 m_RTV_Heap의 시작 위치에 RenderTargetView가 하나 생성되며, 해당 RenderTargetViewm_PostProcessRenderTarget를 가리키도록 됩니다.

 

코드를 작성 한 이후의 리소스를 도식화하면 아래와 같습니다.

Descriptor Heap에 RTV가 생성되었으며 Resource Heap에 Texture가 생성되었고, RTV가 이를 가리키도록 했습니다.

이 Texture는 현재 Shader에서 input으로 쓰이는 상태는 아니므로 RootSignature에서 참조하고 있지 않습니다.

 

3) 이제 남은 과정은 기존에 3D World를 렌더링하는 Pass에서 RenderTarget을 BackBuffer가 아닌 아까 만들어둔 Texture로 변경하는 것입니다.

D3D12_RESOURCE_BARRIER barrier = {};
barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
barrier.Transition.pResource = m_PostProcessRenderTarget.Get();
barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE;
barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_RENDER_TARGET;
barrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
commandList->ResourceBarrier(1, &barrier);

D3D12_CPU_DESCRIPTOR_HANDLE rtvCpuHandle = m_RTV_Heap->GetCPUDescriptorHandleForHeapStart();
commandList->OMSetRenderTargets(1, &rtvCpuHandle, FALSE, nullptr);

... Rendering 관련 코드 ...
... Draw 함수 ...

barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_RENDER_TARGET;
barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE;
commandList->ResourceBarrier(1, &barrier);

먼저 texture 리소스의 상태를 PIXEL_SHADER_RESOURCE에서 RENDER_TARGET으로 변경해줍니다.

그 후 OMSetRenderTargets 함수를 호출해서 Render Target을 Texture로 설정합니다.

마지막으로 texture 리소스의 상태를 RENDER_TARGET에서 PIXEL_SHADER_RESOURCE로 다시 변경해줍니다.

리소스의 상태를 이렇게 변경해주는 이유는 현재는 texture가 render target으로 쓰이지만, 앞으로 있을 Pass에서는 texture가 shader의 input으로 쓰일 예정이기 때문입니다.

 

이렇게 코드를 수정하고 프로그램을 돌려보면 까만 화면만 나오게 됩니다.

화면에 그려질게 텍스쳐에 그려지게 되므로 화면에는 아무 것도 안 보이게 되는 것입니다.

Texture에 별도 효과를 주어 화면에 렌더링하는 Pass

다음으로 해야할 것은 Texture에 그려진 것을 가져와서 화면에 렌더링하는 것입니다.

이 과정은 크게 4 단계로 나눌 수 있습니다. 

1) texture 리소스를 Shader 인풋으로 사용하기 위해 ShaderResourceView를 생성

2) texture를 sampling하기 위해 sampler 및 Heap을 생성

3) vertex shader와 pixel shader를 PostProcess용으로 변경

4) Draw 대상을 sphere아닌 FullScreenQuad로 변경

 

1) ShaderResourceView를 생성하는 코드는 아래와 같습니다.

D3D12_SHADER_RESOURCE_VIEW_DESC srvDesc = {};
srvDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
srvDesc.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D;
srvDesc.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING;
srvDesc.Texture2D.MostDetailedMip = 0;
srvDesc.Texture2D.MipLevels = 1;
srvDesc.Texture2D.PlaneSlice = 0;
srvDesc.Texture2D.ResourceMinLODClamp = 0.0f;
D3D12_CPU_DESCRIPTOR_HANDLE srvHandle = m_CBV_SRV_UAV_Heap->GetCPUDescriptorHandleForHeapStart();
preCBVNum = 2;
srvHandle.ptr += descriptorHandleIncrementSize * preCBVNum;
device->CreateShaderResourceView(m_PostProcessRenderTarget.Get(), &srvDesc, srvHandle);

m_CBV_SRV_UAV_Heap의 시작 위치로부터 2개 떨어진 위치에 SRV를 생성합니다.

이곳에 SRV를 생성하는 이유는 기존에 MVP와 Lighting 관련 정보를 담은 ConstantBufferView 2개가 이미 있기 때문입니다.

SRV가 가리키는 리소스로는 아까 RenderTarget 용으로 만들어둔 m_PostProcessRenderTarget으로 설정합니다.

일단 SRV를 만들기는 했으나, 해당 Texture를 Shader에서 사용할 수 있도록 하려면 RootSignature에 연결을 해주어야 합니다.

RootSignature에 포함하기 위해 DescriptorTable을 그대로 사용할 예정인데, DescriptorTable에는 CBV, SRV, UAV를 묶어 처리할 수 있는 타입과 Sampler를 처리하는 타입이 있습니다. 이번에 추가한 것은 SRV이므로 기존 CBV를 처리하는 DescriptorTable을 그대로 사용합니다.

다만, CBV와 SRV는 별도의 DescriptorRange를 사용해야 합니다.

CD3DX12_DESCRIPTOR_RANGE ranges[2];
ranges[0].Init(D3D12_DESCRIPTOR_RANGE_TYPE_CBV, 2, 0);
ranges[1].Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 1, 0);

CD3DX12_ROOT_PARAMETER rootParameters[1];
rootParameters[0].InitAsDescriptorTable(2, &ranges[0], D3D12_SHADER_VISIBILITY_ALL);

위와 같이 RootParameter를 수정하고 리소스의 상태를 도식화하면 아래와 같습니다.

RootSignature에 Texture를 연결해두었으므로 이제 Shader에서 Texture를 사용할 수 있게 되었습니다.

 

2) 그 다음에는 SamplerSampler를 담아두는 Heap을 만들어야 합니다.

D3D12_DESCRIPTOR_HEAP_DESC samplerHeapDesc = {};
samplerHeapDesc.NumDescriptors = 1;
samplerHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_SAMPLER;
samplerHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
device->CreateDescriptorHeap(&samplerHeapDesc, IID_PPV_ARGS(&m_Sampler_Heap));

먼저 SamplerHeap을 위와 같이 만들어둡니다.

여타 다른 타입의 Descriptor Heap을 만드는 과정과 별 차이가 없습니다.

D3D12_SAMPLER_DESC samplerDesc = {};
samplerDesc.Filter = D3D12_FILTER_MIN_MAG_MIP_LINEAR;
samplerDesc.AddressU = D3D12_TEXTURE_ADDRESS_MODE_WRAP;
samplerDesc.AddressV = D3D12_TEXTURE_ADDRESS_MODE_WRAP;
samplerDesc.AddressW = D3D12_TEXTURE_ADDRESS_MODE_WRAP;
samplerDesc.MipLODBias = 0.0f;
samplerDesc.MaxAnisotropy = 1;
samplerDesc.ComparisonFunc = D3D12_COMPARISON_FUNC_ALWAYS;
samplerDesc.BorderColor[0] = 0.0f;
samplerDesc.BorderColor[1] = 0.0f;
samplerDesc.BorderColor[2] = 0.0f;
samplerDesc.BorderColor[3] = 0.0f;
samplerDesc.MinLOD = 0.0f;
samplerDesc.MaxLOD = D3D12_FLOAT32_MAX;
D3D12_CPU_DESCRIPTOR_HANDLE samplerHandle = m_Sampler_Heap->GetCPUDescriptorHandleForHeapStart();
device->CreateSampler(&samplerDesc, samplerHandle);

그 다음으로는 아까 만든 SamplerHeap 위치에 Sampler를 만들어 줍니다.

Sampler 역시 Shader에서 사용해야 하므로 RootSignature에 연결해줍니다.

Sampler는 기존 CBV와 SRV를 담아두는 Table을 그대로 사용하면 안 되고 별도의 DescriptorTable을 만들어야 합니다.

DescriptorRange 역시 Sampler 용으로 하나 추가해주어야 합니다.

CD3DX12_DESCRIPTOR_RANGE ranges[3];
ranges[0].Init(D3D12_DESCRIPTOR_RANGE_TYPE_CBV, 2, 0);
ranges[1].Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 1, 0);
ranges[2].Init(D3D12_DESCRIPTOR_RANGE_TYPE_SAMPLER, 1, 0);

CD3DX12_ROOT_PARAMETER rootParameters[2];
rootParameters[0].InitAsDescriptorTable(2, &ranges[0], D3D12_SHADER_VISIBILITY_ALL);
rootParameters[1].InitAsDescriptorTable(1, &ranges[2], D3D12_SHADER_VISIBILITY_ALL);

위와 같이 코드를 수정한 후의 리소스 상태는 아래와 같습니다.

3) 리소스들은 전부 준비가 되었으므로 다음으로 할 일은 PostProcess용 VertexShaderPixelShader를 만드는 것입니다.

struct VS_INPUT
{
    float3 Position : POSITION;
    float3 Color    : COLOR;
    float3 Normal   : NORMAL;
    float2 UV       : UV;
};

struct PS_INPUT
{
	float4 Color    : COLOR;
    float4 Position : SV_Position;
    float4 Normal : NORMAL;
    float4 WorldPosition : TEXCOORD0;
    float2 UV : TEXCOORD1;
};

PS_INPUT main(VS_INPUT IN)
{
    PS_INPUT OUT;

    OUT.Position = float4(IN.Position, 1.0f);
    OUT.UV = IN.UV;
    
    return OUT;
}

먼저 VertexShader에서는 단순히 입력 받은 데이터를 PixelShader로 넘겨주기만 하면 됩니다.

Texture2D gTexture : register(t0);
SamplerState gSampler : register(s0);

struct PS_INPUT
{
    float4 Color : COLOR;
    float4 Position : SV_Position;
    float4 Normal : NORMAL;
    float4 WorldPosition : TEXCOORD0;
    float2 UV : TEXCOORD1;
};

float3 GrayScale(float2 uv)
{
    float4 res = gTexture.Sample(gSampler, uv);
    float gray = res.x * 0.299f + res.y * 0.587f + res.z * 0.114f;
    return float3(gray, gray, gray);
}

float4 main(PS_INPUT IN ) : SV_TARGET
{
    return float4(GrayScale(IN.UV), 1.0f);
}

PixelShader에서는 Vertex에서 전달 받은 UV 값을 이용해서 Texture를 Sampling한 후 RGB에 특정 상수를 곱해서 출력할 색깔을 정하면 됩니다.

아까 RootSignature에서 SRV와 Sampler를 연결해두었으므로 gTexturegSampler를 사용할 수 있게 된 것입니다.

 

이렇게 만들어진 Shader는 이따가 FullScreenQuad를 그리는 Pass의 VertexShaderPixelShader로 설정해두면 됩니다.

 

4) FullScreenQuad의 구성은 아래와 같습니다.

4개의 Vertex6개의 Index2개의 Triangle을 구성하였으며 각각 Pos 값과 UV 값은 위와 같습니다.

해당 데이터를 VertexBuffer와 IndexBuffer에 저장한 후 GPU에 업로드해주면 됩니다.

 

이제 모든 리소스가 준비 되었으므로 드디어 PostProcessing을 구현할 수 있게 되었습니다.

전체 과정을 간략하게 요약하면 아래와 같습니다.

1) Texture를 RenderTarget으로 설정

2) VertexShader와 PixelShader를 Sphere 그리기 용으로 설정

3) VertexBuffer와 IndexBuffer를 Sphere 데이터로 설정

4) MVP와 Lighting 정보를 업데이트

5) DrawCall

 

6) BackBuffer를 RenderTarget으로 설정

7) VertexShader와 PixelShader를 FullScreenQuad 그리기 용으로 설정

8) VertexBuffer와 IndexBuffer를 FullScreenQuad 데이터로 설정

9) DrawCall

 

지금까지 DirectX12를 활용해 PostProcessing을 구현하는 방법에 대해 알아봤습니다.

겉으로 보기에 별거 아닌 효과처럼 보이지만 생각보다 추가해야 하는 코드가 매우 많았습니다.

다만 GrayScale 자체를 구현하는데 사용된 코드는 별로 많지 않으므로, 한 번 구조를 만들어 두면 나중에 여러 종류의 PostProcessing 효과를 추가하는데는 크게 어려움이 없을 것으로 보입니다.

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
글 보관함