feat(render #53): cache-hit fast path + dispatcher integration tests

WbDrawDispatcher.Draw now branches on cache hit before running classification:
on hit, walks the cached flat batch list and appends RestPose times entityWorld
to the matching groups; on miss, runs today's classification and populates
the cache (Task 9). Animated entities skip the cache entirely.

Adds dispatcher integration tests #11 (static entity populates + reuses)
and #12 (animated bypasses) per spec test plan section 7.2, plus the
multi-MeshRef regression test that would have caught the bug fixed in
commit 00fa8ae (cache populate must flush at entity boundary, not per-tuple).

Phase 2 (dispatcher integration) complete. End-to-end caching now live.
Invalidation hooks (Phase 3) ensure correctness across despawns + LB demotes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-10 18:56:33 +02:00
parent 00fa8ae839
commit 0cbef3c8b3
2 changed files with 398 additions and 17 deletions

View file

@ -351,4 +351,259 @@ public sealed class WbDrawDispatcherBucketingTests
// 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, ulong texHandle, Matrix4x4? restPose = null)
{
var key = new GroupKey(
Ibo: ibo,
FirstIndex: firstIndex,
BaseVertex: 0,
IndexCount: indexCount,
BindlessTextureHandle: texHandle,
TextureLayer: 0,
Translucency: TranslucencyKind.Opaque);
return new CachedBatch(key, texHandle, restPose ?? Matrix4x4.Identity);
}
[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, texHandle: 0xAA));
scratch.Add(MakeCachedBatch(ibo: 1, firstIndex: 6, indexCount: 6, texHandle: 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, out var entry));
Assert.NotNull(entry);
Assert.Equal(2, entry!.Batches.Length);
Assert.Equal(0xAAul, entry.Batches[0].BindlessTextureHandle);
Assert.Equal(0xBBul, entry.Batches[1].BindlessTextureHandle);
// 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>>();
void AppendInstance(GroupKey k, Matrix4x4 m)
{
if (!groups.TryGetValue(k, out var list))
{
list = new List<Matrix4x4>();
groups[k] = list;
}
list.Add(m);
}
Assert.True(cache.TryGet(EntityId, 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]);
}
[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, out _));
// Suppress unused-variable warning — LandblockId is here for parity
// with the static-entity test's structure.
_ = LandblockId;
}
[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, texHandle) so the
// ordering can be verified post-hoc.
for (int b = 0; b < BatchesPerMeshRef; b++)
{
ulong texHandle = (ulong)(0x100 + meshRefIdx * BatchesPerMeshRef + b);
scratch.Add(MakeCachedBatch(
ibo: (uint)(meshRefIdx + 1),
firstIndex: (uint)(b * 6),
indexCount: 6,
texHandle: texHandle));
}
// 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, 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((ulong)(0x100 + i), entry.Batches[i].BindlessTextureHandle);
// After flush, scratch is cleared so the next entity starts fresh.
Assert.Empty(scratch);
}
}