← 개념 · 데모 가이드 | 구현 허브 | 6장. 고급 렌더링 | 섀도우 맵 (Shadow Mapping)
IMPLEMENTATION · CHAPTER 6

섀도우 맵 (Shadow Mapping) — 한 줄 한 줄 직접 구현

그림자는 장면에 깊이감과 공간감을 주는 가장 강력한 시각 단서입니다. 그리고 그림자를 만드는 가장 보편적인 실시간 기법이 바로 섀도우 맵(shadow mapping)입니다. 아이디어는 놀랄 만큼 단순합니다 — 빛의 눈으로 세상을 한 번 바라보고, "빛이 처음 부딪히는 거리"를 텍스처에 저장합니다. 그다음 본 렌더에서 각 픽셀이 그 저장된 거리보다 멀리 있으면, 그 픽셀은 빛에게 가려진 것이므로 그림자입니다. 이 페이지에서는 원본 예제의 C++(Direct3D 11)와 HLSL 코드를 한 줄, 한 성분씩 해부하면서, 수학 원리 → 기하 직관 → 코드 매핑의 3단으로 처음부터 끝까지 따라갑니다.

규약 이 페이지 전체에서 행벡터 규약을 씁니다: 벡터를 행렬 왼쪽에 두고 v' = v * M, HLSL에서는 mul(v, M). 행렬은 행 우선(row-major)으로 저장하고, 왼손 좌표계(LH), 클립 공간 z는 [0,1] 범위(OpenGL의 [-1,1]이 아님), 텍스처 V축은 위→아래입니다. 실무에서는 손으로 짠 행렬 대신 DirectXMath(검증된 SIMD 라이브러리)를 쓰는 것을 강력히 권장합니다 — 이 페이지의 손계산은 어디까지나 내부에서 무슨 일이 일어나는지 이해하기 위한 학습용입니다.

0. 큰 그림 — 왜 두 번 그리는가

화면에 그림자를 찍기 위해, 섀도우 맵은 장면을 두 번 렌더합니다. 낭비처럼 보이지만, 첫 번째 렌더는 색을 전혀 계산하지 않고 깊이만 기록하므로 매우 가볍습니다.

  1. 깊이 전용 타깃을 만든다. 하나의 텍스처를 DepthStencilView(쓰기용)로도, ShaderResourceView(읽기용)로도 사용합니다. 컬러 타깃은 아예 붙이지 않습니다.
  2. 1패스 — 광원 시점 깊이 렌더. 방향광은 광선이 평행하므로 직교 투영(orthographic)으로 light-space 행렬 lightView * lightProj을 만들고, 그 시점에서 장면을 그려 "빛이 처음 닿는 거리"를 깊이맵에 남깁니다.
  3. 2패스 — 본 렌더. 각 픽셀의 월드 좌표를 광원 공간으로 변환해 [0,1] 텍스처 좌표로 바꾸고, 저장된 깊이와 현재 깊이를 비교 샘플러(SamplerComparisonState)로 비교합니다. 여기에 작은 bias를 더해 자기그림자 줄무늬를 막습니다.
  4. 가장자리를 부드럽게. 한 텍셀 단위로 딱딱하게 끊기는 계단(aliasing)을 없애려고 PCF(Percentage-Closer Filtering, 3×3 평균)로 여러 이웃 텍셀의 비교 결과를 평균 냅니다.

핵심 통찰 하나를 먼저 못 박아 둡시다. 섀도우 맵은 결국 "거리 비교"입니다. 깊이맵에 저장된 값은 광원에서 가장 가까운 표면까지의 거리이고, 본 렌더에서 우리가 들고 있는 값은 지금 그리는 이 픽셀까지의 거리입니다. 이 둘을 같은 좌표계(광원 공간)에서 비교해서, 지금 픽셀이 더 멀면 그 앞에 뭔가가 있다는 뜻이니 그림자입니다.

1. 1패스 준비 — 깊이 텍스처와 비교 샘플러 만들기

첫 번째 코드 블록은 순수하게 리소스 준비입니다. GPU에게 "이런 텍스처 하나 만들고, 그걸 깊이로 쓸 뷰, 셰이더에서 읽을 뷰, 그리고 깊이끼리 비교하는 특수한 샘플러를 준비해 줘"라고 요청합니다. 여기서 가장 낯선 개념이 같은 텍스처를 두 얼굴(DSV/SRV)로 쓰기 위한 TYPELESS 포맷비교 샘플러입니다. 코드 먼저 보고 한 줄씩 뜯어봅니다.

shadow_target.cppcpp
const UINT SHADOW_W = 2048, SHADOW_H = 2048;
using Microsoft::WRL::ComPtr;

// 깊이 텍스처는 DSV와 SRV 두 뷰로 쓰므로 TYPELESS 포맷으로 생성
ComPtr<ID3D11Texture2D>           depthTex;
ComPtr<ID3D11DepthStencilView>    depthDSV;
ComPtr<ID3D11ShaderResourceView>  depthSRV;

