垂直同期を設定している場合でも適切にスリープを挟んだ方がよいという趣旨の記事です。
また注意事項として、筆者の開発環境は以下であり、異なるベンダーのGPU/CPUでは異なる挙動を示す可能性があります。あらかじめご了承ください
OS : Windows 10
GPU : AMD Radeon RX 5700XT
CPU : AMD Ryzen 7 3700X
コントローラやキーボード入力が遅延しているように感じる
よい感じのサンプルが用意できなかったので Dear ImGui のサンプルを使用させていただきます。
まずはDirectX 12 のサンプルを使用します。
デフォルトではバックバッファの数は3つになっているかと思います。
この値を例えば16にして実行してみましょう。
-static int const NUM_BACK_BUFFERS = 3;
+static int const NUM_BACK_BUFFERS = 16;
ImGui のウィンドウを動かしてみてください。
ウィンドウがマウスカーソルよりもかなり遅れて追従したのではないでしょうか?
原因
バックバッファの数を増やしたら遅れるようになったので、原因は明らか・・・と言いたいのですが、減らしたからと言って根本的な解決ではありません。
実は書き込めるバックバッファが残っている場合は垂直同期設定でも待機をしません。
どういうことなのか、計測してみましょう。
サンプルに待機時間計測用のコードを追加します。
WaitForNextFrameResources()
メソッド内で待機を行っているため、ここに計測用コードを追加してみましょう。
計測には std::chrono
を使用します。
+#include <chrono>
FrameContext* WaitForNextFrameResources()
{
UINT nextFrameIndex = g_frameIndex + 1;
g_frameIndex = nextFrameIndex;
HANDLE waitableObjects[] = { g_hSwapChainWaitableObject, nullptr };
DWORD numWaitableObjects = 1;
FrameContext* frameCtx = &g_frameContext[nextFrameIndex % NUM_FRAMES_IN_FLIGHT];
UINT64 fenceValue = frameCtx->FenceValue;
if (fenceValue != 0) // means no fence was signaled
{
frameCtx->FenceValue = 0;
g_fence->SetEventOnCompletion(fenceValue, g_fenceEvent);
waitableObjects[1] = g_fenceEvent;
numWaitableObjects = 2;
}
+ // 計測開始
+ auto begin = std::chrono::steady_clock::now();
WaitForMultipleObjects(numWaitableObjects, waitableObjects, TRUE, INFINITE);
+ // 計測終了
+ auto end = std::chrono::steady_clock::now();
+ auto diff = std::chrono::duration<double, std::milli>(end - begin).count();
+ static uint32_t count = 0;
+ if (count < NUM_BACK_BUFFERS * 2) // とりあえずバックバッファの2倍だけ出力
+ {
+ printf("[%d] : %f ms\n", count, diff);
+ count++;
+ }
return frameCtx;
}
筆者の環境での実行結果が以下になります。
[0] : 0.002000 ms
[1] : 0.001700 ms
[2] : 0.001600 ms
[3] : 0.001300 ms
[4] : 0.001100 ms
[5] : 0.001000 ms
[6] : 0.001000 ms
[7] : 0.001000 ms
[8] : 0.001000 ms
[9] : 0.000900 ms
[10] : 0.000900 ms
[11] : 0.001000 ms
[12] : 0.001000 ms
[13] : 0.001300 ms
[14] : 0.001000 ms
[15] : 0.001000 ms
[16] : 1.694400 ms
[17] : 14.541400 ms
[18] : 15.601300 ms
[19] : 15.563000 ms
[20] : 15.524100 ms
[21] : 13.836600 ms
[22] : 14.679900 ms
[23] : 15.609900 ms
[24] : 15.575600 ms
[25] : 15.391400 ms
[26] : 11.281500 ms
[27] : 15.370500 ms
[28] : 11.758300 ms
[29] : 15.096300 ms
[30] : 14.350600 ms
[31] : 15.132900 ms
確保していたバックバッファの数までは、ほとんど待機時間がありませんね。
そして二巡目以降はおよそ1/60フレームに近い時間だけ待機しているのが分かるかと思います。また、[16]フレーム目では多少(1ミリ秒)待機していますね。
つまり[0]フレーム目に描画したバックバッファが画面に表示されるまでの間に、すでに[15]フレーム目まで描画を終えてしまっているのです。
したがって、最も新しい入力に対して、画面表示が16フレーム分遅延しているという結果になるわけです。
対策
DirectX での対策は簡単です。IDXGISwapChain2::SetMaximumFrameLatency()
に最小値の1を設定するだけです。
このメソッドはCreateDeviceD3D()
内の最後のほうで実行されています。
しかしながら、設定されているのはバックバッファで指定した定数(16)ですね。
これを1にしてみましょう。
-g_pSwapChain->SetMaximumFrameLatency(NUM_BACK_BUFFERS);
+g_pSwapChain->SetMaximumFrameLatency(1);
ちなみに、このメソッドを呼び出さない場合、規定値では3が設定されているようです。
実行結果が以下です。
[0] : 0.002300 ms
[1] : 8.710700 ms
[2] : 11.658700 ms
[3] : 12.174900 ms
[4] : 15.410300 ms
[5] : 14.864600 ms
[6] : 15.345600 ms
[7] : 13.140900 ms
[8] : 12.113900 ms
[9] : 15.563500 ms
[10] : 15.058000 ms
[11] : 15.236400 ms
[12] : 15.674000 ms
[13] : 15.467400 ms
[14] : 15.759000 ms
[15] : 15.785500 ms
[16] : 15.598500 ms
[17] : 15.689300 ms
[18] : 14.945200 ms
[19] : 12.722300 ms
[20] : 12.663700 ms
[21] : 15.360700 ms
[22] : 14.111900 ms
[23] : 14.569200 ms
[24] : 15.320400 ms
[25] : 15.498000 ms
[26] : 15.509100 ms
[27] : 15.620300 ms
[28] : 15.664300 ms
[29] : 15.698100 ms
[30] : 15.659300 ms
[31] : 15.947700 ms
[1]フレーム目からしっかり待機されているようです。
ImGui のウィンドウもしっかりマウスカーソルに追従するようになっていますね。
この記事では遅延を実感してもらうためにバックバッファの数を最大値まで引き上げていますが、ふつうはこの数まで増やすことはないでしょう。
IDXGISwapChain2::SetMaximumFrameLatency()
の初期値も3のようですから、あまり気にしなくてもいいのかもしれません。(格ゲーや音ゲーなどを作成する際は気にしたほうがいいですが。)
Vulkan の場合
DirectX では対策が簡単にできましたが、Vulkan ではそうはいきません。IDXGISwapChain2::SetMaximumFrameLatency()
に相当するものが無いのです。
ではどうするのかというと、別のものがあります。
それがVK_KHR_present_wait
拡張で使用することができるvkWaitForPresentKHR()
です。
以下リファレンスの翻訳です。
presentWait
機能が有効な場合、アプリケーションは、まずVkPresentInfoKHR
構造体のpNext
チェーンにVkPresentIdKHR
構造体を追加して対象のプレゼンテーションのpresentId
を指定し、その後呼び出しによってそのプレゼンテーションが完了するのを待つことで、ユーザに提示される画像を待つことができる
使用方法は以下のようになるでしょうか。(疑似コードです)
static uint64_t value = 0;
value += 1;
VkPresentIdKHR presentId{
.sType = VK_STRUCTURE_TYPE_PRESENT_ID_KHR,
.pNext = NULL,
.swapchainCount = 1,
.pPresentIds = &value, // 1. ここでIDを指定する
};
VkPresentInfoKHR presentInfo{
.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR,
.pNext = &presentId,
.pSwapchains = &swapchain,
.swapchainCount = 1,
.pImageIndices = &imageIndex,
.waitSemaphoreCount = 1,
.pWaitSemaphores = &semaphore,
.pResults = &result,
};
vkQueuePresentKHR(queue, &presentInfo);
// 2. 1.で指定したIDのPresentの完了を待機
vkWaitForPresentKHR(device, swapchain, value, UINT64_MAX);
VK_KHR_present_wait
拡張は、Steam Deck で利用されているvkd3d-proton
でも利用されています。詳しい使い方や実用例として参照してみてください。
VK_KHR_present_wait が利用できない
はい。上記の使用例で疑似コードで示したのは、筆者の開発環境ではVK_KHR_present_wait
が利用できないからです。
Vulkan GPU Info Reports で VK_KHR_present_wait
の実装状況をみてみましょう。
筆者の使用しているGPU、Radeon RX 5700XT は、記載があるにはあるのですが、RADV ・・・すなわち Windows では使用できません。
実際、筆者の開発環境で起動した Vulkan Configurator 上で見られる利用可能な拡張一覧にVK_KHR_present_wait
はありません。
ちなみに NVIDIA GeForce GTX 750 以降のGPUではこの拡張は大体利用できるようです。
というわけで、Radeon ユーザーを切り捨てるのでなければ別の対応を余儀なくされます。
Vulkan での汎用的な対応
長い前振りでしたが、冒頭で記述した通り適切なスリープ処理を実装しよう、というわけです。
ここからのサンプルは Dear ImGui の Vulkan + SDL2 サンプルを使用していきます。
一部 SDL2 で提供される便利関数を利用していますが、Vulkan SDK をインストールしたときに一緒にインストールされて手間がかからないためで、たいていの場合同等の機能があると思いますので、各自置き換えてください。
計測用コードの追加
まずは使用可能なバックバッファのインデックスを取得するメソッド vkAcquireNextImageKHR()
の前後に計測用のコードをFrameRender()
内に追記します。
+#include <chrono>
static void FrameRender(ImGui_ImplVulkanH_Window* wd, ImDrawData* draw_data)
{
VkResult err;
VkSemaphore image_acquired_semaphore = wd->FrameSemaphores[wd->SemaphoreIndex].ImageAcquiredSemaphore;
VkSemaphore render_complete_semaphore = wd->FrameSemaphores[wd->SemaphoreIndex].RenderCompleteSemaphore;
+ auto begin = std::chrono::steady_clock::now();
err = vkAcquireNextImageKHR(g_Device, wd->Swapchain, UINT64_MAX, image_acquired_semaphore, VK_NULL_HANDLE, &wd->FrameIndex);
+ auto end = std::chrono::steady_clock::now();
+ auto diff = std::chrono::duration<double, std::milli>(end - begin).count();
+ static uint32_t count = 0;
+ if (count < g_MinImageCount * 2)
+ {
+ printf("[%d] : %f ms\n", count, diff);
+ count++;
+ }
...以下略
またバックバッファの数をできる限り増やしておきます。
-static uint32_t g_MinImageCount = 2;
+static uint32_t g_MinImageCount = 8;
筆者の環境では16を指定すると怒られたので半分の8にしています。
適宜VkSurfaceCapabilitiesKHR::maxImageCount
を参照して調整してください。
実行結果は以下です。
[0] : 0.404000 ms
[1] : 0.196700 ms
[2] : 0.094800 ms
[3] : 0.098000 ms
[4] : 0.135300 ms
[5] : 0.107400 ms
[6] : 0.154100 ms
[7] : 0.193400 ms
[8] : 0.184700 ms
[9] : 0.120600 ms
[10] : 4.452500 ms
[11] : 15.595500 ms
[12] : 15.102600 ms
[13] : 7.965200 ms
[14] : 15.343300 ms
[15] : 16.969400 ms
DirectX の時と同様にフレームバッファが一巡するまでは待機されていません。
また Dear ImGui のウィンドウを動かしてもマウスカーソルへの追従が遅れていることが分かるかと思います。
待機してはいませんがインデックスの取得をするだけで DirectX よりも時間がかかっていますね・・・
フレームレート制御
適切なスリープ時間を求める処理を追加します。
1フレームの目標時間から、1フレームにかかった処理時間を引けば、待機するべきスリープ時間が得られますね。
つまり、
auto waitTime = ((1000.0 / 目標FPS) - 1フレームにかかった処理時間) - 1;
となります。実際には指定した時間分きっちりスリープしてくれることはほぼないと思いますので、少し猶予(1ミリ秒程度)を持たせています。
垂直同期に合わせるということで、とりあえずモニタのリフレッシュレートを基準にしてみましょう。
SDL2でのリフレッシュレートの取得はSDL_GetCurrentDisplayMode()
が利用できます。
このメソッドで取得できるSDL_DisplayMode
の定義は以下のようになっています。
そのものずばり、リフレッシュレートが取得できますね。
typedef struct
{
Uint32 format; /**< pixel format */
int w; /**< width, in screen coordinates */
int h; /**< height, in screen coordinates */
int refresh_rate; /**< refresh rate (or zero for unspecified) */
void *driverdata; /**< driver-specific data, initialize to 0 */
} SDL_DisplayMode;
SDL_GetCurrentDisplayMode()
の第一引数のint displayIndex
ですが、これは0番を渡しておくとよいでしょう。
というのも、(Windows の話ですが)どうもプライマリモニタのリフレッシュレートが優先されるようです。
例えばプライマリが60Hz、セカンダリが120Hzの構成で使用していた時、リフレッシュレートはプライマリの60Hzが適用されるようです。
したがって、プライマリモニタ(インデックスの0番)を指定しておけばよい、ということになります。
気になるようであれば、SDL_GetWindowDisplayIndex()
を使用してウィンドウが所属するモニタのインデックスを取得してくることもできます。
というわけで、リフレッシュレートを取得するメソッドを追加します。
ついでにstd::thread
のスリープを使いたいのでこれもインクルードしておきます。
#include <thread>
void Wait(double waitTime)
{
if (waitTime > 0) {
std::this_thread::sleep_for(std::chrono::duration<double, std::milli>(waitTime));
}
}
int GetRefreshRate()
{
SDL_DisplayMode displayMode{};
SDL_GetCurrentDisplayMode(0, &displayMode);
return displayMode.refresh_rate;
}
次に、1フレームにかかった処理時間を計測します。Dear ImGui の処理開始から Present 処理まですべて計測します。
メインループの中にあるのでそこに組み込みます。
// Main loop
...中略
+auto begin = std::chrono::steady_clock::now();
// Start the Dear ImGui frame
ImGui_ImplVulkan_NewFrame();
ImGui_ImplSDL2_NewFrame();
ImGui::NewFrame();
...中略
// Present Main Platform Window
if (!main_is_minimized)
FramePresent(wd);
+auto end = std::chrono::steady_clock::now();
+auto diff = std::chrono::duration<double, std::milli>(end - begin).count();
+auto waitTime = (1000.0 / GetRefreshRate() - diff) - 1;
+Wait(waitTime);
さっそく実行してみましょう。Dear ImGui のウィンドウがマウスにしっかり追従してくれるようになったのではないでしょうか?
まとめ
ゲームループにスリープを挟むだけの記事にしてはかなり長くなってしまいました。
垂直同期設定をしていても、次のバックバッファが利用可能になるまで待機しているのであって、描画したバックバッファが画面に表示されるまで待機しているわけではないことに注意が必要ですね。
「描画したバックバッファが画面に表示されるまで待機」する機能は、 Vulkan であればVK_KHR_present_wait
拡張が当てはまるわけですが、Radeon (+ Win)ユーザーは利用できない悲しい現実がありました。
記事中ではより簡単な設定項目があったので省略しましたが、DirectX で次の垂直空白が発生するまでスレッドを停止させる、IDXGIOutput::WaitForVBlank()
というメソッドも存在します。
この記事では垂直同期に絞っていますが、垂直同期をせずに一定のフレームレートにしたい場合、自前のフレームレート調整処理は必須です。この場合、待機にスリープ(ブロッキング)を使用するかは実装によるかとおもいます。
フルスクラッチでゲームを作るかという問題はさておきこのような根幹にあるような機構は開発の初期段階でしっかり作成しておきたいところです。
おまけ
面倒なので試してはいないのですが、IDXGISwapchain
と Vulkan を同時に利用する手法もあるようです。
もしかしたら Vulkan 利用中でもIDXGISwapChain2::SetMaximumFrameLatency()
が利用できるのかも・・・?
言わずもがな同期機構が DirectX の物を使用することになり管理が大変になります。