Update Method Pattern(업데이트 메서드 패턴)

2024. 6. 25. 16:36Game Develop/Design Pattern

업데이트 메서드 패턴은 컬렉션에 들어 있는 객체별로 한 프레임 단위의 작업을 진행하라고 알려줘서 전체를 시뮬레이션하는 방법이다.

 

용사가 보물을 찾기 위해 마왕성에 들어간다. 하지만 커다란 성문 앞에는 저주받은 가고일 석상과 흉측한 언데드 전사가 그 문 앞을 지키고 있다. 그냥 만들어 놓기만 하면 밋밋하니 언데드 병사부터 문 주위를 순찰하게 만들어 보자.

 

게임 프로그래밍을 전혀 모른다면 해골 병사가 왔다갔다하는 코드를 이렇게 작성할 것이다.

while(){
    // 오른쪽으로 간다.
    for(double x = 0; x < 100; x++) { skeleton.setX(x); }
    // 왼쪽으로 간다.
    for(double x = 100; x > 0; x--) { skeleton.setX(x); }
}

 

물론 이 코드는 무한루프가 있어 해골 병사가 순찰도는 걸 플레이어는 볼 수 없다는 문제가 있다(게임이 멈춘다는 뜻이다).

우리가 진짜 원하는 건 해골이 한 프레임에 한 걸음씩 걸어가는 것이다. 따라서 루프를 제거하고 외부 게임 루프를 통해서 반복하도록 해야한다. 이러면 해골병사가 순찰하는 동안에도 게임이 멈추지 않고 유저 입력에 반응할 수 있다.

 

Entity skeleton;
bool patrollingLeft = false;
double x = 0;

// 게임 메인 루프
while(true){
    if(patrollingLeft){
        x--;
        if (x == 0) patrollingLeft = false;
    }
    else{
        x++;
        if(x == 100) patrollingLeft = true;
    }
    skeleton.setX(x);
}

이전 코드보다 확실히 더 복잡해 졌다. 우선 가장 큰 차이는 patrollingLeft라는 변수가 생겼다는 것이다.

처음 코드는 단순 루프 두 개만 서서 좌우로 순찰했지만, 두 번째 코드는 매 프레임마다 외부 게임 루프로 나갔다가 직전 위치에서 다시 시작해야 하기 때문에 patrollingLeft 변수를 쓴 것이다.

 

이제 마법 석상을 두 개 추가해보자. 석상은 용사가 긴장감을 놓치지 않도록 계속해서 광선을 쏜다.

 

밑의 코드는 가장 간단하게 작성한 코드다.

// 해골 병사용 변수들...
Entity leftStatue;
Entity rightStatue;
int leftStatueFrames = 0;
int rightStatueFrames = 0;

while(true){
    // 해골 병사용 코드...
    
    if(++leftStatueFrames == 90){
        leftStatueFrames = 0;
        leftStatue.shootLightning();
    }
    if(++rightStatueFrames == 80){
        rightStatueFrames = 0;
        rightStatue.shootLightning();
    }
}

이런 식으로 작성하면 객체가 쌓일수록 유지보수하기가 점점 어려워진다. 메인 루프에서 각자 다르게 처리할 게임 개체용 변수와 실행 코드가 가득하기 때문이다.

 

위의 문제의 해결책은 간단하다. 모든 개체가 자신의 동작을 캡슐화하면 된다. 이러면 게임 루프를 어지럽히지 않고도 쉽게 개체를 추가 및 삭제할 수 있다.

 

이를 위해 추상 메서드인 update()를 정의해 추상 계층을 더한다.

 

게임루프는 매 프레임마다 객체 컬렉션을 쭉 돌면서 update()를 호출한다. 이때 각 객체는 한 프레임만큼 동작을 진행한다. 덕분에 모든 게임 객체가 동시에 동작한다.

 

게임 루프에는 객체를 관리하는 동적 컬렉션이 있어 컬렉션에 객체를 추가 및 삭제하기만 하면 레벨에 객체를 쉽게 넣었다 뺐다 할 수 있다.

 

 

위 처럼 예시를 들어 봤으니 본격적으로 이 업데이트 메서드 패턴을 언제 쓰는게 가장 효율적일까?

 

일단 플레이어와 상호작용하며 살아 움직이는 개체가 많은 게임에서는 업데이트 메서드 패턴을 어떻게든 쓰기 마련이다.

하지만 게임이 더 추상적이거나 게임에서 움직이는 것들이 살아있다기 보단 체스 같이 턴이 있다면 업데이트 메서드가 정답은 아니다. 

 

