PCF보다 그림자를 부드럽게 표현해보자! PCSS(Percentage Closer Soft Shadows)

umbra - 물체에 가려 완전히 빛이 들어오지 않는 부분
penumbra - 그림자가 지지만 완전히 가려지지 않아 빛이 완전히 들어오지 않는 부분
우리말로는 반그림자라고도 한다.
지금까지는 조명이 점이라고 가정했지만 PCSS에서는 조명에 넓이가 있다고 가정한다.

왼쪽처럼 조명에 넓이가 있으면 penumbra(빨간색 영역)가 생긴다. 그리고 빛을 가리는 물체가 그림자가
드리워지는 물체와 거리가 멀어질수록 penumbra의 영역이 넓어진다. (오른쪽 그림)
PCSS는 내부적으로 PCF를 사용한다. PCF는 일정 거리안에 있는 픽셀들로부터 샘플링을 하고 그 거리를
정할 수가 있었다. 그 거리를 잘 조절하는 것이 PCSS의 원리다.
PCSS는 두 단계로 나누어진다.
1. Blocker Search - 그림자가 생길 수 있는지 확인, 빛을 가리는 물체(blocker)을 search한다.
2. Filtering - 그림자가 진다면 어떤 강도로 지는지 penumbra의 반지름을 계산해서 PCF를 실행한다.

Blocker Search할 때 PCSS는 빛의 넓이가 있으므로 선 두개를 그어서 nearPlane에 투영된 점이 아니라 투영된 영역을
찾는다. (search region) 그리고 저 영역에 대해 blocker가 있는지 전부 체크한다.

Blocker가 있다고 확인되었다면 penumbra의 영역에 따라 반지름을 조절하여 PCF를 실행해주면 된다.
위는 penumbra 영역을 구하는 공식이다.
penumbra 영역 = (Receiver 거리 - Blocker거리) * 빛 영역 / Blocker거리

이것을 코드로 옮길 때는 변경해야할 점이 있다. 우선 기본적으로 depth는 선형이지만 그것을 nearPlane에
projection하고나서는 선형이 아니다. projection 과정에서 z로 나누기 때문에 z값에 z의 나누기가 들어가므로
선형 형태가 아니게 된다. 그래서 view 좌표계에서 depth 값으로 계산을 해서 사용해야 한다.
shadowFactor = PCSS(lightTexcoord, lightScreen.z - 0.01, shadowMap, light.invProj, light.radius);
PCSS에서는 dx를 넣어줬는데 PCSS에서는 light.invProj와 light.radius를 넣는다. light.invProj는 비선형적인 depth를
view좌표계의 선형적인 depth로 바꾸기 위해 넣어준다.
float PCSS(float2 uv, float zReceiverNdc, Texture2D shadowMap, matrix invProj, float lightRadiusWorld)
{
float lightRadiusUV = lightRadiusWorld / LIGHT_FRUSTUM_WIDTH;
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 * lightRadiusUV * NEAR_PLANE / zReceiverView;
// STEP 3: filtering
return PCF_Filter(uv, zReceiverNdc, filterRadiusUV, shadowMap);
}
}
PCSS 함수이다. blockers가 1개 미만일 때 1.0 (빛에 가려지지 않음) 그 외는 위 식을 이용해서 penumbraRatio를 곱해주고
구한 filterRadiusUV를 dx 넣는 곳에 넣고 PCF_Filter을 한다.
void FindBlocker(out float avgBlockerDepthView, out float numBlockers, float2 uv,
float zReceiverView, Texture2D shadowMap, matrix invProj, float lightRadiusWorld)
{
float lightRadiusUV = lightRadiusWorld / LIGHT_FRUSTUM_WIDTH;
float searchRadius = lightRadiusUV * (zReceiverView - NEAR_PLANE) / zReceiverView;
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;
}
FindBlocker에서는 shadowPointSampler을 통해 Blocker을 찾는다. nearPlane에 projection되는 영역은 비례식을 통해
구할 수 있다. 그 영역을 searchRadius에 저장하고 Texture 좌표계에서 조명의 영역의 크기를 월드 좌표계에서 조명 영역의 크기에 월드 좌표계에서 View Frustum 넓이를 나누어 비율을 가지고 줄일 수 있다. 그 비율을 lightRadiusUV에 저장한다. 그리고 shadowMap에서 샘플링을 해오는데 샘플링한 depth는 비선형이다.
float N2V(float ndcDepth, matrix invProj)
{
float4 pointView = mul(float4(0, 0, ndcDepth, 1), invProj);
return pointView.z / pointView.w;
}
그래서 이것을 NDC 좌표계에서 View 좌표계로 변환한다.
// STEP 2: penumbra size
float penumbraRatio = (zReceiverView - avgBlockerDepthView) / avgBlockerDepthView;
float filterRadiusUV = penumbraRatio * lightRadiusUV * NEAR_PLANE / zReceiverView;
그렇게 penumbra 사이즈를 구하고 penumbra 사이즈를 depth map에서 찾아야하는 searchRadius로 바꿔줘야 한다.
penumbraRatio* lightRadiusUV가 penumbra 사이즈가 되고 그걸 nearPlane에 투영시킨 영역을 구하려면 NEAR_PLANE /
zReceiverView를 곱해줘야 한다.
'그래픽스' 카테고리의 다른 글
| [그래픽스] Compute Shader 써보기 (0) | 2023.07.24 |
|---|---|
| [그래픽스] Compute Shader 개념 및 구조 (0) | 2023.07.24 |
| [그래픽스] 부드러운 그림자 - PCF (0) | 2023.07.23 |
| [그래픽스] 그림자 (0) | 2023.07.23 |
| [그래픽스] Depth Map (0) | 2023.07.23 |