Added: Initial world generation tool

This commit is contained in:
Rainer Leit
2026-09-17 17:55:48 +03:00
parent d64748f76f
commit cc43ed8dc8
2065 changed files with 23664 additions and 1011 deletions
@@ -0,0 +1,79 @@
package fluvial
// A monotone bucket priority queue, which is what priority-flood actually needs.
//
// The flood pops cells in non-decreasing elevation and never pushes anything below the cell it just popped:
// a neighbour lower than the current front is raised to it and goes to the FIFO instead. That "monotone"
// property is exactly the condition under which a bucket queue beats a binary heap, because the read cursor
// only ever moves forward and both operations become an append and a scan. The heap was costing about
// log2(3.2M) = 22 comparisons and as many cache misses per operation, on two thirds of the solve's runtime.
//
// Elevations are quantised into fixed-width buckets. Cells inside one bucket pop in an arbitrary but
// deterministic order (last in, first out), so a spill point can be wrong by at most one bucket width. At a
// centimetre against a 2 km elevation range that is far below the millimetre-per-cell epsilon the flood adds
// anyway, and it is the same approximation an integer-elevation priority-flood makes by construction.
type bucketPQ struct {
lo float64
width float64
buckets [][]int32
cur int
count int
}
const bucketWidthM = 0.01
func newBucketPQ(loM, hiM float64) *bucketPQ {
if hiM <= loM {
hiM = loM + 1
}
// Headroom above the top: the flood raises cells by epsilon as it fills, so the highest key pushed can
// sit slightly above the terrain's own maximum.
n := int((hiM-loM)/bucketWidthM) + 64
return &bucketPQ{lo: loM, width: bucketWidthM, buckets: make([][]int32, n)}
}
func (q *bucketPQ) reset() {
for i := range q.buckets {
q.buckets[i] = q.buckets[i][:0]
}
q.cur = 0
q.count = 0
}
func (q *bucketPQ) len() int { return q.count }
func (q *bucketPQ) push(elev float32, idx int32) {
b := int((float64(elev) - q.lo) / q.width)
if b < q.cur {
b = q.cur // monotone: never behind the cursor, whatever rounding says
}
if b >= len(q.buckets) {
b = len(q.buckets) - 1
}
q.buckets[b] = append(q.buckets[b], idx)
q.count++
}
// pop returns the lowest cell. The cursor only moves forward, so the total scan cost over a whole flood is
// the number of buckets, not the number of pops.
func (q *bucketPQ) pop() int32 {
for q.cur < len(q.buckets) && len(q.buckets[q.cur]) == 0 {
q.cur++
}
if q.cur >= len(q.buckets) {
return -1
}
b := q.buckets[q.cur]
v := b[len(b)-1]
q.buckets[q.cur] = b[:len(b)-1]
q.count--
return v
}
// frontElev is the elevation the cursor is at, which the FIFO compares itself against.
func (q *bucketPQ) frontElev() float32 {
for q.cur < len(q.buckets) && len(q.buckets[q.cur]) == 0 {
q.cur++
}
return float32(q.lo + float64(q.cur)*q.width)
}
+512
View File
@@ -0,0 +1,512 @@
// Package fluvial is the stream-power erosion solve: dh/dt = U - K * A^m * S^n, integrated implicitly up the
// drainage stack by the method of Braun & Willett (2013).
//
// This is the reason the generator exists (D-47). Particle erosion carves the path each droplet happens to
// take: it makes wear, but never a network. Stream power solves for drainage area first and erodes in
// proportion to it, which is what produces a branching hierarchy, valleys whose size matches the area they
// drain, and divides that sit where the basins either side of them put them.
//
// Four things happen per step, in this order:
//
// 1. Depressions are filled (priority-flood), because a D8 receiver graph containing a pit has no path to
// base level and the implicit solve has nothing to descend to. This is the only part of a step that is
// not O(n), so it runs every FillEvery steps, not every step.
// 2. Receivers and the stack are computed: steepest descent to one of eight neighbours, then a depth-first
// ordering in which every node appears after its receiver.
// 3. Drainage area is accumulated down the stack in reverse.
// 4. The implicit update runs up the stack, so each node's receiver already holds its new height. This is
// what makes the scheme unconditionally stable in dt, and it is why a naive explicit solver is not an
// acceptable substitute at dt = 1500 yr.
package fluvial
import (
"math"
"salty/terrain/internal/field"
"salty/terrain/internal/thermal"
)
// Params are the stream-power constants. K is per year with A in m².
type Params struct {
K float64
M float64
N float64
DtYr float64
Steps int
Diffusion float64 // hillslope diffusivity, m²/yr
FillEvery int
// Landsliding. Stream power bounds nothing at small drainage area, so without this the hillslopes grow
// as steep as the uplift rate asks them to and the map becomes needles. TalusSlope is rise over run; 0
// disables. It runs inside the loop rather than after it, because a cap applied once at the end just
// shaves the tops off, while a cap applied throughout changes where the sediment goes.
TalusSlope float64
ThermalEvery int
ThermalPasses int
// CriticalAreaM2 is where channels begin. Below it the cell is a hillslope: it still rises, and
// diffusion and landsliding still shape it, but stream power does not incise it.
//
// This is not a tuning knob, it is a correctness fix. Stream power is a law about channels, and applying
// it at A = one cell says a cell that drains only itself should stand at U/(K*cellM^(2m)) — 63 degrees at
// the rates here. That is why the map came out as needles at a 39 degree median. It is also why relief
// was resolution-dependent (1020 m at 512², 2605 m at 1786² on one seed): halving the cell size halves
// the smallest A and steepens every divide, for ever. A critical area is a physical length, so the same
// landscape comes out at any resolution, which is the property the full-resolution run depends on.
CriticalAreaM2 float64
// ChannelTaper is the exponent on (A/Ac) below the channel head. 0 is no taper, 2 is strong.
ChannelTaper float64
// CriticalSlope is Sc in the nonlinear hillslope law, rise over run. Above zero it selects
// DiffuseNonlinear over plain linear diffusion and takes the repose clamp out of the step loop; see
// hillslope.go and Run. Zero keeps the old pairing of linear diffusion and an in-loop clamp.
CriticalSlope float64
SlopeCap float64 // where the flux stops stiffening, as a fraction of Sc
MaxHillslopeSub int // the sub-step budget that bound buys
}
// Grid holds the flow topology and the scratch it is built from. Allocated once and reused across every
// step: at 3.2 M cells the allocations would otherwise dominate the solve.
type Grid struct {
W, H int
CellM float64
// Base marks cells fixed at base level: the ocean. The map border is an outlet too, so what actually
// counts as "fixed" is Base plus the border, and that union is `fixed`. Keeping only Base in mind here is
// how the outlets themselves ended up being uplifted: on a map with no ocean, every border cell has
// Receiver == itself and Base == false, so base level rose two metres a step and the whole solve chased
// it. The steady-state test is what caught it.
Base []bool
fixed []bool
Receiver []int32 // index of the cell this one drains to; itself for a base cell
Length []float32 // distance to that receiver, metres
Stack []int32 // every node after its receiver
Area []float32 // drainage area, m²
seed uint64 // the jitter's seed; see jitter.go and SetSeed
donorOff []int32
donorList []int32
cursor []int32
closed []bool
pq *bucketPQ
fifo []int32
scratch []float32
}
// SetElevationRange sizes the flood's bucket queue. Called once, with the manifest's elevation range plus a
// margin, before the first step.
func (g *Grid) SetElevationRange(loM, hiM float64) {
g.pq = newBucketPQ(loM, hiM)
}
var (
// D8, in the order (-1,-1) .. (1,1) skipping the centre. The order is fixed so that a run is reproducible,
// but it is no longer what decides a tie between two equally steep neighbours: a fixed order resolves
// every tie the same way and prints its preferred axis across any near-flat ground. See jitter.go.
dx8 = [8]int{-1, 0, 1, -1, 1, -1, 0, 1}
dy8 = [8]int{-1, -1, -1, 0, 0, 1, 1, 1}
)
func NewGrid(w, h int, cellM float64, base []bool) *Grid {
n := w * h
g := &Grid{
W: w, H: h, CellM: cellM, Base: base,
Receiver: make([]int32, n), Length: make([]float32, n), Stack: make([]int32, 0, n),
Area: make([]float32, n), donorOff: make([]int32, n+1), donorList: make([]int32, n),
closed: make([]bool, n), fifo: make([]int32, 0, n), scratch: make([]float32, n),
}
g.fixed = make([]bool, n)
for i := range g.fixed {
g.fixed[i] = g.isOutlet(i)
}
g.SetElevationRange(-2000, 4000)
return g
}
// FillDepressions raises closed depressions to their spill point, in place, using Barnes' improved
// priority-flood with a plain FIFO beside the heap. The FIFO is the optimisation that matters: on real
// terrain most cells are reached while descending into an already-flooded pit, and those never touch the
// heap, which turns the cost from "half an hour over a run" into something affordable.
//
// The epsilon variant adds a millimetre of fall per cell across a flat, so filled lakes still route rather
// than becoming a plateau the flow accumulator cannot leave. That millimetre is scattered per cell by a hash
// of the index rather than applied uniformly: a uniform epsilon means the only gradient on a flat is the
// flood's own traversal order, and the router then draws that order as rivers. See jitter.go.
func (g *Grid) FillDepressions(h []float32, epsilon float32) {
n := g.W * g.H
for i := range g.closed {
g.closed[i] = false
}
g.pq.reset()
g.fifo = g.fifo[:0]
for i := 0; i < n; i++ {
if g.isOutlet(i) {
g.closed[i] = true
g.pq.push(h[i], int32(i))
}
}
head := 0
for g.pq.len() > 0 || head < len(g.fifo) {
var c int32
var celev float32
// Drain the FIFO while it cannot violate the queue's ordering.
if head < len(g.fifo) && (g.pq.len() == 0 || h[g.fifo[head]] <= g.pq.frontElev()) {
c = g.fifo[head]
head++
celev = h[c]
} else {
c = g.pq.pop()
if c < 0 {
break
}
celev = h[c]
}
cx := int(c) % g.W
cy := int(c) / g.W
for k := 0; k < 8; k++ {
nx, ny := cx+dx8[k], cy+dy8[k]
if nx < 0 || ny < 0 || nx >= g.W || ny >= g.H {
continue
}
ni := int32(ny*g.W + nx)
if g.closed[ni] {
continue
}
g.closed[ni] = true
if h[ni] <= celev {
h[ni] = celev + epsilon*(0.5+hash01(g.seed, ni))
g.fifo = append(g.fifo, ni)
} else {
g.pq.push(h[ni], ni)
}
}
// Compact the FIFO occasionally so it does not grow without bound over a whole flood.
if head > n/2 {
g.fifo = append(g.fifo[:0], g.fifo[head:]...)
head = 0
}
}
}
func (g *Grid) isOutlet(i int) bool {
if g.Base != nil && g.Base[i] {
return true
}
x, y := i%g.W, i/g.W
return x == 0 || y == 0 || x == g.W-1 || y == g.H-1
}
// ComputeReceivers picks the steepest downhill neighbour of each cell. A base cell, and any cell with no
// lower neighbour, receives itself, which makes it a root of the stack.
//
// Two neighbours of equal steepness are separated by a hash of the cell and the direction rather than by the
// fixed order of dx8/dy8. A fixed order always resolves a tie the same way, which on any near-flat surface
// puts a systematic preference on one grid axis and shows up as rivers that run straight along it. The
// perturbation is a tenth of a percent, so it decides near-ties and nothing else: a neighbour that is
// genuinely steeper than another by more than that is still chosen.
func (g *Grid) ComputeReceivers(h []float32) {
diag := float32(g.CellM * math.Sqrt2)
card := float32(g.CellM)
field.Rows(g.H, func(y0, y1 int) {
for y := y0; y < y1; y++ {
for x := 0; x < g.W; x++ {
i := int32(y*g.W + x)
if g.isOutlet(int(i)) {
g.Receiver[i] = i
g.Length[i] = card
continue
}
best := int32(-1)
bestJitter := float32(0)
bestLen := card
for k := 0; k < 8; k++ {
nx, ny := x+dx8[k], y+dy8[k]
if nx < 0 || ny < 0 || nx >= g.W || ny >= g.H {
continue
}
ni := int32(ny*g.W + nx)
l := card
if dx8[k] != 0 && dy8[k] != 0 {
l = diag
}
s := (h[i] - h[ni]) / l
if s <= 0 {
continue
}
// The tie-break, not a change of gradient: the comparison is jittered, the slope that
// is kept is not, so Length and the stream-power update see the true geometry.
sj := s * (1 + 1e-3*(hash01(g.seed, i*8+int32(k))-0.5))
if sj > bestJitter {
bestJitter, best, bestLen = sj, ni, l
}
}
if best < 0 {
g.Receiver[i] = i
g.Length[i] = card
} else {
g.Receiver[i] = best
g.Length[i] = bestLen
}
}
}
})
}
// BuildStack orders every node after its receiver, by counting donors into a CSR list and then walking it
// depth-first from the roots. O(n), no recursion, and the order is fully determined by the receiver array,
// so it does not vary between runs.
func (g *Grid) BuildStack() {
n := g.W * g.H
for i := 0; i <= n; i++ {
g.donorOff[i] = 0
}
for i := 0; i < n; i++ {
r := g.Receiver[i]
if int(r) != i {
g.donorOff[r+1]++
}
}
for i := 0; i < n; i++ {
g.donorOff[i+1] += g.donorOff[i]
}
cursor := g.scratchInt32()
copy(cursor, g.donorOff[:n])
for i := 0; i < n; i++ {
r := g.Receiver[i]
if int(r) != i {
g.donorList[cursor[r]] = int32(i)
cursor[r]++
}
}
g.Stack = g.Stack[:0]
for i := 0; i < n; i++ {
if int(g.Receiver[i]) == i {
g.Stack = append(g.Stack, int32(i))
}
}
// Depth-first: everything already in the stack expands its donors, which land after it.
for read := 0; read < len(g.Stack); read++ {
c := g.Stack[read]
for d := g.donorOff[c]; d < g.donorOff[c+1]; d++ {
g.Stack = append(g.Stack, g.donorList[d])
}
}
}
// scratchInt32 reuses the float32 scratch as int32 storage; same width, and it saves a 12 MB allocation per
// step at the geology grid.
func (g *Grid) scratchInt32() []int32 {
if cap(g.cursor) < g.W*g.H {
g.cursor = make([]int32, g.W*g.H)
}
return g.cursor[:g.W*g.H]
}
// Accumulate sums drainage area down the stack in reverse, so every node has collected its whole upstream
// catchment before its receiver is reached.
func (g *Grid) Accumulate() {
cell := float32(g.CellM * g.CellM)
for i := range g.Area {
g.Area[i] = cell
}
for k := len(g.Stack) - 1; k >= 0; k-- {
i := g.Stack[k]
r := g.Receiver[i]
if r != i {
g.Area[r] += g.Area[i]
}
}
}
// StreamPower is the implicit update, walked up the stack. uplift is in metres per year and k is the local
// erodibility; either may be nil for a uniform value.
func (g *Grid) StreamPower(h []float32, uplift, k []float32, p Params) {
dt := p.DtYr
linear := math.Abs(p.N-1) < 1e-9
for _, i := range g.Stack {
if g.fixed[i] {
continue // base level is fixed: no uplift, no erosion
}
u := 0.0
if uplift != nil {
u = float64(uplift[i])
}
r := g.Receiver[i]
if r == i {
// A local minimum that the last flood has not reached yet. It still rises: skipping uplift here
// freezes exactly the cells that differential uplift is busy pushing up, which quietly removes
// the basins from the landscape between floods.
h[i] += float32(dt * u)
continue
}
kk := p.K
if k != nil {
kk *= float64(k[i])
}
hr := float64(h[r])
hi := float64(h[i]) + dt*u
area := float64(g.Area[i])
if p.CriticalAreaM2 > 0 && area < p.CriticalAreaM2 {
// Below the channel head, incision is tapered rather than switched off. Switching it off
// entirely is what broke: the material had nowhere to go, because the only remaining transport
// was landsliding, which caps slope but not height, and the map grew until 22 % of it clipped
// the elevation range. A taper suppresses the fine dissection that makes lowlands look like
// small mountains while still letting the hillslope shed its uplift into the network.
kk *= math.Pow(area/p.CriticalAreaM2, p.ChannelTaper)
}
a := math.Pow(area, p.M)
l := float64(g.Length[i])
var next float64
if linear {
f := kk * dt * a / l
next = (hi + f*hr) / (1 + f)
} else {
next = newtonStreamPower(hi, hr, kk*dt*a, l, p.N)
}
// A node may never fall below what it drains into; the implicit form only guarantees that while
// uplift has not raised the receiver past it.
if next < hr {
next = hr
}
h[i] = float32(next)
}
}
// newtonStreamPower solves h - hi + c*((h-hr)/l)^n = 0 for n != 1. Five iterations from the linear answer is
// comfortably enough at the exponents anyone actually uses; it is here so the manifest's n is not a lie.
func newtonStreamPower(hi, hr, c, l, n float64) float64 {
f := c / l
h := (hi + f*hr) / (1 + f) // the n = 1 answer, as a starting point
for iter := 0; iter < 5; iter++ {
d := h - hr
if d < 0 {
d = 0
}
s := d / l
fx := h - hi + c*math.Pow(s, n)/1
dfx := 1 + c*n*math.Pow(s, n-1)/l
if dfx == 0 {
break
}
step := fx / dfx
h -= step
if math.Abs(step) < 1e-6 {
break
}
}
if h < hr {
h = hr
}
return h
}
// Diffuse is hillslope diffusion, which rounds the divides and stops every channel head from being a needle.
//
// Explicit five-point diffusion is stable only while D*dt/dx² <= 0.25, and the defaults sit above that
// (0.02 m²/yr at dt 1500 on 8 m cells is 0.47), so it sub-steps rather than quietly going unstable. This is
// the sort of thing that shows up as a checkerboard three thousand steps in.
func (g *Grid) Diffuse(h []float32, d, dt float64) {
if d <= 0 || dt <= 0 {
return
}
dx2 := g.CellM * g.CellM
total := d * dt / dx2
sub := int(math.Ceil(total / 0.2))
if sub < 1 {
sub = 1
}
alpha := float32(total / float64(sub))
src := h
tmp := g.scratch[:len(h)]
for s := 0; s < sub; s++ {
field.Rows(g.H, func(y0, y1 int) {
for y := y0; y < y1; y++ {
for x := 0; x < g.W; x++ {
i := y*g.W + x
if g.fixed[i] {
tmp[i] = src[i]
continue
}
c := src[i]
lap := clampAt(src, g.W, g.H, x-1, y) + clampAt(src, g.W, g.H, x+1, y) +
clampAt(src, g.W, g.H, x, y-1) + clampAt(src, g.W, g.H, x, y+1) - 4*c
tmp[i] = c + alpha*lap
}
}
})
copy(src, tmp)
}
}
func clampAt(a []float32, w, h, x, y int) float32 {
if x < 0 {
x = 0
} else if x >= w {
x = w - 1
}
if y < 0 {
y = 0
} else if y >= h {
y = h - 1
}
return a[y*w+x]
}
// Run is the whole solve. Progress is reported through log, which is what a five-minute budget needs to be
// steerable: a run that is going wrong should say so at step 500, not at the end.
func (g *Grid) Run(h []float32, uplift, k []float32, p Params, log func(step int, total int, elapsedPct float64)) {
fill := p.FillEvery
if fill < 1 {
fill = 1
}
for step := 0; step < p.Steps; step++ {
if step%fill == 0 {
g.FillDepressions(h, 1e-3)
}
g.ComputeReceivers(h)
g.BuildStack()
g.Accumulate()
g.StreamPower(h, uplift, k, p)
if p.CriticalSlope > 0 {
// The clamp still runs, and it still has to: a belt rising at millimetres a year asks for slopes
// no bounded-flux transport law can hold, which is a fact about the forcing and not about the
// scheme. What changes is the order and who gets the last word. The clamp cuts along the eight
// D8 directions and leaves grid-aligned pyramid faces; nonlinear diffusion then runs over the
// result with a symmetric five-point stencil and rounds them off before the next step sees them.
//
// Running the clamp only once at the end was tried and is worse: a thousand steps of unclamped
// growth arrive at it together, so it cuts deeply, and nothing runs afterwards to soften what it
// cut. The facets came back in the summits. Little and often, with diffusion last, is what keeps
// the constraint without printing the stencil.
if p.TalusSlope > 0 && p.ThermalEvery > 0 && step%p.ThermalEvery == 0 {
g.ClampToRepose(h, p.TalusSlope)
}
g.DiffuseNonlinear(h, p.Diffusion, p.CriticalSlope, p.SlopeCap, p.DtYr, p.MaxHillslopeSub)
} else {
g.Diffuse(h, p.Diffusion, p.DtYr)
if p.TalusSlope > 0 && p.ThermalEvery > 0 && step%p.ThermalEvery == 0 {
// The constraint first, which actually binds, then the transport, which puts scree at the
// foot of what the constraint cut.
g.ClampToRepose(h, p.TalusSlope)
if p.ThermalPasses > 0 {
thermal.Apply(h, g.W, g.H, g.CellM, p.TalusSlope, p.ThermalPasses, g.fixed, g.scratch)
}
}
}
if log != nil && p.Steps >= 10 && step%(p.Steps/10) == 0 {
log(step, p.Steps, float64(step)/float64(p.Steps)*100)
}
}
// One last fill so the result has no closed pits to hand to the detail passes, and one last routing so
// Area and Receiver describe the surface that is actually returned.
g.FillDepressions(h, 1e-3)
g.ComputeReceivers(h)
g.BuildStack()
g.Accumulate()
}
+187
View File
@@ -0,0 +1,187 @@
package fluvial
import (
"math"
"salty/terrain/internal/field"
)
// Nonlinear hillslope transport: q = D*S / (1 - (S/Sc)^2), the Roering form.
//
// It replaces the pair of patches that stood in for a hillslope law — linear diffusion, which does not care
// how steep the ground is, and ClampToRepose, which cares about nothing else — with one process that does
// both jobs. As S goes to zero it *is* linear diffusion, q -> D*S, so the divides in the lowlands round over
// exactly as before. As S approaches Sc the flux diverges, so the slope approaches Sc and never reaches it.
//
// The difference that shows is not in the numbers, it is in the shape. ClampToRepose cuts each cell down to
// talus*distance along one of eight neighbour directions and pops cells in grid order within an elevation
// bucket, so what it leaves is pyramids with faces aligned to the grid — the blocky, ruler-cut facets that
// are visible in any preview of a mountain belt here. Nothing about that is geology; it is the D8 stencil
// printed onto the landscape. Nonlinear diffusion approaches the same limiting angle *asymptotically* and
// through a symmetric five-point stencil, so there is no cut, no facet and no preferred direction.
//
// It is also mass-conserving, which the clamp is not: the flux out of one cell is the flux into its
// neighbour by construction, so material shed from a divide arrives at the foot of the slope rather than
// being deleted.
//
// # The cost, and the honest limit
//
// The catch is stiffness. The tangent of the flux law, which is what sets the explicit time-step limit, is
//
// D_eff(S) = D * (1 + u^2) / (1 - u^2)^2, u = S/Sc
//
// and that goes to infinity at u = 1. At the defaults the linear Courant number D*dt/dx^2 is already 0.29,
// so u = 0.9 alone asks for about seventy sub-steps a step and u = 0.95 for nearly three hundred. That is
// not affordable over a thousand steps, so the stiffening is bounded: u is capped at SlopeCap, and if even
// that exceeds MaxSubSteps the cap is lowered further to whatever the budget affords. The sub-step count is
// then derived from the cap that was actually used, so the scheme stays inside its stability limit whatever
// happens — it degrades by transporting less on the steepest ground, never by going unstable.
//
// Ground steeper than the cap therefore relaxes at a finite rate instead of an unbounded one, and on a
// mountain belt rising at 2 mm/yr that is not fast enough on its own. ClampToRepose stays for exactly that,
// as a safety pass rather than as the process that shapes the land: see Run.
func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub int) {
if d <= 0 || dt <= 0 {
return
}
if sc <= 0 {
g.Diffuse(h, d, dt) // no critical slope configured: the linear law, unchanged
return
}
if slopeCap <= 0 || slopeCap >= 1 {
slopeCap = 0.9
}
if maxSub < 1 {
maxSub = 1
}
dx := g.CellM
dx2 := dx * dx
// The steepest ground on the grid bounds D_eff for the whole call. Uplift is not applied in here and
// diffusion only relaxes slopes, so nothing can get steeper part-way through and invalidate the bound.
u := math.Min(g.maxSlopeRatio(h, sc), slopeCap)
f := stiffness(u)
// What the sub-step budget can pay for. Lowering the cap rather than truncating the sub-step count is
// what keeps this stable: a truncated count leaves alpha above 0.25 and the surface checkerboards a few
// hundred steps later, which is precisely the sort of failure that does not announce itself.
if budget := float64(maxSub) * 0.2 * dx2 / (d * dt); f > budget {
f = budget
u = invStiffness(f)
}
if f < 1 {
// The budget cannot buy even the linear law. It is not optional: D*dt/dx^2 alone may need several
// sub-steps and going without them is an unstable scheme, so MaxSubSteps bounds the nonlinear
// *enhancement* and never the stability floor underneath it.
f = 1
u = 0
}
sub := int(math.Ceil(d * f * dt / dx2 / 0.2))
if sub < 1 {
sub = 1
}
dtSub := dt / float64(sub)
coeff := float32(d * dtSub / dx2)
uCap := float32(u)
// The height difference across one cell that *is* Sc. flux works in height differences rather than
// slopes, so the cell spacing has to be folded into the critical value here; leaving it out makes u a
// factor of dx too large, which pins every face against the cap and quietly turns the whole law into
// linear diffusion with a constant multiplier.
dhCrit := float32(sc * dx)
src := h
tmp := g.scratch[:len(h)]
for s := 0; s < sub; s++ {
field.Rows(g.H, func(y0, y1 int) {
for y := y0; y < y1; y++ {
for x := 0; x < g.W; x++ {
i := y*g.W + x
if g.fixed[i] {
tmp[i] = src[i] // base level: held, and whatever arrives here has left the system
continue
}
c := src[i]
// The net inflow over the four faces. Each face is evaluated from both of its cells,
// which costs twice and buys a gather: no two goroutines ever write the same cell.
net := flux(clampAt(src, g.W, g.H, x-1, y)-c, dhCrit, uCap) +
flux(clampAt(src, g.W, g.H, x+1, y)-c, dhCrit, uCap) +
flux(clampAt(src, g.W, g.H, x, y-1)-c, dhCrit, uCap) +
flux(clampAt(src, g.W, g.H, x, y+1)-c, dhCrit, uCap)
tmp[i] = c + coeff*net
}
}
})
copy(src, tmp)
}
}
// flux is q/D for one face, in height differences rather than slopes: one factor of the cell spacing cancels
// against the divergence and is carried in coeff instead. dhCrit is the height difference that corresponds to
// Sc across one cell, so dh/dhCrit is exactly S/Sc. u is capped so the denominator cannot reach zero.
func flux(dh, dhCrit, uCap float32) float32 {
u := dh / dhCrit
if u < 0 {
u = -u
}
if u > uCap {
u = uCap
}
return dh / (1 - u*u)
}
// stiffness is D_eff/D at a given u = S/Sc: the factor by which the nonlinear law shortens the stable step.
func stiffness(u float64) float64 {
q := 1 - u*u
return (1 + u*u) / (q * q)
}
// invStiffness inverts it. Bisection because stiffness is monotone on [0,1) and this runs once per call, so
// there is nothing to gain from being cleverer and something to lose from being wrong.
func invStiffness(f float64) float64 {
if f <= 1 {
return 0
}
lo, hi := 0.0, 0.999999
for i := 0; i < 60; i++ {
mid := (lo + hi) / 2
if stiffness(mid) < f {
lo = mid
} else {
hi = mid
}
}
return lo
}
// maxSlopeRatio is the steepest face on the grid as a fraction of Sc. Cardinal neighbours only, because those
// are the faces the five-point stencil actually transports across.
func maxSlopeRatio(h []float32, w, hgt int, sc, cellM float64) float64 {
var maxDiff float32
for y := 0; y < hgt; y++ {
for x := 0; x < w; x++ {
i := y*w + x
c := h[i]
if x+1 < w {
if dv := abs32(h[i+1] - c); dv > maxDiff {
maxDiff = dv
}
}
if y+1 < hgt {
if dv := abs32(h[i+w] - c); dv > maxDiff {
maxDiff = dv
}
}
}
}
return float64(maxDiff) / cellM / sc
}
func (g *Grid) maxSlopeRatio(h []float32, sc float64) float64 {
return maxSlopeRatio(h, g.W, g.H, sc, g.CellM)
}
func abs32(v float32) float32 {
if v < 0 {
return -v
}
return v
}
@@ -0,0 +1,239 @@
package fluvial
import (
"math"
"testing"
)
// The three properties the nonlinear law is being trusted for. Each one is a thing the pair it replaces got
// wrong, so each is worth a test rather than an assurance.
// TestDiffuseNonlinearConservesMass is the property ClampToRepose does not have: material shed from a divide
// has to arrive somewhere, not be deleted.
//
// The border is always an outlet, so the sum over the whole grid cannot be conserved by construction — base
// level is a sink and is meant to be. The check is therefore over an interior that the disturbance never
// reaches: a bump in the middle of a grid big enough that nothing has diffused to the edge by the time the
// run ends.
func TestDiffuseNonlinearConservesMass(t *testing.T) {
const (
w, h = 101, 101
cellM = 10.0
sc = 0.7
)
base := make([]bool, w*h)
g := NewGrid(w, h, cellM, base)
g.SetElevationRange(-100, 2000)
field := make([]float32, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
d := math.Hypot(float64(x-w/2), float64(y-h/2)) * cellM
field[y*w+x] = float32(math.Max(0, 300-1.5*d)) // a cone well past Sc
}
}
sum := func() float64 {
var s float64
for y := 2; y < h-2; y++ {
for x := 2; x < w-2; x++ {
s += float64(field[y*w+x])
}
}
return s
}
before := sum()
for i := 0; i < 40; i++ {
g.DiffuseNonlinear(field, 0.02, sc, 0.9, 1500, 24)
}
after := sum()
// The cone is 300 m tall and the interior holds millions of cubic metres; a tenth of a percent is a very
// tight bound on forty steps of an explicit scheme in float32.
rel := math.Abs(after-before) / before
t.Logf("interior mass %.1f -> %.1f, relative change %.2e", before, after, rel)
if rel > 1e-3 {
t.Errorf("interior mass changed by %.3f%%; the flux is not antisymmetric", rel*100)
}
}
// TestDiffuseNonlinearLimitsSlope is the self-limiting property, and the only honest way to test it is under
// uplift. Without uplift every diffusion law flattens everything eventually, nonlinear included, so a
// relaxing cone proves nothing. What distinguishes the two laws is where they come to rest against a forcing:
// linear diffusion has no limiting angle at all and lets relief grow to U*L^2/(2D), which at these numbers is
// a kilometre and slopes many times Sc, while the nonlinear law's flux diverges as the slope approaches Sc so
// the landscape settles near it whatever U is. That is the entire reason for the change, so it is the test.
func TestDiffuseNonlinearLimitsSlope(t *testing.T) {
// The forcing is chosen so the question is about the law and not about the sub-step budget. A hillslope of
// half-width L under uplift U comes to rest, under the linear law, at a maximum slope of U*L/D; here that is
// 0.8, twice Sc, so linear diffusion visibly fails to limit. The nonlinear law can hold Sc only while its
// flux at the cap, D*Sc/(1-uCap^2), still exceeds U*L, and at these numbers it does with room to spare — so
// a failure here is the law's, not the budget's. Push U much higher and no bounded-flux law holds Sc; that
// is the regime ClampToRepose exists for, and Run keeps it for exactly that reason.
const (
w, h = 41, 41
cellM = 10.0
sc = 0.4
upliftM = 2e-4 // 0.2 mm/yr
dt = 1000.0
steps = 5000
)
grow := func(nonlinear bool) float64 {
base := make([]bool, w*h)
g := NewGrid(w, h, cellM, base)
g.SetElevationRange(-100, 8000)
f := make([]float32, w*h)
for i := 0; i < steps; i++ {
for j := range f {
if !g.fixed[j] {
f[j] += float32(upliftM * dt)
}
}
if nonlinear {
g.DiffuseNonlinear(f, 0.05, sc, 0.9, dt, 24)
} else {
g.Diffuse(f, 0.05, dt)
}
}
return maxCardinalSlope(f, w, h, cellM)
}
lin := grow(false)
non := grow(true)
t.Logf("after %.1f Myr at %.1f mm/yr: linear reaches slope %.3f, nonlinear %.3f (Sc %.3f)",
steps*dt/1e6, upliftM*1000, lin, non, sc)
if non > sc {
t.Errorf("nonlinear settled at %.3f, above Sc %.3f: the flux is not stiffening", non, sc)
}
if non < sc*0.4 {
t.Errorf("nonlinear settled at %.3f, far below Sc %.3f: it is over-transporting", non, sc)
}
// The discriminating statement: under one forcing, the linear law overshoots the critical slope and the
// nonlinear law does not. If linear stays under it too, the forcing was too gentle to test anything.
if lin <= sc {
t.Errorf("linear only reached %.3f against Sc %.3f; the forcing is too weak to tell the laws apart", lin, sc)
}
}
// TestDiffuseNonlinearIsStable catches the failure that does not announce itself. An explicit scheme run past
// its stability limit does not blow up on the first step; it grows a checkerboard over hundreds of them, and
// by then the run is finished and the artefact looks like texture. A checkerboard is the mode a five-point
// stencil goes unstable in, so it is what the test starts from: a stable scheme damps it towards flat.
//
// The settings put the sub-step logic where it has to choose. The initial field is far past Sc, so the cap
// engages; the budget is well below what u = 0.95 would want, so the cap has to be lowered rather than the
// sub-step count truncated. Truncating is the tempting, wrong branch and is what this is here to catch.
//
// Only the interior is measured. The border is an outlet and is held fixed by design, so it keeps its initial
// values for ever and reading it back tells you nothing about the scheme.
func TestDiffuseNonlinearIsStable(t *testing.T) {
const (
w, h = 64, 64
cellM = 8.0
sc = 0.7
)
base := make([]bool, w*h)
g := NewGrid(w, h, cellM, base)
g.SetElevationRange(-1000, 4000)
// The checkerboard goes in the interior only. The border is an outlet and is held fixed, so a
// checkerboard written across it is a permanent forcing that keeps re-injecting the mode into the first
// interior ring — the scheme would then be blamed for a boundary condition.
field := make([]float32, w*h)
for y := 1; y < h-1; y++ {
for x := 1; x < w-1; x++ {
if (x+y)%2 == 0 {
field[y*w+x] = 200
}
}
}
for i := range field {
if g.fixed[i] {
field[i] = 100 // flat base level, the mean of the checkerboard
}
}
for i := 0; i < 500; i++ {
g.DiffuseNonlinear(field, 0.02, sc, 0.95, 1500, 24)
}
lo, hi := float32(math.Inf(1)), float32(math.Inf(-1))
for y := 1; y < h-1; y++ {
for x := 1; x < w-1; x++ {
v := field[y*w+x]
if math.IsNaN(float64(v)) || math.IsInf(float64(v), 0) {
t.Fatalf("hillslope diffusion produced %v at %d,%d", v, x, y)
}
if v < lo {
lo = v
}
if v > hi {
hi = v
}
}
}
t.Logf("after 500 steps the interior spans %.3f..%.3f m, from a 200 m checkerboard", lo, hi)
if hi-lo > 1 {
t.Errorf("the checkerboard is still %.1f m after 500 steps: it is not being damped", hi-lo)
}
}
// TestDiffuseNonlinearMatchesLinearWhenGentle pins the other end of the law. Well below Sc the two must agree
// closely, because that is the claim that lets this replace linear diffusion outright rather than sit beside
// it: the lowlands must not change when the switch is thrown.
func TestDiffuseNonlinearMatchesLinearWhenGentle(t *testing.T) {
const (
w, h = 64, 64
cellM = 10.0
sc = 1.0 // Sc far above anything in the field, so u stays near zero
)
base := make([]bool, w*h)
a := make([]float32, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
// A gentle bump: peak slope about 0.01, one percent of Sc.
d := math.Hypot(float64(x-w/2), float64(y-h/2)) * cellM
a[y*w+x] = float32(3 * math.Exp(-d*d/(2*100*100)))
}
}
b := make([]float32, w*h)
copy(b, a)
ga := NewGrid(w, h, cellM, base)
ga.SetElevationRange(-100, 100)
gb := NewGrid(w, h, cellM, base)
gb.SetElevationRange(-100, 100)
for i := 0; i < 20; i++ {
ga.DiffuseNonlinear(a, 0.02, sc, 0.9, 1500, 24)
gb.Diffuse(b, 0.02, 1500)
}
var worst float64
for i := range a {
if d := math.Abs(float64(a[i] - b[i])); d > worst {
worst = d
}
}
t.Logf("worst divergence from linear diffusion over 20 steps: %.4f m", worst)
if worst > 0.01 {
t.Errorf("nonlinear and linear diffusion differ by %.4f m at u ~ 0.01; they should agree", worst)
}
}
func maxCardinalSlope(h []float32, w, hgt int, cellM float64) float64 {
var worst float64
for y := 0; y < hgt; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if x+1 < w {
if s := math.Abs(float64(h[i+1]-h[i])) / cellM; s > worst {
worst = s
}
}
if y+1 < hgt {
if s := math.Abs(float64(h[i+w]-h[i])) / cellM; s > worst {
worst = s
}
}
}
}
return worst
}
+36
View File
@@ -0,0 +1,36 @@
package fluvial
// Deterministic per-cell jitter, and why a router needs one.
//
// D8 lets a cell drain to one of eight neighbours, so every channel is a chain of 0, 45 and 90 degree
// segments. In mountains the slope hides it. On a plain it is the dominant artefact, and for a specific
// reason: across a filled flat the only gradient present is the priority-flood's own epsilon, one millimetre
// a cell, applied in the order the flood happened to reach the cells. The router then faithfully follows the
// flood's traversal geometry and draws it as rivers — ruler-straight diagonals, the polygonal network that
// killed the first attempt at flat plains.
//
// The fix is to stop the epsilon being uniform. A hash of the cell index scatters it by plus or minus half,
// which is far below anything that matters to the solve (a millimetre against metre-scale relief) and far
// above the difference the flood's ordering would otherwise leave, so the descent direction on a flat is
// decided by the hash rather than by scan order. The same hash breaks near-ties between two equally steep
// neighbours, which is the other place a fixed direction order leaks a grid axis into the result.
//
// It is a hash rather than a random source because cross-cutting rule 12 is determinism from a seed: the
// value for a cell must not depend on how many cells were visited before it, on which goroutine ran, or on
// how many steps the solve has taken.
// hash01 is splitmix64 finalised to the unit interval. Cheap, no state, and well enough distributed that
// neighbouring indices get unrelated values — which is the whole requirement here.
func hash01(seed uint64, i int32) float32 {
x := seed ^ (uint64(uint32(i)) * 0x9e3779b97f4a7c15)
x ^= x >> 30
x *= 0xbf58476d1ce4e5b9
x ^= x >> 27
x *= 0x94d049bb133111eb
x ^= x >> 31
return float32(x>>11) / float32(1<<53)
}
// SetSeed ties the jitter to the run's seed, so two seeds do not share the same flat-routing geometry.
// Zero is a perfectly good seed; it is the default and nothing depends on it being set.
func (g *Grid) SetSeed(seed int64) { g.seed = uint64(seed)*0x9e3779b97f4a7c15 + 0x243f6a8885a308d3 }
+74
View File
@@ -0,0 +1,74 @@
package fluvial
import "math"
// ClampToRepose enforces a maximum slope everywhere: no cell may stand above a neighbour by more than
// talus * distance. It returns the mean thickness removed, in metres.
//
// This replaces iterating thermal.Apply inside the solve, which could not do the job however many passes it
// was given (measured: 3, 10 and 40 passes all left the steepest land slope at 64 degrees against a 22 degree
// repose). The reason is structural rather than a bug. That routine moves half the excess downhill, so on a
// *uniform* over-steep slope every cell sheds exactly as much as it receives, the net change is zero, and the
// slope is a fixed point. It relaxes only where the downhill flux diverges — which is why it cuts a cone,
// whose contours converge, and why it cannot touch a planar hillside.
//
// So the constraint is imposed directly instead. This is the priority-flood mirrored: pop cells in ascending
// elevation, and lower any neighbour standing higher than the repose angle allows. Because a lowered cell is
// set to h[c] + talus*d, which is at or above the elevation just popped, the queue stays monotone and the
// bucket queue works unchanged. One pass, O(n) with the bucket queue, and the constraint holds globally when
// it returns.
//
// It is not mass-conserving: the material is removed rather than piled at the foot of the slope. That is the
// deliberate simplification, because in this landscape the foot of a hillslope is a channel and the channel
// exports the sediment anyway. The mean thickness removed is returned so a run can report it, and a run that
// removes a suspicious amount is saying its uplift and its repose angle disagree.
func (g *Grid) ClampToRepose(h []float32, talus float64) float64 {
if talus <= 0 {
return 0
}
n := g.W * g.H
for i := range g.closed {
g.closed[i] = false
}
g.pq.reset()
for i := 0; i < n; i++ {
g.pq.push(h[i], int32(i))
}
card := talus * g.CellM
diag := talus * g.CellM * math.Sqrt2
var removed float64
for g.pq.len() > 0 {
c := g.pq.pop()
if c < 0 {
break
}
if g.closed[c] {
continue
}
g.closed[c] = true
cx, cy := int(c)%g.W, int(c)/g.W
for k := 0; k < 8; k++ {
nx, ny := cx+dx8[k], cy+dy8[k]
if nx < 0 || ny < 0 || nx >= g.W || ny >= g.H {
continue
}
ni := int32(ny*g.W + nx)
if g.closed[ni] || g.fixed[ni] {
continue
}
allow := card
if dx8[k] != 0 && dy8[k] != 0 {
allow = diag
}
limit := h[c] + float32(allow)
if h[ni] > limit {
removed += float64(h[ni] - limit)
h[ni] = limit
g.pq.push(limit, ni)
}
}
}
return removed / float64(n)
}
@@ -0,0 +1,50 @@
package fluvial
import (
"math"
"testing"
)
// TestClampToReposeCutsACone is the smallest possible check on the constraint: a cone far steeper than the
// repose angle must come back at or under it.
func TestClampToReposeCutsACone(t *testing.T) {
const (
w, h = 81, 81
cellM = 10.0
talus = 0.4 // about 22 degrees
)
base := make([]bool, w*h)
field := make([]float32, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
d := math.Hypot(float64(x-w/2), float64(y-h/2)) * cellM
field[y*w+x] = float32(math.Max(0, 800-2.0*d)) // slope 2.0, five times repose
}
}
g := NewGrid(w, h, cellM, base)
g.SetElevationRange(-100, 2000)
removed := g.ClampToRepose(field, talus)
worst := 0.0
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
for k := 0; k < 8; k++ {
nx, ny := x+dx8[k], y+dy8[k]
if nx < 0 || ny < 0 || nx >= w || ny >= h {
continue
}
d := cellM
if dx8[k] != 0 && dy8[k] != 0 {
d = cellM * math.Sqrt2
}
if s := float64(field[y*w+x]-field[ny*w+nx]) / d; s > worst {
worst = s
}
}
}
}
t.Logf("removed %.2f m mean; steepest slope now %.3f (repose %.3f)", removed, worst, talus)
if worst > talus*1.02 {
t.Errorf("steepest slope %.3f exceeds repose %.3f", worst, talus)
}
}