게임 루프 (Game Loop)

게임 시간 진행을 유저 입력, 프로세서 속도와 디커플링한다.

 

 

동기

게임 루프 패턴은 이름에서부터 알 수 있듯이 게임이 아닌 분야에서는 그다지 쓰이지 않는다. 사실상 게임을 위해 존재하는 패턴이다. 구현 내용이 다를 수 있지만 거의 모든 게임에서 사용한다.

 

while (true) {
    char* command = readCommand();
    handleCommand(command);
}

대화형 프로그램을 만들다 보면 자연스럽게 위와 같은 형태가 만들어지게 된다.

그런데 게임은 다른 프로그램과는 달리 유저의 입력이 없어도 계속 돌아간다. 루프에서 유저의 입력을 처리하지만 유저의 입력을 마냥 기다리고 있지 않다는 점에서 차이가 있다. 이것이 게임 루프의 첫 번째 핵심이다.

 

while (true) {
    processInput();
    update();
    render();
}

게임 루프의 기본적인 코드는 위와 크게 달라지지 않는다.

 

 

루프가 유저의 입력을 기다리지 않고 계속 돌아간다면 루프가 도는데 시간이 얼마나 걸리는지를 따져봐야 한다.

게임 입장에서는 루프를 도는것이 시간이 흐르는 것과 동일하다. 물론 그동안 현실세계의 시간도 흘러간다.

이를 측정한것이 초당 프레임 수(FPS)이다.

게임 루프가 빠르게 돌면 FPS가 높아져서 부드럽고 빠른 게임 화면을 볼 수 있지만 루프가 느리면 끊기는 화면을 보게된다.

 

별다른 제한을 두지 않으면 루프는 무조건 빠르게 돈다. 그런데 플랫폼마다 성능에 차이가 있기 때문에 루프의 실행 횟수가 일정하지 않은 문제가 발생한다. 플랫폼에 따라 게임의 경험이 달라지는 것이다.

 

어떤 하드웨어에서도 일정한 속도로 실행될 수 있도록 하는 것이 게임 루프의 두 번째 핵심이다.

 

 

패턴

게임 루프는 게임 내내 실행된다. 루프마다 멈춤 없이 유저 입력 처리, 게임 상태 업데이트, 게임 화면 렌더링을 수행하고 시간의 흐름에 따라 게임플레이 속도를 조절한다.

 

 

언제 쓸 것인가?

게임 루프 패턴은 다른 패턴과 다르게 "언제 쓸 것인가" 를 고민할 필요 없이 "무조건" 사용된다.

설령 내가 작성하지 않더라도 어딘가에는 게임 루프가 반드시 존재한다.

 

 

주의사항

게임 루프는 전체 코드 중에서도 가장 핵심이다. 그래서 최적화를 매우 깐깐하게 해야한다.

또한 플랫폼에 따라 해당 플랫폼의 이벤트 루프를 게임 루프로 사용해야 하는 경우도 있다.

 

 

예제

1) 최대한 빨리 달리기

while (true) {
    processInput();
    update();
    render();
}

처음 봤던 코드이다. 이 코드는 실행 속도를 제어할 수 없기 때문에 실행 환경에 따라 실행 속도가 다르다.

 

2) 한숨 돌리기

while (true) {
    double start = getCurrentTime();
    processInput();
    update();
    render();
    
    sleep(start + MS_PER_FRAME - getCurrentTime());
}

한 프레임이 빨리 끝나도 sleep에 의해 일정 시간 대기하게 되므로 실행 속도에 상한선이 생기지만 그보다 느려지는것은 막지 못한다.

 

3) 한 번은 짧게, 한 번은 길게

게임 루프의 문제는 결국 두가지이다.

 

1. 업데이트할 때마다 정해진 만큼 게임 시간이 진행되고,

2. 업데이트하는 데에는 현실 세계의 시간이 어느 정도 걸린다.

 

여기서 2번이 1번보다 오래 걸리면 게임이 느려지게 된다.

게임의 시간을 16ms 진행시키는데 현실 시간의 16ms보다 오래 걸리면 따라갈수가 없다.

그러면 한 번에 게임 시간을 16ms 이상 진행시킨다면 적어도 업데이트 횟수는 따라잡을 수 있다.

 

한 번에 게임 시간을 더 많이 진행시키려면 프레임을 고정적으로 16ms씩 처리하는것이 아니라 프레임 이후 실제 경과시간에 따라 간격을 가변적으로 잡으면 된다.

 

double lastTime = getCurrentTime();
while (true) {
    double current = getCurrentTime();
    double elapsed = current - lastTime;
    
    processInput();
    update(elapsed);
    render();
    
    lastTime = current;
}

매 프레임마다 직전 루프 이후 실제 경과 시간을 계산하고 그 값을 update에 넘겨주면 해당 시간만큼 게임 월드의 상태를 진행시킨다.

 

문제는 가변 시간 간격에 따른 연산 오차이다.

가변 시간 간격을 사용하면 어떤 오브젝트가 이동할 때, 업데이트 횟수에 상관없이 실제 시간동안 같은 거리를 이동하게 된다.

