[Computer Graphics with DirectX 12] 05. Instancing

2024. 4. 25. 14:37Programming/Computer Graphics

05. Instancing 목표

  • 인스턴싱을 이용한 오브젝트 렌더링
    • 한 번의 렌더 명령으로 다수의 수풀 렌더링
    • 서로 다른 텍스처 리소스를 사용하는 수풀 역시 한 번의 렌더 명령으로 렌더링

 
이전 프로젝트에서 기하 셰이더를 이용해 빌보드를 렌더링 해 보았습니다. 괜찮은 결과가 나왔지만 빌보드의 개수가 조금만 많아져도 렉이 심해지고, 로딩 시간 역시 오래 걸렸습니다.
이는 수 만 개 오브젝트를 생성하며 업로드 버퍼를 초기화하고, 이 오브젝트들에 매 프레임 일일히 렌더링 명령을 내리기 때문입니다. 이 오브젝트들은 월드 변환 행렬(그리고 텍스처)만 다를 뿐, 같은 기하구조를 가지고 있습니다. 따라서 분명 반복되는 렌더링 과정 속에 비효율적인 부분이 존재합니다.
 
프로젝트 5에서는 인스턴싱을 이용하여 한 번의 렌더 명령으로 모든 빌보드가 그려지도록 구현해 보겠습니다. 약 63000개의 빌보드들이 한 번의 렌더링 파이프라인을 통과하는 것으로 렌더링 된다면 상당한 성능 향상이 있을 것입니다.
 

 

GitHub - SH4MDEL/DirectX12-ComputerGraphics

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

github.com

 
위 리포지토리에서 전체 소스코드를 확인하실 수 있습니다.
이 프로젝트의 소스코드는 05. Instancing입니다.


시작 전 주요 수정사항

 

 
지형의 고저차가 다소 크다는 생각이 들어 높이의 편차를 조정해 주었습니다.


설계

class Mesh
void MeshBase::Render(const ComPtr<ID3D12GraphicsCommandList>& commandList) const
{
	commandList->IASetPrimitiveTopology(m_primitiveTopology);
	commandList->IASetVertexBuffers(0, 1, &m_vertexBufferView);
	commandList->DrawInstanced(m_vertices, 1, 0, 0);
}

 
지금까지 Mesh 인스턴스에 렌더링 명령을 내리면 DrawInstanced 함수를 통해 정점 정보를 셰이더에 전송하였습니다. 사실 DrawInstanced 함수 자체가 인스턴싱을 통한 렌더링을 명령하는 함수입니다. DrawInstanced 함수의 두 번째 매개변수를 통해 몇 번 그릴지를 결정하게 됩니다.

즉 지금까지도 인스턴싱을 이용해 렌더링하는 중이었지만, 그리는 횟수가 1로 고정되어 있었다고 할 수 있습니다.
 

void MeshBase::Render(const ComPtr<ID3D12GraphicsCommandList>& commandList) const
{
	commandList->IASetPrimitiveTopology(m_primitiveTopology);
	commandList->IASetVertexBuffers(0, 1, &m_vertexBufferView);
	commandList->DrawInstanced(m_vertices, 2, 0, 0);
}

 
그렇다면 위와 같이 DrawInstanced의 두 번째 매개변수로 2를 전송하면 이 메쉬는 두 번 렌더링될까요? 물론 그렇습니다. 그러나 당연하게도 우리가 그것을 눈치챌 수는 없습니다.
 

HLSL
BILLBOARD_GEOMETRY_INPUT BILLBOARD_VERTEX(BILLBOARD_VERTEX_INPUT input)
{
    BILLBOARD_GEOMETRY_INPUT output;
    output.position = mul(float4(input.position, 1.0f), g_worldMatrix);
    output.size = input.size;
    return output;
}

 
왜냐하면 첫 번째 렌더링되는 메쉬나 두 번째 렌더링되는 메쉬나 같은 월드 행렬, 뷰 행렬, 투영 행렬을 가지고 렌더링되기 때문입니다. (물론 메쉬가 동일하므로 모델 좌표계 정점이나 uv 또한 완전히 같습니다.) 월드 공간의 같은 장소에 렌더링 될 것이고, 화면만 봐서는 그것이 한 번 그려졌는지 두 번 그려졌는지 알 방법이 없습니다.
 
