코승호딩의 메모장

[Frustum Culling] 본문

DirectX12/DirectX12 응용

[Frustum Culling]

코승호딩 2023. 10. 21. 03:06

이번 글에서는 프러스텀 컬링을 구현하려고 한다. 현재까지 월드 기준으로 변환을 진행하였으며 카메라 행렬을 기준으로 생성한 프러스텀을 뷰 변환의 역 변환을 통하여 월드 변환으로 변환한 후에 씬에 배치된 게임 오브젝트와의 충돌 처리를 통해 프러스텀 컬링을 구현해야 한다. 프러스텀의 각 면들을 수학적 계산을 통해 해당 오브젝트의 바운딩 박스가 충돌했는지를 구해야 하지만 DirectX12에서는 DirectXCollision.h를 통해서 바운딩 박스와 바운딩 프러스텀을 제공한다. 따라서 이를 사용해서 구현하기로 한다.


class Camera : public Component
{
	//...
	void GenerateFrustum();
	bool IsInFrustum(BoundingOrientedBox& boundsOOBB);
private:
	BoundingFrustum _frustum;
};

우선 카메라 컴포넌트에 바운딩 프러스텀 객체를 넣어준다. 그리고 카메라 행렬이 바뀔 때마다 프러스텀의 행렬도 새로 생성해야 하기 때문에 프러스텀의 행렬을 새로 생성하여 카메라 역 변환을 통해 월드 변환으로 변경하는 함수를 작성한다.

 

void Camera::GenerateFrustum()
{
	_frustum.CreateFromMatrix(_frustum, _matProjection);
	_frustum.Transform(_frustum, _matView.Invert());
}

bool Camera::IsInFrustum(BoundingOrientedBox& boundsOOBB)
{
	return _frustum.Intersects(boundsOOBB);
}

DirectXCollision.h의 프러스텀을 이용하면 다음과 같이 쉽게 프러스텀의 행렬과 행렬 변환을 수행할 수 있다. 그리고 게임 오브젝트와의 충돌을 검사하기 위해 IsInFrustum 함수도 작성한다. 이 함수는 오브젝트의 바운딩 박스를 인자로 받아 프러스텀의 Intersects 함수를 통해 충돌했다면 true를 충돌하지 않았다면 false를 반환한다. 

 

class Mesh : public Object
{
	//...
private:
	BoundingOrientedBox _boundingBox;
};

그렇다면 뷰 프러스텀뿐만 아니라 객체들이 바운딩 박스도 가지고 있어야 할 것이다. 메쉬의 크기에 맞게 바운딩 박스를 생성해야 하기 때문에 메쉬에서 바운딩 박스를 생성한다.

 

inline MinMaxVert CalcMinMaxVertices(const vector<Vertex>& vec)
{
	Vec3 minPoint = Vec3(FLT_MAX);
	Vec3 maxPoint = Vec3(-FLT_MAX);

	for (const auto& v : vec)
	{
		minPoint.x = min(minPoint.x, v.pos.x);
		minPoint.y = min(minPoint.y, v.pos.y);
		minPoint.z = min(minPoint.z, v.pos.z);
		maxPoint.x = max(maxPoint.x, v.pos.x);
		maxPoint.y = max(maxPoint.y, v.pos.y);
		maxPoint.z = max(maxPoint.z, v.pos.z);
	}

	return MinMaxVert{ minPoint, maxPoint };
}

void CreateBoundingBox(MinMaxVert minMaxVert, sptr<Mesh> mesh)
{
	Vec3 points[2] = {
		minMaxVert.min,
		minMaxVert.max
	};

	mesh->GetBoundingBox().CreateFromPoints(mesh->GetBoundingBox(), 2, points, sizeof(Vec3));
}

