2024. 6. 4. 13:29ㆍGame Develop/Design Pattern
경량 패턴은 GoF의 세가지 패턴 중 구조 패턴에 속한다.
구조 패턴은 클래스나 객체들을 조합하여 더 큰 구조로 만드는 패턴이다.
경량 패턴의 특징은 다음과 같다.
- 인스턴스가 필요할 때마다 매번 생성하는 것이 아니고 가능한 공유해서 사용함으로써 메모리를 절약함
- 다수의 유사 객체를 생성하거나 조작할 때 유용하게 사용할 수 있음
굽이굽이 뻗어 있는 숲을 글로는 몇 문장으로 표현할 수 있지만, 실시간 게임으로 구현하는 것은 전혀 다른 얘기다.
아마 그래픽스 프로그래머는 1초에 60번씩 GPU에 전달해야 하는 몇 백만 개의 폴리곤을 볼것이다.
나무마다 필요한 데이터는 다음과 같다.
- 줄기, 가지, 잎의 형태를 나타내는 폴리곤 메시
- 나무 껍질과 잎사귀 텍스처
- 숲에서의 위치와 방향
- 각각의 나무가 다르게 보이도록 크기와 음영 같은 값을 조절할 수 있는 매개변수
위를 코드로 표현해보자.
class Tree{
private:
Mesh mesh_;
Texture bark_;
Texture leavese_;
Vector position_;
double height_;
double thickness_;
Color barkTint_;
Color leafTint_;
};
나무 하나 마다 이 데이터를 담는 객체를 생성하기에는 비용이 무지막지하게 많이 든다. 따라서 이 문제를 해결하기 위한 핵심은 숲에 나무가 수천 그루 넘게 있다고 해도 대부분 비슷해 보인다는 점이다. 이 말인 즉슨 같은 메시와 텍스처로 표현한다면 나무 객체에 들어 있는 데이터 대부분이 인스턴스별로 다르지 않게 된다는 말이다.
이제 위의 Tree 클래스를 모든 나무가 지닌 공통점과 나무 마다 다르게 지니는 데이터를 기준으로 반으로 나눠줄 수 있다.
class TreeModel{ // 모든 나무가 지니는 공통점(메시 및 텍스쳐)
private:
Mesh mesh_;
Texture bark_;
Texture leaves_;
};
모든 나무는 같은 메시와 텍스처를 공유하기 때문에 TreeModel 객체는 딱 하나만 존재하면 된다.
따라서 Tree 객체는 공유 객체인 TreeModel을 참조하기만 하면 된다.
class Tree{ // TreeModel을 참조하기 위한 포인터 변수 및 나무마다 다르게 가지는 데이터
private:
TreeModel* model_;
Vector position_;
double height_;
double thickness_;
Color barkTint_;
Color leafTint_;
};
객체를 저장만 한다면 이 정도로 충분하지만, 랜더링을 하기 위해선 GPU에게도 어떤 식으로 자원을 공유하고 있는지 잘 말해줘야한다.
데이터 양을 최소화 하기 위해 GPU에게 TreeModel을 딱 한 번만 보내고, 값이 다른 위치, 색, 크기를 전달한다. 마지막으로 전체 나무 인스턴스를 그릴 때 공유 데이터인 TreeModel을 사용해라고 말해야한다.
그리하여 API에서 인스턴스 렌더링을 하려면 데이터 스트림 두 개가 필요하다.
첫 번째 스트림은 공유 데이터가, 두 번째 스트림에는 인스턴스 목록과, 각 인스턴스 마다 다르게 가지는 매개변수들이 들어간다. 이렇게 하면 그리기 호출 한 번 만으로 숲 전체를 모두 그릴 수 있다.
위와 같이 경량 패턴은 어떤 객체의 개수가 너무 많아 좀 더 가볍게 만들고 싶을 때 사용한다.
일반적으로 경량 패턴은 객체 데이터를 두 종류로 나눈다.
먼저 모든 객체의 데이터 값이 같아서 공유할 수 있는 데이터를 모은다. 이러한 데이터를 고유 상태(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;
// 기타 등등
}
}
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;
// 기타 등등
}
}
위의 코드는 동작하지만 결합도가 높다. 이동 비용이나 물 타일의 여부는 지형에 관한 데이터인데 World 클래스에서 관리되고, 같은 지형 종류에 대한 데잍터가 여러 메서드에 나뉘어있다. 이런 데이터는 캡슐화를 통해 결합도를 낮출 수 있다.
따라서 지형클래스를 따로 만들어 함께 관리하는 것이 훨씬 효율적이다.
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 클래스는 World를 구성하는 타일에 관련된 내용은 전혀 없는 것을 볼 수 있다. 고로 Terrain 클래스는 앞서 다뤘던 예제의 TreeModel 클래스와 같은 고유 상태에 해당한다. Terrain 클래스는 딱 한 번만 전달하면 되고 이를 포인터로 나타내어 각 타일이 Terrain 클래스를 참조하게 하면 된다.
그림을 그리면 좀 더 와닿을 것이다.
Terrain 인스턴스가 여러 타일에서 사용되다 보니, 동적 할당시 생명주기를 관리하기가 좀 더 어렵다.
따라서 World 클래스에 저장한다.
class World{
public:
World()
: grassTerrain_(1, false, GRASS_TEXTURE),
grassTerrain_(3, false, HILL_TEXTURE),
riverTerrain_(2, true, RIVER_TEXTURE){
}
private:
Terrain grassTerrain_;
Terrain hillTerrain_;
Terrain riverTerrain_;
// 기타 등등
};
void World::generateTerrain(){
// 땅에 풀을 채운다.
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 즉 땅을 만들 수 있다.
다음 코드를 추가하면 이제 지형 속성 값을 World의 메서드 대신 Terrain 객체에서 바로 얻을 수 있다.
const Terrain& World::getTile(int x, int y) const{
return *tiles_[x][y];
}
이렇게 해주면 World 클래스의 결합도는 내용 결합도에서 외부 결합도로 낮 출 수 있다.
int cost = world.getTile(2,3).getMovementCost();
위 코드 하나로 우리는 타일의 속성을 Terrain 인스턴스에서 바로 얻을 수 있다.
포인터는 열거형과 비교해도 성능 면에서 거의 뒤지지 않는다.
위와 같이 이동 비용 같은 지형 데이터 값을 얻으려면 격자 데이터로부터 지형 객체 포인터를 얻은 다음, 포인터를 통해 이동 비용 값을 얻어야 한다. 이렇게 포인터를 따라가면 캐시 미스가 발생할 수 있어 성능이 약간 떨어질 수는 있다.
다만 확실한 것은 경량 객체를 한 번은 고려해봐야 한다는 점이다. 경량 패턴을 사용하면 객체를 마구 늘리지 않으면서도 객체지향 방식의 장점을 취할 수 있다. 수 많은 다중 선택문을 만드느니 경량 패턴을 고려해보는 것도 합리적이라고 생각한다.
'Game Develop > Design Pattern' 카테고리의 다른 글
State Pattern(상태 패턴) (2) | 2024.06.12 |
---|---|
Singleton Pattern(싱글턴 패턴) (1) | 2024.06.10 |
Prototype Pattern(프로토타입 패턴) (0) | 2024.06.07 |
Command Pattern(명령 패턴) (0) | 2024.06.06 |
Observer Pattern(관찰자 패턴) (0) | 2024.06.05 |