Compare commits

66 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
c1be0f75e5 feat: Random Seed toggle & removed unused atlas.png 2026-06-01 09:24:52 +02:00
MDietermann
a8e94a27b2 fix: Water TextureCoord mapping 2026-05-31 22:15:14 +02:00
MDietermann
f5787dc9cc feat: generate procedural world on load via WorldGenerator 2026-05-31 22:11:27 +02:00
MDietermann
03cf0ceb07 feat: add WorldGenerator filling vertical chunk stacks 2026-05-31 22:08:41 +02:00
MDietermann
6196b38cc8 feat: add TerrainSampler with dense box-blur border smoothing 2026-05-31 22:06:30 +02:00
MDietermann
9880e4379c feat: add BiomeMap with per-cell climate classification 2026-05-31 21:58:34 +02:00
MDietermann
589904beec feat: add Plains/Desert/Mountains/Forest biomes and registry 2026-05-31 21:56:55 +02:00
MDietermann
294f3b82ef feat: add WorldConstants, SurfacePalette and IBiome contract 2026-05-31 21:54:10 +02:00
MDietermann
d0707fde9d feat: add deterministic VoronoiCells partition 2026-05-31 21:50:09 +02:00
MDietermann
44f53e8d50 feat: add seeded NoiseSource over FastNoiseLite 2026-05-31 21:46:53 +02:00
MDietermann
99b411bdbd build: vendor FastNoiseLite, remove unused SimplexNoise package 2026-05-31 21:45:02 +02:00
MDietermann
c81cd3c561 feat: derive atlas tile size from loaded texture dimensions 2026-05-31 21:43:53 +02:00
MDietermann
50f2bc4221 fix: complete WaterBlock IBlock implementation and BlockIds list 2026-05-31 21:41:25 +02:00
MDietermann
af7a83300f feat: 4x4 atlas with TileCoord-based UVs and grass faces 2026-05-31 21:39:42 +02:00
MDietermann
6eb17b6c5d feat: make Sand/Snow opaque and register Sand/Snow/Water blocks 2026-05-31 21:38:11 +02:00
81 changed files with 6406 additions and 184 deletions

3
.gitignore vendored
View File

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

View File

