리소스 시스템은 파일 시스템을 많이 사용한다. 게임엔진에서는 운영체제의 파일 시스템 API를 그대로 사용하지 않고 이를 감싼 랩핑 API를 엔진에 구현하여 사용하는 경우가 많다. 

이렇게 구현하는 이유

1. 멀티 플랫폼 지원을 위해

ㄴ 엔진의 파일 시스템 API를 사용하게 되면 엔진의 나머지 부분에서는 플랫폼의 차이를 신경쓰지 않아도 된다.

2. 운영체제의 파일 시스템 API가 게임 엔진에서 필요로 하는 모든 기능을 지원하지 못하는 경우가 있기 때문에

ㄴ 파일 스트리밍을 지원하는 엔진은 많지만 운영체제는 많지 않다.

 

6.1 파일 시스템

파일 이름과 경로를 다루는 기능

파일들을 열고 닫는 기능과 읽고 쓰는 기능

디렉토리의 내용을 검색하는 기능

스트리밍을 위한 비동기 파일 I/O 요청을 처리하는 기능

 

6.1.1 파일 이름과 경로

6.1.1.1 운영체제에 따른 차이

윈도우

ㄴ 다중 볼륨 지원 D: C: (볼륨을 나타내는 첫 번째 방법)

ㄴ \\ [원격 컴퓨터 이름] (볼륨을 나타내는 두 번째 방법)

ㄴ 파일 이름에 콜론을 사용할 수 없다.

ㄴ 경로 구분 문자로 \ 를 사용

유닉스

ㄴ 볼륨마다 개별적인 디렉터리 구조 를 가질 수 없다. 하나의 통합된 구조이다.

ㄴ 경로 구분 문자로 / 를 사용

mac os

ㄴ 콜론을 경로 구분 문자로 사용한다.

 

6.1.1.3 검색 경로

검색 경로는 경로들의 리스트를 담고 있는 문자열로 콜론이나 세미콜론 등의 특수한 문자로 구분된다.

리소스 파일의 검색 경로를 담은 텍스트 파일을 사용하여 런타임에 자원의 경로를 얻는 방법도 있지만 일반적인 경우 엔진은 자원의 경로를 미리 알고 있다.

 

6.1.1.4 경로 API

ㄴ 경로를 처리할 때 파일 이름 및 확장자를 분리하는 일

ㄴ 절대 경로와 상대 경로를 전환하는 일

윈도우 API shl-wapi.dll에 구현되어 있다.

 

6.1.2 기본 파일 I/O

파일 열기, 읽기, 쓰기에 두 가지 종류의 API가 있다.

1. 버퍼를 사용하는 방식

ㄴ API가 알아서 입력 및 출력 데이터 버퍼를 관리

2. 버퍼를 사용하지 않는 방식

ㄴ 프로그래머가 데이터 버퍼를 할당하고 관리

 

표준 C 라이브러리 함수

ㄴ fopen, fread, fclose, fwrite (버퍼를 사용하는 방식)

 

운영체제가 제공하는 함수

open, read, close, write (버퍼를 프로그래머가 관리하는 방식, 시스템 콜)

 

6.1.2.1 운영체제의 함수를 감싸는 경우와 그렇지 않은 경우

게임 엔진의 I/O API를 구현할 때 두 가지 방식으로 구현 할 수 있다.

 

1. 운영체제의 I/O API를 감싸 엔진의 I/O API를 구현하는 방식

2. 표준 C 라이브러리의 I/O 함수를 사용하는 방식

 

운영체제의 I/O API를 감싸 엔진의 I/O API를 구현하는 방식의 장점

ㄴ 서로 다른 플랫폼에서 똑같은 동작이 보이게 보장할 수 있다.

ㄴ 엔진에서 실제 필요한 기능들만 갖게 API를 단순화할 수 있다.

ㄴ 확장 기능을 지원할 수 있다. (하드 디스크, 네트워크, 휴대형 미디어에 담긴 파일의 처리)

 

6.1.2.2 동기적 파일 I/O

ㄴ 표준 C 라이브러리의 파일 I/O 라이브러리는 동기적으로 동작한다. (fread() 구문에서 block이 걸린다.)

 

6.1.3 비동기적 파일 I/O

ㄴ 스트리밍을 지원하기 위해서는 비동기 파일 I/O 라이브러리를 활용해야 한다.

ㄴ 스트리밍은 오디오, 텍스처 데이터 등 모든 데이터를 대상으로 사용 가능하다.

ㄴ 비동기적 I/O 라이브러리는 메인 프로그램에서 요청한 후 I/O 동작이 완료되기를 기다리는 기능도 지원하는 경우가 일반적이다. (asyncWait 함수)

ㄴ 비동기 연산 시간이 얼마나 걸릴지 예상 시간을 알 수 있는 기능을 제공하기도 한다.

 

6.1.3.1 우선순위 

비동기 I/O 연산에는 보통 우선순위가 있다. 예를 들어 오디오를 하드디스크에서 스트리밍하는 작업은 보통 게임 레벨의 개체들을 불러오는 일보다는 우선순위가 높아야 한다.

 

I/O 시스템은 우선순위가 높은 요청이 제한 시간 안에 완료될 수 있게 우선순위가 낮은 요청을 잠시 정지시킬 수 있어야 한다.

 

6.1.3.2 비동기 파일 I/O의 동작 방법

I/O 요청을 별도의 스레드에서 처리하는 방식으로 동작한다.

1. 메인 스레드에서 비동기 I/O 함수를 호출하면 큐에 요청을 넣는다.

2. I/O 스레드에서 큐에서 요청을 뽑아 동기적 I/O 함수를 사용하여 처리한다.

3-1. 작업이 완료되면 메인 스레드가 지정한 콜백 함수를 호출해 요청이 완료되었음을 알린다.

3-2. I/O 요청을 할 때 콜백 함수를 지정하지 않는 대신 세마포어를 이용하면 메인 스레드에서 I/O 작업이 완료될 때까지 기다린다.

 

6.2 리소스 매니저

리소스의 종류

ㄴ 메시, 머터리얼, 텍스처, 셰이더 프로그램, 애니메이션, 오디오 클립, 레벨 레이아웃, 충돌 기본 단위, 물리 매개변수 등

 

리소스 매니저가 해야 할 일 2가지

1. 리소스를 만들어 내는 오프라인 툴 체인을 관리

2. 런타임에 자원을 올리고 내리는 일을 관리

 

6.2.1 오프라인 리소스 관리와 툴 체인

6.2.1.1 자원의 리비전 컨트롤

자원 파일들을 상용 버전 관리 프로그램을 사용하거나 이런 시스템을 감싼 툴을 제작해 사용하기도 한다.

 

발생하는 문제

ㄴ 아트 파일들은 용량이 커서 중앙 저장소에서 로컬로 파일을 복사하는 방식은 효율이 떨어진다.

ㄴ 심볼릭 링크를 활용해 데이터를 복사하지 않고 저장소의 자원을 로컬 머신에서 볼 수 있는 툴을 만들면 해결 가능하다.

 

6.2.1.2 리소스 데이터베이스

자원의 메타 데이터(자원을 어떤 방식으로 가공할 것인지)를 저장하는 데이터베이스가 필요하다.

메타 데이터는 리소스 자원 안에 포함되어 있는 경우도 있다.

 

리소스 데이터베이스가 제공해야 할 기능

ㄴ 여러 종류의 리소스를 처리할 수 있는 기능

ㄴ 새 리소스를 만들 수 있는 기능

ㄴ 리소스를 지울 수 있는 기능

ㄴ 기존의 리소스를 살펴보고 수정할 수 있는 기능

ㄴ 리소스의 원본 파일을 디스크의 다른 장소로 옮길 수 있는 기능

ㄴ 리소스들끼리 교차 참조할 수 있는 기능, 교차 참조에 대한 참조 무결성을 유지하는 기능

ㄴ 리비전 히스토리를 관리하는 기능 (작업자, 작업내용에 대한 로그)

 

6.2.1.4 자원 다듬기 파이프라인 (ACP : asset conditioning pipeline)

 

1. 내보내기 도구 (Exporter)

ㄴ 보통 DCC(digital cotent creation) 프로그램이 기능을 지원한다.

 

2. 리소스 컴파일러 (컴파일 과정 없이 export 단계 이후 바로 사용할 수 있는 자원도 존재한다.)

ㄴ 데이터를 게임에 적합한 형태로 바꾸거나 계산이 필요하다면 데이터를 이용해 계산을 하는 일들을 진행한다.

 

3. 리소스 링커 (export와 컴파일 단계만 거친 후 바로 게임에서 사용할 수 있는 자원들은 필요 없는 단계)

ㄴ C++ 프로그램을 컴파일하는 과정에서 여러 obj 파일들을 하나의 실행 파일로 링크하는 것과 비슷하게 3D 모델과 같은 복합 리소스를 빌드하는 경우, 메시 파일, 머터리얼 파일, 뼈대 파일, 애니메이션 파일들을 하나의 리소스 파일로 묶어야 하기 때문에 필요한 단계이다.

 

리소스 의존 관계의 빌드 규칙

소스 파일에도 상호 의존성이 있듯 게임 자원에도 의존성이 있다.

메시가 참조하는 머터리얼이 여러 개 있을 수 있고, 머터리얼들은 여러 텍스처를 참조할 수 있다.

파이프라인에서 자원을 처리하는 순서는 상호 의존성에 영향을 받는다.

자원들을 올바른 순서로 빌드할 빌드 툴이 있어야 한다.

 

6.2.2 런타임 리소스 매니저

6.2.2.1 런타임 리소스 매니저의 역할

ㄴ 각 고유한 리소스는 메모리에 하나만 존재하도록 한다.

ㄴ 각 리소스의 수명을 관리한다.

ㄴ 필요한 리소스를 불러오고 더 이상 필요 없는 리소스는 내린다.

ㄴ 복합 리소스의 로딩을 처리한다. 3D 모델 같은 거

ㄴ 참조 무결성을 유지한다. (복합 리소스를 불러올 때 상호 참조 관계로 연결된 리소스들을 모두 불러올 수 있어야 한다.)

ㄴ 불러온 리소스들의 메모리 사용량을 관리하고 메모리의 적절한 곳에 저장한다.

 

6.2.2.2 리소스 파일과 디렉터리 구조

ㄴ pc 엔진은 보통 리소스 파일들을 디스크에 두고 디렉터리 트리로 관리한다.

 

여러 개의 리소스 파일들을 하나의 ZIP 파일이나 복합 파일로 압축하여 관리하는 엔진도 있다.

