[드로우 콜이란]

 

드로우 콜(Draw Call)은 CPU가 GPU에게 오브젝트를 그리라고 명령을 내리는 것입니다. 자세히는 GPU의 Render State를 변경하는 작업입니다. GPU는 혼자서 아무것도 할 수 없습니다. 오브젝트를 그릴 때 조차 어떤 텍스쳐를 써야하는지, 어떤 셰이더를 써야하는지가 정해져 있지 않아서 CPU가 이것을 알려줘야합니다. 이렇게 알려주면(Draw Call) GPU는 이제 그것에 따라서 세팅된 값(Render State)을 그대로(굉장히 빠르게) 처리합니다.

 

물론 이 Render State에 들어갈 값들은 이미 GPU Memory에 존재해야 합니다(VRAM). 우리는 이미 이 값들이 무엇인지 알고 있습니다.

 

- InputLayout

- Vertex Buffer

- Index Buffer

- Constant Buffer(Texture, Sampler)

- Vertex Shader

- Pixel Shader

- DepthStencilState, BlendState, RasterizerState

 

이 값들을 DirectX11의 함수를 통해 바인딩 해주어 데이터 시작 주소를 가져가 VRAM에 Copy/Paste 해준 뒤, GPU가 이대로 그릴 수 있도록 해줬습니다. 이후 마지막 단계에서 이 값들을 가지고 Draw() 함수를 호출하여 오브젝트를 그리게 됩니다.

 

하지만 이 Render State를 바꾸는 과정은 아주 많은 오버헤드가 발생합니다. CPU와 GPU가 사용하는 언어가 다른데, 이걸 번역해주는 과정이 필요하기 때문입니다. 보통 이 번역은 우리가 GPU를 위해 설치하는 GPU Driver 내 Code 들이 실시해줍니다. 결국 번역 때문에 시간이 걸리는 것인데, CPU가 Vsync 1회(보통 33ms) 안에 값을 정상적으로 전달해줘야 되지만, 가끔은 CPU 자체의 전달 과정이 오래 걸리거나 번역에 이상이 생겨 지연될 경우 Bottle Neck 현상이 발생하기도 합니다.

 

 

[드로우 콜 줄이기]

 

당연히 성능을 위해 많은 최적화 기법들이 생겨났습니다.

 

1. 배칭(Batching)

: 여러 메시를 하나의 버퍼에 묶어서 한 번에 그리는 방법입니다. 여기에는 정적 메시를 미리 합쳐두는 Static Batching과, 매 프레임 동적으로 유니폼 버퍼 값만 바꿔서 그리는 Dynamic Batching이 있습니다.

 

- 런타임 오버 헤드 없음

- 병합된 하나의 버퍼만 바인딩하여 드로우 콜이 1회가 됨

- 병합된 버퍼 크기 증가 시 메모리 사용량이 상승

- 스태틱 배칭의 경우 병합된 메시를 모두 활용하기 때문에 메모리 사용량이 증가하며, 개별 컬링이 불가능함, 변형이 필요한 경우 다시 병합해야 하므로 유연성이 낮음 

- 다이나믹 배칭의 경우 실시간으로 연산하기엔 연산량이 비대하여 버텍스 수가 많으면 오히려 배칭으로 발생하는 손해가 더 커짐

 

2. 인스턴싱(Instancing)

: 동일한 메시가 있다면, 이걸 개별로 드로잉하지 않고 여러 위치, 트랜스폼으로 동시에 그려버릴 수 있습니다. 이걸 인스턴싱이라고 합니다. 인스턴스별로 행렬 정보와 머티리얼 값만 다르게 전달해주면 됩니다.

 

- 오브젝트 수 만큼 드로우콜이 감소(10개 그릴걸 하나에 몰아서 동시에 그리기 때문)

- API 호출을 오브젝트 수만큼 하지 않아서 드로우 콜 오버헤드 감소

- 버텍스 데이터가 한 곳에 있고, 인스턴스 데이터가 연속된 배열로 감싸져 있어서 캐시 로컬리티 좋음

- 너무 크면 메모리가 낭비되고, 너무 작으면 여러 번 분할 업데이트 되기 때문에 적당한 크기를 찾아야함

 

3. 텍스쳐 아틀라스(Atlas)

: 여러 텍스쳐를 하나의 큰 텍스쳐에 합쳐서 활용하는 방법입니다. 이러면 셰이더에서 UV 오프셋만 조정해서 다양한 텍스쳐처럼 사용할 수 있어 Texture 바인딩 횟수가 감소하게 됩니다.

 

- 다른 텍스쳐 바인딩을 따로하지 않고 같이 해서 사용하여 드로우콜이 1회로 감소

- 셰이더 리소스 바인딩 횟수가 줄어 오버헤드 감소

- 텍스쳐 샘플링 시 한 덩어리 텍스쳐에서 샘플링하므로 메모리 로컬리티 좋음

- 텍스쳐 경계에서 이웃 픽셀이 섞일 수록, 각 서브 텍스쳐 사이에 1-2px의 패딩을 넣지 않으면 블리딩(Bleeding) 현상 발생

- 자동 생성된 MipMap이 경계 영역을 섞을 수 있도록, 런타임에 수동으로 MipMap을 만들어거나 패딩을 충분히 확보해야함

- GPU마다 지원하는 최대 텍스쳐 크기가 있어서 서브 텍스쳐 갯수와 크기에 맞춰서 설계해야함

 

4. 프로스트럼, 오클루전 컬링

 

: 카메라 시야 밖이나 가려진 오브젝트를 배제하는 방법으로, DirectX에서는 Rasterizer 단계에서 자체적으로 실행되기도 하며, 사용자가 임의로 오브젝트를 컬링하는 방식도 있습니다.

 

5. LOD(Level of Detail)

: 멀리 있는 객체는 폴리곤 수가 적은 메시로 치환하는 방법입니다. 메시의 폴리곤 수가 적으면 적을수록, 이 메시를 표현하기 위해 사용되는 GPU 자원이 적어지기 때문에 드로우 콜 과정이 단순화됩니다.

 

- 멀리 있는 오브젝트는 픽셀 당 차이가 눈에 보이지 않아, 단순화 시켜서 드로우 연산 자체 부담을 줄임

- 복잡한 씬에서도 가까운 오브젝트만 정밀하게 그리고 안보이거나 제대로 안봐도 되는 경우에는 간단하게 렌더링하여 퍼포먼스 최적화

- 하지만 전환 지점이 너무 짧으면 Popping 현상이 발생(페이드 블렌드, 크로스 페이드 적용 필요)

- LOD 모델 자체에 단계별 메시들이 저장되어 있어 메모리 부담, 따로 로드해도 부담

'DirectX11 > 그래픽스' 카테고리의 다른 글

[Graphics] Rendering Pipeline  (0) 2025.06.18
[Graphics] 포워드 렌더링, 디퍼드 렌더링  (0) 2025.06.11
[Graphics] Normal Mapping  (0) 2025.06.07
[Graphics] Normal  (0) 2025.06.06
[Graphics] Lighting  (2) 2025.06.05

[목차]

 

- 변경 이유

- 변경 결과

- 구조 변경

- 디퍼드 렌더링을 위해 필요한 기능들

 

 

[변경 이유]

 

이후의 단계로 그림자 매핑과 포스트 프로세싱을 진행하려고 하는데, 제가 알고있는 방식이 디퍼드 렌더링 방식이라 조금 고민을 했습니다. 그래도 애초에 프레임워크 제작 이유가 그래픽스 학습과 DirectX11 복습이었기 때문도 있고 시간적인 여유가 부족했기 때문에 그냥 디퍼드 렌더링으로 변경하여 다음 단계를 진행하기로 마음먹었습니다.

 

 

[변경 결과]

 

우선 변경 결과입니다.

 

기존의 결과와 크게 달라진 건 없고, 구조적인 부분만 변경되었습니다. 내용이 상당히 길기 때문에 천천히 읽어보시면 될 것 같습니다.

 

 

[구조 변경]

 

1. fx 파일 생성

: G-Buffer를 만들기 전에, 공통적으로 사용하는 메모리 슬롯이나 struct 부분에 대해서 따로 fx 파일을 만들어서 저장해두었습니다. 

/////////// Transform.fx
cbuffer TransformData : register(b0)
{
    float4 LocalPosition;
    float4 LocalRotation;
    float4 LocalScale;
    float4 LocalQuaternion;
    float4x4 LocalMatrix;

    float4 WorldPosition;
    float4 WorldRotation;
    float4 WorldQuaternion;
    float4 WorldScale;
    float4x4 WorldMatrix;

    float4x4 ViewMatrix;
    float4x4 ProjectionMatrix;
    float4x4 WorldViewMatrix;
    float4x4 WorldViewProjectionMatrix;
}

/////////// LightData.fx
struct LightData
{
    float4 LightColor; // RGB(색), w(강도)
    float4 LightWorldPosition;
    float4 LightForwardVector;
    float4 CameraWorldPosition;

    float ShadowTargetSizeX;
    float ShadowTargetSizeY;
    
    float NearDistance;
    float FarDistance;
    float AttenuationValue;
    int LightType;
    bool bIsLightSet;
    
    float4x4 LightViewMatrix;
    float4x4 LightViewInverseMatrix;
    float4x4 LightProjectionMatrix;
    float4x4 LightProjectionInverseMatrix;
    float4x4 LightViewProjectionMatrix;
    float4x4 CameraViewInverseMatrix;
};

cbuffer LightDatas : register(b2)
{
    int LightCount;
    LightData Lights[64];
};

float3 DiffuseLightCalculation(float3 _LightDirection, float3 _Normal)
{
    float3 LightDirection = normalize(_LightDirection); // 빛 계산에 사용하는 LightDirection은 "표면에서 봤을 때 빛이 표면으로 들어오는 방향"을 사용하므로 반대로 뒤집음
    float3 Normal = normalize(_Normal);
    float3 DiffuseLight = saturate(dot(LightDirection, Normal));
    
    return DiffuseLight;
}

float SpecularLightCalculation(float3 _LightDirection, float3 _Normal, float3 _CameraWorldPosition, float3 _PixelPosition, float _Shininess)
{
    // Phong
    //float3 ReflectionVector = normalize(2.0f * _Normal * dot(_LightDirection, _Normal) - _LightDirection); // 반사벡터
    //float3 EyePosition = _CameraWorldPosition;
    //float3 EyeDirection = normalize(EyePosition - _PixelPosition);
    //float RDotV = max(0.0f, dot(ReflectionVector, EyeDirection));
    //float3 SpecularLight = pow(RDotV, _Shininess);
    
    //return SpecularLight;
    
    // Blinn-Phong
    float3 L = normalize(_LightDirection);
    float3 V = normalize(_CameraWorldPosition - _PixelPosition);
    float3 H = normalize(L + V);

    float NdotH = saturate(dot(normalize(_Normal), H));
    float3 SpecularLight = pow(NdotH, _Shininess);
    
    return SpecularLight;
}

float3 AmbientLightCalculation(float _AmbientIntensity)
{
    float3 AmbientLight;
    AmbientLight.x = _AmbientIntensity;
    AmbientLight.y = _AmbientIntensity;
    AmbientLight.z = _AmbientIntensity;
    
    return AmbientLight;
}

 

2. Rendering() 과정 변경

: 업데이트 과정이 변경되었습니다. 그게 Geometry Pass, ShadowMap Pass, Light Pass로 나뉩니다.

void Ext_Camera::Rendering(float _Deltatime)
{
	MeshRenderTarget->RenderTargetClear();
	MeshRenderTarget->RenderTargetSetting();
	
////////////////// 일반 패스(Mesh, Position, Normal Buffer) ///////////////
	// 전체 유닛 Z정렬 후 렌더링
	std::vector<std::shared_ptr<Ext_MeshComponentUnit>> AllRenderUnits;
	for (auto& [RenderPathKey, UnitMap] : MeshComponentUnits)
	{
		for (auto& [IndexKey, UnitList] : UnitMap)
		{
			for (auto& Unit : UnitList)
			{
				auto Owner = Unit->GetOwnerMeshComponent().lock();
				if (!Owner || Owner->IsDeath() || !Owner->IsUpdate())
				{
					continue;
				}
				AllRenderUnits.push_back(Unit);
			}
		}
	}

	float4 CamPos = GetTransform()->GetWorldPosition();
	std::sort(AllRenderUnits.begin(), AllRenderUnits.end(),
		[&](const std::shared_ptr<Ext_MeshComponentUnit>& A, const std::shared_ptr<Ext_MeshComponentUnit>& B)
		{
			auto AMesh = A->GetOwnerMeshComponent().lock();
			auto BMesh = B->GetOwnerMeshComponent().lock();
			return (AMesh->GetTransform()->GetWorldPosition() - CamPos).Size()
			 > (BMesh->GetTransform()->GetWorldPosition() - CamPos).Size();
		});

	std::unordered_set<std::shared_ptr<Ext_MeshComponent>> UpdatedComponents;
	for (auto& Unit : AllRenderUnits)
	{
		auto Owner = Unit->GetOwnerMeshComponent().lock();
		if (!Owner) continue;

		// View/Projection은 한 번만 업데이트
		if (UpdatedComponents.insert(Owner).second)
		{
			Owner->Rendering(_Deltatime, GetViewMatrix(), GetProjectionMatrix()); // 행렬 업데이트
		}

		Unit->Rendering(_Deltatime); // 렌더링 파이프라인 세팅 후 드로우콜
	}

//////////////////////// 쉐도우 패스(Shadow Buffer) /////////////////////
	// 쉐도우 패스, 뎁스 만들기
	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(); 
			auto PShadow = Ext_DirectXMaterial::Find("Shadow");
			PShadow->VertexShaderSetting();
			PShadow->RasterizerSetting();
			PShadow->PixelShaderSetting();
			PShadow->OutputMergerSetting();
			Unit->RenderUnitDraw();
		}
	}

//////////// 라이트 패스(Diffuse, Specluar, Ambient Buffer) /////////////
	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++;
	}

	// 빛 합치기(Merge)
	LightMergeRenderTarget->RenderTargetClear();
	LightMergeRenderTarget->RenderTargetSetting();
	LightMergeUnit.Rendering(_Deltatime);

////////////////////////////// MRT Merge ///////////////////////////////
	CameraRenderTarget->RenderTargetClear();
	CameraRenderTarget->Merge(MeshRenderTarget, 0);
	CameraRenderTarget->Merge(LightMergeRenderTarget);
}

 

1. Geometry Pass : Mesh, Position, Normal Buffer 생성

2. Shadow Pass : Shadow Map 생성

3. Light Pass : Diffuse, Specular, Ambient Buffer 생성

 

 

3. G-Buffer 생성

: 셰이더를 통해 값을 생성해주고 G-Buffer에 담습니다. 여기서 중요한 변경점은 Output 구조체의 출력값입니다. 원래는 시스템 시멘틱과 attribute를 출력했지만, 이제부터는 SV_TARGET[n]를 출력합니다. 이러면 OMSetRenderTargets() 함수로 바인딩된 RenderTarget의 RTV들에게 출력 결과물이 저장됩니다.

// Static_VS, Dynamic_VS는 기존과 동일
// Graphics_PS
#include "LightData.fx"

Texture2D BaseColorTex : register(t0); // 텍스처 자원
SamplerState Sampler : register(s0); // 샘플러

struct PSInput
{
    float4 Position : SV_POSITION;
    float2 TexCoord : TEXCOORD;
    float3 WorldPosition : POSITION0;
    float3 WorldViewPosition : POSITION1;
    float3 WorldNormal : NORMAL0;
    float3 WorldViewNormal : NORMAL1;
};

struct PSOutPut
{
    float4 MeshTarget : SV_TARGET0;
    float4 WPositionTarget : SV_TARGET1; // World
    float4 WVPositionTarget : SV_TARGET2; // WorldView
    float4 WNormalTarget : SV_TARGET3; // World
    float4 WVNormalTarget : SV_TARGET4; // WorldView
};

 // 각 벡터에 normalize를 해주는 이유는, 명시적으로 normalize된 벡터를 넣어줬다 하더라도 
 // 임의의 값이 어떻게 들어올지 모르기 때문에 그냥 해주는것(안정성을 위한 처리라고 보면 됨)
PSOutPut Graphics_PS(PSInput _Input) : SV_TARGET
{
    PSOutPut Output = (PSOutPut) 0;
    
    Output.MeshTarget = BaseColorTex.Sample(Sampler, _Input.TexCoord); // 텍스쳐컬러
    Output.WPositionTarget = float4(_Input.WorldPosition.xyz, 1.0f); // 월드스페이스 Position
    Output.WVPositionTarget = float4(_Input.WorldViewPosition.xyz, 1.0f); // 뷰스페이스 Position
    Output.WNormalTarget = float4(_Input.WorldNormal, 1.0f); // 월드스페이스 Normal
    Output.WVNormalTarget = float4(_Input.WorldViewNormal, 1.0f); // 뷰스페이스 Normal

    return Output;
}

 

위에는 Vertex Shader이고, Light는 Pixel Shader에서 진행해줍니다.

/////////////////////// DeferredLight_VS
struct VSInput
{
    float4 Position : POSITION;
    float4 Texcoord : TEXCOORD;
};

struct VSOutput
{
    float4 Position : SV_Position;
    float2 Texcoord : TEXCOORD;
};

VSOutput DeferredLight_VS(VSInput _Input)
{
    VSOutput OutPut = (VSOutput) 0;
    OutPut.Position = _Input.Position;
    OutPut.Texcoord = _Input.Texcoord.xy;
    
    return OutPut;
}

/////////////////////// DeferredLight_PS
#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
{
    PSOutPut OutPut = (PSOutPut) 0;
        
    float3 WorldPos = PositionTex.Sample(Sampler, _Input.Texcoord).xyz;
    float3 WorldNorm = NormalTex.Sample(Sampler, _Input.Texcoord).xyz;
        
    LightData LTData = Lights[LightCount];
   
    // 공통 파라미터
    float Shininess = 32.0f;
    float3 EyePosition = LTData.CameraWorldPosition.xyz;
    float3 EyeDirection; // 계산 시점에 맞춰 설정
    
    float3 DiffuseLight = float3(0.0f, 0.0f, 0.0f);
    float3 SpecularLight = float3(0.0f, 0.0f, 0.0f);
    float3 AmbientLight = float3(0.0f, 0.0f, 0.0f);
    
    // Directional Light 분기
    if (LTData.LightType == 0)
    {
        // 라이트 방향: surface → light 이므로 LightForwardVector에 -1을 곱함
        float3 LightDirection = -LTData.LightForwardVector.xyz;

        // Diffuse / Specular / Ambient
        DiffuseLight = DiffuseLightCalculation(LightDirection, WorldNorm);
        SpecularLight = SpecularLightCalculation(LightDirection, WorldNorm, EyePosition, WorldPos, Shininess);
        AmbientLight = AmbientLightCalculation(LTData.LightColor.w);
        
        DiffuseLight *= LTData.LightColor.xyz;
        SpecularLight *= LTData.LightColor.xyz;
        AmbientLight *= LTData.LightColor.xyz;
    }
    else if (LTData.LightType == 1)  // Point Light 분기
    {
        // 픽셀 → 광원 벡터
        float3 PixelToLight = LTData.LightWorldPosition.xyz - WorldPos;
        float Distance = length(PixelToLight);
        float3 Vector = normalize(PixelToLight);

        // Diffuse / Specular / Ambient
        DiffuseLight = DiffuseLightCalculation(Vector, WorldNorm);
        SpecularLight = SpecularLightCalculation(Vector, WorldNorm, EyePosition, WorldPos, Shininess);
        AmbientLight = AmbientLightCalculation(LTData.LightColor.w);

        // 거리 기반 감쇠
        float C0 = 1.0f;
        float C1 = 0.0f;
        float C2 = LTData.AttenuationValue / (LTData.FarDistance * LTData.FarDistance);
        float Attenuation = 1.0f / (C0 + C1 * Distance + C2 * Distance * Distance);
        
        DiffuseLight *= Attenuation;
        SpecularLight *= Attenuation;
        AmbientLight *= Attenuation;
        
        DiffuseLight *= LTData.LightColor.xyz;
        SpecularLight *= LTData.LightColor.xyz;
        AmbientLight *= LTData.LightColor.xyz;
    }

    if (DiffuseLight.x > 0.0f)
    {
        // 나중에 셰도우 처리할 곳
        OutPut.ShadowTarget = float4(0.0f, 0.0f, 0.0f, 0.0f);
    }
    
    OutPut.DiffuseTarget = float4(DiffuseLight, 1.0f);
    OutPut.SpecularTarget = float4(SpecularLight, 1.0f);
    OutPut.AmbientTarget = float4(AmbientLight, 1.0f);
    
    return OutPut; // 카메라 행렬 빛의 위치 그려져있는 빛을 기반으로한 깊이 버퍼 텍스처 리턴
}

 

 

4. Merge

