본문 바로가기
IT/Game

게임 프로그래밍 패턴 3장, 4장 (22. 05. 09)

by YEON-DU 2022. 6. 12.
반응형

Chapter3. 경량


어떠한 판타지 게임에서는 거대한 숲을 보여주는 장면이 있다.

이러한 숲을 그리기 위해서는 수천 그루가 넘는 나무마다 각각 수천 폴리곤의 형태로 표현해야 한다.

메모리가 충분하다해도, 이러한 숲을 그리기 위해서는 전체 데이터를 CPU에서 GPU로 버스를 통해 전달해야 한다.

나무마다 필요한 데이터는 아래와 같다.

  • 줄기, 가지, 잎의 형태를 나타내는 폴리곤 메시
  • 나무 껍질과 잎사귀 텍스처
  • 숲에서의 위치와 방향
  • 각각의 나무가 다르게 보이도록 크기와 음영 같은 값을 조절할 수 있는 매개변수
class Tree {
private:
    Mesh mesh_;
    Texture bark_;
    Vector position_;
    double height_;
    double thickness_;
    Color barkTint_;
    Color leafTint_;
};

데이터가 많은 데다가 메시와 텍스처는 크기도 크다.

이렇게 많은 객체로 이루어진 숲 전체는 1프레임에 GPU로 모두 전달하기에는 양이 너무 많다.

더게임툰 66화 [쉔무] 中 스즈키 유의 씨앗 이론 (Theory of Seeds).

다행히 검증된 해결책이 있다. 숲에 나무가 수천 그루 넘게 있다고 해도 대부분 비슷해 보인다는 점에서, 나무 객체에 들어있는 데이터 중 ‘모든 나무가 다 같이 사용하는 데이터’를 공유하도록 하는 것이다.

class TreeModel {
private:
    Mesh mesh_;
    Texture bark_;
    Texture leaves_;
};

게임 내에서 같은 메시와 텍스처를 여러 번 메모리에 올릴 필요가 전혀 없기 때문에 TreeModel 객체는 하나만 존재하게 된다.

각 나무 인스턴스는 공유 객체인 TreeModel을 참조하기만 한다.

Tree 클래스에는 인스턴스별로 다른 상태 값만 남겨둔다.

class Tree {
private:
    TreeModel* model_;

    Vector position_;
    double height_;
    double thickness_;
    Color barkTint_;
    Color leafTint_;
};

나무 인스턴스 4개가 모델 하나를 공유한다.

GPU로 보내는 데이터 양을 최소화하기 위해서는 공유 데이터인 TreeModel를 딱 한 번만 보낼 수 있어야 한다. 그런 후에 나무마다 값이 다른 위치, 색, 크기scale를 전달하고 마지막으로 GPU에 ‘전체 나무 인스턴스를 그릴 때 공유 데이터를 사용해’라고 말하면 된다.

다행히도 요즘의 그래픽 카드나 API에서는 이런 기능을 제공한다.

Direct3D, OpenGL 모두 인스턴스 렌더링instanced rendering을 지원한다.

인스턴스 렌더링을 하려면 데이터 스트림이 두 개 필요하다.

첫 번째 스트림에는 여러 번 렌더링되어야하는 공유 데이터가 들어간다.

두 번째 스트림에는 인스턴스 목록과, 이들 인스턴스를 첫 번째 스트림 데이터를 이용해 그릴 때 각기 다르게 보이기 위해 필요한 매개변수들이 들어간다.

경량 패턴

경량 패턴은 어떤 객체의 개수가 너무 많아서 좀 더 가볍게 만들고 싶을 때 사용한다.

인스턴스 렌더링에서는 메모리 크기보다는 렌더링할 나무 데이터를 하나씩 GPU 버스로 보내는 데 걸리는 시간이 중요하지만, 기본 개념은 경량 패턴과 같다.

이런 문제를 해결하기 위해 경량 패턴은 객체 데이터를 두 종류로 나눈다.

  1. 모든 객체의 데이터 값이 같아서 공유할 수 있는 데이터
    (GoF에서는 고유 상태intrinsic state라고 함.)
    예제에서는 나무 형태geometry나 텍스처가 이에 해당한다.
  2. 인스턴스 별로 값이 다른 외부 상태extrinsic state 데이터.

경량 패턴은 한 개의 고유 상태를 다른 객체에서 공유하게 만들어 메모리 사용량을 줄인다.

공유 객체가 명확하지 않은 경우 경량 패턴은 잘 드러나 보이지 않는다.

그런 경우에는 하나의 객체가 신기하게도 여러 곳에 동시에 존재하는 것처럼 보인다.

