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 툴 구조에 대한 접근 방식

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

 

20.1 의도 

객체를 효과적으로 찾기 위해 객체 위치에 따라 구성되는 자료구조에 저장한다.

 

20.2 동기

주변에 어떤 객체들이 있는지를 알고 싶을 때 게임 상에 존재하는 모든 객체 사이의 거리를 확인하는 행동(O(n²)의 복잡도)을 매 프레임마다 진행할 경우 성능 병목이 될 수 있다.

위치에 따라 유닛을 정렬하고 나면 전체 배열을 다 훑지 않고도 이진 검색 같은 걸로 주변 유닛을 쉽게 찾을 수 있다. (O(log n)의 복잡도)

 

20.3 패턴

객체들은 공간 위에서의 위치 값을 갖는다. 공간 자료구조를 통해서 같은 위치 혹은 주변에 있는 객체를 빠르게 찾을 수 있다. 객체 위치가 바뀌면 공간 자료구조도 업데이트해 계속해서 객체를 찾을 수 있도록 한다.

 

20.4 언제 쓸 것인가?

공간 분할 패턴은 살아 움직이는 게임 객체뿐만 아니라 정적인 프랍이나 지형을 저장하는 데에도 흔하게 사용된다.

복잡한 게임에서는 콘텐츠별로 공간 분할 자료구조를 따로 두기도 한다.

 

20.5 주의사항

객체가 많이 없으면 의미가 없다.

객체의 위치 변경을 처리하기가 어렵다. 객체의 바뀐 위치에 맞춰 자료구조를 재정리(정렬)해야하기 때문에 코드가 더 복잡하고 CPU도 더 소모한다.

속도를 위해 메모리를 희생하는 패턴이기 때문에 메모리가 부족한 환경에서는 오히려 손해일 수도 있다.

 

20.6 예제 코드

공간 분할 패턴은 구현 방법에 여러가지 변형이 있고, 변형들이 잘 문서화 되어 있다.

 

예제 코드는 가장 간단한 공간 분할 형식인 고정 격자 방법에 대한 것이다.

class Unit {
    // 유닛이 움직일 때 격자에 속해 있는 데이터도 제대로 위치해 있도록
    // Grid 객체와 왔다 갔다 해야 할 수 있기 때문에 Grid 클래스가 friend로 정의되어 있다.
    friend class Grid; 

public:
    Unit (Grid* grid, double x, double y) // 새로 유닛을 생성하면서 grid의 (x, y) 좌표에 넣는다.
    : grid_(grid), x_(x), y_(y),
    prev_(NULL), next_(NULL){
        grid_->add(this);
    }
    
    void move(double x, double y);
    
private:
    double x_, y_;
    Grid* grid_;
    Unit* prev_; // 이중 연결 리스트로 유닛을 관리하기 위함
    Unit* next_;
};

class Grid {
public:
    Grid() {
        for (int x = 0; x < NUM_CELLS; x++) {
            for (int y = 0; y < NUM_CELLS; y++) {
                cells_[x][y] = NULL;
            }
        }
    }
    
    void add(Unit* unit) {
        int cellX = (int)(unit->x_ / Grid::CELL_SIZE);
        int cellY = (int)(unit->y_ / Grid::CELL_SIZE);
        
        // 칸에 들어 있는 리스트의 맨 앞에 추가한다.
        unit->prev_ = NULL;
        unit->next_ = cells[cellX][cellY];
        cells_[cellX][cellY] = unit;
        
        if (unit->next_ != NULL) {
            unit->next_->prev_ = unit;
        }
    }
    
    static const int NUM_CELLS = 10;
    static const int CELL_SIZE = 20;
private:
    // 배열의 각 원소는 해당 격자에 들어있는 unit들중 가장 앞에 있는 unit을 가리킨다.
    Unit* cells_[NUM_CELLS][NUM_CELLS]; 
};

격자의 같은 칸에 위치하는 유닛들은 주변에 있는 유닛들로 간주한다. 주변에 있는 유닛들 끼리 상호작용을 해야하기 때문에 같은 칸에 유닛들이 4개 있다면 6번의 상호작용 처리(handleAttack 함수)가 필요하다.

전체 월드에 있는 모든 유닛을 확인하지 않고 같은 칸에 들어 있을 정도로 가까운 유닛들만 검사한다는 점이 최적화의 핵심이다. 

void Grid::handleMelee() { // 각 셀에서 일어나는 전투들을 모두 수행하는 함수
    for (int x = 0; x < NUM_CELLS; x++) {
        for (int y = 0; y < NUM_CELLS; y++) {
            handleCell(cells_[x][y]);
        }
    }
}

void Grid::handleCell(Unit* unit) { // 각 셀에 있는 유닛들끼리 서로 전투(handleAttack)를 진행함
    while (unit != NULL) {
        Unit* other = unit->next_;
        while (other != NULL) {
            if (unit->x_ == other->x_ && unit->y_ == other->y_) {
                handleAttack(unit, other);
            }
            other = other->next_;
        }
        unit = unit->next_;
    }
}

 

유닛이 다른 셀로 이동하는 경우

유닛의 move 함수는 포워딩의 역할만 할 뿐 대부분의 처리는 Grid의 move에서 이뤄진다. (Grid 객체 하나가 모든 유닛들을 관리하기 때문)

void Unit::move(double x, double y) {
    grid_->move(this, x, y);
}

void Grid::move(Unit* unit, double x, double y) {
    int oldCellX = (int)(unit->x_ / Grid::CELL_SIZE);
    int oldCellY = (int)(unit->y_ / Grid::CELL_SIZE);
    
    int cellX = (int)(x / Grid::CELL_SIZE);
    int cellY = (int)(y / Grid::CELL_SIZE);
    
    unit->x_ = x;
    unit->y_ = y;
    
    if (oldCellX == cellX && oldCellY == cellY) {
        return;
    }
    
    // 이전 칸에 들어 있는 리스트에서 유닛을 제거(링크 재설정)
    if (unit->prev_ != NULL) { 
        unit->prev_->next_ = unit->next_;
    }
    
    if (unit->next_ != NULL) {
        unit->next_->prev_ = unit->prev_;
    }
    
    if (cells_[oldCellX][oldCellY] == unit) {
        cells_[oldCellX][oldCellY] = unit->next_;
    }
    
    add(unit);
}

매 프레임마다 많은 유닛을 연결 리스트에서 넣었다 뺐다 할 수 있기 때문에, 추가, 삭제가 빠른 이중 연결 리스트를 사용한다.


같은 위치에 있는 유닛을 포함하여 공격 범위(주변의 다른 셀에 닿는 범위)에 있는 유닛들에도 상호작용 처리를 하고 싶다면 기준이 되는 유닛과 같은 위치에 있는 셀, 주변 4개의 셀에 들어있는 모든 unit들과 거리비교 (unit의 x_ y_를 이용)를 하여 ATTACK_DISTANCE보다 작은 경우에만 상호작용 처리(handleAttack 함수)를 진행한다.

 

격자의 주변 셀을 x, y 값 차이가 1 이하인 경우로 정의했을 때, 같은 위치의 셀을 제외한 주변 셀은 8개이지만 4개의 셀만 처리하는 이유는 발생하는 상호작용의 조건이 단순히 두 유닛의 거리에 따른 것(충돌 검사에 가까움, A와 B가 충돌한다는 것을 확인했다면 B와 A를 따로 검사할 필요가 없다)이고, 모든 유닛의 공격 범위가 같다고 가정했기 때문에 8개의 셀을 모두 검사한다면 중복해서 상호작용 처리가 일어 날 수 있기 때문이다.

 

최대 공격 범위가 한 칸의 크기보다 크면 주변 칸을 더 넓게 검색하거나 칸의 크기를 늘리는 방법이 있다.

 

20.7 디자인 결정

다양한 공간 분할 자료구조를 사용하여 객체를 담는 공간을 구현할 수 있다.

 

공간을 계층적으로 나눌 것인가, 균등하게 나눌 것인가?

격자 예제는 모든 공간을 균등하게 나눈 공간 분할이다.

계층적 공간 분할에서는 먼저 공간을 몇 개의 영역으로 나누고, 객체가 많은 영역은 다시 분할한다. 모든 영역에 들어있는 유닛 개수가 특정 개수 이하로 떨어질 떄 까지 이 과정을 재귀적으로 반복한다.

 

균등하게 나누는 경우

ㄴ 단순하다. 구현하기 편하다.

ㄴ 메모리 사용량이 일정하다.

ㄴ 객체가 위치를 이동할 때 자료구조의 업데이트 속도가 빠르다.

 

계층적으로 나누는 경우

ㄴ 빈 공간을 훨씬 효율적으로 처리할 수 있다.

ㄴ 밀집된 영역도 효과적으로 처리할 수 있다. 

객체들이 한 쪽에 몰려있는 경우 효과적이다.

 

객체 개수에 따라 분할 횟수가 달라지는가?

객체 개수와 상관없이 분할한다면

ㄴ 객체가 빠르게 이동할 수 있다. (유닛 하나가 다른 영역으로 이동해도 다른 유닛들까지 움직이진 않으니까)

ㄴ 영역이 균형 잡혀 있지 않을 수 있다.

 

객체 개수에 따라 영역이 다르게 분할된다면

ㄴ 이진 공간 분할(BSP)이나 k-d 트리 같은 공간 분할 방식

ㄴ 영역의 균형 잡힘을 보장할 수 있다. (성능, 프레임 레이트를 일정하게 유지할 수 있다)

ㄴ 전체 객체에 대해 한 번에 분할해놓는게 훨씬 효과적이다. (고정되어 있는 정적 지형이나 아트 리소스에 자주 사용된다.)

 

영역 분할은 고정되어 있지만, 계층은 객체 개수에 따라 달라진다면

ㄴ 분할 영역이 이동하지 않지만 영역에 들어있는 객체 개수가 정해진 수 이상 넘어가면 영역이 1/4 크기의 사각형 4개로 분할되는 쿼드트리

ㄴ 고정 분할과 적응형 분할의 장점을 둘 다 어느정도 가지는 공간 분할 방식

 

객체를 공간 분할 자료구조에만 저장하는가?

객체를 공간 분할 자료구조에만 저장한다면

ㄴ 관리해야 되는 컬렉션이 하나이므로 동기화 걱정을 안해도 된다.

 

다른 컬렉션에도 객체를 둔다면

ㄴ 객체마다 처리해야 할 작업이 있다면 전체 객체를 순회할 때 모든 격자를 탐색해야 한다. 객체를 별도의 컬렉션(Vector<unit*>와 같은)에 저장하면 순회 과정을 훨씬 빠르게 만들 수 있다. 

객체를 매번 할당, 해제하지 않고 고정 크기 풀에 들어 있는 객체를 재사용함으로써 메모리 사용 성능을 개선한다.

 

19.2 동기

새로운 객체를 생성하고 제거하는 과정에서 발생하는 메모리 단편화 문제를 해결하기 위함이다.

 

19.3 패턴

재사용 가능한 객체들을 모아놓은 객체 풀 클래스를 정의한다. 여기에 들어가는 객체는 현재 자신이 '사용 중'인지 여부를 알 수 있는 방법을 제공해야 한다. 풀은 초기화될 때 사용할 객체들을 미리 생성하고, 이들 객체를 '사용 안함' 상태로 초기화 한다.

새로운 객체가 필요하면 풀에 요청한다. 풀은 사용 가능한 객체를 찾아 '사용 중'으로 초기화한 뒤 반환한다.

 

19.4 언제 쓸 것인가?

객체 풀 패턴은 게임 개체나 시각적 효과같이 눈으로 볼 수 있는 것에 많이 사용된다.

다음과 같을때 사용하면 좋다.

ㄴ 객체를 빈번하게 생성/삭제해야 한다.

ㄴ 객체들의 크기가 비슷해야 한다.

ㄴ 객체를 힙에 생성하기가 느리거나 메모리 단편화가 우려된다.

ㄴ 데이터베이스 연결이나 네트워크 연결같이 접근 비용이 비싸면서 재사용 가능한 자원을 객체가 캡슐화하고 있다.

 

19.5 주의사항

객체 풀에서 사용되지 않는 객체는 메모리 낭비와 다를 바 없다.

객체 풀은 필요에 따라 크기를 조절해야 한다. 크기가 너무 작으면 새로운 객체를 추가할 수 없는 문제가 생기고 

너무 크면 메모리 낭비가 된다.

 

한 번에 사용 가능한 객체 수가 정해져 있다.

객체 풀 패턴을 사용하여 메모리를 객체 종류별로 별개의 풀로 나눠놓으면 한번에 이펙트가 많이 터진다고 해도 파티클 시스템이 메모리를 전부 먹는다거나 메모리가 부족해 새로운 객체를 생성하지 못하는 막을 수 있다는 장점이 있다.

 

객체 풀의 모든 객체가 사용 중이어서 재사용할 객체를 반환받지 못할 경우 해결책

ㄴ 최악의 상황에 맞춰서 객체 풀의 크기를 크게 유지하는 방법 - 메모리 낭비의 문제가 있음

ㄴ 그냥 객체를 생성하지 않는다.

ㄴ 기존 객체를 강제로 제거한다.

ㄴ 풀의 크기를 늘린다.

 

객체를 위한 메모리 크기는 고정되어 있다.

풀에 들어가는 객체가 전부 같은 자료형이라면 상관이 없지만 다른 자료형인 객체나 하위 클래스의 인스턴스를 같은 풀에 넣고 싶다면 풀의 배열 한 칸 크기를 가장 큰 자료형에 맞춰야 한다.

ㄴ 객체 크기별로 풀을 나누는 게 좋다.

 

재사용되는 객체는 저절로 초기화되지 않는다.

객체 풀은 메모리 관리자를 통하지 않고 객체를 재사용하기 때문에 초기화되지 않은 변수나 이미 해제된 메모리를 사용하는 골치 아픈 버그를 주의해야 한다.

새로 할당된 메모리를 초기화하거나 삭제된 메모리를 구별할 수 있도록 특수한 값을 덮어써주어 관리해주는 방법을 사용할 수 있다.

 

사용 중이지 않은 객체도 메모리에 남아 있다.

가비지 컬렉션을 지원하는 시스템에서는 GC가 메모리 단편화를 알아서 처리하기 때문에 객체 풀을 덜 쓰는 편이다. 그래도 모바일같이 CPU가 느리고 단순한 GC만 지원하는 곳에서는 객체 풀로 메모리 할당, 해제 비용을 줄이는게 의미가 있다.

 

GC와 객체 풀을 같이 사용한다면 충돌에 주의해야 한다. GC는 다른 곳을 참조하는 객체는 회수하지 않기 때문에

풀에 있는 객체를 더 이상 사용하지 않을 때 객체에서 다른 객체를 참조하는 부분을 전부 정리해야 한다.

 

19.6 예제 코드

class Particle {
public:
    Particle() : framesLeft_(0) {} // 남은 프레임수 0 (파티클 사용 안함 상태로 초기화한다는 뜻)
    void init(double x, double y, double xVel, double yVel, int lifetime);
    void animate();
    bool inUse() const { return framesLeft_ > 0; }
   
private:
    int framesLeft_;
    double x_, y_;
    double xVel_, yVel_;
};

// init이 호출되면 파티클이 사용 중 상태로 바뀐다.
void Particle::init(double x, double y, double xVel, double yVel, int lifetime) { 
    x_ = x;
    y_ = y;
    xVel_ = xVel;
    yVel_ = yVel;
    framesLeft_ = lifetime;
}

void Particle::animate() {
    if (!inUse()) return; // 남은 
    
    framesLeft_--;
    x_ += xVel_;
    y_ += yVel_;
}

class ParticlePool {
public:
    void create(double x, double y, double xVel, double yVel, int lifetime);
    void animate();
private:
    static const int POOL_SIZE = 100;
    Particle particles_[POOL_SIZE];
}

void ParticlePool::animate() {
    for (int i = 0; i < POOL_SIZE; i++) {
        particles_[i].animate();
    }
}

void ParticlePool::create(double x, double y, double xVel, double yVel, int lifetime) {
    for (int i = 0; i < POOL_SIZE; i++) {
        if (!particles_[i].inUse()) {
            particles_[i].init(x, y, xVel, yVel, lifetime);
            return;
        }
    }
}

위의 예제에서는 ParticlePool에 Particle 객체 배열을 미리 만들어 놓고 (비활성화 상태) ParticlePool의 create 함수에서

배열을 순회하면서 비활성화 상태인 객체를 임의로 하나 찾아 활성화 상태로 바꾼다.(init 함수 호출, 새로운 위치 좌표 변화 값들과 생명주기를 인수로 주고)

파티클 객체 마다 자신의 남은 프레임 수 (생명 주기)를 알고 있기 때문에 update함수( = animate함수) 에서 매 프레임마다 이 값을 감소시켜 시간이 지나면 스스로 비활성화 될 수 있도록 한다.

 

빈칸 리스트 기법을 활용하기

위의 예제에서는 비활성화된 객체를 찾기 위해 배열을 순회했는데, 사용 가능한 파티클 객체 포인트를 별도의 리스트에 저장하는 것도 방법이다. 하지만 이 방법에서는 풀에 들어 있는 객체만큼의 포인터가 들어 있는 리스트를 따로 관리해야 한다. 처음 풀을 생성하면 모든 파티클이 사용 안 함 상태이기 때문에 별도의 리스트에서도 풀에 들어 있는 모든 객체를 포인터로 가리켜야 한다. (추가 메모리가 필요하다)

class Particle{
    // 원래 있던 코드들 생략
public:
    Particle* getNext() const { return state_.next; }
    void setNext(Particle* next) {
        state_.next = next;
    }
    
private:
    int framesLeft_;
    
    union {
        struct {
            double x, y;
            double xVel, yVel;
        }live; // 파티클이 살아 있는 동안에는 live에 파티클의 상태를 둔다.
        
        Particle* next; // 파티클이 죽어 있으면 next에 사용 가능한 파티클 객체를 포인터로 가리킨다.
    } state_;
}

class ParticlePool {
    // 원래 있던 코드들 생략
private:
    Particle* firstAvailable_; // 빈칸 리스트의 head
};

Particle 구조체에서 framesLeft_를 제외한 멤버 변수들을 state_ 공용체의 live 구조체 안으로 옮겼다.

공용체를 사용했기 때문에 추가 메모리 없이 자기 자신을 사용 가능한 파티클 메모리에 등록하게 할 수 있다.

ParticlePool::ParticlePool() {
    // 처음에는 모든 파티클이 비활성화 상태이므로 헤더에는 처음 파티클을 저장한다.
    firstAvailable_ = &aprticles_[0];
    
    // 모든 파티클은 다음 파티클을 가리킨다.
    for (int i = 0; i < POOL_SIZE - 1; i++) {
        particle_[i].setNext(&particles_[i + 1]);
    }
    
    // 마지막 파티클에서 리스트를 종료한다.
    particles_[POOL_SIZE - 1].setNext(NULL);
}

void ParticlePool::create(double x, double y, double xVel, double yVel, int liftime) {
    assert(firstAvailable_ != NULL);
    
    // 얻은 파티클을 빈칸 목록에서 제거한다.
    Particle* newParticle = firstAvailable_; 
    firstAvailable_ = newParticle->getNext(); // 사용 가능한 파티클 객체의 포인터를 옮긴다.
    newParticle->init(x, y, xVel, yVel, lifetime); // 비어 있는 파티클을 init 시킨다.
}

bool Particle::animate() {
    if (!inUse()) return false;
    
    framesLeft_--;
    x_ += xVel_;
    y_ += yVel_;
    
    return framesLeft_ == 0; // 
}

void ParticlePool::animate() { 
    for (int i = 0; i < POOL_SIZE; i++) {
        if (particles_[i].animate()) {
            // 방금 죽은 파티클을 빈칸 리스트 앞에 추가한다. (새로 활성화되는 파티클이 먼저 재사용된다.)
            particles_[i].setNext(firstAvailable_);
            firstAvailable_ = &particles_[i];
        }
    }
}

빈칸 리스트 기법을 위해서 ParticlePool에서는 빈칸 리스트의 head를 관리해야 하며 파티클이 생성, 삭제될 때 head가 가리키는 포인터도 바꿔줘야 한다.

 

19.7 디자인 결정

풀이 객체와 커플링되는가?

객체가 풀과 커플링된다면 (예제의 Particle과 ParticlePool의 방식)

ㄴ 간단하게 구현할 수 있다. 풀에 들어가는 객체에 '사용 중' 플래그나 이런 역할을 하는 함수를 추가하면 된다.

