118 lines
5.5 KiB
Go
118 lines
5.5 KiB
Go
package uplift
|
|
|
|
import (
|
|
"salty/terrain/internal/field"
|
|
"salty/terrain/internal/noise"
|
|
"salty/terrain/internal/world"
|
|
)
|
|
|
|
// Lithology on a painted planet: what the rock is, underneath what the author painted it as.
|
|
//
|
|
// A class is a rate and an erodibility, and on a painted world the erodibility was one flat number over every
|
|
// cell of a colour. That is one range made of one rock, everywhere, and it shows: `map_erodibility.png` on a
|
|
// painted planet was a recolour of `map_class.png`, and two bakes of the same painting under different seeds
|
|
// differed on it only where the *coastline* had moved. Texture inside a range - the reason one flank is
|
|
// gullied and the next is a set of benches - has nowhere to come from.
|
|
//
|
|
// So there is a rock field: low-frequency noise cut into a few types, each with its own multiplier on K,
|
|
// exactly the spec's 4.3 and exactly what the procedural path has always had. What is different here is the
|
|
// two things a decomposed planet forces, and both are the same two the massif fabric ran into first.
|
|
//
|
|
// **The cut is a quantile of the planet, never of the region.** `uplift.Build` takes `f.Percentile()` of the
|
|
// grid it is handed, which on a planet means two regions measuring their own extents and putting the same
|
|
// physical hillside in different rock. The threshold is measured once over the whole cylinder by
|
|
// measureFabric, from a probe that is a property of the planet and of nothing else, so every region computes
|
|
// the identical number from the identical samples.
|
|
//
|
|
// **And nothing here may look at a neighbour.** The procedural version ends in `out.Blur(2)`, so that a rock
|
|
// boundary is a transition rather than a wall the solver carves into a cliff. A blur is a neighbourhood
|
|
// operation, and a neighbourhood operation near a region's edge reads cells that a different decomposition
|
|
// would not have given it. The softening is therefore done **pointwise, in rank space**: a cell near the edge
|
|
// of its band is blended towards the next band by how near it is, which needs only the cell's own value. The
|
|
// width of the transition on the ground then follows the fabric's own gradient - sharp where the rock changes
|
|
// fast, gradual where it does not - which is a better answer than a fixed blur radius anyway.
|
|
|
|
const (
|
|
srcPaintRock = 26
|
|
)
|
|
|
|
// rockOctaves and rockGain shape the rock field. Fewer octaves than the upland fabric on purpose: a lithology
|
|
// map is broad provinces with ragged edges, not a fractal at every scale, and the detail that does belong at
|
|
// metre scale is the strata model in the detail passes rather than this.
|
|
const (
|
|
rockOctaves = 4
|
|
rockGain = 0.5
|
|
)
|
|
|
|
// rockWarp bends the rock field by the shared low-frequency warp, as a fraction of its own wavelength. The
|
|
// same field that bends the massifs and the ridges, because a province boundary that ignored the grain
|
|
// everything else follows would read as a stencil laid over the world.
|
|
const rockWarp = 0.7
|
|
|
|
// rockEdge is how much of a band's width is spent blending into its neighbour, at each end. At 0.15 a
|
|
// province is flat over the middle seven tenths of its range and graded across the rest.
|
|
const rockEdge = 0.15
|
|
|
|
// rockFabric samples the rock field at the given world coordinates.
|
|
func rockFabric(u, v, wx, wy *field.Field, seed int64, baseCells int) *field.Field {
|
|
rs := noise.NewSource(seed, srcPaintRock)
|
|
ru, rv := noise.Warp(u, v, wx, wy, rockWarp/float64(baseCells))
|
|
return noise.FBMAt(ru, rv, rs, noise.Params{
|
|
BaseCells: baseCells, Octaves: rockOctaves, Gain: rockGain,
|
|
})
|
|
}
|
|
|
|
// RockK is the erodibility multiplier the lithology contributes at every cell of a frame, around 1.
|
|
//
|
|
// mult is the manifest's k_multipliers, in order, and the bands are **equal area over the planet**: the rank
|
|
// is uniform on 0..1 by construction, so cutting it into n equal pieces gives each rock type the same share
|
|
// of the world whatever the seed did to the noise. That is the property the procedural path got from
|
|
// `f.Percentile` and the reason it is worth keeping - a seed that produced no hard rock anywhere would be a
|
|
// seed that quietly removed a process.
|
|
//
|
|
// Returns nil when there is nothing to build, which is what a planet with no lithology_wavelength_km gets and
|
|
// what every painted planet got before this existed.
|
|
func RockK(p world.Planet, seed int64, baseCells int, mult []float64, u, v *field.Field) *field.Field {
|
|
if baseCells < 1 || len(mult) < 2 {
|
|
return nil
|
|
}
|
|
wx, wy := paintWarp(u, v, seed)
|
|
fabric := rockFabric(u, v, wx, wy, seed, baseCells)
|
|
cdf := measureFabric(p, seed, baseCells, rockFabric)
|
|
|
|
n := len(mult)
|
|
out := field.NewLike(fabric)
|
|
field.Rows(out.H, func(y0, y1 int) {
|
|
for i := y0 * out.W; i < y1*out.W; i++ {
|
|
out.Data[i] = float32(bandValue(cdf.at(float64(fabric.Data[i])), mult, n))
|
|
}
|
|
})
|
|
return out
|
|
}
|
|
|
|
// bandValue picks the rock type a rank falls in and softens the boundary, pointwise.
|
|
//
|
|
// The blend is half-and-half exactly at a boundary from either side, which is what makes it continuous: a
|
|
// cell at the top of band b is (b + b+1)/2 and a cell at the bottom of band b+1 is (b+1 + b)/2, the same
|
|
// number approached from opposite directions.
|
|
func bandValue(rank float64, mult []float64, n int) float64 {
|
|
x := rank * float64(n)
|
|
b := int(x)
|
|
if b >= n {
|
|
b = n - 1
|
|
}
|
|
if b < 0 {
|
|
b = 0
|
|
}
|
|
f := x - float64(b)
|
|
switch {
|
|
case f > 1-rockEdge && b+1 < n:
|
|
t := noise.Smoothstep((f-(1-rockEdge))/rockEdge) * 0.5
|
|
return mult[b] + (mult[b+1]-mult[b])*t
|
|
case f < rockEdge && b > 0:
|
|
t := noise.Smoothstep((rockEdge-f)/rockEdge) * 0.5
|
|
return mult[b] + (mult[b-1]-mult[b])*t
|
|
}
|
|
return mult[b]
|
|
}
|