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을 적용하면, 객체 하나를 여러 상태 기계에서 재사용할 수 있다.