D3D11_TEXTURE2D_DESC td = {};
td.Width = SHADOW_W; td.Height = SHADOW_H;
td.MipLevels = 1; td.ArraySize = 1;
td.Format = DXGI_FORMAT_R24G8_TYPELESS;   // DSV/SRV가 각기 다른 포맷으로 해석
td.SampleDesc.Count = 1;
td.Usage = D3D11_USAGE_DEFAULT;
td.BindFlags = D3D11_BIND_DEPTH_STENCIL | D3D11_BIND_SHADER_RESOURCE;
device->CreateTexture2D(&td, nullptr, &depthTex);

D3D11_DEPTH_STENCIL_VIEW_DESC dsvd = {};
dsvd.Format        = DXGI_FORMAT_D24_UNORM_S8_UINT;   // 깊이로 기록
dsvd.ViewDimension = D3D11_DSV_DIMENSION_TEXTURE2D;
device->CreateDepthStencilView(depthTex.Get(), &dsvd, &depthDSV);

D3D11_SHADER_RESOURCE_VIEW_DESC srvd = {};
srvd.Format        = DXGI_FORMAT_R24_UNORM_X8_TYPELESS; // 깊이로 읽기
srvd.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D;
srvd.Texture2D.MipLevels = 1;
device->CreateShaderResourceView(depthTex.Get(), &srvd, &depthSRV);

// 비교 샘플러: SampleCmp에서 저장 깊이와 참조 깊이를 비교(PCF는 HW 보간)
ComPtr<ID3D11SamplerState> shadowSampler;
D3D11_SAMPLER_DESC ssd = {};
ssd.Filter         = D3D11_FILTER_COMPARISON_MIN_MAG_LINEAR_MIP_POINT;
ssd.AddressU = ssd.AddressV = ssd.AddressW = D3D11_TEXTURE_ADDRESS_BORDER;
ssd.ComparisonFunc = D3D11_COMPARISON_LESS_EQUAL;
float border[4] = { 1, 1, 1, 1 };   // 맵 밖은 "그림자 아님(깊이 1.0)"
memcpy(ssd.BorderColor, border, sizeof(border));
device->CreateSamplerState(&ssd, &shadowSampler);

// --- 1패스: 광원 시점 깊이 렌더 ---
using namespace DirectX;
// DirectXMath는 행벡터: model*view*proj 순서. 직교 투영은 LH 사용
XMMATRIX lightProj = XMMatrixOrthographicOffCenterLH(-10, 10, -10, 10, 1.0f, 25.f);
XMMATRIX lightView = XMMatrixLookAtLH(lightPos, XMVectorZero(), XMVectorSet(0,1,0,0));
XMMATRIX lightSpace = lightView * lightProj;   // 행벡터 곱 순서

D3D11_VIEWPORT vp = { 0, 0, (float)SHADOW_W, (float)SHADOW_H, 0.0f, 1.0f };
ctx->RSSetViewports(1, &vp);
ctx->OMSetRenderTargets(0, nullptr, depthDSV.Get());   // 컬러 출력 없음
ctx->ClearDepthStencilView(depthDSV.Get(), D3D11_CLEAR_DEPTH, 1.0f, 0);
ctx->RSSetState(cullFrontRS.Get());   // 앞면 컬링 → peter-panning 완화 트릭
renderSceneDepthOnly(lightSpace);
ctx->RSSetState(cullBackRS.Get());
// --- 2패스: 본 렌더 (lightSpace 상수, depthSRV/비교 샘플러를 셰이더로 넘김) ---

1-1. 해상도 상수와 ComPtr

const UINT SHADOW_W = 2048, SHADOW_H = 2048; — 섀도우 맵의 크기를 2048×2048로 정합니다. UINT는 부호 없는 32비트 정수(unsigned int)로, Direct3D API가 크기·개수를 이걸로 받기 때문에 맞춰 줍니다. 이 해상도가 곧 그림자의 정밀도입니다. 화면에서 그림자 경계가 뭉개져 보인다면 가장 먼저 이 값을 올려 봅니다. 다만 2048²는 텍셀 400만 개, 24비트 깊이면 약 12MB — 4096²로 올리면 4배가 되니 메모리와 대역폭을 함께 고려해야 합니다.

using Microsoft::WRL::ComPtr;ComPtr<T>는 COM 객체용 스마트 포인터입니다. Direct3D 11의 모든 인터페이스(ID3D11...)는 COM 객체라 참조 카운트로 수명을 관리하는데, ComPtr이 소멸될 때 Release()를 자동으로 불러 줍니다. 즉 delete를 직접 부를 필요가 없고 누수를 막아 줍니다. .Get()은 안에 든 원시 포인터를, &depthTex(주소 연산자)는 "여기에 새 객체를 담아라"는 출력 슬롯을 넘길 때 씁니다.

