前回、処理を止めない実装を紹介しました。ただ、これだとルーブ内の処理の重さによって、フレームレートが変動してしまいます。フレームレートとは 1秒間で表示される画面更新回数の事です。よく 60FPS とか効くかと思いますが。これは 1秒間に画面を60回書き換えているという意味になります。これが 50回になったり 70回になったりと振らつくと気持ち悪いんです。今回はこのフレームレートを固定する実装について解説します。
--
いきなりここに飛んで来ちゃった人は、よろしければ下記からご覧ください。


  • なぜ画面書き換え枚数が変わるのか
まず論よりRUNで以下のプログラムを走らせてみます。

int cnt = 0; for (clock_t clStart = clock(); clock() < clStart + CLOCKS_PER_SEC; ++cnt) { printf(SCRCLEAR); } printf("cnt = %d\n", cnt);
これは1秒間に何回画面を消せるかというサンプルです。私のシステムであれば cnt = 3155 と画面に表示されます。clock() は通常のシステムでは 1ms 単位での時間経過を計測する関数ですが、CLOCKS_PER_SEC に 1秒のカウント数が定義されていますので、この値を用いて時間経過を図るのが安全です。

さて、では、次の以下のプログラムではどうでしょうか?

int cnt = 0; for (clock_t clStart = clock(); clock() < clStart + CLOCKS_PER_SEC; ++cnt) { printf(SCRCLEAR); printf(SCRCLEAR); printf(SCRCLEAR); printf(SCRCLEAR); printf(SCRCLEAR); } printf("cnt = %d\n", cnt);
私のシステムでは cnt = 749 と表示されました。このように同じ1秒間でも処理する回数が異なります。…えっ?printf をたくさん記述したから速度が変わるのは当然だろって?はい、そうです、当然です。ですが、これを例えば敵の弾に置き換えて考えてみてください。画面上に弾が全く無い状態と、画面中に弾だらけだった時では、処理速度に差がかなり出ますが、メインループ上ではおそらく MoveBullet(); と記述があるだけですよね。だから意識していないと処理速度の違いが発生することに気が付かないのです。


  • 処理の重さに依存しないウエイト
例えば 50FPS でフレームレートを固定したいとします。1秒間に 50回処理するのですから、1000ms ÷ 50回 = 20ms となります。つまりメインループを毎回必ず 20ms で回るように調整すればフレームレートは固定できます。余談ですがよく聞く 60FPS にしなかったのは割り切れないためです。1000ms ÷ 60回 = 16.6666666... となります。少しだけ厄介ですよね。昔のブラウン管テレビが 1秒間に60回画面を書き換えていた時の名残で、今でも 60FPS という速度が用いられていたりします。余談の余談ですが 50FPS はヨーロッパのテレビの書き換え速度ですね。PAL 方式と呼ばれてた画面書き換え方式です。日本のは NTSC です。
ゲームを動かす技術と発想 R
堂前 嘉樹
ボーンデジタル
2020-04-18
※ ゲーム作りの技術本の代表がコチラ。リンク先の試し読みを見て頂ければなるほどと納得してもらえるかと思います。
さて、処理速度に関わらずとするなら、処理の前に現在の時刻を ms単位で調べておいて、画面書き換えタイミングまで時間の経過を待つのが良い方法となります。C言語で記述するとこんな書き方になります。

while (!GetAsyncKeyState(VK_ESCAPE)) { // 処理開始時間 clock_t chkFps = clock() + CLOCKS_PER_SEC / 50; // 実際の処理 printf("\x1b[3%dmAAA", (rand() % 7) + 1); // 余剰時間を待つ while (clock() < chkFps) {} }
これで 1秒間に50回処理を定期的に実行するようになります。以前の Windows だとこのような書き方をすると、CPU 使用率が 100% に跳ね上がりましたが、今時のシステムですと大丈夫になりました。さて、実際の処理の部分に違いがあっても同じ速度で処理しているかどうかですが

while (!GetAsyncKeyState(VK_ESCAPE)) { // 処理開始時間 clock_t chkFps = clock() + CLOCKS_PER_SEC / 50; // 実際の処理 printf("\x1b[3%dmA", (rand() % 7) + 1); printf("\x1b[3%dmA", (rand() % 7) + 1); printf("\x1b[3%dmA", (rand() % 7) + 1); // 余剰時間を待つ while (clock() < chkFps) {} }
このように printf をたくさん書いても同じように処理が進むことで確認できるかもです。フレームレートの固定確認って意外と大変なんですよね…


  • フレームレート固定実装の注意点
まずは以下のプログラムを見てください。

while (true) { // 処理開始時間 clock_t chkFps = clock() + CLOCKS_PER_SEC / 50; MovePlayer(); // 自機移動 MoveEnemy(); // 敵機移動 MoveBullet(); // 弾の移動 DrawGame(); // 描画 // 余剰時間を待つ while (clock() < chkFps) {} }
これの何がイケないかって、Move 系の処理の重さによって、表示のタイミングが微妙にふらつくためです。折角時間合わせしているのですから、ここは表示タイミングも合わせたいところです。そのため…

while (true) { // 処理開始時間 clock_t chkFps = clock() + CLOCKS_PER_SEC / 50; DrawGame(); // 描画 MovePlayer(); // 自機移動 MoveEnemy(); // 敵機移動 MoveBullet(); // 弾の移動 // 余剰時間を待つ while (clock() < chkFps) {} }
このようにすれば少なくも描画タイミングに関しては常に一定に保つことが出来ます。欠点としては、操作から実際に表示までにラグが起きることです。なので、FPS が遅い場合は描画を最後に、FPS が速い場合は描画を最初に行うのが良いと思います。

なお、バックバッファに画面を作っておいて、時間が来たら表示させるという手法を使うのであれば、最後に描画を持ってきても問題ありません。

while (true) { // 処理開始時間 clock_t chkFps = clock() + CLOCKS_PER_SEC / 50; MovePlayer(); // 自機移動 MoveEnemy(); // 敵機移動 MoveBullet(); // 弾の移動 DrawGame(); // 描画 // 余剰時間を待つ while (clock() < chkFps) {} // 裏と表を切り替える ExchangeScreen(); }
結局、やってることは描画を先頭に配置したこととなんら変わらないんですが、描画が各処理にバラけている場合は、バックバッファを使って表示を管理すると、より綺麗に画面表示を行うことが出来ると思います。これ、ダブルバッファというテクニックですね。コマンドプロンプトでも出来なくはないんですが、そこまで手を出すのであれば、もう C++ まで逝っちゃって、DirectX と仲良くしたほうが良いんじゃないかとは思いますw
ゲームを動かす数学・物理 R
堂前嘉樹
ボーンデジタル
2021-04-20
※ ご存知堂前さんの大ヒット解説本。私が学校の先生なら、教科書に採用したいぐらいよく解説されています。是非試し読みから見ていただきたいです。