Home [게임 프로그래밍 패턴] Design Patterns Revisited: Flyweight
Post
Cancel

[게임 프로그래밍 패턴] Design Patterns Revisited: Flyweight

Flyweight

Forest for the Trees

  • 실시간 게임에서 거대한 삼림 지대를 구현하는것은 어렵다.
  • 수천 개의 폴리곤을 포함하는 상세한 지오메트리를 가진 수천개의 나무.
    • 해당 숲을 설명하기에 충분한 메모리가 있더라도, 렌더링할 때 데이터가 버스를 통해 cpu에서 gpu로 이동해야한다.
  • 각 나무는 연관된 비트가 있다.
    • 줄기, 가지, 등 다각형 mesh
    • 나무 껍질 등의 Texture
    • 숲속에서의 위치와 방향
    • 각 나무가 다르게 보이도록 크기 및 색조와 같은 매개변수
  • 코드로 나타내면 다움과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
class Tree
{
private:
  Mesh mesh_;
  Texture bark_;
  Texture leaves_;
  Vector position_;
  double height_;
  double thickness_;
  Color barkTint_;
  Color leafTint_;
};
  • 많은 데이터가 필요하며, 특히 메시와 텍스처가 크다.
  • 이 데이터들을 가지는 숲을 한 프레임에서 GPU에 주기에는 너무 크다.
  • 하지만 아래와 같은 특징을 가지고 있으며, 이를 해결할 트릭이 있다.

    • 수천 그루의 나무가 있어도, 대부분 비슷해보인다.
    • 그들은 모두 같은 메쉬, 텍스처를 사용한다.
    • 대부분의 필드가 모든 인스턴스간에 동일하다.

  • 객체를 반으로 분할하여 모델링할 수 있다.
1
2
3
4
5
6
7
class TreeModel
{
private:
  Mesh mesh_;
  Texture bark_;
  Texture leaves_;
};
  • 동일한 메시와 텍스처는 하나만 있어도 된다.
  • 이제 각 나무의 인스턴스는 공유된 참조 TreeModel을 가지고 있게된다.
  • 나머지 변수들은, 각 인스턴스의 상태이다.
1
2
3
4
5
6
7
8
9
10
11
class Tree
{
private:
  TreeModel* model_;

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

  • 이는 TypeObject 패턴과 유사하다.
  • 둘다 객체 상태의 일부를 여러 인스턴스 간에 공유되는 다른 객체에 위임하는것을 포함한다.
    • 하지만 의도는 다르다.
..
TypeObject“type”을 고유한 객체 모델로 들어올려 정의해야하는 클래스 수를 최소화, 그로부터 얻는 메모리 공유는 보너스
Flyweight순전히 효율성에 관한것
  • 메인 메모리에 물건을 저장하는데 모두 훌륭하지만, 렌더링에는 두움이 되지않는다.
    • 숲이 화면에 나타나기전에 GPU로 넘어가야한다.
    • 이 리소스 공유를 그래픽 카드가 이해하는 방식으로 표현해야한다.

A Thousand Instances

  • GPU에 푸시해야하는 데이터 양을 최소화하기 위해, 공유 데이터를 한번만 보낼 수 있어야한다.
    • 그 후, 개별적으로 모든 나무 인스턴스의 데이터를 푸시
    • 마지막으로, GPU에게 하나의 모델을 사용하여 렌더링하라고 지시해야한다.
  • 오늘날의 그래픽 API는 이를 지원한다.
  • 그래픽스 API는 두가지 데이터 스트림을 제공한다.
    • 공통 데이터 덩어리(메시, 텍스처)
    • 그릴때마다 첫번째 데이터 청크를 변경하는 데 사용되는 인스턴스 및 해당 매개변수의 목록.
  • 인스턴스 렌더링을 통해 한번의 드로우 콜로 전체 숲을 표현할 수 있다.

API가 그래픽카드에 의해 직접 구현된다는 사실은 Flyweight 패턴이 실제 하드웨어를 지원하는 유일한 GOF디자인 패턴일 수 있음을 의미한다.

The Flyweight Pattern

  • flyweight는 일반적으로 너무 많은 객체가 있기 때문에 더 가벼워야하는 객체가 있을때 사용한다.

    • 인스턴스 렌더링의 기본 아이디어와 동일하다.
  • 패턴은 객체의 데이터를 두 종류로 분리하여 문제를 해결한다.

    • 공유할 항목(intrinsic state, context-free): 메시, 텍스
    • 고유한 외부 상태: 나무의 위치, 크기, 색상
  • 공유상태에 대한 명확한 별도의 TreeModel을 제시할 수 있기 때문에 패턴이라고 부를 수 있다.(기본적으로 그저 자원공유처럼 보임)
  • 공유객체에 대해 제대로 정의된 ID가 없으면 이 패턴은 덜 명확해진다(더 영리해짐?).

A Place To Put Down Roots

  • 이 나무들이 자라는 땅은 바닥 타일기반이라고 가정.
    • world의 표면은 작은 타일의 거대한 그리드
    • 각 타일은 한 종류의 지형으로 덮여있다.
  • 지형 타입은 게임플레이에 영향을 준다.

