게임 프레임 관리
Project/Win32API로 던그리드 모작

게임 프레임 관리

Woon2World :: Programming & Art Life

 

 

게임의 동작

게임은 일정 시간 간격마다

  1. 사용자 입력을 처리하고
  2. 객체들을 업데이트한 뒤
  3. 객체들을 렌더링한다.

따라서 게임에서 타이머의 역할은 매우 중요하다. 게임의 거의 모든 기반이 타이머를 통해 돌아간다.

 

 

 

 


초당 프레임 수(FPS)

프레임은 이미지이다.

서로 다른 이미지들을 연속해서 보여주는 것을 애니메이션이라 한다.

애니메이션에 쓰이는 이미지 한 장 한 장을 프레임이라 부른다.

 

플레이어는 애니메이션을 보고 움직임을 인지할 수 있다.

인간이 감각적으로 '생생한 움직임'을 느끼기 위해선 프레임 전환 속도가 빨라야 한다.

따라서 1초 동안 처리되는 프레임 수 FPS는 적절히 높아야 한다.

 

"적절히 높아야" 한다는 게 포인트다.

게임은 이와 같은 방법으로 프레임을 처리한다.

  1. 프레임을 교체( 업데이트 ) 후 출력( 렌더 )한다.
  2. 남은 시간만큼 휴식한다.

여기서 한 가지 허점은, 프레임 처리에 걸리는 시간이 무조건 프레임 당 시간보다 짧을 것이라고 가정한다는 점이다.

1000 FPS를 위해 1ms당 한 프레임을 업데이트한다고 쳐보자. 또 프레임 처리 루틴은 5ms가 걸린다고 쳐보자.

1초가 지나면 프레임을 처리하라는 요청은 1000개가 쌓인다.

하지만 실제 프레임 업데이트는 200번밖에 되지 않는다. 나머지 800개의 프레임 처리 요청은 "렉"으로서 쌓인다.

 

 

 

 


프레임 관리

그렇다면 어떻게 해야 프레임 처리 요청이 쌓이지 않게 하면서 같은 시간에 최대한 많은 프레임을 처리할 수 있는가?

위에서 살펴봤듯이 게임의 동작은 3가지이다. 사용자 입력 처리 루틴, 업데이트 루틴, 렌더 루틴.

렉이 발생하는 순간 렌더 루틴을 생략한다.

프레임 처리 시간을 강제로 줄이는 것이다.

렉이 발생하지 않는다면 세 루틴을 전부 거치지만, 렉이 발생했다면 두 루틴만 거친다.

 

예시

60FPS - 프레임 당 시간 : 16ms, 사용자 입력 처리 루틴 소요 시간 : 3ms,
업데이트 루틴 소요 시간 : 3ms, 렌더 루틴 소요 시간 : 15ms

1 프레임 : 3ms + 3ms + 15ms = 21ms 소요, 5ms 밀림 (1개의 프레임 요청 밀림)
2 프레임 : 3ms + 3ms + 15ms = 21ms 소요, 10ms 밀림 (1개의 프레임 요청 밀림)
3 프레임 : 3ms + 3ms + 15ms = 21ms 소요, 15ms 밀림 (1개의 프레임 요청 밀림)
4 프레임 : 3ms + 3ms + 15ms = 21ms 소요, 20ms 밀림 (2개의 프레임 요청 밀림)
5 프레임 : 3ms + 3ms = 6ms 소요, 10ms 밀림 (1개의 프레임 요청 밀림)
6 프레임 : 3ms + 3ms + 15ms = 21ms 소요, 15ms 밀림 (1개의 프레임 요청 밀림)
 

 

위의 예시는 2개의 프레임 요청이 밀리는 순간 렌더 루틴을 생략한 것이다.

1개의 프레임 요청이 밀리는 순간 렌더 루틴을 생략한다면? 한번 예시 상황 그대로 상상해보기 바란다.

렌더링 횟수가 눈에 띄게 줄어들 것이다.

 

일반적으로 각 루틴의 처리가 얼마나 걸리는지는 고정되어 있지 않다.

게임 내내 단 1개의 밀리는 프레임 요청도 발생하지 않을 수 있고, 갑자기 엄청나게 프레임 요청이 밀릴 수 있다.

완전히 똑같은 동작을 하더라도 사용자의 프로세스 환경이 변화함에 따라 다른 시간이 걸릴 수 있다.

 

 

 

 


아이디어

  • 지연된 시간을 저장하는 변수와 프레임 당 시간을 저장하는 변수를 선언한다.
  • 다음을 반복한다.
    • 지연된 시간에서 프레임 당 시간을 뺀다. 그러나 지연된 시간이 음수가 되지는 않도록 한다.
    • 사용자 입력 처리 루틴, 업데이트 루틴을 처리한다.
    • 지연된 시간이 프레임 당 시간보다 작다면, 렌더 루틴을 처리한다.
    • 모든 처리에 걸린 시간을 지연된 시간에 더한다.

 

 

 


timer 클래스

#ifndef _timer
#define _timer
 
#include <chrono>
 
class timer
{
public:
    static constexpr int ms_per_frame()
    {
        return 30;
    }
 
    static void on_timer()
    {
        // ms_per_frame() 간격마다 이 함수를 호출한다.
    }
 
    timer() = delete;        // 객체 생성 불가
};
 