1-2. 왜 TYPELESS 포맷인가 — 한 텍스처, 두 얼굴

td.Format = DXGI_FORMAT_R24G8_TYPELESS; 이 한 줄이 섀도우 맵 리소스의 핵심 트릭입니다. 우리는 같은 텍스처를 두 가지 용도로 써야 합니다:

문제는 하나의 뷰(view)로는 이 두 해석을 동시에 못 한다는 점입니다. 그래서 텍스처 자체는 타입을 정하지 않은 TYPELESS(형식 없음)로 만들어 두고, ""를 만들 때마다 그때그때 구체적인 포맷을 지정합니다. 비유하자면 텍스처는 24+8비트짜리 날 것의 비트 통이고, DSV/SRV는 그 통을 각각 다른 렌즈(포맷)로 들여다보는 안경입니다. TYPELESS로 안 만들고 처음부터 D24_UNORM_S8_UINT로 만들면, SRV를 만들 때 "이 깊이 포맷은 셰이더에서 못 읽어"라며 실패합니다.

구분포맷24비트 (R24)8비트 (G8/X8)
텍스처(저장)R24G8_TYPELESS형식 미정형식 미정
DSV(쓰기)D24_UNORM_S8_UINT깊이(0~1 정규화)스텐실(정수)
SRV(읽기)R24_UNORM_X8_TYPELESS빨강 채널(0~1 실수)버림(X=무시)

각 필드도 짚어 봅니다. td.MipLevels = 1; — 밉맵은 깊이맵에 필요 없으니 1단계만. 밉맵으로 축소하면 깊이값을 평균 내 버려 의미가 망가집니다. td.ArraySize = 1; — 텍스처 배열이 아닌 단일 텍스처. td.SampleDesc.Count = 1; — MSAA 없음(깊이맵은 멀티샘플하지 않습니다). td.Usage = D3D11_USAGE_DEFAULT; — GPU 전용 메모리에 두고 CPU는 접근하지 않는 가장 빠른 용도. td.BindFlags = D3D11_BIND_DEPTH_STENCIL | D3D11_BIND_SHADER_RESOURCE; — 이 비트 OR(|)가 "깊이 타깃으로도, 셰이더 리소스로도 쓸 거야"라고 두 용도를 동시에 켭니다. 이 두 플래그를 함께 켜는 것이 "같은 텍스처를 두 얼굴로" 쓰는 것을 API 레벨에서 허용하는 지점입니다.

device->CreateTexture2D(&td, nullptr, &depthTex); — 첫 인자는 방금 채운 서술자 구조체의 주소(&td), 두 번째 nullptr은 "초기 데이터 없음"(깊이맵은 매 프레임 새로 그리니 초기값이 필요 없습니다), 세 번째 &depthTex는 만들어진 텍스처를 담아 돌려줄 출력 슬롯입니다.

1-3. DSV — 깊이로 쓰는 뷰

dsvd.Format = DXGI_FORMAT_D24_UNORM_S8_UINT; — 이 뷰를 통해 볼 때는 앞 24비트를 깊이(D, 0~1로 정규화된 UNORM), 뒤 8비트를 스텐실(정수)로 해석하라는 뜻입니다. dsvd.ViewDimension = D3D11_DSV_DIMENSION_TEXTURE2D;는 "이건 2D 텍스처야"라고 차원을 알려 줍니다. CreateDepthStencilView(depthTex.Get(), &dsvd, &depthDSV);에서 첫 인자로 아까 만든 원본 텍스처(.Get()으로 원시 포인터), 두 번째로 이 서술자, 세 번째로 결과를 받을 depthDSV를 넘깁니다. 이제 depthDSV는 그 비트 통을 "깊이/스텐실" 렌즈로 보는 안경입니다.

1-4. SRV — 셰이더에서 읽는 뷰

srvd.Format = DXGI_FORMAT_R24_UNORM_X8_TYPELESS; — 같은 통을 이번엔 "앞 24비트를 빨강 채널(R, 0~1 실수)로, 뒤 8비트는 X(무시)"로 봅니다. 그래서 HLSL에서 shadowMap.SampleCmp(...)로 읽으면 그 값이 곧 저장된 깊이가 됩니다. srvd.Texture2D.MipLevels = 1;은 사용할 밉 단계 수(1개), ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D는 2D. 이렇게 DSV와 SRV라는 두 개의 서로 다른 안경이 하나의 텍스처를 각각 쓰기용/읽기용으로 봅니다.

함정 한 텍스처를 동시에 DSV로 쓰면서 SRV로 바인딩해 두면 Direct3D가 그 SRV를 자동으로 언바인드하고 디버그 레이어가 경고를 뿜습니다("resource being set to ... is still bound on ... as a DepthStencil"). 1패스가 끝나 깊이맵을 다 그린 다음에만 2패스에서 SRV로 붙여야 합니다. 반대로 2패스가 끝나면 다음 프레임의 1패스 전에 SRV를 ctx->PSSetShaderResourcesnullptr을 넣어 풀어 주세요. 안 그러면 다음 프레임 DSV 바인딩 때 또 경고가 납니다.