정리하자면

  • 동시에 동작해야 하는 개체나 시스템이 게임에 많다.
  • 각 객체의 동작은 다른 객체와 거의 독립적이다.
  • 객체는 시간의 흐름에 따라 시뮬레이션되어야 한다.

정도가 되겠다.

 

 

업데이트 메서드 패턴을 쓰기 위해서 필요한 몇 가지 주의 사항들이 있다.

 

코드를 한 프레임 단위로 끊어서 실행하는게 더 복잡하다

위의 예제 중 먼저 작성한 코드가 후자에 비해 훨씬 단순하다. 유저 입력 렌더링 등을 게임 루프가 처리하려면 후자처럼 만들어야하기 때문에, 첫 번째 코드는 작성할 일이 없다.

 

 

다음 프레임에서 다시 시작할 수 있도록 현재 상태를 저장해야 한다

첫 번째 코드에서는 변수가 없어도 스켈레톤의 이동 방향을 유추할 수 있었다.

 

하지만 두 번째 코드로 넘어가면서 이동 방향을 저장할 patrollingLeft 변수가 필요했다.

왜냐하면 코드가 반환하고 나면 이전 실행위치를 모르기 때문에 다음 프레임에서도 실행될 수 있도록 정보를 다시 전달해야하기 때문이다.

 

이럴때 저번에 했던 상태 패턴이 좋을 수 있다.

 

 

모든 객체는 매 프레임마다 시뮬레이션되지만 완전히 동시에 되는 건 아니다.

프로그래밍 언어는 대개 순차적으로 움직이기 때문에 한 프레임으로 실행된다고 하더라도 꼭 먼저 실행되는 부분이 있다.

 

가령 객체 목록에서 A가 B보다 앞에 있고 서로 상호작용해야한다고 가정하자.

A는 B와 상호작용하기 위해 B의 이전 프레임 상태를 보고 업데이트를 한다. B차례가 왔을 때 또한 A와 상호작용을 하려 봤더니 A의 상태는 이전 프레임이 아닌 현재 프레임 상태이게 된다. 플레이어에게는 차이가 없는 것처럼 보일 수 있으나 큰 차이가 있다. 다만 한 프레임 안에 전체를 다 도는 것 뿐이다.

 

이렇게 순차적으로 업데이트하면 편하다. 만약 객체를 병렬로 업데이트한다고 가정했을 때 체스같은 게임에서 둘 다 같은 위치로 같은 시간에 이동하려 한다면 상당히 난처해진다.

 

 

업데이트 도중에 객체 목록을 바꾸는 건 조심해야 한다

위의 내용의 연장선이라 보면 된다.

 

이 패턴에서는 많은 게임 동작이 업데이트 메서드 안에 들어가게 된다. 그중에는 업데이트 가능한 객체를 게임에서 추가, 삭제하는 코드도 포함된다.

 

만약 스켈레톤이 죽는다면 보통은 별 문제 없이 객체 목록 뒤에 새로 추가하면 된다. 하지만 이렇게 하고나서 업데이트를 순차적으로 돌다가 가장 마지막 객체를(스켈레톤이 죽어서 다시 생성한 스켈레톤) 업데이트하게 되면 플레이어는 새로 생성된 객체가 스폰된 걸 볼 틈도 없이 이미 프레임 안에서 작동하게 된다.

 

따라서 위 문제를 해결하기 위해서는 루프 시작 전에 목록에 있는 객체 개수를 미리 저장하고 그만큼만 업데이트하면 된다.

int numOjbectsThisTurn = numOjbects_;
for(int i = 0; i < numObjectsThisTurn; i++){
    objects_[i]->update();
}

 

이렇게하면 새로 생성된 객체 때문에 numObjects_(객체 목록의 크기)가 증가하기 때문에 이번 프레임에서 추가된 객체 앞에서 멈춘다.

 

사실 생성은 쉽지만 삭제하는게 더 어렵다. 만약 스켈레톤을 죽여싿면 객체 목록에서 스켈레톤을 빼야한다. 업데이트하려는 객체 이전에 있는 객체를 삭제할 경우, 의도치 않게 객체 하나를 건너뛰어 버릴 수 있다.

for(int i = 0; i < numObjectsThisturn; i++){
    obbjects_[i]->update();
}

 

 

위의 간단한 루프 코드에서는 매번 루프를 돌 대마다 업데이트되는 객체의 인덱스를 증가시킨다. 영웅을 업데이트할 차례가 됐을 때 배열 내용은 다음 그림과 같다.

