91 lines
4.0 KiB
Go
91 lines
4.0 KiB
Go
// Package detail is the pipeline below the geology grid: the passes that decide how the ground reads to
|
|
// somebody standing on it.
|
|
//
|
|
// Every one of them is local, which is what makes the detail grid tileable at all (internal/tile): noise is
|
|
// pointwise, thermal weathering propagates a cell at a time, and a droplet travels at most its lifetime in
|
|
// cells. And every one of them is a port of tuned numpy from Scripts/Authoring/heightmap_erosion.py rather
|
|
// than a reimplementation. Docs/Terrain.md is explicit about which of its constants are lessons rather than
|
|
// choices, and they all carry across unchanged:
|
|
//
|
|
// - the droplet slope gate at 0.25, which must sit well above the median lowland slope or the meadows come
|
|
// out brushed with rills;
|
|
// - the per-step cut cap, because droplets share cells and a crowd in one runs away to infinity without it;
|
|
// - the load cap, which bounds the mound a droplet leaves where it stops;
|
|
// - cuts through a 3x3 brush and deposits on the droplet's own cell, because spreading the deposit makes a
|
|
// pit's rim rise faster than its floor, so the pit never fills and every droplet feeds a mound;
|
|
// - and thermal weathering shedding half the *largest* excess rather than half the mean.
|
|
//
|
|
// What does not carry across is how the randomness is drawn. The numpy picks spawn cells from an RNG stream,
|
|
// which is index-dependent: the same cell would get different droplets depending on which tile it fell in and
|
|
// every seam would show. Here everything is a hash of the absolute world position.
|
|
package detail
|
|
|
|
import (
|
|
"math"
|
|
|
|
"salty/terrain/internal/noise"
|
|
"salty/terrain/internal/world"
|
|
)
|
|
|
|
// Hardness is rock hardness in [0, 1] as a function of position and *elevation*: horizontal bands with a slow
|
|
// tilt, and a slow change of rock type across the map. Erosion is scaled by (1 - hardness), so a hard band
|
|
// holds a shelf on a cut face.
|
|
//
|
|
// It is orthogonal to the lithology field the fluvial solve uses and both are kept, which is the point:
|
|
// lithology varies with where you are and enters the solve at geology resolution; strata varies with how deep
|
|
// you have cut and scales the droplets at detail resolution. One puts different rock in different valleys,
|
|
// the other puts ledges on a cliff.
|
|
type Hardness struct {
|
|
W, H int
|
|
period float64 // vertical period in cell heights
|
|
contrast float64
|
|
classes *Classes
|
|
tilt []float32
|
|
kind []float32
|
|
}
|
|
|
|
// Pass indices for the detail passes' seeded sources, above everything uplift and coast use.
|
|
const (
|
|
srcTilt = 40
|
|
srcKind = 41
|
|
srcDetail = 42
|
|
srcDroplet = 43
|
|
srcCoastal = 44
|
|
)
|
|
|
|
// NewHardness builds the two fields on world coordinates, so two tiles covering the same rock agree.
|
|
//
|
|
// noisePeriodM is the world period rather than the detail passes' short one: where the rock changes and how
|
|
// the bands tilt are kilometre-scale properties, and a lattice coarse enough for them costs nothing.
|
|
func NewHardness(f world.Frame, seed int64, noisePeriodM, strataPeriodM, contrast float64, classes *Classes) *Hardness {
|
|
u, v := noise.WorldUV(f.W, f.H, f.P.CellM, f.OriginXM(), f.OriginYM(), noisePeriodM)
|
|
tilt := noise.FBMAt(u, v, noise.NewSource(seed, srcTilt), noise.Params{BaseCells: 96, Octaves: 3, Gain: 0.5})
|
|
kind := noise.FBMAt(u, v, noise.NewSource(seed, srcKind), noise.Params{BaseCells: 64, Octaves: 3, Gain: 0.5})
|
|
period := strataPeriodM / f.P.CellM
|
|
if period < 1e-3 {
|
|
period = 1e-3
|
|
}
|
|
return &Hardness{W: f.W, H: f.H, period: period, contrast: contrast, classes: classes,
|
|
tilt: tilt.Data, kind: kind.Data}
|
|
}
|
|
|
|
// At is the hardness at cell i for material standing at heightCells, in cell heights.
|
|
func (hd *Hardness) At(i int, heightCells float64) float64 {
|
|
if hd == nil {
|
|
return 0
|
|
}
|
|
contrast := hd.classes.contrast(i, hd.contrast)
|
|
if contrast == 0 {
|
|
return 0
|
|
}
|
|
band := 0.5 + 0.5*math.Sin(2*math.Pi*(heightCells/hd.period+float64(hd.tilt[i])*2))
|
|
v := 0.5 + contrast*(band-0.5)*(0.4+0.8*float64(hd.kind[i]))
|
|
if v < 0.05 {
|
|
return 0.05
|
|
}
|
|
if v > 0.95 {
|
|
return 0.95
|
|
}
|
|
return v
|
|
}
|