글에 사용된 모든 그림과 내용은 직접 작성한 것입니다.

[유튜브 영상]


[깃허브 보러가기]
https://github.com/Chang-Jin-Lee/D3D11-AliceTutorial/tree/main/26_ShadowMap_PCF

[풀리퀘 보러가기]
https://github.com/Chang-Jin-Lee/D3D11-AliceTutorial/pull/52


글의 목적

D3D11에서 shadow mapping을 구현한 방법에 대해 알리기 위함. 또 PCF에 대해 정리하기 위함

그림자가 그려진 모습

차근차근 진행해보겠습니다.

그림자

  • 그림자란 빛의 진행 경로를 불투명한 물체가 가로막았을 때, 빛이 도달하지 못하여 생기는 어두운 영역을 의미. 즉 빛에서 본 물체의 텍스쳐라는 걸 떠올릴 수 있습니다.

  • 다음의 그림을 보면 광원에서의 카메라를 하나 만들어서 그 카메라는 3D 모델의 실루엣만 본다고 생각하면 됩니다.

Shadow Mapping

  • 렌더 투 텍스쳐 기법의 대표 예입니다. 광원의 중심에서 깊이 정보를 렌더링합니다. 광원에 가상의 카메라가 달려 있어 관찰된 깊이 정보를 저장해두면됩니다. 다시 말해 메인 패스에서 각 픽셀을 라이트 시점으로 투영해서 그림자 여부를 테스트 합니다.

  • 구현 단계는 다음과 같습니다.

    1. ShadowTexture, ShadowSRV, 샘플러와 레스터라이저 만들기
    2. 라이트에서 본 view-proj 계산
    3. Shadow Pass 깊이전용 패스를 렌더
    4. 메인 패스에서 Shadow Map/Sampler 바인딩
    5. 픽셀 세이더에서 PCF 진행

1. Shadow Texture, Shadow State 셋업

  • 텍스쳐 포맷은 R32_TYPELESS + DSV(D32_FLOAT)/SRV(R32_FLOAT) 조합을 사용합니다.

    • 이유는 한 리소스를 DSV(깊이쓰기) 와 SRV(셰이더에서읽기)로 동시에 쓰기위해서 텍스쳐 포맷을 typeless로 만들어 두고, 뷰마다 포맷을 달리 지정하기 위해서입니다. R32_FLOAT는 깊이 값을 float로 그대로 읽을 수 있어 이후 PCF를 구현하기에 단순하고 정확합니다.
  • 래스터라이저에는 뎁스 바이어스/슬로프 바이어스 적용합니다

    • 이유는 섀도우 시 표면 자체가 만든 깊이값과 비교 시 부동소수점 오차/양자화로 인해 shadow acne가 생깁니다. DepthBias는 “그림자 깊이”를 미세하게 뒤로 밀어 shadow acne를 최대한 줄입니다. shadow acne란 빛이 닿는 영역에서도 그림자가 생겨버리는 아티팩트입니다.
  • 샘플러에는 선형, 클램프를 사용합니다.

    • 선형 보간된 깊이를 읽으면 테셀 경계부분이나 서브텍셀에서 생기는 톱니같은 무늬가 줄어들고 부드러워집니다. 클램프로 읽으면 shadow map 경계 밖 샘플을 막아 엉뚱한 깊이 값을 가져오지 않게 합니다.
    • 비교 샘플러를 쓰지않고 float로 읽어서 수동으로 비교하는 이유는, 하드웨어 PCF에 종속되지 않고 커널이나 바이어스를 코드로 제어하기 위함입니다. 원한다면 (ComparisonFunc = ALWAYS)로 바꿔도 되긴합니다.
		HR_T(m_->m_pDevice->CreateShaderResourceView(m_->m_pShadowTex, &srvd, &m_->m_pShadowSRV));

		// Shadow viewport
		m_->m_ShadowViewport = { 0.0f, 0.0f, (float)m_->m_ShadowSize, (float)m_->m_ShadowSize, 0.0f, 1.0f };

		// Depth-bias rasterizer
		D3D11_RASTERIZER_DESC rd{}; rd.FillMode = D3D11_FILL_SOLID; rd.CullMode = D3D11_CULL_BACK;
		rd.FrontCounterClockwise = true; rd.DepthBias = 1000; rd.SlopeScaledDepthBias = 1.0f; rd.DepthBiasClamp = 0.0f;
		rd.DepthClipEnable = TRUE; rd.MultisampleEnable = FALSE; rd.ScissorEnable = FALSE; rd.AntialiasedLineEnable = FALSE;
		HR_T(m_->m_pDevice->CreateRasterizerState(&rd, &m_->RSShadowBias));

		// Shadow sampler (linear, clamp)
		D3D11_SAMPLER_DESC ssd{}; ssd.Filter = D3D11_FILTER_MIN_MAG_LINEAR_MIP_POINT;
		ssd.AddressU = ssd.AddressV = ssd.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP; ssd.MaxLOD = D3D11_FLOAT32_MAX;
		ssd.ComparisonFunc = D3D11_COMPARISON_ALWAYS;
		HR_T(m_->m_pDevice->CreateSamplerState(&ssd, &m_->m_pShadowSampler));
	}

