Add MrGameEng.WorldGen: deterministic procedural world generation
CI / build-test (push) Successful in 1m14s
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:
co-authored by
Claude Opus 4.8
parent
ef1111bcb6
commit
53659450a6
@@ -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 min–max; 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 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];
|
||||
}
|
||||
}
|
||||
|
||||
/// <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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user