Compare commits

..

10 Commits

Author SHA1 Message Date
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
23 changed files with 3195 additions and 27 deletions

View File

@@ -6,6 +6,7 @@ using OpenTK.Windowing.GraphicsLibraryFramework;
using VoxelEngine.Rendering;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.Chunk;
using VoxelEngine.World.WorldGen;
namespace VoxelEngine.Core;
@@ -13,12 +14,14 @@ public sealed class Game : GameWindow
{
private const float MoveSpeed = 8f; // units per second
private const float Sensitivity = 0.15f; // degrees per pixel
private const int Seed = 1337;
private readonly BlockRegistry _registry = new();
private Shader _shader = null!;
private Texture _texture = null!;
private readonly Dictionary<Vector3i, ChunkRenderer> _renderer = new();
private Camera _camera = null!;
private WorldGenerator _generator = null!;
private World.World _world;
@@ -37,11 +40,14 @@ public sealed class Game : GameWindow
this._texture = new Texture("Assets/textures/VoxelShadeAtlas.png");
this._world = new World.World(this._registry);
this._generator = new WorldGenerator(Game.Seed, this._registry);
for (int x = -2; x < 2; x++)
for (int z = -2; z < 2; z++)
for (int cx = -2; cx < 2; cx++)
for (int cz = -2; cz < 2; cz++)
for (int cy = 0; cy < WorldConstants.WorldHeightChunks; cy++)
{
this._world.SetChunk(new Vector3i(x, 0, z), this.BuildChunk());
Vector3i pos = new(cx, cy, cz);
this._world.SetChunk(pos, this._generator.Generate(pos));
}
ChunkMesher mesher = new(this._texture.Width, this._texture.Height);
@@ -59,28 +65,13 @@ public sealed class Game : GameWindow
this._renderer[pos] = new ChunkRenderer(mesh);
}
this._camera = new Camera(new Vector3(8f, 20f, 40f), this.ClientSize.X / (float)this.ClientSize.Y);
this._camera = new Camera(
new Vector3(8f, WorldConstants.SeaLevel + 30f, 40f),
this.ClientSize.X / (float)this.ClientSize.Y);
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)
{
base.OnUpdateFrame(args);

View File

@@ -0,0 +1,54 @@
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);
var noise = new NoiseSource(seed);
var cells = new VoronoiCells(seed, 96f);
return (new BiomeMap(noise, cells, biomes), biomes);
}
[Fact]
public void Classify_HighElevation_IsMountains()
{
var (_, biomes) = Build();
Assert.Same(biomes.Mountains, BiomeMap.Classify(0f, 0.5f, 0f, biomes));
}
[Fact]
public void Classify_HotLowElevation_IsDesert()
{
var (_, biomes) = Build();
Assert.Same(biomes.Desert, BiomeMap.Classify(0.5f, 0f, 0f, biomes));
}
[Fact]
public void Classify_TemperateWooded_IsForest()
{
var (_, biomes) = Build();
Assert.Same(biomes.Forest, BiomeMap.Classify(0f, 0f, 0.5f, biomes));
}
[Fact]
public void Classify_ColdLow_IsPlains()
{
var (_, biomes) = Build();
Assert.Same(biomes.Plains, BiomeMap.Classify(-0.9f, 0f, 0f, biomes));
}
[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,46 @@
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);
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);
Assert.Equal("Stone", biomes.Mountains.TopBlock(70).Name);
Assert.Equal("Snow Block", biomes.Mountains.TopBlock(120).Name);
}
[Fact]
public void Plains_TopBlock_IsGrass_RegardlessOfHeight()
{
var blocks = new BlockRegistry();
var biomes = new BiomeRegistry(42, blocks);
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);
var noise = new NoiseSource(42);
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,29 @@
using VoxelEngine.World.WorldGen;
using Xunit;
namespace VoxelEngine.Tests.WorldGen;
public class NoiseSourceTests
{
[Fact]
public void SameSeed_SameCoord_IsDeterministic()
{
var a = new NoiseSource(42);
var b = new NoiseSource(42);
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);
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,53 @@
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);
var noise = new NoiseSource(seed);
var cells = new VoronoiCells(seed, 96f);
var map = new BiomeMap(noise, cells, biomes);
return new TerrainSampler(noise, map);
}
[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 (those would be 25+ blocks).
// Remaining deltas are rugged intra-Mountains ridged slopes (~9), which
// are intended terrain, not cliffs; 16 separates the two.
int dx = System.Math.Abs(sampler.HeightAt(x, z) - sampler.HeightAt(x + 1, z));
Assert.True(dx <= 16, $"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,70 @@
using OpenTK.Mathematics;
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);
ushort id = WorldGenerator.BlockAt(80, surfaceHeight: 70, biomes.Plains, blocks);
Assert.Equal(blocks.Air.Id, id); // 80 > 70 and 80 > SeaLevel(64)
}
[Fact]
public void BlockAt_AboveTerrainBelowSea_IsWater()
{
var blocks = Blocks;
var biomes = new BiomeRegistry(1, blocks);
ushort id = WorldGenerator.BlockAt(60, surfaceHeight: 50, biomes.Plains, blocks);
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);
ushort id = WorldGenerator.BlockAt(70, surfaceHeight: 70, biomes.Desert, blocks);
Assert.Equal(blocks.Sand.Id, id);
}
[Fact]
public void BlockAt_FillerBand_IsBiomeFiller()
{
var blocks = Blocks;
var biomes = new BiomeRegistry(1, blocks);
ushort id = WorldGenerator.BlockAt(68, surfaceHeight: 70, biomes.Plains, blocks);
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);
ushort id = WorldGenerator.BlockAt(40, surfaceHeight: 70, biomes.Plains, blocks);
Assert.Equal(blocks.Stone.Id, id);
}
[Fact]
public void Generate_IsDeterministic()
{
var gen1 = new WorldGenerator(99, Blocks);
var gen2 = new WorldGenerator(99, Blocks);
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]);
}
}

