2025.02.15 - [TA] - [Unity] Custom Shadow System 제작하기 (2) - Directional Light Shadow Map 그리기
[Unity] Custom Shadow System 제작하기 (2) - Directional Light Shadow Map 그리기
2024.12.30 - [TA] - [Unity] Custom Shadow System 제작하기 (1) - 커스텀 그림자 제작의 필요성 [Unity] Custom Shadow System 제작하기 (1) - 커스텀 그림자 제작의 필요성캐릭터는 젠레스 존 제로의 니콜이다. MMD 공
gohen.tistory.com
Shadow Map을 그렸으니 이제 이걸 샘플링을 하면 끝이다. Per Object Shadow는 Cascade Shadow Map 만드는 것보다 오히려 더 간단한 느낌이다. 뭐 계속 캐릭터 하나만 가지고 샘플 코드만 작성중이라 더 쉬운 것도 있긴 하다.
Shadow Coordinate
Shadow Map을 렌더링 했다면 이제 이 Shadow Map을 샘플링을 해야 한다. 머리속으로야 '이게 머 호모 사피엔스 머시기 게이머람 공간이라서 -w~w 사이 값을 가지는 좌표로 변환되니까 w로 나눠야 NDC 공간 좌표, 즉 -1~1사이 값이 되고 이걸 -1~1에서 0~1사이 값으로 변경하기 위한 좌표 변환을 해야한다 같은 것들이 떠올라서 막 이걸 그대로 uv로 쓸 수 없고 조정이 필요하다' 같은 판단을 내리기는 하지만 항상 머리와 가슴은 따로노는 법. 가슴은 아직 이를 인정하지 못했다. 나같은 멍청이는 그냥 한번 눈으로 보면 된다.
다음과 같이 셰이더를 짰다. _ToLightMatrix는 그냥 view, projection matrix 곱한 값이다.
Shader.SetGlobalTexture("_ShadowMap", rt);
var gpuProj = GL.GetGPUProjectionMatrix(proj, false);
Shader.SetGlobalMatrix("_ToLightMatrix", gpuProj * view);
struct V
{
float3 positionOS : POSITION;
};
struct F
{
float4 positionCS : SV_POSITION;
float3 positionWS : TEXCOORD2;
};
F vert(V i)
{
F o;
o.positionWS = TransformObjectToWorld(i.positionOS);
o.positionCS = TransformWorldToHClip(o.positionWS);
return o;
}
TEXTURE2D(_ShadowMap);
float4x4 _ToLightMatrix;
half4 frag(F i) : SV_TARGET
{
float4 shadowCoord = mul(_ToLightMatrix, float4(i.positionWS, 1.0));
return half4(shadowCoord.xy, 0.0, 1.0);
}
월드 좌표에 Light 기준의 view, projection matrix를 곱해서 HClip 좌표로 변환하고, xy를 그대로 출력해본 것이다.
w로 굳이 나누지 않아도 뭔가 좌표처럼 기능하는 것을 확인할 수 있었다. w로 나누지 않아도 되는 이유는 projection matrix가 orthographic matrix 라서 그런 걸로 추측되는데, 행렬 멍청이인 나는 이 사실을 입증하기 위해 다시 공부를 해야 한다. 그 뭐냐 대충 그럴 거 같다고 생각되는데 증명해보라고 하면 도게자를 박아야할 것 같은 그런 느낌적인 느낌.
일단 넘어가자.
shadow map을 제대로 샘플링 하려면 다음과 같은 형태로 uv가 나와야 한다.
그러면 우선 현재 중심 좌표가 0,0으로 되어 있는데, 이 중심좌표가 왼쪽 하단으로 위치하도록 좌표 변환을 해주면 된다.
float4 shadowCoord = mul(_ToLightMatrix, float4(i.positionWS, 1.0));
float2 uv = shadowCoord.xy * 0.5 + 0.5;
return half4(uv, 0.0, 1.0);
Box 메쉬를 Bounds 내부에 넣고 확인해보니, 얼추 맞는 것처럼 보인다. 그럼 이 uv를 가지고 shadow map을 샘플링하면?
float4 shadowCoord = mul(_ToLightMatrix, float4(i.positionWS, 1.0));
float2 uv = shadowCoord.xy * 0.5 + 0.5;
float rawDepth = SAMPLE_TEXTURE2D(_ShadowMap, sampler_LinearClamp, uv).r;
return half4(rawDepth.r, 0.0, 0.0, 1.0);
잘 나온다. shadowCoordinate에 대한 좌표 변환은 제대로 동작했다.
그러면 이제 shadowCoord의 z값과 shadow map 샘플 데이터의 값을 비교해서 출력해보자.
half4 frag(F i) : SV_TARGET
{
float4 shadowCoord = mul(_ToLightMatrix, float4(i.positionWS, 1.0));
float2 uv = shadowCoord.xy * 0.5 + 0.5;
float rawDepth = SAMPLE_TEXTURE2D(_ShadowMap, sampler_LinearClamp, uv).r;
float shadow = shadowCoord.z <= rawDepth ? 1.0 : 0.0;
return half4(1.0-shadow, 0.0, 0.0, 1.0);
}
clip space 좌표에서 x,y는 [-1,1] 범위를 가지지만, z좌표는 [0,1] 좌표가 된다. 그러므로 텍스처 샘플 데이터인 rawDepth와 shadowCoord.z 값을 단순 비교를 해도 같은 공간에서 깊이 값을 비교한다고 판단할 수 있다.
오오, 벌써 그림자가 나타난다. 당연히 첫 포스팅에서 언급한 것처럼 shadow acne라고 불리는 현상이 나타난다. 그림자 맵은 16비트로 저장되어 있음에 반해 shader에서 계산한 shadowCoord는 더 높은 정밀도를 가지기 때문에 비슷한 깊이 값일 때 어디는 그림자고 어디는 그림자가 아니고를 반복하면서 저렇게 점 같은 것들이 나타난다.
그러면 확실하게 차이가 날 수 있게 shadowCoord.z 값에 Bias를 적용해보자. 복잡한 Bias 값이 아닌, 임의의 작은 상수값을 더하는 것만으로 효과를 확인할 수 있다.
half4 frag(F i) : SV_TARGET
{
float4 shadowCoord = mul(_ToLightMatrix, float4(i.positionWS, 1.0));
float2 uv = shadowCoord.xy * 0.5 + 0.5;
float rawDepth = SAMPLE_TEXTURE2D(_ShadowMap, sampler_LinearClamp, uv).r;
float bias = 0.0005;
float shadow = ((shadowCoord.z + bias) <= rawDepth) ? 1.0 : 0.0;
return half4(1.0-shadow, 0.0, 0.0, 1.0);
}
이렇게 심히 간략화되었지만 Per Object Shadow를 구현했다. 이 shadow 값을 이용해서 라이팅 처리를 하도록 셰이더만 작성하면 끝이다.
심연 속으로
라고 생각하던 때가 있었지요.
진정한 시작은 이제부터. 가까이서 보면 생각보다 그림자 퀄리티가 구리다는 걸 알 수 있다.
여기까지 오면서 응애였다면 와 내가 커스텀 그림자를 만들었어! 하고 뿌듯해하고 마무리를 지었겠지만 난 이젠 슬프게도 응애가 아니다. 게임 출시했는데 그림자가 저 따위로 나오면 누가 하겠는가. 퀄리티업과 범용성, 편의성 등등을 챙기는 영웅호걸의 시간이 다가왔다.
- 기기 스펙에 맞는 shadow map 해상도
- 그림자맵에 그려지는 캐릭터의 Texel 비율이 가로 세로가 일정하지 않은 문제
- shadow map 크기와 bounds 사이즈 등을 고려한 동적 shadow bias 값
- 하나의 캐릭터가 아닌 여러 캐릭터에 대한 대응
- 캐릭터 그림자가 배경에도 지도록 만드려면?
- 최적화 최적화 최적화! Shader에서의 계산은 최대한 CPU로 빼내야 한다. ALU 연산을 최대한 감축해야 한다!
퀄리티업
자, 일단 백문이 불여일견. 먼저 다음 비주얼까지는 만들어 보았다.
TEXTURE2D_SHADOW(_ShadowMap);
SAMPLER_CMP(sampler_LinearClampCompare);
float4x4 _ToLightMatrix;
float3 _LightDirection;
half4 frag(F i) : SV_TARGET
{
float3 worldPos = i.positionWS;
float NdotL = 1.0 - abs(dot(i.normalWS, _LightDirection));
float bias = 0.02;
float4 shadowCoord = mul(_ToLightMatrix, float4(worldPos, 1.0));
float2 uv = shadowCoord.xy * 0.5 + 0.5;
float z = shadowCoord.z + NdotL * bias;
float shadow = real(SAMPLE_TEXTURE2D_SHADOW(_ShadowMap, sampler_LinearClampCompare, float3(uv, z)));
// return half4(NdotL.rrr, 1.0);
return half4(shadow.rrr, 1.0);
}
먼저 위에서는 직접 깊이 값을 비교한 후에 그림자인지 여부를 판정했으나, 사실 그림자맵 샘플링은 매우 오래된 기법이기 때문에 이미 ShadowMap을 하드웨어에서 미리 처리해주는 방식이 정의되어 있다.
따라서 Texture를 정의할 떄 TEXTURE2D_SHADOW라고 하고, 샘플러도 SAMPLER_CMP 형태로 선언한 후에 SAMPLE_TEXTURE2D_SHADOW 매크로를 써서 텍스처 맵을 샘플링하는 명령을 내릴 경우 알아서 깊이 비교를 수행하고 Shadow Attenuation 값을 리턴해준다.
두번째로 적용해본 기법은 이 커스텀 그림자 맵에 대한 첫번째 포스팅에 설명된 Slope Bias를 내 나름대로 해석해서 적용해본 것이다. 현재 메쉬의 Normal과 Light Direction에 대한 Dot Product, 즉 Lambert 값의 절대값은 라이트 방향과 얼마나 평행한지를 나타내는 척도로 활용할 수 있다. 그래서 절대값을 취한 뒤 1에서 이 값을 빼주면 메쉬가 Light와의 각도가 많이 차이날수록 Z값을 더 앞으로 당긴 값과 그림자 맵을 비교하도록 한다.
제작한 Slop Bias 기반 샘플링 | Depth Bias 기반 샘플링 |
![]() |
![]() |
Depth Bias의 경우 다음과 같이 계산할 수 있다.
float depthBias = 0.01;
float3 worldPos = i.positionWS + _LightDirection * depthBias;
World Position 값에 Light 방향으로 일정 수치만큼 오프셋을 추가한 후에 이 값을 ShadowCoordinate로 변경하는 방식이다. 딱 봐도 World Position 값에 일정한 값을 밀어서 사용하는 방식은 별로 결과가 좋지 않음을 확인할 수 있다. 게다가 이 방식을 쓰다보면 Peter Panning 문제도 발생할 수 있다.
유니티의 ShadowBias 적용 코드를 보자.
// URP Shadows.hlsl 468줄
float3 ApplyShadowBias(float3 positionWS, float3 normalWS, float3 lightDirection)
{
float invNdotL = 1.0 - saturate(dot(lightDirection, normalWS));
float scale = invNdotL * _ShadowBias.y;
// normal bias is negative since we want to apply an inset normal offset
positionWS = lightDirection * _ShadowBias.xxx + positionWS;
positionWS = normalWS * scale.xxx + positionWS;
return positionWS;
}
Normal 값과 World Position 값이 들어왔을 때, Depth Bias를 적용하는 방식, 즉 positionWS 값에 조명 방향으로 일정 수치를 더하는 것은 동일하다.
추가적으로 Normal Bias 값의 Scale을 지정하는 부분이 인상적이다.
핵심은 invNdotL이다. 위에서 이미 언급했지만 표면 각도가 조명과 얼마나 차이나는지에 따라 가중치를 주는 방식으로 동작하고 있다.
유니티의 방식대로 PositionWS값을 수정해보면 어떻게 될까?
흠... 별로 좋아보이지 않는다. 캐릭터의 노말 값이 애초에 수정된 상태라서 그런가? 실제 쓰여진 Depth에 맞는 Normal 값을 사용해야 하나? Normal 값을 Bias로 적용하기 위해선 Smoothed Normal을 쓰거나, 아니면 Polygon Normal을 사용해야 할 것으로 보인다.
CPU에서 미리 연산해서 가져오기
view projection matrix가 clip space 에 있기 때문에 0.5를 곱하고 다시 0.5를 더해주는 작업은 모든 픽셀이 동일한 연산을 수행하기 때문에 안 그래도 할게 많은 GPU를 불필요하게 사용하게 된다.
그러므로 CPU에서 미리 이 계산이 수행되도록 하면되는데, 이걸 쉽게 생각하는 방법이 행렬 X Vector4가 원하는 값이 되도록 하기 위한 행렬이 뭐가 되어야 하는지 추측해보는 것이다.
view, projection matrix가 곱해진 light space의 positionCS(Clip Space Position)을 생각해보자.
(positionCS.x, positionCS.y, position.z, position.w) 에서
(positionCS.x *0.5 + 0.5, positionCS.y * 0.5 +0.5, positionCS.z, positionCS.w)로 만들기 위한 행렬은 어떻게 되어야 할까?
positionCS.w 값이 1이라는 가정하에, 위 이미지와 같은 방식으로 행렬 곱을 수행하도록 하면 Shadow UV Matrix를 계산해낼 수 있다.
사실 W가 1이 아니어도 된다. 왜냐하면 positionCS는 다시 풀어서 쓰면
(positionNDC.x * positionCS.w, positionNDC.y * positionCS.w, positionNDC * positionCS.w, positionCS.w) 라고 볼 수 있기 때문이다. Homogeneous clip space 포지션이기 때문에 만약 Orthographic matrix일 경우에는 w값이 1이라서 그냥 쓰면 되고, Perspective Matrix인 경우에는 어쩔 수 없이 w 값으로 한번 나누는 작업을 추가해주면 그만이다.
그러므로 View Projection Matrix에 저 UV 공간 변환 Matrix를 곱해주면 바로 shadow UV Matrix로 만들 수 있게 된다.
Matrix4x4 toLightMatrix = gpuProj * view;
Matrix4x4 toUVMatrix = new Matrix4x4(new Vector4(0.5f, 0, 0, 0), new Vector4(0, 0.5f, 0, 0),
new Vector4(0, 0, 1f, 0), new Vector4(0.5f, 0.5f, 0, 1.0f));
Shader.SetGlobalMatrix("_ToLightMatrix", toUVMatrix * toLightMatrix);
이렇게 바꿔주고 shader에서 uv 변환 부분을 주석처리했을 때 결과를 확인해보면 동일한 것을 알 수 있다.
'TA' 카테고리의 다른 글
[Unity] Skeletal Animation 동작에 대하여 (0) | 2025.02.28 |
---|---|
[Unity] Custom Shadow System 제작하기 (2) - Directional Light Shadow Map 그리기 (0) | 2025.02.15 |
(작성중) [Unity] URP환경에서 GPU Instancing Tool 만들기 (0) | 2025.01.20 |
[Unity] Custom Shadow System 제작하기 (1) - 커스텀 그림자 제작의 필요성 (0) | 2024.12.30 |
(작성중) [Unity] Irradiance Volume 만들기 (짭 Adaptive Probe Volume) (0) | 2024.12.29 |