From e034d1033925cf287b0cf3600cf9e65d03d73a24 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Wed, 3 Jun 2026 16:27:06 +0200 Subject: [PATCH] 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. --- Core/Configuration.cs | 203 +++++++++++++++--- Game/Player.cs | 1 + Rendering/Camera.cs | 5 +- VoxelEngine.Tests/WorldGen/BiomeMapTests.cs | 15 +- VoxelEngine.Tests/WorldGen/BiomeTests.cs | 11 +- .../WorldGen/NoiseSourceTests.cs | 7 +- .../WorldGen/TerrainSamplerTests.cs | 9 +- .../WorldGen/WorldGeneratorTests.cs | 20 +- World/WorldGen/BiomeMap.cs | 37 +++- World/WorldGen/BiomeRegistry.cs | 11 +- World/WorldGen/Biomes/DesertBiome.cs | 11 +- World/WorldGen/Biomes/ForestBiome.cs | 11 +- World/WorldGen/Biomes/MountainsBiome.cs | 16 +- World/WorldGen/Biomes/PlainsBiome.cs | 11 +- World/WorldGen/NoiseSource.cs | 14 +- World/WorldGen/TerrainSampler.cs | 46 ++-- World/WorldGen/VoronoiCells.cs | 52 ++++- World/WorldGen/WorldGenerator.cs | 23 +- 18 files changed, 367 insertions(+), 136 deletions(-) diff --git a/Core/Configuration.cs b/Core/Configuration.cs index 6920bc6..3ebc967 100644 --- a/Core/Configuration.cs +++ b/Core/Configuration.cs @@ -4,58 +4,205 @@ using OpenTK.Mathematics; public sealed class Configuration { + #region -- World -- + + // World generation seed. null → a fresh random seed each run; set a value for + // a reproducible world. + public int? Seed { get; set; } = null; + + // Blocks above the terrain surface at (0,0) where the player spawns. + public float SpawnHeightOffset { get; set; } = 2f; + + #endregion -- World -- + + #region -- Rendering & View -- + + // Horizontal view radius in chunks (Chebyshev). Drives how far terrain loads + // and the fog / far-plane distance. Higher = see further, much heavier load. public int RenderDistanceHorizontal { get; set; } = 8; + // Camera vertical field of view, in degrees. + public float Fov { get; set; } = 60f; + + // Near clip plane distance (world units); anything nearer is not drawn. + // (The far plane is derived from RenderDistanceHorizontal over in Player.) + public float CameraNearPlane { get; set; } = 0.1f; + // Distance fog begins at this fraction of the fog-end distance (fog end is // derived from RenderDistanceHorizontal). 0.7 → fog starts at 70% of the edge. public float FogStartFraction { get; set; } = 0.7f; - // Sky / clear color, also the distance-fog target color. + // Sky / clear colour, also the distance-fog target colour (RGB, 0–1). public Vector3 SkyColor { get; set; } = new(0.45f, 0.65f, 0.95f); - public int SpawnReadyRadiusHorizontal { get; set; } = 1; - public int MaxGenerateJobsInFlight { get; set; } = 8; - public int MaxMeshUploadsPerFrame { get; set; } = 4; - public int WorkerThreadCount { get; set; } = Math.Max(1, Environment.ProcessorCount / 2); + #endregion -- Rendering & View -- - // 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; + #region -- Window -- - public float MoveSpeed { get; set; } = 8f; - public float MouseSensitivity { get; set; } = 0.15f; - public float Fov { get; set; } = 60f; - - public int WindowWidth { get; set; } = 1280; + // Initial window size in pixels. + public int WindowWidth { get; set; } = 1280; public int WindowHeight { get; set; } = 720; - public int? Seed { get; set; } = null; + #endregion -- Window -- - // -- Cave generation tuning -- + #region -- Chunk Streaming -- + + // Horizontal radius (chunks) around spawn that must finish loading before the + // loading screen clears. + public int SpawnReadyRadiusHorizontal { get; set; } = 1; + + // Max chunk generate/mesh jobs running on worker threads at once. + public int MaxGenerateJobsInFlight { get; set; } = 8; + + // Max finished chunk meshes uploaded to the GPU per frame (caps per-frame hitches). + public int MaxMeshUploadsPerFrame { get; set; } = 4; + + // Background worker threads used for chunk generation/meshing. + public int WorkerThreadCount { get; set; } = Math.Max(1, Environment.ProcessorCount / 2); + + // Multiplier applied to (dy)² 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; + + #endregion -- Chunk Streaming -- + + #region -- Player & Controls -- + + // Movement speed, in blocks per second. + public float MoveSpeed { get; set; } = 8f; + + // Mouse-look sensitivity (degrees of rotation per pixel of mouse movement). + public float MouseSensitivity { get; set; } = 0.15f; + + #endregion -- Player & Controls -- + + #region -- World Gen: Terrain Shape -- + + // Average width in blocks of one biome region (Voronoi cell). Larger = bigger, + // fewer biomes. + public float BiomeCellSize { get; set; } = 960f; + + // Blocks over which terrain height eases from one biome to its neighbour across + // their shared border. Larger = gentler, wider transitions. + public float BiomeBlendWidth { get; set; } = 128f; + + // Filler layers (dirt/sand) placed directly below the surface block before the + // biome's solid under-material (usually stone) begins. + public int SurfaceFillerDepth { get; set; } = 3; + + #endregion -- World Gen: Terrain Shape -- + + #region -- World Gen: Climate Noise -- + // Low-frequency fields that decide biome placement. Lower frequency = larger, + // smoother climate regions; more octaves = more fine variation. + + // Elevation field — high values become Mountains, low values lowland. + public float ContinentFrequency { get; set; } = 0.0035f; + public int ContinentOctaves { get; set; } = 4; + + // Temperature field — high values become Desert. + public float TemperatureFrequency { get; set; } = 0.0015f; + public int TemperatureOctaves { get; set; } = 3; + + // Forest-density field — high values become Forest (else Plains). + public float ForestFrequency { get; set; } = 0.02f; + + #endregion -- World Gen: Climate Noise -- + + #region -- World Gen: Biome Selection -- + // Thresholds on the climate fields (values ≈ -1..1), tested in order: + // mountains → desert → forest → plains. + + // Elevation at/above which a cell is Mountains. + public float MountainsElevationThreshold { get; set; } = 0.25f; + + // Temperature above which a non-mountain cell is Desert. + public float DesertTemperatureThreshold { get; set; } = 0.33f; + + // Minimum temperature for Forest (colder than this → Plains). + public float ForestTemperatureThreshold { get; set; } = -0.33f; + + // Minimum forest-density for Forest (below this → Plains). + public float ForestDensityThreshold { get; set; } = 0f; + + #endregion -- World Gen: Biome Selection -- + + #region -- World Gen: Biome Surface Heights -- + // Per biome: surfaceHeight = SeaLevel + ContinentAmplitude·continentNoise + // + DetailAmplitude·detailNoise. Amplitudes are in blocks; detail frequency + // sets the bumpiness scale (higher = smaller, choppier features). + + // Plains — gentle rolling grassland. + public float PlainsContinentAmplitude { get; set; } = 24f; + public float PlainsDetailAmplitude { get; set; } = 8f; + public float PlainsDetailFrequency { get; set; } = 0.01f; + + // Forest — like plains, a touch hillier. + public float ForestContinentAmplitude { get; set; } = 24f; + public float ForestDetailAmplitude { get; set; } = 16f; + public float ForestDetailFrequency { get; set; } = 0.01f; + + // Desert — flat with low dunes. + public float DesertContinentAmplitude { get; set; } = 16f; + public float DesertDetailAmplitude { get; set; } = 6f; + public float DesertDetailFrequency { get; set; } = 0.008f; + + // Mountains — tall ridged peaks (uses ridged noise instead of a smooth detail + // field). Peaks roughly cap at SeaLevel + ContinentAmplitude + RidgedAmplitude. + public float MountainsContinentAmplitude { get; set; } = 40f; + public float MountainsRidgedAmplitude { get; set; } = 45f; + public float MountainsRidgedFrequency { get; set; } = 0.005f; + public int MountainsRidgedOctaves { get; set; } = 4; + // World-Y at/above which mountain tops render as snow. + public int MountainsSnowLine { get; set; } = 270; + + #endregion -- World Gen: Biome Surface Heights -- + + #region -- Cave Generation -- // Spatial frequency of the 3D simplex cave-density field; higher = smaller, // more frequent pockets. - public float CaveNoiseFrequency { get; set; } = 0.03f; + public float CaveNoiseFrequency { get; set; } = 0.03f; + // Density cutoff below which a solid voxel becomes air; higher = more cave. - public float CaveNoiseThreshold { get; set; } = -0.3f; + public float CaveNoiseThreshold { get; set; } = -0.3f; + // Edge length (blocks) of a worm-spawn grid cell; one worm candidate per cell. - public int WormCellSize { get; set; } = 64; + public int WormCellSize { get; set; } = 64; + // Per-cell chance out of 1000 that a worm spawns; 300 = 30%. - public int WormSpawnProbabilityPermille { get; set; } = 300; + public int WormSpawnProbabilityPermille { get; set; } = 300; + // Number of trace steps (≈ blocks) a worm travels. - public int WormMaxLength { get; set; } = 80; - // Worm tunnel radius bounds; interpolated + sine-modulated along the path - // to form bulges/chambers. - public float WormMinRadius { get; set; } = 1.5f; - public float WormMaxRadius { get; set; } = 3.5f; + public int WormMaxLength { get; set; } = 80; + + // Worm tunnel radius bounds; interpolated + sine-modulated along the path to + // form bulges/chambers. + public float WormMinRadius { get; set; } = 1.5f; + public float WormMaxRadius { get; set; } = 3.5f; + // Ceiling: caves carve only at or below this world-Y ("no caves above"). // Default 256 = SeaLevel → caves stay below sea level, keeping the taller // surface terrain solid. Raise toward WorldHeight-1 to allow surface caves. - public int MinCaveY { get; set; } = 256; + public int MinCaveY { get; set; } = 256; + + #endregion -- Cave Generation -- + + #region -- Cave Lighting -- + // Underground darkening: brightness fades from CaveDarkStartY down to + // CaveDarkEndY, never dropping below CaveMinBrightness. - // -- Cave lighting (fog-distance darkness) -- public float CaveDarkStartY { get; set; } = 264f; // WorldConstants.SeaLevel + 8 public float CaveDarkEndY { get; set; } = 224f; // WorldConstants.SeaLevel - 32 - public float CaveMinBrightness { get; set; } = 0.15f; + public float CaveMinBrightness { get; set; } = 0.15f; // floor brightness deep underground (0–1) + + #endregion -- Cave Lighting -- + + #region -- Debug Overlay -- + + // Pixel size of each font "pixel" in the on-screen debug text (biome + coords). + public int DebugOverlayTextScale { get; set; } = 3; + + #endregion -- Debug Overlay -- } diff --git a/Game/Player.cs b/Game/Player.cs index 7b66e9e..af9d49a 100644 --- a/Game/Player.cs +++ b/Game/Player.cs @@ -32,6 +32,7 @@ public sealed class Player this.Camera = new Camera(spawnPosition, aspectRatio) { Fov = cfg.Fov, + NearPlane = cfg.CameraNearPlane, FarPlane = (cfg.RenderDistanceHorizontal + 1) * VoxelChunk.Size, }; } diff --git a/Rendering/Camera.cs b/Rendering/Camera.cs index db8c21a..634b21c 100644 --- a/Rendering/Camera.cs +++ b/Rendering/Camera.cs @@ -10,6 +10,7 @@ public sealed class Camera public float Yaw { get; private set; } = -90f; // degrees; -90 looks down -Z public float Pitch { get; private set; } // degrees public float Fov { get; set; } = 60f; // degrees + public float NearPlane { get; set; } = 0.1f; // world units public float FarPlane { get; set; } = 100f; // world units public float AspectRatio { get; set; } @@ -37,7 +38,7 @@ public sealed class Camera public Matrix4 GetProjectionMatrix() => Matrix4.CreatePerspectiveFieldOfView( - MathHelper.DegreesToRadians(this.Fov), this.AspectRatio, 0.1f, this.FarPlane); + MathHelper.DegreesToRadians(this.Fov), this.AspectRatio, this.NearPlane, this.FarPlane); private void UpdateVectors() { @@ -49,7 +50,7 @@ public sealed class Camera MathF.Sin(pitch), MathF.Sin(yaw) * MathF.Cos(pitch))); - this.Right = Vector3.Normalize(Vector3.Cross(this.Front, Camera.WorldUp)); + this.Right = Vector3.Normalize(Vector3.Cross(this.Front, WorldUp)); this.Up = Vector3.Normalize(Vector3.Cross(this.Right, this.Front)); } } diff --git a/VoxelEngine.Tests/WorldGen/BiomeMapTests.cs b/VoxelEngine.Tests/WorldGen/BiomeMapTests.cs index 7cb86b6..77988a0 100644 --- a/VoxelEngine.Tests/WorldGen/BiomeMapTests.cs +++ b/VoxelEngine.Tests/WorldGen/BiomeMapTests.cs @@ -1,3 +1,4 @@ +using VoxelEngine.Core; using VoxelEngine.World.Blocks; using VoxelEngine.World.WorldGen; using Xunit; @@ -9,38 +10,38 @@ 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 biomes = new BiomeRegistry(seed, blocks, new Configuration()); + var noise = new NoiseSource(seed, new Configuration()); var cells = new VoronoiCells(seed, 96f); - return (new BiomeMap(noise, cells, biomes), biomes); + return (new BiomeMap(noise, cells, biomes, new Configuration()), biomes); } [Fact] public void Classify_HighElevation_IsMountains() { var (_, biomes) = Build(); - Assert.Same(biomes.Mountains, BiomeMap.Classify(0f, 0.5f, 0f, biomes)); + Assert.Same(biomes.Mountains, BiomeMap.Classify(0f, 0.5f, 0f, biomes, new Configuration())); } [Fact] public void Classify_HotLowElevation_IsDesert() { var (_, biomes) = Build(); - Assert.Same(biomes.Desert, BiomeMap.Classify(0.5f, 0f, 0f, biomes)); + Assert.Same(biomes.Desert, BiomeMap.Classify(0.5f, 0f, 0f, biomes, new Configuration())); } [Fact] public void Classify_TemperateWooded_IsForest() { var (_, biomes) = Build(); - Assert.Same(biomes.Forest, BiomeMap.Classify(0f, 0f, 0.5f, biomes)); + Assert.Same(biomes.Forest, BiomeMap.Classify(0f, 0f, 0.5f, biomes, new Configuration())); } [Fact] public void Classify_ColdLow_IsPlains() { var (_, biomes) = Build(); - Assert.Same(biomes.Plains, BiomeMap.Classify(-0.9f, 0f, 0f, biomes)); + Assert.Same(biomes.Plains, BiomeMap.Classify(-0.9f, 0f, 0f, biomes, new Configuration())); } [Fact] diff --git a/VoxelEngine.Tests/WorldGen/BiomeTests.cs b/VoxelEngine.Tests/WorldGen/BiomeTests.cs index 9942b17..38fb70c 100644 --- a/VoxelEngine.Tests/WorldGen/BiomeTests.cs +++ b/VoxelEngine.Tests/WorldGen/BiomeTests.cs @@ -1,3 +1,4 @@ +using VoxelEngine.Core; using VoxelEngine.World.Blocks; using VoxelEngine.World.WorldGen; using Xunit; @@ -10,7 +11,7 @@ public class BiomeTests public void Desert_TopAndFiller_AreSand() { var blocks = new BlockRegistry(); - var biomes = new BiomeRegistry(42, blocks); + var biomes = new BiomeRegistry(42, blocks, new Configuration()); Assert.Equal("Sand", biomes.Desert.Surface.Top.Name); Assert.Equal("Sand", biomes.Desert.Surface.Filler.Name); } @@ -19,7 +20,7 @@ public class BiomeTests public void Mountains_UseSnowAboveSnowLine_StoneBelow() { var blocks = new BlockRegistry(); - var biomes = new BiomeRegistry(42, blocks); + var biomes = new BiomeRegistry(42, blocks, new Configuration()); Assert.Equal("Stone", biomes.Mountains.TopBlock(200).Name); // below snow line Assert.Equal("Snow Block", biomes.Mountains.TopBlock(420).Name); // above snow line (384) } @@ -28,7 +29,7 @@ public class BiomeTests public void Plains_TopBlock_IsGrass_RegardlessOfHeight() { var blocks = new BlockRegistry(); - var biomes = new BiomeRegistry(42, blocks); + var biomes = new BiomeRegistry(42, blocks, new Configuration()); Assert.Equal("Grass", biomes.Plains.TopBlock(40).Name); Assert.Equal("Grass", biomes.Plains.TopBlock(120).Name); } @@ -37,8 +38,8 @@ public class BiomeTests public void SampleHeight_IsDeterministic() { var blocks = new BlockRegistry(); - var biomes = new BiomeRegistry(42, blocks); - var noise = new NoiseSource(42); + var biomes = new BiomeRegistry(42, blocks, new Configuration()); + var noise = new NoiseSource(42, new Configuration()); float h1 = biomes.Mountains.SampleHeight(noise, 100f, 200f); float h2 = biomes.Mountains.SampleHeight(noise, 100f, 200f); Assert.Equal(h1, h2); diff --git a/VoxelEngine.Tests/WorldGen/NoiseSourceTests.cs b/VoxelEngine.Tests/WorldGen/NoiseSourceTests.cs index b4eacd5..ead074e 100644 --- a/VoxelEngine.Tests/WorldGen/NoiseSourceTests.cs +++ b/VoxelEngine.Tests/WorldGen/NoiseSourceTests.cs @@ -1,3 +1,4 @@ +using VoxelEngine.Core; using VoxelEngine.World.WorldGen; using Xunit; @@ -8,8 +9,8 @@ public class NoiseSourceTests [Fact] public void SameSeed_SameCoord_IsDeterministic() { - var a = new NoiseSource(42); - var b = new NoiseSource(42); + var a = new NoiseSource(42, new Configuration()); + var b = new NoiseSource(42, new Configuration()); Assert.Equal(a.Continent(10f, 20f), b.Continent(10f, 20f)); Assert.Equal(a.Temperature(10f, 20f), b.Temperature(10f, 20f)); } @@ -17,7 +18,7 @@ public class NoiseSourceTests [Fact] public void Output_InMinusOneToOneRange() { - var n = new NoiseSource(7); + var n = new NoiseSource(7, new Configuration()); for (int i = 0; i < 200; i++) { float c = n.Continent(i * 3.1f, i * 1.7f); diff --git a/VoxelEngine.Tests/WorldGen/TerrainSamplerTests.cs b/VoxelEngine.Tests/WorldGen/TerrainSamplerTests.cs index b020a56..2ca750f 100644 --- a/VoxelEngine.Tests/WorldGen/TerrainSamplerTests.cs +++ b/VoxelEngine.Tests/WorldGen/TerrainSamplerTests.cs @@ -1,3 +1,4 @@ +using VoxelEngine.Core; using VoxelEngine.World.Blocks; using VoxelEngine.World.WorldGen; using Xunit; @@ -9,11 +10,11 @@ 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 biomes = new BiomeRegistry(seed, blocks, new Configuration()); + var noise = new NoiseSource(seed, new Configuration()); var cells = new VoronoiCells(seed, 96f); - var map = new BiomeMap(noise, cells, biomes); - return new TerrainSampler(noise, map); + var map = new BiomeMap(noise, cells, biomes, new Configuration()); + return new TerrainSampler(noise, map, new Configuration()); } [Fact] diff --git a/VoxelEngine.Tests/WorldGen/WorldGeneratorTests.cs b/VoxelEngine.Tests/WorldGen/WorldGeneratorTests.cs index 8469ed1..731ed44 100644 --- a/VoxelEngine.Tests/WorldGen/WorldGeneratorTests.cs +++ b/VoxelEngine.Tests/WorldGen/WorldGeneratorTests.cs @@ -15,8 +15,8 @@ public class WorldGeneratorTests public void BlockAt_AboveTerrainAndSea_IsAir() { var blocks = Blocks; - var biomes = new BiomeRegistry(1, blocks); - ushort id = WorldGenerator.BlockAt(300, surfaceHeight: 290, biomes.Plains, blocks); + var biomes = new BiomeRegistry(1, blocks, new Configuration()); + ushort id = WorldGenerator.BlockAt(300, surfaceHeight: 290, biomes.Plains, blocks, fillerDepth: 3); Assert.Equal(blocks.Air.Id, id); // 300 > 290 (above surface) and 300 > SeaLevel(256) } @@ -24,8 +24,8 @@ public class WorldGeneratorTests public void BlockAt_AboveTerrainBelowSea_IsWater() { var blocks = Blocks; - var biomes = new BiomeRegistry(1, blocks); - ushort id = WorldGenerator.BlockAt(60, surfaceHeight: 50, biomes.Plains, blocks); + var biomes = new BiomeRegistry(1, blocks, new Configuration()); + ushort id = WorldGenerator.BlockAt(60, surfaceHeight: 50, biomes.Plains, blocks, fillerDepth: 3); Assert.Equal(blocks.Water.Id, id); // 60 > 50 but 60 <= SeaLevel(64) } @@ -33,8 +33,8 @@ public class WorldGeneratorTests public void BlockAt_Surface_IsBiomeTop() { var blocks = Blocks; - var biomes = new BiomeRegistry(1, blocks); - ushort id = WorldGenerator.BlockAt(70, surfaceHeight: 70, biomes.Desert, blocks); + var biomes = new BiomeRegistry(1, blocks, new Configuration()); + ushort id = WorldGenerator.BlockAt(70, surfaceHeight: 70, biomes.Desert, blocks, fillerDepth: 3); Assert.Equal(blocks.Sand.Id, id); } @@ -42,8 +42,8 @@ public class WorldGeneratorTests public void BlockAt_FillerBand_IsBiomeFiller() { var blocks = Blocks; - var biomes = new BiomeRegistry(1, blocks); - ushort id = WorldGenerator.BlockAt(68, surfaceHeight: 70, biomes.Plains, blocks); + var biomes = new BiomeRegistry(1, blocks, new Configuration()); + ushort id = WorldGenerator.BlockAt(68, surfaceHeight: 70, biomes.Plains, blocks, fillerDepth: 3); Assert.Equal(blocks.Dirt.Id, id); // within 3 of surface } @@ -51,8 +51,8 @@ public class WorldGeneratorTests public void BlockAt_Deep_IsStone() { var blocks = Blocks; - var biomes = new BiomeRegistry(1, blocks); - ushort id = WorldGenerator.BlockAt(40, surfaceHeight: 70, biomes.Plains, blocks); + var biomes = new BiomeRegistry(1, blocks, new Configuration()); + ushort id = WorldGenerator.BlockAt(40, surfaceHeight: 70, biomes.Plains, blocks, fillerDepth: 3); Assert.Equal(blocks.Stone.Id, id); } diff --git a/World/WorldGen/BiomeMap.cs b/World/WorldGen/BiomeMap.cs index 857a5c6..c6cbb64 100644 --- a/World/WorldGen/BiomeMap.cs +++ b/World/WorldGen/BiomeMap.cs @@ -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 } } diff --git a/World/WorldGen/BiomeRegistry.cs b/World/WorldGen/BiomeRegistry.cs index 04d742f..0d1d1e7 100644 --- a/World/WorldGen/BiomeRegistry.cs +++ b/World/WorldGen/BiomeRegistry.cs @@ -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); } } diff --git a/World/WorldGen/Biomes/DesertBiome.cs b/World/WorldGen/Biomes/DesertBiome.cs index 4f4c87f..ff867e2 100644 --- a/World/WorldGen/Biomes/DesertBiome.cs +++ b/World/WorldGen/Biomes/DesertBiome.cs @@ -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; } diff --git a/World/WorldGen/Biomes/ForestBiome.cs b/World/WorldGen/Biomes/ForestBiome.cs index 96f31b8..8725a7e 100644 --- a/World/WorldGen/Biomes/ForestBiome.cs +++ b/World/WorldGen/Biomes/ForestBiome.cs @@ -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; } diff --git a/World/WorldGen/Biomes/MountainsBiome.cs b/World/WorldGen/Biomes/MountainsBiome.cs index d4610c8..74ec50a 100644 --- a/World/WorldGen/Biomes/MountainsBiome.cs +++ b/World/WorldGen/Biomes/MountainsBiome.cs @@ -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; } diff --git a/World/WorldGen/Biomes/PlainsBiome.cs b/World/WorldGen/Biomes/PlainsBiome.cs index 872db34..993921b 100644 --- a/World/WorldGen/Biomes/PlainsBiome.cs +++ b/World/WorldGen/Biomes/PlainsBiome.cs @@ -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; } diff --git a/World/WorldGen/NoiseSource.cs b/World/WorldGen/NoiseSource.cs index 74aef97..c737a6b 100644 --- a/World/WorldGen/NoiseSource.cs +++ b/World/WorldGen/NoiseSource.cs @@ -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); diff --git a/World/WorldGen/TerrainSampler.cs b/World/WorldGen/TerrainSampler.cs index 6f01a82..bf90f41 100644 --- a/World/WorldGen/TerrainSampler.cs +++ b/World/WorldGen/TerrainSampler.cs @@ -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); } } diff --git a/World/WorldGen/VoronoiCells.cs b/World/WorldGen/VoronoiCells.cs index ac5291b..2df0962 100644 --- a/World/WorldGen/VoronoiCells.cs +++ b/World/WorldGen/VoronoiCells.cs @@ -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) { diff --git a/World/WorldGen/WorldGenerator.cs b/World/WorldGen/WorldGenerator.cs index 4e09a90..a091db3 100644 --- a/World/WorldGen/WorldGenerator.cs +++ b/World/WorldGen/WorldGenerator.cs @@ -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; }