This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
@@ -0,0 +1,358 @@
package uplift
import (
"math"
"salty/terrain/internal/noise"
"salty/terrain/internal/plates"
"salty/terrain/internal/world"
)
// Faults belonging to a plate boundary rather than to a painted class.
//
// The specification for this file is a map of the Alpide belt - Spain through the Maghreb, Italy, Greece,
// Turkey, Iran, Afghanistan, the Pamir, the Himalaya and into Burma - with every mapped fault trace on it.
// Six things are true of that picture and not one of them is true of a fault set scattered inside a painted
// colour:
//
// 1. **The traces are in swarms along a belt, and everywhere else is blank.** The Sahara has none. Arabia's
// interior has none. Peninsular India, Kazakhstan, Ukraine: none. A craton is not lightly faulted, it is
// unfaulted, and the belt next to it is saturated. Density is therefore a function of distance to a
// boundary and of nothing else - not of which colour the ground was painted.
// 2. **The belt is wide, and how wide varies enormously.** Through Italy and Greece it is a hundred
// kilometres; across Iran and Tibet it is well over a thousand, a fan of parallel traces from the Zagros
// to the Alborz. So the zone is not a fixed halo: it scales with what the margin is doing.
// 3. **Faults are near the line, not on it.** Almost none of those traces *is* the plate boundary. They sit
// tens to hundreds of kilometres either side of it, thickest near it and thinning outwards - deformation
// is distributed across a zone, and the boundary is only where it is centred.
// 4. **Within a swarm they are sub-parallel**, to each other and to the belt, and they follow it round its
// bends: the Turkish arc, the Zagros arc, the Himalayan arc, the fan at the Burma syntaxis. The strike
// comes from the local tangent of the boundary, which is why the whole set curves where the margin does.
// 5. **There is a second, conjugate direction** in the wide interiors - Tibet and Mongolia show two sets
// crossing at a high angle. One direction alone reads as corduroy, which is the defect Terrain-Next 4.A3
// records against the procedural path at a different scale.
// 6. **They splay and anastomose** rather than running as isolated segments, which the en-echelon stepping
// in painted_faults.go already produces and which is kept here unchanged.
//
// Everything about a *trace* - the walked heading, the taper over the last sixth, the en-echelon step past
// twelve kilometres, the escarpment profile, the repose ceiling - is shared with the class-based set through
// traceSet. What is new here is only where a fault is put and which way it points, which is exactly the part
// that was wrong.
const srcBeltFaults = 29
// beltWidth is how wide each kind of margin's deformation zone is, as a multiple of the configured width.
//
// These are ratios between kinds of boundary rather than tuning, which is why they are constants and not
// manifest keys. A continental collision has nowhere to put the convergence except into the crust on both
// sides, so it deforms a belt a thousand kilometres across; a subduction margin puts most of it down the slab
// and deforms an arc and a forearc; a transform is a narrow braid however long it runs, because the motion is
// taken up by sliding rather than by shortening; a rift deforms its two shoulders; and a mid-ocean ridge is
// the narrowest of all, an axis a few tens of kilometres wide.
var beltWidth = map[plates.Kind]float64{
plates.Collision: 1.00,
plates.Subduction: 0.55,
plates.Rift: 0.35,
plates.Transform: 0.30,
plates.Ridge: 0.15,
}
// beltFalloff shapes how the traces thin out away from the line.
//
// An offset drawn as zone*u would spread them evenly across the whole zone, which is not what the map shows:
// the swarm is dense at the margin and trails off. Raising a uniform draw to this power biases it towards
// zero, so the density falls smoothly outwards and the zone edge is a fading-out rather than a line where
// faults stop.
const beltFalloff = 1.8
// beltThrowFloor and beltThrowCeil bound how far the closing rate is allowed to scale a throw. A margin that
// has almost stopped still has inherited structure in it, and one going twice as fast as the reference does
// not build scarps four times the size, because the repose ceiling is waiting either way.
const (
beltThrowFloor = 0.35
beltThrowCeil = 2.0
)
// beltLandProbes is how many positions are sampled across the zone to find out how much of it is land.
//
// It is measured rather than assumed because the density has to keep meaning what it says. A margin running
// down the middle of an ocean and one running along a continent have the same length and the same zone area,
// and if the count came from the zone area alone the first would ask for as many traces as the second and
// then fail to place them - so the density would quietly mean something different on every boundary.
const beltLandProbes = 512
// beltPlaceTries is how many times a trace is redrawn when it lands in the sea before giving up on it.
const beltPlaceTries = 12
// beltLandShare is how much of a trace has to be on land for it to be kept. Half rather than all, because a
// fault that runs out to a coast and stops is right and a fault forbidden from reaching one is not: the
// result of demanding every probe be land is a set that avoids the shore, which is the opposite mistake.
const beltLandShare = 0.5
// BuildBeltFaults places a fault set in the deformation zones around a planet's plate boundaries.
//
// land reports whether a world position is painted land, the same callback plates.Build takes. Offshore
// faults are real - the reference map has them all over the Mediterranean and the Arabian Sea - but the solve
// fixes every ocean cell at sea level, so a trace out there changes nothing and only clutters the diagnostic.
// They are therefore kept on land, and the density is measured against the part of each zone that *is* land
// so that the number an author sets keeps meaning what it says.
func BuildBeltFaults(p world.Planet, seed int64, cfg plates.Belt, bs []plates.Boundary,
land func(xM, yM float64) bool) []FaultTrace {
if !cfg.Wanted() || len(bs) == 0 {
return nil
}
cfg = cfg.WithDefaults()
s := noise.NewSource(seed, srcBeltFaults)
// The bend lattice a trace's walk turns on. Its wavelength is tied to the zone rather than to the
// planet's fault grain: a trace inside a belt should curve on the belt's own scale.
bendCells := int(p.NoisePeriodM/(cfg.ZoneKm*1000) + 0.5)
if bendCells < 1 {
bendCells = 1
}
pl := &beltPlacer{
p: p, s: s, bend: noise.NewLattice(bendCells*4, s), bendCells: bendCells, cfg: cfg,
spread: cfg.Spread() * math.Pi / 180,
conj: cfg.ConjugateDeg * math.Pi / 180,
land: land,
}
refM := cfg.ReferenceCmYr / 100 // cm/yr to m/yr
var out []FaultTrace
for bi := range bs {
b := &bs[bi]
if len(b.V) < 2 {
continue
}
seg := beltSegments(b, cfg, refM)
if seg.zoneKm2 <= 0 {
continue
}
// How much of this belt's zone a fault can actually be placed in, measured by proposing faults
// exactly the way the placement loop below does and counting how many survive. The same draw and the
// same test, so the acceptance rate the loop will see is the one the count is scaled by and the
// density keeps meaning what it says.
kept := 0
for i := 0; i < beltLandProbes; i++ {
if len(pl.propose(seg)) > 0 {
kept++
}
}
usable := float64(kept) / beltLandProbes
if usable <= 0 {
continue
}
want := cfg.Per1000Km2 * seg.zoneKm2 * usable / 1000
n := int(want)
// Stochastic rounding, so a short margin too small for one whole fault still gets one sometimes and
// the density means what it says averaged over a planet rather than being floored to zero.
if s.Float() < want-float64(n) {
n++
}
for i := 0; i < n; i++ {
for try := 0; try < beltPlaceTries; try++ {
if set := pl.propose(seg); len(set) > 0 {
out = append(out, set...)
break
}
}
}
}
return out
}
// beltPlacer is everything one fault's placement needs, carried together because proposing a fault and
// measuring how often a proposal succeeds have to be the same code. See propose.
type beltPlacer struct {
p world.Planet
s *noise.Source
bend *noise.Lattice
bendCells int
cfg plates.Belt
spread float64
conj float64
land func(xM, yM float64) bool
}
// propose draws one fault in the zone, builds it, and returns the traces it is made of with any that ended
// up offshore removed. An empty result is a proposal the caller should redraw.
//
// The land test is applied to the **walked geometry**, not to the straight line the fault was proposed along,
// and that distinction is the whole reason this function exists. Between a proposal and a trace sit two
// things that move it: a fault over twelve kilometres is broken into en-echelon segments staggered across
// strike, and every segment is then walked with a perturbed heading. Testing the proposal let a trace be
// accepted on a headland and then stepped and walked out into open water, which is what the first run of
// this pass drew across two straits.
//
// Segments are filtered one at a time rather than the set being kept or dropped whole, because part of a
// fault continuing offshore while the rest of it is on land is the ordinary case at any coast.
func (pl *beltPlacer) propose(seg beltSeg) []FaultTrace {
xM, yM, tangent, halfM, side := seg.sample(pl.s)
a := tangent + (pl.s.Float()*2-1)*pl.spread
if pl.cfg.Conjugate() > 0 && pl.s.Float() < pl.cfg.Conjugate() {
// The second set, crossing the first. Which way it leans is drawn per fault, because a conjugate
// pair is two directions and picking one of them globally would be the corduroy this exists to avoid.
if pl.s.Float() < 0.5 {
a += pl.conj
} else {
a -= pl.conj
}
}
lengthM := (pl.cfg.LengthKm[0] + (pl.cfg.LengthKm[1]-pl.cfg.LengthKm[0])*pl.s.Float()) * 1000
// A wide belt carries long faults. Scaled against the configured width so that the length range an
// author sets is the one they get on a reference-rate collision.
lengthM *= clampF(halfM/(pl.cfg.ZoneKm*1000), 0.4, 2.2)
throw := pl.cfg.ThrowM[0] + (pl.cfg.ThrowM[1]-pl.cfg.ThrowM[0])*pl.s.Float()
// Vergence, and this is the point of carrying the side at all. A thrust belt is doubly vergent: the
// faults on each flank face outwards, away from the boundary and towards the foreland they are riding
// over. So which block goes up is decided by which side of the line the fault sits on, rather than by
// the coin flip a class fault set has to use for want of anything better.
set := traceSet(pl.p, pl.bend, pl.bendCells, pl.s,
xM, yM, a, lengthM, throw*seg.throwScale, side < 0, -1)
if pl.land == nil {
return set
}
out := set[:0]
for _, f := range set {
if traceLandShare(f, pl.land) >= beltLandShare {
out = append(out, f)
}
}
return out
}
// traceLandShare is how much of a built trace stands on painted land.
//
// Offshore faults are real - the reference map has them throughout the Mediterranean and the Arabian Sea -
// but the solve fixes every ocean cell at sea level, so a trace out there changes no height and does nothing
// but clutter the diagnostic the fault set is read from.
func traceLandShare(f FaultTrace, land func(xM, yM float64) bool) float64 {
if len(f.PointsM) == 0 {
return 0
}
on := 0
for _, pt := range f.PointsM {
if land(pt[0], pt[1]) {
on++
}
}
return float64(on) / float64(len(f.PointsM))
}
// beltSeg is one boundary prepared for sampling: cumulative length along it, the zone half-width at each
// vertex, and the zone's total area.
type beltSeg struct {
b *plates.Boundary
// cum[i] is the length along the polyline up to vertex i, so a uniform draw over cum[last] picks a point
// uniformly along the *line* rather than uniformly among its vertices - which would over-sample wherever
// the chain happened to be dense.
cum []float64
half []float64 // zone half-width in metres at each vertex
zoneKm2 float64
throwScale float64
}
// beltSegments measures a boundary: how wide its zone is at every point, how much ground that is, and how
// much the closing rate should scale the throws in it.
func beltSegments(b *plates.Boundary, cfg plates.Belt, refM float64) beltSeg {
seg := beltSeg{b: b, cum: make([]float64, len(b.V)), half: make([]float64, len(b.V))}
base := cfg.ZoneKm * 1000
closingTotal := 0.0
for i, v := range b.V {
w := beltWidth[v.Kind]
rate := math.Abs(v.ClosingMYr)
if v.Kind == plates.Transform {
// A transform closes at nothing by definition, so its zone has to be scaled by how fast it is
// *sliding* instead. Without this every transform margin in the world would have a zone of zero
// and the San Andreas would be unfaulted.
rate = math.Abs(v.SlipMYr)
}
// The square root, not the rate itself: doubling the convergence does not double the width of the
// belt it deforms, and a linear scale makes the fastest margin swallow a continent.
scale := math.Sqrt(clampF(rate/refM, 0.04, 6))
seg.half[i] = base * w * scale
closingTotal += rate
}
for i := 1; i < len(b.V); i++ {
d := math.Hypot(b.V[i].XM-b.V[i-1].XM, b.V[i].YM-b.V[i-1].YM)
seg.cum[i] = seg.cum[i-1] + d
// The zone either side of this segment, as a trapezium on each flank.
seg.zoneKm2 += d * (seg.half[i-1] + seg.half[i]) / 1e6
}
if n := len(b.V); n > 0 {
mean := closingTotal / float64(n)
seg.throwScale = clampF(mean/refM, beltThrowFloor, beltThrowCeil)
}
return seg
}
// sample draws one position in the zone: a point along the line, then an offset across it.
//
// It returns the world position, the belt's local strike there, the local zone half-width, and which side of
// the line the point fell on. The side is what vergence is read from, and the half-width is what a trace's
// length is scaled by.
func (seg beltSeg) sample(s *noise.Source) (xM, yM, strike, halfM, side float64) {
total := seg.cum[len(seg.cum)-1]
if total <= 0 {
return seg.b.V[0].XM, seg.b.V[0].YM, 0, seg.half[0], 1
}
at := s.Float() * total
// Walk to the segment holding it. Linear rather than a binary search on purpose: a boundary is a few
// hundred vertices and this runs a few thousand times, so the search is not where the time goes and a
// loop with no off-by-one in it is worth more here than the log.
i := 1
for i < len(seg.cum)-1 && seg.cum[i] < at {
i++
}
t := 0.0
if d := seg.cum[i] - seg.cum[i-1]; d > 0 {
t = (at - seg.cum[i-1]) / d
}
a, b := seg.b.V[i-1], seg.b.V[i]
lx := a.XM + (b.XM-a.XM)*t
ly := a.YM + (b.YM-a.YM)*t
nx := a.NX + (b.NX-a.NX)*t
ny := a.NY + (b.NY-a.NY)*t
if d := math.Hypot(nx, ny); d > 0 {
nx, ny = nx/d, ny/d
}
halfM = seg.half[i-1] + (seg.half[i]-seg.half[i-1])*t
// Across the line. The offset is biased towards zero so the swarm is dense at the margin and trails off,
// and the side is drawn separately so both flanks are populated.
side = 1
if s.Float() < 0.5 {
side = -1
}
off := halfM * math.Pow(s.Float(), beltFalloff) * side
// The strike is the boundary's own tangent, which is the perpendicular of the normal. This single line is
// the whole difference between a swarm that follows the Zagros round its arc and a set of traces pointing
// wherever a noise lattice happened to say.
strike = math.Atan2(-nx, ny)
return lx + nx*off, ly + ny*off, strike, halfM, side
}
func clampF(v, lo, hi float64) float64 {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
@@ -0,0 +1,240 @@
package uplift
import (
"math"
"testing"
"salty/terrain/internal/plates"
"salty/terrain/internal/world"
)
// A straight north-south margin down the middle of a planet, closing head-on. Everything a belt fault is
// supposed to do is measurable against a line whose direction is known: the traces should run along it, sit
// near it, and face away from it.
func straightMargin(t *testing.T, p world.Planet) []plates.Boundary {
t.Helper()
const n = 200
xM := p.CircumferenceM() / 2
v := make([]plates.Vertex, n)
for i := range v {
v[i] = plates.Vertex{
XM: xM,
YM: p.HeightM() * float64(i) / float64(n-1),
NX: 1, // the margin runs north-south, so its normal points east
NY: 0,
ClosingMYr: 0.04,
Kind: plates.Collision,
Over: -1,
}
}
return []plates.Boundary{{A: 0, B: 1, V: v}}
}
func beltPlanet(t *testing.T) world.Planet {
t.Helper()
p, err := world.New(40000, 8, 100, 50, 0, 40000)
if err != nil {
t.Fatalf("planet: %v", err)
}
return p
}
func testBelt() plates.Belt {
b := plates.DefaultBelt()
b.ZoneKm = 3
b.Per1000Km2 = 400
none := 0.0
b.ConjugateFraction = &none // measured separately; the main set has to be parallel on its own
return b
}
func allLand(xM, yM float64) bool { return true }
func TestBeltFaultsRunAlongTheMargin(t *testing.T) {
p := beltPlanet(t)
fs := BuildBeltFaults(p, 7, testBelt(), straightMargin(t, p), allLand)
if len(fs) < 20 {
t.Fatalf("%d traces; not enough to measure anything", len(fs))
}
// The margin runs north-south, so every trace should too. Measured as the angle between the trace's own
// end-to-end direction and the line, folded into 0..90 because a fault has no head or tail.
worst, total := 0.0, 0.0
for _, f := range fs {
a, b := f.PointsM[0], f.PointsM[len(f.PointsM)-1]
deg := foldedAngleDeg(math.Atan2(b[1]-a[1], b[0]-a[0]), math.Pi/2)
total += deg
if deg > worst {
worst = deg
}
}
mean := total / float64(len(fs))
// The configured spread is 11 degrees, and the walk wanders on top of it. A mean much above that would
// mean the strike is not coming from the boundary at all, which is the defect this whole file exists for.
if mean > 20 {
t.Errorf("traces average %.1f degrees off the margin; they are not following it", mean)
}
if worst > 55 {
t.Errorf("a trace is %.1f degrees off the margin; nothing should be near perpendicular to it", worst)
}
}
// foldedAngleDeg is the angle between two directions, in degrees, folded into 0..90: a line at 170 degrees
// and one at 10 are twenty degrees apart, not a hundred and sixty.
func foldedAngleDeg(a, b float64) float64 {
d := math.Abs(a-b) * 180 / math.Pi
d = math.Mod(d, 180)
if d > 90 {
d = 180 - d
}
return d
}
func TestBeltFaultsStayInTheDeformationZone(t *testing.T) {
p := beltPlanet(t)
cfg := testBelt()
fs := BuildBeltFaults(p, 7, cfg, straightMargin(t, p), allLand)
if len(fs) == 0 {
t.Fatal("no traces")
}
xM := p.CircumferenceM() / 2
// The zone half-width here is the configured width times the collision multiplier times the rate scale.
// A *fault's* centre is placed inside it, but a trace's need not be: a fault over twelve kilometres is
// broken into en-echelon segments staggered up to 0.06 of its length across strike, which is the whole
// point of the stepping. So the bound on a segment centre is the zone plus that stagger, and the bound on
// any point of it is a further half-length beyond that.
half := cfg.ZoneKm * 1000 * beltWidth[plates.Collision] * math.Sqrt(0.04/(cfg.ReferenceCmYr/100))
longest := cfg.LengthKm[1] * 1000 * 2.2
centreBound := half + 0.06*longest
anyBound := centreBound + longest
far := 0
inZone := 0
for _, f := range fs {
mid := f.PointsM[len(f.PointsM)/2]
d := math.Abs(mid[0] - xM)
if d > centreBound {
far++
}
if d <= half {
inZone++
}
for _, pt := range f.PointsM {
if math.Abs(pt[0]-xM) > anyBound {
t.Fatalf("a trace reaches %.0f m from the margin; the zone, the stagger and a trace is %.0f m",
math.Abs(pt[0]-xM), anyBound)
}
}
}
if far > 0 {
t.Errorf("%d of %d trace centres sit outside the deformation zone and its en-echelon stagger",
far, len(fs))
}
// And they should be *concentrated* near the line rather than spread evenly across the zone: that is what
// beltFalloff is for, and what the reference map shows.
near := 0
for _, f := range fs {
if math.Abs(f.PointsM[len(f.PointsM)/2][0]-xM) < half/2 {
near++
}
}
if float64(near)/float64(inZone) < 0.55 {
t.Errorf("only %d of %d traces are in the inner half of the zone; the falloff is not biting",
near, inZone)
}
}
func TestBeltFaultsVergeAwayFromTheMargin(t *testing.T) {
p := beltPlanet(t)
cfg := testBelt()
// Short faults only. Vergence is decided per *fault*, from which side of the line it was placed on, and
// then every en-echelon segment of it inherits that - correctly, since the segments are one fault. Keeping
// every fault under enEchelonM means one trace per placement, so the side a trace sits on and the side it
// was placed on are the same thing and the property can be measured at all.
cfg.LengthKm = [2]float64{2, 4}
fs := BuildBeltFaults(p, 7, cfg, straightMargin(t, p), allLand)
if len(fs) < 20 {
t.Fatalf("%d traces; not enough to measure anything", len(fs))
}
xM := p.CircumferenceM() / 2
wrong := 0
for _, f := range fs {
mid := f.PointsM[len(f.PointsM)/2]
// A doubly-vergent belt faces outwards on both flanks, so the two sides must disagree about which
// block goes up. Which flank got which sign does not matter; that they are consistent within a flank
// does, because the alternative is the coin flip a class fault set has to use.
if (mid[0] > xM) != f.Reverse {
wrong++
}
}
if wrong != 0 && wrong != len(fs) {
t.Errorf("%d of %d traces disagree with their own flank about vergence; a belt is doubly vergent, "+
"not randomly vergent", min(wrong, len(fs)-wrong), len(fs))
}
}
func TestBeltFaultsNeedLand(t *testing.T) {
p := beltPlanet(t)
m := straightMargin(t, p)
if got := BuildBeltFaults(p, 7, testBelt(), m, func(xM, yM float64) bool { return false }); len(got) != 0 {
t.Errorf("%d traces on a planet with no land", len(got))
}
// A coast down one side of the margin: every trace must be mostly on the land side.
xM := p.CircumferenceM() / 2
half := func(x, y float64) bool { return x < xM }
fs := BuildBeltFaults(p, 7, testBelt(), m, half)
if len(fs) == 0 {
t.Fatal("no traces on a half-land planet")
}
for _, f := range fs {
on := 0
for _, pt := range f.PointsM {
if half(pt[0], pt[1]) {
on++
}
}
if share := float64(on) / float64(len(f.PointsM)); share < 0.3 {
t.Errorf("a trace is only %.0f%% on land; the span test should have refused it", share*100)
}
}
}
func TestABeltWithNoNumbersAsksForNothing(t *testing.T) {
p := beltPlanet(t)
if got := BuildBeltFaults(p, 7, plates.Belt{}, straightMargin(t, p), allLand); got != nil {
t.Errorf("%d traces from an empty config; leaving the block out must leave the feature off", len(got))
}
}
func TestAFasterMarginDeformsAWiderBelt(t *testing.T) {
p := beltPlanet(t)
cfg := testBelt()
spread := func(closing float64) float64 {
bs := straightMargin(t, p)
for i := range bs[0].V {
bs[0].V[i].ClosingMYr = closing
}
fs := BuildBeltFaults(p, 7, cfg, bs, allLand)
if len(fs) == 0 {
t.Fatalf("no traces at %.3g m/yr", closing)
}
xM := p.CircumferenceM() / 2
worst := 0.0
for _, f := range fs {
mid := f.PointsM[len(f.PointsM)/2]
if d := math.Abs(mid[0] - xM); d > worst {
worst = d
}
}
return worst
}
slow, fast := spread(0.01), spread(0.08)
if fast <= slow*1.4 {
t.Errorf("a margin closing eight times faster deforms a belt %.0f m wide against %.0f m; the zone is "+
"not scaling with the rate", fast, slow)
}
}
+216
View File
@@ -0,0 +1,216 @@
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
}
@@ -0,0 +1,242 @@
package uplift
import (
"math"
"testing"
"salty/terrain/internal/manifest"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
// testPlanet is a small cylinder with a period that divides its circumference, which world.Planet.Validate
// requires and which every noise field here depends on.
func testPlanet(t *testing.T, w, h int, cellM float64) world.Planet {
t.Helper()
p := world.Planet{CellM: cellM, W: w, H: h, PadY: 0, NoisePeriodM: float64(w) * cellM}
if err := p.Validate(); err != nil {
t.Fatal(err)
}
return p
}
// The whole contract of the fraction key: it is a share of the planet's surface, and it is the share standing
// above the midpoint between the class floor and the class rate.
//
// This is the test that makes the number worth writing in a legend. Cutting the fabric at a fixed *value*
// instead would make the realised share depend on the shape of the noise's distribution, which nobody can
// predict from a JSON file, and it would drift every time an octave count changed.
func TestTheMassifFractionIsTheShareOfThePlanetThatStandsUp(t *testing.T) {
// Wider than massifProbeW, deliberately. At 512 the probe clamps to the planet's own width and samples
// the identical grid, so every number below comes out exact and the test measures nothing - which is
// what the first version of it did. A real planet is 12500 columns against a 1024-column probe, so the
// distribution being applied is always a coarser measurement of the field than the field it is applied
// to, and that gap is the thing worth bounding.
const w, h, cellM = 2048, 512, 64.0
p := testPlanet(t, w, h, cellM)
f := world.Whole(p)
for _, fraction := range []float64{0.05, 0.15, 0.30, 0.50} {
u, v := noise.WorldUV(w, h, cellM, f.OriginXM(), f.OriginYM(), p.NoisePeriodM)
rank := MassifRank(p, 7, 8, u, v)
above, full, off := 0, 0, 0
for _, r := range rank.Data {
s := massifShape(float64(r), fraction)
if s > 0.5 {
above++
}
if s >= 1 {
full++
}
if s > 0 {
off++
}
}
got := float64(above) / float64(len(rank.Data))
// Two per cent of the planet. The probe is 1024 x 256 against this 2048 x 512 grid, so the two are
// sampling the same field at different steps and cannot agree to the cell.
if math.Abs(got-fraction) > 0.02 {
t.Errorf("fraction %.2f: %.1f%% of the planet stands above the midpoint, want %.0f%%",
fraction, 100*got, 100*fraction)
}
// Half the fraction again reaches the class rate outright and half again above that is off the plain
// at all. Both follow from the ramp and both are what the legend documents.
if g, want := float64(full)/float64(len(rank.Data)), fraction*0.5; math.Abs(g-want) > 0.02 {
t.Errorf("fraction %.2f: %.1f%% is at the full rate, want %.0f%%", fraction, 100*g, 100*want)
}
if g, want := float64(off)/float64(len(rank.Data)), fraction*1.5; math.Abs(g-want) > 0.03 {
t.Errorf("fraction %.2f: %.1f%% is off the plain, want %.0f%%", fraction, 100*g, 100*want)
}
}
}
// Rule 1 of the tiling plan, for the fabric: two regions covering the same physical place must agree to the
// bit. This is the one that would fail if the threshold were ever taken as a percentile of the region, which
// is the obvious implementation and the wrong one - see the note on massifCDF.
func TestTwoFramesAgreeAboutTheSameGround(t *testing.T) {
const w, h, cellM = 2048, 512, 64.0
p := testPlanet(t, w, h, cellM)
rankIn := func(f world.Frame) []float32 {
u, v := noise.WorldUV(f.W, f.H, cellM, f.OriginXM(), f.OriginYM(), p.NoisePeriodM)
return MassifRank(p, 7, 8, u, v).Data
}
whole := rankIn(world.Whole(p))
// A window well inside the planet, and a second one overlapping it from a different origin.
a := world.Frame{P: p, X0: 400, Y0: 80, W: 240, H: 160}
b := world.Frame{P: p, X0: 520, Y0: 120, W: 240, H: 160}
ra, rb := rankIn(a), rankIn(b)
checked := 0
for y := 0; y < a.H; y++ {
for x := 0; x < a.W; x++ {
px, py := a.PlanetXY(x, y)
if px < b.X0 || px >= b.X0+b.W || py < b.Y0 || py >= b.Y0+b.H {
continue
}
got := ra[y*a.W+x]
want := rb[(py-b.Y0)*b.W+(px-b.X0)]
if got != want {
t.Fatalf("at planet (%d,%d) frame A says %v and frame B says %v", px, py, got, want)
}
if wh := whole[py*p.W+px]; wh != got {
t.Fatalf("at planet (%d,%d) a frame says %v and the whole planet says %v", px, py, got, wh)
}
checked++
}
}
if checked == 0 {
t.Fatal("the two frames do not overlap; this test measured nothing")
}
}
// A frame that straddles the seam is ordinary, not special: column W-1 and column 0 are neighbours, so the
// fabric has to run continuously across them. A wrong noise period is the way this breaks, and it breaks
// invisibly on a map whose two edges are as far apart on screen as they can be.
func TestTheFabricCrossesTheSeam(t *testing.T) {
const w, h, cellM = 2048, 512, 64.0
p := testPlanet(t, w, h, cellM)
u, v := noise.WorldUV(w, h, cellM, 0, 0, p.NoisePeriodM)
whole := MassifRank(p, 7, 8, u, v)
// The step across the seam must be no bigger than a typical step inside the map.
worstSeam, worstInside := 0.0, 0.0
for y := 0; y < h; y++ {
d := math.Abs(float64(whole.Data[y*w] - whole.Data[y*w+w-1]))
if d > worstSeam {
worstSeam = d
}
for x := 1; x < w; x++ {
if e := math.Abs(float64(whole.Data[y*w+x] - whole.Data[y*w+x-1])); e > worstInside {
worstInside = e
}
}
}
if worstSeam > worstInside {
t.Errorf("the biggest step across the seam is %.4f against %.4f anywhere inside the map; "+
"the fabric does not wrap", worstSeam, worstInside)
}
}
// What the feature is for, measured on the thing an author actually gets: a class with a massif has to come
// out mostly plain, and the plain has to be the floor rather than some average of the two.
func TestAPaintedClassWithAMassifIsMostlyPlain(t *testing.T) {
const w, h, cellM = 2048, 512, 64.0
p := testPlanet(t, w, h, cellM)
f := world.Whole(p)
class := make([]uint8, w*h)
land := make([]bool, w*h)
for i := range class {
class[i], land[i] = 1, true
}
const rate = 0.00008 // 0.08 mm/yr, the rate a massif reaches
const floor = 0.00001 // 0.01 mm/yr, the plain
const fraction = 0.15
m := manifest.Defaults()
m.Source.Seed = 7
up := FromTemplate(Paint{
Frame: f, Class: class, Land: land,
Rates: []float32{0, rate},
Ks: []float32{1, 1},
PlainM: []float64{0, 0},
PlainFloor: []float32{0, 0},
MassifFloor: []float32{0, floor},
MassifFraction: []float64{0, fraction},
MassifCells: 8,
Variation: 0, // the swell off, so the fabric is the only thing being measured
}, m)
// Under twice the floor is "plain" for this purpose: the ramp is smooth, so a cell just off the plain is
// still plain, and the question being asked is whether most of the class is down there at all.
plain, high := 0, 0
for _, r := range up.Rate.Data {
if float64(r) < 2*floor {
plain++
}
if float64(r) > 0.5*(rate+floor) {
high++
}
}
if share := float64(plain) / float64(len(up.Rate.Data)); share < 0.6 {
t.Errorf("only %.0f%% of the class is plain; the point of a massif is that most of it is", 100*share)
}
if share := float64(high) / float64(len(up.Rate.Data)); math.Abs(share-fraction) > 0.02 {
t.Errorf("%.1f%% of the class is above the midpoint, want %.0f%%", 100*share, 100*fraction)
}
// And the floor has to be the floor. Before this existed the lowest rate on a uniformly painted class was
// the class rate itself, which is exactly the defect: 0.08 mm/yr is an 11 degree hillslope everywhere.
lo := math.Inf(1)
for _, r := range up.Rate.Data {
if float64(r) < lo {
lo = float64(r)
}
}
if math.Abs(lo-floor) > 0.02*floor {
t.Errorf("the lowest rate on the class is %.5f mm/yr, want the floor %.3f", lo*1000, floor*1000)
}
}
// A legend that asks for no massif has to produce exactly what it did before the fabric existed. The fabric
// is opt-in and it must not be a silent change to every template already written against the old contract.
func TestAClassWithoutAMassifIsUnchanged(t *testing.T) {
const w, h, cellM = 256, 128, 64.0
p := testPlanet(t, w, h, cellM)
f := world.Whole(p)
class := make([]uint8, w*h)
land := make([]bool, w*h)
for i := range class {
class[i], land[i] = 1, true
}
const rate = 0.00008
m := manifest.Defaults()
m.Source.Seed = 7
base := Paint{
Frame: f, Class: class, Land: land,
Rates: []float32{0, rate}, Ks: []float32{1, 1},
PlainM: []float64{0, 0}, PlainFloor: []float32{0, 0},
Variation: 0.3,
}
without := FromTemplate(base, m)
withTables := base
withTables.MassifFloor = []float32{0, 0}
withTables.MassifFraction = []float64{0, 0} // the tables present, the feature not asked for
withTables.MassifCells = 8
same := FromTemplate(withTables, m)
for i := range without.Rate.Data {
if without.Rate.Data[i] != same.Rate.Data[i] {
t.Fatalf("cell %d: %v without the massif tables, %v with them at fraction 0",
i, without.Rate.Data[i], same.Rate.Data[i])
}
}
}
+348
View File
@@ -0,0 +1,348 @@
package uplift
import (
"math"
"salty/terrain/internal/dt"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
// The painted source, and the one decision behind all of it: paint the uplift, never the height.
//
// Docs/Terrain-Next.md 6 lists importing a painted heightmap under "do not redo these", and the reason is
// not taste. A stream-power solve handed a painted surface erodes it into something else within a few
// hundred steps, and what it produces instead has no relationship to what was drawn - while the drainage
// network, which is the entire reason this generator replaced the droplet pipeline, is thrown away and
// rebuilt from whatever the painting happened to leave behind.
//
// Painting the uplift rate instead means an author draws intent - a range here, lowlands there, a coast like
// this - and the simulation produces terrain that honours it and has real rivers, real divides and a real
// valley hierarchy, because those came out of the physics rather than out of the brush.
//
// What is left for noise to do is therefore narrow, and it is not decoration:
//
// - The regional swell. D-49 is arithmetic: with critical_area_m2 at 0 the steady-state slope is
// U/(K*A^m) down to a single cell, so a uniform uplift rate over a wide area gives a surface with no
// divides at all. A painted lowland holds one rate over tens of kilometres. Without a long-wavelength
// modulation the plains come out table-flat, the only gradient across them is the priority-flood's
// epsilon, and the router draws the flood's traversal order as rivers. That was measured once already.
// - The initial relief, which only breaks the symmetry. Small on purpose: the solve is what produces
// relief, and starting it from big ridges means it spends its run tearing them down.
//
// Every noise field here is built on world coordinates through noise.WorldUV, so two regions covering the
// same physical place agree to the bit. That is rule 1 of the tiling plan.
// Pass indices for the painted path's seeded sources. They sit above the procedural path's 1..9 so that the
// two never share a stream and adding one here cannot reshuffle the other.
// Feature counts, in lattice cells per noise period. They are named because the warp amounts are derived
// from them - a warp is only meaningful as a fraction of the wavelength it is bending.
const (
swellCells = 4 // 25 km at a 100 km period: the regional swell
ridgeCells = 24 // 4.2 km: the initial relief
crestCells = 64 // 1.6 km: the crest lines
plainCells = 48 // 2.1 km: the lowland break-up
)
const (
srcPaintSwell = 20
srcPaintRidges = 21
srcPaintCrests = 22
srcPaintPlains = 23
srcPaintWarp = 24
srcPaintMassif = 25
)
// Paint is a region's painted world: which class every cell is, which cells are land, and what the legend
// says those classes mean.
type Paint struct {
Frame world.Frame
Class []uint8 // one legend index per frame cell
Land []bool // land the region owns; everything else is water, including other regions' islands
Rates []float32 // per class, metres a year
Ks []float32 // per class, the multiplier on stream-power K
// PlainM and PlainFloor put a class's range inland: within PlainM metres of the waterline the rate ramps
// from PlainFloor up to the class rate. Per class; zero PlainM means the class reaches the sea at its
// full rate, which is what every class did before and still does unless an author asks otherwise.
PlainM []float64
PlainFloor []float32
// MassifFloor and MassifFraction break a class into plain and upland instead of holding it at one rate.
// Where the fraction is zero the class is uniform, which is what every class did before this existed and
// what a legend that asks for nothing still gets. See massif.go: the class rate is then the rate a massif
// reaches, the floor is the plain between them, and the fraction is how much of the class stands above
// the midpoint of the two.
MassifFloor []float32
MassifFraction []float64
// MassifCells is the fabric's wavelength in lattice cells of the noise period, from
// manifest.Planet.MassifCells. Read only when some class asks for a massif.
MassifCells int
// RockCells and RockMult are the planet's lithology: the wavelength of the rock field in lattice cells,
// and the erodibility multiplier of each rock type. LithMix is per class, how much of it shows through.
// Zero cells, fewer than two multipliers, or every mix at zero means no rock field is built at all.
RockCells int
RockMult []float64
LithMix []float64
// Faults is the planet's whole fault set, in world metres. A region filters it to the traces that reach
// into its own frame, which is why it is the planet's and not the region's: a fault crossing a region
// boundary has to be one fault, and two decompositions of the same planet have to produce the same
// escarpment. RunYears is how long the solve runs, which turns a total throw into a rate.
Faults []FaultTrace
RunYears float64
// ClampCeilM is the uplift rate at which a divide reaches the angle of repose, at K x1, in metres a year.
// It bounds what a *fault* may add and nothing else: an author who paints a class past the ceiling gets
// what they asked for and a warning from `terrain plan`, but a fault stacking on top of one is an
// accident nobody chose. Zero switches the bound off.
ClampCeilM float64
// Variation is how far the swell modulates the painted rate, as a fraction. See the note above: this
// is what gives a painted plain its divides, and it is the first thing that will be cut for time.
Variation float64
}
// FromTemplate builds the geology inputs for one region of a painted planet.
//
// It is a sibling of Build rather than a branch inside it. Build's continent mask, percentile range band,
// normalised swell and percentile lithology split are all global operations over the grid they are given,
// and a region is not a world - two regions taking percentiles of their own extents would disagree about
// the same rock. None of them survives here; the paint replaces all four.
func FromTemplate(p Paint, m *manifest.Manifest) *Result {
f := p.Frame
cfg := m.Pipeline
seed := m.Source.Seed
w, h := f.W, f.H
cellM := f.P.CellM
u, v := noise.WorldUV(w, h, cellM, f.OriginXM(), f.OriginYM(), f.P.NoisePeriodM)
// A low-frequency warp, which bends everything built on it so that ridges curve and cells are not
// polygons. Build has one and this did not, which was a porting mistake with a very visible signature:
// Terrain.md records that cellular crest lines without a strong enough warp "turn ranges into a honeycomb
// of polygon walls", and that is exactly what the first painted mountains looked like - flat plates with
// hard edges, at every uplift rate, which is how it was eventually told apart from the repose clamp.
//
// The warp amounts need converting rather than copying. Build works in map coordinates where 0..1 spans
// the map once, so its 0.16 and 0.224 are fractions of a whole map; here 0..1 spans one noise period, and
// what has to be preserved is the warp measured in the *feature's own wavelength*. Build warps the ridges
// by 0.8 of their wavelength (0.16 against BaseCells 5) and the crests by 3.1 of theirs (0.224 against
// BaseCells 14), so those ratios are what carry across.
wx, wy := paintWarp(u, v, seed)
// The regional swell: long-wavelength, so a painted lowland has hills and basins of its own rather than
// one uniform rate across a whole continent. One turn of the planet at BaseCells 4 is a 25 km feature,
// and four octaves take it down to about 3 km.
ss := noise.NewSource(seed, srcPaintSwell)
swu, swv := noise.Warp(u, v, wx, wy, 0.2/swellCells)
swell := noise.FBMAt(swu, swv, ss, noise.Params{BaseCells: swellCells, Octaves: 4, Gain: 0.5})
rate := field.New(w, h, cellM)
k := field.New(w, h, cellM)
land := field.New(w, h, cellM)
base := make([]bool, w*h)
// Distance from every land cell to the nearest water, for the coastal plain. One exact transform over the
// region, computed only when some class asks for it. The region's frame is flat - it is a rectangle cut
// out of the cylinder with water all round it - so this does not wrap, and the water it measures to is
// this region's own coastline: anything else inside the frame is a different landmass, and a different
// landmass is more than a margin away by construction.
var shoreM []float32
if wantsPlain(p.PlainM) {
d2 := dt.Distance2(invert(p.Land), w, h, false)
shoreM = make([]float32, len(d2))
for i, d := range d2 {
shoreM[i] = float32(math.Sqrt(float64(d)) * cellM)
}
}
// The upland fabric, built only when a class asks for one. It is the one field here that is a cut of a
// planet-wide measurement rather than a value read straight off a noise, which is why it lives in
// massif.go with the note on why that measurement cannot be a percentile of the region.
var rank *field.Field
if anyMassif(p.MassifFraction) {
rank = MassifRank(f.P, seed, p.MassifCells, u, v)
}
// The rock field, the same way and for the same reason: a quantile of the planet, never of the region.
var rock *field.Field
if anyMix(p.LithMix) {
rock = RockK(f.P, seed, p.RockCells, p.RockMult, u, v)
}
// And the faults, which are a rate *difference* across a line rather than a field of their own. Built
// once for the planet and filtered to this frame; nil when none of them reaches it.
fault := FaultDelta(f, p.Faults, p.RunYears)
maxRate := 0.0
for _, r := range p.Rates {
if float64(r) > maxRate {
maxRate = float64(r)
}
}
if maxRate <= 0 {
maxRate = 1
}
// The painted class boundary is deliberately not smoothed. The blend rule in Docs/Terrain-Next.md 3.2
// exists because a painted map coarser than the grid reads as blocks; here a paint pixel is 12.9 m
// against an 8 m cell, so there is barely an upsample to soften. And a step in the uplift *rate* is a
// step in steady-state slope, not in height: the solve grades the transition over a hillslope of its own
// accord, which is a better answer than a blur, and blurring would have pulled the sea's zero into the
// coastal cells - the mistake D-52 undid, where the land ending decided how fast it was rising.
// preFault is the rate before any fault touches it: the class rate after the massif cut and the
// coastal-plain ramp. The initial relief is scaled by it rather than by the finished rate, which is
// D-63 and is not a detail - see the amplitude below.
preFault := make([]float32, len(rate.Data))
clamped := 0
for i := range rate.Data {
c0 := p.Class[i]
kk := float64(p.Ks[c0])
if rock != nil && p.LithMix[c0] > 0 {
// The rock field multiplies the class's own erodibility rather than replacing it: `k_mult` is
// what the author said this ground is made of, and the province is the variation within it.
kk *= 1 + p.LithMix[c0]*(float64(rock.Data[i])-1)
}
k.Data[i] = float32(kk)
if !p.Land[i] {
base[i] = true
continue
}
land.Data[i] = 1
c := p.Class[i]
r := float64(p.Rates[c])
if rank != nil && p.MassifFraction[c] > 0 {
// The class rate is the rate a massif reaches; the floor is the plain between them.
r = MassifRate(float64(p.MassifFloor[c]), r, float64(rank.Data[i]), p.MassifFraction[c])
}
if shoreM != nil && p.PlainM[c] > 0 {
// Smoothstep rather than linear, so the plain meets the range without a crease in the slope
// field - a crease there would be a line of channel heads all starting at the same distance
// from the sea, which is the sort of thing that reads as a contour rather than as terrain.
t := float64(shoreM[i]) / p.PlainM[c]
if t > 1 {
t = 1
}
t = t * t * (3 - 2*t)
floor := float64(p.PlainFloor[c])
// Only ever downwards. Before massifs the class rate was uniform and the legend guarantees the
// coastal floor is below it, so this could not fire; a cell of plain between two massifs now
// sits below the coastal floor perfectly legitimately, and ramping it *up* towards the shore
// would put a rim of hills round the edge of every continent.
if r > floor {
r = floor + (r-floor)*t
}
}
asked := r
preFault[i] = float32(asked)
if fault != nil {
// A fault is a difference in rate across a line. It adds on one side and subtracts on the other,
// and the subtraction is what tilts the block rather than merely raising a ridge - so it is
// allowed to take the rate down, but not below zero: subsidence is not modelled.
if r += float64(fault[i]); r < 0 {
r = 0
}
// The only ceiling in the whole painted path, and it binds on faults alone. Past
// U = tan(talus)*K*cell the repose clamp shapes the ground instead of erosion and the surface
// comes out as polygonal facets; an author may choose that for a class, but a fault stacking on
// top of ground that was already near it is nobody's choice. So the bound is the ceiling *or*
// whatever the author's own numbers asked for here, whichever is higher. The count is reported.
if p.ClampCeilM > 0 {
lim := p.ClampCeilM * kk
if lim < asked {
lim = asked
}
if r > lim {
r = lim
clamped++
}
}
}
rate.Data[i] = float32(r * (1 + p.Variation*(2*float64(swell.Data[i])-1)))
}
// Initial relief. The spec says 50-150 m times normalised uplift and means it; this only breaks the
// symmetry so the solve has something to bite on.
rs := noise.NewSource(seed, srcPaintRidges)
ru, rv := noise.Warp(u, v, wx, wy, 0.8/ridgeCells)
ridges := noise.FBMAt(ru, rv, rs, noise.Params{BaseCells: ridgeCells, Octaves: 6, Gain: 0.42, Ridged: true})
cs := noise.NewSource(seed, srcPaintCrests)
cu, cv := noise.Warp(u, v, wx, wy, 3.1/crestCells) // the stronger warp the crest lines need
crests := noise.CellularEdges(cu, cv, cs, int(crestCells), 0.95)
ps := noise.NewSource(seed, srcPaintPlains)
pu, pv := noise.Warp(u, v, wx, wy, 0.5/plainCells)
plains := noise.FBMAt(pu, pv, ps, noise.Params{BaseCells: int(plainCells), Octaves: 4, Gain: 0.45})
ampLo := cfg.Relief.AmplitudeM.Lo()
ampHi := cfg.Relief.AmplitudeM.Hi()
crestW := cfg.Relief.CrestWeight
height := field.New(w, h, cellM)
for i := range height.Data {
if base[i] {
// Ocean sits at sea level for the whole solve and the coastal pass lays the floor afterwards.
// Left at a real depth, a coastal cell drains into it and the solver cuts the river down to meet
// it; the first run with a coast eroded the land to 174 m below sea level for exactly that.
height.Data[i] = float32(m.SeaLevelM)
continue
}
// The amplitude comes from the rate *before* the faults, and is bounded at one (D-63).
//
// It used to come from rate.Data, which is the finished rate with the fault delta in it and no
// upper bound, and that coupling is half of why Bake_018's flanks came out ribbed. The initial
// relief exists only to break the symmetry of the background so the solve has something to bite
// on; how much noise is stamped on a hillside is not a fault's decision. With D-62's six
// kilometre footwalls the ratio went from about 0.18 on unfaulted foreland - 39 m of relief - to
// 1.12 on a footwall, which is 166 m, on a landmass whose whole relief is 221 m. A thousand steps
// cannot erase initial relief the size of the landscape, so the ridged noise stopped being a
// symmetry-breaker and became the terrain: the ribs measure 250-300 m, which is octave five of a
// 4.2 km ridged fBm. The bound at one is a guard rather than the fix - with the fault gone the
// rate cannot exceed the largest class rate - but it is the property worth stating.
norm := float64(preFault[i]) / maxRate
if norm > 1 {
norm = 1
} else if norm < 0 {
norm = 0
}
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, FaultClamped: clamped}
}
// anyMix reports whether any class lets the rock field through.
func anyMix(mix []float64) bool {
for _, v := range mix {
if v > 0 {
return true
}
}
return false
}
func wantsPlain(plain []float64) bool {
for _, v := range plain {
if v > 0 {
return true
}
}
return false
}
func invert(b []bool) []bool {
out := make([]bool, len(b))
for i, v := range b {
out[i] = !v
}
return out
}
@@ -0,0 +1,592 @@
package uplift
import (
"math"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
// Faults on a painted planet: the structure inside a range that the painting cannot draw.
//
// A painted class is one rate over every cell of a colour, and a massif block breaks that into plain and
// upland. Neither can produce the thing a real range has running through it - an escarpment, a block tilted
// against its neighbour, a valley that is straight for fifteen kilometres because it is following a break in
// the rock. That is a fault, and a fault is not a shape to paint: it is a *difference in uplift rate across a
// line*, steep on one side and gentle on the other, which erosion then carves into a scarp. Paint it as
// terrain and the solve erodes it away; apply it as a rate and it maintains itself, which is the same
// argument as "paint the uplift, never the height" one scale down.
//
// The procedural path has had this since the beginning and none of it could be carried over as it stood,
// for two reasons that are by now familiar.
//
// **Placement is in world metres, not map fractions.** `uplift.Build` draws a trace centre as two calls to
// `s.Float()`, which are fractions of the grid it happens to be filling. On a decomposed planet that is a
// different place in every region. Here the whole set is drawn once for the planet, in metres east of the
// seam and metres south of the top painted row, and a region filters it to the traces that reach into its
// own frame - so a fault that crosses a region boundary is one fault, and two different decompositions of the
// same planet produce the same escarpment.
//
// **And a trace is placed in the ground the author painted for it.** A class carries `faults`, so an author
// says *this range is faulted and that plain is not*, which is both the control they want and what is
// actually true of the world: faults belong to orogens. The influence is not restricted to the class,
// because a range-front fault runs along the edge of a range by definition and its scarp faces the lowland.
//
// The four defects recorded against the procedural version in Terrain-Next 4.A2 are fixed here rather than
// carried, because writing a new implementation with a known fault list is cheaper than porting one and
// fixing it afterwards:
//
// - The trace is a walk with a *perturbed heading* rather than one 8-point parabola, so the distance field
// around it has no polygonal contours.
// - The throw is **tapered to zero over the last sixth at each tip** rather than stopping dead where the
// last segment ends, which is what made a fault cut abruptly across a summit.
// - A long fault is broken into overlapping en-echelon segments, which is how long faults actually step.
// - And nothing is clamped to a fraction of a global rate. The procedural version flattens its strongest
// throws with `if r > convergent*1.6`, which turns exactly the faults that matter most into plateaus.
// The ceiling here is the one with a physical meaning - the rate at which a divide reaches the angle of
// repose - and the count of cells that reach it is reported rather than hidden.
const (
srcPaintFaults = 27
srcPaintGrain = 28
)
// The escarpment's shape, in metres, and the reason it is that shape (D-62).
//
// The first version of this made the rate difference a *step*: the whole throw on one side of the trace and
// the whole throw negated on the other, one cell apart, the positive side decaying to nothing over six
// hundred metres and the negative side over six kilometres. Measured on Bake_013 that is a wall of two
// throws across a single 8 m cell, standing in a welt six hundred metres wide. Two things follow, and both
// are visible in a hillshade before any number is taken.
//
// **A step in the rate is a painted cliff.** "Paint the uplift, never the height" is a claim about what a
// solve can undo, and a discontinuity in the rate field is precisely what it cannot: the surface has
// nowhere to put the difference but into a scarp at the angle of repose, so the trace comes out as a
// facetted line at any throw, and turning throw_m down only lowers the same artefact.
//
// **And six hundred metres is narrower than one hillslope.** Bake_013's drainage density is 0.45 channels
// per kilometre, so a divide sits about 1.1 km from its channel. Nothing can dissect a block six hundred
// metres wide - there is no drainage area at that width for stream power to work with, and hillslope
// diffusion only smooths what is already there - so the uplift profile is *printed* onto the surface
// rather than eroded into a landform. That is why every fault in that bake reads as a smooth ruled ridge
// running through terrain dissected everywhere else: it is the one part of the map erosion never touched.
//
// So the profile is antisymmetric, continuous through the trace, and kilometres wide on both flanks.
//
// - faultRampM is how far the rate takes to cross from the hanging wall to the footwall - about one
// hillslope length, which makes the mountain front the sharpest thing this landscape can express
// without it being a cliff nobody solved for.
// - faultFootwallM and faultHangingM are how far each flank reaches. Several hillslope lengths, so a
// drainage network fits on the block and cuts it into spurs and valleys, which is what a range front
// is and what an extruded cross-section will never be.
//
// Neither width scales with the trace's length, and that is deliberate twice over. Physically, the width
// of flexural footwall uplift is set by how the crust bends rather than by the fault in it, so a short
// fault on the same lithosphere makes a *lower* range, not a narrower one - which is what a throw does
// here already. And practically, the floor is the one that matters: a flank has to be wide enough for a
// drainage network whatever the trace is, and scaling it down for the planet's shortest traces - 1.7 km
// on the shipped template against a 6.4 km median - would put the printing artefact straight back on
// exactly those. `length_km` says how far a fault runs along strike; it does not say how wide a belt it
// deforms.
//
// The anomaly is therefore zero *on the trace itself*, which is also the honest reading: a rate difference
// across a line says one side rises relative to the other, and at the line the two average to the regional
// rate. The old profile asserted +throw and -throw at the same point.
const (
faultRampM = 900.0
faultFootwallM = 6000.0
faultHangingM = 4000.0
)
// faultReachM is where the influence is cut off.
//
// The flank envelope reaches zero *with zero gradient* at its own width, so unlike the old
// exponential-minus-a-floor there is nothing to subtract and no step at the box edge to hide: the cut-off
// is the support of the function rather than a truncation of it.
const faultReachM = faultFootwallM
// faultShape is the unnormalised profile at a signed distance from the trace, positive on the upthrown
// side: an odd saturating ramp across the line, times a flank envelope.
//
// The ramp is d/sqrt(R*R+d*d) rather than tanh and the envelope is (1-u*u)^2 rather than an exponential,
// because this runs at every cell of every fault's box - a few hundred million times on a planet - and
// neither transcendental buys anything over the algebraic pair.
func faultShape(d float64) float64 {
w := faultFootwallM
if d < 0 {
w = faultHangingM
}
u := math.Abs(d) / w
if u >= 1 {
return 0
}
e := 1 - u*u
return d / math.Sqrt(faultRampM*faultRampM+d*d) * e * e
}
// faultNorm scales the profile so the whole step across a fault - the footwall crest less the hanging wall
// trough - is exactly the throw the author asked for, which is what the word means: the vertical
// displacement across the fault. The old profile put a full throw on each side and so built two.
//
// Measured over the profile rather than written down, so that changing a width above cannot silently
// change what throw_m means.
var faultNorm = func() float64 {
up, down := 0.0, 0.0
for d := 1.0; d < faultReachM; d++ {
if v := faultShape(d); v > up {
up = v
}
if v := -faultShape(-d); v > down {
down = v
}
}
return up + down
}()
// faultWeight is the escarpment profile, scaled so the crest-to-trough step across it is one throw.
func faultWeight(d float64) float64 { return faultShape(d) / faultNorm }
// faultStackBonus is how much more than its strongest single fault a whole stack of them may build,
// and it is the answer to the defect D-62 caused (D-63).
//
// Faults are rasterised with `+=`, which was harmless while a fault reached six hundred metres: two of
// them almost never met. At six kilometres they meet constantly, and a fault set is *sub-parallel by
// construction* - traces within one cell of the orientation grain share a strike, and a belt fault takes
// its strike from the plate margin - so where they meet they are all pushing the same way. Measured on
// Bake_018's region 11, twenty-two kilometres across with thirteen traces at strikes spanning fourteen
// degrees: **75 % of the faulted ground had two or more faults on it**, the sum was a median 1.77 times
// the largest single contribution there and up to 4.46 times, and the landmass came out at 221 m against
// 75 m for the same ground before D-62. Thirteen per cent of it asked for more uplift than the repose
// ceiling allows on its own, so the hard clamp downstream fired on 160 289 cells - 4.1 % of the region,
// against 0.15 % over the whole planet before - and a hard clamp makes plateaus.
//
// The knee is the **largest single contribution at that cell**, not the largest throw in the set. A
// planet-wide throw would not bite: the biggest throw on this template is 744 m while the biggest single
// contribution anywhere in region 11 is 209 m, because a fault's own taper and falloff have already
// reduced it by the time it reaches anywhere. Keyed per cell, one fault passes through untouched and only
// the stacking is bent.
const faultStackBonus = 0.6
// softStack combines the summed anomaly at a cell with the largest single contribution there.
//
// Below the knee it is the identity, so a cell reached by one fault gets exactly what that fault asked
// for and D-62's calibration - the step across a fault is its throw - is unchanged. Above it the excess
// is bent through a tanh onto an asymptote of (1+faultStackBonus) times the knee, so a belt still stands
// higher than an unfaulted one, which is the point of a belt, but five parallel faults cannot deliver
// five throws. Odd in `sum`, so a stack of hanging walls is bounded on the same terms.
//
// It is continuous: `peak` is a max of continuous functions and the join at the knee has gradient 1 on
// both sides. And it is frame-independent, which is what keeps the decomposition honest - `sum` and
// `peak` at a cell depend only on the faults within reach of it, and a fault too far away to be in a
// frame's box contributes nothing to either.
func softStack(sum, peak float64) float64 {
if peak <= 0 {
return 0
}
a := math.Abs(sum)
if a <= peak {
return sum
}
head := faultStackBonus * peak
v := peak + head*math.Tanh((a-peak)/head)
if sum < 0 {
return -v
}
return v
}
// enEchelonM is the length past which a fault is drawn as overlapping segments instead of one line, and
// enEchelonSpan is how long each segment is as a fraction of the parent.
const (
enEchelonM = 12000.0
enEchelonSpan = 0.55
)
// FaultSpec is what one painted class asks for. A class with no spec has no faults.
type FaultSpec struct {
// Per1000Km2 is how many traces to place in every thousand square kilometres of this class. It is a
// density rather than a count because a class covers whatever an author painted, and a count would mean
// something different on every template.
Per1000Km2 float64
// ThrowM is the whole step across the fault over the run, low to high: the footwall crest less the
// hanging wall trough, which is what the word throw means. It becomes a rate - the solve integrates it
// for `steps * dt_yr` years - so what an author picks is how much higher the upthrown side would stand
// than the downthrown one if erosion never touched either. Before D-62 the profile put a whole throw on
// each flank and so built two of them, and a legend written against that asks for half what it did.
ThrowM [2]float64
// LengthKm is how long a trace is, low to high.
LengthKm [2]float64
}
// Wanted reports whether this spec asks for anything.
func (s FaultSpec) Wanted() bool {
return s.Per1000Km2 > 0 && s.LengthKm[1] > 0 && s.ThrowM[1] > 0
}
// FaultTrace is one fault, in world metres.
//
// X is **unwrapped**: a trace that crosses the seam has X running past the circumference or below zero rather
// than jumping, so that every segment of it is a straight line between neighbouring points and no consumer
// has to special-case the meridian. Whoever draws or tests it wraps by the circumference.
type FaultTrace struct {
PointsM [][2]float64 `json:"points_m"`
ThrowM float64 `json:"throw_m"`
LengthM float64 `json:"length_m"`
Class int `json:"class"`
// Reverse flips which side goes up. Half of them do, drawn from the same stream, because a fault set in
// which every block tilts the same way reads as corduroy.
Reverse bool `json:"reverse"`
}
// BuildFaults draws the planet's whole fault set, once, deterministically from the seed.
//
// candidates[c] is a strided sample of the planet cells belonging to class c: the list a trace centre is
// drawn from, so a fault lands in the ground its class was painted on. Sampled rather than enumerated because
// the full list for a class covering a seventh of a 76-million-cell planet is ten million entries, and the
// only thing asked of it is a uniform draw. areaCells is the class's *exact* cell count, which the projection
// already counted, so the density is not estimated from the sample.
//
// grainKm is the wavelength of the orientation field. Faults within one of its cells come out sub-parallel
// and the set swings gradually across the world, which is what a fault set looks like and what a single
// global strike angle - the procedural path's `grainAngle` - does not: Terrain-Next 4.A3 records that one
// running as straight corduroy across a whole map.
func BuildFaults(p world.Planet, seed int64, grainKm float64, specs []FaultSpec,
candidates [][]int32, areaCells []int) []FaultTrace {
if grainKm <= 0 {
return nil
}
any := false
for _, s := range specs {
any = any || s.Wanted()
}
if !any {
return nil
}
grainCells := int(p.NoisePeriodM/(grainKm*1000) + 0.5)
if grainCells < 1 {
grainCells = 1
}
gs := noise.NewSource(seed, srcPaintGrain)
// Two lattices read as a vector rather than one read as an angle. A value lattice runs 0..1 and an angle
// taken straight from it jumps by a whole turn wherever it crosses its own wrap, which would put a hard
// seam through the fault set along a contour nobody can see. atan2 of two fields is continuous.
gx := noise.NewLattice(grainCells, gs)
gy := noise.NewLattice(grainCells, gs)
// The bend lattice is finer, so a trace curves within the province its strike came from.
bend := noise.NewLattice(grainCells*4, gs)
s := noise.NewSource(seed, srcPaintFaults)
cellArea := p.CellM * p.CellM
var out []FaultTrace
for c := range specs {
spec := specs[c]
if !spec.Wanted() || c >= len(candidates) || len(candidates[c]) == 0 || c >= len(areaCells) {
continue
}
areaKm2 := float64(areaCells[c]) * cellArea / 1e6
want := spec.Per1000Km2 * areaKm2 / 1000
// Stochastic rounding, so a class too small for one whole fault still gets one sometimes and the
// density means what it says when averaged over a world rather than being floored to zero.
n := int(want)
if s.Float() < want-float64(n) {
n++
}
for i := 0; i < n; i++ {
cell := candidates[c][s.IntN(len(candidates[c]))]
cx := float64(int(cell)%p.W) * p.CellM
cy := p.YM(int(cell) / p.W)
lengthM := spec.LengthKm[0] + (spec.LengthKm[1]-spec.LengthKm[0])*s.Float()
lengthM *= 1000
throw := spec.ThrowM[0] + (spec.ThrowM[1]-spec.ThrowM[0])*s.Float()
reverse := s.Float() < 0.5
a := strikeAt(p, gx, gy, grainCells, cx, cy)
out = append(out, traceSet(p, bend, grainCells, s, cx, cy, a, lengthM, throw, reverse, c)...)
}
}
return out
}
// traceSet turns one drawn fault into the one or more traces it is actually made of.
//
// The strike is an argument rather than something this function looks up. Where a fault points is the whole
// difference between the two placements that call it: a class fault takes its angle from a noise grain, and a
// belt fault takes it from the plate boundary it belongs to. Everything below - the en-echelon step, the
// walk, the taper - is the same fault either way.
func traceSet(p world.Planet, bend *noise.Lattice, grainCells int, s *noise.Source,
cx, cy, a, lengthM, throw float64, reverse bool, class int) []FaultTrace {
if lengthM <= enEchelonM {
return []FaultTrace{walkTrace(p, bend, grainCells, cx, cy, a, lengthM, throw, reverse, class)}
}
// A long fault steps. Two or three overlapping segments, each a little over half the parent's length,
// staggered along strike and offset across it - which is what a long fault does in the ground and is also
// the difference between a fifteen-kilometre ruled line and something that reads as structure.
n := 2
if s.Float() < 0.5 {
n = 3
}
segLen := lengthM * enEchelonSpan
out := make([]FaultTrace, 0, n)
for i := 0; i < n; i++ {
// Centres spread along the parent, from -0.5 to +0.5 of its length.
t := (float64(i)/float64(n-1) - 0.5) * (lengthM - segLen)
lateral := (s.Float() - 0.5) * 0.12 * lengthM
sx := cx + math.Cos(a)*t - math.Sin(a)*lateral
sy := cy + math.Sin(a)*t + math.Cos(a)*lateral
out = append(out, walkTrace(p, bend, grainCells, sx, sy, a, segLen, throw, reverse, class))
}
return out
}
// strikeAt is the fault grain's direction at a world position, as an angle.
func strikeAt(p world.Planet, gx, gy *noise.Lattice, cells int, xM, yM float64) float64 {
u := xM / p.NoisePeriodM * float64(cells)
v := yM / p.NoisePeriodM * float64(cells)
return math.Atan2(float64(gy.Sample(u, v))-0.5, float64(gx.Sample(u, v))-0.5)
}
// walkTrace steps outward from the centre in both directions, turning a little each step.
//
// A walk rather than a formula. The procedural path draws one parabola through eight points, and a distance
// field built from eight long straight segments has visibly polygonal contours - which is the first of the
// four things Terrain-Next 4.A2 lists against it. Short steps with a heading that wanders make the same
// gentle curve with none of that.
func walkTrace(p world.Planet, bend *noise.Lattice, grainCells int,
cx, cy, a, lengthM, throw float64, reverse bool, class int) FaultTrace {
segs := int(lengthM / 500)
if segs < 8 {
segs = 8
}
if segs > 64 {
segs = 64
}
if segs%2 == 1 {
segs++ // even, so the centre is a point rather than the middle of a segment
}
step := lengthM / float64(segs)
// The heading turns by at most this much per step. Correlated through the lattice, so consecutive steps
// see nearly the same value and the walk integrates into a smooth arc rather than a jitter.
const maxTurn = 0.12
pts := make([][2]float64, segs+1)
mid := segs / 2
pts[mid] = [2]float64{cx, cy}
for dir := -1; dir <= 1; dir += 2 {
x, y, ang := cx, cy, a
if dir < 0 {
ang += math.Pi
}
for k := 1; k <= mid; k++ {
u := x / p.NoisePeriodM * float64(grainCells*4)
v := y / p.NoisePeriodM * float64(grainCells*4)
ang += (float64(bend.Sample(u, v))*2 - 1) * maxTurn * float64(dir)
x += math.Cos(ang) * step
y += math.Sin(ang) * step
pts[mid+dir*k] = [2]float64{x, y}
}
}
return FaultTrace{PointsM: pts, ThrowM: throw, LengthM: lengthM, Class: class, Reverse: reverse}
}
// FaultDelta is the uplift-rate change the fault set contributes over one frame, in metres a year, or nil
// when none of them reaches it.
//
// Rasterised per fault into its own box rather than per cell over every fault: the set is planet-wide, so a
// per-cell loop over all of it would be the whole planet's faults tested at every cell of every region. A
// fault's box is its trace plus faultReachM on all sides, clipped to the frame, and inside it each cell tests
// only the segments whose own boxes contain it.
func FaultDelta(f world.Frame, faults []FaultTrace, runYears float64) []float32 {
if len(faults) == 0 || runYears <= 0 {
return nil
}
cellM := f.P.CellM
circ := f.P.CircumferenceM()
x0M, y0M := f.OriginXM(), f.OriginYM()
x1M, y1M := x0M+float64(f.W)*cellM, y0M+float64(f.H)*cellM
// peak is the largest single contribution at each cell, which is the knee softStack bends the sum
// over. Carried alongside rather than derived afterwards because a second pass over every fault would
// cost exactly what the first one did.
var out, peak []float32
for fi := range faults {
ft := &faults[fi]
if len(ft.PointsM) < 2 {
continue
}
// Shift the trace by whole turns of the planet so it sits nearest this frame. X is unwrapped in the
// stored trace, so this is the one place the seam is dealt with, once per fault instead of per cell.
pts := shiftToFrame(ft.PointsM, (x0M+x1M)/2, circ)
lo, hi := traceBounds(pts)
if lo[0]-faultReachM > x1M || hi[0]+faultReachM < x0M ||
lo[1]-faultReachM > y1M || hi[1]+faultReachM < y0M {
continue
}
if out == nil {
out = make([]float32, f.W*f.H)
peak = make([]float32, f.W*f.H)
}
cxa := clampInt(int((lo[0]-faultReachM-x0M)/cellM), 0, f.W-1)
cxb := clampInt(int((hi[0]+faultReachM-x0M)/cellM)+1, 0, f.W-1)
cya := clampInt(int((lo[1]-faultReachM-y0M)/cellM), 0, f.H-1)
cyb := clampInt(int((hi[1]+faultReachM-y0M)/cellM)+1, 0, f.H-1)
sign := 1.0
if ft.Reverse {
sign = -1
}
rate := ft.ThrowM / runYears
// One box per segment, computed here rather than inside the cell loop. The rejection below runs for
// every segment at every cell in the fault's box - a few hundred million times on a large region -
// and recomputing four min/max per test was most of what the pass cost.
boxes := segmentBoxes(pts)
for y := cya; y <= cyb; y++ {
py := y0M + float64(y)*cellM
row := y * f.W
for x := cxa; x <= cxb; x++ {
px := x0M + float64(x)*cellM
d, along, ok := nearestOnTrace(px, py, pts, boxes)
if !ok {
continue
}
v := float32(rate * faultWeight(d*sign) * tipTaper(along))
out[row+x] += v
if v < 0 {
v = -v
}
if v > peak[row+x] {
peak[row+x] = v
}
}
}
}
// Bend the stacking. Unconditional whenever anything was rasterised, including when a single trace
// reached this frame: skipping it there would make a cell's value depend on which frame it was asked
// about, which is the one thing FaultDelta is not allowed to do.
for i := range out {
if peak[i] > 0 {
out[i] = float32(softStack(float64(out[i]), float64(peak[i])))
}
}
return out
}
// tipTaper is how much of its throw a fault carries at a fraction along its length.
//
// It was a ramp over the last sixth at each end with a flat top over the middle two thirds. That dies out
// at the tips, which is what it was written for, and leaves the cross-section above *extruded* unchanged
// along two thirds of every trace - which is the other half of why a fault reads as a ruled line. An
// extrusion has no along-strike structure, so erosion has no reason to head a valley in one place rather
// than another and the ridge stays as smooth as the function that drew it.
//
// A real fault carries most of its displacement near the middle and none at either tip, in a profile
// somewhere between elliptical and a linear taper. This is that: an ellipse in q, lifted by q*(2-q) so the
// middle four fifths keeps a body rather than coming to a point. Polynomial on purpose - it is evaluated
// at every cell of every fault's box.
func tipTaper(along float64) float64 {
if along <= 0 || along >= 1 {
return 0
}
q := 4 * along * (1 - along)
return q * (2 - q)
}
// nearestOnTrace is the signed perpendicular distance to a polyline and how far along it the nearest point
// sits, 0 at one tip and 1 at the other. ok is false beyond the ends, where a fault has no effect.
func nearestOnTrace(px, py float64, pts [][2]float64, boxes [][4]float64) (dist, along float64, ok bool) {
best := math.Inf(1)
sign := 1.0
at := 0.0
n := len(pts) - 1
for j := 0; j < n; j++ {
// Cheap rejection first: this loop runs for every cell in the fault's box, and on most of them every
// segment misses.
b := &boxes[j]
if px < b[0] || px > b[2] || py < b[1] || py > b[3] {
continue
}
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-9 {
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
}
projx, projy := ax+t*dx, ay+t*dy
d := math.Hypot(px-projx, py-projy)
if d < best {
best = d
at = (float64(j) + t) / float64(n)
if (px-ax)*dy-(py-ay)*dx < 0 {
sign = -1
} else {
sign = 1
}
}
}
if math.IsInf(best, 1) {
return 0, 0, false
}
return best * sign, at, true
}
// segmentBoxes is each segment's own box, grown by the reach: the rejection test in nearestOnTrace.
func segmentBoxes(pts [][2]float64) [][4]float64 {
out := make([][4]float64, len(pts)-1)
for j := range out {
ax, ay := pts[j][0], pts[j][1]
bx, by := pts[j+1][0], pts[j+1][1]
out[j] = [4]float64{
math.Min(ax, bx) - faultReachM, math.Min(ay, by) - faultReachM,
math.Max(ax, bx) + faultReachM, math.Max(ay, by) + faultReachM,
}
}
return out
}
// shiftToFrame moves a trace by whole circumferences so its middle is nearest a given longitude.
func shiftToFrame(pts [][2]float64, centreXM, circ float64) [][2]float64 {
mid := pts[len(pts)/2][0]
k := math.Round((centreXM - mid) / circ)
if k == 0 {
return pts
}
out := make([][2]float64, len(pts))
for i, p := range pts {
out[i] = [2]float64{p[0] + k*circ, p[1]}
}
return out
}
func traceBounds(pts [][2]float64) (lo, hi [2]float64) {
lo = [2]float64{math.Inf(1), math.Inf(1)}
hi = [2]float64{math.Inf(-1), math.Inf(-1)}
for _, p := range pts {
lo[0] = math.Min(lo[0], p[0])
lo[1] = math.Min(lo[1], p[1])
hi[0] = math.Max(hi[0], p[0])
hi[1] = math.Max(hi[1], p[1])
}
return lo, hi
}
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
@@ -0,0 +1,345 @@
package uplift
import (
"math"
"testing"
"salty/terrain/internal/world"
)
// oneClassCandidates is a planet where every painted cell belongs to class 0, sampled at a stride.
func oneClassCandidates(p world.Planet, stride int) ([][]int32, []int) {
var cells []int32
for y := 0; y < p.H; y += stride {
for x := 0; x < p.W; x += stride {
cells = append(cells, int32(y*p.W+x))
}
}
return [][]int32{cells}, []int{p.W * p.H}
}
func testSpec() []FaultSpec {
return []FaultSpec{{Per1000Km2: 40, ThrowM: [2]float64{200, 400}, LengthKm: [2]float64{4, 8}}}
}
// A seed names a fault set, and the same seed names the same one. Everything else here rests on that.
func TestTheSameSeedDrawsTheSameFaults(t *testing.T) {
const w, h, cellM = 1024, 512, 64.0
p := testPlanet(t, w, h, cellM)
cand, area := oneClassCandidates(p, 8)
a := BuildFaults(p, 7, 32, testSpec(), cand, area)
b := BuildFaults(p, 7, 32, testSpec(), cand, area)
if len(a) == 0 {
t.Fatal("no faults were drawn; this test measured nothing")
}
if len(a) != len(b) {
t.Fatalf("two runs of one seed drew %d and %d traces", len(a), len(b))
}
for i := range a {
if a[i].ThrowM != b[i].ThrowM || a[i].Reverse != b[i].Reverse ||
len(a[i].PointsM) != len(b[i].PointsM) || a[i].PointsM[0] != b[i].PointsM[0] {
t.Fatalf("trace %d differs between two runs of one seed", i)
}
}
// And a different seed is a different world.
if c := BuildFaults(p, 9342, 32, testSpec(), cand, area); len(c) > 0 && c[0].PointsM[0] == a[0].PointsM[0] {
t.Error("a different seed put the first trace in the same place")
}
}
// The density means what it says: traces per thousand square kilometres of the class, not per map.
func TestFaultDensityIsPerAreaOfTheClass(t *testing.T) {
const cellM = 64.0
small := testPlanet(t, 512, 256, cellM)
big := testPlanet(t, 1024, 512, cellM)
count := func(p world.Planet) int {
cand, area := oneClassCandidates(p, 8)
return len(BuildFaults(p, 7, 32, testSpec(), cand, area))
}
ns, nb := count(small), count(big)
if ns == 0 {
t.Fatal("the small planet drew nothing; this test measured nothing")
}
// Four times the area, so about four times the traces. Loose, because a long fault becomes two or three
// en-echelon segments and the draw is stochastic - the assertion is that it scales, not that it is exact.
if ratio := float64(nb) / float64(ns); ratio < 2.5 || ratio > 6 {
t.Errorf("four times the area gave %d traces against %d, a ratio of %.1f", nb, ns, ratio)
}
}
// The property the whole port exists for: a fault is the planet's, not a region's. Two frames overlapping the
// same ground have to agree about the rate it contributes, and both have to agree with the whole planet.
//
// `uplift.Build` places a trace at two calls to Float() read as fractions of the grid it is filling, so the
// obvious port of it fails this - the same fault would land somewhere different in every region.
func TestTwoFramesAgreeAboutTheSameFaults(t *testing.T) {
const w, h, cellM = 1024, 512, 64.0
p := testPlanet(t, w, h, cellM)
cand, area := oneClassCandidates(p, 8)
faults := BuildFaults(p, 7, 32, testSpec(), cand, area)
if len(faults) == 0 {
t.Fatal("no faults; this test measured nothing")
}
const runYears = 1.5e6
whole := FaultDelta(world.Whole(p), faults, runYears)
a := world.Frame{P: p, X0: 200, Y0: 100, W: 300, H: 200}
b := world.Frame{P: p, X0: 380, Y0: 160, W: 300, H: 200}
da, db := FaultDelta(a, faults, runYears), FaultDelta(b, faults, runYears)
if da == nil || db == nil {
t.Fatal("neither frame was reached by any fault; move the windows")
}
checked, nonZero := 0, 0
for y := 0; y < a.H; y++ {
for x := 0; x < a.W; x++ {
px, py := a.PlanetXY(x, y)
if px < b.X0 || px >= b.X0+b.W || py < b.Y0 || py >= b.Y0+b.H {
continue
}
got := da[y*a.W+x]
if want := db[(py-b.Y0)*b.W+(px-b.X0)]; got != want {
t.Fatalf("at planet (%d,%d) frame A says %v and frame B says %v", px, py, got, want)
}
if wh := whole[py*p.W+px]; wh != got {
t.Fatalf("at planet (%d,%d) a frame says %v and the whole planet says %v", px, py, got, wh)
}
checked++
if got != 0 {
nonZero++
}
}
}
if checked == 0 {
t.Fatal("the two frames do not overlap")
}
if nonZero == 0 {
t.Fatal("every cell of the overlap is zero; the agreement is vacuous")
}
}
// X wraps, so a fault whose trace runs past the meridian has to reach the ground on the other side of it.
// The trace is stored unwrapped and shifted once per fault; this is what says that shift works.
func TestAFaultReachesAcrossTheSeam(t *testing.T) {
const w, h, cellM = 512, 256, 64.0
p := testPlanet(t, w, h, cellM)
circ := p.CircumferenceM()
// A trace lying just east of the seam, running north-south, well inside the reach of the map's west edge.
x := 300.0
trace := FaultTrace{
PointsM: [][2]float64{{x, 2000}, {x, 5000}, {x, 8000}},
ThrowM: 400, LengthM: 6000, Reverse: false,
}
west := world.Frame{P: p, X0: 0, Y0: 0, W: 40, H: h}
if d := FaultDelta(west, []FaultTrace{trace}, 1.5e6); d == nil {
t.Fatal("a trace 300 m east of the seam did not reach a frame at the seam")
}
// The same trace written with its X a whole world further east is the same fault, so a frame at the far
// end of the map must see the identical field.
shifted := FaultTrace{
PointsM: [][2]float64{{x + circ, 2000}, {x + circ, 5000}, {x + circ, 8000}},
ThrowM: 400, LengthM: 6000,
}
near := FaultDelta(west, []FaultTrace{trace}, 1.5e6)
far := FaultDelta(west, []FaultTrace{shifted}, 1.5e6)
if far == nil {
t.Fatal("the shifted trace reached nothing; the wrap is not being applied")
}
for i := range near {
if near[i] != far[i] {
t.Fatalf("a trace and the same trace one circumference east differ at cell %d: %v vs %v",
i, near[i], far[i])
}
}
}
// The scarp is asymmetric, which is what makes a fault a tilted block rather than a ridge: it rises fast on
// one side over a couple of hundred metres and falls away slowly on the other over a couple of kilometres.
func TestAFaultIsATiltedBlockAndNotARidge(t *testing.T) {
const w, h, cellM = 1024, 1024, 32.0
p := testPlanet(t, w, h, cellM)
mid := float64(h) * cellM / 2
// A straight east-west trace across the middle.
pts := make([][2]float64, 9)
for i := range pts {
pts[i] = [2]float64{float64(i) * float64(w) * cellM / 8, mid}
}
d := FaultDelta(world.Whole(p), []FaultTrace{{PointsM: pts, ThrowM: 400, LengthM: float64(w) * cellM}}, 1.5e6)
if d == nil {
t.Fatal("the trace reached nothing")
}
col := w / 2
at := func(yM float64) float64 { return float64(d[int(yM/cellM)*w+col]) }
// One side is positive and the other negative: the block tilts.
up, down := at(mid-1400), at(mid+1200)
if up*down >= 0 {
t.Fatalf("both sides of the trace have the same sign (%v, %v); that is a ridge, not a fault", up, down)
}
// And the footwall reaches further than the hanging wall.
if math.Abs(at(mid-4800)) <= math.Abs(at(mid+4800)) {
t.Errorf("at 4800 m the footwall is %v and the hanging wall %v; the asymmetry is the wrong way "+
"round or absent", math.Abs(at(mid-4800)), math.Abs(at(mid+4800)))
}
}
// The defect this file's shape block is about (D-62), as two numbers rather than a hillshade.
//
// The profile used to put the whole throw on one side of the trace and the whole throw negated on the
// other, one cell apart, inside a welt six hundred metres wide. That is unsolvable twice over: a step in
// the rate field is a cliff the erosion can only clamp at the angle of repose, and a block narrower than
// one hillslope has no drainage area on it for stream power to cut with, so the profile is printed onto
// the surface instead of being eroded into a landform.
//
// So: continuous through the trace, and wide enough on the upthrown side for a drainage network to live
// on. 1100 m is the hillslope length measured on Bake_013 (a drainage density of 0.45 channels per km),
// and three of them is the least that can carry a valley and its two divides.
func TestAFaultIsSolvableRatherThanPrinted(t *testing.T) {
const w, h, cellM = 2048, 2048, 8.0
const throw, runYears = 400.0, 1.5e6
p := testPlanet(t, w, h, cellM)
mid := float64(h) * cellM / 2
pts := make([][2]float64, 17)
for i := range pts {
pts[i] = [2]float64{float64(i) * float64(w) * cellM / 16, mid}
}
d := FaultDelta(world.Whole(p), []FaultTrace{{PointsM: pts, ThrowM: throw, LengthM: float64(w) * cellM}}, runYears)
if d == nil {
t.Fatal("the trace reached nothing")
}
col := w / 2
// Metres of displacement the rate builds over the whole run, which is what the surface has to carry.
at := func(yM float64) float64 { return float64(d[int(yM/cellM)*w+col]) * runYears }
var crest, trough, crestAt, troughAt, steepest, steepestAt float64
prev := at(mid - faultReachM)
for dy := -faultReachM + cellM; dy <= faultReachM; dy += cellM {
v := at(mid + dy)
if v > crest {
crest, crestAt = v, dy
}
if v < trough {
trough, troughAt = v, dy
}
if g := math.Abs(v-prev) / cellM; g > steepest {
steepest, steepestAt = g, dy
}
prev = v
}
// Continuous: no cell-to-cell step steeper than ground the solve can actually shape. The repose clamp
// is at 35 degrees and the old profile measured 89.
if deg := math.Atan(steepest) * 180 / math.Pi; deg > 25 {
t.Errorf("the steepest cell-to-cell step in the rate field is %.1f degrees at %+.0f m; that is a "+
"cliff in the uplift, and the solve can only clamp it at the angle of repose", deg, steepestAt)
}
// Zero on the trace itself: a rate difference across a line averages to the regional rate at the line.
if v := math.Abs(at(mid)); v > throw/50 {
t.Errorf("the anomaly on the trace is %.1f m; it should be nothing", v)
}
// Wide enough to be dissected: the upthrown flank has to carry a drainage network.
const hillslopeM = 1100
var above float64
for dy := 0.0; dy <= faultReachM; dy += cellM {
if at(mid-dy) >= crest/2 {
above = dy
}
}
if above < 3*hillslopeM {
t.Errorf("the footwall stands above half its crest for only %.0f m, under three hillslope lengths "+
"(%d m); nothing can cut a valley into it and the profile will print", above, 3*hillslopeM)
}
// And the step across the fault is the throw the author asked for, not two of them.
if step := crest - trough; math.Abs(step-throw) > throw/20 {
t.Errorf("the step across the fault is %.0f m against a throw of %.0f m", step, throw)
}
t.Logf("crest %+.0f m at %+.0f m, trough %+.0f m at %+.0f m, step %.0f m over %.0f m (%.1f deg mean), "+
"steepest cell %.1f deg, footwall above half-crest for %.0f m",
crest, crestAt, trough, troughAt, crest-trough, crestAt-troughAt,
math.Atan((crest-trough)/math.Abs(crestAt-troughAt))*180/math.Pi,
math.Atan(steepest)*180/math.Pi, above)
}
// A fault dies out along strike instead of stopping dead, which is what left an abrupt cut across a summit on
// the procedural path - and it is never flat along strike either, which is what left it extruded.
func TestTheThrowTapersToNothingAtTheTips(t *testing.T) {
if tipTaper(0) != 0 || tipTaper(1) != 0 {
t.Errorf("the tips carry no throw: got %v and %v", tipTaper(0), tipTaper(1))
}
if tipTaper(0.5) != 1 {
t.Errorf("the middle carries all of it: got %v", tipTaper(0.5))
}
// Monotone to the middle, so the ramp has no step in it.
prev := 0.0
for a := 0.0; a <= 0.5; a += 0.01 {
v := tipTaper(a)
if v < prev-1e-12 {
t.Fatalf("the taper goes backwards at %v: %v after %v", a, v, prev)
}
prev = v
}
// Symmetric about the middle.
for _, a := range []float64{0.05, 0.2, 0.37} {
if math.Abs(tipTaper(a)-tipTaper(1-a)) > 1e-12 {
t.Errorf("the two ends differ at %v: %v against %v", a, tipTaper(a), tipTaper(1-a))
}
}
// Nowhere flat: the old taper held exactly 1 across the middle two thirds, which extrudes the
// cross-section along most of every trace. Nothing between the tips and the centre may repeat.
if tipTaper(0.2) >= tipTaper(0.35) || tipTaper(0.35) >= tipTaper(0.5) {
t.Errorf("the throw is flat along strike: %v, %v, %v at a fifth, a third and the middle",
tipTaper(0.2), tipTaper(0.35), tipTaper(0.5))
}
// But it still has a body: most of a trace carries at least half its throw.
above := 0
for i := 0; i <= 1000; i++ {
if tipTaper(float64(i)/1000) >= 0.5 {
above++
}
}
if above < 750 {
t.Errorf("only %d parts in a thousand of the trace carry half the throw; the fault is a spike", above)
}
}
// A long fault steps rather than running as one ruled line.
func TestALongFaultBreaksIntoEnEchelonSegments(t *testing.T) {
const w, h, cellM = 2048, 1024, 64.0
p := testPlanet(t, w, h, cellM)
cand, area := oneClassCandidates(p, 8)
short := []FaultSpec{{Per1000Km2: 40, ThrowM: [2]float64{200, 400}, LengthKm: [2]float64{4, 6}}}
long := []FaultSpec{{Per1000Km2: 40, ThrowM: [2]float64{200, 400}, LengthKm: [2]float64{20, 26}}}
ns := len(BuildFaults(p, 7, 32, short, cand, area))
nl := len(BuildFaults(p, 7, 32, long, cand, area))
if ns == 0 {
t.Fatal("nothing was drawn; this test measured nothing")
}
if nl <= ns {
t.Errorf("faults over the en-echelon length gave %d traces against %d for short ones; they are not "+
"stepping", nl, ns)
}
}
// Nothing is drawn when nothing asks, and nothing is rasterised when no trace reaches a frame - which is what
// keeps a planet with no faults paying nothing for the pass.
func TestNoFaultsCostsNothing(t *testing.T) {
const w, h, cellM = 256, 128, 64.0
p := testPlanet(t, w, h, cellM)
cand, area := oneClassCandidates(p, 8)
if got := BuildFaults(p, 7, 32, []FaultSpec{{}}, cand, area); got != nil {
t.Errorf("an empty spec drew %d traces", len(got))
}
if got := BuildFaults(p, 7, 0, testSpec(), cand, area); got != nil {
t.Error("a zero grain wavelength should draw nothing")
}
far := FaultTrace{PointsM: [][2]float64{{0, 100000}, {1000, 100000}}, ThrowM: 400}
if d := FaultDelta(world.Whole(p), []FaultTrace{far}, 1.5e6); d != nil {
t.Error("a trace far off the frame should allocate no field at all")
}
}
@@ -0,0 +1,117 @@
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]
}
@@ -0,0 +1,162 @@
package uplift
import (
"math"
"testing"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
var testMult = []float64{0.6, 1.0, 1.8}
func rockIn(p world.Planet, f world.Frame, cells int) []float32 {
u, v := noise.WorldUV(f.W, f.H, p.CellM, f.OriginXM(), f.OriginYM(), p.NoisePeriodM)
return RockK(p, 7, cells, testMult, u, v).Data
}
// The rule the whole painted path is built on, applied to the rock field: a threshold on a decomposed planet
// has to be a quantile of the *planet*. Two regions taking percentiles of their own extents would put the
// same physical hillside in different rock, and the boundary between them would be a wall the solver carves.
//
// It is the same negative control TestTwoFramesAgreeAboutTheSameGround is for the upland fabric, and it is
// here rather than assumed because `uplift.Build`'s lithology does take a percentile of its own grid - so the
// obvious port of it would fail this and nothing else would have noticed.
func TestTwoFramesAgreeAboutTheSameRock(t *testing.T) {
const w, h, cellM = 2048, 512, 64.0
p := testPlanet(t, w, h, cellM)
whole := rockIn(p, world.Whole(p), 8)
a := world.Frame{P: p, X0: 400, Y0: 80, W: 240, H: 160}
b := world.Frame{P: p, X0: 520, Y0: 120, W: 240, H: 160}
ra, rb := rockIn(p, a, 8), rockIn(p, b, 8)
checked := 0
for y := 0; y < a.H; y++ {
for x := 0; x < a.W; x++ {
px, py := a.PlanetXY(x, y)
if px < b.X0 || px >= b.X0+b.W || py < b.Y0 || py >= b.Y0+b.H {
continue
}
got := ra[y*a.W+x]
if want := rb[(py-b.Y0)*b.W+(px-b.X0)]; got != want {
t.Fatalf("at planet (%d,%d) frame A says %v and frame B says %v", px, py, got, want)
}
if wh := whole[py*p.W+px]; wh != got {
t.Fatalf("at planet (%d,%d) a frame says %v and the whole planet says %v", px, py, got, wh)
}
checked++
}
}
if checked == 0 {
t.Fatal("the two frames do not overlap; this test measured nothing")
}
}
// Every rock type has to appear, whatever the seed did to the noise. Equal-area bands are what the procedural
// path got out of a percentile and the reason it is worth keeping: a seed that happened to produce no hard
// rock anywhere would be a seed that quietly removed a process.
func TestRockTypesComeOutInEqualShares(t *testing.T) {
const w, h, cellM = 2048, 512, 64.0
p := testPlanet(t, w, h, cellM)
data := rockIn(p, world.Whole(p), 8)
count := map[float64]int{}
for _, v := range data {
// Only the flat interior of a band counts: the edges are deliberately blended, so a cell there is
// between two types and belongs to neither.
for _, m := range testMult {
if math.Abs(float64(v)-m) < 1e-4 {
count[m]++
}
}
}
total := 0
for _, n := range count {
total += n
}
if total < len(data)/2 {
t.Fatalf("only %d of %d cells are in the flat middle of a band; the blend is eating the field",
total, len(data))
}
for _, m := range testMult {
share := float64(count[m]) / float64(total)
if share < 0.2 || share > 0.47 {
t.Errorf("rock type %v is %.1f%% of the land; three equal bands should each be about a third",
m, 100*share)
}
}
}
// The softening is pointwise, in rank space, and it has to be: a blur is a neighbourhood operation and a
// neighbourhood operation near a region's edge reads cells a different decomposition would not have given it.
// What the test asserts is the consequence - the field is continuous, so a rock boundary is a transition and
// not a wall - measured as the largest step between neighbouring cells.
//
// The geometry has to be the real one to mean anything. What decides how wide a boundary comes out *in cells*
// is the wavelength divided by the cell size: the real planet is a 9 km province on an 8 m cell, about eleven
// hundred cells across one, so a blend of a twentieth of the rank falls over tens of cells. A coarse test grid
// compresses the same blend into three or four and would fail a threshold the real run passes comfortably,
// which is a test measuring its own resolution rather than the code.
func TestRockBoundariesAreGradedRatherThanWalls(t *testing.T) {
const w, h, cellM, cells = 4096, 64, 8.0, 4
p := testPlanet(t, w, h, cellM)
d := rockIn(p, world.Whole(p), cells)
if perWave := w / cells; perWave < 512 {
t.Fatalf("%d cells across a province; too coarse to say anything about the real grid", perWave)
}
// The largest gap between neighbouring rock types, which is what a wall would look like.
gap := 0.0
for i := 1; i < len(testMult); i++ {
gap = math.Max(gap, math.Abs(testMult[i]-testMult[i-1]))
}
worst := 0.0
for y := 0; y < h; y++ {
for x := 0; x+1 < w; x++ {
worst = math.Max(worst, math.Abs(float64(d[y*w+x+1]-d[y*w+x])))
}
}
if worst > gap/8 {
t.Errorf("the largest step between neighbouring cells is %.4f against a %.2f gap between types; "+
"the bands are walls, not transitions", worst, gap)
}
if worst == 0 {
t.Fatal("the field is flat; this test measured nothing")
}
}
// bandValue is the pointwise part on its own: continuous, and exactly half-way at a boundary from either side.
func TestBandValueIsContinuousAcrossABoundary(t *testing.T) {
n := len(testMult)
below := bandValue(1/float64(n)-1e-9, testMult, n)
above := bandValue(1/float64(n)+1e-9, testMult, n)
want := (testMult[0] + testMult[1]) / 2
if math.Abs(below-want) > 1e-6 || math.Abs(above-want) > 1e-6 {
t.Errorf("at the first boundary: below %v, above %v, want %v from both sides", below, above, want)
}
if got := bandValue(0.5/float64(n), testMult, n); got != testMult[0] {
t.Errorf("the middle of the first band should be the type itself: got %v want %v", got, testMult[0])
}
// The ends clamp rather than running off.
if got := bandValue(0, testMult, n); got != testMult[0] {
t.Errorf("rank 0 is the first type, got %v", got)
}
if got := bandValue(1, testMult, n); got != testMult[n-1] {
t.Errorf("rank 1 is the last type, got %v", got)
}
}
// Nothing is built when nothing asks for it, which is what a planet with no lithology_wavelength_km gets.
func TestNoRockFieldWhenNoneIsAskedFor(t *testing.T) {
const w, h, cellM = 256, 128, 64.0
p := testPlanet(t, w, h, cellM)
f := world.Whole(p)
u, v := noise.WorldUV(f.W, f.H, cellM, 0, 0, p.NoisePeriodM)
if RockK(p, 7, 0, testMult, u, v) != nil {
t.Error("zero cells should build no field")
}
if RockK(p, 7, 8, []float64{1.0}, u, v) != nil {
t.Error("one rock type is no lithology at all")
}
}
@@ -0,0 +1,186 @@
package uplift
import (
"math"
"testing"
"salty/terrain/internal/manifest"
"salty/terrain/internal/world"
)
// The defect D-62 caused and D-63 answers: a sub-parallel fault set stacked.
//
// Widening a fault's reach from 600 m to 6 km made overlap the ordinary case rather than a rarity, and a
// fault set is sub-parallel by construction - traces within one cell of the orientation grain share a
// strike. On Bake_018's region 11, 75 % of the faulted ground had two or more faults on it and the sum was
// a median 1.77 and up to 4.46 times the largest single contribution, which took the landmass from 75 m to
// 221 m and fired the repose clamp on 4.1 % of it.
//
// Five parallel traces two kilometres apart is that in miniature: at a 6 km footwall every one of them
// reaches every other, so an unbounded sum would be several throws deep.
func TestParallelFaultsDoNotStack(t *testing.T) {
const w, h, cellM = 512, 1600, 16.0
const throw, runYears = 300.0, 1.5e6
p := testPlanet(t, w, h, cellM)
f := world.Whole(p)
trace := func(yM float64) FaultTrace {
pts := make([][2]float64, 17)
for i := range pts {
pts[i] = [2]float64{float64(i) * float64(w) * cellM / 16, yM}
}
return FaultTrace{PointsM: pts, ThrowM: throw, LengthM: float64(w) * cellM}
}
midM := float64(h) * cellM / 2
var many []FaultTrace
for i := -2; i <= 2; i++ {
many = append(many, trace(midM+float64(i)*2000))
}
one := []FaultTrace{trace(midM)}
peakOf := func(faults []FaultTrace) (hi, lo float64) {
d := FaultDelta(f, faults, runYears)
if d == nil {
t.Fatal("the traces reached nothing")
}
col := w / 2
for y := 0; y < h; y++ {
v := float64(d[y*w+col]) * runYears
hi = math.Max(hi, v)
lo = math.Min(lo, v)
}
return hi, lo
}
hi1, lo1 := peakOf(one)
hiN, loN := peakOf(many)
// A belt still stands higher than one fault does, which is the point of a belt.
if hiN <= hi1 {
t.Errorf("five faults build %.0f m against one fault's %.0f m; the stack is suppressed entirely",
hiN, hi1)
}
// But not five times higher. The asymptote is 1+faultStackBonus times the largest single contribution
// at a cell, and the crest of the stack sits near the crest of its strongest member.
limit := (1 + faultStackBonus) * 1.05
if hiN > hi1*limit {
t.Errorf("five parallel faults build %.0f m against one fault's %.0f m, a factor of %.2f; the "+
"bound is %.2f", hiN, hi1, hiN/hi1, limit)
}
// The hanging walls are bounded on the same terms, or a fault set digs a hole instead of building one.
if loN < lo1*limit {
t.Errorf("five parallel faults drop %.0f m against one fault's %.0f m, a factor of %.2f",
loN, lo1, loN/lo1)
}
t.Logf("one fault %+.0f/%+.0f m, five at 2 km spacing %+.0f/%+.0f m (x%.2f)",
hi1, lo1, hiN, loN, hiN/hi1)
// And it is still solvable ground: bending the stack must not put a step back into the rate field.
d := FaultDelta(f, many, runYears)
var steepest float64
col := w / 2
for y := 1; y < h; y++ {
g := math.Abs(float64(d[y*w+col]-d[(y-1)*w+col])) * runYears / cellM
steepest = math.Max(steepest, g)
}
if deg := math.Atan(steepest) * 180 / math.Pi; deg > 25 {
t.Errorf("the steepest cell-to-cell step across the stack is %.1f degrees", deg)
}
}
// One fault is untouched by the bound, so D-62's calibration still holds: the step across a fault is the
// throw its author asked for. softStack is the identity below its knee and the knee is the largest single
// contribution, so this is the property that makes the two changes compose rather than fight.
func TestOneFaultIsNotBentByTheStackBound(t *testing.T) {
const w, h, cellM = 512, 1600, 16.0
const throw, runYears = 300.0, 1.5e6
p := testPlanet(t, w, h, cellM)
f := world.Whole(p)
midM := float64(h) * cellM / 2
pts := make([][2]float64, 17)
for i := range pts {
pts[i] = [2]float64{float64(i) * float64(w) * cellM / 16, midM}
}
d := FaultDelta(f, []FaultTrace{{PointsM: pts, ThrowM: throw, LengthM: float64(w) * cellM}}, runYears)
if d == nil {
t.Fatal("the trace reached nothing")
}
col := w / 2
var hi, lo float64
for y := 0; y < h; y++ {
v := float64(d[y*w+col]) * runYears
hi = math.Max(hi, v)
lo = math.Min(lo, v)
}
if step := hi - lo; math.Abs(step-throw) > throw/20 {
t.Errorf("the step across a lone fault is %.0f m against a throw of %.0f m; the stack bound is "+
"biting on a single fault", step, throw)
}
}
// The initial relief is scaled by the rate *before* the faults (D-63).
//
// It used to be scaled by the finished rate with the fault delta in it and no upper bound, so D-62's six
// kilometre footwalls raised the stamped noise from about 39 m to about 166 m on a landmass whose whole
// relief was 221 m - and a thousand steps cannot erase initial relief the size of the landscape, so the
// ridged fBm stopped breaking the symmetry and became the flank texture instead. The initial relief exists
// to give the solve something to bite on; how much noise sits on a hillside is not a fault's decision.
func TestTheInitialReliefIgnoresFaults(t *testing.T) {
const w, h, cellM = 256, 256, 64.0
p := testPlanet(t, w, h, cellM)
f := world.Whole(p)
class := make([]uint8, w*h)
land := make([]bool, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
if x >= 16 {
class[y*w+x], land[y*w+x] = 1, true
}
}
}
midM := float64(h) * cellM / 2
pts := make([][2]float64, 17)
for i := range pts {
pts[i] = [2]float64{float64(i) * float64(w) * cellM / 16, midM}
}
faults := []FaultTrace{{PointsM: pts, ThrowM: 400, LengthM: float64(w) * cellM}}
m := manifest.Defaults()
m.Source.Seed = 7
build := func(fs []FaultTrace) *Result {
return FromTemplate(Paint{
Frame: f, Class: class, Land: land,
Rates: []float32{0, 0.00025}, // 0.25 mm/yr, the shipped highland
Ks: []float32{1, 1},
Faults: fs,
RunYears: 1.5e6,
Variation: 0,
// The repose bound off, so the fault delta survives into the rate and the difference this
// test is looking for is actually there to find.
ClampCeilM: 0,
}, m)
}
with, without := build(faults), build(nil)
differs := 0
for i := range with.Rate.Data {
if with.Rate.Data[i] != without.Rate.Data[i] {
differs++
}
}
if differs == 0 {
t.Fatal("the fault changed no rate at all; this test measured nothing")
}
for i := range with.Height.Data {
if with.Height.Data[i] != without.Height.Data[i] {
t.Fatalf("the initial relief at cell %d is %.3f m with faults and %.3f m without, over %d "+
"cells whose rate the fault changed; the amplitude is still reading the finished rate",
i, with.Height.Data[i], without.Height.Data[i], differs)
}
}
t.Logf("the fault moved %d of %d rates and no initial relief at all", differs, len(with.Rate.Data))
}
@@ -0,0 +1,111 @@
package uplift
import (
"math"
"testing"
"salty/terrain/internal/manifest"
"salty/terrain/internal/world"
)
// For n = 1 the uplift rate alone fixes the hillslope angle, so a uniformly painted island sits at the angle
// of repose right down to the water. The coastal plain is what puts the range inland: the rate ramps from a
// low value at the shore up to the class rate over a stated distance.
//
// The distinction from D-52, which removed a coastal taper, is that this one is asked for and does not go to
// zero - the waterline keeps a real rate, so the strip the surf works in is not flattened.
func TestTheCoastalPlainRampsInlandAndNeverToZero(t *testing.T) {
// A 64 m cell so that a four-kilometre plain is 62 cells rather than 500: the grid has to be comfortably
// wider than the thing being measured, which the first version of this test was not.
const w, h, cellM = 256, 32, 64.0
p := world.Planet{CellM: cellM, W: w, H: h, PadY: 0, NoisePeriodM: float64(w) * cellM}
if err := p.Validate(); err != nil {
t.Fatal(err)
}
f := world.Whole(p)
class := make([]uint8, w*h)
land := make([]bool, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
// Land from x = 20 rightwards, so distance to the shore is x - 20 cells.
if x >= 20 {
class[y*w+x], land[y*w+x] = 1, true
}
}
}
const rate = 0.0009 // 0.9 mm/yr
const floor = 0.00006 // 0.06 mm/yr
const plainM = 4000.0 // 4 km
m := manifest.Defaults()
m.Source.Seed = 7
up := FromTemplate(Paint{
Frame: f, Class: class, Land: land,
Rates: []float32{0, rate},
Ks: []float32{1, 1},
PlainM: []float64{0, plainM},
PlainFloor: []float32{0, floor},
Variation: 0, // the swell off, so the ramp is the only thing being measured
}, m)
y := h / 2
at := func(cellsInland int) float64 { return float64(up.Rate.Data[y*w+20+cellsInland]) }
// Four kilometres is 62.5 cells at 64 m.
const full = 63
// The first land cell is one cell from the water, not zero, so it has already climbed a sliver of the
// ramp. A couple of per cent is that sliver; anything more would mean the ramp is not anchored at the
// floor where it should be.
if got := at(0); math.Abs(got-floor) > 0.02*floor {
t.Errorf("at the waterline the rate is %.5f mm/yr, want the floor %.3f", got*1000, floor*1000)
}
if at(0) <= 0 {
t.Error("the rate at the waterline is zero; that is D-52's mistake, not this feature")
}
if got := at(full); math.Abs(got-rate) > 1e-7 {
t.Errorf("at 4 km inland the rate is %.4f mm/yr, want the class rate %.2f", got*1000, rate*1000)
}
if got := at(150); math.Abs(got-rate) > 1e-7 {
t.Errorf("well inland the rate is %.4f mm/yr, want the class rate %.2f", got*1000, rate*1000)
}
// Monotonic in between, and genuinely below the class rate at the halfway mark.
prev := 0.0
for d := 0; d <= full; d++ {
v := at(d)
if v < prev-1e-12 {
t.Fatalf("the ramp dips at %d cells inland", d)
}
prev = v
}
if got := at(full / 2); got >= rate*0.9 {
t.Errorf("halfway across the plain the rate is already %.3f mm/yr of %.2f; that is not a plain",
got*1000, rate*1000)
}
}
// Without the knob nothing changes, which is what keeps every existing world and every existing test where it
// was.
func TestNoCoastalPlainMeansTheRateReachesTheSea(t *testing.T) {
const w, h = 64, 32
p := world.Planet{CellM: 8, W: w, H: h, PadY: 0, NoisePeriodM: float64(w) * 8}
f := world.Whole(p)
class := make([]uint8, w*h)
land := make([]bool, w*h)
for i := range land {
if i%w >= 8 {
class[i], land[i] = 1, true
}
}
m := manifest.Defaults()
up := FromTemplate(Paint{
Frame: f, Class: class, Land: land,
Rates: []float32{0, 0.0009}, Ks: []float32{1, 1},
PlainM: []float64{0, 0}, PlainFloor: []float32{0, 0}, Variation: 0,
}, m)
y := h / 2
if got := float64(up.Rate.Data[y*w+8]); math.Abs(got-0.0009) > 1e-9 {
t.Errorf("the first land cell is at %.4f mm/yr, want the full 0.9", got*1000)
}
}
+5
View File
@@ -75,6 +75,11 @@ type Result struct {
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.