기록공간

7장. 게임을 위한 순차 패턴 - 이중 버퍼 본문

Game Design Pattern, Logic

7장. 게임을 위한 순차 패턴 - 이중 버퍼

입코딩 2020. 3. 15. 21:35
반응형

 


"여러 순차 작업의 결과를 한번에 보여준다."

 


본질적으로 컴퓨터는 순차적으로 동작한다. 컴퓨터의 능력은 굉장히 큰 일을 작은 단계로 쪼개어 하나씩 처리할 수 있는데 있다. 하지만 사용자 입장에서는 순차적으로 혹은 동시에 진행되는 여러 작업을 한 번에 모아서 봐야 할 때가 있다. 

 

예를들어, 게임에서 렌더링은 꼭 필요하다. 유저에게 보여줄 게임 화면을 그릴 때는 멀리 있는 산, 언덕, 나무 등 전부를 한번에 보여준다. 이때 화면을 그리는 중간 과정이 보이면 몰입할 수가 없다. 장면은 부드럽고 빠르게 업데이트되어야 하고 매 프레임이 완성되면 한 번에 보여줘야 한다.

 

이중 버퍼는 이런 문제를 해결한다. 들어가기에 앞서서 컴퓨터가 화면을 어떻게 그리는지부터 살펴 보자.

 

컴퓨터 그래픽스 작동 원리

컴퓨터 모니터 같은 비디오 디스플레이는 한 번에 한 픽셀을 그린다. 화면 왼쪽에서 오른쪽으로 한 줄을 그린 후 다음 줄로 내려간다. 후면 우하단에 도달하면 다시 좌상단으로 돌아가서 같은 작업을 반복한다. 너무나도 빠르기 때문에 눈으로는 이 과정을 알아챌 수 없다.

 

대부분의 컴퓨터에서는 픽셀을 프레임버퍼(Framebuffer)로부터 가져오기 때문에 알 수 있다. 프레임버퍼는 메모리에 할당된 픽셀들의 배열로, 한 픽셀의 색을 여러 바이트로 표현하는 RAM의 한 부분이다. 화면에 색을 표현할때 프레임버퍼로부터 한 바이트씩 색깔 값을 읽어온다.

 

궁극적으로 게임을 화면에 보여주려면 프레임버퍼에 값을 써 넣으면 된다. 온갖 그래픽 알고리즘들도 결국에는 프레임버퍼에 값을 써 넣기 위해 존재한다. 다만 사소한 문제가 하나 있다.

 

앞에서 컴퓨터는 순차적이라고 했다. 렌더링 코드가 실행되는 동안 다른 작업은 실행되지 않을 거라고 생각한다. 보통은 맞는 얘기지만 렌더링 도중에 실행되는 작업이 일부 있다. 그중 하나가 게임이 실행되는 동안 비디오 디스플레이가 프레임버퍼를 반복해서 읽는 것인데, 여기에서 문제가 발생한다.

 

화면에 웃는 얼굴을 하나 그린다고 해보자. 코드에서는 루프를 돌면서 프레임 버퍼에 픽셀 값을 입력한다. 하지만 몰랐던 점은 코드가 프레임버퍼에 값을 쓰는 도중에도 비디오 드라이버에서 프레임버퍼 값을 읽는다는 것이다. 우리가 입력해놓은 픽셀 값을 비디오 드라이버가 화면에 출력하면서 웃는 얼굴이 나오기 시작하지만 아직 다 입력하지 못한 버퍼 값까지 화면에 출력될 수 있다. 그 결과 그림 일부만 나타나는 버그인 테어링(Tearing) 혹은 화면 찢김 현상이 발생한다.

 

렌더링 도중 나타나는 테어링

이러한 이유로 이중 버퍼 패턴이 필요하다. 이 문제를 해결하려면 코드에서는 픽셀을 한 번에 하나씩 그리되, 비디오 드라이버는 전체 픽셀을 한 번에 다 읽을 수 있게 해야 한다. 이전 프레임에서는 웃는 얼굴이 안보이다가 다음 프레임에 보여야 한다. 이중 버퍼 패턴이 이런 문제를 어떻게 해결하는지 비유로 살펴보겠다.

 

