The rest of V4t. The composite, particle and shared-atlas texture paths now
hand out the device's GpuTextureSlot instead of a raw 64-bit
ARB_bindless_texture handle, and GroupKey, CachedBatch and ObjectRenderBatch
carry that slot. WbDrawDispatcher, EnvCellRenderer and ParticleRenderer retire
their interim GlBindlessHandleTable instances and share the device's one
table, exactly as V4t-1 did for terrain. Nothing about world submission
changes otherwise: these three renderers are still raw GL, still bind binding
9 themselves, and still draw the same geometry in the same order.
**What produces a slot now.** CompositeTextureArrayCache's GL backend interns
each array's handle when it makes it resident and retires the entry when it
makes it non-resident, so the pair is created and destroyed together and the
cache above it never learns a device exists — the fake backend its tests use
mints a stand-in slot. TextureCache.AcquireParticleTexture does the same for
the one-layer particle arrays it owns, including on its rollback path.
ObjectMeshManager registers each shared atlas's wrap/clamp handles at batch
upload; registration is idempotent by handle, so the many batches sharing an
atlas share its entry.
**Slot release is stricter than what it replaces, not looser.** The interim
tables never released anything — the class comment said so — and they grew
without bound. The device's table has a fixed 16,384-slot capacity, so an
unreleased entry is now a leak with an end. Every producer therefore retires
its entry: the composite backend at MakeNonResident, the particle backend at
MakeNonResident, and ObjectMeshManager when a retiring atlas's PHYSICAL
retirement completes — the point at which its handles are already non-resident
and its texture already deleted. That last one needs the handles snapshotted
at eviction, because ManagedGLTextureArray.Dispose zeroes its own copies as
its first act. Teardown deliberately does not release: the device is being torn
down alongside its callers, so there is nothing left to recycle a slot into,
and deferring work through a possibly-disposed retirement queue would turn a
clean shutdown into a throw.
**The default value became load-bearing, and that is the one real hazard here.**
BindlessTextureLocation could say "not resolved" with handle 0, because no
texture has handle 0. A slot index has no spare value — default(GpuTextureSlot)
is real slot 0 — so a positional record would have turned every
budget-rejected or still-uploading composite into a silent read of whichever
texture registered first. That is the magenta-placeholder failure shape one
layer down. The type is now a struct storing the slot one-based, so default IS
Unresolved, with a test pinning both halves: default is unresolved, and a
location naming slot 0 is resolved and distinguishable from it. Elsewhere the
sentinel is already exact — GpuTextureSlot.Unassigned is 0xFFFFFFFF, which is
common.glsl's ACDREAM_TEXTURE_NONE — so the classify path's "no texture yet"
test and the particle billboard's untextured branch are unchanged in meaning.
**GroupKey ordering is preserved because the key never ordered anything.**
Handle→slot is a bijection (the device interns one slot per resident handle),
so the same (entity, batch) pairs bucket together as before. The key reaches
equality, hashing and the scene-digest fingerprints — never a comparator:
opaque and translucent groups sort by cull mode then camera distance, the
delayed-alpha path by viewer distance then submission ordinal, and group
enumeration follows the persistent dictionary's insertion order, which a
changed hash does not disturb. The digests hash the slot index where they
hashed the handle; both sides of the render-shadow comparison compute them the
same way, so the value changing is invisible to it. Read
CompareOpaqueSubmissionOrder, CompareTransparentSubmissionOrder and
AlphaFingerprintComparer before doubting this — sort-order drift is a
pixel-visible regression class this project has hit, and it is why the check
was made before the retype rather than after.
**One visibility change, forced rather than chosen.** BindlessTextureLocation
was public and now holds an internal contract type, so it is internal;
ObjectRenderBatch.TextureSlot is internal on an otherwise public class for the
same reason. Nothing outside this assembly and its InternalsVisibleTo test
assemblies named either.
**SkyRenderer keeps its interim table**, and the report should say why: the
sky's textures are minted by SkyRenderer itself from TextureCache's raw GL
texture names, which this slice does not retype, so it would be the one
consumer registering handles it produced — a different shape from the world
stack. The offline gate also masks the sky band, so the one automated
instrument here cannot see a sky regression. V4f owns that renderer.
**Gates.** GL offline pixel gate vs cb2a70b8, measured twice: 31 and 22
differing pixels of 563,200 (5.50e-05, 3.91e-05). The first is above the
plan's documented 15-23 px band, so a control was measured rather than
assumed: two same-commit captures at this tree differ by 19 px, and — the
decisive number — a capture at V4t-1 and a capture at this commit differ by
9 px, fewer than the same-commit control. Maximum channel delta is 41-52 in
every pair including the controls, i.e. the differing pixels are drawn from
one flickering population, not from moved geometry. tools/run-repeat-connected-gate.ps1
-Runs 3: 3/3 RENDERED on both the desktop witness and the client capture. One
Vulkan composition-host run with VK_LAYER_KHRONOS_validation proven inserted
by the loader: zero errors, zero warnings, converged ownership ledger. App
tests 4,077 / 3 skips and the complete Release suite 9,140 / 5 — both the
4,075 and 9,138 baselines plus the two tests added here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
823 lines
34 KiB
C#
823 lines
34 KiB
C#
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_NoDrawHiddenOrSuppressedAncestor_SkipsMeshWithoutRemovingEntity()
|
||
{
|
||
WorldEntity suppressed = MakeEntityWithMesh(7u, Vector3.Zero);
|
||
suppressed.IsDrawVisible = false;
|
||
var entities = new[] { suppressed };
|
||
var entries = new[]
|
||
{
|
||
new WbDrawDispatcher.LandblockEntry(
|
||
0xAAAA_FFFFu,
|
||
new Vector3(-10f),
|
||
new Vector3(10f),
|
||
entities,
|
||
BuildById(entities)),
|
||
};
|
||
|
||
var result = WbDrawDispatcher.WalkEntities(
|
||
entries,
|
||
frustum: null,
|
||
neverCullLandblockId: null,
|
||
visibleCellIds: null,
|
||
animatedEntityIds: new HashSet<uint> { suppressed.Id });
|
||
|
||
Assert.Equal(0, result.EntitiesWalked);
|
||
Assert.Empty(result.ToDraw);
|
||
Assert.Same(suppressed, Assert.Single(entries[0].Entities));
|
||
|
||
suppressed.IsDrawVisible = true;
|
||
suppressed.IsAncestorDrawVisible = false;
|
||
result = WbDrawDispatcher.WalkEntities(
|
||
entries,
|
||
frustum: null,
|
||
neverCullLandblockId: null,
|
||
visibleCellIds: null,
|
||
animatedEntityIds: new HashSet<uint> { suppressed.Id });
|
||
|
||
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);
|
||
}
|
||
|
||
// ── Tier 1 cache (#53) dispatcher integration tests ──────────────────────
|
||
//
|
||
// Tasks 9 & 10 wire the EntityClassificationCache into Draw's per-entity
|
||
// loop. These tests exercise the populate + cache-hit fast-path algorithm
|
||
// through the static helpers Draw uses (MaybeFlushOnEntityChange,
|
||
// FinalFlushPopulate, ApplyCacheHit). The helpers were extracted from
|
||
// Draw's foreach for testability — Draw calls them; tests drive them
|
||
// directly with deterministic synthesized inputs. This is the same
|
||
// pattern WalkEntities follows (extracted from Draw, tested in isolation).
|
||
//
|
||
// The tests cover spec §7.2 #11 (static populate + reuse) and #12
|
||
// (animated bypass), plus a multi-MeshRef regression test that would
|
||
// have caught the bug fixed in commit 00fa8ae (per-MeshRef Populate
|
||
// overwrites earlier batches because the cache is keyed by entity.Id).
|
||
|
||
/// <summary>
|
||
/// Helper: constructs a CachedBatch with stable group-key inputs so the
|
||
/// hit-path test can verify membership. Mirrors the shape ClassifyBatches
|
||
/// produces under the collector pattern.
|
||
/// </summary>
|
||
private static CachedBatch MakeCachedBatch(
|
||
uint ibo,
|
||
uint firstIndex,
|
||
int indexCount,
|
||
uint texSlot,
|
||
Matrix4x4? restPose = null,
|
||
Vector3? localSortCenter = null)
|
||
{
|
||
var key = new GroupKey(
|
||
FirstIndex: firstIndex,
|
||
BaseVertex: 0,
|
||
IndexCount: indexCount,
|
||
TextureSlot: new AcDream.App.Rendering.Gpu.GpuTextureSlot(texSlot),
|
||
TextureLayer: 0,
|
||
Translucency: TranslucencyKind.Opaque);
|
||
return new CachedBatch(
|
||
key,
|
||
new AcDream.App.Rendering.Gpu.GpuTextureSlot(texSlot),
|
||
restPose ?? Matrix4x4.Identity,
|
||
localSortCenter ?? Vector3.Zero);
|
||
}
|
||
|
||
[Fact]
|
||
public void Draw_StaticEntity_PopulatesCacheOnFirstFrameAndHitsOnSecond()
|
||
{
|
||
// Spec §7.2 test #11.
|
||
// Drives Draw's populate + cache-hit algorithm through the production
|
||
// static helpers. Verifies that:
|
||
// 1. First "frame": cache is empty → populate fires once at the
|
||
// end-of-loop final flush (entity.Id=100 has 2 batches).
|
||
// 2. Second "frame": cache.TryGet(100) hits → ApplyCacheHit appends
|
||
// cached batches to a fresh _groups dict without re-populating.
|
||
// cache.Count stays at 1 (Populate is idempotent via overwrite,
|
||
// but the hit-path doesn't re-populate at all).
|
||
var cache = new EntityClassificationCache();
|
||
var scratch = new List<CachedBatch>();
|
||
|
||
Assert.Equal(0, cache.Count);
|
||
|
||
// Frame 1: simulate one foreach iteration producing 2 batches for
|
||
// entity 100 in landblock 0xA9B40000. With no prior tracker, the
|
||
// entity-change flush is a no-op. ClassifyBatches' collector adds
|
||
// to scratch. The end-of-loop FinalFlushPopulate commits.
|
||
const uint EntityId = 100;
|
||
const uint LandblockId = 0xA9B40000u;
|
||
|
||
// First MeshRef contributes 2 batches (mimics ClassifyBatches output).
|
||
scratch.Add(MakeCachedBatch(ibo: 1, firstIndex: 0, indexCount: 6, texSlot: 0xAA));
|
||
scratch.Add(MakeCachedBatch(ibo: 1, firstIndex: 6, indexCount: 6, texSlot: 0xBB));
|
||
|
||
uint? populateEntityId = null;
|
||
uint populateLandblockId = 0u;
|
||
// First-tuple boundary check: no flush, sets the tracker.
|
||
(populateEntityId, populateLandblockId) = WbDrawDispatcher.MaybeFlushOnEntityChange(
|
||
populateEntityId, populateLandblockId, EntityId, cache, scratch);
|
||
// After ClassifyBatches the loop sets the tracker (matching Draw).
|
||
populateEntityId = EntityId;
|
||
populateLandblockId = LandblockId;
|
||
|
||
// End-of-loop final flush — this is where the cache populates.
|
||
WbDrawDispatcher.FinalFlushPopulate(populateEntityId, populateLandblockId, cache, scratch);
|
||
|
||
// First-frame post-conditions: 1 cache entry, 2 batches in it.
|
||
Assert.Equal(1, cache.Count);
|
||
Assert.True(cache.TryGet(EntityId, LandblockId, out var entry));
|
||
Assert.NotNull(entry);
|
||
Assert.Equal(2, entry!.Batches.Length);
|
||
Assert.Equal(0xAAu, entry.Batches[0].TextureSlot.Index);
|
||
Assert.Equal(0xBBu, entry.Batches[1].TextureSlot.Index);
|
||
|
||
// Frame 2: cache hit. ApplyCacheHit walks the cached batches and
|
||
// appends RestPose * entityWorld to a per-frame group dict.
|
||
// Production code: this is the !isAnimated && _cache.TryGet branch
|
||
// at the top of the per-entity loop body in Draw.
|
||
var groups = new Dictionary<GroupKey, List<Matrix4x4>>();
|
||
var sortCenters = new List<Vector3>();
|
||
void AppendInstance(GroupKey k, Matrix4x4 m, Vector3 localSortCenter)
|
||
{
|
||
if (!groups.TryGetValue(k, out var list))
|
||
{
|
||
list = new List<Matrix4x4>();
|
||
groups[k] = list;
|
||
}
|
||
list.Add(m);
|
||
sortCenters.Add(localSortCenter);
|
||
}
|
||
|
||
Assert.True(cache.TryGet(EntityId, LandblockId, out var entryHit));
|
||
Assert.NotNull(entryHit);
|
||
var entityWorld = Matrix4x4.CreateTranslation(new Vector3(10f, 20f, 30f));
|
||
WbDrawDispatcher.ApplyCacheHit(entryHit!, entityWorld, AppendInstance);
|
||
|
||
// Cache state stable — Populate didn't fire on the hit path.
|
||
Assert.Equal(1, cache.Count);
|
||
|
||
// Both groups received exactly one matrix each (the entity is one
|
||
// instance contributing once per cached batch).
|
||
Assert.Equal(2, groups.Count);
|
||
foreach (var (_, list) in groups)
|
||
Assert.Single(list);
|
||
|
||
// Matrix composition is RestPose * entityWorld (NOT the reverse).
|
||
// RestPose is Matrix4x4.Identity for the synthesized batches, so the
|
||
// appended matrix must equal entityWorld.
|
||
foreach (var (_, list) in groups)
|
||
Assert.Equal(entityWorld, list[0]);
|
||
Assert.All(sortCenters, center => Assert.Equal(Vector3.Zero, center));
|
||
}
|
||
|
||
[Fact]
|
||
public void ApplyCacheHit_PreservesAuthoredSortCenter()
|
||
{
|
||
Vector3 authoredCenter = new(1.25f, -2.5f, 7.75f);
|
||
var entry = new EntityCacheEntry
|
||
{
|
||
EntityId = 100,
|
||
LandblockHint = 0xA9B40000u,
|
||
Batches =
|
||
[
|
||
MakeCachedBatch(
|
||
ibo: 1,
|
||
firstIndex: 0,
|
||
indexCount: 6,
|
||
texSlot: 0xAA,
|
||
localSortCenter: authoredCenter),
|
||
],
|
||
};
|
||
Vector3 observedCenter = default;
|
||
|
||
WbDrawDispatcher.ApplyCacheHit(
|
||
entry,
|
||
Matrix4x4.Identity,
|
||
(_, _, center) => observedCenter = center);
|
||
|
||
Assert.Equal(authoredCenter, observedCenter);
|
||
}
|
||
|
||
[Fact]
|
||
public void Draw_AnimatedEntity_DoesNotPopulateCache()
|
||
{
|
||
// Spec §7.2 test #12.
|
||
// Animated entities take the slow path with collector=null: their
|
||
// ClassifyBatches output is NOT routed into _populateScratch and the
|
||
// populate-tracking locals stay null. Result: the cache is never
|
||
// populated for animated entities, and FinalFlushPopulate is a no-op.
|
||
//
|
||
// This test models that flow: scratch stays empty, populateEntityId
|
||
// stays null, FinalFlushPopulate fires but commits nothing.
|
||
var cache = new EntityClassificationCache();
|
||
var scratch = new List<CachedBatch>();
|
||
|
||
const uint AnimatedId = 7;
|
||
const uint LandblockId = 0xA9B40000u;
|
||
var animatedSet = new HashSet<uint> { AnimatedId };
|
||
|
||
// Even when the entity has MeshRefs that would produce batches, the
|
||
// animated-set membership means collector=null in Draw — scratch
|
||
// stays empty and the tracker stays null. Simulating that here:
|
||
// we do NOT add to scratch and we do NOT set populateEntityId.
|
||
bool isAnimated = animatedSet.Contains(AnimatedId);
|
||
Assert.True(isAnimated);
|
||
|
||
uint? populateEntityId = null;
|
||
uint populateLandblockId = 0u;
|
||
// Boundary check still runs but is a no-op — tracker is null.
|
||
(populateEntityId, populateLandblockId) = WbDrawDispatcher.MaybeFlushOnEntityChange(
|
||
populateEntityId, populateLandblockId, AnimatedId, cache, scratch);
|
||
|
||
// For animated entities, Draw does NOT set populateEntityId after
|
||
// ClassifyBatches (the `if (collector is not null)` guard).
|
||
// populateEntityId stays null.
|
||
|
||
// End-of-loop flush — no-op for animated-only iterations.
|
||
WbDrawDispatcher.FinalFlushPopulate(populateEntityId, populateLandblockId, cache, scratch);
|
||
|
||
// Cache should never be populated for animated entities.
|
||
Assert.Equal(0, cache.Count);
|
||
Assert.False(cache.TryGet(AnimatedId, LandblockId, out _));
|
||
}
|
||
|
||
[Fact]
|
||
public void Draw_MultiMeshRefStaticEntity_PopulatesAllBatchesIntoSingleCacheEntry()
|
||
{
|
||
// Regression test for the bug fixed at commit 00fa8ae:
|
||
//
|
||
// Task 9's first attempt called _cache.Populate per-(entity,
|
||
// MeshRefIndex) tuple, but the cache is keyed by entity.Id. For
|
||
// multi-MeshRef entities (multi-part Setup buildings, statues,
|
||
// NPCs), each iteration's Populate OVERWROTE the previous one
|
||
// — only the LAST MeshRef's batches survived in the cache. After
|
||
// the fix, Populate fires once per entity at the entity boundary
|
||
// (or end-of-loop), with all MeshRefs' batches accumulated into
|
||
// _populateScratch.
|
||
//
|
||
// This test simulates a 3-MeshRef static entity where each MeshRef
|
||
// contributes 2 batches (total = 6). It walks through Draw's loop
|
||
// structure tuple-by-tuple, calling MaybeFlushOnEntityChange before
|
||
// each tuple's classification and FinalFlushPopulate at end-of-loop.
|
||
// Asserts the cache entry holds ALL 6 batches, not just the last 2.
|
||
//
|
||
// If the per-MeshRef Populate bug were reintroduced, this test would
|
||
// see Batches.Length == 2 (last MeshRef only).
|
||
var cache = new EntityClassificationCache();
|
||
var scratch = new List<CachedBatch>();
|
||
|
||
const uint EntityId = 200;
|
||
const uint LandblockId = 0xA9B40000u;
|
||
const int MeshRefCount = 3;
|
||
const int BatchesPerMeshRef = 2;
|
||
const int ExpectedTotalBatches = MeshRefCount * BatchesPerMeshRef;
|
||
|
||
uint? populateEntityId = null;
|
||
uint populateLandblockId = 0u;
|
||
|
||
// Simulate Draw's foreach over _walkScratch. _walkScratch yields
|
||
// (entity, MeshRefIndex, landblockId) — all MeshRefs of one entity
|
||
// are contiguous because the walk emits them in entity-order.
|
||
for (int meshRefIdx = 0; meshRefIdx < MeshRefCount; meshRefIdx++)
|
||
{
|
||
// Boundary check: same entity across all 3 iterations, so this
|
||
// never fires the flush. populateEntityId stays as is (null on
|
||
// first iter; EntityId on subsequent iters after we set it).
|
||
(populateEntityId, populateLandblockId) = WbDrawDispatcher.MaybeFlushOnEntityChange(
|
||
populateEntityId, populateLandblockId, EntityId, cache, scratch);
|
||
|
||
// Mimic ClassifyBatches' collector output for THIS MeshRef:
|
||
// 2 batches with distinct (ibo, firstIndex, texSlot) so the
|
||
// ordering can be verified post-hoc.
|
||
for (int b = 0; b < BatchesPerMeshRef; b++)
|
||
{
|
||
uint texSlot = (uint)(0x100 + meshRefIdx * BatchesPerMeshRef + b);
|
||
scratch.Add(MakeCachedBatch(
|
||
ibo: (uint)(meshRefIdx + 1),
|
||
firstIndex: (uint)(b * 6),
|
||
indexCount: 6,
|
||
texSlot: texSlot));
|
||
}
|
||
|
||
// After ClassifyBatches, Draw sets the tracker (matching the
|
||
// `if (collector is not null)` block at line 482-486 in
|
||
// WbDrawDispatcher.Draw).
|
||
populateEntityId = EntityId;
|
||
populateLandblockId = LandblockId;
|
||
}
|
||
|
||
// End-of-loop final flush. Without this call (or if Populate fired
|
||
// per-tuple inside the loop), the cache would only hold the last
|
||
// 2 batches — exactly the bug class from commit 00fa8ae.
|
||
WbDrawDispatcher.FinalFlushPopulate(populateEntityId, populateLandblockId, cache, scratch);
|
||
|
||
// Assertions: ONE cache entry with ALL 6 batches in MeshRef order.
|
||
Assert.Equal(1, cache.Count);
|
||
Assert.True(cache.TryGet(EntityId, LandblockId, out var entry));
|
||
Assert.NotNull(entry);
|
||
Assert.Equal(EntityId, entry!.EntityId);
|
||
Assert.Equal(LandblockId, entry.LandblockHint);
|
||
|
||
// KEY ASSERTION: Batches.Length == sum across MeshRefs (6),
|
||
// NOT just the last MeshRef's batch count (2).
|
||
Assert.Equal(ExpectedTotalBatches, entry.Batches.Length);
|
||
|
||
// Per-batch ordering check: batches arrived in MeshRef order, so
|
||
// texture handles run 0x100..0x105 in the order they were appended.
|
||
for (int i = 0; i < ExpectedTotalBatches; i++)
|
||
Assert.Equal((uint)(0x100 + i), entry.Batches[i].TextureSlot.Index);
|
||
|
||
// After flush, scratch is cleared so the next entity starts fresh.
|
||
Assert.Empty(scratch);
|
||
}
|
||
|
||
[Fact]
|
||
public void Cache_Populate_SkipsEntityWithIncompleteClassification()
|
||
{
|
||
// Regression test for the bug where an entity with >=1 MeshRef whose
|
||
// mesh data was still async-decoding at populate time would have a
|
||
// PARTIAL set of batches written to the cache. Subsequent frame
|
||
// cache-hits served the partial entry indefinitely, leaving parts of
|
||
// multi-part entities (drudge statue, etc.) permanently missing.
|
||
//
|
||
// The fix: track currentEntityIncomplete during the foreach. If any
|
||
// tuple's TryGetRenderData returned null, drop the accumulated
|
||
// populate scratch at entity boundary instead of caching it. The
|
||
// slow path retries on the next frame; once all meshes have loaded,
|
||
// the populate fires correctly with the complete classification.
|
||
//
|
||
// This test simulates Draw's inner-loop logic: 3 MeshRef tuples for
|
||
// one entity where tuple 0 produces null renderData (flag the entity
|
||
// incomplete + continue, no batches), and tuples 1 and 2 produce
|
||
// valid renderData (classify + accumulate). End-of-loop check drops
|
||
// scratch + nulls populateEntityId BEFORE FinalFlushPopulate, so the
|
||
// cache stays empty for this entity.
|
||
var cache = new EntityClassificationCache();
|
||
const uint EntityId = 100;
|
||
const uint LandblockId = 0xA9B40000u;
|
||
|
||
// Simulate Draw's per-entity inner-loop logic.
|
||
var scratch = new List<CachedBatch>();
|
||
bool currentEntityIncomplete = false;
|
||
uint? populateEntityId = null;
|
||
uint populateLandblockId = 0u;
|
||
|
||
// Tuple 0 (MeshRef[0]): renderData null -> flag incomplete, skip classify.
|
||
currentEntityIncomplete = true;
|
||
|
||
// Tuple 1 (MeshRef[1]): renderData valid -> classify, accumulate.
|
||
scratch.Add(MakeCachedBatch(ibo: 1, firstIndex: 0, indexCount: 6, texSlot: 0xAA));
|
||
populateEntityId = EntityId;
|
||
populateLandblockId = LandblockId;
|
||
|
||
// Tuple 2 (MeshRef[2]): renderData valid -> classify, accumulate.
|
||
scratch.Add(MakeCachedBatch(ibo: 2, firstIndex: 0, indexCount: 6, texSlot: 0xBB));
|
||
populateEntityId = EntityId;
|
||
populateLandblockId = LandblockId;
|
||
|
||
// End of loop: check incomplete flag, drop scratch + null tracker
|
||
// BEFORE FinalFlushPopulate so the helper sees the cleaned state.
|
||
if (currentEntityIncomplete)
|
||
{
|
||
scratch.Clear();
|
||
populateEntityId = null;
|
||
}
|
||
WbDrawDispatcher.FinalFlushPopulate(populateEntityId, populateLandblockId, cache, scratch);
|
||
|
||
// Cache should NOT have an entry for this entity — partial population
|
||
// would be worse than no cache (cache hit would persist the partial
|
||
// render forever; cache miss retries and gets it right next frame).
|
||
Assert.Equal(0, cache.Count);
|
||
Assert.False(cache.TryGet(EntityId, LandblockId, out _));
|
||
}
|
||
|
||
[Fact]
|
||
public void ApplyCacheHit_PerTupleAmplification_DoesNotOccur()
|
||
{
|
||
// Regression test for the bug fixed at the commit landing alongside
|
||
// this test: Task 10's first attempt called ApplyCacheHit per-(entity,
|
||
// MeshRefIndex) tuple in Draw's foreach, but cachedEntry.Batches is
|
||
// flat across all MeshRefs of the entity. For a 3-MeshRef building on
|
||
// frame 2: 3 tuples × 6 cached batches per call = 18 instances drawn
|
||
// instead of 6. Severe Z-fighting and 3× perf hit on every multi-part
|
||
// static entity (buildings, statues, multi-MeshRef NPCs).
|
||
//
|
||
// This is the symmetric mirror of the Task 9 bug fixed at 00fa8ae —
|
||
// both came from spec §5.2 describing the foreach as per-entity when
|
||
// _walkScratch is per-tuple.
|
||
//
|
||
// The fix: track lastHitEntityId; the cache-hit fast path fires only
|
||
// on the FIRST tuple of each entity. Subsequent tuples of the same
|
||
// entity skip the iteration body via continue.
|
||
//
|
||
// This test simulates the inner-loop logic by directly invoking
|
||
// ApplyCacheHit + AppendInstanceToGroup the way Draw would, with N
|
||
// tuples for the same entity, asserting that groups[key].Count equals
|
||
// the cached batch count (6), NOT N × cached batch count (18).
|
||
|
||
// Set up a synthetic cache entry with 6 batches (representing 3
|
||
// MeshRefs × 2 batches each).
|
||
const int CachedBatchCount = 6;
|
||
var cache = new EntityClassificationCache();
|
||
var batches = new CachedBatch[CachedBatchCount];
|
||
for (int i = 0; i < CachedBatchCount; i++)
|
||
{
|
||
batches[i] = MakeCachedBatch(
|
||
ibo: 1u, firstIndex: (uint)i, indexCount: 6, texSlot: (uint)(0x100 + i));
|
||
}
|
||
cache.Populate(entityId: 100, landblockHint: 0xA9B40000u, batches);
|
||
|
||
// Simulate Draw's per-entity loop: 3 tuples for the same entity.
|
||
// Track which entity has already cache-hit (mirrors the production
|
||
// lastHitEntityId pattern).
|
||
var groups = new Dictionary<GroupKey, List<Matrix4x4>>();
|
||
uint? lastHitEntityId = null;
|
||
var entityWorld = Matrix4x4.Identity; // simplest case for assertion clarity
|
||
const uint EntityId = 100;
|
||
const int MeshRefCount = 3;
|
||
|
||
void AppendInstance(GroupKey k, Matrix4x4 m, Vector3 localSortCenter)
|
||
{
|
||
if (!groups.TryGetValue(k, out var list))
|
||
{
|
||
list = new List<Matrix4x4>();
|
||
groups[k] = list;
|
||
}
|
||
list.Add(m);
|
||
}
|
||
|
||
for (int partIdx = 0; partIdx < MeshRefCount; partIdx++)
|
||
{
|
||
// Skip subsequent tuples of an entity that cache-hit (the fix).
|
||
if (lastHitEntityId == EntityId) continue;
|
||
|
||
if (cache.TryGet(EntityId, 0xA9B40000u, out var entry))
|
||
{
|
||
Assert.NotNull(entry);
|
||
WbDrawDispatcher.ApplyCacheHit(entry!, entityWorld, AppendInstance);
|
||
lastHitEntityId = EntityId;
|
||
}
|
||
}
|
||
|
||
// Assertion: each group's matrix count equals the cached batches matching
|
||
// that key, NOT (cached batches × MeshRef count). Here each batch has a
|
||
// unique key, so each group has exactly 1 matrix.
|
||
int totalMatrices = 0;
|
||
foreach (var (_, matrices) in groups) totalMatrices += matrices.Count;
|
||
Assert.Equal(CachedBatchCount, totalMatrices); // 6, NOT 18
|
||
|
||
// Sanity: 6 distinct keys (one per cached batch since FirstIndex differs).
|
||
Assert.Equal(CachedBatchCount, groups.Count);
|
||
}
|
||
}
|