이에 대한 예시를 살펴보자.

게임에서 나무를 심을 땅을 표현한다고 생각해보자.

풀, 흙, 언덕, 호수, 강 같은 다양한 지형을 이어붙여서 땅을 만든다. 땅은 타일 기반으로 만들 것이다. 즉, 땅은 작은 타일들이 모여있는 거대한 격자인 셈이다.

지형 종류에는 여러 속성들이 들어있다.

  • 플레이어가 얼마나 빠르게 이동할 수 있는지를 결정하는 이동 비용 값
  • 강이나 바다처럼 보트로 건너갈 수 있는 곳인지 여부
  • 렌더링할 때 사용할 텍스쳐
enum Terrain P
    TERRAIN_GRASS,
    TERRAIN_HILL,
    TERRAIN_RIVER
    // 그 외 다른 지형들...
};

이제 월드는 지형을 거대한 격자로 관리한다.

class World {
private:
    Terrain tiles_[WIDTH][HEIGHT];
};

타일 관련 데이터는 다음과 같이 얻을 수 있다.

int World::getMovementCost(int x, int y) {
    switch (tiles_[x][y]) {
        case TERRAIN_GRASS: return 1;
        case TERRAIN_HILL: return 3;
        case TERRAIN_RIVER: return 2;
        // 그 외 다른 지형들..
    }
}

bool World::isWater(int x, int y) {
    switch (tiles_[x][y]) {
        case TERRAIN_GRASS: return false;
        case TERRAIN_HILL: return false;
        case TERRAIN_RIVER: return true;
        // 그 외 다른 지형들...
    }
}

이 코드는 동작하긴 하지만 지저분하다.

이동 비용이나 물인지 땅인지 여부는 지형에 관한 데이터인데 이 코드에서는 하드코딩되어 있다.

또한 같은 지형 종류에 대한 데이터가 여러 메서드에 나뉘어져 있다.

이런 데이터는 하나로 합쳐서 아래의 지형 클래스를 만드는 것처럼 캡슐화하는 것이 좋다.

class Terrain {
public:
    Terrain(int movementCost, bool isWater, Texture texture)
    : movementCost_(movementCost),
        isWater_(isWater),
        texture_(texture) {
    }

    int getMovementCost() const { return movementCost_; }
    bool isWater() const { return isWater_; }
    const Texture& getTexture() const { return texture_; }

private:
    int movementCost_;
    bool isWater_;
    Texture texture_;
};

하지만 타일마다 Terrain 인스턴스를 하나씩 만드는 비용은 피하고 싶다.

Terrain 클래스에는 타일 위치와 관련된 내용은 전혀 없는 것을 볼 수 있다.

즉, 모든 지형 상태는 ‘고유’하다.

따라서 지형 종류별로 Terrain 객체가 여러 개 있을 필요 없다.

지형에 들어가는 모든 클래스밭 타일은 전부 동일하다. 즉, World 클래스 격자 멤버 변수에 열거형이나 Terrain 객체 대신 Terrain 객체의 포인터를 넣을 수 있다.

Terrain 객체를 재사용하는 타일들

Terrain 인스턴스가 여러 곳에서 사용되나보니 동적으로 할당하면 생명주기를 관리하기가 좀 더 어렵다. 따라서 World 클래스에 저장한다.

class World {
public:
    World()
    : grassTerrain_(1, false, GRASS_TEXTURE),
        hillTerrain_(3, false, HILL_TEXTURE),
        riverTerrain_(2, true, RIVER_TEXTURE) {
    }

private:
    Terrain grassTerrain_;
    Terrain hillTerrain_;
    Terrain riverTerrain_;
    // 그 외...
};

void World::generateTerrain() {
    // 땅에 풀을 채운다.
    for (int x = 0; x < WIDTH; x++) {
        for (int y = 0; y < HEIGHT; y++) {
            // 언덕을 몇 개 놓는다.
            if (random(10) == 0) {
                tiles_[x][y] = &hillTerrain_;
            } else {
                tiles_[x][y] = &grassTerrain_;
            }
        }
    }

    // 강을 하나 놓는다.
    int x = random(WIDTH);
        for (int y = 0; y < HEIGHT; y++) {
            tiles_[x][y] = &riverTerrain_;
        }
}

이제 지형 속성 값을 World의 메서드 대신 Terrain 객체에서 바로 얻을 수 있다.

const Terrain& World::getTile(int x, int y) const {
    return *tiles_[x][y];
}

World 클래스는 더 이상 지형의 세부 정보와 커플링되지 않는다. 타일 속성은 Terrain 객체에서 바로 얻을 수 있다.

