PCF(Percentage Closer Filtering)
samp_pcf->Create( D3D11_FILTER_COMPARISON_MIN_MAG_MIP_LINEAR,
D3D11_TEXTURE_ADDRESS_BORDER,
D3D11_COMPARISON_LESS_EQUAL);
...
_shadowMap.SampleCmpLevelZero(samp_pcf, uvd.xy, uvd.z);
sampler 생성을 comparison_linear 태그를 넣어서 넣어줌.
먼곳일 수록 깊이가 커진다는 전제하에서 Less Equal 만이 통과되도록 함.
그리고 쉐도우맵에서 값을 들고올 때 SampCmp 를 써서 z 값을 이용함.
그러면 z 값보다 작을경우 1을 아니면 0 을 줘서 이를 선형보간을 하게 되어 부드러운 외각선이 가능해짐.
Shadow Bias
위와같은 shadow acne 를 해결하기 위해 필요한 기법
이는 ShadowMap 의 resolution 이 유한하기 때문에 생기는데 위의 그림을 보자.
갈색 직선은 ShadowMap 에 기록된 깊이이고 검은색 계단은 실제 vertex 이다. ShadowMap 을 기록할 땐 resolution 의 한계로 저런 울퉁불퉁한걸 평균같은걸 내어서 기록을 하게 된다. 그리고 이를 바탕으로 그림자를 그릴 땐 하나의 픽셀에 여러 높이의 vertex 를 고려하게 되는게 대부분의 경우이다. 그렇기 때문에 갈색직선의 빨간색 부분은 그림자가 되어서 위 그림같은 줄무늬가 만들어지게 된다.
이는 피할 수가 없는게 카메라 위치는 가변이고 빛은 대개 정적이라 보는 각도에 따라서 필연적인 사태가 나오며, ShadowMap 의 Resolution 이 Backbuffer 의 Resolution 이랑도 같으라는 보장이 없기 때문이다.
해결하는 방법은 간단한데 ShadowMap 에 깊이를 넣을 때 Bias 로 조금더 값을 추가해준다. 즉 실제 값보다 더 깊다고 생각하게 하는 것이며, 위 그림으로 치면 갈색 직선이 아래로 내려가는 것이다.
Bias 가 너무 작으면 shadow acne 현상을 해결할 수 없으며 너무 크면 그림자가 물체랑 떨어지는 현상인 Peter Panning 이 일어나게 된다. 후자의 경우 Rasterizer 설정을 Cull Front 를 해서 해결한다. 이 경우 내부 그림자를 고려하지 않는 한 문제가 없다.
DirectX 의 Rasterizer 객체 생성시 bias 를 조절할 수 있어서 하드웨어적으로 관리할 수 있다.
Cascade Shadow Map
Directional Light
Directional Light 에 특히 적용되는 기법이라 우선 이것의 그림자 구현에 대해 설명함.
1. Directional Light 의 경우 적용대상은 Scene 의 전체라서 Shadow Map 에 모두 담기엔 텍스쳐 크기가 너무 작음.
=> Camera 시야에 있는 거만 기록하기로 함.
std::pair<Vector3, float> Camera::ExtractFrustumSphereBound(float nearZ, float farZ) { std::pair<Vector3, float> result; Vector3 axisX = Vector3(_view._11, _view._12, _view._13); Vector3 axisY = Vector3(_view._21, _view._22, _view._23); Vector3 axisZ = Vector3(_view._31, _view._32, _view._33); // The center of the sphere is in the center of the frustum result.first = _pos + axisZ * (nearZ + 0.5f * (nearZ + farZ)); // Radius is the distance to one of the frustum far corners result.second = (_pos + (axisX * _proj._11 + axisY * _proj._22 + axisZ) * farZ - result.first).Length(); return result; }
위처럼 Camera 의 View 행렬의 축을 가져와서 Frustum 의 중심을 구함.
이때 Frustrum 에 내접하는 구를 들고오는데 이후에 설명하겠지만 여러가지로 편하기 때문.
Radius 구하는게 약간 헷갈릴 수 있는데 View 공간의 Axis 가 world 공간에서의 표현된 값인걸 생각하면 이해는 쉬울거임. _proj._11 / _22 는 Project 에서 쓰는 fov 인데 여기서 axisX/Y 와 곱해지면 이것들은 Project Space 의 축 단위 벡터가 됨. X, Y, Z 축 단위벡터를 더한값은 frustum 의 정점을 잇는 직선 위에 있을 거임. 즉 z 만 조절하면 frustum 의 특정 점에 도달할 수 있고 이 값이 farZ 면 frustum 의 끝부분이란 것임.
계산을 열 중심으로하면 axis 도 위와 반대로 열로 되어 있을테니 주의.
그리고 위에서 nearZ + 0.5(nearZ + farZ) 가 아니라 0.5(nearZ + farZ) 인거 같은데 왜 책에선 nearZ 를 더하는지 모르겠음.
auto [center, radius] = camera->ExtractFrustumSphereBound(_cascade_ranges[0], camera->Get_Far()); // directional 에 회전은 의미가 없으므로 아무거나 dir 과 cross 할 뿐 Vector3 up = Vector3::Cross(_dir, { 1,0,0 }); view.LookAtUpLH(center, center + _dir, up); proj.OrthographicLH(2 * radius, 2 * radius, -radius, radius); // shadow map size
View Matrix 는 위에서 구한 Center 를 Eye 값으로, At 을 주어진 Light Direction 으로, Up 을 적당히 At 과 수직인 값으로 해서 계산함.
Project Matrix 는 위에서 구한 Radius 를 width, height, zFar 로 -Radius 를 zNear 로 해서 계산함.
이렇게 구한 ViewProj 를 이용해 ShadowMap 을 만들고, Scene 렌더링 시에 world 좌표를 이 행렬에 곱해서 z 를 비교해 그림자 여부를 지정함.
Cascade
그런데 이렇게 하면 문제가 있음.
검은 사각형을 텍스쳐라고 하고 주황색을 Frustum 이라고 생각하면 Directional Light 가 위에서 아래로 보인다는걸 고려하면 대개 위와 비슷한 모양이 됨.
여기서 바로 알 수 있는건 일단 텍스쳐에서 사용되지 않은 공간이 있다는 것임.
나아가서 절구체의 크기가 커서 삼각형에 가까울 경우 가까이 있는 부분은 텍스쳐의 매우 작은 부분만이 될 것임.
이를 해결하기 위해 텍스쳐를 여러개 사용해 가까움부터 멀리 있는 부분까지 따로 렌더링하는 기법이 이 기법.
for (int i = 0; i < Renderer::SHADOW_CASCADE_LEVEL; i++) { // sphere 을 써서 rotation 에 의존적이지 않게 함 auto[cas_center, cas_radius] = camera->ExtractFrustumSphereBound(_cascade_ranges[i], _cascade_ranges[i+1]); _cascade_radii[i] = std::fmaxf(cas_radius, _cascade_radii[i]); // pixel 단위로 업데이트 함으로써 translation 에 의존적이지 않게함 Vector3 offset = { 0 }; if (CascadeNeedsUpdate(view, i, cas_center, offset)) _cascade_centers[i] += view.Transpose() * offset; // it is relative to frustrum center as multiplied by vieproj auto trans_offset = -(viewproj * _cascade_centers[i]); trans = Matrix::PositionToMatrix({ trans_offset.x, trans_offset.y, 0 }); // zoom in
auto scale_offset = radius / _cascade_radii[i];
scale = Matrix::ScaleToMatrix({scale_offset ,scale_offset , 1});
// z value of vertices must be same within cascade space. so cannot make viewproj directly. _viewprojs[i] = scale * trans * viewproj; // scale 먼저면 trans 도 scale 되어 있어야하니 안댐 }
절구체를 나누는 경계가 _cascade_ranges 에 저장되어 있음.
이 값으로 위에서 설명한 내접하는 구를 구해서 절구체의 일부분에 대한 View Proj 를 구하는 것이 목표임.
이를 위해서 기존의 ShadowSpace 에서 x, y 값만 이동을 하고 화면에 꽉 차도록 확대하는 작업을 하게 됨.
절구체 부분에 대한 center, radius 로 바로 구할 수도 있지만 z 값이 미묘하게 달라서 호환이 안됨. 게다가 여러 ShadowMap Array 에 대해서 깊이 비교를 하기위해 행렬계산을 하는 것보다 scale 과 offset 만 계산하는 것이 더 빠름.
위는 계산 과정을 보여줌.
R_1 은 전체 구, R_2 는 부분구, S = R_1 / R_2,
C 는 R_1 의 위치, R_2 의 위치는 C + T
Z 이동하는 값이 다른걸 확인하면 됨
여기서 수정사항이 하나 있는데, scale 부분에 z 는 1이어야함.
Texture2DArray<float> _shadowCascadeMap : register(t4); float CacadeShadowPCF(float3 pos) { // Transform the world position to shadow space float4 posShadowSpace = mul(float4(pos, 1.0), _gshadow_viewproj); // Transform the shadow space position into each cascade position float4 posCascadeSpaceX = (_casade_offset_x + posShadowSpace.xxxx) * _casade_scale; float4 posCascadeSpaceY = (_casade_offset_y + posShadowSpace.yyyy) * _casade_scale; // Check which cascade we are in float4 inCascadeX = abs(posCascadeSpaceX) <= 1.0; float4 inCascadeY = abs(posCascadeSpaceY) <= 1.0; float4 inCascade = inCascadeX * inCascadeY; // Prepare a mask for the highest quality cascade the position is in float4 bestCascadeMask = inCascade; bestCascadeMask.yzw = (1.0 - bestCascadeMask.x) * bestCascadeMask.yzw; // mask0 이 true 면 나머지는 0이 됨 bestCascadeMask.zw = (1.0 - bestCascadeMask.y) * bestCascadeMask.zw; bestCascadeMask.w = (1.0 - bestCascadeMask.z) * bestCascadeMask.w; float bestCascade = dot(bestCascadeMask, float4(0.0, 1.0, 2.0, 3.0)); // Pick the position in the selected cascade float3 uvd; uvd.x = dot(posCascadeSpaceX, bestCascadeMask); uvd.y = dot(posCascadeSpaceY, bestCascadeMask); uvd.z = posShadowSpace.z; uvd.xy = pack_uv(uvd.xy); // Compute the hardware PCF value float shadow = _shadowCascadeMap.SampleCmpLevelZero(samp_pcf, float3(uvd.xy, bestCascade), uvd.z); // set the shadow to one (fully lit) for positions with no cascade coverage shadow = saturate(shadow + 1.0 - any(bestCascadeMask)); return shadow; }
위는 shadow attenuation 을 구하는 hlsl 함수임.
_ 로 시작하는게 상수버퍼에서 들고오는 값.
Shadow 공간으로 움직이게 한 후에 위에서 구한 scale, offset 을 적용시킴.
_cascade_scale 같은 값은 xyz 순으로 frustum 조각에 대한 값이 들어가 있음.
그래서 위처럼 dot 과 mask 를 이용해서 가장 적절한 값을 구하는게 가능해짐.
Slimmering
카메라가 움직일 때마다 그림자 가장자리가 울렁거리는 현상임.
이는 Shadow Map 을 그릴 때 픽셀 단위로 카메라를 움직이게 하고 - Translate, 그리는 반경을 구로 하면 - Rotate, 해결이 됨.
구로 그리는 것은 위에서 이미 했고, 픽센단위로 하는걸 아래에서 함.
bool CascadeNeedsUpdate(const Matrix& shadow_view, uint cascade_index, const Vector3& new_center, Vector3& out_offset) { auto old_center_view = shadow_view * _cascade_centers[cascade_index]; auto new_center_view = shadow_view * new_center; auto diff_center = new_center_view - old_center_view; // Find the pixel size based on the diameters and map pixel size. size / 2radius float pixel_size = (float)SHADOWMAP_SIZE / (2.0f * _cascade_radii[cascade_index]); float pixel_off_X = diff_center.x * pixel_size; float pixel_off_Y = diff_center.y * pixel_size; // Check if the center moved at least half a pixel unit bool r = abs(pixel_off_X) > 0.5f || abs(pixel_off_Y) > 0.5f; if (r) { // Round to the out_offset.x = floorf(0.5f + pixel_off_X) / pixel_size; out_offset.y = floorf(0.5f + pixel_off_Y) / pixel_size; out_offset.z = diff_center.z; } return r; }
ViewSpace 에서 단위벡터당 픽셀이 몇개 있는지를 pixel_size 에 할당함.
여기에 ViewSpace 에서 업덴 이전의 구의 중심의 차이를 곱해주면 몇 pixel 이 움직이는지 알 수 있음. 이 값을 이용해 정수단위로 움직이게 하고 다시 pixel_size 로 나누면 offset 이 적용된 구의 중심의 차이를 구할 수 있음.
매 업데이트 마다 이 값을 저장된 구의 중심값에 더해주면 됨.
PCSS(Percentage Close Soft Shadow)
float SpotShadowPCSS(float3 position) { float4 posShadowMap = mul(float4(position, 1.0), _shadow_viewproj); float3 uvd = posShadowMap.xyz / posShadowMap.w; uvd.xy = pack_uv(uvd.xy); // Search for blockers float avgBlockerDepth = 0; float blockerCount = 0; [unroll] for (int i = -2; i <= 2; i += 2) { [unroll] for (int j = -2; j <= 2; j += 2) { float4 d4 = _shadowMap.GatherRed(samp_point, uvd.xy, int2(i, j)); float4 b4 = (uvd.z <= d4) ? 0.0 : 1.0; blockerCount += dot(b4, 1.0); avgBlockerDepth += dot(d4, b4); } } // Check if we can early out if (blockerCount <= 0.0) return 1.0; // Penumbra width calculation avgBlockerDepth /= blockerCount; float fRatio = ((uvd.z - avgBlockerDepth) * _light_size) / avgBlockerDepth; fRatio *= fRatio; // Apply the filter float att = 0; [unroll] for (i = 0; i < 16; i++) { float2 offset = fRatio *_shadow_map_size_inv.xx * poissonDisk[i]; att += _shadowMap.SampleCmpLevelZero(samp_pcf, uvd.xy + offset, uvd.z); } // Devide by 16 to normalize return att * 0.0625; }
Blocker 검색을 하는데 ShadowMap 에서 일정 픽셀만큼 간격을 주어 주변을 검색하는 것. 얼마나 현 Pixel 이 Blocker 가장자리로부터 떨어져 있으면서 offset 에서는 닿고 있는지 확인하는 단계임.
이는 Ratio 를 구하기 위해서인데 현 픽셀은 Block 되지 않았고 가장자리에서 살짝 떨어져있으면 ratio 가 크고 멀리 떨어져 있으면 ratio 가 작음. 그리고 현 픽셀이 Block 되어 있는 경우 Blocker 안쪽으로 갈 수록 0으로 가고 바깥일 수록 ratio 가 커짐. 그리고 이 때 Block 된 깊이갚이 작을 수록 ratio 가 커지게 됨.
후자의 경우는 값이 음수가 되지만 offset 값이라 크게 문제되진 않음.
이러한 ratio 를 통해서 그림자 가장자리의 끝부분이 조금 더 부드럽게 됨.
Penumbra 를 제대로 비율에 맞게 그리는건 아니니 주의.
GatherRed 함수는 이걸 참조. 픽셀크기만큼의 offset 임.
ratio 를 제곱하는 이유에 대해선 잘 모르겠음.