돌아와서 우리가 하고 싶은 일은 같은 메쉬를 갖는 오브젝트 여러 개를 한 번에 렌더링하는 것입니다.
이 오브젝트들이 서로 다른 위치에 렌더링되도록 하는 차이는 각자가 가지고 있는 월드 변환 행렬입니다. 따라서 각자의 정점들이 서로 다른 월드 변환 행렬을 선택하여 월드 변환을 실시한 후 이를 다음 렌더링 패스(여기서는 기하 셰이더)로 보내도록 할 수 있다면 인스턴싱을 구현할 수 있습니다.
 
그러나 이를 구현하기 위한 다음과 같은 두 가지 문제가 있습니다. 

  • 지금 프로젝트에서는 한 번의 렌더링 파이프라인에 한 개의 월드 행렬밖에 존재할 수 없음.
    • 따라서 지금까지 오브젝트 클래스의 UpdateShaderVariable 메소드를 이용해 매 번 그리고자 하는 오브젝트의 월드 변환 행렬을 갱신해 주었음.
  • 렌더링 파이프라인에 N개의 월드 행렬을 입력한다고 해도 버텍스 셰이더가 N번 실행되는 동안 내가 몇 번째 월드 변환 행렬을 선택해야 할 지 결정할 수 없음.
BILLBOARD_GEOMETRY_INPUT BILLBOARD_VERTEX(BILLBOARD_VERTEX_INPUT input, 
	uint instanceID : SV_InstanceID)
{
    BILLBOARD_GEOMETRY_INPUT output;
    output.position = mul(float4(input.position, 1.0f), g_worldMatrix);
    output.size = input.size;
    return output;
}

 
두 번째 문제는 간단히 해결할 수 있습니다. 버텍스 셰이더의 입력으로 SV_InstanceID라는 Semantic Value를 갖는 매개 변수를 설정해 주면 메쉬가 N번 렌더링되는 동안 0부터 N-1까지의 정수가 차례로 입력됩니다.
따라서 N개의 월드 변환 행렬을 배열 형태로 미리 입력해 두고, 이 매개 변수를 이용해 차례로 배열을 선택해 월드 변환을 실시해 준다면 인스턴싱을 구현할 수 있습니다.
 
따라서 문제는 한 번의 렌더링 파이프라인에 여러 개의 월드 변환 행렬을 올리는 과정으로 단순화 되었습니다.
 


Instancing 구현

 

class UploadBuffer
struct BufferBase {};

template <typename T> requires derived_from<T, BufferBase>
class UploadBuffer
{
public:
	UploadBuffer(const ComPtr<ID3D12Device>& device, UINT rootParameterIndex,
        BOOL isConstantBuffer = false);
	~UploadBuffer();

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

private:
    void Copy(const T& data) const;

private:
    ComPtr<ID3D12Resource>  m_uploadBuffer;
    T*                      m_data;
    UINT                    m_byteSize;
    UINT                    m_rootParameterIndex;
    BOOL                    m_isConstantBuffer;
};

 
지난 프로젝트에서 상수 버퍼를 초기화하기 위한 UploadBuffer 클래스를 정의하였습니다. 이 클래스를 이용해 상수 버퍼에 원하는 데이터를 올릴 수 있게 되었습니다.
이 상수 버퍼를 이용해 하나의 월드 행렬을 올린 방식을 그대로 활용하여 여러 개의 월드 변환 행렬 역시 셰이더에 올릴 수 있습니다. 하지만 상수 버퍼에는 최대 4096 X 16바이트의 데이터밖에 올릴 수 없기 때문에 수만 개의 월드 변환 행렬을 저장할 장소로는 부족합니다.
따라서 인스턴싱을 구현할 때 월드 변환 행렬을 저장할 장소로 정점 버퍼 등을 활용하곤 하는데, 이 프로젝트에서는 셰이더 리소스인 Structured Buffer를 사용해 보겠습니다.
 

