Compare commits

...

9 Commits

Author SHA1 Message Date
MDietermann
689536348b feat(rendering): on-screen debug overlay showing current biome and coordinates
Asset-free bitmap-text overlay: a 5x7 font (glyphs defined as verifiable ASCII-art) drawn as solid quads through a tiny 2D shader, white text with a drop shadow for legibility over any background. Renders 'BIOME: <name>' and 'XYZ: x, y, z' top-left, updating live. Adds Shader.SetVector2 and WorldGenerator.BiomeNameAt support; text scale and spawn-height offset read from Configuration.
2026-06-03 16:27:15 +02:00
MDietermann
e034d10339 feat(world-gen): larger biomes, smooth height blending, capped mountains; centralize tunables in Configuration
Terrain feel: biome regions ~10x larger (Voronoi cell size 96->960); terrain height now eases across biome borders via a Voronoi distance blend (smooth, and ~25x cheaper than the old 169-sample box blur); mountains capped to ~2/3 world height and gentled (ridged amplitude/frequency lowered), snow line lowered to match.

Centralization: ~27 world-gen/render tuning constants (biome amplitudes & frequencies, climate-noise frequencies/octaves, biome-selection thresholds, blend width, cell size, filler depth, snow line, camera near plane, spawn offset, overlay scale) hoisted from scattered literals into a region-organized, fully-commented Configuration. Configuration is threaded into NoiseSource, the biomes, BiomeRegistry, BiomeMap (+Classify), TerrainSampler, WorldGenerator (+BlockAt filler depth), and Camera via Player. Defaults equal the prior values, so generation is unchanged; worldgen tests updated for the new constructor signatures.
2026-06-03 16:27:06 +02:00
MDietermann
96b8fffcd4 fix(streaming): clear loading screen once the spawn surface band is ready
UpdateSpawnReady required every chunk in the spawn columns down to bedrock. With full-column loading, those deep chunks carry the lowest load priority in the whole render volume (dy^2 * VerticalPriorityWeight), so they stream in last -- the loading screen waited for nearly the entire render distance to generate (minutes on a tall mountain spawn, appearing as a hang).

Gate readiness on just the near-surface band (surfaceTop-2 .. surfaceTop) of the spawn area instead. Those are the highest-priority chunks, so spawn-ready now flips in ~350ms. Deep chunks are occluded at spawn and stream in afterwards. Extracts a SurfaceTopChunk helper shared with CollectTargets.
2026-06-03 16:26:40 +02:00
MDietermann
9193df2710 style: drop redundant type qualifiers, use explicit types (IDE cleanup)
Pure formatting from the IDE's code cleanup: remove redundant ClassName./namespace qualifiers and replace var with explicit types. No behavior change.
2026-06-03 16:26:32 +02:00
MDietermann
102d8166aa fix(streaming): load water-surface chunks over submerged columns 2026-06-03 14:50:52 +02:00
MDietermann
225c6f7253 feat(streaming): load full column bedrock->surface to fix terrain holes 2026-06-03 08:01:38 +02:00
MDietermann
cba6b8c761 feat(world-gen): move cave ceiling and dark bands to new sea level (256) 2026-06-03 07:53:34 +02:00
MDietermann
715444d10a feat(world-gen): rescale biome relief and snow line for 512-tall world 2026-06-03 07:49:34 +02:00
MDietermann
3c7e7a46a3 feat(world-gen): scale world height 128->512 and sea level 64->256 2026-06-03 07:46:21 +02:00
33 changed files with 840 additions and 314 deletions

View File

@@ -0,0 +1,10 @@
#version 330 core
out vec4 FragColor;
uniform vec3 u_color;
void main()
{
FragColor = vec4(u_color, 1.0);
}

View File

@@ -0,0 +1,12 @@
#version 330 core
// Positions are already in normalized device coordinates; u_offset nudges the
// whole batch by a few pixels (in NDC) for the drop-shadow pass.
layout (location = 0) in vec2 aPos;
uniform vec2 u_offset;
void main()
{
gl_Position = vec4(aPos + u_offset, 0.0, 1.0);
}

View File

@@ -4,69 +4,205 @@ 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;
#region -- World --
// 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;
// World generation seed. null → a fresh random seed each run; set a value for
// a reproducible world.
public int? Seed { get; set; } = null;
// Blocks above the terrain surface at (0,0) where the player spawns.
public float SpawnHeightOffset { get; set; } = 2f;
#endregion -- World --
#region -- Rendering & View --
// Horizontal view radius in chunks (Chebyshev). Drives how far terrain loads
// and the fog / far-plane distance. Higher = see further, much heavier load.
public int RenderDistanceHorizontal { get; set; } = 8;
// Camera vertical field of view, in degrees.
public float Fov { get; set; } = 60f;
// Near clip plane distance (world units); anything nearer is not drawn.
// (The far plane is derived from RenderDistanceHorizontal over in Player.)
public float CameraNearPlane { get; set; } = 0.1f;
// 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.
// Sky / clear colour, also the distance-fog target colour (RGB, 0–1).
public Vector3 SkyColor { get; set; } = new(0.45f, 0.65f, 0.95f);
public int SpawnReadyRadiusHorizontal { get; set; } = 1;
public int MaxGenerateJobsInFlight { get; set; } = 8;
public int MaxMeshUploadsPerFrame { get; set; } = 4;
public int WorkerThreadCount { get; set; } = Math.Max(1, Environment.ProcessorCount / 2);
#endregion -- Rendering & View --
// Multiplier applied to (dy)^2 in chunk-load priority. >1 prioritizes
// chunks at the player's Y over equally-distant chunks above/below, so the
// visible surface fills in before far-underground / overhead chunks.
public int VerticalPriorityWeight { get; set; } = 4;
#region -- Window --
public float MoveSpeed { get; set; } = 8f;
public float MouseSensitivity { get; set; } = 0.15f;
public float Fov { get; set; } = 60f;
public int WindowWidth { get; set; } = 1280;
// Initial window size in pixels.
public int WindowWidth { get; set; } = 1280;
public int WindowHeight { get; set; } = 720;
public int? Seed { get; set; } = null;
#endregion -- Window --
// -- Cave generation tuning --
#region -- Chunk Streaming --
// Horizontal radius (chunks) around spawn that must finish loading before the
// loading screen clears.
public int SpawnReadyRadiusHorizontal { get; set; } = 1;
// Max chunk generate/mesh jobs running on worker threads at once.
public int MaxGenerateJobsInFlight { get; set; } = 8;
// Max finished chunk meshes uploaded to the GPU per frame (caps per-frame hitches).
public int MaxMeshUploadsPerFrame { get; set; } = 4;
// Background worker threads used for chunk generation/meshing.
public int WorkerThreadCount { get; set; } = Math.Max(1, Environment.ProcessorCount / 2);
// Multiplier applied to (dy)² in chunk-load priority. >1 prioritizes chunks at
// the player's Y over equally-distant chunks above/below, so the visible
// surface fills in before far-underground / overhead chunks.
public int VerticalPriorityWeight { get; set; } = 4;
#endregion -- Chunk Streaming --
#region -- Player & Controls --
// Movement speed, in blocks per second.
public float MoveSpeed { get; set; } = 8f;
// Mouse-look sensitivity (degrees of rotation per pixel of mouse movement).
public float MouseSensitivity { get; set; } = 0.15f;
#endregion -- Player & Controls --
#region -- World Gen: Terrain Shape --
// Average width in blocks of one biome region (Voronoi cell). Larger = bigger,
// fewer biomes.
public float BiomeCellSize { get; set; } = 960f;
// Blocks over which terrain height eases from one biome to its neighbour across
// their shared border. Larger = gentler, wider transitions.
public float BiomeBlendWidth { get; set; } = 128f;
// Filler layers (dirt/sand) placed directly below the surface block before the
// biome's solid under-material (usually stone) begins.
public int SurfaceFillerDepth { get; set; } = 3;
#endregion -- World Gen: Terrain Shape --
#region -- World Gen: Climate Noise --
// Low-frequency fields that decide biome placement. Lower frequency = larger,
// smoother climate regions; more octaves = more fine variation.
// Elevation field — high values become Mountains, low values lowland.
public float ContinentFrequency { get; set; } = 0.0035f;
public int ContinentOctaves { get; set; } = 4;
// Temperature field — high values become Desert.
public float TemperatureFrequency { get; set; } = 0.0015f;
public int TemperatureOctaves { get; set; } = 3;
// Forest-density field — high values become Forest (else Plains).
public float ForestFrequency { get; set; } = 0.02f;
#endregion -- World Gen: Climate Noise --
#region -- World Gen: Biome Selection --
// Thresholds on the climate fields (values ≈ -1..1), tested in order:
// mountains → desert → forest → plains.
// Elevation at/above which a cell is Mountains.
public float MountainsElevationThreshold { get; set; } = 0.25f;
// Temperature above which a non-mountain cell is Desert.
public float DesertTemperatureThreshold { get; set; } = 0.33f;
// Minimum temperature for Forest (colder than this → Plains).
public float ForestTemperatureThreshold { get; set; } = -0.33f;
// Minimum forest-density for Forest (below this → Plains).
public float ForestDensityThreshold { get; set; } = 0f;
#endregion -- World Gen: Biome Selection --
#region -- World Gen: Biome Surface Heights --
// Per biome: surfaceHeight = SeaLevel + ContinentAmplitude·continentNoise
// + DetailAmplitude·detailNoise. Amplitudes are in blocks; detail frequency
// sets the bumpiness scale (higher = smaller, choppier features).
// Plains — gentle rolling grassland.
public float PlainsContinentAmplitude { get; set; } = 24f;
public float PlainsDetailAmplitude { get; set; } = 8f;
public float PlainsDetailFrequency { get; set; } = 0.01f;
// Forest — like plains, a touch hillier.
public float ForestContinentAmplitude { get; set; } = 24f;
public float ForestDetailAmplitude { get; set; } = 16f;
public float ForestDetailFrequency { get; set; } = 0.01f;
// Desert — flat with low dunes.
public float DesertContinentAmplitude { get; set; } = 16f;
public float DesertDetailAmplitude { get; set; } = 6f;
public float DesertDetailFrequency { get; set; } = 0.008f;
// Mountains — tall ridged peaks (uses ridged noise instead of a smooth detail
// field). Peaks roughly cap at SeaLevel + ContinentAmplitude + RidgedAmplitude.
public float MountainsContinentAmplitude { get; set; } = 40f;
public float MountainsRidgedAmplitude { get; set; } = 45f;
public float MountainsRidgedFrequency { get; set; } = 0.005f;
public int MountainsRidgedOctaves { get; set; } = 4;
// World-Y at/above which mountain tops render as snow.
public int MountainsSnowLine { get; set; } = 270;
#endregion -- World Gen: Biome Surface Heights --
#region -- Cave Generation --
// 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;
public float CaveNoiseFrequency { get; set; } = 0.03f;
// -- Cave lighting (fog-distance darkness) --
public float CaveDarkStartY { get; set; } = 72f; // WorldConstants.SeaLevel + 8
public float CaveDarkEndY { get; set; } = 32f; // WorldConstants.SeaLevel - 32
public float CaveMinBrightness { get; set; } = 0.15f;
// 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 256 = SeaLevel → caves stay below sea level, keeping the taller
// surface terrain solid. Raise toward WorldHeight-1 to allow surface caves.
public int MinCaveY { get; set; } = 256;
#endregion -- Cave Generation --
#region -- Cave Lighting --
// Underground darkening: brightness fades from CaveDarkStartY down to
// CaveDarkEndY, never dropping below CaveMinBrightness.
public float CaveDarkStartY { get; set; } = 264f; // WorldConstants.SeaLevel + 8
public float CaveDarkEndY { get; set; } = 224f; // WorldConstants.SeaLevel - 32
public float CaveMinBrightness { get; set; } = 0.15f; // floor brightness deep underground (0–1)
#endregion -- Cave Lighting --
#region -- Debug Overlay --
// Pixel size of each font "pixel" in the on-screen debug text (biome + coords).
public int DebugOverlayTextScale { get; set; } = 3;
#endregion -- Debug Overlay --
}

