Compare commits
1
Commits
ef1111bcb6
...
53659450a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user