From 53659450a6de73a19fd8dd911cd145d01635ab74 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 12 Jun 2026 09:23:45 +0300 Subject: [PATCH] Add MrGameEng.WorldGen: deterministic procedural world generation Introduce a WorldGen module under Simulation with reusable helpers for seed-based terrain heightmaps: PerlinNoise (deterministic 2D gradient noise), FractalNoise (fBm over octaves), Falloff (island edge masks), Heightmap (row-major grid with min-max normalize) and HeightmapGenerator (settings to normalized heightmap). All deterministic from an integer seed, allocation-free per sample, covered by xUnit tests. Co-Authored-By: Claude Opus 4.8 --- src/MrGameEng.Simulation/WorldGen/Falloff.cs | 32 +++++ .../WorldGen/FractalNoise.cs | 58 ++++++++ .../WorldGen/Heightmap.cs | 64 +++++++++ .../WorldGen/HeightmapGenerator.cs | 97 ++++++++++++++ .../WorldGen/PerlinNoise.cs | 78 +++++++++++ .../WorldGen/WorldGenTests.cs | 124 ++++++++++++++++++ 6 files changed, 453 insertions(+) create mode 100644 src/MrGameEng.Simulation/WorldGen/Falloff.cs create mode 100644 src/MrGameEng.Simulation/WorldGen/FractalNoise.cs create mode 100644 src/MrGameEng.Simulation/WorldGen/Heightmap.cs create mode 100644 src/MrGameEng.Simulation/WorldGen/HeightmapGenerator.cs create mode 100644 src/MrGameEng.Simulation/WorldGen/PerlinNoise.cs create mode 100644 tests/MrGameEng.Simulation.Tests/WorldGen/WorldGenTests.cs diff --git a/src/MrGameEng.Simulation/WorldGen/Falloff.cs b/src/MrGameEng.Simulation/WorldGen/Falloff.cs new file mode 100644 index 0000000..1c2e649 --- /dev/null +++ b/src/MrGameEng.Simulation/WorldGen/Falloff.cs @@ -0,0 +1,32 @@ +namespace MrGameEng.WorldGen; + +/// +/// Falloff masks that bias a heightmap toward water near the map edges, turning open noise into +/// island-like continents. All methods are pure and deterministic. +/// +public static class Falloff +{ + /// + /// Island mask for normalized coordinates (, ) in + /// [-1, 1] where (0,0) is the map centre. Returns 1 at the centre and falls toward 0 at + /// the edges; sets the coastline steepness (higher = sharper). + /// + public static float Island(float nx, float ny, float power = 3f) + { + // Chebyshev distance keeps the whole border ring low, so every edge becomes water. + var d = Math.Clamp(MathF.Max(MathF.Abs(nx), MathF.Abs(ny)), 0f, 1f); + return 1f - MathF.Pow(d, power); + } + + /// + /// Island mask for a grid cell: maps (, ) on a + /// × grid into [-1, 1] and evaluates + /// . + /// + public static float EdgeFalloff(int x, int y, int width, int height, float power = 3f) + { + var nx = width <= 1 ? 0f : x / (float)(width - 1) * 2f - 1f; + var ny = height <= 1 ? 0f : y / (float)(height - 1) * 2f - 1f; + return Island(nx, ny, power); + } +} diff --git a/src/MrGameEng.Simulation/WorldGen/FractalNoise.cs b/src/MrGameEng.Simulation/WorldGen/FractalNoise.cs new file mode 100644 index 0000000..d3c27d5 --- /dev/null +++ b/src/MrGameEng.Simulation/WorldGen/FractalNoise.cs @@ -0,0 +1,58 @@ +namespace MrGameEng.WorldGen; + +/// +/// Fractal Brownian motion over a source: sums several octaves of +/// noise, each at a higher frequency () and lower amplitude +/// (), to build natural detail at multiple scales. The sum is +/// normalized back into [-1, 1] regardless of octave count. Immutable and deterministic. +/// +public sealed class FractalNoise +{ + private readonly PerlinNoise _noise; + + /// Number of noise layers summed (≥1). More octaves add finer detail. + public int Octaves { get; } + + /// Base frequency applied to the first octave's sample coordinates. + public float Frequency { get; } + + /// Amplitude multiplier between octaves (0..1, typically 0.5): lower is smoother. + public float Persistence { get; } + + /// Frequency multiplier between octaves (typically 2): higher adds detail faster. + public float Lacunarity { get; } + + /// Builds an fBm sampler over a Perlin field seeded by . + public FractalNoise( + int seed, + int octaves = 4, + float frequency = 1f, + float persistence = 0.5f, + float lacunarity = 2f + ) + { + _noise = new PerlinNoise(seed); + Octaves = Math.Max(1, octaves); + Frequency = frequency; + Persistence = persistence; + Lacunarity = lacunarity; + } + + /// Samples the summed octaves at (, ); result is in [-1, 1]. + public float Sample(float x, float y) + { + var amplitude = 1f; + var frequency = Frequency; + var sum = 0f; + var totalAmplitude = 0f; + for (var i = 0; i < Octaves; i++) + { + sum += amplitude * _noise.Sample(x * frequency, y * frequency); + totalAmplitude += amplitude; + amplitude *= Persistence; + frequency *= Lacunarity; + } + + return totalAmplitude > 0f ? sum / totalAmplitude : 0f; + } +} diff --git a/src/MrGameEng.Simulation/WorldGen/Heightmap.cs b/src/MrGameEng.Simulation/WorldGen/Heightmap.cs new file mode 100644 index 0000000..ec0108d --- /dev/null +++ b/src/MrGameEng.Simulation/WorldGen/Heightmap.cs @@ -0,0 +1,64 @@ +namespace MrGameEng.WorldGen; + +/// +/// A dense row-major grid of height values. Plain data with indexed access — fill it from a +/// and read it from worldgen code. Values are arbitrary until +/// rescales them into [0, 1]. +/// +public sealed class Heightmap +{ + /// Grid width in cells. + public int Width { get; } + + /// Grid height in cells. + public int Height { get; } + + /// Row-major values, length ×. + public float[] Values { get; } + + /// Creates an all-zero map of the given size. + public Heightmap(int width, int height) + { + Width = width; + Height = height; + Values = new float[width * height]; + } + + /// Height at (, ). + public float this[int x, int y] + { + get => Values[y * Width + x]; + set => Values[y * Width + x] = value; + } + + /// Rescales all values into [0, 1] by min–max; a flat map becomes all zeros. + public void Normalize() + { + var min = float.MaxValue; + var max = float.MinValue; + foreach (var v in Values) + { + if (v < min) + { + min = v; + } + + if (v > max) + { + max = v; + } + } + + var range = max - min; + if (range <= float.Epsilon) + { + Array.Clear(Values); + return; + } + + for (var i = 0; i < Values.Length; i++) + { + Values[i] = (Values[i] - min) / range; + } + } +} diff --git a/src/MrGameEng.Simulation/WorldGen/HeightmapGenerator.cs b/src/MrGameEng.Simulation/WorldGen/HeightmapGenerator.cs new file mode 100644 index 0000000..aee05fa --- /dev/null +++ b/src/MrGameEng.Simulation/WorldGen/HeightmapGenerator.cs @@ -0,0 +1,97 @@ +namespace MrGameEng.WorldGen; + +/// +/// Parameters for . Defaults give a single continent with a soft +/// coastline; raise / for finer detail and +/// for how strongly the edges are pushed underwater. +/// +public readonly record struct HeightmapSettings +{ + /// Integer seed — the whole map derives from it. + public int Seed { get; init; } + + /// Number of feature cycles across the map at the base octave (higher = smaller features). + public float Frequency { get; init; } + + /// Number of fBm octaves (≥1). + public int Octaves { get; init; } + + /// fBm amplitude falloff per octave (0..1). + public float Persistence { get; init; } + + /// fBm frequency growth per octave (typically 2). + public float Lacunarity { get; init; } + + /// Island falloff blend in [0, 1]: 0 disables it, 1 applies it fully. + public float IslandStrength { get; init; } + + /// Coastline steepness of the island mask (higher = sharper); see . + public float IslandPower { get; init; } + + /// Sensible defaults: one continent, 5 octaves, soft coastline. + public static HeightmapSettings Default => + new() + { + Seed = 0, + Frequency = 3f, + Octaves = 5, + Persistence = 0.5f, + Lacunarity = 2f, + IslandStrength = 0.6f, + IslandPower = 3f, + }; +} + +/// +/// Turns into a normalized : fractal noise +/// sampled per cell, optionally multiplied by an island falloff, then rescaled into [0, 1]. +/// Fully deterministic — the same settings always produce the same map, so a world can be rebuilt +/// from its seed alone. Owns no world data. +/// +public sealed class HeightmapGenerator +{ + private readonly HeightmapSettings _settings; + private readonly FractalNoise _noise; + + /// Builds a generator for the given . + public HeightmapGenerator(HeightmapSettings settings) + { + _settings = settings; + _noise = new FractalNoise( + settings.Seed, + settings.Octaves, + frequency: 1f, + settings.Persistence, + settings.Lacunarity + ); + } + + /// Generates a × normalized heightmap. + public Heightmap Generate(int width, int height) + { + var map = new Heightmap(width, height); + var scaleX = width <= 1 ? 0f : _settings.Frequency / (width - 1); + var scaleY = height <= 1 ? 0f : _settings.Frequency / (height - 1); + var island = Math.Clamp(_settings.IslandStrength, 0f, 1f); + + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + // fBm in [-1,1] → [0,1]. + var n = _noise.Sample(x * scaleX, y * scaleY) * 0.5f + 0.5f; + if (island > 0f) + { + var mask = Falloff.EdgeFalloff(x, y, width, height, _settings.IslandPower); + // Blend the raw height with its masked self by the island strength. + n *= 1f - island + mask * island; + } + + map[x, y] = n; + } + } + + map.Normalize(); + return map; + } +} diff --git a/src/MrGameEng.Simulation/WorldGen/PerlinNoise.cs b/src/MrGameEng.Simulation/WorldGen/PerlinNoise.cs new file mode 100644 index 0000000..ba87f3c --- /dev/null +++ b/src/MrGameEng.Simulation/WorldGen/PerlinNoise.cs @@ -0,0 +1,78 @@ +namespace MrGameEng.WorldGen; + +/// +/// Deterministic 2D Perlin (gradient) noise. Construct once per seed, then sample any number of +/// points: is allocation-free and returns smooth values in [-1, 1] +/// that vary continuously across the plane. The permutation table is built from the integer +/// seed, so the same seed always yields the same field — the basis for reproducible world +/// generation. is a pure read and safe to call concurrently. +/// +public sealed class PerlinNoise +{ + private readonly int[] _perm = new int[512]; + + /// Builds the noise field deterministically from . + public PerlinNoise(int seed) + { + var p = new int[256]; + for (var i = 0; i < 256; i++) + { + p[i] = i; + } + + // Deterministic Fisher–Yates shuffle from the seed — never Random.Shared in simulation. + var random = new Random(seed); + for (var i = 255; i > 0; i--) + { + var j = random.Next(i + 1); + (p[i], p[j]) = (p[j], p[i]); + } + + for (var i = 0; i < 512; i++) + { + _perm[i] = p[i & 255]; + } + } + + /// Samples the noise at (, ); result is in [-1, 1]. + public float Sample(float x, float y) + { + var x0 = (int)MathF.Floor(x); + var y0 = (int)MathF.Floor(y); + var xi = x0 & 255; + var yi = y0 & 255; + var xf = x - x0; + var yf = y - y0; + + var u = Fade(xf); + var v = Fade(yf); + + var aa = _perm[_perm[xi] + yi]; + var ab = _perm[_perm[xi] + yi + 1]; + var ba = _perm[_perm[xi + 1] + yi]; + var bb = _perm[_perm[xi + 1] + yi + 1]; + + var x1 = Lerp(Grad(aa, xf, yf), Grad(ba, xf - 1f, yf), u); + var x2 = Lerp(Grad(ab, xf, yf - 1f), Grad(bb, xf - 1f, yf - 1f), u); + return Lerp(x1, x2, v); + } + + // Quintic ease curve (Perlin's improved fade): zero first and second derivatives at 0 and 1. + private static float Fade(float t) => t * t * t * (t * (t * 6f - 15f) + 10f); + + private static float Lerp(float a, float b, float t) => a + t * (b - a); + + // Dot product with one of 8 gradient directions chosen by the low bits of the hash. + private static float Grad(int hash, float x, float y) => + (hash & 7) switch + { + 0 => x + y, + 1 => -x + y, + 2 => x - y, + 3 => -x - y, + 4 => x, + 5 => -x, + 6 => y, + _ => -y, + }; +} diff --git a/tests/MrGameEng.Simulation.Tests/WorldGen/WorldGenTests.cs b/tests/MrGameEng.Simulation.Tests/WorldGen/WorldGenTests.cs new file mode 100644 index 0000000..10c3fc9 --- /dev/null +++ b/tests/MrGameEng.Simulation.Tests/WorldGen/WorldGenTests.cs @@ -0,0 +1,124 @@ +using MrGameEng.WorldGen; +using Xunit; + +namespace MrGameEng.WorldGen.Tests; + +public class WorldGenTests +{ + private static HeightmapSettings Settings(int seed) => + HeightmapSettings.Default with + { + Seed = seed, + }; + + [Fact] + public void Generate_SameSeed_ProducesIdenticalMap() + { + var a = new HeightmapGenerator(Settings(1234)).Generate(64, 48); + var b = new HeightmapGenerator(Settings(1234)).Generate(64, 48); + + Assert.Equal(a.Values, b.Values); + } + + [Fact] + public void Generate_DifferentSeeds_ProduceDifferentMaps() + { + var a = new HeightmapGenerator(Settings(1)).Generate(64, 48); + var b = new HeightmapGenerator(Settings(2)).Generate(64, 48); + + Assert.NotEqual(a.Values, b.Values); + } + + [Fact] + public void Generate_AfterNormalize_ValuesSpanUnitRange() + { + var map = new HeightmapGenerator(Settings(7)).Generate(80, 60); + + var min = float.MaxValue; + var max = float.MinValue; + foreach (var v in map.Values) + { + Assert.InRange(v, 0f, 1f); + min = MathF.Min(min, v); + max = MathF.Max(max, v); + } + + Assert.Equal(0f, min, 3); + Assert.Equal(1f, max, 3); + } + + [Fact] + public void Generate_WithIsland_EdgesAreLowerThanCentre() + { + var settings = HeightmapSettings.Default with { Seed = 42, IslandStrength = 1f }; + var map = new HeightmapGenerator(settings).Generate(64, 64); + + // Average the border ring vs. a centred block. + var edge = 0f; + var edgeCount = 0; + for (var x = 0; x < map.Width; x++) + { + edge += map[x, 0] + map[x, map.Height - 1]; + edgeCount += 2; + } + + var centre = 0f; + var centreCount = 0; + for (var y = map.Height / 2 - 4; y < map.Height / 2 + 4; y++) + { + for (var x = map.Width / 2 - 4; x < map.Width / 2 + 4; x++) + { + centre += map[x, y]; + centreCount++; + } + } + + Assert.True( + edge / edgeCount < centre / centreCount, + "island edges should sit below centre" + ); + } + + [Fact] + public void PerlinSample_StaysWithinUnitRange() + { + var noise = new PerlinNoise(99); + for (var i = 0; i < 2000; i++) + { + var v = noise.Sample(i * 0.37f, i * 0.19f); + Assert.InRange(v, -1f, 1f); + } + } + + [Fact] + public void PerlinSample_IsContinuousBetweenNeighbours() + { + var noise = new PerlinNoise(5); + var previous = noise.Sample(0f, 0f); + for (var i = 1; i < 500; i++) + { + var current = noise.Sample(i * 0.01f, 0f); + Assert.True( + MathF.Abs(current - previous) < 0.2f, + "noise jumped between adjacent samples" + ); + previous = current; + } + } + + [Fact] + public void PerlinSample_AtIntegerLattice_IsZero() + { + var noise = new PerlinNoise(3); + Assert.Equal(0f, noise.Sample(4f, 7f), 5); + } + + [Fact] + public void IslandFalloff_CentreIsHigherThanEdge() + { + Assert.True(Falloff.Island(0f, 0f) > Falloff.Island(1f, 0f)); + Assert.True(Falloff.Island(0f, 0f) > Falloff.Island(0f, 1f)); + Assert.Equal(1f, Falloff.Island(0f, 0f), 5); + Assert.Equal(0f, Falloff.Island(1f, 1f), 5); + } +}