그리고 파일 입출력 또는 메쉬를 사용자가 직접 정점을 생성할 때, (x, y, z)에 대한 각 최솟값과 최댓값을 구한다. 최솟값과 최댓값을 받는 이유는 OOBB의 바운딩 박스를 생성할 때 메쉬의 크기에 맞게 바운딩 박스를 생성하기 위함이다. 그리고 정점과 최소 최대가 정해졌다면 바운딩 박스 생성 함수에 인자로 각 최대 최소점을 넣어주어 바운딩 박스를 생성한다. 이렇게 생성된 바운딩 박스는 모델 좌표계에 있을 것이다. 따라서 이를 사용하기 위해서는 해당 메쉬를 가지고 있는 게임 오브젝트의 월드 변환을 메쉬의 바운딩 박스에 변환해 줌으로써 월드 좌표계로 변경해야 한다.  

 

void Camera::Render()
{
	//...
	for (auto& gameObject : gameObjects)
	{
		if (gameObject->GetMeshRenderer() == nullptr)
			continue;

		BoundingOrientedBox boundingBox = gameObject->GetMeshRenderer()->GetBoundingBox();
		boundingBox.Transform(boundingBox, gameObject->GetTransform()->GetLocalToWorldMatrix());
		
		if (gameObject->GetCheckFrustum())
			if (!IsInFrustum(boundingBox))
				continue;

		gameObject->GetMeshRenderer()->Render();
	}
}

이제 메인 카메라를 렌더링 할 때, 게임 오브젝트의 메쉬가 가지고 있는 바운딩 박스를 자신의 월드 행렬로 변환하여 월드 좌표계로 만들어주게 되면 게임 오브젝트가 어느 크기 또는 어느 위치에 있든지 바운딩 박스는 게임 오브젝트에 붙어 있을 것이다. 그리고 스카이 박스와 같이 프러스텀 컬링이 되어서는 안 되는 오브젝트를 위해 게임 오브젝트 내부 변수에 프러스텀 컬링을 해야 하는지 여부를 담는 변수를 저장하여 만약 컬링이 되어야 하는 오브젝트만 컬링을 하도록 한다. 만약 카메라의 컬링 함수가 false를 반환하였다면 continue를 통해 걸러지게 되고 true라면 그대로 렌더링을 진행할 것이다.

 

(???...)

컬링을 잘 되지만 한 가지 문제가 생겼다. 원래 구였던 오브젝트가 컬링이 되면 아주 잠깐의 한 프레임 동안만 컬링이 된 오브젝트의 정점으로 그려지는 것이다. 디버깅을 통해서 위 결과가 나왔지만 실제 실행에서는 컬링이 될 때마다 계속 깜빡거리며 구에서 육면체로 잠깐 바뀌었다가 다시 구로 바뀐다. 이유가 무엇일까? 

 

문제는 아마도 프레임 리소스 기법을 사용하여 리소스들을 3개로 만들어준 것과 서술자 테이블을 클리어하는 식으로 인덱스를 바꿔가면서 매 프레임마다 0부터 시작하여 계속 서술자들을 복사해서 유동적으로 만들어준 것에서 발생한 것 같다. 

 

enum
{
	FRAME_RESOURCE_COUNT = 1
};

실제로 프레임 리소스를 한 개만 생성하여 실행해 보니 컬링도 잘 되고 깜빡임도 사라졌다. 그러나 이렇게 되면 프러스텀 컬링 또는 프레임 리소스 둘 중 하나를 포기해야 하는 상황이다. 따라서 다른 해결책을 찾아봐야 한다. 이로써 위의 가정이 참이 되었다.

 

아마도 이 전에 구현한 서술자 테이블은 매 프레임마다 인덱스를 0부터 시작하여 서술자 힙의 첫 부분부터 다시 서술자들을 복사해 주는 방법을 사용하였기 때문에 사실상 오브젝트가 사라지더라도 해당 정보는 상수 버퍼에 남아 있기 때문이 생긴 문제가 아닐까라는 생각이 들었다. 이 과정에서 프레임 리소스를 3개나 사용하기 때문에 어딘가에서 깨끗하게 사라지지 않고 쓰레기 값이 남아 있었던 것 같다. 

 

for (int i = 0; i < FRAME_RESOURCE_COUNT; ++i)
{
	gEngine->GetFrameResource()->ObjectCB->Clear();
	gEngine->GetFrameResource()->MaterialCB->Clear();
}

