diff --git a/Core/Game.cs b/Core/Game.cs index 7abc614..8672de5 100644 --- a/Core/Game.cs +++ b/Core/Game.cs @@ -68,6 +68,7 @@ public sealed class Game : GameWindow this._cfg, this._world, this._generator.Generate, + this._generator.SurfaceHeightAt, mesher, this._registry, new ThreadPoolJobExecutor(), diff --git a/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs b/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs index a394ae5..9b75a5e 100644 --- a/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs +++ b/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs @@ -44,7 +44,8 @@ internal sealed class EmptyMesher : IChunkMesher public class ChunkStreamerTests { - private static (ChunkStreamer streamer, World.World world, List created) CreateStreamer(Configuration cfg) + private static (ChunkStreamer streamer, World.World world, List created) CreateStreamer( + Configuration cfg, Func? surfaceHeightAt = null) { var registry = new BlockRegistry(); var store = new InMemoryChunkModificationStore(); @@ -59,7 +60,8 @@ public class ChunkStreamerTests return r; }; Func generate = _ => new VoxelChunk(); - var streamer = new ChunkStreamer(cfg, world, generate, mesher, registry, executor, factory); + Func height = surfaceHeightAt ?? ((_, _) => 8); + var streamer = new ChunkStreamer(cfg, world, generate, height, mesher, registry, executor, factory); return (streamer, world, created); } @@ -186,7 +188,7 @@ public class ChunkStreamerTests int localIndex = (5 * VoxelChunk.Size + 6) * VoxelChunk.Size + 7; store.RecordEdit(pos, localIndex, registry.Stone.Id); - var streamer = new ChunkStreamer(cfg, world, generate, mesher, registry, executor, factory); + var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory); streamer.Tick(pos); Assert.Equal(registry.Stone.Id, world.GetBlock(5, 6, 7).Id); @@ -232,7 +234,7 @@ public class ChunkStreamerTests var executor = new SynchronousJobExecutor(); Func factory = m => new FakeRenderer(m); Func generate = _ => new VoxelChunk(); - var streamer = new ChunkStreamer(cfg, world, generate, mesher, registry, executor, factory); + var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory); var pos = new Vector3i(0, 0, 0); streamer.Tick(pos); @@ -348,7 +350,7 @@ public class ChunkStreamerTests }; Func factory = m => new FakeRenderer(m); - var streamer = new ChunkStreamer(cfg, world, generate, mesher, registry, executor, factory); + var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory); streamer.Tick(new Vector3i(0, 0, 0)); Assert.Equal(1, generateCalls); @@ -382,7 +384,7 @@ public class ChunkStreamerTests }; Func generate = _ => new VoxelChunk(); - var streamer = new ChunkStreamer(cfg, world, generate, mesher, registry, executor, factory); + var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory); var pos = new Vector3i(0, 0, 0); streamer.Tick(pos); @@ -419,4 +421,28 @@ public class ChunkStreamerTests Assert.Null(world.GetChunk(new Vector3i(0, 3, 0))); Assert.Null(world.GetChunk(new Vector3i(0, 1, 0))); } + + [Fact] + public void Tick_LoadsSurfaceShell_AndExcludesAirAboveSurface() + { + 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. + 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 + } } diff --git a/VoxelEngine.Tests/Streaming/StreamingIntegrationTests.cs b/VoxelEngine.Tests/Streaming/StreamingIntegrationTests.cs index a51e3ef..a58f5b8 100644 --- a/VoxelEngine.Tests/Streaming/StreamingIntegrationTests.cs +++ b/VoxelEngine.Tests/Streaming/StreamingIntegrationTests.cs @@ -22,7 +22,7 @@ public class StreamingIntegrationTests var mesher = new ChunkMesher(atlasWidthPixels: 128, atlasHeightPixels: 32); var executor = new SynchronousJobExecutor(); Func factory = m => new FakeRenderer(m); - var streamer = new ChunkStreamer(cfg, world, gen.Generate, mesher, registry, executor, factory); + var streamer = new ChunkStreamer(cfg, world, gen.Generate, gen.SurfaceHeightAt, mesher, registry, executor, factory); return (streamer, world, store); } diff --git a/World/Streaming/ChunkStreamer.cs b/World/Streaming/ChunkStreamer.cs index 81bfea9..f050830 100644 --- a/World/Streaming/ChunkStreamer.cs +++ b/World/Streaming/ChunkStreamer.cs @@ -17,6 +17,7 @@ public sealed class ChunkStreamer : IDisposable private readonly Configuration _cfg; private readonly IWorld _world; private readonly Func _generate; + private readonly Func _surfaceHeightAt; private readonly IChunkMesher _mesher; private readonly BlockRegistry _registry; private readonly IJobExecutor _executor; @@ -51,6 +52,7 @@ public sealed class ChunkStreamer : IDisposable Configuration cfg, IWorld world, Func generate, + Func surfaceHeightAt, IChunkMesher mesher, BlockRegistry registry, IJobExecutor executor, @@ -59,6 +61,7 @@ public sealed class ChunkStreamer : IDisposable this._cfg = cfg; this._world = world; this._generate = generate; + this._surfaceHeightAt = surfaceHeightAt; this._mesher = mesher; this._registry = registry; this._executor = executor; @@ -104,18 +107,8 @@ public sealed class ChunkStreamer : IDisposable this._spawnAnchorSet = true; } - int h = this._cfg.RenderDistanceHorizontal; - int v = this._cfg.RenderDistanceVertical; - var target = new HashSet(); - for (int dx = -h; dx <= h; dx++) - for (int dy = -v; dy <= v; dy++) - for (int dz = -h; dz <= h; dz++) - { - int cy = p.Y + dy; - if (cy < 0 || cy >= WorldConstants.WorldHeightChunks) continue; - target.Add(new Vector3i(p.X + dx, cy, p.Z + dz)); - } + this.CollectTargets(p, this._cfg.RenderDistanceHorizontal, target); foreach (var pos in target) { @@ -145,6 +138,60 @@ 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 loaded Y-range is the union of the surface shell and the + // player slab, clamped to the world's vertical bounds. + 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); + + ChunkStreamer.AddColumn(into, cx, cz, center.Y - v, center.Y + v); + } + } + + private static void AddColumn(HashSet into, int cx, int cz, int loCy, int hiCy) + { + int lo = Math.Max(0, loCy); + int hi = Math.Min(WorldConstants.WorldHeightChunks - 1, hiCy); + for (int cy = lo; cy <= hi; cy++) + 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) + { + int x0 = cx * VoxelChunk.Size; + int z0 = cz * VoxelChunk.Size; + int x1 = x0 + VoxelChunk.Size - 1; + int z1 = z0 + VoxelChunk.Size - 1; + 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 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)); + return (min, max); + } + private void DrainCompleted() { int budget = this._cfg.MaxMeshUploadsPerFrame; @@ -176,20 +223,17 @@ public sealed class ChunkStreamer : IDisposable { if (this._spawnReady || !this._spawnAnchorSet) return; - int hs = this._cfg.SpawnReadyRadiusHorizontal; - int v = this._cfg.RenderDistanceVertical; - Vector3i a = this._spawnAnchor; + var spawnTargets = new HashSet(); + this.CollectTargets(this._spawnAnchor, this._cfg.SpawnReadyRadiusHorizontal, spawnTargets); - for (int dx = -hs; dx <= hs; dx++) - for (int dy = -v; dy <= v; dy++) - for (int dz = -hs; dz <= hs; dz++) + foreach (var pos in spawnTargets) { - int cy = a.Y + dy; - if (cy < 0 || cy >= WorldConstants.WorldHeightChunks) continue; - if (!this._ready.Contains(new Vector3i(a.X + dx, cy, a.Z + dz))) return; + if (this._failed.Contains(pos)) continue; + if (!this._ready.Contains(pos)) return; } + this._spawnReady = true; - Log.Information("Spawn area ready at anchor {Anchor}", a); + Log.Information("Spawn area ready at anchor {Anchor}", this._spawnAnchor); } private void InstallResult(ChunkJobResult result)