From 0a5f7d4924b8f04b4a3193696074e10937ce6539 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 5 Jul 2026 19:10:28 +0200 Subject: [PATCH] feat(pipeline): MP0 - FrameProfEnabled flag + GPU TimeElapsed ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/AcDream.App/Diagnostics/GpuFrameTimer.cs | 87 +++++++++++++++++++ .../Rendering/RenderingDiagnostics.cs | 17 ++++ 2 files changed, 104 insertions(+) create mode 100644 src/AcDream.App/Diagnostics/GpuFrameTimer.cs diff --git a/src/AcDream.App/Diagnostics/GpuFrameTimer.cs b/src/AcDream.App/Diagnostics/GpuFrameTimer.cs new file mode 100644 index 00000000..d75621e9 --- /dev/null +++ b/src/AcDream.App/Diagnostics/GpuFrameTimer.cs @@ -0,0 +1,87 @@ +using System; +using Silk.NET.OpenGL; + +namespace AcDream.App.Diagnostics; + +/// +/// MP0 (2026-07-05) — whole-frame GPU time via a ring of +/// 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. +/// +/// 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. +/// +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(); + } + + /// + /// 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. + /// + 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; + } + + /// End any active query without beginning a new one (used when the profiler is toggled off mid-session). + 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]); + } +} diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 872285e4..b265cb1c 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -567,4 +567,21 @@ public static class RenderingDiagnostics /// public static bool ShouldRenderIndoor(uint playerCellId, bool renderRootResolved) => renderRootResolved && IsEnvCellId(playerCellId); + + /// + /// MP0 (2026-07-05) — master toggle for the permanent frame profiler + /// (AcDream.App.Diagnostics.FrameProfiler): CPU frame time + /// (swap-to-swap), whole-frame GPU time, per-stage CPU attribution, + /// per-frame allocation counters, reported as one [frame-prof] + /// 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 ACDREAM_WB_DIAG=1 (GL forbids nested + /// TimeElapsed queries; WbDrawDispatcher owns per-pass queries under + /// that flag — the 2026-06-23 "separate flags" measurement lesson). + /// Initial state from ACDREAM_FRAME_PROF=1; runtime-toggleable + /// via the DebugPanel mirror (DebugVM.FrameProf). + /// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5. + /// + public static bool FrameProfEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_FRAME_PROF") == "1"; }