혹시 몰라서 모든 프레임 리소스를 클리어하기도 했지만 역시나 문제는 사라지지 않았다. 어차피 프레임마다 현재의 프레임 리소스를 클리어하기 때문에 의미가 없는 행동이었다.

 

사실 프레임마다 계속 클리어를 하는데도 불구하고 이러한 오류가 나타나는 곳을 찾지 못하였다. 

몇 시간의 삽질 끝에 결국 서술자 테이블을 다시 설계하기로 하였다. 기존에 서술자를 서술자 테이블이 가지고 있는 서술자 힙에 계속 복사를 하는 방식과 프레임마다 인덱스를 0으로 초기화하여 오브젝트의 상수 버퍼들과 텍스처의 서술자들을 유동적으로 옮기는 방식이 아닌 오브젝트의 상수 버퍼와 텍스처의 서술자를 애초에 생성할 때부터 위치를 지정해 주는 것이다. 오브젝트가 사라지거나 개발자가 임의로 서술자 힙의 서술자 위치를 바꿔서 memcpy 하지 않는 이상 계속 해당 서술자들은 자신의 위치를 가지고 있을 것이다. 이렇게 구현하게 된다면 다른 오브젝트와의 커플링이 사라져서 만약 오브젝트가 사라진다고 하더라도 다른 오브젝트에 어떠한 영향도 끼치지 않을 것이다.

 

두 방식의 차이점은 이전에는 CBV와 SRV를 미리 생성하여 서술자 테이블 클래스의 서술자 힙에 복사를 하는 방식이었다면 이제는 서술자 테이블 클래스의 서술자 힙을 사용하여 CBV와 SRV를 생성한다는 점이다. 그리고 이로써 가장 큰 차이점은 프레임마다 서술자 힙의 서술자들의 위치가 바뀌지 않고 고정적으로 자신의 자리만을 차지한다는 점이다.

 

class ConstantBuffer
{
	//...
public:
	void Init( uint32 size, uint32 count);
	void CopyData(int elementIndex, void* buffer, size_t size);
	ID3D12Resource* Resource()const { return _cbvBuffer.Get();}

private:
	void CreateBuffer();

private:
	ComPtr<ID3D12Resource>	_cbvBuffer;
	BYTE* _mappedBuffer = nullptr;
	uint32					_elementSize = 0;
	uint32					_elementCount = 0;
};

상수 버퍼 클래스가 굉장히 심플해졌다. 클리어하지 않아도 되며 따로 인덱스와 레지스터를 지정하지 않아도 되기 때문이다. 

 

void ConstantBuffer::CopyData(int elementIndex, void* buffer, size_t size)
{
	::memcpy(&_mappedBuffer[elementIndex * _elementSize], buffer, size);
}

memcpy를 통해서 GPU 메모리로 데이터를 복사할 때도 간단하게 오브젝트가 가지고 있는 자신의 인덱스만 인자로 넣어서 접근하게 되면 딱히 인덱스를 관리할 필요 없다. 

 

void TableDescriptorHeap::Init(uint32 count)
{
	//...
	_cbvSrvDescriptorSize = DEVICE->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV);
	hDescriptor = _descHeap->GetCPUDescriptorHandleForHeapStart();
}

void TableDescriptorHeap::CreateSRV(sptr<Texture> texture)
{
	auto tex = texture->Resource();

	D3D12_SHADER_RESOURCE_VIEW_DESC srvDesc = {};
	//...

	DEVICE->CreateShaderResourceView(tex.Get(), &srvDesc, hDescriptor);
	hDescriptor.Offset(1, _cbvSrvDescriptorSize);
}

서술자 테이블도 단 두 개 함수가 끝이다. 초기화를 하는 부분에서 디스크립터 힙의 CPU 핸들을 받아와서 내부 변수에 저장하고 텍스처를 생성할 때마다 CreateSRV를 통해서 서술자 테이블 클래스가 들고 있는 자신의 서술자 힙에 접근하여 해당 힙에 쉐이더 리소스 뷰를 생성하여 텍스처를 생성하는 것이다. 이제부터는 서술자 테이블을 단지 텍스처만 로드하기 위한 SRV 전용으로만 하고 CBV는 루트 서술자를 사용하기로 한다. 이렇게 하면 매번 서술자 힙에 서술자들을 복사하지 않아도 된다. 이 또한 클리어가 사라진 것을 볼 수 있다. 서술자 테이블도 텍스처들이 서술자 테이블이 가지고 있는 서술자 힙에서 자신만의 공간에서만 만들어지고 사용되기 때문이다. 

 

