게임엔진/일반

엔진 지원 시스템

tsyang 2024. 2. 11. 21:26

시스템 클래스 생성


 

게임에서 사용되는 각종 매니저와 같은 싱글톤 혹은 정적 클래스들은 어떻게 생성하고 파괴해야 할까? 이런 클래스들을 생성/파괴할 때는 클래스들끼리의 의존성을 고려해야 한다.

 


<주문형 생성>

유일한 클래스를 생성하는 메서드를 정의한 다음 의존성에 맞게 호출. 

//class RenderManager
//...
static RenderManager& get()
{
    static RenderManager sSingleton;
    return sSingleton;
}

RenderManager()
{
    VideoManager::get();
    TextureManager::get();
    //...
}
//...

 

그러나 위와 같은 방법은 아래와 같은 단점이 있다.

  1. 파괴 순서를 제어할 수 없어 파괴 과정에서 의존성이 훼손될 수 있음
  2. 매니저 클래스가 언제 만들어질지 알 수 없음(lazy하게 생성됨)
  3. 따라서 매니저의 인스턴스를 얻어올 때 초기화가 된다면 예기치 않게 시간이 오래 걸릴 수 있음

<단순한 방법>

 

우선 매니저마다 우선순위를 정하고 우선순위 큐에 등록한다음 알아서 생성/파괴 하도록 만드는 법도 가능함.

 

혹은 그냥 아래와 같이 단순하게 처리할 수 있다.

int main
{	
    gSomeManager1.startUp();
    gSomeManager3.startUp();
    gSomeManager2.startUp();
    
    //메인루프
    
    gSomeManager2.shutDown();
    gSomeManager3.shutDown();
    gSomeManager1.shutDown();
}

 

 

위와 같은 방식은 몇가지 장점이 있다.

  1. 방식이 단순하고 구현이 쉽다.
  2. 시작 순서가 명확하게 보인다.
  3. 유지보수가 쉽다.

물론 휴먼 에러의 여지가 있지만, 쉽게 수정 가능하다.

 

 

 


 

 

메모리 관리


<동적 메모리 할당>

C/C++의 동적 메모리 할당은 매우 느리다. 먼저 메모리 사이즈마다 할당 방식이 조금 다르기 때문에 관리 비용이 들고, 무엇보다 커널모드로의 전환이 일어날 수 있다. 특히 타이트루프 안에서 힙 할당이 발생하면 치명적일 수 있다.

 

그럼에도 동적 메모리 할당은 불가피한데, 미리 메모리를 할당해 놓고 나눠쓰는 방법으로 동적 할당에 따른 성능 문제를 해결할 수 있다. 

 

-스택 기반 할당

미리 메모리를 할당해 놓고 스택처럼 쓰는 것. 메모리 할당 요청이 오면 현재 포인터에 해당하는 주소를 반환하고, 사이즈만큼 주소를 증가시키면 된다. 단 이렇게 하면 임의의 순서로 메모리를 해제할 수 없다. (역순으로만 가능)

 

변종으로 포인터가 위,아래로 두 개 있는 스택을 쓸 수도 있는데, 이 경우 한쪽은 매 프레임마다 할당했다가 해제되는 임시 메모리, 한 쪽은 일반적인 메모리등쓰면 더 효율적일 수 었다.

 

 

-풀 할당자

메모리를 블록 단위로 쪼개서 연결 리스트따위로 관리하는 것. 별다른 테이블 없이 각 블록이 다음 사용 가능한 블록의 메모리 주소나 인덱스를 가리키게 할 수 있다. 

 

-단일 프레임 할당자

매 게임 루프 시작때 메모리 블록을 초기화시키는 방법. 이러면 할당한 메모리를 해제할 필요가 없고, 엄청나게 빠르다. 그러나 이미 해제된 메모리를 사용하는 오류를 범하지 않도록 주의해야 함.

 

-이중 버퍼 할당자

단일 프레임 할당자를 두개를 만들어서 할당된 메모리의 수명을 2프레임으로 늘리는 것.

 


 

<메모리 단편화>

 

위에서 언급한 할당자들을 쓰면 메모리 단편화를 걱정할 필요 없다. 그러나 크기가 각각 제각각인 객체들이 정해진 순서 없이 할당됐다 해제되는 경우라면 위 방법들을 쓸 수 없다. 

 

위와 같은 경우에는 힙을 주기적으로 조각 모음 해야한다. (관리 언어의 GC)

 

이때, compact 과정에서 메모리 공간의 주소가 바뀔 수 있다. 이를 재배치(relocation)라 한다. 이렇게 되면 원래 해당 메모리 주소를 참조하던 애들도 주소를 같이 바꿔줘야 하는데, 이를 위해 스마트 포인터나 핸들을 쓴다.

 

 

스마트 포인터는 포인터를 포함하는 클래스로, 전역 리스트에 등록되어서 메모리의 재배치가 발생할 때 새로운 주소를 가리킬 수 있도록 해준다.

 


핸들은 그냥 할당된 메모리 블록을 테이블로 관리하는 것이다. 사용하는 쪽에서는 메모리 주소를 곧바로 쓰지 않고 핸들의 인덱스를 사용한다. 이렇게 하면 재배치가 일어날 때 테이블만 바꿔주면 된다. 당연히 테이블 자체는 재배치되면 안 된다.

 

 