#endif
cs

 

 

타이머는 그 어떤 비정적 멤버도 갖지 않는다. 또한 생성자가 삭제되어 있으므로 객체 생성이 불가능하다.

원래는 타이머가 싱글톤을 상속하도록 구현하고 있었는데, 이렇게 훨씬 더 간단한 방법으로 단 한 개의 객체 생성도 허용하지 않을 수 있었다. ㅡ.ㅡ

 

기존에 만들었던 백버퍼 클래스도 이와 같이 수정할 수 있겠다. 하지만 귀찮으니 패스.

 

 

// ...
 
LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
 
    // ...
 
    switch (uMsg)
    {
    case WM_CREATE:
        SetTimer(hWnd, 1, timer::ms_per_frame(), NULL);
 
        // ...
 
        break;
 
    case WM_TIMER:
        timer::on_timer();
        break;
 
        // ...
 
    }
 
    // ...
}
cs

 

 

윈도우 프로시저 함수가 WM_TIMER 메시지를 받았을 때 timer::on_timer()를 호출한다.

 

 

 

 


on_timer() (임시, 테스트)

#ifndef _timer
#define _timer
 
#include <chrono>
#include <iostream>
 
class timer
{
public:
    static constexpr int ms_per_frame()
    {
        return 30;
    }
 
    static void on_timer()
    {
        static int elapsed = 0;
 
        if (elapsed < ms_per_frame())
            elapsed = 0;
        else
            elapsed -= ms_per_frame();
 
        std::cout << "\n루틴 시작 당시 elapsed : " << elapsed << std::endl;
 
        using namespace std::chrono;
        auto tp = system_clock::now();
 
        std::cout << "업데이트 루틴!\n";
        for (int i = 0; i < 20000000++i)
            i += 2;
 
        if (elapsed < ms_per_frame())
        {
            std::cout << "렌더 루틴!\n";
            for (int i = 0; i < 40000000++i)
                i += 2;
        }
 
        elapsed += duration_cast<milliseconds>(system_clock::now() - tp).count();
    }
 
    timer() = delete;
};
 
#endif
cs

 

 

아직 실제 루틴을 구현하지 않은 상태이기 때문에 위와 같이 반복문으로 시간을 지연시키도록 만들었다.

elapsed라는 이름은 보통 경과된 시간을 나타내는 데 쓰인다.

이 문맥에선 처리하면서 경과된 시간인 동시에 프레임 요청이 지연된 시간을 의미한다.

 

지연된 시간이 프레임 당 시간보다 작을 때만 렌더 루틴을 처리한다.

 

 

 

 


리팩토링

후에 크게 리팩토링되었다.

https://jartlife.tistory.com/54

 

코드 리팩토링

들여쓰기, 간격 한 줄짜리 if문과 for문에도 중괄호 들여쓰기를 적용한다. 괄호 내부에 공백을 삽입한다. 함수의 매개변수 목록, 함수 호출 시 인자 목록 내부에 공백을 삽입한다. 조건문의 조건

jartlife.tistory.com

 

 

// timer.h + TMP.h
 
#ifndef _timer
#define _timer
 
#undef max        // c 매크로 max를 제거하기 위함
 
#include <chrono>
#include <iostream>
#include "keyword.h"
#include <algorithm>
 
// 함수의 실행 시간 측정
template < typename _Timet = std::chrono::milliseconds, typename Func, typename ... Args >
auto timefunc( Func func, Args... args )
{
    using namespace std::chrono;
    using _Countt = std::chrono::nanoseconds;
 
    auto tp = system_clock::now();
    func( args... );
    
    return duration_cast<_Countt>( system_clock::now() - tp ).count()
        / static_cast<long double>( _Countt::period::den / _Timet::period::den );
}
 
// 타이머
class timer NONCREATABLE
{
public:
    static constexpr int ms_per_frame() PURE
    {
        return 30;
    }
 
    static void on_timer()
    {
        static int lag = 0;
        std::cout << "\n루틴 시작 당시 lag : " << lag << std::endl;
        auto elapsed = static_cast<int>( timefunc( go_routines, lag ) );
        lag = std::max( lag + elapsed - ms_per_frame(), 0 );
    }
 
    timer() = delete;
 
private:
    HELPER static inline void go_routines( const int current_lag )
    {
        update();
        if ( current_lag < ms_per_frame() )
            render();
    }
 
    HELPER static void update()
    {
        std::cout << "업데이트 루틴!\n";
        for ( int i = 0; i < 20000000++i )
            i += 2;
    }
 
    HELPER static void render()
    {
        std::cout << "렌더 루틴!\n";
        for ( int i = 0; i < 40000000++i )
            i += 2;
    }
};
 
#endif
cs

 

 

 

 

이후 프레임은 가변 프레임이 되었다.

 

https://jartlife.tistory.com/64

 

게임 타이머 구현

가변 프레임 이전의 게임 타이머 구현은 아래 글에서 볼 수 있다. https://jartlife.tistory.com/52 게임 프레임 관리 게임의 동작 게임은 일정 시간 간격마다 사용자 입력을 처리하고 객체들을 업데이트

jartlife.tistory.com

 

 

 

 


테스트 결과

 

테스트는 잘 통과하였다.

지연된 시간이 ms_per_frame()인 30을 넘는 경우 업데이트 루틴만 실행되고 있다.