Compare commits

...

51 Commits

Author SHA1 Message Date
MDietermann
89eda0c1f2 chore(config): adjust Configuration 2026-06-25 20:12:32 +02:00
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
MDietermann
2677b1b003 feat: New Textures for ores 2026-06-01 22:09:02 +02:00
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
22531c6ee1 todo: added todos for new blocks 2026-06-01 15:50:32 +02:00
3704d7ce1e feat(core): wire fog-distance uniforms in Game render loop 2026-06-01 15:48:10 +02:00
aa1aa6fec6 feat(rendering): add fog-distance darkness to chunk shaders 2026-06-01 15:46:33 +02:00
2e36d40386 feat(rendering): add Shader.SetFloat uniform setter 2026-06-01 15:45:40 +02:00
9410fee78b feat(world-gen): integrate CaveCarver into WorldGenerator.Generate 2026-06-01 15:43:32 +02:00
645fd00868 feat: moved blockclasses into dedicated folder 2026-06-01 15:41:49 +02:00
15be8f6059 feat(world-gen): implement worm cave pass in CaveCarver 2026-06-01 15:36:34 +02:00
070bdfeac1 feat(world-gen): implement noise-threshold cave pass 2026-06-01 15:28:11 +02:00
093b3deabf feat(world-gen): add CaveCarver skeleton (no-op Carve) 2026-06-01 15:23:05 +02:00
013d524544 feat(world-gen): add WormHash deterministic mixing function 2026-06-01 15:20:09 +02:00
4587f81071 feat(blocks): add ore block scaffolding (Coal, Iron, Gold, Diamond) 2026-06-01 15:17:33 +02:00
8166c25ba6 feat(core): add cave-generation and fog tuning to Configuration 2026-06-01 15:14:22 +02:00
d1c8c22705 perf: skip renderer for chunks with empty mesh
Pure-air chunks (above terrain) and chunks where every face culls
produce a zero-length mesh from ChunkMesher.Build(). Previously
InstallResult still allocated a real ChunkRenderer (VBO + VAO) and
registered a zero-vertex draw call. With RenderDistanceVertical=2 and
a player at surface Y, the topmost vertical layer is typically all
pure air -- roughly H*H = ~289 wasted renderers per spawn.

The chunk still gets added to _ready so SpawnReady, dirty-marking and
unload bookkeeping continue to work; DrawAll's iteration over the
renderer dictionary naturally skips entries that don't exist.
2026-06-01 13:44:51 +02:00
2c95c7831a feat: weight vertical distance in chunk-load priority
Adds Configuration.VerticalPriorityWeight (default 4). The priority
formula in ChunkStreamer.RecomputeTargetSet now uses
dx^2 + dz^2 + dy^2 * VerticalPriorityWeight, so horizontal chunks at
the player's Y are dispatched before equally-distant chunks above or
below. Fixes janky loading where deep-underground chunks were
competing with surface chunks for early dispatch slots.
2026-06-01 13:41:55 +02:00
4fe552b8bd refactor: early-return in Shader.Location uniform cache lookup 2026-06-01 13:38:54 +02:00
8423b73dfd chore: tighten Configuration defaults for memory and CPU
RenderDistanceVertical 4 -> 2 (smaller vertical load box) and
WorkerThreadCount ProcessorCount-1 -> ProcessorCount/2 (less
aggressive worker pool).
2026-06-01 13:38:53 +02:00
34f923cbca refactor: store-owned diff snapshot and drop dead ChunkJob.ExistingChunk
TryGetDiff now returns an independent copy so callers can safely retain the
reference; removes the defensive copy from ChunkStreamer.DispatchLoad.
ChunkJob.ExistingChunk was never populated (DispatchRemesh uses closures
instead), so the field is removed along with its misleading record-level
comment.
2026-06-01 13:31:05 +02:00
6b148362c0 test: end-to-end streaming + modification-store roundtrip 2026-06-01 13:24:20 +02:00
59466fb291 feat: wire infinite chunk streaming into Game with loading screen 2026-06-01 12:57:09 +02:00
4467a7bd10 feat: add LoadingScreen with full-screen triangle shader 2026-06-01 12:08:41 +02:00
85a345d6e7 feat: ChunkStreamer tracks failed chunks to prevent retry storms 2026-06-01 12:04:50 +02:00
da051ed413 feat: ChunkStreamer dirty set with cardinal-neighbor remesh on load 2026-06-01 11:46:33 +02:00
7d06706400 feat: ChunkStreamer unload path and SpawnReady property 2026-06-01 11:35:35 +02:00
c1f0928344 feat: ChunkStreamer load path with target-set, dispatch, and diff application 2026-06-01 11:19:06 +02:00
dc74fdadcc feat: add streaming primitives (IChunkRenderer, IJobExecutor, NeighborSnapshots, ChunkJob) 2026-06-01 10:55:20 +02:00
39375b83ea refactor: extract shared ChunkMath.FloorDiv helper 2026-06-01 10:45:58 +02:00
2afeee50d5 feat: add Player class wrapping Camera with chunk-coordinate accessor 2026-06-01 10:45:09 +02:00
6a68953c9a feat: World.SetBlock writes through to IChunkModificationStore 2026-06-01 10:35:10 +02:00
7c310ebc6a feat: add IChunkModificationStore and in-memory implementation 2026-06-01 10:16:46 +02:00
6f6fd7ecc5 fix: include SourceContext in console log output 2026-06-01 10:08:25 +02:00
67bff6031f chore: gitignore runtime log directory 2026-06-01 09:41:35 +02:00
53d24f29ab feat: add Serilog logging with daily-rolling file sink 2026-06-01 09:41:26 +02:00
1339bb45ec feat: add Configuration class with user-tunable defaults 2026-06-01 09:34:40 +02:00
72 changed files with 3230 additions and 287 deletions

3
.gitignore vendored
View File

@@ -22,6 +22,9 @@ project.fragment.lock.json
# Local design docs — kept out of the repo by request
docs/
# Runtime logs
logs/
# OS noise
Thumbs.db
desktop.ini

View File

@@ -1,13 +1,35 @@
#version 330 core
in vec2 vUv;
in float vShade;
in float vWorldY;
in vec3 vWorldPos;
out vec4 FragColor;
uniform sampler2D atlas;
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);
FragColor = vec4(texel.rgb * vShade, 1.0);
// 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);
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

