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

488 lines
20 KiB
Go

// Package uplift builds what the fluvial solve integrates: a rock uplift rate field in metres per year, an
// erodibility field, a continent mask that says where the sea is, and a small initial relief to break the
// symmetry.
//
// This is heightmap_noise.generate_metres turned inside out (D-47). The numpy version produced the terrain:
// ranges to 2600 m, foothills, plains, and erosion was applied to it afterwards as decoration. Here the same
// shapes produce an uplift *rate*, and the terrain is whatever the stream-power solve makes of it. The
// percentile thresholding is kept exactly, because it is what makes the result seed-independent.
//
// The coast is kept (D-48). Sea level is the base level on every ocean cell, which is a far better-posed
// boundary for the solve than a single outlet edge and removes the artificial divide a one-outlet map has
// along three of its sides. This package decides *where* the coastline runs and nothing else about it; what
// the shoreline and the sea floor then look like belongs to package coast, which runs after the solve.
//
// # Why the plains need their own uplift, and not a flat one
//
// The first version gave the whole intraplate interior one uniform rate, 0.2 mm/yr against a convergent
// 5 mm/yr. That produced a table-flat green void with a polygonal river network scribbled across it, and the
// network was an artifact rather than drainage. Two reasons, and they are the same reason twice:
//
// - Steady state is S = U/(K*A^m). On a plain A is large and U was tiny, so S collapsed to nothing.
// - Uniform uplift over a wide area produces no *divides*. With no divides there is no drainage to find,
// so the router fell back on the only gradient present, which was the priority-flood's millimetre of
// epsilon, and drew the flood's own traversal geometry as rivers.
//
// Real intraplate regions are not uniform. They warp gently over tens of kilometres into swells and sags, and
// that warping is what puts divides on a plain. So the intraplate rate is modulated by a long-wavelength
// field.
//
// # And why the rate itself must stay low
//
// The first attempt at the above did the right thing and then overdid it: the plains were lifted to
// 0.25-0.9 mm/yr, on the reasoning that a higher rate sustains more relief against K. It does, but relief is
// not the quantity that was in trouble. Steady state is S = U/(K*A^m), and with no critical area that holds
// down to a single cell, so every divide stands at A = cell² whatever else is true of it. At the defaults
// that made 0.25 mm/yr a 32 degree hillslope and 0.9 mm/yr one past the angle of repose — so the repose clamp,
// which is meant to be a mountain process, became the surface of the whole continent. Measured: 81 % of the
// land fell in the >0.5 mm/yr class and the plains held 1 %.
//
// The lesson is that U and S are not two knobs. For n = 1, U alone fixes the hillslope angle at a given A,
// and the only things that make a plain flat are a low U or a large A. So the intraplate rate is an order of
// magnitude lower than it was and the *variation* carries the divides, which is what it was for. The
// mountain-to-plain ratio is 30-fold and up, which is what real ones are.
//
// # Where the land ends does not decide how fast it is rising
//
// The uplift rate used to be multiplied by the continent mask, which is a smoothstep, so it tapered to zero
// across the shore. That made every coastline on the map the lowest-uplift ground on the map, by construction
// and whatever the tectonics said — and since steady state is S = U/(K*A^m), ground with no uplift grades to
// no slope, so every coast was a plain. It is why the coastal pass measured a mean sea cliff of two metres
// while working perfectly: there was nothing anywhere on the map for the surf to cut into.
//
// The two questions are not the same question. The mask answers "is this cell sea", which is a yes or a no and
// is what the solve needs for its base level. The uplift field answers "how fast is this rock rising", and a
// range that happens to run out to the water is rising at range rates right up to the waterline — which is
// what Big Sur, the Norwegian west coast and the Great Australian Bight all are. So the mask is thresholded
// rather than multiplied, and whether a given coast is a cliff or a plain is now decided by where the range
// band falls relative to the coastline, which is exactly the sort of thing that should be decided by the
// tectonics and not by a smoothstep.
package uplift
import (
"math"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/noise"
)
type Result struct {
Rate *field.Field // rock uplift, metres per year
Height *field.Field // initial relief, metres; ocean cells sit at sea level and stay there for the solve
Land *field.Field // continent mask, 0 at sea, 1 inland
K *field.Field // erodibility multiplier from the lithology pass, around 1
Base []bool // cells fixed at base level: the ocean
Faults []Fault
// FaultClamped is how many cells the painted path's fault pass pushed past the angle of repose and had
// to bound. Nonzero is not an error - it is the set telling an author their throws are large for the
// class they sit in - but it is worth seeing rather than discovering in a hillshade.
FaultClamped int
}
// Fault is a recorded trace, kept for meta.json and for whatever later wants to draw one.
type Fault struct {
Points [][2]float64 `json:"points"` // map coordinates, 0..1
ThrowM float64 `json:"throw_m"`
Major bool `json:"major"`
Reverse bool `json:"reverse"` // which side goes up
}
// seaThreshold is where the continent mask stops being land. The mask is a smoothstep, so it has a ramp, and
// this is the one place that ramp is turned into the yes-or-no answer the solve needs: a cell is either an
// ocean cell held at base level or it is not.
//
// It is a named constant rather than a literal in two loops because the rate field and the height field have
// to agree about it exactly. If they ever disagreed, a cell would be uplifted and then pinned at base level,
// or held at sea level while taking no uplift, and neither would be visible in anything a run prints.
const seaThreshold = 0.02
// Pass indices for the seeded sources. Fixed and never reordered: a pass keeps its own stream so that
// inserting a later pass does not reshuffle the ones before it (cross-cutting rule 12).
const (
srcContinent = 1
srcWarp = 2
srcBand = 3
srcRidges = 4
srcCrests = 5
srcRelief = 6
srcSwell = 7
srcFaults = 8
srcLithology = 9
)
// Build produces the geology-grid inputs at size x size.
func Build(size int, cellM float64, m *manifest.Manifest) *Result {
seed := m.Source.Seed
cfg := m.Pipeline
sideM := float64(size-1) * cellM
u, v := noise.Identity(size)
// A low-frequency warp bends everything that follows, so ridges curve and ranges are not blobs.
ws := noise.NewSource(seed, srcWarp)
wx := noise.FBM(size, ws, noise.Params{BaseCells: 3, Octaves: 3, Gain: 0.5})
wy := noise.FBM(size, ws, noise.Params{BaseCells: 3, Octaves: 3, Gain: 0.5})
land := continentMask(size, u, v, wx, wy, seed, cfg.Continent)
// Ranges: an elongated, warped band says where they run, stretched across its grain so they come as long
// chains rather than patches. Thresholded by percentile, not by value, which is the whole trick.
bs := noise.NewSource(seed, srcBand)
angle := bs.Range(0, math.Pi)
cos, sin := math.Cos(angle), math.Sin(angle)
bu := field.NewLike(u)
bv := field.NewLike(v)
for i := range bu.Data {
x := float64(u.Data[i]) - 0.5
y := float64(v.Data[i]) - 0.5
along := x*cos + y*sin
across := -x*sin + y*cos
bu.Data[i] = float32(0.5 + along*0.7 + float64(wx.Data[i]-0.5)*0.32)
bv.Data[i] = float32(0.5 + across*2.2 + float64(wy.Data[i]-0.5)*0.32)
}
band := noise.FBMAt(bu, bv, bs, noise.Params{BaseCells: 3, Octaves: 3, Gain: 0.5})
rangeMask := percentileMask(band, cfg.Plates.LowUpliftFraction.Hi()*100, 86)
// The regional swell: long-wavelength warping of the intraplate interior, which is what puts divides on
// a plain. Without it the lowlands have no drainage of their own and the flood's epsilon decides where
// the water goes.
ss := noise.NewSource(seed, srcSwell)
swu, swv := noise.Warp(u, v, wx, wy, 0.10)
swell := noise.FBMAt(swu, swv, ss, noise.Params{BaseCells: 2, Octaves: 4, Gain: 0.5})
swell.Normalise()
// Rock uplift in metres per year.
intraLo := cfg.Plates.IntraplateMmYr / 1000
intraHi := cfg.Plates.IntraplateSwellMmYr / 1000
if intraHi < intraLo {
intraHi = intraLo
}
convergent := cfg.Plates.ConvergentMmYr.Hi() / 1000
rate := field.New(size, size, cellM)
for i := range rate.Data {
if land.Data[i] <= seaThreshold {
// Ocean. The rate is zeroed for the sake of map_uplift and the statistics; the solve does not
// need it, because a base cell is fixed and StreamPower skips it before it reads the rate.
rate.Data[i] = 0
continue
}
base := intraLo + (intraHi-intraLo)*float64(swell.Data[i])
rate.Data[i] = float32(base + (convergent-base)*float64(rangeMask.Data[i]))
}
faults := buildFaults(rate, u, v, seed, angle, sideM, cfg.Faults, cfg.Plates.ConvergentMmYr.Hi()/1000,
float64(cfg.Fluvial.Steps)*cfg.Fluvial.DtYr)
// Lithology: a plan-view erodibility field. It is what stops every ridge in a range looking like every
// other ridge, because a hard band resists and a soft one is cut away.
k := lithology(size, cellM, u, v, wx, wy, seed, cfg.Lithology)
// Initial relief: small on purpose. The spec says 50-150 m x normalised uplift and it means it; the
// solve is what produces relief, and handing it 2600 m of ridged noise means it spends its whole run
// tearing that down instead of carving.
rs := noise.NewSource(seed, srcRidges)
wu, wv := noise.Warp(u, v, wx, wy, 0.16)
ridges := noise.FBMAt(wu, wv, rs, noise.Params{BaseCells: 5, Octaves: 6, Gain: 0.42, Ridged: true})
ridges.Normalise()
cs := noise.NewSource(seed, srcCrests)
cu, cv := noise.Warp(u, v, wx, wy, 0.224) // the stronger warp the crest lines need
crests := noise.CellularEdges(cu, cv, cs, 14, 0.95)
ps := noise.NewSource(seed, srcRelief)
plains := noise.FBM(size, ps, noise.Params{BaseCells: 6, Octaves: 4, Gain: 0.45})
ampLo := cfg.Relief.AmplitudeM.Lo()
ampHi := cfg.Relief.AmplitudeM.Hi()
crestW := cfg.Relief.CrestWeight
maxRate := convergent
if maxRate <= 0 {
maxRate = 1
}
height := field.New(size, size, cellM)
base := make([]bool, size*size)
for i := range height.Data {
if land.Data[i] <= seaThreshold {
// Ocean: base level, fixed, never eroded, never uplifted, and held at sea level for the whole
// solve. The sea floor is not laid here and deliberately not laid *yet* — a coastal cell drains
// into an ocean cell, and if that cell already sat at -180 m the solver would cut the river down
// to -180 m, because that is the base level it was handed. The first run with a coast eroded the
// land to 174 m below sea level for exactly that reason. Package coast lays the sea floor after
// the solve, where it also has the relief it needs to decide how wide the shelf is.
height.Data[i] = float32(m.SeaLevelM)
base[i] = true
continue
}
norm := float64(rate.Data[i]) / maxRate // normalised uplift, 0..1
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, Faults: faults}
}
// percentileMask thresholds a field between two percentiles and smoothsteps between them, so the fraction of
// the map it covers is the same whatever the seed.
func percentileMask(f *field.Field, loPct, hiPct float64) *field.Field {
lo := f.Percentile(loPct)
hi := f.Percentile(hiPct)
span := float64(hi - lo)
if span < 1e-6 {
span = 1e-6
}
out := field.NewLike(f)
for i, b := range f.Data {
t := (float64(b) - float64(lo)) / span
if t < 0 {
t = 0
} else if t > 1 {
t = 1
}
out.Data[i] = float32(noise.Smoothstep(t))
}
return out
}
// continentMask is the coast. A radial falloff with noise added to the radius gives a disc with a wobbly
// edge, which is what the first version did and what it looked like. Instead a warped multi-octave field is
// biased radially and then thresholded at the percentile that yields the wanted land fraction: the coastline
// gets bays, peninsulas and offshore deeps, and the land area is still the same whatever the seed.
func continentMask(size int, u, v, wx, wy *field.Field, seed int64, cfg manifest.Continent) *field.Field {
out := field.New(size, size, 1)
if !cfg.Enabled {
out.Fill(1)
return out
}
s := noise.NewSource(seed, srcContinent)
cx := 0.5 + (s.Float()-0.5)*0.12
cy := 0.5 + (s.Float()-0.5)*0.12
// Strong domain warp, so the shape is not obviously built from a circle.
//
// The octave count is the coastline's own detail and it is a manifest key because it is the one number
// that decides whether the continent has a coast or an outline: five octaves over this map is a 450 m
// finest feature, which is a smooth blob, and everything downstream that asks "is this stretch sheltered"
// then answers "no" everywhere. Gain stays at 0.5 rather than the 0.42 the *relief* noise uses, because
// this field is thresholded at a percentile rather than read as a height, so a steep spectrum costs
// nothing here and is what makes the shoreline crenellate.
cu, cv := noise.Warp(u, v, wx, wy, cfg.CoastWarp)
octaves := cfg.OutlineOctaves
if octaves < 1 {
octaves = 5
}
gain := cfg.OutlineGain
if gain <= 0 {
gain = 0.5
}
shape := noise.FBMAt(cu, cv, s, noise.Params{BaseCells: 2, Octaves: octaves, Gain: gain})
// The radial term only biases the field towards the middle; it does not define the edge.
score := field.New(size, size, 1)
for i := range score.Data {
x := float64(u.Data[i]) - cx
y := float64(v.Data[i]) - cy
radius := math.Hypot(x*1.05, y*0.95) / cfg.Radius
score.Data[i] = float32(float64(shape.Data[i]) - cfg.RadialBias*radius*radius)
}
// The threshold that yields the wanted land fraction, read off the distribution.
seaPct := (1 - cfg.LandFraction) * 100
lo := score.Percentile(seaPct)
hi := score.Percentile(math.Min(99.9, seaPct+cfg.ShoreWidthPct))
span := float64(hi - lo)
if span < 1e-6 {
span = 1e-6
}
for i, sc := range score.Data {
t := (float64(sc) - float64(lo)) / span
if t < 0 {
t = 0
} else if t > 1 {
t = 1
}
out.Data[i] = float32(noise.Smoothstep(t))
}
// The last few percent of the map is forced to sea, so land never touches the edge.
//
// This is not cosmetic. A border cell is an outlet: it takes no uplift, is never eroded, and the repose
// clamp will not lower it either, so any land that reaches the edge is frozen at whatever height the
// initial relief gave it while the interior erodes away beneath it. The result is a rim of untouched
// terrain standing over a hundred metres above its neighbour — which is exactly what the repose test
// found when it reported a 66 degree slope on a map whose angle of repose was 22. Percentile
// thresholding picks the lowest fraction of the *score* and has no reason to put it at the edges, so the
// margin has to be imposed.
const marginFrac = 0.04
for i := range out.Data {
x := float64(i%size) / float64(size-1)
y := float64(i/size) / float64(size-1)
d := math.Min(math.Min(x, 1-x), math.Min(y, 1-y))
if d < marginFrac {
out.Data[i] *= float32(noise.Smoothstep(math.Max(0, d/marginFrac)))
}
}
return out
}
// lithology is the spec's 4.3: low-frequency noise thresholded into a few rock types, each with its own
// erodibility. Plan view, and orthogonal to the strata model that scales the particle pass by depth.
func lithology(size int, cellM float64, u, v, wx, wy *field.Field, seed int64, cfg manifest.Lithology) *field.Field {
out := field.New(size, size, cellM)
if cfg.Types <= 1 || len(cfg.KMultipliers) == 0 {
out.Fill(1)
return out
}
s := noise.NewSource(seed, srcLithology)
lu, lv := noise.Warp(u, v, wx, wy, 0.18)
f := noise.FBMAt(lu, lv, s, noise.Params{BaseCells: 3, Octaves: 4, Gain: 0.5})
n := cfg.Types
if n > len(cfg.KMultipliers) {
n = len(cfg.KMultipliers)
}
// Equal-area bands, so every rock type actually appears whatever the seed.
edges := make([]float32, n-1)
for i := 1; i < n; i++ {
edges[i-1] = f.Percentile(float64(i) / float64(n) * 100)
}
for i, val := range f.Data {
t := 0
for t < len(edges) && val > edges[t] {
t++
}
out.Data[i] = float32(cfg.KMultipliers[t])
}
// Softened, so a boundary is a transition rather than a wall the solver carves into a cliff.
return out.Blur(2)
}
// buildFaults perturbs the uplift field across a set of traces. Normal faults are applied as an uplift-rate
// *difference* across the trace, steep on one side and gentle on the other, and erosion then carves the
// escarpment; that is the spec's 4.2 and it is why a fault reads as landscape rather than as a drawn line.
//
// Orientation follows the range grain rather than being random, because a fault set that ignores the
// structure it belongs to looks like scratches.
func buildFaults(rate, u, v *field.Field, seed int64, grainAngle, sideM float64,
cfg manifest.Faults, convergent, runYears float64) []Fault {
s := noise.NewSource(seed, srcFaults)
if runYears <= 0 {
runYears = 1.5e6
}
nMajor := int(cfg.Major.Pick(s.Float()) + 0.5)
nMinor := int(cfg.Minor.Pick(s.Float()) + 0.5)
faults := make([]Fault, 0, nMajor+nMinor)
for i := 0; i < nMajor+nMinor; i++ {
major := i < nMajor
lengthM := cfg.LengthKm.Pick(s.Float()) * 1000
if major {
lengthM = math.Max(lengthM, cfg.LengthKm.Hi()*1000*0.6)
}
throw := cfg.ThrowMinorM.Pick(s.Float())
if major {
throw = cfg.ThrowMajorM.Pick(s.Float())
}
// Parallel to the grain, with a little scatter: never a random orientation.
a := grainAngle + (s.Float()-0.5)*0.6
cx, cy := s.Float(), s.Float()
half := lengthM / sideM / 2
// A polyline, gently curved by low-frequency wander rather than a straight segment.
const segs = 8
pts := make([][2]float64, segs+1)
wander := (s.Float() - 0.5) * 0.5
for j := 0; j <= segs; j++ {
t := float64(j)/segs*2 - 1 // -1..1
off := wander * (1 - t*t) // zero at the tips, largest in the middle
px := cx + math.Cos(a)*half*t - math.Sin(a)*half*off
py := cy + math.Sin(a)*half*t + math.Cos(a)*half*off
pts[j] = [2]float64{px, py}
}
faults = append(faults, Fault{Points: pts, ThrowM: throw, Major: major, Reverse: s.Float() < 0.5})
}
// Throw is a total displacement over the run, so it becomes a rate the solve can integrate.
steepM := 200.0
gentleM := 2000.0
field.Rows(rate.H, func(y0, y1 int) {
for y := y0; y < y1; y++ {
for x := 0; x < rate.W; x++ {
i := y*rate.W + x
px := float64(u.Data[i])
py := float64(v.Data[i])
var delta float64
for _, f := range faults {
d, inside := signedDistance(px, py, f.Points)
if !inside {
continue
}
dm := d * sideM
sign := 1.0
if f.Reverse {
sign = -1
}
// Steep side falls off fast, gentle side slowly: an asymmetric block, not a ridge.
var w float64
if dm*sign >= 0 {
w = math.Exp(-math.Abs(dm) / steepM)
} else {
w = -math.Exp(-math.Abs(dm) / gentleM)
}
delta += f.ThrowM / runYears * w
}
if delta != 0 {
r := float64(rate.Data[i]) + delta
if r < 0 {
r = 0
}
if r > convergent*1.6 {
r = convergent * 1.6
}
rate.Data[i] = float32(r)
}
}
}
})
return faults
}
// signedDistance is the perpendicular distance from a point to a polyline, signed by which side it falls on,
// in map units. inside is false beyond the ends, where a fault has no effect.
func signedDistance(px, py float64, pts [][2]float64) (float64, bool) {
best := math.Inf(1)
sign := 1.0
found := false
for j := 0; j+1 < len(pts); j++ {
ax, ay := pts[j][0], pts[j][1]
bx, by := pts[j+1][0], pts[j+1][1]
dx, dy := bx-ax, by-ay
l2 := dx*dx + dy*dy
if l2 < 1e-12 {
continue
}
t := ((px-ax)*dx + (py-ay)*dy) / l2
if t < 0 || t > 1 {
continue // beyond this segment; a neighbouring one may still claim the point
}
found = true
projx, projy := ax+t*dx, ay+t*dy
d := math.Hypot(px-projx, py-projy)
if d < best {
best = d
// Cross product decides the side.
if (px-ax)*dy-(py-ay)*dx < 0 {
sign = -1
} else {
sign = 1
}
}
}
if !found {
return 0, false
}
return best * sign, true
}