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);