@@ -5,6 +5,8 @@ layout(location = 2) in float aShade;
out vec2 vUv;
out float vShade;
out float vWorldY;
out vec3 vWorldPos;
uniform mat4 model;
uniform mat4 view;
@@ -14,6 +16,9 @@ void main()
{
vUv = aUv;
vShade = aShade;
// Vector-on-left convention; matrices are uploaded with transpose=true.
gl_Position = vec4(aPos, 1.0) * model * view * projection;
// 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

@@ -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

@@ -0,0 +1,3 @@
#version 330 core
out vec4 FragColor;
void main() { FragColor = vec4(0.10, 0.12, 0.16, 1.0); }

View File

@@ -0,0 +1,3 @@
#version 330 core
layout(location = 0) in vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 12 KiB

13
Core/ChunkMath.cs Normal file
View File

@@ -0,0 +1,13 @@
namespace VoxelEngine.Core;
internal static class ChunkMath
{
// Floor division: floors toward negative infinity (not toward zero like C#'s /).
// Required for correct world-coord -> chunk-index mapping for negative coordinates.
internal static int FloorDiv(int value, int size)
{
int q = value / size;
if (value % size != 0 && value < 0) q--;
return q;
}
}

208
Core/Configuration.cs Normal file
View File

@@ -0,0 +1,208 @@
namespace VoxelEngine.Core;
using OpenTK.Mathematics;
public sealed class Configuration
{
#region -- World --
// 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 colour, also the distance-fog target colour (RGB, 0–1).
public Vector3 SkyColor { get; set; } = new(0.45f, 0.65f, 0.95f);
#endregion -- Rendering & View --
#region -- Window --
// Initial window size in pixels.
public int WindowWidth { get; set; } = 1280;
public int WindowHeight { get; set; } = 720;
#endregion -- Window --
#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; } = 280;
#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 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

@@ -3,75 +3,79 @@ using OpenTK.Mathematics;
using OpenTK.Windowing.Common;
using OpenTK.Windowing.Desktop;
using OpenTK.Windowing.GraphicsLibraryFramework;
using Serilog;
using VoxelEngine.Game;
using VoxelEngine.Rendering;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.Chunk;
using VoxelEngine.World.Persistence;
using VoxelEngine.World.Streaming;
using VoxelEngine.World.WorldGen;
namespace VoxelEngine.Core;
public sealed class Game : GameWindow
{
private const float MoveSpeed = 8f; // units per second
private const float Sensitivity = 0.15f; // degrees per pixel
private const int Seed = 1337;
private const bool UseSeed = false;
private static readonly ILogger Log = Serilog.Log.ForContext<Game>();
private readonly Configuration _cfg;
private readonly BlockRegistry _registry = new();
private Shader _shader = null!;
private Texture _texture = null!;
private readonly Dictionary<Vector3i, ChunkRenderer> _renderer = new();
private Camera _camera = null!;
private LoadingScreen _loading = null!;
private DebugOverlay _debugOverlay = null!;
private World.World _world = null!;
private InMemoryChunkModificationStore _modStore = null!;
private WorldGenerator _generator = null!;
private ChunkStreamer _streamer = null!;
private Player _player = null!;
private double _logAccumSeconds;
private World.World _world;
public Game(GameWindowSettings gameSettings, NativeWindowSettings nativeSettings)
: base(gameSettings, nativeSettings) { }
public Game(Configuration cfg, GameWindowSettings gameSettings, NativeWindowSettings nativeSettings)
: base(gameSettings, nativeSettings)
{
this._cfg = cfg;
}
protected override void OnLoad()
{
base.OnLoad();
GL.ClearColor(0.45f, 0.65f, 0.95f, 1.0f);
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);
GL.Enable(EnableCap.CullFace);
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._world = new World.World(this._registry);
this._modStore = new InMemoryChunkModificationStore();
this._world = new World.World(this._registry, this._modStore);
int seed = Game.UseSeed ? Game.Seed : new Random().Next();
int seed = this._cfg.Seed ?? new Random().Next();
this._generator = new WorldGenerator(seed, this._registry, this._cfg);
this._generator = new WorldGenerator(seed, this._registry);
// Spawn at terrain surface above (0, 0).
int surfaceY = this._generator.SurfaceHeightAt(0, 0);
Vector3 spawnPosition = new(0.5f, surfaceY + this._cfg.SpawnHeightOffset, 0.5f);
for (int cx = -4; cx < 4; cx++)
for (int cz = -4; cz < 4; cz++)
for (int cy = 0; cy < WorldConstants.WorldHeightChunks; cy++)
{
Vector3i pos = new(cx, cy, cz);
this._world.SetChunk(pos, this._generator.Generate(pos));
}
this._player = new Player(this._cfg, spawnPosition, this.ClientSize.X / (float)this.ClientSize.Y);
ChunkMesher mesher = new(this._texture.Width, this._texture.Height);
foreach ((Vector3i pos, Chunk chunk) in this._world)
{
Vector3i origin = pos * Chunk.Size;
float[] mesh = mesher.Build(
chunk,
this._registry,
(nx, ny, nz) => this._world.GetBlock(
origin.X + nx,
origin.Y + ny,
origin.Z + nz).IsOpaque
);
this._renderer[pos] = new ChunkRenderer(mesh);
}
this._camera = new Camera(
new Vector3(8f, WorldConstants.SeaLevel + 30f, 40f),
this.ClientSize.X / (float)this.ClientSize.Y);
this._streamer = new ChunkStreamer(
this._cfg,
this._world,
this._generator.Generate,
this._generator.SurfaceHeightAt,
mesher,
this._registry,
new ThreadPoolJobExecutor(),
mesh => new ChunkRenderer(mesh));
this.CursorState = CursorState.Grabbed;
}
@@ -81,24 +85,22 @@ public sealed class Game : GameWindow
base.OnUpdateFrame(args);
if (!this.IsFocused) return;
if (this.KeyboardState.IsKeyDown(Keys.Escape)) {
this.Close(); return;
}
if (this.KeyboardState.IsKeyDown(Keys.Escape)) { this.Close(); return; }
float speedMulti = 1f;
float speedMulti = this.KeyboardState.IsKeyDown(Keys.LeftShift) ? 1.8f : 1f;
float distance = this._player.MoveSpeed * speedMulti * (float)args.Time;
if (this.KeyboardState.IsKeyDown(Keys.LeftShift)) speedMulti = 1.8f ;
float distance = Game.MoveSpeed * speedMulti * (float)args.Time;
if (this.KeyboardState.IsKeyDown(Keys.W)) this._camera.Move(this._camera.Front * distance);
if (this.KeyboardState.IsKeyDown(Keys.S)) this._camera.Move(-this._camera.Front * distance);
if (this.KeyboardState.IsKeyDown(Keys.A)) this._camera.Move(-this._camera.Right * distance);
if (this.KeyboardState.IsKeyDown(Keys.D)) this._camera.Move(this._camera.Right * distance);
if (this.KeyboardState.IsKeyDown(Keys.Space)) this._camera.Move(Vector3.UnitY * distance);
if (this.KeyboardState.IsKeyDown(Keys.LeftControl)) this._camera.Move(-Vector3.UnitY * distance);
if (this.KeyboardState.IsKeyDown(Keys.W)) this._player.Move(this._player.Camera.Front * distance);
if (this.KeyboardState.IsKeyDown(Keys.S)) this._player.Move(-this._player.Camera.Front * distance);
if (this.KeyboardState.IsKeyDown(Keys.A)) this._player.Move(-this._player.Camera.Right * distance);
if (this.KeyboardState.IsKeyDown(Keys.D)) this._player.Move(this._player.Camera.Right * distance);
if (this.KeyboardState.IsKeyDown(Keys.Space)) this._player.Move(Vector3.UnitY * distance);
if (this.KeyboardState.IsKeyDown(Keys.LeftControl)) this._player.Move(-Vector3.UnitY * distance);
Vector2 delta = this.MouseState.Delta;
this._camera.AddRotation(delta.X * Game.Sensitivity, -delta.Y * Game.Sensitivity);
this._player.AddRotation(delta.X * this._player.MouseSensitivity, -delta.Y * this._player.MouseSensitivity);
this._streamer.Tick(this._player.ChunkPosition);
}
protected override void OnRenderFrame(FrameEventArgs args)
@@ -107,22 +109,54 @@ public sealed class Game : GameWindow
GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
this._shader.Use();
this._shader.SetMatrix4("view", this._camera.GetViewMatrix());
this._shader.SetMatrix4("projection", this._camera.GetProjectionMatrix());
this._shader.SetInt("atlas", 0);
this._texture.Use();
foreach ((Vector3i pos, ChunkRenderer renderer) in this._renderer)
if (!this._streamer.SpawnReady)
{
this._shader.SetMatrix4("model", Matrix4.CreateTranslation(
pos.X * Chunk.Size,
pos.Y * Chunk.Size,
pos.Z * Chunk.Size
));
renderer.Draw();
this._loading.Draw();
this.SwapBuffers();
return;
}
this._shader.Use();
this._shader.SetMatrix4("view", this._player.Camera.GetViewMatrix());
this._shader.SetMatrix4("projection", this._player.Camera.GetProjectionMatrix());
this._shader.SetInt("atlas", 0);
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);
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();
});
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();
}
@@ -130,18 +164,17 @@ public sealed class Game : GameWindow
{
base.OnResize(e);
GL.Viewport(0, 0, e.Width, e.Height);
// OnResize can fire before OnLoad (where _camera is created), so guard against it.
if (this._camera is not null) this._camera.AspectRatio = e.Width / (float)e.Height;
if (this._player is not null) this._player.Camera.AspectRatio = e.Width / (float)e.Height;
}
protected override void OnUnload()
{
foreach (ChunkRenderer rendererValue in this._renderer.Values)
{
rendererValue.Dispose();
}
this._texture.Dispose();
this._shader.Dispose();
Log.Information("Game shutting down");
this._streamer?.Dispose();
this._debugOverlay?.Dispose();
this._loading?.Dispose();
this._texture?.Dispose();
this._shader?.Dispose();
base.OnUnload();
}
}

43
Game/Player.cs Normal file
View File

@@ -0,0 +1,43 @@
using OpenTK.Mathematics;
using VoxelEngine.Core;
using VoxelEngine.Rendering;
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
namespace VoxelEngine.Game;
public sealed class Player
{
private readonly Configuration _cfg;
public Camera Camera { get; }
public Vector3 Position => this.Camera.Position;
public float MoveSpeed => this._cfg.MoveSpeed;
public float MouseSensitivity => this._cfg.MouseSensitivity;
public Vector3i ChunkPosition
{
get
{
Vector3 p = this.Position;
return new Vector3i(
ChunkMath.FloorDiv((int)MathF.Floor(p.X), VoxelChunk.Size),
ChunkMath.FloorDiv((int)MathF.Floor(p.Y), VoxelChunk.Size),
ChunkMath.FloorDiv((int)MathF.Floor(p.Z), VoxelChunk.Size));
}
}
public Player(Configuration cfg, Vector3 spawnPosition, float aspectRatio)
{
this._cfg = cfg;
this.Camera = new Camera(spawnPosition, aspectRatio)
{
Fov = cfg.Fov,
NearPlane = cfg.CameraNearPlane,
FarPlane = (cfg.RenderDistanceHorizontal + 1) * VoxelChunk.Size,
};
}
public void Move(Vector3 delta) => this.Camera.Move(delta);
public void AddRotation(float yawDelta, float pitchDelta) => this.Camera.AddRotation(yawDelta, pitchDelta);
}

View File

@@ -1,16 +1,45 @@
using OpenTK.Mathematics;
using OpenTK.Windowing.Common;
using OpenTK.Windowing.Desktop;
using Serilog;
using Serilog.Events;
using VoxelEngine.Core;
NativeWindowSettings nativeSettings = new()
{
ClientSize = new Vector2i(1280, 720),
Title = "VoxelEngine",
APIVersion = new Version(3, 3),
Profile = ContextProfile.Core,
Flags = ContextFlags.ForwardCompatible
};
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.FromLogContext()
.WriteTo.Console(
restrictedToMinimumLevel: LogEventLevel.Information,
outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File(
path: "logs/voxelengine-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14,
outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
using Game game = new(GameWindowSettings.Default, nativeSettings);
game.Run();
try
{
Configuration cfg = new();
NativeWindowSettings nativeSettings = new()
{
ClientSize = new Vector2i(cfg.WindowWidth, cfg.WindowHeight),
Title = "VoxelEngine",
APIVersion = new Version(3, 3),
Profile = ContextProfile.Core,
Flags = ContextFlags.ForwardCompatible
};
using Game game = new(cfg, GameWindowSettings.Default, nativeSettings);
game.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Unhandled exception at top level");
throw;
}
finally
{
Log.CloseAndFlush();
}

View File

@@ -10,6 +10,8 @@ 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; }
public Vector3 Front { get; private set; } = -Vector3.UnitZ;
@@ -36,7 +38,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, this.NearPlane, this.FarPlane);
private void UpdateVectors()
{
@@ -48,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 : IDisposable
public sealed class ChunkRenderer : World.Streaming.IChunkRenderer
{
private const int FloatsPerVertex = 6; // pos.xyz, uv, shade
@@ -12,8 +12,8 @@ public sealed class ChunkRenderer : IDisposable
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[] { " #", " #", " # ", " # ", " # ", "# ", "# " },
};
}

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

@@ -0,0 +1,52 @@
using OpenTK.Graphics.OpenGL4;
namespace VoxelEngine.Rendering;
public sealed class LoadingScreen : IDisposable
{
private readonly int _vao;
private readonly int _vbo;
private readonly Shader _shader;
public LoadingScreen()
{
this._shader = new Shader("Assets/shaders/loading.vert", "Assets/shaders/loading.frag");
// Single triangle covering the entire NDC quad.
float[] vertices =
{
-1f, -1f,
3f, -1f,
-1f, 3f,
};
this._vao = GL.GenVertexArray();
GL.BindVertexArray(this._vao);
this._vbo = GL.GenBuffer();
GL.BindBuffer(BufferTarget.ArrayBuffer, this._vbo);
GL.BufferData(BufferTarget.ArrayBuffer, vertices.Length * sizeof(float), vertices, BufferUsageHint.StaticDraw);
GL.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 2 * sizeof(float), 0);
GL.EnableVertexAttribArray(0);
GL.BindVertexArray(0);
}
public void Draw()
{
GL.Disable(EnableCap.DepthTest);
this._shader.Use();
GL.BindVertexArray(this._vao);
GL.DrawArrays(PrimitiveType.Triangles, 0, 3);
GL.BindVertexArray(0);
GL.Enable(EnableCap.DepthTest);
}
public void Dispose()
{
GL.DeleteBuffer(this._vbo);
GL.DeleteVertexArray(this._vao);
this._shader.Dispose();
}
}

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);
@@ -36,13 +36,20 @@ public sealed class Shader : IDisposable
public void SetInt(string name, int value) =>
GL.Uniform1(this.Location(name), value);
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);
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))
{
location = GL.GetUniformLocation(this._handle, name);
this._uniforms[name] = location;
}
if (this._uniforms.TryGetValue(name, out int location)) return location;
location = GL.GetUniformLocation(this._handle, name);
this._uniforms[name] = location;
return location;
}

