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

562 lines
22 KiB
Go

// 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 }