1막 1장

유저를 우리가 연출한 연극을 보는 관객이라고 상상해보자. 1장이 끝나면 2장을 시작하기 전에 무대 설치를 바꿔야 한다. 장면이 바뀔 때마다 무대 담당자들이 올라와 설치물을 옮긴다면 몰입감이 깨질 것이다. 다음 장번을 준비하는 동안 조명을 어둡게 할 수 있지만, 여전히 관객은 뭔가 가 진행된다는 걸 알 수 있다. 끊김 없이 바로 장면을 전환할 방법은 없을까?

 

공간을 좀 투자하면 괜찮은 해결책을 얻을 수 있다. 무대를 두 개 준비해놓고 각 무대별로 조명을 설치한다. 1장은 무대 A에서 시작한다. 그동안 무대 B는 어둡게 해놓고 무대 당담자들이 미리 2장 무대 장치를 준비해놓는다. 1장이 끝나면 무대 A의 조명을 전부 끄고 무대 B의 조명을 켠다. 관객은 무대 B에서 2장이 시작되는 걸 곧바로 볼 수 있다.

 

그동안 무대 담당자들은 조명이 꺼진 무대 A로 가서 1장 무대장치를 철거하고 3장 무대장치를 설치한다.  2장이 끝나면 다시 무대 A에만 조명을 비춘다. 연극이 진행되는 동안 이런 식으로 어두워진 무대를 다음 장을 준비하는 공간으로 활용한다. 무대 조명만 바꾸면 장면을 전환할 수 있기 때문에 기다리지 않고 다음 장면을 볼 수 있다.

 

다시 그래픽스로...

이중 버퍼가 바로 이런 식이다. 거의 모든 게임의 렌더링 시스템이 내부에서 이렇게 동작한다. 프레임버퍼를 두 개준비해, 하나의 버퍼에는 무대 A처럼 지금 프레임에 보일 값을 둬서 GPU가 원할 때 언제든지 읽을 수 있게 한다. 

 

그동안 렌더링 코드는 또 다른 프레임버퍼를 채운다. 조명이 꺼진 무대 B라고 생각하면 된다. 렌더링 코드가 장면을 다 그린 후에는 조명을 바꾸는 것처럼 버퍼를 교체한 뒤에 비디오 하드웨어에 지금부터는 두 번째 버퍼를 읽으라고 알려준다. 화면 깜빡임에 맞춰 버퍼가 바뀌기 때문에 테어링 현상은 더 이상 생기지 않고 전체 장면이 한 번에 나타나게 된다.

 

그리고 그 사이에 교체된 이전 프레임버퍼에 다음 프레임에 들어갈 화면을 그리면 된다.

 

패턴

버퍼 클래스는 변경이 가능한 상태인 버퍼를 캡슐화한다. 버퍼는 점차적으로 수정되지만, 밖에서는 한 번에 바뀌는 것처림 보이게 하고 싶다. 이를 위해서는 버퍼 클래스가 현재 버퍼다음 버퍼, 이렇게 두개의 버퍼를 갖는다. 

 

정보를 읽을 때는 항상 현재 버퍼에 접근한다. 정보를 쓸 때는 항상 다음 버퍼에 접근한다. 변경이 끝나면 다음 버퍼와 현재 버퍼를 교체해 다음 버퍼가 보여지게 한다. 현재 버퍼는 새로운 다음 버퍼가 되어 재사용된다.

 

언제 쓸 것인가?

이중 버퍼 패턴은 언제 써야 할지를 그냥 알 수 있는 패턴 중 하나이다. 이중 버퍼 시스템이 없다면 시작적으로 이상하게 보이거나 시스템이 오작동하기 떄문이다. 구체적으로 다음 같은 상황에서 적합하다.

 

  • 순차적으로 변경해야 하는 상태가 있다.

  • 이 상태는 변경 도중에도 접근 가능해야 한다.

  • 바깥 코드에서는 작업 중인 상태에 접근할 수 없어야 한다.

  • 상태에 값을 쓰는 도중에도 기다리지 않고 바로 접근할 수 있어야 한다.

