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