그러나 재배치가 불가능한 경우도 있다. 외부 라이브러리에서 스마트 포인터나 핸들을 안 쓰는 경우가 그렇다. 이를 해결하기 위한 최선의 방법은 그냥 재배치 가능한 메모리 영역이 아닌 다른 영역을 새로 만들어서 메모리를 할당하는 것. 그러나 이런 블록의 수가 얼마 안 된다면 걍 써도 별 문제 없을 수 있다.

 

 

-조각 모음 분산 처리

조각 모음을 한 프레임에 다 처리하지 말고 분산처리하면 게임 프레임에 거의 영향을 주지 않을 수 있음.

 

 

 

컨테이너 관리


컨테이너를 직접 만들어 쓰는 것은 다음의 장점이 있다.

  1. 완전 제어 가능
  2. 게임 개발 환경에 맞게 최적화 가능
  3. 보완 가능
  4. 외부 의존성 제거 가능
  5. 병행 자료 구조 제어 가능

그럼에도 C++ STL같은 표준 라이브러리를 쓰면 개발할 필요가 없으니 편함. 그러나 다음의 제약을 고려해야 함.

  1. 범용 컨테이너들은 특수 용도의 컨테이너보다 보통 느리다.
  2. 제네릭 컨테이너는 비제네릭보다 메모리 많이 쓴다.
  3. C++ 표준 라이브러리는 동적 메모리 할당을 빈번하게 쓴다. 그리고 이걸 제어하는게 힘들다.

이외에도 C++이라면 boost/Folly같은 대안이 있음.

 


<후치증가 vs 전치증가>

i++같은 후치 증가는 다음의 일들을 수행한다.

  1. 원래 값을 어딘가 복사한다.
  2. 원래 값을 증가시킨다.
  3. 복사한 값을 리턴한다.

 

++i같은 전치 증가는 다음의 일들을 수행한다.

  1. 원래 값을 증가시킨다.
  2. 원래 값을 리턴한다.

후치 증가가 당연히 복사의 과정이 있으므로 느리다. 물론 for문 돌릴 때 단순 반복시키는 경우 컴파일러가 알아서 최적화 하기 때문에 걱정할 일 없다. 

 

그러나 for문 안에서 실제로 ++i같은 값을 사용한다면 얘기가 살짝 달라지는데, 이게 CPU stall (다음 명령어가 전치 증가된 값이 메모리에 저장될 때 까지 기다릴 수 있음)을 발생시킬 수 있기 때문에 후치증가가 나을 수 있다.(복사비용에 따라 다름)

 


 

<해시 테이블>

직접 map(dictionary)컨테이너를 만든다면 해시 테이블에 대해 알아야함. 해시 테이블은 크게 개방형/폐쇄형으로 나눌 수 있음.

 

-개방형 

 

키가 충돌하면 동적 할당한다음 연결 리스트로 관리하는 것. 동적 메모리 할당이 발생한다는 단점 존재.

 

 

-폐쇄형

 

 

폐쇄형은 고정된 메모리 블록을 할당해놓고, 키 충돌이 발생하면 탐색 알고리즘에 따라 비어있는 블록을 탐지하고 값을 할당한다.

 

이런 탐지 알고리즘 중 가장 단순한 방식은 선형 탐지로, 이미 자리가 있으면 그냥 $\pm i$번째 주소를 탐색한다. 그러나 이런 방식은 특정 블록 근처에 값들이 뭉칠 수 있다. 이걸 방지하려면 이차 탐지 알고리즘을 쓰면 된다. 이건 $\pm i^2$ 자리를 탐색한다. 이외에도 탐지 알고리즘은 엄청 많다. 로빈후드 해시나 graveyard hash방식 참고.

 

폐쇄형 해시를 사용할 때는 테이블의 크기가 소수가 되는게 좋다.

 

 

 

-해시 함수

 

해시 값을 계산하는 함수를 해시 함수라 한다. 암호화를 안 사용한다면 xxHash, murmur hash나 암호화를 안 쓴다면 SHA32, MD5등등 찾아보면 좋을 듯.

 

 

문자열


문자열 클래스를 다룰 땐... 해당 클래스가 copy on write나 이동 생성자 따위를 지원하는지 확인해야 함. 

 


<문자열 ID>

문자열을 고유 식별자 (id)로 쓰는 경우도 있는데, 이런 경우 별자 비교를 하는데에 문자열 비교 알고리즘을 쓴다면 비효율적일 것이다. 그렇다고 정수id를 쓰면 가독성이 박살난다. 

 

해시 문자열 ID

이를 위해서 내부적인 id가 문자열이 아닌 문자열의 해시 값을 사용하도록 할 수 있다. 이러면 가독성도 챙기고, 비교 연산도 엄청 빠르다. 해시 키 충돌이 발생할 수 있으나, 해시 함수를 잘 고르면 매우 낮은 확률의 일이다. (실제로 언차티드 개발 중에 충돌 없었다 함.)

 

이런 해시ID를 쓸때는 전역 문자열 테이블을 쓸 수 있다. (인터닝) 또, 런타임 이전에 미리 해시 값을 계산해둘 수도 있다. 

 


<현지화>

현지화를 할 때는 인코딩 방식을 잘 정하자.