View File

@@ -71,4 +71,36 @@ public class BlockRegistryTests
Assert.Equal("Snow Block", registry.Get(5).Name);
Assert.Equal("Water", registry.Get(6).Name);
}
[Fact]
public void Registry_ResolvesOreBlocks_ByStableIds()
{
var registry = new BlockRegistry();
Assert.Equal("Coal Ore", registry.Get(7).Name);
Assert.Equal("Iron Ore", registry.Get(8).Name);
Assert.Equal("Gold Ore", registry.Get(9).Name);
Assert.Equal("Diamond Ore", registry.Get(10).Name);
}
[Fact]
public void Ores_AreOpaqueAndSolid()
{
var registry = new BlockRegistry();
foreach (ushort id in new ushort[] { 7, 8, 9, 10 })
{
Assert.True(registry.Get(id).IsOpaque, $"ore id {id} should be opaque");
Assert.True(registry.Get(id).IsSolid, $"ore id {id} should be solid");
}
}
[Fact]
public void NamedOreAccessors_MatchIds()
{
var registry = new BlockRegistry();
Assert.Equal((ushort)7, registry.CoalOre.Id);
Assert.Equal((ushort)8, registry.IronOre.Id);
Assert.Equal((ushort)9, registry.GoldOre.Id);
Assert.Equal((ushort)10, registry.DiamondOre.Id);
}
}

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

@@ -0,0 +1,84 @@
using VoxelEngine.Core;
using Xunit;
namespace VoxelEngine.Tests;
public class ConfigurationTests
{
[Fact]
public void Defaults_AreSane()
{
var c = new Configuration();
Assert.True(c.RenderDistanceHorizontal >= 1);
Assert.True(c.SpawnReadyRadiusHorizontal >= 0);
Assert.True(c.MaxGenerateJobsInFlight >= 1);
Assert.True(c.MaxMeshUploadsPerFrame >= 1);
Assert.True(c.WorkerThreadCount >= 1);
Assert.True(c.MoveSpeed > 0f);
Assert.True(c.MouseSensitivity > 0f);
Assert.InRange(c.Fov, 1f, 179f);
Assert.True(c.WindowWidth > 0);
Assert.True(c.WindowHeight > 0);
Assert.Null(c.Seed);
}
[Fact]
public void Properties_AreMutable()
{
var c = new Configuration { RenderDistanceHorizontal = 4, Seed = 1337 };
Assert.Equal(4, c.RenderDistanceHorizontal);
Assert.Equal(1337, c.Seed);
var c2 = new Configuration
{
SpawnReadyRadiusHorizontal = 0,
MaxGenerateJobsInFlight = 2,
MaxMeshUploadsPerFrame = 1,
WorkerThreadCount = 2,
MoveSpeed = 4f,
MouseSensitivity = 0.5f,
Fov = 90f,
WindowWidth = 800,
WindowHeight = 600,
};
Assert.Equal(0, c2.SpawnReadyRadiusHorizontal);
Assert.Equal(2, c2.MaxGenerateJobsInFlight);
Assert.Equal(1, c2.MaxMeshUploadsPerFrame);
Assert.Equal(2, c2.WorkerThreadCount);
Assert.Equal(4f, c2.MoveSpeed);
Assert.Equal(0.5f, c2.MouseSensitivity);
Assert.Equal(90f, c2.Fov);
Assert.Equal(800, c2.WindowWidth);
Assert.Equal(600, c2.WindowHeight);
}
[Fact]
public void Defaults_HaveCaveTuningConstants()
{
var c = new Configuration();
// Algorithm
Assert.Equal(0.03f, c.CaveNoiseFrequency);
Assert.Equal(-0.3f, c.CaveNoiseThreshold);
Assert.Equal(64, c.WormCellSize);
Assert.Equal(300, c.WormSpawnProbabilityPermille);
Assert.Equal(80, c.WormMaxLength);
Assert.Equal(1.5f, c.WormMinRadius);
Assert.Equal(3.5f, c.WormMaxRadius);
// Lighting (fog)
Assert.Equal(264f, c.CaveDarkStartY); // SeaLevel (256) + 8
Assert.Equal(224f, c.CaveDarkEndY); // SeaLevel (256) - 32
Assert.Equal(0.15f, c.CaveMinBrightness);
}
[Fact]
public void Defaults_HaveVisibilityAndCaveCeilingConstants()
{
var c = new Configuration();
Assert.Equal(0.7f, c.FogStartFraction);
Assert.Equal(new OpenTK.Mathematics.Vector3(0.45f, 0.65f, 0.95f), c.SkyColor);
Assert.Equal(256, c.MinCaveY);
}
}

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

@@ -0,0 +1,65 @@
using OpenTK.Mathematics;
using VoxelEngine.World.Persistence;
using Xunit;
namespace VoxelEngine.Tests.Persistence;
public class InMemoryChunkModificationStoreTests
{
[Fact]
public void NewStore_HasNoDiffs()
{
var s = new InMemoryChunkModificationStore();
Assert.False(s.HasDiff(new Vector3i(0, 0, 0)));
Assert.False(s.TryGetDiff(new Vector3i(0, 0, 0), out _));
}
[Fact]
public void RecordEdit_StoresAndIsRetrievable()
{
var s = new InMemoryChunkModificationStore();
Vector3i pos = new(1, 2, 3);
s.RecordEdit(pos, 42, 7);
Assert.True(s.HasDiff(pos));
Assert.True(s.TryGetDiff(pos, out var diff));
Assert.Single(diff!);
Assert.Equal((ushort)7, diff![42]);
}
[Fact]
public void RecordEdit_OverwritesSameLocalIndex()
{
var s = new InMemoryChunkModificationStore();
Vector3i pos = new(0, 0, 0);
s.RecordEdit(pos, 10, 1);
s.RecordEdit(pos, 10, 2);
Assert.True(s.TryGetDiff(pos, out var diff));
Assert.Single(diff!);
Assert.Equal((ushort)2, diff![10]);
}
[Fact]
public void RecordEdit_MultipleChunks_AreIndependent()
{
var s = new InMemoryChunkModificationStore();
s.RecordEdit(new Vector3i(0, 0, 0), 0, 1);
s.RecordEdit(new Vector3i(1, 0, 0), 0, 2);
Assert.True(s.TryGetDiff(new Vector3i(0, 0, 0), out var a));
Assert.True(s.TryGetDiff(new Vector3i(1, 0, 0), out var b));
Assert.Equal((ushort)1, a![0]);
Assert.Equal((ushort)2, b![0]);
}
[Fact]
public void ClearDiff_RemovesChunk()
{
var s = new InMemoryChunkModificationStore();
Vector3i pos = new(5, 5, 5);
s.RecordEdit(pos, 0, 1);
s.ClearDiff(pos);
Assert.False(s.HasDiff(pos));
}
}

View File

@@ -0,0 +1,53 @@
using OpenTK.Mathematics;
using VoxelEngine.Core;
using VoxelEngine.Game;
using Xunit;
namespace VoxelEngine.Tests;
public class PlayerTests
{
[Fact]
public void ChunkPosition_Origin_IsZero()
{
var p = new Player(new Configuration(), new Vector3(0, 0, 0), aspectRatio: 1f);
Assert.Equal(new Vector3i(0, 0, 0), p.ChunkPosition);
}
[Fact]
public void ChunkPosition_PositiveCoords_FloorsDown()
{
var p = new Player(new Configuration(), new Vector3(17.5f, 32f, 8f), aspectRatio: 1f);
Assert.Equal(new Vector3i(1, 2, 0), p.ChunkPosition);
}
[Fact]
public void ChunkPosition_NegativeCoords_FloorsToNegativeInfinity()
{
var p = new Player(new Configuration(), new Vector3(-1f, -16f, -17f), aspectRatio: 1f);
Assert.Equal(new Vector3i(-1, -1, -2), p.ChunkPosition);
}
[Fact]
public void ChunkPosition_PositiveExactBoundary_IsNextChunk()
{
var p = new Player(new Configuration(), new Vector3(16f, 0f, 0f), aspectRatio: 1f);
Assert.Equal(new Vector3i(1, 0, 0), p.ChunkPosition);
}
[Fact]
public void Move_UpdatesPosition()
{
var p = new Player(new Configuration(), new Vector3(0, 0, 0), aspectRatio: 1f);
p.Move(new Vector3(5, 0, 0));
Assert.Equal(5f, p.Position.X);
}
[Fact]
public void Constructor_AppliesFovFromConfiguration()
{
var cfg = new Configuration { Fov = 75f };
var p = new Player(cfg, new Vector3(0, 0, 0), aspectRatio: 1.5f);
Assert.Equal(75f, p.Camera.Fov);
}
}

View File

