5.1 서브시스템 시작과 종료

게임 엔진은 서로 통신하는 수많은 하부 시스템이 모여 이루어진 복잡한 소프트웨어이다.

게임 엔진을 시작할 때 각 하부 시스템들을 정해진 순서에 따라 설정하고 초기화해야 한다.

 

5.1.1 C++ 정적 초기화 순서

C++에서는 프로그램의 시작 지점(entry point) 혹은 윈도우의 WinMain()이 호출되기 전에 전역 객체와 정적 객체를 생성한다. 전역 객체와 정적 객체들이 생성되는 순서는 임의적이다.

 

게임 엔진의 하부 시스템을 싱글턴 클래스로 디자인 할 수 없는 이유가 여기에 있다. 전역 혹은 정적 클래스 인스턴스를 생성하고 파괴하는 순서를 제어할 방법이 없기 때문이다.

 

5.1.1.1 주문형 생성 (좋지 않은 방법)

함수 안에서 선언된 정적 변수는  main() 실행 전이 아니라 해당 함수가 처음 호출될 때 생성된다.

그러므로 전역 싱글턴 객체를 함수의 정적 변수로 만들면 생성 순서를 제어할 수 있다.

객체의 파괴 순서는 제어할 수 없기 때문에 의존 관계가 있는 객체를 파괴할 때 문제가 생길 수 있다.

5.1.2 간단하고 제대로 된 방법

생성자와 파괴자를 사용하지 않고, 싱글턴 매니저 클래스에 매니저의 시작과 종료를 담당하는 함수를 명시적으로 정의하여 사용하는 방법이다.

