perf(streaming): cache per-column surface heights to fix move lag spikes
RecomputeTargetSet sampled TerrainSampler.HeightAt (a 169-point biome blend kernel) 5x per column over the whole render distance on the main thread every chunk crossing (~1,445 calls = ~250k biome lookups), causing multi-frame hitches while moving. Memoize each chunk column's (min,max) surface range, reused across ticks and pruned to the horizontal window, so each column is sampled once. Exact (no terrain change); spike is amortized to a thin edge strip per crossing and zero when stationary.
This commit is contained in:
@@ -447,6 +447,29 @@ public class ChunkStreamerTests
|
|||||||
Assert.Null(world.GetChunk(new Vector3i(0, 5, 0))); // air above surface — not loaded
|
Assert.Null(world.GetChunk(new Vector3i(0, 5, 0))); // air above surface — not loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecomputeTargetSet_CachesSurfaceHeights_OnlySamplesNewColumns()
|
||||||
|
{
|
||||||
|
var cfg = new Configuration
|
||||||
|
{
|
||||||
|
RenderDistanceHorizontal = 1,
|
||||||
|
RenderDistanceVertical = 0,
|
||||||
|
SpawnReadyRadiusHorizontal = 1,
|
||||||
|
MaxGenerateJobsInFlight = 100,
|
||||||
|
MaxMeshUploadsPerFrame = 100,
|
||||||
|
};
|
||||||
|
int calls = 0;
|
||||||
|
var (streamer, _, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => { calls++; return 8; });
|
||||||
|
|
||||||
|
streamer.Tick(new Vector3i(0, 0, 0));
|
||||||
|
int afterFirst = calls; // 9 columns × 5 samples = 45 (spawn-check reuses cache)
|
||||||
|
streamer.Tick(new Vector3i(1, 0, 0)); // overlaps 6 columns; only the cx=2 strip is new
|
||||||
|
int newCalls = calls - afterFirst;
|
||||||
|
|
||||||
|
Assert.Equal(45, afterFirst);
|
||||||
|
Assert.Equal(15, newCalls); // 3 new columns × 5 — cached columns are not re-sampled
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DrawVisible_DrawsOnlyChunksPassingPredicate_AndReturnsCount()
|
public void DrawVisible_DrawsOnlyChunksPassingPredicate_AndReturnsCount()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ public sealed class ChunkStreamer : IDisposable
|
|||||||
private readonly IWorld _world;
|
private readonly IWorld _world;
|
||||||
private readonly Func<Vector3i, VoxelChunk> _generate;
|
private readonly Func<Vector3i, VoxelChunk> _generate;
|
||||||
private readonly Func<int, int, int> _surfaceHeightAt;
|
private readonly Func<int, int, int> _surfaceHeightAt;
|
||||||
|
// Per chunk-column (cx,cz) surface (min,max) height range, memoized so each
|
||||||
|
// column's samples are taken once and reused across ticks. Pruned to the
|
||||||
|
// horizontal window each recompute so it stays bounded.
|
||||||
|
private readonly Dictionary<(int cx, int cz), (int min, int max)> _surfaceRangeCache = new();
|
||||||
private readonly IChunkMesher _mesher;
|
private readonly IChunkMesher _mesher;
|
||||||
private readonly BlockRegistry _registry;
|
private readonly BlockRegistry _registry;
|
||||||
private readonly IJobExecutor _executor;
|
private readonly IJobExecutor _executor;
|
||||||
@@ -148,6 +152,23 @@ public sealed class ChunkStreamer : IDisposable
|
|||||||
this._tracked.Remove(pos);
|
this._tracked.Remove(pos);
|
||||||
this._pendingUnload.Enqueue(pos);
|
this._pendingUnload.Enqueue(pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.PruneSurfaceCache(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop cached surface ranges for columns outside the current horizontal
|
||||||
|
// window so the cache stays bounded as the player explores.
|
||||||
|
private void PruneSurfaceCache(Vector3i p)
|
||||||
|
{
|
||||||
|
int keep = this._cfg.RenderDistanceHorizontal + 1;
|
||||||
|
List<(int cx, int cz)>? stale = null;
|
||||||
|
foreach (var key in this._surfaceRangeCache.Keys)
|
||||||
|
{
|
||||||
|
if (Math.Abs(key.cx - p.X) > keep || Math.Abs(key.cz - p.Z) > keep)
|
||||||
|
(stale ??= new()).Add(key);
|
||||||
|
}
|
||||||
|
if (stale != null)
|
||||||
|
foreach (var key in stale) this._surfaceRangeCache.Remove(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fills `into` with the chunk positions to load for a viewer at chunk
|
// Fills `into` with the chunk positions to load for a viewer at chunk
|
||||||
@@ -186,6 +207,9 @@ public sealed class ChunkStreamer : IDisposable
|
|||||||
// sampled at the four corners and the centre (catches slope spread cheaply).
|
// sampled at the four corners and the centre (catches slope spread cheaply).
|
||||||
private (int min, int max) SurfaceHeightRange(int cx, int cz)
|
private (int min, int max) SurfaceHeightRange(int cx, int cz)
|
||||||
{
|
{
|
||||||
|
if (this._surfaceRangeCache.TryGetValue((cx, cz), out (int min, int max) cached))
|
||||||
|
return cached;
|
||||||
|
|
||||||
int x0 = cx * VoxelChunk.Size;
|
int x0 = cx * VoxelChunk.Size;
|
||||||
int z0 = cz * VoxelChunk.Size;
|
int z0 = cz * VoxelChunk.Size;
|
||||||
int x1 = x0 + VoxelChunk.Size - 1;
|
int x1 = x0 + VoxelChunk.Size - 1;
|
||||||
@@ -201,7 +225,10 @@ public sealed class ChunkStreamer : IDisposable
|
|||||||
|
|
||||||
int min = Math.Min(Math.Min(h0, h1), Math.Min(Math.Min(h2, h3), h4));
|
int min = Math.Min(Math.Min(h0, h1), Math.Min(Math.Min(h2, h3), h4));
|
||||||
int max = Math.Max(Math.Max(h0, h1), Math.Max(Math.Max(h2, h3), h4));
|
int max = Math.Max(Math.Max(h0, h1), Math.Max(Math.Max(h2, h3), h4));
|
||||||
return (min, max);
|
|
||||||
|
(int min, int max) range = (min, max);
|
||||||
|
this._surfaceRangeCache[(cx, cz)] = range;
|
||||||
|
return range;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrainCompleted()
|
private void DrainCompleted()
|
||||||
|
|||||||
Reference in New Issue
Block a user