주의사항

교체 연산 지체에 시간이 걸린다

이중 버퍼 패턴에서는 버퍼에 값을 다 입력했다면 버퍼를 교체해야 한다. 교체 연산은 원자적이어야 한다. 즉 교체 중에는 두 버퍼 모두에 접근할 수 없어야 한다. 대부분은 포인터만 바꾸면 되기 때문에 충분히 빠르지만, 혹시라도 버퍼에 값을 쓰는 것보다 교체가 더 오래 걸린다면 이중 버퍼 패턴이 아무런 도움이 안 된다.

 

버퍼가 두 개 필요하다

이중 버퍼 패턴은 메모리가 더 필요하다. 이름에서 알 수 있듯이 상태를 메모리 버퍼 두 곳에 항상 쌍으로 가지고 있어야 하기 때문에 메모리가 부족한 기기에서는 굉장히 부담이 될 수 있다. 메모리가 부족해 버퍼를 두 개 만들기 어렵다면 이중 버퍼 패턴을 포기하고 상태를 변경하는 동안 밖에서 접근하지 못하게 할 방법을 찾아야 할 것이다.

 

예제 코드

이론은 이해했으니 실제로 어떻게 동작하는지 보도록 하자. 프레임 버퍼에 픽셀을 그릴 수 있는 아주 단순한 그래픽 시스템을 만들 것이다. 요즘 콘솔이나 PC는 이런 식으로 저수준에 접근할 수 있도록 비디오 드라이버에서 지원해주지만, 직접 구현해보면 어떤 식으로 돌아가는지 이해하는데 도움이 될 것이다. 버퍼부터 살펴보자.

class Framebuffer
{
public:
	Framebuffer() { clear(); }
	void clear()
	{
		for (int i = 0; i < WIDTH * HEIGHT; ++i)
		{
			pixels_[i] = WHITE;
		}
	}
	void draw(int x, int y)
	{
		pixels_[(WIDTH * y) + x] = BLACK;
	}
	const char* getPixels() { return pixels_; }

private:
	static const int WIDTH = 160;
	static const int HEIGHT = 120;

	char pixels_[WIDTH * HEIGHT];
};

Framebuffer 클래스는 clear() 메서드로 전체 버퍼를 흰색으로 채우거나, draw() 메서드로 특정 픽셀에 검은색을 입력할 수 있다. getPixels() 메서드를 통해 픽셀 데이터를 담고 있는 메모리 배열에 접근할 수도 있다. 이 예제에서는 다루지 않지만, 비디오 드라이버가 화면을 그리기 위해 버퍼 값을 읽을 때 호출하는 게 getPixels() 이다.

 

이걸 Scene 클래스 안에 넣는다. Scene 클래스에서는 여러 번 draw()를 호출해 버퍼에 원하는 그림을 그린다.

class Scene
{
public:
	void draw()
	{
		buffer_.clear();
		buffer_.draw(1, 1); buffer_.draw(4, 1);
		buffer_.draw(1, 3); buffer_.draw(2, 4);
		buffer_.draw(3, 4); buffer_.draw(4, 3);
	}
	Framebuffer& getBuffer() { return buffer_; }

private:
	Framebuffer buffer_;
};

위 코드 출력 장면

게임 코드는 매 프레임마다 어떤 장면을 그려야 할지를 알려준다. 먼저 버퍼를 지운 뒤 한 번에 하나씩 그리고자 하는 픽셀을 찍는다. 동시에 비디오 드라이버에서 내부 버퍼에 접근할 수 있도록 getBuffer()를 제공한다.

 

별로 복잡해 보이지 않지만, 이것만으로는 문제가 생길 수 있다. 비디오 드라이버가 아무 때나 getPixel()를 호출해 버퍼에 접근할 수 있기 때문이다.