ㄴ 로딩 시간이 줄어들 수 있다. 하드 디스크의 연속적인 공간에 모든 데이터가 저장되기 때문에 탐색 시간(헤더 옮기는 시간)이 줄어들고, 파일을 여는 데 드는 시간도 여러 번에서 한 번으로 줄기 때문에

 

ZIP 형식을 사용할 경우 얻는 이점

1. ZIP 파일을 읽고 쓰는데 사용하는 무료 라이브러리가 존재한다. (zlib, zziplib)

2. ZIP 파일 안의 가상 파일들은 일반 파일 시스템과 마찬가지로 상대 경로로 접근할 수 있다.

3. ZIP 파일은 압축이 가능하기 때문에 디스크에서 리소스가 차지하는 용량을 줄일 수 있다.

4. ZIP 파일을 모듈로써 사용할 수 있다. (현지화해야 할 자원들을 지역 별로 묶어 지역별로 다른 ZIP 파일을 둘 수 있다.)

 

언리얼의 경우 패키지 (.pak) 라고 불리는 자체 제작한 복합 파일을 사용한다. 

 

6.2.2.3 리소스 파일 형식

자체 제작한 리소스 파일 형식을 사용하는 이유

ㄴ 표준화된 형식으로 엔진에서 쓰이는 모든 정보들을 표현하지 못하는 경우가 있기 때문에

 

가능한 한 많은 오프라인 처리를 통해 런타임에 처리할 작업을 줄이는 방법의 예

ㄴ 특정한 형태로 리소스 데이터를 메모리에 배열해야 하는 경우 오프라인 툴에서 데이터를 배열하게 하고

메모리에 올리기 위해 이진 데이터 형식을 사용할 수도 있다. (런타임에 리소스 데이터를 로드한 후 배열하데 사용되는 시간을 줄일 수 있다.)

 

6.2.2.4 리소스 GUID(Globally unique identifier)

GUID로 흔히 쓰이는 것은 리소스의 파일 시스템 경로(문자열)이다.

 

언리얼 엔진같은 경우는 패키지라는 큰 파일 안에 여러 리소스들을 저장하기 때문에 패키지 파일의 경로가 각 리소스를 고유하게 나타내지는 못하기 때문에 GUID를 파일 시스템 경로로 사용하지 못한다.(대신 해시 코드를 사용한다)

이를 해결하기 위해 패키지 안의 각 리소스는 파일 시스템 경로와 유사한 고유한 이름을 갖는다. 언리얼 엔진에서의 리소스 GUID는 패키지 파일의 이름 + 패키지 안의 리소스에 대한 경로를 합쳐 만들어진다.

 

6.2.2.5 리소스 레지스트리

리소스 매니저는 레지스트리를 이용하여 하나의 리소스만 메모리에 존재하도록 보장한다.

키-값 자료구조를 갖는 map, hashmap을 이용해서 GUID를 키로 사용하고 값은 리소스의 메모리 주소에 대한 포인터를 사용한다.

 

게임에서 리소스를 요청하는 경우 리소스 매니저는 리소스 레지스트리를 검색하여 리소스가 존재하면 리소스의 포인터를 반환하고 없다면 메모리에 리소스를 올리거나 그냥 실패 코드를 리턴하게 할 수 있다.

 

리소스 로드 작업은 시간이 많이 걸리는 작업(디스크에서 파일을 읽어 큰 용량의 리소스를 올려야 함) 이기 때문에 두 가지 방법으로 보통 처리한다.

 

1. 게임 플레이 중에는 아예 리소스를 로드하지 않는다.

ㄴ 게임 플레이가 시작되기 전에 미리 로드하는 방법

 

2. 리소스 로드를 비동기적으로 수행한다.

ㄴ 게임 플레이 도중 다른 레벨의 리소스를 미리 불러온다면 로딩 화면을 보지 않아도 된다.

 

6.2.2.6 리소스 수명

리소스의 수명은 메모리에 올라가는 순간부터 메모리에서 내려오는 순간까지를 뜻한다.

 

게임을 처음 시작할 때 불러 와서 게임이 진행되는 동안 항상 메모리에 상주해야 하는 리소스들의 수명은 사실상 무한대이기 때문에 글로벌 리소스 또는 글로벌 에셋 리소스라고 부른다.

 

글로벌 에셋 리소스의 예시로는 플레이어 캐릭터의 메시, 머터리얼, 텍스처, 애니메이션, 헤드업 디스플레이에 쓰이는 폰트, 텍스처 등이 있고 이들은 게임 내내 노출되는 리소스들이다.

 

게임의 특정 레벨의 수명을 따라가는 리소스들도 있다. 

 

게임속의 1회성 영상 (시네마틱)에 쓰이는 짧은 애니메이션과 오디오 클립은 영상이 시작하기 전에 미리 로드되었다가 영상 재생이 끝난 뒤 바로 메모리에서 내려가도록 만든다.

 

배경 음악이나 환경 사운드 효과, 풀 스크린 동영상 같은 리소스들은 실시간 스트리밍된다. 리소스를 구성하는 일부 바이트만 메모리에 존재하게 되는데 하드웨어가 한번에 읽어오는 4KiB와 같은 단위로 정하게 되며, 메모리에는 현재 재생 중인 청크와 다음 재생을 위해 메모리로 로드 중인 다음 청크, 두 개의 청크만 존재하게 된다.

 

리소스를 레벨의 수명을 따라가도록 하면 여러 레벨이 걸쳐 공유되는 리소스들은 계속 메모리에 올라갔다 내려갔다 반복하게 될 수 있는데, 리소스에 참조 카운터를 사용하게 되면 이런 문제를 막을 수 있다.


6.2.2.7 리소스와 관련된 메모리 관리

비디오 메모리에 있어야 하는 리소스들 (GPU가 빠르게 접근할 수 있도록)

ㄴ 텍스처, 정점 버퍼, 인덱스 버퍼, 셰이더 코드

 

글로벌 리소스와 실행 중 동적으로 오르는 리소스를 메모리 상의 서로 다른 장소에 두어 관리할 수도 있다.

 

리소스 매니저의 설계와 메모리 할당 하부 시스템의 설계는 밀접한 관계가 있다. 

메모리 할당자를 최대할 활용할 수 있게 리소스 매니저를 설계하는 경우도 있고 반대로 리소스 매니저가 가장 잘 활용할 수 있는 메모리 할당자를 설계하기도 한다.

리소스가 메모리에 올라갔다 내려갔다 하는 과정에서 메모리 단편화가 발생할 수 있는데 메모리 할당 시스템을 스택, 힙 기반으로 만들면 단편화 문제를 해결할 수 있다.

 

https://lemonyun.tistory.com/90

 

5. 엔진 지원 시스템

5.1 서브시스템 시작과 종료 게임 엔진은 서로 통신하는 수많은 하부 시스템이 모여 이루어진 복잡한 소프트웨어이다. 게임 엔진을 시작할 때 각 하부 시스템들을 정해진 순서에 따라 설정하고

lemonyun.tistory.com

힙 기반 리소스 할당

ㄴ 메모리 단편화를 무시하고 범용 힙 할당자 (new malloc)을 사용하는 방법, 가상 메모리 할당을 지원하는 운영체제 (PC) 에서는 메모리 단편화 문제가 덜하기 때문에 사용할 수 있다.

 

스택 기반 리소스 할당

ㄴ 게임 플레이 스타일이 특정한 경우에 사용 가능하다.

1. 게임이 선형 진행 방식이고 레벨 중심일 경우 (레벨1 - 로딩 - 레벨2 - 로딩 - 레벨3 이런 형식인 경우)

2. 레벨이 전부 메모리 안에 들어가는 경우 

 

풀 기반 리소스 할당

ㄴ 리소스 데이터를 똑같은 단위 크기의 청크로 불러오는 방법

ㄴ 모든 리소스 데이터가 똑같은 크기의 청크로 나누어질 수 있게 배열되어야 한다.

ㄴ 반드시 연속적으로 배열되어야 하는 데이터(배열, 구조체)들의 크기는 청크의 크기보다 작아야 한다. 

ㄴ 리소스 데이터를 설계할 때 청크로 나뉠 수 있도록 미리 고려하여 설계해야 한다. 배열과 같은 연속된 자료 구조 대신 연결 리스트를 사용하는 방법도 고려할 수 있다.

ㄴ 리소스 파일의 크기가 청크 크기의 배수가 되지 않는다면 파일의 마지막 청크에는 낭비되는 공간이 생길 수 있다는 단점이 있다.

ㄴ 각 청크의 크기는 I/O 버퍼의 배수가 되게 선택하여 청크를 메모리에 불러올 때 최적의 성능을 내도록 할 수 있다.

 

리소스 청크 할당자

ㄴ 청크에 쓰이는 메모리가 낭비되는 것을 줄이기 위한 방법으로 쓰이지 않는 공간이 있는 청크들에 대한 리스트와 각 청크에 있는 블록의 위치와 크기를 관리한다.

ㄴ 청크가 속한 레벨의 수명(이미 할당한 리소스의 수명)과 일치하는 메모리 요청(특정 레벨에 종속적인 데이터) 에만 리소스 청크 할당자를 사용하여 원하지 않을 때 리소스가 메모리에서 해제되는 현상을 방지한다.

 

6.2.2.8 복합 리소스와 참조 무결성

상호 의존하는 리소스들을 묶어 복합 리소스라고 부르기도 한다. (3D 모델 : 삼각형 메시, 뼈대, 애니메이션들의 리소스로 이루어짐)

대부분의 참조 관계는 의존 관계이다.

게임의 리소스 데이터베이스는 서로 의존하는 데이터 객체들로 이루어진 방향 그래프로 나타낼 수 있다.

 

데이터 객체끼리의 상호 참조는 내부 참조(같은 파일 안에 있는 두 객체 간의 참조)일 수도 있고 외부 참조(다른 파일에 있는 객체에 대한 참조)일 수도 있다.

 

6.2.2.9 리소스 간 상호 참조 처리

두 객체 간 상호 참조를 구현할 때는 포인터나 참조를 사용하지만 디스크 파일에 저장할 때는 포인터로 객체 간 의존성을 나타낼 수는 없다. (객체의 메모리 주소는 실행할 때마다 바뀔 수 있기 때문에)

 

1. GUID를 사용하는 방법

ㄴ 상호 참조 관계에 있을 수 있는 모든 리소스에 GUID를 부여한다.

ㄴ 런타임 리소스 매니저가 룩업 테이블을 전역적으로 관리해야 한다. (GUID와 객체에 대한 포인터를 키-값으로 갖는 테이블)

 

2. 포인터 교정 테이블 (여러 리소스 객체를 이진 파일로 저장한 뒤 메모리에 올려 사용하는 방법)

