252 lines
14 KiB
Python
252 lines
14 KiB
Python
"""Geological passes over a heightmap in metres, numpy only: particle hydraulic erosion, thermal weathering with
|
|
an angle of repose, strata hardness, and the derivative maps (flow, wear, deposition) they leave behind.
|
|
Fractal noise gives pillowy hills; these passes give drainage, V-valleys, alluvial fans, scree aprons and rock
|
|
shelves. Applied by generate_heightmap.py to whatever the manifest's source produced, noise or file.
|
|
|
|
Units inside: heights are in cell widths (metres over the cell size), so a slope of 1.0 is 45 degrees and the
|
|
droplet constants mean the same thing at any resolution. The hydraulic pass runs twice: on a downsampled map
|
|
(coarse cells, long droplet lives) for the valleys, then at full resolution (short lives) for the gullies;
|
|
the coarse result is applied to the full map as a delta, so the fine detail underneath survives.
|
|
|
|
Droplets are simulated in vectorised batches: a batch of tens of thousands takes one step together, reading
|
|
the map as it was at the start of the step and scattering its erosion and deposits back with np.add.at. Two
|
|
droplets in the same cell in the same step do not see each other; at these densities that is invisible.
|
|
"""
|
|
import time
|
|
|
|
import numpy as np
|
|
|
|
import heightmap_io
|
|
import heightmap_noise
|
|
|
|
DEFAULTS = {
|
|
"enabled": True,
|
|
"coarse_factor": 4, # the coarse pass runs on the map downsampled by this
|
|
"coarse_droplets": 800000,
|
|
"coarse_lifetime": 120, # steps, one cell each: 120 coarse cells is 1.7 km of path at 4x on 3.5 m quads
|
|
"fine_droplets": 3000000,
|
|
"fine_lifetime": 40,
|
|
"thermal_passes": 24,
|
|
"talus_deg": 35.0, # angle of repose
|
|
"inertia": 0.1,
|
|
"capacity": 2.0, # sediment a droplet can carry, in cell-heights per unit of slope, speed and water
|
|
"max_load": 2.0, # cell-heights: the most one droplet carries, so the mound it can leave where it stops is bounded
|
|
"min_slope": 0.01,
|
|
"deposit_rate": 0.2,
|
|
"erode_rate": 0.2,
|
|
"evaporation": 0.02,
|
|
"gravity": 4.0,
|
|
"strata_period_m": 160.0, # vertical period of the hard and soft bands
|
|
"strata_contrast": 0.6, # 0 is uniform rock, 1 is hard bands that barely erode next to soft ones that melt
|
|
"max_change": 0.2, # cell-heights one droplet may cut or fill in one step; batches of droplets share cells, so this is the brake
|
|
"max_speed": 5.0,
|
|
"min_erode_slope": 0.25, # below this slope (about 14 degrees) water deposits but barely cuts: lowland soil holds, so meadows stay meadows
|
|
"fine_scale": 0.5, # the fine pass cuts at this fraction of the coarse pass's rates: gullies, not trenches, at 3.5 m cells
|
|
"batch": 200000, # droplets stepping together, at most one per 40 cells of the map
|
|
"seed": 11,
|
|
}
|
|
|
|
|
|
class Hardness:
|
|
"""Rock hardness in [0, 1] as a function of position and elevation: horizontal strata with a slow tilt and a
|
|
slow change of rock type across the map. Erosion is scaled by (1 - hardness), so hard bands hold shelves."""
|
|
|
|
def __init__(self, size, rng, period_cells, contrast):
|
|
self.period = max(float(period_cells), 1e-3)
|
|
self.contrast = float(contrast)
|
|
self.tilt = heightmap_noise.fbm(size, rng, base_cells=3, octaves=3, gain=0.5).astype(np.float32)
|
|
self.kind = heightmap_noise.fbm(size, rng, base_cells=2, octaves=3, gain=0.5).astype(np.float32)
|
|
|
|
def at(self, ix, iy, height):
|
|
band = 0.5 + 0.5 * np.sin(2.0 * np.pi * (height / self.period + self.tilt[iy, ix] * 2.0))
|
|
return np.clip(0.5 + self.contrast * (band - 0.5) * (0.4 + 0.8 * self.kind[iy, ix]), 0.05, 0.95).astype(np.float32)
|
|
|
|
|
|
BRUSH = ((0, 0, 0.36), (0, 1, 0.12), (0, -1, 0.12), (1, 0, 0.12), (-1, 0, 0.12),
|
|
(1, 1, 0.04), (1, -1, 0.04), (-1, 1, 0.04), (-1, -1, 0.04)) # offsets (dy, dx) and weights summing to 1
|
|
|
|
|
|
def sample(h, px, py):
|
|
"""Bilinear height and gradient at float positions; the caller keeps px, py inside [0, size - 2]."""
|
|
x0 = px.astype(np.int32)
|
|
y0 = py.astype(np.int32)
|
|
fx = px - x0
|
|
fy = py - y0
|
|
h00 = h[y0, x0]
|
|
h10 = h[y0, x0 + 1]
|
|
h01 = h[y0 + 1, x0]
|
|
h11 = h[y0 + 1, x0 + 1]
|
|
gx = (h10 - h00) * (1 - fy) + (h11 - h01) * fy
|
|
gy = (h01 - h00) * (1 - fx) + (h11 - h10) * fx
|
|
hc = h00 * (1 - fx) * (1 - fy) + h10 * fx * (1 - fy) + h01 * (1 - fx) * fy + h11 * fx * fy
|
|
return hc, gx, gy, x0, y0, fx, fy
|
|
|
|
|
|
def hydraulic(h, rng, droplets, lifetime, cfg, hardness, spawn_mask, maps, sea_cells=-1e9):
|
|
"""Particle erosion in place on h (cell units). maps: flow, wear, deposit arrays of h's shape, accumulated.
|
|
A droplet that reaches water below `sea_cells` drops its whole load there and ends: the sea is a sink,
|
|
and river mouths get their fans."""
|
|
size = h.shape[0]
|
|
ys, xs = np.nonzero(spawn_mask)
|
|
if xs.size == 0:
|
|
return
|
|
inertia, capacity_factor = cfg["inertia"], cfg["capacity"]
|
|
min_slope, deposit_rate, erode_rate = cfg["min_slope"], cfg["deposit_rate"], cfg["erode_rate"]
|
|
evaporation, gravity = cfg["evaporation"], cfg["gravity"]
|
|
max_change, max_speed, max_load = float(cfg["max_change"]), float(cfg["max_speed"]), float(cfg["max_load"])
|
|
min_erode_slope = max(float(cfg["min_erode_slope"]), 1e-6)
|
|
batch = max(min(int(cfg["batch"]), size * size // 40), 1000)
|
|
limit = size - 2.001
|
|
done = 0
|
|
while done < droplets:
|
|
n = min(batch, droplets - done)
|
|
done += n
|
|
pick = rng.integers(0, xs.size, n)
|
|
px = np.clip(xs[pick] + rng.random(n, dtype=np.float32), 1.0, limit).astype(np.float32)
|
|
py = np.clip(ys[pick] + rng.random(n, dtype=np.float32), 1.0, limit).astype(np.float32)
|
|
dx = np.zeros(n, dtype=np.float32)
|
|
dy = np.zeros(n, dtype=np.float32)
|
|
speed = np.ones(n, dtype=np.float32)
|
|
water = np.ones(n, dtype=np.float32)
|
|
sediment = np.zeros(n, dtype=np.float32)
|
|
|
|
for _ in range(lifetime):
|
|
if px.size == 0:
|
|
break
|
|
hc, gx, gy, x0, y0, fx, fy = sample(h, px, py)
|
|
dx = dx * inertia - gx * (1 - inertia)
|
|
dy = dy * inertia - gy * (1 - inertia)
|
|
length = np.hypot(dx, dy)
|
|
moving = length > 1e-9
|
|
safe = np.where(moving, length, 1.0)
|
|
dx = np.where(moving, dx / safe, 0.0).astype(np.float32)
|
|
dy = np.where(moving, dy / safe, 0.0).astype(np.float32)
|
|
nx = px + dx
|
|
ny = py + dy
|
|
inside = moving & (nx >= 1.0) & (nx <= limit) & (ny >= 1.0) & (ny <= limit)
|
|
hn = sample(h, np.clip(nx, 1.0, limit), np.clip(ny, 1.0, limit))[0]
|
|
dh = np.where(inside, hn - hc, 0.0).astype(np.float32)
|
|
|
|
slope = np.maximum(-dh, min_slope)
|
|
capacity = np.minimum(slope * speed * water * capacity_factor, max_load)
|
|
hard = hardness.at(x0, y0, hc) if hardness is not None else 0.0
|
|
holds = np.clip(np.hypot(gx, gy) / min_erode_slope, 0.0, 1.0) ** 2 # flat ground resists cutting
|
|
deposit = np.where(dh > 0.0, np.minimum(dh, sediment),
|
|
np.where(sediment > capacity, (sediment - capacity) * deposit_rate, 0.0))
|
|
erode = np.where((dh <= 0.0) & (sediment <= capacity),
|
|
np.minimum((capacity - sediment) * erode_rate, -dh) * (1.0 - hard) * holds, 0.0)
|
|
into_sea = inside & (hn < sea_cells)
|
|
deposit = np.where(into_sea, sediment, np.minimum(deposit, max_change)).astype(np.float32)
|
|
erode = np.where(into_sea, 0.0, np.minimum(erode, max_change)).astype(np.float32)
|
|
# Cuts go through a 3x3 brush: a one-cell footprint leaves every path as a rill one cell wide, which
|
|
# reads as brush strokes. Deposits land on the droplet's own bilinear cell: spread through the brush,
|
|
# a pit's rim rises faster than its floor, the pit never fills, and every droplet that drains into it
|
|
# adds to the rim until there is a mound.
|
|
for oy, ox, weight in BRUSH:
|
|
np.add.at(h, (y0 + oy, x0 + ox), -erode * weight)
|
|
np.add.at(h, (y0, x0), deposit * (1 - fx) * (1 - fy))
|
|
np.add.at(h, (y0, x0 + 1), deposit * fx * (1 - fy))
|
|
np.add.at(h, (y0 + 1, x0), deposit * (1 - fx) * fy)
|
|
np.add.at(h, (y0 + 1, x0 + 1), deposit * fx * fy)
|
|
np.add.at(maps["flow"], (y0, x0), water)
|
|
np.add.at(maps["wear"], (y0, x0), erode)
|
|
np.add.at(maps["deposit"], (y0, x0), deposit)
|
|
|
|
sediment = sediment + erode - deposit
|
|
speed = np.minimum(np.sqrt(np.maximum(0.0, speed * speed - dh * gravity)), max_speed).astype(np.float32) # downhill is faster
|
|
water = water * (1.0 - evaporation)
|
|
alive = inside & ~into_sea & (water > 0.001)
|
|
px, py, dx, dy = nx[alive], ny[alive], dx[alive], dy[alive]
|
|
speed, water, sediment = speed[alive], water[alive], sediment[alive]
|
|
|
|
|
|
DIRECTIONS = ((0, 1, 1.0), (0, -1, 1.0), (1, 0, 1.0), (-1, 0, 1.0),
|
|
(1, 1, np.sqrt(2.0)), (1, -1, np.sqrt(2.0)), (-1, 1, np.sqrt(2.0)), (-1, -1, np.sqrt(2.0)))
|
|
|
|
|
|
def thermal(h, passes, talus):
|
|
"""Mass-conserving thermal weathering: where a cell stands above a neighbour by more than the angle of
|
|
repose allows, half the excess slides down, shared among the lower neighbours. Cliffs keep a face, and
|
|
scree builds at their feet. h in cell units, talus is tan(angle of repose)."""
|
|
size = h.shape[0]
|
|
|
|
def neighbour(padded, dy, dx):
|
|
return padded[1 + dy:1 + dy + size, 1 + dx:1 + dx + size]
|
|
|
|
for _ in range(passes):
|
|
start = h.copy()
|
|
padded = np.pad(start, 1, mode="edge")
|
|
worst = np.zeros(h.shape, dtype=np.float32)
|
|
total = np.zeros(h.shape, dtype=np.float32)
|
|
for dy, dx, dist in DIRECTIONS:
|
|
excess = np.maximum(start - neighbour(padded, dy, dx) - talus * dist, 0.0)
|
|
worst = np.maximum(worst, excess)
|
|
total += excess
|
|
# A cell sheds half of its largest excess per pass, split among its lower neighbours in proportion to
|
|
# how far each is below the angle of repose. Never more than half, so slopes settle without inverting.
|
|
scale = np.where(total > 0.0, 0.5 * worst / np.maximum(total, 1e-9), 0.0).astype(np.float32)
|
|
for dy, dx, dist in DIRECTIONS:
|
|
move = np.maximum(start - neighbour(padded, dy, dx) - talus * dist, 0.0) * scale
|
|
h -= move
|
|
h[max(dy, 0):size + min(dy, 0), max(dx, 0):size + min(dx, 0)] += move[max(-dy, 0):size + min(-dy, 0), max(-dx, 0):size + min(-dx, 0)]
|
|
return h
|
|
|
|
|
|
def erode(metres, quad_m, sea_level_m, settings, log=print):
|
|
"""The whole sequence on a map in metres. Returns (metres, maps) where maps holds flow, wear and deposit at
|
|
the map's resolution, in cell-height units accumulated over both passes."""
|
|
cfg = {**DEFAULTS, **(settings or {})}
|
|
size = metres.shape[0]
|
|
maps = {name: np.zeros((size, size), dtype=np.float32) for name in ("flow", "wear", "deposit")}
|
|
if not cfg["enabled"]:
|
|
return metres, maps
|
|
rng = np.random.default_rng(int(cfg["seed"]))
|
|
talus = float(np.tan(np.radians(cfg["talus_deg"])))
|
|
started = time.time()
|
|
|
|
factor = int(cfg["coarse_factor"])
|
|
if factor > 1 and cfg["coarse_droplets"] > 0:
|
|
coarse = heightmap_io.block_mean(metres, factor)
|
|
cell_m = quad_m * factor
|
|
hc = (coarse / cell_m).astype(np.float32)
|
|
hardness = Hardness(hc.shape[0], rng, cfg["strata_period_m"] / cell_m, cfg["strata_contrast"])
|
|
coarse_maps = {name: np.zeros(hc.shape, dtype=np.float32) for name in maps}
|
|
thermal(hc, max(cfg["thermal_passes"] // 4, 1), talus)
|
|
hydraulic(hc, rng, int(cfg["coarse_droplets"]), int(cfg["coarse_lifetime"]), cfg, hardness, coarse > sea_level_m + 2.0, coarse_maps,
|
|
sea_cells=sea_level_m / cell_m)
|
|
thermal(hc, max(cfg["thermal_passes"] // 2, 1), talus)
|
|
delta = hc * cell_m - coarse
|
|
metres = (metres + heightmap_io.resample(delta, size)).astype(np.float32)
|
|
for name in maps:
|
|
maps[name] += heightmap_io.resample(coarse_maps[name], size) * factor
|
|
log(f" coarse erosion at {hc.shape[0]}x{hc.shape[0]}: {cfg['coarse_droplets']} droplets, "
|
|
f"largest cut {-delta.min():.0f} m, largest fill {delta.max():.0f} m, {time.time() - started:.0f} s")
|
|
|
|
hf = (metres / quad_m).astype(np.float32)
|
|
before = hf.copy()
|
|
hardness = Hardness(size, rng, cfg["strata_period_m"] / quad_m, cfg["strata_contrast"])
|
|
fine_cfg = {**cfg, "erode_rate": cfg["erode_rate"] * cfg["fine_scale"], "max_change": cfg["max_change"] * cfg["fine_scale"]}
|
|
hydraulic(hf, rng, int(cfg["fine_droplets"]), int(cfg["fine_lifetime"]), fine_cfg, hardness, metres > sea_level_m + 2.0, maps,
|
|
sea_cells=sea_level_m / quad_m)
|
|
thermal(hf, int(cfg["thermal_passes"]), talus)
|
|
delta = (hf - before) * quad_m
|
|
log(f" fine erosion at {size}x{size}: {cfg['fine_droplets']} droplets, {cfg['thermal_passes']} thermal passes at "
|
|
f"{cfg['talus_deg']:g} deg, largest cut {-delta.min():.0f} m, largest fill {delta.max():.0f} m, {time.time() - started:.0f} s total")
|
|
return (hf * quad_m).astype(np.float32), maps
|
|
|
|
|
|
def curvature(metres, quad_m):
|
|
"""Laplacian of the lightly blurred height, in metres per cell squared: positive on ridges and convex
|
|
shoulders, negative in gullies and sediment traps."""
|
|
h = heightmap_noise.box_blur(metres, 2)
|
|
padded = np.pad(h, 1, mode="edge")
|
|
lap = (padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:] - 4.0 * h)
|
|
return (lap / quad_m).astype(np.float32)
|
|
|
|
|
|
def to_unit(values, percentile=99.0, log_scale=False):
|
|
"""A map squashed into [0, 1] for painting and for an 8-bit PNG."""
|
|
v = np.log1p(np.maximum(values, 0.0)) if log_scale else np.maximum(values, 0.0)
|
|
top = float(np.percentile(v, percentile))
|
|
return np.clip(v / max(top, 1e-6), 0.0, 1.0).astype(np.float32)
|