: 위의 셰이더들을 통해 Mesh, Position, Normal, Diffuse, Specular, Ambient, Shadow 값이 Buffer들(RenderTarget)에 담깁니다. 이제 이들을 하나로 합쳐줍니다. 여기서 각 Buffer값(Texture인데 실제로는 SRV)을 합쳐주기 위한 셰이더의 슬롯에 바인딩해줍니다.

//////////////////////// DeferredMerge_VS
struct VSInput
{
    float4 Position : POSITION;
    float4 Texcoord : TEXCOORD;
};

struct VSOutput
{
    float4 Position : SV_Position;
    float2 Texcoord : TEXCOORD;
};

VSOutput DeferredMerge_VS(VSInput _Input)
{
    VSOutput OutPut = (VSOutput) 0;
    OutPut.Position = _Input.Position;
    OutPut.Texcoord = _Input.Texcoord.xy;
    
    return OutPut;
}

//////////////////////// DeferredMerge_PS
#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 PointWrap : register(s0);

PSOutput DeferredMerge_PS(PSInput _Input) : SV_TARGET
{
    PSOutput OutPut = (PSOutput) 0;
    
    float4 Albedo = BaseColorTex.Sample(PointWrap, _Input.TEXCOORD);
    float4 DiffuseRatio = DiffuseTex.Sample(PointWrap, _Input.TEXCOORD);
    float4 SpacularRatio = SpecularTex.Sample(PointWrap, _Input.TEXCOORD);
    float4 AmbientRatio = AmbientTex.Sample(PointWrap, _Input.TEXCOORD);
    float ShadowMask = ShadowTex.Sample(PointWrap, _Input.TEXCOORD).x;
    
    // 그림자 어두운 정도 (0.0 = 안 어두움, 1.0 = 완전 검정), 테스트중인 그림자
    float ShadowStrength = 0.7;
    float ShadowFactor = lerp(1.0, 1.0 - ShadowStrength, ShadowMask);
    
    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;
}

 

위에는 결과들을 합쳐주는 셰이더이고, 그냥 렌더타겟 끼리 합쳐주는 셰이더는 아래를 활용합니다.

//////////////////////// RenderTargetMerge_VS
struct VSInput
{
    float4 Position : POSITION;
    float4 Texcoord : TEXCOORD;
};

struct VSOutput
{
    float4 Position : SV_POSITION;
    float4 Texcoord : TEXCOORD;
};

VSOutput RenderTargetMerge_VS(VSInput _Input)
{
    VSOutput OutPut = (VSOutput) 0;
    OutPut.Position = _Input.Position;
    OutPut.Texcoord = _Input.Texcoord;
    
    return OutPut;
}

//////////////////////// RenderTargetMerge_PS
Texture2D DiffuseTex : register(t0);
SamplerState AlwaysSampler : register(s0);

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

float4 RenderTargetMerge_PS(PSInput _Input) : SV_TARGET
{
    float4 Color = DiffuseTex.Sample(AlwaysSampler, _Input.Texcoord.xy);
    Color.a = saturate(Color.a);
    
    return Color;
}

 

 

5. Material Setting

: Material 값들은 다음과 같이 사용했습니다.

// Deffered
{
	std::shared_ptr<Ext_DirectXMaterial> NewRenderingPipeline = Ext_DirectXMaterial::CreateMaterial("DeferredLight");
	NewRenderingPipeline->SetVertexShader("DeferredLight_VS");
	NewRenderingPipeline->SetPixelShader("DeferredLight_PS");
	NewRenderingPipeline->SetBlendState("OneBlend");
	NewRenderingPipeline->SetDepthState("AlwayDepth");
	NewRenderingPipeline->SetRasterizer("NonCullingRasterizer");
}

// Deffered된 결과물 Merge용
{
	std::shared_ptr<Ext_DirectXMaterial> NewRenderingPipeline = Ext_DirectXMaterial::CreateMaterial("DeferredMerge");
	NewRenderingPipeline->SetVertexShader("DeferredMerge_VS");
	NewRenderingPipeline->SetPixelShader("DeferredMerge_PS");
	NewRenderingPipeline->SetBlendState("AlphaBlend");
	NewRenderingPipeline->SetDepthState("AlwayDepth");
	NewRenderingPipeline->SetRasterizer("NonCullingRasterizer");
}

// 렌더타겟 간 Merge를 위한 머티리얼
{
	std::shared_ptr<Ext_DirectXMaterial> NewRenderingPipeline = Ext_DirectXMaterial::CreateMaterial("RenderTargetMerge");

	NewRenderingPipeline->SetVertexShader("RenderTargetMerge_VS");
	NewRenderingPipeline->SetPixelShader("RenderTargetMerge_PS");
	NewRenderingPipeline->SetBlendState("MergeBlend");
	NewRenderingPipeline->SetDepthState("AlwayDepth");
	NewRenderingPipeline->SetRasterizer("NonCullingRasterizer");
}

 

새로 추가된 항목이 있습니다.

 

- OneBlend와 MergeBlend

// One 블렌드, Deffered용
{
	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_ADD;

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

	BlendInfo.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;

	Ext_DirectXBlend::CreateBlend("OneBlend", BlendInfo);
}

// Merge용 블렌드
{
	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_INV_SRC_ALPHA;
	BlendInfo.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD;

	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("MergeBlend", BlendInfo);
}

 

One 블렌드 공식은 다음과 같습니다.

OutputColor = SrcColor * 1 + DestColor * 1
            = SrcColor + DestColor

 

Additive Blending인데, 디퍼드 라이팅 조명 패스에서 여러 광원 결과를 누적할 때 주로 사용됩니다.

 

Merge 블렌드 공식은 다음과 같습니다.

OutputColor = SrcColor * 1 + DestColor * (1 - SrcAlpha)
            = SrcColor + DestColor * (1 - SrcAlpha)

 

일반적인 투명도 블렌딩 공식과 거의 같은데, Src에 1을 곱하는 것만 다릅니다. 해당 방식은 Deferred Merge나 반투명 오브젝트 렌더링 시에 활용되는 방식입니다.

 

- AlwaysDepth

// AlwayDepth
{
	D3D11_DEPTH_STENCIL_DESC DepthStencilInfo = { 0, };

	DepthStencilInfo.DepthEnable = true;
	DepthStencilInfo.DepthWriteMask = D3D11_DEPTH_WRITE_MASK::D3D11_DEPTH_WRITE_MASK_ALL;
	DepthStencilInfo.DepthFunc = D3D11_COMPARISON_FUNC::D3D11_COMPARISON_ALWAYS;
	DepthStencilInfo.StencilEnable = false;

	Ext_DirectXDepth::CreateDepthStencilState("AlwayDepth", DepthStencilInfo);
}

 

보통 Depth는 D3D11_DEPTH_WRITE_MASK_ALL에 D3D11_COMPARISON_LESS을 활용하여 깊이 테스트를 진행해 현재 픽셀이 이전 픽셀보다 더 가까울 때만 그리도록 합니다.

 

하지만 위의 DepthStancilState는 깊이 테스트를 무조건 통과시키는 방식입니다. 깊이 버퍼 값도 무조건 현재 픽셀 값으로 덮어씌웁니다. 렌더타겟간 Merge를 실시할 때는 해당 DepthStencilState를 주로 활용합니다.

 

- NonCullingRasterizer

// None컬링 레스터라이저
{
	D3D11_RASTERIZER_DESC Desc = {};

	Desc.CullMode = D3D11_CULL_NONE;
	Desc.FrontCounterClockwise = FALSE;
	Desc.FillMode = D3D11_FILL_SOLID;

	std::shared_ptr<Ext_DirectXRasterizer> NewRast = Ext_DirectXRasterizer::CreateRasterizer("NonCullingRasterizer", Desc);
}

 

이건 아예 후면 컬링을 진행하지 않는다는 Rasterizer인데, 사실 후면 컬링을 진행해도 상관 없습니다. 그래도 2D Texture를 다룰 때는 혹시 모를 상황을 방지하고자 NONE culling을 실시합니다.

 

6. G-Buffer 메모리 공간 확보

: Ext_Camera가 메모리들을 가지고 있을 것입니다. 메모리는 RenderTarget 형태로 존재합니다.

void Ext_Camera::Start()
{
	// ...
    
	// 메인패스 렌더타겟 - MeshTarget, PositionTarget, NormalTarget
	MeshRenderTarget = Ext_DirectXRenderTarget::CreateRenderTarget(DXGI_FORMAT::DXGI_FORMAT_R32G32B32A32_FLOAT, Base_Windows::GetScreenSize(), float4::ZERONULL); // 0 MeshTarget
	MeshRenderTarget->AddNewTexture(DXGI_FORMAT::DXGI_FORMAT_R32G32B32A32_FLOAT, Base_Windows::GetScreenSize(), float4::ZERONULL); // 1 PositionTarget (World)
	MeshRenderTarget->AddNewTexture(DXGI_FORMAT::DXGI_FORMAT_R32G32B32A32_FLOAT, Base_Windows::GetScreenSize(), float4::ZERONULL); // 2 PositionTarget (WorldView)
	MeshRenderTarget->AddNewTexture(DXGI_FORMAT::DXGI_FORMAT_R32G32B32A32_FLOAT, Base_Windows::GetScreenSize(), float4::ZERONULL); // 3 NormalTarget (World)
	MeshRenderTarget->AddNewTexture(DXGI_FORMAT::DXGI_FORMAT_R32G32B32A32_FLOAT, Base_Windows::GetScreenSize(), float4::ZERONULL); // 4 NormalTarget (WorldView)
	MeshRenderTarget->CreateDepthTexture();

	// 디퍼드 라이트 계산 렌더 타겟(쉐도우 뎁스까지 계산) - DiffuseTarget, SpecularTarget, AmbientTarget, ShadowTarget
	LightRenderTarget = Ext_DirectXRenderTarget::CreateRenderTarget(DXGI_FORMAT::DXGI_FORMAT_R32G32B32A32_FLOAT, Base_Windows::GetScreenSize(), float4::ZERONULL); // 0 DiffuseTarget
	LightRenderTarget->AddNewTexture(DXGI_FORMAT::DXGI_FORMAT_R32G32B32A32_FLOAT, Base_Windows::GetScreenSize(), float4::ZERONULL); // 1 SpecularTarget
	LightRenderTarget->AddNewTexture(DXGI_FORMAT::DXGI_FORMAT_R32G32B32A32_FLOAT, Base_Windows::GetScreenSize(), float4::ZERONULL); // 2 AmbientTarget
	LightRenderTarget->AddNewTexture(DXGI_FORMAT::DXGI_FORMAT_R32G32B32A32_FLOAT, Base_Windows::GetScreenSize(), float4::ZERONULL); // 3 ShadowTarget
	LightRenderTarget->AddNewTexture(DXGI_FORMAT::DXGI_FORMAT_R32G32B32A32_FLOAT, Base_Windows::GetScreenSize(), float4::ZERONULL); // 4 ShadowDepthTarget

	// 디퍼드 라이트 Merge 타겟 - DSA를 하나로 만듬
	LightMergeRenderTarget = Ext_DirectXRenderTarget::CreateRenderTarget(DXGI_FORMAT::DXGI_FORMAT_R32G32B32A32_FLOAT, Base_Windows::GetScreenSize(), float4::ZERONULL); // Light들 Merge하는 렌더타겟

	// 카메라 최종 렌더타겟
	CameraRenderTarget = Ext_DirectXRenderTarget::CreateRenderTarget(DXGI_FORMAT::DXGI_FORMAT_R32G32B32A32_FLOAT, Base_Windows::GetScreenSize(), float4::ZERONULL); // 해당 카메라의 최종 결과물 타겟

	// LightRenderTarget(디퍼드 라이트 계산)을 위한 Unit
	LightUnit.MeshComponentUnitInitialize("FullRect", MaterialType::DeferredLight);
	const LightDatas& LTDatas = GetOwnerScene().lock()->GetLightDataBuffer();
	LightUnit.BufferSetter.SetConstantBufferLink("LightDatas", LTDatas);
	LightUnit.BufferSetter.SetTexture(MeshRenderTarget->GetTexture(1), "PositionTex");
	LightUnit.BufferSetter.SetTexture(MeshRenderTarget->GetTexture(3), "NormalTex");
	LightUnit.BufferSetter.SetTexture("Null.png", TextureType::Shadow);

	// LightMergeRenderTarget(디퍼드 라이트 Merge)를 위한 Unit
	LightMergeUnit.MeshComponentUnitInitialize("FullRect", MaterialType::DeferredMerge);
	LightMergeUnit.BufferSetter.SetTexture(MeshRenderTarget->GetTexture(0), "BaseColorTex");
	LightMergeUnit.BufferSetter.SetTexture(LightRenderTarget->GetTexture(0), "DiffuseTex");
	LightMergeUnit.BufferSetter.SetTexture(LightRenderTarget->GetTexture(1), "SpecularTex");
	LightMergeUnit.BufferSetter.SetTexture(LightRenderTarget->GetTexture(2), "AmbientTex");
	LightMergeUnit.BufferSetter.SetTexture(LightRenderTarget->GetTexture(3), "ShadowTex");
}

 

BufferSetting을 각 RenderTarget마다 진행할 것이기 때문에, 렌더링 파이프라인 세팅을 위하여 각각의 Unit들을 만들어줬습니다. 여기서 FullRect를 활용하는데, 이러면 화면에 꽉 차는 사각형이 하나 만들어질 것이며, 여기에 결과물들을 그려주게 될 것입니다.

 

 

[디퍼드 렌더링을 위해 필요한 기능들]

 

1. 셰이더 리소스 뷰(SRV)

: 원래도 Texture를 활용할 때 사용하고 있었는데, RenderTarget을 텍스쳐 슬롯에 바인딩 할때도 똑같이 해당 기능을 활용하게 됩니다.

// 텍스쳐를 사용한 경우, 여기서 추가로 VSSetting 실시
void Ext_DirectXTexture::VSSetting(UINT _Slot)
{
	if (nullptr == SRV)
	{
		MsgAssert("SRV가 존재하지 않는 텍스처를 쉐이더에 세팅할수 없습니다.");
		return;
	}

	Ext_DirectXDevice::GetContext()->VSSetShaderResources(_Slot, 1, &SRV);
}

// 텍스쳐를 사용한 경우, 여기서 추가로 PSSetting 실시
void Ext_DirectXTexture::PSSetting(UINT _Slot)
{
	if (nullptr == SRV)
	{
		MsgAssert("SRV가 존재하지 않는 텍스처를 쉐이더에 세팅할수 없습니다.");
		return;
	}

	Ext_DirectXDevice::GetContext()->PSSetShaderResources(_Slot, 1, &SRV);
}

 

SRV에 대해서 다시 짚고 넘어가자면, 그림판을 어떻게 다룰지에 대한 설명서라고 보시면 됩니다. 어떤 Slot 정보를 가져야 하는지, Format은 뭔지, 몇 차원(Dimentions) 그림인지, MipMap 수준은 어떻게 되는지, 데이터 Array는 어떤지를 SRV가 가지고 있습니다. 

 

SRV 바인딩은 주로 Pixel Shader에서 실시하기 떄문에, PSSetShaderResources() 함수를 호출하여 여기에 텍스쳐의 SRV를 바인딩해주면 현재 바인딩된 Pixel Shader의 t 슬롯에 바인딩 됩니다. Multi Render Target의 경우에는 한꺼번에 넘겨줘서 바인딩시킬 수 있긴 한데, 해당 프레임워크는 그냥 반복문을 돌면서 하나씩 바인딩 해주는 걸로 진행했습니다.

 

 

2. Multi Render Target(MRT)

: 말 그대로 여러 개의 렌더 타겟인데, 위에서 설명한 것과 같이 출력 결과를 RenderTarget 여러 개로 지정할 수 있습니다.

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

PSOutPut DeferredLight_PS(PSInput _Input) : SV_TARGET
{
 // TODO
}

 

이를 위해서는 RenderTarget이 여러 개의 RTV를 가지고 있어야 하며, OMSetRenderTarget을 통해 렌더링 파이프라인에 바인딩되어야 합니다. 이것도 이미 만들어둔 것이기 때문에 그냥 사용하면 됩니다.

 

void Ext_DirectXRenderTarget::RenderTargetSetting()
{
	ID3D11RenderTargetView** RTV = &RTVs[0];
	COMPTR<ID3D11DepthStencilView> DSV = DepthTexture != nullptr ? DepthTexture->GetDSV() : nullptr;
	
	Ext_DirectXDevice::GetContext()->OMSetRenderTargets(static_cast<UINT>(RTVs.size()), RTV, DSV.Get()); // Output-Merger 스테이지에 렌더 타겟 + 뎁스 설정
	Ext_DirectXDevice::GetContext()->RSSetViewports(static_cast<UINT>(ViewPorts.size()), &ViewPorts[0]); // Rasterizer 스테이지에 현재 프레임에서 사용할 ViewPort 영역 설정, 이게 있어야 NDC > 픽셀 공간 변환이 올바르게 수행됨
}

 

맨 처음에 RenderTarget에 저장되는 자료 구조를 굳이 컨테이너로 만든 이유가 MRT 때문입니다.

private:
	std::vector<float4> Colors; // 생성된 렌더타겟 색상 저장
	std::vector<std::shared_ptr<Ext_DirectXTexture>> Textures = {}; // Ext_DirectXTexture(생성 주체) 포인터 저장 컨테이너
	std::shared_ptr<Ext_DirectXTexture> DepthTexture = {};
	std::vector<D3D11_VIEWPORT> ViewPorts = {}; // 생성 주체의 ViewPort 정보 저장 컨테이너
	std::vector<COMPTR<ID3D11RenderTargetView>> RTVs = {}; // 렌더타겟뷰들 저장
	std::vector<COMPTR<ID3D11ShaderResourceView>> SRVs = {}; // 셰이더리소스뷰들 저장
    //...

 

여러 개의 RTV와 SRV를 만드는 과정을 위해 AddNewTexture() 함수를 추가해뒀습니다.

// 카메라 생성 시 호출
void Ext_Camera::Start()
{
	// 렌더 타겟에 Texture2D, RTV, SRV들을 만들고 순서대로 저장
	MeshRenderTarget = Ext_DirectXRenderTarget::CreateRenderTarget(DXGI_FORMAT::DXGI_FORMAT_R32G32B32A32_FLOAT, Base_Windows::GetScreenSize(), float4::ZERONULL); // 0 MeshTarget
	MeshRenderTarget->AddNewTexture(DXGI_FORMAT::DXGI_FORMAT_R32G32B32A32_FLOAT, Base_Windows::GetScreenSize(), float4::ZERONULL); // 1 PositionTarget (World)
	MeshRenderTarget->AddNewTexture(DXGI_FORMAT::DXGI_FORMAT_R32G32B32A32_FLOAT, Base_Windows::GetScreenSize(), float4::ZERONULL); // 2 PositionTarget (WorldView)
	MeshRenderTarget->AddNewTexture(DXGI_FORMAT::DXGI_FORMAT_R32G32B32A32_FLOAT, Base_Windows::GetScreenSize(), float4::ZERONULL); // 3 NormalTarget (World)
	MeshRenderTarget->AddNewTexture(DXGI_FORMAT::DXGI_FORMAT_R32G32B32A32_FLOAT, Base_Windows::GetScreenSize(), float4::ZERONULL); // 4 NormalTarget (WorldView)
	MeshRenderTarget->CreateDepthTexture();
    
    // ,,,
}

 

이제 한번에 출력하면 하나의 렌더 타겟에서 여러 개의 결과물을 확인할 수 있습니다.

 

 

3. Merge와 Blending

결과물 출력도 출력이지만, 서로 간의 Merge, 그리고 Blending 과정도 필수입니다. 

 

Light를 그리는 과정을 예시로 확인해보겠습니다. 메인 렌더 패스 이후 진행되는 라이트 패스는 다음의 순서를 따릅니다.

[1] LightRenderTarget->RenderTargetClear();
[2] LightRenderTarget->RenderTargetSetting();

GetOwnerScene().lock()->GetLightDataBuffer().LightCount = 0; // 라이트 업데이트 전, 상수버퍼 갯수 초기화(순회하면서 넣어줘야하기 때문)
for (auto& [name, CurLight] : Lights)
{
	//LightUnit.BufferSetter.SetTexture(CurLight->GetShadowRenderTarget()->GetTexture(0), "ShadowTex");
	[3] LightUnit.Rendering(_Deltatime);
	GetOwnerScene().lock()->GetLightDataBuffer().LightCount++;
}

// 빛 합치기
[4] LightMergeRenderTarget->RenderTargetClear();
[5] LightMergeRenderTarget->RenderTargetSetting();
[6] LightMergeUnit.Rendering(_Deltatime);

 

프레임워크 코드에서 그대로 가져온건데, 1번부터 6번까지 순서대로 설명드리겠습니다.

 

[1], [2] LightRenderTarget->

