// Command terrain generates L_World's heightmap. See Docs/Terrain.md. // // terrain generate the manifest as it stands // terrain generate --seed 12 another continent // terrain generate --stage fluvial --size 1024 one pass at a small size, the iteration loop // // The flags are deliberately the ones Scripts/Authoring/generate_heightmap.py had, so the two documented // commands in RawContent/World/README.md and everybody's muscle memory survive the port. package main import ( "encoding/json" "flag" "fmt" "image/png" "math" "os" "path/filepath" "runtime" "time" "salty/terrain/internal/coast" "salty/terrain/internal/field" "salty/terrain/internal/fluvial" "salty/terrain/internal/manifest" "salty/terrain/internal/stats" "salty/terrain/internal/thermal" "salty/terrain/internal/uplift" ) func main() { if len(os.Args) < 2 { usage() os.Exit(2) } switch os.Args[1] { case "generate": if err := generate(os.Args[2:]); err != nil { fmt.Fprintln(os.Stderr, "terrain:", err) os.Exit(1) } case "-h", "--help", "help": usage() default: fmt.Fprintf(os.Stderr, "terrain: unknown command %q\n", os.Args[1]) usage() os.Exit(2) } } func usage() { fmt.Fprint(os.Stderr, `terrain - the world's heightmap generator (Docs/Terrain.md) terrain generate [flags] --manifest PATH default RawContent/World/World.json, found by walking up from the working directory --seed N override the noise seed for this run --size N run the geology grid at N instead of the manifest's, for iterating --stage NAME stop after a stage: uplift, fluvial (default: the last one built) --steps N override the fluvial step count --out DIR where the PNGs go (default: beside the manifest, or Preview/ for a --size run) --quiet only the summary --no-coast skip the coastal pass: a flat sea floor and an unworked shoreline `) } func generate(args []string) error { fs := flag.NewFlagSet("generate", flag.ExitOnError) manifestPath := fs.String("manifest", "", "path to World.json") seed := fs.Int64("seed", -1, "override the noise seed") size := fs.Int("size", 0, "run the geology grid at this size instead of the manifest's") stage := fs.String("stage", "fluvial", "stop after this stage: uplift, fluvial") steps := fs.Int("steps", 0, "override the fluvial step count") out := fs.String("out", "", "output directory") quiet := fs.Bool("quiet", false, "only print the summary") fillEvery := fs.Int("fill-every", 0, "override the priority-flood interval") channelKm2 := fs.Float64("channel-km2", 1.0, "drainage area that counts as a channel, km2") criticalM2 := fs.Float64("critical-m2", 0, "override where channels begin, m2") diffusion := fs.Float64("diffusion", 0, "override hillslope diffusivity, m2/yr") talusDeg := fs.Float64("talus", 0, "override the angle of repose, degrees") thermalEvery := fs.Int("thermal-every", 0, "override the landslide interval, steps") thermalPasses := fs.Int("thermal-passes", 0, "override landslide passes per application") convergent := fs.Float64("convergent", 0, "override the convergent uplift rate, mm/yr") intraplate := fs.Float64("intraplate", 0, "override the intraplate sag rate, mm/yr") intraSwell := fs.Float64("intraplate-swell", 0, "override the intraplate swell rate, mm/yr") lithTypes := fs.Int("lithology-types", -1, "override the rock type count; 1 disables lithology") faultScale := fs.Float64("fault-scale", -1, "scale the fault counts; 0 disables faults") kOverride := fs.Float64("k", 0, "override the stream-power erodibility K") cropX := fs.Float64("crop-x", 0.20, "detail crop, left edge in map coordinates") cropY := fs.Float64("crop-y", 0.62, "detail crop, top edge in map coordinates") cropSize := fs.Float64("crop-size", 0.22, "detail crop, side length in map coordinates") reliefWindowM := fs.Float64("relief-window", 500, "window the per-bucket local relief is measured over, m") criticalSlope := fs.Float64("critical-slope", -1, "override Sc in the nonlinear hillslope law, degrees; 0 reverts to linear diffusion and the in-loop clamp") slopeCap := fs.Float64("slope-cap", 0, "override where the nonlinear flux stops stiffening, as a fraction of Sc") hillslopeSub := fs.Int("hillslope-substeps", 0, "override the nonlinear hillslope sub-step budget") mapSize := fs.Int("map-size", 1400, "side, in pixels, of the false-colour data maps") outlineOctaves := fs.Int("outline-octaves", 0, "override how much detail the coastline outline has") outlineGain := fs.Float64("outline-gain", 0, "override the coastline outline's octave gain: how crenellated it is") noCoast := fs.Bool("no-coast", false, "skip the coastal pass: a flat sea floor and an unworked shoreline") surfReach := fs.Float64("surf-reach", 0, "override how far inland the surf planes on open coast, m") cutFraction := fs.Float64("cut-fraction", 0, "override how completely the surf planes the shore platform, 0..1") shelfKm := fs.Float64("shelf-km", 0, "override the widest continental shelf, km") driftM := fs.Float64("drift", 0, "override how far sediment is carried along the shore, m") riverSediment := fs.Float64("river-sediment", -1, "override the river load per km2 of catchment, m3; 0 disables deltas") if err := fs.Parse(args); err != nil { return err } path, err := findManifest(*manifestPath) if err != nil { return err } m, err := manifest.Load(path) if err != nil { return err } if *seed >= 0 { m.Source.Seed = *seed } if *steps > 0 { m.Pipeline.Fluvial.Steps = *steps } if *fillEvery > 0 { m.Pipeline.Fluvial.FillEvery = *fillEvery } if *criticalM2 > 0 { m.Pipeline.Fluvial.CriticalAreaM2 = *criticalM2 } if *diffusion > 0 { m.Pipeline.Fluvial.DiffusionM2Yr = *diffusion } if *talusDeg > 0 { m.Pipeline.Thermal.TalusDeg = *talusDeg } if *thermalEvery > 0 { m.Pipeline.Thermal.Every = *thermalEvery } if *thermalPasses > 0 { m.Pipeline.Thermal.CoarsePasses = *thermalPasses } if *convergent > 0 { m.Pipeline.Plates.ConvergentMmYr[1] = *convergent } if *intraplate > 0 { m.Pipeline.Plates.IntraplateMmYr = *intraplate } if *intraSwell > 0 { m.Pipeline.Plates.IntraplateSwellMmYr = *intraSwell } if *criticalSlope >= 0 { m.Pipeline.Fluvial.CriticalSlopeDeg = *criticalSlope } if *slopeCap > 0 { m.Pipeline.Fluvial.SlopeCap = *slopeCap } if *hillslopeSub > 0 { m.Pipeline.Fluvial.MaxHillslopeSub = *hillslopeSub } if *kOverride > 0 { m.Pipeline.Fluvial.K = *kOverride } if *lithTypes >= 1 { m.Pipeline.Lithology.Types = *lithTypes } if *faultScale >= 0 { m.Pipeline.Faults.Major[0] *= *faultScale m.Pipeline.Faults.Major[1] *= *faultScale m.Pipeline.Faults.Minor[0] *= *faultScale m.Pipeline.Faults.Minor[1] *= *faultScale } if *outlineOctaves > 0 { m.Pipeline.Continent.OutlineOctaves = *outlineOctaves } if *outlineGain > 0 { m.Pipeline.Continent.OutlineGain = *outlineGain } if *noCoast { m.Pipeline.Coast.Enabled = false } if *surfReach > 0 { m.Pipeline.Coast.SurfReachM = *surfReach } if *cutFraction > 0 { m.Pipeline.Coast.CutFraction = *cutFraction } if *shelfKm > 0 { m.Pipeline.Coast.ShelfKm[1] = *shelfKm } if *driftM > 0 { m.Pipeline.Coast.DriftM = *driftM } if *riverSediment >= 0 { m.Pipeline.Coast.RiverM3PerKm2 = *riverSediment } if m.Erosion != nil { fmt.Println("note: this manifest still has an 'erosion' block; D-47 replaced it with 'pipeline' and it is ignored") } geoSize := m.GeologySize() geoCell := m.GeologyCellM() preview := *size > 0 if preview { // A --size run keeps the manifest's physical extent and just samples it more coarsely, so the // metres, the uplift rates and the stream-power constants all still mean what they mean. geoSize = *size geoCell = m.SideM() / float64(*size-1) } outDir := *out if outDir == "" { if preview { outDir = filepath.Join(manifest.ProjectRoot(path), "RawContent", "World", "Preview") } else { outDir = filepath.Join(filepath.Dir(path), "Heightmaps") } } log := func(format string, a ...any) { if !*quiet { fmt.Printf(format+"\n", a...) } } log("%s", m.Describe()) log("geology grid %d at %.2f m a cell, %d cores, GOMAXPROCS %d", geoSize, geoCell, runtime.NumCPU(), runtime.GOMAXPROCS(0)) if preview { log("preview run: the geology grid only, written to %s", outDir) } started := time.Now() up := uplift.Build(geoSize, geoCell, m) rateLo, rateHi := up.Rate.MinMax() hLo, hHi := up.Height.MinMax() landFrac := fractionTrue(invert(up.Base)) kLo, kHi := float32(1), float32(1) if up.K != nil { kLo, kHi = up.K.MinMax() } log("uplift %.3f..%.3f mm/yr, K x%.2f..%.2f, %d faults, initial relief %.0f..%.0f m, %.0f%% land [%s]", float64(rateLo)*1000, float64(rateHi)*1000, kLo, kHi, len(up.Faults), hLo, hHi, landFrac*100, since(started)) h := up.Height.Clone() var grid *fluvial.Grid var kField []float32 if up.K != nil { kField = up.K.Data } if *stage != "uplift" { p := fluvial.Params{ K: m.Pipeline.Fluvial.K, M: m.Pipeline.Fluvial.M, N: m.Pipeline.Fluvial.N, DtYr: m.Pipeline.Fluvial.DtYr, Steps: m.Pipeline.Fluvial.Steps, Diffusion: m.Pipeline.Fluvial.DiffusionM2Yr, FillEvery: m.Pipeline.Fluvial.FillEvery, TalusSlope: thermal.TalusFromDegrees(m.Pipeline.Thermal.TalusDeg), ThermalEvery: m.Pipeline.Thermal.Every, ThermalPasses: m.Pipeline.Thermal.CoarsePasses, CriticalAreaM2: m.Pipeline.Fluvial.CriticalAreaM2, ChannelTaper: m.Pipeline.Fluvial.ChannelTaper, CriticalSlope: thermal.TalusFromDegrees(m.Pipeline.Fluvial.CriticalSlopeDeg), SlopeCap: m.Pipeline.Fluvial.SlopeCap, MaxHillslopeSub: m.Pipeline.Fluvial.MaxHillslopeSub, } hillslope := fmt.Sprintf("linear D %.3f m2/yr, repose clamp every %d steps", p.Diffusion, m.Pipeline.Thermal.Every) if p.CriticalSlope > 0 { hillslope = fmt.Sprintf("nonlinear D %.3f m2/yr, Sc %.0f deg, cap %.2f, up to %d sub-steps", p.Diffusion, m.Pipeline.Fluvial.CriticalSlopeDeg, p.SlopeCap, p.MaxHillslopeSub) } log("fluvial %d steps of %.0f yr (%.1f Myr), K %.1e, m %.2f, n %.2f, fill every %d", p.Steps, p.DtYr, float64(p.Steps)*p.DtYr/1e6, p.K, p.M, p.N, p.FillEvery) log("hillslope: %s", hillslope) grid = fluvial.NewGrid(geoSize, geoSize, geoCell, up.Base) grid.SetSeed(m.Source.Seed) // the flat-routing jitter; see internal/fluvial/jitter.go // Size the flood's bucket queue to the elevation the run can actually reach: the manifest's range, // with headroom for uplift that outruns erosion before the warning catches it. grid.SetElevationRange(m.ElevationM.Min-200, m.ElevationM.Max+500) solveStart := time.Now() grid.Run(h.Data, up.Rate.Data, kField, p, func(step, total int, pct float64) { if step == 0 { return } lo, hi := h.MinMax() elapsed := time.Since(solveStart) eta := time.Duration(float64(elapsed) / (pct / 100) * (1 - pct/100)) log(" %3.0f%% step %d/%d height %.0f..%.0f m eta %s", pct, step, total, lo, hi, eta.Round(time.Second)) }) log("fluvial done [%s]", since(solveStart)) } // The coast, last, on the terrain the solve produced: the sea floor, the surf and the sediment it moves. // It owns the sea floor outright — the ocean cells were held at sea level for the whole solve so that // rivers cut down to sea level and no further, and this is where they stop being held. coastStart := time.Now() var flow []float32 if grid != nil { flow = grid.Area } cs := coast.Build(coast.Input{ Height: h, Sea: up.Base, SeaLevelM: m.SeaLevelM, BreakM: -m.Pipeline.Continent.SeaFloorM.Hi(), AbyssM: -m.Pipeline.Continent.SeaFloorM.Lo(), Flow: flow, Seed: m.Source.Seed, Cfg: m.Pipeline.Coast, }) if m.Pipeline.Coast.Enabled { log("coast: shelf %.1f..%.1f km, surf reach %.0f m, drift %.0f m, %d fetch rays to %.0f m [%s]", m.Pipeline.Coast.ShelfKm.Lo(), m.Pipeline.Coast.ShelfKm.Hi(), m.Pipeline.Coast.SurfReachM, m.Pipeline.Coast.DriftM, m.Pipeline.Coast.FetchDirections, m.Pipeline.Coast.FetchRangeM, since(coastStart)) } // Everything downstream asks about the terrain rather than about the mask that seeded it: a beach the // coastal pass built is land and a headland it planed under the waterline is not, so the mask that the // statistics, the preview and the data maps use is the one the coast pass finished with. sea := cs.Sea land := invert(sea) landFrac = fractionTrue(land) hLo, hHi = h.MinMax() landLo, landHi := minMaxWhere(h.Data, land) rep := stats.Report{ LandFraction: landFrac, ClipFraction: m.ClipFraction(h.Data), MinM: float64(hLo), MaxM: float64(hHi), ReliefM: float64(hHi - hLo), LandMinM: landLo, LandMaxM: landHi, LandReliefM: landHi - landLo, Slopes: stats.ComputeSlopes(h, land), Hypsometry: stats.ComputeHypsometry(h, land), Buckets: stats.UpliftBuckets(h, up.Rate.Data, land, m.Pipeline.Thermal.TalusDeg, *reliefWindowM), } if grid != nil { threshold := *channelKm2 * 1e6 // the incoming spec's channel definition is 1 km² stats.SetExpected(m.Pipeline.Fluvial.M, m.Pipeline.Fluvial.N) rep.SlopeArea = stats.ComputeSlopeArea(h, grid.Area, grid.Receiver, grid.Length, land, up.Rate.Data, kField, m.Pipeline.Fluvial.K, m.Pipeline.Fluvial.N, threshold) rep.DrainageDensity = stats.DrainageDensity(grid.Area, land, geoCell, threshold) } fmt.Println(rep.Summary()) if m.Pipeline.Coast.Enabled { fmt.Println() fmt.Println(cs.Stats.Summary()) } if rep.ClipFraction > 0.001 { fmt.Printf("\nWARNING: %.2f%% of the map is outside elevation_m %g..%g. U/K is the relief knob and the\n"+ " ceiling is a hard clip in the 16-bit encoding, so this is a failed run, not a rounded one.\n", rep.ClipFraction*100, m.ElevationM.Min, m.ElevationM.Max) } if err := os.MkdirAll(outDir, 0o755); err != nil { return err } if err := field.WriteThumbnail(filepath.Join(outDir, "thumb.png"), h, 512); err != nil { return err } // The one that is actually worth looking at: hypsometric tint, hillshade and the drainage network. pv := field.PreviewOptions{Sea: sea, SeaLevelM: m.SeaLevelM, RiverKm2: 0.5, Size: 1600} if grid != nil { flow := field.New(h.W, h.H, h.CellM) copy(flow.Data, grid.Area) pv.Flow = flow } if err := field.WritePreview(filepath.Join(outDir, "preview.png"), h, pv); err != nil { return err } // A detail crop as well, always. The whole continent at 1500 px cannot show whether the lowlands read as // hill country or as small mountains, and that distinction is the current question. detail := pv detail.Crop = [4]float64{*cropX, *cropY, *cropX + *cropSize, *cropY + *cropSize} detail.Size = 1400 detail.RiverKm2 = 0.15 detail.Exaggeration = 2.0 if err := field.WritePreview(filepath.Join(outDir, "preview_detail.png"), h, detail); err != nil { return err } // The geology-grid height, so a preview run has something to look at. The full-resolution height belongs // to the detail passes, which are not built yet (build-order steps 5 to 8). if err := field.WriteGray16(filepath.Join(outDir, "geology_height.png"), h.W, h.H, m.Encode(h.Data), png.DefaultCompression); err != nil { return err } if grid != nil { flow := field.New(h.W, h.H, h.CellM) copy(flow.Data, grid.Area) unit := flow.ToUnit(99.5, true) if err := field.WriteGray8(filepath.Join(outDir, "geology_flow.png"), unit.W, unit.H, toBytes(unit.Data), png.BestSpeed); err != nil { return err } } // The false-colour maps: the inputs the run worked from and the structure it produced, beside the result. // preview.png says whether the landscape looks right; these say why it looks the way it does, and when it // does not they are where the answer is. See internal/field/datamap.go. if err := writeDataMaps(outDir, h, up, cs, grid, *mapSize); err != nil { return err } meta := map[string]any{ "seed": m.Source.Seed, "generated_at": time.Now().UTC().Format(time.RFC3339), "geology_size": geoSize, "geology_cell_m": geoCell, "preview": preview, "manifest": m, "stats": rep, "coast": cs.Stats, "elapsed_s": time.Since(started).Seconds(), } blob, err := json.MarshalIndent(meta, "", " ") if err != nil { return err } if err := os.WriteFile(filepath.Join(outDir, "meta.json"), blob, 0o644); err != nil { return err } log("\nwritten to %s in %s", outDir, since(started)) return nil } func toBytes(v []float32) []uint8 { out := make([]uint8, len(v)) for i, x := range v { if x < 0 { x = 0 } else if x > 1 { x = 1 } out[i] = uint8(x*255 + 0.5) } return out } func invert(b []bool) []bool { out := make([]bool, len(b)) for i, v := range b { out[i] = !v } return out } func minMaxWhere(v []float32, mask []bool) (float64, float64) { lo, hi := math.Inf(1), math.Inf(-1) for i, x := range v { if !mask[i] { continue } f := float64(x) if f < lo { lo = f } if f > hi { hi = f } } if math.IsInf(lo, 1) { return 0, 0 } return lo, hi } func fractionTrue(b []bool) float64 { if len(b) == 0 { return 0 } n := 0 for _, v := range b { if v { n++ } } return float64(n) / float64(len(b)) } func since(t time.Time) string { return time.Since(t).Round(time.Millisecond).String() } // findManifest walks up from the working directory, so the command works from anywhere in the repository // rather than only from the root. func findManifest(explicit string) (string, error) { if explicit != "" { return explicit, nil } dir, err := os.Getwd() if err != nil { return "", err } for i := 0; i < 8; i++ { candidate := filepath.Join(dir, "RawContent", "World", "World.json") if _, err := os.Stat(candidate); err == nil { return candidate, nil } parent := filepath.Dir(dir) if parent == dir { break } dir = parent } return "", fmt.Errorf("no RawContent/World/World.json above %q; pass --manifest", mustWd()) } func mustWd() string { d, _ := os.Getwd() return d } // writeDataMaps renders the run's inputs and structure as false-colour PNGs beside the result. // // Which maps, and why each one earns its place: // // - map_uplift: rock uplift in mm/yr, the field everything else is a consequence of. Steady-state slope is // U/(K*A^m), so this map and the slope map should be recognisably the same picture; when they are not, // something downstream is overriding the tectonics, which is exactly how the repose clamp turned out to // be the surface of the whole continent. // - map_erodibility: the lithology multiplier on K. Hard bands stand up as ridges and soft ones are cut // away, so this is where the texture inside a range comes from. // - map_slope: degrees. The one to read next to map_uplift. // - map_relief: local relief over 500 m, which separates a 5 m hummock from a 500 m mountainside — both // can stand at 30 degrees and the slope map cannot tell them apart. // - map_basins: one colour per drainage basin. The direct picture of whether the solve made a network. // - map_flow: log drainage area, the rivers themselves. func writeDataMaps(dir string, h *field.Field, up *uplift.Result, cs *coast.Result, grid *fluvial.Grid, size int) error { sea := cs.Sea rateMmYr := field.NewLike(up.Rate) for i, v := range up.Rate.Data { rateMmYr.Data[i] = v * 1000 } if err := field.WriteDataMap(filepath.Join(dir, "map_uplift.png"), rateMmYr, field.DataMapOptions{Sea: sea, Size: size, Palette: field.Inferno}); err != nil { return err } if up.K != nil { if err := field.WriteDataMap(filepath.Join(dir, "map_erodibility.png"), up.K, field.DataMapOptions{Sea: sea, Size: size}); err != nil { return err } } slope := h.Slope() deg := field.NewLike(slope) for i, s := range slope.Data { deg.Data[i] = float32(math.Atan(float64(s)) * 180 / math.Pi) } if err := field.WriteDataMap(filepath.Join(dir, "map_slope.png"), deg, field.DataMapOptions{Sea: sea, Size: size, Lo: 0, Hi: 45, Palette: field.Inferno}); err != nil { return err } if err := field.WriteDataMap(filepath.Join(dir, "map_relief.png"), localRelief(h, 500), field.DataMapOptions{Sea: sea, Size: size, Palette: field.Inferno}); err != nil { return err } // The coast's own two. Exposure is the input both shore processes are driven by, and it is the one to // read when a beach turns up on a headland or a cliff at the back of a bay. The change map is the pass's // whole effect in one picture: cool where the surf cut, warm where the sediment landed. // // Exposure is drawn only within a kilometre of the water, and the rest is rendered as the flat "no data" // colour. That is not tidiness: exposure is measured on the waterline and carried to every other cell by // "the stretch of shore nearest to you", which past a few hundred metres is a map of the continent's // medial axis rather than of anything coastal. The first render of it was a sunburst of polygonal wedges // meeting in the middle of the continent, which says nothing about a coast and hides what does. band := make([]bool, len(cs.Exposure.Data)) for i, d := range cs.Geometry.Dist.Data { band[i] = math.Abs(float64(d)) > 1000 } if err := field.WriteDataMap(filepath.Join(dir, "map_exposure.png"), cs.Exposure, field.DataMapOptions{Sea: band, Size: size, Lo: 0, Hi: 1, Palette: field.Inferno}); err != nil { return err } if err := field.WriteDataMap(filepath.Join(dir, "map_coast.png"), cs.Change, field.DataMapOptions{Size: size, Lo: -30, Hi: 30, Palette: field.Divergent}); err != nil { return err } if grid == nil { return nil } flow := field.New(h.W, h.H, h.CellM) copy(flow.Data, grid.Area) if err := field.WriteDataMap(filepath.Join(dir, "map_flow.png"), flow, field.DataMapOptions{Sea: sea, Size: size, Log: true}); err != nil { return err } return field.WriteBasinMap(filepath.Join(dir, "map_basins.png"), h.W, h.H, grid.Receiver, sea, size) } // localRelief is max minus min over a square window, as a field. Separable: the row pass then the column // pass, each a sliding min and max, so the cost does not grow with the window. func localRelief(h *field.Field, windowM float64) *field.Field { r := int(math.Round(windowM / h.CellM / 2)) if r < 1 { r = 1 } rowLo, rowHi := field.NewLike(h), field.NewLike(h) for y := 0; y < h.H; y++ { for x := 0; x < h.W; x++ { lo, hi := float32(math.Inf(1)), float32(math.Inf(-1)) for d := -r; d <= r; d++ { v := h.AtClamped(x+d, y) if v < lo { lo = v } if v > hi { hi = v } } rowLo.Data[y*h.W+x], rowHi.Data[y*h.W+x] = lo, hi } } out := field.NewLike(h) for y := 0; y < h.H; y++ { for x := 0; x < h.W; x++ { lo, hi := float32(math.Inf(1)), float32(math.Inf(-1)) for d := -r; d <= r; d++ { if v := rowLo.AtClamped(x, y+d); v < lo { lo = v } if v := rowHi.AtClamped(x, y+d); v > hi { hi = v } } out.Data[y*h.W+x] = hi - lo } } return out }