    • 통과 속도를 결정하는 이동 비용
    • 보트를 이용할 수 있는지에 관한 플래그
    • 렌더링에 사용되는 텍스처
  • 효율적으로 처리하기위해, 지형 유형을 열거형으로 나타냄
1
2
3
4
5
6
7
enum Terrain
{
  TERRAIN_GRASS,
  TERRAIN_HILL,
  TERRAIN_RIVER
  // Other terrains...
};
  • world는 다음과 같은 거대한 그리드를 유지한다.
1
2
3
4
5
class World
{
private:
  Terrain tiles_[WIDTH][HEIGHT];
};

중첩배열을 사용하여 2D그리드를 저장하는것은 C/C++에서 효율적이다. Java 같은 메모리 관리 언어에서는 그렇지 않을 수 있다.(열배열에 대한 참조인 행 배열, C++은 연속된 묶음)

  • 좋지않은 코드는 타일에대한 데이터는 아래처럼 얻는다.
    • 단일 지형 유형에 대한 데이터가 여러 메서드에 걸쳐 번진다.
    • 캡슐화하는것이 좋다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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;
      // Other terrains...
  }
}

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;
      // Other terrains...
  }
}
  • 다음과 같이 클래스로 설정하는것이 좋다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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타입을 사용하는것은 어찌보면 당연하다. 동일한 객체가 여러 문맥에서 사용되기 때문에, 수정하면 변경사항이 동시에 반영된다. 이 때문에 flyweight 객체는 거의 항상 불변이다.

  • 그러나 world의 각 타일에 대한 인스턴스를 갖는 비용을 지불하지 않는 것이 좋다.
    • 모든 지형상태는 “intrinsic”이거나 “context-free” 상태여야한다.
    • 각 지형 타입을 둘이상 가질 필요가 없다.
    • 열거형 대신 객체에대한 포인터의 그리드를 Terrain로 삼아야한다.
1
2
3
4
5
6
7
class World
{
private:
  Terrain* tiles_[WIDTH][HEIGHT];

  // Other stuff...
};
  • 동일한 지형을 사용하는 각 타일은 동일한 지형 인스턴스를 가진다.

  • 지형 인스턴스는 여러 위치에서 사용되기 때문에, 동적으로 할당하는 경우 수명을 관리하기 복잡하다.
    • 그렇기 때문에, world에 직접 저장.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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_;

  // Other stuff...
};
  • 그런 다음, 이것들을 사용하여 다음과 같이 땅을 칠할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void World::generateTerrain()
{
  // Fill the ground with grass.
  for (int x = 0; x < WIDTH; x++)
  {
    for (int y = 0; y < HEIGHT; y++)
    {
      // Sprinkle some hills.
      if (random(10) == 0)
      {
        tiles_[x][y] = &hillTerrain_;
      }
      else
      {
        tiles_[x][y] = &grassTerrain_;
      }
    }
  }

  // Lay a river.
  int x = random(WIDTH);
  for (int y = 0; y < HEIGHT; y++) {
    tiles_[x][y] = &riverTerrain_;
  }
}
  • world에서 이제 Terrain 객체를 직접 노출할 수 있다.
1
2
3
4
const Terrain& World::getTile(int x, int y) const
{
  return *tiles_[x][y];
}
  • 타일의 일부 속성을 원하는 경우 해당 객체에서 바로 가져올 수 있다.
1
int cost = world.getTile(2, 3).getMovementCost();
  • 포인터를 사용하는것은 보통 열거형보다 오버헤드가 크지 않다.

Performance

  • 포인터로 지형을 참조 == 간접 조회(indirect lookup)를 의미
  • 포인터를 추적하면 캐시 미스가 발생하여 속도가 느려질 수 있다.

포인터 추적 및 개시 미스: 데이터 지역성

  • 최적화의 황금률: 프로파일 우선

    • 저자의 테스트로는, flyweight 을 사용하는데 불이익은 없었고, 성능은 좋았다.
    • 이러한 성능은 하지만 메모리 배치에 영향을 받는다.(그러므로 프로파일링)
  • 이 패턴의 장점은, 객체 지향 스타일의 이점을 제공한다는것.

참고

  • Factory Method 패턴:
    • 동적으로 생성: flyweight를 미리 만들고 싶지 않을 경우, 주문형으로
    • 인스턴스를 생성했는지 확인하고 해당 인스턴스 반환
    • 생성자를 숨기는 패턴
    • 객체 생성 시 생성하는 일 외에 다른 일이 동시에 필요하다면, 이를 관리하는 또 다른 객체를 만들어 생성하는 방법.
  • Object pool:
    • 이전에 생성된 flyweight를 반환하려면 이미 인스턴스화한 flyweight 풀을 추적해야함.
    • 오브젝트 풀은 이를 도와줌.
  • state 패턴:
    • 상태 기계에서 사용되는 상태 객체에 멤버변수가 하나도 없는 경우, flyweight을 적용하면, 객체 하나를 여러 상태 기계에서 재사용할 수 있다.

출처

flyweight

This post is licensed under CC BY 4.0 by the author.

[게임 프로그래밍 패턴] Design Patterns Revisited: Command

[게임 프로그래밍 패턴] Design Patterns Revisited: Observer