본질적으로 컴퓨터는 어떻게 동작을 하죠?
한줄한줄씩 순차적으로 명령어를 실행한다. 하지만 사용자 입장에서는 순차적 혹은 동시에 진행되는 여러작업을 한번에 모아서 본다.
대표적으로 게임에서의 렌더링이 그부분 해당한다. 유저에게 보여줄 화면을 그릴때, 멀리있는 산, 언덕, 나무, 유저, NPC 이런것들 보여줄떄 전부 한번에 그려서 보여준다. 하나씩 그리는 걸 눈으로 보여지게 된다면 그 과정이 눈에보이면서 게임에 몰입하기가 어렵다.
화면 장면이 빠르게 업데이트가 되어야 하고 매 프레임이 완성되면 한번에 보여줘야 한다.
또한 화면을 지우고 다시그려야 하는 문제가 있기때문에 의도치 않게 화면에 깜박임이 생기며 이를 보완해주는 방법이 더블 버퍼링이다.
들어가기 앞서 컴퓨터가 화면을 어떻게 그리는지부터 복습해보자.
컴퓨터 그래픽스 작동 원리
컴퓨터 모니터 같은 디스플레이는 한번에 한 픽셀을 그린다. 화면 왼쪽에서 오른쪽으로 한줄을 그린후에 화면 다음 줄로 내려간다.
화면 우화단에 도달하면 다시 좌상단으로 돌아가서 같은 작업을 계속 반복한다. 너무 빠르기 떄문에 (1초 60번 60FPS) 눈으로는 이것을 알아채기가 어렵다.
이 과정은 픽셀들을 좁은 호스를 통해서 화면에 전달하는 것과 유사합니다. 그럼 호스는 어떤 색을 어디에 뿌려야 하는지 알고 있을까?
대부분의 컴퓨터는 픽셀을 프레임버퍼(FrameBuffer, RenderTarget) 메모리에 할당된 픽셀들의 배열로, 한 픽셀의 색을 여러 바이트로 표현하여 정하고 있다. 해당 호스는 이 값들을 가져와서 모니터에 한픽셀씩 그려준다.
결론적으로 게임을 화면에 보여주려면 이 프레임 버퍼 안에 그림을 그려주면 된다.
앞에서 컴퓨터는 순차적으로 실행을 한다고 했는데 렌더링 코드가 실행되는 동안 다른 작업은 실행되지 않을거라고 생각한다. 보통은 맞는 얘기지만 렌더링 도중에 실행되는 작업이 있다면 프레임버퍼에서 값을 읽고 있는데 중간에 프레임버퍼 값이 바껴버리는 결과 된다. 문제가 된다.
화면에 웃는 얼굴을 하나 그린다고 해보자. 코드에서 루프를 돌면서 프레임 버퍼 픽셀값을 입력한다. 하나 몰랐던 코드가 프레임 버퍼에 값을쓰는 도중에도 비디오 드라이버에서 프레임버퍼 값을 읽는다는 점이다.
우리가 입력해놓은 픽셀 값을 비디오 드라이버가 화면에 출력하면서 웃는 얼굴이 나오기 시작하지만 아직 다 입력하지 못한 버퍼값까지 화면에 출력이 되면서 오싹한 비주얼 버그인 테어링(tearing)이 발생한다.
그래서 우리는 이를 해결하기 위한 해결책으로 더블 버퍼링 패턴이 필요하다. 이문제를 해결하려면 코드에서는 픽셀을 한번에 하나씩 그리며, 그래픽 카드에서는 전체 픽셀을 한 번에 다 읽을 수 있게 해야한다.
이전 프레임에는 얼굴 그림이 하나도 안보이다가, 다음 프레임에 전체가 한번에 보여야 한다. 더블 버퍼링 패턴이 이런문제를 어떻게 하는지 알아보자.
Scene(씬)
유저를 우리가 연출한 보는 관객이라고 상상해보겠습니다. 1장이 끝나면 2장을 시작하기 전에 무대 설치를 바꿔야 한다. 장면이 바뀔때마다 보통은 조명을 어둡게하여 안보인 상태로 바꿔준다. 화면이 끊킴을 기달려야 한다. 방법이 없을까?
공간을 좀 투자하면 가능하다. 무대를 2개를 준비 해두고 각 무대별로 조명을 각각 설치 한후에 1장은 A무대에서 2장 B무대에서 공연을 하면 해결이 가능 한 부분이다.
A화면에서 열심히 공연을 하고있고 B무대에서는 과연 기다리고 있냐? B관계자들은 C화면을 열심히 준비하여 다음 장면을 보여주기 위해 세팅하고 있다. A
B 왔다갔따 화면을 그려주는 방식을 더블 버퍼링이라고 볼 수 있다.
패턴
버퍼클래스는 변경이 가능한 상태인 버퍼를 캡슐화 하고 있습니다. 버퍼는 점진적으로 수정 되지만, 화면(밖)에서는 한번에 바뀌는것 처럼 보일 것이다. 이를 위해서 버퍼와, 다음에 사용할 버퍼 2개의 버퍼를 만든다.
정보를 읽을 때는 현재 버퍼에만 접근을하고, 정보를 쓸때는 현재 버퍼가 아닌 다음 버퍼에 화면 정보를 그려준다. 각각 2개를 번갈아가면서 재사용 하게 된다.
사용은 언제 해야 하는가?
•
순차적으로 변경해야하는 상태가 있을 때
•
이 상태는 변경 도중에는 접근이 가능해야 한다.
•
바깥 코드에서 작업중인 상태에 접근할 수 없어야 한다.
•
상태에 값을 쓰는 도중에도 기다리지 않고 바로 접근 할 수 있어야 한다.
교체 연산 자체가 연샹량이 존재한다.
이중 버퍼는 버퍼에 값을 다 입력했다면 다음버포 교체 되야한다. 이 교체연산자체가 많은 연산량을 활용한다.
버퍼가 2개 필요하기 떄문에 메모리를 더 많이 사용한다.
기본적으로 2개를 만들어야 하기 떄문에 메모리 사용량이 늘어단다. 공간에 대한 부담이 커진다.
예제 코드
이론은 얼추 다 이해가 됬으니 실제로 어떻게 동작하는지 코드를 살펴보도록 하자.
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];
};
C++
복사
framebuffer 클래스는 clear메서드로 전체 버퍼를 흰색으로 채워주거나, draw 메소드로 특정위치의 픽셀을 검정색으로 채워줄수 있다.
이걸 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_;
};
C++
복사
얼굴 그림이 그려진다.
게임 코드는 매 프레임마다 어떤 장면을 그려야 하는지 가르켜 준다. 먼저 버퍼를 지운 뒤에 한번에 하나씩 그리고자 하는 픽셀을 찎는다. 동시에 GPU에서 내부 버퍼에 접근한수 있또록 getBuffer()메소드를 제공한다.
별로 복잡해 보이지 않지만, 이것만으로는 문제가 생길수 있다. 아무 때나 getPixel()를 호출해 버퍼에 접근할 수 있기 떄문이다.
buffer_.draw(1, 1);
buffer_.draw(4, 1);
// <- Video driver reads pixels here!
buffer_.draw(1, 3);
buffer_.draw(2, 4);
buffer_.draw(3, 4);
buffer_.draw(4, 3);
C++
복사
이런일이 벌어지면 화면에 눈만 있고 입과 얼굴은 나오지 않은 상태로 모니터에 출력이 된다.
이문제를 해결 하기 위해서 2중버퍼로 해결보자.
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()
{
// Just switch the pointers.
Framebuffer* temp = current_;
current_ = next_;
next_ = temp;
}
Framebuffer buffers_[2];
Framebuffer* current_;
Framebuffer* next_;
};
C++
복사
이제 씬에는 버퍼가 2개가 만들어져 있다. 버퍼에 접근 할떄는 배열 대신에 next, current를 통해서 포인터 멤버변수로 접근하겠다.
이런식으로 GPU가 작업중인 버퍼에 접근하는 걸 막 을수 있다. 그리는 쪽은 무조건 next, 출력 되는쪽은 current 이기 때문에 설계구조상 서로에게 연관될수 없는 구조가 되어버렸다.
이제 이렇게 사용하면 화면깜박임이나, 테어링 이나 이런 보기 싫은 장면들이 화면에 더이상 나오지 않는다.
그래픽스 외의 활용 사례
변경 중인 상태에 접근할 수 있다는게 이중버퍼로 해결하려는 문제의 핵심이다. 원인은 보통 2가지이다. 첫번때는 다른 스레드나 인터럽트(interrupt)에서 상태에 접근하는 경우인데, 이를 그래픽스 예제에서 대충 살펴보았다. 이것만큼이나 흔한 게 어떤 상태를 변경하는 코드가, 동시에 지금 변경하려는 상태를 읽는 경우다. 특히 물리나 인공지능같이 객체가 서로 상호작용을 할때 이런 경우를 쉽게 볼수 있다. 이럴때에도 이중버퍼가 도움이된다.
인공지능
슬랩스틱 코미디 기반 게임에 들어갈 행동을 만든다고 해보자. 게임에는 무대가 준비되어 있고, 그 위에서 여러 배우들이 이런저런 몸개그를 한다.
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_;
};
C++
복사
매 프레임마다 객체의 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];
};
C++
복사
stage클래스는 배우를 추가 할 수 있고, 관리하는 배우 전체를 업데이트 할수 있는 update()메소드를 제공한다. 배우가 따귀를 맞았을 때 딱 한번만 반응 하기 위해서 맞은 상태를 (slapped)불변수에 저장해 두었고 업데이트가 완료된 후에 reset을 호출해 준다.
다음으로 Actor클래스를 구체화 한 Comdeian 클래스를 정의하겠다. 코미디언이 하는 일은 단순하다. 다른 배우 한 명을 보고 있다가 누구한테든 맞으면 보고 있던 배우를 떄린다.
class Comedian : public Actor
{
public:
void face(Actor* actor) { facing_ = actor; }
virtual void update()
{
if (wasSlapped()) facing_->slap();
}
private:
Actor* facing_;
};
C++
복사
어러명을 무대에 배치시켜두고 어떤일이 벌어지는지 봐보자.
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);
C++
복사
무대는 다음과 같은 그림으로 표현이 가능하다.
Stage updates actor 0 (Harry)
Harry was slapped, so he slaps Baldy
Stage updates actor 1 (Baldy)
Baldy was slapped, so he slaps Chump
Stage updates actor 2 (Chump)
Chump was slapped, so he ... 등등
C++
복사
처음에 해리를 때린 것이 한 프레임 만에 전체 코미디언한테 전파가 된다. 이번에는 코미디언들이 바라보는 대상은 유지하되 stage배열 내에서의 위치를 바꿔 보자.
무대를 초기화하는 코드에서 나머지는 그대로 두고 무대에 배우를 추가하는 코드만 작성해주면된다.
stage.add(harry, 2);
stage.add(baldy, 1);
stage.add(chump, 0);
C++
복사
Stage updates actor 0 (Chump)
Chump was not slapped, so he does nothing
Stage updates actor 1 (Baldy)
Baldy was not slapped, so he does nothing
Stage updates actor 2 (Harry)
Harry was slapped, so he slaps Baldy
C++
복사
전혀 다른 결과가 나왔다. 문제는 명확하다. 배우 전체를 업데이트 할때 배우의 맞은 상태(slapped)를 바꾸는데, 그와 동시에 같은 값을 읽기도 하다 보니 업데이트 초반에 맞은 상태를 바꾼게 나중에 가서 영향을 미치게 된다.
결과적으로 배우가 맞았을 때 배치 순서에 따라 이번 프레임 내에서 반응 할 수도 있고 다음 프레임에서야 반응 할 수도 있다. 배우들이 동시에 행동하는 것처럼 보이고 싶었는데 이런 식으로 업데이트 순서에 따라서 결과가 다르면 안된다.
맞은 상태를 버퍼에 저장해두기
이중버퍼를 만들어서 맞은 상태만 버퍼에 저장해둔다.
class Actor
{
public:
Actor() : currentSlapped_(false) {}
virtual ~Actor() {}
virtual void update() = 0;
void swap()
{
// Swap the buffer.
currentSlapped_ = nextSlapped_;
// Clear the new "next" buffer.
nextSlapped_ = false;
}
void slap() { nextSlapped_ = true; }
bool wasSlapped() { return currentSlapped_; }
private:
bool currentSlapped_;
bool nextSlapped_;
};
C++
복사
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();
}
}
C++
복사
이제 update() 메서드는 모든 배우를 먼저 업데이트한 다음에 상태를 교체한다. 결과적으로 배우객체는 자신이 맞았다는 걸 다음 프레임에서야 알 수 있다. 이제 모든 배우는 배치 순서와 상관없이 똑같이 행동한다. 유저나 빠깥 코드 입장에서는 배우가 한프레임에 동시에 일어나는 것처럼 보인다.
디자인 결정
이중버퍼는 굉장히 단순한 알고리즘이다. 사례 역시 위에서 본예제에서 벗어나지 않는다.