245 lines
11 KiB
Go
245 lines
11 KiB
Go
package template
|
|
|
|
import (
|
|
"math"
|
|
|
|
"salty/terrain/internal/dt"
|
|
"salty/terrain/internal/field"
|
|
"salty/terrain/internal/noise"
|
|
"salty/terrain/internal/world"
|
|
)
|
|
|
|
// A painted coastline is a drawn line, and a coastline is not a drawn line.
|
|
//
|
|
// This is the Richardson paradox with a brush in it. An author draws a shore as a smooth curve, because that
|
|
// is what a hand and a bezier tool produce; a real coast has bays inside bays inside bays and the length you
|
|
// measure depends on the ruler you measure it with. Projected straight, the painting's own smoothness
|
|
// survives all the way to the heightmap, and the result reads as exactly what it is - a shape somebody drew -
|
|
// however good the erosion downstream is. `internal/coast` does this job on the square canvas, where the
|
|
// outline is noise to begin with; the painted planet had a manifest key for it, `coast_jitter_px`, which
|
|
// until now nothing anywhere read.
|
|
//
|
|
// **It is a mask on the waterline, not a warp of the painting.** That distinction was measured rather than
|
|
// reasoned. Displacing the point each cell asks the painting about - a domain warp - was tried first and it
|
|
// cannot cut a bay: a smooth warp of a smooth boundary is another smooth boundary, just wigglier, and at an
|
|
// amplitude large enough to fold it back on itself it drags every inland class boundary the same distance.
|
|
// What produces bays and headlands is thresholding a *signed distance field*: how far is this cell from the
|
|
// waterline, add fractal noise to that distance in metres, and ask again which side of zero it is on. Land
|
|
// juts out where the noise is positive and the sea reaches in where it is negative, at every scale the
|
|
// octaves cover, and nothing away from the shore moves at all.
|
|
//
|
|
// Two things follow from doing it this way, and both are the reason to:
|
|
//
|
|
// - A cell that changes sides needs a class, and the distance transform already knows which one: it
|
|
// returns the nearest seed cell as well as the distance to it, so new land takes the class of the land
|
|
// it grew from and new sea takes the class of the water that came in. Sea eaten out of a shore becomes
|
|
// the surf that was lying against it rather than deep ocean.
|
|
// - Small islands have to survive. An islet thirty pixels across, under a noise field whose wavelength is
|
|
// four hundred, sees very nearly a constant - so it either sits still or vanishes whole, and vanishing
|
|
// whole is how an archipelago disappears between two runs. The amplitude is therefore capped per cell at
|
|
// a fraction of the widest land within reach of it, which is a sliding maximum of the land distance.
|
|
// A continent sees the full amplitude; an islet gets nibbled instead of deleted.
|
|
|
|
// Pass indices for the coast mask's noise, above the painted uplift path's 20..25 so neither can reshuffle
|
|
// the other.
|
|
const (
|
|
srcCoastMask = 30
|
|
)
|
|
|
|
// islandGuard is how much of the widest land within reach the mask may eat. Two thirds leaves an islet
|
|
// recognisably itself while still giving it a ragged edge; at 1 it can take the whole thing.
|
|
const islandGuard = 0.66
|
|
|
|
// Coast is how the painted waterline is roughened before the painting is projected.
|
|
type Coast struct {
|
|
// AmplitudePx is the furthest, in template pixels, that the shoreline may move. Zero switches the whole
|
|
// thing off and the painting is used exactly as drawn.
|
|
AmplitudePx float64
|
|
|
|
// WavelengthPx is the coarsest octave: the width of the biggest bay it can cut. Octaves halve from
|
|
// there, so the finest detail is this over 2^(Octaves-1). Bays come out about this wide and up to
|
|
// AmplitudePx deep, so the ratio of the two is what decides whether the coast reads as a rough line or
|
|
// as a fjord coast.
|
|
WavelengthPx float64
|
|
|
|
// Octaves and Gain are the fractal structure. A gain near 0.5 makes each scale about as prominent as the
|
|
// last, which is the property a real coastline has and a single wobble does not.
|
|
Octaves int
|
|
Gain float64
|
|
|
|
// Scale is a per-pixel multiplier on AmplitudePx at the raster's own resolution, from the annotation
|
|
// layer's coast_jitter marks. Nil is one everywhere, which is every world before D-57.
|
|
//
|
|
// It is what makes a hand-drawn coastline hold. The roughening exists because a drawn shore is smooth and
|
|
// a real one is not, which is true of a shore nobody thought about and false of one somebody traced off a
|
|
// map on purpose; a zero here pins that stretch exactly as painted while the rest of the world is still
|
|
// roughened. Above one chews harder, which is the same knob pointed the other way - a fjord coast wants
|
|
// more than the planet's own amplitude, not less.
|
|
//
|
|
// **A negative entry means the pixel carries no instruction**, which is not the same as one. A mark is a
|
|
// stroke an author drew along a coastline and it lands on whichever side of the waterline their hand was
|
|
// on; if an unmarked cell took the default, a stroke painted on the land would leave the water beside it
|
|
// free to march inland anyway and the coast would move regardless. So an uninstructed cell takes the
|
|
// instruction from the nearest cell on the other side of the waterline, which the distance transform
|
|
// below has already found for a different reason. Painting either side is then enough, and painting over
|
|
// the line - which is what a brush does - is enough twice over.
|
|
Scale []float32
|
|
|
|
Seed int64
|
|
}
|
|
|
|
// Amount reports whether this mask does anything.
|
|
func (c Coast) Amount() bool {
|
|
return c.AmplitudePx > 0 && c.Octaves > 0 && c.WavelengthPx > 0 && c.Gain > 0
|
|
}
|
|
|
|
// maxScale is the largest multiplier any mark asks for, and at least 1. Uninstructed entries are negative and
|
|
// do not count; an unmarked world has no Scale at all and gets 1.
|
|
func (c Coast) maxScale() float64 {
|
|
m := 1.0
|
|
for _, v := range c.Scale {
|
|
if float64(v) > m {
|
|
m = float64(v)
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// RoughenCoast returns the painting with its waterline displaced by fractal noise. The receiver is not
|
|
// modified: a caller that wants both keeps both, which is what the studio's preview does.
|
|
//
|
|
// It runs at the paint's own resolution rather than the planet's. That is a third of the cells, the mask's
|
|
// scales are quoted in template pixels anyway, and the thing being roughened is the painting - so a template
|
|
// re-exported at a different size is the one case where the coast moves, and that is already true of every
|
|
// other thing the painting decides.
|
|
func (r *Raster) RoughenCoast(l *Legend, p world.Planet, c Coast) *Raster {
|
|
if !c.Amount() || r.W == 0 || r.H == 0 {
|
|
return r
|
|
}
|
|
sea := make([]bool, len(l.Classes))
|
|
for i := range l.Classes {
|
|
sea[i] = l.Classes[i].Sea
|
|
}
|
|
|
|
isSea := make([]bool, len(r.Class))
|
|
anySea, anyLand := false, false
|
|
for i, cl := range r.Class {
|
|
isSea[i] = sea[cl]
|
|
if isSea[i] {
|
|
anySea = true
|
|
} else {
|
|
anyLand = true
|
|
}
|
|
}
|
|
if !anySea || !anyLand {
|
|
return r // nothing to move: the painting is all one or all the other
|
|
}
|
|
|
|
// Signed distance to the waterline, positive on land, in template pixels, plus the index of the nearest
|
|
// cell on the other side - which is where a cell that changes sides gets its class from.
|
|
signed := make([]float32, len(r.Class))
|
|
other := make([]int32, len(r.Class))
|
|
|
|
// Seeded on land: for every sea cell, how far to land and which land cell.
|
|
d2, near := dt.Transform(invert(isSea), r.W, r.H, true)
|
|
for i := range signed {
|
|
if isSea[i] {
|
|
signed[i] = -float32(math.Sqrt(float64(d2[i])))
|
|
other[i] = near[i]
|
|
}
|
|
}
|
|
// Seeded on sea: for every land cell, how far to water and which water cell. Released in turn so the
|
|
// two transforms are never both alive - at 29 million pixels each one is a quarter of a gigabyte.
|
|
d2, near = dt.Transform(isSea, r.W, r.H, true)
|
|
for i := range signed {
|
|
if !isSea[i] {
|
|
signed[i] = float32(math.Sqrt(float64(d2[i])))
|
|
other[i] = near[i]
|
|
}
|
|
}
|
|
d2, near = nil, nil
|
|
|
|
// How wide the land is near each cell, so an islet cannot be eaten whole. Only land contributes, so a
|
|
// lone islet reports its own half-width and not the open water around it.
|
|
landOnly := field.New(r.W, r.H, 1)
|
|
for i := range signed {
|
|
if signed[i] > 0 {
|
|
landOnly.Data[i] = signed[i]
|
|
}
|
|
}
|
|
// The window is the furthest the shore could move *anywhere*, which is no longer the plain amplitude: a
|
|
// mark asking for more than the planet's own can reach past it, and a guard measured over too small a
|
|
// window would under-report how wide the land is and let an islet inside such a mark be eaten whole -
|
|
// the one failure this guard exists to stop.
|
|
reach := int(c.AmplitudePx*c.maxScale() + 0.5)
|
|
widest := field.SlidingMax(landOnly, reach, true)
|
|
|
|
// The noise, on world coordinates so it wraps at the seam and two runs of the same world agree.
|
|
// noise.Lattice wraps modulo its cell count, so the lattice has to be a whole number of cells in the
|
|
// noise period; the wavelength is quoted in template pixels and converts through the paint's own scale.
|
|
metresPerPx := p.CircumferenceM() / float64(r.W)
|
|
cells := int(p.NoisePeriodM/(c.WavelengthPx*metresPerPx) + 0.5)
|
|
if cells < 1 {
|
|
cells = 1
|
|
}
|
|
u, v := noise.WorldUV(r.W, r.H, metresPerPx, 0, 0, p.NoisePeriodM)
|
|
n := noise.FBMAt(u, v, noise.NewSource(c.Seed, srcCoastMask),
|
|
noise.Params{BaseCells: cells, Octaves: c.Octaves, Gain: c.Gain})
|
|
|
|
// Stretched to its own full range before it is used, so the amplitude means what it says. An fBm stack
|
|
// is normalised by the sum of its octave amplitudes, which is the value it would take if every octave
|
|
// agreed at once - they never do, so the realised spread is far narrower than 0..1 and a nominal 48 px
|
|
// was moving the shore about ten. The same trap as the massif fabric's threshold, and the same fix:
|
|
// measure the distribution rather than assume it. Here it is one pass of min/max over the whole painting
|
|
// - legitimate because the mask runs once on the whole map and not per region, so there is no second
|
|
// caller to disagree with.
|
|
n.Normalise()
|
|
|
|
out := &Raster{W: r.W, H: r.H, Class: make([]uint8, len(r.Class))}
|
|
copy(out.Class, r.Class)
|
|
|
|
field.Rows(r.H, func(y0, y1 int) {
|
|
for i := y0 * r.W; i < y1*r.W; i++ {
|
|
amp := c.AmplitudePx
|
|
if c.Scale != nil {
|
|
sc := c.Scale[i]
|
|
if sc < 0 {
|
|
// Uninstructed: take the instruction from the far side of the waterline. See Coast.Scale.
|
|
if j := other[i]; j >= 0 {
|
|
sc = c.Scale[j]
|
|
}
|
|
}
|
|
if sc >= 0 {
|
|
amp *= float64(sc)
|
|
}
|
|
}
|
|
if g := islandGuard * float64(widest.Data[i]); g < amp {
|
|
amp = g
|
|
}
|
|
if amp <= 0 {
|
|
continue
|
|
}
|
|
d := float64(signed[i]) + amp*(float64(n.Data[i])*2-1)
|
|
nowLand := d > 0
|
|
if nowLand == !isSea[i] {
|
|
continue // this cell did not change sides
|
|
}
|
|
// It did. Take the class of the nearest cell on the side it has joined, which the transform
|
|
// already found: land grows out of the land beside it, and water comes in as the water that
|
|
// was lying against the shore.
|
|
if j := other[i]; j >= 0 {
|
|
out.Class[i] = r.Class[j]
|
|
}
|
|
}
|
|
})
|
|
return out
|
|
}
|
|
|
|
func invert(b []bool) []bool {
|
|
out := make([]bool, len(b))
|
|
for i, v := range b {
|
|
out[i] = !v
|
|
}
|
|
return out
|
|
}
|