【C++】WindowsAPIで60fpsのゲームループを実装

本稿ではC++(WindowsAPI)でゲームループを作ってみます。

ゲームループはメッセージループのスレッドを利用する方法をやってみます。

ついでに、fps調整の処理もやっておこうと思います。



メッセージループを利用したゲームループ

PeekMessage関数はGetMessage関数と同じくウィンドウメッセージを取得できますが、メッセージ取得待ちが発生しません。

PeekMessage(
メッセージ情報を受け取る構造体へのポインタ
, NULL
, 0, 0
, メッセージの処理方法
)

詳細は公式サイトへどうぞ。

取得したメッセージはMSG.messageで取得できます。messageがWM_QUITである場合はアプリケーション終了メッセージを表しますので、ループを抜けるようにしておきます。(WM_QUITはPostQuitMessageを実行した時発生)

これらを利用してメッセージループの部分を作ってみます。例えば以下のように組むとよさそうです。

	//メッセージループ
	MSG msg = {};
	while (true) {
		//PM_REMOVEはメッセージ処理後に削除することを表す。基本PM_REMOVEでOK
		if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
			if (msg.message == WM_QUIT) {
				break;
			}
			TranslateMessage(&msg);
			DispatchMessage(&msg);
		} else {
			//ゲームの処理
			//17ミリ秒スレッド待機
			std::this_thread::sleep_for(std::chrono::milliseconds(17));
		}
	}

せっかくなのでこのゲームループを使ってアニメーションを作ってみましょうか。

アニメーションはGDIを使用して実装します。最終的にはDirectXを使用するので、GDIに関しては紹介しません。

#include <windows.h>
#include <thread>
#include <chrono>
#include <string>

LRESULT CALLBACK WndProc(HWND hwnd, UINT message,
	WPARAM wParam, LPARAM lParam) {
	switch (message) {
	case WM_CLOSE:
		return DefWindowProc(hwnd, message, wParam, lParam);
	case WM_DESTROY:
		PostQuitMessage(0);
		break;
	default:
		return DefWindowProc(hwnd, message, wParam, lParam);
	}
	return 0L;
}
int WINAPI WinMain(
	HINSTANCE hInstance,      // 現在のインスタンスのハンドル
	HINSTANCE hPrevInstance,  // 以前のインスタンスのハンドル
	LPSTR lpCmdLine,          // コマンドライン
	int nCmdShow              // 表示状態
) {
	HWND hWnd;
	LPCTSTR szclassName = "WinAPITest";
	WNDCLASSEX wcex;

	ZeroMemory((LPVOID)&wcex, sizeof(WNDCLASSEX));

	//ウィンドウクラスを登録
	wcex.cbSize = sizeof(WNDCLASSEX);
	wcex.style = 0;
	wcex.lpfnWndProc = WndProc;
	wcex.cbClsExtra = 0;
	wcex.cbWndExtra = 0;
	wcex.hInstance = hInstance;
	wcex.hIcon = NULL;
	wcex.hCursor = LoadCursor(NULL, IDC_ARROW);
	wcex.hbrBackground = (HBRUSH)(COLOR_BACKGROUND + 1);
	wcex.lpszMenuName = NULL;
	wcex.lpszClassName = szclassName;
	wcex.hIconSm = NULL;
	RegisterClassEx(&wcex);

	//ウィンドウ作成
	hWnd = CreateWindowEx(0, szclassName, "Title", WS_OVERLAPPEDWINDOW,
		0, 0,
		300, 100,
		NULL, NULL, hInstance, NULL);


	//ウィンドウ表示
	ShowWindow(hWnd, SW_SHOW);

	//メッセージループ
	MSG msg = {};
	int cnt = 0;
	HDC hdc = GetDC(hWnd);
	while (true) {
		//メッセージを取得したら1(true)を返し取得しなかった場合は0(false)を返す
		if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
			if (msg.message == WM_QUIT) {
				//終了メッセージが来たらゲームループから抜ける
				break;
			}
			TranslateMessage(&msg);
			DispatchMessage(&msg);
		} else {

			//ゲームの処理を記述
			//DirectXの描画処理などもここに記述する
			//今回はGDIでカウントアップを描画する処理で動作テスト
			cnt++;
			std::string str = std::to_string(cnt);
			const char* result = str.c_str();
			TextOut(hdc, 10, 10, result, str.size());

			//....

			//17ミリ秒スレッド待機
			std::this_thread::sleep_for(std::chrono::milliseconds(17));
		}
	}
	//解放
	ReleaseDC(hWnd, hdc);
}
実行結果