struct BufferBase {};

template <typename T> requires derived_from<T, BufferBase>
class UploadBuffer
{
public:
	UploadBuffer(const ComPtr<ID3D12Device>& device, UINT rootParameterIndex,
        UINT elementCount = 1, BOOL isConstantBuffer = true);
	~UploadBuffer();

    void UpdateRootConstantBuffer(const ComPtr<ID3D12GraphicsCommandList>& commandList, const T& data) const;
    void UpdateRootShaderResource(const ComPtr<ID3D12GraphicsCommandList>& commandList) const;

    void Copy(const T& data, UINT index = 0) const;

private:
    ComPtr<ID3D12Resource>  m_uploadBuffer;
    T*                      m_data;
    UINT                    m_byteSize;
    UINT                    m_rootParameterIndex;
    BOOL                    m_isConstantBuffer;
};

 
먼저 UploadBuffer 클래스를 상수 버퍼 뿐 아니라 셰이더 리소스로도 사용할 수 있도록 수정하겠습니다.
이전까지는 T 크기의 메모리만 확보해 주면 됐지만, 이제 N개의 월드 변환 행렬을 셰이더로 보내 줘야 하므로 T * N 크기의 메모리를 확보해 줄 필요가 있습니다. 따라서 추가로 N을 생성자로 받아오도록 수정하였습니다.
 

template<typename T> requires derived_from<T, BufferBase>
inline UploadBuffer<T>::UploadBuffer(const ComPtr<ID3D12Device>& device, 
    UINT rootParameterIndex, UINT elementCount, BOOL isConstantBuffer) :
    m_rootParameterIndex{ rootParameterIndex }, m_isConstantBuffer{ isConstantBuffer }
{
    if (m_isConstantBuffer) m_byteSize = ((sizeof(T) + 255) & ~255);
    else m_byteSize = sizeof(T);

    Utiles::ThrowIfFailed(device->CreateCommittedResource(
        &CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD),
        D3D12_HEAP_FLAG_NONE,
        &CD3DX12_RESOURCE_DESC::Buffer(m_byteSize * elementCount),
        D3D12_RESOURCE_STATE_GENERIC_READ,
        nullptr,
        IID_PPV_ARGS(&m_uploadBuffer)));

    Utiles::ThrowIfFailed((m_uploadBuffer->Map(0, nullptr, reinterpret_cast<void**>(&m_data))));
}

 
생성자에서는 sizeof(T) * elementCount 크기의 메모리를 업로드 버퍼에 할당합니다.
 

template<typename T> requires derived_from<T, BufferBase>
inline void UploadBuffer<T>::UpdateRootConstantBuffer(
    const ComPtr<ID3D12GraphicsCommandList>& commandList, const T& data) const
{
    Copy(data);

    if (m_isConstantBuffer) {
        commandList->SetGraphicsRootConstantBufferView(
            m_rootParameterIndex, m_uploadBuffer->GetGPUVirtualAddress());
    }
}

template<typename T> requires derived_from<T, BufferBase>
inline void UploadBuffer<T>::UpdateRootShaderResource(
    const ComPtr<ID3D12GraphicsCommandList>& commandList) const
{
    if (!m_isConstantBuffer) {
        commandList->SetGraphicsRootShaderResourceView(
            m_rootParameterIndex, m_uploadBuffer->GetGPUVirtualAddress());
    }
}

