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

544 lines
22 KiB
Go

// Package plates is the tectonics a painted planet does not draw: which rigid pieces the lithosphere is in,
// how they move, and therefore where they are colliding.
//
// It exists because of one row in Docs/Terrain.md's pass table. Pass 1 writes `uplift` *and* `boundaries`,
// and pass 3 - faults - reads `boundaries`. D-53 dropped passes 1 to 4 on the painted path, because a
// painted template is already a statement about where the ranges are. What went out with them was the
// boundary set, and `uplift/painted_faults.go` substituted a noise grain field for it - a field that has
// never been told where a belt is. That substitution is visible in `map_uplift.png`: cyan traces striking
// across the bright belts at angles unrelated to them, the densest set sitting in a lowland, and several
// walking out over open ocean.
//
// The correction is not a better grain field. A range and its faults are not two things, one decorating the
// other: they are both consequences of the same convergence, and the line they are consequences of is the
// plate boundary. So the boundary is what gets built first, and the uplift and the faults are both read off
// it.
//
// **The plates live on the cylinder, not on a sphere.** A real plate moves by rotating about an Euler pole
// through the centre of the planet, and the velocity that produces varies along a boundary - which is the
// reason one margin is a head-on collision at one end and a strike-slip fault at the other. That variation
// is worth having; the sphere is not. Every other pass here measures distance in flat metres on a cylinder
// of fixed circumference with an 8 m cell that never varies (D-48), so a pass that believed in a sphere
// would be the only one whose distances disagreed with the solve's, and its velocities would converge at
// poles nothing else knows are there. The compromise keeps the property and drops the geometry: a plate's
// motion is a translation plus a rotation about a pole **in the map plane**,
//
// v(x) = T + omega x (x - pole)
//
// which is the two-dimensional analogue and varies along a boundary for the same reason.
//
// **What the painting still owns.** Whether a plate is continental is read from the land mask rather than
// drawn from the seed: a plate covering the author's continent *is* a continental plate. That is the one
// place the painting feeds the model rather than competing with it, and it is what makes an ocean-continent
// margin land where an author would expect a subduction zone.
//
// **The tectonic grid is its own, and coarse.** The partition is rasterised at a few hundred metres rather
// than at the 8 m geology cell. A plate boundary belt is tens of kilometres wide and the finest thing read
// off the line is a fault trace, so a quarter-kilometre lattice is already finer than anything downstream
// can use, and it makes the whole pass a few million operations instead of a few hundred million. What
// leaves this package is polylines in **world metres**, which is the same form `uplift.FaultTrace` already
// travels in and for the same reason: a region filters the planet's set to what reaches its own frame, so a
// boundary crossing a region edge is one boundary and two decompositions agree.
package plates
import (
"fmt"
"math"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
// Pass indices for this package's seeded streams. They sit above the detail passes' 40s so that adding one
// here cannot reshuffle any existing field.
const (
srcSites = 50
srcMotion = 51
srcWarp = 52
)
// Config is what the manifest asks for. Every zero field takes a default from withDefaults.
type Config struct {
// Layer and Legend are the painted tectonic layer: an image where a colour is a plate, and a legend
// saying how each one moves. Naming them is what turns the plates from something the seed invents into
// something an author draws, and it is the intended way to use this package - see paint.go.
//
// With no layer the plates come from Count and the seed, which is a Voronoi partition that knows nothing
// about where the continents are. That mode's real job is Propose: it writes a first painting, which the
// author then edits.
Layer string `json:"layer"`
Legend string `json:"legend"`
// Count is how many plates the lithosphere is in. Earth has seven or eight majors and a couple of dozen
// minors; what matters here is that a boundary has to have room to be a mountain belt, so the useful
// range on a hundred-kilometre planet is single digits.
Count int `json:"count"`
// SizeSpread is the ratio between the largest and smallest plate weight. The partition is a
// multiplicatively weighted Voronoi, so a heavier site claims ground further away: 1 makes every plate
// the same size, which is the one thing real plates never are.
SizeSpread float64 `json:"size_spread"`
// VelocityCmYr is how fast a plate moves, low to high. Earth runs 1 to 10; the number that matters
// downstream is the *relative* speed across a boundary, which is a difference of two of these.
VelocityCmYr [2]float64 `json:"velocity_cm_yr"`
// SpinFraction is how much of a plate's speed is rotation about its own centre rather than translation.
// Zero makes every margin uniform along its length, which is the defect the in-plane pole exists to
// avoid; one makes the plate a pinwheel. A third of it is enough to turn a collision into a transform
// over a few tens of kilometres.
//
// A pointer because zero is a real answer here and so is "say nothing". JSON cannot tell an absent
// number from a zero one, and a plain float64 read them as the same thing - which is how a proposal came
// out with every plate's spin at zero and every margin uniform, the one defect this field exists to
// prevent. Absent takes the default; an explicit 0 means none.
SpinFraction *float64 `json:"spin_fraction"`
// WarpFraction is how far a boundary wanders from the straight Voronoi edge, as a fraction of the mean
// plate spacing. Without it the partition is a polygon net and every margin is a ruled line.
//
// It is applied over two octaves, and that is not decoration either: one octave at the plate wavelength
// gives a margin one long shallow bend, which at planet scale is still a ruled line with a kink in it.
// The second octave at a third of the wavelength is what puts a promontory and a re-entrant into a
// margin, and those are where a collision belt gets its along-strike segmentation from.
WarpFraction float64 `json:"warp_fraction"`
// ResolutionM is the tectonic grid's cell. See the package comment: coarse on purpose.
ResolutionM float64 `json:"resolution_m"`
// ContinentalFraction is the share of a plate's painted area that has to be land before it counts as
// continental. Well below a half, because a continental plate carries a shelf and a passive margin as
// well as its continent.
ContinentalFraction float64 `json:"continental_fraction"`
// ObliqueDeg is where a margin stops being convergent or divergent and becomes transform: the angle
// between the relative velocity and the boundary normal, past which the strike-slip component is the
// one in charge. 60 degrees means a margin stays convergent until the slip is over 1.7 times the
// closing.
ObliqueDeg float64 `json:"oblique_deg"`
// Faults is the deformation zone around every margin: how wide it is and how densely it is broken. A
// zero block means the margins carry no faults of their own, which is what every painted planet had
// before it existed - the legend's per-class `faults` blocks are a separate, and now secondary, set.
Faults Belt `json:"faults"`
}
// Default is the configuration a manifest that says nothing gets.
func Default() Config {
return Config{
Count: 7,
SizeSpread: 1.7,
VelocityCmYr: [2]float64{1, 6},
SpinFraction: nil, // see Spin(); the default lives there so that an explicit 0 can mean none
WarpFraction: 0.34,
ResolutionM: 250,
ContinentalFraction: 0.18,
ObliqueDeg: 60,
}
}
// DefaultSpinFraction is what a config that does not mention spin gets. It is not zero on purpose: a planet
// of plates that only drift has margins identical along their whole length, and the along-strike change from
// collision to transform is the most useful thing the model gives a fault set.
const DefaultSpinFraction = 0.35
// Spin is the configured spin fraction, or the default when the manifest said nothing. An explicit zero is
// honoured and means no rotation at all.
func (c Config) Spin() float64 {
if c.SpinFraction == nil {
return DefaultSpinFraction
}
if *c.SpinFraction < 0 {
return 0
}
return *c.SpinFraction
}
func (c Config) withDefaults() Config {
d := Default()
if c.Count <= 0 {
c.Count = d.Count
}
if c.SizeSpread < 1 {
c.SizeSpread = d.SizeSpread
}
if c.VelocityCmYr[1] <= 0 {
c.VelocityCmYr = d.VelocityCmYr
}
if c.VelocityCmYr[0] < 0 {
c.VelocityCmYr[0] = 0
}
if c.WarpFraction < 0 {
c.WarpFraction = d.WarpFraction
}
if c.ResolutionM <= 0 {
c.ResolutionM = d.ResolutionM
}
if c.ContinentalFraction <= 0 {
c.ContinentalFraction = d.ContinentalFraction
}
if c.ObliqueDeg <= 0 || c.ObliqueDeg >= 90 {
c.ObliqueDeg = d.ObliqueDeg
}
return c
}
// Plate is one rigid piece of the lithosphere.
type Plate struct {
ID int `json:"id"`
// SiteXM, SiteYM is the Voronoi site in world metres, and Weight is what makes plates different sizes.
SiteXM float64 `json:"site_x_m"`
SiteYM float64 `json:"site_y_m"`
Weight float64 `json:"weight"`
// CentroidXM, CentroidYM is the plate's centre of area, measured the short way round the cylinder. It is
// where the plate turns about, and it is the one position a painted plate has - a painting has no site.
CentroidXM float64 `json:"centroid_x_m"`
CentroidYM float64 `json:"centroid_y_m"`
// The motion, in metres a year: a translation plus a rotation about a pole in the map plane. The pole is
// always the centroid; it is stored rather than derived so that VelocityAt needs nothing but the plate.
TransXM float64 `json:"trans_x_m_yr"`
TransYM float64 `json:"trans_y_m_yr"`
PoleXM float64 `json:"pole_x_m"`
PoleYM float64 `json:"pole_y_m"`
OmegaRadYr float64 `json:"omega_rad_yr"`
// Continental is read from the painting rather than drawn from the seed: see the package comment.
Continental bool `json:"continental"`
LandFraction float64 `json:"land_fraction"`
// AreaCells is the plate's size on the tectonic grid, which is what decides who overrides whom when two
// oceanic plates converge.
AreaCells int `json:"area_cells"`
}
// VelocityAt is the plate's motion at a world point, in metres a year.
//
// The lever arm is measured the short way round the cylinder. Without that a plate whose pole sits just east
// of the seam would spin the wrong way for every point just west of it, and the boundary running through the
// seam would be classified as convergent on one side and divergent on the other - the one bug this whole
// coordinate system exists to make impossible.
func (pl Plate) VelocityAt(p world.Planet, xM, yM float64) (vx, vy float64) {
rx := wrapDelta(xM-pl.PoleXM, p.CircumferenceM())
ry := yM - pl.PoleYM
return pl.TransXM - pl.OmegaRadYr*ry, pl.TransYM + pl.OmegaRadYr*rx
}
// SpeedMYr is how fast the plate is going at its own site, which is the number worth printing.
func (pl Plate) SpeedMYr(p world.Planet) float64 {
vx, vy := pl.VelocityAt(p, pl.SiteXM, pl.SiteYM)
return math.Hypot(vx, vy)
}
// Model is a planet's tectonics: the plates, the grid they were rasterised on, and the boundaries between
// them.
type Model struct {
P world.Planet `json:"-"`
Cfg Config `json:"config"`
Plates []Plate `json:"plates"`
// The tectonic grid. GCellM is derived rather than taken: it is the circumference divided by a whole
// number of columns, so the grid wraps exactly and a boundary crossing the seam is an ordinary one.
GW int `json:"-"`
GH int `json:"-"`
GCellM float64 `json:"grid_cell_m"`
// Cell is the plate id at every tectonic cell, row-major, X cyclic.
Cell []int16 `json:"-"`
// Boundaries is the whole planet's set, in world metres.
Boundaries []Boundary `json:"boundaries"`
}
// GridXM and GridYM are the world position of a tectonic cell's centre. Y runs from the top of the polar
// pad, so a grid row and a planet row mean the same place.
func (m *Model) GridXM(gx int) float64 { return (float64(gx) + 0.5) * m.GCellM }
func (m *Model) GridYM(gy int) float64 { return m.P.YM(0) + (float64(gy)+0.5)*m.GCellM }
// GridIdx wraps X and clamps Y, the same way world.Planet.Idx does.
func (m *Model) GridIdx(gx, gy int) int {
gx = ((gx % m.GW) + m.GW) % m.GW
if gy < 0 {
gy = 0
} else if gy >= m.GH {
gy = m.GH - 1
}
return gy*m.GW + gx
}
// PlateAt is which plate owns a world position.
func (m *Model) PlateAt(xM, yM float64) int {
gx := int(math.Floor(xM / m.GCellM))
gy := int(math.Floor((yM - m.P.YM(0)) / m.GCellM))
return int(m.Cell[m.GridIdx(gx, gy)])
}
// Build draws a planet's plates and the boundaries between them, once, deterministically from the seed.
//
// land reports whether a world position is painted land. It is a callback rather than a raster so that this
// package knows nothing about templates: what it needs from the painting is one bit, and asking for it this
// way also means the caller decides how the land mask is sampled.
func Build(p world.Planet, seed int64, cfg Config, land func(xM, yM float64) bool) (*Model, error) {
cfg = cfg.withDefaults()
if cfg.Count < 2 {
return nil, fmt.Errorf("a planet in %d plate(s) has no boundaries", cfg.Count)
}
circ := p.CircumferenceM()
gw := int(circ/cfg.ResolutionM + 0.5)
if gw < cfg.Count*4 {
gw = cfg.Count * 4
}
gcell := circ / float64(gw)
gh := int(float64(p.H)*p.CellM/gcell + 0.5)
if gh < 2 {
gh = 2
}
m := &Model{P: p, Cfg: cfg, GW: gw, GH: gh, GCellM: gcell}
m.Plates = placeSites(p, seed, cfg, gh, gcell)
giveMotion(p, seed, cfg, m.Plates)
m.Cell = partition(m, seed)
m.measure(land)
m.Boundaries = m.buildBoundaries()
return m, nil
}
// warpFineGain is how strong the second warp octave is against the first. Held well below a half: past that
// the displacement folds back on itself and the partition grows islands of one plate inside another, which
// is a nonsense the boundary tracer would faithfully chain into a ring.
const warpFineGain = 0.4
// spacingM is the mean distance between neighbouring sites: the length every other length in this package is
// a fraction of.
func spacingM(circ, heightM float64, count int) float64 {
return math.Sqrt(circ * heightM / float64(count))
}
// placeSites scatters the plate centres, refusing any that lands on top of another.
//
// Rejection rather than relaxation. Lloyd's algorithm would give an even, hexagonal net, which is a worse
// answer than this one: plates are not even, and the interesting boundary geometry - a small plate wedged
// between two large ones, a long thin one - comes from exactly the irregularity relaxation removes. The
// minimum separation is only there to stop two sites coinciding, which produces a sliver no boundary tracer
// can chain.
func placeSites(p world.Planet, seed int64, cfg Config, gh int, gcell float64) []Plate {
s := noise.NewSource(seed, srcSites)
circ := p.CircumferenceM()
heightM := float64(gh) * gcell
top := p.YM(0)
minSep := 0.5 * spacingM(circ, heightM, cfg.Count)
out := make([]Plate, 0, cfg.Count)
for len(out) < cfg.Count {
for try := 0; ; try++ {
x := s.Float() * circ
y := top + s.Float()*heightM
// After enough refusals the separation is the thing that is wrong, not the draw, so it is given
// up rather than looped on for ever - a count near the area's limit can have no valid position
// left at all.
if try < 64 && tooClose(out, p, x, y, minSep) {
continue
}
out = append(out, Plate{
ID: len(out),
SiteXM: x,
SiteYM: y,
Weight: 1 + (cfg.SizeSpread-1)*s.Float(),
})
break
}
}
return out
}
func tooClose(out []Plate, p world.Planet, x, y, minSep float64) bool {
circ := p.CircumferenceM()
for i := range out {
dx := wrapDelta(x-out[i].SiteXM, circ)
dy := y - out[i].SiteYM
if math.Hypot(dx, dy) < minSep {
return true
}
}
return false
}
// giveMotion draws each plate's translation and its spin.
//
// **Every plate turns about its own centre of area**, which measure fills in once the partition exists. An
// earlier version put the pole a plate-width off to one side, on the reasoning that a pole at the middle
// would cancel symmetrically and leave the margins as uniform as a pure translation does. That reasoning is
// wrong: the relative velocity at a contact is
//
// (T_a - T_b) + omega_a x (x - c_a) - omega_b x (x - c_b)
//
// which varies along the contact for any pole at all, and a pole at the centre puts the *largest* rotational
// contribution out at the margins, where it is wanted. The offset pole bought nothing and cost something
// real: it cannot be written into a painted legend, where an author says "this plate is also turning
// clockwise" and means about its own middle. A proposal therefore did not read back as the planet it was
// proposed from, which is what TestAProposalReadsBackAsTheSamePlanet caught.
func giveMotion(p world.Planet, seed int64, cfg Config, ps []Plate) {
s := noise.NewSource(seed, srcMotion)
circ := p.CircumferenceM()
spacing := spacingM(circ, p.HeightM(), cfg.Count)
for i := range ps {
speed := s.Range(cfg.VelocityCmYr[0], cfg.VelocityCmYr[1]) / 100 // cm/yr to m/yr
dir := s.Float() * 2 * math.Pi
ps[i].TransXM = math.Cos(dir) * speed
ps[i].TransYM = math.Sin(dir) * speed
// The spin is set so that the rotational speed one spacing from the pole is SpinFraction of the
// translation speed: the fraction means the same thing whatever the planet's size.
sign := 1.0
if s.Float() < 0.5 {
sign = -1
}
if spacing > 0 {
ps[i].OmegaRadYr = sign * cfg.Spin() * speed / spacing
}
}
}
// partition rasterises the plates onto the tectonic grid.
//
// A multiplicatively weighted Voronoi - nearest site by distance/weight - through a warped query point. The
// warp is what stops the result being a polygon net: it is sampled from a lattice in world coordinates, so
// two decompositions of the same planet warp the same point the same way, and its wavelength is deliberately
// long compared with the plate spacing, because a boundary that wiggled at a ten-kilometre wavelength would
// be a coastline rather than a plate margin.
func partition(m *Model, seed int64) []int16 {
p := m.P
circ := p.CircumferenceM()
spacing := spacingM(circ, p.HeightM(), m.Cfg.Count)
ws := noise.NewSource(seed, srcWarp)
// Two octaves. Both cell counts are whole numbers of the noise period, because noise.Lattice.Sample wraps
// modulo its own count and anything else is a discontinuity down one meridian.
coarse := int(p.NoisePeriodM/spacing + 0.5)
if coarse < 1 {
coarse = 1
}
fine := coarse * 3
wx := noise.NewLattice(coarse, ws)
wy := noise.NewLattice(coarse, ws)
fx := noise.NewLattice(fine, ws)
fy := noise.NewLattice(fine, ws)
amp := m.Cfg.WarpFraction * spacing
out := make([]int16, m.GW*m.GH)
for gy := 0; gy < m.GH; gy++ {
yM := m.GridYM(gy)
v := yM / p.NoisePeriodM * float64(coarse)
fv := yM / p.NoisePeriodM * float64(fine)
row := gy * m.GW
for gx := 0; gx < m.GW; gx++ {
xM := m.GridXM(gx)
u := xM / p.NoisePeriodM * float64(coarse)
fu := xM / p.NoisePeriodM * float64(fine)
qx := xM + ((float64(wx.Sample(u, v))*2-1)+(float64(fx.Sample(fu, fv))*2-1)*warpFineGain)*amp
qy := yM + ((float64(wy.Sample(u, v))*2-1)+(float64(fy.Sample(fu, fv))*2-1)*warpFineGain)*amp
best, bestID := math.Inf(1), 0
for i := range m.Plates {
pl := &m.Plates[i]
dx := wrapDelta(qx-pl.SiteXM, circ)
dy := qy - pl.SiteYM
d := math.Hypot(dx, dy) / pl.Weight
if d < best {
best, bestID = d, pl.ID
}
}
out[row+gx] = int16(bestID)
}
}
return out
}
// measure walks the partition once and fills in everything that can only be known after it exists: each
// plate's area, how much of it the author painted as land, and where its centre is.
//
// Painted rows only, for the land fraction. The polar pad is synthetic ocean that no class was ever painted
// on, so counting it would drag every plate that reaches a pole towards oceanic for a reason that is
// scaffolding rather than geography. The area and the centre do count the pad, because a plate really does
// extend over it.
//
// **The centre is a circular mean in X.** A plate painted or drawn across the meridian has cells at both ends
// of the raster, and an arithmetic mean of those columns puts its centre on the far side of the planet - and
// with it the pole the whole plate rotates about, which would make its velocity field nonsense and every
// margin around it wrong. This is the same rule as world.WrapX and Plate.VelocityAt's lever arm: the short
// way round is the only way round.
func (m *Model) measure(land func(xM, yM float64) bool) {
n := len(m.Plates)
landCells := make([]int, n)
paintedCells := make([]int, n)
sumSin := make([]float64, n)
sumCos := make([]float64, n)
sumY := make([]float64, n)
circ := m.P.CircumferenceM()
for gy := range m.GH {
yM := m.GridYM(gy)
painted := yM >= 0 && yM < m.P.HeightM()
row := gy * m.GW
for gx := range m.GW {
id := int(m.Cell[row+gx])
pl := &m.Plates[id]
pl.AreaCells++
xM := m.GridXM(gx)
ang := 2 * math.Pi * xM / circ
sumSin[id] += math.Sin(ang)
sumCos[id] += math.Cos(ang)
sumY[id] += yM
if !painted {
continue
}
paintedCells[id]++
if land != nil && land(xM, yM) {
landCells[id]++
}
}
}
for i := range m.Plates {
pl := &m.Plates[i]
if paintedCells[i] > 0 {
pl.LandFraction = float64(landCells[i]) / float64(paintedCells[i])
}
pl.Continental = pl.LandFraction >= m.Cfg.ContinentalFraction
if pl.AreaCells > 0 {
pl.CentroidXM, pl.CentroidYM = centroid(sumSin[i], sumCos[i], sumY[i], pl.AreaCells, circ)
}
// Every plate turns about its own centre of area. See giveMotion for why it is not somewhere else.
pl.PoleXM, pl.PoleYM = pl.CentroidXM, pl.CentroidYM
}
}
// centroid turns the accumulated sums into a position, taking X the short way round the cylinder.
func centroid(sumSin, sumCos, sumY float64, cells int, circ float64) (xM, yM float64) {
ang := math.Atan2(sumSin, sumCos)
if ang < 0 {
ang += 2 * math.Pi
}
return ang / (2 * math.Pi) * circ, sumY / float64(cells)
}
// wrapDelta brings a difference in X into -circ/2 .. +circ/2: the short way round the cylinder.
func wrapDelta(d, circ float64) float64 {
if circ <= 0 {
return d
}
d = math.Mod(d, circ)
if d > circ/2 {
d -= circ
} else if d < -circ/2 {
d += circ
}
return d
}