EternalWindows
ビットマップ / ゲームループ

ゲームループがどのようなものであるかを説明する前に、 タイマによるアニメーションが優れていない点をいくつか考えてみましょう。 まず、第1に挙げられるのは、タイマの分解能についてです。 SetTimerの第3引数にはタイムアウト値を指定しますが、 このタイムアウト値に指定できる値には限度があります。 Windows2000/XPでは、タイムアウト値に指定できる値は最大で10であり、 10より低い値を指定した場合は、10に丸められることになります。 つまり、プログラムは10ミリ以下の速さでWM_TIMERを受け取ることはできません。 このような時間測定の限界値はタイマの分解能と呼ばれたりしますが、 ミリ秒単位での描画を迫られるゲームでは、かなりの障害となるのは間違いないでしょう。

第2に挙げられるのは、融通が利かないというところでしょうか。 たとえば、タイムアウト値に40ミリ秒と指定した場合、 大まかその間隔でWM_TIMERが送られてくるわけですが、これは常に望むことでしょうか。 ゲーム等では、ウインドウを最小化したときやポーズ処理を施したときには、 一時的にアニメーション処理を停止したい場合もあるはずです。 しかし、タイマはそのようなことは全く気にせずにWM_TIMERを生成します。 確かに、WM_TIMERでIsIconicのような最小化を確認する関数を呼び出せば、 その間でのアニメーション処理は避けることができるでしょうが、 描画するつもりもないのにWM_TIMERが生成され続けるというのは、 やはり気分のいいものではありません。

そして第3に挙げられるのは、画面の更新速度が一定にならないという点です。 この画面の更新というのは、バックバッファをウインドウに表示することであり、 たとえば、タイムアウト値に40ミリ秒と指定したならば、 40ミリ秒に置きに行うのはこの処理です。 しかし、前節で書いたWM_TIMERはそうなっていないのです。

case WM_TIMER: {
	HDC    hdc;
	BITMAP bm;

	if (bUp) {
		nBright += 2;
		if (nBright >= 128) {
			bUp = FALSE;
			nBright = 128;
		}
	}
	else {
		nBright -= 2;
		if (nBright <= 0) {
			bUp = TRUE;
			nBright = 0;
		}
	}
	
	GetObject(hbmpField, sizeof(BITMAP), &bm);

	CopyBits(hbmpBackbuffer, 0, 0, bm.bmWidth, bm.bmHeight, hbmpField, (BYTE)nBright);

	hdc = GetDC(hwnd);

	BitBlt(hdc, 0, 0, 640, 480, hdcBackbuffer, 0, 0, SRCCOPY);

	ReleaseDC(hwnd, hdc);

	return 0;
}

このWM_TIMERではバックバッファの表示以外に、 バックバッファへの描画(CopyBits)も行っています。 このバックバッファへの描画に時間がかかってしまっては、 結果として表示も遅れることになりますから、40ミリ秒間隔での表示は不可能です。 さらに厄介なのは、このバックバッファへの描画に要する時間が非常に曖昧なことです。 ゲームは主にシーンで構成されており、そのシーンに応じて描画する画像の数も 異なるでしょうから、描画に要する時間はシーンによってまばらといえます。 この結果として、あるシーンでは画面の更新が早いけれども、 別のシーンになると画面の更新がやたらと遅いような現象が起きてしまうのです。 また、バックバッファを描画するにはそれに必要なデータ(上記コードでは、nBright)も 更新しなければなりませんから、そのようなデータの更新に時間が掛かる場合も 画面の更新は遅れることになります。

このような問題を引き起こしてしまう原因は、全てがタイマに依存しているからだと思われます。 つまり、WM_TIMERが送られてきたら何かを行うというスタイルであるため、 その送られるタイミングでは分解能の制限を受けることになり、 WM_TIMERの生成を一時的に止めることも難儀です。 ゲームループがこれらの問題を全て解決できるのは、 分解能や描画を行うかどうかの判断、そして画面の更新速度といったものを 全てプログラムが制御して処理するからなのです。

ゲームループの設計の思念は、CPUを一切使ってない時間を捉え、 そこでゲーム関連の処理を行うというものです。 多くのアプリケーションにとって、CPUを一切使ってない時間とは、 メッセージキューにメッセージが存在しない間といえます。 処理するべきメッセージがないわけですから、 結果としてすることがなく、コードの進行は待機するわけです。

while (GetMessage(&msg, NULL, 0, 0) > 0) {
	TranslateMessage(&msg);
	DispatchMessage(&msg);
}

上記コードは、典型的なメッセージループです。 GetMessageは、メッセージキューにメッセージが存在しない場合、 メッセージが到着するまで制御を返しません。 このGetMessageが制御を返さない時間、 つまり、CPUも使わず何もしていない状態のことをアイドル時間と呼びますが、 正にこの状態のときこそゲーム関連処理を実行するチャンスです。 しかし、GetMessageはメッセージが到着するまで制御を返しませんから、 メッセージキューにメッセージが存在するかどうかを関数が必要になってきます。 これは、PeekMessageで行えます。

BOOL PeekMessage(
    LPMSG lpMsg,
    HWND hWnd,
    UINT wMsgFilterMin,
    UINT wMsgFilterMax,
    UINT wRemoveMsg
);

この関数の第1引数から第4引数まではGetMessageと同じ意味を持ちます。 wRemoveMsgはメッセージが存在する場合、 そのメッセージをメッセージキューから削除するかどうかです。 PM_NOREMOVE定数を指定すると、メッセージは削除されません。 戻り値は、メッセージキューにメッセージが存在する場合は、 0以外の値となり、存在しない場合は0となります。 この戻り値を利用すれば、アイドル時間を利用するコードを書くことができます。

for (;;) {
	if (PeekMessage(&msg, NULL, 0, 0, PM_NOREMOVE)) {
		if (GetMessage(&msg, NULL, 0, 0) > 0) {
			TranslateMessage(&msg);
			DispatchMessage(&msg);
		}
		else
			break;
	}
	else {
		// ゲーム関連のコード
	}
}

このようなコードが、ゲームループなのかと訊かれると難しいところですが、 多くのゲームはアイドル時間を利用して動作していると思われるため、 取り敢えず上記のようなコードがゲームループであるとしましょう。 まず、PeekMessageでメッセージが存在するかどうかを調べます。 存在する場合は、いつものようにGetMessageでメッセージを取得し、 DispatchMessageでウインドウプロシージャに送ります、 メッセージが存在しない場合は、ゲーム関連のコードを実行します。 上記コードは、毎回PeekMessageを呼び出しているため、 メッセージが存在する場合はそのメッセージの処理を行えます。 つまり、ゲーム関連のコードがメッセージ処理の妨げになることはありません。


戻る