@@ -1,13 +1,35 @@
#version 330 core #version 330 core
in vec2 vUv; in vec2 vUv;
in float vShade; in float vShade;
in float vWorldY;
in vec3 vWorldPos;
out vec4 FragColor; out vec4 FragColor;
uniform sampler2D atlas; 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() void main()
{ {
vec4 texel = texture(atlas, vUv); 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 vec2 vUv;
out float vShade; out float vShade;
out float vWorldY;
out vec3 vWorldPos;
uniform mat4 model; uniform mat4 model;
uniform mat4 view; uniform mat4 view;
@@ -14,6 +16,9 @@ void main()
{ {
vUv = aUv; vUv = aUv;
vShade = aShade; vShade = aShade;
// Vector-on-left convention; matrices are uploaded with transpose=true. // World-space position uses row-vector convention (matches gl_Position below).
gl_Position = vec4(aPos, 1.0) * model * view * projection; 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: 4.6 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 910 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,107 +3,104 @@ using OpenTK.Mathematics;
using OpenTK.Windowing.Common; using OpenTK.Windowing.Common;
using OpenTK.Windowing.Desktop; using OpenTK.Windowing.Desktop;
using OpenTK.Windowing.GraphicsLibraryFramework; using OpenTK.Windowing.GraphicsLibraryFramework;
using Serilog;
using VoxelEngine.Game;
using VoxelEngine.Rendering; using VoxelEngine.Rendering;
using VoxelEngine.World.Blocks; using VoxelEngine.World.Blocks;
using VoxelEngine.World.Chunk; using VoxelEngine.World.Chunk;
using VoxelEngine.World.Persistence;
using VoxelEngine.World.Streaming;
using VoxelEngine.World.WorldGen;
namespace VoxelEngine.Core; namespace VoxelEngine.Core;
public sealed class Game : GameWindow public sealed class Game : GameWindow
{ {
private const float MoveSpeed = 8f; // units per second private static readonly ILogger Log = Serilog.Log.ForContext<Game>();
private const float Sensitivity = 0.15f; // degrees per pixel
private readonly Configuration _cfg;
private readonly BlockRegistry _registry = new(); private readonly BlockRegistry _registry = new();
private Shader _shader = null!; private Shader _shader = null!;
private Texture _texture = null!; private Texture _texture = null!;
private readonly Dictionary<Vector3i, ChunkRenderer> _renderer = new(); private LoadingScreen _loading = null!;
private Camera _camera = 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(Configuration cfg, GameWindowSettings gameSettings, NativeWindowSettings nativeSettings)
: base(gameSettings, nativeSettings)
public Game(GameWindowSettings gameSettings, NativeWindowSettings nativeSettings) {
: base(gameSettings, nativeSettings) { } this._cfg = cfg;
}
protected override void OnLoad() protected override void OnLoad()
{ {
base.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.DepthTest);
GL.Enable(EnableCap.CullFace); GL.Enable(EnableCap.CullFace);
this._shader = new Shader("Assets/shaders/chunk.vert", "Assets/shaders/chunk.frag"); this._shader = new Shader("Assets/shaders/chunk.vert", "Assets/shaders/chunk.frag");
this._texture = new Texture("Assets/textures/VoxelShadeAtlas.png"); 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);
for (int x = -2; x < 2; x++) int seed = this._cfg.Seed ?? new Random().Next();
for (int z = -2; z < 2; z++) this._generator = new WorldGenerator(seed, this._registry, this._cfg);
{
this._world.SetChunk(new Vector3i(x, 0, z), this.BuildChunk());
}
ChunkMesher mesher = new(); // Spawn at terrain surface above (0, 0).
foreach ((Vector3i pos, Chunk chunk) in this._world) int surfaceY = this._generator.SurfaceHeightAt(0, 0);
{ Vector3 spawnPosition = new(0.5f, surfaceY + this._cfg.SpawnHeightOffset, 0.5f);
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, 20f, 40f), this.ClientSize.X / (float)this.ClientSize.Y); this._player = new Player(this._cfg, spawnPosition, this.ClientSize.X / (float)this.ClientSize.Y);
ChunkMesher mesher = new(this._texture.Width, this._texture.Height);
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; this.CursorState = CursorState.Grabbed;
} }
private Chunk BuildChunk()
{
Chunk chunk = new();
for (int x = 0; x < Chunk.Size; x++)
for (int z = 0; z < Chunk.Size; z++)
for (int y = 0; y < Chunk.Size; y++)
{
chunk[x, y, z] = y switch
{
Chunk.Size - 1 => this._registry.Grass.Id,
>= Chunk.Size - 4 => this._registry.Dirt.Id,
_ => this._registry.Stone.Id
};
}
return chunk;
}
protected override void OnUpdateFrame(FrameEventArgs args) protected override void OnUpdateFrame(FrameEventArgs args)
{ {
base.OnUpdateFrame(args); base.OnUpdateFrame(args);
if (!this.IsFocused) return; if (!this.IsFocused) return;
if (this.KeyboardState.IsKeyDown(Keys.Escape)) { if (this.KeyboardState.IsKeyDown(Keys.Escape)) { this.Close(); return; }
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 ; 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);
float distance = Game.MoveSpeed * speedMulti * (float)args.Time; if (this.KeyboardState.IsKeyDown(Keys.A)) this._player.Move(-this._player.Camera.Right * distance);
if (this.KeyboardState.IsKeyDown(Keys.W)) this._camera.Move(this._camera.Front * distance); if (this.KeyboardState.IsKeyDown(Keys.D)) this._player.Move(this._player.Camera.Right * distance);
if (this.KeyboardState.IsKeyDown(Keys.S)) this._camera.Move(-this._camera.Front * distance); if (this.KeyboardState.IsKeyDown(Keys.Space)) this._player.Move(Vector3.UnitY * distance);
if (this.KeyboardState.IsKeyDown(Keys.A)) this._camera.Move(-this._camera.Right * distance); if (this.KeyboardState.IsKeyDown(Keys.LeftControl)) this._player.Move(-Vector3.UnitY * 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);
Vector2 delta = this.MouseState.Delta; 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) protected override void OnRenderFrame(FrameEventArgs args)
@@ -112,21 +109,53 @@ public sealed class Game : GameWindow
GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
if (!this._streamer.SpawnReady)
{
this._loading.Draw();
this.SwapBuffers();
return;
}
this._shader.Use(); this._shader.Use();
this._shader.SetMatrix4("view", this._camera.GetViewMatrix()); this._shader.SetMatrix4("view", this._player.Camera.GetViewMatrix());
this._shader.SetMatrix4("projection", this._camera.GetProjectionMatrix()); this._shader.SetMatrix4("projection", this._player.Camera.GetProjectionMatrix());
this._shader.SetInt("atlas", 0); 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); this._texture.Use(TextureUnit.Texture0);
foreach ((Vector3i pos, ChunkRenderer renderer) in this._renderer) Matrix4 viewProj = this._player.Camera.GetViewMatrix() * this._player.Camera.GetProjectionMatrix();
Frustum frustum = Frustum.FromViewProjection(viewProj);
int drawn = this._streamer.DrawVisible(
pos =>
{
Vector3 min = new(pos.X * Chunk.Size, pos.Y * Chunk.Size, pos.Z * Chunk.Size);
Vector3 max = min + new Vector3(Chunk.Size);
return frustum.Intersects(min, max);
},
(pos, renderer) =>
{
this._shader.SetMatrix4("model", Matrix4.CreateTranslation(
pos.X * Chunk.Size, pos.Y * Chunk.Size, pos.Z * Chunk.Size));
renderer.Draw();
});
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[]
{ {
this._shader.SetMatrix4("model", Matrix4.CreateTranslation( $"BIOME: {this._generator.BiomeNameAt(px, pz)}",
pos.X * Chunk.Size, $"XYZ: {px}, {py}, {pz}",
pos.Y * Chunk.Size, }, this.ClientSize.X, this.ClientSize.Y, this._cfg.DebugOverlayTextScale);
pos.Z * Chunk.Size
));
renderer.Draw();
}
this.SwapBuffers(); this.SwapBuffers();
} }
@@ -135,18 +164,17 @@ public sealed class Game : GameWindow
{ {
base.OnResize(e); base.OnResize(e);
GL.Viewport(0, 0, e.Width, e.Height); GL.Viewport(0, 0, e.Width, e.Height);
// OnResize can fire before OnLoad (where _camera is created), so guard against it. if (this._player is not null) this._player.Camera.AspectRatio = e.Width / (float)e.Height;
if (this._camera is not null) this._camera.AspectRatio = e.Width / (float)e.Height;
} }
protected override void OnUnload() protected override void OnUnload()
{ {
foreach (ChunkRenderer rendererValue in this._renderer.Values) Log.Information("Game shutting down");
{ this._streamer?.Dispose();
rendererValue.Dispose(); this._debugOverlay?.Dispose();
} this._loading?.Dispose();
this._texture.Dispose(); this._texture?.Dispose();
this._shader.Dispose(); this._shader?.Dispose();
base.OnUnload(); 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.Mathematics;
using OpenTK.Windowing.Common; using OpenTK.Windowing.Common;
using OpenTK.Windowing.Desktop; using OpenTK.Windowing.Desktop;
using Serilog;
using Serilog.Events;
using VoxelEngine.Core; using VoxelEngine.Core;
NativeWindowSettings nativeSettings = new() Log.Logger = new LoggerConfiguration()
{ .MinimumLevel.Debug()
ClientSize = new Vector2i(1280, 720), .Enrich.FromLogContext()
Title = "VoxelEngine", .WriteTo.Console(
APIVersion = new Version(3, 3), restrictedToMinimumLevel: LogEventLevel.Information,
Profile = ContextProfile.Core, outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}")
Flags = ContextFlags.ForwardCompatible .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); try
game.Run(); {
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 Yaw { get; private set; } = -90f; // degrees; -90 looks down -Z
public float Pitch { get; private set; } // degrees public float Pitch { get; private set; } // degrees
public float Fov { get; set; } = 60f; // 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 float AspectRatio { get; set; }
public Vector3 Front { get; private set; } = -Vector3.UnitZ; public Vector3 Front { get; private set; } = -Vector3.UnitZ;
@@ -36,7 +38,7 @@ public sealed class Camera
public Matrix4 GetProjectionMatrix() => public Matrix4 GetProjectionMatrix() =>
Matrix4.CreatePerspectiveFieldOfView( Matrix4.CreatePerspectiveFieldOfView(
MathHelper.DegreesToRadians(this.Fov), this.AspectRatio, 0.1f, 100f); MathHelper.DegreesToRadians(this.Fov), this.AspectRatio, this.NearPlane, this.FarPlane);
private void UpdateVectors() private void UpdateVectors()
{ {
@@ -48,7 +50,7 @@ public sealed class Camera
MathF.Sin(pitch), MathF.Sin(pitch),
MathF.Sin(yaw) * MathF.Cos(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)); this.Up = Vector3.Normalize(Vector3.Cross(this.Right, this.Front));
} }
} }

