Added: Initial world generation tool
This commit is contained in:
@@ -0,0 +1,812 @@
|
||||
// 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 float64 // depth of the abyssal floor, positive metres
|
||||
Flow []float32
|
||||
Seed int64
|
||||
Cfg manifest.Coast
|
||||
}
|
||||
|
||||
// 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 := Measure(in.Sea, w, ht, h.CellM)
|
||||
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.AbyssM)
|
||||
}
|
||||
}
|
||||
res.finish(h.Clone(), 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.
|
||||
before := h.Clone()
|
||||
|
||||
shoreExposure := fetch(g, in)
|
||||
res.Stats.ExposureP10, res.Stats.ExposureP50, res.Stats.ExposureP90 = shorePercentiles(shoreExposure, g)
|
||||
carried := field.NewLike(h)
|
||||
for i, ref := range g.Ref {
|
||||
if ref >= 0 {
|
||||
carried.Data[i] = shoreExposure.Data[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)
|
||||
|
||||
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.
|
||||
supply := make([]float64, w*ht)
|
||||
var cutM3, planedCells float64
|
||||
for i, c := range cut.Data {
|
||||
if c <= 0 {
|
||||
continue
|
||||
}
|
||||
v := float64(c) * cellArea
|
||||
supply[g.Ref[i]] += 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(before, 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(before *field.Field, in Input) {
|
||||
h := in.Height
|
||||
r.Sea = make([]bool, len(h.Data))
|
||||
sea, beach, drowned := 0, 0, 0
|
||||
for i := range h.Data {
|
||||
r.Change.Data[i] = h.Data[i] - before.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 := field.NewLike(h)
|
||||
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 := float64(g.Dist.AtClamped(x+1, y) - g.Dist.AtClamped(x-1, y))
|
||||
dy := float64(g.Dist.AtClamped(x, y+1) - g.Dist.AtClamped(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 := x + int(math.Round(dx*float64(t)))
|
||||
py := y + int(math.Round(dy*float64(t)))
|
||||
if px < 0 || py < 0 || px >= g.W || 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.Data[i] = float32(hi + (lo-hi)*noise.Smoothstep(t))
|
||||
}
|
||||
})
|
||||
|
||||
carried := field.NewLike(h)
|
||||
for i, ref := range g.Ref {
|
||||
if ref >= 0 {
|
||||
carried.Data[i] = out.Data[ref]
|
||||
} else {
|
||||
carried.Data[i] = float32(hi)
|
||||
}
|
||||
}
|
||||
return boxMean(carried, int(shelfSmoothM/h.CellM+0.5), 2)
|
||||
}
|
||||
|
||||
// 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
|
||||
period := cfg.RoughWaveM * 256
|
||||
u, v := noise.WorldUV(g.W, g.H, h.CellM, 0, 0, period)
|
||||
rough := noise.FBMAt(u, v, noise.NewSource(in.Seed, srcShelf),
|
||||
noise.Params{BaseCells: 256, Octaves: 3, Gain: 0.5})
|
||||
|
||||
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
|
||||
}
|
||||
var depth float64
|
||||
if d < width {
|
||||
depth = in.BreakM * math.Pow(d/width, exp)
|
||||
} else {
|
||||
t := (d - width) / slopeW
|
||||
if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
depth = in.BreakM + (in.AbyssM-in.BreakM)*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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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) *field.Field {
|
||||
out := field.New(g.W, g.H, g.CellM)
|
||||
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 := -float64(g.Dist.AtClamped(x0+1, y0) - g.Dist.AtClamped(x0-1, y0))
|
||||
ny := -float64(g.Dist.AtClamped(x0, y0+1) - g.Dist.AtClamped(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 := x0 + int(math.Round(cs[k]*float64(t)))
|
||||
py := y0 + int(math.Round(sn[k]*float64(t)))
|
||||
if px < 0 || py < 0 || px >= g.W || py >= g.H {
|
||||
break // off the map is open water, and the mask keeps the border at sea
|
||||
}
|
||||
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.Data[i] = float32(noise.Smoothstep(t))
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// shorePercentiles reports the fetch distribution over the waterline itself, before it is carried anywhere.
|
||||
func shorePercentiles(shore *field.Field, g *Geometry) (p10, p50, p90 float64) {
|
||||
if len(g.Waterline) == 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
vals := make([]float64, 0, len(g.Waterline))
|
||||
for _, i := range g.Waterline {
|
||||
vals = append(vals, float64(shore.Data[i]))
|
||||
}
|
||||
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
|
||||
shelter := shelterFloor + (1-shelterFloor)*math.Pow(1-float64(exposure.Data[i]), cfg.ShelterBias)
|
||||
want.Data[i] = float32(shelter * shallow)
|
||||
}
|
||||
norm := boxBlur(want, radius, 3)
|
||||
|
||||
share := field.NewLike(h)
|
||||
for i, v := range supply {
|
||||
if v <= 0 {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
|
||||
// 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.
|
||||
func boxMean(f *field.Field, radius, passes int) *field.Field {
|
||||
if radius < 1 || passes < 1 {
|
||||
return f.Clone()
|
||||
}
|
||||
ones := field.NewLike(f)
|
||||
ones.Fill(1)
|
||||
sum := boxBlur(f, radius, passes)
|
||||
cover := boxBlur(ones, radius, passes)
|
||||
out := field.NewLike(f)
|
||||
for i := range out.Data {
|
||||
if c := cover.Data[i]; c > 1e-6 {
|
||||
out.Data[i] = sum.Data[i] / c
|
||||
} else {
|
||||
out.Data[i] = f.Data[i]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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) *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
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user