• 게임을 만드는 데 있어서 중요한 습관 한 가지

1. 최적화를 시키는 습관

게임을 구현하다 보면 보통 동작이 만들어졌는지 확인만 한 상태로 그대로 넘어가는 경우가 있다.

 

그런데 만약 이런 상황에서 구현된 코드를 한 번 더 훑어본 후에 최적화를 할만한 요소가 있는지 확인을 하고 나서

해당 부분을 최적화를 시키면 더욱 좋은 퍼포먼스를 보여줄 수가 있다.

 

만약 작업을 하는데 시간이 여유롭다면 최적화 부분을 신경을 써주면 좋다고 할 수 있지만,

 

대부분 시간이 여유롭지 않다고 생각하기 때문에 이후에 코드 리펙토링을 통해 최적화를 하게 된다.

 

따라서 작업에 주어진 시간이 넉넉하다고 판단이 되면,

그 당시 구현된 내용들을 확인하고 최적화를 시도해보는 것도 나쁘지 않다고 생각한다.

 

 

 

2. 최적화에 쓰이는 자료구조는?

 

Array(배열)와 unordered_map 외에는 쓰이는 것이 별로 없다.

그렇기 때문에 작업을 진행하면서 주로 이 두 가지를 활용하여 구현한 부분을 최적화하게 된다.

 

이와 비슷한 자료구조 중에서는 "Linked list"라는 것이 있다.

 

그런데 Linked list의 가장 큰 단점은 파편화가 되어 있기 때문에 메모리의 속도가 낮아질 수 밖에 없는 것이다.

그래서 보통은 Linked list보다는 Array를 주로 쓰게 되는데, Array 자체가 관리하는 부분이 까다롭기 때문에

해당 부분은 STL_Vector를 사용하여 관리를 하게 된다.

 

STL_Vector도 굉장히 좋은 자료구조 중에 하나라고 말할 수 있지만,

너무 범용적으로 사용이 되고 있기 때문에 Array에 비해서 상당히 무거운 편이다.

그래서 주로 메모리 관리하는 부분에서 따로 만들어서 쓰이게 된다.

 

따라서 프로그램을 최적화하는 부분에 있어서는 목적과 상황에 맞게 Array와 unordered_map

이 두가지를 잘 활용하여 최적화를 하면 좋은 퍼포먼스를 얻을 수 있게 된다.

 

 

  • WorldTransform에서 지정한 부모에 상대적인 LocalTransform으로 변환하는 과정

언리얼과 유니티 엔진을 사용해보면 부모 트랜스폼과 자식 트랜스폼의 관계를 많이 보게 된다.

이제 그 두 관계의 Scale, Rotation, Position이 어떻게 변환이 되는지 변환되는 과정을 Scale부터 순서대로 알아보자.

 

 

  • Scale

먼저 서로 연관관계가 없는 트랜스 폼이 두 개가 있다.

여기서 이제 한 개는 스케일이 0.25 나머지 한 개는 1이다.

이 두 개를 서로 연관 지어서 계층 구조를 만들게 되면 1번이 부모가 되고 2번이 자식이 되는 관계가 형성된다.

 

그럼 여기서 부모 오브젝트의 스케일이 0.25면 자식의 스케일은 1이 될까?

 

1이 되지 않는다. 

 

처음에 오브젝트가 만들어지면 해당 스케일은 월드 스케일을 기준으로 해서 오브젝트가 만들어진다.

그런데 부모와 자식으로 나누어져 있는 경우에는 자식이 부모로부터의 영향을 받게 되므로.

자식의 스케일은 월드가 아닌 로컬 스케일로 변환이 된다.

 

로컬 스케일로 변환이 된 상태에서 월드 스케일로 되어있는 부모 스케일의 정보를 알아내야 하기 때문에

자식의 로컬 스케일에서 부모의 월드 스케일 값을 곱했을 경우에 1이 나와야 한다.

 

왜 1이 나와야 하냐면 게임 오브젝트의 기본 스케일의 값은 1이기 때문이다.

 

따라서 부모의 월드 스케일이 0.25라면 자식의 로컬 스케일의 값은 1이 아닌 4가 된다.

 

다른 예를 들어보자면

서로 연관이 없는 오브젝트 두 개 중에서 1번의 스케일 값은 4, 2번은 1이다. 

 

이런 경우 다음과 같이 부모와 자식 관계를 형성하면 자식의 스케일 값은 몇이 나오게 될까?

위에서 설명한 것처럼 부모의 월드 스케일 값을 곱했을 경우에 1이 나와야 하기 때문에 자식 스케일의 값은 0.25가 된다.

 

 

  • Rotation

회전도 스케일과 같이 유사하게 생각해볼 수가 있다.

현재 부모의 회전 값이 20이고 자식의 회전 값은 10이라고 가정하고 자식이 부모 안으로 들어가게 된다면

자식의 회전 값은 몇이 나올까?

 

자식의 회전 값이 부모의 회전 값보다 10만큼 덜 돌아가 있기 때문에 -10이라고 할 수 있다.

부모의 회전 값에 대한 역수는 자식의 회전 값에서 반대 방향으로 회전을 하여 음수를 붙이게 되는데 이렇게 하면

반대 방향이라는 것은 곱셈에 대한 역원이 아닌 덧셈에 대한 역원이라고 할 수 있다.

 

 

  • Position

게임에 A와 B의 오브젝트가 있다. 여기서 둘 중 하나의 오브젝트의 위치를 알려면 A - B 또는 B - A를 통해 알 수 있다.

