Compare commits

...

7 Commits

Author SHA1 Message Date
MDietermann
9c28bf152f perf(streaming): cache per-column surface heights to fix move lag spikes
RecomputeTargetSet sampled TerrainSampler.HeightAt (a 169-point biome
blend kernel) 5x per column over the whole render distance on the main
thread every chunk crossing (~1,445 calls = ~250k biome lookups), causing
multi-frame hitches while moving. Memoize each chunk column's (min,max)
surface range, reused across ticks and pruned to the horizontal window, so
each column is sampled once. Exact (no terrain change); spike is amortized
to a thin edge strip per crossing and zero when stationary.
2026-06-01 20:43:31 +02:00
MDietermann
94930faccb feat(rendering): frustum-cull chunk draws and log render counts 2026-06-01 20:14:03 +02:00
MDietermann
43527e617b feat(streaming): load surface shell + player slab instead of vertical box 2026-06-01 18:51:38 +02:00
MDietermann
9daa9f8267 feat(rendering): distance fog and config-derived far plane 2026-06-01 18:46:02 +02:00
MDietermann
89ac70f1ab feat(world-gen): add MinCaveY ceiling to CaveCarver 2026-06-01 18:42:29 +02:00
MDietermann
262933238c feat(rendering): add Frustum with AABB intersection test 2026-06-01 18:40:16 +02:00
MDietermann
6c808b507b feat(config): add visibility + MinCaveY fields and cave-gen docs 2026-06-01 18:38:30 +02:00
16 changed files with 418 additions and 40 deletions

View File

@@ -2,6 +2,7 @@
in vec2 vUv;
in float vShade;
in float vWorldY;
in vec3 vWorldPos;
out vec4 FragColor;
@@ -10,11 +11,25 @@ uniform float u_caveDarkStartY;
uniform float u_caveDarkEndY;
uniform float u_caveMinBrightness;
uniform vec3 u_cameraPos;
uniform float u_fogStart;
uniform float u_fogEnd;
uniform vec3 u_skyColor;
void main()
{
vec4 texel = texture(atlas, vUv);
// Y-based cave darkness.
float t = clamp((u_caveDarkStartY - vWorldY) /
(u_caveDarkStartY - u_caveDarkEndY), 0.0, 1.0);
float brightness = mix(1.0, u_caveMinBrightness, t);
FragColor = vec4(texel.rgb * vShade * brightness, 1.0);
vec3 color = texel.rgb * vShade * brightness;
// Distance fog to the sky color, applied after cave darkness.
float dist = length(vWorldPos - u_cameraPos);
float fog = clamp((dist - u_fogStart) / (u_fogEnd - u_fogStart), 0.0, 1.0);
color = mix(color, u_skyColor, fog);
FragColor = vec4(color, 1.0);
}

View File

@@ -6,6 +6,7 @@ layout(location = 2) in float aShade;
out vec2 vUv;
out float vShade;
out float vWorldY;
out vec3 vWorldPos;
uniform mat4 model;
uniform mat4 view;
@@ -17,6 +18,7 @@ void main()
vShade = aShade;
// World-space position uses row-vector convention (matches gl_Position below).
vec4 worldPos = vec4(aPos, 1.0) * model;
vWorldPos = worldPos.xyz;
vWorldY = worldPos.y;
gl_Position = worldPos * view * projection;
}

View File

