Compare commits
14 Commits
4fe552b8bd
...
22531c6ee1
| Author | SHA1 | Date | |
|---|---|---|---|
| 22531c6ee1 | |||
| 3704d7ce1e | |||
| aa1aa6fec6 | |||
| 2e36d40386 | |||
| 9410fee78b | |||
| 645fd00868 | |||
| 15be8f6059 | |||
| 070bdfeac1 | |||
| 093b3deabf | |||
| 013d524544 | |||
| 4587f81071 | |||
| 8166c25ba6 | |||
| d1c8c22705 | |||
| 2c95c7831a |
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
186
VoxelEngine.Tests/WorldGen/CaveCarverTests.cs
Normal file
186
VoxelEngine.Tests/WorldGen/CaveCarverTests.cs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
VoxelEngine.Tests/WorldGen/WormHashTests.cs
Normal file
40
VoxelEngine.Tests/WorldGen/WormHashTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,4 +22,8 @@
|
|||||||
</None>
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="VoxelEngine.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
10
World/Blocks/BlockClasses/CoalOreBlock.cs
Normal file
10
World/Blocks/BlockClasses/CoalOreBlock.cs
Normal 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
|
||||||
|
}
|
||||||
10
World/Blocks/BlockClasses/DiamondOreBlock.cs
Normal file
10
World/Blocks/BlockClasses/DiamondOreBlock.cs
Normal 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
|
||||||
|
}
|
||||||
10
World/Blocks/BlockClasses/GoldOreBlock.cs
Normal file
10
World/Blocks/BlockClasses/GoldOreBlock.cs
Normal 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
|
||||||
|
}
|
||||||
10
World/Blocks/BlockClasses/IronOreBlock.cs
Normal file
10
World/Blocks/BlockClasses/IronOreBlock.cs
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
178
World/WorldGen/CaveCarver.cs
Normal file
178
World/WorldGen/CaveCarver.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
21
World/WorldGen/WormHash.cs
Normal file
21
World/WorldGen/WormHash.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user