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

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]
}