@@ -1,9 +1,28 @@
namespace VoxelEngine.Core;
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.
public float FogStartFraction { get; set; } = 0.7f;
// Sky / clear color, also the distance-fog target color.
public Vector3 SkyColor { get; set; } = new(0.45f, 0.65f, 0.95f);
public int SpawnReadyRadiusHorizontal { get; set; } = 1;
public int MaxGenerateJobsInFlight { get; set; } = 8;
@@ -25,13 +44,26 @@ public sealed class Configuration
public int? Seed { get; set; } = null;
// -- Cave generation tuning --
// Spatial frequency of the 3D simplex cave-density field; higher = smaller,
// more frequent pockets.
public float CaveNoiseFrequency { get; set; } = 0.03f;
// Density cutoff below which a solid voxel becomes air; higher = more cave.
public float CaveNoiseThreshold { get; set; } = -0.3f;
// Edge length (blocks) of a worm-spawn grid cell; one worm candidate per cell.
public int WormCellSize { get; set; } = 64;
// Per-cell chance out of 1000 that a worm spawns; 300 = 30%.
public int WormSpawnProbabilityPermille { get; set; } = 300;
// Number of trace steps (≈ blocks) a worm travels.
public int WormMaxLength { get; set; } = 80;
// Worm tunnel radius bounds; interpolated + sine-modulated along the path
// to form bulges/chambers.
public float WormMinRadius { get; set; } = 1.5f;
public float WormMaxRadius { get; set; } = 3.5f;
// Ceiling: caves carve only at or below this world-Y ("no caves above").
// Default 127 = world top (WorldHeight - 1) → no effective ceiling. Lower it
// to keep caves away from the surface (removes surface cave mouths).
public int MinCaveY { get; set; } = 127;
// -- Cave lighting (fog-distance darkness) --
public float CaveDarkStartY { get; set; } = 72f; // WorldConstants.SeaLevel + 8

View File

@@ -29,6 +29,7 @@ public sealed class Game : GameWindow
private WorldGenerator _generator = null!;
private ChunkStreamer _streamer = null!;
private Player _player = null!;
private double _logAccumSeconds;
public Game(Configuration cfg, GameWindowSettings gameSettings, NativeWindowSettings nativeSettings)
: base(gameSettings, nativeSettings)
@@ -43,7 +44,7 @@ public sealed class Game : GameWindow
Log.Information("Game starting with seed={Seed} renderDistanceH={H} renderDistanceV={V}",
this._cfg.Seed, this._cfg.RenderDistanceHorizontal, this._cfg.RenderDistanceVertical);
GL.ClearColor(0.45f, 0.65f, 0.95f, 1.0f);
GL.ClearColor(this._cfg.SkyColor.X, this._cfg.SkyColor.Y, this._cfg.SkyColor.Z, 1.0f);
GL.Enable(EnableCap.DepthTest);
GL.Enable(EnableCap.CullFace);
@@ -68,6 +69,7 @@ public sealed class Game : GameWindow
this._cfg,
this._world,
this._generator.Generate,
this._generator.SurfaceHeightAt,
mesher,
this._registry,
new ThreadPoolJobExecutor(),
@@ -119,15 +121,38 @@ public sealed class Game : GameWindow
this._shader.SetFloat("u_caveDarkStartY", this._cfg.CaveDarkStartY);
this._shader.SetFloat("u_caveDarkEndY", this._cfg.CaveDarkEndY);
this._shader.SetFloat("u_caveMinBrightness", this._cfg.CaveMinBrightness);
float fogEnd = this._cfg.RenderDistanceHorizontal * Chunk.Size;
this._shader.SetFloat("u_fogEnd", fogEnd);
this._shader.SetFloat("u_fogStart", fogEnd * this._cfg.FogStartFraction);
this._shader.SetVector3("u_cameraPos", this._player.Camera.Position);
this._shader.SetVector3("u_skyColor", this._cfg.SkyColor);
this._texture.Use(TextureUnit.Texture0);
this._streamer.DrawAll((pos, renderer) =>
Matrix4 viewProj = this._player.Camera.GetViewMatrix() * this._player.Camera.GetProjectionMatrix();
Frustum frustum = Frustum.FromViewProjection(viewProj);
int drawn = this._streamer.DrawVisible(
pos =>
{
Vector3 min = new(pos.X * Chunk.Size, pos.Y * Chunk.Size, pos.Z * Chunk.Size);
Vector3 max = min + new Vector3(Chunk.Size);
return frustum.Intersects(min, max);
},
(pos, renderer) =>
{
this._shader.SetMatrix4("model", Matrix4.CreateTranslation(
pos.X * Chunk.Size, pos.Y * Chunk.Size, pos.Z * Chunk.Size));
renderer.Draw();
});
this._logAccumSeconds += args.Time;
if (this._logAccumSeconds >= 1.0)
{
this._logAccumSeconds = 0;
Log.Information("Rendered {Drawn} / {Renderers} chunks ({Tracked} loaded)",
drawn, this._streamer.RendererCount, this._streamer.TrackedCount);
}
this.SwapBuffers();
}

