PCSS기법이란 부드러운 그림자 기법 PCF를 활용하여 더 사실적인 그림자를 묘사하는 기법입니다. light의 너비를 고려하여 빛이 통과되는 부분도 있고 아닌부분도 있는 부분 즉 penumbra를 고려하여 그립니다.
- PCF기법
PCSS기법을 이용하기전에 PCF를 이해해야 하는데요, 레스터라이제이션에서는 raytracing으로 그림자를 처리하지 않고,
light의 카메라로 생각하고 얻은 depth값들을 shadowMap에 저장합니다. 그리고 원래 스크린에 해당하는 픽셀의 posWorld값에서 다시 light으로 투영변환을 거칩니다.(이 때, light를 카메라로 생각하고 투영변환한 투영행렬을 이용합니다) 그렇게 그 값들과 shadowMap을 비교해서 그림자를 판단합니다. 여기서 주위 픽셀 값들을 blend해서 에일리어싱을 조금 감소시킨 것이 PCF입니다.
코드는 다음과 같습니다.
float PCF_Filter(float2 uv, float zReceiverNdc, float filterRadiusUV, Texture2D shadowMap)
{
float sum = 0.0f;
for (int i = 0; i < 64; ++i)
{
float2 offset = diskSamples64[i] * filterRadiusUV;
sum += shadowMap.SampleCmpLevelZero(
shadowCompareSampler, uv + offset, zReceiverNdc);
}
return sum / 64;
}
- PCSS기법
PCSS는 PCF개념을 응용하는데요. 화면에 그릴 vertex의 위치를 정하고 그것을 light로 투영 변환을 한 값을 shadowMap으로 depth를 저장합니다. 그리고
penumbra는 다음 그림을보며 이해해봅시다.

완전히 검은 부분은 umbra부분으로 태양의 너비모두에서 빛을 받지 못합니다. 하지만 penumbra부분은 어느정도는 빛을 받기도 하고 가려지기도 하죠.
일단 우리가 해야할 것은 한 픽셀에 빛이 어느정도로 들어오느냐를 판단합니다. 그 부분의 위치(posWorld)를 이용해서 구하죠. 다음 그림을 보면 penumbra의 너비가 어떻게 변하는지 이해할 수 있습니다.

light와 가려지는 물체의 위치가 가까울수록 penumbra가 커지죠. 이 원리를 이용할 것입니다.
PCSS는 두가지 단계를 거칩니다.
1. 첫번째는 BlockSearch입니다.

그릴 픽셀에 해당하는 posWorld는 P입니다. 여기서 light의 끝 부분으로 선을 두개 그립니다. 여기서 우리는 blockDepth의 평균값을 원하는 것이기 때문에, near부분에 맺힌 값 전체에 샘플링을 하는 것으로 이해해야합니다. 여기서 blockDepth의 평균을 구하는 이유는 닿는 물체와 light의 거리에따라 penumbra의 크기가 달라지기 때문에 평균 penumbra를 구하는 과정이라고 이해하면 됩니다.
코드는 다음과 같습니다.
void FindBlocker(out float avgBlockerDepthView, out float numBlockers, float2 uv,
float zReceiverView, Texture2D shadowMap, matrix invProj, float lightRadiusWorld)
{
float searchRadius = lightRadiusWorld * (zReceiverView - NEAR_PLANE) / zReceiverView;
searchRadius /= LIGHT_FRUSTUM_WIDTH;
float blockerSum = 0;
numBlockers = 0;
for (int i = 0; i < 64; ++i)
{
float shadowMapDepth =
shadowMap.SampleLevel(shadowPointSampler, float2(uv + diskSamples64[i] * searchRadius), 0).r;
shadowMapDepth = N2V(shadowMapDepth, invProj);
if (shadowMapDepth < zReceiverView)
{
blockerSum += shadowMapDepth;
numBlockers++;
}
}
avgBlockerDepthView = blockerSum / numBlockers;
}
zReceiverView는 light에서 바닥부분의 거리고, near은 light 투영행렬에서 사용한 near값 입니다.
위에 그림을보면 zReceiverView-Near_Plane / zReceiverView의 비율이 serarchRadius / lightRadiusWorld라고 볼수 있죠.
근데 왜 searchRadius에서 light의 너비가 아니라 반지름을 이용하느냐? 하면, float2(uv + diskSamples64[i] * searchRadius) 이부분에서 반지름으로 판단하기 때문이죠. searchRadius가 dx역할을 하는 것입니다. diskSampels64는 64개의 점들을 고르게 분포해준 값이고요.
또한 if (shadowMapDepth < zReceiverView)이 부분에서 0.0~1.0의 depth값이 아니라 월드좌표계로 변환 후에 비교하고 있는데요. 0.0~1.0의 값은 ndc좌표계의 값이고, 비선형입니다. 우리가 도형의 비례식으로 판단하고 있는데 비선형으로 값을 정하면 안되겠죠.