template<typename T> requires derived_from<T, BufferBase>
inline void UploadBuffer<T>::Copy(const T& data, UINT index) const
{
    memcpy(&m_data[index], &data, sizeof(T));
}

 
기존에 셰이더를 갱신하기 위한 UpdateShaderVariable 함수는 상수 버퍼를 갱신하기 위한 UpdateRootConstantBuffer 함수와, 셰이더 리소스를 갱신하기 위한 UpdateRootShaderResource함수로 구분해 분리하였습니다.
 

class Instance
struct InstanceData : public BufferBase 
{
	XMFLOAT4X4 worldMatrix;
	UINT textureIndex;
};

class Instance
{
public:
	Instance(const ComPtr<ID3D12Device>& device, const shared_ptr<MeshBase>& mesh, UINT maxObjectCount);

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

	void SetTexture(const shared_ptr<Texture>& texture);
	void SetObject(const shared_ptr<InstanceObject>& object);
	void SetObjects(const vector<shared_ptr<InstanceObject>>& objects);
	void SetObjects(vector<shared_ptr<InstanceObject>>&& objects);

private:
	shared_ptr<MeshBase> m_mesh;
	vector<shared_ptr<GameObject>> m_objects;
	UINT m_maxObjectCount;

	unique_ptr<UploadBuffer<InstanceData>> m_instanceBuffer;
};

 
이제 동일한 Mesh를 갖는 오브젝트들의 인스턴싱을 담당할 클래스인 Instance 클래스를 정의해 주었습니다.
이 클래스에서 단 한 번의 Render 명령을 호출하여 클래스가 관리하고 있는 모든 오브젝트를 렌더링 해 줄 것입니다.
 
셰이더 리소스로 보낼 InstanceData 구조체는 월드 변환 행렬 뿐만 아니라 텍스처 인덱스 또한 관리하고 있습니다.
이전 프로젝트에서 하나의 Texture 클래스가 여러 텍스처 자원을 관리하고, 배열을 통해 각각의 텍스처에 접근할 수 있도록 수정하였는데, 이에 따라 서로 다른 텍스처 자원을 갖는 오브젝트라고 할지라도 어떤 텍스처를 사용할지에 대한 정보만 가지고 있다면 한 번에 인스턴싱이 가능해졌기 때문입니다.
 

HLSL
struct InstanceData
{
    float4x4 worldMatrix;
    uint textureIndex;
};
StructuredBuffer<InstanceData> g_instanceData : register(t0, space1);

 
HLSL에 같은 구조체를 선언해 주고, InstanceData 구조체를 템플릿 인수로 사용하는 StructuredBuffer 역시 선언해 주었습니다. StructuredBuffershaderRegister는 텍스처와 같이 t0으로 지정하고, registerSpacespace1로 설정하여 충돌이 발생하지 않게 하였습니다.
 

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

	CD3DX12_ROOT_PARAMETER rootParameter[5];
	rootParameter[RootParameter::GameObject].InitAsConstantBufferView(0);
	rootParameter[RootParameter::Camera].InitAsConstantBufferView(1);
	rootParameter[RootParameter::Instance].InitAsShaderResourceView(0, 1);
	rootParameter[RootParameter::TextureCube].InitAsDescriptorTable(1,
		&descriptorRange[DescriptorRange::TextureCube], D3D12_SHADER_VISIBILITY_PIXEL);
	rootParameter[RootParameter::Texture].InitAsDescriptorTable(1,
		&descriptorRange[DescriptorRange::Texture], D3D12_SHADER_VISIBILITY_PIXEL);

	...
}

 
루트 시그니처 또한 알맞게 수정해 주었습니다. 인스턴싱용 업로드 버퍼 셰이더 리소스 뷰를 생성하는 함수의 인자가 각각 0(shaderRegister), 1(registerSpace)임을 확인하실 수 있습니다.
 

