diff --git a/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs b/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs index 85d3d62..db8a6f8 100644 --- a/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs +++ b/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs @@ -170,4 +170,74 @@ public class ChunkStreamerTests Assert.Equal(registry.Stone.Id, world.GetBlock(5, 6, 7).Id); } + + [Fact] + public void Tick_PlayerMovesAway_UnloadsOldChunksAndDisposesRenderer() + { + var cfg = new Configuration + { + RenderDistanceHorizontal = 0, + RenderDistanceVertical = 0, + MaxGenerateJobsInFlight = 100, + MaxMeshUploadsPerFrame = 100, + }; + var (streamer, world, created) = CreateStreamer(cfg); + + streamer.Tick(new Vector3i(0, 0, 0)); // loads (0,0,0) + Assert.Single(created); + Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0))); + + streamer.Tick(new Vector3i(5, 0, 0)); // (0,0,0) now out of range; (5,0,0) loaded + + Assert.True(created[0].Disposed); + Assert.Null(world.GetChunk(new Vector3i(0, 0, 0))); + Assert.False(streamer.IsReady(new Vector3i(0, 0, 0))); + } + + [Fact] + public void Tick_UnloadKeepsModificationStoreDiff() + { + var cfg = new Configuration + { + RenderDistanceHorizontal = 0, + RenderDistanceVertical = 0, + MaxGenerateJobsInFlight = 100, + MaxMeshUploadsPerFrame = 100, + }; + var registry = new BlockRegistry(); + var store = new InMemoryChunkModificationStore(); + var world = new World.World(registry, store); + var mesher = new FakeMesher(); + var executor = new SynchronousJobExecutor(); + Func factory = m => new FakeRenderer(m); + Func generate = _ => new VoxelChunk(); + var streamer = new ChunkStreamer(cfg, world, generate, mesher, registry, executor, factory); + + var pos = new Vector3i(0, 0, 0); + streamer.Tick(pos); + store.RecordEdit(pos, 0, registry.Stone.Id); + + streamer.Tick(new Vector3i(99, 0, 0)); // unloads (0,0,0) + Assert.True(store.HasDiff(pos)); // diff survives unload + } + + [Fact] + public void SpawnReady_FlipsTrueOnceSpawnAreaLoaded() + { + var cfg = new Configuration + { + RenderDistanceHorizontal = 1, + RenderDistanceVertical = 0, + SpawnReadyRadiusHorizontal = 1, + MaxGenerateJobsInFlight = 100, + MaxMeshUploadsPerFrame = 1, // throttle to expose progression + }; + var (streamer, _, _) = CreateStreamer(cfg); + + Assert.False(streamer.SpawnReady); + // Tick once per chunk needed (3×3×1 = 9 chunks at this Y). Spawn area + // restricted by Y bounds (only Y=0 valid here) → 9 ticks. + for (int i = 0; i < 9; i++) streamer.Tick(new Vector3i(0, 0, 0)); + Assert.True(streamer.SpawnReady); + } } diff --git a/World/Streaming/ChunkStreamer.cs b/World/Streaming/ChunkStreamer.cs index 3eadc0a..ff328ff 100644 --- a/World/Streaming/ChunkStreamer.cs +++ b/World/Streaming/ChunkStreamer.cs @@ -26,12 +26,17 @@ public sealed class ChunkStreamer : IDisposable private readonly HashSet _ready = new(); private readonly PriorityQueue _pendingLoad = new(); private readonly ConcurrentQueue _completed = new(); + private readonly Queue _pendingUnload = new(); private Vector3i _lastPlayerChunk = new(int.MinValue, int.MinValue, int.MinValue); private int _inFlight; + private bool _spawnReady; + private bool _spawnAnchorSet; + private Vector3i _spawnAnchor; public int RendererCount => this._renderers.Count; public bool IsReady(Vector3i pos) => this._ready.Contains(pos); + public bool SpawnReady => this._spawnReady; public ChunkStreamer( Configuration cfg, @@ -59,14 +64,16 @@ public sealed class ChunkStreamer : IDisposable this._lastPlayerChunk = playerChunkPos; } - // Dispatch BEFORE drain: with a synchronous executor (tests) work - // completes before Submit() returns, so we want to pick up those - // results in this same tick. With the production threadpool executor, - // jobs don't finish synchronously — Drain harvests whatever previous - // ticks' workers completed in the meantime. Either way, this order is - // correct. + // Unloads first to free in-flight slots, then dispatch BEFORE drain: + // 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. + this.ProcessUnloads(); this.DispatchJobs(); this.DrainCompleted(); + this.UpdateSpawnReady(); } public void DrawAll(Action drawCallback) @@ -82,22 +89,46 @@ public sealed class ChunkStreamer : IDisposable private void RecomputeTargetSet(Vector3i p) { + if (!this._spawnAnchorSet) + { + this._spawnAnchor = p; + this._spawnAnchorSet = true; + } + int h = this._cfg.RenderDistanceHorizontal; int v = this._cfg.RenderDistanceVertical; + var target = new HashSet(); for (int dx = -h; dx <= h; dx++) for (int dy = -v; dy <= v; dy++) for (int dz = -h; dz <= h; dz++) { int cy = p.Y + dy; if (cy < 0 || cy >= WorldConstants.WorldHeightChunks) continue; + target.Add(new Vector3i(p.X + dx, cy, p.Z + dz)); + } - var pos = new Vector3i(p.X + dx, cy, p.Z + dz); - if (this._tracked.Contains(pos)) continue; + foreach (var pos in target) + { + if (this._tracked.Add(pos)) + { + int priority = (pos.X - p.X) * (pos.X - p.X) + + (pos.Y - p.Y) * (pos.Y - p.Y) + + (pos.Z - p.Z) * (pos.Z - p.Z); + this._pendingLoad.Enqueue(pos, priority); + } + } - this._tracked.Add(pos); - int priority = dx * dx + dy * dy + dz * dz; - this._pendingLoad.Enqueue(pos, priority); + // Anything currently tracked but no longer in target → unload. + var toRemove = new List(); + foreach (var tracked in this._tracked) + { + if (!target.Contains(tracked)) toRemove.Add(tracked); + } + foreach (var pos in toRemove) + { + this._tracked.Remove(pos); + this._pendingUnload.Enqueue(pos); } } @@ -112,6 +143,41 @@ public sealed class ChunkStreamer : IDisposable } } + private void ProcessUnloads() + { + while (this._pendingUnload.TryDequeue(out Vector3i pos)) + { + if (this._renderers.TryGetValue(pos, out IChunkRenderer? renderer)) + { + renderer.Dispose(); + this._renderers.Remove(pos); + } + this._world.RemoveChunk(pos); + this._ready.Remove(pos); + Log.Debug("Unloaded {Pos}", pos); + } + } + + private void UpdateSpawnReady() + { + if (this._spawnReady || !this._spawnAnchorSet) return; + + int hs = this._cfg.SpawnReadyRadiusHorizontal; + int v = this._cfg.RenderDistanceVertical; + Vector3i a = this._spawnAnchor; + + for (int dx = -hs; dx <= hs; dx++) + for (int dy = -v; dy <= v; dy++) + for (int dz = -hs; dz <= hs; dz++) + { + int cy = a.Y + dy; + if (cy < 0 || cy >= WorldConstants.WorldHeightChunks) continue; + if (!this._ready.Contains(new Vector3i(a.X + dx, cy, a.Z + dz))) return; + } + this._spawnReady = true; + Log.Information("Spawn area ready at anchor {Anchor}", a); + } + private void InstallResult(ChunkJobResult result) { if (result.Error != null || result.Mesh == null || result.Chunk == null)