View File

@@ -29,7 +29,11 @@ public sealed class Player
public Player(Configuration cfg, Vector3 spawnPosition, float aspectRatio)
{
this._cfg = cfg;
this.Camera = new Camera(spawnPosition, aspectRatio) { Fov = cfg.Fov };
this.Camera = new Camera(spawnPosition, aspectRatio)
{
Fov = cfg.Fov,
FarPlane = (cfg.RenderDistanceHorizontal + 1) * VoxelChunk.Size,
};
}
public void Move(Vector3 delta) => this.Camera.Move(delta);

View File

@@ -10,6 +10,7 @@ public sealed class Camera
public float Yaw { get; private set; } = -90f; // degrees; -90 looks down -Z
public float Pitch { get; private set; } // degrees
public float Fov { get; set; } = 60f; // degrees
public float FarPlane { get; set; } = 100f; // world units
public float AspectRatio { get; set; }
public Vector3 Front { get; private set; } = -Vector3.UnitZ;
@@ -36,7 +37,7 @@ public sealed class Camera
public Matrix4 GetProjectionMatrix() =>
Matrix4.CreatePerspectiveFieldOfView(
MathHelper.DegreesToRadians(this.Fov), this.AspectRatio, 0.1f, 100f);
MathHelper.DegreesToRadians(this.Fov), this.AspectRatio, 0.1f, this.FarPlane);
private void UpdateVectors()
{

61
Rendering/Frustum.cs Normal file
View File

@@ -0,0 +1,61 @@
using OpenTK.Mathematics;
namespace VoxelEngine.Rendering;
// View-frustum for culling chunk AABBs. Built from a world→clip matrix in this
// engine's row-vector convention (clip = worldPos * view * projection, matching
// the chunk vertex shader). Planes point inward; a point is inside when it is on
// the positive side of all six.
public readonly struct Frustum
{
// Each Vector4 is (a, b, c, d) for plane a*x + b*y + c*z + d = 0, normalized.
private readonly Vector4 _left, _right, _bottom, _top, _near, _far;
private Frustum(Vector4 l, Vector4 r, Vector4 b, Vector4 t, Vector4 n, Vector4 f)
{
this._left = l; this._right = r; this._bottom = b;
this._top = t; this._near = n; this._far = f;
}
// `vp` maps a world-space row vector p to clip space via p * vp. In that
// convention clip.x = p · column0, clip.w = p · column3, etc., so each plane
// is a combination of vp's columns (Gribb–Hartmann, row-vector form).
public static Frustum FromViewProjection(Matrix4 vp)
{
Vector4 c0 = vp.Column0, c1 = vp.Column1, c2 = vp.Column2, c3 = vp.Column3;
return new Frustum(
Normalize(c3 + c0), // left: clip.w + clip.x >= 0
Normalize(c3 - c0), // right: clip.w - clip.x >= 0
Normalize(c3 + c1), // bottom: clip.w + clip.y >= 0
Normalize(c3 - c1), // top: clip.w - clip.y >= 0
Normalize(c3 + c2), // near: clip.w + clip.z >= 0 (GL NDC z in [-1,1])
Normalize(c3 - c2)); // far: clip.w - clip.z >= 0
}
// True if the axis-aligned box [min,max] is at least partially inside.
public bool Intersects(Vector3 min, Vector3 max)
{
return InsideOrCrossing(this._left, min, max)
&& InsideOrCrossing(this._right, min, max)
&& InsideOrCrossing(this._bottom, min, max)
&& InsideOrCrossing(this._top, min, max)
&& InsideOrCrossing(this._near, min, max)
&& InsideOrCrossing(this._far, min, max);
}
// The box is fully outside a plane only if its most-positive vertex (the
// corner farthest along the plane normal) is still behind the plane.
private static bool InsideOrCrossing(Vector4 plane, Vector3 min, Vector3 max)
{
float px = plane.X >= 0 ? max.X : min.X;
float py = plane.Y >= 0 ? max.Y : min.Y;
float pz = plane.Z >= 0 ? max.Z : min.Z;
return plane.X * px + plane.Y * py + plane.Z * pz + plane.W >= 0f;
}
private static Vector4 Normalize(Vector4 p)
{
float len = new Vector3(p.X, p.Y, p.Z).Length;
return len > 0f ? p / len : p;
}
}

View File

@@ -39,6 +39,9 @@ public sealed class Shader : IDisposable
public void SetFloat(string name, float value) =>
GL.Uniform1(this.Location(name), value);
public void SetVector3(string name, Vector3 value) =>
GL.Uniform3(this.Location(name), value.X, value.Y, value.Z);
private int Location(string name)
{
if (this._uniforms.TryGetValue(name, out int location)) return location;

View File

@@ -40,4 +40,12 @@ public class CameraTests
var camera = new Camera(Vector3.Zero, 1.5f);
Assert.NotEqual(Matrix4.Identity, camera.GetProjectionMatrix());
}
[Fact]
public void FarPlane_ChangesProjection()
{
var near = new Camera(Vector3.Zero, 1f) { FarPlane = 100f };
var far = new Camera(Vector3.Zero, 1f) { FarPlane = 200f };
Assert.NotEqual(near.GetProjectionMatrix(), far.GetProjectionMatrix());
}
}