1-5. 비교 샘플러 — 그림자 전용 특수 샘플러

보통 SamplerState는 텍스처에서 색을 읽어 옵니다. 하지만 그림자에 쓰는 비교 샘플러(comparison sampler)는 다릅니다 — 값을 읽어 오는 게 아니라, "내가 준 참조 깊이가 저장된 텍셀 깊이보다 가까운가?"라는 질문에 0 또는 1로 답합니다. 그리고 결정적으로, 3×3처럼 여러 텍셀에 걸치면 그 0/1 비교 결과들을 하드웨어가 이중선형으로 보간해 0~1 사이 부드러운 값을 돌려줍니다. 이게 PCF(가장자리 부드럽게)를 GPU가 공짜로 해 주는 핵심입니다.

ssd.Filter = D3D11_FILTER_COMPARISON_MIN_MAG_LINEAR_MIP_POINT; — 이름을 풀면 "MIN/MAG(축소/확대)는 LINEAR(이중선형), MIP은 POINT, 그리고 COMPARISON(비교 모드)"입니다. COMPARISON이 붙어야 SampleCmp를 쓸 수 있고, LINEAR가 있어야 비교 결과가 텍셀 사이에서 보간됩니다. 이 둘이 합쳐져 "3×3 하드웨어 PCF"가 됩니다.

ssd.AddressU = ssd.AddressV = ssd.AddressW = D3D11_TEXTURE_ADDRESS_BORDER; — UV가 [0,1] 밖으로 나갔을 때 어떻게 할지입니다. BORDER는 "가장자리 밖은 테두리 색으로 채워라". 그 테두리 색이 바로 다음 줄 float border[4] = { 1, 1, 1, 1 }; — 깊이 1.0입니다. 깊이 1.0은 "무한히 멀다 = 아무것도 그 앞을 가리지 않는다"는 뜻이라, 섀도우 맵 절두체(범위) 밖은 항상 빛(그림자 아님)으로 처리됩니다. 만약 여기를 0.0으로 두면 맵 밖 세상이 통째로 그림자에 잠겨 버립니다.

ssd.ComparisonFunc = D3D11_COMPARISON_LESS_EQUAL; — 비교 연산의 방향입니다. "참조 깊이 ≤ 저장 깊이이면 통과(=빛, 결과 1)". 왜 ≤일까요? 저장 깊이는 광원이 처음 만난 표면까지의 거리이고, 참조 깊이는 지금 이 픽셀까지의 거리입니다. 지금 픽셀이 그 저장값보다 같거나 가까우면 그 픽셀이 곧 광원이 처음 만나는 표면이라는 뜻이니 빛을 받습니다. 더 멀면(>) 그 앞에 다른 게 있다는 뜻이니 그림자입니다. memcpy(ssd.BorderColor, border, sizeof(border));는 테두리 색 배열을 서술자에 복사하는 것뿐입니다.

개념 비교 샘플러의 반환값을 문장으로 기억하세요: 1 = "이 픽셀은 빛을 받음(통과)", 0 = "가려짐(그림자)". 3×3 PCF에서는 9개 텍셀 비교의 하드웨어 보간 평균이라 0.0~1.0 사이 값이 나옵니다. 예를 들어 6개는 빛, 3개는 그림자라면 대략 0.66 정도가 나와 경계가 부드러워집니다.

1-6. 광원 공간 행렬 — 빛의 눈으로 보는 변환

이제 "빛의 카메라"를 세웁니다. 카메라를 정의하는 두 행렬 — 뷰 행렬(어디서 어디를 보는가)과 투영 행렬(어떤 모양으로 화면에 눌러 담는가) — 을 광원 기준으로 만듭니다.

XMMATRIX lightProj = XMMatrixOrthographicOffCenterLH(-10, 10, -10, 10, 1.0f, 25.f);직교 투영(orthographic) 행렬입니다. 방향광(태양처럼 광선이 모두 평행한 빛)은 원근이 없으므로, 원근 투영이 아니라 직교 투영을 씁니다. 인자는 순서대로 (left, right, bottom, top, nearZ, farZ): 가로로 x=−10~10, 세로로 y=−10~10 범위의 상자를, 깊이로 z=1.0~25.0 범위를 담습니다. OffCenter는 좌우·상하 범위를 각각 따로 줄 수 있다는 뜻이고, LH는 왼손 좌표계입니다. 이 상자(절두체)가 바로 그림자가 계산되는 영역이며, 앞서 border=1.0이 담당하는 "맵 밖"이 바로 이 상자 바깥입니다.

