DirectX

68_인스턴싱_ModelRenderer(인스턴싱)

devRiripong 2024. 3. 16.
반응형

Mesh 다음에 Model, Animation을 작업했다. 그 순서대로 작업을 해 볼 것이다.

 

1. ModelInstancingDemo 클래스 만들기

MeshInstancingDemo를 복붙 해서 ModelInstancingDemo라고 하고 Client/Game필터에 넣는다.

 

큐브나 구 같은 걸 메쉬라고 했었고, 모델 같은 경우는 fbx파일을 로드해서 힘들게 작업했던 애들을 모델이라고 볼 수 있다. 타워로 실습을 해볼 예정이다. 타워가 정상적으로 보이게끔 하는 게 목표다.

 

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

	_shader = make_shared<Shader>(L"21. ModelInstancingDemo.fx");

 

렌더링하는 코드는 크게 바뀌지 않는다.

 

원래 AssimpTool의 StaticMeshDemo.cpp에서 타워나 탱크 만들어서 어떻게 했는지 커닝을 해서 ModelInstancingDemo.cpp에 코드를 가져와서 채워본다.

#pragma once
class ModelInstancingDemo : public IExecute
{
public:
	void Init() override;
	void Update() override;
	void Render() override;

private:
	shared_ptr<Shader> _shader;
	shared_ptr<GameObject> _camera;
	vector<shared_ptr<GameObject>> _objs;

private:
};
#include "pch.h"
#include "ModelInstancingDemo.h"
#include "GeometryHelper.h"
#include "Camera.h"
#include "GameObject.h"
#include "CameraScript.h"
#include "MeshRenderer.h"
#include "Mesh.h"
#include "Material.h"
#include "Model.h"
#include "ModelRenderer.h"
#include "ModelAnimator.h"
#include "Mesh.h"
#include "Transform.h"
#include "VertexBuffer.h"
#include "IndexBuffer.h"

void ModelInstancingDemo::Init()
{
	RESOURCES->Init();
	_shader = make_shared<Shader>(L"21. ModelInstancingDemo.fx");

	// Camera
	_camera = make_shared<GameObject>();
	_camera->GetOrAddTransform()->SetPosition(Vec3{ 0.f, 0.f, -5.f });
	_camera->AddComponent(make_shared<Camera>());
	_camera->AddComponent(make_shared<CameraScript>());

	shared_ptr<class Model> m1 = make_shared<Model>();
	m1->ReadModel(L"Tower/Tower");
	m1->ReadMaterial(L"Tower/Tower");

	for (int32 i = 0; i < 500; i++)
	{
		auto obj = make_shared<GameObject>();
		obj->GetOrAddTransform()->SetPosition(Vec3(rand() % 100, 0, rand() % 100));
		obj->GetOrAddTransform()->SetScale(Vec3(0.01f)); // Tower는 너무 크기 때문에 크기를 줄여 준다.
		obj->AddComponent(make_shared<ModelRenderer>(_shader));
		{
			obj->GetModelRenderer()->SetModel(m1); // model이 mesh랑 material 관련 부분들을 다 들고 있는 식으로 만들어 놨으니 
		}
		_objs.push_back(obj); 
	}

	RENDER->Init(_shader);
}

void ModelInstancingDemo::Update()
{
	_camera->Update(); 
	RENDER->Update(); 

	{
		LightDesc lightDesc;
		lightDesc.ambient = Vec4(0.4f);
		lightDesc.diffuse = Vec4(1.f);
		lightDesc.specular = Vec4(0.1f);
		lightDesc.direction = Vec3(1.f, 0.f, 1.f);
		RENDER->PushLightData(lightDesc);
	}

	// INSTANCING
	INSTANCING->Render(_objs);
}

void ModelInstancingDemo::Render()
{
}

이런 식으로 실행 됐을 때 Tower도 엄청 많이 찍을 수 있느냐가 관건이다.

우선 쉐이더는 좀 달라져야 한다.

 

 

2. 21. ModelInstancingDemo.fx

 

탐색기의 Shaders 폴더에서 20. MeshInstancingDemo.fx를 복붙해서 이름을 21. ModelInstancingDemo.fx로 한다.

 

그리고 Client/Shaders/Week3 필터에 넣는다.

 

이전에 만들었던 15. ModelDemo.fx를 살펴 보면 이것과 비슷하게 해 줘야겠다는 생각이 든다. 필요한 부분을 가져온다.

 

uint BoneIndex도 버퍼에 넣는 것도 방법이지만 너무 작업량이 많으니 냅둔다.

 

탱크의 경우 노드에 계층 구조가 있었다. 여러개의 메쉬로 이루어져 있었고, 메쉬들이 상대적인 위치에 배치가 되어 있었기 때문에 상대적인 위치가 있었다. 그걸 BoneTransforms라고 부르고 있었다.

 

