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;