diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
index eecc1a6..fcb9e66 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
@@ -157,9 +157,113 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
Matrix4x4 restPose)
=> restPose * animOverride * entityWorld;
+ ///
+ /// Entry for per-landblock iteration.
+ /// Mirrors the shape yielded by GpuWorldState.LandblockEntries.
+ ///
+ public readonly record struct LandblockEntry(
+ uint LandblockId,
+ Vector3 AabbMin,
+ Vector3 AabbMax,
+ IReadOnlyList Entities,
+ IReadOnlyDictionary? AnimatedById);
+
+ ///
+ /// Result of — the list of (entity, meshRef index)
+ /// pairs that passed all visibility filters, plus a diagnostic walk count.
+ ///
+ public struct WalkResult
+ {
+ public int EntitiesWalked;
+ public List<(WorldEntity Entity, int MeshRefIndex)> ToDraw;
+ }
+
+ ///
+ /// Pure-CPU visibility filter over .
+ /// Separated from so tests can exercise it without GL state.
+ ///
+ ///
+ /// A.5 T17 Change #1: when an LB is frustum-culled AND
+ /// is non-empty, the OLD path walked
+ /// every entity in the LB just to find the few animated ones. This helper
+ /// fixes that: if the LB is invisible, we iterate
+ /// directly and look each up in
+ /// entry.AnimatedById (typically <50 animated, up to ~10K total).
+ ///
+ ///
+ ///
+ /// A.5 T18 Change #2: per-entity AABB cull reads from the cached
+ /// /
+ /// (refreshed lazily if ), instead of
+ /// recomputing Position±5 each frame.
+ ///
+ ///
+ internal static WalkResult WalkEntities(
+ IEnumerable landblockEntries,
+ FrustumPlanes? frustum,
+ uint? neverCullLandblockId,
+ HashSet? visibleCellIds,
+ HashSet? animatedEntityIds)
+ {
+ var result = new WalkResult { ToDraw = new List<(WorldEntity, int)>() };
+
+ foreach (var entry in landblockEntries)
+ {
+ bool landblockVisible = frustum is null
+ || entry.LandblockId == neverCullLandblockId
+ || FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
+
+ if (!landblockVisible)
+ {
+ // A.5 T17 Change #1: walk only animated entities, not all entities.
+ // Avoids O(N_entities) scan when only O(N_animated) work is needed.
+ if (animatedEntityIds is null || animatedEntityIds.Count == 0) continue;
+ if (entry.AnimatedById is null) continue;
+ foreach (var animatedId in animatedEntityIds)
+ {
+ if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue;
+ if (entity.MeshRefs.Count == 0) continue;
+ if (entity.ParentCellId.HasValue && visibleCellIds is not null
+ && !visibleCellIds.Contains(entity.ParentCellId.Value)) continue;
+ result.EntitiesWalked++;
+ for (int i = 0; i < entity.MeshRefs.Count; i++)
+ result.ToDraw.Add((entity, i));
+ }
+ continue;
+ }
+
+ foreach (var entity in entry.Entities)
+ {
+ if (entity.MeshRefs.Count == 0) continue;
+
+ if (entity.ParentCellId.HasValue && visibleCellIds is not null
+ && !visibleCellIds.Contains(entity.ParentCellId.Value))
+ 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;
+ if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
+ {
+ if (entity.AabbDirty) entity.RefreshAabb();
+ if (!FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax))
+ continue;
+ }
+
+ result.EntitiesWalked++;
+ for (int i = 0; i < entity.MeshRefs.Count; i++)
+ result.ToDraw.Add((entity, i));
+ }
+ }
+ return result;
+ }
+
public void Draw(
ICamera camera,
- IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities)> landblockEntries,
+ IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
+ IReadOnlyList Entities,
+ IReadOnlyDictionary? AnimatedById)> landblockEntries,
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null,
HashSet? visibleCellIds = null,
@@ -194,97 +298,79 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
var metaTable = _meshAdapter.MetadataTable;
uint anyVao = 0;
- foreach (var entry in landblockEntries)
+ // Project the 5-tuple enumerable into LandblockEntry records for WalkEntities.
+ static IEnumerable ToEntries(
+ IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
+ IReadOnlyList Entities,
+ IReadOnlyDictionary? AnimatedById)> src)
{
- bool landblockVisible = frustum is null
- || entry.LandblockId == neverCullLandblockId
- || FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
+ foreach (var e in src)
+ yield return new LandblockEntry(e.LandblockId, e.AabbMin, e.AabbMax, e.Entities, e.AnimatedById);
+ }
- if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0))
- continue;
+ var walkResult = WalkEntities(
+ ToEntries(landblockEntries),
+ frustum,
+ neverCullLandblockId,
+ visibleCellIds,
+ animatedEntityIds);
- foreach (var entity in entry.Entities)
+ foreach (var (entity, partIdx) in walkResult.ToDraw)
+ {
+ if (diag) _entitiesSeen++;
+
+ var entityWorld =
+ Matrix4x4.CreateFromQuaternion(entity.Rotation) *
+ Matrix4x4.CreateTranslation(entity.Position);
+
+ // Compute palette-override hash ONCE per entity (perf #4).
+ // Reused across every (part, batch) lookup so the FNV-1a fold
+ // over SubPalettes runs once instead of N times. Zero when the
+ // entity has no palette override (trees, scenery).
+ ulong palHash = 0;
+ if (entity.PaletteOverride is not null)
+ palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride);
+
+ // Note: GameWindow's spawn path already applies
+ // AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix —
+ // close-detail mesh swap for humanoids) to MeshRefs. We
+ // trust MeshRefs as the source of truth here. AnimatedEntityState's
+ // overrides become relevant only for hot-swap (0xF625
+ // ObjDescEvent) which today rebuilds MeshRefs anyway.
+ var meshRef = entity.MeshRefs[partIdx];
+ ulong gfxObjId = meshRef.GfxObjId;
+
+ var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
+ if (renderData is null)
{
- if (entity.MeshRefs.Count == 0) continue;
-
- bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
- if (!landblockVisible && !isAnimated) continue;
-
- if (entity.ParentCellId.HasValue && visibleCellIds is not null
- && !visibleCellIds.Contains(entity.ParentCellId.Value))
- continue;
-
- // Per-entity AABB frustum cull (perf #3). Skips work for distant
- // entities even when their landblock is visible. Animated
- // entities bypass — they're tracked at landblock level + need
- // per-frame work for animation regardless. Conservative 5m
- // radius covers typical entity bounds.
- if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
- {
- var p = entity.Position;
- var aMin = new Vector3(p.X - PerEntityCullRadius, p.Y - PerEntityCullRadius, p.Z - PerEntityCullRadius);
- var aMax = new Vector3(p.X + PerEntityCullRadius, p.Y + PerEntityCullRadius, p.Z + PerEntityCullRadius);
- if (!FrustumCuller.IsAabbVisible(frustum.Value, aMin, aMax))
- continue;
- }
-
- if (diag) _entitiesSeen++;
-
- var entityWorld =
- Matrix4x4.CreateFromQuaternion(entity.Rotation) *
- Matrix4x4.CreateTranslation(entity.Position);
-
- // Compute palette-override hash ONCE per entity (perf #4).
- // Reused across every (part, batch) lookup so the FNV-1a fold
- // over SubPalettes runs once instead of N times. Zero when the
- // entity has no palette override (trees, scenery).
- ulong palHash = 0;
- if (entity.PaletteOverride is not null)
- palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride);
-
- bool drewAny = false;
- for (int partIdx = 0; partIdx < entity.MeshRefs.Count; partIdx++)
- {
- // Note: GameWindow's spawn path already applies
- // AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix —
- // close-detail mesh swap for humanoids) to MeshRefs. We
- // trust MeshRefs as the source of truth here. AnimatedEntityState's
- // overrides become relevant only for hot-swap (0xF625
- // ObjDescEvent) which today rebuilds MeshRefs anyway.
- var meshRef = entity.MeshRefs[partIdx];
- ulong gfxObjId = meshRef.GfxObjId;
-
- var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
- if (renderData is null)
- {
- if (diag) _meshesMissing++;
- continue;
- }
- drewAny = true;
- if (anyVao == 0) anyVao = renderData.VAO;
-
- if (renderData.IsSetup && renderData.SetupParts.Count > 0)
- {
- foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
- {
- var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
- if (partData is null) continue;
-
- var model = ComposePartWorldMatrix(
- entityWorld, meshRef.PartTransform, partTransform);
-
- ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable);
- }
- }
- else
- {
- var model = meshRef.PartTransform * entityWorld;
- ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable);
- }
- }
-
- if (diag && drewAny) _entitiesDrawn++;
+ if (diag) _meshesMissing++;
+ continue;
}
+ if (anyVao == 0) anyVao = renderData.VAO;
+
+ bool drewAny = false;
+ if (renderData.IsSetup && renderData.SetupParts.Count > 0)
+ {
+ foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
+ {
+ var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
+ if (partData is null) continue;
+
+ var model = ComposePartWorldMatrix(
+ entityWorld, meshRef.PartTransform, partTransform);
+
+ ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable);
+ drewAny = true;
+ }
+ }
+ else
+ {
+ var model = meshRef.PartTransform * entityWorld;
+ ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable);
+ drewAny = true;
+ }
+
+ if (diag && drewAny) _entitiesDrawn++;
}
// Nothing visible — skip the GL pass entirely.
diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs
index 9024047..b0ad321 100644
--- a/src/AcDream.App/Streaming/GpuWorldState.cs
+++ b/src/AcDream.App/Streaming/GpuWorldState.cs
@@ -106,17 +106,33 @@ public sealed class GpuWorldState
/// Per-landblock iteration with AABB data for use by the frustum-culling
/// draw path. Landblocks without a stored AABB yield
/// for both corners, which the culler will conservatively treat as visible.
+ ///
+ ///
+ /// A.5 T17: also yields an AnimatedById dictionary built on the fly
+ /// from the landblock's entity list. This lets
+ /// skip the full entity walk when the landblock is frustum-culled but animated
+ /// entities inside it must still be processed (Change #1).
+ /// Building the dict per-yield is cheap (~132 entities/LB max). A caching
+ /// layer is out of A.5 scope.
+ ///
///
- public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities)> LandblockEntries
+ public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
+ IReadOnlyList Entities,
+ IReadOnlyDictionary? AnimatedById)> LandblockEntries
{
get
{
foreach (var kvp in _loaded)
{
+ // Build AnimatedById on the fly — cheap (~132 entities/LB max).
+ var byId = new Dictionary(kvp.Value.Entities.Count);
+ foreach (var e in kvp.Value.Entities)
+ byId[e.Id] = e;
+
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
- yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities);
+ yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities, byId);
else
- yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities);
+ yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities, byId);
}
}
}
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs
new file mode 100644
index 0000000..051dcf2
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs
@@ -0,0 +1,354 @@
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.Meshing;
+using AcDream.Core.World;
+using Xunit;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+///
+/// Tests for — the pure-CPU
+/// visibility filter extracted in A.5 T17. These tests exercise the two
+/// key perf changes from Phase A.5 spec §4.6:
+///
+///
+/// - Change #1 (T17): invisible LB + animated set → iterate
+/// animatedEntityIds directly, not the full entity list.
+/// - Change #2 (T18): per-entity AABB cull reads the cached AABB
+/// (/AabbMax) rather than
+/// recomputing Position±5 per frame.
+///
+///
+public sealed class WbDrawDispatcherBucketingTests
+{
+ // ── helpers ──────────────────────────────────────────────────────────────
+
+ private static WorldEntity MakeEntity(uint id, Vector3 position)
+ => new WorldEntity
+ {
+ Id = id,
+ SourceGfxObjOrSetupId = 0,
+ Position = position,
+ Rotation = Quaternion.Identity,
+ MeshRefs = System.Array.Empty(),
+ };
+
+ private static WorldEntity MakeEntityWithMesh(uint id, Vector3 position)
+ => new WorldEntity
+ {
+ Id = id,
+ SourceGfxObjOrSetupId = 0,
+ Position = position,
+ Rotation = Quaternion.Identity,
+ // Single dummy MeshRef so it passes the MeshRefs.Count == 0 guard.
+ MeshRefs = new[] { new MeshRef { GfxObjId = 0x01000001u } },
+ };
+
+ private static Dictionary BuildById(IEnumerable entities)
+ {
+ var d = new Dictionary();
+ foreach (var e in entities) d[e.Id] = e;
+ return d;
+ }
+
+ ///
+ /// A frustum positioned at (1e6+1, 1e6+1, 1e6+1) looking toward (1e6, 1e6, 1e6)
+ /// with a very narrow near/far. Any AABB near the origin (0..20000) is
+ /// far behind the near plane and fails all six planes.
+ ///
+ private static FrustumPlanes MakeFarAwayFrustum()
+ {
+ var view = Matrix4x4.CreateLookAt(
+ new Vector3(1e6f + 1f, 1e6f + 1f, 1e6f + 1f),
+ new Vector3(1e6f, 1e6f, 1e6f),
+ Vector3.UnitZ);
+ var proj = Matrix4x4.CreatePerspectiveFieldOfView(
+ MathF.PI / 4f, 1f, 0.1f, 1f);
+ return FrustumPlanes.FromViewProjection(view * proj);
+ }
+
+ // ── T17 Change #1 tests ───────────────────────────────────────────────
+
+ [Fact]
+ public void WalkEntities_InvisibleLb_NoAnimated_SkipsEntireBlock()
+ {
+ // When LB is invisible AND animatedEntityIds is empty/null,
+ // WalkEntities should not walk any entities at all.
+ var entities = new List();
+ for (int i = 0; i < 500; i++)
+ entities.Add(MakeEntityWithMesh((uint)i, new Vector3(i, 0, 0)));
+
+ var byId = BuildById(entities);
+ var entries = new[]
+ {
+ new WbDrawDispatcher.LandblockEntry(
+ 0xAAAA_FFFFu,
+ new Vector3(10000, 10000, 10000),
+ new Vector3(20000, 20000, 20000),
+ entities,
+ byId),
+ };
+
+ var result = WbDrawDispatcher.WalkEntities(
+ entries,
+ frustum: MakeFarAwayFrustum(),
+ neverCullLandblockId: null,
+ visibleCellIds: null,
+ animatedEntityIds: null);
+
+ Assert.Equal(0, result.EntitiesWalked);
+ Assert.Empty(result.ToDraw);
+ }
+
+ [Fact]
+ public void WalkEntities_InvisibleLb_AnimatedSet_WalksOnlyAnimatedEntities()
+ {
+ // 1000 entities in an LB whose AABB is far outside the frustum.
+ // Only entity Id=42 is in animatedEntityIds.
+ // Pre-T17 behavior: walk all 1000 entities just to find #42.
+ // Post-T17: walk only the 1 animated entity (EntitiesWalked == 1).
+ const int Total = 1000;
+ var entities = new List(Total);
+ for (int i = 0; i < Total; i++)
+ entities.Add(MakeEntityWithMesh((uint)i, new Vector3(i, 0, 0)));
+
+ var byId = BuildById(entities);
+ var animatedSet = new HashSet { 42 };
+
+ var entries = new[]
+ {
+ new WbDrawDispatcher.LandblockEntry(
+ 0xAAAA_FFFFu,
+ new Vector3(10000, 10000, 10000),
+ new Vector3(20000, 20000, 20000),
+ entities,
+ byId),
+ };
+
+ var result = WbDrawDispatcher.WalkEntities(
+ entries,
+ frustum: MakeFarAwayFrustum(),
+ neverCullLandblockId: null,
+ visibleCellIds: null,
+ animatedEntityIds: animatedSet);
+
+ // Only the 1 animated entity should be walked — not 1000.
+ Assert.Equal(1, result.EntitiesWalked);
+ Assert.Single(result.ToDraw);
+ Assert.Equal(42u, result.ToDraw[0].Entity.Id);
+ }
+
+ [Fact]
+ public void WalkEntities_InvisibleLb_AnimatedIdAbsent_ZeroWalked()
+ {
+ // Animated entity ids 200 and 300 are NOT in this LB (which only
+ // has ids 0..99). Should produce zero walks.
+ var entities = new List();
+ for (int i = 0; i < 100; i++)
+ entities.Add(MakeEntityWithMesh((uint)i, Vector3.Zero));
+
+ var byId = BuildById(entities);
+ var animatedSet = new HashSet { 200, 300 }; // not in this LB
+
+ var entries = new[]
+ {
+ new WbDrawDispatcher.LandblockEntry(
+ 0xBBBB_FFFFu,
+ new Vector3(10000, 10000, 10000),
+ new Vector3(20000, 20000, 20000),
+ entities,
+ byId),
+ };
+
+ var result = WbDrawDispatcher.WalkEntities(
+ entries,
+ frustum: MakeFarAwayFrustum(),
+ neverCullLandblockId: null,
+ visibleCellIds: null,
+ animatedEntityIds: animatedSet);
+
+ Assert.Equal(0, result.EntitiesWalked);
+ Assert.Empty(result.ToDraw);
+ }
+
+ [Fact]
+ public void WalkEntities_NeverCullLb_WalksAllEntitiesRegardlessOfFrustum()
+ {
+ // neverCullLandblockId bypasses the LB AABB check entirely.
+ // All entities with at least one MeshRef should be walked.
+ var entities = new List
+ {
+ MakeEntityWithMesh(1, Vector3.Zero),
+ MakeEntityWithMesh(2, Vector3.Zero),
+ MakeEntityWithMesh(3, Vector3.Zero),
+ };
+
+ var byId = BuildById(entities);
+ const uint lbId = 0xCCCC_FFFFu;
+
+ var entries = new[]
+ {
+ new WbDrawDispatcher.LandblockEntry(
+ lbId,
+ new Vector3(10000, 10000, 10000), // AABB would fail frustum
+ new Vector3(20000, 20000, 20000),
+ entities,
+ byId),
+ };
+
+ var result = WbDrawDispatcher.WalkEntities(
+ entries,
+ frustum: MakeFarAwayFrustum(),
+ neverCullLandblockId: lbId, // exempt from LB cull
+ visibleCellIds: null,
+ animatedEntityIds: null);
+
+ Assert.Equal(3, result.EntitiesWalked);
+ }
+
+ [Fact]
+ public void WalkEntities_NullFrustum_WalksEntitiesWithMeshRefs()
+ {
+ // Null frustum means no culling — all entities with MeshRefs pass.
+ // Entities without MeshRefs are still filtered out.
+ var entities = new List
+ {
+ MakeEntityWithMesh(1, Vector3.Zero),
+ MakeEntity(2, Vector3.Zero), // no MeshRefs — must be skipped
+ MakeEntityWithMesh(3, Vector3.Zero),
+ };
+
+ var byId = BuildById(entities);
+ var entries = new[]
+ {
+ new WbDrawDispatcher.LandblockEntry(
+ 0xDDDD_FFFFu, Vector3.Zero, Vector3.Zero,
+ entities, byId),
+ };
+
+ var result = WbDrawDispatcher.WalkEntities(
+ entries,
+ frustum: null,
+ neverCullLandblockId: null,
+ visibleCellIds: null,
+ animatedEntityIds: null);
+
+ Assert.Equal(2, result.EntitiesWalked);
+ Assert.Equal(2, result.ToDraw.Count);
+ }
+
+ // ── T18 Change #2 tests ───────────────────────────────────────────────
+
+ [Fact]
+ public void WalkEntities_VisibleLb_EntityFarAway_CulledViaCachedAabb()
+ {
+ // LB passes the LB-level cull; entity AABB is far from the frustum.
+ // After RefreshAabb the entity should be culled by the per-entity check.
+ var entity = MakeEntityWithMesh(1, new Vector3(50000, 50000, 50000));
+ entity.RefreshAabb(); // populate cached AABB at (50000±5)
+
+ var byId = BuildById(new[] { entity });
+ var entries = new[]
+ {
+ // LB AABB near origin so it passes the LB cull; entity is far away.
+ new WbDrawDispatcher.LandblockEntry(
+ 0xEEEE_FFFFu,
+ new Vector3(-10, -10, -10),
+ new Vector3(10, 10, 10),
+ new List { entity },
+ byId),
+ };
+
+ // Frustum centered at origin, range ±100.
+ var view = Matrix4x4.CreateLookAt(new Vector3(-50, 0, 0), Vector3.Zero, Vector3.UnitZ);
+ var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 2f, 1f, 0.5f, 100f);
+ var tightFrustum = FrustumPlanes.FromViewProjection(view * proj);
+
+ var result = WbDrawDispatcher.WalkEntities(
+ entries,
+ frustum: tightFrustum,
+ neverCullLandblockId: null,
+ visibleCellIds: null,
+ animatedEntityIds: null);
+
+ // Entity at (50000,50000,50000) is outside the frustum — should be culled.
+ Assert.Equal(0, result.EntitiesWalked);
+ }
+
+ [Fact]
+ public void WalkEntities_AnimatedEntity_BypassesPerEntityAabbCull()
+ {
+ // Animated entities must always pass even if their AABB would be culled.
+ var entity = MakeEntityWithMesh(7, new Vector3(50000, 50000, 50000));
+ entity.RefreshAabb();
+
+ var byId = BuildById(new[] { entity });
+ var entries = new[]
+ {
+ new WbDrawDispatcher.LandblockEntry(
+ 0xEEEF_FFFFu,
+ new Vector3(-10, -10, -10),
+ new Vector3(10, 10, 10),
+ new List { entity },
+ byId),
+ };
+
+ var view = Matrix4x4.CreateLookAt(new Vector3(-50, 0, 0), Vector3.Zero, Vector3.UnitZ);
+ var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 2f, 1f, 0.5f, 100f);
+ var tightFrustum = FrustumPlanes.FromViewProjection(view * proj);
+
+ var animatedSet = new HashSet { 7 };
+
+ var result = WbDrawDispatcher.WalkEntities(
+ entries,
+ frustum: tightFrustum,
+ neverCullLandblockId: null,
+ visibleCellIds: null,
+ animatedEntityIds: animatedSet);
+
+ // Animated entity bypasses per-entity cull.
+ Assert.Equal(1, result.EntitiesWalked);
+ Assert.Single(result.ToDraw);
+ Assert.Equal(7u, result.ToDraw[0].Entity.Id);
+ }
+
+ [Fact]
+ public void WalkEntities_AabbDirty_RefreshedLazilyBeforeCull()
+ {
+ // An entity with AabbDirty=true (initial state) should get its AABB
+ // refreshed lazily by WalkEntities before the cull check.
+ var entity = MakeEntityWithMesh(5, new Vector3(0, 0, 0));
+ // AabbDirty starts true by default — do NOT call RefreshAabb manually.
+ Assert.True(entity.AabbDirty);
+
+ var byId = BuildById(new[] { entity });
+ var entries = new[]
+ {
+ new WbDrawDispatcher.LandblockEntry(
+ 0xF0F0_FFFFu,
+ new Vector3(-10, -10, -10),
+ new Vector3(10, 10, 10),
+ new List { entity },
+ byId),
+ };
+
+ // A frustum that accepts things near origin.
+ var view = Matrix4x4.CreateLookAt(new Vector3(-50, 0, 0), Vector3.Zero, Vector3.UnitZ);
+ var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 2f, 1f, 0.1f, 200f);
+ var nearOriginFrustum = FrustumPlanes.FromViewProjection(view * proj);
+
+ var result = WbDrawDispatcher.WalkEntities(
+ entries,
+ frustum: nearOriginFrustum,
+ neverCullLandblockId: null,
+ visibleCellIds: null,
+ animatedEntityIds: null);
+
+ // Entity at origin is inside the frustum after lazy RefreshAabb.
+ Assert.Equal(1, result.EntitiesWalked);
+ // AabbDirty should have been cleared by the lazy refresh.
+ Assert.False(entity.AabbDirty);
+ }
+}