View File

@@ -10,7 +10,6 @@
<ItemGroup>
<PackageReference Include="OpenTK" Version="4.9.4" />
<PackageReference Include="SimplexNoise" Version="2.0.0" />
<PackageReference Include="StbImageSharp" Version="2.30.15" />
</ItemGroup>
@@ -20,8 +19,4 @@
</None>
</ItemGroup>
<ItemGroup>
<Folder Include="World\WorldGen\" />
</ItemGroup>
</Project>

View File

@@ -6,5 +6,5 @@ public class WaterBlock : IBlock
public string Name => "Water";
public bool IsOpaque => true;
public bool IsSolid => false;
public TileCoord GetTextureTile(BlockFace face) => new(3, 3);
public TileCoord GetTextureTile(BlockFace face) => new(3, 1);
}

View File

@@ -0,0 +1,36 @@
namespace VoxelEngine.World.WorldGen;
public sealed class BiomeMap
{
private readonly NoiseSource _noise;
private readonly VoronoiCells _cells;
private readonly BiomeRegistry _biomes;
public BiomeMap(NoiseSource noise, VoronoiCells cells, BiomeRegistry biomes)
{
this._noise = noise;
this._cells = cells;
this._biomes = biomes;
}
public int CellId(float worldX, float worldZ) => this._cells.Nearest(worldX, worldZ).Id;
public IBiome BiomeAt(float worldX, float worldZ)
{
VoronoiCells.Cell cell = this._cells.Nearest(worldX, worldZ);
// Sample climate at the cell center so the biome is constant across the cell.
float temperature = this._noise.Temperature(cell.CenterX, cell.CenterZ);
float elevation = this._noise.Continent(cell.CenterX, cell.CenterZ);
float forest = this._noise.Forest(cell.CenterX, cell.CenterZ);
return BiomeMap.Classify(temperature, elevation, forest, this._biomes);
}
// Pure climate -> biome lookup. Thresholds in noise units (~[-1,1]).
public static IBiome Classify(float temperature, float elevation, float forest, BiomeRegistry biomes)
{
if (elevation > 0.25f) return biomes.Mountains; // high ground
if (temperature > 0.33f) return biomes.Desert; // hot, low
if (temperature > -0.33f && forest > 0f) return biomes.Forest; // temperate, wooded
return biomes.Plains; // temperate/cold, low
}
}

