자욱한 안개가 걷히고 웅장한 숲이 모습을 보이기 시작한다. 수많은 나무들과 그앞에 수많은 건축물이 있다. 거대한 나무와 건물사이로 웅장한 풍경을 볼수 있을 것이다.
우리는 이런 풍경을 게임에서 종종 흔하게 볼수 있다. 이런장면은 표현 하는 패턴을 ‘경량’ 패턴이라고한다.
숲에 들어갈 나무들
구비구비 뻗어 있는 숲을 글로는 표현하기 어렵지만, 실시간 게임에서 구현하는 것은 전혀 다른 얘기다. 우리가 나무들이 화면을 빽빽하게 채워진 숲을 볼때, 그래픽스 프로그래머는 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
복사