Files
UnrealPrototyping/Tools/Terrain/internal/fluvial/hillslope.go
T

188 lines
7.0 KiB
Go

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
}