はじめに
これはSiv3D Advent Calendar 2023の24日目の記事です。
OpenSiv3DはデフォルトでVSync(垂直同期)が有効に設定されており、フレームレートがモニタのリフレッシュレートに同期されます。
これにより、一般的なモニタの場合は60fps、高リフレッシュレートのゲーミングモニタであれば120fpsや144fps等に固定されます。
VSyncのおかげでティアリングなどの画面崩れを避けることができ、安定した動作が可能となります。
しかし、音楽ゲームなど入力タイミングの精度が重要なゲームでは60fpsでは精度が足りない場合もあり、それ以上のフレームレートを必要とする場面があります。1
Graphics::SetVSyncEnabled(false);
でVSyncを無効にすることは可能ですが、フレームレートが数千fpsに跳ね上がり、CPUやGPUに過度な負荷をかける結果となります。これは望ましい状況ではありません。
そこで本記事では、VSyncを無効にした状態でフレームレートを固定するための方法を紹介します。
アドオン機能を使った実装例
OpenSiv3Dのアドオン機能を使って下記のように時間待ちを行うクラスを実装するのが良いです。
アドオン機能とは、登録後にメインループ外で毎フレーム自動的に処理を実行してくれる便利な機能で、ここではFrameRateLimitAddon
というクラスでアドオンを実装しています。
class FrameRateLimitAddon : public IAddon
{
private:
static constexpr std::chrono::steady_clock::duration MaxDrift = 10ms;
std::chrono::steady_clock::duration m_oneFrameDuration;
std::chrono::time_point<std::chrono::steady_clock> m_sleepUntil;
// 1フレームあたりの時間を計算
static std::chrono::steady_clock::duration FPSToOneFrameDuration(int32 targetFPS)
{
if (targetFPS <= 0)
{
throw Error{ U"不正なフレームレート: {}"_fmt(targetFPS) };
}
return std::chrono::duration_cast<std::chrono::steady_clock::duration>(1s) / targetFPS;
}
public:
explicit FrameRateLimitAddon(int32 targetFPS)
: m_oneFrameDuration(FPSToOneFrameDuration(targetFPS))
, m_sleepUntil(std::chrono::steady_clock::now())
{
}
// 毎フレームの描画反映後に呼ばれる
virtual void postPresent() override
{
m_sleepUntil += m_oneFrameDuration;
m_sleepUntil = Max(m_sleepUntil, std::chrono::steady_clock::now() - MaxDrift);
std::this_thread::sleep_until(m_sleepUntil);
}
// フレームレートを変更
void setTargetFPS(int32 targetFPS)
{
m_oneFrameDuration = FPSToOneFrameDuration(targetFPS);
}
};
使い方
下記のように、VSyncを無効にした上で、FrameRateLimitアドオンを登録します。
ここでは例として300fpsに固定しています。
void Main()
{
// VSyncを無効化
Graphics::SetVSyncEnabled(false);
// FrameRateLimitアドオンを登録
constexpr int32 targetFPS = 300;
Addon::Register(U"FrameRateLimit", std::make_unique<FrameRateLimitAddon>(targetFPS));
while (System::Update())
{
// 現在のフレームレートを出力
ClearPrint();
Print << Profiler::FPS() << U" FPS";
}
}
解説
なぜ単純なSleepでは不十分なのか、なぜアドオン機能を使用する必要があるのかについて、順を追って解説します。
NG例: 素朴にフレーム時間分を時間待ちする
まず初めに考えつく方法として、下記のように1フレーム分の時間(1秒÷フレームレート)をSystem::Sleep
で待つ方法があります。
void Main()
{
Graphics::SetVSyncEnabled(false);
while (System::Update())
{
ClearPrint();
Print << Profiler::FPS() << U" FPS";
// 1フレーム分の時間を待つ
constexpr double targetFPS = 300.0;
System::Sleep(1s / targetFPS);
}
}
しかし、これだとSleep以外にかかる時間(System::Update
やその他の処理)が考慮されていないので、指定している300fpsより低いフレームレートになってしまいます。
かつSleep自体の精度も完全ではないので、待ち時間に誤差が生じる場合があります。
ほぼOKな例: 1フレームあたりの時間を加算してsleep_until
で待つ
C++標準のchronoライブラリには、指定した時刻まで待つためのsleep_until
関数が用意されています。
これを利用して、1フレームおきに目標の待ち時間を進めながら時間待ちすることで、Sleepに誤差が生じて余分な時間待ちが発生しても次回フレームで誤差が吸収されます。
void Main()
{
Graphics::SetVSyncEnabled(false);
// 現在時間を取得
std::chrono::time_point<std::chrono::steady_clock> sleepUntil = std::chrono::steady_clock::now();
// 1フレームあたりの時間
constexpr int32 targetFPS = 300;
constexpr auto oneFrameDuration = std::chrono::duration_cast<std::chrono::steady_clock::duration>(1s) / targetFPS;
while (System::Update())
{
ClearPrint();
Print << Profiler::FPS() << U" FPS";
// 1フレーム分の時間を加算
sleepUntil += oneFrameDuration;
// 時間が来るまで待つ
std::this_thread::sleep_until(sleepUntil);
}
}
これで目標の300fpsに固定することができました!
しかし、これでは不十分です。
実用上はさほど問題ないのですが、これだと画面描画タイミングに最大1フレームの遅延が生じることになります。
メインループ内で時間待ちすると1フレーム分遅延するのはなぜ?
いつも何気なく使っているOpenSiv3DのSystem::Update()
ですが、実は内部では前フレームと次フレームの両方の処理が実行されています。
この仕組みのおかげで、私たちはwhile (System::Update()) { ... }
というシンプルなwhileループでゲームを実装できています。
System::Update()
の内部を含めた毎フレームの処理の流れは下図のようになっています。
1フレームの終わり(青色と緑色の変わり目)はMain関数内のwhileループの一番下ではなく、System::Update()
内部にあることがわかります。
上で示した「ほぼOKな例」を図に示してみると、処理順序に問題があることが分かります。
描画内容の反映より先に時間待ちが実行されてしまうので、最大1フレーム分の描画遅延が発生する訳です。
アドオン機能のpostPresent関数を使って正しいタイミングで時間待ちしよう!
1フレームの描画遅延くらい仕方ない?
……いえ、諦めるのはまだ早いです。
OpenSiv3Dにはアドオン機能という、System::Update
内で自動的に処理を実行してくれる機能があります。
例えば下記は基本的なアドオンの記述例で、これを登録しておけばMain関数のwhileループ内でいちいち呼び出さなくても、自動的にupdate
関数とdraw
関数を呼んでくれます。
class SampleAddon : public IAddon
{
public:
virtual bool update() override
{
// ここに毎フレームの更新処理を記述
return true; // 成功時はtrueを返す
}
virtual void draw() const override
{
// ここに毎フレームの描画処理を記述
}
};
void Main()
{
// アドオンを登録
Addon::Register<SampleAddon>(U"SampleAddon");
while (System::Update())
{
}
}
アドオンでオーバーライドできる関数には、上の例にあるupdate
関数、draw
関数だけでなく、ほかにpostPresent
関数というものがあります。
postPresent
関数はupdate
関数と同じように毎フレーム実行される処理が書けるものですが、こちらはSystem::Update()
内部の描画内容の反映(Renderer::present
)の後ろで実行されるという特徴があります。
すなわち、これを使えば下図のように描画内容が反映された後のタイミングで時間待ちすることができます。
下記のようなFrameRateLimitアドオンを作成すれば、遅延を発生させずに時間待ちすることができる訳です。
class FrameRateLimitAddon : public IAddon
{
public:
virtual void postPresent() override
{
// ここで時間待ちする
}
};
void Main()
{
// アドオンを登録
Addon::Register<FrameRateLimitAddon>(U"FrameRateLimit");
while (System::Update())
{
}
}
ここへ実際の時間待ちの処理を実装すると、最初に示した下記のコードになります。
class FrameRateLimitAddon : public IAddon
{
private:
static constexpr std::chrono::steady_clock::duration MaxDrift = 10ms;
std::chrono::steady_clock::duration m_oneFrameDuration;
std::chrono::time_point<std::chrono::steady_clock> m_sleepUntil;
// 1フレームあたりの時間を計算
static std::chrono::steady_clock::duration FPSToOneFrameDuration(int32 targetFPS)
{
if (targetFPS <= 0)
{
throw Error{ U"不正なフレームレート: {}"_fmt(targetFPS) };
}
return std::chrono::duration_cast<std::chrono::steady_clock::duration>(1s) / targetFPS;
}
public:
explicit FrameRateLimitAddon(int32 targetFPS)
: m_oneFrameDuration(FPSToOneFrameDuration(targetFPS))
, m_sleepUntil(std::chrono::steady_clock::now())
{
}
// 毎フレームの描画反映後に呼ばれる
virtual void postPresent() override
{
m_sleepUntil += m_oneFrameDuration;
m_sleepUntil = Max(m_sleepUntil, std::chrono::steady_clock::now() - MaxDrift);
std::this_thread::sleep_until(m_sleepUntil);
}
// フレームレートを変更
void setTargetFPS(int32 targetFPS)
{
m_oneFrameDuration = FPSToOneFrameDuration(targetFPS);
}
};
大まかな内容は「ほぼOKな例」と同じですが、目標フレームレートに届かなかった場合にm_sleepUntil
の進み方が現在時刻より遅れて目標フレームレートを途中で変更しても効かなくなるのを防ぐため、m_sleepUntil
が現在時刻から10ミリ秒以上前の時刻にならないよう処理を入れています。
下記のように目標フレームレートを途中で変更することもできます。
void Main()
{
Graphics::SetVSyncEnabled(false);
Addon::Register(U"FrameRateLimit", std::make_unique<FrameRateLimitAddon>(300));
FrameRateLimitAddon& frameRateLimitAddon = *Addon::GetAddon<FrameRateLimitAddon>(U"FrameRateLimit");
Print.setFont(Font{ 72 }); // Printの文字を大きくする
while (System::Update())
{
ClearPrint();
Print << Profiler::FPS() << U" FPS";
if (SimpleGUI::Button(U"30fps", Vec2{ 600, 10 }))
{
frameRateLimitAddon.setTargetFPS(30);
}
if (SimpleGUI::Button(U"60fps", Vec2{ 600, 60 }))
{
frameRateLimitAddon.setTargetFPS(60);
}
if (SimpleGUI::Button(U"120fps", Vec2{ 600, 110 }))
{
frameRateLimitAddon.setTargetFPS(120);
}
if (SimpleGUI::Button(U"300fps", Vec2{ 600, 160 }))
{
frameRateLimitAddon.setTargetFPS(300);
}
if (SimpleGUI::Button(U"1000fps", Vec2{ 600, 210 }))
{
frameRateLimitAddon.setTargetFPS(1000);
}
if (SimpleGUI::Button(U"10000fps", Vec2{ 600, 260 }))
{
frameRateLimitAddon.setTargetFPS(10000);
}
}
}
実行例:
※10000fpsを指定した場合は実際の処理時間がフレーム時間を上回るため3500fps前後となっています。
注意点
一部環境で、VSyncを無効化した場合でも120fps等に固定される場合があります。
その場合は、ドライバの設定を確認してください。
↑ 追記: どうやらこれはSwapChain生成時にDXGI_SWAP_CHAIN_FLAG_ALLOW_TEARING
フラグが指定されていないこと、Present実行にDXGI_PRESENT_ALLOW_TEARING
フラグが指定されていないことが原因の可能性が高そうなので、後日Issueを立てて調査してみます。
まとめ
- 本記事で紹介したFrameRateLimitアドオンを使えば、VSyncを無効化した状態で特定のフレームレートに固定できる!
- アドオン機能を利用する理由
- メインループ内で時間待ちを入れてしまうと、処理順序の影響で描画反映タイミングに最大1フレームの遅れが生じてしまう
- アドオン機能のpostPresent関数を利用すれば、正しいタイミングで時間待ちできる!
ライセンス
本ページのサンプルコードはCC0 1.0ライセンス(著作権なし)とします。
著作権表記なしで自由にご利用いただいて構いません。
-
Windows限定ですが、リフレッシュレート以上の精度でキーボード入力を取得する機能(
Platform::Windows::Keyboard::GetEvents()
)は存在するので、入力タイミング精度の問題はフレームレート自体を上げなくても解決できる場合があります:
https://github.com/Siv3D/OpenSiv3D/issues/662 ↩