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++; }