package uplift import ( "math" "salty/terrain/internal/dt" "salty/terrain/internal/field" "salty/terrain/internal/manifest" "salty/terrain/internal/noise" "salty/terrain/internal/world" ) // The painted source, and the one decision behind all of it: paint the uplift, never the height. // // Docs/Terrain-Next.md 6 lists importing a painted heightmap under "do not redo these", and the reason is // not taste. A stream-power solve handed a painted surface erodes it into something else within a few // hundred steps, and what it produces instead has no relationship to what was drawn - while the drainage // network, which is the entire reason this generator replaced the droplet pipeline, is thrown away and // rebuilt from whatever the painting happened to leave behind. // // Painting the uplift rate instead means an author draws intent - a range here, lowlands there, a coast like // this - and the simulation produces terrain that honours it and has real rivers, real divides and a real // valley hierarchy, because those came out of the physics rather than out of the brush. // // What is left for noise to do is therefore narrow, and it is not decoration: // // - The regional swell. D-49 is arithmetic: with critical_area_m2 at 0 the steady-state slope is // U/(K*A^m) down to a single cell, so a uniform uplift rate over a wide area gives a surface with no // divides at all. A painted lowland holds one rate over tens of kilometres. Without a long-wavelength // modulation the plains come out table-flat, the only gradient across them is the priority-flood's // epsilon, and the router draws the flood's traversal order as rivers. That was measured once already. // - The initial relief, which only breaks the symmetry. Small on purpose: the solve is what produces // relief, and starting it from big ridges means it spends its run tearing them down. // // Every noise field here is built on world coordinates through noise.WorldUV, so two regions covering the // same physical place agree to the bit. That is rule 1 of the tiling plan. // Pass indices for the painted path's seeded sources. They sit above the procedural path's 1..9 so that the // two never share a stream and adding one here cannot reshuffle the other. // Feature counts, in lattice cells per noise period. They are named because the warp amounts are derived // from them - a warp is only meaningful as a fraction of the wavelength it is bending. const ( swellCells = 4 // 25 km at a 100 km period: the regional swell ridgeCells = 24 // 4.2 km: the initial relief crestCells = 64 // 1.6 km: the crest lines plainCells = 48 // 2.1 km: the lowland break-up ) const ( srcPaintSwell = 20 srcPaintRidges = 21 srcPaintCrests = 22 srcPaintPlains = 23 srcPaintWarp = 24 srcPaintMassif = 25 ) // Paint is a region's painted world: which class every cell is, which cells are land, and what the legend // says those classes mean. type Paint struct { Frame world.Frame Class []uint8 // one legend index per frame cell Land []bool // land the region owns; everything else is water, including other regions' islands Rates []float32 // per class, metres a year Ks []float32 // per class, the multiplier on stream-power K // PlainM and PlainFloor put a class's range inland: within PlainM metres of the waterline the rate ramps // from PlainFloor up to the class rate. Per class; zero PlainM means the class reaches the sea at its // full rate, which is what every class did before and still does unless an author asks otherwise. PlainM []float64 PlainFloor []float32 // MassifFloor and MassifFraction break a class into plain and upland instead of holding it at one rate. // Where the fraction is zero the class is uniform, which is what every class did before this existed and // what a legend that asks for nothing still gets. See massif.go: the class rate is then the rate a massif // reaches, the floor is the plain between them, and the fraction is how much of the class stands above // the midpoint of the two. MassifFloor []float32 MassifFraction []float64 // MassifCells is the fabric's wavelength in lattice cells of the noise period, from // manifest.Planet.MassifCells. Read only when some class asks for a massif. MassifCells int // RockCells and RockMult are the planet's lithology: the wavelength of the rock field in lattice cells, // and the erodibility multiplier of each rock type. LithMix is per class, how much of it shows through. // Zero cells, fewer than two multipliers, or every mix at zero means no rock field is built at all. RockCells int RockMult []float64 LithMix []float64 // Faults is the planet's whole fault set, in world metres. A region filters it to the traces that reach // into its own frame, which is why it is the planet's and not the region's: a fault crossing a region // boundary has to be one fault, and two decompositions of the same planet have to produce the same // escarpment. RunYears is how long the solve runs, which turns a total throw into a rate. Faults []FaultTrace RunYears float64 // ClampCeilM is the uplift rate at which a divide reaches the angle of repose, at K x1, in metres a year. // It bounds what a *fault* may add and nothing else: an author who paints a class past the ceiling gets // what they asked for and a warning from `terrain plan`, but a fault stacking on top of one is an // accident nobody chose. Zero switches the bound off. ClampCeilM float64 // Variation is how far the swell modulates the painted rate, as a fraction. See the note above: this // is what gives a painted plain its divides, and it is the first thing that will be cut for time. Variation float64 } // FromTemplate builds the geology inputs for one region of a painted planet. // // It is a sibling of Build rather than a branch inside it. Build's continent mask, percentile range band, // normalised swell and percentile lithology split are all global operations over the grid they are given, // and a region is not a world - two regions taking percentiles of their own extents would disagree about // the same rock. None of them survives here; the paint replaces all four. func FromTemplate(p Paint, m *manifest.Manifest) *Result { f := p.Frame cfg := m.Pipeline seed := m.Source.Seed w, h := f.W, f.H cellM := f.P.CellM u, v := noise.WorldUV(w, h, cellM, f.OriginXM(), f.OriginYM(), f.P.NoisePeriodM) // A low-frequency warp, which bends everything built on it so that ridges curve and cells are not // polygons. Build has one and this did not, which was a porting mistake with a very visible signature: // Terrain.md records that cellular crest lines without a strong enough warp "turn ranges into a honeycomb // of polygon walls", and that is exactly what the first painted mountains looked like - flat plates with // hard edges, at every uplift rate, which is how it was eventually told apart from the repose clamp. // // The warp amounts need converting rather than copying. Build works in map coordinates where 0..1 spans // the map once, so its 0.16 and 0.224 are fractions of a whole map; here 0..1 spans one noise period, and // what has to be preserved is the warp measured in the *feature's own wavelength*. Build warps the ridges // by 0.8 of their wavelength (0.16 against BaseCells 5) and the crests by 3.1 of theirs (0.224 against // BaseCells 14), so those ratios are what carry across. wx, wy := paintWarp(u, v, seed) // The regional swell: long-wavelength, so a painted lowland has hills and basins of its own rather than // one uniform rate across a whole continent. One turn of the planet at BaseCells 4 is a 25 km feature, // and four octaves take it down to about 3 km. ss := noise.NewSource(seed, srcPaintSwell) swu, swv := noise.Warp(u, v, wx, wy, 0.2/swellCells) swell := noise.FBMAt(swu, swv, ss, noise.Params{BaseCells: swellCells, Octaves: 4, Gain: 0.5}) rate := field.New(w, h, cellM) k := field.New(w, h, cellM) land := field.New(w, h, cellM) base := make([]bool, w*h) // Distance from every land cell to the nearest water, for the coastal plain. One exact transform over the // region, computed only when some class asks for it. The region's frame is flat - it is a rectangle cut // out of the cylinder with water all round it - so this does not wrap, and the water it measures to is // this region's own coastline: anything else inside the frame is a different landmass, and a different // landmass is more than a margin away by construction. var shoreM []float32 if wantsPlain(p.PlainM) { d2 := dt.Distance2(invert(p.Land), w, h, false) shoreM = make([]float32, len(d2)) for i, d := range d2 { shoreM[i] = float32(math.Sqrt(float64(d)) * cellM) } } // The upland fabric, built only when a class asks for one. It is the one field here that is a cut of a // planet-wide measurement rather than a value read straight off a noise, which is why it lives in // massif.go with the note on why that measurement cannot be a percentile of the region. var rank *field.Field if anyMassif(p.MassifFraction) { rank = MassifRank(f.P, seed, p.MassifCells, u, v) } // The rock field, the same way and for the same reason: a quantile of the planet, never of the region. var rock *field.Field if anyMix(p.LithMix) { rock = RockK(f.P, seed, p.RockCells, p.RockMult, u, v) } // And the faults, which are a rate *difference* across a line rather than a field of their own. Built // once for the planet and filtered to this frame; nil when none of them reaches it. fault := FaultDelta(f, p.Faults, p.RunYears) maxRate := 0.0 for _, r := range p.Rates { if float64(r) > maxRate { maxRate = float64(r) } } if maxRate <= 0 { maxRate = 1 } // The painted class boundary is deliberately not smoothed. The blend rule in Docs/Terrain-Next.md 3.2 // exists because a painted map coarser than the grid reads as blocks; here a paint pixel is 12.9 m // against an 8 m cell, so there is barely an upsample to soften. And a step in the uplift *rate* is a // step in steady-state slope, not in height: the solve grades the transition over a hillslope of its own // accord, which is a better answer than a blur, and blurring would have pulled the sea's zero into the // coastal cells - the mistake D-52 undid, where the land ending decided how fast it was rising. // preFault is the rate before any fault touches it: the class rate after the massif cut and the // coastal-plain ramp. The initial relief is scaled by it rather than by the finished rate, which is // D-63 and is not a detail - see the amplitude below. preFault := make([]float32, len(rate.Data)) clamped := 0 for i := range rate.Data { c0 := p.Class[i] kk := float64(p.Ks[c0]) if rock != nil && p.LithMix[c0] > 0 { // The rock field multiplies the class's own erodibility rather than replacing it: `k_mult` is // what the author said this ground is made of, and the province is the variation within it. kk *= 1 + p.LithMix[c0]*(float64(rock.Data[i])-1) } k.Data[i] = float32(kk) if !p.Land[i] { base[i] = true continue } land.Data[i] = 1 c := p.Class[i] r := float64(p.Rates[c]) if rank != nil && p.MassifFraction[c] > 0 { // The class rate is the rate a massif reaches; the floor is the plain between them. r = MassifRate(float64(p.MassifFloor[c]), r, float64(rank.Data[i]), p.MassifFraction[c]) } if shoreM != nil && p.PlainM[c] > 0 { // Smoothstep rather than linear, so the plain meets the range without a crease in the slope // field - a crease there would be a line of channel heads all starting at the same distance // from the sea, which is the sort of thing that reads as a contour rather than as terrain. t := float64(shoreM[i]) / p.PlainM[c] if t > 1 { t = 1 } t = t * t * (3 - 2*t) floor := float64(p.PlainFloor[c]) // Only ever downwards. Before massifs the class rate was uniform and the legend guarantees the // coastal floor is below it, so this could not fire; a cell of plain between two massifs now // sits below the coastal floor perfectly legitimately, and ramping it *up* towards the shore // would put a rim of hills round the edge of every continent. if r > floor { r = floor + (r-floor)*t } } asked := r preFault[i] = float32(asked) if fault != nil { // A fault is a difference in rate across a line. It adds on one side and subtracts on the other, // and the subtraction is what tilts the block rather than merely raising a ridge - so it is // allowed to take the rate down, but not below zero: subsidence is not modelled. if r += float64(fault[i]); r < 0 { r = 0 } // The only ceiling in the whole painted path, and it binds on faults alone. Past // U = tan(talus)*K*cell the repose clamp shapes the ground instead of erosion and the surface // comes out as polygonal facets; an author may choose that for a class, but a fault stacking on // top of ground that was already near it is nobody's choice. So the bound is the ceiling *or* // whatever the author's own numbers asked for here, whichever is higher. The count is reported. if p.ClampCeilM > 0 { lim := p.ClampCeilM * kk if lim < asked { lim = asked } if r > lim { r = lim clamped++ } } } rate.Data[i] = float32(r * (1 + p.Variation*(2*float64(swell.Data[i])-1))) } // Initial relief. The spec says 50-150 m times normalised uplift and means it; this only breaks the // symmetry so the solve has something to bite on. rs := noise.NewSource(seed, srcPaintRidges) ru, rv := noise.Warp(u, v, wx, wy, 0.8/ridgeCells) ridges := noise.FBMAt(ru, rv, rs, noise.Params{BaseCells: ridgeCells, Octaves: 6, Gain: 0.42, Ridged: true}) cs := noise.NewSource(seed, srcPaintCrests) cu, cv := noise.Warp(u, v, wx, wy, 3.1/crestCells) // the stronger warp the crest lines need crests := noise.CellularEdges(cu, cv, cs, int(crestCells), 0.95) ps := noise.NewSource(seed, srcPaintPlains) pu, pv := noise.Warp(u, v, wx, wy, 0.5/plainCells) plains := noise.FBMAt(pu, pv, ps, noise.Params{BaseCells: int(plainCells), Octaves: 4, Gain: 0.45}) ampLo := cfg.Relief.AmplitudeM.Lo() ampHi := cfg.Relief.AmplitudeM.Hi() crestW := cfg.Relief.CrestWeight height := field.New(w, h, cellM) for i := range height.Data { if base[i] { // Ocean sits at sea level for the whole solve and the coastal pass lays the floor afterwards. // Left at a real depth, a coastal cell drains into it and the solver cuts the river down to meet // it; the first run with a coast eroded the land to 174 m below sea level for exactly that. height.Data[i] = float32(m.SeaLevelM) continue } // The amplitude comes from the rate *before* the faults, and is bounded at one (D-63). // // It used to come from rate.Data, which is the finished rate with the fault delta in it and no // upper bound, and that coupling is half of why Bake_018's flanks came out ribbed. The initial // relief exists only to break the symmetry of the background so the solve has something to bite // on; how much noise is stamped on a hillside is not a fault's decision. With D-62's six // kilometre footwalls the ratio went from about 0.18 on unfaulted foreland - 39 m of relief - to // 1.12 on a footwall, which is 166 m, on a landmass whose whole relief is 221 m. A thousand steps // cannot erase initial relief the size of the landscape, so the ridged noise stopped being a // symmetry-breaker and became the terrain: the ribs measure 250-300 m, which is octave five of a // 4.2 km ridged fBm. The bound at one is a guard rather than the fix - with the fault gone the // rate cannot exceed the largest class rate - but it is the property worth stating. norm := float64(preFault[i]) / maxRate if norm > 1 { norm = 1 } else if norm < 0 { norm = 0 } amp := ampLo + (ampHi-ampLo)*norm shape := (1-crestW)*float64(ridges.Data[i]) + crestW*float64(crests.Data[i]) height.Data[i] = float32(m.SeaLevelM + 20 + amp*shape + float64(plains.Data[i])*8) } return &Result{Rate: rate, Height: height, Land: land, K: k, Base: base, FaultClamped: clamped} } // anyMix reports whether any class lets the rock field through. func anyMix(mix []float64) bool { for _, v := range mix { if v > 0 { return true } } return false } func wantsPlain(plain []float64) bool { for _, v := range plain { if v > 0 { return true } } return false } func invert(b []bool) []bool { out := make([]bool, len(b)) for i, v := range b { out[i] = !v } return out }