From 102d8166aa06778c5469152c7f98cccc852dbd9c Mon Sep 17 00:00:00 2001 From: MDietermann Date: Wed, 3 Jun 2026 14:13:26 +0200 Subject: [PATCH] fix(streaming): load water-surface chunks over submerged columns --- .../Streaming/ChunkStreamerTests.cs | 142 +++++++++++++----- World/Streaming/ChunkStreamer.cs | 13 +- 2 files changed, 113 insertions(+), 42 deletions(-) diff --git a/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs b/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs index 84aae4c..91dae7f 100644 --- a/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs +++ b/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs @@ -67,6 +67,19 @@ public class ChunkStreamerTests return (streamer, world, created); } + // Ticks at a fixed player position until the streamer stops producing new + // renderers (queued loads + their one-shot in-column remesh cascade have all + // drained). Capped to avoid an infinite loop if something never settles. + private static void TickToQuiescence(ChunkStreamer streamer, List created, Vector3i player) + { + int last = -1; + for (int i = 0; i < 200 && created.Count != last; i++) + { + last = created.Count; + streamer.Tick(player); + } + } + [Fact] public void Tick_FirstCall_LoadsAllChunksInLoadBox() { @@ -80,8 +93,9 @@ public class ChunkStreamerTests streamer.Tick(new Vector3i(0, 0, 0)); - // Load box H=1, V=0 → 3 × 1 × 3 = 9 chunks - Assert.Equal(9, created.Count); + // H=1 → a 3×3 horizontal box of full columns. The default test surface + // (8) is below sea level, so each column loads bedrock→waterline; assert + // the box is covered at the player's chunk Y rather than a raw count. for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) { @@ -205,12 +219,14 @@ public class ChunkStreamerTests }; var (streamer, world, created) = CreateStreamer(cfg); - streamer.Tick(new Vector3i(0, 0, 0)); // loads (0,0,0) - Assert.Single(created); + streamer.Tick(new Vector3i(0, 0, 0)); // loads the (0,*,0) column Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0))); + Assert.NotEmpty(created); - streamer.Tick(new Vector3i(5, 0, 0)); // (0,0,0) now out of range; (5,0,0) loaded + streamer.Tick(new Vector3i(5, 0, 0)); // (0,*,0) now out of range → unloaded + // The player chunk (priority 0) loaded first, so created[0] is its + // renderer; moving away must dispose it and drop the chunk. Assert.True(created[0].Disposed); Assert.Null(world.GetChunk(new Vector3i(0, 0, 0))); Assert.False(streamer.IsReady(new Vector3i(0, 0, 0))); @@ -250,14 +266,15 @@ public class ChunkStreamerTests RenderDistanceHorizontal = 1, SpawnReadyRadiusHorizontal = 1, MaxGenerateJobsInFlight = 100, - MaxMeshUploadsPerFrame = 1, // throttle to expose progression + MaxMeshUploadsPerFrame = 100, }; 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)); + // The spawn area is now full columns (bedrock→waterline) per (x,z); tick + // until it finishes loading. Cap generously to avoid an infinite loop. + for (int i = 0; i < 200 && !streamer.SpawnReady; i++) + streamer.Tick(new Vector3i(0, 0, 0)); Assert.True(streamer.SpawnReady); } @@ -272,17 +289,30 @@ public class ChunkStreamerTests }; var (streamer, _, created) = CreateStreamer(cfg); - streamer.Tick(new Vector3i(0, 0, 0)); - Assert.Single(created); - var initialRenderer = created[0]; + // The default test surface (8) is below sea level, so (0,*,0) is a full + // bedrock→waterline column. Loads mark in-column vertical neighbours + // dirty, so tick to quiescence (loads + the one-shot remesh cascade) + // before measuring. Remeshes do NOT re-dirty neighbours, so it settles. + TickToQuiescence(streamer, created, new Vector3i(0, 0, 0)); + int afterLoad = created.Count; + int liveBefore = streamer.RendererCount; + + // Capture the live renderer currently installed for (0,0,0). + IChunkRenderer? initialRenderer = null; + streamer.DrawVisible( + pos => pos == new Vector3i(0, 0, 0), + (_, r) => initialRenderer = r); + Assert.NotNull(initialRenderer); streamer.MarkDirty(new Vector3i(0, 0, 0)); - streamer.Tick(new Vector3i(0, 0, 0)); // same chunk, but a remesh is queued + streamer.Tick(new Vector3i(0, 0, 0)); // exactly one remesh queued (no cascade) - // Two renderers exist over the lifetime — old disposed, new live. - Assert.Equal(2, created.Count); - Assert.True(initialRenderer.Disposed); - Assert.False(created[1].Disposed); + // Exactly one extra renderer (the remesh) — old disposed, new live, live + // count unchanged. + Assert.Equal(afterLoad + 1, created.Count); + Assert.Equal(liveBefore, streamer.RendererCount); + Assert.True(((FakeRenderer)initialRenderer!).Disposed); + Assert.False(created[^1].Disposed); } [Fact] @@ -312,14 +342,23 @@ public class ChunkStreamerTests [Fact] public void MarkDirty_OnUntrackedChunk_IsNoOp() { - var cfg = new Configuration { RenderDistanceHorizontal = 0 }; + var cfg = new Configuration + { + RenderDistanceHorizontal = 0, + MaxGenerateJobsInFlight = 100, + MaxMeshUploadsPerFrame = 100, + }; var (streamer, _, created) = CreateStreamer(cfg); - streamer.MarkDirty(new Vector3i(99, 0, 99)); - streamer.Tick(new Vector3i(0, 0, 0)); + // Settle the full submerged column (loads + their one-shot remesh + // cascade) so the renderer count is stable before the no-op check. + TickToQuiescence(streamer, created, new Vector3i(0, 0, 0)); + int afterLoad = created.Count; - // Only the chunk in the load box was created; no spurious remesh. - Assert.Single(created); + streamer.MarkDirty(new Vector3i(99, 0, 99)); // not tracked + streamer.Tick(new Vector3i(0, 0, 0)); // no remesh should be queued + + Assert.Equal(afterLoad, created.Count); } [Fact] @@ -347,12 +386,13 @@ public class ChunkStreamerTests var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory); streamer.Tick(new Vector3i(0, 0, 0)); - Assert.Equal(1, generateCalls); + int callsAfterFirst = generateCalls; + Assert.True(callsAfterFirst > 0); Assert.False(streamer.IsReady(new Vector3i(0, 0, 0))); - // Tick again — same chunk, same position. Should NOT call generate again. + // Tick again — same position. Failed chunks must not be retried. streamer.Tick(new Vector3i(0, 0, 0)); - Assert.Equal(1, generateCalls); + Assert.Equal(callsAfterFirst, generateCalls); } [Fact] @@ -424,17 +464,40 @@ public class ChunkStreamerTests MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; - // Surface at worldY 40 -> top chunk floor(40/16) = 2. Full column loads - // cy 0,1,2; chunk Y 3+ is air above the surface and must NOT load. - var (streamer, world, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => 40); + // Land surface at worldY 296 -> top chunk floor(296/16) = 18 (above the + // sea-level chunk 16, so no water raises it). Full column loads cy 0..18; + // chunk Y 19+ is air above the surface and must NOT load. + var (streamer, world, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => 296); streamer.Tick(new Vector3i(0, 0, 0)); - Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0))); // bedrock - Assert.NotNull(world.GetChunk(new Vector3i(0, 1, 0))); // mid - Assert.NotNull(world.GetChunk(new Vector3i(0, 2, 0))); // surface - Assert.Null(world.GetChunk(new Vector3i(0, 3, 0))); // air above surface - Assert.Null(world.GetChunk(new Vector3i(0, 5, 0))); // air above surface + Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0))); // bedrock + Assert.NotNull(world.GetChunk(new Vector3i(0, 9, 0))); // mid + Assert.NotNull(world.GetChunk(new Vector3i(0, 18, 0))); // surface + Assert.Null(world.GetChunk(new Vector3i(0, 19, 0))); // air above surface + Assert.Null(world.GetChunk(new Vector3i(0, 25, 0))); // air above surface + } + + [Fact] + public void Tick_LoadsWaterSurface_OverSubmergedColumn() + { + var cfg = new Configuration + { + RenderDistanceHorizontal = 0, // single column at the player + MaxGenerateJobsInFlight = 100, + MaxMeshUploadsPerFrame = 100, + }; + // Terrain surface at worldY 100 (chunk 6) but well below SeaLevel 256. + // Opaque water fills worldY 101..256, so the column must load through the + // sea-level chunk floor(256/16)=16, not stop at the terrain top (chunk 6). + var (streamer, world, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => 100); + + streamer.Tick(new Vector3i(0, 0, 0)); + + Assert.NotNull(world.GetChunk(new Vector3i(0, 6, 0))); // terrain top + Assert.NotNull(world.GetChunk(new Vector3i(0, 10, 0))); // submerged water column + Assert.NotNull(world.GetChunk(new Vector3i(0, 16, 0))); // chunk holding the waterline (y=256) + Assert.Null(world.GetChunk(new Vector3i(0, 17, 0))); // above the waterline -- not loaded } [Fact] @@ -454,17 +517,18 @@ public class ChunkStreamerTests Func factory = m => new FakeRenderer(m); Func generate = _ => new VoxelChunk(); - // Surface at worldY 8 -> top chunk 0. A player edit sits in chunk Y 5, - // far above the surface; that edited section must still load. - var edited = new Vector3i(0, 5, 0); + // Land surface at worldY 296 -> top chunk 18 (above the sea-level chunk + // 16, so no water raises it). A player edit sits in chunk Y 25, above the + // loaded column; that edited section must still load. + var edited = new Vector3i(0, 25, 0); store.RecordEdit(edited, 0, registry.Stone.Id); - var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory); + var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 296, mesher, registry, executor, factory); streamer.Tick(new Vector3i(0, 0, 0)); - Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0))); // surface column + Assert.NotNull(world.GetChunk(new Vector3i(0, 18, 0))); // surface column top Assert.NotNull(world.GetChunk(edited)); // edited air section above surface - Assert.Null(world.GetChunk(new Vector3i(0, 3, 0))); // unedited air -- not loaded + Assert.Null(world.GetChunk(new Vector3i(0, 20, 0))); // unedited air -- not loaded } [Fact] diff --git a/World/Streaming/ChunkStreamer.cs b/World/Streaming/ChunkStreamer.cs index 0965fa7..04618c5 100644 --- a/World/Streaming/ChunkStreamer.cs +++ b/World/Streaming/ChunkStreamer.cs @@ -175,8 +175,9 @@ public sealed class ChunkStreamer : IDisposable // Fills `into` with the chunk positions to load for a viewer at chunk // `center`, over a square horizontal region of Chebyshev radius `radiusH`. // Per column the full vertical stack from bedrock (cy 0) up to the - // surface-top chunk is loaded, plus any section above the surface holding - // player edits (e.g. a built tower). + // surface-top chunk (clamped up to the sea-level chunk so submerged columns + // load their opaque-water surface) is loaded, plus any section above that + // holding player edits (e.g. a built tower). private void CollectTargets(Vector3i center, int radiusH, HashSet into) { for (int dx = -radiusH; dx <= radiusH; dx++) @@ -185,7 +186,13 @@ public sealed class ChunkStreamer : IDisposable int cx = center.X + dx; int cz = center.Z + dz; - int surfaceTop = ChunkMath.FloorDiv(this.SurfaceTopHeight(cx, cz), VoxelChunk.Size); + // 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 clampedTop = Math.Min(WorldConstants.WorldHeightChunks - 1, surfaceTop);