DirectX

67_인스턴싱_MeshRenderer(인스턴싱)

devRiripong 2024. 3. 14.
반응형

인스턴싱 코드를 만들어 봤는데 그다음은 지금까지 만든 프레임워크랑 통합을 해서 관리할 수 있게끔 하나씩 만들어 보는 게 목표라고 보면 된다.

 

InstancingDemo 클래스를 Game 필터로 옮기고 Week2, Week3필터는 삭제한다.

 

원래 작업하던 방식이 메쉬를 먼저 만들어 놨었다.

MeshRenderer 다음에 ModelRenderer 그 다음에 Animator를 만들어 놨으니까 그 순서대로 하나씩 수정을 해본다.

하나씩 만들어 본 다음에 동시에 관리할 수 있게 통합을 하는게 목표다.

 

1. MeshInstancingDemo 클래스와 20. MeshInstancingDemo.fx를 만들고 세팅하기

InstancingDemo클래스를 복붙하고 이름을 MeshInstancingDemo라고 한다.

Client/Game필터에 넣는다.

코드를 MeshInstancingDemo에 맞게 수정한다.

	_shader = make_shared<Shader>(L"20. MeshInstancingDemo.fx");

 

Main.cpp에 가서

#include "MeshInstancingDemo.h"
desc.app = make_shared<MeshInstancingDemo>();

이렇게 세팅하고

 

Shaders의 19. InstancingDemo.fx를 복붙하고

이름을 20. MeshInstancingDemo.fx라고 한다.

Client/Shader/Week3 필터에 넣는다.

 

2. 목표 - _mesh, _material, _worlds, _instanceBuffer를 Engine에서 관리하는 거

지난 시간에 간단하게 테스트 했던 코드가 Mesh니까 _mesh 요 아이를 엔진 코드랑 합쳐가지고 깔끔하게 관리하는 게 목적이다.

 

지난 시간에 만든 InstancingDemo.h에 있던

	shared_ptr<Mesh> _mesh; // 원본 메시
	shared_ptr<Material> _material; 

mesh와 material 짝을 관리해서 동일하면 같이 묶어서 그려주는 아이가 하나 있으면 좋을 거 같다.

 

그런 아이마다

	shared_ptr<VertexBuffer> _instanceBuffer; 

_instatnceBuffer가 있을 거 같고, 그 InstanceBuffer에 따라서 실질적으로 GPU에 복사를 해서 데이터를 건네준 다음에 작업을 하는 식으로 해야 하기 때문에

 

private:
	// INSTANCING
	shared_ptr<Mesh> _mesh; // 원본 메시
	shared_ptr<Material> _material; 

	vector<Matrix> _worlds; // 물체들의 온갖 transform 정보, 월드 변환행렬을 들고 있는 거  
	shared_ptr<VertexBuffer> _instanceBuffer; 

이 4가지 아이가 핵심이라고 보면 된다.

_world는 임시로 만들어서 _instanceBuffer에 밀어넣는 방식으로 해주면 되고

 

전 시간에 테스트 했을 때는 instance buffer를 만들어주고 평해주면 되긴 하다.

 

다만 _mesh, _material, _worlds, _instanceBuffer 이거를 묶어서 중앙에서 관리를 하는 게 머리가 아픈 부분인데

그거를 하기 위해서 여러가지 방법이 있을 수 있다.

 

 

3. InstancingBuffer 클래스 만들기 

유니티 방식으로 하기 위해서 manager를 하나 더 만들어 준다.

RenderManager에서 이어서 해도 되지만 안 건드리는 선에서 하기 위해 InstancingManager를 파준다.

엔진 코드도 구조를 다듬으면서 한다.

 

Engine/02. Managers필터에 새로운 클래스를 추가한다.

이름을 InstancingManager라고 한다.

 