View File

@@ -0,0 +1,20 @@
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)
{
this.Plains = new PlainsBiome(seed, blocks);
this.Desert = new DesertBiome(seed, blocks);
this.Mountains = new MountainsBiome(seed, blocks);
this.Forest = new ForestBiome(seed, blocks);
}
}

View File

@@ -0,0 +1,25 @@
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World.WorldGen.Biomes;
public sealed class DesertBiome : IBiome
{
private readonly FastNoiseLite _detail;
public string Name => "Desert";
public SurfacePalette Surface { get; }
public DesertBiome(int seed, BlockRegistry blocks)
{
this._detail = new FastNoiseLite(seed + 101);
this._detail.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._detail.SetFrequency(0.015f);
this.Surface = new SurfacePalette(blocks.Sand, blocks.Sand, blocks.Stone);
}
public float SampleHeight(NoiseSource noise, float worldX, float worldZ)
=> WorldConstants.SeaLevel + 4f * noise.Continent(worldX, worldZ)
+ 1.5f * this._detail.GetNoise(worldX, worldZ);
public IBlock TopBlock(int worldY) => this.Surface.Top;
}

View File

@@ -0,0 +1,25 @@
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World.WorldGen.Biomes;
public sealed class ForestBiome : IBiome
{
private readonly FastNoiseLite _detail;
public string Name => "Forest";
public SurfacePalette Surface { get; }
public ForestBiome(int seed, BlockRegistry blocks)
{
this._detail = new FastNoiseLite(seed + 103);
this._detail.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._detail.SetFrequency(0.03f);
this.Surface = new SurfacePalette(blocks.Grass, blocks.Dirt, blocks.Stone);
}
public float SampleHeight(NoiseSource noise, float worldX, float worldZ)
=> WorldConstants.SeaLevel + 6f * noise.Continent(worldX, worldZ)
+ 4f * this._detail.GetNoise(worldX, worldZ);
public IBlock TopBlock(int worldY) => this.Surface.Top;
}

View File

@@ -0,0 +1,32 @@
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World.WorldGen.Biomes;
public sealed class MountainsBiome : IBiome
{
private const int SnowLine = 96;
private readonly FastNoiseLite _ridged;
private readonly IBlock _stone;
private readonly IBlock _snow;
public string Name => "Mountains";
public SurfacePalette Surface { get; }
public MountainsBiome(int seed, BlockRegistry blocks)
{
this._ridged = new FastNoiseLite(seed + 102);
this._ridged.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._ridged.SetFractalType(FastNoiseLite.FractalType.Ridged);
this._ridged.SetFractalOctaves(4);
this._ridged.SetFrequency(0.01f);
this._stone = blocks.Stone;
this._snow = blocks.Snow;
this.Surface = new SurfacePalette(blocks.Stone, blocks.Stone, blocks.Stone);
}
public float SampleHeight(NoiseSource noise, float worldX, float worldZ)
=> WorldConstants.SeaLevel + 10f * noise.Continent(worldX, worldZ)
+ 45f * this._ridged.GetNoise(worldX, worldZ);
public IBlock TopBlock(int worldY) => worldY >= MountainsBiome.SnowLine ? this._snow : this._stone;
}

View File

