멀티 패스 렌더링 (Multi-Pass Rendering)
기본적으로 멀티 패스 렌더링이란 하나의 장면(Scene)을 완성하기 위해 여러 단계(Pass)로 나누어 렌더링하는 기법을 말한다. 여기서 패스(Pass) 는 한 번의 DrawCall과 셰이더 처리 과정을 의미하며, 즉 DrawIndexed()나 Draw()호출 한 번이 하나의 패스가 된다.
단일 패스 렌더링에서는 일반적으로 다음과 같은 순서로 진행된다.
RTV(BackBuffer) Clear
DSV Clear
→ Render()
→ Present()
이 과정에서는 한 번의 패스로 장면 전체를 그리며, 렌더 타깃(Render Target)도 보통 백버퍼 하나만 사용한다. 하지만 멀티 패스 렌더링에서는 하나의 모델(또는 장면)을 여러 번 렌더링하며, 각 패스마다 다른 셰이더를 사용하거나 렌더 타깃(RTV) 을 변경하기도 한다. 또한 이전 패스의 결과물(텍스처나 버퍼)을 다음 패스의 입력으로 사용하여 조명, 그림자, 반사, 포스트 프로세싱 등의 시각 효과를 점진적으로 누적해 나간다. 예를 들어 다음과 같은 순서를 가질 수 있다:
[Pass 1] Geometry Pass → 기하 정보(Depth, Normal, Albedo 등)를 G-buffer에 저장
[Pass 2] Lighting Pass → G-buffer를 읽어 조명 계산
[Pass 3] Post-Processing → 블룸, 톤 매핑 등 화면 효과 적용
[Pass 4] Final Present → 최종 결과를 BackBuffer에 출력
이처럼 멀티 패스 렌더링은 렌더 타깃 전환(Render Target Switching)과 셰이더 조합(Shader Variation) 을 통해 단일 패스로는 구현하기 어려운 복잡한 시각 효과를 단계적으로 구현할 수 있게 해준다.
그림자 매핑 (Shadow Mapping)
그림자 매핑(Shadow Mapping)은 빛의 시점에서 본 깊이 정보를 텍스처(Shadow Map)에 저장한 뒤, 이후 조명 계산 시 해당 텍스처를 참조하여 그림자 여부를 판별하는 방식이다. 이 과정은 일반적으로 두 번의 패스(Pass) 로 이루어진다.
[Pass 1] : 빛의 시점(Light View) 에서 장면을 렌더링하여 깊이 정보를 텍스처로 저장
→ Shadow Map (Depth Texture)
[Pass 2] : 카메라 시점(Camera View) 에서 장면을 렌더링하면서, 픽셀마다 Shadow Map을 참조해 빛의 가림 여부를 판단
→ 최종 픽셀 색상 (조명 + 그림자 적용)
[Pass 1] Depth-only Pass : 그림자맵 생성