: 내부에 확인해보시면, 기존에 백버퍼 세팅과 동일하게 Clear를 진행하고 OMSetRenderTargets로 렌더 타겟을 바인딩해주고 있습니다.

 

[3] Rendering

: 기존과 동일하게 Mesh, Material에 대해서 렌더링 파이프라인을 설정하고, 드로우 콜을 호출합니다. 라이트 버퍼 생성에 사용된 머티리얼 세팅이 기존과 다른 점은 Depth, Blend, Rasterizer가 있습니다.

/////////////// Depth
////// 기존 Depth - EQUAL
D3D11_DEPTH_STENCIL_DESC DepthStencilInfo = { 0, };

DepthStencilInfo.DepthEnable = true;
DepthStencilInfo.DepthWriteMask = D3D11_DEPTH_WRITE_MASK::D3D11_DEPTH_WRITE_MASK_ALL;
DepthStencilInfo.DepthFunc = D3D11_COMPARISON_FUNC::D3D11_COMPARISON_LESS_EQUAL;
DepthStencilInfo.StencilEnable = false;

////// 변경 Depth - ALWAYS
D3D11_DEPTH_STENCIL_DESC DepthStencilInfo = { 0, };

DepthStencilInfo.DepthEnable = true;
DepthStencilInfo.DepthWriteMask = D3D11_DEPTH_WRITE_MASK::D3D11_DEPTH_WRITE_MASK_ALL;
DepthStencilInfo.DepthFunc = D3D11_COMPARISON_FUNC::D3D11_COMPARISON_ALWAYS;
DepthStencilInfo.StencilEnable = false;

/////////////// Blend
////// 기존 Blend - BaseBlend
D3D11_BLEND_DESC BlendInfo = { 0, };

BlendInfo.AlphaToCoverageEnable = false;
BlendInfo.IndependentBlendEnable = false;
BlendInfo.RenderTarget[0].BlendEnable = true;

BlendInfo.RenderTarget[0].SrcBlend = D3D11_BLEND_SRC_ALPHA;
BlendInfo.RenderTarget[0].DestBlend = D3D11_BLEND_INV_SRC_ALPHA;
BlendInfo.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD;

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

BlendInfo.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;

////// 변경 Blend - OneBlend
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_ADD;

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

BlendInfo.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;

/////////////// Rasterizer
////// 기존 Rasterizer - BackFace culling
D3D11_RASTERIZER_DESC Desc = {};

Desc.CullMode = D3D11_CULL_BACK;
Desc.FrontCounterClockwise = TRUE;
Desc.FillMode = D3D11_FILL_SOLID;

////// 변경 Rasterizer - Non culling
D3D11_RASTERIZER_DESC Desc = {};

Desc.CullMode = D3D11_CULL_NONE;
Desc.FrontCounterClockwise = FALSE;
Desc.FillMode = D3D11_FILL_SOLID;

 

- Depth

: 기존의 패스(Geometry Pass)에서는 깊이 버퍼에 기록된 것보다 뒤에 존재하는 픽셀은 가린다는 의미로 LESS_EQUAL을 사용했지만, Lighting 패스에서는 전체 화면(FullRect)에 대해 조명 값을 누적할 때,

 

- 이미 기존 패스로 Depth만큼 채워진 픽셀에 대해서만 조명을 적용

- 실제로 깊이 비교(EQUAL) 대신 그냥 모든 스크린 픽셀에 대해서만 조명 계산을 하는게 단순

- 정밀도 차이로 픽셀이 빠져나가는 현상을 방지

 

를 위해서 깊이 테스트를 우회(ALWAYS)하고자 AlwaysDepth로 설정해줍니다. 이러면 조명 대상 컬링은 스텐실이나 마스킹으로 처리하게 됩니다.

 

- Blend : 빛 기여를 정확히 덧셈하여 누적

: 기존의 패스는 투명도 기반 혼합 방식(Src = SRC_ALPHA, Dest = INV_SRC_ALPHA)을 썼지만, Lighting 패스는 덧셈 방식(Additive)으로 값을 누적해야 합니다. Diffuse, Specular, Ambient는 서로 그냥 더해야 최종 밝기가 나오는데, 여기서 만약 기존의 블렌딩 방식을 써버리면 투명도 기반으로만 섞이기 때문에 조명에 관계없는 알파값이 섞여버려 오작동이 발생하거나 부자연스러운 결과가 발생할 수 있습니다. 따라서 Src와 Dest를 ONE, Op를 ADD로 설정하여 빛의 세기만 더하도록 변경해줍니다.

 

- Rasterizer : 전체 화면/라이트 볼륨 모든 면에 대해 조명을 적용

: Light 패스는 FullRect로 화면을 덮어서 활용하는데, 라이트 매스를 활용할 수도 있긴 합니다. 이럴 경우에는 만약 후면 컬링이 켜져 있으면 뷰 방향에 따라서 일부 폴리곤(뒷면)이 잘려나가 제대로 조명이 적용되지 안흔ㄴ 영역이 발생할 수 있습니다. 따라서 아무 면도 걸러내지 않도록 Cull 모드를 NONE으로 설정해줍니다. 사실 FullRect로 화면 전체를 덮어버린다면 그냥 원래 것을 사용해도 무방하긴 합니다.

 

[4], [5] LightMergeRenderTarget->

: 1부터 3번까지 진행하면서 Diffuse, Specular, Ambient 라이트 버퍼를 생성했기 때문에, 이제 이 결과들을 하나로 합쳐줘야 합니다. 이를 위해서 하나로 합쳐줄 렌더 타겟으로 바인딩해줍니다. 여기서는 빛을 합치는 것 외에 다양한 처리를 진행할 수 있습니다(나중에 쉐도우도 활용할 예정)

 

[6] Rendering

: [3]에서 실시한 Rendering 과정과 다른게 딱 하나 입니다. 여기서는 Texture의 Image 값들에 대해서 합치기 때문에, Blend가 다시 원래의 AlphaBlend(BaseBlend)로 해주면 됩니다.

 

1번부터 6번까지 진행했으면, Mesh, Light 값이 저장되어 있을 것입니다. 이걸 하나의 렌더 타겟에 다시 Merge 해줍니다. 뭔가 Merge라는 함수를 만들어서 특별하게 처리하는것 같지만, 실상은 위의 LightMergeRenderTarget에서 Merge 처리했던 방식과 크게 다른게 없습니다. 그냥 다른 렌더 타겟의 Texture2D Image 값을 가져와서 현재의 RenderTarget에 Draw 하는 것입니다. 순서대로 Draw 콜 해주면, 최종 결과물이 나옵니다.

 

[포워드 렌더링]

 

포워드 렌더링은 전통적인 렌더링 방식으로, 라이트 처리를 오브젝트마다 실시하는 방식입니다.

10개의 오브젝트와 10개의 라이트가 있다고 가정해보겠습니다. 이러면 포워드 렌더링의 경우, 각 오브젝트마다 라이팅 연산을 10번씩 실시해주면 됩니다(10*10 = 100). 비교적 단순하게 구현할 수 있는 렌더링 방식이지만, 치명적인 문제가 바로 단순함에 있습니다.

 

방금 말씀드린것 처럼 10개의 오브젝트와 10개의 빛이 있을 때 100번의 연산을 실시한다고 했는데, 만약 100개씩, 1000개씩 있으면 어떻게 될까요? 1000 * 1000번의 연산을 실시하게 될 것입니다. 또한, 물체에 가려져 화면에 보이지 않는 오브젝트에 대해서도 컬링을 하지 않고 빛 연산을 실시하는 경우가 있기도 합니다.

 

단점만 말씀드려서 이걸 왜쓰지라는 생각이 드실 수도 있으실테지만, 포워드 렌더링도 장점이 있습니다.

 

1. 메모리 대역폭

: 이후 말씀드릴 디퍼드 렌더링의 경우에는 G-Buffer라는 것을 사용하기 때문에 다수의 렌더 타겟을 필요로 합니다. 렌더 타겟은 모두 텍스쳐라고 생각하시면 편한데, 렌더 타겟의 크기는 픽셀을 표현할 크기와 픽셀 갯수로 정해집니다. 예를들어 R32G32B32A32로 픽셀을 표현하고 해상도의 크기가 1920 * 1080라고 가정해봅시다. 단순하게 계산해도 하나의 렌더 타겟에 약 2억6천만bit(약 33MB)가 됩니다. 

 

하지만 이게 하나의 렌더 타겟만 있는게 아니라 다수의 렌더 타겟(MRT)을 사용하는 방식이기 때문에 실제로는 33MB보다 훨씬 큽니다. 이걸 프레임별로 계속해서 처리해야하는데, 메모리 대역폭이 제한적인 모바일 환경에서는 감당하기 힘든 수준이 됩니다. 때문에 비교적 효율적이고 전통적인 포워드 렌더링을 활용합니다(물론 그냥 포워드 렌더링이 아니라 포워드+ 렌더링을 사용할 것입니다). 

 

2. 하드웨어 제한

: 메모리 대역폭의 제한 때문도 있지만, 하드웨어 자체의 한계로 인해 포워드 렌더링을 사용하는 것이 좋은 경우도 있습니다. 모바일 환경과 같은 경우 GPU가 MRT를 4개 이상 지원하지 않거나, 지원하더라도 성능 저하가 발생할 우려가 있어 G-Buffer의 구축이 어렵기 때문에 포워드 렌더링을 활용하는 경우가 있습니다.

 

3. 라이트 수와 복잡도

: Scene에 라이트가 디렉셔널, 포인트 라이트 두 개만 있다고 가정해봅시다. 이럴 경우, 굳이 G-Buffer를 쓰면서까지 라이팅 처리를 해야할까요? 라이트가 제한된 환경에서는 포워드 렌더링이 구현 복잡도도 낮고, 메모리 낭비를 하지 않을 수 있기 때문에 디퍼드 렌더링보다 더 효율적 일 수 있습니다.

 

 

[포워드+ 렌더링]

 

그렇다면 포워드 렌더링 방식에서 빛 연산 횟수를 줄이면 좋을 것입니다. 이를 위해 포워드+ 렌더링이 생겨났습니다. 해당 렌더링 방식은 기존과 같이 연산을 실시하는 과정 앞에 조명 컬링을 위한 렌더링 패스를 한 번 진행하게 됩니다.

 

이 과정에서 화면에 그려지는 공간을 균등한 타일 그리드로 나누고, 정보(Light Heatmap)를 저장해둡니다.

https://www.3dgep.com/forward-plus/

 

다음 패스에서 일반적인 포워드 렌더링 패스를 진행하는데, 앞에서 저장한 정보를 활용합니다. 이 정보를 활용하여 조명 처리를 진행할 오브젝트에 대해서만 조명 처리를 실시하게 됩니다. 

 

물론 그리드를 어떻게 나누는지에 따라(많이 나눌 것이냐, 적게 나눌 것이냐) Light Heatmap의 정보 처리가 Light 처리보다 커질 수도 있고(배보다 배꼽이 커지는 경우), 불필요한 연산을 제대로 컬링하지 못하는 수도 있습니다.

 

 

[디퍼드 렌더링]

 

기존의 포워드 렌더링이 오브젝트 하나에 대해 빛 연산을 실시하고 드로우콜을 호출하여 오브젝트를 화면에 바로바로 그리는 방식이었다면, 디퍼드 렌더링은 말 그대로 렌더링을 지연시키는 것입니다. 가장 큰 이유는 바로 빛 연산을 한번에 처리해서 적용시키기 위함입니다.

 

디퍼드 렌더링은 여러 개의 렌더 타겟(멀티 렌더 타겟;MRT)을 버퍼(G-Buffer)로 활용하면서 서로 간에 블렌딩을 통해 하나의 렌더 타겟을 만들어 냅니다.

디퍼드 렌더링의 빛 연산 과정은 다음과 같습니다.

 

1. 각 오브젝트들의 Position, Normal 정보를 담은 Buffer를 만듦

2. Position, Normal Buffer를 통해 Diffuse, Specular, Ambient 등 라이팅 연산 실시 및 Buffer를 만듦

3. 라이트 버퍼와 메시가 렌더링된 렌더 타겟 간에 블렌딩 과정을 통해 최종 렌더링 결과물 출력 및 백 버퍼에 전달

 

위 방식대로 진행하면, 화면에 몇 개의 오브젝트가 존재하던지 간에 라이트의 갯수와 화면의 픽셀 갯수 만큼만 연산을 실시하게 됩니다. 덕분에 오브젝트와 광원 수가 늘어나더라도, 포워드 렌더링과 같이 기하급수적으로 라이팅 처리 비용이 늘어나지는 않습니다.

 

그대로 장점만 있는 것은 아닙니다. 앞서 포워드 렌더링의 장점이 곧 디퍼드 렌더링의 단점이라고 볼 수 있겠습니다.

 

1. G-Buffer를 위한 메모리 대역폭 확보 필요

: MRT를 활용하기 때문에 이들을 저장하고 활용하기 위한 공간 확보는 필수입니다. 이 때문에 기본적으로 디퍼드 렌더링을 위해서는 메모리 대역폭의 확보가 필요합니다.

 

2. MRT 지원 하드웨어 필요

: GPU가 MRT를 지원하지 않거나, 지원하더라고 수의 제한이 있거나, 사용 시에 성능이 현저히 떨어질 수 있습니다. 디퍼드 렌더링을 사용하더라도 하드웨어의 성능이 받쳐줘야합니다.

 

3. 라이트 수에 대한 딜레마

: 포워드 렌더링은 메모리를 덜 쓰고 연산을 많이 하는 방식이었다면, 디퍼드 렌더링은 메모리를 많이 써서 연산을 덜 하는 방식을 취합니다. 하지만 배보다 배꼽이 커지는 경우가 있을 수 있는데, Scene에 라이트가 단 하나만 존재한다면 어떻게 될까요? 이럴 때는 그냥 라이트 하나에 대해서만 라이팅 연산을 진행하고, 구현이 쉬운 포워드 렌더링을 그대로 쓰는 것이 좋을 것입니다. 하지만 굳이 디퍼드 렌더링을 활용하게 되면, 필수적으로 G-Buffer를 만들 것이고, 이를 활용해야하기 때문에 최적화를 위해 만들어진 디퍼드 렌더링 방식이 오히려 독이 될 수 있습니다.

'DirectX11 > 그래픽스' 카테고리의 다른 글

[Graphics] Rendering Pipeline  (0) 2025.06.18
[Graphics] 드로우콜(Draw Call)  (0) 2025.06.11
[Graphics] Normal Mapping  (0) 2025.06.07
[Graphics] Normal  (0) 2025.06.06
[Graphics] Lighting  (2) 2025.06.05

[목차]

 

- 노말 매핑을 한 이유

- 셰이더 변경

- 결과

 

 

[노말 매핑을 한 이유]

 

빛도 만들었겠다, 빛을 가장 다이나믹하게 활용할 수 있는 텍스쳐 기법 중 노말 매핑을 진행해보고자 마음먹었습니다. 이를 위해서 Fab에서 텍스쳐 샘플을 찾아봤는데 적당한게 있어서 가져왔습니다.

 

이름이 Stone_Wall_xxx 이런 식이였는데 그냥 제가 보기 편한대로 이름을 변경했습니다. 여기서 활용할 것은 StoneWall_BaseColor와 StoneWall_Normal입니다.

 

 

[셰이더 변경]

 

먼저 Normal Texture를 사용해야하니까, Texture 슬롯을 하나 선언해야합니다. BaseColorTex에서 t0 슬롯을 이미 사용중이어서, 그냥 적당하게 바로 다음인 t1에 NormalTex라는 이름으로 추가해줬습니다.

Texture2D BaseColorTex : register(t0);
Texture2D NormalTex : register(t1);

 

텍스쳐들에 Roughness, Specular(Metalic), AmbientOcclusion, Emissive 등이 있는데 나중에 시간이 나면 천천히 해볼까 합니다.

 

텍스쳐 슬롯은 등록했으니, 텍스쳐를 로드하고 세팅해주면 됩니다. 그냥 기존에 만들어뒀던 기능을 바로 활용했습니다.

void StoneWallActor::Start()
{
	WallBody = CreateComponent<Ext_CollisionComponent>("WallBody", static_cast<int>(CollisionGroup::Wall));
	WallBody->SetCollsionType(CollsionType::OBB3D);

	WallBodyMesh = CreateComponent<Ext_MeshComponent>("WallBodyMesh");
	WallBodyMesh->CreateMeshComponentUnit("Rect", MaterialType::PBR);
	WallBodyMesh->SetTexture("StoneWall_BaseColor.jpg", TextureType::BaseColor);
	WallBodyMesh->SetTexture("StoneWall_Normal.jpg", TextureType::Normal);

	GetTransform()->SetLocalScale({ 300.f, 300.f, 1.f });
}

 

이렇게 해주면 BaseColorTex 슬롯에는 [StoneWall_BaseColor.jpg]이름의 2DTexture가, NormalTex 슬롯에는 [StoneWall_Normal.jpg]이름의 2DTexture가 저장될 것입니다. Material은 PBR이라고 해서 기존의 Graphics_VS, PS를 그대로 복사한 것을 새로 하나 만들어줬습니다. 기존껀 두고 이걸 수정하려고 합니다.

 

이제 불러온 값을 활용해야합니다. 해당 텍스쳐는 "탄젠트 스페이스 노말맵"입니다. 처음에 노말 매핑에 대해 공부할 때는, 뭐가 월드고 뭐가 탄젠트고 헷갈렸는데, 그림 하나를 보니 바로 구분이 가능해졌습니다.

 

그리고 근래에는 거의 무조건 스태틱이든 다이나믹이든 탄젠트 스페이스 노말맵을 사용한다고 합니다. 하드웨어 성능이 좋아져서 픽셀 셰이더에서 TBN 행렬을 적용하는 과정이 큰 무리가 없나봅니다.

 

이제 정점 셰이더를 수정해줍니다. Input값이 기존과 다르게 변경되었는데, 바로 float4 Tangent : TANGENT, float4 Binormal : BINORMAL가 추가됐다는 점입니다.

cbuffer TransformData : register(b0)
{
    float4 LocalPosition;
    float4 LocalRotation;
    float4 LocalScale;
    float4 LocalQuaternion;
    float4x4 LocalMatrix;

    float4 WorldPosition;
    float4 WorldRotation;
    float4 WorldQuaternion;
    float4 WorldScale;
    float4x4 WorldMatrix;

    float4x4 ViewMatrix;
    float4x4 ProjectionMatrix;
    float4x4 WorldViewMatrix;
    float4x4 WorldViewProjectionMatrix;
}

struct VSInput
{
    float4 Position : POSITION;
    float4 TexCoord : TEXCOORD;
    float4 Normal : NORMAL;
    float4 Tangent : TANGENT; // 로컬 접선
    float4 Binormal : BINORMAL; // 로컬 이접선
};

struct VSOutput
{
    float4 Position : SV_POSITION;
    float4 TexCoord : TEXCOORD;
    float3 WorldPosition : POSITION;
    float3 WorldNormal : NORMAL;
    float3 WorldTangent : TANGENT;
    float3 WorldBinormal : BINORMAL;
};

VSOutput PBR_VS(VSInput _Input)
{
    VSOutput Output;
    
    // Position 설정
    float4 WorldPos = mul(_Input.Position, WorldMatrix);
    float4 ViewPos = mul(WorldPos, ViewMatrix);
    Output.Position = mul(ViewPos, ProjectionMatrix);
    
    // 월드 공간 기준 노말, 접선, 이접선 구하기
    Output.WorldNormal = mul(float4(_Input.Normal.xyz, 0.0f), WorldMatrix).rgb;
    Output.WorldTangent = mul(float4(_Input.Tangent.xyz, 0.0f), WorldMatrix).rgb;
    Output.WorldBinormal = mul(float4(_Input.Binormal.xyz, 0.0f), WorldMatrix).rgb;
    
    // UV 좌표 설정
    Output.TexCoord = _Input.TexCoord;
    
    // 월드 공간 기준 Position 구하기
    Output.WorldPosition = mul(float4(_Input.Position.xyz, 1.0f), WorldMatrix).rgb;
   
    return Output;
}

 

이 두 값은 기존에 만들어뒀던 Ext_VertexData와 Rect, Cube, Sphere, Cylinder Mesh들에 없었기 떄문에, 추가해주고 만들어줬습니다.

 

노말과 탄젠트, 바이노말에 월드 행렬을 곱해서 월드 공간 기준으로 변경해준 뒤 픽셀 셰이더로 넘겨줍니다.

struct LightData
{
    float4 LightColor; // RGB(색), w(강도)
    float4 LightWorldPosition; // 라이트 월드 공간 위치
    float4 LightForwardVector; // 월드 입사 벡터
    float4 CameraWorldPosition; // 시점 월드 공간 위치

    float NearDistance;
    float FarDistance;
    float AttenuationValue;
    int LightType;
    bool bIsLightSet;
};

cbuffer LightDatas : register(b1)
{
    int LightCount;
    LightData Lights[64];
};

