From 2f489a83a7b424ec98a1f7c23a2ab517ef0ea5b6 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 18:24:26 +0200 Subject: [PATCH] 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) --- .../Rendering/Wb/WbDrawDispatcher.cs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index de29be3..9ad4986 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -121,6 +121,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // of GC pressure on the render thread under the original T17 shape. 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 _populateScratch = new(); + // Per-entity-cull AABB radius. Conservative — covers most entities; large // outliers (long banners, tall columns) are still landblock-culled. private const float PerEntityCullRadius = 5.0f; @@ -385,6 +390,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable Matrix4x4.CreateFromQuaternion(entity.Rotation) * Matrix4x4.CreateTranslation(entity.Position); + bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true; + // 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 @@ -410,6 +417,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } 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; if (renderData.IsSetup && renderData.SetupParts.Count > 0) { @@ -422,17 +437,25 @@ public sealed unsafe class WbDrawDispatcher : IDisposable entityWorld, meshRef.PartTransform, 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; } } else { 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; } + 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++; }