ゲームループを任意のfps(描画数)に保つ

fpsとは1秒間に画面が何回更新されるかを表す単位です。(フレームレートのほうが一般的な言い方かもしれません。)例えば40fpsのゲームなら1秒間に40回画面を描画しなおしています。

ゲーム制作では60fpsや30fps等、処理の重さによって描画数を変更したい場合がある為、これの調整をできるようにしておくと便利です。

例えば60fpsなら1000ミリ秒/60で16.6666….ミリ秒スレッドスリープを挟めば良いです。

しかし1ミリ秒以下のスリープはまともに機能しない環境もあるので、60fpsにするには16、17ミリ秒スリープを適度に調整してあげる必要があります。(そもそもスレッドスリープ自体確実に指定時間待機できるわけではないです。)

また、ゲームの処理にかかった時間を待機時間から省いてあげるのもfpsを保つには重要です。これは現在時刻からゲーム処理前の時間を引き算するだけで求まります。

これらを実現するために、1フレームごとに次のフレームの処理開始時間を求め、処理後に時間が余った分を待機時間とします。

例えば50fpsの処理の流れはこんな感じですかね。

実際のコードを作成する前にfps計測する方法も確認しておきます。そのまま実行しても60fpsになっているのかわからないですからね。

fpsの計算は「(1秒)/(現在時刻-計測開始地点の時刻)*(開始地点から現在までのフレーム数)」で計測できます。

サンプルではFrameRateCalculatorクラスを作成し、この機能を持たせることにします。

それでは実際にコードを作成してみましょう。一応マイクロ秒単位での待機にしてますが、ミリ秒単位での待機に変えても対応できているはずです。

#include <windows.h>
#include <thread>
#include <chrono>
#include <string>
//フレームレート計算クラス
class FrameRateCalculator {
	long long cnt = 0;
	const int limit = 60;
	std::string fpsStr = "0fps";
	long long time = currentTime();

	//現在時刻を取得する関数
	long long currentTime() {
		std::chrono::system_clock::duration d = std::chrono::system_clock::now().time_since_epoch();
		return std::chrono::duration_cast<std::chrono::milliseconds>(d).count();
	}

	//フレームレートの計算と結果文字列を構築する
	void updateStr() {
		//fpsを計算し、文字列として保持する
		long long end = currentTime();
		double fpsResult = (double)(1000) / (end - time) * cnt;
		time = end;
		fpsStr = std::to_string(fpsResult) + "fps";
		cnt = 0;
	}
public:

	//フレームレート更新メソッド
	std::string* update() {
		cnt++;
		//規定フレーム数になったらフレームレートの更新
		if (limit <= cnt) {
			updateStr();
		}
		return &fpsStr;
	}
};

LRESULT CALLBACK WndProc(HWND hwnd, UINT message,
	WPARAM wParam, LPARAM lParam) {
	switch (message) {
	case WM_DESTROY:
		PostQuitMessage(0);
		break;
	default:
		return DefWindowProc(hwnd, message, wParam, lParam);
	}
	return 0L;
}