void Scene::LoadTestTextures()
{
	auto newjeans = make_shared<Texture>();
	newjeans->Init(L"..\\Resources\\Texture\\newjeans.dds");
	_textures["newjeans"] = move(newjeans);
	DESCHEAP->Init(_textures.size());
	
	//...
    
	for (auto& tex : _textures) {
		DESCHEAP->CreateSRV(tex.second);
	}
}

void Scene::BuildMaterials()
{
	{
		auto newjeans = make_shared<Material>();
		newjeans->SetMatCBIndex(0);
		newjeans->SetDiffuseSrvHeapIndex(0);
		newjeans->SetFresnel(Vec3(0.9f, 0.9f, 0.9f));
		newjeans->SetRoughness(0.125f);
		shared_ptr<Shader> shader = make_shared<Shader>();
		shader->Init(L"..\\Resources\\Shader\\Default.hlsl");
		newjeans->SetShader(shader);

		_materials["newjeans"] = move(newjeans);
	}
    
	//...
}

이제 방식이 바뀌었으니 씬에서 모든 머티리얼과 텍스처 정보를 맵으로 가지고 있도록 변경하자. 이제 씬 매니저에서는 테스트 씬을 로드할 때 그저 씬에 있는 머티리얼 정보만 가져오면 된다. 이전에는 유동적으로 서술자 테이블이 서술자들을 복사할 수 있었지만 이제는 고정되어 있기 때문에 게임 오브젝트를 만들 때 직접 몇 개를 사용하는지를 알아야 한다. 그러나 미리 머티리얼과 텍스처를 만들어 놓고 사용할 수 있기 때문에 편리한 장점도 있다.

 

CD3DX12_DESCRIPTOR_RANGE texTable;
texTable.Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 1, 0);

CD3DX12_ROOT_PARAMETER slotRootParameter[4];
slotRootParameter[0].InitAsConstantBufferView(0);
slotRootParameter[1].InitAsConstantBufferView(1);
slotRootParameter[2].InitAsConstantBufferView(2);
slotRootParameter[3].InitAsDescriptorTable(1, &texTable, D3D12_SHADER_VISIBILITY_PIXEL);

이제 루트 서명도 바뀌어야 한다. 기존에 하나의 서술자 테이블에서 CBV와 SRV를 동시에 사용하였지만 이제는 각각 나눠서 ObjectCB, PassCB, MaterialCB는 루트 서술자를 사용하고 SRV 즉, 텍스처만 서술자 테이블을 사용하기로 한다. 이렇게 나눠서 구현한다면 이후에 구현할 동적 색인화를 하는데 도움이 될 것이다. 

 

class GameObject : public Object, public enable_shared_from_this<GameObject>
{
	//...
	uint32 _objCBIndex = -1;	
	static uint32 ObjCBIndex;
};

uint32 GameObject::ObjCBIndex = 0;

class Material : public Object
{
	//...
	uint32 _matCBIndex = 0;
};

이제 상수 버퍼를 사용하기 위해서는 자신만의 인덱스를 통해서 해당 서술자 힙에 접근해야 하기 때문에 오브젝트 혹은 머티리얼이 생성될 때마다 인덱스를 1씩 올려서 순서대로 가지고 있도록 한다. 참고로 인덱스에 CBV, SRV의 하드웨어에 따른 사이즈를 곱해주고 서술자 힙의 오프셋을 인덱스만큼 한다면 자신의 서술자에 접근할 수 있다.

 

