[목차]

 

- 그림자 맵 만들기

- 그림자 매핑하기

- 출력하기

 

 

[그림자 맵 만들기]

 

1. 빛을 기준으로 View, Projection 행렬 생성

2. Shadow 영향을 받을 Unit들에게 행렬 적용

3. Shadow 렌더링 파이프라인을 실시하고 DrawIndexed()

4. 출력 : 버퍼의 R채널(x)값에 물체들의 Depth값이 담김, MinBlend를 쓰기 때문에 검은색이 됨

 

현재의 프로젝트는 디퍼드 렌더링 구조입니다. 따라서 그림자 맵도 다른 G-Buffer 처럼 RenderTarget을 만들어주고, 여기에 기록해주면 됩니다. Ext_Light 클래스에 Shadow Render Target을 추가해줍니다.

///////////////////////// Ext_Light.cpp
Ext_Light::Ext_Light()
{
	LTData = std::make_shared<LightData>();
	LTData->bIsLightSet = true;
	LTData->ShadowTargetSizeX = 4096;
	LTData->ShadowTargetSizeY = 4096;
	ShadowRange.x = 2048.0f;
	ShadowRange.y = 2048.0f;
}

void Ext_Light::Start()
{
	GetOwnerScene().lock()->PushLight(GetSharedFromThis<Ext_Light>(), GetName());
	ShadowRenderTarget = Ext_DirectXRenderTarget::CreateRenderTarget(DXGI_FORMAT_R32_FLOAT, { LTData->ShadowTargetSizeX, LTData->ShadowTargetSizeY }, float4::RED);
	ShadowRenderTarget->CreateDepthTexture();
}

// ... Update

///////////////////////// Ext_DirectXRenderTarget.cpp
// 텍스쳐를 생성해서 렌더타겟 생성
void Ext_DirectXRenderTarget::CreateRT(DXGI_FORMAT _Format, float4 _Scale, float4 _Color)
{
	Colors.push_back(_Color);

	D3D11_TEXTURE2D_DESC Desc = { 0 };
	Desc.ArraySize = 1;
	Desc.Width = _Scale.uix();
	Desc.Height = _Scale.uiy();
	Desc.Format = _Format;
	Desc.SampleDesc.Count = 1;
	Desc.SampleDesc.Quality = 0;
	Desc.MipLevels = 1;
	Desc.Usage = D3D11_USAGE_DEFAULT;
	Desc.BindFlags = D3D11_BIND_FLAG::D3D11_BIND_RENDER_TARGET | D3D11_BIND_FLAG::D3D11_BIND_SHADER_RESOURCE;

	std::shared_ptr<Ext_DirectXTexture> Tex = Ext_DirectXTexture::CreateViews(Desc);

	D3D11_VIEWPORT ViewPortData;
	ViewPortData.TopLeftX = 0;
	ViewPortData.TopLeftY = 0;
	ViewPortData.Width = _Scale.x;
	ViewPortData.Height = _Scale.y;
	ViewPortData.MinDepth = 0.0f;
	ViewPortData.MaxDepth = 1.0f;

	ViewPorts.push_back(ViewPortData);
	Textures.push_back(Tex);
	RTVs.push_back(Tex->GetRTV());
	SRVs.push_back(Tex->GetSRV());
}

 

이러면 R채널(x값) 하나의 값만 32bit로 사용하는 4096*4096 크기의 빨간 색상 RenderTarget이 하나 만들어집니다. 이 렌더 타겟의 R채널에 물체의 Depth 값을 기록할 것입니다.

 

다음으로 Depth값 추출을 위한 Vertex, Pixel Shader를 만들어줍니다.

////////////////// Shadow_VS
#include "Transform.fx"

struct VSInput
{
    float4 Position : POSITION;
};

struct VSOutput
{
    float4 Position : SV_POSITION;
};

VSOutput Shadow_VS(VSInput _Input)
{
    VSOutput OutPut = (VSOutput) 0;
    OutPut.Position = mul(_Input.Position, WorldViewProjectionMatrix);
    return OutPut;
}

////////////////// Shadow_PS
struct PSInput
{
    float4 Position : SV_POSITION;
};

float4 Shadow_PS(PSInput _Input) : SV_TARGET
{
    return float4(max(0.0f, _Input.Position.z / _Input.Position.w), 0.0f, 0.0f, 1.0f);
}

 

