코승호딩의 메모장

[Orthographic Projection] 본문

DirectX12/DirectX12 응용

[Orthographic Projection]

코승호딩 2023. 10. 22. 12:58

이번 글에서는 직교 투영을 이용하여 UI와 같은 오브젝트를 구현하려고 한다. 직교 투영은 원근 투영과는 달리 z 값에 다라 크기가 줄어들거나 늘어나는 것이 아니며 간단하게 DirectX의 XMMatrixOrthographicLH 함수를 이용해서 투영 변환 행렬을 생성하면 된다. 주의할 점은 이 전까지는 카메라로 생성되는 뷰 행렬과 투영 행렬은 모두 패스 당 상수 버퍼 즉 패스 당 한 번만 업데이트 되도록 하였다. 그러나 이렇게만 구현한다면 여러 개의 게임 오브젝트 중에 UI만 찍을 카메라로 직교 투영 행렬을 상수 버퍼에 넘겨주더라도 그 후에 다시 기본 오브젝트들을 찍을 메인 카메라의 원근 투영 행렬 계산을 통해 원근 투영 행렬로 덮어 써 질 것이다. 따라서 다른 방법을 생각해봐야 한다.

 

가장 쉽게 생각할 수 있는 방법은 패스 당 상수 버퍼에 원근 투영 변환 행렬뿐만 아니라 직교 투영 변환 행렬도 넘겨주는 방법이 있을 것이다. 그러나 이렇게 되면 쉐이더 코드 안에서 UI를 찍을 카메라인지 아니면 기본 원근 투영을 진행할 게임 오브젝트인지에 따라서 if문 혹은 switch문으로 분기하여 조건에 맞게 투영 행렬을 동차 좌표계에 곱해줘야 할 것이다. 이 방법은 어느 정도 속도는 빠를지 몰라도 쉐이더 코드가 어지러워 진다는 단점이 있다. 쉐이더 코드에서 매번 투영 행렬을 사용할 때 조건문을 달아야 하기 때문이다. 

 

다음 두 번째 방법은 패스 당 상수 버퍼가 아닌 오브젝트 당 상수 버퍼에 원근 투영 변환 행렬을 넣어주는 것이다. 물론 카메라 행렬은 프레임 당 한 번만 업데이트 되기 때문에 굳이 오브젝트 당 상수 버퍼에 넣는 것이 합당하지 않다고 생각할 수 있지만 이전 방법대로라면 UI인지 아닌지를 어차피 오브젝트 당 상수 버퍼로 넘겨줘야 하기 때문에 굳이 이 정보를 넘길 바에는 아예 오브젝트 당 상수 버퍼에 원근 투영 혹은 직교 투영에 따라서 투영 변환 행렬을 넘겨주는 방법이 쉬울 것이다. 또한 쉐이더 코드 안에서도 별다른 작업 없이 투영 행렬을 사용하는 코드에 gPassConstants.proj이 아닌 gObjectConstants.proj으로 변경하면 문제가 없다. 따라서 이번 구현에서는 이 방법을 사용하도록 한다. 

 

참고로 UI를 찍을 카메라는 움직이지 않고 (0, 0, 0)에서 가운데만 직교 투영을 진행해야 한다. 만약 UI 카메라까지 움직인다면 UI가 2D로 그려지지 않고 카메라 행렬에 따라서 3D로 회전 및 이동이 진행될 것이다. 따라서 UI 카메라의 투영 변환 행렬 뿐만 아니라 카메라 행렬(뷰 행렬)도 함께 계산하여 넘겨주는 것이 합당하다.


Layer

 

