이중 버퍼 패턴, Double Buffer Pattern
여러 순차 작업의 결과를 한번에 보여준다.
본질적으로 컴퓨터는 순차적으로 동작한다. 컴퓨터의 능력은 굉장히 큰 일을 작은 단계로 쪼개어 하나씩 처리할 수 있는 데 있다. 하지만 사용자 입장에서는 순차적으로 혹은 동시에 진행되는 여러 작업을 한 번에 모아서 봐야 할때가 있다.
대표적인 예가 게임에서의 렌더링이다. 유저에게 보여줄 게임 화면을 한 번에 보여준다. 이때 화면을 그리는 중간 과정이 보이면 몰입할 수가 없다. 장면은 부드럽고 빠르게 업데이트 되어야 하고 매 프레임이 완성되면 한 번에 보여줘야 한다.
컴퓨터 모니터 같은 비디오 디스플레이는 한 번에 한 픽셀 그린다. 화면 왼쪽에서 오른쪽으로 한 줄을 그린 후 다음 줄로 내려가는 식이다. 대부분의 컴퓨터는 픽셀을 프레임 버퍼로부터 가져온다. 프레임 버퍼는 메모리에 할당된 픽셀들의 배열로, 한 픽셀의 색을 여러 바이트로 표현하는 RAM의 한 부분이다.
렌더링 도중에 실행되는 작업이 일부 있다. 그중 하나가 게임이 실행되는 동안 비디오 디스플레이가 프레임버퍼를 반복해서 읽는 것이다. 즉, 코드가 프레임버퍼에 값을 쓰는 도중에 비디오 드라이버에서 프레임버퍼 값을 읽는다.
이럴때 와레와 같은 테어링 버그가 생길 수 있다.
- (그림 1) 코드에서 픽셀 값을 입력하는 동안 비디오 드라이버도 프레임 버퍼를 읽기 시작한다.
- (그림 2) 결국에는 렌더러가 입력하는 픽셀을 앞질러, 아직 입력하지 않은 픽셀까지 비디오 드라이버가 읽어 들인다.
- (그림 3) 렌더러는 버퍼에 값을 다 그려 넣었지만
- (그림 4) 드라이버는 그 새로 추가된 픽셀을 읽어 들이지 않는다.
이중 버퍼 패턴은 이런 문제를 해결 한다.
1막 1장
연극에서는 무대를 두 개 준비해놓고 한 곳에서 공연을 하는 동안 다른 한 곳은 준비를 한다. 무대 조명만 바꾸면 장면을 곧바로 전환할 수 있기 때문에 기다리지 않고 다음 장면을 볼 수 있다.
이중 버퍼가 바로 이런 식이다. 거의 모든 게임의 렌더링 시스템이 내부에서 이렇게 동작한다. 프레임 버퍼를 두 개 준비해, 하나의 버퍼에는 버퍼에는 프레임에 보일 값을 둬서 GPU가 원할 때 언제든지 읽을 수 있게 한다. 그동안 렌더링 코드는 다른 프레임 버퍼를 채운다. 렌더링 코드가 장면을 다 그린 후에는 버퍼를 교체한 뒤에 비디오 하드웨어에 지금부터는 두 번째 버퍼를 읽으라고 알려준다. 테어링은 더 이상 생기지 않고 전체 장면이 한 번에 나타나게 된다.
그 사이에 교체된 이전 프레임버퍼에 다음 프레임에 들어갈 화면을 그리면 된다.
패턴
버퍼 클래스는 변경이 가능한 상태인 버퍼를 캡슐화 한다. 버퍼는 수정되지만, 밖에서는 한 번에 바뀌는 것처럼 보이게 하기 위해 버퍼 클래스는 현재 버퍼와 다음 버퍼, 이렇게 두 개의 버퍼를 갖는다.
정보를 읽을 때는 항상 현재 버퍼에 접근한다. 정보를 쓸 때는 항상 다음 버퍼에 접근한다. 변경이 끝나면 다음 버퍼와 현재 버퍼를 교체해 다음 버퍼가 보여지게 한다. 현재 버퍼는 새로운 다음 버퍼가 되어 재사용 된다.
예제
버퍼부터 살펴보자.
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() 메서드를 통해 픽셀 데이터를 담고 잇는 메모리 배열에 접근할 수도 있다.
이걸 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);
이런일이 벌어지면 테어링 버그가 발생하게 된다. 다음 프레임에서도 렌더링하는 도중 어딘가에서 비디오 드라이버가 버퍼를 읽어버릴 수 있다. 이러면 무섭게 깜빡거리는 flickering화면을 보게된다. 이중 버퍼로 이 문제를 해결해보자.
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_ 포인터로 현재 버퍼에 접근해 픽셀을 가져온다.
그래픽스 외의 활용법
변경 중인 상태에 접근할 수 있다는 게 이중 버퍼로 해결하려는 문제의 핵심이다. 원인은 보통 두 가지다. 첫 번째는 다른 스레드나 인터럽트에서 상태에 접근하는 경우인데, 이는 그래픽스 예제에서 이미 살펴 보았다.
이것만큼이나 흔한 게 어떤 상태를 변경하는 코드가, 동시에 지금 변경하려는 상태를 읽는 경우다. 특히 물리나 인공지능같이 객체가 서로 상호작용할 때 이런 경우를 쉽게 볼 수 있다. 이때에도 이중 버퍼가 도움이 될 수 있다.
멍청한 인공 지능
슬랩스틱 코미디 기반 게임에 들어갈 행동 시스템을 만든다고 해보자. 배우를 위한 상위 클래스를 만들자.
class Actor {
public:
Actor(const char* name) : slapped_(false), name_(name) {}
virtual ~Actor() {}
virtual void update() = 0;
void reset() { slapped_ = false; }
void slap() {
slapped_ = true;
}
bool wasSlapped() { return slapped_; }
const std::string& name() const { return name_; }
private:
bool slapped_;
std::string name_;
};
매 프레임마다 배우 객체의 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() 메서드를 제공한다. 유저 입장에서는 배우들이 한 번에 움직이는 것처럼 보이겠지만 내부적으로는 하나씩 업데이트된다.
다음으로 Actor를 상속받는 구체 클래스 Comedian을 정의한다. 코미디언이 하는 일은 굉장히 단순하다. 다른 배우 한 명을 보고 있다가 누구한테든 맞으면 보고 있던 배우를 때린다.
class Comedian : public Actor {
public:
void face(Actor* actor) { facing_ = actor; }
virtual void update() {
if(wasSlapped())
facing_->slap();
}
private:
Actor* facing_;
};
이제 코미디언 몇 명을 무대 위에 세워보자.
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->slap();
stage.update();
무대는 다음 그림과 같다.
처음에 harry를 때린 것이 한 프에임만에 전체 코미디언에게 전파된다.
이번에는 코미디언들이 바라보는 대상은 유지하되 stage 배열 내에서의 위치를 바꿔보자.
stage.add(harry, 2);
stage.add(baldy, 1);
stage.add(chump, 0);
전혀 다른 결과가 나왔다. 배우가 배치 순서에 따라 이번 프레임 내에서 반응할 수도 있고 다음 프에임에서야 반응할 수도 있다. 배우들이 동시에 행동하는 것처럼 보이고싶었는데 이런 식으로 업데이트 순서에 따라 결과가 다르면 안 된다.
맞은 상태를 버퍼에 저장하기
다행이도 여기에서도 이중 버퍼 패턴을 써먹을 수 있다. 이번에는 '버퍼' 객체 두 개 대신, 배우의 '맞은' 상태만 버퍼에 저장한다.
class Actor {
public:
Actor() : currentSlapped_(false);
virtual ~Actor() {}
virtual void update() = 0;
void swap() {
// 버퍼 교체.
currentSlapped_ = nextSlapped_;
// 다음 버퍼를 초기화.
nextSlapeed_ = false;
}
void slap() { nextSlapped_ = true; }
bool wasSlapped() { return currentSlapped_; }
private:
bool currentSlapped_;
bool nextSlapped_;
};
Actor 클래스의 slapped_ 상태가 두 개로 늘었다. 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() 메서드는 모든 배우를 먼저 업데이트한 다음에 상태를 교체한다. 결과적으로 배우 객체는 자신이 맞았다는 걸 다음 프레임에서야 알 수 있다. 모든 배우는 배치 순서와 상관없이 똑같이 행동한다. 유저나 바깥 코드 입장에서는 모든 배우가 한 프레임에 동시에 업데이트 되는 것으로 보인다.
More
이중 버퍼 패턴은 거의 모든 그래픽 관련 API에서 찾아볼 수 있다.
- OpenGL의 swapBuffers()
- Direct3D의 swap chain
- XNA의 endDraw()
이 포스트의 글과 그림의 출처는 http://gameprogrammingpatterns.com/double-buffer.html 입니다.
by 소년코딩
추천은 글쓴이에게 큰 도움이 됩니다.
악플보다 무서운 무플, 댓글은 블로그 운영에 큰 힘이됩니다.