Texture2D BaseColorTex : register(t0);
Texture2D NormalTex : register(t1);
SamplerState Sampler : register(s0); // 샘플러

struct PSInput
{
    float4 Position : SV_POSITION;
    float4 TexCoord : TEXCOORD;
    float3 WorldPosition : POSITION;
    float3 WorldNormal : NORMAL;
    float3 WorldTangent : TANGENT;
    float3 WorldBinormal : BINORMAL;
};

// 각 벡터에 normalize를 해주는 이유는, 명시적으로 normalize된 벡터를 넣어줬다 하더라도 
// 임의의 값이 어떻게 들어올지 모르기 때문에 그냥 해주는것(안정성을 위한 처리라고 보면 됨)
float4 PBR_PS(PSInput _Input) : SV_TARGET
{
    // 텍스처 색상
    float4 Albedo = BaseColorTex.Sample(Sampler, _Input.TexCoord.xy);
    
    // 월드공간 기준 픽셀(표면) 위치와 법선 단위 벡터
    float3 PixelPosition = _Input.WorldPosition;
    
    // 누적 조명 컬러 (RGB)
    float3 AccumLightColor = float3(0, 0, 0);
    
    // 노말 맵 샘플링 (Tangent-space 노말 → [-1, 1] 범위)
    float3 SampledNormalTS = NormalTex.Sample(Sampler, _Input.TexCoord.xy).xyz * 2.0f - 1.0f;
    SampledNormalTS = normalize(SampledNormalTS);
    
    // TBN 매트릭스 구성 (열(column) 단위로 저장)
    float3x3 TBNMatrix;
    TBNMatrix[0] = normalize(_Input.WorldTangent); // T
    TBNMatrix[1] = normalize(_Input.WorldBinormal); // B
    TBNMatrix[2] = normalize(_Input.WorldNormal); // N
    
    float3 MappedWorldNormal = normalize(mul(SampledNormalTS, TBNMatrix));
    
    // 빛 구하는 과정
    // 기존에 PixelNormal로 적어둔 곳을 모두 MappedWorldNormal로 바꿔주면 됨
    // ...
}

 

여기서 중요한 것은 노말 텍스쳐 샘플링과 TBN 행렬로 매핑된 노말값을 구하는 과정입니다. 먼저 샘플링 과정에서 [*2.0f - 1.0f]를 해주는데, 이건 그냥 노말 텍스쳐의 리맵핑 공식이라서 그대로 쓰면 됩니다.

 

텍스쳐에 저장된 값이 UV 기준 얼마나 기줄어져있는지면, 정점 셰이더에서 전달해준 값은 해당 월드에 대해 UV값이 어느 방향을 향하는지를 알려주는 값입니다.

 

둘을 mul하고 정규화해주면 우리가 원하는 노말이 나옵니다. 자세한 설명은 아래 그래픽스 포스팅을 확인해보시면 됩니다.

https://umtimos.tistory.com/202

 

[Graphics] Normal Mapping

[눈의 착각] 물체에 굴곡이 있다면 빛의 입사에 대한 반사각도가 달라지게 됩니다. 이렇게 반사된 빛이 얼마만큼 눈에 들어오는지에 따라 음영이 인식되면서 굴곡을 인식하게 됩니다. 하지만 사

umtimos.tistory.com

 

 

[결과]

 

빛 처리를 했을 때 처럼, 굉장히 확연한 차이가 나는 것을 확인할 수 있었습니다. 먼저 적용 전입니다.

 

이제 적용 결과를 확인해보겠습니다.

 

[눈의 착각]

 

물체에 굴곡이 있다면 빛의 입사에 대한 반사각도가 달라지게 됩니다. 이렇게 반사된 빛이 얼마만큼 눈에 들어오는지에 따라 음영이 인식되면서 굴곡을 인식하게 됩니다. 하지만 사람의 눈은 생각보다 정교하게 물체를 인식하지 않기 때문에 단순한 눈속임으로도 충분히 굴곡이 있음을 인식키실 수도 있습니다.

격자 착시

 

눈의 착각을 활용하는 방법 중 가장 대표적인 것은 착시 현상입니다. 노말 매핑은 이런 단순한 속임수를 활용하는 방법이라고 볼 수 있겠습니다.

 

 

[노말 매핑]

 

특정 메시에 굴곡이 있을 때, 굴곡을 모두 폴리곤으로 표현한다면 메시의 폴리곤 수가 그만큼 늘어나게 될 것이고, 이걸 계산하거나 저장하기 위해 많은 컴퓨터 자원을 필요로 할 것입니다. 하지만 굴곡 표현을 텍스쳐 하나로 대체하는 방법이 있습니다.

 

위 그림을 확인해보시면 텍스쳐 한 장이 있고 없고의 차이가 확연하게 느껴지실 겁니다. 이때 사용하는 텍스쳐를 노말맵이라고 하고, 이 텍스쳐를 활용해서 굴곡을 표현하는 기법을 노말 매핑이라고 합니다. 노말 매핑을 활용하면 위에서 말한 것과 같이 메시의 폴리곤 수를 압도적으로 줄일 수 있습니다.

 

초기의 매핑은 높이 정보를 갖는 흑백 텍스쳐를 활용했습니다. 하지만 이러한 텍스쳐는 높은 해상도를 요구하며, 저해상도로 떨어질 경우 표현 가능한 세부 사항이 제한되는 등 문제가 있어 높이가 아닌 표면의 법선 벡터 정보를 활용하는 방식으로 변경되었습니다.

 

 

[노말 매핑을 위한 스페이스]

 

노말 맵은 월드 스페이스 노말맵(World Space Normal Map)과 탄젠트 스페이스 노말맵(Tangent-Space Normal Map)이 있습니다. 둘의 가장 큰 차이는 "어떤 좌표계 기준"으로 노말을 저장하고 불러오느냐에 있습니다.

 

 

1. 월드 스페이스 노말맵

 

노말맵 텍스쳐 안의 RGB값 자체가 월드 좌표계에서의 노말 방향(x, y, z)를 저장하고 있습니다. 그래서 셰이더에서 단순히 이 값을 활용할 경우에는 다음과 같이 사용하면 됩니다.

float3 Sampled = NormalTex.Sample(BaseSampler, uv).xyz; // ∈ [0,1]
float3 WorldSpaceNormal = normalize(Sampled * 2.0f - 1.0f); // ∈ [−1,1]

 

+) 텍스쳐에서 얻어온 Sampled 값(x, y, z)는 모두 0.0에서 1.0사이로 정규화된 값입니다. 하지만 실제 노말 벡터는 -1.0에서 +1.0 사이의 범위에서 기울어짐이 표현되어야 합니다. 따라서 리맵핑을 실시합니다. 이 리맵핑에 필요한 공식이 [Sampled * 2.f - 1.f]라는것 정도만 알고계시면 될 것 같습니다.

 

월드 스페이스 노말 맵은 정적인 오브젝트에 주로 활용됩니다. 이유는 월드 스페이스 노말맵이 단순히 특정 형태로 고정된 메시에 대해, 그 상태에서의 법선 벡터들을 저장한 값이기 때문입니다. 그림으로 예시를 들자면 다음과 같습니다.

그림과 같은 형태의 구체 메시가 있다고 가정해봅시다. 여기서 각 선은 월드 스페이스 노말맵에 저장된 법선 벡터입니다. 여기서 만약 구체가 회전하면 어떻게 될까요?

예상은 그림과 같이 법선 벡터도 이동된 뒤 정상 적용되는 것이지만, 실제로는 다릅니다.

월드 스페이스 노말맵은 특정 형태에 대해 저장된 뒤, 고정된 법선 벡터값을 갖기 때문에 단순히 회전만 할 경우에는 의도치않은 렌더링 결과가 발생하게 됩니다. 물론 회전을 따로 처리하거나 하는 방식으로 노말값을 재정렬할 순 있지만, 그냥 정적인 오브젝트에만 사용하는 것이 좋습니다.

 

2. 탄젠트 스페이스 노말맵

 

탄젠트 스페이스 노말맵에 저장된 값 (nx, ny, nz)들은 텍스쳐 좌표(UV) 기준으로 얼마나 기울어져있는가를 저장한 정보들입니다. 예를 들어 평평한 면을 위에서 내려다본다고 했을 때, U, V가 다음과 같다고 가정해보겠습니다.

 

- 텍스쳐의 가로축(U)이 월드 상에서 오른쪽 방향(→)을 가리킨다.

- 텍스쳐의 세로축(V)이 월드 상에서 위쪽 방향(↑)을 가리킨다.

 

이 조건에서 노말맵 텍스쳐에 저장된 (nx, ny, nz)는 텍스쳐 좌표(UV) 공간에서 볼 때, 각 값이 의미하는 바는 다음과 같습니다.

 

- nx : 표면이 얼마나 U축으로 기울어져 있는가

- ny : 표면이 얼마나 V축으로 기울어져 있는가

- nz : 원래 법선 방향이 Z축으로 얼마나 세워져 있는가

 

이제 이 값을 월드 좌표계로 옮겨줘야합니다. 이때 사용하는 행령이 TBN 행렬입니다. 각 정점(or 픽셀)마다 세 가지 축을 가져옵니다.

 

- Tangent : 이 지점의 텍스쳐 U축이 월드에서는 어느 방향인지

- Binormal : 이 지점의 텍스쳐 V축이 월드에서는 어느 방향인지

- Normal  : 이 지점의 텍스쳐 평면에서의 법선은 월드에서는 어느 방향인지

 

이 세 벡터를 각각 월드 공간으로 변환한 뒤, 노말 텍스쳐에서 읽어들인 값과 mul하면, 텍스쳐 공간 기준 기울기가 월드 공간 기준 기울기로 변환됩니다. 이렇게 매 순간 변환되기 때문에 움직이는 물체에서도 탄젠트 스페이스 노말을 활용하면 노말 매핑을 문제없이 활용할 수 있습니다.

 

실제로 근래의 메시들은 다이나믹 메시가 아니더라도 탄젠트, 바이노말 값을 가지고 있고 노말 텍스쳐도 기본적으로는 탄젠트 스페이스 노말 텍스쳐입니다.

'DirectX11 > 그래픽스' 카테고리의 다른 글

[Graphics] Rendering Pipeline  (0) 2025.06.18
[Graphics] 드로우콜(Draw Call)  (0) 2025.06.11
[Graphics] 포워드 렌더링, 디퍼드 렌더링  (0) 2025.06.11
[Graphics] Normal  (0) 2025.06.06
[Graphics] Lighting  (2) 2025.06.05

[벡터의 외적과 법선 벡터]

 

3D 환경은 Z축이 추가되기 때문에 물체의 앞면과 뒷면이 생깁니다. 하지만 이 앞면, 뒷면을 컴퓨터는 모르기 때문에 사용자가 정의해줘야합니다. 그래서 앞서 렌더링 파이프라인을 학습하는 과정에서 이에 관련된 세팅 두 가지를 진행했습니다.

 

- IndexBuffer에서 정점을 그리는 순서 정의해주기

- Rasterizer에서 CCW 설정 해주기

 

이 설정을 통해 컴퓨터는 법선 벡트를 구할 수 있고, 이 법선 벡터가 어느 방향을 향하고 있는지를 확인하여 어디를 앞면, 뒷면으로 해야할 지 결정할 수 있습니다.

 

법선 벡터는 특정 표면이나 곡선에 대해 수직인 방향을 나타내는 벡터입니다. 이 벡터는 벡터 두개를 활용하여 구할 수 있습니다. 아래와 같이 정점 4개를 순서대로 그려야한다고 가정해보겠습니다.

이렇게 순서를 정해주면 벡터는 1->2, 2->3, 3->4, 4->1인 4 종류가 생성될 수 있습니다. 여기서 1->2, 2->3 벡터를 따로 분리해보겠습니다.

 

이제 이 두 벡터를 외적하면, 두 벡터에 대해 수직인 벡터가 생성됩니다. 이 수직인 벡터가 바로 법선 벡터입니다. 하지만 순서에 상관없이 외적을 진행해서는 안되고, 외적 계산 순서를 정해줘야 법선 벡터의 방향을 결정할 수 있습니다.

 

 

[노말이란]

 

법선 벡터를 구하면 이제 물체의 앞뒷면을 구분할 수 있게 됩니다. 하지만 이 법선 벡터를 그대로 쓰는 것은 아닙니다. 먼저 A(1, 2, 3), B(4, 5, 6) 두 벡터로 이것 저것 확인해보겠습니다.

 

1. 크기

 

2. 내적

 

3. θ

 

4. 외적

 

여기서 외적된 벡터의 크기는 ||A||||B||sinθ인데, 이 값은 [3.7417×8.7750×0.2238=7.3485]가 되어 크기가 1이 아님이 확인됩니다. 이 값을 그대로 쓰면 이런 저런 문제가 있습니다.

 

1. Diffuse Light 계산에서 내적 시 둘 다 단위 벡터여야 cosθ가 나옴, 둘 다 1이 아니면 ||n||cosθ가 나오기 때문에 왜곡된 효과가 발생

2. 반사벡터 구하는 과정도 마찬가지

3. 정점 보간 시 픽셀마다 정규화하지 않으면 영역별로 노말 크기가 달라져 셰이딩 처리에 오류 발생

 

벡터의 크기가 1이어야지 "방향"만을 이용할 수 있기 때문에 단위 벡터로 변경해줘야 합니다. 그래서 보통 처리 시 normalize를 진행해주는데, 이렇게 정규화된 법선 벡터를 노말(Normal)이라고 합니다.

'DirectX11 > 그래픽스' 카테고리의 다른 글

[Graphics] Rendering Pipeline  (0) 2025.06.18
[Graphics] 드로우콜(Draw Call)  (0) 2025.06.11
[Graphics] 포워드 렌더링, 디퍼드 렌더링  (0) 2025.06.11
[Graphics] Normal Mapping  (0) 2025.06.07
[Graphics] Lighting  (2) 2025.06.05

[목차]

 

- 라이트 추가

- 세팅

- 업데이트

- 결과

 

 

[라이트 추가]

 

라이트는 그래픽스에서 가장 기본적이지만 중요하고, 그래픽스를 배울 때 처음으로 접하는 부분이 아닐까 생각됩니다. 실제로 구현해보기 전 개념을 먼저 익혔고, 프레임워크에 추가했습니다.

 

https://umtimos.tistory.com/199

 

[Graphics] Lighting

[Lighting의 필요성] 그래픽스에서 라이팅(Lighting)은 화면의 분위기, 깊이감, 사실감을 결정짓는 중요한 요소입니다. 빛 처리가 없으면 아무리 3D 물체라고 해도 평면처럼 보이게 됩니다. 위의 예시

umtimos.tistory.com

 

 

[세팅]

 

본격적으로 픽셀 셰이더를 활용해야하는 단계입니다. 픽셀 셰이더를 위한 상수버퍼와 Light라는 개념을 정의하기 위한 Ext_Light 클래스를 추가했습니다.

////////////////////////////// Ext_Actor.h
#pragma once
#include "Ext_Actor.h"

enum class LightType
{
	Directional,
	Point,
	Spot,
	Unknown
};

struct LightData
{
	float4 LightColor = { 1.0f, 1.0f, 1.0f, 0.25f };// RGB(색), w(강도)
	float4 LightWorldPosition; // 라이트 월드 공간 위치
	float4 LightForwardVector; // 월드 입사 벡터
	float4 CameraWorldPosition; // 시점 월드 공간 위치

	float NearDistance = 1.0f;
	float FarDistance = 100.0f;
	float AttenuationValue = 1.0f; // 거리감쇠값에 사용
	int LightType = 0;
    bool bIsLightSet = false;
};

constexpr unsigned int MAX_LIGHTS = 64;
struct LightDatas
{
	int LightCount = 0;
	LightData Lights[MAX_LIGHTS];
};

class Ext_Light : public Ext_Actor
{
public:
	// constrcuter destructer
	Ext_Light();
	~Ext_Light() {};

	// delete Function
	Ext_Light(const Ext_Light& _Other) = delete;
	Ext_Light(Ext_Light&& _Other) noexcept = delete;
	Ext_Light& operator=(const Ext_Light& _Other) = delete;
	Ext_Light& operator=(Ext_Light&& _Other) noexcept = delete;

	void LightUpdate(std::shared_ptr<class Ext_Camera> _Camera, float _DeltaTime);

	void SetLightType(LightType _Type) { LTData->LightType = static_cast<int>(_Type); };
	void SetLightColor(float4 _Color) { LTData->LightColor = _Color; };
	void SetAttenuationValue(float _Value) { LTData->AttenuationValue = _Value; };
	void SetLightRange(float _Range)
	{
		LTData->NearDistance = 1.0f;
		LTData->FarDistance = _Range;
	}

	std::shared_ptr<LightData> GetLightData() { return LTData; }

protected:
	
private:
	std::shared_ptr<LightData> LTData = nullptr;
	LightType Type = LightType::Unknown;

};

////////////////////////////// Ext_Actor.cpp
#include "PrecompileHeader.h"
#include "Ext_Light.h"
#include "Ext_Camera.h"
#include "Ext_Transform.h"

Ext_Light::Ext_Light()
{
	LTData = std::make_shared<LightData>();
    LTData->bIsLightSet = true;
}

// 여기서의 Camera는 바라보는 시선, 위치를 의미함
void Ext_Light::LightUpdate(std::shared_ptr<Ext_Camera> _Camera, float _DeltaTime)
{
	LTData->LightWorldPosition = GetTransform()->GetWorldPosition();
	LTData->LightForwardVector = GetTransform()->GetLocalForwardVector();
	LTData->CameraWorldPosition = _Camera->GetTransform()->GetWorldPosition();
}

 

Light는 Actor를 상속받기 때문에 생성 시 Scene의 Actors 컨테이너에 저장됩니다. 하지만 Light들에 대해서 따로 순회를 돌며 Update를 진행해야 하는 부분이 있기 때문에, 이들에 대해서만 저장할 컨테이너를 하나 추가했습니다.

 

또한 MeshComponentUnit들에 대해서도 생성 시 Scene의 LightDatas 상수 버퍼를 세팅하도록 설정했습니다.

// 메시 컴포넌트 유닛 생성 시 호출, Mesh, Material, ConstantBuffer 세팅
void Ext_MeshComponentUnit::MeshComponentUnitInitialize(std::string_view _MeshName, MaterialType _SettingValue)
{
	std::string MaterialName = MaterialSettingToString(_SettingValue);

	Mesh = Ext_DirectXMesh::Find(_MeshName); // 메시 설정
	Material = Ext_DirectXMaterial::Find(MaterialName); // 머티리얼 설정

	if (nullptr == Mesh || nullptr == Material)
	{
		MsgAssert("존재하지 않는 메시나 머티리얼을 메시유닛에 넣을 수는 없습니다.")
	}

	InputLayout->CreateInputLayout(Mesh->GetVertexBuffer(), Material->GetVertexShader()); // InputLayout 설정

	// 상수버퍼 세팅
	// [1] 버텍스 셰이더 정보 가져오기
	const Ext_DirectXBufferSetter& VertexShaderBuffers = Material->GetVertexShader()->GetBufferSetter();
	BufferSetter.Copy(VertexShaderBuffers);

	// [2] 픽셀 셰이더 정보 가져오기
	const Ext_DirectXBufferSetter& PixelShaderBuffers = Material->GetPixelShader()->GetBufferSetter();
	BufferSetter.Copy(PixelShaderBuffers);

	// [3] 트랜스폼 상수버퍼 세팅하기
	const TransformData& TFData = *(OwnerMeshComponent.lock()->GetTransform()->GetTransformData().get());
	BufferSetter.SetConstantBufferLink("TransformData", TFData);

	// [4] 빛 상수버퍼 세팅하기(스태틱, 다이나믹은 빛 연산 실시를 위해 추가 세팅)
	if (_SettingValue == MaterialType::Static || _SettingValue == MaterialType::Dynamic)
	{
		const LightDatas& LTDatas = OwnerMeshComponent.lock()->GetOwnerScene().lock()->GetLightDataBuffer();
		BufferSetter.SetConstantBufferLink("LightDatas", LTDatas);
	}
    //...
}

 

 

[업데이트]

 

Redering 단계에서 카메라의 뷰, 프로젝션 행렬을 구한 뒤 바로 Light 들의 업데이트를 진행합니다.