@@ -0,0 +1,575 @@
using System.Linq;
using OpenTK.Mathematics;
using VoxelEngine.Core;
using VoxelEngine.World;
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;
namespace VoxelEngine.Tests.Streaming;
// Synchronous executor: runs the submitted action on the calling thread.
internal sealed class SynchronousJobExecutor : IJobExecutor
{
public void Submit(Action work) => work();
}
// Fake renderer: never touches GL. Just records lifecycle.
internal sealed class FakeRenderer : IChunkRenderer
{
public bool Disposed { get; private set; }
public int DrawCount { get; private set; }
public float[] Mesh { get; }
public FakeRenderer(float[] mesh) { this.Mesh = mesh; }
public void Draw() => this.DrawCount++;
public void Dispose() => this.Disposed = true;
}
// Minimal mesher returning a fixed-length non-empty mesh.
internal sealed class FakeMesher : IChunkMesher
{
public float[] Build(VoxelChunk chunk, BlockRegistry registry, Func<int, int, int, bool>? neighborOpaqueOutside = null)
=> new float[6]; // one vertex worth
}
// Mesher that returns an empty mesh, simulating a pure-air chunk.
internal sealed class EmptyMesher : IChunkMesher
{
public float[] Build(VoxelChunk chunk, BlockRegistry registry, Func<int, int, int, bool>? neighborOpaqueOutside = null)
=> Array.Empty<float>();
}
public class ChunkStreamerTests
{
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();
var world = new World.World(registry, store);
var mesher = new FakeMesher();
var executor = new SynchronousJobExecutor();
var created = new List<FakeRenderer>();
Func<float[], IChunkRenderer> factory = m =>
{
var r = new FakeRenderer(m);
created.Add(r);
return r;
};
Func<Vector3i, VoxelChunk> generate = _ => new VoxelChunk();
Func<int, int, int> height = surfaceHeightAt ?? ((_, _) => 8);
var streamer = new ChunkStreamer(cfg, world, generate, height, mesher, registry, executor, factory);
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,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, world, created) = CreateStreamer(cfg);
streamer.Tick(new Vector3i(0, 0, 0));
// 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++)
{
var pos = new Vector3i(x, 0, z);
Assert.NotNull(world.GetChunk(pos));
Assert.True(streamer.IsReady(pos));
}
}
[Fact]
public void Tick_PlayerMoves_LoadsNewChunks()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, world, created) = CreateStreamer(cfg);
streamer.Tick(new Vector3i(0, 0, 0)); // loads 9
// After the second tick the 3 new x=2 chunks must be ready.
// Cardinal-neighbor dirty-marking may also produce remesh renderers, so
// we assert on world state rather than an exact renderer count.
streamer.Tick(new Vector3i(1, 0, 0));
for (int z = -1; z <= 1; z++)
{
var newPos = new Vector3i(2, 0, z);
Assert.NotNull(world.GetChunk(newPos));
Assert.True(streamer.IsReady(newPos));
}
// x=-1 column must be unloaded
for (int z = -1; z <= 1; z++)
{
Assert.Null(world.GetChunk(new Vector3i(-1, 0, z)));
Assert.False(streamer.IsReady(new Vector3i(-1, 0, z)));
}
}
[Fact]
public void Tick_RespectsMaxMeshUploadsPerFrame()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 2, // cap drains per tick
};
var (streamer, _, created) = CreateStreamer(cfg);
// With sync executor every submitted job completes immediately and
// posts into _completed. The cap throttles how many drain per Tick().
streamer.Tick(new Vector3i(0, 0, 0)); // dispatch fills the queue; drain ≤ 2
Assert.Equal(2, created.Count);
streamer.Tick(new Vector3i(0, 0, 0)); // same player chunk, drains 2 more
Assert.Equal(4, created.Count);
}
[Fact]
public void Tick_ClampsYToWorldBounds()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
// 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);
streamer.Tick(new Vector3i(0, 0, 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]
public void Tick_AppliesModificationDiffOnLoad()
{
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(); // all air
// Pre-record an edit before the chunk loads.
var pos = new Vector3i(0, 0, 0);
int localIndex = (5 * VoxelChunk.Size + 6) * VoxelChunk.Size + 7;
store.RecordEdit(pos, localIndex, registry.Stone.Id);
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);
}
[Fact]
public void Tick_PlayerMovesAway_UnloadsOldChunksAndDisposesRenderer()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, world, created) = CreateStreamer(cfg);
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) 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)));
}
[Fact]
public void Tick_UnloadKeepsModificationStoreDiff()
{
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();
var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory);
var pos = new Vector3i(0, 0, 0);
streamer.Tick(pos);
store.RecordEdit(pos, 0, registry.Stone.Id);
streamer.Tick(new Vector3i(99, 0, 0)); // unloads (0,0,0)
Assert.True(store.HasDiff(pos)); // diff survives unload
}
[Fact]
public void SpawnReady_FlipsTrueOnceSpawnAreaLoaded()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
SpawnReadyRadiusHorizontal = 1,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, _, _) = CreateStreamer(cfg);
Assert.False(streamer.SpawnReady);
// 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);
}
[Fact]
public void MarkDirty_RemeshesChunkOnNextTick()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, _, created) = CreateStreamer(cfg);
// 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)); // exactly one remesh queued (no cascade)
// 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]
public void Tick_LoadingChunk_MarksReadyCardinalNeighborsDirty()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, _, created) = CreateStreamer(cfg);
streamer.Tick(new Vector3i(0, 0, 0)); // load initial 3×3
int firstWave = created.Count; // 9 chunks
// Move so we load a new x=2 column. The new chunks become ready and
// mark their cardinal neighbors (x=1 column) dirty during Drain.
streamer.Tick(new Vector3i(1, 0, 0)); // dispatch + drain new loads → dirty marks set
streamer.Tick(new Vector3i(1, 0, 0)); // dispatch the queued remeshes → drain them
// Should have: 3 new loads + at least 3 remeshes of the x=1 column.
Assert.True(created.Count > firstWave + 3,
$"Expected remeshes beyond the 3 new loads; got {created.Count - firstWave}");
}
[Fact]
public void MarkDirty_OnUntrackedChunk_IsNoOp()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, _, created) = CreateStreamer(cfg);
// 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;
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]
public void Tick_GeneratorThrows_LogsAndDoesNotRetry()
{
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();
int generateCalls = 0;
Func<Vector3i, VoxelChunk> generate = _ =>
{
generateCalls++;
throw new InvalidOperationException("boom");
};
Func<float[], IChunkRenderer> factory = m => new FakeRenderer(m);
var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory);
streamer.Tick(new Vector3i(0, 0, 0));
int callsAfterFirst = generateCalls;
Assert.True(callsAfterFirst > 0);
Assert.False(streamer.IsReady(new Vector3i(0, 0, 0)));
// Tick again — same position. Failed chunks must not be retried.
streamer.Tick(new Vector3i(0, 0, 0));
Assert.Equal(callsAfterFirst, generateCalls);
}
[Fact]
public void InstallResult_EmptyMesh_SkipsRendererCreationButMarksReady()
{
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 EmptyMesher();
var executor = new SynchronousJobExecutor();
int rendererFactoryCalls = 0;
Func<float[], IChunkRenderer> factory = m =>
{
rendererFactoryCalls++;
return new FakeRenderer(m);
};
Func<Vector3i, VoxelChunk> generate = _ => new VoxelChunk();
var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory);
var pos = new Vector3i(0, 0, 0);
streamer.Tick(pos);
Assert.True(streamer.IsReady(pos));
Assert.Equal(0, rendererFactoryCalls);
Assert.Equal(0, streamer.RendererCount);
}
[Fact]
public void Tick_VerticalPriorityWeight_PrefersHorizontalAtSameDistance()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 5, // player's chunk + 4 horizontal cardinals
VerticalPriorityWeight = 4,
};
// 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);
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 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)));
Assert.NotNull(world.GetChunk(new Vector3i( 0, 2, 1)));
Assert.NotNull(world.GetChunk(new Vector3i( 0, 2, -1)));
Assert.Null(world.GetChunk(new Vector3i(0, 3, 0)));
Assert.Null(world.GetChunk(new Vector3i(0, 1, 0)));
}
[Fact]
public void Tick_LoadsFullColumn_FromBedrockToSurface()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0, // single column at the player
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
// 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))); // 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]
public void RecomputeTargetSet_CachesSurfaceHeights_OnlySamplesNewColumns()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
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,
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

@@ -0,0 +1,82 @@
using OpenTK.Mathematics;
using VoxelEngine.Core;
using VoxelEngine.World;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.Chunk;
using VoxelEngine.World.Persistence;
using VoxelEngine.World.Streaming;
using VoxelEngine.World.WorldGen;
using Xunit;
namespace VoxelEngine.Tests.Streaming;
public class StreamingIntegrationTests
{
private static (ChunkStreamer streamer, World.World world, InMemoryChunkModificationStore store)
BuildRealStack(Configuration cfg)
{
var registry = new BlockRegistry();
var store = new InMemoryChunkModificationStore();
var world = new World.World(registry, store);
var gen = new WorldGenerator(seed: 1337, blocks: registry, cfg: new Configuration());
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, gen.SurfaceHeightAt, mesher, registry, executor, factory);
return (streamer, world, store);
}
[Fact]
public void PlayerWalksAcrossWorld_LoadBoxFollowsPlayer()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, world, _) = BuildRealStack(cfg);
for (int step = 0; step < 5; step++)
{
streamer.Tick(new Vector3i(step, 0, 0));
for (int dx = -1; dx <= 1; dx++)
for (int dz = -1; dz <= 1; dz++)
{
var pos = new Vector3i(step + dx, 0, dz);
Assert.NotNull(world.GetChunk(pos));
Assert.True(streamer.IsReady(pos));
}
}
}
[Fact]
public void ModificationStore_SurvivesUnloadReload()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, world, store) = BuildRealStack(cfg);
// Use a block id that differs from what WorldGenerator places at
// world (0,0,0). At y=0 every biome's "Under" block is Stone (id 1),
// so SetBlock(..., Stone) would hit World.SetBlock's no-op guard and
// skip the diff record. Dirt (id 2) is always a real change.
var pos = new Vector3i(0, 0, 0);
streamer.Tick(pos);
Assert.True(streamer.IsReady(pos));
Assert.True(world.SetBlock(0, 0, 0, /*Dirt*/ (ushort)2));
streamer.Tick(new Vector3i(50, 0, 0));
Assert.Null(world.GetChunk(pos));
Assert.True(store.HasDiff(pos));
streamer.Tick(pos);
Assert.NotNull(world.GetChunk(pos));
Assert.Equal((ushort)2, world.GetBlock(0, 0, 0).Id);
}
}

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

