374 lines
14 KiB
Go
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
|
|
}
|