feat(pipeline): MP0 - FrameProfEnabled flag + GPU TimeElapsed ring

RenderingDiagnostics.FrameProfEnabled is the master toggle for the
upcoming permanent frame profiler (ACDREAM_FRAME_PROF=1, runtime
mirror via DebugVM.FrameProf). GpuFrameTimer wraps a depth-4 ring of
TimeElapsed queries for whole-frame GPU time, mirroring
WbDrawDispatcher's query idiom (including the #125 never-begun-slot
guard). Caller must not run this while ACDREAM_WB_DIAG=1 owns
TimeElapsed queries — GL forbids nested TimeElapsed queries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-05 19:10:28 +02:00
parent 7d74c68c60
commit 0a5f7d4924
2 changed files with 104 additions and 0 deletions

View file

@ -0,0 +1,87 @@
using System;
using Silk.NET.OpenGL;
namespace AcDream.App.Diagnostics;
/// <summary>
/// MP0 (2026-07-05) — whole-frame GPU time via a ring of
/// <see cref="QueryTarget.TimeElapsed"/> queries (depth 4, so results are
/// read ~3 frames late and never stall). Mirrors WbDrawDispatcher's query
/// idiom including the #125 lesson: a glGenQueries name is not a query
/// OBJECT until first glBeginQuery, so never-begun slots are skipped via
/// the Begun flags.
///
/// <para>MUST NOT be active while ACDREAM_WB_DIAG=1: GL forbids two
/// simultaneously active TimeElapsed queries and WbDrawDispatcher brackets
/// its passes with them under that flag. The caller (FrameProfiler)
/// enforces the exclusion; this class just does the ring.</para>
/// </summary>
internal sealed class GpuFrameTimer : IDisposable
{
private const int RingDepth = 4;
private readonly GL _gl;
private readonly uint[] _queries = new uint[RingDepth];
private readonly bool[] _begun = new bool[RingDepth];
private int _frameIndex;
private bool _queryActive;
public GpuFrameTimer(GL gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
for (int i = 0; i < RingDepth; i++)
_queries[i] = _gl.GenQuery();
}
/// <summary>
/// Call once per frame at the frame boundary. Ends the previous
/// frame's query, polls the oldest slot non-blocking, begins this
/// frame's query. Returns the completed GPU time in microseconds for
/// a ~RingDepth-frames-old frame, or null when no result is ready.
/// </summary>
public long? FrameBoundary()
{
if (_queryActive)
{
_gl.EndQuery(QueryTarget.TimeElapsed);
_queryActive = false;
}
long? completedUs = null;
int readSlot = _frameIndex % RingDepth; // about to be reused — oldest
if (_begun[readSlot])
{
_gl.GetQueryObject(_queries[readSlot], QueryObjectParameterName.ResultAvailable, out int avail);
if (avail != 0)
{
_gl.GetQueryObject(_queries[readSlot], QueryObjectParameterName.Result, out ulong ns);
completedUs = (long)(ns / 1000UL);
}
// Not available ⇒ sample silently dropped (same policy as
// WbDrawDispatcher) — percentiles tolerate missing samples.
}
_gl.BeginQuery(QueryTarget.TimeElapsed, _queries[readSlot]);
_begun[readSlot] = true;
_queryActive = true;
_frameIndex++;
return completedUs;
}
/// <summary>End any active query without beginning a new one (used when the profiler is toggled off mid-session).</summary>
public void Stop()
{
if (_queryActive)
{
_gl.EndQuery(QueryTarget.TimeElapsed);
_queryActive = false;
}
}
public void Dispose()
{
Stop();
for (int i = 0; i < RingDepth; i++)
_gl.DeleteQuery(_queries[i]);
}
}