@@ -0,0 +1,202 @@
using OpenTK.Mathematics;
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.Chunk;
using VoxelEngine.World.WorldGen;
using Xunit;
namespace VoxelEngine.Tests.WorldGen;
public class CaveCarverTests
{
private static readonly BlockRegistry Blocks = new();
private static readonly Configuration Cfg = new();
private static Chunk MakeStoneChunk()
{
Chunk c = new();
for (int x = 0; x < Chunk.Size; x++)
for (int y = 0; y < Chunk.Size; y++)
for (int z = 0; z < Chunk.Size; z++)
c[x, y, z] = Blocks.Stone.Id;
return c;
}
[Fact]
public void Carve_IsNoOpOnAirChunk()
{
var carver = new CaveCarver(seed: 42, Blocks, Cfg);
Chunk c = new(); // all-air by default
carver.Carve(c, new Vector3i(0, 4, 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.Air.Id, c[x, y, z]);
}
[Fact]
public void NoiseCaves_OnlyConvertsSolidToAir_NeverWaterNeverOtherBlocks()
{
var carver = new CaveCarver(seed: 42, Blocks, Cfg);
Chunk c = MakeStoneChunk();
// Plant some water and air voxels we expect untouched.
c[1, 1, 1] = Blocks.Water.Id;
c[2, 2, 2] = Blocks.Air.Id;
carver.CarveNoiseCaves(c, new Vector3i(0, 4, 0));
Assert.Equal(Blocks.Water.Id, c[1, 1, 1]); // water survived
Assert.Equal(Blocks.Air.Id, c[2, 2, 2]); // air survived
// Spot-check that everywhere else is either Stone or Air (no other blocks).
for (int x = 0; x < Chunk.Size; x++)
for (int y = 0; y < Chunk.Size; y++)
for (int z = 0; z < Chunk.Size; z++)
{
ushort id = c[x, y, z];
Assert.True(
id == Blocks.Stone.Id || id == Blocks.Air.Id || id == Blocks.Water.Id,
$"unexpected block id {id} at ({x},{y},{z})");
}
}
[Fact]
public void NoiseCaves_IsDeterministic_ForSameSeedAndChunkPos()
{
var carver1 = new CaveCarver(seed: 1234, Blocks, Cfg);
var carver2 = new CaveCarver(seed: 1234, Blocks, Cfg);
Chunk a = MakeStoneChunk();
Chunk b = MakeStoneChunk();
carver1.CarveNoiseCaves(a, new Vector3i(2, 4, 3));
carver2.CarveNoiseCaves(b, new Vector3i(2, 4, 3));
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(a[x, y, z], b[x, y, z]);
}
[Fact]
public void NoiseCaves_RespectsBedrockFloor_DoesNotCarveBelowY1()
{
// ChunkPos.Y = 0 means worldY ranges 0..15. We require Y=0 stays solid.
var carver = new CaveCarver(seed: 42, Blocks, Cfg);
Chunk c = MakeStoneChunk();
carver.CarveNoiseCaves(c, new Vector3i(0, 0, 0));
for (int x = 0; x < Chunk.Size; x++)
for (int z = 0; z < Chunk.Size; z++)
Assert.Equal(Blocks.Stone.Id, c[x, 0, z]); // worldY = 0 is bedrock
}
[Fact]
public void NoiseCaves_SeamContinuity_AdjacentChunksAgreeAtBoundary()
{
var carver = new CaveCarver(seed: 7, Blocks, Cfg);
Chunk c1 = MakeStoneChunk();
Chunk c2 = MakeStoneChunk();
carver.CarveNoiseCaves(c1, new Vector3i(0, 4, 0));
carver.CarveNoiseCaves(c2, new Vector3i(1, 4, 0));
int airAtSeamC1 = 0, airAtSeamC2 = 0;
for (int y = 1; y < Chunk.Size; y++)
for (int z = 0; z < Chunk.Size; z++)
{
if (c1[15, y, z] == Blocks.Air.Id) airAtSeamC1++;
if (c2[0, y, z] == Blocks.Air.Id) airAtSeamC2++;
}
// Sanity: noise threshold = -0.3 yields some carved cells in each.
Assert.True(airAtSeamC1 > 0, "c1 seam column had no air — carve too sparse?");
Assert.True(airAtSeamC2 > 0, "c2 seam column had no air — carve too sparse?");
}
[Fact]
public void WormCaves_IsDeterministic_ForSameSeedAndChunkPos()
{
var c1 = new CaveCarver(seed: 999, Blocks, Cfg);
var c2 = new CaveCarver(seed: 999, Blocks, Cfg);
Chunk a = MakeStoneChunk();
Chunk b = MakeStoneChunk();
c1.CarveWormCaves(a, new Vector3i(0, 4, 0));
c2.CarveWormCaves(b, new Vector3i(0, 4, 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(a[x, y, z], b[x, y, z]);
}
[Fact]
public void WormCaves_OnlyConvertsSolidToAir_NeverWater()
{
var carver = new CaveCarver(seed: 999, Blocks, Cfg);
Chunk c = MakeStoneChunk();
// Set every voxel along a diagonal to water — must survive.
for (int i = 0; i < Chunk.Size; i++)
c[i, i, i] = Blocks.Water.Id;
carver.CarveWormCaves(c, new Vector3i(0, 4, 0));
for (int i = 0; i < Chunk.Size; i++)
Assert.Equal(Blocks.Water.Id, c[i, i, i]);
}
[Fact]
public void WormCaves_RespectsBedrockFloor()
{
var carver = new CaveCarver(seed: 999, Blocks, Cfg);
Chunk c = MakeStoneChunk();
carver.CarveWormCaves(c, new Vector3i(0, 0, 0)); // worldY = 0..15
for (int x = 0; x < Chunk.Size; x++)
for (int z = 0; z < Chunk.Size; z++)
Assert.Equal(Blocks.Stone.Id, c[x, 0, z]); // bedrock untouched
}
[Fact]
public void WormCaves_SeamContinuity_VoxelAtSharedWorldCoord_IsSame()
{
var carver = new CaveCarver(seed: 999, Blocks, Cfg);
Chunk c1 = MakeStoneChunk();
Chunk c2 = MakeStoneChunk();
carver.CarveWormCaves(c1, new Vector3i(0, 4, 0));
carver.CarveWormCaves(c2, new Vector3i(1, 4, 0));
int airC1Seam = 0, airC2Seam = 0;
for (int y = 1; y < Chunk.Size; y++)
for (int z = 0; z < Chunk.Size; z++)
{
if (c1[15, y, z] == Blocks.Air.Id) airC1Seam++;
if (c2[0, y, z] == Blocks.Air.Id) airC2Seam++;
}
bool bothEmpty = airC1Seam == 0 && airC2Seam == 0;
bool bothCarved = airC1Seam > 0 && airC2Seam > 0;
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

@@ -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

@@ -1,4 +1,5 @@
using OpenTK.Mathematics;
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.Chunk;
using VoxelEngine.World.WorldGen;
@@ -14,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)
}
@@ -32,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);
}
@@ -41,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
}
@@ -50,16 +51,16 @@ 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);
}
[Fact]
public void Generate_IsDeterministic()
{
var gen1 = new WorldGenerator(99, Blocks);
var gen2 = new WorldGenerator(99, Blocks);
var gen1 = new WorldGenerator(99, Blocks, new Configuration());
var gen2 = new WorldGenerator(99, Blocks, new Configuration());
Chunk a = gen1.Generate(new Vector3i(0, 4, 0));
Chunk b = gen2.Generate(new Vector3i(0, 4, 0));
for (int x = 0; x < Chunk.Size; x++)
@@ -67,4 +68,32 @@ public class WorldGeneratorTests
for (int z = 0; z < Chunk.Size; z++)
Assert.Equal(a[x, y, z], b[x, y, z]);
}
[Fact]
public void Generate_AppliesCaves_ProducesAirVoxelsBelowSurface()
{
var gen = new WorldGenerator(seed: 12345, Blocks, new Configuration());
// A chunk well below typical surface height — guaranteed mostly stone before carving.
Chunk c = gen.Generate(new Vector3i(0, 1, 0)); // worldY = 16..31
int airCount = 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++)
if (c[x, y, z] == Blocks.Air.Id) airCount++;
Assert.True(airCount > 0,
"Deep chunk had zero air voxels after generation — caves not running?");
}
[Fact]
public void Generate_PreservesBedrockFloor_Y0_IsStone()
{
var gen = new WorldGenerator(seed: 12345, Blocks, new Configuration());
Chunk c = gen.Generate(new Vector3i(0, 0, 0)); // worldY = 0..15
for (int x = 0; x < Chunk.Size; x++)
for (int z = 0; z < Chunk.Size; z++)
Assert.Equal(Blocks.Stone.Id, c[x, 0, z]);
}
}

View File

@@ -0,0 +1,40 @@
using VoxelEngine.World.WorldGen;
using Xunit;
namespace VoxelEngine.Tests.WorldGen;
public class WormHashTests
{
[Fact]
public void Hash_IsDeterministic_ForSameInputs()
{
uint a = WormHash.Hash(3, -2, 7, seed: 1337);
uint b = WormHash.Hash(3, -2, 7, seed: 1337);
Assert.Equal(a, b);
}
[Fact]
public void Hash_DiffersForDifferentCells()
{
uint a = WormHash.Hash(3, -2, 7, seed: 1337);
uint b = WormHash.Hash(3, -2, 8, seed: 1337);
Assert.NotEqual(a, b);
}
[Fact]
public void Hash_DiffersForDifferentSeeds()
{
uint a = WormHash.Hash(3, -2, 7, seed: 1337);
uint b = WormHash.Hash(3, -2, 7, seed: 9999);
Assert.NotEqual(a, b);
}
[Fact]
public void Hash_HandlesNegativeCoords()
{
// Just shouldn't throw and shouldn't collide trivially with the positive case.
uint a = WormHash.Hash(-1, -1, -1, seed: 1);
uint b = WormHash.Hash(1, 1, 1, seed: 1);
Assert.NotEqual(a, b);
}
}

View File

@@ -0,0 +1,88 @@
using OpenTK.Mathematics;
using VoxelEngine.World;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.Persistence;
using Xunit;
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
namespace VoxelEngine.Tests;
public class WorldTests
{
private static (IWorld, BlockRegistry, IChunkModificationStore) Create()
{
var registry = new BlockRegistry();
var store = new InMemoryChunkModificationStore();
IWorld world = new World.World(registry, store);
return (world, registry, store);
}
[Fact]
public void SetBlock_LoadedChunk_WritesBlockAndRecordsDiff()
{
var (world, registry, store) = Create();
var chunkPos = new Vector3i(0, 0, 0);
world.SetChunk(chunkPos, new VoxelChunk());
bool result = world.SetBlock(2, 3, 4, registry.Stone.Id);
Assert.True(result);
Assert.Equal(registry.Stone.Id, world.GetBlock(2, 3, 4).Id);
Assert.True(store.HasDiff(chunkPos));
Assert.True(store.TryGetDiff(chunkPos, out var diff));
int expectedIndex = (2 * VoxelChunk.Size + 3) * VoxelChunk.Size + 4;
Assert.Equal(registry.Stone.Id, diff![expectedIndex]);
}
[Fact]
public void SetBlock_UnloadedChunk_RecordsDiffButReturnsFalse()
{
var (world, registry, store) = Create();
var chunkPos = new Vector3i(5, 0, 0);
bool result = world.SetBlock(5 * VoxelChunk.Size, 0, 0, registry.Stone.Id);
Assert.False(result);
Assert.True(store.HasDiff(chunkPos));
}
[Fact]
public void GetBlock_UnloadedChunk_ReturnsAir()
{
var (world, registry, _) = Create();
Assert.Same(registry.Air, world.GetBlock(0, 0, 0));
}
[Fact]
public void SetBlock_NegativeCoords_FloorsToCorrectChunk()
{
var (world, registry, store) = Create();
var chunkPos = new Vector3i(-1, 0, 0);
world.SetChunk(chunkPos, new VoxelChunk());
bool result = world.SetBlock(-1, 0, 0, registry.Stone.Id);
Assert.True(result);
Assert.Equal(registry.Stone.Id, world.GetBlock(-1, 0, 0).Id);
Assert.True(store.HasDiff(chunkPos));
}
[Fact]
public void RemoveChunk_RemovesAndGetBlockReturnsAir()
{
var (world, registry, _) = Create();
var chunkPos = new Vector3i(0, 0, 0);
world.SetChunk(chunkPos, new VoxelChunk());
world.RemoveChunk(chunkPos);
Assert.Null(world.GetChunk(chunkPos));
Assert.Same(registry.Air, world.GetBlock(0, 0, 0));
}
[Fact]
public void ModificationStore_AccessorReturnsConstructorInjectedStore()
{
var (world, _, store) = Create();
Assert.Same(store, world.ModificationStore);
}
}