지난 시간에 만든 InstancingDemo.h에 _instatnceBuffer 라는게 있었는데

	vector<Matrix> _worlds; // 물체들의 온갖 transform 정보, 월드 변환행렬을 들고 있는 거  
	shared_ptr<VertexBuffer> _instanceBuffer; 

이걸 묶어서 하나의 클래스로 파주면 편리학지 않을까 생각이 든다.

 

같은 아이들의 데이터를 싹다 묶어 가지고 한 번에 관리할 수 있는 그런 개념을 조금 더 상위 개념으로 둬가지고 관리할 것인데

그거를 InstancingBuffer라고 이름을 지어본다.

 

Engine/01. Graphics/Buffer필터에 InstancingBuffer라는 클래스를 파주도록 한다.

#pragma once

class VertexBuffer; 

struct InstancingData
{
	Matrix world; 
};

#define MAX_MESH_INSTANCE 500

class InstancingBuffer
{
public: 
	InstancingBuffer(); 
	~InstancingBuffer(); 

public:
	void ClearData(); // 매 프레임마다 물체들이 갱신 되기 때문에 싹 비워준 다음에 데이터를 채워준다. 그 데이터를 누군가가 대표해서 쉐이더 쪽으로 밀어넣는 식으로 작업을 한다.
	void AddData(InstancingData& data); // 매 프레임마다 정보를 밀어 넣기 위한 함수
	void PushData(); 

public:
	uint32 GetCount() { return static_cast<uint32>(_data.size()); }
	shared_ptr<VertexBuffer> GetBuffer() { return _instanceBuffer; }

private:
	void CreateBuffer(uint32 maxCount = MAX_MESH_INSTANCE); // 벡터가 늘어났다 줄어들었다 하는 것처럼 늘어날 수 있으니까

private:
	// uint64			_instanceId = 0; 
	shared_ptr<VertexBuffer>	_instanceBuffer; // shader에 pushData로 넣어줄 예정
	uint32				_maxCount = 0; 
	vector<InstancingData>		_data; 
};

이 정도 있으면 1차적인 작업은 할 수 있다.

 

이제 하나씩 함수를 구현한다.

#include "pch.h"
#include "InstancingBuffer.h"

InstancingBuffer::InstancingBuffer()
{
	CreateBuffer(MAX_MESH_INSTANCE); 
}

InstancingBuffer::~InstancingBuffer()
{
}

void InstancingBuffer::ClearData()
{
	_data.clear(); 
}

void InstancingBuffer::AddData(InstancingData& data)
{
	_data.push_back(data); 
}

void InstancingBuffer::PushData()
{
	const uint32 dataCount = GetCount(); 
	if (dataCount > _maxCount)
		CreateBuffer(dataCount); 

	D3D11_MAPPED_SUBRESOURCE subResource; 

	DC->Map(_instanceBuffer->GetComPtr().Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &subResource); 
	{
		::memcpy(subResource.pData, _data.data(), sizeof(InstancingData) * dataCount); 
	}
	DC->Unmap(_instanceBuffer->GetComPtr().Get(), 0); 
	
	_instanceBuffer->PushData(); // GPU에 던져줘야 한다.
}

void InstancingBuffer::CreateBuffer(uint32 maxCount)
{
	_maxCount = maxCount; 
	_instanceBuffer = make_shared<VertexBuffer>(); 

	vector<InstancingData> temp(maxCount); 
	_instanceBuffer->Create(temp, /*slot*/1, /*cpuWrite*/true); // cpuWrite가 true여야 매 프래임마다 _instanceBuffer를 수정할 수 있다. 
	// 이제 얼마든지 데이터를 갱신할 수 있는데 갱신하기 위한 정보를 pushData에서 작업을 해준다. 
}

이렇게 만들어 놓으면 InstancingBuffer라는 클래스를 통해서

InsatncingDemo::Init에서 힘들게 했었던

	// INSTANCING
	_instanceBuffer = make_shared<VertexBuffer>(); 

	for (auto& obj : _objs)
	{
		Matrix world = obj->GetTransform()->GetWorldMatrix();
		_worlds.push_back(world); 
	}

	_instanceBuffer->Create(_worlds, /*slot*/ 1); 

