DirectXを始める前にメインループを作ろう


概要

ゲームやアプリの終了タイミングのほとんどはユーザーが決めます。
そのため、プログラムではwhileなどを使用してユーザーの終了合図が来るまで
ゲームのための処理を繰り返し続けます。
この繰り返し処理のことを「メインループ」と呼んでいます。

Windowsでゲームを作る際にメインループで求められている処理は
「ゲーム処理」「フレーム管理」「メッセージ対応」があります。
このページではフレーム管理とメッセージ対応について説明をします。

サンプル

サンプルはここからダウンロードできます。
環境については以下の内容となっています。

開発環境
VSのバージョン VisualStudio 2019
内容 メインループを実行しているだけなので何も起きません

フレーム管理

ゲームは画面に映る絵を動かすために一定の周期ごとに絵の描き替えを行っています。
この周期のことをゲームでは「フレーム」と呼んでおり、フレームの一秒間の周期数を表した
「FPS(Frame Per Seconde」という言葉もあります。
※ゲームでよく使われるのは60FPSか30FPSです。

DirectXを使用すれば自動でフレーム時間の管理をやってくれますが、
このページではDirectXを使用しない場合の実装方法の説明をします。

時間観測による判定

今回紹介する方法は単純でゲーム処理の時間が一フレームの時間よりも早く終わったら、
時間が来るまで待ち続けるというものです。
※今回紹介している方法は複数あるフレーム管理方法のひとつです。
 この方法だけが正解というわけではありません。

directx_0103

上のフローは簡略した内容ではありますが、このようなイメージで考えてください。

計測方法

今回の計測の流れがつかみやすい「timeGetTime関数」を使用した方法で行います。
※timeGetTime計測よりも精度が高い「QueryPerformanceCounter関数」もありますので、
 興味がある方はそちらを使用してみてください。

directx_0104
// 現在のシステム時間を取得
DWORD time = timeGetTime();

timeGetTimeで取得するシステム時間とはコンピュータの世界での時間だと考えてください。
基準時間と呼ばれる時間があり、そこから現在までどのくらいの時間が経過したかという値が
「システム時間」です。

timeGetTimeの関数を使用する場合、ヘッダは「Windows.h」で大丈夫ですが、
「winmm.lib」を「pragma comment」などで追加する必要があります。

#pragma comment (lib, "winmm.lib")

このtimeGetTimeを使用した計測のフローは以下の通りです。

directx_0005
フローで数字が入っている項目の説明をします。

①.現在と過去の時間保存用の変数を用意

時間計測で現在の時間と過去の時間を保存するための変数を整数型で用意します。

// 計測時間保存用
DWORD current = 0;	// 現在時間保存用
DWORD prev = 0;		// 過去時間保存用

②.timeBeginPeriod関数で精度を変更する

timeGetTimeデフォオルトの最小時間の精度は5msですが、
この時間を「timeBeginPeriod関数」で変更可能です。

directx_0105
// 精度を1msに変更
timeBeginPeriod(1);

なぜ、最初から高精度になっていないかというと、精度を高めるほど
処理コストが上がってしまうからです。
そして、ゲームのように高精度を求めるケースがあまりないということもあり、
デフォルトの精度は最大精度になっていません。

③.時間を取得し過去の時間に代入

メインループが始まる前にtimeGetTimeで時間を取得して、
その結果の値を過去時間として保存します。
この項目が過去時間の初期化と考えてください。

// 過去時間として保存
prev = timeGetTime();

④.時間取得して現在の時間に代入

メインループではまずメインとなるゲーム処理などを行い、
その後timeGetTimeで時間を取得し、現在の時間として保存します。

// 現在時間として保存
current = timeGetTime();

⑤.フレーム時間超過判定

現在の時間と過去の時間の情報がそろったので、ここでフレーム時間を
超過しているかどうかを判定します。

directx_0106
// 時間超過判定
if (current - prev >= frame_time)
{
	// 超過した
}

判定により一フレームの時間を超えていない場合は⑥に進み、
超えてしまっていた場合は⑧に進みます。

⑥.Sleepで待機

フレーム時間を超えていない場合、次の時間まで「Sleep関数」を使い待機状態にします。

directx_0107
// 1ms待機
Sleep(1)

⑦.時間取得して現在の時間に代入

Sleepで待機したあとに再度timeGetTimeで時間を取得して
現在時間の変数に代入をします。

// 現在の時間に代入
current = timeGetTime();

⑧.現在の時間を過去時間に代入

フレーム時間が超過した場合は現在の時間を過去の時間変数に代入して、
次のゲーム処理終了後の判定基準として扱います。

// 現在の時間を過去時間に代入
prev = current;

⑨.timeEndPeriod関数で精度を戻す

ゲームが終了したら、timeBeginPeriodで変更した精度を「timeEndPeriod関数」を
使用して元の精度に戻します。

directx_0108
// 1ms制度を解除
timeEndPeriod(1)

この関数はtimeBeginPeriodを実行したらセットで実行するようにしてください。

①~⑨までのコード

/*
	60FPSでは0.0016666描画だけど、
	1msより精度の高い時間の取得ができないので、17msとする
*/
DWORD frame_time = 17; 

// ①.計測時間保存用
DWORD current = 0;	// 現在時間保存用
DWORD prev = 0;		// 過去時間保存用

// ②.精度を1msに変更
timeBeginPeriod(1);

// ③.過去時間として保存
prev = timeGetTime();

// メインループ
while (true)
{
	// ゲーム処理

	// ④.現在時間として保存
	current = timeGetTime();

	// ⑤.フレーム時間超過判定
	if (current - prev >= frame_time)
	{
		// ⑥.Sleepで待機
		Sleep(1);

		// ⑦.時間取得して現在の時間に代入
		current = timeGetTime();
	}

	// ⑧.現在の時間を過去時間に代入
	prev = current;
}

// ⑨.timeEndPeriod関数で精度を戻す
timeEndPeriod(1);

メッセージ対応

メッセージ対応の「メッセージ」とはWindowsがアプリに通知してくる情報のことです。
「画面内で右クリックされた」「ウィンドウのサイズを変更した」
「ウィンドウを再描画」等、様々なメッセージが送られてきます。
このメッセージを受信して対応を行うことがメッセージ対応です。

ゲームとWindowsアプリの違い

メッセージ対応の前にゲームと一般的なWindowsアプリでは動作の流れが異なるので
まずはそちらから説明します。
ゲームは毎フレーム、ゲーム中の処理を行う必要がありますが、
一般的なアプリはクリックされた、画面がスクロールされた等のメッセージを
受信したときにだけ、対応に必要な処理が実行されます。

メッセージ駆動型(イベント駆動型)

「ゲームとWindowsアプリの違い」で説明した後者のメッセージ受信時のみ処理を実行する
プログラム方式を「メッセージ駆動型」、またはメッセージをイベントと解釈して
「イベント駆動型」と呼ばれています。

メッセージ対応の流れ

メッセージの対応は以下の流れで行います。

順番 内容
メッセージ受信
メッセージ翻訳
メッセージ送信

メッセージ受信

OS(Windows)からメッセージを受信するには特徴の異なる「GetMessage関数」か
「PeekMessage関数」を使用します。
もし、メッセージを受信できたら②に進みます。

directx_0109

GetMessageは同期処理なので、OSがメッセージを送信するまで
次の処理に進むことはありません。
C言語関数のscanfやgetcharなどを関数の挙動をイメージしてください。

directx_0110

PeekMessageは非同期処理なので、OSからのメッセージ送信の結果に関係なく
次の処理に進みます。

この二つの特性でゲームに都合がいい処理はメッセージ送信待ちがない
PeekMessageの方なので、こちらを使用します。

②.メッセージ翻訳

メッセージを受信したら次はメッセージの翻訳を行います。
なぜ翻訳が必要かというと、キーボード入力内容の判断を行うためです。

例えばキーボードのいずれかを押したというメッセージを受信したとします。
この時、メッセージ上ではキーボードのどこキーが押されたかは分かりますが、
大文字、小文字などの判断はできません。
そこで、キーボード入力メッセージに対して変換をかけて
押されたキーボードの種類を分かるようにします。
このメッセージの翻訳をするために「TranslateMessage関数」を使用します。

directx_0111
// メッセージ翻訳
TranslateMessage(&msg);

メッセージの翻訳が無事完了したら次は③に進みます。

③.メッセージ送信

受信したメッセージはウィンドウプロシージャで処理します。
ウィンドウプロシージャは通常の関数と異なるので
呼び出すためには専用の関数である「DispatchMessage関数」に
メッセージを渡してウィンドウプロシージャを実行してもらいます。

directx_0112
// プロシージャにメッセージ送信
DispatchMessage(&msg);

メッセージ取得からウィンドウプロシージャまでの流れ

directx_0009

メインループの終了方法

メインループの終了方法はいくつもありますが、
今回紹介する方法は「PostQuitMessage関数」を使用した方法です。

directx_0113
// 終了メッセージを送信する
PostQuitMessage(0);

PostQuitMessage関数を使用するとWM_QUITメッセージが送信されます。
これをメッセージ取得後に判定してループを抜けます。

// WM_QUITを調べてループを抜ける
if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
{
	if (msg.message == WM_QUIT)
	{
		break;
	}
	else
	{
		TranslateMessage(&msg);
		DispatchMessage(&msg);
	}
}
これでメインループの実装は完了です。
「WindowsAPI」「ウィンドウ作成」についても既に読了済みでしたら、
DirectXを使用する前に行う準備は完了です。