ㄴ 포인터를 파일 오프셋으로 변환하는 방법을 사용할 수 있다.

ㄴ 복합 리소스 내의 객체들을 이진 파일로 저장할 때, 파일에 각 객체에 있는 모든 포인터(다른 객체에 대한 참조)를 오프셋으로 대체하여 저장한다.

ㄴ 나중에 파일을 메모리에 불러올 때는 오프셋 값을 다시 포인터로 바꿔야 한다.

ㄴ 이진 파일에 포인터 교정 테이블(Fix-Up Table)을 둔다. 모든 객체내의 참조들의 위치를 기록한다. (파일에서는 오프셋으로 변경되어 있으므로 파일을 메모리에 올리려면 이 테이블에 있는 모든 위치의 오프셋을 포인터로 변경해줘야 한다.)

 

3. C++ 객체를 이진 파일로 저장하는 경우

ㄴ 객체의 생성자를 따로 불러줘야 함.

1. 그냥 생성자가 없는 C의 구조체나 가상함수가 없고 생성자에서 아무것도 하지 않는 데이터 타입(plain old data: PODS)만 이진 파일에 저장하는 방법

2. PODS가 아닌 객체들의 오프셋과 해당 객체들의 클래스 인스턴스 종류를 테이블에 같이 기록하고 placement new 문법(미리 할당된 메모리에 생성자를 호출하는 방법)을 사용하여 생성자를 호출하는 방법 

 

외부 참조 처리 (다른 리소스 파일안에 들어있는 객체를 참조하는 경우)

ㄴ 객체의 오프셋이나 GUID만으로는 표현할 수 없다. 객체가 담긴 리소스 파일에 대한 경로도 필요하다.

 

 

6.2.2.10 로드 후 초기화 과정, 정리 과정

리소스를 메모리기 올린 직후에 초기화 단계를 거치고 리소스의 메모리를 해제하기 전에는 정리 단계를 거친다.

리소스 타입마다 로드 후 초기화 및 정리 과정에서 할 일이 각기 다르다.

ㄴ C++ 에서는 다형성을 이용하여 클래스별로 다른 로드 후 초기화 및 정리 단계를 구현할 수도 있고 그냥 Init(), Destroy() 함수를 사용하여 구현할 수도 있다.

 

반드시 필요한 로드 후 초기화 과정 예시

ㄴ PC의 경우 3D 메시를 나타내는 정점과 인덱스 버퍼는 메모리에서 비디오 메모리로 옮겨져야 한다.

ㄴ 낡은 형식으로 된 메시 데이터를 최신 형식으로 변경하여 메모리의 최종 위치로 복사해야 하는 경우

 

보통 초기화 과정에서 새로운 데이터를 만들어 내기 때문에 리소스에 새로운 데이터를 저장하기 위한 여분의 메모리가 필요할 수 있다.

 

 

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 사용자별 정보

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

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

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

 

DirectX 12 공부하면서 다뤘던 내용들이라 헷갈렸던 내용만 다시 정리한다.

 

4.4 사원수

https://lemonyun.tistory.com/63

 

22. 사원수 (quaternion)

사원수를 쓰는 이유 x y z 축을 순서에 따라 회전시키게 되면 축이 겹치게 되는 현상(gimbal lock)이 생기기 때문 복소수와 사원수 사원수 곱셈은 교환법칙을 만족하지 않지만 결합법칙은 만족한다.

lemonyun.tistory.com

 

4.7 하드웨어 가속 SIMD

비주얼 스튜디오 컴파일러는 __m128이라는 데이터 타입을 지원한다.

__m128 타입 변수는 일반적으로 RAM에 저장된다. 하지만 연산에 사용될 때는 CPU의 SSE 레지스터에서 처리된다.

실제로 지역 변수나 함수 인자를 __m128 타입으로 선언하면 컴파일러가 이 값을 프로그램 스택에 저장하기보다는 바로 SSE 레지스터에 저장하는 경우가 많다.

 

4.7.2.1 __m128 변수의 정렬

__m128 타입의 변수가 RAM에 저장될 때 프로그래머는 이 변수가 16바이트 메모리 주소 경계에 위치하게 보장해야 된다.

__m128 변수를 한 개라도 포함하고 있는 구조체나 클래스를 자동 혹은 전역 변수로 선언하면 컴파일러가 알아서 정렬한다. 하지만 동적으로 할당된 메모리에 있는 자료구조의 정렬에 대해서는 프로그래머가 책임져야 한다.

 

4.7.3 SSE 내장 명령어로 코드 짜기

컴파일러가 지원하는 내장 명령어(외관이나 사용법은 일반적인 C 함수와 같지만 컴파일러가 알아서 인라인 어셈블리 코드로 바꿔주는 명령어)를 사용하여 SSE 연산을 할 수 있다.

 

4.8 난수 생성

4.8.1 선형 합동 생성기

ㄴ 처음 주어진 시드 값이 같으면 생성되는 숫자들은 정확히 똑같다.

 

4.8.2 메르센 트위스터

ㄴ 선형 합동 생성기의 여러 단점을 개선하기 위해 고안된 유사난수 생성 알고리즘,

SIMD 벡터 명령을 사용해 속도를 높인 SFMT(SIMD-oriented fast mersenne twister)를 많이 사용한다.

'읽은 책 > 게임 엔진 아키텍처' 카테고리의 다른 글

6. 리소스 시스템과 파일 시스템  (0) 2022.08.08
5. 엔진 지원 시스템  (0) 2022.08.06
3. 게임을 위한 소프트웨어 엔지니어링 기초  (0) 2022.08.03
2. 도구  (0) 2022.07.31
1. 소개  (0) 2022.07.30

 

3.1 C++ 개념과 올바른 사용법

3.1.1.1 클래스와 객체

클래스와 인스턴스(객체)는 일대다 관계이다.

 

3.1.1.2 캡슐화

클래스를 사용하는 프로그래머의 입장에서는 정해진 인터페이스만 잘 이해하면 구현을 어떻게 하든 신경 쓰지 않아도 된다.

클래스를 만드는 프로그래머의 입장에서는 클래스 인스턴스가 항상 논리적으로 일관된 상태를 유지하게 보장할 수 있다. (클래스 외부에서는 정해진 인터페이스만 사용하고 내부의 데이터를 건드릴 수 없기 때문에 외부에 의한 간섭이 없다)

 

3.1.1.3 상속

상속은 두 클래스 사이에 is-a 관계를 만든다.

C++에서는 다중 상속을 잘 사용하지 않는다. 단일 상속만을 기본으로 하는 구조를 유지하되 간단하고 부모를 갖지 않는 클래스만 추가해 다중 상속을 사용하는 것이 정석이다. 이런 클래스를 믹스인 클래스라고 부른다.

 

3.1.1.4 다형성

서로 다른 타입의 객체들을 하나의 공통 인터페이스로 다룰 수 있는 기능이다.

 

3.1.1.5 합성과 집합

합성(composition) : 서로 영향을 주고받는 여러 객체들을 이용해 복잡한 일을 해결하는 것

ㄴ 클래스 간에 has-a 관계나 uses-a 관계를 형성한다.

ㄴ 다른 클래스의 인스턴스를 멤버로 포함하게 설계하는 방식이다.

 

3.1.1.6 디자인 패턴

싱글턴

반복자

추상화 팩토리

 

3.1.2 코딩 규칙

1. 인터페이스를 중시할 것 : 인터페이스(.h 파일)는 간결하고 단순하며 최소한의 것만 포함해야 한다.

이름을 잘 지을 것

2. 전역 네임스페이스를 깔끔하게 유지할 것 : 다른 라이브러리의 이름과 충돌하는 것을 막아야 한다.

3. 널리 알려진 C++ 사용법을 따를 것 : (Effective C++를 읽기)

 

3.1.3 C++11

3.1.3.1 auto

auto : 컴파일러가 오른쪽 값으로부터 타입을 추정한다.

auto f = 3.141592f; 

 

3.1.3.2 nullptr

기존에는 NULL은 정수값 0과 동일해서 int 값과 비교가 가능했지만 nullptr은 비교가 불가능하다.

std::nullptr_t 타입의 인스턴스인 nullptr를 NULL 포인터로 사용한다.

 

3.1.3.3 영역 기반 for 루프

C-스타일 배열과 멤버가 아닌 begin()과 end()함수가 정의되는 다른 자료구조에 대해 반복 작업을 수행할 수 있다.

for (const auto& pair : myMap) {  } 

 

3.1.3.4 override와 final

C++11 이전에는 virtual 키워드 하나만 사용할 수 있었음

final : 파생 클래스에 의해 오버라이딩되지 않아야 하는 단말(leaf) 가상 함수를 구현할 때

override : 상속받은 가상 함수를 오버라이딩할 때

 

3.1.3.5 강한 타입의 enum

enum 클래스를 사용하여 스코프 문제와 enum 값이 int 변수 값과 비교될 수 있는 문제를 없앴다.

 

3.1.3.6 표준화된 스마트 포인터

std::unique_ptr : 가리키고 있는 객체에 대해 단독 소유권을 유지하기 원할 때 사용

std::shared_ptr : 단일 객체에 대해 다수의 포인터를 사용하고 싶을 때 (참조 횟수로 관리)

std::weak_ptr :  shared_ptr처럼 작동하지만 객체를 가리키는 참조 횟수에는 기여하지 않는다.

 

3.1.3.7 람다

labmda는 익명 함수이다. 펑터(함수처럼 동작하는 클래스), 또는 std::function (함수 포인터로 사용)이 사용되는 모든 곳에 사용할 수 있다.

 

3.1.3.8 move 의미와 rvalue 레퍼런스

C나 C++에서 lvalue는 컴퓨터의 레지스터나 메모리에 있는 실제 저장 위치를 나타낸다. rvalue는 일시적인 자료 객체로서 논리적으로 존재하는 것이지 반드시 메모리를 차지하고 있을 필요는 없다.

 

이동 생성 연산자를 정의하면 move()함수를 사용하여 rvalue 복사를 할 수 있다.

 

lvalue 레퍼런스는 [자료형]& , rvalue 레퍼런스는 [자료형]&& 을 통해 선언된다.

 

어떤 함수에서 임시 객체를 생성하여 객체를 반환할 때 함수 외부에서 반환 값을 받는 부분의 경우는 깊은 복사가 필요 없는데 깊은 복사(복사 생성자에 의해)가 일어난다. 이동 생성자를 정의하면 객체를 새로 동적 할당 하지 않고 대입할 수 있다.

 

3.2 데이터, 코드와 메모리

3.2.1 수 표현

3.2.1.4 부동소수점 표현법

