純規の暇人趣味ブログ

首を突っ込んで足を洗う

[VC++/DirectX]フレームレート(fps)を固定するいくつかの方法

      2016/10/23    HimaJyun

ゲームを作るからには、フレームレートに関する知識は必須でしょう。

フレームレートを司る事、それはすなわちゲーム内の時間を司る事でもあります。

と言う訳で、フレームレートを固定する方法を(知っている限り)書いてみようと思います。

フレームレートを固定するいくつかの方法

ひとえに「フレームレートを固定」と言っても様々な手法があります。

とりあえず知り得る3つの方法を書いてみましょう。(他に「こう言う方法もあるぜ」情報お持ちでしたらぜひコメントへ)

下に解説しているサンプルコードの完全版はGitHub Gistにあります

Sleepで眠らせるやり方

入門ゲームプログラミング (Professional Game Developerシリーズ)とかに載っている手法です。

概要としては、「(今の時間 - 前フレームの時間) = 経過時間」で得られた経過時間を元に

1フレームに掛けられる最小時間(1.0f / フレームレート)で比較して( if(経過時間 < 最小時間) )、時間に余裕があれば次のお仕事まで寝る、と言う物。

何を言っているのかさっぱりだと思う、実際自分でも何を言っているのか意味分からない。

こう言う時は言葉で説明するより、ソースコードで説明された方が分かりやすいので以下実例

// 掲載の都合から割愛してます、実際に動作するコードはGitHubよりどうぞ
#include <mmsystem.h>
#pragma comment(lib,"winmm.lib")

// 本当はグローバルにしない方が良い
const float MIN_FREAM_TIME = 1.0f / 60;
float frameTime = 0;
LARGE_INTEGER timeStart;
LARGE_INTEGER timeEnd;
LARGE_INTEGER timeFreq;
// fpsを取得するなら0で初期化しないとゴミが混ざってマイナスから始まったりする(かも知れない)
float fps = 0;

int WINAPI WinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ LPSTR lpCmdLine, _In_ int nCmdShow) {

	// メインループに入る前に精度を取得しておく
	if (QueryPerformanceFrequency(&timeFreq) == FALSE) { // この関数で0(FALSE)が帰る時は未対応
		// そもそもQueryPerformanceFrequencyが使えない様な(古い)PCではどうせ色々キツイだろうし
		return(E_FAIL); // 本当はこんな帰り方しては行けない(後続の解放処理が呼ばれない)
	}
	// 1度取得しておく(初回計算用)
	QueryPerformanceCounter(&timeStart);
}

//==========================
// ここがゲームの処理と仮定
//==========================
void run() {
	// 今の時間を取得
	QueryPerformanceCounter(&timeEnd);
	// (今の時間 - 前フレームの時間) / 周波数 = 経過時間(秒単位)
	frameTime = static_cast<float>(timeEnd.QuadPart - timeStart.QuadPart) / static_cast<float>(timeFreq.QuadPart);

	if (frameTime < MIN_FREAM_TIME) { // 時間に余裕がある
		// ミリ秒に変換
		DWORD sleepTime = static_cast<DWORD>((MIN_FREAM_TIME - frameTime) * 1000);

		timeBeginPeriod(1); // 分解能を上げる(こうしないとSleepの精度はガタガタ)
		Sleep(sleepTime);   // 寝る
		timeEndPeriod(1);   // 戻す

		// 次週に持ち越し(こうしないとfpsが変になる?)
		return;
	}

	if (frameTime > 0.0) { // 経過時間が0より大きい(こうしないと下の計算でゼロ除算になると思われ)
		fps = (fps*0.99f) + (0.01f / frameTime); // 平均fpsを計算
		#ifdef _DEBUG
		// デバッグ用(デバッガにFSP出す)
		#ifdef UNICODE
		std::wstringstream stream;
		#else
		std::stringstream stream;
		#endif
		stream << fps << " FPS" << std::endl;
		// カウンタ付けて10回に1回出力、とかにしないと見づらいかもね
		OutputDebugString(stream.str().c_str());		
		#endif // _DEBUG
	}

	timeStart = timeEnd; // 入れ替え
}

メリット:

  • 如何なる環境でも最大フレームレートを固定出来る(当然ながらスペック不足だと指定値を下回るのでそこは注意)
  • 精度がそこそこ高い(後述の62.5fps問題がない)

デメリット:

  • パッと見何やってるのか分からない
  • 浮動小数点数使うしキャストしまくってんの気持ち悪い(実際は無視できる負荷ですが)

ちなみに、後で説明するが、vsyncの妨害を受けない様に「D3DPRESENT_PARAMETERS」の「PresentationInterval」を「D3DPRESENT_INTERVAL_IMMEDIATE」にする必要がある。

タイマーを使う