그런데 게임에서는 보통 부동 소수점을 쓰기 때문에 반올림 오차가 발생하기가 매우 쉽고 업데이트 횟수가 많을수록 오차가 더 크게 누적된다.

 

실행 환경에 관계 없이 실행 속도를 동일하게 제어하려고 적용한 가변 시간 간격이 연산 오차로 인해 서로 다른 결과를 발생시킬 수 있는 새로운 문제에 봉착하게 된다.

물리 엔진은 보통 실제 물리 법칙의 근사치를 취하게 되는데, 근사치가 튀는걸 막기 위해 감쇠를 적용하게 된다. 이 감쇠는 시간의 간격에 맞춰서 세심하게 조정해야 하지만 가변 시간 간격을 적용한다면 감쇠 값이 계속 바뀌어서 물리가 불안정해지게 되는 결과를 낳는다.

간단히 말해서 불안정성이 생긴다는 것이다.

 

4) 따라잡기

가변 시간 간격에 영향을 받지 않는 부분 중 하나는 렌더링이다.

모션블러같은 경우를 제외하고는 렌더링은 가변 시간을 고려하지 않고 고려할 필요도 없다.

그래서 물리나 AI 등은 고정 시간 간격을 적용하고 렌더링 같은것은 가변 시간을 적용하여 조금 더 유연하게 만든다.

 

double previous = getCurrentTime();
double lag = 0.0;

while (true) {
    double current = getCurrentTime();
    double elapsed = current - previous;
    previous = current;
    lag += elapsed;
    processInput();
    
    while (lag >= MS_PER_UPDATE) { // 시각적 프레임 레이트가 아님
        update();
        lag -= MS_PER_UPDATE;
    }
    render();
}

주의할 점은 고정 시간 간격을 너무 짧게 잡지 않는것이다. 적어도 update를 실행하는 데 걸리는 시간보다는 간격이 커야한다.

 

5) 중간에 끼는 경우

그런데 만약 렌더링이 두 업데이트 사이에 끼게 된다면 움직임이 튀어보일 수 있다.

다만 이제는 가변 시간 간격을 알기때문에 렌더링 함수의 인수로 (가변 시간 간격 / 업데이트 시간 간격) 을 넘겨주어 값을 보간하여 렌더링한다.

보간의 결과가 틀릴수도 있지만 별로 눈에 띄지도 않고 보간을 하지 않아서 움직임이 튀는 것보다 훨씬 낫다.

 

 

디자인 결정

대부분의 경우 게임 루프를 만들일은 거의 없다.

프레임워크부터 만들어 나간다면 모를까 웹 브라우저는 루프를 따로 만들지 못하도록 막혀있고 엔진을 사용한다면 엔진이 제공하는 게임 루프를 사용할 가능성이 매우 높다.

무엇을 사용하던간에 장단이 존재하지만 게임루프는 어쨌든 반드시 사용한다는 점이 핵심이다.

 

전력 소모에 관해서 잠시 얘기하자면 PC 게임은 성능도 중요하지만 품질 역시 중요하기 때문에 시간이 남으면 FPS나 그래픽 품질을 더 높이는데에 시간을 사용하는 경우가 많아서 전력은 언제나 최대치를 사용하게 된다.

반대로 모바일 게임은 품질보다 성능이 중요하기 때문에 FPS를 제한한다.

 

마지막으로 앞서 언급한 예제들을 정리한다.

 

◾ 동기화 없는 고정 시간 간격 방식

루프를 제어하지 않고 최대한 빠르게 실행한다. 구현이 간단하지만 실행 환경마다 실행 속도가 다르다.

 

◾ 동기화하는 고정 시간 간격 방식

루프의 끝에 지연이나 동기화를 추가하여 너무 빠르게 실행되는 것을 막는다. 전력의 효율이 높지만 게임이 너무 느려질 수도 있다.

 

◾ 가변 시간 간격 방식

환경에 관계 없이 맞춰서 플레이가 가능하다. 하지만 오차로 인해 게임을 불안정하고 비결정적으로 만들어버린다.

 

◾ 업데이트는 고정 시간 간격, 렌더링은 가변 시간 간격

대체로 가장 좋지만 구현이 복잡하다. 특히 업데이트 시간 간격을 잘 조정해야 한다.

이중 버퍼 (Double Buffer)

 

여러 순차 작업의 결과를 한 번에 보여준다.

 

연산 결과를 순차적으로 출력하지 않고 렌더링같이 한번에 출력해야 하는 경우 사용된다.

코드가 프레임 버퍼에 값을 쓰는 동안에 비디오 디스플레이가 프레임 버퍼의 값을 읽어버리면 이미지의 일부만 출력되는 테어링이 발생하게 된다.

 

프레임 버퍼를 두개 준비하여 입력과 출력을 서로 번갈아가며 반복하면 테어링이 발생하지 않게된다.

 

 

패턴

버퍼 클래스가 현재 버퍼와 다음 버퍼, 총 2개의 버퍼를 가진다.

버퍼를 읽을때는 항상 현재 버퍼를 읽고 버퍼에 쓸때는 항상 다음 버퍼에 쓴다. 이를 계속 반복한다.

 

 

