자욱한 안개가 걷히자 웅장하고 오래된 숲이 모습을 드러낸다. 하늘 위로 솟아오른 수많은 노송이 푸르른 대성당을 만든다. 잎은 스테인드글라스 창처럼 햇빛을 산란시켜 황금빛 기둥을 만든다. 거대한 나무 줄기 사이로 숲은 끝없이 이어진다.
판타지 게임에서 흔히 볼 수 있는 설정이다. 일반적으로 이런 장면은 이름이 너무 겸손한 ‘경량’ 패턴으로 종종 구현한다.
5.1. 숲에 들어갈 나무들
구비구비 뻗어 있는 숲을 글로는 몇 문장으로 표현할 수 있지만, 실시간 게임으로 구현하는 것은 전혀 다른 얘기다. 우리가 나무들이 화면을 가득 채운 빽빽한 숲을 볼 때, 그래픽스 프로그래머는 1초에 60번씩 GPU에 전달해야 하는 몇백만 개의 폴리곤을 본다.
수천 그루가 넘는 나무마다 각각 수천 폴리곤의 형태로 표현해야 한다. 설사 메모리가 충분하다고 해도, 이런 숲을 그리기 위해서는 전체 데이터를 CPU에서 GPU로 버스를 통해 전달해야 한다. 나무마다 필요한 데이터는 다음과 같다.
코드로 표현하면 다음과 같다.
class Tree {
private:
Mesh mesh_;
Texture bark_;
Texture leaves_;
Vector position_;
double height_;
double thickness_;
Color barkTint_;
Color leafTint_;
};
▲ 작은 상자에 들어 있는 내용은 모든 나무가 동일하다.
데이터가 많은 데다가 메시와 텍스처는 크기도 크다. 이렇게 많은 객체로 이루어진 숲 전체는 1프레임에 GPU로 모두 전달하기에는 양이 너무 많다. 다행히 검증된 해결책이 있다.
핵심은 숲에 나무가 수천 그루 넘게 있다고 해도 대부분 비슷해 보인다는 점이다. 그렇다면 모든 나무를 같은 메시와 텍스처로 표현할 수 있을 거 같다. 즉, 나무 객체에 들어 있는 데이터 대부분이 인스턴스별로 다르지 않다는 뜻이다. (미치거나 돈이 펑펑 남지 않고서야 숲에 들어갈 나무를 전부 따로 모델링하라고 아티스트에게 시키진 않을 것이다.)
객체를 반으로 쪼개어 이런 점을 명시적으로 모델링할 수 있다. 모든 나무가 다 같이 사용하는 데이터를 뽑아내 새로운 클래스에 모아보자.
class TreeModel {
private:
Mesh mesh_;
Texture bark_;
Texture leaves_;
};
게임 내에서 같은 메시와 텍스처를 여러 번 메모리에 올릴 이유가 전혀 없기 때문에 TreeModel 객체는 하나만 존재하게 된다. 이제 각 나무 인스턴스는 공유 객체인 TreeModel을 참조하기만 한다. Tree 클래스에는 인스턴스별로 다른 상태 값만 남겨둔다. (객체 상태 일부를 여러 인스턴스가 공유하는 다른 객체에 위임한다는 점에서 타입 객체 패턴(13장)과 비슷해 보인다. 하지만 의도가 다르다. 타입 객체 패턴의 목표는 ‘타입’을 직접 만든 객체 모델에 위임함으로써 정의해야 하는 클래스 개수를 줄이는 것이다. 메모리 공유는 어디까지나 덤이다. 경량 패턴(3장)은 순수하게 최적화가 목표다.)
class Tree {
private:
TreeModel* model_;
Vector position_;
double thickness_;
Color barkTint_;
Color leafTint_;
};
그림으로 그려보면 다음과 같다.
▲ 나무 인스턴스 4개가 모델 하나를 공유한다.
주메모리에 객체를 저장하기 위해서라면 이 정도로 충분하다. 하지만 렌더링은 또 다른 얘기다. 화면에 숲을 그리기 위해서는 먼저 데이터를 GPU로 전달해야 한다. 어떤 식으로 자원을 공유하고 있는지를 그래픽 카드도 이해할 수 있는 방식으로 표현해야 한다.
5.2. 수천 개의 인스턴스
GPU로 보내는 데이터 양을 최소화하기 위해서는 공유 데이터인 TreeModel를 딱 한 번만 보낼 수 있어야 한다. 그런 후에 나무마다 값이 다른 위치, 색, 크기scale를 전달하고, 마지막으로 GPU에 ‘전체 나무 인스턴스를 그릴 때 공유 데이터를 사용해’라고 말하면 된다.
다행히, 요즘 나오는 그래픽 카드나 API에서는 이런 기능을 제공한다. 자세한 내용은 이 책의 범위를 벗어나지만, Direct3D, OpenGL 모두 인스턴스 렌더링instanced rendering을 지원한다. (그래픽 카드가 인스턴스 렌더링을 지원한다는 면에서, 경량 패턴은 GoF 패턴 중에서 유일하게 하드웨어가 지원하는 패턴일지도 모른다.)
이들 API에서 인스턴스 렌더링을 하려면 데이터 스트림이 두 개 필요하다. 첫 번째 스트림에는 숲 렌더링 예제의 메시나 텍스처처럼 여러 번 렌더링되어야 하는 공유 데이터가 들어간다. 두 번째 스트림에는 인스턴스 목록과, 이들 인스턴스를 첫 번째 스트림 데이터를 이용해 그릴 때 각기 다르게 보이기 위해 필요한 매개변수들이 들어간다. 이제 그리기draw 호출 한 번만으로 전체 숲을 다 그릴 수 있다.
5.3. 경량 패턴
예제에 대해서는 충분히 알아봤으니 경량 패턴으로 넘어가보자. 이름에서 알 수 있듯이 경량 패턴(『GoF의 디자인 패턴』에서는 ‘플라이급 패턴’이라고 옮겼다(265쪽). - 옮긴이)은 어떤 객체의 개수가 너무 많아서 좀 더 가볍게 만들고 싶을 때 사용한다.
인스턴스 렌더링에서는 메모리 크기보다는 렌더링할 나무 데이터를 하나씩 GPU 버스로 보내는 데 걸리는 시간이 중요하지만, 기본 개념은 경량 패턴과 같다.
이런 문제를 해결하기 위해 경량 패턴은 객체 데이터를 두 종류로 나눈다. 먼저 모든 객체의 데이터 값이 같아서 공유할 수 있는 데이터를 모은다. 이런 데이터를 GoF는 고유 상태intrinsic state라고 했지만, 나는 ‘자유문맥context-free’ 상태라고 부르는 편이다. 예제에서는 나무 형태geometry나 텍스처가 이에 해당한다.
나머지 데이터는 인스턴스별로 값이 다른 외부 상태extrinsic state에 해당한다. 예제에서는 나무의 위치, 크기, 색 등이 이에 해당한다. 예제 코드에서 봤듯이, 경량 패턴은 한 개의 고유 상태를 다른 객체에서 공유하게 만들어 메모리 사용량을 줄이고 있다.
여기까지만 보면 기초적인 자원 공유 기법이지 패턴이라고 부를 정도는 아닌 것처럼 보인다. 이 예제에서는 공유 상태를 TreeModel 클래스로 깔끔하게 분리할 수 있어서 그렇게 보이는 측면도 있다.
공유 객체가 명확하지 않은 경우 경량 패턴은 잘 드러나 보이지 않는다(그만큼 더 교묘하다). 그런 경우에는 하나의 객체가 신기하게도 여러 곳에 동시에 존재하는 것처럼 보인다. 이런 예를 하나 들어보겠다.
5.4. 지형 정보
나무를 심을 땅도 게임에서 표현해야 한다. 보통 풀, 흙, 언덕, 호수, 강 같은 다양한 지형을 이어 붙여서 땅을 만든다. 여기에서는 땅을 타일 기반으로 만들 것이다. 즉, 땅은 작은 타일들이 모여 있는 거대한 격자인 셈이다. 모든 타일은 지형 종류 중 하나로 덮여 있다.
지형 종류에는 게임플레이에 영향을 주는 여러 속성이 들어 있다.
우리 게임 프로그래머는 최적화에 집착하기 때문에, 이들 속성을 지형 타일마다 따로 저장하는 일은 있을 수 없다. (나무 그리기 예제에서 이미 교훈을 얻지 않았는가.) 대신 지형 종류에 열거형을 사용하는 게 일반적이다.
enum Terrain {
TERRAIN_GRASS,
TERRAIN_HILL,
TERRAIN_RIVER
// 그 외 다른 지형들...
};
이제 월드는 지형을 거대한 격자로 관리한다. (여기에서는 2차원 격자를 2차원 배열에 저장했다. C/C++에서는 2차원 배열 데이터가 전부 메모리에 같이 붙어 있어 효과적이다(예를 들어 C/C++에서 int[2][3]은 int[6]와 메모리 구조가 같다 - 옮긴이). 자바 같은 메모리 관리형(memory-managed) 언어는 가로 배열이 각자 세로 배열을 참조하는 형태라 그다지 메모리 친화적이지 않다.
여기에서는 예제 코드를 단순하게 보여주기 위해 이렇게 짰지만, 실제 코드에서는 상세 구현을 잘 만든 2차원 격자 자료구조 안에 숨기는 게 좋다.)
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;
// 그 외 다른 지형들...
}
}
이 코드는 동작하긴 하지만 지저분하다. 이동 비용이나 물인지 땅인지 여부는 지형에 관한 데이터인데 이 코드에서는 하드코딩되어 있다. 게다가 같은 지형 종류에 대한 데이터가 여러 메서드에 나뉘어 있다. 이런 데이터는 하나로 합쳐서 캡슐화하는 게 좋다. 그러라고 객체가 있는 것이니 말이다.
아래와 같이 지형 클래스를 따로 만드는 게 훨씬 낫다. (모든 메서드를 const로 만들었음에 주목하라. 다 이유가 있다. 같은 Terrain 객체를 여러 곳에서 공유해서 쓰기 때문에, 한곳에서 값을 바 꾼다면 그 결과가 여러 군데에서 동시에 나타나게 된다. 이건 원하는 바가 아니다. 메모리를 줄여보겠다고 객체를 공유했는데 그게 코드 기능에 영향을 미쳐서는 안 된다. 이런 이유로 경량 객체는 변경 불가능한(immutable) 상태로 만드는 게 보통이다.)
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 인스턴스를 하나씩 만드는 비용은 피하고 싶다. Terrain 클래스에는 타일 위치와 관련된 내용은 전혀 없는 것을 볼 수 있다. 경량 패턴식으로 얘기하자면 모든 지형 상태는 ‘고유’하다. 즉 ‘자유문맥’에 해당한다.
따라서 지형 종류별로 Terrain 객체가 여러 개 있을 필요가 없다. 지형에 들어가는 모든 풀밭 타일은 전부 동일하다. 즉, World 클래스 격자 멤버 변수에 열거형이나 Terrain 객체 대신 Terrain 객체의 포인터를 넣을 수 있다.
class World {
private:
Terrain* tiles_[WIDTH][HEIGHT];
// 그 외...
};
지형 종류가 같은 타일들은 모두 같은 Terrain 인스턴스 포인터를 갖는다.
▲ 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();
이제 객체로 작동하는 근사한 API가 되었다. 게다가 포인터는 열거형과 비교해도 성능 면에서 거의 뒤지지 않는다.
5.5. 성능에 대해서
아까 ‘거의’라고 단서를 붙인 이유는 성능에 대해 깐깐한 사람이라면 포인터가 열거형보다 느리다고 트집 잡을 만한 요소가 있기 때문이다. 지형 데이터를 포인터로 접근한다는 것은 간접 조회indirect lookup한다는 뜻이다. 이동 비용 같은 지형 데이터 값을 얻으려면 먼저 격자 데이터로부터 지형 객체 포인터를 얻은 다음에, 포인터를 통해서 이동 비용 값을 얻어야 한다. 이렇게 포인터를 따라가면 캐시 미스가 발생할 수 있어 성능이 조금 떨어질 수는 있다. (포인터 따라가기와 캐시 미스에 대해서는 데이터 지역성 패턴(17장)에서 자세히 다룬다.)
최적화의 황금률은 언제나 먼저 측정하는 것이다. 최신 컴퓨터 하드웨어는 너무 복잡해서 더 이상 추측만으로는 최적화하기 어렵다. 측정해본 결과(성능 비교 코드 https://git.io/vwkEL - 옮긴이)로는 경량 패턴을 써도 열거형을 쓴 것과 비교해서 성능이 나빠지지 않았다. 오히려 경량 패턴 방식이 훨씬 더 빨랐다. 하지만 이건 객체가 메모리에 어떤 식으로 배치되느냐에 따라 달라질 수 있다.
확실한 것은 경량 객체를 한 번은 고려해봐야 한다는 점이다. 경량 패턴을 사용하면 객체를 마구 늘리지 않으면서도 객체지향 방식의 장점을 취할 수 있다. 열거형을 선언해 수많은 다중 선택문switch을 만들 생각이라면, 경량 패턴을 먼저 고려해보자. 성능이 걱정된다면, 유지보수하기 어려운 형태로 코드를 고치기 전에 적어도 프로파일링이라도 먼저 해보자.
5.6. 관련자료
이전 글 : 오픈소스 비즈니스 모델을 리펙토링하자
최신 콘텐츠