View File

@@ -24,6 +24,7 @@ public sealed class Game : GameWindow
private Shader _shader = null!;
private Texture _texture = null!;
private LoadingScreen _loading = null!;
private DebugOverlay _debugOverlay = null!;
private World.World _world = null!;
private InMemoryChunkModificationStore _modStore = null!;
private WorldGenerator _generator = null!;
@@ -41,8 +42,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);
@@ -51,6 +52,7 @@ public sealed class Game : GameWindow
this._shader = new Shader("Assets/shaders/chunk.vert", "Assets/shaders/chunk.frag");
this._texture = new Texture("Assets/textures/VoxelShadeAtlas.png");
this._loading = new LoadingScreen();
this._debugOverlay = new DebugOverlay();
this._modStore = new InMemoryChunkModificationStore();
this._world = new World.World(this._registry, this._modStore);
@@ -60,11 +62,11 @@ public sealed class Game : GameWindow
// Spawn at terrain surface above (0, 0).
int surfaceY = this._generator.SurfaceHeightAt(0, 0);
Vector3 spawnPosition = new(0.5f, surfaceY + 2f, 0.5f);
Vector3 spawnPosition = new(0.5f, surfaceY + this._cfg.SpawnHeightOffset, 0.5f);
this._player = new Player(this._cfg, spawnPosition, this.ClientSize.X / (float)this.ClientSize.Y);
var mesher = new ChunkMesher(this._texture.Width, this._texture.Height);
ChunkMesher mesher = new(this._texture.Width, this._texture.Height);
this._streamer = new ChunkStreamer(
this._cfg,
this._world,
@@ -145,6 +147,16 @@ public sealed class Game : GameWindow
renderer.Draw();
});
Vector3 playerPos = this._player.Position;
int px = (int)System.Math.Floor(playerPos.X);
int py = (int)System.Math.Floor(playerPos.Y);
int pz = (int)System.Math.Floor(playerPos.Z);
this._debugOverlay.Draw(new[]
{
$"BIOME: {this._generator.BiomeNameAt(px, pz)}",
$"XYZ: {px}, {py}, {pz}",
}, this.ClientSize.X, this.ClientSize.Y, this._cfg.DebugOverlayTextScale);
this.SwapBuffers();
}
@@ -159,6 +171,7 @@ public sealed class Game : GameWindow
{
Log.Information("Game shutting down");
this._streamer?.Dispose();
this._debugOverlay?.Dispose();
this._loading?.Dispose();
this._texture?.Dispose();
this._shader?.Dispose();

View File

@@ -32,6 +32,7 @@ public sealed class Player
this.Camera = new Camera(spawnPosition, aspectRatio)
{
Fov = cfg.Fov,
NearPlane = cfg.CameraNearPlane,
FarPlane = (cfg.RenderDistanceHorizontal + 1) * VoxelChunk.Size,
};
}

View File

@@ -20,7 +20,7 @@ Log.Logger = new LoggerConfiguration()
try
{
var cfg = new Configuration();
Configuration cfg = new();
NativeWindowSettings nativeSettings = new()
{

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 NearPlane { get; set; } = 0.1f; // world units
public float FarPlane { get; set; } = 100f; // world units
public float AspectRatio { get; set; }
@@ -37,7 +38,7 @@ public sealed class Camera
public Matrix4 GetProjectionMatrix() =>
Matrix4.CreatePerspectiveFieldOfView(
MathHelper.DegreesToRadians(this.Fov), this.AspectRatio, 0.1f, this.FarPlane);
MathHelper.DegreesToRadians(this.Fov), this.AspectRatio, this.NearPlane, this.FarPlane);
private void UpdateVectors()
{
@@ -49,7 +50,7 @@ public sealed class Camera
MathF.Sin(pitch),
MathF.Sin(yaw) * MathF.Cos(pitch)));
this.Right = Vector3.Normalize(Vector3.Cross(this.Front, Camera.WorldUp));
this.Right = Vector3.Normalize(Vector3.Cross(this.Front, WorldUp));
this.Up = Vector3.Normalize(Vector3.Cross(this.Right, this.Front));
}
}

View File