Chapter4. 관찰자


💡 객체 사이에 일 대 다의 의존 관계를 정의해두어, 어떤 객체의 상태가 변할 때 그 객체의 의존성을 가진 객체들이 그 변화를 통지 받고 자동으로 업데이트될 수 있게 만듭니다.

이를테면 게임에서 업적achievement 시스템을 추가한다고 해보자.

특정 기준을 달성하면 배지를 얻을 수 있는데 배지 종류가 수백개가 넘는다고 하자.

업적 종류가 광범위하고 달성할 수 있는 방법도 다양하다보니 깔끔하게 구현하기가 어렵다.

특정 기능을 담당하는 코드는 항상 한데 모아두는 것이 좋지만 업적을 여러 게임 플레이 요소에서 발생시킬 수 있다보니 이런 코드 전부와 커플링되지 않고도 업적 코드가 동작하게 하려면 어떻게 해야할까?

이럴 때 관찰자 패턴을 사용한다.

관찰자 패턴을 사용하면 어떤 코드에서 흥미로운 일이 생겼을 때 누가 받는 상관없이 알림을 보낼 수 있다.

void Physics::updateEntity(Entity& entity) {
    bool wasOnSurfase = entity.isOnSurface();
    entity.accelerate(GRAVITY);
    entity.update();
    if (wasOnSurface && !entity.isOnSurface()) {
        notify(entity, EVENT_START_FALL);
    }
}

이처럼 notify 함수는 ‘다리에서 떨어지기’라는 업적을 달성했다는 것을 알려주는 것이 전부이다.

(물리 엔진 코드와는 커플링되지 않게 작성되었기 때문에 물리 엔진 코드와 상관없이 업적 목록을 바꾸거나 떼어내기가 가능하다.)

작동 원리

다른 객체가 무얼 하는 지 관심이 많은 Observer 클래스를 만들어보자.

class Observer {
    public: virtual ~Observer() {}
    virtual void onNotify(const Entity& entity, Event event) = 0;
};

어떤 클래스든 Observer 인터페이스를 구현하기만 하면 관찰자가 될 수 있다.

업적 시스템을 만들기 위해서 다음과 같이 Observer를 구현한다.

class Achievements : public Observer {
public:
    virtual void onNotify(const Entity& entity, Event event) {
        switch (event) {
        case EVENT_ENTITY_FELL:
            if (entity.isHero() & heroIsOnBridge_) {
                uinlock(ACHIEVENTMENT_FELL_OFF_BRIDGE);
            }
            break;
            // 그 외 다른 이벤트를 처리하고
            // heroIsOnBridge_ 값을 업데이트한다.
        }
};

private:
    void unlock(Achievement achievement) {
        // 아직 업적이 잠겨있다면 잠금해제한다.
    }
    bool heroIsOnBridge_;
};

대상

알림 메서드는 관찰당하는 객체가 호출한다.

GoF에서는 이런 객체를 ‘대상subject’이라고 부른다.

대상에게는 두 가지 임무가 있다.

  • 알림을 기다리는 관찰자 목록을 갖고있는 일
  • 알림을 보내는 일

먼저 관찰자 목록을 갖고 있는 일의 예시를 살펴본다.

class Subject {
private:
    Observer* observers_[MAX_OBSERVERS];
    int numObservers_;
public:
    void addObserver(Observer* observer) {
        // 배열에 추가한다.
    }
    void removeObserver(Observer* observer) {
        // 배열에서 제거한다.
    }
    // 그 외
};

중요한 점은 관찰자 목록을 밖에서 변경할 수 있도록 public으로 열어놓는 것이다.

이를 통해 누가 알림을 받을 것인지를 제어할 수 있다.

대상은 관찰자와 상호작용하지만, 서로 커플링되어 있지 않다. 이것이 관찰자 패턴의 장점이다.

대상이 관찰자를 여러 개 목록으로 관리한다는 점도 중요하다.

자연스럽게 관찰자들은 암시적으로 서로 커플링되지 않게 된다.

오디오 엔진도 뭔가가 떨어질 때 적당한 소리를 낼 수 있도록 알림을 기다린다고 해보자.

대상이 관찰자를 하나만 지원한다면, 오디오 엔진이 자기 자신을 관찰자로 등록할 때 업적 시스템은 관찰자 목록에서 제거될 것이다.

관찰자를 여러 개 등록할 수 있게 하면 관찰자들이 각자 독립적으로 다뤄지는 걸 보장할 수 있다. 관찰자는 월드에서 같은 대상을 관찰하는 다른 관찰자가 있는지를 알지 못한다.