View File

@@ -10,6 +10,9 @@
<ItemGroup>
<PackageReference Include="OpenTK" Version="4.9.4" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="StbImageSharp" Version="2.30.15" />
</ItemGroup>
@@ -19,4 +22,8 @@
</None>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="VoxelEngine.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace VoxelEngine.World.Blocks;
public sealed class CoalOreBlock : IBlock
{
public ushort Id => 7;
public string Name => "Coal Ore";
public bool IsOpaque => true;
public bool IsSolid => true;
public TileCoord GetTextureTile(BlockFace face) => new(2, 2); // TODO: Add Texture
}

View File

@@ -0,0 +1,10 @@
namespace VoxelEngine.World.Blocks;
public sealed class DiamondOreBlock : IBlock
{
public ushort Id => 10;
public string Name => "Diamond Ore";
public bool IsOpaque => true;
public bool IsSolid => true;
public TileCoord GetTextureTile(BlockFace face) => new(0, 2); // TODO: Add Texture
}

View File

@@ -0,0 +1,10 @@
namespace VoxelEngine.World.Blocks;
public sealed class GoldOreBlock : IBlock
{
public ushort Id => 9;
public string Name => "Gold Ore";
public bool IsOpaque => true;
public bool IsSolid => true;
public TileCoord GetTextureTile(BlockFace face) => new(3, 2); // TODO: Add Texture
}

View File

@@ -0,0 +1,10 @@
namespace VoxelEngine.World.Blocks;
public sealed class IronOreBlock : IBlock
{
public ushort Id => 8;
public string Name => "Iron Ore";
public bool IsOpaque => true;
public bool IsSolid => true;
public TileCoord GetTextureTile(BlockFace face) => new(1, 2); // TODO: Add Texture
}

View File

@@ -4,19 +4,25 @@ public sealed class BlockRegistry
{
private readonly IBlock[] _byId;
public AirBlock Air { get; } = new();
public StoneBlock Stone { get; } = new();
public DirtBlock Dirt { get; } = new();
public GrassBlock Grass { get; } = new();
public SandBlock Sand { get; } = new();
public SnowBlock Snow { get; } = new();
public WaterBlock Water { get; } = new();
public AirBlock Air { get; } = new();
public StoneBlock Stone { get; } = new();
public DirtBlock Dirt { get; } = new();
public GrassBlock Grass { get; } = new();
public SandBlock Sand { get; } = new();
public SnowBlock Snow { get; } = new();
public WaterBlock Water { get; } = new();
public CoalOreBlock CoalOre { get; } = new();
public IronOreBlock IronOre { get; } = new();
public GoldOreBlock GoldOre { get; } = new();
public DiamondOreBlock DiamondOre { get; } = new();
public BlockRegistry()
{
// Index by id. Order does not matter; slot = block.Id.
IBlock[] blocks = new IBlock[]
{ this.Air, this.Stone, this.Dirt, this.Grass, this.Sand, this.Snow, this.Water };
{
this.Air, this.Stone, this.Dirt, this.Grass, this.Sand, this.Snow, this.Water,
this.CoalOre, this.IronOre, this.GoldOre, this.DiamondOre,
};
this._byId = new IBlock[blocks.Length];
foreach (IBlock block in blocks)
{

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

@@ -1,11 +1,16 @@
using OpenTK.Mathematics;
using OpenTK.Mathematics;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.Persistence;
namespace VoxelEngine.World;
public interface IWorld
{
public void SetChunk(Vector3i chunkPos, Chunk.Chunk chunk);
public Chunk.Chunk? GetChunk(Vector3i chunkPos);
public IBlock GetBlock(int worldX, int worldY, int worldZ);
IChunkModificationStore ModificationStore { get; }
void SetChunk(Vector3i chunkPos, Chunk.Chunk chunk);
void RemoveChunk(Vector3i chunkPos);
Chunk.Chunk? GetChunk(Vector3i chunkPos);
IBlock GetBlock(int worldX, int worldY, int worldZ);
bool SetBlock(int worldX, int worldY, int worldZ, ushort blockId);
}

View File

@@ -0,0 +1,11 @@
using OpenTK.Mathematics;
namespace VoxelEngine.World.Persistence;
public interface IChunkModificationStore
{
bool TryGetDiff(Vector3i chunkPos, out IReadOnlyDictionary<int, ushort>? diff);
void RecordEdit(Vector3i chunkPos, int localIndex, ushort blockId);
void ClearDiff(Vector3i chunkPos);
bool HasDiff(Vector3i chunkPos);
}

View File

@@ -0,0 +1,35 @@
using OpenTK.Mathematics;
namespace VoxelEngine.World.Persistence;
public sealed class InMemoryChunkModificationStore : IChunkModificationStore
{
private readonly Dictionary<Vector3i, Dictionary<int, ushort>> _diffs = new();
public bool TryGetDiff(Vector3i chunkPos, out IReadOnlyDictionary<int, ushort>? diff)
{
if (this._diffs.TryGetValue(chunkPos, out Dictionary<int, ushort>? d))
{
// Returned snapshot: callers may safely retain and enumerate even
// after concurrent RecordEdit on the same chunk.
diff = new Dictionary<int, ushort>(d);
return true;
}
diff = null;
return false;
}
public void RecordEdit(Vector3i chunkPos, int localIndex, ushort blockId)
{
if (!this._diffs.TryGetValue(chunkPos, out Dictionary<int, ushort>? d))
{
d = new Dictionary<int, ushort>();
this._diffs[chunkPos] = d;
}
d[localIndex] = blockId;
}
public void ClearDiff(Vector3i chunkPos) => this._diffs.Remove(chunkPos);
public bool HasDiff(Vector3i chunkPos) => this._diffs.ContainsKey(chunkPos);
}

View File

@@ -0,0 +1,21 @@
using OpenTK.Mathematics;
using VoxelEngine.World.Chunk;
namespace VoxelEngine.World.Streaming;
// Job dispatched to an IJobExecutor for a fresh chunk load. The worker
// generates the chunk, applies any modification diff, and meshes the result.
// Remesh-only jobs are dispatched separately by DispatchRemesh and capture
// their state via closures, so they do not use this record.
internal sealed record ChunkJob(
Vector3i Pos,
NeighborSnapshots Neighbors,
IReadOnlyDictionary<int, ushort>? Diff);
// Posted by the worker back to the main thread via a ConcurrentQueue.
internal sealed record ChunkJobResult(
Vector3i Pos,
Chunk.Chunk? Chunk,
float[]? Mesh,
Exception? Error,
bool IsRemeshOnly);

View File

@@ -0,0 +1,485 @@
using System.Collections.Concurrent;
using System.Linq;
using OpenTK.Mathematics;
using Serilog;
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.Chunk;
using VoxelEngine.World.WorldGen;
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
namespace VoxelEngine.World.Streaming;
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-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;
private readonly Func<float[], IChunkRenderer> _rendererFactory;
private readonly Dictionary<Vector3i, IChunkRenderer> _renderers = new();
private readonly HashSet<Vector3i> _tracked = new();
private readonly HashSet<Vector3i> _ready = new();
private readonly HashSet<Vector3i> _dirty = new();
private readonly PriorityQueue<Vector3i, int> _pendingLoad = new();
private readonly ConcurrentQueue<ChunkJobResult> _completed = new();
private readonly Queue<Vector3i> _pendingUnload = new();
private readonly HashSet<Vector3i> _failed = new();
private Vector3i _lastPlayerChunk = new(int.MinValue, int.MinValue, int.MinValue);
private int _inFlight;
private bool _spawnReady;
private bool _spawnAnchorSet;
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;
public void MarkDirty(Vector3i chunkPos)
{
if (!this._ready.Contains(chunkPos)) return;
this._dirty.Add(chunkPos);
}
public ChunkStreamer(
Configuration cfg,
IWorld world,
Func<Vector3i, VoxelChunk> generate,
Func<int, int, int> surfaceHeightAt,
IChunkMesher mesher,
BlockRegistry registry,
IJobExecutor executor,
Func<float[], IChunkRenderer> rendererFactory)
{
this._cfg = cfg;
this._world = world;
this._generate = generate;
this._surfaceHeightAt = surfaceHeightAt;
this._mesher = mesher;
this._registry = registry;
this._executor = executor;
this._rendererFactory = rendererFactory;
}
public void Tick(Vector3i playerChunkPos)
{
if (playerChunkPos != this._lastPlayerChunk)
{
this.RecomputeTargetSet(playerChunkPos);
this._lastPlayerChunk = playerChunkPos;
}
// Unloads first to free in-flight slots, then dispatch BEFORE drain:
// with a synchronous executor (tests), Submit() runs work to completion
// before returning, so a single tick must drain its own dispatches.
// With the production threadpool executor, jobs don't finish in this
// tick -- Drain harvests results submitted on previous ticks. Either
// way this ordering is correct.
this.ProcessUnloads();
this.DispatchJobs();
this.DrainCompleted();
this.UpdateSpawnReady();
}
// 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)
{
int drawn = 0;
foreach (KeyValuePair<Vector3i, IChunkRenderer> kvp in this._renderers)
{
if (!isChunkVisible(kvp.Key)) continue;
draw(kvp.Key, kvp.Value);
drawn++;
}
return drawn;
}
public void Dispose()
{
foreach (IChunkRenderer r in this._renderers.Values) r.Dispose();
this._renderers.Clear();
}
private void RecomputeTargetSet(Vector3i p)
{
if (!this._spawnAnchorSet)
{
this._spawnAnchor = p;
this._spawnAnchorSet = true;
}
HashSet<Vector3i> target = new();
this.CollectTargets(p, this._cfg.RenderDistanceHorizontal, target);
foreach (Vector3i pos in target)
{
if (this._failed.Contains(pos)) continue;
if (this._tracked.Add(pos))
{
int dx = pos.X - p.X;
int dy = pos.Y - p.Y;
int dz = pos.Z - p.Z;
int priority = dx * dx
+ dz * dz
+ dy * dy * this._cfg.VerticalPriorityWeight;
this._pendingLoad.Enqueue(pos, priority);
}
}
// Anything currently tracked but no longer in target → unload.
List<Vector3i> toRemove = new();
foreach (Vector3i tracked in this._tracked)
{
if (!target.Contains(tracked)) toRemove.Add(tracked);
}
foreach (Vector3i pos in toRemove)
{
this._tracked.Remove(pos);
this._pendingUnload.Enqueue(pos);
}
this.PruneSurfaceCache(p);
}
// 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 ((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 ((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 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)
{
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 top = this.SurfaceTopChunk(cx, cz);
AddColumn(into, cx, cz, 0, top);
// 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);
}
}
}
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));
}
// 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._surfaceTopCache.TryGetValue((cx, cz), out int 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 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)));
this._surfaceTopCache[(cx, cz)] = max;
return max;
}
private void DrainCompleted()
{
int budget = this._cfg.MaxMeshUploadsPerFrame;
while (budget > 0 && this._completed.TryDequeue(out ChunkJobResult? result))
{
this._inFlight--;
budget--;
this.InstallResult(result);
}
}
private void ProcessUnloads()
{
while (this._pendingUnload.TryDequeue(out Vector3i pos))
{
if (this._renderers.TryGetValue(pos, out IChunkRenderer? renderer))
{
renderer.Dispose();
this._renderers.Remove(pos);
}
this._world.RemoveChunk(pos);
this._ready.Remove(pos);
this._failed.Remove(pos);
Log.Debug("Unloaded {Pos}", pos);
}
}
// 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;
int hs = this._cfg.SpawnReadyRadiusHorizontal;
Vector3i a = this._spawnAnchor;
for (int dx = -hs; dx <= hs; dx++)
for (int dz = -hs; dz <= hs; dz++)
{
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;
Log.Information("Spawn area ready at anchor {Anchor}", this._spawnAnchor);
}
private void InstallResult(ChunkJobResult result)
{
if (result.Error != null || result.Mesh == null)
{
this._failed.Add(result.Pos);
Log.Error(result.Error, "Chunk {Pos} failed to load", result.Pos);
return;
}
if (!result.IsRemeshOnly)
{
if (result.Chunk == null) { Log.Error("Load result for {Pos} had null chunk", result.Pos); return; }
this._world.SetChunk(result.Pos, result.Chunk);
}
if (this._renderers.TryGetValue(result.Pos, out IChunkRenderer? old))
{
old.Dispose();
this._renderers.Remove(result.Pos);
}
// Skip renderer allocation for empty meshes (pure-air chunks above
// terrain, or chunks whose every face culls). The chunk still counts
// as ready — DrawVisible's iteration over _renderers naturally no-ops.
if (result.Mesh.Length > 0)
{
this._renderers[result.Pos] = this._rendererFactory(result.Mesh);
}
this._ready.Add(result.Pos);
this._dirty.Remove(result.Pos);
if (!result.IsRemeshOnly)
{
this.MarkCardinalNeighborsDirty(result.Pos);
Log.Debug("Loaded {Pos}", result.Pos);
}
else
{
Log.Debug("Remeshed {Pos}", result.Pos);
}
}
private void MarkCardinalNeighborsDirty(Vector3i pos)
{
Vector3i[] offsets =
{
new(-1, 0, 0), new(1, 0, 0),
new(0, -1, 0), new(0, 1, 0),
new(0, 0, -1), new(0, 0, 1),
};
foreach (Vector3i off in offsets)
{
Vector3i neighbor = pos + off;
if (this._ready.Contains(neighbor)) this._dirty.Add(neighbor);
}
}
private void DispatchJobs()
{
// Dirty re-meshes first (correctness), then new loads (player view).
while (this._inFlight < this._cfg.MaxGenerateJobsInFlight && this._dirty.Count > 0)
{
Vector3i pos = this._dirty.First();
this._dirty.Remove(pos);
this.DispatchRemesh(pos);
}
while (this._inFlight < this._cfg.MaxGenerateJobsInFlight && this._pendingLoad.Count > 0)
{
Vector3i pos = this._pendingLoad.Dequeue();
this.DispatchLoad(pos);
}
}
private void DispatchRemesh(Vector3i pos)
{
VoxelChunk? chunk = this._world.GetChunk(pos);
if (chunk == null) return; // got unloaded between MarkDirty and now
NeighborSnapshots neighbors = this.SnapshotNeighbors(pos);
VoxelChunk snapshotChunk = CopyChunk(chunk); // worker reads from copy
this._inFlight++;
IChunkMesher mesher = this._mesher;
BlockRegistry registry = this._registry;
ConcurrentQueue<ChunkJobResult> completed = this._completed;
this._executor.Submit(() =>
{
try
{
float[] mesh = mesher.Build(snapshotChunk, registry, (nx, ny, nz) =>
neighbors.IsOpaqueOutside(nx, ny, nz, id => registry.Get(id).IsOpaque));
completed.Enqueue(new ChunkJobResult(pos, snapshotChunk, mesh, Error: null, IsRemeshOnly: true));
}
catch (Exception ex)
{
completed.Enqueue(new ChunkJobResult(pos, Chunk: null, Mesh: null, Error: ex, IsRemeshOnly: true));
}
});
}
private static VoxelChunk CopyChunk(VoxelChunk source)
{
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++)
{
copy[x, y, z] = source[x, y, z];
}
return copy;
}
private void DispatchLoad(Vector3i pos)
{
NeighborSnapshots neighbors = this.SnapshotNeighbors(pos);
this._world.ModificationStore.TryGetDiff(pos, out IReadOnlyDictionary<int, ushort>? diff);
ChunkJob job = new(pos, neighbors, diff);
this._inFlight++;
IChunkMesher mesher = this._mesher;
BlockRegistry registry = this._registry;
Func<Vector3i, VoxelChunk> generate = this._generate;
ConcurrentQueue<ChunkJobResult> completed = this._completed;
this._executor.Submit(() =>
{
try
{
VoxelChunk chunk = generate(job.Pos);
if (job.Diff != null)
{
foreach ((int idx, ushort id) in job.Diff)
{
int lx = idx / (VoxelChunk.Size * VoxelChunk.Size);
int ly = (idx / VoxelChunk.Size) % VoxelChunk.Size;
int lz = idx % VoxelChunk.Size;
chunk[lx, ly, lz] = id;
}
}
float[] mesh = mesher.Build(chunk, registry, (nx, ny, nz) =>
job.Neighbors.IsOpaqueOutside(nx, ny, nz, id => registry.Get(id).IsOpaque));
completed.Enqueue(new ChunkJobResult(job.Pos, chunk, mesh, Error: null, IsRemeshOnly: false));
}
catch (Exception ex)
{
completed.Enqueue(new ChunkJobResult(job.Pos, Chunk: null, Mesh: null, Error: ex, IsRemeshOnly: false));
}
});
}
private NeighborSnapshots SnapshotNeighbors(Vector3i pos)
{
ushort[]? Snap(Vector3i p)
{
VoxelChunk? c = this._world.GetChunk(p);
if (c == null) return null;
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++)
{
copy[(x * VoxelChunk.Size + y) * VoxelChunk.Size + z] = c[x, y, z];
}
return copy;
}
return new NeighborSnapshots(
negX: Snap(pos + new Vector3i(-1, 0, 0)),
posX: Snap(pos + new Vector3i(1, 0, 0)),
negY: Snap(pos + new Vector3i(0, -1, 0)),
posY: Snap(pos + new Vector3i(0, 1, 0)),
negZ: Snap(pos + new Vector3i(0, 0, -1)),
posZ: Snap(pos + new Vector3i(0, 0, 1)));
}
}