@@ -2,7 +2,7 @@ using OpenTK.Graphics.OpenGL4;
namespace VoxelEngine.Rendering;
public sealed class ChunkRenderer : VoxelEngine.World.Streaming.IChunkRenderer
public sealed class ChunkRenderer : World.Streaming.IChunkRenderer
{
private const int FloatsPerVertex = 6; // pos.xyz, uv, shade
@@ -12,8 +12,8 @@ public sealed class ChunkRenderer : VoxelEngine.World.Streaming.IChunkRenderer
public ChunkRenderer(float[] vertices)
{
this._vertexCount = vertices.Length / ChunkRenderer.FloatsPerVertex;
int stride = ChunkRenderer.FloatsPerVertex * sizeof(float);
this._vertexCount = vertices.Length / FloatsPerVertex;
int stride = FloatsPerVertex * sizeof(float);
this._vao = GL.GenVertexArray();
GL.BindVertexArray(this._vao);

164
Rendering/DebugOverlay.cs Normal file
View File

@@ -0,0 +1,164 @@
using OpenTK.Graphics.OpenGL4;
using OpenTK.Mathematics;
namespace VoxelEngine.Rendering;
// Minimal, asset-free debug text. A 5x7 bitmap font is rendered as solid quads
// (one quad per lit font pixel) through a trivial 2D shader. Glyphs are defined
// as visual ASCII-art rows so they can be eyeballed for correctness. Meant for
// low-volume debug HUDs, not general text.
public sealed class DebugOverlay : IDisposable
{
private const int GlyphW = 5;
private const int GlyphH = 7;
private readonly int _vao;
private readonly int _vbo;
private readonly Shader _shader;
private readonly List<float> _verts = new();
public DebugOverlay()
{
this._shader = new Shader("Assets/shaders/debugtext.vert", "Assets/shaders/debugtext.frag");
this._vao = GL.GenVertexArray();
GL.BindVertexArray(this._vao);
this._vbo = GL.GenBuffer();
GL.BindBuffer(BufferTarget.ArrayBuffer, this._vbo);
GL.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 2 * sizeof(float), 0);
GL.EnableVertexAttribArray(0);
GL.BindVertexArray(0);
}
// Draws `lines` stacked top-down in the top-left corner. `scale` is pixels
// per font pixel; fbWidth/fbHeight are the current framebuffer size, used to
// map pixel coordinates to NDC. Drawn over the scene (no depth, no culling).
public void Draw(IReadOnlyList<string> lines, int fbWidth, int fbHeight, int scale = 3)
{
if (fbWidth <= 0 || fbHeight <= 0 || lines.Count == 0) return;
this._verts.Clear();
const int marginX = 10;
const int marginY = 10;
int lineStride = (GlyphH + 2) * scale;
for (int i = 0; i < lines.Count; i++)
this.AddString(lines[i], marginX, marginY + i * lineStride, scale, fbWidth, fbHeight);
if (this._verts.Count == 0) return;
GL.BindVertexArray(this._vao);
GL.BindBuffer(BufferTarget.ArrayBuffer, this._vbo);
GL.BufferData(BufferTarget.ArrayBuffer, this._verts.Count * sizeof(float),
this._verts.ToArray(), BufferUsageHint.DynamicDraw);
bool depthWasOn = GL.IsEnabled(EnableCap.DepthTest);
bool cullWasOn = GL.IsEnabled(EnableCap.CullFace);
GL.Disable(EnableCap.DepthTest);
GL.Disable(EnableCap.CullFace);
this._shader.Use();
int vertexCount = this._verts.Count / 2;
// Drop shadow (offset down-right by ~2px) then white text, so it stays
// legible over sky, grass or stone alike.
this._shader.SetVector2("u_offset", new Vector2(4f / fbWidth, -4f / fbHeight));
this._shader.SetVector3("u_color", new Vector3(0f, 0f, 0f));
GL.DrawArrays(PrimitiveType.Triangles, 0, vertexCount);
this._shader.SetVector2("u_offset", Vector2.Zero);
this._shader.SetVector3("u_color", new Vector3(1f, 1f, 1f));
GL.DrawArrays(PrimitiveType.Triangles, 0, vertexCount);
GL.BindVertexArray(0);
if (depthWasOn) GL.Enable(EnableCap.DepthTest);
if (cullWasOn) GL.Enable(EnableCap.CullFace);
}
private void AddString(string text, int pxX, int pxY, int scale, int fbWidth, int fbHeight)
{
int cursorX = pxX;
foreach (char ch in text)
{
if (DebugOverlay.Glyphs.TryGetValue(char.ToUpperInvariant(ch), out string[]? rows))
{
for (int r = 0; r < GlyphH; r++)
for (int c = 0; c < GlyphW; c++)
{
if (rows[r][c] != ' ')
this.AddQuad(cursorX + c * scale, pxY + r * scale, scale, fbWidth, fbHeight);
}
}
cursorX += (GlyphW + 1) * scale; // one font-pixel gap between glyphs
}
}
// Append two triangles for the pixel-space square at (px,py) of side `s`.
// Pixel space is top-left origin, y down; converted to NDC here.
private void AddQuad(int px, int py, int s, int fbWidth, int fbHeight)
{
float x0 = px / (float)fbWidth * 2f - 1f;
float x1 = (px + s) / (float)fbWidth * 2f - 1f;
float y0 = 1f - py / (float)fbHeight * 2f;
float y1 = 1f - (py + s) / (float)fbHeight * 2f;
this._verts.AddRange(new[]
{
x0, y0, x1, y0, x1, y1,
x0, y0, x1, y1, x0, y1,
});
}
public void Dispose()
{
GL.DeleteBuffer(this._vbo);
GL.DeleteVertexArray(this._vao);
this._shader.Dispose();
}
// 5x7 font. Each glyph is 7 rows of 5 columns; non-space = lit pixel.
private static readonly Dictionary<char, string[]> Glyphs = new()
{
['A'] = new[] { " ### ", "# #", "# #", "#####", "# #", "# #", "# #" },
['B'] = new[] { "#### ", "# #", "# #", "#### ", "# #", "# #", "#### " },
['C'] = new[] { " ####", "# ", "# ", "# ", "# ", "# ", " ####" },
['D'] = new[] { "#### ", "# #", "# #", "# #", "# #", "# #", "#### " },
['E'] = new[] { "#####", "# ", "# ", "#### ", "# ", "# ", "#####" },
['F'] = new[] { "#####", "# ", "# ", "#### ", "# ", "# ", "# " },
['G'] = new[] { " ####", "# ", "# ", "# ##", "# #", "# #", " ####" },
['H'] = new[] { "# #", "# #", "# #", "#####", "# #", "# #", "# #" },
['I'] = new[] { "#####", " # ", " # ", " # ", " # ", " # ", "#####" },
['J'] = new[] { "#####", " # ", " # ", " # ", "# # ", "# # ", " ## " },
['K'] = new[] { "# #", "# # ", "# # ", "## ", "# # ", "# # ", "# #" },
['L'] = new[] { "# ", "# ", "# ", "# ", "# ", "# ", "#####" },
['M'] = new[] { "# #", "## ##", "# # #", "# #", "# #", "# #", "# #" },
['N'] = new[] { "# #", "## #", "# # #", "# ##", "# #", "# #", "# #" },
['O'] = new[] { " ### ", "# #", "# #", "# #", "# #", "# #", " ### " },
['P'] = new[] { "#### ", "# #", "# #", "#### ", "# ", "# ", "# " },
['Q'] = new[] { " ### ", "# #", "# #", "# #", "# # #", "# # ", " ## #" },
['R'] = new[] { "#### ", "# #", "# #", "#### ", "# # ", "# # ", "# #" },
['S'] = new[] { " ####", "# ", "# ", " ### ", " #", " #", "#### " },
['T'] = new[] { "#####", " # ", " # ", " # ", " # ", " # ", " # " },
['U'] = new[] { "# #", "# #", "# #", "# #", "# #", "# #", " ### " },
['V'] = new[] { "# #", "# #", "# #", "# #", "# #", " # # ", " # " },
['W'] = new[] { "# #", "# #", "# #", "# # #", "# # #", "## ##", "# #" },
['X'] = new[] { "# #", "# #", " # # ", " # ", " # # ", "# #", "# #" },
['Y'] = new[] { "# #", "# #", " # # ", " # ", " # ", " # ", " # " },
['Z'] = new[] { "#####", " #", " # ", " # ", " # ", "# ", "#####" },
['0'] = new[] { " ### ", "# #", "# ##", "# # #", "## #", "# #", " ### " },
['1'] = new[] { " # ", " ## ", " # ", " # ", " # ", " # ", "#####" },
['2'] = new[] { " ### ", "# #", " #", " # ", " # ", " # ", "#####" },
['3'] = new[] { "#####", " # ", " # ", " # ", " #", "# #", " ### " },
['4'] = new[] { " # ", " ## ", " # # ", "# # ", "#####", " # ", " # " },
['5'] = new[] { "#####", "# ", "#### ", " #", " #", "# #", " ### " },
['6'] = new[] { " ### ", "# ", "# ", "#### ", "# #", "# #", " ### " },
['7'] = new[] { "#####", " #", " # ", " # ", " # ", " # ", " # " },
['8'] = new[] { " ### ", "# #", "# #", " ### ", "# #", "# #", " ### " },
['9'] = new[] { " ### ", "# #", "# #", " ####", " #", " #", " ### " },
[' '] = new[] { " ", " ", " ", " ", " ", " ", " " },
[':'] = new[] { " ", " # ", " # ", " ", " # ", " # ", " " },
[','] = new[] { " ", " ", " ", " ", " # ", " # ", " # " },
['-'] = new[] { " ", " ", " ", "#####", " ", " ", " " },
['.'] = new[] { " ", " ", " ", " ", " ", " ## ", " ## " },
['/'] = new[] { " #", " #", " # ", " # ", " # ", "# ", "# " },
};
}

View File

@@ -10,8 +10,8 @@ public sealed class Shader : IDisposable
public Shader(string vertexPath, string fragmentPath)
{
int vertex = Shader.CompileFromFile(ShaderType.VertexShader, vertexPath);
int fragment = Shader.CompileFromFile(ShaderType.FragmentShader, fragmentPath);
int vertex = CompileFromFile(ShaderType.VertexShader, vertexPath);
int fragment = CompileFromFile(ShaderType.FragmentShader, fragmentPath);
this._handle = GL.CreateProgram();
GL.AttachShader(this._handle, vertex);
@@ -42,6 +42,9 @@ public sealed class Shader : IDisposable
public void SetVector3(string name, Vector3 value) =>
GL.Uniform3(this.Location(name), value.X, value.Y, value.Z);
public void SetVector2(string name, Vector2 value) =>
GL.Uniform2(this.Location(name), value.X, value.Y);
private int Location(string name)
{
if (this._uniforms.TryGetValue(name, out int location)) return location;

View File

@@ -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);
@@ -70,8 +67,8 @@ public class ConfigurationTests
Assert.Equal(3.5f, c.WormMaxRadius);
// Lighting (fog)
Assert.Equal(72f, c.CaveDarkStartY); // SeaLevel (64) + 8
Assert.Equal(32f, c.CaveDarkEndY); // SeaLevel (64) - 32
Assert.Equal(264f, c.CaveDarkStartY); // SeaLevel (256) + 8
Assert.Equal(224f, c.CaveDarkEndY); // SeaLevel (256) - 32
Assert.Equal(0.15f, c.CaveMinBrightness);
}
@@ -80,9 +77,8 @@ 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(127, c.MinCaveY);
Assert.Equal(256, c.MinCaveY);
}
}

