This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+242 -58
View File
@@ -98,10 +98,57 @@ type Input struct {
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
// 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.
@@ -167,7 +214,7 @@ func Build(in Input) *Result {
w, ht := h.W, h.H
cellArea := h.CellM * h.CellM
g := Measure(in.Sea, w, ht, 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
@@ -175,10 +222,11 @@ func Build(in Input) *Result {
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)
h.Data[i] = float32(in.SeaLevelM - in.abyssAt(i))
}
}
res.finish(h.Clone(), in)
copy(res.Change.Data, h.Data)
res.finish(in)
return res
}
@@ -189,20 +237,23 @@ func Build(in Input) *Result {
// 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()
// 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, g)
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.Data[ref]
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)
res.Exposure = boxMean(carried, int(exposureSmoothM/h.CellM+0.5), 2, g.WrapX)
cut := plane(h, g, res.Exposure, in)
@@ -210,14 +261,21 @@ func Build(in Input) *Result {
// 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)
// 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[g.Ref[i]] += v
supply[ref] += v
cutM3 += v
planedCells++
}
@@ -235,7 +293,7 @@ func Build(in Input) *Result {
res.Stats.BackshoreM = backshore
res.Stats.BackshoreP90M = backshoreP90
res.Stats.ShelfPctSea = shelfFraction(g, in, shelfW)
res.finish(before, in)
res.finish(in)
return res
}
@@ -245,12 +303,13 @@ func Build(in Input) *Result {
// 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) {
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 {
r.Change.Data[i] = h.Data[i] - before.Data[i]
// 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++
@@ -283,7 +342,7 @@ func (r *Result) finish(before *field.Field, in Input) {
// 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)
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
@@ -295,8 +354,8 @@ func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
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))
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
@@ -304,9 +363,9 @@ func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
dx, dy = dx/l, dy/l
var relief float64
for t := 1; t <= steps; t++ {
px := x + int(math.Round(dx*float64(t)))
px, ok := g.col(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 {
if !ok || py < 0 || py >= g.H {
break
}
if e := float64(h.Data[py*g.W+px]) - in.SeaLevelM; e > relief {
@@ -317,19 +376,19 @@ func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
if t > 1 {
t = 1
}
out.Data[i] = float32(hi + (lo-hi)*noise.Smoothstep(t))
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.Data[ref]
carried.Data[i] = out[ref]
} else {
carried.Data[i] = float32(hi)
}
}
return boxMean(carried, int(shelfSmoothM/h.CellM+0.5), 2)
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.
@@ -339,10 +398,21 @@ func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
// 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
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})
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 {
@@ -364,15 +434,24 @@ func layShelf(h *field.Field, g *Geometry, in Input, shelfW *field.Field) {
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 = in.BreakM * math.Pow(d/width, exp)
depth = brk * 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)
depth = brk + (abyss-brk)*noise.Smoothstep(t)
}
taper := depth / 10
if taper > 1 {
@@ -385,6 +464,28 @@ func layShelf(h *field.Field, g *Geometry, in Input, shelfW *field.Field) {
})
}
// 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.
//
@@ -402,8 +503,8 @@ func layShelf(h *field.Field, g *Geometry, in Input, shelfW *field.Field) {
// 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)
func fetch(g *Geometry, in Input) []float32 {
out := make([]float32, len(g.Waterline))
dirs := in.Cfg.FetchDirections
if dirs < 4 {
dirs = 4
@@ -424,8 +525,8 @@ func fetch(g *Geometry, in Input) *field.Field {
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))
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 {
@@ -441,10 +542,13 @@ func fetch(g *Geometry, in Input) *field.Field {
}
reach := maxSteps
for t := 1; t <= maxSteps; t++ {
px := x0 + int(math.Round(cs[k]*float64(t)))
px, ok := g.col(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 !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
@@ -464,20 +568,20 @@ func fetch(g *Geometry, in Input) *field.Field {
} else if t > 1 {
t = 1
}
out.Data[i] = float32(noise.Smoothstep(t))
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 *field.Field, g *Geometry) (p10, p50, p90 float64) {
if len(g.Waterline) == 0 {
func shorePercentiles(shore []float32) (p10, p50, p90 float64) {
if len(shore) == 0 {
return 0, 0, 0
}
vals := make([]float64, 0, len(g.Waterline))
for _, i := range g.Waterline {
vals = append(vals, float64(shore.Data[i]))
vals := make([]float64, 0, len(shore))
for _, v := range shore {
vals = append(vals, float64(v))
}
sort.Float64s(vals)
at := func(f float64) float64 {
@@ -630,16 +734,35 @@ func deposit(h *field.Field, g *Geometry, exposure *field.Field, in Input, suppl
continue
}
shallow := (cfg.DepositDepthM - depth) / cfg.DepositDepthM
shelter := shelterFloor + (1-shelterFloor)*math.Pow(1-float64(exposure.Data[i]), cfg.ShelterBias)
// 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)
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 i, v := range supply {
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
@@ -647,7 +770,8 @@ func deposit(h *field.Field, g *Geometry, exposure *field.Field, in Input, suppl
}
share.Data[i] = float32(v / nb)
}
spread := boxBlur(share, radius, 3)
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.
@@ -734,25 +858,65 @@ func shelfFraction(g *Geometry, in Input, shelfW *field.Field) float64 {
// 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 {
// 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()
}
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]
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.
//
@@ -762,7 +926,7 @@ func boxMean(f *field.Field, radius, passes int) *field.Field {
// 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 {
func boxBlur(f *field.Field, radius, passes int, wrapX bool) *field.Field {
cur := f.Clone()
if radius < 1 || passes < 1 {
return cur
@@ -773,6 +937,22 @@ func boxBlur(f *field.Field, radius, passes int) *field.Field {
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])
@@ -810,3 +990,7 @@ func boxBlur(f *field.Field, radius, passes int) *field.Field {
}
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 }