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

143 lines
5.7 KiB
Go

package detail
import (
"math"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
// Why the detail passes need a noise period of their own, and why it is short.
//
// noise.Lattice allocates cells² floats an octave, and the cell count is the period divided by the
// wavelength. Asking for an eight-metre finest octave on a hundred-kilometre period means a lattice of
// 12500² - one and a half gigabytes for the top octave alone - so world-period noise simply cannot reach
// detail wavelengths with this lattice.
//
// A short period can, and the cost is that the texture repeats. At a kilometre that is invisible: what
// repeats is a few metres of surface roughness, not anything with a shape, and the structure it sits on comes
// from the solve and from the paint, neither of which repeats at all. The period still has to divide the
// circumference exactly or the pattern breaks at the seam, which the manifest checks.
// DetailNoiseParams is pass 9.
type DetailNoiseParams struct {
Cfg manifest.Detail
Seed int64
Frame world.Frame
PeriodM float64 // the short period above; must divide the circumference
SeaLevelM float64
// Classes gives each cell its own amplitude. Nil means the manifest's pair everywhere.
Classes *Classes
}
// slopeFull is the slope at which detail noise reaches its full amplitude - about 27 degrees. Flat ground
// gets the low end and steep ground the high end, which is the same instinct as the droplets' slope gate: a
// meadow is smooth and a scree face is not, and noise applied evenly makes the meadow look like sandpaper.
const slopeFull = 0.5
// shoreTaperM is how far either side of the water the amplitude is faded in. A few metres of noise at the
// waterline turns the shallows into a scatter of one-cell islands, which is the same failure the coastal pass
// tapers its own sea-floor roughness to avoid.
const shoreTaperM = 12
// seabedAmp is how much of the flat-ground amplitude the sea bed gets. A sea bed is not a hillside: what is
// down there is bedform and scattered rock, and it is the shape of the shelf that carries the eye rather than
// its surface. It is a constant rather than a knob because the knob that matters is how deep the texture
// reaches, which is Detail.SeabedM, and two dials for one effect is one too many.
const seabedAmp = 0.45
// lattice builds the noise field both halves of this pass read, on world coordinates.
//
// BaseCells is chosen so the finest octave lands near two cells, which is as fine as a grid can carry.
func (p DetailNoiseParams) lattice(cellM float64) *field.Field {
oct := p.Cfg.Octaves
f := p.Frame
u, v := noise.WorldUV(f.W, f.H, cellM, f.OriginXM(), f.OriginYM(), p.PeriodM)
finest := 2 * cellM
base := int(p.PeriodM/(finest*math.Pow(2, float64(oct-1))) + 0.5)
if base < 2 {
base = 2
}
return noise.FBMAt(u, v, noise.NewSource(p.Seed, srcDetail),
noise.Params{BaseCells: base, Octaves: oct, Gain: 0.45})
}
func (p DetailNoiseParams) off() bool {
lo, hi := p.Cfg.AmplitudeM.Lo(), p.Cfg.AmplitudeM.Hi()
return p.Cfg.Octaves < 1 || (lo == 0 && hi == 0)
}
// RunDetailNoise adds surface texture at wavelengths the geology grid cannot hold.
//
// It is texture and nothing more. The relief, the valleys and the divides all came from the solve; this is
// what the ground does between them, and its amplitude is metres rather than tens of metres on purpose - the
// lesson from the first pipeline is that noise piled on top of erosion reads as noise, not as ground.
func RunDetailNoise(h *field.Field, land []bool, p DetailNoiseParams) {
if p.off() {
return
}
lo, hi := p.Cfg.AmplitudeM.Lo(), p.Cfg.AmplitudeM.Hi()
n := p.lattice(h.CellM)
slope := h.Slope()
for i := range h.Data {
if !land[i] {
continue
}
above := float64(h.Data[i]) - p.SeaLevelM
if above <= 0 {
continue
}
t := float64(slope.Data[i]) / slopeFull
if t > 1 {
t = 1
} else if t < 0 {
t = 0
}
cLo, cHi := p.Classes.amp(i, lo, hi)
amp := cLo + (cHi-cLo)*t
if above < shoreTaperM {
amp *= above / shoreTaperM
}
h.Data[i] += float32(amp * (2*float64(n.Data[i]) - 1))
}
}
// RunSeabedNoise is the same texture, under water.
//
// It is a second entry point rather than a branch inside the first because of *when* it can run. Passes 9 to
// 12 work with the sea flattened to sea level, so while they are running there is no sea bed to texture: the
// floor does not come back until the tile bake restores it, which is after pass 12 and just before the shore
// is drawn. So this runs there, on the same lattice, keyed the same way, and a cell gets the same value it
// would have got from one whole-world run.
//
// What it is for: a coast where the land is rough to the last cell and the water is glass from the first
// reads as a cut-out rather than as a shore, and the line between the two is the land mask's own boundary -
// the one thing in the picture that is a decision rather than a landform.
//
// Flat-ground amplitude only, and less of it: the slope term is what makes a scree face rough and there are
// no scree faces down here. Faded in from nothing at the waterline, so the pass cannot turn the shallows into
// a scatter of one-cell islands, and out to nothing at SeabedM.
func RunSeabedNoise(h *field.Field, p DetailNoiseParams) {
if p.off() || p.Cfg.SeabedM <= 0 {
return
}
lo, hi := p.Cfg.AmplitudeM.Lo(), p.Cfg.AmplitudeM.Hi()
n := p.lattice(h.CellM)
for i := range h.Data {
d := p.SeaLevelM - float64(h.Data[i])
if d <= 0 || d >= p.Cfg.SeabedM {
continue
}
cLo, _ := p.Classes.amp(i, lo, hi)
amp := cLo * seabedAmp * math.Min(d/shoreTaperM, 1) * (1 - noise.Smoothstep(d/p.Cfg.SeabedM))
if amp == 0 {
continue
}
h.Data[i] += float32(amp * (2*float64(n.Data[i]) - 1))
}
}