[Computer Graphics with DirectX 12] 02. Texture

2024. 2. 26. 17:29Programming/Computer Graphics

02. Texture 목표

  • 텍스처 렌더링
    • 플레이어를 포함한 오브젝트에 텍스처 적용
  • 스카이박스 렌더링 
    • 큐브 텍스처(D3D12_SRV_DIMENSION_TEXTURECUBE) 사용

 

프로젝트 1에서는 정점에 직접 색을 지정해 주고, 픽셀 셰이더에서 그 색을 직접 출력하는 방법으로 색을 표현하였습니다. 프로젝트 2에서는 텍스처를 메쉬에 입히는 방법으로 색을 표현해 보겠습니다.

 

 

GitHub - SH4MDEL/DirectX12-ComputerGraphics

Contribute to SH4MDEL/DirectX12-ComputerGraphics development by creating an account on GitHub.

github.com

 

위 리포지토리에서 전체 소스코드를 확인하실 수 있습니다.

이 프로젝트의 소스코드는 02. Texture입니다.

 


시작 전 주요 수정사항

class Shader
class Shader abstract
{
public:
	Shader() = default;
	virtual ~Shader() = default;

	void UpdateShaderVariable(const ComPtr<ID3D12GraphicsCommandList>& commandList);

protected:
	ComPtr<ID3D12PipelineState> m_pipelineState;
};

 

먼저 Shader 클래스를 추상 클래스화 했습니다. 프로젝트 1에서는 장면을 렌더링 하는데 단 하나의 파이프라인 상태 객체만 사용하였습니다. 따라서 하나의 Shader 클래스 인스턴스만으로도 모든 장면을 렌더링 할 수 있었지만, 앞으로는 사용해야 할 파이프라인 상태 객체가 점점 늘어나게 될 것입니다.

당장 이번 프로젝트만 해도, 기존 오브젝트를 렌더링 하기 위한 파이프라인 상태 객체와, 스카이박스를 렌더링 하기 위한 파이프라인 상태 객체가 나뉩니다.

그렇기에 이전 프로젝트의 Mesh 클래스처럼 Shader 클래스 역시 추상 클래스화 하고 필요한 기능의 정의는 상속을 통해 구현하도록 하겠습니다.

 

class ObjectShader : public Shader
{
public:
	ObjectShader(const ComPtr<ID3D12Device>& device, const ComPtr<ID3D12RootSignature>& rootSignature);
	~ObjectShader() override = default;
};

 

기존 Shader 클래스의 기능을 하는 ObjectShader 클래스를 정의해 주고, Shader 클래스의 생성자에서 하던 일을 ObjectShader 클래스의 생성자에서 하도록 수정해 주었습니다.

 

Root Signature
void GameFramework::CreateRootSignature()
{
	CD3DX12_DESCRIPTOR_RANGE descriptorRange[2];
	descriptorRange[DescriptorRange::Texture].Init(
		D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 1, 0, 0, D3D12_DESCRIPTOR_RANGE_OFFSET_APPEND);
	descriptorRange[DescriptorRange::TextureCube].Init(
		D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 1, 1, 0, D3D12_DESCRIPTOR_RANGE_OFFSET_APPEND);

	CD3DX12_ROOT_PARAMETER rootParameter[4];
	rootParameter[RootParameter::GameObject].InitAsConstants(16, 0, 0, D3D12_SHADER_VISIBILITY_ALL);
	rootParameter[RootParameter::Camera].InitAsConstants(32, 1, 0, D3D12_SHADER_VISIBILITY_ALL);
	rootParameter[RootParameter::Texture].InitAsDescriptorTable(1,
		&descriptorRange[DescriptorRange::Texture], D3D12_SHADER_VISIBILITY_PIXEL);
	rootParameter[RootParameter::TextureCube].InitAsDescriptorTable(1,
		&descriptorRange[DescriptorRange::TextureCube], D3D12_SHADER_VISIBILITY_PIXEL);

	CD3DX12_STATIC_SAMPLER_DESC samplerDesc;
	samplerDesc.Init(								
		0,								 			
		D3D12_FILTER_MIN_MAG_MIP_LINEAR,	
		D3D12_TEXTURE_ADDRESS_MODE_WRAP, 
		D3D12_TEXTURE_ADDRESS_MODE_WRAP,
		D3D12_TEXTURE_ADDRESS_MODE_WRAP, 
		0.0f,
		1,
		D3D12_COMPARISON_FUNC_ALWAYS,
		D3D12_STATIC_BORDER_COLOR_TRANSPARENT_BLACK,
		0.0f,
		D3D12_FLOAT32_MAX,
		D3D12_SHADER_VISIBILITY_PIXEL,
		0
	);

	CD3DX12_ROOT_SIGNATURE_DESC rootSignatureDesc;
	rootSignatureDesc.Init(_countof(rootParameter), rootParameter, 1, &samplerDesc,
		D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT);

	ComPtr<ID3DBlob> signature, error;
	Utiles::ThrowIfFailed(D3D12SerializeRootSignature(&rootSignatureDesc, 
		D3D_ROOT_SIGNATURE_VERSION_1, &signature, &error));
	Utiles::ThrowIfFailed(m_device->CreateRootSignature(0, signature->GetBufferPointer(), 
		signature->GetBufferSize(), IID_PPV_ARGS(&m_rootSignature)));
}

 

