Added: Initial world generation tool
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user