View File

@@ -74,4 +74,15 @@ public class ConfigurationTests
Assert.Equal(32f, c.CaveDarkEndY); // SeaLevel (64) - 32
Assert.Equal(0.15f, c.CaveMinBrightness);
}
[Fact]
public void Defaults_HaveVisibilityAndCaveCeilingConstants()
{
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(127, c.MinCaveY);
}
}

View File

@@ -0,0 +1,44 @@
using OpenTK.Mathematics;
using VoxelEngine.Rendering;
using Xunit;
namespace VoxelEngine.Tests;
public class FrustumTests
{
// Camera at the origin, default yaw -90° → looks down -Z.
private static Frustum MakeFrustum()
{
var cam = new Camera(new Vector3(0, 0, 0), 16f / 9f);
return Frustum.FromViewProjection(cam.GetViewMatrix() * cam.GetProjectionMatrix());
}
[Fact]
public void AABBInFront_IsVisible()
{
var f = MakeFrustum();
Assert.True(f.Intersects(new Vector3(-2, -2, -22), new Vector3(2, 2, -18)));
}
[Fact]
public void AABBBehindCamera_IsCulled()
{
var f = MakeFrustum();
Assert.False(f.Intersects(new Vector3(-2, -2, 18), new Vector3(2, 2, 22)));
}
[Fact]
public void AABBStraddlingCamera_Intersects()
{
var f = MakeFrustum();
Assert.True(f.Intersects(new Vector3(-2, -2, -5), new Vector3(2, 2, 5)));
}
[Fact]
public void AABBFarToTheSide_IsCulled()
{
var f = MakeFrustum();
// Far along +X at a depth in front of the camera — outside horizontal FOV.
Assert.False(f.Intersects(new Vector3(1000, -2, -5), new Vector3(1004, 2, -1)));
}
}

View File