텍스처 리소스가 셰이더 프로그램에 추가될 예정이므로 관련된 자원들을 루트 시그니처에 묶어 주었습니다.

2D 텍스처와 스카이박스(TextureCube)를 위한 서술자 테이블을 각각 추가해 주고, 텍스처에 대한 샘플링 정책을 결정하는 샘플러를 추가해 주었습니다.


텍스처 구현

 

먼저 프로젝트 1에서, 화면에 오브젝트를 렌더링 하는 과정이 어떻게 일어났는지 되짚어 보겠습니다.

 

class GameObject
void GameObject::Render(const ComPtr<ID3D12GraphicsCommandList>& commandList) const
{
	UpdateShaderVariable(commandList);
	m_mesh->Render(commandList);
}

void GameObject::UpdateShaderVariable(const ComPtr<ID3D12GraphicsCommandList>& commandList) const
{
	XMFLOAT4X4 worldMatrix;
	XMStoreFloat4x4(&worldMatrix, XMMatrixTranspose(XMLoadFloat4x4(&m_worldMatrix)));
	commandList->SetGraphicsRoot32BitConstants(ShaderRegister::GameObject, 16, &worldMatrix, 0);

	if (m_texture) m_texture->UpdateShaderVariable(commandList);
}

 

  • 어떻게 그리나요? : GameObject에서 관리
    • 무엇을 그리나요? : Mesh에서 관리
    • 어디에 그리나요? : World Matrix 행렬

GameObject 클래스는 하나의 객체가 어떻게 그려질지에 대한 정보를 관리합니다.

그렇기에 프로젝트 1에서는 GameObject 클래스에서 '무엇을 그릴지'를 결정하는 Mesh 클래스의 포인터와, '어디에 그릴지'를 결정하는 World Matrix 행렬을 관리했습니다.

여기서 '무엇을 그릴지'라는 개념을 '정점이 어떤 모양을 이루고 있는지'와 '정점이 어떤 색을 이루고 있는지'로 다시 한번 나눠 보겠습니다. 

 

  • 어떻게 그리나요? : GameObject에서 관리
    • 무엇을 그리나요?
      • 정점이 어떤 모양을 이루고 있나요? : Mesh에서 관리
      • 정점이 어떤 색을 이루고 있나요? : Mesh에서 관리? -> Texture에서 관리!
    • 어디에 그리나요? : World Matrix 행렬

프로젝트 1에서는 정점에 지정된 색을 그대로 출력하여 색을 표현하였습니다. 그렇기에 '정점이 어떤 색을 이루고 있는지'에 대한 정보 역시 Mesh에서 관리했습니다. 앞으로는 텍스처를 관리하는 Texture 클래스를 정의하여 정점(정확히는 폴리곤)이 어떤 색을 이루고 있는지 관리하도록 하겠습니다.

 

class Texture
class Texture
{
public:
	Texture() = delete;
	Texture(const ComPtr<ID3D12Device>& device, 
		const ComPtr<ID3D12GraphicsCommandList>& commandList, 
		const wstring& fileName, UINT rootParameterIndex);
	~Texture() = default;

	void UpdateShaderVariable(const ComPtr<ID3D12GraphicsCommandList>& commandList) const;
	void ReleaseUploadBuffer();

private:
	void LoadTexture(const ComPtr<ID3D12Device>& device,
		const ComPtr<ID3D12GraphicsCommandList>& commandList,
		const wstring& fileName);
	void CreateSrvDescriptorHeap(const ComPtr<ID3D12Device>& device);
	void CreateShaderResourceView(const ComPtr<ID3D12Device>& device);


private:
	ComPtr<ID3D12DescriptorHeap>	m_srvDescriptorHeap;
	ComPtr<ID3D12Resource>			m_texture;
	ComPtr<ID3D12Resource>			m_textureUploadBuffer;
	UINT							m_rootParameterIndex;
};

 