class MemoryManager{
public:
    RenderManager() { // 아무것도 안함 }
    ~RenderManager() { // 아무것도 안함 }
    
    void startup() { // 매니저를 시작 }
    void shutdown() { // 매니저를 종료 }

}

MemoryManager gMemoryManager;
PhysicsManager gPhysicsManager;

int main () {
    gMemoryManager.startup();
    gPhysicsManager.startup();
    
    // 반대 순서로 종료함
    gPyhsicsManager.shutdown();
    gMemoryManager.shutdown();
}

엔진을 시작한다는 것은 단순히 싱글턴 클래스 순서를 정하는 것과는 다르다. 엔진 초기화 과정에서 다양한 운영체제 서비스 및 외부 라이브러리 등을 시작해야 한다.

 

5.2 메모리 관리

메모리가 성능에 영향을 끼치는 형태

1. 동적 메모리 할당, new malloc 연산자는 매우 느리기 때문에 동적 메모리 할당을 아예 피하거나 메모리 할당자를 직접 구현해 사용해야 한다.

2. 데이터를 연속적인 메모리 블록에 효율적으로 배치해야 CPU에서 처리하는 속도가 빠르다.

 

5.2.1 동적 메모리 할당 최적화

힙 할당자(malloc, new)가 느린 이유

1. 힙 할당자는 범용 목적이기 떄문에 관리하는 부가적인 비용이 들기 때문에 느리다.

2. 대부분의 운영체제에서는 힙 할당자를 사용할 때 context switching을 두 번 하기 때문에 (유저모드 커널모드 전환)

 

그렇기 때문에 힙 할당은 최소화하고, 타이트 루프 안에서는 절대 힙 할당을 하지 말아야 한다.

 

사용자 제작 할당자가 운영체제의 힙 할당자보다 대체적으로 성능이 뛰어난 이유

1. 사용자 제작 할당자는 미리 할당된 메모리 블록을 이용하기 때문에 처음에 메모리를 할당 받은 이후에는 context switching 비용을 지불하지 않아도 된다.

2. 사용 패턴을 예측할 수 있기 때문에 효율적으로 동작할 수 있다.

 

5.2.1.1 스택 기반 할당자

크고 연속적인 메모리 블록을 미리 할당(전역 변수 배열로 하는 방법 or malloc, new를 사용하는 방법)하고 스택의 꼭대기를 가리키는 포인터를 하나 유지한다.

스택의 특성상 임의의 순서로 메모리를 해제할 수 없도록 해야 하기 때문에 해제가 역순으로 수행되어야 한다. 이 원칙을 강제할 수 있는 단순한 방법은 개별 블록들이 아예 해제될 수 없게 만들고 스택의 꼭대기를 이전에 표시한 부분까지 롤백 하는 함수를 만들어 사용하는 방법이다.

 

5.2.1.2 풀 할당자

작은 메모리 블록을 같은 크기로 여러 개 할당하는 경우 사용 (같은 크기의 객체를 여러개 사용할 경우)

개별 원소들의 크기에 정확히 배수가 되는 큰 메모리 블록을 할당하는 것

사용 가능 리스트를 두고 메모리 할당 요청이 들어오면 리스트의 첫 원소가 가리키는 공간을 리턴한다. 사용 가능 리스트를 사용할 경우 각 원소마다 포인터 하나를 저장하는 것은 메모리 낭비이기 때문에 블록 자체에 다음 블록을 가리키는 포인터를 저장하는 방법을 사용하면 된다.

https://lemonyun.tistory.com/83

 

19. 최적화 패턴 - 객체 풀

객체를 매번 할당, 해제하지 않고 고정 크기 풀에 들어 있는 객체를 재사용함으로써 메모리 사용 성능을 개선한다. 19.2 동기 새로운 객체를 생성하고 제거하는 과정에서 발생하는 메모리 단편화

lemonyun.tistory.com

 

5.2.1.3 메모리 정렬 할당자

c++의 new가 메모리를 할당하는 방식이다.

메모리 정렬 할당자는 반드시 정렬된 메모리를 리턴할 수 있는 기능이 있어야 한다.

실제 요청된 것보다 조금 큰 메모리를 할당하고 블록의 주소를 살짝 위로 조정해서 정렬을 맞춘 다음 정렬된 주소를 리턴하면 된다.

 

5.2.1.4 단일 프레임과 이중 버퍼 메모리 할당자

 

단일 프레임 할당자

매 프레임마다 단일 프레임 할당자(스택 할당자의 형태로 구현)의 버퍼를 초기화한다. (할당한 메모리를 해제할 필요가 없다)

 

이중 버퍼 할당자

i번째 프레임에서 할당한 메모리 블록을 (i+1)번째 프레임에서 사용할 수 있게 해 주는 것

똑같은 크기의 단일 프레임 스택 할당자를 두 개 만들어 프레임마다 번걸아가면서 사용하는 것이다.

 

5.2.2 메모리 단편화

RAM에서 사용 가능한 메모리가 충분히 존재하지만 중간에 사용중인 공간이 있어서 연속적인 메모리 할당이 불가능한 상태를 메모리 단편화가 발생했다고 한다.

가상 메모리를 지원하는 운영체제의 경우 메모리 단편화가 큰 문제가 되지 않을 수도 있지만, 대부분의 콘솔 게임 엔진들은 가상 메모리가 갖는 본질적인 성능 저하 때문에 가상 메모리를 잘 활용하지 않는다.

 

5.2.2.1 스택 기반 할당자와 풀 할당자로 단편화 예방

스택 할당자는 언제나 연속적으로 할당되고 메모리 블록을 해제할 때는 할당 순서의 반대로 해야하기 때문에 메모리 단편화를 겪지 않는다.

 

풀 할당자는 모든 블록의 크기가 같기 때문에 연속된 공간이 부족해 할당이 실패하는 일은 없다.

 

5.2.2.2 조각모음과 재배치

크기가 제각각인 객체(풀 할당자를 사용할 수 없음)를 사용하거나 객체들이 순서 없이 할당됐다가 해제되는 경우(스택 기반 할당자를 사용할 수 없음)에는 단편화 문제를 해결하기 위해서 힙을 주기적으로 조각 모음(defragmentation)해야 한다.

 

이미 할당되어 사용되고 있는 메모리 블록들을 주소가 낮은 쪽의 구멍을 메우면서 옮겨야 하기 때문에 메모리 블록을 가리키는 포인터들을 모두 찾아 새로운 주소를 가리키도록 포인터 재배치를 해야 한다.

 

포인터 재배치를 위해 포인터들을 일일이 관리하는 방법을 사용할 수도 있고 포인터 대신 스마트 포인터 혹은 핸들을 사용하여 관리하는 방법도 있다.

스마트 포인터를 사용하는 경우 모든 메모리 블록을 가리키는 스마트 포인터들을 전역 연결 리스트에 추가하여 관리한다.

힙에서 어떤 블록을 재배치 하는 경우, 연결 리스트의 모든 스마트 포인터를 검색해서 재배치되는 블록을 가리키는 포인터를 새로운 주소로 업데이트한다.

 

조각 모음으로 인한 성능 저하를 분산하기 위해 힙 전체를 한꺼번에 조각 모음하지 않고 여러 프레임에 걸쳐 진행할 수 있다. 이런 접근 방식이 동작하려면 각 블록의 크기가 상대적으로 작아서 블록을 이동하는 시간이 한 프레임에서 할당된 시간을 초과하지 않아야 한다.

 

5.3 컨테이너

배열 : 순서가 있는 연속적인 요소들에 인덱스로 접근한다. 컴파일할 때 길이가 정적으로 결정된다.

동적 배열 (std::vector) : 런타임에 길이가 변할 수 있는 배열 (연속적인 메모리에 저장)

연결 리스트 (std::list) : 순서가 있는 요소들의 모음이지만 메모리에 연속적으로 저장되는 것이 아니라 포인터에 의해 요소들이 연결된다.

스택 (std::stack) 

큐 (std::queue)

덱 (std::deque)

트리 : 요소들이 계층 구조로 구분된 컨테이너이다.

이진 검색 트리 : 각 노드는 최대 두 개의 자식을 가지고 자식들이 속성에 따라 명확한 기준에 의해 정렬되는 트리

이진 힙 : 이진 트리, 말단 노드들은 왼쪽에서 오른쪽으로 채워져 있어야 하며, 모든 노드는 사용자가 정의한 기준에 따라 그 자식들보다 크거나 같아야 한다.

우선순위 큐 (std::priority_queue) : 언제나 정렬되어 있는 리스트, 항상 최고 우선순위의 요소만을 꺼낼 수 있다. 보통 힙으로 구현한다.

맵, 해쉬맵 (std::map, std::hash_map) : 키-값 쌍으로 이뤄진 테이블 

집합 : 정해진 기준에 의해 중복되는 요소가 없게 보장하는 컨테이너

그래프 : 노드들의 집합으로 노드 간에 단방향 혹은 양방향으로 연결돼 임의의 패턴을 이룬다.

방향성 비순환 그래프 : 단방향으로 연결된 노드들의 집합

 

5.3.2 반복자

특정한 컨테이너의 요소들을 효율적으로 접근하는 방법을 알고 있는 작은 클래스를 반복자라고 한다.

컨테이너의 요소들에 직접 접근하는 대신 반복자를 사용할 때 얻는 이점

ㄴ 컨테이너 클래스의 캡슐화를 지키며(컨테이너의 내부 구현 세부 사항을 외부에 노출하지 않고도) 효율적으로 순회할 수 있다.

ㄴ 반복자는 순회 과정을 단순화 한다. (트리를 순회 하는 경우 그냥 반복자를 증가시키면 끝)

 

5.3.4 자체 구현 컨테이너 클래스 만들기

컨테이너 클래스를 직접 구현하는 이유

ㄴ 목표로 하는 콘솔의 하드웨어 특성에 맞게 알고리즘을 최적화할 수 있다.

ㄴ 외부 의존성을 제거할 수 있다 - 외부 라이브러리를 사용하는 경우 라이브러리에 문제가 생겼을 때 빠르게 대응할 수 없다.

ㄴ 외부 라이브러리에 잘 없는 알고리즘을 자체적으로 만들어 넣을 수도 있다.

ㄴ 병행 데이터 구조에 대한 제어가 가능하다.

 

5.3.4.1 컨테이너를 어떻게 구현할 것인가?

1. 필요한 자료 구조를 직접 만든다. (자체 구현)

2. 외부 구현을 사용한다.

ㄴ STL, STLport (C++ 표준 템플릿 라이브러리)

ㄴ Boost 라이브러리

 

STL의 장점 

ㄴ 다양한 플랫폼에서 쓸 수 있는 대체적으로 안정적인 구현들이 존재한다.

ㄴ 거의 모든 C++ 컴파일러는 STL을 표준으로서 지원한다.

 

STL의 단점

ㄴ 대부분 자체 제작한 자료 구조보다 메모리를 더 많이 소비한다.

ㄴ 어떤 문제를 해결하기 위해 구체적인 목적을 가지고 제작한 자료 구조와 비교할 때 STL의 자료구조는 이보다는 성능이 떨어진다.

ㄴ 동적 메모리 할당을 많이 사용하기 때문에 고성능이 필요하다. 고성능 CPU와 가상 메모리 시스템이 있는 PC 플랫폼에서는 메모리 할당이 다른 플랫폼에 비해 제약이 덜하지만 그런 조건이 없는 콘솔 플랫폼에서는 적합하지 않을 수 있다.

 

STL을 사용할 때 고려해야 할 점

ㄴ 사용하는 STL 컨테이너의 성능 및 메모리 특성을 알고 있어야 한다.

ㄴ 성능이 병목될 만한 코드에는 크고 무거운 STL 클래스들의 사용을 자제해야 한다.

ㄴ 엔진이 여러 플랫폼을 지원하는 경우 STLport를 사용하자

 

Boost 

ㄴ STL을 확장하는 동시에 STL과 같이 쓰일 수 있는 라이브러리를 만들어 내는 오픈소스 프로젝트

ㄴ 문서화가 잘 되어 있다.

ㄴ 스마트 포인터 등 복잡한 문제를 훌륭하게 처리한다.

 

Loki 

ㄴ C++의 템플릿을 이용해 원래는 런타임에 해야 할 일들을 컴파일러가 하게 만드는 라이브러리

ㄴ 읽고 사용하기 힘들며 컴파일러의 부수 효과에 의존하기 때문에 다른 라이브러리들에 비해 이식성이 떨어진다.

 

5.3.4.2 동적 배열과 메모리 할당

게임 프로그래밍에서는 C 형태의 고정 크기 배열을 많이 사용한다.

ㄴ 동적 메모리 할당이 필요 없다.

ㄴ 메모리가 연속적이어서 캐시 성능이 좋다.

ㄴ 데이터 추가나 검색 등 자주 쓰는 동작을 효율적으로 할 수 있다.

 

동적 배열 (std::vector)의 작동 방식

ㄴ 처음에 n개의 요소를 담을 버퍼를 할당한 후 공간이 더 필요한 경우 버퍼를 키운다.

ㄴ 새로운 더 큰 버퍼를 동적 할당하고 원래의 버퍼에서 새 버퍼로 데이터를 복사하는 방식

ㄴ 동적 배열은 사용될 버퍼의 크기를 아직 정하지 못한 개발 기간에 사용하고 메모리 사용량이 정해진 이후에는 고정 크기의 배열로 언제든 바꿀 수 있다.

 

5.3.4.3. 연결 리스트

연속적인 메모리를 마련하는 것보다 임의의 위치에 요소를 삽입하고 제거하는 동작이 더 중요한 경우에는 연결 리스트를 사용하는 것이 낫다.

 

연결 리스트를 구현하는 두 가지 방법

1. extrusive list (돌출 리스트)

ㄴ 링크 자료 구조와 요소 자료구조가 완전히 별개인 연결 리스트

ㄴ 한 요소가 동시에 여러 연결 리스트에서 포함할 수 있다는 장점

ㄴ 링크 객체를 동적 할당해야 한다는 단점이 있기 때문에 풀 할당자를 사용할 여력이 있다면 괜찮은 방법이다.

ㄴ 외부 라이브러리의 클래스 인스턴스를 연결 리스트에 넣어야 하는데 라이브러리의 소스코드를 수정할 수 없다면 선택해야 하는 방법

 

2. instrusive list (함몰 리스트)

ㄴ 요소 자료구조 안에 링크 자료구조를 포함시키는 연결 리스트

ㄴ 링크 객체를 동적 할당하지 않아도 된다.

ㄴ 요소 클래스가 링크 클래스를 상속받게 구현하는 방법도 있다.

 

5.3.4.4. 사전과 해쉬 테이블 (map, hashmap)

ㄴ 키가 주어지면 값을 빠르게 찾아주는 자료구조, 이진 검색트리나 해쉬 테이블을 사용한다.

 

해쉬 테이블 

키를 정수형태로 변환(hashing)하고 변환된 값을 테이블 크기로 모듈로 연산을 하여 테이블 인덱스를 계산한다.

 

개방형 해쉬 테이블

ㄴ 충돌을 해결하기 위해 인덱스 하나에 여러 개 의 키-값 쌍을 연결 리스트의 형태로 저장하는 방식을 사용

ㄴ 새로운 키-값 쌍을 테이블에 추가할 때 동적 메모리 할당이 발생

 

폐쇄형 해쉬 테이블

ㄴ 충돌이 발생하면 빈 슬롯을 찾을 때까지 탐지(정의된 알고리즘을 사용하여 빈 슬롯을 찾는 과정)을 반복한다.

ㄴ 구현이 까다롭고 테이블에 저장할 수 잇는 최대 키-값 쌍의 수에 제한이 있지만, 정해진 메모리만 사용하고, 동적 메모리 할당이 필요가 없다는 장점이 있다.

ㄴ 구현 시 선형 탐지, 이차 탐지 알고리즘을 사용할 수 있다. 테이블의 크기를 소수가 되게 하는것이 좋다.

 

해시 함수

ㄴ 키가 32비트 정수인 경우 변환값은 그대로이다. 

ㄴ 32비트 부동소수의 경우 변환값은 비트값 그대로이다. (32비트 정수로 취급)

ㄴ 문자열의 경우 모든 문자의 ASCII 코드나 UTF 코드를 모아서 32비트 정수 값 하나로 변환한다.

 

좋은 해시 함수는 충돌을 최소화하도록 키 값들을 테이블 전체에 고르게 배분하는 함수이다.

 

해시 함수의 예

LOOKUP3

Cyclic redundancy check function

MD5

 

5.4 문자열

5.4.1 문자열의 문제점

어떤 타입을 사용할 것인가?

ㄴ 문자열 클래스

ㄴ character(문자)의 배열

 

localization(국제화)에 관한 문제

ㄴ 플레이어가 볼 수 있는 문자열을 지역에 따라 번역해야 한다.

 

런타임에 문자열을 처리하는 것은 보통 느리다

ㄴ 예를 들어 문자열 비교, 복사는 int나 float의 비교 복사보다 훨씬 느리다

 

5.4.3 고유 식별자

가상 게임 월드에 있는 물체들을 고유하게 구분할 방법으로 문자열을 사용하는 것은 자연스러운 선택이지만, 고유 식별자들 끼리 비교를 하는 경우 성능상의 문제가 발생할 수 있기 때문에 다른 방법을 사용할 수 있다.

 

5.4.3.1 해시 문자열 ID

고유 식별자들 끼리 비교를 할 때 해시 테이블에 문자열과 정수 ID를 저장하고 비교는 정수 ID로 한다.

 

문자열에서 문자열 ID를 만드는 과정을 문자열을 인턴한다고 부른다.

문자열 인턴은 시간이 오래걸리기 때문에 초기에 한 번만 문자열을 인턴하고 그 결과를 나중에 쓸 수 있게 저장하는 것이 좋다.

 

5.4.4 현지화 

5.4.4.1 유니코드

 

UTF-32

ㄴ 문자열의 모든  character가 4바이트를 차지함(고정 크기)

 

UTF-8

ㄴ 문자열의 캐릭터가 1바이트 혹은 여러 바이트를 차지할 수 있다.

ㄴ ANSI 인코딩과 하위 호환성이 있다.

 

UTF-16 (wide character set) 

ㄴ 유니코드 코드포인트의 집합(플레인) 은 17개이다. 기본 다중언어 플레인(bmp, basic multilingual plane)1개와 보조 플레인 16개가 있다. 각 플레인은 2의 16승 (65536)개의 코드 포인트를 가진다.

ㄴ 문자열의 캐릭터 하나는 최소 2바이트 (4바이트가 될 수도 있음)

ㄴ 기본 다중언어 플레인만을 활용하는 경우 2바이트, 캐릭터가 다른 플레인에서 온 경우 4바이트

ㄴ 대상 CPU에 따라 little-endian 혹은 big-endian이 될 수 있기 때문에 디스크에 저장하는 경우 BOM(바이트 순서 마크)를 지정 해야 한다.

ㄴ 한글은 UTF-8에서 3바이트로 변환되지만 UTF-16에서는 2바이트로 변환되므로 효율적이다.

 

UCS-2 

ㄴ 기본 다중언어 플레인만을 활용하는 UTF-16 인코딩의 제한된 부분집합

ㄴ 유니코드 코드포인트가 수치적으로 0xFFFF (65535) 보다 큰 캐릭터를 표현할 수 없다.

ㄴ 문자열의 캐릭터 하나는 2바이트 고정

 

 

5.4.4.2 char 타입과 wchar_t 타입

 

5.4.4.3 윈도우즈 환경에서 유니코드

유니코드 = wide character set = UTF-16로 간주한다.

 

std::string은 STL의 ANSI 문자열 클래스

std::wstring은 와이드 캐릭터 클래스 (UTF-16 인코딩된 문자열을 위해 사용)

 

char *s = "this is a string" 

wchar_t * s = L"this is a string"

 

5.5 게임 엔진 설정

그래픽 품질, 사운드 효과, 컨트롤러 설정과 같은 게임 엔진의 옵션을 설정하는 방법

 

5.5.1 옵션 불러오기와 저장하기

텍스트 설정 파일

ㄴ 윈도우의 INI 파일은 논리적 단위로 구분된 단순한 키-값 쌍으로 이뤄져 있다.

 

윈도우 레지스트리 

ㄴ 잘 조직된 INI 파일들의 모음과 같은 역할을 한다. 윈도우 애플리케이션이 사용하는 INI 파일이 점점 복잡해지는 문제를 해결하기 위해 레지스트리를 사용한다.

 

커맨드라인 옵션

ㄴ 엔진의 모든 옵션을 커맨드라인으로 설정할 수 있는 경우도 있다.

 

온라인 유저 프로파일

ㄴ 사용자마다 자신이 구매한 게임, 획득한 업적, 게임 옵션 등 여러 정보들이 중앙 서버에 저장되고 인터넷을 통해 접근하는 방식

5.5.2 사용자별 정보

대부분의 게임 엔진에서는 전역 옵션과 사용자별 옵션을 구분한다.

ㄴ 플레이어의 기호에 맞게 게임을 설정할 수 있다. (플레이어 관점의 사용자별 옵션)

ㄴ 프로그래머나 아티스트, 디자이너가 다른 팀원들에 영향을 주지 않으면서 자신만의 개발 환경을 마련할 수 있다.

+ Recent posts