부동소수의 정확도는 절대값이 작을수록 높아진다. - 한전된 비트로 표현된 가수를 정수부와 소수부가 나눠 써야하기 때문이다.

0과 부동소수점으로 표현할 수 있는 0이 아닌 가장 작은 수(음수 제외)는 분명한 간격이 있다.

machine epsilon : 23비트 정확도(가수)를 지닌 IEEE-754 부동소수점(부호1, 지수8, 가수 23)에서 머신 엡실론은 2의 -23승이다. 

머신 엡실론 보다 작은 수를 연산에 사용하면 잘려 나간다. 머신 엡실론을 1.0에 더한 결과는 1.0이다. 

 

3.2.1.5 기본적인 데이터 타입

기본 타입

ㄴ int, char, short, long, double, bool

 

컴파일러에 따라 크기가 다른 타입

ㄴ int8, __int16, __int32, __int64

 

SIMD 타입

ㄴ 요즘의 CPU에는 벡터 프로세서, 혹은 벡터 유닛이라 불리는 산술 연산장치가 있다.

ㄴ 128비트 SIMD 레지스터에 부동소수 4개(32비트 4개) 를 넣어 쓰는 것이 흔하다. 행렬 곱셈이나 내적을 ALU보다 빠르게 처리할 수 있다.

각 마이크로프로세서마다 서로 다른 SIMD 명령어 셋을 구현하고 컴파일러마다 SIMD 변수를 선언하는 방법도 제 각각이다.

 

이식 가능한 크기 타입

컴파일러마다 서로 데이터 타입의 크기가 다르고 살짝 다른 문법을 사용하기 때문에 대부분의 게임 엔진은 스스로 내장 데이터 타입을 정의해서 이식 가능한 코드를 만든다.

C++11 표준라이브러리는 표준화된 크기의 정수 타입을 제공한다. 이것들은 <cstdint>헤더에 선언되어 있다.

std::int8_t, std::int16_t, std::int32_t, std::int64_t를 사용할 수 있다.

 

3.2.1.6 멀티바이트 데이터와 엔디언

1바이트보다 큰 값을 멀티바이트 값이라고 한다.

little-endian : 데이터의 하위 바이트가 낮은 메모리 주소에 저장되는 방식

big-endian : 데이터의 상위 바이트가 낮은 메모리 주소에 저장되는 방식

 

개발할때의 프로세서가 little-endian 방식인데 게임 실행은 big-endian에서 하는 경우 문제가 될 수 있다.

해결 방법

1. 모든 데이터 파일을 텍스트 형태(10진수)로 저장하고 숫자 하나당 바이트 하나씩 사용하여 저장

2. 툴에서 디스크에 저장하기 직전에 엔디언을 바꾸게 하는 방법

 

정수의 엔디언 바꾸기

ㄴ MSB에서 시작해서 LSB와 값을 1바이트 단위로 바꾼다. 가운데에 도달할때 까지 반복하면 된다.

 

부동소수의 엔디언 바꾸기

ㄴ 정수인 것처럼 생각하고 바꾸면 된다. 유니온을 사용하면 쉽다.

 

3.2.2 선언, 정의, 연결성

3.2.2.1 번역 단위 다시 살펴보기

cpp 파일 한 개(번역 단위)를 번역하면 목적 파일 한 개가 생긴다.

목적 파일에는 정의된 함수를 번역한 기계어, 전역 변수와 정적 변수,  다른 .cpp 파일에서 정의된 함수를 가리키는 미확정 참조를 담고 있다.

 

목적 파일들을 모두 모아서 완성된 실행 파일로 만드는 것은 링커의 몫이다. 이 과정에서 링커는 모든 목적 파일을 읽어 미확정 상태인 외부 참조가 진짜 어떤 것인지 알아내려고 시도한다.

 

링커의 주된 역할은 외부 참조를 해결하는 일인데, 이와 관련해 링커가 낼 수 있는 에러는 두 가지이다.

1. extern으로 선언된 외부 참조를 찾아낼 수 없는 경우, 미확정 심볼 (unresolved-symbol) 에러

2. extern으로 선언된 외부 참조를 두 개 이상 발견한 경우, 중복 정의된 심볼 (multiply-defined symbol) 에러

 

3.2.2.2 선언과 정의의 차이 (+ 인라인 함수)

선언

ㄴ 데이터 객체나 함수의 형태를 나타낸다. 이름과 데이터 타입 혹은 함수의 서명(리턴 타입과 인자 타입)을 알려준다.

정의

ㄴ 프로그램 안에 고유한 저장 공간을 나타낸다. 

 

선언으로 함수를 표현하는 경우 다른 번역 단위에서 참조하거나 같은 번역 단위에서 사용할 수 있다. 함수 서명 후에 세미콜론을 붙이면 선언이 되는데 extern 키워드를 붙여도 되고 붙이지 않아도 된다. (안붙이면 붙어있는 것으로 처리됨)

다른 번역 단위에서 정의된 전역 변수를 사용할 때는 현재 번역 단위에서 extern 키워드를 앞에 붙여 선언하면 된다.

 

헤더 파일에 정의를 두지 않는 이유

ㄴ 여러 .cpp 파일에서 #include 구문으로 정의를 불러들이면 중복 정의된 심볼 에러가 발생하기 때문에

ㄴ 인라인 함수의 정의는 예외이다.

 

컴파일러가 인라인 함수를 처리하려면 컴파일러가 함수 구현을 볼 수 있어야 한다.

ㄴ 헤더 파일에 inline 키워드를 붙인 함수를 정의한다.

ㄴ 인라인 키워드는 컴파일러에게 주는 힌트일 뿐이다. 인라인 할지 안할지는 컴파일러가 함수 크기와 얻을 수 있는 효율성을 분석해 정한다.

ㄴ 인라인 함수를 호출하는 부분은 함수 자체의 내용 복사본으로 대체되어 함수 호출 오버헤드가 제거된다.

 

3.2.2.3 연결성

C/C++의 모든 정의마다 연결성(linkage)이라는 속성이 있다.

기본적으로 모든 정의는 외부 연결성(external linkage)이다.

static 키워드는 정의를 내부 연결성으로 바꿀 때 사용한다.

ㄴ static 함수를 예로 들면 다른 번역 단위에 동일한 이름의 함수가 있어도 중복 정의된 심볼 에러를 내지 않고 자신의 번역 단위에 있는 static 함수를 사용한다.

 

3.2.3 C/C++ 프로그램의 메모리 구조

 

3.2.3.1 실행 파일 이미지 (= 프로그램 코드, 데이터)

1. 텍스트 세그먼트 = 코드 세그먼트 = 기계어

2. 데이터 세그먼트 = 전역 변수, 정적 변수 

3. 읽기 전용 데이터 세그먼트 = const로 선언된 전역 변수, 인스턴스

 

3.2.3.2 프로그램 스택

실행 파일이 메모리에 로드될 때 운영체제는 프로그램 스택이라는 메모리 공간을 마련한다.

함수가 불릴 때마다 스택 프레임이라는 것을 프로그램 스택에 push하고 리턴할 때마다 pop한다.

 

스택 프레임에는 세 가지 종류의 데이터가 저장된다.

1. 리턴 주소

ㄴ 함수 리턴 후 PC레지스터가 가리켜야 할 주소(함수를 호출했던 곳)

 

2. CPU 레지스터

ㄴ 함수가 호출되기 이전의 상태로 돌아가기 위해 이전 레지스터들의 값들을 저장하고 있어야 한다.

 

3. 지역 변수

ㄴ 호출된 함수의 지역 변수들은 해당 함수 스택 프레임에 존재한다.

 

3.2.3.3. 동적 할당 힙

전역 변수, 정적 변수, 지역 변수 등은 정적(static)으로 정의되는 저장 공간이어서 컴파일할 때 저장 공간을 알 수 있지만, 프로그램이 메모리를 얼마나 사용할지 컴파일할 때 알 수 없는 경우 프로그램은 추가로 더 필요한 메모리를 동적으로 할당해야 한다.

C++에서는 new delete 연산자를 사용한다.

 

3.2.4 멤버 변수 

클래스나 구조체의 선언만으로는 어떠한 저장 공간도 할당하지 않는다. (멤버 변수들을 위한 저장 공간은 없다)

 

3.2.4.1 클래스 정적 멤버

클래스 정적 변수는 자동으로 클래스의 네임스페이스에 포함되기 때문에 클래스 밖에서 지칭할 때는 항상 클래스 이름을 사용해야 한다. ([클래스 이름]::[변수 이름])

 

3.2.5 메모리상의 객체 구조

 

3.2.5.1 메모리 정렬과 패킹

요즘 나오는 CPU 상당수가 메모리 정렬이 제대로 지켜진 데이터 블록만 읽고 쓸 수 있다.

메모리 정렬 조건 : 객체가 저장된 메모리 주소가 객체의 크기의 배수여야 한다.

구조체와 클래스의 멤버의 정렬 조건 중 가장 큰 정렬 조건을 가지는 멤버(크기가 가장 큰 멤버)의 정렬 조건이 곧 구조체의 정렬 조건이 된다.

구조체 내의 데이터 멤버를 어느 순서로 선언하느냐에 따라 메모리 효율성이 달라질 수 있다.

구조체의 멤버로 패딩을 넣는 것은 구조체가 배열로 사용되었을 경우에도 메모리 정렬을 보장하기 위함이다.

 

3.2.5.2 C++ 클래스의 메모리 구조

컴파일러는 베이스 클래스나 클래스에 가상함수가 있는 경우 가상함수 테이블을 만들고 테이블을 가르키는 4바이트 포인터(64비트 환경에서는 8바이트) 를 클래스 인스턴스 마다 둔다.

 

class A {
    int x;
    int y;
    
    virtual void Draw() {
    
    }
}

class B : public A {
    int x;
    int y;
    
    virtual void Draw() {
    
    }
}

B 클래스 인스턴스의 메모리 구조

sizeof(B)는 20이 된다.

3.2.6 킬로바이트와 키비바이트

킬로바이트 = 1000 바이트

키비바이트 (KiB) = 1024 바이트

 

3.3 에러 감지와 처리

사용자 에러와 프로그래머 에러로 구분할 수 있다.

 

3.3.2 에러 처리

1. 사용자가 게임 플레이어인 경우

ㄴ 게임 로직의 에러는 게임의 내용에 맞게 처리되는 것이 자연스럽다. (게임의 규칙을 따르게 한다.)

 

2. 사용자가 아티스트나 애니메이터, 게임 디자이너 등 게임을 만드는 다른 사람일 경우

ㄴ 잘못된 게임 자원을 사용할 경우 발생한다.

ㄴ 에러가 발생하더라도 다른 자원으로 대체하여 계속 진행할 수 있도록 하는 유연함이 필요하다.

 

