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
@@ -22,6 +22,12 @@ type bucketPQ struct {
const bucketWidthM = 0.01
// reposeOrderBuckets is how far ClampToRepose scatters a cell from its own bucket, in buckets, either way.
// Sixteen buckets is sixteen centimetres: enough that a tie spreads over about thirty of them and the pop
// order stops tracking the raster, far below the metres the clamp's ordering could ever depend on. See
// pushJittered.
const reposeOrderBuckets = 16
func newBucketPQ(loM, hiM float64) *bucketPQ {
if hiM <= loM {
hiM = loM + 1
@@ -54,6 +60,34 @@ func (q *bucketPQ) push(elev float32, idx int32) {
q.count++
}
// pushJittered is push with the bucket chosen from the elevation plus j buckets, so cells at the same
// elevation land in different buckets instead of in one and pop in hash order rather than in reverse raster
// order.
//
// Only ClampToRepose wants this. The flood already scatters its own order through the epsilon it adds to the
// height, so its cells rarely share a bucket; the clamp reads the raw surface, where flat ground puts every
// cell in one bucket and the LIFO below then processes them bottom-right to top-left, every time, everywhere.
//
// Half a bucket is not enough - it splits a tie across two buckets and halves the correlation instead of
// removing it - so the caller scatters over reposeOrderBuckets, and that is safe for a reason worth writing
// down. The clamp's order can only matter between two cells whose heights differ by about the talus
// allowance, which is metres: a cell popped early is marked closed and never lowered again, but every cell
// popping after it stands within the jitter width of it, so its limit is the other cell's height plus the
// full allowance and cannot bind. Reordering cells that are centimetres apart therefore cannot break a
// constraint that only bites metres apart. The bound is talus*cell/2, which is 2.8 m at 35 degrees on an 8 m
// cell; the constant below is two orders of magnitude inside it.
func (q *bucketPQ) pushJittered(elev float32, idx int32, j float32) {
b := int((float64(elev)-q.lo)/q.width + float64(j))
if b < q.cur {
b = q.cur
}
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 {
+170
View File
@@ -0,0 +1,170 @@
package fluvial
import (
"math"
"sort"
"testing"
)
// The diagnosis on a surface with no history.
//
// A planar hillslope is the one case where the right answer is known in closed form: the specific catchment
// area - the upslope area per unit contour length - is the distance from the divide, and it is the same at
// every point along a contour. Nothing about a plane distinguishes one flow line from its neighbour, so a
// router that says otherwise is inventing the difference.
//
// D8 cannot say otherwise quietly. Every cell on the plane picks the same steepest neighbour, so the flow
// lines run exactly parallel and never converge: a cell either sits on a line and carries the whole tube, or
// sits off one and carries a single cell for ever. The ratio between them is the statistic below, and it is
// why the flanks of a bake come out combed - stream power reads A^m off the lie and cuts each line in.
//
// There is no erosion in here. One fill, one receiver pass, one stack, one accumulate, and whatever comes
// out belongs to the router and to nothing else.
// planarRamp is a plane tilted by aspectDeg from the x axis, with a whisper of noise to break exact ties.
// The aspect matters: at 0 or 45 degrees the plane is aligned with a D8 direction and the answer is
// degenerate in the other direction, so the test asks at 22.5, which is the worst case and the honest one.
func planarRamp(n int, cellM, slope, aspectDeg float64) []float32 {
t := aspectDeg * math.Pi / 180
cs, sn := math.Cos(t), math.Sin(t)
h := make([]float32, n*n)
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
d := (float64(x)*cs + float64(y)*sn) * cellM
// A millimetre of hash noise: enough that no two neighbours are bit-identical, far below
// anything the router could read as structure.
j := float64(hashXY(1, int32(x), int32(y), 99)) * 1e-3
h[y*n+x] = float32(4000 - d*slope + j)
}
}
return h
}
// concentration is max over median of the drainage area in a band of cells all the same distance from the
// divide. On a plane the true value is 1: every cell in the band drains the same strip above it.
func concentration(t *testing.T, area []float32, n int, cellM, aspectDeg, lo, hi float64) float64 {
th := aspectDeg * math.Pi / 180
cs, sn := math.Cos(th), math.Sin(th)
dmax := (float64(n-1)*cs + float64(n-1)*sn) * cellM
var band []float64
for y := 2; y < n-2; y++ {
for x := 2; x < n-2; x++ {
d := (float64(x)*cs + float64(y)*sn) * cellM
if d >= lo*dmax && d <= hi*dmax {
band = append(band, float64(area[y*n+x]))
}
}
}
if len(band) < 100 {
t.Fatalf("contour band has only %d cells", len(band))
}
sort.Float64s(band)
med := band[len(band)/2]
if med <= 0 {
t.Fatalf("median area in the band is %g", med)
}
return band[len(band)-1] / med
}
const (
flowN = 256
flowCellM = 10.0
flowSlope = 0.1
flowAspect = 22.5
)
func TestD8ConcentratesFlowOnAPlanarSlope(t *testing.T) {
h := planarRamp(flowN, flowCellM, flowSlope, flowAspect)
g := NewGrid(flowN, flowN, flowCellM, nil)
g.SetSeed(37125)
g.FillDepressions(h, 1e-3)
g.ComputeReceivers(h)
g.BuildStack()
g.Accumulate()
c := concentration(t, g.Area, flowN, flowCellM, flowAspect, 0.6, 0.7)
leaves := 0
for i := range g.Area {
if g.Area[i] <= float32(flowCellM*flowCellM)*1.001 {
leaves++
}
}
t.Logf("D8: concentration max/median = %.1f, leaf cells = %.1f%%",
c, 100*float64(leaves)/float64(flowN*flowN))
if c < 5 {
t.Errorf("D8 concentration is %.1f; this test exists because it is large, so either the router "+
"changed or the measurement is wrong", c)
}
}
func TestMFDDoesNotConcentrateFlowOnAPlanarSlope(t *testing.T) {
h := planarRamp(flowN, flowCellM, flowSlope, flowAspect)
g := NewGrid(flowN, flowN, flowCellM, nil)
g.SetSeed(37125)
g.FillDepressions(h, 1e-3)
g.ComputeReceivers(h)
g.AccumulateMFD(h, 1)
c := concentration(t, g.Area, flowN, flowCellM, flowAspect, 0.6, 0.7)
leaves := 0
for i := range g.Area {
if g.Area[i] <= float32(flowCellM*flowCellM)*1.001 {
leaves++
}
}
t.Logf("MFD: concentration max/median = %.2f, leaf cells = %.1f%%",
c, 100*float64(leaves)/float64(flowN*flowN))
if c > 2 {
t.Errorf("MFD concentration is %.2f on a plane, where the true answer is 1; the partition is not "+
"spreading flow across the contour", c)
}
}
// TestMFDConservesArea is the test the panic in AccumulateMFD cannot be: the walk releasing every cell says
// nothing about how much area arrived. Over a closed basin the total that reaches the outlets has to be the
// whole grid, because there is nowhere else for it to go.
func TestMFDConservesArea(t *testing.T) {
const n = 128
const cellM = 10.0
// A bowl, so every flow path ends at the one interior minimum rather than at the border.
h := make([]float32, n*n)
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
dx, dy := float64(x)-n/2, float64(y)-n/2
j := float64(hashXY(7, int32(x), int32(y), 99)) * 1e-3
h[y*n+x] = float32(100 + 0.02*(dx*dx+dy*dy) + j)
}
}
g := NewGrid(n, n, cellM, nil)
g.SetSeed(9342)
g.ComputeReceivers(h)
g.AccumulateMFD(h, 1)
// Every cell that sends nothing on is a sink: the bowl's floor and the fixed border. What rests in them
// is the whole grid's area.
var rest float64
for i := 0; i < n*n; i++ {
x, y := i%n, i/n
lower := false
for k := 0; k < 8; k++ {
nx, ny := x+dx8[k], y+dy8[k]
if nx < 0 || ny < 0 || nx >= n || ny >= n {
continue
}
if h[ny*n+nx] < h[i] {
lower = true
break
}
}
if !lower || g.fixed[i] {
rest += float64(g.Area[i])
}
}
want := float64(n*n) * cellM * cellM
if rel := math.Abs(rest-want) / want; rel > 1e-4 {
t.Errorf("area resting in sinks is %.0f m2, the grid is %.0f m2: %.2e relative, float32 is not enough",
rest, want, rel)
} else {
t.Logf("area conserved to %.2e relative in float32", rel)
}
}
+45 -4
View File
@@ -64,6 +64,11 @@ type Params struct {
CriticalSlope float64
SlopeCap float64 // where the flux stops stiffening, as a fraction of Sc
MaxHillslopeSub int // the sub-step budget that bound buys
// MFDExponent selects multiple-flow-direction drainage area over D8's single receiver, and is the
// exponent on the partition. 0 keeps the old Accumulate, which is what every bake before this ran and
// what the A/B comparison needs. See mfd.go for why one is the right default.
MFDExponent float64
}
// Grid holds the flow topology and the scratch it is built from. Allocated once and reused across every
@@ -85,7 +90,11 @@ type Grid struct {
Stack []int32 // every node after its receiver
Area []float32 // drainage area, m²
seed uint64 // the jitter's seed; see jitter.go and SetSeed
cancel <-chan struct{} // closed to abandon a run mid-solve; see SetCancel
seed uint64 // the jitter's seed; see jitter.go and SetSeed
originX int32 // where this grid sits on the planet; see SetFrame. Zero is "this grid is the world"
originY int32
planetW int32 // the cylinder's width, or 0 when there is no cylinder
donorOff []int32
donorList []int32
cursor []int32
@@ -93,6 +102,13 @@ type Grid struct {
pq *bucketPQ
fifo []int32
scratch []float32
// Multiple-flow accumulation. mfdPending is how many strictly higher neighbours a cell still owes before
// it may be released; a byte, because a cell has eight neighbours and cannot owe more. See mfd.go.
mfdPending []uint8
mfdQueue []int32
mfdMode mfdPow
mfdExp float64
}
// SetElevationRange sizes the flood's bucket queue. Called once, with the manifest's elevation range plus a
@@ -116,6 +132,7 @@ func NewGrid(w, h int, cellM float64, base []bool) *Grid {
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),
mfdPending: make([]uint8, n),
}
g.fixed = make([]bool, n)
for i := range g.fixed {
@@ -178,7 +195,7 @@ func (g *Grid) FillDepressions(h []float32, epsilon float32) {
}
g.closed[ni] = true
if h[ni] <= celev {
h[ni] = celev + epsilon*(0.5+hash01(g.seed, ni))
h[ni] = celev + epsilon*(0.5+hashXY(g.seed, g.worldX(nx), g.worldY(ny), jitterFloodEpsilon))
g.fifo = append(g.fifo, ni)
} else {
g.pq.push(h[ni], ni)
@@ -239,7 +256,7 @@ func (g *Grid) ComputeReceivers(h []float32) {
}
// 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))
sj := s * (1 + 1e-3*(hashXY(g.seed, g.worldX(x), g.worldY(y), int32(k)+jitterReceiverTie)-0.5))
if sj > bestJitter {
bestJitter, best, bestLen = sj, ni, l
}
@@ -298,6 +315,11 @@ func (g *Grid) BuildStack() {
}
}
// Scratch hands out the grid's spare float32 buffer, which is the width of the grid and is dead between
// steps. It is here so a pass that runs once after the solve - the edge-preserving smooth - does not allocate
// a second copy of the height field at planet scale just to have somewhere to write.
func (g *Grid) Scratch() []float32 { return g.scratch }
// 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 {
@@ -458,6 +480,14 @@ func clampAt(a []float32, w, h, x, y int) float32 {
return a[y*w+x]
}
// SetCancel gives the solve a way to be abandoned part way through.
//
// It is checked once a step rather than inside one, which is the right granularity: a step is milliseconds on
// a small region and a couple of seconds on a big one, so the longest a caller waits is one step, and nothing
// inside a step is safe to leave half done. The height field is left wherever the solve had got to, which is
// what a cancelled run means - it is not a checkpoint and nothing downstream should read it as one.
func (g *Grid) SetCancel(ch <-chan struct{}) { g.cancel = ch }
// 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)) {
@@ -466,12 +496,23 @@ func (g *Grid) Run(h []float32, uplift, k []float32, p Params, log func(step int
fill = 1
}
for step := 0; step < p.Steps; step++ {
if g.cancel != nil {
select {
case <-g.cancel:
return
default:
}
}
if step%fill == 0 {
g.FillDepressions(h, 1e-3)
}
g.ComputeReceivers(h)
g.BuildStack()
g.Accumulate()
if p.MFDExponent > 0 {
g.AccumulateMFD(h, p.MFDExponent)
} else {
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
+58 -8
View File
@@ -18,7 +18,16 @@ import (
// 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.
// through a symmetric stencil, so there is no cut, no facet and no preferred direction.
//
// The stencil is nine-point, and it has to be. Run's design is that the clamp cuts and this rounds off what
// it cut before the next step sees it - but the clamp cuts along all eight neighbour directions and a
// five-point stencil transports across four, so it cannot touch a diagonally-cut facet at all. That was not a
// refinement, it was a hole in the stated design. The weights are 4/6 cardinal and 1/6 diagonal, which is the
// isotropic nine-point Laplacian: on h = (a/2)(x^2+y^2) the cardinal faces sum to 2ad^2 and the diagonals to
// 4ad^2, so (1/6)(8ad^2 + 4ad^2) = 2ad^2 = dx^2 * grad2(h), exactly what the five-point gave. coeff is
// therefore unchanged. A diagonal face is sqrt(2) further away, so it carries its own critical height
// difference; leaving that out would make every diagonal read as 1.41 times its true S/Sc.
//
// 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
@@ -57,6 +66,14 @@ func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub
dx := g.CellM
dx2 := dx * dx
// The Courant number the sub-stepping aims for. The worst mode is the checkerboard: on the five-point
// stencil its cardinal faces sum to -8*amp and the amplification is 1 - 8*coeff, stable to coeff 0.25;
// on the nine-point the diagonals cancel and (4/6)*(-8*amp) leaves 1 - 5.333*coeff, stable to 0.375. Both
// targets keep the same 1.25x margin under their own limit, and the extra room is most of what pays for
// the four extra faces. Raising the target without the 4/6 and 1/6 weights, or adding the faces without
// raising the target, is a scheme that checkerboards a few hundred steps in - which is the failure the
// budget note below is about, and it does not announce itself.
// 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)
@@ -64,7 +81,7 @@ func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub
// 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 {
if budget := float64(maxSub) * subTargetNine * dx2 / (d * dt); f > budget {
f = budget
u = invStiffness(f)
}
@@ -75,7 +92,7 @@ func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub
f = 1
u = 0
}
sub := int(math.Ceil(d * f * dt / dx2 / 0.2))
sub := int(math.Ceil(d * f * dt / dx2 / subTargetNine))
if sub < 1 {
sub = 1
}
@@ -87,6 +104,7 @@ func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub
// 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)
dhCritDiag := float32(sc * dx * math.Sqrt2)
src := h
tmp := g.scratch[:len(h)]
@@ -100,13 +118,17 @@ func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub
continue
}
c := src[i]
// The net inflow over the four faces. Each face is evaluated from both of its cells,
// The net inflow over all eight 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) +
card := 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
diag := flux(clampAt(src, g.W, g.H, x-1, y-1)-c, dhCritDiag, uCap) +
flux(clampAt(src, g.W, g.H, x+1, y-1)-c, dhCritDiag, uCap) +
flux(clampAt(src, g.W, g.H, x-1, y+1)-c, dhCritDiag, uCap) +
flux(clampAt(src, g.W, g.H, x+1, y+1)-c, dhCritDiag, uCap)
tmp[i] = c + coeff*(nineCard*card+nineDiag*diag)
}
}
})
@@ -114,6 +136,14 @@ func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub
}
}
// The isotropic nine-point Laplacian's weights, and the Courant target its stability allows. See
// DiffuseNonlinear.
const (
nineCard = float32(4.0 / 6.0)
nineDiag = float32(1.0 / 6.0)
subTargetNine = 0.3
)
// 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.
@@ -152,9 +182,19 @@ func invStiffness(f float64) float64 {
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.
// maxSlopeRatio is the steepest face on the grid as a fraction of Sc, over every face the stencil transports
// across - which since the stencil went to nine points means the diagonals too. A diagonal face is compared
// against its own critical height difference, sqrt(2) larger, so what comes back is a slope ratio either way.
//
// What this number is for is worth being exact about, because it looks like physics and is not. It bounds the
// stiffening for the whole call, and the flux law only caps a face when that face exceeds the bound - so on a
// grid whose steepest face is the bound, no face is capped and the value has no effect on any cell. Its one
// real job is to decide how many sub-steps the call pays for, which is a cost question. Where it does reach
// the physics is when the sub-step budget cannot buy the grid's own maximum; the cap is then lowered to what
// the budget affords, and that value is the manifest's - D, dt, the cell and MaxHillslopeSub - and not the
// grid's, so a planet decomposed two ways still answers the same.
func maxSlopeRatio(h []float32, w, hgt int, sc, cellM float64) float64 {
invDiag := float32(1 / math.Sqrt2)
var maxDiff float32
for y := 0; y < hgt; y++ {
for x := 0; x < w; x++ {
@@ -164,6 +204,16 @@ func maxSlopeRatio(h []float32, w, hgt int, sc, cellM float64) float64 {
if dv := abs32(h[i+1] - c); dv > maxDiff {
maxDiff = dv
}
if y+1 < hgt {
if dv := abs32(h[i+w+1]-c) * invDiag; dv > maxDiff {
maxDiff = dv
}
}
if y > 0 {
if dv := abs32(h[i-w+1]-c) * invDiag; dv > maxDiff {
maxDiff = dv
}
}
}
if y+1 < hgt {
if dv := abs32(h[i+w] - c); dv > maxDiff {
@@ -152,7 +152,12 @@ func TestDiffuseNonlinearIsStable(t *testing.T) {
field[i] = 100 // flat base level, the mean of the checkerboard
}
}
for i := 0; i < 500; i++ {
// Two thousand steps, not five hundred. The nine-point stencil damps the checkerboard more slowly per
// step than the five-point did - the diagonal faces of a checkerboard are flat, so only the 4/6 of the
// stencil facing the cardinals sees the mode at all - and it is run at a Courant target of 0.3 rather
// than 0.2 because its stability limit is 0.375 rather than 0.25. Both of those are arguments on paper.
// A slow instability takes hundreds of steps to show, and a solve runs a thousand.
for i := 0; i < 2000; i++ {
g.DiffuseNonlinear(field, 0.02, sc, 0.95, 1500, 24)
}
lo, hi := float32(math.Inf(1)), float32(math.Inf(-1))
@@ -170,9 +175,9 @@ func TestDiffuseNonlinearIsStable(t *testing.T) {
}
}
}
t.Logf("after 500 steps the interior spans %.3f..%.3f m, from a 200 m checkerboard", lo, hi)
t.Logf("after 2000 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)
t.Errorf("the checkerboard is still %.1f m after 2000 steps: it is not being damped", hi-lo)
}
}
+61 -15
View File
@@ -1,5 +1,7 @@
package fluvial
import "salty/terrain/internal/world"
// 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
@@ -9,28 +11,72 @@ package fluvial
// 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.
// The fix is to stop the epsilon being uniform. A hash 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.
//
// And it is a hash of a *world position* rather than of a grid index, which is rule 1 of the tiling plan in
// Docs/Terrain-Next.md 3.3. A planet is solved one landmass at a time, so the same physical cell turns up in
// grids of different widths at different offsets; keyed on the index it would jitter differently each time,
// and every place two frames met would show it. Keyed on where the cell actually is, it cannot.
// 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)
// The k namespace. Every caller of hashXY picks a k, and two callers that share one get perfectly correlated
// jitter - the clamp's allowance would track the router's tie-break in the same direction, which is exactly
// the kind of hidden coupling that prints a texture nobody can attribute. They are named here so a new
// caller has to pick a free one.
const (
jitterFloodEpsilon int32 = 0 // the priority-flood's per-cell fall across a flat (fluvial.go)
jitterReceiverTie int32 = 1 // .. 8, one per D8 direction: the steepest-neighbour tie-break (fluvial.go)
jitterReposeAllow int32 = 9 // .. 16, one per D8 direction: the repose clamp's allowance (repose.go)
jitterReposeOrder int32 = 17 // the repose clamp's pop order (repose.go)
)
// hashXY is splitmix64's finaliser over a weighted sum of the seed and the position. One finalising round,
// because this is called eight times per cell per step - several hundred billion times over a planet bake -
// and the requirement is only that neighbouring cells get unrelated values, not cryptographic quality. The
// three odd constants are summed rather than exclusive-ored so that swapping x and y does not collide.
func hashXY(seed uint64, x, y, k int32) float32 {
h := seed ^ (uint64(uint32(x))*0x9e3779b97f4a7c15 +
uint64(uint32(y))*0xc2b2ae3d27d4eb4f +
uint64(uint32(k))*0x165667b19e3779f9)
h ^= h >> 30
h *= 0xbf58476d1ce4e5b9
h ^= h >> 27
h *= 0x94d049bb133111eb
h ^= h >> 31
return float32(h>>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 }
// SetFrame says where on the planet this grid sits, which is what turns the jitter from an index hash into
// a position hash. Without it a grid is its own world at the origin, which is what the square canvas is and
// what every existing test expects, so it is optional and NewGrid does not require it.
func (g *Grid) SetFrame(f world.Frame) {
g.originX = int32(f.P.WrapX(f.X0))
g.originY = int32(f.Y0)
g.planetW = int32(f.P.W)
}
// worldX and worldY map a grid cell to its planet cell.
//
// The wrap is a compare and a subtract rather than a modulo on purpose: originX is already inside the
// planet and x is less than the planet's width, so the sum overshoots by at most one turn. A modulo here
// would be a division in the router's innermost loop.
func (g *Grid) worldX(x int) int32 {
v := g.originX + int32(x)
if g.planetW > 0 && v >= g.planetW {
v -= g.planetW
}
return v
}
func (g *Grid) worldY(y int) int32 { return g.originY + int32(y) }
@@ -0,0 +1,74 @@
package fluvial
import (
"testing"
"salty/terrain/internal/world"
)
// The whole point of the move from an index hash to a position hash: a planet is solved one landmass at a
// time, so the same physical cell turns up in grids of different widths at different offsets. If the jitter
// disagreed between them, every place two frames met would show a line.
func TestJitterFollowsThePositionNotTheIndex(t *testing.T) {
p, err := world.New(512, 8, 100, 50, 2, 512)
if err != nil {
t.Fatal(err)
}
// Two frames of different widths, both covering planet column 5, row 7.
a := Grid{W: 16, H: 16}
a.SetSeed(11)
a.SetFrame(world.Frame{P: p, X0: 0, Y0: 0, W: 16, H: 16})
b := Grid{W: 9, H: 12}
b.SetSeed(11)
b.SetFrame(world.Frame{P: p, X0: 3, Y0: 4, W: 9, H: 12})
for k := int32(0); k < 9; k++ {
ja := hashXY(a.seed, a.worldX(5), a.worldY(7), k)
jb := hashXY(b.seed, b.worldX(2), b.worldY(3), k)
if ja != jb {
t.Fatalf("k=%d: frame a gives %v, frame b gives %v for the same planet cell", k, ja, jb)
}
}
// And it must still be a hash: the neighbouring cell gets an unrelated value.
if hashXY(a.seed, a.worldX(5), a.worldY(7), 0) == hashXY(a.seed, a.worldX(6), a.worldY(7), 0) {
t.Error("neighbouring cells hash the same")
}
}
// A frame that straddles the seam sees the same positions as one that does not.
func TestJitterWrapsAtTheSeam(t *testing.T) {
p, err := world.New(512, 8, 100, 50, 0, 512)
if err != nil {
t.Fatal(err)
}
at := Grid{W: 8, H: 8}
at.SetSeed(3)
at.SetFrame(world.Frame{P: p, X0: 60, Y0: 0, W: 8, H: 8})
origin := Grid{W: 8, H: 8}
origin.SetSeed(3)
origin.SetFrame(world.Frame{P: p, X0: 0, Y0: 0, W: 8, H: 8})
// The seam frame's column 4 is planet column 0, which is the origin frame's column 0.
if got, want := at.worldX(4), origin.worldX(0); got != want {
t.Fatalf("world column = %d, want %d", got, want)
}
if hashXY(at.seed, at.worldX(4), at.worldY(2), 0) != hashXY(origin.seed, origin.worldX(0), origin.worldY(2), 0) {
t.Error("the same planet cell jitters differently on either side of the seam")
}
}
// Without a frame a grid is its own world at the origin, which is what the square canvas is and what every
// existing test relies on.
func TestNoFrameMeansTheGridIsTheWorld(t *testing.T) {
g := Grid{W: 8, H: 8}
g.SetSeed(1)
if got := g.worldX(7); got != 7 {
t.Errorf("worldX(7) = %d, want 7", got)
}
if got := g.worldY(3); got != 3 {
t.Errorf("worldY(3) = %d, want 3", got)
}
}
+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:])
}
@@ -0,0 +1,27 @@
package fluvial
import "testing"
// What the two accumulators cost per cell, which is the number a bake's wall clock is spent against. Both are
// measured on the same surface with the receivers and the stack already built, because those are shared.
func benchAccumulate(b *testing.B, mfd bool) {
const n = 1024
h := planarRamp(n, 8.0, 0.1, 22.5)
g := NewGrid(n, n, 8.0, nil)
g.SetSeed(37125)
g.FillDepressions(h, 1e-3)
g.ComputeReceivers(h)
g.BuildStack()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if mfd {
g.AccumulateMFD(h, 1)
} else {
g.Accumulate()
}
}
b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(b.N)/float64(n*n), "ns/cell")
}
func BenchmarkAccumulateD8(b *testing.B) { benchAccumulate(b, false) }
func BenchmarkAccumulateMFD(b *testing.B) { benchAccumulate(b, true) }
+14 -2
View File
@@ -30,9 +30,16 @@ func (g *Grid) ClampToRepose(h []float32, talus float64) float64 {
for i := range g.closed {
g.closed[i] = false
}
// Pushed with a jittered bucket, not a plain one. The constraint this pass imposes is isotropic; the
// order it imposed it in was not. Every cell went in in flat-index order and the queue pops last-in
// first-out within a bucket, so on ground flat to within a centimetre - which is most of a hillside -
// cells popped bottom-right to top-left, and whichever popped first decided which of its neighbours got
// cut. That is where the grid-aligned pyramid faces came from, and it is one hash away from not being
// there. See bucketpq.go.
g.pq.reset()
for i := 0; i < n; i++ {
g.pq.push(h[i], int32(i))
x, y := i%g.W, i/g.W
g.pq.pushJittered(h[i], int32(i), (hashXY(g.seed, g.worldX(x), g.worldY(y), jitterReposeOrder)-0.5)*2*reposeOrderBuckets)
}
card := talus * g.CellM
@@ -62,11 +69,16 @@ func (g *Grid) ClampToRepose(h []float32, talus float64) float64 {
if dx8[k] != 0 && dy8[k] != 0 {
allow = diag
}
// The same tie-break ComputeReceivers uses and for the same reason: a fixed allowance resolves
// every near-tie the same way and prints its preferred axis. A tenth of a percent, keyed on the
// cell being cut, so what a cell is allowed in a direction does not depend on which neighbour
// reached it first.
allow *= 1 + 1e-3*(float64(hashXY(g.seed, g.worldX(nx), g.worldY(ny), int32(k)+jitterReposeAllow))-0.5)
limit := h[c] + float32(allow)
if h[ni] > limit {
removed += float64(h[ni] - limit)
h[ni] = limit
g.pq.push(limit, ni)
g.pq.pushJittered(limit, ni, (hashXY(g.seed, g.worldX(nx), g.worldY(ny), jitterReposeOrder)-0.5)*2*reposeOrderBuckets)
}
}
}
@@ -48,3 +48,115 @@ func TestClampToReposeCutsACone(t *testing.T) {
t.Errorf("steepest slope %.3f exceeds repose %.3f", worst, talus)
}
}
// TestClampToReposeIsIsotropic asks what shape is left, which the test above cannot: a four-sided pyramid
// satisfies "no slope exceeds repose" exactly, so the constraint check says nothing about whether the clamp
// cut a cone or cut a pyramid.
//
// Clamp a cone far above repose, then for each of 360 azimuths find by bisection the radius at which the
// surface falls through a fixed height. A cone gives a constant radius; the amplitudes of the four-fold and
// eight-fold Fourier components of that radius, as a fraction of its mean, say how far from one it is.
//
// What the numbers turn out to be, and what they are not. Measured 0.97 % four-fold and 2.39 % eight-fold -
// and *identical* with the pop-order jitter, with the allowance jitter, with both and with neither. On a cone
// no two cells share a bucket, because the surface falls twenty metres a cell against a one-centimetre
// bucket, so the ordering bias this file's jitter removes has nothing to bite on here. The residual is
// geometry: a path to a point at 22.5 degrees has to be built of cardinal and diagonal steps, and the octile
// distance it accumulates exceeds the straight line by up to 8 %, so an eight-connected clamp cuts an
// octagon out of a cone whatever order it works in. That is irreducible without a wider neighbourhood, and
// the thresholds below sit above it: this test guards against a regression to something far worse, and
// TestBucketPQDoesNotPreferRasterOrder is what actually holds the ordering honest.
func TestClampToReposeIsIsotropic(t *testing.T) {
const (
w, h = 201, 201
cellM = 10.0
talus = 0.4
level = 300.0
)
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, 2000-2.0*d))
}
}
g := NewGrid(w, h, cellM, make([]bool, w*h))
g.SetSeed(37125)
g.SetElevationRange(-100, 4000)
g.ClampToRepose(field, talus)
at := func(fx, fy float64) float64 { // bilinear, in cells
x0, y0 := int(fx), int(fy)
if x0 < 0 || y0 < 0 || x0 >= w-1 || y0 >= h-1 {
return 0
}
tx, ty := fx-float64(x0), fy-float64(y0)
return (1-ty)*((1-tx)*float64(field[y0*w+x0])+tx*float64(field[y0*w+x0+1])) +
ty*((1-tx)*float64(field[(y0+1)*w+x0])+tx*float64(field[(y0+1)*w+x0+1]))
}
const rays = 360
var sum, c4r, c4i, c8r, c8i float64
for i := 0; i < rays; i++ {
th := 2 * math.Pi * float64(i) / rays
cs, sn := math.Cos(th), math.Sin(th)
lo, hi := 0.0, float64(w/2-2)
for n := 0; n < 40; n++ { // bisect on the radius where the surface crosses `level`
mid := (lo + hi) / 2
if at(float64(w/2)+mid*cs, float64(h/2)+mid*sn) > level {
lo = mid
} else {
hi = mid
}
}
r := (lo + hi) / 2
sum += r
c4r += r * math.Cos(4*th)
c4i += r * math.Sin(4*th)
c8r += r * math.Cos(8*th)
c8i += r * math.Sin(8*th)
}
a4 := 2 * math.Hypot(c4r, c4i) / sum
a8 := 2 * math.Hypot(c8r, c8i) / sum
t.Logf("clamped cone: mean radius %.2f cells, four-fold %.2f%%, eight-fold %.2f%%",
sum/rays, a4*100, a8*100)
if a4 > 0.02 || a8 > 0.04 {
t.Errorf("the clamped cone is %.2f%% four-fold and %.2f%% eight-fold against 0.97 and 2.39 measured: "+
"it is a pyramid, not an octagon", a4*100, a8*100)
}
}
// TestBucketPQDoesNotPreferRasterOrder is the unit underneath it. Pushed plain, cells at one elevation come
// back in exactly reverse insertion order, which is a Spearman correlation of -1.
func TestBucketPQDoesNotPreferRasterOrder(t *testing.T) {
const n = 4096
order := func(jitter bool) float64 {
q := newBucketPQ(0, 100)
for i := 0; i < n; i++ {
if jitter {
q.pushJittered(50, int32(i), (hashXY(1, int32(i%64), int32(i/64), jitterReposeOrder)-0.5)*2*reposeOrderBuckets)
} else {
q.push(50, int32(i))
}
}
var sum float64
for pos := 0; pos < n; pos++ {
idx := float64(q.pop())
sum += (float64(pos) - float64(n-1)/2) * (idx - float64(n-1)/2)
}
var varr float64
for i := 0; i < n; i++ {
d := float64(i) - float64(n-1)/2
varr += d * d
}
return sum / varr
}
plain, jittered := order(false), order(true)
t.Logf("pop order against flat index: plain %.3f, jittered %.3f", plain, jittered)
if plain > -0.99 {
t.Errorf("plain push no longer pops in reverse insertion order (%.3f); this test's premise is gone", plain)
}
if math.Abs(jittered) > 0.05 {
t.Errorf("jittered push still correlates with flat index at %.3f", jittered)
}
}