View File

@@ -2,7 +2,7 @@ using OpenTK.Graphics.OpenGL4;
namespace VoxelEngine.Rendering; namespace VoxelEngine.Rendering;
public sealed class ChunkRenderer : IDisposable public sealed class ChunkRenderer : World.Streaming.IChunkRenderer
{ {
private const int FloatsPerVertex = 6; // pos.xyz, uv, shade private const int FloatsPerVertex = 6; // pos.xyz, uv, shade
@@ -12,8 +12,8 @@ public sealed class ChunkRenderer : IDisposable
public ChunkRenderer(float[] vertices) public ChunkRenderer(float[] vertices)
{ {
this._vertexCount = vertices.Length / ChunkRenderer.FloatsPerVertex; this._vertexCount = vertices.Length / FloatsPerVertex;
int stride = ChunkRenderer.FloatsPerVertex * sizeof(float); int stride = FloatsPerVertex * sizeof(float);
this._vao = GL.GenVertexArray(); this._vao = GL.GenVertexArray();
GL.BindVertexArray(this._vao); 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) public Shader(string vertexPath, string fragmentPath)
{ {
int vertex = Shader.CompileFromFile(ShaderType.VertexShader, vertexPath); int vertex = CompileFromFile(ShaderType.VertexShader, vertexPath);
int fragment = Shader.CompileFromFile(ShaderType.FragmentShader, fragmentPath); int fragment = CompileFromFile(ShaderType.FragmentShader, fragmentPath);
this._handle = GL.CreateProgram(); this._handle = GL.CreateProgram();
GL.AttachShader(this._handle, vertex); GL.AttachShader(this._handle, vertex);
@@ -36,13 +36,20 @@ public sealed class Shader : IDisposable
public void SetInt(string name, int value) => public void SetInt(string name, int value) =>
GL.Uniform1(this.Location(name), 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) private int Location(string name)
{ {
if (!this._uniforms.TryGetValue(name, out int location)) if (this._uniforms.TryGetValue(name, out int location)) return location;
{ location = GL.GetUniformLocation(this._handle, name);
location = GL.GetUniformLocation(this._handle, name); this._uniforms[name] = location;
this._uniforms[name] = location;
}
return location; return location;
} }

View File

@@ -7,6 +7,9 @@ public sealed class Texture : IDisposable
{ {
private readonly int _handle; private readonly int _handle;
public int Width { get; }
public int Height { get; }
public Texture(string path) public Texture(string path)
{ {
this._handle = GL.GenTexture(); this._handle = GL.GenTexture();
@@ -15,6 +18,8 @@ public sealed class Texture : IDisposable
StbImage.stbi_set_flip_vertically_on_load(1); StbImage.stbi_set_flip_vertically_on_load(1);
using FileStream stream = File.OpenRead(path); using FileStream stream = File.OpenRead(path);
ImageResult image = ImageResult.FromStream(stream, ColorComponents.RedGreenBlueAlpha); ImageResult image = ImageResult.FromStream(stream, ColorComponents.RedGreenBlueAlpha);
this.Width = image.Width;
this.Height = image.Height;
GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.Rgba, GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.Rgba,
image.Width, image.Height, 0, PixelFormat.Rgba, PixelType.UnsignedByte, image.Data); image.Width, image.Height, 0, PixelFormat.Rgba, PixelType.UnsignedByte, image.Data);

View File

@@ -49,4 +49,58 @@ public class BlockRegistryTests
Assert.Equal((ushort)2, registry.Dirt.Id); Assert.Equal((ushort)2, registry.Dirt.Id);
Assert.Equal((ushort)3, registry.Grass.Id); Assert.Equal((ushort)3, registry.Grass.Id);
} }
[Fact]
public void SandAndSnow_AreOpaque_WaterIsOpaque()
{
var registry = new BlockRegistry();
Assert.True(registry.Get(4).IsOpaque); // Sand
Assert.True(registry.Get(5).IsOpaque); // Snow
Assert.True(registry.Get(6).IsOpaque); // Water (single-pass renderer => opaque)
}
[Fact]
public void Registry_ResolvesAllSevenBlocks()
{
var registry = new BlockRegistry();
Assert.Equal("Air", registry.Get(0).Name);
Assert.Equal("Stone", registry.Get(1).Name);
Assert.Equal("Dirt", registry.Get(2).Name);
Assert.Equal("Grass", registry.Get(3).Name);
Assert.Equal("Sand", registry.Get(4).Name);
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); var camera = new Camera(Vector3.Zero, 1.5f);
Assert.NotEqual(Matrix4.Identity, camera.GetProjectionMatrix()); 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

@@ -16,7 +16,7 @@ public class ChunkMesherTests
[Fact] [Fact]
public void EmptyChunk_ProducesNoFaces() public void EmptyChunk_ProducesNoFaces()
{ {
var mesh = new ChunkMesher().Build(new Chunk(), new BlockRegistry()); var mesh = new ChunkMesher(128, 128).Build(new Chunk(), new BlockRegistry());
Assert.Empty(mesh); Assert.Empty(mesh);
} }
@@ -26,7 +26,7 @@ public class ChunkMesherTests
var chunk = new Chunk(); var chunk = new Chunk();
chunk[8, 8, 8] = 1; // stone chunk[8, 8, 8] = 1; // stone
var mesh = new ChunkMesher().Build(chunk, new BlockRegistry()); var mesh = new ChunkMesher(128, 128).Build(chunk, new BlockRegistry());
Assert.Equal(6, FaceCount(mesh)); Assert.Equal(6, FaceCount(mesh));
} }
@@ -40,7 +40,7 @@ public class ChunkMesherTests
for (int z = 0; z < Chunk.Size; z++) for (int z = 0; z < Chunk.Size; z++)
chunk[x, y, z] = 1; // stone chunk[x, y, z] = 1; // stone
var mesh = new ChunkMesher().Build(chunk, new BlockRegistry()); var mesh = new ChunkMesher(128, 128).Build(chunk, new BlockRegistry());
// 6 sides * 16 * 16 visible faces // 6 sides * 16 * 16 visible faces
Assert.Equal(6 * Chunk.Size * Chunk.Size, FaceCount(mesh)); Assert.Equal(6 * Chunk.Size * Chunk.Size, FaceCount(mesh));
@@ -52,10 +52,32 @@ public class ChunkMesherTests
var chunk = new Chunk(); var chunk = new Chunk();
chunk[0, 0, 0] = 1; // corner block: all 6 neighbors are air/out-of-bounds -> 6 faces chunk[0, 0, 0] = 1; // corner block: all 6 neighbors are air/out-of-bounds -> 6 faces
var mesh = new ChunkMesher().Build(chunk, new BlockRegistry()); var mesh = new ChunkMesher(128, 128).Build(chunk, new BlockRegistry());
// Pins the layout: 6 faces * 6 vertices/face * 6 floats/vertex. // Pins the layout: 6 faces * 6 vertices/face * 6 floats/vertex.
Assert.Equal(6 * VerticesPerFace * FloatsPerVertex, mesh.Length); Assert.Equal(6 * VerticesPerFace * FloatsPerVertex, mesh.Length);
Assert.Equal(0, mesh.Length % FloatsPerVertex); Assert.Equal(0, mesh.Length % FloatsPerVertex);
} }
[Fact]
public void Mesher_UsesAtlasDimensions_ForTileSize()
{
var chunk = new Chunk();
chunk[0, 0, 0] = 1; // stone, tile (1,0)
// 128px atlas, 32px tiles => 4x4 grid, normalized tile size 0.25.
var mesh = new ChunkMesher(128, 128).Build(chunk, new BlockRegistry());
// Gather all U coords (offset 3) and V coords (offset 4); each must be a
// multiple of 0.25 within [0,1].
for (int i = 0; i < mesh.Length; i += 6)
{
float u = mesh[i + 3];
float v = mesh[i + 4];
Assert.InRange(u, 0f, 1f);
Assert.InRange(v, 0f, 1f);
Assert.True(System.Math.Abs(u * 4f - System.MathF.Round(u * 4f)) < 1e-4f, $"u={u} not on 0.25 grid");
Assert.True(System.Math.Abs(v * 4f - System.MathF.Round(v * 4f)) < 1e-4f, $"v={v} not on 0.25 grid");
}
}
} }

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

