Files
UnrealPrototyping/Tools/Terrain/internal/uplift/massif.go
T
2026-09-25 17:02:24 +03:00

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
}