소년코딩

경량 패턴, Flyweight Pattern

공유를 통해 많은 수의 소립 객체들을 효과적으로 지원합니다. (GoF의 디자인 패턴 265p)

'오래된 숲이 모습을 드러낸다.' 게임에서 흔히 볼 수 있는 설정이다. 일반적으로 이런 장면은 '경량패턴'으로 종종 구현한다.


숲에 들어갈 나무들

나무들이 화면을 가득 채운 빽뺵한 숲을 볼 때, 그래픽스 프로그래머는 1초에 60번씩 GPU에 전달해야 하는 몇백만 개의 폴리곤을 본다.

수천 그루가 넘는 나무마다 각각 수천 폴리곤의 형태로 표현해야 한다. 설사 메모리가 충분하다고 해도, 이런 숲을 그리기 위해서는 전체 데이터를 CPU에서 GPU로 버스를 통해 전달해야 한다.

나무마다 필요한 데이터는 다음과 같다.

  • 줄기, 가지, 잎의 형태를 나타내는 폴리곤 메시
  • 나무 껍질과 잎사귀 텍스처
  • 숲에서의 위치와 방향
  • 각각의 나무가 다르게 보이도록 크기와 음영 같은 값을 조절할 수 있는 매개변수

코드로 표현하면 다음과 같다.

class Tree {
private:
    Mesh mesh;       // 메시
    Texture bark;    // 나무껍질 텍스처
    Texture leaves;  // 잎사귀 텍스처
    Vector position;
    double height;
    double thickness;
    Color barTint;
    Color leafTint;
};

데이터가 많은데다가 메시와 텍스처는 크기도 크다. 이렇게 많은 객체로 이루어진 숲 전체는 1프레임에 GPU로 모두 전달하기에는 양이 너무 많다. 다행히 검증된 해결책이 있다.

핵심은 숲은 나무에 수천그루 넘게 있다고 해도 대부분 비슷해 보이므로 모든 나무를 같은 메시와 텍스처로 표현할 수 있다는 것이다. 즉, 나무 객체에 들어 있는 데이터 대부분이 인스턴스별로 다르지 않다는 뜻이다.

객체를 반으로 쪼개어 이런 점을 명시적으로 모델링할 수 있다. 모든 나무가 다 같이 사용하는 데이터를 뽑아내 새로운 클래스에 모아보자.

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;
};

수천 개의 인스턴스

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

요즘 나오는 그래픽 카드나 API는 이런 기능을 제공한다. (Direct3D, OpenGL 모두 인스턴스 렌더링을 지원한다.) 이들 API에서 인스턴스 렌더링을 하려면 데이터 스트림이 두 개 필요하다. 첫 번째 스트림에는 숲 렌더링 예제의 메시나 텍스처처럼 여러 번 렌더링 되어야 하는 공유 데이터가 들어간다. 두 번째 스트림에는 인스턴스 목록과, 이들 인스턴스를 첫 번째 스트림 데이터를 이용해 그릴 때 각기 다르게 보이기 위해 필요한 매개변수들이 들어간다.


경량 패턴

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

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

이런 문제를 해결하기 위해 경량 패턴은 객체 데이터를 두 종류로 나눈다. 먼저 모든 객체의 데이터 값이 같아서 공유할 수 있는 데이터를 모은다. 이런 데이터를 GoF는 고유 상태 또는 자유 문맥 상태라고 부른다. 예제에서는 나무 형태나 텍스처가 이에 해당한다.

나머지 데이터는 인스턴스별로 값이 다른 외부 상태에 해당한다. 예제에서는 나무의 위치, 크기, 색 등이 이에 해당한다.

위에서 봤듯이, 경량 패턴은 한 개의 고유 상태를 다른 객체에서 공유하게 만들어 메모리 사용량을 줄일 수 있다.


지형 정보

나무를 심을 땅도 게임에서 표현해야 한다. 보통 풀, 흙, 언덕, 호수, 강 같은 다양한 지형을 이어 붙여서 땅을 만든다. 여기에서는 땅을 타일 기반으로 만들 것이다. 즉, 땅은 작은 타일들이 모여 있는 거대한 격자인 셈이다. 모든 타일은 지형 종류 중 하나로 덮여 있다.

지형 종류에는 게임플레이에 영향을 주는 여러 속성이 들어 있다. 이들 속성을 지형 타일마다 따로 저장하는 일은 있을 수 없다. 대신 지형 종류에 열거형을 사용하는 게 일반적이다.

enum Terrain {
    TERRAIN_GRASS,
    TERRAIN_HILL,
    TERRAIN_RIVER
    // 그 외 다른 지형들...
};

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

class Word {
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_;
};

// 모든 메서드를 const로 만든 이유는 Terrain 객체를 여러 곳에서 공유해서 쓰기 때문에, 한 곳에서 값을 바꾼다면 그 결과가 여러 군데에서 동시에 나타나기 때문이다.
// 메모리를 줄여보겠다고 객체를 공유햇는데 그게 코드에 영향을 미쳐서는 아뇐다. 이런 이유로 경량 객체는 변경 불가능한(immutable) 상태로 만드는 게 보통이다.

하지만 타일마다 Terrain 인스턴스를 하나씩 만드는 비용은 피하고 싶다. Terrain 클래스에는 타일 위치와 관련된 내용은 전혀 없다. 경량 패턴식으로 보면 모든 지형 상태는 고유 하다. 즉, 자유 문맥에 해당한다.

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

class World {
private:
    Terrain* tiles[WIDTH][HEIGHT];
    // ...
};

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

class World {
public:
    World()
    : glassTerrain_(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 객체에서 바로 얻을 수 있다.

int cost = world.getTile(2, 3).getMovmentCost();

열거형을 선언해 수많은 다중 선택문(switch)을 만들 생각이라면, 경량 패턴을 먼저 고려해보자. 성능이 걱정된다면, 유지보수하기 어려운 형태로 코드를 고치기 전에 적어도 프로파일링이라도 먼저 해보자.


디자인패턴

by 소년코딩

추천은 글쓴이에게 큰 도움이 됩니다.

악플보다 무서운 무플, 댓글은 블로그 운영에 큰 힘이됩니다.

신고
댓글 로드 중…

트랙백을 확인할 수 있습니다

URL을 배껴둬서 트랙백을 보낼 수 있습니다