언제 쓸 것인가?

  • 순차적으로 변경해야 하는 상태가 있다.
  • 이 상태는 변경 도중에도 접근 가능해야 한다.
  • 외부 코드에서는 작업 중인 상태에 접근할 수 없어야 한다.
  • 상태에 값을 쓰는 도중에도 기다리지 않고 바로 접근할 수 있어야 한다.

 

 

주의사항

버퍼의 교체 연산에는 짧더라도 시간이 걸리기때문에 교체 연산은 반드시 원자적이어야 한다.

대부분은 포인터만 교환하기 때문에 빠르지만 혹시라도 버퍼에 값을 쓰는 것보다 교환이 더 오래걸리면 이중 버퍼는 아무런 도움이 안된다.

 

또한 버퍼를 두개 사용하는 만큼 메모리를 더 사용하므로 메모리가 부족한 기기에서는 사용이 부담스러울 수 있다.

 

 

예제

class Framebuffer {
public:
    Framebuffer() { clear(); }
    void clear() {
        for (int i = 0; i < WIDTH * HEIGHT; ++i) {
            pixels_[i] = WHITE;
        }
    }
    void draw(int x, int y) {
        pixels_[(WIDTH * y) + x] = BLACK;
    }
    const char* getPixels() { return pixels_; }
    
private:
    static const int WIDTH = 160;
    static const int HEIGHT = 120;
    
    char pixels_[WIDTH * HEIGHT];
};


class Scene {
public:
    Scene() : current_(&buffers_[0]), next_(&buffers_[1]) {}
    void draw() {
        next_->clear();
        next_->draw(1, 1);
        // ...
        next_->draw(4, 3);
        swap();
    }
    Framebuffer& getBuffer() { return *current_; }
    
private:
    void swap() {
        // 버퍼 포인터 교체
        Framebuffer* temp = current_;
        current_ = next_;
        next_ = temp;
    }
    
    Framebuffer buffers_[2];
    Framebuffer* current_;
    Framebuffer* next_;
};

 

그래픽스 외에 물리나 인공지능같이 객체가 서로 상호작용할 때 이중 버퍼를 사용하면 도움이 될 수 있다.

어떤 상태를 변경하는 코드가 일괄적으로 변경하려는 상태를 읽을 때 말이다.

 

 

디자인 결정

버퍼 교체 연산은 원자적으로 이루어져야 한다. 그에 따라서 버퍼 교체 연산 작업중에는 읽거나 쓰는 작업이 불가능해진다.

 

 

버퍼에 남아있는 데이터는 직전 프레임이 아닌 2프레임 직전의 데이터라는것을 이해해야 한다.

일반적으로는 버퍼를 정리하기 때문에 문제가 되지 않지만 모션블러같이 버퍼의 기존 데이터를 섞어서 사용하는 경우가 존재한다.

 

또한 버퍼가 하나의 큰 덩어리(그래픽스)인지, 혹은 객체의 컬렉션 안에 분산(인공지능)되어 있는지에 따라 버퍼링의 성능이 달라진다.

전자의 경우 서로 바꾸기만 하면 되기 때문에 속도가 매우 빠르다. 하지만 후자의 경우 전체 컬렉션을 순회하며 알려주어야 하기 때문에 훨씬 더 느리다. 다만 단순한 경우라면 순회하며 교환하는 대신, 정적 함수의 플래그 변수를 통해 버퍼의 인덱스만 수정하면 최적화가 가능하다.

 

class Actor {
public:
    static void init() { current_ = 0; }
    static void swap() { current_ = next(); }
    
    void slap() { slapped_[next()] = true; }
    bool wasSlapped() { return slapped_[current_]; }
    
private:
    static int current_;
    static int next() { return 1 - current_; }
    
    bool slapped_[2];
};

 

이런 식으로 말이다.

상태 (State)

 

객체의 내부 상태에 따라 스스로 행동을 변경할 수 있게 허가하는 패턴으로, 이렇게 하면 객체는 마치 자신의 클래스를 바꾸는 것처럼 보입니다.

 

예제

플랫포머 게임에서 점프를 구현한다고 해보자.

 

void Heroine::handlerInput(Input input)
{
    if (input == PRESS_B) {
        yVelocity_ = JUMP_VELOCITY;
        setGraphics(IMAGE_JUMP);
    }
}

 

구현: B버튼을 누르면 점프를 한다.

문제점: 공중점프를 무한하게 할 수 있다.

 

void Heroine::handlerInput(Input input)
{
    if (input == PRESS_B) {
        if (!isJumping_)
            isJumping_ = true;
            yVelocity_ = JUMP_VELOCITY;
            setGraphics(IMAGE_JUMP);
        }
    }
}

 

개선: 플래그 변수를 추가해서 점프 상태를 판별하여 공중점프를 막는다.

 

이번에는 캐릭터가 땅에 있을 때 아래 버튼을 누르면 엎드리고 버튼을 떼면 일어나는것을 구현한다고 해보자.

 

void Heroine::handlerInput(Input input)
{
    if (input == PRESS_B) {
        if (!isJumping_) {
            isJumping_ = true;
            yVelocity_ = JUMP_VELOCITY;
            setGraphics(IMAGE_JUMP);
        }
    }
    else if (input == PRESS_DOWN) {
        if (!isJumping_) {
            setGraphics(IMAGE_DUCK);
        }
    }
    else if (input == RELEASE_DOWN) {
        setGraphics(IMAGE_STAND);
    }
}

 