이런 부분들을 깔끔하게 할 수 있게 된다.

InstancingDemo::Init에서는 한 번만 세팅을 하고 고치지 않았으니까 편한 것처럼 보였겠지만 데이터가 바뀐다고 하면 간단하게 한 번만 만들고 하는 건 아니기 때문에 InstancingBuffer에 수정이 가능한 버퍼를 파줬고, 그게 InstancingBuffer의 개조라고 보면 된다.

 

Engine을 빌드한다.

 

4. InstancingManager 만들기

InstancingBuffer가 완료 되었으면 InstancingManager로 돌아가서 계속 케어를 해보도록 한다.

 

Define.h에 가서

#define INSTANCING	GET_SINGLE(InstancingManager)

 

많이 사용할 애니까 EnginePch.h에 가서

#include "InstancingManager.h"

이렇게 추가해준다.

 

InstancingManager코드로 가서 작업을 해준다.

비슷한 애들 끼리 둘둘 모아서 관리를 하겠다가 핵심 콘셉트이다.

아이디를 하나 만들어서 작업을 하면 된다. 인스턴싱을 하는 애들도 결국에는 메쉬랑 material이랑 관련된 두 개의 짝으로 이루어져 있고 일단은 볼 수가 있으니까 그걸 이용해 가지고 뭔가 아이디를 만들어 주면 되는데

 

1) 아이디를 포인터로 관리하기 

아이디를 발급하는 걸 어떻게 할지가 고민이다.

 

고민 끝에 어차피 우리가 사용하는 모든 포인터는 스마트 포인터로 관리하고 있고, 그러다 보니까 머테리얼이나 메쉬나 주소값으로 아이디를 판별해 가지고 같은 주소값이면 같은 거라고 판별을 해도 무방할 거 같아서 그 방식으로 사용해 보도록 한다.

그게 아니면 어떻게든 리소스를 관리해서 그 리소스에다가 아이디를 부여한 다음에 그런 식으로 관리하는 것도 하나의 방법이다. 하지만 툴로 드래그 앤 드롭해서 관리할 수 있는 게 아니니 여기서는 포인터로 관리를 해본다.

 

Types.h에 가서

// MeshID / MaterialID
using InstanceID = std::pair<uint64, uint64>; 

 

얘네들이 짝이다 보니까 두 개가 동시에 같아야지만 같은 걸로 인식을 한다.

 

InstancingManager.h로 돌아와서

등록했다가 등록해제 하는 것까지 케어하기에는 어려우니 여기서는 현재 프레임에 그려야 하는 모든 물체들을 Render라는 인자에다가 다 넘겨줄 것이고, Render에서 그걸 재활용하듯 GameObject 중에서 실질적으로 인스턴싱이 되어야 하는 애들을 골라서

map<InstanceID, shared_ptr<InstancingBuffer>> _buffers;

여기에 넣어주는 식으로 해주게 될 것이다.

 

MeshRenderer에 가서 GetInstanceID라는 함수를 만들어 준다.

MeshRenderer.h에서 레거시 코드를 삭제한다.

	uint8 _pass = 0; 
	void SetMaterial(shared_ptr<Material> material) { _material = material; }
	void SetPass(uint8 pass) { _pass = pass;  }

	InstanceID GetInstanceID(); 

이렇게 추가하고,

MeshRenderer.cpp에 가서 GetInstanceID를 정의한다.

