diff --git a/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs b/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs index abe7476..b7307ac 100644 --- a/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs +++ b/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs @@ -447,6 +447,29 @@ public class ChunkStreamerTests 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] public void DrawVisible_DrawsOnlyChunksPassingPredicate_AndReturnsCount() { diff --git a/World/Streaming/ChunkStreamer.cs b/World/Streaming/ChunkStreamer.cs index f44620b..a8a9b09 100644 --- a/World/Streaming/ChunkStreamer.cs +++ b/World/Streaming/ChunkStreamer.cs @@ -18,6 +18,10 @@ public sealed class ChunkStreamer : IDisposable private readonly IWorld _world; private readonly Func _generate; private readonly Func _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 BlockRegistry _registry; private readonly IJobExecutor _executor; @@ -148,6 +152,23 @@ public sealed class ChunkStreamer : IDisposable this._tracked.Remove(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 @@ -186,6 +207,9 @@ public sealed class ChunkStreamer : IDisposable // sampled at the four corners and the centre (catches slope spread cheaply). 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 z0 = cz * VoxelChunk.Size; 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 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()