Windows において、高精度なタイマーを実装するのは 2000 年以前から難しい(困難)ことで知られています。
ゲーム開発においては 60/30 fps でゲームを動かすので、そのためによくタイマーを利用しますが、諸々の理由でなるべく高精度なタイマーであるほうが望ましいケースもある。(格闘ゲームや音楽ゲームは理由をイメージしやすい)
そんなわけでなるべく高精度なタイマーをどのようにして利用するのか記録した記事です。
C# の Timer 事情
- System.Timers.Timer
- System.Thereading.Timer
- System.Windows.Forms.Timer
- System.Windows.Threading.DispatcherTimer
このように4つもタイマーが存在している。各タイマーの違いは下記サイトに詳しいです。
ざっくりと下2つは GUI 用途で精度が(比較して)低い。上2つは、ほとんど差が無いようです。精度的には 16ms くらいまで、という説明になっていますが、これは疑問もあってバラつきが報告されています。
ここでは OS の負荷によって sleep の待機時間が揺らぐため、安定したいなら timeSetEvent
はどうか、というコメントがつきました。
このような感じで高精度なタイマーを使いたいなら、System.Timers.Timer
or System.Thereading.Timer
を利用するというのが基本になって、もうすこし深く掘るなら timeSetEvent
や timeGetTime
といった Windows マルチメディアのタイマーを利用する形になると思います。
今回は、どうせなので Windows マルチメディアのタイマーを利用してみました。
補足:自分で sleep を利用したタイマーを作る場合
C# の Sleep()
は reference を参照すると以下のようになっています。具体的な実装は見えません。
/*========================================================================= ** Suspends the current thread for timeout milliseconds. If timeout == 0, ** forces the thread to give up the remainer of its timeslice. If timeout ** == Timeout.Infinite, no timeout will occur. ** ** Exceptions: ArgumentException if timeout < 0. ** ThreadInterruptedException if the thread is interrupted while sleeping. =========================================================================*/ [System.Security.SecurityCritical] // auto-generated [ResourceExposure(ResourceScope.None)] [MethodImplAttribute(MethodImplOptions.InternalCall)] private static extern void SleepInternal(int millisecondsTimeout); [System.Security.SecuritySafeCritical] // auto-generated public static void Sleep(int millisecondsTimeout) { SleepInternal(millisecondsTimeout); // Ensure we don't return to app code when the pause is underway if(AppDomainPauseManager.IsPaused) AppDomainPauseManager.ResumeEvent.WaitOneWithoutFAS(); }
stack overflow を参考にすると以下など。
結局 Sleep()
も数 ms の動作を保証し続けることは難しい。「C#マイクロ秒レベルの高精度Timer」の内容になると思います。
Windows マルチメディアのタイマー実装の注意
具体的な話になるが timeBeginPeriod
を設定したほうがよい。下記のように timeBeginPeriod
の値を固定値にしているサンプルコードもあるが、PC のスペックにあわせて timeGetDevCaps
で情報を取得したほうが丁寧だと思います。
古い情報だと timeBeginPeriod
自体、本当に動作に影響を与えているのか、といった情報もありますが、これはさすがにもう十分に反証と訂正がされているといっていいでしょう。
補足すると、timeBeginPeriod
は windows10 2004 以前は、OS 全体に影響を及ぼす仕様だったようです。(下記 Microsoft Learn に詳細あり)
上述の(固定値)記事は 2007 年のものなので、取り扱いには下記のとおり注意が必要だった。コピペ実装は怖いよねという話。
なので、Windows 10 ver 2004 以降での利用とするほうが OS の挙動としては親切です。TargetFramework を指定しておきましょう。
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net7.0-windows10.0.19041.0</TargetFramework> <UseWPF>true</UseWPF> </PropertyGroup> </Project>
or
[SupportedOSPlatform("windows10.0.19041.0")]
私の今の考えは Windows98-2000 世代だと、
timeBeginPeriod
を必要するようなゲームは、フルスクリーンで遊ぶものだったのでギリギリ仕様として想定/運用できたように思います。それでも、注意の言及は無かったわけでもない。凝り性の人は、やっぱり調べていますし、Windows 10 まで修正が無かったというのは(私の意見からすると)さすがに遅い対応だとも思います。 > コンストラクタで timeBeginPeriod し、デストラクタで timeEndPeriod するようなプログラムを組んではいけないのである。(日記より)
実装するときの P/Invoke
下記のとおり api を利用することができます。
#region P/Invoke [StructLayout(LayoutKind.Sequential)] public struct TIMECAPS { public UInt32 wPeriodMin; public UInt32 wPeriodMax; }; private const int TIMERR_NOERROR = 0x00; private const int TIMERR_BASE = 0x60; private const int TIMERR_NOCANDO = TIMERR_BASE + 0x01; private const int TIMERR_STRUCT = TIMERR_BASE + 0x21; private const int TIME_ONESHOT = 0x00; // イベントを1度だけ実行 private const int TIME_PERIODIC = 0x01; // イベントを繰り返し実行 private delegate void LPTIMECALLBACK(uint uTimerID, uint uMsg, ref uint dwUser, uint dw1, uint dw2); [DllImport("winmm.dll", SetLastError = true, EntryPoint = "timeGetDevCaps")] static extern uint __TimeGetDevCaps(ref TIMECAPS timeCaps, uint sizeTimeCaps); [DllImport("winmm.dll", SetLastError = true, EntryPoint = "timeSetEvent")] private static extern uint __TimeSetEvent(uint uDelay, uint uResolution, LPTIMECALLBACK lpTimeProc, ref uint dwUser, uint fuEvent); [DllImport("winmm.dll", SetLastError = true, EntryPoint = "timeKillEvent")] private static extern uint __TimeKillEvent(uint uTimerId); /// <summary> /// システム時刻をミリ秒単位で取得 /// </summary> /// <returns>システム時刻をミリ秒単位</returns> [DllImport("winmm.dll", EntryPoint = "timeGetTime")] private static extern uint __GetTime(); /// <param name="uMilliseconds">アプリケーションまたはデバイス ドライバーの最小タイマー解像度 (ms)</param> /// <returns>TIMERR_NOERROR を返却</returns> [DllImport("winmm.dll", EntryPoint = "timeBeginPeriod")] private static extern uint __BeginPeriod(uint uMilliseconds); /// <summary> /// 以前に設定した最小タイマー解像度をクリア /// </summary> /// <param name="uMilliseconds">前回の呼び出しで指定された最小タイマーのクリア</param> /// <returns>TIMERR_NOERROR を返却</returns> [DllImport("winmm.dll", EntryPoint = "timeEndPeriod")] private static extern uint __EndPeriod(uint uMilliseconds); #endregion
具体的な C# のクラスにしたケースは、下記のサンプルを参考にしてみてください。
注意点を挙げると、timeSetEvent
を使用する際は LPTIMECALLBACK
コールバック関数を(クラス内などで)インスタンスを保持しておかないと、GC が働いて ExecutionEngineException の例外が発生してしまう。
ダメなコードを例示するとこんな感じ。LPTIMECALLBACK
の部分を匿名関数のようにして解決していると、タイマーで待機している間にメモリー上から失われてしまいます。(GC が解決をするタイミング次第で、匿名関数部が動いたり動かなかったりするはず)
uint dwUser = 0; _TimerID = __TimeSetEvent(interval, resolution, (uint uTimerID, uint uMsg, ref uint dw, uint dw1, uint dw2) => { // なにかの処理 }, ref dwUser, TIME_PERIODIC);
なので、下の例示のように _TimeCallback
のような変数にして、クラス変数などにしてコールバック関数が動作する間はインスタンスを持ち続ける必要があります。使い終わったらインスタンスを手放すといった対応は、C言語のポインターに近いと思います。
uint dwUser = 0; _TimeCallback = (uint uTimerID, uint uMsg, ref uint dw, uint dw1, uint dw2) => { // なにかの処理 }; _TimerID = __TimeSetEvent(interval, resolution, _TimeCallback, ref dwUser, TIME_PERIODIC);
太古の情報
Windows マルチメディアタイマーは、2000 年以前から存在するようで、ネット上の古い日記にも言及がありました。
あとの日記で こちらもおススメされていました。現在もメンテナンスがされていて「ゲームの VSYNC 問題とその対策」になっています。
なので、timeGetTime()
といったマルチメディアタイマーは(現在も歴史的にも)高精度なほうだと評価されているようです。
パフォーマンスカウンターも同様に評価されており、環境によってどちらがよいのか甲乙は調べないとわからない。使い分けになる。
やねうらおさんは、YaneuraoGameSDK を公開しており、FpsTimer
や GameTimer
といったクラスを含んでいます。こちらも参考になるかもしれません。(2007 年の SDK なのでもう古いのと、SDL を利用しているので Sleep()
のように見えない部分もある)
サンプル
GitHub に今回のタイマーをクラス化したサンプルを掲載しています。
- MultimediaTimer
- ElapsedEventArgs
基本的には
System.Timers.Timer
と同じような使い方にしています。