@@ -0,0 +1,55 @@
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.WorldGen;
using Xunit;
namespace VoxelEngine.Tests.WorldGen;
public class BiomeMapTests
{
private static (BiomeMap map, BiomeRegistry biomes) Build(int seed = 42)
{
var blocks = new BlockRegistry();
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, new Configuration()), biomes);
}
[Fact]
public void Classify_HighElevation_IsMountains()
{
var (_, biomes) = Build();
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, new Configuration()));
}
[Fact]
public void Classify_TemperateWooded_IsForest()
{
var (_, biomes) = Build();
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, new Configuration()));
}
[Fact]
public void BiomeAt_IsConstantWithinACell()
{
var (map, _) = Build();
IBiome a = map.BiomeAt(500f, 500f);
IBiome b = map.BiomeAt(500.01f, 500.01f); // same cell
Assert.Same(a, b);
}
}

View File

@@ -0,0 +1,47 @@
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.WorldGen;
using Xunit;
namespace VoxelEngine.Tests.WorldGen;
public class BiomeTests
{
[Fact]
public void Desert_TopAndFiller_AreSand()
{
var blocks = new BlockRegistry();
var biomes = new BiomeRegistry(42, blocks, new Configuration());
Assert.Equal("Sand", biomes.Desert.Surface.Top.Name);
Assert.Equal("Sand", biomes.Desert.Surface.Filler.Name);
}
[Fact]
public void Mountains_UseSnowAboveSnowLine_StoneBelow()
{
var blocks = new BlockRegistry();
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, new Configuration());
Assert.Equal("Grass", biomes.Plains.TopBlock(40).Name);
Assert.Equal("Grass", biomes.Plains.TopBlock(120).Name);
}
[Fact]
public void SampleHeight_IsDeterministic()
{
var blocks = new BlockRegistry();
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

@@ -0,0 +1,30 @@
using VoxelEngine.Core;
using VoxelEngine.World.WorldGen;
using Xunit;
namespace VoxelEngine.Tests.WorldGen;
public class NoiseSourceTests
{
[Fact]
public void SameSeed_SameCoord_IsDeterministic()
{
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));
}
[Fact]
public void Output_InMinusOneToOneRange()
{
var n = new NoiseSource(7, new Configuration());
for (int i = 0; i < 200; i++)
{
float c = n.Continent(i * 3.1f, i * 1.7f);
float t = n.Temperature(i * 2.3f, i * 0.9f);
Assert.InRange(c, -1.001f, 1.001f);
Assert.InRange(t, -1.001f, 1.001f);
}
}
}