#include "00. Global.fx"
#include "00. Light.fx"

#define MAX_MODEL_TRANSFORMS 250

cbuffer BoneBuffer
{
    matrix BoneTransforms[MAX_MODEL_TRANSFORMS];
};

uint BoneIndex;

struct VS_IN
{
    float4 position : POSITION; 
    float2 uv : TEXCOORD; 
    float3 normal : NORMAL; 
    float3 tangent : TANGENT; 
    float4 blendIndices : BLEND_INDICES; // 애니메이션 블렌딩 할 거 
    float4 blendWeights : BLEND_WEIGHTS; 
    // INSTANCING
    uint instanceID : SV_InstanceID; 
    matrix world : INST;
};

struct VS_OUT
{
    float4 position : SV_POSITION; 
    float3 worldPosition : POSITION1; 
    float2 uv : TEXCOORD; 
    float3 normal : NORMAL; 
};

VS_OUT VS(VS_IN input)
{
    VS_OUT output;

    output.position = mul(input.position, BoneTransforms[BoneIndex]); // Model Global (Root를 기준)로 일단 가기
    output.position = mul(output.position, input.world); // W
    output.worldPosition = output.position;
    output.position = mul(output.position, VP);
    output.uv = input.uv;
    output.normal = input.normal;

    return output;
}

float4 PS(VS_OUT input) : SV_TARGET
{
    //float4 color = ComputeLight(input.normal, input.uv, input.worldPosition);
    float4 color = DiffuseMap.Sample(LinearSampler, input.uv);
    return color;
}

technique11 T0
{
    PASS_VP(P0, VS, PS)
};

 

Client프로젝트를 빌드한다.

 

 

3. InstancingManager 수정

 

흐름대로 가면 obj가 여러개 만들어질 것이고,

ModelInstancingDemo::Update에서 InstancingManager::Render로 들어오게 된다.

InstancingManager에서 코드를 추가하면 된다.

 

InstanceID를 여러개 파서 MeshRenderer전용, ModelRenderer전용으로 해도 되지만 포인터라 겹칠일이 없을 거 같기 때문에 그냥 통합해서 관리해도 될 거 같다.

 

1) RenderModelRenderer 함수 추가하기

RenderMeshRenderer에 다 넣기보다는 RenderModelRenderer함수를 만든다. 가끔은 이렇게 분리해서 작업하는 게 버그 확률이 낮다.

 

InstancingManager::Render에서 RenderModelRenderer를 호출한다.

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

	RenderMeshRenderer(gameObjects);
	RenderModelRenderer(gameObjects);
}

 

RenderModelRenderer는 ModelRenderer만 싹 찾아서 그 부분들을 작업하는 애라고 볼 수 있다.

 

RenderMeshRenderer의 내용을 복붙한 다음에 수정한다.

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

	for (shared_ptr<GameObject>& gameObject : gameObjects)
	{
		if (gameObject->GetModelRenderer() == nullptr)
			continue;

		const InstanceID instanceId = gameObject->GetModelRenderer()->GetInstanceID();
		cache[instanceId].push_back(gameObject);
	}

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

		//if (vec.size() == 1)
		//{
		//	vec[0]->GetMeshRenderer()->RenderSingle();
		//}
		//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);
			}

			shared_ptr<InstancingBuffer>& buffer = _buffers[instanceId];
			vec[0]->GetModelRenderer()->RenderInstancing(buffer);
		}
	}
}

 

gameObject→GetModelRenderer()→GetInstanceID()가 없다고 나오는데 상속구조를 쓴다면 이럴 때 쓰면 좋다. MeshRenderer, ModelRenderer를 Renderer라는 상위 계층으로 묶어서 거기다가 GetInstanceID랑 RenderInstancing이라는 함수를 넣어 놓는 것을 고려할 수 있다. 지금은 스트레이트로 진행하기 위해 상속 없이 바로 간다.

 

2) ModelRenderer에 GetInstanceID 함수 추가하기

ModelRenderer.h에 가서

	void RenderInstancing(shared_ptr<class InstancingBuffer>& buffer);
	InstanceID GetInstanceID(); 

이렇게 2개를 추가한다.

 

InstanceID ModelRenderer::GetInstanceID()
{
	return make_pair((uint64)_model.get(), (uint64)_shader.get()); 
}

같은 모델, 같은 쉐이더 이 두 가지 정보만 일치하면 되니, 모델 주소와 쉐이더 주소를 번호로 연결한다.

 

3) ModelRenderer에 RenderInstancing 함수 추가하기

RenderInstancing에는 원래 Update에 넣어줬던 부분을 이전시키면 된다.

ModelRenderer의 Update함수를 삭제하고 RenderInstancing에 붙여 넣기를 하고, 수정을 한다.

 

