aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/src/Godosa.Core/Diagnostics/FrameTimeStats.cs
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
committergodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
commit39066900773e7857faf2d02e7ed51b71d219d97d (patch)
treeffab8e4ddd971626776c3b1f4ce1da2c186c2187 /src/Godosa.Core/Diagnostics/FrameTimeStats.cs
downloadgodosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.tar.gz
godosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.zip
godosa-engine: initial public history
Diffstat (limited to 'src/Godosa.Core/Diagnostics/FrameTimeStats.cs')
-rw-r--r--src/Godosa.Core/Diagnostics/FrameTimeStats.cs139
1 files changed, 139 insertions, 0 deletions
diff --git a/src/Godosa.Core/Diagnostics/FrameTimeStats.cs b/src/Godosa.Core/Diagnostics/FrameTimeStats.cs
new file mode 100644
index 0000000..2d1945c
--- /dev/null
+++ b/src/Godosa.Core/Diagnostics/FrameTimeStats.cs
@@ -0,0 +1,139 @@
+namespace Godosa.Core.Diagnostics;
+
+/// <summary>One snapshot of the rolling frame window (game-loop spec §5). Times in ms.</summary>
+public readonly record struct FrameStatsSummary(
+ int Frames,
+ double Fps,
+ double AvgMs,
+ double P50Ms,
+ double P95Ms,
+ double P99Ms,
+ double MaxMs,
+ double OnePercentLowFps,
+ int Hitches,
+ double AvgWorkMs,
+ double SimTickRate,
+ double AvgTickMs,
+ int GpuSamples = 0,
+ double AvgGpuMs = 0);
+
+/// <summary>
+/// Rolling window of render-frame timings (game-loop spec §5). FPS is frames ÷
+/// window time, percentiles are nearest-rank over frame times, 1% low is the
+/// mean of the slowest 1% of frames, and a hitch is a frame over 2× the median.
+/// Buffers are preallocated so recording and summarizing don't allocate.
+/// </summary>
+public sealed class FrameTimeStats
+{
+ private readonly double[] _intervals;
+ private readonly double[] _work;
+ private readonly int[] _ticks;
+ private readonly double[] _sim;
+ private readonly double[] _sorted;
+ private readonly double[] _gpu;
+ private int _next, _gpuNext, _gpuCount;
+
+ public FrameTimeStats(int capacity = 240)
+ {
+ _intervals = new double[capacity];
+ _work = new double[capacity];
+ _ticks = new int[capacity];
+ _sim = new double[capacity];
+ _sorted = new double[capacity];
+ _gpu = new double[capacity];
+ }
+
+ /// <summary>GPU time of one frame (timer queries report a few frames late, so these
+ /// roll separately from <see cref="Record"/>).</summary>
+ public void RecordGpu(double seconds)
+ {
+ _gpu[_gpuNext] = seconds;
+ _gpuNext = (_gpuNext + 1) % Capacity;
+ if (_gpuCount < Capacity) _gpuCount++;
+ }
+
+ public int Capacity => _intervals.Length;
+ public int Count { get; private set; }
+
+ /// <param name="intervalSeconds">Time since the previous frame started.</param>
+ /// <param name="workSeconds">CPU time spent inside this frame's render callback.</param>
+ /// <param name="simTicks">Simulation ticks run this frame.</param>
+ /// <param name="simSeconds">CPU time those ticks took.</param>
+ public void Record(double intervalSeconds, double workSeconds, int simTicks, double simSeconds)
+ {
+ _intervals[_next] = intervalSeconds;
+ _work[_next] = workSeconds;
+ _ticks[_next] = simTicks;
+ _sim[_next] = simSeconds;
+ _next = (_next + 1) % Capacity;
+ if (Count < Capacity) Count++;
+ }
+
+ /// <summary>Copies frame intervals in ms, oldest first; returns how many were written.</summary>
+ public int CopyIntervalsMs(Span<float> destination)
+ {
+ int n = Math.Min(Count, destination.Length);
+ int start = (_next - n + Capacity) % Capacity;
+ for (int i = 0; i < n; i++)
+ destination[i] = (float)(_intervals[(start + i) % Capacity] * 1000.0);
+ return n;
+ }
+
+ public FrameStatsSummary Summarize()
+ {
+ int n = Count;
+ if (n == 0) return default(FrameStatsSummary) with { GpuSamples = _gpuCount, AvgGpuMs = GpuAverageMs() };
+
+ double total = 0, work = 0, sim = 0;
+ int ticks = 0;
+ for (int i = 0; i < n; i++)
+ {
+ total += _intervals[i];
+ work += _work[i];
+ ticks += _ticks[i];
+ sim += _sim[i];
+ _sorted[i] = _intervals[i];
+ }
+ Array.Sort(_sorted, 0, n);
+
+ double median = NearestRank(50, n);
+ int slowest = Math.Max(1, (int)Math.Ceiling(n * 0.01));
+ double slowestSum = 0;
+ for (int i = n - slowest; i < n; i++) slowestSum += _sorted[i];
+
+ int hitches = 0;
+ for (int i = 0; i < n; i++)
+ if (_intervals[i] > 2 * median) hitches++;
+
+ return new FrameStatsSummary(
+ Frames: n,
+ Fps: total > 0 ? n / total : 0,
+ AvgMs: total / n * 1000,
+ P50Ms: median * 1000,
+ P95Ms: NearestRank(95, n) * 1000,
+ P99Ms: NearestRank(99, n) * 1000,
+ MaxMs: _sorted[n - 1] * 1000,
+ OnePercentLowFps: slowestSum > 0 ? slowest / slowestSum : 0,
+ Hitches: hitches,
+ AvgWorkMs: work / n * 1000,
+ SimTickRate: total > 0 ? ticks / total : 0,
+ AvgTickMs: ticks > 0 ? sim / ticks * 1000 : 0,
+ GpuSamples: _gpuCount,
+ AvgGpuMs: GpuAverageMs());
+ }
+
+ private double GpuAverageMs()
+ {
+ if (_gpuCount == 0) return 0;
+ double sum = 0;
+ for (int i = 0; i < _gpuCount; i++) sum += _gpu[i];
+ return sum / _gpuCount * 1000;
+ }
+
+ /// <summary>Nearest-rank percentile of the first <paramref name="n"/> sorted intervals.</summary>
+ private double NearestRank(int percent, int n)
+ {
+ int rank = Math.Max(1, (int)Math.Ceiling(percent / 100.0 * n));
+ return _sorted[rank - 1];
+ }
+}