Design Pattern(2-1) - 경량 패턴(Flyweight) 원리
흔히 말하는 대작 게임들을 보면, 엄청난 그래픽과 사실성으로 유저들의 시선을 끌어당깁니다. 대작 게임의 나무, 잔디 하나하나 보면 어딜 봐도 High-Quality입니다.
제가 이런 배경들을 잔디 대신 나무로 한번 만들어보고 싶어서 나무를 한 100개 정도최적화 없이 for문을 돌려 나무를 그렸다고 가정해보겠습니다.
음.. 60fps 에서 5fps이 되어 버렸군요. 뭐 다양한 문제가 있었겠지만 기본적으로 똑같은 나무들을 계속 생성했었죠.
이게 왜 문제가 되냐? 할 수도 있지만 게임은 최소로 최적화한다 해도 1초에 60번씩 GPU에 전달해야 합니다. 이 때 몇백만 개의 폴리곤을 쌩으로 그린다면? 당연히 프레임이 엄청나게 떨어질 것입니다.
설사 메모리가 100GB라고 할지어도 그래픽스 프로그래밍은 GPU에서의 간단한 연산을 하기 위해 CPU에서 GPU로 버스를 통해 전달해야 하기때문에 엄청나게 많은 데이터를 보내게 되죠.
그림에서 생성하는 나무에 필요한 데이터는 다음과 같습니다.
1. 줄기, 가지 잎의 형태를 나타내는 폴리곤 메쉬
2. 나무 껍질과 잎사귀 텍스처
3. 숲에서의 위치와 방향
4. 각각의 나무가 다르게 보이도록 크기와 음영 같은 값을 조절할 수 있는 매개변수
(이런 기능들은 대부분 Model class를 만들고 상속해서 확장성을 넓힘)
코드로 표현하면 다음과 같습니다.
class Tree {
private:
Mesh mesh_;
Texture bark_;
Texture leaves_;
Vector3 position_;
double height_;
double thickness_;
Color barkInt_;
Color leafInt_;
}
2048 x 2048의 텍스처와 10만개의 폴리곤을 가진 모델이라고 가정해보죠.
그렇다면 Real-time에 렌더링을 1fps 안에 다 처리할 수 없을만큼 많은 양의 데이터를 GPU에 보낼 것입니다.
그래서 나무에서 텍스처나 모델같은 부분들을 똑같은 데이터를 공유해서 사용해볼까? 라고 생각해서 만든 패턴이 바로 경량 패턴입니다.
예를들어 산을 좋아해서 등산을 한다고 가정해보죠.(그럴일은 없을 것 같긴 합니다) 산에 오르는 재미가 있어 등산을 하는거지 산에서 나무들이 어떻게 생겼는지 정확히 관찰하지 않습니다.
그렇기 때문에 게임에서 모든 나무와 텍스처를 같은 것으로 표현하면 어떨까? 라는 생각으로 시작되었습니다.
(현실에서나 게임에서나 나무의 생김새는 사실 사람에게 크게 관심이 없기 때문)
이러한 생각을 바탕으로 나무 객체에 들어 있는 데이터 대부분이 인스턴스별로 크게 다르지 않다는 뜻입니다.
이러한 공유하는 데이터를 뽑아내 새로운 클래스에 모아줍니다.
class Tree {
private:
Mesh mesh_;
Texture bark_;
Texture leaves_;
}
게임 내에서 같은 메시와 텍스처를 여러 번 메모리에 올릴 이유가 전혀 없기 때문에 TreeModel 객체는 하나만 존재하게 됩니다.
이제 각 나무의 인스턴스는 공유 객체인 TreeModel을 참조하기만 하면 됩니다. Tree 클래스에는 인스턴스별로 다르게 설정해야하는 상태 값들만 남겨두게 됩니다.
class Tree {
private:
TreeModel* mesh_;
Vector3 position_;
double height_;
double thickness_;
Color barkInt_;
Color leafInt_;
}
이렇게 공유 데이터들을 참조하면 위 그림이 다음과 같이 변하게 됩니다.
메모리에 객체를 저장하기 위해서라면 이 정도로 충분하다. 하지만 렌더링은 다른 얘기입니다. 화면에 숲을 그리기 위해서는 먼저 데이터를 GPU로 전달해야 합니다.
어떤 식으로 자원을 공유하고 있는지를 그래픽 카드도 이해할 수 있는 방식으로 표현해야 합니다.
GPU로 보내는 데이터 양을 최소화하기 위해서는 공유 데이터인 TreeModel을 딱 한 번만 보낼 수 있어야 합니다.
그런 이후 각각의 상태 값들을 전달하고, GPU에서 전체 나무 인스턴스를 그릴 때 공유 데이터를 사용해! 라고 설정하면 됩니다.
다행히 요즘 나오는 그래픽 카드가 API에서는 이런 기능을 제공합니다.
DirectX, OpenGL 모두 인스턴스 렌더링을 지원하기 때문에 설정을 통해 바꿀 수 있습니다. (DirectX는 구조체 flag 값을 바꾸어 지원가능, OpenGL은 모름)
이러한 API에서도 인스턴스 렌더링을 하려면 데이터 스트림이 두 개 필요합니다.
1. 여러 번 렌더링 해야하는 공유 데이터
2. 인스턴스 목록, 고유의 상태값
즉, 텍스처와 모델을 공유하고 음영과 색깔을 다르게 한다는 것입니다. 이렇게 Draw 호출 한 번만으로 전체 숲을 다 그릴 수 있습니다.
그럼 이렇게 힘들게 인스턴스 렌더링을 했는데, 그래서 장점이 뭔가요?
대표적으로 두 가지의 장점이 있습니다.
CPU의 부하 감소
하나의 Draw Call로 모든 나무 인스턴스를 렌더링할 수 있습니다.이는 수천 개의 Draw Call을 단 하나로 줄이는 효과를 가집니다.
GPU 효율성 향상
GPU는 일관된 상태에서 대량의 인스턴스 데이터를 처리할 수 있어 파이프라인이 효율적으로 작동합니다.
경량 패턴을 요약하자면, 어떤 객체가 개수가 많아서 좀 더 가볍게 만들 때 사용합니다. 이전 포스팅에서 인스턴스 렌더링 에서는 메모리 크기보다는 렌더링할 나무 데이터를 하나씩 GPU 버스로 보내는 데 걸리는 시간이 중요하지만, 기본 개념은 경량 패턴과 같습니다.
경량 패턴은 객체의 개수를 줄이기 위해 객체 데이터들 두 종류로 나눠서 사용했습니다.
첫 번째는 객체 중 같은 값을 공유할 수 있는 데이터 값을 모으는 것입니다. 대표적으로 이전 예제에서 설명했듯이 Mesh나 Texture 등을 공유합니다.(공유 데이터들은 GoF에서는 고유 상태라고 부른다)
두 번째는 인스턴스별로 값이 다른 외부 상태에 해당합니다. 이전 예제에서의 나무의 위치, 크기, 색 등이 이에 해당합니다.
이렇게 경량 패턴은 한 개의 고유 상태를 다른 객체에서 공유하게 만들어 메모리 사용량을 줄이고 있습니다. 경량 패턴은 어떻게 보면 간단합니다. 고유 상태를 다른 객체에서 공유하게 만들어 메모리 사용량을 줄이는 것이 이 패턴의 핵심입니다.
여기까지 인스턴스 렌더링을 통해 경량 패턴이 어떻게 작동하는 지에 대해 알아봤지만, 자원 공유만 하고 끝나기 때문에 이게 왜 패턴이지? 라고 생각할 수도 있습니다. 물론 개발자의 생각으로는 부정을 통해 이유를 생각하는 것이 대단히 효율적입니다. 실제로 공유 상태를 TreeModel 클래스로 깔끔하게 분리할 수 있어서 그렇게 보이는 측면도 있습니다.
전략 시뮬레이션 게임에서 Terrian을 만든다고 가정한다면, 물, 평야, 산같이 지형의 값이 다른 대용량의 환경관리를 할 수 있습니다. 경량 패턴을 사용해 공유 객체를 명확하게 하고 메모리를 잘 활용해 관리한다고 한다면, 하나의 객체가 여러 곳에서 물, 평야, 산같이 다른 객체가 여러 곳에서 동시에 존재하는 것처럼 보이도록 코드를 작성하는 방법도 효과적입니다.
공유 객체가 명확하지 않은 경우에는 경량 패턴을 써서 쓰는 것이 더 비효율적으로 보일수도 있습니다. 실제로 그런 경우도 있기 때문에 상황에 맞게 다른 최적화 방법을 고려하는 것이 좋습니다. 예를 들어, LOD(Level of Detail), 배칭(Batching), 프러스텀 컬링(Frustum Culling) 등의 다른 기법들을 사용할 수 있습니다.
다음 포스팅에서는 경량 패턴을 이용해 Terrain의 정보를 담는 방법을 알아보겠습니다. 감사합니다.