From 15be8f60590f3ef17e074c0c8e6b5a8b8d034522 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Mon, 1 Jun 2026 15:32:56 +0200 Subject: [PATCH] feat(world-gen): implement worm cave pass in CaveCarver --- VoxelEngine.Tests/WorldGen/CaveCarverTests.cs | 68 ++++++++++ World/WorldGen/CaveCarver.cs | 119 +++++++++++++++++- 2 files changed, 186 insertions(+), 1 deletion(-) diff --git a/VoxelEngine.Tests/WorldGen/CaveCarverTests.cs b/VoxelEngine.Tests/WorldGen/CaveCarverTests.cs index 2c6d027..d876f9b 100644 --- a/VoxelEngine.Tests/WorldGen/CaveCarverTests.cs +++ b/VoxelEngine.Tests/WorldGen/CaveCarverTests.cs @@ -115,4 +115,72 @@ public class CaveCarverTests 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}"); + } } diff --git a/World/WorldGen/CaveCarver.cs b/World/WorldGen/CaveCarver.cs index b5bb20a..22b3a90 100644 --- a/World/WorldGen/CaveCarver.cs +++ b/World/WorldGen/CaveCarver.cs @@ -14,6 +14,7 @@ public sealed class CaveCarver private readonly BlockRegistry _blocks; private readonly Configuration _cfg; private readonly FastNoiseLite _caveNoise; + private readonly FastNoiseLite _wormDirNoise; public CaveCarver(int seed, BlockRegistry blocks, Configuration cfg) { @@ -24,12 +25,16 @@ public sealed class CaveCarver 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); - // Worm pass added in Task 6. + this.CarveWormCaves(chunk, chunkPos); } // Per-voxel 3D simplex threshold. Public for direct testability, @@ -58,4 +63,116 @@ public sealed class CaveCarver } } } + + 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); }