Measure frame time and verify an optimization
A complete C# interval sampler: warmup, mean, p95, maximum and over-budget frames.
Capture a matching scenario
- Create FrameTimingProbe.cs in a 1.0.0 C# project. Attach it to an Entity that stays in the tree throughout the measurement.
- For the first run, keep 60 warmup frames, 300 samples and a 16.67 ms budget. Build the project and run the level with F6.
- Repeat the chosen action during capture. Keep camera, object count, resolution and FPS limits unchanged between comparison runs.
- Wait for the single
FRAME n=300 ...line. Save it with the build/device configuration.
FrameTimingProbe.cs
using System;
using System.Diagnostics;
using Reckfell;
public partial class FrameTimingProbe : Entity
{
public readonly record struct Summary(int Count, double MeanMs, double P95Ms,
double MaxMs, int OverBudget, double BudgetMs);
[Export] public int SampleCount { get; set; } = 300;
[Export] public int WarmupFrames { get; set; } = 60;
[Export] public double BudgetMs { get; set; } = 1000.0 / 60;
public bool Busy { get; private set; }
public Summary Last { get; private set; }
public event Action<Summary>? Completed;
private double[] _samples = [];
private int _warmup;
private int _count;
private long _previous;
private double _budgetMs;
public override void _Ready()
{
if (Start(SampleCount, WarmupFrames, BudgetMs) != Error.Ok)
ReckfellApi.PrintErr("FrameTimingProbe: invalid settings");
}
public Error Start(int count = 300, int warmup = 60, double budgetMs = 1000.0 / 60)
{
if (!ReckfellThread.IsMainThread()) return Error.Unavailable;
if (!IsInsideTree()) return Error.Unconfigured;
if (count < 1 || count > 10000 || warmup < 0 || warmup > 10000 ||
!double.IsFinite(budgetMs) || budgetMs <= 0) return Error.InvalidParameter;
_samples = new double[count];
_count = 0;
_warmup = warmup;
BudgetMs = budgetMs;
_budgetMs = budgetMs;
_previous = Stopwatch.GetTimestamp();
Busy = true;
SetProcess(true);
return Error.Ok;
}
public override void _Process(double delta)
{
if (!Busy) return;
long now = Stopwatch.GetTimestamp();
double ms = Stopwatch.GetElapsedTime(_previous, now).TotalMilliseconds;
_previous = now;
if (_warmup > 0) { _warmup--; return; }
_samples[_count++] = ms;
if (_count != _samples.Length) return;
Last = Summarize(_samples, _budgetMs);
Busy = false;
SetProcess(false);
ReckfellApi.Print(FormattableString.Invariant(
$"FRAME n={Last.Count} mean_ms={Last.MeanMs:F3} p95_ms={Last.P95Ms:F3} max_ms={Last.MaxMs:F3} over={Last.OverBudget}"));
Completed?.Invoke(Last);
}
public static Summary Summarize(double[] milliseconds, double budgetMs)
{
ArgumentNullException.ThrowIfNull(milliseconds);
if (milliseconds.Length == 0 || !double.IsFinite(budgetMs) || budgetMs <= 0)
throw new ArgumentOutOfRangeException(nameof(budgetMs));
double[] sorted = (double[])milliseconds.Clone();
double mean = 0;
int over = 0;
for (int i = 0; i < sorted.Length; i++)
{
double value = sorted[i];
if (!double.IsFinite(value) || value < 0)
throw new ArgumentOutOfRangeException(nameof(milliseconds));
mean += (value - mean) / (i + 1);
if (value > budgetMs) over++;
}
Array.Sort(sorted);
int p95 = (int)Math.Ceiling(sorted.Length * 0.95) - 1;
return new Summary(sorted.Length, mean, sorted[p95], sorted[^1], over, budgetMs);
}
public override void _ExitTree()
{
Busy = false;
_samples = [];
}
}The script measures monotonic elapsed time between entries into its _Process. OS pauses, FPS limits and presentation waits may be included. It is neither an individual function's CPU time nor a GPU timestamp. delta is deliberately not the stopwatch: it is the gameplay step input, affected by engine time settings.
A paused or disabled Entity receives no samples. A gap between callbacks increases the next interval. Exclude such actions from an ordinary comparison, or measure them as a separate scenario. Start resets the current capture; leaving the tree stops it without Completed. Call Start explicitly after reentry.
Read the result
| Field | Meaning |
|---|---|
| mean_ms | Mean interval duration; hides individual long frames |
| p95_ms | Sorted sample value at index ceil(0.95 × n) − 1 |
| max_ms | Longest interval in this run |
| over | Number of intervals strictly greater than BudgetMs |
For four intervals of 10, 20, 30, 40 ms and a 25 ms budget, the mean is 25, p95 and maximum are 40, and over is 2. The documentation fixture checks this case. Small-sample p95 often equals the maximum; that is neither a rounding error nor a promise about future stalls.
1000 / mean_ms gives a rate from the mean interval, not the arithmetic mean of instantaneous FPS. Do not compare one run's mean FPS with another run's p95 as though they were the same metric. Retain all four measurements.
Test one hypothesis
Capture baseline A, changed B and A again in the same scenario after matching warmup. Repeat the series. Change one cause, such as the number of target searches per frame. Verify the same gameplay results. A shorter duration is useless if the modified code skips collisions or AI work.
Initial loading and shader compilation may differ from repeat runs. Measure first launch separately when it matters; warming it away does not make it a first-launch result. Per-frame logs, debuggers and deep instrumentation add work themselves. Check the final target build, not only an editor launch.
Checks cover statistics, input bounds, live _Process sampling, restart and main-thread Completed. The headless fixture checks sampler behavior, not rendering or device performance. Continue with CPU code measurement to locate a cause.