ㄴ 객체가 풀을 통해서만 생성할 수 있도록 강제할 수 있다. 풀 클래스를 객체 클래스의 friend로 만든 뒤 객체 생성자를 private에 두면 된다.

 

객체가 풀과 커플링되지 않는다면

ㄴ 어떤 객체라도 풀에 넣을 수 있다. 객체와 풀을 디커플링함으로써, 재사용 가능한 풀 클래스를 구현할 수 있다.

ㄴ 가장 간단한 방법은 비트 필드를 따로 두는 것이다.

template <class TObject> // 일반적이면서 재사용 가능한 풀 클래스 템플릿
class GenericPool {
private:
    static const int POOL_SIZE = 100;
    
    TObject pool_[POOL_SIZE];
    bool inUse_[POOL_SIZE]; // 각 객체의 활성화 상태를 알기 위해 bool 배열을 pool에서 관리한다.
};

재사용되는 객체를 초기화할 때 어떤 점을 주의해야 하는가?

객체를 풀 안에서 초기화한다면 (예제의 방법)

ㄴ 풀은 객체를 완전히 캡슐화할 수 있다. 

ㄴ 풀 클래스는 객체가 초기화 하는 방법과 결합된다. (객체 초기화 메서드가 여러 개라면 객체 풀에도 포워딩 메서드가 같은 수만큼 있어야 한다.)

 

객체를 밖에서 초기화한다면

ㄴ 풀의 인터페이스는 단순해진다. (객체 초기화 메서드가 하나만 있어도 된다.)

ㄴ 이 방법의 경우 풀은 새로운 객체에 대한 레퍼런스만 반환하는데, 사용 가능한 객체가 없어서 NULL값을 반환한다면 외부 코드에서 이 객체에 대한 NULL 검사가 필요할 수 있다.

 

불필요한 작업을 피하기 위해 실제로 필요할 때까지 그 일을 미룬다.

 

장면 그래프는 계층적이다.

매 프레임마다 모든 객체의 월드변환 계산을 해야 한다면 성능에 크게 영향을 준다.

 

위 그림에서 해적, 배, 앵무새가 동시에 움직여(지역 변환이 바뀌어) 3개 객체의 월드 변환을 매번 재계산한다면

 

상위 객체의 지역 변환이 바뀌면, 해당 객체의 하위 객체들의 월드 변환 값을 다시 계산해야 한다.

위 그림에서 예를 들자면 배의 지역 변환(=월드 변환) 값과 해적의 지역 변환 값이 바뀔 때마다 객체들의 월드 변환을 계산한다면 앵무새의 월드 변환은 두 번 계산되게 된다. 

배의 지역변환이 수정될때

ㄴ 수정된 배의 지역변환 X 해적 지역변환 X 앵무새 지역 변환

해적의 지역변환이 수정될 때

ㄴ 수정된 배의 지역변환 X 해적 지역변환 X 앵무새 지역 변환

 

재계산 미루기

DirectX으로 배우는 게임 프로그래밍 23장 메시 스키닝에서 이 주제에 대해 다뤘었음

https://lemonyun.tistory.com/64?category=1020933 

 

23. 캐릭터 애니메이션

23.1 뼈대 좌표계들의 계통구조 캐릭터의 골격은 계통구조로 만들어진다. 예를들면 팔은 상박, 하박 손으로 이루어지고 이들은 부모 자식 관계이기 때문에부모가 회전하면 자식들도 회전한다.

lemonyun.tistory.com

지역 변환 값 변경과 월드 변환 값 업데이트를 분리하려 한다.

 

객체에 플래그를 두어 변경이 필요한 월드 변환 값을 렌더링 직전에 한번만 계산하도록 한다.

 

위 예제에서 해적의 지역 변환 값이 변경되었다면 해적 객체와 앵무새 객체의 더티 플래그를 설정한다.

모든 객체의 지역 변환 값 갱신이 끝나면 바뀐 객체 (더티 플래그가 설정된) 의 월드 변환을 상위 객체 부터 하향식으로 계산한다. 객체의 월드 변환 =  (부모 객체의 월드 변환) X (자신의 지역 변환) 이므로 객체 하나의 월드 변환을 계산하는데 행렬 곱셈 한번이면 된다.

 

객체의 월드 변환을 갱신하면 더티 플래그를 비활성화 하여 월드 변환이 최신 상태임을 알려야 한다.

 

18.4 언제 쓸 것인가?

파생 값이 사용되는 횟수보다 기본 값이 더 자주 변경되어야 한다.

ㄴ 이 패턴은 도중에 기본 값 (지역 변환) 이 바뀌는 바람에 계산해놓은 파생 값 (월드 변환) 이 사용 전에 무효화되는 것 막는다. 이 예제에서 만약 한 프레임에 하나의 객체의 지역 변환만 바뀔 수 있다고 하면 더티 플래그는 의미가 없어진다. (지역 변환이 한 번만 바뀌기 때문에 파생 값이 무효화 될 일이 없어서)

 

DirectX 의 프레임 자원의 동기화를 위해 NumFramesDirty = "프레임 자원 수"로 두고 Update할 때마다 NumFramesDirty-- 을 시켰었다.

 

18.5 주의사항

너무 오래 지연하려면 비용이 든다.

예제 같은 월드 좌표 계산은 한 프레임 안에서도 금방 할 수 있기 때문에 크게 문제가 되지 않는다. 하지만 전체를 처리하는데 상당한 시간이 걸리는 작업이 있다면 결과가 필요할 때 처리를 시작하면 화면 멈춤 현상이 생길 수 있다.

 

상태가 변할 때마다 플래그를 켜야 한다.

이 패턴에서는 캐시(기본 값) 무효화 = 기본 값이 바뀌었을 때 더티 플래그를 켜주는 것

기본 값을 변경하는 모든 코드가 더티 플래그를 같이 설정하도록 주의해야 한다. 어느 한 곳에서라도 놓치면 잡기 어려운 버그가 발생할 수 있다.

 

이전 파생 값을 메모리에 저장해둬야 한다.

메모리보다 시간이 남는다면 더티 플래그 패턴을 쓰지 않고 파생 값이 필요할 때마다 계산하여 사용한뒤 버리면 된다. 

더티 플래그 패턴은 속도를 위해 메모리를 희생한다.

 

18.6 예제 코드

class Transform {
public:
    static Transform origin();
    Transform combine(Transform& other);
};

class GraphNode {
public:
    GraphNode(Mesh* mesh)
    : mesh_(mesh),
      local_(Transform::origin()),
      dirty_(true) {}
    
    void render(Transform parentWorld, bool dirty) {
        dirty |= dirty_;
        if(dirty) {
            // 부모의 월드 변환과 자신의 지역 변환을 결합하여 자신의 월드 변환을 생성
            world_ = local_.combine(parentWorld); 
            dirty_ = false;
        }
        
        if (mesh_) renderMesh(mesh_, world_);
        
        for (int i = 0; i < numChildren_; i++) {
            children_[i]->render(world_, dirty);
        }
    }
    
