인스턴싱 코드를 만들어 봤는데 그다음은 지금까지 만든 프레임워크랑 통합을 해서 관리할 수 있게끔 하나씩 만들어 보는 게 목표라고 보면 된다.
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를 인스턴싱 하는 부분을 해 보았다.
나머지 모델이나 애니메이션은 조금 더 생각할 부분이 많아진다. 이어서 해 본다.
'DirectX' 카테고리의 다른 글
69_인스턴싱_ModelAnimation(인스턴싱) (0) | 2024.03.18 |
---|---|
68_인스턴싱_ModelRenderer(인스턴싱) (0) | 2024.03.16 |
66_인스턴싱_인스턴싱과 드로우콜 (0) | 2024.03.12 |
65. 애니메이션_SkyBox (0) | 2024.03.10 |
64. 애니메이션_애니메이션#4_tweening (0) | 2024.03.08 |
댓글