void Transform::PushData()
{
	ObjectConstants objectConstants = {};
	objectConstants.matWorld = _matWorld;

	uint32 objCBIndex = GetGameObject()->GetObjCBIndex();
	CB(CONSTANT_BUFFER_TYPE::OBJECT)->CopyData(objCBIndex, &objectConstants, sizeof(ObjectConstants));

	UINT objCBByteSize = d3dUtil::CalcConstantBufferByteSize(sizeof(ObjectConstants));

	D3D12_GPU_VIRTUAL_ADDRESS objCBAddress = CB(CONSTANT_BUFFER_TYPE::OBJECT)->Resource()->GetGPUVirtualAddress() + objCBIndex * objCBByteSize;
	CMD_LIST->SetGraphicsRootConstantBufferView(1, objCBAddress);
}

다음과 같이 오브젝트 당 상수 버퍼를 복사할 때, 해당 오브젝트 인덱스를 CopyData의 인덱스 인자에 넣어주면 될 것이다. 그리고 상수 버퍼 뷰를 셋 할 때는 상수 버퍼를 자신의 인덱스만큼 오프셋 하여 넣어주면 될 것이다. 

 

void Material::Update()
{
	CB(CONSTANT_BUFFER_TYPE::MATERIAL)->CopyData(_matCBIndex, &_params, sizeof(MaterialConstants));

	UINT matCBByteSize = d3dUtil::CalcConstantBufferByteSize(sizeof(MaterialConstants));
	D3D12_GPU_VIRTUAL_ADDRESS matCBAddress = CURR_FRAMERESOURCE->MaterialCB->Resource()->GetGPUVirtualAddress() + _matCBIndex * matCBByteSize;
	CMD_LIST->SetGraphicsRootConstantBufferView(2, matCBAddress);

	CD3DX12_GPU_DESCRIPTOR_HANDLE tex(DESCHEAP->GetDescriptorHeap()->GetGPUDescriptorHandleForHeapStart());
	tex.Offset(_diffuseSrvHeapIndex, DESCHEAP->GetCbvSrvDescriptorSize());
	CMD_LIST->SetGraphicsRootDescriptorTable(3, tex);

	_shader->Update();
}

머티리얼 또한 자신의 인덱스만큼 오프셋 하여 상수 버퍼 뷰를 셋 하고 머티리얼이 텍스처 정보를 가지고 있으므로 서술자 테이블에서 서술자 힙의 GPU 핸들을 받아와 해당 텍스처 인덱스만큼 오프셋 하여 서술자 테이블에 셋 하면 된다. 대공사가 끝나고 이를 실행해 보면 이제 프러스텀 컬링이 문제없이 되며 깜빡임 또한 사라지고 프레임 리소스를 3개를 사용한 모습을 볼 수 있다. 현재는 레지스터를 t0 밖에 사용하지 못하지만 이후에 동적 색인화를 통해 동적으로 텍스처에 접근하도록 변경할 것이다.


프러스텀 컬링을 구현하려 했으나 리팩토링 하는데 더 많은 시간을 쏟았다. 그러나 의미 있는 작업이었고 만약 프러스텀 컬링이 아니더라도 실제 게임 세상에서 오브젝트가 사라지거나 죽게 되면 언젠가는 터질 문제였다. 만약 지금 잡지 못한 채 프로젝트가 더 커져서 그때서야 문제를 알게 되었을 때 끔찍하다. 고쳐야 할 부분이 많기 때문이다. 지금 발견한 것도 정말 다행이라고 생각하고 아직 나 자신의 코드에서 버그가 날 명분은 충분히 있을 것이다. 그러나 어느 게임이든 버그가 없는 것은 없을 것이다. 완벽한 코드란 없기 때문이다. 따라서 버그가 날 것을 예방하는 것도 중요하지만 확실한 설계와 버그가 났을 때 어떻게 고칠지를 배우는 것도 매우 중요하다고 느꼈다.

'DirectX12 > DirectX12 응용' 카테고리의 다른 글

[Render Target]  (0) 2023.10.22
[Orthographic Projection]  (1) 2023.10.22
[Dynamic Indexing]  (1) 2023.10.21
[CubeMap]  (1) 2023.10.16
[Normal Mapping]  (0) 2023.10.12
[Light-2]  (1) 2023.10.11
[Light-1]  (1) 2023.10.10