역자 주: 중국 개발사 엔진팀에서 근무 할 때 아트팀 요청(?) 아닌 요구에 의해 PCSS 와 VSM 정도를 URP 에 적용했던 경험 정도가 있는데요 개인적으로 모멘트 기반 섀도우 맵핑에 관심이 많습니다. 아마도 곧 작업을 해 봐야 할 것 만 같은 뭐 그런 느낌이네요.
저자: Metaneko
먼저 본문의 내용 구조를 첨부한다.

Shadow Map
간단히 말해, 광원의 시점에서 깊이 텍스처(Depth Map)를 렌더링하여 광원 위치에서 씬까지의 최소 깊이를 기록하는 방법이다. 렌더링 시 기록된 최소 깊이를 기반으로 현재 픽셀이 그림자 안에 있는지를 판단한다.
주의할 점은, 깊이 텍스처를 생성한 후 그림자를 계산할 때 특정 프래그먼트가 그림자 안에 있는지 검출해야 한다는 것이다. 이때 버텍스 셰이더에서 버텍스를 광원 공간(Light Space)으로 변환하고, 광원 공간의 버텍스 좌표와 일반적인 월드 공간의 버텍스 좌표를 함께 프래그먼트 셰이더로 전달한다. 프래그먼트 셰이더에서는 광원 공간의 좌표를 사용해 해당 픽셀에 그림자를 생성할지를 판단한다.
그림자 색상을 계산할 때, 그림자는 완전한 검정이 아니기 때문에 ambient 성분은 그림자 요소와 곱할 필요가 없다.
그러나 이 방법으로 계산된 그림자는 해상도의 한계로 인해 그림자 왜곡(Shadow Acne) 문제가 발생한다. 원인은 아래 그림과 같다.

섀도우 맵의 해상도가 낮은 경우, 깊이가 계단 형태로 존재하는 것으로 인식된다. 각 경사면은 깊이 텍스처의 텍셀 하나를 나타내기 때문에, 광원 방향과 수신면의 각도가 매우 작을 때 심각한 그림자 왜곡이 발생한다. 이를 개선하기 위해 그림자 계산 시 적절한 오프셋(Bias)을 추가해 일정 깊이 차이 범위 내에서는 그림자에 가려진 상태로 간주할 수 있다. 그러나 이로 인해 또 다른 문제인 피터패닝(Peter Panning) 현상, 즉 그림자가 실제 물체에서 떠오르는 듯한 현상이 발생한다.
CSM (Cascaded Shadow Maps)
섀도우 맵 해상도 문제를 해결하기 위해, CSM은 여러 장의 깊이 텍스처를 기록하고, 카메라 시점 씬의 Z값에 따라 서로 다른 해상도의 깊이 텍스처를 샘플링하여 그림자 앨리어싱(Shadow Aliasing) 문제를 해결한다.
CSM의 구체적인 처리 흐름은 다음 단계로 나뉜다:
- 깊이 Z를 기준으로 카메라 뷰 프러스텀을 분할한다.
- 분할된 각 부분의 바운딩 박스를 계산한다.
- 각 부분에 대한 투영 행렬을 생성한다.
- 각 부분에 대한 섀도우 맵을 생성한다.
- 각 픽셀에 적합한 섀도우 맵을 선택하여 그림자를 생성한다.
CSM은 한 번의 렌더링에서 여러 섀도우 맵을 계산해야 하며, 씬 내 섀도우 맵 경계에서 뚜렷한 전환 흔적이 나타날 수 있다. 이는 경계 부근에서 두 텍스처를 블렌딩하는 방식으로 제거할 수 있다. 또한 섀도우 맵이 매 프레임 재계산될 때 미세한 차이가 생겨 그림자 지터링(Jittering) 현상이 발생하기도 한다.
PCF (Percentage Closer Filtering)
PCF는 여러 필터링 방식을 조합하여 그림자를 더 부드럽게 만들고, 계단 현상(Aliasing)과 하드 엣지를 줄이는 기법이다. 간단한 PCF 구현은 텍셀 주변에서 깊이 텍스처를 여러 번 샘플링한 후 결과를 평균 내는 방식이다.
PCSS (Percentage Closer Soft Shadows)
먼저 현상에서 문제를 도출하자. 실제 조명에서 소프트 섀도우는 물체에 가까울수록 선명하고, 멀수록 흐릿하다. 따라서 거리에 따라 필터 크기를 결정하여 다양한 부드러움의 그림자를 렌더링해야 한다. PCSS는 거리에 따라 서로 다른 필터 크기를 선택하는 방법에 초점을 맞춘다.
필터 크기를 구하는 방법의 기본 아이디어는 아래 그림(GAMES202_Lecture_03 참조)과 같다.