XMMATRIX lightView = XMMatrixLookAtLH(lightPos, XMVectorZero(), XMVectorSet(0,1,0,0)); — 광원 위치 lightPos에서 원점 XMVectorZero()(0,0,0)를 바라보는 뷰 행렬입니다. 세 번째 인자 XMVectorSet(0,1,0,0)은 위쪽 방향(up)으로 +y축을 씁니다. 즉 "빛이 이 위치에 서서 장면 중앙을 내려다본다"는 카메라입니다.

XMMATRIX lightSpace = lightView * lightProj; — 두 변환을 합칩니다. 행벡터 규약이라 곱 순서가 중요합니다. 월드 좌표에 이 행렬을 곱하면(mul(worldPos, lightSpace)) → 먼저 lightView가 적용돼 광원 시점 좌표로, 이어서 lightProj가 적용돼 클립 공간으로 갑니다. 곱은 왼쪽→오른쪽 순으로 벡터에 적용되므로 view * proj가 맞습니다. 열벡터(OpenGL식) 규약이었다면 proj * view로 뒤집혔을 것입니다.

성능 직교 절두체의 범위를 장면에 딱 맞게 좁힐수록 그림자 품질이 좋아집니다. 넓게 잡으면 같은 2048² 텍셀이 더 넓은 면적을 덮어 그림자 해상도(단위 면적당 텍셀 밀도)가 떨어지고, near/far가 벌어지면 깊이 정밀도도 낮아져 shadow acne가 심해집니다. 실무에서는 카메라 절두체를 감싸는 최소 상자를 매 프레임 계산하는 "tight-fit" 기법을 씁니다.

1-7. 1패스 실행 — 깊이만 그리기

D3D11_VIEWPORT vp = { 0, 0, (float)SHADOW_W, (float)SHADOW_H, 0.0f, 1.0f }; — 뷰포트를 깊이맵 해상도에 맞춰 2048×2048로 설정합니다. 화면 뷰포트가 아니라 섀도우 맵 크기여야 텍셀 전체를 채웁니다. 필드는 (TopLeftX, TopLeftY, Width, Height, MinDepth, MaxDepth)이고, 깊이 범위는 [0,1]입니다. ctx->RSSetViewports(1, &vp);로 이 뷰포트 하나를 적용합니다.

ctx->OMSetRenderTargets(0, nullptr, depthDSV.Get()); — 이 줄이 "컬러 없이 깊이만"의 실체입니다. 첫 인자 0은 컬러 렌더 타깃 개수 0개, 두 번째 nullptr은 RTV 배열 없음, 세 번째만 depthDSV를 넘깁니다. 컬러 타깃이 없으면 픽셀 셰이더의 컬러 계산이 통째로 생략돼 매우 빠릅니다 — 우리는 깊이만 있으면 되니까요. ctx->ClearDepthStencilView(depthDSV.Get(), D3D11_CLEAR_DEPTH, 1.0f, 0);는 깊이 버퍼를 1.0(가장 먼 값)으로, 스텐실을 0으로 초기화합니다. 매 프레임 지난 그림자를 지우는 시작점입니다.

ctx->RSSetState(cullFrontRS.Get()); — 깊이 패스 동안만 앞면 컬링(front-face culling)으로 바꿉니다. 보통은 뒷면을 버리지만, 여기서는 반대로 앞면을 버려 물체의 뒷면 깊이를 기록합니다. 이렇게 하면 저장 깊이가 실제 표면보다 살짝 안쪽(뒤)으로 밀려, 본 렌더에서 자기 표면과 비교할 때 여유가 생겨 peter-panning은 줄이고 acne도 억제하는 널리 쓰이는 트릭입니다. renderSceneDepthOnly(lightSpace);lightSpace 행렬을 상수로 넘겨 장면을 그린 뒤, ctx->RSSetState(cullBackRS.Get());로 2패스를 위해 다시 뒷면 컬링으로 되돌립니다.

함정 앞면 컬링 트릭은 닫힌(watertight) 메시에서만 안전합니다. 벽처럼 한 겹짜리 평면(뒷면이 없는 얇은 지오메트리)은 앞면을 버리면 깊이가 아예 안 그려져 그림자가 통째로 사라집니다. 그런 경우엔 컬링을 끄거나(D3D11_CULL_NONE) bias만으로 처리하는 편이 낫습니다.

2. 2패스 셰이더 — 깊이 비교로 그림자량 계산

이제 본 렌더의 픽셀 셰이더 안에서 각 픽셀이 그림자인지 판정합니다. 함수 shadowCalc0.0(완전히 빛) ~ 1.0(완전한 그림자) 사이의 "그림자량"을 돌려줍니다. 코드부터 봅니다.

lighting_shadow.hlsl (그림자 부분)hlsl
Texture2D              shadowMap : register(t4);
SamplerComparisonState shadowSampler : register(s1);  // 비교 샘플러

// FragPosLightSpace = mul(worldPos, lightSpace) (정점에서 계산해 보간)

