기록공간

2장. Flyweight Pattern (경량 패턴) 본문

Game Design Pattern, Logic

2장. Flyweight Pattern (경량 패턴)

입코딩 2020. 2. 3. 21:41
반응형

 


"공유(Sharing)를 통해 많은 수의 소립(Fine-Grained) 객체들을 효과적으로 지원합니다."

(GoF의 디자인 패턴 311p)


숲에 들어갈 나무들

우선 경량 패턴이 어떠한 상황에 쓰이는지부터 알아보도록 한다. 일반적으로 나무가 울창하게 있는 숲을 구현할 때에는 '경량' 패턴으로 종종 구현하게 된다. 구비구비 뻗어 있는 숲을 글로는 몇 문장으로 표현이 가능하지만, 실시간 게임으로 구현하는 것은 전혀 다른 이야기이다. 

 

숲이다 보니 수천 그루가 넘는 나무마다 각각 수천 폴리곤의 형태로 표현해야 한다. 설사 메모리가 충분하다고 해도, 이런 숲을 그리기 위해서는 전체 데이터를 CPU에서 GPU로 전달해야 한다. 나무마다 필요한 데이터를 코드로 표현하면 다음과 같다.

class Tree 
{
private:
    Mesh mesh_;		// 나무를 나타낼 폴리곤 메쉬
    Texture bark_;	// 나무 껍질과 잎사귀 텍스처
    Texture leaves_;
    Vector position_;	// 숲에서의 위치와 방향
    coutble height_;	// 각각의 나무가 다르게 보이도록 해주는 크기나 음영 같은 값들
    double thickness;
    Color barkTint_;
    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로 전달해야 한다. 어떤 식으로 자원을 공유하고 있는지를 그래픽 카드도 이해할 수 있는 방식으로 표현해야 한다. 


수천 개의 인스턴스

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

 

다행히, 요즘 나오는 그래픽 카드나 API에서는 이러한 기능을 제공한다. 예를 들면 Direct3D, OpenGL 모두 인스턴스 렌더링(Instanced Rendering)을 지원한다.

 

이들 API에서 인스턴스 렌더링을 하려면 데이터 스트림이 두 개 필요하다. 첫 번째 스트림에는 메시나 텍스처와 같은 여러 번 렌더링되어야 하는 공유 데이터가 들어간다. 두 번째 스트림에는 인스턴스 목록과, 각기 다르게 보이기 위해 필요한 매개변수들이 들어간다. 이제 그리기 호출 한 번만으로 전체 숲을 다 그릴 수 있게 되었다.


경량 패턴

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

 

앞서 살펴봤듯이, 경량 패턴은 객체 데이터를 두 종류로 나눈다. 먼저 모든 객체의 데이터 값이 같아서 공유할 수 있는 데이터를 모은다. 이런 데이터를 GoF는 고유 상태(Intrinsic State)라고 한다.

 

나머지 데이터는 인스턴스별로 값이 다른 외부 상태(Extrinsic State)에 해당한다. 경량 패턴은 한 개의 고유 상태를 다른 객체에서 공유하게 만들어 메모리 사용량을 줄이고 있다. 

 

공유 객체가 명확하지 않은 경우 경량 패턴은 잘 드러나 보이지 않는다. 그런 경우에는 하나의 객체가 신기하게도 여러 곳에 동시에 존재하는 것처럼 보인다. 다음 예를 한번 들어보겠다.


지형 정보

나무를 심을 땅도 게임에서 표현해야 한다. 보통 풀, 흙, 언덕, 호수, 강 같은 다양한 지형을 이어 붙여서 땅을 만든다. 땅을 타일 기반으로 만든다고 생각해보자. 어찌보면 땅은 작은 타일들이 모여 있는 거대한 격자와 같다. 모든 타일은 지형 종류 중 하나로 덮여 있다. 지형 종류에는 게임 플레이에 영향을 주는 여러 속성이 들어있다. (이동 가능여부, 텍스처, 이동 비용 등) 지형 종류를 열거형을 사용하여 표현하면 다음과 같다.

enum Terrain
{
    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;
    // 그외 다른 지형들...
    }
}

int 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_; }
    int isWater() const {return isWater_; }
    const Texture& getTexture() const { return texture_; }
    
private:
    int movementCost_;
    bool isWater;
    Texture texture_;
}

하지만 타일마다 Terrain 인스턴스를 하나씩 만드는 비용은 피하고 싶다. 현재 Terrain 클래스에는 타일 위치와 관련된 내용은 전혀 없는 것을 볼 수 있다. 경량 패턴식으로 얘기하자면 모든 지형 상태는 '고유'하다. 

 

따라서 지형 종류별로 Terrain 객체가 여러 개 있을 필요가 없다. 예를들면 지형에 들어가는 모든 강 타일은 전부 동일하다. World 클래스 격자 멤버 변수에 열거형이나 Terrain 객체 대신 Terrain 객체의 포인터를 넣을 수 있다. 지형 종류가 같은 타일들은 모두 같인 Terrain 인스턴스 포인터를 갖는다.

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

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 객체에서 바로 얻을 수 있다.

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

성능에 대해서...

지형 데이터를 포인터로 접근한다는 것은 간접 조회 한다는 뜻이다. 이동 비용 같은 지형 데이터 값을 얻으려면 먼저 격자 데이터로부터 지형 객체 포인터를 얻은 후, 포인터를 통해서 이동 비용 값을 얻어야 한다. 이렇게 포인터를 따라가면 캐시 미스가 발생할 수 있어 성능이 조금 떨어질 수는 있다.

 

최적화의 황금률은 언제나 먼저 측정하는 것이다. 최신 컴퓨터 하드웨어는 너무 복잡해서 더이상 추측만으로는 최적화하기 어려워졌다. 결론만 말하자면 경량 패턴을 쓰는것이 열거형에 비해 훨씬 빨랐다. (객체가 메모리에 어떤 식으로 배치되느냐에 따라 달라질 수는 있다.)

 

확실한 것은 경량 객체를 한 번은 고려해봐야 한다는 점이다. 경량 패턴을 사용하면 객체를 마구 늘리지 않으면서도 객체지향 방식의 장점을 취할 수 있다. 열거형을 선언해 수많은 switch문을 만들 생각이라면, 경량 패턴을 고려해보자. 성능이 걱정된다면, 유지보수하기 어려운 형태의 코드를 고치기 전에 성능 분석이라도 먼저 해보자.


자료의 출처는 http://gameprogrammingpatterns.com/flyweight.html입니다.

반응형
Comments