2021년 2월 19일 금요일

Shadow 기법들

 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 를 제곱하는 이유에 대해선 잘 모르겠음.







List