2. 라이트에서 본 뷰-프로젝션

  • 라이트 뷰 공간에서 포커스 주변 AABB(±r)를 투영해서 minZ/maxZ를 구해내고 near/far를 계산합니다

  • 라이트 카메라 위치를 focus - dir * r로 두어 AABB 앞에 씬이 위치하도록 합니다

  • XY 텍셀 스냅으로 그림자 움직임을 따라 다닙니다

    • 여기 조심해야되는 부분이 처음엔 UI에서 near/far를 고정값으로 두고 조절하려고 했습니다. 씬 스케일이 커질수록 클리핑이 발생했습니다. 수직에 가까운 각도(머리 위)에서 섀도우가 잘리거나 길게 드리운 구간에서 깨지는 현상이 발생했습니다.
		auto UpdateShadow = [this](XMFLOAT3& focusF) {
		// 모델 중심을 포커스로 사용하여 카메라 움직임과 무관하게 안정화
		XMVECTOR focus = XMLoadFloat3(&focusF);

		XMVECTOR fwd = XMVector3Normalize(XMLoadFloat3(&m_->m_DirLight.direction)); // 라이트가 비추는 방향
		float r = m_->m_ShadowOrthoRadius;
		// 큰 오브젝트에서도 라이트 카메라가 항상 AABB 뒤쪽에 위치하도록 반경만큼 뒤로 물린다
		float backDist = r;
		XMVECTOR lightPos = XMVectorSubtract(focus, XMVectorScale(fwd, backDist));
		XMVECTOR up = XMVectorSet(0, 1, 0, 0);
		if (fabsf(XMVectorGetX(XMVector3Dot(up, fwd))) > 0.99f) up = XMVectorSet(0, 0, 1, 0);
		XMMATRIX LView = XMMatrixLookToLH(lightPos, fwd, up);

		// 라이트 뷰 공간에서 포커스 주변 AABB(±r)를 투영해 동적으로 near/far 계산
		float minZ = 1e9f, maxZ = -1e9f;
		for (int sx = -1; sx <= 1; sx += 2)
		for (int sy = -1; sy <= 1; sy += 2)
		for (int sz = -1; sz <= 1; sz += 2)
		{
			XMVECTOR cornerWS = XMVectorSet(focusF.x + sx * r, focusF.y + sy * r, focusF.z + sz * r, 1.0f);
			XMVECTOR cornerLS = XMVector3TransformCoord(cornerWS, LView);
			float z = XMVectorGetZ(cornerLS);
			minZ = (z < minZ) ? z : minZ;
			maxZ = (z > maxZ) ? z : maxZ;
		}
		// 여유 패딩(5%)을 주고 near는 0.01 이상으로 고정
		float zPad = r * 0.05f;
		float zn = (minZ - zPad);
		if (zn < 0.01f) zn = 0.01f;
		float zf = maxZ + zPad;
		if (zf <= zn) zf = zn + 0.01f;
		XMMATRIX LProj = XMMatrixOrthographicOffCenterLH(-r, r, -r, r, zn, zf);

		// 텍셀 스냅: 라이트 뷰 공간 XY를 섀도우맵 텍셀 그리드에 정렬(near/far에는 영향 없음)
		XMVECTOR focusLS = XMVector3TransformCoord(focus, LView);
		float texelWorld = (2.0f * r) / (float)m_->m_ShadowSize;
		float fx = XMVectorGetX(focusLS);
		float fy = XMVectorGetY(focusLS);
		float snapX = floorf(fx / texelWorld) * texelWorld;
		float snapY = floorf(fy / texelWorld) * texelWorld;
		XMMATRIX snap = XMMatrixTranslation(snapX - fx, snapY - fy, 0.0f);
		LView = snap * LView;

		XMMATRIX LVP = XMMatrixMultiply(LView, LProj);
		m_->m_baseProjection.lightViewProj = XMMatrixTranspose(LVP);
		};

3. 뎁스 전용 섀도우 패스

  • 동일 리소스를 DSV로 바인딩하기 전에 PS의 SRV(t4)를 해제합니다. 혹시 모를 충돌을 막기 위해서입니다.

  • VS만 사용합니다. PS는 잠시 사용하지 않습니다. 그리고 깊이만 렌더합니다

  • 스키닝/비스키닝에서 VS를 선택합니다.

  • 여기까지 했으면 90%는 완성한겁니다.

  • 아래는 cpp 코드입니다.

