This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+539
View File
@@ -0,0 +1,539 @@
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
}
+204
View File
@@ -0,0 +1,204 @@
package planet
import (
"math"
"salty/terrain/internal/dt"
"salty/terrain/internal/template"
)
// Impacts, stamped onto the finished terrain.
//
// A crater is not an uplift rate and it cannot be one, which is worth writing down because it is the obvious
// thing to try. A closed basin does not survive the fluvial solve: the priority-flood runs every step and
// *raises* every depression to its spill level, so a crater built out of negative uplift would be filled in
// before the run was a hundred steps old. It is also the wrong model. A crater is an event, not a rate - it
// postdates the landscape it sits in - and a pass running after the solve is what that means.
//
// The shape is derived from the painted blob rather than drawn. Distance inward from the blob's own boundary,
// normalised by its widest point, is a coordinate that runs 0 at the shore to 1 at the centre whatever size
// and shape the author painted, so one set of numbers describes every crater on the map.
// craterProfile is the height at a normalised distance t in from the shore.
//
// t = 0 the waterline: sea level, so the island keeps the outline that was painted
// t = RimAt the crest
// t = WallAt the foot of the inner wall
// t > WallAt floor
//
// Both segments are smoothstepped, so the crest is a ridge rather than a corner and the floor meets the wall
// without a crease. A corner at either would be ground the detail passes then spend their time sanding off.
func craterProfile(c template.Crater, seaLevelM, t float64) float64 {
switch {
case t <= 0:
return seaLevelM
case t < c.RimAt:
return seaLevelM + (c.RimM-seaLevelM)*smoothstep(t/c.RimAt)
case t < c.WallAt:
return c.RimM + (c.FloorM-c.RimM)*smoothstep((t-c.RimAt)/(c.WallAt-c.RimAt))
default:
return c.FloorM
}
}
func smoothstep(t float64) float64 {
if t <= 0 {
return 0
}
if t >= 1 {
return 1
}
return t * t * (3 - 2*t)
}
// CraterStats is what the pass stamped.
//
// The floor is measured over the cells that actually reached it rather than over the whole blob, and that is
// not fussiness: the profile starts at sea level on the shoreline, so the minimum over a blob is always zero
// and reporting it as the floor says nothing at all. What is worth knowing is whether the blob was wide
// enough for the profile to get there - a crater painted smaller than its own rim is a hill.
type CraterStats struct {
Class string
Blobs int
Cells int
FloorCells int // cells past wall_at, which are the ones at the floor
RadiusM float64 // the widest blob's inradius: what the profile is normalised by
RimM float64 // the highest point actually stamped
FloorM float64 // the lowest point among the floor cells
}
// stampCraters reshapes every blob of every crater class in the planet raster.
//
// It runs after the regions are composited and before the ocean is laid, so it sees finished land and writes
// only onto land the paint marked as crater.
func stampCraters(in *Inputs, res *Result, log func(string, ...any)) []CraterStats {
if !in.Legend.HasCraters() {
return nil
}
p := in.P
n := p.W * p.H
var out []CraterStats
for ci := range in.Legend.Classes {
c := in.Legend.Classes[ci]
if c.Crater == nil {
continue
}
mask := make([]bool, n)
outside := make([]bool, n)
count := 0
for i := 0; i < n; i++ {
if in.Map.Class[i] == uint8(ci) && !in.Map.Sea[i] {
mask[i] = true
count++
} else {
outside[i] = true
}
}
if count == 0 {
continue
}
// Distance inward from the blob's boundary: seed the transform with everything that is *not* this
// class, and every cell of it then knows how far it is from the nearest edge. Wrapped, because a
// crater on the seam is one crater.
d2 := dt.Distance2(outside, p.W, p.H, true)
dist := make([]float64, n)
for i := range d2 {
if mask[i] {
dist[i] = math.Sqrt(float64(d2[i]))
}
}
// Each blob is normalised by its own widest point, so a big crater and a small one get the same
// shape rather than the same depth. Components are found with the same wrap-aware flood the region
// partitioner uses; there are a handful of them and they are tiny.
comp, maxDist, blobs := craterComponents(mask, dist, p.W, p.H, p.WrapX)
st := CraterStats{Class: c.Name, Blobs: blobs, Cells: count, FloorM: math.Inf(1),
RimM: math.Inf(-1)}
for _, d := range maxDist {
if d*p.CellM > st.RadiusM {
st.RadiusM = d * p.CellM
}
}
for i := 0; i < n; i++ {
if !mask[i] || comp[i] < 0 {
continue
}
d := maxDist[comp[i]]
if d <= 0 {
continue
}
t := dist[i] / d
h := craterProfile(*c.Crater, in.M.SeaLevelM, t)
res.Height.Data[i] = float32(h)
if h > st.RimM {
st.RimM = h
}
if t >= c.Crater.WallAt {
st.FloorCells++
if h < st.FloorM {
st.FloorM = h
}
}
}
if st.FloorCells == 0 {
st.FloorM = 0
}
out = append(out, st)
log("crater %s: %d blob(s), %d cells, widest %.0f m across; rim reached %.0f m, "+
"%d cells at the floor (%.0f m)",
st.Class, st.Blobs, st.Cells, 2*st.RadiusM, st.RimM, st.FloorCells, st.FloorM)
if st.FloorCells == 0 {
log(" WARNING no cell reached the floor: every blob is narrower than wall_at asks for, " +
"so this is a hill rather than a crater. Paint it wider or lower wall_at.")
}
}
return out
}
// craterComponents labels each blob and records its widest point, which is the radius the profile is
// normalised by.
func craterComponents(mask []bool, dist []float64, w, h int, wrap func(int) int) (comp []int32, maxDist []float64, n int) {
comp = make([]int32, len(mask))
for i := range comp {
comp[i] = -1
}
var stack []int32
for start := 0; start < len(mask); start++ {
if !mask[start] || comp[start] >= 0 {
continue
}
id := int32(len(maxDist))
maxDist = append(maxDist, 0)
comp[start] = id
stack = append(stack[:0], int32(start))
for len(stack) > 0 {
c := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if dist[c] > maxDist[id] {
maxDist[id] = dist[c]
}
cx, cy := int(c)%w, int(c)/w
for dy := -1; dy <= 1; dy++ {
ny := cy + dy
if ny < 0 || ny >= h {
continue
}
base := ny * w
for dx := -1; dx <= 1; dx++ {
if dx == 0 && dy == 0 {
continue
}
ni := int32(base + wrap(cx+dx))
if mask[ni] && comp[ni] < 0 {
comp[ni] = id
stack = append(stack, ni)
}
}
}
}
}
return comp, maxDist, len(maxDist)
}
@@ -0,0 +1,94 @@
package planet
import (
"math"
"testing"
"salty/terrain/internal/template"
)
// The profile is the whole of what a crater is, so it is worth pinning down: shore at sea level, a crest, an
// inner wall, a floor, and nothing anywhere that is not monotonic on its own segment.
func TestCraterProfileIsRimThenFloor(t *testing.T) {
c := template.Crater{RimM: 340, FloorM: 60, RimAt: 0.30, WallAt: 0.62}
if got := craterProfile(c, 0, 0); got != 0 {
t.Errorf("at the shore = %.1f m, want sea level: the island keeps the outline that was painted", got)
}
if got := craterProfile(c, 0, c.RimAt); math.Abs(got-c.RimM) > 1e-9 {
t.Errorf("at rim_at = %.1f m, want the rim %.1f", got, c.RimM)
}
if got := craterProfile(c, 0, c.WallAt); math.Abs(got-c.FloorM) > 1e-9 {
t.Errorf("at wall_at = %.1f m, want the floor %.1f", got, c.FloorM)
}
if got := craterProfile(c, 0, 1); got != c.FloorM {
t.Errorf("at the centre = %.1f m, want the floor %.1f", got, c.FloorM)
}
// Rising to the crest, falling to the floor, and the floor is dry.
prev := craterProfile(c, 0, 0)
for i := 1; i <= 30; i++ {
v := craterProfile(c, 0, c.RimAt*float64(i)/30)
if v < prev-1e-9 {
t.Fatalf("the outer flank dips at t = %.3f", c.RimAt*float64(i)/30)
}
prev = v
}
prev = craterProfile(c, 0, c.RimAt)
for i := 1; i <= 30; i++ {
v := craterProfile(c, 0, c.RimAt+(c.WallAt-c.RimAt)*float64(i)/30)
if v > prev+1e-9 {
t.Fatalf("the inner wall rises at t = %.3f", c.RimAt+(c.WallAt-c.RimAt)*float64(i)/30)
}
prev = v
}
if c.FloorM <= 0 {
t.Error("this crater's floor is not above sea level, which is what was asked for")
}
}
// A blob is normalised by its own widest point, so the same numbers describe a small crater and a large one.
func TestCraterComponentsMeasureEachBlobsOwnRadius(t *testing.T) {
const w, h = 40, 12
mask := make([]bool, w*h)
dist := make([]float64, w*h)
set := func(x0, y0, wid, hei int, d float64) {
for y := y0; y < y0+hei; y++ {
for x := x0; x < x0+wid; x++ {
mask[y*w+x] = true
dist[y*w+x] = d
}
}
}
set(2, 2, 6, 6, 3) // a blob whose widest point is 3
set(20, 4, 4, 4, 1.5) // and a smaller one at 1.5
wrap := func(x int) int { return ((x % w) + w) % w }
comp, maxDist, n := craterComponents(mask, dist, w, h, wrap)
if n != 2 {
t.Fatalf("found %d blobs, want 2", n)
}
a := maxDist[comp[3*w+3]]
b := maxDist[comp[5*w+21]]
if a != 3 || b != 1.5 {
t.Errorf("radii %.1f and %.1f, want 3 and 1.5", a, b)
}
}
// A crater on the seam is one crater, not two half craters with two different radii.
func TestACraterOnTheSeamIsOneBlob(t *testing.T) {
const w, h = 40, 12
mask := make([]bool, w*h)
dist := make([]float64, w*h)
for y := 4; y < 8; y++ {
for _, x := range []int{38, 39, 0, 1} {
mask[y*w+x] = true
dist[y*w+x] = 2
}
}
wrap := func(x int) int { return ((x % w) + w) % w }
_, _, n := craterComponents(mask, dist, w, h, wrap)
if n != 1 {
t.Fatalf("found %d blobs across the seam, want 1", n)
}
}
+442
View File
@@ -0,0 +1,442 @@
package planet
import (
"image/png"
"math"
"path/filepath"
"salty/terrain/internal/field"
"salty/terrain/internal/uplift"
)
// The maps the plan command writes, and why each one earns its place.
//
// They are rendered by point-sampling the planet's own arrays straight into an image rather than by building
// a full-resolution Field and handing it to field.WriteDataMap. At 78 million cells a Field is 312 MB, and
// the whole point of the plan command is that it costs a minute and nothing else.
// water is the flat blue every map uses for cells that are not land, so the land reads against it.
var water = [3]uint8{24, 44, 74}
// renderRGB point-samples the painted rows of the planet down to width pixels, keeping the aspect, and asks
// at() for a colour per sampled cell. The polar pad is not drawn: it is scaffolding, not world.
// The callback is given the image pixel as well as the planet cell, because a map may have a field of its
// own built at the image's resolution rather than the planet's - the uplift map does, since the massif fabric
// is a field and not a per-class constant.
func renderRGB(in *Inputs, width int, at func(planetIdx, imgIdx int) [3]uint8) (px []uint8, w, h int) {
p := in.P
if width <= 0 || width > p.W {
width = p.W
}
paintH := p.PaintH()
height := int(float64(width)*float64(paintH)/float64(p.W) + 0.5)
if height < 1 {
height = 1
}
px = make([]uint8, width*height*3)
field.Rows(height, func(y0, y1 int) {
for y := y0; y < y1; y++ {
sy := p.PadY + y*paintH/height
for x := 0; x < width; x++ {
sx := x * p.W / width
c := at(sy*p.W+sx, y*width+x)
o := (y*width + x) * 3
px[o], px[o+1], px[o+2] = c[0], c[1], c[2]
}
}
})
return px, width, height
}
func write(path string, px []uint8, w, h int) error {
return field.WriteRGB(path, w, h, px, png.DefaultCompression)
}
// WriteClassMap is the first thing to look at when a template comes out wrong: the legend's own colours,
// after the strokes have been dissolved and the poles rescued. If this is not the painting, nothing
// downstream can be.
func WriteClassMap(dir string, in *Inputs, width int) error {
cols := make([][3]uint8, len(in.Legend.Classes))
for i, c := range in.Legend.Classes {
cols[i] = [3]uint8{uint8(c.RGB[0]), uint8(c.RGB[1]), uint8(c.RGB[2])}
}
px, w, h := renderRGB(in, width, func(i, _ int) [3]uint8 { return cols[in.Map.Class[i]] })
return write(filepath.Join(dir, "map_class.png"), px, w, h)
}
// WriteRegionMap shows how the planet was cut up. Each region gets its own hue: its land saturated, the
// water it carries as a margin the same hue dimmed. Water owned by nobody is flat blue.
//
// What to read from it: that the seam-straddling landmass is one colour and not two, that no two landmasses
// which should be independent have been merged into one enormous box, and that the margins are not so wide
// that the regions have swallowed the ocean.
func WriteRegionMap(dir string, in *Inputs, width int) error {
hues := in.RegionHues()
px, w, h := renderRGB(in, width, func(i, _ int) [3]uint8 {
r := in.Part.Owner[i]
if r < 0 {
return water
}
c := hues[r]
if in.Map.Sea[i] {
// The margin: the same region, dimmed, so the box it will be solved in is visible.
return [3]uint8{c[0]/3 + water[0]/2, c[1]/3 + water[1]/2, c[2]/3 + water[2]/2}
}
return c
})
return write(filepath.Join(dir, "map_regions.png"), px, w, h)
}
// goldenAngle is the fraction of a turn between consecutive region hues: 1/phi, or 137.507 degrees.
const goldenAngle = 0.6180339887498949
// RegionHues is the colour each region is drawn in, indexed the same way in.Part.Regions is.
//
// Exported so that a caller drawing a key beside the map gets the colours from here rather than
// reimplementing it. A legend that is a second copy of the thing it describes is a legend that will
// eventually be wrong about it.
//
// The hue walks by the golden angle rather than coming out of a hash of the index, which is what it used to
// do. A hash gives *independent* hues, and independent hues collide: the closest pair of the hash's first
// twenty was 8.5 apart in RGB, which is two colours nobody can tell apart, on a map whose entire job is
// answering "is that one landmass or two". Stepping 137.5 degrees is the arrangement that keeps every prefix
// of the sequence as far apart as a sequence can be, and saturation and value then cycle on 3 and 2 so that
// two regions coming round to the same hue still differ in something else. Measured over the same walk: the
// closest pair is 44.0 at twenty regions, 41.9 at twenty-six and 37.7 at forty.
//
// Neither cycle is pushed far. The ocean margin is drawn as this colour thirded and mixed with water, so a
// region that starts dim dims to the same grey-blue as every other dim one.
func (in *Inputs) RegionHues() [][3]uint8 {
hues := make([][3]uint8, len(in.Part.Regions))
for i := range hues {
c := field.HSV(math.Mod(float64(i)*goldenAngle, 1), 0.48+0.17*float64(i%3), 0.96-0.16*float64(i%2))
hues[i] = [3]uint8{clamp8(c[0]), clamp8(c[1]), clamp8(c[2])}
}
return hues
}
// RegionLabels is where to write each region's id over the region map: the centroid of its land, as fractions
// of the drawn map - 0..1 across, 0..1 down the painted rows, the same frame renderRGB draws into.
//
// Exported for the reason RegionHues is. The alternative is a caller reproducing the polar row offset and the
// seam wrap in a second language, and a label half a region away from the region it names is worse than no
// label at all. Colour alone cannot carry this: even at 137.5 degrees a step, forty regions are forty hues and
// a person matching a hue to a swatch is doing work a two-digit number does for them.
//
// Two details it would be wrong to leave out. The mean across is *circular*, because a landmass over the seam
// has land at x=0 and at x=W-1 and a plain average puts its number on the opposite side of the planet. And it
// is sampled on a stride rather than walked whole: this is a place to put a number, the planet is seventy-six
// million cells, and a quarter of a cell of accuracy is not worth a sixteenth of a plan. A region too small to
// catch a sample falls back to the middle of its frame, which is the only thing left to say about it.
func (in *Inputs) RegionLabels() [][2]float64 {
p := in.P
out := make([][2]float64, len(in.Part.Regions))
paintH := p.PaintH()
if len(out) == 0 || paintH <= 0 {
return out
}
const stride = 4
cosX := make([]float64, p.W)
sinX := make([]float64, p.W)
for x := 0; x < p.W; x++ {
a := 2 * math.Pi * float64(x) / float64(p.W)
cosX[x], sinX[x] = math.Cos(a), math.Sin(a)
}
sumC := make([]float64, len(out))
sumS := make([]float64, len(out))
sumY := make([]float64, len(out))
n := make([]float64, len(out))
for y := p.PadY; y < p.PadY+paintH; y += stride {
row := y * p.W
for x := 0; x < p.W; x += stride {
i := row + x
r := in.Part.Owner[i]
if r < 0 || in.Map.Sea[i] {
continue
}
sumC[r] += cosX[x]
sumS[r] += sinX[x]
sumY[r] += float64(y - p.PadY)
n[r]++
}
}
for r := range out {
if n[r] == 0 {
f := in.Part.Regions[r].Frame
// X0 can run past W on a seam region and Y0 can reach into the polar pad, so both are brought
// back into the drawn frame rather than trusted.
u := math.Mod(float64(f.X0)+float64(f.W)/2, float64(p.W)) / float64(p.W)
v := (float64(f.Y0-p.PadY) + float64(f.H)/2) / float64(paintH)
out[r] = [2]float64{clamp01(u), clamp01(v)}
continue
}
a := math.Atan2(sumS[r]/n[r], sumC[r]/n[r])
if a < 0 {
a += 2 * math.Pi
}
out[r] = [2]float64{a / (2 * math.Pi), clamp01(sumY[r] / n[r] / float64(paintH))}
}
return out
}
func clamp01(v float64) float64 {
if v <= 0 {
return 0
}
if v >= 1 {
return 1
}
return v
}
// UpliftScale is the top of the uplift map's ramp, in mm/yr, and the colours along it. Same argument as
// RegionHues: the key comes from the code that drew the picture.
func (in *Inputs) UpliftScale(stops int) (hi float64, ramp [][3]uint8) {
for _, r := range in.Legend.Rates() {
if v := float64(r) * 1000; v > hi {
hi = v
}
}
if hi <= 0 {
hi = 1
}
return hi, sampleRamp(stops, field.Inferno)
}
// ErodibilityScale is the low and high end of the erodibility map's ramp and the colours along it.
func (in *Inputs) ErodibilityScale(stops int) (lo, hi float64, ramp [][3]uint8) {
lo, hi = in.erodibilityRange()
return lo, hi, sampleRamp(stops, field.Viridis)
}
// erodibilityRange is what the erodibility map spans: every land class's own multiplier, widened by the rock
// field's extremes wherever a class lets them through. It has to account for the lithology or the ramp would
// clip exactly the variation the field was added to show.
func (in *Inputs) erodibilityRange() (lo, hi float64) {
lo, hi = 1, 1
mult := in.M.Pipeline.Lithology.KMultipliers
rockLo, rockHi := 1.0, 1.0
if in.M.Planet.LithologyCells() > 0 && len(mult) > 1 {
rockLo, rockHi = mult[0], mult[0]
for _, v := range mult {
rockLo = math.Min(rockLo, v)
rockHi = math.Max(rockHi, v)
}
}
for i := range in.Legend.Classes {
c := in.Legend.Classes[i]
if !c.Land() {
continue
}
k, mix := c.K(), c.LithMix()
lo = math.Min(lo, k*(1+mix*(rockLo-1)))
hi = math.Max(hi, k*(1+mix*(rockHi-1)))
}
return lo, hi
}
func sampleRamp(stops int, f func(float64) [3]float64) [][3]uint8 {
if stops < 2 {
stops = 2
}
out := make([][3]uint8, stops)
for i := range out {
c := f(float64(i) / float64(stops-1))
out[i] = [3]uint8{clamp8(c[0]), clamp8(c[1]), clamp8(c[2])}
}
return out
}
// WriteUpliftMap is the field everything else is a consequence of, in mm/yr. On the square canvas this map
// would have shown, at a glance and with no arithmetic, that the plains were being raised at mountain rates.
// On a painted planet it is the direct check that the legend's numbers landed where the author painted them.
func WriteUpliftMap(dir string, in *Inputs, width int) error {
rates := in.Legend.Rates()
massifFloor, massifFraction := in.Legend.Massifs()
hi := 0.0
for _, r := range rates {
if v := float64(r) * 1000; v > hi {
hi = v
}
}
if hi <= 0 {
hi = 1
}
// The fabric, at the image's resolution rather than the planet's. It has to be drawn, not left out: with
// massifs the rate is a field and not a per-class constant, and a map that showed the class rate flat
// across a landmass would be showing the one thing that is no longer true about it. Building it here
// costs a couple of million noise samples rather than the planet's seventy-eight.
var rank *field.Field
if in.Legend.HasMassifs() {
u, v := in.renderUV(width)
rank = uplift.MassifRank(in.P, in.M.Source.Seed, in.M.Planet.MassifCells(), u, v)
}
px, w, h := renderRGB(in, width, func(i, img int) [3]uint8 {
if in.Map.Sea[i] {
return water
}
cl := in.Map.Class[i]
r := float64(rates[cl])
if rank != nil && massifFraction[cl] > 0 {
r = uplift.MassifRate(float64(massifFloor[cl]), r, float64(rank.Data[img]), massifFraction[cl])
}
c := field.Inferno(r * 1000 / hi)
return [3]uint8{clamp8(c[0]), clamp8(c[1]), clamp8(c[2])}
})
drawFaults(in, px, w, h)
return write(filepath.Join(dir, "map_uplift.png"), px, w, h)
}
// faultInk is the colour traces are drawn in: cyan, which appears nowhere in the Inferno ramp underneath, so
// a trace cannot be mistaken for a value.
var faultInk = [3]uint8{80, 240, 255}
// drawFaults strokes every fault trace over a map, as a line.
//
// The *line* rather than the rate it contributes, deliberately. A fault's escarpment is a couple of hundred
// metres wide and this image is a hundred kilometres across, so the thing it changes is a twentieth of a
// pixel and rendering the field would show nothing at all. What an author wants from this map is where the
// faults are and which way they run - the same question `map_regions` answers about the region cuts - and a
// stroked polyline answers it exactly.
func drawFaults(in *Inputs, px []uint8, w, h int) {
if len(in.Faults) == 0 {
return
}
p := in.P
sx := float64(w) / p.CircumferenceM()
sy := float64(h) / p.HeightM()
set := func(x, y int) {
if y < 0 || y >= h {
return
}
x = ((x % w) + w) % w // X wraps, because the traces do
o := (y*w + x) * 3
px[o], px[o+1], px[o+2] = faultInk[0], faultInk[1], faultInk[2]
}
for _, f := range in.Faults {
for j := 0; j+1 < len(f.PointsM); j++ {
ax, ay := f.PointsM[j][0]*sx, f.PointsM[j][1]*sy
bx, by := f.PointsM[j+1][0]*sx, f.PointsM[j+1][1]*sy
steps := int(math.Hypot(bx-ax, by-ay)) + 1
for k := 0; k <= steps; k++ {
t := float64(k) / float64(steps)
set(int(ax+(bx-ax)*t), int(ay+(by-ay)*t))
}
}
}
}
// renderUV is the world coordinates of the cells renderRGB will point-sample, in the same order it writes
// them. Exactly the sampled cells rather than an even walk across the map: a diagnostic that showed the
// fabric half a pixel from where the class was read would be a diagnostic nobody could trust to the pixel.
func (in *Inputs) renderUV(width int) (u, v *field.Field) {
p := in.P
if width <= 0 || width > p.W {
width = p.W
}
paintH := p.PaintH()
height := int(float64(width)*float64(paintH)/float64(p.W) + 0.5)
if height < 1 {
height = 1
}
cellM := p.CircumferenceM() / float64(width)
u = field.New(width, height, cellM)
v = field.New(width, height, cellM)
for y := 0; y < height; y++ {
sy := p.PadY + y*paintH/height
vy := float32(p.YM(sy) / p.NoisePeriodM)
for x := 0; x < width; x++ {
i := y*width + x
u.Data[i] = float32(p.XM(x*p.W/width) / p.NoisePeriodM)
v.Data[i] = vy
}
}
return u, v
}
// WriteErodibilityMap is where texture inside a range comes from: the multiplier on stream-power K.
func WriteErodibilityMap(dir string, in *Inputs, width int) error {
ks := in.Legend.Erodibilities()
mix := in.Legend.LithologyMixes()
lo, hi := in.erodibilityRange()
span := hi - lo
if span < 1e-9 {
span = 1
}
// The rock field at the image's resolution rather than the planet's, the same way and for the same reason
// the uplift map builds the massif fabric: with lithology the erodibility is a *field*, and a map drawing
// the class multiplier flat across a landmass would be showing the one thing that is no longer true of it.
var rock *field.Field
if cells := in.M.Planet.LithologyCells(); cells > 0 && in.Legend.HasLithology() {
u, v := in.renderUV(width)
rock = uplift.RockK(in.P, in.M.Source.Seed, cells, in.M.Pipeline.Lithology.KMultipliers, u, v)
}
px, w, h := renderRGB(in, width, func(i, img int) [3]uint8 {
if in.Map.Sea[i] {
return water
}
cl := in.Map.Class[i]
k := float64(ks[cl])
if rock != nil && mix[cl] > 0 {
k *= 1 + mix[cl]*(float64(rock.Data[img])-1)
}
c := field.Viridis((k - lo) / span)
return [3]uint8{clamp8(c[0]), clamp8(c[1]), clamp8(c[2])}
})
return write(filepath.Join(dir, "map_erodibility.png"), px, w, h)
}
func clamp8(v float64) uint8 {
if v <= 0 {
return 0
}
if v >= 255 {
return 255
}
return uint8(v + 0.5)
}
// WriteOverlayMap draws the annotation layer over a dimmed class map, which is the only way to judge it: a
// mark means nothing on its own and everything relative to the coastline or the range it was drawn against.
//
// It samples the overlay at its own resolution rather than the planet's. Everything else here reads a planet
// array; the overlay is registered to the *template*, so going through the planet grid would resample it
// twice and lose thin strokes on the way.
func WriteOverlayMap(dir string, in *Inputs, width int) error {
if in.OverlayRaster == nil {
return nil
}
cols := make([][3]uint8, len(in.Overlay.Marks)+1)
for i, m := range in.Overlay.Marks {
cols[i+1] = [3]uint8{uint8(m.RGB[0]), uint8(m.RGB[1]), uint8(m.RGB[2])}
}
class := make([][3]uint8, len(in.Legend.Classes))
for i, c := range in.Legend.Classes {
// Halved towards black, so a full-strength mark on top of it cannot be mistaken for the ground.
class[i] = [3]uint8{uint8(c.RGB[0] / 2), uint8(c.RGB[1] / 2), uint8(c.RGB[2] / 2)}
}
ov := in.OverlayRaster
p := in.P
paintH := p.PaintH()
px, w, h := renderRGB(in, width, func(i, img int) [3]uint8 {
// The planet cell this pixel came from, turned back into an overlay pixel. Both rasters cover the
// same painted rows, so the conversion is two ratios and no interpolation.
x := i % p.W
y := i/p.W - p.PadY
ox := x * ov.W / p.W
oy := y * ov.H / paintH
if m := ov.At(ox, oy); m != 0 && int(m) < len(cols) {
return cols[m]
}
return class[in.Map.Class[i]]
})
return write(filepath.Join(dir, "map_overlay.png"), px, w, h)
}
@@ -0,0 +1,139 @@
package planet
import (
"math"
"os"
"path/filepath"
"salty/terrain/internal/field"
"salty/terrain/internal/plates"
)
// The tectonic map: which plate every place belongs to, and what is happening where two of them meet.
//
// It earns its place the same way map_uplift does. Every belt, every fault and every basin this model
// produces is a consequence of one line and what is happening across it, so when a range comes out in the
// wrong place the question is always "what did the boundary there do", and this is the only picture that
// answers it. The lines are drawn by *kind* rather than by rate, for the reason drawFaults gives about
// stroking traces: a collision belt is tens of kilometres wide, a map of a whole planet is a few thousand
// pixels, and the thing an author needs from it is where the margins are and which ones are closing.
// plateInk is the colour of each kind of margin. They are picked to be distinguishable from each other and
// from the plate fills underneath, which are pastel by construction so that these read on top of them.
var plateInk = map[plates.Kind][3]uint8{
plates.Collision: {255, 64, 48}, // red: two continents, the thing that makes mountains
plates.Subduction: {255, 156, 32}, // orange: an ocean going under
plates.Rift: {96, 240, 120}, // green: a continent pulling apart
plates.Ridge: {72, 196, 255}, // blue: new ocean floor
plates.Transform: {236, 232, 128}, // yellow: sliding, neither up nor down
}
// WritePlateMap draws the tectonic model over the painted land.
func WritePlateMap(dir string, in *Inputs, width int) error {
m := in.Plates
if m == nil {
return nil
}
// A hue per plate, walked round the wheel by a step coprime-ish with the count so that neighbouring ids
// are not neighbouring hues - plates 3 and 4 are usually adjacent on the map, and two greens meeting
// would hide the very boundary this map exists to show.
n := len(m.Plates)
hue := make([]float64, n)
for i := range hue {
hue[i] = math.Mod(float64(i)*0.61803398875, 1)
}
px, w, h := renderRGB(in, width, func(i, img int) [3]uint8 {
id := m.PlateAt(in.P.XM(i%in.P.W), in.P.YM(i/in.P.W))
if id < 0 || id >= n {
return water
}
// Land is the plate's hue at full strength and sea is the same hue dimmed, so the painting stays
// legible underneath: a margin is only interesting relative to where the coasts are.
sat, val := 0.55, 0.78
if in.Map.Sea[i] {
sat, val = 0.38, 0.34
}
// A continental plate is warmer than an oceanic one at the same hue, because which of the two a
// plate is decides what every convergent margin around it does.
if !m.Plates[id].Continental {
sat *= 0.5
}
c := field.HSV(hue[id]*360, sat, val)
return [3]uint8{clamp8(c[0]), clamp8(c[1]), clamp8(c[2])}
})
drawBoundaries(m, in, px, w, h)
return write(filepath.Join(dir, "map_plates.png"), px, w, h)
}
// WritePlateProposal writes a tectonic layer and its legend for an author to open and edit.
//
// A blank canvas is the wrong place to start this. Seven plates with plausible motions is a second's work for
// the generator and an afternoon's by hand, and what an author actually wants to do is move two of them and
// change a heading - which is editing. The pair it writes is exactly what `planet.plates.layer` and
// `planet.plates.legend` take, so adopting it is two lines in the manifest.
func WritePlateProposal(dir string, in *Inputs, width int) error {
if in.Plates == nil {
return nil
}
px, w, h, lg := in.Plates.Propose(width)
const name = "plates_proposal"
if err := write(filepath.Join(dir, name+".png"), px, w, h); err != nil {
return err
}
lg.Image = name + ".png"
data, err := plates.MarshalLegend(lg)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, name+".json"), data, 0o644)
}
// boundaryWidthMYr is the closing rate, in metres a year, at which a margin is drawn at its full width. A
// margin at a tenth of it is still one pixel, so a slow boundary is visible without a fast one being a blot.
const boundaryWidthMYr = 0.08
// drawBoundaries strokes every margin, coloured by what it is doing and thickened by how fast.
func drawBoundaries(m *plates.Model, in *Inputs, px []uint8, w, h int) {
p := in.P
sx := float64(w) / p.CircumferenceM()
sy := float64(h) / p.HeightM()
set := func(x, y int, c [3]uint8) {
if y < 0 || y >= h {
return
}
x = ((x % w) + w) % w // X wraps, because the boundaries do
o := (y*w + x) * 3
px[o], px[o+1], px[o+2] = c[0], c[1], c[2]
}
disc := func(x, y, r int, c [3]uint8) {
for dy := -r; dy <= r; dy++ {
for dx := -r; dx <= r; dx++ {
if dx*dx+dy*dy <= r*r {
set(x+dx, y+dy, c)
}
}
}
}
for _, b := range m.Boundaries {
for j := 0; j+1 < len(b.V); j++ {
v := b.V[j]
ink := plateInk[v.Kind]
r := int(math.Abs(v.ClosingMYr)/boundaryWidthMYr*2 + 0.5)
if r > 2 {
r = 2
}
ax, ay := v.XM*sx, v.YM*sy
bx, by := b.V[j+1].XM*sx, b.V[j+1].YM*sy
steps := int(math.Hypot(bx-ax, by-ay)) + 1
for k := 0; k <= steps; k++ {
t := float64(k) / float64(steps)
disc(int(ax+(bx-ax)*t), int(ay+(by-ay)*t), r, ink)
}
}
}
}
@@ -0,0 +1,157 @@
package planet
import (
"math"
"testing"
"salty/terrain/internal/region"
"salty/terrain/internal/template"
"salty/terrain/internal/world"
)
// A region label is a number written on a picture, so the only thing worth asserting about it is that it lands
// on the region it names.
//
// The seam is the case that fails silently. A landmass with land at x=0 and at x=W-1 has an arithmetic mean
// halfway round the planet - on some other continent entirely - and the map would look perfectly plausible
// while being wrong about the one thing the number is for. Everything else here is a guard on the arithmetic
// around it: the polar pad is not painted rows, and a region too small to catch a sample still has to get a
// position rather than a NaN.
const (
labelW = 64
labelPadY = 4
labelPaint = 32
)
// labelFixture is a planet with three regions painted onto it: a compact island in the middle, an island over
// the seam, and a single cell small enough that the stride walks straight past it.
func labelFixture() *Inputs {
p := world.Planet{
CellM: 1, W: labelW, H: labelPaint + 2*labelPadY, PadY: labelPadY,
NoisePeriodM: float64(labelW),
}
n := p.W * p.H
m := &template.Map{P: p, Class: make([]uint8, n), Sea: make([]bool, n)}
for i := range m.Sea {
m.Sea[i] = true
}
part := &region.Partition{P: p, MarginCells: 1, Owner: make([]int32, n)}
for i := range part.Owner {
part.Owner[i] = -1
}
land := func(r int32, x0, x1, py0, py1 int) {
for py := py0; py <= py1; py++ {
for x := x0; x <= x1; x++ {
i := (labelPadY+py)*p.W + ((x%p.W)+p.W)%p.W
part.Owner[i], m.Sea[i] = r, false
}
}
}
// 0: a compact island around the middle of the map, centred on x = 31.5, painted row 15.5.
land(0, 28, 35, 12, 19)
part.Regions = append(part.Regions, region.Region{
ID: 0, Frame: world.Frame{P: p, X0: 28, Y0: labelPadY + 12, W: 8, H: 8}, LandCells: 64,
})
// 1: the same island moved onto the seam, centred on x = 63.5 - half of it at x >= 60 and half at x <= 3.
land(1, 60, 67, 12, 19)
part.Regions = append(part.Regions, region.Region{
ID: 1, Frame: world.Frame{P: p, X0: 60, Y0: labelPadY + 12, W: 8, H: 8}, LandCells: 64, Seam: true,
})
// 2: one cell, at coordinates the stride never samples, so this is the fallback path.
land(2, 1, 1, 1, 1)
part.Regions = append(part.Regions, region.Region{
ID: 2, Frame: world.Frame{P: p, X0: 1, Y0: labelPadY + 1, W: 1, H: 1}, LandCells: 1,
})
return &Inputs{P: p, Map: m, Part: part}
}
// circDist is the distance between two positions round the cylinder, in fractions of a turn.
func circDist(a, b float64) float64 {
d := math.Abs(a - b)
return math.Min(d, 1-d)
}
func TestRegionLabelsLandOnTheirRegion(t *testing.T) {
in := labelFixture()
got := in.RegionLabels()
if len(got) != 3 {
t.Fatalf("got %d labels for 3 regions", len(got))
}
// The compact island. The stride costs up to two cells of accuracy in each axis, which is 0.03 of a turn
// across and 0.06 down, and a label is allowed to be that far from dead centre.
const wantU0, wantV0 = 31.5 / labelW, 15.5 / labelPaint
if d := circDist(got[0][0], wantU0); d > 0.05 {
t.Errorf("region 0 u = %.4f, want within 0.05 of %.4f (off by %.4f)", got[0][0], wantU0, d)
}
if d := math.Abs(got[0][1] - wantV0); d > 0.08 {
t.Errorf("region 0 v = %.4f, want within 0.08 of %.4f", got[0][1], wantV0)
}
// The seam island, and the reason this file exists. Its land is centred on x = 63.5, a quarter of a cell
// short of the seam. An arithmetic mean of those columns gives 31.5, which is region 0's island: if this
// assertion ever fails by landing near 0.49, the circular mean has been lost.
const wantU1 = 63.5 / labelW
if d := circDist(got[1][0], wantU1); d > 0.06 {
t.Errorf("region 1 u = %.4f, want within 0.06 of %.4f (off by %.4f); "+
"0.49 means the mean across is no longer circular", got[1][0], wantU1, d)
}
if d := math.Abs(got[1][1] - wantV0); d > 0.08 {
t.Errorf("region 1 v = %.4f, want within 0.08 of %.4f", got[1][1], wantV0)
}
// The single cell, which no sample touches: the frame centre, and in particular a v measured from the
// painted rows rather than from the top of the polar pad.
const wantU2, wantV2 = 1.5 / labelW, 1.5 / labelPaint
if d := circDist(got[2][0], wantU2); d > 0.01 {
t.Errorf("region 2 u = %.4f, want %.4f from its frame", got[2][0], wantU2)
}
if d := math.Abs(got[2][1] - wantV2); d > 0.01 {
t.Errorf("region 2 v = %.4f, want %.4f from its frame; a v of %.4f would be measuring from the "+
"top of the pad instead of the first painted row",
got[2][1], wantV2, float64(labelPadY+1)/labelPaint)
}
for i, l := range got {
if math.IsNaN(l[0]) || math.IsNaN(l[1]) ||
l[0] < 0 || l[0] > 1 || l[1] < 0 || l[1] > 1 {
t.Errorf("region %d label %v is outside the drawn map", i, l)
}
}
}
// The hues have to be far enough apart to tell one landmass from another, which is the whole reason they
// stopped coming out of a hash of the index. See RegionHues for the argument; this is the measurement.
func TestRegionHuesStayApart(t *testing.T) {
const regions = 40
in := &Inputs{Part: &region.Partition{Regions: make([]region.Region, regions)}}
hues := in.RegionHues()
worst, wa, wb := math.MaxFloat64, 0, 0
for a := 0; a < regions; a++ {
for b := a + 1; b < regions; b++ {
dr := float64(hues[a][0]) - float64(hues[b][0])
dg := float64(hues[a][1]) - float64(hues[b][1])
db := float64(hues[a][2]) - float64(hues[b][2])
if d := math.Sqrt(dr*dr + dg*dg + db*db); d < worst {
worst, wa, wb = d, a, b
}
}
}
t.Logf("closest of %d hues: %.1f, between region %d and region %d", regions, worst, wa, wb)
// Measured: the walk gives 44.0 at twenty regions, 41.9 at twenty-six and 37.7 at forty, against 8.5 for
// the hash this replaced. The floor is set well below what the walk achieves rather than at it, so that a
// change to the saturation or value cycle is free to move the number a little and not free to collapse it.
const floor = 25
if worst < floor {
t.Errorf("closest two hues are %.1f apart (regions %d and %d), want at least %d: "+
"two landmasses that colour alike is the defect this walk exists to prevent", worst, wa, wb, floor)
}
}
+373
View File
@@ -0,0 +1,373 @@
package planet
import (
"encoding/json"
"fmt"
"image/png"
"math"
"os"
"path/filepath"
"time"
"salty/terrain/internal/field"
"salty/terrain/internal/stats"
)
// What a bake writes, and why.
//
// Three resolutions of the same 16-bit heightmap, because the three answer different questions: the geology
// grid is the thing the detail passes will be built on, the middle one is what fits in an image viewer, and
// the small one is a minimap. The preview and the data maps are for judging, not for importing.
// Painted returns a field over the painted rows only, with the polar pad dropped. The pad is scaffolding -
// synthetic ocean that exists so a cap touching the top of the map has a shore to drain to - and it is
// removed before anything leaves the generator.
func (r *Result) Painted() *field.Field {
p := r.In.P
out := field.New(p.W, p.PaintH(), p.CellM)
copy(out.Data, r.Height.Data[p.PadY*p.W:(p.H-p.PadY)*p.W])
return out
}
// PaintedSea is the sea mask over the painted rows.
func (r *Result) PaintedSea() []bool {
p := r.In.P
return r.Sea[p.PadY*p.W : (p.H-p.PadY)*p.W]
}
// PaintedFlow is the drainage area over the painted rows.
func (r *Result) PaintedFlow() *field.Field {
p := r.In.P
out := field.New(p.W, p.PaintH(), p.CellM)
copy(out.Data, r.Flow[p.PadY*p.W:(p.H-p.PadY)*p.W])
return out
}
// Write puts the bake on disk.
func (r *Result) Write(outDir string, mapWidth int, log func(string, ...any)) error {
if log == nil {
log = func(string, ...any) {}
}
if err := os.MkdirAll(outDir, 0o755); err != nil {
return err
}
m := r.In.M
h := r.Painted()
sea := r.PaintedSea()
flow := r.PaintedFlow()
// The statistics, pooled. Regions are merged in *region order* rather than in the order they finished:
// the histograms themselves are integer counts and would not care, but the running sums are floats and
// float addition is not associative, so a run's numbers would otherwise depend on which landmass came
// back first. Cross-cutting rule 12, in the one place left where it could still leak.
acc := stats.New(statsOptions(m))
for i := range r.Regions {
acc.Merge(r.Regions[i].stats)
}
// And the extent, measured once on the composited planet. A region carries an ocean margin and two
// neighbouring margins overlap, so pooling "cells" across regions counts the same water twice and reports
// a land fraction that means nothing; the finished cylinder is the only place the question has an answer.
land := make([]bool, len(sea))
for i, s := range sea {
land[i] = !s
}
acc.AddExtent(h.Data, land, m.ClipCells(h.Data))
rep := acc.Report(h.CellM)
r.Stats = &rep
// The heightmap, three ways. Compression is worth paying for on the full one, which is the thing
// anything downstream actually reads; the two overviews are rebuilt from a seed in seconds.
levels := []struct {
name string
w, h int
lvl png.CompressionLevel
}{
{"planet_height.png", h.W, h.H, png.DefaultCompression},
{"planet_height_mid.png", h.W / 4, h.H / 4, png.BestSpeed},
{"planet_height_low.png", h.W / 10, h.H / 10, png.BestSpeed},
}
for _, l := range levels {
if l.w < 2 || l.h < 2 {
continue
}
data := h.Data
if l.w != h.W || l.h != h.H {
data = boxDown(h.Data, h.W, h.H, l.w, l.h)
}
if err := field.WriteGray16(filepath.Join(outDir, l.name), l.w, l.h, m.Encode(data), l.lvl); err != nil {
return err
}
log("wrote %-24s %d x %d at %.1f m", l.name, l.w, l.h, float64(h.W)*h.CellM/float64(l.w))
}
// How much of the 16-bit ramp the world actually used, which until D-64 nothing said. The clip fraction
// is the check at the top end and it only ever catches a range too *narrow*; a range several times too
// wide clips nothing, reports nothing, and quietly spends most of its resolution and all of its contrast
// on elevations no cell on the planet has. A heightmap that uses a tenth of its ramp is a flat grey
// picture in every viewer, and the ocean and the land in it are the same grey.
span := m.ElevationM.Max - m.ElevationM.Min
used := (rep.MaxM - rep.MinM) / span
landUsed := (rep.LandMaxM - m.SeaLevelM) / span
log("range %.0f..%.0f m encoded, %.0f..%.0f m used: %.0f%% of the ramp, and land is %.1f%% of it",
m.ElevationM.Min, m.ElevationM.Max, rep.MinM, rep.MaxM, used*100, landUsed*100)
if used < 0.5 {
log(" tighten elevation_m to about %.0f..%.0f m and the same terrain arrives with %.0fx the "+
"contrast and %.0fx the vertical resolution; the range is an author's choice and nothing but "+
"this line will tell you it is wrong, because too wide never clips",
math.Floor(rep.MinM/64)*64, math.Ceil(rep.MaxM/64)*64, 1/used, 1/used)
}
topM, err := field.WritePreview(filepath.Join(outDir, "preview.png"), h, field.PreviewOptions{
Flow: flow, Sea: sea, Snow: r.In.Map.SnowMask(), Palette: r.In.Palette,
SeaLevelM: m.SeaLevelM, RiverKm2: 0.5, Size: mapWidth,
})
if err != nil {
return err
}
// Say what the colours meant. The ramp is relative by default, so bare rock and snow on a preview mean
// "the highest ground on this world", not "high ground" - and on a 47 m lowland continent those are the
// same pixels a 2800 m range would produce. A relative picture is fine; one nobody was told is relative
// is how a plain gets read as an alpine massif. `palette.land_top_m` makes it absolute.
if r.In.Palette != nil && r.In.Palette.LandTopM > 0 {
log("preview the hypsometric ramp tops out at a fixed %.0f m, so the colours mean the same thing "+
"they would on any other world", topM)
} else {
log("preview the hypsometric ramp tops out at %.0f m - the %.4g%% percentile of *this* world's land, "+
"so rock and snow mean \"the highest ground here\" and nothing about scale. Set "+
"palette.land_top_m for an absolute ramp", topM, palPercentile(r.In.Palette))
}
for _, w := range []func(string, *Inputs, int) error{
WriteClassMap, WriteRegionMap, WriteUpliftMap, WriteErodibilityMap, WriteOverlayMap, WritePlateMap,
} {
if err := w(outDir, r.In, mapWidth); err != nil {
return err
}
}
// The annotation layer travels with the bake, so a heightmap and the things the author placed on it are
// never in two directories that can drift apart.
if r.In.OverlayDoc != nil {
if err := r.In.OverlayDoc.WriteJSON(outDir); err != nil {
return err
}
}
slope := h.Slope()
for i, v := range slope.Data {
slope.Data[i] = float32(degrees(float64(v)))
}
if err := field.WriteDataMap(filepath.Join(outDir, "map_slope.png"), slope, field.DataMapOptions{
Sea: sea, Size: mapWidth, Lo: 0, Hi: 45, Palette: field.Inferno,
}); err != nil {
return err
}
if err := field.WriteDataMap(filepath.Join(outDir, "map_flow.png"), flow, field.DataMapOptions{
Sea: sea, Size: mapWidth, Log: true,
}); err != nil {
return err
}
if err := r.writeCoastMaps(outDir, mapWidth); err != nil {
return err
}
log("wrote preview.png and the data maps at %d px wide", mapWidth)
meta := map[string]any{
"when": time.Now().UTC().Truncate(time.Second),
"manifest": m.Path,
"seed": m.Source.Seed,
"plan": r.In.Report(),
"regions": r.Regions,
"craters": r.Craters,
"stats": r.Stats,
"coast": coastStats(r),
// The traces themselves, not just the count: a fault set is a property of the seed and the painting,
// and "which fault made that valley" is a question somebody will ask of a finished world. Thirty
// traces of thirty points is forty kilobytes, which is nothing against the heightmap beside it.
"faults": r.In.Faults,
// The tectonic model, when there is one, for the same reason and one level up: every belt and every
// fault this planet has is a consequence of one of these lines, so "why is there a range here" is
// answerable afterwards rather than only while the process that drew it is still running.
"plates": r.In.Plates,
"elapsed": r.Elapsed.Round(time.Second).String(),
"steps": m.Pipeline.Fluvial.Steps,
}
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(outDir, "meta.json"), append(data, '\n'), 0o644)
}
// Summary is the verdict line, printed per region and then for the planet.
func (r *Result) Summary() string {
lo, hi := 1e30, -1e30
clipWorst, clipWorstID := 0.0, -1
total := 0.0
for _, rr := range r.Regions {
if rr.LandCells == 0 {
continue
}
if rr.MinM < lo {
lo = rr.MinM
}
if rr.MaxM > hi {
hi = rr.MaxM
}
if rr.ClipFrac > clipWorst {
clipWorst, clipWorstID = rr.ClipFrac, rr.ID
}
total += rr.Seconds
}
s := fmt.Sprintf(" %d regions solved in %s of wall time (%.0f s of solve)\n"+
" land %.0f..%.0f m against the manifest's %.0f..%.0f m\n",
len(r.Regions), r.Elapsed.Round(time.Second), total,
lo, hi, r.In.M.ElevationM.Min, r.In.M.ElevationM.Max)
if clipWorstID >= 0 && clipWorst > 0 {
verdict := "which is a rounding"
if clipWorst > 0.001 {
verdict = "WHICH IS A FAILED RUN, not a rounded one: U/K is the relief knob"
}
s += fmt.Sprintf(" worst clip %.3f%% in region %d, %s\n", clipWorst*100, clipWorstID, verdict)
}
// The block Terrain-Next has called the one that matters most since D-53, and which a planet bake could
// not print until the statistics learned to pool: map-wide medians cannot answer "are the plains plains",
// and that is the question.
if r.Stats != nil {
s += "\n" + r.Stats.Summary() + "\n"
}
return s
}
// boxDown is an area-average downsample for any ratio, integer or not: every source cell is added to the
// bucket its centre falls in. field.Resample's mass-preserving path needs an exact integer factor on the
// quad count, and a planet's two sides rarely share one.
func boxDown(src []float32, w, h, dw, dh int) []float32 {
sum := make([]float64, dw*dh)
n := make([]int32, dw*dh)
for y := 0; y < h; y++ {
dy := y * dh / h
for x := 0; x < w; x++ {
d := dy*dw + x*dw/w
sum[d] += float64(src[y*w+x])
n[d]++
}
}
out := make([]float32, dw*dh)
for i := range out {
if n[i] > 0 {
out[i] = float32(sum[i] / float64(n[i]))
}
}
return out
}
func degrees(slope float64) float64 { return math.Atan(slope) * 180 / math.Pi }
// BakePrefix is the directory a bake is written into, numbered upwards.
const BakePrefix = "Bake_"
// NextBakeDir is the first version number not already on disk.
//
// Bakes are versioned for the same reason paintings are: an hour and a half is far too long to spend on a
// change you then cannot compare against what it replaced.
func NextBakeDir(base string) string {
for n := 1; n < 10000; n++ {
dir := filepath.Join(base, fmt.Sprintf("%s%03d", BakePrefix, n))
if _, err := os.Stat(dir); os.IsNotExist(err) {
return dir
}
}
return filepath.Join(base, BakePrefix+"overflow")
}
// palPercentile is the ramp's percentile, or the default's when the bake carries no palette of its own.
func palPercentile(p *field.Palette) float64 {
if p == nil {
p = field.DefaultPalette()
}
return p.LandTopPercentile
}
// writeCoastMaps draws the two pictures the coastal pass is judged from.
//
// **The change map** is the whole pass in one image: cool where the surf cut, warm where the sediment landed.
// The sea floor is excluded from it, because the ocean goes from sea level to five hundred metres down in one
// pass and a few hundred metres of that would swamp the few the shore processes move, which is the thing the
// map exists to show.
//
// **Exposure** is drawn only within a kilometre of the water. That is not tidiness: it is measured on the
// waterline and carried to every other cell by "the stretch of shore nearest to you", so past a few hundred
// metres it is a map of the continent's medial axis rather than of anything coastal - the first render of it
// on the square canvas was a sunburst of polygonal wedges meeting in the middle of a continent.
func (r *Result) writeCoastMaps(outDir string, mapWidth int) error {
cs := r.Coast
if cs == nil || !r.In.M.Pipeline.Coast.Enabled {
return nil
}
p := r.In.P
lo, hi := p.PadY*p.W, (p.H-p.PadY)*p.W
painted := func(src *field.Field) *field.Field {
out := field.New(p.W, p.PaintH(), p.CellM)
copy(out.Data, src.Data[lo:hi])
return out
}
band := make([]bool, p.W*p.PaintH())
for i, d := range cs.Geometry.Dist.Data[lo:hi] {
band[i] = math.Abs(float64(d)) > 1000
}
if err := field.WriteDataMap(filepath.Join(outDir, "map_exposure.png"), painted(cs.Exposure),
field.DataMapOptions{Sea: band, Size: mapWidth, Lo: 0, Hi: 1, Palette: field.Inferno}); err != nil {
return err
}
// The sea floor masked out, so the scale belongs to the shore rather than to the shelf.
deep := make([]bool, p.W*p.PaintH())
for i, d := range cs.Geometry.Dist.Data[lo:hi] {
deep[i] = float64(d) < -r.In.M.Pipeline.Coast.DepositReachM*2
}
if err := field.WriteDataMap(filepath.Join(outDir, "map_coast.png"), painted(cs.Change),
field.DataMapOptions{Sea: deep, Size: mapWidth, Lo: -30, Hi: 30, Palette: field.Divergent}); err != nil {
return err
}
return r.writeExposure(outDir)
}
// writeExposure carries the fetch field forward to the detail bake, at the geology grid and unmasked.
//
// It is data rather than a picture, which is why it is not map_exposure.png: that one is scaled to a map
// width and blanked away from the water, both of which are right for looking at and useless for reading back.
//
// The detail bake needs it because it cannot compute it. Fetch is cast fifteen hundred metres in sixteen
// directions from every waterline cell, and a tile is five kilometres across with a two hundred and fifty
// metre margin - so a tile can see neither the far side of a bay nor the open ocean beyond a headland, and
// whether the water in front of a beach is one or the other is the whole difference between a berm and a
// mudflat. It is the same rule the massif threshold and the lithology split are under: a quantity measured
// over the whole world is measured once, by the pass that has the whole world, and carried.
//
// Eight bits, so a stretch of shore is placed to a four-hundredth of the range. The field is a smoothed
// fetch ratio and its own noise floor is well above that.
func (r *Result) writeExposure(outDir string) error {
p := r.In.P
lo := p.PadY * p.W
n := p.W * p.PaintH()
px := make([]uint8, n)
for i := 0; i < n; i++ {
v := float64(r.Coast.Exposure.Data[lo+i])
if v < 0 {
v = 0
} else if v > 1 {
v = 1
}
px[i] = uint8(v*255 + 0.5)
}
return field.WriteGray8(filepath.Join(outDir, "coast_exposure.png"), p.W, p.PaintH(), px,
png.DefaultCompression)
}
// coastStats is the pass's own accounting for meta.json, or nil when it did not run.
func coastStats(r *Result) any {
if r.Coast == nil || !r.In.M.Pipeline.Coast.Enabled {
return nil
}
return r.Coast.Stats
}
@@ -0,0 +1,54 @@
package planet
import (
"math"
"testing"
)
// boxDown is what the two overview heightmaps are made with, and a planet's two sides rarely share an
// integer factor, so it has to be right at a ratio that does not divide.
func TestBoxDownAveragesAndKeepsTheMean(t *testing.T) {
const w, h = 12, 7
src := make([]float32, w*h)
sum := 0.0
for i := range src {
src[i] = float32(i%5) + float32(i/w)
sum += float64(src[i])
}
want := sum / float64(len(src))
for _, d := range [][2]int{{6, 7}, {4, 3}, {5, 3}, {12, 7}, {1, 1}} {
out := boxDown(src, w, h, d[0], d[1])
if len(out) != d[0]*d[1] {
t.Fatalf("%dx%d: got %d values", d[0], d[1], len(out))
}
got := 0.0
for _, v := range out {
got += float64(v)
}
got /= float64(len(out))
// Buckets do not all hold the same number of cells at a ratio that does not divide, so the mean of
// the means drifts a little; what must not happen is a bucket left empty or a value invented.
if math.Abs(got-want) > 0.35 {
t.Errorf("%dx%d: mean %.3f, source mean %.3f", d[0], d[1], got, want)
}
lo, hi := math.Inf(1), math.Inf(-1)
for _, v := range out {
lo = math.Min(lo, float64(v))
hi = math.Max(hi, float64(v))
}
if lo < 0 || hi > 11 {
t.Errorf("%dx%d: range %.2f..%.2f is outside the source's 0..10", d[0], d[1], lo, hi)
}
}
}
func TestBoxDownIsIdentityAtTheSameSize(t *testing.T) {
src := []float32{1, 2, 3, 4, 5, 6}
out := boxDown(src, 3, 2, 3, 2)
for i := range src {
if out[i] != src[i] {
t.Fatalf("cell %d: %v, want %v", i, out[i], src[i])
}
}
}
+354
View File
@@ -0,0 +1,354 @@
package planet
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"salty/terrain/internal/field"
"salty/terrain/internal/fluvial"
"salty/terrain/internal/manifest"
"salty/terrain/internal/overlay"
)
// Proposing an overlay from a finished bake.
//
// The annotation layer is hand-painted and starts blank, and the three things most worth putting on it -
// woodland, settlements and the roads between them - are all consequences of ground the author cannot see
// while painting. The classes are painted before the solve exists, and once it does exist it is a
// seventy-six-million-cell heightmap. So this reads the bake back and hands internal/overlay the four
// fields it needs to have an opinion: height, slope, the sea, and where the water collects.
//
// It reads the bake from disk rather than hooking into one, for the same reason `terrain tiles` does: the
// geology is two hours and this is seconds, so an author can regenerate the sheet as often as they like
// against a bake they already have.
//
// **It works at the template's resolution, not the geology grid's.** The overlay is registered to the
// template and must be exactly its size, so generating anywhere else would mean resampling the output - and
// a resampled mark is a blend of two colours, which the classifier reads as a third mark or as nothing. The
// geology is pooled down to the template once, here, and everything downstream is at that scale.
// OverlayGenOptions is what the generator is pointed at.
type OverlayGenOptions struct {
In *Inputs
BakeDir string
// Replace ignores the overlay already on disk instead of filling in around it. The default is to keep
// every painted pixel, because regenerating must never cost an author their work; this is the flag for
// "throw away the last generation and start again", and it says so at the call site.
Replace bool
// Seed overrides the manifest's, which is what a re-roll is: the painting fixes where the land is and
// the seed decides everything it does not - which patch of eligible ground becomes woodland, and which
// of the equally good sites gets the town.
Seed int64
// Existing overrides the overlay loaded from disk. The studio sets it, because the sheet an author is
// looking at includes strokes they have not saved, and generating around the file instead of around the
// screen would put marks on top of work that is visibly there.
Existing *overlay.Raster
Log func(string, ...any)
}
// GenerateOverlay reads a bake and proposes the marks whose legend asks for them.
func GenerateOverlay(opt OverlayGenOptions) (*overlay.Raster, overlay.GenReport, error) {
var none overlay.GenReport
in := opt.In
log := opt.Log
if log == nil {
log = func(string, ...any) {}
}
if in.Overlay == nil {
return nil, none, fmt.Errorf("%s has no overlay legend; set planet.overlay_legend and say which "+
"marks to generate", in.M.Path)
}
wants := false
for i := range in.Overlay.Marks {
if in.Overlay.Marks[i].Generate != nil {
wants = true
break
}
}
if !wants {
return nil, none, fmt.Errorf("no mark in %s has a `generate` block, so there is nothing to "+
"generate. Generation is opt-in per mark; see the overlay section of the templates README",
in.M.Planet.OverlayLegend)
}
ow, oh := in.PaintW, in.PaintH
cellM := in.P.CircumferenceM() / float64(ow)
if opt.BakeDir != "" {
if ok, why := BakeIsOfThisPainting(opt.BakeDir, in.M); !ok {
log("ignoring %s: %s, so its terrain is not this world's", filepath.Base(opt.BakeDir), why)
opt.BakeDir = ""
}
}
if opt.BakeDir == "" {
// No bake: the painting is all there is.
//
// Worth supporting rather than refusing, because the first thing an author wants after drawing a
// world is to see something placed on it, and the bake is two hours away. What survives without a
// solve is everything the *painting* knows - where the land is, where the sea is, and which class
// each cell was painted - so the coast still shapes where towns go and the class filters still keep
// woodland off the ice. What is lost is everything the terrain knows: there are no rivers to sit on,
// no slope to avoid, and therefore no reason for a road to bend. Say which of the two ran; a draft
// made this way is a sketch, and reading it as the other is how somebody concludes the generator
// ignores the terrain.
log("no bake: generating from the painting alone, so there are no rivers and no slope to read")
gin := flatInputs(in, ow, oh, cellM, opt.Replace)
opt.apply(&gin)
return in.Overlay.Generate(gin)
}
if err := CheckBake(opt.BakeDir, in.M, log); err != nil {
return nil, none, err
}
hpath := filepath.Join(opt.BakeDir, "planet_height.png")
values, gw, gh, err := field.ReadHeightmap(hpath, 0)
if err != nil {
return nil, none, fmt.Errorf("%s: %w (run `terrain bake` first)", hpath, err)
}
if gw != in.P.W || gh != in.P.PaintH() {
return nil, none, fmt.Errorf("%s is %dx%d but the manifest describes a %dx%d planet; the bake and "+
"the manifest have drifted apart", hpath, gw, gh, in.P.W, in.P.PaintH())
}
geology := in.M.Decode(values)
log("read %s: %d x %d at %.1f m", filepath.Base(hpath), gw, gh, in.P.CellM)
heightM := poolTo(geology, gw, gh, ow, oh)
sea := make([]bool, ow*oh)
seaCells := 0
for i, v := range heightM {
if float64(v) < in.M.SeaLevelM {
sea[i] = true
seaCells++
}
}
log("overlay grid %d x %d at %.1f m a pixel, %.0f%% sea",
ow, oh, cellM, 100*float64(seaCells)/float64(ow*oh))
flow := drainage(heightM, sea, ow, oh, cellM, in.M.Pipeline.Fluvial.MFDExponent)
// The class each overlay cell was painted, so a mark can be kept off ground its author called ice or
// desert. Nearest-sampled rather than averaged: a class is a name, and the mean of two names is not one.
classAt := make([]uint8, ow*oh)
for y := 0; y < oh; y++ {
sy := y*gh/oh + in.P.PadY
for x := 0; x < ow; x++ {
classAt[y*ow+x] = in.Map.Class[sy*in.P.W+x*gw/ow]
}
}
names := make([]string, len(in.Legend.Classes))
for i, c := range in.Legend.Classes {
names[i] = c.Name
}
gin := overlay.GenInputs{
W: ow, H: oh, CellM: cellM,
HeightM: heightM, Sea: sea, FlowM2: flow,
ClassAt: classAt, ClassNames: names,
Seed: in.M.Source.Seed,
}
if !opt.Replace {
gin.Existing = in.OverlayRaster
}
opt.apply(&gin)
return in.Overlay.Generate(gin)
}
// apply puts the caller's overrides onto the inputs, after the world has been read.
func (opt OverlayGenOptions) apply(gin *overlay.GenInputs) {
if opt.Seed != 0 {
gin.Seed = opt.Seed
}
if opt.Existing != nil {
gin.Existing = opt.Existing
}
}
// poolTo box-averages a field onto a smaller grid. The two grids cover exactly the same painted rows, so
// this is a straight ratio in each axis with no registration to work out.
//
// Averaging rather than sampling, because a single sample of an 8 m grid at 12.9 m spacing would alias every
// ridge it stepped over and put woodland in stripes.
func poolTo(src []float32, sw, sh, dw, dh int) []float32 {
out := make([]float32, dw*dh)
field.Rows(dh, func(y0, y1 int) {
for dy := y0; dy < y1; dy++ {
sy0 := dy * sh / dh
sy1 := (dy + 1) * sh / dh
if sy1 <= sy0 {
sy1 = sy0 + 1
}
for dx := 0; dx < dw; dx++ {
sx0 := dx * sw / dw
sx1 := (dx + 1) * sw / dw
if sx1 <= sx0 {
sx1 = sx0 + 1
}
sum, n := 0.0, 0
for sy := sy0; sy < sy1 && sy < sh; sy++ {
row := sy * sw
for sx := sx0; sx < sx1 && sx < sw; sx++ {
sum += float64(src[row+sx])
n++
}
}
if n > 0 {
out[dy*dw+dx] = float32(sum / float64(n))
}
}
}
})
return out
}
// drainage is the catchment area per cell at the overlay's resolution, which is what tells a settlement
// where the water is.
//
// Recomputed rather than read from the bake: `map_flow.png` is a picture a few hundred pixels wide, scaled
// for looking at, and what is wanted here is a number per overlay cell. Re-deriving it from the pooled
// height is cheap - one fill and one multiple-flow accumulation - and it is self-consistent with the slope
// and the sea mask beside it, which a resampled flow map would not be.
func drainage(heightM []float32, sea []bool, w, h int, cellM, mfdExp float64) []float32 {
g := fluvial.NewGrid(w, h, cellM, sea)
// The fill runs on a copy: it raises every pit to its spill level, which is right for routing water and
// wrong for everything else here. Slope and the treeline must see the surface the bake actually made.
filled := make([]float32, len(heightM))
copy(filled, heightM)
g.FillDepressions(filled, 1e-3)
g.AccumulateMFD(filled, mfdExp)
out := make([]float32, len(heightM))
copy(out, g.Area)
return out
}
// OverlaySummary is the run's report, as lines to print.
func OverlaySummary(rep overlay.GenReport, ow, oh int) []string {
var lines []string
total := float64(ow * oh)
if rep.Kept > 0 {
lines = append(lines, fmt.Sprintf("kept %d px already painted (%.2f%% of the sheet); "+
"generation only fills blank ground", rep.Kept, 100*float64(rep.Kept)/total))
}
if rep.TreelineM > 0 {
lines = append(lines, fmt.Sprintf("treeline %.0f m, from the land's own heights", rep.TreelineM))
}
for _, m := range rep.Marks {
switch m.Kind {
case overlay.GenSettlement:
line := fmt.Sprintf(" %-14s %d placed, %d px", m.Name, m.Pieces, m.Cells)
if m.Wanted > m.Pieces {
// The two things that ration settlements are the spacing and how much flat ground there is,
// and neither is visible in the output, so the shortfall is said here rather than left to be
// counted off the sheet.
line += fmt.Sprintf(" (asked for %d; the spacing or the flat ground ran out)", m.Wanted)
}
lines = append(lines, line)
case overlay.GenRoad:
lines = append(lines, fmt.Sprintf(" %-14s %d %s, %d px",
m.Name, m.Pieces, plural(m.Pieces, "link", "links"), m.Cells))
default:
lines = append(lines, fmt.Sprintf(" %-14s %d px (%.2f%% of the sheet)",
m.Name, m.Cells, 100*float64(m.Cells)/total))
}
}
lines = append(lines, fmt.Sprintf("painted %d px (%.2f%% of the sheet)",
rep.Painted, 100*float64(rep.Painted)/total))
return lines
}
func plural(n int, one, many string) string {
if n == 1 {
return one
}
return many
}
// flatInputs builds the generator's world from the painting alone, for a planet that has not been baked.
//
// Height is a flat plateau on land and a flat floor at sea, which makes the slope field zero everywhere and
// the treeline meaningless - both correct rather than approximate, because a world with no solve genuinely
// has no relief to read. Drainage is nil rather than zero, which the generator treats as "rivers contribute
// nothing" instead of "every cell is equally dry"; the difference matters, because a score of zero
// everywhere would still be a score and would silently reweight the coast against it.
func flatInputs(in *Inputs, ow, oh int, cellM float64, replace bool) overlay.GenInputs {
n := ow * oh
heightM := make([]float32, n)
sea := make([]bool, n)
classAt := make([]uint8, n)
// The class raster is already at the template's resolution, which is the overlay's, so this is a direct
// read with no resampling at all.
for i := 0; i < n && i < len(in.Raster.Class); i++ {
c := in.Raster.Class[i]
classAt[i] = c
if int(c) < len(in.Legend.Classes) && in.Legend.Classes[c].Sea {
sea[i] = true
heightM[i] = float32(in.M.SeaLevelM - 100)
} else {
heightM[i] = float32(in.M.SeaLevelM + 10)
}
}
names := make([]string, len(in.Legend.Classes))
for i, c := range in.Legend.Classes {
names[i] = c.Name
}
gin := overlay.GenInputs{
W: ow, H: oh, CellM: cellM,
HeightM: heightM, Sea: sea,
ClassAt: classAt, ClassNames: names,
Seed: in.M.Source.Seed,
}
if !replace {
gin.Existing = in.OverlayRaster
}
return gin
}
// bakeTemplate is the template a bake was made from, or "" when the bake does not record one.
//
// It exists because CheckBake cannot catch this. That check compares the numbers a heightmap is *encoded*
// with - the elevation range, the circumference, the cell size, the seed - and two different paintings of
// the same planet agree on every one of them. So a bake of one world passes every test and is then read as
// another, and the marks come out placed against terrain that is not there: rivers in the wrong valleys,
// towns on coasts that do not exist. Nothing in the output says so, which is what makes it worth a check of
// its own rather than a note in a README.
func bakeTemplate(dir string) string {
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
if err != nil {
return ""
}
var meta struct {
Plan struct {
Template string `json:"template"`
} `json:"plan"`
}
if json.Unmarshal(raw, &meta) != nil {
return ""
}
return meta.Plan.Template
}
// BakeIsOfThisPainting reports whether a bake was made from the template the manifest now names, and why not
// when it was not. A bake that does not record its template is taken on trust, because it predates the
// field; that is said rather than assumed.
func BakeIsOfThisPainting(dir string, m *manifest.Manifest) (bool, string) {
was := bakeTemplate(dir)
if was == "" {
return true, ""
}
if filepath.Base(was) == filepath.Base(m.Planet.Template) {
return true, ""
}
return false, fmt.Sprintf("%s was baked from %s and this planet is painted on %s",
filepath.Base(dir), filepath.Base(was), filepath.Base(m.Planet.Template))
}
+561
View File
@@ -0,0 +1,561 @@
package planet
import (
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"sort"
"time"
"salty/terrain/internal/manifest"
"salty/terrain/internal/overlay"
)
// Plan reads a template and works out what baking it would involve, without eroding anything.
//
// It exists because two decisions can wreck an hour-long bake and both are settled before the first erosion
// step: how the legend read the painting, and how the planet was cut into regions. Looking at them costs
// about a minute here and an hour if the bake has to be thrown away.
func Plan(m *manifest.Manifest, outDir string, mapWidth int, log func(string, ...any)) (*Inputs, error) {
return PlanPainting(m, nil, outDir, mapWidth, log)
}
// WriteMaps draws the four diagnostic maps. Separate from Plan because a caller that already has an Inputs
// may want to redraw them without preparing again: the studio does, when only the legend's numbers changed
// and so only the colouring-in can differ.
func WriteMaps(outDir string, in *Inputs, mapWidth int) error {
if err := os.MkdirAll(outDir, 0o755); err != nil {
return err
}
for _, w := range []func(string, *Inputs, int) error{
WriteClassMap, WriteRegionMap, WriteUpliftMap, WriteErodibilityMap, WriteOverlayMap, WritePlateMap,
} {
if err := w(outDir, in, mapWidth); err != nil {
return err
}
}
return nil
}
// MapNames is what WriteMaps wrote, which is what the studio lists as buttons. The overlay map is there only
// when there is an overlay, because a map of nothing is a map nobody should be offered.
func (in *Inputs) MapNames() []string {
out := []string{"map_class", "map_uplift", "map_regions", "map_erodibility"}
if in.OverlayRaster != nil {
out = append(out, "map_overlay")
}
if in.Plates != nil {
out = append(out, "map_plates")
}
return out
}
// PlanPainting is Plan over paintings already in memory. See PrepareWith.
func PlanPainting(m *manifest.Manifest, art *Painting, outDir string, mapWidth int,
log func(string, ...any)) (*Inputs, error) {
in, err := PrepareWith(m, art, log)
if err != nil {
return nil, err
}
if err := WriteMaps(outDir, in, mapWidth); err != nil {
return nil, err
}
if in.OverlayDoc != nil {
if err := in.OverlayDoc.WriteJSON(outDir); err != nil {
return nil, err
}
}
rep := in.Report()
data, err := json.MarshalIndent(rep, "", " ")
if err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(outDir, "plan.json"), append(data, '\n'), 0o644); err != nil {
return nil, err
}
return in, nil
}
// Report is the machine-readable half of a plan, written to plan.json beside the maps.
type Report struct {
Manifest string `json:"manifest"`
Template string `json:"template"`
Legend string `json:"legend"`
When time.Time `json:"when"`
PaintW int `json:"paint_w"`
PaintH int `json:"paint_h"`
CircumferenceKm float64 `json:"circumference_km"`
HeightKm float64 `json:"height_km"`
AreaKm2 float64 `json:"area_km2"`
CellM float64 `json:"cell_m"`
GridW int `json:"grid_w"`
GridH int `json:"grid_h"`
PadRows int `json:"pad_rows"`
MarginCells int `json:"margin_cells"`
TalusDeg float64 `json:"talus_deg"`
ClampCeilMmYr float64 `json:"clamp_ceiling_mm_yr"`
ElevationMinM float64 `json:"elevation_min_m"`
ElevationMaxM float64 `json:"elevation_max_m"`
Seed int64 `json:"seed"`
// MassifWavelengthKm is the upland fabric's size after rounding to a whole number of lattice cells, and
// zero when no class asked for one.
MassifWavelengthKm float64 `json:"massif_wavelength_km,omitempty"`
// The two fields a seed re-rolls that the painting does not fix: the rock provinces and the fault set.
// Zero wavelength means the planet asks for none of that field at all.
LithologyKm float64 `json:"lithology_wavelength_km,omitempty"`
LithologyTypes int `json:"lithology_types,omitempty"`
FaultGrainKm float64 `json:"fault_grain_km,omitempty"`
FaultCount int `json:"faults,omitempty"`
MatchFar int `json:"match_far_px"`
MatchWorst float64 `json:"match_worst_distance"`
MatchWorstAt [2]int `json:"match_worst_at"`
EdgeRescued int `json:"stroke_rescued_at_poles_px"`
Dissolved int `json:"stroke_dissolved_px"`
WrapRows int `json:"wrap_rows"`
WrapDiffer int `json:"wrap_differ"`
WrapLandSea int `json:"wrap_land_against_sea"`
WrapFarEdge int `json:"wrap_far_edge_px"`
Classes []ClassShare `json:"classes"`
Regions []RegionPlan `json:"regions"`
// Overlay is the annotation layer's share of the plan, or nil when the planet has none. The full
// document - every feature in world metres - goes to overlay.json beside the maps; this is the summary.
Overlay *OverlayShare `json:"overlay,omitempty"`
LandCells int `json:"land_cells"`
SolveCells int `json:"solve_cells"`
DroppedRegions int `json:"dropped_regions"`
DroppedCells int `json:"dropped_cells"`
EstimateMin float64 `json:"estimate_minutes"`
EstimatePeakGB float64 `json:"estimate_peak_gb"`
PrepareSeconds float64 `json:"prepare_seconds"`
}
type ClassShare struct {
Name string `json:"name"`
Sea bool `json:"sea"`
Cells int `json:"cells"`
Share float64 `json:"share"`
UpliftMmYr float64 `json:"uplift_mm_yr,omitempty"`
KMult float64 `json:"k_mult,omitempty"`
DepthM float64 `json:"depth_m,omitempty"`
// DivideDeg is the hillslope angle this class's numbers imply at a divide, and Clamped says whether
// that is past the angle of repose. See the note on divideAngle.
DivideDeg float64 `json:"divide_deg,omitempty"`
Clamped bool `json:"clamped,omitempty"`
// MedianDeg and P90Deg are what the ground actually comes out as: the median slope over the class and
// the ninetieth percentile. See typicalFromDivide - a divide is the *steepest* place in a catchment and
// there are very few of them, so the divide angle is about three times the ground, and an author reading
// it as the landscape sets every rate they own two or three times too hot.
MedianDeg float64 `json:"median_deg,omitempty"`
P90Deg float64 `json:"p90_deg,omitempty"`
// ReadsAs names the ground that angle makes. It is here because an uplift rate does not look like
// anything, and reading one as terrain is the mistake the massif field exists to undo. It is taken from
// the median rather than from the divide, because "what does this read as" is a question about the
// ground somebody is standing on.
ReadsAs string `json:"reads_as,omitempty"`
// The massif block, when this class has one: the plain between the massifs, the angle *it* makes, and
// how much of the class stands above the midpoint of the two. Absent for a class that is one rate all
// over, which is what every class was before D-55.
FloorMmYr float64 `json:"floor_mm_yr,omitempty"`
FloorDeg float64 `json:"floor_divide_deg,omitempty"`
FloorMedianDeg float64 `json:"floor_median_deg,omitempty"`
FloorReadsAs string `json:"floor_reads_as,omitempty"`
MassifFraction float64 `json:"massif_fraction,omitempty"`
// Faults is how many traces landed in this class's ground, and FaultThrowM the range they were drawn
// from. Zero when the class asked for none.
Faults int `json:"faults,omitempty"`
FaultThrowM [2]float64 `json:"fault_throw_m,omitempty"`
// LithologyMix is how much of the planet's rock field this class lets through. Reported even at 1, which
// is the default, because the useful reading is the column rather than one entry in it.
LithologyMix float64 `json:"lithology_mix,omitempty"`
}
// OverlayShare is how much of the world the annotation layer covers and what it asked for.
type OverlayShare struct {
Legend string `json:"legend"`
PaintedPx int `json:"painted_px"`
FarPx int `json:"far_px"`
Features int `json:"features"`
Marks []overlay.MarkShare `json:"marks"`
}
type RegionPlan struct {
ID int `json:"id"`
X0 int `json:"x0"`
Y0 int `json:"y0"`
W int `json:"w"`
H int `json:"h"`
WidthKm float64 `json:"width_km"`
HeightKm float64 `json:"height_km"`
Cells int `json:"cells"`
LandCells int `json:"land_cells"`
Seam bool `json:"seam"`
EstimateMin float64 `json:"estimate_minutes"`
EstimateGB float64 `json:"estimate_gb"`
}
// Report gathers everything the plan knows.
func (in *Inputs) Report() *Report {
perClass, land, total := in.Map.Counts()
r := &Report{
Manifest: in.M.Path, Template: in.M.Planet.Template, Legend: in.M.Planet.Legend,
When: time.Now().UTC().Truncate(time.Second),
PaintW: in.PaintW, PaintH: in.PaintH,
CircumferenceKm: in.P.CircumferenceM() / 1000,
HeightKm: in.P.HeightM() / 1000,
AreaKm2: in.P.CircumferenceM() * in.P.HeightM() / 1e6,
CellM: in.P.CellM,
GridW: in.P.W, GridH: in.P.PaintH(),
PadRows: in.P.PadY,
MarginCells: in.MarginCells,
TalusDeg: in.M.Pipeline.Thermal.TalusDeg,
ClampCeilMmYr: clampCeiling(in.M.Pipeline.Fluvial.K, in.P.CellM,
in.M.Pipeline.Fluvial.M, in.M.Pipeline.Thermal.TalusDeg),
ElevationMinM: in.M.ElevationM.Min,
ElevationMaxM: in.M.ElevationM.Max,
Seed: in.M.Source.Seed,
MatchFar: in.Match.Far, MatchWorst: in.Match.MaxDist, MatchWorstAt: in.Match.MaxAt,
EdgeRescued: in.EdgeRewritten, Dissolved: in.Dissolved,
WrapRows: in.Match.WrapRows, WrapDiffer: in.Match.WrapDiffer,
WrapLandSea: in.Match.WrapLandSea, WrapFarEdge: in.Match.WrapFarEdge,
LandCells: land,
SolveCells: in.SolveCells(),
DroppedRegions: in.Part.DroppedRegions,
DroppedCells: in.Part.DroppedCells,
PrepareSeconds: in.Elapsed.Seconds(),
}
if in.Legend.HasMassifs() {
r.MassifWavelengthKm = in.M.Planet.MassifWavelengthRoundedKm()
}
for i, c := range in.Legend.Classes {
if perClass[i] == 0 {
continue
}
cs := ClassShare{Name: c.Name, Sea: c.Sea, Cells: perClass[i],
Share: float64(perClass[i]) / float64(total)}
if c.Land() {
cs.UpliftMmYr, cs.KMult = c.UpliftMmYr, c.K()
cs.DivideDeg = divideAngle(c.RateMYr(), in.M.Pipeline.Fluvial.K*c.K(),
in.P.CellM, in.M.Pipeline.Fluvial.M)
cs.Clamped = cs.DivideDeg >= in.M.Pipeline.Thermal.TalusDeg
cs.MedianDeg, cs.P90Deg = typicalFromDivide(cs.DivideDeg)
cs.ReadsAs = readsAs(cs.MedianDeg)
if fr := c.MassifFraction(); fr > 0 {
cs.MassifFraction = fr
cs.FloorMmYr = c.Massif.FloorMmYr
cs.FloorDeg = divideAngle(c.MassifFloorMYr(), in.M.Pipeline.Fluvial.K*c.K(),
in.P.CellM, in.M.Pipeline.Fluvial.M)
cs.FloorMedianDeg, _ = typicalFromDivide(cs.FloorDeg)
cs.FloorReadsAs = readsAs(cs.FloorMedianDeg)
}
cs.LithologyMix = c.LithMix()
if c.Faults != nil {
cs.FaultThrowM = c.ThrowM()
for _, f := range in.Faults {
if f.Class == i {
cs.Faults++
}
}
}
} else {
cs.DepthM = c.DepthM
}
r.Classes = append(r.Classes, cs)
}
r.FaultGrainKm = in.M.Planet.FaultGrainKm
r.FaultCount = len(in.Faults)
if cells := in.M.Planet.LithologyCells(); cells > 0 && in.Legend.HasLithology() {
r.LithologyKm = in.M.Planet.NoisePeriodKm / float64(cells)
r.LithologyTypes = len(in.M.Pipeline.Lithology.KMultipliers)
}
if d := in.OverlayDoc; d != nil {
r.Overlay = &OverlayShare{
Legend: in.M.Planet.OverlayLegend, PaintedPx: in.OverlayMatch.Total - in.OverlayMatch.Blank,
FarPx: in.OverlayMatch.Far, Features: len(d.Features), Marks: d.Marks,
}
}
peak := 0.0
for _, rg := range in.Part.Regions {
mins := in.EstimateSeconds(rg.Cells()) / 60
gb := float64(in.EstimateBytes(rg.Cells())) / (1 << 30)
if gb > peak {
peak = gb
}
r.EstimateMin += mins
r.Regions = append(r.Regions, RegionPlan{
ID: rg.ID, X0: rg.Frame.X0, Y0: rg.Frame.Y0, W: rg.Frame.W, H: rg.Frame.H,
WidthKm: float64(rg.Frame.W) * in.P.CellM / 1000,
HeightKm: float64(rg.Frame.H) * in.P.CellM / 1000,
Cells: rg.Cells(), LandCells: rg.LandCells, Seam: rg.Seam,
EstimateMin: mins, EstimateGB: gb,
})
}
r.EstimatePeakGB = peak
sort.Slice(r.Regions, func(a, b int) bool { return r.Regions[a].Cells > r.Regions[b].Cells })
return r
}
// Print is the human half: the two tables worth reading before spending an hour.
func (r *Report) Print(w *os.File) {
p := func(format string, a ...any) { fmt.Fprintf(w, format+"\n", a...) }
p("")
p(" planet %.1f x %.1f km, %.0f km2 - %d x %d cells of %.1f m (+%d rows of polar pad)",
r.CircumferenceKm, r.HeightKm, r.AreaKm2, r.GridW, r.GridH, r.CellM, r.PadRows)
p(" template %d x %d px, %.2f m a pixel - the paint is %s than the grid",
r.PaintW, r.PaintH, r.CircumferenceKm*1000/float64(r.PaintW),
coarserOrFiner(r.CircumferenceKm*1000/float64(r.PaintW), r.CellM))
p(" classify %d px further than the warn distance from any class (worst %.0f at %d,%d)",
r.MatchFar, r.MatchWorst, r.MatchWorstAt[0], r.MatchWorstAt[1])
p(" %d px rescued as map-edge class, %d px of stroke dissolved",
r.EdgeRescued, r.Dissolved)
if r.WrapRows > 0 {
pct := 100 * float64(r.WrapDiffer) / float64(r.WrapRows)
p(" wrap the left and right edges are the same meridian: they disagree on %d of %d rows (%.1f%%),",
r.WrapDiffer, r.WrapRows, pct)
p(" %d of those land against water, and %d px in the outermost columns match no class.",
r.WrapLandSea, r.WrapFarEdge)
if r.WrapLandSea > r.WrapRows/50 {
p(" THAT IS A VISIBLE SEAM. The generator wraps; the painting has to as well.")
}
}
p("")
p(" class share cells uplift mm/yr K depth m divide typical")
clamped := 0
for _, c := range r.Classes {
if c.Sea {
p(" %-12s %5.1f%% %10d - - %7.0f", c.Name, 100*c.Share, c.Cells, c.DepthM)
continue
}
note := ""
if c.Clamped {
note = " CLAMPED"
clamped++
}
p(" %-12s %5.1f%% %10d %8.3f %4.2f - %5.1f deg %6.1f deg %s%s",
c.Name, 100*c.Share, c.Cells, c.UpliftMmYr, c.KMult, c.DivideDeg, c.MedianDeg, c.ReadsAs, note)
if c.MassifFraction > 0 {
p(" %-12s %s", "",
fmt.Sprintf("massif over %.0f%% of it; the other %.0f%% is %.3f mm/yr, %.1f deg at a divide "+
"and %.1f typical - %s",
100*c.MassifFraction, 100*(1-c.MassifFraction), c.FloorMmYr, c.FloorDeg,
c.FloorMedianDeg, c.FloorReadsAs))
}
}
if clamped > 0 {
p("")
p(" %d class(es) sit past the %.0f degree angle of repose at a divide, so the repose clamp shapes",
clamped, r.TalusDeg)
p(" them rather than erosion does, and the ground comes out as flat polygonal facets cut along the")
p(" eight D8 directions. Steady state is S = U/(K*A^m) applied down to a single cell, so at a %.0f m",
r.CellM)
p(" cell the ceiling is U = tan(talus)*K*cell = %.3f mm/yr at K x1. Above it, relief and steepness", r.ClampCeilMmYr)
p(" are the same knob and you get talus, not mountains. See Terrain-Next 4.B1 and 4.D.3.")
}
p("")
p(" `divide` is the steepest ground a rate can make and `typical` is the median over the class, which")
p(" is about a third of it: a divide is the top of a catchment and there are very few of them. Read the")
p(" second column. Reading the first as the landscape is how a legend ends up two or three times too")
p(" hot everywhere, which is the defect the massif block was added to undo one size up.")
if r.FaultCount > 0 || r.LithologyKm > 0 {
p("")
p(" what the seed re-rolls, and the painting does not")
if r.LithologyKm > 0 {
p(" lithology %d rock types over provinces of %.1f km, cut at quantiles of the *planet* so "+
"every", r.LithologyTypes, r.LithologyKm)
p(" region agrees; it multiplies each class's own k_mult by its lithology_mix")
}
if r.FaultCount > 0 {
p(" faults %d traces, strike from a %.0f km grain field. A trace is a rate difference "+
"across a", r.FaultCount, r.FaultGrainKm)
p(" line, steep one side and gentle the other, which erosion carves into a scarp")
for _, c := range r.Classes {
if c.Faults > 0 {
word := "traces"
if c.Faults == 1 {
word = "trace"
}
p(" %-12s %4d %-7s throw %.0f..%.0f m over the run",
c.Name, c.Faults, word+",", c.FaultThrowM[0], c.FaultThrowM[1])
}
}
}
p(" change source.seed, or pass --seed, and all of it moves while the painting stays put")
}
if r.MassifWavelengthKm > 0 {
p("")
p(" the massif fabric is %.1f km and is one field for the whole planet, so a highland belt and the",
r.MassifWavelengthKm)
p(" hills in the lowland beside it are high and low parts of the same structure. A fraction is a")
p(" share of the planet's surface, so it is only the *expected* share of any one island: a small one")
p(" may get all of a massif or none, which is the point of not normalising it per landmass.")
}
if o := r.Overlay; o != nil {
p("")
p(" overlay %s: %d px painted, %d features", o.Legend, o.PaintedPx, o.Features)
if o.FarPx > 0 {
p(" %d px are painted but match no mark and were dropped", o.FarPx)
}
for _, m := range o.Marks {
line := fmt.Sprintf(" %-12s %8.2f km2 %4d %s", m.Name, m.AreaKm2, m.Pieces,
pieces(m.Pieces))
if m.HasJitter {
if m.Jitter == 0 {
line += " coastline pinned as drawn"
} else {
line += fmt.Sprintf(" coast jitter x%.2g", m.Jitter)
}
}
if m.Kind == overlay.KindPath && m.WidthM > 0 {
line += fmt.Sprintf(" %.0f m wide", m.WidthM)
}
p("%s", line)
}
}
p("")
p(" %d regions, %d cells to solve against %d cells of painted land; margin %d cells",
len(r.Regions), r.SolveCells, r.LandCells, r.MarginCells)
if r.DroppedRegions > 0 {
p(" %d specks dropped, %d land cells, below the minimum", r.DroppedRegions, r.DroppedCells)
}
p("")
p(" id rect km cells land est min est GB")
shown := r.Regions
if len(shown) > 12 {
shown = shown[:12]
}
for _, rg := range shown {
seam := " "
if rg.Seam {
seam = "*"
}
p(" %3d%s %6.1f x %6.1f %10d %10d %8.1f %6.2f",
rg.ID, seam, rg.WidthKm, rg.HeightKm, rg.Cells, rg.LandCells, rg.EstimateMin, rg.EstimateGB)
}
if len(r.Regions) > len(shown) {
p(" ... and %d smaller", len(r.Regions)-len(shown))
}
p("")
p(" estimate %.0f min of solve in total, %.2f GB at the largest region, both scaled from one measured",
r.EstimateMin, r.EstimatePeakGB)
p(" lowland region and to be read as a floor: steep ground costs about five times what a plain")
p(" does per cell, because it drives the hillslope law to its full sub-step budget every step.")
p(" prepared in %.1f s", r.PrepareSeconds)
p("")
}
func coarserOrFiner(paintM, cellM float64) string {
if paintM > cellM {
return "coarser"
}
return "finer"
}
// divideAngle is the hillslope angle a class's numbers imply at a drainage divide, in degrees.
//
// Steady state is S = U/(K*A^m), and with critical_area_m2 at 0 that law is applied down to a single cell, so
// at a divide A is one cell squared and A^m is just the cell size. For n = 1 the uplift rate alone therefore
// fixes the hillslope angle - that is D-49, and it is the most useful number in the whole legend, because it
// decides whether the ground is shaped by erosion or by landsliding.
func divideAngle(rateMYr, k, cellM, m float64) float64 {
if k <= 0 || cellM <= 0 {
return 0
}
s := rateMYr / (k * math.Pow(cellM*cellM, m))
return math.Atan(s) * 180 / math.Pi
}
// typicalMedianFrac and typicalP90Frac turn a divide angle into the ground underneath it.
//
// The divide angle is exact and it is not the landscape. S = U/(K*A^m) is largest where A is smallest, which
// is the top of a catchment; slope falls away downstream from there, and almost none of a map is divide. So
// the number the legend hands an author is the steepest place in their world and they read it as the world.
//
// Measured rather than derived, on a 600 x 600 grid of 8 m cells with one coast, the manifest's own
// constants, 1000 steps, and a uniform rate:
//
// U mm/yr divide median P90 over 3 deg
// 0.012 1.7 0.58 1.00 4 %
// 0.045 6.4 2.12 2.85 8 %
// 0.080 11.3 3.72 4.85 75 %
// 0.250 32.0 11.13 14.03 99 %
//
// In tangent the median/divide ratio is 0.34, 0.33, 0.33 and 0.32 - flat enough over a factor of twenty in
// rate to be worth quoting as one number. The P90 ratio drifts from 0.59 to 0.40 as the ground steepens,
// because the tail of the slope distribution is the part the repose clamp eventually binds; 0.45 is the
// middle of it and it is the weaker of the two.
//
// Both are fractions of the *tangent*, not of the angle, because the steady-state law is about slope.
const (
typicalMedianFrac = 0.33
typicalP90Frac = 0.45
)
// typicalFromDivide is the median and P90 slope, in degrees, for a class whose divide angle is deg.
func typicalFromDivide(deg float64) (median, p90 float64) {
t := math.Tan(deg * math.Pi / 180)
return math.Atan(t*typicalMedianFrac) * 180 / math.Pi,
math.Atan(t*typicalP90Frac) * 180 / math.Pi
}
func pieces(n int) string {
if n == 1 {
return "piece"
}
return "pieces"
}
// readsAs names the ground a divide angle makes.
//
// It exists because an uplift rate does not look like anything, and the one number an author has to hand is
// therefore the one they cannot picture. The boundaries are angles rather than rates deliberately: the mistake
// this is here to stop is reading internal/stats' "plain below 0.1 mm/yr" as a description of terrain. It is
// not - it is a reporting bucket calibrated for the procedural path's intraplate rates - and 0.1 mm/yr is a 14
// degree hillslope on every divide of the map, which is hill country wherever it is painted.
func readsAs(deg float64) string {
switch {
case deg < 3:
return "plain"
case deg < 8:
return "rolling"
case deg < 16:
return "hill country"
case deg < 28:
return "mountain"
default:
return "alpine"
}
}
// clampCeiling is the uplift rate, in mm/yr, at which a divide reaches the angle of repose at K x1. Above it
// the repose clamp does the shaping and the terrain comes out as flat polygonal facets.
func clampCeiling(k, cellM, m, talusDeg float64) float64 {
return math.Tan(talusDeg*math.Pi/180) * k * math.Pow(cellM*cellM, m) * 1000
}
+561
View File
@@ -0,0 +1,561 @@
// Package planet is the driver for a painted world: template in, terrain out.
//
// It owns the order of operations and nothing else. The image and its legend belong to internal/template,
// the cylinder to internal/world, the cutting up to internal/region, and every physical process to the
// packages that already had it. What lives here is the sequence, the reporting, and the two things that are
// only true of a whole planet: that its open ocean is painted rather than solved, and that its statistics
// pool across regions rather than being computed per region and averaged.
package planet
import (
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"time"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/overlay"
"salty/terrain/internal/plates"
"salty/terrain/internal/region"
"salty/terrain/internal/template"
"salty/terrain/internal/uplift"
"salty/terrain/internal/world"
)
// Inputs is everything a bake needs before a single erosion step has run: the painted map classified and
// projected onto the cylinder, and the cylinder cut into regions.
//
// It is a type of its own because it is worth looking at on its own. Two decisions can wreck an hour-long
// bake - how the legend read the paint, and how the planet was cut up - and both are settled here, in about
// a minute. That is what the plan command exists to show.
type Inputs struct {
M *manifest.Manifest
P world.Planet
Legend *template.Legend
Raster *template.Raster // at paint resolution
Map *template.Map // at planet resolution
Part *region.Partition
// Palette is how the preview is drawn. Never nil: the generator's own when the manifest names none.
Palette *field.Palette
PaintW, PaintH int
Match template.Match
EdgeRewritten int
Dissolved int
MarginCells int
Elapsed time.Duration
// The annotation layer, or nil throughout when the manifest configures none. It is prepared here rather
// than at export time for one reason: its coast_jitter marks have to be read *before* the waterline is
// roughened, which is the first thing that happens to the painting, so by the time a plan exists the
// overlay has already had its say. Everything else it carries is inert - see internal/overlay.
Overlay *overlay.Legend
OverlayRaster *overlay.Raster
OverlayMatch overlay.Match
OverlayDoc *overlay.Document
// Faults is the planet's whole fault set, in world metres, drawn once here because placing a trace needs
// the class raster of the *whole* cylinder - and because a set drawn per region would put a different
// fault in every one of them, which is the defect that kept the procedural path's version from being
// portable at all. Every region reads the same slice and filters it to its own frame.
Faults []uplift.FaultTrace
// Plates is the tectonic model, or nil when the manifest asks for none. Drawn here for the same reason
// the fault set is: a plate is a planet-wide object, and a partition computed per region would give the
// same physical margin a different classification in every one of them.
Plates *plates.Model
}
// Painting is the two images already in memory, which is what the studio has: the pictures being edited are
// the ones in the browser, so a plan run against the files on disk would answer a question nobody asked.
//
// A nil Painting, or a nil half of one, means "read what the manifest names". The two halves are separate
// because they are edited separately: moving a road does not re-roughen the coast unless a coast_jitter mark
// moved with it, and the studio's plan cache keys on them one at a time.
type Painting struct {
Class []uint8
ClassW, ClassH int
Overlay []uint8
OverlayAlpha []uint8
OverlayW, OverlayH int
// The tectonic layer, RGB with no alpha: every pixel of it is some plate, so there is no "nothing" to
// carry. Like the other two it is the authority while the studio is open.
Plates []uint8
PlatesW, PlatesH int
}
// Prepare reads the template, classifies it, projects it onto the planet and partitions it.
//
// Nothing here erodes anything, and nothing here is expensive: the whole thing is a few image passes and
// three distance transforms.
func Prepare(m *manifest.Manifest, log func(string, ...any)) (*Inputs, error) {
return PrepareWith(m, nil, log)
}
// PrepareWith is Prepare over paintings already in memory. See Painting; nil reads what the manifest names,
// which is what Prepare does.
func PrepareWith(m *manifest.Manifest, art *Painting, log func(string, ...any)) (*Inputs, error) {
if !m.IsPlanet() {
return nil, fmt.Errorf("%s has no planet block; this is the square canvas that `generate` builds",
m.Path)
}
start := time.Now()
if log == nil {
log = func(string, ...any) {}
}
pb := m.Planet
cell := m.GeologyCellM()
lg, err := template.Load(m.LegendPath())
if err != nil {
return nil, err
}
log("legend %s: %d classes", m.Planet.Legend, len(lg.Classes))
var px []uint8
var pw, ph int
if art != nil {
px, pw, ph = art.Class, art.ClassW, art.ClassH
}
if px == nil {
var err error
px, pw, ph, err = template.DecodeRGB(m.TemplatePath())
if err != nil {
return nil, err
}
log("template %s: %d x %d px", pb.Template, pw, ph)
} else {
log("template in memory: %d x %d px", pw, ph)
}
ras, match := lg.Classify(px, pw, ph)
px = nil
edge, dissolved := ras.DissolveStrokes(lg)
speckle := ras.Despeckle()
log("classify %s; %d px rescued at the poles, %d dissolved, %d despeckled",
match, edge, dissolved, speckle)
marginCells := pb.MarginCells(cell)
p, err := world.New(pb.CircumferenceKm*1000, cell, pw, ph, marginCells, pb.NoisePeriodKm*1000)
if err != nil {
return nil, err
}
log("planet %d x %d cells of %.1f m: %.1f x %.1f km, %.0f km2 (+%d rows of polar pad)",
p.W, p.PaintH(), p.CellM, p.CircumferenceM()/1000, p.HeightM()/1000,
p.CircumferenceM()*p.HeightM()/1e6, p.PadY)
padClass := lg.FirstSea()
if pb.PadClass != "" {
padClass = lg.Index(pb.PadClass)
if padClass < 0 {
return nil, fmt.Errorf("%s: planet.pad_class %q is not a class in the legend", m.Path, pb.PadClass)
}
if !lg.Classes[padClass].Sea {
return nil, fmt.Errorf("%s: planet.pad_class %q is land; the pad is the ocean a polar cap "+
"drains into", m.Path, pb.PadClass)
}
}
if padClass < 0 {
return nil, fmt.Errorf("%s: the legend has no sea class, so there is nothing to fill the polar pad "+
"with", m.LegendPath())
}
pal := field.DefaultPalette()
if path := m.PalettePath(); path != "" {
pal, err = field.LoadPalette(path)
if err != nil {
return nil, err
}
log("palette %s", m.Planet.Palette)
}
// The annotation layer, read before the coast is roughened rather than after: its coast_jitter marks are
// the one thing on it the generator reads, and what they decide is how far the waterline may move.
ov, ovRas, ovMatch, err := loadOverlay(m, art, pw, ph, log)
if err != nil {
return nil, err
}
// The painted waterline is roughened before it is projected: a drawn shore is a smooth curve and a coast
// is not. See template/coast.go. Off by default only in the sense that an amplitude of zero is the
// painting exactly as drawn.
coast := template.Coast{
AmplitudePx: pb.CoastJitterPx,
WavelengthPx: pb.CoastJitterWavelengthPx,
Octaves: pb.CoastJitterOctaves,
Gain: pb.CoastJitterGain,
Seed: m.Source.Seed,
}
if ov != nil && ovRas != nil {
coast.Scale = ov.CoastScale(ovRas)
}
if coast.Amount() {
ras = ras.RoughenCoast(lg, p, coast)
masked := ""
if coast.Scale != nil {
masked = ", masked by the overlay"
}
log("coast the painted waterline roughened by up to %.0f px over %.0f px bays, %d octaves%s",
coast.AmplitudePx, coast.WavelengthPx, coast.Octaves, masked)
}
pm := ras.Project(p, lg, padClass)
part, err := region.Build(pm, marginCells, pb.MinLandCells)
if err != nil {
return nil, err
}
// The plates first, because the fault set is now partly a consequence of them.
tect, err := buildPlates(m, p, pm, art, log)
if err != nil {
return nil, err
}
faults := buildFaultSet(pm, lg, pb.FaultGrainKm, m.Source.Seed)
if len(faults) > 0 {
log("faults %d class traces over %d classes, grain %.0f km",
len(faults), faultClasses(lg), pb.FaultGrainKm)
}
if belt := buildBeltFaults(p, pm, tect, pb.Plates.Faults, m.Source.Seed, log); len(belt) > 0 {
faults = append(faults, belt...)
}
in := &Inputs{
M: m, P: p, Legend: lg, Raster: ras, Map: pm, Part: part, Palette: pal,
PaintW: pw, PaintH: ph, Match: match,
EdgeRewritten: edge, Dissolved: dissolved, MarginCells: marginCells,
Overlay: ov, OverlayRaster: ovRas, OverlayMatch: ovMatch,
Faults: faults,
Plates: tect,
Elapsed: time.Since(start),
}
if ov != nil && ovRas != nil {
in.OverlayDoc = ov.Describe(ovRas, ovMatch, in.OverlayScale(),
m.Planet.Overlay, m.Planet.OverlayLegend)
}
return in, nil
}
// faultCandidateTarget is roughly how many strided samples of the planet the fault placement draws from. The
// sample is only ever used for a uniform draw - the *areas* are the projection's own exact counts - so what
// it has to be is dense enough that a small class still has somewhere to put a trace, not dense enough to
// measure anything. Sixty-odd thousand over a 76-million-cell planet is a stride of about 34 cells, 270 m,
// and leaves a class covering a fifth of a per cent with over a hundred candidates.
const faultCandidateTarget = 65536
// buildFaultSet draws the planet's faults, or returns nil when no class asks for any.
func buildFaultSet(pm *template.Map, lg *template.Legend, grainKm float64, seed int64) []uplift.FaultTrace {
if !lg.HasFaults() || grainKm <= 0 {
return nil
}
specs := make([]uplift.FaultSpec, len(lg.Classes))
for i := range lg.Classes {
f := lg.Classes[i].Faults
if f == nil || !lg.Classes[i].Land() {
continue
}
specs[i] = uplift.FaultSpec{Per1000Km2: f.Per1000Km2, ThrowM: f.ThrowM, LengthKm: f.LengthKm}
}
p := pm.P
stride := int(math.Sqrt(float64(p.W)*float64(p.PaintH())/faultCandidateTarget) + 0.5)
if stride < 1 {
stride = 1
}
candidates := make([][]int32, len(lg.Classes))
// Painted rows only. The polar pad is synthetic ocean that no class was ever painted on, and a trace
// placed there would be a fault in scaffolding.
for y := p.PadY; y < p.H-p.PadY; y += stride {
for x := 0; x < p.W; x += stride {
i := y*p.W + x
c := pm.Class[i]
if specs[c].Wanted() {
candidates[c] = append(candidates[c], int32(i))
}
}
}
areaCells, _, _ := pm.Counts()
return uplift.BuildFaults(p, seed, grainKm, specs, candidates, areaCells)
}
// buildPlates draws the planet's tectonics, or returns nil when the manifest asks for none.
//
// The land mask is read through a callback at world coordinates rather than handed over as a raster, and
// that is what keeps internal/plates ignorant of templates. What it wants from the painting is one bit per
// position - continent or ocean - and that bit is what decides whether a plate is continental, and therefore
// whether a margin between two of them is a collision or a subduction zone.
func buildPlates(mf *manifest.Manifest, p world.Planet, pm *template.Map, art *Painting,
log func(string, ...any)) (*plates.Model, error) {
cfg := mf.Planet.Plates
inMemory := art != nil && art.Plates != nil
painted := inMemory || mf.HasPaintedPlates()
if !painted && cfg.Count <= 0 {
return nil, nil
}
var m *plates.Model
var err error
if painted {
m, err = paintedPlates(mf, p, pm, art, cfg, log)
} else {
m, err = plates.Build(p, mf.Source.Seed, cfg, landAt(p, pm))
}
if err != nil {
return nil, err
}
continental := 0
for i := range m.Plates {
if m.Plates[i].Continental {
continental++
}
}
byKind := plates.LengthByKind(m.Boundaries)
source := "from seed " + strconv.FormatInt(mf.Source.Seed, 10)
if painted {
source = "painted"
}
log("plates %d %s (%d continental), %d boundaries, tectonic grid %.0f m",
len(m.Plates), source, continental, len(m.Boundaries), m.GCellM)
log(" collision %.0f km, subduction %.0f km, rift %.0f km, ridge %.0f km, transform %.0f km",
byKind[plates.Collision]/1000, byKind[plates.Subduction]/1000, byKind[plates.Rift]/1000,
byKind[plates.Ridge]/1000, byKind[plates.Transform]/1000)
return m, nil
}
// landAt answers "is this world position painted land" against the projected map.
//
// A callback rather than the raster itself, because internal/plates and internal/uplift's belt placement both
// want exactly this one bit and neither should know what a template is. It is also the only thing the
// painting tells the tectonic model, which is worth being able to point at: everything else about a plate
// comes from the seed and the manifest.
func landAt(p world.Planet, pm *template.Map) func(xM, yM float64) bool {
return func(xM, yM float64) bool {
x := p.WrapX(int(math.Floor(xM / p.CellM)))
y := p.ClampY(int(math.Floor(yM/p.CellM)) + p.PadY)
return !pm.Sea[y*p.W+x]
}
}
// buildBeltFaults places the traces that belong to the plate margins rather than to a painted class.
//
// Separate from buildFaultSet and added to the same slice, because from the solve's point of view a fault is
// a fault: both end up in Inputs.Faults, both are rasterised by the same FaultDelta, and the difference is
// only in how they were placed. Where they came from survives in the log line and in the trace's own Class,
// which is -1 for a belt fault because no painted colour asked for it.
func buildBeltFaults(p world.Planet, pm *template.Map, tect *plates.Model, cfg plates.Belt,
seed int64, log func(string, ...any)) []uplift.FaultTrace {
if tect == nil || !cfg.Wanted() {
return nil
}
out := uplift.BuildBeltFaults(p, seed, cfg, tect.Boundaries, landAt(p, pm))
cfg = cfg.WithDefaults()
log(" %d belt traces, %.0f km deformation half-width at %.0f cm/yr, %.0f%% conjugate",
len(out), cfg.ZoneKm, cfg.ReferenceCmYr, cfg.Conjugate()*100)
return out
}
// paintedPlates reads the tectonic layer and turns it into a model.
//
// The decoding happens here rather than in internal/plates for the same reason the land mask does: that
// package deals in geometry and motion, and giving it a file path would give it an opinion about image
// formats, paths and the manifest. It is handed pixels.
func paintedPlates(mf *manifest.Manifest, p world.Planet, pm *template.Map, art *Painting,
cfg plates.Config, log func(string, ...any)) (*plates.Model, error) {
legendPath := mf.PlatesLegendPath()
lg, err := plates.LoadPaintLegend(legendPath)
if err != nil {
return nil, err
}
// The sheet in memory wins when there is one, for the same reason Painting exists at all: the studio is
// editing a picture in a browser, and a plan run against the file on disk would answer a question nobody
// asked.
layerPath := mf.Planet.Plates.Layer
var px []uint8
var pw, ph int
if art != nil && art.Plates != nil {
px, pw, ph = art.Plates, art.PlatesW, art.PlatesH
layerPath = "in memory"
} else {
path := mf.PlatesLayerPath()
if px, pw, ph, err = template.DecodeRGB(path); err != nil {
return nil, fmt.Errorf("tectonic layer %s: %w", path, err)
}
}
m, match, err := plates.FromPainting(p, cfg, lg, px, pw, ph, landAt(p, pm))
if err != nil {
return nil, fmt.Errorf("tectonic layer %s: %w", layerPath, err)
}
log("tectonic %s, %dx%d px, %d plates", layerPath, pw, ph, len(lg.Plates))
if match.Far > 0 {
// Reported rather than fatal, and loudly. Every pixel becomes the nearest plate whatever happens, so
// a layer whose colours have drifted still produces a model - it just produces the wrong one, with
// boundaries somewhere nobody put them.
log(" %d of %d sampled cells are over %.0f from any plate colour (worst %.0f); the layer and "+
"%s disagree", match.Far, match.Cells, lg.WarnDistance, match.MaxDistance,
filepath.Base(legendPath))
}
return m, nil
}
// RebuildFaults draws the fault set again from the legend as it stands now.
//
// It exists for the studio's plan cache, which reuses a whole prepare when only the legend's *numbers*
// changed - and a class's `faults` block is a number that changes the set without changing a pixel of the
// raster the cache is keyed on. Cheap: a strided scan and a few dozen walks, against the six seconds the
// cache is there to avoid.
func (in *Inputs) RebuildFaults() {
in.Faults = buildFaultSet(in.Map, in.Legend, in.M.Planet.FaultGrainKm, in.M.Source.Seed)
// The belt set comes back too. It is not the legend's, but it is in the same slice, and a rebuild that
// dropped it would silently unfault every margin on the planet the first time a class number changed.
quiet := func(string, ...any) {}
in.Faults = append(in.Faults,
buildBeltFaults(in.P, in.Map, in.Plates, in.M.Planet.Plates.Faults, in.M.Source.Seed, quiet)...)
}
// faultClasses is how many classes asked for traces, for the log line.
func faultClasses(lg *template.Legend) int {
n := 0
for i := range lg.Classes {
if lg.Classes[i].Faults != nil {
n++
}
}
return n
}
// OverlayScale is how an overlay pixel maps to world metres. The overlay is registered to the template and
// shares its frame, so this is the template's scale and not the geology grid's - a pixel is 12.9 m where a
// cell is 8.
func (in *Inputs) OverlayScale() overlay.Scale {
w, h := in.PaintW, in.PaintH
if in.OverlayRaster != nil {
w, h = in.OverlayRaster.W, in.OverlayRaster.H
}
circ := in.P.CircumferenceM()
return overlay.Scale{
MetresPerPxX: circ / float64(w),
MetresPerPxY: in.P.HeightM() / float64(h),
CircumferenceM: circ,
}
}
// loadOverlay reads and classifies the annotation layer, from memory when the studio has one and from disk
// otherwise. Returns nils all round when the manifest configures none, which is not an error anywhere.
//
// The overlay must be the same size as the template. It is registered to it - a mark means "here", and here
// is a place on the painting - so two different sizes is not something to resample past, it is an author who
// exported one of the two at the wrong scale and would otherwise find their villages drifting.
func loadOverlay(m *manifest.Manifest, art *Painting, paintW, paintH int, log func(string, ...any)) (
*overlay.Legend, *overlay.Raster, overlay.Match, error) {
var none overlay.Match
if !m.HasOverlay() {
return nil, nil, none, nil
}
ov, err := overlay.Load(m.OverlayLegendPath())
if err != nil {
return nil, nil, none, err
}
var px, alpha []uint8
var w, h int
if art != nil && art.Overlay != nil {
px, alpha, w, h = art.Overlay, art.OverlayAlpha, art.OverlayW, art.OverlayH
} else {
path := m.OverlayPath()
if path == "" {
if ov.Image == "" {
return nil, nil, none, fmt.Errorf("%s: planet.overlay_legend is set but neither it nor "+
"planet.overlay names an image", m.Path)
}
path = filepath.Join(filepath.Dir(m.OverlayLegendPath()), ov.Image)
}
if _, statErr := os.Stat(path); statErr != nil {
// A configured overlay whose image is not there yet is the state the studio starts an author in:
// the legend is written and the sheet is blank. Worth saying, not worth failing on.
log("overlay %s: no image yet (%s); nothing is marked",
m.Planet.OverlayLegend, filepath.Base(path))
return ov, nil, none, nil
}
px, alpha, w, h, err = template.DecodeRGBA(path)
if err != nil {
return nil, nil, none, err
}
}
if w != paintW || h != paintH {
return nil, nil, none, fmt.Errorf("the overlay is %dx%d and the template is %dx%d; they are "+
"registered to each other, so they have to be the same size", w, h, paintW, paintH)
}
ras, match := ov.Classify(px, alpha, w, h)
log("overlay %d marks: %s", len(ov.Marks), match)
return ov, ras, match, nil
}
// SolveCells is how many cells the geology solve will actually visit, summed over regions. It is the number
// the bake time is proportional to, and it is well above the land area because every region carries water.
func (in *Inputs) SolveCells() int {
n := 0
for _, r := range in.Part.Regions {
n += r.Cells()
}
return n
}
// LandCells is how many painted land cells the regions own.
func (in *Inputs) LandCells() int {
n := 0
for _, r := range in.Part.Regions {
n += r.LandCells
}
return n
}
// The measurement the estimates below are scaled from, and the thing it cannot know.
//
// Docs/Terrain.md's time budget records 256 s for 3.2 M cells over 1000 steps on the development machine's
// 16 cores, which is 8.0e-8 s a cell-step. Measured again on a lowland region of this planet - 14.0 M cells,
// 200 steps, 415 s - it is 1.48e-7, very nearly twice as slow, most likely because the square canvas is two
// thirds land while a region is two thirds water: a cheap ocean cell is not a free one.
//
// What no single constant can capture is that **the cost per cell depends on the uplift rate, and by a lot**.
// Measured on the same bake at 1000 steps with four regions in flight:
//
// lowland 0.08 mm/yr 14.0 M cells 1014 s 72 s per million cells
// highland 0.90 mm/yr 4.0 M cells 1394 s 350 s per million cells
// crater 1.60 mm/yr 1.4 M cells 1829 s 1278 s per million cells
//
// Eighteen-fold, and it is not the stream power. It is the hillslope: DiffuseNonlinear sub-steps to stay
// stable, the count rises with the steepest slope on the grid, and it saturates at max_hillslope_substeps -
// 24 by default. Steep ground pays all 24 every step; a plain pays one.
//
// So the estimate is calibrated on the plains and **badly under-predicts a mountainous template**. It is a
// floor rather than a forecast, the printed line says so, and the practical consequence for an author is
// that raising an uplift rate does not only change the terrain, it changes how long the bake takes.
const secondsPerCellStep = 415.0 / (14.02e6 * 200)
// EstimateSeconds is how long a region's solve should take at the manifest's step count.
func (in *Inputs) EstimateSeconds(cells int) float64 {
return float64(cells) * float64(in.M.Pipeline.Fluvial.Steps) * secondsPerCellStep
}
// bytesPerCell is what a region costs while it is being solved: the fluvial.Grid's eight int32/float32
// arrays and three masks, plus the height, uplift and erodibility fields the solve reads. It is an estimate
// and it is labelled as one wherever it is printed.
const bytesPerCell = 35 + 12 + 16
// EstimateBytes is roughly how much memory a region's solve holds at once.
func (in *Inputs) EstimateBytes(cells int) int64 { return int64(cells) * bytesPerCell }
+686
View File
@@ -0,0 +1,686 @@
package planet
import (
"encoding/json"
"fmt"
"image/png"
"math"
"os"
"path/filepath"
"sync"
"time"
"salty/terrain/internal/detail"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/noise"
"salty/terrain/internal/overlay"
"salty/terrain/internal/thermal"
"salty/terrain/internal/tile"
)
// The detail bake: the geology grid becomes ground somebody can stand on, one tile at a time.
//
// It reads the heightmap a geology bake left behind rather than solving anything itself, which is what makes
// it batchable. The geology is hours; a tile is seconds, and the islands somebody cares about can be baked
// first and the rest later or never.
//
// Every pass here is local, and every hash and noise lattice in them is keyed on absolute world position, so
// a tile's interior comes out the same as it would have in one impossible whole-world run. That is measured
// rather than asserted: internal/detail's TestHowFarTheCutEdgeReachesIn is where the margin comes from.
// TileOptions steer a detail bake.
type TileOptions struct {
In *Inputs
HeightM *field.Field // the geology heightmap, painted rows only, in metres
Sea []bool // painted rows
// Exposure is the coastal pass's fetch field, painted rows, 0 sheltered to 1 open water, or nil when the
// bake predates it. A tile cannot compute this - see detail.CoastalParams - so without it the coastal
// detail pass treats every shore as fully exposed and the run says so once.
Exposure *field.Field
Out string
Prefix string
Only [4]int // x0,y0,x1,y1 in tile indices; zero means all
OnlySet bool
// NoDetail writes the tile as the geology upsampled and nothing else. It is a diagnostic and it earns
// its place: when ground looks wrong at two metres, the first question is always whether the detail
// passes did it or whether they are faithfully magnifying something the solve produced, and there is no
// other way to ask.
NoDetail bool
// NoShore skips pass 11b and nothing else, for the same reason NoDetail exists one level up: when a
// coastline looks wrong the first question is whether the shore pass did it or whether it is faithfully
// magnifying what the geology handed it, and a diff between two runs is the only way to ask.
NoShore bool
Jobs int
Log func(string, ...any)
}
// TileRecord is one tile in the index.
type TileRecord struct {
IX int `json:"ix"`
IY int `json:"iy"`
File string `json:"file"`
W int `json:"w"`
H int `json:"h"`
OriginXM float64 `json:"origin_x_m"`
OriginYM float64 `json:"origin_y_m"`
MinM float64 `json:"min_m"`
MaxM float64 `json:"max_m"`
ClipFrac float64 `json:"clip_fraction"`
Droplets int `json:"droplets"`
Rounds int `json:"rounds"`
LargestCutM float64 `json:"largest_cut_m"`
LargestFillM float64 `json:"largest_fill_m"`
Seconds float64 `json:"seconds"`
// Coastal is what pass 11b moved on this tile, or nil on a tile with no shore in it.
Coastal *detail.CoastalStats `json:"coastal,omitempty"`
// OverlayFile is the annotation mask beside this tile - one mark index a detail cell, zero for nothing -
// or empty when the planet has no overlay. The key is in overlay.json in the same directory.
OverlayFile string `json:"overlay_file,omitempty"`
}
// TileIndex is tiles.json: everything a consumer needs to place the tiles back into a world.
type TileIndex struct {
When time.Time `json:"when"`
Prefix string `json:"prefix"`
CellM float64 `json:"cell_m"`
TilePx int `json:"tile_px"`
MarginPx int `json:"margin_px"`
NX int `json:"nx"`
NY int `json:"ny"`
WrapX bool `json:"wrap_x"`
WorldWM float64 `json:"world_w_m"`
WorldHM float64 `json:"world_h_m"`
ElevationM struct {
Min float64 `json:"min"`
Max float64 `json:"max"`
} `json:"elevation_m"`
Tiles []TileRecord `json:"tiles"`
}
// CheckBake refuses a detail bake whose geology was produced by a different manifest.
//
// The failure it exists for is silent and total. A heightmap is 16-bit samples over an elevation range, so a
// bake made under one range and decoded under another comes out shifted - and if the shift takes the land
// below sea level, every tile decides it is ocean, holds itself at sea level, and writes a flat zero. That
// happened on the first run of this command and there was nothing in the output to say why.
func CheckBake(dir string, m *manifest.Manifest, warn func(string, ...any)) error {
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
if err != nil {
return fmt.Errorf("%s: %w (run `terrain bake` first)", filepath.Join(dir, "meta.json"), err)
}
// Pointers, so that a field a bake did not record is distinguishable from one it recorded as zero. An
// absent field is a bake older than this check, which is a reason to say so and carry on; a different
// field is a reason to stop. The first version conflated the two and refused a perfectly good bake.
var meta struct {
Seed *int64 `json:"seed"`
Plan struct {
CircumferenceKm *float64 `json:"circumference_km"`
CellM *float64 `json:"cell_m"`
ElevationMinM *float64 `json:"elevation_min_m"`
ElevationMaxM *float64 `json:"elevation_max_m"`
} `json:"plan"`
}
if err := json.Unmarshal(raw, &meta); err != nil {
return fmt.Errorf("%s: %w", filepath.Join(dir, "meta.json"), err)
}
if warn == nil {
warn = func(string, ...any) {}
}
unknown := 0
for _, c := range []struct {
what string
was *float64
now float64
}{
{"elevation_m.min", meta.Plan.ElevationMinM, m.ElevationM.Min},
{"elevation_m.max", meta.Plan.ElevationMaxM, m.ElevationM.Max},
{"circumference_km", meta.Plan.CircumferenceKm, m.Planet.CircumferenceKm},
{"the geology cell", meta.Plan.CellM, m.GeologyCellM()},
} {
if c.was == nil {
unknown++
continue
}
if *c.was != c.now {
return fmt.Errorf("%s was baked with %s %v and the manifest now says %v. The heightmap on disk "+
"means something different from what this run would read it as; rebake, or put the manifest "+
"back", dir, c.what, *c.was, c.now)
}
}
if meta.Seed != nil && *meta.Seed != m.Source.Seed {
return fmt.Errorf("%s was baked with seed %d and the manifest now says %d; the detail passes would "+
"be hashing a different world from the one in the heightmap", dir, *meta.Seed, m.Source.Seed)
}
if unknown > 0 {
warn("warning %s predates this check and does not record %d of the numbers it would be checked "+
"against; if the manifest has moved since it was baked, the heights will be read as something "+
"they are not", dir, unknown)
}
return nil
}
// BakeTiles runs the detail passes over a rectangle of tiles and writes them.
func BakeTiles(opt TileOptions) (*TileIndex, error) {
log := opt.Log
if log == nil {
log = func(string, ...any) {}
}
in := opt.In
m := in.M
cfg := m.Pipeline
marginPx := detail.MarginCells(cfg.Particle)
g, err := tile.NewGrid(in.P, cfg.GeologyFactor, cfg.Detail.TilePx, marginPx)
if err != nil {
return nil, err
}
detailCellM := in.P.CellM / float64(cfg.GeologyFactor)
log("tiles %d x %d of %d px at %.1f m (%.2f km), margin %d px (%.0f m)",
g.NX, g.NY, cfg.Detail.TilePx, detailCellM,
float64(cfg.Detail.TilePx)*detailCellM/1000, g.MarginGeo*g.Factor, float64(g.MarginGeo)*in.P.CellM)
if want := int(cfg.Detail.ClassBlendM/in.P.CellM + 0.5); want > g.MarginGeo/2 {
log("warning class_blend_m is %.0f m, which a tile cannot reach past its own margin; it will blend "+
"over %.0f m instead. Two passes of the blur reach twice its radius, and the margin is %.0f m",
cfg.Detail.ClassBlendM, float64(g.MarginGeo/2)*in.P.CellM, float64(g.MarginGeo)*in.P.CellM)
}
if cd := cfg.CoastDetail; cd.Enabled && !opt.NoShore {
log("shore the coastal detail pass is on: %.0f m of surf reach is %.0f cells here, a beach below "+
"%.0f m of backshore and a cliff above %.0f", cfg.Coast.SurfReachM,
cfg.Coast.SurfReachM/detailCellM, cd.CliffFromM, cd.CliffToM)
} else {
log("shore the coastal detail pass is off; the shore is the geology upsampled")
}
all := g.Tiles()
wanted := all[:0:0]
for _, t := range all {
if opt.OnlySet {
if t.IX < opt.Only[0] || t.IX > opt.Only[2] || t.IY < opt.Only[1] || t.IY > opt.Only[3] {
continue
}
}
wanted = append(wanted, t)
}
if len(wanted) == 0 {
return nil, fmt.Errorf("no tiles selected; the grid is %d x %d", g.NX, g.NY)
}
if err := os.MkdirAll(opt.Out, 0o755); err != nil {
return nil, err
}
prefix := opt.Prefix
if prefix == "" {
prefix = "Planet"
}
jobs := opt.Jobs
if jobs <= 0 {
jobs = 4
}
if jobs > len(wanted) {
jobs = len(wanted)
}
recs := make([]TileRecord, len(wanted))
errs := make([]error, len(wanted))
var wg sync.WaitGroup
var mu sync.Mutex
next := make(chan int)
go func() {
for i := range wanted {
next <- i
}
close(next)
}()
for w := 0; w < jobs; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range next {
rec, err := bakeOneTile(g, wanted[i], opt, prefix)
recs[i], errs[i] = rec, err
mu.Lock()
if err != nil {
log("tile %s FAILED: %v", wanted[i].Name(prefix), err)
} else {
log("tile %s %d x %d %.0f..%.0f m %.3f%% clipped [%.1f s]",
rec.File, rec.W, rec.H, rec.MinM, rec.MaxM, rec.ClipFrac*100, rec.Seconds)
}
mu.Unlock()
}
}()
}
wg.Wait()
for _, err := range errs {
if err != nil {
return nil, err
}
}
idx := &TileIndex{
When: time.Now().UTC().Truncate(time.Second), Prefix: prefix,
CellM: detailCellM, TilePx: cfg.Detail.TilePx, MarginPx: g.MarginGeo * g.Factor,
NX: g.NX, NY: g.NY, WrapX: true,
WorldWM: in.P.CircumferenceM(), WorldHM: in.P.HeightM(),
Tiles: recs,
}
idx.ElevationM.Min, idx.ElevationM.Max = m.ElevationM.Min, m.ElevationM.Max
data, err := json.MarshalIndent(idx, "", " ")
if err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(opt.Out, "tiles.json"), append(data, '\n'), 0o644); err != nil {
return nil, err
}
// The key to every *_overlay.png, plus the features in world metres, written beside them so an importer
// reads one directory rather than two. It is the same document the plan and the bake write; it is small,
// it describes the whole planet, and a tile batch that did not carry it would be a folder of masks with
// no legend.
if in.OverlayDoc != nil {
if err := in.OverlayDoc.WriteJSON(opt.Out); err != nil {
return nil, err
}
}
return idx, nil
}
// bakeOneTile is passes 8 to 12 and 14 over one tile.
func bakeOneTile(g *tile.Grid, t tile.Tile, opt TileOptions, prefix string) (TileRecord, error) {
in := opt.In
m := in.M
cfg := m.Pipeline
// Pass 8: cut with the margin and upsample. UpsampleInt is exact-factor Catmull-Rom, so every geology
// sample lands exactly on a detail sample and there is no phase error to accumulate along a tile row.
//
// The sea is flattened to sea level *before* the upsample, not after, and both halves of that matter. The
// geology raster drops from the shore to the painted ocean depth in a single cell, so a Catmull-Rom
// upsample of it rings at every coastline - hundreds of metres of overshoot in the water and a wave of it
// back into the land. And with the sea flat, the interpolated height crosses sea level on a smooth
// contour, so the detail land mask can be read off the height itself; taken up from the geology mask by
// nearest neighbour instead, the coastline comes out as a staircase of 8 m blocks and it is plainly
// visible in a hillshade.
//
// It is the same invariant the fluvial solve keeps, for the same reason: with the floor in place a cell at
// the waterline stands five hundred metres above its neighbour, and thermal weathering would find the
// whole coastline past the angle of repose and pour it into the sea.
geo, _, _ := g.Cut(t, opt.HeightM, 0)
geoSea := g.CutMask(t, opt.Sea, opt.HeightM.W, 0)
floor := geo.Clone()
for i, isSea := range geoSea {
if isSea {
geo.Data[i] = float32(m.SeaLevelM)
}
}
h := geo.UpsampleInt(cfg.GeologyFactor)
land := make([]bool, len(h.Data))
for i, v := range h.Data {
land[i] = float64(v) > m.SeaLevelM
}
f := g.Frame(t)
periodM := m.Planet.DetailNoisePeriodKm * 1000
classes := blendedClasses(g, t, opt, geo, h)
if opt.NoDetail {
restoreSeaFloor(h, land, geo, floor, m, cfg.GeologyFactor)
return finishTile(g, t, opt, prefix, h, land, nil, nil)
}
// Pass 9.
detail.RunDetailNoise(h, land, detail.DetailNoiseParams{
Cfg: cfg.Detail, Seed: m.Source.Seed, Frame: f, PeriodM: periodM, SeaLevelM: m.SeaLevelM,
Classes: classes,
})
// Pass 10 feeds pass 11 rather than standing alone: strata is hardness, and hardness is what the
// droplets scale their cutting by, which is how a hard band ends up holding a shelf on a cut face.
hard := detail.NewHardness(f, m.Source.Seed, m.Planet.NoisePeriodKm*1000,
cfg.Strata.PeriodM, cfg.Strata.Contrast, classes)
// Pass 11.
maps, _ := detail.RunParticle(h, land, detail.ParticleParams{
Cfg: cfg.Particle, Seed: m.Source.Seed, Frame: f, SeaLevelM: m.SeaLevelM, Hardness: hard,
Classes: classes,
})
// Pass 12: the same mass-conserving weathering the coarse grid gets, at the cell size where scree and a
// cliff face are actually resolved.
fixed := make([]bool, len(land))
for i := range land {
fixed[i] = !land[i]
}
thermal.Apply(h.Data, h.W, h.H, h.CellM, thermal.TalusFromDegrees(cfg.Thermal.TalusDeg),
cfg.Thermal.FinePasses, fixed, nil)
// The sea floor goes back *here*, before the shore is drawn, rather than on the way out. Pass 11b works
// on both sides of the waterline - a foreshore is below it and a berm is above it - so a shore laid onto
// water that is about to be overwritten would be half a shore.
restoreSeaFloor(h, land, geo, floor, m, cfg.GeologyFactor)
// Pass 9b: the same texture as pass 9, under water, now that there is a sea bed to put it on. It runs
// before the shore rather than after, so the beach the shore pass draws is smooth sand over it rather
// than sand with noise on top.
detail.RunSeabedNoise(h, detail.DetailNoiseParams{
Cfg: cfg.Detail, Seed: m.Source.Seed, Frame: f, PeriodM: periodM, SeaLevelM: m.SeaLevelM,
Classes: classes,
})
// Pass 11b: the shore. It runs last of the detail passes because marine processes are the last thing to
// act on a coast and they act faster than anything inland: a berm is rebuilt by every tide, while the
// hillslope creep that pass 12 stands for takes the age of the cliff behind it. Running it before the
// fine thermal would have that creep immediately relax the one face on the map that is meant to be
// steeper than the angle of repose.
var coastal *detail.CoastalStats
if cfg.CoastDetail.Enabled && !opt.NoShore {
st := detail.RunCoastal(h, land, detail.CoastalParams{
Cfg: cfg.CoastDetail, Surf: cfg.Coast, Seed: m.Source.Seed, Frame: f, PeriodM: periodM,
SeaLevelM: m.SeaLevelM, Exposure: cutExposure(g, t, opt, geo, h), Hardness: hard,
})
coastal = &st
}
return finishTile(g, t, opt, prefix, h, land, maps, coastal)
}
// cutExposure lifts the coastal pass's fetch field onto this tile's detail grid, or nil when the bake did not
// carry one. Interpolated rather than nearest: it is a smooth field and a staircase in it would put a
// staircase into the berm height along every beach.
func cutExposure(g *tile.Grid, t tile.Tile, opt TileOptions, geo, h *field.Field) []float32 {
if opt.Exposure == nil {
return nil
}
cut, _, _ := g.Cut(t, opt.Exposure, 0)
up := cut.UpsampleInt(opt.In.M.Pipeline.GeologyFactor)
if len(up.Data) != len(h.Data) {
return nil
}
return up.Data
}
// restoreSeaFloor puts the water back after the land passes, which ran with the sea flattened to sea level.
//
// It used to be nearest neighbour, unconditionally, and the comment said why: the geology raster dropped from
// the shore to the painted ocean depth in a single cell, and interpolating a five-hundred-metre step is
// exactly what the flattening exists to avoid. The cost was a four-fold staircase over the whole sea floor,
// which nobody could see while the shore was a cliff into five hundred metres of water.
//
// D-60 changed the input. There is a continental shelf now, and a surf-cut platform, and a beach, and between
// them they carry the sea floor down from the waterline to the shelf break over kilometres rather than over
// one cell. So the shallow water is interpolated - from the *unflattened* cut, which still holds the land
// heights, so the surface runs across the waterline with no seam in it - and only the drop past the break is
// still nearest. The two are blended over a depth band rather than switched between, because a hard switch
// would put back a smaller version of the step it exists to avoid.
func restoreSeaFloor(h *field.Field, land []bool, geo, floor *field.Field, m *manifest.Manifest, factor int) {
breakM := m.ShelfBreakM()
if breakM <= 0 {
breakM = 30
}
// The clamp sits at the *far* end of the blend band rather than at the break, so that everywhere the blend
// is still reading the interpolation, the interpolation is of the real sea floor. Clamped at the break
// instead, the smooth half of the blend was a flat surface at break depth while the nearest half followed
// the slope down, and the mixture lifted the floor by up to half the band - ten metres of invented shelf
// in exactly the strip the blend exists to make invisible.
const bandM = 40.0
deepest := float32(m.SeaLevelM - (breakM + bandM))
shallow := floor.Clone()
for i, v := range shallow.Data {
if v < deepest {
shallow.Data[i] = deepest
}
}
smooth := shallow.UpsampleInt(factor)
// Not smoothed, and it is worth saying why not, because the first version was.
//
// The interpolated sea floor comes out of a hillshade covered in dotted contour lines, which look exactly
// like an interpolation artefact and are not: measured, an eighty by hundred patch of open water takes
// three distinct 8-bit shade values, 94 % of them the same one. It is the hillshade's own quantisation on
// a surface that slopes at one in three hundred, it was there before and it is in the picture rather than
// in the ground. A box blur over the floor was tried against it and changed the tile by a fifth of a
// height quantum on average - its only real effect was to soften genuine one-cell steps in the geology,
// which is not what it was for.
for y := 0; y < h.H; y++ {
sy := y / factor
if sy >= geo.H {
sy = geo.H - 1
}
for x := 0; x < h.W; x++ {
i := y*h.W + x
if land[i] {
continue
}
sx := x / factor
if sx >= geo.W {
sx = geo.W - 1
}
near := float64(floor.Data[sy*geo.W+sx])
depth := m.SeaLevelM - near
t := (depth - breakM) / bandM
if t <= 0 {
h.Data[i] = smooth.Data[i]
continue
}
if t >= 1 {
h.Data[i] = float32(near)
continue
}
w := noise.Smoothstep(t)
h.Data[i] = float32((1-w)*float64(smooth.Data[i]) + w*near)
}
}
}
// finishTile restores the sea floor, crops the margin away and writes everything out.
func finishTile(g *tile.Grid, t tile.Tile, opt TileOptions, prefix string, h *field.Field, land []bool,
maps *detail.Maps, coastal *detail.CoastalStats) (TileRecord, error) {
start := time.Now()
_ = land
m := opt.In.M
cfg := m.Pipeline
// Pass 14: crop the margin away and write. Everything outside the interior was only ever there so the
// passes above had somewhere to read from.
ix, iy := g.MarginGeo*g.Factor, g.MarginGeo*g.Factor
iw, ih := g.DetailW(t), g.DetailH(t)
out := crop(h, ix, iy, iw, ih)
rec := TileRecord{
IX: t.IX, IY: t.IY, File: t.Name(prefix) + ".png", W: iw, H: ih,
OriginXM: g.OriginXM(t), OriginYM: g.OriginYM(t),
}
if coastal != nil && coastal.ShoreCells > 0 {
rec.Coastal = coastal
}
// The annotation layer, sampled onto this tile's interior. It is written before the height, because it is
// the cheap one and a failure here should not leave a heightmap with no mask beside it.
//
// Nothing in the detail passes read it and nothing here consults it: it is the author's layer travelling
// through to whatever builds the level. The values are mark indices, zero for nothing, and the key is in
// overlay.json beside tiles.json.
if ov := opt.In.OverlayRaster; ov != nil {
marks := ov.SampleWorld(rec.OriginXM, rec.OriginYM, h.CellM, iw, ih, opt.In.OverlayScale())
rec.OverlayFile = t.Name(prefix) + "_overlay.png"
if err := overlay.WriteMask(filepath.Join(opt.Out, rec.OverlayFile), iw, ih, marks); err != nil {
return rec, err
}
}
lo, hi := out.MinMax()
rec.MinM, rec.MaxM = float64(lo), float64(hi)
rec.ClipFrac = m.ClipFraction(out.Data)
if err := field.WriteGray16(filepath.Join(opt.Out, rec.File), iw, ih,
m.Encode(out.Data), png.DefaultCompression); err != nil {
return rec, err
}
// A hillshade beside the heightmap, at full resolution. A 16-bit grey PNG of a hundred metres of relief
// is a flat grey rectangle to look at, and the whole reason these passes exist is what they do to the
// surface - which cannot be judged from a number.
// Shaded with the water clamped at the shelf break rather than at sea level. Clamping at sea level was
// right while the shore was a step into five hundred metres of water and there was nothing below the
// waterline worth looking at; now there is a shore platform, a foreshore and a beach down there, and
// they are most of what pass 11b does. The break is still clamped, because a continental slope in the
// corner of a tile would otherwise set the whole hillshade's contrast.
shade := out.Clone()
shadeFloor := float32(m.SeaLevelM - m.ShelfBreakM())
for i := range shade.Data {
if shade.Data[i] < shadeFloor {
shade.Data[i] = shadeFloor
}
}
if err := field.WriteHillshade(filepath.Join(opt.Out, t.Name(prefix)+"_shade.png"), shade, iw, 1); err != nil {
return rec, err
}
// A slice, not a map: map iteration order is randomised in Go and nothing in this generator is allowed
// to depend on it (cross-cutting rule 12). Here it would only reorder two file writes, which is exactly
// the kind of "it does not matter this time" that makes the rule worth keeping without exception.
// The full-scale values are fixed constants, not percentiles of the tile.
//
// Field.ToUnit takes the 99th percentile of whatever it is given, which is exactly right for one map of
// one world and exactly wrong here: it is a statistic of the tile's own extent, so two tiles would stretch
// by different anchors and their shared valley would come out two different greys. That is the same
// mistake the coastal pass's exposure made and had withdrawn, and the same rule - no pass computes a
// statistic of the piece of the world it happens to be looking at.
//
// Flow is water-units accumulated and runs over decades, so it is log-scaled; wear and deposit are metres
// and a metre of either is a great deal at a 2 m cell.
flowFull := 40 * cfg.Particle.DropletsPerCell * float64(cfg.Particle.Lifetime)
var derived []struct {
name string
data []float32
full float64
log bool
}
if maps != nil {
derived = []struct {
name string
data []float32
full float64
log bool
}{
{"flow", maps.Flow, flowFull, true},
{"wear", maps.Wear, 1.0, false},
{"deposit", maps.Deposit, 1.0, false},
}
}
for _, d := range derived {
c := crop(&field.Field{W: h.W, H: h.H, CellM: h.CellM, Data: d.data}, ix, iy, iw, ih)
if err := field.WriteGray8(filepath.Join(opt.Out, t.Name(prefix)+"_"+d.name+".png"), iw, ih,
toBytes(normalise(c.Data, d.full, d.log)), png.BestSpeed); err != nil {
return rec, err
}
}
rec.Seconds = time.Since(start).Seconds()
return rec, nil
}
// upsampleClass takes the class raster up by an integer factor, nearest. A class index is a name and not a
// quantity: interpolating one would invent a class that is neither of its neighbours.
func crop(f *field.Field, x0, y0, w, h int) *field.Field {
out := field.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
}
// normalise maps values onto 0..1 against a fixed full-scale, never a percentile of the data. See the note
// where the constants are chosen.
func normalise(data []float32, full float64, logScale bool) []float32 {
if full <= 0 {
full = 1
}
top := full
if logScale {
top = math.Log1p(full)
}
out := make([]float32, len(data))
for i, v := range data {
x := float64(v)
if x < 0 {
x = 0
}
if logScale {
x = math.Log1p(x)
}
out[i] = float32(x / top)
}
return out
}
func toBytes(data []float32) []uint8 {
out := make([]uint8, len(data))
for i, v := range data {
x := v
if x < 0 {
x = 0
} else if x > 1 {
x = 1
}
out[i] = uint8(x*255 + 0.5)
}
return out
}
// blendedClasses turns the painted class raster into the four numbers the detail passes read, per cell, with
// the boundaries between classes faded rather than stepped.
//
// **Why the fade.** A class is a name and a name is never interpolated - the mask that travels to whatever
// builds the level is still nearest neighbour, and it has to be. But the numbers a class stands for are
// quantities. Kept as a lookup on the class index, a desert meeting a lowland went from seven metres of dune
// amplitude to two, and from a fifth of the running water to all of it, in the width of one cell, along a
// line somebody drew with a mouse. It read as what it was: a boundary in a picture rather than a change in
// the ground. Faded over `class_blend_m`, the same boundary is a few hundred metres of one becoming the
// other, which is what the edge of a sand sea looks like from inside it.
//
// **Why at the geology grid.** The class raster is a geology-resolution field, so blurring it there costs a
// four-hundredth of blurring at detail resolution, and the upsample afterwards is the same exact-factor
// Catmull-Rom every other field gets - so the result is smoother than a blur at detail resolution would have
// been, not coarser.
//
// **Why the radius is clamped.** A blur reads outside the cell it writes, and a tile only has its margin to
// read from. Two passes of a box blur of radius r reach 2r, so r is capped at half the margin and the run
// says so once when the manifest asks for more. Past that cap a tile would be blending against its own cut
// edge and two tiles would disagree about the same ground, which is the one thing the tiling may not do.
func blendedClasses(g *tile.Grid, t tile.Tile, opt TileOptions, geo, h *field.Field) *detail.Classes {
in := opt.In
cfg := in.M.Pipeline
if !in.Legend.Overrides() {
return nil
}
tbl := in.Legend.DetailTables(cfg.Particle.DropletsPerCell,
cfg.Detail.AmplitudeM.Lo(), cfg.Detail.AmplitudeM.Hi(), cfg.Strata.Contrast)
cls := g.CutClass(t, in.Map.Class, in.P.W, in.P.PadY)
radius := int(cfg.Detail.ClassBlendM/in.P.CellM + 0.5)
if max := g.MarginGeo / 2; radius > max {
radius = max
}
lift := func(table []float64) []float32 {
f := field.New(geo.W, geo.H, geo.CellM)
for i, k := range cls {
f.Data[i] = float32(table[k])
}
if radius > 0 {
field.BoxSmooth(f.Data, f.W, f.H, radius, 2)
}
return f.UpsampleInt(cfg.GeologyFactor).Data
}
return &detail.Classes{
Droplets: lift(tbl.Droplets), AmpLo: lift(tbl.AmpLo),
AmpHi: lift(tbl.AmpHi), Contrast: lift(tbl.Contrast),
}
}