【C#】謎のフレーム落ち問題を解消したくタイマーを自作した話

# はじめに

最近、自作ゲームがフレーム落ちするようになり原因を調査していました。
どうやら Direct3D の垂直同期がうまく機能していないらしいです。

```cs
// 垂直同期がきっちり待機してくれない
SwapChain.Present(1, SharpDX.DXGI.PresentFlags.None);
```

グラフィックスドライバをロールバックしたら症状が改善したのでおそらくドライバ周りが原因です。ところが PC を再起動すると再びフレーム落ちするようになり悩ましい限りです。  
あれこれ調査したのですが結局症状は改善しませんでした。グラフィックスドライバ周りは複雑で一筋縄ではいかず、保留することにしました。

というわけでタイマーを自作して、自前でフレーム更新を管理するようにしました。

## サンプルコード

<details>
<summary>ValueFpsWaiter.cs</summary>

```cs
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

/// <summary>
/// フレーム更新間隔を待機
/// </summary>
public struct ValueFpsWaiter
{
    private const float DefaultFps = 60f;

    private static readonly long LongTicks = -TimeSpan.FromMilliseconds(2).Ticks;
    private static readonly long JumpTicks = TimeSpan.FromSeconds(1).Ticks;

    private float _fps;
    /// <summary>
    /// 目標の FPS
    /// 60.0
    /// </summary>
    /// <exception cref="System.ArgumentOutOfRangeException">ThrowIfNegativeOrZero</exception>
    public float Fps
    {
        readonly get => this._fps is 0f ? DefaultFps : this._fps;
        set
        {
            ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
            if (this._fps == value) return;
            this._fps = value;
            this._frameTicks = TimeSpan.FromMilliseconds(1000d / value).Ticks;
        }
    }

    private long _frameTicks;
    private long _nextTicks;

    /// <summary>
    /// 目標の FPS になるよう待機
    /// </summary>
    public void Wait()
    {
        if (this._fps is 0f)
            this.Fps = DefaultFps;

        Label:
        var ticks = Stopwatch.GetTimestamp();
        var d = ticks - this._nextTicks;
        if (d < LongTicks)
        {
            Thread.Sleep(1);
        }
        else if (d > 0)
        {
            this._nextTicks += this._frameTicks;
            if (this._nextTicks < ticks - JumpTicks)
            {
                this._nextTicks = ticks + this._frameTicks;
            }
            return;
        }
        goto Label;
    }
}

// Thread.Sleep(1) の精度を出すための設定
// Thread.Sleep(1) が通常だと 16 ms ほど停止する
// timeBeginPeriod を噛ませても 2 ms ほど停止する
file static class CPUTime
{
    [DllImport("Winmm.dll")]
    private static extern uint timeBeginPeriod(uint uPeriod);

    [ModuleInitializer]
    internal static void Init() => _ = timeBeginPeriod(1);
}
```

</details>

<details>

<summary>テストコード</summary>

```cs
using System.Diagnostics;
using Xunit;

file struct ValueFpsChecker
{
    private static long NextTicks = TimeSpan.FromSeconds(1).Ticks;

    private long _lastTicks;
    private int _updateFrames;

    /// <summary>
    /// 計測した FPS
    /// </summary>
    public float RealFps { readonly get; private set; }

    /// <summary>
    /// 更新
    /// 1 秒経過した場合 true を返す
    /// </summary>
    /// <returns></returns>
    public bool Update()
    {
        ++this._updateFrames;
        var ticks = Stopwatch.GetTimestamp();
        var d = ticks - this._lastTicks;
        if (d >= NextTicks)
        {
            this.RealFps = (float)(this._updateFrames / TimeSpan.FromTicks(d).TotalSeconds);
            this._updateFrames = 0;
            this._lastTicks = ticks;
            return true;
        }
        return false;
    }
}

public class __ValueFpsWaiterTest
{
    void HowToUse()
    {
        var waiter = new ValueFpsWaiter();
        waiter.Fps = 60f;

        for (; ; )
        {
            this.DoWork();
            waiter.Wait();
        }
    }

    private void DoWork() { }

    [Fact]
    void TestFps60()
    {
        Console.WriteLine("Count\tFPS\tms");

        var checker = new ValueFpsChecker();
        var waiter = new ValueFpsWaiter();

        int count = 0;
        var lastTicks = Stopwatch.GetTimestamp();
        var exitTicks = lastTicks + TimeSpan.FromSeconds(5).Ticks;
    Label:
        if (Stopwatch.GetTimestamp() > exitTicks) return;
        waiter.Wait();
        checker.Update();
        ++count;
        if (count is 60)
        {
            var ticks = Stopwatch.GetTimestamp();
            var tmp = ticks - lastTicks;
            lastTicks = ticks;
            Console.WriteLine($"{count}\t{checker.RealFps:00.000}\t{TimeSpan.FromTicks(tmp).TotalMilliseconds:0.000}");
            count = 0;
        }
        goto Label;
    }
}
```

</details>

## 使い方

```cs
var waiter = new ValueFpsWaiter();
waiter.Fps = 60f;

for (; ; )
{
    this.DoWork();
    waiter.Wait();
}
```

`ValueFpsWaiter` は**値型**です。通常はクラスのフィールドに持たせて使います。  
`System.Windows.Forms.Timer` とは異なり、`Tick` 等にイベントハンドラを登録する方法**ではなく** `Wait()` により直接スレッドを待機します。

## 解説

### `Stopwatch.GetTimestamp()`

<https://qiita.com/h084/items/5efa49166e5f2524a908> によると、単純な経過時間を取得する場合は [`System.Diagnostics.Stopwatch.GetTimestamp()`](https://learn.microsoft.com/ja-jp/dotnet/api/system.diagnostics.stopwatch.gettimestamp?view=net-8.0) を使うとスマートです。

### `timeBeginPeriod(uint)`

<https://qiita.com/take4eng/items/a4a77179ff15bc86d251> によると、`timeBeginPeriod(uint)` を用いることで [`System.Threading.Thread.Sleep()`](https://learn.microsoft.com/ja-jp/dotnet/api/system.threading.thread.sleep?view=net-8.0) の精度を高めることができます。

今回は更に（ミリ秒以下の）高い精度が必要になるため工夫が必要です。  
タイマーの精度を高めることと CPU 使用率を抑えることはトレードオフの関係なので、今回はハイブリッドしてうまいことやっています。

```cs
var ticks = Stopwatch.GetTimestamp();
var d = ticks - this._nextTicks;
if (d < LongTicks)
{
    // 十分な時間がある場合はパフォーマンス重視
    // 環境依存だが 2ms 以上あれば精度を損なわない
    Thread.Sleep(1);
}
else if (d > 0)
{
    // そうでない場合は精度重視
    this._nextTicks += this._frameTicks;
    if (this._nextTicks < ticks - JumpTicks)
    {
        this._nextTicks = ticks + this._frameTicks;
    }
    return;
}
```

## パフォーマンス

|フレーム数|FPS|待機時間 (ms)|
|---:|---:|---:|
|60|00.000|984.006|
|60|60.016|999.996|
|60|60.000|999.996|
|60|60.001|999.997|
|60|60.000|999.996|

実行環境: Windows11 x64 .NET Runtime 9.0.0

- １秒目はなんか安定しません
- ２秒目以降は**マイクロ秒単位**で見ても結構安定しています。ゲームのフレームレート管理としては十分な精度です

## おわりに

モニタの垂直同期がうまいこといかない問題は解消しなかったものの、自作ゲームがフレーム落ちする問題は解消できました。  
そこそこの精度のタイマーという副産物もできたので、今回はよしとします。