Files
UnrealPrototyping/Tools/Terrain/internal/planet/output.go
T
2026-09-25 17:02:24 +03:00

374 lines
14 KiB
Go

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
}