[Design Pattern] 게임 순서 : 이중 버퍼, 게임 루프, 업데이트 메서드
Table of Contents
게임 프로그래밍 패턴 책을 읽고 공부한 노트입니다.
이중 버퍼 #
- 본질적으로 컴퓨터는 순차적으로 동작한다. 하지만 사용자 입장에서는 동시에 진행되는 것처럼 한 번에 모아서 봐야할 때가 있다.
- 게임 렌더링이 그렇다. 매 프레임이 완성되면 한 번에 짠하고 보여져야 한다.
- 이중 버퍼는 중간 과정을 보이지 않고 한 번에 완성된 화면을 보여주도록 도와준다.
- 프레임 버퍼(framebuffer: 메모리에 할당된 픽셀의 배열)를 두 개 준비한다.
- 하나의 버퍼에 있는 픽셀값이 화면에 출력되는 동안, 렌더링 코드는 다른 버퍼를 채운다.
- 그리고 다음 화면이 보여져야 할 때 버퍼를 교체에서 한 번에 완성된 화면을 출력한다.
class Framebuffer
{
private:
static const int WIDTH = 160;
static const int HEIGHT = 120;
char pixels[WIDTH * HEIGHT];
public:
Framebuffer() { clear(); }
void clear()
{
for (int i = 0; i < WIDTH * HEIGHT; i++)
pixel[i] = WHITE;
}
void draw(int x, int y)
{
pixel[(WIDTH * y) + x] = BLACK;
}
const char* getPixels() { return pixels; }
};
class Scene
{
private:
Framebuffer buffers[2];
Framebuffer* cur;
Framebuffer* next;
void swap()
{
Framebuffer* temp = cur;
cur = next;
next = temp;
}
public:
Scene() : cur(&buffers[0]), next(&buffers[1]) {}
void draw()
{
// 그린다.
next->clear();
next->draw(1, 1);
next->draw(1, 3);
next->draw(1, 4);
// 프레임 버퍼를 교체해서 한 번에 출력한다.
swap();
}
Framebuffer& getBuffer() { return buffer; }
};
- 언제 쓸까?
- 순차적으로 변경해야 하는 상태가 있다.
- 이 상태는 값을 변경하는 도중에도 기다리지 않고 바로 접근할 수 있어야 한다.
- 바깥 코드에서는 작업 중인 상태에 접근할 수 없어야 한다.
- 주의사항
- 교체 연산 자체에 시간이 걸린다면 이중 버퍼 패턴은 아무런 도움이 되지 않는다.
- 버퍼가 두 개 필요하므로 메모리가 더 필요하다.
게임 루프 #
- 게임 루프 패턴은 어느 게임에서나 쓰인다.
- 기본적으로 사용자 입력을 받을 때까지 멈춰 있는 다른 소프트웨어들과는 달리 게임 루프는 끊임없이 돌아간다.
while (true)
{
processInput(); // 유저 입력을 처리한다.
update(); // 게임 상태를 업데이트한다.
render(); // 게임 화면을 렌더링한다.
}
- 주의사항
- 최적화를 고려해 깐깐하게 만들어야 한다.
- 그래픽 UI와 이벤트 루프가 있는 플랫폼에서 게임을 만든다면, 돌아가는 루프가 두 개인 셈이다. 따라서 이때에는 서로를 잘 맞춰야 하겠다.
게임 월드에서의 시간을 고려하자 #
- FPS(frames per second)
- 초당 프레임 수
- 예를 들어, 60FPS는 1초에 60장 화면을 그렸다는 것이며, 10FPS는 1초에 10장 화면을 그렸다는 것이다.
- 실제 시간 동안 게임 루프가 얼마나 돌았는지를 측정하면 얻을 수 있다.
- FPS를 고려해서 게임 루프를 설계해야 어느 컴퓨터에서나 똑같은 실행 속도를 만들어낼 수 있겠다.
- 예를 들어 플레이어가 총알을 발사했다고 치자.
- 아래의 코드의 경우 60FPS인 사람은 1초에 60번 업데이트가 되어 그 만큼 멀리 총알이 나간 걸로 업데이트가 된다.
- 하지만 10FPS인 사람은 1초에 10번밖에 업데이트가 안 되서 같은 총알인데 조금 밖에 안 나가게된다.
while (true)
{
processInput();
update();
render();
}
- 따라서 다음과 같이 프레임 사이에 실제 시간이 얼마나 지났는지에 따라 업데이트 양을 조절해볼 수 있겠다.
- 이것을 가변(유동) 시간 간격이라고 한다.
double lastTime = getCurrentTime();
while (true)
{
double current = getCurrentTime();
double elapsed = current - lastTime;
processInput();
// 이전 프레임 ~ 현재 프레임 사이에 흐른 시간을 고려해서 그 만큼 업데이트한다.
update(elapsed);
render();
lastTime = current;
}
- 하지만 가변 시간 간격이라는 데서 문제가 있다.
- 보통의 게임에서는 부동 소수점을 사용한다. 따라서 업데이트가 각각 60번, 10번 이루어지면서 반올림 오차가 다르게 누적된다. 결과적으로는 둘 사이의 총알 위치에 오차가 생길 것이다.
- 또한 물리 엔진에서는 실제 물리 법칙의 근사치를 취하는데, 이것이 튀는 걸 막기 위해서 감쇠를 사용한다. 감쇠는 시간 간격에 따라 세심하게 조정해야 하는데, 감쇠 값이 바뀌다보면 물리가 불안정해진다.
- 물리 적용을 좀 더 안정적으로 만들기 위해 가변 시간 간격이 아니라 고정 시간 간격으로 업데이트하자.
- 실제 시간에 비해 뒤쳐지는 시간만큼 계속 업데이트하면 되겠다.
double previous = getCurrentTime();
double lag = 0.0;
while (true)
{
double current = getCurrentTime();
double elapsed = current - preious;
previous = current;
lag += elapsed; // 실제 시간에 비해 게임 시간이 얼마나 뒤쳐졌는지를 나타낸다.
processInput();
// MS_PER_UPDATE이라는 고정 시간을 기준으로 뒤쳐진만큼 업데이트를 한다.
// MS_PER_UPDATE이 짧을 수록 많이 업데이트 하고, 실제 시간을 따라잡는 데 오래걸리겠다.
while (lag >= MS_PER_UPDATE)
{
update();
lag -= MS_PER_UPDATE;
}
render();
}
- 업데이트는 고정 시간으로 하게 되었지만, 렌더링은 가능할 때마다 한다.
- 만약에 두 업데이트 사이에 렌더링을 하게 됐다면?
- 그 사이값에 맞게 렌더링되는 총알의 위치를 중간즈음으로 바꾸어 주어야하겠다.
update()
후에 남은lag
는 다음 프레임까지 남은 시간이므로lag
를 고려해 렌더링하면 되겠다.
// lag를 MS_PER_UPDATE로 나눠서 정규화한다.
// 그러면 0~1사이의 값이 나온다.
render(lag / MS_PER_UPDATE);
디자인 결정에 고려할 사항들 #
- 게임 루프를 직접 관리할 것인가, 플랫폼이 관리할 것인가?
- 전력 소모를 줄이기 위해 CPU를 쉬게 해줄 것인가? 아니면 FPS나 그래픽 품질을 높일 것인가?
- 게임플레이 속도는 어떻게 제한할 것인가?
- 동기화 없는 고정 시간 간격 방식
- 동기화 있는 고정 시간 간격 방식
- 가변 시간 간격 방식
- 업데이트는 고정 시간 간격으로, 렌더링은 가변 시간 간격으로
업데이트 메서드 #
- 이제
update()
메서드를 어떻게 만들 수 있을지 생각해보자. - 모든 개체에 대한 처리를 한 메서드 안에서 한다면 매우 복잡해질 것이다.
- 따라서 각 개체가 자신의 동작을 캡슐화해야 하겠다.
- 주의사항
- 프레임 단위로 끊어서 계산하는 것이 더 복잡하다.
- 다음 프레임에서 다시 시작할 수 있도록 현재 상태를 저장해야 한다.
- 모든 객체는 매 프레임마다 업데이트 되지만 진짜로 동시에 되는 건 아니다. 따라서 순서가 중요하겠다.
- 업데이트 도중에 객체의 목록을 바꾸는 건 조심해야 한다.
코드 예제 #
- 다음은 고정 시간 간격을 사용한 업데이트 예제이다.
class Entity
{
private:
double x, y;
public:
Entity() : x(0), y(0) {}
virtual ~Entity() {}
// update()를 각 개체가 구현한다.
virtual void update() = 0;
};
class Skeleton : public Entity
{
private:
bool patrollingLeft;
public:
Skeleton() : patrollingLeft(false) {}
virtual void update()
{
// 해골병사는 좌우로 패트롤한다.
if (patrollingLeft)
{
x--;
if (x == 0) patrollingLeft = false;
}
else
{
x++;
if (x == 100) patrollingLeft = true;
}
}
};
class Statue : public Entity
{
private:
// 각 개체가 자신의 동작을 캡슐화함으로써
// 타이머를 각자 관리할 수 있게 되었다.
int frames;
int delay;
public:
Statue(int d) : frames(0), delay(d) {}
virtual void update()
{
// 석상은 delay 간격으로 번개를 쏜다.
if (++frames == delay)
{
shootLightning();
frames = 0;
}
}
};
class World
{
private:
Entity* entities[MAX_ENTITIES];
int numEntities;
public:
World() : numEntities(0) {}
void gameLoop()
{
while (true)
{
// ...입력 처리
// 이렇게 간단하게 update()만 호출하면 된다! 야호!
for (int i = 0; i < numEntities; i++)
entities[i]->update();
// ...물리, 렌더링 처리
}
}
};
디자인 결정에 고려할 사항들 #
- 업데이트 메서드를 어느 클래스에 둘 것인가?
- 개체 클래스
- 컴포넌트 클래스 (컴포넌트 패턴을 사용하는 경우)
- 위임 클래스 (상태 패턴, 타입 객체 패턴을 사용하는 경우)
- 업데이트가 필요 없는 휴면 객체를 어떻게 처리할 것인가?
- 컬렉션 하나로 묶어서 관리한다.
- 활성 객체만 모은 컬렉션을 따로 둔다.