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 }