    void setTransform(Transform local) { 
        local_ = local;
        // 노드 하나의 dirty 플래그만 바꿔도 된다.
        // 하위 노드의 render함수의 인수로 dirty_ 값을 전달하기 때문에
        dirty_ = true;
    }
private:
    Transform world_; // 이전에 계산한 월드 변환 값 저장
    Transform local_; // 상위 노드 기준 지역 변환 값 저장
    Mesh* mesh_;
    GraphNode* children_[MAX_CHILDREN];
    int numChildren_;
    bool dirty_;
}
    
    
// 사용 예시

GraphNode* graph_ = new GraphNode(NULL); // 최상위 노드(장면 그래프)

// 노드를 루트 노드에 추가
GraphNode* sub_ = new GraphNode(graph_);

graph_->setTransform(Transform 객체);
graph_->render(Transform::origin(), false);

18.7 디자인 결정

더티 플래그를 언제 끌 것인가(데이터를 갱신할 것인가)?

결과값이 필요할 때

ㄴ 계산 시간이 오래 걸린다면 거슬리는 멈춤 현상이 생길 수 있다.

 

미리 정해놓은 지점에서 할 때

ㄴ 로딩 화면이나 컷신이 나오는 동안 처리하면 지연 작업 처리가 플레이 경험에 영향을 미치지 않는다.

ㄴ 특정 위치로 플레이어가 이동했을 때 처리가 진행되도록 설계한 경우에는 작업 처리 시점을 제어할 수 없다.

 

백그라운드로 처리할 때

처음 값을 변경할 때 정해진 타이머를 추가하고 타이머가 돌아왔을 때 지금까지의 변경사항을 처리한다.

ㄴ 작업 처리 주기를 정할 수 있다. 

ㄴ 비동기 작업을 지원해야 한다. (멀티 스레딩 같은 기법으로 처리해야 한다.)

데이터 지역성 패턴은 CPU 캐시를 최대한 활용할 수 있도록 데이터를 배치해 메모리 접근 속도를 높인다.

 

현대 칩의 속도(CPU)는 계속해서 빨라져왔지만 그것은 데이터 연산의 속도가 빨라진 것이지, 데이터를 RAM에서 가져오는 속도는 그다지 빨라지지 않았다.

 

CPU 캐시 - CPU안의 작은 메모리(RAM보다 빠르게 CPU에 데이터를 전달할 수 있음)

칩이 RAM으로부터 데이터를 한 바이트라도 가져와야 할 경우 RAM은 연속된 메모리(캐시 라인)를 선택해 캐시에 복사한다.

 

캐시에서 원하는 데이터를 찾는 것을 캐시 히트(cache hit), 데이터를 찾지 못해 주 메모리(RAM)에서 가져오는 것을 캐시 미스(cache miss)라고 한다. 캐시 미스가 발생하면 CPU는 멈춘다. (RAM에서 데이터를 가져올 때까지 다른 작업을 해야 한다.)

 

자료구조를 잘 만들어서 처리하려는 값들이 메모리 내에서 서로 가까이 붙어 있도록 만드는 것이 캐시 라인에 있는 값들을 

재사용 할 수 있도록 만들기 때문에 전체적인 메모리 접근 속도를 높일 수 있다.

 

17.4 언제 쓸 것인가?

성능 문제가 있을 때 써야 한다. (프로파일링을 해야 한다)

코드 두 지점 사이에 얼마나 시간이 지났는지를 타이머 코드를 넣어서 확인하거나 캐시 사용량을 확인할 수 있는 프로파일러를 사용하여 분석해야 한다.

 

17.5 주의사항

C++에서 인터페이스를 사용하려면 포인터나 레퍼런스를 통해 객체에 접근해야 한다. 포인터를 쓰게 되면 메모리를 여기저기 찾아가야 하기 때문에 데이터 지역성 패턴을 통해서 피하고자 했던 캐시 미스가 발생한다. (가상 함수 호출시 객체의 vtable에서 실제 호출할 함수의 포인터를 찾는다.)

 

데이터 지역성 패턴을 위해서는 디커플링, 추상화의 장점을 포기해야 한다.

 

17.6 예제

컴포넌트 배열 예제

왼쪽 그림의 경우 (데이터 지역성 패턴을 사용하지 않은 경우)

1. 게임 개체가 배열에 포인터로 저장되어 있어서 개체 배열값에 접근할 때마다 포인터를 따라가면서 캐시 미스가 발생한다

2. 게임 개체는 컴포넌트를 포인터로 들고 있어서 다시 한 번 캐시 미스가 발생한다.

update() { // 게임 루프
    for (int i = 0; i < numEntities; i++) {
        entities[i]->ai()->update();
    }
}

 

 

오른쪽 그림의 경우 (데이터 지역성 패턴을 사용한 경우)

1. 게임 개체 배열을 거쳐가지 않아도 모든 개체가 가지고 있는 컴포넌트들에 접근할 수 있다. (각 컴포넌트의 배열 주소를 전역에서 접근할 수 있다면)

// 컴포넌트 자료형 별로 큰 배열에 컴포넌트 객체가 들어간다.
AIComponent* aiComponents = new AIComponent[MAX_ENTITIES];

update() { // 게임 루프
    for (int i = 0; i < numEntities; i++) {
        aiComponents[i].update();
    }
}

 

파티클 시스템 예제

class Particle {
public:
    void update() {}
};

class ParticleSystem { // Particle 객체를 위해 별도로 만든 객체 풀(19장)
public:
    ParticleSystem() : numParticles_(0) {}
    void update();
    
private:
    static const int MAX_PARTICLES = 100000;
    int numParticles_;
    Particle particles_[MAX_PARTICLES];
};

파티클 객체 중 일부가 비활성화되어 처리를 할 필요가 없다면 update를 안해줘도 된다.

for (int i = 0; i < numParticles_; i++) {
    if (particles_[i].isActive()) {
        particles_[i].update();
    }
}

루프가 플래그 값(isActive())를 캐시에 로딩하면서 나머지 파티클 데이터도 같이 캐시에 올리기 때문에 이 파티클이 비활성 상태라면 쓸모없는 값이 캐시에 올라가게 되는 것이다.

아무리 객체를 연속적인 배열에 둔다고 해도, 활성화된 객체가 연속적으로 배열에 있는 것이 아니라면 캐시로 이득을 볼 수가 없다. 활성 객체들을 배열의 맨 앞으로 모으고 비활성 객체는 그 뒤쪽으로 모으는 방법을 사용하면 해결할 수 있다.

void ParticleSystem::activateParticle(int index) {
    assert(index >= numActive_); // 접근한 객체는 비활성 상태여야 함
    
    Particle temp = particles_[numActive_];
    particles_[numActive_] = particles_[index];
    particles_[index] = temp;
    
    numActive_++;
}