// 0.0 = 완전히 빛, 1.0 = 완전한 그림자
float shadowCalc(float4 fragPosLS, float3 N, float3 L) {
    // 원근 분할 후 [-1,1] → [0,1] 텍스처 좌표로
    float3 proj = fragPosLS.xyz / fragPosLS.w;
    // D3D는 clip-space z가 이미 [0,1]. xy만 [0,1]로 매핑, v는 뒤집는다
    float2 uv = proj.xy * float2(0.5, -0.5) + 0.5;
    float current = proj.z;             // 이 픽셀의 광원 기준 깊이
    if (current > 1.0) return 0.0;      // 절두체 밖은 그림자 아님

    // 경사에 따라 bias를 키워 shadow acne 억제
    float bias = max(0.05 * (1.0 - dot(N, L)), 0.005);

    // PCF 3x3: SampleCmp가 (참조깊이 <= 저장깊이)를 텍셀마다 비교, HW 이중선형 보간
    float shadow = 0.0;
    float2 texel;
    shadowMap.GetDimensions(texel.x, texel.y);
    texel = 1.0 / texel;
    [unroll] for (int x = -1; x <= 1; ++x)
    [unroll] for (int y = -1; y <= 1; ++y) {
        // SampleCmp: 결과 1=빛(통과), 0=그림자 → (1-결과)로 그림자량 누적
        float lit = shadowMap.SampleCmp(shadowSampler,
                        uv + float2(x, y) * texel, current - bias);
        shadow += 1.0 - lit;
    }
    return shadow / 9.0;
}
// 사용: float3 color = ambient + (1.0 - shadowCalc(...)) * (diffuse + specular);

2-1. 리소스 바인딩과 함수 시그니처

Texture2D shadowMap : register(t4); — 1패스에서 만든 depthSRV를 셰이더의 텍스처 슬롯 t4에 붙입니다(C++에서 ctx->PSSetShaderResources(4, 1, depthSRV.GetAddressOf())로). 숫자 4는 다른 텍스처(알베도 t0 등)와 겹치지 않게 고른 것뿐입니다. SamplerComparisonState shadowSampler : register(s1); — 일반 SamplerState가 아니라 비교 샘플러 전용 타입입니다. 이 타입이어야 SampleCmp를 호출할 수 있습니다. 슬롯 s1에 1패스에서 만든 비교 샘플러를 붙입니다.

주석 // FragPosLightSpace = mul(worldPos, lightSpace)정점 셰이더에서 각 정점의 월드 좌표에 lightSpace를 곱해 광원 클립 좌표를 만들고, 그걸 float4로 픽셀 셰이더에 넘겨 보간한다는 뜻입니다. 보간은 래스터라이저가 삼각형 내부 픽셀마다 자동으로 해 줍니다.

float shadowCalc(float4 fragPosLS, float3 N, float3 L) — 반환은 그림자량(float), 인자는: fragPosLS(광원 클립 공간에서의 이 픽셀 좌표, 원근 분할 전이라 float4로 w 성분까지 들고 옴), N(표면 법선, 정규화됨), L(표면→광원 방향, 정규화됨). N과 L은 뒤에서 경사 기반 bias에 쓰입니다.

2-2. 원근 분할과 [0,1] 텍스처 좌표 매핑

float3 proj = fragPosLS.xyz / fragPosLS.w;원근 분할(perspective divide)입니다. 클립 공간 좌표를 w로 나눠 정규화 장치 좌표(NDC)로 바꿉니다. 직교 투영이면 w가 1이라 사실상 변화가 없지만, 원근 투영(점광원 그림자 등)도 지원하려고 항상 나눠 줍니다. 결과 proj는 x,y가 [−1,1], z가 [0,1] 범위(D3D 규약)입니다.

float2 uv = proj.xy * float2(0.5, -0.5) + 0.5; — NDC의 xy(−1~1)를 텍스처 좌표(0~1)로 옮깁니다. 성분별로 손계산해 봅시다:

왜 v만 뒤집을까요? NDC는 y가 위로 증가하지만, 텍스처 좌표 V는 규약상 위→아래로 증가하기(위가 0, 아래가 1) 때문입니다. 이 −0.5 하나가 그 상하 반전을 담당합니다. OpenGL에서 왔다면 여기서 흔히 실수하는 지점이니(OpenGL은 V가 아래→위) 부호를 꼭 확인하세요.

float current = proj.z; — z가 곧 이 픽셀의 광원 기준 깊이입니다. D3D는 클립 z가 이미 [0,1]이라 별도 변환이 필요 없습니다(OpenGL이라면 z가 [−1,1]이라 z*0.5+0.5가 추가로 필요합니다). 이 current가 나중에 저장 깊이와 비교할 "참조 깊이"입니다.

if (current > 1.0) return 0.0; — z가 1.0을 넘으면 광원 절두체의 far 평면 바깥이라 깊이맵에 정보가 없습니다. 그런 픽셀은 그냥 빛(그림자량 0)으로 처리해 이상한 그림자가 안 생기게 합니다.

