Files
2026-09-25 17:02:24 +03:00

540 lines
21 KiB
Go

package planet
import (
"fmt"
"sort"
"sync"
"time"
"salty/terrain/internal/coast"
"salty/terrain/internal/field"
"salty/terrain/internal/fluvial"
"salty/terrain/internal/manifest"
"salty/terrain/internal/region"
"salty/terrain/internal/stats"
"salty/terrain/internal/template"
"salty/terrain/internal/thermal"
"salty/terrain/internal/uplift"
)
// Bake solves the geology of every region and composites the result into one planet.
//
// The order is the whole design: each landmass is solved in a box of its own with water all round it, which
// is exactly the same answer as solving the planet whole because no flow path crosses open water; then the
// land is written back; then the sea floor is laid once, over the finished cylinder.
// Result is a baked planet at geology resolution.
type Result struct {
In *Inputs
Height *field.Field // metres, over the whole planet including the polar pad
Flow []float32 // drainage area in m2, for the rivers on the preview
Sea []bool // after the bake: below sea level
Regions []RegionResult
Craters []CraterStats
Elapsed time.Duration
// Coast is the shelf, the surf and the sediment budget, run once over the whole cylinder after every
// region is composited. Nil only when there is no planet to run it on.
Coast *coast.Result
// Stats is the whole planet's, pooled from the regions. Nil until Write computes it.
//
// Pooled rather than averaged, which is the one thing that makes it a planet statistic at all: a region
// holds a histogram and histograms add, so merging them and taking a quantile of the sum gives exactly
// what one pass over the whole world would have. See internal/stats.
Stats *stats.Report
cancelled bool
}
// RegionResult is what one region's solve cost and produced.
type RegionResult struct {
ID int
Cells int
LandCells int
Seconds float64
MinM float64
MaxM float64
ClipFrac float64
// FaultClamped is how many cells the fault pass pushed past the angle of repose and had to bound. Not an
// error - it is the set saying the throws are large for the class they sit in - but worth seeing in the
// summary rather than discovering later in a hillshade full of polygonal facets.
FaultClamped int
// stats is this region's land statistics, unexported because it is scaffolding: it exists to be merged
// into the planet's and is not part of what a region result means.
stats *stats.Accumulator
}
// statsOptions is what a world is judged against, in one place so a region and the planet it belongs to
// cannot disagree about it - two accumulators built on different bounds do not merge.
func statsOptions(m *manifest.Manifest) stats.Options {
return stats.Options{
ElevMin: m.ElevationM.Min, ElevMax: m.ElevationM.Max,
TalusDeg: m.Pipeline.Thermal.TalusDeg, ReliefWindowM: reliefWindowM,
ChannelM2: channelKm2 * 1e6,
K: m.Pipeline.Fluvial.K, M: m.Pipeline.Fluvial.M, N: m.Pipeline.Fluvial.N,
}
}
// The two constants the statistics are taken at. They are flags on `generate` and fixed here, because a bake
// is compared against other bakes and a window that moved between them would make the relief column
// meaningless: 500 m is the usual choice, and 1 km2 is the incoming spec's channel definition.
const (
reliefWindowM = 500.0
channelKm2 = 1.0
)
// BakeOptions steer a run without editing the manifest.
type BakeOptions struct {
Only []int // region ids; empty means all
Steps int // override the fluvial step count
Jobs int // regions solved at once; 0 is the default
Log func(string, ...any)
// OnRegion is called each time a region's land has been written back, with the planet as it stands.
//
// It runs holding the composite lock, so Height and Flow can be read without racing the workers still
// running - and every one of them is stopped while it does, so it has to be quick and it must not keep a
// reference to either past the call. It exists so a caller can *watch* a bake: two hours is a long time
// to find out at the end that the numbers were wrong, and the world filling in one landmass at a time
// answers that at the first one.
OnRegion func(*Result, RegionResult)
// Cancel abandons the run when it is closed. Regions in flight stop at the end of their current step and
// their land is composited in whatever state the solve had reached, so a cancelled Result is for looking
// at and never for writing out as a bake.
Cancel <-chan struct{}
}
// Cancelled reports whether a run was abandoned before it finished.
func (r *Result) Cancelled() bool { return r.cancelled }
// Bake runs the geology solve over a prepared planet.
func Bake(in *Inputs, opt BakeOptions) (*Result, error) {
log := opt.Log
if log == nil {
log = func(string, ...any) {}
}
m := in.M
p := in.P
n := p.W * p.H
started := time.Now()
params := solveParams(m)
if opt.Steps > 0 {
params.Steps = opt.Steps
}
log("fluvial %d steps of %.0f yr (%.2f Myr), K %.1e, m %.2f, n %.2f, fill every %d",
params.Steps, params.DtYr, float64(params.Steps)*params.DtYr/1e6,
params.K, params.M, params.N, params.FillEvery)
res := &Result{In: in, Height: field.New(p.W, p.H, p.CellM), Flow: make([]float32, n)}
for i := range res.Height.Data {
res.Height.Data[i] = float32(m.SeaLevelM)
}
rates := in.Legend.Rates()
ks := in.Legend.Erodibilities()
plainM, plainFloor := in.Legend.CoastalPlains()
massifFloor, massifFraction := in.Legend.Massifs()
wanted := wantedRegions(in.Part.Regions, opt.Only)
// Regions are solved a few at a time. They are independent - each writes only the land it owns, and no
// flow path crosses the water between them - so this changes nothing about the result; what it buys is
// the parts of the solve that are sequential *within* a region. Terrain.md's profile says most of the
// runtime is the stack walk and the flood's cursor, neither of which parallelises inside one grid, so
// overlapping regions is where the cores actually go.
//
// Results land in indexed slots and are read back in region order afterwards, never drained from a
// channel: cross-cutting rule 12 means the output must not depend on which goroutine finished first.
tables := paint{rates: rates, ks: ks, plainM: plainM, plainFloor: plainFloor,
massifFloor: massifFloor, massifFraction: massifFraction, massifCells: m.Planet.MassifCells(),
rockCells: m.Planet.LithologyCells(), rockMult: m.Pipeline.Lithology.KMultipliers,
lithMix: in.Legend.LithologyMixes(), faults: in.Faults,
// The *manifest's* step count, not the overridden one. `throw_m` is a total displacement over the
// run and it becomes a rate by dividing by the run's length, so taking the override would make a
// short run raise the *rate* to build the same scarp in less time - which at `--steps 200` is five
// times the uplift and past the repose ceiling, so a tuning run would show every fault pinned
// against the clamp and tell an author nothing about the world they are tuning. `--steps` means
// "run less time"; everything else in the solve is under-done by it and faults should be too.
runYears: float64(m.Pipeline.Fluvial.Steps) * params.DtYr,
clampCeilM: faultCeilingMYr(m),
}
jobs := opt.Jobs
if jobs <= 0 {
jobs = 3
}
if jobs > len(wanted) {
jobs = len(wanted)
}
// Biggest first, so the long poles start early and the short ones fill the tail.
order := append([]region.Region(nil), wanted...)
sort.Slice(order, func(a, b int) bool { return order[a].Cells() > order[b].Cells() })
out := make([]RegionResult, len(order))
var wg sync.WaitGroup
var mu sync.Mutex
// Guards the composite - the one place workers touch shared state - so that OnRegion can read the whole
// planet without racing them.
var composite sync.Mutex
safeLog := func(format string, a ...any) {
mu.Lock()
log(format, a...)
mu.Unlock()
}
next := make(chan int)
go func() {
for i := range order {
next <- i
}
close(next)
}()
for w := 0; w < jobs; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range next {
if cancelled(opt.Cancel) {
continue // drain the queue; the workers already running stop at their next step
}
out[i] = solveRegion(in, order[i], params, tables, res, safeLog, opt, &composite)
}
}()
}
wg.Wait()
res.Regions = out
sort.Slice(res.Regions, func(a, b int) bool { return res.Regions[a].ID < res.Regions[b].ID })
if cancelled(opt.Cancel) {
res.cancelled = true
res.Elapsed = time.Since(started)
log("cancelled after %s", res.Elapsed.Round(time.Second))
return res, nil
}
res.Craters = stampCraters(in, res, log)
runCoast(in, res, log)
res.Elapsed = time.Since(started)
return res, nil
}
func wantedRegions(all []region.Region, only []int) []region.Region {
if len(only) == 0 {
return all
}
keep := make(map[int]bool, len(only))
for _, id := range only {
keep[id] = true
}
var out []region.Region
for _, r := range all {
if keep[r.ID] {
out = append(out, r)
}
}
return out
}
// solveRegion is one landmass: cut it out, build its painted geology, run the solve, write the land back.
// progressCells is where a region becomes big enough to be worth reporting on: below a couple of million it
// is over in a minute or two and the lines are noise.
const progressCells = 2_000_000
// paint is the per-class tables a region's geology is built from, bundled so the worker signature does not
// grow a column every time the legend learns a new word.
type paint struct {
rates, ks []float32
plainM []float64
plainFloor []float32
massifFloor []float32
// massifFraction is per class, zero for a class that is one rate all over. massifCells is the planet's
// upland fabric wavelength in lattice cells, which is a property of the planet rather than of a class.
massifFraction []float64
massifCells int
// The rock field and the fault set: both are properties of the planet that every region reads the same
// way, which is the whole point of computing them once above rather than per region.
rockCells int
rockMult []float64
lithMix []float64
faults []uplift.FaultTrace
runYears float64
clampCeilM float64
}
func cancelled(ch <-chan struct{}) bool {
if ch == nil {
return false
}
select {
case <-ch:
return true
default:
return false
}
}
func solveRegion(in *Inputs, rg region.Region, params fluvial.Params, t paint,
res *Result, log func(string, ...any), opt BakeOptions, composite *sync.Mutex) RegionResult {
m := in.M
start := time.Now()
class, land := in.Part.Cut(in.Map, rg)
up := uplift.FromTemplate(uplift.Paint{
Frame: rg.Frame, Class: class, Land: land,
Rates: t.rates, Ks: t.ks, PlainM: t.plainM, PlainFloor: t.plainFloor,
MassifFloor: t.massifFloor, MassifFraction: t.massifFraction, MassifCells: t.massifCells,
RockCells: t.rockCells, RockMult: t.rockMult, LithMix: t.lithMix,
Faults: t.faults, RunYears: t.runYears, ClampCeilM: t.clampCeilM,
Variation: m.Planet.UpliftVariation,
}, m)
h := up.Height.Clone()
grid := fluvial.NewGrid(rg.Frame.W, rg.Frame.H, rg.Frame.P.CellM, up.Base)
grid.SetSeed(m.Source.Seed)
grid.SetCancel(opt.Cancel)
grid.SetFrame(rg.Frame) // the jitter is a hash of world position, not of grid index
grid.SetElevationRange(m.ElevationM.Min-200, m.ElevationM.Max+500)
log("region %2d start %.1f x %.1f km, %d cells (%d land)%s",
rg.ID, float64(rg.Frame.W)*rg.Frame.P.CellM/1000, float64(rg.Frame.H)*rg.Frame.P.CellM/1000,
rg.Cells(), rg.LandCells, seamNote(rg.Seam))
// Progress, because a big region is an hour on its own and silence for an hour is indistinguishable from
// a hang. Every tenth, and only for regions big enough to be worth waiting on: with several in flight the
// lines interleave, so each one carries its region id and none of them is per-step.
var progress func(step, total int, pct float64)
if rg.Cells() > progressCells {
next := 10.0
progress = func(step, total int, pct float64) {
if pct < next || step == 0 {
return
}
next = pct + 10
lo, hi := landExtent(h.Data, land)
elapsed := time.Since(start)
eta := time.Duration(float64(elapsed) / (pct / 100) * (1 - pct/100))
log("region %2d %3.0f%% step %d/%d, land %.0f..%.0f m, eta %s",
rg.ID, pct, step, total, lo, hi, eta.Round(time.Second))
}
}
grid.Run(h.Data, up.Rate.Data, up.K.Data, params, progress)
// The edge-preserving pass, if the manifest asked for one. Here and not after the write: the region's
// statistics are gathered a few lines below while the grid is alive, and a smooth applied after them
// would put a surface on disk that meta.json does not describe - which is the bake-against-tiles drift
// trap in a new costume.
//
// It reads a cell's eight neighbours, and that is safe against the decomposition for a reason particular
// to this pass: a region's frame edges are open ocean by construction and every landmass in it sits at
// least margin_cells from them, so a land cell's neighbours are always inside its own region. The rule
// this would otherwise break - no neighbourhood operation near a region edge - still stands for anything
// that reaches across the waterline.
if sm := m.Pipeline.Smooth; sm.Passes > 0 {
field.SmoothEdgePreserving(h.Data, h.W, h.H, h.CellM, land, sm.Passes, sm.SlopeRef, grid.Scratch())
}
lo, hi := landExtent(h.Data, land)
peakRate := 0.0
for i, isLand := range land {
if isLand && float64(up.Rate.Data[i]) > peakRate {
peakRate = float64(up.Rate.Data[i])
}
}
clip := clipFraction(h.Data, land, m.ElevationM.Min, m.ElevationM.Max)
secs := time.Since(start).Seconds()
clampNote := ""
if up.FaultClamped > 0 {
clampNote = fmt.Sprintf(", %.2f%% of it bounded at repose by faults",
100*float64(up.FaultClamped)/float64(rg.LandCells))
}
log("region %2d done %.0f..%.0f m, %.3f%% clipped, %.2f mm/yr peak%s [%.0f s]",
rg.ID, lo, hi, clip*100, peakRate*1000, clampNote, secs)
// The region's own land statistics, gathered here while its grid is still alive - it is thrown away a
// few lines below, and the composited planet has no uplift field or flow topology to recover them from.
// Only the land this region *owns*: `Cut` marks nothing else, so the pieces are disjoint and the sum is
// the planet.
acc := stats.New(statsOptions(m))
acc.Add(stats.Input{
H: h, Land: land, UpliftMYr: up.Rate.Data, KLocal: up.K.Data,
Area: grid.Area, Receiver: grid.Receiver, Length: grid.Length,
})
rr := RegionResult{ID: rg.ID, Cells: rg.Cells(), LandCells: rg.LandCells,
Seconds: secs, MinM: lo, MaxM: hi, ClipFrac: clip, FaultClamped: up.FaultClamped, stats: acc}
// The write-back and the hook under one lock. Composite is the only place a worker touches shared state,
// so holding it here is what lets OnRegion read the whole planet without racing the others.
composite.Lock()
in.Part.Composite(res.Height.Data, in.Map, rg, h.Data)
compositeFlow(in.Part, in.Map, rg, res.Flow, grid.Area)
if opt.OnRegion != nil {
opt.OnRegion(res, rr)
}
composite.Unlock()
return rr
}
func seamNote(seam bool) string {
if seam {
return ", across the seam"
}
return ""
}
// compositeFlow writes a region's drainage area back for the preview's rivers. It follows Composite's rule:
// only the land this region owns.
func compositeFlow(part *region.Partition, mp *template.Map, rg region.Region, dst, area []float32) {
for y := 0; y < rg.Frame.H; y++ {
for x := 0; x < rg.Frame.W; x++ {
pi := rg.Frame.PlanetIdx(x, y)
if part.Owner[pi] != int32(rg.ID) || mp.Sea[pi] {
continue
}
dst[pi] = area[y*rg.Frame.W+x]
}
}
}
func solveParams(m *manifest.Manifest) fluvial.Params {
f := m.Pipeline.Fluvial
return fluvial.Params{
K: f.K, M: f.M, N: f.N, DtYr: f.DtYr, Steps: f.Steps,
Diffusion: f.DiffusionM2Yr, FillEvery: f.FillEvery,
TalusSlope: thermal.TalusFromDegrees(m.Pipeline.Thermal.TalusDeg),
ThermalEvery: m.Pipeline.Thermal.Every,
ThermalPasses: m.Pipeline.Thermal.CoarsePasses,
CriticalAreaM2: f.CriticalAreaM2,
ChannelTaper: f.ChannelTaper,
CriticalSlope: thermal.TalusFromDegrees(f.CriticalSlopeDeg),
SlopeCap: f.SlopeCap,
MaxHillslopeSub: f.MaxHillslopeSub,
MFDExponent: f.MFDExponent,
}
}
// runCoast lays the sea floor and works the shoreline, once, over the whole cylinder.
//
// Once and whole rather than per region, which is D-53's rule and is not a convenience: the pass costs tens
// of nanoseconds a cell against tens of nanoseconds a cell *per step* for the solve, and cutting it up would
// truncate the fetch across every strait, split the sediment budget whose conservation is the one thing in it
// not derived from something already measured, and leave the shoreline length and the exposure percentiles as
// statistics that do not pool. It runs after every region is composited, because two of its three processes
// read the finished land: the shelf width comes off the relief standing behind each shore, and the surf cuts
// into whatever the solve built.
//
// The painted depths go in as the *abyss*, one value per cell. That is what makes the derived margin meet the
// painted ocean instead of stepping to it: an author who painted `shelf` at 120 m, `deep` at 512 and `surf`
// at 20 gets a continental slope that runs down to each of those where each of them is, and a strait painted
// shallower than the shelf break comes out as shelf all the way across rather than as a trench.
func runCoast(in *Inputs, res *Result, log func(string, ...any)) {
m := in.M
depths := in.Legend.Depths()
abyss := make([]float32, len(res.Height.Data))
for i := range abyss {
if in.Map.Sea[i] {
abyss[i] = depths[in.Map.Class[i]]
}
}
start := time.Now()
cs := coast.Build(coast.Input{
Height: res.Height, Sea: in.Map.Sea, SeaLevelM: m.SeaLevelM,
BreakM: m.ShelfBreakM(),
// AbyssM is the square canvas's key and is never read on this path: `Abyss` below is per-cell and
// always allocated, so `abyssAt` takes the painting every time. Left as the fallback it is rather
// than removed, and named here because it is the same shape as the read D-64 had to fix one line up.
AbyssM: -m.Pipeline.Continent.SeaFloorM.Lo(),
Abyss: abyss,
// The cylinder, which is the whole of what D-59's successor had to add: every march, every ray and
// every running sum in the pass crosses the seam now, and a planet measured on a flat grid would have
// had its shelf, its fetch and its sediment budget all stop dead at one meridian.
WrapX: true,
NoisePeriodM: in.P.NoisePeriodM,
Flow: res.Flow,
Seed: m.Source.Seed,
Cfg: m.Pipeline.Coast,
})
res.Coast = cs
res.Sea = cs.Sea
if !m.Pipeline.Coast.Enabled {
log("ocean the painted depths, flat: the coastal pass is switched off in the manifest")
return
}
// The margin's own numbers, said out loud, because they decide how much of the painted ocean survives
// and they were a square-canvas inheritance nobody could see until D-64. A shelf and a slope together
// reach `ShelfKm.Hi() + SlopeKm` from every shore; where the sea is narrower than twice that, the
// painting's depth is never reached anywhere in it and the author's ocean is whatever `break_m` says.
cfg := m.Pipeline.Coast
reach := cfg.ShelfKm.Hi() + cfg.SlopeKm
log(" sea floor: shelf %.1f..%.1f km to a break at %.0f m, then %.1f km of slope to the painted "+
"depth, so the painting owns the water past %.1f km offshore and nothing nearer",
cfg.ShelfKm.Lo(), cfg.ShelfKm.Hi(), m.ShelfBreakM(), cfg.SlopeKm, reach)
log("coast %s", cs.Stats.Summary())
log(" over the whole cylinder in %s, once: the pass is tens of nanoseconds a cell and cutting it",
time.Since(start).Round(time.Millisecond))
log(" up would truncate the fetch across every strait and split the sediment budget")
}
func landExtent(h []float32, land []bool) (lo, hi float64) {
lo, hi = 1e30, -1e30
any := false
for i, v := range h {
if !land[i] {
continue
}
any = true
if float64(v) < lo {
lo = float64(v)
}
if float64(v) > hi {
hi = float64(v)
}
}
if !any {
return 0, 0
}
return lo, hi
}
// clipFraction is how much of the land the 16-bit encoding would cut off. Above a fraction of a per cent it
// is a failed run rather than a rounded one, and painted uplift makes it easier to hit: an author can ask
// for more relief than the elevation range holds.
func clipFraction(h []float32, land []bool, minM, maxM float64) float64 {
n, clipped := 0, 0
for i, v := range h {
if !land[i] {
continue
}
n++
if float64(v) < minM || float64(v) > maxM {
clipped++
}
}
if n == 0 {
return 0
}
return float64(clipped) / float64(n)
}
// faultCeilingMYr is the uplift rate at which a divide stands at the angle of repose, in metres a year at
// K x1. It is the number `terrain plan` already prints as the clamp ceiling, in the unit the solve works in;
// here it bounds what a *fault* may add on top of what an author painted, and nothing else. Zero when the
// repose clamp is switched off, which switches the bound off with it.
func faultCeilingMYr(m *manifest.Manifest) float64 {
if m.Pipeline.Thermal.TalusDeg <= 0 || m.Pipeline.Thermal.TalusDeg >= 90 {
return 0
}
return clampCeiling(m.Pipeline.Fluvial.K, m.GeologyCellM(), m.Pipeline.Fluvial.M,
m.Pipeline.Thermal.TalusDeg) / 1000
}