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); } }