buffer_.draw(1, 1); buffer_.draw(4, 1);
// <- 이때 비디오 드라이버가 픽셀 버퍼 전체를 읽을 수도 있다!
buffer_.draw(1, 3); buffer_.draw(2, 4);
buffer_.draw(3, 4); buffer_.draw(4, 3);

 이런 일이 벌어지면 화면에 눈만 있고 입은 없는 얼굴이 나오게 된다. 다음 프레임에서도 렌더링하는 도중 어딘가에서 비디오 드라이버가 버퍼를 읽어버릴 수 있다. 이러면 무섭게 깜빡거리는 화면을 보게 된다. 이중 버퍼로 이 문제로 해결해보자.

class Scene
{
public:
	Scene() : current_(&buffers_[0]), next_(&buffers_[1]) {}
	void draw()
	{
		next_->clear();
		next_->draw(1, 1);
		// 생략...
		next_->draw(4, 3);
		swap();
	}
	Framebuffer& getBuffer() { return *current_; }

private:
	void swap()
	{
		// 버퍼 포인터만 교체한다.
		Framebuffer* temp = current_;
		current_ = next_;
		next_ = temp;
	}
	Framebuffer* buffers_[2];
	Framebuffer* current_;
	Framebuffer* next_;
};

이제 Scene 클래스에는 버퍼 두 개가 buffers_ 배열에 들어 있다. 버퍼에 접근할 때는 배열 대신 next_와 current_ 포인터 멤버 변수로 접근한다. 렌더링할 때는 next_ 포인터가 가리키는 다음 버퍼에 그리고, 비디오 드라이버는 current_ 포인터로 현재 버퍼에 접근해 픽셀을 가져온다.

 

이런 식으로 비디오 드라이버가 작업 중인 버퍼에 접근하는 걸 막을 수 있다. 이제 장면을 다 그린 후에 swap()만 호출하면 된다. swap()에서는 next_와 current_ 포인터를 맞바꾸는 게 전부다. 이제 비디오 드라이버가 getBuffer()를 호출하면 이전에 화면에 그리기 위해 사용한 버퍼 대신 방금 그린 화면이 들어 있는 버퍼를 얻게 된다. 테어링이나 이상한 화면은 더 이상 나오지 않는다.

 

그래픽스 이외의 활용법

변경 중인 상태에 접근할 수 있다는 게 이중 버퍼로 해결하려는 문제의 핵심이다. 원인은 보통 두 가지다. 첫 번째는 다른 스레드나 인터럽드에서 상태에 접근하는 경우인데, 이는 앞에서 이미 살펴보았다.

 

이것만큼이나 흔한 게 어떤 상태를 변경하는 코드가, 동시에 지금 변경하려는 상태를 읽는 경우다. 특히 물리나 인공지능같이 객체가 서로 상호작용할 때 이런 경우를 쉽게 볼 수 있다. 이 때에도 이중 버퍼가 도움이 될 수 있다.

 

멍청한 인공 지능

슬랩스틱 코미디 기반 게임에 들어갈 행동 시스템을 만든다고 해보자. 게임에는 무대가 준비되어 있고, 그 위에서 여러 배우들이 이런저런 몸개그를 하고 있다. 먼저 배우를 위한 상위 클래스를 만들자.

class Actor
{
public:
	Actor() : slapped_(false) {}
	virtual ~Actor() {}
	virtual void update() = 0;
	void reset() { slapped_ = false; }
	void slap() { slapped_ = true; }
	bool wasSlapped() { return slapped_; }

private:
	bool slapped_;
};

매 프레임마다 배우 객체의 update()를 호출해 배우가 뭔가를 진행할 수 있게 해줘야 한다. 특히 유저 입장에서는 모든 배우가 한 번에 업데이트되는 것처럼 보여야 한다. 

 

배우는 서로 상호작용할 수 있다. '돌아가면서 서로 때리는' 것을 '상호작용'이라고 부를 수 있다면 말이다. update()가 호출될 때 배우는 다른 배우 객체의 slap()을 호출해 때리고, wasSlapped()를 통해서 맞았는지 여부를 알 수 있다.

 