구현: 점프 상태가 아닐 때, 아래키를 누르면 엎드리고 키를 떼면 일어선다.

문제점: 엎드린 상태에서 점프한 뒤, 공중에서 아래 버튼을 떼면 땅에 서있는 모습으로 보인다.

 

void Heroine::handlerInput(Input input)
{
    if (input == PRESS_B) {
        if (!isJumping_ && !isDucking_) {
            isJumping_ = true;
            yVelocity_ = JUMP_VELOCITY;
            setGraphics(IMAGE_JUMP);
        }
    }
    else if (input == PRESS_DOWN) {
        if (!isJumping_) {
            isDucking_ = true;
            setGraphics(IMAGE_DUCK);
        }
    }
    else if (input == RELEASE_DOWN) {
        isDucking_ = false;
        setGraphics(IMAGE_STAND);
    }
}

 

개선: 플래그 변수를 또 추가해서 상태를 판별하여 막는다.

 

코드가 하나씩 추가될때마다 구조가 크게 망가진다. 이런식으로 구현하다가는 끝이 없다.

 

 

유한 상태 기계(Finite State Machine)

캐릭터가 할 수 있는 동작과 조건을 플로 차트로 그리면 위와 같다. 이것이 유한 상태 기계이다.

유한 상태 기계의 요점은 다음과 같다.

 

◾ 가질 수 있는 '상태'가 한정된다.

◾ 한 번에 '한 가지' 상태만 될 수 있다.

◾ '입력'이나 '이벤트'가 기계에 전달된다.

◾ 각 상태에는 입력에 따라 다음 상태로 바뀌는 '전이'가 있다.

 

 

FSM의 상태는 주로 열거형으로 정의되고 다중 조건(switch-case)문으로 조건을 검사하여 상태를 변경한다.

 

enum State {
    STATE_STANDING,
    STATE_JUMPING,
    STATE_DUCKING,
    STATE_DIVING
};

void Heroine::handleInput(Input input)
{
    switch (state_) {
    case STATE_STANDING:
        if (input == PRESS_B) {
            state_ = STATE_JUMPING;
            yVelocity_ = JUMP_VELOCITY;
            setGraphics(IMAGE_JUMP);
        }
        else if (input == PRESS_DOWN) {
            state_ = STATE_DUCKING;
            setGraphics(IMAGE_DUCK);
        }
        ...
    }
};

 

계속 추가되는 플래그 변수 대신 상태를 나타내는 열거형 하나로 줄어들어서 코드 가독성이 조금 더 좋아진다.

 

만약 엎드린 상태라면 기를 모으고 특수공격을 할 수 있다고 해보자.

 

void Heroine::update()
{
    if (state_ == STATE_DUCKING) {
        chargeTime_++;
        if (chargeTime_ > MAX_CHARGE) {
            superBomb();
        }
    }
}

 

이 때는 열거형 뿐만 아니라 다른 변수를 추가로 사용하면 된다.

 

 

상태 패턴

◾ 상태 인터페이스

class HeroineState {
public:
    virtual ~heroineState() {}
    virtual void handleInput(Heroine& heroine, Input input) {}
    virtual void update(Heroine& heroine) {}
};

 

상태에 의존하는 모든 동작을 인터페이스의 가상 메서드로 만든다.

 

◾ 상태별 클래스 만들기

class DuckingState : public HeroineState {
public:
    DuckingState() : chargeTime_(0) {}
    virtual void handleInput(Heroint& heroint, Input input) {
        if (input == RELEASE_DOWN) {
            ...
            heroine.setGraphics(IMAGE_STAND);
        }
    }
    
    virtual void update(Heroine& heroine) {
        chargeTime++;
        if (chargeTime_ > MAX_CHARGE) heroine.superBomb();
    }
private:
    int chargeTime_;
};

 

switch-case문에서 case별로 존재하던 상태를 별도의 클래스로 만든다.

 

◾ 동작을 상태에 위임하기

class Heroine {
public:
    virtual void handleInput(Input input) { state_->handleInput(*this, input); }
    virtual void update() { state_->update(*this); }
    ...
private:
    HeroineState* state_;
};

 

선택문을 모두 제거하고 자신의 현재 상태는 HeroineState 객체 포인터에게 위임한다.

 

 

상태 객체는 어디에 둬야 할까?

열거형과 다르게 상태 패턴은 클래스를 사용하기 때문에 포인터에 담을 실제 인스턴스가 필요하다.

 

◾ 정적 객체

상태 객체에 chargeTime_ 같은 필드가 따로 없다면 인스턴스가 모두 동일하기 때문에 정적 인스턴스 하나만 만들어도 충분하다.(=경량 패턴)

만약 필드도 없고 가상 메서드도 하나밖에 없으면 상태 클래스 대신 정적 함수로 바꿔도 무관하다.

 

◾ 상태 객체 만들기

상태 객체에 필드가 존재한다면 각 캐릭터마다 필드의 값을 다르게 유지해야 하기 때문에 정적 객체만으로는 해결할 수 없다.