그중 닮음 삼각형으로 구한

이 바로 해당 위치의 필터 크기가 된다.
또 한 가지 주의할 점은, 여기서의 Blocker 깊이는 해당 지점의 정확한 Blocker 깊이가 아니라는 것이다. 섀도우 맵에서는 각 점의 깊이가 어느 Blocker에 대응하는지 구분할 수 없기 때문에, 이 깊이는 실제로 해당 Blocker의 상대적인 평균 깊이이다.
따라서 PCSS 알고리즘은 3가지 주요 단계로 나눌 수 있다:
- Blocker Search: 특정 범위 내에서 Blocker 평균 깊이를 구한다.
- Penumbra Estimation: 구한 Blocker 깊이를 사용해 필터 크기를 계산한다.
- Percentage Closer Filtering: PCF를 사용해 그림자를 계산한다.
문제는, 필터 크기를 알려면 먼저 Blocker의 평균 깊이를 알아야 하고, 평균 깊이를 구하려면 특정 영역 범위가 필요하다는 것이다. 이 초기 범위를 구하는 방법에는 두 가지가 있다.
첫 번째 방법은 처음부터 고정된 영역 범위를 사용해 평균 깊이를 계산하는 것이다.
두 번째 방법은 광원의 뷰 프러스텀 내에서 섀도우 맵을 특정 위치에 배치하고, 픽셀에서 광원까지 연결선을 그어 그 선이 섀도우 맵에서 잘라낸 영역을 평균 계산 범위로 사용하는 것이다. 아래 그림(GAMES202_Lecture_03 참조)과 같다.

이 방식에 따르면, 섀도우 맵이 광원에 가까울수록 쿼리 범위가 커지고, 광원에서 멀수록 쿼리 범위가 작아진다. 이를 통해 초기 평균 깊이 쿼리 범위를 구해 평균 깊이를 계산한다.
PCSS는 현재 업계에서 그림자 계산에 매우 광범위하게 사용되지만, 소프트 섀도우 계산에 단점이 없는 것은 아니다. 알고리즘의 1단계와 3단계에서 두 번의 영역 쿼리가 필요해 연산 비용이 매우 크다. 이를 개선한 방법이 아래의 VSSM이다.
물론 비용을 줄이는 또 다른 방법은 영역 쿼리 시 모든 점을 조회하지 않고 랜덤 샘플링으로 근사치를 구하는 것이다. 이 방식은 근사적인 평균을 구할 수 있지만, 랜덤 샘플링 특성상 렌더링 결과에 노이즈가 발생한다. 노이즈를 제거하려면 렌더링 후 이미지 공간에서 디노이징 처리를 해야 한다. 최근 디노이징 알고리즘의 발전 덕분에 그 효과가 점점 좋아지고 있어, 이 방법도 꽤 훌륭한 선택지가 되고 있다.
VSSM (Variance Soft Shadow Mapping)
VSSM은 주로 PCSS의 1단계와 3단계에서 발생하는 과도한 비용 문제를 해결하기 위해 등장했다. 핵심 아이디어는 몇 가지 기법을 통해 근사치를 구하는 것이다.
먼저 개념적 발전 과정을 살펴보자. PCSS의 3단계 PCF에서는 필터 범위 내에서 현재 셰이딩 픽셀의 깊이보다 작은 픽셀이 얼마나 되는지를 계산해야 한다. 즉, 해당 깊이 미만에 해당하는 백분율 값을 알아야 한다는 뜻이다. 영역 내 깊이 분포를 정규 분포로 근사한다면, 평균과 분산만 알면 분포를 정의할 수 있다. 확률 분포가 정의되면 원래 필요했던 픽셀 깊이 가중 평균 계산값을 쿼리 방식으로 직접 얻을 수 있다.
이제 문제는 해당 영역의 평균과 분산을 어떻게 구하느냐로 바뀐다.
평균을 구하는 방법에는 두 가지가 있다. 첫 번째는 Mipmap을 사용하는 것이다. 하지만 Mipmap 기반 평균 계산은 정밀도에 한계가 있어, 두 번째 방법인 SAT(Summed Area Table)가 등장했다.
SAT는 프리픽스 합(Prefix Sum) 개념을 적용한 자료구조로, 각 셀에 저장되는 값이 해당 셀의 값에서 그 셀 이전의 모든 셀 값의 합으로 바뀐 형태이다. 이를 통해 직사각형 영역의 합을 구할 때 모든 셀을 순회할 필요 없이 몇 번의 덧셈과 뺄셈만으로 결과를 얻을 수 있다. 프리픽스 합의 응용에 대해서는 Leetcode에 관련 문제가 매우 많다.
다음은 분산 계산이다. 분산은 매우 기본적인 기댓값 공식을 활용한다.

