From 9217fd93cd3335a355cd8b0578e7c696a9f79704 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 09:10:42 +0200 Subject: [PATCH] =?UTF-8?q?fix(A.5):=20strip=20far-tier=20entities=20in=20?= =?UTF-8?q?worker=20(Bug=20A=20=E2=80=94=20far=20tier=20optimization)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A.5's two-tier streaming spec promised that far-tier landblocks ship terrain ONLY — no entities, no scenery, no interior cells. T13/T16 wired the controller side (RecenterTo emits ToLoadFar/ToLoadNear/ToPromote; controller passes JobKind to the worker), but the worker's HandleJob never branched on Kind: every load called BuildLandblockForStreaming which runs the full hydration + scenery generation + interior cell path. Result: at default radii (N₁=4 / N₂=12), 540 far-tier LBs each loaded their full entity layer (~132 entities/LB → ~71K entities total) into GpuWorldState. The dispatcher then walked all ~54K entities per frame (post-frustum-cull), driving the entity dispatcher cpu_us from ~3.6ms median (T24 baseline) to ~18-21ms (post-T22.5 horizon-test). User- observed: 40 FPS / 25ms frame time at horizon-safe settings; system crash at full High preset. Minimum-diff fix: in LandblockStreamer.HandleJob, after _loadLandblock returns, strip Entities to empty for LoadFar before posting Loaded. Worker still does wasted hydration CPU (off the render thread, harmless). Render-side dispatcher walk drops from ~54K to ~10K entities/frame. Math: post-fix entity dispatcher should drop to ~3-4ms median at N₁=4 / N₂=12 (matches T24's 3.6ms at radius=5 single-tier, since N₁=4 has 33% fewer near entities than N₁=5). Future optimization (N.6 / A.6): plumb JobKind through BuildLandblockForStreaming so the worker also skips the wasted CPU. Out of A.5 scope. Bug B (T17 WalkEntities allocation) is a smaller perf hit — defer if post-Bug-A FPS is acceptable. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Streaming/LandblockStreamer.cs | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs index a3416de..0811c8e 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -177,11 +177,19 @@ public sealed class LandblockStreamer : IDisposable switch (job) { case LandblockStreamJob.Load load: - // TODO(A.5 T16): route by load.Kind. LoadFar will skip - // LandBlockInfo + scenery generation; PromoteToNear will skip - // mesh build (terrain already on GPU). Today every Kind takes - // the full-load path via _loadLandblock, which matches today's - // single-tier semantics. + // A.5 T26 follow-up (Bug A): far-tier LBs must NOT contribute + // entities to GpuWorldState — that defeats the whole purpose of + // the two-tier split. The factory still builds the full entity + // layer (LandblockLoader + scenery generation + interior cells) + // regardless of Kind because it doesn't know about JobKind today. + // We strip Entities here for far-tier results so the render- + // thread dispatcher walks only near-tier (~10K) entities, not + // all (~71K) entities at radius=12. + // + // Wasted worker-thread CPU is acceptable (it's off the render + // thread). A future optimization (TODO N.6 or A.6) plumbs Kind + // through BuildLandblockForStreaming so the dat read + scenery + // generation are skipped entirely for far-tier. try { var lb = _loadLandblock(load.LandblockId); @@ -200,6 +208,14 @@ public sealed class LandblockStreamer : IDisposable } var tier = load.Kind == LandblockStreamJobKind.LoadFar ? LandblockStreamTier.Far : LandblockStreamTier.Near; + if (tier == LandblockStreamTier.Far && lb.Entities.Count > 0) + { + // Strip entities — far-tier ships terrain only. + lb = new LoadedLandblock( + lb.LandblockId, + lb.Heightmap, + System.Array.Empty()); + } _outbox.Writer.TryWrite(new LandblockStreamResult.Loaded( load.LandblockId, tier, lb, mesh)); }