ndc좌표계로 변환한게 오른쪽 값인데. /z로 z가 일차함수가 아닙니다. 따라서 다시 월드좌표계로 변환하는 것입니다. 그렇다고 월드좌표계로 변환한것이 왼쪽값은 아닙니다.
또 여기서 의문이 들 수 있는게, if (shadowMapDepth < zReceiverView)
{
blockerSum += shadowMapDepth;
numBlockers++;
} 이부분에서 왜 가리지 않는 부분은 평균에 해당하지 않느냐하면, near부분을 전체 샘플링 한다고해서 아직 빛의 세기를 구하는 단계가 아니기 때문입니다. 그냥 이 함수는 blockDepth의 평균을 구하는 과정일 뿐입니다. 빛의 세기를 구하는 단계는 다음 PCSS함수에서 나옵니다.
그리고 searchRadius /= LIGHT_FRUSTUM_WIDTH; 이 부분에서 왜 near_width로 나눠주냐하면, 나눠주기전에는 월드좌표계로 searchRadius크기가 나옵니다. near에 맺힌 크기이죠.
그런데 shadowMap.SampleLevel(shadowPointSampler, float2(uv + diskSamples64[i] * searchRadius), 0).r; 이 부분에서는 uv좌표계에서 searchRadius를 따지죠. near을 ndc좌표계로 바꿔줘야합니다. 예를들어 searchRadius가 0.3이고 near_width가 3이라고 하면, near_width로 나눠주지 않으면 uv좌표계에서 searchRadius가 너무 커지게 됩니다. 0~1중 0.3을 차지하니까요. 하지만 near_width로 나눠주면 0.1이되죠. 즉 near 클리핑에서 searchRadius의 비율을 가져가는 것입니다.
정리하자면, 여기서는 near클리핑 부분에서 radius를 uv좌표계로 변환후 샘플링을 통해, 평균 depth값을 구하는 과정입니다.
2. 두번째는 Penumbra estimation입니다.
다음은 PCSS 함수를 보겠습니다.
float PCSS(float2 uv, float zReceiverNdc, Texture2D shadowMap, matrix invProj, float lightRadiusWorld)
{
float zReceiverView = N2V(zReceiverNdc, invProj);
// STEP 1: blocker search
float avgBlockerDepthView = 0;
float numBlockers = 0;
FindBlocker(avgBlockerDepthView, numBlockers, uv, zReceiverView, shadowMap, invProj, lightRadiusWorld);
if (numBlockers < 1)
{
// There are no occluders so early out(this saves filtering)
return 1.0f;
}
else
{
// STEP 2: penumbra size
float penumbraRatio = (zReceiverView - avgBlockerDepthView) / avgBlockerDepthView;
float filterRadiusUV = penumbraRatio * lightRadiusWorld * NEAR_PLANE / zReceiverView;
filterRadiusUV /= LIGHT_FRUSTUM_WIDTH;
// STEP 3: filtering
return PCF_Filter(uv, zReceiverNdc, filterRadiusUV, shadowMap);
}
}
penumbraRatio는 다음 그림으로 이해합니다.

W light와 d(receiver)-d(blocker) / d(blocker)를 통해 w(penumbra)를 구합니다. 여기서 d(blocker)는 FindBlocker에서 얻은 평균 depth값을 뜻합니다. 그런데 여기서는 w가 월드좌표계의 크기입니다. 월드좌표계의 크기를 near로 옮겨주기위해, NEAR_PLANE / zReceiverView를 거치고요. 이게 near에 맺힌 값이죠? 여기서 아까처럼 LIGHT_FRUSTUM_WIDTH로 나눠줘서 uv좌표계로 옮겨줍니다. 그러면 이제 최종 값, 이것이 dx가 됩니다. 이것을 PCF_Filter에 넣어줘서 최종적으로 PCSS를 구현하게 됩니다.
크게 정리하자면,
1. depth의 평균을 구한다.
2. depth의 평균으로 penumbra의 크기를 구한다.
3. 그 크기를 dx로 이용해 pcf로 샘플링한다.
로 큰 줄기를 잡고 이해하면 도움이 될 것 같습니다.
내용이 많이 어려운데, 강의를 들으며 이 블로그의 내용을 참고해서 천천히 이해하면 이해가 될 겁니다.
출처 : 홍정모 그래픽스 새싹코스 part3 (부드러운 그림자 - PCSS)
참고 자료
https://developer.download.nvidia.com/shaderlibrary/docs/shadow_PCSS.pdf
Percentage-Closer Soft Shadows
Integrating Realistic Soft Shadows Into Your Game Engine
Advanced Soft Shadow Mapping Techniques, Louis Bavoil, GDC slides, 2008
'그래픽스 기술' 카테고리의 다른 글
[Compute Shader] Bitonic-sort (0) | 2025.02.16 |
---|---|
[ComputeShader] 컴퓨팅 쉐이더 픽셀 깨짐 (0) | 2025.02.15 |
[DepthStencil] 거울 반사의 원리와 구현 (0) | 2025.01.21 |
[PBR] HDRI - postprocess (0) | 2025.01.15 |
[Tessellation] quad tesselation순서 및 patch control point 계산 (0) | 2025.01.06 |