From 003443cd1aa0fd4a19408828fbb1d3577c9524f9 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 08:18:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(A.5=20T17):=20WbDrawDispatcher=20Change=20?= =?UTF-8?q?#1=20=E2=80=94=20animated-walk=20fix=20+=20WalkEntities=20helpe?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Phase A.5 spec §4.6 Change #1: when an LB is invisible AND animatedEntityIds is non-empty, the inner loop walked every entity in the LB just to find the few animated ones. At ~10.7K entities (N1=4) that is wasted iteration cost per frame. Extracted a pure-CPU internal static WalkEntities helper. When LB is invisible: iterate animatedEntityIds directly and look each up in a per-LB AnimatedById dictionary (typically <50 animated vs ~10K total). When LB is visible: walk all entities as before. GpuWorldState.LandblockEntries now yields an AnimatedById map as a 5th tuple field alongside the AABB tuple. Dictionary is built on each yield (cheap — ~132 entities/LB max). A caching layer is out of A.5 scope. WbDrawDispatcher.Draw signature updated to consume the 5-tuple. GameWindow.cs call site passes _worldState.LandblockEntries which now yields the 5-tuple — no change needed there. 8 new tests in WbDrawDispatcherBucketingTests cover T17 Change #1 (invisible LB / animated set / neverCull / null frustum) and T18 Change #2 guard tests (cached AABB / dirty flag / animated bypass). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rendering/Wb/WbDrawDispatcher.cs | 260 ++++++++----- src/AcDream.App/Streaming/GpuWorldState.cs | 22 +- .../Wb/WbDrawDispatcherBucketingTests.cs | 354 ++++++++++++++++++ 3 files changed, 546 insertions(+), 90 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs 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); + } +}