유니티의 카메라 컴포넌트에는 레이어가 존재한다. 레이어는 해당 카메라가 어떤 오브젝트를 찍을지를 선택할 수 있고 선택한 오브젝트가 아니라면 그 오브젝트는 해당 카메라가 찍지 않는다. 예를 들어서 UI를 찍는 카메라가 있다면 다른 레이어들은 꺼지고 오로지 UI 레이어만 켜진 상태로 변경한다. 그렇다면 이 카메라는 UI만 찍는 카메라가 될 것이다. 간단하게 카메라의 렌더 부분에서 게임 오브젝트를 검사하며 자신이 찍어야할 레이어가 아니라면 렌더하지 않게 구현하면 될 것이다.

 

enum
{
	LAYER_COUNT = 32,
};

class SceneManager
{
	//...
private:
	//...
	array<wstring, LAYER_COUNT> _layerNames;
	map<wstring, uint8> _layerIndex;
};

우선 씬 매니저에서 레이어 목록을 가지고 있도록 다음과 같이 구현한다. _layerNames는 사용할 레이어의 각 이름 정보를 가지고 있다. 그리고 _layerIndex는 맵을 통해 레이어 이름을 입력하면 몇 번 레이어인지를 밸류 값으로 주도록 하였다.

 

shared_ptr<Scene> SceneManager::LoadTestScene()
{
#pragma region LayerMask
	SetLayerName(0, L"Default");
	SetLayerName(1, L"UI");
#pragma endregion
	//...
}

이제 사용할 씬에서 다음과 같이 Set 함수를 통해 레이어 색인과 레이어 이름을 넣어주면 _layerNames와 _layerIndex에 각각 이름과 레이어 색인이 맵핑될 것이다. 이 말은 즉, 씬에서 어떠한 레이어들을 사용할지에 해당한다. 이제 씬에서 사용할 레이어들을 정의하였기 때문에 오브젝트 또한 자신이 어떤 레이어를 가지고 있는지 정보를 갖고 있어야 한다.

 

class GameObject : public Object, public enable_shared_from_this<GameObject>
{
public:
	//...
	void SetLayerIndex(uint8 layer) { _layerIndex = layer; }
	uint8 GetLayerIndex() { return _layerIndex; }
private:
	//...
	uint8 _layerIndex = 0;
};

게임 오브젝트는 단지 멤버 변수로 자신이 어떠한 레이어 값을 가지고 있는지를 저장하기만 할 뿐이다. 만약 이 게임 오브젝트가 UI라면 위 씬 매니저의 코드처럼 UI가 인덱스 1에 해당하므로 게임 오브젝트도 1이라는 레이어 색인을 갖고 있다.

 

class Camera : public Component
{
public:
	//...
	void SetCullingMaskLayerOnOff(uint8 layer, bool on)
	{
		if (on)
			_cullingMask |= (1 << layer);
		else
			_cullingMask &= ~(1 << layer);
	}

	void SetCullingMaskAll() { SetCullingMask(UINT32_MAX); }
	void SetCullingMask(uint32 mask) { _cullingMask = mask; }
	bool IsCulled(uint8 layer) { return (_cullingMask & (1 << layer)) != 0; }
private:
	//...
	uint32 _cullingMask = 0;
};

게임 오브젝트와 씬의 레이어를 모두 정의하였으므로 이제 카메라 자신이 어떠한 레이어들을 찍을지를 구현해야 한다. 우선 내부적으로 컬링 마스크라는 32비트 값을 가지고 있도록 한다. 이 말은 즉 최대 32개의 비트를 레이어로 사용한다는 뜻이다. 0번 레이어는 32비트 중 가장 최하위 값에 해당하고 31번 레이어는 가장 최상위 비트에 해당한다. SetCullingMaskLayerOnOff 함수는 해당 레이어 값을 인자로 받아 on이 true일 경우에 or 연산을 통해 해당 레이어 비트만 컬링 마스크를 끈다. 그리고 false라면 해당 레이어 비트만 컬링 마스크를 키게 된다. (참고로 컬링 마스크는 컬링을 할 대상으로 컬링 마스크가 켜져 있다면 해당 비트를 컬링을 통해 그리지 않겠다는 뜻이다.)

 

만약에 왼쪽과 같은 32비트 값을 가진 카메라가 있다고 가정하자. 그리고 오른쪽과 같이 UI 즉 1에 해당하는 레이어만 컬링 마스크를 키고 싶다고 가정할 때, 우선 레이어의 인덱스만큼 쉬프트 연산을 진행한다. 

 

쉬프트를 진행하면 오른쪽 그림과 같이 변경되는데 만약 레이어 8이라면 8번 쉬프트를 진행하여 자신의 위치를 찾는다. 그리고 위 두 비트를 or 연산을 하게 된다면 둘 중 하나가 1이라면 1이 되고 하나라도 1이 없다면 0의 값이 되므로 오른쪽 그림에서 UI에 해당하는 비트 외 다른 비트들은 모두 0이기 때문에 왼쪽 컬링 마스크를 따라가게 된다.

 

결국 다음과 같이 해당하는 비트만 1로 켜지게 되는 것이다. off를 진행할 때도 마찬가지로 계산을 해보면 해당 비트만 킨다.

 

나머지 함수들은 쉽게 알 수 있을 것이고 마지막 IsCulled 함수는 해당 레이어 값을 받아서 자신의 레이어와 비교하여 둘 다 켜져 있다면 true를 반환하여 컬링을 진행할 것이고 둘 중 하나라도 켜져 있지 않다면 false를 반환하여 컬링을 하지 않는다.

 

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

이제 게임 오브젝트를 돌면서 게임 오브젝트의 레이어 인덱스를 IsCulled 비트 연산을 통해 컬링할 대상을 컬러준다.


Orthographic Projection

이제 레이어도 만들었으니 본격적으로 직교 투영을 진행해보도록 하자. 

 

void Camera::FinalUpdate()
{
	_matView = GetTransform()->GetLocalToWorldMatrix().Invert();

	float width = static_cast<float>(gEngine->GetWindow().width);
	float height = static_cast<float>(gEngine->GetWindow().height);

	if (_type == PROJECTION_TYPE::PERSPECTIVE)
		_matProjection = ::XMMatrixPerspectiveFovLH(_fov, width / height, _near, _far);
	else
		_matProjection = ::XMMatrixOrthographicLH(width * _scale, height * _scale, _near, _far);

	// Regenerate Frutum
	_frustum.CreateFromMatrix(_frustum, _matProjection);
	_frustum.Transform(_frustum, _matView.Invert());
}

우선 기본적으로 투영 타입에 따라 투영 행렬을 직교 혹은 원근 투영으로 계산하는 것은 준비되어 있다. 그렇다면 앞서 말했듯이 상수 버퍼에 투영 행렬과 뷰 행렬을 추가하도록 수정하자.

 

struct ObjectConstants
{
    row_major matrix    world;
    row_major matrix    viewProj;
    uint                materialIndex;
    float3              padding;
};

ConstantBuffer<ObjectConstants> gObjConstants : register(b1);

HLSL에서 오브젝트 당 상수 버퍼에 viewProj을 추가한 모습이다. 

 

struct ObjectConstants
{
	Matrix	matWorld = Matrix::Identity;
	Matrix  matViewProj = Matrix::Identity;
	uint32	materialIndex = 0;
	Vec3	padding;
};

당연히 응용 프로그램에서도 오브젝트 당 상수 버퍼 구조체를 수정한다.

 

void Camera::Render()
{
	MatView = _matView;
	MatProjection = _matProjection;
	//...
}

그리고 카메라를 렌더하기 전 정적 멤버 변수로 선언된 MatView와 MatProjection을 업데이트 한다. 이런 과정을 거치는 이뉴는 정적 멤버 변수에 각 카메라 자신이 가지고 있는 투영, 뷰 행렬을 오브젝트 당 상수 버퍼로 넘기기 위함이다. 만약 이 코드가 카메라의 FinalUpdate에서 실행된다면 모든 카메라의 FinalUpdate가 호출되며 덮어 쓰게 될 것이다.

 

