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:
parent
00fa8ae839
commit
0cbef3c8b3
2 changed files with 398 additions and 17 deletions
|
|
@ -404,15 +404,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// Flush-on-entity-change: if the previous entity accumulated any
|
||||
// batches AND this iteration is for a different entity, populate
|
||||
// its cache entry now and reset the scratch buffer.
|
||||
if (populateEntityId.HasValue && populateEntityId.Value != entity.Id)
|
||||
{
|
||||
if (_populateScratch.Count > 0)
|
||||
{
|
||||
_cache.Populate(populateEntityId.Value, populateLandblockId, _populateScratch.ToArray());
|
||||
}
|
||||
_populateScratch.Clear();
|
||||
populateEntityId = null;
|
||||
}
|
||||
(populateEntityId, populateLandblockId) = MaybeFlushOnEntityChange(
|
||||
populateEntityId, populateLandblockId, entity.Id, _cache, _populateScratch);
|
||||
|
||||
var entityWorld =
|
||||
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
|
||||
|
|
@ -420,6 +413,36 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
|
||||
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
|
||||
|
||||
// Cache-hit fast path (Task 10): static entity with a populated
|
||||
// cache entry skips classification entirely. Walk the cached
|
||||
// (GroupKey, RestPose) flat list and append cached.RestPose *
|
||||
// entityWorld to each matching group's matrices. Animated entities
|
||||
// bypass the cache (collector is set null below; their entries are
|
||||
// never populated in the first place).
|
||||
//
|
||||
// Placed AFTER the entity-change flush above so that, on a
|
||||
// hit, this iteration also finishes flushing any pending
|
||||
// populate state from a previous entity. Animated entities never
|
||||
// enter this branch — the !isAnimated guard makes that explicit.
|
||||
if (!isAnimated && _cache.TryGet(entity.Id, out var cachedEntry))
|
||||
{
|
||||
ApplyCacheHit(cachedEntry!, entityWorld, AppendInstanceToGroup);
|
||||
|
||||
// anyVao recovery: when the first visible entity in the frame
|
||||
// takes the fast path, no slow-path lookup has populated
|
||||
// anyVao yet. Look up THIS entity's first MeshRef once via
|
||||
// the mesh adapter — cheap dict lookup, not a re-classify.
|
||||
if (anyVao == 0)
|
||||
{
|
||||
var firstMeshRef = entity.MeshRefs[partIdx];
|
||||
var firstRenderData = _meshAdapter.TryGetRenderData(firstMeshRef.GfxObjId);
|
||||
if (firstRenderData is not null) anyVao = firstRenderData.VAO;
|
||||
}
|
||||
|
||||
if (diag) _entitiesDrawn++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute palette-override hash ONCE per entity (perf #4).
|
||||
// Reused across every (part, batch) lookup so the FNV-1a fold
|
||||
// over SubPalettes runs once instead of N times. Zero when the
|
||||
|
|
@ -449,8 +472,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// Static entities accumulate into _populateScratch across ALL
|
||||
// their MeshRefs; the flush at next-entity-boundary (or
|
||||
// end-of-loop) commits them as a single Populate call.
|
||||
// Task 10 will add the cache-hit fast path that skips slow
|
||||
// classification when an entry already exists.
|
||||
var collector = isAnimated ? null : _populateScratch;
|
||||
|
||||
bool drewAny = false;
|
||||
|
|
@ -492,12 +513,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// to trigger the entity-change flush, so commit its accumulated batches
|
||||
// here. No-op when the last entity was animated (populateEntityId stays
|
||||
// null) or when no entities walked at all.
|
||||
if (populateEntityId.HasValue && _populateScratch.Count > 0)
|
||||
{
|
||||
_cache.Populate(populateEntityId.Value, populateLandblockId, _populateScratch.ToArray());
|
||||
_populateScratch.Clear();
|
||||
populateEntityId = null;
|
||||
}
|
||||
FinalFlushPopulate(populateEntityId, populateLandblockId, _cache, _populateScratch);
|
||||
|
||||
// Nothing visible — skip the GL pass entirely.
|
||||
if (anyVao == 0)
|
||||
|
|
@ -781,6 +797,116 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
return copy[idx];
|
||||
}
|
||||
|
||||
// ── Tier 1 cache (#53) helpers extracted for testability ─────────────────
|
||||
//
|
||||
// Three pure-CPU static helpers carved out of Draw's per-entity loop so
|
||||
// unit tests can exercise the populate/flush algorithm + cache-hit fast
|
||||
// path without needing a real GL context. Production code (Draw) calls
|
||||
// these helpers; the dispatcher integration tests in
|
||||
// WbDrawDispatcherBucketingTests use them to drive the same algorithm
|
||||
// through deterministic inputs.
|
||||
|
||||
/// <summary>
|
||||
/// Apply a cache hit's batches into the per-frame group dictionary by
|
||||
/// composing <c>cached.RestPose * entityWorld</c> per batch and routing
|
||||
/// the result through <paramref name="appendInstance"/>. The delegate
|
||||
/// abstracts over <see cref="InstanceGroup"/> so this helper stays
|
||||
/// GL-free and unit-testable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Matrix multiplication is non-commutative: it MUST be
|
||||
/// <c>RestPose * entityWorld</c>, not the reverse. See
|
||||
/// <see cref="ComposePartWorldMatrix"/> for the full part-world product.
|
||||
/// </remarks>
|
||||
internal static void ApplyCacheHit(
|
||||
EntityCacheEntry entry,
|
||||
Matrix4x4 entityWorld,
|
||||
Action<GroupKey, Matrix4x4> appendInstance)
|
||||
{
|
||||
foreach (var cached in entry.Batches)
|
||||
{
|
||||
appendInstance(cached.Key, cached.RestPose * entityWorld);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-tuple flush check. If <paramref name="populateEntityId"/> is set
|
||||
/// AND differs from <paramref name="currentEntityId"/>, the previous
|
||||
/// entity's accumulated batches are committed to <paramref name="cache"/>
|
||||
/// and <paramref name="populateScratch"/> is cleared. Returns the
|
||||
/// updated tracker tuple — pass these back into the field locals in the
|
||||
/// caller's loop.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the bug-fix structure from commit 00fa8ae (per-MeshRef
|
||||
/// Populate would overwrite earlier MeshRefs because the cache is
|
||||
/// keyed by entity.Id; flushing only on entity boundary preserves all
|
||||
/// MeshRefs' batches). _walkScratch is in entity-order so all MeshRefs
|
||||
/// of one entity arrive contiguously.
|
||||
/// </remarks>
|
||||
internal static (uint? PopulateEntityId, uint PopulateLandblockId)
|
||||
MaybeFlushOnEntityChange(
|
||||
uint? populateEntityId,
|
||||
uint populateLandblockId,
|
||||
uint currentEntityId,
|
||||
EntityClassificationCache cache,
|
||||
List<CachedBatch> populateScratch)
|
||||
{
|
||||
if (populateEntityId.HasValue && populateEntityId.Value != currentEntityId)
|
||||
{
|
||||
if (populateScratch.Count > 0)
|
||||
{
|
||||
cache.Populate(populateEntityId.Value, populateLandblockId, populateScratch.ToArray());
|
||||
}
|
||||
populateScratch.Clear();
|
||||
return (null, 0u);
|
||||
}
|
||||
return (populateEntityId, populateLandblockId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End-of-loop final flush. The last entity in <c>_walkScratch</c> has
|
||||
/// no next-iteration to trigger <see cref="MaybeFlushOnEntityChange"/>,
|
||||
/// so commit its accumulated batches here. No-op when no populate is
|
||||
/// pending (the last entity was animated, or the scratch is empty).
|
||||
/// </summary>
|
||||
internal static void FinalFlushPopulate(
|
||||
uint? populateEntityId,
|
||||
uint populateLandblockId,
|
||||
EntityClassificationCache cache,
|
||||
List<CachedBatch> populateScratch)
|
||||
{
|
||||
if (populateEntityId.HasValue && populateScratch.Count > 0)
|
||||
{
|
||||
cache.Populate(populateEntityId.Value, populateLandblockId, populateScratch.ToArray());
|
||||
populateScratch.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Instance-side helper used by <see cref="ApplyCacheHit"/>. Looks up or
|
||||
/// creates an <see cref="InstanceGroup"/> for the given key in
|
||||
/// <c>_groups</c> and appends the per-instance world matrix.
|
||||
/// </summary>
|
||||
private void AppendInstanceToGroup(GroupKey key, Matrix4x4 model)
|
||||
{
|
||||
if (!_groups.TryGetValue(key, out var grp))
|
||||
{
|
||||
grp = new InstanceGroup
|
||||
{
|
||||
Ibo = key.Ibo,
|
||||
FirstIndex = key.FirstIndex,
|
||||
BaseVertex = key.BaseVertex,
|
||||
IndexCount = key.IndexCount,
|
||||
BindlessTextureHandle = key.BindlessTextureHandle,
|
||||
TextureLayer = key.TextureLayer,
|
||||
Translucency = key.Translucency,
|
||||
};
|
||||
_groups[key] = grp;
|
||||
}
|
||||
grp.Matrices.Add(model);
|
||||
}
|
||||
|
||||
private void ClassifyBatches(
|
||||
ObjectRenderData renderData,
|
||||
ulong gfxObjId,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue