Compare commits

...

14 Commits

Author SHA1 Message Date
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
29 changed files with 695 additions and 22 deletions

View File

@@ -1,13 +1,20 @@
#version 330 core #version 330 core
in vec2 vUv; in vec2 vUv;
in float vShade; in float vShade;
in float vWorldY;
out vec4 FragColor; out vec4 FragColor;
uniform sampler2D atlas; uniform sampler2D atlas;
uniform float u_caveDarkStartY;
uniform float u_caveDarkEndY;
uniform float u_caveMinBrightness;
void main() void main()
{ {
vec4 texel = texture(atlas, vUv); vec4 texel = texture(atlas, vUv);
FragColor = vec4(texel.rgb * vShade, 1.0); float t = clamp((u_caveDarkStartY - vWorldY) /
(u_caveDarkStartY - u_caveDarkEndY), 0.0, 1.0);
float brightness = mix(1.0, u_caveMinBrightness, t);
FragColor = vec4(texel.rgb * vShade * brightness, 1.0);
} }

View File

@@ -5,6 +5,7 @@ layout(location = 2) in float aShade;
out vec2 vUv; out vec2 vUv;
out float vShade; out float vShade;
out float vWorldY;
uniform mat4 model; uniform mat4 model;
uniform mat4 view; uniform mat4 view;
@@ -14,6 +15,8 @@ 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;
vWorldY = worldPos.y;
gl_Position = worldPos * view * projection;
} }

View File

@@ -10,6 +10,11 @@ public sealed class Configuration
public int MaxMeshUploadsPerFrame { get; set; } = 4; public int MaxMeshUploadsPerFrame { get; set; } = 4;
public int WorkerThreadCount { get; set; } = Math.Max(1, Environment.ProcessorCount / 2); public int WorkerThreadCount { get; set; } = Math.Max(1, Environment.ProcessorCount / 2);
// Multiplier applied to (dy)^2 in chunk-load priority. >1 prioritizes
// chunks at the player's Y over equally-distant chunks above/below, so the
// visible surface fills in before far-underground / overhead chunks.
public int VerticalPriorityWeight { get; set; } = 4;
public float MoveSpeed { get; set; } = 8f; public float MoveSpeed { get; set; } = 8f;
public float MouseSensitivity { get; set; } = 0.15f; public float MouseSensitivity { get; set; } = 0.15f;
public float Fov { get; set; } = 60f; public float Fov { get; set; } = 60f;
@@ -18,4 +23,18 @@ public sealed class Configuration
public int WindowHeight { get; set; } = 720; public int WindowHeight { get; set; } = 720;
public int? Seed { get; set; } = null; public int? Seed { get; set; } = null;
// -- Cave generation tuning --
public float CaveNoiseFrequency { get; set; } = 0.03f;
public float CaveNoiseThreshold { get; set; } = -0.3f;
public int WormCellSize { get; set; } = 64;
public int WormSpawnProbabilityPermille { get; set; } = 300;
public int WormMaxLength { get; set; } = 80;
public float WormMinRadius { get; set; } = 1.5f;
public float WormMaxRadius { get; set; } = 3.5f;
// -- Cave lighting (fog-distance darkness) --
public float CaveDarkStartY { get; set; } = 72f; // WorldConstants.SeaLevel + 8
public float CaveDarkEndY { get; set; } = 32f; // WorldConstants.SeaLevel - 32
public float CaveMinBrightness { get; set; } = 0.15f;
} }

View File

