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. 큐가 소유권을 가진다.

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

한 개체가 여러 분야를 서로 커플링 없이 다룰 수 있게 한다.

 

유니티 게임 오브젝트에 여러 컴포넌트를 붙였던 경험을 떠올려 쉽게 이해할 수 있었다.

 

멀티스레드 환경에서는 게임 코드를 분야별로 스레드의 방법을 사용한다. AI, 사운드, 렌더링을 각자 스레드에서 실행시킨다. 분야별로 디커플링 해야 교착상태 같은 동시성 버그를 피할 수 있다.

 

서로 통신이 필요한 컴포넌트가 있다면 필요한 컴포넌트 끼리의 결합을 사용할 수 있다.

 

14.3 패턴

여러 분야를 다루는 하나의 개체가 있다. 분야별로 격리하기 위해, 각각의 코드를 별도의 컴포넌트 클래스에 둔다. 이제 개체 클래스는 단순히 이들 컴포넌트들의 컨테이너 역할만 한다.

 

14.4 언제 쓸 것인가?

게임 개체를 정의하는 핵심 클래스(GameObject)에서 가장 많이 사용되지만, 다음 조건 중 하나라도 만족한다면 다른 분야에서도 유용하게 쓸 수 있다.

 

1. 한 클래스에서 여러 분야를 건드리고 있어서 이들을 서로 디커플링하고 싶다.

2. 클래스가 거대해져서 작업하기가 어렵다.

3. 여러 다른 기능을 공유하는 다양한 객체를 정의하고 싶다. 단, 상속으로는 딱 원하는 부분만 골라서 재사용할 수가 없다.

 

14.5 주의사항

컴포넌트 패턴을 적용하면 클래스 하나에 코드를 모아놨을 때보다 더 복잡해질 가능성이 높다.

컴포넌트끼리 통신하기 어렵다.

각 컴포넌트의 객체를 생성하고 초기화를 잘 해줘야 한다.

대상 개체의 데이터에 접근하거나 행동을 수행하기 위해서 개체의 컴포넌트 객체를 거쳐야 하기 때문에 성능이 민감한 내부 루프 코드에서 이런 식으로 포인터를 따라가다 보면 성능이 떨어질 수 있다.

 

14.6 예제 코드

class GameObject {
public:
    int velocity;
    int x, y; // 컴포넌트간 통신을 위한 멤버 변수
    
    GameObject(InputComponent* input,
    		PhysicsComponent* physics,
                GraphicsComponent* grpahics): 
                input_(input),
                pyhics_(physics),
                graphics_(graphics) {
                
    }
    
    void update(Wolrd& world, Graphics& graphics) {
    	input_->update(*this);
        physics_->update(*this, world);
        graphics_->update(*this, graphics);
    }
    
private:
    InputComponent* input_;
    PhysicsComponent* physics_;
    GraphicsComponent* graphics_;
};
class PhysicsComponent {
	public:
    	virtual ~PhysicsComponent() {}
        virtual void update(GameObject& obj, World& world) = 0;
};

class BjronPhysicsComponent : public PhysicsComponent {
public:
    virtual void update(GameObject& obj, World& world) {
        
	}
};

 

14.7 디자인 결정

객체는 컴포넌트를 어떻게 얻는가?

1. 객체가 필요한 컴포넌트를 알아서 생성할 때

ㄴ 객체는 항상 필요한 컴포넌트를 가지게 된다.

ㄴ 객체를 변경하기가 어렵다. (컴포넌트 패턴의 강점 중 하나는 컴포넌트 재조합만으로 새로운 종류의 객체를 만들 수 있다는 점인데 어떤 컴포넌트를 사용할지를 하드코딩해놓으면, 이런 유연성을 잃게 된다.)

 

2. 외부 코드에서 컴포넌트를 제공할 때

ㄴ 객체가 훨씬 유연해진다. 컴포넌트 구현 클래스만 변경해도 다르게 동작하는 컴포넌트를 객체에 제공할 수 있다.

ㄴ 객체를 구체 컴포넌트 자료형 (예제의 BjronPhysicsComponent)으로부터 디커플링 할 수 있다. 

 

컴포넌트들끼리는 어떻게 통신할 것인가? (중복 가능)

