feat(dispatcher): [indoor-walk] + [indoor-cull] probes

Instruments WalkVisibleEntities to identify whether cell entities (first
MeshRef.GfxObjId low-16-bits >= 0x0100) pass all visibility filters or
get culled. Three emission paths:

- [indoor-cull] reason=visibleCellIds-miss -- when the ParentCellId
  filter rejects the entity.
- [indoor-cull] reason=frustum -- when AABB frustum cull rejects.
- [indoor-walk] -- when the entity passes all filters and reaches the
  draw list.

Rate-limited to once per cellId per ~1 sec (30 frames at 30 Hz) via
IndoorProbeState, a nested class wrapping _lastIndoorProbeFrame dictionary
and _indoorProbeFrameCounter (bumped at top of Draw()). WalkEntitiesInto
accepts a new optional IndoorProbeState? parameter (null = probes off,
default) so the test-friendly WalkEntities overload is unaffected. The
ShouldEmitIndoorProbe instance helper is also retained for Task 7 use.

Disambiguates hypothesis H3 (cull bug -- cell entity dropped before draw).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-19 11:43:56 +02:00
parent 1dd20ddd40
commit 36a29ceff5

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using AcDream.Core.Meshing; using AcDream.Core.Meshing;
using AcDream.Core.Rendering;
using AcDream.Core.Terrain; using AcDream.Core.Terrain;
using AcDream.Core.World; using AcDream.Core.World;
using Chorizite.OpenGLSDLBackend.Lib; using Chorizite.OpenGLSDLBackend.Lib;
@ -140,6 +141,31 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private bool _disposed; private bool _disposed;
/// <summary>
/// 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.
/// </summary>
private readonly Dictionary<ulong, int> _lastIndoorProbeFrame = new();
private int _indoorProbeFrameCounter;
private const int IndoorProbeRateLimitFrames = 30;
/// <summary>
/// Returns true at most once per <see cref="IndoorProbeRateLimitFrames"/>
/// frames per cellId. Caller must already have checked that an indoor
/// probe flag is enabled.
/// </summary>
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. // Diagnostic counters logged once per ~5s under ACDREAM_WB_DIAG=1.
private int _entitiesSeen; private int _entitiesSeen;
private int _entitiesDrawn; private int _entitiesDrawn;
@ -271,6 +297,16 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// list. <see cref="Draw"/> reuses a per-dispatcher scratch field across frames to /// list. <see cref="Draw"/> reuses a per-dispatcher scratch field across frames to
/// avoid the 480+ KB / frame GC pressure that the test-friendly overload incurs. /// avoid the 480+ KB / frame GC pressure that the test-friendly overload incurs.
/// Returns walk count via <paramref name="result"/>'s <c>EntitiesWalked</c> field. /// Returns walk count via <paramref name="result"/>'s <c>EntitiesWalked</c> field.
///
/// <para>
/// When <paramref name="indoorProbeState"/> is non-null the method emits
/// <c>[indoor-cull]</c> lines for cell entities rejected by the
/// visibleCellIds or frustum filters, and <c>[indoor-walk]</c> lines for
/// cell entities that pass all filters. Rate-limited by
/// <see cref="IndoorProbeState"/>. Pass <see langword="null"/> (the default)
/// to disable all probe emission — used by the test-friendly
/// <see cref="WalkEntities"/> overload.
/// </para>
/// </summary> /// </summary>
internal static void WalkEntitiesInto( internal static void WalkEntitiesInto(
IEnumerable<LandblockEntry> landblockEntries, IEnumerable<LandblockEntry> landblockEntries,
@ -279,7 +315,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
HashSet<uint>? visibleCellIds, HashSet<uint>? visibleCellIds,
HashSet<uint>? animatedEntityIds, HashSet<uint>? animatedEntityIds,
List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch, List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch,
ref WalkResult result) ref WalkResult result,
IndoorProbeState? indoorProbeState = null)
{ {
scratch.Clear(); scratch.Clear();
result.EntitiesWalked = 0; result.EntitiesWalked = 0;
@ -314,19 +351,65 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
{ {
if (entity.MeshRefs.Count == 0) continue; if (entity.MeshRefs.Count == 0) continue;
if (entity.ParentCellId.HasValue && visibleCellIds is not null // Detect cell entity for indoor probes — first MeshRef.GfxObjId
&& !visibleCellIds.Contains(entity.ParentCellId.Value)) // 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; continue;
}
// Per-entity AABB frustum cull (perf #3). Animated entities bypass — // Per-entity AABB frustum cull (perf #3). Animated entities bypass —
// they're tracked at landblock level + need per-frame work regardless. // they're tracked at landblock level + need per-frame work regardless.
// A.5 T18 Change #2: read cached AABB, refresh lazily on AabbDirty. // A.5 T18 Change #2: read cached AABB, refresh lazily on AabbDirty.
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true; bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
bool aabbVisible = true;
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId) if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
{ {
if (entity.AabbDirty) entity.RefreshAabb(); if (entity.AabbDirty) entity.RefreshAabb();
if (!FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax)) aabbVisible = FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax);
continue; }
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++; result.EntitiesWalked++;
@ -347,6 +430,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
HashSet<uint>? animatedEntityIds = null) HashSet<uint>? animatedEntityIds = null)
{ {
_shader.Use(); _shader.Use();
_indoorProbeFrameCounter++;
var vp = camera.View * camera.Projection; var vp = camera.View * camera.Projection;
_shader.SetMatrix4("uViewProjection", vp); _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 // A.5 T26 follow-up (Bug B): use the no-alloc WalkEntitiesInto overload
// that populates _walkScratch (a per-dispatcher field reused across frames) // that populates _walkScratch (a per-dispatcher field reused across frames)
// instead of allocating a fresh List<(WorldEntity, int)> per frame. // 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); var walkResult = default(WalkResult);
WalkEntitiesInto( WalkEntitiesInto(
ToEntries(landblockEntries), ToEntries(landblockEntries),
@ -399,7 +501,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
visibleCellIds, visibleCellIds,
animatedEntityIds, animatedEntityIds,
_walkScratch, _walkScratch,
ref walkResult); ref walkResult,
probeState);
// Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple // Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple
// per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of // per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of
@ -1289,6 +1392,41 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// ──────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────
/// <summary>
/// Thin wrapper around an instance's rate-limit dictionary + frame
/// counter, passed into the static <see cref="WalkEntitiesInto"/>
/// overload so it can emit rate-limited probe lines without access
/// to instance fields. Null = probes disabled (test-friendly overload).
/// </summary>
internal sealed class IndoorProbeState
{
private readonly Dictionary<ulong, int> _lastFrame;
private readonly int _currentFrame;
private const int RateLimit = IndoorProbeRateLimitFrames;
internal IndoorProbeState(Dictionary<ulong, int> lastFrame, int currentFrame)
{
_lastFrame = lastFrame;
_currentFrame = currentFrame;
}
/// <summary>
/// Returns true at most once per <see cref="IndoorProbeRateLimitFrames"/>
/// frames per <paramref name="cellId"/>. Side-effect: stamps the frame
/// number into the dictionary on success.
/// </summary>
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 private sealed class InstanceGroup
{ {
public uint Ibo; public uint Ibo;