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