namespace Godosa.Core.Diagnostics;
/// One snapshot of the rolling frame window (game-loop spec §5). Times in ms.
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);
///
/// 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.
///
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];
}
/// GPU time of one frame (timer queries report a few frames late, so these
/// roll separately from ).
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; }
/// Time since the previous frame started.
/// CPU time spent inside this frame's render callback.
/// Simulation ticks run this frame.
/// CPU time those ticks took.
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++;
}
/// Copies frame intervals in ms, oldest first; returns how many were written.
public int CopyIntervalsMs(Span 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;
}
/// Nearest-rank percentile of the first sorted intervals.
private double NearestRank(int percent, int n)
{
int rank = Math.Max(1, (int)Math.Ceiling(percent / 100.0 * n));
return _sorted[rank - 1];
}
}