From 225c6f7253f628eadf59ca31b7d671798d259e02 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Wed, 3 Jun 2026 08:01:38 +0200 Subject: [PATCH] feat(streaming): load full column bedrock->surface to fix terrain holes --- Core/Configuration.cs | 11 --- Core/Game.cs | 4 +- VoxelEngine.Tests/ConfigurationTests.cs | 4 - .../Streaming/ChunkStreamerTests.cs | 90 +++++++++++-------- .../Streaming/StreamingIntegrationTests.cs | 2 - World/Streaming/ChunkStreamer.cs | 63 +++++++------ 6 files changed, 87 insertions(+), 87 deletions(-) diff --git a/Core/Configuration.cs b/Core/Configuration.cs index f2d55b0..6920bc6 100644 --- a/Core/Configuration.cs +++ b/Core/Configuration.cs @@ -5,17 +5,6 @@ using OpenTK.Mathematics; public sealed class Configuration { public int RenderDistanceHorizontal { get; set; } = 8; - // Player-slab half-height in chunks. NOTE: repurposed by the visibility - // rework — this is no longer a global vertical load-box. It is how many - // chunks load above and below the player's chunk across the whole - // horizontal region, so caves at the player's depth render to the full - // horizontal distance. - public int RenderDistanceVertical { get; set; } = 2; - - // Chunks loaded below the lowest surface chunk of each column. Gives the - // surface chunk a solid neighbor below (so its buried bottom faces cull) - // and covers cave mouths just under the surface. - public int SurfaceShellSkirtBelow { get; set; } = 1; // Distance fog begins at this fraction of the fog-end distance (fog end is // derived from RenderDistanceHorizontal). 0.7 → fog starts at 70% of the edge. diff --git a/Core/Game.cs b/Core/Game.cs index bf7ba80..d8b1006 100644 --- a/Core/Game.cs +++ b/Core/Game.cs @@ -41,8 +41,8 @@ public sealed class Game : GameWindow { base.OnLoad(); - Log.Information("Game starting with seed={Seed} renderDistanceH={H} renderDistanceV={V}", - this._cfg.Seed, this._cfg.RenderDistanceHorizontal, this._cfg.RenderDistanceVertical); + Log.Information("Game starting with seed={Seed} renderDistanceH={H}", + this._cfg.Seed, this._cfg.RenderDistanceHorizontal); GL.ClearColor(this._cfg.SkyColor.X, this._cfg.SkyColor.Y, this._cfg.SkyColor.Z, 1.0f); GL.Enable(EnableCap.DepthTest); diff --git a/VoxelEngine.Tests/ConfigurationTests.cs b/VoxelEngine.Tests/ConfigurationTests.cs index 7efb95d..195333b 100644 --- a/VoxelEngine.Tests/ConfigurationTests.cs +++ b/VoxelEngine.Tests/ConfigurationTests.cs @@ -10,7 +10,6 @@ public class ConfigurationTests { var c = new Configuration(); Assert.True(c.RenderDistanceHorizontal >= 1); - Assert.True(c.RenderDistanceVertical >= 1); Assert.True(c.SpawnReadyRadiusHorizontal >= 0); Assert.True(c.MaxGenerateJobsInFlight >= 1); Assert.True(c.MaxMeshUploadsPerFrame >= 1); @@ -32,7 +31,6 @@ public class ConfigurationTests var c2 = new Configuration { - RenderDistanceVertical = 2, SpawnReadyRadiusHorizontal = 0, MaxGenerateJobsInFlight = 2, MaxMeshUploadsPerFrame = 1, @@ -43,7 +41,6 @@ public class ConfigurationTests WindowWidth = 800, WindowHeight = 600, }; - Assert.Equal(2, c2.RenderDistanceVertical); Assert.Equal(0, c2.SpawnReadyRadiusHorizontal); Assert.Equal(2, c2.MaxGenerateJobsInFlight); Assert.Equal(1, c2.MaxMeshUploadsPerFrame); @@ -80,7 +77,6 @@ public class ConfigurationTests { var c = new Configuration(); - Assert.Equal(1, c.SurfaceShellSkirtBelow); Assert.Equal(0.7f, c.FogStartFraction); Assert.Equal(new OpenTK.Mathematics.Vector3(0.45f, 0.65f, 0.95f), c.SkyColor); Assert.Equal(256, c.MinCaveY); diff --git a/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs b/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs index b7307ac..84aae4c 100644 --- a/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs +++ b/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs @@ -6,6 +6,7 @@ using VoxelEngine.World.Blocks; using VoxelEngine.World.Chunk; using VoxelEngine.World.Persistence; using VoxelEngine.World.Streaming; +using VoxelEngine.World.WorldGen; using Xunit; using VoxelChunk = VoxelEngine.World.Chunk.Chunk; @@ -72,7 +73,6 @@ public class ChunkStreamerTests var cfg = new Configuration { RenderDistanceHorizontal = 1, - RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; @@ -97,7 +97,6 @@ public class ChunkStreamerTests var cfg = new Configuration { RenderDistanceHorizontal = 1, - RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; @@ -130,7 +129,6 @@ public class ChunkStreamerTests var cfg = new Configuration { RenderDistanceHorizontal = 1, - RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 2, // cap drains per tick }; @@ -151,19 +149,21 @@ public class ChunkStreamerTests var cfg = new Configuration { RenderDistanceHorizontal = 0, - RenderDistanceVertical = 2, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; - var (streamer, world, created) = CreateStreamer(cfg); + // Surface far above the world ceiling -> top chunk clamps to + // WorldHeightChunks-1 (31). Full column loads cy 0..31 and nothing above. + var (streamer, world, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => 100_000); - // Player at y=0 (chunk Y = 0). V=2 wants Y ∈ [-2, 2], but valid Y is [0, 8). - // So only Y ∈ {0, 1, 2} qualifies → 1 × 3 × 1 = 3 chunks. streamer.Tick(new Vector3i(0, 0, 0)); - Assert.Equal(3, created.Count); - Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0))); - Assert.NotNull(world.GetChunk(new Vector3i(0, 1, 0))); - Assert.NotNull(world.GetChunk(new Vector3i(0, 2, 0))); + + for (int cy = 0; cy < WorldConstants.WorldHeightChunks; cy++) + { + var pos = new Vector3i(0, cy, 0); + Assert.NotNull(world.GetChunk(pos)); + Assert.True(streamer.IsReady(pos)); + } } [Fact] @@ -172,7 +172,6 @@ public class ChunkStreamerTests var cfg = new Configuration { RenderDistanceHorizontal = 0, - RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; @@ -201,7 +200,6 @@ public class ChunkStreamerTests var cfg = new Configuration { RenderDistanceHorizontal = 0, - RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; @@ -224,7 +222,6 @@ public class ChunkStreamerTests var cfg = new Configuration { RenderDistanceHorizontal = 0, - RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; @@ -251,7 +248,6 @@ public class ChunkStreamerTests var cfg = new Configuration { RenderDistanceHorizontal = 1, - RenderDistanceVertical = 0, SpawnReadyRadiusHorizontal = 1, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 1, // throttle to expose progression @@ -271,7 +267,6 @@ public class ChunkStreamerTests var cfg = new Configuration { RenderDistanceHorizontal = 0, - RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; @@ -296,7 +291,6 @@ public class ChunkStreamerTests var cfg = new Configuration { RenderDistanceHorizontal = 1, - RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; @@ -318,7 +312,7 @@ public class ChunkStreamerTests [Fact] public void MarkDirty_OnUntrackedChunk_IsNoOp() { - var cfg = new Configuration { RenderDistanceHorizontal = 0, RenderDistanceVertical = 0 }; + var cfg = new Configuration { RenderDistanceHorizontal = 0 }; var (streamer, _, created) = CreateStreamer(cfg); streamer.MarkDirty(new Vector3i(99, 0, 99)); @@ -334,7 +328,6 @@ public class ChunkStreamerTests var cfg = new Configuration { RenderDistanceHorizontal = 0, - RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; @@ -368,7 +361,6 @@ public class ChunkStreamerTests var cfg = new Configuration { RenderDistanceHorizontal = 0, - RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; @@ -400,20 +392,20 @@ public class ChunkStreamerTests var cfg = new Configuration { RenderDistanceHorizontal = 1, - RenderDistanceVertical = 1, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 5, // player's chunk + 4 horizontal cardinals VerticalPriorityWeight = 4, }; - var (streamer, world, _) = CreateStreamer(cfg); + // Surface at worldY 48 -> top chunk 3, so a full column is cy 0..3 and + // the player at Y=2 has in-column vertical neighbours at Y=1 and Y=3. + var (streamer, world, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => 48); - // Player chunk at Y=2 so vertical neighbors Y=1 and Y=3 are valid. var playerPos = new Vector3i(0, 2, 0); streamer.Tick(playerPos); // Drain budget = 5 = priority 0 (player) + 4 priority-1 (horizontal // cardinals). With VerticalPriorityWeight=4, vertical cardinals - // (Y=1, Y=3) have priority 4 — they should NOT be among the first 5. + // (Y=1, Y=3) have priority 4 -- they must NOT be among the first 5. Assert.NotNull(world.GetChunk(playerPos)); Assert.NotNull(world.GetChunk(new Vector3i( 1, 2, 0))); Assert.NotNull(world.GetChunk(new Vector3i(-1, 2, 0))); @@ -424,27 +416,55 @@ public class ChunkStreamerTests } [Fact] - public void Tick_LoadsSurfaceShell_AndExcludesAirAboveSurface() + public void Tick_LoadsFullColumn_FromBedrockToSurface() { var cfg = new Configuration { RenderDistanceHorizontal = 0, // single column at the player - RenderDistanceVertical = 0, // slab is just the player's chunk - SurfaceShellSkirtBelow = 1, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; - // Surface at worldY 40 → chunk Y 2. Shell = [2-1, 2] = {1, 2}. Slab at - // player Y=0 = {0}. Union = {0, 1, 2}. Chunk Y 3+ is air above surface. + // 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); streamer.Tick(new Vector3i(0, 0, 0)); - Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0))); // slab - Assert.NotNull(world.GetChunk(new Vector3i(0, 1, 0))); // shell skirt - Assert.NotNull(world.GetChunk(new Vector3i(0, 2, 0))); // shell surface - Assert.Null(world.GetChunk(new Vector3i(0, 3, 0))); // air above surface — not loaded - Assert.Null(world.GetChunk(new Vector3i(0, 5, 0))); // air above surface — not loaded + 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 + } + + [Fact] + public void Tick_LoadsEditedSectionAboveSurface() + { + var cfg = new Configuration + { + RenderDistanceHorizontal = 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 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); + store.RecordEdit(edited, 0, registry.Stone.Id); + + var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, 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(edited)); // edited air section above surface + Assert.Null(world.GetChunk(new Vector3i(0, 3, 0))); // unedited air -- not loaded } [Fact] @@ -453,7 +473,6 @@ public class ChunkStreamerTests var cfg = new Configuration { RenderDistanceHorizontal = 1, - RenderDistanceVertical = 0, SpawnReadyRadiusHorizontal = 1, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, @@ -476,7 +495,6 @@ public class ChunkStreamerTests var cfg = new Configuration { RenderDistanceHorizontal = 1, - RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; diff --git a/VoxelEngine.Tests/Streaming/StreamingIntegrationTests.cs b/VoxelEngine.Tests/Streaming/StreamingIntegrationTests.cs index a58f5b8..8d9eb33 100644 --- a/VoxelEngine.Tests/Streaming/StreamingIntegrationTests.cs +++ b/VoxelEngine.Tests/Streaming/StreamingIntegrationTests.cs @@ -32,7 +32,6 @@ public class StreamingIntegrationTests var cfg = new Configuration { RenderDistanceHorizontal = 1, - RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; @@ -58,7 +57,6 @@ public class StreamingIntegrationTests var cfg = new Configuration { RenderDistanceHorizontal = 0, - RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; diff --git a/World/Streaming/ChunkStreamer.cs b/World/Streaming/ChunkStreamer.cs index a8a9b09..0965fa7 100644 --- a/World/Streaming/ChunkStreamer.cs +++ b/World/Streaming/ChunkStreamer.cs @@ -18,10 +18,11 @@ public sealed class ChunkStreamer : IDisposable private readonly IWorld _world; private readonly Func _generate; private readonly Func _surfaceHeightAt; - // Per chunk-column (cx,cz) surface (min,max) height range, memoized so each - // column's samples are taken once and reused across ticks. Pruned to the - // horizontal window each recompute so it stays bounded. - private readonly Dictionary<(int cx, int cz), (int min, int max)> _surfaceRangeCache = new(); + // Per chunk-column (cx,cz) surface-TOP height, memoized so each column's + // samples are taken once and reused across ticks. Pruned to the horizontal + // window each recompute so it stays bounded. Full-column loading only needs + // the top; the bottom is always bedrock (cy 0). + private readonly Dictionary<(int cx, int cz), int> _surfaceTopCache = new(); private readonly IChunkMesher _mesher; private readonly BlockRegistry _registry; private readonly IJobExecutor _executor; @@ -156,42 +157,43 @@ public sealed class ChunkStreamer : IDisposable this.PruneSurfaceCache(p); } - // Drop cached surface ranges for columns outside the current horizontal + // Drop cached surface tops for columns outside the current horizontal // window so the cache stays bounded as the player explores. private void PruneSurfaceCache(Vector3i p) { int keep = this._cfg.RenderDistanceHorizontal + 1; List<(int cx, int cz)>? stale = null; - foreach (var key in this._surfaceRangeCache.Keys) + foreach (var 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._surfaceRangeCache.Remove(key); + foreach (var key in stale) this._surfaceTopCache.Remove(key); } // 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 loaded Y-range is the union of the surface shell and the - // player slab, clamped to the world's vertical bounds. + // 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). private void CollectTargets(Vector3i center, int radiusH, HashSet into) { - int v = this._cfg.RenderDistanceVertical; - int skirt = this._cfg.SurfaceShellSkirtBelow; - for (int dx = -radiusH; dx <= radiusH; dx++) for (int dz = -radiusH; dz <= radiusH; dz++) { int cx = center.X + dx; int cz = center.Z + dz; - (int minH, int maxH) = this.SurfaceHeightRange(cx, cz); - int shellBot = ChunkMath.FloorDiv(minH, VoxelChunk.Size) - skirt; - int shellTop = ChunkMath.FloorDiv(maxH, VoxelChunk.Size); - ChunkStreamer.AddColumn(into, cx, cz, shellBot, shellTop); + int surfaceTop = ChunkMath.FloorDiv(this.SurfaceTopHeight(cx, cz), VoxelChunk.Size); + ChunkStreamer.AddColumn(into, cx, cz, 0, surfaceTop); - ChunkStreamer.AddColumn(into, cx, cz, center.Y - v, center.Y + v); + int clampedTop = Math.Min(WorldConstants.WorldHeightChunks - 1, surfaceTop); + for (int cy = clampedTop + 1; cy < WorldConstants.WorldHeightChunks; cy++) + { + var pos = new Vector3i(cx, cy, cz); + if (this._world.ModificationStore.HasDiff(pos)) into.Add(pos); + } } } @@ -203,11 +205,13 @@ public sealed class ChunkStreamer : IDisposable into.Add(new Vector3i(cx, cy, cz)); } - // Min/max terrain surface height across a chunk column's 16×16 footprint, - // sampled at the four corners and the centre (catches slope spread cheaply). - private (int min, int max) SurfaceHeightRange(int cx, int cz) + // 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, + // so only the top is needed. + private int SurfaceTopHeight(int cx, int cz) { - if (this._surfaceRangeCache.TryGetValue((cx, cz), out (int min, int max) cached)) + if (this._surfaceTopCache.TryGetValue((cx, cz), out int cached)) return cached; int x0 = cx * VoxelChunk.Size; @@ -217,18 +221,13 @@ public sealed class ChunkStreamer : IDisposable int xm = x0 + VoxelChunk.Size / 2; int zm = z0 + VoxelChunk.Size / 2; - int h0 = this._surfaceHeightAt(x0, z0); - int h1 = this._surfaceHeightAt(x1, z0); - int h2 = this._surfaceHeightAt(x0, z1); - int h3 = this._surfaceHeightAt(x1, z1); - int h4 = this._surfaceHeightAt(xm, zm); + int max = Math.Max( + Math.Max(this._surfaceHeightAt(x0, z0), this._surfaceHeightAt(x1, z0)), + Math.Max(Math.Max(this._surfaceHeightAt(x0, z1), this._surfaceHeightAt(x1, z1)), + this._surfaceHeightAt(xm, zm))); - int min = Math.Min(Math.Min(h0, h1), Math.Min(Math.Min(h2, h3), h4)); - int max = Math.Max(Math.Max(h0, h1), Math.Max(Math.Max(h2, h3), h4)); - - (int min, int max) range = (min, max); - this._surfaceRangeCache[(cx, cz)] = range; - return range; + this._surfaceTopCache[(cx, cz)] = max; + return max; } private void DrainCompleted()