Tooling
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 := ®ion.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: ®ion.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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user