int WINAPI WinMain(
	HINSTANCE hInstance,      // 現在のインスタンスのハンドル
	HINSTANCE hPrevInstance,  // 以前のインスタンスのハンドル
	LPSTR lpCmdLine,          // コマンドライン
	int nCmdShow              // 表示状態
) {
	HWND hWnd;
	LPCTSTR szclassName = "WinAPITest";
	WNDCLASSEX wcex;

	ZeroMemory((LPVOID)&wcex, sizeof(WNDCLASSEX));

	//ウィンドウクラスを登録
	wcex.cbSize = sizeof(WNDCLASSEX);
	wcex.style = 0;
	wcex.lpfnWndProc = WndProc;
	wcex.cbClsExtra = 0;
	wcex.cbWndExtra = 0;
	wcex.hInstance = hInstance;
	wcex.hIcon = NULL;
	wcex.hCursor = LoadCursor(NULL, IDC_ARROW);
	wcex.hbrBackground = (HBRUSH)(COLOR_BACKGROUND + 1);
	wcex.lpszMenuName = NULL;
	wcex.lpszClassName = szclassName;
	wcex.hIconSm = NULL;
	RegisterClassEx(&wcex);

	//ウィンドウ作成
	hWnd = CreateWindowEx(0, szclassName, "Title", WS_OVERLAPPEDWINDOW,
		0, 0,
		300, 100,
		NULL, NULL, hInstance, NULL);


	//ウィンドウ表示
	ShowWindow(hWnd, SW_SHOW);

	//現在時刻をマイクロ秒で取得
	std::function<long long(void)> currentTimeMicro = []() {
		std::chrono::system_clock::duration d = std::chrono::system_clock::now().time_since_epoch();
		return std::chrono::duration_cast<std::chrono::microseconds>(d).count();
	};

	//fps計算用オブジェクト
	FrameRateCalculator fr;

	//メッセージループ
	MSG msg = {};
	int cnt = 0;
	HDC hdc = GetDC(hWnd);

	//60fpsで動作させる
	int fps = 60;

	//現在時刻を取得(1秒=1000000)
	long long end = currentTimeMicro();

	//次の更新時間を計算(1秒/フレームレート)
	long long next = end + (1000 * 1000 / fps);
	while (true) {
		//メッセージを取得したら1(true)を返し取得しなかった場合は0(false)を返す
		if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
			if (msg.message == WM_QUIT) {
				//終了メッセージが来たらゲームループから抜ける
				break;
			}
			TranslateMessage(&msg);
			DispatchMessage(&msg);
		} else {
			
			//ゲームの処理を記述
			//DirectXの描画処理などもここに記述する
			//今回はGDIでカウントアップを描画する処理で動作テスト
			cnt++;
			std::string str = std::to_string(cnt);
			const char* result = str.c_str();
			TextOut(hdc, 10, 10, result, str.size());

			//重い処理があったとする
			std::this_thread::sleep_for(std::chrono::milliseconds(5));
			
			//fps描画
			std::string* fpsStr = fr.update();
			const char* fpsStrResult = fpsStr->c_str();
			TextOut(hdc, 100, 30, fpsStrResult, fpsStr->size());



			//できるだけ60fpsになるようにスレッド待機
			end = currentTimeMicro();
			if (end < next) {
				//更新時間まで待機
				std::this_thread::sleep_for(std::chrono::microseconds(next - end));

				//次の更新時間を計算(1秒/フレームレート加算)
				next += (1000 * 1000 / fps);
			} else {
				//更新時間を過ぎた場合は現在時刻から次の更新時間を計算
				next = end + (1000 * 1000 / fps);
			}
		}
	}
	//解放
	ReleaseDC(hWnd, hdc);
}
実行結果

大体60fpsになっていますかね。処理落ちしたら当然60fpsになりません。デバッグモードだと処理落ちしやすいので気を付けてください。

ちなみにDirectXを利用する場合は垂直同期が利用できるため、モニターの描画数に合わせたい場合に限り、スレッド待機の処理を作る必要がありません。だいたい60fpsになってるので、必要ない場合は、あえて実装する必要はないです。

C++

Posted by nompor