2-3. 경사 기반 bias — acne를 막는 작은 여유

float bias = max(0.05 * (1.0 - dot(N, L)), 0.005);shadow acne(표면이 자기 자신에 드리우는 줄무늬 그림자)를 막는 핵심 줄입니다. 원인부터: 깊이맵은 유한한 텍셀·유한한 정밀도라, 저장된 깊이와 현재 픽셀 깊이가 정확히 같아야 할 곳에서도 미세한 오차로 "현재가 살짝 더 멀다"고 오판해 자기 그림자가 지그재그로 생깁니다. 해결책은 비교할 때 현재 깊이에서 작은 값을 빼 여유(bias)를 주는 것입니다.

경사(slope)에 따라 bias를 키울까요? 빛이 표면에 비스듬히 닿을수록 하나의 깊이 텍셀이 덮는 실제 깊이 범위가 넓어져 오차가 커지기 때문입니다. dot(N, L)은 법선과 광원 방향이 이루는 각의 코사인입니다:

max(..., 0.005)바닥값을 보장합니다 — 정면으로 닿아 bias가 0이 되면 acne가 다시 나므로, 최소 0.005는 항상 확보합니다. 이 값들(0.05, 0.005)은 장면 스케일과 깊이 정밀도에 맞춰 튜닝하는 상수입니다.

함정 bias는 양날의 검입니다. 너무 작으면 acne(줄무늬), 너무 크면 peter-panning(그림자가 물체에서 떨어져 물체가 공중에 뜬 것처럼 보이는 현상)이 생깁니다. 경사 기반 bias + 1패스의 앞면 컬링을 함께 쓰면 둘 사이 균형이 좋아집니다. 그래도 안 되면 near/far를 좁혀 깊이 정밀도를 올리는 게 근본 처방입니다.

2-4. PCF 3×3 — 계단 없애기

float shadow = 0.0; — 그림자량 누적 변수. float2 texel; shadowMap.GetDimensions(texel.x, texel.y); — 깊이맵의 픽셀 크기(2048, 2048)를 받아 옵니다. texel = 1.0 / texel; — 역수를 취해 한 텍셀의 UV 폭 (1/2048 ≈ 0.000488)을 구합니다. 이 값이 이웃 텍셀로 한 칸 이동하는 UV 단위입니다.

[unroll] for (int x = -1; x <= 1; ++x) / [unroll] for (int y = -1; y <= 1; ++y) — x,y가 각각 −1,0,1을 돌아 3×3 = 9개 이웃을 훑습니다. [unroll]은 컴파일러에게 이 루프를 펼쳐서 (분기 없이) 9번 직렬 코드로 만들라는 지시입니다. 반복 횟수가 고정이라 분기 오버헤드를 없애 GPU에서 더 빠릅니다.

float lit = shadowMap.SampleCmp(shadowSampler, uv + float2(x, y) * texel, current - bias); — 이 페이지에서 가장 중요한 한 줄입니다. SampleCmp의 세 인자:

  1. shadowSampler: 아까 만든 비교 샘플러(LESS_EQUAL, LINEAR).
  2. uv + float2(x, y) * texel: 중심 UV에서 (x,y)만큼 텍셀 단위로 이동한 샘플 위치.
  3. current - bias: 비교에 쓸 참조 깊이. bias만큼 당겨(작게 해) 여유를 줬습니다.

하드웨어는 각 위치에서 "(current − bias) ≤ 저장깊이?"를 판정해 통과하면 1, 아니면 0을 내는데, LINEAR 필터 덕에 그 0/1을 인접 텍셀끼리 이중선형 보간해 부드러운 소수값을 돌려줍니다. lit은 "이 위치가 얼마나 빛을 받는가"(1=빛, 0=그림자).

shadow += 1.0 - lit; — 우리가 원하는 건 그림자이라 1 − lit(=그림자 정도)로 뒤집어 누적합니다. 9번 돌고 나면 return shadow / 9.0;으로 평균을 냅니다. 9개 중 몇 개가 그림자였느냐의 비율이라, 경계에서 0/1이 아니라 0.11, 0.44, 0.78 같은 중간값이 나와 계단이 사라지고 부드러워집니다.

마지막 주석 float3 color = ambient + (1.0 - shadowCalc(...)) * (diffuse + specular);이 사용 예입니다. shadowCalc가 1(완전 그림자)이면 1 − 1 = 0이 되어 diffuse·specular가 0으로 꺼지고 ambient만 남습니다. 반대로 0(완전 빛)이면 diffuse·specular가 온전히 더해집니다. ambient는 항상 남겨 그림자 속이 완전 검정이 되지 않게 합니다.

2-5. 손계산 예제 — 값이 실제로 어떻게 흐르는가

