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