1. 컨테이너 객체의 상태를 변경(공유)하는 방식 (멤버 변수를 공유 정보로써 사용할 수 있다.)

ㄴ 컴포넌트들이 공유하는 정보를 컨테이너 객체에 전부 넣어야 한다.

ㄴ 컴포넌트끼리 암시적으로 통신하다 보니 컴포넌트 실행 순서에 의존하게 된다.

 

2. 컴포넌트가 서로 참조하는 방식

ㄴ 컴포넌트 구체 클래스의 생성자로 참조할 컴포넌트 객체를 인수로 전달한다.

ㄴ 참조하는 컴포넌트들 끼리 강하게 결합된다.

 

3. 메세지를 전달하는 방식

ㄴ 컴포넌트가 컨테이너에 메세지를 보내면, 컨테이너는 자기에게 있는 모든 컴포넌트에 이를 전파한다.

ㄴ 메시징은 호출하고 나서 신경 안 써도 되는 사소한 통신에 쓰기 좋다. 예를 들어 물리 컴포넌트에서 객체가 무엇인가와 충돌했다고 전파하면 오디오 컴포넌트가 이를 받아서 소리를 내는 식이다.

ㄴ 상태 공유 방식에서처럼 상위 컨테이너 객체를 통해서 통신하기 때문에, 컴포넌트들은 메세지 값과 커플링되지만 하위 컴포넌트들끼리는 디커플링 상태를 유지한다. 

class Component {
public:
    virtual ~Component() {}
    virtual void receive(int message) = 0;
};

class ContainerObject { 
public:
	void send(int message) {
    	for (int i=0; i < MAX_COMPONENTS; i++){
        	if (components_[i] != NULL){
            	components_[i]->receive(message);
            }
        }
    }
private:
	static const int MAX_COMPONENTS = 10;
    Component* components_[MAX_COMPONENTS];
};

 

클래스 하나를 인스턴스 별로 다른 객체형으로 표현할 수 있게 만들어, 새로운 '클래스들'을 유연하게 만들 수 있게 한다.

 

Zombie - Monster, Dragon - Monster는 is-a 관게이다. 이런 관계를 전통적인 OOP에서는 클래스 상속으로 구현한다.

몬스터 종족의 수가 많아지면 코드를 추가, 수정하는데 비용이 많이 들게 된다.

 

Monster 클래스에서 종족 정보를 담는 Breed 클래스를 참조하게 만들면 해결할 수 있다.

Breed 객체는 설정 파일(json)에서 읽은 데이터로 생성한다.

 

타입 객체 패턴은 코드 수정 없이 새로운 타입을 정의할 수 있다는 게 장점이다. 코드에서 클래스 상속으로 만들던 타입 시스템의 일부를 런타임에 정의할 수 있는 데이터로 옮긴다.

 

13.3 패턴

타입 객체 클래스(Breed)와 타입 사용 객체 클래스(Monster)를 정의한다.

인스턴스 별로 다른 데이터는 타입 사용 객체 인스턴스에 저장하고,

개념적으로 같은 타입끼리 공유하는 데이터나 동작은  타입 객체에 저장한다.

 

13.4 언제 쓸 것인가?

나중에 어떤 타입(새로운 몬스터 종족)이 필요할지 알 수 없을 때

컴파일이나 코드 변경 없이 새로운 타입을 추가하거나 타입을 변경하고 싶을 경우

 

13.5 주의사항

타입 객체 패턴를 직접 관리해야 한다

상속 방식을 사용했을 때는 종족 클래스를 정의하는 데이터가 컴파일될 때 포함되는데,

타입 객체 패턴을 사용하면 종족 객체를 직접 만들고 초기화시켜 사용 객체 클래스(Monster)에 넘겨줘야 한다.

 

타입별로 동작을 표현하기가 더 어렵다

상속 방식에서는 메서드를 오버라이드해서 코드로 값을 계산하거나 다른 코드를 호출하는 등 마음대로 할 수 있다.

 

타입 객체 패턴에서는 종족 객체 변수에 데이터를 저장하는 방법으로 타입을 설정하기 때문에 동작을 정의하기는 어렵다.

