Add MrGameEng.WorldGen: deterministic procedural world generation
CI / build-test (push) Successful in 1m14s

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 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-12 09:23:45 +03:00
co-authored by Claude Opus 4.8
parent ef1111bcb6
commit 53659450a6
6 changed files with 453 additions and 0 deletions
@@ -0,0 +1,32 @@
namespace MrGameEng.WorldGen;
/// <summary>
/// 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.
/// </summary>
public static class Falloff
{
/// <summary>
/// Island mask for normalized coordinates (<paramref name="nx"/>, <paramref name="ny"/>) in
/// <c>[-1, 1]</c> where (0,0) is the map centre. Returns 1 at the centre and falls toward 0 at
/// the edges; <paramref name="power"/> sets the coastline steepness (higher = sharper).
/// </summary>
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);
}
/// <summary>
/// Island mask for a grid cell: maps (<paramref name="x"/>, <paramref name="y"/>) on a
/// <paramref name="width"/>×<paramref name="height"/> grid into <c>[-1, 1]</c> and evaluates
/// <see cref="Island(float, float, float)"/>.
/// </summary>
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);
}
}
@@ -0,0 +1,58 @@
namespace MrGameEng.WorldGen;
/// <summary>
/// Fractal Brownian motion over a <see cref="PerlinNoise"/> source: sums several octaves of
/// noise, each at a higher frequency (<see cref="Lacunarity"/>) and lower amplitude
/// (<see cref="Persistence"/>), to build natural detail at multiple scales. The sum is
/// normalized back into <c>[-1, 1]</c> regardless of octave count. Immutable and deterministic.
/// </summary>
public sealed class FractalNoise
{
private readonly PerlinNoise _noise;
/// <summary>Number of noise layers summed (≥1). More octaves add finer detail.</summary>
public int Octaves { get; }
/// <summary>Base frequency applied to the first octave's sample coordinates.</summary>
public float Frequency { get; }
/// <summary>Amplitude multiplier between octaves (0..1, typically 0.5): lower is smoother.</summary>
public float Persistence { get; }
/// <summary>Frequency multiplier between octaves (typically 2): higher adds detail faster.</summary>
public float Lacunarity { get; }
/// <summary>Builds an fBm sampler over a Perlin field seeded by <paramref name="seed"/>.</summary>
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;
}
/// <summary>Samples the summed octaves at (<paramref name="x"/>, <paramref name="y"/>); result is in <c>[-1, 1]</c>.</summary>
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;
}
}
@@ -0,0 +1,64 @@
namespace MrGameEng.WorldGen;
/// <summary>
/// A dense row-major grid of height values. Plain data with indexed access — fill it from a
/// <see cref="HeightmapGenerator"/> and read it from worldgen code. Values are arbitrary until
/// <see cref="Normalize"/> rescales them into <c>[0, 1]</c>.
/// </summary>
public sealed class Heightmap
{
/// <summary>Grid width in cells.</summary>
public int Width { get; }
/// <summary>Grid height in cells.</summary>
public int Height { get; }
/// <summary>Row-major values, length <see cref="Width"/>×<see cref="Height"/>.</summary>
public float[] Values { get; }
/// <summary>Creates an all-zero map of the given size.</summary>
public Heightmap(int width, int height)
{
Width = width;
Height = height;
Values = new float[width * height];
}
/// <summary>Height at (<paramref name="x"/>, <paramref name="y"/>).</summary>
public float this[int x, int y]
{
get => Values[y * Width + x];
set => Values[y * Width + x] = value;
}
/// <summary>Rescales all values into <c>[0, 1]</c> by minmax; a flat map becomes all zeros.</summary>
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;
}
}
}
@@ -0,0 +1,97 @@
namespace MrGameEng.WorldGen;
/// <summary>
/// Parameters for <see cref="HeightmapGenerator"/>. Defaults give a single continent with a soft
/// coastline; raise <see cref="Octaves"/>/<see cref="Frequency"/> for finer detail and
/// <see cref="IslandStrength"/> for how strongly the edges are pushed underwater.
/// </summary>
public readonly record struct HeightmapSettings
{
/// <summary>Integer seed — the whole map derives from it.</summary>
public int Seed { get; init; }
/// <summary>Number of feature cycles across the map at the base octave (higher = smaller features).</summary>
public float Frequency { get; init; }
/// <summary>Number of fBm octaves (≥1).</summary>
public int Octaves { get; init; }
/// <summary>fBm amplitude falloff per octave (0..1).</summary>
public float Persistence { get; init; }
/// <summary>fBm frequency growth per octave (typically 2).</summary>
public float Lacunarity { get; init; }
/// <summary>Island falloff blend in <c>[0, 1]</c>: 0 disables it, 1 applies it fully.</summary>
public float IslandStrength { get; init; }
/// <summary>Coastline steepness of the island mask (higher = sharper); see <see cref="Falloff.Island"/>.</summary>
public float IslandPower { get; init; }
/// <summary>Sensible defaults: one continent, 5 octaves, soft coastline.</summary>
public static HeightmapSettings Default =>
new()
{
Seed = 0,
Frequency = 3f,
Octaves = 5,
Persistence = 0.5f,
Lacunarity = 2f,
IslandStrength = 0.6f,
IslandPower = 3f,
};
}
/// <summary>
/// Turns <see cref="HeightmapSettings"/> into a normalized <see cref="Heightmap"/>: fractal noise
/// sampled per cell, optionally multiplied by an island falloff, then rescaled into <c>[0, 1]</c>.
/// Fully deterministic — the same settings always produce the same map, so a world can be rebuilt
/// from its seed alone. Owns no world data.
/// </summary>
public sealed class HeightmapGenerator
{
private readonly HeightmapSettings _settings;
private readonly FractalNoise _noise;
/// <summary>Builds a generator for the given <paramref name="settings"/>.</summary>
public HeightmapGenerator(HeightmapSettings settings)
{
_settings = settings;
_noise = new FractalNoise(
settings.Seed,
settings.Octaves,
frequency: 1f,
settings.Persistence,
settings.Lacunarity
);
}
/// <summary>Generates a <paramref name="width"/>×<paramref name="height"/> normalized heightmap.</summary>
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;
}
}
@@ -0,0 +1,78 @@
namespace MrGameEng.WorldGen;
/// <summary>
/// Deterministic 2D Perlin (gradient) noise. Construct once per seed, then sample any number of
/// points: <see cref="Sample"/> is allocation-free and returns smooth values in <c>[-1, 1]</c>
/// 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. <see cref="Sample"/> is a pure read and safe to call concurrently.
/// </summary>
public sealed class PerlinNoise
{
private readonly int[] _perm = new int[512];
/// <summary>Builds the noise field deterministically from <paramref name="seed"/>.</summary>
public PerlinNoise(int seed)
{
var p = new int[256];
for (var i = 0; i < 256; i++)
{
p[i] = i;
}
// Deterministic FisherYates 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];
}
}
/// <summary>Samples the noise at (<paramref name="x"/>, <paramref name="y"/>); result is in <c>[-1, 1]</c>.</summary>
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,
};
}