// SRV/DSV 충돌 방지: PS의 t4 해제
ID3D11ShaderResourceView* nullSRV = nullptr;
m_->m_pDeviceContext->PSSetShaderResources(4, 1, &nullSRV);

// 섀도우 뷰포트/RTV/DSV 바인딩
m_->m_pDeviceContext->RSSetViewports(1, &m_->m_ShadowViewport);
ID3D11RenderTargetView* nullRTV = nullptr;
m_->m_pDeviceContext->OMSetRenderTargets(0, &nullRTV, m_->m_pShadowDSV);
m_->m_pDeviceContext->ClearDepthStencilView(m_->m_pShadowDSV, D3D11_CLEAR_DEPTH, 1.0f, 0);
if (m_->RSShadowBias) m_->m_pDeviceContext->RSSetState(m_->RSShadowBias);
// PS off
m_->m_pDeviceContext->PSSetShader(nullptr, nullptr, 0);

// 모델 루프: VSShadow 또는 VSSkinnedShadow
// ... world, lightViewProj, bias, mapSize, PCFRadius 업로드 ...
  • 아래는 VertexShader 코드입니다.
struct VSShadowOut
{
    float4 posH : SV_POSITION;
};

VSShadowOut VSShadow(VertexIn vIn)
{
    VSShadowOut o;
    float4 posW = mul(float4(vIn.posL, 1.0f), g_World);
    o.posH = mul(posW, g_LightViewProj);
    return o;
}

4. 메인 패스에서 섀도우 샘플링 (PCF 3x3)

  • t4: 섀도우 SRV, s1: 섀도우 샘플러

  • VS에서 전달한 light-space 위치를 사용합니다

  • g_ShadowBias, g_ShadowPCFRadius, g_ShadowMapSize를 파라미터로 사용합니다.

  • 아래는 PixelShader 코드입니다.

    // Shadowing (directional) with PCF
    if (g_ShadowEnabled != 0)
    {
        // VS에서 가져온 라이트 공간 좌표 사용.동차좌표계
        float3 sh = pIn.posShadowH.xyz / pIn.posShadowH.w;          // NDC
        float2 uv = sh.xy * float2(0.5f, -0.5f) + float2(0.5f, 0.5f);        // [0,1]
        float  depth = sh.z;                    // light clip depth

        // Early out if outside
        if (all(uv >= 0.0.xx) && all(uv <= 1.0.xx))
        {
            float texel = 1.0f / max(g_ShadowMapSize, 1.0f);
            float r = max(g_ShadowPCFRadius, 0.0f) * texel;
            // 3x3 PCF
            float sum = 0.0f; int taps = 0;
            [unroll]
            for (int dy = -1; dy <= 1; ++dy)
            {
                [unroll]
                for (int dx = -1; dx <= 1; ++dx)
                {
                    float2 uvOff = uv + float2(dx,dy) * r;
                    float d = g_ShadowMap.Sample(g_ShadowSamp, uvOff).r;
                    sum += (depth - g_ShadowBias <= d) ? 1.0f : 0.0f;
                    taps++;
                }
            }
            float vis = sum / max(taps, 1);
            litColor *= vis;
        }
    }

5. 메인 패스 시작 시점에서 바인딩하기

	m_->m_pDeviceContext->PSSetSamplers(0, 1, &m_->m_pSamplerState);
	// Bind shadow sampler/SRV
	if (m_->m_pShadowSampler) m_->m_pDeviceContext->PSSetSamplers(1, 1, &m_->m_pShadowSampler);
	if (m_->m_pShadowSRV)     m_->m_pDeviceContext->PSSetShaderResources(4, 1, &m_->m_pShadowSRV);
	// 큐브맵을 t1 슬롯에 바인딩 (픽셀 셰이더에서 g_TexCube : t1)
	m_->m_pDeviceContext->PSSetShaderResources(1, 1, &m_->m_pTextureSRV);


PCF(Percentage Closer Filtering)

  • 간단하게 말해서 픽셀에서 주변 픽셀들의 평균을 구해서 색을 부드럽게 만드는 방법입니다. 그림자는 사실 부드럽다라는 표현이 그림자의 가장자리 부분이기 때문에 그림자의 가장자리 부분에 부드럽게 처리를 하면 됩니다.

  • 무조건 여러번 계산해서 (다시말해 반복문을 여러번 돌아서) 부드러운 그림자를 얻는게 좋은게 아닙니다. 계산 비용이 많이 증가합니다.

  • Radius를 두고 샘플 포인트 간 간격을 결해서 값이 작을수록 필터링이 세밀하게 가능하지만 블러 효과는 감소하게 됩니다.

  • 이 값들을 적절하게 조절해서 게임에 맞는 값을 넣는게 좋습니다.

그림자가 제대로 그려진 사진그림자가 제대로 그려진 사진2
profile
게임 프로그래머

0개의 댓글