Double Buffer Pattern(이중 버퍼 패턴)

2024. 6. 15. 11:48Game Develop/Design Pattern

이중 버퍼 패턴은 순서 패턴의 한 중류이다.

이중 버퍼 사용의 의도는 여러 순차 작업의 결과를 한 번에 보여주기 위함이다.

 

본질적으로 컴퓨터는 순차적으로 동작한다. 굉장히 큰 일은 작은 단계로 쪼개어 하나씩 처리하는 것이다.

하지만 사용자 입장에서 순차적으로 혹은 동시에 진행 되는 여러 작업을 한 번에 모아서 봐야 할 경우가 있다.

 

예를 들어 게임 랜더링시에 플레이어에게 시각적인 모든 정보를 한 번에 제공해야 한다.

멀리 있는 산, 굴불구불한 언덕, 나무 등 모든 것들을 말이다.

이때 화면에 그려지는 인스턴스들을 하나씩 보여주면 플레이어의 몰입을 방해할 수 있다.

따라서 장면은 부드럽고 빠르게 업데이트 돼야 하고 매 프레임 완성되면 한 번에 보여줘야 한다.

 

위의 조건을 만족하기 위한 수단으로 이중 버퍼 패턴을 사용한다.

 

컴퓨터는 화면을 어떻게 그리는가?

 

컴퓨터 모니터 같은 비디오 디스플레이는 한 번에 한 픽셀을 그린다. 좌상단부터 우하단까지 순차적으로 그린다. 모니터 주사율에 따라 이 행동을 초당 60정도에서 많게는 240번 하게 된다. 이게 흔히 말하는 모니터 프레임이다.

 

대부분의 컴퓨터에서는 픽셀을 프레임버퍼로부터 가져온다. 프레임버퍼란 메모리에 할당된 픽셀들의 배열로, 한 픽셀의 색을 여러 바이트로 표현하는 RAM의 한 부분이다. 

 

궁극적으로 게임을 화면에 보여주려면 프레이버퍼에 표시할 값을 써 넣으면 된다. 다만 사소한 문제가 하나 있다.

 

렌더링 코드가 실행되는 동안 다른 작업이 실행되지 않을 것 같지만 렌더링 도중 실행되는 작업이 일부 있기는 하다. 그중 하나가 게임 실행중 비디오 디스플레이가 프레임버퍼를 반복해서 읽는 것인데, 여기서 문제가 발생한다.

 

화면에 얼굴을 하나 그린다고 하자. 화면에 얼굴을 표시하기 위해 렌더링 코드는 루프를 돌며 프레임버퍼에 픽셀 값을 입력하지만, 동시에 비디오 드라이버에서도 프레임버퍼 값을 읽는다는 것이다. 이렇게 되면 프레임버퍼에 미쳐 입력하지 못한 버퍼 값까지 화면에 출력될 수 있다. 이렇게 되면 화면에 이렇게 출력될 수 있다.

그림 1. 렌더링 과정 중의 테어링

위와 같은 현상을 테어링이라고 한다. 화면 찢김 현상이라고도 한다. 이렇기 때문에 이중 버퍼 패턴이 필요하다. 

픽셀을 한 번에 하나씩 그리되 비디오 드라이버는 전체 픽셀을 한 번에 다 읽게 해야 한다. 즉 이전 프레임에서는 그림이 하나도 안보이다가 다음 프레임에 짠! 하고 전체가 전부 보여야 한다.

 

이중 버퍼를 이해하기 쉽게 비유를 한 번 들어보자.

 

플레이어는 우리가 연출하는 연극을 보는 관객이라고 가정하자. 1장이 끝나고 2장을 시작하기 전에 무대의 세팅을 바꿔야 한다. 대부분의 극장이 1장이 끝나고 조명을 어둡게 한 후 무대를 세팅하지만 관객들은 그 시간을 기다려야한다.

하지만 무대가 두 개라면? 무대 A와 B에 각각 1장과 2장의 무대 세팅을 해놓은 후 1장이 끝나면 바로 무대 B로 넘어가 극을 시작하는 것이다. 그 사이에 A 무대에는 3장의 세팅을 해놓은 뒤 2장이 끝나면 다시 A 무대로 와 3장을 시작하면 되는 것이다. 이렇게 하면 연극이 진행되는 동안 관객들은 무대 스탭들을 볼 일도 없고 몰입이 끊길 이유도 없다.

 

자 이중 버퍼가 이런식이다. 거의 모든 게임의 렌더링 시스템이 내부에서 이렇게 동작한다. 하나의 버퍼에는 지금 프레임에 보일 값을 둬서 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() 메서드를 호출해 버퍼에 접근할 수 있기 때문에 추가적인 내용이 필요하다.

 

이렇게 되면 그림 1에서 처럼 얼굴이 잘려서 보이게 된다. 이렇게 되면 화면이 엄청나게 깜박거릴 것이다.

자 이제 이중 버퍼가 나설 시간이다.

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() 메서드만 호출하여 next_와 current_ 포인터 변수를 맞바꾸는게 전부다.

 