빛이 표면에 닿으면 그 뒤에있는 물체에는 빛이 더이상 닿지 않기 때문에 그림자가 진다. 이 방식은 깊이 테스트를 하는 방식과 동일하다. 그러므로 첫번째 패스에서는 그광원 시점에서의 깊이 테스트를 통해 그림자 맵(Shadow Map)을 만들어 낸다. 즉 그림자맵에는 광원 시점에서 가장 앞에있는 표면의 depth만 기록되는 것이다.
- 렌더링 대상 : 빛(Light)의 시점에서 본 장면
- view, projection 행렬 : 광원의 view와 projection 행렬
- 렌더 타겟 : 일반적인 BackBuffer가 아닌, Depth Texture (Shadow Map)
- 출력 내용 : 각 픽셀이 빛으로부터 얼마나 떨어져 있는지(Depth 값)
그림자맵의 저장과 활용을 위해서는 별도의 DSV(Depth Stencil View)와 SRV(ShaderResourceView)가 필요하다. 깊이 정보를 DSV에 기록하고, SRV로 바인딩하여 두번째 패스에서 그림자맵을 샘플링할 수 있도록 한다.
// Shadow map 해상도 (고해상도 추천)
const UINT SHADOW_WIDTH = 8192;
const UINT SHADOW_HEIGHT = 8192;
// viewport
viewport_shadowMap = {};
viewport_shadowMap.TopLeftX = 0;
viewport_shadowMap.TopLeftY = 0;
viewport_shadowMap.Width = (float)8192;
viewport_shadowMap.Height = (float)8192;
viewport_shadowMap.MinDepth = 0.0f;
viewport_shadowMap.MaxDepth = 1.0f;
// DSV, SRV
ID3D11Texture2D* shadowMap = nullptr;
ID3D11DepthStencilView* shadowDSV = nullptr;
ID3D11ShaderResourceView* shadowSRV = nullptr;
// create shadow map
D3D11_TEXTURE2D_DESC texDesc = {};
texDesc.Width = SHADOW_WIDTH;
texDesc.Height = SHADOW_HEIGHT;
texDesc.MipLevels = 1;
texDesc.ArraySize = 1;
texDesc.Format = DXGI_FORMAT_R32_TYPELESS;
texDesc.Usage = D3D11_USAGE_DEFAULT;
texDesc.BindFlags = D3D11_BIND_DEPTH_STENCIL | // 깊이값 기록 용도
D3D11_BIND_SHADER_RESOURCE; // 셰이더에서 텍스처 슬롯에 설정할 용도
texDesc.SampleDesc.Count = 1;
texDesc.SampleDesc.Quality = 0;
HRESULT hr = device->CreateTexture2D(&texDesc, nullptr, &shadowMap);
if (FAILED(hr)) { ... }
// create shadow DSV
D3D11_DEPTH_STENCIL_VIEW_DESC dsvDesc = {};
dsvDesc.Format = DXGI_FORMAT_D232_FLOAT;
dsvDesc.ViewDimension = D3D11_DSV_DIMENSION_TEXTURE2D;
hr = device->CreateDepthStencilView(shadowMap, &dsvDesc, &shadowDSV);
if (FAILED(hr)) { ... }
// create shadow SRV
D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc = {};
srvDesc.Format = DXGI_FORMAT_R32_FLOAT;
srvDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D;
srvDesc.Texture2D.MipLevels = 1;
hr = device->CreateShaderResourceView(shadowMap, &srvDesc, &shadowSRV);
if (FAILED(hr)) { ... }
광원의 view 행렬과 projection행렬은 카메라가 바라보는 임의의 위치 SceneCenter와 광원의 Direction을 통해 구할 수 있다. 상단의 사진을 보면 이해가 갈 것이다.
// Scene Center : 카메라가 바라보는 임의의 위치
Vector3 SceneCenter = cameroLootAtDir * dist;
// LightPos : shadow map 렌더링의 중심(SceneCenter)를 바라보도록 direction을 유지하며 만든 가상의 광원 위치
Vector3 LightPos = SceneCenter - LightDir * dist2;
// Light View : 광원 관점의 뷰 행렬
Matrix LightView = LookAtLH(LightPos, SceneCenter, Up);
// Light Projection: 광원 관점의 투영 행렬
Matrix lightProj = XMMatrixPerspectiveFovLH(fovY, aspect, nearZ, farZ);
광원 시점의 view, projection행렬이 준비되었다면 그림자맵 생성을 위한 VertexShader를 작성해야한다. shadowView와 shadowProjection를 사용해서 광원 시점의 Clip Space 기준의 position을 만들어낸다. 그러면 알아서 이 position의 z값으로 깊이 테스트를 진행하고 바인딩해둔 DSV에 깊이 정보가 기록된다. 이번 패스는 Depth Only Pass로 출력할 색상이 없으므로 PixelShader는 실행하지 않는다. 이러면 첫번째 패스의 목적인 Depth Texture가 만들어진다.
// ConstantBuffer
cbuffer TrnasformTransformCB : register(b0)
{
matrix view; // 카메라 view
matrix projection; // 카메라 projection
matrix shadowView; // 광원 view
matrix shadowProjection; // 광원 projection
}
// VertexShader
PS_INPUT main(VS_INPUT input)
{
PS_INPUT output = (PS_INPUT);
output.pos = input.position;
// 스키닝 생략
output.pos = mul(output.pos, world);
output.pos = mul(output.pos, shadowView); // 광읜 view 적용
output.pos = mul(output.pos, shadowProjection); // 광원 projection 적용
return output; // 광원 관점의 Clip Space Position -> Z-Test
}
// Pixel Shader
// 렌더 타겟에 기록할 RGBA가 없으므로 PixelShader는 실행하지 않는다.
// PS를 실행하지 않더라도 Z-test는 이루어져 DSV에 깊이 정보가 기록된다. (이번 패스는 이게 목적!)
// deviceContext->PSSetShader(NULL, NULL, 0);
[Pass 2] Render Pass : 그림자맵 샘플링 및 최종 픽셀 색상 결정