Texture 클래스는 텍스처 리소스에 관한 정보를 관리합니다. 서술자 힙이 필요 없는 정점 버퍼와는 다르게, 텍스처를 서술하는 데에는 서술자 힙을 필요로 하므로 ID3D12DescriptorHeap 타입의 포인터를 정의해 주었습니다.

그리고 Mesh때와 같이 텍스처 리소스로 사용할 m_texture와, 텍스처 리소스를 초기화하기 위한 m_textureUploadBuffer를 정의해 주었습니다.

 

Texture::Texture(const ComPtr<ID3D12Device>& device, 
	const ComPtr<ID3D12GraphicsCommandList>& commandList, 
	const wstring& fileName, UINT rootParameterIndex) : 
	m_rootParameterIndex{rootParameterIndex}
{
	LoadTexture(device, commandList, fileName);
	CreateSrvDescriptorHeap(device);
	CreateShaderResourceView(device);
}

 

Texture 클래스의 생성자에서는 읽고자 하는 파일 이름과 사용할 루트 파라미터의 번호를 받아 텍스처를 로드하고, 서술자 힙과 서술자를 생성합니다.

LoadTexture, CreateSrvDescriptorHeap, CreateShaderResourceView 함수에서 각각 어떤 일을 하는지 설명드리겠습니다.

 

void Texture::LoadTexture(const ComPtr<ID3D12Device>& device,
	const ComPtr<ID3D12GraphicsCommandList>& commandList,
	const wstring& fileName)
{
	unique_ptr<uint8_t[]> ddsData;
	vector<D3D12_SUBRESOURCE_DATA> subresources;
	DDS_ALPHA_MODE ddsAlphaMode{ DDS_ALPHA_MODE_UNKNOWN };
	Utiles::ThrowIfFailed(DirectX::LoadDDSTextureFromFileEx(device.Get(), fileName.c_str(), 0,
		D3D12_RESOURCE_FLAG_NONE, DDS_LOADER_DEFAULT, m_texture.GetAddressOf(), ddsData, subresources, &ddsAlphaMode));

	UINT nSubresources{ (UINT)subresources.size() };
	const UINT64 TextureSize{ GetRequiredIntermediateSize(m_texture.Get(), 0, nSubresources) };

	Utiles::ThrowIfFailed(device->CreateCommittedResource(
		&CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD),
		D3D12_HEAP_FLAG_NONE,
		&CD3DX12_RESOURCE_DESC::Buffer(TextureSize),
		D3D12_RESOURCE_STATE_GENERIC_READ,
		nullptr,
		IID_PPV_ARGS(&m_textureUploadBuffer)
	));

	UpdateSubresources(commandList.Get(), m_texture.Get(), m_textureUploadBuffer.Get(), 0, 0, nSubresources, subresources.data());

	commandList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition(m_texture.Get(),
		D3D12_RESOURCE_STATE_COPY_DEST, D3D12_RESOURCE_STATE_GENERIC_READ));
}

 

먼저 LoadTexture 함수에서는 DDSTextureLoader12.h 헤더 파일에 정의된 LoadDDSTextureFromFileEx 함수를 통해 DDS 파일을 메모리에 올립니다. 그리고 Mesh 때와 마찬가지로 버텍스 버퍼(텍스처)와 업로드 버퍼를 준비하여 텍스처 리소스에 자원을 올려 주었습니다.

Mesh 때와 달리 버텍스 버퍼(텍스처)는 CreateCommittedResource를 통해 리소스를 생성해 주지 않았는데, LoadDDSTextureFromFileEx 함수 내부에서 이미 해당 작업이 실행하고 있기 때문입니다.

 

void Texture::CreateSrvDescriptorHeap(const ComPtr<ID3D12Device>& device)
{
	D3D12_DESCRIPTOR_HEAP_DESC srvHeapDesc = {};
	srvHeapDesc.NumDescriptors = 1;
	srvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV;
	srvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
	Utiles::ThrowIfFailed(device->CreateDescriptorHeap(
		&srvHeapDesc, IID_PPV_ARGS(&m_srvDescriptorHeap)));
}

 

이어서 셰이더 리소스 뷰를 저장할 수 있는 서술자 힙을 생성해 주었습니다.

 

