From 96b8fffcd4686e06adf93704ef92802c41f8a6d6 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Wed, 3 Jun 2026 16:26:40 +0200 Subject: [PATCH] fix(streaming): clear loading screen once the spawn surface band is ready UpdateSpawnReady required every chunk in the spawn columns down to bedrock. With full-column loading, those deep chunks carry the lowest load priority in the whole render volume (dy^2 * VerticalPriorityWeight), so they stream in last -- the loading screen waited for nearly the entire render distance to generate (minutes on a tall mountain spawn, appearing as a hang). Gate readiness on just the near-surface band (surfaceTop-2 .. surfaceTop) of the spawn area instead. Those are the highest-priority chunks, so spawn-ready now flips in ~350ms. Deep chunks are occluded at spawn and stream in afterwards. Extracts a SurfaceTopChunk helper shared with CollectTargets. --- World/Streaming/ChunkStreamer.cs | 94 ++++++++++++++++++++------------ 1 file changed, 58 insertions(+), 36 deletions(-) diff --git a/World/Streaming/ChunkStreamer.cs b/World/Streaming/ChunkStreamer.cs index 04618c5..ab188e0 100644 --- a/World/Streaming/ChunkStreamer.cs +++ b/World/Streaming/ChunkStreamer.cs @@ -14,6 +14,10 @@ public sealed class ChunkStreamer : IDisposable { private static readonly ILogger Log = Serilog.Log.ForContext(); + // How many chunks below the surface the spawn area must load before the + // loading screen clears (just enough to stand on and see; the rest streams in). + private const int SpawnReadyDepthChunks = 2; + private readonly Configuration _cfg; private readonly IWorld _world; private readonly Func _generate; @@ -101,7 +105,7 @@ public sealed class ChunkStreamer : IDisposable public int DrawVisible(Func isChunkVisible, Action draw) { int drawn = 0; - foreach (var kvp in this._renderers) + foreach (KeyValuePair kvp in this._renderers) { if (!isChunkVisible(kvp.Key)) continue; draw(kvp.Key, kvp.Value); @@ -112,7 +116,7 @@ public sealed class ChunkStreamer : IDisposable public void Dispose() { - foreach (var r in this._renderers.Values) r.Dispose(); + foreach (IChunkRenderer r in this._renderers.Values) r.Dispose(); this._renderers.Clear(); } @@ -124,10 +128,10 @@ public sealed class ChunkStreamer : IDisposable this._spawnAnchorSet = true; } - var target = new HashSet(); + HashSet target = new(); this.CollectTargets(p, this._cfg.RenderDistanceHorizontal, target); - foreach (var pos in target) + foreach (Vector3i pos in target) { if (this._failed.Contains(pos)) continue; if (this._tracked.Add(pos)) @@ -143,12 +147,12 @@ public sealed class ChunkStreamer : IDisposable } // Anything currently tracked but no longer in target → unload. - var toRemove = new List(); - foreach (var tracked in this._tracked) + List toRemove = new(); + foreach (Vector3i tracked in this._tracked) { if (!target.Contains(tracked)) toRemove.Add(tracked); } - foreach (var pos in toRemove) + foreach (Vector3i pos in toRemove) { this._tracked.Remove(pos); this._pendingUnload.Enqueue(pos); @@ -163,13 +167,13 @@ public sealed class ChunkStreamer : IDisposable { int keep = this._cfg.RenderDistanceHorizontal + 1; List<(int cx, int cz)>? stale = null; - foreach (var key in this._surfaceTopCache.Keys) + foreach ((int cx, int cz) key in this._surfaceTopCache.Keys) { if (Math.Abs(key.cx - p.X) > keep || Math.Abs(key.cz - p.Z) > keep) (stale ??= new()).Add(key); } if (stale != null) - foreach (var key in stale) this._surfaceTopCache.Remove(key); + foreach ((int cx, int cz) key in stale) this._surfaceTopCache.Remove(key); } // Fills `into` with the chunk positions to load for a viewer at chunk @@ -186,19 +190,14 @@ public sealed class ChunkStreamer : IDisposable int cx = center.X + dx; int cz = center.Z + dz; - // Clamp the column top up to at least the sea-level chunk: where the - // terrain surface is below sea level, WorldGenerator fills opaque water - // from the surface up to SeaLevel, so the column must load through the - // waterline or the submerged water surface renders as see-through holes. - int surfaceTop = Math.Max( - ChunkMath.FloorDiv(this.SurfaceTopHeight(cx, cz), VoxelChunk.Size), - ChunkMath.FloorDiv(WorldConstants.SeaLevel, VoxelChunk.Size)); - ChunkStreamer.AddColumn(into, cx, cz, 0, surfaceTop); + int top = this.SurfaceTopChunk(cx, cz); + AddColumn(into, cx, cz, 0, top); - int clampedTop = Math.Min(WorldConstants.WorldHeightChunks - 1, surfaceTop); - for (int cy = clampedTop + 1; cy < WorldConstants.WorldHeightChunks; cy++) + // Player-built structures can rise above the surface; load any edited + // section above the column top so they remain visible. + for (int cy = top + 1; cy < WorldConstants.WorldHeightChunks; cy++) { - var pos = new Vector3i(cx, cy, cz); + Vector3i pos = new(cx, cy, cz); if (this._world.ModificationStore.HasDiff(pos)) into.Add(pos); } } @@ -212,6 +211,16 @@ public sealed class ChunkStreamer : IDisposable into.Add(new Vector3i(cx, cy, cz)); } + // Chunk Y of a column's loaded top: the terrain surface chunk, raised to the + // sea-level (waterline) chunk for submerged columns, and clamped to the world + // ceiling. The bottom of a loaded column is always bedrock (cy 0). + private int SurfaceTopChunk(int cx, int cz) + => Math.Min( + WorldConstants.WorldHeightChunks - 1, + Math.Max( + ChunkMath.FloorDiv(this.SurfaceTopHeight(cx, cz), VoxelChunk.Size), + ChunkMath.FloorDiv(WorldConstants.SeaLevel, VoxelChunk.Size))); + // Max terrain surface height across a chunk column's 16x16 footprint, // sampled at the four corners and the centre (cheap bound on the column's // top). Cached per column. The bottom of the loaded stack is always bedrock, @@ -264,17 +273,30 @@ public sealed class ChunkStreamer : IDisposable } } + // The loading screen waits on this. It only needs the VISIBLE surface around + // spawn to be ready — gating on the full bedrock column would block on the + // deepest chunks, which carry the lowest load priority in the whole render + // volume and stream in last (minutes for tall mountain spawn columns). Those + // deep chunks are occluded at spawn and arrive shortly after. private void UpdateSpawnReady() { if (this._spawnReady || !this._spawnAnchorSet) return; - var spawnTargets = new HashSet(); - this.CollectTargets(this._spawnAnchor, this._cfg.SpawnReadyRadiusHorizontal, spawnTargets); - - foreach (var pos in spawnTargets) + int hs = this._cfg.SpawnReadyRadiusHorizontal; + Vector3i a = this._spawnAnchor; + for (int dx = -hs; dx <= hs; dx++) + for (int dz = -hs; dz <= hs; dz++) { - if (this._failed.Contains(pos)) continue; - if (!this._ready.Contains(pos)) return; + int cx = a.X + dx; + int cz = a.Z + dz; + int top = this.SurfaceTopChunk(cx, cz); + int bottom = Math.Max(0, top - SpawnReadyDepthChunks); + for (int cy = bottom; cy <= top; cy++) + { + Vector3i pos = new(cx, cy, cz); + if (this._failed.Contains(pos)) continue; + if (!this._ready.Contains(pos)) return; + } } this._spawnReady = true; @@ -330,9 +352,9 @@ public sealed class ChunkStreamer : IDisposable new(0, -1, 0), new(0, 1, 0), new(0, 0, -1), new(0, 0, 1), }; - foreach (var off in offsets) + foreach (Vector3i off in offsets) { - var neighbor = pos + off; + Vector3i neighbor = pos + off; if (this._ready.Contains(neighbor)) this._dirty.Add(neighbor); } } @@ -360,13 +382,13 @@ public sealed class ChunkStreamer : IDisposable if (chunk == null) return; // got unloaded between MarkDirty and now NeighborSnapshots neighbors = this.SnapshotNeighbors(pos); - VoxelChunk snapshotChunk = ChunkStreamer.CopyChunk(chunk); // worker reads from copy + VoxelChunk snapshotChunk = CopyChunk(chunk); // worker reads from copy this._inFlight++; IChunkMesher mesher = this._mesher; BlockRegistry registry = this._registry; - var completed = this._completed; + ConcurrentQueue completed = this._completed; this._executor.Submit(() => { @@ -385,7 +407,7 @@ public sealed class ChunkStreamer : IDisposable private static VoxelChunk CopyChunk(VoxelChunk source) { - var copy = new VoxelChunk(); + VoxelChunk copy = new(); for (int x = 0; x < VoxelChunk.Size; x++) for (int y = 0; y < VoxelChunk.Size; y++) for (int z = 0; z < VoxelChunk.Size; z++) @@ -400,13 +422,13 @@ public sealed class ChunkStreamer : IDisposable NeighborSnapshots neighbors = this.SnapshotNeighbors(pos); this._world.ModificationStore.TryGetDiff(pos, out IReadOnlyDictionary? diff); - var job = new ChunkJob(pos, neighbors, diff); + ChunkJob job = new(pos, neighbors, diff); this._inFlight++; IChunkMesher mesher = this._mesher; BlockRegistry registry = this._registry; - var generate = this._generate; - var completed = this._completed; + Func generate = this._generate; + ConcurrentQueue completed = this._completed; this._executor.Submit(() => { @@ -415,7 +437,7 @@ public sealed class ChunkStreamer : IDisposable VoxelChunk chunk = generate(job.Pos); if (job.Diff != null) { - foreach (var (idx, id) in job.Diff) + foreach ((int idx, ushort id) in job.Diff) { int lx = idx / (VoxelChunk.Size * VoxelChunk.Size); int ly = (idx / VoxelChunk.Size) % VoxelChunk.Size; @@ -442,7 +464,7 @@ public sealed class ChunkStreamer : IDisposable { VoxelChunk? c = this._world.GetChunk(p); if (c == null) return null; - var copy = new ushort[VoxelChunk.Size * VoxelChunk.Size * VoxelChunk.Size]; + ushort[] copy = new ushort[VoxelChunk.Size * VoxelChunk.Size * VoxelChunk.Size]; for (int x = 0; x < VoxelChunk.Size; x++) for (int y = 0; y < VoxelChunk.Size; y++) for (int z = 0; z < VoxelChunk.Size; z++)