217 lines
9.6 KiB
Go
217 lines
9.6 KiB
Go
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
|
|
}
|