From da051ed4131d6a8f0b166713639899f9f06c0f06 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Mon, 1 Jun 2026 11:40:55 +0200 Subject: [PATCH] feat: ChunkStreamer dirty set with cardinal-neighbor remesh on load --- .../Streaming/ChunkStreamerTests.cs | 85 ++++++++++++++++- World/Streaming/ChunkStreamer.cs | 95 ++++++++++++++++++- 2 files changed, 171 insertions(+), 9 deletions(-) diff --git a/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs b/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs index db8a6f8..45416ee 100644 --- a/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs +++ b/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs @@ -82,7 +82,7 @@ public class ChunkStreamerTests } [Fact] - public void Tick_PlayerMoves_LoadsNewChunksOnly() + public void Tick_PlayerMoves_LoadsNewChunks() { var cfg = new Configuration { @@ -94,10 +94,24 @@ public class ChunkStreamerTests var (streamer, world, created) = CreateStreamer(cfg); streamer.Tick(new Vector3i(0, 0, 0)); // loads 9 - int firstWave = created.Count; - streamer.Tick(new Vector3i(1, 0, 0)); // loads only the 3 new column to +x - Assert.Equal(firstWave + 3, created.Count); + // After the second tick the 3 new x=2 chunks must be ready. + // Cardinal-neighbor dirty-marking may also produce remesh renderers, so + // we assert on world state rather than an exact renderer count. + streamer.Tick(new Vector3i(1, 0, 0)); + + for (int z = -1; z <= 1; z++) + { + var newPos = new Vector3i(2, 0, z); + Assert.NotNull(world.GetChunk(newPos)); + Assert.True(streamer.IsReady(newPos)); + } + // x=-1 column must be unloaded + for (int z = -1; z <= 1; z++) + { + Assert.Null(world.GetChunk(new Vector3i(-1, 0, z))); + Assert.False(streamer.IsReady(new Vector3i(-1, 0, z))); + } } [Fact] @@ -240,4 +254,67 @@ public class ChunkStreamerTests for (int i = 0; i < 9; i++) streamer.Tick(new Vector3i(0, 0, 0)); Assert.True(streamer.SpawnReady); } + + [Fact] + public void MarkDirty_RemeshesChunkOnNextTick() + { + var cfg = new Configuration + { + RenderDistanceHorizontal = 0, + RenderDistanceVertical = 0, + MaxGenerateJobsInFlight = 100, + MaxMeshUploadsPerFrame = 100, + }; + var (streamer, _, created) = CreateStreamer(cfg); + + streamer.Tick(new Vector3i(0, 0, 0)); + Assert.Single(created); + var initialRenderer = created[0]; + + streamer.MarkDirty(new Vector3i(0, 0, 0)); + streamer.Tick(new Vector3i(0, 0, 0)); // same chunk, but a remesh is queued + + // Two renderers exist over the lifetime — old disposed, new live. + Assert.Equal(2, created.Count); + Assert.True(initialRenderer.Disposed); + Assert.False(created[1].Disposed); + } + + [Fact] + public void Tick_LoadingChunk_MarksReadyCardinalNeighborsDirty() + { + var cfg = new Configuration + { + RenderDistanceHorizontal = 1, + RenderDistanceVertical = 0, + MaxGenerateJobsInFlight = 100, + MaxMeshUploadsPerFrame = 100, + }; + var (streamer, _, created) = CreateStreamer(cfg); + + streamer.Tick(new Vector3i(0, 0, 0)); // load initial 3×3 + int firstWave = created.Count; // 9 chunks + + // Move so we load a new x=2 column. The new chunks become ready and + // mark their cardinal neighbors (x=1 column) dirty during Drain. + streamer.Tick(new Vector3i(1, 0, 0)); // dispatch + drain new loads → dirty marks set + streamer.Tick(new Vector3i(1, 0, 0)); // dispatch the queued remeshes → drain them + + // Should have: 3 new loads + at least 3 remeshes of the x=1 column. + Assert.True(created.Count > firstWave + 3, + $"Expected remeshes beyond the 3 new loads; got {created.Count - firstWave}"); + } + + [Fact] + public void MarkDirty_OnUntrackedChunk_IsNoOp() + { + var cfg = new Configuration { RenderDistanceHorizontal = 0, RenderDistanceVertical = 0 }; + var (streamer, _, created) = CreateStreamer(cfg); + + streamer.MarkDirty(new Vector3i(99, 0, 99)); + streamer.Tick(new Vector3i(0, 0, 0)); + + // Only the chunk in the load box was created; no spurious remesh. + Assert.Single(created); + } } diff --git a/World/Streaming/ChunkStreamer.cs b/World/Streaming/ChunkStreamer.cs index ff328ff..a07de75 100644 --- a/World/Streaming/ChunkStreamer.cs +++ b/World/Streaming/ChunkStreamer.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Linq; using OpenTK.Mathematics; using Serilog; using VoxelEngine.Core; @@ -24,6 +25,7 @@ public sealed class ChunkStreamer : IDisposable private readonly Dictionary _renderers = new(); private readonly HashSet _tracked = new(); private readonly HashSet _ready = new(); + private readonly HashSet _dirty = new(); private readonly PriorityQueue _pendingLoad = new(); private readonly ConcurrentQueue _completed = new(); private readonly Queue _pendingUnload = new(); @@ -38,6 +40,12 @@ public sealed class ChunkStreamer : IDisposable public bool IsReady(Vector3i pos) => this._ready.Contains(pos); public bool SpawnReady => this._spawnReady; + public void MarkDirty(Vector3i chunkPos) + { + if (!this._ready.Contains(chunkPos)) return; + this._dirty.Add(chunkPos); + } + public ChunkStreamer( Configuration cfg, IWorld world, @@ -68,8 +76,8 @@ public sealed class ChunkStreamer : IDisposable // with a synchronous executor (tests), Submit() runs work to completion // before returning, so a single tick must drain its own dispatches. // With the production threadpool executor, jobs don't finish in this - // tick — Drain harvests results submitted on previous ticks. Either way - // this ordering is correct. + // tick -- Drain harvests results submitted on previous ticks. Either + // way this ordering is correct. this.ProcessUnloads(); this.DispatchJobs(); this.DrainCompleted(); @@ -180,13 +188,17 @@ public sealed class ChunkStreamer : IDisposable private void InstallResult(ChunkJobResult result) { - if (result.Error != null || result.Mesh == null || result.Chunk == null) + if (result.Error != null || result.Mesh == null) { Log.Error(result.Error, "Chunk {Pos} failed to load", result.Pos); return; } - this._world.SetChunk(result.Pos, result.Chunk); + if (!result.IsRemeshOnly) + { + if (result.Chunk == null) { Log.Error("Load result for {Pos} had null chunk", result.Pos); return; } + this._world.SetChunk(result.Pos, result.Chunk); + } if (this._renderers.TryGetValue(result.Pos, out IChunkRenderer? old)) { @@ -194,12 +206,44 @@ public sealed class ChunkStreamer : IDisposable } this._renderers[result.Pos] = this._rendererFactory(result.Mesh); this._ready.Add(result.Pos); + this._dirty.Remove(result.Pos); - Log.Debug("Loaded {Pos}", result.Pos); + if (!result.IsRemeshOnly) + { + this.MarkCardinalNeighborsDirty(result.Pos); + Log.Debug("Loaded {Pos}", result.Pos); + } + else + { + Log.Debug("Remeshed {Pos}", result.Pos); + } + } + + private void MarkCardinalNeighborsDirty(Vector3i pos) + { + Vector3i[] offsets = + { + new(-1, 0, 0), new(1, 0, 0), + new(0, -1, 0), new(0, 1, 0), + new(0, 0, -1), new(0, 0, 1), + }; + foreach (var off in offsets) + { + var neighbor = pos + off; + if (this._ready.Contains(neighbor)) this._dirty.Add(neighbor); + } } private void DispatchJobs() { + // Dirty re-meshes first (correctness), then new loads (player view). + while (this._inFlight < this._cfg.MaxGenerateJobsInFlight && this._dirty.Count > 0) + { + Vector3i pos = this._dirty.First(); + this._dirty.Remove(pos); + this.DispatchRemesh(pos); + } + while (this._inFlight < this._cfg.MaxGenerateJobsInFlight && this._pendingLoad.Count > 0) { Vector3i pos = this._pendingLoad.Dequeue(); @@ -207,6 +251,47 @@ public sealed class ChunkStreamer : IDisposable } } + private void DispatchRemesh(Vector3i pos) + { + VoxelChunk? chunk = this._world.GetChunk(pos); + if (chunk == null) return; // got unloaded between MarkDirty and now + + NeighborSnapshots neighbors = this.SnapshotNeighbors(pos); + VoxelChunk snapshotChunk = ChunkStreamer.CopyChunk(chunk); // worker reads from copy + + this._inFlight++; + + IChunkMesher mesher = this._mesher; + BlockRegistry registry = this._registry; + var completed = this._completed; + + this._executor.Submit(() => + { + try + { + float[] mesh = mesher.Build(snapshotChunk, registry, (nx, ny, nz) => + neighbors.IsOpaqueOutside(nx, ny, nz, id => registry.Get(id).IsOpaque)); + completed.Enqueue(new ChunkJobResult(pos, snapshotChunk, mesh, Error: null, IsRemeshOnly: true)); + } + catch (Exception ex) + { + completed.Enqueue(new ChunkJobResult(pos, Chunk: null, Mesh: null, Error: ex, IsRemeshOnly: true)); + } + }); + } + + private static VoxelChunk CopyChunk(VoxelChunk source) + { + var copy = new VoxelChunk(); + for (int x = 0; x < VoxelChunk.Size; x++) + for (int y = 0; y < VoxelChunk.Size; y++) + for (int z = 0; z < VoxelChunk.Size; z++) + { + copy[x, y, z] = source[x, y, z]; + } + return copy; + } + private void DispatchLoad(Vector3i pos) { NeighborSnapshots neighbors = this.SnapshotNeighbors(pos);