package uplift import ( "salty/terrain/internal/field" "salty/terrain/internal/noise" "salty/terrain/internal/world" ) // Lithology on a painted planet: what the rock is, underneath what the author painted it as. // // A class is a rate and an erodibility, and on a painted world the erodibility was one flat number over every // cell of a colour. That is one range made of one rock, everywhere, and it shows: `map_erodibility.png` on a // painted planet was a recolour of `map_class.png`, and two bakes of the same painting under different seeds // differed on it only where the *coastline* had moved. Texture inside a range - the reason one flank is // gullied and the next is a set of benches - has nowhere to come from. // // So there is a rock field: low-frequency noise cut into a few types, each with its own multiplier on K, // exactly the spec's 4.3 and exactly what the procedural path has always had. What is different here is the // two things a decomposed planet forces, and both are the same two the massif fabric ran into first. // // **The cut is a quantile of the planet, never of the region.** `uplift.Build` takes `f.Percentile()` of the // grid it is handed, which on a planet means two regions measuring their own extents and putting the same // physical hillside in different rock. The threshold is measured once over the whole cylinder by // measureFabric, from a probe that is a property of the planet and of nothing else, so every region computes // the identical number from the identical samples. // // **And nothing here may look at a neighbour.** The procedural version ends in `out.Blur(2)`, so that a rock // boundary is a transition rather than a wall the solver carves into a cliff. A blur is a neighbourhood // operation, and a neighbourhood operation near a region's edge reads cells that a different decomposition // would not have given it. The softening is therefore done **pointwise, in rank space**: a cell near the edge // of its band is blended towards the next band by how near it is, which needs only the cell's own value. The // width of the transition on the ground then follows the fabric's own gradient - sharp where the rock changes // fast, gradual where it does not - which is a better answer than a fixed blur radius anyway. const ( srcPaintRock = 26 ) // rockOctaves and rockGain shape the rock field. Fewer octaves than the upland fabric on purpose: a lithology // map is broad provinces with ragged edges, not a fractal at every scale, and the detail that does belong at // metre scale is the strata model in the detail passes rather than this. const ( rockOctaves = 4 rockGain = 0.5 ) // rockWarp bends the rock field by the shared low-frequency warp, as a fraction of its own wavelength. The // same field that bends the massifs and the ridges, because a province boundary that ignored the grain // everything else follows would read as a stencil laid over the world. const rockWarp = 0.7 // rockEdge is how much of a band's width is spent blending into its neighbour, at each end. At 0.15 a // province is flat over the middle seven tenths of its range and graded across the rest. const rockEdge = 0.15 // rockFabric samples the rock field at the given world coordinates. func rockFabric(u, v, wx, wy *field.Field, seed int64, baseCells int) *field.Field { rs := noise.NewSource(seed, srcPaintRock) ru, rv := noise.Warp(u, v, wx, wy, rockWarp/float64(baseCells)) return noise.FBMAt(ru, rv, rs, noise.Params{ BaseCells: baseCells, Octaves: rockOctaves, Gain: rockGain, }) } // RockK is the erodibility multiplier the lithology contributes at every cell of a frame, around 1. // // mult is the manifest's k_multipliers, in order, and the bands are **equal area over the planet**: the rank // is uniform on 0..1 by construction, so cutting it into n equal pieces gives each rock type the same share // of the world whatever the seed did to the noise. That is the property the procedural path got from // `f.Percentile` and the reason it is worth keeping - a seed that produced no hard rock anywhere would be a // seed that quietly removed a process. // // Returns nil when there is nothing to build, which is what a planet with no lithology_wavelength_km gets and // what every painted planet got before this existed. func RockK(p world.Planet, seed int64, baseCells int, mult []float64, u, v *field.Field) *field.Field { if baseCells < 1 || len(mult) < 2 { return nil } wx, wy := paintWarp(u, v, seed) fabric := rockFabric(u, v, wx, wy, seed, baseCells) cdf := measureFabric(p, seed, baseCells, rockFabric) n := len(mult) out := field.NewLike(fabric) field.Rows(out.H, func(y0, y1 int) { for i := y0 * out.W; i < y1*out.W; i++ { out.Data[i] = float32(bandValue(cdf.at(float64(fabric.Data[i])), mult, n)) } }) return out } // bandValue picks the rock type a rank falls in and softens the boundary, pointwise. // // The blend is half-and-half exactly at a boundary from either side, which is what makes it continuous: a // cell at the top of band b is (b + b+1)/2 and a cell at the bottom of band b+1 is (b+1 + b)/2, the same // number approached from opposite directions. func bandValue(rank float64, mult []float64, n int) float64 { x := rank * float64(n) b := int(x) if b >= n { b = n - 1 } if b < 0 { b = 0 } f := x - float64(b) switch { case f > 1-rockEdge && b+1 < n: t := noise.Smoothstep((f-(1-rockEdge))/rockEdge) * 0.5 return mult[b] + (mult[b+1]-mult[b])*t case f < rockEdge && b > 0: t := noise.Smoothstep((rockEdge-f)/rockEdge) * 0.5 return mult[b] + (mult[b-1]-mult[b])*t } return mult[b] }