티스토리 뷰
개요
이번 시간에는 DirectX12를 활용해서 Deferred Shading을 하는 방법에 대해서 알아보겠습니다.
먼저 단어를 분석해보겠습니다.
- "Deferred"는 "지연된"이라는 의미입니다.
- "Shading"은 "음영 처리"라는 의미입니다. 컴퓨터 그래픽스 분야에서는 조명에 따라 픽셀을 어떤 색으로 칠할지 정하는 것을 의미합니다.
따라서 "Deferred Shading"은 말 그대로 "조명 계산을 지연시켜 나중에 처리하는 렌더링 방식"을 의미합니다.
Deferred Shading을 사용하는 이유는 기존 방식인 Forward Shading과 비교해서 장점이 있기 때문입니다.
어떤 장점이 있는지 알아보기 위해 Deferred Shading의 반대되는 개념인 Forward Shading을 먼저 알아봅시다.
Deferred Shading vs Forward Shading
Forward Shading은 각 오브젝트를 렌더링할 때 조명 계산까지 포함하여 한 번에 처리하는 방식입니다.
먼저 여러 개의 물체와 여러 개의 조명이 있는 경우를 생각해봅시다.
- Forward Shading에서는 물체 하나를 그릴 때 한 번의 렌더링 Pass가 돌아서 Vertex Shader와 Pixel Shader가 동작합니다.
- Pixel Shader에서는 모든 조명에 대해서 연산을 하고 해당 픽셀에 끼치는 영향을 합산해서 현재 픽셀의 색을 정합니다.
- 위 과정을 모든 물체에 대해서 반복합니다.
다만 위와 같이 조명 처리를 하게 되면, 다른 물체에 가려져서 화면에 보이지 않는 픽셀에 대해서도 조명 계산이 수행될 수 있기 때문에 비효율적입니다.
아래 예시 이미지에서 보이 듯이 9개의 물체를 그리기 위해서, 총 9번의 DrawCall이 있었고, 각 DrawCall에서 한 물체의 Shading까지 모두 끝냈습니다.

반면 Deferred Shading은 렌더링과 조명 처리를 분리하여 진행하는 방식입니다.
- 먼저 G-Buffer라는 일련의 텍스처에 Position, Normal, Color, Depth 등 오브젝트의 기초 정보를 렌더링합니다. 이 과정을 Base Pass라고 합니다.
- 이후 조명 계산은 G-Buffer를 입력으로 받아 Full-Screen Pass를 한 번만 돌려서 수행됩니다. 이 과정을 Lighting Pass라고 부릅니다.
이 방식으로 렌더링을 하면 다음과 같은 장점이 있습니다.
- 결론적으로 최종 화면에서 가려지는 픽셀은 이미 Base Pass에서 처리가 되기 때문에, 화면에 표시되는 픽셀에 대해서만 조명 연산을 수행하므로 불필요한 계산을 줄일 수 있습니다.
- 모든 물체의 조명 연산을 한 번의 Pixel Shader에서 처리하므로, GPU의 병렬 처리 성능을 극대화할 수 있습니다.
아래 예시 이미지를 보시면 Base Pass에서는 9번의 DrawCall을 통해 Position, Normal, Color 텍스쳐를 만들었고, Lighting Pass에서 한 번에 조명 연산을 끝낸 것을 볼 수 있습니다.
예제처럼 조명 연산이 크게 무겁지 않은 경우는 별 차이가 없을지 모르지만, 조명의 개수가 많고 계산식이 복잡한 경우에는 조명 계산하는 픽셀을 줄이는 것이 최적화에 많은 도움이 됩니다.


다만 Deferred Shading이 장점만 있는 것은 아닙니다.
- 일단 별도의 Texture들을 여러 장 만들고 유지해야 하기 때문에 Forward Shading에 비해 많은 VRAM이 필요합니다.
- Deferred Shading에서는 투명 물체 표현이 어렵습니다. 투명한 물체를 표현하려면 뒤쪽에 있는 물체의 색도 고려해야 하는데, G-Buffer는 일반적으로 최종적으로 그려지게 될 가장 앞쪽의 있는 물체의 정보만 저장하기 때문입니다.
구현
이제 본격적으로 Deferred Shading을 어떻게 구현하는지 알아보겠습니다.
아까도 언급했지만 Deferred Shading 구현은 일반적으로 다음과 같이 2단계로 구성됩니다:
- Base Pass: 각 오브젝트를 렌더링하면서 G-Buffer에 Position, Normal, Color, Depth 등의 정보를 저장합니다.
- Lighting Pass: G-Buffer를 입력으로 받아, 전체 화면을 대상으로 조명 연산을 수행합니다.
전체 과정을 요약해서 도식화 하면 아래와 같습니다.