class Instance
Instance::Instance(const ComPtr<ID3D12Device>& device, const shared_ptr<MeshBase>& mesh, UINT maxObjectCount)
	: m_mesh{ mesh }, m_maxObjectCount{ maxObjectCount }
{
	m_instanceBuffer = make_unique<UploadBuffer<InstanceData>>(device,
		(UINT)RootParameter::Instance, m_maxObjectCount, false);
}

 
Instance 클래스의 생성자에서는 인스턴싱할 메쉬의 개수를 받아 셰이더 리소스 업로드 버퍼를 생성합니다.
 

void Instance::Render(const ComPtr<ID3D12GraphicsCommandList>& commandList) const
{
	UpdateShaderVariable(commandList);

	m_mesh->Render(commandList, m_objects.size());
}

void Instance::UpdateShaderVariable(const ComPtr<ID3D12GraphicsCommandList>& commandList) const
{
	for (int i = 0; const auto & object : m_objects) {
		InstanceData buffer;
		object->UpdateShaderVariable(buffer);
		m_instanceBuffer->Copy(buffer, i++);
	}
	m_instanceBuffer->UpdateRootShaderResource(commandList);
	if (m_texture) m_texture->UpdateShaderVariable(commandList);
}

 
렌더링 명령을 내리면 클래스가 관리하는 모든 오브젝트를 순회하며 월드 변환 행렬과 텍스처 인덱스 정보를 복사해 줍니다. 순회가 끝나면 UpdateRootShaderResource 메소드를 호출하여 복사한 정보를 셰이더에 올립니다.
 

HLSL
struct BILLBOARD_VERTEX_INPUT
{
    float3 position : POSITION;
    float2 size : SIZE;
};

struct BILLBOARD_GEOMETRY_INPUT
{
    float4 position : POSITION;
    float2 size : SIZE;
    uint textureIndex : TEXINDEX;
};

struct BILLBOARD_PIXEL_INPUT
{
    float4 position : SV_POSITION;
    float3 normal : NORMAL;
    float2 uv : TEXCOORD;
    nointerpolation uint textureIndex : TEXINDEX;
};

 
상술했듯 버텍스 셰이더의 입력으로 현재 몇 번째 메쉬를 인스턴싱 하고 있는지에 대한 정보가 들어옵니다. 이를 인덱스로 하여 월드 변환 행렬과 텍스처 인덱스를 확인할 수 있습니다. 따라서 이 때 버텍스 셰이더의 입력으로 확인한 텍스처 인덱스를 픽셀 셰이더까지 전달해 줘야 합니다. 그래야 픽셀 셰이더에서 어떤 텍스처를 선택할 지 결정할 수 있습니다.
 
텍스처 인덱스가 래스터화를 거칠 때 주변 정점과 보간되어서는 안 됩니다. 따라서 nointerpolation 키워드를 붙여 이를 명시해 주었습니다.
 

BILLBOARD_GEOMETRY_INPUT BILLBOARD_VERTEX(BILLBOARD_VERTEX_INPUT input, uint instanceID : SV_InstanceID)
{
    BILLBOARD_GEOMETRY_INPUT output;
    InstanceData instData = g_instanceData[instanceID];
    output.position = mul(float4(input.position, 1.0f), instData.worldMatrix);
    output.size = input.size;
    output.textureIndex = instData.textureIndex;
    return output;
}

 
버텍스 셰이더에서는 instanceID를 인덱스로 하여 InstanceData 배열을 참조하고, 참조한 월드 변환 행렬을 이용해 월드 변환을 실시해 줍니다. 상술했듯 0~N-1까지의 값이 차례로 들어오기 때문에 서로 다른 N번의 월드 변환을 실시해 줄 수 있습니다.
 
기하 셰이더에서는 버텍스 셰이더로부터 넘어온 텍스처 인덱스를 픽셀 셰이더로 전달해주는 부분이 추가된 점 외에 변경점은 없습니다.
 

