Task 10 (commit0cbef3c) called ApplyCacheHit inside the per-(entity, partIdx) foreach loop, but cachedEntry.Batches is flat across all MeshRefs of the entity. For a 3-MeshRef static building on frame 2: 3 tuples times 6 cached batches per call = 18 instances drawn instead of 6. Severe Z-fighting and 3x perf hit on every multi-part static entity (buildings, statues, multi- MeshRef NPCs). This is the symmetric mirror of the Task 9 bug fixed at00fa8ae. Both spec section 5.2 and the plan describe the foreach as per-entity, but _walkScratch has been per-tuple since Task 6. The implementation faithfully ported the buggy spec. Fix: track lastHitEntityId; the cache-hit fast path fires only on the first tuple of each entity, and subsequent tuples skip the iteration body via continue. Adds a regression test pinning the per-entity amplification invariant. Caught by code review (subagent-driven-development) before Phase 3 dispatched. The bug was invisible in the no-multi-frame-test 1702/8 baseline; would have manifested as visible Z-fighting on every multi- part building on second-and-subsequent frames once Task 13 perf gate captured live runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
688 lines
28 KiB
C#
688 lines
28 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_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, 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);
|
||
}
|
||
|
||
[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, texHandle: (ulong)(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)
|
||
{
|
||
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, 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);
|
||
}
|
||
}
|