[게임 프로그래밍 패턴] Chapter8 이중 버퍼

Jangmanbo·2023년 9월 21일
0

의도

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

동기

순차적으로 혹은 동시에 진행되는 여러 작업을 한 번에 모아서 봐야할 때가 있다.

예를 들어 렌더링을 할 때 화면을 그리는 중간 과정이 보이면 안되며,
매 프레임 완성되면 한 번에 보여줘야 한다.

프레임 버퍼에 값을 쓰는 픽셀을 비디오 드라이버에서 프레임 버퍼를 읽는 픽셀이 추월한다면 테어링이 발생한다. 이래서 이중 버퍼 패턴이 필요하다.

(= 이전 프레임에는 하나도 안 보이다가, 다음 프레임에 전체가 보여야 한다.)



이중 버퍼 패턴

프레임 버퍼를 두 개 준비해 A 버퍼에는 지금 프레임에 보일 값을 두고, 렌더링 코드는 B 버퍼에 값을 쓴다.
A버퍼를 다 그린 후에는 버퍼를 교체하고, 이제 B 버퍼를 읽으라고 비디오 하드웨어에게 알린다.

이렇게 점차적으로 수정되는 버퍼를 밖에서는 한 번에 바뀌는 것처럼 보이기 위해,
버퍼 클래스는 현재 버퍼다음 버퍼 두 개의 버퍼를 가져아 한다.

정보를 읽을 때는 현재 버퍼, 정보를 쓸 때는 다음 버퍼에 접근한다.
다 읽으면(다 보여주면) 현재 버퍼를 교체해 다음 버퍼가 보여지게 한다. 현재 버퍼는 새로운 다음 버퍼가 되어 재사용된다.

언제 사용?

테어링이 방지하고자 할 때 사용한다. 더 보편적인 상황으로 치환한다면

  • 순차적으로 변경해야 하는 상태가 있다.
  • 이 상태는 변경 도중에도 접근 가능해야 한다.
  • 바깥 코드에는 작업 중인 상태에 접근할 수 없어야 한다.
  • 상태에 값을 쓰는 도중에도 기다리지 않고 바로 접근할 수 있어야 한다.

주의사항

1. 교체 연산 자체에 시간이 걸린다.

버퍼의 교체 연산은 원자적이어야 한다. 즉 교체 중에는 두 버퍼 모두 접근할 수 없어야 한다.
대게 포인터만 바꾸면 되지만, 만약 (버퍼 교체 시간 > 버퍼에 값을 쓰는 시간) 이면 이중 버퍼 패턴의 의미가 없다.

2. 버퍼가 두 개 필요하다

따라서 메모리가 더 필요하다. 메모리가 부족한 기기는 이중 버퍼 패턴 말고 다른 방법을 찾아야 한다.



예제

class Framebuffer {
public:
	Framebuffer() { Clear(); }
	void Clear() {
		for (int i = 0; i < WIDTH * HEIGHT; ++i) {
			pixels_[i] = '0';
		}
	}

	void Draw(int x, int y)
	{
		pixels_[(WIDTH * y) + x] = '1';
	}

	const char* GetPixels() { return pixels_; }

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

	char pixels_[WIDTH * HEIGHT];
};

Clear(): 전체 버퍼를 흰색으로 채움
Draw(): 특정 픽셀에 검은색 입력
GetPixels(): 픽셀 데이터를 담고 있는 메모리 배열에 접근. 비디오 드라이버가 화면을 그리기 위한 버퍼를 읽을 때 을 호출함.

class Scene{
public:
	void Draw() {
		buffer_->clear();
		buffer_->draw(1, 1);
        buffer_->draw(4, 1);
        // ...
	}
    Franebuffer& GetBuffer() { return buffer_; }
private:
	Framebuffer buffer_;
};

Scene 클래스는 여러번 draw()를 호출해 버퍼에 원하는 그림을 그린다.
매 프레임마다 어떤 장면을 그려야 할 지 게임 코드가 알려주면, 버퍼를 clear()하고 한 번에 하나씩 그리고자 하는 픽셀을 찍는다.
비디오 드라이버가 내부 버퍼에 접근 가능하도록 getBuffer()를 제공한다.

그러나 비디오 드라이버가 아무 때나 getBuffer()를 호출하면 안된다.
draw() 중에 버퍼를 읽으면 안되기 때문이다.


class Scene{
public:
	Scene() : current_(&buffers_[0]), next_(&buffers_[1]) {}
	void Draw() {
		next_->Clear();
		next_->Draw(1, 1);
		next_->Draw(4, 1);
		next_->Draw(1, 3);
		next_->Draw(2, 4);
		next_->Draw(3, 4);
		next_->Draw(4, 3);
		Swap();
	}
    
    Franebuffer& GetBuffer() { return *current_; }
    
private:
	void Swap() {
		Framebuffer* temp = current_;
		current_ = next_;
		next_ = temp;
	}

	Framebuffer buffers_[2];
	Framebuffer* current_;
	Framebuffer* next_;
};

렌더링할 픽셀은 next_에 쓰고, 비디오 드라이버는 current_에 접근하여 픽셀을 읽는다.



그래픽스 외의 이중 버퍼 패턴 활용

  1. 다른 스레드나 인터럽트에서 상태에 접근하는 경우 (그래픽 예제)
  2. 어떤 상태를 변경하는 코드가, 동시에 지금 변경하려는 상태를 읽는 경우 (배우 예제)

이중 버퍼 패턴 구현 시 중요한 포인트

A. 버퍼의 교체 방법

버퍼 교체 연산은 읽기/쓰기 버퍼 모두 사용 불가하므로, 최대한 빠르게 교체해야 한다.]

  1. 버퍼 포인터나 레퍼런스를 교체(그래픽 예제): 가장 일반적인 방법
    • 속도가 빠르다.
    • 버퍼 코드 밖에서는 버퍼 메모리를 포인터로 저장할 수 없다는 한계가 있다.
    • 버퍼에 남아 있는 데이터는 바로 이전 프레임 데이터가 아닌 2프레임 전 데이터다.
  2. 버퍼끼리 데이터를 복사(배우 예제)
    • 다음 버퍼에는 딱 한 프레임 전 데이터가 들어있다.
    • 교체 시간이 더 걸린다.

B. 얼머나 정밀하게 버퍼링할 것인가

  1. 버퍼가 한 덩어리라면(그래픽 예제)
    • 간단히 교체할 수 있다. (A-1)
  2. 여러 객체가 각종 데이터를 들고 있다면(배우 예제)
    • 교체가 느리다. 전체 객체 컬렉션을 순회하면서 교체하라고 알려줘야 한다.
    • 만약 이전 버퍼를 건드리지 않아도 된다면, 버퍼가 여러 객체에 퍼여 있어도 현재다음 포인터 개념을 오프셋으로 치환하여 단일 버퍼와 같은 성능을 낼 수 있도록 최적화할 수 있다.

0개의 댓글