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