배우들이 상호작용할 수 있는 무대를 제공하자.

class Stage
{
public:
	void add(Actor* actor, int index)
	{
		actors_[index] = actor;
	}
	void update()
	{
		for (int i = 0; i < NUM_ACTORS; ++i)
		{
			actors_[i]->update();
			actors_[i]->reset();
		}
	}

private:
	static const int NUM_ACTORS = 3;
	Actor* actors_[NUM_ACTORS];
};

Stage 클래스는 배우를 추가할 수 있고, 관리하는 배우 전체를 업데이트 할 수 있다. 유저 입장에서는 배우들이 한 번에 움직이는 것처럼 보이겠지만 내부적으로는 하나씩 업데이트 된다.

 

배우가 따귀를 맞았을 때 딱 한 번만 반응하게 하기 위해서 맞은 상태를 update() 후에 바로 reset() 한다는 점에 주의하자.

 

다음으로 Actor를 상속받는 구체 클래스 Comedian을 정의한다. 코미디언은 다른 배우 한 명을 보고 있다가 누구한테든 맞으면 보고 있던 배우를 때린다.

class Comedian : public Actor
{
public:
	void face(Actor* actor) { facing_ = actor; }
	virtual void update()
	{
		if (wasSlapped())
			facing_->slap();
	}

private:
	Actor* facing_;
};

이제 코미디언 몇 명을 무대 위에 세워놓고 어떤 일이 벌어지는지 보자. 3명의 코미디언이 각자 다음 사람을 바라보게 하고, 마지막 사람은 첫 번째 사람을 볼 수 있도록 큰 원 형태로 둔다.

Stage stage;

Comedian* harry = new Comedian();
Comedian* baldy = new Comedian();
Comedian* chump = new Comedian();

harry->face(baldy);
baldy->face(chump);
chump->face(harry);

stage.add(harry, 0);
stage.add(baldy, 1);
stage.add(chump, 2);

 무대는 다음 그림과 같다. 화살표는 누가 누구를 보고 있는지 보여준다.

 

Harry를 떄린 후에 어떤 일이 벌어지는지 보자.

harry->slap();
stage.update();

코드가 실행된 후에는 이런 결과를 얻게 된다.

 

  • Stage가 actor 0인 Harry를 업데이트 -> Harry가 따귀를 맞았다. Harry는 Baldy를 때린다.

  • Stage가 actor 1인 Baldy를 업데이트 -> Baldy가 따귀를 맞았다. Baldy는 Chump를 때린다.

  • Stage가 actor 2인 Chump를 업데이트 -> Chump가 따귀를 맞았다. Chump는 Harry를 때린다.

  • Stage 업데이트 끝

처음에 Harry를 때린 것이 한 프레임 만에 전체 코미디언에게 전파된다. 이번에는 코미디언들이 바라보는 대상은 유지하되 Stage 배열 내에서의 위치만 바꿔보자.

 

무대를 초기화하는 코드에서 나머지는 그대로 두고 무대에 배우를 추가하는 코드만 다음과 같이 바꾼다.

stage.add(harry, 2);
stage.add(baldy, 1);
stage.add(chump, 0);

다시 Harry를 때린 후에 어떤 일이 벌어지는지 보자.

 

  • Stage가 actor 0인 Chump를 업데이트 -> Chump가 따귀를 맞지 않았다. Chump는 아무것도 하지 않는다.

  • Stage가 actor 1인 Baldy를 업데이트 -> Baldy가 따귀를 맞지 않았다. Baldy는 아무것도 하지 않는다.

  • Stage가 actor 2인 Harry를 업데이트 -> Harry가 따귀를 맞았다. Harry는 Baldy를 때린다.

  • Stage 업데이트 끝

전혀 다른 결과가 나왔다. 문제는 명확하다. 배우 전체를 업데이트할 때 배우의 맞은 상태를 바꾸는데, 그와 동시에 같은 값을 읽기도 하다 보니 업데이트 초반에 맞은 상태를 바꾼 게 나중에 가서 영향을 미치게 된다. 

 

