PCゲームは描画フレームの遅延が大きいとよく言われますが、実際のところはどうなのでしょうか。これを簡単に確認できるツールがあります。Windows SDK for Windows 7に含まれるGPUViewというツールです。このツールを使用することで、Windowsシステム全体のGPUの使用状況を把握することが出来ます。
インストールは、SDKのインストールの際にWindows Performance Toolkitを選択します。デフォルトではインストールされないようです。使い方は簡単で、管理者権限でコマンドプロンプトを開き、”log.cmd”を実行するだけでログをとり始めます。終了時も、”log.cmd”を実行すればログをとり終え、ログのマージ作業を行います。あとは”Merged.etl”というファイルが目的のログになるので、これを”GPUView.exe”にドラッグアンドドロップするだけです。デフォルトではログを詳しくとるので、ファイルサイズが非常に大きくなるので注意が必要です。
“log.cmd”に引数”light”をつけて実行するとログが小さくなり、システム全体のパフォーマンスへの影響も小さくなります。詳しい使い方などは、”log.cmd”はバッチファイルなので、中を読んだ方が早いです。
では早速ログをとってみました。
3フレームの遅延
このサンプルは、GameApp.exeという名前のDX11のアプリケーションを実行中のログです。
約22FPSで描画されていたので、1フレームあたり45msの描画時間となるはずです。下部にある”Context CPU Queue”がプロセス側のコマンドキューで、上部の”GPU Hardware Queue”がOS全体のGPUに対するコマンドキューです。このログでは他のアプリケーションは極力動作させない状況で取得したので、他のアプリケーションの描画コマンドが”GPU Hardware Queue”側にありませんが、他のアプリケーションが同時に描画を行っていれば、GPU Queue側にはそれぞれののアプリケーションから随時DMAパケットが届き、パケットごとの時分割で処理されます。 黄色くハイライトされているのがPresentを含むDMAパケットです。
選択してハイライトした期間が、Presentのパケットがプロセス側のQueに詰まれてから、GPUに引き渡されて実行されるまでになります。約136msになります。Presentの処理自体は一瞬で終了して、次のパケットの処理をすぐに開始しているので、このタイミングでディスプレイに描画されはじめていると考えて間違いないと思います。(ここから先がディスプレイ側の描画遅延になるはずです。) 約3フレームの遅延となります。これはDirectXがデフォルトで採用しているフレーム遅延に一致します。
このフレームの遅延はDX9EXとDX10以降のAPIで指定可能です。メソッドは次の通りです。
IDXGIDevice1::SetMaximumFrameLatency IDirect3DDevice9Ex::SetMaximumFrameLatency
ちなみにGet用のメソッドもあるので、プログラム内で随時遅延フレーム数を確認することができます。
次のサンプルは上記と同じ描画を実行しているのですが、上記メソッドでフレーム遅延を1に設定したものです。
Presentのパケットが生成されてからGPUに引き渡されるまでに45msほどかかっていますので、約1フレームの遅延となります。 これが現在設定出来る最低の遅延フレームになります。ちなみにどちらも22FPSで描画できていますので、パフォーマンスの低下は発生していません。このようにフレーム遅延を少なくしたからといって、必ずしもFPSが低下するとは限りません。
それではどのようなケースならばFPSが低下することなく描画できるのでしょうか。
答えは簡単です。GPUがアイドルしなければいいのです。
そのためには、
- ディスクのIOやCPUのリソース競合などでプロセス側が描画コマンドの送出を停滞させない
- RenderTargetやOcclusionQueryの読み出しを行う場合はGPUの処理が完了してから行う
これだけでうまくいくはずです。
ただし1番目の項目はWindowsシステム全体の問題なので、ユーザー側には制御が出来ないかもしれません。 性能が比較的低いシステムでは、著しい描画パフォーマンスの低下を招くかも知れません。また、 性能が高いシステムでも、状況によってはFPSが不安定になるかもしれません。しかしそれでも、ユーザー側の細心の配慮で解決できる問題ともいえます。デフォルトの描画遅延フレーム数が3なのはともかく、この遅延数をユーザー側に選択する機会があってもいいかも知れません。
上記のケースでは22FPSで描画するアプリでテストしたので、フレーム遅延が3の場合と1の場合で約90msの差になりましたが、 60FPS描画のアプリケーションなら約32ms程度の差になるはずです。
VSyncを考察する
次は100FPS以上描画できるアプリケーションでVSyncをOnにした場合とOffにした場合のGPUViewのログを見てみます。
このアプリケーションはCPU処理がほとんどなく、単にGPUにDirectXの描画コマンドを投げるだけのものです。VSyncをOnにした場合はディスプレイのリフレッシュレート(この場合はは60FPS)に同期するので、CPUとGPU共にアイドル時間があります。黄色でハイライトしたのがPresentパケットです。Presentが発行されてから即時処理されていますので、CPUとGPUに時間差はほとんど生じていません。このアプリケーションではPresent終了後に次のフレームの描画処理が即時に開始されるので、続けて描画コマンドのパケットがスタックされていきます。GPUもそれを随時処理して、あとは次のVSyncの同期待つためにアイドルします。
次に同様の描画をVsyncをOffにして行った場合です。
VSyncの同期が無いと、プロセス側は描画コマンドをスタックできるだけ積み上げます。 GPUもそれを最大限処理していきます。このケースではPresentが約9ms間隔で処理されているので、110FPSで描画されています。 黄色くハイライトしたのがPresentパケットです。処理されるまでの遅延は、このケースでは約26msもありました。 VSyncを切って高FPSで描画したにも関わらず、フレームの遅延は増大したことになります。 プログラマ側の視点から考えると、これで正しく動作しているのですが、ゲームのプレイヤー側からの視点から考えると、なんか釈然としない感じです。ちなみにこれはどちらがプレイヤーにとって好ましい状況かは、ゲームの種類によって変わると思います。
上記のケースに関してプレイヤー側からの視点で考えた場合
- VSyncをOffにした場合
ゲームループが描画ループに同期しているゲームの場合、ゲームの処理がより多く回る。
その結果、入力判定や衝突判定などの機会が増える。ただし描画処理はゲームループの処理から遅延フレーム分だけ遅延する。 - VSyncをOnにした場合
ゲームループが描画ループに同期しているゲームの場合、ゲームの処理はVSyncの周期と同じになる。
描画処理はゲームループの処理からほとんど遅延しない。
(VSyncでのPresentなので表示されるのは、次のディスプレイの表示期間)
もう少し見てみます。実際のゲームに近づけるために、上記のプログラムの描画ループの先頭に、CPU負荷を掛けるコードを付け足してみます。VsyncをOffにすると70FPSほどで描画できる状態にしました。このプログラムでVSyncをOnにした状態で計測してみます。VSyncをOnにしたので60FPSの描画となりました。しかし遅延はゼロではありません。
具体的に説明しますと、下記のハイライトした部分がCPU側から見たゲームループになります。
一つ前のフレームのPresentパケットが生成されたことは、プログラム側ではPresent()メソッドから処理が戻ったことを意味します。大抵の場合、ここが次のゲームループの処理の始まりになります。GameApp.exeのスレッドがアクティブなのは、CPU負荷を掛けたコードを処理しているからです。実際のケースではゲーミングの処理になるはずです。そしてフレームの後半でDXの描画コマンドを呼び出すので描画コマンドのDMAパケットがスタックに生成されます。最後にPresent()を呼び出しますが、VSync同期があるので少し待たされるようです。フレームの最後の方でGameApp.exeのスレッドが休止しているのはそのためだと思われます。
次に、下記が該当フレームのGPU側の処理期間をハイライトしたものになります。
直前のフレームのPresentパケットの処理が終了した時点から、GPUは基本的にアイドルしています。描画コマンドが無いからです。 フレームの後半でCPU側からの描画コマンドが届くので処理を開始します。最終的にはPresentの処理に至るまでGPUは動作し続けます。
最後にこのアプリの描画遅延時間をハイライトしたものになります。
具体的にはPresentのパケットが生成されてから、GPU側で処理されるまでの時間です。このケースでは約5.3msの遅延となりました。このケースを見ていただいたとおり、VSync同期して60FPSで描画できているケースでも、描画の内容によって、最大1フレーム分の遅延が発生する可能性があります。
GPUのアイドル時間について
ここでもう少しこの手のゲームループに関して考えてみたいと思います。上記のケースで想定されているゲームループはシングルスレッドで、ゲームループ前半にゲーミングの処理があり、後半にその結果を受けた描画処理があるものです。このケースではたいていループの前半にGPUのアイドル時間が存在します。またGPUViewのログをよく観察してみると、このゲームの描画では1フレームあたり計3回のDMAパケットが生成されてGPUに送られています。おそらく1000を超えるDirectXの呼び出しを、3回のDMAパケットで送出しているわけです。ここで勘のいい人は気づいたと思います。
各フレームの最初のDMAパケットをドライバが溜め込んでる間は、GPUはアイドルしているのです。この最初の描画コマンドを早くGPUに送ることが出来れば、GPUの時間をもっと有効に使えるかもしれません。
上記のグラフは、先頭のパケットを早い段階で送出し、2番目のパケットが1番目のパケットの処理が終了する直前に届くように調整されたものです。この描画では1フレームあたり4回のDMAパケットが生成されています。FPSは60FPSのままですが、描画の遅延時間は5.3msから5.0msに短縮されました。CPU側のゲームループから見れば、0.3ms分だけ早く描画が終了するようになったということです。
ではどのように描画コマンドの送出タイミングを制御するかですが、このケースはDX9のアプリケーションだったので、OcclusionQueryを使いました。描画コマンドをフラッシュしたいタイミングでOcclusionQueryを掛けてGetDataで取得を試みます。
DWORD w; IDirect3DQuery9::GetData(&w, sizeof(DWORD), D3DGETDATA_FLUSH);
こんな感じです。戻り値はおそらくS_FALSEが返ってくると思いますが、1度の呼び出しで十分です。Queryの値は必要ありません。これでコマンドバッファがフラッシュされてGPUに送られます。ただし、この様なコマンドバッファのフラッシュを頻繁に行えばCPU,GPUともに転送のオーバーヘッドが増えます。 また、適切なフラッシュのタイミングはCPU,GPUのスペックやGPUのドライバのバージョン等に依存します。 あくまでトリック的な要素が強いですが、フレーム遅延をシリアスに考慮するゲームでは一考の価値があるかもしれません。