InstanceID MeshRenderer::GetInstanceID()
{
	return make_pair((uint64)_mesh.get(), (uint64)_material.get()); // get()으로 포인터를 얻어올 수 있다. 주소값 2개로 짝을 이뤄 아이디를 발급해줬다.
	// 극단적인 예외로 메모리 풀링을 하는데 머테리얼을 사용하다가 머테리얼을 삭제했고, 다시 발급을 했는데 같은 주소를 차지하는 확률을 생각하면 이방식이 맞진 않지만 여기선 이렇게 한다. 
	// mesh랑 material은 리소스로 계속 들고 있을 거라 괜찮지만 서버를 만들 때는 극악의 확률도 생각해서 만들어야 한다. 
}

주소값 2개로 아이디를 발급해 준 형태가 된다.

 

2) 대표가 총대 메는 MeshRenderer::RenderInstancing

MeshRenderer에서 RenderInstancing이라는 걸 대표적으로 아이가 해주면 된다 라는 얘기가 되는데, 원래 방식에서는 Update를 하다가 자기 자신을 그려주는 역할을 하게 되는데 지금 방식대로라면 기존의 MeshRenderer::Update의 코드가 호출이 되면 안 되는 것이다. 자기 자신만을 담당하는 애기 때문에 이 함수는 막혀야 한다. 그래서 삭제를 한다.

 

원하는 방식으로 만들어 준다.

	void RenderInstancing(shared_ptr<class InstancingBuffer>& buffer); 
void MeshRenderer::RenderInstancing(shared_ptr<class InstancingBuffer>& buffer)
{
	if (_mesh == nullptr || _material == nullptr)
		return; 

	auto shader = _material->GetShader(); 
	if (shader == nullptr)
		return; 

	// Light
	_material->Update(); 

	_mesh->GetVertexBuffer()->PushData(); 
	_mesh->GetIndexBuffer()->PushData(); 
	buffer->PushData(); // instancing buffer에서 pushData를 하면 world이긴 한데 나의 world가 아니라 10000개라면 10000개 모두의 world가 들어가 있다. 그걸 밀어 넣는거 

	shader->DrawIndexedInstanced(0, _pass, _mesh->GetIndexBuffer()->GetCount(), buffer->GetCount());  
}

대표적으로 그려줄 애 한 명이 모든 걸 그려줬다.

Update 할 때는 가만히 있다가

대표를 RenderManager에서 지정해 주면 대표적으로 그려주게 RenderInstancing 함수를 작업했다.

 

3) InstancingManager

결국 목적 자체가 같은 인스턴스끼리는 뭉쳐서 한 번만 그려야 한다가 목표여서 이 방식으로 간단하게 만들 수 있다.

#pragma once
#include "InstancingBuffer.h"

class GameObject; 

class InstancingManager
{
	DECLARE_SINGLE(InstancingManager); 

public: 
	void Render(vector<shared_ptr<GameObject>>& gameObjects);
	void ClearData(); // 이전 프레임에 등록되어 있는 데이터는 날려주는거 

private:
	// MeshRenderer를 들고 있는 object만 보여주는 함수
	void RenderMeshRenderer(vector<shared_ptr<GameObject>>& gmaeObjects);

private:
	void AddData(InstanceID instanceId, InstancingData& data);

private: 
	map<InstanceID, shared_ptr<InstancingBuffer>> _buffers;
};

#include "pch.h"
#include "InstancingManager.h"
#include "GameObject.h"
#include "MeshRenderer.h"
#include "ModelRenderer.h"
#include "ModelAnimator.h"

void InstancingManager::Render(vector<shared_ptr<GameObject>>& gameObjects)
{

	ClearData(); 

	RenderMeshRenderer(gameObjects); // 매 프레임마다 모으는 작업
}

// map을 날리는게 아니라 InstancingBuffer에 있는 vector<InstancingData>	 _data; 이걸 날린다는 얘기다
void InstancingManager::ClearData() 
{
	// 싹 다 밀어준다.
	for (auto& pair : _buffers)
	{
		shared_ptr<InstancingBuffer>& buffer = pair.second; // shared_ptr의 레퍼런스 카운트를 1 늘리는게 아토믹해서 부담이 크기 때문에 일시적으로 활용할 때는 복사하지 않고 참조값을 붙여서 쓴다. 
		buffer->ClearData(); 
	}
}