Push 함수를 넣어 놓은 RenderManager도 오늘까지는 두고 다음 챕터에 수정을 할 것이다. 전역으로 항상 하기엔 쉐이더가 바뀔 때마다 RenderManager::Init이 갱신되는 건 말이 안 되니까 전역으로 할 수 없고 물체마다 배치한다거나 뭔가 수단을 찾아야 한다. 지금 고치면 이전 코드가 안 돌아가니 일단 이대로 한다.

만약 옮긴다고 하면 Shader 클래스 쪽에다가 옮기는게 나쁘지 않을 거 같다. 아니면 유틸함수로 빼준다거나 하는 식으로 고려할 수 있다.

 

일단 코드를 복기해보면 Bone의 정보를 만들어서 그 Bone의 정보를 밀어 넣고 있다.

cbuffer BoneBuffer
{
    matrix BoneTransforms[MAX_MODEL_TRANSFORMS];
};

여기에 들어갈 bone 정보를 ModelRenderer::RenderInstancing에서 PushBoneData로 밀어 넣고 있다.

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

이 Transform 정보를 밀어 넣고 있는데

 

날려야 되는 정보가 있고 남겨야 될 정보가 있다.

Bone정보와 Transform 정보는 각각 남겨줘야 할까?

 

Transform 정보는 들고 있을 필요가 없다. // Transform 부분의 코드를 삭제한다.

왜냐하면 애당초 InstancingBuffer를 만들 때

InstanceBuffer.h의 

struct InstancingData
{
	Matrix world; 
};

이 아이가 world의 transform 정보를 들고 있는 것이고,

그 작업을 InstancingManager::RenderModelRenderer에서

    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를 찾아준 다음에 버퍼에다가 데이터를 넣어주는 함수
    }

싹 모아 가지고 해주고 있다.

이제 개인 transform 정보는 필요가 없다는 것이다.

만약 Instancing버전이 아니라 나만 그리고 싶다면 다른 Render 함수를 파주고 거기다가 남기는 건 말이 될 것이다.

 

Bone 정보의 경우는 어떤 코드를 만들지에 따라 다를 수 있다.

만약 모든 물체들이 같은 구조로 되어 있다면 BoneBuffer, BoneIndex 가 모델에 대한 Bone 정보를 말하는 거다. 그러면 모든 애들이 똑같으니 다를 바가 없다.

경우에 따라 계층 정보가 수정이 되는 경우가 생길 수 있다. 예를 들면 탱크도 계층 구조를 만든 이유가 포신만 움직이고 싶을 때 그렇게 하기 위해서일 것이다. 그럴 때 계층 구조가 바뀌게 된다. 그렇다면 모든 애들을 똑같이 넘기는 경우 모든 물체가 동일해야 하고

cbuffer BoneBuffer
{
    matrix BoneTransforms[MAX_MODEL_TRANSFORMS];
};

uint BoneIndex;

이거는 바뀔 수 없는 것이다. 무조건 0번인 RenderInstancing을 정해주는 아이가 회전하는 그 방향대로만 모든 물체들이 그려진다는 얘기가 된다. 그래도 상관없다면 이렇게 진행하면 되고,

 

그게 아니라 하나의 탱크의 포신이 회전한다거나 계층 정보가 달라질 수 있다고 하면

cbuffer BoneBuffer
{
    matrix BoneTransforms[MAX_MODEL_TRANSFORMS];
};

uint BoneIndex;

이게 하나로 부족하고 2차 배열까지 가야 하는데 그렇게 하려면 복잡해진다.

지금은 안한다.

모든 물체가 동일한 계층 구조로 되어 있다고 가정을 한다. 만약에 회전을 하고 싶다면 다른 방법을 찾아야 한다.

cbuffer BoneBuffer 부분을 추가하거나 포신이란 메쉬만 따로 분리해서 만들건 해야 한다.

이렇게 계층 관계에 대한 정보를 넘겼다.

 

 

그다음에 mesh 마다 하나씩 순회를 하면서 메쉬가 몇 번째 계층 구조에 있는지를 BoneIndex를 넣어 주고 있다. Shader의 uint BoneIndex이 부분은 메쉬마다 달라진다.

 

        DC->IASetVertexBuffers(0, 1, mesh->vertexBuffer->GetComPtr().GetAddressOf(), &stride, &offset);
        DC->IASetIndexBuffer(mesh->indexBuffer->GetComPtr().Get(), DXGI_FORMAT_R32_UINT, 0);
        _shader->DrawIndexed(0, _pass, mesh->indexBuffer->GetCount(), 0, 0);

SetVertexBufer, SetIndexBuffer, DrawIndexed가 나오는데 여기는 지난 시간에 고쳐 본 적 있다.

 