결과적으로 배우가 맞았을 떄 배치 순서에 따라 이번 프레임 내에서 반응할 수도 있고 다음 프레임에서야 반응할 수도 있다. 배우들이 동시에 행동하는 것처럼 보이고 싶었는데 이런 식으로 업데이트 순서에 따라 결과가 다르면 안 된다.

 

맞은 상태를 버퍼에 저장하기

다행히 여기에서도 이중 버퍼 패턴을 써먹을 수 있다. 이번에는 통짜 버퍼 객체 두 개 대신, 더 정교하게 배우의 맞은 상태만 버퍼에 저장한다.

class Actor
{
public:
	Actor() : currentSlapped_(false) {}
	virtual ~Actor() {}
	virtual void update() = 0;

	void swap()
	{
		// 버퍼 교체
		currentSlapped_ = nextSlapped_;
		
		// 다음 버퍼를 초기화
		nextSlapped_ = false;
	}

	void slap() { nextSlapped_ = true; }
	bool wasSlapped() { return currentSlapped_; }

private:
	bool nextSlapped_;
	bool currentSlapped_;
};

Actor 클래스의 slapped_ 상태가 두 개로 늘었다. 앞에서 본 그래픽스 예제처럼 현재 상태(currentSlapped_)는 읽기 용도로, 다음 상태(nextSlapped_)는 쓰기 용도로 사용한다.

 

reset() 메서드가 없어지고 대신 swap() 메서드가 생겼다. swap()은 다음 상태를 현재 상태로 복사한 후 다음 상태를 초기화한다. Stage 클래스도 약간 고쳐야 한다.

void Stage::update()
{
	for (int i = 0; i < NUM_ACTORS; ++i)
	{
		actors_[i]->update();
	}
	for (int i = 0; i < NUM_ACTORS; ++i)
	{
		actors_[i]->swap();
	}
}

이제 update() 메서드는 모든 배우를 먼저 업데이트한 다음에 상태를 교체한다. 결과적으로 배우 객체는 자신이 맞았다는 걸 다음 프레임에서야 알 수 있다. 이제 모든 배우는 배치 순서와 상관없이 똑같이 행동한다. 유저나 바깥 코드 입장에서는 모든 배우가 한 프레임에 동시에 업데이트 되는 것으로 보인다.

 

디자인 결정

이중 버퍼는 굉장히 단순하다. 사례 역시 앞에서 본 예제에서 크게 벗어나지 않는다. 이중 패턴을 구현할 때 결정해야 할 중요한 점 두 가지를 살펴보겠다.

 

버퍼를 어떻게 교체할 것인가?

버퍼 교체 연산은 읽기 버퍼와 쓰기 버퍼 모두를 사용하지 못하게 한다는 점에서 매우 중요하다. 최고 성능을 얻기 위해서는 최대한 빠르게 교체해야 한다.

 

1. 버퍼 포인터나 레퍼런스를 교체

그래픽스 예제에서 봤던 방식이다. 이중 버퍼 그래픽스에서는 가장 일반적으로 사용된다. 

  • 빠르다. 버퍼가 아무리 커도 포인터 두 개만 바꾸면 된다. 속도나 단순성 면에서 이보다 좋은 방법을 찾기 어렵다.

  • 버퍼 코드 밖에서는 버퍼 메모리를 포인터로 저장할 수 없다는 한계가 있다. 이 방식에서는 데이터를 실제로 옮기지는 않고, 앞서 본 무대 비유에서처럼 주기적으로 다른 버퍼를 읽으라고 알려준다. 버퍼 외부 코드에서 버퍼 내 데이터를 직접 포인터로 저장하면 버퍼 교체 후 잘못 된 데이터를 가리킬 가능성이 있다. 

    특히 비디오 드라이버가 프레임 버퍼는 항상 메모리에서 같은 위치에 있을 거라고 기대하는 시스템에서 문제가 된다. 이런 시스템에서는 이중 버퍼를 쓸 수 없다.

  • 버퍼에 남아 있는 데이터는 바로 이전 프레임 데이터가 아닌 2 프레임 전 데이터다.(버퍼끼리 데이터를 복사하지 않는 한) 그렇기 때문에 버퍼에 남은 데이터를 재사용할 때는 이 데이터가 2프레임 전 데이터라는 것을 감안해야 한다.

