Added: Initial world generation tool
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
// Package check holds the generator's integration tests: the ones that need more than one package and so
|
||||
// cannot live in either. There are two, and they are the two that matter.
|
||||
//
|
||||
// Determinism is a promise the project makes (cross-cutting rule 12) and Go is the language most likely to
|
||||
// break it quietly, so it is asserted rather than assumed. Steady state is the physics: if the solver does
|
||||
// not reproduce the analytic stream-power answer on a case with a known answer, every prettier result it
|
||||
// produces is a coincidence.
|
||||
package check
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"math"
|
||||
"runtime"
|
||||
"sort"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"salty/terrain/internal/coast"
|
||||
"salty/terrain/internal/fluvial"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/uplift"
|
||||
)
|
||||
|
||||
func hash(v []float32) string {
|
||||
b := unsafe.Slice((*byte)(unsafe.Pointer(&v[0])), len(v)*4)
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// run is one whole small pipeline: uplift, the solve, then the coast. All three are in it because all three
|
||||
// are parallel, and the coast pass in particular has two places determinism could leak — the fetch rays run
|
||||
// over a slice of the waterline, and the sediment scatter accumulates several land cells into one shore cell,
|
||||
// which is why that scatter is deliberately serial.
|
||||
func run(size, steps int) []float32 {
|
||||
m := manifest.Defaults()
|
||||
m.Source.Seed = 7
|
||||
up := uplift.Build(size, 40, m)
|
||||
h := up.Height.Clone()
|
||||
g := fluvial.NewGrid(size, size, 40, up.Base)
|
||||
g.SetElevationRange(-2000, 4000)
|
||||
g.Run(h.Data, up.Rate.Data, nil, fluvial.Params{
|
||||
K: 5e-5, M: 0.5, N: 1, DtYr: 1500, Steps: steps, Diffusion: 0.02, FillEvery: 1,
|
||||
}, nil)
|
||||
coast.Build(coast.Input{
|
||||
Height: h, Sea: up.Base, SeaLevelM: m.SeaLevelM,
|
||||
BreakM: -m.Pipeline.Continent.SeaFloorM.Hi(), AbyssM: -m.Pipeline.Continent.SeaFloorM.Lo(),
|
||||
Flow: g.Area, Seed: m.Source.Seed, Cfg: m.Pipeline.Coast,
|
||||
})
|
||||
return h.Data
|
||||
}
|
||||
|
||||
// TestDeterministicAcrossGOMAXPROCS is the assertion Docs/Terrain.md makes about the Go core: the result must
|
||||
// be byte-identical however many cores it ran on. Go offers three good ways to break this - randomised map
|
||||
// iteration, goroutine completion order, and a shared global RNG - so it is worth a test rather than a
|
||||
// comment.
|
||||
func TestDeterministicAcrossGOMAXPROCS(t *testing.T) {
|
||||
was := runtime.GOMAXPROCS(1)
|
||||
defer runtime.GOMAXPROCS(was)
|
||||
|
||||
single := run(128, 40)
|
||||
hSingle := hash(single)
|
||||
|
||||
for _, procs := range []int{2, 4, 8, 16} {
|
||||
if procs > runtime.NumCPU()*2 {
|
||||
continue
|
||||
}
|
||||
runtime.GOMAXPROCS(procs)
|
||||
got := hash(run(128, 40))
|
||||
if got != hSingle {
|
||||
t.Fatalf("GOMAXPROCS=%d gave %s, GOMAXPROCS=1 gave %s: the pipeline is not deterministic", procs, got, hSingle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSameSeedSameResult guards the other half of rule 12: a seed names a world.
|
||||
func TestSameSeedSameResult(t *testing.T) {
|
||||
if a, b := hash(run(96, 20)), hash(run(96, 20)); a != b {
|
||||
t.Fatalf("two runs of one seed differ: %s vs %s", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSteadyStateMatchesStreamPower is the physics check. On a uniform uplift field with uniform erodibility,
|
||||
// the analytic steady state of dh/dt = U - K*A^m*S^n is S = (U/K)^(1/n) * A^(-m/n), so K*A^m*S^n / U must be
|
||||
// 1 at every channel cell. Anything systematically off means the implicit update, the drainage accumulation
|
||||
// or the stack order is wrong, and no amount of tuning would fix it.
|
||||
func TestSteadyStateMatchesStreamPower(t *testing.T) {
|
||||
const (
|
||||
size = 160
|
||||
cellM = 50.0
|
||||
k = 1e-4
|
||||
mExp = 0.5
|
||||
nExp = 1.0
|
||||
u = 1e-3 // m/yr
|
||||
)
|
||||
base := make([]bool, size*size) // no ocean: the borders are the outlets
|
||||
h := make([]float32, size*size)
|
||||
rate := make([]float32, size*size)
|
||||
// A little noise so the flow network has something to organise; the answer must not depend on it.
|
||||
seed := uint32(12345)
|
||||
for i := range h {
|
||||
seed = seed*1664525 + 1013904223
|
||||
h[i] = float32(seed>>8&0xffff) / 65535 * 5
|
||||
rate[i] = u
|
||||
}
|
||||
|
||||
g := fluvial.NewGrid(size, size, cellM, base)
|
||||
g.SetElevationRange(-100, 5000)
|
||||
g.Run(h, rate, nil, fluvial.Params{
|
||||
K: k, M: mExp, N: nExp, DtYr: 2000, Steps: 4000, Diffusion: 0, FillEvery: 1,
|
||||
}, nil)
|
||||
|
||||
// Only well-developed channels: headwaters are hillslopes, where stream power is not the whole story
|
||||
// and where the discrete D8 grid quantises slope badly.
|
||||
var ratios []float64
|
||||
threshold := 200 * cellM * cellM
|
||||
for i := range h {
|
||||
r := g.Receiver[i]
|
||||
if int(r) == i || g.Base[i] || float64(g.Area[i]) < threshold {
|
||||
continue
|
||||
}
|
||||
s := float64(h[i]-h[r]) / float64(g.Length[i])
|
||||
if s <= 0 {
|
||||
continue
|
||||
}
|
||||
ratios = append(ratios, k*math.Pow(float64(g.Area[i]), mExp)*math.Pow(s, nExp)/u)
|
||||
}
|
||||
if len(ratios) < 100 {
|
||||
t.Fatalf("only %d channel cells; the solve did not organise a network", len(ratios))
|
||||
}
|
||||
sort.Float64s(ratios)
|
||||
median := ratios[len(ratios)/2]
|
||||
if math.Abs(median-1) > 0.1 {
|
||||
t.Errorf("median K*A^m*S^n/U = %.4f over %d channel cells, want 1.0 +/- 0.1: "+
|
||||
"the solve is not reaching the analytic steady state", median, len(ratios))
|
||||
}
|
||||
t.Logf("steady state check: median ratio %.4f over %d channel cells", median, len(ratios))
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/uplift"
|
||||
)
|
||||
|
||||
// TestBorderIsAlwaysOcean is the boundary condition the whole solve rests on, asserted rather than assumed.
|
||||
//
|
||||
// A cell on the map border is an outlet: `fluvial.isOutlet` returns true for it whatever else is true, so it
|
||||
// takes no uplift and is never eroded, and the repose clamp will not lower it either. Land that reaches the
|
||||
// border therefore freezes at whatever the initial relief gave it while the interior erodes away beneath it,
|
||||
// and what that looks like is a rim of untouched terrain standing a hundred metres over its neighbour — it is
|
||||
// what once reported a 66 degree slope on a map whose angle of repose was 22. `continentMask` imposes a
|
||||
// margin to prevent it, and this test is what says the margin is still doing its job.
|
||||
//
|
||||
// It is worth a test rather than a comment because the margin is easy to break from a distance. The uplift
|
||||
// rate used to be multiplied by the continent mask, so the margin's taper was suppressing uplift at the border
|
||||
// as a side effect; decoupling the two (D-52) removed that, and nothing in the change was anywhere near this
|
||||
// file. The invariant is the border cells themselves, so that is what is asserted; the distances and rates are
|
||||
// reported alongside because they are what a person would want to see when it does fail.
|
||||
func TestBorderIsAlwaysOcean(t *testing.T) {
|
||||
const size = 1400
|
||||
const sideM = 14280.0
|
||||
cell := sideM / (size - 1)
|
||||
|
||||
for _, seed := range []int64{7, 9342, 67914} {
|
||||
m := manifest.Defaults()
|
||||
m.Source.Seed = seed
|
||||
up := uplift.Build(size, cell, m)
|
||||
|
||||
for x := 0; x < size; x++ {
|
||||
for _, i := range [2]int{x, (size-1)*size + x} {
|
||||
if !up.Base[i] {
|
||||
t.Errorf("seed %d: cell (%d,%d) on the top or bottom border is land", seed, i%size, i/size)
|
||||
}
|
||||
}
|
||||
}
|
||||
for y := 0; y < size; y++ {
|
||||
for _, i := range [2]int{y * size, y*size + size - 1} {
|
||||
if !up.Base[i] {
|
||||
t.Errorf("seed %d: cell (%d,%d) on the left or right border is land", seed, i%size, i/size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// How hard the margin is pressing. The margin does not only keep land off the border; where the
|
||||
// continent would have run past the edge it *cuts* it, and it cuts along a contour of distance-to-edge,
|
||||
// which is a straight line parallel to that edge. So a coastline inside the margin band is a coastline
|
||||
// the margin drew rather than one the noise did, and the count is the measure of how much of one there
|
||||
// is. It is logged, not asserted: some is unavoidable and the right threshold is a judgement.
|
||||
band := int(0.04 * size)
|
||||
nearest := size
|
||||
var peakRate, peakHeight float64
|
||||
landInBand, shoreInBand, shore := 0, 0, 0
|
||||
for i := range up.Base {
|
||||
x, y := i%size, i/size
|
||||
d := min(min(x, size-1-x), min(y, size-1-y))
|
||||
if up.Base[i] {
|
||||
// A waterline cell: ocean with land in the four-neighbourhood.
|
||||
for _, n := range [4]int{i - 1, i + 1, i - size, i + size} {
|
||||
if n >= 0 && n < len(up.Base) && !up.Base[n] {
|
||||
shore++
|
||||
if d < band {
|
||||
shoreInBand++
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if d < nearest {
|
||||
nearest = d
|
||||
}
|
||||
if d < band {
|
||||
landInBand++
|
||||
if r := float64(up.Rate.Data[i]) * 1000; r > peakRate {
|
||||
peakRate = r
|
||||
}
|
||||
if h := float64(up.Height.Data[i]); h > peakHeight {
|
||||
peakHeight = h
|
||||
}
|
||||
}
|
||||
}
|
||||
pct := 0.0
|
||||
if shore > 0 {
|
||||
pct = float64(shoreInBand) / float64(shore) * 100
|
||||
}
|
||||
t.Logf("seed %6d: nearest land %d cells (%.0f m) from the border; inside the %d-cell margin, "+
|
||||
"%d land cells, peak uplift %.2f mm/yr, peak initial relief %.0f m; %.1f%% of the shoreline is "+
|
||||
"inside the margin band (a coast the margin drew, not the noise)",
|
||||
seed, nearest, float64(nearest)*cell, band, landInBand, peakRate, peakHeight, pct)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/fluvial"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/thermal"
|
||||
"salty/terrain/internal/uplift"
|
||||
)
|
||||
|
||||
// TestTalusHoldsInThePipeline is the gap between a unit test and a system. thermal.Apply demonstrably cuts a
|
||||
// cone to the angle of repose, and yet a full run with the angle set to 22 degrees reports a median land slope
|
||||
// of 37. One of those two things is wrong and the difference matters: the angle of repose is the only direct
|
||||
// lever on how steep hill country looks.
|
||||
func TestTalusHoldsInThePipeline(t *testing.T) {
|
||||
const (
|
||||
size = 300
|
||||
deg = 22.0
|
||||
steps = 300
|
||||
)
|
||||
passesUnderTest := 3
|
||||
if v := os.Getenv("TALUS_PASSES"); v != "" {
|
||||
passesUnderTest, _ = strconv.Atoi(v)
|
||||
}
|
||||
m := manifest.Defaults()
|
||||
m.Source.Seed = 7
|
||||
cellM := m.SideM() / float64(size-1)
|
||||
up := uplift.Build(size, cellM, m)
|
||||
h := up.Height.Clone()
|
||||
|
||||
g := fluvial.NewGrid(size, size, cellM, up.Base)
|
||||
g.SetElevationRange(-2000, 4000)
|
||||
talus := thermal.TalusFromDegrees(deg)
|
||||
g.Run(h.Data, up.Rate.Data, up.K.Data, fluvial.Params{
|
||||
K: m.Pipeline.Fluvial.K, M: 0.5, N: 1, DtYr: 1500, Steps: steps,
|
||||
Diffusion: 0.02, FillEvery: 1,
|
||||
TalusSlope: talus, ThermalEvery: 1, ThermalPasses: passesUnderTest,
|
||||
}, nil)
|
||||
|
||||
if os.Getenv("CLAMP_AFTER") != "" {
|
||||
t.Logf("removed %.2f m mean by a final clamp", g.ClampToRepose(h.Data, talus))
|
||||
}
|
||||
|
||||
// The steepest neighbour drop anywhere on land, in the same units the angle of repose is written in.
|
||||
worst, count := 0.0, 0
|
||||
wx, wy := 0, 0
|
||||
worstOther := float32(0)
|
||||
worstOtherBase := false
|
||||
for y := 1; y < size-1; y++ {
|
||||
for x := 1; x < size-1; x++ {
|
||||
i := y*size + x
|
||||
if up.Base[i] {
|
||||
continue
|
||||
}
|
||||
for _, d := range [][2]int{{1, 0}, {0, 1}, {1, 1}, {1, -1}} {
|
||||
ni := (y+d[1])*size + (x + d[0])
|
||||
if up.Base[ni] {
|
||||
continue
|
||||
}
|
||||
dist := cellM
|
||||
if d[0] != 0 && d[1] != 0 {
|
||||
dist = cellM * math.Sqrt2
|
||||
}
|
||||
if s := math.Abs(float64(h.Data[i]-h.Data[ni])) / dist; s > worst {
|
||||
worst = s
|
||||
wx, wy = x, y
|
||||
worstOther = h.Data[ni]
|
||||
worstOtherBase = up.Base[ni]
|
||||
}
|
||||
}
|
||||
if count++; false {
|
||||
_ = count
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("worst pair at (%d,%d): h=%.1f vs h=%.1f, base=%v/%v", wx, wy, h.Data[wy*size+wx], worstOther, up.Base[wy*size+wx], worstOtherBase)
|
||||
gotDeg := math.Atan(worst) * 180 / math.Pi
|
||||
t.Logf("steepest land slope after %d steps: %.1f deg (repose %.1f, cell %.0f m)", steps, gotDeg, deg, cellM)
|
||||
if gotDeg > deg+5 {
|
||||
t.Errorf("steepest land slope is %.1f deg with the angle of repose at %.1f: landsliding is not binding", gotDeg, deg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
// Package coast is what happens where the land meets the sea.
|
||||
//
|
||||
// Until this pass existed the coastline was only a *line*: the continent mask said which cells were ocean,
|
||||
// the fluvial solve held those cells at sea level as its base level, and afterwards the sea floor was dropped
|
||||
// to a flat plane 180 m down in one step. That is enough to give the solve a well-posed boundary — which is
|
||||
// why D-48 kept the continent — and it is not a coast. There was no shelf, so a third of the map was a flat
|
||||
// plane occupying a third of the elevation range; there was no surf, so the land met the water at whatever
|
||||
// angle the last erosion step happened to leave it; and there was no sediment, so every bay was as deep as
|
||||
// every headland was steep.
|
||||
//
|
||||
// # The three things this pass adds, and why each is a process rather than a shape
|
||||
//
|
||||
// 1. The shelf. A continental margin is a shelf at a very gentle grade out to a shelf break and then a much
|
||||
// steeper slope to the abyssal floor. The width of the shelf is not a constant: it is wide off a low
|
||||
// coastal plain and narrow where a range comes down to the water. So it is read off the relief standing
|
||||
// behind each stretch of shore rather than set, and the same manifest numbers then produce a wide shelf
|
||||
// on a passive coast and a narrow one on an active coast without either having been asked for.
|
||||
//
|
||||
// 2. The surf. Within a reach of the waterline the land is planed towards a shore platform. The reach is set
|
||||
// by how open the water is, so an exposed headland is attacked further inland than the back of a bay. The
|
||||
// cliff is not drawn: it is the step where the reach ends, and its height is whatever the land behind it
|
||||
// happened to stand at. That is the right way round — a sea cliff is tall because the land is tall, not
|
||||
// because a constant says so.
|
||||
//
|
||||
// 3. The sediment. What the surf cuts is counted, carried along the shore, and laid down in sheltered water
|
||||
// shallower than a few tens of metres: beaches and bars in the bays, nothing on the headlands. Rivers
|
||||
// deliver their own load at their mouths in proportion to what they drain, which is what makes a delta.
|
||||
// Mass is conserved to within the drift kernel's edges, and what will not fit under the berm is reported
|
||||
// rather than quietly dropped.
|
||||
//
|
||||
// # Why it runs after the solve and not before
|
||||
//
|
||||
// Two of the three need the finished terrain: the shelf width is a function of the relief behind the shore,
|
||||
// and the surf cuts into whatever the solve built. The third could run before but would then be erased. So
|
||||
// this pass owns the sea floor outright — uplift.Build no longer produces a bathymetry field — and it is the
|
||||
// last thing that touches the geology grid.
|
||||
//
|
||||
// The one invariant it must not break: the sea floor is laid after the solve, never during it. A coastal cell
|
||||
// drains into an ocean cell, and if that ocean cell sits at -180 m then the solver cuts the river down to
|
||||
// -180 m; the first run with a coast eroded the land to 174 m below sea level for exactly that reason. A
|
||||
// river's base level is sea level, and what the sea floor does below that is scenery.
|
||||
package coast
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/noise"
|
||||
)
|
||||
|
||||
// srcShelf is this pass's noise stream. Pass indices are fixed and never reordered so that inserting a pass
|
||||
// does not reshuffle the ones before it; uplift owns 1 to 9, so the coast starts at 10.
|
||||
const srcShelf = 10
|
||||
|
||||
// backshoreM is how far inland the shelf looks for the relief that decides its width. Not a manifest key: it
|
||||
// is the length over which "the land behind this beach" means anything, and 600 m is one hillside.
|
||||
const backshoreM = 600
|
||||
|
||||
// The two anchors of the fetch scale, in fractions of the fetch range the seaward rays travelled. They are
|
||||
// properties of how fetch is measured rather than of a world, which is why they are constants and not keys:
|
||||
// a coast whose seaward rays nearly all run to the horizon is open however big the map is.
|
||||
const (
|
||||
shelteredFetch = 0.35
|
||||
openFetch = 0.90
|
||||
)
|
||||
|
||||
// shelfSmoothM is how far the carried shelf width is smoothed along the coast. See shelfWidth.
|
||||
const shelfSmoothM = 700
|
||||
|
||||
// exposureSmoothM is how far the carried fetch is smoothed along the coast. Shorter than the shelf's, because
|
||||
// exposure is only read within a few hundred metres of the water and smoothing it over more than the reach of
|
||||
// the processes that use it would flatten the very contrast it exists to provide.
|
||||
const exposureSmoothM = 250
|
||||
|
||||
// shelterFloor is how much sediment the most exposed water will still take.
|
||||
//
|
||||
// Shelter cannot be a gate. Measured on the real continent with no floor, 73 % of the sediment budget came
|
||||
// back unplaced, because the seaward rays from an ordinary stretch of coast nearly all run to the horizon and
|
||||
// it therefore scores as fully exposed — and a fully exposed coast with no floor wants nothing at all. Real
|
||||
// exposed coasts do have beaches; what they do not have is *more* sand than the bay next door. So the floor
|
||||
// keeps the contrast, which is the part that matters, and stops the budget falling on the floor.
|
||||
const shelterFloor = 0.15
|
||||
|
||||
// platformResidualCapM bounds what CutFraction may leave standing on the shore platform.
|
||||
//
|
||||
// CutFraction below 1 exists so the platform is not glass, and the obvious reading — leave that fraction of
|
||||
// the height above the target — is wrong in a way that only shows on a tall coast: 15 % of a 120 m headland is
|
||||
// 18 m, which is not a rough platform, it is an uncut headland. The residual is therefore a few metres at
|
||||
// most, whatever the coast behind it stands at.
|
||||
const platformResidualCapM = 4
|
||||
|
||||
// Input is everything the pass needs. Height is modified in place.
|
||||
type Input struct {
|
||||
Height *field.Field
|
||||
Sea []bool // the continent mask's ocean: the cells the solve held at base level
|
||||
SeaLevelM float64 // the base level the solve used, and the datum every depth here is measured from
|
||||
BreakM float64 // depth at the shelf break, positive metres
|
||||
AbyssM float64 // depth of the abyssal floor, positive metres
|
||||
Flow []float32
|
||||
Seed int64
|
||||
Cfg manifest.Coast
|
||||
}
|
||||
|
||||
// Result is the geometry the pass built and the accounting it kept.
|
||||
type Result struct {
|
||||
Geometry *Geometry
|
||||
Exposure *field.Field // 0 sheltered, 1 open water; defined on every cell through Geometry.Ref
|
||||
Change *field.Field // metres this pass moved: negative where the surf cut, positive where it laid
|
||||
Sea []bool // the mask as it now stands: everything strictly below sea level
|
||||
Stats Stats
|
||||
}
|
||||
|
||||
// Stats is the pass's own report. The volumes are the point: the sediment budget is the one part of this that
|
||||
// is not derived from something already measured, so it is printed rather than assumed.
|
||||
type Stats struct {
|
||||
ShorelineKm float64 `json:"shoreline_km"`
|
||||
SeaFraction float64 `json:"sea_fraction"`
|
||||
ShelfPctSea float64 `json:"shelf_pct_of_sea"`
|
||||
CutM3 float64 `json:"surf_cut_m3"`
|
||||
RiverM3 float64 `json:"river_load_m3"`
|
||||
LaidM3 float64 `json:"laid_m3"`
|
||||
UnplacedM3 float64 `json:"unplaced_m3"`
|
||||
RiverMouths int `json:"river_mouths"`
|
||||
PlanedKm2 float64 `json:"planed_km2"`
|
||||
BeachKm2 float64 `json:"beach_km2"`
|
||||
DrownedKm2 float64 `json:"drowned_km2"`
|
||||
// How high the land stands immediately behind the surf strip, which is the cliff when there is one.
|
||||
//
|
||||
// The first version of this measured the drop from a cell to its seaward neighbour and called that the
|
||||
// cliff. That is a gradient, not a height: at the angle of repose one cell of a 10 m grid is 7 m, so the
|
||||
// number could not exceed 7 whatever the coast did, and it read 2 m on a plain coast and 3 m on a coast
|
||||
// with genuine cliffs on it. A cliff is how far you fall, not how steep the first cell is.
|
||||
BackshoreM float64 `json:"backshore_m"`
|
||||
BackshoreP90M float64 `json:"backshore_p90_m"`
|
||||
// Exposure percentiles over the waterline cells, which is where it is measured and the only place it
|
||||
// means anything. A coast that is all 1.0 has no bays as far as the fetch can tell, and then neither the
|
||||
// surf reach nor the shelter is doing any work; a coast that is all 0 means the anchors are wrong. This
|
||||
// is the diagnostic to read before touching either.
|
||||
ExposureP10 float64 `json:"exposure_p10"`
|
||||
ExposureP50 float64 `json:"exposure_p50"`
|
||||
ExposureP90 float64 `json:"exposure_p90"`
|
||||
}
|
||||
|
||||
func (s Stats) Summary() string {
|
||||
return fmt.Sprintf(
|
||||
"coast: %.0f km of shoreline, %.0f%% sea, shelf %.0f%% of it; surf planed %.1f km2 and cut %.2f Mm3,\n"+
|
||||
" %d river mouths delivered %.2f Mm3, %.2f Mm3 laid (%.0f%% unplaced) as %.2f km2 of new beach;\n"+
|
||||
" backshore %.0f m median, %.0f m P90, %.2f km2 drowned; exposure %.2f / %.2f / %.2f (p10/p50/p90)",
|
||||
s.ShorelineKm, s.SeaFraction*100, s.ShelfPctSea, s.PlanedKm2, s.CutM3/1e6,
|
||||
s.RiverMouths, s.RiverM3/1e6, s.LaidM3/1e6, pct(s.UnplacedM3, s.CutM3+s.RiverM3), s.BeachKm2,
|
||||
s.BackshoreM, s.BackshoreP90M, s.DrownedKm2, s.ExposureP10, s.ExposureP50, s.ExposureP90)
|
||||
}
|
||||
|
||||
func pct(a, b float64) float64 {
|
||||
if b <= 0 {
|
||||
return 0
|
||||
}
|
||||
return a / b * 100
|
||||
}
|
||||
|
||||
// Build lays the sea floor, cuts the shore and moves what it cuts. Height is modified in place.
|
||||
func Build(in Input) *Result {
|
||||
h := in.Height
|
||||
w, ht := h.W, h.H
|
||||
cellArea := h.CellM * h.CellM
|
||||
|
||||
g := Measure(in.Sea, w, ht, h.CellM)
|
||||
res := &Result{Geometry: g, Exposure: field.NewLike(h), Change: field.NewLike(h)}
|
||||
|
||||
// Disabled, or a map with no coast on it: the sea floor is the flat plane at the abyssal depth, which is
|
||||
// what the generator produced before this pass existed. Everything below is skipped.
|
||||
if !in.Cfg.Enabled || len(g.Waterline) == 0 {
|
||||
for i := range in.Sea {
|
||||
if in.Sea[i] {
|
||||
h.Data[i] = float32(in.SeaLevelM - in.AbyssM)
|
||||
}
|
||||
}
|
||||
res.finish(h.Clone(), in)
|
||||
return res
|
||||
}
|
||||
|
||||
shelfW := shelfWidth(h, g, in)
|
||||
layShelf(h, g, in, shelfW)
|
||||
|
||||
// The before-and-after is taken here, after the sea floor and before the two shore processes. Taken any
|
||||
// earlier it would be a map of the sea floor: the ocean cells go from sea level to -180 m in one step, and
|
||||
// a few hundred metres of that swamps the few metres the surf and the sediment move, which is the thing
|
||||
// the map exists to show.
|
||||
before := h.Clone()
|
||||
|
||||
shoreExposure := fetch(g, in)
|
||||
res.Stats.ExposureP10, res.Stats.ExposureP50, res.Stats.ExposureP90 = shorePercentiles(shoreExposure, g)
|
||||
carried := field.NewLike(h)
|
||||
for i, ref := range g.Ref {
|
||||
if ref >= 0 {
|
||||
carried.Data[i] = shoreExposure.Data[ref]
|
||||
}
|
||||
}
|
||||
// Smoothed for the same reason the shelf width is: carrying a per-shore value by "the stretch nearest to
|
||||
// you" partitions the map into Voronoi wedges, and a wedge boundary inside the deposition band would put
|
||||
// a straight edge through a beach.
|
||||
res.Exposure = boxMean(carried, int(exposureSmoothM/h.CellM+0.5), 2)
|
||||
|
||||
cut := plane(h, g, res.Exposure, in)
|
||||
|
||||
// The scatter into the supply array is serial and in index order on purpose: several land cells share a
|
||||
// waterline cell, so a parallel loop would be accumulating into the same slot from several goroutines and
|
||||
// the float sum would depend on who got there first. Cross-cutting rule 12 is not negotiable here, and
|
||||
// one linear pass over the grid costs nothing next to the solve.
|
||||
supply := make([]float64, w*ht)
|
||||
var cutM3, planedCells float64
|
||||
for i, c := range cut.Data {
|
||||
if c <= 0 {
|
||||
continue
|
||||
}
|
||||
v := float64(c) * cellArea
|
||||
supply[g.Ref[i]] += v
|
||||
cutM3 += v
|
||||
planedCells++
|
||||
}
|
||||
backshore, backshoreP90 := measureBackshore(h, g, in)
|
||||
|
||||
riverM3, mouths := rivers(g, in, supply)
|
||||
laid, unplaced := deposit(h, g, res.Exposure, in, supply)
|
||||
|
||||
res.Stats.CutM3 = cutM3
|
||||
res.Stats.RiverM3 = riverM3
|
||||
res.Stats.RiverMouths = mouths
|
||||
res.Stats.LaidM3 = laid
|
||||
res.Stats.UnplacedM3 = unplaced
|
||||
res.Stats.PlanedKm2 = planedCells * cellArea / 1e6
|
||||
res.Stats.BackshoreM = backshore
|
||||
res.Stats.BackshoreP90M = backshoreP90
|
||||
res.Stats.ShelfPctSea = shelfFraction(g, in, shelfW)
|
||||
res.finish(before, in)
|
||||
return res
|
||||
}
|
||||
|
||||
// finish computes the mask the rest of the run should use, and the before-and-after difference.
|
||||
//
|
||||
// The mask is "strictly below sea level", not the continent mask it started from, and that is the point: a
|
||||
// beach the pass built out of cliff debris is land, and a low headland it planed under the waterline is not.
|
||||
// The statistics and the preview both ask what is above sea level, so they get an answer about the terrain
|
||||
// rather than about the mask that seeded it.
|
||||
func (r *Result) finish(before *field.Field, in Input) {
|
||||
h := in.Height
|
||||
r.Sea = make([]bool, len(h.Data))
|
||||
sea, beach, drowned := 0, 0, 0
|
||||
for i := range h.Data {
|
||||
r.Change.Data[i] = h.Data[i] - before.Data[i]
|
||||
r.Sea[i] = float64(h.Data[i]) < in.SeaLevelM
|
||||
if r.Sea[i] {
|
||||
sea++
|
||||
if !in.Sea[i] {
|
||||
drowned++
|
||||
}
|
||||
} else if in.Sea[i] {
|
||||
beach++
|
||||
}
|
||||
}
|
||||
n := float64(len(h.Data))
|
||||
cellArea := h.CellM * h.CellM
|
||||
r.Stats.SeaFraction = float64(sea) / n
|
||||
r.Stats.BeachKm2 = float64(beach) * cellArea / 1e6
|
||||
r.Stats.DrownedKm2 = float64(drowned) * cellArea / 1e6
|
||||
r.Stats.ShorelineKm = r.Geometry.ShoreM / 1000
|
||||
}
|
||||
|
||||
// shelfWidth is metres of shelf for every cell: measured on the waterline from the relief standing behind it,
|
||||
// carried out to sea by the nearest-shore reference, and then smoothed.
|
||||
//
|
||||
// The inland direction comes from the gradient of the signed distance field rather than from the eight-way
|
||||
// step to the nearest land cell: the distance field is smooth, so the march does not stagger along the grid
|
||||
// axes and the widths do not come out banded.
|
||||
//
|
||||
// The smoothing is not cosmetic. Carrying a per-shore quantity out to sea by "the stretch nearest to you"
|
||||
// partitions the ocean into Voronoi wedges, and a wedge boundary is a discontinuity that runs for kilometres:
|
||||
// the first render of the change map came out as a sunburst of straight rays radiating from every headland,
|
||||
// which is a map of the feature transform rather than of a sea floor. Blurring the carried field over a few
|
||||
// hundred metres turns the wedge boundaries back into what they should have been, a shelf whose width varies
|
||||
// smoothly along the coast.
|
||||
func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
|
||||
out := field.NewLike(h)
|
||||
steps := int(backshoreM/h.CellM + 0.5)
|
||||
lo := in.Cfg.ShelfKm.Lo() * 1000
|
||||
hi := in.Cfg.ShelfKm.Hi() * 1000
|
||||
steep := in.Cfg.SteepCoastM
|
||||
if steep <= 0 {
|
||||
steep = 1
|
||||
}
|
||||
field.Rows(len(g.Waterline), func(a, b int) {
|
||||
for n := a; n < b; n++ {
|
||||
i := int(g.Waterline[n])
|
||||
x, y := i%g.W, i/g.W
|
||||
dx := float64(g.Dist.AtClamped(x+1, y) - g.Dist.AtClamped(x-1, y))
|
||||
dy := float64(g.Dist.AtClamped(x, y+1) - g.Dist.AtClamped(x, y-1))
|
||||
l := math.Hypot(dx, dy)
|
||||
if l < 1e-6 {
|
||||
dx, dy, l = 1, 0, 1
|
||||
}
|
||||
dx, dy = dx/l, dy/l
|
||||
var relief float64
|
||||
for t := 1; t <= steps; t++ {
|
||||
px := x + int(math.Round(dx*float64(t)))
|
||||
py := y + int(math.Round(dy*float64(t)))
|
||||
if px < 0 || py < 0 || px >= g.W || py >= g.H {
|
||||
break
|
||||
}
|
||||
if e := float64(h.Data[py*g.W+px]) - in.SeaLevelM; e > relief {
|
||||
relief = e
|
||||
}
|
||||
}
|
||||
t := relief / steep
|
||||
if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
out.Data[i] = float32(hi + (lo-hi)*noise.Smoothstep(t))
|
||||
}
|
||||
})
|
||||
|
||||
carried := field.NewLike(h)
|
||||
for i, ref := range g.Ref {
|
||||
if ref >= 0 {
|
||||
carried.Data[i] = out.Data[ref]
|
||||
} else {
|
||||
carried.Data[i] = float32(hi)
|
||||
}
|
||||
}
|
||||
return boxMean(carried, int(shelfSmoothM/h.CellM+0.5), 2)
|
||||
}
|
||||
|
||||
// layShelf writes the sea floor: a gentle shelf out to the break, then the continental slope to the abyss.
|
||||
//
|
||||
// The roughness is scaled by depth so it dies out at the waterline. Without that it puts metre-scale noise on
|
||||
// water a few centimetres deep and the shallows come out as a scatter of one-cell islands, which then read as
|
||||
// land in every statistic downstream.
|
||||
func layShelf(h *field.Field, g *Geometry, in Input, shelfW *field.Field) {
|
||||
cfg := in.Cfg
|
||||
period := cfg.RoughWaveM * 256
|
||||
u, v := noise.WorldUV(g.W, g.H, h.CellM, 0, 0, period)
|
||||
rough := noise.FBMAt(u, v, noise.NewSource(in.Seed, srcShelf),
|
||||
noise.Params{BaseCells: 256, Octaves: 3, Gain: 0.5})
|
||||
|
||||
exp := cfg.ShelfExponent
|
||||
if exp <= 0 {
|
||||
exp = 1
|
||||
}
|
||||
slopeW := cfg.SlopeKm * 1000
|
||||
if slopeW <= 0 {
|
||||
slopeW = 1
|
||||
}
|
||||
field.Rows(g.H, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < g.W; x++ {
|
||||
i := y*g.W + x
|
||||
if !in.Sea[i] {
|
||||
continue
|
||||
}
|
||||
d := -float64(g.Dist.Data[i]) // metres offshore
|
||||
width := float64(shelfW.Data[i])
|
||||
if width <= 0 {
|
||||
width = cfg.ShelfKm.Hi() * 1000
|
||||
}
|
||||
var depth float64
|
||||
if d < width {
|
||||
depth = in.BreakM * math.Pow(d/width, exp)
|
||||
} else {
|
||||
t := (d - width) / slopeW
|
||||
if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
depth = in.BreakM + (in.AbyssM-in.BreakM)*noise.Smoothstep(t)
|
||||
}
|
||||
taper := depth / 10
|
||||
if taper > 1 {
|
||||
taper = 1
|
||||
}
|
||||
r := (float64(rough.Data[i])*2 - 1) * cfg.RoughnessM * taper
|
||||
h.Data[i] = float32(in.SeaLevelM - depth + r)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// fetch is how open the water is in front of each waterline cell: rays cast seaward until they hit land,
|
||||
// weighted by the cosine of their angle from the shore normal, and averaged.
|
||||
//
|
||||
// Two things about that sentence are the whole of it, and the first version got both wrong.
|
||||
//
|
||||
// **Only seaward.** Casting in every direction counts the land *behind* the shore as shelter, and every coast
|
||||
// has land behind it — so a straight open coast, where seven rays in sixteen stop after one cell, scored as
|
||||
// more sheltered than the back of a bay whose walls are half a kilometre off. Restricting to the half-space
|
||||
// the shore faces, weighted by the cosine of the angle from the normal, is the standard effective fetch and it
|
||||
// gets the sign right: open coast near 1, embayment well below it, enclosed inlet near 0.
|
||||
//
|
||||
// **Absolute, not a percentile.** The first version stretched the map's own 5th to 95th percentile onto 0..1,
|
||||
// which is robust and which collapses to nonsense on a coast that does not vary — a perfectly straight one has
|
||||
// no spread, so every cell of it came out at the same end of the scale and the whole continent read as one
|
||||
// sheltered lagoon. A percentile is also a global statistic, which rule 1 of the tiling plan rules out: two
|
||||
// tiles would stretch by different anchors and their shared bay would be two different colours. So the
|
||||
// anchors are fixed and physical, and the units are "fraction of the fetch range the rays got".
|
||||
func fetch(g *Geometry, in Input) *field.Field {
|
||||
out := field.New(g.W, g.H, g.CellM)
|
||||
dirs := in.Cfg.FetchDirections
|
||||
if dirs < 4 {
|
||||
dirs = 4
|
||||
}
|
||||
maxSteps := int(in.Cfg.FetchRangeM / g.CellM)
|
||||
if maxSteps < 2 {
|
||||
maxSteps = 2
|
||||
}
|
||||
cs := make([]float64, dirs)
|
||||
sn := make([]float64, dirs)
|
||||
for k := 0; k < dirs; k++ {
|
||||
th := 2 * math.Pi * float64(k) / float64(dirs)
|
||||
cs[k], sn[k] = math.Cos(th), math.Sin(th)
|
||||
}
|
||||
field.Rows(len(g.Waterline), func(a, b int) {
|
||||
for n := a; n < b; n++ {
|
||||
i := int(g.Waterline[n])
|
||||
x0, y0 := i%g.W, i/g.W
|
||||
// The seaward normal: the distance field increases inland, so its gradient points away from the
|
||||
// water and the negative of it is the direction this stretch of shore faces.
|
||||
nx := -float64(g.Dist.AtClamped(x0+1, y0) - g.Dist.AtClamped(x0-1, y0))
|
||||
ny := -float64(g.Dist.AtClamped(x0, y0+1) - g.Dist.AtClamped(x0, y0-1))
|
||||
if l := math.Hypot(nx, ny); l > 1e-6 {
|
||||
nx, ny = nx/l, ny/l
|
||||
} else {
|
||||
nx, ny = 0, 0 // no usable normal: fall back to the whole circle
|
||||
}
|
||||
var num, den float64
|
||||
for k := 0; k < dirs; k++ {
|
||||
w := cs[k]*nx + sn[k]*ny
|
||||
if nx == 0 && ny == 0 {
|
||||
w = 1
|
||||
} else if w <= 0 {
|
||||
continue
|
||||
}
|
||||
reach := maxSteps
|
||||
for t := 1; t <= maxSteps; t++ {
|
||||
px := x0 + int(math.Round(cs[k]*float64(t)))
|
||||
py := y0 + int(math.Round(sn[k]*float64(t)))
|
||||
if px < 0 || py < 0 || px >= g.W || py >= g.H {
|
||||
break // off the map is open water, and the mask keeps the border at sea
|
||||
}
|
||||
if !in.Sea[py*g.W+px] {
|
||||
reach = t
|
||||
break
|
||||
}
|
||||
}
|
||||
num += w * float64(reach) / float64(maxSteps)
|
||||
den += w
|
||||
}
|
||||
raw := 1.0
|
||||
if den > 0 {
|
||||
raw = num / den
|
||||
}
|
||||
t := (raw - shelteredFetch) / (openFetch - shelteredFetch)
|
||||
if t < 0 {
|
||||
t = 0
|
||||
} else if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
out.Data[i] = float32(noise.Smoothstep(t))
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// shorePercentiles reports the fetch distribution over the waterline itself, before it is carried anywhere.
|
||||
func shorePercentiles(shore *field.Field, g *Geometry) (p10, p50, p90 float64) {
|
||||
if len(g.Waterline) == 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
vals := make([]float64, 0, len(g.Waterline))
|
||||
for _, i := range g.Waterline {
|
||||
vals = append(vals, float64(shore.Data[i]))
|
||||
}
|
||||
sort.Float64s(vals)
|
||||
at := func(f float64) float64 {
|
||||
i := int(float64(len(vals)-1) * f)
|
||||
return vals[i]
|
||||
}
|
||||
return at(0.10), at(0.50), at(0.90)
|
||||
}
|
||||
|
||||
// plane cuts the shore platform and returns how much it took off each cell, in metres.
|
||||
//
|
||||
// The shape is deliberate. Within the reach the land is planed nearly all the way to the platform, and only
|
||||
// over the last quarter of the reach is the cut rolled off — so the profile is a gentle platform, then a short
|
||||
// steep face, then untouched land. That face is the cliff. Rolling the cut off over the whole reach instead
|
||||
// would give a ramp, which is what a coast looks like when someone has smoothed it rather than eroded it.
|
||||
func plane(h *field.Field, g *Geometry, exposure *field.Field, in Input) *field.Field {
|
||||
cut := field.NewLike(h)
|
||||
cfg := in.Cfg
|
||||
field.Rows(g.H, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < g.W; x++ {
|
||||
i := y*g.W + x
|
||||
if in.Sea[i] {
|
||||
continue
|
||||
}
|
||||
d := float64(g.Dist.Data[i])
|
||||
e := float64(exposure.Data[i])
|
||||
reach := cfg.SurfReachM * (0.35 + 0.65*e)
|
||||
if reach <= 0 || d >= reach {
|
||||
continue
|
||||
}
|
||||
target := in.SeaLevelM + cfg.PlatformGrade*d
|
||||
above := float64(h.Data[i]) - target
|
||||
if above <= 0 {
|
||||
continue
|
||||
}
|
||||
w := 1.0
|
||||
if tail := reach * 0.25; d > reach-tail {
|
||||
w = noise.Smoothstep((reach - d) / tail)
|
||||
}
|
||||
residual := above * (1 - cfg.CutFraction)
|
||||
if residual > platformResidualCapM {
|
||||
residual = platformResidualCapM
|
||||
}
|
||||
c := (above - residual) * w
|
||||
h.Data[i] -= float32(c)
|
||||
cut.Data[i] = float32(c)
|
||||
}
|
||||
}
|
||||
})
|
||||
return cut
|
||||
}
|
||||
|
||||
// measureBackshore is how high the land stands immediately behind the surf strip: between one and two surf
|
||||
// reaches inland, so it is clear of everything the surf planed whatever the exposure there was.
|
||||
//
|
||||
// The median says what the ordinary coast is — a plain, on this continent, and it should be — and the P90 is
|
||||
// the number that answers "are there sea cliffs anywhere on this map", which a median never can when most of a
|
||||
// coastline is lowland.
|
||||
func measureBackshore(h *field.Field, g *Geometry, in Input) (median, p90 float64) {
|
||||
lo := in.Cfg.SurfReachM
|
||||
hi := lo * 2
|
||||
vals := make([]float64, 0, 4096)
|
||||
for i := range in.Sea {
|
||||
if in.Sea[i] {
|
||||
continue
|
||||
}
|
||||
if d := float64(g.Dist.Data[i]); d < lo || d > hi {
|
||||
continue
|
||||
}
|
||||
vals = append(vals, float64(h.Data[i])-in.SeaLevelM)
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
sort.Float64s(vals)
|
||||
return vals[len(vals)/2], vals[int(float64(len(vals)-1)*0.9)]
|
||||
}
|
||||
|
||||
// rivers adds each river mouth's load to the sediment supply. The load scales with what the river drains,
|
||||
// sub-linearly, because the alternative is one trunk basin delivering more than every other mouth together.
|
||||
//
|
||||
// Only cells orthogonally against the water count as a mouth, so a channel contributes once or twice rather
|
||||
// than along its whole lower course.
|
||||
func rivers(g *Geometry, in Input, supply []float64) (total float64, mouths int) {
|
||||
if in.Flow == nil || in.Cfg.RiverM3PerKm2 <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
threshold := in.Cfg.RiverChannelKm2 * 1e6
|
||||
for i := range in.Sea {
|
||||
if in.Sea[i] || float64(g.Dist.Data[i]) > g.CellM*1.01 {
|
||||
continue
|
||||
}
|
||||
a := float64(in.Flow[i])
|
||||
if a < threshold {
|
||||
continue
|
||||
}
|
||||
v := in.Cfg.RiverM3PerKm2 * math.Pow(a/1e6, in.Cfg.RiverExponent)
|
||||
supply[g.Ref[i]] += v
|
||||
total += v
|
||||
mouths++
|
||||
}
|
||||
return total, mouths
|
||||
}
|
||||
|
||||
// deposit carries the supply along the shore and lays it in sheltered shallow water.
|
||||
//
|
||||
// The transport is a box-kernel spread over DriftM, which is longshore drift at the only fidelity this grid
|
||||
// can carry: it moves sediment out of the place it was cut and into the bays either side of it, and it does
|
||||
// not pretend to know which way the waves run.
|
||||
//
|
||||
// # The order of the two operations, which is the whole of the mass balance
|
||||
//
|
||||
// Each source cell divides what it has among the cells around it in proportion to how much each wants it.
|
||||
// Writing K for the kernel and w for the want, cell i receives
|
||||
//
|
||||
// dep_i = w_i * sum_j K(i,j) * sup_j / Wbar_j, Wbar_j = sum_k K(j,k) w_k
|
||||
//
|
||||
// which sums to exactly sum_j sup_j, because summing over i turns the inner weight back into Wbar_j. In code
|
||||
// that is: divide the supply by the blurred want *first*, then blur, then multiply by the want.
|
||||
//
|
||||
// The obvious-looking alternative — blur the supply, then scale it by w_i / Wbar_i — is not the same thing and
|
||||
// does not conserve. It was what this function did first, and it lost 68 % of the budget: the blur spreads
|
||||
// supply onto land, onto deep water and onto exposed headlands, every one of which has w = 0 and is skipped,
|
||||
// so everything that landed there was silently dropped. The test that caught it is an accounting identity, not
|
||||
// a picture, which is the only kind of test that could have.
|
||||
//
|
||||
// The cap is the berm: nothing is laid more than BermM above sea level, because a beach crests and stops. What
|
||||
// will not fit is offered once more to whatever still has room, and whatever is left after that is reported
|
||||
// rather than dropped.
|
||||
func deposit(h *field.Field, g *Geometry, exposure *field.Field, in Input, supply []float64) (laid, unplaced float64) {
|
||||
cfg := in.Cfg
|
||||
cellArea := h.CellM * h.CellM
|
||||
radius := int(cfg.DriftM/h.CellM + 0.5)
|
||||
if radius < 1 {
|
||||
radius = 1
|
||||
}
|
||||
|
||||
// want is how much each cell of shallow, sheltered water will take. It is the only place sediment may go.
|
||||
want := field.NewLike(h)
|
||||
for i := range h.Data {
|
||||
if !in.Sea[i] {
|
||||
continue
|
||||
}
|
||||
if d := -float64(g.Dist.Data[i]); d > cfg.DepositReachM {
|
||||
continue
|
||||
}
|
||||
depth := in.SeaLevelM - float64(h.Data[i])
|
||||
if depth <= 0 || depth >= cfg.DepositDepthM {
|
||||
continue
|
||||
}
|
||||
shallow := (cfg.DepositDepthM - depth) / cfg.DepositDepthM
|
||||
shelter := shelterFloor + (1-shelterFloor)*math.Pow(1-float64(exposure.Data[i]), cfg.ShelterBias)
|
||||
want.Data[i] = float32(shelter * shallow)
|
||||
}
|
||||
norm := boxBlur(want, radius, 3)
|
||||
|
||||
share := field.NewLike(h)
|
||||
for i, v := range supply {
|
||||
if v <= 0 {
|
||||
continue
|
||||
}
|
||||
nb := float64(norm.Data[i])
|
||||
if nb < 1e-9 {
|
||||
unplaced += v // nowhere within a drift length will take it
|
||||
continue
|
||||
}
|
||||
share.Data[i] = float32(v / nb)
|
||||
}
|
||||
spread := boxBlur(share, radius, 3)
|
||||
|
||||
// place walks the grid in index order, which keeps the running totals deterministic: the writes are to
|
||||
// distinct cells but the sums are not, so this one stays serial.
|
||||
place := func(source *field.Field, scaled bool) (placed, over float64) {
|
||||
for i := range h.Data {
|
||||
w := float64(want.Data[i])
|
||||
if w <= 0 {
|
||||
continue
|
||||
}
|
||||
v := float64(source.Data[i])
|
||||
if scaled {
|
||||
v *= w
|
||||
}
|
||||
if v <= 0 {
|
||||
continue
|
||||
}
|
||||
room := in.SeaLevelM + cfg.BermM - float64(h.Data[i])
|
||||
if room <= 0 {
|
||||
over += v
|
||||
continue
|
||||
}
|
||||
dz := v / cellArea
|
||||
if dz > room {
|
||||
over += (dz - room) * cellArea
|
||||
dz = room
|
||||
}
|
||||
h.Data[i] += float32(dz)
|
||||
placed += dz * cellArea
|
||||
}
|
||||
return placed, over
|
||||
}
|
||||
|
||||
laid, over := place(spread, true)
|
||||
|
||||
// One more round for what would not fit, spread over the whole shore in proportion to want rather than
|
||||
// locally: by this point the material has already been carried as far as the model knows how to carry it.
|
||||
if over > 1 {
|
||||
var wsum float64
|
||||
for i := range h.Data {
|
||||
if want.Data[i] > 0 && float64(h.Data[i]) < in.SeaLevelM+cfg.BermM {
|
||||
wsum += float64(want.Data[i])
|
||||
}
|
||||
}
|
||||
if wsum > 0 {
|
||||
second := field.NewLike(h)
|
||||
for i := range h.Data {
|
||||
if want.Data[i] > 0 && float64(h.Data[i]) < in.SeaLevelM+cfg.BermM {
|
||||
second.Data[i] = float32(over * float64(want.Data[i]) / wsum)
|
||||
}
|
||||
}
|
||||
more, still := place(second, false)
|
||||
laid += more
|
||||
over = still
|
||||
}
|
||||
}
|
||||
return laid, unplaced + over
|
||||
}
|
||||
|
||||
// shelfFraction is how much of the sea is shallower than the break, which is the number that says whether the
|
||||
// margin came out as a shelf or as a trench with a rim.
|
||||
func shelfFraction(g *Geometry, in Input, shelfW *field.Field) float64 {
|
||||
var shelf, sea float64
|
||||
for i := range in.Sea {
|
||||
if !in.Sea[i] {
|
||||
continue
|
||||
}
|
||||
sea++
|
||||
if -float64(g.Dist.Data[i]) < float64(shelfW.Data[i]) {
|
||||
shelf++
|
||||
}
|
||||
}
|
||||
if sea == 0 {
|
||||
return 0
|
||||
}
|
||||
return shelf / sea * 100
|
||||
}
|
||||
|
||||
// boxMean smooths a *value* rather than a quantity: the same kernel, divided by how much of it landed on the
|
||||
// grid, so a cell at the border keeps the average of its neighbours instead of being pulled towards zero.
|
||||
//
|
||||
// The distinction is not pedantic and it cost a test to notice. boxBlur is mass-preserving because it treats
|
||||
// everything off the map as zero, which is right for sediment — there is none out there — and wrong for a
|
||||
// shelf width, where off the map means "no information", not "a shelf of width zero". Smoothing the carried
|
||||
// width with the mass-preserving kernel shrank every shelf near the border to nothing and put the whole
|
||||
// margin below the break. Blurring a field of ones with the same kernel gives exactly the coverage to divide
|
||||
// by, so the two share their arithmetic and cannot drift apart.
|
||||
func boxMean(f *field.Field, radius, passes int) *field.Field {
|
||||
if radius < 1 || passes < 1 {
|
||||
return f.Clone()
|
||||
}
|
||||
ones := field.NewLike(f)
|
||||
ones.Fill(1)
|
||||
sum := boxBlur(f, radius, passes)
|
||||
cover := boxBlur(ones, radius, passes)
|
||||
out := field.NewLike(f)
|
||||
for i := range out.Data {
|
||||
if c := cover.Data[i]; c > 1e-6 {
|
||||
out.Data[i] = sum.Data[i] / c
|
||||
} else {
|
||||
out.Data[i] = f.Data[i]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// boxBlur is a separable running-sum box blur: O(n) whatever the radius, which is what makes a 300 m drift
|
||||
// kernel cost the same as a 30 m one.
|
||||
//
|
||||
// It divides by the full window rather than by however much of the window was on the grid, which is to say it
|
||||
// treats everything outside the map as zero. That choice is what makes the deposition sum come out right. The
|
||||
// mass balance in deposit needs the kernel to be *symmetric* — a cell's share of its neighbour must equal the
|
||||
// neighbour's share of it — and dividing each output by its own truncated window size breaks that symmetry at
|
||||
// the border, which cost 4 % of the sediment budget on a coast that ran off the edge of the map. Zero padding
|
||||
// keeps K(i,j) = K(j,i) everywhere, and a cell outside the map has no want, so nothing is owed to it.
|
||||
func boxBlur(f *field.Field, radius, passes int) *field.Field {
|
||||
cur := f.Clone()
|
||||
if radius < 1 || passes < 1 {
|
||||
return cur
|
||||
}
|
||||
inv := 1 / float64(2*radius+1)
|
||||
next := field.NewLike(f)
|
||||
for p := 0; p < passes; p++ {
|
||||
field.Rows(f.H, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
row := y * f.W
|
||||
var sum float64
|
||||
for x := 0; x <= radius && x < f.W; x++ {
|
||||
sum += float64(cur.Data[row+x])
|
||||
}
|
||||
for x := 0; x < f.W; x++ {
|
||||
next.Data[row+x] = float32(sum * inv)
|
||||
if hi := x + radius + 1; hi < f.W {
|
||||
sum += float64(cur.Data[row+hi])
|
||||
}
|
||||
if lo := x - radius; lo >= 0 {
|
||||
sum -= float64(cur.Data[row+lo])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
cur, next = next, cur
|
||||
field.Rows(f.W, func(x0, x1 int) {
|
||||
for x := x0; x < x1; x++ {
|
||||
var sum float64
|
||||
for y := 0; y <= radius && y < f.H; y++ {
|
||||
sum += float64(cur.Data[y*f.W+x])
|
||||
}
|
||||
for y := 0; y < f.H; y++ {
|
||||
next.Data[y*f.W+x] = float32(sum * inv)
|
||||
if hi := y + radius + 1; hi < f.H {
|
||||
sum += float64(cur.Data[hi*f.W+x])
|
||||
}
|
||||
if lo := y - radius; lo >= 0 {
|
||||
sum -= float64(cur.Data[lo*f.W+x])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
cur, next = next, cur
|
||||
}
|
||||
return cur
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package coast
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/manifest"
|
||||
)
|
||||
|
||||
// TestEdtMatchesBruteForce is the one test the whole package rests on. Everything else is written in terms of
|
||||
// "how far is this cell from the waterline and which stretch does it belong to", so a distance transform that
|
||||
// is subtly wrong would not fail loudly, it would put the shelf break in slightly the wrong place everywhere.
|
||||
// Felzenszwalb's transform is exact, so the comparison is against an exhaustive search and the tolerance is
|
||||
// float32 rounding, not a percentage.
|
||||
func TestEdtMatchesBruteForce(t *testing.T) {
|
||||
const w, h = 41, 37
|
||||
seed := uint32(99)
|
||||
seeds := make([]bool, w*h)
|
||||
for i := range seeds {
|
||||
seed = seed*1664525 + 1013904223
|
||||
seeds[i] = seed>>20&7 == 0
|
||||
}
|
||||
seeds[0] = true // guarantee at least one
|
||||
|
||||
d2, near := edt(seeds, w, h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
best := math.Inf(1)
|
||||
for sy := 0; sy < h; sy++ {
|
||||
for sx := 0; sx < w; sx++ {
|
||||
if !seeds[sy*w+sx] {
|
||||
continue
|
||||
}
|
||||
dx, dy := float64(x-sx), float64(y-sy)
|
||||
if d := dx*dx + dy*dy; d < best {
|
||||
best = d
|
||||
}
|
||||
}
|
||||
}
|
||||
i := y*w + x
|
||||
if math.Abs(float64(d2[i])-best) > 1e-3 {
|
||||
t.Fatalf("cell (%d,%d): d2 %g, brute force %g", x, y, d2[i], best)
|
||||
}
|
||||
// The feature index must be a seed, and it must be one at exactly that distance.
|
||||
n := int(near[i])
|
||||
if n < 0 || !seeds[n] {
|
||||
t.Fatalf("cell (%d,%d): nearest %d is not a seed", x, y, n)
|
||||
}
|
||||
dx, dy := float64(x-n%w), float64(y-n/w)
|
||||
if math.Abs(dx*dx+dy*dy-best) > 1e-3 {
|
||||
t.Fatalf("cell (%d,%d): nearest seed %d is at %g, not %g", x, y, n, dx*dx+dy*dy, best)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignedDistanceIsMetresEitherWay checks the sign convention and the unit on a straight coast, where the
|
||||
// answer is arithmetic. The cells asked about are named explicitly: the map's own border is forced to sea by
|
||||
// the continent mask in a real run, and a test that read the border back would be measuring the boundary
|
||||
// condition rather than the transform.
|
||||
func TestSignedDistanceIsMetresEitherWay(t *testing.T) {
|
||||
const w, h = 60, 20
|
||||
const cellM = 8.0
|
||||
sea := make([]bool, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
sea[y*w+x] = x < 30
|
||||
}
|
||||
}
|
||||
g := Measure(sea, w, h, cellM)
|
||||
|
||||
y := h / 2
|
||||
for _, c := range []struct {
|
||||
x int
|
||||
want float64
|
||||
}{{29, -cellM}, {30, cellM}, {33, 4 * cellM}, {26, -4 * cellM}} {
|
||||
if got := float64(g.Dist.Data[y*w+c.x]); math.Abs(got-c.want) > 1e-3 {
|
||||
t.Errorf("x=%d: distance %.3f m, want %.3f m", c.x, got, c.want)
|
||||
}
|
||||
}
|
||||
// The waterline is the sea side of the boundary, one column of it.
|
||||
for _, i := range g.Waterline {
|
||||
if x := int(i) % w; x != 29 {
|
||||
t.Fatalf("waterline cell at x=%d, want 29", x)
|
||||
}
|
||||
}
|
||||
if len(g.Waterline) != h {
|
||||
t.Errorf("%d waterline cells, want %d", len(g.Waterline), h)
|
||||
}
|
||||
// A straight coast of h cells has h boundary edges.
|
||||
if want := float64(h) * cellM; math.Abs(g.ShoreM-want) > 1e-6 {
|
||||
t.Errorf("shoreline %.1f m, want %.1f m", g.ShoreM, want)
|
||||
}
|
||||
}
|
||||
|
||||
// coastFixture is a straight coast: sea to the left of x=split, a plateau at heightM to the right.
|
||||
func coastFixture(w, h, split int, cellM, heightM float64) (*field.Field, []bool) {
|
||||
f := field.New(w, h, cellM)
|
||||
sea := make([]bool, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
if x < split {
|
||||
sea[i] = true
|
||||
f.Data[i] = 0 // held at sea level by the solve; the pass overwrites it
|
||||
} else {
|
||||
f.Data[i] = float32(heightM)
|
||||
}
|
||||
}
|
||||
}
|
||||
return f, sea
|
||||
}
|
||||
|
||||
func testCfg() manifest.Coast {
|
||||
c := manifest.Defaults().Pipeline.Coast
|
||||
c.RoughnessM = 0 // the profile tests are about the profile, not about the noise on it
|
||||
return c
|
||||
}
|
||||
|
||||
// shelfOnlyCfg silences the surf, which silences the sediment with it: no cut means no supply, and no supply
|
||||
// means the sea floor is the shelf profile and nothing else. Without this the two shelf tests are also testing
|
||||
// the beach the deposition step builds over the top of it, which is a different question and has its own test.
|
||||
func shelfOnlyCfg() manifest.Coast {
|
||||
c := testCfg()
|
||||
c.SurfReachM = 0
|
||||
c.RiverM3PerKm2 = 0
|
||||
return c
|
||||
}
|
||||
|
||||
// TestShelfDeepensAwayFromTheShore is the sea floor's shape: monotone down from the waterline, through the
|
||||
// break, to the abyssal floor, and never above sea level.
|
||||
//
|
||||
// The coast in this fixture stands 5 m above the water, so the shelf comes out at its widest — 3 km of shelf
|
||||
// and 1.6 km of slope — and the map is made wide enough to hold both. That matters: on a narrower map the
|
||||
// abyssal floor is simply never reached, which is correct behaviour and would read as a failed test.
|
||||
func TestShelfDeepensAwayFromTheShore(t *testing.T) {
|
||||
const w, h, split = 1000, 40, 600
|
||||
const cellM = 8.0
|
||||
cfg := shelfOnlyCfg()
|
||||
f, sea := coastFixture(w, h, split, cellM, 5)
|
||||
Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: cfg})
|
||||
|
||||
y := h / 2
|
||||
prev := 0.0
|
||||
for x := split - 1; x >= 1; x-- {
|
||||
z := float64(f.Data[y*w+x])
|
||||
if z > 0 {
|
||||
t.Fatalf("x=%d: sea floor at %.2f m, above sea level", x, z)
|
||||
}
|
||||
if x < split-1 && z > prev+1e-4 {
|
||||
t.Fatalf("x=%d: sea floor rose from %.2f to %.2f m going offshore", x, prev, z)
|
||||
}
|
||||
prev = z
|
||||
}
|
||||
// Past the shelf and the slope together, 4.6 km out, is the abyssal floor.
|
||||
if z := float64(f.Data[y*w+2]); math.Abs(z+180) > 1 {
|
||||
t.Errorf("the far sea floor is at %.1f m, want -180 m", z)
|
||||
}
|
||||
// And the break is where it was asked for: just inside the shelf width, the depth is the break depth.
|
||||
shelfCells := int(cfg.ShelfKm.Hi()*1000/cellM) - 2
|
||||
if z := float64(f.Data[y*w+split-1-shelfCells]); math.Abs(z+30) > 2 {
|
||||
t.Errorf("the shelf break is at %.1f m, want -30 m", z)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShelfIsNarrowerOffAMountain is the one behaviour that makes the shelf width worth deriving rather than
|
||||
// setting: the same manifest gives a wide shelf off a plain and a narrow one off a range.
|
||||
func TestShelfIsNarrowerOffAMountain(t *testing.T) {
|
||||
const w, h, split = 700, 60, 400
|
||||
const cellM = 8.0
|
||||
depthAt := func(backshoreM float64, x int) float64 {
|
||||
f, sea := coastFixture(w, h, split, cellM, backshoreM)
|
||||
in := Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: shelfOnlyCfg()}
|
||||
Build(in)
|
||||
return float64(f.Data[(h/2)*w+x])
|
||||
}
|
||||
// One kilometre offshore: on a plain coast that is still shelf, on a mountain coast it is past the break.
|
||||
const probe = 400 - 125
|
||||
plain := depthAt(20, probe)
|
||||
mountain := depthAt(600, probe)
|
||||
if !(mountain < plain-20) {
|
||||
t.Errorf("1 km offshore: %.1f m off a 20 m coast, %.1f m off a 600 m coast; "+
|
||||
"the mountain coast should be far deeper", plain, mountain)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSurfCutsACliffNotARamp is the shape the surf is for. A ramp would satisfy "the land is lower near the
|
||||
// water" just as well, and it is not what a coast looks like, so the test asks for both halves: a nearly flat
|
||||
// platform at the water and a step at the back of it.
|
||||
func TestSurfCutsACliffNotARamp(t *testing.T) {
|
||||
const w, h, split = 700, 60, 400
|
||||
const cellM, plateau = 8.0, 120.0
|
||||
f, sea := coastFixture(w, h, split, cellM, plateau)
|
||||
cfg := testCfg()
|
||||
in := Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: cfg}
|
||||
Build(in)
|
||||
|
||||
y := h / 2
|
||||
// The platform: the first five cells inland, 8 to 40 m from the water.
|
||||
for x := split; x < split+5; x++ {
|
||||
if z := float64(f.Data[y*w+x]); z > 8 {
|
||||
t.Errorf("x=%d (%.0f m inland): %.1f m, want a platform near sea level",
|
||||
x, float64(x-split+1)*cellM, z)
|
||||
}
|
||||
}
|
||||
// The land beyond twice the reach is untouched.
|
||||
far := split + int(2*cfg.SurfReachM/cellM)
|
||||
if z := float64(f.Data[y*w+far]); math.Abs(z-plateau) > 1e-3 {
|
||||
t.Errorf("%.0f m inland: %.1f m, want the plateau at %.0f m", 2*cfg.SurfReachM, z, plateau)
|
||||
}
|
||||
// The cliff: somewhere in the strip there is a step of at least a third of the plateau in one cell.
|
||||
biggest := 0.0
|
||||
for x := split; x < far; x++ {
|
||||
if d := float64(f.Data[y*w+x+1] - f.Data[y*w+x]); d > biggest {
|
||||
biggest = d
|
||||
}
|
||||
}
|
||||
if biggest < plateau/3 {
|
||||
t.Errorf("the biggest step in the surf strip is %.1f m over %.0f m; a %0.f m plateau should leave a "+
|
||||
"cliff, not a ramp", biggest, cellM, plateau)
|
||||
}
|
||||
}
|
||||
|
||||
// bayFixture is a straight coast with a semicircular bay bitten out of it, which is the smallest shape that
|
||||
// has both an exposed stretch and a sheltered one.
|
||||
func bayFixture(w, h, split, radius int, cellM, heightM float64) (*field.Field, []bool) {
|
||||
f, sea := coastFixture(w, h, split, cellM, heightM)
|
||||
cx, cy := split, h/2
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
dx, dy := float64(x-cx), float64(y-cy)
|
||||
if math.Hypot(dx, dy) < float64(radius) {
|
||||
i := y*w + x
|
||||
sea[i] = true
|
||||
f.Data[i] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return f, sea
|
||||
}
|
||||
|
||||
// TestSedimentBudgetBalances is an accounting identity, and it is worth asserting because the deposition step
|
||||
// is the only place in the generator where material is moved from one place to another rather than created or
|
||||
// destroyed by a law. Everything cut, plus everything the rivers deliver, is either laid down or reported as
|
||||
// unplaced; nothing evaporates.
|
||||
func TestSedimentBudgetBalances(t *testing.T) {
|
||||
const w, h, split = 400, 400, 250
|
||||
const cellM = 8.0
|
||||
f, sea := bayFixture(w, h, split, 90, cellM, 90)
|
||||
res := Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: testCfg()})
|
||||
s := res.Stats
|
||||
|
||||
in := s.CutM3 + s.RiverM3
|
||||
out := s.LaidM3 + s.UnplacedM3
|
||||
if in <= 0 {
|
||||
t.Fatalf("the surf cut nothing: there is no budget to balance")
|
||||
}
|
||||
if rel := math.Abs(out-in) / in; rel > 0.02 {
|
||||
t.Errorf("cut %.0f m3 + rivers %.0f m3 = %.0f, but laid %.0f + unplaced %.0f = %.0f (%.1f%% out)",
|
||||
s.CutM3, s.RiverM3, in, s.LaidM3, s.UnplacedM3, out, rel*100)
|
||||
}
|
||||
if s.LaidM3 <= 0 {
|
||||
t.Errorf("nothing was laid down at all; a bay should collect sediment")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSedimentPrefersTheBay is the behaviour the fetch field exists to produce. Without it the surf would cut
|
||||
// a headland and lay the debris straight back down on the headland, which is the one thing a coast never does.
|
||||
func TestSedimentPrefersTheBay(t *testing.T) {
|
||||
const w, h, split, radius = 400, 400, 250, 90
|
||||
const cellM = 8.0
|
||||
f, sea := bayFixture(w, h, split, radius, cellM, 90)
|
||||
res := Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: testCfg()})
|
||||
|
||||
// Two windows of sea cells: the back of the bay, and open water the same distance offshore from the
|
||||
// straight coast well clear of it.
|
||||
var bay, open float64
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
if !sea[i] || res.Change.Data[i] <= 0 {
|
||||
continue
|
||||
}
|
||||
inBay := math.Hypot(float64(x-split), float64(y-h/2)) < float64(radius)
|
||||
farFromBay := math.Abs(float64(y-h/2)) > float64(radius)*1.6
|
||||
if inBay {
|
||||
bay += float64(res.Change.Data[i])
|
||||
} else if farFromBay && x > split-40 {
|
||||
open += float64(res.Change.Data[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !(bay > open*2) {
|
||||
t.Errorf("sediment laid: %.0f m in the bay against %.0f m on the open coast; "+
|
||||
"shelter is not steering deposition", bay, open)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBoxBlurIsMassPreservingAndSymmetric guards the drift kernel, which the deposition mass balance rests on.
|
||||
// Support well inside the grid must come through with its total intact, and a single grain must spread to a
|
||||
// kernel that is the same either side of where it started — that symmetry is what makes "what i gives j" equal
|
||||
// "what j gives i", and it is what the zero padding is there to protect.
|
||||
func TestBoxBlurIsMassPreservingAndSymmetric(t *testing.T) {
|
||||
f := field.New(64, 64, 1)
|
||||
seed := uint32(5)
|
||||
var before float64
|
||||
for y := 20; y < 44; y++ { // support clear of the border by more than the kernel
|
||||
for x := 20; x < 44; x++ {
|
||||
seed = seed*1664525 + 1013904223
|
||||
f.Data[y*64+x] = float32(seed>>16&255) / 255
|
||||
before += float64(f.Data[y*64+x])
|
||||
}
|
||||
}
|
||||
out := boxBlur(f, 5, 3)
|
||||
var after float64
|
||||
for _, v := range out.Data {
|
||||
after += float64(v)
|
||||
}
|
||||
if rel := math.Abs(after-before) / before; rel > 1e-4 {
|
||||
t.Errorf("the kernel moved the total from %.4f to %.4f (%.4f%%)", before, after, rel*100)
|
||||
}
|
||||
|
||||
one := field.New(64, 64, 1)
|
||||
one.Data[32*64+32] = 1
|
||||
k := boxBlur(one, 5, 3)
|
||||
for d := 1; d <= 16; d++ {
|
||||
l, r := k.Data[32*64+32-d], k.Data[32*64+32+d]
|
||||
if math.Abs(float64(l-r)) > 1e-7 {
|
||||
t.Fatalf("the kernel is not symmetric at offset %d: %g against %g", d, l, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisabledIsThePreCoastBehaviour keeps the escape hatch honest: --no-coast has to give the flat sea floor
|
||||
// the generator had before this pass, not a half-applied version of it.
|
||||
func TestDisabledIsThePreCoastBehaviour(t *testing.T) {
|
||||
const w, h, split = 200, 40, 120
|
||||
f, sea := coastFixture(w, h, split, 8, 100)
|
||||
cfg := testCfg()
|
||||
cfg.Enabled = false
|
||||
Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: cfg})
|
||||
for i := range sea {
|
||||
if sea[i] && math.Abs(float64(f.Data[i])+180) > 1e-3 {
|
||||
t.Fatalf("cell %d: %.2f m, want a flat floor at -180 m", i, f.Data[i])
|
||||
}
|
||||
if !sea[i] && math.Abs(float64(f.Data[i])-100) > 1e-3 {
|
||||
t.Fatalf("cell %d: land at %.2f m, want it untouched at 100 m", i, f.Data[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package coast
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
// The coast is a *distance*, not a line. Every coastal process is written in terms of how far a cell is from
|
||||
// the waterline and which stretch of waterline it belongs to: the shelf deepens with distance offshore, the
|
||||
// surf planes the land within a reach of it, sediment settles in the shallows behind it, and shelter is a
|
||||
// property of a stretch of shore that every cell near it inherits. So the first thing the pass builds is an
|
||||
// exact signed distance field with a feature index, and everything after it is a lookup.
|
||||
//
|
||||
// Exact, not a chamfer approximation: Felzenszwalb & Huttenlocher's transform is two 1-D passes and O(n)
|
||||
// whatever the radius, so there is nothing to buy by approximating, and a chamfer's 2 % anisotropy would show
|
||||
// up directly as a shelf that is wider along the grid axes than across them.
|
||||
|
||||
// edt returns, for every cell, the squared distance in cells to the nearest seed cell and the index of that
|
||||
// seed. A column pass finds the nearest seed in each column; a row pass takes the lower envelope of the
|
||||
// parabolas those distances define.
|
||||
//
|
||||
// Cells in a column with no seed at all are given a cost above any real distance rather than an infinity, so
|
||||
// the envelope arithmetic never sees a NaN; they are then never chosen unless the map has no seeds anywhere,
|
||||
// which the caller checks for.
|
||||
func edt(seed []bool, w, h int) (d2 []float32, near []int32) {
|
||||
d2 = make([]float32, w*h)
|
||||
near = make([]int32, w*h)
|
||||
|
||||
bigF := float64(w*w+h*h) * 4 // above any achievable dx² + dy²
|
||||
bigD := float32(math.Sqrt(bigF))
|
||||
|
||||
colD := make([]float32, w*h) // distance in cells to the nearest seed in this column
|
||||
colN := make([]int32, w*h) // that seed's row, or -1
|
||||
|
||||
field.Rows(w, func(x0, x1 int) {
|
||||
for x := x0; x < x1; x++ {
|
||||
best := -1
|
||||
for y := 0; y < h; y++ {
|
||||
i := y*w + x
|
||||
if seed[i] {
|
||||
best = y
|
||||
}
|
||||
if best < 0 {
|
||||
colD[i], colN[i] = bigD, -1
|
||||
} else {
|
||||
colD[i], colN[i] = float32(y-best), int32(best)
|
||||
}
|
||||
}
|
||||
best = -1
|
||||
for y := h - 1; y >= 0; y-- {
|
||||
i := y*w + x
|
||||
if seed[i] {
|
||||
best = y
|
||||
}
|
||||
if best >= 0 {
|
||||
if d := float32(best - y); d < colD[i] {
|
||||
colD[i], colN[i] = d, int32(best)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
field.Rows(h, func(y0, y1 int) {
|
||||
f := make([]float64, w)
|
||||
v := make([]int, w)
|
||||
z := make([]float64, w+1)
|
||||
for y := y0; y < y1; y++ {
|
||||
row := y * w
|
||||
for x := 0; x < w; x++ {
|
||||
d := float64(colD[row+x])
|
||||
f[x] = d * d
|
||||
}
|
||||
k := 0
|
||||
v[0] = 0
|
||||
z[0] = math.Inf(-1)
|
||||
z[1] = math.Inf(1)
|
||||
for q := 1; q < w; q++ {
|
||||
s := intersect(f, v[k], q)
|
||||
for s <= z[k] {
|
||||
k--
|
||||
s = intersect(f, v[k], q)
|
||||
}
|
||||
k++
|
||||
v[k] = q
|
||||
z[k] = s
|
||||
z[k+1] = math.Inf(1)
|
||||
}
|
||||
k = 0
|
||||
for q := 0; q < w; q++ {
|
||||
for z[k+1] < float64(q) {
|
||||
k++
|
||||
}
|
||||
dx := float64(q - v[k])
|
||||
d2[row+q] = float32(dx*dx + f[v[k]])
|
||||
if n := colN[row+v[k]]; n < 0 {
|
||||
near[row+q] = -1
|
||||
} else {
|
||||
near[row+q] = n*int32(w) + int32(v[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return d2, near
|
||||
}
|
||||
|
||||
// intersect is where the parabolas rooted at p and q cross.
|
||||
func intersect(f []float64, p, q int) float64 {
|
||||
return ((f[q] + float64(q*q)) - (f[p] + float64(p*p))) / float64(2*q-2*p)
|
||||
}
|
||||
|
||||
// Geometry is the coastline as the rest of the pass sees it.
|
||||
type Geometry struct {
|
||||
W, H int
|
||||
CellM float64
|
||||
|
||||
// Dist is metres to the waterline: positive inland, negative offshore.
|
||||
Dist *field.Field
|
||||
|
||||
// Ref is, for every cell, the waterline cell whose stretch of shore it belongs to. A land cell takes the
|
||||
// sea cell nearest to it, which is on the waterline by construction; a sea cell takes the waterline cell
|
||||
// nearest to the land cell nearest to it, which is the stretch of shore facing it. Every per-shore
|
||||
// quantity — shelter, shelf width, the backshore relief — is computed once on the waterline and read
|
||||
// everywhere else through this.
|
||||
Ref []int32
|
||||
|
||||
// Waterline is the sea cells that touch land, in row-major order so anything iterating them is
|
||||
// deterministic.
|
||||
Waterline []int32
|
||||
|
||||
// ShoreM is the length of the land/sea boundary in metres, counted as boundary edges. It overestimates a
|
||||
// diagonal coast by about 4/pi, as any edge-counted perimeter does.
|
||||
ShoreM float64
|
||||
}
|
||||
|
||||
// Measure builds the signed distance field and the shore reference from a land/sea mask.
|
||||
func Measure(sea []bool, w, h int, cellM float64) *Geometry {
|
||||
anySea, anyLand := false, false
|
||||
land := make([]bool, len(sea))
|
||||
for i, s := range sea {
|
||||
land[i] = !s
|
||||
if s {
|
||||
anySea = true
|
||||
} else {
|
||||
anyLand = true
|
||||
}
|
||||
}
|
||||
g := &Geometry{W: w, H: h, CellM: cellM, Dist: field.New(w, h, cellM), Ref: make([]int32, w*h)}
|
||||
for i := range g.Ref {
|
||||
g.Ref[i] = -1
|
||||
}
|
||||
if !anySea || !anyLand {
|
||||
return g // an all-land or all-sea map has no coast; every pass below is a no-op on it
|
||||
}
|
||||
|
||||
d2Sea, nearSea := edt(sea, w, h) // for a land cell: how far to water, and where
|
||||
d2Land, nearLand := edt(land, w, h) // for a sea cell: how far to land, and where
|
||||
|
||||
for i := range sea {
|
||||
if sea[i] {
|
||||
g.Dist.Data[i] = float32(-math.Sqrt(float64(d2Land[i])) * cellM)
|
||||
} else {
|
||||
g.Dist.Data[i] = float32(math.Sqrt(float64(d2Sea[i])) * cellM)
|
||||
}
|
||||
}
|
||||
|
||||
// The waterline: sea cells with land in the eight-neighbourhood, which is d2Land of 1 or 2.
|
||||
for i := range sea {
|
||||
if sea[i] && d2Land[i] <= 2.001 {
|
||||
g.Waterline = append(g.Waterline, int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
for i := range sea {
|
||||
if sea[i] {
|
||||
if l := nearLand[i]; l >= 0 {
|
||||
g.Ref[i] = nearSea[l]
|
||||
}
|
||||
} else {
|
||||
g.Ref[i] = nearSea[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Perimeter by boundary edges, which is what a shoreline length means on a grid.
|
||||
edges := 0
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
if x+1 < w && sea[i] != sea[i+1] {
|
||||
edges++
|
||||
}
|
||||
if y+1 < h && sea[i] != sea[i+w] {
|
||||
edges++
|
||||
}
|
||||
}
|
||||
}
|
||||
g.ShoreM = float64(edges) * cellM
|
||||
return g
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package field
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// False-colour maps of the fields a run works from, as opposed to the field it produces.
|
||||
//
|
||||
// preview.png answers "does this look like a landscape". These answer the question that comes next, which is
|
||||
// "why does it look like that" — the uplift field, the erodibility, the slope and the basins are the inputs
|
||||
// and the structure, and when a run comes out wrong it is almost always one of them that says so first. The
|
||||
// uplift map in particular is the one that would have shown, without any arithmetic, that the plains were
|
||||
// being raised at mountain rates.
|
||||
|
||||
// DataMapOptions controls one false-colour render.
|
||||
type DataMapOptions struct {
|
||||
// Sea marks cells to render as flat water rather than data. Optional.
|
||||
Sea []bool
|
||||
// Size is the output side in pixels; the field is point-sampled down to it.
|
||||
Size int
|
||||
// Log renders log10 of the value, for anything with a heavy tail — drainage area spans seven decades and
|
||||
// is unreadable linearly.
|
||||
Log bool
|
||||
// Lo and Hi bound the colour ramp. Left at zero they are taken from the 1st and 99th percentile of the
|
||||
// data, which keeps one outlier cell from flattening the whole image.
|
||||
Lo, Hi float64
|
||||
// Palette maps 0..1 to a colour. Nil is Viridis.
|
||||
Palette func(float64) [3]float64
|
||||
}
|
||||
|
||||
// WriteDataMap renders one scalar field as a false-colour PNG.
|
||||
func WriteDataMap(path string, f *Field, opt DataMapOptions) error {
|
||||
size := opt.Size
|
||||
if size <= 0 || size > f.W {
|
||||
size = f.W
|
||||
}
|
||||
pal := opt.Palette
|
||||
if pal == nil {
|
||||
pal = Viridis
|
||||
}
|
||||
|
||||
vals := make([]float64, len(f.Data))
|
||||
for i, v := range f.Data {
|
||||
x := float64(v)
|
||||
if opt.Log {
|
||||
if x < 1 {
|
||||
x = 1
|
||||
}
|
||||
x = math.Log10(x)
|
||||
}
|
||||
vals[i] = x
|
||||
}
|
||||
|
||||
lo, hi := opt.Lo, opt.Hi
|
||||
if lo == 0 && hi == 0 {
|
||||
lo, hi = percentiles(vals, opt.Sea, 1, 99)
|
||||
}
|
||||
span := hi - lo
|
||||
if span < 1e-12 {
|
||||
span = 1
|
||||
}
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
for y := 0; y < size; y++ {
|
||||
sy := y * f.H / size
|
||||
for x := 0; x < size; x++ {
|
||||
sx := x * f.W / size
|
||||
i := sy*f.W + sx
|
||||
if opt.Sea != nil && opt.Sea[i] {
|
||||
img.Set(x, y, color.RGBA{24, 44, 74, 255})
|
||||
continue
|
||||
}
|
||||
t := (vals[i] - lo) / span
|
||||
if t < 0 {
|
||||
t = 0
|
||||
} else if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
c := pal(t)
|
||||
img.Set(x, y, color.RGBA{clamp8(c[0]), clamp8(c[1]), clamp8(c[2]), 255})
|
||||
}
|
||||
}
|
||||
return encode(path, img, png.DefaultCompression)
|
||||
}
|
||||
|
||||
// WriteBasinMap colours each drainage basin, which is the one picture that shows whether the solve produced a
|
||||
// *network* rather than a set of scratches: real basins tile the land, meet along divides that sit where the
|
||||
// two catchments either side put them, and come in a spread of sizes. A map of noisy speckle means the router
|
||||
// is re-deciding where the water goes every few cells.
|
||||
//
|
||||
// receiver is the D8 receiver array; a cell whose receiver is itself is a basin root.
|
||||
func WriteBasinMap(path string, w, h int, receiver []int32, sea []bool, size int) error {
|
||||
if size <= 0 || size > w {
|
||||
size = w
|
||||
}
|
||||
// Walk each cell down to its root with path compression, so the whole thing stays O(n).
|
||||
root := make([]int32, w*h)
|
||||
for i := range root {
|
||||
root[i] = -1
|
||||
}
|
||||
var stack []int32
|
||||
for i := range root {
|
||||
if root[i] >= 0 {
|
||||
continue
|
||||
}
|
||||
stack = stack[:0]
|
||||
c := int32(i)
|
||||
for root[c] < 0 && receiver[c] != c {
|
||||
stack = append(stack, c)
|
||||
c = receiver[c]
|
||||
}
|
||||
r := root[c]
|
||||
if r < 0 {
|
||||
r = c
|
||||
root[c] = r
|
||||
}
|
||||
for _, s := range stack {
|
||||
root[s] = r
|
||||
}
|
||||
}
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
for y := 0; y < size; y++ {
|
||||
sy := y * h / size
|
||||
for x := 0; x < size; x++ {
|
||||
sx := x * w / size
|
||||
i := sy*w + sx
|
||||
if sea != nil && sea[i] {
|
||||
img.Set(x, y, color.RGBA{24, 44, 74, 255})
|
||||
continue
|
||||
}
|
||||
// A hash of the root id, so neighbouring basins get unrelated colours and a divide is a hard
|
||||
// edge rather than a gradient.
|
||||
k := uint64(uint32(root[i]))*0x9e3779b97f4a7c15 + 0x2545f4914f6cdd1d
|
||||
k ^= k >> 29
|
||||
k *= 0xbf58476d1ce4e5b9
|
||||
k ^= k >> 32
|
||||
c := hsv(float64(k%3600)/3600, 0.45+float64((k>>12)%40)/100, 0.55+float64((k>>24)%40)/100)
|
||||
img.Set(x, y, color.RGBA{clamp8(c[0]), clamp8(c[1]), clamp8(c[2]), 255})
|
||||
}
|
||||
}
|
||||
return encode(path, img, png.DefaultCompression)
|
||||
}
|
||||
|
||||
// Viridis, sampled at sixteen stops. Perceptually uniform and legible in greyscale, which matters because
|
||||
// these get pasted into notes and printed.
|
||||
var viridisStops = [][3]float64{
|
||||
{68, 1, 84}, {72, 26, 108}, {71, 47, 125}, {65, 68, 135},
|
||||
{57, 86, 140}, {49, 104, 142}, {42, 120, 142}, {35, 136, 142},
|
||||
{31, 152, 139}, {34, 168, 132}, {53, 183, 121}, {84, 197, 104},
|
||||
{122, 209, 81}, {165, 219, 54}, {210, 226, 27}, {253, 231, 37},
|
||||
}
|
||||
|
||||
func Viridis(t float64) [3]float64 { return sampleStops(viridisStops, t) }
|
||||
|
||||
// Inferno, for anything where "how much" reads better as heat: slope and local relief.
|
||||
var infernoStops = [][3]float64{
|
||||
{0, 0, 4}, {12, 8, 38}, {36, 12, 79}, {66, 10, 104},
|
||||
{93, 18, 110}, {120, 28, 109}, {147, 38, 103}, {174, 48, 92},
|
||||
{199, 62, 76}, {221, 81, 58}, {237, 105, 37}, {247, 133, 17},
|
||||
{251, 164, 10}, {249, 196, 41}, {243, 228, 96}, {252, 255, 164},
|
||||
}
|
||||
|
||||
func Inferno(t float64) [3]float64 { return sampleStops(infernoStops, t) }
|
||||
|
||||
// Divergent is for a field with a meaningful zero and a sign: cool below, near-white at zero, warm above.
|
||||
// The change map is the one that needs it — where the surf cut and where it laid are the same magnitude and
|
||||
// opposite in meaning, and a sequential ramp renders them as the same colour.
|
||||
func Divergent(t float64) [3]float64 { return sampleStops(divergentStops, t) }
|
||||
|
||||
var divergentStops = [][3]float64{
|
||||
{30, 64, 120}, {64, 126, 180}, {150, 196, 220}, {238, 238, 236},
|
||||
{236, 196, 140}, {206, 132, 62}, {140, 66, 22},
|
||||
}
|
||||
|
||||
func sampleStops(s [][3]float64, t float64) [3]float64 {
|
||||
if t <= 0 {
|
||||
return s[0]
|
||||
}
|
||||
if t >= 1 {
|
||||
return s[len(s)-1]
|
||||
}
|
||||
x := t * float64(len(s)-1)
|
||||
i := int(x)
|
||||
u := x - float64(i)
|
||||
a, b := s[i], s[i+1]
|
||||
return [3]float64{a[0] + (b[0]-a[0])*u, a[1] + (b[1]-a[1])*u, a[2] + (b[2]-a[2])*u}
|
||||
}
|
||||
|
||||
func hsv(hue, sat, val float64) [3]float64 {
|
||||
h6 := hue * 6
|
||||
i := int(h6)
|
||||
f := h6 - float64(i)
|
||||
p := val * (1 - sat)
|
||||
q := val * (1 - sat*f)
|
||||
t := val * (1 - sat*(1-f))
|
||||
var r, g, b float64
|
||||
switch i % 6 {
|
||||
case 0:
|
||||
r, g, b = val, t, p
|
||||
case 1:
|
||||
r, g, b = q, val, p
|
||||
case 2:
|
||||
r, g, b = p, val, t
|
||||
case 3:
|
||||
r, g, b = p, q, val
|
||||
case 4:
|
||||
r, g, b = t, p, val
|
||||
default:
|
||||
r, g, b = val, p, q
|
||||
}
|
||||
return [3]float64{r * 255, g * 255, b * 255}
|
||||
}
|
||||
|
||||
func percentiles(vals []float64, sea []bool, loPct, hiPct float64) (float64, float64) {
|
||||
keep := make([]float64, 0, len(vals))
|
||||
for i, v := range vals {
|
||||
if sea != nil && sea[i] {
|
||||
continue
|
||||
}
|
||||
keep = append(keep, v)
|
||||
}
|
||||
if len(keep) == 0 {
|
||||
return 0, 1
|
||||
}
|
||||
sort.Float64s(keep)
|
||||
at := func(p float64) float64 {
|
||||
i := int(p / 100 * float64(len(keep)-1))
|
||||
return keep[i]
|
||||
}
|
||||
return at(loPct), at(hiPct)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Package field is the one array type the whole generator passes around: a square-ish grid of float32 in a
|
||||
// known unit, with the cell size in metres attached so no pass has to be told the scale twice.
|
||||
//
|
||||
// Determinism (cross-cutting rule 12) is a property of this package as much as of the passes. Everything
|
||||
// parallel here partitions rows into disjoint, contiguous ranges and writes only into its own range, so the
|
||||
// result does not depend on how the goroutines were scheduled. Nothing reduces through a channel.
|
||||
package field
|
||||
|
||||
import (
|
||||
"math"
|
||||
"runtime"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Field is a W x H grid, row-major, with CellM metres between neighbouring samples.
|
||||
type Field struct {
|
||||
W, H int
|
||||
CellM float64
|
||||
Data []float32
|
||||
}
|
||||
|
||||
func New(w, h int, cellM float64) *Field {
|
||||
return &Field{W: w, H: h, CellM: cellM, Data: make([]float32, w*h)}
|
||||
}
|
||||
|
||||
// NewLike is an empty field with another's shape and scale.
|
||||
func NewLike(f *Field) *Field { return New(f.W, f.H, f.CellM) }
|
||||
|
||||
func (f *Field) Idx(x, y int) int { return y*f.W + x }
|
||||
func (f *Field) At(x, y int) float32 { return f.Data[y*f.W+x] }
|
||||
func (f *Field) Set(x, y int, v float32) { f.Data[y*f.W+x] = v }
|
||||
func (f *Field) Len() int { return len(f.Data) }
|
||||
|
||||
// AtClamped samples with edge clamping, which is what every stencil in the generator wants at the border.
|
||||
func (f *Field) AtClamped(x, y int) float32 {
|
||||
if x < 0 {
|
||||
x = 0
|
||||
} else if x >= f.W {
|
||||
x = f.W - 1
|
||||
}
|
||||
if y < 0 {
|
||||
y = 0
|
||||
} else if y >= f.H {
|
||||
y = f.H - 1
|
||||
}
|
||||
return f.Data[y*f.W+x]
|
||||
}
|
||||
|
||||
func (f *Field) Clone() *Field {
|
||||
c := New(f.W, f.H, f.CellM)
|
||||
copy(c.Data, f.Data)
|
||||
return c
|
||||
}
|
||||
|
||||
func (f *Field) Fill(v float32) {
|
||||
for i := range f.Data {
|
||||
f.Data[i] = v
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Field) MinMax() (float32, float32) {
|
||||
if len(f.Data) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
lo, hi := f.Data[0], f.Data[0]
|
||||
for _, v := range f.Data {
|
||||
if v < lo {
|
||||
lo = v
|
||||
}
|
||||
if v > hi {
|
||||
hi = v
|
||||
}
|
||||
}
|
||||
return lo, hi
|
||||
}
|
||||
|
||||
func (f *Field) Mean() float64 {
|
||||
if len(f.Data) == 0 {
|
||||
return 0
|
||||
}
|
||||
// Summed as float64 in index order: the same total every run, whatever the machine.
|
||||
var sum float64
|
||||
for _, v := range f.Data {
|
||||
sum += float64(v)
|
||||
}
|
||||
return sum / float64(len(f.Data))
|
||||
}
|
||||
|
||||
// Percentile sorts a copy, so it costs a copy and a sort; used for thresholds, not in inner loops.
|
||||
func (f *Field) Percentile(p float64) float32 {
|
||||
if len(f.Data) == 0 {
|
||||
return 0
|
||||
}
|
||||
c := make([]float32, len(f.Data))
|
||||
copy(c, f.Data)
|
||||
sort.Slice(c, func(i, j int) bool { return c[i] < c[j] })
|
||||
i := int(p / 100 * float64(len(c)-1))
|
||||
if i < 0 {
|
||||
i = 0
|
||||
} else if i >= len(c) {
|
||||
i = len(c) - 1
|
||||
}
|
||||
return c[i]
|
||||
}
|
||||
|
||||
// Normalise maps the field onto [0, 1]. A flat field becomes zero rather than a division by nothing.
|
||||
func (f *Field) Normalise() {
|
||||
lo, hi := f.MinMax()
|
||||
span := float64(hi - lo)
|
||||
if span < 1e-9 {
|
||||
f.Fill(0)
|
||||
return
|
||||
}
|
||||
for i, v := range f.Data {
|
||||
f.Data[i] = float32((float64(v) - float64(lo)) / span)
|
||||
}
|
||||
}
|
||||
|
||||
// Slope returns rise over run per cell, the central difference used by the layer rules and the statistics.
|
||||
func (f *Field) Slope() *Field {
|
||||
out := NewLike(f)
|
||||
inv := float32(1.0 / (2.0 * f.CellM))
|
||||
Rows(f.H, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < f.W; x++ {
|
||||
gx := (f.AtClamped(x+1, y) - f.AtClamped(x-1, y)) * inv
|
||||
gy := (f.AtClamped(x, y+1) - f.AtClamped(x, y-1)) * inv
|
||||
out.Data[out.Idx(x, y)] = float32(math.Hypot(float64(gx), float64(gy)))
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Curvature is the Laplacian in metres per cell squared: positive on ridges and convex shoulders, negative in
|
||||
// gullies and sediment traps. Ported from heightmap_erosion.curvature, which blurs lightly first.
|
||||
func (f *Field) Curvature() *Field {
|
||||
h := f.Blur(2)
|
||||
out := NewLike(f)
|
||||
inv := float32(1.0 / f.CellM)
|
||||
Rows(f.H, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < f.W; x++ {
|
||||
lap := h.AtClamped(x-1, y) + h.AtClamped(x+1, y) + h.AtClamped(x, y-1) + h.AtClamped(x, y+1) - 4*h.At(x, y)
|
||||
out.Data[out.Idx(x, y)] = lap * inv
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Blur is the five-point box blur the numpy pipeline used, repeated. Edge-clamped, so it does not darken
|
||||
// the border the way a zero-padded one would.
|
||||
func (f *Field) Blur(passes int) *Field {
|
||||
cur := f.Clone()
|
||||
if passes <= 0 {
|
||||
return cur
|
||||
}
|
||||
next := NewLike(f)
|
||||
for p := 0; p < passes; p++ {
|
||||
Rows(f.H, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < cur.W; x++ {
|
||||
s := cur.At(x, y) + cur.AtClamped(x-1, y) + cur.AtClamped(x+1, y) + cur.AtClamped(x, y-1) + cur.AtClamped(x, y+1)
|
||||
next.Data[next.Idx(x, y)] = s / 5
|
||||
}
|
||||
}
|
||||
})
|
||||
cur, next = next, cur
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// Rows runs fn over disjoint contiguous row ranges, one per core. The ranges are fixed before any goroutine
|
||||
// starts and each writes only into its own, so the output is identical at any GOMAXPROCS. Every parallel
|
||||
// loop in the generator goes through here; none spawns goroutines of its own.
|
||||
func Rows(h int, fn func(y0, y1 int)) {
|
||||
workers := runtime.GOMAXPROCS(0)
|
||||
if workers > h {
|
||||
workers = h
|
||||
}
|
||||
if workers <= 1 {
|
||||
fn(0, h)
|
||||
return
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
step := (h + workers - 1) / workers
|
||||
for y0 := 0; y0 < h; y0 += step {
|
||||
y1 := y0 + step
|
||||
if y1 > h {
|
||||
y1 = h
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(a, b int) {
|
||||
defer wg.Done()
|
||||
fn(a, b)
|
||||
}(y0, y1)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package field
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Greyscale PNG in and out, plus the raw 16-bit little-endian .r16 that World Machine, Gaea and the engine's
|
||||
// own exporter write. The numpy pipeline hand-rolled all of this because the engine's Python has no PIL;
|
||||
// image/png covers it, so the only thing worth carrying over is the behaviour, not the code.
|
||||
|
||||
// WriteGray16 writes values already in 0..65535. Compression matters at this size: one 7141 x 7141 map is
|
||||
// 102 MB of samples, so the level is a parameter and the caller pays for what it needs. The height map is
|
||||
// imported by the editor and worth compressing; the derivative maps are rebuilt from a seed and are not.
|
||||
func WriteGray16(path string, w, h int, values []uint16, level png.CompressionLevel) error {
|
||||
if len(values) != w*h {
|
||||
return fmt.Errorf("%s: %d values for a %dx%d image", path, len(values), w, h)
|
||||
}
|
||||
img := image.NewGray16(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
row := img.Pix[y*img.Stride : y*img.Stride+w*2]
|
||||
src := values[y*w : y*w+w]
|
||||
for x, v := range src {
|
||||
binary.BigEndian.PutUint16(row[x*2:], v) // PNG is big-endian; image.Gray16 stores it that way too
|
||||
}
|
||||
}
|
||||
return encode(path, img, level)
|
||||
}
|
||||
|
||||
// WriteGray8 writes values already in 0..255.
|
||||
func WriteGray8(path string, w, h int, values []uint8, level png.CompressionLevel) error {
|
||||
if len(values) != w*h {
|
||||
return fmt.Errorf("%s: %d values for a %dx%d image", path, len(values), w, h)
|
||||
}
|
||||
img := image.NewGray(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
copy(img.Pix[y*img.Stride:y*img.Stride+w], values[y*w:y*w+w])
|
||||
}
|
||||
return encode(path, img, level)
|
||||
}
|
||||
|
||||
func encode(path string, img image.Image, level png.CompressionLevel) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// Written beside the target and renamed, so a killed run never leaves create_world.py a half-written PNG
|
||||
// to import. The numpy pipeline learned this the hard way with a killed commandlet.
|
||||
tmp := path + ".tmp"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bw := bufio.NewWriterSize(f, 1<<20)
|
||||
enc := png.Encoder{CompressionLevel: level}
|
||||
if err := enc.Encode(bw, img); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := bw.Flush(); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// ReadHeightmap reads a 16-bit greyscale PNG, an 8-bit greyscale PNG (widened, as the numpy pipeline widened
|
||||
// it) or a raw 16-bit little-endian .r16/.raw, and returns values in 0..65535 with the image's dimensions.
|
||||
// A raw file needs its width when it is not square.
|
||||
func ReadHeightmap(path string, width int) (values []uint16, w, h int, err error) {
|
||||
switch ext := filepath.Ext(path); ext {
|
||||
case ".r16", ".raw":
|
||||
return readRaw(path, width)
|
||||
default:
|
||||
return readPNG(path)
|
||||
}
|
||||
}
|
||||
|
||||
func readPNG(path string) ([]uint16, int, int, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
img, err := png.Decode(bufio.NewReaderSize(f, 1<<20))
|
||||
if err != nil {
|
||||
return nil, 0, 0, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
out := make([]uint16, w*h)
|
||||
switch src := img.(type) {
|
||||
case *image.Gray16:
|
||||
for y := 0; y < h; y++ {
|
||||
row := src.Pix[y*src.Stride:]
|
||||
for x := 0; x < w; x++ {
|
||||
out[y*w+x] = binary.BigEndian.Uint16(row[x*2:])
|
||||
}
|
||||
}
|
||||
case *image.Gray:
|
||||
// Widened the way the numpy reader widened it: 8-bit 255 must become 65535, not 65280, or a DEM
|
||||
// comes in a whisker short of its own ceiling.
|
||||
for y := 0; y < h; y++ {
|
||||
row := src.Pix[y*src.Stride:]
|
||||
for x := 0; x < w; x++ {
|
||||
out[y*w+x] = uint16(row[x]) * 257
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Anything else (RGB, paletted) goes through the generic accessor, which already returns 16-bit.
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
r, g, bl, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
|
||||
out[y*w+x] = uint16((r*299 + g*587 + bl*114) / 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, w, h, nil
|
||||
}
|
||||
|
||||
func readRaw(path string, width int) ([]uint16, int, int, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
if len(raw)%2 != 0 {
|
||||
return nil, 0, 0, fmt.Errorf("%s: %d bytes is not a whole number of 16-bit samples", path, len(raw))
|
||||
}
|
||||
n := len(raw) / 2
|
||||
w := width
|
||||
if w <= 0 {
|
||||
w = isqrt(n)
|
||||
if w*w != n {
|
||||
return nil, 0, 0, fmt.Errorf("%s: %d samples is not square; give the width", path, n)
|
||||
}
|
||||
}
|
||||
if n%w != 0 {
|
||||
return nil, 0, 0, fmt.Errorf("%s: %d samples do not divide by width %d", path, n, w)
|
||||
}
|
||||
out := make([]uint16, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = binary.LittleEndian.Uint16(raw[i*2:])
|
||||
}
|
||||
return out, w, n / w, nil
|
||||
}
|
||||
|
||||
func isqrt(n int) int {
|
||||
r := 0
|
||||
for (r+1)*(r+1) <= n {
|
||||
r++
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// WriteThumbnail writes a small 8-bit preview of a height field, hillshaded so the drainage is actually
|
||||
// visible: a flat grey ramp hides exactly the thing this generator exists to produce.
|
||||
func WriteThumbnail(path string, h *Field, size int) error {
|
||||
small := h.Resample(size, size)
|
||||
lo, hi := small.MinMax()
|
||||
span := float64(hi - lo)
|
||||
if span < 1e-6 {
|
||||
span = 1
|
||||
}
|
||||
// Light from the north-west at 45 degrees, the convention every DEM hillshade uses.
|
||||
px := make([]uint8, size*size)
|
||||
for y := 0; y < size; y++ {
|
||||
for x := 0; x < size; x++ {
|
||||
gx := float64(small.AtClamped(x+1, y) - small.AtClamped(x-1, y))
|
||||
gy := float64(small.AtClamped(x, y+1) - small.AtClamped(x, y-1))
|
||||
shade := (0.5*gx + 0.5*gy) / (2 * small.CellM)
|
||||
lum := 0.35 + 0.65*(float64(small.At(x, y))-float64(lo))/span
|
||||
lum += shade * 0.35
|
||||
if lum < 0 {
|
||||
lum = 0
|
||||
} else if lum > 1 {
|
||||
lum = 1
|
||||
}
|
||||
px[y*size+x] = uint8(lum * 255)
|
||||
}
|
||||
}
|
||||
return WriteGray8(path, size, size, px, png.BestSpeed)
|
||||
}
|
||||
|
||||
var _ io.Writer = (*bufio.Writer)(nil)
|
||||
@@ -0,0 +1,259 @@
|
||||
package field
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// A colour preview of a height field: hypsometric tint, hillshade, and the drainage network drawn on top.
|
||||
//
|
||||
// The grey thumbnail is nearly useless for judging this generator, which is a problem, because the thing it
|
||||
// exists to produce is a drainage network and a flat grey ramp is exactly what hides one. Rivers are drawn
|
||||
// from the flow accumulation with a width that grows with drainage area, so a glance says whether the network
|
||||
// branches like a river system or like noise.
|
||||
|
||||
type PreviewOptions struct {
|
||||
// Flow is drainage area per cell, m². Optional; without it no rivers are drawn.
|
||||
Flow *Field
|
||||
// Sea marks cells below sea level. Optional.
|
||||
Sea []bool
|
||||
SeaLevelM float64
|
||||
// RiverKm2 is the drainage area at which a channel starts being drawn.
|
||||
RiverKm2 float64
|
||||
Size int
|
||||
// Crop is a sub-rectangle in map coordinates (x0, y0, x1, y1 in 0..1), rendered at full resolution.
|
||||
// A whole continent at 1500 px puts ten kilometres into a hundred pixels, which is enough to see that
|
||||
// there is drainage and not nearly enough to see whether it is the right *kind* of drainage. Judging
|
||||
// hill country against real hill country needs a crop.
|
||||
Crop [4]float64
|
||||
// Hillshade exaggerates the vertical before shading. Lowland relief is a few tens of metres over
|
||||
// kilometres and disappears at true scale, which is the same reason every printed relief map lies.
|
||||
Exaggeration float64
|
||||
}
|
||||
|
||||
// rgb is a colour in 0..255 kept as float64 so the hillshade can multiply it before it is clamped.
|
||||
type rgb = [3]float64
|
||||
|
||||
type stop struct {
|
||||
t float64
|
||||
c rgb
|
||||
}
|
||||
|
||||
var (
|
||||
// A hypsometric ramp: salt-marsh green at sea level through farmland and rock to snow. Stops are chosen
|
||||
// so the lowland does not read as one flat colour, which is where most of the map is.
|
||||
landStops = []stop{
|
||||
{0.00, rgb{72, 106, 68}},
|
||||
{0.08, rgb{104, 132, 74}},
|
||||
{0.20, rgb{142, 152, 88}},
|
||||
{0.38, rgb{164, 148, 104}},
|
||||
{0.58, rgb{150, 128, 106}},
|
||||
{0.75, rgb{138, 130, 128}},
|
||||
{0.88, rgb{176, 174, 174}},
|
||||
{1.00, rgb{246, 246, 250}},
|
||||
}
|
||||
seaShallow = rgb{56, 104, 136}
|
||||
seaDeep = rgb{18, 40, 72}
|
||||
riverTint = rgb{70, 132, 180}
|
||||
)
|
||||
|
||||
func ramp(t float64) rgb {
|
||||
if t <= 0 {
|
||||
return landStops[0].c
|
||||
}
|
||||
for i := 1; i < len(landStops); i++ {
|
||||
if t <= landStops[i].t {
|
||||
a, b := landStops[i-1], landStops[i]
|
||||
u := (t - a.t) / (b.t - a.t)
|
||||
return rgb{
|
||||
a.c[0] + (b.c[0]-a.c[0])*u,
|
||||
a.c[1] + (b.c[1]-a.c[1])*u,
|
||||
a.c[2] + (b.c[2]-a.c[2])*u,
|
||||
}
|
||||
}
|
||||
}
|
||||
return landStops[len(landStops)-1].c
|
||||
}
|
||||
|
||||
// WritePreview renders the field at opt.Size and writes an RGB PNG.
|
||||
func WritePreview(path string, h *Field, opt PreviewOptions) error {
|
||||
size := opt.Size
|
||||
if size <= 0 {
|
||||
size = 1024
|
||||
}
|
||||
if size > h.W {
|
||||
size = h.W
|
||||
}
|
||||
if opt.Crop[2] > opt.Crop[0] && opt.Crop[3] > opt.Crop[1] {
|
||||
fullW, fullH := h.W, h.H
|
||||
h = h.Sub(opt.Crop)
|
||||
if opt.Flow != nil {
|
||||
opt.Flow = opt.Flow.Sub(opt.Crop)
|
||||
}
|
||||
if opt.Sea != nil {
|
||||
opt.Sea = subMask(opt.Sea, fullW, fullH, opt.Crop)
|
||||
}
|
||||
if size > h.W {
|
||||
size = h.W
|
||||
}
|
||||
}
|
||||
small := h.Resample(size, size)
|
||||
exag := opt.Exaggeration
|
||||
if exag <= 0 {
|
||||
exag = 1
|
||||
}
|
||||
|
||||
// Land elevations only: letting the sea floor into the range squashes the whole land ramp.
|
||||
//
|
||||
// And the top of the ramp is a high percentile, not the maximum. One 2800 m summit over a continent whose
|
||||
// land is mostly under 300 m puts every other cell into the bottom tenth of the ramp, and the map reads as
|
||||
// uniform green with a white dot on it — which says far more about one pixel than about the terrain. The
|
||||
// percentile lets the tint span the distribution that is actually there; the few cells above it clamp to
|
||||
// snow, which is what they should look like anyway.
|
||||
sea := resampleMask(opt.Sea, h.W, h.H, size)
|
||||
landVals := make([]float64, 0, len(small.Data))
|
||||
for i, v := range small.Data {
|
||||
if sea != nil && sea[i] {
|
||||
continue
|
||||
}
|
||||
landVals = append(landVals, float64(v))
|
||||
}
|
||||
landMax := 1.0
|
||||
if len(landVals) > 0 {
|
||||
sort.Float64s(landVals)
|
||||
landMax = landVals[int(0.995*float64(len(landVals)-1))]
|
||||
}
|
||||
if landMax <= 0 {
|
||||
landMax = 1
|
||||
}
|
||||
var seaMin float64
|
||||
for i, v := range small.Data {
|
||||
if sea != nil && sea[i] && float64(v) < seaMin {
|
||||
seaMin = float64(v)
|
||||
}
|
||||
}
|
||||
|
||||
var flow *Field
|
||||
riverA := opt.RiverKm2 * 1e6
|
||||
if opt.Flow != nil && riverA > 0 {
|
||||
flow = opt.Flow.Resample(size, size)
|
||||
}
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
for y := 0; y < size; y++ {
|
||||
for x := 0; x < size; x++ {
|
||||
i := y*size + x
|
||||
elev := float64(small.Data[i])
|
||||
var c rgb
|
||||
|
||||
if sea != nil && sea[i] {
|
||||
d := 0.0
|
||||
if seaMin < 0 {
|
||||
d = math.Min(1, (opt.SeaLevelM-elev)/(opt.SeaLevelM-seaMin))
|
||||
}
|
||||
c = rgb{
|
||||
seaShallow[0] + (seaDeep[0]-seaShallow[0])*d,
|
||||
seaShallow[1] + (seaDeep[1]-seaShallow[1])*d,
|
||||
seaShallow[2] + (seaDeep[2]-seaShallow[2])*d,
|
||||
}
|
||||
} else {
|
||||
c = ramp(math.Min(1, math.Max(0, elev)/landMax))
|
||||
// Hillshade from the north-west at 45 degrees, the DEM convention. Applied to land only;
|
||||
// shading the sea floor would draw attention to bathymetry nobody will ever see.
|
||||
gx := float64(small.AtClamped(x+1, y)-small.AtClamped(x-1, y)) * exag
|
||||
gy := float64(small.AtClamped(x, y+1)-small.AtClamped(x, y-1)) * exag
|
||||
slope := math.Atan(math.Hypot(gx, gy) / (2 * small.CellM))
|
||||
aspect := math.Atan2(gy, -gx)
|
||||
lum := math.Cos(slope)*math.Cos(math.Pi/4) +
|
||||
math.Sin(slope)*math.Sin(math.Pi/4)*math.Cos(3*math.Pi/4-aspect)
|
||||
lum = 0.45 + 0.75*math.Max(0, lum)
|
||||
for k := range c {
|
||||
c[k] *= lum
|
||||
}
|
||||
}
|
||||
|
||||
// Rivers on top, their strength growing with the log of drainage area so a trunk reads darker
|
||||
// than a headwater without needing a width in pixels.
|
||||
if flow != nil {
|
||||
if a := float64(flow.Data[i]); a >= riverA {
|
||||
w := math.Min(1, math.Log10(a/riverA)/2.2)
|
||||
blend := 0.45 + 0.55*w
|
||||
for k := range c {
|
||||
c[k] = c[k]*(1-blend) + riverTint[k]*blend
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
img.Set(x, y, color.RGBA{clamp8(c[0]), clamp8(c[1]), clamp8(c[2]), 255})
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
bw := bufio.NewWriterSize(f, 1<<20)
|
||||
enc := png.Encoder{CompressionLevel: png.DefaultCompression}
|
||||
if err := enc.Encode(bw, img); err != nil {
|
||||
return err
|
||||
}
|
||||
return bw.Flush()
|
||||
}
|
||||
|
||||
// resampleMask takes a boolean mask down to the preview size by nearest neighbour; a mask has no meaningful
|
||||
// average.
|
||||
func resampleMask(mask []bool, w, h, size int) []bool {
|
||||
if mask == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]bool, size*size)
|
||||
for y := 0; y < size; y++ {
|
||||
sy := y * (h - 1) / (size - 1)
|
||||
for x := 0; x < size; x++ {
|
||||
sx := x * (w - 1) / (size - 1)
|
||||
out[y*size+x] = mask[sy*w+sx]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clamp8(v float64) uint8 {
|
||||
if v <= 0 {
|
||||
return 0
|
||||
}
|
||||
if v >= 255 {
|
||||
return 255
|
||||
}
|
||||
return uint8(v + 0.5)
|
||||
}
|
||||
|
||||
// subMask is Sub for a boolean mask.
|
||||
func subMask(mask []bool, w, h int, crop [4]float64) []bool {
|
||||
clamp := func(v float64) float64 { return math.Min(1, math.Max(0, v)) }
|
||||
x0 := int(clamp(crop[0]) * float64(w-1))
|
||||
y0 := int(clamp(crop[1]) * float64(h-1))
|
||||
x1 := int(clamp(crop[2]) * float64(w-1))
|
||||
y1 := int(clamp(crop[3]) * float64(h-1))
|
||||
if x1 <= x0 {
|
||||
x1 = x0 + 1
|
||||
}
|
||||
if y1 <= y0 {
|
||||
y1 = y0 + 1
|
||||
}
|
||||
cw, ch := x1-x0+1, y1-y0+1
|
||||
out := make([]bool, cw*ch)
|
||||
for y := 0; y < ch; y++ {
|
||||
copy(out[y*cw:(y+1)*cw], mask[(y0+y)*w+x0:(y0+y)*w+x0+cw])
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package field
|
||||
|
||||
import "math"
|
||||
|
||||
// The vertex convention, which every resample here obeys: a field of N samples a side spans N-1 quads, so
|
||||
// sample i sits at parameter i/(N-1) and the four corners are fixed points of any resize. Getting this wrong
|
||||
// shifts the whole map by half a cell per resize and the error compounds over a pipeline.
|
||||
|
||||
// Resample returns the field at a new resolution: block means when shrinking by an exact integer factor
|
||||
// (which is what the geology grid wants, and what preserves mass), bilinear otherwise. Ported from
|
||||
// heightmap_io.resample, which chose the same two paths for the same reasons.
|
||||
func (f *Field) Resample(w, h int) *Field {
|
||||
if w == f.W && h == f.H {
|
||||
return f.Clone()
|
||||
}
|
||||
cell := f.CellM * float64(f.W-1) / float64(w-1)
|
||||
if w < f.W && (f.W-1)%(w-1) == 0 && (f.H-1)%(h-1) == 0 && (f.W-1)/(w-1) == (f.H-1)/(h-1) {
|
||||
return f.blockMean((f.W-1)/(w-1), w, h, cell)
|
||||
}
|
||||
return f.bilinear(w, h, cell)
|
||||
}
|
||||
|
||||
// blockMean averages each factor x factor block of quads onto one output sample. The last row and column are
|
||||
// half-blocks under the vertex convention, which is why the accumulation counts what it actually summed.
|
||||
func (f *Field) blockMean(factor, w, h int, cell float64) *Field {
|
||||
out := New(w, h, cell)
|
||||
Rows(h, func(y0, y1 int) {
|
||||
for oy := y0; oy < y1; oy++ {
|
||||
for ox := 0; ox < w; ox++ {
|
||||
var sum float64
|
||||
var n int
|
||||
for dy := 0; dy < factor; dy++ {
|
||||
sy := oy*factor + dy - factor/2
|
||||
if sy < 0 || sy >= f.H {
|
||||
continue
|
||||
}
|
||||
for dx := 0; dx < factor; dx++ {
|
||||
sx := ox*factor + dx - factor/2
|
||||
if sx < 0 || sx >= f.W {
|
||||
continue
|
||||
}
|
||||
sum += float64(f.At(sx, sy))
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
out.Data[out.Idx(ox, oy)] = float32(sum / float64(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *Field) bilinear(w, h int, cell float64) *Field {
|
||||
out := New(w, h, cell)
|
||||
sx := float64(f.W-1) / float64(w-1)
|
||||
sy := float64(f.H-1) / float64(h-1)
|
||||
Rows(h, func(y0, y1 int) {
|
||||
for oy := y0; oy < y1; oy++ {
|
||||
fy := float64(oy) * sy
|
||||
iy := int(fy)
|
||||
ty := float32(fy - float64(iy))
|
||||
for ox := 0; ox < w; ox++ {
|
||||
fx := float64(ox) * sx
|
||||
ix := int(fx)
|
||||
tx := float32(fx - float64(ix))
|
||||
a := f.AtClamped(ix, iy)
|
||||
b := f.AtClamped(ix+1, iy)
|
||||
c := f.AtClamped(ix, iy+1)
|
||||
d := f.AtClamped(ix+1, iy+1)
|
||||
top := a + (b-a)*tx
|
||||
bot := c + (d-c)*tx
|
||||
out.Data[out.Idx(ox, oy)] = top + (bot-top)*ty
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// UpsampleInt is the geology-to-detail step: an exact integer factor on the quad count, so 1786 at factor 4
|
||||
// becomes (1786-1)*4+1 = 7141 with every source sample landing exactly on an output sample and no resample
|
||||
// phase error at all. Catmull-Rom between them, which is the bicubic the spec asks for and does not overshoot
|
||||
// into ringing the way a plain cubic does on a ridge.
|
||||
func (f *Field) UpsampleInt(factor int) *Field {
|
||||
if factor <= 1 {
|
||||
return f.Clone()
|
||||
}
|
||||
w := (f.W-1)*factor + 1
|
||||
h := (f.H-1)*factor + 1
|
||||
out := New(w, h, f.CellM/float64(factor))
|
||||
inv := 1.0 / float64(factor)
|
||||
Rows(h, func(y0, y1 int) {
|
||||
for oy := y0; oy < y1; oy++ {
|
||||
sy := oy / factor
|
||||
ty := float64(oy%factor) * inv
|
||||
for ox := 0; ox < w; ox++ {
|
||||
sx := ox / factor
|
||||
tx := float64(ox%factor) * inv
|
||||
var col [4]float64
|
||||
for k := 0; k < 4; k++ {
|
||||
col[k] = catmullRom(
|
||||
float64(f.AtClamped(sx-1, sy-1+k)),
|
||||
float64(f.AtClamped(sx, sy-1+k)),
|
||||
float64(f.AtClamped(sx+1, sy-1+k)),
|
||||
float64(f.AtClamped(sx+2, sy-1+k)), tx)
|
||||
}
|
||||
out.Data[out.Idx(ox, oy)] = float32(catmullRom(col[0], col[1], col[2], col[3], ty))
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func catmullRom(p0, p1, p2, p3, t float64) float64 {
|
||||
t2 := t * t
|
||||
t3 := t2 * t
|
||||
return 0.5 * ((2 * p1) +
|
||||
(-p0+p2)*t +
|
||||
(2*p0-5*p1+4*p2-p3)*t2 +
|
||||
(-p0+3*p1-3*p2+p3)*t3)
|
||||
}
|
||||
|
||||
// ToUnit squashes a field into [0, 1] against a percentile, optionally through log1p first: what the four
|
||||
// derivative maps (flow, wear, deposit) need before they become 8-bit PNGs. Ported from
|
||||
// heightmap_erosion.to_unit.
|
||||
func (f *Field) ToUnit(percentile float64, logScale bool) *Field {
|
||||
out := NewLike(f)
|
||||
for i, v := range f.Data {
|
||||
x := float64(v)
|
||||
if x < 0 {
|
||||
x = 0
|
||||
}
|
||||
if logScale {
|
||||
x = math.Log1p(x)
|
||||
}
|
||||
out.Data[i] = float32(x)
|
||||
}
|
||||
top := float64(out.Percentile(percentile))
|
||||
if top < 1e-6 {
|
||||
top = 1e-6
|
||||
}
|
||||
for i, v := range out.Data {
|
||||
x := float64(v) / top
|
||||
if x > 1 {
|
||||
x = 1
|
||||
}
|
||||
out.Data[i] = float32(x)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Sub extracts a sub-rectangle given in map coordinates (x0, y0, x1, y1 in 0..1), at the source resolution.
|
||||
// Used by the preview to look at a piece of the map closely, which is the only way to judge whether hill
|
||||
// country reads as hill country rather than as small mountains.
|
||||
func (f *Field) Sub(crop [4]float64) *Field {
|
||||
clamp := func(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
x0 := int(clamp(crop[0]) * float64(f.W-1))
|
||||
y0 := int(clamp(crop[1]) * float64(f.H-1))
|
||||
x1 := int(clamp(crop[2]) * float64(f.W-1))
|
||||
y1 := int(clamp(crop[3]) * float64(f.H-1))
|
||||
if x1 <= x0 {
|
||||
x1 = x0 + 1
|
||||
}
|
||||
if y1 <= y0 {
|
||||
y1 = y0 + 1
|
||||
}
|
||||
w, h := x1-x0+1, y1-y0+1
|
||||
out := New(w, h, f.CellM)
|
||||
for y := 0; y < h; y++ {
|
||||
copy(out.Data[y*w:(y+1)*w], f.Data[(y0+y)*f.W+x0:(y0+y)*f.W+x0+w])
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package fluvial
|
||||
|
||||
// A monotone bucket priority queue, which is what priority-flood actually needs.
|
||||
//
|
||||
// The flood pops cells in non-decreasing elevation and never pushes anything below the cell it just popped:
|
||||
// a neighbour lower than the current front is raised to it and goes to the FIFO instead. That "monotone"
|
||||
// property is exactly the condition under which a bucket queue beats a binary heap, because the read cursor
|
||||
// only ever moves forward and both operations become an append and a scan. The heap was costing about
|
||||
// log2(3.2M) = 22 comparisons and as many cache misses per operation, on two thirds of the solve's runtime.
|
||||
//
|
||||
// Elevations are quantised into fixed-width buckets. Cells inside one bucket pop in an arbitrary but
|
||||
// deterministic order (last in, first out), so a spill point can be wrong by at most one bucket width. At a
|
||||
// centimetre against a 2 km elevation range that is far below the millimetre-per-cell epsilon the flood adds
|
||||
// anyway, and it is the same approximation an integer-elevation priority-flood makes by construction.
|
||||
type bucketPQ struct {
|
||||
lo float64
|
||||
width float64
|
||||
buckets [][]int32
|
||||
cur int
|
||||
count int
|
||||
}
|
||||
|
||||
const bucketWidthM = 0.01
|
||||
|
||||
func newBucketPQ(loM, hiM float64) *bucketPQ {
|
||||
if hiM <= loM {
|
||||
hiM = loM + 1
|
||||
}
|
||||
// Headroom above the top: the flood raises cells by epsilon as it fills, so the highest key pushed can
|
||||
// sit slightly above the terrain's own maximum.
|
||||
n := int((hiM-loM)/bucketWidthM) + 64
|
||||
return &bucketPQ{lo: loM, width: bucketWidthM, buckets: make([][]int32, n)}
|
||||
}
|
||||
|
||||
func (q *bucketPQ) reset() {
|
||||
for i := range q.buckets {
|
||||
q.buckets[i] = q.buckets[i][:0]
|
||||
}
|
||||
q.cur = 0
|
||||
q.count = 0
|
||||
}
|
||||
|
||||
func (q *bucketPQ) len() int { return q.count }
|
||||
|
||||
func (q *bucketPQ) push(elev float32, idx int32) {
|
||||
b := int((float64(elev) - q.lo) / q.width)
|
||||
if b < q.cur {
|
||||
b = q.cur // monotone: never behind the cursor, whatever rounding says
|
||||
}
|
||||
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 {
|
||||
for q.cur < len(q.buckets) && len(q.buckets[q.cur]) == 0 {
|
||||
q.cur++
|
||||
}
|
||||
if q.cur >= len(q.buckets) {
|
||||
return -1
|
||||
}
|
||||
b := q.buckets[q.cur]
|
||||
v := b[len(b)-1]
|
||||
q.buckets[q.cur] = b[:len(b)-1]
|
||||
q.count--
|
||||
return v
|
||||
}
|
||||
|
||||
// frontElev is the elevation the cursor is at, which the FIFO compares itself against.
|
||||
func (q *bucketPQ) frontElev() float32 {
|
||||
for q.cur < len(q.buckets) && len(q.buckets[q.cur]) == 0 {
|
||||
q.cur++
|
||||
}
|
||||
return float32(q.lo + float64(q.cur)*q.width)
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
// Package fluvial is the stream-power erosion solve: dh/dt = U - K * A^m * S^n, integrated implicitly up the
|
||||
// drainage stack by the method of Braun & Willett (2013).
|
||||
//
|
||||
// This is the reason the generator exists (D-47). Particle erosion carves the path each droplet happens to
|
||||
// take: it makes wear, but never a network. Stream power solves for drainage area first and erodes in
|
||||
// proportion to it, which is what produces a branching hierarchy, valleys whose size matches the area they
|
||||
// drain, and divides that sit where the basins either side of them put them.
|
||||
//
|
||||
// Four things happen per step, in this order:
|
||||
//
|
||||
// 1. Depressions are filled (priority-flood), because a D8 receiver graph containing a pit has no path to
|
||||
// base level and the implicit solve has nothing to descend to. This is the only part of a step that is
|
||||
// not O(n), so it runs every FillEvery steps, not every step.
|
||||
// 2. Receivers and the stack are computed: steepest descent to one of eight neighbours, then a depth-first
|
||||
// ordering in which every node appears after its receiver.
|
||||
// 3. Drainage area is accumulated down the stack in reverse.
|
||||
// 4. The implicit update runs up the stack, so each node's receiver already holds its new height. This is
|
||||
// what makes the scheme unconditionally stable in dt, and it is why a naive explicit solver is not an
|
||||
// acceptable substitute at dt = 1500 yr.
|
||||
package fluvial
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/thermal"
|
||||
)
|
||||
|
||||
// Params are the stream-power constants. K is per year with A in m².
|
||||
type Params struct {
|
||||
K float64
|
||||
M float64
|
||||
N float64
|
||||
DtYr float64
|
||||
Steps int
|
||||
Diffusion float64 // hillslope diffusivity, m²/yr
|
||||
FillEvery int
|
||||
|
||||
// Landsliding. Stream power bounds nothing at small drainage area, so without this the hillslopes grow
|
||||
// as steep as the uplift rate asks them to and the map becomes needles. TalusSlope is rise over run; 0
|
||||
// disables. It runs inside the loop rather than after it, because a cap applied once at the end just
|
||||
// shaves the tops off, while a cap applied throughout changes where the sediment goes.
|
||||
TalusSlope float64
|
||||
ThermalEvery int
|
||||
ThermalPasses int
|
||||
|
||||
// CriticalAreaM2 is where channels begin. Below it the cell is a hillslope: it still rises, and
|
||||
// diffusion and landsliding still shape it, but stream power does not incise it.
|
||||
//
|
||||
// This is not a tuning knob, it is a correctness fix. Stream power is a law about channels, and applying
|
||||
// it at A = one cell says a cell that drains only itself should stand at U/(K*cellM^(2m)) — 63 degrees at
|
||||
// the rates here. That is why the map came out as needles at a 39 degree median. It is also why relief
|
||||
// was resolution-dependent (1020 m at 512², 2605 m at 1786² on one seed): halving the cell size halves
|
||||
// the smallest A and steepens every divide, for ever. A critical area is a physical length, so the same
|
||||
// landscape comes out at any resolution, which is the property the full-resolution run depends on.
|
||||
CriticalAreaM2 float64
|
||||
|
||||
// ChannelTaper is the exponent on (A/Ac) below the channel head. 0 is no taper, 2 is strong.
|
||||
ChannelTaper float64
|
||||
|
||||
// CriticalSlope is Sc in the nonlinear hillslope law, rise over run. Above zero it selects
|
||||
// DiffuseNonlinear over plain linear diffusion and takes the repose clamp out of the step loop; see
|
||||
// hillslope.go and Run. Zero keeps the old pairing of linear diffusion and an in-loop clamp.
|
||||
CriticalSlope float64
|
||||
SlopeCap float64 // where the flux stops stiffening, as a fraction of Sc
|
||||
MaxHillslopeSub int // the sub-step budget that bound buys
|
||||
}
|
||||
|
||||
// Grid holds the flow topology and the scratch it is built from. Allocated once and reused across every
|
||||
// step: at 3.2 M cells the allocations would otherwise dominate the solve.
|
||||
type Grid struct {
|
||||
W, H int
|
||||
CellM float64
|
||||
|
||||
// Base marks cells fixed at base level: the ocean. The map border is an outlet too, so what actually
|
||||
// counts as "fixed" is Base plus the border, and that union is `fixed`. Keeping only Base in mind here is
|
||||
// how the outlets themselves ended up being uplifted: on a map with no ocean, every border cell has
|
||||
// Receiver == itself and Base == false, so base level rose two metres a step and the whole solve chased
|
||||
// it. The steady-state test is what caught it.
|
||||
Base []bool
|
||||
fixed []bool
|
||||
|
||||
Receiver []int32 // index of the cell this one drains to; itself for a base cell
|
||||
Length []float32 // distance to that receiver, metres
|
||||
Stack []int32 // every node after its receiver
|
||||
Area []float32 // drainage area, m²
|
||||
|
||||
seed uint64 // the jitter's seed; see jitter.go and SetSeed
|
||||
donorOff []int32
|
||||
donorList []int32
|
||||
cursor []int32
|
||||
closed []bool
|
||||
pq *bucketPQ
|
||||
fifo []int32
|
||||
scratch []float32
|
||||
}
|
||||
|
||||
// SetElevationRange sizes the flood's bucket queue. Called once, with the manifest's elevation range plus a
|
||||
// margin, before the first step.
|
||||
func (g *Grid) SetElevationRange(loM, hiM float64) {
|
||||
g.pq = newBucketPQ(loM, hiM)
|
||||
}
|
||||
|
||||
var (
|
||||
// D8, in the order (-1,-1) .. (1,1) skipping the centre. The order is fixed so that a run is reproducible,
|
||||
// but it is no longer what decides a tie between two equally steep neighbours: a fixed order resolves
|
||||
// every tie the same way and prints its preferred axis across any near-flat ground. See jitter.go.
|
||||
dx8 = [8]int{-1, 0, 1, -1, 1, -1, 0, 1}
|
||||
dy8 = [8]int{-1, -1, -1, 0, 0, 1, 1, 1}
|
||||
)
|
||||
|
||||
func NewGrid(w, h int, cellM float64, base []bool) *Grid {
|
||||
n := w * h
|
||||
g := &Grid{
|
||||
W: w, H: h, CellM: cellM, Base: base,
|
||||
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),
|
||||
}
|
||||
g.fixed = make([]bool, n)
|
||||
for i := range g.fixed {
|
||||
g.fixed[i] = g.isOutlet(i)
|
||||
}
|
||||
g.SetElevationRange(-2000, 4000)
|
||||
return g
|
||||
}
|
||||
|
||||
// FillDepressions raises closed depressions to their spill point, in place, using Barnes' improved
|
||||
// priority-flood with a plain FIFO beside the heap. The FIFO is the optimisation that matters: on real
|
||||
// terrain most cells are reached while descending into an already-flooded pit, and those never touch the
|
||||
// heap, which turns the cost from "half an hour over a run" into something affordable.
|
||||
//
|
||||
// The epsilon variant adds a millimetre of fall per cell across a flat, so filled lakes still route rather
|
||||
// than becoming a plateau the flow accumulator cannot leave. That millimetre is scattered per cell by a hash
|
||||
// of the index rather than applied uniformly: a uniform epsilon means the only gradient on a flat is the
|
||||
// flood's own traversal order, and the router then draws that order as rivers. See jitter.go.
|
||||
func (g *Grid) FillDepressions(h []float32, epsilon float32) {
|
||||
n := g.W * g.H
|
||||
for i := range g.closed {
|
||||
g.closed[i] = false
|
||||
}
|
||||
g.pq.reset()
|
||||
g.fifo = g.fifo[:0]
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
if g.isOutlet(i) {
|
||||
g.closed[i] = true
|
||||
g.pq.push(h[i], int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
head := 0
|
||||
for g.pq.len() > 0 || head < len(g.fifo) {
|
||||
var c int32
|
||||
var celev float32
|
||||
// Drain the FIFO while it cannot violate the queue's ordering.
|
||||
if head < len(g.fifo) && (g.pq.len() == 0 || h[g.fifo[head]] <= g.pq.frontElev()) {
|
||||
c = g.fifo[head]
|
||||
head++
|
||||
celev = h[c]
|
||||
} else {
|
||||
c = g.pq.pop()
|
||||
if c < 0 {
|
||||
break
|
||||
}
|
||||
celev = h[c]
|
||||
}
|
||||
cx := int(c) % g.W
|
||||
cy := int(c) / g.W
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
ni := int32(ny*g.W + nx)
|
||||
if g.closed[ni] {
|
||||
continue
|
||||
}
|
||||
g.closed[ni] = true
|
||||
if h[ni] <= celev {
|
||||
h[ni] = celev + epsilon*(0.5+hash01(g.seed, ni))
|
||||
g.fifo = append(g.fifo, ni)
|
||||
} else {
|
||||
g.pq.push(h[ni], ni)
|
||||
}
|
||||
}
|
||||
// Compact the FIFO occasionally so it does not grow without bound over a whole flood.
|
||||
if head > n/2 {
|
||||
g.fifo = append(g.fifo[:0], g.fifo[head:]...)
|
||||
head = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Grid) isOutlet(i int) bool {
|
||||
if g.Base != nil && g.Base[i] {
|
||||
return true
|
||||
}
|
||||
x, y := i%g.W, i/g.W
|
||||
return x == 0 || y == 0 || x == g.W-1 || y == g.H-1
|
||||
}
|
||||
|
||||
// ComputeReceivers picks the steepest downhill neighbour of each cell. A base cell, and any cell with no
|
||||
// lower neighbour, receives itself, which makes it a root of the stack.
|
||||
//
|
||||
// Two neighbours of equal steepness are separated by a hash of the cell and the direction rather than by the
|
||||
// fixed order of dx8/dy8. A fixed order always resolves a tie the same way, which on any near-flat surface
|
||||
// puts a systematic preference on one grid axis and shows up as rivers that run straight along it. The
|
||||
// perturbation is a tenth of a percent, so it decides near-ties and nothing else: a neighbour that is
|
||||
// genuinely steeper than another by more than that is still chosen.
|
||||
func (g *Grid) ComputeReceivers(h []float32) {
|
||||
diag := float32(g.CellM * math.Sqrt2)
|
||||
card := float32(g.CellM)
|
||||
field.Rows(g.H, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < g.W; x++ {
|
||||
i := int32(y*g.W + x)
|
||||
if g.isOutlet(int(i)) {
|
||||
g.Receiver[i] = i
|
||||
g.Length[i] = card
|
||||
continue
|
||||
}
|
||||
best := int32(-1)
|
||||
bestJitter := float32(0)
|
||||
bestLen := card
|
||||
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
|
||||
}
|
||||
ni := int32(ny*g.W + nx)
|
||||
l := card
|
||||
if dx8[k] != 0 && dy8[k] != 0 {
|
||||
l = diag
|
||||
}
|
||||
s := (h[i] - h[ni]) / l
|
||||
if s <= 0 {
|
||||
continue
|
||||
}
|
||||
// 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))
|
||||
if sj > bestJitter {
|
||||
bestJitter, best, bestLen = sj, ni, l
|
||||
}
|
||||
}
|
||||
if best < 0 {
|
||||
g.Receiver[i] = i
|
||||
g.Length[i] = card
|
||||
} else {
|
||||
g.Receiver[i] = best
|
||||
g.Length[i] = bestLen
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BuildStack orders every node after its receiver, by counting donors into a CSR list and then walking it
|
||||
// depth-first from the roots. O(n), no recursion, and the order is fully determined by the receiver array,
|
||||
// so it does not vary between runs.
|
||||
func (g *Grid) BuildStack() {
|
||||
n := g.W * g.H
|
||||
for i := 0; i <= n; i++ {
|
||||
g.donorOff[i] = 0
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
r := g.Receiver[i]
|
||||
if int(r) != i {
|
||||
g.donorOff[r+1]++
|
||||
}
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
g.donorOff[i+1] += g.donorOff[i]
|
||||
}
|
||||
cursor := g.scratchInt32()
|
||||
copy(cursor, g.donorOff[:n])
|
||||
for i := 0; i < n; i++ {
|
||||
r := g.Receiver[i]
|
||||
if int(r) != i {
|
||||
g.donorList[cursor[r]] = int32(i)
|
||||
cursor[r]++
|
||||
}
|
||||
}
|
||||
|
||||
g.Stack = g.Stack[:0]
|
||||
for i := 0; i < n; i++ {
|
||||
if int(g.Receiver[i]) == i {
|
||||
g.Stack = append(g.Stack, int32(i))
|
||||
}
|
||||
}
|
||||
// Depth-first: everything already in the stack expands its donors, which land after it.
|
||||
for read := 0; read < len(g.Stack); read++ {
|
||||
c := g.Stack[read]
|
||||
for d := g.donorOff[c]; d < g.donorOff[c+1]; d++ {
|
||||
g.Stack = append(g.Stack, g.donorList[d])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if cap(g.cursor) < g.W*g.H {
|
||||
g.cursor = make([]int32, g.W*g.H)
|
||||
}
|
||||
return g.cursor[:g.W*g.H]
|
||||
}
|
||||
|
||||
// Accumulate sums drainage area down the stack in reverse, so every node has collected its whole upstream
|
||||
// catchment before its receiver is reached.
|
||||
func (g *Grid) Accumulate() {
|
||||
cell := float32(g.CellM * g.CellM)
|
||||
for i := range g.Area {
|
||||
g.Area[i] = cell
|
||||
}
|
||||
for k := len(g.Stack) - 1; k >= 0; k-- {
|
||||
i := g.Stack[k]
|
||||
r := g.Receiver[i]
|
||||
if r != i {
|
||||
g.Area[r] += g.Area[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StreamPower is the implicit update, walked up the stack. uplift is in metres per year and k is the local
|
||||
// erodibility; either may be nil for a uniform value.
|
||||
func (g *Grid) StreamPower(h []float32, uplift, k []float32, p Params) {
|
||||
dt := p.DtYr
|
||||
linear := math.Abs(p.N-1) < 1e-9
|
||||
for _, i := range g.Stack {
|
||||
if g.fixed[i] {
|
||||
continue // base level is fixed: no uplift, no erosion
|
||||
}
|
||||
u := 0.0
|
||||
if uplift != nil {
|
||||
u = float64(uplift[i])
|
||||
}
|
||||
r := g.Receiver[i]
|
||||
if r == i {
|
||||
// A local minimum that the last flood has not reached yet. It still rises: skipping uplift here
|
||||
// freezes exactly the cells that differential uplift is busy pushing up, which quietly removes
|
||||
// the basins from the landscape between floods.
|
||||
h[i] += float32(dt * u)
|
||||
continue
|
||||
}
|
||||
kk := p.K
|
||||
if k != nil {
|
||||
kk *= float64(k[i])
|
||||
}
|
||||
hr := float64(h[r])
|
||||
hi := float64(h[i]) + dt*u
|
||||
area := float64(g.Area[i])
|
||||
if p.CriticalAreaM2 > 0 && area < p.CriticalAreaM2 {
|
||||
// Below the channel head, incision is tapered rather than switched off. Switching it off
|
||||
// entirely is what broke: the material had nowhere to go, because the only remaining transport
|
||||
// was landsliding, which caps slope but not height, and the map grew until 22 % of it clipped
|
||||
// the elevation range. A taper suppresses the fine dissection that makes lowlands look like
|
||||
// small mountains while still letting the hillslope shed its uplift into the network.
|
||||
kk *= math.Pow(area/p.CriticalAreaM2, p.ChannelTaper)
|
||||
}
|
||||
a := math.Pow(area, p.M)
|
||||
l := float64(g.Length[i])
|
||||
|
||||
var next float64
|
||||
if linear {
|
||||
f := kk * dt * a / l
|
||||
next = (hi + f*hr) / (1 + f)
|
||||
} else {
|
||||
next = newtonStreamPower(hi, hr, kk*dt*a, l, p.N)
|
||||
}
|
||||
// A node may never fall below what it drains into; the implicit form only guarantees that while
|
||||
// uplift has not raised the receiver past it.
|
||||
if next < hr {
|
||||
next = hr
|
||||
}
|
||||
h[i] = float32(next)
|
||||
}
|
||||
}
|
||||
|
||||
// newtonStreamPower solves h - hi + c*((h-hr)/l)^n = 0 for n != 1. Five iterations from the linear answer is
|
||||
// comfortably enough at the exponents anyone actually uses; it is here so the manifest's n is not a lie.
|
||||
func newtonStreamPower(hi, hr, c, l, n float64) float64 {
|
||||
f := c / l
|
||||
h := (hi + f*hr) / (1 + f) // the n = 1 answer, as a starting point
|
||||
for iter := 0; iter < 5; iter++ {
|
||||
d := h - hr
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
s := d / l
|
||||
fx := h - hi + c*math.Pow(s, n)/1
|
||||
dfx := 1 + c*n*math.Pow(s, n-1)/l
|
||||
if dfx == 0 {
|
||||
break
|
||||
}
|
||||
step := fx / dfx
|
||||
h -= step
|
||||
if math.Abs(step) < 1e-6 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if h < hr {
|
||||
h = hr
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Diffuse is hillslope diffusion, which rounds the divides and stops every channel head from being a needle.
|
||||
//
|
||||
// Explicit five-point diffusion is stable only while D*dt/dx² <= 0.25, and the defaults sit above that
|
||||
// (0.02 m²/yr at dt 1500 on 8 m cells is 0.47), so it sub-steps rather than quietly going unstable. This is
|
||||
// the sort of thing that shows up as a checkerboard three thousand steps in.
|
||||
func (g *Grid) Diffuse(h []float32, d, dt float64) {
|
||||
if d <= 0 || dt <= 0 {
|
||||
return
|
||||
}
|
||||
dx2 := g.CellM * g.CellM
|
||||
total := d * dt / dx2
|
||||
sub := int(math.Ceil(total / 0.2))
|
||||
if sub < 1 {
|
||||
sub = 1
|
||||
}
|
||||
alpha := float32(total / float64(sub))
|
||||
src := h
|
||||
tmp := g.scratch[:len(h)]
|
||||
for s := 0; s < sub; s++ {
|
||||
field.Rows(g.H, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < g.W; x++ {
|
||||
i := y*g.W + x
|
||||
if g.fixed[i] {
|
||||
tmp[i] = src[i]
|
||||
continue
|
||||
}
|
||||
c := src[i]
|
||||
lap := clampAt(src, g.W, g.H, x-1, y) + clampAt(src, g.W, g.H, x+1, y) +
|
||||
clampAt(src, g.W, g.H, x, y-1) + clampAt(src, g.W, g.H, x, y+1) - 4*c
|
||||
tmp[i] = c + alpha*lap
|
||||
}
|
||||
}
|
||||
})
|
||||
copy(src, tmp)
|
||||
}
|
||||
}
|
||||
|
||||
func clampAt(a []float32, w, h, x, y int) float32 {
|
||||
if x < 0 {
|
||||
x = 0
|
||||
} else if x >= w {
|
||||
x = w - 1
|
||||
}
|
||||
if y < 0 {
|
||||
y = 0
|
||||
} else if y >= h {
|
||||
y = h - 1
|
||||
}
|
||||
return a[y*w+x]
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
fill := p.FillEvery
|
||||
if fill < 1 {
|
||||
fill = 1
|
||||
}
|
||||
for step := 0; step < p.Steps; step++ {
|
||||
if step%fill == 0 {
|
||||
g.FillDepressions(h, 1e-3)
|
||||
}
|
||||
g.ComputeReceivers(h)
|
||||
g.BuildStack()
|
||||
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
|
||||
// no bounded-flux transport law can hold, which is a fact about the forcing and not about the
|
||||
// scheme. What changes is the order and who gets the last word. The clamp cuts along the eight
|
||||
// D8 directions and leaves grid-aligned pyramid faces; nonlinear diffusion then runs over the
|
||||
// result with a symmetric five-point stencil and rounds them off before the next step sees them.
|
||||
//
|
||||
// Running the clamp only once at the end was tried and is worse: a thousand steps of unclamped
|
||||
// growth arrive at it together, so it cuts deeply, and nothing runs afterwards to soften what it
|
||||
// cut. The facets came back in the summits. Little and often, with diffusion last, is what keeps
|
||||
// the constraint without printing the stencil.
|
||||
if p.TalusSlope > 0 && p.ThermalEvery > 0 && step%p.ThermalEvery == 0 {
|
||||
g.ClampToRepose(h, p.TalusSlope)
|
||||
}
|
||||
g.DiffuseNonlinear(h, p.Diffusion, p.CriticalSlope, p.SlopeCap, p.DtYr, p.MaxHillslopeSub)
|
||||
} else {
|
||||
g.Diffuse(h, p.Diffusion, p.DtYr)
|
||||
if p.TalusSlope > 0 && p.ThermalEvery > 0 && step%p.ThermalEvery == 0 {
|
||||
// The constraint first, which actually binds, then the transport, which puts scree at the
|
||||
// foot of what the constraint cut.
|
||||
g.ClampToRepose(h, p.TalusSlope)
|
||||
if p.ThermalPasses > 0 {
|
||||
thermal.Apply(h, g.W, g.H, g.CellM, p.TalusSlope, p.ThermalPasses, g.fixed, g.scratch)
|
||||
}
|
||||
}
|
||||
}
|
||||
if log != nil && p.Steps >= 10 && step%(p.Steps/10) == 0 {
|
||||
log(step, p.Steps, float64(step)/float64(p.Steps)*100)
|
||||
}
|
||||
}
|
||||
// One last fill so the result has no closed pits to hand to the detail passes, and one last routing so
|
||||
// Area and Receiver describe the surface that is actually returned.
|
||||
g.FillDepressions(h, 1e-3)
|
||||
g.ComputeReceivers(h)
|
||||
g.BuildStack()
|
||||
g.Accumulate()
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package fluvial
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
// Nonlinear hillslope transport: q = D*S / (1 - (S/Sc)^2), the Roering form.
|
||||
//
|
||||
// It replaces the pair of patches that stood in for a hillslope law — linear diffusion, which does not care
|
||||
// how steep the ground is, and ClampToRepose, which cares about nothing else — with one process that does
|
||||
// both jobs. As S goes to zero it *is* linear diffusion, q -> D*S, so the divides in the lowlands round over
|
||||
// exactly as before. As S approaches Sc the flux diverges, so the slope approaches Sc and never reaches it.
|
||||
//
|
||||
// The difference that shows is not in the numbers, it is in the shape. ClampToRepose cuts each cell down to
|
||||
// talus*distance along one of eight neighbour directions and pops cells in grid order within an elevation
|
||||
// bucket, so what it leaves is pyramids with faces aligned to the grid — the blocky, ruler-cut facets that
|
||||
// are visible in any preview of a mountain belt here. Nothing about that is geology; it is the D8 stencil
|
||||
// printed onto the landscape. Nonlinear diffusion approaches the same limiting angle *asymptotically* and
|
||||
// through a symmetric five-point stencil, so there is no cut, no facet and no preferred direction.
|
||||
//
|
||||
// It is also mass-conserving, which the clamp is not: the flux out of one cell is the flux into its
|
||||
// neighbour by construction, so material shed from a divide arrives at the foot of the slope rather than
|
||||
// being deleted.
|
||||
//
|
||||
// # The cost, and the honest limit
|
||||
//
|
||||
// The catch is stiffness. The tangent of the flux law, which is what sets the explicit time-step limit, is
|
||||
//
|
||||
// D_eff(S) = D * (1 + u^2) / (1 - u^2)^2, u = S/Sc
|
||||
//
|
||||
// and that goes to infinity at u = 1. At the defaults the linear Courant number D*dt/dx^2 is already 0.29,
|
||||
// so u = 0.9 alone asks for about seventy sub-steps a step and u = 0.95 for nearly three hundred. That is
|
||||
// not affordable over a thousand steps, so the stiffening is bounded: u is capped at SlopeCap, and if even
|
||||
// that exceeds MaxSubSteps the cap is lowered further to whatever the budget affords. The sub-step count is
|
||||
// then derived from the cap that was actually used, so the scheme stays inside its stability limit whatever
|
||||
// happens — it degrades by transporting less on the steepest ground, never by going unstable.
|
||||
//
|
||||
// Ground steeper than the cap therefore relaxes at a finite rate instead of an unbounded one, and on a
|
||||
// mountain belt rising at 2 mm/yr that is not fast enough on its own. ClampToRepose stays for exactly that,
|
||||
// as a safety pass rather than as the process that shapes the land: see Run.
|
||||
func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub int) {
|
||||
if d <= 0 || dt <= 0 {
|
||||
return
|
||||
}
|
||||
if sc <= 0 {
|
||||
g.Diffuse(h, d, dt) // no critical slope configured: the linear law, unchanged
|
||||
return
|
||||
}
|
||||
if slopeCap <= 0 || slopeCap >= 1 {
|
||||
slopeCap = 0.9
|
||||
}
|
||||
if maxSub < 1 {
|
||||
maxSub = 1
|
||||
}
|
||||
dx := g.CellM
|
||||
dx2 := dx * dx
|
||||
|
||||
// The steepest ground on the grid bounds D_eff for the whole call. Uplift is not applied in here and
|
||||
// diffusion only relaxes slopes, so nothing can get steeper part-way through and invalidate the bound.
|
||||
u := math.Min(g.maxSlopeRatio(h, sc), slopeCap)
|
||||
f := stiffness(u)
|
||||
// What the sub-step budget can pay for. Lowering the cap rather than truncating the sub-step count is
|
||||
// what keeps this stable: a truncated count leaves alpha above 0.25 and the surface checkerboards a few
|
||||
// hundred steps later, which is precisely the sort of failure that does not announce itself.
|
||||
if budget := float64(maxSub) * 0.2 * dx2 / (d * dt); f > budget {
|
||||
f = budget
|
||||
u = invStiffness(f)
|
||||
}
|
||||
if f < 1 {
|
||||
// The budget cannot buy even the linear law. It is not optional: D*dt/dx^2 alone may need several
|
||||
// sub-steps and going without them is an unstable scheme, so MaxSubSteps bounds the nonlinear
|
||||
// *enhancement* and never the stability floor underneath it.
|
||||
f = 1
|
||||
u = 0
|
||||
}
|
||||
sub := int(math.Ceil(d * f * dt / dx2 / 0.2))
|
||||
if sub < 1 {
|
||||
sub = 1
|
||||
}
|
||||
dtSub := dt / float64(sub)
|
||||
coeff := float32(d * dtSub / dx2)
|
||||
uCap := float32(u)
|
||||
// The height difference across one cell that *is* Sc. flux works in height differences rather than
|
||||
// slopes, so the cell spacing has to be folded into the critical value here; leaving it out makes u a
|
||||
// factor of dx too large, which pins every face against the cap and quietly turns the whole law into
|
||||
// linear diffusion with a constant multiplier.
|
||||
dhCrit := float32(sc * dx)
|
||||
|
||||
src := h
|
||||
tmp := g.scratch[:len(h)]
|
||||
for s := 0; s < sub; s++ {
|
||||
field.Rows(g.H, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < g.W; x++ {
|
||||
i := y*g.W + x
|
||||
if g.fixed[i] {
|
||||
tmp[i] = src[i] // base level: held, and whatever arrives here has left the system
|
||||
continue
|
||||
}
|
||||
c := src[i]
|
||||
// The net inflow over the four faces. Each face is evaluated from both of its cells,
|
||||
// which costs twice and buys a gather: no two goroutines ever write the same cell.
|
||||
net := flux(clampAt(src, g.W, g.H, x-1, y)-c, dhCrit, uCap) +
|
||||
flux(clampAt(src, g.W, g.H, x+1, y)-c, dhCrit, uCap) +
|
||||
flux(clampAt(src, g.W, g.H, x, y-1)-c, dhCrit, uCap) +
|
||||
flux(clampAt(src, g.W, g.H, x, y+1)-c, dhCrit, uCap)
|
||||
tmp[i] = c + coeff*net
|
||||
}
|
||||
}
|
||||
})
|
||||
copy(src, tmp)
|
||||
}
|
||||
}
|
||||
|
||||
// flux is q/D for one face, in height differences rather than slopes: one factor of the cell spacing cancels
|
||||
// against the divergence and is carried in coeff instead. dhCrit is the height difference that corresponds to
|
||||
// Sc across one cell, so dh/dhCrit is exactly S/Sc. u is capped so the denominator cannot reach zero.
|
||||
func flux(dh, dhCrit, uCap float32) float32 {
|
||||
u := dh / dhCrit
|
||||
if u < 0 {
|
||||
u = -u
|
||||
}
|
||||
if u > uCap {
|
||||
u = uCap
|
||||
}
|
||||
return dh / (1 - u*u)
|
||||
}
|
||||
|
||||
// stiffness is D_eff/D at a given u = S/Sc: the factor by which the nonlinear law shortens the stable step.
|
||||
func stiffness(u float64) float64 {
|
||||
q := 1 - u*u
|
||||
return (1 + u*u) / (q * q)
|
||||
}
|
||||
|
||||
// invStiffness inverts it. Bisection because stiffness is monotone on [0,1) and this runs once per call, so
|
||||
// there is nothing to gain from being cleverer and something to lose from being wrong.
|
||||
func invStiffness(f float64) float64 {
|
||||
if f <= 1 {
|
||||
return 0
|
||||
}
|
||||
lo, hi := 0.0, 0.999999
|
||||
for i := 0; i < 60; i++ {
|
||||
mid := (lo + hi) / 2
|
||||
if stiffness(mid) < f {
|
||||
lo = mid
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
return lo
|
||||
}
|
||||
|
||||
// maxSlopeRatio is the steepest face on the grid as a fraction of Sc. Cardinal neighbours only, because those
|
||||
// are the faces the five-point stencil actually transports across.
|
||||
func maxSlopeRatio(h []float32, w, hgt int, sc, cellM float64) float64 {
|
||||
var maxDiff float32
|
||||
for y := 0; y < hgt; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
c := h[i]
|
||||
if x+1 < w {
|
||||
if dv := abs32(h[i+1] - c); dv > maxDiff {
|
||||
maxDiff = dv
|
||||
}
|
||||
}
|
||||
if y+1 < hgt {
|
||||
if dv := abs32(h[i+w] - c); dv > maxDiff {
|
||||
maxDiff = dv
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return float64(maxDiff) / cellM / sc
|
||||
}
|
||||
|
||||
func (g *Grid) maxSlopeRatio(h []float32, sc float64) float64 {
|
||||
return maxSlopeRatio(h, g.W, g.H, sc, g.CellM)
|
||||
}
|
||||
|
||||
func abs32(v float32) float32 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package fluvial
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The three properties the nonlinear law is being trusted for. Each one is a thing the pair it replaces got
|
||||
// wrong, so each is worth a test rather than an assurance.
|
||||
|
||||
// TestDiffuseNonlinearConservesMass is the property ClampToRepose does not have: material shed from a divide
|
||||
// has to arrive somewhere, not be deleted.
|
||||
//
|
||||
// The border is always an outlet, so the sum over the whole grid cannot be conserved by construction — base
|
||||
// level is a sink and is meant to be. The check is therefore over an interior that the disturbance never
|
||||
// reaches: a bump in the middle of a grid big enough that nothing has diffused to the edge by the time the
|
||||
// run ends.
|
||||
func TestDiffuseNonlinearConservesMass(t *testing.T) {
|
||||
const (
|
||||
w, h = 101, 101
|
||||
cellM = 10.0
|
||||
sc = 0.7
|
||||
)
|
||||
base := make([]bool, w*h)
|
||||
g := NewGrid(w, h, cellM, base)
|
||||
g.SetElevationRange(-100, 2000)
|
||||
|
||||
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, 300-1.5*d)) // a cone well past Sc
|
||||
}
|
||||
}
|
||||
|
||||
sum := func() float64 {
|
||||
var s float64
|
||||
for y := 2; y < h-2; y++ {
|
||||
for x := 2; x < w-2; x++ {
|
||||
s += float64(field[y*w+x])
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
before := sum()
|
||||
for i := 0; i < 40; i++ {
|
||||
g.DiffuseNonlinear(field, 0.02, sc, 0.9, 1500, 24)
|
||||
}
|
||||
after := sum()
|
||||
|
||||
// The cone is 300 m tall and the interior holds millions of cubic metres; a tenth of a percent is a very
|
||||
// tight bound on forty steps of an explicit scheme in float32.
|
||||
rel := math.Abs(after-before) / before
|
||||
t.Logf("interior mass %.1f -> %.1f, relative change %.2e", before, after, rel)
|
||||
if rel > 1e-3 {
|
||||
t.Errorf("interior mass changed by %.3f%%; the flux is not antisymmetric", rel*100)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiffuseNonlinearLimitsSlope is the self-limiting property, and the only honest way to test it is under
|
||||
// uplift. Without uplift every diffusion law flattens everything eventually, nonlinear included, so a
|
||||
// relaxing cone proves nothing. What distinguishes the two laws is where they come to rest against a forcing:
|
||||
// linear diffusion has no limiting angle at all and lets relief grow to U*L^2/(2D), which at these numbers is
|
||||
// a kilometre and slopes many times Sc, while the nonlinear law's flux diverges as the slope approaches Sc so
|
||||
// the landscape settles near it whatever U is. That is the entire reason for the change, so it is the test.
|
||||
func TestDiffuseNonlinearLimitsSlope(t *testing.T) {
|
||||
// The forcing is chosen so the question is about the law and not about the sub-step budget. A hillslope of
|
||||
// half-width L under uplift U comes to rest, under the linear law, at a maximum slope of U*L/D; here that is
|
||||
// 0.8, twice Sc, so linear diffusion visibly fails to limit. The nonlinear law can hold Sc only while its
|
||||
// flux at the cap, D*Sc/(1-uCap^2), still exceeds U*L, and at these numbers it does with room to spare — so
|
||||
// a failure here is the law's, not the budget's. Push U much higher and no bounded-flux law holds Sc; that
|
||||
// is the regime ClampToRepose exists for, and Run keeps it for exactly that reason.
|
||||
const (
|
||||
w, h = 41, 41
|
||||
cellM = 10.0
|
||||
sc = 0.4
|
||||
upliftM = 2e-4 // 0.2 mm/yr
|
||||
dt = 1000.0
|
||||
steps = 5000
|
||||
)
|
||||
grow := func(nonlinear bool) float64 {
|
||||
base := make([]bool, w*h)
|
||||
g := NewGrid(w, h, cellM, base)
|
||||
g.SetElevationRange(-100, 8000)
|
||||
f := make([]float32, w*h)
|
||||
for i := 0; i < steps; i++ {
|
||||
for j := range f {
|
||||
if !g.fixed[j] {
|
||||
f[j] += float32(upliftM * dt)
|
||||
}
|
||||
}
|
||||
if nonlinear {
|
||||
g.DiffuseNonlinear(f, 0.05, sc, 0.9, dt, 24)
|
||||
} else {
|
||||
g.Diffuse(f, 0.05, dt)
|
||||
}
|
||||
}
|
||||
return maxCardinalSlope(f, w, h, cellM)
|
||||
}
|
||||
lin := grow(false)
|
||||
non := grow(true)
|
||||
t.Logf("after %.1f Myr at %.1f mm/yr: linear reaches slope %.3f, nonlinear %.3f (Sc %.3f)",
|
||||
steps*dt/1e6, upliftM*1000, lin, non, sc)
|
||||
|
||||
if non > sc {
|
||||
t.Errorf("nonlinear settled at %.3f, above Sc %.3f: the flux is not stiffening", non, sc)
|
||||
}
|
||||
if non < sc*0.4 {
|
||||
t.Errorf("nonlinear settled at %.3f, far below Sc %.3f: it is over-transporting", non, sc)
|
||||
}
|
||||
// The discriminating statement: under one forcing, the linear law overshoots the critical slope and the
|
||||
// nonlinear law does not. If linear stays under it too, the forcing was too gentle to test anything.
|
||||
if lin <= sc {
|
||||
t.Errorf("linear only reached %.3f against Sc %.3f; the forcing is too weak to tell the laws apart", lin, sc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiffuseNonlinearIsStable catches the failure that does not announce itself. An explicit scheme run past
|
||||
// its stability limit does not blow up on the first step; it grows a checkerboard over hundreds of them, and
|
||||
// by then the run is finished and the artefact looks like texture. A checkerboard is the mode a five-point
|
||||
// stencil goes unstable in, so it is what the test starts from: a stable scheme damps it towards flat.
|
||||
//
|
||||
// The settings put the sub-step logic where it has to choose. The initial field is far past Sc, so the cap
|
||||
// engages; the budget is well below what u = 0.95 would want, so the cap has to be lowered rather than the
|
||||
// sub-step count truncated. Truncating is the tempting, wrong branch and is what this is here to catch.
|
||||
//
|
||||
// Only the interior is measured. The border is an outlet and is held fixed by design, so it keeps its initial
|
||||
// values for ever and reading it back tells you nothing about the scheme.
|
||||
func TestDiffuseNonlinearIsStable(t *testing.T) {
|
||||
const (
|
||||
w, h = 64, 64
|
||||
cellM = 8.0
|
||||
sc = 0.7
|
||||
)
|
||||
base := make([]bool, w*h)
|
||||
g := NewGrid(w, h, cellM, base)
|
||||
g.SetElevationRange(-1000, 4000)
|
||||
|
||||
// The checkerboard goes in the interior only. The border is an outlet and is held fixed, so a
|
||||
// checkerboard written across it is a permanent forcing that keeps re-injecting the mode into the first
|
||||
// interior ring — the scheme would then be blamed for a boundary condition.
|
||||
field := make([]float32, w*h)
|
||||
for y := 1; y < h-1; y++ {
|
||||
for x := 1; x < w-1; x++ {
|
||||
if (x+y)%2 == 0 {
|
||||
field[y*w+x] = 200
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range field {
|
||||
if g.fixed[i] {
|
||||
field[i] = 100 // flat base level, the mean of the checkerboard
|
||||
}
|
||||
}
|
||||
for i := 0; i < 500; i++ {
|
||||
g.DiffuseNonlinear(field, 0.02, sc, 0.95, 1500, 24)
|
||||
}
|
||||
lo, hi := float32(math.Inf(1)), float32(math.Inf(-1))
|
||||
for y := 1; y < h-1; y++ {
|
||||
for x := 1; x < w-1; x++ {
|
||||
v := field[y*w+x]
|
||||
if math.IsNaN(float64(v)) || math.IsInf(float64(v), 0) {
|
||||
t.Fatalf("hillslope diffusion produced %v at %d,%d", v, x, y)
|
||||
}
|
||||
if v < lo {
|
||||
lo = v
|
||||
}
|
||||
if v > hi {
|
||||
hi = v
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("after 500 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiffuseNonlinearMatchesLinearWhenGentle pins the other end of the law. Well below Sc the two must agree
|
||||
// closely, because that is the claim that lets this replace linear diffusion outright rather than sit beside
|
||||
// it: the lowlands must not change when the switch is thrown.
|
||||
func TestDiffuseNonlinearMatchesLinearWhenGentle(t *testing.T) {
|
||||
const (
|
||||
w, h = 64, 64
|
||||
cellM = 10.0
|
||||
sc = 1.0 // Sc far above anything in the field, so u stays near zero
|
||||
)
|
||||
base := make([]bool, w*h)
|
||||
a := make([]float32, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
// A gentle bump: peak slope about 0.01, one percent of Sc.
|
||||
d := math.Hypot(float64(x-w/2), float64(y-h/2)) * cellM
|
||||
a[y*w+x] = float32(3 * math.Exp(-d*d/(2*100*100)))
|
||||
}
|
||||
}
|
||||
b := make([]float32, w*h)
|
||||
copy(b, a)
|
||||
|
||||
ga := NewGrid(w, h, cellM, base)
|
||||
ga.SetElevationRange(-100, 100)
|
||||
gb := NewGrid(w, h, cellM, base)
|
||||
gb.SetElevationRange(-100, 100)
|
||||
for i := 0; i < 20; i++ {
|
||||
ga.DiffuseNonlinear(a, 0.02, sc, 0.9, 1500, 24)
|
||||
gb.Diffuse(b, 0.02, 1500)
|
||||
}
|
||||
|
||||
var worst float64
|
||||
for i := range a {
|
||||
if d := math.Abs(float64(a[i] - b[i])); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
t.Logf("worst divergence from linear diffusion over 20 steps: %.4f m", worst)
|
||||
if worst > 0.01 {
|
||||
t.Errorf("nonlinear and linear diffusion differ by %.4f m at u ~ 0.01; they should agree", worst)
|
||||
}
|
||||
}
|
||||
|
||||
func maxCardinalSlope(h []float32, w, hgt int, cellM float64) float64 {
|
||||
var worst float64
|
||||
for y := 0; y < hgt; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
if x+1 < w {
|
||||
if s := math.Abs(float64(h[i+1]-h[i])) / cellM; s > worst {
|
||||
worst = s
|
||||
}
|
||||
}
|
||||
if y+1 < hgt {
|
||||
if s := math.Abs(float64(h[i+w]-h[i])) / cellM; s > worst {
|
||||
worst = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package fluvial
|
||||
|
||||
// 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
|
||||
// segments. In mountains the slope hides it. On a plain it is the dominant artefact, and for a specific
|
||||
// reason: across a filled flat the only gradient present is the priority-flood's own epsilon, one millimetre
|
||||
// a cell, applied in the order the flood happened to reach the cells. The router then faithfully follows the
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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 }
|
||||
@@ -0,0 +1,74 @@
|
||||
package fluvial
|
||||
|
||||
import "math"
|
||||
|
||||
// ClampToRepose enforces a maximum slope everywhere: no cell may stand above a neighbour by more than
|
||||
// talus * distance. It returns the mean thickness removed, in metres.
|
||||
//
|
||||
// This replaces iterating thermal.Apply inside the solve, which could not do the job however many passes it
|
||||
// was given (measured: 3, 10 and 40 passes all left the steepest land slope at 64 degrees against a 22 degree
|
||||
// repose). The reason is structural rather than a bug. That routine moves half the excess downhill, so on a
|
||||
// *uniform* over-steep slope every cell sheds exactly as much as it receives, the net change is zero, and the
|
||||
// slope is a fixed point. It relaxes only where the downhill flux diverges — which is why it cuts a cone,
|
||||
// whose contours converge, and why it cannot touch a planar hillside.
|
||||
//
|
||||
// So the constraint is imposed directly instead. This is the priority-flood mirrored: pop cells in ascending
|
||||
// elevation, and lower any neighbour standing higher than the repose angle allows. Because a lowered cell is
|
||||
// set to h[c] + talus*d, which is at or above the elevation just popped, the queue stays monotone and the
|
||||
// bucket queue works unchanged. One pass, O(n) with the bucket queue, and the constraint holds globally when
|
||||
// it returns.
|
||||
//
|
||||
// It is not mass-conserving: the material is removed rather than piled at the foot of the slope. That is the
|
||||
// deliberate simplification, because in this landscape the foot of a hillslope is a channel and the channel
|
||||
// exports the sediment anyway. The mean thickness removed is returned so a run can report it, and a run that
|
||||
// removes a suspicious amount is saying its uplift and its repose angle disagree.
|
||||
func (g *Grid) ClampToRepose(h []float32, talus float64) float64 {
|
||||
if talus <= 0 {
|
||||
return 0
|
||||
}
|
||||
n := g.W * g.H
|
||||
for i := range g.closed {
|
||||
g.closed[i] = false
|
||||
}
|
||||
g.pq.reset()
|
||||
for i := 0; i < n; i++ {
|
||||
g.pq.push(h[i], int32(i))
|
||||
}
|
||||
|
||||
card := talus * g.CellM
|
||||
diag := talus * g.CellM * math.Sqrt2
|
||||
var removed float64
|
||||
|
||||
for g.pq.len() > 0 {
|
||||
c := g.pq.pop()
|
||||
if c < 0 {
|
||||
break
|
||||
}
|
||||
if g.closed[c] {
|
||||
continue
|
||||
}
|
||||
g.closed[c] = true
|
||||
cx, cy := int(c)%g.W, int(c)/g.W
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
ni := int32(ny*g.W + nx)
|
||||
if g.closed[ni] || g.fixed[ni] {
|
||||
continue
|
||||
}
|
||||
allow := card
|
||||
if dx8[k] != 0 && dy8[k] != 0 {
|
||||
allow = diag
|
||||
}
|
||||
limit := h[c] + float32(allow)
|
||||
if h[ni] > limit {
|
||||
removed += float64(h[ni] - limit)
|
||||
h[ni] = limit
|
||||
g.pq.push(limit, ni)
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed / float64(n)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package fluvial
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestClampToReposeCutsACone is the smallest possible check on the constraint: a cone far steeper than the
|
||||
// repose angle must come back at or under it.
|
||||
func TestClampToReposeCutsACone(t *testing.T) {
|
||||
const (
|
||||
w, h = 81, 81
|
||||
cellM = 10.0
|
||||
talus = 0.4 // about 22 degrees
|
||||
)
|
||||
base := make([]bool, w*h)
|
||||
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, 800-2.0*d)) // slope 2.0, five times repose
|
||||
}
|
||||
}
|
||||
g := NewGrid(w, h, cellM, base)
|
||||
g.SetElevationRange(-100, 2000)
|
||||
removed := g.ClampToRepose(field, talus)
|
||||
|
||||
worst := 0.0
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
for k := 0; k < 8; k++ {
|
||||
nx, ny := x+dx8[k], y+dy8[k]
|
||||
if nx < 0 || ny < 0 || nx >= w || ny >= h {
|
||||
continue
|
||||
}
|
||||
d := cellM
|
||||
if dx8[k] != 0 && dy8[k] != 0 {
|
||||
d = cellM * math.Sqrt2
|
||||
}
|
||||
if s := float64(field[y*w+x]-field[ny*w+nx]) / d; s > worst {
|
||||
worst = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("removed %.2f m mean; steepest slope now %.3f (repose %.3f)", removed, worst, talus)
|
||||
if worst > talus*1.02 {
|
||||
t.Errorf("steepest slope %.3f exceeds repose %.3f", worst, talus)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
// Package manifest reads RawContent/World/World.json, the one place that says how big L_World is, what a
|
||||
// heightmap value means in metres, and where the height comes from. It is the Go half of a contract whose
|
||||
// other half is Scripts/Authoring/world_manifest.py: create_world.py still reads the same file to place the
|
||||
// landscape, so the two must derive the same Z scale and the same Z offset from the same keys. Any change to
|
||||
// the height contract here is a change there.
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// The engine maps heightmap value v to local height (v - 32768) / 128 * ZScale cm, so ZScale 100 spans 512 m.
|
||||
const EngineSpanMAtScale100 = 512.0
|
||||
|
||||
// Range is the [low, high] pair the pipeline block uses for anything a seed picks between.
|
||||
type Range [2]float64
|
||||
|
||||
func (r Range) Lo() float64 { return r[0] }
|
||||
func (r Range) Hi() float64 { return r[1] }
|
||||
|
||||
// Pick returns a value in the range from a unit random.
|
||||
func (r Range) Pick(u float64) float64 { return r[0] + (r[1]-r[0])*u }
|
||||
|
||||
type Elevation struct {
|
||||
Min float64 `json:"min"`
|
||||
Max float64 `json:"max"`
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
Kind string `json:"kind"`
|
||||
Seed int64 `json:"seed"`
|
||||
Path string `json:"path"`
|
||||
Elevation *Elevation `json:"elevation_m"`
|
||||
Width int `json:"width"`
|
||||
FlipY bool `json:"flip_y"`
|
||||
SmoothPasses int `json:"smooth_passes"`
|
||||
}
|
||||
|
||||
// Layers is the paint-layer rule block, unchanged in meaning from the numpy pipeline.
|
||||
type Layers struct {
|
||||
RockSlopeStart float64 `json:"rock_slope_start"`
|
||||
RockSlopeFull float64 `json:"rock_slope_full"`
|
||||
HighAltitudeStartM float64 `json:"high_altitude_start_m"`
|
||||
HighAltitudeFullM float64 `json:"high_altitude_full_m"`
|
||||
BreakupM float64 `json:"breakup_m"`
|
||||
WearRockStart float64 `json:"wear_rock_start"`
|
||||
RidgeRock float64 `json:"ridge_rock"`
|
||||
DepositSoftens float64 `json:"deposit_softens"`
|
||||
}
|
||||
|
||||
type Plates struct {
|
||||
Count int `json:"count"`
|
||||
VelocityCmYr Range `json:"velocity_cm_yr"`
|
||||
ConvergentMmYr Range `json:"convergent_mm_yr"`
|
||||
BandKm Range `json:"band_km"`
|
||||
DivergentMmYr Range `json:"divergent_mm_yr"`
|
||||
RiftKm Range `json:"rift_km"`
|
||||
// IntraplateMmYr and IntraplateSwellMmYr are the two ends of the regional swell: the interior warps
|
||||
// between them over tens of kilometres. A single uniform intraplate rate is what produced a table-flat
|
||||
// plain with no divides on it, and therefore no drainage for the router to find. See package uplift.
|
||||
IntraplateMmYr float64 `json:"intraplate_mm_yr"`
|
||||
IntraplateSwellMmYr float64 `json:"intraplate_swell_mm_yr"`
|
||||
LowUpliftFraction Range `json:"low_uplift_fraction"`
|
||||
}
|
||||
|
||||
type Faults struct {
|
||||
Major Range `json:"major"`
|
||||
Minor Range `json:"minor"`
|
||||
LengthKm Range `json:"length_km"`
|
||||
SpacingKm Range `json:"spacing_km"`
|
||||
ThrowMajorM Range `json:"throw_major_m"`
|
||||
ThrowMinorM Range `json:"throw_minor_m"`
|
||||
StrikeSlipM Range `json:"strike_slip_m"`
|
||||
}
|
||||
|
||||
type Lithology struct {
|
||||
Types int `json:"types"`
|
||||
KMultipliers []float64 `json:"k_multipliers"`
|
||||
}
|
||||
|
||||
type Relief struct {
|
||||
Octaves int `json:"octaves"`
|
||||
Gain float64 `json:"gain"`
|
||||
BaseFrequencyM float64 `json:"base_frequency_m"`
|
||||
AmplitudeM Range `json:"amplitude_m"`
|
||||
CrestWeight float64 `json:"crest_weight"`
|
||||
}
|
||||
|
||||
// Fluvial is the stream-power block: dh/dt = U - K * A^m * S^n, solved implicitly up the drainage stack.
|
||||
type Fluvial struct {
|
||||
K float64 `json:"k"`
|
||||
M float64 `json:"m"`
|
||||
N float64 `json:"n"`
|
||||
DtYr float64 `json:"dt_yr"`
|
||||
Steps int `json:"steps"`
|
||||
DiffusionM2Yr float64 `json:"diffusion_m2_yr"`
|
||||
// FillEvery is the one number that decides whether a full run is five minutes or half an hour: the
|
||||
// priority-flood is the only part of a step that is not O(n). See Docs/Terrain.md, the time budget.
|
||||
FillEvery int `json:"fill_every"`
|
||||
// CriticalAreaM2 is where channels begin; below it a cell is a hillslope. See package fluvial.
|
||||
CriticalAreaM2 float64 `json:"critical_area_m2"`
|
||||
ChannelTaper float64 `json:"channel_taper"`
|
||||
|
||||
// The nonlinear hillslope law, q = D*S/(1-(S/Sc)^2). CriticalSlopeDeg is Sc as an angle; 0 falls back to
|
||||
// linear diffusion with the repose clamp inside the step loop. See internal/fluvial/hillslope.go.
|
||||
CriticalSlopeDeg float64 `json:"critical_slope_deg"`
|
||||
SlopeCap float64 `json:"slope_cap"`
|
||||
MaxHillslopeSub int `json:"max_hillslope_substeps"`
|
||||
}
|
||||
|
||||
type Thermal struct {
|
||||
CoarsePasses int `json:"coarse_passes"`
|
||||
Every int `json:"every"`
|
||||
FinePasses int `json:"fine_passes"`
|
||||
TalusDeg float64 `json:"talus_deg"`
|
||||
}
|
||||
|
||||
type Strata struct {
|
||||
PeriodM float64 `json:"period_m"`
|
||||
Contrast float64 `json:"contrast"`
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
Octaves int `json:"octaves"`
|
||||
AmplitudeM Range `json:"amplitude_m"`
|
||||
}
|
||||
|
||||
// Particle is the droplet block, demoted by D-47 from "carves the valleys" to detail only. Every brake in it
|
||||
// was learned the hard way; see Docs/Terrain.md.
|
||||
type Particle struct {
|
||||
Droplets int `json:"droplets"`
|
||||
Lifetime int `json:"lifetime"`
|
||||
Scale float64 `json:"scale"`
|
||||
MinErodeSlope float64 `json:"min_erode_slope"`
|
||||
MaxChange float64 `json:"max_change"`
|
||||
MaxSpeed float64 `json:"max_speed"`
|
||||
MaxLoad float64 `json:"max_load"`
|
||||
Inertia float64 `json:"inertia"`
|
||||
Capacity float64 `json:"capacity"`
|
||||
MinSlope float64 `json:"min_slope"`
|
||||
ErodeRate float64 `json:"erode_rate"`
|
||||
DepositRate float64 `json:"deposit_rate"`
|
||||
Evaporation float64 `json:"evaporation"`
|
||||
Gravity float64 `json:"gravity"`
|
||||
Batch int `json:"batch"`
|
||||
}
|
||||
|
||||
// Continent is the coast and the sea floor: not in the incoming spec at all, kept by D-48 because sea level
|
||||
// is a better-posed base level for the fluvial solve than one outlet edge.
|
||||
type Continent struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Radius float64 `json:"radius"`
|
||||
CoastWarp float64 `json:"coast_warp"`
|
||||
SeaFloorM Range `json:"sea_floor_m"`
|
||||
// LandFraction is met exactly, by thresholding the continent field at the percentile that yields it, so
|
||||
// the land area does not wander with the seed.
|
||||
LandFraction float64 `json:"land_fraction"`
|
||||
// RadialBias pulls the land towards the middle. It only biases: at 0 the continent is wherever the noise
|
||||
// puts it, and high values return the disc with a wobbly edge that the first version produced.
|
||||
RadialBias float64 `json:"radial_bias"`
|
||||
// ShoreWidthPct is how many percentiles the shore transition spans. Small is a cliff coast, large is a
|
||||
// wide tidal shelf.
|
||||
ShoreWidthPct float64 `json:"shore_width_pct"`
|
||||
// OutlineOctaves and OutlineGain are how much detail the coastline itself has. A real coastline is
|
||||
// fractal — that is the whole point of the Richardson coastline paradox — and five octaves over a 14 km
|
||||
// map puts the finest feature at about 450 m, which is a smooth blob with no inlets, no headlands and no
|
||||
// islands. Measured on seed 7 with five octaves: the fetch called the median stretch of coast fully open,
|
||||
// because there was nothing at the fetch scale to shelter anything from anything.
|
||||
OutlineOctaves int `json:"outline_octaves"`
|
||||
OutlineGain float64 `json:"outline_gain"`
|
||||
}
|
||||
|
||||
// Coast is what happens where the land meets the sea: the shape of the sea floor, and the two processes
|
||||
// that work on the shoreline itself.
|
||||
//
|
||||
// It is a separate block from Continent because the two answer different questions. Continent decides *where*
|
||||
// the coastline runs — it is part of the tectonics, it is what the fluvial solve takes as its base level, and
|
||||
// it is fixed before a single step of erosion. Coast decides what the shoreline *is*, and it runs after the
|
||||
// solve, on the terrain the solve produced: the sea floor cannot be laid until the land behind it has its
|
||||
// relief, and the surf cannot cut a cliff into a mountain that has not been built yet.
|
||||
type Coast struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// The sea floor. A real margin is a shelf at a very gentle grade out to a shelf break, and then a much
|
||||
// steeper continental slope down to the abyssal floor; the flat plane at the bottom of the elevation
|
||||
// range that this replaces was neither. ShelfKm is a range because the shelf width is not a constant:
|
||||
// it is wide off a low coastal plain and narrow off a mountain range that comes down to the water, so it
|
||||
// is interpolated per stretch of shore by the relief standing behind that stretch.
|
||||
ShelfKm Range `json:"shelf_km"`
|
||||
SteepCoastM float64 `json:"steep_coast_m"`
|
||||
SlopeKm float64 `json:"slope_km"`
|
||||
ShelfExponent float64 `json:"shelf_exponent"`
|
||||
RoughnessM float64 `json:"roughness_m"`
|
||||
RoughWaveM float64 `json:"rough_wavelength_m"`
|
||||
|
||||
// Shelter. Fetch is cast from every waterline cell in FetchDirections directions out to FetchRangeM, and
|
||||
// how far the rays get before they hit land is what separates an exposed headland from the back of a bay.
|
||||
// It is the one field both coastal processes are driven by: the surf reaches furthest inland where the
|
||||
// water is open, and sediment settles where it is not.
|
||||
FetchDirections int `json:"fetch_directions"`
|
||||
FetchRangeM float64 `json:"fetch_range_m"`
|
||||
|
||||
// The surf. Within a reach of the waterline the land is planed towards a shore platform at
|
||||
// PlatformGrade; the step at the back of the planed strip is the cliff, and it is a consequence of the
|
||||
// reach ending rather than something drawn. CutFraction below 1 leaves the platform rough.
|
||||
SurfReachM float64 `json:"surf_reach_m"`
|
||||
PlatformGrade float64 `json:"platform_grade"`
|
||||
CutFraction float64 `json:"cut_fraction"`
|
||||
|
||||
// Deposition. What the surf cuts does not vanish: it is carried DriftM along the shore and laid in
|
||||
// sheltered water shallower than DepositDepthM and within DepositReachM of the shore, up to BermM above
|
||||
// sea level. Rivers deliver their own load at their mouths, which is what makes a delta.
|
||||
DepositReachM float64 `json:"deposit_reach_m"`
|
||||
DepositDepthM float64 `json:"deposit_depth_m"`
|
||||
ShelterBias float64 `json:"shelter_bias"`
|
||||
BermM float64 `json:"berm_m"`
|
||||
DriftM float64 `json:"drift_m"`
|
||||
RiverM3PerKm2 float64 `json:"river_m3_per_km2"`
|
||||
RiverExponent float64 `json:"river_exponent"`
|
||||
RiverChannelKm2 float64 `json:"river_channel_km2"`
|
||||
}
|
||||
|
||||
type Pipeline struct {
|
||||
GeologyFactor int `json:"geology_factor"`
|
||||
Continent Continent `json:"continent"`
|
||||
Coast Coast `json:"coast"`
|
||||
Plates Plates `json:"plates"`
|
||||
Faults Faults `json:"faults"`
|
||||
Lithology Lithology `json:"lithology"`
|
||||
Relief Relief `json:"relief"`
|
||||
Fluvial Fluvial `json:"fluvial"`
|
||||
Thermal Thermal `json:"thermal"`
|
||||
Strata Strata `json:"strata"`
|
||||
Detail Detail `json:"detail"`
|
||||
Particle Particle `json:"particle"`
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
Path string `json:"-"`
|
||||
|
||||
Level string `json:"level"`
|
||||
VerticesPerSide int `json:"vertices_per_side"`
|
||||
QuadCm float64 `json:"quad_cm"`
|
||||
ElevationM Elevation `json:"elevation_m"`
|
||||
SeaLevelM float64 `json:"sea_level_m"`
|
||||
SpawnPadM float64 `json:"spawn_pad_m"`
|
||||
StreamingGridComponents int `json:"streaming_grid_components"`
|
||||
Source Source `json:"source"`
|
||||
Layers Layers `json:"layers"`
|
||||
Pipeline Pipeline `json:"pipeline"`
|
||||
|
||||
// Erosion is the pre-D-47 block. Kept only so a manifest that still carries it can be reported rather
|
||||
// than silently ignored.
|
||||
Erosion map[string]any `json:"erosion"`
|
||||
}
|
||||
|
||||
// Defaults are the generator's own numbers, so a manifest carries only what differs from them. This is the
|
||||
// Go equivalent of heightmap_erosion.DEFAULTS and it plays the same role.
|
||||
func Defaults() *Manifest {
|
||||
return &Manifest{
|
||||
Level: "/Game/Maps/L_World",
|
||||
VerticesPerSide: 7141, // 255*28+1 (D-48): the importer's own rule then gives 28x28 components
|
||||
QuadCm: 200,
|
||||
ElevationM: Elevation{Min: -512, Max: 1536}, // span 2048 m is exactly Z scale 400
|
||||
SeaLevelM: 0,
|
||||
SpawnPadM: 150,
|
||||
StreamingGridComponents: 2,
|
||||
Source: Source{Kind: "noise", Seed: 7},
|
||||
Layers: Layers{
|
||||
RockSlopeStart: 0.55, RockSlopeFull: 1.05,
|
||||
HighAltitudeStartM: 1100, HighAltitudeFullM: 1650, BreakupM: 18,
|
||||
WearRockStart: 0.35, RidgeRock: 0.6, DepositSoftens: 0.7,
|
||||
},
|
||||
Pipeline: Pipeline{
|
||||
GeologyFactor: 4,
|
||||
Continent: Continent{
|
||||
Enabled: true, Radius: 0.62, CoastWarp: 0.28,
|
||||
SeaFloorM: Range{-180, -30}, LandFraction: 0.62,
|
||||
RadialBias: 0.85, ShoreWidthPct: 3,
|
||||
// Measured on seed 7 at 1400, sweeping the gain with everything else held: shoreline length
|
||||
// 64 km at 0.50, 81 at 0.58, 96 at 0.62, 114 at 0.66, and the fetch's view of the coast went
|
||||
// from "the median stretch is fully open" (1.00) to 0.98, 0.84 and 0.51. 0.62 is where the
|
||||
// coast has islands, inlets and headlands that shelter each other without the outline
|
||||
// breaking up into speckle. Octaves past 9 buy nothing: at gain 0.50 the sweep 5, 7, 8, 9, 10
|
||||
// gave 59, 63, 64, 65, 66 km and it had flattened.
|
||||
OutlineOctaves: 8, OutlineGain: 0.62,
|
||||
},
|
||||
Coast: Coast{
|
||||
Enabled: true,
|
||||
// The canvas is 14.28 km a side and the sea is a third of it, so a real shelf — 75 km out
|
||||
// to a break at 130 m — does not fit and is not what these numbers are. They are the same
|
||||
// *shape* scaled to the map: a gentle shelf a kilometre or two wide, a break at the
|
||||
// SeaFloorM high end, and a slope to the SeaFloorM low end over another kilometre and a
|
||||
// half. The two SeaFloorM numbers keep their meaning; what changes is that the depth
|
||||
// between them is now a function of distance offshore rather than of the mask's ramp.
|
||||
ShelfKm: Range{0.6, 3.0}, SteepCoastM: 300, SlopeKm: 1.6, ShelfExponent: 0.7,
|
||||
RoughnessM: 10, RoughWaveM: 1200,
|
||||
FetchDirections: 16, FetchRangeM: 1500,
|
||||
// 110 m of reach is 14 cells at the 8 m geology cell, which is about the least that can
|
||||
// carry a platform and a cliff at this resolution. The shore is the one landform whose
|
||||
// scale is set by physics rather than by the map, so it does not grow with the canvas;
|
||||
// when the detail passes exist this pass is where the beach itself gets built, at 2 m.
|
||||
SurfReachM: 110, PlatformGrade: 0.02, CutFraction: 0.85,
|
||||
DepositReachM: 350, DepositDepthM: 25, ShelterBias: 1.5, BermM: 2, DriftM: 300,
|
||||
// Untuned, and deliberately reported rather than assumed: the summary prints the volume
|
||||
// cut, the volume laid and the volume the rivers delivered, so the next round of tuning
|
||||
// has a number to work from instead of an impression of a picture.
|
||||
RiverM3PerKm2: 1.2e5, RiverExponent: 0.6, RiverChannelKm2: 0.5,
|
||||
},
|
||||
Plates: Plates{
|
||||
Count: 6, VelocityCmYr: Range{1, 5}, BandKm: Range{2, 4},
|
||||
DivergentMmYr: Range{-2, -1}, RiftKm: Range{3, 6},
|
||||
// The swell is the fix for the dead plains and it stays: what a lowland needs in order to
|
||||
// have drainage is not a higher uplift rate but a *varying* one, because divides come from
|
||||
// variation. What was wrong was the absolute rate, not the idea.
|
||||
//
|
||||
// Steady-state slope is S = U/(K*A^m), and at CriticalAreaM2 0 that law is applied down to
|
||||
// a single cell, so every divide on the map sits at A = cell². At K 5e-5, m 0.5 and an 8 m
|
||||
// cell that is S = U/4e-4: 0.25 mm/yr puts every divide at 32 degrees and 0.9 mm/yr puts it
|
||||
// past the 35 degree repose clamp. Measured on the old numbers, 81 % of the land came out
|
||||
// in the >0.5 mm/yr class and the plain class held 1 %, all of it sea cliff. The plains were
|
||||
// not over-dissected; they were being uplifted at mountain rates, and U sets how *high* the
|
||||
// summits get, not how steep the ground is — for n = 1 the hillslope angle is the same
|
||||
// everywhere the same U is applied.
|
||||
//
|
||||
// So the rate drops an order of magnitude and the variation stays: 0.03 to 0.08 is still
|
||||
// the ~2.5-fold warp that puts divides on a plain, and it gives 4 to 11 degree hillslopes
|
||||
// and lowland channel gradients near 1 m/km. Against a convergent 1-2 mm/yr that is a
|
||||
// 30-to-60-fold mountain-to-plain ratio, which is what real ones are; the three-fold ratio
|
||||
// this replaces was not mountains and plains, it was mountains and slightly lower mountains.
|
||||
// The percentile ramp in rangeMask keeps the foreland continuous, so nothing becomes bimodal.
|
||||
ConvergentMmYr: Range{1.0, 2.0},
|
||||
IntraplateMmYr: 0.03,
|
||||
IntraplateSwellMmYr: 0.08,
|
||||
LowUpliftFraction: Range{0.2, 0.4},
|
||||
},
|
||||
Faults: Faults{
|
||||
Major: Range{3, 6}, Minor: Range{10, 30}, LengthKm: Range{2, 15}, SpacingKm: Range{1, 4},
|
||||
ThrowMajorM: Range{100, 400}, ThrowMinorM: Range{20, 80}, StrikeSlipM: Range{200, 800},
|
||||
},
|
||||
Lithology: Lithology{Types: 3, KMultipliers: []float64{0.5, 1.0, 3.0}},
|
||||
Relief: Relief{
|
||||
// The low end is 15 m, not 50: amplitude scales with normalised uplift, so the lo end is
|
||||
// what the plains start as, and steady-state plain relief at the rates above is about 10 m.
|
||||
// Starting them as 50 m hills means the run spends itself eroding away relief it was handed
|
||||
// rather than carving what the uplift field asks for.
|
||||
Octaves: 7, Gain: 0.45, BaseFrequencyM: 4000, AmplitudeM: Range{15, 150}, CrestWeight: 0.12,
|
||||
},
|
||||
Fluvial: Fluvial{
|
||||
// 1000 steps, not the incoming spec's 5000: at this K the trunk response time is about
|
||||
// 45 000 yr, and the exponent stops moving after 500 steps at 512². FillEvery is 1 and is
|
||||
// not a budget knob: at 50 the solve is simply wrong (see Docs/Terrain.md).
|
||||
K: 5e-5, M: 0.5, N: 1.0, DtYr: 1500, Steps: 1000, DiffusionM2Yr: 0.02, FillEvery: 1,
|
||||
// 0 disables it, and it is disabled on purpose. A channelization threshold is the textbook
|
||||
// answer to stream power over-steepening hillslopes, but it only works paired with a
|
||||
// hillslope transport law strong enough to carry the uplift into the channels, and at this
|
||||
// timescale there isn't one: the diffusivity it would need (~0.3 m²/yr over a 220 m
|
||||
// hillslope) has a diffusion length of sqrt(D*t) ≈ 470 m over 1.5 Myr, which smooths away
|
||||
// every landform the generator exists to make. Measured: the map went to melted wax. With
|
||||
// the threshold on and diffusion left low, hillslopes instead accumulate uplift unchecked
|
||||
// and the map clipped 22% of the elevation range. Landsliding carries the hillslopes here.
|
||||
// Measured again after the uplift field was fixed, and it still fails: at 1e4 the plains
|
||||
// went from 0.8 to 7.0 degrees median, the rolling class from 7.4 to 32.8 with half of it
|
||||
// pinned against the repose clamp, and the mountains to 79 % pinned. The reason is the same
|
||||
// one as before — the hillslope the threshold creates has to shed its uplift by diffusion,
|
||||
// and at D 0.02 it cannot, so the clamp takes the job instead. It stays at 0 until there is
|
||||
// a transport law strong enough to pair it with.
|
||||
CriticalAreaM2: 0,
|
||||
ChannelTaper: 2,
|
||||
// Sc is the repose angle, so the nonlinear law limits at the same place the clamp did; what
|
||||
// changes is that it approaches it smoothly and isotropically instead of cutting to it along
|
||||
// eight grid directions. See internal/fluvial/hillslope.go for what the cap and the sub-step
|
||||
// budget buy and what they cost.
|
||||
CriticalSlopeDeg: 35,
|
||||
SlopeCap: 0.9,
|
||||
MaxHillslopeSub: 24,
|
||||
},
|
||||
Thermal: Thermal{CoarsePasses: 2, Every: 4, FinePasses: 24, TalusDeg: 35},
|
||||
Strata: Strata{PeriodM: 160, Contrast: 0.6},
|
||||
Detail: Detail{Octaves: 4, AmplitudeM: Range{2, 8}},
|
||||
Particle: Particle{
|
||||
Droplets: 9000000, Lifetime: 40, Scale: 0.5, MinErodeSlope: 0.25, MaxChange: 0.2,
|
||||
MaxSpeed: 5, MaxLoad: 2, Inertia: 0.1, Capacity: 2, MinSlope: 0.01,
|
||||
ErodeRate: 0.2, DepositRate: 0.2, Evaporation: 0.02, Gravity: 4, Batch: 200000,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the manifest over the defaults, so a key absent from the file keeps the generator's number.
|
||||
// encoding/json only assigns fields that are present, which gives exactly the merge the numpy pipeline did
|
||||
// with {**DEFAULTS, **settings}.
|
||||
func Load(path string) (*Manifest, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := Defaults()
|
||||
if err := json.Unmarshal(raw, m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
m.Path = path
|
||||
return m, m.Validate()
|
||||
}
|
||||
|
||||
func (m *Manifest) Validate() error {
|
||||
if m.VerticesPerSide < 2 {
|
||||
return fmt.Errorf("%s: vertices_per_side must be at least 2", m.Path)
|
||||
}
|
||||
if m.QuadCm <= 0 {
|
||||
return fmt.Errorf("%s: quad_cm must be positive", m.Path)
|
||||
}
|
||||
if m.ElevationM.Max <= m.ElevationM.Min {
|
||||
return fmt.Errorf("%s: elevation_m.max must be above .min", m.Path)
|
||||
}
|
||||
// D-45: the importer picks the largest section size that divides the quad count, preferring one section
|
||||
// per component, so a resolution off the ladder of 255*N+1 or 127*N+1 silently multiplies the component
|
||||
// count. 4033 gave 4096 components and a forty-minute import. Refuse rather than let it happen again.
|
||||
q := m.QuadsPerSide()
|
||||
section := 0
|
||||
for _, s := range []int{255, 127, 63, 31, 15, 7} {
|
||||
if q%s == 0 {
|
||||
section = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if section == 0 {
|
||||
return fmt.Errorf("%s: vertices_per_side %d gives %d quads, which no section size divides; use 255*N+1 or 127*N+1",
|
||||
m.Path, m.VerticesPerSide, q)
|
||||
}
|
||||
if components := (q / section) * (q / section); components > 1024 {
|
||||
return fmt.Errorf("%s: vertices_per_side %d gives %d components of %d quads; that import takes tens of minutes (D-45)",
|
||||
m.Path, m.VerticesPerSide, components, section)
|
||||
}
|
||||
if f := m.Pipeline.GeologyFactor; f < 1 || q%f != 0 {
|
||||
return fmt.Errorf("%s: geology_factor %d must divide the quad count %d exactly", m.Path, f, q)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Derived geometry, all of it mirroring world_manifest.py.
|
||||
|
||||
func (m *Manifest) QuadsPerSide() int { return m.VerticesPerSide - 1 }
|
||||
func (m *Manifest) QuadM() float64 { return m.QuadCm / 100 }
|
||||
func (m *Manifest) SideM() float64 { return float64(m.QuadsPerSide()) * m.QuadM() }
|
||||
func (m *Manifest) AreaKm2() float64 { s := m.SideM() / 1000; return s * s }
|
||||
|
||||
func (m *Manifest) ElevationSpanM() float64 { return m.ElevationM.Max - m.ElevationM.Min }
|
||||
func (m *Manifest) ElevationMidM() float64 { return (m.ElevationM.Max + m.ElevationM.Min) / 2 }
|
||||
|
||||
// ZScale is the landscape actor's Z scale so the 16-bit range spans exactly the manifest's elevation range.
|
||||
func (m *Manifest) ZScale() float64 { return m.ElevationSpanM() / EngineSpanMAtScale100 * 100 }
|
||||
|
||||
// LandscapeZCm places value 32768 at the middle of the range, so elevation 0 m lands on world Z 0.
|
||||
func (m *Manifest) LandscapeZCm() float64 { return m.ElevationMidM() * 100 }
|
||||
|
||||
func (m *Manifest) MetresToValue(metres float64) float64 {
|
||||
return (metres - m.ElevationM.Min) / m.ElevationSpanM() * 65535
|
||||
}
|
||||
|
||||
func (m *Manifest) ValueToMetres(v float64) float64 {
|
||||
return m.ElevationM.Min + v/65535*m.ElevationSpanM()
|
||||
}
|
||||
|
||||
// GeologySize is the coarse grid the tectonics and the fluvial solve run on: an exact integer factor of the
|
||||
// quad count, so the upsample back to full resolution lands every sample on a sample.
|
||||
func (m *Manifest) GeologySize() int {
|
||||
return m.QuadsPerSide()/m.Pipeline.GeologyFactor + 1
|
||||
}
|
||||
|
||||
func (m *Manifest) GeologyCellM() float64 {
|
||||
return m.QuadM() * float64(m.Pipeline.GeologyFactor)
|
||||
}
|
||||
|
||||
// SectionLayout reports what the engine's importer will choose, so a run can print it and a person can see
|
||||
// the component count before the editor spends minutes on it.
|
||||
func (m *Manifest) SectionLayout() (section, componentsPerSide int) {
|
||||
q := m.QuadsPerSide()
|
||||
for _, s := range []int{255, 127, 63, 31, 15, 7} {
|
||||
if q%s == 0 {
|
||||
return s, q / s
|
||||
}
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Resolve reads a manifest path as relative to the project root.
|
||||
func (m *Manifest) Resolve(rel string) string {
|
||||
if filepath.IsAbs(rel) {
|
||||
return rel
|
||||
}
|
||||
return filepath.Join(ProjectRoot(m.Path), rel)
|
||||
}
|
||||
|
||||
// ProjectRoot walks up from the manifest (RawContent/World/World.json) to the repository root.
|
||||
func ProjectRoot(manifestPath string) string {
|
||||
abs, err := filepath.Abs(manifestPath)
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
return filepath.Dir(filepath.Dir(filepath.Dir(abs)))
|
||||
}
|
||||
|
||||
func (m *Manifest) Describe() string {
|
||||
section, perSide := m.SectionLayout()
|
||||
return fmt.Sprintf(
|
||||
"%d vertices a side at %g cm: %.2f km, %.0f km2; elevation %g..%g m (Z scale %g, actor Z %g cm, %.2f cm a step); "+
|
||||
"%dx%d components of %d quads; geology %d at %.1f m; source %s seed %d",
|
||||
m.VerticesPerSide, m.QuadCm, m.SideM()/1000, m.AreaKm2(),
|
||||
m.ElevationM.Min, m.ElevationM.Max, m.ZScale(), m.LandscapeZCm(), m.ElevationSpanM()/65535*100,
|
||||
perSide, perSide, section, m.GeologySize(), m.GeologyCellM(), m.Source.Kind, m.Source.Seed)
|
||||
}
|
||||
|
||||
// ClipFraction is the check D-48 made a pass/fail: U/K is the one relief knob and the elevation ceiling is a
|
||||
// hard clip in the 16-bit encoding, so a run that clips is a failed run, not a rounded one.
|
||||
func (m *Manifest) ClipFraction(metres []float32) float64 {
|
||||
if len(metres) == 0 {
|
||||
return 0
|
||||
}
|
||||
var n int
|
||||
for _, v := range metres {
|
||||
if float64(v) < m.ElevationM.Min || float64(v) > m.ElevationM.Max {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return float64(n) / float64(len(metres))
|
||||
}
|
||||
|
||||
// Encode turns metres into the 16-bit values the PNG carries, clamping to the range.
|
||||
func (m *Manifest) Encode(metres []float32) []uint16 {
|
||||
out := make([]uint16, len(metres))
|
||||
for i, v := range metres {
|
||||
x := m.MetresToValue(float64(v))
|
||||
if x < 0 {
|
||||
x = 0
|
||||
} else if x > 65535 {
|
||||
x = 65535
|
||||
}
|
||||
out[i] = uint16(math.Round(x))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Package noise is the small toolkit the uplift field is built from: value noise, fBm, domain warping and
|
||||
// Worley crest lines. Ported from Scripts/Authoring/heightmap_noise.py, and the tuning is the point of the
|
||||
// port, not the shapes. Read the constants in Docs/Terrain.md before changing any of them; every one of them
|
||||
// is a round of measurement that has already been paid for.
|
||||
//
|
||||
// The one change of intent from the numpy original (D-47): what comes out of here is no longer the terrain.
|
||||
// It is an uplift rate field that the fluvial pass integrates. Amplitudes are therefore far smaller and the
|
||||
// shapes matter more than the heights.
|
||||
package noise
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
// Source is a per-pass random source. Never the global one: determinism is cross-cutting rule 12 and a
|
||||
// shared source makes the result depend on which pass drew first.
|
||||
type Source struct{ r *rand.Rand }
|
||||
|
||||
// NewSource seeds from the run seed and the pass index, so inserting a pass does not reshuffle the ones
|
||||
// before it.
|
||||
func NewSource(seed int64, pass uint64) *Source {
|
||||
return &Source{r: rand.New(rand.NewPCG(uint64(seed), pass))}
|
||||
}
|
||||
|
||||
func (s *Source) Float() float64 { return s.r.Float64() }
|
||||
func (s *Source) Range(lo, hi float64) float64 { return lo + (hi-lo)*s.r.Float64() }
|
||||
func (s *Source) IntN(n int) int { return s.r.IntN(n) }
|
||||
|
||||
func Smoothstep(t float64) float64 { return t * t * (3 - 2*t) }
|
||||
|
||||
func smoothstep32(t float32) float32 { return t * t * (3 - 2*t) }
|
||||
|
||||
// Lattice is one octave's random grid. Periodic: sampling outside it wraps, so warped or stretched
|
||||
// coordinates never run off an edge.
|
||||
type Lattice struct {
|
||||
Cells int
|
||||
Data []float32
|
||||
}
|
||||
|
||||
// NewLattice draws cells*cells uniforms in index order, which is what makes the octave reproducible however
|
||||
// it is later sampled in parallel.
|
||||
func NewLattice(cells int, s *Source) *Lattice {
|
||||
l := &Lattice{Cells: cells, Data: make([]float32, cells*cells)}
|
||||
for i := range l.Data {
|
||||
l.Data[i] = float32(s.Float())
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// Sample interpolates the periodic lattice at (u, v) in cell units.
|
||||
func (l *Lattice) Sample(u, v float64) float32 {
|
||||
c := l.Cells
|
||||
i0 := int(math.Floor(u))
|
||||
j0 := int(math.Floor(v))
|
||||
tu := smoothstep32(float32(u - math.Floor(u)))
|
||||
tv := smoothstep32(float32(v - math.Floor(v)))
|
||||
i0 = ((i0 % c) + c) % c
|
||||
j0 = ((j0 % c) + c) % c
|
||||
i1 := (i0 + 1) % c
|
||||
j1 := (j0 + 1) % c
|
||||
top := l.Data[j0*c+i0]*(1-tu) + l.Data[j0*c+i1]*tu
|
||||
bot := l.Data[j1*c+i0]*(1-tu) + l.Data[j1*c+i1]*tu
|
||||
return top*(1-tv) + bot*tv
|
||||
}
|
||||
|
||||
// Params are one fBm stack. Gain is the constant that matters most: eight octaves at 0.5 makes every octave
|
||||
// as steep as the last and puts a third of the land above 50 degrees. 0.42 to 0.45.
|
||||
type Params struct {
|
||||
BaseCells int
|
||||
Octaves int
|
||||
Gain float64
|
||||
Ridged bool
|
||||
}
|
||||
|
||||
// FBM fills a size x size field with fractional Brownian motion in [0, 1] on the regular grid.
|
||||
func FBM(size int, s *Source, p Params) *field.Field {
|
||||
uv := identity(size)
|
||||
return FBMAt(uv.u, uv.v, s, p)
|
||||
}
|
||||
|
||||
// FBMAt samples the same stack at map coordinates, where 0..1 spans the map once. Feed it warped or
|
||||
// anisotropic coordinates and the noise bends and stretches with them, which is how the ranges come out as
|
||||
// long chains rather than blobs.
|
||||
func FBMAt(u, v *field.Field, s *Source, p Params) *field.Field {
|
||||
out := field.NewLike(u)
|
||||
amplitude, cells, norm := 1.0, p.BaseCells, 0.0
|
||||
// Lattices are built up front, in octave order, before any sampling: the draw order must not depend on
|
||||
// the parallel loop below.
|
||||
lattices := make([]*Lattice, p.Octaves)
|
||||
for o := 0; o < p.Octaves; o++ {
|
||||
lattices[o] = NewLattice(cells, s)
|
||||
cells *= 2
|
||||
}
|
||||
cells = p.BaseCells
|
||||
for o := 0; o < p.Octaves; o++ {
|
||||
l := lattices[o]
|
||||
amp := float32(amplitude)
|
||||
c := float64(cells)
|
||||
field.Rows(u.H, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < u.W; x++ {
|
||||
i := y*u.W + x
|
||||
n := l.Sample(float64(u.Data[i])*c, float64(v.Data[i])*c)
|
||||
if p.Ridged {
|
||||
n = 1 - float32(math.Abs(float64(n)*2-1))
|
||||
n = n * n
|
||||
}
|
||||
out.Data[i] += n * amp
|
||||
}
|
||||
}
|
||||
})
|
||||
norm += amplitude
|
||||
amplitude *= p.Gain
|
||||
cells *= 2
|
||||
}
|
||||
inv := float32(1 / norm)
|
||||
for i := range out.Data {
|
||||
out.Data[i] *= inv
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CellularEdges is Worley F2-F1 through periodic jittered feature points, mapped so the borders between cells
|
||||
// read 1 and the interiors 0: a network of thin, branching crest lines.
|
||||
//
|
||||
// Kept light on purpose. At 30 % of the mountain height this turned the ranges into a honeycomb of polygon
|
||||
// walls with flat floors (2026-09-17); 12 % through a stronger warp is the setting that survived.
|
||||
func CellularEdges(u, v *field.Field, s *Source, cells int, jitter float64) *field.Field {
|
||||
pts := make([]float32, cells*cells*2)
|
||||
for i := range pts {
|
||||
pts[i] = float32(s.Float()*jitter + (1-jitter)*0.5)
|
||||
}
|
||||
out := field.NewLike(u)
|
||||
field.Rows(u.H, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < u.W; x++ {
|
||||
i := y*u.W + x
|
||||
su := float64(u.Data[i]) * float64(cells)
|
||||
sv := float64(v.Data[i]) * float64(cells)
|
||||
i0 := int(math.Floor(su))
|
||||
j0 := int(math.Floor(sv))
|
||||
fu := su - math.Floor(su)
|
||||
fv := sv - math.Floor(sv)
|
||||
f1, f2 := math.Inf(1), math.Inf(1)
|
||||
for dj := -1; dj <= 1; dj++ {
|
||||
for di := -1; di <= 1; di++ {
|
||||
ci := ((i0+di)%cells + cells) % cells
|
||||
cj := ((j0+dj)%cells + cells) % cells
|
||||
px := float64(pts[(cj*cells+ci)*2]) + float64(di) - fu
|
||||
py := float64(pts[(cj*cells+ci)*2+1]) + float64(dj) - fv
|
||||
d := math.Hypot(px, py)
|
||||
if d < f1 {
|
||||
f2, f1 = f1, d
|
||||
} else if d < f2 {
|
||||
f2 = d
|
||||
}
|
||||
}
|
||||
}
|
||||
e := 1 - (f2-f1)/0.6
|
||||
if e < 0 {
|
||||
e = 0
|
||||
} else if e > 1 {
|
||||
e = 1
|
||||
}
|
||||
out.Data[i] = float32(e * e)
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
type uvPair struct{ u, v *field.Field }
|
||||
|
||||
// identity is the unwarped coordinate pair: 0..1 across the map, matching numpy's mgrid / (size - 1).
|
||||
func identity(size int) uvPair {
|
||||
u := field.New(size, size, 1)
|
||||
v := field.New(size, size, 1)
|
||||
inv := float32(1) / float32(size-1)
|
||||
for y := 0; y < size; y++ {
|
||||
for x := 0; x < size; x++ {
|
||||
u.Data[y*size+x] = float32(x) * inv
|
||||
v.Data[y*size+x] = float32(y) * inv
|
||||
}
|
||||
}
|
||||
return uvPair{u, v}
|
||||
}
|
||||
|
||||
// Identity exposes the coordinate pair so passes can warp it.
|
||||
func Identity(size int) (u, v *field.Field) {
|
||||
p := identity(size)
|
||||
return p.u, p.v
|
||||
}
|
||||
|
||||
// Warp offsets a coordinate pair by a low-frequency field scaled by amount. This is what bends ridges so
|
||||
// ranges curve instead of running straight.
|
||||
func Warp(u, v, dx, dy *field.Field, amount float64) (*field.Field, *field.Field) {
|
||||
wu := u.Clone()
|
||||
wv := v.Clone()
|
||||
a := float32(amount)
|
||||
for i := range wu.Data {
|
||||
wu.Data[i] += (dx.Data[i] - 0.5) * 2 * a
|
||||
wv.Data[i] += (dy.Data[i] - 0.5) * 2 * a
|
||||
}
|
||||
return wu, wv
|
||||
}
|
||||
|
||||
// WorldUV is the coordinate pair for noise that must not move when the map does: u and v count periods of
|
||||
// *world* space from the world origin rather than fractions of the map.
|
||||
//
|
||||
// Everything else in this package takes coordinates where 0..1 spans the map once, which is right for a field
|
||||
// whose whole job is to be shaped like the continent — the range grain, the continent outline, the lithology.
|
||||
// It is wrong for anything that will one day be generated a tile at a time, because two tiles asking about the
|
||||
// same physical place would get different values and every seam would show. That is rule 1 of the tiling plan
|
||||
// in Docs/Terrain-Next.md, and this function is what obeying it looks like.
|
||||
//
|
||||
// periodM is how far the lattice runs before it repeats, so it must be comfortably larger than any world that
|
||||
// will ever be generated; the octave count then sets the finest wavelength, periodM / (BaseCells * 2^octaves).
|
||||
func WorldUV(w, h int, cellM, originXM, originYM, periodM float64) (u, v *field.Field) {
|
||||
u = field.New(w, h, cellM)
|
||||
v = field.New(w, h, cellM)
|
||||
for y := 0; y < h; y++ {
|
||||
vy := float32((originYM + float64(y)*cellM) / periodM)
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
u.Data[i] = float32((originXM + float64(x)*cellM) / periodM)
|
||||
v.Data[i] = vy
|
||||
}
|
||||
}
|
||||
return u, v
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
// Package stats is how a run is judged. "It reads as real geology" is not a screenshot; it is a straight
|
||||
// slope-area plot and an S-shaped hypsometric curve, and this package produces both.
|
||||
//
|
||||
// The project's habit already is to measure rather than eyeball — a slope histogram settled the noise tuning
|
||||
// and attributed rill damage to a specific pass — and these are the two standard checks the incoming spec
|
||||
// added on top. The slope-area exponent in particular is the direct test of whether the fluvial pass did the
|
||||
// thing it exists to do, so it is the proof that closes build-order step 4.
|
||||
package stats
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
type Bin struct {
|
||||
LogA float64 `json:"log_area"`
|
||||
LogS float64 `json:"log_slope"`
|
||||
N int `json:"n"`
|
||||
}
|
||||
|
||||
// SlopeArea is the stream-power signature. At steady state S = (U/K)^(1/n) * A^(-m/n), so log S against
|
||||
// log A is a straight line of gradient -m/n: -0.5 at the defaults. A curved or scattered plot means K, m, n
|
||||
// or the run length is wrong, and no amount of detail noise will hide it.
|
||||
//
|
||||
// The catch, and it took a bad R2 to notice: that relation has the same gradient but a *different intercept*
|
||||
// for every uplift rate. This map's uplift spans 0.2 to 5 mm/yr, so regressing every channel together stacks
|
||||
// twenty-five-fold-separated parallel lines into a cloud and fits nonsense to it. Slope is therefore
|
||||
// normalised by (U/K)^(1/n) first, which collapses every regime onto one line through the origin and tests
|
||||
// the exponent rather than the uplift field's heterogeneity. RawExponent keeps the unnormalised fit, which is
|
||||
// what a single-uplift map would report and is worth seeing next to it.
|
||||
type SlopeArea struct {
|
||||
Bins []Bin `json:"bins"`
|
||||
Exponent float64 `json:"exponent"`
|
||||
RawExponent float64 `json:"raw_exponent"`
|
||||
Expected float64 `json:"expected"`
|
||||
R2 float64 `json:"r2"`
|
||||
RawR2 float64 `json:"raw_r2"`
|
||||
Channels int `json:"channel_cells"`
|
||||
ThreshKm2 float64 `json:"threshold_km2"`
|
||||
}
|
||||
|
||||
// Hypsometry is the second check: cumulative area against normalised elevation should be S-shaped. The
|
||||
// integral is the single number - convex and high means too young or too much uplift, concave and low means
|
||||
// over-eroded. Mature landscapes sit near 0.4 to 0.6.
|
||||
type Hypsometry struct {
|
||||
Integral float64 `json:"integral"`
|
||||
Curve []float64 `json:"curve"` // area fraction at 11 elevation fractions, 0.0 to 1.0
|
||||
}
|
||||
|
||||
type Slopes struct {
|
||||
Under15Deg float64 `json:"under_15_deg"`
|
||||
Under30Deg float64 `json:"under_30_deg"`
|
||||
Over50Deg float64 `json:"over_50_deg"`
|
||||
MedianDeg float64 `json:"median_deg"`
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
LandFraction float64 `json:"land_fraction"`
|
||||
ClipFraction float64 `json:"clip_fraction"`
|
||||
// Min and Max span the whole field, sea floor included, because that is what the 16-bit encoding has to
|
||||
// fit. Land relief is the number that says anything about the terrain, and they are not the same: a
|
||||
// -180 m sea floor flatters the relief by 180 m for free.
|
||||
ReliefM float64 `json:"relief_m"`
|
||||
MinM float64 `json:"min_m"`
|
||||
MaxM float64 `json:"max_m"`
|
||||
LandMinM float64 `json:"land_min_m"`
|
||||
LandMaxM float64 `json:"land_max_m"`
|
||||
LandReliefM float64 `json:"land_relief_m"`
|
||||
Slopes Slopes `json:"slopes"`
|
||||
SlopeArea SlopeArea `json:"slope_area"`
|
||||
Hypsometry Hypsometry `json:"hypsometry"`
|
||||
DrainageDensity float64 `json:"drainage_density_per_km"`
|
||||
|
||||
// Buckets is the whole-map aggregates split by the uplift class that caused them; see UpliftBuckets.
|
||||
// The map-wide median above cannot tell a mountain belt from a plain, and that is the question.
|
||||
Buckets []UpliftBucket `json:"uplift_buckets"`
|
||||
}
|
||||
|
||||
// ComputeSlopeArea bins channel cells by log10 drainage area and takes the median slope in each bin, which
|
||||
// is far more robust than the mean: one cliff cell in a bin drags a mean and leaves a median alone.
|
||||
//
|
||||
// S is the gradient *along the flow path*, (h - h_receiver) / L, not the magnitude of the topographic
|
||||
// gradient. The difference is not pedantic: for a cell on a valley floor the central difference is dominated
|
||||
// by the valley walls across the channel, which reads as a far steeper slope than the water actually runs
|
||||
// down, and it bends the fitted exponent well past -m/n. The receiver gradient is the quantity the
|
||||
// stream-power law is written in, so it is the quantity the plot has to use.
|
||||
// kLocal is the per-cell erodibility multiplier from the lithology pass, and passing it matters as much as
|
||||
// passing the uplift. Erodibility correlates with drainage area by construction: soft rock is cut down, so it
|
||||
// sits low and collects flow, while hard rock stands up as ridges and drains little. Normalising every cell by
|
||||
// one global K therefore mis-corrects the large-A end systematically and bends the fitted exponent — it read
|
||||
// -1.23 against a true -0.50 on a landscape the solver had built correctly. Steady state is written in the
|
||||
// local K, so the normalisation has to be too.
|
||||
func ComputeSlopeArea(h *field.Field, area []float32, receiver []int32, length []float32, land []bool,
|
||||
upliftMYr, kLocal []float32, k, n float64, thresholdM2 float64) SlopeArea {
|
||||
const binsPerDecade = 4
|
||||
type acc struct{ norm, raw []float64 }
|
||||
bins := map[int]*acc{}
|
||||
count := 0
|
||||
for i := range h.Data {
|
||||
if land != nil && !land[i] {
|
||||
continue
|
||||
}
|
||||
r := receiver[i]
|
||||
if int(r) == i { // a root drains to itself and has no gradient to measure
|
||||
continue
|
||||
}
|
||||
a := float64(area[i])
|
||||
s := float64(h.Data[i]-h.Data[r]) / float64(length[i])
|
||||
if a < thresholdM2 || s <= 1e-6 {
|
||||
continue
|
||||
}
|
||||
u := 0.0
|
||||
if upliftMYr != nil {
|
||||
u = float64(upliftMYr[i])
|
||||
}
|
||||
kk := k
|
||||
if kLocal != nil {
|
||||
kk *= float64(kLocal[i])
|
||||
}
|
||||
if u <= 0 || kk <= 0 || n <= 0 {
|
||||
continue // no steady state to normalise against
|
||||
}
|
||||
count++
|
||||
key := int(math.Floor(math.Log10(a) * binsPerDecade))
|
||||
b := bins[key]
|
||||
if b == nil {
|
||||
b = &acc{}
|
||||
bins[key] = b
|
||||
}
|
||||
b.norm = append(b.norm, math.Log10(s/math.Pow(u/kk, 1/n)))
|
||||
b.raw = append(b.raw, math.Log10(s))
|
||||
}
|
||||
// Map iteration is randomised in Go, so the keys are sorted before anything reads them. Determinism is
|
||||
// cross-cutting rule 12 and this is exactly where it would leak.
|
||||
keys := make([]int, 0, len(bins))
|
||||
for k := range bins {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
|
||||
out := SlopeArea{Expected: expectedGradient, Channels: count, ThreshKm2: thresholdM2 / 1e6}
|
||||
var xs, normYs, rawYs []float64
|
||||
for _, key := range keys {
|
||||
b := bins[key]
|
||||
if len(b.norm) < 8 { // a bin with a handful of cells is noise, not a data point
|
||||
continue
|
||||
}
|
||||
sort.Float64s(b.norm)
|
||||
sort.Float64s(b.raw)
|
||||
logA := (float64(key) + 0.5) / binsPerDecade
|
||||
out.Bins = append(out.Bins, Bin{LogA: logA, LogS: b.norm[len(b.norm)/2], N: len(b.norm)})
|
||||
xs = append(xs, logA)
|
||||
normYs = append(normYs, b.norm[len(b.norm)/2])
|
||||
rawYs = append(rawYs, b.raw[len(b.raw)/2])
|
||||
}
|
||||
out.Exponent, out.R2 = fitLine(xs, normYs)
|
||||
out.RawExponent, out.RawR2 = fitLine(xs, rawYs)
|
||||
return out
|
||||
}
|
||||
|
||||
// expectedGradient is the -m/n the theory predicts, kept in one place so the verdict compares the fit against
|
||||
// the exponents the run was actually configured with rather than against the defaults.
|
||||
var expectedGradient = -0.5
|
||||
|
||||
// SetExpected is called once from the command before any report is computed.
|
||||
func SetExpected(m, n float64) {
|
||||
if n != 0 {
|
||||
expectedGradient = -m / n
|
||||
}
|
||||
}
|
||||
|
||||
// fitLine is an ordinary least-squares fit returning the gradient and R².
|
||||
func fitLine(x, y []float64) (float64, float64) {
|
||||
n := float64(len(x))
|
||||
if n < 3 {
|
||||
return 0, 0
|
||||
}
|
||||
var sx, sy, sxx, sxy float64
|
||||
for i := range x {
|
||||
sx += x[i]
|
||||
sy += y[i]
|
||||
sxx += x[i] * x[i]
|
||||
sxy += x[i] * y[i]
|
||||
}
|
||||
den := n*sxx - sx*sx
|
||||
if math.Abs(den) < 1e-12 {
|
||||
return 0, 0
|
||||
}
|
||||
grad := (n*sxy - sx*sy) / den
|
||||
intercept := (sy - grad*sx) / n
|
||||
mean := sy / n
|
||||
var ssRes, ssTot float64
|
||||
for i := range x {
|
||||
pred := grad*x[i] + intercept
|
||||
ssRes += (y[i] - pred) * (y[i] - pred)
|
||||
ssTot += (y[i] - mean) * (y[i] - mean)
|
||||
}
|
||||
if ssTot < 1e-12 {
|
||||
return grad, 0
|
||||
}
|
||||
return grad, 1 - ssRes/ssTot
|
||||
}
|
||||
|
||||
func ComputeHypsometry(h *field.Field, land []bool) Hypsometry {
|
||||
vals := make([]float64, 0, len(h.Data))
|
||||
for i, v := range h.Data {
|
||||
if land != nil && !land[i] {
|
||||
continue
|
||||
}
|
||||
vals = append(vals, float64(v))
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return Hypsometry{}
|
||||
}
|
||||
sort.Float64s(vals)
|
||||
lo, hi := vals[0], vals[len(vals)-1]
|
||||
span := hi - lo
|
||||
if span < 1e-6 {
|
||||
return Hypsometry{Integral: 0}
|
||||
}
|
||||
var sum float64
|
||||
for _, v := range vals {
|
||||
sum += (v - lo) / span
|
||||
}
|
||||
curve := make([]float64, 11)
|
||||
for i := 0; i <= 10; i++ {
|
||||
target := lo + span*float64(i)/10
|
||||
// Fraction of land standing above this elevation.
|
||||
idx := sort.SearchFloat64s(vals, target)
|
||||
curve[i] = 1 - float64(idx)/float64(len(vals))
|
||||
}
|
||||
return Hypsometry{Integral: sum / float64(len(vals)), Curve: curve}
|
||||
}
|
||||
|
||||
func ComputeSlopes(h *field.Field, land []bool) Slopes {
|
||||
slope := h.Slope()
|
||||
degs := make([]float64, 0, len(slope.Data))
|
||||
for i, s := range slope.Data {
|
||||
if land != nil && !land[i] {
|
||||
continue
|
||||
}
|
||||
degs = append(degs, math.Atan(float64(s))*180/math.Pi)
|
||||
}
|
||||
if len(degs) == 0 {
|
||||
return Slopes{}
|
||||
}
|
||||
sort.Float64s(degs)
|
||||
frac := func(limit float64) float64 {
|
||||
return float64(sort.SearchFloat64s(degs, limit)) / float64(len(degs))
|
||||
}
|
||||
return Slopes{
|
||||
Under15Deg: frac(15),
|
||||
Under30Deg: frac(30),
|
||||
Over50Deg: 1 - frac(50),
|
||||
MedianDeg: degs[len(degs)/2],
|
||||
}
|
||||
}
|
||||
|
||||
// DrainageDensity is channel length over basin area, per kilometre. Real landscapes sit around 1 to 10 /km;
|
||||
// a value near zero means the solve never organised into channels at all.
|
||||
func DrainageDensity(area []float32, land []bool, cellM float64, thresholdM2 float64) float64 {
|
||||
var channels, total int
|
||||
for i, a := range area {
|
||||
if land != nil && !land[i] {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if float64(a) >= thresholdM2 {
|
||||
channels++
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
lengthKm := float64(channels) * cellM / 1000
|
||||
areaKm2 := float64(total) * cellM * cellM / 1e6
|
||||
if areaKm2 == 0 {
|
||||
return 0
|
||||
}
|
||||
return lengthKm / areaKm2
|
||||
}
|
||||
|
||||
// Summary is the one block a run prints. Written so the numbers that decide whether the run was any good are
|
||||
// the ones you see without asking.
|
||||
func (r Report) Summary() string {
|
||||
sa := r.SlopeArea
|
||||
verdict := "no channels: the solve did not organise"
|
||||
if sa.Channels > 0 && len(sa.Bins) >= 3 {
|
||||
switch {
|
||||
case sa.R2 >= 0.9 && math.Abs(sa.Exponent-sa.Expected) < 0.15:
|
||||
verdict = "straight and at the expected gradient: stream power is doing its job"
|
||||
case sa.R2 >= 0.9:
|
||||
verdict = "straight but off gradient: K, m or n is wrong, or the run is too short"
|
||||
default:
|
||||
verdict = "scattered: not at steady state, or pits are routing badly"
|
||||
}
|
||||
}
|
||||
hyp := "mature"
|
||||
switch {
|
||||
case r.Hypsometry.Integral > 0.6:
|
||||
hyp = "convex: too young, or too much uplift"
|
||||
case r.Hypsometry.Integral < 0.35:
|
||||
hyp = "concave: over-eroded"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
" field %.0f..%.0f m; land %.0f..%.0f m (relief %.0f m), %.0f%% land, %.2f%% clipped\n"+
|
||||
" slopes: %.0f%% under 15 deg, %.0f%% under 30, %.1f%% over 50, median %.1f deg\n"+
|
||||
" slope-area: exponent %.3f (expect %.3f), R2 %.3f over %d bins, %d channel cells above %.2f km2\n"+
|
||||
" unnormalised %.3f, R2 %.3f (heterogeneous uplift, so this one is expected to be worse)\n"+
|
||||
" %s\n"+
|
||||
" hypsometric integral %.3f (%s); drainage density %.2f /km\n"+
|
||||
"%s",
|
||||
r.MinM, r.MaxM, r.LandMinM, r.LandMaxM, r.LandReliefM, r.LandFraction*100, r.ClipFraction*100,
|
||||
r.Slopes.Under15Deg*100, r.Slopes.Under30Deg*100, r.Slopes.Over50Deg*100, r.Slopes.MedianDeg,
|
||||
sa.Exponent, sa.Expected, sa.R2, len(sa.Bins), sa.Channels, sa.ThreshKm2,
|
||||
sa.RawExponent, sa.RawR2,
|
||||
verdict, r.Hypsometry.Integral, hyp, r.DrainageDensity,
|
||||
BucketSummary(r.Buckets))
|
||||
}
|
||||
|
||||
// Uplift buckets: the measurement that decides whether a plain is a plain.
|
||||
//
|
||||
// Every aggregate above is taken over the whole land mask, and that is exactly what hid the problem this
|
||||
// bucketing was added to find. A continent whose mountains are at 35 degrees and whose plains are at 32
|
||||
// reports a median of 31 and looks, from the summary, like a mountainous map — which it is, but not for the
|
||||
// reason anyone assumed. Splitting by the uplift rate that *caused* the slope separates the two questions:
|
||||
// "are the mountains right" and "are the plains plains".
|
||||
//
|
||||
// Uplift is the right axis rather than elevation. Elevation is the output of the solve, so bucketing by it
|
||||
// mixes a low mountain valley in with a plain and moves the boundary every time a constant changes; uplift
|
||||
// is an input, fixed before the first step, and it is the term that sets steady-state slope through
|
||||
// S = U/(K*A^m). A cell's bucket therefore does not move when the run does.
|
||||
|
||||
// UpliftBucket is one class of the uplift field and what the landscape did with it.
|
||||
type UpliftBucket struct {
|
||||
Name string `json:"name"`
|
||||
LoMmYr float64 `json:"lo_mm_yr"`
|
||||
HiMmYr float64 `json:"hi_mm_yr"`
|
||||
LandFrac float64 `json:"land_fraction"` // share of land in this bucket
|
||||
MedianDeg float64 `json:"median_deg"`
|
||||
P90Deg float64 `json:"p90_deg"`
|
||||
MedianRelM float64 `json:"median_relief_m"` // local relief, max-min over the window below
|
||||
WindowM float64 `json:"relief_window_m"`
|
||||
NearTalus float64 `json:"near_talus_fraction"` // within 2 degrees of the angle of repose
|
||||
MedianElevM float64 `json:"median_elev_m"`
|
||||
Cells int `json:"cells"`
|
||||
}
|
||||
|
||||
// UpliftBuckets splits the land by rock uplift rate and reports slope, local relief and how much of each
|
||||
// bucket is pinned against the repose clamp. The last of those is the diagnostic: a bucket where most cells
|
||||
// sit within two degrees of talus is not being shaped by erosion at all, it is being shaped by the clamp,
|
||||
// and no amount of tuning downstream of that will change what it looks like.
|
||||
//
|
||||
// reliefWindowM is the side of the square the local relief is taken over; 500 m is the usual choice and is
|
||||
// what the caller passes.
|
||||
func UpliftBuckets(h *field.Field, upliftMYr []float32, land []bool, talusDeg, reliefWindowM float64) []UpliftBucket {
|
||||
// The class boundaries are in mm/yr and are deliberately absolute rather than percentiles of this map's
|
||||
// own field: the point is to compare one run against the next, and a percentile split would redefine
|
||||
// "plain" every time the uplift field was retuned.
|
||||
defs := []struct {
|
||||
name string
|
||||
lo, hi float64
|
||||
}{
|
||||
{"plain", 0, 0.1},
|
||||
{"rolling", 0.1, 0.5},
|
||||
// The top bound is finite rather than +Inf only because the report is marshalled to meta.json and
|
||||
// encoding/json refuses an infinity. 100 mm/yr is an order of magnitude above anything on Earth.
|
||||
{"mountain", 0.5, 100},
|
||||
}
|
||||
if upliftMYr == nil {
|
||||
return nil
|
||||
}
|
||||
slope := h.Slope()
|
||||
radius := int(math.Round(reliefWindowM / h.CellM / 2))
|
||||
if radius < 1 {
|
||||
radius = 1
|
||||
}
|
||||
type acc struct {
|
||||
deg, rel, elev []float64
|
||||
near, total int
|
||||
}
|
||||
accs := make([]acc, len(defs))
|
||||
landCells := 0
|
||||
for i := range h.Data {
|
||||
if land != nil && !land[i] {
|
||||
continue
|
||||
}
|
||||
landCells++
|
||||
u := float64(upliftMYr[i]) * 1000 // mm/yr
|
||||
b := -1
|
||||
for j, d := range defs {
|
||||
if u >= d.lo && u < d.hi {
|
||||
b = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if b < 0 {
|
||||
continue
|
||||
}
|
||||
a := &accs[b]
|
||||
deg := math.Atan(float64(slope.Data[i])) * 180 / math.Pi
|
||||
a.deg = append(a.deg, deg)
|
||||
a.elev = append(a.elev, float64(h.Data[i]))
|
||||
a.rel = append(a.rel, localRelief(h, i%h.W, i/h.W, radius))
|
||||
a.total++
|
||||
if deg >= talusDeg-2 { // pinned against the clamp rather than shaped by erosion
|
||||
a.near++
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]UpliftBucket, 0, len(defs))
|
||||
for j, d := range defs {
|
||||
a := &accs[j]
|
||||
if a.total == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Float64s(a.deg)
|
||||
sort.Float64s(a.rel)
|
||||
sort.Float64s(a.elev)
|
||||
out = append(out, UpliftBucket{
|
||||
Name: d.name, LoMmYr: d.lo, HiMmYr: d.hi,
|
||||
LandFrac: float64(a.total) / float64(max(landCells, 1)),
|
||||
MedianDeg: a.deg[len(a.deg)/2],
|
||||
P90Deg: a.deg[min(len(a.deg)*9/10, len(a.deg)-1)],
|
||||
MedianRelM: a.rel[len(a.rel)/2],
|
||||
WindowM: float64(radius*2) * h.CellM,
|
||||
NearTalus: float64(a.near) / float64(a.total),
|
||||
MedianElevM: a.elev[len(a.elev)/2],
|
||||
Cells: a.total,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// localRelief is max minus min over a square window, the standard field measure of how rugged a place is.
|
||||
// Slope alone cannot tell a 5 m hummock from a 500 m mountainside, because both can stand at 30 degrees.
|
||||
func localRelief(h *field.Field, cx, cy, radius int) float64 {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for y := cy - radius; y <= cy+radius; y++ {
|
||||
for x := cx - radius; x <= cx+radius; x++ {
|
||||
v := float64(h.AtClamped(x, y))
|
||||
if v < lo {
|
||||
lo = v
|
||||
}
|
||||
if v > hi {
|
||||
hi = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return hi - lo
|
||||
}
|
||||
|
||||
// BucketSummary is the block the buckets print. Kept separate from Summary so a run that has no uplift field
|
||||
// to hand still prints the rest.
|
||||
func BucketSummary(bs []UpliftBucket) string {
|
||||
if len(bs) == 0 {
|
||||
return ""
|
||||
}
|
||||
s := " by uplift class:\n"
|
||||
for _, b := range bs {
|
||||
hi := fmt.Sprintf("%.2f", b.HiMmYr)
|
||||
if b.HiMmYr >= 100 {
|
||||
hi = " up"
|
||||
}
|
||||
s += fmt.Sprintf(" %-9s %.2f..%s mm/yr %4.0f%% of land slope %4.1f deg median, %4.1f P90 "+
|
||||
"relief %5.0f m/%.0f m at talus %4.0f%% median %.0f m\n",
|
||||
b.Name, b.LoMmYr, hi, b.LandFrac*100, b.MedianDeg, b.P90Deg, b.MedianRelM, b.WindowM,
|
||||
b.NearTalus*100, b.MedianElevM)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Package thermal is mass-conserving thermal weathering at an angle of repose: where a cell stands above a
|
||||
// neighbour by more than the angle allows, material slides down.
|
||||
//
|
||||
// It is not decoration, and this is worth stating because it looks like decoration. Stream power puts *no
|
||||
// upper bound* on hillslope angle: the steady state S = (U/K)^(1/n) * A^(-m/n) is evaluated per cell, and a
|
||||
// cell next to a divide drains one cell, so A is tiny and S is enormous. Raising the intraplate uplift rate
|
||||
// to give the plains some relief therefore made every hillslope on the map proportionally steeper, and the
|
||||
// median slope went from 10 to 39 degrees. What limits a real hillslope is not fluvial incision, it is
|
||||
// landsliding, and this is landsliding.
|
||||
//
|
||||
// Ported from heightmap_erosion.thermal, including the lesson attached to it: shed half the *largest* excess
|
||||
// rather than half the mean, because the mean converges far slower. Mass is conserved, so a cliff keeps its
|
||||
// face and scree gathers at its foot instead of evaporating.
|
||||
package thermal
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
var (
|
||||
dx8 = [8]int{-1, 0, 1, -1, 1, -1, 0, 1}
|
||||
dy8 = [8]int{-1, -1, -1, 0, 0, 1, 1, 1}
|
||||
)
|
||||
|
||||
// Apply runs `passes` iterations over h in place. talus is the maximum slope as rise over run (tan of the
|
||||
// angle of repose). fixed marks cells that may not move, which is base level.
|
||||
//
|
||||
// Each pass reads h and writes a delta, then applies it: no cell is read after any cell has been written, so
|
||||
// the result does not depend on the order the rows were processed and the parallel version is identical to
|
||||
// the serial one.
|
||||
func Apply(h []float32, w, hgt int, cellM, talus float64, passes int, fixed []bool, scratch []float32) {
|
||||
if passes <= 0 || talus <= 0 {
|
||||
return
|
||||
}
|
||||
delta := scratch[:len(h)]
|
||||
card := cellM
|
||||
diag := cellM * math.Sqrt2
|
||||
|
||||
for p := 0; p < passes; p++ {
|
||||
for i := range delta {
|
||||
delta[i] = 0
|
||||
}
|
||||
// Deltas are accumulated per source cell into a private view; each row range owns its own writes for
|
||||
// the cell it is standing on, and the neighbour credits are gathered rather than scattered, which is
|
||||
// what keeps this free of races without a lock.
|
||||
field.Rows(hgt, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
// Gather: how much this cell RECEIVES from higher neighbours, and how much it sheds.
|
||||
var give, take float64
|
||||
if fixed == nil || !fixed[i] {
|
||||
give = shed(h, w, hgt, x, y, card, diag, talus)
|
||||
}
|
||||
take = 0
|
||||
for k := 0; k < 8; k++ {
|
||||
nx, ny := x+dx8[k], y+dy8[k]
|
||||
if nx < 0 || ny < 0 || nx >= w || ny >= hgt {
|
||||
continue
|
||||
}
|
||||
ni := ny*w + nx
|
||||
if fixed != nil && fixed[ni] {
|
||||
continue
|
||||
}
|
||||
// The share this cell gets of that neighbour's shed material.
|
||||
if share := shedShare(h, w, hgt, nx, ny, i, card, diag, talus); share > 0 {
|
||||
take += share
|
||||
}
|
||||
}
|
||||
delta[i] = float32(take - give)
|
||||
}
|
||||
}
|
||||
})
|
||||
for i := range h {
|
||||
h[i] += delta[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shed is the total a cell gives away this pass: half the largest excess over the angle of repose.
|
||||
func shed(h []float32, w, hgt, x, y int, card, diag, talus float64) float64 {
|
||||
i := y*w + x
|
||||
maxExcess := 0.0
|
||||
for k := 0; k < 8; k++ {
|
||||
nx, ny := x+dx8[k], y+dy8[k]
|
||||
if nx < 0 || ny < 0 || nx >= w || ny >= hgt {
|
||||
continue
|
||||
}
|
||||
d := card
|
||||
if dx8[k] != 0 && dy8[k] != 0 {
|
||||
d = diag
|
||||
}
|
||||
if e := float64(h[i]-h[ny*w+nx]) - talus*d; e > maxExcess {
|
||||
maxExcess = e
|
||||
}
|
||||
}
|
||||
return maxExcess / 2
|
||||
}
|
||||
|
||||
// shedShare is how much of cell (x, y)'s shed material lands on cell target, proportional to target's share
|
||||
// of the total excess below it.
|
||||
func shedShare(h []float32, w, hgt, x, y, target int, card, diag, talus float64) float64 {
|
||||
i := y*w + x
|
||||
maxExcess, total, mine := 0.0, 0.0, 0.0
|
||||
for k := 0; k < 8; k++ {
|
||||
nx, ny := x+dx8[k], y+dy8[k]
|
||||
if nx < 0 || ny < 0 || nx >= w || ny >= hgt {
|
||||
continue
|
||||
}
|
||||
d := card
|
||||
if dx8[k] != 0 && dy8[k] != 0 {
|
||||
d = diag
|
||||
}
|
||||
ni := ny*w + nx
|
||||
e := float64(h[i]-h[ni]) - talus*d
|
||||
if e <= 0 {
|
||||
continue
|
||||
}
|
||||
if e > maxExcess {
|
||||
maxExcess = e
|
||||
}
|
||||
total += e
|
||||
if ni == target {
|
||||
mine = e
|
||||
}
|
||||
}
|
||||
if total <= 0 || mine <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (maxExcess / 2) * (mine / total)
|
||||
}
|
||||
|
||||
// TalusFromDegrees converts an angle of repose to the rise-over-run the solver wants.
|
||||
func TalusFromDegrees(deg float64) float64 { return math.Tan(deg * math.Pi / 180) }
|
||||
@@ -0,0 +1,73 @@
|
||||
package thermal
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestConeIsCutToRepose is the property the whole hillslope story rests on: after enough passes nothing may
|
||||
// stand steeper than the angle of repose. A cone is the worst case, because every cell on it is steep.
|
||||
func TestConeIsCutToRepose(t *testing.T) {
|
||||
const (
|
||||
w, h = 101, 101
|
||||
cellM = 10.0
|
||||
deg = 33.0
|
||||
)
|
||||
talus := TalusFromDegrees(deg)
|
||||
field := make([]float32, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
// A steep cone: 60 m of fall per 10 m cell at the flanks, far beyond repose.
|
||||
d := math.Hypot(float64(x-w/2), float64(y-h/2))
|
||||
field[y*w+x] = float32(math.Max(0, 600-6*d*cellM))
|
||||
}
|
||||
}
|
||||
before := maxSlope(field, w, h, cellM)
|
||||
scratch := make([]float32, w*h)
|
||||
|
||||
volBefore := sum(field)
|
||||
Apply(field, w, h, cellM, talus, 400, nil, scratch)
|
||||
volAfter := sum(field)
|
||||
|
||||
after := maxSlope(field, w, h, cellM)
|
||||
t.Logf("max slope %.1f deg -> %.1f deg (repose %.1f)", degOf(before), degOf(after), deg)
|
||||
if degOf(after) > deg+2 {
|
||||
t.Errorf("after 400 passes the steepest slope is %.1f deg, want no more than %.1f", degOf(after), deg+2)
|
||||
}
|
||||
if rel := math.Abs(volAfter-volBefore) / volBefore; rel > 1e-4 {
|
||||
t.Errorf("mass changed by %.4f%%; thermal weathering must conserve it", rel*100)
|
||||
}
|
||||
}
|
||||
|
||||
func sum(a []float32) float64 {
|
||||
var s float64
|
||||
for _, v := range a {
|
||||
s += float64(v)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func degOf(slope float64) float64 { return math.Atan(slope) * 180 / math.Pi }
|
||||
|
||||
func maxSlope(a []float32, w, h int, cellM float64) float64 {
|
||||
worst := 0.0
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
for k := 0; k < 8; k++ {
|
||||
nx, ny := x+dx8[k], y+dy8[k]
|
||||
if nx < 0 || ny < 0 || nx >= w || ny >= h {
|
||||
continue
|
||||
}
|
||||
d := cellM
|
||||
if dx8[k] != 0 && dy8[k] != 0 {
|
||||
d = cellM * math.Sqrt2
|
||||
}
|
||||
if s := float64(a[i]-a[ny*w+nx]) / d; s > worst {
|
||||
worst = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
// Package uplift builds what the fluvial solve integrates: a rock uplift rate field in metres per year, an
|
||||
// erodibility field, a continent mask that says where the sea is, and a small initial relief to break the
|
||||
// symmetry.
|
||||
//
|
||||
// This is heightmap_noise.generate_metres turned inside out (D-47). The numpy version produced the terrain:
|
||||
// ranges to 2600 m, foothills, plains, and erosion was applied to it afterwards as decoration. Here the same
|
||||
// shapes produce an uplift *rate*, and the terrain is whatever the stream-power solve makes of it. The
|
||||
// percentile thresholding is kept exactly, because it is what makes the result seed-independent.
|
||||
//
|
||||
// The coast is kept (D-48). Sea level is the base level on every ocean cell, which is a far better-posed
|
||||
// boundary for the solve than a single outlet edge and removes the artificial divide a one-outlet map has
|
||||
// along three of its sides. This package decides *where* the coastline runs and nothing else about it; what
|
||||
// the shoreline and the sea floor then look like belongs to package coast, which runs after the solve.
|
||||
//
|
||||
// # Why the plains need their own uplift, and not a flat one
|
||||
//
|
||||
// The first version gave the whole intraplate interior one uniform rate, 0.2 mm/yr against a convergent
|
||||
// 5 mm/yr. That produced a table-flat green void with a polygonal river network scribbled across it, and the
|
||||
// network was an artifact rather than drainage. Two reasons, and they are the same reason twice:
|
||||
//
|
||||
// - Steady state is S = U/(K*A^m). On a plain A is large and U was tiny, so S collapsed to nothing.
|
||||
// - Uniform uplift over a wide area produces no *divides*. With no divides there is no drainage to find,
|
||||
// so the router fell back on the only gradient present, which was the priority-flood's millimetre of
|
||||
// epsilon, and drew the flood's own traversal geometry as rivers.
|
||||
//
|
||||
// Real intraplate regions are not uniform. They warp gently over tens of kilometres into swells and sags, and
|
||||
// that warping is what puts divides on a plain. So the intraplate rate is modulated by a long-wavelength
|
||||
// field.
|
||||
//
|
||||
// # And why the rate itself must stay low
|
||||
//
|
||||
// The first attempt at the above did the right thing and then overdid it: the plains were lifted to
|
||||
// 0.25-0.9 mm/yr, on the reasoning that a higher rate sustains more relief against K. It does, but relief is
|
||||
// not the quantity that was in trouble. Steady state is S = U/(K*A^m), and with no critical area that holds
|
||||
// down to a single cell, so every divide stands at A = cell² whatever else is true of it. At the defaults
|
||||
// that made 0.25 mm/yr a 32 degree hillslope and 0.9 mm/yr one past the angle of repose — so the repose clamp,
|
||||
// which is meant to be a mountain process, became the surface of the whole continent. Measured: 81 % of the
|
||||
// land fell in the >0.5 mm/yr class and the plains held 1 %.
|
||||
//
|
||||
// The lesson is that U and S are not two knobs. For n = 1, U alone fixes the hillslope angle at a given A,
|
||||
// and the only things that make a plain flat are a low U or a large A. So the intraplate rate is an order of
|
||||
// magnitude lower than it was and the *variation* carries the divides, which is what it was for. The
|
||||
// mountain-to-plain ratio is 30-fold and up, which is what real ones are.
|
||||
//
|
||||
// # Where the land ends does not decide how fast it is rising
|
||||
//
|
||||
// The uplift rate used to be multiplied by the continent mask, which is a smoothstep, so it tapered to zero
|
||||
// across the shore. That made every coastline on the map the lowest-uplift ground on the map, by construction
|
||||
// and whatever the tectonics said — and since steady state is S = U/(K*A^m), ground with no uplift grades to
|
||||
// no slope, so every coast was a plain. It is why the coastal pass measured a mean sea cliff of two metres
|
||||
// while working perfectly: there was nothing anywhere on the map for the surf to cut into.
|
||||
//
|
||||
// The two questions are not the same question. The mask answers "is this cell sea", which is a yes or a no and
|
||||
// is what the solve needs for its base level. The uplift field answers "how fast is this rock rising", and a
|
||||
// range that happens to run out to the water is rising at range rates right up to the waterline — which is
|
||||
// what Big Sur, the Norwegian west coast and the Great Australian Bight all are. So the mask is thresholded
|
||||
// rather than multiplied, and whether a given coast is a cliff or a plain is now decided by where the range
|
||||
// band falls relative to the coastline, which is exactly the sort of thing that should be decided by the
|
||||
// tectonics and not by a smoothstep.
|
||||
package uplift
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/noise"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Rate *field.Field // rock uplift, metres per year
|
||||
Height *field.Field // initial relief, metres; ocean cells sit at sea level and stay there for the solve
|
||||
Land *field.Field // continent mask, 0 at sea, 1 inland
|
||||
K *field.Field // erodibility multiplier from the lithology pass, around 1
|
||||
Base []bool // cells fixed at base level: the ocean
|
||||
|
||||
Faults []Fault
|
||||
}
|
||||
|
||||
// Fault is a recorded trace, kept for meta.json and for whatever later wants to draw one.
|
||||
type Fault struct {
|
||||
Points [][2]float64 `json:"points"` // map coordinates, 0..1
|
||||
ThrowM float64 `json:"throw_m"`
|
||||
Major bool `json:"major"`
|
||||
Reverse bool `json:"reverse"` // which side goes up
|
||||
}
|
||||
|
||||
// seaThreshold is where the continent mask stops being land. The mask is a smoothstep, so it has a ramp, and
|
||||
// this is the one place that ramp is turned into the yes-or-no answer the solve needs: a cell is either an
|
||||
// ocean cell held at base level or it is not.
|
||||
//
|
||||
// It is a named constant rather than a literal in two loops because the rate field and the height field have
|
||||
// to agree about it exactly. If they ever disagreed, a cell would be uplifted and then pinned at base level,
|
||||
// or held at sea level while taking no uplift, and neither would be visible in anything a run prints.
|
||||
const seaThreshold = 0.02
|
||||
|
||||
// Pass indices for the seeded sources. Fixed and never reordered: a pass keeps its own stream so that
|
||||
// inserting a later pass does not reshuffle the ones before it (cross-cutting rule 12).
|
||||
const (
|
||||
srcContinent = 1
|
||||
srcWarp = 2
|
||||
srcBand = 3
|
||||
srcRidges = 4
|
||||
srcCrests = 5
|
||||
srcRelief = 6
|
||||
srcSwell = 7
|
||||
srcFaults = 8
|
||||
srcLithology = 9
|
||||
)
|
||||
|
||||
// Build produces the geology-grid inputs at size x size.
|
||||
func Build(size int, cellM float64, m *manifest.Manifest) *Result {
|
||||
seed := m.Source.Seed
|
||||
cfg := m.Pipeline
|
||||
sideM := float64(size-1) * cellM
|
||||
u, v := noise.Identity(size)
|
||||
|
||||
// A low-frequency warp bends everything that follows, so ridges curve and ranges are not blobs.
|
||||
ws := noise.NewSource(seed, srcWarp)
|
||||
wx := noise.FBM(size, ws, noise.Params{BaseCells: 3, Octaves: 3, Gain: 0.5})
|
||||
wy := noise.FBM(size, ws, noise.Params{BaseCells: 3, Octaves: 3, Gain: 0.5})
|
||||
|
||||
land := continentMask(size, u, v, wx, wy, seed, cfg.Continent)
|
||||
|
||||
// Ranges: an elongated, warped band says where they run, stretched across its grain so they come as long
|
||||
// chains rather than patches. Thresholded by percentile, not by value, which is the whole trick.
|
||||
bs := noise.NewSource(seed, srcBand)
|
||||
angle := bs.Range(0, math.Pi)
|
||||
cos, sin := math.Cos(angle), math.Sin(angle)
|
||||
bu := field.NewLike(u)
|
||||
bv := field.NewLike(v)
|
||||
for i := range bu.Data {
|
||||
x := float64(u.Data[i]) - 0.5
|
||||
y := float64(v.Data[i]) - 0.5
|
||||
along := x*cos + y*sin
|
||||
across := -x*sin + y*cos
|
||||
bu.Data[i] = float32(0.5 + along*0.7 + float64(wx.Data[i]-0.5)*0.32)
|
||||
bv.Data[i] = float32(0.5 + across*2.2 + float64(wy.Data[i]-0.5)*0.32)
|
||||
}
|
||||
band := noise.FBMAt(bu, bv, bs, noise.Params{BaseCells: 3, Octaves: 3, Gain: 0.5})
|
||||
rangeMask := percentileMask(band, cfg.Plates.LowUpliftFraction.Hi()*100, 86)
|
||||
|
||||
// The regional swell: long-wavelength warping of the intraplate interior, which is what puts divides on
|
||||
// a plain. Without it the lowlands have no drainage of their own and the flood's epsilon decides where
|
||||
// the water goes.
|
||||
ss := noise.NewSource(seed, srcSwell)
|
||||
swu, swv := noise.Warp(u, v, wx, wy, 0.10)
|
||||
swell := noise.FBMAt(swu, swv, ss, noise.Params{BaseCells: 2, Octaves: 4, Gain: 0.5})
|
||||
swell.Normalise()
|
||||
|
||||
// Rock uplift in metres per year.
|
||||
intraLo := cfg.Plates.IntraplateMmYr / 1000
|
||||
intraHi := cfg.Plates.IntraplateSwellMmYr / 1000
|
||||
if intraHi < intraLo {
|
||||
intraHi = intraLo
|
||||
}
|
||||
convergent := cfg.Plates.ConvergentMmYr.Hi() / 1000
|
||||
rate := field.New(size, size, cellM)
|
||||
for i := range rate.Data {
|
||||
if land.Data[i] <= seaThreshold {
|
||||
// Ocean. The rate is zeroed for the sake of map_uplift and the statistics; the solve does not
|
||||
// need it, because a base cell is fixed and StreamPower skips it before it reads the rate.
|
||||
rate.Data[i] = 0
|
||||
continue
|
||||
}
|
||||
base := intraLo + (intraHi-intraLo)*float64(swell.Data[i])
|
||||
rate.Data[i] = float32(base + (convergent-base)*float64(rangeMask.Data[i]))
|
||||
}
|
||||
|
||||
faults := buildFaults(rate, u, v, seed, angle, sideM, cfg.Faults, cfg.Plates.ConvergentMmYr.Hi()/1000,
|
||||
float64(cfg.Fluvial.Steps)*cfg.Fluvial.DtYr)
|
||||
|
||||
// Lithology: a plan-view erodibility field. It is what stops every ridge in a range looking like every
|
||||
// other ridge, because a hard band resists and a soft one is cut away.
|
||||
k := lithology(size, cellM, u, v, wx, wy, seed, cfg.Lithology)
|
||||
|
||||
// Initial relief: small on purpose. The spec says 50-150 m x normalised uplift and it means it; the
|
||||
// solve is what produces relief, and handing it 2600 m of ridged noise means it spends its whole run
|
||||
// tearing that down instead of carving.
|
||||
rs := noise.NewSource(seed, srcRidges)
|
||||
wu, wv := noise.Warp(u, v, wx, wy, 0.16)
|
||||
ridges := noise.FBMAt(wu, wv, rs, noise.Params{BaseCells: 5, Octaves: 6, Gain: 0.42, Ridged: true})
|
||||
ridges.Normalise()
|
||||
cs := noise.NewSource(seed, srcCrests)
|
||||
cu, cv := noise.Warp(u, v, wx, wy, 0.224) // the stronger warp the crest lines need
|
||||
crests := noise.CellularEdges(cu, cv, cs, 14, 0.95)
|
||||
|
||||
ps := noise.NewSource(seed, srcRelief)
|
||||
plains := noise.FBM(size, ps, noise.Params{BaseCells: 6, Octaves: 4, Gain: 0.45})
|
||||
|
||||
ampLo := cfg.Relief.AmplitudeM.Lo()
|
||||
ampHi := cfg.Relief.AmplitudeM.Hi()
|
||||
crestW := cfg.Relief.CrestWeight
|
||||
maxRate := convergent
|
||||
if maxRate <= 0 {
|
||||
maxRate = 1
|
||||
}
|
||||
height := field.New(size, size, cellM)
|
||||
base := make([]bool, size*size)
|
||||
for i := range height.Data {
|
||||
if land.Data[i] <= seaThreshold {
|
||||
// Ocean: base level, fixed, never eroded, never uplifted, and held at sea level for the whole
|
||||
// solve. The sea floor is not laid here and deliberately not laid *yet* — a coastal cell drains
|
||||
// into an ocean cell, and if that cell already sat at -180 m the solver would cut the river down
|
||||
// to -180 m, because that is the base level it was handed. The first run with a coast eroded the
|
||||
// land to 174 m below sea level for exactly that reason. Package coast lays the sea floor after
|
||||
// the solve, where it also has the relief it needs to decide how wide the shelf is.
|
||||
height.Data[i] = float32(m.SeaLevelM)
|
||||
base[i] = true
|
||||
continue
|
||||
}
|
||||
norm := float64(rate.Data[i]) / maxRate // normalised uplift, 0..1
|
||||
amp := ampLo + (ampHi-ampLo)*norm
|
||||
shape := (1-crestW)*float64(ridges.Data[i]) + crestW*float64(crests.Data[i])
|
||||
height.Data[i] = float32(m.SeaLevelM + 20 + amp*shape + float64(plains.Data[i])*8)
|
||||
}
|
||||
|
||||
return &Result{Rate: rate, Height: height, Land: land, K: k, Base: base, Faults: faults}
|
||||
}
|
||||
|
||||
// percentileMask thresholds a field between two percentiles and smoothsteps between them, so the fraction of
|
||||
// the map it covers is the same whatever the seed.
|
||||
func percentileMask(f *field.Field, loPct, hiPct float64) *field.Field {
|
||||
lo := f.Percentile(loPct)
|
||||
hi := f.Percentile(hiPct)
|
||||
span := float64(hi - lo)
|
||||
if span < 1e-6 {
|
||||
span = 1e-6
|
||||
}
|
||||
out := field.NewLike(f)
|
||||
for i, b := range f.Data {
|
||||
t := (float64(b) - float64(lo)) / span
|
||||
if t < 0 {
|
||||
t = 0
|
||||
} else if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
out.Data[i] = float32(noise.Smoothstep(t))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// continentMask is the coast. A radial falloff with noise added to the radius gives a disc with a wobbly
|
||||
// edge, which is what the first version did and what it looked like. Instead a warped multi-octave field is
|
||||
// biased radially and then thresholded at the percentile that yields the wanted land fraction: the coastline
|
||||
// gets bays, peninsulas and offshore deeps, and the land area is still the same whatever the seed.
|
||||
func continentMask(size int, u, v, wx, wy *field.Field, seed int64, cfg manifest.Continent) *field.Field {
|
||||
out := field.New(size, size, 1)
|
||||
if !cfg.Enabled {
|
||||
out.Fill(1)
|
||||
return out
|
||||
}
|
||||
s := noise.NewSource(seed, srcContinent)
|
||||
cx := 0.5 + (s.Float()-0.5)*0.12
|
||||
cy := 0.5 + (s.Float()-0.5)*0.12
|
||||
|
||||
// Strong domain warp, so the shape is not obviously built from a circle.
|
||||
//
|
||||
// The octave count is the coastline's own detail and it is a manifest key because it is the one number
|
||||
// that decides whether the continent has a coast or an outline: five octaves over this map is a 450 m
|
||||
// finest feature, which is a smooth blob, and everything downstream that asks "is this stretch sheltered"
|
||||
// then answers "no" everywhere. Gain stays at 0.5 rather than the 0.42 the *relief* noise uses, because
|
||||
// this field is thresholded at a percentile rather than read as a height, so a steep spectrum costs
|
||||
// nothing here and is what makes the shoreline crenellate.
|
||||
cu, cv := noise.Warp(u, v, wx, wy, cfg.CoastWarp)
|
||||
octaves := cfg.OutlineOctaves
|
||||
if octaves < 1 {
|
||||
octaves = 5
|
||||
}
|
||||
gain := cfg.OutlineGain
|
||||
if gain <= 0 {
|
||||
gain = 0.5
|
||||
}
|
||||
shape := noise.FBMAt(cu, cv, s, noise.Params{BaseCells: 2, Octaves: octaves, Gain: gain})
|
||||
|
||||
// The radial term only biases the field towards the middle; it does not define the edge.
|
||||
score := field.New(size, size, 1)
|
||||
for i := range score.Data {
|
||||
x := float64(u.Data[i]) - cx
|
||||
y := float64(v.Data[i]) - cy
|
||||
radius := math.Hypot(x*1.05, y*0.95) / cfg.Radius
|
||||
score.Data[i] = float32(float64(shape.Data[i]) - cfg.RadialBias*radius*radius)
|
||||
}
|
||||
|
||||
// The threshold that yields the wanted land fraction, read off the distribution.
|
||||
seaPct := (1 - cfg.LandFraction) * 100
|
||||
lo := score.Percentile(seaPct)
|
||||
hi := score.Percentile(math.Min(99.9, seaPct+cfg.ShoreWidthPct))
|
||||
span := float64(hi - lo)
|
||||
if span < 1e-6 {
|
||||
span = 1e-6
|
||||
}
|
||||
for i, sc := range score.Data {
|
||||
t := (float64(sc) - float64(lo)) / span
|
||||
if t < 0 {
|
||||
t = 0
|
||||
} else if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
out.Data[i] = float32(noise.Smoothstep(t))
|
||||
}
|
||||
|
||||
// The last few percent of the map is forced to sea, so land never touches the edge.
|
||||
//
|
||||
// This is not cosmetic. A border cell is an outlet: it takes no uplift, is never eroded, and the repose
|
||||
// clamp will not lower it either, so any land that reaches the edge is frozen at whatever height the
|
||||
// initial relief gave it while the interior erodes away beneath it. The result is a rim of untouched
|
||||
// terrain standing over a hundred metres above its neighbour — which is exactly what the repose test
|
||||
// found when it reported a 66 degree slope on a map whose angle of repose was 22. Percentile
|
||||
// thresholding picks the lowest fraction of the *score* and has no reason to put it at the edges, so the
|
||||
// margin has to be imposed.
|
||||
const marginFrac = 0.04
|
||||
for i := range out.Data {
|
||||
x := float64(i%size) / float64(size-1)
|
||||
y := float64(i/size) / float64(size-1)
|
||||
d := math.Min(math.Min(x, 1-x), math.Min(y, 1-y))
|
||||
if d < marginFrac {
|
||||
out.Data[i] *= float32(noise.Smoothstep(math.Max(0, d/marginFrac)))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lithology is the spec's 4.3: low-frequency noise thresholded into a few rock types, each with its own
|
||||
// erodibility. Plan view, and orthogonal to the strata model that scales the particle pass by depth.
|
||||
func lithology(size int, cellM float64, u, v, wx, wy *field.Field, seed int64, cfg manifest.Lithology) *field.Field {
|
||||
out := field.New(size, size, cellM)
|
||||
if cfg.Types <= 1 || len(cfg.KMultipliers) == 0 {
|
||||
out.Fill(1)
|
||||
return out
|
||||
}
|
||||
s := noise.NewSource(seed, srcLithology)
|
||||
lu, lv := noise.Warp(u, v, wx, wy, 0.18)
|
||||
f := noise.FBMAt(lu, lv, s, noise.Params{BaseCells: 3, Octaves: 4, Gain: 0.5})
|
||||
|
||||
n := cfg.Types
|
||||
if n > len(cfg.KMultipliers) {
|
||||
n = len(cfg.KMultipliers)
|
||||
}
|
||||
// Equal-area bands, so every rock type actually appears whatever the seed.
|
||||
edges := make([]float32, n-1)
|
||||
for i := 1; i < n; i++ {
|
||||
edges[i-1] = f.Percentile(float64(i) / float64(n) * 100)
|
||||
}
|
||||
for i, val := range f.Data {
|
||||
t := 0
|
||||
for t < len(edges) && val > edges[t] {
|
||||
t++
|
||||
}
|
||||
out.Data[i] = float32(cfg.KMultipliers[t])
|
||||
}
|
||||
// Softened, so a boundary is a transition rather than a wall the solver carves into a cliff.
|
||||
return out.Blur(2)
|
||||
}
|
||||
|
||||
// buildFaults perturbs the uplift field across a set of traces. Normal faults are applied as an uplift-rate
|
||||
// *difference* across the trace, steep on one side and gentle on the other, and erosion then carves the
|
||||
// escarpment; that is the spec's 4.2 and it is why a fault reads as landscape rather than as a drawn line.
|
||||
//
|
||||
// Orientation follows the range grain rather than being random, because a fault set that ignores the
|
||||
// structure it belongs to looks like scratches.
|
||||
func buildFaults(rate, u, v *field.Field, seed int64, grainAngle, sideM float64,
|
||||
cfg manifest.Faults, convergent, runYears float64) []Fault {
|
||||
s := noise.NewSource(seed, srcFaults)
|
||||
if runYears <= 0 {
|
||||
runYears = 1.5e6
|
||||
}
|
||||
|
||||
nMajor := int(cfg.Major.Pick(s.Float()) + 0.5)
|
||||
nMinor := int(cfg.Minor.Pick(s.Float()) + 0.5)
|
||||
faults := make([]Fault, 0, nMajor+nMinor)
|
||||
|
||||
for i := 0; i < nMajor+nMinor; i++ {
|
||||
major := i < nMajor
|
||||
lengthM := cfg.LengthKm.Pick(s.Float()) * 1000
|
||||
if major {
|
||||
lengthM = math.Max(lengthM, cfg.LengthKm.Hi()*1000*0.6)
|
||||
}
|
||||
throw := cfg.ThrowMinorM.Pick(s.Float())
|
||||
if major {
|
||||
throw = cfg.ThrowMajorM.Pick(s.Float())
|
||||
}
|
||||
// Parallel to the grain, with a little scatter: never a random orientation.
|
||||
a := grainAngle + (s.Float()-0.5)*0.6
|
||||
cx, cy := s.Float(), s.Float()
|
||||
half := lengthM / sideM / 2
|
||||
|
||||
// A polyline, gently curved by low-frequency wander rather than a straight segment.
|
||||
const segs = 8
|
||||
pts := make([][2]float64, segs+1)
|
||||
wander := (s.Float() - 0.5) * 0.5
|
||||
for j := 0; j <= segs; j++ {
|
||||
t := float64(j)/segs*2 - 1 // -1..1
|
||||
off := wander * (1 - t*t) // zero at the tips, largest in the middle
|
||||
px := cx + math.Cos(a)*half*t - math.Sin(a)*half*off
|
||||
py := cy + math.Sin(a)*half*t + math.Cos(a)*half*off
|
||||
pts[j] = [2]float64{px, py}
|
||||
}
|
||||
faults = append(faults, Fault{Points: pts, ThrowM: throw, Major: major, Reverse: s.Float() < 0.5})
|
||||
}
|
||||
|
||||
// Throw is a total displacement over the run, so it becomes a rate the solve can integrate.
|
||||
steepM := 200.0
|
||||
gentleM := 2000.0
|
||||
field.Rows(rate.H, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < rate.W; x++ {
|
||||
i := y*rate.W + x
|
||||
px := float64(u.Data[i])
|
||||
py := float64(v.Data[i])
|
||||
var delta float64
|
||||
for _, f := range faults {
|
||||
d, inside := signedDistance(px, py, f.Points)
|
||||
if !inside {
|
||||
continue
|
||||
}
|
||||
dm := d * sideM
|
||||
sign := 1.0
|
||||
if f.Reverse {
|
||||
sign = -1
|
||||
}
|
||||
// Steep side falls off fast, gentle side slowly: an asymmetric block, not a ridge.
|
||||
var w float64
|
||||
if dm*sign >= 0 {
|
||||
w = math.Exp(-math.Abs(dm) / steepM)
|
||||
} else {
|
||||
w = -math.Exp(-math.Abs(dm) / gentleM)
|
||||
}
|
||||
delta += f.ThrowM / runYears * w
|
||||
}
|
||||
if delta != 0 {
|
||||
r := float64(rate.Data[i]) + delta
|
||||
if r < 0 {
|
||||
r = 0
|
||||
}
|
||||
if r > convergent*1.6 {
|
||||
r = convergent * 1.6
|
||||
}
|
||||
rate.Data[i] = float32(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return faults
|
||||
}
|
||||
|
||||
// signedDistance is the perpendicular distance from a point to a polyline, signed by which side it falls on,
|
||||
// in map units. inside is false beyond the ends, where a fault has no effect.
|
||||
func signedDistance(px, py float64, pts [][2]float64) (float64, bool) {
|
||||
best := math.Inf(1)
|
||||
sign := 1.0
|
||||
found := false
|
||||
for j := 0; j+1 < len(pts); j++ {
|
||||
ax, ay := pts[j][0], pts[j][1]
|
||||
bx, by := pts[j+1][0], pts[j+1][1]
|
||||
dx, dy := bx-ax, by-ay
|
||||
l2 := dx*dx + dy*dy
|
||||
if l2 < 1e-12 {
|
||||
continue
|
||||
}
|
||||
t := ((px-ax)*dx + (py-ay)*dy) / l2
|
||||
if t < 0 || t > 1 {
|
||||
continue // beyond this segment; a neighbouring one may still claim the point
|
||||
}
|
||||
found = true
|
||||
projx, projy := ax+t*dx, ay+t*dy
|
||||
d := math.Hypot(px-projx, py-projy)
|
||||
if d < best {
|
||||
best = d
|
||||
// Cross product decides the side.
|
||||
if (px-ax)*dy-(py-ay)*dx < 0 {
|
||||
sign = -1
|
||||
} else {
|
||||
sign = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return 0, false
|
||||
}
|
||||
return best * sign, true
|
||||
}
|
||||
Reference in New Issue
Block a user