buffer->PushData()에서 들어가는 정보는 무엇일까? world 포지션을 넣어주고 있다.

void ModelRenderer::RenderInstancing(shared_ptr<class InstancingBuffer>& buffer)
{
	if (_model == nullptr)
		return;

	// Bones
	BoneDesc boneDesc;

	const uint32 boneCount = _model->GetBoneCount();

	for (uint32 i = 0; i < boneCount; i++)
	{
		shared_ptr<ModelBone> bone = _model->GetBoneByIndex(i);
		boneDesc.transform[i] = bone->transform;
		// 안채워 준 값은 쓰레기값이 들어가겠지만 쉐이더에서 사용 안할 예정이라서 일단은 냅둔다.
		// 깔끔하게 하려면 identity 행렬을 밀어 준다거나 하는 식으로 작업을 하면 된다.
	}
	RENDER->PushBoneData(boneDesc);

	const auto& meshes = _model->GetMeshes();
	for (auto& mesh : meshes)
	{
		if (mesh->material)
			mesh->material->Update();

		// BoneIndex
		_shader->GetScalar("BoneIndex")->SetInt(mesh->boneIndex);

		// IA 
		mesh->vertexBuffer->PushData(); 
		mesh->indexBuffer->PushData(); 

		buffer->PushData(); // matrix world : INST;를 넣어주고 있다

		_shader->DrawIndexedInstanced(0, _pass, mesh->indexBuffer->GetCount(), buffer->GetCount()); 
	}
}

이렇게 하면 정상적으로 작동할 것이다.

 

Engine을 빌드한다.

Mesh마다 각기 따로 그리고 있는데 이게 괜찮을까?

탱크가 있다고 했을 때 부품이 10개짜리라 하면 인스턴싱을 했을 때 몇 번만에 그릴 것이냐.

10개짜리 부품으로 이루어진 탱크가 1000대 있다고 하면 드로우콜은 몇 배가 될 것인가, 부품의 개수만큼 그려야 한다. 그 부분이 여기에 들어가 있는 것이다.

mesh가 하나의 부품이다. 하나의 model에 서브 mesh가 여러 개 있다.

1000마리 끼리는 한 번에 그릴 수 있다는 거지만 mesh가 10개면 10번 그려야 한다.

즉 탱크 몇 100개의 부품으로 만들기보다는 하나의 mesh로 통합해서 만드는 게 성능적으로 최적화할 수 있는 부분이 많다.

 

4. 실행하기

Main으로 가서

#include "ModelInstancingDemo.h"
	desc.app = make_shared<ModelInstancingDemo>(); // 실행 단위

이렇게 하고 실행을 하면

프레임이 유지되면서 잘 뜨고 있다.

 

같은 물체를 이렇게 많이 배치할 수 있다는 걸 알 수 있게 된 거고 인스턴싱이 잘 되고 있다는 걸 확인할 수 있었다.

 

지형지물 같은 걸 동일한 물체를 많이 활용한다면 웅장한 걸 만들 수 있다.

장르에 따라 동일한 건물이나 몬스터를 많이 배치하고 학살하는 식의 게임을 만들 수 있는 게 기술적으로 가능하다.

무작정 각기 다른 1000마리 배치하는 거랑 동일한 걸 10000마리 배치하는 거랑 사실상 10000마리 배치하는 게 더 쉬울 수 있다.

그런 걸 알아야 한다.

 

만약에 물체가 회전이 되고 스케일이 늘어난다면 어떻게 될까?

인스턴싱이 될까? 어차피 넣어준 world matrix는 SRT를 포함하기 때문에 Scale, Rotation 값이 적용하더라도 아무런 상관이 없다.

위치만 적용되는 게 아니다.

재질이나 material 상세 수치가 바뀐다거나 하면 문제가 된다.

하지만 같은 옵션으로 그리는데 SRT라는 world 행렬만 바뀐다면 상관이 없다.

 

포폴을 만들 때 웅장하게 만들어서 인스턴싱을 만들었다 어필하면서 만들고 서버 붙이면 완벽할 것이다.

 

중요한 건 인스턴싱을 적용하지 않으면 프레임이 떨어져 구현이 안될 것이다.

frustum culling 등 기법을 넣어줄 수 있다.

유니티에서 카메라가 다른 방향을 바라보면 Batches가 줄어든다. 카메라에 안 보이면 컬링이 되는 게 유니티에 적용되어 있는 걸 알 수 있다.

 

모델 배치까지 성공했다.

뼈대 같은 경우는 공용이냐 아니냐 이런 것들을 선택해야 하고

마지막으로 Animator만 복원을 하면 된다.

 

삼총사가 완료되는 거고

통합해서 한 번에 관리할 수 있게 해야 한다.

반응형

댓글