대상의 다른 임무인 알림 보내기는 아래와 같이 작성된다.

class Subject {
protected:
    void notify(const Entity& entity, Event event) {
        for (int i = 0; i < numObservers_; i++) {
            observers_[i]->onNotify(entity, event);
        }
        // 그 외..
};

물리 관찰

남은 작업은 물리 엔진에 훅hook을 걸어 알림을 보낼 수 있게 하는 일업적 시스템에서 알림을 받을 수 있도록 스스로를 등록하게 하는 일이다.

class Physics : public Subject {
public:
    void updateEntity(Entity& entity);
};

이렇게 하면 Subject 클래스의 notify() 메서드를 protected로 만들 수 있다.

Subject를 상속받은 Physics 클래스는 notify()를 통해서 알림을 보낼 수 있지만, 밖에서는 notify()에 접근할 수 없다.

반면, addObserver()와 removeObserver()는 public이기 때문에 물리 시스템에 접근할 수만 있다면 어디서나 물리 시스템을 관찰할 수 있다.

대상과 대상에서 관리하고 있는 관찰자 레퍼런스 목록

특정 인터페이스를 구현한 인스턴스 포인터 목록을 관리하는 클래스 하나만 있으면 간단하게 관찰자 패턴을 만들 수 있다.

그러나 관찰자 패턴에도 반대파들이 있다. 문제는 크게 두 가지이다.

  • 너무 느리다.
  • 동적 할당을 너무 많이한다.

관찰자 패턴은 느리다?

관찰자 패턴은 특히 ‘이벤트’, ‘메시지’, 심지어 ‘데이터 바인딩’과 함께 사용되어 부당한 평가를 받아왔다. 이런 시스템 중 일부는 알림이 있을 때마다 동적할당하거나 큐잉queuing하기 때문이 실제로 느릴 수 있다.

하지만 관찰자 패턴은 그냥 목록을 돌면서 필요한 가상 함수를 호출하면 알림을 보낼 수 있기 때문에 전혀 느리지 않다. 정적 호출보다야 약간 느릴 수 있지만 진짜 성능에 민감한 코드가 아니라면 이정도는 문제가 되지 않는다.

관찰자 패턴은 성능에 민감하지 않은 곳에 가장 잘 맞기 때문에, 동적 디스패치를 써도 크게 상관없다. 이 점만 제외하면 성능이 나쁠 이유가 없다. (인터페이스를 통해 동기적으로 메서드를 간접 호출할 뿐 메시징용 객체를 할당하지도, 큐잉을 하지도 않는다.)

사실 주의해야할 점은 관찰자 패턴이 동기적이라는 점이다.

대상이 관찰자 메서드를 직접 호출하기 때문에 모든 관찰자가 알림 메서드를 반환하기 전에는 다음 작업을 진행할 수 없다. 관찰자 중 하나라도 느리면 대상이 블록될 수도 있다.

이벤트에 동기적으로 반응한다면 최대한 빨리 작업을 끝내고 제어권을 다시 넘겨줘서 UI가 멈추지 않게 해야한다. 오래 걸리는 작업이 있다면 다른 스레드에 넘기거나 작업 큐를 활용해야 한다.

관찰자 패턴은 동적 할당을 너무 많이한다?

가비지 컬렉션이 있는 관리 언어managed language로 소프트웨어를 개발한다 해도 게임 같이 성능에 민감한 소프트웨어는 메모리 할당이 여전히 문제가 된다. 저절로 관리가 된다고는 하지만 메모리를 회수reclaim하다 보면 동적 할당이 오래걸릴 수 있다.

그러나 앞서 작성한 예제는 관찰자가 추가될 때만 메모리를 할당하고, 알림을 보낼 때는 메서드를 호출할 뿐 동적 할당은 전혀 하지 않는다. 즉 게임 코드가 실행될 때 처음 관찰자를 등록해놓은 뒤에 건드리지 않는다면 메모리 할당은 거의 일어나지 않는다.

동적할당을 하지 않고 관찰자를 등록, 해제하는 방법으로는 연결리스트, 리스트 노드 풀을 사용하는 방법이 있다.

class Observer {
    friend class Subject;

public:
    Observer() : next_(NULL) {}

    // 그 외..

private:
    Observer* next_;
};
class Subject {
    Subject() : head_(NULL) {}

public:
    void addObserver(Observer* observer) {
        observer->next_ = head_;
        head_ = observer;
    }