void Texture::CreateShaderResourceView(const ComPtr<ID3D12Device>& device)
{
	CD3DX12_CPU_DESCRIPTOR_HANDLE descriptorHandle{ 
		m_srvDescriptorHeap->GetCPUDescriptorHandleForHeapStart() };

	D3D12_SHADER_RESOURCE_VIEW_DESC srvDesc = {};
	srvDesc.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING;
	srvDesc.Format = m_texture->GetDesc().Format;

	switch (m_rootParameterIndex)
	{
	case RootParameter::Texture:
		srvDesc.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D;
		srvDesc.Texture2D.MostDetailedMip = 0;
		srvDesc.Texture2D.MipLevels = m_texture->GetDesc().MipLevels;
		srvDesc.Texture2D.ResourceMinLODClamp = 0.0f;
		break;
	case RootParameter::TextureCube:
		srvDesc.ViewDimension = D3D12_SRV_DIMENSION_TEXTURECUBE;
		srvDesc.TextureCube.MostDetailedMip = 0;
		srvDesc.TextureCube.MipLevels = m_texture->GetDesc().MipLevels;
		srvDesc.TextureCube.ResourceMinLODClamp = 0.0f;
		break;
	default:
		break;
	}
	device->CreateShaderResourceView(m_texture.Get(), &srvDesc, descriptorHandle);
}

 

마지막으로 셰이더 리소스 뷰를 생성해 주면 되는데, 그냥 2차원 텍스처에 대한 셰이더 리소스 뷰를 만들지, 스카이박스로 사용하기 위한 셰이더 리소스 뷰를 만들 지에 따라 설정을 달리 해 줘야 합니다.

저는 루트 파라미터의 번호를 통해 텍스처의 용도를 구분하였습니다.

 

class Mesh
class CubeMesh : public Mesh
{
private:
	struct Vertex
	{
		XMFLOAT3 position;
		XMFLOAT2 uv;
	};

public:
	CubeMesh(const ComPtr<ID3D12Device>& device, const ComPtr<ID3D12GraphicsCommandList>& commandList);
	~CubeMesh() override = default;
};

 

이제 Mesh가 더 이상 색에 관한 정보를 가지고 있을 필요가 없습니다.

Mesh 클래스 내의 Vertex를 수정하여, 색 대신 uv 좌표를 갖도록 수정하였습니다. 즉 Mesh는 이제 어떤 색으로 출력될지를 결정하지 않고, 텍스처상의 어떤 텍셀을 선택할지에 대한 정보만을 갖게 됩니다.

 

CubeMesh::CubeMesh(const ComPtr<ID3D12Device>& device, const ComPtr<ID3D12GraphicsCommandList>& commandList)
{
	...
    
	// Front
	vertices.emplace_back(LEFTUPFRONT,		XMFLOAT2{ 0.0f, 0.0f });
	vertices.emplace_back(RIGHTUPFRONT,		XMFLOAT2{ 1.0f, 0.0f });
	vertices.emplace_back(RIGHTDOWNFRONT,	XMFLOAT2{ 1.0f, 1.0f });
	
	vertices.emplace_back(LEFTUPFRONT,		XMFLOAT2{ 0.0f, 0.0f });
	vertices.emplace_back(RIGHTDOWNFRONT,	XMFLOAT2{ 1.0f, 1.0f });
	vertices.emplace_back(LEFTDOWNFRONT,	XMFLOAT2{ 0.0f, 1.0f });
	
	...
}

 

uv 좌표는 왼쪽 상단을 (0.0, 0.0)으로, 오른쪽 하단을 (1.0, 1.0)으로 지정해 주었습니다.

 