렌더될 대상들에 대해 Pixel Shader에 View, Projection 행렬을 곱한 Clip Space 값을 전달해줍니다. 이러면 깊이를 만들기 위해서 z에 w divide를 수행해야합니다. 그 점을 이용하여 전달받은 값에 대해 z를 w로 나눠 R채널에 깊이값을 기록해줍니다.

float4(max(0.0f, _Input.Position.z / _Input.Position.w), 0.0f, 0.0f, 1.0f);

 

이러면 렌더 대상들의 가진 픽셀들에 대해, 아래의 형태로 깊이값이 기록됩니다.

float4(Rchanel, 0.0f, 0.0f, 1.0f); // Rchanel : Depth-Value(0.0f ~ 1.0f)

 

그리고 각 값들은 Min Blending이 실시됩니다.

D3D11_BLEND_DESC BlendInfo = { 0, };

BlendInfo.AlphaToCoverageEnable = false;
BlendInfo.IndependentBlendEnable = false;

BlendInfo.RenderTarget[0].BlendEnable = true;
BlendInfo.RenderTarget[0].SrcBlend = D3D11_BLEND_ONE;
BlendInfo.RenderTarget[0].DestBlend = D3D11_BLEND_ONE;
BlendInfo.RenderTarget[0].BlendOp = D3D11_BLEND_OP_MIN;

BlendInfo.RenderTarget[0].SrcBlendAlpha = D3D11_BLEND_ONE;
BlendInfo.RenderTarget[0].DestBlendAlpha = D3D11_BLEND_ONE;
BlendInfo.RenderTarget[0].BlendOpAlpha = D3D11_BLEND_OP_MAX;

BlendInfo.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;

Ext_DirectXBlend::CreateBlend("MinBlend", BlendInfo);

 

하나의 RenderTarget 내에서 위의 블렌딩 공식을 사용하기 때문에, 현재 값보다 더 작은 값이 RenderTarget에 기록됩니다. 아래의 그림을 확인해보겠습니다.

 

빛이 45도 각도로 네모, 삼각형, 동그라미를 내리쬐는 형태로, 순서대로 네모, 세모, 동그라미, 오각형이 그러지며, 모두의 깊이값이 대략 0.45부터 0.8 사이 정도에 존재한다고 가정해보겠습니다.

 

1. 네모

: 아무것도 그려지지 않았기 때문에 그냥 그려지면서, 깊이값을 기록합니다.

 

2. 세모

: 1번(빨간점)부터 2번(보라점)까지의 범위가 네모와 겹칩니다. 해당 부분의 깊이값을 봤을 때, Min Blending을 실시하기 때문에 값이 더 작은 깊이값이 기록됩니다. 네모는 대략 0.5, 세모는 대략 0.7이라고 가정한다면, RenderTarget의 현재 위치는 네모의 0.5가 기록됩니다.

 

3. 동그라미

: 동그라미가 그려졌는데, 3번(초록점)부터 4번(하늘색점)까지의 범위가 기존에 그려졌던 세모와 겹칩니다. 똑같이 Min Blending을 실시하기 때문에 값이 더 작은 깊이값이 기록됩니다. 동그라미는 대력 0.6, 세모는 대략 0.72이라고 가정한다면, RenderTarget의 현재 위치는 동그라미의 0.6으로 갱신됩니다.

 

4. 오각형

: 자기 외에 아무것도 그려지지 않은 상태이기 때문에, 깊이값을 자신의 것으로 기록합니다.

 

5. 이외

: 이외에는 깊이값이 기록되지 않기 때문에 원래의 렌더 타겟 색상(1.0f, 0.0f, 0.0f, 1.0f)을 갖게 됩니다.

 

최종적으로 그려지는 결과는 다음과 같습니다.

 

이렇게 생성되는 것이 바로 그림자 맵입니다. Ext_Camera의 Rendering() 함수 중, 아래의 부분에서 지금까지 설명드린 과정이 진행됩니다.