Base Pass
Base Pass에서 신경써야 할 부분은 MRT(Multi Render Target)입니다.
Position Map에 Draw하고, Normal Map에 Draw하고, Color Map에 Draw를 하는 것도 가능하지만 기왕이면 한 번 Draw를 할 때 3개의 Map에 Draw를 하는 것이 효율적입니다.
MRT를 사용하는 방법을 간단하게 요약하면 아래와 같습니다.
- MRT로 사용할 Texture(Resource)를 만듭니다. device->CreateCommitedResource 함수를 사용하면 됩니다.
- Texture를 Render Target 용도로 사용하기 위해 Descriptor를 만듭니다. device->CreateRenderTargetView 함수를 사용하면 됩니다.
- PSO(Pipeline State Object)에 MRT 관련 정보를 넘겨줍니다. device->CreateGraphicsPipelineState 함수를 호출할 때 사용하는 psoDesc의 NumRenderTargets 값을 수정하고, 각 RTVFormats에 값을 설정해줍니다.
- Draw하기 전에 현재 Pass에서 어떤 Render Target에 그릴지 정합니다. commandList->OMSetRenderTargets 함수를 사용하면 됩니다.
- MRT로 사용할 Texture들의 Resource Barrier 상태를 적절히 설정해줍니다. Render Target용으로 쓰일 때와 Shader Resource로 쓰일 때 각각 다르게 설정합니다.
- Pixel Shader에서 MRT에 데이터를 쓸 수 있도록 내용을 정의해줍니다. PixelShader에 Outpue용 구조체를 정의하고 각 변수에 SV_TargetN 이라는 키워드를 추가해줍니다.
- 모든 내용이 준비되었다면 DrawCall을 합니다.
추가적으로 고려해야 할 부분은 여러 객체를 그릴 것이므로 객체별로 다른 Model Matrix를 보내주는 것입니다.
CBV를 여러 개 만들고 Root Paramter를 이용해서 데이터를 넘길 때 적절하게 설정해 주어야 합니다.
일단 예제에서는 물체의 개수만큼 Constant Buffer를 만들었습니다.
각 Constant Buffer에는 Model Matrix 정보를 담아두었습니다.
그리고 같은 수의 Constant Buffer View를 Heap에 생성했습니다.
Root Signature를 생성할 때는 Descriptor Table 타입의 Root Parameter를 하나만 만들었습니다.
그리고 Draw를 진행할 때 SetGraphicsRootDescriptorTable 함수를 통해서 해당 Root Parameter가 가리키는 Descriptor Heap의 위치를 매번 알맞은 위치로 설정해주는 방식을 통해 여러 개의 CBV에 담긴 Model Matrix 정보를 사용할 수 있도록 했습니다.
DrawInstanced Draw를 통해 같은 타입의 메쉬를 한 번에 그릴 수도 있지만 일단 현재 예제에서는 각각 Draw를 통해 그렸습니다.
Base Pass에서 쓰인 Vertex Shader와 Pixel Shader의 함수 내용은 아래와 같습니다.
PS_INPUT main(VS_INPUT IN)
{
PS_INPUT OUT;
float4 WorldPosition = mul(M, float4(IN.Position.xyz, 1.0f));
float4 ViewPosition = mul(V, float4(WorldPosition.xyz, 1.0f));
OUT.Position = mul(P, float4(ViewPosition.xyz, 1.0f));
OUT.Normal = float4(IN.Normal, 0.0f);
float4x4 normalMatrix = transpose(inverse(M));
OUT.WorldPosition = WorldPosition;
OUT.WorldNormal = mul(normalMatrix, float4(IN.Normal, 0.0f));
OUT.Color = float4(IN.Color, 0.0f);
return OUT;
}
Vertex Shader에서는 일단 물체를 랜더링하기 위해 입력 Position에 MVP를 곱해서 Clip Space에서의 Position을 구해 넘겼습니다.
추가적으로 Position에 Model Matrix만을 곱해서 WorldPosition을 만들어서 보냈고, Normal에 Model Matrix의 inverse의 transpose한 것을 보내서 WorldNormal을 보냈습니다.
Color 값은 변환없이 그대로 보냈습니다.
Position과 Normal을 넘길 때 World Space 값을 넘기든, View Space 값을 넘기든 상관은 없습니다.
다만, 나중에 있을 Lighting Pass에서 조명 처리를 할 때 쓰는 Light 정보, Eye Position 정보등과 Space를 맞춰 주기만 하면 됩니다.
PS_OUTPUT main(PS_INPUT IN) : SV_TARGET
{
PS_OUTPUT o;
o.BaseColor = IN.Color;
o.Position = IN.WorldPosition;
o.Normal = IN.WorldNormal;
return o;
}
Pixel Shader에서는 단순히 입력 받은 정보들을 넘기는 용도로 사용했습니다.
Graphics Pipeline을 구성할 때 3개의 Render Targets에 쓰도록 구성했으므로 3개에 Texture에 값이 넘어가게 됩니다.
아래 이미지는 모든 객체를 그리고 나서의 Position Map, Color Map, Normal Map입니다.