void Transform::PushData()
{
	ObjectConstants objectConstants = {};
	objectConstants.matWorld = _matWorld;
	objectConstants.materialIndex = GetGameObject()->GetMatIndex();
	objectConstants.matViewProj = Camera::MatView * Camera::MatProjection;
	
	//CopyData
	//...
}

이제 Transform 컴포넌트에서 오브젝트 당 상수 버퍼에 memcpy를 할 때, 카메라의 정적 멤버 변수인 MatView와 MatProjection을 곱하여 viewProj 행렬을 넘겨준다. 이 때 정적 멤버 변수는 카메라의 렌더 함수를 부를 때마다 자신의 행렬로 변경하기 때문에 문제 없이 잘 실행이 된다.

 

#pragma region MainCamera
	//...
	uint8 layerIndex = GET_SINGLE(SceneManager)->LayerNameToIndex(L"UI");
	mainCamera->GetCamera()->SetCullingMaskLayerOnOff(layerIndex, true);
#pragma endregion

이제 UI가 아닌 다른 게임 오브젝트 모두를 찍을 카메라에서는 UI의 컬링 마스크만 켜야 한다. 따라서 씬 매니저에서 UI의 인덱스 값을 받아서 이 값을 메인 카메라의 컬링 마스크 함수에 넘겨 주게 되면 해당 UI 비트만 컬링 마스크가 켜진다.

 

#pragma region UICamera
	//...
	camera->GetCamera()->SetProjectionType(PROJECTION_TYPE::ORTHOGRAPHIC);
	uint8 layerIndex = GET_SINGLE(SceneManager)->LayerNameToIndex(L"UI");
	camera->GetCamera()->SetCullingMaskAll();
	camera->GetCamera()->SetCullingMaskLayerOnOff(layerIndex, false);
#pragma endregion

그리고 UI만 찍을 UI 카메라에서는 모든 컬링 마스크를 킨 다음 UI 컬링 마스크만 끄는 것이다.

 

#pragma region UITest
	shared_ptr<GameObject> gameObject = make_shared<GameObject>();
	gameObject->SetLayerIndex(GET_SINGLE(SceneManager)->LayerNameToIndex(L"UI"));
	//...
#pragma endregion

다음으로 UI 게임 오브젝트를 생성하여 자신의 레이어를 UI로 지정하면 끝이다. 

 

이제 실행해보면 다음과 같이 뉴진스 사진만 UI로 생성하여 카메라에 부착되어 있는 모습을 볼 수 있다. 


이번에는 생각보다 간단하게 직교 투영을 만들 수 있었다. 아마도 기존에 설계를 잘 해놓았기 때문에 금방 만들 수 있었던것 같다. 게임공학과 학생으로써 선배님들의 졸업작품들을 보면 굉장히 존경스럽지만 한편으로는 아쉽다라는 생각도 있었다. 왜냐하면 게임은 출시해도 될 정도로 재미있고 잘 만들었지만 UI 쪽은 많이 신경을 쓰지 못하였던 것 같다. 사실 본인은 디자인과에서 게임공학과로 전과를 한 학생으로써 어딘가 디자인적으로 불편함이 있다면 참지 못하는 성격이 있다. 따라서 UI 또한 굉장히 게임에서 흥미를 느끼게 하고 미적 아름다움을 줄 수 있는 중요한 요소라고 생각한다.   

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

[Deferred Rendering]  (0) 2023.10.30
[Render Target]  (0) 2023.10.22
[Dynamic Indexing]  (1) 2023.10.21
[Frustum Culling]  (0) 2023.10.21
[CubeMap]  (1) 2023.10.16
[Normal Mapping]  (0) 2023.10.12
[Light-2]  (1) 2023.10.11