1. 방향광 하나에 섀도우 맵 한 장을 붙이고, PCF로 가장가지를 부드럽게 만들기
2. 섀도우 맵 한장의 한계를 CSM(Cascade Shadow Map)으로 해결하기
[프레임 순서]
Shadow Pass (빛 시점 깊이만 기록) → Scene Pass (깊이 비교로 그림자 판정) → Sky → Tonemap → ImGui
1. 섀도우 맵 기본
원리
- 빛의 시점에서 씬을 렌더링해 깊이만 기록합니다. 이것이 섀도우 맵이고, 빛이 닿는 가장 가까운 표면의 깊이를 담고 있습니다.
- 본 렌더링에서 각 픽셀의 월드 좌표를 빛 공간으로 변환합니다.
- 섀도우 맵에 기록된 깊이보다 이 픽셀이 더 멀면, 빛과 픽셀 사이에 무언가 있다는 뜻이므로 그림자입니다.
방향광은 평행광이라서 원근 투영이 아니라 직교 투영(Orthographic)을 씁니다.
part1. ShadowMap 리소스
깊이 텍스처를 쓰기(DSV)와 읽기(SRV) 두 용도로 써야 해서, 포맷을 R32_TYPELESS로 만들고 뷰마다 포맷을 따로 지정해야 합니다.
| 용도 | 뷰 | 포맷 |
| 그림자 패스에서 깊이 쓰기 | DSV | D32_FLOAT |
| 씬 패스에서 깊이 읽기 | SRV | R32_FLOAT |
void ShadowMap::Init(ID3D12Device* device, DescriptorHeap* dsvHeap, DescriptorHeap* srvHeap, UINT size)
{
mDsvHeap = dsvHeap; mSrvHeap = srvHeap; mSize = size;
auto desc = CD3DX12_RESOURCE_DESC::Tex2D(DXGI_FORMAT_R32_TYPELESS, size, size, 1, 1, 1, 0,
D3D12_RESOURCE_FLAG_ALLOW_DEPTH_STENCIL);
D3D12_CLEAR_VALUE clear{};
clear.Format = DsvFormat; // D32_FLOAT
clear.DepthStencil.Depth = 1.0f;
CD3DX12_HEAP_PROPERTIES heap(D3D12_HEAP_TYPE_DEFAULT);
ThrowIfFailed(device->CreateCommittedResource(&heap, D3D12_HEAP_FLAG_NONE, &desc,
D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE, &clear, IID_PPV_ARGS(&mTex)));
D3D12_DEPTH_STENCIL_VIEW_DESC dsv{};
dsv.Format = DsvFormat;
dsv.ViewDimension = D3D12_DSV_DIMENSION_TEXTURE2D;
mDsv = mDsvHeap->Allocate();
device->CreateDepthStencilView(mTex.Get(), &dsv, mDsv.cpu);
D3D12_SHADER_RESOURCE_VIEW_DESC srv{};
srv.Format = DXGI_FORMAT_R32_FLOAT;
srv.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D;
srv.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING;
srv.Texture2D.MipLevels = 1;
mSrv = mSrvHeap->Allocate();
device->CreateShaderResourceView(mTex.Get(), &srv, mSrv.cpu);
}
Begin은 상태를 PIXEL_SHADER_RESOURCE -> DEPTH_WRTIE 로 바꾸고, Clear하고, 렌더 타킷 없이 DSV만 바인딩합니다. End는 상태를 되돌립니다.
part2. 깊이 전용 PSO
기존 PSO를 복사해서 픽셀 셰이더와 렌더 타깃을 없애면 깊이만 기록하는 PSO가 됩니다.
auto shadowPso = pso;
shadowPso.PS = {}; // 픽셀 셰이더 없음 = 깊이만 기록
shadowPso.NumRenderTargets = 0;
shadowPso.RTVFormats[0] = DXGI_FORMAT_UNKNOWN;
shadowPso.DSVFormat = ShadowMap::DsvFormat;
shadowPso.RasterizerState.DepthBias = 1000; // 고정 바이어스
shadowPso.RasterizerState.SlopeScaledDepthBias = 2.0f; // 비스듬한 면일수록 더 밀어냄
shadowPso.RasterizerState.DepthBiasClamp = 0.0f;
하드웨어 Depth Bias는 기록하는 기핑 자체를 살짝 뒤로 밀어서 섀도우 여드름(shadow acne)을 줄입니다.
기울기 바이어스(slope-scaled bias)는 빛에 비스듬한 면일수록 더 많이 밀어냅니다.
part3. 빛 시점 행렬
처음에는 단순하게 원전 중심, 반경 R의 고정 박스로 시작했습니다.
const float R = scene.light.shadowRadius;
const XMVECTOR dir = XMVector3Normalize(XMLoadFloat3(&scene.light.direction));
const XMVECTOR center = XMVectorZero();
const XMVECTOR eye = center - dir * (2.0f * R);
const XMMATRIX lightView = XMMatrixLookAtLH(eye, center, XMVectorSet(0, 1, 0, 0));
const XMMATRIX lightProj = XMMatrixOrthographicLH(2.0f * R, 2.0f * R, 0.1f, 4.0f * R);
XMStoreFloat4x4(&mLightViewProj, lightView * lightProj);
center = 원점 중심, R = shadowRadius
part4. 비교 샘플러와 PCF
그림자 판정에는 일반 샘플러 대신 비교 샘플러(SamplerComparisonState)를 씁니다.
SampleCmp는 기록된 깊이와 비교한 결과(0 또는 1)를 하드웨어가 주변 4텍셀에서 보간해서 돌려줍니다.
// s2: 그림자 비교용, 범위 밖은 흰색(= 그림자 없음)
CD3DX12_STATIC_SAMPLER_DESC(2, D3D12_FILTER_COMPARISON_MIN_MAG_LINEAR_MIP_POINT,
D3D12_TEXTURE_ADDRESS_MODE_BORDER, D3D12_TEXTURE_ADDRESS_MODE_BORDER, D3D12_TEXTURE_ADDRESS_MODE_BORDER,
0.0f, 16, D3D12_COMPARISON_FUNC_LESS_EQUAL, D3D12_STATIC_BORDER_COLOR_OPAQUE_WHITE),
여기서 3x3으로 9번 샘플링해서 평균을 내면(PCF, Percentage Closer Filtering) 그림자 가장자리가 부드러워집니다.
float ShadowFactor(float3 worldPos, float3 Ng)
{
float3 p = worldPos + Ng * gShadowParams.z; // normal offset
float4 lp = mul(float4(p, 1.0f), gLightViewProj);
float3 ndc = lp.xyz / lp.w;
float2 uv = ndc.xy * float2(0.5f, -0.5f) + 0.5f; // NDC -> UV (Y 반전)
if (any(uv < 0.0f) || any(uv > 1.0f) || ndc.z > 1.0f)
return 1.0f;
float depth = ndc.z - gShadowParams.x; // bias
float texel = gShadowParams.y;
float sum = 0.0f;
[unroll] for (int y = -1; y <= 1; ++y)
[unroll] for (int x = -1; x <= 1; ++x)
sum += gShadowMap.SampleCmpLevelZero(gShadowSampler, uv + float2(x, y) * texel, depth);
return sum / 9.0f;
}
그림자는 직사광에만 곱합니다. IBL(환경광)까지 어두워지면 그림자 속이 새까맣게 됩니다.
float3 direct = (diffuse + specular) * gLightColor * NdotL * shadow;
float3 color = direct + ambient + emissive;
여드름(acne)을 잡는 세 가지 방법
| 방법 | 위치 | 효과 | 부작용 |
| Depth Bias / Slope Bias | 그림자 패스 PSO | 기록되는 깊이를 뒤로 밀기 | 과하면 그림자가 물체에서 떨어짐(피터패닝) |
| 상수 Bias | 셰이더 비교 시 | 비교 깊이를 앞으로 당기기 | 마찬가지로 피터패닝 |
| Normal Offset | 셰이더 샘플 위치 | 샘플 위치를 표면 법선 방향으로 띄우기 | 과하면 그림자 모양이 틀어짐 |
노멀 오프셋에는 노멀맵이 적용된 N이 아니라 지오메트리 법선 Ng를 써야합니다.
노멀맵 법선은 픽셀마다 흔들려서 그림자도 같이 흔들립니다.
한계
반경 R하나로 씬 전체를 덮으면, 넓게 잡으면 흐려지고 좁게 잡으면 먼 곳에 그림자가 없습니다. 2048² 섀도우 맵으로 반경 30m를 덮으면 텍셀 하나가 약 3cm인데, 이건 카메라 바로 앞에서는 계단이 보이고 먼곳에서는 낭비되는 해상도이다.
2. Cascaded Shadow Maps
구상
카메라 시야(절두체)를 거리별로 4구간으로 나누고, 구간마다 섀도우 맵을 따로 만듭니다.
카메라 ──┬── 구간 0 : 좁은 범위 → 아주 선명
├── 구간 1
├── 구간 2
└── 구간 3 : 넓은 범위 → 거칠지만 화면에서 작게 보임
| 항목 | Basic Shadow Map | Cascaded Shadow Maps |
| 섀도우 맵 | Texture2D 1장 | Texture2DArray 4장 |
| 빛 시점 박스 | 원점 중심 고정 | 카메라 절두체의 각 구간에 맞춰 매프레임 계산 |
| 안정성 | 고정이라 문제 없음 | 경계 구 + 텍셀 스냅으로 흔들림 방지 |
| 셰이더 | 맵 하나 샘플링 | 픽셀 거리로 구간을 선택해서 샘플링 |
part1. Shadow Map -> Texture2DArray
리소스 하나에 배열 4장을 만들고, 쓰기는 장마다 DSV 하나, 배열 전체를 SRV 하나로 만듭니다.
auto desc = CD3DX12_RESOURCE_DESC::Tex2D(DXGI_FORMAT_R32_TYPELESS, size, size, kCascades, 1, 1, 0,
D3D12_RESOURCE_FLAG_ALLOW_DEPTH_STENCIL);
// ... CreateCommittedResource 동일 ...
// 쓰기: 장마다 DSV
for (UINT c = 0; c < kCascades; ++c)
{
D3D12_DEPTH_STENCIL_VIEW_DESC dsv{};
dsv.Format = DsvFormat;
dsv.ViewDimension = D3D12_DSV_DIMENSION_TEXTURE2DARRAY;
dsv.Texture2DArray.FirstArraySlice = c;
dsv.Texture2DArray.ArraySize = 1;
mDsv[c] = mDsvHeap->Allocate();
device->CreateDepthStencilView(mTex.Get(), &dsv, mDsv[c].cpu);
}
// 읽기: 배열 전체를 SRV 하나로
D3D12_SHADER_RESOURCE_VIEW_DESC srv{};
srv.Format = DXGI_FORMAT_R32_FLOAT;
srv.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2DARRAY;
srv.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING;
srv.Texture2DArray.MipLevels = 1;
srv.Texture2DArray.ArraySize = kCascades;
mSrv = mSrvHeap->Allocate();
device->CreateShaderResourceView(mTex.Get(), &srv, mSrv.cpu);
배리어는 리소스 전체에 한 번만 걸고, 구간마다 DSV만 바꿔서 Clear하고 그립니다.
void ShadowMap::BeginCascade(ID3D12GraphicsCommandList* cmd, UINT cascade)
{
cmd->OMSetRenderTargets(0, nullptr, FALSE, &mDsv[cascade].cpu);
cmd->ClearDepthStencilView(mDsv[cascade].cpu, D3D12_CLEAR_FLAG_DEPTH, 1.0f, 0, 0, nullptr);
}
part2. 구간 분할(Partical Split Scheme)
| 방식 | 공식 | 특징 |
| 균등 분할 | n + (f - n) * i / N | 가까운 곳의 해상도가 부족 |
| 로그 분할 | n * (f / n)^(i / N) | 가까운 곳에 너무 몰림 |
| 혼합 | λ·log + (1-λ)·uniform | λ로 조절 (보통 0.5~0.8) |
float splits[N + 1];
splits[0] = nearZ;
for (UINT i = 1; i <= N; ++i)
{
const float p = (float)i / N;
const float logSplit = nearZ * powf(farZ / nearZ, p);
const float uniSplit = nearZ + (farZ - nearZ) * p;
splits[i] = lambda * logSplit + (1.0f - lambda) * uniSplit;
}
farZ는 카메라의 far(1000)가 아니라 그림자 거리(shadow Distance, 50m)입니다. 1000m를 4장으로 나누면 의미가 없습니다.
part3. 구간마다 빛 시점 박스 맞추기
- 카메라 절두체를 해당 구간 거리로 잘라서 8개 꼭짓점(월드 공간)을 구합니다.
- 꼭짓점을 감싸는 경계 구(bounding sphere)를 구합니다.
- 구 중심을 바라보는 빛 시점 직교 행렬을 만듭니다.
- 텍셀 스냅을 적용합니다.
for (UINT c = 0; c < N; ++c)
{
// 1) 이 구간의 절두체 꼭짓점: NDC 큐브의 8점을 역변환
const XMMATRIX proj = XMMatrixPerspectiveFovLH(camera.FovY(), camera.Aspect(), splits[c], splits[c + 1]);
const XMMATRIX invVP = XMMatrixInverse(nullptr, view * proj);
XMVECTOR corners[8];
int k = 0;
for (float x : { -1.0f, 1.0f })
for (float y : { -1.0f, 1.0f })
for (float z : { 0.0f, 1.0f })
corners[k++] = XMVector3TransformCoord(XMVectorSet(x, y, z, 1.0f), invVP);
// 2) 경계 구
XMVECTOR center = XMVectorZero();
for (const XMVECTOR& p : corners) center += p;
center /= 8.0f;
float radius = 0.0f;
for (const XMVECTOR& p : corners)
radius = std::max(radius, XMVectorGetX(XMVector3Length(p - center)));
radius = ceilf(radius * 16.0f) / 16.0f; // 미세한 값 변화로 크기가 흔들리지 않게 양자화
// 3) 빛 시점 행렬: 구간 밖에서 그림자를 드리우는 물체도 포함하도록 extra만큼 뒤로
const float extra = 50.0f;
const XMVECTOR eye = center - dir * (radius + extra);
const XMMATRIX lightView = XMMatrixLookAtLH(eye, center, up);
XMMATRIX lightProj = XMMatrixOrthographicOffCenterLH(-radius, radius, -radius, radius,
0.0f, 2.0f * radius + extra);
// 4) 텍셀 스냅: 월드 원점이 항상 텍셀 경계에 오도록 박스를 미세 이동
const XMVECTOR origin = XMVector3TransformCoord(XMVectorZero(), lightView * lightProj);
const float half = size * 0.5f;
const float ox = XMVectorGetX(origin) * half, oy = XMVectorGetY(origin) * half;
lightProj.r[3] += XMVectorSet((roundf(ox) - ox) / half, (roundf(oy) - oy) / half, 0.0f, 0.0f);
XMStoreFloat4x4(&mCascadeVP[c], lightView * lightProj);
(&mCascadeSplits.x)[c] = splits[c + 1];
(&mCascadeTexel.x)[c] = 2.0f * radius / size; // 이 구간의 텍셀 하나 = 월드 몇 m
}
왜 박스(AABB)가 아니라 구인가?
꼭짓점을 빛 공간 AABB로 감싸면 카메라가 회전할 때 박스 크기가 계속 바뀝니다. 그러면 텍셀 크기도 매 프레임 바뀌어서 그림자가 출렁입니다. 구는 회전해도 반지름이 같아서 텍셀 크기가 고정됩니다. 공간은 조금 낭비되지만 안정성이 더 중요합니다.
텍셀 스냅이 왜 필요한가?
카메라가 이동하면 박스도 따라 움직입니다. 박스가 텍셀보다 작은 단위로 움직이면, 같은 물체가 매 프레임 다른 텍셀 경계에 걸려 그림자 가장자리가 지글거립니다(shimmering). 박스를 항상 텍셀 크기의 정수배로만 움직이게 하면 해결됩니다.
투영 행렬의 r[3] = (x 이동, y 이동, z 이동, 1)
↑ w = 1 은 절대 건드리면 안 됨
→ 기존 값에 "더하기(+=)"만 한다
part4. 깊이 패스: 오브젝트 상수 재사용
같은 물체를 4번 그리니까 월드 행렬 상수는 한 번만 업로드하고, 구간마다 PerFrame(빛 VP)만 바꿉니다.
std::vector<std::pair<const Entity*, D3D12_GPU_VIRTUAL_ADDRESS>> draws;
for (const Entity& e : scene.Entities())
{
if (!e.visible || !e.mesh) continue;
PerObjectCB po{};
XMStoreFloat4x4(&po.world, XMMatrixTranspose(e.transform.Matrix()));
draws.push_back({ &e, cb.Alloc(&po, sizeof(po)) });
}
shadow.Begin(cmd);
cmd->SetGraphicsRootSignature(mRootSig.Get());
cmd->SetPipelineState(mShadowPso.Get());
for (UINT c = 0; c < N; ++c)
{
shadow.BeginCascade(cmd, c);
PerFrameCB pf{};
XMStoreFloat4x4(&pf.viewProj, XMMatrixTranspose(XMLoadFloat4x4(&mCascadeVP[c])));
cmd->SetGraphicsRootConstantBufferView(0, cb.Alloc(&pf, sizeof(pf)));
for (const auto& [e, addr] : draws)
{
cmd->SetGraphicsRootConstantBufferView(1, addr);
e->mesh->Draw(cmd);
}
}
shadow.End(cmd);
part5. 상수 버퍼 레이아웃
c++과 HLSL의 필드 순서가 정확히 같아야 합니다. float3 뒤에는 16바이트 정렬용 패딩을 둡니다.
struct PerFrameCB
{
XMFLOAT4X4 viewProj;
XMFLOAT3 cameraPos; float pad0;
XMFLOAT3 lightDir; float pad1;
XMFLOAT3 lightColor; float ambient;
XMFLOAT3 cameraForward; float pad2;
XMFLOAT4X4 cascadeViewProj[ShadowMap::kCascades];
XMFLOAT4 cascadeSplits; // 구간별 끝 거리
XMFLOAT4 cascadeTexel; // 구간별 텍셀의 월드 크기
XMFLOAT4 shadowParams; // x = bias, y = 1/size, z = normal offset(텍셀 단위), w = 디버그
};
UI에는 Distance, Split Lambda, Bias, Normal Offset, Show Cascades를 노출했습니다.
part6. 셰이더: 구간 선택과 샘플링
Texture2DArray gShadowMap : register(t7);
float ShadowFactor(float3 worldPos, float3 Ng, out uint cascade)
{
// 카메라 전방축 기준 거리로 구간 선택
float viewDepth = dot(worldPos - gCameraPos, gCameraForward);
cascade = 0;
[unroll] for (uint c = 0; c < 3; ++c)
if (viewDepth > gCascadeSplits[c]) cascade = c + 1;
if (viewDepth > gCascadeSplits[3]) { cascade = 4; return 1.0f; } // 그림자 거리 밖
// 노멀 오프셋을 구간의 텍셀 크기에 맞춰 환산
float3 p = worldPos + Ng * (gShadowParams.z * gCascadeTexel[cascade]);
float4 lp = mul(float4(p, 1.0f), gCascadeViewProj[cascade]);
float3 ndc = lp.xyz / lp.w;
float2 uv = ndc.xy * float2(0.5f, -0.5f) + 0.5f;
if (any(uv < 0.0f) || any(uv > 1.0f) || ndc.z > 1.0f)
return 1.0f;
float depth = ndc.z - gShadowParams.x;
float texel = gShadowParams.y;
float sum = 0.0f;
[unroll] for (int y = -1; y <= 1; ++y)
[unroll] for (int x = -1; x <= 1; ++x)
sum += gShadowMap.SampleCmpLevelZero(gShadowSampler,
float3(uv + float2(x, y) * texel, cascade), depth);
return sum / 9.0f;
}
- 구간 선택: 카메라와의 직선 거리가 아니라 카메라 전방축으로 투영한 거리(view-space z)를 씁니다. 분할도 이 기준으로 했기 때문입니다.
- 배열 샘플링: float3(uv, sliceIndex)의 z에 구간 번호를 넣습니다.
- 노멀 오프셋 단위: 먼 구간일수록 텍셀 하나가 덮는 면적이 커서 여드림이 더 잘 생깁니다. 그래서 오프셋을 텍셀 단위로 받아서 구간마다 월드 크기로 환산합니다.
part7. 디버그: 구간 색 표시
static const float3 kCascadeColors[5] =
{
float3(1.0f, 0.4f, 0.4f), float3(0.4f, 1.0f, 0.4f),
float3(0.4f, 0.4f, 1.0f), float3(1.0f, 1.0f, 0.4f),
float3(1.0f, 1.0f, 1.0f),
};
...
if (gShadowParams.w > 0.5f)
color *= kCascadeColors[cascade];
'DirectX12 > 엔진 만들어보기' 카테고리의 다른 글
| 스카이박스와 IBL(Image-Based Lighting) (0) | 2026.10.12 |
|---|---|
| glTF 모델 로딩 (0) | 2026.10.09 |
| cgltf API 정리 (사용한것 기준) (0) | 2026.10.09 |
| 라이팅에서 PBR 머티리얼까지 (0) | 2026.10.06 |
| 그리드, Scene/Entity, Hierarchy·Inspector, 기즈모 (0) | 2026.10.05 |