class Shader
ObjectShader::ObjectShader(const ComPtr<ID3D12Device>& device, const ComPtr<ID3D12RootSignature>& rootSignature)
{
	vector<D3D12_INPUT_ELEMENT_DESC> inputLayout = {
	{ "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
	{ "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 12, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 } };
	
    ...
}

 

수정된 Mesh의 정점 정보대로 셰이더의 Input Layout 또한 수정해 주었습니다.

 

HLSL
cbuffer GameObject : register(b0)
{
    matrix g_worldMatrix : packoffset(c0);
};

cbuffer Camera : register(b1)
{
    matrix g_viewMatrix : packoffset(c0);
    matrix g_projectionMatrix : packoffset(c4);
};

Texture2D g_texture : register(t0);
TextureCube g_textureCube : register(t1);

SamplerState g_sampler : register(s0);

struct OBJECT_VERTEX_INPUT
{
    float3 position : POSITION;
    float2 uv : TEXCOORD;
};

struct OBJECT_PIXEL_INPUT
{
    float4 position : SV_POSITION;
    float2 uv : TEXCOORD;
};

OBJECT_PIXEL_INPUT OBJECT_VERTEX(OBJECT_VERTEX_INPUT input)
{
    OBJECT_PIXEL_INPUT output;
    output.position = mul(float4(input.position, 1.0f), g_worldMatrix);
    output.position = mul(output.position, g_viewMatrix);
    output.position = mul(output.position, g_projectionMatrix);
    output.uv = input.uv;
    
    return output;
}

float4 OBJECT_PIXEL(OBJECT_PIXEL_INPUT input) : SV_TARGET
{
    return g_texture.Sample(g_sampler, input.uv);
}

 

HLSL에서는 먼저 루트 시그니처를 통해 등록한 두 텍스처와 샘플러를 정의해 주었습니다.

1번 텍스처는 2D 텍스처로 사용하고, 2번 텍스처는 스카이박스를 렌더링 하기 위한 TextureCube로 사용할 것입니다.

 

버텍스 셰이더에는 큰 변경사항이 없습니다. 입력으로 들어온 색을 그대로 내보내던 것을, uv 좌표를 그대로 내보내도록 수정했습니다.

픽셀 셰이더에서는 uv 좌표를 이용해 텍스처를 샘플링하여 출력해 줍니다.

 

 

목표한 대로 잘 렌더링 되는 모습입니다!

GameObject 클래스가 Texture를 관리하고 있기에, Mesh 인스턴스는 단 하나만 존재함에도 여러 종류의 텍스처를 서로 다른 오브젝트에 입혀 줄 수 있습니다.


스카이박스 구현

 

이제 스카이박스를 렌더링 해 보도록 하겠습니다.

스카이박스는 육면체 내부에 배경 텍스처를 입혀 주변 환경을 나타내는 기법입니다.

지금까지는 육면체의 외부에 텍스처를 입혔습니다. 이제는 육면체의 내부에 텍스처를 입혀 줄 방법에 대해 생각해 보겠습니다.

 

CubeMesh::CubeMesh(const ComPtr<ID3D12Device>& device, const ComPtr<ID3D12GraphicsCommandList>& commandList)
{
	...

	// Front
	vertices.emplace_back(LEFTUPFRONT,		XMFLOAT2{ 0.0f, 0.0f });
	vertices.emplace_back(RIGHTUPFRONT,		XMFLOAT2{ 1.0f, 0.0f });
	vertices.emplace_back(RIGHTDOWNFRONT,	XMFLOAT2{ 1.0f, 1.0f });
	
	vertices.emplace_back(LEFTUPFRONT,		XMFLOAT2{ 0.0f, 0.0f });
	vertices.emplace_back(RIGHTDOWNFRONT,	XMFLOAT2{ 1.0f, 1.0f });
	vertices.emplace_back(LEFTDOWNFRONT,	XMFLOAT2{ 0.0f, 1.0f });
	...
}

 

먼저 떠오르는 방법은, 위처럼 육면체의 바깥에 렌더링 하기 위해 시계 방향으로 육면체의 정점을 정의해 줬듯, 육면체의 내부에 렌더링 하기 위해 반시계 방향으로 육면체의 정점을 정의해 주는 방법입니다.

 

SkyboxShader::SkyboxShader(const ComPtr<ID3D12Device>& device, const ComPtr<ID3D12RootSignature>& rootSignature)
{
	...
	psoDesc.RasterizerState = CD3DX12_RASTERIZER_DESC(D3D12_DEFAULT);
	psoDesc.RasterizerState.CullMode = D3D12_CULL_MODE_FRONT;
	...
}

 

또 다른 방법으로는, 래스터라이저의 은면 제거 단계에서 뒷면이 아닌 앞면을 제거하도록 설정해 주는 방법입니다.

D3D12_GRAPHICS_PIPELINE_STATE_DESC 구조체의 RasterizerState.CullMode 값을 D3D12_CULL_MODE_FRONT로 바꿔 주게 되면, OpenGL처럼 오른손 법칙을 따라 은면 제거를 실시합니다. 정점의 위치를 일일이 바꿔 주기 번거롭기도 하고, 마침 스카이박스를 렌더링 하기 위해서 스카이박스 전용 셰이더가 필요하기 때문에 저는 이 방법을 채택하도록 하겠습니다.

 

class SkyboxMesh : public Mesh
{
private:
	struct Vertex
	{
		XMFLOAT3 position;
	};

public:
	SkyboxMesh(const ComPtr<ID3D12Device>& device, const ComPtr<ID3D12GraphicsCommandList>& commandList);
	~SkyboxMesh() override = default;
};

 

위 방법대로라면 메쉬의 정점 좌표를 수정할 필요가 없으니 기존 CubeMesh 클래스를 그대로 사용해도 무방하지만, 새 Mesh 클래스 SkyboxMesh를 정의해 주도록 하겠습니다.

정점 좌표는 수정할 필요가 없지만, 스카이박스를 렌더링 할 때 uv 값이 필요 없기 때문입니다.

 

HLSL
struct SKYBOX_VERTEX_INPUT
{
    float3 position : POSITION;
};

struct SKYBOX_PIXEL_INPUT
{
    float4 position : SV_POSITION;
    float3 lookup : LOOKUP;
};

SKYBOX_PIXEL_INPUT SKYBOX_VERTEX(SKYBOX_VERTEX_INPUT input)
{
    SKYBOX_PIXEL_INPUT output;
    output.position = mul(float4(input.position, 1.0f), g_worldMatrix);
    output.position = mul(output.position, g_viewMatrix);
    output.position = mul(output.position, g_projectionMatrix).xyww;
    output.lookup = input.position;

    return output;
}

float4 SKYBOX_PIXEL(SKYBOX_PIXEL_INPUT input) : SV_TARGET
{
    return g_textureCube.Sample(g_sampler, input.lookup);
}

 

큐브 텍스처를 렌더링 할 때도 2D 텍스처와 마찬가지로 샘플링을 위해 텍스처에 적절한 값을 전달해줘야 합니다. 앞서 Texture2D 형식의 텍스처를 샘플링할 때는 왼쪽 위를 (0, 0), 오른쪽 아래를 (1, 1)로 하는 uv 값을 전달해 줬습니다. 그러면 텍스처는 uv 값을 이용해 적절한 텍셀 값을 반환해 줍니다.

 

반면 TextureCube 형식의 텍스처를 샘플링할 때는 uv 값 대신 float3 형식의 조회 벡터(Lookup Vector)를 필요로 합니다. 그러면 텍스처는 TextureCube를 Texture2D 6개로 이루어진 입방체로 생각하여, 조회 벡터를 원점에서 쏘아 충돌하는 텍스처의 텍셀을 샘플링해 줍니다.

벡터를 구해야 한다는 점이, 비교적 직관적인 uv에 비해 난해하다고 느낄 수도 있겠지만, 복잡하게 생각할 것 없이 모델 좌표계의 정점을 그대로 조회 벡터로 사용해 주면 됩니다. 큐브 메쉬의 정점을 점이 아니라 벡터로 생각한다면, 버텍스 셰이더에 입력으로 주어지는 모델 좌표계 상의 정점은 각 정점을 향하는 벡터가 됩니다.

이 벡터가 래스터화를 거치면 각각의 프래그먼트가 가진 벡터는 이미 자신이 속한 입방체를 샘플링하는데 필요한 3차원 벡터로 보간 된 상태가 됩니다. 이 벡터를 그대로 샘플링에 사용해 주시면 됩니다.

 

 

이제 스카이박스를 화면의 어떤 지점에 어떻게 그리면 될지 생각해 보겠습니다.

스카이박스는 어떠한 물체도 가리면 안 됩니다. 뷰 프러스텀 내의 가장 먼 물체도 스카이박스보다 가까이 있어야 합니다. 그러나 아무것도 그려지지 않은 픽셀에는, 스카이박스가 그려져야 합니다.

어떻게 그려야 할까요?

SkyboxShader::SkyboxShader(const ComPtr<ID3D12Device>& device, const ComPtr<ID3D12RootSignature>& rootSignature)
{
	...
	psoDesc.DepthStencilState = CD3DX12_DEPTH_STENCIL_DESC(D3D12_DEFAULT);
	psoDesc.DepthStencilState.DepthEnable = false;
	...
}

 

먼저 셰이더에서 깊이 검사를 끈 후, 다른 모든 오브젝트보다 먼저 그리는 방법을 생각해 볼 수 있습니다.

괜찮은 방법이고 잘 동작합니다. 그러나 효율적이지 않은 부분이 있습니다. 스카이박스는 다른 모든 오브젝트보다 뒤에 있어야 합니다. 그렇기에 오브젝트가 많아질수록 스카이박스가 실제 렌더링되는 부분은 화면에서 매우 작아지고, 심지어 스카이박스가 전혀 렌더링 되지 않는 상황도 존재할 수 있습니다.

따라서 화면의 모든 픽셀에 대해 텍스처링 작업을 실시하여 렌더 타겟에 쓰인 스카이박스는 결국 다른 오브젝트들에 가려 대부분 사라지게 됩니다. 이는 꽤 비효율적입니다.

 

이는 비단 스카이박스에만 적용되는 문제가 아닙니다. 픽셀 셰이더를 통과한 프래그먼트가 출력 병합 단계에서 Z 버퍼링에 의해 다른 프래그먼트에 가려진다고 판정된다면, 여러 복잡한 연산을 마치고 출력된 프래그먼트는 아무 역할도 하지 못하고 폐기됩니다. 

따라서 래스터라이저는 현재 처리 중인 프래그먼트가 Z 버퍼 내의 프래그먼트에 가려질 것으로 판단될 경우, 프래그먼트를 픽셀 셰이더로 넘기지 않고 미리 폐기합니다. (이를 Z 컬링이라고 합니다.)

그렇기에 어떤 물체가 카메라에서 멀리 떨어져 있다면 나중에 그리는 것이 유리합니다. 이는 최대한 많은 프래그먼트가 Z 컬링을 통해 래스터라이저에서 미리 기각되도록 하여 그려지지 않을 프래그먼트가 픽셀 셰이더로 진입하지 못하게 하기 위함입니다. 이를 통해 상당한 GPU 자원을 절약할 수 있습니다.

 

class Scene
void Scene::Render(const ComPtr<ID3D12GraphicsCommandList>& commandList) const
{
	m_camera->UpdateShaderVariable(commandList);

	m_shaders.at("OBJECT")->UpdateShaderVariable(commandList);
	for (auto& object : m_objects) {
		object->Render(commandList);
	}
	m_player->Render(commandList);

	m_shaders.at("SKYBOX")->UpdateShaderVariable(commandList);
	m_skybox->Render(commandList);
}

 

그러나 위 방법대로라면 오히려 가장 멀리 떨어진 스카이박스를 가장 먼저 그리게 됩니다. 그렇다고 스카이박스를 가장 나중에 그리게 된다면, 깊이 검사에 포함되지 않은 스카이박스가 모든 오브젝트를 덮어쓰게 될 것입니다.

따라서 스카이박스를 깊이 검사에 포함시키고, 가장 나중에 그리겠습니다.

 

HLSL
...
SKYBOX_PIXEL_INPUT SKYBOX_VERTEX(SKYBOX_VERTEX_INPUT input)
{
    SKYBOX_PIXEL_INPUT output;
    output.position = mul(float4(input.position, 1.0f), g_worldMatrix);
    output.position = mul(output.position, g_viewMatrix);
    output.position = mul(output.position, g_projectionMatrix).xyww;
    output.lookup = input.position;

    return output;
}
...

 

스카이박스를 깊이 검사에 포함시킨다면 생각해 봐야 할 부분은 어떻게 해야 아무것도 그려지지 않은 픽셀에는 스카이박스를 그리되, 다른 어떤 오브젝트라도 그려진 픽셀에는 스카이박스를 그리지 않을 것인가에 대한 문제입니다.

아무것도 그려지지 않은 픽셀의 Z 버퍼에는 1.0이 들어가 있을 것이고, 다른 Z 버퍼에는 그보다 작은 값이 들어가 있을 것입니다. 따라서 투영 변환을 마친 동차 좌표가 래스터라이저에서 원근 나눗셈을 실시할 때, 그 결과의 z값이 1.0이 되도록 설정할 수 있다면 스카이박스의 프래그먼트를 깊이 버퍼의 가장 먼 위치로 지정할 수 있습니다.

저는 스위즐링(.xyww)을 통해 투영 변환을 마친 동차 좌표의 z값이 w와 같아지도록 설정했습니다. 이렇게 하면 NDC의 z 좌표는 항상 1.0이 됩니다.

 

class Shader
SkyboxShader::SkyboxShader(const ComPtr<ID3D12Device>& device, const ComPtr<ID3D12RootSignature>& rootSignature)
{
	...
	psoDesc.DepthStencilState = CD3DX12_DEPTH_STENCIL_DESC(D3D12_DEFAULT);
	psoDesc.DepthStencilState.DepthFunc = D3D12_COMPARISON_FUNC_LESS_EQUAL;
	...
}

 

이제 원근 나눗셈을 마친 z 좌표가 1.0이 되어 래스터화 단계에서의 효율적인 Z 컬링이 가능해졌습니다. 그러나 아무것도 그려지지 않은 픽셀(즉, 실제 스카이박스가 그려져야 할 픽셀)을 처리할 때 문제가 생깁니다.

출력 병합 단계에서 Z 버퍼링을 실시할 때, 픽셀이 교체되려면 새 프래그먼트의 z값이 Z 버퍼의 z값보다 '작아야' 합니다.

그러나 스카이박스의 z값과 아무것도 그려지지 않은 픽셀의 z값은 1.0으로 같기 때문에 픽셀이 교체되지 않습니다.

이를 처리하기 위해 D3D12_GRAPHICS_PIPELINE_STATE_DESC 구조체의 DepthStencilState.DepthFunc 값을 D3D12_COMPARISON_FUNC_LESS_EQUAL로 바꿔 주면, 작을 때뿐만 아니라 작거나 같을 때 픽셀을 교체하게 됩니다.

(기본값은 D3D12_COMPARISON_FUNC_LESS입니다.)

 

class Shader
SkyboxShader::SkyboxShader(const ComPtr<ID3D12Device>& device, const ComPtr<ID3D12RootSignature>& rootSignature)
{
	vector<D3D12_INPUT_ELEMENT_DESC> inputLayout = {
	{ "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 } };

#if defined(_DEBUG)
	UINT compileFlags = D3DCOMPILE_DEBUG | D3DCOMPILE_SKIP_OPTIMIZATION;
#else
	UINT compileFlags = 0;
#endif

	ComPtr<ID3DBlob> mvsByteCode, mpsByteCode;
	Utiles::ThrowIfFailed(D3DCompileFromFile(TEXT("shader.hlsl"), nullptr,
		D3D_COMPILE_STANDARD_FILE_INCLUDE, "SKYBOX_VERTEX", "vs_5_1", compileFlags, 0, &mvsByteCode, nullptr));
	Utiles::ThrowIfFailed(D3DCompileFromFile(TEXT("shader.hlsl"), nullptr,
		D3D_COMPILE_STANDARD_FILE_INCLUDE, "SKYBOX_PIXEL", "ps_5_1", compileFlags, 0, &mpsByteCode, nullptr));

	D3D12_GRAPHICS_PIPELINE_STATE_DESC psoDesc{};
	psoDesc.InputLayout = { inputLayout.data(), (UINT)inputLayout.size() };
	psoDesc.pRootSignature = rootSignature.Get();
	psoDesc.VS = {
		reinterpret_cast<BYTE*>(mvsByteCode->GetBufferPointer()),
		mvsByteCode->GetBufferSize() };
	psoDesc.PS = {
		reinterpret_cast<BYTE*>(mpsByteCode->GetBufferPointer()),
		mpsByteCode->GetBufferSize() };
	psoDesc.RasterizerState = CD3DX12_RASTERIZER_DESC(D3D12_DEFAULT);
	psoDesc.RasterizerState.CullMode = D3D12_CULL_MODE_FRONT;
	psoDesc.DepthStencilState = CD3DX12_DEPTH_STENCIL_DESC(D3D12_DEFAULT);
	psoDesc.DepthStencilState.DepthFunc = D3D12_COMPARISON_FUNC_LESS_EQUAL;
	psoDesc.BlendState = CD3DX12_BLEND_DESC(D3D12_DEFAULT);
	psoDesc.SampleMask = UINT_MAX;
	psoDesc.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE;
	psoDesc.NumRenderTargets = 1;
	psoDesc.RTVFormats[0] = DXGI_FORMAT_R8G8B8A8_UNORM;
	psoDesc.DSVFormat = DXGI_FORMAT_D24_UNORM_S8_UINT;
	psoDesc.SampleDesc.Count = 1;
	psoDesc.Flags = D3D12_PIPELINE_STATE_FLAG_NONE;
	Utiles::ThrowIfFailed(device->CreateGraphicsPipelineState(&psoDesc, IID_PPV_ARGS(&m_pipelineState)));
}

 

Shader 클래스의 변경점을 정리해 보겠습니다.

먼저 Mesh가 uv 좌표를 가지고 있지 않으니, Input Layout 역시 알맞게 수정해 줘야 합니다.

또한 RasterizerState에서 CullMode를 D3D12_CULL_MODE_FRONT로 설정하여, 은면 제거 시 앞면이 제거되도록 설정해 주었습니다. 그리고 DepthStencilState에서 DepthFunc를 D3D12_COMPARISON_FUNC_LESS_EQUAL로 설정하여, Z값이 작을 때뿐 아니라 같을 때도 교체되도록 설정해 주었습니다.

 


결과

 

 

스카이박스를 포함한 텍스처가 잘 렌더링 되고 있습니다!

 

다음 프로젝트에서는 높이 맵을 이용한 지형을 생성해 보겠습니다.