그런데 왜 위치는 덧셈 연산일까?

 

해당 위치에 이동 행렬에서 반대 방향에 대한 것은 역행렬이고 이것은 덧셈에 대한 역원에 들어가기 때문에

덧셈 연산을 쓸 수 있다고 생각한다.

 

그런데 만약 회전이 들어간다면 어떻게 될까?

 

회전이 들어가게 되면 위치 역시 변화가 생긴다.

 

그렇다면 B가 A의 자식으로 들어가고 A의 회전 값이 20, B의 회전 값이 10이라고 했을 때

부모의 트랜스폼의 정보는 어떻게 계산을 해야 될까?

 

A와 B의 오브젝트의 거리가 (20, 10)이라고 가정하였을 때

일단 A(부모)가 회전한 값(20)을 초기 값(0)으로 먼저 되돌려 주고 기본 상태로 만든다.

그렇게 되면 (20, 10)이라는 벡터 역시 되돌려준 회전 값만큼 반대로 회전을 하게 되면서

부모로부터 얼마만큼 떨어졌는지 측정을 할 수 있고, 

그 후에 스케일로 다시 역변환을 해줘야 올바른 로컬 좌표의 위치를 얻게 된다.

 

 

  • 부모 GameObject에 WorldTransform을 저장하는 이유는?

부모 오브젝트에 월드 트랜스폼이 없으면 자식 오브젝트에서 월드 트랜스폼의 정보를 알아낼 때

부모 오브젝트 안에 있는 자식의 오브젝트 들의 정보를 처음부터 끝까지 모두 계산을 해야 하기 때문이다.

만약 부모 오브젝트에 월드 트랜스폼이 있다고 한다면 더 많은 메모리를 소비한다는 단점이 있지만

월드 트랜스폼이 없는 것보다는 정보를 더 빠르게 찾을 수가 있다는 장점이 있다.

Circle 구조체

Circle.cpp

Mesh에 Circle 바운드 정보를 추가

Mesh.h

Mesh.cpp

Camera에 Circle Bound를 추가하고 컬링 구현

Camera2D.h

Camera2D.cpp

LoadScene에서 Circle의 Radius 값을 지정 후 LoadResources에서 계산해서 저장까지 수행 된 바운드를 불러온다.

GameEngine.cpp / LoadScene()

GameEngine.cpp / LoadResources()

Render2D에서 생성된 배경오브젝트, 컬링된 오브젝트, 렌더링된 오브젝트의 개수를 파악하기 위해

변수 세 가지를 만들어준다.

SoftRenderer.cpp / Render2D()

그 다음 Render2D에 카메라와 카메라 바운드를 가져온다.

 

마지막으로 카메라의 위치에 충돌영역을 만들어 주고,

해당 영역을 뷰 좌표계로 변환하여 카메라 바운딩 영역 충돌 체크를 통해서

바운딩 영역 안의 오브젝트들은 렌더링이 되고 영역 밖의 오브젝트들은 컬링이 되도록 해준다.

실행결과

쿼드트리란?

트리 자료구조중 하나로 부모 노드 아래에 자식 노드를 4개씩 가지고 있는 트리이다.

2차원의 공간을 4개의 자식노드로 재귀적으로 분할하고, 거대한 지형을 빠르게 검색이 가능하다.

주로 이미지 처리, 메시 생성, 충돌, 컬링 등 다양한 곳에서 최적화 기법으로 사용되고 있다.

 

소프트렌더러 프로젝트에 여러 개의 게임 오브젝트를 구현.

GameEngine.cpp / LoadScene

 

 

소프트렌더러 프로젝트에서 카메라를 따라 플레이어를 구현

SoftRenderer2D.cpp / Update2D

 

 

결과

 

 

소프트렌더 전체 프로세스 정리

 

 

 

렌더링 파이프라인 

 

GameEngine, GameObject2D 클래스에서 각 멤버 변수 유형에 대한 적절한 이유는?

 

Smart Pointer에는 세 가지 포인터가 존재한다.

1. Shared_ptr : 인스턴스를 가리키는 포인터의 갯수를 카운팅하여 메모리 누수가 발생하지 않도록 한다.
2. unique_ptr : 하나의 포인터만이 해당 인스턴스를 가리킬 수 있도록 한다.
3. weak_ptr : 하나 이상의 shared_ptr가 가리키는 객체를 참조할 수 있지만 reference count를 늘리지 않는다.

 

C#과 언리얼에서는 unique_ptr이 아닌 Shared_ptr을 사용한다.

왜냐하면 C#과 언리얼 엔진에서는 GC(가비지컬렉션)를 통해서 메모리를 관리하기 때문에 어쩔 수 없이

Shared_ptr을 사용을 해야한다.

 

_Player

_QuadMesh

_Camera2D

 

위에 3가지 멤버 변수는 각각의 인스턴스에 대한 유니크 포인터이다.

이것을 사용하는 이유는 파생 클래스를 처리하거나 또는 원하는 곳에서 객체를 생성과 소멸시키기 위해서이다.

 

_Transform 

Transform2D는 하나의 변수로 선언한다. 왜냐하면 모든 게임 오브젝트는 최소 하나씩의 값을 가지고 있기 때문이다.

 

_MeshPtr

Mesh는 일반 포인터를 사용한다. 왜냐하면 Mesh는 엔진을 초기화할 때 이미 생성된 초기 Mesh 데이터를 사용하기

때문이다. 또한 Mesh 데이터는 처음에 생성이 된 후에는 변화가 생겨서는 안 된다.

+ Recent posts