영웅을 업데이트할 때 영웅이 스켈레톤을 죽였기 때문에 괴물은 배열에서 빠지게 되고 자연스레 농부도 2에서 1로 올라가게 된다.영웅을 업데이트하고 i값은 2로 증가했기 때문에 1번으로 당겨진 농부는 업데이트되지 않는다.

 

이를 고려해 객체를 삭재할 때는 순회 변수 i를 업데이트하는 것도 한 방법이다. 목록을 다 순회할 때까지 삭제를 늦추는 방법 또한 있지만, 객체가 죽었다는 걸 저장할 변수 하나가 더 필요하고 객체를 제거하기 위해 객체 목록을 한 번 더 돌아야한다는 비용이 발생한다.

 

 

 

자 위에서 설명했던 내용들을 토대로 본격적으로 만들어 보자. 우선 해골 병사와 석상을 구현할 Entity 클래스부터 만들어보자.

class Entity{
public:
    ENtity() : x_(0), y_(0) {}
    virtual ~Entity() {}
    virtual void update() = 0;
    
    double x() const { return x_; }
    double y() const { return y_; }
    
    void setX(double x) { x_ = x; }
    void setY(double y) { y_ = y; }
    
private:
    double x_;
    double y_;
};

우리가 가장 주목해야할 부분은 추상 메서드 update()다.

 

게임은 개체 컬렉션을 관리한다. 예제에서는 게임 월드를 대표하는 클래스에 개체 컬렉션 관리를 맡긴다.

class World{
public:
    World() : numEntities_(0) {}
    void gameLoop();
private:
    Entity* entities_[MAX_ENTITIES];
    int numEntities_;
};

이제 모든 것이 준비 됐으니 매 프레임마다 개체들을 업데이트하면 업데이트 메서드 구현이 끝난다.

 

void World::gameLoop(){
    while(true){
        // 유저 입력 처리...
        
        // 각 개체를 업데이트한다.
        for(int i = 0; i < numEntities_; i++){
            entities_[i]->update();
        }
        // 물리, 렌더링...
    }
}

 

순찰을 도는 해골 경비병과 번개를 쏘는 마법 석상을 정의해보자.

 

순찰을 정의하기 위해, update() 적당히 구현한 새로운 개체를 만들어보자.

class Skeleton : public Entity{
public:
    Skeleton() : patrollingLeft_(false) {}

    virtual void update(){
        if(patrollingLeft){
            setX(x() - 1);
            if(x() == 0) patrollingLeft_ = false;
        }
        else{
            setX(x_() + 1);
            if(x() == 100) patrollingLeft_ = true;
        }
    }
    
private:
    bool patrollingLeft_;
};

앞에서 만들었던 게임 루프의 코드를 거의 그대로 가져왔다. 사소한 차이라면 지역 변수였던 patrollingLeft_가 멤버 변수로 바뀐 정도다. 이렇게 함으로써 update() 호출 후에도 값을 유지할 수 있게 된다.

 

이번엔 석상 차례다.

class statue : public Entity{
public:
    Statue(int delay) : frames_(0), delay_(delay) {}
    
    virtual void update(){
        if(++frames_ == delay_){
            shootLightning();
            
            // 타이머 초기화
            frames_ = 0;
        }
    }
    
private:
    int frames_;
    int delay_;
    
    void shootLightning(){
        // 번개를 쏜다...
    }
};

위 또한 코드 대부분을 가져와 이름만 살짝 바꿨다. 오히려 더 단순해졌다.

 

이들 변수를 캡슐화했기 때문에 석상 인스턴스가 타이머를 각자 관리할 수 있어서 수가 많아도 관리하기 훨씬 수훨해 졌다.

이것이 업데이트 패턴을 활용하는 핵심 이유이다.

객체가 자신이 필요한 모든 걸 직접 들고 관리하기 때문에 게임 원들에 새로운 개체를 추가하기가 훨씬 쉬워진다.

 

여기까지가 업데이트 패턴의 핵심이다.

 

조금더 다듬어 보자면, 지금까지는 UPDATE()를 부를 때마다 게임 월드 상태가 동일한 고정 단위 시간만큼 진행된다고 가정하고 있었다. 그렇다면 가변 시간 간격을 쓰는 게임이라면? 이전 프레임에서 작업 진행과 렌더링에 걸린 시간에 따라 시간 간격을 크게 혹은 짧게 시뮬레이션해야한다. 

 