3. 프로그래머 에러 처리

ㄴ 가장 좋은 방법은 에러 감지 코드를 소스코드 곳곳에 넣고 감지가 되면 시스템을 멈춰버리는 assertion 시스템을 활용하는 것

 

3.3.3 에러 감지와 에러 처리 구현

1. 에러 리턴 코드

ㄴ 함수의 리턴 값으로 불가능한 값을 리턴하여 에러를 처리하는 방법

ㄴ 에러를 감지한 함수가 그 에러를 다룰 수 있는 함수와 연관이 없는 경우 에러 코드를 에러를 다룰 수 있는 상위 함수에 도달할 때까지 전달해야 한다. 

 

2. 예외 처리

ㄴ 에러가 발생했을 때 어떤 함수가 처리할 것인지 신경쓰지 않고 에러를 전달할 수 있다.

ㄴ 프로그래머가 정의한 예외 객체에 연관 정보를 넣고 콜 스택을 펼쳐(펼치는 과정에서 지역 변수들은 소멸된다.)  try-catch 블록에 도달할 때까지 반복되며 찾으면 catch 블록 안의 코드가 실행된다.

ㄴ 별로 안좋다고 한다. 

 

3. assertion

ㄴ C++ 에서는 cassert 헤더 파일을 include하고 assert(표현식) 의 형태로 사용한다.

ㄴ assertion은 #define 매크로로 구현되기 때문에 #define 구문만 바꿔주면 assertion을 없어지게 할 수도 있다. 

ㄴ assertion이 실패할 경우 항상 프로그램 실행을 멈춰야 한다.

ㄴ 치명적인 에러를 잡는데만 써야 한다.

ㄴ 사용자 에러를 찾는데 써서는 안된다.

 

3.4 파이프라인, 캐쉬 그리고 최적화

여러가지 성능 최적화 중 여기에서는 소프트웨어가 계산을 최대한 빠르게 수행하도록 만드는 방법에 대해서 논한다.

 

3.4.1 병렬처리 패러다임의 변화

과거에는 CPU가 상대적으로 느렸기 때문에 프로그래머는 명령에 의해 소비되는 사이클 수를 줄이도록 노력했지만

현대에는 다수 CPU 코어를 이용하도록(병렬성을 이용하도록) 소프트웨어가 작성되어야 한다.

현대의 CPU는 메모리 접근 비용이 CPU 연산 비용보다 상대적으로 비싸졌기 때문에 프로그래머는 CPU에 더 많은 일을 하도록 설계한다.

 

3.4.2 메모리 캐시

캐시는 메인 RAM보다 훨씬 더 빠르게 CPU에서 읽고 쓸 수 있다.

3.4.2.1 캐시 라인

메인 RAM이 256MiB, 캐시라인이 각각 128바이트고 캐시의 크기가 32 KiB인 경우 (256개의 캐시 라인)

캐시는 캐시 라인 크기의 배수에 정렬되는 메모리 주소만 처리할 수 있다.

직접 사상 캐시의 경우

메인 RAM의 어떤 주소에 들어있는 데이터가 캐시에 들어 있는 경우 (메인 RAM의 주소 % 캐시 크기(32KiB)) 값을 캐시 주소로 사용하여 캐시에 접근하면 데이터를 얻을 수 있다.

캐시는 캐시 라인 크기의 배수에 정렬되는 메모리 주소만 처리할 수 있다.

 

TLB를 모두 탐색했을 때 일치하는 블록 인덱스(위의 그림에서 0 ~ 8191)가 있다면 캐시 주소로 캐시에 접근해 데이터를 얻고 없으면 page table을 통해 ram에 접근을 한다.

 

TLB는 context switching때 초기화된다.

 

3.4.2.2 명령어 캐시와 자료 캐시

명령어 캐시 (I-캐시) : 수행되기 전에 실행 기계 코드를 미리 적재하는 것

자료 캐시 (D-캐시): 메인 RAM에서 자료를 읽거나 쓰는 작업의 속도를 높이기 위해 자료를 적재하는 것

보편적으로 L1 캐시에서는 두 종류의 캐시를 따로 존재한다. 

 

3.4.2.3 집합 연합과 대체 정책

각각의 메인 메모리 주소가 두 개 이상의 캐시 라인으로 사상될 수 있다면 더 좋은 성능을 낼 수 있다. (N way 집합 연합 캐시를 사용하는 경우)

 

위 그림(직접 사상 캐시 : 1 way set associateive 캐시)에서는 8192개의 개별 캐시 라인 크기(128byte) 블록이 하나의 캐시 라인으로 사상된다.

 

캐시 미스가 발생하면 여러 개의 캐시 경로 중 어떤 것을 내쫓을 것인지 정하는 방법을 대체 정책이라고 한다. 보통 가장 오래된 자료를 내쫓는다.

 

3.4.2.4 쓰기 정책

1. 직접 쓰기 캐시 : 캐시에 쓰면 RAM에 즉시 적용됨

2. 뒤에 쓰기 캐시 : 자료가 먼저 캐시에 기록된다. 캐시 미스로 인해 새로운 캐시 라인을 읽어들이기 위해 내쫓기는 경우, 프로그램이 명시적으로 캐시 비움을 요청하는 경우 RAM에 캐시 라인이 전달된다. (= RAM에 캐시 라인을 복사한다.)

 

3.4.2.5 다층 캐시

캐시의 크기가 커지면 적중률이 높아져 프로그램이 수행을 더 잘한다. 하지만 캐시의 크기가 커지면 CPU에 가깝게 배치될 수 없어서 더 작은 캐시보다 느려진다.

 

대부분의 게임 콘솔은 두 개 층의 캐시를 채택한다. 레벨 1 캐시에서 자료 찾기를 시도하고 없으면 지연 시간이 더 긴 레벨 2 캐시에서 시도한다. 자료가 L2 캐시에도 없다면 RAM의 접근 비용을 지불해야 한다. (지연 시간이 매우 크다..)

 

3.4.2.6 캐시 일관성: MESI와 MOESI

다수의 CPU 코어가 한 개의 메인 메모리 공간을 공유할 때 문제가 생길 수 있다.

각각의 코어가 자신만의 L1 캐시를 갖는 것은 보통이지만, 다수의 코어가 L2 캐시와 메인 RAM은 공유하게 되기 때문이다.시스템을 캐시 일관성을 유지하는 것이 중요하다. 캐시들에 있는 자료가 서로 일치하고 메인 RAM에 있는 자료와도 일치되도록 유지해야 한다.

보통 사용되는 캐시 일관성 프로토콜은 MESI와 MOESI가 있다.

 

3.4.2.7 캐시 미스 피하기

캐시 미스를 완전히 피하는 것은 불가능하지만 프로그래머는 캐시 미스가 최대한 적게 발생하도록 노력할 수 있다.

 

 D-캐시 미스를 피하는 좋은 방법 : 자료를 가능한 한 제일 작은 연속된 블록으로 조직하고 순차적으로 접근하는 것이다.

예를 들어 객체의 리스트를 순회해야 한다면, 객체는 포인터로 만든 연결리스트가 아닌 배열로 구현(연속된 블록)하고, 객체의 크기는 최대한 줄이고(한정된 캐시의 크기에 최대한 많은 수의 객체를 넣어야 하므로), 배열의 0번 인덱스부터 차례대로 접근(캐시를 알차게 쓸 수 있다) 하면 된다.

 

I-캐시 미스를 피하는 좋은 방법 : 코드 크기 면에서 성능에 가장 영향을 미치는 반복문을 가능한 작게 만들고, 제일 안쪽의 반복문에서 함수 호출을 피하는 것이다. 이것은 반복문 전체 몸체가 반복문이 수행되는 동안 내내 I-캐시 안에 머물도록 해준다.

반복문이 함수를 호출할 필요가 있을 때는, 불려지는 함수의 코드가 메모리 상에서 반복문의 몸체를 포함하는 코드 가까이에 위치하도록 하는 것이 좋다. 컴파일러와 링커가 메모리상에서 코드 배치를 어떻게 할지 결정하기 때문에 사용자는 I-캐시 미스에 대해서 다룰 권한이 없다고 생각할 수 있지만 그렇지 않다.

 

대부분의 C/C++ 링커는 다음과 규칙을 따른다.

1. 단일 함수를 위한 기계어 코드는 거의 항상 메모리상에 연속적이다.

2. 함수는 소스 코드에 나타나는 순서대로 메모리에 배치된다.

 

I-캐시 미스를 피하기 위한 규칙

1. 기계 언어 명령어 수의 관점에서 성능에 영향을 미치는 코드는 가능한 한 작게 만들어라

2. 성능에 민감한 코드 안에서는 함수 호출을 피하라

3. 함수를 호출해야 한다면, 부르는 함수에 최대한 가까이 배치시켜라 (부르는 함수의 앞이나 뒤에 배치시키고, 다른 번역 단위에 놓지 마라)

4. 인라인 함수를 분별 있게 사용하라 : 작은 함수를 인라인으로 바꾸는 것은 성능상의 장점이 될 수도 있지만, 너무 많은 인라인화는 코드 크기를 부풀린다. 불필요하게 커진 코드 부분이 성능에 치명적인 부분 (예를 들어 반복문 내부) 이라면 캐시안에 전부 들어가지 못하게 될 수 있고, 반복문의 반복 한번에 캐시 미스가 두 번 생길 수 있다.

이런 경우에는 코드의 크기를 줄이도록 알고리즘과 구현을 개선하는 방법을 생각해야 한다.

 

3.4.3 명령문 파이프라인과 슈퍼스칼라 CPU (CPU 자체 안에서의 병렬 처리를 향상시킬 수 있는 두가지 아키텍처 구조)

cpu 명령 파이프라이닝 

ㄴ 파이프라인의 지연시간은 하나의 명령어를 완전히 처리하는데 소요되는 시간이다. 파이프라인의 각 단계중 가장 오래걸리는 지연 시간을 기준으로 결정된다.

 

슈퍼스칼라 프로세서는 파이프라인 단계들의 일부분이나 전체에 대한 다중 회로 복사본을 포함한다. 이는 여러 개의 명령어 스트림을 병렬로 처리할 수 있다.

예를 들어 CPU가 두 개의 정수 산술/논리 단위(ALU)를 가지고 있다면, 두 개의 정수 명령어가 동시에 처리될 수 있다.

 

3.4.3.1 자료 의존과 칸막이

파이프라인식 CPU는 새로운 명령어를 모든 클락 사이클마다 실행시켜서 가지고 있는 모든 단계(fetch, decode, execute, memory, register write-back)를 활성화 시키려 한다. 어떤 명령어의 결과가 다른 명령어를 실행시키기 위해서 필요하다면 