void InstancingManager::RenderMeshRenderer(vector<shared_ptr<GameObject>>& gameObjects)
{
	map<InstanceID, vector<shared_ptr<GameObject>>> cache; 

	// 분류 단계
	for (shared_ptr<GameObject>& gameObject : gameObjects) //  &를 써서 레퍼런스 카운터를 안가지고 와서 쓰면 문제가 되는 부분이 없을까? 다른 데서 레퍼런스 카운트를 줄일 때 날아갈 수 있다. 멀티 스레드에서는 위험하지만 싱글 스레드에서는 문제 없다.
	{
		if (gameObject->GetMeshRenderer() == nullptr)
			continue; 

		const InstanceID instanceId = gameObject->GetMeshRenderer()->GetInstanceID(); 
		cache[instanceId].push_back(gameObject); // 같은 애들끼리 분리수거를 해주는 것이다. 
	}

	for (auto& pair : cache)
	{
		const vector<shared_ptr<GameObject>>& vec = pair.second; 

		//if (vec.size() == 1)
		//{
		//    하나만 있을 때는 옛날 버전으로 하면 된다. 
		//}
		//else 무조건 인스턴싱을 적용하는 버전으로 만든다.
		{
			const InstanceID instanceId = pair.first; 

			for (int32 i = 0; i < vec.size(); i++)
			{
				const shared_ptr<GameObject>& gameObject = vec[i]; 

				InstancingData data;
				data.world = gameObject->GetTransform()->GetWorldMatrix(); 

				AddData(instanceId, data); // InstanceId에 따라서 InstancingBuffer를 찾아준 다음에 버퍼에다가 데이터를 넣어주는 함수
			}	

			// 막타
			// 대표로 할 애를 정하면 된다. 
			shared_ptr<InstancingBuffer>& buffer = _buffers[instanceId]; 
			vec[0]->GetMeshRenderer()->RenderInstancing(buffer); 
		}
	}
}

void InstancingManager::AddData(InstanceID instanceId, InstancingData& data)
{
	if (_buffers.find(instanceId) == _buffers.end())
		_buffers[instanceId] = make_shared<InstancingBuffer>(); 

	_buffers[instanceId]->AddData(data); 
}

 

InstancingManager::Render가 매 프레임 호출이 되어서

ClearData로 날리고

RenderMeshRenderer로 MeshRenderer를 다 모아가지고

대표 뽑아서 막타를 치고 호출하고

이 부분까지 끝내게 되면 인스턴싱 작업이 되어야 정상일 것이고

안되면 문제가 있는 것이다.

 

5. MeshInstancingDemo에서 InstancingManager사용하기

다시 MeshInstancingDemo로 돌아와서

여기선 어떻게 코드가 바뀌어야 되는지 살펴보면 된다.

// INSTANCING
shared_ptr<Mesh> _mesh; // 원본 메시
shared_ptr<Material> _material; 

vector<Matrix> _worlds; // 물체들의 온갖 transform 정보, 월드 변환행렬을 들고 있는 거  
shared_ptr<VertexBuffer> _instanceBuffer; 

이게 필요 없으니 삭제한다.

 

MeshInstancingDemo.cpp에서 // INSTANCING 코드들을 삭제한다. 이 부분을 케어해 주는 코드를 넣어줘야 한다.

 

MeshInstancingDemo::Update에 넣어놨던

_material->Update(); // 렌더링 하는 부분이 케어가 된다. 온갖 잡동사니를 밀어 넣어주기 때문에 조명과 관련된 부분들이 세팅된다. 

//auto world = GetTransform()->GetWorldMatrix();
//RENDER->PushTransformData(TransformDesc{ world });

_mesh->GetVertexBuffer()->PushData(); 
_instanceBuffer->PushData(); 	
_mesh->GetIndexBuffer()->PushData(); 

