자욱한 안개가 걷히고 웅장한 숲이 모습을 보이기 시작한다. 수많은 나무들과 그앞에 수많은 건축물이 있다. 거대한 나무와 건물사이로 웅장한 풍경을 볼수 있을 것이다.
우리는 이런 풍경을 게임에서 종종 흔하게 볼수 있다. 이런장면은 표현 하는 패턴을 ‘경량’ 패턴이라고한다.
숲에 들어갈 나무들
구비구비 뻗어 있는 숲을 글로는 표현하기 어렵지만, 실시간 게임에서 구현하는 것은 전혀 다른 얘기다. 우리가 나무들이 화면을 빽빽하게 채워진 숲을 볼때, 그래픽스 프로그래머는 1초에 60번씩 GPU가 전달해야 하는 몇백만개의 폴리곤을 본다.
수천 그루가 넘는 나무마다 각각 수천개의 폴리곤 형태로 표현을 해야 할것이다. 메모리가 충분하다 해도 렌더링을 하다보면 CPU에서 GPU로 통해 전달해야 한다.
나무마다 필요한 데이터는 다음과 같다.
1.
줄기, 가지, 잎사귀의 형태를 나타내는 폴리곤 메쉬
2.
나무 껍질과 잎사귀의 텍스쳐
3.
숲에서의 위치와 방향
4.
각각의 나무마다 색, 크기 등등 다른 설정값
코드로 표현해보자면 다음과 같을 것이다.
class Tree
{
private:
Mesh mesh_;
Texture bark_;
Texture leaves_;
Vector position_;
double height_;
double thickness_;
Color barkTint_;
Color leafTint_;
};
JavaScript
복사
나무 폴리곤 하나마다 들어있는 MESH, LEAVES… 등등 상자안에의 내용은 모두 같은 값이다.
하지만 각각 메모리 영역이 다 따로 할당되어 있다.
이를 봐서 데이터 많고 텍스처 크기도 크다. 이렇게 많은 객체로 이루어진 숲 전체는 1프레임에 GPU연산을 모두 하기 어려울수 있다.
핵심은 숲에 나무가 아무리 많더라도 전부 비슷한 형태를 가지고 있다. 그렇다면 모든 나무를 같은 메시 1개, 텍스처1개를 가지고 공유해서 사용해서 표현이 가능하다. 즉 나무 객체에 들어 있는 데이터 대부분이 인스턴스별로 다르지 않다.
게임 내에서 같은 메시와 텍스처를 여러번 메모리 올릴 이유가 전혀 없기 때문에 TreeModel한개만 올리면된다. 그리고 모든 나무들은 해당 메모리에 올라간 한개의 나무를 참조해서 사용하고 크기나 색깔등등 상태값만 표현가능하게 나둔다.
class Tree
{
private:
TreeModel* model_;
Vector position_;
double height_;
double thickness_;
Color barkTint_;
Color leafTint_;
};
JavaScript
복사
주메모리에 객체를 저장하기 위해서라면 이 정도로 충분하다. 하지만 렌더링은 또 다른 얘기다. 화면에 숲을 그리기 위해서는 먼저 데이터를 GPU로 전달해야 한다. 어떤식으로 자원을 공유하고 있는지 그래픽 카드도 이해할 수 있는 방식으로 표현이 가능해야 한다.
GPU로 보내는 데이터 양을 최소화하기 위해서는 공유 데이터인 TreeNode를 딱 한번만 보낼 수 있어야 한다. 그런 후에 나무마다 값이 다른 위치, 색, 크기 등을 전달하고, 마지막으로 GPU에 ‘전체 나무 객체를 그릴때 이 공유 데이터를 사용해’ 라고 명령해주면 된다.
다행이요 최신 그래픽카드들은 이런 기능 제공한다. 이를 인스턴싱(Instanced handling)을 지원한다.
인스턴싱을 하려면 데이터 스트림이 두개 필요하다. 첫번째는 숲렌더링 예제나 위 그림처럼 사용될 메쉬(폴리곤)의 데이터가 공유 되어야한다.
두번 째는 인스턴싱 목록과, 이를 각각의 인스턴스들을 첫 번째 스트림에 들어있는 데이터를 이용해서 그릴때 사용해야할 (크기, 위치, 색) 이런 매개변수들이 들어 가야 한다.
그리고 마지막으로 그리기 (draw)호출 한번만 호출해서 화면에 한번에 빡! 하고 그려주면된다.
경량패턴
예시에대해서는 이제 충분히 알아 보았으니 경량패턴으로 넘어가보자. 이름에서 느껴지지만 어떤 객체의 수가 너무 많아서 좀 더 가볍게 만들고 싶을 때 사용한다.
인스턴싱에서는 메모리 크기보다는 렌더링할 나무를 하나씩 GPU한테 보내는데 걸리는 시간이 중요하지만, 기본적인 개념은 경량패턴과 같다.
이런 문제를 해결하귀 경량패턴 객체 데이터를 두 데이터 나눈다.
먼저 모든 객체의 데이터값이 같아서 공유 할수 있는 데이터 이런 데이터를 GOF에서는 고유상태(intrinsic state)라고 했지만, ‘자유 문맥(Context free)라고 부르기도 한다. 예제에서는 나무 형태(폴리곤 데이터), 텍스처(textuure)이게 여기에 해당한다.
나머지 데이터는 외부 상태(extrinsic state)에 해당한다. 예제에서는 나무의 크기, 색, 위치등이 해당한다.
공유 객체가 명확하지 않은 경우 경량 패턴은 잘 드러나 보이지 않는다. 그런 경우에는 하나의 객체가 신기하게도 여러곳에 동시에 존재하는것처럼 보일 수 잇다. 이런 예시를 보자
타일맵(Tile map)
나무를 심을 땅도 게임에서 표현 해야 한다. 보통 흙, 언덕, 호수 , 강 같은 다양한지형을 이어서 붙여가지고 지형을 만든다. 여기에서 땅을 타일 기반으로 만들면 경량패턴과 같은 역할을 충분히 수행 할 수 있다.
•
플레이어가 얼마나 빠르게 이동 할 수 있는 지를 결정하는 이동 비용 값
•
강이나 바다처럼 보트로 건너갈 수 있는지를 결정하는 여부
•
렌더링할떄 사용할 텍스처
우리 이들 속성을 지형 타일마다 따로 저장하는 일은 있을 수 없다. 대신 지형 종류에 열거형을 사용해보자
enum Terrain
{
TERRAIN_GRASS,
TERRAIN_HILL,
TERRAIN_RIVER
// Other terrains...
};
JavaScript
복사
이제 월드는 지형을 거대한 타일맵으로 관리한다.
class World //Tile Map
{
private:
Terrain tiles_[WIDTH][HEIGHT];
};
JavaScript
복사
타일 관련 데이터는 다음과 같이 얻을 수 있다.
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...
}
}
JavaScript
복사
이 코드는 동작하긴 하지만 지저분하다. 이동 비용이나 물인지 땅인지 여부는 지형에 관한 데이터인데 이 코드에서는 하드코딩 되어있다. 게다가 지형 종류에 대한 데이터가 여러 메서드로 나뉘어 있다. 이런 데이터는 하나로 합쳐서 캡슐화 하는게 좋다. 그러라고 객체가 있는 것이다.
지형 클래스를 따로 만들어보자.
class Terrain //Tile
{
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_;
};
JavaScript
복사
하지만 타일마다 Terrain 인스턴스를 하나씩 만드는 비용은 피하고 싶다. Terrain클래스에서는 타일 위치와 관련된 내용은 보이지가 않는다. 경량 패턴식으로 풀어보자면 모든 지형상태는 ‘고유’하다. 즉 타일맵의 타일 상태는 공유되어야 하는 데이터이다.
따라서 지형 종류별로 Terrain 객체가 여러개 있을 필요가 없다. 지형에 들어가는 모든 풀밭 타일은 동일한 객체이다. 즉 world클래스 격자 메법 변수에 열거형이나 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_;
// Other stuff...
};
JavaScript
복사
그리고 땅위를 채워주는 코드이다.
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_;
}
}
JavaScript
복사
이제 지형 속성 값을 World 메서드 대신 Terrain 객체에서 바로 얻을 수 있다.
const Terrain& World::getTile(int x, int y) const
{
return *tiles_[x][y];
}
JavaScript
복사
World클래스는 더이상 지형의 세부정보를 알수 없다. (디커플링) 타일 속성은 Terrain 객체에서 바로 얻을 수있다.
int cost = world.getTile(2, 3).getMovementCost();
JavaScript
복사
이렇게 하면 타일맵을 렌더링할때 메모리에 할당되는 객체수를 최대한 줄여 줄 수 있다.
성능
지형 데이터를 포인터로 접근하는 것은 참조(간접적으로) 조회 한다는 뜻이다. 이동 비용 같은 지형 데이터 값을 얻으려면 World 타일맵 격자에 접근해서 지형 객체 포인터를 얻어서, 포인터를 통해서 값을 얻어야 한다. 이는 포인터를 따라가기 떄문에 캐시 미스(캐시 적중률) 발생할수 있어 성능이 조금 떨어 질수는 있다. 이를 극복하기위해서는 메모리에 객체를 어떤식으로 배치하냐에 따라서 캐식적중률을 높일 수 있다.
확실 한 건 경량 패턴을 한번은 고려를 해보자. 경량 패턴을 사용하면 객체를 마구 늘리지 않으면서도 객체지향 방식을 취할 수 있다. 열거형을 통해 수많은 다중 선택문을 만들거라면 경량패턴을 사용해보자. 그리고나서 성능에 따른 리팩토링은 나중에 해도 늦지 않다.
경량패턴 연습해보기
C++ 콘솔창을 이용하여 숙제를 해주세요.
타일 맵 클래스를 구성해줘라. 그리고 타일맵 클래스는 타일 객체를 맵크기만큼 10x10의 크기로 가지고 있는다. 각가의 Tile을 동적할당해서 표현해줘라.
타일의 종류는 랜덤하게 배치해주면된다.
위과정을 전부 마쳤다면 타일맵안에 겹치는 타일의 객체를 경량패턴을 이용해 타일 종류만큼 만들고 메모리를 공유해서 사용하도록 바꿔보아라.
#include <iostream>
#include <vector>
#include <Windows.h>
class Tile //Terrain
{
}
class TimeMap //World
{
public:
//....
private:
std::vector<std::vector<Tile*>> map;
};
int main()
{
TileMap world;
while (true)
{
world.Render();
}
return 0;
}
JavaScript
복사
원문 전체 해설·코드·도해
위의 수업용 정리와 실습을 먼저 읽은 뒤, 아래에서 책의 원문 흐름·모든 코드·원본 도해·각주를 빠짐없이 확인할 수 있습니다.
경량 패턴(Flyweight Pattern)
공유를 통해 많은 수의 작은 객체들을 효율적으로 지원합니다.
— 『GoF의 디자인 패턴』 한국어판 265쪽
자욱한 안개가 걷히자 웅장하고 오래된 숲이 드러납니다. 수많은 노송이 하늘로 솟아 푸른 대성당을 이루고, 잎은 스테인드글라스처럼 햇빛을 흩어 황금빛 기둥을 만듭니다. 거대한 나무줄기 사이로 숲은 끝없이 이어집니다.
판타지 게임에서 흔히 볼 법한 장면입니다. 이런 웅장한 장면도 이름은 겸손한 경량 패턴으로 구현하는 경우가 많습니다.
3.1 숲에 들어갈 나무들
숲을 글로 묘사하는 데는 몇 문장이면 충분하지만, 실시간 게임에서 구현하는 것은 다른 문제입니다. 우리가 화면을 가득 채운 나무를 볼 때 그래픽스 프로그래머는 초당 60번씩 GPU에 전달해야 할 수백만 개의 폴리곤을 봅니다.
나무가 수천 그루이고 각각 수천 개의 폴리곤으로 이루어졌다고 생각해 보세요. 메모리가 충분해도 렌더링하려면 데이터를 CPU에서 GPU로 버스를 통해 전달해야 합니다.
각 나무에는 다음 데이터가 필요합니다.
•
줄기·가지·잎의 모양을 나타내는 폴리곤 메시
•
나무껍질과 잎사귀의 텍스처
•
숲 안에서의 위치와 방향
•
나무마다 다르게 보이게 할 크기·음영 등의 매개변수
코드로 표현하면 다음과 같습니다.
class Tree {
private:
Mesh mesh_;
Texture bark_;
Texture leaves_;
Vector position_;
double height_;
double thickness_;
Color barkTint_;
Color leafTint_;
};
C++
복사
그림 3-1. 각 나무가 같은 메시와 텍스처 데이터를 따로 가지고 있습니다.
데이터도 많고, 그중 메시와 텍스처는 특히 큽니다. 이 숲 전체를 매 프레임 GPU로 보내기에는 양이 너무 많습니다. 다행히 검증된 해결책이 있습니다.
핵심은 나무가 수천 그루여도 대부분 비슷한 모습을 가진다는 점입니다. 같은 메시와 텍스처로 표현할 수 있다면 나무 데이터의 상당 부분은 인스턴스마다 다르지 않습니다.
원주 1: 저자는 제정신이 아니거나 돈이 넘쳐 나지 않는 한 숲의 나무를 모두 따로 모델링하라고 아티스트에게 요구하지는 않을 것이라고 농담합니다.
이 사실을 객체 구조에도 명시적으로 반영해 보겠습니다. 모든 나무가 같이 쓰는 데이터를 별도 클래스에 모읍니다.
class TreeModel {
private:
Mesh mesh_;
Texture bark_;
Texture leaves_;
};
C++
복사
같은 메시와 텍스처를 여러 번 메모리에 올릴 이유가 없으므로 TreeModel은 하나만 만들면 됩니다. 각 나무는 그 공유 객체를 참조하고, 자신의 위치나 색처럼 서로 다른 상태만 따로 보관합니다.
class Tree {
private:
TreeModel* model_;
Vector position_;
double height_;
double thickness_;
Color barkTint_;
Color leafTint_;
};
C++
복사
그림 3-2. 네 나무 인스턴스가 같은 모델 하나를 참조합니다.
원주 2: 여러 인스턴스가 상태 일부를 공유 객체에 위임한다는 점에서 13장의 타입 객체 패턴과 닮았습니다. 하지만 목적은 다릅니다. 타입 객체는 직접 만든 객체 모델에 ‘타입’을 위임해 정의해야 할 클래스 수를 줄이는 것이 목표이고, 메모리 공유는 부수 효과입니다. 경량 패턴은 최적화 자체가 목표입니다.
주메모리에 객체를 저장하는 문제라면 이 정도로 충분합니다. 렌더링은 별개입니다. 그래픽 카드도 공유 구조를 이해할 수 있도록 데이터를 표현해야 합니다.
함께 저장해도 되는 것과 각자 가져야 하는 것을 나눠 보세요.
나무 1만 그루가 같은 모델을 사용한다면 메시·기본 텍스처는 공유할 수 있습니다. 각 나무의 위치까지 공유하면 한 그루를 옮길 때 다른 나무도 영향을 받으므로 위치는 개별 상태입니다. 한 그루만 불타는 연출도 공용 모델을 무작정 바꾸기보다 개별 상태나 별도의 자원 변형으로 표현해야 합니다.
3.2 수천 개의 인스턴스
GPU로 보내는 양을 최소화하려면 TreeModel의 공유 데이터를 한 번만 보내야 합니다. 그다음 나무별 위치·색·크기를 전달하고, 전체 나무를 그릴 때 공유 데이터를 사용하라고 GPU에 지시합니다.
원서에서 설명하는 Direct3D와 OpenGL은 이런 인스턴스 렌더링(instanced rendering) 기능을 제공합니다. 자세한 그래픽 API 사용법은 이 책의 범위를 벗어나지만, 기본 방식은 두 데이터 스트림을 사용하는 것입니다.
첫 번째 스트림에는 메시와 텍스처처럼 반복해서 사용할 공유 데이터가 들어갑니다. 두 번째에는 인스턴스 목록과 각 인스턴스를 다르게 보이게 하는 매개변수가 들어갑니다. 그러면 한 번의 그리기(draw) 호출로 전체 숲을 그릴 수 있습니다.
원주 3: 저자는 그래픽 카드의 인스턴스 렌더링을 보면 경량 패턴이 GoF 패턴 중 하드웨어가 직접 지원하는 유일한 패턴일지도 모른다고 말합니다.
경량 패턴과 GPU 인스턴싱의 관계
경량 패턴은 공유 데이터와 개별 데이터를 나누는 설계 방법입니다. GPU 인스턴싱은 그 분리를 그래픽 API에서 활용하는 예입니다. 모든 경량 패턴이 GPU 기능을 사용하는 것은 아니며, 실제 렌더링에서는 재질·상태·API의 배치 조건에 따라 그리기 호출 수가 달라질 수 있습니다.
3.3 경량 패턴의 두 상태
예제를 충분히 보았으니 패턴 자체를 살펴보겠습니다. 경량 패턴은 객체가 너무 많아 더 가볍게 만들고 싶을 때 사용합니다. 인스턴스 렌더링에서는 메모리 크기보다 데이터를 GPU 버스로 보내는 시간이 더 중요한 문제였지만, 기본 개념은 같습니다.
객체 데이터를 두 종류로 나눕니다.
•
고유 상태(intrinsic state): 여러 객체의 값이 같아 공유할 수 있는 데이터입니다. 저자는 ‘자유 문맥(context-free) 상태’라고 부르는 편을 선호합니다. 나무의 형태와 텍스처가 여기에 해당합니다.
•
외부 상태(extrinsic state): 인스턴스마다 다른 값입니다. 나무의 위치·크기·색이 해당합니다.
경량 패턴은 하나의 고유 상태를 여러 객체가 공유하게 해 메모리 사용량을 줄입니다. 여기까지만 보면 이름 붙일 정도의 패턴이라기보다 단순한 자원 공유처럼 보일 수 있습니다. 이 예제에서는 공유 상태를 TreeModel이라는 클래스로 쉽게 분리할 수 있었기 때문입니다.
공유 객체가 뚜렷하지 않을 때는 패턴도 덜 눈에 띕니다. 그만큼 더 교묘합니다. 마치 객체 하나가 여러 장소에 동시에 존재하는 것처럼 보이기도 합니다. 다음 예제를 보겠습니다.
원주 4·옮긴이: 『GoF의 디자인 패턴』 한국어판에서는 Flyweight를 ‘플라이급 패턴’이라고 옮겼습니다.
3.4 지형 정보
나무가 서 있을 땅도 필요합니다. 풀, 흙, 언덕, 호수, 강 같은 지형을 이어 붙여 땅을 만듭니다. 여기서는 작은 타일들이 모인 커다란 격자를 사용하고, 각 타일이 한 종류의 지형으로 덮이게 하겠습니다.
지형에는 게임플레이에 영향을 주는 속성이 있습니다.
•
플레이어의 이동 속도를 결정하는 이동 비용
•
강이나 바다처럼 보트로 건널 수 있는지 여부
•
그릴 때 사용할 텍스처
최적화에 관심 많은 게임 프로그래머라면 이 속성을 타일마다 복사하고 싶지 않을 것입니다. 나무 예제에서 이미 배운 교훈입니다. 흔한 방법은 지형 종류를 열거형으로 표현하는 것입니다.
enum Terrain {
TERRAIN_GRASS,
TERRAIN_HILL,
TERRAIN_RIVER
// 그 외 다른 지형들...
};
C++
복사
원주 5: “나무 그리기 예제에서 이미 교훈을 얻지 않았는가”라는 저자의 덧붙임입니다.
월드는 지형을 큰 격자로 저장합니다.
class World {
private:
Terrain tiles_[WIDTH][HEIGHT];
};
C++
복사
원주 6: C/C++의 이런 2차원 배열은 데이터가 메모리에 연속 배치됩니다. 예를 들어 int[2][3]은 int[6]과 같은 크기의 연속 저장 공간을 사용합니다. 자바처럼 각 행이 별도 배열을 참조하는 언어에서는 배치가 다르므로 메모리 접근 특성도 다릅니다. 예제는 단순함을 위해 배열을 직접 쓰지만, 실제 코드에서는 잘 만든 2차원 격자 자료구조 안으로 상세 구현을 숨기는 편이 좋습니다.
타일의 속성은 다음과 같이 얻을 수 있습니다.
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;
// 그 외 다른 지형들...
}
}
C++
복사
동작은 하지만 지저분합니다. 이동 비용이나 물 여부는 지형 데이터인데 코드에 하드코딩되어 있습니다. 한 지형에 속하는 정보도 여러 메서드에 흩어졌습니다. 관련 데이터를 묶고 캡슐화하는 것이 객체의 역할입니다.
지형 클래스를 따로 만들면 더 나아집니다.
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_;
};
C++
복사
하지만 타일마다 이 객체를 하나씩 만들고 싶지는 않습니다. Terrain에는 타일 위치가 없습니다. 이 클래스의 데이터는 모두 고유 상태, 즉 문맥과 독립된 상태입니다.
원주 7: 접근 메서드가 모두 const인 이유가 있습니다. 여러 곳에서 같은 Terrain을 공유하므로 한곳에서 값을 바꾸면 다른 곳에도 동시에 반영됩니다. 메모리를 절약하려고 공유했는데 동작이 달라져서는 안 됩니다. 그래서 경량 객체는 보통 변경 불가능한(immutable) 상태로 만듭니다.
지형 종류마다 Terrain을 여러 개 만들 필요가 없습니다. 모든 풀밭 타일은 같은 풀밭 객체를 참조할 수 있습니다. 따라서 격자에는 열거형이나 Terrain 객체 대신 포인터를 저장합니다.
class World {
private:
Terrain* tiles_[WIDTH][HEIGHT];
// 그 외...
};
C++
복사
같은 종류의 타일은 같은 인스턴스를 가리킵니다.
그림 3-3. 여러 타일이 지형 종류별 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_;
// 그 외...
};
C++
복사
이제 격자를 채워 보겠습니다.
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_;
}
}
C++
복사
원주 8: 저자는 뛰어난 절차적 지형 생성 알고리즘은 아니라는 점을 인정합니다.
지형 속성을 World가 직접 계산할 필요가 없어졌습니다. 해당 타일의 Terrain을 얻습니다.
const Terrain& World::getTile(int x, int y) const {
return *tiles_[x][y];
}
C++
복사
World는 더 이상 지형의 상세 속성과 결합되지 않습니다. 속성은 지형 객체에서 바로 얻습니다.
int cost = world.getTile(2, 3).getMovementCost();
C++
복사
이제 객체를 사용하는 편리한 API가 됐습니다. 포인터는 열거형과 비교해도 성능 면에서 거의 뒤지지 않습니다.
타일 100개와 지형 객체 3개를 구분해 보세요.
10×10 맵에는 칸이 100개 필요하지만 풀·언덕·강의 속성 객체는 3개면 됩니다. 각 칸이 자신의 좌표에서 어떤 지형을 참조하는지 저장합니다. 경량 패턴은 맵의 칸을 없애는 방식이 아니라 반복되는 지형 속성 복사를 없애는 방식입니다. 위 코드 조각은 책의 단계를 보여 주는 예제이므로, 실제 클래스에서는 앞에서 나온 격자 멤버와 필요한 메서드 선언을 함께 구성해야 합니다.
3.5 성능에 대해서
앞에서 ‘거의’라고 한 이유는 포인터 방식이 느려질 가능성도 있기 때문입니다. 포인터를 통해 지형 데이터에 접근하면 간접 조회(indirect lookup)가 생깁니다. 먼저 격자에서 지형 객체의 포인터를 읽고, 그 포인터를 따라가 이동 비용을 읽습니다. 이 과정에서 캐시 미스가 생겨 조금 느려질 수 있습니다.
원주 9: 포인터 따라가기와 캐시 미스는 17장 데이터 지역성에서 자세히 다룹니다.
최적화의 황금률은 먼저 측정하는 것입니다. 컴퓨터 하드웨어는 복잡하므로 추측만으로 최적화하기 어렵습니다. 저자가 측정한 경우에는 경량 패턴이 열거형 방식보다 느리지 않았고 오히려 훨씬 빨랐습니다. 다만 객체가 메모리에 어떻게 배치되는지에 따라 결과가 달라질 수 있습니다.
확실한 것은 경량 객체를 한 번 고려할 가치가 있다는 점입니다. 객체를 무작정 늘리지 않으면서 객체지향 설계의 장점을 얻을 수 있습니다. 열거형을 선언하고 수많은 switch 문을 만들 생각이라면 경량 패턴을 먼저 검토해 보세요. 성능이 걱정되더라도 유지보수하기 어려운 코드로 바꾸기 전에 프로파일링부터 해야 합니다.
‘공유하면 반드시 빠르다’는 결론은 아닙니다.
메모리 사용량, 캐시 배치, 간접 참조 비용을 함께 측정해야 합니다. 또한 원본의 switch 예제는 생략된 지형이 있으므로 실제 코드에서는 모든 입력을 처리하거나 잘못된 값에 대한 정책을 정해야 합니다. 반환형이 있는 함수에서 반환 없이 끝나는 경로가 남지 않도록 확인하세요.
3.6 관련 자료
•
타일 예제는 지형 종류별 객체를 미리 World에 만들었기 때문에 찾고 재사용하기 쉬웠습니다. 하지만 어떤 공유 객체가 필요할지 미리 알 수 없다면 요청 시점에 만드는 편이 낫습니다. 이때 이미 같은 객체를 만들었는지 먼저 확인하고, 있으면 그것을 반환해야 공유가 유지됩니다. 생성 코드를 인터페이스 뒤로 숨기는 이 방식은 GoF의 팩토리 메서드(Factory Method) 패턴의 한 예이기도 합니다.
•
만들어 둔 경량 객체를 재사용하려면 기존 객체를 찾을 수 있는 풀(pool)을 관리해야 합니다. 19장의 객체 풀 패턴이 이런 문제에 유용합니다.
•
7장의 상태 패턴에서는 상태의 식별자와 메서드만 필요해 멤버 변수가 없는 상태 객체를 사용하는 경우가 있습니다. 이때 경량 패턴을 적용하면 상태 인스턴스 하나를 여러 상태 기계가 함께 사용할 수 있습니다.
경량 패턴 연습해 보기
먼저 10×10 타일맵을 만들고 각 칸에 풀·언덕·강 중 하나를 배치해 보세요. 처음에는 칸마다 속성 객체를 따로 만들고, 다음에는 지형 종류 수만큼만 객체를 만들도록 바꿉니다. 칸 수와 공유 객체 수를 출력해 차이를 확인한 뒤, 한 칸의 종류를 바꿔도 다른 칸의 종류가 바뀌지 않는지 살펴보세요.
출처: 사용자가 제공한 『게임 프로그래밍 패턴』 한국어판 3장(인쇄 57~67쪽). 원본 그림 3-1~3-3, 모든 코드와 원주 1~10을 포함했습니다. 회색
콜아웃은 추가 설명입니다.
콜아웃은 추가 설명입니다.