즉, 매번 UPDATE 함수는 얼마나 많은 시간이 지났는지 알아야하기 때문에 지난 시간을 인수로 받는다. 해골 경비병은 가변 시간 간격을 아래와 같이 처리한다.

void Skeleton::update(double elapsed){
    if(patrollingLeft_){
        x -= elapsed;
        if(x <= 0){
            patrollingLeft_ = false;
            x = -x;
        }
    }
    else{
        x += elapsed;
        if(x >= 100){
            patrollingLeft_ = true;
            x = 100 - (x - 100);
        }
    }
}

해골 병사의 이동거리는 지난 시간에 따라 늘어난다. 코드가 좀 복잡해 졌지만 그래도 뭐 크게 변한건 없는 것 같다.

다만, 업데이트 시간 간격이 크면 해골 경비병이 순찰 범위를 벗어날 수 있으니 주의해야 한다.

 

 

그렇다면 업데이트 메서드를 도대체 어느 클래스에 넣어두면 좋을까?

두 가지 후보가 있으니 상황에 맞게 활용하면 좋을 것 같다.

 

| 개체 클래스 |

이미 개체 클래스가 있다면 다른 클래스를 추가하지 않아도 된다는 점에서 가장 간단한 방법이다.

 

다만, 개체 종류가 많지 않다면 다행이지만, 많아지면 많아질수록 새로운 동작을 만들 때마다 개체 클래스를 상속해야한다.

그렇기 때문에 코드가 망가지기 쉽고 작업하기가 어렵다.

 

| 위임 클래스 |

클래스의 동작 일부를 다른 객체에 위임하는 것과 관련된 패턴이 더 있다. 상태 패턴은 상태가 위임하는 객체를 바꿈으로써 객체의 동작을 변경할 수 있게 해 준다.

타입 객체 패턴은 같은 종류의 여러 개체가 동작을 공유할 수 있게 해준다.

이들 패턴 중 하나를 쓰고 있다면 위임 클래스에 update() 두는 게 자연스럽다. 여전히 update() 메서드는 개체 클래스에 있지만 가상 함수가 아니며 다음과 같이 위임 객체에 포워딩만 한다.

void entity::update)_{
    // 상태 객체에 포워딩한다.
    state_->update();
}

 

새로운 동작을 정의하고 싶다면 위임 객체를 바꾸면 된다. 이러면 완전히 새로운 상속 클래스를 정의하지 않아도 동작을 바꿀 수 있는 유연성을 얻을 수 있다.

 

 

최종장으로 와서 휴면 객체 처리까지 알아보고 마치도록하겠다.

 

휴면 객체 처리

여러 이유로 일시적으로 업데이트가 필요 없는 객체가 생길 수 있다. 이런 객체가 많으면 매 프레임마다 쓸데없이 객체를 순회하면서 CPU 클럭을 낭비하게 된다.

 

한 가지 대안은 업데이트가 필요한 객체만 따로 컬렉션에 모아두는 것이다. 객체가 비활성화되면 컬렉션에서 제거하고 다시 활성화되면 컬렉션에 추가하는 것이다. 이렇게 하면 실제로 작업이 필요한 객체만 순회할 수 있다.

 

| 비활성 객체가 포함된 컬렉현 하나만 사용할 경우 |

  • 시간을 낭비한다. 비활성 객체의 경우 '활성 상태인지' 나타내는 플래그만 검사하거나 아무것도 하지 않는 메서드를 호출할 뿐이다.

 

| 활성 객체만 모여 있는 컬렉션을 하나 더 둘 경우 |

  • 두 번째 컬렉션을 위해 메모리를 추가로 사용해야 한다. 전체 개체를 대상으로 작업해야 할 수도 있기 때문에 모든 개체를 모아놓은 마스터 컬렉션도 있기 마련이다. 이때 활성 객체 컬렉션은 엄밀히 말하자면 중복 데이터이다. 메모리보다 속도가 중요하다면 상관없다.
  • 컬렉션 두 개의 동기화를 유지해야 한다. 객체가 생성되거나 (잠시 비활성화된 게 아니라) 완전히 소멸되면 주 컬렉션과 활성 객체 컬렉션 둘 다 변경해야 한다.

위의 두 가지 방법 중 하나를 선택하기 위한 조건은 얼마나 많은 객체가 비활성 상태로 남아 있는가에 따라 방법을 결정하면 된다. 비활성 객체가 많을수록 컬렉션을 따로 두는 게 좋다.