feat(world-gen): larger biomes, smooth height blending, capped mountains; centralize tunables in Configuration

Terrain feel: biome regions ~10x larger (Voronoi cell size 96->960); terrain height now eases across biome borders via a Voronoi distance blend (smooth, and ~25x cheaper than the old 169-sample box blur); mountains capped to ~2/3 world height and gentled (ridged amplitude/frequency lowered), snow line lowered to match.

Centralization: ~27 world-gen/render tuning constants (biome amplitudes & frequencies, climate-noise frequencies/octaves, biome-selection thresholds, blend width, cell size, filler depth, snow line, camera near plane, spawn offset, overlay scale) hoisted from scattered literals into a region-organized, fully-commented Configuration. Configuration is threaded into NoiseSource, the biomes, BiomeRegistry, BiomeMap (+Classify), TerrainSampler, WorldGenerator (+BlockAt filler depth), and Camera via Player. Defaults equal the prior values, so generation is unchanged; worldgen tests updated for the new constructor signatures.
This commit is contained in:
MDietermann
2026-06-03 16:27:06 +02:00
parent 96b8fffcd4
commit e034d10339
18 changed files with 367 additions and 136 deletions

View File

@@ -1,3 +1,5 @@
using VoxelEngine.Core;
namespace VoxelEngine.World.WorldGen;
public sealed class BiomeMap
@@ -5,32 +7,51 @@ public sealed class BiomeMap
private readonly NoiseSource _noise;
private readonly VoronoiCells _cells;
private readonly BiomeRegistry _biomes;
private readonly Configuration _cfg;
public BiomeMap(NoiseSource noise, VoronoiCells cells, BiomeRegistry biomes)
public BiomeMap(NoiseSource noise, VoronoiCells cells, BiomeRegistry biomes, Configuration cfg)
{
this._noise = noise;
this._cells = cells;
this._biomes = biomes;
this._cfg = cfg;
}
public int CellId(float worldX, float worldZ) => this._cells.Nearest(worldX, worldZ).Id;
public IBiome BiomeAt(float worldX, float worldZ)
=> this.BiomeForCell(this._cells.Nearest(worldX, worldZ));
// Climate -> biome, sampled at the cell centre so the biome is constant
// across the cell.
public IBiome BiomeForCell(VoronoiCells.Cell cell)
{
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);
return Classify(temperature, elevation, forest, this._biomes, this._cfg);
}
// The biomes of the two nearest cells plus how far inside the nearer cell the
// point sits (SecondDist - FirstDist, in blocks; 0 exactly on the border).
// TerrainSampler uses this to ease height across biome borders.
public readonly record struct BiomeBlend(IBiome Near, IBiome Far, float BorderDistance);
public BiomeBlend NearestTwo(float worldX, float worldZ)
{
VoronoiCells.NearestPair p = this._cells.NearestTwo(worldX, worldZ);
return new BiomeBlend(
this.BiomeForCell(p.First),
this.BiomeForCell(p.Second),
p.SecondDist - p.FirstDist);
}
// Pure climate -> biome lookup. Thresholds in noise units (~[-1,1]).
public static IBiome Classify(float temperature, float elevation, float forest, BiomeRegistry biomes)
public static IBiome Classify(float temperature, float elevation, float forest, BiomeRegistry biomes, Configuration cfg)
{
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
if (elevation > cfg.MountainsElevationThreshold) return biomes.Mountains; // high ground
if (temperature > cfg.DesertTemperatureThreshold) return biomes.Desert; // hot, low
if (temperature > cfg.ForestTemperatureThreshold && forest > cfg.ForestDensityThreshold) return biomes.Forest; // temperate, wooded
return biomes.Plains; // temperate/cold, low
}
}

View File

