diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 36ebdc9..5af05ed 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Numerics; using System.Runtime.InteropServices; using AcDream.Core.Meshing; +using AcDream.Core.Rendering; using AcDream.Core.Terrain; using AcDream.Core.World; using Chorizite.OpenGLSDLBackend.Lib; @@ -140,6 +141,31 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private bool _disposed; + /// + /// Per-cell-entity last-log frame number for rate-limiting the + /// [indoor-walk] / [indoor-lookup] / [indoor-xform] / [indoor-cull] + /// probes. Defaults to 30 frames at 30Hz = 1 sec. + /// + private readonly Dictionary _lastIndoorProbeFrame = new(); + private int _indoorProbeFrameCounter; + private const int IndoorProbeRateLimitFrames = 30; + + /// + /// Returns true at most once per + /// frames per cellId. Caller must already have checked that an indoor + /// probe flag is enabled. + /// + private bool ShouldEmitIndoorProbe(ulong cellId) + { + if (!_lastIndoorProbeFrame.TryGetValue(cellId, out int last) + || _indoorProbeFrameCounter - last >= IndoorProbeRateLimitFrames) + { + _lastIndoorProbeFrame[cellId] = _indoorProbeFrameCounter; + return true; + } + return false; + } + // Diagnostic counters logged once per ~5s under ACDREAM_WB_DIAG=1. private int _entitiesSeen; private int _entitiesDrawn; @@ -271,6 +297,16 @@ public sealed unsafe class WbDrawDispatcher : IDisposable /// list. reuses a per-dispatcher scratch field across frames to /// avoid the 480+ KB / frame GC pressure that the test-friendly overload incurs. /// Returns walk count via 's EntitiesWalked field. + /// + /// + /// When is non-null the method emits + /// [indoor-cull] lines for cell entities rejected by the + /// visibleCellIds or frustum filters, and [indoor-walk] lines for + /// cell entities that pass all filters. Rate-limited by + /// . Pass (the default) + /// to disable all probe emission — used by the test-friendly + /// overload. + /// /// internal static void WalkEntitiesInto( IEnumerable landblockEntries, @@ -279,7 +315,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable HashSet? visibleCellIds, HashSet? animatedEntityIds, List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch, - ref WalkResult result) + ref WalkResult result, + IndoorProbeState? indoorProbeState = null) { scratch.Clear(); result.EntitiesWalked = 0; @@ -314,19 +351,65 @@ public sealed unsafe class WbDrawDispatcher : IDisposable { if (entity.MeshRefs.Count == 0) continue; - if (entity.ParentCellId.HasValue && visibleCellIds is not null - && !visibleCellIds.Contains(entity.ParentCellId.Value)) + // Detect cell entity for indoor probes — first MeshRef.GfxObjId + // is an EnvCell id (low 16 bits ≥ 0x0100). Cheap to compute; + // result reused for all probe checks below. + ulong cellProbeId = (ulong)entity.MeshRefs[0].GfxObjId; + bool isCellEntity = indoorProbeState is not null + && RenderingDiagnostics.IsEnvCellId(cellProbeId); + + bool cellInVis = !(entity.ParentCellId.HasValue + && visibleCellIds is not null + && !visibleCellIds.Contains(entity.ParentCellId.Value)); + if (!cellInVis) + { + if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled + && indoorProbeState!.ShouldEmit(cellProbeId)) + { + Console.WriteLine( + $"[indoor-cull] cellEnt=0x{entity.Id:X8} " + + $"reason=visibleCellIds-miss " + + $"parentCell=0x{entity.ParentCellId!.Value:X8}"); + } continue; + } // Per-entity AABB frustum cull (perf #3). Animated entities bypass — // they're tracked at landblock level + need per-frame work regardless. // A.5 T18 Change #2: read cached AABB, refresh lazily on AabbDirty. bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true; + bool aabbVisible = true; if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId) { if (entity.AabbDirty) entity.RefreshAabb(); - if (!FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax)) - continue; + aabbVisible = FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax); + } + + if (!aabbVisible) + { + if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled + && indoorProbeState!.ShouldEmit(cellProbeId)) + { + Console.WriteLine( + $"[indoor-cull] cellEnt=0x{entity.Id:X8} " + + $"reason=frustum " + + $"aabbMin=({entity.AabbMin.X:F1},{entity.AabbMin.Y:F1},{entity.AabbMin.Z:F1}) " + + $"aabbMax=({entity.AabbMax.X:F1},{entity.AabbMax.Y:F1},{entity.AabbMax.Z:F1})"); + } + continue; + } + + // Passed all filters — emit walk probe. + if (isCellEntity && RenderingDiagnostics.ProbeIndoorWalkEnabled + && indoorProbeState!.ShouldEmit(cellProbeId)) + { + Console.WriteLine( + $"[indoor-walk] cellEnt=0x{entity.Id:X8} " + + $"pos=({entity.Position.X:F1},{entity.Position.Y:F1},{entity.Position.Z:F1}) " + + $"parentCell=0x{(entity.ParentCellId ?? 0u):X8} " + + $"meshRef0=0x{cellProbeId:X8} " + + $"meshRefCount={entity.MeshRefs.Count} " + + $"landblockVisible=true aabbVisible=true cellInVis=true"); } result.EntitiesWalked++; @@ -347,6 +430,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable HashSet? animatedEntityIds = null) { _shader.Use(); + _indoorProbeFrameCounter++; var vp = camera.View * camera.Projection; _shader.SetMatrix4("uViewProjection", vp); @@ -391,6 +475,24 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // A.5 T26 follow-up (Bug B): use the no-alloc WalkEntitiesInto overload // that populates _walkScratch (a per-dispatcher field reused across frames) // instead of allocating a fresh List<(WorldEntity, int)> per frame. + // + // Pass an IndoorProbeState when any indoor probe is active so the static + // WalkEntitiesInto can emit rate-limited [indoor-cull] / [indoor-walk] + // lines without needing access to instance fields. Null = probes off. + IndoorProbeState? probeState = null; + if (RenderingDiagnostics.ProbeIndoorCullEnabled || RenderingDiagnostics.ProbeIndoorWalkEnabled) + { + // _currentFrame is snapped at construction time. Construct + // once per Draw() call only — a second construction within + // the same frame would stamp the dictionary with the + // (already-advanced) counter value, suppressing the second + // pass's emissions for IndoorProbeRateLimitFrames frames. + // Today Draw() is called exactly once per frame; if a + // future refactor adds a shadow / reflection / second pass, + // this assumption needs revisiting. + probeState = new IndoorProbeState(_lastIndoorProbeFrame, _indoorProbeFrameCounter); + } + var walkResult = default(WalkResult); WalkEntitiesInto( ToEntries(landblockEntries), @@ -399,7 +501,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable visibleCellIds, animatedEntityIds, _walkScratch, - ref walkResult); + ref walkResult, + probeState); // Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple // per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of @@ -1289,6 +1392,41 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // ──────────────────────────────────────────────────────────────────────── + /// + /// Thin wrapper around an instance's rate-limit dictionary + frame + /// counter, passed into the static + /// overload so it can emit rate-limited probe lines without access + /// to instance fields. Null = probes disabled (test-friendly overload). + /// + internal sealed class IndoorProbeState + { + private readonly Dictionary _lastFrame; + private readonly int _currentFrame; + private const int RateLimit = IndoorProbeRateLimitFrames; + + internal IndoorProbeState(Dictionary lastFrame, int currentFrame) + { + _lastFrame = lastFrame; + _currentFrame = currentFrame; + } + + /// + /// Returns true at most once per + /// frames per . Side-effect: stamps the frame + /// number into the dictionary on success. + /// + internal bool ShouldEmit(ulong cellId) + { + if (!_lastFrame.TryGetValue(cellId, out int last) + || _currentFrame - last >= RateLimit) + { + _lastFrame[cellId] = _currentFrame; + return true; + } + return false; + } + } + private sealed class InstanceGroup { public uint Ibo;