From 0405947bace6f5104b9b36f3c670e409de335e5d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 07:35:45 +0200 Subject: [PATCH] feat(A.5 T12): inject mesh-build dependency into LandblockStreamer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the T7-temporary default! MeshData placeholder. Streamer now takes Func at construction; the worker calls it after _loadLandblock succeeds and passes the pre-built mesh into LandblockStreamResult.Loaded. GameWindow's buildMeshOrNull factory takes the already-loaded LoadedLandblock (lb.Heightmap is the LandBlock dat object), so no additional dat read is needed — _heightTable and _blendCtx are read-only after init, _surfaceCache is ConcurrentDictionary (T9). Zero dat lock needed inside the mesh-build closure. StreamingController._applyTerrain delegate signature widened to Action so the pre-built mesh flows render-thread-side via the Loaded result. ApplyLoadedTerrainLocked now accepts meshData and calls _terrain.AddLandblock directly, skipping the per-frame LandblockMesh.Build that previously ran on the render thread (~5ms per LB at radius=12 first traversal). StreamingControllerTests updated: all four applyTerrain lambdas adapted to the two-arg Action signature. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 55 ++++++++++++------- .../Streaming/StreamingController.cs | 7 ++- .../Streaming/StreamingControllerTests.cs | 8 +-- 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 332abdb..741b2a9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1584,11 +1584,24 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"streaming: radius={_streamingRadius} (window={2*_streamingRadius+1}×{2*_streamingRadius+1})"); // Phase A.5 T11+: the streamer now runs on a dedicated worker thread. - // loadLandblock and buildMeshOrNull are called on the worker; both - // closures acquire _datLock (T10) before touching DatCollection. - // T12 wires the real mesh-build factory below. + // loadLandblock acquires _datLock (T10) before touching DatCollection. + // buildMeshOrNull (T12) receives the already-loaded LoadedLandblock so + // it can call LandblockMesh.Build without a dat read — _heightTable and + // _blendCtx are read-only after init, _surfaceCache is ConcurrentDictionary (T9). _streamer = new AcDream.App.Streaming.LandblockStreamer( - loadLandblock: id => BuildLandblockForStreaming(id)); + loadLandblock: id => BuildLandblockForStreaming(id), + buildMeshOrNull: (id, lb) => + { + if (lb is null || _heightTable is null || _blendCtx is null || _surfaceCache is null) + return null; + uint lbX = (id >> 24) & 0xFFu; + uint lbY = (id >> 16) & 0xFFu; + // _surfaceCache is ConcurrentDictionary (T9) — safe from worker thread. + // _heightTable and _blendCtx are read-only after initialization. + // lb.Heightmap is the pre-loaded LandBlock; no dat read needed here. + return AcDream.Core.Terrain.LandblockMesh.Build( + lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache); + }); _streamer.Start(); _streamingController = new AcDream.App.Streaming.StreamingController( @@ -4987,24 +5000,26 @@ public sealed class GameWindow : IDisposable } /// - /// Phase A.1: render-thread callback from StreamingController.Tick + /// Phase A.1 / A.5 T12: render-thread callback from StreamingController.Tick /// whenever a new landblock's terrain + entities are ready for GPU upload. - /// Mirrors the terrain-build + entity-upload part of the old preload. + /// Phase A.5 T12: the worker pre-builds off the + /// render thread via ; + /// this callback no longer pays that CPU cost. /// Must only be called from the render thread. /// - private void ApplyLoadedTerrain(AcDream.Core.World.LoadedLandblock lb) + private void ApplyLoadedTerrain(AcDream.Core.World.LoadedLandblock lb, + AcDream.Core.Terrain.LandblockMeshData meshData) { - if (_terrain is null || _dats is null || _blendCtx is null - || _heightTable is null || _surfaceCache is null) return; + if (_terrain is null || _dats is null) return; // Phase A.1 hotfix: render-thread path also takes the dat lock so it // doesn't race with BuildLandblockForStreaming on the worker thread. - // Hold the lock across the entire apply because we read dats below - // (GfxObj sub-mesh builds) and mutate the shared _surfaceCache from - // LandblockMesh.Build. + // Hold the lock across the entity hydration below (GfxObj sub-mesh + // builds). The terrain mesh is pre-built by the worker (T12) and passed + // in via meshData, so LandblockMesh.Build no longer runs under this lock. lock (_datLock) { - ApplyLoadedTerrainLocked(lb); + ApplyLoadedTerrainLocked(lb, meshData); } } @@ -5114,10 +5129,12 @@ public sealed class GameWindow : IDisposable _pendingCells.Add(loaded); } - private void ApplyLoadedTerrainLocked(AcDream.Core.World.LoadedLandblock lb) + private void ApplyLoadedTerrainLocked(AcDream.Core.World.LoadedLandblock lb, + AcDream.Core.Terrain.LandblockMeshData meshData) { - if (_terrain is null || _dats is null || _blendCtx is null - || _heightTable is null || _surfaceCache is null) return; + // _blendCtx / _surfaceCache no longer needed here (mesh pre-built by worker). + // _heightTable still needed for physics TerrainSurface below. + if (_terrain is null || _dats is null || _heightTable is null) return; uint lbXu = (lb.LandblockId >> 24) & 0xFFu; uint lbYu = (lb.LandblockId >> 16) & 0xFFu; @@ -5128,10 +5145,8 @@ public sealed class GameWindow : IDisposable (lbY - _liveCenterY) * 192f, 0f); - // Build terrain mesh data on the render thread (pure CPU; acceptable - // for the MVP; a future pass can move it to the worker thread). - var meshData = AcDream.Core.Terrain.LandblockMesh.Build( - lb.Heightmap, lbXu, lbYu, _heightTable, _blendCtx, _surfaceCache); + // Phase A.5 T12: terrain mesh is pre-built by the worker thread and + // passed in via meshData. No longer rebuilt here on the render thread. _terrain.AddLandblock(lb.LandblockId, meshData, origin); // Step 4: drain pending LoadedCells from the worker thread. diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 53b0030..61cd5b8 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using AcDream.Core.Terrain; using AcDream.Core.World; namespace AcDream.App.Streaming; @@ -19,7 +20,7 @@ public sealed class StreamingController private readonly Action _enqueueLoad; private readonly Action _enqueueUnload; private readonly Func> _drainCompletions; - private readonly Action _applyTerrain; + private readonly Action _applyTerrain; private readonly Action? _removeTerrain; private readonly GpuWorldState _state; private StreamingRegion? _region; @@ -48,7 +49,7 @@ public sealed class StreamingController Action enqueueLoad, Action enqueueUnload, Func> drainCompletions, - Action applyTerrain, + Action applyTerrain, GpuWorldState state, int radius, Action? removeTerrain = null) @@ -92,7 +93,7 @@ public sealed class StreamingController switch (result) { case LandblockStreamResult.Loaded loaded: - _applyTerrain(loaded.Landblock); + _applyTerrain(loaded.Landblock, loaded.MeshData); _state.AddLandblock(loaded.Landblock); break; case LandblockStreamResult.Unloaded unloaded: diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs index 9b7fdcb..bafe59a 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs @@ -34,7 +34,7 @@ public class StreamingControllerTests enqueueLoad: fake.EnqueueLoad, enqueueUnload: fake.EnqueueUnload, drainCompletions: fake.DrainCompletions, - applyTerrain: _ => { }, + applyTerrain: (_, _) => { }, state: state, radius: 2); @@ -53,7 +53,7 @@ public class StreamingControllerTests var fake = new FakeStreamer(); var controller = new StreamingController( fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, - _ => { }, state, radius: 2); + (_, _) => { }, state, radius: 2); controller.Tick(50, 50); fake.Loads.Clear(); @@ -72,7 +72,7 @@ public class StreamingControllerTests var applied = new List(); var controller = new StreamingController( fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, - applied.Add, state, radius: 2); + (lb, _) => applied.Add(lb), state, radius: 2); // Note: LoadedLandblock's actual fields are LandblockId, Heightmap, // Entities (positional record). Adjust if the first positional arg @@ -93,7 +93,7 @@ public class StreamingControllerTests var fake = new FakeStreamer(); var controller = new StreamingController( fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, - _ => { }, state, radius: 2); + (_, _) => { }, state, radius: 2); var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty()); state.AddLandblock(lb);