@@ -1,3 +1,4 @@
using System.Linq;
using OpenTK.Mathematics;
using VoxelEngine.Core;
using VoxelEngine.World;
@@ -44,7 +45,8 @@ internal sealed class EmptyMesher : IChunkMesher
public class ChunkStreamerTests
{
private static (ChunkStreamer streamer, World.World world, List<FakeRenderer> created) CreateStreamer(Configuration cfg)
private static (ChunkStreamer streamer, World.World world, List<FakeRenderer> created) CreateStreamer(
Configuration cfg, Func<int, int, int>? surfaceHeightAt = null)
{
var registry = new BlockRegistry();
var store = new InMemoryChunkModificationStore();
@@ -59,7 +61,8 @@ public class ChunkStreamerTests
return r;
};
Func<Vector3i, VoxelChunk> generate = _ => new VoxelChunk();
var streamer = new ChunkStreamer(cfg, world, generate, mesher, registry, executor, factory);
Func<int, int, int> height = surfaceHeightAt ?? ((_, _) => 8);
var streamer = new ChunkStreamer(cfg, world, generate, height, mesher, registry, executor, factory);
return (streamer, world, created);
}
@@ -186,7 +189,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 +235,7 @@ public class ChunkStreamerTests
var executor = new SynchronousJobExecutor();
Func<float[], IChunkRenderer> factory = m => new FakeRenderer(m);
Func<Vector3i, VoxelChunk> 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 +351,7 @@ public class ChunkStreamerTests
};
Func<float[], IChunkRenderer> 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 +385,7 @@ public class ChunkStreamerTests
};
Func<Vector3i, VoxelChunk> 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 +422,72 @@ 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
}
[Fact]
public void RecomputeTargetSet_CachesSurfaceHeights_OnlySamplesNewColumns()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
RenderDistanceVertical = 0,
SpawnReadyRadiusHorizontal = 1,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
int calls = 0;
var (streamer, _, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => { calls++; return 8; });
streamer.Tick(new Vector3i(0, 0, 0));
int afterFirst = calls; // 9 columns × 5 samples = 45 (spawn-check reuses cache)
streamer.Tick(new Vector3i(1, 0, 0)); // overlaps 6 columns; only the cx=2 strip is new
int newCalls = calls - afterFirst;
Assert.Equal(45, afterFirst);
Assert.Equal(15, newCalls); // 3 new columns × 5 — cached columns are not re-sampled
}
[Fact]
public void DrawVisible_DrawsOnlyChunksPassingPredicate_AndReturnsCount()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
RenderDistanceVertical = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, _, created) = CreateStreamer(cfg);
streamer.Tick(new Vector3i(0, 0, 0)); // 9 columns, all chunk Y 0, non-empty meshes
int drawn = streamer.DrawVisible(
pos => pos == new Vector3i(0, 0, 0), // only the centre chunk "visible"
(_, r) => r.Draw());
Assert.Equal(1, drawn);
Assert.Equal(1, created.Sum(r => r.DrawCount));
}
}

View File

@@ -22,7 +22,7 @@ public class StreamingIntegrationTests
var mesher = new ChunkMesher(atlasWidthPixels: 128, atlasHeightPixels: 32);
var executor = new SynchronousJobExecutor();
Func<float[], IChunkRenderer> 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);
}

View File

@@ -183,4 +183,20 @@ public class CaveCarverTests
Assert.True(bothEmpty || bothCarved,
$"seam mismatch: c1={airC1Seam}, c2={airC2Seam}");
}
[Fact]
public void Carve_RespectsMinCaveY_NoCarvingAboveCeiling()
{
var cfg = new Configuration { MinCaveY = 20 };
var carver = new CaveCarver(seed: 42, Blocks, cfg);
Chunk c = MakeStoneChunk();
// Chunk Y=2 → worldY 32..47, all above MinCaveY=20 → must stay solid.
carver.Carve(c, new Vector3i(0, 2, 0));
for (int x = 0; x < Chunk.Size; x++)
for (int y = 0; y < Chunk.Size; y++)
for (int z = 0; z < Chunk.Size; z++)
Assert.Equal(Blocks.Stone.Id, c[x, y, z]);
}
}

View File

