GAME
フレームレートを固定化したゲームループの作り方


HomeProgramming TipsGame Tips[GAME002]

能書きはどうでも良いので結果だけ知りたい人は…
 [GO!] コマンドプロンプト推奨ゲームループを知りたい 
 [GO!] Win32SDK 推奨ゲームループを知りたい 
 [GO!] MFC 推奨ゲームループを知りたい 

60 FPS などという言葉を聞いたことがあるだろうか。
これは、1秒間に60回、画面書き換えをしているという意味です(Frame Per Second)。

人間の「感度」はとても微妙な違いを感じ取ることができます。
そのため、このフレームレートが時々変動すると「なんか気持ち悪い」という印象を与えてしまいます。

ちなみに、日本のゲームでは 60FPS が多いのですが、これは、日本のテレビが採用している
NTSC のスキャン周波数が1秒間辺り 60回であることに由来します。

1秒を 60 で割ると 16.66666ms ※1となり割り切れません。
※1: ms とは「ミリセカンド」の単位で 1/1000秒です。

これはパソコンでゲームを作る上では大変都合が悪いのです。
ところで、ヨーロッパ圏のテレビで採用されている PAL のスキャン周波数が 50 です。
1秒を 50 で割ると 20ms と割り切れます。
大変都合が良いため、これからのゲーム作りは 50FPS を標準にすると良いと思います。
この 20ms という数字のままでは分かりにくいので別名定義します。

■ 定義

#define FRAME_RATE      (1000/50)   // フレームレート



さて、1秒間に FRAME_RATE ずつ画面を書き換えれば良いことが分かりましたのでウエイトを入れます。

■ サンプル1

    ::timeBeginPeriod(1);
    while (1) {
        Games();
        Sleep(FRAME_RATE);
    }
    ::timeEndPeriod(1);



この処理はとても拙いです。
何しろゲームを処理している Games() 関数の処理時間が考慮されていません。
では、Games() の処理時間分を減らしてやればどうでしょうか。

■ サンプル2

    ::timeBeginPeriod(1);
    while (1) {
        Games();
        Sleep(FRAME_RATE - 10);
    }
    ::timeEndPeriod(1);



Games() 関数の処理時間が毎回必ず 10ms という保証はありません。
例え平均 10ms だとしても、それはあくまでも平均ですから、ゲームの表示はふらつくことになります。
これを解決するには処理の開始前に現在時間を記録しておき、
Games() 関数の処理が終了した時点の時間と比較すれば、Games() の処理時間が分かります。
その時間分をさっ引いて時間待ちをすれば、ゲーム全体の処理時間は固定化されます。

■ サンプル3

    ::timeBeginPeriod(1);
    while (1) {
        DWORD dwTime = ::timeGetTime();
        Games();
        dwTime = ::timeGetTime() - dwTime;
        Sleep(FRAME_RATE - dwTime);
    }
    ::timeEndPeriod(1);



一見良さそうですが、Games() 関数の処理が FRAME_RATE より多かった場合に大問題を引き起こします。
時間の単位は符号無しですので、FRAME_RATE より時間がかかった場合、
無限に近い時間が待ち時間となってしまいます。ハングアップに見えるでしょうね。

そこで FRAME_RATE 以上だったら処理を継続するという処理に書き換えます。

■ サンプル4

    ::timeBeginPeriod(1);
    while (1) {
        DWORD dwCheck = ::timeGetTime() + FRAME_RATE;
        Games();
        while (dwCheck > ::timeGetTime());
    }
    ::timeEndPeriod(1);



これで Games() 関数がどれだけの処理時間であろうと、固定のタイミングで画面を書き換えるようになりました。
ところが実際にこれを動かすと拙いことが起きます。CPU 使用率が常に 100% になるのです。
そこで時間待ちしているときは 1ms ずつ CPU を休ませます。

■ コマンドプロンプト推奨ゲームループ

    ::timeBeginPeriod(1);
    while (1) {
        DWORD dwCheck = ::timeGetTime() + FRAME_RATE;
        Games();
        while (dwCheck > ::timeGetTime())
            Sleep(1);
    }
    ::timeEndPeriod(1);



コマンドプロンプトで動作するゲームを作るのであれば、この記述で問題ありません。
ところが、Windows プログラムとなると話は変わります。
これでは OS に制御が戻らないため、Windows の正常動作を妨げることになります。

WM_TIMER はダメです。全くアテにできません。
常に処理をループさせることができるのはメッセージループです。
多くのビジネスアプリのメッセージループは GetMessage() API によりメッセージが来るまで
待機するというスタイルを採用しています。

■ 通常のメッセージループ

    ::timeBeginPeriod(1);
    while (1) {
        MSG msg;
        switch (::GetMessage(&msg, NULL, 0, 0)){
        case 0:
            return EXIT_SUCCESS;
        case -1:
            return EXIT_FAILURE;
        default:
            ::TranslateMessage(&msg);
            ::DispatchMessage(&msg);
        }
    }
    ::timeEndPeriod(1);


■ ゲームに適応したメッセージループ

    ::timeBeginPeriod(1);
    while (1) {
        MSG msg;
        if (::PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)){
            if (msg.message == WM_QUIT)
                return EXIT_SUCCESS;
            ::TranslateMessage(&msg);
            ::DispatchMessage(&msg);
        }
        Sleep(1);
    }
    ::timeEndPeriod(1);



ゲームに適したメッセージループの場合、無限ループ内でプログラムがずっと動き続けることになります。
このループ内にゲームループを記述すれば OK です。

■ Win32SDK 推奨ゲームループ

    ::timeBeginPeriod(1);
    DWORD dwTime = ::timeGetTime() + FRAME_RATE;
    while (1) {
        MSG msg;
        if (::PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)){
            if (msg.message == WM_QUIT)
                return EXIT_SUCCESS;
            ::TranslateMessage(&msg);
            ::DispatchMessage(&msg);
        }
        if (dwTime > ::timeGetTime()) {
            Sleep(1);
            continue;
        }
        dwTime = ::timeGetTime() + FRAME_RATE;
        Games();
    }
    ::timeEndPeriod(1);



さて、MFC プログラミングの場合は、このメッセージループが公開されていません。
そのため、メッセージがヒマになると呼び出される OnIdle() メソッドを使用します。

■ MFC 推奨ゲームループ

// CMyApp 初期化
BOOL CMyApp::InitInstance()
{
    ::timeBeginPeriod(1);
}

// CMyApp 後片付け
int CMyApp::ExitInstance()
{
    ::timeEndPeriod(1);
}

// CMyApp メッセージ ハンドラ
BOOL CMyApp::OnIdle(LONG lCount)
{
    static DWORD s_dwTime = ::timeGetTime() + FRAME_RATE;
    if (s_dwTime > ::timeGetTime()) {
        Sleep(1);
    } else {
        s_dwTime = ::timeGetTime() + FRAME_RATE;
        Games();
    }
    return TRUE;
//  return CWinApp::OnIdle(lCount);
}


※ 2005/04/04 追記
 勘違いされるといけないと思い、timeBeginPeriod(1)、timeEndPeriod(1) をプログラムに追加記入させていただきました。



 Copyright 2005 VALGUS. All Rights Reserved.