feat: ChunkStreamer dirty set with cardinal-neighbor remesh on load

This commit is contained in:
2026-06-01 11:40:55 +02:00
parent 7d06706400
commit da051ed413
2 changed files with 171 additions and 9 deletions

View File

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

View File

@@ -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<Vector3i, IChunkRenderer> _renderers = new();
private readonly HashSet<Vector3i> _tracked = new();
private readonly HashSet<Vector3i> _ready = new();
private readonly HashSet<Vector3i> _dirty = new();
private readonly PriorityQueue<Vector3i, int> _pendingLoad = new();
private readonly ConcurrentQueue<ChunkJobResult> _completed = new();
private readonly Queue<Vector3i> _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;
}
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);
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);