View File

@@ -0,0 +1,6 @@
namespace VoxelEngine.World.Streaming;
public interface IChunkRenderer : IDisposable
{
void Draw();
}

View File

@@ -0,0 +1,15 @@
namespace VoxelEngine.World.Streaming;
public interface IJobExecutor
{
void Submit(Action work);
}
public sealed class ThreadPoolJobExecutor : IJobExecutor
{
// Fire-and-forget: the Task is intentionally discarded. The caller's `work`
// delegate MUST catch its own exceptions and report them via a dedicated
// channel (e.g. ChunkJobResult.Error in the streaming pipeline). Unhandled
// exceptions here are silently lost.
public void Submit(Action work) => _ = Task.Run(work);
}

View File

@@ -0,0 +1,52 @@
using VoxelEngine.World.Chunk;
namespace VoxelEngine.World.Streaming;
// Snapshots of the six cardinal neighbor chunks' block arrays, taken on the
// main thread at job-dispatch time. Each slot is null if that neighbor is not
// currently loaded. The ChunkMesher reads only these arrays from a worker
// thread — never the live World.
public sealed class NeighborSnapshots
{
public ushort[]? NegX { get; }
public ushort[]? PosX { get; }
public ushort[]? NegY { get; }
public ushort[]? PosY { get; }
public ushort[]? NegZ { get; }
public ushort[]? PosZ { get; }
// Snapshots take ownership of the arrays passed in. Callers must hand
// in fresh copies (not live references into a chunk's _ids buffer) —
// the snapshot does NOT copy defensively. This is enforced at the
// single call-site in ChunkStreamer.SnapshotNeighbors which allocates
// fresh arrays. The arrays must not be mutated after construction.
public NeighborSnapshots(
ushort[]? negX, ushort[]? posX,
ushort[]? negY, ushort[]? posY,
ushort[]? negZ, ushort[]? posZ)
{
this.NegX = negX; this.PosX = posX;
this.NegY = negY; this.PosY = posY;
this.NegZ = negZ; this.PosZ = posZ;
}
// Returns true if the out-of-chunk position (nx, ny, nz) — exactly one of
// which is out of [0, Chunk.Chunk.Size) — is occupied by an opaque block
// in the snapshot. Returns false if the relevant neighbor is null.
public bool IsOpaqueOutside(int nx, int ny, int nz, Func<ushort, bool> isOpaque)
{
ushort[]? n;
int lx = nx, ly = ny, lz = nz;
if (nx < 0) { n = this.NegX; lx = Chunk.Chunk.Size - 1; }
else if (nx >= Chunk.Chunk.Size) { n = this.PosX; lx = 0; }
else if (ny < 0) { n = this.NegY; ly = Chunk.Chunk.Size - 1; }
else if (ny >= Chunk.Chunk.Size) { n = this.PosY; ly = 0; }
else if (nz < 0) { n = this.NegZ; lz = Chunk.Chunk.Size - 1; }
else if (nz >= Chunk.Chunk.Size) { n = this.PosZ; lz = 0; }
else return false; // not actually outside — shouldn't happen, mesher guards
if (n == null) return false;
int index = (lx * Chunk.Chunk.Size + ly) * Chunk.Chunk.Size + lz;
return isOpaque(n[index]);
}
}

View File

