ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [DirectX11] 쉐도우 맵과 PCF(Percentage Closer Filtering)
    DirectX11 2023. 12. 15. 13:59

     

    이전 글의 결과물을 보면 그림자가 없어서 어색하기도 하지만, 물체가 바닥과 얼마나 떨어져있는지 구분이 안되기도 한다.

     

    그림자가 없는 상태

    실제로는 바닥에 닿아있는 상태지만 떠있는듯이 보이는 문제가 있다.

    그림자를 추가하면 더 자연스러워질 것이다.

     

     

     


     

     

     

    그림자를 그리기 위해서 쉐도우 매핑을 한다.

    이론에 대해서는 자세하게 알려주는 글이 많으니 자세히 다루지는 않겠다.

    간단하게 적으면 카메라 시점에서 렌더링 하기 전에 조명 시점에서 모든 물체를 렌더링하고 깊이 정보를 저장한다.

    그 다음에 카메라 시점에서 렌더링 할 때 조명시점에서의 깊이 정보를 사용해서 그림자가 진 영역인지 아닌지를 판단한다.

    카메라 시점에서 렌더링할때 대상의 월드 좌표를 알 수 있고, 이 월드 좌표에서부터 조명까지의 거리도 알 수 있다.

    만약 이 조명까지의 거리가 조명시점에서의 깊이 정보보다 멀다면 그림자가 진 영역이다.

    조명 시점에서 보면 더 가까운 무언가가 존재한다는 의미기 때문이다.

     


     

    다이렉트x에서는 렌더링된 결과를 텍스쳐로 저장할 수 있다.

    조명 시점에서 렌더링해서 얻은 깊이 정보를 텍스쳐를 통해 저장해두고, 카메라 시점에서 렌더링할때 텍스쳐를 전달해주는 방식이다.

    그렇게 하기위해서 필요한 것들은 다음과 같다.

    // Shadow map
    D3D11_VIEWPORT             g_ShadowViewport;
    ID3D11Texture2D*           g_pShadowMap = NULL;
    ID3D11ShaderResourceView*  g_pShadowSRV = NULL;
    ID3D11DepthStencilView*    g_pShadowDSV = NULL;
    ID3D11SamplerState*        g_pShadowComparisonSampler = NULL;
    ID3D11Buffer*              g_pCBLightBuffer = NULL;
    ID3D11RasterizerState*     g_pShadowRasterizerState = NULL;
    ID3D11VertexShader*        g_pShadowVertexShader = NULL;
    ID3D11VertexShader*        g_pShadowInstanceVertexShader = NULL;
    int                        g_ShadowMapWidth = 4096;
    int                        g_ShadowMapHeight = 4096;

    D3D11_VIEWPORT

    그림자에 사용할 깊이 맵은 화면에 표시하기위한 용도가 아니며, 그림자의 선명도 때문에 화면에 띄울 이미지의 해상도와 다른 해상도로 렌더링 되는 경우가 많다.

    그러기 위해서는 다른 크기의 뷰포트를 지정해줘야 정상적으로 렌더링할 수 있다.


    ID3D11Texture2D

    조명의 시점에서 깊이맵을 렌더링 해서 이곳에 저장한다.


    ID3D11ShaderResourceView

    렌더링된 깊이맵을 GPU로 전달하려면 쉐이더 리소스 뷰를 사용해야한다.


    ID3D11DepthStencilView

    깊이 정보를 위해서 필요하다.

    그리고 쉐도우맵은 렌더 타겟 뷰가 없으므로, 렌더 타겟을 설정할 때 렌더 타겟 뷰 대신에 뎁스 스텐실 뷰가 사용된다.

    뎁스 스텐실 뷰에는 위의 텍스쳐2D가 설정되어서 렌더 결과가 텍스쳐2D에 저장되게 한다.


    ID3D11SamplerState

    깊이 맵을 GPU에서 사용할 때 사용되는 샘플러다.


    ID3D11Buffer

    조명의 정보를 전달하기 위한 버퍼다.


    ID3D11RasterizerState

    쉐도우맵을 렌더링할때 사용될 래스터라이저다.


    ID3D11VertexShader

    쉐도우맵을 위한 버텍스 쉐이더다.


    ID3D11VertexShader

    DrawIndexedInstanced를 위해서 버텍스 쉐이더를 따로 만들었다.

     

    int    g_ShadowMapWidth = 4096;
    int    g_ShadowMapHeight = 4096;

    쉐도우맵의 해상도다.

     


     

    뷰포트 설정

    g_ShadowViewport.Width = (FLOAT)g_ShadowMapWidth;
    g_ShadowViewport.Height = (FLOAT)g_ShadowMapHeight;
    g_ShadowViewport.MinDepth = 0.0f;
    g_ShadowViewport.MaxDepth = 1.0f;
    g_ShadowViewport.TopLeftX = 0;
    g_ShadowViewport.TopLeftY = 0;

     

     

    텍스쳐2D 설정

    D3D11_TEXTURE2D_DESC shadowMapDesc;
    ZeroMemory(&shadowMapDesc, sizeof(D3D11_TEXTURE2D_DESC));
    shadowMapDesc.Format = DXGI_FORMAT_R32_TYPELESS;
    shadowMapDesc.MipLevels = 0;
    shadowMapDesc.ArraySize = 1;
    shadowMapDesc.Usage = D3D11_USAGE_DEFAULT;
    shadowMapDesc.CPUAccessFlags = 0;
    shadowMapDesc.SampleDesc.Count = 1;
    shadowMapDesc.SampleDesc.Quality = 0;
    shadowMapDesc.BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_DEPTH_STENCIL;
    shadowMapDesc.Height = (UINT)g_ShadowMapHeight;
    shadowMapDesc.Width = (UINT)g_ShadowMapWidth;
    hr = g_pd3dDevice->CreateTexture2D(&shadowMapDesc, nullptr, &g_pShadowMap);
    if (FAILED(hr))
        return hr;

    뎁스 스텐실 뷰는 보통 3바이트는 뎁스에, 1바이트는 스텐실에 사용해서 포맷에 R24G8을 사용하는데 여기에서는 스텐실을 사용하지 않을 예정이어서 텍스쳐 포맷을 R32로 했다.

    BindFlag에는 쉐이더 리소스로 사용하고, 뎁스 스텐실에 사용할 텍스쳐로 지정한다.

     

    쉐이더 리소스 뷰 설정

    D3D11_SHADER_RESOURCE_VIEW_DESC shaderResourceViewDesc;
    ZeroMemory(&shaderResourceViewDesc, sizeof(D3D11_SHADER_RESOURCE_VIEW_DESC));
    shaderResourceViewDesc.Format = DXGI_FORMAT_R32_FLOAT;
    shaderResourceViewDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D;
    shaderResourceViewDesc.Texture2D.MipLevels = 1;
    hr = g_pd3dDevice->CreateShaderResourceView(g_pShadowMap, &shaderResourceViewDesc, &g_pShadowSRV);
    if (FAILED(hr))
        return hr;

    여기도 포맷을 R32로 한다.

    대신에 float 타입을 사용한다.

    그리고 위에서 생성한 텍스쳐2D와 연결해준다.

     

    // HLSL
    Texture2D ShadowMap : register(t1);

    쉐이더 코드에서는 1번 슬롯에 이 리소스를 받게 했다.

     

    뎁스 스텐실 뷰 설정

    D3D11_DEPTH_STENCIL_VIEW_DESC depthStencilViewDesc;
    ZeroMemory(&depthStencilViewDesc, sizeof(D3D11_DEPTH_STENCIL_VIEW_DESC));
    depthStencilViewDesc.Format = DXGI_FORMAT_D32_FLOAT;
    depthStencilViewDesc.ViewDimension = D3D11_DSV_DIMENSION_TEXTURE2D;
    depthStencilViewDesc.Texture2D.MipSlice = 0;
    hr = g_pd3dDevice->CreateDepthStencilView(g_pShadowMap, &depthStencilViewDesc, &g_pShadowDSV);
    if (FAILED(hr))
        return hr;

    4바이트 모두 뎁스에 사용하므로 포멧에 D32를 지정한다.

    여기에서도 위에서 생성한 텍스쳐2D를 연결한다.

     

    샘플러 설정

    D3D11_SAMPLER_DESC comparisonSamplerDesc;
    ZeroMemory(&comparisonSamplerDesc, sizeof(D3D11_SAMPLER_DESC));
    comparisonSamplerDesc.AddressU = D3D11_TEXTURE_ADDRESS_BORDER;
    comparisonSamplerDesc.AddressV = D3D11_TEXTURE_ADDRESS_BORDER;
    comparisonSamplerDesc.AddressW = D3D11_TEXTURE_ADDRESS_BORDER;
    comparisonSamplerDesc.BorderColor[0] = 1.0f;
    comparisonSamplerDesc.BorderColor[1] = 1.0f;
    comparisonSamplerDesc.BorderColor[2] = 1.0f;
    comparisonSamplerDesc.BorderColor[3] = 1.0f;
    comparisonSamplerDesc.MinLOD = 0.f;
    comparisonSamplerDesc.MaxLOD = 0.f;
    comparisonSamplerDesc.MipLODBias = 0.f;
    comparisonSamplerDesc.MaxAnisotropy = 0;
    comparisonSamplerDesc.Filter = D3D11_FILTER_COMPARISON_MIN_MAG_LINEAR_MIP_POINT;
    comparisonSamplerDesc.ComparisonFunc = D3D11_COMPARISON_LESS_EQUAL;
    hr = g_pd3dDevice->CreateSamplerState(&comparisonSamplerDesc, &g_pShadowComparisonSampler);
    if (FAILED(hr))
        return hr;
    g_pImmediateContext->PSSetSamplers(1, 1, &g_pShadowComparisonSampler);

    샘플러는 Comparison 샘플러로 생성한다.

    그러기 위해서는 FilterCOMPARISON이 붙은 필터를 지정하면 된다.

    Comparison 선형 필터를 지정했다.

    그리고 밉맵은 사용되지 않는다.

    Address의 경우에는 BORDER로 했고, Border의 색은 (1.0, 1.0, 1.0, 1.0)으로 지정했다.

    쉐도우맵의 영역을 벗어나는 곳이 항상 존재할텐데 이렇게 하면 그곳의 깊이는 1으로 읽게된다.

    그러면 가장 멀리 떨어져있는 것으로 되어 항상 빛을 받는 곳으로 판단한다.

     

    만약 Border를 0으로 하면 아래 예시처럼 쉐도우맵을 넘어가는 곳은 그림자가 진 영역으로 판단한다.

     

    하지만 Border 값에 0을 넣거나, AddressBORDER 대신에 CLAMP로 지정해도 위와같은 현상을 막는 방법들이 있다.

     

    ComparisonFuncLESS_EQUAL을 지정한다.

    이 옵션에 대한 자세한 내용은 아래에서 이 샘플러를 사용할 때에 다루겠다.

     

    // HLSL
    SamplerComparisonState samShadow : register(s1);

    그리고 쉐이더에서 이 샘플러를 사용해야하는데 Comparison 샘플러이므로 SamplerComparisonState 타입으로 받아야 한다.

     

    조명 버퍼

    struct CBLight
    {
        XMFLOAT4 vDirectionalLightDir;
        XMFLOAT4 vDirectionalLightColor;
        XMFLOAT4 vAmbientColor;
        XMMATRIX mLightView;
        XMMATRIX mLightProjection;
    };

    조명 버퍼에 담길 정보들이다.

    조명의 방향, 색상 정보와 조명 시점에서 렌더링하기 위한 뷰 스페이스 변환 행렬과 프로젝션 행렬이 들어있다.

     

    래스터라이저 설정

    D3D11_RASTERIZER_DESC rd;
    ZeroMemory(&rd, sizeof(rd));
    rd.CullMode = D3D11_CULL_FRONT;
    rd.FillMode = D3D11_FILL_SOLID;
    rd.FrontCounterClockwise = FALSE;
    rd.DepthBias = D3D11_DEFAULT_DEPTH_BIAS;
    rd.DepthBiasClamp = D3D11_DEFAULT_DEPTH_BIAS_CLAMP;
    rd.SlopeScaledDepthBias = D3D11_DEFAULT_SLOPE_SCALED_DEPTH_BIAS;
    rd.DepthClipEnable = TRUE;
    rd.ScissorEnable = FALSE;
    rd.MultisampleEnable = FALSE;
    rd.AntialiasedLineEnable = FALSE;
    hr = g_pd3dDevice->CreateRasterizerState(&rd, &g_pShadowRasterizerState);
    if (FAILED(hr))
        return hr;

    래스터라이저를 따로 생성하는 이유는 CullModeFRONT로 지정해서 앞면을 컬링하기 위해서다.

    쉐도우맵의 특성상 아래와같은 현상이 나타날 수 있는데 쉽게 해결할 수 있는 방법은 front face culling이다.

     

    Back face culling, Shadow acne

    이렇게 줄무늬가 생기는 현상을 Shadow acne라고 한다.

    이러한 현상을 없애려면 거리 계산을 할때 bias를 추가하는 방법도 있지만 bias를 사용하지 않고 front face culling을 해도 해결할 수 있다.

     

    Front face culling

    위 이미지는 front face culling을 적용한 결과다.

    Front face를 지웠으니 쉐도우맵은 물체의 뒷면을 기준으로 깊이를 계산했고, 쉐도우맵의 깊이 값은 더 깊어졌기 때문에 Shadow acne가 나타나지 않는다.

    하지만 물체끼리 가까이 닿는 부분은 그림자가 생기지 않는 문제가 있다.

     

    개인적인 경험으로는 front face culling보다는 bias를 추가하는 방식이 더 자연스러워서 back face culling으로 진행하겠다.

    rd.CullMode = D3D11_CULL_BACK;

     

     

    버텍스 쉐이더

    struct VS_INPUT
    {
        float4 Pos : POSITION0;
        float3 Norm : NORMAL;
        float2 TexCoord : TEXCOORD0;
        float3 Tangent : TANGENT;
    };
    
    struct VS_OUTPUT_Shadow
    {
        float4 Pos : SV_POSITION;
    };
    
    // ...
    
    VS_OUTPUT_Shadow VS_shadow(VS_INPUT input)
    {
        VS_OUTPUT_Shadow output = (VS_OUTPUT_Shadow) 0;
        output.Pos = mul(input.Pos, mWorld);
        output.Pos = mul(output.Pos, mLightView);
        output.Pos = mul(output.Pos, mLightProjection);
        
        return output;
    }
    
    VS_OUTPUT_Shadow VS_shadow_Instance(VS_INPUT input, uint instanceID : SV_InstanceID)
    {
        VS_OUTPUT_Shadow output = (VS_OUTPUT_Shadow) 0;
        output.Pos = mul(input.Pos, mInstanceToWorld[instanceID]);
        output.Pos = mul(output.Pos, mLightView);
        output.Pos = mul(output.Pos, mLightProjection);
        
        return output;
    }

    정점의 오브젝트 스페이스에서의 위치를 조명의 클립 스페이스로 변환하는 작업만 하는 버텍스 쉐이더다.

     


     

    이제 쉐도우맵을 렌더한다.

    먼저 버퍼에 값을 지정한다.

    CBLight cbL;
    ZeroMemory(&cbL, sizeof(cbL));
    XMVECTOR LightDir = XMVector3Normalize(DirectionalLightPos);
    XMStoreFloat4(&cbL.vDirectionalLightDir, LightDir);
    cbL.vDirectionalLightColor = XMFLOAT4(1.f, 1.f, 1.f, 1.f);
    cbL.vAmbientColor = XMFLOAT4(0.05f, 0.05f, 0.05f, 1.f);
    
    XMVECTOR Eye = DirectionalLightPos;
    XMVECTOR At = XMVectorSet(0.0f, 0.0f, 0.0f, 0.0f);
    XMVECTOR Up = XMVectorSet(0.0f, 1.0f, 0.0f, 0.0f);
    cbL.mLightView = XMMatrixTranspose(XMMatrixLookAtLH(Eye, At, Up));
    cbL.mLightProjection = XMMatrixTranspose(XMMatrixOrthographicLH(80, 80, 1, 100));
    g_pImmediateContext->UpdateSubresource(g_pCBLightBuffer, 0, NULL, &cbL, 0, 0);

    DirectionalLightPos라는 XMFLOAT3 타입 변수에 조명 위치가 저장되어있다.

    그리고 조명은 직사광선으로 정해서 시점은 Perspective가 아니라 Orthographic으로 했다.

     

    다음으로 쉐도우맵을 렌더하기 전에 설정을 해준다.

    if (g_pShadowDSV)
        g_pImmediateContext->ClearDepthStencilView(g_pShadowDSV, D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0);
    
    g_pImmediateContext->RSSetViewports(1, &g_ShadowViewport);
    g_pImmediateContext->OMSetRenderTargets(0, NULL, g_pShadowDSV);
    g_pImmediateContext->RSSetState(g_pShadowRasterizerState);
    g_pImmediateContext->VSSetShader(g_pShadowVertexShader, NULL, 0);
    g_pImmediateContext->PSSetShader(NULL, NULL, 0); // 픽셀 쉐이더는 필요없음.
    
    // 오브젝트 draw
    
    // 그려진 쉐도우맵을 텍스쳐로 사용하기 위해 먼저 렌더타겟을 해제해야함.
    g_pImmediateContext->OMSetRenderTargets(0, NULL, NULL);
    
    // 쉐도우맵을 픽셀 쉐이더로 전달.
    g_pImmediateContext->PSSetShaderResources(1, 1, &g_pShadowSRV);

    먼저 쉐도우맵의 뎁스 스텐실 뷰를 Clear한다.

    가장 멀리있다는 의미로 값을 1으로 설정한다.

    그 다음 쉐도우맵에 사용하기위해 생성한 뷰포트, 렌더 타겟, 래스터라이저, 버텍스 쉐이더를 설정한다.

    렌더 타겟의 경우 뎁스 스텐실 뷰만 지정해주고 렌더 타겟 뷰는 NULL으로 지정한다.

    픽셀 쉐이더는 필요 없으므로 아무것도 지정해주지 않는다.

     

    모두 설정하고 나면 오브젝트들을 그려야한다.

    각 오브젝트들에 맞게 버퍼를 설정하고 렌더한다.

     

    오브젝트 렌더가 모두 끝나면 쉐도우맵이 생성되었고, 이 쉐도우맵을 사용하기위해 먼저 렌더타겟뷰에서 뎁스 스텐실 뷰를 해제해준다.

    그 다음에 GPU에 전달해주면 픽셀 쉐이더에서 사용 가능하다.

     

    카메라 시점에서 렌더하기 전에 위에서 설정했던 뷰포트, 렌더 타겟, 래스터라이저, 버텍스 쉐이더를 기존의 것으로 변경한 뒤에 렌더해야한다.

     


     

    마지막으로 카메라 시점에서 렌더할때 그림자를 계산해야한다.

    나는 픽셀 쉐이더에서 현재 보고있는 픽셀에 해당하는 월드 좌표를 조명의 클립 스페이스(NDC)로 변환해서 쉐이더맵의 텍스쳐 좌표를 알아내는 방법을 썼다.

    그러기 위해선 픽셀 쉐이더의 입력값에 월드 위치에 대한 정보가 포함되어있어야하므로, 버텍스 쉐이더의 출력 구조체에 해당 값을 추가하고 값을 지정해줘야한다.

    struct VS_OUTPUT
    {
        //...
        float4 PosWorld : POSWORLD;
        //...
    };
    
    VS_OUTPUT VS(VS_INPUT input)
    {
        VS_OUTPUT output = (VS_OUTPUT) 0;
        input.Pos.w = 1.f;
        output.Pos = mul(input.Pos, mWorld);
        output.PosWorld = output.Pos;
        output.Pos = mul(output.Pos, mView);
        output.Pos = mul(output.Pos, mViewProjection);
        
        //...
        
        return output;
    }

     

    다음은 픽셀 쉐이더다.

    float4 PS(VS_OUTPUT input) : SV_Target
    {
        // ...
        
        // Shadow
        float ShadowMapLight = GetLightFromShadowMap(input);
        
        // ...
        
        finalColor *= ShadowMapLight; // 예시
    }
    
    float GetLightFromShadowMap(VS_OUTPUT input)
    {
        float bias = 0.001f;
        
        float4 LightViewPos = mul(input.PosWorld, mLightView);
        float4 LightClipSpacePos = mul(LightViewPos, mLightProjection);
        
        float2 ShadowMapTexCoord = {
            0.5f + LightClipSpacePos.x / LightClipSpacePos.w / 2.f,
            0.5f - LightClipSpacePos.y / LightClipSpacePos.w / 2.f
        };
        float LightDistance = LightClipSpacePos.z / LightClipSpacePos.w;
        LightDistance -= bias;
        
        return ShadowMap.SampleCmpLevelZero(samShadow, ShadowMapTexCoord, LightDistance).r;
    }

    GetLightFromShadowMap 함수를 통해서 쉐도우맵을 통해 빛을 받는 양을 계산한다.

    나는 현재 픽셀에 해당하는 대상의 월드 위치를 조명의 클립 스페이스(NDC)로 변환해서 쉐이더맵에서의 텍스쳐 좌표를 구했다.

    LightClipSpacePos 변수에 월드 위치를 조명의 클립 스페이스로 변환한 값이 들어있다.

    클립 스페이스는 -1에서 1 사이의 값으로 변환되므로, 0과 1 사이의 값으로 매핑해서 텍스쳐 좌표값을 얻는다.

    이때 벡터의 w값으로 나누는걸 잊지 말아야한다.

    그리고 LightClipSpacePos 에는 z값도 포함되어있어서 조명에서부터의 거리도 알 수 있다.

    그렇게 구한 조명에서의 거리값을 bias로 조정해줘서 Shadow acne 현상을 방지한다.

     

    마지막으로 Comparison 샘플러를 통해 해당 위치가 그림자가 진 영역인지 아닌지를 판단한다.

    이때 SampleCmpLevelZero 함수를 사용하고 LevelZero의 의미는 밉맵의 LOD 0을 의미한다.

    그래서 SampleCmp 함수도 따로 존재한다.

     

    그리고 Comparison 샘플러를 설정할때 ComparisonFuncD3D11_COMPARISON_LESS_EQUAL을 지정했었다.

    이게 여기에서 사용되는데 SampleCmpLevelZero 함수의 세번째 파라미터를 텍스쳐 좌표를 통해 얻은 값과 비교해서 작거나 같은(LESS_EQUAL) 경우에 1을 리턴하고 반대의 경우에는 0을 리턴한다.

    즉 현재 색칠해야할 픽셀의 월드 위치에서부터 조명까지의 거리가 조명의 시점에서 해당 위치를 봤을때의 거리와 같거나 더 작다면, 월드 위치에서부터 조명까지의 방향에 가리고있는 물체가 없다는 뜻이 되므로 해당 영역은 빛을 받는 영역이다.

     

    참고로 SampleCmpLevelZero 함수는 1과 0 사이의 값을 리턴하므로, Comparison 선형 보간을 지정한 경우 경계가 부드러운 그림자를 얻을 수 있게 된다.

    선형 보간을 지정한 경우 쉐도우 맵의 텍셀보다 픽셀의 크기가 작다면 weight를 통해 값을 보간하는데 이때 주변의 텍셀을 가져와서 그대로 사용하는 것이 아니라 LightDistance의 값과 비교한 결과값을 사용해서 보간한다.

    그래서 빛을 받아야할 영역이면 1을, 그림자가 져야할 영역이면 0을 가져와서 이 값들을 이용해서 보간한다.

    그래서 부드러운 경계가 나온다.

     

    이것이 하드웨어에서 지원하는 PCF(Percentage Closer Filtering)다.

     

    샘플링 방식 비교. (2000px * 2000px)

    왼쪽 위의 방식은 POINT로 nearest 방식과 동일하다.

    그리고 Comparison 샘플러를 사용한것과 일반 샘플러를 사용한 결과가 같다.

    경계가 날카롭고 각이 져있어서 자연스럽지 않다.

     

    오른쪽 위는 일반 샘플러에 LINEAR을 선택한 것이다.

    일반 샘플러를 사용한경우 선형보간을 하더라도 POINT 방식과 비슷하지만 모서리만 둥글게 된 이상한 결과가 나온다.

    이 방식은 경계가 날카로운건 그대로다.

     

    보통 원하던 결과는 왼쪽 아래처럼 부드러운 경계일 것이다.

    Comparison 샘플러를 사용하면 경계가 부드러워진다.

     

    오른쪽 아래는 주변 8개의 텍셀을 추가로 샘플링해서 그 결과의 평균을 구한 것이다.

    즉 픽셀 하나당 9개의 샘플링을 하게 된다.

    조금 부담이 되겠지만 결과 차이는 확실하다.

    그리고 이것이 소프트웨어 방식의 PCF다.

     

    코드는 다음과 같다.

    bool InRange(float val, float min, float max)
    {
        return (min <= val && val <= max);
    }
    
    // ...
    
    float GetLightFromShadowMap(VS_OUTPUT input)
    {
        // ...
        
        // Percentage Closer Filtering
        float Light = 0.f;
        float OffsetX = 1.f / ShadowMapWidth;
        float OffsetY = 1.f / ShadowMapHeight;
        for (int i = -1; i <= 1; i++)
        {
            for (int j = -1; j <= 1; j++)
            {
                float2 SampleCoord =
                {
                    ShadowMapTexCoord.x + OffsetX * i,
                    ShadowMapTexCoord.y + OffsetY * j
                };
                if (InRange(SampleCoord.x, 0.f, 1.f) && InRange(SampleCoord.y, 0.f, 1.f))
                {
                    Light += ShadowMap.SampleCmpLevelZero(samShadow, SampleCoord, LightDistance).r;
                }
                else
                {
                    Light += 1.f;
                }
            }
        }
        Light /= 9;
        return Light;
    }

    생략된 코드는 이전의 코드에서 return에 해당하는 줄만 제거된 것과 동일하다.

    텍스쳐의 좌표는 0과 1 사이로 매핑되므로 텍스쳐의 크기를 안다면 텍셀의 크기(Offset)를 구할 수 있고, 주변의 텍셀값도 구할 수 있다.

    쉐이더맵의 크기는 버퍼를 통해 전달하면 되고 여기에 관한 내용은 생략하겠다.

    주변의 텍스쳐 좌표를 구할 때, 0과 1 사이의 범위를 넘어가면 항상 빛을 받는것으로 해서 1을 더하게 했다.

    그래서 샘플러 설정에서 BORDER를 통해 지정했던 것은 사실상 의미가 없어진 셈이다.

    이렇게 구한 결과값의 평균을 리턴하면 된다.

     

    만약 일반 샘플러에 이 방식의 PCF를 적용하게되면 결과는 다음과 같다.

    조금 부드러워졌긴 하지만 경계는 여전히 보인다.

     

    원래 PCF는 주변의 텍셀을 무작위로 샘플링하는 방식이다.

    그래서 정직하게 주변의 인접한 텍셀을 샘플링하면 이런 결과가 나온다.

    하지만 쉐이더에서는 랜덤값을 구할 수 없으므로 노이즈 텍스쳐를 따로 전달하는 방식을 사용한다고 한다.

     


     

    이런 방법 외에 그림자를 더 자연스럽게 만들려면 쉐이더맵의 해상도를 늘리면 된다.

    쉐이더맵 크기에 따른 차이

    왼쪽 아래의 경우 Shadow acne가 나타났다.

    주변의 텍셀을 샘플링하므로 이런 현상이 다시 나타날 수도 있다.

    이런 경우 bias값을 다시 조정해야한다.

     


     

    Shadow acne 현상은 조명의 시점에서 봤을 때 비스듬하게 기울어져있는 면에서 주로 발생한다.

    조명의 방향 벡터와 면의 노멀 벡터를 내적했을때 0에 가까울 수록 Shadow acne가 발생할 가능성이 높아진다는 뜻이다.

    면이 비스듬하게 보일수록 보이는 면적이 작아지고, 원래의 면적에 비해 보이는 픽셀은 적으니 해당 면의 깊이에 대한 정밀도가 떨어지고, 인접한 픽셀과 비교해보면 깊이의 차이가 커지게 된다.

    이건 조명 시점에서 봤을 때의 얘기고, 카메라 시점에서 봤을 때에는 해당 면이 비스듬하게 보일 것이라는 보장이 없다.

    카메라 시점에서 봤을 때 면의 노멀 벡터와 카메라 방향의 벡터가 일치할수록 보이는 면적이 넓어져서 픽셀의 개수가 많아지지만, 쉐도우맵에서는 해당 면의 해상도가 낮고 깊이 값의 편차가 크므로 정확한 계산을 할 수가 없다.

    쉐도우맵의 깊이 정보가 계단처럼 건너뛰기를 하므로 그림자를 계산한 결과도 계단처럼 무늬가 생기게 된다.

     

    그렇다면 조명의 방향 벡터와 조명 시점에서의 면의 노멀벡터가 일치할 수록 쉐도우맵이 정확해지고, 그 반대의 경우에는 정확도가 떨어지므로 Shadow acne는 나타나는 곳이 있고, 나타나지 않는 곳이 있게 된다.

    모든 곳에서 Shadow acne를 없애기 위해서 동일한 bias로 보정하면 Shadow acne가 나타나지 않던 곳에도 영향을 주게 된다.

    그러면 일부 영역은 Bias를 필요 이상으로 높게 지정하게 될 수도 있는데 이때 그림자가 멀리 떨어져서 그려지는 피터 패닝(Peter panning) 현상이 나타난다.

     

    피터 패닝(Peter panning)

    이러한 현상을 막기위해서 bias를 동적으로 변경시킬 수 있다.

    노멀 벡터와 조명의 방향 벡터 내적값을 이용하면 된다.

    내적값이 0에 가까울수록 bias를 크게 하고, 1에 가까울수록 bias를 줄이면 된다.

    float GetLightFromShadowMap(VS_OUTPUT input)
    {
        float NdotL = dot(normalize(input.Norm), (float3) vDirectionalLightDir);
        float bias = 0.001f * (1 - NdotL) + 0.0001f;
        
        // ...
    }

    이 경우에는 0.0001에서 0.0011 사이의 값을 bias로 사용하게 된다.

     

     

     


     

     

     

    최종 결과

     

     

     

     

    'DirectX11' 카테고리의 다른 글

    [DirectX11] chrono로 정확한 DeltaTime 구하기  (1) 2023.12.18
    [DirectX11] 1인칭 카메라 조작  (0) 2023.12.18
    [DirectX11] 텍스쳐와 노멀매핑  (1) 2023.12.13
    [DirectX11] Disney Diffuse  (0) 2023.12.12
    [DirectX11] Specular BRDF  (3) 2023.12.11
Designed by Tistory.