// Camera들의 Rendering 호출
void Ext_Scene::Rendering(float _DeltaTime)
{
	// Rendering 업데이트
	for (auto& CamIter : Cameras)
	{
		std::shared_ptr<Ext_Camera> CurCamera = CamIter.second;

		CurCamera->CameraTransformUpdate(); // 카메라에 대한 뷰, 프로젝션, 뷰포트 행렬 최신화
		LightDataBuffer.LightCount = 0; // 라이트 업데이트 전, 상수버퍼 갯수 초기화(순회하면서 값 넣어줘야하기 때문)

		for (auto& LightIter : Lights) // 라이트들 돌면서 업데이트
		{
			std::shared_ptr<Ext_Light> CurLight = LightIter.second;
			CurLight->LightUpdate(CurCamera, _DeltaTime);

			LightDataBuffer.Lights[LightDataBuffer.LightCount] = *CurLight->GetLightData().get(); // 상수버퍼에 값 넣어주기
			++LightDataBuffer.LightCount; // 다음 순회를 위해 1 올려주는 작업임
		}

		CurCamera->Rendering(_DeltaTime); // Camera의 MeshComponent들 Rendering
	}

	Ext_Imgui::Render(GetSharedFromThis<Ext_Scene>(), _DeltaTime);
}

 

다음으로는 Vertex Shader입니다.

 

cbuffer TransformData : register(b0)
{
    float4 LocalPosition;
    float4 LocalRotation;
    float4 LocalScale;
    float4 LocalQuaternion;
    float4x4 LocalMatrix;

    float4 WorldPosition;
    float4 WorldRotation;
    float4 WorldQuaternion;
    float4 WorldScale;
    float4x4 WorldMatrix;

    float4x4 ViewMatrix;
    float4x4 ProjectionMatrix;
    float4x4 WorldViewMatrix;
    float4x4 WorldViewProjectionMatrix;
}

struct VSInput
{
    float4 Position : POSITION;
    float4 TexCoord : TEXCOORD;
    float4 Normal : NORMAL;
};

struct VSOutput
{
    float4 Position : SV_POSITION;
    float4 TexCoord : TEXCOORD;
    float3 WorldPosition : POSITION;
    float3 WorldNormal : NORMAL;
};

VSOutput Grapics_VS(VSInput _Input)
{
    VSOutput Output;
    // _Input.Position.w = 1.0f;
    
    // Position 설정
    float4 WorldPos = mul(_Input.Position, WorldMatrix);
    float4 ViewPos = mul(WorldPos, ViewMatrix);
    Output.Position = mul(ViewPos, ProjectionMatrix);
    
    // UV 좌표 설정
    Output.TexCoord = _Input.TexCoord;
    
    // 월드 공간 기준으로 조명 계산을 진행하기 위해 WorldMatrix만 처리한 Position, Normal을 생성하여 Pixel Shader에 넘겨줌
    Output.WorldPosition = mul(float4(_Input.Position.xyz, 1.0f), WorldMatrix).rgb;
    Output.WorldNormal = mul(float4(_Input.Normal.xyz, 0.0f), WorldMatrix).rgb;
   
    return Output;
}

 

VSOutput 구조체에 WorldPosition과 WorldNormal을 추가했습니다. 픽셀 셰이더에서 빛 처리를 World Space 기준으로 하기 위해서입니다.

 

다음은 Pixel Shader입니다.

struct LightData
{
    float4 LightColor; // RGB(색), w(강도)
    float4 LightWorldPosition; // 라이트 월드 공간 위치
    float4 LightForwardVector; // 월드 입사 벡터
    float4 CameraWorldPosition; // 시점 월드 공간 위치

    float NearDistance;
    float FarDistance;
    float AttenuationValue;
    int LightType;
    bool bIsLightSet;
};

cbuffer LightDatas : register(b1)
{
    int LightCount;
    LightData Lights[64];
};

Texture2D BaseColorTex : register(t0); // 텍스처 자원
SamplerState Sampler : register(s0); // 샘플러

struct PSInput
{
    float4 Position : SV_POSITION;
    float4 TexCoord : TEXCOORD;
    float3 WorldPosition : POSITION;
    float3 WorldNormal : NORMAL;
};

// 각 벡터에 normalize를 해주는 이유는, 명시적으로 normalize된 벡터를 넣어줬다 하더라도 
// 임의의 값이 어떻게 들어올지 모르기 때문에 그냥 해주는것(안정성을 위한 처리라고 보면 됨)
float4 Grapics_PS(PSInput _Input) : SV_TARGET
{
    // 텍스처 색상
    float4 Albedo = BaseColorTex.Sample(Sampler, _Input.TexCoord);
    
    // 월드공간 기준 픽셀(표면) 위치와 법선 단위 벡터
    float3 PixelPosition = _Input.WorldPosition;
    float3 PixelNormal = normalize(_Input.WorldNormal);
    
    // 누적 조명 컬러 (RGB)
    float3 AccumLightColor = float3(0, 0, 0);
    
    for (int i = 0; i < LightCount; ++i)
    {
        LightData LTData = Lights[i];
        
        if (LTData.bIsLightSet == false)
        {
            continue;
        }
        
        // 공통분모
        float3 FinalLight = float3(0.0f, 0.0f, 0.0f);
        float3 LightDirection = normalize(-LTData.LightForwardVector.xyz); // 빛 계산에 사용하는 LightDirection은 "표면에서 봤을 때 빛이 표면으로 들어오는 방향"을 사용하므로 반대로 뒤집음
        
        float Shininess = 32.0f; // Shininess는 임의로 32 고정
        float AmbientIntensity = LTData.LightColor.w;
        
        // 월드공간 기준 시선 위치와 시선의 방향 단위 벡터
        float3 EyePosition = LTData.CameraWorldPosition.xyz;
        float3 EyeDirection = normalize(EyePosition - _Input.WorldPosition);
        
        if (LTData.LightType == 0) // Directional Light
        {
            // Diffuse Light 계산
            float DiffuseLight = saturate(dot(LightDirection, PixelNormal));
        
            // Specular Light 계산, Phong 모델을 사용하기 때문에 R = reflect(L, N)
            float3 ReflectionVector = normalize(2.0f * PixelNormal * dot(LightDirection, PixelNormal) - LightDirection); // 반사벡터
            float RDotV = max(0.0f, dot(ReflectionVector, EyeDirection));
            float3 SpecularLight = pow(RDotV, Shininess);
        
            // Ambient Light 계산, 강도는 LightColor의 w값 사용
            float3 AmbientLight = AmbientIntensity;
        
            AccumLightColor += LTData.LightColor.xyz * (DiffuseLight + SpecularLight + AmbientLight);
        }
        else if (LTData.LightType == 1) // 포인트 라이트
        {
            float3 LightPosition = LTData.LightWorldPosition.xyz;
        
            // 픽셀과 위치와 광원 위치 사이 거리 및 방향 구하기
            float3 PixelToLight = LightPosition - PixelPosition;
            float Distance = length(PixelToLight); // Distance로 Near, Far 컬링하면 재밌는거 볼 수 있음, 밑에 주석을 사용
            //if (Distance >= LTData.NearDistance && Distance <= LTData.FarDistance)
            
            // 픽셀에서 광원으로 향하는 단위 벡터
            float3 Vector = normalize(PixelToLight);
            
            // 감쇠(Attenuation) 공식 = 1/(c0 + c1·d + c2·d²)
            float C0 = 1.0f;
            float C1 = 0.0f;
            float C2 = LTData.AttenuationValue / (LTData.FarDistance * LTData.FarDistance); // 거리에 따른 제곱항 계수, 여기를 조정하면 감쇠가 쌔짐
            float Attenuation = 1.0f / (C0 + C1 * Distance + C2 * Distance * Distance);

            // Diffuse Light 계산
            float DiffuseLight = saturate(dot(PixelNormal, Vector));

            // Specular Light 계산, Phong 모델을 사용하기 때문에 R = reflect(L, N)
            float3 ReflectionVector = normalize(2.0f * PixelNormal * dot(Vector, PixelNormal) - Vector);
            float RDotV = max(0.0f, dot(ReflectionVector, EyeDirection));
            float3 SpecularLight = pow(RDotV, Shininess);

            // Ambient Light 계산, 강도는 LightColor의 w값 사용
            float3 AmbientLight = AmbientIntensity;

            // 누적
            AccumLightColor += LTData.LightColor.xyz * (DiffuseLight + SpecularLight + AmbientLight) * Attenuation;
        }
    }
    
    Albedo.rgb *= AccumLightColor;
    return Albedo;
}

 

 

Pixel Shader에서 실시하는 것은 다음과 같습니다.

 

1. 기존 색상 사용

: Texture가 있다면 그 값이 기본 Color

 

2. 월드 공간 위치, 월드 공간 노말 값 확인

: 노말은 normalize() 실시(혹시 몰라서 그냥 해줌)

 

3. LightData 반복문

: 갯수는 최대 64개로 해뒀기 때문에, 64번 순회를 돌 것입니다(실제로는 2개밖에 없지만). 

 

4. Directional Light 연산 실시

: Diffuse, Specular, Ambient 연산 실시

 

5. Color에 적용

: Diffuse, Specular, Ambient 세 개를 Light Color에 모두 더하고, 기본 Color와 곱해주면서 누적

 

+) 포인트 라이트는 감쇠를 적용해줍니다.

 

 

[결과]

 

디렉셔널 라이트는 기본으로 사용하고, 메인 카메라에 PointLight를 붙여서 Scene 내에 총 2개의 라이트를 사용해봤습니다. 포인트 라이트는 감쇠값을 크게 설정해서, 가까이 가야 잘보입니다.

 

[Lighting의 필요성]

 

그래픽스에서 라이팅(Lighting)은 화면의 분위기, 깊이감, 사실감을 결정짓는 중요한 요소입니다. 빛 처리가 없으면 아무리 3D 물체라고 해도 평면처럼 보이게 됩니다.

 

위의 예시처럼 라이팅 처리를 하지 않을 경우, 주변에 동일한 색상을 지닌 물체와의 경계가 모호해지고, 이 물체가 2D인지 3D인지 분간이 어려워집니다. 여기서 간단한 Lighting 처리를 하나 실시해보겠습니다.

 

보시는것과 같이 바로 경계선, 입체감이 생성되어 물체가 3D임이 제대로 인식됩니다.

 

 

[라이트 모델과 셰이딩 기법]

 

그래픽스에서는 라이트 모델과 셰이딩 기법을 활용하여 라이트를 표현합니다.

 

- 라이트 모델 : 표면이 빛을 어떻게 산란시키고, 반사시킬 지 결정하는 수학적or 물리적 표현식

- 셰이딩 기법 : 라이트 모델을 정점(픽셀)에 어떻게 적용시킬 것인지에 대한 방법들

 

 

[라이트 모델]

 

먼저 라이트 모델입니다. 대표적인 네 가지 방법을 알아보겠습니다.

 

1. Lambert Diffuse(램버트 난반사)

: 단순한 난반사(Diffuse) 모델입니다. 표면의 노말 벡터(법선 벡터)와 표면으로부터의 광원 방향에 대한 단위 벡터를 내적하여 구합니다. 이때 나오는 값은 cosθ로, -1부터 1까지의 값을 갖지만, -1부터 0까지의 값은 눈에 보이지 않는 각도의 값이기 때문에  무시해도 됩니다. 공식은 다음과 같습니다.

(Ld : 광원색/강도, kd : 표면 난반사 계수, N : 법선 벡터(노말 벡터), L : 광원 방향 벡터(입사 벡))

 

보시는 것과 같이 계산식이 매우 간단하여 연산이 가볍지만, 하이라이트(Specular)는 표현할 수 없습니다.

 

2. Phong Reflection(퐁 반사 모델)

: 램버트 난반사에 하이라이트(Specluar)를 추가한 모델입니다. 시선 방향 단위 벡터와 표면에서 나아가는 반사 단위 벡터를 내적하여 구합니다. 이때 나오는 값도 cosθ입니다. 이 값의 크기로 하이라이트 처리를 실시합니다. 공식은 다음과 같습니다.

(Ls : 광원색/강도, ks : 표면 난반사 계수, R :입사광에 대한 반사 벡터, V : 시선 방향 벡터, n : 광택지수)

 

이렇게 하이라이트값을 구한 뒤, 다음의 식을 통해 빛을 계산해주면 됩니다.

 

 

3. Blinn Phong

: 퐁 모델의 변형 버전 모델입니다. 반사 벡터 대신 하프 벡터(Harf-Vector)를 사용하여 하이라이트를 계산합니다. 하프 벡터는 다음의 공식으로 구합니다.

(L : 광원 방향 벡터, V : 시선 방향 벡터

 

하프 벡터를 구했다면, 다음의 공식을 활용하여 하이라이트를 구합니다.

(Ls : 광원색/강도, ks : 표면 난반사 계수, N : 법선 벡터(노말 벡터), H : 하프 벡터)

 

블린 퐁 모델의 장점은 반사 벡터를 계산하지 않아 연산량이 줄어든다는 점에 있습니다. 이 덕분에 GPU 셰이더 모델이서 널리 사용되었습니다.

 

4. Cook-Torrance

: 현대의 그래픽스에서, 물체 표면 렌더링은 물리 기반 렌더링(PBR)을 실시합니다. 여기서 라이트 계산에 대해 쿡-토렌스 모델을 표준으로 활용합니다. 이 방식을 활용하면 마이크로페이셜(Microfacetial) 이론을 적용해 표면의 미세한 입자 구조를 활용해여 빛이 어떻게 반사되는지를 계산할 수 있습니다. 공식은 다음과 같습니다.

 

- D : Distribution, 표면의 미세 입자 분포 함수

- F : Fresnel, 입사각에 따라 반사율이 달라지는 프리넬 효과

- G : Geometry/Shadowing-Masking, 미세면체(마이크로페이셜)가 서로 그림자를 만드는 효과

 

이외 여러 공식이 얽혀있기 때문에, 이런 방식을 사용하고 있다 정도로만 알고 넘어가시면 될 것 같습니다. 해당 모델을 활용하면 물리적으로 훨씬 정확한 반사/굴절을 구현할 수 있지만, 공식이 다른 모델에 비해 길고 복잡하기 때문에 당연히 계산 비용이 증가합니다(퐁/블린-퐁에 비해 상당히 높음).

 

5. 기타

: 이런것도 있다 정도만 알고 넘어가시면 될 것 같습니다.

 

- 오렌-나야르(Oren-Nayar) : 비광텍 매트재 위주의 난반사를 물리적으로 근사하는 방식, 표면의 거친 정도(Roughness)를 고려하기 때문에 램버트 난반사보다 더 현실적인 확산 반사 표현 가능하지만, 계산식이 복잡하기 때문에 블린-퐁처럼 쉽지 않음

ϕ : 표면의 거친 정도

 

- 미나르트(Minnaert) : 암흑 주변부 표현에 중점, 곡면이 뒤로 굽어질수록 밝기가 낮아지는 효과가 있어 비슷한 색상에서도 곡률 감쇠를 표현할 수 있지만, 실시간 렌더링에는 사용이 쉽지않음

kM : 미나르트 지수

 

- 아시크민-쉬를리(Ashikhmin-Shirley) : 비대칭(애니소트로피) 재질(금속 헤어, 브러쉬 처리된 금속면 등) 표현에 적합한 PBR 모델로, 쿡-토렌스보다 복잡하지만 비등방적 표면을 현실적으로 표현 가능

 

- 투칭 셰이딩(Toon/Cel Shading) : 비사실적 렌더링(NPR) 계열로, 윤곽선과 단계별 톤만 남겨 만화같은 애니메이션 느낌을 줄 수 있다. 수식은 퐁 또는 램버트 계산 결과에 threshold 처리 후 윤곽선에 별도로 스텐실/포스트 프로세싱 실시하면 된다.

 

 

[셰이딩 기법]

 

이제 위의 모델들을 어떤 방식으로 계산할지에 대해 알아보겠습니다. 대표적으로 세 가지만 알아보겠습니다.

 

1. Flat Shading(플랫 셰이딩)

: 각 폴리곤(삼각형)마다 한 번만 법선 벡터를 계산하여 그 값으로 전체 면을 색칠하는 방법입니다. 연산량이 매우 적지만 면 단위로 색이 균일하게 나누어지기 때문에 거친 외관이 형성됩니다.

퍼포먼스를 최우선으로 하거나(빛 연산은 확인하는 디버깅용), 임의로 이런 효과를 원한다거나(레트로 감성) 할 경우 사용합니다.

 

2. Gouraud Shading(구로 셰이딩)

: 각 정점에 조명 모델을 적용해(혹은 광원값) 색을 미리 계산한 뒤, 삼각형 내무 픽셀에서는 정점 간 보간만을 수행하는 방식입니다.

퐁 셰이딩보다 가볍지만, 하이라이트가 삼각형 꼭짓점 바로 근처에 있을 때만 제대로 표현되어 삼각형 내부에서는 사라지거나 퍼져버릴 수 있습니다(하이라이트 손실). 과거 하드웨어의 성능이 낮았던 때 채택했던 모델로, 현재는 픽셀 셰이딩을 위한 GPU 연산 퍼포먼스가 충분하기 때문에 잘 사용되지 않습니다.

 

3. Phong Shading(퐁 셰이딩)

: 각 픽셀마다 보간된 법선 벡터를 통해 조명 모델을 계산하는 방식입니다.

하이라이트나 곡면 느낌이 부드럽게 표현되어 현대의 실시간 렌더링에서 기본으로 사용되는 기법입니다. 물론 연산량이 앞선 두 방식에 비해 높지만, 현대의 GPU 기준으로는 퍼포먼스가 충분하기 때문에 걱정하실 필요는 없습니다.

 

 

4. 기타

: 앞선 세 방식처럼 라이트를 어떻게 표현할지에 대한 직접적인 방법보다, 연산 최적화나 발전형 렌더링 기법으로 보시면 될 것 같습니다.

 

- 디퍼드 셰이딩(Deferred Shading) : 처음 렌더링 과정에서 위치, 법선, 재질 정보 등을 G-Buffer에 저장한 뒤 두 번째 렌더링 과정에서 G-Buffer를 활용하여 광원을 한꺼번에 수행하는 방식입니다. 많은 광원이 동시에 존재해도 픽셀당 계산을 모아서 하기 때문에 효율적(큰 장면일 수록)이지만, 메모리 사용량이 큽니다(G-Buffer 구축 비용).

 

- 어드벤스드 셰이딩(Clursterd/Forward+ Shading) : 화면을 작게 분할(클러스터링)한 뒤, 각 구역에 영향 있는 광원 목록을 미리 추려두어 계산하는 방식으로, 기본적인 포워드 방식보다 많은 광원을 처리 가능하다. 보통 상용 엔진에서 Forward 렌더링을 선택하면 이걸 선택하는 것이다.

 

 

[퐁 셰이딩 기법]

 

그렇다면 퐁 셰이딩 기법을 통해 어떻게 게임에서 빛을 계산하여 활용하는지 알아보도록 하겠습니다. 예시는 Directional Light를 물체에 적용시키기 위한 Pixel Shader 코드의 일부입니다.

cbuffer LightData : register(b0)
{
    float4 LightColor; // 빛의 색깔, w에 빛의 강도가 들어있음
    float4 LightForwardVector; // 월드 기준 빛 입사 방향 벡터
    float4 CameraWorldPosition; // 시점(카메라)의 월드 공간 좌표

    float LightNear;
    float LightFar;
    int LightType;
}

struct PSInput
{
    float4 Position : SV_POSITION; // 월드뷰프로젝션처리된 위치, 여기선 안씀
    float4 TexCoord : TEXCOORD; // UV값, 마찬가지로 안씀
    float3 WorldPosition : POSITION; // 월드기준 Position 값에 월드 행렬만 곱한 것, 월드 기준 위치값
    float3 WorldNormal : NORMAL; // 월드기준 Normal 값에 월드 행렬만 곱한 것, 월드 기준 노말값
};

 

1. Ambient Light(환경광)

: 환경광은 장면 전체에 균일하게 퍼지는 간접광입니다. 여기에는 방향성이나 위치 개념이 필요 없고 그림자 계산도 필요 없습니다. 보통의 환경광은 주변의 물체가 완전히 검게 보이지 않도록 최소한의 밝기를 제공하기 위해 사용됩니다.

 

+) 원래는 특정 물체에 입사 후 반사될 때 변하는 빛의 색상, 빛이 몇 번 입사/반사 되는지 등을 모두 계산하고 물체에 적용하지만 이 과정은 환경광 자체 외에도 복잡한 수식을 전개(앰비언트 오클루전, 글로벌 일루미네이션 등)해야하기 때문에 여기서는 설명을 배제하도록 하겠습니다.

// Ambient Light 계산, 강도는 LightColor의 w값 사용
float AmbientIntensity = LightColor.w;
float3 AmbientLight = AmbientIntensity;

 

 

2. Diffuse(확산광)

: 빛이 표면에 닿고 사방으로 고르게 확산, 반사될 때 생기는 밝기입니다. 밝기는 표면 노말 벡터와 표면에서 광원으로 뻗어나가는 벡터들을 단위 벡터로 만든 후, 내적한 값입니다(cosθ). 

// 월드 공간 기준 빛 방향과 표면 법선 벡터 구하기
float3 LightDirection = normalize(-LightForwardVector.xyz);
float3 SurfaceNormal = normalize(_Input.WorldNormal);
    
// Diffuse Light 계산
float DiffuseLight = max(0.0f, dot(LightDirection, SurfaceNormal));

 

