fix(streaming): load water-surface chunks over submerged columns

This commit is contained in:
MDietermann
2026-06-03 14:13:26 +02:00
parent 225c6f7253
commit 102d8166aa
2 changed files with 113 additions and 42 deletions

View File

@@ -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<FakeRenderer> 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<float[], IChunkRenderer> factory = m => new FakeRenderer(m);
Func<Vector3i, VoxelChunk> 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]

View File

@@ -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<Vector3i> 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);