View File

@@ -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;
@@ -66,13 +67,25 @@ 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()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
RenderDistanceVertical = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
@@ -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++)
{
@@ -97,7 +111,6 @@ public class ChunkStreamerTests
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
RenderDistanceVertical = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
@@ -130,7 +143,6 @@ public class ChunkStreamerTests
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
RenderDistanceVertical = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 2, // cap drains per tick
};
@@ -151,19 +163,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 +186,6 @@ public class ChunkStreamerTests
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
RenderDistanceVertical = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
@@ -201,18 +214,19 @@ public class ChunkStreamerTests
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
RenderDistanceVertical = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
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)));
@@ -224,7 +238,6 @@ public class ChunkStreamerTests
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
RenderDistanceVertical = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
@@ -251,17 +264,17 @@ public class ChunkStreamerTests
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
RenderDistanceVertical = 0,
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);
}
@@ -271,23 +284,35 @@ public class ChunkStreamerTests
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
RenderDistanceVertical = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
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]
@@ -296,7 +321,6 @@ public class ChunkStreamerTests
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
RenderDistanceVertical = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
@@ -318,14 +342,23 @@ public class ChunkStreamerTests
[Fact]
public void MarkDirty_OnUntrackedChunk_IsNoOp()
{
var cfg = new Configuration { RenderDistanceHorizontal = 0, RenderDistanceVertical = 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]
@@ -334,7 +367,6 @@ public class ChunkStreamerTests
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
RenderDistanceVertical = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
@@ -354,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]
@@ -368,7 +401,6 @@ public class ChunkStreamerTests
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
RenderDistanceVertical = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
@@ -400,20 +432,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 +456,79 @@ 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.
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))); // 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, 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]
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<float[], IChunkRenderer> factory = m => new FakeRenderer(m);
Func<Vector3i, VoxelChunk> generate = _ => new VoxelChunk();
// 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, (_, _) => 296, mesher, registry, executor, factory);
streamer.Tick(new Vector3i(0, 0, 0));
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, 20, 0))); // unedited air -- not loaded
}
[Fact]
@@ -453,7 +537,6 @@ public class ChunkStreamerTests
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
RenderDistanceVertical = 0,
SpawnReadyRadiusHorizontal = 1,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
@@ -476,7 +559,6 @@ public class ChunkStreamerTests
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
RenderDistanceVertical = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};

View File

@@ -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,
};

View File

@@ -1,3 +1,4 @@
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.WorldGen;
using Xunit;
@@ -9,38 +10,38 @@ public class BiomeMapTests
private static (BiomeMap map, BiomeRegistry biomes) Build(int seed = 42)
{
var blocks = new BlockRegistry();
var biomes = new BiomeRegistry(seed, blocks);
var noise = new NoiseSource(seed);
var biomes = new BiomeRegistry(seed, blocks, new Configuration());
var noise = new NoiseSource(seed, new Configuration());
var cells = new VoronoiCells(seed, 96f);
return (new BiomeMap(noise, cells, biomes), biomes);
return (new BiomeMap(noise, cells, biomes, new Configuration()), biomes);
}
[Fact]
public void Classify_HighElevation_IsMountains()
{
var (_, biomes) = Build();
Assert.Same(biomes.Mountains, BiomeMap.Classify(0f, 0.5f, 0f, biomes));
Assert.Same(biomes.Mountains, BiomeMap.Classify(0f, 0.5f, 0f, biomes, new Configuration()));
}
[Fact]
public void Classify_HotLowElevation_IsDesert()
{
var (_, biomes) = Build();
Assert.Same(biomes.Desert, BiomeMap.Classify(0.5f, 0f, 0f, biomes));
Assert.Same(biomes.Desert, BiomeMap.Classify(0.5f, 0f, 0f, biomes, new Configuration()));
}
[Fact]
public void Classify_TemperateWooded_IsForest()
{
var (_, biomes) = Build();
Assert.Same(biomes.Forest, BiomeMap.Classify(0f, 0f, 0.5f, biomes));
Assert.Same(biomes.Forest, BiomeMap.Classify(0f, 0f, 0.5f, biomes, new Configuration()));
}
[Fact]
public void Classify_ColdLow_IsPlains()
{
var (_, biomes) = Build();
Assert.Same(biomes.Plains, BiomeMap.Classify(-0.9f, 0f, 0f, biomes));
Assert.Same(biomes.Plains, BiomeMap.Classify(-0.9f, 0f, 0f, biomes, new Configuration()));
}
[Fact]

View File

