FPS、タイマー、垂直同期(vsync)とかその辺の話

まず最初に、以下でいうFPSとは「描画」のFPSのことを指す。

タイマーによるFPS調整

Unlimited FireworksFPS表示が安定しない原因を調べてた。問題自体はすぐ解決したけど、調べるうちに色々とわかったのでメモ。
とりあえずその問題について。
UFのFPS調整の実装はタイマーによる時間待ちの方法をとっている。ABA GamesさんのrRootageのソースを参考にして書いたので、それを(少しだけ変えたものを)以下に載せる。


nowTick = SDL_GetTicks();
frame = (int)(nowTick-prvTickCount) / interval;
if ( frame <= 0 ) {
frame = 1;
SDL_Delay(prvTickCount+interval-nowTick);
if ( accframe ) {
prvTickCount = SDL_GetTicks(); //・・・A
} else {
prvTickCount += interval; //・・・B
}
} else if ( frame > 5 ) {
frame = 5;
prvTickCount = nowTick;
} else {
prvTickCount += frame*interval;
}
for ( i=0 ; i

フラグaccframeによって条件分岐しているが、自分のソースではBのやり方にしていた。
ここをAにすると、FPS表示は62に安定した。(interval=16なので1000/16=62.50の端数切り捨て)
訂正。ふらついていたのは垂直同期ONなのにタイマ待ちしていたせいのようで、垂直同期を切ればBでもちゃんと62で安定しました。
上の二つのやり方がどう違うのかというと、Aは待ち時間のオーバー分をゲームの処理時間に含めないが、Bは含めて考えるということである。時間待ちに使っているSDL_Delay()の中で多分Sleep()を呼んでいると思うのだが、こいつは引数の指定時間ぴったりに復帰するわけではない。たとえ他のプロセスがすぐ処理を返したとしてもちょっとオーバーする(SDLのドキュメントでは10msは見とけと書いてある)し、なかなか処理を返さなければそれだけ大きくオーバーする。
つまり、Bは他に重いプロセスが走っているとそいつに処理時間が食われたと考えるので、「ゲームの処理は1フレーム時間内に終わっていたとしても」FPS表示が下がり、フレームスキップが起こる。Aは他のプロセスが時間を食ってもそれを無視するので、「ゲームの処理さえ1フレーム時間内に終わっていれば」規定のFPSが出ていると表示し、フレームスキップもしない。
具体的に何が起きるかというと、他でめちゃくちゃ重いプログラムが走っていた場合、Aはそれに合わせてゲームスピードそのものが遅くなり(しかしFPS表示は60近傍のまま)、Bはゲームスピードを規定値に保とうとするため描画が飛びまくる。
まあ実際はそんなに重いプログラムと同時に走ることはないとして、問題は平常時にどっちがいいのかということだ。
厳密にプレイヤーが感じるスピードに一致しているのはBの方なのだが、Sleep()の精度や他のプロセスのせいでFPS表示が落ちてるだけなのに「このゲーム遅いんですけど」って言われるのを避けるためにはAにする方がいいのか。
ここも訂正。他で負荷をかければゲームがゆっくりになるという設計はまずいに決まってるのでAはダメ。
ちなみにAの方法はここの「B宗1派とフレームスキップについての考察」の回避方法(2)にあたる・・・かと思ったけど、リンク先の文章はゲームの処理が1フレーム時間をオーバーした場合の話なので、時間待ちしていて待ち時間をオーバーしたってのとはちょっと話が違うか。

タイマーと垂直同期(vsync)

上の問題で載せたページは、もともとはFPS調整をタイマーで行うか垂直同期(vsync)で行うかという主旨の内容だった。
とりあえず二つの方法の特徴をまとめてみる。


タイマー
メリット
環境に依存せず同じゲームスピードを再現できる。
デメリット
モニタのリフレッシュレートとずれるのでティアリング(描画更新の境界線が見えること)が起きる。

垂直同期(vsync)
メリット
モニタのリフレッシュレートと描画が同期するのでティアリングが起きない。
デメリット
想定しているゲームスピードとリフレッシュレートが異なる場合に補正が必要になる。
(ゲームスピードを補正する方法と、リフレッシュレートを変更する方法がある)

今までなんとなくタイマーを使っていたのだが、どうも次のような使い分けが一般的らしい。

フルスクリーンでは垂直同期
ゲームとしてはティアリングは当然ない方が望ましいので垂直同期を優先する。
大抵のゲームはゲームスピード60FPSを想定しているので、リフレッシュレートが60Hzでない場合は補正する必要がある。ゲームスピードを補正するやり方の場合、たとえば75Hzの環境ならキャラクタの移動量を60/75倍して辻褄を合わせる。しかしこれだと同じゲーム内容でも環境によってループを回る数が異なるため、リプレイなどを実現するのが難しくなる。
もう一つのリフレッシュレートそのものを変更するやり方は、ゲームの処理を書き分けなくていいので簡単。しかしハードやドライバの問題で変更できないこともあるので、その場合はタイマー使用にせざるをえない。

ウィンドウモードではタイマー
これはDirectX8以前ではウィンドウモードで垂直同期が取れなかったことに起因するらしい。最近のDirectX9を使ったゲームではウィンドウモードでも垂直同期を取っているものがあるようだ。

さて、ここで一つおかしなことが起こった。
上の問題でも述べたが、自分のプログラムではタイマーでFPSを調整している。なのでティアリングが起きるはずである。が、よーく見てもティアリングが起きているようには見えない。色々なゲームでウィンドウモードでやるとはっきりとティアリングが見えるのだが、自分のプログラムではそれがぜんぜん見えない。いったいどういうことなのか。
答えは描画関数にあった。SDLOpenGLを用いているのでSDL_GL_SwapBuffers()でバックバッファとフロントバッファをフリップするのだが、こいつはディスプレイドライバのOpenGLの設定が垂直同期オンなら垂直同期を取ってからフリップを行うようなのだ。つまり、タイマーで調整しているつもりが実は垂直同期になっていたというわけ。試しにFPS調整のコードを全部はずして時間待ちせずにループを回してみると、何の問題も無く60FPS近傍で安定した。ありゃー。ABAさんの書き間違いかなあこれは・・・。プログラム側で垂直同期設定を切ってるような記述も見当たらないけど。
まあリフレッシュレートが60Hzならこのコードでもほとんど問題はなさそうだけど、60Hzじゃないときには60FPSで時間待ちしながら別の周期でフリップ待ちもするからかなりヤバイことになりそう。
ちなみにOpenGLはウィンドウモードでも垂直同期が利くようなので、60Hzなら垂直同期にしてそれ以外ならタイマーにするように書き直すことにしようかな。

おまけ

いくつかのゲームでドライバの垂直同期をオンオフしたときの挙動を見てみた。

東方紅魔郷(DirectX8.0)

垂直同期オン 垂直同期オフ
フルスクリーン 60FPS近傍。ティアリングなし。 タイマ同期もなし。FPSが700とかになる。超高速ゲーム。
ウィンドウ 62.50FPSなのでおそらく16ms固定のタイマ同期。ティアリングあり。 62.50FPSなのでおそらく16ms固定のタイマ同期。ティアリングあり。

リフレッシュレートが60Hz以外の場合はゲームスピードを補正する模様。その場合リプレイが撮れなくなるが、「強制的に60フレームにする」オプションを使えばタイマ同期になるのでリプレイが撮れる。

東方妖々夢(DirectX8.0)

垂直同期オン 垂直同期オフ
フルスクリーン 60FPS近傍。ティアリングなし。 60FPS近傍。ティアリングあり。高精度のタイマ同期をしてる模様。
ウィンドウ 60FPS近傍。ティアリングあり。高精度のタイマ同期をしてる模様。 60FPS近傍。ティアリングあり。高精度のタイマ同期をしてる模様。

リフレッシュレートが60Hz以外の場合は60Hzへの変更を試みる模様。ダメなら自動的にタイマ同期になるのかは不明。

ひぐらしデイブレイク改(DirectX9.0c)

垂直同期オン 垂直同期オフ
フルスクリーン 60〜61FPS。ティアリングなし。 タイマ同期もなし。FPSが120とかになる。しかしゲームスピードはなぜか正常。
ウィンドウ 60〜61FPS。ティアリングなし。 60〜61FPS。見た感じティアリングなし・・・いったいどうなってるんだろう。