계산 과정을 말로 설명드리면, 다음과 같습니다.

 

1. 빛의 입사 벡터(LightForwardVector)에 대해 마이너스 처리를 해서 "표면에서 광원으로 뻗어나가는 벡터"를 구함(L)

2. 표면 법선 벡터를 구함(N)

3. 단위 벡터값이 아닐수도 있기 때문에 normalize 한번 해줌

4. L과 N을 내적하여 값을 구하고, 0.0f 이하로는 clamp 처리

+) 만약 거리에 따른 빛의 감쇠(attenuation)를 적용하고자 한다면, (1.0 / (a + b*d + c*d*d))과 같은 감쇠 계수를 곱해서 사용

 

 

3. Specular(반사광)

: 매끄러운 표면에서 생기는 강한 하이라이트입니다. Phong 모델에서는 반사 벡터와 뷰 벡터를 내적하여 사이값(cosθ)을 구하고, 적용합니다. 마지막 연산에서 Shininess 값을 활용할 수도 있는데, 값이 높으면 높을수록 하이라이트가 좁고 반짝임이 강해집니다.

float3 LightDirection = normalize(-LightForwardVector.xyz); // 빛 계산에 사용하는 LightDirection은 "표면에서 봤을 때 빛이 표면으로 들어오는 방향"을 사용하므로 반대로 뒤집음
float3 SurfaceNormal = normalize(_Input.WorldNormal);

// Specular Light 계산
float3 Reflection = normalize(2.0f * SurfaceNormal * dot(LightDirection, SurfaceNormal) - LightDirection);
float3 EyeDirection = normalize(CameraWorldPosition.xyz - _Input.WorldPosition);
float RDotV = max(0.0f, dot(Reflection, EyeDirection));
float Shininess = 32.0f;
float3 SpecularLight = pow(RDotV, Shininess);

 

계산 과정을 말로 설명드리면, 다음과 같습니다.

 

1. 반사 벡터(R)을 구하기 위해 "표면에서 광원으로 뻗어나가는 벡터"를 구함(L)

2. 표면 법선 벡터를 구함(N)

3. 단위 벡터값이 아닐수도 있기 때문에 normalize 한번 해줌

4. L과 N을 내적하면, L에 대한 정사영 길이 벡터(A)가 나옴

5. 이제 L과 A를 빼면(L - A) 새로운 벡터 B가 나오는데, 벡터 B는 L지점에서 A 지점으로 향하는 벡터

6. 이걸로 R을 구하면 됨 [R = L + (-B * 2)]

7. 다음으로 시선의 현재 위치(EyePosition)과 표면의 현재 위치(_Input.WorldPosition)의 차를 통해, "표면에서 카메라로 향하는 벡터"를 구하고, normalize하여 단위 벡터로 만들어줌(Eye)

8. 이제 R과 Eye를 내적하면 cosθ가 나오는데, 이 값을 활용하면 됨

9. 추가로 shininess 값을 활용할 수 있는데, 값이 클수록 좁고 반짝이는 하이라이트를 만들 수 있음

 

4. Emissive Light(자체발산광)

: 위의 세 가지만 활용해도 빛은 표현할 수 있지만, 추가로 하나 더 설명드리겠습니다. 해당 종류의 빛은 자체발산광입니다. 말 그대로 물체 자체가 발산하는 빛이기 때문에, 밝은 물체를 표현하고 싶을 때 사용합니다(형광등). 

 

- 퐁 셰이딩 결과 -

 

 

[퐁 셰이딩 기법을 통한 빛의 종류 생성]

 

크게 Ambient Light, Directional Light, Point Light, Spot Light가 있습니다. 

 

1. Ambient Light(환경광)

: 실내 조명에서 천장에 반사된 빛처럼, 그림자 속 물체 내면에 약간의 밝기를 주기 위해 사용되거나, 기초 베이스 조명으로 모든 픽셀에 일정량 더해주는 용도로 주로 활용됩니다.

// 픽셀 셰이더 예시 (의사 코드)
float3 ambientColor = float3(0.2, 0.2, 0.2);  // 연회색 톤의 환경광
float3 baseColor = Texture.Sample(uSampler, uv).rgb;
float3 finalColor = baseColor * ambientColor; // Ambient만 적용한 경우

 

2. Directional Light(직사광)

: 태양광(Sunlight)처럼 한 방향에서 오는 빛을 표현하기 위해 활용됩니다. 빛의 위치가 아닌 방향만을 활용하며, 야외의 주 광원이나 그림자맵을 만들 때 활용됩니다.

// 노말(Normal)과 라이트 방향의 내적(Dot)으로 확산광(Diffuse) 계산
float3 lightDir = normalize(float3(0.5, -1, 0.2)); // 임의의 태양 방향
float3 normal = normalize(Input.Normal);
float NdotL = max(dot(normal, -lightDir), 0.0);
float3 diffuseColor = baseColor * lightColor * NdotL;

 

3. Point Light(포인트 라이트)

: 한 점에서 모든 방향으로 빛이 퍼져나가는 형태를 표현할 때 활용되는 광원 종류입니다. 전구나 촛불 등을 표현할 때 활용됩니다. 빛의 세기는 거리에 따라 감쇠됩니다.

// 점광원 거리 기반 감쇠 계산 (의사 코드)
float3 lightPos = float3(0, 5, 0);      // 점광원 위치
float range = 10.0;                     // 조명이 영향을 미치는 최대 거리
float3 toLight = lightPos - worldPos;  
float dist = length(toLight);
float attenuation = saturate(1.0 - dist / range);  // 단순 선형 감쇠 예시

float3 L = normalize(toLight);
float diff = max(dot(normal, L), 0.0);
float3 pointDiffuse = baseColor * lightColor * diff * attenuation;

 

4. Spot Light(스포트 라이트)

: 특정 점에서 원뿔 형태로 퍼져나오는 빛 형태를 표현할 때 활용되는 광원 종류입니다. 무대 조명이나 손전등 등을 구현할 때 활용됩니다. Point Light와 비슷하지만, Inner Cone과 Outer Cone 각도로 빛 세기를 가중치 계산합니다. 일반적으로 cosθ 기반으로 Smoothing 함수를 적용하여 부드러운 테두리를 만드는 방식으로 제작됩니다.

// 스포트라이트 조명 계산 (의사 코드)
float3 lightPos = float3(0, 5, 0);
float3 spotDir = normalize(float3(0, -1, 0));  // 아래 방향으로 비춤
float innerCutoff = cos(radians(20.0));  // 내부 원뿔 각도
float outerCutoff = cos(radians(30.0));  // 외부 원뿔 각도

float3 toLight = worldPos - lightPos;
float3 L = normalize(-toLight);
float theta = dot(L, spotDir);  // 픽셀에서 스포트라이트 중심 방향과의 코사인

if (theta > outerCutoff)
{
    float epsilon = innerCutoff - outerCutoff;
    float intensity = clamp((theta - outerCutoff) / epsilon, 0.0, 1.0);

    float diff = max(dot(normal, L), 0.0);
    float3 spotDiffuse = baseColor * lightColor * diff * intensity;
}

 

'DirectX11 > 그래픽스' 카테고리의 다른 글

[Graphics] Rendering Pipeline  (0) 2025.06.18
[Graphics] 드로우콜(Draw Call)  (0) 2025.06.11
[Graphics] 포워드 렌더링, 디퍼드 렌더링  (0) 2025.06.11
[Graphics] Normal Mapping  (0) 2025.06.07
[Graphics] Normal  (0) 2025.06.06

[목차]

 

- 기능 추가

- 본격적인 시작 전 간단한 Lighting 테스트

 

 

[기능 추가]

 

아마 마지막 기능 추가가 되지 않을까 싶습니다. 이번의 기능들은 마치 게임에서 플레이어블 캐릭터를 조종하는 것과 유사한 환경을 만들기 위해서 추가되었습니다. 

 

1. FSM

: 위에서 언급한 것과 같이, 특정 상태값을 표현하기 위해 Finite State Machine(FSM)을 추가했습니다. 사용하고 싶은 Object에서 <Ext_FSM> 객체를 만들고, Create()와 Change()를 활용하면 됩니다. 값으로 전달하는 것은 { .Start, .Update(float _DeltaTime), .End() } 입니다.

 

- Start : 해당 FSM 상태로 진입할 때 1회 적용되는 로직

- Update : Actor 업데이트간 함께 Update되는 로직

- End : 다른 상태값으로 변경될 때 1회 적용되는 로직

 

예를들어 현재 State가 Idle인데 Walk로 변경된다고 가정해봅시다. Idle은 최초에 .Start 이후 .Update(float _DeltaTime)을 진행중이었지만 이제 Walk로 변경되어야 합니다. 이때 먼저 Idle의 .End가 호출됩니다. 이후 바로 Walk의 .Start가 호출됩니다. 바로 다음 프레임 부터는 이제 Idle 대신  Walk의 Update(float _DeltaTime)이 진행됩니다.

 

FSM을 활용하면 특정 동작에 대해서만 Update가 진행되도록 할 수 있기 때문에, 캐릭터의 조작 같은 경우나 엄청 간단한 AI를 충분히 만들어낼 수 있습니다. 물론 행동트리도 있지만, 이것 또한 너무 과하다고 생각되어 FSM을 선택했습니다.

 

2. 충돌

: 충돌은 DirectX의 Intersects() 함수를 활용하여 만들었고, 종류는 Sphere, AABB, OBB 세 가지 입니다. 이를 위해 <Ext_CollisionComponent>를 만들었고, 이 클래스는 <Ext_Component>를 상속받습니다. 기존 Component와 마찬가지로 Actor 내부에서 CraeteComponent를 통해 생성이 가능합니다. 생성 시 두 가지 값을 멤버 변수로 갖습니다.

enum class CollsionType
{
    Sphere3D,
    AABB3D,
    OBB3D,
    Unknown,
};

class CollisionData
{
public:
	CollisionData() {}
	~CollisionData() {}

	union
	{
		DirectX::BoundingSphere Sphere;
		DirectX::BoundingBox AABB;
		DirectX::BoundingOrientedBox OBB;
	};

	void ScaleABS()
	{
		OBB.Extents.x = abs(OBB.Extents.x);
		OBB.Extents.y = abs(OBB.Extents.y);
		OBB.Extents.z = abs(OBB.Extents.z);
	}
};

 

CollisionType은 자신이 어떤 형태인지 정의하는 것이고, CollisionData는 union 메모리에 대한 Center, Extents, Orientation값을 Update하여 저장하기 위해 존재합니다. 세 값은 모두 Direct3D에서 도형 정보를 표현할 때 사용하는 값들입니다.

 

- Center : WorldPosition입니다. 자신이 현재 어디에 위치하고 있는지에 대한 정보입니다.

- Extents : WorldPosition으로부터 각 면이 얼마나 떨어져있는지에 대한 정보입니다. x, y, z값으로 얼만큼씩 떨어져있는지를 담습니다.

- Orientation : WorldRotation입니다. 자신의 회전 정보이며, 쿼터니언값으로 저장됩니다.

 

union을 활용하고 매 프레임의 CollisionData를 BoundingOrientedBox(OBB)의 세 가지 값에 대해 업데이트하는 이유는 다음과 같습니다. 