2. 버퍼끼리 데이터를 복사

유저가 다른 버퍼를 재지정하게 할 수 없다면, 다음 버퍼 데이터를 현재 버퍼로 복사해주는 수 밖에 없다. 앞에서 본 코미디언 예제가 그런 식이다. 복사해야 하는 상태가 bool 변수 하나밖에 없었기 때문에 버퍼 포인터를 교체하는 것과 속도 면에서 차이가 없어서 이런 방법을 선택했다. 

  • 다음 버퍼에는 딱 한 프레임 전 데이터가 들어 있다. 이전 버퍼에서 좀 더 최신 데이터를 얻을 수 있다는 점에서 위 방식보다 좋다.

  • 교체 시간이 더 걸린다. 이게 가장 큰 단점이다. 교체를 하려면 전체 버퍼를 다 복사해야 한다. 버퍼가 전체 프레임버퍼같이 크다면, 엄청난 시간이 걸릴 수 있다. 그동안 양쪽 버퍼에 읽고 쓰기가 불가능하기 때문에 제약이 크다.

얼마나 정밀하게 버퍼링할 것인가?

그다음으로 생각해볼 것은 버퍼가 어떻게 구성되어 있는가다. 버퍼가 하나의 큰 데이터 덩어리인가, 아니면 객체 컬렉션 안에서 분산되어 있는가? 먼저 살펴본 그래픽 예제는 전자고, 코미디언 예제는 후자다.

 

대부분은 버퍼링하는 대상에 따라 대답이 달라지겠지만 일부 예외도 있다. 예를 들어 코미디언 객체들은 하나의 메시지 블록에 메시지를 전부 저장해놓고 인덱스로 참조할 수도 있다.

 

1. 버퍼가 한 덩어리라면

간단히 교체할 수 있다. 버퍼 두 개만 있기 때문에 한 번에 맞바꾸기만 하면 된다. 포인터로 버퍼를 가리키고 있다면 버퍼 크기와 상관없이 포인터 대입 두 번만으로 버퍼를 교체할 수 있다.

 

2. 여러 객체가 각종 데이터를 들고 있다면

교체가 더 느리다. 전체 객체 컬렉션을 순회하면서 교체하라고 알려줘야 한다.

코미디언 예제에서는 어쨋거나 다음 맞은 상태를 정리하기 위해 매 프레임마다 버퍼된 상태를 건드려줘야 했기 때문에 이 방식도 문제없었다. 만약 이전 버퍼를 건드리지 않아도 된다면, 버퍼가 여러 객체에 퍼져 있어도 단일 버퍼와 같은 성능을 낼 수 있도록 간단하게 최적화할 방법이 있다.

아이디어는 현재다음 포인터 개념을 객체 상대적 오프셋으로 응용하는 데 있다. 다음 코드를 보자.

class Actor
{
public:
	static void init() { current_ = 0; }
	static void swap() { current_ = next(); }

	void slap() { slapped_[next()] = true; }
	bool wasSlapped() { return slapped_[current_]; }

private:
	static int current_;
	static int next() { return 1 - current_; }

	bool slapped_[2];
};

배우는 상태 배열(slapped_[2])의 current_ 인덱스를 통해 맞은 상태에 접근한다. 다음 상태는 배열의 나머지 한 값이므로 next()로 인덱스를 계산한다. 상태 교체는 current_ 값을 바꾸기만 하면 된다. 여기서 swap()이 '정적' 함수이기 때문에 한 번만 호출해도 '모든' 배우의 상태를 교체할 수 있다는 게 핵심이다. 


자료의 출처는 http://gameprogrammingpatterns.com/double-buffer.html입니다.

반응형
Comments