Particle 객체 관리를 위한 ParticleSystem에 활성 객체 정렬 관리를 위한 activateParticle함수와 numActive_ 변수가 추가되었다. 임시 Particle 객체에 객체를 복사하고 정렬을 구현한다.

메모리에서 객체를 복사하는 비용과 포인터를 추적하는 비용을 프로파일링을 통해 비교하여 더 나은 방법을 선택하면 될 것이다.

 

빈번한 코드와 한산한 코드 나누기

매 프레임마다 필요한 빈번한(hot) 데이터와 한산한(cold) 데이터를 두 개로 분리하자

 

AI 컴포넌트를 정렬된 연속 배열을 따라가면서 업데이트 한다고 할 때, AI 컴포넌트 내부에 아이템 드랍 정보(한산한 데이터)가 들어 있어서 한 번에 캐시 라인에 들어갈 컴포넌트 개수가 줄어들어 캐시 미스가 더 자주 발생하게 된다.

 

컴포넌트의 데이터 중 자주 빈번한 데이터는 그대로 두고 한산한 부분은 옆으로 치워놓되 필요할 때를 위해 빈번한 부분에서 포인터로 가리키게 한다.

 

class AIComponent {
public:
    void update() {}

private:
    // 빈번한 데이터
    Animation* animation_;
    double energy_;
    Vector goalPos_;
    
    // 한산한 데이터
    LootType drop_;
    int minDrops_;
    int maxDrops_;
    double changeOfDrop_;
};
    
//////////////////////////////
// 두 부분으로 분리한 코드

class AIComponent {
public:
    void update() { }
    
private:
	// 빈번한 데이터는 그대로
    Animation* animation_;
    double energy_;
    Vector goalPos_;
    LootDrop* loot_; // 한산한 데이터는 빈번한 부분에서 포인터로 가리키기
};

class LootDrop {
    friend class AIComponent;
    LootType drop_;
    int minDrops_;
    int maxDrops_;
    double chanceOfDrop_;
};

17.7 디자인 결정

예제에서는 정렬된 단일 자료형 객체 배열에 객체가 들어 있다고 가정했다.

다형성은 어떻게 할 것인가?

사용하지 않는다

ㄴ 상속을 사용하는 경우 동적 디스패치(실행시점에 어떤 메소드를 실행할 지 결정)를 하려면 vtable에서 메서드를 차아본 다음에 포인터를 통해서 실제 코드를 찾아가야 하는데 이는 성능 비용이 든다. 상속을 사용하지 않으면 이를 신경쓰지 않아도 된다.

 

종류별로 다른 배열에 넣는다

ㄴ 종류별로 객체를 나눠놨기 때문에 다형성을 쓰지 않고 일반적인 비가상함수를 호출할 수 있다.

게임 개체는 어떻게 정의할 것인가?

게임 개체 클래스가 자기 컴포넌트를 포인터로 들고 있을 때

ㄴ 게임 개체는 컴포넌트가 실제로 어디에 있는지 신경 쓰지 않기 때문에, 컴포넌트들을 정렬된 배열에 둬서 순회 작업을 최적화할 수 있다.

ㄴ 개체로부터 개체 컴포넌트를 쉽게 얻을 수 있다.

ㄴ 컴포넌트를 메모리에서 옮기기가 어렵다. (활성 컴포넌트가 앞에 모여 있도록 유지하려면 개체가 가리키는 포인터도 바꿔줘야 한다.)

 

게임 개체 클래스가 컴포넌트를 ID로 들고 있을 때

ㄴ 컴포넌트 배열에서 컴포넌트의 위치가 바뀌어도 개체로부터 개체 컴포넌트를 찾을 수 있도록 컴포넌트별로 유일한 ID를 발급한 뒤에 배열에서 찾아도 되고, 컴포넌트 배열에서의 현재 위치를 ID와 매핑하는 해시 테이블로 관리해도 된다.

 

게임 개체가 단순히 ID일 때

ㄴ 개체의 동작과 상태를 개체 클래스로부터 전부 컴포넌트로 옮김

ㄴ 컴포넌트끼리 상호작용하기 위해 모든 컴포넌트는 자신을 소유하는 개체의 ID를 기억하고, 자기와 같은 개체 ID를 가진 컴포넌트에 접근해야 한다.

ㄴ 개체는 단순한 값이 된다.

서비스 중개자 패턴은 서비스를 구현한 구체 클래스는 숨긴 채로 어디에서나 서비스에 접근할 수 있게 한다.

싱글턴 패턴(6장) 과 비슷하기 때문에 둘 중 어느 쪽이 더 필요에 맞는지 판단하여 적용해야 한다.

 

소리를 출력하려 하는 경우

// 정적 클래스를 사용할 수도 있고

AudioSystem::playSound(VERY_LOUD_BANG);

 

// 싱글턴을 쓸 수도 있다.

AudioSystem::instance()->playSound(VERY_LOUD_BANG);

 

유니티의 GetComponent<Transform>() 구문도 Transform 컴포넌트(서비스)를 반환하는 서비스 중개자 패턴으로 볼 수 있다.

 

 

16.3 패턴

서비스는 여러 기능을 추상 인터페이스로 정의한다.

구체 서비스 제공자는 이런 서비스 인터페이스를 상속받아 구현한다.

서비스 중개자는 서비스 제공자의 실제 자료형과 이를 등록하는 과정은 숨긴채 적절한 서비스 제공자를 찾아 서비스에 대한 접근을 제공한다.

 

16.4 언제 쓸 것인가?

싱글턴과 유사한데 서비스를 구현한 구체 클래스 자료형이 무엇인지(AudioSystem), 어디에 있는지(클래스 인스턴스를 어떻게 얻을지)를 몰라도 되게 해준다.

16.5 주의사항

서비스가 실제로 등록되어 있어야 한다.

싱글턴이나 정적 클래스에서는 인스턴스가 항상 준비되어 있지만 서비스 중개자 패턴에서는 서비스 객체를 등록해야 하기 때문에 필요한 객체가 없을 때를 대비해야 한다.

서비스는 누가 자기를 가져다가 놓는지 모른다.

서비스 중개자는 전역에서 접근 가능하기 때문에 특정 상황에서만 실행되어야 하는 클래스가 있다면 정해진 곳에서만 실행되는 것을 보장할 수 없기 때문에 서비스로는 적합하지 않다.

 

16.6 예제 코드

// 서비스 (추상 인터페이스로 정의)
class Audio {
public:
    virtual ~Audio() {}
    virtual void playSound(int soundID) = 0;
    virtual void stopSound(int soundID) = 0;
    virtual void stopAllSounds() = 0;
};

// 서비스 제공자
class ConsoleAudio : public Audio {
public:
    virtual void playSound(int soundID) {
    	// 콘솔의 오디오 API를 이용해 사운드를 출력한다.
    }
    virtual void stopSound(int soundID) {
    	// 콘솔의 오디오 API를 이용해 사운드를 중지한다.
    }
    virtual void stopAllSounds() {
    	// 콘솔의 오디오 API를 이용해 모든 사운드를 중지한다.
    }
};

