feat: ChunkStreamer unload path and SpawnReady property
This commit is contained in:
@@ -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<float[], IChunkRenderer> factory = m => new FakeRenderer(m);
|
||||
Func<Vector3i, VoxelChunk> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,17 @@ public sealed class ChunkStreamer : IDisposable
|
||||
private readonly HashSet<Vector3i> _ready = new();
|
||||
private readonly PriorityQueue<Vector3i, int> _pendingLoad = new();
|
||||
private readonly ConcurrentQueue<ChunkJobResult> _completed = new();
|
||||
private readonly Queue<Vector3i> _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<Vector3i, IChunkRenderer> drawCallback)
|
||||
@@ -82,25 +89,49 @@ 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<Vector3i>();
|
||||
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;
|
||||
|
||||
this._tracked.Add(pos);
|
||||
int priority = dx * dx + dy * dy + dz * dz;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Anything currently tracked but no longer in target → unload.
|
||||
var toRemove = new List<Vector3i>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrainCompleted()
|
||||
{
|
||||
int budget = this._cfg.MaxMeshUploadsPerFrame;
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user