이때는 상태를 전이할때마다 새로운 상태 객체를 만들고 기존의 상태 객체는 해제한다.

 

void Heroine::handleInput(Input input)
{
    HeroineState* state = state_->handleInput(*this, input);
    if (state != nullptr) {
        delete state_;
        state_ = state;
    }
}

HeroineState* StandingState::handleInput(Heroine& heroine, Input input)
{
    if (input == PRESS_DOWN) {
        ...
        return new DuckingState();
    }
    ...
    return nullptr;
}

 

상태 전이가 일어날 때마다 할당과 해제가 이루어지므로 가능하면 정적 상태를 사용하는것이 좋을 것이다.

메모리 단편화가 걱정된다면 차후에 다룰 메모리 풀을 고려해본다.

 

 

상태 전이: 입장과 퇴장

HeroineState* DuckingState::handleInput(Heroint& heroint, Input input) {
    if (input == RELEASE_DOWN) {
        heroine.setGraphics(IMAGE_STAND);
        return new StandingState();
    }
    ...
}

 

상태가 변화할 때, 스프라이트는 새로운 상태가 아닌 이전 상태에서 변경되기 때문에 시기가 올바르지 않는다.

때문에 상태에서 스프라이트까지 제어하는 것이 더 바람직한 흐름 및 구조를 가지게 될 것이다.

 

class StandingState : public HeroineState {
public:
    virtual void enter(Heroine& heroine) { heroine.setGraphics(IMAGE_STAND); }
    virtual Heroine* handleInput(Heroine& heroine, Input input) { ... }
    ...
};


void Heroine::handleInput(Input input)
{
    HeroineState* state = state_->handleInput(*this, input);
    if (state != nullptr) {
        delete state_;
        state_ = state;
        state_->enter(*this); // 새로운 상태로 전이할 때 enter 호출
    }
}

 

상태가 전이할 때 전이되는 상태 객체에서 스프라이트를 변경시키기 때문에 이전 상태와 상관없는 코드로 작성이 되었다.

만약 상태가 전이되기 전에 퇴장 함수를 호출해서 변경해야 할 사항들이 있다면 객체를 삭제하기 전에 호출하면 된다.

 

 

단점

엄격하게 제한된 구조를 강제한다.

미리 정의해둔 여러 개의 상태와 현재 상태 하나, 그리고 하드코딩되어있는 전이만이 존재한다.

하지만 이 구조가 단점이면서 장점이 될 수도 있다.

 

 

상태 기계의 단점 극복: 병행 상태 기계

FSM은 오로지 하나의 상태만 가져야 하기 때문에 두 가지 상태가 병행해야 하는 경우라면 두 가지 상태를 병행하는 한가지 새로운 상태를 정의해야 한다.

그런데 이렇게 구현하는 경우에는 행위에 대한 상태가 N개, 소유에 대한 상태가 M개 있다고 했을 때, N*M개의 상태를 정의해야 한다.

상태의 종류가 무수히 많아지는것 뿐만 아니라 중복 코드도 무수히 많아지는 문제가 발생한다.

이럴때는 상태 기계를 둘로 나누면 된다.

 

class Heroine {
public:
    void handleInput(Input input) {
        state_->handleInput(*this, input);
        equipment_->handleInput(*this, input);
    }
private:
    HeroineState* state_;
    HeroineState* equipment_;
};

 

개념이 심플한 만큼 사용하는것도 심플하다.

대신 각각의 상태기계가 독립적이고 연관성이 없다는 보장이 있어야만 문제가 생기지 않을것이다.

점프 상태에서는 칼을 휘두를수 있지만, 엎드린 상태에서는 칼을 휘두를수 없다고 한다면 서로의 상태를 검사해야 하는 지저분한 코드가 작성될 여지가 있다.

 

 

상태 기계의 단점 극복: 계층형 상태 기계

서기, 걷기, 달리기, 미끄러지기 이런 행동에서는 공통적으로 점프나 엎드리기 같은 상태로 전이하는 공통된 코드가 작성되기 마련이다.

공통되는 코드를 묶어서 상위 상태로 만들고, 하위 상태는 상속받아서 고유 동작을 추가해서 상위 상태를 호출하면 된다.

 

class OnGroundState : public HeroineState {
public:
    virtual void handleInputHGeroine& heroine, Input input) {
        if (input == PRESS_B) { /* 점프 */ }
        else if (input == PRESS_DOWN) { /* 엎드리기 */ }
    }
};

class DuckingState : public OnGroundState {
public:
    virtual void handleInputHGeroine& heroine, Input input) {
        if (input == RELEASE_DOWN) { /* 서기 */ }
        else {
            OnGroundState::handleInput(heroine, input); // 상위 상태 호출
            // 필요시 추가 동작 구현
        }
    }
};

 

만약 상속을 통한 계층 구조 설계가 불가능하다면 주 클래스에 단일 상태 대신 상태 스택을 만들어서 연쇄적으로 모델링 할 수 있다.

 

 

상태 기계의 단점 극복: 푸시다운 오토마타

계층형 상태 기계에서 마지막에 언급한 상태 스택과는 다른 방식으로 상태 스택을 활용하여 FSM을 확장시킨다.

