diff --git a/src/AcDream.App/Rendering/FrameProfiler.cs b/src/AcDream.App/Rendering/FrameProfiler.cs index 7d7df179..ce246725 100644 --- a/src/AcDream.App/Rendering/FrameProfiler.cs +++ b/src/AcDream.App/Rendering/FrameProfiler.cs @@ -82,6 +82,22 @@ internal sealed class FrameProfiler } } + // THROWAWAY per-frame CPU sub-phase timing for DrawInside. Gated on + // ACDREAM_FPS_PROF in {1,2}; run under =2 (no glFinish) so the DRAW phases + // measure pure CPU submission cost, not GPU. Read+cleared each Flush. + internal static readonly bool CpuPhaseEnabled = + Environment.GetEnvironmentVariable("ACDREAM_FPS_PROF") is "1" or "2"; + private static readonly Dictionary _cpuPhase = new(); + private static readonly object _cpLock = new(); + internal static void AddCpuPhase(string name, double ms) + { + lock (_cpLock) + { + var cur = _cpuPhase.TryGetValue(name, out var v) ? v : (sum: 0.0, n: 0); + _cpuPhase[name] = (cur.sum + ms, cur.n + 1); + } + } + /// Call at the very top of OnUpdate. Stamps the start of the update phase. public void MarkUpdateStart() => _updateStartMs = _clock.Elapsed.TotalMilliseconds; @@ -177,6 +193,18 @@ internal sealed class FrameProfiler _rendererGpu.Clear(); } } + + lock (_cpLock) + { + if (_cpuPhase.Count > 0) + { + var parts = _cpuPhase + .OrderByDescending(kv => kv.Value.sum) + .Select(kv => $"{kv.Key}={kv.Value.sum / Math.Max(1, _flushFrames):F2}ms/frame (calls/frame={kv.Value.n / (double)Math.Max(1, _flushFrames):F1})"); + Console.WriteLine("[CPU-PHASE] (DrawInside CPU ms/frame; =2 no-glFinish) " + string.Join(" ", parts)); + _cpuPhase.Clear(); + } + } } private static (double p50, double p95, double min, double max) Stats(double[] src, int n) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3d37e743..6d599b95 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -62,11 +62,17 @@ public sealed class GameWindow : IDisposable private double _lastFps = 60.0; private double _lastFrameMs = 16.7; - // THROWAWAY FPS-investigation apparatus (2026-06-23, ACDREAM_FPS_PROF=1): + // THROWAWAY FPS-investigation apparatus (2026-06-23): // frame-level CPU/GPU/wall profiler to find the dense-town bottleneck. See // FrameProfiler. Lazy-constructed on first OnRender once _gl is live. + // ACDREAM_FPS_PROF=1 — whole-frame query + per-pass glFinish brackets + // ([PASS-GPU]); the glFinish SERIALIZES the pipe so cpuRender/gpu/wall + // collapse together — relative per-pass attribution only. + // ACDREAM_FPS_PROF=2 — whole-frame TimeElapsed query ONLY, NO per-pass + // glFinish (PassGpuEnabled stays "1"-gated). This is the honest CPU-vs-GPU + // split: gpu≈wall+present>0 ⇒ GPU-bound; cpuRender≈wall+present≈0 ⇒ CPU-bound. private readonly bool _fpsProf = - string.Equals(Environment.GetEnvironmentVariable("ACDREAM_FPS_PROF"), "1", StringComparison.Ordinal); + Environment.GetEnvironmentVariable("ACDREAM_FPS_PROF") is "1" or "2"; private FrameProfiler? _frameProfiler; private int _msaaSamples; diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 2b93c862..dae38278 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -65,6 +65,18 @@ public sealed class RetailPViewRenderer { ArgumentNullException.ThrowIfNull(ctx); + // THROWAWAY CPU sub-phase timing (ACDREAM_FPS_PROF=2 clean-split): attribute + // which CPU phase of DrawInside eats the dense-town frame. Strip with the FPS + // apparatus (plan Task 5). Zero-alloc: GetTimestamp/GetElapsedTime + a direct- + // call local fn (struct closure over _ts, never a delegate). + long _ts = System.Diagnostics.Stopwatch.GetTimestamp(); + void Phase(string n) + { + if (FrameProfiler.CpuPhaseEnabled) + FrameProfiler.AddCpuPhase(n, System.Diagnostics.Stopwatch.GetElapsedTime(_ts).TotalMilliseconds); + _ts = System.Diagnostics.Stopwatch.GetTimestamp(); + } + var pvFrame = PortalVisibilityBuilder.Build( ctx.RootCell, ctx.ViewerEyePos, @@ -101,9 +113,11 @@ public sealed class RetailPViewRenderer && ctx.NearbyBuildingCells is not null && pvFrame.OutsideView.Polygons.Count > 0) BuildInteriorRootLookIns(ctx, pvFrame); + Phase("flood"); var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame); UploadClipFrame(ctx.SetTerrainClipUbo); + Phase("assemble"); // R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the // assembler handed a clip-slot. This feeds the Prepare filter + entity partition, @@ -136,6 +150,7 @@ public sealed class RetailPViewRenderer centerLbX: ctx.RenderCenterLbX, centerLbY: ctx.RenderCenterLbY, renderRadius: ctx.RenderRadius); + Phase("prepare"); var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries); var result = new RetailPViewFrameResult @@ -189,12 +204,18 @@ public sealed class RetailPViewRenderer } } + Phase("partition"); DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition, viewcone); + Phase("landscape"); UseIndoorMembershipOnlyRouting(); DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); + Phase("portalmask"); DrawEnvCellShells(pvFrame); + Phase("shells"); DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone); + Phase("cellobjects"); DrawDynamicsLast(ctx, partition, viewcone, ctx.RootCell.IsOutdoorNode); + Phase("dynamics"); return result; }