자료 의존 문제가 발생하여 파이프라인에 칸막이(stall)을 만든다.

 

move 5, r3

mul r0, 10, r1

add r1, 7, r2

 

와 같은 어셈블리어 명령문을 실행하게 되면 add는 mul의 결과 (r1)을 사용해야 하기 때문에 mul 명령어의 register write-back 단계가 끝날 때까지 기다려야 한다.

최적화 컴파일러에서는 stall을 피하기 위해 add r1, 7, r2 뒤에 있는 의존적이지 않은 명령들을 mul과 add 사이로 옮긴다.

 

3.4.3.2 분기 예측

if 문을 만나게 되면 stall이 발생한다. 기다리는 대신에 분기를 하나 골라서 계속 실행하고 분기문의 조건 계산이 끝난 후 추측이 맞다면 그대로 진행하고 추측이 틀렸다면 파이프라인은 비워지고 올바른 분기의 첫 번째 명령어가 재시작되어야 한다.

어떤 분기를 선택할지 예측은 하드웨어가 한다. 분기 예측 하드웨어 성능이 좋아야 한다.

'읽은 책 > 게임 엔진 아키텍처' 카테고리의 다른 글

6. 리소스 시스템과 파일 시스템  (0) 2022.08.08
5. 엔진 지원 시스템  (0) 2022.08.06
4. 게임에 사용되는 3D 수학  (0) 2022.08.04
2. 도구  (0) 2022.07.31
1. 소개  (0) 2022.07.30

 

2.1 버전 컨트롤

2.1.1 버전 컨트롤을 쓰는 이유

ㄴ 모든 엔지니어가 공유할 소스코드의 중심 저장소 역할

ㄴ 모든 소스코드의 변경 이력을 보관

ㄴ 특정 상태나 시점의 기반 소스코드에 태그를 달고 필요할 때 복원하는 기능

ㄴ 버전 브랜치 기능

 

git, 서브버전과 같은 버전 컨트롤 시스템을 사용할 수 있다.

 

2.2 마이크로소프트 비주얼 스튜디오

2.2.1 소스파일, 헤더 파일, 번역 단위

소스파일(.c .cpp) = 번역 단위 (컴파일러가 한 번에 기계어 코드로 바꾸는 단위)

 

컴파일러는 헤더 파일이 존재하는지 모른다. C++ 전처리기가 컴파일에 번역 단위를 보내기 전에 모든 #include 구문을 헤더 파일의 내용으로 교체하기 때문이다. 덕분에 컴파일러는 번역 단위만 다루면 된다.

 

2.2.2 라이브러리, 실행 파일, 동적 링크 라이브러리

컴파일로 생성된 기계어는 목적 파일(.obj)에 저장된다. 목적 파일 안의 기계어는 다음과 같은 특성이 있다.

1. 재배치 가능 - 코드가 위치할 메모리 주소가 아직 결정되지 않은 상태다.

2. 링크되지 않음 - 번역 단위 안에 들어 있지 않은 외부의 함수나 전역 데이터로의 외부 참조가 아직 확정되지 않았다.

 

라이브러리는 목적 파일 여러 개를 묶어 놓은 집합체들이다.

 

링커에 의해 실행 파일로 변환된 목적 파일들은 각 번역 단위에서 확정하지 못했던 외부 함수와 전역 데이터로의 참조를 확정하게 된다. 실행 파일 내의 기계어 코드는 여전히 재배치 가능한 상태(메모리 주소가 결정되지 않은)이다. 메모리 주소는 실행 파일이 메모리에 올라가기 직전에 결정된다.

 

동적 링크 라이브러리 (DLL)

ㄴ 운영체제가 따로 로드하고, C++ 실행 파일의 main() 과 비슷한 형태의 시작과 끝을 처리하는 코드가 있다.

ㄴ DLL을 사용하는 C++ 실행 파일은 DLL 안에 포함되어 있는 함수 혹은 데이터에 대한 참조는 결정되지 않은 상태로 존재한다. (부분적인 링크)

ㄴ 실행 파일을 실제로 실행할 때(메모리에 올릴 때) 운영체제가 필요한 DLL이 로드되어있지 않으면 로드하여 링크되지 않은 참조를 확정한다.

ㄴ 실행 파일을 변경하지 않으면서 필요한 DLL만 개별적으로 교체할 수 있다.

 

2.2.4 빌드 설정

디버그 버전 빌드는 릴리즈 버전 빌드에 비해 느리게 동작하지만 프로그래머가 개발하고 디버깅하는데 필요한 중요한 정보를 제공한다.

 

2.2.4.1 일반적인 빌드 설정

전처리기 설정

커맨드라인 옵션을 통해 전처리기 매크로를 정의할 수 있는데 ( -D 옵션 ) 이를 이용해 조건부 컴파일을 할 수 있다.

소스코드가 실행될 대상 플랫폼도 마찬가지로 매크로를 통해 인식할 수 있기 때문에 이를 이용해 교차 플랫폼 용 소스코드 작성이 가능하다.

 

컴파일러 설정

목적 파일에 디버그 정보를 포함시킬 것인지 아닐 것인지 지정하는 옵션이 있다.

인라인 함수를 확장할 것인지 아닌지를 지정할 수 있다. - 확장하게 되면 인라인 함수의 실행 속도 향상을 기대할 수 있다.

인라인 함수 확장과 같은 최적화를 하면 대개 소스 코드 실행 순서가 바뀌고 변수가 사라지거나 위치가 바뀌기도 하기 때문에 디버깅하기 어려워진다. 그래서 디버그 빌드에서는 모든 최적화를 사용하지 않는다.

 

링커 설정

실행 파일(.exe)를 만들 것인지 동적 라이브러리(.dll)를 만들 것인지 어떤 외부 라이브러리를 링크해서 실행 파일을 만들지, 어느 경로에서 이 라이브러리를 찾을지 등을 지정할 수 있다. 보통 디버그 빌드에서는 디버그용 라이브러리를 링크하고 릴리즈 빌드에서는 최적화된 라이브러리를 사용한다.

 

2.2.4.2 흔히 사용하는 빌드 설정

1. 디버그 - 모든 최적화를 끔, 인라인 함수 확장은 꺼져 있으며, 디버그 정보를 최대한 갖고 있는 매우 느린 버전

2. 릴리즈 - 디버그 빌드보다 빠르지만 디버그 정보와 assertion은 포함

3. 제품 -  가능한한 많은 최적화, 디버그 정보와 assertion 미포함

