721 lines
29 KiB
Go
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
|