View File

@@ -0,0 +1,54 @@
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.WorldGen;
using Xunit;
namespace VoxelEngine.Tests.WorldGen;
public class TerrainSamplerTests
{
private static TerrainSampler Build(int seed = 42)
{
var blocks = new BlockRegistry();
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, new Configuration());
return new TerrainSampler(noise, map, new Configuration());
}
[Fact]
public void Height_StaysWithinWorldBounds()
{
var sampler = Build();
for (int x = -300; x < 300; x += 7)
for (int z = -300; z < 300; z += 7)
{
int h = sampler.HeightAt(x, z);
Assert.InRange(h, 0, WorldConstants.WorldHeight - 1);
}
}
[Fact]
public void Height_IsDeterministic()
{
var a = Build();
var b = Build();
Assert.Equal(a.HeightAt(120, 240), b.HeightAt(120, 240));
}
[Fact]
public void AdjacentColumns_DoNotCliff()
{
var sampler = Build();
for (int x = -200; x < 200; x += 3)
for (int z = -200; z < 200; z += 3)
{
// 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 <= 64, $"height jump {dx} at ({x},{z})");
}
}
}

View File

@@ -0,0 +1,36 @@
using VoxelEngine.World.WorldGen;
using Xunit;
namespace VoxelEngine.Tests.WorldGen;
public class VoronoiCellsTests
{
[Fact]
public void Nearest_IsDeterministic()
{
var a = new VoronoiCells(42, 96f);
var b = new VoronoiCells(42, 96f);
Assert.Equal(a.Nearest(123.4f, 56.7f), b.Nearest(123.4f, 56.7f));
}
[Fact]
public void Nearest_IsLocallyConstant()
{
var cells = new VoronoiCells(42, 96f);
// Two points a tiny step apart almost always share a cell.
var c1 = cells.Nearest(500f, 500f);
var c2 = cells.Nearest(500.01f, 500.01f);
Assert.Equal(c1.Id, c2.Id);
}
[Fact]
public void DifferentSeeds_DifferAtSomePoint()
{
var a = new VoronoiCells(1, 96f);
var b = new VoronoiCells(2, 96f);
bool anyDiff = false;
for (int i = 0; i < 50 && !anyDiff; i++)
anyDiff = a.Nearest(i * 31f, i * 17f).Id != b.Nearest(i * 31f, i * 17f).Id;
Assert.True(anyDiff);
}
}

View File

@@ -0,0 +1,99 @@
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 WorldGeneratorTests
{
private static BlockRegistry Blocks => new();
[Fact]
public void BlockAt_AboveTerrainAndSea_IsAir()
{
var blocks = Blocks;
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, 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)
}
[Fact]
public void BlockAt_Surface_IsBiomeTop()
{
var blocks = 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);
}
[Fact]
public void BlockAt_FillerBand_IsBiomeFiller()
{
var blocks = 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
}
[Fact]
public void BlockAt_Deep_IsStone()
{
var blocks = 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, 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++)
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 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,7 +10,9 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="OpenTK" Version="4.9.4" /> <PackageReference Include="OpenTK" Version="4.9.4" />
<PackageReference Include="SimplexNoise" Version="2.0.0" /> <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" /> <PackageReference Include="StbImageSharp" Version="2.30.15" />
</ItemGroup> </ItemGroup>
@@ -21,7 +23,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="World\WorldGen\" /> <InternalsVisibleTo Include="VoxelEngine.Tests" />
</ItemGroup> </ItemGroup>
</Project> </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

