feat(A.5 T12): inject mesh-build dependency into LandblockStreamer

Replaces the T7-temporary default! MeshData placeholder. Streamer
now takes Func<uint, LoadedLandblock?, LandblockMeshData?> 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<LoadedLandblock, LandblockMeshData> 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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-10 07:35:45 +02:00
parent 00bb030c9f
commit 0405947bac
3 changed files with 43 additions and 27 deletions

View file

@ -1584,11 +1584,24 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"streaming: radius={_streamingRadius} (window={2*_streamingRadius+1}×{2*_streamingRadius+1})"); 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. // Phase A.5 T11+: the streamer now runs on a dedicated worker thread.
// loadLandblock and buildMeshOrNull are called on the worker; both // loadLandblock acquires _datLock (T10) before touching DatCollection.
// closures acquire _datLock (T10) before touching DatCollection. // buildMeshOrNull (T12) receives the already-loaded LoadedLandblock so
// T12 wires the real mesh-build factory below. // 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( _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(); _streamer.Start();
_streamingController = new AcDream.App.Streaming.StreamingController( _streamingController = new AcDream.App.Streaming.StreamingController(
@ -4987,24 +5000,26 @@ public sealed class GameWindow : IDisposable
} }
/// <summary> /// <summary>
/// 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. /// 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 <paramref name="meshData"/> off the
/// render thread via <see cref="AcDream.Core.Terrain.LandblockMesh.Build"/>;
/// this callback no longer pays that CPU cost.
/// Must only be called from the render thread. /// Must only be called from the render thread.
/// </summary> /// </summary>
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 if (_terrain is null || _dats is null) return;
|| _heightTable is null || _surfaceCache is null) return;
// Phase A.1 hotfix: render-thread path also takes the dat lock so it // Phase A.1 hotfix: render-thread path also takes the dat lock so it
// doesn't race with BuildLandblockForStreaming on the worker thread. // doesn't race with BuildLandblockForStreaming on the worker thread.
// Hold the lock across the entire apply because we read dats below // Hold the lock across the entity hydration below (GfxObj sub-mesh
// (GfxObj sub-mesh builds) and mutate the shared _surfaceCache from // builds). The terrain mesh is pre-built by the worker (T12) and passed
// LandblockMesh.Build. // in via meshData, so LandblockMesh.Build no longer runs under this lock.
lock (_datLock) lock (_datLock)
{ {
ApplyLoadedTerrainLocked(lb); ApplyLoadedTerrainLocked(lb, meshData);
} }
} }
@ -5114,10 +5129,12 @@ public sealed class GameWindow : IDisposable
_pendingCells.Add(loaded); _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 // _blendCtx / _surfaceCache no longer needed here (mesh pre-built by worker).
|| _heightTable is null || _surfaceCache is null) return; // _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 lbXu = (lb.LandblockId >> 24) & 0xFFu;
uint lbYu = (lb.LandblockId >> 16) & 0xFFu; uint lbYu = (lb.LandblockId >> 16) & 0xFFu;
@ -5128,10 +5145,8 @@ public sealed class GameWindow : IDisposable
(lbY - _liveCenterY) * 192f, (lbY - _liveCenterY) * 192f,
0f); 0f);
// Build terrain mesh data on the render thread (pure CPU; acceptable // Phase A.5 T12: terrain mesh is pre-built by the worker thread and
// for the MVP; a future pass can move it to the worker thread). // passed in via meshData. No longer rebuilt here on the render thread.
var meshData = AcDream.Core.Terrain.LandblockMesh.Build(
lb.Heightmap, lbXu, lbYu, _heightTable, _blendCtx, _surfaceCache);
_terrain.AddLandblock(lb.LandblockId, meshData, origin); _terrain.AddLandblock(lb.LandblockId, meshData, origin);
// Step 4: drain pending LoadedCells from the worker thread. // Step 4: drain pending LoadedCells from the worker thread.

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using AcDream.Core.Terrain;
using AcDream.Core.World; using AcDream.Core.World;
namespace AcDream.App.Streaming; namespace AcDream.App.Streaming;
@ -19,7 +20,7 @@ public sealed class StreamingController
private readonly Action<uint> _enqueueLoad; private readonly Action<uint> _enqueueLoad;
private readonly Action<uint> _enqueueUnload; private readonly Action<uint> _enqueueUnload;
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions; private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action<LoadedLandblock> _applyTerrain; private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain;
private readonly Action<uint>? _removeTerrain; private readonly Action<uint>? _removeTerrain;
private readonly GpuWorldState _state; private readonly GpuWorldState _state;
private StreamingRegion? _region; private StreamingRegion? _region;
@ -48,7 +49,7 @@ public sealed class StreamingController
Action<uint> enqueueLoad, Action<uint> enqueueLoad,
Action<uint> enqueueUnload, Action<uint> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions, Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
Action<LoadedLandblock> applyTerrain, Action<LoadedLandblock, LandblockMeshData> applyTerrain,
GpuWorldState state, GpuWorldState state,
int radius, int radius,
Action<uint>? removeTerrain = null) Action<uint>? removeTerrain = null)
@ -92,7 +93,7 @@ public sealed class StreamingController
switch (result) switch (result)
{ {
case LandblockStreamResult.Loaded loaded: case LandblockStreamResult.Loaded loaded:
_applyTerrain(loaded.Landblock); _applyTerrain(loaded.Landblock, loaded.MeshData);
_state.AddLandblock(loaded.Landblock); _state.AddLandblock(loaded.Landblock);
break; break;
case LandblockStreamResult.Unloaded unloaded: case LandblockStreamResult.Unloaded unloaded:

View file

@ -34,7 +34,7 @@ public class StreamingControllerTests
enqueueLoad: fake.EnqueueLoad, enqueueLoad: fake.EnqueueLoad,
enqueueUnload: fake.EnqueueUnload, enqueueUnload: fake.EnqueueUnload,
drainCompletions: fake.DrainCompletions, drainCompletions: fake.DrainCompletions,
applyTerrain: _ => { }, applyTerrain: (_, _) => { },
state: state, state: state,
radius: 2); radius: 2);
@ -53,7 +53,7 @@ public class StreamingControllerTests
var fake = new FakeStreamer(); var fake = new FakeStreamer();
var controller = new StreamingController( var controller = new StreamingController(
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions,
_ => { }, state, radius: 2); (_, _) => { }, state, radius: 2);
controller.Tick(50, 50); controller.Tick(50, 50);
fake.Loads.Clear(); fake.Loads.Clear();
@ -72,7 +72,7 @@ public class StreamingControllerTests
var applied = new List<LoadedLandblock>(); var applied = new List<LoadedLandblock>();
var controller = new StreamingController( var controller = new StreamingController(
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, 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, // Note: LoadedLandblock's actual fields are LandblockId, Heightmap,
// Entities (positional record). Adjust if the first positional arg // Entities (positional record). Adjust if the first positional arg
@ -93,7 +93,7 @@ public class StreamingControllerTests
var fake = new FakeStreamer(); var fake = new FakeStreamer();
var controller = new StreamingController( var controller = new StreamingController(
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions,
_ => { }, state, radius: 2); (_, _) => { }, state, radius: 2);
var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty<WorldEntity>()); var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty<WorldEntity>());
state.AddLandblock(lb); state.AddLandblock(lb);