Added: Initial world generation tool

This commit is contained in:
Rainer Leit
2026-09-17 17:55:48 +03:00
parent d64748f76f
commit cc43ed8dc8
2065 changed files with 23664 additions and 1011 deletions
+138
View File
@@ -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)
}
}
+812
View File
@@ -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
}
+351
View File
@@ -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])
}
}
}
+200
View File
@@ -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
}
+236
View File
@@ -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)
}
+201
View File
@@ -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()
}
+195
View File
@@ -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)
+259
View File
@@ -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
}
+182
View File
@@ -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)
}
+512
View File
@@ -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()
}
+187
View File
@@ -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
}
+36
View File
@@ -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 }
+74
View File
@@ -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)
}
}
+546
View File
@@ -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
}
+233
View File
@@ -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
}
+474
View File
@@ -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
}
+136
View File
@@ -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
}
+482
View File
@@ -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
}