@@ -1,63 +1,62 @@
using System.Collections;
using OpenTK.Mathematics;
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.Persistence;
namespace VoxelEngine.World;
public sealed class World : IWorld, IEnumerable<KeyValuePair<Vector3i, Chunk.Chunk>>
public sealed class World(BlockRegistry registry, IChunkModificationStore modStore)
: IWorld
{
private readonly Dictionary<Vector3i, Chunk.Chunk> _chunks = new();
private readonly BlockRegistry _registry;
public World(BlockRegistry registry)
{
this._registry = registry;
}
public IChunkModificationStore ModificationStore { get; } = modStore;
public void SetChunk(Vector3i chunkPos, Chunk.Chunk chunk)
{
this._chunks[chunkPos] = chunk;
}
public void SetChunk(Vector3i chunkPos, Chunk.Chunk chunk) => this._chunks[chunkPos] = chunk;
public Chunk.Chunk? GetChunk(Vector3i chunkPos)
{
return this._chunks.GetValueOrDefault(chunkPos);
}
public void RemoveChunk(Vector3i chunkPos) => this._chunks.Remove(chunkPos);
public Chunk.Chunk? GetChunk(Vector3i chunkPos) => this._chunks.GetValueOrDefault(chunkPos);
public IBlock GetBlock(int worldX, int worldY, int worldZ)
{
Vector3i chunkPos = new(
World.FloorDiv(worldX, Chunk.Chunk.Size),
World.FloorDiv(worldY, Chunk.Chunk.Size),
World.FloorDiv(worldZ, Chunk.Chunk.Size)
);
ChunkMath.FloorDiv(worldX, Chunk.Chunk.Size),
ChunkMath.FloorDiv(worldY, Chunk.Chunk.Size),
ChunkMath.FloorDiv(worldZ, Chunk.Chunk.Size));
Chunk.Chunk? chunk = this.GetChunk(chunkPos);
if (chunk == null)
{
return this._registry.Air;
}
if (chunk == null) return registry.Air;
int localX = worldX - chunkPos.X * Chunk.Chunk.Size;
int localY = worldY - chunkPos.Y * Chunk.Chunk.Size;
int localZ = worldZ - chunkPos.Z * Chunk.Chunk.Size;
return this._registry.Get(chunk[localX, localY, localZ]);
int localX = worldX - chunkPos.X * Chunk.Chunk.Size;
int localY = worldY - chunkPos.Y * Chunk.Chunk.Size;
int localZ = worldZ - chunkPos.Z * Chunk.Chunk.Size;
return registry.Get(chunk[localX, localY, localZ]);
}
private static int FloorDiv(int value, int size)
public bool SetBlock(int worldX, int worldY, int worldZ, ushort blockId)
{
int quotient = value / size;
if (value % size != 0 && value < 0)
{
quotient--;
}
return quotient;
Vector3i chunkPos = new(
ChunkMath.FloorDiv(worldX, Chunk.Chunk.Size),
ChunkMath.FloorDiv(worldY, Chunk.Chunk.Size),
ChunkMath.FloorDiv(worldZ, Chunk.Chunk.Size));
Chunk.Chunk? chunk = this.GetChunk(chunkPos);
int localX = worldX - chunkPos.X * Chunk.Chunk.Size;
int localY = worldY - chunkPos.Y * Chunk.Chunk.Size;
int localZ = worldZ - chunkPos.Z * Chunk.Chunk.Size;
int localIndex = (localX * Chunk.Chunk.Size + localY) * Chunk.Chunk.Size + localZ;
// For a loaded chunk, no-op writes shouldn't pollute the diff store.
if (chunk != null && chunk[localX, localY, localZ] == blockId) return true;
this.ModificationStore.RecordEdit(chunkPos, localIndex, blockId);
if (chunk == null) return false;
chunk[localX, localY, localZ] = blockId;
return true;
}
public IEnumerator<KeyValuePair<Vector3i, Chunk.Chunk>> GetEnumerator()
=> this._chunks.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator();
}

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

@@ -0,0 +1,180 @@
using OpenTK.Mathematics;
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
namespace VoxelEngine.World.WorldGen;
// Carves caves into an already-terrain-filled chunk. Stateless after
// construction; the carve methods are pure functions of (chunk, chunkPos).
// Safe to share across worker threads.
public sealed class CaveCarver
{
private readonly int _seed;
private readonly BlockRegistry _blocks;
private readonly Configuration _cfg;
private readonly FastNoiseLite _caveNoise;
private readonly FastNoiseLite _wormDirNoise;
public CaveCarver(int seed, BlockRegistry blocks, Configuration cfg)
{
this._seed = seed;
this._blocks = blocks;
this._cfg = cfg;
this._caveNoise = new FastNoiseLite(seed + 100);
this._caveNoise.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._caveNoise.SetFrequency(cfg.CaveNoiseFrequency);
this._wormDirNoise = new FastNoiseLite(seed + 200);
this._wormDirNoise.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._wormDirNoise.SetFrequency(0.05f);
}
public void Carve(VoxelChunk chunk, Vector3i chunkPos)
{
this.CarveNoiseCaves(chunk, chunkPos);
this.CarveWormCaves(chunk, chunkPos);
}
// Per-voxel 3D simplex threshold. Public for direct testability,
// matching the pattern of WorldGenerator.BlockAt.
public void CarveNoiseCaves(VoxelChunk chunk, Vector3i chunkPos)
{
int baseX = chunkPos.X * VoxelChunk.Size;
int baseY = chunkPos.Y * VoxelChunk.Size;
int baseZ = chunkPos.Z * 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++)
{
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;
if (id == this._blocks.Water.Id) continue;
float density = this._caveNoise.GetNoise(baseX + x, worldY, baseZ + z);
if (density < this._cfg.CaveNoiseThreshold)
{
chunk[x, y, z] = this._blocks.Air.Id;
}
}
}
public void CarveWormCaves(VoxelChunk chunk, Vector3i chunkPos)
{
int cellSize = this._cfg.WormCellSize;
int radius = (int)Math.Ceiling((double)this._cfg.WormMaxLength / cellSize) + 1;
int chunkBaseX = chunkPos.X * VoxelChunk.Size;
int chunkBaseY = chunkPos.Y * VoxelChunk.Size;
int chunkBaseZ = chunkPos.Z * VoxelChunk.Size;
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++)
for (int cdz = -radius; cdz <= radius; cdz++)
{
int cellX = chunkCellX + cdx;
int cellY = chunkCellY + cdy;
int cellZ = chunkCellZ + cdz;
uint h = WormHash.Hash(cellX, cellY, cellZ, this._seed);
if ((h % 1000) >= (uint)this._cfg.WormSpawnProbabilityPermille) continue;
// Hash bit layout: [ox:6][oy:6][oz:6][theta:8][phi:6] = 32 bits
float ox = (h & 0x3F) / 64f * cellSize;
float oy = ((h >> 6) & 0x3F) / 64f * cellSize;
float oz = ((h >> 12) & 0x3F) / 64f * cellSize;
Vector3 pos = new(cellX * cellSize + ox,
cellY * cellSize + oy,
cellZ * cellSize + oz);
float theta = ((h >> 18) & 0xFF) / 255f * MathF.Tau;
float phi = (((h >> 26) & 0x3F) / 63f * 2f - 1f) * 0.3f * MathF.PI;
Vector3 dir = new(
MathF.Cos(theta) * MathF.Cos(phi),
MathF.Sin(phi),
MathF.Sin(theta) * MathF.Cos(phi));
dir = Vector3.Normalize(dir);
this.TraceAndCarveWorm(chunk, chunkPos, pos, dir);
}
}
private void TraceAndCarveWorm(VoxelChunk chunk, Vector3i chunkPos,
Vector3 startPos, Vector3 startDir)
{
int maxLen = this._cfg.WormMaxLength;
float minR = this._cfg.WormMinRadius;
float maxR = this._cfg.WormMaxRadius;
Vector3 pos = startPos;
Vector3 dir = startDir;
for (int step = 0; step < maxLen; step++)
{
float t = step / (float)maxLen;
float bulgeT = 0.5f + 0.5f * MathF.Sin(t * MathF.Tau * 3f);
float r = minR + (maxR - minR) * bulgeT;
this.CarveSphere(chunk, chunkPos, pos, r);
float px = this._wormDirNoise.GetNoise(pos.X, pos.Y, pos.Z);
float py = this._wormDirNoise.GetNoise(pos.X + 100f, pos.Y, pos.Z);
float pz = this._wormDirNoise.GetNoise(pos.X, pos.Y, pos.Z + 100f);
Vector3 perturbation = new(px, py, pz);
dir = Vector3.Normalize(dir + perturbation * 0.3f);
dir = new Vector3(dir.X, dir.Y * 0.7f, dir.Z);
dir = Vector3.Normalize(dir);
pos += dir;
}
}
private void CarveSphere(VoxelChunk chunk, Vector3i chunkPos, Vector3 center, float r)
{
int baseX = chunkPos.X * VoxelChunk.Size;
int baseY = chunkPos.Y * VoxelChunk.Size;
int baseZ = chunkPos.Z * VoxelChunk.Size;
int xMin = Math.Max(0, (int)MathF.Floor(center.X - r) - baseX);
int xMax = Math.Min(VoxelChunk.Size - 1, (int)MathF.Ceiling(center.X + r) - baseX);
int yMin = Math.Max(0, (int)MathF.Floor(center.Y - r) - baseY);
int yMax = Math.Min(VoxelChunk.Size - 1, (int)MathF.Ceiling(center.Y + r) - baseY);
int zMin = Math.Max(0, (int)MathF.Floor(center.Z - r) - baseZ);
int zMax = Math.Min(VoxelChunk.Size - 1, (int)MathF.Ceiling(center.Z + r) - baseZ);
float r2 = r * r;
for (int x = xMin; x <= xMax; x++)
for (int y = yMin; y <= yMax; y++)
for (int z = zMin; z <= zMax; z++)
{
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;
float vz = baseZ + z + 0.5f;
float dx = vx - center.X, dy = vy - center.Y, dz = vz - center.Z;
if (dx * dx + dy * dy + dz * dz > r2) continue;
ushort id = chunk[x, y, z];
if (id == this._blocks.Air.Id) continue;
if (id == this._blocks.Water.Id) continue;
chunk[x, y, z] = this._blocks.Air.Id;
}
}
private static int FloorDiv(int a, int b) => a >= 0 ? a / b : -(((-a) + b - 1) / b);
}

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

@@ -1,4 +1,5 @@
using OpenTK.Mathematics;
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
@@ -6,23 +7,28 @@ 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;
private readonly CaveCarver _caves;
public WorldGenerator(int seed, BlockRegistry blocks)
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();
@@ -41,20 +47,21 @@ 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);
}
}
this._caves.Carve(chunk, chunkPos);
return chunk;
}
// Pure column fill rule, extracted for testability.
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;
}

View File

@@ -0,0 +1,21 @@
namespace VoxelEngine.World.WorldGen;
// Deterministic 32-bit mixing hash for worm-cell coordinates.
// Cell coords can be negative; cast through unchecked to preserve bit pattern.
internal static class WormHash
{
public static uint Hash(int cellX, int cellY, int cellZ, int seed)
{
unchecked
{
uint h = (uint)seed;
h ^= (uint)cellX * 73856093u;
h ^= (uint)cellY * 19349663u;
h ^= (uint)cellZ * 83492791u;
h ^= h >> 13;
h *= 0x5bd1e995u;
h ^= h >> 15;
return h;
}
}
}