Game Design Pattern
[TOC]
좋은 소프트웨어 구조
- 새로운 작업을 할 때 코드를 거의 건드리지 않고 적당한 함수 몇 개만 호출하면 원하는 작업을 할 수 있는 코드 및 구조
- 변화가 쉬운 코드
- 작업에 들어가기 전에 기존 코드를 파악하는(알아야할 지식) 양이 적은 구조
- 디커플링
추상화와 디커플링의 적절한 적용
- 유연함이 필요할지 확신이 안선다면 추상화와 디커플링을 적용하느라 시간 낭비 말라.
- 저수준의 핵심 최적화는 가능한 늦게 하라.
- 버릴 코드는 빠르게 기능을 확인하는 것이다.
Design Pattern
Command Pattern(명령 패턴)
- 메서드 호출을 실체화한 것
- 실체화
- 어떤 개념을 변수에 저장하거나 함수에 전달할 수 있도록 데이터(객체)로 바꿀 수 있다.
- 메서드 호출을 객체화하여 전달하는 것
- 실체화
- 콜백을 객체지향적으로 표현한 것
- 비슷한 개념
- 콜백
- 일급 함수
- 자바스크립트의 함수와 같이 함수를 할당, 전달, 반환 할 수 있는 함수
- 함수 포인터
- 상태값을 가지지 않지만 간단한 기능에는 사용할 수 있겠다.
- 클로저
- 현재 문맥(context)의 값을 저장하는 함수 객체를 간단히 만들어주므로 명령 패턴을 구현하기 이상적인 기술이지만 너무 자동화를 잘해줘서 클로저가 어떤 상태를 갖고 있는지 알아보기 어렵다.
- 위키백과 : 컴퓨터 언어에서 클로저(Closure)는 일급 객체 함수(first-class functions)의 개념을 이용하여 스코프(scope)에 묶인 변수를 바인딩 하기 위한 일종의 기술이다. 기능상으로, 클로저는 함수를 저장한 레코드(record)이며, 스코프(scope)의 인수(Factor)들은 클로저가 만들어질 때 정의(define)되며, 스코프 내의 영역이 소멸(remove)되었어도 그에 대한 접근(access)은 독립된 복사본인 클로저를 통해 이루어질 수 있다.
- 부분 적용(partial application) 함수
- 여러 함수를 거쳐 결과를 반환하는 함수일 경우 중간까지만 계산한 값을 갖고 있는 함수를 나중에 실행하는 것.
Flyweight Pattern(경량 패턴)
- 어떤 객체의 개수가 너무 많아서 좀 더 가볍게 만들고 싶을 때 사용
경량 패턴을 사용하기 위한 데이터의 분류
- 고유 상태(intrinsic state)
- 모든 객체의 데이터 값이 같아서 공유할 수 있는 데이터
- 외부 상태(extrinsic state)
- 인스턴스별로 값이 다른 데이터
경량 패턴 예
- 지형 데이터
- 지형이 한정된 타입으로 구성된 경우, 타입별 지형을 만들고 전체 지형은 생성된 지형의 포인터만 갖는다.
- 경량 패턴의 객체는 여러곳에서 공유하기 때문에 변경 불가능한(immutable) 상태로 만드는게 보통이다.
Observer Pattern(관찰자 패턴)
관찰자 패턴 구성 요소
- 대상(Subject)
- 특정 조건이 되면 관찰자 목록에 저장된 객체의 특정 메서드(onNotify())를 호출하는 객체
- 관찰자(Observer)
- 대상 객체에 등록되어 특정 조건에 메서드(onNotify())가 호출되는 객체
관찰자 패턴 사용시 주의점
- 멀티스레드, 락(lock)과 함께 사용할 때는 조심
- 관찰자 목록의 통지 메서드를 호출할 때 락이 걸린다면 게임 전체가 교착상태에 빠질 수 있다.
- 관찰자 등록에 동적할당 컬렉션을 사용한다면 메모리 단편화가 생길 수도 있다.
- vector같은 동적할당 컬렉션은 용량 부족 시 기존 용량의 두 배 가량의 메모리를 새로 할당받아 복사하고 기존 메모리는 해제하기 때문에 단편화가 생길 수 있다.
- 따라서 연결 리스트로 구현하면 객체가 늘어나도 기존 용량을 해제하는 단편화가 생기지 않는다.
- 이중 연결 리스트라면 추가/삭제가 더 간편하다.
- 관찰자 패턴은 서로 연관없는 코드 덩어리들이 서로 상호 작용하기 좋은 방법이다.
- 하나의 기능을 구현하기 위한 코드 덩어리는 명시적으로 연결하는게 더 낫다.
연결리스트 관찰자 패턴의 포인터의 포인터 예제 해석
https://gist.github.com/parkpd/4638874487a358e27957971394d42e90
void Subject::removeObserver(Observer* observer) {
//head변수의 포인터를 받지 않으면 head가 observer일 경우 head에 observer->next를 할당하는 코드가 필요하다.
Observer** current = &head_;
while (*current != NULL) {
if (*current == observer) {
*current = (*current)->next_;
observer->next_ = NULL;
return;
}
current = &(*current)->next_;
}
})
요즘의 관찰자 패턴
- 언어 자체적으로 지원하는게 많다.
- C# : event
- Java : EventListener
- 클로저가 있는 언어는 클로저로 구현하는 경우가 많다.
미래의 관찰자 패턴
- UI같이 성능에 덜 민감하고 데이터에 따른 갱신이 필요한 분야에서는 데이터 바인딩 방식이 대세가 될 것이다.
Prototype Pattern(프로토타입 패턴)
- 객체를 생성할 때 클라스가 아닌 인스턴스로부터 생성하는 방식
CPP에서 프로토타입
- cpp에서는 clone함수를 만들어 객체의 생태를 복사하는 형식으로 만들어야하는데 이는 코드 양도 많고 요즘 일반적인 방법은 아니다.
- 다음과 같이 클래스를 사용하는 방식이 프로토타입을 구현한 것은 아니지만 비슷하게 사용할 수 있는 방법이다.
- cpp에서는 클래스가 일급 자료형이 이니므로 클래스를 함수의 매개변수로 전달하지 못하기 때문에 이와 같은 방법이 필요하다.
- 자바스크립트, 파이썬, 루비 등은 클래스가 일급 자료형이다.
몬스터를 스폰하는 프로토타입 예제(함수포인터 사용)
- 몬스터를 생성하는 함수 정의
- 몬스터 생성 함수를 저장하는 스폰 클래스 정의
//Ghost는 Monster를 상속한다.
Monster* spawnGhost(){
return new Ghost();
}
typedef Monster* (*SpawnCallback)(); //Monster반환 함수 포인터
class Spawner{
public:
Spawner(SpawnCallback spawn) spawn_(spawn){}
Monster* spawnMonster(){return spawn_();}
private:
SpawnCallback spawn_;
}
//스폰 객체 생성
Spawner* ghostSpawner = new Spawner(spawnGhost);
//몬스터 스폰
ghostSpawner.spawnMonster();
몬스터를 스폰하는 프로토타입 예제(템플릿 사용)
- 스폰 클래스 정의
- 템플릿 타입 매개변수로 몬스트 클래스 전달
//Spawner 클래스를 따로 만드는 이유는 몬스터를 스폰하는 코드에 매번 템플릿 매개변수로 Monster 관련 클래스를 넣지 않기 위함
class Spawner {
public:
virtual ~Spawner(){}
virtual Monster* spawnMonster() = 0;
};
template <class T>
class SpawnerFor : public Spawner{
public:
virtual Monster* spawnMonster(){
return new T();
}
}
// 스폰 객체 생성, Ghost는 Monster의 유도객체
Spawner* ghostSpawner = new SpawnerFor<Ghost>();
프로토타입 언어 패러다임
- 객체를 원형으로 하여 복제의 과정을 통하여 객체의 동작 방식을 다시 사용할 수 있는 프로그래밍 방식
- 프로토타입 최고 구현 언어는 셀프이다.
자바스크립트에서의 프로토타입
- 언어적으로 프로토타입을 기본으로 제작되었지만 객체의 복제를 지원하지 않고 클래스 기반의 프로그래밍 방식을 사용하고 있다.
자바스크립트 객체 생성 방식
- new로 인해 빈 객체가 생성되고 생성자 함수의 this로 바인딩된다.
- 생성된 객체의
__proto__필드에 생성자 함수의 prototype을 저장한다. - 생성자 함수의 반환값으로 새로운 객체를 반환한다.
//클래스 정의(JS에서 클래스는 생성자 함수의 정의와 같다)
function A(b){
this.b = b; //b라는 필트를 추가한다.
}
//A의 prototype에 fn 함수를 추가한다. new A(1) 이후에 해도 상관없다.
A.prototype.fn = function(){
console.log(this.b);
};
var a = new A(1);
a.__proto__; // a에 필드가 없을 때 위임하는 객체, A.prototype
a.fn(); // 1, a객체에 fn필드가 없기 때문에 위임 객체는 A.prototype객체에 fn이 있는지 찾아 호출한다.
데이터 모델링을 위한 프로토타입
- 데이터에 프로토타입 언어와 같이 위임을 설정할 수 있다면 이를 활용하여 데이터의 중복을 제거할 수 있다.
- Json과 같은 데이터 구조에서 프로토타입을 대른 데이터로 지정한다면 공통되는 필드를 모두 지정하지 않아도 프로토타입의 위임 기능으로 찾아올 수 있다.
var a = {
name:"abc",
};
var b = {
hp:10
};
b.__proto__ = a;//(참고로 a는 함수가 아니므로 protytype필드가 없다)
console.log(b.name); // abc
Singleton Pattern(싱글턴 패턴)
- 오직 한 개의 클래스 인스턴스만을 갖도록 보장하고, 이에 대한 전역적인 접근점을 제공
싱글턴을 사용하는 이유
- 한 번도 사용하지 않는다면 아예 인스턴스를 생성하지 않는다.
- 런타임에 초기화된다.
- 싱글턴 대신 정적 멤버 변수를 사용할 수 있지만 정적 멤버 변수는 다음과 같은 단점이 있다.
- 정적 멤버 변수는 자동 초기화된다.
- 따라서 프로그램 실행 이후에 알 수 있는 정보를 활용할 수 없다.
- 초기화 순서를 컴파일러에서 보장해주지 않기 때문에 다른 정적 변수에 안전하게 의존할 수 없다.
- 싱글턴은 최대한 늦게 초기화 된다.
- 정적 멤버 변수는 자동 초기화된다.
- 싱글턴 대신 정적 멤버 변수를 사용할 수 있지만 정적 멤버 변수는 다음과 같은 단점이 있다.
- 싱글턴을 상속할 수 있다.
싱글턴이 문제인 이유
- 싱글턴은 사실 전역 변수와 같다.
- 전역 변수는 코드를 이해하기 어렵게 한다.
- 정적 변수의 값을 바꾸는 곳을 찾기가 쉽지 않다.
- 전역 변수는 커플링을 조장한다.
- 전역 변수는 멀티스레딩 같은 동시성 프로그래밍에 맞지 않다.
- 전역 변수는 코드를 이해하기 어렵게 한다.
- 싱글턴은 문제가 하나일 때도 두 가지 문제를 풀려 한다.
- 오직 한 개의 인스턴스만 필요한 경우
- 전역 접근이 필요한 경우
- 게으른 초기화는 제어할 수가 없다.
- 인스턴스가 사용되는 시점에 초기화 된다면 성능이 중요한 곳에서 느려질 수 있다.
- 게이른 초기화는 메모리 단편화를 불러올 수 있다.
- 이를 해결하기 위해 싱글턴 멤버 변수를 포인터가 아닌 변수로 만들어 main 함수가 불리기 전에 초기화가 되도록 강제할 수 있다.
- 이는 또 다른 단점을 불러온다.
- 다형성을 활용할 수 없다.
- 인스턴스가 필요없어도 메모리를 해제할 수 없다.
- 정적 멤버로 해결 할 수 있다면 instance() 함수 대신 정적 멤버를 사용하는게 더 명확한 코드이다.
- 이는 또 다른 단점을 불러온다.
- 이를 해결하기 위해 싱글턴 멤버 변수를 포인터가 아닌 변수로 만들어 main 함수가 불리기 전에 초기화가 되도록 강제할 수 있다.
싱글턴의 대안
- 싱글턴이 단순히 다른 클래스의 인스턴스의 관리용이라면 기능을 클래스로 옮긴다.
- 인스턴스가 하나만 필요하다면 생성자에 단언문(assert) 등으로 이미 생성된 인스턴스가 있는지 확인한다.
- 싱글턴은 인스턴스에 쉽게 접근할 수 있기 때문에 널리 쓰인다. 이를 해결하여 싱글턴 사용을 줄일 수 있다.
- 넘겨주기, 의존성 주입(dependency injection)
- 필요로 하는 의존 객체를 전역이 아닌 매개변수로 받아서 사용한다.
- 상위 클래스로부터 얻기
- 이미 전역인 객체로부터 얻기
- 서비스 중개자로부터 얻기
- 전역 접근을 제공하는 용도로만 사용하는 클래스를 따로 정의하여 전역 인스턴스를 제공한다.
- 넘겨주기, 의존성 주입(dependency injection)
- 싱글턴을 사용하지 않고도 게임을 만들 수 있다.
- 인스턴스를 하나로 제한할 경우
- 정적 클래스를 사용
- 생성자에 정적 플래그를 둬서 런타임에 인스턴스 개수 검사
- 인스턴스를 하나로 제한할 경우
- 여튼 싱글턴은 전역적으로 데이터를 변경시킬 수 있기 때문에 최소화해야한다.
State Pattern(상태 패턴)
- 상태 패턴의 목표
- 같은 상태에 대한 모든 동작과 데이터를 클래스 하나에 캡슐화하는 것
FSM(Finite State Machine - 유한 상태 기계)
- 한 번에 하나의 상태만 갖는다.
- 조건에 따라 다른 상태로 전이(transition)될 수 있다.
FSM을 머드 게임의 맵에 비유
- 각 방은 상태들
- 주인공이 있는 방은 현재 상태
- 각 방의 출구는 전이
- 이동 명령은 입력
FSM 구현
- 상태 객체를 갖는 객체
- 상태 객체에 현재 객체의 업데이트를 맡긴다.
- 상태 객체
- 상태 전이 함수를 갖는다.
- 매개변수로 받은 값으로 전이된 상태 객체를 반환한다.
- 상태가 전이됐을 때 초기화 및 정리가 필요할 수 있으므로 입장, 퇴장 함수를 갖는다.
- 상태 전이 함수를 갖는다.
class Hero{
private:
HeroState* state_;
}
void Hero::handleInput(Input input) {
//상태객체가 전이된 상태를 반환한다
HeroState* state = state_->handleInput(*this, input);
if(state != nullptr) {
//현재 상태객체 교체하고 Hero 객체를 갱신한다.
state_->exit(*this);
delete state_;
//새로운 상태 할당
state_ = state;
//새로운 상태로 입장
state_->enter(*this);
}
//상태에 따라 갱신
state_->update(*this);
}
class HeroState{
public:
HeroState* handleInput(Hero, Input) = 0;
void enter(Hero) = 0;
void exit(Hero) = 0;
void update(Hero) = 0;
}
class HeroStateStanding : public HeroState {
HeroState* handleInput(Hero &hero, Input input) {
//현재 상태에서 전이될 수 있는 상태중에 조건에 맞는 상태를 반환한다.
if(input == PRESS_DOWN){
return new HeroStateDucking();
}
}
void enter(Hero &hero) {
//현재 상태에 맞춰 Hero 초기화
}
void exit(Hero &hero) {
//현재 상태의 종료를 위한 정리
}
void update(Hero &hero) {
//hero 갱신
}
}
class HeroStateDucking : public HeroState { ... }
// ----- 상태 객체를 정적 변수로 만들어 사용 ----- //
// 상태 객체가 상태(멤버변수)를 갖지 않는 경우
// 함수는 모두 공통으로 사용하기 때문에 정적 상태 변수로 미리 만들어 놓고 사용
class HeroState{
public:
static HeroStateStanding standing;
static HeroStateDucking ducking;
...
}
class HeroStateStanding : public HeroState {
HeroState* handleInput(Hero &hero, Input input) {
//현재 상태에서 전이될 수 있는 상태중에 조건에 맞는 상태를 반환한다.
if(input == PRESS_DOWN){
return &HeroState::standing;
}
}
...
}
FSM의 단점과 해결
병행 상태 기계
- 문제
- 상태가 두가지 이상 조합될 경우 각각의 상태를 조합한 새로운 상태를 만들어야한다.
- n개의 상태와 m개의 상태가 조합되는 n*m개의 상태가 생긴다.
- 상태 코드가 중복된다.
- 상태가 두가지 이상 조합될 경우 각각의 상태를 조합한 새로운 상태를 만들어야한다.
- 해결
- 상태들이 전혀 연관이 없다면 다음과 같이 한다.
- 각각의 상태를 나눈다.
- 상태가 각각 전이되도록 한다.
- 상태들이 연관이 있다면 다른 상태 기계의 상태를 알아야하므로 코드가 지저분해진다.
- 상태들이 전혀 연관이 없다면 다음과 같이 한다.
계층형 상태 기계
- 문제
- a, b, c 상태 모두 d 상태로 전이 될 수 있는 경우 a, b, c 모든 상태에 전이되는 조건 코드가 중복된다.
- 해결
- 상태 기계를 상속관계로 만들어 같은 전이가 필요한 상태들이 상속받도록 한다.
- 서기, 걷기, 달리기, 미끄러지기 등에서 모두 점프로 전이되는 경우
- 점프로 전이되는 코드를 상위 상태 기계 클래스에 작성하고
- 서기, 걷기, 달리기, 미끄러지기 상태 기계 클래스가 상속 받도록 한다.
푸시다운 오토마타(push-down Automata)
- 문제
- 계층형 상태 기계와 같이 스택을 구현한 경우 이력이 없다는 문제가 있다.
- 이전 상태에 대한 이력이 없기 때문에 이전으로 전이할 수 없다.
- 해결
- 상태를 스택으로 관리한다.
- 새로운 상태를 스택에 넣는다.(push)
- 최상위 상태를 스택에서 뺀다.(pop)
- 상태가 종료된 경우 이전 상태로 돌아갈 수 있다.
- 상태를 스택으로 관리한다.
푸시다운 오토마타를 이해하기 위한 개념
오토마타(Automata)
- 기계 내부의 구성이나 동작에 대산 세부 사항이 무시되고 입력과 출력에 대한 사항만이 명시되는 추상적인 기계
- 계산 능령이 있는 추상 기계와 그 기계를 이용해서 풀 수 있는 문제들에 대한 학문적 접근
- 동작 방식
- 오토마타는 유한한 내부상태를 가지며 무한한 저장공간을 가질 수도 있다.
- 입력은 유한한 문자열이고 한번에 하나의 입력문자을 읽을 수 있다.
- 읽은 문자와 현재의 내부상태, 그리고 저장공간에 적힌 값에 따라서 다음의 내부상태가 바뀐다.
푸시다운 오토마타
- 내부상태 이외에 스택으로 된 저장공간을 갖는 오토마타
- 입력 문자열이 주어지면 이것을 하나씩 읽으면서 내부 상태를 바꿔나간다. 동시에 스택 맨 위의 적힌 내용을 읽고 바꾸거나 지우거나 새로 쓸 수 있다.
FSM이 유용한 경우
- 내부 상태에 따라 객체 동작이 바뀔 때
- 이런 상태가 그다지 많지 않은 선택지로 분명하게 구분될 수 있을 때
- 객체가 입력이나 이벤트에 따라 반응할 때
Strategy Pattern(전략 패턴)
- 객체가 할 수 있는 행위를 개별 클래스(전략)로 만들어 인스턴스화 하여 위임하고, 동적으로 행위의 수정이 필요한 경우 다른 행동을하는 인스턴스(전략)로 교체한다.
- 컴포넌트 패턴과 유사하다.
- 전략 패턴은 내부 상태가 없는 경우가 대부분이고
- 컴포넌트 패턴은 상태를 갖는 경우가 많다.
- 하지만 엄밀하게 나뉘지는 않는다.
Sequencing Patterns(순서 패턴)
Double Buffer Pattern(이중 버퍼 패턴)
의도
- 여러 순차 작업의 결과를 한 번에 보여준다.
- 컴퓨터 그래픽스의 더블 버퍼링을 생각하면 된다.
구조
- 버퍼 클래스이 버퍼는 점차적으로 수정되지만 밖에서는 한 번에 바뀌는 것처럼 보인다.
- 이를 위해 버퍼 클래스는 현재 버퍼와 다음 버퍼, 두 개의 버퍼를 갖는다.
- 정보를 읽을 때는 현재 버퍼에 접근하고 정보를 쓸 때는 다음 버퍼에 접근한다.
- 다음 버퍼의 변경이 완료되면 현재 버퍼와 다음 버퍼를 교체한다.
사용하는 곳
- 순차적으로 변경해야하는 곳
- 이 생태는 변경 도중에도 접근 가능해야 한다.
- 바깥 코드에서는 작업중인 상태에 접근할 수 없어야 한다.
- 상태에 값을 쓰는 도중에도 기다리지 않고 바로 접근할 수 있어야 한다.
- 바깥 코드에서는 작업중인 상태에 접근할 수 없어야 한다.
- 이 생태는 변경 도중에도 접근 가능해야 한다.
- 어떤 상태를 변경하는 코드가, 동시에 지금 변경하려는 상태를 읽는 경우
- 물리나 인공지능 같이 객체가 서로 상호작용할 때 이런 경우가 많다.
- 모든 객체의 상태 변수는 현재, 다음 두 개로 나뉜다.
- 모든 객체의 상태 업데이트 시 다음 상태 변수에 기록한다.
- 기록된 다음 생태값을 현재 상태값에 기록한다.
- 변경된 사항은 다음 프레임에 적용된다.
- 물리나 인공지능 같이 객체가 서로 상호작용할 때 이런 경우가 많다.
//의사 코드
void updateAll(){
for objs.update();//상태를 업데이트 한다. 다음 버퍼에 기록된다.
for objs.swap();//상태를 변경한다. 현재 버퍼에 다음 버퍼의 값을 기록한다.(포인터를 교체하거나 값을 복사한다)
}
주의사항
- 교체 연산 자체에 시간이 걸린다
- 교체 연산은 원자적이어야 하고 교체 중 두 버퍼 모두 접근할 수 없어야한다.
- 버퍼에 값을 쓰는 시간보다 교체 시간이 더 오래 걸린다면 버퍼 패턴은 아무런 도움이 안된다.
- 버퍼가 두 개 필요하다
- 필요한 메모리가 두 배이다.
- 메모리가 부족하다면 이중 버퍼 패턴을 포기하고 상태 변경 시 밖에서 접근하지 못하게 할 방법을 찾아야 한다.
디자인 결정 시 고려사항
버퍼를 어떻게 교체할 것인가?
- 버퍼 포인터나 레퍼런스를 교체
- 버퍼 메모리 두 개를 번갈아 가며 현재 버퍼 메모리로 지정한다.
- 특징
- 빠르다
- 버퍼 코드 밖에서는 버퍼 메모리를 포인터로 저장할 수 없다는 한계가 있다
- 버퍼에 남아 있는 데이터는 바로 이전 프레임 데이터가 아닌 2프레임 전 데이터이다.
- 일반적으로 그리기 전에 버퍼를 정리하는 경우는 별 문제가 안된다.
- 버퍼에 남은 데이터를 재사용할 경우 문제가 된다.
- 모션 블러 효과를 주기 위해서 현재 프레임 이미지에 이전 프레임 값을 살작 섞어서 이미지를 뭉개기 때문에 문제가 될 수 있다.
- 버퍼끼리 데이터를 복사
- 다음 버퍼에 데이터를 기록하고 현재 버퍼에 데이터를 복사한다.
- 특징
- 다음 버퍼에는 딱 한 프레임 전 데이터가 들어 있다.
- 이전 버퍼에서 좀 더 최신 데이터를 얻을 수 있다는 점에서는 두 버퍼를 교체하는 방식보다 좋다.
- 교체 시간이 더 걸린다.
- 교체를 하려면 전체 버퍼를 다 복사해야 한다.
- 버퍼가 크다면 시간이 오래걸리고 이 시간동안 버퍼 모두 읽기 쓰기가 불가능하기 대문에 제약이 크다.
- 다음 버퍼에는 딱 한 프레임 전 데이터가 들어 있다.
얼마나 정밀하게 버퍼링할 것인가?
- 버퍼가 한 덩어리라면
- 간단히 교체 가능
- 포인터로 버퍼를 가리키고 있다면 버퍼의 크기와 상관없이 간단히 교체 가능
- 여러 객체가 각종 데이터를 들고 있다면
- 교체가 더 느리다.
- 전체 객체 컬렉션을 순회하면서 교체하라고 알려줘야 한다.
- 만약 객체 개별적으로 현재 버퍼와 다음 버퍼의 교체를 관리할 필요가 없다면 정적 변수(함수)로 현재와 다음 버퍼를 지정하는 값을 변경해 줄 수 있다.
- 모든 객체가 현재 버퍼와 다음 버퍼의 교체를 동일하게 한다면 정적 함수로 관리할 수 있다.
- 이런게 얼마나 있을까?
- 모든 객체가 현재 버퍼와 다음 버퍼의 교체를 동일하게 한다면 정적 함수로 관리할 수 있다.
- 교체가 더 느리다.
Game Loop Pattern(게임 루프 패턴)
의도
- 게임 시간 진행을 유저 입력, 프로세서 속도와 디커플링한다.
동기
- 일반 애플리케이션과 같이 사용자의 반응에 응답하는 프로그램은 사용자의 입력이 있을 때까지 대기한다.
- 게임도 마찮가지지만 다른 점은 사용자가 입력하지 않아도 마냥 대기하지 않고 루프를 실행한다.
//게임 루프
while(true){
processInput();//이전 호출 이후 들어온 사용자 입력 처리
update();//게임 시뮬레이션을 한 단계 실행(AI, 물리 순으로 실행)
render();//게임 화면 그리기
}
게임에서의 시간
- 초당 프레임을 제어하는 코드가 없다면 한 프레임당 시간은 다음과 같은 요소로 결정된다.
- 한 프레임에 얼마나 많은 작업을 하는가
- 코드가 실행되는 플랫폼의 속도
- 현재는 게임이 실행되는 하드웨어를 한정할 수 없는 경우가 더 많기 때문에 어떤 하드웨어에서라도 일정한 속도로 실행될 수 있도록 하는 것이 게임 루프의 핵심 업무다.
언제 쓸 것인가?
- 게임이라면 게임 루프는 무조건 사용된다.
주의사항
플랫폼의 이벤트 루프에 맞춰야 할 수도 있다
- 윈도우에서는 루프안에서 GetMessage()대신 PeekMessage()를 호출해 OS의 이벤트 루프에서 벗어날 수 있다.
- 웹 브라우저에서는 이벤트 루프를 제어하기 힘드므로 웹 브라우저에 맞워야 할 수도 있다.
Update Method Pattern(업데이트 메서드 패턴)
Behavioral Patterns(행동 패턴)
Bytecode Pattern(바이트코드 패턴)
- 데이터와 더불어 행동까지 지정할 수 있는 외부 데이터
- 프로그램 실행 중에 해석되는 인터프리터 언어와 비슷한 개념
Subclass Sandbox Pattern(하위 클래스 샌드박스 패턴)
- 행동의 기본 단위를 상위 클래스에 작성하고 하위 클래스에서는 이 행동들을 조합하여 고유 행동을 만든다.
- 하위 클래스에서 상위 클래스 메서드를 조합하여 고유 메서드를 만들 때 이 메서드를 샌드박스 메서드라 한다.
의도
- 상위 클래스가 제공하는 기능들을 통해서 하위 클래스에서 행동을 정의한다.
동기
Type Object Pattern(타입 객체 패턴)
- 여러 객체끼리 데이터와 동작을 공유하려는 것
- 공통되는 데이터와 동작을 다른 클래스로 만들어 위임하고 포함시킨다.
- 보통 불변 데이터를 주로 위임한다
- 상태 패턴에서는 객체의 현재 상태가 어떤지를 나타내는 임시 데이터를 주로 위임한다.
Decoupling Pattern(디커플링 패턴)
Component Pattern(컴포넌트 패턴)
- 개별 행위를 책임질 클래스(컴포넌트)를 만들고 이들을 조합하여 객체의 행동을 표현한다.
- 행위의 주체가 되는 클래스는 컴포넌트들을 관리하고 컴포넌트들을 사용하여 행동한다.
의도
- 한 개체가 여러 분야를 서로 커플링 없이 다룰 수 있게 한다.
Event Queue Pattern(이벤트 큐 패턴)
Intent
- 메시지나 이벤트를 보내는 시점과 처리하는 시점을 디커플링한다.
Motivation
- Observer Pattern, Command Pattern과 같은 동기적 이벤트 호출(즉시성)의 문제점
- 처리가 완료될 때까지 블록된다.
- 사운드 재생과 같이 디스크에서 로딩하는 경우
- 요청을 모아서 처리할 수 없다.
- 요청이 원치 않는 스레드에서 처리된다.
- 요청을 받아 처리하는 객체가 요청을 처리하기 적당한 시기가 아닐 수 있다.
- 처리가 완료될 때까지 블록된다.
Concept Of Event Queue Pattern
- 큐에는 요청이나 알림을 들어온 순서대로 저장
- 요청을 큐에 넣은 뒤에 결과를 기다리지 않고 리턴(비동기)
- 요청을 처리하는 곳에서 큐에 들어 있는 요청을 나중에 처리
- 직접 처리하거나 다른 곳으로 보내질 수 있다.
- 코드뿐만 아니라 시간 측면에서도 디커플링 된다.
When to Use It
- 메시지를 보내는 시점과 받는 시점을 분리하고 싶을 때 Event Queue Pattern을 사용한다.
- 메시지를 보내는 곳과 받는 곳만 분리하고 싶으면 Observer Pattern, Command Pattern을 사용하면 더 간단하게 구현할 수 있다.
- 메시지를 보내는 쪽에서 응답을 받아야한다면 큐를 쓰는게 적합하지 않다.
Keep in Mind
- Event Queue는 복잡하고 게임 구조에 전반적인 영향을 미치기 때문에 어떻게 사용할지, 정말 쓸 것인지를 잘 생각해야한다.
- 중앙 이벤트 큐는 전역변수와 같다.
- 따라서 전역적 접근이 가능하기 때문에 문제가 생기기 쉽다.
- 월드 상태는 언제든 바뀔 수 있다.
- 이벤트를 받았을 때는 현재 월드 상태가 이벤트가 만들어진 당시 상태와는 다를 수 있기 때문에 이런 비동기적 이벤트는 동기적 이벤트보다 데이터가 많이 필요하다.
- 피드백 루프에 빠질 수 있다.
- 두 객체가 서로 이벤트를 주고받는 순환이 생기지 않도록 주의한다.
- 동기적 이벤트는 스택 오버플로 크래시가 나기 때문에 금방 찾을 수 있지만 이벤트 큐 시스템은 문제를 감지하고 찾기가 쉽지 않다.
- 두 객체가 서로 이벤트를 주고받는 순환이 생기지 않도록 주의한다.
예제
- 사운드 출력 객체에 사운드 출력을 요청(이벤트 발생)
- 사웅드 출력 객체는 일단 큐에 넣고 다음 처리 주기가 올 때까지 대기했다가 처리한다.
- 기본 배열을 큐로 사용하고 원형 버퍼로 구현한다.
A ring buffer 원형 버퍼
- head는 큐에서 요청을 읽을 위치
- tail은 큐에 새로운 요청이 들어갈 자리
- 마지막 요청의 다음 칸이다.
- 버퍼가 가득차면 Doubling으로 배열 크기를 늘린다.
- 배열이 늘어날 때 데이터를 복사해야하는 단점이 있지만 Amortized Analysis에 의하면 큐에 데이터를 넣는 작업은 평균적으로 상수 시간(O(1))에 가능하다.
Aggregating requests 요청 취합하기
- 대기 중인 요청을 확인할 수 있기 때문에 같은 요청이 있다면 병합한다.
- 큐에 넣기 전에 병합한다.
- 병합을 위해 같은 요청이 있는지 확인하는 비용을 줄이려면 다른 자료형을 사용한다.
- id를 키로 하는 해시 테이블에 저장한다면 상수 시간에 중복을 검출할 수 있다.
- 근데 이렇게 하면 해시 테이블은 큐와 같이 엘리먼트 순서를 정할 수 없기 때문에 큐와 해시 테이블 두 개를 유지해야할듯.
- id를 키로 하는 해시 테이블에 저장한다면 상수 시간에 중복을 검출할 수 있다.
- 병합을 위해 같은 요청이 있는지 확인하는 비용을 줄이려면 다른 자료형을 사용한다.
Source Code
class Audio{
public:
void playSound(soundData){
//같은 요청이 있는지 확인해서 무시할 수 있다
for(i=head_; i!=tail; i=(i+1)%MAX_PENDING){
if(pending_[i] == soundData) return;
}
pending_[tail_] = soundData;//요청을 tail_위치에 추가
tail_ = (tail_+1) % MAX_PENDING;//끝이면 처음으로 보낸다
}
void update(){
if(head_ == tail_) return;
startSound(pending_[head_]);//head_ 위치의 요청 처리
head_ = (head_+1) % MAX_PENDING;//끝이면 처음으로 보낸다
}
static const int MAX_PENDING = 16;
static SoundData pending_[MAX_PENDING];//큐, 보류된 요청
static int head_;
static int tail_;
}
멀티스레드에서는?
- Event Queue 패턴에는 이미 다음과 같은 구조가 있다.
- 요청 코드와 처리 코드가 분리되어 있다.
- 양쪽 코드 사이에 Marshalling을 제공하기 위한 큐가 있다.
- 큐는 나머지 코드로부터 캡슐화되어 있다.
- 따라서 요청 코드와 처리 코드를 스레드 안전하게 만들면 된다.
Design Decisions
What goes in the queue? 큐에 무엇을 넣을 것인가?
If you queue events 큐에 이벤트를 넣는 경우
-
이벤트는 이미 발생한 사건을 표현한다.
-
복수 개의 리스너를 지원해야 할 때도 많다.
- 이미 발생한 일이므로 받아서 처리할 곳이 많을 수 있다.
-
큐의 범위가 더 넓은 편이다.
- 이벤트를 원하는 누구에게든지 전파하는 용도이기 때문에 리스너 등록기 쉽도록 전역적으로 노출한다.
If you queue messages 큐에 메시지를 넣는 경우
- 메시지는 나중에 실행했으면 하는 행동을 표현한다.
- 사운드 틀기 등
- 서비스에 비동기적으로 API를 호출하는 것과 같다.
- 대부분은 리스너가 하나이다.
- 비동기적 API 호출과 같기 때문에 행동할 객체가 이미 정해져있다.
Who can read from the queue? 누가 큐를 읽는가?
A single-cast queue 싱글캐스트 큐
- 큐가 어떤 클래스의 API 일부일 때 적합
- 호출하는 쪽에서는 공개된 API만 보인다.
- 큐는 밖에서는 보이지 않는 내부 구현이 된다.
- 큐가 더 캡슐화되어 있다.
- 리스너가 하나이기 때문에 리스너간 경쟁을 고민할 필요가 없다.
A broadcast queue 브로드캐스트 큐
- 대부분의 이벤트 시스템이 브로드캐스트 큐이다.
- 여러 개의 리스너가 있으며 리스너 모두에게 이벤트를 알려준다.
- 이벤트가 무시될 수 있다.
- 리스너가 없다면 이벤트는 무시된다.
- 이벤트 필터링이 필요할 수 있다.
- 리스너가 특정 이벤트만 받도록 이벤트 집합을 조절한다.
A work queue 작업 큐
- 브로드캐스트와 비슷하게 리스너가 여러 개 있다.
- 차이점은 이벤트가 리스너 한곳으로만 전달된다.
- 스레드 풀에 작업을 나눠줘야하는 경우 일반적으로 사용된다.
- 작업을 분배해야한다.
- Round-robin, Random, 그 외 복잡한 우선순위 시스템 등
Who can write to the queue? 누가 큐에 값을 넣는가?
With one writer 넣는 측이 하나라면
- 동기형 Observer Pattern에 가깝다.
- 특정 객체만 이벤트를 만들 수 있고, 나머지는 이벤트를 받을 수만 있다.
- 큐에 값을 추가할 수 있는 객체가 하나이므로 누가 이벤트를 보내는지 안다.
- 보통 리스너가 여러 개다.
With multiple writers 넣는 측이 여러 개라면
- 예제의 오디오 엔진이 이 구조이다.
- 코드 어디에서나 큐에 요청을 넣을 수 있다.
- 이벤트 순환을 주의해야 한다.
- 이벤트 처리도중 큐에 이벤트가 들어올 수 있다.
- 피드백 루프가 생길 수 있다.
- 이벤트 처리도중 큐에 이벤트가 들어올 수 있다.
- 이벤트를 보낸 객체의 레퍼런스를 이벤트에 추가 해야 할 수도 있다.
- 이벤트는 누구나 보내기 때문에 리스너는 누가 보냈는지 알 수 없다.
What is the lifetime of the objects in the queue? 큐에 들어간 객체의 생명주기는 어떻게 관라할 것인가?
Pass ownership 소유권을 전달한다
- 메시지가 큐에 들어가면 메시지의 소유권을 큐가 가져간다
- C++의 unique_ptr
와 비슷하다
- C++의 unique_ptr
Share ownership 소유권을 공유한다
- 메시지를 보낸 객체와 큐에서 메시지의 참조를 가질 수 있다.
- shared_ptr
와 비슷 - GC의 대상이된다.
- shared_ptr
The queue owns it 큐가 소유권을 가진다.
- 보내는 쪽에서 큐의 레퍼런스를 요청하여 작성하고 받는 쪽에서 참조하여 행동한다.
- 메시지의 삭제는 큐가 한다.
See Also 참고자료
- Event Queue는 Observer Pattern의 비동기형이다.
- 이벤트 큐를 메시지 큐라고도 부르지만 메시지 큐는 좀 더 고수준의 구조를 부를 때 사용된다.
- 여러 어플끼리 통신 또는 대규모 분산처리 시스템을 가리키는 용도
- publish/subscribe 또는 pubsub이라 부르기도 한다.
Service Locator Pattern 서비스 중개자
Intent
- 서비스를 구현한 구체 클래스는 숨긴 채로 어디에서나 서비스에 접근할 수 있게 한다.
Motivation
- 게임 전체에 사용되는 일종의 서비스 개념의 시스템이 있다.
- 메모리 할당, 로그, 난수 생성, 오디오, …
- 이런 시스템은 정적 클래스 또는 싱글톤 등으로 제공될 수 있다.
- 하지만 이렇게 직접 접근하는 것은 커플링을 강하게 만들고 추후 변경을 어렵게 한다.
- 해결
- 서비스를 찾을 방법을 한 곳에서 관리한다.
- 구체 클래스의 자료형을 숨기고 클래스 인스턴스를 어떻게 얻을지를 몰라도 되게한다.
- 서비스를 찾을 방법을 한 곳에서 관리한다.
The Pattern
- Service는 인터페이스로 정의된다.
- Service Provider(서비스 제공자)는 서비스 인터페이스를 상속받아 구현한다.
- Service Locator(서비스 중개자)는 서비스에 대한 접근을 인터페이스로 제공한다.
- 구체 클래스의 자료형과 등록 과정은 숨긴다.
When to Use It
- 전역 접근은 문제를 일으키기 쉽다. 절제해서 사용해야한다.
- 전역 대신 객체를 인수로 넘겨줄 수 있는지 생각해보자.
- 커플링을 명확히 보여준다.
- 인수로 넘기는 방식이 불필요하거나 코드를 읽기 어렵게 하기도 한다.
- 렌더링 함수 인수에 로그나 메모리 관리 같은 시스템이 포함되어 있으면 안 된다.
- 본질적으로 하나인 시스템을 인수로 전달하면 복잡성만 늘리는 셈이다.
- 사운드 시스템 등
- 이럴 때 Service Locator Pattern을 쓰면 된다.
Keep in Mind 주의사항
- Service Locator 패턴의 단점
- 두 코드(객체)의 의존성을 런타임 시점까지 미루기 때문에 코드만 봐서는 어떤 의존성을 사용하는지 알기 어렵다.
- 코드 유지보수 측면에서 이는 곧 비용이다.
- 두 코드(객체)의 의존성을 런타임 시점까지 미루기 때문에 코드만 봐서는 어떤 의존성을 사용하는지 알기 어렵다.
The service actually has to be located. 서비스가 실제로 등록되어 있어야 한다.
- Service Locator 패턴은 서비스 객체를 등록해야하기 때문에 필요한 서비스 객체가 없을 경우를 대비해야 한다.
The service doesn`t know who is locating it. 서비스는 누가 자기를 가져다가 놓는지 모른다.
- 어떤 서비스(클래스)가 특정 상황에서만 실행되거나 실행되면 안될 경우에는 서비스 중개자 패턴을 적용하지 않는게 안전하다.
예제
The service
- 오디오 서비스를 인터페이스로 구성한다.
The service provider 서비스 제공자
- 서비스를 구현한 구체 클래스
A Locator 중개자
- 서비스 인터페이스를 통해 서비스 제공 객체를 제공하는 클래스
- 다른 객체가 Locator를 통해 서비스를 요청하기 전에 서비스 제공자 객체를 주입받아야 한다.
- 디커플링
- 서비스 사용쪽에서는 어떤 구체클래스인지 알 수 없다.
- 서비스 제공 클래스도 어디서 사용되는지 모른다.
- 서비스 중개자 패턴으로 만들지 않은 다른 클래스에도 적용할 수 있다.
A null service 널 서비스
- 서비스 제공자가 등록되기 전에 서비스를 호출하면 null이 반환된다.
- 이렇게 호출에 순서가 있는 경우 temporal coupling(시간적 결합)이라 한다.
- Null Object Pattern을 사용하여 해결
- null을 반환해야할 때 서비스 인터페이스를 구현한 특수 객체를 반환한다.
- 이 객체는 인터페이스는 있지만 아무것도 하지 않는다.
- null을 반환해야할 때 서비스 인터페이스를 구현한 특수 객체를 반환한다.
- 서비스 중개자가 서비스 제공자를 제공 받을 때 null 체크를 하므로 서비스 사용하는 곳에서 null 체크를 하지 않아도 된다.
- 또한 cpu낭비도 막는다.
- 이로인해 서비스 중개자가 서비스 제공시 포인터 대신 레퍼런스를 제공할 수 있게됐다.
- 언어적으로 레퍼런스는 null이 될 수 없기 때문에 사용하는 곳에서 null아 아님을 안심하고 사용할 수 있다.
- 서비스 중개자가 싱글턴이 아니라면 서비스가 사용되기 전에 널 객체로 초기화하는 코드를 호출해줘야한다.
- 의도적으로 특정 서비스를 못 찾게 할 때도 유용하다.
- 스테이지 전환 시 잠시 음악을 끄는 등…
class NullAudio{
public:
virtual void play(){/*아무것도 하지 않는다*/}
}
class Locator{
public:
static void initialize(){//getAudio()보다 먼저 호출되어야함
service_ = &nullService_;//null객체를 등록
}
static Audio& getAudio(){return *service_;}//레퍼런스를 반환. null이 아님을 명시적으로 알림
static void provide(Audio* service){
if(service == null){//주입받은 서비스가 null인지 확인
service_ = nullService_;
} else {
service_ = service;
}
}
private:
Audio* service_;
NullAudio nullService_;
}
Logging decorator 로그 데커레이터
- 서비스 별로 로그를 켜고 끌수 있게 Decorator Pattern(장식자 패턴)을 활용한다.
- 오디오 인터페이스를 구현하고 오디오 서비스를 래핑하여 API 호출에 따른 로그를 남길 수 있다.
class LoggedAudio : public Audio{
public:
LoggedAutio(Audio &wrapped):wrapped_(wrapped){}
virtual void play(){
log("play()");
wrapped_.play();
}
private:
Audio &wrapped_;
}
//오디오 서비스 로그 활성/비활성
Audio* service = new LoggedAudio(Locator::getAudio());//기존 Audio서비스 데코레이트
Locator::provide(service);//로그 서비스로 바꿔치기 한다.
Design Decisions 디자인 결정
How is the service located? 서비스는 어떻게 등록되는가?
outside code register it 외부 코드에서 등록
- 빠르고 간단하다
- 포인터 반환만 하는 함수는 컴파일러가 인라인시킬 수 있기 때문에 성능 손해도 없고 추상 계층을 둘 수도 있다.
- 서비스 제공자를 어떻게 만들지 제어할 수 있다.
- 서비스 제공자의 구체 클래스를 모르는 서비스 중개자라도 외부에서 인스턴스를 전달하기 때문에 서비스 제공이 가능하다.
- 게임 실행 도중에 서비스를 교체할 수 있다.
- 서비스를 교체하고 중단할 수 있다.
- 단점
- 서비스 중개자가 외부 코드에 의존한다
- 서비스를 사용하는 곳에서는 서비스 중개자가 이미 어딘가에서 서비스 등록 됐다고 기대한다.
- 서비스 중개자가 외부 코드에 의존한다
Bind to it at compile time 컴파일할 때 바인딩
- 빠르다
- 컴파일할 때 결정되기 때문에 런타임에 할 일이 없다.
- 컴파일 되었기 때문에 서비스는 항상 사용 가능하다
- 단점
- 컴파일에 서비스가 결정되기 때문에 서비스를 바꾸려면 재컴파일 해야한.
Configure it at runtime 런타임에 설정 값 읽기
- 보통은 서비스 제공자 설정파일을 로딩한 뒤에 Reflection으로 서비스 객체를 런타임에 생성한다.
- 장점
- 다시 컴파일 하지 않고도 서비스를 교체할 수 있다.
- 하나의 코드로 여러 설정을 동시에 지원할 수 있다.
- 단점
- 복잡하다.
- 설정 파일을 로딩하고 파싱한 뒤에 서비스를 등록하는 시스템을 만들어야한다.
- 런타임에 서비스 사용시 서비스를 찾기 위해 약간의 시간 비용이 발생한다.
- 캐시 등으로 최소화 할 수 있지만 캐시로 저장하기 전에는 비용이 발생한다.
- 복잡하다.
What happens if the service can`t be located? 서비스를 못 찾으면 어떻게 할 것인가?
Let the user handle it 사용자가 알아서 처리하게 한다.
- 서비스 찾기 실패 시(null 반환시) 사용하는 코드쪽에서 대응하도록 한다.
- 단점
- 호출하는 쪽에서 항상 서비스 검증 코드를 넣어야한다.
- 검증 코드가 중복된다.
Halt the game 게임을 멈춘다.
- Service Locator가 서비스를 제공하지 못하는 경우 assert()등으로 게임을 중단한다.
- 장점
- 사용측에서 검증할 필요없다.
- Service Locator는 항상 서비스를 제공한다고 가정한다.
- 사용측에서 검증할 필요없다.
- 단점
- 문제 해결 전까진 개발 또는 테스트도 중단될 수 있다.
- 테스트 과정에서 모두 문제발생을 모두 찾아 고쳐야한다.
- 최종 제품에서 발생할 경우 치명적이다.
Return a null service 널 서비스를 반환한다.
- 호출하는 쪽에서 서비스가 없는 경우를 가정하지 않아도 된다.
- 서비스를 사용할 수 없을 때도 게임을 진행할 수 있다.
- 단점
- 장점이지만 디버깅이 쉽지 않다.
- 해결
- 널 서비스 사용 시 디버깅용 로그를 출력
- 해결
- 장점이지만 디버깅이 쉽지 않다.
What is the scope of the service? 서비스의 범위는 어떻게 잡을 것인가?
- 서비스가 특정 분야에 한정된다면 하나의 클래스로 접근 범위를 좁히고 다양한 곳에서 사용된다면 전역적으로 만든다.
- 서비스 및 제공 함수를 protected 선언하여 상속받은 클래스에서만 사용할 수 있게 제한할 수 있다.
If access is global 전역에서 접근 가능한 경우
- 전역 코드에서 같은 서비스를 쓴다.
- 서비스 객체는 하나만 존재
- 단점
- 언제 어디에서 서비스가 사용되는지를 제어할 수 없다.
- 싱글턴과 같은 단점이다.
- 언제 어디에서 서비스가 사용되는지를 제어할 수 없다.
If access is restricted to a class 접근이 특정 클래스에 제한되면
- 장점
- 커플링을 제어할 수 있다.
- 서비스를 특정 클래스를 상속받는 클래스들에게만 제한하기 때문에 다른 시스템과 커플링되지 않는다.
- 단점
- 중복 작업이 발생할 수 있다.
- 서비스 등록과 찾는 작업을 접근이 허용된 클래스에 대해 중복으로 해줘야한다.
- 이 작업을 하는 클래스를 상속받도록 해도되지만 이는 상관없는 클래스를 형제 클래스로 만드는 등의 단점이 있다.
- 서비스 등록과 찾는 작업을 접근이 허용된 클래스에 대해 중복으로 해줘야한다.
- 중복 작업이 발생할 수 있다.
- 커플링을 제어할 수 있다.
See Also 참고사항
- Service Locator Pattern은 Singleton Pattern과 비슷하다.
- 유니티 프레임워크에서는 GetComponent()에서 컴포넌트 패턴과 함게 서비스 중개자 패턴을 사용한다.
- 마이크로소프트 XNA 프레임워크의 핵심 클래스인 Game에 서비스 중개자 패턴이 포함되어 있다.
- Game.Services 속성에 어떤 종류의 서비스라도 등록해 쓸 수 있다.
Optimization Patterns 최적화 패턴
Data Locality 데이터 지역성
Intent
- CPU 캐시를 최대한 활용할 수 있도록 데이터를 배치해 메모리 접근 속도를 높인다.
Motivtion
- CPU의 데이터의 연산은 빨라졌지만 RAM에서 데이터를 가져오는 건 그다지 빨리지지 않았다.
- RAM에서 데이터를 가져올 때 CPU는 몇백 사이클을 기다린다.
CPU가 RAM에서 데이터를 가져오는 과정
A data warehouse 데이터 창고
- 필요한 데이터를 개별적으로 가져오는 것이 아니라 데이터 주위에 있는 데이터를 한 묶음 단위로 한 번에 가져온다.
- Locality of reference로 인해 다음 작업에 바로 옆 데이터가 필요한 경우가 많기 때문이다.
A pallet for your CPU. CPU를 위한 팰릿(팔레트)
- CPU안의 작은 메모리를 Cache라고 한다.
- L1, L2, L3 등 여러 계층으로 나뉘고 숫자가 올라갈수록 크기는 커지지만 속도는 느려진다.
- 캐시 용량이 작은 이유
- 칩 안에 넣어야해서
- Static RAM(정적램)이라는 비싼 메모리 소자를 사용하기 때문
- RAM에서 데이터를 가져와야 할 경우 연속된 메모리를 캐시에 저장한다.
- 이런 메모리 덩어리를 Cache line이라고 한다.
- CPU는 데이터가 캐시라인에 있으면 RAM에 있는 데이터 보다 훨씬 빨리 데이터를 가져올 수 있다.
- Cache hit
- 캐시 라인에 데이터가 있는 경우
- Cache miss
- 캐리 라인에 데이터가 없어서 RAM에서 데이터를 가져오는 경우
- 캐시 미스가 발생하면 CPU는 데이터가 올 때까지 멈추기 때문에 최대한 캐시 히트가 되도록 해야한다.
- Cache hit
Wait, data is performance? 데이터 = 성능?
- 데이터를 어떻게 두느냐가 성능에 직접적인 영량을 미친다.
- 자료구조를 잘 만들어서 처리하려는 값이 메모리 내에서 서로 가까이 붙어 있도록 하는 것이 목표다.
The Pattern
- 캐시를 사용하면 최근 접근한 메모리 근처에 있는 메모리를 훨씬 빠르게 접근할 수 있다.
- 데이터 지역성
- 관련있는, 혹은 처리 순서가 있는 데이터를 연속된 메모리에 두는 것
- 데이터가 모여있을 수록 캐시를 통해 성능 향상을 할 수 있다.
When to Use It
- 성능 문제가 캐시 미스인 경우 사용해야한다.
- 캐시 분석 도구를 사용하자
- 무료 툴 Cachegrind
- 미리 캐시 최적화를 할 필요는 없지만 자료구조를 캐시하기 좋게 만들 필요는 있다.
Keep in Mind
- 데이터 지역성 패턴을 위해서는 추상화를 일부 희생해야 한다.
- 추상화를 위해 인터페이스를 사용하면 객체 접근에 포인터, 레퍼런스를 사용하는데 이 과정에서 메모리를 랜덤 액세스하게 된다. 또한 가상 메서드 호출도 객체의 vtable에서 호출 함수의 포인터를 찾아야 하므로 캐시 미스가 일어날 수 있다.
Sample Code
Contiguous arrays 연속 배열
- 포인터 대신 실제 객체가 있는 배열을 이용해 객체를 메모리 상 연속된 공간에 배치한다.
// 개선 전 - 객체를 포인터로 관리
class GameEntity{
AIComponent *ai;
}
GameEntity* gameEntities;
for ... {
//gameEntities에 객체의 포인터가 들어있어 있어 접근하며 캐시 미스
gameEntities[i]->ai->update();
}
// 개선 후 - 포인터가 아닌 객체 배열로 데이터 지역성을 높임
AIComponent* aiComponents = new AIComponent[MAX_ENTITIES];
for ... {
//간접참조(->)를 안하기 때문에 캐시 히트
aiComponents[i].update();
}
Packed data 정렬된 데이터
- 객체 풀과 같이 연속된 배열에 객체가 있다.
- 객체에 순차접근하여 메서드 호출 등의 작업을 한다.
- 이 때 모든 객체에 작업이 필요 없을 수 있다.
- 객체가 활성화 됐는지 플래그 변수를 추가하고 if 문으로 분기하여 활성화 여부를 검사 후 작업한다.
- if 문은 Branch Misprediction(분기 예측 실패)가 발생할 수 있다.
- 이를 해결하기 위해 가장 앞쪽에 객체 활성화 시 앞쪽으로 몰아서 활성화된 객체까지만 작업하면 된다.
- 객체의 포인터 이동이 아닌 값의 이동이기 때문에 무거울 수 있지만 포인터 추적 비용으로 인한 캐시 미스가 비용이 더 클 수 있으므로 프로파일링 후 더 나은 해결책을 사용하면 된다.
Hot/cold splitting 빈번한 코드와 한산한 코드 나누기
- 연속된 배열에 있는 객체가 클 경우 캐시 라인에 들어갈 객체가 줄어들어서 캐시 미스가 더 자주 발생한다.
- 자주 사용되지 않는 데이터를 다른 곳에 두고 포인터로 접근할 수 있게 한다면 실제 사용될 데이터만 캐시 라인에 들어가므로 캐시 미스를 줄일 수 있다.
Design Decisions 디자인 결정
How do you handle polymorphism? 다형성은 어떻게 할 것인가?
Don`t (상속을) 사용하지 않는다.
- 장점
- 안전하고 쉽다.
- 더 빠르다.
- vtable에서 메서드를 찾지 않기 때문에 더 빠르다.
- Devirtualization이 지원된다면 ‘더’ 빠르진 않다.
- vtable에서 메서드를 찾지 않기 때문에 더 빠르다.
- 단점
- 유연하지 않다.
- 동적 디스패치(vtable)를 사용하지 않으면 개체별로 다른 동작을 하기 위해 선택문이 들어가야 하므로 코드가 지저분해진다.
- 유연하지 않다.
Use separate arrays for each type 종류별로 다른 배열에 넣는다.
- 장점
- 객체를 빈틈없이 담을 수 있다.
- 정적 디스패치를 할 수 있다.
- 단점
- 여러 컬렉션을 관리해야 한다.
- 종류가 많아지면 관리가 부담된다.
- 모든 자료형을 알고 있어야 한다.
- 다형성을 제공하지 않으니 다른 코드와 커플링된다.
- 여러 컬렉션을 관리해야 한다.
Use a collection of pointers 하나의 컬렉션에 포인터를 모아놓는다.
- 장점
- 유연하다.
- 인터페이스를 통해 객체를 관리하기 때문에 인터페이스를 상속한 어떤 객체라도 컬렉션에 들어갈 수 있다.
- 유연하다.
- 단점
- 캐시 친화적이지 않다.
- 포인터 접근은 캐시 친화적이지 않다.
- 성능에 문제가 없다면 이렇게 해도 된다.
- 캐시 친화적이지 않다.
How are game entities defined? 게임 개체(GameEntity)는 어떻게 정의할 것인가?
- 여기서 말하는 게임 개체는 여러 컴포넌트를 묶어 게임에서 작동하는 하나의 단위를 말한다.
If game entities are classes with pointers to their components 게임 개체 클래스가 자기 컴포넌트들을 포인터로 들고 있을 때
- 장점
- 컴포넌트들을 연속된 배열에 저장할 수 있다.
- 게임 개체와 상관없는 곳에 저장되므로
- 개체로부터 개체 컴포넌트를 쉽게 얻을 수 있다.
- 포인터로 관리하므로
- 컴포넌트들을 연속된 배열에 저장할 수 있다.
- 단점
- 컴포넌트를 메모리에 옮기기가 어렵다.
- 컴포넌트의 메모리 위치가 변경될 경우 게임 개체의 컴포넌트 포인터도 같이 업데이트 해야한다.
- 컴포넌트를 메모리에 옮기기가 어렵다.
If game entities are classes with IDs for their components 게임 개체 클래스가 컴포넌트를 ID로 들고 있을 때
- 설명
- 컴포넌트의 ID로 실제 컴포넌트를 찾는다.
- 배열의 인덱스 또는 ID를 갖고 배열이나 해시 테이블에서 컴포넌트를 찾을 수 있다.
- 컴포넌트의 ID로 실제 컴포넌트를 찾는다.
- 단점
- 더 복잡하다
- 포인트를 쓰는 것보다 구현할게 많다.
- 더 느리다.
- 검색이나 해싱 작업이 필요하므로 포인터 보다 느리다.
- 컴포넌트 ‘관리자’ 같은 것에 접근해야 한다.
- 인덱스나 ID를 받아 컴포넌트를 찾아주는 관리자 클래스가 필요하다.
- 컴포넌트 등록소도 있어야 한다.
- 더 복잡하다
If the game entity is itself just an ID 게임 개체가 단순히 ID일 때
- 설명
- 게임 개체에서 컴포넌트를 찾는 대신 컴포넌트가 게임 개체의 ID나 인덱스를 가지고 형재 컴포넌트를 찾을 수 있다.
- 게임 개체는 실제하지 않고 개념적으로 존재하며 컴포넌트를 그룹화하는 역할을 한다.
- 장점
- 개체가 단순해진다.
- 숫자 하나로 게임 개체의 레퍼런스를 주고 받는다.
- 개체가 비어 있다.
- 개체가 실존하지 않는다.
- 개체 생명주기를 관리하지 않아도 된다.
- 단순한 값이기 때문에 메모리에 할당/해제하지 않아도 된다.
- 개체가 단순해진다.
- 단점
- 개체가 ID로 관리될 경우 특정 개체의 컴포넌트를 찾는게 느릴 수 있다.
- ID로 개체를 찾기위해 해싱등을 할 경우 느려진다.
- 배열의 인덱스를 ID로 삼는 경우 해결 가능하지만 이럴 경우 모든 컴포넌트들이 같은 배열 위치를 가져야하므로 부분적으로 컴포넌트를 활성/비활성 할 경우 정렬이 불가능해진다.
- 개체가 ID로 관리될 경우 특정 개체의 컴포넌트를 찾는게 느릴 수 있다.
See Also
- Data Locality 패턴은 컴포넌트 패턴과 관련이 많다.
- Data Locality 패턴에서는 거의 언제나 단일 자료형을 하나의 연속된 배열에 나열하는 방식을 활용한다.
- 객체 풀 패턴이라고 볼 수도 있다.
Dirty Flag
Intend
- 불필요한 작업을 피하기 위해 실제로 필요할 때까지 그 일을 미룬다.
Motivation
- 게임 씬 안에 있는 게임 개체들의 계층 구조에서 부모 객체의 Transform(위치, 크기, 회전) 변경 시 자식 객체의 Transform을 변환해야한다.
- 계층 구조에 있는 모든 개체들의 Transform이 변경될 경우 계층구조의 끝으로 갈 수록 쓸데없는 계산을 더 많이 하게된다.
- 따라서 계산 결과가 필요한 시점까지 계산을 미루어 계산을 한 번만 하도록 하는 패턴이다.
Local and world transforms 지역 변환과 월드 변환
- 로컬 좌표의 모델을 화면에 출력하기 위해서는 월드 좌표로 변환해야한다.
- 움직이지 않는 모델의 로컬 좌표를 매프레임 월드 좌표로 변환하는 것은 낭비다.
Cached world transforms 월드 변환 값 캐싱
- 매 프레임 월드 변환을 하지 않으려면 변환 행렬값을 캐시하여 사용한다.
- 모델의 움직임이 있을 때만 월드 변환을 계산한다.
- 하지만 버려지는 계산을 하는 것은 여전하다.
Deferred recalculation 재계산 미루기
- 지역 Transform 변경을 모두 한 뒤 월드 Transform 업데이트를 한다.
- 방법
- 개체에 dirty 플래그를 추가하여 Transform 변경이 있는 경우 true로 설정하고 true인 개체만 월드 Transform 업데이트를 한다.
- dirty는 ‘더 이상 맞지 않음’을 나타낸다.
- 개체에 dirty 플래그를 추가하여 Transform 변경이 있는 경우 true로 설정하고 true인 개체만 월드 Transform 업데이트를 한다.
- 효과
- 계층 구조를 따라가며 지역 변환을 곱하던 것을 한 번의 재계산으로 합친다.
- 움직이지 않는 객체는 변환 계산을 하지 않는다.
- 렌더링 전에 제거될 객체는 월드 변환 계산을 하지 않는다.
The Pattern
- 기본값과 파생값에서 기본값이 변경되면 더티 플래그가 켜진다.
- 파생값이 필요할 때 기본값의 더티 플래그 켜져있으면 재계산한 후 플래그를 끈다.
- 더티 플래그가 꺼져있으면 이전 캐시 값을 사용한다.
When to Use It
- 코드가 복잡해지므로 성능 문제가 있는 경우에만 사용. 적용전 프로파일링 해보자.
- 파생 값이 사용되는 횟수보다 기본 값이 더 자주 변경되어야 한다.
- 기본 값이 바뀔 때마다 파생 값이 항상 필요하다면 더티 플래그 패턴은 무의미 하다.
Keep in Mind
There is a cost to deferring for too long 너무 오래 지연하려면 비용이 든다.
- 파생 값 계산이 오래 걸릴경우 멈춤 현상이 발생할 수 있다.
You have to make sure to set the flag every time the state changes 상태가 변할 때마다 플래그를 켜야 한다.
- Cache Invalidation 캐시 무효화
- 원본 데이터가 변경될 때 캐시 값이 더 이상 맞지 않을을 제때 알려주는 것.
- 더티 플래그를 켜는 것
- 데이터 변경 시 캐시 무효과를 놓칠 경우 무효화된 파생 값을 사용하게 된다.
- 기본 값 변경을 한 곳에서만 하도록 강제하면 피할 수 있다.
You have to keep the previous derived data in memory 이전 파생 값을 메모리에 저장해둬야 한다.
- 파생 값을 메모리에 저장해야한다.
- 속도를 위해 메모리를 희생한다.
- 메모리와 시간이 남는다면 필요할 때마다 계산하는 게 더 낫다.
Sample Code
void GraphNode::render(Transform parentWorld, bool dirty){
dirty |= dirty_;//계층구조에서 하나라도 켜진경우 그 밑으로 모두 켜진다.
if(dirty){
world = local_.combine(parentWorld);//행렬변환
dirty_ = false;//현재 객체의 dirty 플래그를 끈다.
}
renderMesh(mesh_, world_);
for(...){
children_[i]->render(world, dirty);//자식 객체의 render()를 호출
}
}
Design Decisions
When is the dirty flag clearned? 더티 플래그를 언제 끌 것인가?
When the result is needed 결과가 필요할 때
- 결과 값이 필요 없다면 아예 계산하지 않을 수 있다.
- 파생값 사용 빈도보다 기본값이 훨씬 자주 바뀔 때 좋다.
- 계산 시간이 오래걸리면 멈춤 현상이 생길 수 있다.
At well-defined checkpoints 미리 정해놓은 지점에서 할 때
- 항구 정박, 로딩 화면, 컷신 등의 상황에 처리
- 지연 작업 처리가 플레이 경험에 영향을 주지 않는다.
- 플레이어의 관심을 돌릴 화면을 보여준다.
- 작업 처리 시점을 제어할 수 없다.
- 플레이어가 의도된 곳으로 이동하지 않을 경우 프로그래머의 의도보다 지연이 길어질 수 있다.
In the background 백그라운드 처리할 때
- 타이머를 돌려 처리한다.
- 얼마나 자주 처리할지 시간으로 조절할 수 있다.
- 정해진 간격으로 처리하므로 필요 없는 작업을 더 많이 할 수 있다.
- 비동기 작업을 지원해야 한다.
- 백그라운드 처리 시 플레이가 계속되어야 한다.
How fine-grained is your dirty tracking? 더티 플래그는 값 변화를 얼마나 세밀하게 관리해야 하는가?
If it’s more fine-grained 더 세밀하게 관리된다면
- 개별 객체마다 더티 플래그가 있다.
- 장점
- 실제로 변경된 데이터만 처리한다.
- 단점
- 데이터 처리에 필요한 간접 비용이 개별 객체마다 필요하므로 오버헤드 시간이 늘어난다.
If it’s more coarse-grained 더 듬성듬성하게 관리한다면
- 변경 시 업데이트 될 객체들을 그룹화 한다.
- 장점
- 고정 오버헤드에 드는 시간을 줄일 수 있다.
- 여러 객체가 하나의 더티 플래그를 사용하면 그만큼 오버헤드가 줄어든다.
- 더티 플래그에 드는 메모리가 줄어든다.
- 고정 오버헤드에 드는 시간을 줄일 수 있다.
- 단점
- 변경 안 된 데이터도 같이 처리해야 된다.
See Also
- 더티 플래그 패턴은 UI에서도 많이 사용된다.
- 물리 엔진에서는 물리 연산이 필요하지 않은 객체는 잠재우는 방식으로 더티 플래그 패턴을 사용한다.
Object Pool 객체 풀
Intent
- 객체를 매번 할당, 해제하지 않고 고정 크기 풀에 들어 있는 객체를 재사용함으로써 메모리 사용 성능을 개선한다.
Motivation
- 파티클을 빠르게 생성, 제거하는 과정에서 메모리 단편화가 생겨서는 안 된다.
The curse of fragmentation 메모리 단편화의 저주
- fragmentation 단편화
- 힙에 사용 가능한 메모리 공간이 크게 연결되 있지 않고 작게 조각나 있는 상태
- 메모리가 해제되며 빈 공간을 만드는데 이 빈공간을 합한 용량은 클지라도 연속해서 사용 가능한 메모리 영역이 작아져서 큰 메모리 공간을 새로 할당할 수 없게 된다.
The best of both worlds 둘 다 만족시키기
- 메모리 단편화를 해결하려면 실행시 메모리를 크게 잡아 놓고 종료까지 들고 있으면 된다.
- 하지만 실행 중 객체를 생성, 제거해야하는 시스템에서는 해결책이라 할 수 없다.
- 이럴 때 객체 풀은 실행 시 메모리를 잡아 놓고 사용자 입장에서는 객체 풀에 요청하여 할당, 해제(반납) 할 수 있다.
The Pattern
- 객체를 모아 놓은 객체 풀 클래스를 정의한다.
- 객체 풀에 들어갈 객체는 자신이 ‘사용 중’인지 여부를 제공한다.
- 객체 풀은 객체들을 미리 생성하고 ‘사용 안 함’ 상태로 초기화한다.
- 객체 풀에 객체 요청 시 ‘사용 중’으로 초기화한 뒤 반환한다.
- 객체 풀에 객체 반납 시 ‘사용 안 함’ 상태로 되돌린다.
When to Use It
- 객체를 빈번하게 생성/삭제해야 한다.
- 객체들의 크기가 비슷하다.
- 객체를 힙에 생성하기가 느리거나 메모리 단편화가 우려된다.
- 데이터베이스 연결이나 네트워크 연결같이 접근 비용이 비싸면서 재사용 가능한 지원을 객체가 캡슐화하고 있다.
Keep in Mind
The pool may waste memory on unneeded objects 객체 풀에서 사용되지 않는 객체는 메모리 낭비화 다를 바 없다.
- 객체 풀은 너무 작거나 너무 크지 않도록 주의해야 한다.
Only a fixed number of objects can be active at any one time 한 번에 사용 가능한 객체 개수가 정해져 있다.
- 객체 풀에 사용할 수 있는 객체가 없을 때를 대비해야 한다.
- 이런 일이 아예 생기지 않게 한다.
- 최대로 사용될 풀의 크기를 미리(컴파일타임) 정한다.
- 그냥 객체를 생성하지 않는다.
- 파티클 등에서 이미 많은 파티클이 활성화인데 몇 개 더 안나온다고 이상해보이진 않는다.
- 기존 객체를 강제로 제거한다.
- 새로운 객체가 반드시 필요할 경우 기존에 사용중인 객체중 우선순위가 낮은 객체를 교체한다.
- 풀의 크기를 늘린다.
- 런타임에 풀의 크기를 늘리거나 보조 풀을 만든다.
- 추가로 늘린 메모리가 더 이상 필요하지 않을 때 해제할지를 정해야한다.
- 런타임에 풀의 크기를 늘리거나 보조 풀을 만든다.
- 이런 일이 아예 생기지 않게 한다.
Memory size for each object is fixed 객체를 위한 메모리 크기는 고정되어 있다.
- 객체가 크기가 달라질 수 있는 상속된 자료형일 경우 객체 풀은 가장 큰 자료형에 맞춰야한다.
- 가장 큰 자료형에 맞추니 메모리가 낭비된다.
- 객체 크기별로 풀을 나누는게 좋다.
Reused objects aren’t automatically cleared 재사용되는 객체는 저절로 초기화되지 않는다.
- 객체를 회수할 때 객체의 메모리를 완전히 초기화한다.
Unused objects will remain in memory 사용 중이지 않은 객체도 메모리에 남아 있다.
- GC 시스템은 메모리 단편화를 알아서 처리하지만 단순한 GC만 지원하는 곳에서는 객체 풀이 의미가 있다.
- 객체 풀과 GC를 같이 사용할 때 주의할 점
- 객체 풀은 메모리에서 해제되지 않기 때문에 GC 대상 객체의 참조를 갖고 있으면 GC되지 않는다.
- 따라서 객체 풀의 객체 초기화 시 다른 객체 참조 부분을 모두 정리해야 한다.
Sample Code
- 파티클을 예로 든다.
- 파티클 객체를 파티클 풀 객체가 관리한다.
- 파티클 객체 요청 시 하니씩 꺼나 주고 해제 시 비활성화 한다.
- 사용 가능한 파티클 객체를 찾기 위해 배열을 검색해야하기 때문에 O(n)의 탐색속도가 필요하다.
- 개선
- 파티클 객체끼리 단방향 연결 리스트를 만들어준다.
- 파티클 객체는 비활성화 상태일 때 데이터가 의미가 없으므로 이 데이터 영역과 다음 파티클 포인터를 공용체(union)으로 묶어 메모리를 아낄 수 있다.
- next 변수에 다음 파티클 객체의 포인터를 넣는다.
- free list(빈칸 리스트) 기법이라 한다.
- 첫 파티클을 head에 저장하고 파티클 요청 시 head를 반환한 뒤 head의 next를 head로 만든다.
- 파티클 해제 시 head를 반환된 파티클의 next로 만들고 반환된 파티클을 head로 만든다.
- 파티클 객체끼리 단방향 연결 리스트를 만들어준다.
class Particle{
bool inUse;
union {
//활성화일 때 사용하는 데이터
struct{
double x, y;
} data;
//비활성화일 때 사용하는 포인터
Particle* next;
} dataAndNext;
}
class ParticlePool{
Particle particles_[99];
Particle* head;
init(){
// particles_를 초기화하고 next를 채워준다.
}
createParticle(){//파티클 생성
head = head.dataAndNext.next;//head를 교체하고 반환
return head;
}
releaseParticle(Particle* particle){//파티클 해제
particle.dataAndNext.next = head;//반환된 파티클을 head로 지정
head = particle;
}
}
Design Decisions
Are objects coupled to the pool? 풀이 객체와 커플링되는가?
If objects are coupled to the pool 객체가 풀과 커플링된다면
- 장점
- 더 간단하게 구현할 수 있다.
- 풀에 들어가는 객체에 사용중 플래그를 추가해도 된다.
- 객체가 풀을 통해서만 생성할 수 있도록 강제할 수 있다.
- 객체의 생성자를 private으로 숨기고 객체 풀 클래스를 friend로 지정하여 객체 풀 클래스에서만 초기화 하도록 할 수 있다.
- ‘사용 중’ 플래그를 필드로 두지 않고 메서드로 제공할 수 있다.
- 메서드에서 ‘사용 중’ 인지 아닌지를 판단하고 true/false를 반환한다.
- 더 간단하게 구현할 수 있다.
If objects are not coupled to the pool 객체가 풀과 커플링되지 않는다면
- 장점
- 어떤 객체라도 넣을 수 있다.
- 객체와 풀이 디커플링되어 일반적이고 재사용 가능한 풀 클래스를 구현할 수 있다.
- 어떤 객체라도 넣을 수 있다.
- 단점
- ‘사용 중’ 상태를 객체 외부에서 관리해야 한다.
- 풀에서 관리하려면 객체 배열과 동일한 크기의 플래그(비트) 배열을 두고 관리한다.
- ‘사용 중’ 상태를 객체 외부에서 관리해야 한다.
What is responsible for initializing the reused objecct? 재사용되는 객체를 초기화할 때 어떤 점을 주의해야 하는가?
If the pool reinitializes internally 객체를 풀 안에서 초기화한다면
- 장점
- 풀은 객체를 완전히 캡슐화할 수 있다.
- 밖에서 객체를 참조하게 하려면 포인터 대신 핸들 값을 반환하자
- 핸들 값은 객체를 간접 조작할 수 있는 값이다.
- 반복자, 인덱스 등…
- 핸들 값은 객체를 간접 조작할 수 있는 값이다.
- 밖에서 객체를 참조하게 하려면 포인터 대신 핸들 값을 반환하자
- 풀은 객체를 완전히 캡슐화할 수 있다.
- 단점
- 풀 클래스는 객체가 초기화되는 방법과 결합된다.
- 객체 초기화 방법이 여러 개이면 모두 지원해야 한다.
- 풀 클래스는 객체가 초기화되는 방법과 결합된다.
If outside code initializes the object 객체를 밖에서 초기화한다면
- 장점
- 풀의 인터페이스는 단순해진다.
- 풀은 객체 초기화와 상관없으므로 객체에 대한 레퍼런스만 반환한다.
- 풀의 인터페이스는 단순해진다.
- 단점
- 외부 코드에서는 객체 생성이 실패할 때의 처리를 챙겨야 할 수 있다.
- 풀에 요청한 객체가 null인지 확인해야 한다.
- 외부 코드에서는 객체 생성이 실패할 때의 처리를 챙겨야 할 수 있다.
See Also
- Object Pool 패턴과 Flyweight 패턴은 객체를 재사용한다는 점에서 비슷하다.
- Object Pool 패턴은 객체 초기화를 통해 재사용한다.
- Flyweight 패턴은 공통으로 사용되는 데이터를 여러 객체가 공통으로 재사용한다.
- 배열을 사용하여 같은 타입의 객체를 메모리에 모아두면 캐시 히트에 도움이 된다.
Spatial Partition 공간 분할
Intent
- 객체를 효과적으로 찾기 위해 객체 위치에 따라 구성되는 자료구조에 저장한다.
Motivation
- 게임의 공간 안에 있는 객체가 다른 객체와 상호작용하기 위해 ‘주변에 어떤 객체들이 있는지’를 알아야한다.
- 이를 매 프레임마다 모든 객체에 대해 확인하면 성능 병목이 있을 수 있다.
Units on the field of battle 전장 위의 유닛들
- 모든 유닛이 자신을 제외한 모든 유닛에 대해 위치 검사를 한다면 O(n²)의 복잡도를 가진다.
- 이미 검사한 유닛끼리는 다시 검사하지 않게 할 경우 O(n²)보다 작지만 유닛이 늘어남에 따라 복잡도가 점점 더 많이 증가하므로 O(n²)이라 할 수 있다.
Drawing battle lines 1차원 전장
- 1차원 배열에 유닛을 정렬 시키면 전체 배열을 훑지 않아도 이진 검색 등으로 주변 유닛을 쉽게 찾을 수 있다.
- 위치에 따라 구성되는 자료구조에 객체를 저장하면 객체를 빠르게 찾을 수 있다.
- 2차원 이상의 공간을 구성하는 자료구조에 객체를 저장한 것이 공간 분할 패턴이다.
The Pattern
- 객체들은 공간에서 위치 값을 갖는다.
- 객체들을 객체 위치에 따라 구성되는 공간 자료구조에 저장한다.
- 공간 자료구조를 통해 주변 객체를 빠르게 찾을 수 있다.
- 객체 위치가 바뀌면 공간 자료구조도 업데이트 한다.
When to Use It
- 위치 값이 있는 객체가 많고, 위치에 따라 객체를 찾는 질의가 성능에 영향을 줄 정도로 잦을 때 공간 분할 패턴을 사용한다.
Keep in Mind
- O(n²) 복잡도를 낮출 수 있다.
- 객체가 많을 수록 의미가 있다.
- n이 충분히 작다면 크게 의미가 없다.
- 객체의 위치 변경 처리가 어렵다.
- 객체의 위치 변화에 맞춰 자료구조를 재정리해야 한다.
- 코드가 복잡하고 CPU를 더 많이 사용한다.
- 이래도 사용할 가치가 있는지 확인하자
- 추가 메모리가 필요하다.
- 속도를 위해 메모리를 희생한다.
Sample Code
- 가장 간단한 공간 분할 형식 Fixed Grid(고정 격자) 방법
- 전장에 유닛들이 있는 상황
A Sheet of graph paper 모눈종이
- 공간을 고정 크기 격자로 나눈다.
- 유닛의 위치로 어떤 격자에 들어갈지 확인한다.
- 격자의 유닛 리스트 맨 앞에 추가한다.
A grid of linked units 유닛을 연결 리스트로 저장한 격자
- 유닛을 연결리스트로 연결한다.
- 프로그램 언어에 기본 포함된 컬렉션을 사용한다.
Entering the field of battle 전장 속으로 들어가기
- 유닛이 어떤 칸에 들어갈지 판단하고 그 칸의 유닛리스트에 넣는다.
A clash of swords 검의 격돌
- 같은 칸 안에 있는 유닛끼리만 공격 판정을 하므로 전체 유닛을 대상으로한 판정보다 빨라졌다.
Charging forward 진격
- 유닛이 이동 시 칸을 넘어가면 현재 칸에서 제거하고 새로운 칸에 추가한다.
At arm’s length 사정거리 안에서
- 공격 범위가 칸을 벗어날 수 있으므로 더 넓은 범위의 칸들을 검사해야 한다.
- 공격하는 방향에 있는 칸들만 검사하면 된다.
Design Decisions
Is the partition hierarchical or flat? 공간을 계층적으로 나눌 것인가, 균등하게 나눌 것인가?
If it’s a flat partitions 균등하게 나눈다면
- 더 단순하다
- 균등 자료구조는 이해하고 구현하기 쉽다.
- 메모리 사용량이 일정하다.
- 객체 추가/삭제와 상관없이 공간이 일정한 크기이므로 메모리 양은 변하지 않는다.
- 객체가 위치를 이동할 때 자료구조의 업데이트 속도가 빠르다.
- 계층적 공간 분할은 객체 수 변경에 따라 공간을 분할 하므로 이에 대비해 빠르다.
If it’s hierarchical 계층적으로 나눈다면
- 빈 공간을 훨씬 효율적으로 처리할 수 있다.
- 한산한 공간은 재분할하지 않으므로 메모리도 차지하지 않고 검색도 빠르다.
- 밀집된 영역도 효과적으로 처리할 수 있다.
- (책의 내용이 앞뒤가 맞지 않아 정리가 안됨.)
Does the partitioning depend on the set of objects? 객체 개수에 따라 분할 횟수가 달라지는가?
- 성능을 높이기 위해 영역마다 비슷한 수의 유닛이 들어갈 수 있도록 균형 잡힌 분할을 만든다.
If the partitioning is object-independent 객체 개수와 상관없이 분할한다면
- 객체는 순차적(incrementally)으로 추가될 수 있다.
- 적당한 위치를 찾아 넣어주기만 하면 된다.
- 분할 영역의 크기가 고정이므로 객체가 빠르게 이동할 수 있다.
- 객체 수에 따라 분할 한다면 객체 이동 시 영역 분할을 갱신해야 한다.
- 영역이 균형 잡혀 있지 않을 수 있다.
- 객체가 한곳에 뭉쳐있을 수 있어서 메모리가 낭비되고 성능도 떨어질 수 있다.
If the partitioning adapts to the set of objects 객체 개수에 따라 영역이 다르게 분할된다면
- 객체의 분포에 맞춰서 공간을 분할한다.
- 영역의 균형 잡힘(balanced)을 보장할 수 있다.
- 영역별로 객체 수가 같으므로 성능을 일정하게 유지할 수 있다.
- 전체 객체를 미리 준비하고 한 번에 분할해놓는 게 훨씬 효과적이다.
- 따라서 정적 지형이나 아트 리소스에 훨씬 자주 사용된다.
If the partitioning is object-independent, but hierarchy is object-dependent 영역 분할은 고정되어 있지만, 계층은 객체 개수에 따라 달라진다면
- Quadtree
- 고정 분할과 적응형 분할의 장점을 가진 방식
- 작동방법
- 전체 공간을 한 영역으로 시작
- 객체 개수가 정해진 수를 넘어가면 사각형 4개로 분할
- 각 영역의 개수가 정해진 수 이하가 될 때까지 재귀적으로 반복 분할
- 장점
- 객체를 순차적을 추가할 수 있다.
- 위치에 맞는 사각형 칸에 추가하면 된다.
- 이동하는 객체가 일정 수 이하이기 때문에 일정 성능이 보장된다.
- 객체 제거 후 개수가 최대수 미만이면 분할 영역을 합친다.
- 객체 이동이 빠르다.
- 이동 시 객체를 추가/삭제만 하면된다.
- 분할 영역이 균형 잡혀 있다.
- 영역에 들어가는 객체는 항상 최대 개수 미만이다.
- 하지만 영역의 크기를 무한정 줄일 수 없기 때문에 객체의 위치가 겹칠 수 있다면 영역당 유닛 개수를 제어하지 못할 수도 있다.
- 객체를 순차적을 추가할 수 있다.
Are objects only stored in the partition? 객체를 공간 분할 자료구조에만 저장하는가?
- 객체 리스트를 공간 분할 자료구조에서만 참조한다면 전체 순환 등의 작업에 비용이 발생할 수 있다.
If it is the only place objects are stored 객체를 공간 분할 자료구조에만 저장한다면
- 컬렉션이 두 개가 되면서 생기는 메모리 비용과 복잡도를 피할 수 있다.
If there is another collection for the objects 다른 컬렉션에도 객체를 둔다면
- 장점
- 전체 객체를 더 빠르게 순회할 수 있다.
- 공간 분할 자료구조의 비어있는 칸에서 객체를 찾지 않아도 된다.
- 전체 객체를 더 빠르게 순회할 수 있다.
- 단점
- 객체를 컬렉션에 생성, 삭제하는 작업을 추가로 해줘야 한다.
See Also
- 공간 분할 자료구조
- 격자
- 쿼드트리
- 이진 공간 분할(BSP)
- k-d 트리
- 경계 볼륨 계층구조(BVH)
- 1차원 자료구조와 공간 분할 자료구조
- 격자의 1차원 버전은 Bucket Sort(버킷 정렬)이다.
- BSP, k-d 트리, BVH의 1차원 버전은 이진 검색 트리다.
- 쿼드트리, 옥트트리의 1차원 버전은 Trie(트라이)다.
그 외 디자인 패턴
Dependency Injection DI 의존성주입
- 특정 객체가 필요로하는 의존 객체를 외부 코드에서 주입한다.
Decorator Pattern 장식자 패턴
용어
Queuing(큐잉)
- 큐에 넣는것?
Tearing(테어링)
- tear : 찢다
- 비디오 버퍼에 프로그램이 화면을 다 그리기 전에 비디오 드라이버가 버퍼를 읽어 화면에 출력하면 이전 프레임과 현재 프레임이 동시에 그려지며 화면이 어긋나 찢어진듯이 보이는 현상
Atomic(원자적)
- 더 이상 나누어 질 수 없는 하나의 행위
- 수행 도중 중단될 수 없는 하나의 동작 단위
- 예
- 프로세스가 명령어(instruction)를 실행 중이라면 어떤 인터럽트가 발생하더라도 명령어를 수행을 완료한 후 인터럽트를 처리한다.
- 데이터베이스의 트랜잭션은 본래 원자성이 없는 명령어들을 묶어 원자성을 가진 실행 단위로 만든 것
Marshalling(마샬링)
- 한 객체의 메모리에서의 표현 방식을 저장 또는 전송에 적합한 다른 데이터 형식으로 변환하는 과정
- 데이터를 컴퓨터 프로그램의 서로 다른 부분 간에 혹은 한 프로그램에서 다른 프로그램으로 이동해야 할 때도 사용
- Serialization(직렬화)과 유사
- 반대 개념은 Unmarshalling 혹은 Demarshalling이라하고 Deserialization과 유사하다.
Serialization(직렬화)
- 오브젝트의 상태를 오브젝트의 사본으로 다시 변환할 수 있는 바이트 스트림으로 변환하는 것
Reflection
- 런타임에 타입 시스템과 상호작용할 수 있다.
- 구체적인 클래스 타입을 알지 못해도, 컴파일된 바이트 코드를 통해 역으로 클래스 정보를 알아내어 클래스를 사용할 수 있다.
- 이름만으로 클래스를 찾은 뒤에 생성자를 호출해 인스턴스를 생성할 수 있다.
Locality of reference 참조 지역성
- 방금 사용한 데이터 근처에 있는 데이터를 바로 다음에 사용하는 것
Thrash 캐시 뒤엎기
- 캐시 무효화가 계속 반복되는 현상
- 캐시 히트와 캐시 미스의 퍼포먼스 비교 코드
Branch Misprediction 분기 예측 실패
- CPU 유휴 시간을 줄이기 위해 분기가 있을 때 이전 분기값을 참고하여 분기를 예측한다.
- 이 때 다음 명령어가 파이프 라인에 올라오게 되는데 분기 예측에 실패하면 Pipeline Flush(파이프라인 비우기)를 실행하여 Pipeline Stall(파이프라인 지연)이 발생한다.
Data-oriented Design 데이터 중심 디자인
- 데이터를 연속된 공간에 모아두어 캐시 미스를 최소화하는 방법
- Data driven Design(데이터 주도형 디자인)과는 다르다.
- http://parkpd.egloos.com/4092250
Data driven Design 데이터 주도형 디자인
- 프로그램의 기능을 최대한 밖으로 꺼내어 데이터쪽으로 옮기는 것.
Dynamic Dispatch 동적 디스패치
- 런타임 시에 호출 객체를 확인해 해당 객체의 메서드를 호출한다.
Static Dispatch 정적 디스패치
- 컴파일 타임에 어떤 메서드가 호출될지 정해진다.
Method Signature 메서드 시그니처
- 메서드를 구분할 수 있는 정의
- 반환형, 이름, 인자
- 자바에서는 반환형은 시그니처에 포함되지 않는다.
- 따라서 반환형만 다른 메서드는 오버로딩되지 않는다.
- 자바에서는 반환형은 시그니처에 포함되지 않는다.
Overhead
영어
- intrinsic [intrínsik]
- 본질적인, 고유한, 내재한
- extrinsic [ikstrínsik]
- 비본질적인, 고유의 것이 아닌, 외부(로부터)의
- heroine [hérouin]
- 여주인공
- interpret
- ~을 해석하다.
- Breed [briːd]
- 품종
- amortize [ǽmərtàiz]
- 분할 상환하다
- pending [péndiŋ]
- 현안의, 남아있는, 계류중인, 미결, 당면한
- aggregate [ǽgrigət]
- 잡합한, 집합체, 모으다.
- locator [lóukeitər]
- 경계 설정자, 권리자, 임대인, 위치를 나타내는 것
- temporal [témpərəl]
- 시간의
- located
- ~에 위치한, 자리잡다.
- reflection
- 반사, 반성, 반영, 심사숙고
- configure [kənfígjər]
- 형성하다, 배열하다, ~을 (특별한 목적에 맞추어) 설계(개조, 수정)하다.
- halt [hɔːlt]
- 중단, 멈추다, 막다, 중지하다, 마비
- pallet [pǽlit]
- 팔레트, 주걱, 흙손
- 지게차 따위로 물건을 실어 나를 때 물건을 안정적으로 옮기기 위해 사용하는 틀 같은 구조물
- cache [kæʃ]
- 은닉처, 저장하다, ~을 은닉처에 감추다
- 캐시 메모리(데이터의 일부를 일시적으로 보관하는 고속 기억 장치)
- thrash [θræʃ]
- 호되게 때리다, 완패시키다, 탈곡하다
- contiguous [kəntígjuəs]
- 인접하는, 인접하고 있는, 연속적인
- loot [luːt]
- 약탈하다, 전리품, 돈
- traversal [trævə́ːrsl]
- 횡단하기, 횡단
- grained
- 나뭇결이 있는, 알갱이가 ~한, 우툴두툴한
- fine-grained
- 결이 고운, 미립자의
- coarse [kɔːrs]
- 거친, 상스러운
- 반의어
- fine
- 좋은, 벌금, 미세한, 잘, 고급의
- fine
- coarse-grained
- 표면이 거친, 결이 거친, 조잡한, 난폭한
- well-defined
- 정의가 명확한, 윤곽이 뚜렷한
- overhead
- 간접 비용
- spatial [spéiʃəl]
- 공간의, 공간적인, 장소의
- clash [klæʃ]
- 충돌하다, 전투하다, 대립하다, 격돌하다, 상충되다.
- charge [ʧaːrdʒ]
- 청구하다, (~의)부담으로 하다, 외상으로 하다, 맡기다, 고발하다, 비난다다, 채우다, ~에 돌격하다,
참고
- [서적] 게임 프로그래밍 패턴 / 로버트 나이트롬, 박일
- 오토마타에 대해 알아봅시다.
- 오토마타