Tooling
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
package detail
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// brush is the 3x3 kernel a droplet's cut goes through, weights summing to one.
|
||||
//
|
||||
// A one-cell footprint leaves every droplet path as a rill one cell wide, which reads across the lowlands as
|
||||
// brush strokes. Deposits are *not* spread through it and land on the droplet's own bilinear cell instead:
|
||||
// spread through the brush, a pit's rim rises faster than its floor, the pit never fills, and every droplet
|
||||
// that drains into it adds to the rim until there is a mound.
|
||||
var brush = [9]struct {
|
||||
dx, dy int
|
||||
w float64
|
||||
}{
|
||||
{0, 0, 0.36},
|
||||
{0, 1, 0.12}, {0, -1, 0.12}, {1, 0, 0.12}, {-1, 0, 0.12},
|
||||
{1, 1, 0.04}, {1, -1, 0.04}, {-1, 1, 0.04}, {-1, -1, 0.04},
|
||||
}
|
||||
|
||||
// Maps are the derivative fields the droplets leave behind: how much water passed, how much bedrock was
|
||||
// scraped, how much sediment was laid. The layer rules read them - scraped bedrock and convex ridges paint as
|
||||
// rock, sediment fans and basins as meadow.
|
||||
type Maps struct {
|
||||
Flow, Wear, Deposit []float32
|
||||
}
|
||||
|
||||
func newMaps(n int) *Maps {
|
||||
return &Maps{Flow: make([]float32, n), Wear: make([]float32, n), Deposit: make([]float32, n)}
|
||||
}
|
||||
|
||||
// ParticleParams is one particle pass over one tile.
|
||||
type ParticleParams struct {
|
||||
Cfg manifest.Particle
|
||||
Seed int64
|
||||
Frame world.Frame // the tile's cut, at detail resolution: what the hashes are keyed on
|
||||
|
||||
SeaLevelM float64
|
||||
Hardness *Hardness
|
||||
|
||||
// Classes gives each cell its own droplet density, which is the difference between a rain-fed landscape
|
||||
// and an arid one: drop it and the dendritic gully network thins to isolated channels.
|
||||
Classes *Classes
|
||||
}
|
||||
|
||||
// ParticleStats is what the pass moved, in metres.
|
||||
type ParticleStats struct {
|
||||
Droplets int
|
||||
Rounds int
|
||||
LargestCut, LargestFill float64
|
||||
}
|
||||
|
||||
// RunParticle erodes a tile in place with hydraulic droplets.
|
||||
//
|
||||
// h is in metres and land marks the cells droplets may spawn on. Everything inside works in *cell heights* -
|
||||
// metres over the cell size - so a slope of 1 is 45 degrees and every constant in the manifest means the same
|
||||
// thing at any resolution, which is how the numpy was tuned and why the numbers carry across.
|
||||
//
|
||||
// Determinism, which is the part that is not a port. The numpy draws spawn cells from an RNG stream; that is
|
||||
// index-dependent, so a cell would get different droplets depending on which tile it fell in and every seam
|
||||
// would show. Here a cell's droplet count and every one of their choices is a hash of (seed, world position),
|
||||
// so a droplet spawned in a tile's interior is bit-identical to the one spawned when that cell falls inside a
|
||||
// neighbour's margin.
|
||||
//
|
||||
// The pass runs in rounds, which is the numpy's batching kept deliberately rather than inherited: droplets
|
||||
// within a round read the height as it was when the round began and scatter their deltas into per-band
|
||||
// buffers summed afterwards in band order, so two droplets in one cell in one round do not see each other and
|
||||
// the result does not depend on which goroutine ran. Feedback - a channel deepening as more water follows it -
|
||||
// comes from the rounds, not from within one.
|
||||
func RunParticle(h *field.Field, land []bool, p ParticleParams) (*Maps, ParticleStats) {
|
||||
var st ParticleStats
|
||||
cellM := h.CellM
|
||||
w, ht := h.W, h.H
|
||||
maps := newMaps(w * ht)
|
||||
cfg := p.Cfg
|
||||
if cfg.Lifetime <= 0 || (cfg.DropletsPerCell <= 0 && p.Classes == nil) {
|
||||
return maps, st
|
||||
}
|
||||
|
||||
// Into cell heights, and back at the end.
|
||||
hc := make([]float64, w*ht)
|
||||
inv := 1 / cellM
|
||||
for i, v := range h.Data {
|
||||
hc[i] = float64(v) * inv
|
||||
}
|
||||
// The numpy spawns on land standing at least two metres clear of the water, which keeps droplets out of
|
||||
// the surf zone where they would only churn the beach the coastal pass laid.
|
||||
spawnAbove := (p.SeaLevelM + 2) / cellM
|
||||
|
||||
lifetime := cfg.Lifetime
|
||||
inertia := cfg.Inertia
|
||||
capacityF := cfg.Capacity
|
||||
minSlope := cfg.MinSlope
|
||||
depositRate := cfg.DepositRate
|
||||
erodeRate := cfg.ErodeRate * orOne(cfg.Scale)
|
||||
maxChange := cfg.MaxChange * orOne(cfg.Scale)
|
||||
evaporation := cfg.Evaporation
|
||||
gravity := cfg.Gravity
|
||||
maxSpeed := cfg.MaxSpeed
|
||||
maxLoad := cfg.MaxLoad
|
||||
minErode := math.Max(cfg.MinErodeSlope, 1e-6)
|
||||
limit := float64(w) - 2.001
|
||||
limitY := float64(ht) - 2.001
|
||||
|
||||
// How many droplets each cell spawns, and therefore how many rounds. Counting first costs one pass over
|
||||
// the tile and makes the round count a property of the world rather than of the loop.
|
||||
total := 0
|
||||
for y := 0; y < ht; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
if !land[i] || hc[i] <= spawnAbove {
|
||||
continue
|
||||
}
|
||||
wx, wy := p.Frame.PlanetXY(x, y)
|
||||
total += int(p.Classes.droplets(i, cfg.DropletsPerCell) + hashXY(p.Seed, wx, wy, 0))
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return maps, st
|
||||
}
|
||||
// Rounds comes from the manifest and *not* from the droplet count, which is the one place this departs
|
||||
// from the numpy on purpose. Derived from the count it would depend on how big a piece of the world was
|
||||
// being worked on, so a droplet would land in a different round in a tile than in the whole map and the
|
||||
// seams would not close.
|
||||
rounds := cfg.Rounds
|
||||
if rounds < 1 {
|
||||
rounds = 1
|
||||
}
|
||||
st.Droplets, st.Rounds = total, rounds
|
||||
|
||||
reach := lifetime + 2 // a droplet steps one cell at a time; the brush adds one more
|
||||
// A fixed band size, not one per core: a cell's contributions are summed band by band and floating-point
|
||||
// addition is not associative, so a partition that moved with GOMAXPROCS would move the last bit with it.
|
||||
const bandRows = 64
|
||||
bands := field.FixedBandCount(ht, bandRows)
|
||||
type buf struct {
|
||||
y0, y1 int // the rows this band may touch
|
||||
dh []float64
|
||||
flow, wear, dep []float32
|
||||
}
|
||||
bufs := make([]buf, bands)
|
||||
|
||||
for round := 0; round < rounds; round++ {
|
||||
field.FixedBands(ht, bandRows, func(b, y0, y1 int) {
|
||||
lo := y0 - reach
|
||||
if lo < 0 {
|
||||
lo = 0
|
||||
}
|
||||
hi := y1 + reach
|
||||
if hi > ht {
|
||||
hi = ht
|
||||
}
|
||||
n := (hi - lo) * w
|
||||
bf := &bufs[b]
|
||||
if len(bf.dh) != n {
|
||||
bf.dh = make([]float64, n)
|
||||
bf.flow = make([]float32, n)
|
||||
bf.wear = make([]float32, n)
|
||||
bf.dep = make([]float32, n)
|
||||
} else {
|
||||
clear(bf.dh)
|
||||
clear(bf.flow)
|
||||
clear(bf.wear)
|
||||
clear(bf.dep)
|
||||
}
|
||||
bf.y0, bf.y1 = lo, hi
|
||||
|
||||
add := func(x, y int, dh, flow, wear, dep float64) {
|
||||
if y < lo || y >= hi || x < 0 || x >= w {
|
||||
return
|
||||
}
|
||||
j := (y-lo)*w + x
|
||||
bf.dh[j] += dh
|
||||
bf.flow[j] += float32(flow)
|
||||
bf.wear[j] += float32(wear)
|
||||
bf.dep[j] += float32(dep)
|
||||
}
|
||||
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
if !land[i] || hc[i] <= spawnAbove {
|
||||
continue
|
||||
}
|
||||
wx, wy := p.Frame.PlanetXY(x, y)
|
||||
count := int(p.Classes.droplets(i, cfg.DropletsPerCell) + hashXY(p.Seed, wx, wy, 0))
|
||||
for j := 0; j < count; j++ {
|
||||
if int(hashXY(p.Seed, wx, wy, int32(100+j))*float64(rounds)) != round {
|
||||
continue
|
||||
}
|
||||
px := clampF(float64(x)+hashXY(p.Seed, wx, wy, int32(3*j+1)), 1, limit)
|
||||
py := clampF(float64(y)+hashXY(p.Seed, wx, wy, int32(3*j+2)), 1, limitY)
|
||||
runDroplet(hc, land, w, px, py, dropletConst{
|
||||
lifetime: lifetime, inertia: inertia, capacityF: capacityF,
|
||||
minSlope: minSlope, depositRate: depositRate, erodeRate: erodeRate,
|
||||
maxChange: maxChange, evaporation: evaporation, gravity: gravity,
|
||||
maxSpeed: maxSpeed, maxLoad: maxLoad, minErode: minErode,
|
||||
limitX: limit, limitY: limitY,
|
||||
}, p.Hardness, add)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Summed in band order, never drained from a channel: the result must not depend on which goroutine
|
||||
// finished first (cross-cutting rule 12).
|
||||
for b := range bufs {
|
||||
bf := &bufs[b]
|
||||
if bf.dh == nil {
|
||||
continue
|
||||
}
|
||||
for y := bf.y0; y < bf.y1; y++ {
|
||||
src := (y - bf.y0) * w
|
||||
dst := y * w
|
||||
for x := 0; x < w; x++ {
|
||||
hc[dst+x] += bf.dh[src+x]
|
||||
maps.Flow[dst+x] += bf.flow[src+x]
|
||||
maps.Wear[dst+x] += bf.wear[src+x]
|
||||
maps.Deposit[dst+x] += bf.dep[src+x]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i := range h.Data {
|
||||
after := float32(hc[i] * cellM)
|
||||
if d := float64(after - h.Data[i]); d < st.LargestCut {
|
||||
st.LargestCut = d
|
||||
} else if d > st.LargestFill {
|
||||
st.LargestFill = d
|
||||
}
|
||||
h.Data[i] = after
|
||||
}
|
||||
// Wear and deposit are in cell heights; report them in metres like everything else.
|
||||
for i := range maps.Wear {
|
||||
maps.Wear[i] = float32(float64(maps.Wear[i]) * cellM)
|
||||
maps.Deposit[i] = float32(float64(maps.Deposit[i]) * cellM)
|
||||
}
|
||||
st.LargestCut = -st.LargestCut
|
||||
return maps, st
|
||||
}
|
||||
|
||||
type dropletConst struct {
|
||||
lifetime int
|
||||
inertia, capacityF, minSlope float64
|
||||
depositRate, erodeRate, maxChange float64
|
||||
evaporation, gravity, maxSpeed float64
|
||||
maxLoad, minErode float64
|
||||
limitX, limitY float64
|
||||
}
|
||||
|
||||
// runDroplet is one droplet's whole life. It reads the height as it was at the start of the round and reports
|
||||
// what it moved through add; it never writes to the shared map itself.
|
||||
func runDroplet(h []float64, land []bool, w int, px, py float64, c dropletConst, hard *Hardness,
|
||||
add func(x, y int, dh, flow, wear, dep float64)) {
|
||||
|
||||
dx, dy := 0.0, 0.0
|
||||
speed, water, sediment := 1.0, 1.0, 0.0
|
||||
|
||||
for step := 0; step < c.lifetime; step++ {
|
||||
hcv, gx, gy, x0, y0, fx, fy := sampleBilinear(h, w, px, py)
|
||||
|
||||
dx = dx*c.inertia - gx*(1-c.inertia)
|
||||
dy = dy*c.inertia - gy*(1-c.inertia)
|
||||
length := math.Hypot(dx, dy)
|
||||
if length <= 1e-9 {
|
||||
return // standing water: it cannot pick a direction, so it stops
|
||||
}
|
||||
dx /= length
|
||||
dy /= length
|
||||
|
||||
nx, ny := px+dx, py+dy
|
||||
inside := nx >= 1 && nx <= c.limitX && ny >= 1 && ny <= c.limitY
|
||||
hn, _, _, _, _, _, _ := sampleBilinear(h, w, clampF(nx, 1, c.limitX), clampF(ny, 1, c.limitY))
|
||||
dh := 0.0
|
||||
if inside {
|
||||
dh = hn - hcv
|
||||
}
|
||||
|
||||
slope := math.Max(-dh, c.minSlope)
|
||||
capacity := math.Min(slope*speed*water*c.capacityF, c.maxLoad)
|
||||
hardness := 0.0
|
||||
if hard != nil {
|
||||
hardness = hard.At(y0*w+x0, hcv)
|
||||
}
|
||||
// Flat ground resists cutting. The gate has to sit well above the median lowland slope or the
|
||||
// meadows come out brushed with rills, which is the lesson 0.25 encodes.
|
||||
holds := math.Hypot(gx, gy) / c.minErode
|
||||
if holds > 1 {
|
||||
holds = 1
|
||||
}
|
||||
holds *= holds
|
||||
|
||||
deposit, erode := 0.0, 0.0
|
||||
if dh > 0 {
|
||||
deposit = math.Min(dh, sediment) // uphill: fill the pit it is climbing out of
|
||||
} else if sediment > capacity {
|
||||
deposit = (sediment - capacity) * c.depositRate
|
||||
}
|
||||
if dh <= 0 && sediment <= capacity {
|
||||
erode = math.Min((capacity-sediment)*c.erodeRate, -dh) * (1 - hardness) * holds
|
||||
}
|
||||
|
||||
// The sea is a sink: the droplet drops its whole load at the mouth, which is what makes a fan. It is
|
||||
// the land mask that decides, not a height comparison - the sea floor is held at sea level while the
|
||||
// detail passes run (the same invariant the solve keeps), so there is no depth to compare against.
|
||||
intoSea := false
|
||||
if inside {
|
||||
nxi, nyi := int(nx+0.5), int(ny+0.5)
|
||||
if nxi >= 0 && nxi < w && nyi >= 0 && nyi*w+nxi < len(land) {
|
||||
intoSea = !land[nyi*w+nxi]
|
||||
}
|
||||
}
|
||||
if intoSea {
|
||||
deposit, erode = sediment, 0
|
||||
} else {
|
||||
deposit = math.Min(deposit, c.maxChange)
|
||||
erode = math.Min(erode, c.maxChange)
|
||||
}
|
||||
|
||||
// Neither the cut nor the deposit may touch water. Both stencils straddle the waterline whenever a
|
||||
// droplet is within a cell of it, and the sea floor is held at sea level here and put back afterwards,
|
||||
// so anything written there would be silently thrown away - sediment that should have built a beach,
|
||||
// quietly deleted. The cut is simply skipped, because cutting a sea floor that is a placeholder means
|
||||
// nothing; the deposit is given to the droplet's own cell, which is land for as long as it is alive.
|
||||
onLand := func(x, y int) bool {
|
||||
if x < 0 || x >= w || y < 0 {
|
||||
return false
|
||||
}
|
||||
i := y*w + x
|
||||
return i < len(land) && land[i]
|
||||
}
|
||||
if erode > 0 {
|
||||
for _, b := range brush {
|
||||
if onLand(x0+b.dx, y0+b.dy) {
|
||||
add(x0+b.dx, y0+b.dy, -erode*b.w, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
if deposit > 0 {
|
||||
put := func(x, y int, amount float64) {
|
||||
if !onLand(x, y) {
|
||||
x, y = x0, y0
|
||||
}
|
||||
add(x, y, amount, 0, 0, 0)
|
||||
}
|
||||
put(x0, y0, deposit*(1-fx)*(1-fy))
|
||||
put(x0+1, y0, deposit*fx*(1-fy))
|
||||
put(x0, y0+1, deposit*(1-fx)*fy)
|
||||
put(x0+1, y0+1, deposit*fx*fy)
|
||||
}
|
||||
add(x0, y0, 0, water, erode, deposit)
|
||||
|
||||
sediment += erode - deposit
|
||||
speed = math.Min(math.Sqrt(math.Max(0, speed*speed-dh*c.gravity)), c.maxSpeed)
|
||||
water *= 1 - c.evaporation
|
||||
|
||||
if !inside || intoSea || water <= 0.001 {
|
||||
return
|
||||
}
|
||||
px, py = nx, ny
|
||||
}
|
||||
}
|
||||
|
||||
// sampleBilinear is the height and its gradient at a float position, with the integer cell and the
|
||||
// fractions the caller needs to scatter back. The caller keeps the position inside [1, size-2].
|
||||
func sampleBilinear(h []float64, w int, px, py float64) (hc, gx, gy float64, x0, y0 int, fx, fy float64) {
|
||||
x0 = int(px)
|
||||
y0 = int(py)
|
||||
fx = px - float64(x0)
|
||||
fy = py - float64(y0)
|
||||
i := y0*w + x0
|
||||
h00 := h[i]
|
||||
h10 := h[i+1]
|
||||
h01 := h[i+w]
|
||||
h11 := h[i+w+1]
|
||||
gx = (h10-h00)*(1-fy) + (h11-h01)*fy
|
||||
gy = (h01-h00)*(1-fx) + (h11-h10)*fx
|
||||
hc = h00*(1-fx)*(1-fy) + h10*fx*(1-fy) + h01*(1-fx)*fy + h11*fx*fy
|
||||
return hc, gx, gy, x0, y0, fx, fy
|
||||
}
|
||||
|
||||
func clampF(v, lo, hi float64) float64 {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func orOne(v float64) float64 {
|
||||
if v <= 0 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// hashXY is splitmix64's finaliser over the seed and a world position, in [0, 1). The same arithmetic as the
|
||||
// router's jitter and for the same reason: everything random has to be a hash of where a thing is, never of
|
||||
// the order it was visited in.
|
||||
func hashXY(seed int64, x, y int, k int32) float64 {
|
||||
h := uint64(seed)*0x9e3779b97f4a7c15 + 0x243f6a8885a308d3
|
||||
h ^= uint64(uint32(int32(x)))*0x9e3779b97f4a7c15 +
|
||||
uint64(uint32(int32(y)))*0xc2b2ae3d27d4eb4f +
|
||||
uint64(uint32(k))*0x165667b19e3779f9
|
||||
h ^= h >> 30
|
||||
h *= 0xbf58476d1ce4e5b9
|
||||
h ^= h >> 27
|
||||
h *= 0x94d049bb133111eb
|
||||
h ^= h >> 31
|
||||
return float64(h>>11) / float64(uint64(1)<<53)
|
||||
}
|
||||
Reference in New Issue
Block a user