Depth Map도 사용하고 있었으므로 Depth Map도 그려집니다.
Lighting Pass
Lighting Pass는 별도의 Pass이기 때문에 별도의 Graphics Pipeline State Object, Root Signature, Vertex Shader, Pixel Shader가 필요합니다.
Draw 대상은 Post Processing를 구현할 때처럼 FullScreen Quad입니다.
Lighting Pass에서 따로 신경써야 할 부분은 G-Buffer를 Shader에 제대로 넘겨주는 것입니다.
기존에 만들었던 G-Buffer Texture들에 SRV(Shader Resource View)를 만들어서 연결해 주었습니다.
그리고 Root Paramter로 SRV들을 연결해서 Shader로 넘겨주었습니다.
Shader에서는 만들어진 G-Buffer에서 값들을 샘플링하고 Space를 맞춘 뒤 Lighting 연산을 진행하면 됩니다.
Lighting Pass에서 쓰인 Vertex Shader와 Pixel Shader의 함수 내용은 아래와 같습니다.
PS_INPUT main(VS_INPUT IN)
{
PS_INPUT OUT;
OUT.Position = float4(IN.Position, 1.0f);
OUT.UV = IN.UV;
return OUT;
}
Vertex Shader에서는 단순히 Position과 UV 값을 입력 받아 넘겼습니다.
FullScreenQuad를 그릴 것이므로 Position과 UV 값에 별도의 Transform 없이 그대로 넘겼습니다.
float4 main(PS_INPUT IN ) : SV_TARGET
{
float3 pos = positionMap.Sample(positionMapSampler, IN.UV.xy).rgb;
float3 normal = normalMap.Sample(normalMapSampler, IN.UV.xy).rgb;
float3 color = colorMap.Sample(colorMapSampler, IN.UV.xy).rgb;
normal = normalize(normal);
float3 lightDir = normalize(-LightDir.rgb);
float3 viewDir = normalize(EyePosition.xyz - pos);
float3 diffuse = max(dot(normal, lightDir), 0.0) * LightColor;
float3 halfWayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(normal, halfWayDir), 0.0f), 32.0f);
float3 specular = LightColor * spec;
float3 resColor = diffuse + specular;
return float4(resColor, 1.0f);
}
Pixel Shader에서는 Phong Shading 방식으로 조명 처리를 했습니다.
Directional Light가 하나 있다고 가정했고 가장 단순한 조명 처리를 했습니다.
일단 가장 먼저 positionMap, normalMap, colorMap으로부터 데이터를 얻었습니다.
그리고 Light 데이터와 EyePosition 데이터를 이용해서, lightDir, viewDir등을 구했습니다.
lightDir, viewDir, normal이 구해졌으므로 조명 처리를 위한 벡터는 모두 구한 셈입니다.
normal과 lightDir을 통해 diffuse 값을 구하고, lightDir과 viewDir을 통해서 specular 값을 구했습니다.
마지막으로는 diffuse 값과 specular 값을 더해서 최종적으로 그려질 색을 결정했습니다.
결론
이번 시간에는 Deferred Shading의 개념과 Forward Shading과의 차이점, 그리고 DirectX 12에서 구현 방식에 대해 살펴보았습니다.
Deferred Shading은 Unreal Engine을 포함한 최신 게임 엔진에서 기본적으로 채택하고 있는 방식입니다.
복잡한 장면에서 효율적인 조명 처리를 가능하게 해주며, 다양한 조명 조건에서도 성능을 유지하는 데 큰 도움이 됩니다.
Deferred Shading의 단점을 보완하거나 성능을 높이기 위한 기술들로는 다음과 같은 방법들이 있습니다:
- Tiled Deferred Lighting: 조명 처리를 타일 단위로 병렬화하여 성능을 향상시킴
- Clustered Deferred Shading: 타일보다 더 유연한 방식으로, 뷰 프러스텀을 클러스터로 나누어 조명을 효율적으로 관리
이번 포스팅에서는 다루지 않았지만 나중에 기회가 있다면 위 방식들도 더 진행해보도록 하겠습니다.
'프로그래밍 > DirectX12' 카테고리의 다른 글
| DirectX12로 Compute Shader 사용하기 (0) | 2025.08.12 |
|---|---|
| DirectX12를 활용한 Bloom 효과 (0) | 2025.03.30 |
| DirectX12를 활용한 Instanced Drawing (0) | 2025.03.03 |
| DirectX12에서의 CPU & GPU 동기화 (1) | 2025.01.26 |
| Microsoft PIX를 활용한 DirectX12 프로그램 디버깅 (0) | 2025.01.13 |
- Total
- Today
- Yesterday
- 유니티
- 순열
- MeshProcessing
- RubiksCube
- 경우의 수
- opengl
- 루빅스큐브
- 값 형식
- 수학
- value type
- VTK
- CollisionDetection
- Mesh Processing
- Unreal
- RL
- DirectX12
- 조합
- 통계학
- 중복 순열
- perspective projection
- C#
- Scriptable Render Pipeline
- AABB
- 최적화
- collision detection
- Mesh
- 참조 형식
- Unity
- normalized device coordinate
- 중복 조합
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 | 31 |