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 }