// 서비스 중개자
class Locator {
public:
    static Audio* getAudio() { return service_; }
    static void provide(Audio* service) { service_ = service; }
    
private:
    static Audio* services_;
};
// 외부 코드에서 서비스 제공자를 중개자에 등록
ConsoleAudio *audio = new ConsoleAudio();
Locator::provide(audio);

// Audio 서비스 인스턴스 사용 예시
Audio *audio = Locator::getAudio();
audio->playSound(VERY_LOUD_BANG); 
// playSound를 호출하는 쪽에서는 Audio라는 추상 인터페이스만 알 뿐 
// ConsoleAudio라는 구체 클래스에 대해서는 전혀 모른다.

NULL 서비스

서비스 제공자가 서비스를 등록하기 전에 서비스를 사용하려고 시도하면 NULL을 반환한다.

서비스가 NULL이면 같은 인터페이스(Audio)를 구현한 특수 객체 NullAudio를 대신 반환하도록 한다.

의도적으로 특정 서비스의 기능을 막고 싶을 때에도 유용하다.

 

class NullAudio : public Audio {
public:
    virtual void playSound(int soundID) { }
    virtual void stopSound(int soundID) { }
    virtual void stopAllSounds() { }
};

class Locator {
public:
    static void initialize() {
        service_ = &nullService_;
    }
    static Audio& getAudio() { return *service_; }
    static void provide(Audio* service) {
        if (service == NULL) {
            // 널 서비스로 돌려놓는다.
            service_ = &nullService_;
        } else {
            service_ = service;
        }
    }
private:
    static Audio* service_;
    static NullAudio nullService_;
};

getAudio()가 서비스를 포인터가 아닌 레퍼런스로 반환하게 바꾼 것은 NULL이 될수 없기 때문에 항상 객체를 받을 수 있다고 개대해도 된다는 의미이다.

분기문을 getAudio()가 아닌 provide()에 둠으로써 매번 서비스에 접근할 때마다 분기문에 접근하지 않아도 되지만 Locator가 기본값을 null 객체로 초기화할 수 있도록 initalize() 함수를 먼저 호출해야 한다.

 

로그 데커레이터

데커레이션으로 감싼 서비스

class LoggedAudio : public Audio {
public:
    LoggedAudio(Audio &wrapped) : wraped_(wrapped) {}
    virtual void playSound(int soundID) {
        log("사운드 출력");
        wrapped_.playSound(soundID);
    }
    virtual void stopSound(int soundID) {
        log("사운드 중지");
        wrapped_.stopSound(soundID);
    }
    virtual void stopAllSounds() {
        log("모든 사운드 중지");
        wrapped_.stopAllSounds();
    }
private:
    void log(const char* message) {
        //로그를 남기는 코드...
    }
    Audio &wrapped_;
};

//사용 예시
void enableAudioLogging() {
    // 기존 서비스를 데커레이트한다.
    Audio *service = new LoggedAudio(Locator::getAudio());
    // 이 값으로 바꿔치기 한다.
    Locator::provide(service);
}

데커레이터 패턴을 널 서비스에 적용하면 사운드는 비활성화해놓고도 정상적으로 사운드가 활성화되었다면 어떤 사운드 가 출력되었을지를 로그로 확인할 수 있다.

 

16.7 디자인 결정

서비스는 어떻게 등록되는가?

외부 코드에서 등록 (가장 일반적인 방법)

ㄴ 예제 코드에서 서비스를 등록하는 방식

ㄴ 서비스 제공자를 어떻게 만들지 제어할 수 있다. (로그를 찍는 오디오 서비스라던지, 소리 크기를 줄인 오디오 서비스 라던지 ...)

ㄴ 게임 실행 도중에 서비스를 교체할 수 있다.

ㄴ 서비스 중개자가 외부 코드에 의존한다는 단점이 있다. 외부 코드에서 초기화를 제대로 안해주면 문제가 생긴다.

 

컴파일할 때 바인딩

ㄴ 전처리기 매크로를 이용해 서비스를 컴파일할 때 등록한다.

ㄴ 빠르다. (모든 작업이 컴파일에 끝나기 때문에 런타임에 따로 할 일이 없다.)

ㄴ 서비스를 쉽게 변경할 수 없다.

class Locator {
public:
    static Audio& getAudio() { return service_; }
private:
#if DEBUG
    static DebugAudio service_;
#else
    static ReleaseAudio service_;
#endif
};

 

런타임에 설정 값 읽기

ㄴ 서비스 제공자 설정 파일을 로딩한 뒤에, 리플렉션(프로그램이 실행시간에 자기 자신을 조사하는 기능)으로 원하는 서비스 제공자 클래스 객체를 런타임에 생성한다.

ㄴ 다시 컴파일하지 않고도 서비스를 교체할 수 있다. 바꾼 설정 값을 적용하려면 게임을 재시작해야 하기 때문에 실행중에 서비스를 교체할 수 있는 방법보다는 덜 유연하다.

ㄴ 서비스 등록에 시간이 걸린다. (런타임에 설정 값을 사용하려면 파일을 읽어야 하기 때문에 )

ㄴ 복잡하다 (파일을 로딩해서 파싱한 뒤에 서비스를 등록하는 설정 시스템을 만들어야 하기 때문에)

 

서비스를 못 찾으면 어떻게 할 것인가?

사용자가 알아서 처리하게 한다

ㄴ 실패했을 때 어떻게 처리할지를 사용자 쪽에서 정할 수 있다.

게임을 멈춘다. (단언문 사용)

ㄴ getAudio() return문 앞에 assert(service != NULL) 구문을 넣으면 서비스를 찾지 못했을 때 게임이 중단된다.

ㄴ 사용자 측에서 서비스가 없는 경우를 처리하지 않아도 된다.

NULL 서비스를 반환한다. (규모가 큰 팀에서 좋다)

ㄴ 위의 예제에서 사용한 방법이다.

ㄴ 사용자 측에서 서비스가 없는 경우를 처리하지 않아도 된다.

ㄴ 서비스를 사용할 수 없을 때에도 게임을 계속 진행할 수 있다.

ㄴ 디버깅하기 쉽지 않다. (널 서비스라 기능이 제공이 안되는 것인지 서비스의 기능이 문제인지 파악이 힘들다)

 

서비스의 범위는 어떻게 잡을 것인가?

전역에서 접근 가능한 경우

ㄴ 전체 코드에서 같은 서비스를 쓰도록 한다.

 

접근이 특정 클래스에 제한되는 경우

ㄴ 커플링을 제어할 수 있다. 서비스를 특정 클래스를 상속받는 클래스들에게만 제한함으로써 다른 시스템으로부터는 디커플링 상태를 유지할 수 있다.

메세지나 이벤트를 보내는 시점과 처리하는 시점을 디커플링 한다.

 

이벤트는 큐를 통해 OS로부터 애플리케이션으로 전달된다.

 