//////////// DirectXCollision.h
//...
    struct BoundingSphere
    {
        XMFLOAT3 Center;            // Center of the sphere.
        float Radius;               // Radius of the sphere.
//...        
    struct BoundingBox
    {
        static constexpr size_t CORNER_COUNT = 8;

        XMFLOAT3 Center;            // Center of the box.
        XMFLOAT3 Extents;           // Distance from the center to each side.
//...        
    struct BoundingOrientedBox
    {
        static constexpr size_t CORNER_COUNT = 8;

        XMFLOAT3 Center;            // Center of the box.
        XMFLOAT3 Extents;           // Distance from the center to each side.
        XMFLOAT4 Orientation;       // Unit quaternion representing rotation (box -> world).

 

 

위 코드 내용은 DirectXCollision.h의 각 도형들이 자신들의 충돌 처리를 위해 가져야하는 값들입니다. 보시면 BoundingSphere(Sphere)는 Center 하나, BoundingBox(AABB)는 Center, Extents 두개, BoundingOrientedBox(OBB)는 Center, Extents, Orientation 세개의 정보를 필요로 합니다. 만약 여기서 BoundingOrientedBox의 세 가지 정보만 업데이트 해도, 나머지 두가지 형태에 대해서는 이미 동일한 메모리 상태를 공유한다고 가정했을 때(union), 모두 정보가 업데이트 되고 활용할 수 있게 됩니다.

 

앞의 설명이 길었습니다만, 결국 충돌을 자신이 어떤 형태를 가지고 있는지, 충돌하려고 하는 상대는 어떤 형태를 가지고 있는지를 알아내고 서로 업데이트된 행렬 정보를 토대로 DirectX의 함수인 Intersects()를 활용하여 충돌 체크를 진행하는 것입니다. 사용할 때는 간단하게 CollisionComponent->Collision(/*충돌하고자하는 그룹*/)으로 충돌 체크를 진행하면 됩니다. 물론 이러기 위해서는 Collsion을 생성할 때(CreateComponent()) Order를 지정해줘야 합니다. 이 이상 설명드리면 너무 내용이 길어지기 때문에 관심이 있으신 분이 계시다면 코드를 확인해보시면 좋을 것 같습니다.

 

아무튼 정리해서, 충돌체크를 진행하면 결과는 다음과 같습니다.

 

보시는 것 처럼, 회색 바닥과 캐릭터의 발에 달려있는 충돌체들이 서로 충돌하면 else 구문의 브레이크 포인트에 체크가 걸립니다(잘 되고 있다는 뜻)

 

 

[본격적인 시작 전 간단한 Lighting 테스트]

 

픽셀 셰이더 테스트 진행을 위해 Z+ 직진 방향으로 뻗어나가는 Directional Light를 하나 만들어봤습니다. 먼저 각 Scene은 생성과 동시에 Directional Light 하나를 갖습니다. 이후 Ext_Scene::Rendering() 과정에서 Camera의 View, Projection 행렬 정보 업데이트 후 Light에 대한 Update를 진행합니다.

void Ext_Light::LightUpdate(std::shared_ptr<Ext_Camera> _Camera, float _DeltaTime)
{
	LTData->LightWorldPosition = GetTransform()->GetWorldPosition();
	LTData->LightForward = GetTransform()->GetLocalForwardVector();
}

 

지금까지는 필요하다고 생각되는 정보들을 넣어놨습니다. 빛의 위치(Directional에는 필요없음), 빛의 방향, 빛이 적용되는 부분이 어디인지 3D 스페이스 공간에 담기 위한 정보들을 담아줍니다. 구조체 정보는 다음과 같습니다(아직은 임시입니다).

struct LightData
{
	float4 LightColor = { 1.0f, 1.0f, 1.0f, 0.1f }; // RGB(색), w(강도)
	
	float4 LightWorldPosition; // 라이트 현재 위치(월드)
	float4 LightForward; // 라이트 전방 방향

	float LightNear = 1.0f;
	float LightFar = 1.0f;
	int LightType = 0;
};

 

이제 이 LTData(LightData)도 사용하려면 기존에 Unit 생성 시 Transform 데이터를 Link 했던것처럼 해줘야합니다. 

// 메시 컴포넌트 유닛 생성 시 호출, Mesh, Material, ConstantBuffer 세팅
void Ext_MeshComponentUnit::MeshComponentUnitInitialize(std::string_view _MeshName, std::string_view _MaterialName)
{
	Mesh = Ext_DirectXMesh::Find(_MeshName); // 메시 설정
	Material = Ext_DirectXMaterial::Find(_MaterialName); // 머티리얼 설정
	
	if (nullptr == Mesh || nullptr == Material)
	{
		MsgAssert("존재하지 않는 메시나 머티리얼을 메시유닛에 넣을 수는 없습니다.")
	}

	InputLayout->CreateInputLayout(Mesh->GetVertexBuffer(), Material->GetVertexShader()); // InputLayout 설정

	// 상수버퍼 세팅
	// [1] 버텍스 셰이더 정보 가져오기
	const Ext_DirectXBufferSetter& VertexShaderBuffers = Material->GetVertexShader()->GetBufferSetter();
	BufferSetter.Copy(VertexShaderBuffers);

	// [2] 픽셀 셰이더 정보 가져오기
	const Ext_DirectXBufferSetter& PixelShaderBuffers = Material->GetPixelShader()->GetBufferSetter();
	BufferSetter.Copy(PixelShaderBuffers);

	// [3] 트랜스폼 상수버퍼 세팅하기
	const TransformData& TFData = *(OwnerMeshComponent.lock()->GetTransform()->GetTransformData().get());
	BufferSetter.SetConstantBufferLink("TransformData", TFData);

	// [4] 빛 상수버퍼 세팅하기
	if (_MaterialName == "Static" || _MaterialName == "Dynamic")
	{
		const LightData& LTData = *(OwnerMeshComponent.lock()->GetOwnerScene().lock()->GetDirectionalLight()->GetLightData().get());
		BufferSetter.SetConstantBufferLink("LightData", LTData);
	}
    //...
}

 

Material Setting이 Static과 Dynamic인 경우에 대해서만 LightData를 링크해줬습니다.

 

아직 버텍스 셰이더는 따로 수정하지 않고 기존의 것을 그대로 사용하고, 픽셀 셰이더만 새로 만들어줬습니다. 내용은 다음과 같습니다.

cbuffer LightData : register(b0)
{
    float4 LightColor; // RGB(색), w(강도)
	
    float4 LightWorldPosition; // 라이트 현재 위치(월드)
    float4 LightForward; // 라이트 전방 방향

    float LightNear = 1.0f;
    float LightFar = 1.0f;
    int LightType = 0;
}

Texture2D BaseColorTex : register(t0); // 텍스처 자원
SamplerState Sampler : register(s0); // 샘플러

struct PSInput
{
    float4 Position : SV_POSITION;
    float4 Color : COLOR;
    float2 TexCoord : TEXCOORD;
    float4 Normal : NORMAL;
};

float4 Grapics_PS(PSInput _Input) : SV_TARGET
{
    // 텍스처 색상
    float4 Albedo = BaseColorTex.Sample(Sampler, _Input.TexCoord);

    // 정규화된 노멀 및 라이트 방향
    float3 Normal = normalize(_Input.Normal.xyz);
    float3 LightDir = normalize(-LightForward.xyz); // 역방향 (빛이 오는 방향)

    // Lambert 조명 모델 (디퓨즈)
    float Diff = max(dot(Normal, LightDir), 0.0f);

    // 조명 색 * 확산 * 텍스처 색
    float3 LitColor = Albedo.rgb * Diff * LightColor.rgb;

    return float4(LitColor, Albedo.a);
}

 

이러면 이제 엔트리 포인트에서 텍스쳐 색상을 결정할 때, 빛 정보를 바탕으로 추가 처리를 진행합니다. 아직 View 값에 대해서는 재쳐두고, 그냥 빛의 Forward만 가져와서 럼버트 공식으로 Diff값을 구해줍니다.

 

이 값은 현재 빛의 색과 곱한 뒤 Texture 색과 곱해주면 됩니다. 이러면 해당 픽셀 셰이더를 활용하는 렌더러들은 맨 처음에 올린 영상과 같이 음영이 생기게 됩니다.

 

위는 Z+ 정면으로 빛이 뻗어나갈 경우에 그려지는 음영이고, 만약 X축으로 45도 회전, Y축으로 45도 회전한 Forward 값을 활용하게 된다면, 다음과 같이 음영이 그려지게 됩니다.

 

지금은 고정된 음영 형태를 가지고 회전하고 있는데, 이건 노말 처리를 하지 않아서 그렇습니다. 아직 노말을 배울 단계는 아니지만, 다음과 같이 임시로 정점 셰이더 내용을 변경해보겠습니다.

cbuffer TransformData : register(b0)
{
    float4 LocalPosition;
    float4 LocalRotation;
    float4 LocalScale;
    float4 LocalQuaternion;
    float4x4 LocalMatrix;

    float4 WorldPosition;
    float4 WorldRotation;
    float4 WorldQuaternion;
    float4 WorldScale;
    float4x4 WorldMatrix;

    float4x4 ViewMatrix;
    float4x4 ProjectionMatrix;
    float4x4 WorldViewMatrix;
    float4x4 WorldViewProjectionMatrix;
}

struct VSInput
{
    float4 Position : POSITION;
    float4 Color : COLOR;
    float4 TexCoord : TEXCOORD;
    float4 Normal : NORMAL;
};

struct VSOutput
{
    float4 Position : SV_POSITION;
    float4 Color : COLOR;
    float4 TexCoord : TEXCOORD;
    float4 Normal : NORMAL;
};

VSOutput Grapics_VS(VSInput _Input)
{
    VSOutput Output;
    // _Input.Position.w = 1.0f;
    
    float4 WorldPos = mul(_Input.Position, WorldMatrix);
    float4 ViewPos = mul(WorldPos, ViewMatrix);
    Output.Position = mul(ViewPos, ProjectionMatrix);
    // Output.Position = mul(_Input.Position, WorldViewProjectionMatrix);
    
    Output.Color = _Input.Color;
    Output.TexCoord = _Input.TexCoord;
    
    // Output.Normal = _Input.Normal; << 원래 이건데, 아래로 내용 변경
    float4x4 worldView4x4 = mul(WorldMatrix, ViewMatrix);
    float3x3 worldView3x3 = (float3x3) worldView4x4;
    float3 normalVS3 = normalize(mul(_Input.Normal.xyz, worldView3x3));

    // 3) float3 → float4 로 명시적 확장. w 성분은 방향 벡터이므로 0.0f
    Output.Normal = float4(normalVS3, 0.0f);
   
    return Output;
}

 

이렇게 수정하면 카메라가 바라보는 방향 기준으로 빛 처리가 진행됩니다.

 

[목차]

 

- assimp 라이브러리 이식하기

- assimp 기능을 활용하여 Mesh 로드하기

- 애니메이션 로드하기

- 애니메이션 행렬 계산하기

- 애니메이션 스키닝 계산하기

 

 

[assimp 라이브러리 이식하기]

 

assimp(Open Asset Import) 라이브러리는 다양한 3D 애셋에 대해 API를 제공하여, 3D 모델을 임포트하기 위해 사용되는 오픈 소스 라이브러리입니다. 옛날에 FBX 라이브러리를 활용해본적이 있는데, 조금 복잡하다고 느껴서 조금 더 간단한 라이브러리가 없나 찾아보다가 알게 되었습니다.

 

https://github.com/assimp/assimp

 

GitHub - assimp/assimp: The official Open-Asset-Importer-Library Repository. Loads 40+ 3D-file-formats into one unified and clea

The official Open-Asset-Importer-Library Repository. Loads 40+ 3D-file-formats into one unified and clean data structure. - GitHub - assimp/assimp: The official Open-Asset-Importer-Library Reposit...

github.com

 

파일을 받은 다음 CMake를 통해 lib, dll 파일을 추출하고, inluce 파일을 가져와서 사용하면 됩니다. 

 

 

위의 링크에서 파일을 받은 다음 CMake를 통해 lib과 dll을 추출하고, include 파일을 가져와서 사용하면 됩니다. 해당 프로젝트에서는 아래의 폴더 구조와 같이 파일들을 위치시켰습니다.

[DirectX11_RenderingPipeline 프로젝트 폴더]
ㄴ[DirectX11_Base]
 [DirectX11_Contents]
 [DirectX11_Extension]
 [DirectX11_RenderingPipeline]
 [Shader]
 [ThirdParty]
  ㄴ[DirectTex]
   [Assimp]
  ㄴ[inc]
     ㄴ[assimp]
      ㄴ[많은 파일들]
    ㄴ[lib]
      ㄴ[x64]
        ㄴ[Debug]
         ㄴassimp-vc143-mtd.dll
          assimp-vc143-mtd.lib
           assimp-vc143-mtd.exp
           assimp-vc143-mtd.pdb
        ㄴ[Release]
         ㄴassimp-vc143-mt.dll
          assimp-vc143-mt.lib
           assimp-vc143-mt.exp
           assimp-vc143-mt.pdb

 

다음은 프로젝트 세팅입니다. 먼저 exe 프로젝트의 링커 설정을 진행했습니다. 일반은 Debug, Release 설정이 동일합니다.

 

입력은 lib간 차이가 있기 때문에, 구성에 따라 다르게 설정해줍니다.

 

dll 파일은 exe 파일과 동일한 위치에 있어야하는데, [명령줄] 기능을 이용하면 프로젝트 빌드 시 dll 파일을 exe파일이 존재하는 폴더에 복사/붙여넣기할 수 있습니다.

[Debug]
xcopy /Y /D "$(ProjectDir)..\ThirdParty\Assimp\lib\x64\Debug\assimp-vc143-mtd.dll" "$(OutDir)"

[Release]
xcopy /Y /D "$(ProjectDir)..\ThirdParty\Assimp\lib\x64\Release\assimp-vc143-mt.dll" "$(OutDir)"

 

이후 lib 프로젝트들의 포함 디렉터리에 [..\ThirdParty\Asssimp\inc\]를 넣어주면 됩니다.

 

 

[assimp 기능을 활용하여 Mesh 로드하기]

 

사용할 Mesh는 Maximo에서 구했습니다.

https://www.mixamo.com/#/

 

Mixamo

 

www.mixamo.com

 

저는 Michelle 캐릭터를 골라서 사용했습니다.

 

Animation도 필요한게 있으면 받아옵니다.

 

저는 Walking 외 몇가지 필요한 애니메이션 파일들을 받아왔습니다.

 

+) Maximo 파일은 FBX에 텍스쳐 파일이 함께 저장되어 있습니다. 텍스쳐를 따로 꺼내오기 위해서는 Blender를 사용해서 추출해야합니다.

 

Static Mesh와 Dynamic Mesh 로드 과정을 만들었습니다. 설명은 Dynmic Mesh를 로드 기준으로 설명하겠습니다. Static Mesh는 그냥 몇가지 로드만 하고 바로 사용하면 되기 때문입니다. 먼저 파일을 로드할 곳에서 다음과 같이 선언해줍니다.

// DynamicMesh Load
{
	Base_Directory Dir;
	Dir.MakePath("../Resource/Character/Mesh/Girl.fbx");
	Ext_DirectXMesh::CreateDynamicMesh(Dir.GetPath());
}
// Texture도 쓸거면 로드
{
	Base_Directory Dir;
	Dir.MakePath("../Resource/Character/Texture");
	std::vector<std::string> Paths = Dir.GetAllFile({ "png", "tga", "dss" });
	for (const std::string& FilePath : Paths)
	{
		Ext_DirectXTexture::LoadTexture(FilePath.c_str());
	}
}

 

CreateDynamicMesh() 함수를 호출하면 다음의 함수가 호출됩니다.

 

+) 호출 전에 InputLayout을 추가로 정의해줬습니다.

Ext_DirectXVertexData::GetInputLayoutData().AddInputLayoutDesc("POSITION", DXGI_FORMAT_R32G32B32A32_FLOAT);
Ext_DirectXVertexData::GetInputLayoutData().AddInputLayoutDesc("COLOR", DXGI_FORMAT_R32G32B32A32_FLOAT);
Ext_DirectXVertexData::GetInputLayoutData().AddInputLayoutDesc("TEXCOORD", DXGI_FORMAT_R32G32B32A32_FLOAT);
Ext_DirectXVertexData::GetInputLayoutData().AddInputLayoutDesc("NORMAL", DXGI_FORMAT_R32G32B32A32_FLOAT);
Ext_DirectXVertexData::GetInputLayoutData().AddInputLayoutDesc("TANGENT", DXGI_FORMAT_R32G32B32A32_FLOAT); // Normal Mapping용
Ext_DirectXVertexData::GetInputLayoutData().AddInputLayoutDesc("BINORMAL", DXGI_FORMAT_R32G32B32A32_FLOAT); // Normal Mapping용
Ext_DirectXVertexData::GetInputLayoutData().AddInputLayoutDesc("BONEID", DXGI_FORMAT_R32G32B32A32_SINT); // FBX Animation용
Ext_DirectXVertexData::GetInputLayoutData().AddInputLayoutDesc("WEIGHT", DXGI_FORMAT_R32G32B32A32_FLOAT); // FBX Animation용
// assimp 라이브러리를 활용하여 DynamicMesh 로드하기
std::shared_ptr<Ext_DirectXMesh>Ext_DirectXMesh::CreateDynamicMesh(std::string_view _FilePath)
{
    ////////////////////////////////////// Bone 정보 추출하기
    std::shared_ptr<Ext_SkeltalMesh> NewSkeleton = std::make_shared<Ext_SkeltalMesh>();
    
    const aiScene* AIScene = NewSkeleton->MeshImporter.ReadFile(_FilePath.data(), aiProcess_Triangulate | aiProcess_JoinIdenticalVertices | aiProcess_CalcTangentSpace | aiProcess_GenSmoothNormals | aiProcess_LimitBoneWeights);

    if (nullptr == AIScene || AIScene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || nullptr == AIScene->mRootNode)
    {
        MessageBoxA(nullptr, NewSkeleton->MeshImporter.GetErrorString(), "Dynamic Mesh Import Error", MB_OK);
        return nullptr;
    }

    ////////////////////////////////////// 메시로드 시, 파일 안에는 하나의 메시만 존재한다고 가정
    const aiMesh* AIMesh = AIScene->mMeshes[0];
    std::vector<Ext_DirectXVertexData> Vertices;
    std::vector<UINT> Indices;

    Vertices.reserve(AIMesh->mNumVertices);
    Indices.reserve(AIMesh->mNumFaces * 3);

    ////////////////////////////////////// 정점 정보 채우기: POSITION, COLOR, TEXCOORD, NORMAL
    for (unsigned int i = 0; i < AIMesh->mNumVertices; ++i)
    {
        Ext_DirectXVertexData VertexData;
        VertexData.POSITION = float4(AIMesh->mVertices[i].x, AIMesh->mVertices[i].y, AIMesh->mVertices[i].z);
        VertexData.NORMAL = AIMesh->HasNormals() ? float4(AIMesh->mNormals[i].x, AIMesh->mNormals[i].y, AIMesh->mNormals[i].z) : float4::ZERO;
        VertexData.TANGENT = AIMesh->HasTangentsAndBitangents() ? float4(AIMesh->mTangents[i].x, AIMesh->mTangents[i].y, AIMesh->mTangents[i].z) : float4::ZERO;
        VertexData.BINORMAL = AIMesh->HasTangentsAndBitangents() ? float4(AIMesh->mBitangents[i].x, AIMesh->mBitangents[i].y, AIMesh->mBitangents[i].z) : float4::ZERO;
        if (AIMesh->HasTextureCoords(0))
        {
            //VertexData.TEXCOORD = float4(AIMesh->mTextureCoords[0][i].x, AIMesh->mTextureCoords[0][i].y);
            VertexData.TEXCOORD = float4(AIMesh->mTextureCoords[0][i].x, 1.0f - AIMesh->mTextureCoords[0][i].y); // CCW
        }
        else
        {
            VertexData.TEXCOORD = float4::ZERO;
        }

        Vertices.push_back(VertexData);
    }

    if (!AIMesh->HasBones())
    {
        MsgAssert("본이 없는 다이나믹메시는 이 방식으로 로드할 수 없습니다.");
        return nullptr;
    }

    // (혹시) 겹치는 이름이 있으면 걸러줌
    std::set<std::string> uniqueBoneNames;
    for (unsigned int b = 0; b < AIMesh->mNumBones; ++b)
    {
        uniqueBoneNames.insert(AIMesh->mBones[b]->mName.C_Str());
    }

    // 수집된 고유 Bone 이름을 순서대로 BoneID 부여 및 OffsetMatrix 저장
    for (auto& boneName : uniqueBoneNames)
    {
        // 첫 번째로 등장하는 aiBone에서 OffsetMatrix 가져오기
        aiMatrix4x4 offset;
        bool found = false;
        for (unsigned int m = 0; m < AIScene->mNumMeshes && !found; ++m)
        {
            aiMesh* mesh = AIScene->mMeshes[m];
            if (!mesh->HasBones()) continue;

            for (unsigned int b = 0; b < mesh->mNumBones; ++b)
            {
                if (boneName == mesh->mBones[b]->mName.C_Str())
                {
                    offset = mesh->mBones[b]->mOffsetMatrix;
                    found = true;
                    break;
                }
            }
        }

        Ext_SkeltalMesh::BoneInfomation BInf;
        BInf.ID = NewSkeleton->BoneCount;
        BInf.OffsetMatrix = offset; // 모델공간 → 본공간
        NewSkeleton->BoneInfomations[boneName] = BInf;

        ++NewSkeleton->BoneCount;
    }

    ////////////////////////////////////// 정점 정보 채우기: BONEID, WEIGHT
    for (unsigned int b = 0; b < AIMesh->mNumBones; ++b)
    {
        const aiBone* AibonePtr = AIMesh->mBones[b];
        std::string    BoneName(AibonePtr->mName.C_Str());

        auto It = NewSkeleton->BoneInfomations.find(BoneName);
        if (It == NewSkeleton->BoneInfomations.end()) continue;

        int BoneID = It->second.ID;
        for (unsigned int w = 0; w < AibonePtr->mNumWeights; ++w)
        {
            unsigned int VertID = AibonePtr->mWeights[w].mVertexId;
            float         Weight = AibonePtr->mWeights[w].mWeight;
            Ext_DirectXVertexData& DestV = Vertices[VertID];

            // 네 슬롯(0~3) 중 빈 슬롯에 BoneID/Weight 저장
            for (int k = 0; k < 4; ++k)
            {
                bool IsEmpty = false;
                switch (k)
                {
                case 0:  IsEmpty = (DestV.WEIGHT.x == 0.0f); break;
                case 1:  IsEmpty = (DestV.WEIGHT.y == 0.0f); break;
                case 2:  IsEmpty = (DestV.WEIGHT.z == 0.0f); break;
                case 3:  IsEmpty = (DestV.WEIGHT.w == 0.0f); break;
                }
                if (IsEmpty)
                {
                    switch (k)
                    {
                    case 0:
                        DestV.BONEID.x = static_cast<uint32_t>(BoneID);
                        DestV.WEIGHT.x = Weight;
                        break;
                    case 1:
                        DestV.BONEID.y = static_cast<uint32_t>(BoneID);
                        DestV.WEIGHT.y = Weight;
                        break;
                    case 2:
                        DestV.BONEID.z = static_cast<uint32_t>(BoneID);
                        DestV.WEIGHT.z = Weight;
                        break;
                    case 3:
                        DestV.BONEID.w = static_cast<uint32_t>(BoneID);
                        DestV.WEIGHT.w = Weight;
                        break;
                    }
                    break;
                }
            }
        }
    }

    ////////////////////////////////////// 인덱스 정보 채우기
    for (unsigned int i = 0; i < AIMesh->mNumFaces; ++i)
    {
        const aiFace& Face = AIMesh->mFaces[i];

        if (Face.mNumIndices == 3) // 삼각형만 처리
        {
            // CW → CCW로 전환 (Face.mIndices[1], [2] 순서 변경)
            Indices.push_back(Face.mIndices[0]);
            Indices.push_back(Face.mIndices[2]);
            Indices.push_back(Face.mIndices[1]);
        }
        else
        {
            // 삼각형이 아닐 경우 그대로 넣거나 무시
            for (unsigned int j = 0; j < Face.mNumIndices; ++j)
            {
                Indices.push_back(Face.mIndices[j]);
            }
        }
    }

    std::string FileName = Base_Directory::GetFileName(_FilePath);
    std::string UpperName = Base_String::ToUpper(FileName);

    Ext_DirectXVertexBuffer::CreateVertexBuffer(FileName, Vertices);
    Ext_DirectXIndexBuffer::CreateIndexBuffer(FileName, Indices);

    NewSkeleton->MeshScene = AIScene;
    SkeletalMeshs[UpperName] = NewSkeleton;

    return CreateMesh(FileName.c_str());
}

 

assimp 라이브러리는 로드하는 방식이나 사용 방식을 세세하게 알려주기 때문에 그대로 가져와서 자신의 작업 환경에 맞게 조금 변경해주면 됩니다.

 

해당 프레임워크에서는 Create할 때 VertexBuffer와 IndexBuffer를 생성하고 그걸 Mesh 클래스가 들고있도록 설계해뒀기 때문에, 로드하는 과정에서 파일에 저장된 값을 불러들인 후 마지막에 CreateMesh()를 실시하여 Mesh를 리소스 매니저에 저장했습니다.

 

필요로 하는 파일 정보를 추출하기 위해서는 최초에 Assimp::Importer에 플래그들을 세워줘야 하는데, 저는 다음의 플래그들을 사용했습니다.

aiProcess_Triangulate           // 메시를 삼각형 폴리곤으로 변환
aiProcess_JoinIdenticalVertices // 중복된 정점 데이터 병합
aiProcess_CalcTangentSpace      // Tangent, Bitangent 계산
aiProcess_GenSmoothNormals      // SmoothNormal 자동 생성(모델에 노말 없으면 노말 생성)
aiProcess_LimitBoneWeights      // 정점당 최대 N개의 Bone Influence만 남김, 가중치 높은 순

 

함수 내부에서는 크게 다음의 작업들을 진행합니다.

 

1. Vertex Buffer, Index Buffer를 위한 컨테이너 생성하기

2. SkeletalMesh 값 저장을 위한 구조체를 할당하기

3. POSITION, NORMAL, TANGENT, BINORMAL, TEXCOORD, WEIGHT, BONEID 값을 Vetext Buffer 컨테이너에 넣기

4. SkeletalMesh 구조체에 Bone 갯수, OffsetMatrix, Bone들의 정보, 해당 Mesh Importer 정보, aiScene 정보 저장

5. 정점 그리는 순서 Index Buffer 컨테이너에 넣기

6. Vertex Buffer, Index Buffer 생성

7. Mesh 생성

 

이러면 Mesh 생성은 끝입니다. 참고로 SkeletalMesh는 Mesh 클래스 위에 하나 추가해줬습니다.

// DynamicMesh의 Bone 정보
class Ext_SkeltalMesh
{
	friend class Ext_DirectXMesh;

public:
	struct BoneInfomation
	{
		int ID; // 0부터 시작하는 본 인덱스
		aiMatrix4x4 OffsetMatrix;  // aiBone->mOffsetMatrix: “모델 스페이스 → 본 로컬 스페이스”
	};

	// Getter
	int GetBoneCount() { return BoneCount; }
	const aiScene* GetMeshScene() { return MeshScene; }
	std::unordered_map<std::string, BoneInfomation>& GetBoneInfomations() { return BoneInfomations; }

private:
	std::unordered_map<std::string, BoneInfomation> BoneInfomations;
	int BoneCount = 0;

	Assimp::Importer MeshImporter; // 이거 없으면 밑에 깨짐(릴리즈됨)
	const aiScene* MeshScene = nullptr; // OwnerAIScene

};

 

생성된 Skeletal Mesh 정보들은 전역 컨테이너에 저장됩니다. 사실 여기까지만 하고 바로 로드하면 기존에 다른 Mesh들을 로드하는 것과 같이 T-Pose를 취하는 캐릭터를 하나 화면에 바로 띄워볼 수 있습니다.

다른 캐릭터로 테스트해봤을 때의 결과입니다. 잘 출력되는 것을 확인했습니다.

 

 

[애니메이션 로드하기]

 

DynamicMesh의 경우에는 Animation을 진행하려고 로드하는 것이기 때문에, Ext_DynamicMeshComponent 클래스를 새로 만들어줬습니다. 해당 클래스는 Ext_MeshComponent를 상속받는 클래스입니다. 크게 다른건 없고 Ext_Animator를 하나 내부에 들고있는 클래스입니다.

#pragma once

#include "Ext_MeshComponent.h"

class Ext_DynamicMeshComponent : public Ext_MeshComponent
{
public:
    Ext_DynamicMeshComponent() {}
    ~Ext_DynamicMeshComponent() {}

    Ext_DynamicMeshComponent(const Ext_DynamicMeshComponent&) = delete;
    Ext_DynamicMeshComponent(Ext_DynamicMeshComponent&&) noexcept = delete;
    Ext_DynamicMeshComponent& operator=(const Ext_DynamicMeshComponent&) = delete;
    Ext_DynamicMeshComponent& operator=(Ext_DynamicMeshComponent&&) noexcept = delete;

    std::shared_ptr<class Ext_MeshComponentUnit> CreateMeshComponentUnit(std::string_view _Mesh, MaterialType _SettingValue) override; // 메시 컴포넌트에 필요한 유닛 생성 및 저장
    void CreateAnimation(std::string_view _FilePath);
    void SetAnimation(std::string_view _AnimName, bool _IsLoop = false);
    bool IsAnimationEnd();

    std::shared_ptr<class Ext_Animator> GetAnimator() { return Animator; };

protected:
    void Start() override;
    void Rendering(float _DeltaTime, const float4 _CameraWorldPosition, const float4x4& _ViewMatrix, const float4x4& _ProjectionMatrix) override;

private:
    float AccumulatedTime = 0.0f; // 애니메이션 재생 시간을 누적할 변수
    std::string SkinnedCBName = "CB_SkinnedMatrix"; 
    std::shared_ptr<class Ext_Animator> Animator = nullptr; // 
};

 

이제 사용을 위해 Unit의 Initialize를 실시합니다. 기존과 동일하지만, BufferSetting일 하나 추가로 해줘야합니다.

Unit->GetBufferSetter().SetConstantBufferLink(SkinnedCBName, &Animator->GetCBMat(), sizeof(CB_SkinnedMatrix));

 

바로 Animator에 있는 Bone matrix입니다. 나중에 Vertex Shader에서 이 행렬 정보를 받아 스키닝을 진행하게 됩니다.

// GPU에 넘길 상수 버퍼 구조체
constexpr unsigned int MAX_BONES = 100;
struct CB_SkinnedMatrix
{
    float4x4 Bones[MAX_BONES];
};

 

여기까지 진행했으면, Animation 설정을 진행해줘야합니다. 다음과 같이 실행해줍니다.

Base_Directory Dir;
Dir.MakePath("../Resource/Character/Animation");
std::vector<std::string> Pathse = Dir.GetAllFile({ "fbx" });
for (const std::string& FilePath : Pathse)
{
	BodyMesh->CreateAnimation(FilePath);
}

 

이러면 Animator의 LoadAnimation() 함수가 실행됩니다.

// 애니메이션 생성
bool Ext_Animator::LoadAnimation(std::string_view _FilePath)
{
    std::shared_ptr<AnimationData> NewAnimData = std::make_shared<AnimationData>();

    // 1) 애니메이션 FBX 읽기 → DirectX(LH) 좌표계로 변환
    const aiScene* NewAnimScene = NewAnimData->AnimImporter.ReadFile(_FilePath.data(), aiProcess_Triangulate | aiProcess_GenSmoothNormals | aiProcess_CalcTangentSpace | aiProcess_JoinIdenticalVertices | aiProcess_LimitBoneWeights);
    if (!NewAnimScene || (NewAnimScene->mFlags & AI_SCENE_FLAGS_INCOMPLETE) /*|| !AnimSceneAIAnimScenemRootNode*/)
    {
        MsgAssert("FBX 애니메이션 로드 실패");
        return false;
    }

    // 2) 애니메이션 개수 확인
    if (NewAnimScene->mNumAnimations == 0)
    {
        MsgAssert("애니메이션 채널이 들어 있지 않음");
        return false;
    }

    // 3) 기본 애니메이션 채널 매핑
    std::vector<std::string> TranslationNodes; // 루트모션 끄려고 찾는것
    CurrentAnimation = NewAnimScene->mAnimations[0];
    BoneNameToAnimChannel.clear();
    for (unsigned int c = 0; c < CurrentAnimation->mNumChannels; ++c)
    {
        const aiNodeAnim* Channel = CurrentAnimation->mChannels[c];
        BoneNameToAnimChannel[Channel->mNodeName.C_Str()] = Channel;
        
        if (Channel->mNumPositionKeys > 0) // 위치 키가 하나라도 있으면 Translation이 움직이는것
        {
            TranslationNodes.push_back(Channel->mNodeName.C_Str());
        }
    }

    NewAnimData->AIAnimScene = NewAnimScene;
    std::string NewName = Base_Directory::GetFileName(_FilePath);
    std::string UpperName = Base_String::ToUpper(NewName);

    if (AnimationDatas.find(UpperName) != AnimationDatas.end())
    {
        MsgAssert("이미 동일한 이름의 애니메이션이 등록되어 있습니다. " + UpperName);
        return false;
    }

    const aiNode* RootNode = SkeletalMesh->GetMeshScene()->mRootNode; // 씬 루트 노드 가져오기
    RootMotionBoneName = FindRootMotionNode(RootNode, TranslationNodes); // 최상위 Translation 채널(부모가 없는 채널)을 찾아서 저장

    AnimationDatas[UpperName] = NewAnimData; // 다 끝났으면 저장

    return true;
}

 

Mesh 로드와 마찬가지로 Importer를 통해 로드를 진행하는데, 플래그를 설정해줄 수 있습니다. 플래그는 Mesh 로드때와 동일합니다.

 

여기서도 로드 방식은 알려진 것에서 저에게 알맞게 수정했습니다. 한 가지 바뀐 점은 RootMotion을 끄기 위해 mNumPositionKeys가 있는 Animation의 RootNodeName을 따로 이름으로 찾아둔 것입니다(RootMotionBoneName(string)에 저장). 위 함수 실행이 정상적으로 진행됐다면 [const aiAnimation* CurrentAnimation] 멤버 변수에 현재 애니메이션으로 저장됩니다(이게 끝임).

 

애니메이션을 Setting할때는 SetAnimation() 함수를 호출하는데, 이것도 그냥 저장된 aiAnimation 중에 이름값이 동일한 애니메이션을 찾아서 지정해주는 방식입니다(자세한건 너무 길어지니 깃허브 코드 확인으로,,,).

 

 

[애니메이션 행렬 계산하기]

 

Mesh와 Animation이 모두 로드됐으면, Animation을 매 프레임마다 실행해주면 됩니다. 이를 위해 Ext_DynamicMeshComponent 클래스의 Rendering() 함수 내부에서 다음의 함수를 호출해줍니다.

void Ext_DynamicMeshComponent::Rendering(float _DeltaTime, const float4 _CameraWorldPosition, const float4x4& _ViewMatrix, const float4x4& _ProjectionMatrix)
{
    // 기본 렌더링 (Transform / Material 셋업 후)
    __super::Rendering(_DeltaTime, _CameraWorldPosition, _ViewMatrix, _ProjectionMatrix);

    Animator->UpdateAnimation(_DeltaTime);
}

 

UpdateAnimation() 함수로 들어가 봅시다.

// 선택된 애니메이션 재생
void Ext_Animator::UpdateAnimation(float _DeltaTime)
{
    if (!CurrentAnimation) // aiScene이 없거나
    {
        return;
    }

    // 1) FinalBoneMatrices 초기화
    for (size_t i = 0; i < SkeletalMesh->GetBoneCount(); ++i)
    {
        FinalBoneMatrices[i] = aiMatrix4x4();
    }

    AccumulatedTime += _DeltaTime;

    // 애니메이션 끝났는지 검사
    bIsAnimationEnd = false;
    bool HasEnded = (AccumulatedTime >= AnimationLengthSec);
    if (HasEnded)
    {
        bIsAnimationEnd = true;
        if (bIsLoop)
        {
            AccumulatedTime = fmod(AccumulatedTime, (float)AnimationLengthSec); // 루핑 재생이라면, 누적 시간을 “mod”해 주거나 리셋
        }
        else
        {
            AccumulatedTime = (float)AnimationLengthSec; // 비루핑(딱 한 번 재생)이라면, AccumulatedTime을 최대 길이로 고정
        }
    }

    const aiNode* RootNode = SkeletalMesh->GetMeshScene()->mRootNode;
    aiMatrix4x4 Identity; // 기본 생성자 → 항등행렬
    ReadNodeHierarchy(AccumulatedTime, RootNode, Identity);

    RenderSkinnedMesh(); // 다 돌았으면 CB에 값 저장
}

 

해당 함수의 최종 목적은 Unit을 Initialize하는 과정에서 BufferSetting을 진행해줬던 CBMat에 애니메이션 연산 결과를 저장하여 GPU에서 사용할 수 있도록 하는 것입니다. 애니메이션 행렬 연산은 ReadNodeHierarchy() 함수가 재귀적으로 동작하면서 수행됩니다.

// 재귀적으로 노드 트리를 순회하여 FinalBoneMatrices에 값 적용
aiMatrix4x4 Ext_Animator::ReadNodeHierarchy(float _AccumulatedTime, const aiNode* _CurNode, const aiMatrix4x4& _ParentTransform)
{
    // 현재 노드 이름
    std::string CurNodeName = _CurNode->mName.C_Str();
    aiMatrix4x4 Mat;

    // 애니메이션 채널 보간 여부
    auto AnimIter = BoneNameToAnimChannel.find(CurNodeName);
    if (AnimIter != BoneNameToAnimChannel.end())
    {
        const aiNodeAnim* Channel = AnimIter->second;

        // 초 → 틱 변환
        float Ticks = TimeInTicks(_AccumulatedTime);
        float Duration = static_cast<float>(CurrentAnimation->mDuration);
        float AnimTime = fmod(Ticks, Duration);

        // 보간 후 TRS
        aiVector3D InterpPosition;
        aiQuaternion InterpRotation;
        aiVector3D InterpScale;
        CalcInterpolatedPosition(InterpPosition, AnimTime, Channel);
        CalcInterpolatedRotation(InterpRotation, AnimTime, Channel);
        CalcInterpolatedScaling(InterpScale, AnimTime, Channel);


        if ("" != RootMotionBoneName && CurNodeName == RootMotionBoneName)
        {
            InterpPosition.x = 0.0f;
            InterpPosition.y = 0.0f;
            InterpPosition.z = 0.0f;
        }

        // 일단 사용(루트모션 제거용)
        //InterpPosition.z = 0.0f;

        aiMatrix4x4 TranslationMat;
        aiMatrix4x4 RotationMat;
        aiMatrix4x4 ScaleMat;
        aiMatrix4x4::Translation(InterpPosition, TranslationMat);
        RotationMat = aiMatrix4x4(InterpRotation.GetMatrix());
        aiMatrix4x4::Scaling(InterpScale, ScaleMat);

        Mat = TranslationMat * RotationMat * ScaleMat;
    }

    // 부모 변환과 곱해 글로벌 변환 계산
    aiMatrix4x4 GlobalTransform = _ParentTransform * Mat;

    // 이 노드가 본(BoneNameToInfo에 존재)이라면 최종 스킨 행렬 계산
    auto BoneIter = SkeletalMesh->GetBoneInfomations().find(CurNodeName);
    if (BoneIter != SkeletalMesh->GetBoneInfomations().end())
    {
        int BoneID = BoneIter->second.ID;
        const aiMatrix4x4& OffsetMat = BoneIter->second.OffsetMatrix;

        aiMatrix4x4 FinalBoneTransform = GlobalTransform * OffsetMat;
        FinalBoneMatrices[BoneID] = FinalBoneTransform;
    }

    // (6) 자식 노드 순회
    for (unsigned int i = 0; i < _CurNode->mNumChildren; ++i)
    {
        ReadNodeHierarchy(_AccumulatedTime, _CurNode->mChildren[i], GlobalTransform);
    }

    return GlobalTransform;
}

 

해당 부분도 assimp 사용 방식에서 크게 다른 점은 없습니다. 중요한 점은 TranslationMat, RotationMat, ScaleMat를 구하고 이들을 곱해서 SRT 행렬로 만든다음, 이걸 부모 행렬과 곱하여 GlobalTransform을 구하고, 맨 처음에 Mesh 로드 과정에서 구한 OffsetMat에 적용시키는 것입니다. 이러면 애니메이션의 흐름에 따라 Bone들이 월드 좌표 기준으로 어디에 위치하게 되는지 대략적으로 구해집니다. 이 정보들을 FinalBoneMatrices라는 std::vector<aiMatrix4x4> 컨테이너에 저장해줍니다.

 

모든 Bone들의 행렬 연산이 끝났다면, 이제 RenderSkinnedMesh() 함수로 이동하여 CBMat에 정보를 넘겨줍니다.

void Ext_Animator::RenderSkinnedMesh()
{
    // FinalBoneMatrices[i]를 그대로 XMMATRIX 생성자에 넘겨 주면 됩니다
    for (size_t i = 0; i < FinalBoneMatrices.size() && i < MAX_BONES; ++i)
    {
        const aiMatrix4x4& m = FinalBoneMatrices[i];

        // “행 우선(row-major)”대로 XMMATRIX 생성
        DirectX::XMMATRIX xm = DirectX::XMMATRIX(
            m.a1, m.a2, m.a3, m.a4,
            m.b1, m.b2, m.b3, m.b4,
            m.c1, m.c2, m.c3, m.c4,
            m.d1, m.d2, m.d3, m.d4
        );

        CBMat.Bones[i] = xm;
    }

    // 나머지 본은 단위행렬로 채우기
    for (size_t i = FinalBoneMatrices.size(); i < MAX_BONES; ++i)
    {
        CBMat.Bones[i] = DirectX::XMMatrixIdentity();
    }
}

 

이렇게 해서  aiMatrix4x4를 XMMATRIX로 암시적 변환하여 저장해줍니다. 최종적으로 이 값은 상수버퍼로 Vertex Shader에 전달됩니다.

 

 

[애니메이션 스키닝 계산하기]

#include "Transform.fx"

#define MAX_BONES 100
cbuffer CB_SkinnedMatrix : register(b1)
{
    float4x4 Bones[MAX_BONES];
};

struct VSInput
{
    float4 Position : POSITION;
    float4 TexCoord : TEXCOORD;
    float4 Normal : NORMAL;
    uint4 BoneID : BONEID;
    float4 Weight : WEIGHT;
    float4 Tangent : TANGENT; // 로컬 접선
    float4 Binormal : BINORMAL; // 로컬 이접선
};

struct VSOutput
{
    float4 Position : SV_POSITION;
    float2 TexCoord : TEXCOORD;
    float3 WorldPosition : POSITION;
    float3 WorldNormal : NORMAL;
    float3 WorldTangent : TANGENT;
    float3 WorldBinormal : BINORMAL;
};

VSOutput DynamicPBR_VS(VSInput _Input)
{
    VSOutput Output = (VSOutput) 0;

    // 1. 스키닝 매트릭스 계산
    float4x4 SkinMatrix = float4x4(
        0, 0, 0, 0,
        0, 0, 0, 0,
        0, 0, 0, 0,
        0, 0, 0, 0
    );

    [unroll]
    for (int i = 0; i < 4; ++i)
    {
        uint BoneIndex = _Input.BoneID[i];
        float Weight = _Input.Weight[i];
        if (Weight > 0.0f && BoneIndex < MAX_BONES)
        {
            SkinMatrix += Bones[BoneIndex] * Weight;
        }
    }

    // SkinNormal 계산
    float4 SkinPosition;
    SkinPosition.x = dot(SkinMatrix[0], _Input.Position); // row0 · [x y z 1]
    SkinPosition.y = dot(SkinMatrix[1], _Input.Position); // row1 · [x y z 1]
    SkinPosition.z = dot(SkinMatrix[2], _Input.Position); // row2 · [x y z 1]
    SkinPosition.w = dot(SkinMatrix[3], _Input.Position); // row3 · [x y z 1]
 
    float3 SkinNormal;
    {
        float3 Row0 = float3(SkinMatrix[0][0], SkinMatrix[0][1], SkinMatrix[0][2]);
        float3 Row1 = float3(SkinMatrix[1][0], SkinMatrix[1][1], SkinMatrix[1][2]);
        float3 Row2 = float3(SkinMatrix[2][0], SkinMatrix[2][1], SkinMatrix[2][2]);
        float3 LocalN = _Input.Normal.xyz;
        
        SkinNormal.x = dot(Row0, LocalN);
        SkinNormal.y = dot(Row1, LocalN);
        SkinNormal.z = dot(Row2, LocalN);
        SkinNormal = normalize(SkinNormal);
    }
    
    // SkinTangent 계산
    float3 SkinTangent;
    {
        float3 Row0 = float3(SkinMatrix[0][0], SkinMatrix[0][1], SkinMatrix[0][2]);
        float3 Row1 = float3(SkinMatrix[1][0], SkinMatrix[1][1], SkinMatrix[1][2]);
        float3 Row2 = float3(SkinMatrix[2][0], SkinMatrix[2][1], SkinMatrix[2][2]);
        float3 LocalT = _Input.Tangent.xyz;
        
        SkinTangent.x = dot(Row0, LocalT);
        SkinTangent.y = dot(Row1, LocalT);
        SkinTangent.z = dot(Row2, LocalT);
        SkinTangent = normalize(SkinTangent);
    }
    
    // SkinBinormal 계산
    float3 SkinBinormal;
    {
        float3 Row0 = float3(SkinMatrix[0][0], SkinMatrix[0][1], SkinMatrix[0][2]);
        float3 Row1 = float3(SkinMatrix[1][0], SkinMatrix[1][1], SkinMatrix[1][2]);
        float3 Row2 = float3(SkinMatrix[2][0], SkinMatrix[2][1], SkinMatrix[2][2]);
        float3 LocalB = _Input.Binormal.xyz;
        
        SkinBinormal.x = dot(Row0, LocalB);
        SkinBinormal.y = dot(Row1, LocalB);
        SkinBinormal.z = dot(Row2, LocalB);
        SkinBinormal = normalize(SkinBinormal);
    }
    
    // Position 설정
    float4 WorldPos = mul(SkinPosition, WorldMatrix);
    float4 ViewPos = mul(WorldPos, ViewMatrix);
    Output.Position = mul(ViewPos, ProjectionMatrix);
         
    // UV 좌표 설정
    Output.TexCoord = _Input.TexCoord.xy;
    
    // 월드 공간 기준으로 조명 계산을 진행하기 위해 WorldMatrix만 처리한 Position, Normal을 생성하여 Pixel Shader에 넘겨줌
    Output.WorldPosition = WorldPos.xyz;
    Output.WorldNormal = mul(SkinNormal, (float3x3) WorldMatrix);
    Output.WorldTangent = mul(SkinTangent, (float3x3) WorldMatrix);
    Output.WorldBinormal = mul(SkinBinormal, (float3x3) WorldMatrix);
    
    return Output;
}

 

CPU에서 각 Bone의 최종 월드/로컬 행렬들을 정해줬는데, 추가로 연산을 진행하는게 의아하실 수 있습니다.

// 1. 스키닝 매트릭스 계산
float4x4 SkinMatrix = float4x4(
    0, 0, 0, 0,
    0, 0, 0, 0,
    0, 0, 0, 0,
    0, 0, 0, 0
);

[unroll]
for (int i = 0; i < 4; ++i)
{
    uint BoneIndex = _Input.BoneID[i];
    float Weight = _Input.Weight[i];
    if (Weight > 0.0f && BoneIndex < MAX_BONES)
    {
        SkinMatrix += Bones[BoneIndex] * Weight;
    }
}

float4 SkinPosition;
SkinPosition.x = dot(SkinMatrix[0], _Input.Position); // row0 · [x y z 1]
SkinPosition.y = dot(SkinMatrix[1], _Input.Position); // row1 · [x y z 1]
SkinPosition.z = dot(SkinMatrix[2], _Input.Position); // row2 · [x y z 1]
SkinPosition.w = dot(SkinMatrix[3], _Input.Position); // row3 · [x y z 1]

 

해당 부분은 정점이 여러 개의 Bone 영향을 받는 스키닝(Skinning; 가중 평균 변환) 연산을 수행하기 위함입니다. 해당 연산을 수행해야 메시가 실제로 움직입니다(메시가 이동되는 것).

 

1. 초기화

: SkinMatrix를 모두 0으로 초기화합니다.

 

2. 최대 4개의 Bone에 대해 반복

: 보통 하나의 정점은 최대 4개의 Bone에 영향을 받는다고 제한합니다. GPU 구조상 효율 문제가 있기 때문입니다.

for (int i = 0; i < 4; ++i)

 

3. Bone ID와 Weight 추출

: 정점이 영향을 받는 Bone ID와 그 Bone의 Weight를 가져옵니다.

uint BoneIndex = _Input.BoneID[i];
float Weight = _Input.Weight[i];

 

4. Weight가 0 이상이고, 유효한 Bone ID일 경우

: 해당 Bone 행렬 Bones[BoneIndex]를 가중치만큼 곱해서 누적합니다.

SkinMatrix += Bones[BoneIndex] * Weight;

 

Bones[BoneIndex]는 앞서 CPU에서 계산해준 값입니다. 여기서 Weight는 Bone이 해당 정점에 미치는 영향인데, 0.0 ~ 1.0 사이 값을 갖습니다. 이걸 가중 합산하면 정점의 최종 변환 결과가 나옵니다.

 

5. 결과 적용

: 이제 이 값을 정점에 적용해줍니다. Position과 Noraml에 SkinMatrix를 적용해주면 됩니다.

// 스키닝된 모델 공간의 좌표/노말 계산
float4 SkinPosition;
SkinPosition.x = dot(SkinMatrix[0], _Input.Position); // row0 · [x y z 1]
SkinPosition.y = dot(SkinMatrix[1], _Input.Position); // row1 · [x y z 1]
SkinPosition.z = dot(SkinMatrix[2], _Input.Position); // row2 · [x y z 1]
SkinPosition.w = dot(SkinMatrix[3], _Input.Position); // row3 · [x y z 1]
 
float3 SkinNormal;
float3 row0 = float3(SkinMatrix[0][0], SkinMatrix[0][1], SkinMatrix[0][2]);
float3 row1 = float3(SkinMatrix[1][0], SkinMatrix[1][1], SkinMatrix[1][2]);
float3 row2 = float3(SkinMatrix[2][0], SkinMatrix[2][1], SkinMatrix[2][2]);

SkinNormal.x = dot(row0, _Input.Normal.xyz);
SkinNormal.y = dot(row1, _Input.Normal.xyz);
SkinNormal.z = dot(row2, _Input.Normal.xyz);
SkinNormal = normalize(SkinNormal);

 

여기까지 해주면 정상적으로 애니메이션을 수행할 수 있게 됩니다.

 

 

+ Recent posts