    void removeObserver(Observer* observer) {
        if (head_ == observer) {
            head_ = observer->next_;
            observer->next_ = NULL;
            return;
        }

        Observer* current = head_;
        while (current != NULL) {
            if (current->next_ = observer) {
                current->next_ = observer->next_;
                observer->next_ = NULL;
                return;
            }
            current = current->next_;
        }
    }
private:
    Observer* head_;

protected:
    void notify(const Entity& entity, Event event) {
        Observer* observer = head_;
        while (observer != NULL) {
            observer->onNotify(entity, event);
            observer = observer->next_;
        }
};

대상은 관찰자 연결 리스트를 포인터로 가리킨다.

이처럼 대상은 동적 메모리를 할당하지 않고도 얼마든지 관찰자를 등록할 수 있다.

남은 문제점들

관찰자 패턴은 간단하고 빠르며, 메모리 관리 측면에도 깔끔하게 만들 수 있다.

그렇다면 항상 관찰자 패턴을 사용해야할까?

→ 그것은 전혀 다른 얘기이다. 다른 디자인 패턴들처럼 관찰자 패턴이 만능은 아니다.

대상과 관찰자 제거

관찰자를 부주의하게 삭제하다보면 대상에 있는 포인터가 이미 삭제된 객체를 가리킬 수 있다.

해제된 메모리를 가리키는 무효 포인터dangling pointer에 알림을 보낼 수도 있다.

보통은 관찰자가 대상을 참조하지 않게 구현하기 때문에 대상을 제거하기가 상대적으로 쉽다.

그럼에도 대상이 삭제되면 더 이상 알림을 받을 수 없는데도 관찰자는 그런 줄 모르고 알림을 기다릴 여지가 있기 때문에, 문제가 생길 여지가 있다.

(스스로 관찰자라고 생각할 뿐, 대상에 추가되어있지 않은 관찰자는 절대로 관찰자가 아니다.)

대상이 죽었을 때 관찰자가 계속 기다리는 걸 막는 건 간단하다.

대상이 삭제되기 직전에 마지막으로 ‘사망’알림을 보내면 된다.

해당 알림을 받은 관찰자는 필요한 작업을 하면 된다.

관찰자는 제거하기가 더 어렵다. 대상이 관찰자를 포인터로 알고 있기 때문이다.

해결 방법 중 가장 쉬운 방법은 관찰자가 삭제될 때 스스로를 등록 취소하는 것 (소멸자에서 관찰 중인 대상의 removeObserver()를 호출)이다.

GC는 리스너 문제를 해결해주는가?

유저가 상태창을 열면 상태창 UI 객체를 생성한다.

상태창을 닫으면 UI 객체를 따로 삭제하지 않고 GC가 정리하도록 한다.

캐릭터의 체력이 달라지면 캐릭터를 관찰하던 UI창은 알림을 받아 체력바를 갱신한다.

이제 유저가 상태창을 닫을 때 관찰자를 등록 취소하지 않는다면 어떻게 될까?

UI는 더 이상 보이지 않지만 캐릭터의 관찰자 목록에서 여전히 상태창 UI를 참조하고 있기 때문에 GC가 UI 객체를 삭제하지 않는다.

심지어 유저가 상태창을 열 때마다 상태창 UI 객체를 새로 생성한다.

이는 알림 시스템에서 굉장히 자주 일어나는 ‘사라진 리스너 문제lapsed listener problem’이다.

대상이 리스너 레퍼런스를 유지하기 때문에, 메모리에 남아있는 좀비 UI 객체가 생긴다. 따라서 주의해서 리스너를 등록 취소 해주어야 한다.

버그 수정의 어려움

관찰자 목록은 두 코드 간의 결합을 최소화하기 위해서 사용된다.

그러나 버그가 생겼을 때 관찰자 목록을 통해서 코드가 커플링되어있다면 실제로 어떤 관찰자가 알림을 받는지는 런타임에서 확인해보는 수밖에 없다. 프로그램에서 정적으로는 알 수 없고, 명령 실행 과정을 동적으로 추론해야 한다.

오늘날의 관찰자

최신 방식으로 관찰자를 만드는 방식은 ‘무겁고 융통성 없는 인터페이스 상속’ 방식이 아니라, 메서드나 함수 레퍼런스만으로 ‘관찰자’를 만드는 것이 주요 방식이다.

미래의 관찰자

데이터 바인딩되는 관찰자 패턴이 대세가 될 것이다.

보통은 Observer가 observe하는 걸 ‘구독’의 개념에 비유하는 책이 많다.

구독해두었다가 새로운 글이 등록되면 (이벤트가 오면) 그 글에 따라서 행동 방향성을 정하는 것이다.

반응형

댓글