구체적인 수치로 한 픽셀을 따라가 봅시다. 어떤 픽셀의 광원 클립 좌표가 fragPosLS = (0.4, 0.6, 0.73, 1.0)이고, 법선과 광원 방향의 내적 N·L = 0.8이라고 합시다.

  1. 원근 분할: w=1이므로 proj = (0.4, 0.6, 0.73).
  2. UV 매핑: u = 0.4×0.5 + 0.5 = 0.7, v = 0.6×(−0.5) + 0.5 = 0.2. → 샘플 중심 (0.7, 0.2).
  3. 참조 깊이: current = 0.73. 1.0 이하이므로 계속 진행.
  4. bias: max(0.05×(1−0.8), 0.005) = max(0.01, 0.005) = 0.01. → 비교값 current − bias = 0.72.
  5. PCF: 9개 이웃 각각에서 "0.72 ≤ 저장깊이?"를 판정. 저장깊이가 대부분 0.72라면(같은 표면), 여유 0.01 덕에 통과(빛)가 많아 acne가 안 생김. 만약 앞에 물체가 있어 저장깊이가 0.5라면 0.72 > 0.5라 대부분 그림자 → shadow ≈ 1.0에 가까워짐.

예컨대 9개 중 3개만 그림자였다면 shadow = 3/9 ≈ 0.33. 최종 색은 ambient + (1 − 0.33)×(diffuse+specular) = ambient + 0.67×(diffuse+specular)로, 부분 그림자(반그늘)가 됩니다.

3. C++ ↔ HLSL ↔ DirectXMath 대응표

지금까지 나온 조각들이 어느 API에 대응하는지 한눈에 정리합니다. 실무에서는 아래 오른쪽 열(DirectXMath/D3D API)을 그대로 씁니다.

개념 / 원본 코드대응 API역할
깊이 텍스처 (양면 뷰)CreateTexture2D + TYPELESS 포맷DSV·SRV로 동시에 쓸 통
깊이 쓰기 뷰CreateDepthStencilView (D24_UNORM_S8_UINT)1패스 깊이 기록 타깃
깊이 읽기 뷰CreateShaderResourceView (R24_UNORM_X8_TYPELESS)2패스에서 셰이더가 읽음
비교 샘플러D3D11_FILTER_COMPARISON_... + LESS_EQUALSampleCmp + HW PCF 보간
직교 투영 (방향광)XMMatrixOrthographicOffCenterLH평행광용 light-space proj
광원 뷰 행렬XMMatrixLookAtLH빛의 위치·시선
lightView * lightProj행벡터 곱 (transpose 후 cbuffer 업로드)월드→광원 클립 변환
깊이만 렌더OMSetRenderTargets(0, nullptr, dsv)컬러 PS 생략, 고속
앞면 컬링 트릭RasterizerState.CullMode = D3D11_CULL_FRONTpeter-panning 완화
비교 샘플 (HLSL)Texture2D.SampleCmp참조깊이 ≤ 저장깊이 → 0/1 보간
규약 cbuffer 업로드 시 transpose 주의. DirectXMath는 행벡터·행 우선이지만 HLSL의 mul(v, M)이 기대하는 상수 버퍼 레이아웃과 맞추려면, C++에서 lightSpaceXMMatrixTranspose(lightSpace)로 뒤집어 업로드하는 것이 일반적입니다(프로젝트 관례에 따라 다름). 셰이더에서 그림자가 엉뚱한 방향으로 찍힌다면 가장 먼저 이 transpose와 곱 순서를 의심하세요.

4. 정리 — 섀도우 맵 체크리스트

  1. 깊이 텍스처는 TYPELESS로 만들고 DSV(D24_UNORM_S8_UINT)·SRV(R24_UNORM_X8_TYPELESS) 두 뷰를 만든다.
  2. 비교 샘플러는 COMPARISON 필터 + LESS_EQUAL + BORDER(1.0)로 설정한다.
  3. 방향광은 직교 투영으로 lightView * lightProj(행벡터 순서)를 만든다.
  4. 1패스는 컬러 없이 깊이만, 앞면 컬링으로 그린다. 끝나면 뒷면 컬링으로 복구.
  5. 2패스는 원근 분할 → [0,1] UV(v는 뒤집기) → current = proj.z.
  6. 경사 기반 bias로 acne를, 앞면 컬링으로 peter-panning을 잡는다.
  7. PCF 3×3로 경계를 부드럽게. SampleCmp의 HW 보간을 활용한다.
  8. SRV를 DSV와 동시에 바인딩하지 않도록 패스 사이에 언바인드한다.

이걸 이해하면 큐브맵 섀도우(점광원 전방향 그림자), 캐스케이드 섀도우 맵(CSM, 카메라 거리별 다중 맵), VSM/ESM(분산·지수 섀도우 맵) 같은 고급 기법도 같은 골격 위에 얹은 변주임을 알게 됩니다. 다음 장의 디퍼드 렌더링에서는 이 그림자를 G-buffer 기반 라이팅 패스와 결합하는 흐름을 봅니다.