feat(A.5 T17): WbDrawDispatcher Change #1 — animated-walk fix + WalkEntities helper
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) <noreply@anthropic.com>
This commit is contained in:
parent
0de6bc9c96
commit
003443cd1a
3 changed files with 546 additions and 90 deletions
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="WbDrawDispatcher.WalkEntities"/> — 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:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item>Change #1 (T17): invisible LB + animated set → iterate
|
||||
/// <c>animatedEntityIds</c> directly, not the full entity list.</item>
|
||||
/// <item>Change #2 (T18): per-entity AABB cull reads the cached AABB
|
||||
/// (<see cref="WorldEntity.AabbMin"/>/<c>AabbMax</c>) rather than
|
||||
/// recomputing Position±5 per frame.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
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<MeshRef>(),
|
||||
};
|
||||
|
||||
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<uint, WorldEntity> BuildById(IEnumerable<WorldEntity> entities)
|
||||
{
|
||||
var d = new Dictionary<uint, WorldEntity>();
|
||||
foreach (var e in entities) d[e.Id] = e;
|
||||
return d;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<WorldEntity>();
|
||||
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<WorldEntity>(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<uint> { 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<WorldEntity>();
|
||||
for (int i = 0; i < 100; i++)
|
||||
entities.Add(MakeEntityWithMesh((uint)i, Vector3.Zero));
|
||||
|
||||
var byId = BuildById(entities);
|
||||
var animatedSet = new HashSet<uint> { 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<WorldEntity>
|
||||
{
|
||||
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<WorldEntity>
|
||||
{
|
||||
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<WorldEntity> { 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<WorldEntity> { 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<uint> { 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<WorldEntity> { 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue