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:
Erik 2026-05-10 08:18:02 +02:00
parent 0de6bc9c96
commit 003443cd1a
3 changed files with 546 additions and 90 deletions

View file

@ -157,9 +157,113 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
Matrix4x4 restPose) Matrix4x4 restPose)
=> restPose * animOverride * entityWorld; => restPose * animOverride * entityWorld;
/// <summary>
/// Entry for <see cref="WalkEntities"/> per-landblock iteration.
/// Mirrors the shape yielded by <c>GpuWorldState.LandblockEntries</c>.
/// </summary>
public readonly record struct LandblockEntry(
uint LandblockId,
Vector3 AabbMin,
Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById);
/// <summary>
/// Result of <see cref="WalkEntities"/> — the list of (entity, meshRef index)
/// pairs that passed all visibility filters, plus a diagnostic walk count.
/// </summary>
public struct WalkResult
{
public int EntitiesWalked;
public List<(WorldEntity Entity, int MeshRefIndex)> ToDraw;
}
/// <summary>
/// Pure-CPU visibility filter over <paramref name="landblockEntries"/>.
/// Separated from <see cref="Draw"/> so tests can exercise it without GL state.
///
/// <para>
/// A.5 T17 Change #1: when an LB is frustum-culled AND
/// <paramref name="animatedEntityIds"/> 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
/// <paramref name="animatedEntityIds"/> directly and look each up in
/// <c>entry.AnimatedById</c> (typically &lt;50 animated, up to ~10K total).
/// </para>
///
/// <para>
/// A.5 T18 Change #2: per-entity AABB cull reads from the cached
/// <see cref="WorldEntity.AabbMin"/>/<see cref="WorldEntity.AabbMax"/>
/// (refreshed lazily if <see cref="WorldEntity.AabbDirty"/>), instead of
/// recomputing Position±5 each frame.
/// </para>
/// </summary>
internal static WalkResult WalkEntities(
IEnumerable<LandblockEntry> landblockEntries,
FrustumPlanes? frustum,
uint? neverCullLandblockId,
HashSet<uint>? visibleCellIds,
HashSet<uint>? 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( public void Draw(
ICamera camera, ICamera camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries, IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries,
FrustumPlanes? frustum = null, FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null, uint? neverCullLandblockId = null,
HashSet<uint>? visibleCellIds = null, HashSet<uint>? visibleCellIds = null,
@ -194,40 +298,25 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
var metaTable = _meshAdapter.MetadataTable; var metaTable = _meshAdapter.MetadataTable;
uint anyVao = 0; uint anyVao = 0;
foreach (var entry in landblockEntries) // Project the 5-tuple enumerable into LandblockEntry records for WalkEntities.
static IEnumerable<LandblockEntry> ToEntries(
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> src)
{ {
bool landblockVisible = frustum is null foreach (var e in src)
|| entry.LandblockId == neverCullLandblockId yield return new LandblockEntry(e.LandblockId, e.AabbMin, e.AabbMax, e.Entities, e.AnimatedById);
|| FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0))
continue;
foreach (var entity in entry.Entities)
{
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;
} }
var walkResult = WalkEntities(
ToEntries(landblockEntries),
frustum,
neverCullLandblockId,
visibleCellIds,
animatedEntityIds);
foreach (var (entity, partIdx) in walkResult.ToDraw)
{
if (diag) _entitiesSeen++; if (diag) _entitiesSeen++;
var entityWorld = var entityWorld =
@ -242,9 +331,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (entity.PaletteOverride is not null) if (entity.PaletteOverride is not null)
palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride); palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride);
bool drewAny = false;
for (int partIdx = 0; partIdx < entity.MeshRefs.Count; partIdx++)
{
// Note: GameWindow's spawn path already applies // Note: GameWindow's spawn path already applies
// AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix — // AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix —
// close-detail mesh swap for humanoids) to MeshRefs. We // close-detail mesh swap for humanoids) to MeshRefs. We
@ -260,9 +346,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (diag) _meshesMissing++; if (diag) _meshesMissing++;
continue; continue;
} }
drewAny = true;
if (anyVao == 0) anyVao = renderData.VAO; if (anyVao == 0) anyVao = renderData.VAO;
bool drewAny = false;
if (renderData.IsSetup && renderData.SetupParts.Count > 0) if (renderData.IsSetup && renderData.SetupParts.Count > 0)
{ {
foreach (var (partGfxObjId, partTransform) in renderData.SetupParts) foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
@ -274,18 +360,18 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
entityWorld, meshRef.PartTransform, partTransform); entityWorld, meshRef.PartTransform, partTransform);
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable); ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable);
drewAny = true;
} }
} }
else else
{ {
var model = meshRef.PartTransform * entityWorld; var model = meshRef.PartTransform * entityWorld;
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable); ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable);
} drewAny = true;
} }
if (diag && drewAny) _entitiesDrawn++; if (diag && drewAny) _entitiesDrawn++;
} }
}
// Nothing visible — skip the GL pass entirely. // Nothing visible — skip the GL pass entirely.
if (anyVao == 0) if (anyVao == 0)

View file

@ -106,17 +106,33 @@ public sealed class GpuWorldState
/// Per-landblock iteration with AABB data for use by the frustum-culling /// Per-landblock iteration with AABB data for use by the frustum-culling
/// draw path. Landblocks without a stored AABB yield <see cref="Vector3.Zero"/> /// draw path. Landblocks without a stored AABB yield <see cref="Vector3.Zero"/>
/// for both corners, which the culler will conservatively treat as visible. /// for both corners, which the culler will conservatively treat as visible.
///
/// <para>
/// A.5 T17: also yields an <c>AnimatedById</c> dictionary built on the fly
/// from the landblock's entity list. This lets <see cref="WbDrawDispatcher"/>
/// 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.
/// </para>
/// </summary> /// </summary>
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> LandblockEntries public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
{ {
get get
{ {
foreach (var kvp in _loaded) foreach (var kvp in _loaded)
{ {
// Build AnimatedById on the fly — cheap (~132 entities/LB max).
var byId = new Dictionary<uint, WorldEntity>(kvp.Value.Entities.Count);
foreach (var e in kvp.Value.Entities)
byId[e.Id] = e;
if (_aabbs.TryGetValue(kvp.Key, out var aabb)) 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 else
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities); yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities, byId);
} }
} }
} }

View file

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