FSM은 현재 상태는 알 수 있지만, 직전 상태가 무엇인지 저장하지 않기 때문에 이력 개념이 없다.

이게 문제가 되는 경우를 예를 들어보자면, 캐릭터가 총을 발사하면 발사 애니메이션 재생과 총알 및 이펙트를 생성하는 상태를 새로 만들어서 전이하게 된다.

이 때, 총을 발사할 수 있는 상태가 한 개라면 문제가 되지 않지만 두 개 이상이면 이력이 저장되어 있지 않아서 사격이 끝나고 어떤 상태로 돌아가는지 알 수 없는 문제가 발생한다.

 

일반적인 FSM에서는 각각의 새로운 상태를 만들고 사격이 끝났을 때 되돌아갈 상태를 하드코딩 해야한다.

하지만 푸시다운 오토마타는 상태를 한 개의 포인터로 관리하는 것이 아니라 스택으로 관리하기 때문에 사격이 끝나면 현재 상태를 스택에서 제거하고 바로 다음 상태로 전이하면 된다.

 

계층형 상태 기계에서의 상태 스택은 상태 관련 동작을 최상위 스택부터 전달해서 처리될때까지 하위 단계로 내려보내는 방식이고 푸시다운 오토마타에서의 상태 스택은 이력 저장을 위해 사용한다는 차이가 있다.

 

 

상태 기계의 유용성

FSM을 개선한다고 해도 한계가 존재하기 때문에 요즘 AI는 행동 트리 또는 계획 시스템을 더 많이 사용한다.

그렇다고 상태 기계가 쓸모없다는 것은 아니고 간단한 상태에서는 유용하게 사용할 수 있다.

 

◾ 내부 상태에 따라 객체 동작이 바뀔 때

◾ 이런 상태가 그다지 많지 않은 선택지로 분명하게 구분될 수 있을 때

◾ 객체가 입력이나 이벤트에 따라 반응할 때

 

위와 같은 경우에 사용하면 좋다.

싱글턴 (Singleton)

 

오직 한 개의 클래스 인스턴스만을 갖도록 보장하고, 이에 대한 전역적인 접근점을 제공합니다.

 

어떤 식으로 써야하는지를 알아야 하는 다른 패턴과는 반대로 어떤 식으로 안써야하는지를 아는것이 더 중요한 패턴이다.

 

 

◾ 오직 한 개의 클래스 인스턴스만 갖도록 보장

아무데서나 클래스 인스턴스 여러개를 만들 수 없어야 한다.

 

◾ 전역 접근점을 제공

하나의 인스턴스만 생성하는 것에 더해서 전역에서 접근할 수 있는 메서드를 제공한다.

 

class FileSyetem {
public:
    static FileSystem& instance() {
        if (instance_ == nullptr) { // 게으른 초기화
            instance_ = new FileSystem();
        }
        return *instance_;
    }
private:
    FileSystem() {}
    static FileSystem* instance_;
};

 

class FileSystem {
public:
    static FileSystem& instance() {
        static FileSystem* instance = new FileSystem();
        return *instance;
    }
private:
    FileSystem() {}
};

 

요즘은 이렇게도 만든다.

 

 

왜 사용하는가?

◾ 한 번도 사용하지 않는다면 아예 인스턴스를 생성하지 않는다

 

◾ 런타임에 초기화된다

정적 클래스의 경우 정적 멤버 변수는 사용하지 않더라도 자동으로 초기화가 이루어진다. 또한 초기화 순서도 보장되지 않기 때문에 다른 정적 변수에 안전하게 의존할 수 없다.

하지만 싱글턴은 게으른 초기화 덕분에 순환 의존이 존재하지 않으면 괜찮다.

 

◾ 싱글턴을 상속할 수 있다

만약 파일 시스템 래퍼가 크로스 플랫폼을 지원해야 하는 경우라면 추상 인터페이스를 만들고 플랫폼 별로 구체 클래스를 만들면 된다.

 

class FileSystem {
public:
    static FileSystem& instance() {
    #if PLATFORM == PLAYSTATION3
        static FileSystem* instance = new PS3FileSystem();
    #elif PLATFORM == WII
        static FileSystem* instance = new WiiFileSystem();
        
        return *instance;        
    }
    virtual ~FileSystem() {}
    virtual char* readFile(char* path) = 0;
    virtual void writeFile(char* path, char* contents) = 0;
protected:
    FileSystem() {}
};

class PS3FileSystem : public FileSystem {
public:
    virtual char* readFile(char* path) { /* PS3 파일 시스템 사용 */ }
    virtual void writeFile(char* path, char* contents) { /* PS3 파일 시스템 사용 */ }
};

class WiiFileSystem : public FileSystem {
public:
    virtual char* readFile(char* path) { /* Wii 파일 시스템 사용 */ }
    virtual void writeFile(char* path, char* contents) { /* Wii 파일 시스템 사용 */ }
};

 

 

왜 문제인가?

◾ 전역 변수는 코드를 이해하기 어렵게 한다