// 카메라의 MeshComponents들에 대한 업데이트 및 렌더링 파이프라인 리소스 정렬
void Ext_Camera::Rendering(float _Deltatime)
{
	// ...
	// Geometry Pass 진행하는 부분

	// 쉐도우 패스, 뎁스 만들기
	auto& Lights = GetOwnerScene().lock()->GetLights();
	for (auto& [name, CurLight] : Lights)
	{
		std::shared_ptr<Ext_DirectXRenderTarget> CurShadowRenderTarget = CurLight->GetShadowRenderTarget();
		if (!CurShadowRenderTarget) continue; // 세팅 안되어있으면 그릴 필요 없음

		std::shared_ptr<LightData> LTData = CurLight->GetLightData(); // 현재 라이트의 데이터(앞서 업데이트됨)
		CurShadowRenderTarget->RenderTargetSetting(); // 백버퍼에서 지금 렌더 타겟으로 바인딩 변경, 여기다 그리기

		// 쉐도우 뎁스 텍스쳐 만들기
		for (auto& Unit : AllRenderUnits)
		{
			if (!Unit->GetIsShadow()) continue;

			Unit->GetOwnerMeshComponent().lock()->GetTransform()->SetCameraMatrix(LTData->LightViewMatrix, LTData->LightProjectionMatrix); // 라이트 기준으로 행렬 세팅
			Unit->RenderUnitShadowSetting(); 

			std::shared_ptr<Ext_DirectXMaterial> ShadowPipeLine;

			if (ShadowType::Static == Unit->GetShadowType())
			{
				ShadowPipeLine = Ext_DirectXMaterial::Find("Shadow");
			}
			else if (ShadowType::Dynamic == Unit->GetShadowType())
			{
				ShadowPipeLine = Ext_DirectXMaterial::Find("DynamicShadow");
			}
			else
			{
				MsgAssert("여기 들어오면 안되는데 뭔가 잘못됨");
			}

			ShadowPipeLine->VertexShaderSetting();
			ShadowPipeLine->RasterizerSetting();
			ShadowPipeLine->PixelShaderSetting();
			ShadowPipeLine->OutputMergerSetting();
			Unit->RenderUnitDraw();
		}
	}
    
    // ...
    // Deferred 작업 진행하는 부분
}

 

그림자를 활성화할 렌더 대상들의 경우, 아래와 같이 ShadowOn() 함수를 호출해주도록 했습니다.

void SphereActor::Start()
{
	MeshComp = CreateComponent<Ext_MeshComponent>("BasicMesh");
	MeshComp->CreateMeshComponentUnit("Sphere", MaterialType::Static);
	MeshComp->SetTexture("Gray.png");
	MeshComp->ShadowOn(ShadowType::Static);
    // ...
}

void Ext_MeshComponentUnit::StaticShadowOn()
{
	bIsShadow = true;
	
	if (nullptr == ShadowInputLayout)
	{
		ShadowT = ShadowType::Static;
		std::shared_ptr<Ext_DirectXVertexShader> ShadowVS = Ext_DirectXVertexShader::Find("Shadow_VS");
		ShadowInputLayout = std::make_shared<Ext_DirectXInputLayout>();
		ShadowInputLayout->CreateInputLayout(Mesh->GetVertexBuffer(), ShadowVS);
	}
}

 

이러면 셰도우를 위핸 Unit(렌더링 파이프라인)도 생성되고, bIsShadow 값도 변경해주기 때문에 Update() 부분에서 예외 처리를 해줄 수 있게 됩니다.

 

지금까지의 과정을 프레임워크에서 실행하면, 다음의 결과를 얻을 수 있습니다.

 

 

[그림자 매핑하기]

 

이제 그림자 맵을 매핑해주는 단계를 진행합니다. 그림자의 매핑은 Light들을 계산하는 부분에서 함께 실시했습니다. 

// 카메라의 MeshComponents들에 대한 업데이트 및 렌더링 파이프라인 리소스 정렬
void Ext_Camera::Rendering(float _Deltatime)
{
	// ...
	// 그림자 맵 생성 후
    
    // 렌더타겟 세팅
	LightRenderTarget->RenderTargetClear();
	LightRenderTarget->RenderTargetSetting();
	
    // 라이트 업데이트
	GetOwnerScene().lock()->GetLightDataBuffer().LightCount = 0; // 라이트 업데이트 전, 상수버퍼 갯수 초기화(순회하면서 넣어줘야하기 때문)
	for (auto& [name, CurLight] : Lights)
	{
		LightUnit.BufferSetter.SetTexture(CurLight->GetShadowRenderTarget()->GetTexture(0), "ShadowTex");
		LightUnit.Rendering(_Deltatime);
		GetOwnerScene().lock()->GetLightDataBuffer().LightCount++;
	}
    
    // ...
}

 

위의 과정을 진행하면, LightUnit(Light Buffer를 생성하기 위한 렌더링 파이프라인)을 통해 LightRenderTarget에 그림자 맵이 매핑됩니다.

 

#include "LightData.fx"

struct PSInput
{
    float4 Position : SV_POSITION;
    float2 Texcoord : TEXCOORD;
};