//shader->DrawIndexed(0, 0, _mesh->GetIndexBuffer()->GetCount(), 0, 0);
_shader->DrawIndexedInstanced(0, 0, _mesh->GetIndexBuffer()->GetCount(), _objs.size()); 

잡동사니 부분들을 삭제한다.

대신

	INSTANCING->Render(_objs);

이런 식으로 InstancingManager를 통해서 Render를 해달라 한 번에 요청하는 것이다.

같은 애들만 있다는 보장은 없지만 분류 작업을 RenderMeshRender에서 해줘서 똑같은 애들끼리 모아줄 것이고, MeshRenderer가 있는 애들끼리 모아 줄 것이고,

그 애들끼리 RenderInstancing이 호출될 것이니까

그러면 아까랑 동일하게 호출이 되어야지 정상적인 상황이라고 볼 수 있다.

 

실행을 하면

Instancing이 된 버전으로 잘 나온다.

 

사실 지난 시간에 만들었던 코드를 바꾼 느낌이다.

 

중요한 건 엔진에 맞게끔 어떻게 넣어주면 될 것인지를 고민해서 이런 식으로 InstancingManager라는 애를 적용시켜서 걔네들이 관리하는 식으로 수정을 해 보았다.

Update 코드에서는 자신을 그려주는 코드를 넣어주면 안 되고, 가만히 있다가 InstancingManager가 최종적으로 호출해서 뭘 하라고 하면 그제야 해주는 식으로 작업을 하면 된다.

 

void InstancingManager::Render(vector<shared_ptr<GameObject>>& gameObjects)
{

	ClearData(); 

	RenderMeshRenderer(gameObjects); // 매 프레임마다 모으는 작업
}

매 프레임마다 날리고 다시 만드는 게 마음에 안들 수 있는데

그렇게 안 하면 까다로운 부분이 많아진다.

 

GameObject가 만들어졌다가 삭제되는 시점에서 그거를 InstancingManager에서 알아보고 삭제해야 하고, 컴포넌트도 원래는 AddComponent만 만들었지만 RemoveComponent를 해서 언제든지 렌더 컴포넌트를 제거할 수 있을 것이다. 그런 부분도 케어해줘야 하기 때문에 너무 복잡해서 이런 식으로 매번 밀고, 새롭게 시작하는 게 깔끔하다고 보면 된다.

 

중요한 건 흐름을 기억하면 된다.

 

물체가 많으면, 하나씩 그리는 게 아니라 InstancingManager에서 같은 아이들을 모아서, 그 아이들 중 대표 하나를 뽑아서 그 대표가 RenderInstancing을 호출해서 그리게끔 InstancingBuffer를 건네주고, 이 RenderInstancing에서는 DrawIndexedInstance를 호출하게 되면 Shader코드는 수정할 필요 없이 그냥 world가 들어가서 넣어준 world를 기반으로 그려준다는 것을 알 수 있다.

 

참고로 20. MeshInstancingDemo.fx의 VS_IN에

    uint instanceID : SV_InstanceID; 

이걸 추가하면

경우에 따라 matrix World : INST만 넣어주는 게 아니라 내가 몇 번째인지 궁금할 수도 있다.

지금 단계는 아니지만 애니메이션이 들어가면 몇 번인지 알아야 되는 게 필요할 수 있는데 그럴 때는 이 uint instanceID : SV_InstanceID; 을 통해 찾아올 수 있을 것이다.

나중에 사용해 볼 것이다.

 

코드는 어렵지 않다.

기술적으로 봤을 때 코드로 넣는 게 어려울 수 있지만 애니메이션에 비해 아이디어자체는 어려운 개념이 안 남았고 보면 된다.

 

이렇게 mesh를 인스턴싱 하는 부분을 해 보았다.

나머지 모델이나 애니메이션은 조금 더 생각할 부분이 많아진다. 이어서 해 본다.

 

 

반응형

댓글