fix(streaming): load water-surface chunks over submerged columns
This commit is contained in:
@@ -67,6 +67,19 @@ public class ChunkStreamerTests
|
|||||||
return (streamer, world, created);
|
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]
|
[Fact]
|
||||||
public void Tick_FirstCall_LoadsAllChunksInLoadBox()
|
public void Tick_FirstCall_LoadsAllChunksInLoadBox()
|
||||||
{
|
{
|
||||||
@@ -80,8 +93,9 @@ public class ChunkStreamerTests
|
|||||||
|
|
||||||
streamer.Tick(new Vector3i(0, 0, 0));
|
streamer.Tick(new Vector3i(0, 0, 0));
|
||||||
|
|
||||||
// Load box H=1, V=0 → 3 × 1 × 3 = 9 chunks
|
// H=1 → a 3×3 horizontal box of full columns. The default test surface
|
||||||
Assert.Equal(9, created.Count);
|
// (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 x = -1; x <= 1; x++)
|
||||||
for (int z = -1; z <= 1; z++)
|
for (int z = -1; z <= 1; z++)
|
||||||
{
|
{
|
||||||
@@ -205,12 +219,14 @@ public class ChunkStreamerTests
|
|||||||
};
|
};
|
||||||
var (streamer, world, created) = CreateStreamer(cfg);
|
var (streamer, world, created) = CreateStreamer(cfg);
|
||||||
|
|
||||||
streamer.Tick(new Vector3i(0, 0, 0)); // loads (0,0,0)
|
streamer.Tick(new Vector3i(0, 0, 0)); // loads the (0,*,0) column
|
||||||
Assert.Single(created);
|
|
||||||
Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0)));
|
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.True(created[0].Disposed);
|
||||||
Assert.Null(world.GetChunk(new Vector3i(0, 0, 0)));
|
Assert.Null(world.GetChunk(new Vector3i(0, 0, 0)));
|
||||||
Assert.False(streamer.IsReady(new Vector3i(0, 0, 0)));
|
Assert.False(streamer.IsReady(new Vector3i(0, 0, 0)));
|
||||||
@@ -250,14 +266,15 @@ public class ChunkStreamerTests
|
|||||||
RenderDistanceHorizontal = 1,
|
RenderDistanceHorizontal = 1,
|
||||||
SpawnReadyRadiusHorizontal = 1,
|
SpawnReadyRadiusHorizontal = 1,
|
||||||
MaxGenerateJobsInFlight = 100,
|
MaxGenerateJobsInFlight = 100,
|
||||||
MaxMeshUploadsPerFrame = 1, // throttle to expose progression
|
MaxMeshUploadsPerFrame = 100,
|
||||||
};
|
};
|
||||||
var (streamer, _, _) = CreateStreamer(cfg);
|
var (streamer, _, _) = CreateStreamer(cfg);
|
||||||
|
|
||||||
Assert.False(streamer.SpawnReady);
|
Assert.False(streamer.SpawnReady);
|
||||||
// Tick once per chunk needed (3×3×1 = 9 chunks at this Y). Spawn area
|
// The spawn area is now full columns (bedrock→waterline) per (x,z); tick
|
||||||
// restricted by Y bounds (only Y=0 valid here) → 9 ticks.
|
// until it finishes loading. Cap generously to avoid an infinite loop.
|
||||||
for (int i = 0; i < 9; i++) streamer.Tick(new Vector3i(0, 0, 0));
|
for (int i = 0; i < 200 && !streamer.SpawnReady; i++)
|
||||||
|
streamer.Tick(new Vector3i(0, 0, 0));
|
||||||
Assert.True(streamer.SpawnReady);
|
Assert.True(streamer.SpawnReady);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,17 +289,30 @@ public class ChunkStreamerTests
|
|||||||
};
|
};
|
||||||
var (streamer, _, created) = CreateStreamer(cfg);
|
var (streamer, _, created) = CreateStreamer(cfg);
|
||||||
|
|
||||||
streamer.Tick(new Vector3i(0, 0, 0));
|
// The default test surface (8) is below sea level, so (0,*,0) is a full
|
||||||
Assert.Single(created);
|
// bedrock→waterline column. Loads mark in-column vertical neighbours
|
||||||
var initialRenderer = created[0];
|
// 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.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.
|
// Exactly one extra renderer (the remesh) — old disposed, new live, live
|
||||||
Assert.Equal(2, created.Count);
|
// count unchanged.
|
||||||
Assert.True(initialRenderer.Disposed);
|
Assert.Equal(afterLoad + 1, created.Count);
|
||||||
Assert.False(created[1].Disposed);
|
Assert.Equal(liveBefore, streamer.RendererCount);
|
||||||
|
Assert.True(((FakeRenderer)initialRenderer!).Disposed);
|
||||||
|
Assert.False(created[^1].Disposed);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -312,14 +342,23 @@ public class ChunkStreamerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void MarkDirty_OnUntrackedChunk_IsNoOp()
|
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);
|
var (streamer, _, created) = CreateStreamer(cfg);
|
||||||
|
|
||||||
streamer.MarkDirty(new Vector3i(99, 0, 99));
|
// Settle the full submerged column (loads + their one-shot remesh
|
||||||
streamer.Tick(new Vector3i(0, 0, 0));
|
// 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.
|
streamer.MarkDirty(new Vector3i(99, 0, 99)); // not tracked
|
||||||
Assert.Single(created);
|
streamer.Tick(new Vector3i(0, 0, 0)); // no remesh should be queued
|
||||||
|
|
||||||
|
Assert.Equal(afterLoad, created.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -347,12 +386,13 @@ public class ChunkStreamerTests
|
|||||||
var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory);
|
var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory);
|
||||||
|
|
||||||
streamer.Tick(new Vector3i(0, 0, 0));
|
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)));
|
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));
|
streamer.Tick(new Vector3i(0, 0, 0));
|
||||||
Assert.Equal(1, generateCalls);
|
Assert.Equal(callsAfterFirst, generateCalls);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -424,17 +464,40 @@ public class ChunkStreamerTests
|
|||||||
MaxGenerateJobsInFlight = 100,
|
MaxGenerateJobsInFlight = 100,
|
||||||
MaxMeshUploadsPerFrame = 100,
|
MaxMeshUploadsPerFrame = 100,
|
||||||
};
|
};
|
||||||
// Surface at worldY 40 -> top chunk floor(40/16) = 2. Full column loads
|
// Land surface at worldY 296 -> top chunk floor(296/16) = 18 (above the
|
||||||
// cy 0,1,2; chunk Y 3+ is air above the surface and must NOT load.
|
// sea-level chunk 16, so no water raises it). Full column loads cy 0..18;
|
||||||
var (streamer, world, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => 40);
|
// 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));
|
streamer.Tick(new Vector3i(0, 0, 0));
|
||||||
|
|
||||||
Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0))); // bedrock
|
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, 9, 0))); // mid
|
||||||
Assert.NotNull(world.GetChunk(new Vector3i(0, 2, 0))); // surface
|
Assert.NotNull(world.GetChunk(new Vector3i(0, 18, 0))); // surface
|
||||||
Assert.Null(world.GetChunk(new Vector3i(0, 3, 0))); // air above surface
|
Assert.Null(world.GetChunk(new Vector3i(0, 19, 0))); // air above surface
|
||||||
Assert.Null(world.GetChunk(new Vector3i(0, 5, 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]
|
[Fact]
|
||||||
@@ -454,17 +517,18 @@ public class ChunkStreamerTests
|
|||||||
Func<float[], IChunkRenderer> factory = m => new FakeRenderer(m);
|
Func<float[], IChunkRenderer> factory = m => new FakeRenderer(m);
|
||||||
Func<Vector3i, VoxelChunk> generate = _ => new VoxelChunk();
|
Func<Vector3i, VoxelChunk> generate = _ => new VoxelChunk();
|
||||||
|
|
||||||
// Surface at worldY 8 -> top chunk 0. A player edit sits in chunk Y 5,
|
// Land surface at worldY 296 -> top chunk 18 (above the sea-level chunk
|
||||||
// far above the surface; that edited section must still load.
|
// 16, so no water raises it). A player edit sits in chunk Y 25, above the
|
||||||
var edited = new Vector3i(0, 5, 0);
|
// loaded column; that edited section must still load.
|
||||||
|
var edited = new Vector3i(0, 25, 0);
|
||||||
store.RecordEdit(edited, 0, registry.Stone.Id);
|
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));
|
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.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]
|
[Fact]
|
||||||
|
|||||||
@@ -175,8 +175,9 @@ public sealed class ChunkStreamer : IDisposable
|
|||||||
// Fills `into` with the chunk positions to load for a viewer at chunk
|
// Fills `into` with the chunk positions to load for a viewer at chunk
|
||||||
// `center`, over a square horizontal region of Chebyshev radius `radiusH`.
|
// `center`, over a square horizontal region of Chebyshev radius `radiusH`.
|
||||||
// Per column the full vertical stack from bedrock (cy 0) up to the
|
// 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
|
// surface-top chunk (clamped up to the sea-level chunk so submerged columns
|
||||||
// player edits (e.g. a built tower).
|
// 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)
|
private void CollectTargets(Vector3i center, int radiusH, HashSet<Vector3i> into)
|
||||||
{
|
{
|
||||||
for (int dx = -radiusH; dx <= radiusH; dx++)
|
for (int dx = -radiusH; dx <= radiusH; dx++)
|
||||||
@@ -185,7 +186,13 @@ public sealed class ChunkStreamer : IDisposable
|
|||||||
int cx = center.X + dx;
|
int cx = center.X + dx;
|
||||||
int cz = center.Z + dz;
|
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);
|
ChunkStreamer.AddColumn(into, cx, cz, 0, surfaceTop);
|
||||||
|
|
||||||
int clampedTop = Math.Min(WorldConstants.WorldHeightChunks - 1, surfaceTop);
|
int clampedTop = Math.Min(WorldConstants.WorldHeightChunks - 1, surfaceTop);
|
||||||
|
|||||||
Reference in New Issue
Block a user