feat: ChunkStreamer dirty set with cardinal-neighbor remesh on load
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user