feat(render #53): cache-miss populate on first frame for static entities

Restructures Draw's per-entity loop: animated entities still skip the
cache entirely, but static entities now collect their classification into
_populateScratch and call cache.Populate at the end of the iteration.

Cache fast-path (skip slow classification on cache hit) lands in Task 10.
This intermediate state is verifiable: behavior unchanged, but the cache
is being populated as entities render. Diagnostic-friendly split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-10 18:24:26 +02:00
parent 28513eae88
commit 2f489a83a7

View file

@ -121,6 +121,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// of GC pressure on the render thread under the original T17 shape. // of GC pressure on the render thread under the original T17 shape.
private readonly List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> _walkScratch = new(); private readonly List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> _walkScratch = new();
// Tier 1 cache (#53) — per-entity classification collector. Reused across
// frames; cleared once per static entity inside Draw. Animated entities
// skip this scratch entirely (collector = null).
private readonly List<CachedBatch> _populateScratch = new();
// Per-entity-cull AABB radius. Conservative — covers most entities; large // Per-entity-cull AABB radius. Conservative — covers most entities; large
// outliers (long banners, tall columns) are still landblock-culled. // outliers (long banners, tall columns) are still landblock-culled.
private const float PerEntityCullRadius = 5.0f; private const float PerEntityCullRadius = 5.0f;
@ -385,6 +390,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
Matrix4x4.CreateFromQuaternion(entity.Rotation) * Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position); Matrix4x4.CreateTranslation(entity.Position);
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
// Compute palette-override hash ONCE per entity (perf #4). // Compute palette-override hash ONCE per entity (perf #4).
// Reused across every (part, batch) lookup so the FNV-1a fold // Reused across every (part, batch) lookup so the FNV-1a fold
// over SubPalettes runs once instead of N times. Zero when the // over SubPalettes runs once instead of N times. Zero when the
@ -410,6 +417,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
} }
if (anyVao == 0) anyVao = renderData.VAO; if (anyVao == 0) anyVao = renderData.VAO;
// Cache-miss path (animated entities skip cache entirely).
// Static entities collect into _populateScratch on the first frame
// they're visible, so the cache has fresh data for the next frame.
// Task 10 will add the cache-hit fast path that skips slow
// classification when an entry already exists.
var collector = isAnimated ? null : _populateScratch;
collector?.Clear();
bool drewAny = false; bool drewAny = false;
if (renderData.IsSetup && renderData.SetupParts.Count > 0) if (renderData.IsSetup && renderData.SetupParts.Count > 0)
{ {
@ -422,17 +437,25 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
entityWorld, meshRef.PartTransform, partTransform); entityWorld, meshRef.PartTransform, partTransform);
var restPose = partTransform * meshRef.PartTransform; var restPose = partTransform * meshRef.PartTransform;
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose); ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, collector);
drewAny = true; drewAny = true;
} }
} }
else else
{ {
var model = meshRef.PartTransform * entityWorld; var model = meshRef.PartTransform * entityWorld;
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform); ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform, collector: collector);
drewAny = true; drewAny = true;
} }
if (collector is not null && collector.Count > 0)
{
// Populate cache for static entity on cache-miss.
// Each entity classifies once at first visibility; subsequent
// frames will hit the fast path (added in Task 10).
_cache.Populate(entity.Id, landblockId, collector.ToArray());
}
if (diag && drewAny) _entitiesDrawn++; if (diag && drewAny) _entitiesDrawn++;
} }