@@ -55,7 +55,7 @@ public sealed class Game : GameWindow
this._world = new World.World(this._registry, this._modStore); this._world = new World.World(this._registry, this._modStore);
int seed = this._cfg.Seed ?? new Random().Next(); int seed = this._cfg.Seed ?? new Random().Next();
this._generator = new WorldGenerator(seed, this._registry); this._generator = new WorldGenerator(seed, this._registry, this._cfg);
// Spawn at terrain surface above (0, 0). // Spawn at terrain surface above (0, 0).
int surfaceY = this._generator.SurfaceHeightAt(0, 0); int surfaceY = this._generator.SurfaceHeightAt(0, 0);
@@ -116,6 +116,9 @@ public sealed class Game : GameWindow
this._shader.SetMatrix4("view", this._player.Camera.GetViewMatrix()); this._shader.SetMatrix4("view", this._player.Camera.GetViewMatrix());
this._shader.SetMatrix4("projection", this._player.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);
this._texture.Use(TextureUnit.Texture0); this._texture.Use(TextureUnit.Texture0);
this._streamer.DrawAll((pos, renderer) => this._streamer.DrawAll((pos, renderer) =>

View File

@@ -36,6 +36,9 @@ 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);
private int Location(string name) private int Location(string name)
{ {
if (this._uniforms.TryGetValue(name, out int location)) return location; if (this._uniforms.TryGetValue(name, out int location)) return location;

View File

@@ -71,4 +71,36 @@ public class BlockRegistryTests
Assert.Equal("Snow Block", registry.Get(5).Name); Assert.Equal("Snow Block", registry.Get(5).Name);
Assert.Equal("Water", registry.Get(6).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

@@ -54,4 +54,24 @@ public class ConfigurationTests
Assert.Equal(800, c2.WindowWidth); Assert.Equal(800, c2.WindowWidth);
Assert.Equal(600, c2.WindowHeight); 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(72f, c.CaveDarkStartY); // SeaLevel (64) + 8
Assert.Equal(32f, c.CaveDarkEndY); // SeaLevel (64) - 32
Assert.Equal(0.15f, c.CaveMinBrightness);
}
} }

View File

@@ -35,6 +35,13 @@ internal sealed class FakeMesher : IChunkMesher
=> new float[6]; // one vertex worth => 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 public class ChunkStreamerTests
{ {
private static (ChunkStreamer streamer, World.World world, List<FakeRenderer> created) CreateStreamer(Configuration cfg) private static (ChunkStreamer streamer, World.World world, List<FakeRenderer> created) CreateStreamer(Configuration cfg)
@@ -351,4 +358,65 @@ public class ChunkStreamerTests
streamer.Tick(new Vector3i(0, 0, 0)); streamer.Tick(new Vector3i(0, 0, 0));
Assert.Equal(1, generateCalls); Assert.Equal(1, generateCalls);
} }
[Fact]
public void InstallResult_EmptyMesh_SkipsRendererCreationButMarksReady()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
RenderDistanceVertical = 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, 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,
RenderDistanceVertical = 1,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 5, // player's chunk + 4 horizontal cardinals
VerticalPriorityWeight = 4,
};
var (streamer, world, _) = CreateStreamer(cfg);
// Player chunk at Y=2 so vertical neighbors Y=1 and Y=3 are valid.
var playerPos = new Vector3i(0, 2, 0);
streamer.Tick(playerPos);
// Drain budget = 5 = priority 0 (player) + 4 priority-1 (horizontal
// cardinals). With VerticalPriorityWeight=4, vertical cardinals
// (Y=1, Y=3) have priority 4 — they should NOT be among the first 5.
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)));
}
} }

View File

@@ -18,7 +18,7 @@ public class StreamingIntegrationTests
var registry = new BlockRegistry(); var registry = new BlockRegistry();
var store = new InMemoryChunkModificationStore(); var store = new InMemoryChunkModificationStore();
var world = new World.World(registry, store); var world = new World.World(registry, store);
var gen = new WorldGenerator(seed: 1337, blocks: registry); var gen = new WorldGenerator(seed: 1337, blocks: registry, cfg: new Configuration());
var mesher = new ChunkMesher(atlasWidthPixels: 128, atlasHeightPixels: 32); var mesher = new ChunkMesher(atlasWidthPixels: 128, atlasHeightPixels: 32);
var executor = new SynchronousJobExecutor(); var executor = new SynchronousJobExecutor();
Func<float[], IChunkRenderer> factory = m => new FakeRenderer(m); Func<float[], IChunkRenderer> factory = m => new FakeRenderer(m);

View File

@@ -0,0 +1,186 @@
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}");
}
}

View File

