package uplift import ( "salty/terrain/internal/field" "salty/terrain/internal/noise" "salty/terrain/internal/world" ) // The planet's upland fabric: where the ground stands above the plain, before any class has said how far. // // It exists because of one piece of arithmetic. For n = 1 the steady-state divide slope is U/(K*A^m) applied // down to a single cell, so a class's uplift rate *is* its hillslope angle - 0.08 mm/yr is 11.3 degrees at an // 8 m cell - and a class is one rate over every cell an author painted with it. A landmass painted one colour // therefore comes out uniformly dissected from the waterline to the summit, with no flat ground anywhere on // it. That is not what a continent looks like. Europe away from the Alps is a plain at a fraction of a degree // with isolated massifs standing out of it, and the difference is not the rate, it is that the rate is not // the same everywhere. // // So a class carries a floor as well as a rate, and this decides, per cell, how far between the two it sits. // // One fabric for the whole planet, cut at a different level by each class that asks. That is deliberate and // it is the reason this is not a per-class noise: a highland belt and the hills in the lowland next to it // then come out as the high and low parts of one structure - an orogen and its outliers - rather than as two // unrelated fields meeting at a painted edge. // fabricProbeW is how finely a fabric is sampled to find out what its values mean. 1024 columns is 98 m a // sample on a 100 km planet against a finest octave of a kilometre or so, which is ten samples across it: the // distribution the probe measures is the distribution the 8 m grid will draw from, which is the only thing // asked of it. const fabricProbeW = 1024 // fabricBins is the resolution of the measured distribution. The fabric lives in a fraction of 0..1, so four // thousand bins over the whole interval is finer than the probe's sampling error by a wide margin. const fabricBins = 4096 // massifOctaves and massifGain shape the fabric itself. Five octaves at 0.45 keeps the blocks legible at // their own wavelength while giving their edges a fractal outline, which is what stops a massif reading as a // painted blob - the thing an author would have drawn by hand, and the reason they should not have to. const ( massifOctaves = 5 massifGain = 0.45 ) // massifWarp is how far the fabric is bent by the shared low-frequency warp, as a fraction of its own // wavelength. Ridges take 0.8 and crest lines 3.1; a little under one wavelength keeps a block a block while // stopping the lattice showing through as a grid of round hills. const massifWarp = 0.9 // paintWarp is the low-frequency warp every painted noise field is built on, so that ridges curve and blocks // are not polygons. Shared rather than copied: the fabric has to be bent by the same field the relief is, or // a massif and the ridges on it would disagree about which way the grain runs. func paintWarp(u, v *field.Field, seed int64) (wx, wy *field.Field) { ws := noise.NewSource(seed, srcPaintWarp) wx = noise.FBMAt(u, v, ws, noise.Params{BaseCells: 3, Octaves: 3, Gain: 0.5}) wy = noise.FBMAt(u, v, ws, noise.Params{BaseCells: 3, Octaves: 3, Gain: 0.5}) return wx, wy } // massifFabric samples the upland fabric at the given world coordinates. func massifFabric(u, v, wx, wy *field.Field, seed int64, baseCells int) *field.Field { ms := noise.NewSource(seed, srcPaintMassif) mu, mv := noise.Warp(u, v, wx, wy, massifWarp/float64(baseCells)) return noise.FBMAt(mu, mv, ms, noise.Params{ BaseCells: baseCells, Octaves: massifOctaves, Gain: massifGain, }) } // fabricCDF is a planet-wide fabric's distribution, measured once over the whole cylinder: it turns a fabric // value into the share of the planet standing below it. // // It is shared by every field that has to be cut at the same level in every region - the upland fabric here // and the lithology in painted_rock.go - because the argument below is not about massifs, it is about what a // threshold on a *decomposed* planet is allowed to be. // // The measurement is the hard part of this feature and it is worth saying why. A threshold cannot be a // percentile of the region. uplift.Build takes percentiles of the grid it is handed and FromTemplate exists // precisely not to do that: two regions taking quantiles of their own extents would put the same physical // hillside on different sides of the cut, and the planet would disagree with itself along every region // boundary. A quantile of the *planet* is a different animal. It is one number for the whole world, every // region computes the same one from the same samples because the samples are defined by the planet and not by // the caller, and it costs half a million noise evaluations - about ten milliseconds, once per region. // // It is a histogram rather than a sort for the same reason it is cheap: a sorted copy of the probe is four // megabytes and a hundred milliseconds, and nothing here needs a resolution a sort would buy. type fabricCDF struct { lo, hi float64 cum []float64 // fabricBins+1 entries: cum[i] is the share below lo + i*(hi-lo)/fabricBins } // fabricFunc builds a planet-wide fabric at the given world coordinates. The two that exist are massifFabric // and rockFabric; both take the shared low-frequency warp so that every field on a planet bends the same way. type fabricFunc func(u, v, wx, wy *field.Field, seed int64, baseCells int) *field.Field // measureFabric probes a fabric over the entire cylinder, the pad included, and measures its distribution. // // The pad is in on purpose. It is a couple of hundred rows of synthetic ocean at each pole, no class ever // reads a rate there, and leaving it out would make the answer depend on how thick the pad happened to be. // What matters is that the probe is a property of the planet and of nothing else. func measureFabric(p world.Planet, seed int64, baseCells int, build fabricFunc) fabricCDF { w := fabricProbeW if w > p.W { w = p.W } h := int(float64(w)*float64(p.H)/float64(p.W) + 0.5) if h < 1 { h = 1 } // The probe walks the same world metres the regions do, at a coarser step, through the same WorldUV: what // it measures is the same field, sampled more sparsely. cellM := p.CircumferenceM() / float64(w) u, v := noise.WorldUV(w, h, cellM, 0, p.YM(0), p.NoisePeriodM) wx, wy := paintWarp(u, v, seed) f := build(u, v, wx, wy, seed, baseCells) lo, hi := f.MinMax() c := fabricCDF{lo: float64(lo), hi: float64(hi), cum: make([]float64, fabricBins+1)} if c.hi <= c.lo { // A degenerate fabric - one lattice cell, or a probe of a single column. Every value is the same, // so every cell is at the same place in the distribution and the shape below is flat. c.hi = c.lo + 1 return c } // Counted serially. It is a millisecond and cross-cutting rule 12 says the answer must not depend on how // many goroutines ran. counts := make([]float64, fabricBins) scale := float64(fabricBins) / (c.hi - c.lo) for _, x := range f.Data { b := int((float64(x) - c.lo) * scale) if b < 0 { b = 0 } if b >= fabricBins { b = fabricBins - 1 } counts[b]++ } total := float64(len(f.Data)) run := 0.0 for i, n := range counts { c.cum[i] = run / total run += n } c.cum[fabricBins] = 1 return c } // at is the share of the planet standing below this fabric value, in 0..1. func (c fabricCDF) at(x float64) float64 { t := (x - c.lo) / (c.hi - c.lo) * float64(fabricBins) if t <= 0 { return 0 } if t >= float64(fabricBins) { return 1 } i := int(t) return c.cum[i] + (c.cum[i+1]-c.cum[i])*(t-float64(i)) } // MassifRate blends a class's floor and its rate at one place in the fabric: the plain where the fabric is // low, the class rate where it is high. // // The ramp is cut in the *rank* - the share of the planet standing below this cell - rather than in the // fabric's own values, which is what makes fraction mean something an author can predict: exactly `fraction` // of the planet stands above the midpoint, half that again reaches the class rate outright, and half again // above that is off the plain at all. Cutting in value space instead would make the realised share depend on // the shape of the noise's distribution, which is not a number anybody should have to know. func MassifRate(floorMYr, rateMYr, rank, fraction float64) float64 { return floorMYr + (rateMYr-floorMYr)*massifShape(rank, fraction) } func massifShape(rank, fraction float64) float64 { lo := 1 - 1.5*fraction hi := 1 - 0.5*fraction t := (rank - lo) / (hi - lo) if t <= 0 { return 0 } if t >= 1 { return 1 } return t * t * (3 - 2*t) } // MassifRank is where every cell of a frame sits in the planet's upland fabric: 0 is the lowest ground on the // planet and 1 the highest, as a share of the planet's surface rather than as a height. // // It takes world coordinates rather than a Frame so that the diagnostic maps, which point-sample the planet // down to an image, can ask about exactly the cells they drew rather than about a frame they do not have. func MassifRank(p world.Planet, seed int64, baseCells int, u, v *field.Field) *field.Field { if baseCells < 1 { baseCells = 1 } wx, wy := paintWarp(u, v, seed) fabric := massifFabric(u, v, wx, wy, seed, baseCells) cdf := measureFabric(p, seed, baseCells, massifFabric) 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(cdf.at(float64(fabric.Data[i]))) } }) return out } // anyMassif reports whether any class in the legend asked for a fabric. func anyMassif(fraction []float64) bool { for _, f := range fraction { if f > 0 { return true } } return false }