@@ -1,3 +1,4 @@
using VoxelEngine.Core;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.WorldGen.Biomes;
@@ -10,11 +11,11 @@ public sealed class BiomeRegistry
public IBiome Mountains { get; }
public IBiome Forest { get; }
public BiomeRegistry(int seed, BlockRegistry blocks)
public BiomeRegistry(int seed, BlockRegistry blocks, Configuration cfg)
{
this.Plains = new PlainsBiome(seed, blocks);
this.Desert = new DesertBiome(seed, blocks);
this.Mountains = new MountainsBiome(seed, blocks);
this.Forest = new ForestBiome(seed, blocks);
this.Plains = new PlainsBiome(seed, blocks, cfg);
this.Desert = new DesertBiome(seed, blocks, cfg);
this.Mountains = new MountainsBiome(seed, blocks, cfg);
this.Forest = new ForestBiome(seed, blocks, cfg);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
using VoxelEngine.Core;
namespace VoxelEngine.World.WorldGen;
// Seeded wrapper over FastNoiseLite. All Get* methods return roughly [-1, 1].
@@ -7,23 +9,23 @@ public sealed class NoiseSource
private readonly FastNoiseLite _temperature;
private readonly FastNoiseLite _forest;
public NoiseSource(int seed)
public NoiseSource(int seed, Configuration cfg)
{
this._continent = new FastNoiseLite(seed);
this._continent.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._continent.SetFractalType(FastNoiseLite.FractalType.FBm);
this._continent.SetFractalOctaves(4);
this._continent.SetFrequency(0.0035f);
this._continent.SetFractalOctaves(cfg.ContinentOctaves);
this._continent.SetFrequency(cfg.ContinentFrequency);
this._temperature = new FastNoiseLite(seed + 1);
this._temperature.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._temperature.SetFractalType(FastNoiseLite.FractalType.FBm);
this._temperature.SetFractalOctaves(3);
this._temperature.SetFrequency(0.0015f);
this._temperature.SetFractalOctaves(cfg.TemperatureOctaves);
this._temperature.SetFrequency(cfg.TemperatureFrequency);
this._forest = new FastNoiseLite(seed + 2);
this._forest.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
this._forest.SetFrequency(0.02f);
this._forest.SetFrequency(cfg.ForestFrequency);
}
public float Continent(float x, float z) => this._continent.GetNoise(x, z);

View File

@@ -1,38 +1,40 @@
using VoxelEngine.Core;
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;
private readonly Configuration _cfg;
public TerrainSampler(NoiseSource noise, BiomeMap biomes)
public TerrainSampler(NoiseSource noise, BiomeMap biomes, Configuration cfg)
{
this._noise = noise;
this._biomes = biomes;
this._cfg = cfg;
}
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);
// Blend the two nearest biomes' heights (each evaluated AT this column) by
// how deep inside the nearer cell we are: exactly on the border they mix
// 50/50, easing to 100% of the nearer biome over BiomeBlendWidth blocks.
BiomeMap.BiomeBlend blend = this._biomes.NearestTwo(worldX, worldZ);
float hNear = blend.Near.SampleHeight(this._noise, worldX, worldZ);
float hFar = blend.Far.SampleHeight(this._noise, worldX, worldZ);
float fracFar = 0.5f * (1f - SmoothStep01(blend.BorderDistance / this._cfg.BiomeBlendWidth));
float avg = hNear * (1f - fracFar) + hFar * fracFar;
int h = (int)MathF.Round(avg);
return Math.Clamp(h, 0, WorldConstants.WorldHeight - 1);
}
// Hermite smoothstep on [0,1].
private static float SmoothStep01(float t)
{
t = Math.Clamp(t, 0f, 1f);
return t * t * (3f - 2f * t);
}
}

View File