순수 함수는 해당 함수의 코드와 매개변수만 확인하면 된다. 하지만 전역 변수나 함수에 접근한다면 해당 전역 변수와 함수에 접근하는 모든 곳을 다 살펴봐야만 상황을 파악할 수 있다.

 

◾ 전역 변수는 커플링을 조장한다

 

◾전역 변수는 멀티스레딩 같은 동시성 프로그래밍에 알맞지 않다

모든 스레드가 볼 수 있기 때문에 일종의 공유자원이 되므로 교착상태 등의 스레드 동기화 버그가 발생할 수 있다.

 

◾ 싱글턴은 문제가 하나뿐일 때도 두 가지 문제를 풀려 든다

아래와 같은 두 가지 문제가 있다고 하자.

  1. 인스턴스를 한개로 강제할 뿐, 전역 접근을 허용하지 않는다.
  2. 인스턴스는 여러개일 수 있지만, 전역 접근이 허용된다.

 

보통 2번의 경우에 싱글턴 패턴을 선택한다. 예를 들어 로그를 기록하는 클래스가 있다.

처음에는 별 문제가 되지 않다가 프로젝트의 규모가 커지면서 각자 필요한 정보를 로그로 남기다 보면 로그 파일이 뒤죽박죽 섞이게 된다.

이 시기쯤 되어서 로거를 나누려고 해도 싱글턴이기 때문에 인스턴스를 하나밖에 만들지 못하는 설계 제약이 발목을 잡는다.

 

◾ 게으른 초기화는 제어할 수 없다

지연 기법은 대체적으로 좋은 선택이지만 게임에서는 예외사항이 있다.

예를 들어 오디오 시스템의 초기화 시점이 최초로 소리가 재생될때라면 전투 도중에 초기화가 이루어지는 바람에 순간적으로 프레임이 떨어질 수 있다.

또한 오디오 시스템이 상당한 양의 메모리가 할당된다면 힙 어디에 메모리를 할당할지 제어할 수 있어야 하기 때문에 무조건 게으른 초기화가 이루어져선 안되고 적절한 초기화 시점을 찾아야 한다.

 

 

대안

싱글턴 클래스가 꼭 필요한지를 고려해봐야 한다.

애매하게 다른 객체 관리용으로만 존재하는 Manager, System, Engine 등의 관리자 클래스 인스턴스가 존재한다면 안에 존재하는 기능들을 원래 클래스에 구현해 두는것이 더 좋을 수도 있다.

객체가 스스로를 챙기는것이 OOP이기 때문이다.

 

 

인스턴스가 한개만 존재하길 바라면서 전역 접근을 허용하고 싶지 않은 경우에는 싱글턴 대신 생성자에 단언문을 넣어서 제어할 수 있다.

 

class FileSystem {
public:
    FileSystem() {
        assert(!instantiated_); // 단언문
        instantiated_ = true;
    }
    ~FileSystem() {
        instantiated_ = false;
    }
private:
    static bool instantiated_;
};

bool FileSystem::instantiated_ = false;

 

인스턴스가 최초 생성될때는 문제가 없지만 두 개째 생성부터는 단언문에 의해 코드 실행이 중지된다.

단일 인스턴스는 보장하면서 클래스를 어떻게 사용할지에 대해서는 제약이 없다.

다만 기존 싱글턴이 컴파일 타임에 단일 인스턴스를 보장하는 반면, 위의 방식은 런타임에 인스턴스 개수를 확인한다.

 

 

객체를 전역이 아니어도 접근할 수 있는 방법에 대해서도 고민을 해보아야 한다.

 

◾ 넘겨주기

전역으로 접근하는 것 보다 함수의 매개변수로 받아서 접근하는게 더 쉽고 최선인 경우가 있을수도 있다.

매개변수로 받는 객체가 동일한 인터페이스를 제공하는 경우가 이에 해당한다.

 

◾ 상위 클래스로부터 얻기

class GameObject {
protected:
    Log& getLog() { return log_; }
private:
    static Log& log_;
};

class Enemy : public GameObject {
public:
    void doSomething() {
        getLog().write("I can log!");
    }
};

 

파생 객체들이 공통된 단일 인스턴스를 사용해야 하는 경우라면 기본 클래스에 정적 데이터를 정의함으로써 인스턴스를 얻을 수 있다.

 

◾ 이미 전역인 객체로부터 얻기

전역 상태를 모두 제거한다는 것은 사실상 매우 어렵다. 결국 전체 게임 상태를 관리하는 Game이나 World같은 전역 객체와 커플링 될수밖에 없기 때문이다.

그대신 어쩔수 없이 존재하는 전역 객체에 빌붙어서 전역 클래스 숫자를 줄일 수 있다.

 

class Game {
public:
    static Game& instance() { return instance_; }
    
    Log& getLog() { return *log_; }
    FileSystem& getFileSystem() { return *fileSystem_; }
    AudioPlayer& getAudioPlayer() { return *audioPlayer_; }
    ...
private:
    static Game instance_;
    Log *log_;
    FileSystem* fileSystem;
    AudioPlayer* audioPlayer;
};

 

기존에는 FileSystem, AudioPlayer 등의 클래스가 전역 객체로 개별적으로 존재해야 했지만, 기존에 존재하는 전역 클래스인 Game의 멤버로 들어감으로써 전역 클래스 개수를 줄인다.

그만큼 더 많은 코드가 Game 클래스와 커플링 된다는 단점은 존재한다.

 

 

추후 싱글턴 패턴을 대체할 수 있는 샌드박스 패턴, 서비스 중개자 패턴을 다룬다.

프로토타입 (Prototype)

 

원형이 되는 인스턴스를 사용하여 생성할 객체의 종류를 명시하고, 이렇게 만든 견본을 복사해서 새로운 객체를 생성합니다.

 

다른말로 원형이라고도 부른다.

 

 

예제

핵앤슬래시 게임을 만들다고 해보자. 몬스터들은 스포너를 통해 게임에 스폰되고 몬스터별로 스포너가 존재한다.

 

class Monster {};
class Ghost : public Monster {};
...

class Spawner {
public:
    virtual ~Spawner() {}
    virtual Monster* spawnMonster() = 0;
};

class GhostSpawner : public Spawner {
public:
    virtual Monster* spawnMonster() { return new Ghost(); }
};
...

 

만약 이런식으로 구현하게 된다면 몬스터의 종류가 늘어나면 스포너도 그에 맞춰서 늘어나기 때문에 코드의 중복도 발생하므로 비효율적이다.

하지만 프로토타입 패턴을 적용하면 자신과 비슷한 객체를 스폰(복제)할 수 있기 때문에 개별적인 스포너를 추가하지 않아도 된다.

 

class Monster {
public:
    virtual ~Monster() {}
    virtual Monster* clone() = 0; // 복제를 위한 순수가상함수 추가
};

class Ghost : public Monster {
public:
    Ghost(int health, int speed) : health_(health), speed_(speed) {}
    virtual Monster* clone() { return new Ghost(health_, speed_); }
private:
    int health_;
    int speed_;
};

class Spawner {
public:
    Spawner(Monster* prototype) : prototype_(prototype) {}
    Monster* spawnMonster() { return prototype->clone(); }
private:
    Monster* prototype_;
};

 

몬스터별로 스포너를 만드는 대신 몬스터의 기본 클래스에 복제를 위한 인터페이스를 추가한다.

몬스터들은 동일한 인터페이스를 제공하기 때문에 스포너는 스폰(복제)할 원본 객체를 보관했다가 필요할 때 인터페이스를 통해 스폰하면 된다.

스포너에 있던 인터페이스가 몬스터로 옮겨갔다고 보면 될것같다.

 

Monster* ghostPrototype = new Ghost(15, 3); // 원형 객체 생성
Spawner* ghostSpawner = new Spawner(ghostPrototype); // 앞으로 health 15, speed 3의 객체 복제

 

프로토타입 패턴의 장점은 클래스뿐만 아니라 상태도 같이 복제한다는 점이다. 원형 객체를 생성할 때 설정된 상태 그대로 복제한다.

 

그래도 결과적으로는 코드 양이 크게 줄어들지는 않는다.

또한 요즘은 몬스터 종류별로 클래스를 만들기보다는 컴포넌트나 타입객체로 모델링 하는것이 더 선호된다.

 

다른 구현 방법으로는 함수 포인터, 템플릿, 일급 자료형을 사용하는 방법이 있다.

 

 

데이터 모델링을 위한 프로토타입

게임에서는 데이터 모델을 정의할 때, 보통 JSON을 많이 사용한다.

 

{
    "이름": "고블린 보병",
    "기본체력": 20,
    "최대체력": 30,
    "내성": ["추위", "독"],
    "약점": ["불", "빛"]
}

{
    "이름": "고블린 마법사",
    "기본체력": 20,
    "최대체력": 30,
    "내성": ["추위", "독"],
    "약점": ["불", "빛"]
    "마법": ["화염구", "번개 화살"]
}

{
    "이름": "고블린 궁수",
    "기본체력": 20,
    "최대체력": 30,
    "내성": ["추위", "독"],
    "약점": ["불", "빛"]
    "공격방법": ["단궁"]
 }

 

키/값 구조로 위와 같이 정의되어 있을 것이다. 하지만 중복되는 부분이 매우 많다.

 

{
    "이름": "고블린 보병",
    "기본체력": 20,
    "최대체력": 30,
    "내성": ["추위", "독"],
    "약점": ["불", "빛"]
}

{
    "이름": "고블린 마법사",
    "프로토타입": "고블린 보병",
    "마법": ["화염구", "번개 화살"]
}

{
    "이름": "고블린 궁수",
    "프로토타입": "고블린 보병",
    "공격방법": ["단궁"]
 }

 

추상 프로토타입을 만드는 대신 기존의 객체를 위임함으로써 코드 중복을 대폭 줄일 수 있다.

 

{
    "이름": "참수의 마법검",
    "프로토타입": "롱소드",
    "보너스대미지": "20"
}

 

기존 아이템에 추가옵션이 붙는 아이템의 경우에도 동일하게 적용할 수 있다.

 

 

프로토타입은 결과적으로 기존 객체를 복제하거나, 추상 클래스를 만드는 대신 위임을 통해 코드의 중복을 회피하는 기법이라고 볼 수 있을것 같다.

+ Recent posts