이렇게 변경 중인 상태에 접근할 수 있다는게 이중 버퍼로 해결하려는 문제의 핵심이다.

이유는 두 가지이다. 하나는 다른 스레드나 인터럽트에서 상태에 접근하는 경우인데 위의 예제에서 다뤘다.

다른 하나는 어떤 상태를 변경하는 코드가 동시에 지금 변경하려는 상태를 읽는 경우이다. 이 때에도 이중 버퍼는 도움이 된다.

 

 

자 여기 또다른 예제가 있다. 슬랩스틱 코미디 기반 게임에 들어갈 행동 시스템을 만든다고 가정하자.

게임에는 무대가 있고, 그 위에 여러 배우들이 몸개그를 하고 있다. 먼저 배우를 위한 상위 클래스를 만들자.

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

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

 

배우 끼리는 서로 상호작용할 수 있다. 위의 slap() 이다. 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() 메서드를 제공한다. 유저 입장에서는 배우들이 한 번에 움직이는 것처럼 보이겠지만 내부적으로는 하나씩 업데이트된다.

 

배우가 뺨을 맞았을 때 딱 한 번만 반응하기 위해 맞은 상태를 update() 후에 바로 초기화 해야하는 것도 잊지말자.

 

다음으로 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->slap();
stage.update
baldy->slap();
stage.update
chump->slap();
stage.update

이런 식으로 작성해주면 이런 결과가 나온다.

  • Stage가 actor 0 인 Harry를 업데이트 → Harry가 뺨을 맞았다. Harray는 Baldy를 때린다.
  • Stage가 actor 1 인 Baldy를 업데이트 → Baldy가 뺨을 맞았다. Baldy는 Chump를 때린다.
  • Stage가 actor 2 인 Chump를 업데이트 → Chump가 뺨을 맞았다. Chump는 Harry를 때린다.
  • Stage 업데이트 끝.

처음에 Harray때린 것이 한 프레임 만에 전체 코미디언에게 전파된다. 이번에는 바라보는 방향은 유지하되 배우들의 위치만 바꿔보자.

위의 무대 초기화 코드에서 각 배우들의 번호만 바꿔주면 된다.

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_ = ture; }
    bool wasSlapped() { return currentSlapped_; }
    
private:
    bool currentSlapped_;
    bool nextSlapped_;
};
  •  

위의 코드를 보면 알 수 있듯이 Actor 클래스의 slapped_ 상태가 두 개로 늘었다. 앞선 예제처럼 현재 상태 (currentSlapped_)는 읽기 용도로, 다음 상태(nextSlapped_)는 쓰기 용도로 사용한다.

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();
    }
}

이제 udpate() 메서드는 모든 배우를 먼저 업데이트한 다음 상태를 교체한다. 결과적으로 배우 객체는 자신이 맞았다는 걸 다음 프레임에서야 알 수 있다. 이제서야 모든 배우가 배치 순서와 상관없이 똑같이 행동한다. 유저 입장에서도 마찬가지다.

 

 

위의 두 예제를 다루고 알 수 있듯이 이중 버퍼는 꽤 단순한 편이다.

마지막으로 이중 버퍼 패턴을 구현하기 위해서 결정해야할 중요한 점 두 가지만 살펴보고 마치도록하자.

 

결정해야할 중요한 점 두 가지

  1. 버퍼를 어떻게 교체할 것인가?
  2. 얼마나 정밀하게 버퍼링할 것인가?

이다.

 

첫 번재 버퍼를 어떻게 교체할 것인가?의 방법에는 두 가지가 있다.

 

 

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

  첫 번째 그래픽스 예제에서 썼던 방식이다. 이중 버퍼 그래픽스에서는 가장 일반적으로 사용되는 방법이다.

 

이 방식에는 세 가지 특징이 있는데 다음과 같다.

  • 빠르다. 버퍼가 아무리 커도 포인터 두 개만 바꾸면 된다.
  • 버퍼 코드 밖에서는 버퍼 메모리를 포인터로 저장할 수 없다는 한계가 있다. 이 방식에서는 데이터를 실제로 옮기지는 않는 대신 주기적으로 다른 버퍼를 읽으라고 알려준다. 버퍼 외부 코드에서 버퍼 내 데이터를 직접 포인터로 저장하면 버퍼 교체 후 잘못된 데이터를 가리킬 가능성이 있다.
    특히 비디오 드라이버가 프레임버퍼는 항상 메모리에서 같은 위치에 있을 거라고 기대하는 시스템에서 문제가 된다. 이런 시스템에선 이중 버퍼를 쓸 수 없다.
  • 버퍼에 남아있는 데이터는 바로 이전 프레임 데이터가 아닌 2프레임 전 데이터다. 버퍼끼리 데이터를 복사하지 않는 한 다음 프레임은 다음과 같이 다른 버퍼에 그려진다. 이는 만약 버퍼에 남은 데이터를 재사용할 시에 문제(?)가 되는데 이 데이터가 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()이 정적 함수이기 때문에 한 번만 호출해도 '모든'         배우의 상태를 교체할 수 있다는게 핵심이다.