@@ -9,8 +9,8 @@ public sealed class GrassBlock : IBlock
public TileCoord GetTextureTile(BlockFace face) => face switch public TileCoord GetTextureTile(BlockFace face) => face switch
{ {
BlockFace.Top => new(0, 1), // grass top BlockFace.Top => new TileCoord(0, 1), // grass top
BlockFace.Bottom => new(0, 0), // dirt BlockFace.Bottom => new TileCoord(0, 0), // dirt
_ => new(1, 1) // grass side _ => new TileCoord(1, 1) // grass side
}; };
} }

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

@@ -0,0 +1,10 @@
namespace VoxelEngine.World.Blocks;
public class SandBlock : IBlock
{
public ushort Id => 4;
public string Name => "Sand";
public bool IsOpaque => true;
public bool IsSolid => true;
public TileCoord GetTextureTile(BlockFace face) => new(2, 1);
}

View File

@@ -0,0 +1,16 @@
namespace VoxelEngine.World.Blocks;
public class SnowBlock : IBlock
{
public ushort Id => 5;
public string Name => "Snow Block";
public bool IsOpaque => true;
public bool IsSolid => true;
public TileCoord GetTextureTile(BlockFace face) => face switch
{
BlockFace.Top => new TileCoord(2, 0),
BlockFace.Bottom => new TileCoord(0, 0),
_ => new TileCoord(3, 0)
};
}

View File

@@ -0,0 +1,10 @@
namespace VoxelEngine.World.Blocks;
public class WaterBlock : IBlock
{
public ushort Id => 6;
public string Name => "Water";
public bool IsOpaque => true;
public bool IsSolid => false;
public TileCoord GetTextureTile(BlockFace face) => new(3, 1);
}

View File

@@ -0,0 +1,7 @@
Air = 0
Stone = 1
Dirt = 2
Grass = 3
Sand = 4
Snow = 5
Water = 6

View File

@@ -4,15 +4,25 @@ public sealed class BlockRegistry
{ {
private readonly IBlock[] _byId; private readonly IBlock[] _byId;
public AirBlock Air { get; } = new(); public AirBlock Air { get; } = new();
public StoneBlock Stone { get; } = new(); public StoneBlock Stone { get; } = new();
public DirtBlock Dirt { get; } = new(); public DirtBlock Dirt { get; } = new();
public GrassBlock Grass { 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() public BlockRegistry()
{ {
// Index by id. Order does not matter; slot = block.Id. IBlock[] blocks = new IBlock[]
IBlock[] blocks = new IBlock[] { this.Air, this.Stone, this.Dirt, this.Grass }; {
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]; this._byId = new IBlock[blocks.Length];
foreach (IBlock block in blocks) foreach (IBlock block in blocks)
{ {

View File

@@ -0,0 +1,5 @@
namespace VoxelEngine.World.Blocks;
// Position of a tile within the texture atlas, in tile units.
// Origin (0,0) is the top-left tile; X increases right, Y increases down.
public readonly record struct TileCoord(int X, int Y);

View File

@@ -4,16 +4,16 @@ public sealed class Chunk
{ {
public const int Size = 16; 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] public ushort this[int x, int y, int z]
{ {
get => this._ids[Chunk.Index(x, y, z)]; get => this._ids[Index(x, y, z)];
set => this._ids[Chunk.Index(x, y, z)] = value; set => this._ids[Index(x, y, z)] = value;
} }
public static bool InBounds(int x, int y, int z) => 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

@@ -5,9 +5,15 @@ namespace VoxelEngine.World.Chunk;
public sealed class ChunkMesher : IChunkMesher public sealed class ChunkMesher : IChunkMesher
{ {
private const int TileSizePixels = 32; // each tile is 32x32 pixels private const int TileSizePixels = 32; // each tile is 32x32 pixels
private const int AtlasSizePixels = 64; // atlas is 64x64 pixels (2x2 grid)
private const float TileSize = (float)TileSizePixels / AtlasSizePixels; // normalized tile size private readonly float _tileU; // normalized tile width
private readonly float _tileV; // normalized tile height
public ChunkMesher(int atlasWidthPixels, int atlasHeightPixels)
{
this._tileU = (float)TileSizePixels / atlasWidthPixels;
this._tileV = (float)TileSizePixels / atlasHeightPixels;
}
// Per-face geometry: base corner + two in-plane edge vectors (u, v) with // Per-face geometry: base corner + two in-plane edge vectors (u, v) with
// cross(u, v) == outward normal, guaranteeing CCW winding from outside. // cross(u, v) == outward normal, guaranteeing CCW winding from outside.
@@ -59,7 +65,7 @@ public sealed class ChunkMesher : IChunkMesher
IBlock block = registry.Get(chunk[x, y, z]); IBlock block = registry.Get(chunk[x, y, z]);
if (!block.IsOpaque) continue; if (!block.IsOpaque) continue;
foreach (Face face in ChunkMesher.Faces) foreach (Face face in Faces)
{ {
int nx = x + face.Normal.x; int nx = x + face.Normal.x;
int ny = y + face.Normal.y; int ny = y + face.Normal.y;
@@ -72,29 +78,29 @@ public sealed class ChunkMesher : IChunkMesher
: (neighborOpaqueOutside?.Invoke(nx, ny, nz) ?? false); : (neighborOpaqueOutside?.Invoke(nx, ny, nz) ?? false);
if (neighborOpaque) continue; if (neighborOpaque) continue;
ChunkMesher.AddFace(verts, x, y, z, face, block.GetTextureTile(face.Kind)); this.AddFace(verts, x, y, z, face, block.GetTextureTile(face.Kind));
} }
} }
return verts.ToArray(); return verts.ToArray();
} }
private static void AddFace(List<float> verts, int x, int y, int z, Face face, TileCoord tile) private void AddFace(List<float> verts, int x, int y, int z, Face face, TileCoord tile)
{ {
float uBase = tile.X * ChunkMesher.TileSize; float uBase = tile.X * this._tileU;
// Image is flipped on load, so offset from the top to keep tile (0,0) = top-left. // Image is flipped on load, so offset from the top to keep tile (0,0) = top-left.
float vBase = 1f - (tile.Y + 1) * ChunkMesher.TileSize; 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 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; float py = y + face.Base.y + face.U.y * cu + face.V.y * cv;
float pz = z + face.Base.z + face.U.z * cu + face.V.z * cv; float pz = z + face.Base.z + face.U.z * cu + face.V.z * cv;
float u = uBase + cu * ChunkMesher.TileSize; float u = uBase + cu * this._tileU;
float v = vBase + cv * ChunkMesher.TileSize; float v = vBase + cv * this._tileV;
verts.Add(px); verts.Add(py); verts.Add(pz); verts.Add(px); verts.Add(py); verts.Add(pz);
verts.Add(u); verts.Add(v); verts.Add(u); verts.Add(v);

View File

@@ -1,11 +1,16 @@
using OpenTK.Mathematics; using OpenTK.Mathematics;
using VoxelEngine.World.Blocks; using VoxelEngine.World.Blocks;
using VoxelEngine.World.Persistence;
namespace VoxelEngine.World; namespace VoxelEngine.World;
public interface IWorld public interface IWorld
{ {
public void SetChunk(Vector3i chunkPos, Chunk.Chunk chunk); IChunkModificationStore ModificationStore { get; }
public Chunk.Chunk? GetChunk(Vector3i chunkPos);
public IBlock GetBlock(int worldX, int worldY, int worldZ); 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 OpenTK.Mathematics;
using VoxelEngine.Core;
using VoxelEngine.World.Blocks; using VoxelEngine.World.Blocks;
using VoxelEngine.World.Persistence;
namespace VoxelEngine.World; 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 Dictionary<Vector3i, Chunk.Chunk> _chunks = new();
private readonly BlockRegistry _registry;
public World(BlockRegistry registry) public IChunkModificationStore ModificationStore { get; } = modStore;
{
this._registry = registry;
}
public void SetChunk(Vector3i chunkPos, Chunk.Chunk chunk) public void SetChunk(Vector3i chunkPos, Chunk.Chunk chunk) => this._chunks[chunkPos] = chunk;
{
this._chunks[chunkPos] = chunk;
}
public Chunk.Chunk? GetChunk(Vector3i chunkPos) public void RemoveChunk(Vector3i chunkPos) => this._chunks.Remove(chunkPos);
{
return this._chunks.GetValueOrDefault(chunkPos); public Chunk.Chunk? GetChunk(Vector3i chunkPos) => this._chunks.GetValueOrDefault(chunkPos);
}
public IBlock GetBlock(int worldX, int worldY, int worldZ) public IBlock GetBlock(int worldX, int worldY, int worldZ)
{ {
Vector3i chunkPos = new( Vector3i chunkPos = new(
World.FloorDiv(worldX, Chunk.Chunk.Size), ChunkMath.FloorDiv(worldX, Chunk.Chunk.Size),
World.FloorDiv(worldY, Chunk.Chunk.Size), ChunkMath.FloorDiv(worldY, Chunk.Chunk.Size),
World.FloorDiv(worldZ, Chunk.Chunk.Size) ChunkMath.FloorDiv(worldZ, Chunk.Chunk.Size));
);
Chunk.Chunk? chunk = this.GetChunk(chunkPos); Chunk.Chunk? chunk = this.GetChunk(chunkPos);
if (chunk == null) if (chunk == null) return registry.Air;
{
return this._registry.Air;
}
int localX = worldX - chunkPos.X * Chunk.Chunk.Size; int localX = worldX - chunkPos.X * Chunk.Chunk.Size;
int localY = worldY - chunkPos.Y * Chunk.Chunk.Size; int localY = worldY - chunkPos.Y * Chunk.Chunk.Size;
int localZ = worldZ - chunkPos.Z * Chunk.Chunk.Size; int localZ = worldZ - chunkPos.Z * Chunk.Chunk.Size;
return registry.Get(chunk[localX, localY, localZ]);
return this._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; Vector3i chunkPos = new(
if (value % size != 0 && value < 0) ChunkMath.FloorDiv(worldX, Chunk.Chunk.Size),
{ ChunkMath.FloorDiv(worldY, Chunk.Chunk.Size),
quotient--; ChunkMath.FloorDiv(worldZ, Chunk.Chunk.Size));
}
return quotient; 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

@@ -0,0 +1,57 @@
using VoxelEngine.Core;
namespace VoxelEngine.World.WorldGen;
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, 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)
{
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 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, Configuration cfg)
{
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

@@ -0,0 +1,21 @@
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.WorldGen.Biomes;
namespace VoxelEngine.World.WorldGen;
public sealed class BiomeRegistry
{
public IBiome Plains { get; }
public IBiome Desert { get; }
public IBiome Mountains { get; }
public IBiome Forest { get; }
public BiomeRegistry(int seed, BlockRegistry blocks, Configuration cfg)
{
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

@@ -0,0 +1,28 @@
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
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, Configuration cfg)
{
this._cfg = cfg;
this._detail = new FastNoiseLite(seed + 101);
this._detail.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
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 + 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

@@ -0,0 +1,28 @@
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
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, Configuration cfg)
{
this._cfg = cfg;
this._detail = new FastNoiseLite(seed + 103);
this._detail.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
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 + 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

@@ -0,0 +1,34 @@
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World.WorldGen.Biomes;
public sealed class MountainsBiome : IBiome
{
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, 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(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 + this._cfg.MountainsContinentAmplitude * noise.Continent(worldX, worldZ)
+ this._cfg.MountainsRidgedAmplitude * this._ridged.GetNoise(worldX, worldZ);
public IBlock TopBlock(int worldY) => worldY >= this._cfg.MountainsSnowLine ? this._snow : this._stone;
}

View File

@@ -0,0 +1,28 @@
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
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, Configuration cfg)
{
this._cfg = cfg;
this._detail = new FastNoiseLite(seed + 100);
this._detail.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
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 + 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);
}

File diff suppressed because it is too large Load Diff

17
World/WorldGen/IBiome.cs Normal file
View File

@@ -0,0 +1,17 @@
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World.WorldGen;
public interface IBiome
{
string Name { get; }
// Absolute target surface height at the column (continuous, pre-rounding).
float SampleHeight(NoiseSource noise, float worldX, float worldZ);
SurfacePalette Surface { get; }
// Surface block for a given height; lets biomes vary the top by altitude
// (e.g. Mountains use Snow above the snow line). Defaults to Surface.Top.
IBlock TopBlock(int worldY);
}

View File

@@ -0,0 +1,34 @@
using VoxelEngine.Core;
namespace VoxelEngine.World.WorldGen;
// Seeded wrapper over FastNoiseLite. All Get* methods return roughly [-1, 1].
public sealed class NoiseSource
{
private readonly FastNoiseLite _continent;
private readonly FastNoiseLite _temperature;
private readonly FastNoiseLite _forest;
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(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(cfg.TemperatureOctaves);
this._temperature.SetFrequency(cfg.TemperatureFrequency);
this._forest = new FastNoiseLite(seed + 2);
this._forest.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._forest.SetFrequency(cfg.ForestFrequency);
}
public float Continent(float x, float z) => this._continent.GetNoise(x, z);
public float Temperature(float x, float z) => this._temperature.GetNoise(x, z);
public float Forest(float x, float z) => this._forest.GetNoise(x, z);
}

View File

@@ -0,0 +1,7 @@
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World.WorldGen;
// Surface block layering for a biome: the top block, the filler band below it,
// and the deep "under" block (stone). Filler depth is fixed at 3 blocks.
public readonly record struct SurfacePalette(IBlock Top, IBlock Filler, IBlock Under);

View File

@@ -0,0 +1,40 @@
using VoxelEngine.Core;
namespace VoxelEngine.World.WorldGen;
public sealed class TerrainSampler
{
private readonly NoiseSource _noise;
private readonly BiomeMap _biomes;
private readonly Configuration _cfg;
public TerrainSampler(NoiseSource noise, BiomeMap biomes, Configuration cfg)
{
this._noise = noise;
this._biomes = biomes;
this._cfg = cfg;
}
public int HeightAt(float worldX, float worldZ)
{
// 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

@@ -0,0 +1,100 @@
namespace VoxelEngine.World.WorldGen;
// A jittered-grid Voronoi partition. Pure integer-hash based, fully deterministic.
public sealed class VoronoiCells
{
public readonly record struct Cell(int Id, float CenterX, float CenterZ);
private readonly int _seed;
private readonly float _cellSize;
public VoronoiCells(int seed, float cellSize)
{
this._seed = seed;
this._cellSize = cellSize;
}
public Cell Nearest(float x, float z)
{
int gx = (int)MathF.Floor(x / this._cellSize);
int gz = (int)MathF.Floor(z / this._cellSize);
float bestDist = float.MaxValue;
int bestId = 0;
float bestCx = 0f, bestCz = 0f;
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;
if (dist < bestDist)
{
bestDist = dist;
bestId = (int)Hash(cgx, cgz, this._seed);
bestCx = cx;
bestCz = cz;
}
}
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)
=> (Hash(x, z, seed) & 0xFFFFFF) / (float)0x1000000; // [0,1)
private static uint Hash(int x, int z, int seed)
{
unchecked
{
uint h = (uint)(x * 374761393 + z * 668265263 + seed * 362437);
h = (h ^ (h >> 13)) * 1274126177u;
return h ^ (h >> 16);
}
}
}

View File

@@ -0,0 +1,10 @@
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
namespace VoxelEngine.World.WorldGen;
public static class WorldConstants
{
public const int WorldHeightChunks = 32;
public const int WorldHeight = WorldHeightChunks * VoxelChunk.Size; // 512
public const int SeaLevel = 256;
}

View File

@@ -0,0 +1,68 @@
using OpenTK.Mathematics;
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
namespace VoxelEngine.World.WorldGen;
public sealed class WorldGenerator
{
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, Configuration cfg)
{
this._cfg = cfg;
this._blocks = blocks;
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();
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 z = 0; z < VoxelChunk.Size; z++)
{
int worldX = baseX + x;
int worldZ = baseZ + z;
int height = this._terrain.HeightAt(worldX, worldZ);
IBiome biome = this._biomeMap.BiomeAt(worldX, worldZ);
for (int y = 0; y < VoxelChunk.Size; y++)
{
int worldY = baseY + y;
chunk[x, y, z] = BlockAt(worldY, height, biome, this._blocks, this._cfg.SurfaceFillerDepth);
}
}
this._caves.Carve(chunk, chunkPos);
return chunk;
}
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 - 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;
}
}
}