두번째 패스에서는 그림자맵을 샘플링하여 해당 픽셀에 광원이 닿는지 차단되는지 판단하고 최종 픽셀의 색상을 결정한다. 광원의 차단 여부는 두 깊이값의 비교를 통해 이루어지는데, 이때 비교하는 깊이값은 ‘그림자맵에 기록된 depth값’과 ‘현재 pixel의 광원관점에서의 depth값’이다. 둘은 같은 계산 과정을 따르지만 그림자맵에는 광원이 닿는 픽셀의 depth값만 기록되어있다. 만약 현재 픽셀의 depth값이 그림자맵의 depth값보다 크다면 자신보다 더 앞에있는 픽셀이 있었다는 뜻이고, 자신은 광원에게 빛을 받지 못해 Pass1에서 버려졌다는 것을 의미한다.
if(currentShadowDepth > shadowMapDepth)
{
// 광원이 차단되어 그림자가 그려진다.
directLighing = 0.0f;
}
else
{
// 광원이 닿는다.
}
이번 패스는 카메라 시점을 기준으로 렌더링을 하기 때문에 PS의 pixel값은 카메라에서 바라본 Scene의 한 픽셀이다. currentShadowDepth을 구하기 위해선 VertexShader에서 미리 광원 관점에서의 Clip Space position을 구해 PixelShader로 넘겨주어야 한다.
struct PS_INPUT
{
.. 생략
// 광원 관점에서의 clip space position
flaot4 posShadow : TECOORD1;
}
// VertexShader
PS_INPUT main(VS_INPUT input)
{
PS_INPUT output = (PS_INPUT);
output.pos = input.position;
// 스키닝 생략
// output.pos : SV_POSITION
// Camera Clip Space Position
output.pos = mul(output.pos, world);
output.posWolrd = output.pos;
output.pos = mul(output.pos, view);
output.pos = mul(output.pos, projection);
// Light Clip Space Position (따로 구해주어야한다)
output.posShadow = mul(output.posWolrd, shadowView);
output.posShadow = mul(output.posShadow, shadowProjection);
return output;
}
Pixel Shader도 마찬가지로 카메라 시점에서 실행되므로, 광원 시점에서 구한 posShadow의 depth값은 직접 구해주어야 한다. 그리고 posShadow의 x, y를 uv좌표로 정규화하여 그림자맵을 샘플링하여 텍스처에 저장된 depth값을 구한다. 이렇게 구한 currentShadowDepth와 shadowMapDepth를 비교하여 직접광(directLight)의 차단 여부를 판별할 수 있다. currentShadowDepth > shadowMapDepth이라면 직접광이 차단된 픽셀이기 때문에 shadowFactor= 0 (Specular, Lambert 적용 x), currentShadowDepth == shadowMapDepth이라면 광원 시점에서 가장 앞에있었던 픽셀이므로 shadowFactor= 1 이다.
Texture2D shadowMap: register(t6);
SamplerState samplerLinear : register(s0);
float4 main(PS_INPUTinput) : SV_TARGET
{
float shadowFactor = 1.0f;
// 1. Current Shadow Depth
float currentShadowDepth = itput.posShadow.z / input.posShadow.w;
// Shadow map UV (0~1)
float uv = input.posShadow.xy / input.posShadow.w; // -1 ~ 1
uv.y = -uv.y; // y반전
uv= uv * 0.5 + 0.5; // 0 ~ 1
// 2. ShadowMap Depth
if(uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0)
{
float shadowMapDepth = shadowMap.Sample(samplerLinear , uv).r;
// ⭐ 직접광 차단 여부 판별
if(currentShadowDepth > shadowMapDepth + 0.001)
{
shadowFactor = 0.0f; // 그림자 (빛 차단)
}
else
{
shadowFactor = 1.0f; // 빛이 닿음
}
}
...기타 텍스처맵핑 및 라이트 계산 생략
float final = (directLighting * shadowFactor) + ambientLighting + emissive;
return float4(final, opacity);
}
첫번째 패스와 두번째 패스를 모두 이해했다면 이제 스테이지를 셋팅하고 DrawCall을 호출하면 된다. 이때 주의할 점이 그림자맵 텍스처을 Pass1에서는 DSV로 쓰고 Pass2에서 SRV로 읽기 때문에 두 과정에서 충돌이 일어나지 않도록 렌더패스 이후에 언바인딩을 한번 진행해주어야한다.
// Model Render
// 1. Depth Only Pass
D3D::deviceContext->RSSetViewports(1, &D3D::viewport_shadowMap); // viewport binding
D3D::deviceContext->OMSetRenderTargets(0, nullptr, D3D::shadowDSV.Get());
D3D::deviceContext->IASetInputLayout(D3D::inputLayout_BoneWeightVertex.Get());
D3D::deviceContext->ClearDepthStencilView(D3D::shadowDSV.Get(), D3D11_CLEAR_DEPTH, 1.0f, 0);
D3D::deviceContext->VSSetShader(D3D::ShadowDepth_Skinned_VS.Get(), NULL, 0);
D3D::deviceContext->PSSetShader(nullptr, nullptr, 0);
warrior->Render();
enemy->Render();
D3D::deviceContext->IASetInputLayout(D3D::inputLayout_Vertex.Get());
D3D::deviceContext->VSSetShader(D3D::ShadowDepth_Static_VS.Get(), NULL, 0);
plane->Render();
// 2. Render Pass
D3D::deviceContext->RSSetViewports(1, &D3D::viewport_screen); // viewport binding
D3D::deviceContext->OMSetRenderTargets(1, D3D::renderTargetView.GetAddressOf(), D3D::depthStencilView.Get());
D3D::deviceContext->IASetInputLayout(D3D::inputLayout_BoneWeightVertex.Get());
D3D::deviceContext->VSSetShader(D3D::BaseLit_Skinned_VS.Get(), NULL, 0);
D3D::deviceContext->PSSetShader(D3D::BlinnPhong_PS.Get(), NULL, 0);
D3D::deviceContext->PSSetShaderResources(6, 1, D3D::shadowSRV.GetAddressOf());
warrior->Render();
enemy->Render();
D3D::deviceContext->IASetInputLayout(D3D::inputLayout_Vertex.Get());
D3D::deviceContext->VSSetShader(D3D::BaseLit_Static_VS.Get(), NULL, 0);
D3D::deviceContext->PSSetShaderResources(6, 1, D3D::shadowSRV.GetAddressOf());
plane->Render();
// 다음 shadowpass에서 SRV를 DSV로 다시 쓰기 위해 연결 해제
ID3D11ShaderResourceView* nullSRV[1] = { nullptr };
D3D::deviceContext->PSSetShaderResources(6, 1, nullSRV);
자세한 코드는 10_ShadowMapping 프로젝트에서 확인할 수 있다. 아직 부족한 부분이 있는 것 같다.
GitHub - wooj22/DirectX3D11_Study: DirectX3D11 공부용 레포지토리입니다.
DirectX3D11 공부용 레포지토리입니다. Contribute to wooj22/DirectX3D11_Study development by creating an account on GitHub.
github.com
'Programming > 컴퓨터그래픽스 (DX 11)' 카테고리의 다른 글
| [PBR] PBR이란 무엇인가? (1) | 2025.12.04 |
|---|---|
| [DirectX11] PCF방식을 통한 부드러운 그림자 매핑(Soft Shadow Mapping) (0) | 2025.11.11 |
| [Shader] 외곽선(OutLine) 패스 (0) | 2025.11.10 |
| [Shader] 툰 쉐이딩(Toon Shading)과 램프텍스처(RampTexture) (2) | 2025.11.10 |
| [DirectX 11] 3D 모델 로드하기 - Rigid Skeletal Mesh (0) | 2025.10.29 |