// Package coast is what happens where the land meets the sea. // // Until this pass existed the coastline was only a *line*: the continent mask said which cells were ocean, // the fluvial solve held those cells at sea level as its base level, and afterwards the sea floor was dropped // to a flat plane 180 m down in one step. That is enough to give the solve a well-posed boundary — which is // why D-48 kept the continent — and it is not a coast. There was no shelf, so a third of the map was a flat // plane occupying a third of the elevation range; there was no surf, so the land met the water at whatever // angle the last erosion step happened to leave it; and there was no sediment, so every bay was as deep as // every headland was steep. // // # The three things this pass adds, and why each is a process rather than a shape // // 1. The shelf. A continental margin is a shelf at a very gentle grade out to a shelf break and then a much // steeper slope to the abyssal floor. The width of the shelf is not a constant: it is wide off a low // coastal plain and narrow where a range comes down to the water. So it is read off the relief standing // behind each stretch of shore rather than set, and the same manifest numbers then produce a wide shelf // on a passive coast and a narrow one on an active coast without either having been asked for. // // 2. The surf. Within a reach of the waterline the land is planed towards a shore platform. The reach is set // by how open the water is, so an exposed headland is attacked further inland than the back of a bay. The // cliff is not drawn: it is the step where the reach ends, and its height is whatever the land behind it // happened to stand at. That is the right way round — a sea cliff is tall because the land is tall, not // because a constant says so. // // 3. The sediment. What the surf cuts is counted, carried along the shore, and laid down in sheltered water // shallower than a few tens of metres: beaches and bars in the bays, nothing on the headlands. Rivers // deliver their own load at their mouths in proportion to what they drain, which is what makes a delta. // Mass is conserved to within the drift kernel's edges, and what will not fit under the berm is reported // rather than quietly dropped. // // # Why it runs after the solve and not before // // Two of the three need the finished terrain: the shelf width is a function of the relief behind the shore, // and the surf cuts into whatever the solve built. The third could run before but would then be erased. So // this pass owns the sea floor outright — uplift.Build no longer produces a bathymetry field — and it is the // last thing that touches the geology grid. // // The one invariant it must not break: the sea floor is laid after the solve, never during it. A coastal cell // drains into an ocean cell, and if that ocean cell sits at -180 m then the solver cuts the river down to // -180 m; the first run with a coast eroded the land to 174 m below sea level for exactly that reason. A // river's base level is sea level, and what the sea floor does below that is scenery. package coast import ( "fmt" "math" "sort" "salty/terrain/internal/field" "salty/terrain/internal/manifest" "salty/terrain/internal/noise" ) // srcShelf is this pass's noise stream. Pass indices are fixed and never reordered so that inserting a pass // does not reshuffle the ones before it; uplift owns 1 to 9, so the coast starts at 10. const srcShelf = 10 // backshoreM is how far inland the shelf looks for the relief that decides its width. Not a manifest key: it // is the length over which "the land behind this beach" means anything, and 600 m is one hillside. const backshoreM = 600 // The two anchors of the fetch scale, in fractions of the fetch range the seaward rays travelled. They are // properties of how fetch is measured rather than of a world, which is why they are constants and not keys: // a coast whose seaward rays nearly all run to the horizon is open however big the map is. const ( shelteredFetch = 0.35 openFetch = 0.90 ) // shelfSmoothM is how far the carried shelf width is smoothed along the coast. See shelfWidth. const shelfSmoothM = 700 // exposureSmoothM is how far the carried fetch is smoothed along the coast. Shorter than the shelf's, because // exposure is only read within a few hundred metres of the water and smoothing it over more than the reach of // the processes that use it would flatten the very contrast it exists to provide. const exposureSmoothM = 250 // shelterFloor is how much sediment the most exposed water will still take. // // Shelter cannot be a gate. Measured on the real continent with no floor, 73 % of the sediment budget came // back unplaced, because the seaward rays from an ordinary stretch of coast nearly all run to the horizon and // it therefore scores as fully exposed — and a fully exposed coast with no floor wants nothing at all. Real // exposed coasts do have beaches; what they do not have is *more* sand than the bay next door. So the floor // keeps the contrast, which is the part that matters, and stops the budget falling on the floor. const shelterFloor = 0.15 // platformResidualCapM bounds what CutFraction may leave standing on the shore platform. // // CutFraction below 1 exists so the platform is not glass, and the obvious reading — leave that fraction of // the height above the target — is wrong in a way that only shows on a tall coast: 15 % of a 120 m headland is // 18 m, which is not a rough platform, it is an uncut headland. The residual is therefore a few metres at // most, whatever the coast behind it stands at. const platformResidualCapM = 4 // Input is everything the pass needs. Height is modified in place. type Input struct { Height *field.Field Sea []bool // the continent mask's ocean: the cells the solve held at base level SeaLevelM float64 // the base level the solve used, and the datum every depth here is measured from BreakM float64 // depth at the shelf break, positive metres // AbyssM is how deep the open ocean is, in positive metres, and Abyss is the same thing per cell when a // world has one. A painted planet does: its sea classes carry their own `depth_m`, so the ocean is // already laid at several depths before this pass runs, and a derived shelf that bottomed out at one // global abyss would put a step at the shelf break wherever the two disagreed. Nil falls back to AbyssM, // which is what the square canvas has and what every caller had before. AbyssM float64 Abyss []float32 // WrapX says the grid is a cylinder: column W-1 and column 0 are neighbours. A planet is measured once, // whole, so every march, every ray and every running sum in this pass has to cross the seam - the // alternative is a shelf, a fetch and a sediment budget that all stop dead at one meridian. WrapX bool // NoisePeriodM is how far the sea-floor roughness runs before it repeats. It has to divide the // circumference exactly on a cylinder or the noise breaks at the seam like every other field; zero means // the flat-grid default, which is a multiple of the roughness wavelength and repeats wherever it likes // because a flat grid has no seam to break. NoisePeriodM float64 Flow []float32 Seed int64 Cfg manifest.Coast } // abyssAt is how deep the open ocean is at one cell. func (in Input) abyssAt(i int) float64 { if in.Abyss != nil { return float64(in.Abyss[i]) } return in.AbyssM } // col brings a column index onto the grid: wrapped on a cylinder, refused past the edge of a flat one. func (g *Geometry) col(x int) (int, bool) { if g.WrapX { return ((x % g.W) + g.W) % g.W, true } if x < 0 || x >= g.W { return 0, false } return x, true } // distAt reads the signed distance field with X wrapped on a cylinder and clamped otherwise. Y always clamps, // because the top and bottom of the map are the poles and not each other. func (g *Geometry) distAt(x, y int) float64 { if g.WrapX { x = ((x % g.W) + g.W) % g.W } return float64(g.Dist.AtClamped(x, y)) } // Result is the geometry the pass built and the accounting it kept. type Result struct { Geometry *Geometry Exposure *field.Field // 0 sheltered, 1 open water; defined on every cell through Geometry.Ref Change *field.Field // metres this pass moved: negative where the surf cut, positive where it laid Sea []bool // the mask as it now stands: everything strictly below sea level Stats Stats } // Stats is the pass's own report. The volumes are the point: the sediment budget is the one part of this that // is not derived from something already measured, so it is printed rather than assumed. type Stats struct { ShorelineKm float64 `json:"shoreline_km"` SeaFraction float64 `json:"sea_fraction"` ShelfPctSea float64 `json:"shelf_pct_of_sea"` CutM3 float64 `json:"surf_cut_m3"` RiverM3 float64 `json:"river_load_m3"` LaidM3 float64 `json:"laid_m3"` UnplacedM3 float64 `json:"unplaced_m3"` RiverMouths int `json:"river_mouths"` PlanedKm2 float64 `json:"planed_km2"` BeachKm2 float64 `json:"beach_km2"` DrownedKm2 float64 `json:"drowned_km2"` // How high the land stands immediately behind the surf strip, which is the cliff when there is one. // // The first version of this measured the drop from a cell to its seaward neighbour and called that the // cliff. That is a gradient, not a height: at the angle of repose one cell of a 10 m grid is 7 m, so the // number could not exceed 7 whatever the coast did, and it read 2 m on a plain coast and 3 m on a coast // with genuine cliffs on it. A cliff is how far you fall, not how steep the first cell is. BackshoreM float64 `json:"backshore_m"` BackshoreP90M float64 `json:"backshore_p90_m"` // Exposure percentiles over the waterline cells, which is where it is measured and the only place it // means anything. A coast that is all 1.0 has no bays as far as the fetch can tell, and then neither the // surf reach nor the shelter is doing any work; a coast that is all 0 means the anchors are wrong. This // is the diagnostic to read before touching either. ExposureP10 float64 `json:"exposure_p10"` ExposureP50 float64 `json:"exposure_p50"` ExposureP90 float64 `json:"exposure_p90"` } func (s Stats) Summary() string { return fmt.Sprintf( "coast: %.0f km of shoreline, %.0f%% sea, shelf %.0f%% of it; surf planed %.1f km2 and cut %.2f Mm3,\n"+ " %d river mouths delivered %.2f Mm3, %.2f Mm3 laid (%.0f%% unplaced) as %.2f km2 of new beach;\n"+ " backshore %.0f m median, %.0f m P90, %.2f km2 drowned; exposure %.2f / %.2f / %.2f (p10/p50/p90)", s.ShorelineKm, s.SeaFraction*100, s.ShelfPctSea, s.PlanedKm2, s.CutM3/1e6, s.RiverMouths, s.RiverM3/1e6, s.LaidM3/1e6, pct(s.UnplacedM3, s.CutM3+s.RiverM3), s.BeachKm2, s.BackshoreM, s.BackshoreP90M, s.DrownedKm2, s.ExposureP10, s.ExposureP50, s.ExposureP90) } func pct(a, b float64) float64 { if b <= 0 { return 0 } return a / b * 100 } // Build lays the sea floor, cuts the shore and moves what it cuts. Height is modified in place. func Build(in Input) *Result { h := in.Height w, ht := h.W, h.H cellArea := h.CellM * h.CellM g := MeasureWrapped(in.Sea, w, ht, h.CellM, in.WrapX) res := &Result{Geometry: g, Exposure: field.NewLike(h), Change: field.NewLike(h)} // Disabled, or a map with no coast on it: the sea floor is the flat plane at the abyssal depth, which is // what the generator produced before this pass existed. Everything below is skipped. if !in.Cfg.Enabled || len(g.Waterline) == 0 { for i := range in.Sea { if in.Sea[i] { h.Data[i] = float32(in.SeaLevelM - in.abyssAt(i)) } } copy(res.Change.Data, h.Data) res.finish(in) return res } shelfW := shelfWidth(h, g, in) layShelf(h, g, in, shelfW) // The before-and-after is taken here, after the sea floor and before the two shore processes. Taken any // earlier it would be a map of the sea floor: the ocean cells go from sea level to -180 m in one step, and // a few hundred metres of that swamps the few metres the surf and the sediment move, which is the thing // the map exists to show. // The "before" snapshot and the change map are the same array. Change is h minus before, so the snapshot // is taken *into* the field that will hold the answer and subtracted from in place at the end - one field // of 304 MB at planet scale rather than two, for a picture. copy(res.Change.Data, h.Data) shoreExposure := fetch(g, in) res.Stats.ExposureP10, res.Stats.ExposureP50, res.Stats.ExposureP90 = shorePercentiles(shoreExposure) carried := field.NewLike(h) for i, ref := range g.Ref { if ref >= 0 { carried.Data[i] = shoreExposure[ref] } } // Smoothed for the same reason the shelf width is: carrying a per-shore value by "the stretch nearest to // you" partitions the map into Voronoi wedges, and a wedge boundary inside the deposition band would put // a straight edge through a beach. res.Exposure = boxMean(carried, int(exposureSmoothM/h.CellM+0.5), 2, g.WrapX) cut := plane(h, g, res.Exposure, in) // The scatter into the supply array is serial and in index order on purpose: several land cells share a // waterline cell, so a parallel loop would be accumulating into the same slot from several goroutines and // the float sum would depend on who got there first. Cross-cutting rule 12 is not negotiable here, and // one linear pass over the grid costs nothing next to the solve. // One entry per *waterline cell*, not per grid cell. There are a few hundred thousand of the first and // tens of millions of the second, and this used to be the second: 608 MB at planet scale for an array // that is only ever read at the shore. See Geometry.Ref. supply := make([]float64, len(g.Waterline)) var cutM3, planedCells float64 for i, c := range cut.Data { if c <= 0 { continue } ref := g.Ref[i] if ref < 0 { continue // no shore to credit it to; cannot happen for a cell the surf reached, but cheap to say } v := float64(c) * cellArea supply[ref] += v cutM3 += v planedCells++ } backshore, backshoreP90 := measureBackshore(h, g, in) riverM3, mouths := rivers(g, in, supply) laid, unplaced := deposit(h, g, res.Exposure, in, supply) res.Stats.CutM3 = cutM3 res.Stats.RiverM3 = riverM3 res.Stats.RiverMouths = mouths res.Stats.LaidM3 = laid res.Stats.UnplacedM3 = unplaced res.Stats.PlanedKm2 = planedCells * cellArea / 1e6 res.Stats.BackshoreM = backshore res.Stats.BackshoreP90M = backshoreP90 res.Stats.ShelfPctSea = shelfFraction(g, in, shelfW) res.finish(in) return res } // finish computes the mask the rest of the run should use, and the before-and-after difference. // // The mask is "strictly below sea level", not the continent mask it started from, and that is the point: a // beach the pass built out of cliff debris is land, and a low headland it planed under the waterline is not. // The statistics and the preview both ask what is above sea level, so they get an answer about the terrain // rather than about the mask that seeded it. func (r *Result) finish(in Input) { h := in.Height r.Sea = make([]bool, len(h.Data)) sea, beach, drowned := 0, 0, 0 for i := range h.Data { // Change came in holding the *before* heights; it leaves holding the difference. r.Change.Data[i] = h.Data[i] - r.Change.Data[i] r.Sea[i] = float64(h.Data[i]) < in.SeaLevelM if r.Sea[i] { sea++ if !in.Sea[i] { drowned++ } } else if in.Sea[i] { beach++ } } n := float64(len(h.Data)) cellArea := h.CellM * h.CellM r.Stats.SeaFraction = float64(sea) / n r.Stats.BeachKm2 = float64(beach) * cellArea / 1e6 r.Stats.DrownedKm2 = float64(drowned) * cellArea / 1e6 r.Stats.ShorelineKm = r.Geometry.ShoreM / 1000 } // shelfWidth is metres of shelf for every cell: measured on the waterline from the relief standing behind it, // carried out to sea by the nearest-shore reference, and then smoothed. // // The inland direction comes from the gradient of the signed distance field rather than from the eight-way // step to the nearest land cell: the distance field is smooth, so the march does not stagger along the grid // axes and the widths do not come out banded. // // The smoothing is not cosmetic. Carrying a per-shore quantity out to sea by "the stretch nearest to you" // partitions the ocean into Voronoi wedges, and a wedge boundary is a discontinuity that runs for kilometres: // the first render of the change map came out as a sunburst of straight rays radiating from every headland, // which is a map of the feature transform rather than of a sea floor. Blurring the carried field over a few // hundred metres turns the wedge boundaries back into what they should have been, a shelf whose width varies // smoothly along the coast. func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field { out := make([]float32, len(g.Waterline)) steps := int(backshoreM/h.CellM + 0.5) lo := in.Cfg.ShelfKm.Lo() * 1000 hi := in.Cfg.ShelfKm.Hi() * 1000 steep := in.Cfg.SteepCoastM if steep <= 0 { steep = 1 } field.Rows(len(g.Waterline), func(a, b int) { for n := a; n < b; n++ { i := int(g.Waterline[n]) x, y := i%g.W, i/g.W dx := g.distAt(x+1, y) - g.distAt(x-1, y) dy := g.distAt(x, y+1) - g.distAt(x, y-1) l := math.Hypot(dx, dy) if l < 1e-6 { dx, dy, l = 1, 0, 1 } dx, dy = dx/l, dy/l var relief float64 for t := 1; t <= steps; t++ { px, ok := g.col(x + int(math.Round(dx*float64(t)))) py := y + int(math.Round(dy*float64(t))) if !ok || py < 0 || py >= g.H { break } if e := float64(h.Data[py*g.W+px]) - in.SeaLevelM; e > relief { relief = e } } t := relief / steep if t > 1 { t = 1 } out[n] = float32(hi + (lo-hi)*noise.Smoothstep(t)) } }) carried := field.NewLike(h) for i, ref := range g.Ref { if ref >= 0 { carried.Data[i] = out[ref] } else { carried.Data[i] = float32(hi) } } return boxMean(carried, int(shelfSmoothM/h.CellM+0.5), 2, g.WrapX) } // layShelf writes the sea floor: a gentle shelf out to the break, then the continental slope to the abyss. // // The roughness is scaled by depth so it dies out at the waterline. Without that it puts metre-scale noise on // water a few centimetres deep and the shallows come out as a scatter of one-cell islands, which then read as // land in every statistic downstream. func layShelf(h *field.Field, g *Geometry, in Input, shelfW *field.Field) { cfg := in.Cfg // The lattice has to come back to itself at the seam, so on a cylinder the period is the planet's and not // a multiple of the roughness wavelength. Without it the sea floor gains a metre-scale discontinuity down // one meridian - small, and exactly the kind of thing nobody finds by looking at the middle of the map. period := cfg.RoughWaveM * 256 if in.NoisePeriodM > 0 { period = in.NoisePeriodM } cells := 256 if in.NoisePeriodM > 0 && cfg.RoughWaveM > 0 { cells = int(period/cfg.RoughWaveM + 0.5) if cells < 1 { cells = 1 } } rough := shelfRoughness(g, in, period, cells) exp := cfg.ShelfExponent if exp <= 0 { exp = 1 } slopeW := cfg.SlopeKm * 1000 if slopeW <= 0 { slopeW = 1 } field.Rows(g.H, func(y0, y1 int) { for y := y0; y < y1; y++ { for x := 0; x < g.W; x++ { i := y*g.W + x if !in.Sea[i] { continue } d := -float64(g.Dist.Data[i]) // metres offshore width := float64(shelfW.Data[i]) if width <= 0 { width = cfg.ShelfKm.Hi() * 1000 } // The open-ocean depth at *this* cell, so the derived slope arrives exactly where the ocean // already is rather than at one global number it may be hundreds of metres from. And the // break cannot be deeper than the water it is a break in: painted shallows - a 20 m surf // class against a 30 m break - are shelf all the way out, with no slope to run down. abyss := in.abyssAt(i) brk := in.BreakM if abyss < brk { brk = abyss } var depth float64 if d < width { depth = brk * math.Pow(d/width, exp) } else { t := (d - width) / slopeW if t > 1 { t = 1 } depth = brk + (abyss-brk)*noise.Smoothstep(t) } taper := depth / 10 if taper > 1 { taper = 1 } r := (float64(rough.Data[i])*2 - 1) * cfg.RoughnessM * taper h.Data[i] = float32(in.SeaLevelM - depth + r) } } }) } // shelfRoughness is the noise on the sea floor, built in row bands. // // In bands because at planet scale the two coordinate fields and the result are three arrays of 76 million // floats - 900 MB for a field whose amplitude is ten metres. The lattices are rebuilt from the same seeded // source for every band, so the bands agree exactly where they meet; that is the same trick, for the same // reason, as internal/planet's ocean roughness. func shelfRoughness(g *Geometry, in Input, period float64, cells int) *field.Field { out := field.New(g.W, g.H, g.CellM) const bandRows = 512 params := noise.Params{BaseCells: cells, Octaves: 3, Gain: 0.5} for y0 := 0; y0 < g.H; y0 += bandRows { y1 := y0 + bandRows if y1 > g.H { y1 = g.H } u, v := noise.WorldUV(g.W, y1-y0, g.CellM, 0, float64(y0)*g.CellM, period) band := noise.FBMAt(u, v, noise.NewSource(in.Seed, srcShelf), params) copy(out.Data[y0*g.W:y1*g.W], band.Data) } return out } // fetch is how open the water is in front of each waterline cell: rays cast seaward until they hit land, // weighted by the cosine of their angle from the shore normal, and averaged. // // Two things about that sentence are the whole of it, and the first version got both wrong. // // **Only seaward.** Casting in every direction counts the land *behind* the shore as shelter, and every coast // has land behind it — so a straight open coast, where seven rays in sixteen stop after one cell, scored as // more sheltered than the back of a bay whose walls are half a kilometre off. Restricting to the half-space // the shore faces, weighted by the cosine of the angle from the normal, is the standard effective fetch and it // gets the sign right: open coast near 1, embayment well below it, enclosed inlet near 0. // // **Absolute, not a percentile.** The first version stretched the map's own 5th to 95th percentile onto 0..1, // which is robust and which collapses to nonsense on a coast that does not vary — a perfectly straight one has // no spread, so every cell of it came out at the same end of the scale and the whole continent read as one // sheltered lagoon. A percentile is also a global statistic, which rule 1 of the tiling plan rules out: two // tiles would stretch by different anchors and their shared bay would be two different colours. So the // anchors are fixed and physical, and the units are "fraction of the fetch range the rays got". func fetch(g *Geometry, in Input) []float32 { out := make([]float32, len(g.Waterline)) dirs := in.Cfg.FetchDirections if dirs < 4 { dirs = 4 } maxSteps := int(in.Cfg.FetchRangeM / g.CellM) if maxSteps < 2 { maxSteps = 2 } cs := make([]float64, dirs) sn := make([]float64, dirs) for k := 0; k < dirs; k++ { th := 2 * math.Pi * float64(k) / float64(dirs) cs[k], sn[k] = math.Cos(th), math.Sin(th) } field.Rows(len(g.Waterline), func(a, b int) { for n := a; n < b; n++ { i := int(g.Waterline[n]) x0, y0 := i%g.W, i/g.W // The seaward normal: the distance field increases inland, so its gradient points away from the // water and the negative of it is the direction this stretch of shore faces. nx := -(g.distAt(x0+1, y0) - g.distAt(x0-1, y0)) ny := -(g.distAt(x0, y0+1) - g.distAt(x0, y0-1)) if l := math.Hypot(nx, ny); l > 1e-6 { nx, ny = nx/l, ny/l } else { nx, ny = 0, 0 // no usable normal: fall back to the whole circle } var num, den float64 for k := 0; k < dirs; k++ { w := cs[k]*nx + sn[k]*ny if nx == 0 && ny == 0 { w = 1 } else if w <= 0 { continue } reach := maxSteps for t := 1; t <= maxSteps; t++ { px, ok := g.col(x0 + int(math.Round(cs[k]*float64(t)))) py := y0 + int(math.Round(sn[k]*float64(t))) if !ok || py < 0 || py >= g.H { // Off the map is open water, and the mask keeps the border at sea. On a cylinder a // ray never runs off in X at all - it comes round - so this is the poles, where the // synthetic polar ocean is genuinely open. break } if !in.Sea[py*g.W+px] { reach = t break } } num += w * float64(reach) / float64(maxSteps) den += w } raw := 1.0 if den > 0 { raw = num / den } t := (raw - shelteredFetch) / (openFetch - shelteredFetch) if t < 0 { t = 0 } else if t > 1 { t = 1 } out[n] = float32(noise.Smoothstep(t)) } }) return out } // shorePercentiles reports the fetch distribution over the waterline itself, before it is carried anywhere. func shorePercentiles(shore []float32) (p10, p50, p90 float64) { if len(shore) == 0 { return 0, 0, 0 } vals := make([]float64, 0, len(shore)) for _, v := range shore { vals = append(vals, float64(v)) } sort.Float64s(vals) at := func(f float64) float64 { i := int(float64(len(vals)-1) * f) return vals[i] } return at(0.10), at(0.50), at(0.90) } // plane cuts the shore platform and returns how much it took off each cell, in metres. // // The shape is deliberate. Within the reach the land is planed nearly all the way to the platform, and only // over the last quarter of the reach is the cut rolled off — so the profile is a gentle platform, then a short // steep face, then untouched land. That face is the cliff. Rolling the cut off over the whole reach instead // would give a ramp, which is what a coast looks like when someone has smoothed it rather than eroded it. func plane(h *field.Field, g *Geometry, exposure *field.Field, in Input) *field.Field { cut := field.NewLike(h) cfg := in.Cfg field.Rows(g.H, func(y0, y1 int) { for y := y0; y < y1; y++ { for x := 0; x < g.W; x++ { i := y*g.W + x if in.Sea[i] { continue } d := float64(g.Dist.Data[i]) e := float64(exposure.Data[i]) reach := cfg.SurfReachM * (0.35 + 0.65*e) if reach <= 0 || d >= reach { continue } target := in.SeaLevelM + cfg.PlatformGrade*d above := float64(h.Data[i]) - target if above <= 0 { continue } w := 1.0 if tail := reach * 0.25; d > reach-tail { w = noise.Smoothstep((reach - d) / tail) } residual := above * (1 - cfg.CutFraction) if residual > platformResidualCapM { residual = platformResidualCapM } c := (above - residual) * w h.Data[i] -= float32(c) cut.Data[i] = float32(c) } } }) return cut } // measureBackshore is how high the land stands immediately behind the surf strip: between one and two surf // reaches inland, so it is clear of everything the surf planed whatever the exposure there was. // // The median says what the ordinary coast is — a plain, on this continent, and it should be — and the P90 is // the number that answers "are there sea cliffs anywhere on this map", which a median never can when most of a // coastline is lowland. func measureBackshore(h *field.Field, g *Geometry, in Input) (median, p90 float64) { lo := in.Cfg.SurfReachM hi := lo * 2 vals := make([]float64, 0, 4096) for i := range in.Sea { if in.Sea[i] { continue } if d := float64(g.Dist.Data[i]); d < lo || d > hi { continue } vals = append(vals, float64(h.Data[i])-in.SeaLevelM) } if len(vals) == 0 { return 0, 0 } sort.Float64s(vals) return vals[len(vals)/2], vals[int(float64(len(vals)-1)*0.9)] } // rivers adds each river mouth's load to the sediment supply. The load scales with what the river drains, // sub-linearly, because the alternative is one trunk basin delivering more than every other mouth together. // // Only cells orthogonally against the water count as a mouth, so a channel contributes once or twice rather // than along its whole lower course. func rivers(g *Geometry, in Input, supply []float64) (total float64, mouths int) { if in.Flow == nil || in.Cfg.RiverM3PerKm2 <= 0 { return 0, 0 } threshold := in.Cfg.RiverChannelKm2 * 1e6 for i := range in.Sea { if in.Sea[i] || float64(g.Dist.Data[i]) > g.CellM*1.01 { continue } a := float64(in.Flow[i]) if a < threshold { continue } v := in.Cfg.RiverM3PerKm2 * math.Pow(a/1e6, in.Cfg.RiverExponent) supply[g.Ref[i]] += v total += v mouths++ } return total, mouths } // deposit carries the supply along the shore and lays it in sheltered shallow water. // // The transport is a box-kernel spread over DriftM, which is longshore drift at the only fidelity this grid // can carry: it moves sediment out of the place it was cut and into the bays either side of it, and it does // not pretend to know which way the waves run. // // # The order of the two operations, which is the whole of the mass balance // // Each source cell divides what it has among the cells around it in proportion to how much each wants it. // Writing K for the kernel and w for the want, cell i receives // // dep_i = w_i * sum_j K(i,j) * sup_j / Wbar_j, Wbar_j = sum_k K(j,k) w_k // // which sums to exactly sum_j sup_j, because summing over i turns the inner weight back into Wbar_j. In code // that is: divide the supply by the blurred want *first*, then blur, then multiply by the want. // // The obvious-looking alternative — blur the supply, then scale it by w_i / Wbar_i — is not the same thing and // does not conserve. It was what this function did first, and it lost 68 % of the budget: the blur spreads // supply onto land, onto deep water and onto exposed headlands, every one of which has w = 0 and is skipped, // so everything that landed there was silently dropped. The test that caught it is an accounting identity, not // a picture, which is the only kind of test that could have. // // The cap is the berm: nothing is laid more than BermM above sea level, because a beach crests and stops. What // will not fit is offered once more to whatever still has room, and whatever is left after that is reported // rather than dropped. func deposit(h *field.Field, g *Geometry, exposure *field.Field, in Input, supply []float64) (laid, unplaced float64) { cfg := in.Cfg cellArea := h.CellM * h.CellM radius := int(cfg.DriftM/h.CellM + 0.5) if radius < 1 { radius = 1 } // want is how much each cell of shallow, sheltered water will take. It is the only place sediment may go. want := field.NewLike(h) for i := range h.Data { if !in.Sea[i] { continue } if d := -float64(g.Dist.Data[i]); d > cfg.DepositReachM { continue } depth := in.SeaLevelM - float64(h.Data[i]) if depth <= 0 || depth >= cfg.DepositDepthM { continue } shallow := (cfg.DepositDepthM - depth) / cfg.DepositDepthM // Clamped, and not defensively. `ShelterBias` is fractional, so `math.Pow` of a negative base is NaN // - and one NaN here spreads through the drift kernel into every cell of the budget and comes out as // a laid volume of NaN with no other symptom. Exposure is a smoothed field, so it is 0..1 only to // within the rounding of however it was smoothed; relying on the smoother to bound it is relying on // an invariant a hundred lines away. Found when the coverage became separable and the divisor changed // from float32 to float64: the ratio went over 1 by five parts in a hundred thousand, and 1720 cells // of a 200x40 test came out NaN. e := float64(exposure.Data[i]) if e < 0 { e = 0 } else if e > 1 { e = 1 } shelter := shelterFloor + (1-shelterFloor)*math.Pow(1-e, cfg.ShelterBias) want.Data[i] = float32(shelter * shallow) } norm := boxBlur(want, radius, 3, g.WrapX) // want is still needed below; norm and share are not, past the loops that read them. Dropping the // references is what lets the collector reclaim 304 MB apiece at planet scale before the next one is // allocated, rather than after. // The supply is per waterline cell and the blur works on a grid, so it is scattered back onto the cells // its stretches of shore sit at. Distinct slots are distinct cells, so nothing collides. share := field.NewLike(h) for slot, v := range supply { if v <= 0 { continue } i := int(g.Waterline[slot]) nb := float64(norm.Data[i]) if nb < 1e-9 { unplaced += v // nowhere within a drift length will take it continue } share.Data[i] = float32(v / nb) } spread := boxBlur(share, radius, 3, g.WrapX) share, norm = nil, nil // place walks the grid in index order, which keeps the running totals deterministic: the writes are to // distinct cells but the sums are not, so this one stays serial. place := func(source *field.Field, scaled bool) (placed, over float64) { for i := range h.Data { w := float64(want.Data[i]) if w <= 0 { continue } v := float64(source.Data[i]) if scaled { v *= w } if v <= 0 { continue } room := in.SeaLevelM + cfg.BermM - float64(h.Data[i]) if room <= 0 { over += v continue } dz := v / cellArea if dz > room { over += (dz - room) * cellArea dz = room } h.Data[i] += float32(dz) placed += dz * cellArea } return placed, over } laid, over := place(spread, true) // One more round for what would not fit, spread over the whole shore in proportion to want rather than // locally: by this point the material has already been carried as far as the model knows how to carry it. if over > 1 { var wsum float64 for i := range h.Data { if want.Data[i] > 0 && float64(h.Data[i]) < in.SeaLevelM+cfg.BermM { wsum += float64(want.Data[i]) } } if wsum > 0 { second := field.NewLike(h) for i := range h.Data { if want.Data[i] > 0 && float64(h.Data[i]) < in.SeaLevelM+cfg.BermM { second.Data[i] = float32(over * float64(want.Data[i]) / wsum) } } more, still := place(second, false) laid += more over = still } } return laid, unplaced + over } // shelfFraction is how much of the sea is shallower than the break, which is the number that says whether the // margin came out as a shelf or as a trench with a rim. func shelfFraction(g *Geometry, in Input, shelfW *field.Field) float64 { var shelf, sea float64 for i := range in.Sea { if !in.Sea[i] { continue } sea++ if -float64(g.Dist.Data[i]) < float64(shelfW.Data[i]) { shelf++ } } if sea == 0 { return 0 } return shelf / sea * 100 } // boxMean smooths a *value* rather than a quantity: the same kernel, divided by how much of it landed on the // grid, so a cell at the border keeps the average of its neighbours instead of being pulled towards zero. // // The distinction is not pedantic and it cost a test to notice. boxBlur is mass-preserving because it treats // everything off the map as zero, which is right for sediment — there is none out there — and wrong for a // shelf width, where off the map means "no information", not "a shelf of width zero". Smoothing the carried // width with the mass-preserving kernel shrank every shelf near the border to nothing and put the whole // margin below the break. Blurring a field of ones with the same kernel gives exactly the coverage to divide // by, so the two share their arithmetic and cannot drift apart. // The coverage is *separable*, which is what keeps this affordable at planet scale. // // Blurring a field of ones is the obvious way to get the divisor, and it was the first way: two more full // fields plus a second boxBlur's two temporaries, which at 76 million cells is 1.2 GB for a quantity that // depends on nothing but the distance to the edge. But the blur is a row pass and a column pass, and applying // a 1-D operation to a field that is constant along the other axis leaves it constant along that axis - so // the coverage factorises as cx(x)*cy(y) for every pass count, exactly. Two vectors of W and H entries say // everything the field said. func boxMean(f *field.Field, radius, passes int, wrapX bool) *field.Field { if radius < 1 || passes < 1 { return f.Clone() } cx := boxCover(f.W, radius, passes, wrapX) cy := boxCover(f.H, radius, passes, false) // Y never wraps: the top and bottom of a map are the poles out := boxBlur(f, radius, passes, wrapX) for y := 0; y < f.H; y++ { row := y * f.W for x := 0; x < f.W; x++ { if c := cx[x] * cy[y]; c > 1e-6 { out.Data[row+x] /= float32(c) } else { out.Data[row+x] = f.Data[row+x] } } } return out } // boxCover is what a line of ones comes back as after the same running-sum passes boxBlur applies: 1 in the // middle and less than 1 within a kernel of each end, or 1 everywhere when the line wraps. func boxCover(n, radius, passes int, wrap bool) []float64 { cur := make([]float64, n) for i := range cur { cur[i] = 1 } if wrap { return cur // every cell has a full window; nothing runs off a cylinder } next := make([]float64, n) inv := 1 / float64(2*radius+1) for p := 0; p < passes; p++ { var sum float64 for i := 0; i <= radius && i < n; i++ { sum += cur[i] } for i := 0; i < n; i++ { next[i] = sum * inv if hi := i + radius + 1; hi < n { sum += cur[hi] } if lo := i - radius; lo >= 0 { sum -= cur[lo] } } cur, next = next, cur } return cur } // boxBlur is a separable running-sum box blur: O(n) whatever the radius, which is what makes a 300 m drift // kernel cost the same as a 30 m one. // // It divides by the full window rather than by however much of the window was on the grid, which is to say it // treats everything outside the map as zero. That choice is what makes the deposition sum come out right. The // mass balance in deposit needs the kernel to be *symmetric* — a cell's share of its neighbour must equal the // neighbour's share of it — and dividing each output by its own truncated window size breaks that symmetry at // the border, which cost 4 % of the sediment budget on a coast that ran off the edge of the map. Zero padding // keeps K(i,j) = K(j,i) everywhere, and a cell outside the map has no want, so nothing is owed to it. func boxBlur(f *field.Field, radius, passes int, wrapX bool) *field.Field { cur := f.Clone() if radius < 1 || passes < 1 { return cur } inv := 1 / float64(2*radius+1) next := field.NewLike(f) for p := 0; p < passes; p++ { field.Rows(f.H, func(y0, y1 int) { for y := y0; y < y1; y++ { row := y * f.W if wrapX { // On a cylinder every cell has a *full* window in X, so the running sum wraps instead of // being truncated. That makes the row pass lossless rather than zero-padded, which the // mass balance is happy with for the same reason it was happy before: the kernel stays // symmetric, K(i,j) = K(j,i), and now nothing runs off the side at all. var sum float64 for k := -radius; k <= radius; k++ { sum += float64(cur.Data[row+wrapCol(k, f.W)]) } for x := 0; x < f.W; x++ { next.Data[row+x] = float32(sum * inv) sum += float64(cur.Data[row+wrapCol(x+radius+1, f.W)]) sum -= float64(cur.Data[row+wrapCol(x-radius, f.W)]) } continue } var sum float64 for x := 0; x <= radius && x < f.W; x++ { sum += float64(cur.Data[row+x]) } for x := 0; x < f.W; x++ { next.Data[row+x] = float32(sum * inv) if hi := x + radius + 1; hi < f.W { sum += float64(cur.Data[row+hi]) } if lo := x - radius; lo >= 0 { sum -= float64(cur.Data[row+lo]) } } } }) cur, next = next, cur field.Rows(f.W, func(x0, x1 int) { for x := x0; x < x1; x++ { var sum float64 for y := 0; y <= radius && y < f.H; y++ { sum += float64(cur.Data[y*f.W+x]) } for y := 0; y < f.H; y++ { next.Data[y*f.W+x] = float32(sum * inv) if hi := y + radius + 1; hi < f.H { sum += float64(cur.Data[hi*f.W+x]) } if lo := y - radius; lo >= 0 { sum -= float64(cur.Data[lo*f.W+x]) } } } }) cur, next = next, cur } return cur } // wrapCol brings a column index onto a cylinder of width w. A free function rather than a Geometry method // because boxBlur is handed a plain field and has no geometry to ask. func wrapCol(x, w int) int { return ((x % w) + w) % w }