Skip to main content

[Design Pattern] 게임 순서 : 이중 버퍼, 게임 루프, 업데이트 메서드

게임 프로그래밍 패턴 책을 읽고 공부한 노트입니다.




이중 버퍼 #

  • 본질적으로 컴퓨터는 순차적으로 동작한다. 하지만 사용자 입장에서는 동시에 진행되는 것처럼 한 번에 모아서 봐야할 때가 있다.
    • 게임 렌더링이 그렇다. 매 프레임이 완성되면 한 번에 짠하고 보여져야 한다.

  • 이중 버퍼는 중간 과정을 보이지 않고 한 번에 완성된 화면을 보여주도록 도와준다.
    • 프레임 버퍼(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();
        
      // ...물리, 렌더링 처리 
    }
  }
};

디자인 결정에 고려할 사항들 #

  • 업데이트 메서드를 어느 클래스에 둘 것인가?
    • 개체 클래스
    • 컴포넌트 클래스 (컴포넌트 패턴을 사용하는 경우)
    • 위임 클래스 (상태 패턴, 타입 객체 패턴을 사용하는 경우)
  • 업데이트가 필요 없는 휴면 객체를 어떻게 처리할 것인가?
    • 컬렉션 하나로 묶어서 관리한다.
    • 활성 객체만 모은 컬렉션을 따로 둔다.