Files
UnrealPrototyping/Tools/Terrain/internal/detail/coastal.go
T
2026-09-25 17:02:24 +03:00

721 lines
29 KiB
Go

package detail
import (
"math"
"sort"
"salty/terrain/internal/dt"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
// Pass 11b: the shore at two metres.
//
// The coastal pass on the geology grid (internal/coast) decides where the shore *is*: it lays the shelf,
// planes a platform within a reach of the waterline, leaves a cliff where that reach ends, and carries the
// sediment it cut along the shore into the bays. All of that is right and almost none of it is visible,
// because the surf reach is 110 m and a geology cell is 8: a beach is fourteen cells wide, a berm is a
// quarter of one cell high, and a wave-cut notch is a fifth of one.
//
// The surf reach is the only length in the generator set by physics rather than by the canvas - it is how far
// a wave runs up, and a wave does not know how big the map is - so it does not shrink when the cell does. At
// the 2 m detail cell the same 110 m is 55 cells, which is enough to hold a real profile. That is the whole
// argument for this being a pass of its own rather than a knob on the one above.
//
// Everything here is measured against that reach and against the exposure the geology pass computed, so the
// two cannot disagree about where the shore is: this pass re-evaluates the same
// reach = SurfReachM * (0.35 + 0.65*exposure) that plane() used, and draws the profile the geology grid was
// too coarse to hold.
//
// It is local, which is what lets it run per tile: nothing here reads or writes further from the waterline
// than two surf reaches, which is 220 m against a tile margin of 244. Measured rather than reasoned - the
// pass reaches 110 to 136 m on the fixtures in TestThePassFitsInsideTheTileMargin - but the 220 is a hard
// limit rather than a measurement, because past it a cell has no stretch of shore to belong to at all.
// CoastalParams is pass 11b's input.
type CoastalParams struct {
Cfg manifest.CoastDetail
Surf manifest.Coast // the geology pass's own numbers: the reach and the platform grade come from it
Seed int64
Frame world.Frame
PeriodM float64 // the detail noise period, for the crenulation lattice
SeaLevelM float64
// Exposure is the geology pass's fetch field sampled onto this tile, 0 sheltered to 1 open water.
//
// It cannot be computed here and must not be: fetch is cast fifteen hundred metres in sixteen directions
// and a tile is five kilometres across, so a tile has no way of knowing whether the water in front of it
// is a bay or an ocean. It is exactly the quantity D-53's rule says has to come from the pass that ran
// over the whole cylinder. Nil means the bake predates the field, and then every coast is treated as
// fully exposed - which is what the geology pass's own percentiles say most coast is anyway.
Exposure []float32
Hardness *Hardness
}
// CoastalStats is what the pass moved, for the tile record. The cliff branch conserves: what it cuts off the
// face it lays at the foot, per stretch of shore, and ScreeM3 is reported beside CutM3 so a run where the two
// have drifted apart says so rather than quietly losing rock.
type CoastalStats struct {
ShoreCells int `json:"shore_cells"`
CliffFrac float64 `json:"cliff_fraction"`
CutM3 float64 `json:"cliff_cut_m3"`
ScreeM3 float64 `json:"scree_laid_m3"`
BeachM3 float64 `json:"beach_net_m3"`
// How high the land stands behind this tile's shore, over its waterline cells. It is the input the
// beach-or-cliff decision is made from, so it is reported rather than left to be inferred from the
// fraction: a run with no cliffs anywhere is either a coast with no cliffs on it or a threshold in the
// wrong place, and these two numbers are the only thing that tells the two apart.
BackshoreP50M float64 `json:"backshore_p50_m"`
BackshoreP90M float64 `json:"backshore_p90_m"`
}
// coastalTaper is how far past the surf reach the profile fades out, as a fraction of the reach. The taper
// exists so the pass hands back to the droplets rather than ending in a line across the ground.
const coastalTaper = 0.5
// beachFace is the slope of the swash face of a sand beach, which is what sets where the berm crest sits: a
// berm bh metres high has its crest bh/beachFace metres inland. 1:10 is the ordinary figure for medium sand,
// and it is the one number here that is a property of the sediment rather than of the wave.
const beachFace = 0.1
// RunCoastal cuts the shore profile. Height is modified in place; land is the detail land mask as the passes
// above left it and is not updated - the waterline this pass works from is the one they agreed on.
func RunCoastal(h *field.Field, land []bool, p CoastalParams) CoastalStats {
var st CoastalStats
cfg := p.Cfg
if !cfg.Enabled {
return st
}
reachMax := p.Surf.SurfReachM
if reachMax <= 0 {
return st
}
w, ht := h.W, h.H
cellM := h.CellM
// The shoreline, which is not the land mask's boundary.
//
// On a coastal plain the ground crosses sea level at a grade of about one in a hundred, so whether a cell
// is land is decided by centimetres over a strip forty metres wide and the mask's boundary is a band of
// speckle rather than a curve. Everything this pass does is measured from that boundary, and measuring
// from speckle went wrong twice: it put a separate two-metre berm on every island in the band, and - less
// visibly and worse - it wrecked the backshore, because a cell two hundred metres inland had its nearest
// waterline cell in a puddle beside it rather than out at the coast, so the real shore was left measuring
// the height of the land behind almost nothing.
//
// So the shoreline is derived: the signed distance to the raw boundary, smoothed, thresholded back. That
// is a curve, it is within a few metres of the mask's own boundary, and everything below is measured from
// it. Taking the waterline on the land side of it is a half-cell choice, recorded rather than hidden.
rough := boundaryOf(land, w, ht)
sd := signedDistance(rough, land, w, ht, cellM)
smoothShore(sd, w, ht, int(cfg.ShoreSmoothM/cellM+0.5))
wet := make([]bool, len(sd))
for i, v := range sd {
wet[i] = v > 0
}
line := boundaryOf(wet, w, ht)
shore := make([]int32, 0, 4096)
for i, on := range line {
if on {
shore = append(shore, int32(i))
}
}
if len(shore) == 0 {
return st
}
st.ShoreCells = len(shore)
// One transform, seeded on the waterline itself, answers both halves of every question this pass asks:
// how far a cell is from the shore, and which stretch of shore it belongs to. The geology pass needs two
// because it wants the sea side and the land side to answer different things; here they answer the same.
//
// wrapX is false and has to be: a tile is a rectangle cut out of the cylinder with a margin on it, and
// the seam is the tiling's business rather than the pass's. A tile that wrapped its own left edge onto
// its own right would be inventing a shore.
d2, near := dt.Transform(line, w, ht, false)
// Per stretch of shore: how open it is, how far the surf reaches, how high the land behind it stands, and
// how far the whole profile is displaced in or out. Indexed by slot rather than by cell, which is the
// same economy the geology pass keeps - a tile has millions of cells and thousands of shore cells.
n := len(shore)
expo := make([]float64, n)
reach := make([]float64, n)
cren := make([]float64, n)
crenNoise := p.crenulation(h)
for s, ci := range shore {
e := 1.0
if p.Exposure != nil {
e = float64(p.Exposure[ci])
if e < 0 {
e = 0
} else if e > 1 {
e = 1
}
}
expo[s] = e
reach[s] = reachMax * (0.35 + 0.65*e)
if crenNoise != nil {
cren[s] = cfg.CrenulationM * (2*float64(crenNoise.Data[ci]) - 1)
}
}
// The signed distance to that shoreline, which needs no smoothing of its own: the curve it is measured
// from is already smooth.
dist := make([]float32, len(d2))
for i := range d2 {
dm := math.Sqrt(float64(d2[i])) * cellM
if wet[i] {
dist[i] = float32(dm)
} else {
dist[i] = float32(-dm)
}
}
// Which stretch of shore each cell belongs to.
slot := make([]int32, len(d2))
// Two surf reaches is the outer limit of the whole pass, on both sides, and it is a limit rather than a
// consequence: it is the window the backshore is measured in, so it is the furthest any cell has a stretch
// of shore to belong to at all, and it is what makes the margin claim one number. 220 m at the default
// reach, against a tile margin of 244.
backOuter := 2 * reachMax
for i := range d2 {
dm := math.Sqrt(float64(d2[i])) * cellM
slot[i] = -1
if dm > backOuter || near[i] < 0 {
continue
}
if s := slotOf(shore, near[i]); s >= 0 {
slot[i] = int32(s)
}
}
back := marchBackshore(h, dist, wet, shore, reach, p.SeaLevelM)
cliff := make([]float64, n)
for s := range back {
cliff[s] = cliffiness(back[s], cfg.CliffFromM, cfg.CliffToM)
st.CliffFrac += cliff[s]
}
st.CliffFrac /= float64(n)
st.BackshoreP50M, st.BackshoreP90M = percentiles(back)
// The roughness fade, before the profile is drawn on top of it.
//
// The profile is only a few tens of metres wide, so on its own the ground goes from a drawn beach to full
// dune amplitude and droplet rills within the width of its taper, and the beach reads as a ribbon laid on
// the terrain rather than as part of it. This blends the surface towards a smoothed copy of itself over a
// wider band: the relief is untouched - the smoothing radius is metres, not tens of them - and what fades
// is the metre-scale texture, so the backshore comes out smoother than the hillside behind it. Which is
// what a backshore is: sand and dune over whatever the hillside is made of.
smoothShoreRoughness(h, dist, wet, reachMax, cfg.SmoothReachM)
// The profile. Two targets blended by how high the land behind stands, and the result blended into the
// surface by how far the cell is from the shore, so the pass fades out rather than ending in a line.
cut := make([]float64, n)
for i := range dist {
s := slot[i]
if s < 0 {
continue
}
x := float64(dist[i]) - cren[s]
r := reach[s]
now := float64(h.Data[i])
bh := p.Surf.BermM * (0.35 + 0.65*expo[s])
// The two branches carry their own reach as well as their own shape, which the first version of this
// did not: a beach is over within a few tens of metres of the water, and holding its berm out to the
// full surf reach cut a ninety-metre terrace into the land behind every beach on the map.
crest := bh / beachFace
face := math.Min(back[s], cfg.CliffMaxM)
wb := branchWeight(x, crest, math.Min(crest+cfg.BermBackM, backOuter), r*0.5, math.Min(r, backOuter))
wc := branchWeight(x, r,
math.Min(r+face/max64(cfg.CliffGrade, 1e-3), backOuter),
r*0.5, math.Min(r*(1+coastalTaper), backOuter))
if wb <= 0 && wc <= 0 {
continue
}
// A beach is a veneer of sediment, not a landform that fills a fjord. Without the cap the equilibrium
// profile is a *target depth*, so a shore with forty metres of water a hundred metres off it - a
// drowned valley, which is an ordinary thing on a real coast - gets thirty-seven metres of sand
// invented to bring the floor up to the curve. Capped, the beach is a few metres of sediment laid on
// whatever is there, and where the water is deep it simply runs out. That is what a steep-to shore is.
tb := beachTarget(x, bh, cfg.DeanA, p.SeaLevelM)
if fill := now + cfg.BeachFillM; tb > fill {
tb = fill
}
tc := cliffTarget(x, r, face, p.Surf.PlatformGrade, cfg.CliffGrade, p.SeaLevelM)
// The platform is rock, and rock does not plane flat: hard bands stand out as ledges and reefs and
// soft ones cut down into runnels. It goes into the cliff target *before* the clamp below, which is
// the difference between a ledge and a wall built out of the sea: a band that resisted is rock the
// surf did not take, so it is still below where the ground started.
if p.Hardness != nil && cfg.PlatformReliefM > 0 {
if win := platformWindow(x, r); win > 0 {
hard := p.Hardness.At(i, now/cellM)
tc += cfg.PlatformReliefM * (2*hard - 1) * win
}
}
// The cliff branch never builds, on either side of the waterline. A shore platform and the face above
// it are what is left after the sea took rock away, so a target above the ground is the pass
// proposing to invent a headland, and the honest answer to that is to leave the ground where it is.
// It is also what keeps the platform from being laid out across deep water: it planes what is
// shallower than it and passes over what is not.
if tc > now {
tc = now
}
dCliff := cliff[s] * wc * (tc - now) // never positive, by the clamp above
dBeach := (1 - cliff[s]) * wb * (tb - now)
h.Data[i] = float32(now + dCliff + dBeach)
cut[s] -= dCliff
st.BeachM3 += dBeach
}
area := cellM * cellM
for _, c := range cut {
st.CutM3 += c * area
}
st.BeachM3 *= area
st.ScreeM3 = layScree(h, dist, shore, reach, cut, cfg, area)
return st
}
// cliffiness is how much of a cliff a stretch of shore is: 0 where the land behind it is at beach height, 1
// where it stands a cliff's worth above the water, smooth in between so the two profiles do not switch over
// from one shore cell to the next.
func cliffiness(backM, from, to float64) float64 {
if to <= from {
if backM >= to {
return 1
}
return 0
}
t := (backM - from) / (to - from)
if t <= 0 {
return 0
}
if t >= 1 {
return 1
}
return noise.Smoothstep(t)
}
// beachTarget is the equilibrium beach: a swash face rising to a berm crest above water, and Dean's profile
// below it.
//
// depth = A * x^(2/3) is the standard equilibrium profile, and A is a property of the sand rather than of the
// wave - it is the shape a beach returns to whatever the last storm did to it, which is exactly the right
// thing for a generator to draw, because what a generator has is the long-run average and never the storm.
// The berm is the other half: its crest sits at the wave runup limit, runup scales with wave height and wave
// height with fetch, so a berm on an exposed coast stands higher than one at the back of a bay. That is why
// the crest height arrives already scaled by exposure.
func beachTarget(x, bermM, deanA, seaLevelM float64) float64 {
if x >= 0 {
crest := bermM / beachFace
if crest <= 0 {
return seaLevelM
}
if x >= crest {
return seaLevelM + bermM
}
return seaLevelM + bermM*x/crest
}
return seaLevelM - deanA*math.Pow(-x, 2.0/3.0)
}
// cliffTarget is a shore platform out to the foot and a face above it, up to faceM high.
//
// faceM is capped rather than being the backshore itself, and the cap is what stops the pass carving a
// seventy-degree wall four hundred metres up a coastal range: the only other thing that stops the face is the
// ground rising faster than it does, and ground behind a mountain coast does. A sea cliff is what the surf
// undercut; above that height the face is a hillslope and it belongs to the solve.
//
// The foot is at the surf reach, which is not a choice: it is where plane() stopped cutting on the geology
// grid, so the cliff is already there and already in the right place. What this does is give it a *face*. At
// 8 m the step from the platform to the backshore is one cell, and upsampled by four it is a four-cell ramp
// at whatever angle the interpolation chose; at 2 m the same height can stand at the angle a cliff stands at.
//
// Seaward of the waterline the platform simply continues at its own grade, which is what a shore platform
// does - it is cut across the intertidal and runs on a little way below low water before the sea floor takes
// over.
func cliffTarget(x, reachM, faceM, platformGrade, cliffGrade, seaLevelM float64) float64 {
if x < 0 {
return seaLevelM - platformGrade*(-x)
}
if x <= reachM {
return seaLevelM + platformGrade*x
}
foot := seaLevelM + platformGrade*reachM
t := foot + cliffGrade*(x-reachM)
if top := seaLevelM + faceM; t > top {
return top
}
return t
}
// branchWeight is how much of a branch's target a cell takes: all of it inside that branch's core, and
// smoothstepping to none at its outer limit, so the pass hands back to the droplets and the noise instead of
// ending in a line across the ground.
func branchWeight(x, coreLand, outLand, coreSea, outSea float64) float64 {
if x >= 0 {
return taperTo(x, coreLand, outLand)
}
return taperTo(-x, coreSea, outSea)
}
func taperTo(d, core, out float64) float64 {
if d <= core {
return 1
}
if d >= out || out <= core {
return 0
}
return noise.Smoothstep((out - d) / (out - core))
}
// platformWindow fades the strata relief in across the shore platform and out at both ends of it: nothing at
// the foot of the cliff, where the face takes over, and nothing where the platform runs out under water.
//
// It reaches seaward as well as inland, because a shore platform does: it is cut across the intertidal and
// carries on a little below low water, and that submerged half is where the ledges and the reefs are.
func platformWindow(x, reachM float64) float64 {
if reachM <= 0 {
return 0
}
lo, hi := -reachM*0.5, reachM
if x <= lo || x >= hi {
return 0
}
t := (x - lo) / (hi - lo)
return noise.Smoothstep(math.Min(t*4, 1)) * noise.Smoothstep(math.Min((1-t)*4, 1))
}
// layScree puts back what the face lost, at the foot, at the angle of repose.
//
// The cliff branch only ever cuts, so it has a volume to account for, and a cliff that shed its face into
// nothing would be the one place in this generator where rock disappears. It goes where it goes on a real
// coast: an apron at the foot, thickest against the face and thinning seaward, at the angle blocky debris
// stands at. The volume is matched per stretch of shore rather than per tile, so the apron under a cliff is
// the apron that cliff produced.
//
// Marched along the shore normal, for the same reason marchBackshore is: a stretch of shore inside a bay owns
// no cells at all a hundred metres out, because the nearest-shore wedges converge there, so an apron scattered
// over those cells simply had nowhere to go. Measured on region 11 before the change, the aprons gained 2085
// of the 3030 cubic metres the faces lost and the rest was silently dropped. A march has a line of cells to
// put it on whatever the coast does, and the normalisation is the same one: a stretch of shore owns a strip
// one cell wide, so a scattered wedge and a marched line cover the same area on a straight coast and agree.
func layScree(h *field.Field, dist []float32, shore []int32, reach, cut []float64,
cfg manifest.CoastDetail, area float64) float64 {
if cfg.ScreeDeg <= 0 || cfg.ScreeReachM <= 0 {
return 0
}
w, ht := h.W, h.H
cellM := h.CellM
at := func(x, y int) float64 {
if x < 0 {
x = 0
} else if x >= w {
x = w - 1
}
if y < 0 {
y = 0
} else if y >= ht {
y = ht - 1
}
return float64(dist[y*w+x])
}
var laid float64
var line [128]int32
var wgt [128]float64
for s, ci := range shore {
if cut[s] <= 0 {
continue
}
x, y := int(ci)%w, int(ci)/w
dx := at(x+1, y) - at(x-1, y)
dy := at(x, y+1) - at(x, y-1)
l := math.Hypot(dx, dy)
if l < 1e-9 {
continue
}
dx, dy = dx/l, dy/l
lo := int((reach[s]-cfg.ScreeReachM)/cellM + 0.5)
hi := int(reach[s]/cellM + 0.5)
if lo < 0 {
lo = 0
}
nsteps, total := 0, 0.0
for t := lo; t <= hi && nsteps < len(line); t++ {
px := x + int(math.Round(dx*float64(t)))
py := y + int(math.Round(dy*float64(t)))
if px < 0 || px >= w || py < 0 || py >= ht {
break
}
v := screeWedge(float64(t)*cellM, reach[s], cfg.ScreeReachM)
if v <= 0 {
continue
}
line[nsteps], wgt[nsteps] = int32(py*w+px), v
total += v
nsteps++
}
if total <= 0 {
continue
}
for k := 0; k < nsteps; k++ {
add := cut[s] * wgt[k] / total
h.Data[line[k]] += float32(add)
laid += add
}
}
return laid * area
}
// crenulation is the noise that moves the whole profile in and out along the shore.
//
// It is applied to the *distance* rather than to the height, which is what makes it a crenulate coastline
// rather than a rough one: the profile stays a profile and the shoreline wanders. And it is read at the
// nearest waterline cell rather than at the cell being written, so it varies along the shore and not across
// it - read per cell, a two-dimensional noise field would ripple the profile in the cross-shore direction
// too, and a beach with corrugations up its face is not a beach.
func (p CoastalParams) crenulation(h *field.Field) *field.Field {
if p.Cfg.CrenulationM <= 0 || p.Cfg.CrenulationWaveM <= 0 || p.PeriodM <= 0 {
return nil
}
f := p.Frame
u, v := noise.WorldUV(f.W, f.H, h.CellM, f.OriginXM(), f.OriginYM(), p.PeriodM)
base := int(p.PeriodM/p.Cfg.CrenulationWaveM + 0.5)
if base < 2 {
base = 2
}
return noise.FBMAt(u, v, noise.NewSource(p.Seed, srcCoastal),
noise.Params{BaseCells: base, Octaves: 3, Gain: 0.5})
}
// slotOf is where a waterline cell sits in the shore list, which is sorted because it was built by scanning.
// -1 for a cell that is not on the list, which the distance transform should never hand back and which is
// cheaper to rule out here than to debug as an index out of range at planet scale.
func slotOf(shore []int32, cell int32) int {
k := sort.Search(len(shore), func(k int) bool { return shore[k] >= cell })
if k < len(shore) && shore[k] == cell {
return k
}
return -1
}
// smoothShore blurs a signed distance field, in place.
//
// Smoothing the *distance* is the point, and it is worth saying what the two obvious alternatives do instead.
// Smoothing the mask only moves the speckle around: it is a majority vote over a band that is half land and
// half water, so it produces different speckle. Smoothing the heightmap flattens the berm along with it. The
// distance is the one field whose smoothing has exactly the wanted effect - the shoreline becomes a curve, a
// few metres from where the mask put it, and nothing else about the ground changes at all.
//
// Two passes rather than one, because one leaves a box kernel's corners in the isolines and they show in a
// hillshade on ground this flat.
func smoothShore(sd []float32, w, h, radius int) {
field.BoxSmooth(sd, w, h, radius, 2)
}
// percentiles sorts a copy and reads the median and the P90 off it. A few thousand shore cells a tile, so a
// sort is nothing; this is the one place in the detail passes where that is true, and it is why there is no
// histogram here the way there is in internal/stats.
func percentiles(v []float64) (p50, p90 float64) {
if len(v) == 0 {
return 0, 0
}
c := append([]float64(nil), v...)
sort.Float64s(c)
return c[len(c)/2], c[int(float64(len(c)-1)*0.9)]
}
func max64(a, b float64) float64 {
if a > b {
return a
}
return b
}
// boundaryOf is the cells of a mask that are orthogonally against a cell that is not, which is to say its
// edge on the inside.
func boundaryOf(mask []bool, w, h int) []bool {
out := make([]bool, len(mask))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if !mask[i] {
continue
}
if (x > 0 && !mask[i-1]) || (x < w-1 && !mask[i+1]) ||
(y > 0 && !mask[i-w]) || (y < h-1 && !mask[i+w]) {
out[i] = true
}
}
}
return out
}
// signedDistance is metres to the nearest boundary cell, positive inside the mask.
//
// Distance2 rather than Transform, because this one is thrown away after it has been smoothed and thresholded
// back into a shoreline: nothing asks it which stretch of shore a cell belongs to, and the feature index and
// the scratch it needs are two more arrays of four bytes a cell.
func signedDistance(boundary, mask []bool, w, h int, cellM float64) []float32 {
d2 := dt.Distance2(boundary, w, h, false)
out := make([]float32, len(d2))
for i := range d2 {
d := float32(math.Sqrt(float64(d2[i])) * cellM)
if mask[i] {
out[i] = d
} else {
out[i] = -d
}
}
return out
}
// marchBackshore is how high the land stands behind each stretch of shore: the mean height between one and
// two surf reaches inland, walked in along the shore normal.
//
// It is the window measureBackshore uses on the geology grid and for the same reason - it is clear of
// everything the surf planed, whatever the exposure there was - and it is what decides whether a stretch of
// shore is a beach or the foot of a cliff.
//
// **Walked rather than gathered**, and that is the whole of this function. The obvious implementation is to
// scatter every cell in the band onto the stretch of shore nearest to it, which costs one pass and no marches
// at all; it was the first one, and it is wrong in a way that only shows up on a real coastline. A cell two
// hundred metres inland belongs to exactly one shore cell, so on a concave shore - the inside of every bay,
// which is half of any coastline - the wedges converge and most shore cells are left owning nothing at all in
// the band. Their backshore then reads zero, which is not "the land behind is at sea level", it is "I did not
// look", and the two are indistinguishable afterwards. Measured on region 11: the median backshore over
// 69 km of waterline read 0.0 m while the mean height of the land 110 to 220 m inland was 1.9 m.
//
// A march gives every stretch of shore its own samples, whichever way the coast bends. Where it walks off the
// land - a spit narrower than a surf reach - the count stops rising, and a backshore of zero then means what
// it says.
func marchBackshore(h *field.Field, dist []float32, wet []bool, shore []int32, reach []float64,
seaLevelM float64) []float64 {
w, ht := h.W, h.H
cellM := h.CellM
at := func(x, y int) float64 {
if x < 0 {
x = 0
} else if x >= w {
x = w - 1
}
if y < 0 {
y = 0
} else if y >= ht {
y = ht - 1
}
return float64(dist[y*w+x])
}
out := make([]float64, len(shore))
for s, ci := range shore {
x, y := int(ci)%w, int(ci)/w
// Inland is up the gradient of the signed distance, which is smooth here because the shoreline it is
// measured from is a curve rather than the raw mask's boundary.
dx := at(x+1, y) - at(x-1, y)
dy := at(x, y+1) - at(x, y-1)
l := math.Hypot(dx, dy)
if l < 1e-9 {
continue
}
dx, dy = dx/l, dy/l
lo := int(reach[s]/cellM + 0.5)
hi := 2 * lo
var sum float64
var count int
for t := lo; t <= hi; t++ {
px := x + int(math.Round(dx*float64(t)))
py := y + int(math.Round(dy*float64(t)))
if px < 0 || px >= w || py < 0 || py >= ht {
break
}
j := py*w + px
if !wet[j] {
break
}
sum += float64(h.Data[j]) - seaLevelM
count++
}
if count > 0 {
out[s] = sum / float64(count)
}
}
return out
}
// screeWedge is the shape of the apron along the march: a wedge under the foot of the cliff, thickest against
// the face and thinning to nothing a scree reach seaward of it. Zero past the foot, because an apron lying
// *on* the cliff is not an apron.
func screeWedge(x, reachM, screeM float64) float64 {
if x > reachM {
return 0
}
d := reachM - x
if d >= screeM {
return 0
}
return 1 - d/screeM
}
// smoothShoreRoughness damps the metre-scale texture near the shore, in place.
//
// A blur of a few cells, mixed in by how close a cell is to the waterline. The radius is what keeps it a
// *roughness* fade rather than a shape one: at six metres it takes the top off the detail noise and the
// droplet rills and leaves everything the solve built, which is tens of metres across at the very least.
//
// Full strength within half a surf reach either side, then off over reachM more. Both sides on purpose - the
// shallows get the same treatment as the backshore, because a shore is a *place* rather than a line and it is
// smoother than either the land or the sea bed away from it.
//
// **Masked, and that is not a detail.** A plain blur across the waterline does not damp texture, it bridges
// the shoreline: the step there is a landform and not roughness. Measured on a fixture with forty metres of
// water against the land, an unmasked blur lifted the sea floor by twenty metres, which is a beach the size
// of the drowned valley it was supposed to leave alone.
func smoothShoreRoughness(h *field.Field, dist []float32, wet []bool, surfReachM, reachM float64) {
if reachM <= 0 {
return
}
radius := int(shoreRoughM/h.CellM + 0.5)
if radius < 1 {
return
}
soft := append([]float32(nil), h.Data...)
dry := make([]bool, len(wet))
for i, on := range wet {
dry[i] = !on
}
field.BoxSmoothMasked(soft, wet, h.W, h.H, radius, 2)
field.BoxSmoothMasked(soft, dry, h.W, h.H, radius, 2)
core := surfReachM * 0.5
out := core + reachM
for i := range h.Data {
d := math.Abs(float64(dist[i]))
if d >= out {
continue
}
w := 1.0
if d > core {
w = noise.Smoothstep((out - d) / (out - core))
}
h.Data[i] += float32(w * (float64(soft[i]) - float64(h.Data[i])))
}
}
// shoreRoughM is the wavelength the shore fade takes off. It is deliberately short: this is meant to remove
// the texture the detail passes added and nothing the solve built, and the solve's finest feature is a gully
// tens of metres across.
const shoreRoughM = 6