우회적인 방법으로 바이트코드 패턴(11장)에서 하던 것처럼 동작 코드를 정의해놓은 뒤에 타입 객체 데이터에서 이 중 하나를 선택하는 것이다.

 

13.6 예제 코드

class Breed {
	public:
    	Monster* newMonster() {
        	// Monster에서 Breed가 friend로 선언되었기 때문에 생성자가 private이어도 가능하다.
        	// 팩토리 메서드 패턴의 생성자
            return new Monster(*this); 
        }
    
    	Breed(int health, const char* attack)
        : health_(health), attack_(attack) {}
        
        int getHealth() { return health_; }
        const char* getAttack() { return attack_; }
        
    private:
    	int health_;
        const char* attack_;
};

class Monster {
	friend class Breed;

	public:
        const char* getAttack() { return breed_.getAttack(); }
        
    private:
    	Monster(Breed& breed)
        : health_(breed.getHealth()), breed_(breed) {}
        
    	int health_;
        Breed& breed_;      
};
// 몬스터를 생성하는 코드
Monster* monster = someBreed.newMonster();

 

Breed 클래스에 생성자 함수 newMonster를 정의하면 Monster 클래스에 초기화 제어권을 넘겨주기 전에 메모리 풀이나 커스텀 힙에서 메모리를 가져올 수 있다. (위 예제의 return new Monster(*this)가 아닌 다른 방법으로 메모리를 얻을 수 있다는 뜻이다.)  몬스터를 생성할 수 있는 유일한 곳인 Breed 클래스 안에 이런 로직을 둠으로써, 모든 몬스터가 정해놓은 메모리 관리 루틴을 따라 생성되도록 강제할 수 있다.

 

타입 객체 클래스 간의 상속(프로그래밍 언어의 상속이 아님)으로 데이터 공유하기

Breed 클래스의 멤버로 Breed 객체(부모 Breed)를 추가한다. // Breed* parent_;

하위 객체는 데이터를 상위 객체로부터 받을지, 자기 값으로 오버라이드 할지 제어할 수 있어야 한다.

 

1. 속성 값을 요청받을 때마다 동적으로 위임하는 방식으로 구현

ㄴ 종족 속성 값이 런타임에 바뀔 때 사용 

 

2. 객체 생성 시점에 위임 (copy - down 위임)

ㄴ 종족 속성 값이 바뀌지 않을때 사용

 

13.7 디자인 결정

타입 객체 시스템의 사용자는 프로그래머가 아닌 경우가 많아서 이해하기 쉽게 만들어야 한다.

 

타입 객체를 숨길 것이가? 노출할 것인가?

숨기는 경우

ㄴ 타입 사용 객체는 타입 객체로부터 동작을 선택적으로 오버라이드할 수 있다.

ㄴ 타입 객체 메서드를 전부 포워딩해야 한다.

 

노출하는 경우

ㄴ 타입 사용 클래스 인스턴스를 통하지 않고도 외부에서 타입 객체에 접근할 수 있다.

ㄴ 타입 객체가 공개 API의 일부가 된다.

타입 사용 객체를 어떻게 생성할 것인가?

타입 사용 객체를 생성한 뒤에 타입 객체를 넘겨주는 경우 (Monster* monster = new Monster(someBreed) )

ㄴ 외부 코드에서 메모리 할당을 제어할 수 있다.

 

타입 객체의 '생성자' 함수를 호출하는 경우 (위 예제의 방법, Monster* monster = someBreed.newMonster() )

ㄴ 타입 객체에서 메모리 할당을 제어한다.

타입을 바꿀 수 있는가?

타입을 바꿀 수 없다면 

ㄴ 디버깅 하기 쉽다.

 

타입을 바꿀 수 있다면

ㄴ 객체 생성 횟수가 줄어든다. (바꿀 수 없다면 객체를 지우고 다시 만드는 방식으로 구현해야 하기 때문에)

 

상속을 어떻게 지원할 것인가?

상속 없음, 단일 상속, 다중 상속 중 선택할 수 있다.

상속이 많을수록 데이터 중복을 피할 수 있지만, 이해하기 복잡하고 런타임 낭비가 일어날 수 있다.

+ Recent posts