@@ -16,8 +16,8 @@ public sealed class VoronoiCells
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);
int gx = (int)MathF.Floor(x / this._cellSize);
int gz = (int)MathF.Floor(z / this._cellSize);
float bestDist = float.MaxValue;
int bestId = 0;
@@ -28,8 +28,8 @@ public sealed class VoronoiCells
{
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 jitterX = Jitter(cgx, cgz, this._seed);
float jitterZ = Jitter(cgx, cgz, this._seed + 9871);
float cx = (cgx + jitterX) * this._cellSize;
float cz = (cgz + jitterZ) * this._cellSize;
float dx = cx - x, dz = cz - z;
@@ -37,7 +37,7 @@ public sealed class VoronoiCells
if (dist < bestDist)
{
bestDist = dist;
bestId = (int)VoronoiCells.Hash(cgx, cgz, this._seed);
bestId = (int)Hash(cgx, cgz, this._seed);
bestCx = cx;
bestCz = cz;
}
@@ -45,8 +45,48 @@ public sealed class VoronoiCells
return new Cell(bestId, bestCx, bestCz);
}
public readonly record struct NearestPair(Cell First, Cell Second, float FirstDist, float SecondDist);
// The two nearest cells to (x,z) with their (Euclidean) distances. Used to
// ease terrain height across the border between adjacent biome cells. Same
// 3x3 jittered-grid search as Nearest — the two closest centres always fall
// within that neighbourhood for jitter in [0,1).
public NearestPair NearestTwo(float x, float z)
{
int gx = (int)MathF.Floor(x / this._cellSize);
int gz = (int)MathF.Floor(z / this._cellSize);
float best = float.MaxValue, second = float.MaxValue;
Cell bestCell = default, secondCell = default;
for (int oz = -1; oz <= 1; oz++)
for (int ox = -1; ox <= 1; ox++)
{
int cgx = gx + ox;
int cgz = gz + oz;
float jitterX = Jitter(cgx, cgz, this._seed);
float jitterZ = Jitter(cgx, cgz, this._seed + 9871);
float cx = (cgx + jitterX) * this._cellSize;
float cz = (cgz + jitterZ) * this._cellSize;
float dx = cx - x, dz = cz - z;
float dist = dx * dx + dz * dz;
Cell cell = new((int)Hash(cgx, cgz, this._seed), cx, cz);
if (dist < best)
{
second = best; secondCell = bestCell;
best = dist; bestCell = cell;
}
else if (dist < second)
{
second = dist; secondCell = cell;
}
}
return new NearestPair(bestCell, secondCell,
MathF.Sqrt(best), MathF.Sqrt(second));
}
private static float Jitter(int x, int z, int seed)
=> (VoronoiCells.Hash(x, z, seed) & 0xFFFFFF) / (float)0x1000000; // [0,1)
=> (Hash(x, z, seed) & 0xFFFFFF) / (float)0x1000000; // [0,1)
private static uint Hash(int x, int z, int seed)
{

View File

@@ -7,9 +7,7 @@ namespace VoxelEngine.World.WorldGen;
public sealed class WorldGenerator
{
private const int FillerDepth = 3;
private const float CellSize = 96f;
private readonly Configuration _cfg;
private readonly BlockRegistry _blocks;
private readonly BiomeMap _biomeMap;
private readonly TerrainSampler _terrain;
@@ -17,17 +15,20 @@ public sealed class WorldGenerator
public WorldGenerator(int seed, BlockRegistry blocks, Configuration cfg)
{
this._cfg = cfg;
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);
NoiseSource noise = new(seed, cfg);
VoronoiCells cells = new(seed, cfg.BiomeCellSize);
BiomeRegistry biomes = new(seed, blocks, cfg);
this._biomeMap = new BiomeMap(noise, cells, biomes, cfg);
this._terrain = new TerrainSampler(noise, this._biomeMap, cfg);
this._caves = new CaveCarver(seed, blocks, cfg);
}
public int SurfaceHeightAt(int worldX, int worldZ) => this._terrain.HeightAt(worldX, worldZ);
public string BiomeNameAt(int worldX, int worldZ) => this._biomeMap.BiomeAt(worldX, worldZ).Name;
public VoxelChunk Generate(Vector3i chunkPos)
{
VoxelChunk chunk = new();
@@ -46,7 +47,7 @@ public sealed class WorldGenerator
for (int y = 0; y < VoxelChunk.Size; y++)
{
int worldY = baseY + y;
chunk[x, y, z] = WorldGenerator.BlockAt(worldY, height, biome, this._blocks);
chunk[x, y, z] = BlockAt(worldY, height, biome, this._blocks, this._cfg.SurfaceFillerDepth);
}
}
@@ -54,13 +55,13 @@ public sealed class WorldGenerator
return chunk;
}
public static ushort BlockAt(int worldY, int surfaceHeight, IBiome biome, BlockRegistry blocks)
public static ushort BlockAt(int worldY, int surfaceHeight, IBiome biome, BlockRegistry blocks, int fillerDepth)
{
if (worldY > surfaceHeight)
return worldY <= WorldConstants.SeaLevel ? blocks.Water.Id : blocks.Air.Id;
if (worldY == surfaceHeight)
return biome.TopBlock(worldY).Id;
if (worldY >= surfaceHeight - WorldGenerator.FillerDepth)
if (worldY >= surfaceHeight - fillerDepth)
return biome.Surface.Filler.Id;
return biome.Surface.Under.Id;
}