4. 툴 - 오프라인 툴과 게임 간에 공유되는 라이브러리를 활용할 때 툴에 의한 사용을 위해 공유된 코드를 조건부로 컴파일( #define TOOLS_BUILD와 같은 구문을 사용하여) 하는데 사용될 수 있다.

 

하이브리드 빌드

일부의 번역 단위만 디버그 모드로 빌드하고 나머지 대부분은 릴리즈 모드로 빌드하는 빌드 설정

make와 같은 텍스트 기반의 빌드 시스템은 사용하기 수월하지만 비주얼 스튜디오는 프로젝트 단위로 빌드 설정을 사용하기 때문에 쉽지 않다.

 

빌드 설정과 테스트 용이성

빌드 설정을 여러 개 사용할수록 테스트하기는 어려워진다. 다양한 빌드 설정들 간에 차이가 미미하다고 할지라도 한 빌드에서는 치명적인 버그가 발생할 수 있기 때문이다.

 

2.2.4.3 프로젝트 속성

 

일반 속성 페이지

일반 속성 페이지

출력 디렉터리 : 실행 파일이나 라이브러리, DLL 등 컴파일러/링커가 최종적으로 만들어 낼 결과물이 어디에 저장될지를 정의한다. $(OutDir) 매크로와 동일하다.

 

중간 디렉터리 : 빌드 중간에 생기는 파일인 목적 파일이 어디에 저장될지 지정한다. 목적 파일은 빌드 중에만 필요하고 최종 결과물에는 포함되지 않기 때문에 최종 결과물이 저장되는 출력 디렉터리와 다르게 설정하는 게 좋다.

 

매크로를 사용하면 더 쉽게 설정을 바꿀 수 있다.

 

디버깅 속성 페이지

디버깅 속성 페이지

디버깅할 실행 파일의 이름과 위치를 지정할 수 있다.

실행될 때 전달되어야 하는 커맨드라인 명령어의 변수를 지정할 수 있다.

 

C/C++ 속성 페이지

소스 파일이 컴파일되어서 목적 파일이 되는 과정에 관한 컴파일-시점 언어 설정을 제어할 수 있다.

c c++ / 일반 / 추가 포함 디렉터리 : #include 된 헤더 파일을 검색할 때 로컬 디스크상의 어떤 디렉터리를 찾을지를 결정한다.

c c++ / 전처리기 : 컴파일할 때 소스 코드에 정의되어 있어야 하는 C/C++ 전처리기 심볼들의 리스트를 지정한다.

c c++ / 디버그 정보 형식 : 디버그 정보를 생성할지와 어떤 포맷으로 해야 할지를 지정한다. 보통 디버그와 릴리즈 빌드 구성은 개발 중에 문제 추적을 위해 디버그 정보를 생성하고, 최종 빌드에서는 해킹 방지를 위해 디버그 정보를 제거한다.

 

링커 속성 페이지

링커 속성 페이지

목적 파일들이 어떻게 실행 파일이나 DLL로 링크될지에 영향을 줄 속성들의 리스트를 보여준다.

링커 / 일반 / 출력 파일 : 실행 파일이나 DLL과 같은 빌드 최종 결과물의 이름과 위치들의 리스트를 보여준다. (출력 디렉터리/aaa.exe)

링커 / 일반 / 추가 라이브러리 디렉터리 : C/C++의 추가 포함 디렉터리와 비슷하게 최종 실행 파일에 링크시킬 목적 파일과 라이브러리를 찾아 볼 경로들의 리스트를 보여준다.

링커 / 입력 / 추가 종속성 : 실행 파일이나 DLL로 링크 시킬 외부 라이브러리들의 리스트를 보여준다. 새로운 라이브러리를 등록할 경우 라이브러리 디렉터리에 들어있는 라이브러리 파일명을 추가하면 된다.

 

비주얼 스튜디오에서는 실행 파일의 링크될 라이브러리를 정하는 구문 #pragma를 사용하면 프로젝트 속성 탭에서 라이브러리를 따로 추가하지 않고도 라이브러리를 링크할 수 있다.

 

DLL 파일을 사용하기 위해서는 실행파일과 동일한 폴더에 DLL 파일을 넣어야 한다.

파일을 옮기기 싫다면 디버깅 / 환경 에서 DLL이 들어있는 폴더 경로를 설정해줘야 한다.

 

2.2.5.8 최적화되어 있는 빌드의 디버깅에 필요한 기술

ㄴ 디버거의 디스어셈블리를 읽을 줄 아는 능력 키우기

ㄴ 레지스터를 해석해 변수 값이나 메모리 주소를 알아내기

ㄴ 주소에 의해 변수나 객체의 내용을 알아내기

ㄴ 정적 변수와 전역 변수를 활용하기

ㄴ 코드를 수정해 보기

 

2.3 프로파일링 툴

프로파일러는 크게 두 가지 부류로 나눌 수 있다.

1. 통계 방식 프로파일러 

ㄴ CPU의 프로그램 카운터 레지스터를 주기적으로 샘플링해서 어떤 함수가 실행 중인지 알아내는 원리로 작용한다. 각 함수 안에서 수집된 샘플 숫자의 합이 전체 실행 시간에서 그 함수를 실행하는 데 걸린 시간의 근사적인 비율이다.

ㄴ 대상 프로그램에 영향을 주지 않아서 프로파일링을 적용할 때와 그렇지 않을 때의 실행 속도 차이가 별로 없다.

 

2. instrument 방식의 프로파일러

ㄴ 가장 폭넓은 시간 데이터 정보를 제공하지만, 실제 수행 속도 감소를 감수해야 한다.

ㄴ 프로그램의 매 함수마다 시작과 끝에 임의의 코드를 삽입하여 작동시킨다. 삽입된 코드는 프로파일링 라이브러리를 매번 호출한다.

ㄴ 콜 스택을 조사하고 어떤 부모 함수가 조사 대상 함수를 호출했는지 정보를 제공한다.

 

2.4 메모리 누수와 오염 감지

메모리 누수는 보통 C/C++의 포인터를 잘 못써서 발생한다.

메모리 누수 감지 프로그램을 사용할 수 있다.

ㄴ IBM PurifyPlus는 소스코드를 실행하기 전에 코드를 가공해 개발자가 실행한 모든 메몸리 비정상 참조와 메모리 할당, 해제를 감지할 수 있게 해준다.

 

2.5 기타 도구 

ㄴ 비교 툴 : 텍스트 파일 두 개 비교하여 어떤 부분이 다른지 체크 (Diff Check)

ㄴ 합치기 툴 : 두 텍스트 파일 합치기

ㄴ 헥스 에디터 

 

1.1 게임 팀 구성

1.1.1 엔지니어

엔지니어는 게임을 만드는데 쓰이는 소프트웨어와 툴을 만든다.

 

엔진과 게임을 개발하는 런타임 프로그래머 

다른 개발 팀들이 효율적으로 일하는 데 필요한 오프라인 툴들을 만드는 툴 프로그래머

1.1.2 아티스트

컨셉 아티스트 

3D 모델러

텍스처 아티스트

광원 아티스트

애니메이터

모션 캡처 배우

사운드 디자이너

성우

1.1.3 기획자

게임 플레이를 설계한다.

게임 월드의 지형이나 레벨을 다루며, 배경 배치 및 적 출현 위치와 시점 결정, 아이템 배치, 퍼즐 설계 등을 맡는다.

1.1.5 지원부서

스튜디오의 경영 관리 팀

마케팅 팀

IT기술 지원부서

1.1.6 퍼블리셔

게임을 판매, 제조, 배포하는 일은 대부분 개발 스튜디오가 아닌 퍼블리셔를 통한다.

 

1.3 게임 엔진이란

게임 엔진이라는 말은 1990년대에 폭팔적인 인기를 끌었던 아이디소프트의 '둠' 등의 일인칭 시점 슈팅(FPS) 게임에서 유래했다.

 

처음에 엔진은 '모드 제작'을 염두에 두고 설계된 게임들을 위해 존재했다.

 

게임을 만드는데 사용된 소프트웨어가 엔진인지 아닌지를 구분할 때는 소프트웨어가 데이터 주도적으로 설계되었는지를 보는 것이 일반적이다. 게임 규칙이나 어떤 게임 객체를 그리는 부분이 하드코딩돼 있다면 그 소프트웨어는 다른 게임을 만들기 어렵다.

 

일반적으로 게임 엔진이나 미들웨어의 범용성이 커질수록 특정 플랫폼이나 게임에 대한 최적화는 떨어진다.

예를 들면 실내 환경을 전문적으로 처리하게 설계된 렌더링 엔진을 넓은 실외 환경을 다루는데 사용하면 효율적이지 않다.

 

보통 실내용 엔진은 벽 뒤에 있거나 다른 물체에 가려 그릴 필요가 없는 것들을 구분하기 위해 BSP 트리나 포탈 시스템 등을 이용한다. 하지만 실외용 엔진은 물체의 차폐 문제에 깊이 신경 쓰기보다는 적극적으로 LOD을 사용할 가능성이 크다.

 

CPU와 그래픽 카드의 성능이 나날이 향상되고 좀 더 효율적인 알고리즘과 자료구조가 개발됨에 따라 서로 다른 장르를 위한 그래픽 엔진들 간 차이가 줄어들었지만, 범용성과 최적화 간의 균형을 조절하는 것은 게임 개발에서 중요한 문제다.

 

1.4 장르별 게임 엔진

보통 게임 엔진은 특정한 장르의 게임을 위해 만들어진다.

FPS, RTS를 위한 게임 엔진은 서로 차이가 있다.

 

키보드, 마우스, 조이스틱 등 사용자 입력을 처리하는 부분이나 3D 메시를 그리는 부분, 헤드업 디스플레이(HUD) 텍스트 렌더링, 오디오 등은 게임의 장르가 다르더라도 공통적으로 게임 엔진에서 지원해야 할 기능들이다.

 

1.4.1 1인칭 시점 슈터 게임 (FPS : First Person Shooter)

FPS 게임에서 중요시하는 기술

ㄴ 광활한 3D 가상 세계의 효율적인 렌더링

ㄴ 즉각적인 카메라 조작과 조준

ㄴ 매우 사실적인 캐릭터의 팔과 무기 애니메이션

ㄴ 다양한 종류의 소형화기 구현

ㄴ 그럴싸한 캐릭터 움직임과 충돌 구현

ㄴ 매우 사실적인 NPC 애니메이션(플레이어의 적이나 동료 등)과 인공지능

ㄴ 작은 규모의 멀티플레이 지원(보통 64인 이하), '데스 매치'라 불리는 플레이어 간 결투 기능

 

1.4.2 플랫포머와 다른 3인칭 시점 게임

플랫포머에서 중요시하는 기술

ㄴ 움직이는 발판, 사다리, 밧줄, 창살 등의 흥미로운 이동방식

ㄴ 퍼즐이 가득한 배경

ㄴ 메인 캐릭터를 따라 다니며 회전 가능한 3인칭 시점 카메라

ㄴ 시야를 보장하기 위해 시점이 배경이나 물체에 가리거나 뚫고 들어가지 않게 하는 카메라 충돌 시스템

 

1.4.3 격투 게임

ㄴ 풍부한 격투 애니메이션

ㄴ 정확한 타격 감지

ㄴ 버튼과 조이스틱으로 이뤄진 복잡한 입력을 처리할 수 있는 시스템

ㄴ 군중과 대체로 정적인 배경

 

1.4.4 레이싱 게임

ㄴ 먼 곳의 배경을 그리기 위해 다양한 눈속임들(나무나 언덕, 산 등을 빌보드로 그리는 등)을 사용한다.

ㄴ 트랙을 단순한 2차원적인 구획들로 나눠 처리하는 경우가 많은데, 이런 자료구조는 렌더링 최적화나 시야 결정을 비롯해 AI, 길 찾기 등 다양한 기술적 문제를 해결하는데 쓰인다.

ㄴ 카메라는 3차원 시점으로 탈것의 뒤를 쫓아가거나 운전석 안에 위치해 1차원 시점을 제공한다.

ㄴ 터널 등 좁은 트랙을 달릴 때 카메라가 배경과 부딪히거나 뚫지 않도록 한다.

 

1.4.5 실시간 전략 게임 (RTS : Real-Time Strategy)

ㄴ 유닛들은 상대적으로 디테일이 낮아서 한 번에 많은 수의 유닛이 화면에 나와도 문제 없게 한다.

ㄴ 게임을 디자인하고 플레이하는 배경이 되는 지형은 높이 필드를 통해 구현하는 경우가 많다.

ㄴ 병력을 조종하는 기능과 새로운 건물을 지을 수 있는 기능이 있다.

ㄴ 유저 입력은 마우스 클릭이나 드래그를 받고 메뉴와 툴바를 통해 명령, 장비, 유닛 타입, 빌딩 타입 등을 표현한다.

 

1.4.6 대규모 다중 사용자 멀티플레이어 온라인 게임 (MMOG : Massively Multiplayer Online Games)

ㄴ 서버의 역할이 중요

 

1.6 런타임 게임 아키텍처

게임 엔진은 크게 툴과 런타임 구성 요소로 나뉜다. 

게임의 런타임 구성 요소만 해도 엄청 많다..

상위 계층은 하위 계층에 의존하지만 그 반대는 아니다. 게임 엔진 뿐만 아니라 다른 어느 소프트웨어라도 하위 계층이 상위 계층에 의존하게 되면 시스템 간 불필요한 결합을 생기게 하기 때문에 주의해야 한다.

 

1.6.1 Hardware 계층

ㄴ 게임이 동작할 컴퓨터나 콘솔 시스템을 뜻한다.

 

1.6.2 Drivers 계층

ㄴ 디바이스 드라이버는 운영체제나 하드웨어 제조사에서 제공하는 로우레벨 소프트웨어이다.

 

1.6.3 OS 계층

ㄴ 대부분의 최신 OS(윈도우, 플레이스테이션 3, 엑스박스 360) 에서는 여러 애플리케이션 간 하드웨어 자원을 분배할 때 선점형 멀티태스킹으로 알려진 시분할 기법을 사용한다.

 

1.6.4 써드파티 SDK와 미들웨어 계층

대부분 게임 엔진은 다양한 서드파티 소프트웨어 개발 도구(SDK)와 미들웨어를 적극 이용한다.

 

1.6.4.1 자료구조와 알고리즘을 제공하는 라이브러리

ㄴ STL : C++ 표준 템플릿 라이브러리는 여러 가지 자료 구조, 문자열, I/O 스트림에 관한 코드와 알고리즘을 제공한다.

ㄴ STL port : 여러 플랫폼에서 사용 가능한 좀 더 최적화된 STL

ㄴ Boost : STL 스타일로 설계된 강력한 자료 구조와 알고리즘 라이브러리

ㄴ Loki : 강력한 제네릭 프로그래밍 템플릿 라이브러리

 

어떤 라이브러리를 사용할지 결정하는 데 있어서 PC 환경인지 콘솔 환경인지가 중요한 요소가 된다. 예를 들어 콘솔 환경은 PC 환경과 달리 캐시 미스 문제가 치명적인데, 어떤 자료구조를 사용하느냐에 따라 캐시 미스 발생률에 차이가 있을 수 있다.

 

1.6.4.2 그래픽스

게임 렌더링 엔진은 다음과 같은 하드웨어 인터페이스 라이브러리를 사용하여 구현한다.

ㄴ OpenGL

ㄴ DirectX

 

1.6.4.3 충돌과 물리

ㄴ Havok : 인기 있는 고성능 물리, 충돌 엔진

ㄴ PhysX : 엔비디아에서 만든 물리, 충돌 엔진

ㄴ Open Dynamics Engine : 오픈소스 물리, 충돌 패키지

 

1.6.4.4 캐릭터 애니메이션

1.6.4.5 인공지능

1.6.4.6 생체 역학적 캐릭터 모델

 

1.6.5 플랫폼 독립적 계층

대부분 게임 엔진은 적어도 한 개 이상의 하드웨어 플랫폼에서 동작해야 하기 때문에 (더 넓은 시장을 확보하기 위해)

함수가 여러 플랫폼에서 일관성 있게 동작할 수 있게 하는 계층이다. 

ㄴ 파일 시스템

ㄴ TCP/UDP

ㄴ 스레드 라이브러리

 

1.6.6 코어 시스템

게임 엔진을 비롯해 규모가 큰 모든 C++ 소프트웨어에서 사용할 수 있는 유틸리티

ㄴ assertion : assert 단정문을 사용하여 전제 조건을 점검한다. 최종 버전에서는 보통 제거한다.

ㄴ 메모리 관리 시스템 : 메모리 할당과 해제의 효율성, 메모리 단편화 방지를 위해 전용 메모리 시스템을 구현한다.

ㄴ 수학 라이브러리 : 게임 엔진은 수리 연산을 많이 사용하기 때문에 필요하다.

 

1.6.7 자원 관리자

게임 엔진의 모든 자원과 엔진 데이터에 접근하기 위해서는 자원 관리자를 거쳐야 한다.

자원의 종류 (게임 Assets)

ㄴ 게임 월드

ㄴ 3D model

ㄴ 텍스처

ㄴ 폰트

등..

 

1.6.8 렌더링 엔진

널리 알려진 효율적인 렌더링 엔진은 계층적인 구조로 디자인되어 있다.

 

1.6.8.1 로우레벨 렌더러

1. 그래픽 디바이스 인터페이스

ㄴ 다이렉트X나 OpenGL같은 그래픽 SDK를 사용할 때 그래픽 하드웨어를 찾아내고 초기화하고 후면버퍼, 스텐실 버퍼를 설정하는 부분을 뜻한다.

ㄴ PC용 게임 엔진을 만들 때는 윈도우 메세지가 도착하면 처리를 하고 나머지 시간에 렌더러의 루프를 돌리는 방법을 쓴다. 이 방법의 단점은 키보드 메세지를 처리하는 루프와 스크린을 업데이트하는 렌더러의 루프 사이에 의존성이 생긴다는 것이다.

 

2. 기타 렌더러 구성 요소

카메라 월드 행렬, FOV, 근평면 원평면, material, 

 

1.6.8.2 장면 그래프와 추려내기 최적화

게임 월드가 작다면 단순한 절두체 cull로 충분하지만 큰 게임 월드에서는 고급 공간 분할 방식을 사용한다.

보일 가능성이 있는 집합(PVS : potentially visible set)을 빨리 찾아내 렌더링 성능을 향상시키기 위해서다. 

공간 분할은 이진 공간 분할 트리(BSP),  옥트리(octree), kd 트리, 구형 계층 등을 사용할 수 있다.

 

로우레벨 렌더러는 사용된 공간 분할 방식이나 장면 그래프 종류에 전혀 영향을 받지 않는 것이 가장 이상적이다.

 

1.6.8.3 시각 효과

ㄴ 파티클 시스템

ㄴ 데칼 시스템

ㄴ 조명 매핑, 환경 매핑

ㄴ 동적 그림자

ㄴ 풀 스크린 후처리 효과

 

1.6.8.4 Front End

대부분의 게임은 3D 그래픽 외에 그 위에 덧씌워지는 2D 그래픽을 사용한다.

2D 화면들은 텍스처를 입힌 사각형을 직교 투영해 그리기도 하고, 3D 빌보드에 그린 후 항상 카메라를 향하게 구현하기도 한다.

ㄴ 게임의 HUD

ㄴ 게임 메뉴, 콘솔, 기타 개발 툴, GUI

 

인게임 시네마틱 : 장면을 순차적으로 재생시킬 수 있는 시스템

FMV 시스템 : 미리 녹화된 풀 스크린 비디오를 재생하는 시스템

 

1.6.9 프로파일링과 디버깅 툴

실시간 시스템인 특성 때문에 게임은 항상 최적화를 위해 프로파일링을 해야 한다. 또 메모리가 항상 부족하기 때문에 메모리 분석 툴도 함께 사용해야 한다.

대부분의 게임 엔진을 자체적으로 프로파일링과 디버깅 툴을 구현한다.

이런 툴들에 들어갈 만한 기능들

ㄴ 코드를 실행 시간을 측정하는 기능

ㄴ 게임 화면에서 실시간으로 프로파일링 수치를 보여주는 기능

ㄴ 게임 엔진과 각 하부 시스템이 사용 중인 메모리의 양을 측정하는 기능

ㄴ 디버그 메세지를 코드 어느 곳에서나 출력할 수 있고 필요한 항목마다 켜고 끄는 기능

ㄴ 게임 플레이를 녹화하고 재현하는 기능

 

1.6.10 충돌과 물리

보통 직접 물리 엔진을 만들기 보다는 Havok, PhysX, 오픈소스 엔진 중 선택하여 사용한다.

 

1.6.11 애니메이션

애니메이션의 종류

ㄴ 스트라이프 / 텍스처 애니메이션

ㄴ 강체 계층 애니메이션

ㄴ 뼈대 애니메이션 (오늘날 가장 널리 쓰이는 방식)

ㄴ 정점 애니메이션

ㄴ 모프 타겟

 

1.6.12 휴먼 인터페이스 장치

ㄴ 키보드 마우스 조이패드

ㄴ 동치, 연타, 제스처를 감지하는 시스템을 구현하는 경우가 있다.

ㄴ 플랫폼마다 다른 하드웨어 정보를 상위 게임 컨트롤에서 분리시키는 것이 고려 사항인 경우도 있다.

 

1.6.13 오디오

퀘이크나 언리얼 엔진의 오디오 툴

PC나 엑스박스 360용 다이렉트X의 XACT 오디오 툴

 

1.6.14 온라인 멀티플레이어와 네트워킹

멀티플레이어 게임의 분류

1. 단일 스크린 멀티플레이어 - 여러개의 인터페이스 장치가 하나의 게임기에 연결, 하나의 화면

2. 분할 화면 멀티플레이어 - 여러개의 인터페이스 장치가 하나의 게임기에 연결, 플레이어마다 화면이 존재

3. 네트워크 멀티플레이어 - 여러 대의 컴퓨터나 게임기가 네트워크를 통해 연결돼 있는데, 각 기계는 한 명의 플레이어가 사용

4. 대규모 다중 사용자 게임 - 여러 대의 서버가 필요함

 

1.6.15 게임플레이 기반 시스템

게임 내에서 하는 행동, 게임 세계의 규칙, 플레이어 캐릭터들의 능력 등을 구현하는 계층

게임 플레이 계층과 로우 레벨 계층들 사이 매개하는 계층

 

1.6.15.1 게임 월드와 객체 모델

게임 월드안에는 여러가지 가변적인 요소와 그렇지 않은 요소들이 포함돼 있다.

게임 월드에 속하는 여러 구성물들을 객체지향적인 방법을 모델화한다.

게임 객체의 예

ㄴ 배경, 빌딩, 길, 지형

ㄴ 동적인 단단한 물체들, 바위, 음료수 캔, 의자

ㄴ 플레이어 캐릭터

ㄴ NPC

ㄴ 무기

ㄴ 탈것

ㄴ 빛

ㄴ 카메라

 

1.6.15.2 이벤트 시스템

게임 안의 객체들이 서로 소통하기 위해 필요하다.

 

1.6.15.3 스크립트 시스템

게임 규칙과 콘텐츠를 쉽고 빠르게 변경하기 위해 컴파일을 다시 하지 않고 게임을 수정하기 위해 스크립트 언어를 사용한다.

 

1.6.15.4 인공지능 기반 시스템

ㄴ AI 캐릭터가 지형지물과 충돌하지 않고 이동할 수 있도록 이동 가능한 지역을 경로 노드에 연결하는 기능

ㄴ A* 알고리즘을 기반으로 한 길 찾기 엔진 

 

1.6.16 게임 특화 하부 시스템 (게임 플레이 계층)

게임플레이 시스템은 수가 많고 종류가 다양할 뿐만 아니라 어떤 게임을 개발하느냐에 따라 특화된 모습을 보인다.

 

1.7 툴과 자원 파이프라인

1.7.1 디지털 콘텐츠 생성 도구 (Digital Content Creation)

ㄴ 마야, 3ds 맥스 : 3D메시와 애니메이션 데이터를 생성

ㄴ SoundForge : 오디오 클립을 생성

ㄴ 포토샵 : 텍스처 생성

ㄴ 월드 제작 툴 : 자체 제작

 

1.7.2 자원 다듬기 파이프라인

DCC에서 만든 데이터(자원)를 바로 게임 엔진에서 사용할 수는 없다. 

1. DCC의 자원 메모리 모델은 게임 엔진에서 그대로 쓰기에는 필요 이상으로 복잡하기 때문

2. DCC 프로그램의 데이터 파일 형식은 읽어 들이는데 시간이 너무 많이 걸리고 저작권이 걸린 경우도 있다.

ㄴ 접근성이 뛰어난 표준 포맷이나 다른 형태로 export 해야 한다.

 

1.7.8 툴 구조에 대한 접근 방식

게임 엔진에서 툴은 독자적 소프트웨어인 경우도 있고, 엔진과 일정한 하위 계층을 공유하기도 하며, 어떤 경우는 아예 게임 안에 포함되는 경우 (실행 커맨드 옵션만 바꿔서 게임과 에디터를 왔다 갔다)도 있다.

+ Recent posts