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]);
}
}

View file

@ -567,4 +567,21 @@ public static class RenderingDiagnostics
/// </summary>
public static bool ShouldRenderIndoor(uint playerCellId, bool renderRootResolved)
=> renderRootResolved && IsEnvCellId(playerCellId);
/// <summary>
/// MP0 (2026-07-05) — master toggle for the permanent frame profiler
/// (<c>AcDream.App.Diagnostics.FrameProfiler</c>): CPU frame time
/// (swap-to-swap), whole-frame GPU time, per-stage CPU attribution,
/// per-frame allocation counters, reported as one <c>[frame-prof]</c>
/// line every ~5 s. Permanent apparatus (every MP-track gate reads it) —
/// do NOT strip with session probes. The whole-frame GPU query is
/// self-disabled while <c>ACDREAM_WB_DIAG=1</c> (GL forbids nested
/// TimeElapsed queries; WbDrawDispatcher owns per-pass queries under
/// that flag — the 2026-06-23 "separate flags" measurement lesson).
/// Initial state from <c>ACDREAM_FRAME_PROF=1</c>; runtime-toggleable
/// via the DebugPanel mirror (<c>DebugVM.FrameProf</c>).
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
/// </summary>
public static bool FrameProfEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_FRAME_PROF") == "1";
}