Files

168 lines
8.4 KiB
Python

"""The noise heightmap source: a seeded continent in metres, and the small numpy toolkit (value noise, fBm,
domain warping, cellular crest lines, blur) that generate_heightmap.py also uses to derive the paint layers.
The shape, in metres: a continent with ragged coasts and sea around it, meadow lowlands, rolling hills, and
mountain ranges that run as long warped chains over about two fifths of the land, foothills included, with
ridged crests up to the manifest's ceiling. This is the uplift only; heightmap_erosion.py weathers and carves
it afterwards. Rocky Meadows is the look: meadow between the ranges, rock on them. This is the placeholder
until a real heightmap replaces it in the manifest; nothing downstream can tell the difference.
"""
import numpy as np
def smoothstep(t):
return t * t * (3.0 - 2.0 * t)
def value_noise(size, cells, rng):
"""One octave on the regular grid: a random lattice of cells x cells, smoothly interpolated to size x size."""
lattice = rng.random((cells + 1, cells + 1), dtype=np.float32)
coords = np.linspace(0.0, cells, size, endpoint=False, dtype=np.float32)
i = np.floor(coords).astype(np.int32)
t = smoothstep(coords - i)
i1 = np.minimum(i + 1, cells)
top = lattice[i[:, None], i[None, :]] * (1 - t[None, :]) + lattice[i[:, None], i1[None, :]] * t[None, :]
bottom = lattice[i1[:, None], i[None, :]] * (1 - t[None, :]) + lattice[i1[:, None], i1[None, :]] * t[None, :]
return top * (1 - t[:, None]) + bottom * t[:, None]
def sample_lattice(lattice, u, v):
"""One octave at arbitrary coordinates: smooth interpolation of a periodic lattice at (u, v) in cell units,
any float arrays of one shape. Periodic, so warped or stretched coordinates never run off the edge."""
cells = lattice.shape[0]
i0 = np.floor(u).astype(np.int32)
j0 = np.floor(v).astype(np.int32)
tu = smoothstep(u - i0)
tv = smoothstep(v - j0)
i0 %= cells
j0 %= cells
i1 = (i0 + 1) % cells
j1 = (j0 + 1) % cells
top = lattice[j0, i0] * (1 - tu) + lattice[j0, i1] * tu
bottom = lattice[j1, i0] * (1 - tu) + lattice[j1, i1] * tu
return top * (1 - tv) + bottom * tv
def fbm(size, rng, base_cells=4, octaves=8, gain=0.5, ridged=False):
"""Fractional Brownian motion in [0, 1] on the regular grid: octaves of value noise, each twice as fine
and `gain` as strong."""
total = np.zeros((size, size), dtype=np.float32)
amplitude, cells, norm = 1.0, base_cells, 0.0
for _ in range(octaves):
n = value_noise(size, cells, rng)
if ridged:
n = 1.0 - np.abs(n * 2.0 - 1.0)
n = n * n
total += n * amplitude
norm += amplitude
amplitude *= gain
cells *= 2
return total / norm
def fbm_at(u, v, rng, base_cells=4, octaves=8, gain=0.5, ridged=False):
"""fBm sampled at map coordinates (u, v), where 0..1 spans the map once; anything outside wraps. Feed it
warped or anisotropic coordinates and the noise bends and stretches with them."""
total = np.zeros(u.shape, dtype=np.float32)
amplitude, cells, norm = 1.0, base_cells, 0.0
for _ in range(octaves):
lattice = rng.random((cells, cells), dtype=np.float32)
n = sample_lattice(lattice, u * cells, v * cells)
if ridged:
n = 1.0 - np.abs(n * 2.0 - 1.0)
n = n * n
total += n * amplitude
norm += amplitude
amplitude *= gain
cells *= 2
return total / norm
def normalised(a):
return (a - a.min()) / max(float(a.max() - a.min()), 1e-6)
def cellular_edges(u, v, rng, cells=12, jitter=0.9):
"""Worley cellular noise, F2 - F1 through periodic jittered feature points, mapped so the borders between
cells read 1 and the interiors 0: a network of thin, branching crest lines. Sampled at map coordinates
like fbm_at, so warped coordinates bend the network."""
points = rng.random((cells, cells, 2), dtype=np.float32) * jitter + (1.0 - jitter) * 0.5
su = u * cells
sv = v * cells
i0 = np.floor(su).astype(np.int32)
j0 = np.floor(sv).astype(np.int32)
fu = (su - i0).astype(np.float32)
fv = (sv - j0).astype(np.float32)
f1 = np.full(u.shape, np.inf, dtype=np.float32)
f2 = f1.copy()
for dj in (-1, 0, 1):
for di in (-1, 0, 1):
ci = (i0 + di) % cells
cj = (j0 + dj) % cells
d = np.hypot(points[cj, ci, 0] + di - fu, points[cj, ci, 1] + dj - fv)
closer = d < f1
f2 = np.where(closer, f1, np.minimum(f2, d))
f1 = np.where(closer, d, f1)
edge = 1.0 - np.clip((f2 - f1) / 0.6, 0.0, 1.0)
return (edge * edge).astype(np.float32)
def box_blur(h, passes):
for _ in range(passes):
padded = np.pad(h, 1, mode="edge")
h = (padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:] + h) / 5.0
return h.astype(np.float32)
def generate_metres(size, seed, quad_m, sea_level_m=0.0, land_height_m=2560.0):
"""A size x size continent in metres above `sea_level_m`, before erosion: the uplift. The crests approach
`land_height_m` above the sea; the sea floor lies 30 to 180 m below it, shaped so the shore is not a step.
On steepness: each octave of noise contributes a slope of about amplitude over wavelength, so with gain 0.5
every octave is as steep as the last and eight of them stack into cliffs everywhere. The gains here keep
the meadows gentle; the ranges are meant to be steep and the erosion pass gives them their faces.
Measure the result with a slope histogram before tuning by eye.
"""
rng = np.random.default_rng(seed)
y, x = np.mgrid[0:size, 0:size].astype(np.float32) / (size - 1)
# Continent: a radial falloff with a ragged, noise-warped edge, so the coast is not a circle.
cx, cy = 0.5 + (rng.random() - 0.5) * 0.15, 0.5 + (rng.random() - 0.5) * 0.15
radius = np.sqrt(((x - cx) * 1.05) ** 2 + ((y - cy) * 0.95) ** 2)
coast_warp = (fbm(size, rng, base_cells=3, octaves=5, gain=0.45) - 0.5) * 0.35
continent = np.clip(1.0 - (radius + coast_warp) / 0.55, 0.0, 1.0)
continent = smoothstep(np.clip(continent * 1.6, 0.0, 1.0))
# A low-frequency warp field bends everything that follows, so ridges curve and ranges are not blobs.
warp_x = (fbm(size, rng, base_cells=3, octaves=3, gain=0.5) - 0.5) * 0.16
warp_y = (fbm(size, rng, base_cells=3, octaves=3, gain=0.5) - 0.5) * 0.16
plains = fbm(size, rng, base_cells=6, octaves=4, gain=0.45) * 0.05
hills = fbm_at(x + warp_x * 0.5, y + warp_y * 0.5, rng, base_cells=5, octaves=5, gain=0.45) * 0.18
# Ranges. An elongated, warped band says where they run: stretched across its grain so they come as long
# chains, thresholded by percentile so they and their foothills cover about two fifths of the map whatever
# the seed. Ridged noise through the same warp gives them their crests; the gamma keeps the flanks massive.
angle = float(rng.uniform(0.0, np.pi))
along = (x - 0.5) * np.cos(angle) + (y - 0.5) * np.sin(angle)
across = -(x - 0.5) * np.sin(angle) + (y - 0.5) * np.cos(angle)
band = fbm_at(0.5 + along * 0.7 + warp_x, 0.5 + across * 2.2 + warp_y, rng, base_cells=3, octaves=3, gain=0.5)
band_lo, band_hi = np.percentile(band, [58.0, 86.0])
range_mask = smoothstep(np.clip((band - band_lo) / max(band_hi - band_lo, 1e-6), 0.0, 1.0))
ridges = normalised(fbm_at(x + warp_x, y + warp_y, rng, base_cells=5, octaves=6, gain=0.42, ridged=True))
# Cellular edges through a stronger warp: a light touch of branching crest lines where cells meet. Kept
# light on purpose: at 0.3 the ranges became a honeycomb of polygon walls with flat floors (2026-09-17).
crests = cellular_edges(x + warp_x * 1.4, y + warp_y * 1.4, rng, cells=14, jitter=0.95)
mountains = np.power(0.88 * ridges + 0.12 * crests, 0.8) * range_mask
# Ground detail at the scale of a few quads, a few metres tall: texture, not terrain.
detail = (fbm(size, rng, base_cells=200, octaves=3, gain=0.5) - 0.5) * 2.0 * 4.0
land = 0.04 + plains + hills * (0.4 + 0.6 * continent) + mountains * 1.0
height = continent * land * land_height_m + detail * continent
sea_floor = (-0.03 - (1.0 - continent) * 0.04 + plains * 0.3) * land_height_m
height = np.where(continent > 0.02, height, sea_floor).astype(np.float32)
height = np.maximum(height, sea_floor.astype(np.float32))
# Weathering and erosion are heightmap_erosion.py's job; this is the raw uplift.
return (height + sea_level_m).astype(np.float32)