이 공식을 통해 X의 기댓값의 제곱과 X 제곱의 기댓값만 알면 분산을 구할 수 있다. X 제곱의 기댓값을 위해서는 거리의 제곱을 기록하는 또 다른 섀도우 맵을 추가로 기록하면 된다. 이 새로운 맵과 기존 맵 총 두 장을 사용하면 필요한 분산 값을 쉽게 구할 수 있다.
두 장의 섀도우 맵이 생기지만, 실제로는 각 맵이 텍스처의 채널 하나만 사용하면 되므로, 하나의 렌더링 패스에서 두 맵을 하나의 텍스처에 저장할 수 있고, 쿼리 시에도 텍스처 샘플링 한 번으로 해결된다.
이제 깊이 분포의 PDF(확률 밀도 함수)가 준비되었다. 다음 과제는 이 PDF에서 특정 값보다 클 확률을 구하는 것이다. 범위가 정규 분포로 한정된다면 수치해로 구할 수 있지만, 정규 분포가 아니고 정밀한 해가 필요하지 않은 경우에는 체비쇼프 부등식(Chebyshev's Inequality)을 활용할 수 있다.

체비쇼프 부등식은 PDF에서 t값보다 클 최대 면적이 얼마인지를 알려준다. 우리가 필요한 것은 t보다 작은 면적이므로, 이 부등식으로 필요한 확률의 추정값을 쉽게 구할 수 있다. 체비쇼프 부등식은 정규 분포에 국한되지 않고, 비교적 단순한 단봉(Unimodal) PDF라면 이 방식으로 풀 수 있다. 따라서 단점도 명확하다. 씬의 섀도우 맵이 다봉(Multi-modal) PDF인 경우 오차가 크게 증가해 최종 렌더링 결과에 눈에 띄는 문제가 생긴다. 또 다른 문제는 이 부등식이 특정 조건에서만 정확하다는 것이다.
여기까지, PCSS 3단계의 비용 문제가 세 가지 O(1) 문제로 해결되었다. 이제 1단계인 Blocker Search에서 평균 깊이 계산 문제를 해결해야 한다.
5×5 영역을 샘플링한다고 가정하면(GAMES202_Lecture_04 참조):


Moment Shadow Mapping
VSSM은 깊이 X와 X의 제곱, 두 가지 맵을 저장했다. 즉, 2차 모멘트(2nd-order moment)를 사용해 확률 밀도 함수를 기술한 셈이다. Moment Shadow Mapping은 더 높은 차수의 모멘트를 사용하여 확률 밀도 분포 함수를 기술하고자 한다.
결론을 먼저 말하면, 전 m차 모멘트는 m/2개의 계단 함수를 근사할 수 있다. 아래 그림(GAMES202_Lecture_04 참조)과 같다:

그림에서 초록색 선은 4차 모멘트로 기술한 CDF(누적 분포 함수)를 나타낸다. 일반적으로 4차 모멘트를 사용하면 이미 꽤 좋은 결과를 얻을 수 있다.
자, 그림자 렌더링 알고리즘 정리는 여기서 마치겠다. 주로 GAMES202의 그림자 계산 강의 덕분에 많이 이해할 수 있었다. 현재 이 알고리즘들을 직접 구현해본 적은 없어서 이해도 상당히 제한적이다. 나중에 시간이 생기면 직접 구현해보면서 어디서 막히는지 체험해보고 싶다.
반 달이 넘게 지났고, Unity로 위 그림자 알고리즘들을 구현한 과정을 여기에 남긴다.
먼저 본문의 내용 구조를 첨부한다.
Unity 매크로를 이용한 그림자 구현
지난번에 그림자 구현 원리에 대해 설명했고, 이번에 드디어 Unity로 직접 구현해봤다. 원리 설명은 어렵지 않지만 실제 구현 과정에서 꽤 많은 함정들을 만났다. 아래에 이번에 만난 함정들과 전체 구현 과정을 기록해두겠다. 원래는 요즘 작성 중인 Vulkan 렌더러로 구현하려 했는데, 진도가 너무 느려서 RHI도 아직 캡슐화를 못 했다. 그냥 Unity로 먼저 해보기로 했다. 역시 학습은 한 걸음씩 차근차근 해야 한다는 걸 다시 느꼈다. 잡소리는 여기까지, 바로 시작하자.
Unity의 그림자니까 빠질 수 없는 것이 바로 Unity 내장 그림자 렌더링 매크로 세 가지다: SHADOW_COORDS, TRANSFER_SHADOW, SHADOW_ATTENUATION.
SHADOW_COORDS는 그림자 텍스처 샘플링에 사용할 좌표를 선언하는 데 쓰인다. 사용법도 매우 간단하여 v2f 구조체 안에 바로 선언하면 된다. 예를 들면:
struct v2f {
//다른 속성 선언
...
//그림자 텍스처 좌표 선언
SHADOW_COORDS(2)
};
TRANSFER_SHADOW의 구현은 플랫폼에 따라 다르다. 현재 플랫폼이 스크린 스페이스 섀도우 맵핑을 지원하면 내장 ComputeScreenPos 함수를 호출하여 _ShadowCoord를 계산하고, 지원하지 않으면 전통적인 섀도우 맵핑 기법을 사용해 버텍스 좌표를 모델 공간에서 광원 공간으로 변환하여 _ShadowCoord에 저장한다. v2f 구조체에 SHADOW_COORDS를 사용했다면, 버텍스 셰이더에서 TRANSFER_SHADOW를 그냥 사용하면 된다:
v2f vert(a2v v) {
v2f o;
//다른 속성값 계산
...
//텍스처 좌표 변환
TRANSFER_SHADOW(o);
return o;
}
SHADOW_ATTENUATION은 _ShadowCoord를 사용하여 그림자 관련 텍스처를 샘플링하여 그림자 정보를 가져온다. 이 매크로도 프래그먼트 셰이더에서 바로 사용하면 된다:
fixed4 frag(v2f i) : SV_Target {
//물체 표면 조명 계산
...
//내장 매크로로 그림자 샘플링
fixed shadow = SHADOW_ATTENUATION(i);
return fixed4(ambient + (diffuse + specular) * atten * shadow, 1.0);
}
물론 매크로인 만큼 네이밍 규칙도 있다. 이 매크로들이 올바르게 작동하려면 a2v 구조체의 버텍스 좌표 변수 이름이 반드시 vertex이어야 하고, 버텍스 셰이더의 입력 구조체 a2v는 반드시 v로 명명해야 하며, v2f의 버텍스 위치 변수는 반드시 pos로 명명해야 한다.
마지막으로 Unity 에디터에서 그림자를 드리울 오브젝트에는 Cast Shadow 옵션을, 그림자를 받을 평면에는 Receive Shadows 옵션을 켜주면 그림자가 렌더링된다. 결과는 대략 이렇다:

하지만 이것만으로는 내가 직접 한 게 없는 것 같아서, 깊이 텍스처를 직접 생성하고 직접 샘플링하는 완전히 직접 작성한 그림자를 렌더링해보고 싶었다.
Unity 매크로 없이 그림자 구현하기
Unity 매크로의 도움 없이 먼저 직접 깊이 텍스처를 생성해야 한다. 깊이 텍스처 생성은 대략 다음 단계로 나뉜다:
- 씬에 광원이 있고, 스크립트에서 광원 위치를 가져와 해당 위치에 카메라를 생성하고 초기화한다.
- 새로운 텍스처를 생성하여 카메라의 렌더 타겟으로 설정한다.
- 이 광원 카메라 렌더링 전용 셰이더를 작성하여 깊이 텍스처를 생성한다.
- Camera의 Render 메서드를 호출하여 렌더링한다.
이 네 단계가 깊이 텍스처를 생성하는 흐름이다. Unity가 오브젝트 렌더링 파이프라인을 호출하기 전에 이 과정을 완료하면 된다. 특별히 주의할 부분은 없으므로 핵심 코드를 바로 붙여넣겠다:
//카메라와 텍스처 생성
_lightCamera = CreateLightCamera();
_texture = CreateTexture((int) shadowResolution);
//카메라의 타겟 텍스처를 방금 생성한 텍스처로 설정
_lightCamera.targetTexture = _texture;
//광원의 위치 파라미터를 카메라에 전달하여 카메라와 광원을 같은 위치에 배치
var cameraTransform = _lightCamera.transform;
var lightTransform = dirLight.gameObject.transform;
cameraTransform.position = lightTransform.position;
cameraTransform.rotation = lightTransform.rotation;
cameraTransform.localScale = lightTransform.localScale;
//깊이 텍스처 셰이더로 렌더링 수행
_lightCamera.RenderWithShader(depthTextureShader, "");
//렌더링된 깊이 텍스처를 그림자 셰이더에 전달
Shader.SetGlobalTexture("_shadowMapTexture", _texture);
//광원 카메라의 VP 행렬을 셰이더에 전달
//오브젝트를 월드 좌표에서 광원 카메라의 클립 좌표로 변환하여 shadowCoord를 생성하는 데 사용
Matrix4x4 projectionMatrix = GL.GetGPUProjectionMatrix(_lightCamera.projectionMatrix, false);
Matrix4x4 viewMatrix = _lightCamera.worldToCameraMatrix;
Shader.SetGlobalMatrix(WorldToShadowID, projectionMatrix * viewMatrix);
다음은 깊이 텍스처를 렌더링하는 셰이더 코드이다. 주의할 점은 깊이 정밀도를 높이기 위해 깊이 값을 RGBA 4채널로 인코딩하여 저장한다는 것이다:
v2f vert(a2v v)
{
v2f o;
o.position = UnityObjectToClipPos(v.vertex);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
float depth = i.position.z / i.position.w;
//플랫폼에 따라 깊이 값 정규화
#if defined(SHADER_TARGET_GLSL)
depth = depth * 0.5 + 0.5;
#elif defined(UNITY_REVERSED_Z)
depth = 1 - depth;
#endif
return EncodeFloatRGBA(depth);
}
여기까지 깊이 텍스처 처리가 완료되었다. 이제 이 섀도우 맵으로 그림자를 생성할 차례다. 스크립트 쪽 코드는 신경 쓰지 않고 그림자 셰이더 작성에만 집중하면 된다.
그림자 셰이더의 v2f 구조체에는 그림자 샘플링 텍스처 좌표를 저장할 shadowCoord 속성을 수동으로 선언해야 한다. shadowCoord 계산도 간단하므로 바로 코드를 붙여넣겠다:
v2f vert(a2v v)
{
v2f o;
//다른 속성 계산
...
//그림자 샘플링 좌표 계산
//1. 오브젝트 좌표계 → 월드 좌표계
o.worldPos = mul(unity_ObjectToWorld, v.vertex);
//2. 월드 좌표계 → 광원 카메라 좌표계
//_worldToShadow는 방금 전달받은 광원 카메라 VP 행렬의 곱
o.shadowCoord = mul(_worldToShadow, o.worldPos);
}
이제 광원 공간의 shadowCoord를 얻었지만, 주의할 점이 있다. 여기서 계산한 shadowCoord는 여전히 광원 카메라 클립 좌표계의 값, 즉 범위가 [-1, 1]이다. 텍스처 샘플링을 위해 이를 [0, 1] 범위의 UV 좌표로 변환해야 한다:
float2 uv = i.shadowCoord.xy / i.shadowCoord.w;
uv = uv * 0.5 + 0.5;
UV 계산이 완료되면, shadowCoord의 z값을 사용하여 수신면 픽셀에서 광원까지의 실제 깊이를 계산해야 한다:
float depth = i.shadowCoord.z / i.shadowCoord.w;
#if defined(SHADER_TARGET_GLSL)
depth = depth * 0.5 + 0.5;
#elif defined(UNITY_REVERSED_Z)
depth = 1 - depth;
#endif
이제 방금 계산한 UV 좌표로 깊이 텍스처를 샘플링하고 실제 깊이 값과 비교하면 그림자 값을 계산할 수 있다. 판정 과정에서 그림자 바이어스(bias)도 추가하면 더 좋다:
float hardShadow(float depth, float2 uv)
{
float4 orignDepth = tex2D(_shadowMapTexture, uv);
float sampleDepth = DecodeFloatRGBA(orignDepth);
return (sampleDepth + _shadowBias) < depth ? _shadowStrength : 1;
}
고해상도와 저해상도 섀도우 맵의 그림자 결과를 각각 보여주겠다:


저해상도에서 그림자에 매우 큰 계단 현상이 나타나는 것을 볼 수 있다. 결과가 매우 좋지 않다. 그런데 흥미롭게도 저해상도 사도우 맵에서 광원 방향을 여러 가지로 바꾸어보듈는데 아무리 해도 Shadow Acne 현상이 나타나지 않았다. 정말 의아하다. 깊이 맵의 저장 정밀도, 광원 방향 등 여러 파라미터를 바꾸어보듈는데 Shadow Acne가 생겨야 할 줄무니가 전혀 나타나지 않았다. 이 글을 보시는 고수분들 중에 아시는 분이 있으시면 댓글로 꼭 알려주시길 바란다.
그래서 계단 현상을 해결하기 위해 모두가 아는 PCF를 꼼아냈다.
PCF
여기서 내가 사용한 PCF는 매우 간단하다. filterSize 크기에 따라 균일 필터링(평균 필터)을 적용하는 것이다. 원리가 간단하니 바로 코드를 보자:
float pcf(float depth, float2 uv, int filterSize)
{
float shadow = 0.0;
int halfSize = max(0, (filterSize - 1) / 2);
for(int i = -halfSize; i <= halfSize; ++i)
{
for(int j = -halfSize; j < halfSize; ++j)
{
//샘플링 시 픽셀 오프셋을 위해 uv에 _shadowMapTexture_TexelSize를 곱해야 한다
float4 orignDepth = tex2D(_shadowMapTexture, uv + float2(i, j) * _shadowMapTexture_TexelSize.xy);
float sampleDepth = DecodeFloatRGBA(orignDepth);
shadow += (sampleDepth + _shadowBias) < depth ? _shadowStrength : 1;
}
}
return shadow / (filterSize * filterSize);
}
PCF 적용 후 저해상도 깊이 맵의 그림자 결과를 보면, 계단 현상이 눈에 띄게 개선되었고 소프트 쀌도우의 느낌도 난다. 결과에서 사용한 filterSize는 5이다: 결과에서 사용한 filterSize는 5이다:

여기까지 왔으니 소프트 쀌도우도 구현해보기로 했다.
PCSS
PCSS 구현은 조금 복잡해진다. 이전 글에서 설명했듯, PCSS의 계산 과정은 세 부분으로 나눠다. 다시 한번 복붙지하겠다.
- Blocker Search: 특정 범위 내에서 Blocker 평균 깊이를 구한다.
- Penumbra Estimation: 구한 Blocker 깊이를 사용해 필터 크기를 계산한다.
- Percentage Closer Filtering: PCF를 사용해 그림자를 계산한다.
먼저 첫 번째 단계인 Blocker Search를 구현하자.
이 단계를 수행하기 전에, 섀도우 맵을 광원과 그림자 수신면 사이의 어떤 위치에 배치한다고 가정해야 한다. 맞다, 그 그림에서 묘사한 그것이다:

또한 이 광원이 면광원(Area Light)이라고 가정해야 한다. 이렇게 하면 광원 크기(Light Size) 값을 얻을 수 있다. 이 두 값을 이용해 그림의 두 닮음 삼각형 비례 관계를 통해 쀌도우 맵에서 Blocker를 탐색할 범위를 계산하고, 그 평균 깊이를 구할 수 있다.
다만 여기서는 위의 PCF처럼 모든 점을 탐색하지 않고 포아송 분포에 따라 무작위로 샘플 포인트를 선택하여 연산량을 줄인다. 코드는 다음과 같다:
float findBlocker(float depth, float2 uv)
{
int blockerSearchNumSamples = _Samples;
float lightSizeUV = _LightSize / _LightFrustumWidth;
float searchRadius = lightSizeUV * (depth - _NearPlane) / depth;
float blockerDepthSum = 0.0;
int numBlockers = 0;
for(int i = 0; i < blockerSearchNumSamples; i++)
{
float4 orignDepth = tex2D(_shadowMapTexture, uv + poissonDisk[i] * searchRadius);
float sampleDepth = DecodeFloatRGBA(orignDepth);
if(sampleDepth < depth)
{
blockerDepthSum += sampleDepth;
numBlockers++;
}
}
if(numBlockers == 0)
{
return -1.0;
}
return blockerDepthSum / numBlockers;
}
두 번째 단계는 깊이 맵에서 PCF 샘플링에 사용할 필터 크기를 계산하는 것이다. 여기도 두 단계로 나뉀며, 마찬가지로 두 닮음 삼각형의 비례 관계를 통해 구한다:
//해당 지점이 평면에 투영된 그림자 크기(소프트 쀌도우의 크기) 계산
float penumbraRatio = (depth - avgBlockerDepth) / avgBlockerDepth * _LightSize;
//그림자 크기를 역산하여 ShadowMap 상의 filter 크기 계산
float filterSize = penumbraRatio * _NearPlane / depth;
마지막 단계는 각 영역에 서로 다른 필터 크기로 PCF 샘플링을 수행하는 것이다. 여기서도 마찬가지로 포아송 분포를 이용해 무작위 샘플링을 하여 모든 픽셀을 순회하지 않는다:
float pcfSample(float depth, float2 uv, float filterSize)
{
float shadow = 0.0;
int numSamples = _Samples;
for(int i = 0; i < numSamples; ++i)
{
float4 orignDepth = tex2D(_shadowMapTexture, uv + poissonDisk[i] * filterSize);
float sampleDepth = DecodeFloatRGBA(orignDepth);
shadow += (sampleDepth + _shadowBias) < depth ? _shadowStrength : 1;
}
for(int i = 0; i < numSamples; ++i)
{
float4 orignDepth = tex2D(_shadowMapTexture, uv - poissonDisk[i] * filterSize);
float sampleDepth = DecodeFloatRGBA(orignDepth);
shadow += (sampleDepth + _shadowBias) < depth ? _shadowStrength : 1;
}
return shadow / (2.0 * numSamples);
}
이렇게 하면 기본적인 PCSS 소프트 윌도우 효과를 구현할 수 있다. 파라미터를 조정하면 대략 이런 결과가 나온다:

파라미터를 더 과감하게 조정하면 좀 더 극단적인 그림자 효과도 만들 수 있다(어떻게 해도 다른 사람들이 만든 것만큼 예쁜 것 같지 않아서 나중에 최적화해봐야겠다):

마지막으로 PCSS의 연산량 문제를 해결하기 위해 등장한 VSSM이다.
VSSM
VSSM 구현의 핵심은 샪도우 맵에 깊이와 깊이의 제곱을 동시에 저장해야 한다는 것이다. 따라서 깊이 텍스처를 계산하는 셰이더를 수정해야 한다. 수정 방법은 간단하다. 두 값을 바로 출력하면 된다. 다만 나는 여기서 깊이의 4제곱까지 저장했다:
fixed4 frag(v2f i) : SV_Target
{
float depth = i.position.z / i.position.w;
#if defined(SHADER_TARGET_GLSL)
depth = depth * 0.5 + 0.5;
#elif defined(UNITY_REVERSED_Z)
depth = 1 - depth;
#endif
float depth2 = depth * depth;
float depth3 = depth2 * depth;
float depth4 = depth3 * depth;
return float4(depth, depth2, depth3, depth4);
}
분산 계산 시 필요한 것은 특정 영역 내 깊이의 기댓값과 깊이 제곱의 기댓값이다. 즉, 이 깊이 텍스처를 그냥 셰이더에 넣어서는 그 기댓값을 얻을 수 없다. 따라서 이 계산된 깊이 텍스처를 Compute Shader에 넣어 한 번 균일 필터링(Blur)을 수행한다. 스크립트에서 Compute Shader를 호출하는 코드를 몇 줄 추가한다:
//반복 횟수는 compute shader 호출 횟수. 매 호출 시 compute shader 내에서 3*3 균일 필터 1회 실행
for (int i = 0; i < 7; ++i)
{
blurShader.SetTexture(0, "Read", _texture);
blurShader.SetTexture(0, "Result", _blurTexture);
blurShader.Dispatch(0, _texture.width / 8, _texture.height / 8, 1);
Swap(ref _texture, ref _blurTexture);
}
겨사겨사 Unity의 Compute Shader 작성법도 배웠다:
void CSMain (uint3 id : SV_DispatchThreadID)
{
float4 pixel = float4(0, 0, 0, 0);
for(int i = -1; i <= 1; ++i)
{
for(int j = -1; j <= 1; ++j)
{
uint2 index = id.xy;
index.x += i;
index.y += j;
pixel += Read[index.xy] / 9;
}
}
pixel.a = 1;
Result[id.xy] = pixel;
}
처리가 완료된 텍스처를 그림자 셰이더에 전달하면, 분산 계산 시 해당 픽셀의 깊이와 깊이 제곱 값을 샘플링한 후 바로 차이를 계산하면 된다. 이후 단계는 공식대로 따라가면 된다:
//핵심 코드는 이것뿐
float vssm(float depth, float2 uv)
{
float4 depthTexture = tex2D(_shadowMapTexture, uv);
float d1 = depthTexture.r;
float d2 = depthTexture.g;
float variance = clamp(d2 - d1 * d1, 0, 1);
float delta = depth - d1;
if((d1 + _shadowBias) < depth)
{
float p1 = variance;
float p2 = variance + delta * delta;
float p = p1 / p2;
return p;
} else
{
return 1.0;
}
}
VSSM의 최종 결과물이다:

주의할 점은, VSSM 깊이 텍스처를 계산할 때 텍스처 포맷을 RGFLOAT로 변경하여 더 높은 정밀도를 확보해야 한다는 것이다. 그렇지 않으면 깊이 제곱 계산 시 정밀도 문제로 오차가 생기고, 분산 계산에서 그 오차가 매우 큰 영향을 미친다. 이 문제로 꾽 오랜 시간 고생했다. 정밀도 문제임을 짐작하기는 했지만, 최종적으로 텍스처 포맷을 수정하는 방법으로 해결될 줄은 몰랐다.
잘못된 결과 예시도 첨부한다:

이상이 각종 그림자를 구현하는 과정에서 하나씩 만나며 해결한 함정들이다. 꾽 많은 시간이 걸렸다(중간에 대부분의 시간을 목스터 헌터를 하며 날려버린 건 비밀이다). 코드는 GitHub에 올려둘 예정이니 관심 있는 분들은 가져가시길. 물론, 알고리즘이나 구현에 오류가 있다면 알려주시면 정말 감사하겠다. Unity 뉴비이자 그래픽스 뉴비인지라.
'TECH.ART.FLOW.IO' 카테고리의 다른 글
| [번역] Modern rendering culling techniques (3) | 2026.04.24 |
|---|---|
| [번역] SurfelPlus Project Page. (0) | 2026.04.22 |
| [번역] 명일방주: 엔드필드 캐릭터 렌더링 (Unity URP) (1) | 2026.04.13 |
| [발표 번역] UF2025(Shanghai)—《델타포스》글로벌 일루미네이션 기술 심층 분석: Lightmap에서 Lumen까지 크로스플랫폼 구현의 여정 (0) | 2026.04.10 |
| [번역] UE5 포스트 프로세스 ScreenPass에서 에디터 뷰포트 UV를 올바르게 가져오기 (5) | 2026.04.10 |