float4 BILLBOARD_PIXEL(BILLBOARD_PIXEL_INPUT input) : SV_TARGET
{
    return g_texture[input.textureIndex].Sample(g_sampler, input.uv);
}

 
픽셀 셰이더에서는 이 텍스처 인덱스를 통해 텍스처를 샘플링하여 화면에 출력합니다.
 

class Scene
inline void Scene::BuildTextures(const ComPtr<ID3D12Device>& device,
	const ComPtr<ID3D12GraphicsCommandList>& commandList)
{
	...

	auto grassTexture = make_shared<Texture>(device);
	grassTexture->LoadTexture(device, commandList,
		TEXT("../Resources/Textures/Grass01.dds"), RootParameter::Texture);
	grassTexture->LoadTexture(device, commandList,
		TEXT("../Resources/Textures/Grass02.dds"), RootParameter::Texture);
	grassTexture->LoadTexture(device, commandList,
		TEXT("../Resources/Textures/Grass03.dds"), RootParameter::Texture);
	grassTexture->LoadTexture(device, commandList,
		TEXT("../Resources/Textures/Grass04.dds"), RootParameter::Texture);
	grassTexture->CreateShaderVariable(device);
	m_textures.insert({ "GRASS", grassTexture });
}

 
Scene 클래스에서는 한 번의 렌더링 파이프라인이 진행되는 동안 셰이더에서 여러 텍스처에 접근할 수 있도록 처리해주기 위해 하나의 Texture 클래스가 모든 수풀 관련 텍스처를 관리하도록 수정하였습니다.
 

inline void Scene::BuildObjects(const ComPtr<ID3D12Device>& device)
{
	...

	vector<shared_ptr<GameObject>> grasses;
	for (int x = -127; x <= 127; x += 1) {
		for (int z = -127; z <= 127; z += 1) {
			FLOAT fx = static_cast<FLOAT>(x);
			FLOAT fz = static_cast<FLOAT>(z);
			auto grass0 = make_shared<GameObject>();
			grass0->SetPosition(XMFLOAT3{ fx, m_terrain->GetHeight(fx, fz), fz });
			grass0->SetTextureIndex(grasses.size() % 4);
			grasses.push_back(grass0);
		}
	}

	m_instanceBillboard = make_unique<Instance>(device,
		static_pointer_cast<Mesh<TextureVertex>>(m_meshes["BILLBOARD"]), static_cast<UINT>(grasses.size()));
	m_instanceBillboard->SetObjects(move(grasses));
	m_instanceBillboard->SetTexture(m_textures["GRASS"]);
}

 
BuildObjects 메소드에서는 이전과 같은 방법으로 수풀을 렌더링해주되, 생성한 오브젝트들을 Instance 클래스에 넣어 주었습니다. 
 

void Scene::Render(const ComPtr<ID3D12GraphicsCommandList>& commandList) const
{
	...

	m_shaders.at("BILLBOARD")->UpdateShaderVariable(commandList);
	m_instanceBillboard->Render(commandList);

	...
}

 
그리고 모든 Object를 돌면서 렌더링 명령을 내려주는 대신, Instance 클래스에서 단 한 번의 렌더 명령만 내려주도록 수정하였습니다.
 

 
그 결과 약 6만 3천 개의 수풀이 잘 렌더링되고, 프레임 또한 매우 안정적으로 유지됩니다!
다만 실행 후 로딩 시간이 오래 걸리는 문제는 여전히 존재함을 확인할 수 있었습니다.
 

class GameObject
GameObject::GameObject(const ComPtr<ID3D12Device>& device) : Object()
{
	m_constantBuffer = make_unique<UploadBuffer<ObjectData>>(device, (UINT)RootParameter::GameObject);
}

 
GameObject 클래스의 생성자에서는 월드 변환 행렬을 셰이더에 전송하기 위한 업로드 버퍼를 생성합니다. 인스턴싱용 오브젝트는 월드 변환 행렬을 직접 셰이더에 전송하지 않기에 당연히 필요 없는 작업입니다.
월드 변환 행렬을 참조하기 위해 여전히 63000개 오브젝트를 생성하고 있기에 실제로 사용되지 않을 수 만 개의 업로드 버퍼를 생성하고 있습니다.
 

