Tooling
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user