Измерить время кадра и проверить оптимизацию
Готовый C# сборщик интервалов: прогрев, среднее, p95, максимум и кадры сверх бюджета.
Снять одинаковый сценарий
- В C#-проекте 1.0.0 создайте файл FrameTimingProbe.cs. Прикрепите его к Entity, которая остаётся в дереве на время замера.
- Для первого запуска оставьте 60 кадров прогрева, 300 измерений и бюджет 16,67 мс. Соберите проект и запустите уровень через F6.
- Во время замера повторяйте выбранное действие. Не меняйте камеру, число объектов, разрешение и ограничения FPS между сравниваемыми прогонами.
- Дождитесь единственной строки
FRAME n=300 .... Сохраните её вместе с конфигурацией сборки и устройства.
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 = [];
}
}Скрипт измеряет монотонное реальное время между входами в свой _Process. Пауза ОС, ограничение FPS и ожидание показа кадра могут попасть в этот интервал. Это не отдельное время CPU-функции и не GPU timestamp. delta здесь намеренно не используется как секундомер: это вход игрового шага, на который влияют настройки времени движка.
Пока Entity не обрабатывается из-за паузы или отключённого процесса, измерения не приходят. Пауза между двумя вызовами увеличит следующий интервал. Не включайте такие действия в обычное сравнение, либо измеряйте их отдельным сценарием. Start сбрасывает текущий набор; выход из дерева прекращает сбор без Completed. При повторном входе запустите Start явно.
Прочитать результат
| Поле | Значение |
|---|---|
| mean_ms | Среднее время интервала; скрывает отдельные длинные кадры |
| p95_ms | Значение отсортированной выборки с индексом ceil(0,95 × n) − 1 |
| max_ms | Самый долгий интервал в этом прогоне |
| over | Число интервалов строго больше BudgetMs |
Для четырёх интервалов 10, 20, 30, 40 мс и бюджета 25 мс среднее равно 25, p95 и максимум — 40, over — 2. Эта проверка встроена в стенд документации. При малой выборке p95 часто совпадает с максимумом; это не ошибка округления и не обещание будущих задержек.
1000 / mean_ms даёт частоту по средней длительности интервала; это не среднее арифметическое мгновенных FPS. Не сравнивайте средний FPS одного прогона с p95 другого как одну метрику. Сохраняйте все четыре показателя.
Проверить одну гипотезу
Снимите исходный вариант A, изменённый B и снова A в одном сценарии после одинакового прогрева. Повторите серию. Меняйте одну причину: например, число поисков цели в кадре. Проверьте одинаковое число игровых результатов. Улучшение длительности бесполезно, если изменённый код пропускает столкновения или работу ИИ.
Первые загрузки и компиляции шейдеров могут отличаться от повторных. Если важен первый запуск, измеряйте его отдельно; не удаляйте эти задержки прогревом из отчёта о первом запуске. Логи в каждом кадре, отладчик и глубокая инструментализация сами добавляют работу. Проверяйте финальную целевую сборку, а не только запуск из редактора.
Проверены расчёт статистики, границы входных данных, живой сбор _Process, повторный запуск и главный поток Completed. Headless-прогон стенда проверяет поведение сборщика, а не производительность отрисовки или конкретного устройства. Для локализации причины продолжите измерением CPU-кода.