class GameObject
{
public:
	GameObject(const ComPtr<ID3D12Device>& device);
	virtual ~GameObject() = default;

	virtual void Update(FLOAT timeElapsed);
	virtual void Render(const ComPtr<ID3D12GraphicsCommandList>& commandList) const;
	virtual void UpdateShaderVariable(const ComPtr<ID3D12GraphicsCommandList>& commandList) const;
	void UpdateShaderVariable(InstanceData& buffer);

	void Transform(XMFLOAT3 shift);
	void Rotate(FLOAT pitch, FLOAT yaw, FLOAT roll);

	void SetMesh(const shared_ptr<MeshBase>& mesh);
	void SetTexture(const shared_ptr<Texture>& texture);
	void SetTextureIndex(UINT textureIndex);

	void SetPosition(XMFLOAT3 position);
	XMFLOAT3 GetPosition() const;

protected:
	XMFLOAT4X4			m_worldMatrix;

	XMFLOAT3			m_right;
	XMFLOAT3			m_up;
	XMFLOAT3			m_front;

	shared_ptr<MeshBase>	m_mesh;
	shared_ptr<Texture>		m_texture;
	UINT					m_textureIndex;

	unique_ptr<UploadBuffer<ObjectData>> m_constantBuffer;
};

 
그 외에도 GameObject 클래스를 살펴 보면 인스턴싱되지 않을 오브젝트에게 필요 없는 메소드(SetTextureIndex)와 변수(m_textureIndex)가 존재하고, 반대로 인스턴싱될 오브젝트에게 필요 없는 메소드(SetMesh, SetTexture)와 변수(m_mesh, m_texture, m_constantBuffer) 등이 혼재되어 매우 지저분하다는 느낌이 듭니다.
 
따라서 GameObject 클래스를 조금 더 추상화한 고수준의 클래스를 하나 만들고 이를 상속받아 인스턴싱 될 오브젝트가 사용할 클래스와 인스턴싱 되지 않을 오브젝트가 사용할 클래스를 각각 새롭게 정의해 주겠습니다.
 

class Object
class Object abstract
{
public:
	Object();

	virtual void Update(FLOAT timeElapsed) = 0;

	void Transform(XMFLOAT3 shift);
	void Rotate(FLOAT pitch, FLOAT yaw, FLOAT roll);

	void SetPosition(XMFLOAT3 position);
	XMFLOAT3 GetPosition() const;

protected:
	XMFLOAT4X4			m_worldMatrix;

	XMFLOAT3			m_right;
	XMFLOAT3			m_up;
	XMFLOAT3			m_front;
};

 
월드 변환 행렬만을 관리하는 추상 클래스 Object입니다. 월드 변환 행렬만은 인스턴싱하든 그렇지 않든 반드시 소유하고 있어야 하기 때문에 상위 클래스로 분리해 주었습니다.
 

class GameObject : public Object
{
public:
	GameObject(const ComPtr<ID3D12Device>& device);
	virtual ~GameObject() = default;

	virtual void Update(FLOAT timeElapsed) override;
	virtual void Render(const ComPtr<ID3D12GraphicsCommandList>& commandList) const;
	virtual void UpdateShaderVariable(const ComPtr<ID3D12GraphicsCommandList>& commandList) const;

	void SetMesh(const shared_ptr<MeshBase>& mesh);
	void SetTexture(const shared_ptr<Texture>& texture);

protected:
	shared_ptr<MeshBase>	m_mesh;
	shared_ptr<Texture>	m_texture;

	unique_ptr<UploadBuffer<ObjectData>> m_constantBuffer;
};

 
인스턴싱되지 않을 오브젝트, 즉 기존 오브젝트들이 사용할 GameObject 클래스입니다.
 