struct PSOutPut
{
    float4 DiffuseTarget : SV_TARGET0;
    float4 SpecularTarget : SV_TARGET1;
    float4 AmbientTarget : SV_TARGET2;
    float4 ShadowTarget : SV_TARGET3;
};

Texture2D PositionTex : register(t0); // G-Buffer: 월드-스페이스 위치(x,y,z) + 1
Texture2D NormalTex : register(t1); // G-Buffer: 월드-스페이스 법선(x,y,z) + 1
Texture2D ShadowTex : register(t2);
SamplerState Sampler : register(s0);
SamplerComparisonState ShadowCompare : register(s1);

PSOutPut DeferredLight_PS(PSInput _Input) : SV_TARGET
{
    // ...
    // 기존의 라이트 계산 실시

    if (DiffuseLight.x > 0.0f)
    {
        float4 LightPos = mul(float4(WorldPos.xyz, 1.0f), LTData.LightViewProjectionMatrix);
        float3 LightProjection = LightPos.xyz / LightPos.w;
        
        float2 ShadowUV = float2(LightProjection.x * 0.5f + 0.5f, LightProjection.y * -0.5f + 0.5f);
        float fShadowDepth = ShadowTex.Sample(Sampler, float2(ShadowUV.x, ShadowUV.y)).r;
        
        if (fShadowDepth >= 0.0f && LightProjection.z >= (fShadowDepth + 0.001f))
        {
            OutPut.ShadowTarget.x = 1.0f;
            OutPut.ShadowTarget.a = 1.0f;
        }
    }
    
    OutPut.DiffuseTarget = float4(DiffuseLight, 1.0f);
    OutPut.SpecularTarget = float4(SpecularLight, 1.0f);
    OutPut.AmbientTarget = float4(AmbientLight, 1.0f);
    
    return OutPut; // 카메라 행렬 빛의 위치 그려져있는 빛을 기반으로한 깊이 버퍼 텍스처 리턴
}

 

해당 부분이 LightUnit 렌더링 파이프라인 중 그림자를 매핑해주는 부분입니다(Pixel Shader). 매핑은 Diffuse Light가 닿는 곳에 대해서만 진행하도록 합니다(안비추는 곳은 어차피 계산할 필요가 없습니다).

 

먼저 월드 기준으로 위치한 물체(정점들)에 대해 빛의 View, Projection 행렬을 곱해주어 빛 기준의 Clip Space 값을 얻습니다. 여기서 W Divide를 실시하면 NDC 좌표값이 되면서,  x, y는 텍스쳐 좌표값, z는 깊이값이 담깁니다.

 

해당 과정은 카메라로 시점 내에 물체들이 어디에 위치하는지 값을 구하는 과정과 완전히 동일합니다. 시점이 카메라에서 빛으로 변경된 것 뿐입니다.

원래 우리가 하던거
여기서 하는거

 

위에서 구한 x, y값은 -1 ~ 1까지의 범위를 갖기 때문에, UV로 쓰기 위해서(위치시킨 부분을 UV 값으로 보기 위함) 값을 변형시켜줍니다.

LightProjection.x * 0.5f + 0.5f,     // x: [-1,1] → [0,1]
LightProjection.y * -0.5f + 0.5f     // y: [-1,1] → [1,0] (상하 반전)

 

이러면 x는 0 ~ 1, y는 1 ~ 0 사이 값인 UV값이 됩니다. y에 -0.5f를 곱하는 이유는 NDC 공간의 Y축은 위가 +, 텍스쳐 UV는 위가 0이므로 보정해주는 것입니다(Y축 상하 반전 수행).

 

이 값으로 Shadow Map을 샘플링합니다.

float fShadowDepth = ShadowTex.Sample(Sampler, float2(ShadowUV.x, ShadowUV.y)).r;

 

이렇게 해주면 최종적으로 값들은 다음과 같습니다.

 

- LightProjection.z : 라이트 시점에서의 깊이값

- fShadowDepth : 라이트 시점에서의 Shadow Map의 깊이값

 

이제 두 값을 비교해서 현재 픽셀이 Shadow Map보다 더 뒤에 있으면(가리지 않으면) 그림자가 됩니다. 깊이값은 더 큰게 뒤에 있는 것입니다. [LightProjection.z ≥ fShadowDepth + Bias] 조건식을 수행하여 그림자 영역을 찾아주고, 해당 부분은 float4(1.0f, 0.0f, 0.0f, 1.0f)로 기록해줍니다(R채널 말고 G, B채널 아무대나 해도 상관 없습니다).

 