게임에서 자체 이벤트 큐를 만들어 중추 통신 시스템으로 활용한다. 게임 시스템들이 디커플링 상태를 유지한 채로 서로 고수준 통신을 하고 싶을 때 사용한다.

 

이벤트 요청을 받는 부분과 요청을 처리하는 부분을 분리할 수 있게 된다.

 

15.3 패턴

큐는 요청이나 알림을 들어온 순서대로 저장한다. 알림을 보내는 곳에서는 요청을 큐에 넣은 뒤에 결과를 기다리지 않고 리턴한다. 요청을 처리하는 곳에서는 큐에 들어 있는 요청을 나중에 처리한다. 요청은 그곳에서 직접 처리될 수도 있고, 다른 여러 곳으로 보내질 수도 있다. 이를 통해 요청을 보내는 쪽과 받는 쪽을 코드뿐만 아니라 시간 측면에서도 디커플링한다.

 

15.4 언제 쓸 것인가?

메세지를 보내는 곳과 받는 곳을 분리하고 싶을 뿐이라면 관찰자 패턴(4장)이나 명령 패턴(2장)을 사용하면 처리할 수 있

다. 메세지를 보내는 시점과 받는 시점을 분리하고 싶을 때만 큐가 필요하다. (상대적으로 간단한 관찰자, 명령 패턴으로 해결할 수 있으면 그렇게 하는 편이 낫다)

 

A코드가 메세지를 보내면 (큐에 요청을 push) B 코드는 자기가 편할 때 요청을 가져온다 (큐에서 pop)

메세지를 보내는 쪽(A 코드)은 요청을 보내기만 할 뿐 응답을 받지 못한다.

 

15.5 주의사항

중앙 이벤트 큐는 전역 변수와 같다

ㄴ 어디에서나 접근 가능하기 때문에 주의해야 한다.

월드 상태는 언제든 바뀔 수 있다

ㄴ 큐에 요청을 보낸 시점과 요청을 처리하는 시점이 다를 수 있기 때문에 월드 상태가 다를 수 있다.

ㄴ 동기적으로 처리되는 이벤트보다 큐에 들어가는 이벤트에는 데이터가 훨씬 더 많이 필요하다.

피드팩 루프에 빠질 수 있다

ㄴ 일반적으로 이벤트를 처리하는 코드 내에서는 이벤트를 보내지 않는 방법으로 해결한다.

 

15.6 예제 코드

사운스 시스템에서의 패턴 적용 예시

 

사운드 출력 작업을 지연시키고, playSound()가 바로 리턴하게 만들어야 한다. 

이 예제에서는 일반 구조체(사운드를 출력할 때 필요한 정보를 담은) 배열을 큐로 사용한다.

큐는 원형 버퍼 방식으로 구현한다.

 

class Audio {
public:
	static void init() {
    	head_ = 0;
        tail_ = 0;
        numPending_ = 0;
    }
    
    static void update() {
    	if(head_ == tail_) return; // 보류된 요청이 없다면 아무것도 하지 않는다.
        ResourceId resource = loadSound(pending_[head_].id);
        int channel = findOpenChannel();
        if (channel == -1) return;
        startSound(resource, channel, pending_[head_].volume);
        head_ = (head_ + 1) % MAX_PENDING;
        
    }
    // 메서드
    
private:
    static int head_;
    static int tail_;
    static int MAX_PENDING = 16;
    static PlayMessage pending_[MAX_PENDING];
    static int numPending_;
};

void Audio::playSound(SoundId id, int volume) {
    for (int i = head_; i != tail_; i = (i + 1) % MAX_PENDING) {
    	if(pending_[i].id == id) {
        	// 같은 소리를 동시에 틀면 소리가 너무 커지는 현상이 있기 때문에 
            // 그 중 가장 큰 소리 하나를 사용한다.
        	pending_[i].volume = max(volume, pending_[i].volume);
            // 이 요청은 큐에 넣지 않는다.
        	return;
        }
    }
    pending_[tail_].id = id;
    pending_[tail_].volume = volume;
    tail_ = (tail_ + 1) % MAX_PENDING;
}

스레드에 코드를 분배하는 방법은 다양하지만, 오디오, 렌더링, AI같이 분야별로 할당하는 전략을 많이 쓴다.

멀티스레드 환경에서는 큐가 동시에 수정되는 것만 막으면 된다.

 

15.7 디자인 결정

큐에 무엇을 넣을 것인가?

1. 큐에 이벤트를 넣는 경우

ㄴ 이미 발생한 사건을 표현한다. 복수개의 리스너로 이벤트를 원하는 누구에든지 전파하는 용도로 사용된다.

 

2. 큐에 메세지를 넣는 경우

ㄴ 나중에 실행했으면 하는 행동을 표현한다.

ㄴ 대부분은 리스너가 하나다.

 

누가 큐를 읽는가?

1. 싱글캐스트 큐

ㄴ 큐가 어떤 클래스(Audio)의 API 일부일 때 적합하다.

ㄴ 클래스(Audio) 내부에 큐를 정의한다.

ㄴ 리스너 간에 경쟁을 고민하지 않아도 된다. (리스너가 하나 이기 때문에)

 

2. 브로드캐스트 큐

ㄴ 이벤트가 무시될 수 있다. 리스너가 없을 때 발생한 이벤트는 버려진다.

ㄴ 이벤트 필터링이 필요할 수 있다. 이벤트 개수 X 리스너 수만큼 이벤트 핸들러가 자주 호출되기 떄문에 리스너가 받고 싶은 이벤트 집합을 조절하여 이벤트 핸들러 호출 횟수를 줄일 수 있다.

 

3. 작업 큐

ㄴ 브로드캐스트 큐와 마찬가지로 리스너가 여러 개 있지만 큐에 들어있는 데이터가 리스너 중에서 한곳에만 간다.

ㄴ 스레드가 여러 개가 동시에 실행 중인 스레드 풀에 작업을 나눠줘야 할 때 일반적으로 사용하는 패턴이다.

ㄴ 어느 리스너에 데이터를 보낼지 정하는 작업 분배 알고리즘이 필요하다.

 

누가 큐에 값을 넣는가?

1. 넣는 측이 하나라면

ㄴ 하나의 특정 객체에서만 이벤트를 만들 수 있기 때문에 모든 리스너는 누가 데이터를 보냈는지를 추측할 수 있다.

 

2. 넣는 측이 여러 개라면

ㄴ 누가 보냈는지 정보를 추가해야 한다. (어떤 개체가 playSound () 를 호출하여 큐에 요청을 넣었는지)

ㄴ 이벤트 순환을 주의해야 한다.

 

큐에 들어간 객체의 생명주기는 어떻게 관리할 것인가?

1. 소유권을 전달한다

ㄴ unique_ptr<T>를 사용한다.

2. 소유권을 공유한다.

ㄴ shared_ptr<T>를 사용한다.

3. 큐가 소유권을 가진다.

ㄴ 큐가 메세지를 만들고 레퍼런스를 메세지 보내는 쪽에 돌려준다. (큐의 클래스화?)

+ Recent posts