This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+584 -19
View File
@@ -11,6 +11,8 @@ import (
"math"
"os"
"path/filepath"
"salty/terrain/internal/plates"
)
// The engine maps heightmap value v to local height (v - 32768) / 128 * ZScale cm, so ZScale 100 spans 512 m.
@@ -110,6 +112,18 @@ type Fluvial struct {
CriticalSlopeDeg float64 `json:"critical_slope_deg"`
SlopeCap float64 `json:"slope_cap"`
MaxHillslopeSub int `json:"max_hillslope_substeps"`
// MFDExponent is the exponent on the multiple-flow-direction partition of drainage area. 0 goes back to
// D8's single receiver, which is what every bake before this one ran. See internal/fluvial/mfd.go.
MFDExponent float64 `json:"mfd_exponent"`
}
// Smooth is the edge-preserving pass that runs once after the solve. It is a filter, not a process, and it
// is off by default: 0 passes. See internal/field/smooth.go for why it cannot go inside the step loop, and
// Docs/Terrain-Next.md for the statistics a run with it on has to match a run with it off.
type Smooth struct {
Passes int `json:"passes"`
SlopeRef float64 `json:"slope_ref"` // rise over run; ground steeper than this is preserved
}
type Thermal struct {
@@ -127,12 +141,43 @@ type Strata struct {
type Detail struct {
Octaves int `json:"octaves"`
AmplitudeM Range `json:"amplitude_m"`
// ClassBlendM is how far a painted class's detail numbers fade into its neighbour's.
//
// The class *index* is never interpolated - a class is a name - but the numbers it stands for are
// quantities, and a boundary somebody drew with a mouse should not be a step in the ground. At 0 it is a
// step, which is what it was before: seven metres of dune amplitude to two in the width of one cell.
ClassBlendM float64 `json:"class_blend_m"`
// SeabedM is how deep the detail texture reaches below the waterline, and the reason it is not zero is
// that a coast where the land is rough and the water is glass reads as a cut-out rather than as a shore.
// The amplitude fades in from nothing at the waterline - a few metres of noise there turns the shallows
// into a scatter of one-cell islands - and back out to nothing at this depth.
SeabedM float64 `json:"seabed_m"`
// TilePx is the interior side of a detail tile, in detail cells. It must divide the planet's width in
// geology cells once divided by geology_factor, because X wraps and a tile grid that did not come out
// whole would leave the last tile overlapping the first by an arbitrary amount.
TilePx int `json:"tile_px"`
}
// Particle is the droplet block, demoted by D-47 from "carves the valleys" to detail only. Every brake in it
// was learned the hard way; see Docs/Terrain.md.
type Particle struct {
Droplets int `json:"droplets"`
Droplets int `json:"droplets"`
// DropletsPerCell is what the tiled detail pass uses instead of Droplets, because a tile does not know
// how big the world is and must not: a cell has to spawn the same droplets whichever tile it falls in.
// 0.18 is the density Droplets 9e6 at 7141 squared comes to, which is the density the numpy was tuned at.
DropletsPerCell float64 `json:"droplets_per_cell"`
// Rounds is how many passes the droplets are split into. Within a round they read the height as it was
// when it began, so this is what lets a channel deepen as more water follows it; the numpy got the same
// effect from its batch size, and this is that number expressed so it does not depend on how big a piece
// of the world is being worked on. A tile and the whole map must agree about which round a droplet is in
// or the seams would not close.
Rounds int `json:"rounds"`
Lifetime int `json:"lifetime"`
Scale float64 `json:"scale"`
MinErodeSlope float64 `json:"min_erode_slope"`
@@ -190,7 +235,18 @@ type Coast struct {
// range that this replaces was neither. ShelfKm is a range because the shelf width is not a constant:
// it is wide off a low coastal plain and narrow off a mountain range that comes down to the water, so it
// is interpolated per stretch of shore by the relief standing behind that stretch.
ShelfKm Range `json:"shelf_km"`
ShelfKm Range `json:"shelf_km"`
// BreakM is the depth at the shelf break, in positive metres: how deep the water is where the gentle
// shelf ends and the continental slope begins. It is the one number that decides how deep the near-shore
// sea *is*, and until D-64 it was not a key at all - it was read off `continent.sea_floor_m.hi()`, whose
// default is -30 because the square canvas is 14.28 km a side and a real margin does not fit on it.
// Applied unchanged to a 100 km painted planet that reads as no ocean at all: the derived margin is up to
// 3 km of shelf and 1.6 km of slope, 1069 km of shoreline carries 4900 km2 of it against a 3100 km2 sea,
// so the margin covers the whole ocean and pins it between 0 and 30 m whatever the author painted. Zero
// keeps the old behaviour, which is what the square canvas wants; a planet gets 130 m from
// fillPlanetDefaults, and the first template's own `shelf` class is 120 m, which is the same number by
// the other route. The break can never be deeper than the water it is a break in - see layShelf.
BreakM float64 `json:"break_m"`
SteepCoastM float64 `json:"steep_coast_m"`
SlopeKm float64 `json:"slope_km"`
ShelfExponent float64 `json:"shelf_exponent"`
@@ -224,19 +280,219 @@ type Coast struct {
RiverChannelKm2 float64 `json:"river_channel_km2"`
}
// CoastDetail is the shore at the detail cell: pass 11b, and the one landform the geology grid cannot hold.
//
// Every length in it is in metres and none of them scales with the canvas, which is the argument for it being
// a block of its own rather than more knobs on Coast. The geology pass decides where the shore is, how far the
// surf reaches and how sheltered each stretch is, and those are *its* numbers, read from Coast; this decides
// what the shore looks like once there are cells small enough to draw it.
type CoastDetail struct {
Enabled bool `json:"enabled"`
// Crenulation moves the whole profile in and out along the shore, which is what a crenulate coastline is.
// It is added to the distance rather than to the height and it is drawn at the nearest waterline cell, so
// it varies along the shore and not across it. This is the fine end of the same idea as the template's
// coast_jitter_px, three orders of magnitude down: that one decides which pixels are land, this one wiggles
// a waterline that is already decided.
CrenulationM float64 `json:"crenulation_m"`
CrenulationWaveM float64 `json:"crenulation_wavelength_m"`
// ShoreSmoothM is how far the signed distance to the waterline is smoothed before the profile is measured
// from it, and it is not cosmetic. On a coastal plain the ground crosses sea level at a grade of about
// one in a hundred, so the land mask there is not a line but a forty-metre band of speckle, and a profile
// measured from it builds a separate two-metre berm on every isolated cell in it. Measured on the first
// run of the pass: a string of beads down the whole coast at a spacing of twenty to thirty metres.
// Smoothing the distance rather than the mask is what keeps the profile a profile - the shoreline moves,
// the shape crossing it does not.
ShoreSmoothM float64 `json:"shore_smooth_m"`
// The beach. DeanA is the A of the equilibrium profile depth = A*x^(2/3), in metres to the one third: 0.1
// is fine sand and 0.2 is coarse. BermBackM is how far inland the berm crest is held before the profile
// hands back to whatever the droplets left.
DeanA float64 `json:"dean_a"`
BermBackM float64 `json:"berm_back_m"`
// BeachFillM is the most sediment a beach may lay on what is already there. The equilibrium profile is a
// target *depth*, so without a cap a shore with deep water close in - a drowned valley, which is an
// ordinary thing - gets tens of metres of sand invented to bring the floor up to the curve.
BeachFillM float64 `json:"beach_fill_m"`
// The cliff. A stretch of shore is a beach below CliffFromM of backshore and a cliff above CliffToM, and
// blended between. CliffGrade is the tangent of the angle the face stands at - 2.75 is 70 degrees, which
// is a sea cliff rather than a hillside. ScreeDeg is the angle its debris comes to rest at and ScreeReachM
// is how far out from the foot the apron reaches.
// CliffMaxM is how tall a face the surf is allowed to have cut. Past it the ground is a mountain coming
// down to the water rather than a wave-cut cliff, and its face is a hillslope that belongs to the solve.
// Without it a coastal range gets a seventy-degree wall carved four hundred metres inland, because the
// only thing stopping the face is the ground rising faster than it does.
CliffFromM float64 `json:"cliff_from_m"`
CliffToM float64 `json:"cliff_to_m"`
CliffMaxM float64 `json:"cliff_max_m"`
CliffGrade float64 `json:"cliff_grade"`
ScreeDeg float64 `json:"scree_repose_deg"`
ScreeReachM float64 `json:"scree_reach_m"`
// PlatformReliefM is how far the strata field is allowed to move the shore platform, which is how a
// platform gets its ledges and runnels instead of being planed flat.
PlatformReliefM float64 `json:"platform_relief_m"`
// SmoothReachM is how far past the profile the shore damps the ground's *roughness* - not its shape.
//
// The profile itself is only a few tens of metres wide, so without this the ground goes from a drawn
// beach to full dune amplitude and droplet rills in the width of the taper, and the beach reads as a
// ribbon laid on top of the terrain rather than as part of it. What this does is blend the surface
// towards a smoothed copy of itself over a wider band: the relief is untouched, the metre-scale texture
// fades, and the backshore of a beach comes out smoother than the hillside behind it - which is what a
// backshore is. 0 turns it off.
SmoothReachM float64 `json:"smooth_reach_m"`
}
type Pipeline struct {
GeologyFactor int `json:"geology_factor"`
Continent Continent `json:"continent"`
Coast Coast `json:"coast"`
Plates Plates `json:"plates"`
Faults Faults `json:"faults"`
Lithology Lithology `json:"lithology"`
Relief Relief `json:"relief"`
Fluvial Fluvial `json:"fluvial"`
Thermal Thermal `json:"thermal"`
Strata Strata `json:"strata"`
Detail Detail `json:"detail"`
Particle Particle `json:"particle"`
GeologyFactor int `json:"geology_factor"`
Continent Continent `json:"continent"`
Coast Coast `json:"coast"`
CoastDetail CoastDetail `json:"coast_detail"`
Plates Plates `json:"plates"`
Faults Faults `json:"faults"`
Lithology Lithology `json:"lithology"`
Relief Relief `json:"relief"`
Fluvial Fluvial `json:"fluvial"`
Thermal Thermal `json:"thermal"`
Smooth Smooth `json:"smooth"`
Strata Strata `json:"strata"`
Detail Detail `json:"detail"`
Particle Particle `json:"particle"`
}
// Planet turns a manifest into a planet-scale bake driven by a painted template instead of a seed.
//
// Its presence is what switches the generator from the square canvas to the cylinder; a manifest without it
// is the world the `generate` command has always built, unchanged. Paths are relative to the manifest file.
type Planet struct {
Template string `json:"template"` // the painted map
Legend string `json:"legend"` // what its colours mean
// Palette is how the preview is *drawn* - the hypsometric ramp, the water, the rivers, the ice and the
// light. Optional, and deliberately a file of its own rather than part of the legend: the legend says
// what the colours in the input mean and is about the world, while this is purely a matter of taste
// about the picture, and taste is the thing most likely to want swapping. Empty means the generator's
// own, which internal/field.DefaultPalette holds.
Palette string `json:"palette"`
// CircumferenceKm is how far it is all the way round. With the geology cell fixed at 8 m by D-48 this
// is the one number that sets how big the world is, and it must be a whole number of cells or the seam
// would fall between two columns.
CircumferenceKm float64 `json:"circumference_km"`
// OceanMarginKm is how much water each region carries around its landmass.
//
// The solve needs only one cell of it - a grid edge is an outlet, and the edge has to be water - because
// the coastal pass runs once on the whole cylinder rather than per region. What the margin actually
// decides is clustering: two landmasses within twice this distance are solved in one box, which is the
// right call when they are close enough to be one drainage problem and a waste of memory when they are
// not.
OceanMarginKm float64 `json:"ocean_margin_km"`
// MinLandCells drops specks. A stray paint pixel classified as land would otherwise cost a whole region
// for a rock; below this many cells a landmass goes back to the sea and the run says how many did.
MinLandCells int `json:"min_land_cells"`
// PadClass is the sea class filling the synthetic rows above and below the painted map, which exist so
// that a polar cap has a shore to drain to. Empty means the legend's first sea class.
PadClass string `json:"pad_class"`
// NoisePeriodKm is how far a world-coordinate noise lattice runs before repeating. It must divide the
// circumference exactly or every noise field breaks at the seam. Zero means one turn.
NoisePeriodKm float64 `json:"noise_period_km"`
// DetailNoisePeriodKm is the same thing for the detail passes, and it is short because it has to be: a
// noise lattice holds (period/wavelength)^2 floats, so an eight-metre octave on a hundred-kilometre
// period is a gigabyte and a half. What repeats at a kilometre is a few metres of surface roughness with
// no shape to it; everything with a shape comes from the solve and the paint, which do not repeat.
// It must divide the circumference too.
DetailNoisePeriodKm float64 `json:"detail_noise_period_km"`
// UpliftVariation is how much the painted uplift rate is modulated by sub-pixel noise, as a fraction.
//
// It is not decoration. D-49: uniform uplift over a wide area produces no divides, and with no divides
// the router falls back on the priority-flood's epsilon and draws its traversal order as rivers. A
// painted lowland holds one rate over tens of kilometres, so without this it would come out table-flat
// with the flood's geometry scratched across it.
UpliftVariation float64 `json:"uplift_variation"`
// MassifWavelengthKm is how big the planet's upland fabric is: the size of the blocks a class with a
// massif breaks into. One fabric for the whole world rather than one per class, deliberately, so that a
// highland belt and the hills in the lowland beside it are high and low parts of a single structure - a
// foreland and its outliers - instead of two unrelated noises that happen to meet at a painted edge.
//
// It is rounded to a whole number of lattice cells in the noise period, because noise.Lattice.Sample
// wraps modulo its cell count and anything else breaks at the seam. `terrain plan` prints what it was
// rounded to.
MassifWavelengthKm float64 `json:"massif_wavelength_km"`
// LithologyWavelengthKm is how big the planet's rock provinces are. Zero means no lithology at all, which
// is what every painted planet had before D-58: one flat erodibility inside each painted class, so
// map_erodibility.png was a recolour of map_class.png and there was nothing to make one flank of a range
// read differently from the next.
//
// The types and their multipliers are `pipeline.lithology`, shared with the procedural path. What is new
// here is the wavelength, because a province on a 100 km planet is a different size from one on a 14 km
// canvas, and the cut is a quantile of the planet rather than a percentile of whatever grid is in front
// of it - see internal/uplift's painted_rock.go for why that distinction is not optional.
LithologyWavelengthKm float64 `json:"lithology_wavelength_km"`
// FaultGrainKm is the wavelength of the fault set's orientation field: faults within one of its cells
// come out sub-parallel, and the strike swings gradually across the world.
//
// It is a field rather than one global angle because a single strike is what the procedural path has and
// it reads as corduroy across a whole map. Which classes are faulted at all, and how hard, is the
// legend's `faults` block; this is only how they are aimed.
FaultGrainKm float64 `json:"fault_grain_km"`
// CoastJitterPx perturbs the painted waterline by this many template pixels of world-coordinate noise.
//
// An upsampled painted outline is a smooth polygon, and a coastline is fractal - which is the whole
// content of the Richardson paradox and, measured, the difference between a shore with bays the shelter
// model can work with and one the fetch reports as fully open everywhere.
CoastJitterPx float64 `json:"coast_jitter_px"`
// CoastJitterWavelengthPx is the coarsest octave: the size of the biggest bay it can cut, in template
// pixels. Octaves halve from there, so the finest detail is this over 2^(octaves-1).
CoastJitterWavelengthPx float64 `json:"coast_jitter_wavelength_px"`
// CoastJitterOctaves and CoastJitterGain are the fractal structure. A gain near 0.5 makes each scale as
// prominent as the last, which is the property a real coastline has and a single wobble does not - it is
// the whole content of the Richardson paradox, and it is why one octave reads as a wobbly line rather
// than as a coast.
CoastJitterOctaves int `json:"coast_jitter_octaves"`
CoastJitterGain float64 `json:"coast_jitter_gain"`
// Overlay and OverlayLegend are the annotation layer: a second painting registered to the first, and a
// legend of marks saying what its colours stand for. Both empty means there is no overlay, which is what
// every planet had before D-57 and what one still has until an author paints one.
//
// It is a second *image* rather than more colours on the first because the two answer different
// questions. A class is geology - every colour on the template changes an uplift rate or an erodibility,
// and the solve answers for it - while a mark is a thing placed on the finished world: a forest, a
// village, a road, or a stretch of coast the author drew deliberately and does not want roughened. There
// is no uplift rate for a town, and a mark has to be able to sit on top of any class without changing it.
//
// See internal/overlay. Only one mark property is read by the generator at all (coast_jitter); the rest
// travel through to the engine as per-tile masks and as features in world metres in overlay.json.
Overlay string `json:"overlay"`
OverlayLegend string `json:"overlay_legend"`
// Plates is the tectonic model: how many rigid pieces the lithosphere is in and how fast they move.
//
// It is `planet.plates` rather than `pipeline.plates` deliberately. The two are different models of the
// same word: the procedural block below is a percentile range band over whatever grid it is handed, which
// D-53 forbids on a decomposed planet, while this one is drawn once for the whole cylinder in world
// metres and produces boundary *geometry* - the lines pass 3 was always specified to read.
//
// A count of zero switches it off, which is what every template painted before it had. It is off by
// default because nothing in the solve reads it yet: what it produces today is a diagnostic map and a
// set of lines in meta.json.
Plates plates.Config `json:"plates"`
}
type Manifest struct {
@@ -253,6 +509,9 @@ type Manifest struct {
Layers Layers `json:"layers"`
Pipeline Pipeline `json:"pipeline"`
// Planet is present only on a planet manifest. Its absence is what keeps `generate` exactly as it was.
Planet *Planet `json:"planet"`
// Erosion is the pre-D-47 block. Kept only so a manifest that still carries it can be reported rather
// than silently ignored.
Erosion map[string]any `json:"erosion"`
@@ -311,6 +570,25 @@ func Defaults() *Manifest {
// has a number to work from instead of an impression of a picture.
RiverM3PerKm2: 1.2e5, RiverExponent: 0.6, RiverChannelKm2: 0.5,
},
CoastDetail: CoastDetail{
Enabled: true,
// A bay a hundred and twenty metres across with six metres of wander in it. That is the
// scale the template's own coast_jitter cannot reach: its wavelength is 384 template px,
// which is five kilometres here, and its finest octave is still 150 m of paint.
CrenulationM: 6, CrenulationWaveM: 120, ShoreSmoothM: 12,
// Dean's A for medium sand. 0.12 puts the 2 m contour 65 m offshore and the 5 m contour
// 260 m, which is a beach you can wade out on and not a shelf.
DeanA: 0.12, BermBackM: 25, BeachFillM: 3,
// A coast with eight metres of land behind it is a beach; one with thirty is a cliff. Both
// are the backshore *mean* between one and two surf reaches inland, so a low headland in a
// bay does not turn the bay into a cliff coast.
CliffFromM: 8, CliffToM: 30, CliffMaxM: 60,
// tan 70 degrees. A heightfield cannot hold an overhang, so a wave-cut notch is the one
// piece of a cliff this pass cannot draw; what it can do is stop a thirty-metre cliff
// arriving as a four-cell ramp, which is what the upsample makes of it.
CliffGrade: 2.75, ScreeDeg: 34, ScreeReachM: 30,
PlatformReliefM: 0.6, SmoothReachM: 90,
},
Plates: Plates{
Count: 6, VelocityCmYr: Range{1, 5}, BandKm: Range{2, 4},
DivergentMmYr: Range{-2, -1}, RiftKm: Range{3, 6},
@@ -378,12 +656,31 @@ func Defaults() *Manifest {
CriticalSlopeDeg: 35,
SlopeCap: 0.9,
MaxHillslopeSub: 24,
// One, and the choice is not a tuning decision. On a planar hillslope the correct specific
// catchment area is the same at every point along a contour, and D8 cannot say so: it gives
// one cell the whole flow tube and its neighbour a single cell for ever. Measured on a ramp
// at an aspect of 22.5 degrees, the most-drained cell in a contour band carried 769 times
// the median and 30 % of the grid drained nothing; at an exponent of one it is 1.34 and
// 0.4 %. Raising it past one narrows the spread again, so it is the knob to reach for if
// map_flow reads as broad smears rather than rivers - but a real valley has its cross-valley
// neighbours *above* it, which get zero weight whatever the exponent, so MFD is already D8
// wherever convergence is real.
MFDExponent: 1,
},
Thermal: Thermal{CoarsePasses: 2, Every: 4, FinePasses: 24, TalusDeg: 35},
Strata: Strata{PeriodM: 160, Contrast: 0.6},
Detail: Detail{Octaves: 4, AmplitudeM: Range{2, 8}},
// Off. Turning it on is a decision to hide something rather than to fix it, so it is a decision
// somebody makes in a file. 0.3 is about seventeen degrees: steeper than that is a landform and
// is left alone.
Smooth: Smooth{Passes: 0, SlopeRef: 0.3},
Strata: Strata{PeriodM: 160, Contrast: 0.6},
// ClassBlendM 120 is fifteen geology cells, which is exactly half the tile margin and therefore the
// most a tile can blend without reading past its own cut: two passes of a box blur reach twice the
// radius. SeabedM 24 is twice the shore taper, so the texture is fully in by the time the water is
// deep enough to hold it and gone again before the shelf.
Detail: Detail{Octaves: 4, AmplitudeM: Range{2, 8}, TilePx: 2500, ClassBlendM: 120, SeabedM: 24},
Particle: Particle{
Droplets: 9000000, Lifetime: 40, Scale: 0.5, MinErodeSlope: 0.25, MaxChange: 0.2,
Droplets: 9000000, DropletsPerCell: 0.18, Rounds: 16,
Lifetime: 40, Scale: 0.5, MinErodeSlope: 0.25, MaxChange: 0.2,
MaxSpeed: 5, MaxLoad: 2, Inertia: 0.1, Capacity: 2, MinSlope: 0.01,
ErodeRate: 0.2, DepositRate: 0.2, Evaporation: 0.02, Gravity: 4, Batch: 200000,
},
@@ -404,9 +701,254 @@ func Load(path string) (*Manifest, error) {
return nil, fmt.Errorf("%s: %w", path, err)
}
m.Path = path
m.fillPlanetDefaults()
return m, m.Validate()
}
// fillPlanetDefaults runs after the merge rather than in Defaults(), because the block is a pointer: a
// manifest without one is not a planet at all, and json.Unmarshal would allocate a zero struct over
// anything Defaults had put there.
func (m *Manifest) fillPlanetDefaults() {
p := m.Planet
if p == nil {
return
}
if p.CircumferenceKm == 0 {
p.CircumferenceKm = 100
}
if p.OceanMarginKm == 0 {
p.OceanMarginKm = 0.5
}
if p.MinLandCells == 0 {
p.MinLandCells = 16
}
if p.UpliftVariation == 0 {
p.UpliftVariation = 0.30
}
// 12 px is about 155 m on a 100 km planet drawn at 7738 px, and the octaves run from 2.5 km down to
// 155 m. The old default was 1.5 px, which is one template pixel of wobble and would have been invisible
// - it was never read by anything, so it was never a measured number.
if p.CoastJitterPx == 0 {
p.CoastJitterPx = 12
}
if p.CoastJitterWavelengthPx == 0 {
p.CoastJitterWavelengthPx = 192
}
if p.CoastJitterOctaves == 0 {
p.CoastJitterOctaves = 5
}
if p.CoastJitterGain == 0 {
p.CoastJitterGain = 0.55
}
if p.MassifWavelengthKm == 0 {
p.MassifWavelengthKm = 12
}
if p.NoisePeriodKm == 0 {
p.NoisePeriodKm = p.CircumferenceKm
}
if p.DetailNoisePeriodKm == 0 {
p.DetailNoisePeriodKm = 1
}
// A real shelf break, because on a planet a real margin fits. The square canvas's 30 m is not a shelf
// break at all, it is the shallow end of a 14 km canvas's sea floor range, and inheriting it here is
// what made a painted 512 m ocean come out as a 20 m pond (D-64).
if m.Pipeline.Coast.BreakM == 0 {
m.Pipeline.Coast.BreakM = 130
}
}
// ShelfBreakM is how deep the water is at the shelf break, in positive metres.
//
// The planet names it outright. The square canvas never did: its sea floor is a range and the shelf break is
// the shallow end of it, so the fallback keeps that reading rather than inventing a key for a manifest that
// was written without one.
func (m *Manifest) ShelfBreakM() float64 {
if m.Pipeline.Coast.BreakM > 0 {
return m.Pipeline.Coast.BreakM
}
return -m.Pipeline.Continent.SeaFloorM.Hi()
}
// IsPlanet reports whether this manifest describes a painted planet rather than the square canvas.
func (m *Manifest) IsPlanet() bool { return m.Planet != nil }
// TemplatePath and LegendPath resolve the planet's two inputs against the manifest's own directory.
func (m *Manifest) TemplatePath() string { return m.relative(m.Planet.Template) }
func (m *Manifest) LegendPath() string { return m.relative(m.Planet.Legend) }
// OverlayPath and OverlayLegendPath resolve the annotation layer, or "" when there is none. The image may
// be named by the manifest or, failing that, by the overlay legend itself; the manifest wins, which is what
// lets the studio's versioned saves repoint without rewriting a file the author wrote.
func (m *Manifest) OverlayLegendPath() string {
if m.Planet == nil || m.Planet.OverlayLegend == "" {
return ""
}
return m.relative(m.Planet.OverlayLegend)
}
// OverlayPath is the painted overlay named by the manifest, or "" when it names none.
func (m *Manifest) OverlayPath() string {
if m.Planet == nil || m.Planet.Overlay == "" {
return ""
}
return m.relative(m.Planet.Overlay)
}
// HasOverlay reports whether an annotation layer is configured at all.
func (m *Manifest) HasOverlay() bool { return m.OverlayLegendPath() != "" }
// PlatesLayerPath and PlatesLegendPath resolve the painted tectonic layer, or "" when there is none. Same
// shape as the overlay's pair above, and for the same reason: the manifest names the image so that a
// versioned save can be repointed without rewriting the legend an author wrote.
func (m *Manifest) PlatesLayerPath() string {
if m.Planet == nil || m.Planet.Plates.Layer == "" {
return ""
}
return m.relative(m.Planet.Plates.Layer)
}
func (m *Manifest) PlatesLegendPath() string {
if m.Planet == nil || m.Planet.Plates.Legend == "" {
return ""
}
return m.relative(m.Planet.Plates.Legend)
}
// HasPaintedPlates reports whether the tectonics are drawn rather than generated.
func (m *Manifest) HasPaintedPlates() bool {
return m.PlatesLayerPath() != "" && m.PlatesLegendPath() != ""
}
// PalettePath is the preview palette, or "" when the manifest names none.
func (m *Manifest) PalettePath() string {
if m.Planet == nil || m.Planet.Palette == "" {
return ""
}
return m.relative(m.Planet.Palette)
}
func (m *Manifest) relative(p string) string {
if p == "" || filepath.IsAbs(p) {
return p
}
return filepath.Join(filepath.Dir(m.Path), p)
}
// validatePlanet checks the numbers that would otherwise fail deep inside a bake, or - worse - not fail.
func (m *Manifest) validatePlanet() error {
p := m.Planet
if p.Template == "" {
return fmt.Errorf("%s: planet.template is empty", m.Path)
}
if p.Legend == "" {
return fmt.Errorf("%s: planet.legend is empty", m.Path)
}
if p.CircumferenceKm <= 0 {
return fmt.Errorf("%s: planet.circumference_km is %v", m.Path, p.CircumferenceKm)
}
cell := m.GeologyCellM()
cols := p.CircumferenceKm * 1000 / cell
if d := cols - math.Round(cols); d > 1e-9 || d < -1e-9 {
return fmt.Errorf("%s: a %.3f km circumference is %.4f cells of %.1f m. It must be a whole number, "+
"or the seam falls between two columns; the nearest that works is %.3f km",
m.Path, p.CircumferenceKm, cols, cell, math.Round(cols)*cell/1000)
}
if p.OceanMarginKm <= 0 {
return fmt.Errorf("%s: planet.ocean_margin_km is %v; a region needs a ring of water", m.Path, p.OceanMarginKm)
}
if p.CoastJitterPx < 0 {
return fmt.Errorf("%s: planet.coast_jitter_px is %v", m.Path, p.CoastJitterPx)
}
if p.CoastJitterPx > 0 {
if p.CoastJitterWavelengthPx <= 0 {
return fmt.Errorf("%s: planet.coast_jitter_wavelength_px is %v", m.Path, p.CoastJitterWavelengthPx)
}
if p.CoastJitterOctaves < 1 || p.CoastJitterOctaves > 12 {
return fmt.Errorf("%s: planet.coast_jitter_octaves is %d, outside 1..12",
m.Path, p.CoastJitterOctaves)
}
if p.CoastJitterGain <= 0 || p.CoastJitterGain >= 1 {
return fmt.Errorf("%s: planet.coast_jitter_gain is %v, outside 0..1 exclusive",
m.Path, p.CoastJitterGain)
}
}
if p.MassifWavelengthKm <= 0 {
return fmt.Errorf("%s: planet.massif_wavelength_km is %v", m.Path, p.MassifWavelengthKm)
}
for _, w := range []struct {
key string
km float64
}{{"lithology_wavelength_km", p.LithologyWavelengthKm}, {"fault_grain_km", p.FaultGrainKm}} {
if w.km < 0 {
return fmt.Errorf("%s: planet.%s is %v; it is a wavelength in kilometres", m.Path, w.key, w.km)
}
if w.km > p.NoisePeriodKm {
return fmt.Errorf("%s: planet.%s is %v km, longer than the noise period of %v km, so the field "+
"would be one lattice cell and flat over the whole world",
m.Path, w.key, w.km, p.NoisePeriodKm)
}
}
if p.MassifWavelengthKm > p.NoisePeriodKm {
return fmt.Errorf("%s: planet.massif_wavelength_km is %v against a noise period of %v. The fabric "+
"would be a single lattice cell, so every massif on the planet would be the same one",
m.Path, p.MassifWavelengthKm, p.NoisePeriodKm)
}
for _, np := range []struct {
key string
period float64
}{{"noise_period_km", p.NoisePeriodKm}, {"detail_noise_period_km", p.DetailNoisePeriodKm}} {
if np.period <= 0 {
return fmt.Errorf("%s: planet.%s is %v", m.Path, np.key, np.period)
}
if k := p.CircumferenceKm / np.period; math.Abs(k-math.Round(k)) > 1e-9 || k < 1 {
return fmt.Errorf("%s: planet.%s %v does not divide the circumference %v (%.4f times); every "+
"noise field built on it would break at the seam", m.Path, np.key, np.period,
p.CircumferenceKm, k)
}
}
return nil
}
// LithologyCells is the rock field's wavelength in lattice cells of the noise period, or 0 when the planet
// asks for no lithology. Rounded the same way MassifCells is and for the same reason: noise.Lattice.Sample
// wraps modulo its cell count, so anything else breaks at the seam.
func (p *Planet) LithologyCells() int {
if p.LithologyWavelengthKm <= 0 {
return 0
}
n := int(p.NoisePeriodKm/p.LithologyWavelengthKm + 0.5)
if n < 1 {
n = 1
}
return n
}
// MassifCells is the upland fabric's wavelength counted in lattice cells of the noise period, which is what
// noise.Params.BaseCells takes. It has to be a whole number: noise.Lattice.Sample wraps modulo its cell count,
// so a fraction of a cell at the seam is a discontinuity down one meridian.
func (p *Planet) MassifCells() int {
n := int(p.NoisePeriodKm/p.MassifWavelengthKm + 0.5)
if n < 1 {
n = 1
}
return n
}
// MassifWavelengthRoundedKm is the wavelength MassifCells actually delivers, which is what a run should
// report rather than what was asked for.
func (p *Planet) MassifWavelengthRoundedKm() float64 {
return p.NoisePeriodKm / float64(p.MassifCells())
}
// MarginCells is the ocean margin in geology cells, at least one.
func (p *Planet) MarginCells(cellM float64) int {
n := int(p.OceanMarginKm*1000/cellM + 0.5)
if n < 1 {
n = 1
}
return n
}
func (m *Manifest) Validate() error {
if m.VerticesPerSide < 2 {
return fmt.Errorf("%s: vertices_per_side must be at least 2", m.Path)
@@ -439,6 +981,9 @@ func (m *Manifest) Validate() error {
if f := m.Pipeline.GeologyFactor; f < 1 || q%f != 0 {
return fmt.Errorf("%s: geology_factor %d must divide the quad count %d exactly", m.Path, f, q)
}
if m.IsPlanet() {
return m.validatePlanet()
}
return nil
}
@@ -521,16 +1066,36 @@ func (m *Manifest) ClipFraction(metres []float32) float64 {
if len(metres) == 0 {
return 0
}
var n int
return float64(m.ClipCells(metres)) / float64(len(metres))
}
// ClipCells is the same count before it is turned into a fraction.
//
// A fraction of one region is not a fraction of a planet and cannot be made into one without carrying the
// region's size beside it, so anything that pools across regions counts cells and divides at the end. See
// internal/stats.
func (m *Manifest) ClipCells(metres []float32) int64 {
var n int64
for _, v := range metres {
if float64(v) < m.ElevationM.Min || float64(v) > m.ElevationM.Max {
n++
}
}
return float64(n) / float64(len(metres))
return n
}
// Encode turns metres into the 16-bit values the PNG carries, clamping to the range.
// Decode is Encode's inverse: 16-bit samples back to metres. It is what lets the detail bake read a geology
// bake's heightmap off disk instead of holding it, which is what makes the two commands separable.
func (m *Manifest) Decode(values []uint16) []float32 {
span := m.ElevationM.Max - m.ElevationM.Min
out := make([]float32, len(values))
for i, v := range values {
out[i] = float32(m.ElevationM.Min + float64(v)/65535*span)
}
return out
}
func (m *Manifest) Encode(metres []float32) []uint16 {
out := make([]uint16, len(metres))
for i, v := range metres {