@@ -1,3 +1,4 @@
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.WorldGen;
using Xunit;
@@ -10,7 +11,7 @@ public class BiomeTests
public void Desert_TopAndFiller_AreSand()
{
var blocks = new BlockRegistry();
var biomes = new BiomeRegistry(42, blocks);
var biomes = new BiomeRegistry(42, blocks, new Configuration());
Assert.Equal("Sand", biomes.Desert.Surface.Top.Name);
Assert.Equal("Sand", biomes.Desert.Surface.Filler.Name);
}
@@ -19,16 +20,16 @@ public class BiomeTests
public void Mountains_UseSnowAboveSnowLine_StoneBelow()
{
var blocks = new BlockRegistry();
var biomes = new BiomeRegistry(42, blocks);
Assert.Equal("Stone", biomes.Mountains.TopBlock(70).Name);
Assert.Equal("Snow Block", biomes.Mountains.TopBlock(120).Name);
var biomes = new BiomeRegistry(42, blocks, new Configuration());
Assert.Equal("Stone", biomes.Mountains.TopBlock(200).Name); // below snow line
Assert.Equal("Snow Block", biomes.Mountains.TopBlock(420).Name); // above snow line (384)
}
[Fact]
public void Plains_TopBlock_IsGrass_RegardlessOfHeight()
{
var blocks = new BlockRegistry();
var biomes = new BiomeRegistry(42, blocks);
var biomes = new BiomeRegistry(42, blocks, new Configuration());
Assert.Equal("Grass", biomes.Plains.TopBlock(40).Name);
Assert.Equal("Grass", biomes.Plains.TopBlock(120).Name);
}
@@ -37,8 +38,8 @@ public class BiomeTests
public void SampleHeight_IsDeterministic()
{
var blocks = new BlockRegistry();
var biomes = new BiomeRegistry(42, blocks);
var noise = new NoiseSource(42);
var biomes = new BiomeRegistry(42, blocks, new Configuration());
var noise = new NoiseSource(42, new Configuration());
float h1 = biomes.Mountains.SampleHeight(noise, 100f, 200f);
float h2 = biomes.Mountains.SampleHeight(noise, 100f, 200f);
Assert.Equal(h1, h2);

View File

@@ -1,3 +1,4 @@
using VoxelEngine.Core;
using VoxelEngine.World.WorldGen;
using Xunit;
@@ -8,8 +9,8 @@ public class NoiseSourceTests
[Fact]
public void SameSeed_SameCoord_IsDeterministic()
{
var a = new NoiseSource(42);
var b = new NoiseSource(42);
var a = new NoiseSource(42, new Configuration());
var b = new NoiseSource(42, new Configuration());
Assert.Equal(a.Continent(10f, 20f), b.Continent(10f, 20f));
Assert.Equal(a.Temperature(10f, 20f), b.Temperature(10f, 20f));
}
@@ -17,7 +18,7 @@ public class NoiseSourceTests
[Fact]
public void Output_InMinusOneToOneRange()
{
var n = new NoiseSource(7);
var n = new NoiseSource(7, new Configuration());
for (int i = 0; i < 200; i++)
{
float c = n.Continent(i * 3.1f, i * 1.7f);

View File

@@ -1,3 +1,4 @@
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.WorldGen;
using Xunit;
@@ -9,11 +10,11 @@ public class TerrainSamplerTests
private static TerrainSampler Build(int seed = 42)
{
var blocks = new BlockRegistry();
var biomes = new BiomeRegistry(seed, blocks);
var noise = new NoiseSource(seed);
var biomes = new BiomeRegistry(seed, blocks, new Configuration());
var noise = new NoiseSource(seed, new Configuration());
var cells = new VoronoiCells(seed, 96f);
var map = new BiomeMap(noise, cells, biomes);
return new TerrainSampler(noise, map);
var map = new BiomeMap(noise, cells, biomes, new Configuration());
return new TerrainSampler(noise, map, new Configuration());
}
[Fact]
@@ -43,11 +44,11 @@ public class TerrainSamplerTests
for (int x = -200; x < 200; x += 3)
for (int z = -200; z < 200; z += 3)
{
// The dense blur removes inter-biome cliffs (those would be 25+ blocks).
// Remaining deltas are rugged intra-Mountains ridged slopes (~9), which
// are intended terrain, not cliffs; 16 separates the two.
// The dense blur removes inter-biome cliffs. Remaining deltas are
// rugged intra-Mountains ridged slopes, now ~4x larger after the
// 128->512 rescale; 64 separates intended slopes from true cliffs.
int dx = System.Math.Abs(sampler.HeightAt(x, z) - sampler.HeightAt(x + 1, z));
Assert.True(dx <= 16, $"height jump {dx} at ({x},{z})");
Assert.True(dx <= 64, $"height jump {dx} at ({x},{z})");
}
}
}

View File

@@ -15,17 +15,17 @@ public class WorldGeneratorTests
public void BlockAt_AboveTerrainAndSea_IsAir()
{
var blocks = Blocks;
var biomes = new BiomeRegistry(1, blocks);
ushort id = WorldGenerator.BlockAt(80, surfaceHeight: 70, biomes.Plains, blocks);
Assert.Equal(blocks.Air.Id, id); // 80 > 70 and 80 > SeaLevel(64)
var biomes = new BiomeRegistry(1, blocks, new Configuration());
ushort id = WorldGenerator.BlockAt(300, surfaceHeight: 290, biomes.Plains, blocks, fillerDepth: 3);
Assert.Equal(blocks.Air.Id, id); // 300 > 290 (above surface) and 300 > SeaLevel(256)
}
[Fact]
public void BlockAt_AboveTerrainBelowSea_IsWater()
{
var blocks = Blocks;
var biomes = new BiomeRegistry(1, blocks);
ushort id = WorldGenerator.BlockAt(60, surfaceHeight: 50, biomes.Plains, blocks);
var biomes = new BiomeRegistry(1, blocks, new Configuration());
ushort id = WorldGenerator.BlockAt(60, surfaceHeight: 50, biomes.Plains, blocks, fillerDepth: 3);
Assert.Equal(blocks.Water.Id, id); // 60 > 50 but 60 <= SeaLevel(64)
}
@@ -33,8 +33,8 @@ public class WorldGeneratorTests
public void BlockAt_Surface_IsBiomeTop()
{
var blocks = Blocks;
var biomes = new BiomeRegistry(1, blocks);
ushort id = WorldGenerator.BlockAt(70, surfaceHeight: 70, biomes.Desert, blocks);
var biomes = new BiomeRegistry(1, blocks, new Configuration());
ushort id = WorldGenerator.BlockAt(70, surfaceHeight: 70, biomes.Desert, blocks, fillerDepth: 3);
Assert.Equal(blocks.Sand.Id, id);
}
@@ -42,8 +42,8 @@ public class WorldGeneratorTests
public void BlockAt_FillerBand_IsBiomeFiller()
{
var blocks = Blocks;
var biomes = new BiomeRegistry(1, blocks);
ushort id = WorldGenerator.BlockAt(68, surfaceHeight: 70, biomes.Plains, blocks);
var biomes = new BiomeRegistry(1, blocks, new Configuration());
ushort id = WorldGenerator.BlockAt(68, surfaceHeight: 70, biomes.Plains, blocks, fillerDepth: 3);
Assert.Equal(blocks.Dirt.Id, id); // within 3 of surface
}
@@ -51,8 +51,8 @@ public class WorldGeneratorTests
public void BlockAt_Deep_IsStone()
{
var blocks = Blocks;
var biomes = new BiomeRegistry(1, blocks);
ushort id = WorldGenerator.BlockAt(40, surfaceHeight: 70, biomes.Plains, blocks);
var biomes = new BiomeRegistry(1, blocks, new Configuration());
ushort id = WorldGenerator.BlockAt(40, surfaceHeight: 70, biomes.Plains, blocks, fillerDepth: 3);
Assert.Equal(blocks.Stone.Id, id);
}

View File

@@ -4,16 +4,16 @@ public sealed class Chunk
{
public const int Size = 16;
private readonly ushort[] _ids = new ushort[Chunk.Size * Chunk.Size * Chunk.Size];
private readonly ushort[] _ids = new ushort[Size * Size * Size];
public ushort this[int x, int y, int z]
{
get => this._ids[Chunk.Index(x, y, z)];
set => this._ids[Chunk.Index(x, y, z)] = value;
get => this._ids[Index(x, y, z)];
set => this._ids[Index(x, y, z)] = value;
}
public static bool InBounds(int x, int y, int z) =>
x is >= 0 and < Chunk.Size && y is >= 0 and < Chunk.Size && z is >= 0 and < Chunk.Size;
x is >= 0 and < Size && y is >= 0 and < Size && z is >= 0 and < Size;
private static int Index(int x, int y, int z) => (x * Chunk.Size + y) * Chunk.Size + z;
private static int Index(int x, int y, int z) => (x * Size + y) * Size + z;
}

View File

@@ -11,8 +11,8 @@ public sealed class ChunkMesher : IChunkMesher
public ChunkMesher(int atlasWidthPixels, int atlasHeightPixels)
{
this._tileU = (float)ChunkMesher.TileSizePixels / atlasWidthPixels;
this._tileV = (float)ChunkMesher.TileSizePixels / atlasHeightPixels;
this._tileU = (float)TileSizePixels / atlasWidthPixels;
this._tileV = (float)TileSizePixels / atlasHeightPixels;
}
// Per-face geometry: base corner + two in-plane edge vectors (u, v) with
@@ -65,7 +65,7 @@ public sealed class ChunkMesher : IChunkMesher
IBlock block = registry.Get(chunk[x, y, z]);
if (!block.IsOpaque) continue;
foreach (Face face in ChunkMesher.Faces)
foreach (Face face in Faces)
{
int nx = x + face.Normal.x;
int ny = y + face.Normal.y;
@@ -91,9 +91,9 @@ public sealed class ChunkMesher : IChunkMesher
// Image is flipped on load, so offset from the top to keep tile (0,0) = top-left.
float vBase = 1f - (tile.Y + 1) * this._tileV;
foreach (int corner in ChunkMesher.Triangle)
foreach (int corner in Triangle)
{
(float cu, float cv) = ChunkMesher.Corners[corner];
(float cu, float cv) = Corners[corner];
float px = x + face.Base.x + face.U.x * cu + face.V.x * cv;
float py = y + face.Base.y + face.U.y * cu + face.V.y * cv;

View File

@@ -14,14 +14,19 @@ public sealed class ChunkStreamer : IDisposable
{
private static readonly ILogger Log = Serilog.Log.ForContext<ChunkStreamer>();
// How many chunks below the surface the spawn area must load before the
// loading screen clears (just enough to stand on and see; the rest streams in).
private const int SpawnReadyDepthChunks = 2;
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();
// 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;
@@ -100,7 +105,7 @@ public sealed class ChunkStreamer : IDisposable
public int DrawVisible(Func<Vector3i, bool> isChunkVisible, Action<Vector3i, IChunkRenderer> draw)
{
int drawn = 0;
foreach (var kvp in this._renderers)
foreach (KeyValuePair<Vector3i, IChunkRenderer> kvp in this._renderers)
{
if (!isChunkVisible(kvp.Key)) continue;
draw(kvp.Key, kvp.Value);
@@ -111,7 +116,7 @@ public sealed class ChunkStreamer : IDisposable
public void Dispose()
{
foreach (var r in this._renderers.Values) r.Dispose();
foreach (IChunkRenderer r in this._renderers.Values) r.Dispose();
this._renderers.Clear();
}
@@ -123,10 +128,10 @@ public sealed class ChunkStreamer : IDisposable
this._spawnAnchorSet = true;
}
var target = new HashSet<Vector3i>();
HashSet<Vector3i> target = new();
this.CollectTargets(p, this._cfg.RenderDistanceHorizontal, target);
foreach (var pos in target)
foreach (Vector3i pos in target)
{
if (this._failed.Contains(pos)) continue;
if (this._tracked.Add(pos))
@@ -142,12 +147,12 @@ public sealed class ChunkStreamer : IDisposable
}
// Anything currently tracked but no longer in target → unload.
var toRemove = new List<Vector3i>();
foreach (var tracked in this._tracked)
List<Vector3i> toRemove = new();
foreach (Vector3i tracked in this._tracked)
{
if (!target.Contains(tracked)) toRemove.Add(tracked);
}
foreach (var pos in toRemove)
foreach (Vector3i pos in toRemove)
{
this._tracked.Remove(pos);
this._pendingUnload.Enqueue(pos);
@@ -156,42 +161,45 @@ 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 ((int cx, int cz) 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 ((int cx, int cz) 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 (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)
{
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 top = this.SurfaceTopChunk(cx, cz);
AddColumn(into, cx, cz, 0, top);
ChunkStreamer.AddColumn(into, cx, cz, center.Y - v, center.Y + v);
// Player-built structures can rise above the surface; load any edited
// section above the column top so they remain visible.
for (int cy = top + 1; cy < WorldConstants.WorldHeightChunks; cy++)
{
Vector3i pos = new(cx, cy, cz);
if (this._world.ModificationStore.HasDiff(pos)) into.Add(pos);
}
}
}
@@ -203,11 +211,23 @@ 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)
// Chunk Y of a column's loaded top: the terrain surface chunk, raised to the
// sea-level (waterline) chunk for submerged columns, and clamped to the world
// ceiling. The bottom of a loaded column is always bedrock (cy 0).
private int SurfaceTopChunk(int cx, int cz)
=> Math.Min(
WorldConstants.WorldHeightChunks - 1,
Math.Max(
ChunkMath.FloorDiv(this.SurfaceTopHeight(cx, cz), VoxelChunk.Size),
ChunkMath.FloorDiv(WorldConstants.SeaLevel, VoxelChunk.Size)));
// 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 +237,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()
@@ -258,17 +273,30 @@ public sealed class ChunkStreamer : IDisposable
}
}
// The loading screen waits on this. It only needs the VISIBLE surface around
// spawn to be ready — gating on the full bedrock column would block on the
// deepest chunks, which carry the lowest load priority in the whole render
// volume and stream in last (minutes for tall mountain spawn columns). Those
// deep chunks are occluded at spawn and arrive shortly after.
private void UpdateSpawnReady()
{
if (this._spawnReady || !this._spawnAnchorSet) return;
var spawnTargets = new HashSet<Vector3i>();
this.CollectTargets(this._spawnAnchor, this._cfg.SpawnReadyRadiusHorizontal, spawnTargets);
foreach (var pos in spawnTargets)
int hs = this._cfg.SpawnReadyRadiusHorizontal;
Vector3i a = this._spawnAnchor;
for (int dx = -hs; dx <= hs; dx++)
for (int dz = -hs; dz <= hs; dz++)
{
if (this._failed.Contains(pos)) continue;
if (!this._ready.Contains(pos)) return;
int cx = a.X + dx;
int cz = a.Z + dz;
int top = this.SurfaceTopChunk(cx, cz);
int bottom = Math.Max(0, top - SpawnReadyDepthChunks);
for (int cy = bottom; cy <= top; cy++)
{
Vector3i pos = new(cx, cy, cz);
if (this._failed.Contains(pos)) continue;
if (!this._ready.Contains(pos)) return;
}
}
this._spawnReady = true;
@@ -324,9 +352,9 @@ public sealed class ChunkStreamer : IDisposable
new(0, -1, 0), new(0, 1, 0),
new(0, 0, -1), new(0, 0, 1),
};
foreach (var off in offsets)
foreach (Vector3i off in offsets)
{
var neighbor = pos + off;
Vector3i neighbor = pos + off;
if (this._ready.Contains(neighbor)) this._dirty.Add(neighbor);
}
}
@@ -354,13 +382,13 @@ public sealed class ChunkStreamer : IDisposable
if (chunk == null) return; // got unloaded between MarkDirty and now
NeighborSnapshots neighbors = this.SnapshotNeighbors(pos);
VoxelChunk snapshotChunk = ChunkStreamer.CopyChunk(chunk); // worker reads from copy
VoxelChunk snapshotChunk = CopyChunk(chunk); // worker reads from copy
this._inFlight++;
IChunkMesher mesher = this._mesher;
BlockRegistry registry = this._registry;
var completed = this._completed;
ConcurrentQueue<ChunkJobResult> completed = this._completed;
this._executor.Submit(() =>
{
@@ -379,7 +407,7 @@ public sealed class ChunkStreamer : IDisposable
private static VoxelChunk CopyChunk(VoxelChunk source)
{
var copy = new VoxelChunk();
VoxelChunk copy = new();
for (int x = 0; x < VoxelChunk.Size; x++)
for (int y = 0; y < VoxelChunk.Size; y++)
for (int z = 0; z < VoxelChunk.Size; z++)
@@ -394,13 +422,13 @@ public sealed class ChunkStreamer : IDisposable
NeighborSnapshots neighbors = this.SnapshotNeighbors(pos);
this._world.ModificationStore.TryGetDiff(pos, out IReadOnlyDictionary<int, ushort>? diff);
var job = new ChunkJob(pos, neighbors, diff);
ChunkJob job = new(pos, neighbors, diff);
this._inFlight++;
IChunkMesher mesher = this._mesher;
BlockRegistry registry = this._registry;
var generate = this._generate;
var completed = this._completed;
Func<Vector3i, VoxelChunk> generate = this._generate;
ConcurrentQueue<ChunkJobResult> completed = this._completed;
this._executor.Submit(() =>
{
@@ -409,7 +437,7 @@ public sealed class ChunkStreamer : IDisposable
VoxelChunk chunk = generate(job.Pos);
if (job.Diff != null)
{
foreach (var (idx, id) in job.Diff)
foreach ((int idx, ushort id) in job.Diff)
{
int lx = idx / (VoxelChunk.Size * VoxelChunk.Size);
int ly = (idx / VoxelChunk.Size) % VoxelChunk.Size;
@@ -436,7 +464,7 @@ public sealed class ChunkStreamer : IDisposable
{
VoxelChunk? c = this._world.GetChunk(p);
if (c == null) return null;
var copy = new ushort[VoxelChunk.Size * VoxelChunk.Size * VoxelChunk.Size];
ushort[] copy = new ushort[VoxelChunk.Size * VoxelChunk.Size * VoxelChunk.Size];
for (int x = 0; x < VoxelChunk.Size; x++)
for (int y = 0; y < VoxelChunk.Size; y++)
for (int z = 0; z < VoxelChunk.Size; z++)

View File

@@ -1,3 +1,5 @@
using VoxelEngine.Core;
namespace VoxelEngine.World.WorldGen;
public sealed class BiomeMap
@@ -5,32 +7,51 @@ public sealed class BiomeMap
private readonly NoiseSource _noise;
private readonly VoronoiCells _cells;
private readonly BiomeRegistry _biomes;
private readonly Configuration _cfg;
public BiomeMap(NoiseSource noise, VoronoiCells cells, BiomeRegistry biomes)
public BiomeMap(NoiseSource noise, VoronoiCells cells, BiomeRegistry biomes, Configuration cfg)
{
this._noise = noise;
this._cells = cells;
this._biomes = biomes;
this._cfg = cfg;
}
public int CellId(float worldX, float worldZ) => this._cells.Nearest(worldX, worldZ).Id;
public IBiome BiomeAt(float worldX, float worldZ)
=> this.BiomeForCell(this._cells.Nearest(worldX, worldZ));
// Climate -> biome, sampled at the cell centre so the biome is constant
// across the cell.
public IBiome BiomeForCell(VoronoiCells.Cell cell)
{
VoronoiCells.Cell cell = this._cells.Nearest(worldX, worldZ);
// Sample climate at the cell center so the biome is constant across the cell.
float temperature = this._noise.Temperature(cell.CenterX, cell.CenterZ);
float elevation = this._noise.Continent(cell.CenterX, cell.CenterZ);
float forest = this._noise.Forest(cell.CenterX, cell.CenterZ);
return BiomeMap.Classify(temperature, elevation, forest, this._biomes);
return Classify(temperature, elevation, forest, this._biomes, this._cfg);
}
// The biomes of the two nearest cells plus how far inside the nearer cell the
// point sits (SecondDist - FirstDist, in blocks; 0 exactly on the border).
// TerrainSampler uses this to ease height across biome borders.
public readonly record struct BiomeBlend(IBiome Near, IBiome Far, float BorderDistance);
public BiomeBlend NearestTwo(float worldX, float worldZ)
{
VoronoiCells.NearestPair p = this._cells.NearestTwo(worldX, worldZ);
return new BiomeBlend(
this.BiomeForCell(p.First),
this.BiomeForCell(p.Second),
p.SecondDist - p.FirstDist);
}
// Pure climate -> biome lookup. Thresholds in noise units (~[-1,1]).
public static IBiome Classify(float temperature, float elevation, float forest, BiomeRegistry biomes)
public static IBiome Classify(float temperature, float elevation, float forest, BiomeRegistry biomes, Configuration cfg)
{
if (elevation > 0.25f) return biomes.Mountains; // high ground
if (temperature > 0.33f) return biomes.Desert; // hot, low
if (temperature > -0.33f && forest > 0f) return biomes.Forest; // temperate, wooded
if (elevation > cfg.MountainsElevationThreshold) return biomes.Mountains; // high ground
if (temperature > cfg.DesertTemperatureThreshold) return biomes.Desert; // hot, low
if (temperature > cfg.ForestTemperatureThreshold && forest > cfg.ForestDensityThreshold) return biomes.Forest; // temperate, wooded
return biomes.Plains; // temperate/cold, low
}
}

View File

@@ -1,3 +1,4 @@
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.WorldGen.Biomes;
@@ -10,11 +11,11 @@ public sealed class BiomeRegistry
public IBiome Mountains { get; }
public IBiome Forest { get; }
public BiomeRegistry(int seed, BlockRegistry blocks)
public BiomeRegistry(int seed, BlockRegistry blocks, Configuration cfg)
{
this.Plains = new PlainsBiome(seed, blocks);
this.Desert = new DesertBiome(seed, blocks);
this.Mountains = new MountainsBiome(seed, blocks);
this.Forest = new ForestBiome(seed, blocks);
this.Plains = new PlainsBiome(seed, blocks, cfg);
this.Desert = new DesertBiome(seed, blocks, cfg);
this.Mountains = new MountainsBiome(seed, blocks, cfg);
this.Forest = new ForestBiome(seed, blocks, cfg);
}
}

View File

@@ -1,3 +1,4 @@
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World.WorldGen.Biomes;
@@ -5,21 +6,23 @@ namespace VoxelEngine.World.WorldGen.Biomes;
public sealed class DesertBiome : IBiome
{
private readonly FastNoiseLite _detail;
private readonly Configuration _cfg;
public string Name => "Desert";
public SurfacePalette Surface { get; }
public DesertBiome(int seed, BlockRegistry blocks)
public DesertBiome(int seed, BlockRegistry blocks, Configuration cfg)
{
this._cfg = cfg;
this._detail = new FastNoiseLite(seed + 101);
this._detail.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._detail.SetFrequency(0.015f);
this._detail.SetFrequency(cfg.DesertDetailFrequency);
this.Surface = new SurfacePalette(blocks.Sand, blocks.Sand, blocks.Stone);
}
public float SampleHeight(NoiseSource noise, float worldX, float worldZ)
=> WorldConstants.SeaLevel + 4f * noise.Continent(worldX, worldZ)
+ 1.5f * this._detail.GetNoise(worldX, worldZ);
=> WorldConstants.SeaLevel + this._cfg.DesertContinentAmplitude * noise.Continent(worldX, worldZ)
+ this._cfg.DesertDetailAmplitude * this._detail.GetNoise(worldX, worldZ);
public IBlock TopBlock(int worldY) => this.Surface.Top;
}

View File

@@ -1,3 +1,4 @@
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World.WorldGen.Biomes;
@@ -5,21 +6,23 @@ namespace VoxelEngine.World.WorldGen.Biomes;
public sealed class ForestBiome : IBiome
{
private readonly FastNoiseLite _detail;
private readonly Configuration _cfg;
public string Name => "Forest";
public SurfacePalette Surface { get; }
public ForestBiome(int seed, BlockRegistry blocks)
public ForestBiome(int seed, BlockRegistry blocks, Configuration cfg)
{
this._cfg = cfg;
this._detail = new FastNoiseLite(seed + 103);
this._detail.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._detail.SetFrequency(0.03f);
this._detail.SetFrequency(cfg.ForestDetailFrequency);
this.Surface = new SurfacePalette(blocks.Grass, blocks.Dirt, blocks.Stone);
}
public float SampleHeight(NoiseSource noise, float worldX, float worldZ)
=> WorldConstants.SeaLevel + 6f * noise.Continent(worldX, worldZ)
+ 4f * this._detail.GetNoise(worldX, worldZ);
=> WorldConstants.SeaLevel + this._cfg.ForestContinentAmplitude * noise.Continent(worldX, worldZ)
+ this._cfg.ForestDetailAmplitude * this._detail.GetNoise(worldX, worldZ);
public IBlock TopBlock(int worldY) => this.Surface.Top;
}

View File

@@ -1,32 +1,34 @@
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World.WorldGen.Biomes;
public sealed class MountainsBiome : IBiome
{
private const int SnowLine = 96;
private readonly FastNoiseLite _ridged;
private readonly IBlock _stone;
private readonly IBlock _snow;
private readonly Configuration _cfg;
public string Name => "Mountains";
public SurfacePalette Surface { get; }
public MountainsBiome(int seed, BlockRegistry blocks)
public MountainsBiome(int seed, BlockRegistry blocks, Configuration cfg)
{
this._cfg = cfg;
this._ridged = new FastNoiseLite(seed + 102);
this._ridged.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._ridged.SetFractalType(FastNoiseLite.FractalType.Ridged);
this._ridged.SetFractalOctaves(4);
this._ridged.SetFrequency(0.01f);
this._ridged.SetFractalOctaves(cfg.MountainsRidgedOctaves);
this._ridged.SetFrequency(cfg.MountainsRidgedFrequency);
this._stone = blocks.Stone;
this._snow = blocks.Snow;
this.Surface = new SurfacePalette(blocks.Stone, blocks.Stone, blocks.Stone);
}
public float SampleHeight(NoiseSource noise, float worldX, float worldZ)
=> WorldConstants.SeaLevel + 10f * noise.Continent(worldX, worldZ)
+ 45f * this._ridged.GetNoise(worldX, worldZ);
=> WorldConstants.SeaLevel + this._cfg.MountainsContinentAmplitude * noise.Continent(worldX, worldZ)
+ this._cfg.MountainsRidgedAmplitude * this._ridged.GetNoise(worldX, worldZ);
public IBlock TopBlock(int worldY) => worldY >= MountainsBiome.SnowLine ? this._snow : this._stone;
public IBlock TopBlock(int worldY) => worldY >= this._cfg.MountainsSnowLine ? this._snow : this._stone;
}

View File

@@ -1,3 +1,4 @@
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World.WorldGen.Biomes;
@@ -5,21 +6,23 @@ namespace VoxelEngine.World.WorldGen.Biomes;
public sealed class PlainsBiome : IBiome
{
private readonly FastNoiseLite _detail;
private readonly Configuration _cfg;
public string Name => "Plains";
public SurfacePalette Surface { get; }
public PlainsBiome(int seed, BlockRegistry blocks)
public PlainsBiome(int seed, BlockRegistry blocks, Configuration cfg)
{
this._cfg = cfg;
this._detail = new FastNoiseLite(seed + 100);
this._detail.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._detail.SetFrequency(0.02f);
this._detail.SetFrequency(cfg.PlainsDetailFrequency);
this.Surface = new SurfacePalette(blocks.Grass, blocks.Dirt, blocks.Stone);
}
public float SampleHeight(NoiseSource noise, float worldX, float worldZ)
=> WorldConstants.SeaLevel + 6f * noise.Continent(worldX, worldZ)
+ 2f * this._detail.GetNoise(worldX, worldZ);
=> WorldConstants.SeaLevel + this._cfg.PlainsContinentAmplitude * noise.Continent(worldX, worldZ)
+ this._cfg.PlainsDetailAmplitude * this._detail.GetNoise(worldX, worldZ);
public IBlock TopBlock(int worldY) => this.Surface.Top;
}

View File

@@ -74,9 +74,9 @@ public sealed class CaveCarver
int chunkBaseY = chunkPos.Y * VoxelChunk.Size;
int chunkBaseZ = chunkPos.Z * VoxelChunk.Size;
int chunkCellX = CaveCarver.FloorDiv(chunkBaseX, cellSize);
int chunkCellY = CaveCarver.FloorDiv(chunkBaseY, cellSize);
int chunkCellZ = CaveCarver.FloorDiv(chunkBaseZ, cellSize);
int chunkCellX = FloorDiv(chunkBaseX, cellSize);
int chunkCellY = FloorDiv(chunkBaseY, cellSize);
int chunkCellZ = FloorDiv(chunkBaseZ, cellSize);
for (int cdx = -radius; cdx <= radius; cdx++)
for (int cdy = -radius; cdy <= radius; cdy++)

View File

@@ -1,3 +1,5 @@
using VoxelEngine.Core;
namespace VoxelEngine.World.WorldGen;
// Seeded wrapper over FastNoiseLite. All Get* methods return roughly [-1, 1].
@@ -7,23 +9,23 @@ public sealed class NoiseSource
private readonly FastNoiseLite _temperature;
private readonly FastNoiseLite _forest;
public NoiseSource(int seed)
public NoiseSource(int seed, Configuration cfg)
{
this._continent = new FastNoiseLite(seed);
this._continent.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._continent.SetFractalType(FastNoiseLite.FractalType.FBm);
this._continent.SetFractalOctaves(4);
this._continent.SetFrequency(0.0035f);
this._continent.SetFractalOctaves(cfg.ContinentOctaves);
this._continent.SetFrequency(cfg.ContinentFrequency);
this._temperature = new FastNoiseLite(seed + 1);
this._temperature.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._temperature.SetFractalType(FastNoiseLite.FractalType.FBm);
this._temperature.SetFractalOctaves(3);
this._temperature.SetFrequency(0.0015f);
this._temperature.SetFractalOctaves(cfg.TemperatureOctaves);
this._temperature.SetFrequency(cfg.TemperatureFrequency);
this._forest = new FastNoiseLite(seed + 2);
this._forest.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._forest.SetFrequency(0.02f);
this._forest.SetFrequency(cfg.ForestFrequency);
}
public float Continent(float x, float z) => this._continent.GetNoise(x, z);

View File

@@ -1,38 +1,40 @@
using VoxelEngine.Core;
namespace VoxelEngine.World.WorldGen;
public sealed class TerrainSampler
{
// Box-blur radius (in blocks) applied to the biome-height field to smooth
// biome borders. A full (2R+1)x(2R+1) kernel at 1-block spacing bounds the
// per-block height change to ~maxBiomeHeightDiff / (2R+1), preventing cliffs.
// A sparse kernel does NOT bound it (axis-aligned borders flip several samples
// at once), so the kernel must be dense.
private const int BlendRadius = 6;
private readonly NoiseSource _noise;
private readonly BiomeMap _biomes;
private readonly Configuration _cfg;
public TerrainSampler(NoiseSource noise, BiomeMap biomes)
public TerrainSampler(NoiseSource noise, BiomeMap biomes, Configuration cfg)
{
this._noise = noise;
this._biomes = biomes;
this._cfg = cfg;
}
public int HeightAt(float worldX, float worldZ)
{
// For each kernel point find the biome THERE, but evaluate its height AT the
// column. Averaging blurs the hard biome borders into smooth transitions.
float sum = 0f;
int count = 0;
for (int dz = -TerrainSampler.BlendRadius; dz <= TerrainSampler.BlendRadius; dz++)
for (int dx = -TerrainSampler.BlendRadius; dx <= TerrainSampler.BlendRadius; dx++)
{
IBiome biome = this._biomes.BiomeAt(worldX + dx, worldZ + dz);
sum += biome.SampleHeight(this._noise, worldX, worldZ);
count++;
}
float avg = sum / count;
int h = (int)System.MathF.Round(avg);
return System.Math.Clamp(h, 0, WorldConstants.WorldHeight - 1);
// Blend the two nearest biomes' heights (each evaluated AT this column) by
// how deep inside the nearer cell we are: exactly on the border they mix
// 50/50, easing to 100% of the nearer biome over BiomeBlendWidth blocks.
BiomeMap.BiomeBlend blend = this._biomes.NearestTwo(worldX, worldZ);
float hNear = blend.Near.SampleHeight(this._noise, worldX, worldZ);
float hFar = blend.Far.SampleHeight(this._noise, worldX, worldZ);
float fracFar = 0.5f * (1f - SmoothStep01(blend.BorderDistance / this._cfg.BiomeBlendWidth));
float avg = hNear * (1f - fracFar) + hFar * fracFar;
int h = (int)MathF.Round(avg);
return Math.Clamp(h, 0, WorldConstants.WorldHeight - 1);
}
// Hermite smoothstep on [0,1].
private static float SmoothStep01(float t)
{
t = Math.Clamp(t, 0f, 1f);
return t * t * (3f - 2f * t);
}
}

View File

@@ -16,8 +16,8 @@ public sealed class VoronoiCells
public Cell Nearest(float x, float z)
{
int gx = (int)System.MathF.Floor(x / this._cellSize);
int gz = (int)System.MathF.Floor(z / this._cellSize);
int gx = (int)MathF.Floor(x / this._cellSize);
int gz = (int)MathF.Floor(z / this._cellSize);
float bestDist = float.MaxValue;
int bestId = 0;
@@ -28,8 +28,8 @@ public sealed class VoronoiCells
{
int cgx = gx + ox;
int cgz = gz + oz;
float jitterX = VoronoiCells.Jitter(cgx, cgz, this._seed);
float jitterZ = VoronoiCells.Jitter(cgx, cgz, this._seed + 9871);
float jitterX = Jitter(cgx, cgz, this._seed);
float jitterZ = Jitter(cgx, cgz, this._seed + 9871);
float cx = (cgx + jitterX) * this._cellSize;
float cz = (cgz + jitterZ) * this._cellSize;
float dx = cx - x, dz = cz - z;
@@ -37,7 +37,7 @@ public sealed class VoronoiCells
if (dist < bestDist)
{
bestDist = dist;
bestId = (int)VoronoiCells.Hash(cgx, cgz, this._seed);
bestId = (int)Hash(cgx, cgz, this._seed);
bestCx = cx;
bestCz = cz;
}
@@ -45,8 +45,48 @@ public sealed class VoronoiCells
return new Cell(bestId, bestCx, bestCz);
}
public readonly record struct NearestPair(Cell First, Cell Second, float FirstDist, float SecondDist);
// The two nearest cells to (x,z) with their (Euclidean) distances. Used to
// ease terrain height across the border between adjacent biome cells. Same
// 3x3 jittered-grid search as Nearest — the two closest centres always fall
// within that neighbourhood for jitter in [0,1).
public NearestPair NearestTwo(float x, float z)
{
int gx = (int)MathF.Floor(x / this._cellSize);
int gz = (int)MathF.Floor(z / this._cellSize);
float best = float.MaxValue, second = float.MaxValue;
Cell bestCell = default, secondCell = default;
for (int oz = -1; oz <= 1; oz++)
for (int ox = -1; ox <= 1; ox++)
{
int cgx = gx + ox;
int cgz = gz + oz;
float jitterX = Jitter(cgx, cgz, this._seed);
float jitterZ = Jitter(cgx, cgz, this._seed + 9871);
float cx = (cgx + jitterX) * this._cellSize;
float cz = (cgz + jitterZ) * this._cellSize;
float dx = cx - x, dz = cz - z;
float dist = dx * dx + dz * dz;
Cell cell = new((int)Hash(cgx, cgz, this._seed), cx, cz);
if (dist < best)
{
second = best; secondCell = bestCell;
best = dist; bestCell = cell;
}
else if (dist < second)
{
second = dist; secondCell = cell;
}
}
return new NearestPair(bestCell, secondCell,
MathF.Sqrt(best), MathF.Sqrt(second));
}
private static float Jitter(int x, int z, int seed)
=> (VoronoiCells.Hash(x, z, seed) & 0xFFFFFF) / (float)0x1000000; // [0,1)
=> (Hash(x, z, seed) & 0xFFFFFF) / (float)0x1000000; // [0,1)
private static uint Hash(int x, int z, int seed)
{

View File

@@ -4,7 +4,7 @@ namespace VoxelEngine.World.WorldGen;
public static class WorldConstants
{
public const int WorldHeightChunks = 8;
public const int WorldHeight = WorldConstants.WorldHeightChunks * VoxelChunk.Size; // 128
public const int SeaLevel = 64;
public const int WorldHeightChunks = 32;
public const int WorldHeight = WorldHeightChunks * VoxelChunk.Size; // 512
public const int SeaLevel = 256;
}

View File

@@ -7,9 +7,7 @@ namespace VoxelEngine.World.WorldGen;
public sealed class WorldGenerator
{
private const int FillerDepth = 3;
private const float CellSize = 96f;
private readonly Configuration _cfg;
private readonly BlockRegistry _blocks;
private readonly BiomeMap _biomeMap;
private readonly TerrainSampler _terrain;
@@ -17,17 +15,20 @@ public sealed class WorldGenerator
public WorldGenerator(int seed, BlockRegistry blocks, Configuration cfg)
{
this._cfg = cfg;
this._blocks = blocks;
NoiseSource noise = new(seed);
VoronoiCells cells = new(seed, WorldGenerator.CellSize);
BiomeRegistry biomes = new(seed, blocks);
this._biomeMap = new BiomeMap(noise, cells, biomes);
this._terrain = new TerrainSampler(noise, this._biomeMap);
NoiseSource noise = new(seed, cfg);
VoronoiCells cells = new(seed, cfg.BiomeCellSize);
BiomeRegistry biomes = new(seed, blocks, cfg);
this._biomeMap = new BiomeMap(noise, cells, biomes, cfg);
this._terrain = new TerrainSampler(noise, this._biomeMap, cfg);
this._caves = new CaveCarver(seed, blocks, cfg);
}
public int SurfaceHeightAt(int worldX, int worldZ) => this._terrain.HeightAt(worldX, worldZ);
public string BiomeNameAt(int worldX, int worldZ) => this._biomeMap.BiomeAt(worldX, worldZ).Name;
public VoxelChunk Generate(Vector3i chunkPos)
{
VoxelChunk chunk = new();
@@ -46,7 +47,7 @@ public sealed class WorldGenerator
for (int y = 0; y < VoxelChunk.Size; y++)
{
int worldY = baseY + y;
chunk[x, y, z] = WorldGenerator.BlockAt(worldY, height, biome, this._blocks);
chunk[x, y, z] = BlockAt(worldY, height, biome, this._blocks, this._cfg.SurfaceFillerDepth);
}
}
@@ -54,13 +55,13 @@ public sealed class WorldGenerator
return chunk;
}
public static ushort BlockAt(int worldY, int surfaceHeight, IBiome biome, BlockRegistry blocks)
public static ushort BlockAt(int worldY, int surfaceHeight, IBiome biome, BlockRegistry blocks, int fillerDepth)
{
if (worldY > surfaceHeight)
return worldY <= WorldConstants.SeaLevel ? blocks.Water.Id : blocks.Air.Id;
if (worldY == surfaceHeight)
return biome.TopBlock(worldY).Id;
if (worldY >= surfaceHeight - WorldGenerator.FillerDepth)
if (worldY >= surfaceHeight - fillerDepth)
return biome.Surface.Filler.Id;
return biome.Surface.Under.Id;
}