
글에 사용된 모든 그림과 내용은 직접 작성한 것입니다.
[유튜브 영상]
[깃허브 보러가기]
https://github.com/Chang-Jin-Lee/D3D11-AliceTutorial/tree/main/26_ShadowMap_PCF
[풀리퀘 보러가기]
https://github.com/Chang-Jin-Lee/D3D11-AliceTutorial/pull/52
D3D11에서 shadow mapping을 구현한 방법에 대해 알리기 위함. 또 PCF에 대해 정리하기 위함
| 그림자가 그려진 모습 |
|---|
차근차근 진행해보겠습니다.
그림자란 빛의 진행 경로를 불투명한 물체가 가로막았을 때, 빛이 도달하지 못하여 생기는 어두운 영역을 의미. 즉 빛에서 본 물체의 텍스쳐라는 걸 떠올릴 수 있습니다.
다음의 그림을 보면 광원에서의 카메라를 하나 만들어서 그 카메라는 3D 모델의 실루엣만 본다고 생각하면 됩니다.

렌더 투 텍스쳐 기법의 대표 예입니다. 광원의 중심에서 깊이 정보를 렌더링합니다. 광원에 가상의 카메라가 달려 있어 관찰된 깊이 정보를 저장해두면됩니다. 다시 말해 메인 패스에서 각 픽셀을 라이트 시점으로 투영해서 그림자 여부를 테스트 합니다.
구현 단계는 다음과 같습니다.
텍스쳐 포맷은 R32_TYPELESS + DSV(D32_FLOAT)/SRV(R32_FLOAT) 조합을 사용합니다.
래스터라이저에는 뎁스 바이어스/슬로프 바이어스 적용합니다
샘플러에는 선형, 클램프를 사용합니다.
HR_T(m_->m_pDevice->CreateShaderResourceView(m_->m_pShadowTex, &srvd, &m_->m_pShadowSRV));
// Shadow viewport
m_->m_ShadowViewport = { 0.0f, 0.0f, (float)m_->m_ShadowSize, (float)m_->m_ShadowSize, 0.0f, 1.0f };
// Depth-bias rasterizer
D3D11_RASTERIZER_DESC rd{}; rd.FillMode = D3D11_FILL_SOLID; rd.CullMode = D3D11_CULL_BACK;
rd.FrontCounterClockwise = true; rd.DepthBias = 1000; rd.SlopeScaledDepthBias = 1.0f; rd.DepthBiasClamp = 0.0f;
rd.DepthClipEnable = TRUE; rd.MultisampleEnable = FALSE; rd.ScissorEnable = FALSE; rd.AntialiasedLineEnable = FALSE;
HR_T(m_->m_pDevice->CreateRasterizerState(&rd, &m_->RSShadowBias));
// Shadow sampler (linear, clamp)
D3D11_SAMPLER_DESC ssd{}; ssd.Filter = D3D11_FILTER_MIN_MAG_LINEAR_MIP_POINT;
ssd.AddressU = ssd.AddressV = ssd.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP; ssd.MaxLOD = D3D11_FLOAT32_MAX;
ssd.ComparisonFunc = D3D11_COMPARISON_ALWAYS;
HR_T(m_->m_pDevice->CreateSamplerState(&ssd, &m_->m_pShadowSampler));
}
라이트 뷰 공간에서 포커스 주변 AABB(±r)를 투영해서 minZ/maxZ를 구해내고 near/far를 계산합니다
라이트 카메라 위치를 focus - dir * r로 두어 AABB 앞에 씬이 위치하도록 합니다
XY 텍셀 스냅으로 그림자 움직임을 따라 다닙니다
auto UpdateShadow = [this](XMFLOAT3& focusF) {
// 모델 중심을 포커스로 사용하여 카메라 움직임과 무관하게 안정화
XMVECTOR focus = XMLoadFloat3(&focusF);
XMVECTOR fwd = XMVector3Normalize(XMLoadFloat3(&m_->m_DirLight.direction)); // 라이트가 비추는 방향
float r = m_->m_ShadowOrthoRadius;
// 큰 오브젝트에서도 라이트 카메라가 항상 AABB 뒤쪽에 위치하도록 반경만큼 뒤로 물린다
float backDist = r;
XMVECTOR lightPos = XMVectorSubtract(focus, XMVectorScale(fwd, backDist));
XMVECTOR up = XMVectorSet(0, 1, 0, 0);
if (fabsf(XMVectorGetX(XMVector3Dot(up, fwd))) > 0.99f) up = XMVectorSet(0, 0, 1, 0);
XMMATRIX LView = XMMatrixLookToLH(lightPos, fwd, up);
// 라이트 뷰 공간에서 포커스 주변 AABB(±r)를 투영해 동적으로 near/far 계산
float minZ = 1e9f, maxZ = -1e9f;
for (int sx = -1; sx <= 1; sx += 2)
for (int sy = -1; sy <= 1; sy += 2)
for (int sz = -1; sz <= 1; sz += 2)
{
XMVECTOR cornerWS = XMVectorSet(focusF.x + sx * r, focusF.y + sy * r, focusF.z + sz * r, 1.0f);
XMVECTOR cornerLS = XMVector3TransformCoord(cornerWS, LView);
float z = XMVectorGetZ(cornerLS);
minZ = (z < minZ) ? z : minZ;
maxZ = (z > maxZ) ? z : maxZ;
}
// 여유 패딩(5%)을 주고 near는 0.01 이상으로 고정
float zPad = r * 0.05f;
float zn = (minZ - zPad);
if (zn < 0.01f) zn = 0.01f;
float zf = maxZ + zPad;
if (zf <= zn) zf = zn + 0.01f;
XMMATRIX LProj = XMMatrixOrthographicOffCenterLH(-r, r, -r, r, zn, zf);
// 텍셀 스냅: 라이트 뷰 공간 XY를 섀도우맵 텍셀 그리드에 정렬(near/far에는 영향 없음)
XMVECTOR focusLS = XMVector3TransformCoord(focus, LView);
float texelWorld = (2.0f * r) / (float)m_->m_ShadowSize;
float fx = XMVectorGetX(focusLS);
float fy = XMVectorGetY(focusLS);
float snapX = floorf(fx / texelWorld) * texelWorld;
float snapY = floorf(fy / texelWorld) * texelWorld;
XMMATRIX snap = XMMatrixTranslation(snapX - fx, snapY - fy, 0.0f);
LView = snap * LView;
XMMATRIX LVP = XMMatrixMultiply(LView, LProj);
m_->m_baseProjection.lightViewProj = XMMatrixTranspose(LVP);
};
동일 리소스를 DSV로 바인딩하기 전에 PS의 SRV(t4)를 해제합니다. 혹시 모를 충돌을 막기 위해서입니다.
VS만 사용합니다. PS는 잠시 사용하지 않습니다. 그리고 깊이만 렌더합니다
스키닝/비스키닝에서 VS를 선택합니다.
여기까지 했으면 90%는 완성한겁니다.
아래는 cpp 코드입니다.
// SRV/DSV 충돌 방지: PS의 t4 해제
ID3D11ShaderResourceView* nullSRV = nullptr;
m_->m_pDeviceContext->PSSetShaderResources(4, 1, &nullSRV);
// 섀도우 뷰포트/RTV/DSV 바인딩
m_->m_pDeviceContext->RSSetViewports(1, &m_->m_ShadowViewport);
ID3D11RenderTargetView* nullRTV = nullptr;
m_->m_pDeviceContext->OMSetRenderTargets(0, &nullRTV, m_->m_pShadowDSV);
m_->m_pDeviceContext->ClearDepthStencilView(m_->m_pShadowDSV, D3D11_CLEAR_DEPTH, 1.0f, 0);
if (m_->RSShadowBias) m_->m_pDeviceContext->RSSetState(m_->RSShadowBias);
// PS off
m_->m_pDeviceContext->PSSetShader(nullptr, nullptr, 0);
// 모델 루프: VSShadow 또는 VSSkinnedShadow
// ... world, lightViewProj, bias, mapSize, PCFRadius 업로드 ...
struct VSShadowOut
{
float4 posH : SV_POSITION;
};
VSShadowOut VSShadow(VertexIn vIn)
{
VSShadowOut o;
float4 posW = mul(float4(vIn.posL, 1.0f), g_World);
o.posH = mul(posW, g_LightViewProj);
return o;
}
t4: 섀도우 SRV, s1: 섀도우 샘플러
VS에서 전달한 light-space 위치를 사용합니다
g_ShadowBias, g_ShadowPCFRadius, g_ShadowMapSize를 파라미터로 사용합니다.
아래는 PixelShader 코드입니다.
// Shadowing (directional) with PCF
if (g_ShadowEnabled != 0)
{
// VS에서 가져온 라이트 공간 좌표 사용.동차좌표계
float3 sh = pIn.posShadowH.xyz / pIn.posShadowH.w; // NDC
float2 uv = sh.xy * float2(0.5f, -0.5f) + float2(0.5f, 0.5f); // [0,1]
float depth = sh.z; // light clip depth
// Early out if outside
if (all(uv >= 0.0.xx) && all(uv <= 1.0.xx))
{
float texel = 1.0f / max(g_ShadowMapSize, 1.0f);
float r = max(g_ShadowPCFRadius, 0.0f) * texel;
// 3x3 PCF
float sum = 0.0f; int taps = 0;
[unroll]
for (int dy = -1; dy <= 1; ++dy)
{
[unroll]
for (int dx = -1; dx <= 1; ++dx)
{
float2 uvOff = uv + float2(dx,dy) * r;
float d = g_ShadowMap.Sample(g_ShadowSamp, uvOff).r;
sum += (depth - g_ShadowBias <= d) ? 1.0f : 0.0f;
taps++;
}
}
float vis = sum / max(taps, 1);
litColor *= vis;
}
}
m_->m_pDeviceContext->PSSetSamplers(0, 1, &m_->m_pSamplerState);
// Bind shadow sampler/SRV
if (m_->m_pShadowSampler) m_->m_pDeviceContext->PSSetSamplers(1, 1, &m_->m_pShadowSampler);
if (m_->m_pShadowSRV) m_->m_pDeviceContext->PSSetShaderResources(4, 1, &m_->m_pShadowSRV);
// 큐브맵을 t1 슬롯에 바인딩 (픽셀 셰이더에서 g_TexCube : t1)
m_->m_pDeviceContext->PSSetShaderResources(1, 1, &m_->m_pTextureSRV);
간단하게 말해서 픽셀에서 주변 픽셀들의 평균을 구해서 색을 부드럽게 만드는 방법입니다. 그림자는 사실 부드럽다라는 표현이 그림자의 가장자리 부분이기 때문에 그림자의 가장자리 부분에 부드럽게 처리를 하면 됩니다.
무조건 여러번 계산해서 (다시말해 반복문을 여러번 돌아서) 부드러운 그림자를 얻는게 좋은게 아닙니다. 계산 비용이 많이 증가합니다.
Radius를 두고 샘플 포인트 간 간격을 결해서 값이 작을수록 필터링이 세밀하게 가능하지만 블러 효과는 감소하게 됩니다.
이 값들을 적절하게 조절해서 게임에 맞는 값을 넣는게 좋습니다.

| 그림자가 제대로 그려진 사진 | 그림자가 제대로 그려진 사진2 |
|---|---|