여기서 한 가지 특이한 점이, fShadowDepth 값에 0.001f를 더해주고 있는 것입니다. 이건 Depth Bias라고 하는데, Self_Shadowing을 막기 위해 현재 Fragment Light 공간 깊이에 아주 작은 Offeset을 더해주는 행위입니다. GPU에서 깊이에 기록되는 값은 부동소수점이고, 이 부동소수점은 정밀도에 한계가 있어 실제 렌더되는 대상의 위치와 완전히 일치하지 않을 수도 있습니다.

 

또한 Bias 없이 [LightProjection.z ≥ fShadowDepth]만 수행한다면, 같은 표면 위에 있는 픽셀도(혹은 +- 0.0000001) [>] 판정이 되어 표면 위에 검은 줄이 생길 수도 있습니다.

 

결과는 다음과 같습니다. 왼쪽 위는 최종 결과이니, 왼쪽 아래 결과를 보시면 됩니다.

 

 

[출력하기]

 

이제 기록된 값을 활용해주면 됩니다. 해당 부분은 모든 Light Buffer 값들을 Merge 해주는 곳입니다.

#include "LightData.fx"

struct PSInput
{
    float4 POSITION : SV_POSITION;
    float2 TEXCOORD : TEXCOORD;
};

struct PSOutput
{
    float4 Color : SV_TARGET;
};

Texture2D BaseColorTex : register(t0);
Texture2D DiffuseTex : register(t1);
Texture2D SpecularTex : register(t2);
Texture2D AmbientTex : register(t3);
Texture2D ShadowTex : register(t4);
SamplerState Sampler : register(s0);

PSOutput DeferredMerge_PS(PSInput _Input) : SV_TARGET
{
    PSOutput OutPut = (PSOutput) 0;
    
    float4 Albedo = BaseColorTex.Sample(Sampler, _Input.TEXCOORD);
    float4 DiffuseRatio = DiffuseTex.Sample(Sampler, _Input.TEXCOORD);
    float4 SpacularRatio = SpecularTex.Sample(Sampler, _Input.TEXCOORD);
    float4 AmbientRatio = AmbientTex.Sample(Sampler, _Input.TEXCOORD);
    float ShadowMask = ShadowTex.Sample(Sampler, _Input.TEXCOORD).x;
    
    // 그림자 어두운 정도 (0.0 = 안 어두움, 1.0 = 완전 검정)
    float ShadowStrength = 0.7;

    // 그림자 계수 계산: 
    // shadowM==0 → 1.0 (원래 라이팅),  
    // shadowM==1 → 1.0-shadowStrength (어두워진 라이팅)
    float ShadowFactor = lerp(1.0, 1.0 - ShadowStrength, ShadowMask);
    
    DiffuseRatio *= ShadowFactor;
    SpacularRatio *= ShadowFactor;
    
    if (Albedo.a)
    {
        OutPut.Color.xyz = Albedo.xyz * (DiffuseRatio.xyz + SpacularRatio.xyz + AmbientRatio.xyz);
        OutPut.Color.a = saturate(Albedo.a + (DiffuseRatio.w + SpacularRatio.w + AmbientRatio.w));
        OutPut.Color.a = 1.0f;
    }
    else
    {
        OutPut.Color.xyz = (DiffuseRatio.xyz + SpacularRatio.xyz + AmbientRatio.xyz);
        OutPut.Color.a = saturate(OutPut.Color.x);
    }
    
    return OutPut;
}

 

샘플링을 실시하면 특정 픽셀에 대해서는 R채널(x) 값이 1로 나옵니다. 해당 위치는 그림자 영역이기 때문에, 음영이 지도록 처리해주면 됩니다. 여기서는 단순하게 해당 영역에 대해서는 Diffuse와 Specular Light 값에 Shadow Factor라고 해서 약간의 값을 처리해줬습니다. 1 이하의 값이기 때문에 곱해주면 원래 빛의 Color 보다 어두워질 것입니다.

 

+) 저는 약간의 노가다를 해서 원래 Light 들로 생겨났던 음영과 비슷한 강도로 설정했습니다.

 

최종 결과를 확인해봅시다.

 

+) 여기서는 따로 언급을 안했는데, Dynamic의 Shadow의 경우, 그림자맵을 만들 때 Vertex Shader에서 기존과 같이 스키닝을 실시해줘야 합니다. 아마 안하면 T-Pose를 취한 상태의 Maximo 캐릭터가 공중에 떠있는 그림자가 생성될 것입니다.

+ Recent posts