@@ -0,0 +1,25 @@
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World.WorldGen.Biomes;
public sealed class PlainsBiome : IBiome
{
private readonly FastNoiseLite _detail;
public string Name => "Plains";
public SurfacePalette Surface { get; }
public PlainsBiome(int seed, BlockRegistry blocks)
{
this._detail = new FastNoiseLite(seed + 100);
this._detail.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._detail.SetFrequency(0.02f);
this.Surface = new SurfacePalette(blocks.Grass, blocks.Dirt, blocks.Stone);
}
public float SampleHeight(NoiseSource noise, float worldX, float worldZ)
=> WorldConstants.SeaLevel + 6f * noise.Continent(worldX, worldZ)
+ 2f * this._detail.GetNoise(worldX, worldZ);
public IBlock TopBlock(int worldY) => this.Surface.Top;
}

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,32 @@
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)
{
this._continent = new FastNoiseLite(seed);
this._continent.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._continent.SetFractalType(FastNoiseLite.FractalType.FBm);
this._continent.SetFractalOctaves(4);
this._continent.SetFrequency(0.0035f);
this._temperature = new FastNoiseLite(seed + 1);
this._temperature.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._temperature.SetFractalType(FastNoiseLite.FractalType.FBm);
this._temperature.SetFractalOctaves(3);
this._temperature.SetFrequency(0.0015f);
this._forest = new FastNoiseLite(seed + 2);
this._forest.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._forest.SetFrequency(0.02f);
}
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,38 @@
namespace VoxelEngine.World.WorldGen;
public sealed class TerrainSampler
{
// Box-blur radius (in blocks) applied to the biome-height field to smooth
// biome borders. A full (2R+1)x(2R+1) kernel at 1-block spacing bounds the
// per-block height change to ~maxBiomeHeightDiff / (2R+1), preventing cliffs.
// A sparse kernel does NOT bound it (axis-aligned borders flip several samples
// at once), so the kernel must be dense.
private const int BlendRadius = 6;
private readonly NoiseSource _noise;
private readonly BiomeMap _biomes;
public TerrainSampler(NoiseSource noise, BiomeMap biomes)
{
this._noise = noise;
this._biomes = biomes;
}
public int HeightAt(float worldX, float worldZ)
{
// For each kernel point find the biome THERE, but evaluate its height AT the
// column. Averaging blurs the hard biome borders into smooth transitions.
float sum = 0f;
int count = 0;
for (int dz = -TerrainSampler.BlendRadius; dz <= TerrainSampler.BlendRadius; dz++)
for (int dx = -TerrainSampler.BlendRadius; dx <= TerrainSampler.BlendRadius; dx++)
{
IBiome biome = this._biomes.BiomeAt(worldX + dx, worldZ + dz);
sum += biome.SampleHeight(this._noise, worldX, worldZ);
count++;
}
float avg = sum / count;
int h = (int)System.MathF.Round(avg);
return System.Math.Clamp(h, 0, WorldConstants.WorldHeight - 1);
}
}

View File

@@ -0,0 +1,60 @@
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)System.MathF.Floor(x / this._cellSize);
int gz = (int)System.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 = VoronoiCells.Jitter(cgx, cgz, this._seed);
float jitterZ = VoronoiCells.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)VoronoiCells.Hash(cgx, cgz, this._seed);
bestCx = cx;
bestCz = cz;
}
}
return new Cell(bestId, bestCx, bestCz);
}
private static float Jitter(int x, int z, int seed)
=> (VoronoiCells.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 = 8;
public const int WorldHeight = WorldConstants.WorldHeightChunks * VoxelChunk.Size; // 128
public const int SeaLevel = 64;
}

View File

@@ -0,0 +1,61 @@
using OpenTK.Mathematics;
using VoxelEngine.World.Blocks;
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
namespace VoxelEngine.World.WorldGen;
public sealed class WorldGenerator
{
private const int FillerDepth = 3;
private const float CellSize = 96f;
private readonly BlockRegistry _blocks;
private readonly BiomeMap _biomeMap;
private readonly TerrainSampler _terrain;
public WorldGenerator(int seed, BlockRegistry blocks)
{
this._blocks = blocks;
NoiseSource noise = new(seed);
VoronoiCells cells = new(seed, WorldGenerator.CellSize);
BiomeRegistry biomes = new(seed, blocks);
this._biomeMap = new BiomeMap(noise, cells, biomes);
this._terrain = new TerrainSampler(noise, this._biomeMap);
}
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] = WorldGenerator.BlockAt(worldY, height, biome, this._blocks);
}
}
return chunk;
}
// Pure column fill rule, extracted for testability.
public static ushort BlockAt(int worldY, int surfaceHeight, IBiome biome, BlockRegistry blocks)
{
if (worldY > surfaceHeight)
return worldY <= WorldConstants.SeaLevel ? blocks.Water.Id : blocks.Air.Id;
if (worldY == surfaceHeight)
return biome.TopBlock(worldY).Id;
if (worldY >= surfaceHeight - WorldGenerator.FillerDepth)
return biome.Surface.Filler.Id;
return biome.Surface.Under.Id;
}
}