@@ -17,6 +17,11 @@ public sealed class ChunkStreamer : IDisposable
private readonly Configuration _cfg;
private readonly IWorld _world;
private readonly Func<Vector3i, VoxelChunk> _generate;
private readonly Func<int, int, int> _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();
private readonly IChunkMesher _mesher;
private readonly BlockRegistry _registry;
private readonly IJobExecutor _executor;
@@ -38,6 +43,7 @@ public sealed class ChunkStreamer : IDisposable
private Vector3i _spawnAnchor;
public int RendererCount => this._renderers.Count;
public int TrackedCount => this._tracked.Count;
public bool IsReady(Vector3i pos) => this._ready.Contains(pos);
public bool SpawnReady => this._spawnReady;
@@ -51,6 +57,7 @@ public sealed class ChunkStreamer : IDisposable
Configuration cfg,
IWorld world,
Func<Vector3i, VoxelChunk> generate,
Func<int, int, int> surfaceHeightAt,
IChunkMesher mesher,
BlockRegistry registry,
IJobExecutor executor,
@@ -59,6 +66,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;
@@ -85,9 +93,20 @@ public sealed class ChunkStreamer : IDisposable
this.UpdateSpawnReady();
}
public void DrawAll(Action<Vector3i, IChunkRenderer> drawCallback)
// Draws every loaded chunk for which `isChunkVisible` returns true. Returns
// the number of chunks actually drawn (passed the visibility test). The
// visibility predicate lives with the caller so the streamer stays free of
// any rendering/Frustum dependency.
public int DrawVisible(Func<Vector3i, bool> isChunkVisible, Action<Vector3i, IChunkRenderer> draw)
{
foreach (var kvp in this._renderers) drawCallback(kvp.Key, kvp.Value);
int drawn = 0;
foreach (var kvp in this._renderers)
{
if (!isChunkVisible(kvp.Key)) continue;
draw(kvp.Key, kvp.Value);
drawn++;
}
return drawn;
}
public void Dispose()
@@ -104,18 +123,8 @@ public sealed class ChunkStreamer : IDisposable
this._spawnAnchorSet = true;
}
int h = this._cfg.RenderDistanceHorizontal;
int v = this._cfg.RenderDistanceVertical;
var target = new HashSet<Vector3i>();
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)
{
@@ -143,6 +152,83 @@ public sealed class ChunkStreamer : IDisposable
this._tracked.Remove(pos);
this._pendingUnload.Enqueue(pos);
}
this.PruneSurfaceCache(p);
}
// Drop cached surface ranges 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)
{
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);
}
// 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<Vector3i> 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<Vector3i> 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)
{
if (this._surfaceRangeCache.TryGetValue((cx, cz), out (int min, int max) cached))
return cached;
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));
(int min, int max) range = (min, max);
this._surfaceRangeCache[(cx, cz)] = range;
return range;
}
private void DrainCompleted()
@@ -176,20 +262,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<Vector3i>();
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)
@@ -214,7 +297,7 @@ public sealed class ChunkStreamer : IDisposable
}
// Skip renderer allocation for empty meshes (pure-air chunks above
// terrain, or chunks whose every face culls). The chunk still counts
// as ready — DrawAll's iteration over _renderers naturally no-ops.
// as ready — DrawVisible's iteration over _renderers naturally no-ops.
if (result.Mesh.Length > 0)
{
this._renderers[result.Pos] = this._rendererFactory(result.Mesh);

View File

@@ -51,6 +51,7 @@ public sealed class CaveCarver
{
int worldY = baseY + y;
if (worldY <= 0) continue;
if (worldY > this._cfg.MinCaveY) continue;
ushort id = chunk[x, y, z];
if (id == this._blocks.Air.Id) continue;
@@ -160,6 +161,7 @@ public sealed class CaveCarver
{
int worldY = baseY + y;
if (worldY <= 0) continue;
if (worldY > this._cfg.MinCaveY) continue;
float vx = baseX + x + 0.5f;
float vy = baseY + y + 0.5f;