This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+218
View File
@@ -0,0 +1,218 @@
package fluvial
import (
"math"
"salty/terrain/internal/field"
)
// Multiple-flow-direction drainage area: Freeman, Quinn and Holmgren's partition, and the answer to the one
// thing D8 cannot do.
//
// A planar hillslope is where D8 fails, and it fails in closed form. The specific catchment area on a plane
// is the distance from the divide and it is the same at every point along a contour, because nothing about a
// plane tells one flow line from its neighbour. D8 has to disagree: every cell picks the same steepest
// neighbour, so the flow lines run exactly parallel and never converge, and a cell either sits on a line and
// carries the whole tube or sits off one and carries a single cell for ever. Measured on a ramp at an aspect
// of 22.5 degrees, the most-drained cell in a contour band carries 769 times the median and 30 % of the grid
// drains nothing at all (flow_test.go). Stream power then reads A^m off that and cuts each line in, which is
// what a bake's mountain flanks were: a comb of ruler-straight parallel grooves, one per surviving line,
// spaced by the mean distance between the merges the router's tie-break jitter happened to allow - a spacing
// in cells, which is why it measured the same eighteen cells at an 8 m cell and at a 32 m one.
//
// The partition below splits a cell's area among every downslope neighbour by (dh_k * q_k)^p, where q_k is
// the share of the cell's perimeter facing direction k divided by the centre-to-centre distance: 0.5 for a
// cardinal neighbour, 0.25 for a diagonal. The cell size cancels out of the ratio, so the weights are height
// differences times a constant - no division and no transcendental in the inner loop at p = 1.
//
// It replaces Accumulate and it replaces only that. Receiver, Length and Stack stay D8, because
// Braun-Willett's implicit update walks one receiver chain and there is no multi-receiver form of it that is
// still unconditionally stable. Stream power therefore incises along the steepest path using the area that
// actually converges there. That pairing is deliberate and it is the standard one; it is not an oversight.
// mfdPow selects how the partition quantity is raised to p, once per call rather than once per cell. p is
// almost always 1, where the whole thing is a multiply.
type mfdPow uint8
const (
mfdP1 mfdPow = iota
mfdP2
mfdP3
mfdP4
mfdGeneral
)
func mfdModeFor(p float64) (mfdPow, float64) {
switch {
case math.Abs(p-1) < 1e-9:
return mfdP1, 1
case math.Abs(p-2) < 1e-9:
return mfdP2, 2
case math.Abs(p-3) < 1e-9:
return mfdP3, 3
case math.Abs(p-4) < 1e-9:
return mfdP4, 4
default:
return mfdGeneral, p
}
}
func (g *Grid) mfdRaise(v float32) float32 {
switch g.mfdMode {
case mfdP1:
return v
case mfdP2:
return v * v
case mfdP3:
return v * v * v
case mfdP4:
v2 := v * v
return v2 * v2
default:
return float32(math.Pow(float64(v), g.mfdExp))
}
}
// mfdQ is the perimeter share facing each D8 direction divided by the distance to it, in the order of dx8
// and dy8: NW N NE W E SW S SE. Cardinal 0.5, diagonal 0.25.
var mfdQ = [8]float32{0.25, 0.5, 0.25, 0.5, 0.5, 0.25, 0.5, 0.25}
// AccumulateMFD fills Area with multiple-flow drainage area, in m².
//
// The order is Kahn's algorithm over the flow graph rather than a sort by elevation, and neither of the two
// obvious alternatives works. The D8 stack cannot be reused: BuildStack is a depth-first walk of the donor
// tree, so a deep node of one subtree precedes a shallow node of the next and the order is not descending in
// elevation - a cell would send area to a neighbour that had already been processed, and the loss would fall
// on the flanks, which is exactly where it cannot be afforded. A bucket sort cannot either: the queue
// quantises to a centimetre while the flood's epsilon ladder across a filled flat is a millimetre a cell, so
// ten cells of one descending chain share a bucket and a lake bed would leak its area.
//
// Kahn needs no elevation comparison at all. mfdPending[i] is how many strictly higher neighbours i still
// owes; a cell is ready when the count reaches zero. Because "strictly lower" is a strict order the graph is
// acyclic, so every cell is released exactly once - which is asserted, because the alternative is a drainage
// area that is quietly too small in a two-hour bake.
//
// The counting pass is inside this function and not folded into ComputeReceivers, which already reads all
// eight neighbours and could have produced it for nothing. It was, and it was wrong: the walk *consumes* the
// counts, so a second call without an intervening ComputeReceivers seeded its whole queue at once and
// returned a drainage area that was silently wrong rather than panicking. Run happens to call the two in
// lockstep, so nothing would have caught it there. A pass that owns its own preconditions cannot be misused
// that way, and this one is a pure gather, so it parallelises and costs almost nothing in wall clock.
func (g *Grid) AccumulateMFD(h []float32, p float64) {
n := g.W * g.H
g.mfdMode, g.mfdExp = mfdModeFor(p)
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
pend := uint8(0)
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
}
// How many neighbours will hand this cell a share: the ones strictly above it. The
// weights below skip a neighbour when hn >= hc, so c sends to n exactly when
// h[c] > h[n] - the same predicate, and it has to stay the same one or the walk ends
// short.
if h[ny*g.W+nx] > h[i] {
pend++
}
}
g.mfdPending[i] = pend
}
}
})
cell := float32(g.CellM * g.CellM)
for i := range g.Area {
g.Area[i] = cell
}
if cap(g.mfdQueue) < n {
g.mfdQueue = make([]int32, 0, n)
}
q := g.mfdQueue[:0]
for i := 0; i < n; i++ {
if g.mfdPending[i] == 0 {
q = append(q, int32(i))
}
}
// p = 1 is the default and it is a multiply; hoisting the mode test out of the cell loop saves a call
// and a switch on every one of the eight faces of every cell of every step.
linear := g.mfdMode == mfdP1
var wgt [8]float32
for read := 0; read < len(q); read++ {
c := q[read]
cx, cy := int(c)%g.W, int(c)/g.W
hc := h[c]
var total float32
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 {
wgt[k] = 0
continue
}
// The same predicate the counting pass above used, written the same way round, because the
// counts and these weights have to agree cell for cell or the walk ends short.
hn := h[ny*g.W+nx]
if hn >= hc {
wgt[k] = 0
continue
}
dh := hc - hn
w := dh * mfdQ[k]
if !linear {
w = g.mfdRaise(w)
}
wgt[k] = w
total += w
}
// A fixed cell is base level: it absorbs what arrives and sends nothing on. It still has to release
// the cells below it, or their counts would never reach zero and the walk would end short - which is
// why the release
// loop below is not inside the `total > 0` branch.
share := float32(0)
if total > 0 && !g.fixed[c] {
share = g.Area[c] / total
}
for k := 0; k < 8; k++ {
if wgt[k] == 0 {
continue
}
ni := int32((cy+dy8[k])*g.W + cx + dx8[k])
if share > 0 {
g.Area[ni] += share * wgt[k]
}
g.mfdPending[ni]--
if g.mfdPending[ni] == 0 {
q = append(q, ni)
}
}
}
g.mfdQueue = q
if len(q) != n {
// Unreachable unless the pending counts and the weights disagree about which neighbours are lower,
// which would mean area silently going missing. Loud is the only useful behaviour here.
panic("fluvial: MFD released " + itoa(len(q)) + " of " + itoa(n) + " cells; the pending counts and " +
"the downslope test disagree")
}
}
func itoa(v int) string {
if v == 0 {
return "0"
}
var b [20]byte
i := len(b)
for v > 0 {
i--
b[i] = byte('0' + v%10)
v /= 10
}
return string(b[i:])
}