struct InstanceData;
class InstanceObject : public Object
{
public:
	InstanceObject();
	virtual ~InstanceObject() = default;

	virtual void Update(FLOAT timeElapsed) override;
	void UpdateShaderVariable(InstanceData& buffer);

	void SetTextureIndex(UINT textureIndex);

protected:
	UINT				m_textureIndex;
};

 
인스턴싱되는 오브젝트들이 사용할 InstanceObject 클래스입니다.
당연히 생성자에서 업로드 버퍼를 생성하지 않으며, 사용하지 않을 포인터들을 가지고 있지 않기 때문에 좀 더 가볍습니다.
 

inline void Scene::BuildObjects(const ComPtr<ID3D12Device>& device)
{
	m_player = make_shared<Player>();
	m_player->SetPosition(XMFLOAT3{ 0.f, 0.f, 0.f });
	m_player->SetTextureIndex(0);

	for (int x = -10; x <= 10; x += 5) {
		for (int y = 0; y <= 20; y += 5) {
			for (int z = -10; z <= 10; z += 5) {
				auto object = make_shared<RotatingObject>();
				object->SetPosition(XMFLOAT3{
					static_cast<FLOAT>(x),
					static_cast<FLOAT>(y),
					static_cast<FLOAT>(z) });
				object->SetTextureIndex(1);
				m_objects.push_back(object);
			}
		}
	}
	m_instanceObject = make_unique<Instance>(device,
		static_pointer_cast<Mesh<TextureVertex>>(m_meshes["CUBE"]), static_cast<UINT>(m_objects.size() + 1));
	m_instanceObject->SetObjects(m_objects);
	m_instanceObject->SetObject(m_player);
	m_instanceObject->SetTexture(m_textures["CUBE"]);
    
    
	...
    

	vector<shared_ptr<InstanceObject>> grasses;
	for (int x = -127; x <= 127; x += 1) {
		for (int z = -127; z <= 127; z += 1) {
			FLOAT fx = static_cast<FLOAT>(x);
			FLOAT fz = static_cast<FLOAT>(z);
			auto grass = make_shared<InstanceObject>();
			grass->SetPosition(XMFLOAT3{ fx, m_terrain->GetHeight(fx, fz), fz });
			grass->SetTextureIndex(grasses.size() % 4);
			grasses.push_back(grass);
		}
	}

	m_instanceBillboard = make_unique<Instance>(device,
		static_pointer_cast<Mesh<TextureVertex>>(m_meshes["BILLBOARD"]), static_cast<UINT>(grasses.size()));
	m_instanceBillboard->SetObjects(move(grasses));
	m_instanceBillboard->SetTexture(m_textures["GRASS"]);
}

 
다시 InstanceObject를 이용하여 빌보드 객체를 생성해 주도록 수정했습니다. 더불어 빌보드 뿐 아니라 회전하는 정육면체 또한 인스턴싱을 통해 렌더링 되도록 구현해 주었습니다.
플레이어 역시 회전하는 정육면체와 같은 메쉬를 사용하므로, 서로 다른 텍스처를 사용함에도 한 번에 인스턴싱 할 수 있습니다.


결과

 

 

 
프로그램 실행 시 로딩 시간 문제도 사라졌고, 약 63000개의 빌보드가 높은 프레임을 유지하며 잘 렌더링됩니다!
 

 
심지어 빌보드를 다시 4배로 늘려 25만 개 이상의 빌보드를 렌더링해도 용납 가능한 수준의 FPS를 유지하는 것을 확인할 수 있었습니다. (참고로 모든 성능 측정은 드라마틱한 성능 비교를 위해 디버그 모드에서 실시하고 있습니다.)
 
다음 프로젝트에서는 터레인 테셀레이션을 통해 지형을 더욱 매끄럽게 출력해 보겠습니다.