@@ -1,4 +1,5 @@
using OpenTK.Mathematics; using OpenTK.Mathematics;
using VoxelEngine.Core;
using VoxelEngine.World.Blocks; using VoxelEngine.World.Blocks;
using VoxelEngine.World.Chunk; using VoxelEngine.World.Chunk;
using VoxelEngine.World.WorldGen; using VoxelEngine.World.WorldGen;
@@ -58,8 +59,8 @@ public class WorldGeneratorTests
[Fact] [Fact]
public void Generate_IsDeterministic() public void Generate_IsDeterministic()
{ {
var gen1 = new WorldGenerator(99, Blocks); var gen1 = new WorldGenerator(99, Blocks, new Configuration());
var gen2 = new WorldGenerator(99, Blocks); var gen2 = new WorldGenerator(99, Blocks, new Configuration());
Chunk a = gen1.Generate(new Vector3i(0, 4, 0)); Chunk a = gen1.Generate(new Vector3i(0, 4, 0));
Chunk b = gen2.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 x = 0; x < Chunk.Size; x++)
@@ -67,4 +68,32 @@ public class WorldGeneratorTests
for (int z = 0; z < Chunk.Size; z++) for (int z = 0; z < Chunk.Size; z++)
Assert.Equal(a[x, y, z], b[x, y, 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

@@ -22,4 +22,8 @@
</None> </None>
</ItemGroup> </ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="VoxelEngine.Tests" />
</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(0, 0); // 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, 0); // 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(0, 0); // TODO: Add Texture
}

View File

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

View File

@@ -11,12 +11,18 @@ public sealed class BlockRegistry
public SandBlock Sand { get; } = new(); public SandBlock Sand { get; } = new();
public SnowBlock Snow { get; } = new(); public SnowBlock Snow { get; } = new();
public WaterBlock Water { 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.Sand, this.Snow, this.Water }; {
this.Air, this.Stone, this.Dirt, this.Grass, this.Sand, this.Snow, this.Water,
this.CoalOre, this.IronOre, this.GoldOre, this.DiamondOre,
};
this._byId = new IBlock[blocks.Length]; this._byId = new IBlock[blocks.Length];
foreach (IBlock block in blocks) foreach (IBlock block in blocks)
{ {

View File

@@ -122,9 +122,12 @@ public sealed class ChunkStreamer : IDisposable
if (this._failed.Contains(pos)) continue; if (this._failed.Contains(pos)) continue;
if (this._tracked.Add(pos)) if (this._tracked.Add(pos))
{ {
int priority = (pos.X - p.X) * (pos.X - p.X) int dx = pos.X - p.X;
+ (pos.Y - p.Y) * (pos.Y - p.Y) int dy = pos.Y - p.Y;
+ (pos.Z - p.Z) * (pos.Z - p.Z); int dz = pos.Z - p.Z;
int priority = dx * dx
+ dz * dz
+ dy * dy * this._cfg.VerticalPriorityWeight;
this._pendingLoad.Enqueue(pos, priority); this._pendingLoad.Enqueue(pos, priority);
} }
} }
@@ -207,8 +210,15 @@ public sealed class ChunkStreamer : IDisposable
if (this._renderers.TryGetValue(result.Pos, out IChunkRenderer? old)) if (this._renderers.TryGetValue(result.Pos, out IChunkRenderer? old))
{ {
old.Dispose(); 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 — DrawAll's iteration over _renderers naturally no-ops.
if (result.Mesh.Length > 0)
{
this._renderers[result.Pos] = this._rendererFactory(result.Mesh); this._renderers[result.Pos] = this._rendererFactory(result.Mesh);
}
this._ready.Add(result.Pos); this._ready.Add(result.Pos);
this._dirty.Remove(result.Pos); this._dirty.Remove(result.Pos);

View File

@@ -0,0 +1,178 @@
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;
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 = CaveCarver.FloorDiv(chunkBaseX, cellSize);
int chunkCellY = CaveCarver.FloorDiv(chunkBaseY, cellSize);
int chunkCellZ = CaveCarver.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;
float vx = baseX + x + 0.5f;
float vy = baseY + y + 0.5f;
float vz = baseZ + z + 0.5f;
float dx = vx - center.X, dy = vy - center.Y, dz = vz - center.Z;
if (dx * dx + dy * dy + dz * dz > r2) continue;
ushort id = chunk[x, y, z];
if (id == this._blocks.Air.Id) continue;
if (id == this._blocks.Water.Id) continue;
chunk[x, y, z] = this._blocks.Air.Id;
}
}
private static int FloorDiv(int a, int b) => a >= 0 ? a / b : -(((-a) + b - 1) / b);
}

View File

@@ -1,4 +1,5 @@
using OpenTK.Mathematics; using OpenTK.Mathematics;
using VoxelEngine.Core;
using VoxelEngine.World.Blocks; using VoxelEngine.World.Blocks;
using VoxelChunk = VoxelEngine.World.Chunk.Chunk; using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
@@ -12,8 +13,9 @@ public sealed class WorldGenerator
private readonly BlockRegistry _blocks; private readonly BlockRegistry _blocks;
private readonly BiomeMap _biomeMap; private readonly BiomeMap _biomeMap;
private readonly TerrainSampler _terrain; private readonly TerrainSampler _terrain;
private readonly CaveCarver _caves;
public WorldGenerator(int seed, BlockRegistry blocks) public WorldGenerator(int seed, BlockRegistry blocks, Configuration cfg)
{ {
this._blocks = blocks; this._blocks = blocks;
NoiseSource noise = new(seed); NoiseSource noise = new(seed);
@@ -21,6 +23,7 @@ public sealed class WorldGenerator
BiomeRegistry biomes = new(seed, blocks); BiomeRegistry biomes = new(seed, blocks);
this._biomeMap = new BiomeMap(noise, cells, biomes); this._biomeMap = new BiomeMap(noise, cells, biomes);
this._terrain = new TerrainSampler(noise, this._biomeMap); this._terrain = new TerrainSampler(noise, this._biomeMap);
this._caves = new CaveCarver(seed, blocks, cfg);
} }
public int SurfaceHeightAt(int worldX, int worldZ) => this._terrain.HeightAt(worldX, worldZ); public int SurfaceHeightAt(int worldX, int worldZ) => this._terrain.HeightAt(worldX, worldZ);
@@ -46,10 +49,11 @@ public sealed class WorldGenerator
chunk[x, y, z] = WorldGenerator.BlockAt(worldY, height, biome, this._blocks); chunk[x, y, z] = WorldGenerator.BlockAt(worldY, height, biome, this._blocks);
} }
} }
this._caves.Carve(chunk, chunkPos);
return chunk; return chunk;
} }
// Pure column fill rule, extracted for testability.
public static ushort BlockAt(int worldY, int surfaceHeight, IBiome biome, BlockRegistry blocks) public static ushort BlockAt(int worldY, int surfaceHeight, IBiome biome, BlockRegistry blocks)
{ {
if (worldY > surfaceHeight) if (worldY > surfaceHeight)

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