タイマーで怠慢……(寒い

マスタリングDirectXプログラミングとかに載っている方法ですね。(余談ですが、この本はDirectXの勉強本と言うより、モノ作りに対する考え方を勉強する本として神書なので一冊持っておく事をオススメします)

一定間隔でイベントを通知してくれるWindowsのSetTimerを利用して、フレームレートを固定しよう、と言うやり方です。

下のコードのハイライトの通り、SetTimerとWM_TIMERがミソ。

SetTimerで指定した周期でWM_TIIMERが送られてくるので、それが来たら処理を行う、と言う訳。

// 注:テストしたらなぜか40fpsしか出なかったのでどこか間違ってるかもです。
// fps計測周りがおかしいのかな?、どこがおかしいかわかったら教えて(はぁと
// 先ほど同様割愛済み

#define TIMER_ID 1
#define FREAM_RATE (1000 / 60)
// fps測定用
DWORD timeBefore;
DWORD fps = 0;

int WINAPI WinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ LPSTR lpCmdLine, _In_ int nCmdShow) {

	// fpsも欲しいなら初期化しておこう
	timeBefore = GetTickCount();
	// タイマーセット
	SetTimer(hwnd, TIMER_ID, FREAM_RATE, NULL);

	MSG msg;
	while (true) {
		if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
			if (msg.message == WM_QUIT) {
				break;
			} else {
				TranslateMessage(&msg);
				DispatchMessage(&msg);
			}
		} else {
			// CPUが過負荷(いわゆるビジーループ)になるので少し休ませる
			Sleep(5);
		}
	}

	// タイマーはウインドウの破棄と同時に自動で破棄されるらしい
	// が、信用ならないので自分でもやっておく
	KillTimer(hwnd, TIMER_ID);
}

//==========================
// ここがゲームの処理と仮定
//==========================
void run() {
	// ゲームの処理をほげほげ

	// fpsを加算
	++fps;

	if ((GetTickCount() - timeBefore) >= 1000) { // 1秒以上経過している
		#ifdef _DEBUG // デバッグ用(デバッガにFSP出す)
		#ifdef UNICODE
		std::wstringstream stream;
		#else
		std::stringstream stream;
		#endif
		stream << fps << " FPS" << std::endl;
		OutputDebugString(stream.str().c_str());
		#endif

		// 0にリセット
		fps = 0;
		// リセット
		timeBefore = GetTickCount();
	}
}

//==================
// イベントハンドラ
//==================
LRESULT WINAPI WinProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
	switch (msg) {
	case WM_DESTROY:
		PostQuitMessage(0);
		return 0;
	case WM_TIMER: // タイマーによる割り込み
		// ゲームの処理を実行
		run();
		return 0;
	}

	return DefWindowProc(hwnd, msg, wParam, lParam);
}

ただ、この方法、精度がお察しで、音ゲーだの弾幕シューティングだのリアルタイム性が高いゲームではとてもじゃないが使えない。

しかも、タイマーの周期はミリ秒単位でしか指定できないため、1000/60=16.666……となり、小数点数切り捨ての16ミリ秒単位での呼び出しになる。

すなわち、1000/16=62.5になってしまい、厳密には62.5fpsになる(手元でテストしたら何故か40fpsしか出なかったが……)

RPGやノベルゲームなどのターン制でリアルタイム性もそう高くないゲームであれば楽なので有効な方法(楽、それはすなわち開発速度が高いと言う意味でもある)

メリット:

  • とにかく簡単
  • 何がしたいのか分かりやすい

デメリット:

  • 一部、タイマーを勝手に破棄する関数があるので注意が必要
  • 精度がお察し

念のために「D3DPRESENT_PARAMETERS」の「PresentationInterval」は「D3DPRESENT_INTERVAL_IMMEDIATE」にしておいた方が良いと思う。

番外:vsyncに任せる

可能ならば一番優雅で美しいやり方。

要は液晶の垂直同期(vsync)に合わせる機能を利用してfpsを固定してしまうと言う物。

やり方も簡単、「D3DPRESENT_PARAMETERS」の「PresentationInterval」に「D3DPRESENT_INTERVAL_DEFAULT」、もしくは「D3DPRESENT_INTERVAL_ONE」を指定する。

両者の違いは、DEFAULTだとそのままで、ONEだと内部的にtimeBeginPeriodでタイマーの精度を上げている(らしい)

サンプルコードを書こうと思ったらDirectXの初期化周りのコードを書かなければならなくなるので、それは勘弁願う。

これを設定しておくと、Presentメソッドを呼び出した時に自動で垂直同期に合わせられる。

テアリングも起こりづらく、使えるのであれば使いたい。

メリット:

  • 最強に簡単

デメリット:

  • 周波数の違う液晶がある

サラッとヤバい事を書いたが、文字通り。

世の中には変態向けディスプレイとして、120hzとか144hzとかそう言うふざけた代物がある。(もう存在しないと思うが、60hz以下の液晶もある?)

なので、「1フレームに10px右に進む」みたいな設計になっているとユーザの環境に応じて早くなったり遅くなったりしてしまう。

それがvsyncの唯一で最大の欠点、これをソフトウェア側から強制的に60hzとかに引き下げ出来る様になれば良いんだろうけど……多分叶わない願い。

どれがベスト?

結局、どれがベストかと聞かれたら……どれも一長一短。

そりゃ、理想だけで言うならばvsyncを利用するのが一番なんだろうけど、これだと所謂可変フレームレートとかああ言うのになって難易度が跳ね上がる。

固定フレームレートでベストなのはSleepで眠らせる方法辺りだと思う。

コンシューマ機ならその辺りも良い感じのベストプラクティスがあるんだろうなぁ……ゲーム開発って、奥が深い。

 - プログラミング ,