This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+485
View File
@@ -0,0 +1,485 @@
package main
// `mapart biomes` turns the two categorical maps of the planet into the smooth 0..1 masks the landscape's
// paint layers are built from: one greyscale PNG per biome, at the source's own resolution, already blurred.
//
// Why this is here and not in generate_region_tiles.py, which is what consumes it. The class source is the
// painting - 7738 x 3761 of RGB - and the engine's Python has no image library that can decode it: heightmap_io
// is greyscale-only and unfilters a byte at a time. The same constraint that made this tool Go in the first
// place. What it hands back is 8-bit greyscale, which is heightmap_io's fast path.
//
// Why blurred here rather than per tile. A biome boundary has to be a gradient or the ground has a drawn line
// on it, and a blur computed per tile is a blur that disagrees with itself across a tile seam unless every tile
// carries a margin the width of the blur - 200 vertices at 400 m and 2 m quads, a third more area on every one
// of ninety-eight tiles. Blurring once, globally, in the source's own pixels, makes the field smooth *before*
// anything samples it, so a tile can read it with plain bilinear interpolation at its global coordinates and
// two tiles agree at a shared vertex by construction. It is the same reasoning as sampling the height by global
// position, applied a step earlier.
//
// Both sources are read by identity in normalised u,v, which is measured rather than assumed: the painting is
// 7738 x 3761 and the heightmap is 8192 x 4096, and the two candidate registrations were tested against each
// other on land/sea agreement - identity scored 98.09% against 96.14% for the alternative, and won in every
// latitude band including the polar ones, which is where a vertical scale error shows first.
import (
"fmt"
"image"
"math"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// ---------------------------------------------------------------------------------------------------------
// The two legends
type legendClass struct {
Name string `json:"name"`
RGB []int `json:"rgb"`
Sea bool `json:"sea"`
}
type paintLegend struct {
Classes []legendClass `json:"classes"`
}
// koppenClass is one row of Tools/Orogen/js/koppen.js.
//
// Parsed out of the JavaScript rather than copied into a JSON beside it, because the browser twin is where that
// table is *used* and two copies of a palette is how one of them goes stale. The parse is strict and the caller
// checks the count: a table that has moved or been reformatted fails loudly here rather than silently matching
// every pixel to the wrong biome.
type koppenClass struct {
Code string
Name string
SRGB [3]uint8
}
// The colours in koppen.js are linear 0..1 and the browser writes them through an sRGB encode, which is why a
// naive read of the exported PNG matches nothing: the observed "ocean" is 147,177,211 where the table says
// 0.29,0.44,0.65. Encoding the table the same way reproduces every observed colour to within 1.4/255.
func linearToSRGB8(c float64) uint8 {
var s float64
if c <= 0.0031308 {
s = c * 12.92
} else {
s = 1.055*math.Pow(c, 1/2.4) - 0.055
}
return uint8(math.Round(math.Max(0, math.Min(1, s)) * 255))
}
var koppenRow = regexp.MustCompile(`\{\s*code:\s*'([^']+)'\s*,\s*name:\s*'([^']+)'\s*,\s*color:\s*\[([^\]]+)\]`)
func parseKoppen(path string) ([]koppenClass, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
matches := koppenRow.FindAllStringSubmatch(string(data), -1)
out := make([]koppenClass, 0, len(matches))
for _, m := range matches {
parts := strings.Split(m[3], ",")
if len(parts) != 3 {
continue
}
var rgb [3]uint8
ok := true
for i, p := range parts {
f, err := strconv.ParseFloat(strings.TrimSpace(p), 64)
if err != nil {
ok = false
break
}
rgb[i] = linearToSRGB8(f)
}
if ok {
out = append(out, koppenClass{Code: m[1], Name: m[2], SRGB: rgb})
}
}
if len(out) < 20 {
return nil, fmt.Errorf("%s: parsed only %d Koppen classes, expected about 31 - has the table been "+
"reformatted? Matching against a partial palette would put every unmatched pixel in the wrong biome", path, len(out))
}
return out, nil
}
// ---------------------------------------------------------------------------------------------------------
// Classification
// classify turns an image into a per-pixel index into `palette`, by exact match where possible and nearest
// colour otherwise. It returns how far the worst pixel had to travel: on the painting that is 0, because a
// painted map is made of the legend's own colours and nothing else, and a number above a few units means the
// image is a *render* of a classification rather than the classification itself - which is the difference
// between data and a picture of data, and the reason the Orogen class export is not used here.
func classify(im image.Image, palette [][3]uint8) ([]uint8, float64, float64, error) {
if len(palette) == 0 || len(palette) > 255 {
return nil, 0, 0, fmt.Errorf("classify: %d palette entries, need 1..255", len(palette))
}
read, err := rgbAccess(im)
if err != nil {
return nil, 0, 0, err
}
b := im.Bounds()
w, h := b.Dx(), b.Dy()
out := make([]uint8, w*h)
exact := map[[3]uint8]uint8{}
for i, p := range palette {
exact[p] = uint8(i)
}
// Cached per distinct colour, but `far` counts *pixels*: an image whose boundaries are anti-aliased has
// few distinct intermediate colours and a great many pixels wearing them, and it is the pixel count that
// says whether the classification can be trusted.
type match struct {
index uint8
far bool
}
worst := 0.0
far := 0
cache := map[[3]uint8]match{}
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
r, g, bl := read(b.Min.X+x, b.Min.Y+y)
key := [3]uint8{r, g, bl}
if idx, ok := exact[key]; ok {
out[y*w+x] = idx
continue
}
m, ok := cache[key]
if !ok {
best, bestD := uint8(0), math.MaxFloat64
for i, p := range palette {
d := sq(float64(r)-float64(p[0])) + sq(float64(g)-float64(p[1])) + sq(float64(bl)-float64(p[2]))
if d < bestD {
bestD, best = d, uint8(i)
}
}
d := math.Sqrt(bestD)
if d > worst {
worst = d
}
m = match{index: best, far: d > farThreshold}
cache[key] = m
}
if m.far {
far++
}
out[y*w+x] = m.index
}
}
return out, worst, 100 * float64(far) / float64(w*h), nil
}
// How far a colour may be from the nearest palette entry before it counts as "not really that class". Eight is
// well past the rounding a PNG encode can introduce and well short of the distance between two palette colours.
const farThreshold = 8.0
func sq(v float64) float64 { return v * v }
// ---------------------------------------------------------------------------------------------------------
// Masks
// mask builds the 0/1 indicator of a set of palette indices, then blurs it. Two box passes rather than one:
// a single box leaves visible straight edges where the kernel enters a blob, and two is a good enough triangle
// filter for ground that is about to be broken up by noise anyway.
//
// X wraps and Y clamps, because the source is a cylinder with no route over its poles - the same rule the map
// view's projection follows.
func mask(index []uint8, w, h int, want map[uint8]bool, radius int) []float32 {
field := make([]float32, w*h)
for i, v := range index {
if want[v] {
field[i] = 1
}
}
if radius < 1 {
return field
}
field = boxBlur(field, w, h, radius)
return boxBlur(field, w, h, radius)
}
func boxBlur(src []float32, w, h, radius int) []float32 {
tmp := make([]float32, w*h)
out := make([]float32, w*h)
window := float32(2*radius + 1)
// Horizontal, wrapping. A running sum, so the cost is per pixel and not per pixel per tap.
for y := 0; y < h; y++ {
row := src[y*w : (y+1)*w]
var sum float32
for k := -radius; k <= radius; k++ {
sum += row[((k%w)+w)%w]
}
dst := tmp[y*w : (y+1)*w]
for x := 0; x < w; x++ {
dst[x] = sum / window
sum -= row[(((x-radius)%w)+w)%w]
sum += row[(((x+radius+1)%w)+w)%w]
}
}
// Vertical, clamping.
at := func(x, y int) float32 {
if y < 0 {
y = 0
} else if y >= h {
y = h - 1
}
return tmp[y*w+x]
}
for x := 0; x < w; x++ {
var sum float32
for k := -radius; k <= radius; k++ {
sum += at(x, k)
}
for y := 0; y < h; y++ {
out[y*w+x] = sum / window
sum -= at(x, y-radius)
sum += at(x, y+radius+1)
}
}
return out
}
// ---------------------------------------------------------------------------------------------------------
// The command
type biomeLayer struct {
Name string `json:"name"`
Rule string `json:"rule"`
Classes []string `json:"classes"`
Koppen []string `json:"koppen"`
Enabled *bool `json:"enabled"`
}
type biomeConfig struct {
ClassImage string `json:"class_image"`
ClassLegend string `json:"class_legend"`
ClimateImage string `json:"climate_image"`
KoppenJS string `json:"koppen_js"`
MasksDir string `json:"masks_dir"`
BlendM float64 `json:"blend_m"`
}
type regionLayers struct {
Biomes biomeConfig `json:"biomes"`
Paint []biomeLayer `json:"paint"`
}
type regionWithLayers struct {
Region
Layers regionLayers `json:"layers"`
}
type maskReport struct {
Layer string `json:"layer"`
Rule string `json:"rule"`
Of string `json:"of"`
Output string `json:"output"`
Width int `json:"width"`
Height int `json:"height"`
RadiusPx int `json:"radius_px"`
CoverPct float64 `json:"cover_pct"`
Enabled bool `json:"enabled"`
}
type biomeReport struct {
When string `json:"when"`
BlendM float64 `json:"blend_m"`
ClassWorst float64 `json:"class_worst_colour_distance"`
ClassFarPct float64 `json:"class_unmatched_pct"`
ClimateWorst float64 `json:"climate_worst_colour_distance"`
ClimateFarPct float64 `json:"climate_unmatched_pct"`
Masks []maskReport `json:"masks"`
}
func biomes(root string) error {
regionPath := filepath.Join(root, "RawContent", "World", "Region.json")
var region regionWithLayers
if err := readJSON(regionPath, &region); err != nil {
return err
}
cfg := region.Layers.Biomes
if cfg.ClassImage == "" {
return fmt.Errorf("%s: layers.biomes has no class_image; nothing to classify", regionPath)
}
if cfg.MasksDir == "" {
cfg.MasksDir = "RawContent/World/Biomes"
}
if cfg.BlendM <= 0 {
cfg.BlendM = 400
}
outDir := filepath.Join(root, filepath.FromSlash(cfg.MasksDir))
if err := os.MkdirAll(outDir, 0o755); err != nil {
return err
}
rep := biomeReport{When: time.Now().UTC().Format(time.RFC3339), BlendM: cfg.BlendM}
worldW := region.widthM()
// --- the painted classes
var legend paintLegend
if err := readJSON(filepath.Join(root, filepath.FromSlash(cfg.ClassLegend)), &legend); err != nil {
return err
}
classNames := make([]string, len(legend.Classes))
classPalette := make([][3]uint8, len(legend.Classes))
for i, c := range legend.Classes {
if len(c.RGB) != 3 {
return fmt.Errorf("class %q has no rgb", c.Name)
}
classNames[i] = c.Name
classPalette[i] = [3]uint8{uint8(c.RGB[0]), uint8(c.RGB[1]), uint8(c.RGB[2])}
}
classIm, err := load(filepath.Join(root, filepath.FromSlash(cfg.ClassImage)))
if err != nil {
return err
}
classIndex, classWorst, classFar, err := classify(classIm, classPalette)
if err != nil {
return err
}
cw, ch := classIm.Bounds().Dx(), classIm.Bounds().Dy()
rep.ClassWorst, rep.ClassFarPct = classWorst, classFar
fmt.Printf("class %-38s %5dx%-5d %d classes, worst distance %.1f, %.3f%% of pixels unmatched\n",
filepath.Base(cfg.ClassImage), cw, ch, len(classNames), classWorst, classFar)
if classWorst > 8 {
fmt.Printf(" WARNING: a painted map is made of its legend's own colours, so this should be 0.\n")
fmt.Printf(" A number this size means the image is a *render* of a classification rather than the\n")
fmt.Printf(" classification itself, and every pixel is being snapped to whatever is nearest.\n")
}
// --- the Koppen climate, only if a layer asks for it
var climateIndex []uint8
var climateCodes []string
var clw, clh int
needsClimate := false
for _, l := range region.Layers.Paint {
if l.Rule == "climate" {
needsClimate = true
}
}
if needsClimate {
if cfg.ClimateImage == "" || cfg.KoppenJS == "" {
return fmt.Errorf("a layer has rule \"climate\" but layers.biomes has no climate_image/koppen_js")
}
kop, err := parseKoppen(filepath.Join(root, filepath.FromSlash(cfg.KoppenJS)))
if err != nil {
return err
}
climatePalette := make([][3]uint8, len(kop))
climateCodes = make([]string, len(kop))
for i, k := range kop {
climatePalette[i] = k.SRGB
climateCodes[i] = k.Code
}
climateIm, err := load(filepath.Join(root, filepath.FromSlash(cfg.ClimateImage)))
if err != nil {
return err
}
var worst, farPct float64
climateIndex, worst, farPct, err = classify(climateIm, climatePalette)
if err != nil {
return err
}
clw, clh = climateIm.Bounds().Dx(), climateIm.Bounds().Dy()
rep.ClimateWorst, rep.ClimateFarPct = worst, farPct
fmt.Printf("climate %-38s %5dx%-5d %d Koppen classes, worst distance %.1f, %.3f%% of pixels unmatched\n",
filepath.Base(cfg.ClimateImage), clw, clh, len(kop), worst, farPct)
// A render of a classification has anti-aliased boundaries, and a pixel halfway between two palette
// colours is snapped to whichever is nearer - arbitrary, but only ever a pixel or two wide, and the
// mask is blurred by tens of pixels afterwards. A large fraction would mean something else is wrong.
if farPct > 5 {
fmt.Printf(" WARNING: %.2f%% of the climate map is not close to any Koppen colour. Boundary\n", farPct)
fmt.Printf(" anti-aliasing accounts for a fraction of a per cent; this is too much for that.\n")
}
}
indexOf := func(names []string, want string) int {
for i, n := range names {
if n == want {
return i
}
}
return -1
}
for _, layer := range region.Layers.Paint {
if layer.Rule != "class" && layer.Rule != "climate" {
continue // slope, altitude, beach and remainder are derived per tile from the height
}
enabled := layer.Enabled == nil || *layer.Enabled
var index []uint8
var w, h int
var names, wanted []string
if layer.Rule == "class" {
index, w, h, names, wanted = classIndex, cw, ch, classNames, layer.Classes
} else {
index, w, h, names, wanted = climateIndex, clw, clh, climateCodes, layer.Koppen
}
want := map[uint8]bool{}
for _, n := range wanted {
i := indexOf(names, n)
if i < 0 {
return fmt.Errorf("layer %q asks for %q, which is not in the %s legend (%s)",
layer.Name, n, layer.Rule, strings.Join(names, ", "))
}
want[uint8(i)] = true
}
if len(want) == 0 {
return fmt.Errorf("layer %q has rule %q but names no classes", layer.Name, layer.Rule)
}
// The blur radius is in this image's own pixels, because the two sources are not the same resolution.
metresPerPx := worldW / float64(w)
radius := int(math.Round(cfg.BlendM / metresPerPx / 2))
field := mask(index, w, h, want, radius)
var cover float64
for _, v := range field {
cover += float64(v)
}
cover = 100 * cover / float64(len(field))
out := image.NewGray(image.Rect(0, 0, w, h))
for i, v := range field {
out.Pix[i] = uint8(math.Round(float64(clamp01(v)) * 255))
}
name := "mask_" + strings.ToLower(layer.Name) + ".png"
if err := writePNG(filepath.Join(outDir, name), out); err != nil {
return err
}
state := ""
if !enabled {
state = " (not enabled yet)"
}
fmt.Printf(" %-10s %-8s %-28s -> %-22s r=%3d px %5.2f%% cover%s\n",
layer.Name, layer.Rule, strings.Join(wanted, "+"), name, radius, cover, state)
rep.Masks = append(rep.Masks, maskReport{
Layer: layer.Name, Rule: layer.Rule, Of: strings.Join(wanted, "+"), Output: name,
Width: w, Height: h, RadiusPx: radius, CoverPct: cover, Enabled: enabled,
})
}
sort.Slice(rep.Masks, func(i, j int) bool { return rep.Masks[i].Layer < rep.Masks[j].Layer })
if err := writeJSON(filepath.Join(outDir, "biomes.json"), rep); err != nil {
return err
}
fmt.Printf("%d mask(s) into %s\n", len(rep.Masks), outDir)
return nil
}
func clamp01(v float32) float32 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
+696
View File
@@ -0,0 +1,696 @@
// Command mapart turns planet-wide images into the layers of the world map: it downsamples what is already
// coloured and renders shaded relief from the heightmap, writing PNGs that Scripts/Authoring/create_world_map.py
// imports as textures.
//
// go run ./Tools/MapArt build # write RawContent/World/MapArt/map_<id>.png for every layer
// go run ./Tools/MapArt check # land/sea agreement of every layer against the heightmap
//
// Why this is a Go tool and not part of the Python authoring set. The engine's Python has numpy and no PIL, and
// Scripts/Authoring/heightmap_io.py's PNG decoder is greyscale-only with a per-byte unfilter loop - fine for a
// 4081-square heightmap once, hopeless for 33 megapixels of RGB. Go's image/png does both in a few seconds.
//
// Why it is not part of Tools/Terrain. That is the generator: it decides what the ground IS. This decides what a
// picture of the ground LOOKS like, downstream of every decision the generator has already made, and it will grow
// the other way - towards compositing the overlay's marks, roads and labels onto a map sheet.
package main
import (
"encoding/json"
"fmt"
"image"
"image/color"
_ "image/jpeg"
"image/png"
"math"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// ---------------------------------------------------------------------------------------------------------
// Manifests
type Layer struct {
ID string `json:"id"`
Name string `json:"name"`
File string `json:"file"`
Render string `json:"render"`
Default bool `json:"default"`
Note string `json:"note"`
}
type Relief struct {
AzimuthDeg float64 `json:"light_azimuth_deg"`
AltitudeDeg float64 `json:"light_altitude_deg"`
Exaggeration float64 `json:"exaggeration"`
LandTopM *float64 `json:"land_top_m"`
ShadeStrength float64 `json:"shade_strength"`
}
type Manifest struct {
SourceDir string `json:"source_dir"`
OutputDir string `json:"output_dir"`
RegionPath string `json:"region"`
Output struct {
Width int `json:"width"`
Height int `json:"height"`
} `json:"output"`
Package string `json:"package"`
Definition string `json:"definition"`
Level string `json:"level"`
Layers []Layer `json:"layers"`
Relief Relief `json:"relief"`
}
// Region is the slice of RawContent/World/Region.json this tool needs. The world's size and the source's
// elevation ramp are the generator's numbers, not ours, so they are read rather than repeated.
type Region struct {
Tiles struct {
Columns int `json:"columns"`
Rows int `json:"rows"`
Vertices int `json:"vertices"`
} `json:"tiles"`
QuadCm float64 `json:"quad_cm"`
ElevationM MinMax `json:"elevation_m"`
SeaLevelM float64 `json:"sea_level_m"`
Source struct {
Path string `json:"path"`
ElevationM MinMax `json:"elevation_m"`
SeaScale float64 `json:"sea_scale"`
Window struct {
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
} `json:"window"`
} `json:"source"`
}
type MinMax struct {
Min float64 `json:"min"`
Max float64 `json:"max"`
}
func (r Region) quadsX() int { return (r.Tiles.Vertices - 1) * r.Tiles.Columns }
func (r Region) quadsY() int { return (r.Tiles.Vertices - 1) * r.Tiles.Rows }
func (r Region) widthM() float64 { return float64(r.quadsX()) * r.QuadCm / 100 }
func (r Region) heightM() float64 { return float64(r.quadsY()) * r.QuadCm / 100 }
// sourceMetres turns a raw 16-bit sample into metres the way create_region_world.py does: the source's own ramp,
// then sea_scale on everything below sea level. Anything that reads a height here must agree with the landscape
// or the map and the ground tell different stories about the same place.
func (r Region) sourceMetres(v uint16) float64 {
e := r.Source.ElevationM
m := e.Min + float64(v)/65535.0*(e.Max-e.Min)
if m < 0 {
m *= r.Source.SeaScale
}
return m
}
// ---------------------------------------------------------------------------------------------------------
// sRGB. Averaging encoded sRGB darkens a downsample; these two tables are the whole fix and cost nothing.
var srgbToLinear [256]float32
var linearToSrgb [4096]uint8
func init() {
for i := 0; i < 256; i++ {
c := float64(i) / 255
if c <= 0.04045 {
srgbToLinear[i] = float32(c / 12.92)
} else {
srgbToLinear[i] = float32(math.Pow((c+0.055)/1.055, 2.4))
}
}
for i := range linearToSrgb {
c := float64(i) / float64(len(linearToSrgb)-1)
var s float64
if c <= 0.0031308 {
s = c * 12.92
} else {
s = 1.055*math.Pow(c, 1/2.4) - 0.055
}
linearToSrgb[i] = uint8(math.Round(s * 255))
}
}
func encodeSrgb(linear float32) uint8 {
if linear <= 0 {
return 0
}
if linear >= 1 {
return 255
}
return linearToSrgb[int(linear*float32(len(linearToSrgb)-1)+0.5)]
}
// ---------------------------------------------------------------------------------------------------------
// Image access. The type switch is the point: At() through the image.Image interface costs an interface call and
// a colour conversion per pixel, which over 33 megapixels is the difference between seconds and minutes.
type rgbReader func(x, y int) (r, g, b uint8)
func rgbAccess(im image.Image) (rgbReader, error) {
switch src := im.(type) {
case *image.NRGBA:
return func(x, y int) (uint8, uint8, uint8) {
i := src.PixOffset(x, y)
return src.Pix[i], src.Pix[i+1], src.Pix[i+2]
}, nil
case *image.RGBA: // premultiplied; opaque map art, so the difference never shows, but be honest about alpha
return func(x, y int) (uint8, uint8, uint8) {
i := src.PixOffset(x, y)
a := src.Pix[i+3]
if a == 0 || a == 255 {
return src.Pix[i], src.Pix[i+1], src.Pix[i+2]
}
un := func(c uint8) uint8 { return uint8(int(c) * 255 / int(a)) }
return un(src.Pix[i]), un(src.Pix[i+1]), un(src.Pix[i+2])
}, nil
case *image.YCbCr: // the .jpg templates
return func(x, y int) (uint8, uint8, uint8) {
return color.YCbCrToRGB(src.Y[src.YOffset(x, y)], src.Cb[src.COffset(x, y)], src.Cr[src.COffset(x, y)])
}, nil
case *image.Gray:
return func(x, y int) (uint8, uint8, uint8) {
v := src.Pix[src.PixOffset(x, y)]
return v, v, v
}, nil
case *image.Gray16:
return func(x, y int) (uint8, uint8, uint8) {
v := src.Pix[src.PixOffset(x, y)]
return v, v, v
}, nil
case *image.Paletted:
return func(x, y int) (uint8, uint8, uint8) {
r, g, b, _ := src.Palette[src.Pix[src.PixOffset(x, y)]].RGBA()
return uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)
}, nil
}
return nil, fmt.Errorf("unsupported image type %T", im)
}
// grey16Access reads the 16-bit sample a heightmap carries. A heightmap that came back 8-bit is refused rather
// than stretched: 256 levels over 11 km is 43 m a step, and a relief map built from that is terracing, not terrain.
func grey16Access(im image.Image) (func(x, y int) uint16, error) {
switch src := im.(type) {
case *image.Gray16:
return func(x, y int) uint16 {
i := src.PixOffset(x, y)
return uint16(src.Pix[i])<<8 | uint16(src.Pix[i+1])
}, nil
case *image.NRGBA64:
return func(x, y int) uint16 {
i := src.PixOffset(x, y)
return uint16(src.Pix[i])<<8 | uint16(src.Pix[i+1])
}, nil
}
return nil, fmt.Errorf("heightmap is %T, not 16-bit greyscale", im)
}
func load(path string) (image.Image, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
im, _, err := image.Decode(f)
return im, err
}
// ---------------------------------------------------------------------------------------------------------
// Downsampling. One pass over the source accumulating into output bins: an exact box filter when the ratio is a
// whole number, which it is for 8192 -> 4096, and a reasonable one when it is not.
func downsampleRGB(im image.Image, outW, outH int) (*image.RGBA, error) {
read, err := rgbAccess(im)
if err != nil {
return nil, err
}
b := im.Bounds()
srcW, srcH := b.Dx(), b.Dy()
sums := make([]float32, outW*outH*3)
counts := make([]uint32, outW*outH)
for y := 0; y < srcH; y++ {
oy := y * outH / srcH
for x := 0; x < srcW; x++ {
ox := x * outW / srcW
r, g, bl := read(b.Min.X+x, b.Min.Y+y)
i := oy*outW + ox
sums[i*3+0] += srgbToLinear[r]
sums[i*3+1] += srgbToLinear[g]
sums[i*3+2] += srgbToLinear[bl]
counts[i]++
}
}
out := image.NewRGBA(image.Rect(0, 0, outW, outH))
for i := 0; i < outW*outH; i++ {
n := float32(counts[i])
if n == 0 {
n = 1
}
out.Pix[i*4+0] = encodeSrgb(sums[i*3+0] / n)
out.Pix[i*4+1] = encodeSrgb(sums[i*3+1] / n)
out.Pix[i*4+2] = encodeSrgb(sums[i*3+2] / n)
out.Pix[i*4+3] = 255
}
return out, nil
}
// downsampleHeights averages in metres, not in sample values, because sea_scale makes the two different curves.
func downsampleHeights(im image.Image, outW, outH int, region Region) ([]float32, error) {
read, err := grey16Access(im)
if err != nil {
return nil, err
}
b := im.Bounds()
srcW, srcH := b.Dx(), b.Dy()
var table [65536]float32 // one lookup beats a branch and two multiplies per source pixel
for v := 0; v < 65536; v++ {
table[v] = float32(region.sourceMetres(uint16(v)))
}
sums := make([]float32, outW*outH)
counts := make([]uint32, outW*outH)
for y := 0; y < srcH; y++ {
oy := y * outH / srcH
for x := 0; x < srcW; x++ {
ox := x * outW / srcW
i := oy*outW + ox
sums[i] += table[read(b.Min.X+x, b.Min.Y+y)]
counts[i]++
}
}
for i := range sums {
if counts[i] > 0 {
sums[i] /= float32(counts[i])
}
}
return sums, nil
}
// ---------------------------------------------------------------------------------------------------------
// The relief render.
type stop struct {
at float64
r, g, b float64
}
// Hypsometric, the convention: green lowland through tan and brown to rock and snow. Read as fractions of the
// land's own top, so it says nothing about absolute height - which is the honest thing, because Orogen's metres
// are art (Region.json says so) and a ramp keyed to real metres would lie with more conviction.
var landRamp = []stop{
{0.00, 78, 116, 68},
{0.12, 108, 138, 76},
{0.30, 158, 158, 94},
{0.50, 168, 134, 92},
{0.70, 146, 118, 106},
{0.88, 186, 186, 190},
{1.00, 250, 250, 252},
}
// By depth, shallow to abyss. The shelf is the light band; it is where the coast pass does its work and it should
// be visible as a band rather than melting into the deep.
var seaRamp = []stop{
{0.00, 122, 174, 200},
{0.10, 86, 144, 186},
{0.35, 48, 100, 152},
{1.00, 16, 38, 78},
}
func sample(ramp []stop, t float64) (float64, float64, float64) {
if t <= ramp[0].at {
return ramp[0].r, ramp[0].g, ramp[0].b
}
for i := 1; i < len(ramp); i++ {
if t <= ramp[i].at {
a, b := ramp[i-1], ramp[i]
f := (t - a.at) / (b.at - a.at)
return a.r + (b.r-a.r)*f, a.g + (b.g-a.g)*f, a.b + (b.b-a.b)*f
}
}
last := ramp[len(ramp)-1]
return last.r, last.g, last.b
}
func renderRelief(heights []float32, w, h int, cellM float64, cfg Relief, sea float64) (*image.RGBA, float64, float64) {
// The ramp's ceiling. The 99.5th percentile rather than the maximum, so one summit cannot flatten the tint
// over a whole continent - the same reasoning as the terrain tool's palette.land_top_m.
landTop := 0.0
if cfg.LandTopM != nil {
landTop = *cfg.LandTopM
} else {
land := make([]float32, 0, len(heights)/2)
for _, m := range heights {
if float64(m) > sea {
land = append(land, m)
}
}
if len(land) > 0 {
sort.Slice(land, func(i, j int) bool { return land[i] < land[j] })
landTop = float64(land[int(float64(len(land)-1)*0.995)])
}
}
if landTop <= sea {
landTop = sea + 1
}
deepest := 0.0
for _, m := range heights {
if float64(m) < deepest {
deepest = float64(m)
}
}
if deepest >= 0 {
deepest = -1
}
az := cfg.AzimuthDeg * math.Pi / 180
zen := (90 - cfg.AltitudeDeg) * math.Pi / 180
cosZen, sinZen := math.Cos(zen), math.Sin(zen)
at := func(x, y int) float64 {
if y < 0 {
y = 0
} else if y >= h {
y = h - 1
}
x = ((x % w) + w) % w // the map is a cylinder: the seam column is lit by its true neighbour
return float64(heights[y*w+x])
}
out := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
m := float64(heights[y*w+x])
var r, g, b float64
shaded := false
if m > sea {
r, g, b = sample(landRamp, (m-sea)/(landTop-sea))
shaded = true
} else {
r, g, b = sample(seaRamp, m/deepest)
}
if shaded {
// Horn's 3x3 slope and aspect, then the standard hillshade. Exaggerated, because a few hundred
// metres of relief over 17 m pixels is under two degrees and an honest shade of it is flat grey.
a, bb, c := at(x-1, y-1), at(x, y-1), at(x+1, y-1)
d, _, f := at(x-1, y), at(x, y), at(x+1, y)
gg, hh, ii := at(x-1, y+1), at(x, y+1), at(x+1, y+1)
dzdx := ((c + 2*f + ii) - (a + 2*d + gg)) / (8 * cellM) * cfg.Exaggeration
dzdy := ((gg + 2*hh + ii) - (a + 2*bb + c)) / (8 * cellM) * cfg.Exaggeration
slope := math.Atan(math.Hypot(dzdx, dzdy))
aspect := math.Atan2(dzdy, -dzdx)
shade := cosZen*math.Cos(slope) + sinZen*math.Sin(slope)*math.Cos(az-aspect)
if shade < 0 {
shade = 0
}
// 0.5 is neutral, so flat ground keeps the tint it was given and only slopes move.
factor := 1 + cfg.ShadeStrength*(2*shade-1)
r, g, b = r*factor, g*factor, b*factor
}
i := (y*w + x) * 4
out.Pix[i+0] = clamp8(r)
out.Pix[i+1] = clamp8(g)
out.Pix[i+2] = clamp8(b)
out.Pix[i+3] = 255
}
}
return out, landTop, deepest
}
func clamp8(v float64) uint8 {
if v <= 0 {
return 0
}
if v >= 255 {
return 255
}
return uint8(v + 0.5)
}
// ---------------------------------------------------------------------------------------------------------
type layerReport struct {
ID string `json:"id"`
Name string `json:"name"`
Source string `json:"source"`
Output string `json:"output"`
Render string `json:"render"`
Default bool `json:"default"`
SourceW int `json:"source_width"`
SourceH int `json:"source_height"`
LandTopM float64 `json:"land_top_m,omitempty"`
DeepestM float64 `json:"deepest_m,omitempty"`
AgreePct float64 `json:"land_sea_agreement_pct,omitempty"`
Seconds float64 `json:"seconds"`
}
type report struct {
When string `json:"when"`
Manifest string `json:"manifest"`
Region string `json:"region"`
WorldWidthM float64 `json:"world_width_m"`
WorldHeightM float64 `json:"world_height_m"`
MetresPerPx float64 `json:"metres_per_pixel"`
Output [2]int `json:"output"`
Layers []layerReport `json:"layers"`
}
func main() {
command := "build"
if len(os.Args) > 1 {
command = os.Args[1]
}
root, err := repoRoot()
must(err)
// The biome masks read Region.json alone - they are about what the ground is made of, not about the map's
// art - so they run before layers.json is even opened.
if command == "biomes" {
must(biomes(root))
return
}
// The substances read RawContent/Terrain/ground.json and nothing else: they are what the ground is made
// of rather than what a picture of it looks like.
if command == "substances" {
must(substances(root))
return
}
manifestPath := filepath.Join(root, "RawContent", "World", "MapArt", "layers.json")
var man Manifest
must(readJSON(manifestPath, &man))
var region Region
regionPath := filepath.Join(root, filepath.FromSlash(man.RegionPath))
must(readJSON(regionPath, &region))
outDir := filepath.Join(root, filepath.FromSlash(man.OutputDir))
srcDir := filepath.Join(root, filepath.FromSlash(man.SourceDir))
must(os.MkdirAll(outDir, 0o755))
metresPerPx := region.widthM() / float64(man.Output.Width)
fmt.Printf("world %.2f x %.2f km, %d x %d output, %.2f m a pixel\n",
region.widthM()/1000, region.heightM()/1000, man.Output.Width, man.Output.Height, metresPerPx)
if ratio := region.widthM() / region.heightM(); math.Abs(ratio-float64(man.Output.Width)/float64(man.Output.Height)) > 0.01 {
fmt.Printf("WARNING: the world is %.3f:1 and the output is %.3f:1, so the map is stretched\n",
ratio, float64(man.Output.Width)/float64(man.Output.Height))
}
switch command {
case "build":
build(man, region, srcDir, outDir, regionPath, metresPerPx)
case "check":
check(man, region, srcDir)
default:
fmt.Fprintf(os.Stderr, "usage: mapart [build|check|biomes]\n\n"+
" build render the world map's layers from the planet images\n"+
" check land/sea agreement of every map layer against the heightmap\n"+
" biomes the landscape's biome masks, from the painting and the Koppen climate\n")
os.Exit(2)
}
}
func build(man Manifest, region Region, srcDir, outDir, regionPath string, metresPerPx float64) {
rep := report{
When: time.Now().UTC().Format(time.RFC3339),
Manifest: man.RegionPath,
Region: regionPath,
WorldWidthM: region.widthM(),
WorldHeightM: region.heightM(),
MetresPerPx: metresPerPx,
Output: [2]int{man.Output.Width, man.Output.Height},
}
for _, layer := range man.Layers {
started := time.Now()
srcPath := filepath.Join(srcDir, layer.File)
im, err := load(srcPath)
must(err)
b := im.Bounds()
entry := layerReport{
ID: layer.ID, Name: layer.Name, Source: layer.File, Render: layer.Render,
Default: layer.Default, SourceW: b.Dx(), SourceH: b.Dy(),
}
var out *image.RGBA
switch layer.Render {
case "copy", "":
out, err = downsampleRGB(im, man.Output.Width, man.Output.Height)
must(err)
case "relief":
heights, err := downsampleHeights(im, man.Output.Width, man.Output.Height, region)
must(err)
var top, deep float64
out, top, deep = renderRelief(heights, man.Output.Width, man.Output.Height, metresPerPx, man.Relief, region.SeaLevelM)
entry.LandTopM, entry.DeepestM = top, deep
default:
must(fmt.Errorf("layer %q: unknown render %q", layer.ID, layer.Render))
}
outPath := filepath.Join(outDir, "map_"+layer.ID+".png")
must(writePNG(outPath, out))
entry.Output = "map_" + layer.ID + ".png"
entry.Seconds = time.Since(started).Seconds()
extra := ""
if layer.Render == "relief" {
extra = fmt.Sprintf(" land tops at %.0f m, deepest %.0f m", entry.LandTopM, entry.DeepestM)
}
fmt.Printf(" %-10s %5dx%-5d -> %s %.1fs%s\n", layer.ID, b.Dx(), b.Dy(), entry.Output, entry.Seconds, extra)
rep.Layers = append(rep.Layers, entry)
}
must(writeJSON(filepath.Join(outDir, "mapart.json"), rep))
fmt.Printf("%d layers into %s\n", len(rep.Layers), outDir)
}
// check is the guard against the one failure this pipeline cannot see: a layer of a different planet. Nothing in
// a PNG says which world it is, and every layer here is a different render of the same one, so the test is not a
// hash but agreement - does this image call the sea the sea where the heightmap does.
func check(man Manifest, region Region, srcDir string) {
var heightLayer *Layer
for i := range man.Layers {
if man.Layers[i].Render == "relief" {
heightLayer = &man.Layers[i]
break
}
}
if heightLayer == nil {
must(fmt.Errorf("no layer with render \"relief\", so there is no heightmap to check against"))
}
hm, err := load(filepath.Join(srcDir, heightLayer.File))
must(err)
readH, err := grey16Access(hm)
must(err)
hb := hm.Bounds()
fmt.Printf("checking against %s\n", heightLayer.File)
worst := 100.0
for _, layer := range man.Layers {
if layer.Render == "relief" {
continue
}
im, err := load(filepath.Join(srcDir, layer.File))
must(err)
read, err := rgbAccess(im)
must(err)
b := im.Bounds()
agree, total := 0, 0
for y := 8; y < hb.Dy(); y += 16 {
for x := 8; x < hb.Dx(); x += 16 {
isSea := region.sourceMetres(readH(hb.Min.X+x, hb.Min.Y+y)) <= region.SeaLevelM
// Scaled by fraction, so a layer at a different resolution still lines up.
lx := b.Min.X + x*b.Dx()/hb.Dx()
ly := b.Min.Y + y*b.Dy()/hb.Dy()
r, g, bl := read(lx, ly)
looksSea := int(bl) > int(r)+8 && int(bl) > int(g)+4
total++
if looksSea == isSea {
agree++
}
}
}
pct := 100 * float64(agree) / float64(total)
if pct < worst {
worst = pct
}
verdict := "same planet"
if pct < 85 {
verdict = "SUSPECT - check this is the same planet, at the same longitude origin"
}
fmt.Printf(" %-10s %5.2f%% land/sea agreement %s\n", layer.ID, pct, verdict)
}
fmt.Printf("worst %.2f%%\n", worst)
fmt.Println("A layer of ice or heavy cloud scores lower without being wrong; the test catches a different")
fmt.Println("planet or a shifted seam, not a few per cent. Look at the map if a number surprises you.")
}
// ---------------------------------------------------------------------------------------------------------
func repoRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
if _, err := os.Stat(filepath.Join(dir, "Salty.uproject")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("no Salty.uproject above %s; run this from inside the project", dir)
}
dir = parent
}
}
func readJSON(path string, into any) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
if err := json.Unmarshal(data, into); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
return nil
}
func writeJSON(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(data, '\n'), 0o644)
}
func writePNG(path string, im image.Image) error {
f, err := os.Create(path)
if err != nil {
return err
}
enc := png.Encoder{CompressionLevel: png.DefaultCompression}
if err := enc.Encode(f, im); err != nil {
f.Close()
return err
}
return f.Close()
}
func must(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "mapart: %v\n", strings.TrimSpace(err.Error()))
os.Exit(1)
}
}
+324
View File
@@ -0,0 +1,324 @@
package main
// `mapart substances` turns the raw Fab/Quixel downloads into the handful of maps the landscape actually
// samples, at the resolution it actually needs.
//
// Why it exists. A downloaded set is nine maps at 4K - AO, BaseColor, Bump, Cavity, Displacement, Gloss,
// Normal, Roughness, Specular - about 110 MB a set, and the landscape material samples three of them. Worse,
// a UTexture2D keeps its *source* inside the uasset, so importing 4K would put hundreds of megabytes through
// LFS to render ground that is almost always seen at grazing distance: the scans are 2 m across, so 4K is
// 2048 pixels per metre. This writes 2K, three maps, and nothing else.
//
// Normals are downsampled and then **renormalised**. Averaging four unit vectors gives a shorter one, and a
// normal map whose vectors are not unit length lights slightly flat - not obviously wrong, just quietly
// duller everywhere, which is the kind of thing nobody finds later.
//
// The physical size comes out of the set's own metadata rather than being typed here. It is what the material
// needs to tile the texture life-size, and a number copied by hand is a number that goes stale when somebody
// swaps a substance for one scanned at a different scale.
import (
"fmt"
"image"
"image/jpeg"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type substanceSet struct {
Name string `json:"name"`
Folder string `json:"folder"`
Layer string `json:"layer"`
PhysicalM float64 `json:"physical_m"` // 0 = read it from the set's metadata
Note string `json:"note"`
}
type substanceConfig struct {
SourceDir string `json:"source_dir"`
OutputDir string `json:"output_dir"`
Resolution int `json:"resolution"`
Maps []string `json:"maps"`
JpegQuality int `json:"jpeg_quality"`
Sets []substanceSet `json:"sets"`
}
type groundManifest struct {
Substances substanceConfig `json:"substances"`
}
// The per-asset metadata Quixel ships beside the maps. Only two fields matter here.
type quixelMeta struct {
ID string `json:"id"`
Maps []struct {
Name string `json:"name"`
Type string `json:"type"`
PhysicalSize string `json:"physicalSize"`
Resolution string `json:"resolution"`
MimeType string `json:"mimeType"`
} `json:"maps"`
}
type substanceReport struct {
Name string `json:"name"`
Layer string `json:"layer"`
Source string `json:"source"`
PhysicalM float64 `json:"physical_m"`
Written []string `json:"written"`
FromPx int `json:"from_px"`
ToPx int `json:"to_px"`
Seconds float64 `json:"seconds"`
}
func substances(root string) error {
groundPath := filepath.Join(root, "RawContent", "Terrain", "ground.json")
var ground groundManifest
if err := readJSON(groundPath, &ground); err != nil {
return err
}
cfg := ground.Substances
if len(cfg.Sets) == 0 {
return fmt.Errorf("%s: no substances.sets, nothing to extract", groundPath)
}
if cfg.Resolution <= 0 {
cfg.Resolution = 2048
}
if cfg.JpegQuality <= 0 {
cfg.JpegQuality = 92
}
if len(cfg.Maps) == 0 {
cfg.Maps = []string{"BaseColor", "Normal", "Roughness"}
}
srcRoot := filepath.Join(root, filepath.FromSlash(cfg.SourceDir))
outDir := filepath.Join(root, filepath.FromSlash(cfg.OutputDir))
if err := os.MkdirAll(outDir, 0o755); err != nil {
return err
}
fmt.Printf("substances: %d set(s) -> %d px, maps %s\n", len(cfg.Sets), cfg.Resolution, strings.Join(cfg.Maps, ", "))
reports := make([]substanceReport, 0, len(cfg.Sets))
for _, set := range cfg.Sets {
started := time.Now()
dir := filepath.Join(srcRoot, filepath.FromSlash(set.Folder))
extracted, err := findExtracted(dir)
if err != nil {
return fmt.Errorf("substance %q: %w", set.Name, err)
}
physical := set.PhysicalM
if physical <= 0 {
physical, err = physicalSize(extracted)
if err != nil {
return fmt.Errorf("substance %q: %w", set.Name, err)
}
}
rep := substanceReport{Name: set.Name, Layer: set.Layer, Source: set.Folder, PhysicalM: physical, ToPx: cfg.Resolution}
for _, kind := range cfg.Maps {
srcFile, err := findMap(extracted, kind)
if err != nil {
return fmt.Errorf("substance %q: %w", set.Name, err)
}
im, err := load(srcFile)
if err != nil {
return fmt.Errorf("%s: %w", srcFile, err)
}
rep.FromPx = im.Bounds().Dx()
isNormal := strings.EqualFold(kind, "Normal")
out, err := downsampleSurface(im, cfg.Resolution, isNormal)
if err != nil {
return fmt.Errorf("%s: %w", srcFile, err)
}
// Normals as PNG: the source is already JPEG and a second lossy pass on a vector field shows up as
// blocky lighting on flat ground. Colour and roughness stay JPEG, where it does not.
name := fmt.Sprintf("T_%s_%s", set.Name, kind)
var written string
if isNormal {
written = name + ".png"
err = writePNG(filepath.Join(outDir, written), out)
} else {
written = name + ".jpg"
err = writeJPEG(filepath.Join(outDir, written), out, cfg.JpegQuality)
}
if err != nil {
return err
}
rep.Written = append(rep.Written, written)
}
rep.Seconds = time.Since(started).Seconds()
fmt.Printf(" %-26s %-11s %4d -> %4d px, %.2f m scan, %d map(s), %.1fs\n",
set.Name, set.Layer, rep.FromPx, rep.ToPx, rep.PhysicalM, len(rep.Written), rep.Seconds)
reports = append(reports, rep)
}
if err := writeJSON(filepath.Join(outDir, "substances.json"), map[string]any{
"when": time.Now().UTC().Format(time.RFC3339),
"resolution": cfg.Resolution,
"maps": cfg.Maps,
"sets": reports,
}); err != nil {
return err
}
fmt.Printf("%d substance(s) into %s\n", len(reports), outDir)
return nil
}
// findExtracted locates the ..._extracted folder inside a Fab download, whose name nobody chose and which is
// the only place the maps and the metadata actually live.
func findExtracted(dir string) (string, error) {
var found string
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() && strings.HasSuffix(info.Name(), "_extracted") {
found = path
}
return nil
})
if err != nil {
return "", err
}
if found == "" {
return "", fmt.Errorf("no ..._extracted folder under %s - is this a Fab texture-set download?", dir)
}
return found, nil
}
func physicalSize(extracted string) (float64, error) {
entries, err := os.ReadDir(extracted)
if err != nil {
return 0, err
}
for _, e := range entries {
if !strings.EqualFold(filepath.Ext(e.Name()), ".json") {
continue
}
var meta quixelMeta
if err := readJSON(filepath.Join(extracted, e.Name()), &meta); err != nil {
continue
}
for _, m := range meta.Maps {
// "2x2" metres. Square scans only; a non-square one would need two numbers and the material
// would need to know about both, so it is refused rather than silently halved.
if m.PhysicalSize == "" {
continue
}
parts := strings.Split(strings.ToLower(m.PhysicalSize), "x")
if len(parts) != 2 {
continue
}
w, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
h, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
if err1 != nil || err2 != nil || w <= 0 {
continue
}
if math.Abs(w-h) > 1e-6 {
return 0, fmt.Errorf("scan is %s m, not square; the landscape material tiles with one number", m.PhysicalSize)
}
return w, nil
}
}
return 0, fmt.Errorf("no physicalSize in the metadata under %s; set physical_m in ground.json instead", extracted)
}
func findMap(extracted, kind string) (string, error) {
entries, err := os.ReadDir(extracted)
if err != nil {
return "", err
}
want := "_" + strings.ToLower(kind) + "."
var best string
for _, e := range entries {
name := strings.ToLower(e.Name())
if strings.Contains(name, want) && (strings.HasSuffix(name, ".jpg") || strings.HasSuffix(name, ".png")) {
best = filepath.Join(extracted, e.Name())
}
}
if best == "" {
return "", fmt.Errorf("no %s map in %s", kind, extracted)
}
return best, nil
}
// downsampleSurface box-filters to `size` square. Colour is averaged in linear light like the map art;
// a normal map is averaged as a vector and renormalised, which is the whole reason this is not one function.
func downsampleSurface(im image.Image, size int, isNormal bool) (*image.RGBA, error) {
read, err := rgbAccess(im)
if err != nil {
return nil, err
}
b := im.Bounds()
w, h := b.Dx(), b.Dy()
if size > w {
size = w
}
sums := make([]float64, size*size*3)
counts := make([]uint32, size*size)
for y := 0; y < h; y++ {
oy := y * size / h
for x := 0; x < w; x++ {
ox := x * size / w
r, g, bl := read(b.Min.X+x, b.Min.Y+y)
i := oy*size + ox
if isNormal {
// To -1..1 before averaging: the midpoint of two opposite normals is flat, and the midpoint
// of their 0..1 encodings is flat too, but only if the average happens in the signed space.
sums[i*3+0] += float64(r)/127.5 - 1
sums[i*3+1] += float64(g)/127.5 - 1
sums[i*3+2] += float64(bl)/127.5 - 1
} else {
sums[i*3+0] += float64(srgbToLinear[r])
sums[i*3+1] += float64(srgbToLinear[g])
sums[i*3+2] += float64(srgbToLinear[bl])
}
counts[i]++
}
}
out := image.NewRGBA(image.Rect(0, 0, size, size))
for i := 0; i < size*size; i++ {
n := float64(counts[i])
if n == 0 {
n = 1
}
if isNormal {
x, y, z := sums[i*3+0]/n, sums[i*3+1]/n, sums[i*3+2]/n
length := math.Sqrt(x*x + y*y + z*z)
if length < 1e-9 {
x, y, z, length = 0, 0, 1, 1
}
x, y, z = x/length, y/length, z/length
out.Pix[i*4+0] = encodeUnit(x)
out.Pix[i*4+1] = encodeUnit(y)
out.Pix[i*4+2] = encodeUnit(z)
} else {
out.Pix[i*4+0] = encodeSrgb(float32(sums[i*3+0] / n))
out.Pix[i*4+1] = encodeSrgb(float32(sums[i*3+1] / n))
out.Pix[i*4+2] = encodeSrgb(float32(sums[i*3+2] / n))
}
out.Pix[i*4+3] = 255
}
return out, nil
}
func encodeUnit(v float64) uint8 {
return uint8(math.Round(math.Max(0, math.Min(255, (v+1)*127.5))))
}
func writeJPEG(path string, im image.Image, quality int) error {
f, err := os.Create(path)
if err != nil {
return err
}
if err := jpeg.Encode(f, im, &jpeg.Options{Quality: quality}); err != nil {
f.Close()
return err
}
return f.Close()
}
+42
View File
@@ -0,0 +1,42 @@
name: Deploy to GitHub Pages
on:
release:
types: [published]
concurrency:
group: "pages"
cancel-in-progress: false
permissions:
contents: read
pages: write
id-token: write
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Delete old artifacts
uses: geekyeggo/delete-artifact@v5
with:
name: github-pages
failOnError: false
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: '.'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+6
View File
@@ -0,0 +1,6 @@
.claude/settings.local.json
node_modules/
package.json
package-lock.json
tuning/screenshots/
tuning/results/*.json
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Page Not Found — World Orogen</title>
<meta name="robots" content="noindex">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #030308; color: #fff;
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; text-align: center; padding: 20px;
}
.card { max-width: 480px; }
h1 { font-size: 72px; margin-bottom: 8px; }
h2 { font-size: 22px; font-weight: 600; margin-bottom: 12px; color: #ccd; }
p { color: #889; margin-bottom: 24px; line-height: 1.6; }
a {
display: inline-block; padding: 12px 28px;
background: rgba(80, 140, 255, 0.15); color: #7ab;
border: 1px solid rgba(80, 140, 255, 0.3); border-radius: 8px;
text-decoration: none; font-weight: 500; transition: background 0.2s;
}
a:hover { background: rgba(80, 140, 255, 0.25); }
</style>
</head>
<body>
<div class="card">
<h1>404</h1>
<h2>This planet doesn't exist yet</h2>
<p>The page you're looking for wasn't found. Head back to World Orogen and generate a new world instead.</p>
<a href="/">Build a New World</a>
</div>
</body>
</html>
+162
View File
@@ -0,0 +1,162 @@
# CLAUDE.md
## Project Overview
World Orogen — a browser-based procedural planet generator using Three.js and ES modules with no build step.
**World Orogen is concept art for planets, not a geophysical simulator.** Every feature should prioritize making the output *look* more believable or helping users iterate faster. Never slow down generation to chase physical accuracy — if a simpler approximation looks just as good, use it. However, the scientific grounding is what makes the output convincing: tectonic models inspired by real geology, pressure-driven wind patterns, and Köppen classification aren't optional polish — they're the reason the output passes the glance test. Preserve and extend this scientific foundation whenever it serves the visuals. The tool's job is to be the fastest path from a blank page to a world worth building on.
## Guiding Principles
All three tenets should be considered simultaneously. When they conflict, break ties in this order:
1. **Artistic appeal** — The output should look visually interesting and compelling, informed by real science but not constrained by it. Aesthetics come first.
2. **Ease of use and efficiency** — The interface should be approachable and intuitive. Generation should be fast. Don't sacrifice usability for realism.
3. **Scientific plausibility** — Terrain, tectonics, and geology should be grounded in real planetary science. Results don't need to be physically accurate simulations, but they should be believable.
## What Users Love (Protect These)
User feedback consistently highlights these as World Orogen's core strengths. Any change should preserve or enhance them — never degrade them as a side effect.
1. **Climate simulation depth** — The climate view (wind, ocean currents, precipitation, Köppen) is the single most-cited differentiator. Users call it "the only map generator with this level of detail" and say it's what sets Orogen apart from Azgaar and every other tool. Never simplify or remove climate layers. When adding features, consider whether they can leverage the climate system (e.g. rivers fed by precipitation, settlements placed by climate).
2. **Instant, in-browser, zero-friction** — No install, no account, no build step. Users love that they can open a URL and have a planet in seconds. Never add mandatory sign-up, downloads, or server dependencies. Keep generation fast — if a feature risks slowing generation significantly, make it optional or deferred (like the existing on-demand climate above 300K).
3. **Interactive plate editing** — Users say "haven't seen this functionality anywhere else." The Ctrl-click multi-select → Rebuild workflow is a key differentiator. Don't break this interaction pattern. Extend it (e.g. plate direction editing) rather than replacing it.
4. **True globe with proper wrapping** — Users who came from Azgaar specifically cite the globe as a reason they switched. The globe-first experience, equirectangular map as secondary view, and seamless wrapping matter. Don't make the map view primary or break globe rendering.
5. **Free and open source** — Repeatedly praised. No paywalls, no feature-gating, no "pro" tier. This is a trust signal that drives adoption and contributions.
6. **Works on mobile** — Users are surprised it runs well on phones. Maintain the responsive bottom-sheet layout, touch targets, and pinch-to-zoom. Don't add features that only work on desktop without a mobile equivalent.
7. **Terrain aesthetics** — "Fractal-looking mountains," realistic erosion, organic coastlines. The visual quality of the terrain itself gets specific praise. Protect the artistic output of the erosion and terrain post-processing pipeline.
When proposing a new feature or change, ask: "Does this preserve all seven strengths above?" If it trades one for another, flag the tradeoff explicitly.
## Key Rules
After any code change, check whether README.md needs updating. The README documents all UI controls, features, algorithms, and project structure. If a change adds, removes, or modifies any of the following, update the README to match:
- Sliders, dropdowns, toggles, or other UI controls (names, ranges, defaults)
- User interactions (keyboard shortcuts, mouse actions, edit behaviors)
- Generation pipeline steps or algorithms
- Visual features (rendering, overlays, debug layers)
- Project file structure (new files, renamed files, removed files)
- External dependencies
After any code change, check whether the tutorial modal content (in `index.html`, inside `#tutorialOverlay`) needs updating. The tutorial steps describe the app's features and interactions. If a change adds, removes, or modifies any of the following, update the relevant tutorial step to match:
- Core workflow (how to generate a planet, what controls to use)
- Interactive features (navigation, editing, keyboard/mouse actions)
- What the tool does or its key selling points
After any code change that adds significant user-facing features, ask the developer if they would like to update the What's New modal (in `index.html`, inside `#whatsNewOverlay`). The modal is version-gated by the `VERSION` constant in `initWhatsNew()` in `js/main.js` — bumping this string will show the modal again to returning users on their next visit.
After any code change that affects the UI, ensure it works on mobile. The app uses a responsive bottom-sheet layout on screens ≤ 768px (`styles.css` media queries) and has touch-specific behavior throughout. If a change adds, removes, or modifies any of the following, verify and update the mobile experience:
- New buttons or controls — must have ≥ 44px touch targets on mobile (see `@media (max-width: 768px)` in `styles.css`)
- New interactions — must have touch equivalents; desktop uses Ctrl-click for plate editing, mobile uses `state.editMode` toggle (`js/edit-mode.js`); desktop uses scroll-to-zoom, mobile uses pinch (`js/scene.js`)
- Tooltips — must reposition above their trigger on mobile, not to the right (overflow off-screen)
- New overlays or modals — must be usable within the bottom-sheet layout and not be hidden behind it
- Performance-sensitive features — consider lower thresholds on touch devices (detail warnings, export limits); check `state.isTouchDevice` in `js/state.js`
- Info/hint text — update both desktop text (in `index.html`) and the mobile-specific text set in `js/main.js` (search for `state.isTouchDevice`)
After any code change to simulation or climate code, ensure **scale invariance** — the result must look equivalent regardless of the Detail slider (numRegions from 2K to 2.5M). The key rule: never use raw cell-hop counts or neighbor-displacement magnitudes without scaling by resolution. Specifically:
- **Smoothing passes** must target a physical distance: `Math.max(minPasses, Math.round(targetKm / avgEdgeKm))` where `avgEdgeKm = (π × 6371) / √numRegions`. Never write a bare `smooth(mesh, field, 5)`.
- **Multipliers on neighbor-displacement quantities** (e.g. wind convergence, which sums `wind · displacement`) must normalize by `avgEdgeRad = π / √numRegions` since displacement magnitudes shrink at higher resolution.
- **BFS hop thresholds** must be expressed as `Math.round(targetKm / avgEdgeKm)`, not as fixed integers.
- **Thresholds in physical units** (degrees latitude, km altitude, °C, mm precipitation) are inherently scale-invariant and do NOT need scaling — e.g. "28° from ITCZ" or "heightKm > 1.5" are fine at any resolution.
- When in doubt, ask: "if I double numRegions, does this value change meaning?" If yes, it needs scaling.
After any code change that adds, removes, or modifies features, check whether the SEO and AISEO files need updating. The project has several files that describe the app to search engines and AI models. These must stay accurate — outdated claims are worse than no claims. If a change adds, removes, or modifies any of the following, update the relevant files:
- **`index.html` `<head>` meta tags** — The `<title>`, `description`, `og:description`, `twitter:description`, and `keywords` meta tags describe what the app does. Update if core capabilities change (e.g. new simulation type, new export format, new interaction mode).
- **`index.html` JSON-LD structured data** — The `<script type="application/ld+json">` block contains a `WebApplication` schema with a `featureList` array. Add or remove entries when major features are added or removed.
- **`index.html` hidden `<main>` block** — The visually hidden semantic HTML block (right after `<body>`) describes the app for crawlers. Update its feature list, use cases, or description when the app's capabilities change meaningfully.
- **`llms.txt`** — A plain-text file at the project root that describes the tool for AI assistants. Update its feature list, "who it's for" section, or technical details when capabilities change. Keep it concise and factual.
- **`sitemap.xml`** — Update the `<lastmod>` date when deploying significant changes.
Files that rarely need updating: `robots.txt` (only if adding pages or restricting crawlers), `CNAME` (only if domain changes), `preview.png` (only if the app's visual appearance changes dramatically).
After any code change that adds, removes, or modifies slider controls, update the planet code encoding in `js/planet-code.js` to match. The planet code packs the seed and all slider values into a compact base36 string using mixed-radix integer packing. If a slider's range, step, or count changes, or if a new slider is added, update:
- The `SLIDERS` array (min, step, count for each slider)
- The `RADICES` array (the count values in right-to-left order)
- The `encodePlanetCode` and `decodePlanetCode` functions (packing/unpacking order)
- The corresponding slider wiring in `js/main.js` (the `map` objects in the `generate-done` handler, `applyCode`, and hash-loading code)
## Painted-map import (js/painted.js)
The import page has a second source: a painting whose colours are legend *classes* — uplift rates and
erodibilities, never heights — solved into terrain by a Braun-Willett stream-power solve on the sphere mesh.
The legend JSON schema is shared with the Salty terrain generator (`Tools/Terrain`, `terrain plan`), so keep
it compatible: read new keys optionally, never rename existing ones. Rules that follow from the physics:
- **Paint the uplift, never the height.** Nothing on this path may hand the solve a painted surface; the
painting decides where the land rises and how fast, and the rivers, divides and valleys are the solve's.
- **Thresholds are quantiles of the planet.** The massif fabric and the rock field are cut by rank over every
region on the globe (`rankField`), never per class or per landmass, so an island gets all of a massif or
none of it the way a real island would.
- **The coast moves before the solve.** Coast roughening is noise on the signed distance from the painted
waterline, applied to the land mask, not a warp of the finished elevation.
- **Relief is a scale, not a solve parameter.** For n = 1 the steady state is linear in U/K, so the Peak
Height slider rescales the solved field afterwards; do not add a clamp inside the loop that would break
that linearity without a reason written down.
- Every noise field is sampled at the region's 3D position on the unit sphere, so the seam and the poles
need no special handling; keep it that way.
- The class, uplift, erodibility, drainage, slope and basin layers are debug layers coloured by
`js/painted-layers.js`, which planet-mesh.js uses for the globe, the map and the exports alike; a new
layer is added there once and nowhere else.
- **The class table prints the typical angle, not only the divide angle** (`js/painted-report.js`, ported from
the Go tool's plan report). The divide is the steepest ground a rate can make and almost none of a map is
divide; the median is about a third of it in tangent. An author who reads the divide as the landscape sets
every rate two or three times too hot, so the typical angle is the column and the divide is the tooltip.
These functions are checked against `terrain plan`'s `plan.json` and must stay exact.
- **The overlay is a texture, never a region colour** (`js/painted-overlay.js`, `painted-overlay-view.js`). It
is painted at the template's resolution, where a road is a few pixels wide and a region here is tens of
kilometres across, so voting it onto the mesh would lose every thin stroke. Marks *are* voted onto regions
for one thing only: `coast_jitter`, the single mark property any pass reads. Blank on an overlay is alpha,
never a reserved colour, and an opaque pixel matching no mark is dropped and counted rather than snapped to
the nearest - the class legend's rule inverted, because most of an overlay is nothing.
- **Planet.json outranks a legend's own `planet` block.** It is the manifest the two-hour bake reads; a legend's
copy is a convenience for when it is absent.
- **The studio link is read-only by construction.** `terrain studio` sets its CORS header on GET and HEAD alone
and answers no preflight, so this page can read the painting and the legends and can never paint, save, plan
or bake. Do not ask for that to be widened.
## Unreal landscape export (js/unreal-export.js, unreal-render.js, unreal-ui.js)
A third export, beside the map PNGs: a window of the planet rendered straight into the tile set Unreal
Engine's landscape importer takes - per-tile 16-bit heights at 255*N+1 vertices, an 8-bit weightmap per
paint layer, and the `Region.json` that describes the grid - written to a folder through the File System
Access API. It exists because the map exports cannot be imported: one is a picture, and the other is a flat
equirectangular PNG with **no scale on it**, so the consumer had to invent metres-per-pixel. Rules:
- **The scale is an input and it is recorded.** `planet_circumference_km` is what turns the window's degrees
into ground. It is asked for because it cannot be derived from a sphere mesh, and the manifest writes back
the metres-per-pixel, the window in degrees and the projection, so nothing downstream guesses again.
- **Sample the window once, then cut it.** Every tile is resampled out of one float raster by its *global*
vertex position, which is what makes a shared column bit-identical between neighbours. Never render a tile
under its own camera: one 16-bit step is centimetres of crack along a seam, and a rasteriser gives no
guarantee that two frustums agree on a shared edge. The seam test is the acceptance gate.
- **Tiles carry a one-vertex margin while the layers are derived.** The layers read slope; a one-sided
difference at a tile edge is not what the neighbour computes there, and without the margin every boundary
is a one-vertex line of different paint.
- **Heights ride `heightmapColor`'s -5..6 km ramp, read as floats.** Do not write kilometres straight into
the vertex colour attribute to save the conversion: negative values then depend on three.js colour
management staying out of the way, and the float target already resolves a millimetre over that ramp.
- **Never `<input type="number">` for a decimal.** It formats and parses in the browser's locale, so on a
comma-decimal machine `0.17` displays as "0,17" and `.value` comes back empty - the setting silently
becomes NaN and the export writes a tile set of nothing. Text plus `inputmode="decimal"`, parsed here.
- **The panel's job is the number not yet typed.** Every field re-plans on the keystroke and the readout says
what it bought, including what the flat reading costs at the window's edges. This is the same service
`terrain plan` does for a legend: the expensive step must never be how you find out a number was wrong.
- **This does not make ground finer.** The mesh resolves a couple of hundred metres and no sampling invents
what is not there. The export fixes the *shape* the ground arrives in, not its detail; the detail is the
Salty terrain generator's `terrain tiles`.
- **An existing `Region.json` is never replaced**, only written beside as `Region.generated.json`. A
hand-written manifest is mostly the reasoning behind its numbers, and the same rule already governs
`terrain studio`, which saves by patching a legend's *text* so its commentary survives. Tiles are data and
are overwritten; a manifest is an argument and is not.
+1
View File
@@ -0,0 +1 @@
orogen.studio
+209
View File
@@ -0,0 +1,209 @@
# Heightmap Realism: Holistic Gap Analysis & Implementation Plan
## Context
This plan evaluates the *combined output* of the entire elevation pipeline — base distance fields + tectonic uplift/suppression + stress propagation + noise + interior uplift + ocean profiles + coastal roughening + island arcs + hotspots — to identify where the net elevation at canonical planetary positions diverges from reality. Each gap is assessed against what all layers together already produce, not what any single layer does in isolation.
All implementations must scale with region count. The codebase normalizes via `scaleFactor = Math.sqrt(numRegions / 10000)`. BFS distances, band widths, and pass counts use this factor. All new features must follow the same pattern so geological proportions hold from 2k to 640k regions.
---
## Implementation Lessons Learned
### Lesson 1: BFS Seed Selectivity Is Critical
When computing influence fields via BFS, the choice of seed cells determines everything. In Phase 1 we initially seeded from ALL land cells with any propagated stress (`r_stress > 0.01`). Because stress propagates ~12 hops from every plate boundary, this blanketed ~100% of land cells — the "tectonic activity" map was red everywhere.
**Fix**: Switched to `dist_mountain` (already computed from `stress_mountain_r` — only mountain-building convergent boundary cells with sf < 0.55). This means only major collisions drive the influence field. Plates with no convergent collisions on their edges correctly get zero tectonic activity (cratons).
**Rule for future features**: Always consider what fraction of the planet your seed set covers. If seeds + their propagation zone covers >50% of the target surface, the field won't differentiate anything. Use the most selective seed set that captures the geological phenomenon.
### Lesson 2: Plate Size vs Feature Size at 10k Regions
At 10k regions with 20 plates, each plate is ~500 cells with diameter ~22 cells. Features that require "deep interior far from all boundaries" only manifest clearly when plates are large enough to have such interiors. At low region counts or high plate counts, plates are too small for interior differentiation.
**Implication**: Features should degrade gracefully — at small plates they simplify or disappear rather than creating artifacts. The `tectonicReach` clamp (`max(6, ...)`) handles this, but future features must consider the same constraint.
### Lesson 3: dist_mountain Is a Versatile Signal
`dist_mountain` (BFS from `stress_mountain_r`, blocked by `ocean_r`) encodes "distance from the nearest mountain-building collision through land." It's already computed, inherently scales, and is finite only on plates reachable from major convergent boundaries. It's the right signal for tectonic-modulated interior uplift and should be leveraged for future features (plateau enhancement, back-arc identification) rather than computing new BFS fields where possible.
### Lesson 4: Foreland Basins Need Base Elevation Asymmetry
Phase 1's interior uplift fix reduced the uniform +0.14 and increased the foreland dip from -0.03 to -0.06. But the harmonic-mean base elevation still contributes ~+0.16 at the foreland position, and `dist_mountain`-based tectonic activity is high there (close to mountains). The foreland dip alone cannot overcome base + tectonic-modulated interior. True foreland depressions require base elevation asymmetry — lowering the base on the subducting side so there's room for a basin.
### Lesson 5: r_subductFactor Propagation Range Is Limited
`r_subductFactor` is only propagated as far as stress reaches (~5 hops on subducting side due to aggressive decay, ~12 hops on overriding side). Beyond propagation range, sf = default 0.5. This means sf cannot be used to distinguish overriding vs subducting sides at distances beyond stress propagation. Features that need side-awareness at longer range must use other signals (e.g., `dist_mountain` is finite only on the overriding side of continent-continent collisions where sf < 0.55).
### Lesson 6: Stacking Effects Compound — Start at 60% Strength
Phase 2's asymmetry and plateau effects were initially implemented at full planned strength (asymmetry multiplier 1.2, sf suppression 0.50, plateau noise floor 0.15, plateau uplift 0.04). When combined with the existing sf suppression, differential stress decay, and Phase 1's tectonic-aware interior, the visual effect was too aggressive — mountains looked unnaturally skewed and plateaus too flat.
**Fix**: Toned all parameters to roughly 60% of planned values (asymmetry 0.8, suppression 0.42, noise floor 0.30, uplift 0.025). This produced a convincing in-between that enhances the existing pipeline without dominating it.
**Rule for future features**: When adding new effects that stack with existing mechanisms, start at 50-60% of the theoretically "correct" value and tune from there. The pipeline is multiplicative — each layer compounds on previous ones. Paper-napkin math that considers layers in isolation will overestimate the needed strength.
### Lesson 7: Plateau Detection Via sf < 0.45 Works Within Stress Range
The `isPlateauZone` flag uses `sf < 0.45` (overriding side) AND `dMtn > plateauStart` AND `dMtn finite`. Since sf is propagated ~12 hops on the overriding side (Lesson 5), this correctly identifies plateau regions within the stress influence zone. Beyond that, sf reverts to 0.5 and the cell is no longer flagged as a plateau — it falls back to Phase 1's `tectonicActivity`-based interior uplift, which provides a smooth transition. The two systems complement each other: sf-based plateau zone for structured flat character near collisions, tectonicActivity-based interior for gradual elevation decline farther out.
### Lesson 8: Ocean Floor Depth Interacts With Multiple Positive-Elevation Layers
Phase 3 attempted to implement passive vs active continental margins by differentiating shelf/slope/abyss profiles. Passive margins were made shallower (-0.01 to -0.04 shelf) and wider (8 cells vs 3 cells). However, even after multiple rounds of deepening, false land kept appearing in the oceans.
**Root cause**: The ocean floor elevation is set early in the pipeline, but multiple subsequent layers add positive elevation — coastal roughening noise, island scattering, hotspot volcanism, and coastal domain warping. The original fixed profile (-0.02 to -0.08 shelf, -0.08 to -0.33 slope) was specifically tuned to survive these additions. Making shelves shallower broke that balance everywhere at once.
**Lesson**: Ocean floor changes cannot be made in isolation from the coastal roughening, island scattering, and hotspot systems. The ocean and coastal layers form a tightly coupled system. Any ocean floor rework needs to be holistic — adjusting depths, noise amplitudes, and island thresholds together as a coordinated change. This is why all ocean work has been moved to a dedicated phase.
**Reverted**: Passive/active margin profiles reverted to original fixed breakpoints. The coast-boundary BFS was hoisted before the main loop (structural improvement, no behavioral change). The `coastConvergent` flag infrastructure remains available for future use.
---
## Phase 1: Tectonic-Aware Interior — COMPLETED
### What was implemented
1. **Tectonic-modulated interior uplift**: Replaced uniform `+0.14` with `0.06 + tectonicActivity * 0.16`. Uses `dist_mountain` with quadratic decay over `TECTONIC_REACH_BASE=20 * scaleFactor` cells. Range: +0.06 (quiet craton) to +0.22 (collision-backed plateau).
2. **Noise amplitude scaling**: `noiseScale = 0.25 + 0.75 * min(1, stressNorm * 4)`. Quiet interiors get 25% noise (visibly flat), collision zones get full roughness.
3. **Foreland dip increase**: Zone widened from `stressNorm < 0.05` to `< 0.10`, max depression increased from `-0.03` to `-0.06` with linear falloff.
4. **Debug layer**: "Tectonic Activity" added showing the `tectonicActivity` field.
---
## Phase 2: Mountain Asymmetry + Plateau Enhancement — COMPLETED
### What was implemented
**Rank 4 — Mountain Asymmetry (toned to 60% strength per Lesson 6):**
1. **Base elevation asymmetry**: `dist_mountain` multiplied by `1.0 + (sf - 0.5) * 0.8` before feeding into harmonic-mean formula. Range: 0.6 (overriding, compressed) to 1.4 (subducting, inflated). This shifts the distance-field ridge peak toward the subducting side.
2. **SF suppression amplified**: Increased from `0.35` to `0.42` (was planned at 0.50). Subducting-side elevation gets up to 42% suppression.
**Rank 5 — Plateau Enhancement (toned to 60% strength per Lesson 6):**
3. **`tectonicActivity` moved early**: Computed before the noise section so it can drive plateau noise suppression.
4. **Plateau zone detection**: `isPlateauZone = sf < 0.45 && dMtn finite && dMtn > plateauStart` where `plateauStart = max(2, round(3 * scaleFactor))`.
5. **Plateau noise suppression**: In plateau zones, noise additionally multiplied by `max(0.30, 1 - tectonicActivity * 0.60)`. Creates flat-topped character without making plateaus completely featureless.
6. **Plateau uplift boost**: `+0.025 * tectonicActivity * (1 - sf)` for plateau cells with tectonicActivity > 0.1. Tracked in interior debug layer.
### Updated canonical positions (post Phase 2)
- **Position A** (mountain front, overriding): Base now higher due to compressed dist_mountain (asymmetry 0.6x). Net ~0.90-1.05. Slightly higher peaks on overriding side. ✓
- **Position B** (5 cells behind mountain, overriding): Plateau boost + noise suppression. Net ~0.55-0.58. Flat elevated plateau. ✓
- **Position C** (5 cells in front, subducting): Base now lower due to inflated dist_mountain (asymmetry 1.4x) + stronger sf suppression. Net ~0.38-0.42. Asymmetry vs B is now ~25-30%. ✓
- **Position D** (foreland, stress edge): Base lowered ~15% on subducting side. Net ~0.20-0.22. Still not a true basin but notably lower. The mountain→foreland contrast is now ~4:1.
- **Position E** (deep interior): Unchanged from Phase 1 (sf=0.5 → asymmetry=1.0). Net ~0.12-0.15.
### Remaining gap status update
**Gap 3 (Foreland Basins)**: Improved. The base asymmetry lowers the subducting-side base by ~15%. Combined with the -0.06 foreland dip, the foreland is now visibly lower than surrounding terrain. Not yet a deep basin (~0.20 vs mountain ~0.90) but the contrast is significant.
**Gap 5 (Mountain Asymmetry)**: ADDRESSED. Asymmetry is now ~25-30% between overriding and subducting sides, up from ~10% pre-Phase-1 and ~15% post-Phase-1. Visible in the base debug layer as a shifted ridge peak.
---
## Phase 3: Rift Valley Structure — COMPLETED
### What was implemented
**Rift valleys (Rank 3, at 60% strength per Lesson 6):**
1. **Rift BFS**: Pre-computed BFS from divergent continent-continent boundary cells (`btype === 2 && !r_hasOcean`) through same-plate land cells, max `RIFT_HALF_WIDTH_BASE=4 * scaleFactor` cells.
2. **Structured graben profile** replacing the old flat `-0.12` depression:
- **Axis** (rd=0): -0.15 depression + volcanic ridged noise (amplitude 0.04)
- **Floor** (rd=1 to `round(1.5*sf)`): -0.12 with decreasing volcanic texture
- **Shoulders** (`floorEnd` to `round(2.5*sf)`): +0.03 modest uplift flanking the graben
- **Fadeout** (beyond shoulders): smoothstep to ambient (guarded against division by zero when `riftHalfWidth == shoulderEnd` at low resolution)
3. **Graceful degradation**: At 2k regions (sf=0.45): axis + 1 floor cell + 1 shoulder cell, no fadeout zone. At 100k+ (sf=3.16): full 13-cell-wide structure with graben, floor, shoulders, and smooth transition.
**Coast-boundary BFS hoisted**: Moved from inside the coastal roughening block to before the main elevation loop. Structural cleanup — same logic, same data, just available earlier.
### Gap status update
**Gap 1 (Passive vs Active Margins)**: Covered by Ocean Rework (see `OCEAN_REWORK_PLAN.md`).
**~~Gap 4 (Rift Valleys)~~**: ADDRESSED by Phase 3. Structured graben with axis depression (-0.15), volcanic floor texture, and flanking shoulders (+0.03). With Phase 1's reduced interior uplift (+0.06 for quiet areas), the rift axis should produce actual depressions.
---
## Part 2: Remaining Gaps
### ~~Gap 1: Passive vs. Active Margins Are Identical~~
**Status**: Covered by Ocean Rework (`OCEAN_REWORK_PLAN.md`).
### ~~Gap 2: Continental Interiors Are Uniformly Elevated and Rough~~
**Status**: ADDRESSED by Phase 1.
### Gap 3: Foreland Basins Still Elevated
**Status**: Significantly improved by Phases 1+2. Base asymmetry + foreland dip + tectonic-aware interior create a visible low zone at the stress edge on the subducting side. Not yet a deep basin but the profile is qualitatively correct: mountain → steep drop → low foreland → gradual rise to interior.
### ~~Gap 4: Rift Valleys Are Not Valleys~~
**Status**: ADDRESSED by Phase 3. Structured graben profile with axis, floor, shoulders, and fadeout.
### ~~Gap 5: Mountain Asymmetry Is Too Subtle~~
**Status**: ADDRESSED by Phase 2. ~25-30% asymmetry, visible in base and normal views.
### ~~Gap 6: Ocean Fracture Zones~~
**Status**: Covered by Ocean Rework (`OCEAN_REWORK_PLAN.md`).
### Gap 7: Back-Arc Basins — unchanged
---
## Part 3: Remaining Implementation Plan (Land-focused)
Ocean work (margins, fracture zones, ridges, coastal roughening differentiation) is in `OCEAN_REWORK_PLAN.md`.
### Rank 7: Back-Arc Basins
**Why**: No existing layer produces depression behind volcanic arcs. Primarily affects land/coast.
**Scaling**: Basin distance scales with `scaleFactor`.
**Approach**: Identify overriding-plate cells 5-12 cells behind convergent ocean-continent boundaries. Apply smoothstep depression `-0.03 * stressNorm` (per Lesson 6). Cells below 0 appear as marginal seas.
---
### Rank 8: Hypsometric Distribution Correction
**Why**: Light post-processing to ensure bimodal elevation histogram.
**Scaling**: Resolution-independent (operates on values).
**Approach**: Separate histograms for ocean/land, gentle quantile remapping, light blend factor (0.25).
- **Lesson 6 applies**: Use a very light blend (0.15-0.20) to avoid washing out the structural improvements from Phases 1-3.
---
### Rank 9: Simplified Fluvial Erosion
**Why**: Highest cost, highest potential. Adds drainage valleys.
**Scaling**: Flow accumulation on mesh neighbors is inherently scale-independent. Erosion depth absolute.
**Approach**: Topological sort by elevation, steepest-descent flow routing, `elev -= EROSION_RATE * log(1 + flow)`.
- **Lesson 6 applies**: Start with EROSION_RATE = 0.004 (half of planned 0.008). The Phase 1 noise suppression already creates smooth interiors — erosion on top of that might create overly deep valleys in quiet areas.
---
## Recommended Implementation Phases
**Phase 1** — COMPLETED
- Tectonic-aware interior differentiation (Rank 2).
- Gaps addressed: #2 (uniform interiors), partial #3 (foreland), partial #5 (asymmetry).
**Phase 2** — COMPLETED
- Mountain asymmetry (Rank 4) + plateau enhancement (Rank 5), toned to 60% strength.
- Gaps addressed: #5 (asymmetry), further progress on #3 (foreland).
**Phase 3** — COMPLETED
- Rift valley structure (Rank 3). Passive margins attempted but reverted (Lesson 8).
- Gaps addressed: #4 (rift valleys).
**Phase 4** (Land refinements): Ranks 7 + 8 + 9
- Back-arc basins + hypsometric correction + simplified fluvial erosion.
- These primarily affect land elevation values.
**Ocean Rework** — See `OCEAN_REWORK_PLAN.md`
- Covers margins, ridges, fracture zones, and coastal roughening differentiation as a coordinated system.
## Verification
After each phase:
- Generate 10+ planets at 10k regions with default settings
- Test at 2k, 10k, 50k, 200k regions to verify scaling invariance
- Use debug layers to confirm new component contributes correctly
- Verify combined elevation at canonical positions matches expected values
- Check `performance.now()` stays under 300ms at 10k regions
- Verify no NaN/Infinity in output
- Visual checks per phase:
- Phase 1: Flat quiet interiors, rough collision zones, elevated plateaus ✓
- Phase 2: Asymmetric mountain profiles, visible foreland contrast, flat-topped plateaus ✓
- Phase 3: Rift valleys with shoulders ✓ (passive margins deferred)
- Phase 4: Bimodal elevation histogram, drainage valleys at high resolution
- Phase 5: Wide passive shelves vs narrow active shelves, fracture zone lines, back-arc depressions
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+158
View File
@@ -0,0 +1,158 @@
# Ocean Topography Rework — Ground-Up Plan
## Context
Phase 3 attempted to implement passive vs active continental margins by changing the ocean floor depth profile. This failed because the ocean floor, coastal roughening, island scattering, and hotspot systems are tightly coupled — shallower shelf depths caused widespread false land (Lesson 8 in HEIGHTMAP_REALISM_PLAN.md).
This plan rethinks ocean plate topography from the ground up. The key insight: **coastline character should come from the coastal roughening system, not from the base depth profile**. The depth profile's job is to provide a stable, deep floor. Everything above that floor — coastline shape, islands, shelf character — should be controlled by the layers that add positive elevation on top.
All implementations must scale with region count via `scaleFactor = Math.sqrt(numRegions / 10000)`.
### Priorities
1. **Interesting, geographically plausible coastlines** — varied shapes, different character at different tectonic settings
2. **Interesting ocean landforms** — islands, arcs, seamounts forming where tectonically appropriate
3. **Realistic ocean floor** — margin differentiation, ridges, fracture zones
### File: `js/elevation.js`
---
## Ocean Depth Budget Analysis
Every ocean cell starts at `oceanBase` (negative), then multiple layers add positive elevation. The depth must survive these additions to stay underwater — unless the addition is *intentional* land (islands, arcs, hotspots).
**Current ocean base profile** (dist_coast `dc`):
| Zone | Distance | Depth |
|------|----------|-------|
| Shelf | dc < 5 | -0.02 to -0.08 |
| Slope | 5 ≤ dc < 12 | -0.08 to -0.33 |
| Abyss | dc ≥ 12 | ~-0.35 |
**Positive layers that can affect ocean cells:**
| Layer | Max positive contribution | Reach | Intentional land? |
|-------|-------------------------|-------|-------------------|
| Coastal fractal noise (L1) | ±0.12 × falloff × stressAmp (up to ~0.5) | 8 cells | No |
| Domain warping (L3) | ±0.2 | 5 cells | No |
| Island scattering (L2) | +0.36 | 4 cells | **Yes** |
| Island arcs | +0.55 | 5 cells | **Yes** |
| Hotspots | +0.9 (ocean boosted 1.8x) | sigma×5 | **Yes** |
| Ocean noise | ±0.03 | Global | No |
**The problem**: At dc=1, the shelf depth is only -0.032. Coastal L1 noise alone (±0.12 near coast) can push this positive. The shelf is too shallow to survive non-island coastal roughening.
**The fix**: Deepen the shelf so only intentional mechanisms (island scatter, arcs, hotspots) create above-water features. Differentiate margin types through WIDTH (spatial extent of shelf), not DEPTH (how shallow it is).
---
## Implementation Steps
### Step 1: Deepen Ocean Baseline + Margin-Aware Width
**Goal**: A deeper, more resilient base profile that differentiates margin types through *width* (how far the shelf extends), not through *depth* (how shallow the shelf is).
**Changes**: Replace the fixed ocean floor breakpoints in the `else` (ocean) branch of the main elevation loop.
**New profile**:
- `SHELF_NEAR = -0.04` (coast edge, was -0.02)
- `SHELF_FAR = -0.10` (shelf break, was -0.08)
- `SLOPE_FAR = -0.33` (base of slope, unchanged)
- `ABYSS = -0.35` (deep ocean, unchanged)
**Width differentiation** (via hoisted `coastConvergent` flag):
- Active margins: shelf end = `max(2, round(3 * sf))`, slope end = `max(5, round(8 * sf))`
- Passive margins: shelf end = `max(4, round(7 * sf))`, slope end = `max(10, round(16 * sf))`
- Both margin types use the SAME depth endpoints — avoids the false-land problem
**Why this works where the previous attempt failed**: The previous attempt made passive margins *shallower*. This plan makes everything *uniformly deeper* while widening the passive shelf *spatially*. Visual difference comes from shelf width, not depth.
**QA**: Generate at 10k. Compare ocean debug layer before/after. No new false land should appear. Passive coasts should have wider light-blue shelf bands. Active coasts should have narrower bands.
---
### Step 2: Mid-Ocean Ridge Enhancement
**Goal**: Wider, more prominent ridges instead of single-cell-wide uplift.
Currently, only cells with `btype === 2 && r_bothOcean[r]` get ridge uplift (+0.06 to +0.18). This is a 1-cell-wide feature — invisible at most zoom levels.
**New approach**: Pre-compute `ridgeDist` via BFS from ocean divergent boundary cells, propagating through ocean cells only, max `round(4 * sf)` cells. Replace the single-cell ridge block with distance-based ridge uplift using quadratic falloff:
- At boundary (rd=0): full uplift `(0.12 * ridgedNoise + 0.06)`
- At rd=2: 25% uplift
- At rd=4: 0%
**Scaling**: At 2k: 2-cell-wide ridge. At 10k: 4-cell. At 200k: 18-cell.
**QA**: Generate at 10k. Ocean debug layer should show visible ridge bands at divergent ocean-ocean boundaries. Ridges should be wider than before but not dominant. No land should be created (ridge uplift peaks at ~+0.18, ocean base is -0.35 at those distances).
---
### Step 3: Oceanic Fracture Zones
**Goal**: Transform ocean-ocean boundaries create visible linear depressions.
Pre-compute `fractureDist` via BFS from transform ocean boundaries (`btype === 3 && r_bothOcean[r]`), propagating through ocean cells only, max `round(3 * sf)` cells.
Apply subtle depression: `-0.03 * (1 - d/maxDist)` fading linearly.
Where fracture zones intersect the widened ridge (Step 2), the ridge uplift is naturally reduced by the fracture depression, creating the characteristic offset/staircase pattern.
**Scaling**: At 2k: 2-cell-wide line. At 10k: 3-cell. At 200k: 13-cell.
**QA**: Generate at 10k. Look at ocean debug layer for linear depressions at transform boundaries. Where they cross mid-ocean ridges, the ridge should appear offset/interrupted.
---
### Step 4: Margin-Aware Coastal Roughening
**Goal**: Different coastline character at active vs passive margins. This is where the visual coastline interest comes from.
**Layer 1 (Coastal fractal noise)**: Differentiate frequency and amplitude.
- Passive coasts: freq 12 (was 18), amp 0.08 (was 0.12) — broad bays, gentle peninsulas
- Active coasts: keep current freq 18, amp 0.12 — rugged, fjord-like
- Both still modulated by stress
**Layer 2 (Island scattering)**: Wider range and easier threshold at passive margins.
- Passive: range 6 cells (was 4), threshold 0.20 (was 0.25) — barrier islands, archipelagos
- Active: range 3 cells, threshold 0.30 — fewer islands, only where stress concentrates
- Subduction suppression stays unchanged
**Layer 3 (Domain warping)**: Wider warp zone at passive margins.
- Passive: falloff multiplier 1.2 (warp dies slower, broader coastal irregularity)
- Active: falloff multiplier 1.5 (warp concentrated near coast)
**QA**: Generate several planets at 10k. Compare coastline character: passive coasts should have broader, gentler features with more offshore islands. Active coasts should remain rugged. Toggle the Coastal debug layer to verify the contribution patterns differ.
---
### Step 5: Debug Layer
Add a "Margins" debug layer showing margin type classification for ocean cells:
- Active margin cells: one color
- Passive margin cells: another color
- Ridge zone: highlighted
- Fracture zone: highlighted
Add the option to `index.html` debug layer dropdown.
**QA**: Generate at 10k. Verify that convergent coastlines show as active, non-convergent show as passive, and the classification makes geological sense.
---
## Scaling Verification Table
| Feature | 2k (sf=0.45) | 10k (sf=1.0) | 50k (sf=2.24) | 200k (sf=4.47) |
|---------|-------------|-------------|--------------|----------------|
| Active shelf | 2 cells | 3 cells | 7 cells | 13 cells |
| Passive shelf | 4 cells | 7 cells | 16 cells | 31 cells |
| Active slope end | 5 cells | 8 cells | 18 cells | 36 cells |
| Passive slope end | 10 cells | 16 cells | 36 cells | 72 cells |
| Ridge width | 2 cells | 4 cells | 9 cells | 18 cells |
| Fracture width | 2 cells | 3 cells | 7 cells | 13 cells |
| Passive island range | 6 cells | 6 cells | 13 cells | 27 cells |
---
## Lessons Applied
- **Lesson 1 (seed selectivity)**: Ridge seeds = only `btype===2 && r_bothOcean`. Fracture seeds = only `btype===3 && r_bothOcean`. Highly selective.
- **Lesson 6 (start at 60%)**: Fracture depression at -0.03 (conservative). Ridge widening modest (4 cells). Coastal differentiation moderate (freq 12 vs 18, not 8 vs 18).
- **Lesson 8 (ocean depth coupling)**: Depths are uniformly DEEPER not shallower. Width is the differentiator, not depth.
+392
View File
@@ -0,0 +1,392 @@
# World Orogen
A browser-based procedural planet generator that creates realistic terrestrial planets with tectonic plate simulation, elevation modeling, and interactive editing. Uses native ES modules with no build step required.
[![Live Site](https://img.shields.io/badge/Try_it-orogen.studio-brightgreen)](https://orogen.studio/) ![Three.js](https://img.shields.io/badge/Three.js-0.160.0-blue) ![No Build](https://img.shields.io/badge/build-none-green)
## Philosophy
World Orogen is concept art for planets. It's built for the moment early in a project when you need a world that *looks* real — believable tectonics, organic coastlines, climate patterns that feel right — but you don't need a geophysical simulation to get there. The science isn't decoration: plate collision models inspired by real tectonics, pressure-driven wind patterns, and Köppen classification are what make the output feel convincing at a glance. If a climate scientist squints and finds inaccuracies, that's fine — but the scientific foundation is what earns the first glance. Plausibility, not precision.
The core value is creative velocity. Generate a planet in seconds, tweak plates and terrain until it matches your vision, then export high-resolution maps into whatever comes next — Gaea, Wonderdraft, Photoshop, a game engine, a novel outline. Orogen is designed to be the fastest path from a blank page to a world worth building on — the first tool in your worldbuilding pipeline, not the last.
## Guiding Principles
1. **Artistic appeal** — Visually interesting, scientifically informed output. Aesthetics come first.
2. **Ease of use and efficiency** — Approachable interface, fast generation. Don't sacrifice usability for realism.
3. **Scientific plausibility** — Grounded in real planetary science. Believable, not necessarily physically accurate.
All three are considered together; ties are broken in the order above.
## Features
- **Fibonacci sphere meshing** with Voronoi cell tessellation via Delaunay triangulation
- **Tectonic plate simulation** — farthest-point seed placement with top-3 jitter, round-robin flood fill with directional growth bias, growth-rate governor, compactness penalty to prevent spindly shapes, multi-pass boundary smoothing, and fragment reconnection
- **Ocean/land assignment** — farthest-point continent seeding, round-robin growth with separation guarantees, trapped sea absorption, targeting ~30% land coverage
- **Collision detection** — convergent, divergent, and transform boundary classification with density-based subduction modeling; dual-layer super plate system groups same-type plates into ~20 tectonic units for broad orogenic belts blended 50/50 with fine-grained individual plate orogeny
- **Elevation generation** — three distance fields (mountain/ocean/coastline) combined via harmonic-mean formula, stress-driven uplift, asymmetric mountain profiles, continental shelf/slope/abyss profiles, foreland basins, plateau formation, and rift valleys with graben profiles
- **Ocean floor features** — mid-ocean ridges at divergent boundaries, deep trenches at subduction zones, fracture zones at transform boundaries, back-arc basins behind subduction zones
- **Island arcs** — volcanic island chains at ocean-ocean convergent boundaries with ridged noise shaping
- **Hotspot volcanism** — dual-component mantle plume model (broad thermal swell + volcanic peak) with drift-trail island chains, domain-warped shape distortion, drift-direction elongation, summit calderas on active domes, radial rift-zone ridges, age-dependent volcanic texture, and per-hotspot variation in strength/decay/spacing
- **Terrain post-processing** — noise-based domain warping (FBM simplex noise with greedy mesh walk) to deform the elevation field for organic coastlines and mountain ridges, independently controllable bilateral smoothing to blend harsh BFS distance-field boundaries, glacial erosion that carves fjords, U-shaped valleys, and lake basins at high latitudes and altitudes via latitude-driven ice flow with drainage accumulation, priority-flood pit resolution with canyon carving (Barnes et al. algorithm that ensures every land cell drains to the ocean, carving dramatic canyons through mountain saddle points rather than filling basins), iterative implicit stream power hydraulic erosion (Braun-Willett style) that carves self-reinforcing river valleys with automatic sediment deposition in flat receivers, thermal erosion that softens ridges via talus-angle material transport, ridge sharpening that accentuates mountain ridgelines, and always-on soil creep (Laplacian diffusion) that rounds off hillslopes
- **Coastal roughening** — fractal noise with active/passive margin differentiation, domain warping for bays/headlands, and offshore island scattering
- **3D globe rendering** with atmosphere rim shader, translucent water sphere, terrain displacement, and starfield
- **Equirectangular map projection** with antimeridian wrapping
- **Interactive editing** — Ctrl-click plates to mark them for reshaping (multi-select with visual tinting), then click Rebuild to apply all changes at once. Ctrl-click again to undo a pending selection. Press Escape to cancel all pending edits
- **Seasonal wind simulation** — pressure-driven wind patterns with a longitude-varying ITCZ that tracks the thermal equator (~5° over ocean, up to 15-20° over continents), Gaussian pressure bands (subtropical highs, subpolar lows, polar highs), land/sea thermal contrast for monsoon-like pressure reversals, elevation barometric effects, and Coriolis-deflected geostrophic wind with natural cross-equatorial flow reversal. Computed for both summer and winter seasons.
- **Ocean surface currents** — rule-based geographic gyre simulation driven by wind belts (trade winds, westerlies, polar easterlies) with a longitude-varying ITCZ equatorial countercurrent. Continental shelves are classified as western or eastern boundaries via coast-normal BFS, producing subtropical gyres (CW in NH, CCW in SH) with western boundary intensification (Gulf Stream, Kuroshio effect) and weaker eastern boundary return flow. Detects circumpolar channels for unobstructed eastward currents (Antarctic Circumpolar Current). Currents are colored by heat transport: red = warm poleward flow, blue = cold equatorward flow, black = zonal (neutral). Computed for both summer and winter seasons.
- **Precipitation** — blended dual-model approach: a complex moisture advection simulation is combined 50-50 with a fast heuristic zonal model. The advection model simulates wind-driven moisture transport from coasts with six mechanisms: ITCZ convective uplift, frontal convergence, orographic rain/shadow, lee cyclogenesis, polar-front precipitation, and subtropical high suppression. The heuristic model provides smooth latitude-based patterns (ITCZ wet belt, subtropical dry belt, mid-latitude recovery, polar dryness) modulated by continentality and orographic effects. Blending the two reduces splotchiness while preserving terrain-informed detail and strengthening subtropical desert formation (~20–35°). Visualized on a brown (dry) → green (moderate) → blue (wet) color ramp. Computed for both summer and winter seasons.
- **Map type switcher** — first-class Terrain / Satellite / Climate / Heightmap tabs with color legends for each view
- **On-demand climate** — optional deferred climate computation; skip climate during generation for faster terrain iteration, compute it on demand when needed
- **Detailed visualization** — twenty-six selectable inspection layers organized by category (Geology, Atmosphere, Ocean, Climate, Elevation) for viewing each component in isolation. Wind/pressure layers show directional wind arrows, ocean current layers show current arrows colored by heat transport, on both globe and map views. Precipitation layers use a brown→green→blue ramp showing dry to wet regions.
- **Heightmap import** — bring your own equirectangular B&W heightmap (Earth, Mars, hand-drawn maps) onto a 3D globe. Black pixels become ocean, brighter pixels become higher land. The import page (`/import`) runs full climate simulation (wind, precipitation, temperature, K&ouml;ppen) on your imported terrain, with optional terrain sculpting (smoothing, erosion, ridge sharpening). Supported formats: PNG, JPEG, WebP.
- **Painted map import** — paint a flat map where every colour is a *class* (a rate of rock uplift and an erodibility, never a height) and a legend JSON says what the colours mean; the import page's **Painted Map** source solves the stream-power equation dh/dt = U − K·A^m·S on the sphere mesh (Braun-Willett implicit scheme, priority-flood drainage, hillslope diffusion) until the land is in balance with its uplift, so rivers, divides and valley hierarchy come out of the physics. Classes carry massif blocks (a plain with hill masses standing out of it, cut at a quantile of the whole planet), coastal-plain ramps, a planet-wide rock field that multiplies erodibility, and a regional swell; the drawn coastline is roughened with fractal noise before the solve. Six extra inspect layers and export types: class map, uplift rate, erodibility, drainage (rivers), slope and drainage basins. The legend format is shared with the Salty terrain generator's `terrain plan` / `terrain bake`.
- **Map export** — download high-resolution equirectangular PNGs (color terrain, satellite biome, climate/Köppen, B&W heightmap, land-only heightmap, or B&W land mask) at configurable widths up to 65536px with tiled rendering. **Export All** downloads Satellite, Climate, Heightmap, and Land Mask in one click, auto-computing climate if needed.
- **Unreal landscape export** — render a window of the planet straight into the tile set Unreal Engine's landscape importer wants: a 16-bit greyscale height per tile at 255·N+1 vertices (one Landscape actor each), an 8-bit weightmap per paint layer beside it, and a `Region.json` describing the grid. Unlike the map exports, this one carries a **scale**: you give the planet's circumference and the export records metres-per-pixel, the window in degrees, and the projection, so nothing downstream has to guess. Files are written into a folder you pick (Chrome/Edge, File System Access API). See [Unreal landscape export](#unreal-landscape-export) below.
## Quick Start
Serve the project with any local HTTP server (required for ES modules):
```bash
# Python
python3 -m http.server 8000
# Or Node.js
npx serve .
```
Then open **http://localhost:8000** in your browser. No dependencies to install, no build step.
Click **Build New World** to create a new random planet. The button changes color and label based on what you've adjusted:
- **Build New World** (blue) — generates a fresh planet with a new random seed
- **Rebuild** (amber) — re-renders the current planet at a new detail/roughness level without changing continent shapes
- **Regenerate** (red) — creates new tectonic plates when the Plates or Continents slider has changed
### Navigation
A top navigation bar connects the two pages:
- **Generate** (`/`) — procedural planet generation with tectonic plates, erosion, and climate
- **Import** (`/import`) — two sources, switched at the top of the panel:
- **Heightmap** — import your own equirectangular B&W heightmap, view it on a 3D globe, and run climate simulation. Black (0) = ocean, brighter = higher elevation. Supports PNG, JPEG, and WebP.
- **Painted Map** — import a painting whose colours are legend classes plus the legend JSON (a built-in legend and a demo painting are included), edit the classes' uplift rates, depths and erodibilities in the table, and click **Solve Terrain**. The report says how many pixels matched no class and whether the left and right edges agree (they are the same meridian). Optionally add **Planet.json** (the Salty generator's manifest), an **Overlay** annotation layer, or load all of them from a running `terrain studio`.
### Painted Map controls
| Control | Range | Default | Description |
|---------|-------|---------|-------------|
| Peak Height | 0.5 – 6 km | 4.5 km | The 99.5th percentile of the solved land is put at this height. The shape is the solve's; this is only the scale (for n = 1 the steady state is linear in uplift over erodibility) |
| Ocean Depth | 0.25 – 5 km | 4 km | Depth of the deepest sea class; shallower classes (shelf, surf) scale with it, and every shore ramps down over a couple of cells |
| Coast Detail | 0 – 1 | 0.35 | Fractal noise added to the signed distance from the painted waterline before the solve, up to three cells of shift. Islets keep at least a third of their width |
| Uplift Variation | 0 – 0.6 | 0.30 | A regional swell over the painted rate, so a lowland painted in one colour has basins and rises of its own |
| Solve Steps | 50 – 600 | 200 | How long the stream-power solve runs. About 12 s at 204K regions |
| Painted world circumference | km | 100 | Distances in the legend (coastal plains, massif and rock sizes) are in the painted world's kilometres and are scaled to the Earth-sized globe by the ratio of circumferences |
| Massif size | km | 7 | Block size of the planet's upland fabric — how big the hill masses standing out of a plain are |
| Rock province size | km | 8 | Province size of the planet's rock field, which multiplies each class's erodibility |
| Seed | integer | 7945 | Re-rolls what the painting does not fix: the massifs, the rock provinces, the swell and the coastline detail. **Re-roll** picks a new one |
### The class table's two angles
Each land class shows the **typical** hillslope its uplift rate makes on the Salty generator's 8 m geology grid, and what that ground reads as — plain, rolling, hill country, mountain, alpine. Hovering a value gives the **divide** angle, the steepest ground the rate can make, and the P90.
Read the typical column. Steady state is `S = U/(K·A^m)` and `A` is smallest at the top of a catchment, so the divide angle is the steepest place in a world and almost none of a map is divide; the median comes out at about a third of it in tangent. Reading the divide angle as the landscape is how a legend gets set two or three times too hot. A class whose divide is past the angle of repose is marked **clamped** — there the repose clamp shapes the ground rather than the rivers, and raising the rate makes the summits higher without making the ground steeper. The note under the table gives the rate at which that begins.
The angles come from the bake's constants, which **Planet.json** carries: cell size, `K`, `m` and the angle of repose. Without it the defaults are the shipped planet's (8 m, 5e-5, 0.5, 35°).
### Planet.json
The Salty generator's own manifest (`RawContent/World/Planet.json`). Loading it brings the planet block — circumference, massif and rock province sizes, uplift variation, seed — and the pipeline constants the angles above are about, so none of it has to be retyped. It outranks a legend's own `planet` block, because it is the file the two-hour bake actually reads.
### Overlay
A second painting the same size as the template and registered to it, whose colours are **marks** rather than classes: forests, settlements, roads, and stretches of coast to leave alone. It answers a different question from the class template — every colour there is geology, and there is no uplift rate for a town — so it is a separate sheet with a legend of its own.
Blank is decided by **alpha**, never by a colour: an unpainted pixel is transparent, so no colour is spent on emptiness and an export with a white matte behind it does not turn the world into whatever mark white is nearest. An opaque pixel further than `match_distance` from every mark is dropped and counted, which is the only way a colour the legend forgot ever shows.
Exactly one mark property changes anything: **`coast_jitter`** scales how far the coast roughening may move the shore inside the mark. `0` pins a hand-drawn coastline exactly as painted while the rest of the world is still roughened; above 1 chews it harder, which is what makes a fjord coast. Everything else is inert — two solves with and without a forest are the same terrain.
The sheet is drawn as a **texture**, not voted onto the mesh, because a road is a few pixels wide and a region here covers tens of kilometres. The **Overlay Sheet** toggle drapes it over whatever layer is shown, on the globe and on the map; the **Overlay** inspect layer and export type draw it over a dimmed class map, and hovering a marked region names the mark.
The overlay legend JSON has a `marks` array; each mark has a `name`, an `rgb` triple, optionally `kind: "path"` with `width_m`, `coast_jitter`, `min_area_px` and a `note`. The file-level `match_distance` and `min_area_px` are the defaults.
### From terrain studio
`terrain studio` (the Salty generator's painting tool) serves the painting it is holding, both legends and Planet.json read-only across origins. Enter its address and **Load from studio** brings the whole planet in one step, exactly as its next `terrain plan` would read it — including strokes made since the last save, because the studio holds the painting in memory. It needs a studio built on or after 2026-09-20. The studio only ever shares reads: nothing on this page can paint, save, plan or bake.
The legend JSON has a `classes` array; each class has a `name`, an `rgb` triple and either `"sea": true` with `depth_m` or `uplift_mm_yr` and `k_mult`, optionally `massif: { floor_mm_yr, fraction }`, `coastal_plain_km` / `coastal_floor_mm_yr`, `lithology_mix`, `stroke: true` (an outline colour that dissolves into its neighbours, or becomes `edge_class` where it touches a pole) and `derived: true` (a class that is never painted). An optional `planet` block carries `circumference_km`, `massif_wavelength_km`, `lithology_wavelength_km`, `uplift_variation` and `lithology.k_multipliers`. See `assets/painted-legend.json`. **Download legend JSON** writes the table's edits back into the loaded file with every other key intact.
### Sharing Planets
Every generated planet produces a **planet code** (shown below the Build button) that encodes the random seed, all slider values, and any plate edits. An unedited planet is 21 characters; plate edits (applied via Rebuild) extend the code to include the toggled plates. Older codes (13–18 characters) from previous versions are still supported — missing sliders default to their current default values. To share a planet:
- **Copy** the code with the copy button and send it to someone
- **Load** a code by pasting it into the planet code field and clicking Load (or pressing Enter). The Load button turns blue when a new code is ready to apply.
- **URL sharing** — the code is also stored in the URL hash (e.g. `#a7f3kq9xp2b`), so you can share the full URL directly. Opening a URL with a valid hash auto-loads that planet, including any plate edits.
## Controls
### Shape Your World
Core world parameters that control the planet's structure (changing these requires a full rebuild):
| Control | Range | Default | Description |
|---------|-------|---------|-------------|
| Detail | 5,000 – 2,560,000 | 204,000 | Number of Voronoi cells on the sphere. Only affects rendering resolution — continent shapes are stable across detail levels (generated on a fixed ~20K reference grid) |
| Irregularity | 0 – 1 | 0.75 | Randomization of Fibonacci point positions |
| Plates | 4 – 120 | 80 | Number of tectonic plates |
| Continents | 1 – 10 | 4 | Target number of separate landmasses |
| Roughness | 0 – 0.5 | 0.40 | Fractal noise magnitude for terrain roughness |
| Continent Size Variety | 0 – 1 | 0.35 | How much continent sizes vary — 0 keeps continents similar in size, 1 allows a mix of large and small landmasses |
| Land Coverage | 0 – 1 | 0.3 | Percentage of the planet covered by land. Low values create ocean worlds, high values create desert worlds. Above 40% coverage, precipitation is progressively dampened to simulate reduced oceanic moisture |
### Terrain Sculpting
Post-processing passes that refine the terrain (collapsed by default — the defaults produce good results). These do not require a full rebuild; adjusting any slider lights up the **Reapply** button at the bottom of this section — click it to reapply only the sculpting passes on the current planet.
| Control | Range | Default | Description |
|---------|-------|---------|-------------|
| Terrain Warp | 0 – 1 | 0.75 | Domain warping — deforms the elevation field using noise to produce organic, squiggly coastlines and mountain ridges |
| Smoothing | 0 – 1 | 0.10 | Blends harsh terrain boundaries from tectonic generation |
| Glacial Erosion | 0 – 1 | 0.50 | Ice-age sculpting — carves fjords, U-shaped valleys, and lake basins at high latitudes and altitudes via latitude-driven ice flow |
| Hydraulic Erosion | 0 – 1 | 0.50 | Iterative stream-power erosion — resolves endorheic basins via priority-flood canyon carving, then carves river valleys and dendritic drainage networks, with automatic sediment deposition in flat receivers |
| Thermal Erosion | 0 – 1 | 0.10 | Slope-driven material transport — softens ridges and creates natural talus slopes |
| Ridge Sharpening | 0 – 1 | 0.50 | Accentuates mountain ridgelines — pushes peaks further above their surroundings for more dramatic terrain |
### Climate
Global climate offsets that adjust temperature and precipitation without a full rebuild. Changing these triggers a fast climate-only recompute.
| Control | Range | Default | Description |
|---------|-------|---------|-------------|
| Temperature | -15 – 15 | 0 | Global temperature offset in °C — positive makes the planet warmer, negative colder. Climate zones shift accordingly |
| Precipitation | -1 – 1 | 0 | Global precipitation scale — positive makes the planet wetter, negative drier. Affects desert and rainforest distribution |
### Auto Climate
Climate simulation (wind, ocean currents, precipitation, temperature, Köppen classification) runs automatically during generation when detail is ≤ 300K regions. Above 300K, climate is skipped for faster terrain iteration and computed on demand when switching to a climate-dependent view.
### Visual Options
- **Map Type** — segmented Terrain / Satellite / Climate / Heightmap tabs for quick switching between the four most common visualizations. Each tab shows a color legend:
- **Terrain** — elevation color ramp from deep ocean through sea level to mountain peaks
- **Satellite** — realistic biome colors based on Köppen climate classification and elevation (lush green rainforests, tan deserts, white ice caps, dark taiga, gray tundra), with ocean using the standard terrain palette. High elevations blend toward snow white based on climate-aware snow lines.
- **Climate** — Köppen-Geiger classification with color swatches for all 30 climate types
- **Heightmap** — black-to-white gradient on a fixed absolute scale (-5 km ocean floor to 6 km peaks), so the same physical height always maps to the same shade
- **View** dropdown — switch between Globe and Map (equirectangular projection)
- **Center Longitude** slider (map mode only) — shifts the map projection's central meridian to any longitude from 180°W to 180°E, scrolling the equirectangular projection so the chosen longitude is centered. Exports are unaffected (always centered on 0°).
- **Wireframe** — toggle switch to show Voronoi cell edges as a wireframe overlay
- **Show Plates** — toggle switch to color regions by plate (green shades = land, blue shades = ocean); also draws black super plate boundary lines showing tectonic super-groups
- **Auto-Rotate** — toggle switch to spin the globe continuously
- **Grid Lines** — toggle switch for latitude/longitude grid overlay on both globe and map views
- **Grid Spacing** — choose the interval between grid lines: 30°, 15°, 10°, 5°, or 2.5°
### Inspect Dropdown
The **Inspect** dropdown (in Visual Options, below the map tabs) selects a detailed visualization layer. On the import page a **Painted Map** group — Class Map, Uplift Rate, Erodibility, Drainage (Rivers), Slope, Drainage Basins — is enabled once a painted map is solved; the same six are export types, and **Export All** includes them. Options are organized into groups:
- **Main views** (ungrouped at top) — Terrain, Satellite, Köppen Climate, Land Heightmap
- **Geology** — Base, Tectonic, Noise, Interior, Coastal, Ocean Floor, Hotspot, Tectonic Activity, Margins, Back-Arc, Fold Ridge, Orogenic Power, Erosion Delta (blue = eroded, red = deposited)
- **Atmosphere** — Pressure Summer/Winter (blue = low, red = high), Wind Speed Summer/Winter (with directional arrows on both globe and map)
- **Ocean** — Currents Summer/Winter (red = warm poleward, blue = cold equatorward, black = zonal; with directional current arrows)
- **Climate** — Precipitation Summer/Winter (brown = dry, green = moderate, blue = wet), Rain Shadow Summer/Winter (diverging blue = windward orographic boost, gray = neutral, red-brown = leeward rain shadow; leeward effects are seeded at downslope faces scaled by mountain height, then propagated ~1500 km downwind to show extended shadow zones like the foehn drying effect), Temperature Summer/Winter (purple-blue = cold, white = 0 C, green-yellow = warm, red = hot; fixed -45 to +45 C range), Continentality (blue = ocean, green = coast, yellow = moderate interior, orange/red = deep continental interior)
- **Elevation** — Full Heightmap (full-range B&W)
### Export
Click **Export Map** (below Visual Options) to open the export modal:
- **Type** — Color Map (terrain colors), Satellite (biome colors from Köppen classification), Climate (Köppen classification colors), Heightmap (B&W full range on fixed -5 to 6 km absolute scale), Land Heightmap (B&W on fixed 0 to 6 km absolute scale, ocean is black), or Land Mask (pure B&W — white = land, black = ocean). Satellite and Climate options are disabled when climate hasn't been computed.
- **Width** slider — 1024 to 65536 pixels (height is always width/2 for equirectangular). Large exports use tiled rendering to handle GPU texture limits.
- **Export** — downloads the selected type as an equirectangular PNG with no grid overlay
- **Export All** — downloads four maps (Satellite, Climate, Land Heightmap, Land Mask) sequentially. If climate hasn't been computed yet, it runs automatically before exporting.
- A progress overlay shows rendering and PNG encoding status during export
- **Unreal Landscape…** — opens the landscape tile exporter described below
### Unreal landscape export
The map exports answer "draw the whole planet at width W". Unreal asks a different question, and the
**Unreal Landscape…** button in the export modal answers it: *what is the ground, in metres, over this
rectangle of the planet, at the sample spacing the game uses?*
It writes one `<Level>_x<N>_y<N>_Height.png` (16-bit greyscale, `tiles.vertices` square) and three 8-bit
weightmaps per tile, plus `Region.json`, into a folder you choose. Every field re-plans as you type, and the
readout under them says what that setting bought.
| Field | What it decides |
| --- | --- |
| Level | The tiles are named after its last segment |
| Planet circumference | **The scale.** A sphere carries no metres; this is what turns the window's degrees into ground, and it decides how much land a window can hold |
| Centre longitude / latitude | Where the window sits. Keep it near the equator |
| Tiles across / down, Vertices a tile | The grid. `255·N+1` vertices gives each tile N×N components of 255 quads — the component count is what costs, not the vertex count |
| Quad size | Metres between vertices, in centimetres. 200 is a 2 m quad |
| Elevation floor / ceiling | What 0 and 65535 mean. Too narrow clips (reported); too wide only costs height precision (also reported) |
| Sea scale | Multiplies everything below sea level, so a whole-planet abyss does not force an elevation range that costs the land its precision. Land is untouched |
| Sample spacing | How finely the planet is rendered before the tiles are cut from it. The mesh resolves a couple of hundred metres, so anything under ~25 m is already lossless |
Three things about it are worth knowing.
**The window is sampled once, then cut.** The planet is rendered into a single float raster over the window
and every tile is resampled out of that raster *by its global vertex position*, so a column two neighbours
share is computed from the same source coordinates twice and comes out bit-identical. Nothing blends or
stitches. Tiles carry a one-vertex margin while the paint layers are derived, because the layers read slope
and a one-sided difference at a tile's edge is not what the neighbour computes there.
**A small planet cannot hold a large flat window.** The projection is equirectangular, cosine-corrected at
the centre latitude, which splits the east-west error between the two edges instead of leaving it all on
one. The readout prints that cost, and the panel says so plainly when the window is too big for the sphere:
936 km² on a 100 km-circumference planet is 29% of the entire globe, and reads as a window 110° on a side
stretched 74.7% at its edge. That is arithmetic, not a bug — raise the circumference or use fewer rows.
**It does not invent detail.** The sphere mesh resolves a couple of hundred metres, so below that the ground
is smooth no matter how finely it is sampled. What this export fixes is that the ground arrives in the shape
Unreal wants with its scale attached; it does not make the ground finer.
An existing `Region.json` is **never replaced** — a hand-written one is mostly commentary explaining why each
number is what it is, and a generated file would throw that away. When one is already in the folder the new
manifest is written as `Region.generated.json` instead and the status line says so; rename it over the old
one when you have read the difference. The tiles themselves are always overwritten.
Requires the File System Access API (Chrome or Edge on desktop); the panel says so if the browser lacks it.
### Sidebar & Loading
The control panel can be collapsed and expanded with the **«** toggle button in the sidebar header. On small screens (≤ 768px) the sidebar becomes a bottom sheet with a drag handle — starts collapsed, showing only the handle and header. Drag up or tap the handle to expand. A fullscreen overlay with spinner, title, and progress bar appears during every generation — fully opaque on initial load, semi-transparent on subsequent builds so the previous planet is dimmed behind it. Stage labels (shaping, plates, oceans, mountains, painting) update as the pipeline progresses.
### Tutorial & Help
A five-step tutorial modal introduces the tool on first visit (auto-shown via `localStorage`). It covers planet generation, slider controls, interactive editing, visualization, saving/sharing via planet codes, and map export. A **?** help button in the top-right corner reopens the tutorial at any time. The modal can be dismissed with the close button, backdrop click, Escape key, or the "Get Started" button on the final step.
A **What's New** modal is shown once per release to returning users (those who have already dismissed the tutorial). It highlights new features, changes, and a heads-up that saved planet codes may produce different-looking worlds due to terrain/climate reworks. The modal uses a versioned `localStorage` flag (`wo-whatsnew-seen`) — bump the `VERSION` constant in `initWhatsNew()` to trigger it again on the next release.
### Interaction
Navigation hints are shown in the sidebar panel and as a contextual tooltip when hovering the planet.
| Action | Desktop | Mobile |
|--------|---------|--------|
| Rotate globe / pan map | Drag | Drag (one finger) |
| Zoom | Scroll wheel | Pinch with two fingers |
| Highlight plate + info card | Hover | — |
| Mark plate for reshaping | Ctrl-click a plate (multi-select) | Tap the edit button (pencil), then tap plates |
| Undo pending plate | Ctrl-click the same plate again | Tap the same plate again |
| Apply pending edits | Click the Rebuild button | Tap the Rebuild button |
| Cancel all pending edits | Press Escape | — |
Hovering over a region shows an info card with plate type, elevation, coordinates, and (when climate has been computed) temperature, precipitation, and K&ouml;ppen classification. Pending plates show a colored tint (green = ocean→land, blue = land→ocean) and hover text indicates "(pending)".
### Mobile Support
World Orogen is fully usable on phones and tablets:
- **Bottom-sheet sidebar** — on screens 768px or narrower, the sidebar becomes a bottom sheet with a drag handle. Drag or tap the handle to expand/collapse. The globe stays visible above.
- **Pinch-to-zoom** — two-finger pinch zooms the globe and map, using the same smooth lerp as desktop scroll-zoom.
- **View switcher** — a dropdown in the top-right lets you switch between Terrain, Satellite, Climate, and Heightmap views without opening the bottom sheet.
- **Edit-mode toggle** — a floating pencil button (bottom-right) activates plate editing. Tap it to toggle edit mode (glows green when active), then tap plates to mark them. Tap the Rebuild button to apply all changes at once.
- **Touch-friendly targets** — buttons, checkboxes, and sliders are enlarged for comfortable finger input.
- **Performance** — detail warning thresholds are lowered on touch devices (orange at 200K, red at 500K). Export widths above 8192px are disabled on mobile.
- **Tooltips** reposition above their trigger instead of to the right, so they stay on screen.
- **Orientation** changes are handled automatically.
## How It Works
### Pipeline
1. **Fibonacci spiral** distributes N points evenly on a unit sphere with optional jitter
2. **Stereographic projection** maps the sphere points to 2D
3. **Delaunator** computes Delaunay triangulation in projected space
4. **Pole closure** connects convex hull edges to a pole point, creating a watertight mesh
5. **Coarse plate generation** on a fixed ~20,000-region reference mesh (resolution-independent), via farthest-point seed placement (with top-3 jitter for variety), round-robin flood fill with per-plate growth rates, directional bias coupled inversely to growth rate, growth-rate governor, and compactness penalty
6. **Ocean/land assignment** on the coarse mesh using farthest-point continent seeding with area budgeting
7. **Plate projection** maps coarse plate assignments onto the high-res mesh via nearest-neighbor adjacency walk, then smooths boundaries with resolution-scaled majority-vote passes
8. **Collision detection** simulates plate drift to classify convergent/divergent/transform boundaries
9. **Stress propagation** diffuses collision stress inward through continental plates via frontier BFS
10. **Elevation assignment** combines distance fields, stress-driven uplift, ocean floor profiles, rift valleys, back-arc basins, hotspot volcanism, island arcs, coastal roughening, and multi-layered noise
11. **Terrain post-processing** applies domain warping (controlled by Terrain Warp slider) using FBM simplex noise to deform the elevation field for organic coastlines and mountain ridges via greedy mesh walk, then bilateral smoothing (controlled by Smoothing slider) to blend BFS banding artefacts, glacial erosion (controlled by Glacial Erosion slider) carves fjords, U-shaped valleys, and lake basins at high latitudes and altitudes, priority-flood pit resolution carves canyons through mountain saddle points to ensure all land drains to the ocean, iterative implicit stream power hydraulic erosion with sediment deposition (controlled by Hydraulic Erosion slider) carves self-reinforcing river valleys, thermal erosion (controlled by Thermal Erosion slider) softens ridges via talus-angle material transport, ridge sharpening (controlled by Ridge Sharpening slider) accentuates mountain ridgelines, and always-on soil creep gently rounds off hillslopes
12. **Wind simulation** computes a longitude-varying ITCZ by scanning for the thermal maximum at each longitude (accounting for land/sea heating differential and elevation lapse rate), builds pressure fields from Gaussian zonal bands centered on the ITCZ plus land/sea thermal modifiers and elevation barometric effects, then derives wind vectors from pressure gradients with latitude-dependent Coriolis deflection and surface friction. Computed for both NH summer and winter.
13. **Ocean currents** uses a rule-based geographic approach: classifies ocean cells by wind belt (trades, westerlies, polar easterlies) to set base zonal flow, runs three BFS passes from coastal seeds to compute distance to western and eastern coastlines (classified by coast-normal direction), deflects currents poleward near western boundaries (warm, intensified ×2) and equatorward near eastern boundaries (cold, weaker ×0.8), detects circumpolar channels at ±60° latitude for unobstructed eastward flow, smooths with 5 Laplacian passes, and classifies heat transport by meridional flow direction. Computed for both seasons.
14. **Precipitation** uses a blended dual-model approach. The complex model computes moisture advection from coasts using iterative upwind propagation driven by wind vectors, with depletion based on distance and elevation gain, plus six mechanisms: ITCZ convective uplift, frontal convergence at subpolar lows, orographic rain/rain shadow, lee cyclogenesis, polar front diffuse precipitation, and seasonal subtropical high suppression (shifts poleward in local summer to create Mediterranean dry-summer patterns). A heuristic zonal model computes smooth precipitation from ITCZ distance (with aggressive subtropical drying at 15–30°), seasonal hemisphere boost with Mediterranean subtropical suppression (up to 55% summer reduction at 25-42° latitude), continental dryness, and orographic rain shadow. The two models are blended 50-50 then normalized via 95th-percentile scaling. Computed for both seasons.
15. **Temperature** computes per-cell surface temperature using the ITCZ as the thermal equator (28°C peak, warmest latitude band), with poleward cooling following a power-law curve (exponent 1.2, 13° tropical plateau, 52°C range). Modulated by seasonal hemisphere offset with latitude-dependent seasonal amplitude boost (up to ±12°C peaking at 55-75° latitude), continentality-scaled maritime factor (coast 0.50× to deep interior 1.20× seasonal swing), moisture-dependent elevation lapse rate (4.5 C/km in wet regions to 9.3 C/km in dry regions, interpolated by precipitation), ocean current warmth (16-pass diffusion onto coastal land, ±20°C effect with 0.95 continentality gate), and precipitation/cloud cover moderation. Normalized to a fixed -45 to +45 C range. Computed for both seasons.
16. **Rendering** builds a Voronoi cell mesh with per-vertex colors and terrain displacement
### Key Algorithms
- **Seeded PRNG** — Park-Miller LCG for deterministic generation
- **3D Simplex noise** — with fBm and ridged fBm variants for terrain detail
- **Harmonic-mean distance blending** — `(1/a - 1/b) / (1/a + 1/b + 1/c)` for smooth elevation transitions
- **Domain warping** — noise-driven coordinate offsets for organic coastlines
- **Density-based subduction** — tanh mapping of density differences with undulation noise
- **BFS distance fields** — randomized frontier expansion from boundary seeds, used for elevation, coast distance, rift width, ridge profiles, and back-arc basins
- **Gaussian dome uplift** — hotspot volcanism modeled as dual-component Gaussians (thermal swell + volcanic peak) with domain-warped shape distortion, anisotropic drift elongation, summit calderas, radial rift ridges, and age-dependent texture blending
## Project Structure
```
index.html Main page — HTML markup + import map + structured data
import.html Import page — heightmap upload + climate visualization
styles.css All CSS (shared by both pages)
robots.txt Search engine crawler directives
sitemap.xml Sitemap for search engine indexing
site.webmanifest Web app manifest (metadata + theming)
llms.txt AI/LLM-readable site description (AISEO)
humans.txt Project credits
CNAME Custom domain config (orogen.studio)
404.html Custom 404 page
preview.png Social preview image (og:image / Twitter card)
js/
main.js Generator entry point — UI wiring, animation loop
import-main.js Import page entry point — file upload, import dispatch, painted-map legend table
painted.js Painted-map import — legend parsing, pixel classification, region voting, stroke dissolution, coast roughening, uplift field, Braun-Willett stream-power solve
painted-layers.js Colours and legends for the painted layers (class, uplift, erodibility, drainage, slope, basins, overlay)
painted-report.js What a legend's numbers make before a solve: the divide and typical hillslope angles per class
painted-overlay.js The annotation layer — mark legend parsing, pixel classification, region voting, coast_jitter per region
painted-overlay-view.js The overlay sheet as a texture on the globe, the map and the exports
state.js Shared mutable application state
generate.js Worker dispatcher — posts jobs, handles results
planet-worker.js Web Worker — runs geology pipeline off main thread
planet-code.js Planet code encode/decode (seed + sliders → base36)
rng.js Seeded PRNG (Park-Miller LCG)
simplex-noise.js 3D Simplex noise with fBm and ridged fBm
color-map.js Elevation → RGB colour mapping + satellite biome colors
sphere-mesh.js Fibonacci sphere, Delaunay, SphereMesh dual-mesh
plates.js Tectonic plate generation (farthest-point seeding, round-robin flood fill, compactness constraints)
coarse-plates.js Resolution-independent plate pipeline — coarse reference grid, projection, boundary smoothing
super-plates.js Groups same-type plates into ~20 super plates for broad orogenic belts
ocean-land.js Ocean/land assignment with continent seeding
elevation.js Collisions, stress propagation, distance fields, elevation
terrain-post.js Domain warping, bilateral smoothing, glacial/hydraulic/thermal erosion, ridge sharpening, soil creep
climate-util.js Shared climate utilities — smoothing, ITCZ lookup, percentile selection
wind.js Seasonal wind simulation — pressure fields, ITCZ tracking, Coriolis wind
ocean.js Ocean surface currents — rule-based wind-belt gyres, coast BFS, circumpolar detection
precipitation.js Precipitation simulation — moisture advection, ITCZ/frontal/orographic effects, blended with heuristic
heuristic-precip.js Heuristic zonal precipitation model — smooth latitude/continentality/orographic patterns
temperature.js Temperature simulation — ITCZ thermal equator, lapse rate, continentality, ocean currents
scene.js Three.js scene, cameras, controls, lights
planet-mesh.js Voronoi mesh, map projection, hover highlight
edit-mode.js Ctrl-click plate multi-select + hover info
detail-scale.js Non-linear (power-curve) detail slider mapping
png-write.js 8-bit and 16-bit greyscale PNG encoders (a canvas gives neither)
unreal-render.js Renders a lon/lat window of the planet into a float raster of kilometres
unreal-export.js Cuts that raster into Unreal landscape tiles, derives the weightmaps, writes Region.json
unreal-ui.js The Unreal Landscape panel, built in JS so both pages share one copy
```
## Dependencies
Loaded via CDN import maps (no installation needed):
- [Three.js](https://threejs.org/) v0.160.0 — 3D rendering
- [Delaunator](https://github.com/mapbox/delaunator) v5.0.1 — 2D Delaunay triangulation
## License
This project is licensed under the GNU General Public License v3.0 — see [LICENSE](LICENSE) for details.
## Acknowledgments
Inspired by [Red Blob Games' planet generation](https://www.redblobgames.com/x/1843-planet-generation/) — Fibonacci sphere meshing, dual-mesh traversal, and distance-field elevation approach.
Additional inspiration and reference from:
- [Worldbuilding Pasta](https://worldbuildingpasta.blogspot.com/) — worldbuilding science and climate reference
- [Artifexian](https://www.youtube.com/@Artifexian) — worldbuilding tutorials and planetary science inspiration
- [Madeline James](https://www.youtube.com/@MadelineJamesWorldbuilds) ([website](https://www.madelinejameswrites.com/)) — worldbuilding methodology and climate design reference
- [Fractal Philosophy](https://www.youtube.com/watch?v=7xL0udlhnqI) — procedural terrain generation inspiration
+111
View File
@@ -0,0 +1,111 @@
# World Buildr — V1 Product Review
## What's Good (Strengths)
### Technical Foundation is Impressive
- The geology pipeline is genuinely sophisticated — tectonic plates, collision detection, stress propagation, distance fields, island arcs, hotspot volcanism, rift valleys, back-arc basins. This isn't a noise-on-a-sphere generator; it's a real tectonic simulation. That's the differentiator and it's strong.
- Deterministic planet codes with URL sharing is a killer feature for virality. Compact 11-char codes that fully reproduce a planet (including manual edits) is smart product thinking.
- Zero build step, no install, CDN-loaded deps. The lowest possible friction to get it running.
### UI is Clean and Focused
- The sidebar panel is well-organized with collapsible sections. Slider hints ("Coarse / Fine", "Supercontinent / Archipelago") are excellent — they tell users what the slider *means*, not just what it *does*.
- The `?` tooltip system on each slider is unobtrusive but available.
- The stale indicator (button turns orange "Rebuild" when sliders change) communicates state without words.
- Hover-to-highlight-plate with contextual info is discoverable and satisfying.
- The tutorial is lightweight (4 steps) and dismissable. Correct approach for a tool like this.
### Artistic Appeal is Solid
- Atmosphere rim shader, translucent water sphere, starfield — the globe looks like a planet, not a texture demo. The color ramp produces believable earth tones with good contrast between ocean/land/mountain/snow.
---
## What Needs Work for Market-Ready V1
### 1. Performance & Perceived Speed (High Priority)
**Generation blocks the main thread.** The `setTimeout(..., 16)` in `generate.js` lets the button state repaint, but the actual work is synchronous — at 200K+ cells, the browser locks for multiple seconds. Users will think the app is frozen.
- **Move generation to a Web Worker.** This is the single biggest UX improvement possible. It unblocks the UI, lets you show a progress bar, and prevents the browser's "page unresponsive" warning at high detail levels.
- At minimum, add a visible progress indicator (spinner, progress bar, or pulsing animation on the button) beyond just the text changing to "Building...".
### 2. Mobile & Responsive (High Priority)
- The UI panel is absolutely positioned at `top: 16px; left: 16px` with a fixed `min-width: 270px`. On mobile screens this will cover most of the viewport. There's no way to collapse or dismiss it.
- No `@media` queries anywhere in CSS. No touch gesture handling. Ctrl-click is impossible on mobile.
- **For V1:** At minimum, make the sidebar collapsible/toggleable on small screens. Consider touch-to-select as the mobile equivalent of Ctrl-click.
### 3. First Impression & Empty State (High Priority)
- When the page loads, it immediately starts generating a planet. That's fine — but there's no loading state visible before JS loads and executes. On slower connections, users see a black screen.
- **Add a lightweight loading indicator in pure HTML/CSS** (no JS dependency) that gets replaced when the app initializes.
### 4. Export & Practical Utility (Medium-High Priority)
Right now users can look at planets and share codes. But what can they *do* with what they've made? For a tool going to market, you need at least one export path:
- **Image export** — "Save as PNG" for the current view (globe or map). This is trivial with `renderer.domElement.toDataURL()` and immediately makes the tool useful for worldbuilding, RPGs, wallpapers.
- **Heightmap export** — a grayscale equirectangular PNG of the elevation data. This makes the tool usable in Unity, Unreal, Blender, and other 3D tools. This is the bridge from "cool demo" to "useful tool."
- **Consider STL/OBJ export** for 3D printing enthusiasts (lower priority but high wow-factor).
### 5. Color Map & Biome Richness (Medium Priority)
The current color map (`color-map.js`) is a single elevation-to-color function with 8 linear interpolation bands. It works, but:
- No latitude-based variation — polar regions look the same as the equator. Adding even a simple latitude tint (white toward poles, warmer at equator) would dramatically increase visual appeal.
- No biome differentiation — deserts, forests, tundra, ice caps are all absent. Even a simple noise-modulated biome layer on top of the elevation coloring would make planets feel more alive and give users something to discover as they rotate.
- The ocean coloring is uniform depth-based blue. Real oceans have color variation from coastal shallows (teal/cyan) to deep abyssal (near-black). The data is already there in `dist_coast`.
### 6. Accessibility & Discoverability (Medium Priority)
- **Ctrl-click is not discoverable.** It's mentioned in the tutorial and in small text at the bottom, but there's no visual affordance. Users who dismiss the tutorial will never find this feature. Consider a mode toggle button ("Edit Plates" on/off) that makes regular clicks toggle plates.
- **Keyboard shortcuts are absent.** Space to generate, R to toggle rotation, W for wireframe, etc. — these are cheap to add and power users will expect them.
- **No undo for plate edits.** Ctrl-click is destructive (triggers a full recompute). A simple undo stack (even just "undo last edit") would make editing feel safe.
### 7. Branding & Polish (Medium Priority)
- **Favicon** is `data:,` (empty). Add a real favicon — even a simple colored globe emoji rendered to a canvas.
- **No Open Graph / social meta tags.** When someone shares a planet URL on Twitter/Discord/Slack, it will show nothing. Add `og:title`, `og:description`, `og:image` (a static preview image) at minimum.
- **The title bar just says "World Buildr."** Consider dynamically updating it: "World Buildr — #a7f3kq9xp2b" when a planet is loaded, so browser tabs are identifiable.
- **No 404/error handling for bad hash codes.** If someone visits a URL with a corrupted hash, the error is silent. Show a brief toast message.
### 8. Code Architecture for Future Growth (Low-Medium Priority)
- **`elevation.js` is 970 lines** doing collision detection, stress propagation, distance fields, rift BFS, ridge BFS, fracture BFS, back-arc BFS, coastal roughening, island arcs, hotspot volcanism, and final elevation assembly — all in a single function. This will become unmaintainable. Even a basic extraction of each geological feature into its own function/file would help.
- **`buildDriftArrows` has an early `return`** on line 373 of `planet-mesh.js` — the entire function is dead code after it. Either remove it or finish it.
### 9. Map View Quality (Low-Medium Priority)
- The equirectangular map projection works but has visible triangle seams near the poles and antimeridian. For a market product, these artifacts reduce confidence in quality.
- Map view has no grid lines, labels, or legend. Even a simple lat/lon grid overlay would make it feel like a proper map.
### 10. Documentation & Landing (Low Priority for MVP, High for Marketing)
- The README is thorough for developers but there's no landing page, no screenshots, no GIF/video showing the tool in action. For a product going to market, the first thing someone sees should be a compelling visual, not a markdown file.
- Consider a simple landing section or splash that shows off the best-looking generated planet before asking users to interact.
---
## Priority Summary
| Priority | Item | Effort | Status |
|----------|------|--------|--------|
| **Must Have** | Web Worker for generation (no UI freeze) | Medium | |
| **Must Have** | Mobile-responsive sidebar (collapsible) | Low-Medium | Done |
| **Must Have** | Loading state before JS initializes | Low | Done |
| **Must Have** | Image export (PNG screenshot) | Low | |
| **Should Have** | Heightmap export (grayscale PNG) | Medium | |
| **Should Have** | Latitude-based color variation / basic biomes | Medium | |
| **Should Have** | OG/social meta tags for link previews | Low | |
| **Should Have** | Real favicon | Low | |
| **Should Have** | Edit mode toggle (not just Ctrl-click) | Low | |
| **Should Have** | Undo for plate edits | Low-Medium | |
| **Nice to Have** | Keyboard shortcuts | Low | |
| **Nice to Have** | Elevation.js refactor | Medium | |
| **Nice to Have** | Map view polish (grid lines, pole fixes) | Medium | Done |
| **Nice to Have** | Landing page / hero visual | Medium | |
---
## Bottom Line
The core of this product is genuinely strong — the tectonic simulation, the planet codes, and the clean UI put it well ahead of most procedural planet generators. What's missing for V1-to-market is mostly in the **"last mile" category**: making the output *usable* beyond just looking at it (exports), making it *work everywhere* (mobile), and making it *feel* polished (favicon, social previews, loading states, no UI freezes). The geology engine is the hard part, and that's already done. The rest is packaging.
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7a5d6e2da8117625318159574c907594337ea9cb9d18e35476af2e23f0edbb22
size 327162
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c422294c85f5327e5aeed6c50582864cf994aff84694391f561eeb556a56b05d
size 273264
+38
View File
@@ -0,0 +1,38 @@
{
"_comment": "The built-in legend for a painted map: what each colour means, in rock uplift (mm/yr) and erodibility, never height. It is the same schema Tools/Terrain reads (RawContent/World/Templates/Map3.legend.json in the Salty repo, with its commentary trimmed), so a painting and legend made for one tool load in the other. The optional planet block carries the numbers Planet.json holds there.",
"image": "painted-demo.png",
"warn_distance": 60,
"planet": {
"circumference_km": 100,
"massif_wavelength_km": 7,
"lithology_wavelength_km": 8,
"uplift_variation": 0.30,
"lithology": { "types": 3, "k_multipliers": [0.6, 1.0, 1.8] }
},
"classes": [
{ "name": "ocean", "rgb": [ 91, 175, 185], "sea": true, "depth_m": 512 },
{ "name": "deep", "rgb": [ 65, 165, 180], "sea": true, "depth_m": 512 },
{ "name": "shelf", "rgb": [153, 204, 221], "sea": true, "depth_m": 120 },
{ "name": "surf", "rgb": [221, 238, 238], "sea": true, "depth_m": 20 },
{ "name": "ice", "derived": true, "rgb": [250, 250, 250], "uplift_mm_yr": 0.05, "k_mult": 1.0,
"snow": true, "lithology_mix": 0 },
{ "name": "lowland", "rgb": [153, 204, 102], "uplift_mm_yr": 0.08, "k_mult": 1.0,
"massif": { "floor_mm_yr": 0.012, "fraction": 0.16 },
"coastal_plain_km": 1.0, "coastal_floor_mm_yr": 0.012 },
{ "name": "highland", "rgb": [ 68, 170, 102], "uplift_mm_yr": 0.25, "k_mult": 1.0,
"massif": { "floor_mm_yr": 0.045, "fraction": 0.30 },
"coastal_plain_km": 4.0, "coastal_floor_mm_yr": 0.03 },
{ "name": "desert", "rgb": [238, 221, 153], "uplift_mm_yr": 0.10, "k_mult": 0.5,
"massif": { "floor_mm_yr": 0.015, "fraction": 0.14 },
"coastal_plain_km": 1.5, "coastal_floor_mm_yr": 0.015 },
{ "name": "crater", "rgb": [124, 117, 111], "uplift_mm_yr": 0.15, "k_mult": 1.5,
"lithology_mix": 0 },
{ "name": "stroke", "rgb": [238, 238, 238], "stroke": true, "edge_class": "ice" }
]
}
+17
View File
@@ -0,0 +1,17 @@
/* TEAM */
Project: World Orogen
Site: https://orogen.studio
Contact: https://github.com/raguilar011095/planet_heightmap_generation
/* THANKS */
Red Blob Games — Fibonacci sphere meshing and planet generation inspiration
Three.js — 3D rendering
Delaunator — Delaunay triangulation
Worldbuilding Pasta (https://worldbuildingpasta.blogspot.com/) — worldbuilding science and climate reference
Artifexian (https://www.youtube.com/@Artifexian) — worldbuilding tutorials and planetary science inspiration
Madeline James (https://www.youtube.com/@MadelineJamesWorldbuilds, https://www.madelinejameswrites.com/) — worldbuilding methodology and climate design reference
Fractal Philosophy (https://www.youtube.com/watch?v=7xL0udlhnqI) — procedural terrain generation inspiration
/* SITE */
Standards: HTML5, ES Modules, WebGL
Software: Three.js 0.160.0, Delaunator 5.0.1
+410
View File
@@ -0,0 +1,410 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>World Orogen — Import Heightmap or Painted Map</title>
<meta name="description" content="Import your own equirectangular heightmap, or a painted map whose colours are uplift rates that stream-power erosion turns into terrain with real rivers, onto a 3D globe with automatic climate simulation — wind, precipitation, temperature, and K&ouml;ppen classification. Annotate it with an overlay of forests, settlements, roads and coastlines. Free, in your browser.">
<meta name="keywords" content="heightmap import, painted map, uplift, stream power erosion, equirectangular projection, climate simulation, world generator, terrain viewer, planet builder, map overlay, annotation layer, hillslope angle, Three.js, worldbuilding tool">
<meta name="author" content="World Orogen">
<meta name="theme-color" content="#0a0e17">
<link rel="canonical" href="https://orogen.studio/import">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://orogen.studio/import">
<meta property="og:title" content="World Orogen — Import Heightmap">
<meta property="og:description" content="Import your own equirectangular heightmap onto a 3D globe with automatic climate simulation. Free, in your browser.">
<meta property="og:image" content="https://orogen.studio/preview.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="World Orogen">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="World Orogen — Import Heightmap">
<meta name="twitter:description" content="Import your own equirectangular heightmap onto a 3D globe with automatic climate simulation. Free, in your browser.">
<meta name="twitter:image" content="https://orogen.studio/preview.png">
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
<link rel="manifest" href="site.webmanifest">
<link rel="author" href="humans.txt">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text x='50' y='50' dominant-baseline='central' text-anchor='middle' font-size='75'>🌍</text></svg>">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<nav id="topNav">
<a href="/" class="nav-tab">Generate</a>
<a href="/import" class="nav-tab active">Import</a>
</nav>
<div id="buildOverlay" class="build-overlay hidden">
<div class="build-overlay-inner">
<div class="build-overlay-spinner"></div>
<div class="build-overlay-title" id="buildOverlayTitle">Importing heightmap</div>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="buildBarFill"></div>
</div>
<div class="progress-label" id="buildBarLabel"></div>
</div>
</div>
<canvas id="canvas"></canvas>
<div id="ui">
<div id="sheetHandle" class="sheet-handle"><span></span></div>
<div id="sidebarHeader">
<div>
<h2>World Orogen</h2>
<div class="sub">Import a heightmap or a painted map</div>
</div>
<button id="sidebarToggle" title="Collapse panel">&#x00AB;</button>
</div>
<div id="sidebarContent">
<details class="section" open>
<summary>Import</summary>
<div class="section-body">
<div class="map-tabs source-tabs" id="sourceTabs">
<button class="map-tab active" data-source="heightmap">Heightmap</button>
<button class="map-tab" data-source="painted">Painted Map</button>
</div>
<div class="src-heightmap">
<div class="import-upload-area">
<label class="import-file-label" for="heightmapFile">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Choose Image
</label>
<input type="file" id="heightmapFile" accept="image/png,image/jpeg,image/webp" style="display:none">
<div id="importFileName" class="import-file-name"></div>
</div>
<canvas id="importPreview" class="import-preview" style="display:none"></canvas>
<div id="importDims" class="import-dims" style="display:none"></div>
<div class="import-hint">Black (0) = ocean. Brighter = higher elevation. Use a 2:1 equirectangular image.</div>
<div id="importExpect" class="import-expect" style="display:none">Your heightmap will be projected onto a 3D sphere with full climate simulation — wind patterns, precipitation, temperature, and biome classification — computed automatically.</div>
</div>
<div class="src-painted">
<div class="import-upload-area">
<label class="import-file-label" for="paintFile">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Choose Painting
</label>
<input type="file" id="paintFile" accept="image/png,image/jpeg,image/webp" style="display:none">
<div id="paintFileName" class="import-file-name"></div>
</div>
<div class="import-upload-area">
<label class="import-file-label" for="legendFile">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/></svg>
Choose Legend
</label>
<input type="file" id="legendFile" accept="application/json,.json" style="display:none">
<div id="legendFileName" class="import-file-name">Built-in legend</div>
</div>
<div class="import-upload-area">
<label class="import-file-label" for="planetFile">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
Choose Planet.json
</label>
<input type="file" id="planetFile" accept="application/json,.json" style="display:none">
<div id="planetFileName" class="import-file-name">Defaults</div>
</div>
<canvas id="paintPreview" class="import-preview" style="display:none"></canvas>
<div id="paintDims" class="import-dims" style="display:none"></div>
<div class="import-hint">Every colour is a <em>class</em> — a rate of rock uplift and an erodibility, never a height — and the legend JSON says what the colours mean. The land is then eroded with stream power until it is in balance with its uplift, so the rivers, divides and valleys are the physics' answer to your painting. 2:1 equirectangular. <a href="#" id="paintDemoLink">Load the demo painting</a>.</div>
<div id="paintReport" class="import-report" style="display:none"></div>
<div id="paintLegendWrap" style="display:none">
<table class="paint-legend" id="paintLegend">
<thead><tr><th></th><th>Class</th><th>Painted</th><th>Uplift <span class="unit">mm/yr</span> / depth <span class="unit">m</span></th><th>k</th><th>Slope <span class="tip" data-tip="The typical (median) hillslope this rate makes on the bake's geology grid, and what it reads as. Hover a value for the divide angle, the steepest ground the rate can make: almost none of a map is divide, so read this column, not that one">?</span></th></tr></thead>
<tbody></tbody>
</table>
<div id="legendNote" class="import-hint legend-note"></div>
<button id="legendDownload" class="btn-ghost btn-small" title="Save the legend with your edits, in the format Tools/Terrain reads">Download legend JSON</button>
</div>
<div class="cg">
<label>Peak Height <span class="tip" data-tip="How high the land stands: the 99.5th percentile of the solved terrain is put at this height. The shape — where the rivers run, how far a coast is from its divide — is the solve's; this is only the scale">?</span> <span class="v" id="vPk">4.5 km</span></label>
<input type="range" id="sPk" min="0.5" max="6" value="4.5" step="0.25">
<div class="slider-hint"><span>Hills</span><span>Alps</span></div>
</div>
<div class="cg">
<label>Ocean Depth <span class="tip" data-tip="The depth of the deepest sea class; shallower classes (shelf, surf) scale with it, and every shore ramps down over a couple of cells">?</span> <span class="v" id="vOd">4.0 km</span></label>
<input type="range" id="sOd" min="0.25" max="5" value="4" step="0.25">
<div class="slider-hint"><span>Shallow</span><span>Abyssal</span></div>
</div>
<div class="cg">
<label>Coast Detail <span class="tip" data-tip="A drawn shore is a smooth curve and a real one is fractal. Noise is added to the distance from the painted waterline before anything is solved, so bays and headlands appear where the brush was straight. Islets keep at least a third of their width">?</span> <span class="v" id="vCd">0.35</span></label>
<input type="range" id="sCd" min="0" max="1" value="0.35" step="0.05">
<div class="slider-hint"><span>As drawn</span><span>Ragged</span></div>
</div>
<div class="cg">
<label>Uplift Variation <span class="tip" data-tip="A regional swell over the painted rate, so a lowland painted in one colour has basins and rises of its own rather than one flat rate across a continent">?</span> <span class="v" id="vUv">0.30</span></label>
<input type="range" id="sUv" min="0" max="0.6" value="0.3" step="0.05">
<div class="slider-hint"><span>Uniform</span><span>Rolling</span></div>
</div>
<div class="cg">
<label>Solve Steps <span class="tip" data-tip="How long the stream-power solve runs. It converges within a few hundred steps at any detail; more steps cost time and change little">?</span> <span class="v" id="vSt">200</span></label>
<input type="range" id="sSt" min="50" max="600" value="200" step="10">
<div class="slider-hint"><span>Quick</span><span>Settled</span></div>
</div>
<details class="subsection">
<summary>Painted planet</summary>
<div class="subsection-body">
<div class="import-hint">The globe is Earth-sized. Distances in the legend — coastal plains, massif and rock sizes — are in the painted world's kilometres and are scaled up by the ratio of the two circumferences.</div>
<div class="num-row"><label for="nCirc">Painted world circumference, km</label><input type="number" id="nCirc" value="100" min="1" step="1"></div>
<div class="num-row"><label for="nMassif">Massif size, km <span class="tip" data-tip="The size of the blocks in the planet's upland fabric. A class with a massif block is a plain with hill masses standing out of it; this is how big they are">?</span></label><input type="number" id="nMassif" value="7" min="0" step="0.5"></div>
<div class="num-row"><label for="nLith">Rock province size, km <span class="tip" data-tip="The size of the provinces in the planet's rock field, which multiplies each class's erodibility so a range is made of several rocks rather than one">?</span></label><input type="number" id="nLith" value="8" min="0" step="0.5"></div>
<div class="num-row"><label for="nSeed">Seed <span class="tip" data-tip="A painting is a composition; the seed re-rolls what it does not fix — the massifs, the rock provinces, the swell and the coastline detail">?</span></label><input type="number" id="nSeed" value="7945" min="0" step="1"><button id="paintReroll" class="btn-ghost btn-small" title="New seed">Re-roll</button></div>
</div>
</details>
<details class="subsection" id="overlaySection">
<summary>Overlay</summary>
<div class="subsection-body">
<div class="import-hint">A second painting the same size as the template and registered to it, whose colours are <em>marks</em> rather than classes: forests, settlements, roads, and stretches of coast to leave alone. Blank is transparent. Nothing on it changes a height except <code>coast_jitter</code>, which pins (0) or roughens (above 1) the shore inside the mark.</div>
<div class="import-upload-area">
<label class="import-file-label" for="overlayFile">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 15l5-5 4 4 3-3 6 6"/><circle cx="16" cy="8" r="1.5"/></svg>
Choose Overlay
</label>
<input type="file" id="overlayFile" accept="image/png,image/webp" style="display:none">
<div id="overlayFileName" class="import-file-name"></div>
</div>
<div class="import-upload-area">
<label class="import-file-label" for="overlayLegendFile">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/></svg>
Choose Overlay Legend
</label>
<input type="file" id="overlayLegendFile" accept="application/json,.json" style="display:none">
<div id="overlayLegendFileName" class="import-file-name"></div>
</div>
<div id="overlayReport" class="import-report" style="display:none"></div>
</div>
</details>
<details class="subsection" id="studioSection">
<summary>From terrain studio</summary>
<div class="subsection-body">
<div class="import-hint">Load the painting, both legends and Planet.json straight from a running <code>terrain studio</code> (Tools/Terrain), exactly as its next plan would read them.</div>
<div class="num-row"><label for="studioUrl">Studio address</label><input type="text" id="studioUrl" value="http://127.0.0.1:8099" spellcheck="false"></div>
<button id="studioLoad" class="btn-ghost btn-small">Load from studio</button>
<div id="studioNote" class="import-report" style="display:none"></div>
</div>
</details>
</div>
<div class="cg">
<label>Detail <span class="tip" data-tip="Resolution of the sphere mesh — more detail means finer coastlines but takes longer to process">?</span> <span class="v" id="vN">204,000</span></label>
<input type="range" id="sN" min="0" max="1000" step="1" value="600">
<div class="slider-hint"><span>Coarse</span><span>Fine</span></div>
<div class="detail-warn" id="detailWarn"></div>
</div>
<button id="importBtn" class="import-btn" disabled>Import</button>
<div class="import-hint" style="margin-top:8px">Your image stays on your device — nothing is uploaded. Imported worlds can't be shared via planet codes.</div>
</div>
</details>
<details class="section">
<summary>Terrain Sculpting <span class="tip" data-tip="Post-processing passes to refine the imported terrain. All default to 0 (no effect). Adjust sliders and click Reapply.">?</span></summary>
<div class="section-body">
<div class="cg">
<label>Terrain Warp <span class="tip" data-tip="Deforms the elevation field using noise for more organic coastlines and mountain ridges">?</span> <span class="v" id="vTw">0.00</span></label>
<input type="range" id="sTw" min="0" max="1" value="0" step="0.05">
<div class="slider-hint"><span>None</span><span>Heavy</span></div>
</div>
<div class="cg">
<label>Smoothing <span class="tip" data-tip="Blends harsh terrain edges and pixelation from the source image">?</span> <span class="v" id="vS">0.00</span></label>
<input type="range" id="sS" min="0" max="1" value="0" step="0.05">
<div class="slider-hint"><span>None</span><span>Heavy</span></div>
</div>
<div class="cg">
<label>Glacial Erosion <span class="tip" data-tip="Ice-age sculpting — carves fjords, U-shaped valleys, and lake basins at high latitudes and altitudes">?</span> <span class="v" id="vGl">0.00</span></label>
<input type="range" id="sGl" min="0" max="1" value="0" step="0.05">
<div class="slider-hint"><span>None</span><span>Ice Age</span></div>
</div>
<div class="cg">
<label>Hydraulic Erosion <span class="tip" data-tip="Iterative stream-power erosion — carves river valleys and dendritic drainage networks">?</span> <span class="v" id="vHEr">0.00</span></label>
<input type="range" id="sHEr" min="0" max="1" value="0" step="0.05">
<div class="slider-hint"><span>None</span><span>Deep</span></div>
</div>
<div class="cg">
<label>Thermal Erosion <span class="tip" data-tip="Slope-driven material transport — softens ridges and creates natural talus slopes">?</span> <span class="v" id="vTEr">0.00</span></label>
<input type="range" id="sTEr" min="0" max="1" value="0" step="0.05">
<div class="slider-hint"><span>None</span><span>Heavy</span></div>
</div>
<div class="cg">
<label>Ridge Sharpening <span class="tip" data-tip="Accentuates mountain ridgelines — pushes peaks further above their surroundings">?</span> <span class="v" id="vRs">0.00</span></label>
<input type="range" id="sRs" min="0" max="1" value="0" step="0.05">
<div class="slider-hint"><span>None</span><span>Jagged</span></div>
</div>
<button id="reapplyBtn" title="Reapply terrain sculpting" disabled><span class="reapply-icon">&#x21bb;</span> Reapply</button>
</div>
</details>
<details class="section">
<summary>Climate <span class="tip" data-tip="Global temperature and precipitation offsets — changes apply on slider release, recomputing only precipitation, temperature, and climate zones">?</span></summary>
<div class="section-body">
<div class="climate-hint">Changes apply on release — only climate zones are recomputed.</div>
<div class="cg">
<label>Temperature <span class="tip" data-tip="Shift global temperature — positive makes the planet warmer, negative makes it colder. Climate zones shift accordingly.">?</span> <span class="v" id="vTmp">&plusmn;0&deg;C</span></label>
<input type="range" id="sTmp" min="-15" max="15" value="0" step="1">
<div class="slider-hint"><span>Colder</span><span>Warmer</span></div>
</div>
<div class="cg">
<label>Precipitation <span class="tip" data-tip="Scale global precipitation — positive makes the planet wetter, negative drier. Affects desert and rainforest distribution.">?</span> <span class="v" id="vPrc">&plusmn;0%</span></label>
<input type="range" id="sPrc" min="-1" max="1" value="0" step="0.1">
<div class="slider-hint"><span>Drier</span><span>Wetter</span></div>
</div>
</div>
</details>
<details class="section" open>
<summary>Visual Options</summary>
<div class="section-body">
<div class="cg">
<label>View</label>
<select id="viewMode">
<option value="globe">Globe</option>
<option value="map">Map</option>
</select>
</div>
<div class="cg" id="mapCenterLonGroup" style="display:none">
<label>Center Longitude <span class="v" id="vMapCenterLon">0&deg;</span></label>
<input type="range" id="sMapCenterLon" min="-180" max="180" value="0" step="5">
</div>
<div class="map-tabs" id="mapTabs">
<button class="map-tab active" data-layer="">Terrain</button>
<button class="map-tab" data-layer="biome">Satellite</button>
<button class="map-tab" data-layer="koppen">Climate</button>
<button class="map-tab" data-layer="landheightmap">Heightmap</button>
</div>
<div class="cg">
<label>Inspect</label>
<select id="debugLayer">
<option value="">Terrain</option>
<option value="biome">Satellite</option>
<option value="koppen">K&ouml;ppen Climate</option>
<option value="landheightmap">Land Heightmap</option>
<optgroup label="Atmosphere">
<option value="pressureSummer">Pressure (Summer)</option>
<option value="pressureWinter">Pressure (Winter)</option>
<option value="windSpeedSummer">Wind Speed (Summer)</option>
<option value="windSpeedWinter">Wind Speed (Winter)</option>
</optgroup>
<optgroup label="Ocean">
<option value="oceanCurrentSummer">Currents (Summer)</option>
<option value="oceanCurrentWinter">Currents (Winter)</option>
</optgroup>
<optgroup label="Climate">
<option value="precipSummer">Precipitation (Summer)</option>
<option value="precipWinter">Precipitation (Winter)</option>
<option value="rainShadowSummer">Rain Shadow (Summer)</option>
<option value="rainShadowWinter">Rain Shadow (Winter)</option>
<option value="tempSummer">Temperature (Summer)</option>
<option value="tempWinter">Temperature (Winter)</option>
<option value="continentality">Continentality</option>
</optgroup>
<optgroup label="Elevation">
<option value="heightmap">Full Heightmap</option>
<option value="erosionDelta">Erosion Delta</option>
</optgroup>
<optgroup label="Painted Map" id="paintedInspectGroup">
<option value="paintClass" disabled>Class Map</option>
<option value="paintUplift" disabled>Uplift Rate</option>
<option value="paintK" disabled>Erodibility</option>
<option value="flow" disabled>Drainage (Rivers)</option>
<option value="slope" disabled>Slope</option>
<option value="basins" disabled>Drainage Basins</option>
<option value="paintOverlay" disabled>Overlay</option>
</optgroup>
</select>
</div>
<div id="vizLegend" class="viz-legend"></div>
<div class="tg">
<label class="toggle-label"><input type="checkbox" id="chkWire"><span class="toggle-track"><span class="toggle-thumb"></span></span>Wireframe</label>
<label class="toggle-label"><input type="checkbox" id="chkRotate"><span class="toggle-track"><span class="toggle-thumb"></span></span>Auto-Rotate</label>
<label class="toggle-label"><input type="checkbox" id="chkGrid" checked><span class="toggle-track"><span class="toggle-thumb"></span></span>Grid Lines</label>
<label class="toggle-label src-painted" title="Drape the overlay sheet over whatever layer is shown"><input type="checkbox" id="chkOverlay" disabled><span class="toggle-track"><span class="toggle-thumb"></span></span>Overlay Sheet</label>
</div>
<div class="cg" id="gridSpacingGroup">
<label>Grid Spacing</label>
<select id="gridSpacing">
<option value="30">30&deg;</option>
<option value="15" selected>15&deg;</option>
<option value="10">10&deg;</option>
<option value="5">5&deg;</option>
<option value="2.5">2.5&deg;</option>
</select>
</div>
<details id="statsDetails" class="stats-toggle">
<summary>Stats</summary>
<div id="stats"></div>
</details>
</div>
</details>
<button id="exportBtn" class="export-btn">Export Map</button>
<a id="repoLink" href="https://github.com/raguilar011095/planet_heightmap_generation" target="_blank" rel="noopener">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.64 7.64 0 0 1 4 0c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
GitHub
</a>
</div>
</div>
<select id="mobileViewSwitch" class="mobile-view-switch">
<option value="">Terrain</option>
<option value="biome">Satellite</option>
<option value="koppen">Climate</option>
<option value="landheightmap">Heightmap</option>
</select>
<div id="exportOverlay" class="hidden">
<div id="exportCard">
<button id="exportClose">&times;</button>
<h3>Export Map</h3>
<div class="cg">
<label>Type</label>
<select id="exportType">
<option value="color">Color Map</option>
<option value="biome">Satellite</option>
<option value="koppen">Climate (K&ouml;ppen)</option>
<option value="heightmap">Heightmap (B&amp;W)</option>
<option value="landheightmap">Land Heightmap (B&amp;W)</option>
<option value="landmask">Land Mask (B&amp;W)</option>
<optgroup label="Painted Map" id="paintedExportGroup">
<option value="paintclass" disabled>Class Map</option>
<option value="uplift" disabled>Uplift Rate</option>
<option value="erodibility" disabled>Erodibility</option>
<option value="flow" disabled>Drainage (Rivers)</option>
<option value="slope" disabled>Slope</option>
<option value="basins" disabled>Drainage Basins</option>
<option value="overlay" disabled>Overlay</option>
</optgroup>
</select>
</div>
<div class="cg">
<label>Width <span class="v" id="exportDims">4096 &times; 2048</span></label>
<select id="exportWidth">
<option value="1024">1024</option>
<option value="2048">2048</option>
<option value="4096" selected>4096</option>
<option value="8192">8192</option>
<option value="16384">16384</option>
<option value="32768">32768</option>
<option value="65536">65536</option>
</select>
</div>
<div class="export-actions">
<button id="exportCancel" class="btn-ghost">Cancel</button>
<button id="exportGo" class="btn-primary">Export</button>
<button id="exportAllGo" class="btn-primary">Export All</button>
</div>
</div>
</div>
<!-- Hidden elements for generate.js compatibility (reads these by ID) -->
<input type="checkbox" id="chkPlates" style="display:none">
<input type="hidden" id="sLc" value="0.3">
<div id="topInfo">Import an equirectangular heightmap to visualize it on a globe</div>
<div id="hoverInfo"></div>
<div id="info">Import an equirectangular B&amp;W heightmap to get started</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
"delaunator": "https://cdn.jsdelivr.net/npm/delaunator@5.0.1/+esm"
}
}
</script>
<script type="module" src="js/import-main.js"></script>
</body>
</html>
+530
View File
@@ -0,0 +1,530 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>World Orogen — Procedural Planet Generator</title>
<meta name="description" content="Generate realistic procedural planets with tectonic plates, erosion, climate simulation, and volcanic islands — free, in your browser. Export heightmaps for worldbuilding, games, and tabletop RPGs.">
<meta name="keywords" content="procedural planet generator, world generator, heightmap generator, tectonic simulation, worldbuilding tool, fantasy map maker, terrain generator, planet builder, procedural generation, Three.js, tabletop RPG map, D&D world map, game dev heightmap">
<meta name="author" content="World Orogen">
<meta name="theme-color" content="#0a0e17">
<link rel="canonical" href="https://orogen.studio/">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://orogen.studio/">
<meta property="og:title" content="World Orogen — Procedural Planet Generator">
<meta property="og:description" content="Generate realistic procedural planets with tectonic plates, erosion, climate simulation, and volcanic islands — free, in your browser. Export heightmaps for worldbuilding, games, and tabletop RPGs.">
<meta property="og:image" content="https://orogen.studio/preview.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="World Orogen">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="World Orogen — Procedural Planet Generator">
<meta name="twitter:description" content="Generate realistic procedural planets with tectonic plates, erosion, climate simulation, and volcanic islands — free, in your browser.">
<meta name="twitter:image" content="https://orogen.studio/preview.png">
<!-- Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "World Orogen",
"url": "https://orogen.studio/",
"description": "A browser-based procedural planet generator that creates realistic terrestrial planets with tectonic plate simulation, erosion, climate modeling, and interactive editing. Export heightmaps, satellite views, and climate maps for worldbuilding, game development, and tabletop RPGs.",
"applicationCategory": "DesignApplication",
"operatingSystem": "Any (browser-based)",
"browserRequirements": "Requires a modern browser with WebGL support",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"featureList": [
"Tectonic plate simulation with collision detection",
"Glacial, hydraulic, and thermal erosion",
"Climate simulation with wind, precipitation, and ocean currents",
"Hotspot volcanism with island chains",
"Interactive plate editing",
"Equirectangular heightmap import with automatic climate simulation",
"Equirectangular map export up to 65536px",
"Unreal Engine landscape tile export with recorded scale and projection",
"Multiple visualization modes (terrain, satellite, climate, heightmap)",
"Shareable planet codes"
],
"screenshot": "https://orogen.studio/preview.png",
"softwareVersion": "1.0",
"creator": {
"@type": "Organization",
"name": "World Orogen"
}
}
</script>
<!-- FAQ Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What is World Orogen?",
"acceptedAnswer": {
"@type": "Answer",
"text": "World Orogen is a free, browser-based procedural planet generator. It creates realistic terrestrial planets with tectonic plate simulation, multiple erosion types, climate modeling, and volcanic features. No download or account required — it runs entirely in your browser."
}
},
{
"@type": "Question",
"name": "Can I use the exported maps in my own projects?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. World Orogen exports high-resolution equirectangular maps (up to 65,536px wide) in multiple formats: color terrain, satellite biome, Köppen climate, heightmap, and land mask. These can be used in game engines like Unity or Unreal, tabletop RPG campaigns, worldbuilding projects, or any creative work."
}
},
{
"@type": "Question",
"name": "What browsers does World Orogen support?",
"acceptedAnswer": {
"@type": "Answer",
"text": "World Orogen works in any modern browser with WebGL support, including Chrome, Firefox, Safari, and Edge. It works on both desktop and mobile devices."
}
},
{
"@type": "Question",
"name": "How do I share a planet with someone?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Every generated planet has a unique planet code shown below the Build button. Copy the code or share the URL directly — anyone can paste the code or open the link to recreate your exact planet, including any plates you've edited."
}
}
]
}
</script>
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
<link rel="manifest" href="site.webmanifest">
<link rel="author" href="humans.txt">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text x='50' y='50' dominant-baseline='central' text-anchor='middle' font-size='75'>🌍</text></svg>">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<nav id="topNav">
<a href="/" class="nav-tab active">Generate</a>
<a href="/import" class="nav-tab">Import</a>
</nav>
<!-- Semantic content for search engines and AI crawlers (visually hidden) -->
<main aria-hidden="true" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap">
<h1>World Orogen — Procedural Planet Generator</h1>
<p>World Orogen is a free, browser-based procedural planet generator. Build realistic terrestrial planets shaped by tectonic plates, erosion, and volcanism — no download or account required.</p>
<h2>How It Works</h2>
<p>Start by adjusting sliders for detail level, number of tectonic plates, continents, and terrain roughness. Click "Build New World" to generate a unique planet with realistic continental shapes, mountain ranges, ocean trenches, volcanic island chains, and hotspot volcanism. Every planet is procedurally generated and completely unique.</p>
<h2>Key Features</h2>
<ul>
<li>Tectonic plate simulation with convergent, divergent, and transform boundaries</li>
<li>Multiple erosion types: glacial (fjords, U-shaped valleys), hydraulic (river valleys), and thermal (talus slopes)</li>
<li>Climate simulation with seasonal wind patterns, ocean currents, precipitation, and K&ouml;ppen climate classification</li>
<li>Hotspot volcanism with drift-trail island chains (like Hawaii)</li>
<li>Interactive plate editing — select multiple plates for batch land/ocean toggling with visual preview before rebuild</li>
<li>Heightmap import — bring your own equirectangular B&amp;W heightmap (Earth, Mars, hand-drawn) and run full climate simulation on it</li>
<li>Multiple visualization modes: terrain, satellite biome, climate, and heightmap</li>
<li>26 detailed inspection layers for geology, atmosphere, ocean, and climate</li>
<li>High-resolution equirectangular map export up to 65,536px wide</li>
<li>Shareable planet codes — copy a code to share your exact planet with others</li>
</ul>
<h2>Use Cases</h2>
<p>World Orogen is used by worldbuilders, game developers, tabletop RPG players, fantasy authors, and anyone who needs realistic terrain. Export heightmaps for use in game engines like Unity or Unreal, or download satellite-style maps for your D&amp;D campaign setting. The climate simulation produces realistic biome distributions for believable fantasy worlds.</p>
<h2>Share Your World</h2>
<p>Every planet has a unique planet code. Share the URL (e.g. orogen.studio/#CODE) to let anyone recreate your exact world — including any tectonic plates you've edited by hand.</p>
</main>
<div id="buildOverlay" class="build-overlay initial">
<div class="build-overlay-inner">
<div class="build-overlay-spinner"></div>
<div class="build-overlay-title">World Orogen</div>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="buildBarFill"></div>
</div>
<div class="progress-label" id="buildBarLabel"></div>
</div>
</div>
<canvas id="canvas"></canvas>
<div id="ui">
<div id="sheetHandle" class="sheet-handle"><span></span></div>
<div id="sidebarHeader">
<div>
<h2>World Orogen</h2>
<div class="sub">Build worlds shaped by tectonic forces</div>
</div>
<button id="sidebarToggle" title="Collapse panel">&#x00AB;</button>
</div>
<div id="sidebarContent">
<details class="section" open>
<summary>Shape Your World</summary>
<div class="section-body">
<div class="cg">
<label>Detail <span class="tip" data-tip="Resolution of the sphere — more detail means finer coastlines and terrain, but takes longer to generate">?</span> <span class="v" id="vN">204,000</span></label>
<input type="range" id="sN" min="0" max="1000" step="1" value="600">
<div class="slider-hint"><span>Coarse</span><span>Fine</span></div>
<div class="detail-warn" id="detailWarn"></div>
</div>
<div class="cg">
<label>Irregularity <span class="tip" data-tip="How randomly the cell points are scattered — 0 gives a uniform grid, 1 gives fully organic, irregular shapes">?</span> <span class="v" id="vJ">0.75</span></label>
<input type="range" id="sJ" min="0" max="1" value="0.75" step="0.05">
<div class="slider-hint"><span>Uniform</span><span>Scattered</span></div>
</div>
<div class="cg">
<label>Plates <span class="tip" data-tip="Number of tectonic plates — more plates means more boundaries where mountains, trenches, and coastlines form">?</span> <span class="v" id="vP">80</span></label>
<input type="range" id="sP" min="4" max="120" value="80" step="1">
<div class="slider-hint"><span>Few</span><span>Many</span></div>
</div>
<div class="cg">
<label>Continents <span class="tip" data-tip="Target number of landmasses — 1 creates a supercontinent, higher values scatter land into multiple continents and archipelagos">?</span> <span class="v" id="vCn">4</span></label>
<input type="range" id="sCn" min="1" max="10" value="4" step="1">
<div class="slider-hint"><span>Supercontinent</span><span>Archipelago</span></div>
</div>
<div class="cg">
<label>Roughness <span class="tip" data-tip="Terrain roughness — higher values add more fractal detail to mountains and coastlines">?</span> <span class="v" id="vNs">0.40</span></label>
<input type="range" id="sNs" min="0" max="0.5" value="0.40" step="0.01">
<div class="slider-hint"><span>Smooth</span><span>Rugged</span></div>
</div>
<div class="cg">
<label>Continent Size Variety <span class="tip" data-tip="How much continent sizes vary — at 0 all continents are similar in size, at 1 you get a mix of large and small landmasses">?</span> <span class="v" id="vCsv">0.35</span></label>
<input type="range" id="sCsv" min="0" max="1" value="0.35" step="0.05">
<div class="slider-hint"><span>Equal</span><span>Varied</span></div>
</div>
<div class="cg">
<label>Land Coverage <span class="tip" data-tip="Percentage of the planet covered by land — low values create ocean worlds, high values create desert worlds with reduced precipitation">?</span> <span class="v" id="vLc">30%</span></label>
<input type="range" id="sLc" min="0" max="1" value="0.3" step="0.01">
<div class="slider-hint"><span>Ocean World</span><span>Desert World</span></div>
</div>
</div>
</details>
<button id="generate">Build New World</button>
<div id="seedRow">
<input type="text" id="seedCode" placeholder="Planet code">
<button id="copyBtn" title="Copy code">&#x2398;</button>
<button id="loadBtn" title="Load planet from code">Load</button>
</div>
<div id="seedError">Invalid planet code</div>
<details class="section">
<summary>Terrain Sculpting <span class="tip" data-tip="Erosion and smoothing passes that refine the raw terrain. Adjust sliders and click Reapply — no full rebuild needed.">?</span></summary>
<div class="section-body">
<div class="cg">
<label>Terrain Warp <span class="tip" data-tip="Deforms the elevation field on the sphere using noise, producing more organic, squiggly coastlines and mountain ridges">?</span> <span class="v" id="vTw">0.75</span></label>
<input type="range" id="sTw" min="0" max="1" value="0.75" step="0.05">
<div class="slider-hint"><span>None</span><span>Heavy</span></div>
</div>
<div class="cg">
<label>Smoothing <span class="tip" data-tip="Blends harsh terrain boundaries from tectonic generation — smooths banded ridges and abrupt transitions">?</span> <span class="v" id="vS">0.10</span></label>
<input type="range" id="sS" min="0" max="1" value="0.10" step="0.05">
<div class="slider-hint"><span>None</span><span>Heavy</span></div>
</div>
<div class="cg">
<label>Glacial Erosion <span class="tip" data-tip="Ice-age sculpting — carves fjords, U-shaped valleys, and lake basins at high latitudes and altitudes">?</span> <span class="v" id="vGl">0.50</span></label>
<input type="range" id="sGl" min="0" max="1" value="0.50" step="0.05">
<div class="slider-hint"><span>None</span><span>Ice Age</span></div>
</div>
<div class="cg">
<label>Hydraulic Erosion <span class="tip" data-tip="Iterative stream-power erosion — carves river valleys and dendritic drainage networks">?</span> <span class="v" id="vHEr">0.50</span></label>
<input type="range" id="sHEr" min="0" max="1" value="0.50" step="0.05">
<div class="slider-hint"><span>None</span><span>Deep</span></div>
</div>
<div class="cg">
<label>Thermal Erosion <span class="tip" data-tip="Slope-driven material transport — softens ridges and creates natural talus slopes">?</span> <span class="v" id="vTEr">0.10</span></label>
<input type="range" id="sTEr" min="0" max="1" value="0.10" step="0.05">
<div class="slider-hint"><span>None</span><span>Heavy</span></div>
</div>
<div class="cg">
<label>Ridge Sharpening <span class="tip" data-tip="Accentuates mountain ridgelines — pushes peaks further above their surroundings for more dramatic terrain">?</span> <span class="v" id="vRs">0.50</span></label>
<input type="range" id="sRs" min="0" max="1" value="0.50" step="0.05">
<div class="slider-hint"><span>None</span><span>Jagged</span></div>
</div>
<button id="reapplyBtn" title="Reapply terrain sculpting" disabled><span class="reapply-icon">&#x21bb;</span> Reapply</button>
</div>
</details>
<details class="section">
<summary>Climate <span class="tip" data-tip="Global temperature and precipitation offsets — changes apply on slider release, recomputing only precipitation, temperature, and climate zones">?</span></summary>
<div class="section-body">
<div class="climate-hint">Changes apply on release — only climate zones are recomputed.</div>
<div class="cg">
<label>Temperature <span class="tip" data-tip="Shift global temperature — positive makes the planet warmer, negative makes it colder. Climate zones shift accordingly.">?</span> <span class="v" id="vTmp">±0°C</span></label>
<input type="range" id="sTmp" min="-15" max="15" value="0" step="1">
<div class="slider-hint"><span>Colder</span><span>Warmer</span></div>
</div>
<div class="cg">
<label>Precipitation <span class="tip" data-tip="Scale global precipitation — positive makes the planet wetter, negative drier. Affects desert and rainforest distribution.">?</span> <span class="v" id="vPrc">±0%</span></label>
<input type="range" id="sPrc" min="-1" max="1" value="0" step="0.1">
<div class="slider-hint"><span>Drier</span><span>Wetter</span></div>
</div>
</div>
</details>
<details class="section" open>
<summary>Visual Options</summary>
<div class="section-body">
<div class="cg">
<label>View</label>
<select id="viewMode">
<option value="globe">Globe</option>
<option value="map">Map</option>
</select>
</div>
<div class="cg" id="mapCenterLonGroup" style="display:none">
<label>Center Longitude <span class="v" id="vMapCenterLon">0°</span></label>
<input type="range" id="sMapCenterLon" min="-180" max="180" value="0" step="5">
</div>
<div class="map-tabs" id="mapTabs">
<button class="map-tab active" data-layer="">Terrain</button>
<button class="map-tab" data-layer="biome">Satellite</button>
<button class="map-tab" data-layer="koppen">Climate</button>
<button class="map-tab" data-layer="landheightmap">Heightmap</button>
</div>
<div class="cg">
<label>Inspect</label>
<select id="debugLayer">
<option value="">Terrain</option>
<option value="biome">Satellite</option>
<option value="koppen">K&ouml;ppen Climate</option>
<option value="landheightmap">Land Heightmap</option>
<optgroup label="Geology">
<option value="base">Base</option>
<option value="tectonic">Tectonic</option>
<option value="noise">Noise</option>
<option value="interior">Interior</option>
<option value="coastal">Coastal</option>
<option value="ocean">Ocean Floor</option>
<option value="hotspot">Hotspot</option>
<option value="tecActivity">Tectonic Activity</option>
<option value="margins">Margins</option>
<option value="backArc">Back-Arc</option>
<option value="foldRidge">Fold Ridge</option>
<option value="orogenicPower">Orogenic Power</option>
<option value="erosionDelta">Erosion Delta</option>
</optgroup>
<optgroup label="Atmosphere">
<option value="pressureSummer">Pressure (Summer)</option>
<option value="pressureWinter">Pressure (Winter)</option>
<option value="windSpeedSummer">Wind Speed (Summer)</option>
<option value="windSpeedWinter">Wind Speed (Winter)</option>
</optgroup>
<optgroup label="Ocean">
<option value="oceanCurrentSummer">Currents (Summer)</option>
<option value="oceanCurrentWinter">Currents (Winter)</option>
</optgroup>
<optgroup label="Climate">
<option value="precipSummer">Precipitation (Summer)</option>
<option value="precipWinter">Precipitation (Winter)</option>
<option value="rainShadowSummer">Rain Shadow (Summer)</option>
<option value="rainShadowWinter">Rain Shadow (Winter)</option>
<option value="tempSummer">Temperature (Summer)</option>
<option value="tempWinter">Temperature (Winter)</option>
<option value="continentality">Continentality</option>
</optgroup>
<optgroup label="Elevation">
<option value="heightmap">Full Heightmap</option>
</optgroup>
</select>
</div>
<div id="vizLegend" class="viz-legend"></div>
<div class="tg">
<label class="toggle-label"><input type="checkbox" id="chkWire"><span class="toggle-track"><span class="toggle-thumb"></span></span>Wireframe</label>
<label class="toggle-label"><input type="checkbox" id="chkPlates"><span class="toggle-track"><span class="toggle-thumb"></span></span>Show Plates</label>
<label class="toggle-label"><input type="checkbox" id="chkRotate"><span class="toggle-track"><span class="toggle-thumb"></span></span>Auto-Rotate</label>
<label class="toggle-label"><input type="checkbox" id="chkGrid" checked><span class="toggle-track"><span class="toggle-thumb"></span></span>Grid Lines</label>
</div>
<div class="cg" id="gridSpacingGroup">
<label>Grid Spacing</label>
<select id="gridSpacing">
<option value="30">30°</option>
<option value="15" selected>15°</option>
<option value="10">10°</option>
<option value="5">5°</option>
<option value="2.5">2.5°</option>
</select>
</div>
<details id="statsDetails" class="stats-toggle">
<summary>Stats</summary>
<div id="stats"></div>
</details>
</div>
</details>
<button id="exportBtn" class="export-btn">Export Map</button>
<a id="repoLink" href="https://github.com/raguilar011095/planet_heightmap_generation" target="_blank" rel="noopener">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.64 7.64 0 0 1 4 0c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
GitHub
</a>
</div>
</div>
<select id="mobileViewSwitch" class="mobile-view-switch">
<option value="">Terrain</option>
<option value="biome">Satellite</option>
<option value="koppen">Climate</option>
<option value="landheightmap">Heightmap</option>
</select>
<button id="helpBtn" title="Tutorial">?</button>
<div id="tutorialOverlay" class="hidden">
<div id="tutorialCard">
<button id="tutorialClose">&times;</button>
<div class="tutorial-step" data-step="0">
<h3>Welcome to World Orogen</h3>
<p>Procedural planets inspired by real tectonics, erosion, and climate.</p>
<p>Every world is one of a kind — with believable continents, mountains, ocean trenches, and volcanic islands. Build one in seconds, tweak it until it's yours.</p>
</div>
<div class="tutorial-step" data-step="1">
<h3>Shape Your World</h3>
<p>Use the <strong>Shape Your World</strong> sliders to control detail, plates, continents, and roughness, then hit <strong>Build New World</strong>. The <strong>Detail</strong> slider only changes rendering resolution &mdash; continent shapes stay the same, so you can iterate quickly at low detail and crank it up when the planet looks good. Expand <strong>Terrain Sculpting</strong> to fine-tune erosion and smoothing &mdash; these can be reapplied instantly with the <strong>&#x21bb;</strong> button.</p>
</div>
<div class="tutorial-step" data-step="2">
<h3>Explore &amp; Edit</h3>
<p><strong>Drag</strong> to rotate the globe. <strong>Scroll</strong> to zoom in and out. <strong>Ctrl-click</strong> plates to mark them for reshaping &mdash; select multiple, then hit <strong>Rebuild</strong> to apply all at once. Ctrl-click again to undo a pending selection. <strong>Hover</strong> any region to see elevation, coordinates, temperature, precipitation, and climate classification.</p>
</div>
<div class="tutorial-step" data-step="3">
<h3>Visualize Your World</h3>
<p>Switch between <strong>Terrain</strong>, <strong>Satellite</strong>, <strong>Climate</strong>, and <strong>Heightmap</strong> views using the tabs under Visual Options. Use the <strong>Inspect</strong> dropdown for detailed layers like pressure, wind, and precipitation.</p>
<p>At high detail, climate is skipped automatically for faster generation &mdash; it computes on demand when you switch to a climate view.</p>
</div>
<div class="tutorial-step" data-step="4">
<h3>Save &amp; Share</h3>
<p>Every planet gets a unique <strong>planet code</strong> shown below the Build button &mdash; including any plates you've edited. <strong>Copy</strong> it to share with others, or <strong>paste</strong> a code and click <strong>Load</strong> to recreate someone else's world. You can also share the URL directly.</p>
<p>Use <strong>Export Map</strong> to download high-resolution equirectangular images &mdash; terrain, satellite, climate, heightmaps, or land masks. <strong>Export All</strong> downloads satellite, climate, heightmap, and land mask in one click.</p>
</div>
<div class="tutorial-dots">
<span class="dot active" data-dot="0"></span>
<span class="dot" data-dot="1"></span>
<span class="dot" data-dot="2"></span>
<span class="dot" data-dot="3"></span>
<span class="dot" data-dot="4"></span>
</div>
<div class="tutorial-nav">
<button id="tutorialBack" class="btn-ghost" disabled>Back</button>
<button id="tutorialNext" class="btn-primary">Next</button>
</div>
</div>
</div>
<div id="exportOverlay" class="hidden">
<div id="exportCard">
<button id="exportClose">&times;</button>
<h3>Export Map</h3>
<div class="cg">
<label>Type</label>
<select id="exportType">
<option value="color">Color Map</option>
<option value="biome">Satellite</option>
<option value="koppen">Climate (K&ouml;ppen)</option>
<option value="heightmap">Heightmap (B&amp;W)</option>
<option value="landheightmap">Land Heightmap (B&amp;W)</option>
<option value="landmask">Land Mask (B&amp;W)</option>
</select>
</div>
<div class="cg">
<label>Width <span class="v" id="exportDims">4096 &times; 2048</span></label>
<select id="exportWidth">
<option value="1024">1024</option>
<option value="2048">2048</option>
<option value="4096" selected>4096</option>
<option value="8192">8192</option>
<option value="16384">16384</option>
<option value="32768">32768</option>
<option value="65536">65536</option>
</select>
</div>
<div class="export-actions">
<button id="exportCancel" class="btn-ghost">Cancel</button>
<button id="exportGo" class="btn-primary">Export</button>
<button id="exportAllGo" class="btn-primary">Export All</button>
</div>
</div>
</div>
<div id="surveyOverlay" class="hidden">
<div id="surveyCard">
<button id="surveyClose">&times;</button>
<h3>Thanks for exploring!</h3>
<p>You've spent some real time with World Orogen &mdash; that means a lot. If you have a minute, I'd love to hear what you think.</p>
<div class="survey-actions">
<button id="surveyDismiss" class="btn-ghost">Maybe later</button>
<a id="surveyLink" href="https://docs.google.com/forms/d/e/1FAIpQLScFSryT8Uom4jMkpb-YQnyjHMSWqZmDDT3bSOSabHovsjKL7A/viewform?usp=dialog" target="_blank" rel="noopener" class="btn-primary">Take the survey</a>
</div>
</div>
</div>
<div id="whatsNewOverlay" class="hidden">
<div id="whatsNewCard">
<button id="whatsNewClose">&times;</button>
<div class="whatsnew-step" data-step="0">
<h3>What's New in World Orogen</h3>
<p>A lot has changed since your last visit. This update brings features many of you asked for &mdash; plus major improvements under the hood.</p>
<p class="whatsnew-warn"><strong>Heads up:</strong> Saved planet codes will still load, but your worlds may look different. The terrain, erosion, and climate systems have all been reworked, so elevations, coastlines, and biome placement will shift.</p>
</div>
<div class="whatsnew-step" data-step="1">
<h3>New Controls</h3>
<ul class="whatsnew-list">
<li><strong>Land Coverage</strong> &mdash; Control how much of your planet is land vs. ocean. Want a water world with scattered islands? A Pangaea with inland seas? Now you can dial it in.</li>
<li><strong>Continent Size Variety</strong> &mdash; Go from uniform landmasses to a mix of sprawling continents and smaller islands.</li>
<li><strong>Temperature &amp; Precipitation</strong> &mdash; Shift global climate warmer or colder, wetter or drier. No more getting stuck with a generic climate.</li>
</ul>
</div>
<div class="whatsnew-step" data-step="2">
<h3>Heightmap Import</h3>
<p>You can now <strong>bring your own heightmap</strong> &mdash; upload an equirectangular image and Orogen runs the full climate simulation on it. Wind, currents, precipitation, K&ouml;ppen classification, all of it.</p>
<p>Use it to see how your hand-drawn world's climate would actually play out, or import Earth and Mars for reference. Or paint a map where each colour is an uplift rate and let stream-power erosion carve the terrain and its rivers. Find both under the <strong>Import</strong> tab.</p>
</div>
<div class="whatsnew-step" data-step="3">
<h3>Plate Editing &amp; Climate Fixes</h3>
<ul class="whatsnew-list">
<li><strong>Multi-select plates</strong> &mdash; Mark several plates at once, then reshape them all in a single rebuild. No more click-wait-repeat.</li>
<li><strong>Southern hemisphere climates fixed</strong> &mdash; Mediterranean and continental climates now appear properly in both hemispheres.</li>
<li><strong>Better terrain at every detail level</strong> &mdash; Mountains, erosion, and coastlines now scale consistently whether you're at 5K or 2.5M regions.</li>
</ul>
</div>
<div class="tutorial-dots">
<span class="dot active" data-dot="0"></span>
<span class="dot" data-dot="1"></span>
<span class="dot" data-dot="2"></span>
<span class="dot" data-dot="3"></span>
</div>
<div class="tutorial-nav">
<button id="whatsNewBack" class="btn-ghost" disabled>Back</button>
<button id="whatsNewNext" class="btn-primary">Next</button>
</div>
</div>
</div>
<div id="topInfo">Drag to rotate &middot; Scroll to zoom &middot; Ctrl-click to reshape continents</div>
<button id="editToggle" class="edit-toggle" title="Toggle plate edit mode">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.5 2.5l3 3L6 17H3v-3L14.5 2.5z"/>
</svg>
</button>
<button id="refreshFab" class="refresh-fab" title="Generate new planet">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 10a7 7 0 1 1-2-5"/>
<polyline points="15 2 17 5 14 6"/>
</svg>
</button>
<div id="hoverInfo"></div>
<button id="rebuildFab" class="rebuild-fab" style="display:none">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="2 8 6 12 14 4"/>
</svg>
<span>Rebuild (0)</span>
</button>
<div id="info">Drag to rotate &middot; Scroll to zoom &middot; Ctrl-click to reshape continents</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
"delaunator": "https://cdn.jsdelivr.net/npm/delaunator@5.0.1/+esm"
}
}
</script>
<script type="module" src="js/main.js"></script>
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
// Shared climate utilities: smoothing, ITCZ lookup, and percentile selection.
// ── Laplacian smoothing ──────────────────────────────────────────────────────
export function smoothField(mesh, field, passes) {
const { adjOffset, adjList, numRegions } = mesh;
const tmp = new Float32Array(numRegions);
let src = field, dst = tmp;
for (let pass = 0; pass < passes; pass++) {
for (let r = 0; r < numRegions; r++) {
let sum = src[r];
let count = 1;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
sum += src[adjList[ni]];
count++;
}
dst[r] = sum / count;
}
const swap = src; src = dst; dst = swap;
}
// If result ended up in tmp, copy back to field
if (src !== field) field.set(src);
}
// ── ITCZ latitude lookup (linear interpolation with wrapping) ────────────────
export function makeItczLookup(itczLons, itczLats) {
const n = itczLons.length;
const step = (2 * Math.PI) / n;
const lonStart = -Math.PI + step * 0.5;
return function (lon) {
let fi = (lon - lonStart) / step;
fi = ((fi % n) + n) % n;
const i0 = Math.floor(fi);
const i1 = (i0 + 1) % n;
const frac = fi - i0;
return itczLats[i0] * (1 - frac) + itczLats[i1] * frac;
};
}
// ── Floyd-Rivest selection (O(N) expected percentile) ────────────────────────
function floydRivest(arr, left, right, k) {
while (right > left) {
if (right - left > 600) {
const n = right - left + 1;
const i = k - left + 1;
const z = Math.log(n);
const s = 0.5 * Math.exp(2 * z / 3);
const sd = 0.5 * Math.sqrt(z * s * (n - s) / n) * (i - n / 2 < 0 ? -1 : 1);
const newLeft = Math.max(left, Math.floor(k - i * s / n + sd));
const newRight = Math.min(right, Math.floor(k + (n - i) * s / n + sd));
floydRivest(arr, newLeft, newRight, k);
}
const t = arr[k];
if (t !== t) return; // NaN pivot — cannot partition, bail out
let i = left;
let j = right;
arr[k] = arr[left];
arr[left] = t;
if (arr[right] > t) {
arr[left] = arr[right];
arr[right] = t;
}
while (i < j) {
const tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
i++;
j--;
while (arr[i] < t) i++;
while (arr[j] > t) j--;
}
if (arr[left] === t) {
const tmp = arr[left];
arr[left] = arr[j];
arr[j] = tmp;
} else {
j++;
const tmp = arr[j];
arr[j] = arr[right];
arr[right] = tmp;
}
if (j <= k) left = j + 1;
if (k <= j) right = j - 1;
}
}
/**
* Compute the p-th percentile of a numeric array in O(N) expected time.
* Returns the value at index floor(n * p) of the sorted order.
* Makes a copy so the input is not mutated. Returns 1 if the result is 0.
*/
export function percentile(arr, p) {
const n = arr.length;
if (n === 0) return 1;
const work = new Float32Array(arr);
const k = Math.floor(n * p);
floydRivest(work, 0, n - 1, k);
return work[k] || 1;
}
+120
View File
@@ -0,0 +1,120 @@
// Coarse reference grid for resolution-independent plate boundaries.
// Generates plates on a fixed ~20K-region mesh, then projects onto any
// high-res mesh with FBM noise perturbation for fractal boundaries.
import { makeRng } from './rng.js';
import { buildSphere } from './sphere-mesh.js';
import { SimplexNoise } from './simplex-noise.js';
import { generatePlates } from './plates.js';
import { assignOceanLand } from './ocean-land.js';
import {
N_COARSE, COARSE_JITTER, COARSE_PERTURB_BASE, COARSE_PERTURB_LOW_T,
COARSE_FBM_BASE_FREQ, COARSE_FBM_OCTAVES, COARSE_FBM_DECAY, COARSE_FBM_FREQ_MULT,
PLATE_LOW_PLATE_T_HIGH, PLATE_LOW_PLATE_T_RANGE,
} from './terrain-config.js';
/**
* Generate plates and ocean/land on a fixed coarse reference mesh.
* Uses isolated RNG so it doesn't affect the main mesh's random stream.
* Jitter is fixed so plate shapes don't change when the user adjusts irregularity.
*/
export function generateCoarsePlates(seed, numPlates, numContinents, continentSizeVariety = 0, landCoverage = 0.3) {
const coarseRng = makeRng(seed + 137);
const { mesh: coarseMesh, r_xyz: coarse_xyz } = buildSphere(N_COARSE, COARSE_JITTER, coarseRng);
const { r_plate: coarse_r_plate, plateSeeds: coarsePlateSeeds, plateVec: coarsePlateVec } =
generatePlates(coarseMesh, coarse_xyz, numPlates, seed);
const coarsePlateIsOcean = assignOceanLand(
coarseMesh, coarse_r_plate, coarsePlateSeeds, coarse_xyz, seed, numContinents, continentSizeVariety, landCoverage
);
return {
coarseMesh,
coarse_xyz,
coarse_r_plate,
coarsePlateSeeds,
coarsePlateVec,
coarsePlateIsOcean,
};
}
/**
* Project coarse plate assignments onto a high-res mesh via nearest-neighbor
* with FBM noise perturbation for fractal plate boundaries.
*
* Each hi-res point is shifted by multi-octave simplex noise before the
* nearest-neighbor lookup, which wobbles the plate boundary by ~2 coarse
* cell widths with fractal detail at multiple scales.
*
* Uses adjacency-walk on the coarse mesh with warm-starting for O(1)
* amortized cost per region.
*/
export function projectCoarsePlates(mesh, r_xyz, coarseMesh, coarse_xyz, coarse_r_plate, seed, numPlates) {
const N = mesh.numRegions;
const r_plate = new Int32Array(N);
const { adjOffset: cOff, adjList: cAdj } = coarseMesh;
// FBM noise for fractal boundary perturbation
const noise = new SimplexNoise(seed + 999);
const coarseEdgeRad = Math.PI / Math.sqrt(coarseMesh.numRegions);
const lowPlateT = numPlates != null ? Math.max(0, Math.min(1, (PLATE_LOW_PLATE_T_HIGH - numPlates) / PLATE_LOW_PLATE_T_RANGE)) : 0;
const perturbAmp = coarseEdgeRad * (COARSE_PERTURB_BASE + COARSE_PERTURB_LOW_T * lowPlateT); // 1.5 → 2.5 coarse cells
const BASE_FREQ = COARSE_FBM_BASE_FREQ; // ~8 features per sphere diameter → ~16 around equator
const NC = coarseMesh.numRegions;
const MAX_WALK = Math.ceil(Math.sqrt(NC)); // safety cap for greedy walk
let cur = 0; // current best coarse region — warm-started across iterations
for (let r = 0; r < N; r++) {
const ox = r_xyz[3 * r], oy = r_xyz[3 * r + 1], oz = r_xyz[3 * r + 2];
// FBM perturbation: shift lookup point for fractal boundaries
let dx = 0, dy = 0, dz = 0;
let amp = perturbAmp, freq = BASE_FREQ;
for (let oct = 0; oct < COARSE_FBM_OCTAVES; oct++) {
dx += noise.noise3D(ox * freq, oy * freq, oz * freq) * amp;
dy += noise.noise3D(ox * freq + 100, oy * freq + 100, oz * freq + 100) * amp;
dz += noise.noise3D(ox * freq + 200, oy * freq + 200, oz * freq + 200) * amp;
amp *= COARSE_FBM_DECAY;
freq *= COARSE_FBM_FREQ_MULT;
}
// Project perturbed point back onto unit sphere
let px = ox + dx, py = oy + dy, pz = oz + dz;
const len = Math.sqrt(px * px + py * py + pz * pz) || 1;
px /= len; py /= len; pz /= len;
// Greedy walk: find nearest coarse region to the perturbed point
let bestDot = px * coarse_xyz[3 * cur] + py * coarse_xyz[3 * cur + 1] + pz * coarse_xyz[3 * cur + 2];
let improved = true;
let steps = 0;
while (improved && steps < MAX_WALK) {
improved = false;
steps++;
for (let i = cOff[cur], iEnd = cOff[cur + 1]; i < iEnd; i++) {
const nb = cAdj[i];
const d = px * coarse_xyz[3 * nb] + py * coarse_xyz[3 * nb + 1] + pz * coarse_xyz[3 * nb + 2];
if (d > bestDot) {
bestDot = d;
cur = nb;
improved = true;
}
}
}
// Fallback: if greedy walk hit the step limit, brute-force search
if (steps >= MAX_WALK) {
for (let c = 0; c < NC; c++) {
const d = px * coarse_xyz[3 * c] + py * coarse_xyz[3 * c + 1] + pz * coarse_xyz[3 * c + 2];
if (d > bestDot) { bestDot = d; cur = c; }
}
}
r_plate[r] = coarse_r_plate[cur];
}
return r_plate;
}
+125
View File
@@ -0,0 +1,125 @@
// Elevation → RGB colour mapping.
// Convert raw mesh elevation (nonlinear, 0-~1 for land) to physical height
// in kilometres. Hybrid S-curve: quartic start gives extensive flatlands,
// steepest rise around t≈0.75, derivative→0 at top so peaks compress.
// Ocean (elev < 0) is mapped with a linear scale (~5 km at -0.5).
export function elevToHeightKm(elev) {
if (elev <= 0) return elev * 10; // ocean: -0.5 → -5 km
const t = Math.min(elev, 1);
const t2 = t * t;
return 6 * t2 * t2 * (5 - 4 * t); // 0→0, 0.25→0.09, 0.5→1.13, 0.75→3.80, 1.0→6
}
// Biome base colors indexed by Köppen class ID (satellite-view palette).
// 0=Ocean delegated, 1-30 = land biomes.
const BIOME_COLORS = [
null, // 0 Ocean — handled separately
[0.05, 0.30, 0.05], // 1 Af Tropical rainforest — deep emerald
[0.08, 0.33, 0.07], // 2 Am Tropical monsoon — dense green
[0.42, 0.50, 0.18], // 3 Aw Tropical savanna — yellow-green
[0.82, 0.72, 0.50], // 4 BWh Hot desert — sandy tan
[0.60, 0.55, 0.48], // 5 BWk Cold desert — gray-brown
[0.72, 0.62, 0.30], // 6 BSh Hot steppe — dry gold
[0.55, 0.52, 0.32], // 7 BSk Cold steppe — muted olive-tan
[0.18, 0.42, 0.12], // 8 Cfa Humid subtropical — mid green
[0.12, 0.38, 0.10], // 9 Cfb Oceanic — rich green
[0.10, 0.28, 0.10], // 10 Cfc Subpolar oceanic — dark muted green
[0.45, 0.48, 0.22], // 11 Csa Hot-summer Mediterranean — khaki-green
[0.40, 0.45, 0.20], // 12 Csb Warm-summer Mediterranean — chaparral
[0.35, 0.40, 0.20], // 13 Csc Cold-summer Mediterranean — darker khaki
[0.20, 0.44, 0.14], // 14 Cwa Humid subtropical monsoon — mid green
[0.15, 0.40, 0.12], // 15 Cwb Subtropical highland — green
[0.12, 0.32, 0.10], // 16 Cwc Cold subtropical highland — dark green
[0.12, 0.36, 0.08], // 17 Dfa Hot-summer continental — forest green
[0.10, 0.32, 0.08], // 18 Dfb Warm-summer continental — forest green
[0.06, 0.22, 0.08], // 19 Dfc Subarctic — dark spruce green
[0.05, 0.18, 0.07], // 20 Dfd Extremely cold subarctic — very dark
[0.38, 0.38, 0.18], // 21 Dsa Hot-summer continental dry — olive-brown
[0.35, 0.35, 0.17], // 22 Dsb Warm-summer continental dry — olive-brown
[0.08, 0.22, 0.08], // 23 Dsc Subarctic dry summer — dark green
[0.06, 0.18, 0.07], // 24 Dsd Extremely cold subarctic dry — very dark
[0.14, 0.36, 0.10], // 25 Dwa Hot-summer continental monsoon — forest green
[0.12, 0.32, 0.09], // 26 Dwb Warm-summer continental monsoon
[0.07, 0.22, 0.08], // 27 Dwc Subarctic monsoon — dark spruce
[0.05, 0.18, 0.07], // 28 Dwd Extremely cold subarctic monsoon
[0.35, 0.32, 0.22], // 29 ET Tundra — earthy brown (sparse moss/lichen on rock)
[0.78, 0.80, 0.84], // 30 EF Ice cap — blue-tinted white
];
// Rocky/alpine mountain color for high-elevation blending.
const ROCK_COLOR = [0.42, 0.38, 0.32];
// Altitude thresholds (km) by Köppen group:
// [alpine line, snow line]
// Alpine line: vegetation gives way to rocky alpine terrain.
// Snow line: permanent snow begins.
function altitudeThresholds(classId) {
if (classId <= 0) return [0, 0]; // Ocean
if (classId <= 3) return [3.5, 5.5]; // Tropical (A)
if (classId <= 7) return [3.0, 5.0]; // Arid (B)
if (classId <= 16) return [2.0, 3.5]; // Temperate (C)
if (classId <= 18 || classId === 21 || classId === 22 ||
classId === 25 || classId === 26) return [1.5, 3.0]; // Continental humid (D*a, D*b)
if (classId <= 28) return [0.8, 2.0]; // Subarctic (D*c, D*d)
if (classId === 29) return [0.4, 1.5]; // Tundra (ET) — rocky higher up, snow only at peaks
return [0, 0.5]; // Ice cap (EF)
}
// Satellite-view biome color: realistic land colors based on Köppen class
// and elevation, with ocean delegated to the standard ocean palette.
export function biomeColor(koppenId, elevation) {
// Ocean
if (koppenId === 0 || elevation <= 0) return elevationToColor(elevation);
const base = BIOME_COLORS[koppenId] || [0.30, 0.50, 0.20];
const hKm = elevToHeightKm(elevation);
const [alpineLine, snowLine] = altitudeThresholds(koppenId);
let r = base[0], g = base[1], b = base[2];
// Low-elevation subtle darkening for depth (0-200m)
if (hKm < 0.2) {
const dark = 0.93 + 0.07 * (hKm / 0.2);
r *= dark; g *= dark; b *= dark;
}
// Mid-elevation: gentle darkening to show terrain relief (200m to alpine line)
if (alpineLine > 0 && hKm > 0.2 && hKm < alpineLine) {
const t = (hKm - 0.2) / (alpineLine - 0.2);
const darken = 1.0 - t * 0.15; // up to 15% darker at alpine line
r *= darken; g *= darken; b *= darken;
}
// Alpine zone: blend toward rocky brown-gray above the tree/vegetation line
if (alpineLine > 0 && hKm > alpineLine) {
const rockZone = snowLine > alpineLine ? snowLine - alpineLine : 2.0;
const rockT = Math.min(1, (hKm - alpineLine) / rockZone);
const s = rockT * rockT; // ease-in for gradual transition
r = r + (ROCK_COLOR[0] - r) * s;
g = g + (ROCK_COLOR[1] - g) * s;
b = b + (ROCK_COLOR[2] - b) * s;
}
// Snow zone: blend toward white above the snow line
if (snowLine > 0 && hKm > snowLine) {
const snowT = Math.min(1, (hKm - snowLine) / 2.5);
const s = snowT * snowT; // ease-in for gradual snow buildup
r = r + (0.92 - r) * s;
g = g + (0.93 - g) * s;
b = b + (0.96 - b) * s;
}
return [r, g, b];
}
export function elevationToColor(e) {
if (e < -0.50) return [0.04, 0.06, 0.30];
if (e < -0.10) { const t=(e+0.50)/0.40; return [0.04+t*0.07,0.06+t*0.14,0.30+t*0.18]; }
if (e < 0.00) { const t=(e+0.10)/0.10; return [0.11+t*0.19,0.20+t*0.22,0.48+t*0.12]; }
if (e < 0.03) { const t=e/0.03; return [0.72+t*0.08,0.68-t*0.02,0.46-t*0.10]; }
if (e < 0.25) { const t=(e-0.03)/0.22; return [0.20-t*0.06,0.54-t*0.12,0.12+t*0.08]; }
if (e < 0.50) { const t=(e-0.25)/0.25; return [0.14+t*0.30,0.42-t*0.14,0.20-t*0.06]; }
if (e < 0.75) { const t=(e-0.50)/0.25; return [0.44+t*0.16,0.28+t*0.12,0.14+t*0.18]; }
{ const t=Math.min(1,(e-0.75)/0.20); return [0.60+t*0.35,0.40+t*0.50,0.32+t*0.60]; }
}
+14
View File
@@ -0,0 +1,14 @@
// Non-linear detail slider mapping (power curve, p=5).
// Slider position 0–1000 maps to detail 2,000–2,560,000.
// Gives generous control in the normal range; the old max (640K) sits at ~76%.
const MIN = 5000, MAX = 2560000, RANGE = MAX - MIN, STEPS = 1000, P = 5;
export function detailFromSlider(pos) {
const t = pos / STEPS;
return Math.round((MIN + RANGE * Math.pow(t, P)) / 1000) * 1000;
}
export function sliderFromDetail(n) {
return Math.round(STEPS * Math.pow(Math.max(0, n - MIN) / RANGE, 1 / P));
}
+271
View File
@@ -0,0 +1,271 @@
// Plate interaction: hover info + ctrl-click to toggle land/sea.
// Uses analytical ray-sphere intersection instead of Three.js mesh raycasting
// for O(N) dot-product lookups rather than O(N) triangle intersection tests.
import * as THREE from 'three';
import { canvas, camera, mapCamera } from './scene.js';
import { state } from './state.js';
import { updateHoverHighlight, updateMapHoverHighlight, updatePendingHighlight, updateMapPendingHighlight } from './planet-mesh.js';
import { KOPPEN_CLASSES } from './koppen.js';
import { elevToHeightKm } from './color-map.js';
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const _inverseMatrix = new THREE.Matrix4();
const _localRay = new THREE.Ray();
/** Find nearest region to a unit-sphere direction (max dot product). */
function findNearestRegion(nx, ny, nz) {
const { mesh, r_xyz, r_plate } = state.curData;
const N = mesh.numRegions;
let bestDot = -2, bestR = -1;
for (let r = 0; r < N; r++) {
const dot = nx * r_xyz[3 * r] + ny * r_xyz[3 * r + 1] + nz * r_xyz[3 * r + 2];
if (dot > bestDot) { bestDot = dot; bestR = r; }
}
if (bestR < 0) return null;
return { region: bestR, plate: r_plate[bestR] };
}
/** Globe view: analytical ray-sphere intersection → nearest region.
* ~50-100x faster than Three.js mesh raycasting at high detail. */
function getHitInfoGlobe(event) {
if (!state.planetMesh) return null;
const rect = canvas.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
// Transform ray into planet's local space (handles auto-rotation)
_inverseMatrix.copy(state.planetMesh.matrixWorld).invert();
_localRay.copy(raycaster.ray).applyMatrix4(_inverseMatrix);
const ox = _localRay.origin.x, oy = _localRay.origin.y, oz = _localRay.origin.z;
const dx = _localRay.direction.x, dy = _localRay.direction.y, dz = _localRay.direction.z;
// Ray-sphere: |O + tD|² = R² (a=1 since direction is normalised)
const R = 1.08; // slightly above max elevation displacement
const b = 2 * (ox * dx + oy * dy + oz * dz);
const c = ox * ox + oy * oy + oz * oz - R * R;
const disc = b * b - 4 * c;
if (disc < 0) return null;
const t = (-b - Math.sqrt(disc)) * 0.5;
if (t < 0) return null;
// Hit point → normalise to unit direction
const hx = ox + t * dx, hy = oy + t * dy, hz = oz + t * dz;
const len = Math.sqrt(hx * hx + hy * hy + hz * hz) || 1;
return findNearestRegion(hx / len, hy / len, hz / len);
}
/** Map view: unproject mouse → map plane → inverse equirect → nearest region. */
function getHitInfoMap(event) {
if (!state.mapMesh) return null;
const rect = canvas.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
// Intersect ray with z=0 plane to get world coords on the map
raycaster.setFromCamera(mouse, mapCamera);
const o = raycaster.ray.origin, d = raycaster.ray.direction;
if (Math.abs(d.z) < 1e-10) return null;
const t = -o.z / d.z;
const wx = o.x + t * d.x;
const wy = o.y + t * d.y;
// Inverse equirectangular: map coords → lon/lat → unit sphere xyz
const PI = Math.PI;
const sx = 2 / PI;
let lon = wx / sx + (state.mapCenterLon || 0);
const lat = wy / sx;
if (lat < -PI / 2 || lat > PI / 2) return null;
// Wrap lon back to [-PI, PI]
if (lon > PI) lon -= 2 * PI;
else if (lon < -PI) lon += 2 * PI;
const cosLat = Math.cos(lat);
return findNearestRegion(
cosLat * Math.sin(lon),
Math.sin(lat),
cosLat * Math.cos(lon)
);
}
function getHitInfo(event) {
if (!state.curData) return null;
return state.mapMode ? getHitInfoMap(event) : getHitInfoGlobe(event);
}
/** Build multi-line hover HTML for a region. */
function buildHoverHTML(region, plate) {
const d = state.curData;
const isOcean = d.plateIsOcean.has(plate);
const isPending = state.pendingToggles.has(plate);
const dot = `<span style="color:${isOcean ? '#4af' : '#6b3'}">●</span>`;
const action = state.isTouchDevice ? 'Tap' : 'Ctrl-click';
const lines = [];
// Line 1: plate type + edit hint
if (isPending) {
const target = isOcean ? 'Land' : 'Ocean';
lines.push(`${dot} <b>${isOcean ? 'Ocean' : 'Land'} → ${target}</b> <span style="color:#fa0">(pending)</span> · ${action} to undo`);
} else {
lines.push(`${dot} <b>${isOcean ? 'Ocean' : 'Land'}</b> plate · ${action} to ${isOcean ? 'raise land' : 'flood'}`);
}
// Elevation
const elev = d.r_elevation[region];
const elevKm = elevToHeightKm(elev).toFixed(1);
lines.push(`<span class="hi-label">Elev</span> ${elevKm} km`);
// Lat/Lon from r_xyz
const x = d.r_xyz[3 * region];
const y = d.r_xyz[3 * region + 1];
const z = d.r_xyz[3 * region + 2];
const lat = Math.asin(Math.max(-1, Math.min(1, y))) * (180 / Math.PI);
const lon = Math.atan2(x, z) * (180 / Math.PI);
const latStr = Math.abs(lat).toFixed(1) + '°' + (lat >= 0 ? 'N' : 'S');
const lonStr = Math.abs(lon).toFixed(1) + '°' + (lon >= 0 ? 'E' : 'W');
lines.push(`<span class="hi-label">Coord</span> ${latStr}, ${lonStr}`);
// Climate data (only if computed)
if (state.climateComputed && d.r_temperature_summer) {
const tS = -45 + Math.max(0, Math.min(1, d.r_temperature_summer[region])) * 90;
const tW = -45 + Math.max(0, Math.min(1, d.r_temperature_winter[region])) * 90;
if (elev <= 0) {
// Ocean: show as SST
lines.push(`<span class="hi-label">SST</span> ${tS.toFixed(0)}°C / ${tW.toFixed(0)}°C`);
} else {
lines.push(`<span class="hi-label">Temp</span> ${tS.toFixed(0)}°C / ${tW.toFixed(0)}°C`);
// Precipitation (land only)
if (d.r_precip_summer) {
const pS = (Math.max(0, Math.min(1, d.r_precip_summer[region])) * 1000).toFixed(0);
const pW = (Math.max(0, Math.min(1, d.r_precip_winter[region])) * 1000).toFixed(0);
lines.push(`<span class="hi-label">Precip</span> ${pS} / ${pW} mm`);
}
// Köppen (land only)
if (d.debugLayers && d.debugLayers.koppen) {
const kIdx = d.debugLayers.koppen[region];
const kc = KOPPEN_CLASSES[kIdx];
if (kc && kc.code !== 'Ocean') {
const [r, g, b] = kc.color;
const hex = '#' + [r, g, b].map(v => Math.round(v * 255).toString(16).padStart(2, '0')).join('');
lines.push(`<span class="hi-label">Clima</span> <span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${hex};vertical-align:middle;margin-right:4px"></span>${kc.code} — ${kc.name}`);
}
}
}
}
return lines.join('<br>');
}
/** Set up hover and ctrl-click event listeners. */
export function setupEditMode() {
let downInfo = null;
let orbiting = false;
let lastHoverTime = 0;
const HOVER_INTERVAL = 50; // ms — cap hover lookups
canvas.addEventListener('pointerdown', (e) => {
if (!state.curData) return;
const isEditTap = (e.button === 0 && e.ctrlKey) ||
(e.button === 0 && state.isTouchDevice && state.editMode);
if (isEditTap) {
// Ctrl-click or mobile edit-mode tap: plate editing
const hit = getHitInfo(e);
if (!hit) return;
downInfo = { x: e.clientX, y: e.clientY, plate: hit.plate };
} else if (e.button === 0 || e.button === 2) {
// Regular click/right-click: orbit or pan — skip hover raycasts
orbiting = true;
}
});
canvas.addEventListener('pointerup', (e) => {
orbiting = false;
if (!downInfo || !state.curData || e.button !== 0) { downInfo = null; return; }
const dx = e.clientX - downInfo.x;
const dy = e.clientY - downInfo.y;
if (dx * dx + dy * dy < 36) {
const pid = downInfo.plate;
// Toggle pending: add if absent, remove if present (undo)
if (state.pendingToggles.has(pid)) {
state.pendingToggles.delete(pid);
} else {
state.pendingToggles.add(pid);
}
// Remove hover highlight first so pending tint applies to base colors.
// Hover saves its backup from pre-pending colors; if we don't strip it,
// the hover restore in updateHoverHighlight wipes out the pending tint.
const savedHover = state.hoveredPlate;
state.hoveredPlate = -1;
if (state.mapMode) updateMapHoverHighlight();
else updateHoverHighlight();
state.hoveredPlate = savedHover;
// Apply pending tint to the now-clean base colors
updatePendingHighlight();
updateMapPendingHighlight();
// Re-apply hover on top of pending-tinted colors
if (state.mapMode) updateMapHoverHighlight();
else updateHoverHighlight();
// Update hover text to reflect pending state
const hoverEl = document.getElementById('hoverInfo');
if (state.hoveredRegion >= 0 && state.curData) {
hoverEl.innerHTML = buildHoverHTML(state.hoveredRegion, state.hoveredPlate);
}
// Notify main.js to show/hide rebuild button
document.dispatchEvent(new CustomEvent('pending-edits-changed'));
}
downInfo = null;
});
canvas.addEventListener('pointermove', (e) => {
if (!state.curData) {
if (state.hoveredPlate >= 0 || state.hoveredRegion >= 0) {
state.hoveredPlate = -1;
state.hoveredRegion = -1;
document.getElementById('hoverInfo').style.display = 'none';
}
return;
}
// Skip while orbiting/panning — no hover lookup during drag
if (orbiting) return;
// Throttle hover updates
const now = performance.now();
if (now - lastHoverTime < HOVER_INTERVAL) return;
lastHoverTime = now;
const hit = getHitInfo(e);
const newRegion = hit ? hit.region : -1;
// Only highlight the plate when in edit mode (Ctrl held or mobile edit toggle)
const inEditMode = e.ctrlKey || (state.isTouchDevice && state.editMode);
const newPlate = (hit && inEditMode) ? hit.plate : -1;
// Update plate highlight only when plate changes
if (newPlate !== state.hoveredPlate) {
state.hoveredPlate = newPlate;
if (state.mapMode) updateMapHoverHighlight();
else updateHoverHighlight();
}
// Update info text when region changes
if (newRegion !== state.hoveredRegion) {
state.hoveredRegion = newRegion;
state.hoveredPlate = (hit && inEditMode) ? hit.plate : -1;
const hoverEl = document.getElementById('hoverInfo');
if (newRegion >= 0) {
hoverEl.innerHTML = buildHoverHTML(newRegion, hit.plate);
hoverEl.style.display = 'block';
} else {
hoverEl.style.display = 'none';
}
}
});
}
File diff suppressed because it is too large Load Diff
+991
View File
@@ -0,0 +1,991 @@
// Planet generation — dispatches work to a Web Worker, falls back to
// synchronous main-thread generation if module workers aren't supported.
import Delaunator from 'delaunator';
import { setDelaunator, SphereMesh } from './sphere-mesh.js';
import { computePlateColors, buildMesh } from './planet-mesh.js';
import { state } from './state.js';
import { detailFromSlider } from './detail-scale.js';
import { computeOceanCurrents } from './ocean.js';
import { computePrecipitation } from './precipitation.js';
import { computeTemperature } from './temperature.js';
import { classifyKoppen } from './koppen.js';
// Main thread still needs Delaunator for SphereMesh reconstruction
setDelaunator(Delaunator);
// Read all slider values from the DOM into a params object
function readSliders() {
return {
N: detailFromSlider(+document.getElementById('sN').value),
P: +document.getElementById('sP').value,
jitter: +document.getElementById('sJ').value,
nMag: +document.getElementById('sNs').value,
numContinents: +document.getElementById('sCn').value,
terrainWarp: +document.getElementById('sTw').value,
smoothing: +document.getElementById('sS').value,
hydraulicErosion: +document.getElementById('sHEr').value,
thermalErosion: +document.getElementById('sTEr').value,
ridgeSharpening: +document.getElementById('sRs').value,
glacialErosion: +document.getElementById('sGl').value,
continentSizeVariety: +document.getElementById('sCsv').value,
temperatureOffset: +document.getElementById('sTmp').value,
precipitationOffset: +document.getElementById('sPrc').value,
landCoverage: +document.getElementById('sLc').value,
};
}
// Read sliders with optional chaining (for import page where some sliders may not exist)
function readSlidersOptional() {
return {
N: detailFromSlider(+document.getElementById('sN').value),
jitter: +(document.getElementById('sJ')?.value ?? 0.75),
terrainWarp: +(document.getElementById('sTw')?.value ?? 0),
smoothing: +(document.getElementById('sS')?.value ?? 0),
hydraulicErosion: +(document.getElementById('sHEr')?.value ?? 0),
thermalErosion: +(document.getElementById('sTEr')?.value ?? 0),
ridgeSharpening: +(document.getElementById('sRs')?.value ?? 0),
glacialErosion: +(document.getElementById('sGl')?.value ?? 0),
};
}
// --- Worker setup ---
let worker = null;
let workerSupported = true;
try {
worker = new Worker(new URL('./planet-worker.js', import.meta.url), { type: 'module' });
} catch (e) {
console.warn('[World Orogen] Module workers not supported, falling back to main thread:', e);
workerSupported = false;
}
// Active callback state
let _onProgress = null;
let _onDone = null;
let _t0 = 0;
function resetUI() {
const btn = document.getElementById('generate');
btn.disabled = false;
btn.textContent = 'Build New World';
btn.classList.remove('generating', 'stale');
}
function fail(err) {
console.error('[World Orogen] Generation failed:', err);
resetUI();
if (_onProgress) _onProgress(0, '');
}
// Reconstruct SphereMesh from transferred data
function reconstructMesh(triangles, halfedges, numRegions) {
return new SphereMesh(triangles, halfedges, numRegions);
}
// Build minimal wind-result-like object for computeOceanCurrents fallback.
// Derives geographic data (lat, sinLat, isLand, tangent frames) from r_xyz/r_elevation
// and wraps the wind vectors the worker already sent.
function buildWindResultForOcean(mesh, r_xyz, r_elevation,
r_wind_east_summer, r_wind_north_summer, r_wind_east_winter, r_wind_north_winter,
itczLons, itczLatsSummer, itczLatsWinter) {
const n = mesh.numRegions;
const r_lat = new Float32Array(n);
const r_lon = new Float32Array(n);
const r_sinLat = new Float32Array(n);
const r_isLand = new Uint8Array(n);
const r_eastX = new Float32Array(n), r_eastY = new Float32Array(n), r_eastZ = new Float32Array(n);
const r_northX = new Float32Array(n), r_northY = new Float32Array(n), r_northZ = new Float32Array(n);
for (let r = 0; r < n; r++) {
const x = r_xyz[3 * r], y = r_xyz[3 * r + 1], z = r_xyz[3 * r + 2];
r_sinLat[r] = y;
r_lat[r] = Math.asin(Math.max(-1, Math.min(1, y)));
r_lon[r] = Math.atan2(x, z);
r_isLand[r] = r_elevation[r] > 0 ? 1 : 0;
// East = cross(up, position) normalized
let ex = z, ey = 0, ez = -x;
const elen = Math.sqrt(ex * ex + ez * ez);
if (elen > 1e-10) { ex /= elen; ez /= elen; }
else { ex = 1; ez = 0; } // poles
r_eastX[r] = ex; r_eastY[r] = ey; r_eastZ[r] = ez;
// North = cross(position, east) normalized
let nx = y * ez - z * ey;
let ny = z * ex - x * ez;
let nz = x * ey - y * ex;
const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
r_northX[r] = nx / nlen; r_northY[r] = ny / nlen; r_northZ[r] = nz / nlen;
}
// BFS coast distance through land (needed by precipitation fallback)
const { adjOffset, adjList } = mesh;
const r_coastDistLand = new Int32Array(n);
r_coastDistLand.fill(-1);
const bfsQueue = [];
for (let r = 0; r < n; r++) {
if (!r_isLand[r]) continue;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
if (!r_isLand[adjList[ni]]) {
r_coastDistLand[r] = 0;
bfsQueue.push(r);
break;
}
}
}
let bfsHead = 0;
while (bfsHead < bfsQueue.length) {
const r = bfsQueue[bfsHead++];
const d = r_coastDistLand[r] + 1;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (r_isLand[nb] && r_coastDistLand[nb] === -1) {
r_coastDistLand[nb] = d;
bfsQueue.push(nb);
}
}
}
// Compute wind speed from components (prevents TypeError if accessed)
const r_wind_speed_summer = new Float32Array(n);
const r_wind_speed_winter = new Float32Array(n);
for (let r = 0; r < n; r++) {
const se = r_wind_east_summer[r], sn = r_wind_north_summer[r];
r_wind_speed_summer[r] = Math.sqrt(se * se + sn * sn);
const we = r_wind_east_winter[r], wn = r_wind_north_winter[r];
r_wind_speed_winter[r] = Math.sqrt(we * we + wn * wn);
}
// Zero-filled pressure deviation (neutral: no pressure-driven effects in fallback)
const r_pressure_summer = new Float32Array(n);
const r_pressure_winter = new Float32Array(n);
return {
r_lat, r_lon, r_sinLat, r_isLand,
r_eastX, r_eastY, r_eastZ,
r_northX, r_northY, r_northZ,
r_coastDistLand,
r_wind_east_summer, r_wind_north_summer,
r_wind_east_winter, r_wind_north_winter,
r_wind_speed_summer, r_wind_speed_winter,
r_pressure_summer, r_pressure_winter,
itczLons, itczLatsSummer, itczLatsWinter
};
}
if (worker) {
worker.onmessage = (e) => {
const msg = e.data;
switch (msg.type) {
case 'progress':
if (_onProgress) _onProgress(msg.pct, msg.label);
break;
case 'done': {
const tMainStart = performance.now();
const tReconStart = performance.now();
const mesh = reconstructMesh(msg.triangles, msg.halfedges, msg.numRegions);
const tRecon = performance.now() - tReconStart;
const tColorsStart = performance.now();
computePlateColors(new Set(msg.plateSeeds), new Set(msg.plateIsOcean));
const tColors = performance.now() - tColorsStart;
state.climateComputed = !msg.skipClimate;
const tStateStart = performance.now();
state.curData = {
mesh,
r_xyz: msg.r_xyz,
t_xyz: msg.t_xyz,
r_plate: msg.r_plate,
plateSeeds: new Set(msg.plateSeeds),
plateVec: msg.plateVec,
plateIsOcean: new Set(msg.plateIsOcean),
originalPlateIsOcean: new Set(msg.originalPlateIsOcean),
plateDensity: msg.plateDensity,
plateDensityLand: msg.plateDensityLand,
plateDensityOcean: msg.plateDensityOcean,
prePostElev: msg.prePostElev,
r_elevation: msg.r_elevation,
t_elevation: msg.t_elevation,
mountain_r: new Set(msg.mountain_r),
coastline_r: new Set(msg.coastline_r),
ocean_r: new Set(msg.ocean_r),
r_stress: msg.r_stress,
r_wind_east_summer: msg.r_wind_east_summer,
r_wind_north_summer: msg.r_wind_north_summer,
r_wind_east_winter: msg.r_wind_east_winter,
r_wind_north_winter: msg.r_wind_north_winter,
itczLons: msg.itczLons,
itczLatsSummer: msg.itczLatsSummer,
itczLatsWinter: msg.itczLatsWinter,
r_ocean_current_east_summer: msg.r_ocean_current_east_summer,
r_ocean_current_north_summer: msg.r_ocean_current_north_summer,
r_ocean_current_east_winter: msg.r_ocean_current_east_winter,
r_ocean_current_north_winter: msg.r_ocean_current_north_winter,
r_ocean_speed_summer: msg.r_ocean_speed_summer,
r_ocean_speed_winter: msg.r_ocean_speed_winter,
r_ocean_warmth_summer: msg.r_ocean_warmth_summer,
r_ocean_warmth_winter: msg.r_ocean_warmth_winter,
r_precip_summer: msg.r_precip_summer,
r_precip_winter: msg.r_precip_winter,
r_temperature_summer: msg.r_temperature_summer,
r_temperature_winter: msg.r_temperature_winter,
seed: msg.seed,
nMag: msg.nMag,
debugLayers: msg.debugLayers,
terrainMetrics: msg.terrainMetrics || null,
painted: msg.painted || null
};
if (msg.terrainMetrics) window.__terrainMetrics = msg.terrainMetrics;
const tState = performance.now() - tStateStart;
// Main-thread fallbacks — only run when climate was requested but partially missing
// (e.g. older cached worker). Skip entirely when skipClimate was set.
if (!msg.skipClimate) {
let tOceanFallback = 0;
const d = state.curData;
let windResult = null;
if (msg.r_wind_east_summer && (!d.r_ocean_speed_summer || !d.r_precip_summer || !d.r_temperature_summer)) {
windResult = buildWindResultForOcean(mesh, d.r_xyz, d.r_elevation,
d.r_wind_east_summer, d.r_wind_north_summer,
d.r_wind_east_winter, d.r_wind_north_winter,
d.itczLons, d.itczLatsSummer, d.itczLatsWinter);
}
if (!d.r_ocean_speed_summer && windResult) {
console.log('[generate.js] Ocean data missing from worker — computing on main thread');
const t0Ocean = performance.now();
const oceanResult = computeOceanCurrents(mesh, d.r_xyz, d.r_elevation, windResult);
d.r_ocean_current_east_summer = oceanResult.r_ocean_current_east_summer;
d.r_ocean_current_north_summer = oceanResult.r_ocean_current_north_summer;
d.r_ocean_current_east_winter = oceanResult.r_ocean_current_east_winter;
d.r_ocean_current_north_winter = oceanResult.r_ocean_current_north_winter;
d.r_ocean_speed_summer = oceanResult.r_ocean_speed_summer;
d.r_ocean_speed_winter = oceanResult.r_ocean_speed_winter;
d.r_ocean_warmth_summer = oceanResult.r_ocean_warmth_summer;
d.r_ocean_warmth_winter = oceanResult.r_ocean_warmth_winter;
tOceanFallback = performance.now() - t0Ocean;
console.log(`[generate.js] Ocean currents computed on main thread in ${tOceanFallback.toFixed(0)} ms`);
}
if (!d.r_precip_summer && windResult) {
console.log('[generate.js] Precipitation data missing from worker — computing on main thread');
const t0Precip = performance.now();
const precipResult = computePrecipitation(mesh, d.r_xyz, d.r_elevation, windResult, d);
d.r_precip_summer = precipResult.r_precip_summer;
d.r_precip_winter = precipResult.r_precip_winter;
if (d.debugLayers) {
d.debugLayers.precipSummer = precipResult.r_precip_summer;
d.debugLayers.precipWinter = precipResult.r_precip_winter;
d.debugLayers.rainShadowSummer = precipResult.r_rainshadow_summer;
d.debugLayers.rainShadowWinter = precipResult.r_rainshadow_winter;
}
console.log(`[generate.js] Precipitation computed on main thread in ${(performance.now() - t0Precip).toFixed(0)} ms`);
}
if (!d.r_temperature_summer && windResult) {
console.log('[generate.js] Temperature data missing from worker — computing on main thread');
const t0Temp = performance.now();
const tempResult = computeTemperature(mesh, d.r_xyz, d.r_elevation, windResult, d, d);
d.r_temperature_summer = tempResult.r_temperature_summer;
d.r_temperature_winter = tempResult.r_temperature_winter;
if (d.debugLayers) {
d.debugLayers.tempSummer = tempResult.r_temperature_summer;
d.debugLayers.tempWinter = tempResult.r_temperature_winter;
}
console.log(`[generate.js] Temperature computed on main thread in ${(performance.now() - t0Temp).toFixed(0)} ms`);
}
if (state.curData.debugLayers && !state.curData.debugLayers.koppen &&
state.curData.r_temperature_summer && state.curData.r_precip_summer) {
const d = state.curData;
d.debugLayers.koppen = classifyKoppen(mesh, d.r_elevation,
{ r_temperature_summer: d.r_temperature_summer, r_temperature_winter: d.r_temperature_winter },
{ r_precip_summer: d.r_precip_summer, r_precip_winter: d.r_precip_winter });
}
}
const tBuildStart = performance.now();
buildMesh();
const tBuild = performance.now() - tBuildStart;
const tMainTotal = performance.now() - tMainStart;
const tTotal = performance.now() - _t0;
// Diagnostics
{
let landCount = 0, nanCount = 0;
const plateIsOcean = state.curData.plateIsOcean;
const r_plate = state.curData.r_plate;
const r_elevation = state.curData.r_elevation;
for (let r = 0; r < mesh.numRegions; r++) {
if (!plateIsOcean.has(r_plate[r])) landCount++;
if (isNaN(r_elevation[r])) nanCount++;
}
const landPct = (100 * landCount / mesh.numRegions).toFixed(1);
if (nanCount > 0) console.error(`[World Orogen] WARNING: ${nanCount} NaN elevation values detected!`);
if (landCount / mesh.numRegions < 0.10) console.warn(`[World Orogen] WARNING: Only ${landPct}% land (${landCount} regions). Ocean/land growth may have stalled.`);
}
const f = v => typeof v === 'number' ? v.toFixed(1) : v;
console.log(`%c[World Orogen] Generation complete`, 'color:#6cf;font-weight:bold');
if (msg._params) {
console.log(` Params: N=${msg._params.N.toLocaleString()} P=${msg._params.P} jitter=${msg._params.jitter} noise=${msg._params.nMag} continents=${msg._params.numContinents} seed=${msg._params.seed}`);
console.log(` Sculpting: warp=${msg._params.terrainWarp} smooth=${msg._params.smoothing} glacial=${msg._params.glacialErosion} hydraulic=${msg._params.hydraulicErosion} thermal=${msg._params.thermalErosion} ridge=${msg._params.ridgeSharpening}`);
}
console.log(` Regions: ${mesh.numRegions.toLocaleString()} Triangles: ${mesh.numTriangles.toLocaleString()} Sides: ${mesh.numSides.toLocaleString()}`);
// Worker pipeline stages
if (msg._pipelineTiming) {
console.groupCollapsed(' %cWorker pipeline stages', 'color:#8cf');
console.table(msg._pipelineTiming.map(r => ({ Stage: r.stage, 'ms': f(r.ms) })));
console.groupEnd();
}
// Elevation sub-stages
if (msg._timing) {
console.groupCollapsed(' %cElevation sub-stages', 'color:#fc8');
console.table(msg._timing.map(r => ({ Stage: r.stage, 'ms': f(r.ms) })));
console.groupEnd();
}
// Post-processing sub-stages
if (msg._postTiming && msg._postTiming.length > 0) {
console.groupCollapsed(' %cPost-processing sub-stages', 'color:#8f8');
console.table(msg._postTiming.map(r => ({ Stage: r.stage, 'ms': f(r.ms) })));
console.groupEnd();
}
// Summary
const tWorker = msg._workerTotal || 0;
const tTransfer = tTotal - tWorker - tMainTotal;
console.log(
` %cSummary:%c Worker: ${f(tWorker)} ms | Transfer: ${f(tTransfer)} ms | Main thread: ${f(tMainTotal)} ms (reconstruct=${f(tRecon)}, colors=${f(tColors)}, state=${f(tState)}, buildMesh=${f(tBuild)}) | TOTAL: ${f(tTotal)} ms`,
'color:#ff6;font-weight:bold', ''
);
const ms = tTotal.toFixed(0);
document.getElementById('stats').innerHTML =
`Regions: ${mesh.numRegions.toLocaleString()}<br>` +
`Triangles: ${mesh.numTriangles.toLocaleString()}<br>` +
`Generated in ${ms} ms<br>` +
`<span style="color:#445;font-size:10px">worker ${tWorker.toFixed(0)} · render ${tBuild.toFixed(0)}</span>`;
if (_onProgress) _onProgress(100, 'Done');
resetUI();
document.getElementById('generate').dispatchEvent(new CustomEvent('generate-done'));
if (_onDone) { _onDone(); _onDone = null; }
break;
}
case 'reapplyDone': {
const tMainStart = performance.now();
state.climateComputed = !msg.skipClimate;
const d = state.curData;
d.r_elevation = msg.r_elevation;
d.t_elevation = msg.t_elevation;
d.debugLayers.erosionDelta = msg.erosionDelta;
if (msg.r_wind_east_summer) {
d.r_wind_east_summer = msg.r_wind_east_summer;
d.r_wind_north_summer = msg.r_wind_north_summer;
d.r_wind_east_winter = msg.r_wind_east_winter;
d.r_wind_north_winter = msg.r_wind_north_winter;
}
if (msg.itczLons) {
d.itczLons = msg.itczLons;
d.itczLatsSummer = msg.itczLatsSummer;
d.itczLatsWinter = msg.itczLatsWinter;
}
if (msg.r_ocean_current_east_summer) {
d.r_ocean_current_east_summer = msg.r_ocean_current_east_summer;
d.r_ocean_current_north_summer = msg.r_ocean_current_north_summer;
d.r_ocean_current_east_winter = msg.r_ocean_current_east_winter;
d.r_ocean_current_north_winter = msg.r_ocean_current_north_winter;
d.r_ocean_speed_summer = msg.r_ocean_speed_summer;
d.r_ocean_speed_winter = msg.r_ocean_speed_winter;
d.r_ocean_warmth_summer = msg.r_ocean_warmth_summer;
d.r_ocean_warmth_winter = msg.r_ocean_warmth_winter;
}
// Fallback: compute ocean currents on main thread if worker didn't
if (!d.r_ocean_speed_summer && d.r_wind_east_summer) {
const wr = buildWindResultForOcean(d.mesh, d.r_xyz, d.r_elevation,
d.r_wind_east_summer, d.r_wind_north_summer,
d.r_wind_east_winter, d.r_wind_north_winter,
d.itczLons, d.itczLatsSummer, d.itczLatsWinter);
const oc = computeOceanCurrents(d.mesh, d.r_xyz, d.r_elevation, wr);
Object.keys(oc).filter(k => k.startsWith('r_ocean_')).forEach(k => d[k] = oc[k]);
}
if (msg.r_precip_summer) {
d.r_precip_summer = msg.r_precip_summer;
d.r_precip_winter = msg.r_precip_winter;
}
if (msg.r_temperature_summer) {
d.r_temperature_summer = msg.r_temperature_summer;
d.r_temperature_winter = msg.r_temperature_winter;
}
if (msg.windDebugLayers) {
Object.assign(d.debugLayers, msg.windDebugLayers);
}
// Fallback: compute precip/temp on main thread if climate was
// requested but data is missing (e.g. partial worker result)
if (!msg.skipClimate && d.r_wind_east_summer) {
let wr = null;
if (!d.r_precip_summer || !d.r_temperature_summer) {
wr = buildWindResultForOcean(d.mesh, d.r_xyz, d.r_elevation,
d.r_wind_east_summer, d.r_wind_north_summer,
d.r_wind_east_winter, d.r_wind_north_winter,
d.itczLons, d.itczLatsSummer, d.itczLatsWinter);
}
if (!d.r_precip_summer && wr) {
const pr = computePrecipitation(d.mesh, d.r_xyz, d.r_elevation, wr, d);
d.r_precip_summer = pr.r_precip_summer;
d.r_precip_winter = pr.r_precip_winter;
if (d.debugLayers) {
d.debugLayers.precipSummer = pr.r_precip_summer;
d.debugLayers.precipWinter = pr.r_precip_winter;
d.debugLayers.rainShadowSummer = pr.r_rainshadow_summer;
d.debugLayers.rainShadowWinter = pr.r_rainshadow_winter;
}
}
if (!d.r_temperature_summer && wr) {
const tr = computeTemperature(d.mesh, d.r_xyz, d.r_elevation, wr, d, d);
d.r_temperature_summer = tr.r_temperature_summer;
d.r_temperature_winter = tr.r_temperature_winter;
if (d.debugLayers) {
d.debugLayers.tempSummer = tr.r_temperature_summer;
d.debugLayers.tempWinter = tr.r_temperature_winter;
}
}
}
// Clear stale climate data when climate was skipped so rendering
// doesn't show mismatched terrain/climate from a previous run
if (msg.skipClimate) {
d.r_precip_summer = null;
d.r_precip_winter = null;
d.r_temperature_summer = null;
d.r_temperature_winter = null;
if (d.debugLayers) {
d.debugLayers.koppen = null;
d.debugLayers.tempSummer = null;
d.debugLayers.tempWinter = null;
d.debugLayers.precipSummer = null;
d.debugLayers.precipWinter = null;
}
}
const tBuildStart = performance.now();
buildMesh();
const tBuild = performance.now() - tBuildStart;
const tMainTotal = performance.now() - tMainStart;
const f = v => typeof v === 'number' ? v.toFixed(1) : v;
const rt = msg._reapplyTiming || {};
console.log(`%c[World Orogen] Reapply complete`, 'color:#8f8;font-weight:bold');
if (msg._postTiming && msg._postTiming.length > 0) {
console.groupCollapsed(' %cPost-processing sub-stages', 'color:#8f8');
console.table(msg._postTiming.map(r => ({ Stage: r.stage, 'ms': f(r.ms) })));
console.groupEnd();
}
console.log(
` %cSummary:%c Worker: ${f(rt.workerTotal || 0)} ms (clone=${f(rt.clone || 0)}, postProcess=${f(rt.postProcessing || 0)}, triElev=${f(rt.triangleElevations || 0)}) | Main: ${f(tMainTotal)} ms (buildMesh=${f(tBuild)})`,
'color:#ff6;font-weight:bold', ''
);
if (_onProgress) _onProgress(100, 'Done');
if (_onDone) { _onDone(); _onDone = null; }
break;
}
case 'editDone': {
const tMainStart = performance.now();
state.climateComputed = !msg.skipClimate;
const d = state.curData;
d.prePostElev = msg.prePostElev;
d.r_elevation = msg.r_elevation;
d.t_elevation = msg.t_elevation;
d.mountain_r = new Set(msg.mountain_r);
d.coastline_r = new Set(msg.coastline_r);
d.ocean_r = new Set(msg.ocean_r);
d.r_stress = msg.r_stress;
if (msg.r_wind_east_summer) {
d.r_wind_east_summer = msg.r_wind_east_summer;
d.r_wind_north_summer = msg.r_wind_north_summer;
d.r_wind_east_winter = msg.r_wind_east_winter;
d.r_wind_north_winter = msg.r_wind_north_winter;
}
if (msg.itczLons) {
d.itczLons = msg.itczLons;
d.itczLatsSummer = msg.itczLatsSummer;
d.itczLatsWinter = msg.itczLatsWinter;
}
if (msg.r_ocean_current_east_summer) {
d.r_ocean_current_east_summer = msg.r_ocean_current_east_summer;
d.r_ocean_current_north_summer = msg.r_ocean_current_north_summer;
d.r_ocean_current_east_winter = msg.r_ocean_current_east_winter;
d.r_ocean_current_north_winter = msg.r_ocean_current_north_winter;
d.r_ocean_speed_summer = msg.r_ocean_speed_summer;
d.r_ocean_speed_winter = msg.r_ocean_speed_winter;
d.r_ocean_warmth_summer = msg.r_ocean_warmth_summer;
d.r_ocean_warmth_winter = msg.r_ocean_warmth_winter;
}
// Fallback: compute ocean currents on main thread if worker didn't
if (!d.r_ocean_speed_summer && d.r_wind_east_summer) {
const wr = buildWindResultForOcean(d.mesh, d.r_xyz, d.r_elevation,
d.r_wind_east_summer, d.r_wind_north_summer,
d.r_wind_east_winter, d.r_wind_north_winter,
d.itczLons, d.itczLatsSummer, d.itczLatsWinter);
const oc = computeOceanCurrents(d.mesh, d.r_xyz, d.r_elevation, wr);
Object.keys(oc).filter(k => k.startsWith('r_ocean_')).forEach(k => d[k] = oc[k]);
}
if (msg.r_precip_summer) {
d.r_precip_summer = msg.r_precip_summer;
d.r_precip_winter = msg.r_precip_winter;
}
if (msg.r_temperature_summer) {
d.r_temperature_summer = msg.r_temperature_summer;
d.r_temperature_winter = msg.r_temperature_winter;
}
d.debugLayers = msg.debugLayers;
// Fallback: compute precip/temp on main thread if climate was
// requested but data is missing (e.g. partial worker result)
if (!msg.skipClimate && d.r_wind_east_summer) {
let wr = null;
if (!d.r_precip_summer || !d.r_temperature_summer) {
wr = buildWindResultForOcean(d.mesh, d.r_xyz, d.r_elevation,
d.r_wind_east_summer, d.r_wind_north_summer,
d.r_wind_east_winter, d.r_wind_north_winter,
d.itczLons, d.itczLatsSummer, d.itczLatsWinter);
}
if (!d.r_precip_summer && wr) {
const pr = computePrecipitation(d.mesh, d.r_xyz, d.r_elevation, wr, d);
d.r_precip_summer = pr.r_precip_summer;
d.r_precip_winter = pr.r_precip_winter;
if (d.debugLayers) {
d.debugLayers.precipSummer = pr.r_precip_summer;
d.debugLayers.precipWinter = pr.r_precip_winter;
d.debugLayers.rainShadowSummer = pr.r_rainshadow_summer;
d.debugLayers.rainShadowWinter = pr.r_rainshadow_winter;
}
}
if (!d.r_temperature_summer && wr) {
const tr = computeTemperature(d.mesh, d.r_xyz, d.r_elevation, wr, d, d);
d.r_temperature_summer = tr.r_temperature_summer;
d.r_temperature_winter = tr.r_temperature_winter;
if (d.debugLayers) {
d.debugLayers.tempSummer = tr.r_temperature_summer;
d.debugLayers.tempWinter = tr.r_temperature_winter;
}
}
}
// Clear stale climate data when climate was skipped
if (msg.skipClimate) {
d.r_precip_summer = null;
d.r_precip_winter = null;
d.r_temperature_summer = null;
d.r_temperature_winter = null;
if (d.debugLayers) {
d.debugLayers.koppen = null;
d.debugLayers.tempSummer = null;
d.debugLayers.tempWinter = null;
d.debugLayers.precipSummer = null;
d.debugLayers.precipWinter = null;
}
}
const tColorsStart = performance.now();
computePlateColors(d.plateSeeds, d.plateIsOcean);
const tColors = performance.now() - tColorsStart;
const tBuildStart = performance.now();
buildMesh();
const tBuild = performance.now() - tBuildStart;
const tMainTotal = performance.now() - tMainStart;
const f = v => typeof v === 'number' ? v.toFixed(1) : v;
const et = msg._editTiming || {};
console.log(`%c[World Orogen] Edit recompute complete`, 'color:#fc8;font-weight:bold');
if (msg._timing) {
console.groupCollapsed(' %cElevation sub-stages', 'color:#fc8');
console.table(msg._timing.map(r => ({ Stage: r.stage, 'ms': f(r.ms) })));
console.groupEnd();
}
if (msg._postTiming && msg._postTiming.length > 0) {
console.groupCollapsed(' %cPost-processing sub-stages', 'color:#8f8');
console.table(msg._postTiming.map(r => ({ Stage: r.stage, 'ms': f(r.ms) })));
console.groupEnd();
}
console.log(
` %cSummary:%c Worker: ${f(et.workerTotal || 0)} ms (elevation=${f(et.elevation || 0)}, postProcess=${f(et.postProcessing || 0)}, triElev=${f(et.triangleElevations || 0)}, retain=${f(et.retainState || 0)}) | Main: ${f(tMainTotal)} ms (colors=${f(tColors)}, buildMesh=${f(tBuild)})`,
'color:#ff6;font-weight:bold', ''
);
if (_onProgress) _onProgress(100, 'Done');
if (_onDone) { _onDone(); _onDone = null; }
break;
}
case 'climateDone': {
const d = state.curData;
if (d) {
// Copy all climate arrays
d.r_wind_east_summer = msg.r_wind_east_summer;
d.r_wind_north_summer = msg.r_wind_north_summer;
d.r_wind_east_winter = msg.r_wind_east_winter;
d.r_wind_north_winter = msg.r_wind_north_winter;
d.itczLons = msg.itczLons;
d.itczLatsSummer = msg.itczLatsSummer;
d.itczLatsWinter = msg.itczLatsWinter;
d.r_ocean_current_east_summer = msg.r_ocean_current_east_summer;
d.r_ocean_current_north_summer = msg.r_ocean_current_north_summer;
d.r_ocean_current_east_winter = msg.r_ocean_current_east_winter;
d.r_ocean_current_north_winter = msg.r_ocean_current_north_winter;
d.r_ocean_speed_summer = msg.r_ocean_speed_summer;
d.r_ocean_speed_winter = msg.r_ocean_speed_winter;
d.r_ocean_warmth_summer = msg.r_ocean_warmth_summer;
d.r_ocean_warmth_winter = msg.r_ocean_warmth_winter;
d.r_precip_summer = msg.r_precip_summer;
d.r_precip_winter = msg.r_precip_winter;
d.r_temperature_summer = msg.r_temperature_summer;
d.r_temperature_winter = msg.r_temperature_winter;
// Merge climate debug layers
if (msg.climateDebugLayers && d.debugLayers) {
Object.assign(d.debugLayers, msg.climateDebugLayers);
}
}
state.climateComputed = true;
buildMesh();
const f = v => typeof v === 'number' ? v.toFixed(1) : v;
const ct = msg._climateTiming || {};
console.log(`%c[World Orogen] Climate computed on demand`, 'color:#f8a;font-weight:bold');
console.log(
` %cSummary:%c Worker: ${f(ct.workerTotal || 0)} ms (wind=${f(ct.wind || 0)}, ocean=${f(ct.ocean || 0)}, precip=${f(ct.precipitation || 0)}, temp=${f(ct.temperature || 0)}, koppen=${f(ct.koppen || 0)})`,
'color:#ff6;font-weight:bold', ''
);
if (_onProgress) _onProgress(100, 'Done');
if (_onDone) { _onDone(); _onDone = null; }
break;
}
case 'error':
fail(msg.message);
if (_onDone) { _onDone(); _onDone = null; }
break;
}
};
worker.onerror = (e) => {
fail(e.message || 'Worker crashed');
if (_onDone) { _onDone(); _onDone = null; }
};
}
// --- Synchronous fallback (imported lazily to avoid loading when worker works) ---
let _fallbackModules = null;
async function loadFallback() {
if (_fallbackModules) return _fallbackModules;
const [rng, simplex, sphere, plates, ocean, elev, post, wind, oceanCurrents, precip, temp, coarsePlates] = await Promise.all([
import('./rng.js'),
import('./simplex-noise.js'),
import('./sphere-mesh.js'),
import('./plates.js'),
import('./ocean-land.js'),
import('./elevation.js'),
import('./terrain-post.js'),
import('./wind.js'),
import('./ocean.js'),
import('./precipitation.js'),
import('./temperature.js'),
import('./coarse-plates.js')
]);
_fallbackModules = { rng, simplex, sphere, plates, ocean, elev, post, wind, oceanCurrents, precip, temp, coarsePlates };
return _fallbackModules;
}
function generateFallback(overrideSeed, toggledIndices, onProgress, skipClimate) {
// Dynamic import already resolved — run synchronously via rAF stages
const m = _fallbackModules;
const btn = document.getElementById('generate');
const { N, P, jitter, nMag, numContinents, terrainWarp, smoothing, hydraulicErosion, thermalErosion, ridgeSharpening, glacialErosion, continentSizeVariety, temperatureOffset, precipitationOffset, landCoverage } = readSliders();
const progress = onProgress || (() => {});
const ctx = {};
const stages = [
{ pct: 0, label: 'Shaping the world\u2026', work() {
ctx.seed = overrideSeed ?? Math.floor(Math.random() * 16777216);
ctx.rng = m.rng.makeRng(ctx.seed);
const { mesh, r_xyz } = m.sphere.buildSphere(N, jitter, ctx.rng);
ctx.mesh = mesh; ctx.r_xyz = r_xyz;
ctx.t_xyz = m.sphere.generateTriangleCenters(mesh, r_xyz);
}},
{ pct: 10, label: 'Generating coarse plates\u2026', work() {
const { coarseMesh, coarse_xyz, coarse_r_plate, coarsePlateSeeds, coarsePlateVec, coarsePlateIsOcean } =
m.coarsePlates.generateCoarsePlates(ctx.seed, P, numContinents, continentSizeVariety, landCoverage);
ctx.coarseMesh = coarseMesh; ctx.coarse_xyz = coarse_xyz;
ctx.coarse_r_plate = coarse_r_plate;
ctx.plateSeeds = coarsePlateSeeds; ctx.plateVec = coarsePlateVec;
ctx.coarsePlateIsOcean = coarsePlateIsOcean;
}},
{ pct: 18, label: 'Projecting plates\u2026', work() {
ctx.r_plate = m.coarsePlates.projectCoarsePlates(ctx.mesh, ctx.r_xyz, ctx.coarseMesh, ctx.coarse_xyz, ctx.coarse_r_plate, ctx.seed, P);
m.plates.smoothAndReconnectPlates(ctx.mesh, ctx.r_plate, ctx.plateSeeds, 3);
}},
{ pct: 25, label: 'Carving oceans\u2026', work() {
const plateIsOcean = ctx.coarsePlateIsOcean;
ctx.originalPlateIsOcean = new Set(plateIsOcean);
if (toggledIndices.length > 0) {
const seedArr = Array.from(ctx.plateSeeds);
for (const i of toggledIndices) {
if (i < seedArr.length) {
const r = seedArr[i];
if (plateIsOcean.has(r)) plateIsOcean.delete(r);
else plateIsOcean.add(r);
}
}
}
computePlateColors(ctx.plateSeeds, plateIsOcean);
const plateDensity = {}, plateDensityLand = {}, plateDensityOcean = {};
for (const r of ctx.plateSeeds) {
const drng = m.rng.makeRng(r + 777);
plateDensityOcean[r] = 3.0 + drng() * 0.5;
plateDensityLand[r] = 2.4 + drng() * 0.5;
plateDensity[r] = plateIsOcean.has(r) ? plateDensityOcean[r] : plateDensityLand[r];
}
ctx.plateIsOcean = plateIsOcean; ctx.plateDensity = plateDensity;
ctx.plateDensityLand = plateDensityLand; ctx.plateDensityOcean = plateDensityOcean;
ctx.noise = new m.simplex.SimplexNoise(ctx.seed);
}},
{ pct: 35, label: 'Raising mountains\u2026', work() {
const { r_elevation, mountain_r, coastline_r, ocean_r, r_stress, debugLayers, _timing } =
m.elev.assignElevation(ctx.mesh, ctx.r_xyz, ctx.plateIsOcean, ctx.r_plate, ctx.plateVec, ctx.plateSeeds, ctx.noise, nMag, ctx.seed, 5, ctx.plateDensity);
ctx.r_elevation = r_elevation; ctx.mountain_r = mountain_r; ctx.coastline_r = coastline_r;
ctx.ocean_r = ocean_r; ctx.r_stress = r_stress; ctx.debugLayers = debugLayers;
ctx.prePostElev = new Float32Array(r_elevation);
if (terrainWarp > 0) m.post.warpTerrain(ctx.mesh, r_elevation, ctx.r_xyz, ctx.seed, terrainWarp, debugLayers.hotspot);
const r_isOcean = new Uint8Array(ctx.mesh.numRegions);
for (let r = 0; r < ctx.mesh.numRegions; r++) { if (r_elevation[r] <= 0) r_isOcean[r] = 1; }
const preErosion = new Float32Array(r_elevation);
if (smoothing > 0) m.post.smoothElevation(ctx.mesh, r_elevation, r_isOcean, Math.round(1 + smoothing * 4), 0.2 + smoothing * 0.5);
if (glacialErosion > 0 || hydraulicErosion > 0 || thermalErosion > 0)
m.post.erodeComposite(ctx.mesh, r_elevation, ctx.r_xyz, r_isOcean, Math.round(hydraulicErosion * 20), hydraulicErosion * 0.0006, 0.5, 1.0, Math.round(thermalErosion * 10), 1.2 - thermalErosion * 0.4, thermalErosion * 0.15, Math.round(glacialErosion * 10), glacialErosion);
if (ridgeSharpening > 0) m.post.sharpenRidges(ctx.mesh, r_elevation, r_isOcean, Math.round(1 + ridgeSharpening * 3), ridgeSharpening * 0.08);
m.post.applySoilCreep(ctx.mesh, r_elevation, r_isOcean, 3, 0.1125);
const dl_erosionDelta = new Float32Array(ctx.mesh.numRegions);
for (let r = 0; r < ctx.mesh.numRegions; r++) dl_erosionDelta[r] = r_elevation[r] - preErosion[r];
debugLayers.erosionDelta = dl_erosionDelta;
if (!skipClimate) {
const windResult = m.wind.computeWind(ctx.mesh, ctx.r_xyz, r_elevation, ctx.plateIsOcean, ctx.r_plate, ctx.noise);
debugLayers.pressureSummer = windResult.r_pressure_summer;
debugLayers.pressureWinter = windResult.r_pressure_winter;
debugLayers.windSpeedSummer = windResult.r_wind_speed_summer;
debugLayers.windSpeedWinter = windResult.r_wind_speed_winter;
ctx.windResult = windResult;
const oceanResult = m.oceanCurrents.computeOceanCurrents(ctx.mesh, ctx.r_xyz, r_elevation, windResult);
ctx.oceanResult = oceanResult;
const precipResult = m.precip.computePrecipitation(ctx.mesh, ctx.r_xyz, r_elevation, windResult, oceanResult, precipitationOffset, landCoverage);
ctx.precipResult = precipResult;
debugLayers.precipSummer = precipResult.r_precip_summer;
debugLayers.precipWinter = precipResult.r_precip_winter;
debugLayers.rainShadowSummer = precipResult.r_rainshadow_summer;
debugLayers.rainShadowWinter = precipResult.r_rainshadow_winter;
const tempResult = m.temp.computeTemperature(ctx.mesh, ctx.r_xyz, r_elevation, windResult, oceanResult, precipResult, temperatureOffset);
ctx.tempResult = tempResult;
debugLayers.tempSummer = tempResult.r_temperature_summer;
debugLayers.tempWinter = tempResult.r_temperature_winter;
debugLayers.koppen = classifyKoppen(ctx.mesh, r_elevation, tempResult, precipResult);
}
const t_elevation = new Float32Array(ctx.mesh.numTriangles);
for (let t = 0; t < ctx.mesh.numTriangles; t++) {
const s0 = 3 * t;
const a = ctx.mesh.s_begin_r(s0), b = ctx.mesh.s_begin_r(s0+1), c = ctx.mesh.s_begin_r(s0+2);
t_elevation[t] = (r_elevation[a] + r_elevation[b] + r_elevation[c]) / 3;
}
ctx.t_elevation = t_elevation;
}},
{ pct: 85, label: 'Painting the surface\u2026', work() {
state.curData = {
mesh: ctx.mesh, r_xyz: ctx.r_xyz, t_xyz: ctx.t_xyz,
r_plate: ctx.r_plate, plateSeeds: ctx.plateSeeds, plateVec: ctx.plateVec,
plateIsOcean: ctx.plateIsOcean, originalPlateIsOcean: ctx.originalPlateIsOcean,
plateDensity: ctx.plateDensity, plateDensityLand: ctx.plateDensityLand,
plateDensityOcean: ctx.plateDensityOcean, prePostElev: ctx.prePostElev,
r_elevation: ctx.r_elevation, t_elevation: ctx.t_elevation,
mountain_r: ctx.mountain_r, coastline_r: ctx.coastline_r, ocean_r: ctx.ocean_r,
r_stress: ctx.r_stress, noise: ctx.noise, seed: ctx.seed, debugLayers: ctx.debugLayers,
r_wind_east_summer: ctx.windResult ? ctx.windResult.r_wind_east_summer : null,
r_wind_north_summer: ctx.windResult ? ctx.windResult.r_wind_north_summer : null,
r_wind_east_winter: ctx.windResult ? ctx.windResult.r_wind_east_winter : null,
r_wind_north_winter: ctx.windResult ? ctx.windResult.r_wind_north_winter : null,
itczLons: ctx.windResult ? ctx.windResult.itczLons : null,
itczLatsSummer: ctx.windResult ? ctx.windResult.itczLatsSummer : null,
itczLatsWinter: ctx.windResult ? ctx.windResult.itczLatsWinter : null,
r_ocean_current_east_summer: ctx.oceanResult ? ctx.oceanResult.r_ocean_current_east_summer : null,
r_ocean_current_north_summer: ctx.oceanResult ? ctx.oceanResult.r_ocean_current_north_summer : null,
r_ocean_current_east_winter: ctx.oceanResult ? ctx.oceanResult.r_ocean_current_east_winter : null,
r_ocean_current_north_winter: ctx.oceanResult ? ctx.oceanResult.r_ocean_current_north_winter : null,
r_ocean_speed_summer: ctx.oceanResult ? ctx.oceanResult.r_ocean_speed_summer : null,
r_ocean_speed_winter: ctx.oceanResult ? ctx.oceanResult.r_ocean_speed_winter : null,
r_ocean_warmth_summer: ctx.oceanResult ? ctx.oceanResult.r_ocean_warmth_summer : null,
r_ocean_warmth_winter: ctx.oceanResult ? ctx.oceanResult.r_ocean_warmth_winter : null,
r_precip_summer: ctx.precipResult ? ctx.precipResult.r_precip_summer : null,
r_precip_winter: ctx.precipResult ? ctx.precipResult.r_precip_winter : null,
r_temperature_summer: ctx.tempResult ? ctx.tempResult.r_temperature_summer : null,
r_temperature_winter: ctx.tempResult ? ctx.tempResult.r_temperature_winter : null
};
state.climateComputed = !skipClimate;
buildMesh();
progress(100, 'Done');
resetUI();
btn.dispatchEvent(new CustomEvent('generate-done'));
}}
];
function runStage(idx) {
if (idx >= stages.length) return;
const s = stages[idx];
try { progress(s.pct, s.label); } catch (e) { fail(e); return; }
requestAnimationFrame(() => setTimeout(() => {
try { s.work(); runStage(idx + 1); } catch (e) { fail(e); }
}, 0));
}
setTimeout(() => runStage(0), 0);
}
// --- Public API ---
export function generate(overrideSeed, toggledIndices = [], onProgress, skipClimate = false) {
const btn = document.getElementById('generate');
btn.disabled = true;
btn.textContent = 'Building\u2026';
btn.classList.add('generating');
_onProgress = onProgress || (() => {});
_t0 = performance.now();
if (!worker) {
// Fallback: load modules then run synchronously
loadFallback().then(() => generateFallback(overrideSeed, toggledIndices, onProgress, skipClimate));
return;
}
const s = readSliders();
worker.postMessage({
cmd: 'generate',
...s,
seed: overrideSeed,
toggledIndices,
skipClimate
});
}
export function reapplyViaWorker(onDone, skipClimate = false) {
if (!worker || !state.curData) return;
_onProgress = (pct, label) => {
// Progress updates during reapply (used by build overlay if shown)
};
_onDone = onDone || null;
_t0 = performance.now();
const s = readSlidersOptional();
const temperatureOffset = +(document.getElementById('sTmp')?.value ?? 0);
const precipitationOffset = +(document.getElementById('sPrc')?.value ?? 0);
const landCoverage = +(document.getElementById('sLc')?.value ?? 0.3);
worker.postMessage({
cmd: 'reapply',
...s, temperatureOffset, precipitationOffset, landCoverage,
skipClimate
});
}
export function editRecomputeViaWorker(onDone, skipClimate = false) {
if (!worker || !state.curData) return;
const d = state.curData;
_onProgress = () => {};
_onDone = onDone || null;
_t0 = performance.now();
const { nMag, terrainWarp, smoothing, glacialErosion, hydraulicErosion, thermalErosion, ridgeSharpening, temperatureOffset, precipitationOffset, landCoverage } = readSliders();
worker.postMessage({
cmd: 'editRecompute',
plateIsOcean: Array.from(d.plateIsOcean),
plateDensity: d.plateDensity,
nMag, terrainWarp, smoothing, glacialErosion, hydraulicErosion, thermalErosion, ridgeSharpening,
temperatureOffset, precipitationOffset, landCoverage,
skipClimate
});
}
export function computeClimateViaWorker(onProgress, onDone) {
if (!worker || !state.curData) return;
_onProgress = onProgress || (() => {});
_onDone = onDone || null;
_t0 = performance.now();
const temperatureOffset = +(document.getElementById('sTmp')?.value ?? 0);
const precipitationOffset = +(document.getElementById('sPrc')?.value ?? 0);
const landCoverage = +(document.getElementById('sLc')?.value ?? 0.3);
worker.postMessage({
cmd: 'computeClimate',
temperatureOffset, precipitationOffset, landCoverage
});
}
/**
* Import a painted class map: `classRaster` is one legend index per pixel (from
* painted.js classifyImage on the main thread), `legend` the raw legend JSON object, and
* `paintedParams` the planet block plus the solve controls (peak height, ocean depth, steps,
* coast detail, uplift variation, seed).
*/
export function importPainted(classRaster, imageWidth, imageHeight, legend, paintedParams, onProgress, skipClimate = false, overlay = null) {
if (!worker) return;
_onProgress = onProgress || (() => {});
_t0 = performance.now();
const { N, jitter, terrainWarp, smoothing, hydraulicErosion, thermalErosion, ridgeSharpening, glacialErosion } = readSlidersOptional();
const temperatureOffset = +(document.getElementById('sTmp')?.value ?? 0);
const precipitationOffset = +(document.getElementById('sPrc')?.value ?? 0);
worker.postMessage({
cmd: 'importPainted',
N, jitter,
classRaster, imageWidth, imageHeight,
legend, painted: paintedParams,
overlay,
terrainWarp, smoothing, hydraulicErosion, thermalErosion, ridgeSharpening, glacialErosion,
temperatureOffset, precipitationOffset,
skipClimate
}, [classRaster.buffer, ...(overlay && overlay.marks ? [overlay.marks.buffer] : [])]);
}
export function importHeightmap(grayscale, imageWidth, imageHeight, onProgress, skipClimate = false) {
if (!worker) return;
_onProgress = onProgress || (() => {});
_t0 = performance.now();
const { N, jitter, terrainWarp, smoothing, hydraulicErosion, thermalErosion, ridgeSharpening, glacialErosion } = readSlidersOptional();
worker.postMessage({
cmd: 'importHeightmap',
N, jitter,
grayscale, imageWidth, imageHeight,
terrainWarp, smoothing, hydraulicErosion, thermalErosion, ridgeSharpening, glacialErosion,
skipClimate
}, [grayscale.buffer]);
}
+269
View File
@@ -0,0 +1,269 @@
// Heuristic precipitation model: smooth zonal patterns blended with the
// complex advection model to reduce splotchiness and strengthen deserts.
// Computes precipitation from four multiplicative factors: zonal base curve
// (distance from ITCZ), seasonal modifier, continental dryness, and
// orographic rain shadow.
import { smoothstep } from './wind.js';
import { elevToHeightKm } from './color-map.js';
import { smoothField, makeItczLookup } from './climate-util.js';
const DEG = Math.PI / 180;
// ── Zonal base curve ────────────────────────────────────────────────────────
// Returns a value in [0.03, 1.0] based on distance from the ITCZ in degrees.
function zonalBase(distDeg) {
if (distDeg < 5) {
// ITCZ core: 1.0
return 1.0;
} else if (distDeg < 10) {
// Outer ITCZ / trades: 1.0 → 0.35 (faster falloff)
return 1.0 - 0.65 * smoothstep(5, 10, distDeg);
} else if (distDeg < 33) {
// Subtropical highs (desert factory): 0.35 → 0.02
// Very aggressive minimum — core of the desert belt.
return 0.35 - 0.33 * smoothstep(10, 28, distDeg);
} else if (distDeg < 55) {
// Mid-lat westerlies recovery: 0.02 → 0.5
return 0.02 + 0.48 * smoothstep(33, 55, distDeg);
} else if (distDeg < 70) {
// Subpolar: 0.5 → 0.3
return 0.5 - 0.2 * smoothstep(55, 70, distDeg);
} else {
// Polar: 0.3 → 0.1
return 0.3 - 0.2 * smoothstep(70, 90, distDeg);
}
}
// ── Heuristic zonal wind ────────────────────────────────────────────────────
// Idealized wind direction based on latitude relative to the ITCZ.
// Returns local east/north components (positive east = blowing eastward,
// positive north = blowing poleward in NH).
//
// Zonal wind belts (Earth-like):
// ITCZ (0-5°): light/convergent
// Trades (5-30°): strong easterlies, deflected equatorward by Coriolis
// Subtropical (25-35°): weak/variable (transition)
// Westerlies (35-60°): west→east, deflected poleward
// Polar easterlies (60-90°): east→west, deflected equatorward
function heuristicWind(distFromItczDeg, isNorthOfItcz) {
// Sign for hemisphere: +1 if north of ITCZ, -1 if south
const hemiSign = isNorthOfItcz ? 1 : -1;
let we, wn;
if (distFromItczDeg < 5) {
// ITCZ: light convergent winds — slight equatorward component
we = 0;
wn = -hemiSign * 0.1;
} else if (distFromItczDeg < 30) {
// Trade winds: easterlies (blowing westward) with equatorward component
// Strength ramps up from ITCZ edge, peaks ~15-20°, fades toward subtropics
const tradeStrength = smoothstep(5, 15, distFromItczDeg)
* (1 - smoothstep(25, 32, distFromItczDeg));
we = -tradeStrength * 0.8; // strong westward
wn = -hemiSign * tradeStrength * 0.3; // equatorward (toward ITCZ)
} else if (distFromItczDeg < 60) {
// Westerlies: blowing eastward with poleward component
const westStrength = smoothstep(30, 40, distFromItczDeg)
* (1 - smoothstep(55, 65, distFromItczDeg));
we = westStrength * 0.9; // strong eastward
wn = hemiSign * westStrength * 0.25; // poleward
} else {
// Polar easterlies: blowing westward with equatorward component
const polarStrength = smoothstep(60, 70, distFromItczDeg);
we = -polarStrength * 0.4; // moderate westward
wn = -hemiSign * polarStrength * 0.15; // equatorward
}
return { we, wn };
}
// ── Heuristic wind field for a full season ──────────────────────────────────
// Computes idealized zonal wind E/N arrays for all regions.
export function computeHeuristicWindField(numRegions, r_lat, r_lon, itczLookup) {
const hWindE = new Float32Array(numRegions);
const hWindN = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const lat = r_lat[r];
const itczLat = itczLookup(r_lon[r]) * 0.3; // dampened ITCZ, same as precip
const signedDist = lat - itczLat;
const distDeg = Math.abs(signedDist) / DEG;
const northOfItcz = signedDist > 0;
const { we, wn } = heuristicWind(distDeg, northOfItcz);
hWindE[r] = we;
hWindN[r] = wn;
}
return { hWindE, hWindN };
}
// ── Main entry point ─────────────────────────────────────────────────────────
/**
* Compute heuristic precipitation for both seasons.
* Returns raw (un-normalized) Float32Arrays.
*
* @param {SphereMesh} mesh
* @param {Float32Array} r_xyz
* @param {Float32Array} r_elevation
* @param {object} windResult - output from computeWind()
* @param {Float32Array} r_elevGradE - pre-computed east elevation gradient
* @param {Float32Array} r_elevGradN - pre-computed north elevation gradient
* @param {Int32Array} r_coastDistLand - BFS hop distance from coast through land
* @returns {{ r_precip_summer, r_precip_winter }}
*/
export function computeHeuristicPrecipitation(mesh, r_xyz, r_elevation, windResult, r_elevGradE, r_elevGradN, r_coastDistLand) {
const numRegions = mesh.numRegions;
const { r_lat, r_lon, r_isLand, r_continentality } = windResult;
const avgEdgeKm = (Math.PI * 6371) / Math.sqrt(numRegions);
// Precompute west-coast proximity: positive = west coast, negative = east coast.
// Coastal land cells check which side ocean is on relative to the local east
// direction, then the signal is smoothed ~300 km inland through land only.
const { r_eastX, r_eastY, r_eastZ } = windResult;
const { adjOffset, adjList } = mesh;
const r_westCoast = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r] || r_coastDistLand[r] !== 0) continue;
let oceanDotEast = 0;
let count = 0;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (!r_isLand[nb]) {
const dx = r_xyz[3 * nb] - r_xyz[3 * r];
const dy = r_xyz[3 * nb + 1] - r_xyz[3 * r + 1];
const dz = r_xyz[3 * nb + 2] - r_xyz[3 * r + 2];
oceanDotEast += dx * r_eastX[r] + dy * r_eastY[r] + dz * r_eastZ[r];
count++;
}
}
if (count > 0) {
// Negative dot = ocean is to the west = west coast
r_westCoast[r] = oceanDotEast < 0 ? 1 : -1;
}
}
// Smooth through land only (~300 km) so the signal bleeds inland
const wcPasses = Math.max(2, Math.round(300 / avgEdgeKm));
const wcTmp = new Float32Array(numRegions);
for (let pass = 0; pass < wcPasses; pass++) {
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r]) { wcTmp[r] = 0; continue; }
let sum = r_westCoast[r], count = 1;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (r_isLand[nb]) { sum += r_westCoast[nb]; count++; }
}
wcTmp[r] = sum / count;
}
r_westCoast.set(wcTmp);
}
const result = {};
const seasons = [
{ name: 'summer', shift: 5 },
{ name: 'winter', shift: -5 }
];
for (const { name } of seasons) {
const isSummer = name === 'summer';
const itczLookup = makeItczLookup(windResult.itczLons,
isSummer ? windResult.itczLatsSummer : windResult.itczLatsWinter);
const precip = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const lat = r_lat[r];
const lon = r_lon[r];
// ── A. Zonal base curve (distance from ITCZ) ──
// Dampen ITCZ shift: use only 30% of the complex model's ITCZ
// displacement so the zonal bands stay close to the geographic
// equator. The full ITCZ swing (up to 15-20°) would drag the
// subtropical desert belt too far, drying the true equator and
// wetting the mid-latitudes in the shifted season.
const itczLat = itczLookup(lon) * 0.3;
const signedDist = lat - itczLat;
const distFromItczDeg = Math.abs(signedDist) / DEG;
const isNorthOfItcz = signedDist > 0;
const zonal = zonalBase(distFromItczDeg);
// ── B. Seasonal modifier + Mediterranean subtropical suppression ──
const absLatDeg = Math.abs(lat) / DEG;
const inSummerHemi = isSummer ? (lat >= 0) : (lat < 0);
let seasonMod = inSummerHemi ? 1.1 : 0.9;
// Mediterranean suppression: subtropical highs expand poleward in
// local summer, strongly suppressing rainfall at 25-42° latitude.
// In local winter the highs retreat equatorward and westerlies
// bring rain to these latitudes. This seasonal contrast is the
// primary driver of Mediterranean (Cs) climates.
// Stronger on west coasts (subtropical highs sit over eastern ocean
// basins, drying the adjacent western continental margins) and
// weaker on east coasts (onshore tropical moisture counters drying).
if (inSummerHemi && absLatDeg > 22 && absLatDeg < 45) {
const medSuppress = smoothstep(22, 30, absLatDeg)
* (1 - smoothstep(38, 45, absLatDeg));
const wc = r_westCoast[r]; // +1 west coast, -1 east coast, 0 inland
const strength = 0.15 + wc * 0.20; // 0.35 west coast, 0.15 inland, ~0 east coast
seasonMod *= (1 - medSuppress * Math.max(0, strength));
}
// ── C. Continental dryness ──
let contMod = 1.0;
const cont = (r_isLand[r] && r_continentality) ? r_continentality[r] : 0;
if (cont > 0) {
contMod = 1.0 - cont * cont * 0.65;
}
// ── D. Orographic rain shadow (using heuristic zonal wind) ──
let oroMod = 1.0;
if (r_isLand[r] && r_elevation[r] > 0) {
const { we, wn } = heuristicWind(distFromItczDeg, isNorthOfItcz);
// Wind dot elevation gradient: positive = windward, negative = leeward
const windDotGrad = we * r_elevGradE[r] + wn * r_elevGradN[r];
if (windDotGrad > 0) {
// Windward: up to +60% boost
const uplift = Math.min(1, windDotGrad * 15);
oroMod = 1.0 + uplift * 0.6;
} else {
// Leeward: up to -70% suppression, scaled by mountain height
const heightKm = elevToHeightKm(Math.max(0, r_elevation[r]));
const heightScale = Math.min(1, heightKm / 3); // 3km+ = full shadow
const shadow = Math.min(1, -windDotGrad * 18);
oroMod = Math.max(0.3, 1.0 - shadow * 0.7 * heightScale);
}
}
// ── E. Hard distance-from-coast cutoff ──
// Fixed 2000-3000km cutoff regardless of latitude.
let distMod = 1.0;
if (r_isLand[r] && r_coastDistLand[r] > 0) {
const distKm = r_coastDistLand[r] * avgEdgeKm;
if (distKm > 2000) {
distMod = Math.max(0.03, 1 - smoothstep(2000, 3000, distKm));
}
}
// ── Final ──
precip[r] = Math.max(0.05, zonal * seasonMod * contMod * oroMod * distMod);
}
// Light smoothing ~100km
const smoothPasses = Math.max(1, Math.round(100 / avgEdgeKm));
smoothField(mesh, precip, smoothPasses);
result[`r_precip_${name}`] = precip;
}
return result;
}
File diff suppressed because it is too large Load Diff
+301
View File
@@ -0,0 +1,301 @@
// Köppen climate classification using the "worldbuilding pasta" band-based
// methodology. Two-season (summer/winter) data is used as a proxy for
// warmest/coldest month values.
//
// Approach:
// Step 1 – Temperature bands (tropical → temperate → continental → tundra → ice cap)
// Step 2 – Arid zones (B) dry in both seasons → desert core + steppe fringe
// Step 3 – Precipitation subtypes within each band (A / C / D details)
//
// IMPORTANT: The simulation labels "summer" and "winter" are NH-centric
// (NH summer = June-Aug, NH winter = Dec-Feb). For each cell we determine
// the LOCAL warm/cold season from temperature and use that to assign the
// correct precipitation pattern (s/w/f). Without this, Mediterranean (Cs)
// and monsoon (Cw/Dw) climates are hemisphere-flipped.
import { smoothstep } from './wind.js';
/**
* Köppen class definitions: ID → { code, name, color [r,g,b] 0-1 }.
*/
export const KOPPEN_CLASSES = [
{ code: 'Ocean', name: 'Ocean', color: [0.29, 0.44, 0.65] }, // #4a6fa5
{ code: 'Af', name: 'Tropical rainforest', color: [0.00, 0.00, 1.00] }, // #0000FF
{ code: 'Am', name: 'Tropical monsoon', color: [0.00, 0.47, 1.00] }, // #0077FF
{ code: 'Aw', name: 'Tropical savanna', color: [0.27, 0.67, 0.98] }, // #46AAFA
{ code: 'BWh', name: 'Hot desert', color: [1.00, 0.00, 0.00] }, // #FF0000
{ code: 'BWk', name: 'Cold desert', color: [1.00, 0.59, 0.59] }, // #FF9696
{ code: 'BSh', name: 'Hot steppe', color: [0.96, 0.65, 0.00] }, // #F5A500
{ code: 'BSk', name: 'Cold steppe', color: [1.00, 0.86, 0.39] }, // #FFDB63
{ code: 'Cfa', name: 'Humid subtropical', color: [0.78, 1.00, 0.31] }, // #C8FF50
{ code: 'Cfb', name: 'Oceanic', color: [0.39, 1.00, 0.31] }, // #64FF50
{ code: 'Cfc', name: 'Subpolar oceanic', color: [0.20, 0.78, 0.00] }, // #32C800
{ code: 'Csa', name: 'Hot-summer Mediterranean', color: [1.00, 1.00, 0.00] }, // #FFFF00
{ code: 'Csb', name: 'Warm-summer Mediterranean', color: [0.78, 0.78, 0.00] }, // #C8C800
{ code: 'Csc', name: 'Cold-summer Mediterranean', color: [0.59, 0.59, 0.00] }, // #969600
{ code: 'Cwa', name: 'Humid subtropical (monsoon)', color: [0.59, 1.00, 0.59] }, // #96FF96
{ code: 'Cwb', name: 'Subtropical highland', color: [0.39, 0.78, 0.39] }, // #63C764
{ code: 'Cwc', name: 'Cold subtropical highland', color: [0.20, 0.59, 0.20] }, // #329633
{ code: 'Dfa', name: 'Hot-summer continental', color: [0.00, 1.00, 1.00] }, // #00FFFF
{ code: 'Dfb', name: 'Warm-summer continental', color: [0.22, 0.78, 1.00] }, // #37C8FF
{ code: 'Dfc', name: 'Subarctic', color: [0.00, 0.49, 0.49] }, // #007D7D
{ code: 'Dfd', name: 'Extremely cold subarctic', color: [0.00, 0.27, 0.37] }, // #00465F
{ code: 'Dsa', name: 'Hot-summer continental (dry summer)', color: [0.90, 0.50, 1.00] }, // #E680FF
{ code: 'Dsb', name: 'Warm-summer continental (dry summer)', color: [0.70, 0.35, 0.85] }, // #B359D9
{ code: 'Dsc', name: 'Subarctic (dry summer)', color: [0.50, 0.20, 0.65] }, // #8033A6
{ code: 'Dsd', name: 'Extremely cold subarctic (dry summer)', color: [0.35, 0.10, 0.45] }, // #591A73
{ code: 'Dwa', name: 'Hot-summer continental (monsoon)', color: [0.67, 0.69, 1.00] }, // #ABB1FF
{ code: 'Dwb', name: 'Warm-summer continental (monsoon)', color: [0.43, 0.47, 0.78] }, // #6E77C8
{ code: 'Dwc', name: 'Subarctic (monsoon)', color: [0.29, 0.31, 0.78] }, // #4A50C8
{ code: 'Dwd', name: 'Extremely cold subarctic (monsoon)', color: [0.20, 0.00, 0.53] }, // #320087
{ code: 'ET', name: 'Tundra', color: [0.70, 0.70, 0.70] }, // #B2B2B2
{ code: 'EF', name: 'Ice cap', color: [0.41, 0.41, 0.41] }, // #686868
];
// Lookup table: KOPPEN_CLASSES code → ID (built once at import time)
const CODE_TO_ID = {};
KOPPEN_CLASSES.forEach((c, i) => { CODE_TO_ID[c.code] = i; });
/**
* Classify each region into a Köppen climate type using the worldbuilding-
* pasta band-based methodology.
*
* @param {object} mesh - SphereMesh
* @param {Float32Array} r_elevation - per-region elevation (<=0 = ocean)
* @param {object} tempResult - { r_temperature_summer, r_temperature_winter } (0-1 → -45..+45 C)
* @param {object} precipResult - { r_precip_summer, r_precip_winter } (0-1 p95-normalized)
* @returns {Uint8Array} r_koppen - per-region class ID (index into KOPPEN_CLASSES)
*/
export function classifyKoppen(mesh, r_elevation, tempResult, precipResult) {
const n = mesh.numRegions;
const r_koppen = new Uint8Array(n);
const tSummer = tempResult.r_temperature_summer;
const tWinter = tempResult.r_temperature_winter;
const pSummer = precipResult.r_precip_summer;
const pWinter = precipResult.r_precip_winter;
for (let r = 0; r < n; r++) {
// ── Ocean ──
if (r_elevation[r] <= 0) {
r_koppen[r] = 0;
continue;
}
// ── Convert normalised values to physical units ──
// Ts/Tw are NH summer/winter proxies — NOT necessarily local warm/cold
const Ts = -45 + Math.max(0, Math.min(1, tSummer[r])) * 90;
const Tw = -45 + Math.max(0, Math.min(1, tWinter[r])) * 90;
const Thot = Math.max(Ts, Tw); // warmest month proxy (°C)
const Tcold = Math.min(Ts, Tw); // coldest month proxy (°C)
const Tann = (Ts + Tw) / 2;
// "Shoulder-month" temperature: approximate the temp 2 months before
// peak summer. With only 2 seasons we interpolate 2/6 of the way from
// peak toward cold. Used for the humid-continental / subarctic split
// and for the tempLetter 'b' criterion (4+ months >= 10°C).
const Tshoulder = Thot - (Thot - Tcold) * (2 / 6);
// ── Hemisphere-aware local seasons ──
// Determine which simulation season is this cell's LOCAL warm season.
// NH cells: sim summer = local summer. SH cells: sim winter = local summer.
const localSummerIsSim = Ts >= Tw;
// Precipitation: each season value ∈ [0,1] represents ~6 months.
// Scale to approximate mm for that half-year.
const Ps = Math.max(0, pSummer[r]) * 1000; // NH summer half-year mm
const Pw = Math.max(0, pWinter[r]) * 1000; // NH winter half-year mm
const Pann = Ps + Pw; // annual mm
// Local summer/winter precipitation (hemisphere-corrected)
const PsummerLocal = localSummerIsSim ? Ps : Pw;
const PwinterLocal = localSummerIsSim ? Pw : Ps;
const PsMonthLocal = PsummerLocal / 6; // avg monthly precip in local summer
const PwMonthLocal = PwinterLocal / 6; // avg monthly precip in local winter
// Estimate driest individual month from the 6-month average.
// A 6-month dry-season average of 40mm might contain months ranging from
// 10mm to 70mm. The stronger the seasonal contrast (wet vs dry half-year),
// the more peaked the distribution within each half-year, so the driest
// month is further below the half-year average.
// Factor: at equal seasons (ratio=1) → driest ≈ 0.7× average
// at strong monsoon (ratio=5+) → driest ≈ 0.35× average
const seasonRatio = Math.max(PsMonthLocal, PwMonthLocal) / (Math.min(PsMonthLocal, PwMonthLocal) || 1);
const driestFraction = 0.60 - 0.35 * smoothstep(1, 4, seasonRatio);
const Pdry = Math.min(PsMonthLocal, PwMonthLocal) * driestFraction;
// ================================================================
// STEP 1 – TEMPERATURE BANDS
// ================================================================
// Band codes: 'A' tropical, 'C' temperate, 'D' continental,
// 'ET' tundra, 'EF' ice cap
// Sub-bands for temperate: 'hotSummer' (>=22°C) vs 'coolSummer'
// Sub-bands for continental: 'humidCont' (Tshoulder>=10) vs 'subarctic'
let band;
let tempSubBand = ''; // 'hotSummer'|'coolSummer' for C; 'humidCont'|'subarctic' for D
if (Thot < 0) {
// Ice cap: warmest month < 0°C
band = 'EF';
} else if (Thot < 10) {
// Tundra: warmest month 0-10°C
band = 'ET';
} else if (Tcold >= 18) {
// Tropical: coldest month >= 18°C
band = 'A';
} else if (Tcold >= 0) {
// Temperate: coldest month 0-18°C AND warmest >= 10°C
band = 'C';
tempSubBand = Thot >= 22 ? 'hotSummer' : 'coolSummer';
} else {
// Continental: coldest month < 0°C AND warmest >= 10°C
band = 'D';
tempSubBand = Tshoulder >= 10 ? 'humidCont' : 'subarctic';
}
// ── Short-circuit polar types ──
if (band === 'EF') { r_koppen[r] = CODE_TO_ID['EF']; continue; }
if (band === 'ET') { r_koppen[r] = CODE_TO_ID['ET']; continue; }
// ================================================================
// STEP 2 – ARID ZONES (B)
// ================================================================
// The blog approach: areas "dry in both seasons" become desert by
// default, with steppe as a transition on the edges.
//
// We use the standard Köppen aridity threshold (which encodes the
// idea of evapotranspiration exceeding precipitation) to decide B,
// then split desert vs steppe.
//
// h/k is determined by mean annual temperature (standard Köppen):
// Tann >= 18°C → hot (h)
// Tann < 18°C → cold (k)
//
// summerFrac uses LOCAL warm-season precipitation (hemisphere-corrected)
// because the threshold encodes evapotranspiration which peaks in
// the warm season regardless of hemisphere.
let Pthresh;
const summerFrac = Pann > 0 ? PsummerLocal / Pann : 0.5;
if (summerFrac >= 0.7) {
Pthresh = 20 * Tann + 280;
} else if (summerFrac <= 0.3) {
Pthresh = 20 * Tann;
} else {
Pthresh = 20 * Tann + 140;
}
Pthresh = Math.max(0, Pthresh);
if (Pann < Pthresh) {
const isHot = Tann >= 18; // standard Köppen: h if mean annual temp >= 18°C
if (Pann < Pthresh * 0.5) {
// Desert
r_koppen[r] = isHot ? CODE_TO_ID['BWh'] : CODE_TO_ID['BWk'];
} else {
// Steppe (transition fringe)
r_koppen[r] = isHot ? CODE_TO_ID['BSh'] : CODE_TO_ID['BSk'];
}
continue;
}
// ================================================================
// STEP 3 – PRECIPITATION SUBTYPES WITHIN EACH BAND
// ================================================================
// ── Determine s / w / f precipitation pattern ──
// All comparisons use LOCAL summer/winter so the pattern is correct
// in both hemispheres.
// Our "monthly" values are 6-month averages, not individual months —
// this smooths the driest/wettest month contrast, so thresholds are
// relaxed vs. standard Köppen (which uses actual monthly extremes).
// s = dry local summer: summer month < 50mm AND < 1/2 winter month
// w = dry local winter: winter month < 1/4 summer month
// (relaxed from standard 1/10 because 6-month averages compress contrast)
// f = no dry season
let precipPattern;
const localSummerDrier = PsummerLocal < PwinterLocal;
if (localSummerDrier && PsMonthLocal < 50 && PsMonthLocal < PwMonthLocal / 2) {
precipPattern = 's';
} else if (!localSummerDrier && PwMonthLocal < PsMonthLocal / 3) {
precipPattern = 'w';
} else {
precipPattern = 'f';
}
// ── Determine temperature sub-letter (a / b / c / d) ──
// a: warmest month >= 22°C
// b: warmest < 22°C but 4+ months >= 10°C (proxy: Tshoulder >= 10°C)
// c: fewer than 4 months >= 10°C, coldest >= −38°C
// d: coldest < −38°C (extreme continental, only for D)
let tempLetter;
if (Thot >= 22) {
tempLetter = 'a';
} else if (Tshoulder >= 10) {
tempLetter = 'b';
} else if (Tcold >= -38) {
tempLetter = 'c';
} else {
tempLetter = 'd';
}
// ── Band A: Tropical ──
if (band === 'A') {
// Blog approach:
// very wet both seasons → Af (tropical rainforest)
// wet both seasons → Am (tropical monsoon)
// wet one season, dry other → Aw (tropical savanna)
//
// Translated with thresholds:
// Af: driest month >= 60 mm
// Am: Pann >= 25*(100 - Pdry) (i.e. enough total rain to sustain forest
// despite a short dry spell)
// Aw: everything else
if (Pdry >= 60) {
r_koppen[r] = CODE_TO_ID['Af'];
} else if (Pann >= 25 * (100 - Pdry)) {
r_koppen[r] = CODE_TO_ID['Am'];
} else {
r_koppen[r] = CODE_TO_ID['Aw'];
}
continue;
}
// ── Band C: Temperate ──
if (band === 'C') {
// Blog approach:
// dry local summer → Mediterranean (Cs)
// remaining hot-summer → humid subtropical (Cfa / Cwa)
// remaining cool-summer → oceanic (Cfb / Cwb / Cfc / Cwc)
const code = 'C' + precipPattern + tempLetter;
const id = CODE_TO_ID[code];
if (id !== undefined) {
r_koppen[r] = id;
} else {
r_koppen[r] = CODE_TO_ID['Cfb'];
}
continue;
}
// ── Band D: Continental ──
if (band === 'D') {
// Blog approach:
// humid continental (Tshoulder >= 10°C) = Dfa/Dfb/Dsa/Dsb/Dwa/Dwb
// subarctic (Tshoulder < 10°C) = Dfc/Dfd/Dsc/Dsd/Dwc/Dwd
//
// Ds zones appear near Mediterranean regions; Dw zones appear
// near regions with strong monsoon effect (far ITCZ excursion).
const code = 'D' + precipPattern + tempLetter;
const id = CODE_TO_ID[code];
if (id !== undefined) {
r_koppen[r] = id;
} else {
const fallback = 'Df' + tempLetter;
r_koppen[r] = CODE_TO_ID[fallback] || CODE_TO_ID['Dfc'];
}
continue;
}
}
return r_koppen;
}
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
// Ocean / land assignment.
// Targets ~30% land by surface area. numContinents controls how many
// separate landmasses to create. Small trapped interior seas are absorbed.
import { makeRng } from './rng.js';
export function assignOceanLand(mesh, r_plate, plateSeeds, r_xyz, seed, numContinents, continentSizeVariety = 0, landCoverage = 0.3) {
const rng = makeRng(seed + 42);
const numRegions = mesh.numRegions;
const plateIds = Array.from(plateSeeds);
const numPlates = plateIds.length;
const { adjOffset, adjList } = mesh;
// 1. Plate areas and centroids
const plateArea = {};
const plateCentroid = {};
for (const pid of plateIds) {
plateArea[pid] = 0;
plateCentroid[pid] = [0, 0, 0];
}
for (let r = 0; r < numRegions; r++) {
const p = r_plate[r];
if (!plateCentroid[p]) { plateArea[p] = 0; plateCentroid[p] = [0, 0, 0]; }
plateArea[p]++;
plateCentroid[p][0] += r_xyz[3*r];
plateCentroid[p][1] += r_xyz[3*r+1];
plateCentroid[p][2] += r_xyz[3*r+2];
}
for (const pid of plateIds) {
const a = plateArea[pid] || 1;
plateCentroid[pid][0] /= a;
plateCentroid[pid][1] /= a;
plateCentroid[pid][2] /= a;
}
// 2. Plate adjacency graph + perimeter
const plateAdj = {};
const platePerim = {};
for (const pid of plateIds) { plateAdj[pid] = new Set(); platePerim[pid] = 0; }
for (let r = 0; r < numRegions; r++) {
const myPlate = r_plate[r];
let isBoundary = false;
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
const nbPlate = r_plate[adjList[ni]];
if (myPlate !== nbPlate) {
plateAdj[myPlate].add(nbPlate);
isBoundary = true;
}
}
if (isBoundary) platePerim[myPlate]++;
}
// Plate compactness
const plateCompact = {};
let maxCompact = 0;
for (const pid of plateIds) {
const c = Math.sqrt(plateArea[pid] || 1) / (platePerim[pid] || 1);
plateCompact[pid] = c;
if (c > maxCompact) maxCompact = c;
}
if (maxCompact > 0) {
for (const pid of plateIds) plateCompact[pid] /= maxCompact;
}
const targetLandArea = landCoverage * numRegions;
// 3. Pick continent seeds via farthest-point sampling
const effectiveNum = Math.min(numContinents, numPlates);
const continentSeeds = [];
const chosen = new Set();
const first = plateIds[Math.floor(rng() * numPlates)];
continentSeeds.push(first);
chosen.add(first);
for (let s = 1; s < effectiveNum; s++) {
const candidates = [];
for (const pid of plateIds) {
if (chosen.has(pid)) continue;
const cx = plateCentroid[pid];
let minDist = Infinity;
for (const existing of continentSeeds) {
const ex = plateCentroid[existing];
const dx = cx[0]-ex[0], dy = cx[1]-ex[1], dz = cx[2]-ex[2];
const d = dx*dx + dy*dy + dz*dz;
if (d < minDist) minDist = d;
}
const rawAreaFactor = Math.sqrt(numRegions / numPlates) / Math.sqrt(plateArea[pid] || 1);
const areaFactor = 1 + (rawAreaFactor - 1) * (1 - continentSizeVariety * 0.5);
const compact = 0.3 + 0.7 * plateCompact[pid];
candidates.push({ pid, score: minDist * areaFactor * compact });
}
if (candidates.length === 0) break;
candidates.sort((a, b) => b.score - a.score);
const topK = Math.min(candidates.length, 3);
const pick = candidates[Math.floor(rng() * topK)];
continentSeeds.push(pick.pid);
chosen.add(pick.pid);
}
// If seeds alone exceed the land budget, trim the largest seeds
let seedArea = 0;
for (const pid of continentSeeds) seedArea += plateArea[pid];
while (continentSeeds.length > 1 && seedArea > targetLandArea) {
let maxIdx = 0;
for (let i = 1; i < continentSeeds.length; i++) {
if (plateArea[continentSeeds[i]] > plateArea[continentSeeds[maxIdx]]) maxIdx = i;
}
seedArea -= plateArea[continentSeeds[maxIdx]];
chosen.delete(continentSeeds[maxIdx]);
continentSeeds.splice(maxIdx, 1);
}
// 4. Initialize continent assignment
const plateContinent = {};
for (let c = 0; c < continentSeeds.length; c++) {
plateContinent[continentSeeds[c]] = c;
}
let landArea = seedArea;
// 5. Round-robin growth with per-continent targets
const growTarget = targetLandArea * 0.9;
const numC = continentSeeds.length;
// Per-continent growth targets: at variety=0 all equal, at variety=1 highly skewed
const continentTarget = new Float64Array(numC);
const continentArea = new Float64Array(numC);
for (let c = 0; c < numC; c++) {
continentArea[c] = plateArea[continentSeeds[c]];
}
if (continentSizeVariety > 0 && numC > 1) {
const weights = [];
for (let c = 0; c < numC; c++) {
// Log-normal-ish: at variety=1, weights span ~0.3x to ~3.5x (12:1 ratio)
const logWeight = (rng() - 0.5) * continentSizeVariety * 2.5;
weights.push(Math.exp(logWeight));
}
const totalWeight = weights.reduce((a, b) => a + b, 0);
for (let c = 0; c < numC; c++) {
continentTarget[c] = growTarget * weights[c] / totalWeight;
}
} else {
const equal = growTarget / Math.max(numC, 1);
for (let c = 0; c < numC; c++) continentTarget[c] = equal;
}
let progress = true;
while (progress && landArea < growTarget) {
progress = false;
for (let c = 0; c < numC && landArea < growTarget; c++) {
// Skip continents that have reached their individual target
if (continentArea[c] >= continentTarget[c]) continue;
const candidates = [];
for (const pid of plateIds) {
if (plateContinent[pid] !== undefined) continue;
let touchesSelf = false, touchesOther = false;
let sameCount = 0;
for (const adj of plateAdj[pid]) {
const ac = plateContinent[adj];
if (ac === c) { touchesSelf = true; sameCount++; }
else if (ac !== undefined) { touchesOther = true; break; }
}
if (touchesSelf && !touchesOther) {
candidates.push({ pid, score: sameCount + plateCompact[pid] * 3 + rng() * 0.5 });
}
}
if (candidates.length === 0) continue;
candidates.sort((a, b) => b.score - a.score);
const topK = Math.min(candidates.length, 3);
const pick = candidates[Math.floor(rng() * topK)];
plateContinent[pick.pid] = c;
continentArea[c] += plateArea[pick.pid];
landArea += plateArea[pick.pid];
progress = true;
}
}
// 6. Absorb trapped interior seas
const oceanComponents = [];
const visited = new Set();
for (const pid of plateIds) {
if (plateContinent[pid] !== undefined || visited.has(pid)) continue;
const component = [pid];
visited.add(pid);
for (let qi = 0; qi < component.length; qi++) {
for (const adj of plateAdj[component[qi]]) {
if (plateContinent[adj] === undefined && !visited.has(adj)) {
visited.add(adj);
component.push(adj);
}
}
}
oceanComponents.push(component);
}
let mainIdx = 0;
for (let i = 1; i < oceanComponents.length; i++) {
let areaI = 0, areaM = 0;
for (const p of oceanComponents[i]) areaI += plateArea[p];
for (const p of oceanComponents[mainIdx]) areaM += plateArea[p];
if (areaI > areaM) mainIdx = i;
}
const absorbCap = targetLandArea * 1.1;
for (let i = 0; i < oceanComponents.length; i++) {
if (i === mainIdx) continue;
const component = oceanComponents[i];
const bordering = new Set();
for (const op of component) {
for (const adj of plateAdj[op]) {
if (plateContinent[adj] !== undefined) bordering.add(plateContinent[adj]);
}
if (bordering.size > 1) break;
}
if (bordering.size === 1) {
let compArea = 0;
for (const op of component) compArea += plateArea[op];
if (landArea + compArea <= absorbCap) {
const c = bordering.values().next().value;
for (const op of component) plateContinent[op] = c;
landArea += compArea;
}
}
}
// 7. Build plateIsOcean set
const plateIsOcean = new Set();
for (const pid of plateIds) {
if (plateContinent[pid] === undefined) plateIsOcean.add(pid);
}
return plateIsOcean;
}
+389
View File
@@ -0,0 +1,389 @@
// Ocean current simulation: rule-based geographic approach with wind-belt-driven gyres.
// Wind belts drive zonal currents; continental shelves deflect them into gyres.
// Warmth is classified geographically: western coasts = warm, eastern coasts = cold.
console.log('[ocean.js] Module loaded');
import { smoothstep } from './wind.js';
import { makeItczLookup, percentile } from './climate-util.js';
const DEG = Math.PI / 180;
// ── Coast distance & classification via BFS ─────────────────────────────────
function computeCoastFields(mesh, r_xyz, r_isOcean,
r_eastX, r_eastY, r_eastZ) {
const { adjOffset, adjList, numRegions } = mesh;
const westSeeds = [];
const eastSeeds = [];
const allCoastSeeds = [];
for (let r = 0; r < numRegions; r++) {
if (!r_isOcean[r]) continue;
let landDirX = 0, landDirY = 0, landDirZ = 0;
let hasLandNeighbor = false;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (!r_isOcean[nb]) {
hasLandNeighbor = true;
landDirX += r_xyz[3 * nb] - r_xyz[3 * r];
landDirY += r_xyz[3 * nb + 1] - r_xyz[3 * r + 1];
landDirZ += r_xyz[3 * nb + 2] - r_xyz[3 * r + 2];
}
}
if (!hasLandNeighbor) continue;
allCoastSeeds.push(r);
// Project land direction into tangent frame east component
const normalE = landDirX * r_eastX[r] + landDirY * r_eastY[r] + landDirZ * r_eastZ[r];
// normalE < -0.2 → land is to the west → western coast seed
// normalE > +0.2 → land is to the east → eastern coast seed
if (normalE < -0.2) {
westSeeds.push(r);
} else if (normalE > 0.2) {
eastSeeds.push(r);
} else {
if (normalE <= 0) westSeeds.push(r);
else eastSeeds.push(r);
}
}
// BFS: compute hop distance from seed set through ocean cells.
// Reuses a single queue array (capacity allocated once) across all three passes.
const bfsQueue = new Int32Array(numRegions);
function bfsDistance(seeds) {
const dist = new Int32Array(numRegions);
dist.fill(-1);
let qLen = 0;
for (const s of seeds) {
dist[s] = 0;
bfsQueue[qLen++] = s;
}
let head = 0;
while (head < qLen) {
const r = bfsQueue[head++];
const d = dist[r] + 1;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (r_isOcean[nb] && dist[nb] === -1) {
dist[nb] = d;
bfsQueue[qLen++] = nb;
}
}
}
return dist;
}
const r_coastDist = bfsDistance(allCoastSeeds);
const r_westCoastDist = bfsDistance(westSeeds);
const r_eastCoastDist = bfsDistance(eastSeeds);
return { r_coastDist, r_westCoastDist, r_eastCoastDist };
}
// ── Circumpolar channel detection ───────────────────────────────────────────
function hasCircumpolarChannel(r_lat, r_lon, r_isOcean, numRegions, targetLat, bandWidth) {
const NUM_BINS = 72;
const binHasOcean = new Uint8Array(NUM_BINS);
const latMin = targetLat - bandWidth;
const latMax = targetLat + bandWidth;
for (let r = 0; r < numRegions; r++) {
if (!r_isOcean[r]) continue;
const lat = r_lat[r];
if (lat < latMin || lat > latMax) continue;
let bin = Math.floor(((r_lon[r] + Math.PI) / (2 * Math.PI)) * NUM_BINS);
bin = ((bin % NUM_BINS) + NUM_BINS) % NUM_BINS;
binHasOcean[bin] = 1;
}
for (let i = 0; i < NUM_BINS; i++) {
if (!binHasOcean[i]) return false;
}
return true;
}
// ── Geographic heat classification ──────────────────────────────────────────
// Warmth is determined by coast type and wind cell. The prevailing wind
// direction determines which side of a basin accumulates warm water:
// Hadley cell (trades westward): western=warm, eastern=cold
// Ferrel cell (westerlies eastward): western=cold, eastern=warm (flipped)
// Polar cell (easterlies westward): western=warm, eastern=cold (flipped back)
function classifyWarmth(r_isOcean, r_lat, numRegions,
r_westCoastDist, r_eastCoastDist, fadeRange, seasonalShiftDeg) {
const r_warmth = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
if (!r_isOcean[r]) continue;
// Shifted latitude for cell boundaries (matches wind band shift)
const bandLatDeg = Math.abs(r_lat[r] / DEG - seasonalShiftDeg);
// Wind cell sign: trades/polar push water west (western=warm → +1),
// westerlies push water east (western=cold → -1)
let cellSign;
if (bandLatDeg < 28) {
cellSign = 1;
} else if (bandLatDeg < 35) {
cellSign = 1 - 2 * smoothstep(28, 35, bandLatDeg);
} else if (bandLatDeg < 55) {
cellSign = -1;
} else if (bandLatDeg < 65) {
cellSign = -1 + 2 * smoothstep(55, 65, bandLatDeg);
} else {
cellSign = 1;
}
const wDist = r_westCoastDist[r];
const eDist = r_eastCoastDist[r];
let warm = 0;
if (wDist >= 0 && wDist < fadeRange) {
const t = 1 - wDist / fadeRange;
warm += cellSign * t * t;
}
if (eDist >= 0 && eDist < fadeRange) {
const t = 1 - eDist / fadeRange;
warm -= cellSign * t * t;
}
r_warmth[r] = Math.max(-1, Math.min(1, warm));
}
return r_warmth;
}
// ── Laplacian smoothing (ocean only) ────────────────────────────────────────
function smoothOcean(mesh, field, r_isOcean, passes) {
const { adjOffset, adjList, numRegions } = mesh;
const tmp = new Float32Array(numRegions);
for (let pass = 0; pass < passes; pass++) {
for (let r = 0; r < numRegions; r++) {
if (!r_isOcean[r]) { tmp[r] = field[r]; continue; }
let sum = field[r], count = 1;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (r_isOcean[nb]) {
sum += field[nb];
count++;
}
}
tmp[r] = sum / count;
}
field.set(tmp);
}
}
// ── Main entry point ────────────────────────────────────────────────────────
/**
* Compute ocean surface currents using rule-based geographic approach.
* Wind belts drive zonal currents, continental shelves deflect them into
* gyres. Warmth is classified geographically by coast type.
*
* @param {SphereMesh} mesh
* @param {Float32Array} r_xyz - per-region 3D positions
* @param {Float32Array} r_elevation - per-region elevation
* @param {object} windResult - output from computeWind() (includes lat, lon, sinLat, isLand, tangent frames, ITCZ arrays)
* @returns {object} current vectors, warmth, and speed arrays for both seasons
*/
export function computeOceanCurrents(mesh, r_xyz, r_elevation, windResult) {
console.log('[ocean.js] computeOceanCurrents called, numRegions:', mesh.numRegions);
const numRegions = mesh.numRegions;
const avgEdgeKm = (Math.PI * 6371) / Math.sqrt(numRegions);
const timing = [];
const { r_lat, r_sinLat, r_isLand,
r_eastX, r_eastY, r_eastZ,
r_northX, r_northY, r_northZ } = windResult;
// Ocean mask
const r_isOcean = new Uint8Array(numRegions);
for (let r = 0; r < numRegions; r++) r_isOcean[r] = r_isLand[r] ? 0 : 1;
// Step 0: Setup — r_lon and ITCZ lookups
let t0 = performance.now();
let r_lon = windResult.r_lon;
if (!r_lon) {
r_lon = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
r_lon[r] = Math.atan2(r_xyz[3 * r], r_xyz[3 * r + 2]);
}
}
const itczLookupSummer = makeItczLookup(windResult.itczLons, windResult.itczLatsSummer);
const itczLookupWinter = makeItczLookup(windResult.itczLons, windResult.itczLatsWinter);
timing.push({ stage: 'Ocean: setup (ITCZ lookup + lon)', ms: performance.now() - t0 });
// Step 1: Coast distance & classification (shared between seasons)
t0 = performance.now();
const { r_coastDist, r_westCoastDist, r_eastCoastDist } =
computeCoastFields(mesh, r_xyz, r_isOcean,
r_eastX, r_eastY, r_eastZ);
timing.push({ stage: 'Ocean: coast BFS (3 passes)', ms: performance.now() - t0 });
// Step 2: Circumpolar channel detection
t0 = performance.now();
const circumpolarNH = hasCircumpolarChannel(r_lat, r_lon, r_isOcean, numRegions, 60 * DEG, 5 * DEG);
const circumpolarSH = hasCircumpolarChannel(r_lat, r_lon, r_isOcean, numRegions, -60 * DEG, 5 * DEG);
console.log(`[ocean.js] Circumpolar: NH=${circumpolarNH}, SH=${circumpolarSH}`);
timing.push({ stage: 'Ocean: circumpolar detection', ms: performance.now() - t0 });
// Coast influence threshold
const coastThreshold = Math.max(5, Math.round(Math.sqrt(numRegions) * 0.035));
// Warmth fade range — extends beyond coast deflection zone
const warmthRange = coastThreshold * 2;
const result = {};
const seasons = [
{ name: 'summer', itczLookup: itczLookupSummer },
{ name: 'winter', itczLookup: itczLookupWinter }
];
for (const { name, itczLookup } of seasons) {
// Seasonal shift: wind cells migrate ~5° toward summer hemisphere
const seasonalShiftDeg = name === 'summer' ? 5 : -5;
// Steps 3–4: Wind band classification + current vectors
t0 = performance.now();
const currentE = new Float32Array(numRegions);
const currentN = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
if (!r_isOcean[r]) continue;
const lat = r_lat[r];
const absLatDeg = Math.abs(lat) / DEG;
const lon = r_lon[r];
const hemisphereSign = lat >= 0 ? 1 : -1;
// Shifted latitude for wind band boundaries (cells migrate with season)
const bandLatDeg = Math.abs(lat / DEG - seasonalShiftDeg);
// ITCZ latitude at this longitude
const itczLat = itczLookup(lon);
const distFromItcz = Math.abs(lat - itczLat) / DEG;
// Step 3: Base zonal flow from wind band (using shifted boundaries)
let baseE;
if (distFromItcz < 3) {
// ITCZ zone: eastward countercurrent at center, blends to westward at edges
baseE = 1 - 2 * smoothstep(0, 3, distFromItcz);
} else if (bandLatDeg < 30) {
// Trade winds: westward
baseE = -1;
} else if (bandLatDeg < 35) {
// Subtropical transition: blend trades → westerlies
baseE = -1 + 2 * smoothstep(30, 35, bandLatDeg);
} else if (bandLatDeg < 58) {
// Ferrel cell / westerlies: eastward
baseE = 1;
} else if (bandLatDeg < 65) {
// Subpolar transition: blend westerlies → polar easterlies
baseE = 1 - 1.5 * smoothstep(58, 65, bandLatDeg);
} else {
// Polar easterlies: weak westward
baseE = -0.5;
}
currentE[r] = baseE;
currentN[r] = 0;
// Step 4: Coast deflection
const wDist = r_westCoastDist[r];
const eDist = r_eastCoastDist[r];
// Near western coast: strong poleward deflection (warm current)
if (wDist >= 0 && wDist < coastThreshold) {
const t = 1 - wDist / coastThreshold;
const strength = t * t * 2.0; // western intensification ×2
currentN[r] += hemisphereSign * strength; // poleward
currentE[r] *= (1 - t * t * 0.7);
}
// Near eastern coast: moderate equatorward deflection (cold current)
if (eDist >= 0 && eDist < coastThreshold) {
const t = 1 - eDist / coastThreshold;
const strength = t * t * 0.8; // eastern weaker ×0.8
currentN[r] -= hemisphereSign * strength; // equatorward
currentE[r] *= (1 - t * t * 0.5);
}
// Circumpolar override (55–75° with open channel)
const isCircumpolar = (lat > 0 && circumpolarNH) || (lat < 0 && circumpolarSH);
if (isCircumpolar && absLatDeg >= 55 && absLatDeg <= 75) {
const cStrength = 1 - Math.abs(absLatDeg - 65) / 10;
currentE[r] = currentE[r] * (1 - cStrength) + 1.5 * cStrength;
currentN[r] *= (1 - cStrength * 0.8);
}
}
timing.push({ stage: `Ocean: wind bands + vectors (${name})`, ms: performance.now() - t0 });
// Step 5: Smooth ~125 km (scale-invariant)
t0 = performance.now();
const oceanSmoothPasses = Math.max(2, Math.round(125 / avgEdgeKm));
smoothOcean(mesh, currentE, r_isOcean, oceanSmoothPasses);
smoothOcean(mesh, currentN, r_isOcean, oceanSmoothPasses);
// Zero out land
for (let r = 0; r < numRegions; r++) {
if (!r_isOcean[r]) { currentE[r] = 0; currentN[r] = 0; }
}
timing.push({ stage: `Ocean: smoothing (${name})`, ms: performance.now() - t0 });
// Step 6: Geographic warmth classification (coast type, not flow direction)
// Smoothed heavily to blend out jagged coastline noise and dilute
// small island contributions (few coast cells → weak signal after smoothing).
t0 = performance.now();
const r_warmth = classifyWarmth(r_isOcean, r_lat, numRegions,
r_westCoastDist, r_eastCoastDist, warmthRange, seasonalShiftDeg);
const warmthSmoothPasses = Math.max(3, Math.round(900 / avgEdgeKm));
smoothOcean(mesh, r_warmth, r_isOcean, warmthSmoothPasses);
// Step 7: Normalize speed (95th percentile)
// Use speed-squared to avoid sqrt in the hot loop; sqrt is monotonic
// so percentile on squared values gives the same ranking.
const r_speed = new Float32Array(numRegions);
const oceanSpeedsSq = new Float32Array(numRegions);
let oceanCount = 0;
for (let r = 0; r < numRegions; r++) {
const spdSq = currentE[r] * currentE[r] + currentN[r] * currentN[r];
r_speed[r] = spdSq;
if (r_isOcean[r] && spdSq > 0) oceanSpeedsSq[oceanCount++] = spdSq;
}
const p95Sq = percentile(oceanSpeedsSq.subarray(0, oceanCount), 0.95);
// Now convert to linear 0-1: speed/p95 = sqrt(spdSq)/sqrt(p95Sq) = sqrt(spdSq/p95Sq)
const invP95Sq = 1 / p95Sq;
for (let r = 0; r < numRegions; r++) {
r_speed[r] = Math.min(1, Math.sqrt(r_speed[r] * invP95Sq));
}
console.log(`[Ocean ${name}] coastThreshold=${coastThreshold}, warmthRange=${warmthRange}, p95Sq=${p95Sq.toExponential(3)}, oceanCells=${oceanCount}`);
timing.push({ stage: `Ocean: warmth + normalize (${name})`, ms: performance.now() - t0 });
result[`r_ocean_current_east_${name}`] = currentE;
result[`r_ocean_current_north_${name}`] = currentN;
result[`r_ocean_speed_${name}`] = r_speed;
result[`r_ocean_warmth_${name}`] = r_warmth;
}
result._oceanTiming = timing;
return result;
}
+176
View File
@@ -0,0 +1,176 @@
// Colours and legends for the painted-map layers: the class map, the uplift rate, the
// erodibility, the drainage (log area), the slope and the drainage basins.
//
// Shared by planet-mesh.js (globe, map and export colouring) and import-main.js (the sidebar
// legend), so the picture on the globe and the one in the exported PNG are the same picture.
export const PAINTED_LAYERS = new Set(['paintClass', 'paintUplift', 'paintK', 'flow', 'slope', 'basins', 'paintOverlay']);
// Export type → debug layer it draws.
export const PAINTED_EXPORT_TYPES = {
paintclass: 'paintClass',
uplift: 'paintUplift',
erodibility: 'paintK',
flow: 'flow',
slope: 'slope',
basins: 'basins',
overlay: 'paintOverlay',
};
export const PAINTED_EXPORT_LABELS = {
paintclass: 'Class Map',
uplift: 'Uplift Rate',
erodibility: 'Erodibility',
flow: 'Drainage',
slope: 'Slope',
basins: 'Basins',
overlay: 'Overlay',
};
const SEA_DARK = [0.05, 0.07, 0.12];
const SEA_GREY = [0.16, 0.18, 0.24];
function lerp3(a, b, t) {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function ramp(stops, t) {
if (t <= 0) return stops[0];
if (t >= 1) return stops[stops.length - 1];
const x = t * (stops.length - 1);
const i = Math.floor(x);
return lerp3(stops[i], stops[Math.min(stops.length - 1, i + 1)], x - i);
}
// Uplift: dark violet → wine → orange → pale yellow. Sea is dark.
const UPLIFT_STOPS = [[0.09, 0.04, 0.20], [0.42, 0.08, 0.36], [0.78, 0.25, 0.22], [0.98, 0.62, 0.12], [1.00, 0.95, 0.65]];
// Erodibility: hard rock blue → neutral grey → soft rock orange.
const K_STOPS = [[0.20, 0.35, 0.80], [0.55, 0.57, 0.62], [0.95, 0.55, 0.15]];
// Drainage: dry ground olive → light blue → white at the trunk rivers.
const FLOW_STOPS = [[0.26, 0.28, 0.16], [0.30, 0.40, 0.30], [0.35, 0.62, 0.85], [0.75, 0.90, 1.00], [1.00, 1.00, 1.00]];
// Slope: white → yellow → red → near-black.
const SLOPE_STOPS = [[0.96, 0.96, 0.94], [0.98, 0.85, 0.30], [0.90, 0.30, 0.10], [0.25, 0.05, 0.05]];
function basinColor(id) {
// Golden-ratio hue per basin, moderate saturation so neighbours differ without shouting.
const hue = ((id * 0.6180339887) % 1 + 1) % 1;
const s = 0.55, l = 0.55;
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const f = (t) => {
t = ((t % 1) + 1) % 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
return [f(hue + 1 / 3), f(hue), f(hue - 1 / 3)];
}
/**
* Prepare a colour function for one painted layer. Returns null when the data is missing.
* The returned object has `color(regionIndex)` and the numbers the sidebar legend prints.
*/
export function preparePaintedLayer(layer, arr, curData) {
if (!arr) return null;
const painted = curData && curData.painted;
const N = arr.length;
switch (layer) {
case 'paintClass': {
const cls = painted && painted.legend && painted.legend.classes;
if (!cls) return null;
const table = cls.map(c => [c.rgb[0] / 255, c.rgb[1] / 255, c.rgb[2] / 255]);
return { color: r => table[arr[r]] || SEA_GREY, classes: cls };
}
case 'paintUplift': {
let max = 0;
for (let r = 0; r < N; r++) if (arr[r] > max) max = arr[r];
const inv = max > 0 ? 1 / max : 0;
return { color: r => arr[r] < 0 ? SEA_DARK : ramp(UPLIFT_STOPS, arr[r] * inv), max };
}
case 'paintK': {
let lo = Infinity, hi = -Infinity;
for (let r = 0; r < N; r++) { const v = arr[r]; if (v < 0) continue; if (v < lo) lo = v; if (v > hi) hi = v; }
if (!(hi > lo)) { lo = 0.5; hi = 1.5; }
// Centre the ramp on k = 1 so "harder than average" and "softer" read as colours.
const span = Math.max(hi - 1, 1 - lo, 1e-6);
return { color: r => arr[r] < 0 ? SEA_GREY : ramp(K_STOPS, 0.5 + (arr[r] - 1) / (2 * span)), lo, hi };
}
case 'flow': {
let max = 0;
for (let r = 0; r < N; r++) if (arr[r] > max) max = arr[r];
const inv = max > 0 ? 1 / max : 0;
return { color: r => { const v = arr[r]; if (v < 0) return SEA_DARK; const t = v * inv; return ramp(FLOW_STOPS, t * t); }, max };
}
case 'slope': {
const cap = 30;
return { color: r => arr[r] < 0 ? SEA_GREY : ramp(SLOPE_STOPS, Math.min(1, arr[r] / cap)), cap };
}
case 'basins':
return { color: r => arr[r] < 0 ? SEA_DARK : basinColor(arr[r]) };
case 'paintOverlay': {
// The class map halved towards black, so a full-strength mark drawn over it cannot be mistaken
// for the ground - the Go tool's map_overlay.png, drawn the same way. The sheet itself is a
// texture (painted-overlay-view.js), not a region colour.
const cls = painted && painted.legend && painted.legend.classes;
if (!cls) return null;
const table = cls.map(c => [c.rgb[0] / 510, c.rgb[1] / 510, c.rgb[2] / 510]);
return { color: r => table[arr[r]] || SEA_DARK, classes: cls };
}
default:
return null;
}
}
function css(c) { return `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`; }
function gradientHTML(stops, labels) {
const pcts = stops.map((_, i) => Math.round(i / (stops.length - 1) * 100));
const grad = stops.map((c, i) => `${css(c)} ${pcts[i]}%`).join(', ');
return `<div class="legend-gradient" style="background:linear-gradient(to right,${grad})"></div>` +
`<div class="legend-labels">${labels.map(l => `<span>${l}</span>`).join('')}</div>`;
}
/** Sidebar legend HTML for a painted layer, or '' when the layer has no data. `overlay` is state.overlay. */
export function paintedLegendHTML(layer, curData, overlay) {
const d = curData;
if (!d || !d.debugLayers) return '';
const prep = preparePaintedLayer(layer, d.debugLayers[layer], d);
if (!prep) return '';
switch (layer) {
case 'paintClass': {
let html = '<div class="legend-classes">';
for (const c of prep.classes) {
if (c.derived && !c.used) continue;
const what = c.sea ? `${c.depthM} m deep` : `${c.upliftMmYr} mm/yr`;
html += `<div class="legend-class"><span class="legend-koppen-swatch" style="background:rgb(${c.rgb.join(',')})"></span>${c.name} <span class="legend-class-num">${what}</span></div>`;
}
return html + '</div>';
}
case 'paintUplift':
return gradientHTML(UPLIFT_STOPS, ['0', 'Uplift, mm/yr', prep.max.toFixed(2)]);
case 'paintK':
return gradientHTML(K_STOPS, ['Hard rock', 'k = 1', 'Soft rock']);
case 'flow':
return gradientHTML(FLOW_STOPS, ['Hillslope', 'Drainage area', 'Trunk river']);
case 'slope':
return gradientHTML(SLOPE_STOPS, ['0°', 'Slope', `${prep.cap}°+`]);
case 'basins':
return '<div class="legend-labels"><span>One colour per drainage basin, by river mouth</span></div>';
case 'paintOverlay': {
if (!overlay || !overlay.legend) return '<div class="legend-labels"><span>No overlay loaded</span></div>';
const rep = overlay.report;
let html = '<div class="legend-classes">';
for (const m of overlay.legend.marks) {
const share = rep ? 100 * rep.counts[m.index] / rep.total : 0;
let what = share > 0 ? share.toFixed(2) + ' %' : 'none';
if (m.coastJitter !== null) what += m.coastJitter === 0 ? ', coast pinned' : `, coast \u00d7${m.coastJitter}`;
else if (m.kind === 'path') what += ', path';
html += `<div class="legend-class"><span class="legend-koppen-swatch" style="background:rgb(${m.rgb.join(',')})"></span>${m.name} <span class="legend-class-num">${what}</span></div>`;
}
return html + '</div><div class="legend-labels"><span>Marks over the dimmed class map</span></div>';
}
default:
return '';
}
}
+204
View File
@@ -0,0 +1,204 @@
// Showing the overlay: the sheet as a texture draped over the globe and laid over the map.
//
// A mark is not voted onto the mesh for display, deliberately. The sheet is painted at the template's
// resolution - a road is eight pixels wide, a village a few hundred - and a region on this mesh covers
// roughly eight pixels each way, so a per-region colour would turn every road into a chain of blobs and lose
// the thin strokes entirely. A texture keeps every stroke exactly as painted, which is what an author wants
// to check: does the road follow the valley the solve made, is the town on the coast it was drawn against.
//
// On the globe the texture rides on the planet mesh's own triangles, with longitude and latitude as UVs, so
// it follows the relief and is never a shell floating over the mountains. On the map it is one flat quad
// over the map mesh, shifted with the centre-longitude slider through the texture offset rather than by
// moving the quad, so the seam wraps for free. The class map underneath is dimmed by the Overlay layer
// (painted-layers.js) for the same reason the Go tool's map_overlay.png halves the class colours: a
// full-strength mark on top of it cannot be mistaken for the ground.
import * as THREE from 'three';
import { scene } from './scene.js';
import { state } from './state.js';
// The largest sheet uploaded to the GPU. A 7738-wide template is 120 MB as RGBA; halved it is 30 and every
// stroke survives, because a block keeps the most common mark in it rather than the top-left pixel.
const SHEET_MAX_W = 4096;
const MAP_CLIP_PLANES = [
new THREE.Plane(new THREE.Vector3(1, 0, 0), 2),
new THREE.Plane(new THREE.Vector3(-1, 0, 0), 2),
];
/**
* Draw the mark raster as an RGBA canvas no wider than maxW: each mark in its legend colour, blank
* transparent. Downsampling is by block vote over the non-blank marks, so a stroke thinner than the block
* still shows.
*/
export function buildSheetCanvas(overlay, maxW = SHEET_MAX_W) {
const { raster, w, h, legend } = overlay;
const f = Math.max(1, Math.ceil(w / maxW));
const ow = Math.ceil(w / f), oh = Math.ceil(h / f);
const cvs = document.createElement('canvas');
cvs.width = ow; cvs.height = oh;
const ctx = cvs.getContext('2d');
const img = ctx.createImageData(ow, oh);
const px = img.data;
const marks = legend.marks;
const counts = new Int32Array(marks.length + 1);
for (let oy = 0; oy < oh; oy++) {
const y0 = oy * f, y1 = Math.min(h, y0 + f);
for (let ox = 0; ox < ow; ox++) {
let best = 0;
if (f === 1) {
best = raster[y0 * w + ox];
} else {
const x0 = ox * f, x1 = Math.min(w, x0 + f);
counts.fill(0);
for (let y = y0; y < y1; y++) {
const row = y * w;
for (let x = x0; x < x1; x++) { const m = raster[row + x]; if (m) counts[m]++; }
}
let bc = 0;
for (let m = 1; m < counts.length; m++) if (counts[m] > bc) { bc = counts[m]; best = m; }
}
if (!best) continue;
const rgb = marks[best - 1].rgb;
const o = (oy * ow + ox) * 4;
px[o] = rgb[0]; px[o + 1] = rgb[1]; px[o + 2] = rgb[2]; px[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
return cvs;
}
function makeTexture(cvs) {
const tex = new THREE.CanvasTexture(cvs);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.LinearMipmapLinearFilter;
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
function disposeOverlayMeshes() {
for (const key of ['overlayGlobeMesh', 'overlayMapMesh']) {
const m = state[key];
if (!m) continue;
scene.remove(m);
m.geometry.dispose();
if (m.material.map && m.material.map !== (state.overlay && state.overlay.texture)) m.material.map.dispose();
m.material.dispose();
state[key] = null;
}
}
/**
* Install a classified overlay ({ legend, raster, w, h, report, name }) as the current sheet, or null to
* remove it. Builds the texture once; the meshes follow the terrain meshes and are rebuilt with them.
*/
export function setOverlaySheet(overlay) {
disposeOverlayMeshes();
if (state.overlay && state.overlay.texture) state.overlay.texture.dispose();
if (!overlay) { state.overlay = null; return; }
overlay.sheet = buildSheetCanvas(overlay);
overlay.texture = makeTexture(overlay.sheet);
state.overlay = overlay;
updateOverlayMeshes();
}
/** Whether the sheet should be on screen: the toggle, or the Overlay layer, which always shows it. */
export function overlayWanted() {
return !!(state.overlay && state.overlay.texture) && (!!state.overlayVisible || state.debugLayer === 'paintOverlay');
}
/** Show or hide the sheet meshes for the current view without rebuilding them. */
export function setOverlayVisible() {
const on = overlayWanted();
if (state.overlayGlobeMesh) state.overlayGlobeMesh.visible = on && !state.mapMode;
if (state.overlayMapMesh) state.overlayMapMesh.visible = on && state.mapMode;
}
/** Follow the centre-longitude slider while it is being dragged: the map mesh moves, the sheet's UVs shift. */
export function syncOverlayMapCenter() {
const m = state.overlayMapMesh;
if (!m || !m.material.map) return;
m.material.map.offset.x = (state.mapCenterLon || 0) / (2 * Math.PI);
}
/**
* Rebuild the sheet meshes over whatever terrain meshes exist now. Called at the end of buildMesh and
* buildMapMesh in planet-mesh.js, so a rebuilt globe never keeps a stale sheet.
*/
export function updateOverlayMeshes() {
disposeOverlayMeshes();
const ov = state.overlay;
if (!ov || !ov.texture) return;
if (state.planetMesh) {
// The planet mesh's own triangles, unindexed, so each vertex is one triangle's and a triangle across
// the seam can have its U unwrapped past 1 without touching its neighbours.
const pos = state.planetMesh.geometry.getAttribute('position');
const n = pos.count;
const uv = new Float32Array(n * 2);
for (let i = 0; i < n; i += 3) {
let umin = 2, umax = -1;
for (let k = 0; k < 3; k++) {
const x = pos.getX(i + k), y = pos.getY(i + k), z = pos.getZ(i + k);
const len = Math.hypot(x, y, z) || 1;
const lon = Math.atan2(x, z);
const lat = Math.asin(Math.max(-1, Math.min(1, y / len)));
const u = (lon / Math.PI + 1) * 0.5;
uv[(i + k) * 2] = u;
uv[(i + k) * 2 + 1] = 0.5 + lat / Math.PI;
if (u < umin) umin = u;
if (u > umax) umax = u;
}
if (umax - umin > 0.5) {
for (let k = 0; k < 3; k++) { const j = (i + k) * 2; if (uv[j] < 0.5) uv[j] += 1; }
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', pos);
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
const mat = new THREE.MeshBasicMaterial({
map: ov.texture, transparent: true, depthWrite: false,
polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.renderOrder = 2;
scene.add(mesh);
state.overlayGlobeMesh = mesh;
}
if (state.mapMesh) {
const geo = new THREE.PlaneGeometry(4, 2);
// Its own texture object over the same canvas, because the offset is per texture and the globe's must
// stay at zero.
const tex = makeTexture(ov.sheet);
const mat = new THREE.MeshBasicMaterial({
map: tex, transparent: true, depthWrite: false, side: THREE.DoubleSide,
clippingPlanes: MAP_CLIP_PLANES,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.z = 0.0035;
mesh.renderOrder = 2;
scene.add(mesh);
state.overlayMapMesh = mesh;
syncOverlayMapCenter();
}
setOverlayVisible();
}
/**
* Composite the sheet over an exported map canvas of width x height, 1:1 where the sheet is at least that
* wide and by nearest upscaling where it is not, so marks stay crisp.
*/
export function compositeOverlaySheet(ctx, width, height) {
const ov = state.overlay;
if (!ov || !ov.raster) return;
const sheet = width >= ov.w ? buildSheetCanvas(ov, ov.w) : buildSheetCanvas(ov, width);
const prev = ctx.imageSmoothingEnabled;
ctx.imageSmoothingEnabled = sheet.width === width;
ctx.drawImage(sheet, 0, 0, width, height);
ctx.imageSmoothingEnabled = prev;
sheet.width = 0; sheet.height = 0;
}
+230
View File
@@ -0,0 +1,230 @@
// The overlay: the second painting beside a class template, ported from Tools/Terrain internal/overlay.
//
// The class legend answers "what is the rock doing here" and every colour on it changes the terrain. That is
// the wrong place to say "a forest grows here", "this is the village", "a road follows this valley" or "leave
// this stretch of coast exactly as I drew it". So there is a second image, the same size as the template and
// registered to it, whose colours are *marks* rather than classes, with a legend of its own. Two rules are
// the whole design, and both are kept here exactly as the Go tool has them:
//
// - **A mark that no pass reads still travels.** Forests, settlements and roads change no height anywhere;
// they are shown over the terrain and reported, and that is all.
// - **A mark that a pass does read changes one number.** `coast_jitter` scales how far the waterline
// roughening may move the shore inside the mark: 0 pins a hand-drawn coastline exactly as painted, above
// 1 chews it harder than the rest of the world. It is the only mark property any pass reads.
//
// Blank is decided by alpha, never by a colour: an unpainted pixel is transparent, so no colour is spent on
// emptiness and an export with a white matte behind it does not turn the world into whatever mark white is
// nearest. An opaque pixel further than `match_distance` from every mark is dropped and counted - the rule
// the class legend has inverted, because there every pixel must become something and here most of the sheet
// is nothing.
//
// Pure computation, no DOM: the classifier runs on the main thread so the report is on screen before a
// solve, and the region sampling runs inside planet-worker.js.
export const OVERLAY_DEFAULT_MATCH_DISTANCE = 40;
export const OVERLAY_DEFAULT_MIN_AREA_PX = 24;
export const KIND_AREA = 'area';
export const KIND_PATH = 'path';
/** Parse an overlay legend JSON object (the same schema Tools/Terrain reads). Throws with a readable message. */
export function parseOverlayLegend(obj) {
if (!obj || typeof obj !== 'object') throw new Error('The overlay legend is not a JSON object');
if (!Array.isArray(obj.marks)) throw new Error('The overlay legend has no "marks" array');
if (obj.marks.length > 254) throw new Error(`The overlay has ${obj.marks.length} marks; the raster holds 254 plus blank`);
const seenName = new Map(), seenRGB = new Map();
const marks = obj.marks.map((m, i) => {
if (!m || typeof m !== 'object') throw new Error(`Mark ${i} is not an object`);
if (typeof m.name !== 'string' || !m.name) throw new Error(`Mark ${i} has no name`);
if (seenName.has(m.name)) throw new Error(`Marks ${seenName.get(m.name)} and ${i} are both named "${m.name}"`);
seenName.set(m.name, i);
if (!Array.isArray(m.rgb) || m.rgb.length !== 3) throw new Error(`Mark "${m.name}" has no rgb`);
const rgb = m.rgb.map(v => {
const n = Math.round(+v);
if (!(n >= 0 && n <= 255)) throw new Error(`Mark "${m.name}": rgb ${v} is outside 0..255`);
return n;
});
const key = rgb.join(',');
if (seenRGB.has(key)) throw new Error(`Marks "${seenRGB.get(key)}" and "${m.name}" share the colour ${key}; nothing could tell them apart`);
seenRGB.set(key, m.name);
let kind = m.kind || KIND_AREA;
if (kind !== KIND_AREA && kind !== KIND_PATH) throw new Error(`Mark "${m.name}": kind "${kind}" is neither "area" nor "path"`);
let coastJitter = null;
if (m.coast_jitter !== undefined && m.coast_jitter !== null) {
coastJitter = +m.coast_jitter;
if (!(coastJitter >= 0)) throw new Error(`Mark "${m.name}": coast_jitter is ${m.coast_jitter}; it is a multiplier on how far the waterline may move, so it is never negative`);
}
const widthM = +m.width_m || 0;
if (widthM < 0) throw new Error(`Mark "${m.name}": width_m is ${m.width_m}`);
return {
index: i + 1, // raster index; 0 is blank
name: m.name, rgb, kind, coastJitter, widthM,
minAreaPx: +m.min_area_px > 0 ? +m.min_area_px : 0,
note: typeof m.note === 'string' ? m.note : '',
};
});
return {
image: typeof obj.image === 'string' ? obj.image : '',
matchDistance: +obj.match_distance > 0 ? +obj.match_distance : OVERLAY_DEFAULT_MATCH_DISTANCE,
minAreaPx: +obj.min_area_px > 0 ? +obj.min_area_px : OVERLAY_DEFAULT_MIN_AREA_PX,
marks,
source: obj,
};
}
/** Whether any mark asks anything of the coast, which is the only reason a solve has to know about the sheet. */
export function overlayTouchesCoast(legend) {
return legend.marks.some(m => m.coastJitter !== null);
}
/**
* Assign every pixel of an RGBA sheet to a mark, or to blank (0). Same rule as the Go classifier: alpha below
* half is unpainted; otherwise the nearest mark wins if it is within the tolerance, and a colour further than
* that from everything is dropped and counted as `far`.
*/
export function classifyOverlay(rgba, w, h, legend) {
const marks = legend.marks;
const n = marks.length;
const pr = new Int32Array(n), pg = new Int32Array(n), pb = new Int32Array(n);
for (let k = 0; k < n; k++) { pr[k] = marks[k].rgb[0]; pg[k] = marks[k].rgb[1]; pb[k] = marks[k].rgb[2]; }
const tol2 = legend.matchDistance * legend.matchDistance;
const total = w * h;
const out = new Uint8Array(total);
const counts = new Int32Array(n + 1);
let blank = 0, far = 0, maxD2 = -1, maxAt = [-1, -1];
// A flat stroke repeats its colour millions of times, so the last answer is cached: one compare before
// any distance is computed. Exact, because the cache is keyed on the full 24-bit colour.
let lastKey = -1, lastBest = 0, lastD2 = 0;
for (let p = 0, o = 0; p < total; p++, o += 4) {
if (rgba[o + 3] < 128) { blank++; counts[0]++; continue; }
const r = rgba[o], g = rgba[o + 1], b = rgba[o + 2];
const key = (r << 16) | (g << 8) | b;
if (key !== lastKey) {
let best = -1, bestD = 1 << 30;
for (let k = 0; k < n; k++) {
const dr = r - pr[k], dg = g - pg[k], db = b - pb[k];
const d = dr * dr + dg * dg + db * db;
if (d < bestD) { bestD = d; best = k; }
}
lastKey = key; lastBest = best; lastD2 = bestD;
}
if (lastBest < 0 || lastD2 > tol2) {
blank++; counts[0]++; far++;
if (lastD2 > maxD2) { maxD2 = lastD2; maxAt = [p % w, (p / w) | 0]; }
continue;
}
out[p] = lastBest + 1;
counts[lastBest + 1]++;
}
return {
marks: out, w, h, counts, total, blank, far,
maxDist: maxD2 >= 0 ? Math.sqrt(maxD2) : 0, maxAt,
};
}
/** The classifier's report as one line, the way `terrain plan` prints it. */
export function overlayReportText(rep) {
if (!rep || rep.total === 0) return 'no overlay';
const painted = rep.total - rep.blank;
let s = `${painted.toLocaleString()} px painted of ${(rep.total / 1e6).toFixed(1)} MP (${(100 * painted / rep.total).toFixed(1)} %)`;
if (rep.far > 0) s += `; ${rep.far.toLocaleString()} px match no mark and were dropped (worst ${rep.maxDist.toFixed(0)} at ${rep.maxAt[0]}, ${rep.maxAt[1]})`;
return s + '.';
}
const clamp1 = v => Math.max(-1, Math.min(1, v));
/**
* Vote the mark raster onto the mesh. Unlike a class, a mark is sparse - a stroke along a coast is a few
* pixels wide - so a plain majority would hand almost every region to blank. A region takes the most common
* non-blank mark under it when marks cover at least a third of its footprint, else 0.
*/
export function sampleMarksToMesh(mesh, r_xyz, markRaster, w, h, numMarks) {
const N = mesh.numRegions;
const r_mark = new Uint8Array(N);
const spacing = Math.sqrt(4 * Math.PI / Math.max(1, N - 1));
const pxPerRadX = w / (2 * Math.PI);
const pxPerRadY = h / Math.PI;
const counts = new Int32Array(numMarks + 1);
const MAXS = 7;
for (let r = 0; r < N; r++) {
const x = r_xyz[3 * r], y = r_xyz[3 * r + 1], z = r_xyz[3 * r + 2];
const lat = Math.asin(clamp1(y));
const lon = Math.atan2(x, z);
const cx = (lon / Math.PI + 1) * 0.5 * w;
const cy = (0.5 - lat / Math.PI) * h;
const cosLat = Math.max(Math.cos(lat), 1e-3);
let hx = 0.5 * spacing * pxPerRadX / cosLat;
if (hx > w / 2) hx = w / 2;
const hy = 0.5 * spacing * pxPerRadY;
const nx = Math.min(MAXS, Math.max(1, Math.round(2 * hx)));
const ny = Math.min(MAXS, Math.max(1, Math.round(2 * hy)));
counts.fill(0);
let marked = 0;
for (let j = 0; j < ny; j++) {
const sy = ny === 1 ? cy : cy - hy + (2 * hy) * (j + 0.5) / ny;
let py = Math.floor(sy);
if (py < 0) py = 0; else if (py >= h) py = h - 1;
const row = py * w;
for (let i = 0; i < nx; i++) {
const sx = nx === 1 ? cx : cx - hx + (2 * hx) * (i + 0.5) / nx;
let px = Math.floor(sx);
px = ((px % w) + w) % w;
const m = markRaster[row + px];
if (m) { counts[m]++; marked++; }
}
}
if (marked * 3 < nx * ny) continue;
let best = 0;
for (let m = 1; m <= numMarks; m++) if (counts[m] > counts[best]) best = m;
r_mark[r] = best;
}
return r_mark;
}
/**
* The coast-jitter multiplier per region, from the marks under it. `markJitter[i]` is the multiplier mark i
* asks for, or null when it says nothing; regions with no such mark get 1.
*
* Painting either side of the waterline is enough: the Go pass reads the mark on the far side of the shore
* too, so here a set factor spreads two hops over the mesh, and where two spread factors meet the smaller
* wins, because pinning is the deliberate act. Returns the factors and how many regions each way.
*/
export function jitterPerRegion(mesh, r_mark, markJitter, hops = 2) {
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
const f = new Float32Array(N).fill(-1); // -1 is "unset"
let queue = [];
for (let r = 0; r < N; r++) {
const m = r_mark[r];
if (!m) continue;
const j = markJitter[m];
if (j === null || j === undefined) continue;
f[r] = j;
queue.push(r);
}
for (let hop = 0; hop < hops && queue.length; hop++) {
const next = [];
for (const c of queue) {
const fc = f[c];
for (let k = adjOffset[c], kEnd = adjOffset[c + 1]; k < kEnd; k++) {
const nb = adjList[k];
if (f[nb] < 0) { f[nb] = fc; next.push(nb); }
else if (fc < f[nb] && r_mark[nb] === 0) f[nb] = fc;
}
}
queue = next;
}
let pinned = 0, marked = 0;
for (let r = 0; r < N; r++) {
if (f[r] < 0) { f[r] = 1; continue; }
marked++;
if (f[r] === 0) pinned++;
}
return { r_jitter: f, pinned, marked };
}
+84
View File
@@ -0,0 +1,84 @@
// What a legend's numbers make, before anything is solved: the two angles per class that `terrain plan`
// prints, ported from Tools/Terrain internal/planet/plan.go so the class table here says the same thing.
//
// Steady state for the stream-power law is S = U/(K·A^m). With channels allowed down to a single cell, A at a
// drainage divide is one cell squared, so for n = 1 the uplift rate alone fixes the hillslope angle at the
// divide - the most useful number in the whole legend, because it decides whether the ground is shaped by
// rivers or by landsliding. But almost none of a map is divide: slope falls away downstream, and the median
// over a class comes out at about a third of the divide angle in tangent. That ratio was measured on the Go
// tool's 8 m grid over a factor of twenty in rate (0.34, 0.33, 0.33, 0.32), and it is the reason there are
// two columns. An author who reads the divide angle as the landscape sets every rate two or three times too
// hot.
//
// Pure functions of the legend and the bake's constants; no mesh, no DOM. The constants are the geology
// grid's (cell size, K, m, angle of repose), which is what the numbers are *about*: the ground the bake makes,
// not the globe here, whose relief is a scale.
export const SOLVE_DEFAULTS = { k: 5e-5, m: 0.5, cellM: 8, talusDeg: 35 };
const RAD = Math.PI / 180;
/** Hillslope angle at a divide, in degrees, for a rate in mm/yr and an erodibility multiplier on K. */
export function divideAngleDeg(rateMmYr, kMult, solve) {
const s = solve || SOLVE_DEFAULTS;
const k = (s.k || SOLVE_DEFAULTS.k) * (kMult > 0 ? kMult : 1);
const cellM = s.cellM || SOLVE_DEFAULTS.cellM;
const m = (s.m === undefined || s.m === null) ? SOLVE_DEFAULTS.m : s.m;
if (k <= 0 || cellM <= 0) return 0;
const slope = (rateMmYr / 1000) / (k * Math.pow(cellM * cellM, m));
return Math.atan(slope) / RAD;
}
// Fractions of the *tangent*, not of the angle, because the law is about slope.
const TYPICAL_MEDIAN_FRAC = 0.33;
const TYPICAL_P90_FRAC = 0.45;
/** The median and 90th-percentile slope over a class whose divide angle is `deg`. */
export function typicalFromDivide(deg) {
const t = Math.tan(deg * RAD);
return { median: Math.atan(t * TYPICAL_MEDIAN_FRAC) / RAD, p90: Math.atan(t * TYPICAL_P90_FRAC) / RAD };
}
/** The ground a median slope reads as. Boundaries are angles, not rates, deliberately. */
export function readsAs(deg) {
if (deg < 3) return 'plain';
if (deg < 8) return 'rolling';
if (deg < 16) return 'hill country';
if (deg < 28) return 'mountain';
return 'alpine';
}
/** The rate, in mm/yr, at which a divide at k = 1 reaches the angle of repose; above it the clamp shapes the ground. */
export function clampCeilingMmYr(solve) {
const s = solve || SOLVE_DEFAULTS;
const k = s.k || SOLVE_DEFAULTS.k;
const cellM = s.cellM || SOLVE_DEFAULTS.cellM;
const m = (s.m === undefined || s.m === null) ? SOLVE_DEFAULTS.m : s.m;
const talus = s.talusDeg || SOLVE_DEFAULTS.talusDeg;
return Math.tan(talus * RAD) * k * Math.pow(cellM * cellM, m) * 1000;
}
/**
* Every angle the plan prints for one parsed class (see painted.js parseLegend): the divide, the typical
* median and P90, what it reads as, whether the divide is past the angle of repose, and the same for the
* massif floor when the class has one. Null for a class that is not land.
*/
export function classAngles(c, solve) {
if (!c || c.sea || c.stroke || c.derived) return null;
const s = solve || SOLVE_DEFAULTS;
const talus = s.talusDeg || SOLVE_DEFAULTS.talusDeg;
const divide = divideAngleDeg(c.upliftMmYr, c.kMult, s);
const typ = typicalFromDivide(divide);
const out = {
divide, median: typ.median, p90: typ.p90,
readsAs: readsAs(typ.median),
clamped: divide >= talus,
floor: null,
};
if (c.massif && c.massif.fraction > 0) {
const fd = divideAngleDeg(c.massif.floorMmYr, c.kMult, s);
const ft = typicalFromDivide(fd);
out.floor = { rateMmYr: c.massif.floorMmYr, divide: fd, median: ft.median, readsAs: readsAs(ft.median), fraction: c.massif.fraction };
}
return out;
}
+924
View File
@@ -0,0 +1,924 @@
// Painted-map import — the uplift-painting workflow from the Salty terrain generator
// (Tools/Terrain, `terrain plan` / `terrain bake`) on World Orogen's sphere mesh.
//
// The author paints a flat equirectangular map where every colour is a *class*: a rate of rock
// uplift and an erodibility, never a height. A legend JSON beside the painting says what the
// colours mean. This module turns the two into a planet:
//
// 1. classify — every pixel goes to its nearest legend colour (nearest, never "unmatched",
// so a JPEG halo or a stray pixel lands on something sensible; the report
// says how many were far from everything).
// 2. vote — every Voronoi region takes the majority class of the pixels under it.
// 3. strokes — the white outline an artist draws round every island dissolves into
// whichever real class is nearest; a white blob touching a pole is the ice cap.
// 4. coast — the drawn shoreline is roughened by adding noise to the signed distance
// from it, because a drawn coast is a smooth curve and a real one is fractal.
// 5. uplift — class rate × the planet's upland fabric (a massif is where one fabric,
// cut at a quantile of the *planet*, stands high) × a coastal-plain ramp ×
// a regional swell; erodibility = class k × the planet's rock field.
// 6. solve — dh/dt = U − K·A^m·S, integrated implicitly up the drainage stack
// (Braun & Willett 2013) with the ocean fixed at sea level, until the land is
// in balance with its uplift. Rivers, divides and the valley hierarchy come out
// of the physics; the painting decides only where the land rises and how fast.
//
// Paint the uplift, never the height: a solve handed a painted surface erodes it into something
// else within a few hundred steps and throws the drainage network away.
//
// Everything here is pure computation with no DOM, so it runs inside planet-worker.js. The
// legend parser and the pixel classifier also run on the main thread, to show the match report
// before anything is solved.
import { SimplexNoise } from './simplex-noise.js';
import { elevToHeightKm } from './color-map.js';
export const DEFAULT_WARN_DISTANCE = 60;
export const DEFAULT_COASTAL_FLOOR_MM_YR = 0.02;
export const MAX_MASSIF_FRACTION = 0.6;
export const EARTH_CIRCUMFERENCE_KM = 40030;
export const EARTH_RADIUS_KM = 6371;
// Defaults for the planet block, matching RawContent/World/Planet.json in the Salty repo.
export const PLANET_DEFAULTS = {
circumferenceKm: 100,
massifWavelengthKm: 7,
lithologyWavelengthKm: 8,
lithology: [0.6, 1.0, 1.8],
variation: 0.30,
coastDetail: 0.35,
steps: 200,
peakKm: 4.5,
oceanDepthKm: 4.0,
seed: 7945,
// The bake's geology grid - Planet.json's pipeline block - which the class table's angles are about
// (painted-report.js). Not used by the solve here, whose relief is a scale.
k: 5e-5, m: 0.5, cellM: 8, talusDeg: 35,
};
// ─── Legend ───────────────────────────────────────────────────────
/**
* Parse a legend JSON object (the same schema Tools/Terrain reads) into a normalised legend.
* Throws with a readable message when the legend cannot make a world.
*/
export function parseLegend(obj) {
if (!obj || typeof obj !== 'object') throw new Error('The legend is not a JSON object');
if (!Array.isArray(obj.classes) || obj.classes.length === 0) throw new Error('The legend has no "classes" array');
if (obj.classes.length > 255) throw new Error('The legend has more than 255 classes');
const classes = obj.classes.map((c, i) => {
if (!c || typeof c !== 'object') throw new Error(`Class ${i} is not an object`);
if (typeof c.name !== 'string' || !c.name) throw new Error(`Class ${i} has no name`);
let rgb = null;
if (Array.isArray(c.rgb) && c.rgb.length === 3) {
rgb = c.rgb.map(v => Math.max(0, Math.min(255, Math.round(+v || 0))));
}
if (!rgb && !c.derived) throw new Error(`Class "${c.name}" has no rgb`);
const sea = !!c.sea;
let massif = null;
if (c.massif && +c.massif.fraction > 0 && !sea) {
massif = {
floorMmYr: Math.max(0, +c.massif.floor_mm_yr || 0),
fraction: Math.min(MAX_MASSIF_FRACTION, +c.massif.fraction),
};
}
return {
index: i,
name: c.name,
rgb: rgb || [0, 0, 0],
sea,
depthM: sea ? Math.max(0, +c.depth_m || 0) : 0,
upliftMmYr: sea ? 0 : Math.max(0, +c.uplift_mm_yr || 0),
kMult: (+c.k_mult > 0) ? +c.k_mult : 1,
stroke: !!c.stroke,
derived: !!c.derived,
snow: !!c.snow,
edgeClass: typeof c.edge_class === 'string' ? c.edge_class : '',
edgeIndex: -1,
massif,
coastalPlainKm: Math.max(0, +c.coastal_plain_km || 0),
coastalFloorMmYr: Math.max(0, +c.coastal_floor_mm_yr || 0),
lithologyMix: (c.lithology_mix === undefined || c.lithology_mix === null) ? 1 : Math.max(0, Math.min(1, +c.lithology_mix)),
raw: c,
};
});
for (const c of classes) {
if (!c.edgeClass) continue;
const j = classes.findIndex(o => o.name === c.edgeClass);
if (j < 0) throw new Error(`Class "${c.name}" names edge_class "${c.edgeClass}", which is not in the legend`);
c.edgeIndex = j;
}
const paintable = classes.filter(c => !c.derived);
if (!paintable.some(c => c.sea)) throw new Error('The legend has no sea class');
if (!classes.some(c => !c.sea && !c.stroke)) throw new Error('The legend has no land class');
const planet = readPlanetBlock(obj);
return {
classes,
warnDistance: +obj.warn_distance > 0 ? +obj.warn_distance : DEFAULT_WARN_DISTANCE,
planet,
source: obj,
};
}
/**
* The "planet" block: the numbers Planet.json carries in the Go tool. Read from a legend that carries an
* optional copy of it, or from Planet.json itself, which has the same keys plus the pipeline block the
* class table's angles need. Missing keys keep `base`, which is PLANET_DEFAULTS for a legend.
*/
function readPlanetBlock(obj, base = PLANET_DEFAULTS) {
const p = obj.planet || {};
const pipe = obj.pipeline || {};
const lith = pipe.lithology || p.lithology || null;
const kmults = lith && Array.isArray(lith.k_multipliers) && lith.k_multipliers.length >= 2
? lith.k_multipliers.map(v => Math.max(0.05, +v || 1)) : (base.lithology || PLANET_DEFAULTS.lithology).slice();
const num = (v, d) => (v !== undefined && v !== null && isFinite(+v)) ? +v : d;
const fluvial = pipe.fluvial || {};
const thermal = pipe.thermal || {};
// The geology cell is the detail quad times the geology factor, as manifest.go derives it.
const quadM = num(obj.quad_cm, 0) / 100;
const geologyFactor = num(pipe.geology_factor, 0);
return {
circumferenceKm: Math.max(1, num(p.circumference_km, base.circumferenceKm)),
massifWavelengthKm: Math.max(0, num(p.massif_wavelength_km, base.massifWavelengthKm)),
lithologyWavelengthKm: Math.max(0, num(p.lithology_wavelength_km, base.lithologyWavelengthKm)),
lithology: kmults,
variation: Math.max(0, Math.min(0.6, num(p.uplift_variation, base.variation))),
seed: num(obj.source && obj.source.seed, num(p.seed, base.seed)),
k: Math.max(0, num(fluvial.k, base.k)),
m: num(fluvial.m, base.m),
cellM: quadM > 0 && geologyFactor > 0 ? quadM * geologyFactor : num(p.cell_m, base.cellM),
talusDeg: num(thermal.talus_deg, base.talusDeg),
overlayLegend: typeof p.overlay_legend === 'string' ? p.overlay_legend : (base.overlayLegend || ''),
overlay: typeof p.overlay === 'string' ? p.overlay : (base.overlay || ''),
};
}
/**
* Read Planet.json - the Go tool's own manifest - onto a parsed legend, so its planet block, its lithology
* multipliers, its seed and the geology grid's constants travel without being retyped. Keys it lacks keep
* what the legend had. Returns the merged block.
*/
export function applyPlanetManifest(legend, manifest) {
if (!manifest || typeof manifest !== 'object') throw new Error('Planet.json is not a JSON object');
if (!manifest.planet || typeof manifest.planet !== 'object') throw new Error('Planet.json has no "planet" block; the painted path reads a painted planet');
legend.planet = readPlanetBlock(manifest, legend.planet);
return legend.planet;
}
/** The class's plain-at-the-waterline rate, never above the class rate. */
export function plainFloorMmYr(c) {
let floor = c.coastalFloorMmYr > 0 ? c.coastalFloorMmYr : DEFAULT_COASTAL_FLOOR_MM_YR;
if (floor > c.upliftMmYr) floor = c.upliftMmYr;
return floor;
}
/**
* Write a legend back out as JSON text, keeping every key of the loaded file and only replacing
* the numbers the table edits, so the file still reads in Tools/Terrain with its commentary intact.
*/
export function serializeLegend(legend) {
const out = JSON.parse(JSON.stringify(legend.source));
for (let i = 0; i < legend.classes.length; i++) {
const c = legend.classes[i];
const raw = out.classes[i];
if (!raw) continue;
if (c.sea) raw.depth_m = c.depthM;
else {
raw.uplift_mm_yr = c.upliftMmYr;
raw.k_mult = c.kMult;
if (c.massif && raw.massif) {
raw.massif.floor_mm_yr = c.massif.floorMmYr;
raw.massif.fraction = c.massif.fraction;
}
}
}
return JSON.stringify(out, null, 2);
}
// ─── Pixel classification ─────────────────────────────────────────
/**
* Assign every pixel of an RGBA image to its nearest paintable class.
* Returns the class raster plus a match report.
*/
export function classifyImage(rgba, w, h, legend) {
const cls = legend.classes;
const n = cls.length;
const paint = [];
for (let i = 0; i < n; i++) if (!cls[i].derived) paint.push(i);
const np = paint.length;
const pr = new Int32Array(np), pg = new Int32Array(np), pb = new Int32Array(np);
for (let k = 0; k < np; k++) {
const c = cls[paint[k]];
pr[k] = c.rgb[0]; pg[k] = c.rgb[1]; pb[k] = c.rgb[2];
}
// Colours are quantised to six bits a channel and classified once per cell. A legend's
// classes sit tens of units apart (warn distance 60), so the ±2 of the cell is nothing.
const cacheCls = new Uint8Array(1 << 18).fill(255);
const cacheD2 = new Float32Array(1 << 18);
const total = w * h;
const out = new Uint8Array(total);
const counts = new Int32Array(n);
const warn2 = legend.warnDistance * legend.warnDistance;
let far = 0, maxD2 = 0, maxAt = 0;
for (let p = 0, o = 0; p < total; p++, o += 4) {
const r = rgba[o], g = rgba[o + 1], b = rgba[o + 2];
const key = ((r >> 2) << 12) | ((g >> 2) << 6) | (b >> 2);
let c = cacheCls[key];
if (c === 255) {
const cr = (r & ~3) + 2, cg = (g & ~3) + 2, cb = (b & ~3) + 2;
let best = 0, bestD = Infinity;
for (let k = 0; k < np; k++) {
const dr = cr - pr[k], dg = cg - pg[k], db = cb - pb[k];
const d = dr * dr + dg * dg + db * db;
if (d < bestD) { bestD = d; best = k; }
}
c = paint[best];
cacheCls[key] = c;
cacheD2[key] = bestD;
}
out[p] = c;
counts[c]++;
const d2 = cacheD2[key];
if (d2 > warn2) far++;
if (d2 > maxD2) { maxD2 = d2; maxAt = p; }
}
// The wrap: the left and right columns are the same meridian.
let wrapDiffer = 0, wrapLandSea = 0;
for (let y = 0; y < h; y++) {
const a = out[y * w], b = out[y * w + w - 1];
if (a !== b) {
wrapDiffer++;
if (cls[a].sea !== cls[b].sea) wrapLandSea++;
}
}
return {
classes: out,
counts,
total,
far,
maxDist: Math.sqrt(maxD2),
maxAt: [maxAt % w, (maxAt / w) | 0],
wrapRows: h,
wrapDiffer,
wrapLandSea,
};
}
// ─── Region sampling ──────────────────────────────────────────────
const clamp1 = v => Math.max(-1, Math.min(1, v));
/**
* Vote the class raster onto the mesh: every region takes the majority class of a box of
* pixels the size of its own footprint, so a thin stroke or a JPEG halo never decides a cell.
* Returns the class per region and the latitude per region (reused by the stroke pass).
*/
export function sampleClassesToMesh(mesh, r_xyz, classRaster, w, h, numClasses) {
const N = mesh.numRegions;
const r_class = new Uint8Array(N);
const r_lat = new Float32Array(N);
const spacing = Math.sqrt(4 * Math.PI / Math.max(1, N - 1)); // radians between neighbours
const pxPerRadX = w / (2 * Math.PI);
const pxPerRadY = h / Math.PI;
const counts = new Int32Array(numClasses);
const MAXS = 7;
for (let r = 0; r < N; r++) {
const x = r_xyz[3 * r], y = r_xyz[3 * r + 1], z = r_xyz[3 * r + 2];
const lat = Math.asin(clamp1(y));
const lon = Math.atan2(x, z);
r_lat[r] = lat;
const cx = (lon / Math.PI + 1) * 0.5 * w;
const cy = (0.5 - lat / Math.PI) * h;
const cosLat = Math.max(Math.cos(lat), 1e-3);
let hx = 0.5 * spacing * pxPerRadX / cosLat;
if (hx > w / 2) hx = w / 2;
const hy = 0.5 * spacing * pxPerRadY;
const nx = Math.min(MAXS, Math.max(1, Math.round(2 * hx)));
const ny = Math.min(MAXS, Math.max(1, Math.round(2 * hy)));
counts.fill(0);
for (let j = 0; j < ny; j++) {
const sy = ny === 1 ? cy : cy - hy + (2 * hy) * (j + 0.5) / ny;
let py = Math.floor(sy);
if (py < 0) py = 0; else if (py >= h) py = h - 1;
const row = py * w;
for (let i = 0; i < nx; i++) {
const sx = nx === 1 ? cx : cx - hx + (2 * hx) * (i + 0.5) / nx;
let px = Math.floor(sx);
px = ((px % w) + w) % w;
counts[classRaster[row + px]]++;
}
}
let best = 0;
for (let c = 1; c < numClasses; c++) if (counts[c] > counts[best]) best = c;
r_class[r] = best;
}
return { r_class, r_lat };
}
/**
* Resolve stroke classes on the mesh. A stroke component touching a pole becomes its
* edge_class (the ice cap painted in the same white as the outlines); everything else
* dissolves into the nearest non-stroke class by BFS.
*/
export function resolveStrokes(mesh, r_class, r_lat, legend) {
const cls = legend.classes;
const N = mesh.numRegions;
const isStroke = new Uint8Array(cls.length);
let any = false;
for (let i = 0; i < cls.length; i++) if (cls[i].stroke) { isStroke[i] = 1; any = true; }
if (!any) return { edgeAssigned: 0, dissolved: 0 };
const { adjOffset, adjList } = mesh;
const spacing = Math.sqrt(4 * Math.PI / Math.max(1, N - 1));
const poleLat = Math.PI / 2 - 2.5 * spacing;
const queue = new Int32Array(N);
const visited = new Uint8Array(N);
let edgeAssigned = 0, dissolved = 0;
// Connected components of each stroke class; the ones at a pole become the edge class.
for (let r = 0; r < N; r++) {
if (visited[r] || !isStroke[r_class[r]]) continue;
const ci = r_class[r];
const edge = cls[ci].edgeIndex;
let head = 0, tail = 0, touches = false;
queue[tail++] = r; visited[r] = 1;
while (head < tail) {
const c = queue[head++];
if (Math.abs(r_lat[c]) > poleLat) touches = true;
for (let j = adjOffset[c], jEnd = adjOffset[c + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (!visited[nb] && r_class[nb] === ci) { visited[nb] = 1; queue[tail++] = nb; }
}
}
if (touches && edge >= 0) {
for (let k = 0; k < tail; k++) r_class[queue[k]] = edge;
edgeAssigned += tail;
}
}
// Dissolve what is left into the nearest real class.
const assigned = new Uint8Array(N);
let head = 0, tail = 0;
for (let r = 0; r < N; r++) {
if (!isStroke[r_class[r]]) { assigned[r] = 1; queue[tail++] = r; }
}
while (head < tail) {
const c = queue[head++];
for (let j = adjOffset[c], jEnd = adjOffset[c + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (!assigned[nb]) { assigned[nb] = 1; r_class[nb] = r_class[c]; queue[tail++] = nb; dissolved++; }
}
}
return { edgeAssigned, dissolved };
}
// ─── Coast ────────────────────────────────────────────────────────
/**
* Hop distance from the coast: for land, to the nearest sea region; for sea, to the nearest
* land region. 1 means adjacent. 0 means the planet has no coast at all.
*/
export function coastDistance(mesh, r_land) {
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
const hop = new Int32Array(N);
const queue = new Int32Array(N);
let head = 0, tail = 0;
for (let r = 0; r < N; r++) {
const land = r_land[r];
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
if (r_land[adjList[j]] !== land) { hop[r] = 1; queue[tail++] = r; break; }
}
}
while (head < tail) {
const c = queue[head++];
const land = r_land[c];
const d = hop[c] + 1;
for (let j = adjOffset[c], jEnd = adjOffset[c + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (hop[nb] === 0 && r_land[nb] === land) { hop[nb] = d; queue[tail++] = nb; }
}
}
return hop;
}
/** Connected components of land regions; returns per-region component id and each component's max coast hop. */
function landComponents(mesh, r_land, hop) {
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
const comp = new Int32Array(N).fill(-1);
const compMax = [];
const queue = new Int32Array(N);
for (let r = 0; r < N; r++) {
if (!r_land[r] || comp[r] >= 0) continue;
const id = compMax.length;
let head = 0, tail = 0, mx = 0;
queue[tail++] = r; comp[r] = id;
while (head < tail) {
const c = queue[head++];
if (hop[c] > mx) mx = hop[c];
for (let j = adjOffset[c], jEnd = adjOffset[c + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (r_land[nb] && comp[nb] < 0) { comp[nb] = id; queue[tail++] = nb; }
}
}
compMax.push(mx);
}
return { comp, compMax };
}
/**
* Roughen the drawn coastline by adding fractal noise to the signed hop distance from it.
* `amount` 0..1 is up to three cells of shift; an islet may lose at most two thirds of its
* width so an archipelago does not vanish. Returns a new land mask.
*/
export function roughenCoast(mesh, r_xyz, r_land, amount, seed, r_jitter = null) {
const N = mesh.numRegions;
const out = new Uint8Array(r_land);
if (amount <= 0) return { r_land: out, flipped: 0 };
const hop = coastDistance(mesh, r_land);
const amp = amount * 3;
// The overlay's coast_jitter marks scale the reach per region (painted-overlay.js): 0 pins the shore as
// painted, above 1 chews it harder. The early-out below has to use the largest reach any of them asks.
let ampMax = amp;
if (r_jitter) {
let jm = 1;
for (let r = 0; r < N; r++) if (r_jitter[r] > jm) jm = r_jitter[r];
ampMax = amp * jm;
}
const { comp, compMax } = landComponents(mesh, r_land, hop);
const noise = new SimplexNoise(seed * 31 + 17);
// Bays about eight cells wide, with four octaves below that.
const spacing = Math.sqrt(4 * Math.PI / Math.max(1, N - 1));
const F = 1 / (8 * spacing);
let flipped = 0;
for (let r = 0; r < N; r++) {
if (hop[r] === 0) continue;
const d = hop[r] - 0.5;
if (d > ampMax + 1) continue;
let a = r_jitter ? amp * r_jitter[r] : amp;
if (a <= 0 || d > a + 1) continue;
if (r_land[r]) {
const cap = 0.66 * compMax[comp[r]];
if (cap < a) a = cap;
if (a < 0.5) continue;
}
const n = noise.fbm(r_xyz[3 * r] * F, r_xyz[3 * r + 1] * F, r_xyz[3 * r + 2] * F, 4, 0.5);
const s = (r_land[r] ? d : -d) + 2 * n * a;
const land = s > 0 ? 1 : 0;
if (land !== r_land[r]) { out[r] = land; flipped++; }
}
return { r_land: out, flipped };
}
/**
* After the coast moves, a region can be land wearing a sea class or the other way round.
* Give each such region the class of the nearest region that agrees with its new type.
*/
export function reconcileClasses(mesh, r_class, r_land, legend) {
const cls = legend.classes;
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
const queue = new Int32Array(N);
const done = new Uint8Array(N);
let changed = 0;
for (const wantLand of [1, 0]) {
done.fill(0);
let head = 0, tail = 0;
for (let r = 0; r < N; r++) {
const consistent = (cls[r_class[r]].sea ? 0 : 1) === r_land[r];
if (consistent) { done[r] = 1; if (r_land[r] === wantLand) queue[tail++] = r; }
else if (r_land[r] !== wantLand) done[r] = 1; // the other pass's problem
}
while (head < tail) {
const c = queue[head++];
for (let j = adjOffset[c], jEnd = adjOffset[c + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (!done[nb]) { done[nb] = 1; r_class[nb] = r_class[c]; queue[tail++] = nb; changed++; }
}
}
// A region nothing reached (an all-sea planet turned to land somewhere) takes the first
// class of the wanted type.
for (let r = 0; r < N; r++) {
if (done[r]) continue;
const idx = cls.findIndex(c => !c.derived && !c.stroke && (c.sea ? 0 : 1) === wantLand);
if (idx >= 0) { r_class[r] = idx; changed++; }
done[r] = 1;
}
}
return changed;
}
// ─── Uplift field ─────────────────────────────────────────────────
/** Exact rank of every value in 0..1 over the whole array: the share of the planet standing below it. */
export function rankField(values) {
const N = values.length;
const idx = new Uint32Array(N);
for (let i = 0; i < N; i++) idx[i] = i;
idx.sort((a, b) => values[a] - values[b]);
const rank = new Float32Array(N);
const denom = Math.max(1, N - 1);
for (let i = 0; i < N; i++) rank[idx[i]] = i / denom;
return rank;
}
function massifShape(rank, fraction) {
const lo = 1 - 1.5 * fraction;
const hi = 1 - 0.5 * fraction;
const t = (rank - lo) / (hi - lo);
if (t <= 0) return 0;
if (t >= 1) return 1;
return t * t * (3 - 2 * t);
}
/** The class rate where the fabric is high, the floor where it is low, cut in rank so `fraction` means what it says. */
export function massifRate(floorMmYr, rateMmYr, rank, fraction) {
return floorMmYr + (rateMmYr - floorMmYr) * massifShape(rank, fraction);
}
function sampleFbm(noise, r_xyz, N, F, octaves, gain) {
const out = new Float32Array(N);
for (let r = 0; r < N; r++) {
out[r] = noise.fbm(r_xyz[3 * r] * F, r_xyz[3 * r + 1] * F, r_xyz[3 * r + 2] * F, octaves, gain);
}
return out;
}
/**
* Build the uplift-rate field (mm/yr per region) and the erodibility multiplier from the
* classes and the legend. Distances in the legend are in the painted planet's kilometres and
* are scaled to Orogen's Earth-sized globe by 40030 / circumferenceKm.
*/
export function buildUpliftField(mesh, r_xyz, r_class, r_land, legend, opts) {
const cls = legend.classes;
const N = mesh.numRegions;
const circ = Math.max(1, opts.circumferenceKm);
const scale = EARTH_CIRCUMFERENCE_KM / circ;
const avgEdgeKm = Math.PI * EARTH_RADIUS_KM / Math.sqrt(N);
const seed = opts.seed | 0;
const hop = coastDistance(mesh, r_land);
// A noise wavelength of λ painted kilometres is F = circ / (2π λ) noise units per radian.
const freqFor = km => circ / (2 * Math.PI * Math.max(1e-6, km));
let rank = null;
if (opts.massifWavelengthKm > 0 && cls.some(c => c.massif)) {
const fab = sampleFbm(new SimplexNoise(seed * 7 + 1), r_xyz, N, freqFor(opts.massifWavelengthKm), 5, 0.45);
rank = rankField(fab);
}
let rock = null;
const kmults = opts.lithology || [];
if (opts.lithologyWavelengthKm > 0 && kmults.length >= 2 && cls.some(c => c.lithologyMix > 0)) {
const fab = sampleFbm(new SimplexNoise(seed * 7 + 2), r_xyz, N, freqFor(opts.lithologyWavelengthKm), 4, 0.5);
const rr = rankField(fab);
rock = new Float32Array(N);
const types = kmults.length;
for (let r = 0; r < N; r++) rock[r] = kmults[Math.min(types - 1, Math.floor(rr[r] * types))];
}
let swell = null;
const variation = Math.max(0, opts.variation || 0);
if (variation > 0) swell = sampleFbm(new SimplexNoise(seed * 7 + 3), r_xyz, N, freqFor(25), 4, 0.5);
const r_rate = new Float32Array(N);
const r_k = new Float32Array(N);
let rateMax = 0, landCount = 0;
for (let r = 0; r < N; r++) {
const c = cls[r_class[r]];
let k = c.kMult;
if (rock && c.lithologyMix > 0) k *= 1 + c.lithologyMix * (rock[r] - 1);
r_k[r] = k;
if (!r_land[r]) continue;
landCount++;
let rate = c.upliftMmYr;
if (rank && c.massif) rate = massifRate(c.massif.floorMmYr, rate, rank[r], c.massif.fraction);
if (c.coastalPlainKm > 0) {
const plainKm = c.coastalPlainKm * scale;
const shoreKm = Math.max(0, hop[r] - 0.5) * avgEdgeKm;
let t = Math.min(1, shoreKm / plainKm);
t = t * t * (3 - 2 * t);
const floor = plainFloorMmYr(c);
if (rate > floor) rate = floor + (rate - floor) * t;
}
if (swell) {
let sw = 0.5 + swell[r] * 0.8;
if (sw < 0) sw = 0; else if (sw > 1) sw = 1;
rate *= 1 + variation * (2 * sw - 1);
}
r_rate[r] = rate;
if (rate > rateMax) rateMax = rate;
}
return { r_rate, r_k, rateMax, avgEdgeKm, scale, hop, landCount, massifRank: rank };
}
// ─── The solve ────────────────────────────────────────────────────
function hash01(a, b) {
let x = (Math.imul(a, 374761393) + Math.imul(b, 668265263)) | 0;
x = Math.imul(x ^ (x >>> 13), 1274126177);
x ^= x >>> 16;
return (x >>> 0) / 4294967296;
}
/** Binary min-heap over region indices with a copied key, sized once. */
class RegionHeap {
constructor(capacity) {
this.keys = new Float64Array(capacity);
this.items = new Int32Array(capacity);
this.size = 0;
}
clear() { this.size = 0; }
push(item, key) {
let i = this.size++;
const keys = this.keys, items = this.items;
while (i > 0) {
const p = (i - 1) >> 1;
if (keys[p] <= key) break;
keys[i] = keys[p]; items[i] = items[p];
i = p;
}
keys[i] = key; items[i] = item;
}
pop() {
const keys = this.keys, items = this.items;
const top = items[0];
const n = --this.size;
if (n > 0) {
const key = keys[n], item = items[n];
let i = 0;
while (true) {
let l = 2 * i + 1;
if (l >= n) break;
const r = l + 1;
if (r < n && keys[r] < keys[l]) l = r;
if (keys[l] >= key) break;
keys[i] = keys[l]; items[i] = items[l];
i = l;
}
keys[i] = key; items[i] = item;
}
return top;
}
}
/**
* Solve the stream-power equation on the mesh from an uplift field.
*
* Units are dimensionless: U is the rate as a fraction of the largest class rate, K is the
* erodibility multiplier, A is drainage area in cells and lengths are in mean edges, so a
* divide one cell from the sea at full rate stands about one unit high. For n = 1 the steady
* state is linear in U/K, so the relief is set afterwards by a single scale (see toElevation)
* and the shape of the land — where the rivers run, how the valleys nest, how far a coast is
* from its divide — is what the solve decides.
*
* Each step: priority-flood so every land cell has a downhill path to the ocean, steepest
* receivers, a donor stack, drainage area down the stack, the implicit update up it, and a
* touch of hillslope diffusion so the divides are rounded rather than needles.
*/
export function solveUplift(mesh, neighborDist, r_land, r_rate, r_k, rateMax, params, onProgress) {
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
const steps = Math.max(1, params.steps | 0);
const dt = 1;
const m = params.m ?? 0.5;
const alpha = params.diffusion ?? 0.04;
const seed = params.seed | 0;
const eps = 1e-4;
let sumL = 0;
for (let i = 0; i < adjList.length; i++) sumL += neighborDist[i];
const meanEdge = sumL / Math.max(1, adjList.length);
const invMean = 1 / meanEdge;
const U = new Float32Array(N);
const h = new Float64Array(N);
for (let r = 0; r < N; r++) {
if (!r_land[r]) continue;
U[r] = rateMax > 0 ? r_rate[r] / rateMax : 0;
// A little initial relief, scaled by the rate, only so the first routing has something
// to bite on. The solve produces the relief; starting from ridges means tearing them down.
h[r] = 0.05 * U[r] * (0.75 + 0.5 * hash01(r, seed)) + 1e-3 * hash01(r, seed + 1);
}
const receiver = new Int32Array(N);
const recvLen = new Float32Array(N);
const donorOff = new Int32Array(N + 1);
const donorList = new Int32Array(N);
const cursor = new Int32Array(N);
const stack = new Int32Array(N);
const area = new Float32Array(N);
const closed = new Uint8Array(N);
const tmp = new Float64Array(N);
const heap = new RegionHeap(N);
function flood(step) {
closed.fill(0);
heap.clear();
for (let r = 0; r < N; r++) {
if (!r_land[r]) { closed[r] = 1; heap.push(r, h[r]); }
}
while (heap.size > 0) {
const c = heap.pop();
const hc = h[c];
for (let j = adjOffset[c], jEnd = adjOffset[c + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (closed[nb]) continue;
closed[nb] = 1;
// The epsilon is scattered per cell and per step, so a filled flat has no
// gradient the router could read as the flood's own traversal order.
if (h[nb] <= hc) h[nb] = hc + eps * (0.5 + hash01(nb, step * 7919 + 13));
heap.push(nb, h[nb]);
}
}
}
function receivers() {
for (let r = 0; r < N; r++) {
if (!r_land[r]) { receiver[r] = r; recvLen[r] = meanEdge; continue; }
const hr = h[r];
let best = -1, bestS = 0, bestJ = -1;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
const drop = hr - h[nb];
if (drop <= 0) continue;
const s = drop / neighborDist[j];
if (s > bestS) { bestS = s; best = nb; bestJ = j; }
}
if (best < 0) { receiver[r] = r; recvLen[r] = meanEdge; }
else { receiver[r] = best; recvLen[r] = neighborDist[bestJ]; }
}
}
function buildStack() {
donorOff.fill(0);
for (let i = 0; i < N; i++) { const r = receiver[i]; if (r !== i) donorOff[r + 1]++; }
for (let i = 0; i < N; i++) donorOff[i + 1] += donorOff[i];
cursor.set(donorOff.subarray(0, N));
for (let i = 0; i < N; i++) { const r = receiver[i]; if (r !== i) donorList[cursor[r]++] = i; }
let tail = 0;
for (let i = 0; i < N; i++) if (receiver[i] === i) stack[tail++] = i;
for (let read = 0; read < tail; read++) {
const c = stack[read];
for (let d = donorOff[c], dEnd = donorOff[c + 1]; d < dEnd; d++) stack[tail++] = donorList[d];
}
return tail;
}
function accumulate(len) {
area.fill(1);
for (let k = len - 1; k >= 0; k--) {
const i = stack[k];
const r = receiver[i];
if (r !== i) area[r] += area[i];
}
}
function update(len) {
for (let k = 0; k < len; k++) {
const i = stack[k];
if (!r_land[i]) continue;
const r = receiver[i];
if (r === i) { h[i] += dt * U[i]; continue; }
const L = recvLen[i] * invMean;
const f = r_k[i] * dt * Math.pow(area[i], m) / L;
const hr = h[r];
let next = (h[i] + dt * U[i] + f * hr) / (1 + f);
if (next < hr) next = hr;
h[i] = next;
}
}
function diffuse() {
if (alpha <= 0) return;
for (let r = 0; r < N; r++) {
if (!r_land[r]) continue;
let sum = 0, cnt = 0;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) { sum += h[adjList[j]]; cnt++; }
tmp[r] = cnt > 0 ? h[r] + alpha * (sum / cnt - h[r]) : h[r];
}
for (let r = 0; r < N; r++) if (r_land[r]) h[r] = tmp[r];
}
const t0 = (typeof performance !== 'undefined') ? performance.now() : Date.now();
let stackLen = 0;
const report = Math.max(1, Math.floor(steps / 20));
for (let step = 0; step < steps; step++) {
flood(step);
receivers();
stackLen = buildStack();
accumulate(stackLen);
update(stackLen);
diffuse();
if (onProgress && (step % report === 0 || step === steps - 1)) onProgress(step + 1, steps);
}
// One last fill and routing, so area and receiver describe the surface that is returned.
flood(steps);
receivers();
stackLen = buildStack();
accumulate(stackLen);
const basin = new Int32Array(N);
for (let k = 0; k < stackLen; k++) {
const i = stack[k];
const r = receiver[i];
basin[i] = r === i ? i : basin[r];
}
const t1 = (typeof performance !== 'undefined') ? performance.now() : Date.now();
return { h, area, receiver, recvLen, basin, meanEdge, solveMs: t1 - t0 };
}
// ─── Scaling to Orogen's elevation ────────────────────────────────
// Inverse of elevToHeightKm on land: a table over 0..6 km, built once.
let _invTable = null;
const INV_BINS = 6000;
function invHeightKm(km) {
if (!_invTable) {
const table = new Float32Array(INV_BINS + 1);
let t = 0;
const dtStep = 1 / 65536;
for (let b = 0; b <= INV_BINS; b++) {
const target = 6 * b / INV_BINS;
while (t < 1 && elevToHeightKm(t) < target) t += dtStep;
table[b] = Math.min(1, t);
}
_invTable = table;
}
if (km <= 0) return 0;
if (km >= 6) return 1;
const x = km / 6 * INV_BINS;
const b = Math.floor(x);
const f = x - b;
return _invTable[b] * (1 - f) + _invTable[Math.min(INV_BINS, b + 1)] * f;
}
/**
* Turn the solved units into Orogen's elevation field, plus the derived layers.
* Land is scaled so its 99.5th percentile stands at peakKm; sea takes the class depth,
* scaled so the deepest class sits at oceanDepthKm, with a short ramp down from the shore.
*/
export function toElevation(mesh, solved, r_land, r_class, legend, hop, opts) {
const cls = legend.classes;
const N = mesh.numRegions;
const { h, area, receiver, recvLen, basin, meanEdge } = solved;
const avgEdgeKm = opts.avgEdgeKm;
const landVals = [];
for (let r = 0; r < N; r++) if (r_land[r]) landVals.push(h[r]);
landVals.sort((a, b) => a - b);
const p995 = landVals.length ? landVals[Math.min(landVals.length - 1, Math.floor(landVals.length * 0.995))] : 0;
const kmScale = p995 > 0 ? opts.peakKm / p995 : 0;
let deepest = 0;
for (const c of cls) if (c.sea && c.depthM > deepest) deepest = c.depthM;
const depthScale = deepest > 0 ? (opts.oceanDepthKm * 1000) / deepest : 0;
const r_elevation = new Float32Array(N);
const slopeDeg = new Float32Array(N);
const flowLog = new Float32Array(N);
const basinOut = new Int32Array(N);
let maxKm = 0;
for (let r = 0; r < N; r++) {
if (r_land[r]) {
let km = h[r] * kmScale;
if (km > 6) km = 6;
if (km > maxKm) maxKm = km;
let e = invHeightKm(km);
if (e < 0.002) e = 0.002;
r_elevation[r] = e;
const rec = receiver[r];
if (rec !== r) {
const dz = (h[r] - h[rec]) * kmScale;
const dx = recvLen[r] / meanEdge * avgEdgeKm;
slopeDeg[r] = Math.atan2(Math.max(0, dz), Math.max(1e-6, dx)) * 180 / Math.PI;
}
flowLog[r] = Math.log10(Math.max(1, area[r]));
basinOut[r] = basin[r];
} else {
const c = cls[r_class[r]];
let depthKm = c.depthM / 1000 * depthScale;
const f = Math.min(1, Math.max(0, (hop[r] - 0.5) / 2));
depthKm *= f;
if (depthKm < 0.005) depthKm = 0.005;
let e = -depthKm / 10;
if (e < -0.5) e = -0.5;
r_elevation[r] = e;
slopeDeg[r] = -1;
flowLog[r] = -1;
basinOut[r] = -1;
}
}
return { r_elevation, slopeDeg, flowLog, basin: basinOut, kmScale, p995, maxKm };
}
+275
View File
@@ -0,0 +1,275 @@
// Planet code encode/decode — packs seed + slider values into a compact base36 string.
// Pure functions, no DOM access.
// Slider quantization tables
const SLIDERS = [
{ min: 5000, step: 1000, count: 2556 }, // Detail (N)
{ min: 0, step: 0.05, count: 21 }, // Irregularity (jitter)
{ min: 4, step: 1, count: 117 }, // Plates (P)
{ min: 1, step: 1, count: 10 }, // Continents
{ min: 0, step: 0.01, count: 51 }, // Roughness
{ min: 0, step: 0.05, count: 21 }, // Smoothing
{ min: 0, step: 0.05, count: 21 }, // Glacial Erosion
{ min: 0, step: 0.05, count: 21 }, // Hydraulic Erosion
{ min: 0, step: 0.05, count: 21 }, // Thermal Erosion
{ min: 0, step: 0.05, count: 21 }, // Ridge Sharpening
{ min: 0, step: 0.05, count: 21 }, // Soil Creep
{ min: 0, step: 0.05, count: 21 }, // Terrain Warp
{ min: 0, step: 0.05, count: 21 }, // 12: Continent Size Variety
{ min: -15, step: 1, count: 31 }, // 13: Temperature
{ min: -1, step: 0.1, count: 21 }, // 14: Precipitation
{ min: 0, step: 0.01, count: 101 }, // 15: Land Coverage
];
// Mixed-radix bases (right-to-left): lcIdx, prcIdx, tmpIdx, csvIdx, twIdx, scIdx, rsIdx, teIdx, heIdx, glIdx, smIdx, nsIdx, cnIdx, pIdx, jIdx, nIdx, seed
const RADICES = [101, 21, 31, 21, 21, 21, 21, 21, 21, 21, 21, 51, 10, 117, 21, 2556];
const SEED_MAX = 16777216; // 2^24
const BASE_LEN = 22; // base code length (no toggles)
const PREV5_LEN = 21; // previous 21-char codes (before land coverage)
const PREV4_LEN = 18; // previous 18-char codes (before continent variety/temp/precip)
const PREV3_LEN = 17; // previous 17-char codes (before terrain warp)
const PREV2_LEN = 16; // previous 16-char codes (before glacial erosion)
const PREV_LEN = 14; // previous 14-char codes (before ridge/creep)
const LEGACY_LEN = 13; // legacy 13-char codes (single erosion slider)
const IDX_CHARS = 2; // base36 chars per plate index (max index 119 = "3b")
// Legacy radices for decoding old 13-char codes (single erosion slider)
const LEGACY_RADICES = [21, 21, 51, 10, 117, 21, 2559];
// Previous-gen radices for decoding 14-char codes (two erosion sliders, no ridge/creep)
const PREV_RADICES = [21, 21, 21, 51, 10, 117, 21, 2559];
// Previous2-gen radices for decoding 16-char codes (no glacial erosion)
const PREV2_RADICES = [21, 21, 21, 21, 21, 51, 10, 117, 21, 2559];
// Previous3-gen radices for decoding 17-char codes (no terrain warp)
const PREV3_RADICES = [21, 21, 21, 21, 21, 21, 51, 10, 117, 21, 2559];
// Previous5-gen radices for decoding 21-char codes (before land coverage)
const PREV5_RADICES = [21, 31, 21, 21, 21, 21, 21, 21, 21, 21, 51, 10, 117, 21, 2556];
// Previous4-gen radices for decoding 18-char codes (before continent variety/temp/precip)
const PREV4_RADICES = [21, 21, 21, 21, 21, 21, 21, 51, 10, 117, 21, 2556];
function toIndex(value, slider) {
return Math.round((value - slider.min) / slider.step);
}
function fromIndex(idx, slider) {
// Round to step precision to avoid floating-point drift
const raw = slider.min + idx * slider.step;
const decimals = slider.step < 1 ? String(slider.step).split('.')[1].length : 0;
return decimals > 0 ? parseFloat(raw.toFixed(decimals)) : raw;
}
/** Parse a base36 string into a BigInt (char-by-char for full precision). */
function parseBase36(str) {
return [...str].reduce((acc, ch) => {
const d = parseInt(ch, 36);
if (isNaN(d)) throw new Error('bad char');
return acc * 36n + BigInt(d);
}, 0n);
}
// Decode format configs: one entry per code length.
// fields: [fieldName, SLIDERS_index] in LSB-first extraction order.
// defaults: field values not encoded in this format.
const DECODE_FORMATS = {
[LEGACY_LEN]: {
radices: LEGACY_RADICES,
fields: [
['hydraulicErosion', 7], ['smoothing', 5], ['roughness', 4],
['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
],
defaults: { terrainWarp: 0.5, glacialErosion: 0, thermalErosion: 0.1,
ridgeSharpening: 0.35, soilCreep: 0.05, continentSizeVariety: 0,
temperatureOffset: 0, precipitationOffset: 0, landCoverage: 0.3 }
},
[PREV_LEN]: {
radices: PREV_RADICES,
fields: [
['thermalErosion', 8], ['hydraulicErosion', 7], ['smoothing', 5], ['roughness', 4],
['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
],
defaults: { terrainWarp: 0.5, glacialErosion: 0, ridgeSharpening: 0.35,
soilCreep: 0.05, continentSizeVariety: 0,
temperatureOffset: 0, precipitationOffset: 0, landCoverage: 0.3 }
},
[PREV2_LEN]: {
radices: PREV2_RADICES,
fields: [
['soilCreep', 10], ['ridgeSharpening', 9], ['thermalErosion', 8], ['hydraulicErosion', 7],
['smoothing', 5], ['roughness', 4], ['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
],
defaults: { terrainWarp: 0.5, glacialErosion: 0, continentSizeVariety: 0,
temperatureOffset: 0, precipitationOffset: 0, landCoverage: 0.3 }
},
[PREV3_LEN]: {
radices: PREV3_RADICES,
fields: [
['soilCreep', 10], ['ridgeSharpening', 9], ['thermalErosion', 8], ['hydraulicErosion', 7],
['glacialErosion', 6], ['smoothing', 5], ['roughness', 4],
['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
],
defaults: { terrainWarp: 0.5, continentSizeVariety: 0,
temperatureOffset: 0, precipitationOffset: 0, landCoverage: 0.3 }
},
[PREV4_LEN]: {
radices: PREV4_RADICES,
fields: [
['terrainWarp', 11], ['soilCreep', 10], ['ridgeSharpening', 9],
['thermalErosion', 8], ['hydraulicErosion', 7], ['glacialErosion', 6],
['smoothing', 5], ['roughness', 4], ['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
],
defaults: { continentSizeVariety: 0, temperatureOffset: 0, precipitationOffset: 0, landCoverage: 0.3 }
},
[PREV5_LEN]: {
radices: PREV5_RADICES,
fields: [
['precipitationOffset', 14], ['temperatureOffset', 13], ['continentSizeVariety', 12],
['terrainWarp', 11], ['soilCreep', 10], ['ridgeSharpening', 9],
['thermalErosion', 8], ['hydraulicErosion', 7], ['glacialErosion', 6],
['smoothing', 5], ['roughness', 4], ['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
],
defaults: { landCoverage: 0.3 }
},
[BASE_LEN]: {
radices: RADICES,
fields: [
['landCoverage', 15], ['precipitationOffset', 14], ['temperatureOffset', 13],
['continentSizeVariety', 12], ['terrainWarp', 11], ['soilCreep', 10], ['ridgeSharpening', 9],
['thermalErosion', 8], ['hydraulicErosion', 7], ['glacialErosion', 6],
['smoothing', 5], ['roughness', 4], ['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
],
defaults: {}
},
};
/** Generic mixed-radix decode: extract fields LSB-first, validate, convert, apply defaults. */
function decodeFormat(packed, config, toggleStr) {
const { radices, fields, defaults } = config;
const result = {};
for (let i = 0; i < radices.length; i++) {
const [name, si] = fields[i];
const idx = Number(packed % BigInt(radices[i]));
packed = packed / BigInt(radices[i]);
if (idx >= SLIDERS[si].count) return null;
result[name] = fromIndex(idx, SLIDERS[si]);
}
result.seed = Number(packed);
if (result.seed < 0 || result.seed >= SEED_MAX) return null;
Object.assign(result, defaults);
const toggledIndices = [];
if (toggleStr) {
for (let i = 0; i < toggleStr.length; i += IDX_CHARS) {
const idx = parseInt(toggleStr.slice(i, i + IDX_CHARS), 36);
if (isNaN(idx) || idx >= result.P) return null;
toggledIndices.push(idx);
}
}
result.toggledIndices = toggledIndices;
return result;
}
/**
* Encode planet parameters into a base36 planet code.
* @param {number} seed - Integer seed 0–16777215
* @param {number} N - Detail (5000–2560000, step 1000)
* @param {number} jitter - Irregularity (0–1, step 0.05)
* @param {number} P - Plates (4–120, step 1)
* @param {number} numContinents - Continents (1–10, step 1)
* @param {number} roughness - Roughness (0–0.5, step 0.01)
* @param {number} terrainWarp - Terrain Warp (0–1, step 0.05)
* @param {number} smoothing - Smoothing (0–1, step 0.05)
* @param {number} glacialErosion - Glacial Erosion (0–1, step 0.05)
* @param {number} hydraulicErosion - Hydraulic Erosion (0–1, step 0.05)
* @param {number} thermalErosion - Thermal Erosion (0–1, step 0.05)
* @param {number} ridgeSharpening - Ridge Sharpening (0–1, step 0.05)
* @param {number} soilCreep - Soil Creep (0–1, step 0.05)
* @param {number} continentSizeVariety - Continent Size Variety (0–1, step 0.05)
* @param {number} temperatureOffset - Temperature offset (-15–15, step 1)
* @param {number} precipitationOffset - Precipitation offset (-1–1, step 0.1)
* @param {number} landCoverage - Land Coverage (0–1, step 0.05)
* @param {number[]} [toggledIndices=[]] - Sorted array of toggled plate indices
* @returns {string} base36 code (22 chars without edits, 22 + '-' + 2*k with k edits)
*/
export function encodePlanetCode(seed, N, jitter, P, numContinents, roughness, terrainWarp, smoothing, glacialErosion, hydraulicErosion, thermalErosion, ridgeSharpening, soilCreep, continentSizeVariety, temperatureOffset, precipitationOffset, landCoverage, toggledIndices = []) {
const nIdx = toIndex(N, SLIDERS[0]);
const jIdx = toIndex(jitter, SLIDERS[1]);
const pIdx = toIndex(P, SLIDERS[2]);
const cnIdx = toIndex(numContinents, SLIDERS[3]);
const nsIdx = toIndex(roughness, SLIDERS[4]);
const smIdx = toIndex(smoothing, SLIDERS[5]);
const glIdx = toIndex(glacialErosion, SLIDERS[6]);
const heIdx = toIndex(hydraulicErosion, SLIDERS[7]);
const teIdx = toIndex(thermalErosion, SLIDERS[8]);
const rsIdx = toIndex(ridgeSharpening, SLIDERS[9]);
const scIdx = toIndex(soilCreep, SLIDERS[10]);
const twIdx = toIndex(terrainWarp, SLIDERS[11]);
const csvIdx = toIndex(continentSizeVariety, SLIDERS[12]);
const tmpIdx = toIndex(temperatureOffset, SLIDERS[13]);
const prcIdx = toIndex(precipitationOffset, SLIDERS[14]);
const lcIdx = toIndex(landCoverage, SLIDERS[15]);
// Mixed-radix packing (least-significant first: lcIdx, prcIdx, tmpIdx, csvIdx, twIdx, ...)
let packed = BigInt(seed);
packed = packed * BigInt(RADICES[15]) + BigInt(nIdx); // * 2556
packed = packed * BigInt(RADICES[14]) + BigInt(jIdx); // * 21
packed = packed * BigInt(RADICES[13]) + BigInt(pIdx); // * 117
packed = packed * BigInt(RADICES[12]) + BigInt(cnIdx); // * 10
packed = packed * BigInt(RADICES[11]) + BigInt(nsIdx); // * 51
packed = packed * BigInt(RADICES[10]) + BigInt(smIdx); // * 21
packed = packed * BigInt(RADICES[9]) + BigInt(glIdx); // * 21
packed = packed * BigInt(RADICES[8]) + BigInt(heIdx); // * 21
packed = packed * BigInt(RADICES[7]) + BigInt(teIdx); // * 21
packed = packed * BigInt(RADICES[6]) + BigInt(rsIdx); // * 21
packed = packed * BigInt(RADICES[5]) + BigInt(scIdx); // * 21
packed = packed * BigInt(RADICES[4]) + BigInt(twIdx); // * 21
packed = packed * BigInt(RADICES[3]) + BigInt(csvIdx); // * 21
packed = packed * BigInt(RADICES[2]) + BigInt(tmpIdx); // * 31
packed = packed * BigInt(RADICES[1]) + BigInt(prcIdx); // * 21
packed = packed * BigInt(RADICES[0]) + BigInt(lcIdx); // * 21
let code = packed.toString(36).padStart(BASE_LEN, '0');
// Append toggled plate indices: "-" + 2-char base36 per index
if (toggledIndices.length > 0) {
code += '-' + toggledIndices
.map(i => i.toString(36).padStart(IDX_CHARS, '0'))
.join('');
}
return code;
}
/**
* Decode a base36 planet code back into planet parameters.
* Supports 22-char (current), 21-char (prev5), 18-char (prev4), 17-char (prev3), 16-char (prev2), 14-char (previous-gen), and 13-char (legacy) codes.
* @param {string} code - base36 code (13, 14, 16, 17, 18, 21, or 22 chars, optionally followed by "-" + toggle indices)
* @returns {{ seed: number, N: number, jitter: number, P: number, numContinents: number, roughness: number, terrainWarp: number, smoothing: number, glacialErosion: number, hydraulicErosion: number, thermalErosion: number, ridgeSharpening: number, soilCreep: number, continentSizeVariety: number, temperatureOffset: number, precipitationOffset: number, landCoverage: number, toggledIndices: number[] } | null}
*/
export function decodePlanetCode(code) {
if (typeof code !== 'string') return null;
code = code.trim().toLowerCase();
// Split base code from optional toggle suffix
const dashIdx = code.indexOf('-');
const base = dashIdx === -1 ? code : code.slice(0, dashIdx);
const toggleStr = dashIdx === -1 ? '' : code.slice(dashIdx + 1);
const config = DECODE_FORMATS[base.length];
if (!config) return null;
if (!/^[0-9a-z]+$/.test(base)) return null;
if (toggleStr && !/^[0-9a-z]+$/.test(toggleStr)) return null;
if (toggleStr && toggleStr.length % IDX_CHARS !== 0) return null;
let packed;
try {
packed = parseBase36(base);
} catch {
return null;
}
return decodeFormat(packed, config, toggleStr);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+362
View File
@@ -0,0 +1,362 @@
// Plate generation — round-robin weighted fill with directional bias.
// Each plate gets a random growth rate and preferred direction.
import { makeRng, makeRandInt } from './rng.js';
import {
PLATE_LOW_PLATE_T_HIGH, PLATE_LOW_PLATE_T_RANGE,
PLATE_RATE_MIN_BASE, PLATE_RATE_MIN_LOW_T,
PLATE_RATE_RANGE_BASE, PLATE_RATE_RANGE_LOW_T,
PLATE_DIR_BASE_BASE, PLATE_DIR_BASE_LOW_T,
PLATE_DIR_SCALE_BASE, PLATE_DIR_SCALE_LOW_T,
PLATE_DIR_STRENGTH_CAP,
PLATE_COMPACT_BASE, PLATE_COMPACT_LOW_T,
PLATE_AREA_GOVERNOR_BASE, PLATE_AREA_GOVERNOR_LOW_T,
PLATE_COMPACT_THRESHOLD_MULT, PLATE_COMPACT_PENALTY_MULT,
PLATE_OMEGA_MIN, PLATE_OMEGA_RANGE,
PLATE_SMOOTH_BASE, PLATE_SMOOTH_LOW_T,
PLATE_SMOOTH_FIRST_THRESH, PLATE_SMOOTH_LATER_THRESH,
} from './terrain-config.js';
export function generatePlates(mesh, r_xyz, numPlates, seed) {
const { numRegions } = mesh;
const r_plate = new Int32Array(numRegions).fill(-1);
const rng = makeRng(seed + 0.5);
const randInt = makeRandInt(seed);
// Farthest-point seed distribution with top-3 jitter
const plateSeeds = new Set();
const isSeed = new Uint8Array(numRegions);
const minDistToSeed = new Float32Array(numRegions).fill(Infinity);
const firstSeed = randInt(numRegions);
plateSeeds.add(firstSeed);
isSeed[firstSeed] = 1;
const fsx = r_xyz[3*firstSeed], fsy = r_xyz[3*firstSeed+1], fsz = r_xyz[3*firstSeed+2];
for (let r = 0; r < numRegions; r++) {
minDistToSeed[r] = 1 - (r_xyz[3*r]*fsx + r_xyz[3*r+1]*fsy + r_xyz[3*r+2]*fsz);
}
minDistToSeed[firstSeed] = 0;
while (plateSeeds.size < numPlates && plateSeeds.size < numRegions) {
// Find top-3 farthest regions (flat vars, no object allocation)
let t0r = -1, t0d = -1, t1r = -1, t1d = -1, t2r = -1, t2d = -1;
for (let r = 0; r < numRegions; r++) {
if (isSeed[r]) continue;
const d = minDistToSeed[r];
if (d > t2d) {
if (d > t0d) {
t2r = t1r; t2d = t1d; t1r = t0r; t1d = t0d; t0r = r; t0d = d;
} else if (d > t1d) {
t2r = t1r; t2d = t1d; t1r = r; t1d = d;
} else {
t2r = r; t2d = d;
}
}
}
let validCount = (t0r !== -1) + (t1r !== -1) + (t2r !== -1);
if (!validCount) break;
const pick = randInt(validCount);
const newSeed = pick === 0 ? t0r : pick === 1 ? t1r : t2r;
plateSeeds.add(newSeed);
isSeed[newSeed] = 1;
const nsx = r_xyz[3*newSeed], nsy = r_xyz[3*newSeed+1], nsz = r_xyz[3*newSeed+2];
// Fused pass: update minDistToSeed from new seed AND find top-3 for next iteration
if (plateSeeds.size < numPlates) {
t0r = -1; t0d = -1; t1r = -1; t1d = -1; t2r = -1; t2d = -1;
for (let r = 0; r < numRegions; r++) {
const d = 1 - (r_xyz[3*r]*nsx + r_xyz[3*r+1]*nsy + r_xyz[3*r+2]*nsz);
if (d < minDistToSeed[r]) minDistToSeed[r] = d;
if (isSeed[r]) continue;
const md = minDistToSeed[r];
if (md > t2d) {
if (md > t0d) {
t2r = t1r; t2d = t1d; t1r = t0r; t1d = t0d; t0r = r; t0d = md;
} else if (md > t1d) {
t2r = t1r; t2d = t1d; t1r = r; t1d = md;
} else {
t2r = r; t2d = md;
}
}
}
// Next iteration can skip the search pass — top-3 is already computed
validCount = (t0r !== -1) + (t1r !== -1) + (t2r !== -1);
if (!validCount) break;
const pick2 = randInt(validCount);
const newSeed2 = pick2 === 0 ? t0r : pick2 === 1 ? t1r : t2r;
plateSeeds.add(newSeed2);
isSeed[newSeed2] = 1;
const ns2x = r_xyz[3*newSeed2], ns2y = r_xyz[3*newSeed2+1], ns2z = r_xyz[3*newSeed2+2];
for (let r = 0; r < numRegions; r++) {
const d = 1 - (r_xyz[3*r]*ns2x + r_xyz[3*r+1]*ns2y + r_xyz[3*r+2]*ns2z);
if (d < minDistToSeed[r]) minDistToSeed[r] = d;
}
} else {
// Last seed — just update distances (needed for distance field, but loop will exit)
for (let r = 0; r < numRegions; r++) {
const d = 1 - (r_xyz[3*r]*nsx + r_xyz[3*r+1]*nsy + r_xyz[3*r+2]*nsz);
if (d < minDistToSeed[r]) minDistToSeed[r] = d;
}
}
}
// Interpolation factor: more cragginess at low plate counts
const lowPlateT = Math.max(0, Math.min(1, (PLATE_LOW_PLATE_T_HIGH - numPlates) / PLATE_LOW_PLATE_T_RANGE));
// Per-plate growth properties
const plateGrowthRate = {};
const plateGrowthDir = {};
const plateDirStrength = {};
const rateMin = PLATE_RATE_MIN_BASE - PLATE_RATE_MIN_LOW_T * lowPlateT; // 0.7 → 0.3
const rateRange = PLATE_RATE_RANGE_BASE + PLATE_RATE_RANGE_LOW_T * lowPlateT; // 2.3 → 4.7
const dirBase = PLATE_DIR_BASE_BASE + PLATE_DIR_BASE_LOW_T * lowPlateT; // 0.15 → 0.4
const dirScale = PLATE_DIR_SCALE_BASE + PLATE_DIR_SCALE_LOW_T * lowPlateT; // 0.25 → 0.5
for (const center of plateSeeds) {
plateGrowthRate[center] = rateMin + rng() * rng() * rateRange;
const px = r_xyz[3*center], py = r_xyz[3*center+1], pz = r_xyz[3*center+2];
const pLen = Math.sqrt(px*px + py*py + pz*pz) || 1;
const nx = px/pLen, ny = py/pLen, nz = pz/pLen;
const rx = rng()-0.5, ry = rng()-0.5, rz = rng()-0.5;
const d = rx*nx + ry*ny + rz*nz;
let tx = rx - d*nx, ty = ry - d*ny, tz = rz - d*nz;
const tLen = Math.sqrt(tx*tx + ty*ty + tz*tz) || 1;
plateGrowthDir[center] = [tx/tLen, ty/tLen, tz/tLen];
plateDirStrength[center] = Math.min(PLATE_DIR_STRENGTH_CAP, rng() * (dirBase + dirScale / plateGrowthRate[center]));
}
// Per-plate frontiers — round-robin ensures every plate advances
const plateIds = Array.from(plateSeeds);
const frontiers = new Map();
const plateAreaCount = {};
for (const pid of plateIds) {
r_plate[pid] = pid;
frontiers.set(pid, [pid]);
plateAreaCount[pid] = 1;
}
const { adjOffset, adjList } = mesh;
let remaining = numRegions - plateIds.length;
const COMPACT_WEIGHT = PLATE_COMPACT_BASE - PLATE_COMPACT_LOW_T * lowPlateT; // 0.3 → 0.08
const expectedArea = Math.max(1, (numRegions - plateIds.length) / numPlates);
const areaGovernorMult = PLATE_AREA_GOVERNOR_BASE + PLATE_AREA_GOVERNOR_LOW_T * lowPlateT; // 2.0 → 4.0
const invNumRegions = 1 / numRegions;
while (remaining > 0) {
let anyProgress = false;
for (const pid of plateIds) {
const frontier = frontiers.get(pid);
if (frontier.length === 0) continue;
const rate = plateGrowthRate[pid];
const dir = plateGrowthDir[pid];
const d0 = dir[0], d1 = dir[1], d2 = dir[2];
const dirStr = plateDirStrength[pid];
const dirStrHalf = dirStr * 0.5;
let steps = Math.max(1, Math.ceil(rate * (0.5 + rng())));
// Governor: halve steps for plates exceeding threshold
if (plateAreaCount[pid] > expectedArea * areaGovernorMult) {
steps = Math.max(1, Math.ceil(steps * 0.5));
}
// Compactness: expected chord distance for a circular plate of current area
const expectedChordDist = Math.sqrt((plateAreaCount[pid] || 1) * invNumRegions / Math.PI) * 2;
const compactThreshold = expectedChordDist * PLATE_COMPACT_THRESHOLD_MULT;
// Precompute seed coordinates
const sx = r_xyz[3*pid], sy = r_xyz[3*pid+1], sz = r_xyz[3*pid+2];
for (let s = 0; s < steps && frontier.length > 0; s++) {
let bestIdx = 0, bestScore = -Infinity;
const samples = Math.min(frontier.length, 3 + Math.floor(dirStr * 5));
for (let i = 0; i < samples; i++) {
const idx = randInt(frontier.length);
const cell = frontier[idx];
const ci = 3*cell;
const dx = r_xyz[ci] - sx, dy = r_xyz[ci+1] - sy, dz = r_xyz[ci+2] - sz;
const dLenSq = dx*dx + dy*dy + dz*dz;
const dLen = Math.sqrt(dLenSq) || 1;
const alignment = (dx*d0 + dy*d1 + dz*d2) / dLen;
// Compactness: seedDist = dLenSq/2 for unit-sphere points
const excess = Math.max(0, dLenSq * 0.5 - compactThreshold);
const compactPenalty = excess * (COMPACT_WEIGHT * PLATE_COMPACT_PENALTY_MULT);
const score = alignment * dirStr + rng() * (1 - dirStrHalf) - compactPenalty;
if (score > bestScore) { bestScore = score; bestIdx = idx; }
}
const current = frontier[bestIdx];
frontier[bestIdx] = frontier[frontier.length - 1];
frontier.pop();
for (let j = adjOffset[current], jEnd = adjOffset[current + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (r_plate[nb] === -1) {
r_plate[nb] = pid;
frontier.push(nb);
plateAreaCount[pid]++;
remaining--;
anyProgress = true;
}
}
}
}
if (!anyProgress) break;
}
// Cleanup: assign orphaned regions to nearest claimed neighbor
let orphans = true;
while (orphans) {
orphans = false;
for (let r = 0; r < numRegions; r++) {
if (r_plate[r] === -1) {
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (r_plate[nb] !== -1) {
r_plate[r] = r_plate[nb];
orphans = true;
break;
}
}
}
}
}
smoothAndReconnectPlates(mesh, r_plate, plateSeeds, Math.round(PLATE_SMOOTH_BASE - PLATE_SMOOTH_LOW_T * lowPlateT));
// Assign an Euler pole + angular velocity per plate
const plateVec = {};
for (const center of plateSeeds) {
// Random Euler pole uniformly distributed on the sphere
const theta = rng() * 2 * Math.PI;
const cosP = 2 * rng() - 1;
const sinP = Math.sqrt(1 - cosP * cosP);
const pole = [sinP * Math.cos(theta), sinP * Math.sin(theta), cosP];
// Angular velocity: magnitude 0.5–2.0, random sign
const omega = (PLATE_OMEGA_MIN + rng() * PLATE_OMEGA_RANGE) * (rng() < 0.5 ? -1 : 1);
plateVec[center] = { pole, omega };
}
return { r_plate, plateSeeds, plateVec };
}
/**
* Smooth plate boundaries via majority-vote, then reconnect severed plates.
* @param {SphereMesh} mesh
* @param {Int32Array} r_plate — mutated in place
* @param {Set|Array} plateSeeds — seed region IDs (used for connectivity roots & protection)
* @param {number} numPasses — number of majority-vote smoothing passes
*/
export function smoothAndReconnectPlates(mesh, r_plate, plateSeeds, numPasses) {
const { numRegions, adjOffset, adjList } = mesh;
const plateIds = Array.from(plateSeeds);
// Build seed lookup for protection during smoothing.
// Protects plate seed regions from being reassigned by majority-vote.
// After coarse→hi-res projection the seed IDs are coarse-mesh indices
// that won't satisfy r_plate[pid] === pid on the hi-res mesh, so the
// array stays all-zeros and protection is effectively skipped — this is
// intentional since projected boundaries don't need seed anchoring.
const isSeed = new Uint8Array(numRegions);
for (const pid of plateIds) {
if (pid < numRegions && r_plate[pid] === pid) isSeed[pid] = 1;
}
// Smooth boundaries: majority-vote removes thin tendrils
let maxDeg = 0;
for (let r = 0; r < numRegions; r++) {
const deg = adjOffset[r + 1] - adjOffset[r];
if (deg > maxDeg) maxDeg = deg;
}
const cntPlates = new Int32Array(maxDeg);
const cntValues = new Uint8Array(maxDeg);
for (let pass = 0; pass < numPasses; pass++) {
const threshold = pass === 0 ? PLATE_SMOOTH_FIRST_THRESH : PLATE_SMOOTH_LATER_THRESH;
for (let r = 0; r < numRegions; r++) {
const rStart = adjOffset[r], rEnd = adjOffset[r + 1];
const deg = rEnd - rStart;
let nDistinct = 0;
for (let j = rStart; j < rEnd; j++) {
const p = r_plate[adjList[j]];
let found = false;
for (let k = 0; k < nDistinct; k++) {
if (cntPlates[k] === p) { cntValues[k]++; found = true; break; }
}
if (!found) { cntPlates[nDistinct] = p; cntValues[nDistinct] = 1; nDistinct++; }
}
let bestPlate = r_plate[r], bestCount = 0;
for (let k = 0; k < nDistinct; k++) {
if (cntValues[k] > bestCount) { bestCount = cntValues[k]; bestPlate = cntPlates[k]; }
}
if (bestCount > deg * threshold && !isSeed[r]) {
r_plate[r] = bestPlate;
}
}
}
// Reconnect: smoothing or projection may create disconnected plate fragments.
// For each plate, keep the LARGEST connected component and mark the rest
// for reassignment. This is stable across resolutions (unlike first-found).
{
const visited = new Uint8Array(numRegions);
// Per-plate: track the largest component's BFS list
const bestComponent = {}; // pid → [region indices]
for (let r = 0; r < numRegions; r++) {
if (visited[r]) continue;
const pid = r_plate[r];
const bfs = [r];
visited[r] = 1;
for (let qi = 0; qi < bfs.length; qi++) {
for (let ni = adjOffset[bfs[qi]], niEnd = adjOffset[bfs[qi] + 1]; ni < niEnd; ni++) {
const nb = adjList[ni];
if (!visited[nb] && r_plate[nb] === pid) {
visited[nb] = 1;
bfs.push(nb);
}
}
}
if (!bestComponent[pid] || bfs.length > bestComponent[pid].length) {
bestComponent[pid] = bfs;
}
}
// Mark regions in the largest component per plate
const inMain = new Uint8Array(numRegions);
for (const pid of Object.keys(bestComponent)) {
for (const r of bestComponent[pid]) inMain[r] = 1;
}
// Reassign orphaned regions (not in their plate's largest component)
// via BFS from the main-component boundary
const queue = [];
for (let r = 0; r < numRegions; r++) {
if (!inMain[r]) {
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
if (inMain[adjList[ni]]) {
r_plate[r] = r_plate[adjList[ni]];
inMain[r] = 1;
queue.push(r);
break;
}
}
}
}
for (let qi = 0; qi < queue.length; qi++) {
const r = queue[qi];
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
const nb = adjList[ni];
if (!inMain[nb]) {
r_plate[nb] = r_plate[r];
inMain[nb] = 1;
queue.push(nb);
}
}
}
}
}
+92
View File
@@ -0,0 +1,92 @@
// Greyscale PNG encoding, 8-bit and 16-bit.
//
// Both are here because the Unreal landscape export needs both - a 16-bit height and an 8-bit weightmap
// per paint layer per tile - and because a canvas cannot produce either. toBlob writes 8-bit RGBA and
// Unreal's landscape importer wants single-channel, so the weightmaps would have to be un-RGBA'd on the
// way in; the heights have no 16-bit canvas path at all. Writing the chunks directly is less code than
// working around either.
//
// Compression is CompressionStream('deflate'), which is the zlib wrapper PNG asks for, not the raw
// DEFLATE that 'deflate-raw' would give. Filter 0 (None) on every scanline: the rows here are either
// smooth height ramps or near-flat weight fields, and Paeth would cost a pass over the image to save a
// few per cent of a file that is written once and read once.
const _crc32Table = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c;
}
return t;
})();
function crc32(buf) {
let crc = 0xFFFFFFFF;
for (let i = 0; i < buf.length; i++) crc = _crc32Table[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8);
return (crc ^ 0xFFFFFFFF) >>> 0;
}
function chunk(type, data) {
const out = new Uint8Array(4 + 4 + data.length + 4);
const dv = new DataView(out.buffer);
dv.setUint32(0, data.length);
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
out.set(data, 8);
dv.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
return out;
}
async function assemble(width, height, bitDepth, raw) {
const ihdr = new Uint8Array(13);
const iv = new DataView(ihdr.buffer);
iv.setUint32(0, width);
iv.setUint32(4, height);
ihdr[8] = bitDepth;
ihdr[9] = 0; // colour type 0: greyscale
const cs = new CompressionStream('deflate');
const writer = cs.writable.getWriter();
writer.write(raw);
writer.close();
const body = new Uint8Array(await new Response(cs.readable).arrayBuffer());
const parts = [
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
chunk('IHDR', ihdr),
chunk('IDAT', body),
chunk('IEND', new Uint8Array(0)),
];
const png = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let at = 0;
for (const part of parts) { png.set(part, at); at += part.length; }
return new Blob([png], { type: 'image/png' });
}
/** 16-bit greyscale PNG from a Uint16Array of width * height, row-major. Big-endian, as PNG requires. */
export async function encodeGray16(width, height, data) {
const rowLen = 1 + width * 2;
const raw = new Uint8Array(height * rowLen);
for (let y = 0; y < height; y++) {
const off = y * rowLen;
raw[off] = 0;
for (let x = 0; x < width; x++) {
const v = data[y * width + x];
raw[off + 1 + x * 2] = (v >> 8) & 0xFF;
raw[off + 2 + x * 2] = v & 0xFF;
}
}
return assemble(width, height, 16, raw);
}
/** 8-bit greyscale PNG from a Uint8Array of width * height, row-major. */
export async function encodeGray8(width, height, data) {
const rowLen = 1 + width;
const raw = new Uint8Array(height * rowLen);
for (let y = 0; y < height; y++) {
const off = y * rowLen;
raw[off] = 0;
raw.set(data.subarray(y * width, (y + 1) * width), off + 1);
}
return assemble(width, height, 8, raw);
}
+686
View File
@@ -0,0 +1,686 @@
// Precipitation simulation: moisture advection driven by wind, ocean warmth,
// orographic effects, ITCZ uplift, frontal convergence, and polar fronts.
// Computes per-region precipitation for summer and winter seasons.
import { smoothstep } from './wind.js';
import { computeGradients } from './wind.js';
import { elevToHeightKm } from './color-map.js';
import { computeHeuristicPrecipitation, computeHeuristicWindField } from './heuristic-precip.js';
import { smoothField, makeItczLookup, percentile } from './climate-util.js';
const DEG = Math.PI / 180;
// ── Wind convergence ─────────────────────────────────────────────────────────
// Compute per-region convergence of the wind field. Negative divergence means
// winds are piling into a region (frontal zone / ITCZ-like uplift). We measure
// this as net inward flux: for each neighbor pair, how much does the neighbor's
// wind point toward us vs. our wind point toward the neighbor?
function computeWindConvergence(mesh, r_xyz,
r_wind3dX, r_wind3dY, r_wind3dZ) {
const { adjOffset, adjList, numRegions } = mesh;
const convergence = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
// Wind at r in 3D (pre-computed)
const wdx = r_wind3dX[r];
const wdy = r_wind3dY[r];
const wdz = r_wind3dZ[r];
let conv = 0;
let count = 0;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
// Direction from r to nb
const dx = r_xyz[3 * nb] - r_xyz[3 * r];
const dy = r_xyz[3 * nb + 1] - r_xyz[3 * r + 1];
const dz = r_xyz[3 * nb + 2] - r_xyz[3 * r + 2];
// inFlux - outFlux = -(nw·d) - (w·d) = -((nw + w)·d)
conv -= (r_wind3dX[nb] + wdx) * dx
+ (r_wind3dY[nb] + wdy) * dy
+ (r_wind3dZ[nb] + wdz) * dz;
count++;
}
// Normalize by neighbor count; positive = converging, negative = diverging
convergence[r] = count > 0 ? conv / count : 0;
}
return convergence;
}
// ── Upwind moisture advection ────────────────────────────────────────────────
// For each land cell, accumulate moisture from upwind neighbors.
// Moisture originates at coast cells proportional to ocean warmth and
// depletes with distance and elevation gain.
function advectMoisture(mesh, r_xyz, r_heightKm, r_isLand,
r_windE, r_windN,
r_wind3dX, r_wind3dY, r_wind3dZ,
r_oceanWarmth, r_coastDistLand, maxHops, avgEdgeKm) {
const { adjOffset, adjList, numRegions } = mesh;
const moisture = new Float32Array(numRegions);
// Initialize moisture: coastal land cells from adjacent ocean warmth,
// ocean cells from their own warmth
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r]) {
// Ocean cells: base moisture proportional to warmth
const warmth = r_oceanWarmth ? r_oceanWarmth[r] : 0;
moisture[r] = 0.4 + 0.35 * Math.max(0, warmth);
continue;
}
if (r_coastDistLand[r] !== 0) continue; // not a coast cell
// Coastal land cell — check for onshore wind
let warmthSum = 0;
let oceanCount = 0;
let oceanDirX = 0, oceanDirY = 0, oceanDirZ = 0;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (!r_isLand[nb]) {
oceanCount++;
if (r_oceanWarmth) warmthSum += r_oceanWarmth[nb];
oceanDirX += r_xyz[3 * nb] - r_xyz[3 * r];
oceanDirY += r_xyz[3 * nb + 1] - r_xyz[3 * r + 1];
oceanDirZ += r_xyz[3 * nb + 2] - r_xyz[3 * r + 2];
}
}
if (oceanCount === 0) continue;
const avgWarmth = warmthSum / oceanCount;
// Wind direction in 3D (pre-computed)
const wdx = r_wind3dX[r];
const wdy = r_wind3dY[r];
const wdz = r_wind3dZ[r];
// Onshore = wind blows FROM ocean toward land = wind dot (ocean→region) < 0
const windDotOcean = wdx * oceanDirX + wdy * oceanDirY + wdz * oceanDirZ;
const onshore = windDotOcean < 0 ? 1.0 : 0.25;
// Base moisture: warm currents provide more, cold currents less
const warmthFactor = 0.5 + 0.5 * Math.max(-0.8, Math.min(1, avgWarmth));
moisture[r] = onshore * warmthFactor;
}
// Base friction: ~78% moisture survives the full maxHops
// distance over flat terrain. Per-hop retention = 0.78^(1/maxHops).
const depletionBase = 1 - Math.pow(0.78, 1 / maxHops);
// Iterative downwind propagation (ping-pong double-buffering)
let src = moisture;
let dst = new Float32Array(numRegions);
for (let iter = 0; iter < maxHops; iter++) {
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r]) { dst[r] = src[r]; continue; }
const we = r_windE[r], wn = r_windN[r];
if (we * we + wn * wn < 1e-6) { dst[r] = src[r]; continue; }
// Wind direction in 3D (pre-computed)
const wdx = r_wind3dX[r];
const wdy = r_wind3dY[r];
const wdz = r_wind3dZ[r];
// Find upwind neighbors (those where wind at neighbor points toward us)
// Track weighted-average upwind elevation for gradient-based depletion
let upwindMoisture = 0;
let upwindWeight = 0;
let upwindHeightSum = 0;
const heightHere = r_heightKm[r];
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
// Direction from nb to r
const dx = r_xyz[3 * r] - r_xyz[3 * nb];
const dy = r_xyz[3 * r + 1] - r_xyz[3 * nb + 1];
const dz = r_xyz[3 * r + 2] - r_xyz[3 * nb + 2];
// Alignment: how much does wind at nb point toward r?
const dot = r_wind3dX[nb] * dx + r_wind3dY[nb] * dy + r_wind3dZ[nb] * dz;
if (dot > 0) {
upwindMoisture += src[nb] * dot;
upwindHeightSum += r_heightKm[nb] * dot;
upwindWeight += dot;
}
}
if (upwindWeight > 0) {
const incoming = upwindMoisture / upwindWeight;
const upwindHeight = upwindHeightSum / upwindWeight;
// Depletion depends on physical height GAIN (km) from upwind.
const heightGain = Math.max(0, heightHere - upwindHeight);
// Height gain per hop (km) shrinks at higher resolution.
// Multiply by maxHops to get total rise over the advection
// distance. A ~1 km total rise dumps significant moisture,
// ~2 km near-total.
const normalizedGain = heightGain * maxHops;
const elevDepletion = Math.min(0.8, normalizedGain * 0.55);
const depletion = depletionBase + elevDepletion;
const carried = incoming * Math.max(0, 1 - depletion);
dst[r] = Math.max(src[r], carried);
} else {
dst[r] = src[r];
}
}
// Swap buffers
const swap = src;
src = dst;
dst = swap;
}
return src;
}
// ── Main entry point ─────────────────────────────────────────────────────────
/**
* Compute seasonal precipitation fields.
*
* @param {SphereMesh} mesh
* @param {Float32Array} r_xyz - per-region 3D positions
* @param {Float32Array} r_elevation - per-region elevation
* @param {object} windResult - output from computeWind()
* @param {object} oceanResult - output from computeOceanCurrents()
* @returns {{ r_precip_summer, r_precip_winter }} normalized 0–1 arrays
*/
export function computePrecipitation(mesh, r_xyz, r_elevation, windResult, oceanResult, precipitationOffset = 0, landCoverage = 0.3) {
console.log('[precipitation.js] computePrecipitation called, numRegions:', mesh.numRegions);
const numRegions = mesh.numRegions;
const timing = [];
const { r_lat, r_lon, r_isLand, r_continentality,
r_eastX, r_eastY, r_eastZ,
r_northX, r_northY, r_northZ } = windResult;
// Scale-dependent hop count: ~2000 km reach.
// Average edge length ≈ π / sqrt(numRegions) radians ≈ (π * 6371) / sqrt(N) km
// hops ≈ 2000 / edgeLengthKm
const avgEdgeKm = (Math.PI * 6371) / Math.sqrt(numRegions);
const avgEdgeRad = Math.PI / Math.sqrt(numRegions);
const maxHops = Math.max(8, Math.min(20, Math.round(2000 / avgEdgeKm)));
// Coast distance through land — reuse BFS already computed by wind.js
const r_coastDistLand = windResult.r_coastDistLand;
// Elevation gradient for orographic detection (shared).
// Use a smoothed copy of elevation so local noise/crags don't fragment
// the large-scale windward/leeward signal at high resolutions.
// Target ~200 km smoothing radius — enough to average out terrain noise
// while preserving the broad mountain-range slope.
let t0 = performance.now();
const elevSmoothPasses = Math.max(2, Math.round(200 / avgEdgeKm));
const r_elevSmoothed = new Float32Array(r_elevation);
smoothField(mesh, r_elevSmoothed, elevSmoothPasses);
// Blend smoothed with actual: keeps broad slope signal but retains some local detail
for (let r = 0; r < numRegions; r++) {
r_elevSmoothed[r] = r_elevSmoothed[r] * 0.6 + r_elevation[r] * 0.4;
}
const r_elevGradE = new Float32Array(numRegions);
const r_elevGradN = new Float32Array(numRegions);
computeGradients(mesh, r_xyz, r_elevSmoothed,
r_eastX, r_eastY, r_eastZ, r_northX, r_northY, r_northZ,
r_elevGradE, r_elevGradN);
timing.push({ stage: 'Precip: elevation gradients (smoothed)', ms: performance.now() - t0 });
// Pre-compute height in km for advection and mechanisms (elevation is constant across seasons)
const r_heightKm = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
r_heightKm[r] = elevToHeightKm(Math.max(0, r_elevation[r]));
}
const result = {};
const seasons = [
{ name: 'summer', shift: 5 },
{ name: 'winter', shift: -5 }
];
for (const { name, shift } of seasons) {
t0 = performance.now();
const r_windE_raw = windResult[`r_wind_east_${name}`];
const r_windN_raw = windResult[`r_wind_north_${name}`];
const r_windSpeed = windResult[`r_wind_speed_${name}`];
const r_pressure = windResult[`r_pressure_${name}`];
const r_oceanWarmth = oceanResult[`r_ocean_warmth_${name}`];
const itczLookup = makeItczLookup(windResult.itczLons,
name === 'summer' ? windResult.itczLatsSummer : windResult.itczLatsWinter);
// ── Blend complex wind with heuristic zonal wind (50-50) ──
// Smooths out noisy pressure-derived wind patterns, strengthens
// zonal consistency for advection and orographic effects.
const { hWindE, hWindN } = computeHeuristicWindField(
numRegions, r_lat, r_lon, itczLookup);
const r_windE = new Float32Array(numRegions);
const r_windN = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
r_windE[r] = 0.5 * r_windE_raw[r] + 0.5 * hWindE[r];
r_windN[r] = 0.5 * r_windN_raw[r] + 0.5 * hWindN[r];
}
// Pre-compute 3D wind vectors for convergence and advection
const r_wind3dX = new Float32Array(numRegions);
const r_wind3dY = new Float32Array(numRegions);
const r_wind3dZ = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const we = r_windE[r], wn = r_windN[r];
r_wind3dX[r] = we * r_eastX[r] + wn * r_northX[r];
r_wind3dY[r] = we * r_eastY[r] + wn * r_northY[r];
r_wind3dZ[r] = we * r_eastZ[r] + wn * r_northZ[r];
}
// ── Step 1a: Wind convergence field ──
// Compute raw convergence then smooth heavily — real fronts are
// messy, mobile bands, not sharp lines. The smoothing spreads the
// signal over a wide area representing the zone where frontal
// weather systems wander over a season.
const r_convergence = computeWindConvergence(mesh, r_xyz,
r_wind3dX, r_wind3dY, r_wind3dZ);
// Smooth ~400 km worth of hops so frontal zones are broad bands
const convSmoothPasses = Math.max(3, Math.round(400 / avgEdgeKm));
smoothField(mesh, r_convergence, convSmoothPasses);
// ── Step 1b: Moisture advection from coasts ──
const moisture = advectMoisture(mesh, r_xyz, r_heightKm, r_isLand,
r_windE, r_windN,
r_wind3dX, r_wind3dY, r_wind3dZ,
r_oceanWarmth, r_coastDistLand, maxHops, avgEdgeKm);
const tAdvect = performance.now() - t0;
// ── Step 2: Apply precipitation mechanisms ──
t0 = performance.now();
const precip = new Float32Array(numRegions);
const rainShadow = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const lat = r_lat[r];
const lon = r_lon[r];
const absLatDeg = Math.abs(lat) / DEG;
const elev = r_elevation[r];
const isLand = r_isLand[r];
let p = moisture[r];
// (a) ITCZ uplift: boost moisture within ±15° of ITCZ
const itczLat = itczLookup(lon);
const distFromItcz = Math.abs(lat - itczLat) / DEG;
const cont = (isLand && r_continentality) ? r_continentality[r] : 0;
if (distFromItcz < 15) {
const itczStrength = smoothstep(15, 0, distFromItcz);
// Core ITCZ (within 5°): strong uplift and convective rain
const coreBoost = distFromItcz < 5 ? 1.5 : 1.0;
p = p * (1 + itczStrength * coreBoost) + itczStrength * 0.3;
}
// (b) Frontal precipitation: actual wind convergence
// Where winds collide (convergence > 0) air is forced upward,
// creating turbulence and wringing out whatever moisture is present.
// This naturally finds frontal zones, ITCZ-like convergence,
// and any other place where air masses meet.
const conv = r_convergence[r];
if (conv > 0) {
// Scale convergence: gentle convergence gives mild boost,
// strong convergence (opposing air masses) gives large boost.
// Only amplifies existing moisture — dry converging air
// doesn't produce rain.
// Raw convergence ∝ avgEdgeRad (neighbor displacements shrink
// at higher resolution), so normalize to make scale-invariant.
const convStrength = Math.min(1, (conv / avgEdgeRad) * 0.055);
p = p * (1 + convStrength * 1.2) + convStrength * moisture[r] * 0.4;
}
// (c) Orographic effects (land only)
// The advection step already handles gradient-based moisture loss
// per hop. This step adds the *local* precipitation boost on windward
// slopes (forced uplift squeezes out extra rain at that cell) and a
// moderate leeward shadow for any remaining moisture.
if (isLand && elev > 0) {
const we = r_windE[r], wn = r_windN[r];
// Windward uplift: wind dot elevation gradient
// Positive = wind blows upslope (windward), negative = downslope (leeward)
const windDotGrad = we * r_elevGradE[r] + wn * r_elevGradN[r];
if (windDotGrad > 0) {
// Windward: orographic enhancement — the steeper the slope
// the wind is pushing up, the more rain wrung out.
// gradient strength matters more than absolute height.
const uplift = Math.min(1, windDotGrad * 15);
p += uplift * 1.0;
} else {
// Leeward: rain shadow. The advection step already depleted
// moisture crossing the ridge; this is the *extra* suppression
// from descending/warming air (foehn drying) on the lee side.
const shadow = Math.min(1, -windDotGrad * 18);
p *= Math.max(0.02, 1 - shadow * 0.95);
}
}
// (d) Pressure-driven suppression/enhancement (hybrid)
// Start with a gentle latitude-band expectation for subtropical
// highs, then let the actual pressure field shift it — so the
// effect tracks real geography without being too aggressive.
const pDev = r_pressure[r]; // deviation from 1013 hPa
// Seasonal subtropical suppression: the subtropical high shifts
// poleward in local summer (creating Mediterranean dry summers)
// and retreats equatorward in local winter (allowing westerly rain).
const inLocalSummer = (name === 'summer') ? (lat >= 0) : (lat < 0);
const subtropCenter = inLocalSummer ? 30 : 24;
const subtropWidth = inLocalSummer ? 16 : 12;
let subtropPeak = inLocalSummer ? 0.50 : 0.30;
// East-coast monsoon relief: reduce summer drying where
// poleward winds bring tropical moisture onshore. On Earth
// this produces humid subtropical (Cfa) on east coasts
// while west coasts keep Mediterranean (Cs) dry summers.
if (isLand && inLocalSummer) {
const polewardWind = lat >= 0 ? r_windN[r] : -r_windN[r];
if (polewardWind > 0) {
const coastDist = r_coastDistLand[r] >= 0 ? r_coastDistLand[r] : maxHops;
const coastProximity = 1 - smoothstep(0, maxHops * 0.4, coastDist);
const monsoonRelief = smoothstep(0, 0.15, polewardWind) * coastProximity;
subtropPeak *= (1 - monsoonRelief * 0.7);
}
}
const subtropDist = Math.abs(absLatDeg - subtropCenter);
const latBandSuppression = subtropDist < subtropWidth
? smoothstep(subtropWidth, 0, subtropDist) * subtropPeak : 0;
// Pressure modifier: high pressure adds suppression, low reduces it
// Kept gentle — pressure nudges the baseline, doesn't overwhelm it.
let pressureMod = 0;
if (pDev > 0) {
pressureMod = smoothstep(0, 12, pDev) * 0.25; // extra suppression
} else {
pressureMod = -smoothstep(0, 15, -pDev) * 0.2; // relief / enhancement
}
const totalSuppression = Math.max(0, latBandSuppression + pressureMod);
if (totalSuppression > 0) {
p *= Math.max(0.05, 1 - totalSuppression);
} else {
// Net enhancement from low pressure outside subtropical belt
p *= (1 - totalSuppression); // totalSuppression is negative here
}
// (e) Polar front: diffuse precipitation at high latitudes
// The polar front is broad and pushes moisture deep inland —
// the blog cites ~2000 km downwind, ~1500 km crosswind from
// any coast, including coasts with offshore winds.
// It always brings *some* precipitation from its own cyclonic
// activity, even deep inland, plus a stronger coastal component.
if (absLatDeg > 40) {
const polarStrength = smoothstep(40, 70, absLatDeg);
const coastDist = r_coastDistLand[r] < 0 ? maxHops : r_coastDistLand[r];
const inlandFade = 1 - smoothstep(0, maxHops, coastDist);
// Base: always present regardless of coast distance
const polarBase = polarStrength * 0.10;
// Coastal enhancement: fades inland
const polarCoastal = polarStrength * 0.20 * inlandFade;
// Mostly enhances existing moisture, but adds some regardless
p += polarBase + polarCoastal;
p *= (1 + polarStrength * 0.15); // gentle multiplicative boost
}
// (f) Continental interior dryness
// Now that continentality is BFS-based (0 at coast, 0.5 at ~1000km,
// 1.0 at ~2000km), we can use it directly. Squared curve keeps
// near-coast areas gentle while ramping for deep interiors.
if (isLand && cont > 0) {
const dryness = cont * cont * 0.55;
p *= Math.max(0.03, 1 - dryness);
}
// (g) Lee cyclogenesis: localized wet zone on leeward side of high mountains
// when ocean is nearby downwind (~200 km)
const heightKm = r_heightKm[r];
if (isLand && heightKm > 1.5) {
const we = r_windE[r], wn = r_windN[r];
const windDotGrad = we * r_elevGradE[r] + wn * r_elevGradN[r];
// ~200 km in hops (scale-invariant)
const leeCoastHops = Math.max(2, Math.round(200 / avgEdgeKm));
if (windDotGrad < -0.01 && r_coastDistLand[r] >= 0 && r_coastDistLand[r] < leeCoastHops) {
p += 0.15 * Math.min(1, heightKm / 5);
}
}
// Ocean cells: precipitation over ocean (for visual completeness)
if (!isLand) {
// ITCZ and frontal zones already contribute above.
// Add baseline ocean precipitation, suppressed under high pressure
const highPressureFade = pDev > 0 ? smoothstep(0, 12, pDev) : 0;
const oceanBase = 0.15 * (1 - highPressureFade);
p = Math.max(p, oceanBase);
}
// (h) Hard distance-from-coast moisture cutoff
// Beyond ~2000 km from any coast, moisture drops off steeply.
// By 3000 km almost nothing remains.
if (isLand && r_coastDistLand[r] > 0) {
const distKm = r_coastDistLand[r] * avgEdgeKm;
if (distKm > 2000) {
const fade = 1 - smoothstep(2000, 3000, distKm);
p *= Math.max(0.03, fade);
}
}
const precipMult = 1 + precipitationOffset * 0.5;
let finalPrecip = p * precipMult;
if (landCoverage > 0.4) {
const t = (landCoverage - 0.4) / 0.6;
finalPrecip *= 1 - t * t * 0.98;
}
precip[r] = Math.max(0, finalPrecip);
}
const tMechanisms = performance.now() - t0;
// ── Step 2b: Rain shadow diagnostic — local source + bidirectional propagation ──
// Seed leeward slopes with negative shadow strength and windward slopes
// with positive orographic rain. Then propagate each in the correct
// direction: shadow travels DOWNWIND (foehn drying), windward rain
// extends UPWIND (rising air condenses approaching the mountains).
{
const { adjOffset, adjList } = mesh;
// Seed: local orographic effect at each cell
// Only significant terrain (≥0.8 km) seeds shadows — small hills
// shouldn't cast continent-scale rain shadows.
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r] || r_elevation[r] <= 0) continue;
const we = r_windE[r], wn = r_windN[r];
const windDotGrad = we * r_elevGradE[r] + wn * r_elevGradN[r];
const heightKm = r_heightKm[r];
if (heightKm < 0.8) continue; // skip low terrain
const heightScale = Math.min(1, (heightKm - 0.5) / 2.5);
if (windDotGrad > 0) {
rainShadow[r] = Math.min(1, windDotGrad * 20) * heightScale;
} else if (windDotGrad < 0) {
rainShadow[r] = -Math.min(1, -windDotGrad * 18) * heightScale;
}
}
// Pre-compute wind-aligned neighbor lists once — avoids
// redundant dot-product calculations inside every propagation
// iteration. Two sets: "upwind" (nb's wind points toward r,
// for shadow propagation) and "downwind" (r's wind points
// toward nb, for windward propagation).
const maxNbTotal = adjList.length;
const upNb = new Int32Array(maxNbTotal);
const upWt = new Float32Array(maxNbTotal);
const upOff = new Int32Array(numRegions + 1);
const dnNb = new Int32Array(maxNbTotal);
const dnWt = new Float32Array(maxNbTotal);
const dnOff = new Int32Array(numRegions + 1);
let upCount = 0, dnCount = 0;
for (let r = 0; r < numRegions; r++) {
upOff[r] = upCount;
dnOff[r] = dnCount;
if (!r_isLand[r]) continue;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
const dx = r_xyz[3 * r] - r_xyz[3 * nb];
const dy = r_xyz[3 * r + 1] - r_xyz[3 * nb + 1];
const dz = r_xyz[3 * r + 2] - r_xyz[3 * nb + 2];
// Upwind: wind at nb points toward r
const upDot = r_wind3dX[nb] * dx + r_wind3dY[nb] * dy + r_wind3dZ[nb] * dz;
if (upDot > 0) { upNb[upCount] = nb; upWt[upCount] = upDot; upCount++; }
// Downwind: wind at r points toward nb (direction is -dx,-dy,-dz)
const dnDot = -(r_wind3dX[r] * dx + r_wind3dY[r] * dy + r_wind3dZ[r] * dz);
if (dnDot > 0) { dnNb[dnCount] = nb; dnWt[dnCount] = dnDot; dnCount++; }
}
}
upOff[numRegions] = upCount;
dnOff[numRegions] = dnCount;
// --- Pass 1: Propagate shadow DOWNWIND (~2500 km, 15% survives) ---
const shadowHops = Math.max(8, Math.round(2500 / avgEdgeKm));
const shadowDecay = 1 - Math.pow(0.15, 1 / shadowHops);
const shadowField = new Float32Array(rainShadow);
// Reusable ping-pong buffers for both shadow and windward passes
let src = new Float32Array(shadowField);
let dst = new Float32Array(numRegions);
for (let iter = 0; iter < shadowHops; iter++) {
for (let r = 0; r < numRegions; r++) {
let upVal = 0, upW = 0;
const uEnd = upOff[r + 1];
for (let ui = upOff[r]; ui < uEnd; ui++) {
const val = src[upNb[ui]];
if (val < 0) { upVal += val * upWt[ui]; upW += upWt[ui]; }
}
if (upW > 0) {
const carried = (upVal / upW) * (1 - shadowDecay);
dst[r] = Math.min(src[r], carried);
} else {
dst[r] = src[r];
}
}
const swap = src; src = dst; dst = swap;
}
for (let r = 0; r < numRegions; r++) {
if (src[r] < shadowField[r]) shadowField[r] = src[r];
}
// --- Pass 2: Propagate windward rain UPWIND (~1500 km, 25% survives) ---
const windwardHops = Math.max(6, Math.round(1500 / avgEdgeKm));
const windwardDecay = 1 - Math.pow(0.25, 1 / windwardHops);
const windwardField = new Float32Array(rainShadow);
// Reuse ping-pong buffers from shadow pass
src.set(windwardField);
dst.fill(0);
for (let iter = 0; iter < windwardHops; iter++) {
for (let r = 0; r < numRegions; r++) {
let dnVal = 0, dnW = 0;
const dEnd = dnOff[r + 1];
for (let di = dnOff[r]; di < dEnd; di++) {
const val = src[dnNb[di]];
if (val > 0) { dnVal += val * dnWt[di]; dnW += dnWt[di]; }
}
if (dnW > 0) {
const carried = (dnVal / dnW) * (1 - windwardDecay);
dst[r] = Math.max(src[r], carried);
} else {
dst[r] = src[r];
}
}
const swap = src; src = dst; dst = swap;
}
for (let r = 0; r < numRegions; r++) {
if (src[r] > windwardField[r]) windwardField[r] = src[r];
}
// Merge: shadow dominates if present, otherwise take windward
for (let r = 0; r < numRegions; r++) {
rainShadow[r] = shadowField[r] < 0 ? shadowField[r] : windwardField[r];
}
}
// Smooth ~150 km so the zones read clearly
const rsSmoothPasses = Math.max(2, Math.round(150 / avgEdgeKm));
smoothField(mesh, rainShadow, rsSmoothPasses);
// ── Step 2c: Apply propagated rain shadow to actual precipitation ──
// The local orographic effect in (c) only touches the mountain slopes
// themselves. This step extends the shadow hundreds of km downwind and
// boosts windward rain upwind, using the propagated field from 2b.
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r]) continue;
const rs = rainShadow[r];
if (rs < -0.01) {
// Shadow zone: precipitation suppression behind mountains
const strength = Math.min(1, -rs * 2.25);
precip[r] *= Math.max(0.02, 1 - strength * 0.92);
} else if (rs > 0.01) {
// Windward zone: strong orographic precipitation enhancement
precip[r] += rs * 1.2;
}
}
// ── Step 3: Smooth (normalization deferred to blending step) ──
t0 = performance.now();
// Light smoothing ~100 km to blend cell-to-cell noise
const precipSmoothPasses = Math.max(1, Math.round(100 / avgEdgeKm));
smoothField(mesh, precip, precipSmoothPasses);
const tSmooth = performance.now() - t0;
timing.push({ stage: `Precip: advection (${name})`, ms: tAdvect });
timing.push({ stage: `Precip: mechanisms (${name})`, ms: tMechanisms });
timing.push({ stage: `Precip: smooth (${name})`, ms: tSmooth });
result[`r_precip_${name}`] = precip;
result[`r_rainshadow_${name}`] = rainShadow;
}
// ── Step 4: Blend with heuristic model and normalize ──
t0 = performance.now();
const heuristic = computeHeuristicPrecipitation(mesh, r_xyz, r_elevation, windResult, r_elevGradE, r_elevGradN, r_coastDistLand);
for (const seasonName of ['summer', 'winter']) {
const complex = result[`r_precip_${seasonName}`];
const heur = heuristic[`r_precip_${seasonName}`];
const blended = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
blended[r] = 0.5 * complex[r] + 0.5 * heur[r];
}
// 95th-percentile normalization on blended result
const maxPrecip = percentile(blended, 0.95);
for (let r = 0; r < numRegions; r++) {
blended[r] = Math.min(1, blended[r] / maxPrecip);
}
// Continental interior cap: interior regions can't exceed steppe-level
// precipitation. At cont=1.0, cap is 0.20 per season (≈ 200mm
// half-year → 400mm annual — solidly in steppe territory). Fades in
// from cont 0.5 so the transition is gradual. Other factors (desert
// factory, rain shadows, distance cutoff) can still push lower.
const r_continentality = windResult.r_continentality;
if (r_continentality) {
for (let r = 0; r < numRegions; r++) {
if (r_isLand[r] && r_continentality[r] > 0.5) {
const t = smoothstep(0.5, 1.0, r_continentality[r]);
const cap = 1.0 - t * 0.80; // 1.0 at cont=0.5, 0.20 at cont=1.0
blended[r] = Math.min(blended[r], cap);
}
}
}
result[`r_precip_${seasonName}`] = blended;
}
timing.push({ stage: 'Precip: heuristic blend+normalize', ms: performance.now() - t0 });
result._precipTiming = timing;
return result;
}
+11
View File
@@ -0,0 +1,11 @@
// Seeded RNG — deterministic pseudo-random number generators.
export function makeRng(seed) {
let s = (Math.abs(Math.floor(seed * 9301 + 49297)) % 2147483646) + 1;
return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; };
}
export function makeRandInt(seed) {
const r = makeRng(seed);
return (n) => Math.floor(r() * n);
}
+175
View File
@@ -0,0 +1,175 @@
// Three.js scene setup: renderer, cameras, controls, lights, atmosphere, water, stars.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
export const canvas = document.getElementById('canvas');
export const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
export const scene = new THREE.Scene();
scene.background = new THREE.Color(0x030308);
export const camera = new THREE.PerspectiveCamera(50, innerWidth/innerHeight, 0.1, 200);
camera.position.set(0, 0.4, 2.8);
export const ctrl = new OrbitControls(camera, canvas);
ctrl.enableDamping = true; ctrl.dampingFactor = 0.06;
ctrl.enablePan = false;
ctrl.minDistance = 1.4; ctrl.maxDistance = 8;
ctrl.enableZoom = false; // disable built-in zoom; custom handler below
// Smooth zoom: wheel sets a target distance, each frame lerps toward it
let _zoomTarget = camera.position.distanceTo(ctrl.target);
const ZOOM_STEP = 0.92; // multiplier per tick (lower = faster zoom)
const ZOOM_SMOOTH = 0.12; // lerp speed per frame (higher = snappier)
canvas.addEventListener('wheel', (e) => {
if (!ctrl.enabled) return;
e.preventDefault();
const dir = Math.sign(e.deltaY);
_zoomTarget *= dir > 0 ? 1 / ZOOM_STEP : ZOOM_STEP;
_zoomTarget = THREE.MathUtils.clamp(_zoomTarget, ctrl.minDistance, ctrl.maxDistance);
}, { passive: false });
// Pinch-to-zoom for globe (touch)
let _pinchDist = 0;
canvas.addEventListener('touchstart', (e) => {
if (!ctrl.enabled || e.touches.length !== 2) { _pinchDist = 0; return; }
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
_pinchDist = Math.sqrt(dx * dx + dy * dy);
}, { passive: true });
canvas.addEventListener('touchmove', (e) => {
if (!ctrl.enabled || e.touches.length !== 2 || _pinchDist === 0) return;
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const dist = Math.sqrt(dx * dx + dy * dy);
const ratio = _pinchDist / dist;
_zoomTarget *= ratio;
_zoomTarget = THREE.MathUtils.clamp(_zoomTarget, ctrl.minDistance, ctrl.maxDistance);
_pinchDist = dist;
}, { passive: true });
canvas.addEventListener('touchend', () => { _pinchDist = 0; }, { passive: true });
export function tickZoom() {
const v = new THREE.Vector3().subVectors(camera.position, ctrl.target);
const cur = v.length();
const next = THREE.MathUtils.lerp(cur, _zoomTarget, ZOOM_SMOOTH);
if (Math.abs(next - cur) < 0.0001) return;
v.setLength(next);
camera.position.copy(ctrl.target).add(v);
}
scene.add(new THREE.AmbientLight(0xaabbcc, 3.5));
export const sun = new THREE.DirectionalLight(0xfff8ee, 1.5);
sun.position.set(5, 3, 4);
scene.add(sun);
// Stars
export let starsMesh;
{ const g=new THREE.BufferGeometry(),p=[];
for(let i=0;i<3000;i++){const th=Math.random()*Math.PI*2,ph=Math.acos(2*Math.random()-1),r=40+Math.random()*30;
p.push(r*Math.sin(ph)*Math.cos(th),r*Math.sin(ph)*Math.sin(th),r*Math.cos(ph));}
g.setAttribute('position',new THREE.Float32BufferAttribute(p,3));
starsMesh = new THREE.Points(g,new THREE.PointsMaterial({color:0xffffff,size:0.08}));
scene.add(starsMesh); }
// Atmosphere
const atmosMat = new THREE.ShaderMaterial({
uniforms:{c:{value:new THREE.Color(0.35,0.6,1.0)}},
vertexShader:`varying vec3 vN,vP;void main(){vN=normalize(normalMatrix*normal);vP=(modelViewMatrix*vec4(position,1)).xyz;gl_Position=projectionMatrix*vec4(vP,1);}`,
fragmentShader:`uniform vec3 c;varying vec3 vN,vP;void main(){float r=1.0-max(0.0,dot(normalize(-vP),vN));gl_FragColor=vec4(c,pow(r,3.5)*0.55);}`,
transparent:true,side:THREE.FrontSide,depthWrite:false
});
export const atmosMesh = new THREE.Mesh(new THREE.SphereGeometry(1.12,64,64), atmosMat);
scene.add(atmosMesh);
// Water sphere
const waterMat = new THREE.MeshPhongMaterial({
color:0x0c3a6e, transparent:true, opacity:0.55,
shininess:120, specular:0x4488bb, depthWrite:false
});
export const waterMesh = new THREE.Mesh(new THREE.SphereGeometry(1.0,80,80), waterMat);
scene.add(waterMesh);
// Equirectangular map camera & controls
export const mapCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100);
mapCamera.position.set(0, 0, 5);
mapCamera.lookAt(0, 0, 0);
export function updateMapCameraFrustum() {
const aspect = innerWidth / innerHeight;
const mapAspect = 2;
let halfW, halfH;
if (aspect > mapAspect) {
halfH = 1.15;
halfW = halfH * aspect;
} else {
halfW = 2.3;
halfH = halfW / aspect;
}
mapCamera.left = -halfW; mapCamera.right = halfW;
mapCamera.top = halfH; mapCamera.bottom = -halfH;
mapCamera.updateProjectionMatrix();
}
updateMapCameraFrustum();
export const mapCtrl = new OrbitControls(mapCamera, canvas);
mapCtrl.enableRotate = false;
mapCtrl.enableDamping = true;
mapCtrl.dampingFactor = 0.09;
mapCtrl.panSpeed = 1.4;
mapCtrl.screenSpacePanning = true;
mapCtrl.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.PAN };
mapCtrl.touches = { ONE: THREE.TOUCH.PAN, TWO: THREE.TOUCH.DOLLY_PAN };
mapCtrl.minZoom = 0.5;
mapCtrl.maxZoom = 20;
mapCtrl.enableZoom = false; // custom handler below
mapCtrl.enabled = false;
// Smooth zoom for map view (orthographic)
let _mapZoomTarget = mapCamera.zoom;
const MAP_ZOOM_STEP = 0.92;
const MAP_ZOOM_SMOOTH = 0.12;
canvas.addEventListener('wheel', (e) => {
if (!mapCtrl.enabled) return;
e.preventDefault();
const dir = Math.sign(e.deltaY);
_mapZoomTarget *= dir < 0 ? 1 / MAP_ZOOM_STEP : MAP_ZOOM_STEP;
_mapZoomTarget = THREE.MathUtils.clamp(_mapZoomTarget, mapCtrl.minZoom, mapCtrl.maxZoom);
}, { passive: false });
// Pinch-to-zoom for map (touch)
let _mapPinchDist = 0;
canvas.addEventListener('touchstart', (e) => {
if (!mapCtrl.enabled || e.touches.length !== 2) { _mapPinchDist = 0; return; }
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
_mapPinchDist = Math.sqrt(dx * dx + dy * dy);
}, { passive: true });
canvas.addEventListener('touchmove', (e) => {
if (!mapCtrl.enabled || e.touches.length !== 2 || _mapPinchDist === 0) return;
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const dist = Math.sqrt(dx * dx + dy * dy);
const ratio = dist / _mapPinchDist;
_mapZoomTarget *= ratio;
_mapZoomTarget = THREE.MathUtils.clamp(_mapZoomTarget, mapCtrl.minZoom, mapCtrl.maxZoom);
_mapPinchDist = dist;
}, { passive: true });
canvas.addEventListener('touchend', () => { _mapPinchDist = 0; }, { passive: true });
export function tickMapZoom() {
const cur = mapCamera.zoom;
const next = THREE.MathUtils.lerp(cur, _mapZoomTarget, MAP_ZOOM_SMOOTH);
if (Math.abs(next - cur) < 0.0001) return;
mapCamera.zoom = next;
mapCamera.updateProjectionMatrix();
}
+54
View File
@@ -0,0 +1,54 @@
// Simplex Noise 3D with fBm and ridged fBm variants.
import { makeRng } from './rng.js';
export class SimplexNoise {
constructor(seed = 0) {
this.G = [[1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],[1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1],[0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1]];
const rng = makeRng(seed);
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
for (let i = 255; i > 0; i--) { const j = Math.floor(rng()*(i+1)); [p[i],p[j]]=[p[j],p[i]]; }
this.perm = new Uint8Array(512);
this.pm12 = new Uint8Array(512);
for (let i = 0; i < 512; i++) { this.perm[i] = p[i&255]; this.pm12[i] = this.perm[i]%12; }
}
noise3D(x,y,z) {
const F=1/3,H=1/6,s=(x+y+z)*F;
const i=Math.floor(x+s),j=Math.floor(y+s),k=Math.floor(z+s);
const t=(i+j+k)*H,x0=x-i+t,y0=y-j+t,z0=z-k+t;
let i1,j1,k1,i2,j2,k2;
if(x0>=y0){if(y0>=z0){i1=1;j1=0;k1=0;i2=1;j2=1;k2=0;}else if(x0>=z0){i1=1;j1=0;k1=0;i2=1;j2=0;k2=1;}else{i1=0;j1=0;k1=1;i2=1;j2=0;k2=1;}}
else{if(y0<z0){i1=0;j1=0;k1=1;i2=0;j2=1;k2=1;}else if(x0<z0){i1=0;j1=1;k1=0;i2=0;j2=1;k2=1;}else{i1=0;j1=1;k1=0;i2=1;j2=1;k2=0;}}
const x1=x0-i1+H,y1=y0-j1+H,z1=z0-k1+H,x2=x0-i2+2*H,y2=y0-j2+2*H,z2=z0-k2+2*H,x3=x0-1+3*H,y3=y0-1+3*H,z3=z0-1+3*H;
const ii=i&255,jj=j&255,kk=k&255,{perm:P,pm12:M,G:g}=this;
let n0=0,n1=0,n2=0,n3=0;
let a=0.6-x0*x0-y0*y0-z0*z0;if(a>0){a*=a;const v=g[M[ii+P[jj+P[kk]]]];n0=a*a*(v[0]*x0+v[1]*y0+v[2]*z0);}
let b=0.6-x1*x1-y1*y1-z1*z1;if(b>0){b*=b;const v=g[M[ii+i1+P[jj+j1+P[kk+k1]]]];n1=b*b*(v[0]*x1+v[1]*y1+v[2]*z1);}
let c=0.6-x2*x2-y2*y2-z2*z2;if(c>0){c*=c;const v=g[M[ii+i2+P[jj+j2+P[kk+k2]]]];n2=c*c*(v[0]*x2+v[1]*y2+v[2]*z2);}
let d=0.6-x3*x3-y3*y3-z3*z3;if(d>0){d*=d;const v=g[M[ii+1+P[jj+1+P[kk+1]]]];n3=d*d*(v[0]*x3+v[1]*y3+v[2]*z3);}
return 32*(n0+n1+n2+n3);
}
fbm(x,y,z,octaves=5,persistence=2/3) {
let sum=0,max=0,amp=1;
for(let o=0;o<octaves;o++){const f=1<<o;sum+=amp*this.noise3D(x*f,y*f,z*f);max+=amp;amp*=persistence;}
return sum/max;
}
ridgedFbm(x, y, z, octaves = 6, lacunarity = 2.0, gain = 0.5, offset = 1.0) {
let sum = 0, freq = 1, amp = 1, prev = 1, maxVal = 0;
for (let o = 0; o < octaves; o++) {
let n = this.noise3D(x * freq, y * freq, z * freq);
n = offset - Math.abs(n);
n = n * n;
sum += n * amp * prev;
maxVal += amp;
prev = Math.min(n, 1);
freq *= lacunarity;
amp *= gain;
}
return sum / maxVal;
}
}
+219
View File
@@ -0,0 +1,219 @@
// Sphere mesh construction: Fibonacci sphere → Delaunay → close pole → SphereMesh.
// Adapted from Red Blob Games sphere-mesh.js.
let _Delaunator = null;
export function setDelaunator(D) { _Delaunator = D; }
// Fibonacci sphere with jitter — evenly-distributed points using the
// Fibonacci spiral. Jitter randomises positions for more organic Voronoi cells.
export function generateFibonacciSphere(N, jitter, rng) {
const r_xyz = new Float32Array(3 * N);
const s = 3.6 / Math.sqrt(N);
const dlong = Math.PI * (3 - Math.sqrt(5));
const dz = 2.0 / N;
for (let k = 0, lng = 0, z = 1 - dz / 2; k < N; k++, z -= dz) {
const r = Math.sqrt(1 - z * z);
let latDeg = Math.asin(z) * 180 / Math.PI;
let lonDeg = lng * 180 / Math.PI;
if (jitter > 0) {
const jLat = (rng() - rng());
const jLon = (rng() - rng());
const nextZ = Math.max(-1, z - dz * 2 * Math.PI * r / s);
latDeg += jitter * jLat * (latDeg - Math.asin(nextZ) * 180 / Math.PI);
lonDeg += jitter * jLon * (s / r * 180 / Math.PI);
}
const latR = latDeg * Math.PI / 180;
const lonR = lonDeg * Math.PI / 180;
r_xyz[3*k] = Math.cos(latR) * Math.cos(lonR);
r_xyz[3*k+1] = Math.cos(latR) * Math.sin(lonR);
r_xyz[3*k+2] = Math.sin(latR);
lng += dlong;
}
return r_xyz;
}
// Stereographic projection (for Delaunay on a sphere).
// Projects every point from the "north pole" (0,0,1) onto a plane.
export function stereographicProjection(r_xyz, N) {
const flat = new Float64Array(2 * N);
for (let i = 0; i < N; i++) {
const z = r_xyz[3*i+2];
// Clamp denominator to prevent Infinity when a jittered point lands
// on or near the projection pole (z ≈ 1). The exact projected position
// doesn't matter for near-pole points — addPoleToMesh corrects connectivity.
const denom = Math.max(1e-12, 1 - z);
flat[2*i] = r_xyz[3*i] / denom;
flat[2*i+1] = r_xyz[3*i+1] / denom;
}
return flat;
}
// Add pole back into mesh — close the mesh by connecting hull edges to the pole.
export function addPoleToMesh(poleId, triangles, halfedges) {
const numSides = triangles.length;
const next = s => (s % 3 === 2) ? s - 2 : s + 1;
let numUnpaired = 0, firstUnpaired = -1;
const pointToSide = [];
for (let s = 0; s < numSides; s++) {
if (halfedges[s] === -1) {
numUnpaired++;
pointToSide[triangles[s]] = s;
firstUnpaired = s;
}
}
const nt = new Int32Array(numSides + 3 * numUnpaired);
const nh = new Int32Array(numSides + 3 * numUnpaired);
nt.set(triangles);
nh.set(halfedges);
for (let i = 0, s = firstUnpaired;
i < numUnpaired;
i++, s = pointToSide[nt[next(s)]]) {
const ns = numSides + 3 * i;
nh[s] = ns;
nh[ns] = s;
nt[ns] = nt[next(s)];
nt[ns + 1] = nt[s];
nt[ns + 2] = poleId;
const k = numSides + (3 * i + 4) % (3 * numUnpaired);
nh[ns + 2] = k;
nh[k] = ns + 2;
}
return { triangles: nt, halfedges: nh };
}
// Lightweight dual-mesh helper wrapping Delaunator output.
// Regions = Voronoi cells, Triangles = Delaunay triangles, Sides = half-edges.
export class SphereMesh {
constructor(triangles, halfedges, numRegions) {
this.triangles = triangles;
this.halfedges = halfedges;
this.numRegions = numRegions;
this.numSides = triangles.length;
this.numTriangles = (triangles.length / 3) | 0;
this._r_s = new Int32Array(numRegions).fill(-1);
for (let s = 0; s < this.numSides; s++) {
const r = triangles[s];
if (this._r_s[r] === -1) this._r_s[r] = s;
}
// Pre-compute flat adjacency lists for r_circulate_r and r_circulate_t.
// Replaces per-call half-edge traversal with cache-friendly array reads.
const adjCount = new Int32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const s0 = this._r_s[r];
if (s0 === -1) continue;
let s = s0;
do {
adjCount[r]++;
s = this._next(this.halfedges[s]);
} while (s !== s0);
}
this._adjOffset = new Int32Array(numRegions + 1);
for (let r = 0; r < numRegions; r++) {
this._adjOffset[r + 1] = this._adjOffset[r] + adjCount[r];
}
const totalAdj = this._adjOffset[numRegions];
this._adjList = new Int32Array(totalAdj); // neighbor regions
this._adjTriList = new Int32Array(totalAdj); // neighbor triangles
for (let r = 0; r < numRegions; r++) {
const s0 = this._r_s[r];
if (s0 === -1) continue;
let s = s0;
let idx = this._adjOffset[r];
do {
this._adjList[idx] = this.s_end_r(s);
this._adjTriList[idx] = this.s_inner_t(s);
idx++;
s = this._next(this.halfedges[s]);
} while (s !== s0);
}
// Public aliases for direct adjacency iteration (avoids r_circulate_r copy overhead)
this.adjOffset = this._adjOffset;
this.adjList = this._adjList;
}
_next(s) { return (s % 3 === 2) ? s - 2 : s + 1; }
s_begin_r(s){ return this.triangles[s]; }
s_end_r(s) { return this.triangles[this._next(s)]; }
s_inner_t(s){ return (s / 3) | 0; }
s_outer_t(s){ return (this.halfedges[s] / 3) | 0; }
r_circulate_r(out, r) {
const start = this._adjOffset[r];
const end = this._adjOffset[r + 1];
const len = end - start;
out.length = len;
for (let i = 0; i < len; i++) out[i] = this._adjList[start + i];
return out;
}
r_circulate_t(out, r) {
const start = this._adjOffset[r];
const end = this._adjOffset[r + 1];
const len = end - start;
out.length = len;
for (let i = 0; i < len; i++) out[i] = this._adjTriList[start + i];
return out;
}
}
// Build sphere — Fibonacci points → Delaunay → close pole.
export function buildSphere(N, jitter, rng) {
const r_xyz = generateFibonacciSphere(N, jitter, rng);
const flat = stereographicProjection(r_xyz, N);
const delaunay = new _Delaunator(flat);
const poleXYZ = new Float32Array(3 * (N + 1));
poleXYZ.set(r_xyz);
poleXYZ[3*N] = 0; poleXYZ[3*N+1] = 0; poleXYZ[3*N+2] = 1;
const closed = addPoleToMesh(N, delaunay.triangles, delaunay.halfedges);
const mesh = new SphereMesh(closed.triangles, closed.halfedges, N + 1);
return { mesh, r_xyz: poleXYZ };
}
// Pre-compute Euclidean distance between each region and its neighbors.
// Indexed by the same adjacency slot as adjList: neighborDist[i] is the
// distance from region r to adjList[i] where adjOffset[r] <= i < adjOffset[r+1].
export function computeNeighborDist(mesh, r_xyz) {
const { adjOffset, adjList } = mesh;
const neighborDist = new Float32Array(adjList.length);
for (let r = 0; r < mesh.numRegions; r++) {
const x = r_xyz[3*r], y = r_xyz[3*r+1], z = r_xyz[3*r+2];
for (let i = adjOffset[r]; i < adjOffset[r+1]; i++) {
const nb = adjList[i];
const dx = x - r_xyz[3*nb], dy = y - r_xyz[3*nb+1], dz = z - r_xyz[3*nb+2];
neighborDist[i] = Math.sqrt(dx*dx + dy*dy + dz*dz);
}
}
return neighborDist;
}
// Triangle centres (= Voronoi vertices on the sphere).
export function generateTriangleCenters(mesh, r_xyz) {
const { numTriangles } = mesh;
const t_xyz = new Float32Array(3 * numTriangles);
for (let t = 0; t < numTriangles; t++) {
const s0 = 3 * t;
const a = mesh.s_begin_r(s0),
b = mesh.s_begin_r(s0 + 1),
c = mesh.s_begin_r(s0 + 2);
t_xyz[3*t] = (r_xyz[3*a] + r_xyz[3*b] + r_xyz[3*c]) / 3;
t_xyz[3*t+1] = (r_xyz[3*a+1]+r_xyz[3*b+1]+r_xyz[3*c+1]) / 3;
t_xyz[3*t+2] = (r_xyz[3*a+2]+r_xyz[3*b+2]+r_xyz[3*c+2]) / 3;
}
return t_xyz;
}
+41
View File
@@ -0,0 +1,41 @@
// Shared mutable application state.
// All modules import this same object, so mutations are visible everywhere.
export const state = {
planetMesh: null,
wireMesh: null,
arrowGroup: null,
windArrowGroup: null,
curData: null,
plateColors: {},
_hoverBackup: null,
hoveredPlate: -1,
hoveredRegion: -1,
hoveredKoppen: -1,
_koppenHoverBackup: null,
_mapKoppenHoverBackup: null,
mapMesh: null,
mapFaceToSide: null,
_mapHoverBackup: null,
mapGridMesh: null,
globeGridMesh: null,
gridEnabled: true,
gridSpacing: 15,
mapMode: false,
mapCenterLon: 0,
dragStart: null,
debugLayer: '',
isTouchDevice: ('ontouchstart' in window) || (navigator.maxTouchPoints > 0),
editMode: false,
oceanCurrentArrowGroup: null,
climateComputed: false,
pendingToggles: new Set(),
_pendingBackup: null,
_mapPendingBackup: null,
importedHeightmap: false,
// The painted map's overlay sheet (painted-overlay-view.js): the classified marks and their texture,
// the meshes that show it on the globe and the map, and whether the toggle asks for it.
overlay: null,
overlayVisible: false,
overlayGlobeMesh: null,
overlayMapMesh: null,
};
+273
View File
@@ -0,0 +1,273 @@
// Super plates: groups connected same-type plates into ~20 larger tectonic
// units that move cohesively, producing broad orogenic belts while preserving
// fine-grained detail from individual plate interactions.
/**
* Build super plate assignments from individual plates.
*
* @param {Object} mesh Sphere mesh (adjOffset, adjList, numRegions)
* @param {Int32Array} r_plate Region → plate seed ID
* @param {Set} plateSeeds Set of all plate seed IDs
* @param {Object} plateVec plate seed → { pole: [x,y,z], omega }
* @param {Set} plateIsOcean Set of ocean plate seed IDs
* @param {Object} plateDensity plate seed → density value
* @returns {{ r_superPlate, superPlateVec, superPlateIsOcean, superPlateDensity, numSuperPlates }}
*/
export function buildSuperPlates(mesh, r_plate, plateSeeds, plateVec, plateIsOcean, plateDensity) {
const { numRegions, adjOffset, adjList } = mesh;
const numPlates = plateSeeds.size;
// 1. Count regions per plate (plate areas)
const plateArea = {};
for (const pid of plateSeeds) plateArea[pid] = 0;
for (let r = 0; r < numRegions; r++) {
plateArea[r_plate[r]]++;
}
// 2. Build plate adjacency graph
// plateNeighbors: pid → Set of neighbor plate IDs
const plateNeighbors = {};
for (const pid of plateSeeds) plateNeighbors[pid] = new Set();
for (let r = 0; r < numRegions; r++) {
const myPlate = r_plate[r];
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
const nbPlate = r_plate[adjList[ni]];
if (nbPlate !== myPlate) {
plateNeighbors[myPlate].add(nbPlate);
}
}
}
// 3. Connected components of same-type plates (BFS on plate graph)
const plateVisited = new Set();
const components = []; // each: array of plate seed IDs
for (const pid of plateSeeds) {
if (plateVisited.has(pid)) continue;
const isOcean = plateIsOcean.has(pid);
const comp = [];
const queue = [pid];
plateVisited.add(pid);
let head = 0;
while (head < queue.length) {
const cur = queue[head++];
comp.push(cur);
for (const nb of plateNeighbors[cur]) {
if (!plateVisited.has(nb) && plateIsOcean.has(nb) === isOcean) {
plateVisited.add(nb);
queue.push(nb);
}
}
}
components.push(comp);
}
// 4. Split large components to reach target count
const target = Math.max(2, Math.min(20, Math.round(numPlates / 4)));
const totalPlates = numPlates;
// plateToSuperPlate: plate seed → super plate ID
const plateToSuperPlate = {};
let nextSuperPlate = 0;
for (const comp of components) {
const k = Math.max(1, Math.round(target * comp.length / totalPlates));
if (k <= 1) {
// Entire component is one super plate
const spId = nextSuperPlate++;
for (const pid of comp) plateToSuperPlate[pid] = spId;
} else {
// Farthest-point seeding on plate graph using area-weighted
// distances, then multi-source Dijkstra assignment.
// Edge cost = sqrt(area of destination plate), so traversing a
// large plate costs more than a small one → more equal-area splits.
const compSet = new Set(comp);
const localAdj = {};
for (const pid of comp) {
localAdj[pid] = [];
for (const nb of plateNeighbors[pid]) {
if (compSet.has(nb)) localAdj[pid].push(nb);
}
}
// Edge weight: sqrt of destination plate area (linear proxy)
const edgeWeight = {};
for (const pid of comp) {
edgeWeight[pid] = Math.sqrt(plateArea[pid] || 1);
}
// Dijkstra from source set — updates dist in-place
const dist = {};
const dijkstraFrom = (startPids) => {
for (const pid of comp) dist[pid] = Infinity;
const visited = new Set();
for (const s of startPids) dist[s] = 0;
for (let iter = 0; iter < comp.length; iter++) {
// Find unvisited node with smallest dist
let cur = -1, minD = Infinity;
for (const pid of comp) {
if (!visited.has(pid) && dist[pid] < minD) {
minD = dist[pid]; cur = pid;
}
}
if (cur === -1) break;
visited.add(cur);
for (const nb of localAdj[cur]) {
const nd = dist[cur] + edgeWeight[nb];
if (nd < dist[nb]) dist[nb] = nd;
}
}
};
// Farthest-point seeding: pick k seeds maximizing minimum weighted distance
const seeds = [comp[0]];
dijkstraFrom([comp[0]]);
for (let si = 1; si < k; si++) {
let farthest = comp[0], maxDist = -1;
for (const pid of comp) {
if (dist[pid] > maxDist) {
maxDist = dist[pid];
farthest = pid;
}
}
seeds.push(farthest);
dijkstraFrom(seeds);
}
// Multi-source Dijkstra from seeds to assign plates to nearest seed
const assignment = {};
for (const pid of comp) assignment[pid] = -1;
const d = {};
for (const pid of comp) d[pid] = Infinity;
const visited = new Set();
for (let si = 0; si < seeds.length; si++) {
const spId = nextSuperPlate + si;
assignment[seeds[si]] = spId;
d[seeds[si]] = 0;
}
for (let iter = 0; iter < comp.length; iter++) {
let cur = -1, minD = Infinity;
for (const pid of comp) {
if (!visited.has(pid) && d[pid] < minD) {
minD = d[pid]; cur = pid;
}
}
if (cur === -1) break;
visited.add(cur);
for (const nb of localAdj[cur]) {
const nd = d[cur] + edgeWeight[nb];
if (nd < d[nb]) {
d[nb] = nd;
assignment[nb] = assignment[cur];
}
}
}
for (const pid of comp) {
plateToSuperPlate[pid] = assignment[pid];
}
nextSuperPlate += seeds.length;
}
}
const numSuperPlates = nextSuperPlate;
// 5. Build r_superPlate: region → super plate ID
const r_superPlate = new Int32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
r_superPlate[r] = plateToSuperPlate[r_plate[r]];
}
// 6. Compute super plate Euler poles (area-weighted)
// L = sum(area_i * omega_i * pole_i) — resultant angular momentum vector
// omega_avg = sum(area_i * |omega_i|) / sum(area_i) — restores magnitude
const spLx = new Float64Array(numSuperPlates);
const spLy = new Float64Array(numSuperPlates);
const spLz = new Float64Array(numSuperPlates);
const spOmegaSum = new Float64Array(numSuperPlates);
const spAreaSum = new Float64Array(numSuperPlates);
const spLargestPlate = new Array(numSuperPlates).fill(null); // { pid, area } for fallback
for (const pid of plateSeeds) {
const spId = plateToSuperPlate[pid];
const pv = plateVec[pid];
if (!pv || !pv.pole) continue; // skip synthetic/zero-velocity plates
const area = plateArea[pid];
const omega = pv.omega;
const px = pv.pole[0], py = pv.pole[1], pz = pv.pole[2];
spLx[spId] += area * omega * px;
spLy[spId] += area * omega * py;
spLz[spId] += area * omega * pz;
spOmegaSum[spId] += area * Math.abs(omega);
spAreaSum[spId] += area;
if (!spLargestPlate[spId] || area > spLargestPlate[spId].area) {
spLargestPlate[spId] = { pid, area };
}
}
const superPlateVec = {};
for (let sp = 0; sp < numSuperPlates; sp++) {
const lx = spLx[sp], ly = spLy[sp], lz = spLz[sp];
const lLen = Math.sqrt(lx * lx + ly * ly + lz * lz);
const totalArea = spAreaSum[sp];
if (lLen < 1e-8 || totalArea < 1) {
// Fallback: use largest constituent plate's pole
const largest = spLargestPlate[sp];
if (largest) {
const pv = plateVec[largest.pid];
if (pv && pv.pole) {
superPlateVec[sp] = { pole: [pv.pole[0], pv.pole[1], pv.pole[2]], omega: pv.omega };
continue;
}
}
superPlateVec[sp] = { pole: [0, 1, 0], omega: 0 };
continue;
}
const pole = [lx / lLen, ly / lLen, lz / lLen];
const omega = spOmegaSum[sp] / totalArea;
// Preserve sign from resultant direction
superPlateVec[sp] = { pole, omega };
}
// 7. Super plate ocean/land type: majority area of constituent plates
const superPlateIsOcean = new Set();
const spOceanArea = new Float64Array(numSuperPlates);
const spTotalArea = new Float64Array(numSuperPlates);
for (const pid of plateSeeds) {
const spId = plateToSuperPlate[pid];
const area = plateArea[pid];
spTotalArea[spId] += area;
if (plateIsOcean.has(pid)) spOceanArea[spId] += area;
}
for (let sp = 0; sp < numSuperPlates; sp++) {
if (spOceanArea[sp] > spTotalArea[sp] * 0.5) {
superPlateIsOcean.add(sp);
}
}
// 8. Super plate density: area-weighted average
const superPlateDensity = {};
const spDensitySum = new Float64Array(numSuperPlates);
const spDensityArea = new Float64Array(numSuperPlates);
for (const pid of plateSeeds) {
const spId = plateToSuperPlate[pid];
const area = plateArea[pid];
const density = plateDensity[pid];
if (density !== undefined) {
spDensitySum[spId] += area * density;
spDensityArea[spId] += area;
}
}
for (let sp = 0; sp < numSuperPlates; sp++) {
superPlateDensity[sp] = spDensityArea[sp] > 0
? spDensitySum[sp] / spDensityArea[sp]
: 2.7; // fallback average crust density
}
return { r_superPlate, superPlateVec, superPlateIsOcean, superPlateDensity, numSuperPlates };
}
+239
View File
@@ -0,0 +1,239 @@
// Temperature simulation: computes per-region surface temperature for summer
// and winter seasons based on ITCZ position, continentality, moisture-dependent
// elevation lapse rate (dry adiabatic 9.3 C/km to moist adiabatic 4.5 C/km),
// ocean current warmth, and precipitation/cloud cover moderation.
// Returns normalized 0-1 values mapped to a fixed -45 to +45 C range.
import { smoothstep } from './wind.js';
import { elevToHeightKm } from './color-map.js';
import { smoothField, makeItczLookup } from './climate-util.js';
const DEG = Math.PI / 180;
// ── Diffuse ocean warmth onto nearby coastal land ───────────────────────────
// Uses plate-based continentality so that warmth spreads freely across
// shallow continental-shelf ocean and penetrates further inland. Ocean cells
// on continental plates (shallow seas) inherit warmth from nearby oceanic-
// plate cells first, then the warmth diffuses onto land.
function diffuseOceanWarmth(mesh, r_oceanWarmth, r_isLand, r_plateContinentality, passes) {
const { adjOffset, adjList, numRegions } = mesh;
const coastal = new Float32Array(numRegions);
// Seed: all ocean cells contribute their warmth directly.
// Continental-shelf ocean cells may have weak/no current warmth;
// they'll pick up values from nearby oceanic-plate neighbors via diffusion.
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r]) {
coastal[r] = r_oceanWarmth ? r_oceanWarmth[r] : 0;
}
}
const tmp = new Float32Array(numRegions);
for (let pass = 0; pass < passes; pass++) {
tmp.set(coastal);
for (let r = 0; r < numRegions; r++) {
// Skip deep-interior continental cells (plate-based)
if (r_plateContinentality && r_plateContinentality[r] >= 0.95) continue;
// Ocean cells also participate in diffusion so continental-shelf
// cells inherit warmth from nearby open-ocean neighbors
let sum = coastal[r];
let count = 1;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
sum += coastal[adjList[ni]];
count++;
}
tmp[r] = sum / count;
}
coastal.set(tmp);
}
return coastal;
}
// ── Main entry point ────────────────────────────────────────────────────────
/**
* Compute seasonal temperature fields.
*
* @param {SphereMesh} mesh
* @param {Float32Array} r_xyz - per-region 3D positions
* @param {Float32Array} r_elevation - per-region elevation
* @param {object} windResult - output from computeWind()
* @param {object} oceanResult - output from computeOceanCurrents()
* @param {object} precipResult - output from computePrecipitation()
* @returns {{ r_temperature_summer, r_temperature_winter, _tempTiming }}
*/
export function computeTemperature(mesh, r_xyz, r_elevation, windResult, oceanResult, precipResult, temperatureOffset = 0) {
const numRegions = mesh.numRegions;
const timing = [];
const { r_lat, r_lon, r_isLand, r_continentality, r_plateContinentality } = windResult;
// Minimal smoothing: 1 pass just to blend cell-to-cell noise
const smoothPasses = 1;
const T_MIN = -45;
const T_MAX = 45;
const T_RANGE = T_MAX - T_MIN;
const result = {};
// Pre-compute constants shared across seasons
const avgEdgeKm = (Math.PI * 6371) / Math.sqrt(numRegions);
const oceanWarmthPasses = Math.max(4, Math.round(1400 / avgEdgeKm));
const plateCont = r_plateContinentality || r_continentality;
const seasons = ['summer', 'winter'];
for (const name of seasons) {
const t0 = performance.now();
const r_oceanWarmth = oceanResult[`r_ocean_warmth_${name}`];
const r_oceanSpeed = oceanResult[`r_ocean_speed_${name}`];
const r_precip = precipResult[`r_precip_${name}`];
const itczLookup = makeItczLookup(windResult.itczLons,
name === 'summer' ? windResult.itczLatsSummer : windResult.itczLatsWinter);
// Pre-compute diffused ocean warmth for coastal land influence
// Use plate-based continentality for diffusion so warmth crosses
// continental shelves and reaches further inland
const coastalWarmth = diffuseOceanWarmth(mesh, r_oceanWarmth, r_isLand, plateCont, oceanWarmthPasses);
const temp = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const lat = r_lat[r];
const lon = r_lon[r];
const latDeg = lat / DEG;
const isLand = r_isLand[r];
const elev = r_elevation[r];
const cont = r_continentality ? r_continentality[r] : 0;
const pCont = r_plateContinentality ? r_plateContinentality[r] : cont;
// ── 1. Base temperature from thermal equator (ITCZ) ──
// Two curves blended by absolute latitude:
// - T_itcz: based on distance from the actual (land-warped) ITCZ
// - T_flat: based on distance from a fixed ITCZ at ±5° (ocean default)
// Near the tropics the real ITCZ matters; at high latitudes the
// ITCZ position is irrelevant and a stable zonal baseline takes over.
const tropicalHW = 13; // flat plateau half-width (degrees)
const maxDist = 90 - tropicalHW;
// Actual ITCZ curve
const itczLat = itczLookup(lon);
const distItcz = Math.abs(lat - itczLat) / DEG;
const tItcz = Math.max(0, distItcz - tropicalHW) / maxDist;
const T_itcz = 28 - 47 * Math.pow(tItcz, 1.4);
// Flat reference curve (ITCZ at 5° in summer hemisphere)
const flatItczLat = (name === 'summer' ? 5 : -5) * DEG;
const distFlat = Math.abs(lat - flatItczLat) / DEG;
const tFlat = Math.max(0, distFlat - tropicalHW) / maxDist;
const T_flat = 28 - 47 * Math.pow(tFlat, 1.4);
// Blend: ITCZ curve dominates tropics, flat curve dominates poles
const absLatDeg = Math.abs(lat) / DEG;
const blend = smoothstep(45, 90, absLatDeg);
let T = T_itcz * (1 - blend) + T_flat * blend;
// ── 2. Elevation lapse rate ──
// Moisture-dependent: dry air cools at ~9.8 C/km (dry adiabatic),
// saturated air at ~5 C/km (moist adiabatic) due to latent heat
// release. Use precipitation as a moisture proxy to interpolate.
const moisture = r_precip ? r_precip[r] : 0.5;
const lapse = 4.5 + 4.8 * (1 - moisture); // 4.5 C/km (wet) to 9.3 C/km (dry)
if (isLand && elev > 0) {
T -= lapse * elevToHeightKm(elev);
}
// ── 5. Ocean current temperature influence ──
if (!isLand && r_oceanWarmth && r_oceanSpeed) {
// Direct ocean effect: warm/cold currents shift SST
const warmth = r_oceanWarmth[r];
const speed = r_oceanSpeed[r];
T += warmth * Math.min(1, speed * 2) * 16;
} else if (isLand) {
// Coastal land: diffused ocean warmth fades with plate-based
// continentality so the effect reaches further inland and
// crosses continental shelves naturally
const cw = coastalWarmth[r];
if (Math.abs(cw) > 0.001) {
T += cw * (1 - smoothstep(0, 0.95, pCont)) * 20;
}
}
// ── 6. Precipitation / cloud cover moderation ──
if (r_precip) {
const p = r_precip[r];
if (p > 0.5) {
// High precip → clouds → moderate toward latitude baseline
const mod = smoothstep(0.5, 1.0, p) * 0.15;
// Pull toward 0 (moderate extremes)
T *= (1 - mod);
} else if (p < 0.3) {
// Low precip → clear skies → amplify extremes
const amp = smoothstep(0.3, 0.0, p) * 0.15;
T *= (1 + amp);
}
}
// ── 7. Maritime / continental moderation ──
// Ocean has high thermal inertia: coasts and small islands have
// smaller seasonal temperature swings (moderate climate), while
// continental interiors get more extreme summers and winters.
// Compute an annual-mean baseline (ITCZ at equator, no seasonal
// shift) and scale the seasonal deviation by continentality.
{
const distAnn = Math.abs(lat) / DEG; // distance from equator
const tAnn = Math.max(0, distAnn - tropicalHW) / maxDist;
const T_annual = 28 - 47 * Math.pow(tAnn, 1.4); // match new curve
// Apply same moisture-dependent lapse to annual baseline
const T_ann_adj = isLand && elev > 0
? T_annual - lapse * elevToHeightKm(elev)
: T_annual;
const deviation = T - T_ann_adj;
// Latitude-dependent seasonal boost: ITCZ shift alone gives ~5-6°C
// swing; real planets have 15-25°C from direct solar heating.
// Peaks at 55-75° latitude, zero at equator and poles.
const seasonalBoost = 12 * smoothstep(10, 55, distAnn)
* (1 - smoothstep(75, 90, distAnn));
const isLocalSummer = (name === 'summer') ? (lat >= 0) : (lat < 0);
const seasonSign = isLocalSummer ? 1 : -1;
const boostedDeviation = deviation + seasonSign * seasonalBoost;
// Maritime: coast damps swing to 50%, deep interior amplifies to 120%
const maritimeFactor = 0.50 + cont * 0.70;
T = T_ann_adj + boostedDeviation * maritimeFactor;
}
T += temperatureOffset;
temp[r] = T;
}
const tCompute = performance.now() - t0;
// ── 7. Laplacian smoothing ──
const tSmooth0 = performance.now();
smoothField(mesh, temp, smoothPasses);
const tSmooth = performance.now() - tSmooth0;
// ── 8. Normalize to 0-1 using fixed range ──
const tNorm0 = performance.now();
for (let r = 0; r < numRegions; r++) {
temp[r] = Math.max(0, Math.min(1, (temp[r] - T_MIN) / T_RANGE));
}
const tNorm = performance.now() - tNorm0;
timing.push({ stage: `Temp: compute (${name})`, ms: tCompute });
timing.push({ stage: `Temp: smooth (${name})`, ms: tSmooth });
timing.push({ stage: `Temp: normalize (${name})`, ms: tNorm });
result[`r_temperature_${name}`] = temp;
}
result._tempTiming = timing;
return result;
}
+357
View File
@@ -0,0 +1,357 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const RIDGE_HEIGHT_VAR_BASE = 0.6;
export const RIDGE_HEIGHT_VAR_SCALE = 0.6;
export const RIDGE_HEIGHT_VAR_FREQ = 2.5;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 2.0;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.18;
export const RIFT_AXIS_VOLCANIC_AMP = 0.06;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.05;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.14;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.5;
export const CRATON_AMP_SUPPRESS = 0.25;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.55;
export const SUMMIT_STRESS_MIN = 0.03;
export const SUMMIT_SPIKE_OFFSET = 0.40;
export const SUMMIT_STRESS_FLOOR = 0.25;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.14;
export const INTERIOR_BASE_BASIN = 0.04;
export const INTERIOR_TECTONIC = 0.20;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.04;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.20;
export const TRENCH_STRESS_DEPTH = 0.20;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.35;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.22;
export const ISLAND_PEAK_FLOOR = 0.04;
export const ISLAND_SUBDUCT_MAX = 0.3;
export const MAX_OCEAN_ARC_ELEV = 0.20;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.90;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.13;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 12;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.025;
export const GLACIAL_CONVERGENCE_BONUS = 0.015;
export const GLACIAL_DEPOSIT_AMOUNT = 0.007;
export const GLACIAL_FJORD_CARVE = 0.020;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 2.0;
export const VALLEY_DEEPEN_FACTOR = 0.5;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
+839
View File
@@ -0,0 +1,839 @@
// Terrain quality metrics — computes a numeric scorecard from generation
// output for automated tuning evaluation. Runs inside the web worker
// after generation completes.
//
// Each metric function receives a context object with mesh, arrays, and
// debug layers, and returns a plain object of named scores.
// ────────────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────────────
/** Average edge length in radians for the current mesh resolution. */
function avgEdgeRad(numRegions) {
return Math.PI / Math.sqrt(numRegions);
}
/** Convert a BFS hop‐distance to approximate km (Earth radius). */
function hopsToKm(hops, numRegions) {
return hops * avgEdgeRad(numRegions) * 6371;
}
/** Percentile of a Float32Array (0–1). Mutates a copy. */
function percentile(arr, p) {
const sorted = Float32Array.from(arr).sort();
const idx = Math.min(Math.floor(p * sorted.length), sorted.length - 1);
return sorted[idx];
}
/** Flood-fill connected components on a boolean mask using mesh adjacency. */
function connectedComponents(mesh, mask) {
const N = mesh.numRegions;
const label = new Int32Array(N).fill(-1);
const components = []; // array of { id, cells: Set }
let nextId = 0;
const queue = [];
for (let r = 0; r < N; r++) {
if (!mask[r] || label[r] >= 0) continue;
const id = nextId++;
const cells = new Set();
label[r] = id;
cells.add(r);
queue.length = 0;
queue.push(r);
let head = 0;
while (head < queue.length) {
const cur = queue[head++];
const off0 = mesh.adjOffset[cur];
const off1 = mesh.adjOffset[cur + 1];
for (let i = off0; i < off1; i++) {
const nb = mesh.adjList[i];
if (mask[nb] && label[nb] < 0) {
label[nb] = id;
cells.add(nb);
queue.push(nb);
}
}
}
components.push({ id, cells });
}
return { label, components };
}
/** BFS distance (in hops) from a seed set, with optional barrier mask. */
function bfsDistance(mesh, seeds, barrier) {
const N = mesh.numRegions;
const dist = new Int32Array(N).fill(-1);
const queue = [];
let head = 0;
for (const r of seeds) {
if (barrier && barrier[r]) continue;
dist[r] = 0;
queue.push(r);
}
while (head < queue.length) {
const cur = queue[head++];
const d1 = dist[cur] + 1;
const off0 = mesh.adjOffset[cur];
const off1 = mesh.adjOffset[cur + 1];
for (let i = off0; i < off1; i++) {
const nb = mesh.adjList[i];
if (dist[nb] >= 0) continue;
if (barrier && barrier[nb]) continue;
dist[nb] = d1;
queue.push(nb);
}
}
return dist;
}
// ────────────────────────────────────────────────────────────────────
// Tier 1 — Artistic Interest
// ────────────────────────────────────────────────────────────────────
/**
* Continental Silhouette Variety
* Measures variance of convex-hull-solidity across continents.
* (Approximated: since we're on a sphere mesh, we use the ratio of
* actual cell count to the BFS-bounding-box area as a proxy for solidity.)
*/
function continentSilhouette(ctx) {
const { mesh, r_elevation } = ctx;
const N = mesh.numRegions;
const isLand = new Uint8Array(N);
for (let r = 0; r < N; r++) if (r_elevation[r] > 0) isLand[r] = 1;
const { components } = connectedComponents(mesh, isLand);
// Filter to continents (>0.5% of land cells)
const totalLand = components.reduce((s, c) => s + c.cells.size, 0);
const minSize = Math.max(10, totalLand * 0.005);
const continents = components.filter(c => c.cells.size >= minSize);
const islands = components.filter(c => c.cells.size < minSize);
// Approximate solidity: area / (pi * (max_bfs_radius)^2)
// We compute max BFS radius from centroid of each continent
const solidities = [];
for (const cont of continents) {
const cellArr = Array.from(cont.cells);
// Find approximate centroid (cell with min max-distance to others via BFS from random sample)
const sample = cellArr[Math.floor(cellArr.length / 2)];
const distFromSample = bfsDistance(mesh, [sample], null);
let maxDist = 0;
for (const r of cellArr) {
if (distFromSample[r] > maxDist) maxDist = distFromSample[r];
}
// Solidity proxy: cellCount / (pi * maxDist^2)
const circleArea = Math.PI * maxDist * maxDist;
const solidity = circleArea > 0 ? Math.min(1, cont.cells.size / circleArea) : 1;
solidities.push(solidity);
}
const mean = solidities.length > 0
? solidities.reduce((a, b) => a + b, 0) / solidities.length : 0;
const variance = solidities.length > 1
? solidities.reduce((s, v) => s + (v - mean) ** 2, 0) / solidities.length : 0;
return {
continent_count: continents.length,
island_count_total: islands.length,
island_cells_total: islands.reduce((s, c) => s + c.cells.size, 0),
continent_solidity_mean: +mean.toFixed(4),
continent_solidity_variance: +variance.toFixed(4),
// Store components for reuse by other metrics
_continents: continents,
_islands: islands,
_isLand: isLand,
};
}
/**
* Elevation Drama
* Relief headroom (p95-p50 of land), plus check that peaks are clustered.
*/
function elevationDrama(ctx) {
const { mesh, r_elevation } = ctx;
const N = mesh.numRegions;
const landElev = [];
for (let r = 0; r < N; r++) {
if (r_elevation[r] > 0) landElev.push(r_elevation[r]);
}
if (landElev.length < 10) {
return { relief_headroom: 0, peak_clustering: 0 };
}
const arr = new Float32Array(landElev);
const p50 = percentile(arr, 0.50);
const p95 = percentile(arr, 0.95);
const relief = p95 - p50;
// Peak clustering: fraction of top-5% cells that have a top-5% neighbor
const threshold = p95;
const isPeak = new Uint8Array(N);
let peakCount = 0;
for (let r = 0; r < N; r++) {
if (r_elevation[r] >= threshold) { isPeak[r] = 1; peakCount++; }
}
let clustered = 0;
for (let r = 0; r < N; r++) {
if (!isPeak[r]) continue;
const off0 = mesh.adjOffset[r];
const off1 = mesh.adjOffset[r + 1];
for (let i = off0; i < off1; i++) {
if (isPeak[mesh.adjList[i]]) { clustered++; break; }
}
}
return {
relief_headroom: +relief.toFixed(4),
peak_clustering: peakCount > 0 ? +(clustered / peakCount).toFixed(4) : 0,
};
}
/**
* Coast Complexity
* Dimensionless roughness: coastline_cell_count / sqrt(land_cell_count).
*/
function coastComplexity(ctx) {
const { mesh, r_elevation } = ctx;
const N = mesh.numRegions;
let landCount = 0;
let coastCount = 0;
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0) continue;
landCount++;
const off0 = mesh.adjOffset[r];
const off1 = mesh.adjOffset[r + 1];
for (let i = off0; i < off1; i++) {
if (r_elevation[mesh.adjList[i]] <= 0) { coastCount++; break; }
}
}
const index = landCount > 0 ? coastCount / Math.sqrt(landCount) : 0;
return {
coast_complexity_index: +index.toFixed(4),
coastline_cells: coastCount,
land_cells: landCount,
};
}
/**
* Ocean Floor Texture
* Standard deviation of ocean elevations + trench presence.
*/
function oceanFloorTexture(ctx) {
const { mesh, r_elevation } = ctx;
const N = mesh.numRegions;
const oceanElev = [];
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0) oceanElev.push(r_elevation[r]);
}
if (oceanElev.length < 10) {
return { ocean_elev_stddev: 0, trench_fraction: 0 };
}
const arr = new Float32Array(oceanElev);
const mean = oceanElev.reduce((a, b) => a + b, 0) / oceanElev.length;
const variance = oceanElev.reduce((s, v) => s + (v - mean) ** 2, 0) / oceanElev.length;
const stddev = Math.sqrt(variance);
// Trench fraction: cells below p2 (expect distinct spike)
const p02 = percentile(arr, 0.02);
const p05 = percentile(arr, 0.05);
const trenchGap = p05 - p02; // distance between p2 and p5 — large = distinct trench tail
return {
ocean_elev_stddev: +stddev.toFixed(5),
ocean_trench_gap: +trenchGap.toFixed(5),
};
}
/**
* Flat Land on Ocean Plates
* Land cells assigned to ocean plates that lack volcanic/tectonic relief.
* These should be mountainous/volcanic, not flat plains.
*/
function flatOceanPlateLand(ctx) {
const { mesh, r_elevation, r_plate, plateIsOcean, debugLayers } = ctx;
const N = mesh.numRegions;
const oceanPlateSet = new Set(plateIsOcean);
let oceanPlateLandCount = 0;
let flatOceanPlateLandCount = 0;
const FLAT_THRESHOLD = 0.21; // below ~50m (quartic elev mapping) — barely above sea level
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0) continue;
if (!oceanPlateSet.has(r_plate[r])) continue;
// This is land on an ocean plate
oceanPlateLandCount++;
if (r_elevation[r] < FLAT_THRESHOLD) {
flatOceanPlateLandCount++;
}
}
return {
ocean_plate_land_cells: oceanPlateLandCount,
flat_ocean_plate_land_cells: flatOceanPlateLandCount,
flat_ocean_plate_land_fraction: oceanPlateLandCount > 0
? +(flatOceanPlateLandCount / oceanPlateLandCount).toFixed(4) : 0,
};
}
// ────────────────────────────────────────────────────────────────────
// Tier 2 — Scientific Plausibility
// ────────────────────────────────────────────────────────────────────
/**
* Bimodal Hypsometry
* Fit two-Gaussian model to elevation histogram. Measure trough depth
* and mode positions.
*/
function bimodalHypsometry(ctx) {
const { r_elevation } = ctx;
const N = r_elevation.length;
const BINS = 200;
const minE = -0.5, maxE = 0.8;
const binW = (maxE - minE) / BINS;
const hist = new Float64Array(BINS);
for (let r = 0; r < N; r++) {
const b = Math.floor((r_elevation[r] - minE) / binW);
if (b >= 0 && b < BINS) hist[b]++;
}
// Normalize
const total = hist.reduce((a, b) => a + b, 0);
for (let i = 0; i < BINS; i++) hist[i] /= total;
// Find two peaks: one below sea level (ocean), one above (land)
const seaBin = Math.floor((0 - minE) / binW);
let oceanPeak = 0, oceanPeakVal = 0;
for (let i = 0; i < seaBin; i++) {
if (hist[i] > oceanPeakVal) { oceanPeakVal = hist[i]; oceanPeak = i; }
}
let landPeak = seaBin, landPeakVal = 0;
for (let i = seaBin; i < BINS; i++) {
if (hist[i] > landPeakVal) { landPeakVal = hist[i]; landPeak = i; }
}
// Trough: minimum between the two peaks
let troughVal = Infinity;
for (let i = oceanPeak; i <= landPeak; i++) {
if (hist[i] < troughVal) troughVal = hist[i];
}
const peakAvg = (oceanPeakVal + landPeakVal) / 2;
const troughDepth = peakAvg > 0 ? 1 - troughVal / peakAvg : 0;
return {
ocean_mode_elev: +(minE + (oceanPeak + 0.5) * binW).toFixed(4),
land_mode_elev: +(minE + (landPeak + 0.5) * binW).toFixed(4),
hypsometry_trough_depth: +troughDepth.toFixed(4),
};
}
/**
* Mountain–Boundary Spatial Correlation
* Top 5% land cells should cluster near actual plate boundaries
* (cells where r_plate differs from a neighbor), not the propagated
* stress field which extends far inland.
*/
function mountainBoundaryCorrelation(ctx) {
const { mesh, r_elevation, r_plate } = ctx;
const N = mesh.numRegions;
// Find actual plate boundary cells (where r_plate differs from a neighbor)
const boundaryCells = [];
for (let r = 0; r < N; r++) {
const pid = r_plate[r];
const off0 = mesh.adjOffset[r];
const off1 = mesh.adjOffset[r + 1];
for (let i = off0; i < off1; i++) {
if (r_plate[mesh.adjList[i]] !== pid) {
boundaryCells.push(r);
break;
}
}
}
if (boundaryCells.length === 0) {
return { mountain_boundary_ratio: 1.0 };
}
const distToBoundary = bfsDistance(mesh, boundaryCells, null);
// Land cells only
const landDists = [];
const mountainDists = [];
const landElev = [];
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0) continue;
landElev.push(r_elevation[r]);
}
const p95 = percentile(new Float32Array(landElev), 0.95);
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0 || distToBoundary[r] < 0) continue;
landDists.push(distToBoundary[r]);
if (r_elevation[r] >= p95) mountainDists.push(distToBoundary[r]);
}
const medianLand = landDists.length > 0
? percentile(new Float32Array(landDists), 0.5) : 0;
const medianMountain = mountainDists.length > 0
? percentile(new Float32Array(mountainDists), 0.5) : 0;
const ratio = medianLand > 0 ? medianMountain / medianLand : 1;
return {
mountain_boundary_ratio: +ratio.toFixed(4),
mountain_boundary_median_hops: +medianMountain.toFixed(1),
all_land_boundary_median_hops: +medianLand.toFixed(1),
};
}
/**
* Orogenic Power vs Elevation Correlation
* The tectonic signal should survive post-processing.
*/
function orogenicCorrelation(ctx) {
const { r_elevation, debugLayers } = ctx;
if (!debugLayers || !debugLayers.orogenicPower) {
return { orogenic_elev_correlation: null };
}
const op = debugLayers.orogenicPower;
const N = r_elevation.length;
// Pearson correlation on land cells
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0, n = 0;
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0) continue;
const x = op[r], y = r_elevation[r];
sumX += x; sumY += y; sumXY += x * y;
sumX2 += x * x; sumY2 += y * y;
n++;
}
if (n < 10) return { orogenic_elev_correlation: 0 };
const denom = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
const corr = denom > 0 ? (n * sumXY - sumX * sumY) / denom : 0;
return {
orogenic_elev_correlation: +corr.toFixed(4),
};
}
/**
* Erosion–Slope Coherence
* Hydraulic erosion should preferentially hit high-slope cells.
*/
function erosionSlopeCoherence(ctx) {
const { mesh, r_elevation, r_xyz, debugLayers } = ctx;
if (!debugLayers || !debugLayers.erosionDelta) {
return { erosion_slope_correlation: null };
}
const delta = debugLayers.erosionDelta;
const N = mesh.numRegions;
// Compute slope per land cell (max elevation difference to neighbors)
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0, n = 0;
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0) continue;
const off0 = mesh.adjOffset[r];
const off1 = mesh.adjOffset[r + 1];
let maxSlope = 0;
for (let i = off0; i < off1; i++) {
const nb = mesh.adjList[i];
const dh = Math.abs(r_elevation[r] - r_elevation[nb]);
if (dh > maxSlope) maxSlope = dh;
}
// Erosion delta should be negative (erosion) where slope is high
const x = maxSlope;
const y = -delta[r]; // positive = more erosion
sumX += x; sumY += y; sumXY += x * y;
sumX2 += x * x; sumY2 += y * y;
n++;
}
if (n < 10) return { erosion_slope_correlation: 0 };
const denom = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
const corr = denom > 0 ? (n * sumXY - sumX * sumY) / denom : 0;
return {
erosion_slope_correlation: +corr.toFixed(4),
};
}
// ────────────────────────────────────────────────────────────────────
// Tier 1+ — Island Metrics
// ────────────────────────────────────────────────────────────────────
/**
* Island analysis: count, size distribution, arc association, elevation profile.
*/
function islandMetrics(ctx, silhouetteResult) {
const { mesh, r_elevation, r_stress, r_plate, plateIsOcean } = ctx;
const N = mesh.numRegions;
const islands = silhouetteResult._islands;
const oceanPlateSet = new Set(plateIsOcean);
if (!islands || islands.length === 0) {
return {
island_count: 0,
island_size_max: 0,
island_mean_elevation: 0,
island_arc_association: 0,
};
}
// Size distribution
const sizes = islands.map(c => c.cells.size).sort((a, b) => b - a);
// Distance to high-stress cells (proxy for convergent boundaries)
const stressCells = [];
for (let r = 0; r < N; r++) {
if (r_stress[r] > 0.2) stressCells.push(r);
}
const distToStress = stressCells.length > 0
? bfsDistance(mesh, stressCells, null) : null;
// Per-island analysis
let arcAssocCount = 0;
let totalMeanElev = 0;
const ARC_DIST_THRESHOLD = Math.round(800 / hopsToKm(1, N)); // ~800km in hops
for (const island of islands) {
// Mean elevation
let elevSum = 0;
let minDistToStress = Infinity;
for (const r of island.cells) {
elevSum += r_elevation[r];
if (distToStress && distToStress[r] >= 0 && distToStress[r] < minDistToStress) {
minDistToStress = distToStress[r];
}
}
totalMeanElev += elevSum / island.cells.size;
if (minDistToStress <= ARC_DIST_THRESHOLD) arcAssocCount++;
}
return {
island_count: islands.length,
island_size_max: sizes[0],
island_size_median: sizes[Math.floor(sizes.length / 2)],
island_mean_elevation: +(totalMeanElev / islands.length).toFixed(4),
island_arc_association: +(arcAssocCount / islands.length).toFixed(4),
};
}
// ────────────────────────────────────────────────────────────────────
// Tier 1+ — Coastal Lowland & Shelf Metrics
// ────────────────────────────────────────────────────────────────────
/**
* Near-sea-level land fraction and elevation band distribution.
*/
function coastalLowlandIndex(ctx) {
const { r_elevation } = ctx;
const N = r_elevation.length;
// Elevation bands in normalized units (roughly: 0.01 ≈ 80m)
// 0-50m ≈ 0-0.00625, 50-200m ≈ 0.00625-0.025, 200-500m ≈ 0.025-0.0625, 500m+ ≈ 0.0625+
// But the exact scale depends on the planet's max elevation.
// Use relative bands: bottom 5%, 5-20%, 20-50%, 50%+ of land elevation range.
const landElevs = [];
for (let r = 0; r < N; r++) {
if (r_elevation[r] > 0) landElevs.push(r_elevation[r]);
}
if (landElevs.length < 10) {
return { lowland_fraction: 0, midland_fraction: 0, highland_fraction: 0 };
}
const sorted = new Float32Array(landElevs).sort();
const p10 = sorted[Math.floor(sorted.length * 0.10)];
const p30 = sorted[Math.floor(sorted.length * 0.30)];
// Thresholds from elevToHeightKm() quartic mapping:
// 50m (0.05km) → elev 0.21, 200m → 0.31, 500m → 0.40
let band0_50 = 0, band50_200 = 0, band200_500 = 0, band500plus = 0;
for (const e of landElevs) {
if (e < 0.21) band0_50++;
else if (e < 0.31) band50_200++;
else if (e < 0.40) band200_500++;
else band500plus++;
}
const total = landElevs.length;
return {
land_band_0_50m_frac: +(band0_50 / total).toFixed(4),
land_band_50_200m_frac: +(band50_200 / total).toFixed(4),
land_band_200_500m_frac: +(band200_500 / total).toFixed(4),
land_band_500m_plus_frac: +(band500plus / total).toFixed(4),
coastal_lowland_fraction: +((band0_50 + band50_200) / total).toFixed(4),
};
}
/**
* Shelf Width — distance from coast to -200m depth.
* Separately for active vs passive margins (using stress as proxy).
*/
function shelfWidth(ctx) {
const { mesh, r_elevation, r_stress } = ctx;
const N = mesh.numRegions;
// Find coastline cells (land adjacent to ocean)
const coastCells = [];
const coastIsActive = [];
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0) continue;
const off0 = mesh.adjOffset[r];
const off1 = mesh.adjOffset[r + 1];
let isCoast = false;
for (let i = off0; i < off1; i++) {
if (r_elevation[mesh.adjList[i]] <= 0) { isCoast = true; break; }
}
if (isCoast) {
coastCells.push(r);
// Active margin: near high stress
coastIsActive.push(r_stress[r] > 0.15);
}
}
// For each coast cell, walk outward into ocean measuring:
// 1) Distance to shelf break (elevation crossing below p25 of ocean depth)
// 2) First-ocean-cell elevation (diagnostic)
//
// We use a relative shelf break threshold based on actual ocean elevation
// distribution rather than a fixed value, since the elevation scale varies.
const oceanElevs = [];
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0) oceanElevs.push(r_elevation[r]);
}
// Shelf break = 25th percentile of ocean depth (shallow quarter = shelf)
const SHELF_BREAK_DEPTH = oceanElevs.length > 0
? percentile(new Float32Array(oceanElevs), 0.25) : -0.1;
const activeWidths = [];
const passiveWidths = [];
let firstOceanElevSum = 0;
let firstOceanCount = 0;
// Sample coastline to keep computation bounded (every 3rd coast cell)
for (let ci = 0; ci < coastCells.length; ci += 3) {
const start = coastCells[ci];
const visited = new Set();
visited.add(start);
let frontier = [start];
let dist = 0;
let found = false;
const MAX_DIST = 80;
while (frontier.length > 0 && dist < MAX_DIST) {
dist++;
const next = [];
for (const cur of frontier) {
const off0 = mesh.adjOffset[cur];
const off1 = mesh.adjOffset[cur + 1];
for (let i = off0; i < off1; i++) {
const nb = mesh.adjList[i];
if (visited.has(nb)) continue;
visited.add(nb);
if (r_elevation[nb] > 0) continue; // stay in ocean
if (dist === 1) {
firstOceanElevSum += r_elevation[nb];
firstOceanCount++;
}
if (r_elevation[nb] <= SHELF_BREAK_DEPTH) {
if (coastIsActive[ci]) activeWidths.push(dist);
else passiveWidths.push(dist);
found = true;
break;
}
next.push(nb);
}
if (found) break;
}
if (found) break;
frontier = next;
}
}
const medianActive = activeWidths.length > 0
? percentile(new Float32Array(activeWidths), 0.5) : 0;
const medianPassive = passiveWidths.length > 0
? percentile(new Float32Array(passiveWidths), 0.5) : 0;
return {
shelf_break_threshold: +SHELF_BREAK_DEPTH.toFixed(4),
shelf_width_active_hops: +medianActive.toFixed(1),
shelf_width_passive_hops: +medianPassive.toFixed(1),
shelf_width_active_km: +hopsToKm(medianActive, N).toFixed(0),
shelf_width_passive_km: +hopsToKm(medianPassive, N).toFixed(0),
shelf_passive_wider_than_active: medianPassive > medianActive,
shelf_measurements_active: activeWidths.length,
shelf_measurements_passive: passiveWidths.length,
shelf_first_ocean_cell_mean_elev: firstOceanCount > 0
? +(firstOceanElevSum / firstOceanCount).toFixed(5) : 0,
};
}
/**
* Continental Interior Elevation Gradient
* How steeply land rises from coastline inland.
*/
function interiorGradient(ctx) {
const { mesh, r_elevation } = ctx;
const N = mesh.numRegions;
// Find coastal land cells
const coastSeeds = [];
const isOcean = new Uint8Array(N);
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0) { isOcean[r] = 1; continue; }
const off0 = mesh.adjOffset[r];
const off1 = mesh.adjOffset[r + 1];
for (let i = off0; i < off1; i++) {
if (r_elevation[mesh.adjList[i]] <= 0) { coastSeeds.push(r); break; }
}
}
// BFS distance from coast (land only)
const distFromCoast = bfsDistance(mesh, coastSeeds, isOcean);
// Bin by distance, compute mean elevation at each distance band
const MAX_BAND = 30; // ~30 hops inland
const bandElev = new Float64Array(MAX_BAND);
const bandCount = new Int32Array(MAX_BAND);
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0 || distFromCoast[r] < 0) continue;
const band = Math.min(distFromCoast[r], MAX_BAND - 1);
bandElev[band] += r_elevation[r];
bandCount[band]++;
}
// Compute gradient from band 0 to band 5 (first ~5 hops = near-coast)
const nearCoastElev = bandCount[0] > 0 ? bandElev[0] / bandCount[0] : 0;
let midBand = 5;
while (midBand > 1 && bandCount[midBand] === 0) midBand--;
const midElev = bandCount[midBand] > 0 ? bandElev[midBand] / bandCount[midBand] : 0;
const nearCoastGradient = midBand > 0 ? (midElev - nearCoastElev) / midBand : 0;
// Gradient per km
const hopKm = hopsToKm(1, N);
const gradientPerKm = hopKm > 0 ? nearCoastGradient / hopKm : 0;
return {
near_coast_mean_elev: +nearCoastElev.toFixed(5),
interior_gradient_per_hop: +nearCoastGradient.toFixed(5),
interior_gradient_per_km: +gradientPerKm.toFixed(6),
};
}
// ────────────────────────────────────────────────────────────────────
// Tier 3 — Layer Coherence
// ────────────────────────────────────────────────────────────────────
/**
* Hotspot Contribution Distinctiveness
* Kurtosis of hotspot layer — should be high (sparse, intense).
*/
function hotspotDistinctiveness(ctx) {
const { debugLayers } = ctx;
if (!debugLayers || !debugLayers.hotspot) {
return { hotspot_kurtosis: null };
}
const hs = debugLayers.hotspot;
const N = hs.length;
let sum = 0, n = 0;
for (let i = 0; i < N; i++) {
if (hs[i] !== 0) { sum += hs[i]; n++; }
}
if (n < 10) return { hotspot_kurtosis: 0, hotspot_active_fraction: 0 };
const mean = sum / n;
let m2 = 0, m4 = 0;
for (let i = 0; i < N; i++) {
if (hs[i] === 0) continue;
const d = hs[i] - mean;
m2 += d * d;
m4 += d * d * d * d;
}
m2 /= n; m4 /= n;
const kurtosis = m2 > 0 ? m4 / (m2 * m2) - 3 : 0; // excess kurtosis
return {
hotspot_kurtosis: +kurtosis.toFixed(2),
hotspot_active_fraction: +(n / N).toFixed(4),
};
}
/**
* Back-Arc and Fold Ridge Presence
* Verify these features have nonzero signal where expected.
*/
function backArcFoldPresence(ctx) {
const { debugLayers } = ctx;
const result = {};
if (debugLayers && debugLayers.backArc) {
const ba = debugLayers.backArc;
let nonzero = 0, sum = 0;
for (let i = 0; i < ba.length; i++) {
if (ba[i] !== 0) { nonzero++; sum += Math.abs(ba[i]); }
}
result.back_arc_active_cells = nonzero;
result.back_arc_mean_magnitude = nonzero > 0 ? +(sum / nonzero).toFixed(5) : 0;
}
if (debugLayers && debugLayers.foldRidge) {
const fr = debugLayers.foldRidge;
let nonzero = 0, sum = 0;
for (let i = 0; i < fr.length; i++) {
if (fr[i] !== 0) { nonzero++; sum += Math.abs(fr[i]); }
}
result.fold_ridge_active_cells = nonzero;
result.fold_ridge_mean_magnitude = nonzero > 0 ? +(sum / nonzero).toFixed(5) : 0;
}
return result;
}
// ────────────────────────────────────────────────────────────────────
// Main entry point
// ────────────────────────────────────────────────────────────────────
/**
* Compute all terrain quality metrics.
*
* @param {Object} ctx — context with:
* mesh, r_xyz, r_elevation, r_plate, plateIsOcean (Array or Set),
* r_stress, debugLayers, prePostElev (optional)
* @returns {Object} flat scorecard of named metrics
*/
export function computeTerrainMetrics(ctx) {
// Normalize plateIsOcean to an iterable of seed region IDs
if (ctx.plateIsOcean instanceof Set) {
ctx.plateIsOcean = Array.from(ctx.plateIsOcean);
}
const t0 = typeof performance !== 'undefined' ? performance.now() : Date.now();
const silhouette = continentSilhouette(ctx);
const drama = elevationDrama(ctx);
const coast = coastComplexity(ctx);
const oceanFloor = oceanFloorTexture(ctx);
const flatOcean = flatOceanPlateLand(ctx);
const hyps = bimodalHypsometry(ctx);
const mtnBoundary = mountainBoundaryCorrelation(ctx);
const orogenic = orogenicCorrelation(ctx);
const erosion = erosionSlopeCoherence(ctx);
const islands = islandMetrics(ctx, silhouette);
const lowland = coastalLowlandIndex(ctx);
const shelf = shelfWidth(ctx);
const gradient = interiorGradient(ctx);
const hotspot = hotspotDistinctiveness(ctx);
const backArcFold = backArcFoldPresence(ctx);
const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - t0;
// Flatten into single scorecard, dropping internal fields
const scorecard = {};
for (const partial of [silhouette, drama, coast, oceanFloor, flatOcean, hyps,
mtnBoundary, orogenic, erosion, islands, lowland,
shelf, gradient, hotspot, backArcFold]) {
for (const [k, v] of Object.entries(partial)) {
if (!k.startsWith('_')) scorecard[k] = v;
}
}
scorecard._metrics_ms = +elapsed.toFixed(1);
return scorecard;
}
+840
View File
@@ -0,0 +1,840 @@
// Terrain post-processing: domain warping, bilateral smoothing, and
// flow-based erosion. Runs after elevation assignment to deform terrain
// for organic shapes, soften harsh boundaries, and carve natural
// drainage patterns.
import { SimplexNoise } from './simplex-noise.js';
import {
FLOOD_NOISE_AMP, FLOOD_CARVE_RADIUS_FRAC,
WARP_FREQ, WARP_OCTAVES, WARP_MAX_AMP_MULT,
WARP_BIAS_BASE, WARP_BIAS_STRENGTH_SCALE, WARP_HOTSPOT_DAMPEN,
SMOOTH_EDGE_SENSITIVITY,
GLACIAL_LAT_DIVISOR, GLACIAL_ELEV_LOW, GLACIAL_ELEV_HIGH,
GLACIAL_ELEV_FACTOR_SCALE, GLACIAL_ELEV_FACTOR_LAT_BASE, GLACIAL_ELEV_FACTOR_LAT_SCALE,
GLACIAL_CARVE_RATE, GLACIAL_CONVERGENCE_BONUS, GLACIAL_DEPOSIT_AMOUNT,
GLACIAL_FJORD_CARVE, GLACIAL_FLOW_THRESHOLD, GLACIAL_FJORD_THRESHOLD,
GLACIAL_WIDENING_FRAC, GLACIAL_TERMINUS_RATIO, GLACIAL_FJORD_ICE_MIN,
GLACIAL_POST_SMOOTH, GLACIAL_MID_FLOOD_FRAC, GLACIAL_MID_FLOOD_CARVE,
GLACIAL_INITIAL_CARVE,
HYDRAULIC_DEPOSIT_FRAC, HYDRAULIC_SLOPE_SENSITIVITY,
THERMAL_TRANSFER_FRAC,
RIDGE_SHARPEN_CAP, VALLEY_DEEPEN_FACTOR, VALLEY_FLOOR_FRAC, VALLEY_FLOOR_MIN,
} from './terrain-config.js';
/**
* Inline binary min-heap keyed on an external Float32Array of priorities.
* Each cell is pushed/popped exactly once — no decrease-key needed.
*/
class MinHeap {
constructor(keyArray) {
this._key = keyArray;
this._data = [];
}
get size() { return this._data.length; }
push(cell) {
this._data.push(cell);
let i = this._data.length - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (this._key[this._data[i]] >= this._key[this._data[parent]]) break;
const tmp = this._data[i]; this._data[i] = this._data[parent]; this._data[parent] = tmp;
i = parent;
}
}
pop() {
const top = this._data[0];
const last = this._data.pop();
if (this._data.length > 0) {
this._data[0] = last;
let i = 0;
const n = this._data.length;
while (true) {
let smallest = i;
const l = 2 * i + 1, r = 2 * i + 2;
if (l < n && this._key[this._data[l]] < this._key[this._data[smallest]]) smallest = l;
if (r < n && this._key[this._data[r]] < this._key[this._data[smallest]]) smallest = r;
if (smallest === i) break;
const tmp = this._data[i]; this._data[i] = this._data[smallest]; this._data[smallest] = tmp;
i = smallest;
}
}
return top;
}
}
/**
* Priority-flood pit resolution with canyon carving.
* Ensures every land cell has a monotonically descending drainage path to
* the ocean, favoring carving through spill points over filling pit floors.
*
* Pass 1: Standard Barnes et al. priority-flood fill from ocean-adjacent
* land cells inward → surface[], drainTo[]
* Pass 2: Redistribute fill deficit as carving along spill paths
* Pass 3: Enforce monotonic drainage with epsilon gradient
*/
function priorityFloodCarve(mesh, r_elevation, r_isOcean, carveStrength) {
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
const EPS = 1e-7;
// --- Identify the main ocean body via BFS ---
// Find connected ocean components and mark only the largest as "open ocean"
const oceanLabel = new Int32Array(N).fill(-1);
const componentSizes = [];
for (let r = 0; r < N; r++) {
if (!r_isOcean[r] || oceanLabel[r] >= 0) continue;
const label = componentSizes.length;
let size = 0;
const queue = [r];
oceanLabel[r] = label;
while (queue.length > 0) {
const cur = queue.pop();
size++;
for (let i = adjOffset[cur], iEnd = adjOffset[cur + 1]; i < iEnd; i++) {
const nb = adjList[i];
if (r_isOcean[nb] && oceanLabel[nb] < 0) {
oceanLabel[nb] = label;
queue.push(nb);
}
}
}
componentSizes.push(size);
}
let mainOceanLabel = 0;
for (let i = 1; i < componentSizes.length; i++) {
if (componentSizes[i] > componentSizes[mainOceanLabel]) mainOceanLabel = i;
}
const isOpenOcean = new Uint8Array(N);
for (let r = 0; r < N; r++) {
if (r_isOcean[r] && oceanLabel[r] === mainOceanLabel) isOpenOcean[r] = 1;
}
// --- Deterministic hash for noise perturbation (meander paths) ---
// Small noise on priority keys makes the flood front irregular,
// producing winding drainage paths instead of straight lines
const NOISE_AMP = FLOOD_NOISE_AMP; // amplitude relative to typical elevation range
function cellNoise(r) {
let h = (r * 2654435761) >>> 0; // Knuth multiplicative hash
h = ((h >>> 16) ^ h) * 0x45d9f3b >>> 0;
h = ((h >>> 16) ^ h) >>> 0;
return (h / 0xffffffff) * NOISE_AMP;
}
const surface = new Float32Array(r_elevation);
const drainTo = new Int32Array(N).fill(-1);
const visited = new Uint8Array(N);
// Priority key array — elevation + small noise for meandering
const key = new Float32Array(N);
for (let r = 0; r < N; r++) key[r] = r_elevation[r] + cellNoise(r);
const heap = new MinHeap(key);
// Seed: land cells adjacent to the main open ocean (not inland seas)
for (let r = 0; r < N; r++) {
if (r_isOcean[r]) { visited[r] = 1; continue; }
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
if (isOpenOcean[adjList[i]]) {
visited[r] = 1;
drainTo[r] = adjList[i]; // drains to open ocean neighbor
heap.push(r);
break;
}
}
}
// Pass 1: priority-flood fill (noise-perturbed for winding paths)
while (heap.size > 0) {
const r = heap.pop();
const surfR = surface[r];
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
const nb = adjList[i];
if (visited[nb]) continue;
visited[nb] = 1;
drainTo[nb] = r;
if (r_elevation[nb] < surfR + EPS) {
// Pit detected — fill to current surface + epsilon
surface[nb] = surfR + EPS;
key[nb] = surface[nb] + cellNoise(nb);
}
// else: neighbor drains naturally, surface[nb] already = r_elevation[nb]
heap.push(nb);
}
}
// Pass 2: carve-bias redistribution
// For each filled cell, trace path back to ocean, find the peak (spill point),
// and redistribute deficit as carving near the peak
for (let r = 0; r < N; r++) {
if (r_isOcean[r]) continue;
const deficit = surface[r] - r_elevation[r];
if (deficit <= EPS) continue;
// Trace drainTo path toward ocean, collect path and find peak
const path = [];
let peakIdx = -1;
let peakElev = -Infinity;
let cur = r;
while (cur >= 0 && !r_isOcean[cur]) {
path.push(cur);
if (r_elevation[cur] > peakElev) {
peakElev = r_elevation[cur];
peakIdx = path.length - 1;
}
cur = drainTo[cur];
}
if (peakIdx < 0 || path.length === 0) continue;
// Carve: lower cells near the peak using a triangle kernel
const carveAmount = deficit * carveStrength;
const radius = Math.max(3, Math.ceil(path.length * FLOOD_CARVE_RADIUS_FRAC));
const startIdx = Math.max(0, peakIdx - radius);
const endIdx = Math.min(path.length - 1, peakIdx + radius);
let kernelSum = 0;
for (let k = startIdx; k <= endIdx; k++) {
const dist = Math.abs(k - peakIdx);
kernelSum += 1 - dist / (radius + 1);
}
if (kernelSum > 0) {
for (let k = startIdx; k <= endIdx; k++) {
const dist = Math.abs(k - peakIdx);
const weight = (1 - dist / (radius + 1)) / kernelSum;
r_elevation[path[k]] -= carveAmount * weight;
if (r_elevation[path[k]] < 0) r_elevation[path[k]] = 0;
}
}
// Fill: raise the pit floor by the remaining fraction
const fillAmount = deficit * (1 - carveStrength);
r_elevation[r] += fillAmount;
}
// Pass 3: enforce monotonic drainage along drainTo paths
// Process cells in order of ascending surface (re-sort by surface)
const order = [];
for (let r = 0; r < N; r++) {
if (!r_isOcean[r]) order.push(r);
}
order.sort((a, b) => surface[a] - surface[b]);
for (let i = 0; i < order.length; i++) {
const r = order[i];
const target = drainTo[r];
if (target < 0) continue;
const targetElev = r_isOcean[target] ? 0 : r_elevation[target];
if (r_elevation[r] <= targetElev) {
r_elevation[r] = targetElev + EPS;
}
}
}
/**
* Domain warping — displaces each region's elevation lookup by FBM simplex
* noise in the tangent plane, producing organic, squiggly coastlines and
* mountain ridges. Scale-invariant: noise is evaluated in 3D coordinate
* space and amplitude is in radians (physical distance on the sphere).
*
* For each region:
* 1. Compute a tangent-plane frame (east/north) at its position on the unit sphere
* 2. Use FBM simplex noise (4 octaves, frequency 6) to generate two
* displacement values in the tangent plane
* 3. Displace the region's 3D position along the tangent frame by the noise
* offsets, then re-project onto the unit sphere
* 4. Walk the mesh graph (greedy nearest-neighbor) from the original region
* toward the displaced point to find the closest region
* 5. Copy that source region's elevation to the output
*/
export function warpTerrain(mesh, r_elevation, r_xyz, seed, strength, r_hotspot) {
if (strength <= 0) return;
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
const noise = new SimplexNoise(seed + 9999);
const freq = WARP_FREQ;
const octaves = WARP_OCTAVES;
const maxAmp = WARP_MAX_AMP_MULT * strength; // radians (~760 km at Earth scale when strength=1)
const out = new Float32Array(r_elevation);
for (let r = 0; r < N; r++) {
const px = r_xyz[3 * r], py = r_xyz[3 * r + 1], pz = r_xyz[3 * r + 2];
// Tangent frame: east = normalize(cross(up, pos)), north = cross(pos, east)
let ex = -pz, ey = 0, ez = px; // cross([0,1,0], pos) = [-pz, 0, px]
const elen = Math.sqrt(ex * ex + ez * ez);
if (elen > 1e-10) { ex /= elen; ez /= elen; }
else { ex = 1; ez = 0; } // poles
const nx = py * ez;
const ny = pz * ex - px * ez;
const nz = -py * ex;
const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
const nnx = nx / nlen, nny = ny / nlen, nnz = nz / nlen;
// FBM noise → two displacement values
const d1 = noise.fbm(px * freq, py * freq, pz * freq, octaves) * maxAmp;
const d2 = noise.fbm(px * freq + 31.7, py * freq + 47.3, pz * freq + 19.1, octaves) * maxAmp;
// Displace position along tangent frame and re-project onto unit sphere
let wx = px + ex * d1 + nnx * d2;
let wy = py + ey * d1 + nny * d2;
let wz = pz + ez * d1 + nnz * d2;
const wlen = Math.sqrt(wx * wx + wy * wy + wz * wz) || 1;
wx /= wlen; wy /= wlen; wz /= wlen;
// Greedy mesh walk from r toward the displaced point
let cur = r;
let bestDot = wx * px + wy * py + wz * pz;
for (;;) {
let moved = false;
for (let i = adjOffset[cur], iEnd = adjOffset[cur + 1]; i < iEnd; i++) {
const nb = adjList[i];
const dot = wx * r_xyz[3 * nb] + wy * r_xyz[3 * nb + 1] + wz * r_xyz[3 * nb + 2];
if (dot > bestDot) {
bestDot = dot;
cur = nb;
moved = true;
}
}
if (!moved) break;
}
out[r] = r_elevation[cur];
}
// Weighted max: pick whichever is larger, biased by strength
// At strength≈0 → 75% original, at strength=1 → 75% warped
// Dampen near hotspots so volcanic peaks keep their sculpted shape
const warpBias = WARP_BIAS_BASE + WARP_BIAS_STRENGTH_SCALE * strength;
for (let r = 0; r < N; r++) {
const orig = r_elevation[r];
const warped = out[r];
let bias = warpBias;
if (r_hotspot) {
const hotFrac = Math.min(1, Math.abs(r_hotspot[r]) / (Math.abs(orig) || 1));
bias *= 1 - WARP_HOTSPOT_DAMPEN * hotFrac;
}
if (warped > orig) {
r_elevation[r] = orig + (warped - orig) * bias;
} else {
r_elevation[r] = warped + (orig - warped) * (1 - bias);
}
}
}
/**
* Bilateral-weighted Laplacian smoothing.
* Neighbors with similar elevation receive more weight, preserving ridges
* and trenches while blending the banded artefacts from BFS distance fields.
* Coastline cells (land adjacent to ocean) are locked to prevent drift.
*/
export function smoothElevation(mesh, r_elevation, r_isOcean, iterations, strength) {
const N = mesh.numRegions;
const tmp = new Float32Array(N);
const { adjOffset, adjList } = mesh;
// Pre-compute coastline lock: land cells adjacent to at least one ocean cell
const locked = new Uint8Array(N);
for (let r = 0; r < N; r++) {
if (r_isOcean[r]) continue;
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
if (r_isOcean[adjList[i]]) { locked[r] = 1; break; }
}
}
for (let iter = 0; iter < iterations; iter++) {
for (let r = 0; r < N; r++) {
if (locked[r]) { tmp[r] = r_elevation[r]; continue; }
const h = r_elevation[r];
let wSum = 0, hSum = 0;
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
const nh = r_elevation[adjList[i]];
const diff = Math.abs(nh - h);
const w = 1 / (1 + diff * SMOOTH_EDGE_SENSITIVITY);
wSum += w;
hSum += nh * w;
}
if (wSum > 0) {
const avg = hSum / wSum;
tmp[r] = h + (avg - h) * strength;
} else {
tmp[r] = h;
}
}
// Copy back
for (let r = 0; r < N; r++) r_elevation[r] = tmp[r];
}
}
/**
* Combined iterative erosion — interleaves hydraulic (stream power) and
* thermal (talus-angle) passes so they interact each iteration.
*
* Hydraulic: Braun-Willett implicit stream power. Rebuilds drainage graph
* each iteration so carved valleys attract more flow.
*
* Thermal: Slope-driven material transport. Redistributes material from
* steep slopes to lower neighbors using a simultaneous delta buffer.
*
* Each iteration runs one hydraulic step then one thermal step (if their
* respective iteration counts haven't been exhausted).
*/
export function erodeComposite(mesh, r_elevation, r_xyz, r_isOcean,
hIters, K, m, dt,
tIters, talusSlope, kThermal,
gIters, glacialStrength,
neighborDist)
{
gIters = gIters || 0;
glacialStrength = glacialStrength || 0;
const totalIters = Math.max(hIters, tIters, gIters);
if (totalIters <= 0) return;
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
// Collect land cell indices
const landCells = [];
for (let r = 0; r < N; r++) {
if (!r_isOcean[r]) landCells.push(r);
}
const landCount = landCells.length;
if (landCount === 0) return;
// Shared buffers
const drainTarget = new Int32Array(N);
const cellDist = new Float32Array(N);
const flow = new Float32Array(N);
const delta = new Float32Array(N);
// Priority-flood pit resolution: ensure every land cell drains to ocean
// before hydraulic erosion begins. Carves canyons through spill points.
if (hIters > 0) {
priorityFloodCarve(mesh, r_elevation, r_isOcean, GLACIAL_INITIAL_CARVE);
}
// ---- Glacial precomputation (once — index is position-based) ----
let glacIdx = null;
let iceTarget = null;
let iceFlow = null;
let numIceUpstream = null;
if (gIters > 0 && glacialStrength > 0) {
function smoothstep(x, edge0, edge1) {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
glacIdx = new Float32Array(N);
// At strength=1 glaciation starts at ~50° latitude; at 0.5 it starts at ~70°
const thresholdLat = Math.PI / 2 - glacialStrength * Math.PI / GLACIAL_LAT_DIVISOR;
for (let r = 0; r < N; r++) {
if (r_isOcean[r]) continue;
const y = r_xyz[3 * r + 1];
const polarDist = Math.abs(Math.asin(Math.max(-1, Math.min(1, y))));
const latFactor = smoothstep(polarDist, thresholdLat, Math.PI / 2);
const elevFactor = smoothstep(r_elevation[r], GLACIAL_ELEV_LOW, GLACIAL_ELEV_HIGH);
const latScale = smoothstep(polarDist, Math.PI / 8, Math.PI / 3);
glacIdx[r] = Math.max(latFactor, elevFactor * GLACIAL_ELEV_FACTOR_SCALE * (GLACIAL_ELEV_FACTOR_LAT_BASE + GLACIAL_ELEV_FACTOR_LAT_SCALE * latScale)) * glacialStrength;
}
iceTarget = new Int32Array(N);
iceFlow = new Float32Array(N);
numIceUpstream = new Uint8Array(N);
}
// Per-iteration glacial rates (scaled so total effect ≈ same regardless of iter count)
const gScale = gIters > 0 ? 1.0 / gIters : 0;
const gCarveRate = GLACIAL_CARVE_RATE * gScale;
const gConvergenceBonus = GLACIAL_CONVERGENCE_BONUS * gScale;
const gDepositAmount = GLACIAL_DEPOSIT_AMOUNT * gScale;
const gFjordCarve = GLACIAL_FJORD_CARVE * gScale;
const gFlowThreshold = GLACIAL_FLOW_THRESHOLD;
const gFjordThreshold = GLACIAL_FJORD_THRESHOLD;
// Mid-loop drainage fix: at 75% of iterations, run a carve-biased
// priority-flood to cut outlets through basins created by glaciation.
const midFloodIter = Math.round(totalIters * GLACIAL_MID_FLOOD_FRAC);
let midFloodDone = false;
// Pre-allocate thermal erosion buffers (max neighbor degree)
let maxDeg = 0;
for (let r = 0; r < N; r++) {
const deg = adjOffset[r + 1] - adjOffset[r];
if (deg > maxDeg) maxDeg = deg;
}
const excNb = new Int32Array(maxDeg);
const excVal = new Float32Array(maxDeg);
const excAdjIdx = new Int32Array(maxDeg);
const excSlope = new Float32Array(maxDeg);
for (let iter = 0; iter < totalIters; iter++) {
if (!midFloodDone && iter >= midFloodIter) {
midFloodDone = true;
priorityFloodCarve(mesh, r_elevation, r_isOcean, GLACIAL_MID_FLOOD_CARVE);
}
// Sort land cells by descending elevation — needed by glacial ice flow
// and hydraulic flow accumulation. If glacial runs this iteration and
// hydraulic follows, glacial modifies elevations so we re-sort before hydraulic.
const glacialThisIter = iter < gIters && glacIdx;
const hydraulicThisIter = iter < hIters;
if (glacialThisIter || hydraulicThisIter) {
landCells.sort((a, b) => r_elevation[b] - r_elevation[a]);
}
// ---- Glacial step ----
if (glacialThisIter) {
// Rebuild ice drainage from current elevations
iceTarget.fill(-1);
numIceUpstream.fill(0);
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
if (glacIdx[r] <= 0) continue;
const h = r_elevation[r];
let bestNb = -1, bestDrop = 0;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
const drop = h - r_elevation[nb];
if (drop > bestDrop) { bestDrop = drop; bestNb = nb; }
}
if (bestNb >= 0) iceTarget[r] = bestNb;
}
// Accumulate ice flow downstream
for (let r = 0; r < N; r++) iceFlow[r] = glacIdx[r];
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
const target = iceTarget[r];
if (target >= 0 && iceFlow[r] > 0) {
iceFlow[target] += iceFlow[r];
numIceUpstream[target]++;
}
}
// Carving: deepening + widening + over-deepening
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
if (iceFlow[r] <= gFlowThreshold) continue;
const deepening = gCarveRate * Math.pow(iceFlow[r], 0.6) * glacialStrength;
r_elevation[r] -= deepening;
// Valley widening for U-shape
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (r_isOcean[nb]) continue;
const d = neighborDist[j] || 1e-6;
const slope = Math.abs(r_elevation[r] - r_elevation[nb]) / d;
r_elevation[nb] -= deepening * GLACIAL_WIDENING_FRAC * Math.max(0, 1 - slope);
}
// Over-deepening at convergence zones
if (numIceUpstream[r] >= 2) {
r_elevation[r] -= gConvergenceBonus * Math.pow(iceFlow[r], 0.4);
}
}
// Moraine deposition at glacier termini
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
if (iceFlow[r] <= gFlowThreshold) continue;
const target = iceTarget[r];
if (target < 0 || r_isOcean[target]) continue;
if (glacIdx[target] < glacIdx[r] * GLACIAL_TERMINUS_RATIO) {
r_elevation[target] += gDepositAmount * Math.pow(iceFlow[r], 0.3);
}
}
// Fjord enhancement on coastal glaciated cells
for (let r = 0; r < N; r++) {
if (r_isOcean[r]) continue;
if (glacIdx[r] <= GLACIAL_FJORD_ICE_MIN || iceFlow[r] <= gFjordThreshold) continue;
let isCoastal = false;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
if (r_isOcean[adjList[j]]) { isCoastal = true; break; }
}
if (isCoastal) {
r_elevation[r] -= gFjordCarve * Math.pow(iceFlow[r], 0.5);
if (r_elevation[r] < 0) r_elevation[r] = 0;
}
}
// Clamp: land stays land
for (let r = 0; r < N; r++) {
if (!r_isOcean[r] && r_elevation[r] < 0) r_elevation[r] = 0;
}
}
// ---- Hydraulic step ----
if (hydraulicThisIter) {
// Re-sort if glacial step modified elevations this iteration
if (glacialThisIter) {
landCells.sort((a, b) => r_elevation[b] - r_elevation[a]);
}
// Build drainage graph (steepest descent)
drainTarget.fill(-1);
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
const h = r_elevation[r];
let bestNb = -1, bestDrop = -Infinity, bestJ = -1;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
const drop = h - r_elevation[nb];
if (drop > bestDrop) {
bestDrop = drop;
bestNb = nb;
bestJ = j;
}
}
// Pit handling: drain to least-steep-ascent neighbor
if (bestDrop <= 0) {
let minAscent = Infinity;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
const ascent = r_elevation[nb] - h;
if (ascent < minAscent) {
minAscent = ascent;
bestNb = nb;
bestJ = j;
}
}
}
if (bestNb >= 0) {
drainTarget[r] = bestNb;
cellDist[r] = neighborDist[bestJ] || 1e-6;
}
}
// Flow accumulation (already sorted descending at top of iteration)
flow.fill(0);
for (let i = 0; i < landCount; i++) flow[landCells[i]] = 1;
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
const target = drainTarget[r];
if (target >= 0) flow[target] += flow[r];
}
// Implicit stream power solve (ascending elevation order) + sediment deposition
for (let i = landCount - 1; i >= 0; i--) {
const r = landCells[i];
const target = drainTarget[r];
if (target < 0 || cellDist[r] <= 0) continue;
const factor = K * Math.pow(flow[r], m) * dt / cellDist[r];
const h_receiver = Math.max(r_elevation[target], 0);
let h_new = (r_elevation[r] + factor * h_receiver) / (1 + factor);
if (h_new < h_receiver) h_new = h_receiver;
if (h_new < 0) h_new = 0;
// Sediment deposition: deposit fraction of eroded material at receiver
const eroded = r_elevation[r] - h_new;
if (eroded > 0 && !r_isOcean[target]) {
const drainOfTarget = drainTarget[target];
let receiverSlope = 0;
if (drainOfTarget >= 0 && cellDist[target] > 0) {
receiverSlope = Math.abs(r_elevation[target] - r_elevation[drainOfTarget]) / cellDist[target];
}
const depositFrac = HYDRAULIC_DEPOSIT_FRAC / (1 + receiverSlope * HYDRAULIC_SLOPE_SENSITIVITY);
const deposit = eroded * depositFrac;
r_elevation[target] += deposit;
if (r_elevation[target] > h_new) r_elevation[target] = h_new;
}
r_elevation[r] = h_new;
}
}
// ---- Thermal step ----
if (iter < tIters) {
delta.fill(0);
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
const h = r_elevation[r];
let totalExcess = 0;
let excCount = 0;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (r_isOcean[nb]) continue;
const nh = r_elevation[nb];
if (nh >= h) continue;
const d = neighborDist[j] || 1e-6;
const slope = (h - nh) / d;
if (slope > talusSlope) {
const excess = (slope - talusSlope) * d;
excNb[excCount] = nb;
excVal[excCount] = excess;
excAdjIdx[excCount] = j;
excCount++;
totalExcess += excess;
}
}
if (totalExcess <= 0) continue;
// Slope-weighted distribution: steeper neighbors get more debris
let totalSlopeWeighted = 0;
for (let k = 0; k < excCount; k++) {
const d = neighborDist[excAdjIdx[k]] || 1e-6;
excSlope[k] = (h - r_elevation[excNb[k]]) / d;
totalSlopeWeighted += excVal[k] * excSlope[k];
}
const transfer = kThermal * totalExcess * THERMAL_TRANSFER_FRAC;
if (totalSlopeWeighted > 0) {
for (let k = 0; k < excCount; k++) {
const share = (excVal[k] * excSlope[k] / totalSlopeWeighted) * transfer;
delta[r] -= share;
delta[excNb[k]] += share;
}
} else {
for (let k = 0; k < excCount; k++) {
const share = (excVal[k] / totalExcess) * transfer;
delta[r] -= share;
delta[excNb[k]] += share;
}
}
}
for (let i = 0; i < landCount; i++) {
r_elevation[landCells[i]] += delta[landCells[i]];
}
}
}
// Post-loop: light Laplacian smooth on glaciated cells to blend carving edges
if (glacIdx) {
const tmp = new Float32Array(r_elevation);
for (let r = 0; r < N; r++) {
if (r_isOcean[r] || glacIdx[r] <= 0) continue;
let sum = 0, count = 0;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
if (!r_isOcean[adjList[j]]) { sum += r_elevation[adjList[j]]; count++; }
}
if (count > 0) {
const avg = sum / count;
tmp[r] = r_elevation[r] + (avg - r_elevation[r]) * GLACIAL_POST_SMOOTH;
}
}
for (let r = 0; r < N; r++) {
if (!r_isOcean[r] && glacIdx[r] > 0) r_elevation[r] = tmp[r];
}
}
}
/**
* Ridge sharpening — pushes cells that sit above their neighborhood average
* further upward, accentuating ridgelines without creating unrealistic spikes.
*/
export function sharpenRidges(mesh, r_elevation, r_isOcean, iterations, strength) {
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
// Pre-build land cell list to skip ~40% ocean cells each iteration
const landCells = [];
for (let r = 0; r < N; r++) {
if (!r_isOcean[r]) landCells.push(r);
}
const landCount = landCells.length;
const tmp = new Float32Array(N);
const original = new Float32Array(r_elevation);
for (let iter = 0; iter < iterations; iter++) {
for (let li = 0; li < landCount; li++) {
const r = landCells[li];
const h = r_elevation[r];
let sum = 0;
const count = adjOffset[r + 1] - adjOffset[r];
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
sum += r_elevation[adjList[i]];
}
if (count === 0) { tmp[r] = h; continue; }
const avg = sum / count;
if (h > avg) {
// Ridge sharpening: push peaks up
let h_new = h + (h - avg) * strength;
// Clamp: don't exceed 1.5x original elevation
const cap = original[r] * RIDGE_SHARPEN_CAP;
if (h_new > cap) h_new = cap;
tmp[r] = h_new;
} else if (h < avg) {
// Valley deepening: push valleys down (weaker than ridge sharpening)
const VALLEY_FACTOR = VALLEY_DEEPEN_FACTOR;
let h_new = h - (avg - h) * strength * VALLEY_FACTOR;
// Floor cap: don't go below 0.5x original (symmetric to 1.5x ceiling)
const floor = original[r] * VALLEY_FLOOR_FRAC;
if (original[r] > 0 && h_new < floor) h_new = floor;
// Don't push land below sea level
if (original[r] > 0 && h_new < VALLEY_FLOOR_MIN) h_new = VALLEY_FLOOR_MIN;
tmp[r] = h_new;
} else {
tmp[r] = h;
}
}
for (let li = 0; li < landCount; li++) r_elevation[landCells[li]] = tmp[landCells[li]];
}
}
/**
* Soil creep — simple Laplacian diffusion on land cells.
* Unlike bilateral smoothing, this doesn't preserve ridges — it uniformly
* rounds off hillslopes. Coastline cells are locked.
*/
export function applySoilCreep(mesh, r_elevation, r_isOcean, iterations, strength) {
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
// Pre-build interior land cell list: skip ocean cells and coastline-locked cells
const interiorLand = [];
for (let r = 0; r < N; r++) {
if (r_isOcean[r]) continue;
let coastal = false;
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
if (r_isOcean[adjList[i]]) { coastal = true; break; }
}
if (!coastal) interiorLand.push(r);
}
const ilCount = interiorLand.length;
const tmp = new Float32Array(N);
for (let iter = 0; iter < iterations; iter++) {
for (let li = 0; li < ilCount; li++) {
const r = interiorLand[li];
const h = r_elevation[r];
let sum = 0, count = 0;
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
if (!r_isOcean[adjList[i]]) {
sum += r_elevation[adjList[i]];
count++;
}
}
if (count === 0) { tmp[r] = h; continue; }
const avg = sum / count;
tmp[r] = h + (avg - h) * strength;
}
for (let li = 0; li < ilCount; li++) r_elevation[interiorLand[li]] = tmp[interiorLand[li]];
}
}
+578
View File
@@ -0,0 +1,578 @@
// Exporting a planet as Unreal landscape tiles.
//
// Unreal's landscape importer will not take either of Orogen's two existing exports. The preview is a
// picture. The heightmap is one flat 8192 x 4096 PNG with no scale attached to it - the ramp is absolute,
// so the shades mean metres, but nothing in the file says how wide the planet is, and without that there is
// no answer to "how many metres is a pixel". What the importer wants is per-tile 16-bit greyscale PNGs at
// exactly 255*N+1 vertices, one Landscape actor each, plus a separate 8-bit weightmap per paint layer, at
// the sample spacing the game actually uses. This module writes that, straight out of the planet.
//
// Three things about it are the whole design.
//
// **The scale is an input, not a guess.** A sphere mesh has no metres on it; `planet_circumference_km` is
// what turns the window's degrees into ground. It is asked for rather than derived because it cannot be
// derived, and because it is the number that decides how much land a window holds. The report prints what
// the choice bought, including the east-west stretch at the window's edges, so a window that does not fit
// on the planet says so instead of quietly producing 30 km of ground on a 31.8 km world.
//
// **The window is sampled once, then cut.** The planet is rendered into one float raster over the window,
// and every tile is resampled out of that raster by its *global* vertex position. This is what closes the
// seams: a vertex column shared by two neighbours is computed from the same source coordinates twice and
// comes out bit-identical, so nothing has to blend or stitch. Rendering each tile under its own camera
// would have been one step shorter and would have put a rasteriser's floating-point luck on every seam,
// where one 16-bit step is 11 cm of crack.
//
// **The intermediate is float and it is the window.** The old path read a whole-planet 16-bit PNG and spent
// its resolution on the whole planet; this spends all of it on the window, and carries kilometres as
// float32 rather than quantised to a -5000..6000 m ramp. Both matter less than they sound, because the
// sphere mesh only resolves a couple of hundred metres and no amount of sampling invents what is not there
// - the detail below that is still the Go generator's job. What they do buy is that nothing downstream has
// to know a magic number.
//
// The resampler is the Catmull-Rom from generate_region_tiles.py, clamped to its two central taps for the
// same reason: a plain cubic overshoots at a step, the steps here are coastlines, and unclamped every shore
// gets a raised lip on the land side and a trench on the sea side.
import { renderHeightWindowKm } from './unreal-render.js';
import { encodeGray16, encodeGray8 } from './png-write.js';
// One vertex of the neighbours on every side, sampled before the paint layers are derived and thrown away
// after. The layers read slope, a one-sided difference at an array edge is not what the neighbouring tile
// computes for that same vertex, and without this every tile boundary is a one-vertex line of different
// paint. One vertex is all a central difference needs.
const LAYER_MARGIN = 1;
// Pixels of the intermediate raster rendered beyond the window on each side, so the resampler's outer taps
// and the layer margin read real ground instead of a clamped edge. Catmull-Rom reaches two pixels.
const RASTER_PAD = 4;
export const LAYER_NAMES = ['Base_Layer', 'Layer_02', 'Layer_03']; // rock, meadow, high rock
// The defaults are a window that actually fits on this project's planet, which is a smaller one than it
// looks. Planet.json is 100 km round, so the whole globe is 3183 km2 of surface; the 936 km2 square the
// numpy region pipeline cuts is 29% of it, and that is why that window reads as 110 degrees on a side and
// stretches by three quarters at its edge. Four tiles by two is 20.4 x 10.2 km, 208 km2, and about 5%
// stretch at the edge - a window a sphere this size can actually hold flat.
export const DEFAULTS = {
level: '/Game/Maps/L_Region',
planet_circumference_km: 100,
centre: { lon_deg: 0, lat_deg: 0 },
tiles: { columns: 4, rows: 2, vertices: 2551 },
quad_cm: 200,
sea_level_m: 0,
spawn_pad_m: 150,
streaming_grid_components: 5,
elevation_m: { min: -1024, max: 6144 },
sea_scale: 0.17,
source_metres_per_pixel: 8,
layers: {
rock_slope_start: 0.55,
rock_slope_full: 1.05,
high_altitude_start_m: 1400,
high_altitude_full_m: 2000,
breakup_m: 18,
breakup_cells: 24,
breakup_seed: 7,
},
};
// ── Geometry ────────────────────────────────────────────────────────────────────────────────────────
/**
* Everything that follows from the options, with nothing rendered yet. Cheap, so the UI can call it on
* every keystroke to show what a setting buys - this is the export's equivalent of `--scout`.
*/
export function planRegion(opts) {
const o = mergeDefaults(opts);
const vertices = o.tiles.vertices;
if ((vertices - 1) % 255 !== 0) {
throw new Error(`tiles.vertices must be 255 * N + 1 (2551, 2041, 1021 ...), not ${vertices}`);
}
const quadsPerTile = vertices - 1;
const quadM = o.quad_cm / 100;
const quadsX = o.tiles.columns * quadsPerTile;
const quadsY = o.tiles.rows * quadsPerTile;
const widthM = quadsX * quadM;
const heightM = quadsY * quadM;
const radiusM = o.planet_circumference_km * 1000 / (2 * Math.PI);
const centreLat = o.centre.lat_deg * Math.PI / 180;
const centreLon = o.centre.lon_deg * Math.PI / 180;
const latSpan = heightM / radiusM;
// Cosine-corrected at the centre latitude, so ground metres are right there rather than only at the
// equator. A flat reading of an equirectangular map stretches east-west by 1/cos(latitude); this puts
// the error at zero in the middle of the window and splits it between the north and south edges.
const lonSpan = widthM / (radiusM * Math.cos(centreLat));
const latMin = centreLat - latSpan / 2;
const latMax = centreLat + latSpan / 2;
const innerW = Math.max(2, Math.round(widthM / o.source_metres_per_pixel) + 1);
const innerH = Math.max(2, Math.round(heightM / o.source_metres_per_pixel) + 1);
// What the flat reading costs at the window's edges: 1 at the centre latitude by construction.
const stretchAt = lat => Math.cos(centreLat) / Math.cos(Math.max(-1.55, Math.min(1.55, lat)));
const warnings = [];
if (latSpan >= Math.PI) {
warnings.push(`the window is ${(latSpan * 180 / Math.PI).toFixed(0)} degrees of latitude tall, which `
+ `is more than the planet has. Raise planet_circumference_km or use fewer tiles.`);
}
if (lonSpan >= 2 * Math.PI) {
warnings.push(`the window wraps the planet more than once at this latitude. Raise `
+ `planet_circumference_km or use fewer tiles.`);
}
if (Math.abs(latMax) > 1.4 || Math.abs(latMin) > 1.4) {
warnings.push('the window reaches past 80 degrees of latitude, where an equirectangular reading '
+ 'stretches without bound. Move the centre towards the equator.');
}
const worstStretch = Math.max(stretchAt(latMin), stretchAt(latMax));
if (worstStretch > 1.1 && warnings.length === 0) {
warnings.push(`the ground is stretched east-west by up to ${((worstStretch - 1) * 100).toFixed(1)}% `
+ 'at the window\'s edge. A window this tall on a planet this small cannot avoid it; a bigger '
+ 'planet_circumference_km or fewer rows would.');
}
return {
options: o,
quadsPerTile, quadM, quadsX, quadsY, widthM, heightM,
areaKm2: widthM * heightM / 1e6,
tileSideM: quadsPerTile * quadM,
tileCount: o.tiles.columns * o.tiles.rows,
componentsPerTile: (quadsPerTile / 255) ** 2,
radiusM, centreLat, centreLon, latSpan, lonSpan, latMin, latMax,
lonMin: centreLon - lonSpan / 2,
lonMax: centreLon + lonSpan / 2,
innerW, innerH,
rasterW: innerW + 2 * RASTER_PAD,
rasterH: innerH + 2 * RASTER_PAD,
metresPerPixel: widthM / (innerW - 1),
stretchNorth: stretchAt(latMax),
stretchSouth: stretchAt(latMin),
warnings,
};
}
function mergeDefaults(opts) {
const o = { ...DEFAULTS, ...(opts || {}) };
o.centre = { ...DEFAULTS.centre, ...(opts && opts.centre) };
o.tiles = { ...DEFAULTS.tiles, ...(opts && opts.tiles) };
o.elevation_m = { ...DEFAULTS.elevation_m, ...(opts && opts.elevation_m) };
o.layers = { ...DEFAULTS.layers, ...(opts && opts.layers) };
return o;
}
// ── Resampling ──────────────────────────────────────────────────────────────────────────────────────
/** Catmull-Rom weights for taps at -1, 0, +1, +2. */
function cubicWeights(t) {
const t2 = t * t, t3 = t2 * t;
return [
-0.5 * t3 + t2 - 0.5 * t,
1.5 * t3 - 2.5 * t2 + 1.0,
-1.5 * t3 + 2.0 * t2 + 0.5 * t,
0.5 * t3 - 0.5 * t2,
];
}
/**
* One separable pass of clamped Catmull-Rom along x: `src` is srcW wide and `rows` tall, `coords` are
* float source columns. Held between the two central taps, which is what stops it ringing at a coastline.
*/
function resampleX(src, srcW, rows, coords) {
const outW = coords.length;
const out = new Float32Array(rows * outW);
const clampIdx = i => (i < 0 ? 0 : i >= srcW ? srcW - 1 : i);
for (let o = 0; o < outW; o++) {
const c = coords[o];
const i = Math.floor(c);
const w = cubicWeights(c - i);
const i0 = clampIdx(i - 1), i1 = clampIdx(i), i2 = clampIdx(i + 1), i3 = clampIdx(i + 2);
for (let r = 0; r < rows; r++) {
const base = r * srcW;
const a = src[base + i0], b = src[base + i1], c2 = src[base + i2], d = src[base + i3];
let v = a * w[0] + b * w[1] + c2 * w[2] + d * w[3];
const lo = b < c2 ? b : c2, hi = b < c2 ? c2 : b;
out[r * outW + o] = v < lo ? lo : v > hi ? hi : v;
}
}
return out;
}
/** The same along y: `src` is width wide and srcH tall, `coords` are float source rows. */
function resampleY(src, width, srcH, coords) {
const outH = coords.length;
const out = new Float32Array(outH * width);
const clampIdx = j => (j < 0 ? 0 : j >= srcH ? srcH - 1 : j);
for (let o = 0; o < outH; o++) {
const c = coords[o];
const j = Math.floor(c);
const w = cubicWeights(c - j);
const r0 = clampIdx(j - 1) * width, r1 = clampIdx(j) * width;
const r2 = clampIdx(j + 1) * width, r3 = clampIdx(j + 2) * width;
const dst = o * width;
for (let x = 0; x < width; x++) {
const a = src[r0 + x], b = src[r1 + x], c2 = src[r2 + x], d = src[r3 + x];
let v = a * w[0] + b * w[1] + c2 * w[2] + d * w[3];
const lo = b < c2 ? b : c2, hi = b < c2 ? c2 : b;
out[dst + x] = v < lo ? lo : v > hi ? hi : v;
}
}
return out;
}
// ── Break-up noise ──────────────────────────────────────────────────────────────────────────────────
//
// Value-noise fBm on a periodic lattice, sampled at *global* window coordinates so a tile boundary is not
// a discontinuity in the paint. The lattice values come from a hash of (seed, octave, cell) rather than
// from a stream of random numbers, which is what makes a single tile computable without generating the
// ones before it. This is the same shape as heightmap_noise.fbm_at but not the same numbers: numpy's PCG64
// stream cannot be reproduced here, and it does not need to be - the two pipelines are alternatives, never
// mixed, and this noise only decides where a paint boundary wobbles.
function hash01(seed, octave, cells, i, j) {
let h = (seed ^ Math.imul(octave + 1, 0x9E3779B1)) >>> 0;
h = Math.imul(h ^ Math.imul(i, 0x27D4EB2D), 0x165667B1);
h = Math.imul(h ^ Math.imul(j, 0x85EBCA77), 0xC2B2AE3D);
h = Math.imul(h ^ cells, 0x27D4EB2F);
h ^= h >>> 15; h = Math.imul(h, 0x2545F491); h ^= h >>> 13;
return (h >>> 0) / 4294967296;
}
const smoothstep = t => t * t * (3 - 2 * t);
function fbmAt(u, v, seed, baseCells, octaves = 4, gain = 0.5) {
let total = 0, amplitude = 1, cells = baseCells, norm = 0;
for (let o = 0; o < octaves; o++) {
const su = u * cells, sv = v * cells;
const i0 = Math.floor(su), j0 = Math.floor(sv);
const tu = smoothstep(su - i0), tv = smoothstep(sv - j0);
const ia = ((i0 % cells) + cells) % cells, ja = ((j0 % cells) + cells) % cells;
const ib = (ia + 1) % cells, jb = (ja + 1) % cells;
const top = hash01(seed, o, cells, ia, ja) * (1 - tu) + hash01(seed, o, cells, ib, ja) * tu;
const bottom = hash01(seed, o, cells, ia, jb) * (1 - tu) + hash01(seed, o, cells, ib, jb) * tu;
total += (top * (1 - tv) + bottom * tv) * amplitude;
norm += amplitude;
amplitude *= gain;
cells *= 2;
}
return total / norm;
}
// ── One tile ────────────────────────────────────────────────────────────────────────────────────────
/** Global vertex indices along one axis for a tile, with `margin` extra on each side. */
function tileVertices(plan, tile, margin) {
const n = plan.options.tiles.vertices + 2 * margin;
const out = new Float64Array(n);
for (let k = 0; k < n; k++) out[k] = tile * plan.quadsPerTile + (k - margin);
return out;
}
/** Global vertex indices to raster pixel coordinates. RASTER_PAD is where the window's first vertex sits. */
function toRasterCoords(plan, vertices, axis) {
const inner = axis === 0 ? plan.innerW : plan.innerH;
const quads = axis === 0 ? plan.quadsX : plan.quadsY;
const out = new Float64Array(vertices.length);
for (let k = 0; k < vertices.length; k++) {
out[k] = RASTER_PAD + vertices[k] * (inner - 1) / quads;
}
return out;
}
/** One tile's height in metres, sampled out of the window raster by global position. */
function tileMetres(plan, raster, tx, ty, margin) {
const vx = tileVertices(plan, tx, margin);
const vy = tileVertices(plan, ty, margin);
const sx = toRasterCoords(plan, vx, 0);
const sy = toRasterCoords(plan, vy, 1);
// Only the raster rows this tile reaches, so a tile costs a band rather than the whole window.
const row0 = Math.max(0, Math.floor(sy[0]) - 1);
const row1 = Math.min(plan.rasterH, Math.floor(sy[sy.length - 1]) + 3);
const bandRows = row1 - row0;
const band = raster.subarray(row0 * plan.rasterW, row1 * plan.rasterW);
const afterX = resampleX(band, plan.rasterW, bandRows, sx);
const shifted = new Float64Array(sy.length);
for (let k = 0; k < sy.length; k++) shifted[k] = sy[k] - row0;
const km = resampleY(afterX, sx.length, bandRows, shifted);
const out = new Float32Array(km.length);
const seaScale = plan.options.sea_scale;
for (let k = 0; k < km.length; k++) {
let m = km[k] * 1000;
if (m < 0) m *= seaScale;
out[k] = m;
}
return { metres: out, vx, vy, width: sx.length, height: sy.length };
}
/**
* The flat disc at the centre of the *window* for the player starts, blended over a second radius. It is
* computed from global position, so where it crosses a tile boundary the two tiles agree on it.
*/
function applySpawnPad(plan, tile, padMetres) {
const radius = plan.options.spawn_pad_m;
if (radius <= 0) return;
const { metres, vx, vy, width, height } = tile;
const quadM = plan.quadM;
for (let j = 0; j < height; j++) {
const dy = (vy[j] - plan.quadsY / 2) * quadM;
for (let i = 0; i < width; i++) {
const dx = (vx[i] - plan.quadsX / 2) * quadM;
const dist = Math.hypot(dx, dy);
const t = Math.max(0, Math.min(1, 1 - (dist - radius) / radius));
if (t <= 0) continue;
const w = smoothstep(t);
const at = j * width + i;
metres[at] = metres[at] * (1 - w) + padMetres * w;
}
}
}
/**
* The pack's three paint layers from height and slope: meadow everywhere, rock by slope, high rock by
* altitude, with an fBm break-up so neither boundary is a contour line. There is no erosion on this path,
* so unlike L_World's version there is no wear, curvature or deposit term. `tile` carries LAYER_MARGIN
* vertices of its neighbours on every side; the layers are computed over the lot and the margin cropped at
* the end, so the slope at a tile's edge is the central difference its neighbour computes there too.
*/
function deriveLayers(plan, tile) {
const rules = plan.options.layers;
const { metres, vx, vy, width, height } = tile;
const quadM = plan.quadM;
const margin = LAYER_MARGIN;
const outW = width - 2 * margin, outH = height - 2 * margin;
// Both axes divided by the *longer* one, so the noise stays square on the ground and, because neither
// coordinate then exceeds 1, it never repeats across the window.
const span = Math.max(plan.quadsX, plan.quadsY);
const breakupM = rules.breakup_m;
const slopeBreakupScale = breakupM ? 0.12 / breakupM : 0;
const layers = {};
for (const name of LAYER_NAMES) layers[name] = new Uint8Array(outW * outH);
for (let j = margin; j < height - margin; j++) {
for (let i = margin; i < width - margin; i++) {
const at = j * width + i;
// Central differences, which is why the margin is here.
const gx = (metres[at + 1] - metres[at - 1]) / (2 * quadM);
const gy = (metres[at + width] - metres[at - width]) / (2 * quadM);
const slope = Math.hypot(gx, gy);
const noise = fbmAt(vx[i] / span, vy[j] / span, rules.breakup_seed | 0, rules.breakup_cells | 0);
const breakup = (noise - 0.5) * 2 * breakupM;
let rock = smoothstep(Math.max(0, Math.min(1,
(slope + breakup * slopeBreakupScale - rules.rock_slope_start)
/ (rules.rock_slope_full - rules.rock_slope_start))));
let high = smoothstep(Math.max(0, Math.min(1,
(metres[at] + breakup - rules.high_altitude_start_m)
/ (rules.high_altitude_full_m - rules.high_altitude_start_m))));
high = high * (1 - rock * 0.5);
const meadow = Math.max(0, Math.min(1, 1 - rock - high));
const total = Math.max(meadow + rock + high, 1e-6);
const out = (j - margin) * outW + (i - margin);
layers.Base_Layer[out] = Math.round(rock / total * 255);
layers.Layer_02[out] = Math.round(meadow / total * 255);
layers.Layer_03[out] = Math.round(high / total * 255);
}
}
return { layers, width: outW, height: outH };
}
/** Metres to the 16-bit code the manifest's elevation_m range defines. */
function encodeHeights(plan, tile) {
const { metres, width, height } = tile;
const margin = LAYER_MARGIN;
const outW = width - 2 * margin, outH = height - 2 * margin;
const lo = plan.options.elevation_m.min, hi = plan.options.elevation_m.max;
const span = hi - lo;
const out = new Uint16Array(outW * outH);
let clipped = 0;
// Measured over the cropped tile, not over `metres`, which still carries the margin ring. On an outside
// tile that ring is sampled beyond the window, so a range taken across it can quote ground that is not
// in the world - and this number is what the manifest prints as "the ground came out X..Y m".
let minM = Infinity, maxM = -Infinity;
for (let j = 0; j < outH; j++) {
for (let i = 0; i < outW; i++) {
const m = metres[(j + margin) * width + (i + margin)];
if (m < minM) minM = m;
if (m > maxM) maxM = m;
if (m < lo || m > hi) clipped++;
const bounded = m < lo ? lo : m > hi ? hi : m;
const v = Math.round((bounded - lo) / span * 65535);
out[j * outW + i] = v < 0 ? 0 : v > 65535 ? 65535 : v;
}
}
return { heights: out, width: outW, height: outH, clipped: clipped / (outW * outH), minM, maxM };
}
// ── Writing ─────────────────────────────────────────────────────────────────────────────────────────
async function writeBlob(dirHandle, name, blob) {
const handle = await dirHandle.getFileHandle(name, { create: true });
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
}
async function fileExists(dirHandle, name) {
try {
await dirHandle.getFileHandle(name);
return true;
} catch {
return false; // NotFoundError, and anything else here means we cannot claim it is there
}
}
function tileName(plan, tx, ty) {
const level = plan.options.level.split('/').pop();
return `${level}_x${tx}_y${ty}`;
}
/**
* The manifest, in the shape RawContent/World/Region.json has - so the Unreal side reads this export with
* region_manifest.py exactly as it reads a hand-written one, and nothing downstream needs to know which
* tool cut the tiles.
*
* The `source` block is kept, and made honest. generate_region_tiles.py is what reads it, and it will not
* run against these tiles because they are already there; what it records is where the ground came from
* and at what scale, which used to be a number somebody chose and wrote in a comment.
*/
function regionManifest(plan, meta) {
const o = plan.options;
const deg = r => +(r * 180 / Math.PI).toFixed(6);
return {
_comment: 'Written by World Orogen\'s Unreal landscape export. The tiles in RegionTiles/ are a '
+ 'product of this file and the planet named below; generate_region_tiles.py is not in this '
+ 'path and does not need to run. Every key is explained in Scripts/Authoring/region_manifest.py.',
level: o.level,
_comment_tiles: `${o.tiles.columns} x ${o.tiles.rows} landscapes of ${o.tiles.vertices} vertices. `
+ `${plan.quadsPerTile} quads is ${plan.quadsPerTile / 255} x 255, so the engine gives each tile `
+ `${plan.componentsPerTile} components of 255 quads: ${plan.tileCount * plan.componentsPerTile} `
+ `over the window. Neighbours share their edge vertices, so the grid is ${plan.quadsX + 1} x `
+ `${plan.quadsY + 1} vertices, ${(plan.widthM / 1000).toFixed(2)} x `
+ `${(plan.heightM / 1000).toFixed(2)} km, ${plan.areaKm2.toFixed(0)} km2 of map.`,
tiles: { ...o.tiles },
quad_cm: o.quad_cm,
sea_level_m: o.sea_level_m,
spawn_pad_m: o.spawn_pad_m,
streaming_grid_components: o.streaming_grid_components,
elevation_m: { ...o.elevation_m },
_comment_elevation: `The ground came out ${meta.minM.toFixed(0)}..${meta.maxM.toFixed(0)} m, which `
+ `uses ${(meta.rampUsed * 100).toFixed(0)}% of the 16-bit ramp at `
+ `${((o.elevation_m.max - o.elevation_m.min) / 65535 * 100).toFixed(1)} cm a step. `
+ `${meta.clipped === 0 ? 'Nothing clips.' : (meta.clipped * 100).toFixed(3) + '% of vertices clip - widen elevation_m.'}`,
source: {
_comment: 'Rendered directly out of World Orogen rather than cut from a PNG, so the scale is '
+ 'recorded rather than chosen after the fact. metres_per_pixel is what one pixel of the '
+ 'intermediate float raster was worth; the tiles themselves are at quad_cm.',
kind: 'orogen_render',
planet: meta.planetCode || null,
planet_circumference_km: o.planet_circumference_km,
centre: { lon_deg: o.centre.lon_deg, lat_deg: o.centre.lat_deg },
window_deg: {
lon_min: deg(plan.lonMin), lon_max: deg(plan.lonMax),
lat_min: deg(plan.latMin), lat_max: deg(plan.latMax),
},
projection: 'equirectangular, cosine-corrected at the centre latitude',
east_west_stretch: { north: +plan.stretchNorth.toFixed(4), south: +plan.stretchSouth.toFixed(4) },
metres_per_pixel: +plan.metresPerPixel.toFixed(6),
// The window in pixels of the intermediate raster, so region_manifest.py's metres_per_pixel()
// has the same shape of answer here as it does for a manifest that names a PNG. The raster is
// rendered `pad` pixels wider on every side than the window, for the resampler's outer taps.
window: { x: RASTER_PAD, y: RASTER_PAD, width: plan.innerW, height: plan.innerH },
raster: { width: plan.rasterW, height: plan.rasterH, pad: RASTER_PAD },
elevation_m: { min: -5000, max: 6000 },
sea_scale: o.sea_scale,
exported: new Date().toISOString(),
},
layers: { ...o.layers },
};
}
/**
* Renders the window and writes the whole tile set, plus Region.json, into a directory the user picks.
*
* `dirHandle` should be the project's RawContent/World: the tiles go into RegionTiles/ beneath it and the
* manifest beside it, which is the layout create_region_world.py already reads. One tile is held in memory
* at a time; the window raster is the only large allocation and it is float32 over the window, not the
* planet.
*/
export async function exportUnrealRegion(opts, dirHandle, onProgress = () => {}) {
const plan = planRegion(opts);
const o = plan.options;
onProgress(0.02, 'Sampling the planet');
const raster = await renderHeightWindowKm({
lonMin: plan.lonMin - RASTER_PAD * plan.lonSpan / (plan.innerW - 1),
lonMax: plan.lonMax + RASTER_PAD * plan.lonSpan / (plan.innerW - 1),
latMin: plan.latMin - RASTER_PAD * plan.latSpan / (plan.innerH - 1),
latMax: plan.latMax + RASTER_PAD * plan.latSpan / (plan.innerH - 1),
width: plan.rasterW,
height: plan.rasterH,
onProgress: (f, label) => onProgress(0.02 + f * 0.18, label),
});
// The pad's height is read at the window's exact centre, once, so every tile it touches lifts to the
// same level. Never below the sea: a pad in the water is not a place to stand.
const cx = RASTER_PAD + (plan.innerW - 1) / 2;
const cy = RASTER_PAD + (plan.innerH - 1) / 2;
const centreKm = resampleY(
resampleX(raster, plan.rasterW, plan.rasterH, Float64Array.from([cx])),
1, plan.rasterH, Float64Array.from([cy]))[0];
let padMetres = centreKm * 1000;
if (padMetres < 0) padMetres *= o.sea_scale;
padMetres = Math.max(padMetres, o.sea_level_m + 30);
const tilesDir = await dirHandle.getDirectoryHandle('RegionTiles', { create: true });
const meta = { minM: Infinity, maxM: -Infinity, clipped: 0, planetCode: opts && opts.planet_code };
const total = plan.tileCount;
let done = 0;
for (let ty = 0; ty < o.tiles.rows; ty++) {
for (let tx = 0; tx < o.tiles.columns; tx++) {
const name = tileName(plan, tx, ty);
onProgress(0.2 + done / total * 0.8, `${name} (${done + 1}/${total})`);
const tile = tileMetres(plan, raster, tx, ty, LAYER_MARGIN);
applySpawnPad(plan, tile, padMetres);
const { heights, width, height, clipped, minM, maxM } = encodeHeights(plan, tile);
if (minM < meta.minM) meta.minM = minM;
if (maxM > meta.maxM) meta.maxM = maxM;
meta.clipped += clipped / total;
await writeBlob(tilesDir, `${name}_Height.png`, await encodeGray16(width, height, heights));
const derived = deriveLayers(plan, tile);
for (const layerName of LAYER_NAMES) {
await writeBlob(tilesDir, `${name}_${layerName}.png`,
await encodeGray8(derived.width, derived.height, derived.layers[layerName]));
}
done++;
await new Promise(r => setTimeout(r, 0));
}
}
meta.rampUsed = (meta.maxM - meta.minM) / (o.elevation_m.max - o.elevation_m.min);
const manifest = regionManifest(plan, meta);
// An existing Region.json is never replaced. The one in this project is hand-written and most of it is
// commentary explaining why each number is what it is; a generated file would throw all of that away,
// and the same rule already governs the terrain studio, which saves by patching a legend's *text* so its
// reasoning survives. The tiles are the product here - the manifest is a description of what was cut -
// so the generated one lands beside it under a name of its own and the caller is told which it got.
const manifestName = (await fileExists(dirHandle, 'Region.json'))
? 'Region.generated.json' : 'Region.json';
await writeBlob(dirHandle, manifestName,
new Blob([JSON.stringify(manifest, null, 2) + '\n'], { type: 'application/json' }));
onProgress(1, 'Done');
return { plan, meta, manifest, manifestName };
}
+185
View File
@@ -0,0 +1,185 @@
// Sampling the planet over a window, for the Unreal landscape export.
//
// The map exports in planet-mesh.js answer one question: "draw the whole planet at width W". Unreal needs
// a different one answered - "what is the ground, in metres, over *this* rectangle of the planet, at
// whatever sample spacing I ask for" - and this module is that question and nothing else. Keeping it
// separate is what lets unreal-export.js know about tiles and weightmaps without planet-mesh.js knowing
// about either.
//
// Heights come back as kilometres through the same fixed -5..6 km ramp `heightmapColor` uses, rather than
// as kilometres written straight into the vertex colours. The ramp is code that is already proven by the
// 16-bit export; the difference here is that the float render target is read as floats instead of being
// quantised to 16 bits. A float32 over 0..1 resolves about 1e-7, which over an 11 km ramp is a millimetre,
// three orders of magnitude finer than the 16-bit PNG's 17 cm - so nothing is lost coming back out, and
// negative values never have to survive a vertex-colour path where three.js colour management could reach
// them.
import * as THREE from 'three';
import { renderer } from './scene.js';
import { state } from './state.js';
import { elevToHeightKm } from './color-map.js';
export const RAMP_MIN_KM = -5;
export const RAMP_SPAN_KM = 11;
/** The ramp `heightmapColor` paints with, as a number rather than a colour. Clamping is a formality:
* elevToHeightKm cannot leave -5..6 by construction. */
function toRamp(elevation) {
const km = elevToHeightKm(elevation);
return Math.max(0, Math.min(1, (km - RAMP_MIN_KM) / RAMP_SPAN_KM));
}
// The same smooth triangle soup the 16-bit heightmap export builds: one triangle per mesh side, with the
// two triangle-centre vertices carrying the average of the three regions they touch, so a cell interpolates
// as a Gouraud gradient rather than reading as a flat hex.
//
// Two differences, both because this is sampled rather than looked at. Nothing is clamped into x in
// [-2, 2]: that clamp squashes the triangles straddling the date line, which is invisible in a whole-planet
// image because the wrapped copy covers it, and is a torn seam in a window that happens to sit there. And
// the caller draws the result three times, a full map apart, so a window crossing the date line sees real
// geometry on both sides instead of the edge of the mesh.
function buildHeightMapMesh(curData) {
const { mesh, r_xyz, t_xyz, r_elevation } = curData;
const { numSides, numTriangles } = mesh;
const PI = Math.PI;
const sx = 2 / PI;
const t_elev = new Float32Array(numTriangles);
const tris = mesh.triangles;
for (let t = 0; t < numTriangles; t++) {
const s0 = 3 * t;
t_elev[t] = (r_elevation[tris[s0]] + r_elevation[tris[s0 + 1]] + r_elevation[tris[s0 + 2]]) / 3;
}
const posArr = new Float32Array(numSides * 18);
const colArr = new Float32Array(numSides * 18);
let triCount = 0;
const emit = (lonA, latA, lonB, latB, lonC, latC, vA, vB, vC) => {
const off = triCount * 9;
posArr[off] = lonA * sx; posArr[off + 1] = latA * sx; posArr[off + 2] = 0;
posArr[off + 3] = lonB * sx; posArr[off + 4] = latB * sx; posArr[off + 5] = 0;
posArr[off + 6] = lonC * sx; posArr[off + 7] = latC * sx; posArr[off + 8] = 0;
colArr[off] = colArr[off + 1] = colArr[off + 2] = vA;
colArr[off + 3] = colArr[off + 4] = colArr[off + 5] = vB;
colArr[off + 6] = colArr[off + 7] = colArr[off + 8] = vC;
triCount++;
};
for (let s = 0; s < numSides; s++) {
const it = mesh.s_inner_t(s);
const ot = mesh.s_outer_t(s);
const br = mesh.s_begin_r(s);
const v0 = toRamp(t_elev[it]);
const v1 = toRamp(t_elev[ot]);
const v2 = toRamp(r_elevation[br]);
const x0 = t_xyz[3 * it], y0 = t_xyz[3 * it + 1], z0 = t_xyz[3 * it + 2];
const x1 = t_xyz[3 * ot], y1 = t_xyz[3 * ot + 1], z1 = t_xyz[3 * ot + 2];
const x2 = r_xyz[3 * br], y2 = r_xyz[3 * br + 1], z2 = r_xyz[3 * br + 2];
let lon0 = Math.atan2(x0, z0), lat0 = Math.asin(Math.max(-1, Math.min(1, y0)));
let lon1 = Math.atan2(x1, z1), lat1 = Math.asin(Math.max(-1, Math.min(1, y1)));
let lon2 = Math.atan2(x2, z2), lat2 = Math.asin(Math.max(-1, Math.min(1, y2)));
if (Math.max(lon0, lon1, lon2) - Math.min(lon0, lon1, lon2) > PI) {
if (lon0 < 0) lon0 += 2 * PI;
if (lon1 < 0) lon1 += 2 * PI;
if (lon2 < 0) lon2 += 2 * PI;
emit(lon0, lat0, lon1, lat1, lon2, lat2, v0, v1, v2);
emit(lon0 - 2 * PI, lat0, lon1 - 2 * PI, lat1, lon2 - 2 * PI, lat2, v0, v1, v2);
} else {
emit(lon0, lat0, lon1, lat1, lon2, lat2, v0, v1, v2);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(posArr.buffer, 0, triCount * 9), 3));
geo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(colArr.buffer, 0, triCount * 9), 3));
return new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide }));
}
/**
* Renders a longitude/latitude rectangle of the current planet into kilometres above sea level.
*
* The raster's *pixel centres* span the rectangle exactly - pixel 0 sits on lonMin, pixel width-1 on
* lonMax - because that is the convention the resampler downstream reads it with, so the frustum is
* widened by half a pixel on each side to put them there. Row 0 is the northern edge, as in an image.
*
* @returns {Promise<Float32Array>} width * height kilometres, row-major from the north.
*/
export async function renderHeightWindowKm({ lonMin, lonMax, latMin, latMax, width, height, onProgress }) {
if (!state.curData) throw new Error('no planet loaded to export');
if (width < 2 || height < 2) throw new Error('a height window needs at least 2 x 2 samples');
const sx = 2 / Math.PI;
const mapMesh = buildHeightMapMesh(state.curData);
const offScene = new THREE.Scene();
offScene.background = new THREE.Color(0x000000);
// Three copies, a full map apart, so a window crossing the date line is covered on both sides of it.
for (const shift of [-4, 0, 4]) {
const copy = new THREE.Mesh(mapMesh.geometry, mapMesh.material);
copy.position.x = shift;
offScene.add(copy);
}
const halfU = (lonMax - lonMin) * sx / (width - 1) / 2;
const halfV = (latMax - latMin) * sx / (height - 1) / 2;
const mx0 = lonMin * sx - halfU, mx1 = lonMax * sx + halfU;
const my0 = latMin * sx - halfV, my1 = latMax * sx + halfV;
const out = new Float32Array(width * height);
const step = Math.min(2048, renderer.capabilities.maxTextureSize);
const tilesX = Math.ceil(width / step);
const tilesY = Math.ceil(height / step);
const total = tilesX * tilesY;
let done = 0;
const prevColorSpace = renderer.outputColorSpace;
renderer.outputColorSpace = THREE.LinearSRGBColorSpace;
try {
for (let ty = 0; ty < tilesY; ty++) {
for (let tx = 0; tx < tilesX; tx++) {
const px0 = tx * step, py0 = ty * step;
const pw = Math.min(step, width - px0);
const ph = Math.min(step, height - py0);
const cam = new THREE.OrthographicCamera(
mx0 + (mx1 - mx0) * px0 / width,
mx0 + (mx1 - mx0) * (px0 + pw) / width,
my1 - (my1 - my0) * py0 / height,
my1 - (my1 - my0) * (py0 + ph) / height,
0.1, 10);
cam.position.set(0, 0, 5);
cam.lookAt(0, 0, 0);
const target = new THREE.WebGLRenderTarget(pw, ph, { type: THREE.FloatType });
renderer.setRenderTarget(target);
renderer.render(offScene, cam);
const pixels = new Float32Array(pw * ph * 4);
renderer.readRenderTargetPixels(target, 0, 0, pw, ph, pixels);
renderer.setRenderTarget(null);
target.dispose();
for (let y = 0; y < ph; y++) {
const src = (ph - 1 - y) * pw; // the readback is bottom-up
const dst = (py0 + y) * width + px0;
for (let x = 0; x < pw; x++) {
out[dst + x] = pixels[(src + x) * 4] * RAMP_SPAN_KM + RAMP_MIN_KM;
}
}
done++;
if (onProgress) onProgress(done / total, 'Sampling the planet');
await new Promise(r => setTimeout(r, 0));
}
}
} finally {
renderer.outputColorSpace = prevColorSpace;
renderer.setRenderTarget(null);
mapMesh.geometry.dispose();
mapMesh.material.dispose();
}
return out;
}
+287
View File
@@ -0,0 +1,287 @@
// The Unreal landscape export's panel.
//
// Built in JavaScript rather than written into index.html and import.html, because it is the same panel on
// both pages and two copies of a form with fourteen fields drift within a week.
//
// The panel's job is not to collect the numbers - it is to show what a number *buys* before two hours are
// spent on it. Every field re-plans on the keystroke and the readout underneath says how much ground the
// window holds, how many landscape components that is, how finely the planet is being sampled and what the
// flat reading costs at the window's edges. That is the same service `terrain plan` does for a legend and
// `--scout` does for a region: the expensive step should never be how you find out you set a number wrong.
import { planRegion, exportUnrealRegion, DEFAULTS } from './unreal-export.js';
const FIELDS = [
{ key: 'level', label: 'Level', type: 'text', width: 'wide',
hint: 'The tiles are named after its last segment: L_Region_x0_y0_Height.png' },
{ key: 'planet_circumference_km', label: 'Planet circumference', unit: 'km', type: 'number', step: 1,
hint: 'The sphere carries no metres. This is what turns the window\'s degrees into ground, and it is '
+ 'the number that decides how much land a window can hold. RawContent/World/Planet.json is where '
+ 'this project\'s own value lives; at 100 km round the whole globe is 3183 km2 of surface, so a '
+ 'window of a few hundred km2 is already a large piece of it.' },
{ key: 'centre.lon_deg', label: 'Centre longitude', unit: '°', type: 'number', step: 0.1 },
{ key: 'centre.lat_deg', label: 'Centre latitude', unit: '°', type: 'number', step: 0.1,
hint: 'Keep the window near the equator: an equirectangular reading stretches east-west by '
+ '1/cos(latitude), without bound at the poles.' },
{ key: 'tiles.columns', label: 'Tiles across', type: 'number', step: 1, min: 1 },
{ key: 'tiles.rows', label: 'Tiles down', type: 'number', step: 1, min: 1 },
{ key: 'tiles.vertices', label: 'Vertices a tile', type: 'select',
options: [[1021, '1021 (4 x 4 components)'], [2041, '2041 (8 x 8)'], [2551, '2551 (10 x 10)'],
[3061, '3061 (12 x 12)']],
hint: '255 * N + 1, so the engine gives each tile N x N components of 255 quads. The component count '
+ 'is what costs, not the vertex count.' },
{ key: 'quad_cm', label: 'Quad size', unit: 'cm', type: 'number', step: 1,
hint: 'Metres between vertices, in centimetres. 200 is a 2 m quad.' },
{ key: 'elevation_m.min', label: 'Elevation floor', unit: 'm', type: 'number', step: 1 },
{ key: 'elevation_m.max', label: 'Elevation ceiling', unit: 'm', type: 'number', step: 1,
hint: 'What 0 and 65535 mean. Too narrow clips; too wide only costs height precision, and the export '
+ 'reports both.' },
{ key: 'sea_scale', label: 'Sea scale', type: 'number', step: 0.01,
hint: 'Multiplies everything below sea level. Orogen\'s abyss is 5 km down on a whole-planet ramp, '
+ 'which over a small window is either a clipped plateau or an elevation range so wide the land '
+ 'loses its precision. Land is untouched.' },
{ key: 'source_metres_per_pixel', label: 'Sample spacing', unit: 'm', type: 'number', step: 0.5,
hint: 'How finely the planet is rendered before the tiles are cut from it. The mesh only resolves a '
+ 'couple of hundred metres, so anything below about 25 m here is already lossless.' },
];
const get = (obj, path) => path.split('.').reduce((o, k) => (o == null ? o : o[k]), obj);
function set(obj, path, value) {
const parts = path.split('.');
const last = parts.pop();
const target = parts.reduce((o, k) => (o[k] = o[k] || {}), obj);
target[last] = value;
}
function clone(o) { return JSON.parse(JSON.stringify(o)); }
const STORE_KEY = 'orogen.unrealExport';
function loadSettings() {
const settings = clone(DEFAULTS);
try {
const saved = JSON.parse(localStorage.getItem(STORE_KEY) || '{}');
for (const { key } of FIELDS) {
const v = get(saved, key);
if (v !== undefined && v !== null) set(settings, key, v);
}
} catch { /* a stale or blocked store is not a reason to refuse to open */ }
return settings;
}
function saveSettings(settings) {
try {
const out = {};
for (const { key } of FIELDS) set(out, key, get(settings, key));
localStorage.setItem(STORE_KEY, JSON.stringify(out));
} catch { /* private windows and blocked site data are fine; the panel just forgets */ }
}
function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') node.className = v;
else if (k === 'text') node.textContent = v;
else if (v !== undefined && v !== null) node.setAttribute(k, v);
}
for (const child of children) if (child) node.appendChild(child);
return node;
}
let panel = null;
function build() {
const settings = loadSettings();
const overlay = el('div', { id: 'unrealOverlay', class: 'hidden' });
const card = el('div', { id: 'unrealCard' });
const close = el('button', { id: 'unrealClose', type: 'button', 'aria-label': 'Close', text: '×' });
card.appendChild(close);
card.appendChild(el('h3', { text: 'Export Unreal Landscape' }));
card.appendChild(el('p', { class: 'unreal-blurb', text:
'Renders a window of this planet straight into the tile set Unreal\'s landscape importer wants: a '
+ '16-bit height and three 8-bit weightmaps per tile, plus the Region.json that describes them.' }));
const grid = el('div', { class: 'unreal-grid' });
const inputs = {};
for (const field of FIELDS) {
const wrap = el('div', { class: 'cg' + (field.width === 'wide' ? ' unreal-wide' : '') });
const label = el('label', { text: field.label });
if (field.unit) label.appendChild(el('span', { class: 'v', text: field.unit }));
if (field.hint) label.setAttribute('title', field.hint);
wrap.appendChild(label);
let input;
if (field.type === 'select') {
input = el('select');
for (const [value, text] of field.options) input.appendChild(el('option', { value, text }));
} else if (field.type === 'number') {
// Deliberately not <input type="number">. That control formats and parses in the *browser's*
// locale, so on a machine whose decimal separator is a comma a sea scale of 0.17 is shown as
// "0,17" and `.value` comes back as the empty string - the setting silently becomes NaN and the
// export writes a whole tile set of nothing. A text box with inputmode="decimal" gets the same
// numeric keyboard on a phone and leaves the parsing here, where both separators are accepted.
input = el('input', { type: 'text', inputmode: 'decimal', autocomplete: 'off', spellcheck: 'false' });
} else {
input = el('input', { type: field.type, autocomplete: 'off', spellcheck: 'false' });
}
input.value = get(settings, field.key);
input.addEventListener('input', () => {
const raw = input.value;
if (field.type === 'text') {
set(settings, field.key, raw);
} else if (field.type === 'select') {
set(settings, field.key, Number(raw));
} else {
const parsed = Number(String(raw).trim().replace(',', '.'));
input.classList.toggle('unreal-bad', raw.trim() !== '' && !Number.isFinite(parsed));
if (!Number.isFinite(parsed)) return; // keep the last good value while it is being typed
set(settings, field.key, parsed);
}
saveSettings(settings);
refresh();
});
inputs[field.key] = input;
wrap.appendChild(input);
grid.appendChild(wrap);
}
card.appendChild(grid);
const readout = el('div', { class: 'unreal-readout' });
card.appendChild(readout);
const status = el('div', { class: 'unreal-status' });
card.appendChild(status);
const cancel = el('button', { class: 'btn-ghost', type: 'button', text: 'Close' });
const go = el('button', { class: 'btn-primary', type: 'button', text: 'Choose folder & export' });
const actions = el('div', { class: 'export-actions' }, cancel, go);
card.appendChild(actions);
overlay.appendChild(card);
document.body.appendChild(overlay);
function refresh() {
let plan;
try {
plan = planRegion(settings);
} catch (err) {
readout.innerHTML = '';
readout.appendChild(el('div', { class: 'unreal-warn', text: err.message }));
go.disabled = true;
return null;
}
go.disabled = false;
const rows = [
['Ground', `${(plan.widthM / 1000).toFixed(2)} × ${(plan.heightM / 1000).toFixed(2)} km, `
+ `${plan.areaKm2.toFixed(0)} km²`],
['Tiles', `${plan.tileCount} of ${(plan.tileSideM / 1000).toFixed(2)} km, `
+ `${plan.tileCount * plan.componentsPerTile} components`],
['Files', `${plan.tileCount * 4} PNGs, about `
+ `${(plan.tileCount * (settings.tiles.vertices ** 2) * 5 / 1e9).toFixed(1)} GB uncompressed`],
['Window', `${(plan.lonSpan * 180 / Math.PI).toFixed(2)}° × `
+ `${(plan.latSpan * 180 / Math.PI).toFixed(2)}° of the planet`],
['Sampled at', `${plan.metresPerPixel.toFixed(2)} m a pixel `
+ `(${plan.rasterW} × ${plan.rasterH})`],
['E-W stretch', `${((plan.stretchNorth - 1) * 100).toFixed(1)}% north, `
+ `${((plan.stretchSouth - 1) * 100).toFixed(1)}% south`],
];
readout.innerHTML = '';
for (const [name, value] of rows) {
readout.appendChild(el('div', { class: 'unreal-row' },
el('span', { class: 'unreal-key', text: name }),
el('span', { class: 'unreal-val', text: value })));
}
for (const warning of plan.warnings) {
readout.appendChild(el('div', { class: 'unreal-warn', text: warning }));
}
return plan;
}
function setStatus(text, kind = '') {
status.textContent = text;
status.className = 'unreal-status' + (kind ? ' ' + kind : '');
}
go.addEventListener('click', async () => {
if (!window.showDirectoryPicker) {
setStatus('This browser cannot write to a folder. The export needs the File System Access API: '
+ 'use Chrome or Edge on a desktop.', 'unreal-warn');
return;
}
let dir;
try {
dir = await window.showDirectoryPicker({ mode: 'readwrite', id: 'orogen-unreal-region' });
} catch {
return; // the picker was dismissed, which is not an error
}
go.disabled = true;
cancel.disabled = true;
try {
const result = await exportUnrealRegion(settings, dir, (fraction, label) => {
setStatus(`${Math.round(fraction * 100)}% — ${label}`);
});
const { meta } = result;
const manifestNote = result.manifestName === 'Region.json'
? 'Region.json written beside RegionTiles/.'
: 'A Region.json was already there and was left alone — the new one is '
+ 'Region.generated.json. Rename it over the old one when you are ready.';
setStatus(`Done. ${result.plan.tileCount} tiles, ground `
+ `${meta.minM.toFixed(0)}..${meta.maxM.toFixed(0)} m, `
+ `${(meta.rampUsed * 100).toFixed(0)}% of the 16-bit ramp used`
+ `${meta.clipped > 0 ? `, ${(meta.clipped * 100).toFixed(3)}% clipped — widen the elevation range` : ', nothing clipped'}`
+ `. ${manifestNote}`, meta.clipped > 0 ? 'unreal-warn' : 'unreal-ok');
} catch (err) {
setStatus(`Failed: ${err.message}`, 'unreal-warn');
throw err;
} finally {
go.disabled = false;
cancel.disabled = false;
}
});
const hide = () => overlay.classList.add('hidden');
close.addEventListener('click', hide);
cancel.addEventListener('click', hide);
overlay.addEventListener('click', e => { if (e.target === overlay) hide(); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && !overlay.classList.contains('hidden')) hide();
});
panel = {
overlay,
open() { overlay.classList.remove('hidden'); refresh(); },
settings,
refresh,
};
refresh();
return panel;
}
export function openUnrealExport() {
(panel || build()).open();
}
/**
* Adds the button that opens the panel to whichever export card the page has. Called by both entry points;
* does nothing if the page has no export card or the button is already there.
*/
export function installUnrealExportButton() {
const actions = document.querySelector('#exportCard .export-actions');
if (!actions || document.getElementById('unrealExportBtn')) return;
const button = el('button', { id: 'unrealExportBtn', class: 'btn-ghost', type: 'button',
text: 'Unreal Landscape…' });
button.addEventListener('click', () => {
document.getElementById('exportOverlay').classList.add('hidden');
openUnrealExport();
});
actions.insertBefore(button, actions.firstChild);
// A handle for headless runs, the same way the painted import exposes window.orogenPainted.
window.orogenUnreal = {
plan: opts => planRegion({ ...(panel ? panel.settings : DEFAULTS), ...(opts || {}) }),
exportTo: (opts, dirHandle, onProgress) => exportUnrealRegion(opts, dirHandle, onProgress),
open: openUnrealExport,
get settings() { return panel ? panel.settings : loadSettings(); },
};
}
+768
View File
@@ -0,0 +1,768 @@
// Wind simulation: pressure-driven seasonal wind with longitude-varying ITCZ.
// Computes pressure fields and wind vectors for summer and winter seasons.
import { elevToHeightKm } from './color-map.js';
import { smoothField, percentile } from './climate-util.js';
const DEG = Math.PI / 180;
const RAD = 180 / Math.PI;
// ── Periodic cubic spline interpolation ──────────────────────────────────────
function buildPeriodicSpline(xs, ys) {
// xs: sorted longitude samples (radians), ys: ITCZ latitude values
// Returns spline data for evaluateSpline()
const n = xs.length;
const period = 2 * Math.PI;
// Build tridiagonal system for periodic natural cubic spline
const h = new Float64Array(n);
const alpha = new Float64Array(n);
for (let i = 0; i < n; i++) {
const next = (i + 1) % n;
h[i] = (xs[next] - xs[i] + period) % period;
if (h[i] === 0) h[i] = period / n;
}
for (let i = 0; i < n; i++) {
const prev = (i - 1 + n) % n;
const next = (i + 1) % n;
alpha[i] = (3 / h[i]) * (ys[next] - ys[i]) - (3 / h[prev]) * (ys[i] - ys[prev]);
}
// Solve with Thomas-like algorithm for periodic system
// Simplified: use iterative relaxation (fast enough for n=72)
const c = new Float64Array(n);
for (let iter = 0; iter < 20; iter++) {
for (let i = 0; i < n; i++) {
const prev = (i - 1 + n) % n;
const next = (i + 1) % n;
c[i] = (alpha[i] - h[prev] * c[prev] - h[i] * c[next]) /
(2 * (h[prev] + h[i]));
}
}
const b = new Float64Array(n);
const d = new Float64Array(n);
for (let i = 0; i < n; i++) {
const next = (i + 1) % n;
b[i] = (ys[next] - ys[i]) / h[i] - h[i] * (c[next] + 2 * c[i]) / 3;
d[i] = (c[next] - c[i]) / (3 * h[i]);
}
return { xs, ys, b, c, d, h, n, period };
}
function evaluateSpline(spline, lon) {
const { xs, ys, b, c, d, n, period } = spline;
// Normalize lon to [xs[0], xs[0] + period)
let t = ((lon - xs[0]) % period + period) % period + xs[0];
// Direct index calculation — segments are equally spaced
const segStep = period / n;
let seg = Math.floor((t - xs[0]) / segStep);
if (seg < 0) seg = 0;
else if (seg >= n) seg = n - 1;
const dx = t - xs[seg];
return ys[seg] + b[seg] * dx + c[seg] * dx * dx + d[seg] * dx * dx * dx;
}
// ── Smoothstep utility ───────────────────────────────────────────────────────
export function smoothstep(edge0, edge1, x) {
if (edge0 === edge1) return x >= edge1 ? 1 : 0;
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
// ── ITCZ computation ─────────────────────────────────────────────────────────
/**
* Build a spatial index binning regions by latitude/longitude for fast
* geographic sampling. Returns a function landFracAndElev(lat, lon, radius)
* that returns { landFrac, avgElev } by scanning nearby bins.
*/
function buildGeoIndex(r_lat, r_lon, r_sinLat, r_cosLat, r_elevation, r_isLand, numRegions) {
const LAT_BINS = 36; // 5° each
const LON_BINS = 72; // 5° each
const numBins = LAT_BINS * LON_BINS;
// CSR (compressed sparse row) format: count regions per bin, then prefix-sum
// Cache bin index per region to avoid recomputing in the fill pass
const r_bin = new Uint32Array(numRegions);
const binCount = new Uint32Array(numBins);
for (let r = 0; r < numRegions; r++) {
const latBin = Math.max(0, Math.min(LAT_BINS - 1,
Math.floor((r_lat[r] + Math.PI / 2) / Math.PI * LAT_BINS)));
const lonBin = Math.max(0, Math.min(LON_BINS - 1,
Math.floor((r_lon[r] + Math.PI) / (2 * Math.PI) * LON_BINS)));
const bin = latBin * LON_BINS + lonBin;
r_bin[r] = bin;
binCount[bin]++;
}
const binOffset = new Uint32Array(numBins + 1);
for (let i = 0; i < numBins; i++) {
binOffset[i + 1] = binOffset[i] + binCount[i];
}
const indices = new Uint32Array(numRegions);
const fillPos = new Uint32Array(numBins);
for (let r = 0; r < numRegions; r++) {
const bin = r_bin[r];
indices[binOffset[bin] + fillPos[bin]] = r;
fillPos[bin]++;
}
/**
* Sample land fraction and average elevation in a circular region.
* @param {number} lat - center latitude (radians)
* @param {number} lon - center longitude (radians)
* @param {number} radius - great-circle radius (radians)
*/
return function sample(lat, lon, radius) {
const latMin = lat - radius, latMax = lat + radius;
const bMin = Math.max(0, Math.floor((latMin + Math.PI / 2) / Math.PI * LAT_BINS));
const bMax = Math.min(LAT_BINS - 1, Math.floor((latMax + Math.PI / 2) / Math.PI * LAT_BINS));
// Longitude span widens near equator
const cosLat = Math.cos(lat) || 0.01;
const lonSpan = radius / cosLat;
const lMin = Math.floor((lon - lonSpan + Math.PI) / (2 * Math.PI) * LON_BINS);
const lMax = Math.floor((lon + lonSpan + Math.PI) / (2 * Math.PI) * LON_BINS);
let landCount = 0, totalCount = 0, elevSum = 0;
const cosRadius = Math.cos(radius);
const sinLat0 = Math.sin(lat), cosLat0 = Math.cos(lat);
for (let bi = bMin; bi <= bMax; bi++) {
for (let li = lMin; li <= lMax; li++) {
const lj = ((li % LON_BINS) + LON_BINS) % LON_BINS;
const bin = bi * LON_BINS + lj;
const start = binOffset[bin];
const end = binOffset[bin + 1];
for (let k = start; k < end; k++) {
const r = indices[k];
const sinLat1 = r_sinLat[r];
const cosLat1 = r_cosLat[r];
const dlon = r_lon[r] - lon;
const cosDist = sinLat0 * sinLat1 + cosLat0 * cosLat1 * Math.cos(dlon);
if (cosDist >= cosRadius) {
totalCount++;
if (r_isLand[r]) landCount++;
elevSum += Math.max(0, r_elevation[r]);
}
}
}
}
if (totalCount === 0) return { landFrac: 0, avgElev: 0 };
return { landFrac: landCount / totalCount, avgElev: elevSum / totalCount };
};
}
/**
* Compute ITCZ latitude at sampled longitudes for a given season.
* Uses a thermal equator search: scans latitudes from -30° to +30°,
* computes a heating score at each, and picks the peak.
*
* Heating score combines:
* - Solar insolation (cosine of latitude offset from subsolar point)
* - Land thermal boost (land heats faster than ocean)
* - Elevation boost (plateaus heat more intensely — thinner atmosphere)
* - Cross-equatorial anchoring (winter-hemisphere land pulls ITCZ equatorward)
*
* @param {function} geoSample - from buildGeoIndex
* @param {string} season - 'summer' (NH) or 'winter' (NH)
* @param {number} tiltRad - axial tilt in radians
* @returns {{ spline, lons: Float64Array, lats: Float64Array }}
*/
function computeITCZ(geoSample, season, tiltRad) {
const NUM_LON = 72;
// Two sampling radii: local (5°) for precise land detection, wide (30°) for continental scale
const localRadius = 5 * DEG;
const wideRadius = 30 * DEG;
// +1 = NH summer, -1 = SH summer (NH winter)
const sign = season === 'summer' ? 1 : -1;
// Subsolar latitude: where the sun is directly overhead this season
// Full tilt in summer hemisphere (e.g. +23.5° for NH summer)
const subsolarLat = sign * tiltRad;
// Scan range: -30° to +30° in 2.5° steps
const SCAN_MIN = -30;
const SCAN_MAX = 30;
const SCAN_STEP = 2.5;
const numScans = Math.round((SCAN_MAX - SCAN_MIN) / SCAN_STEP) + 1;
const lons = new Float64Array(NUM_LON);
const rawLats = new Float64Array(NUM_LON);
for (let i = 0; i < NUM_LON; i++) {
const lon = -Math.PI + (i + 0.5) * (2 * Math.PI / NUM_LON);
lons[i] = lon;
let bestScore = -Infinity;
let bestLat = sign * 5 * DEG; // fallback
for (let si = 0; si < numScans; si++) {
const latDeg = SCAN_MIN + si * SCAN_STEP;
const lat = latDeg * DEG;
const local = geoSample(lat, lon, localRadius);
const wide = geoSample(lat, lon, wideRadius);
// (a) Solar insolation: peaks at subsolar latitude, broad Gaussian falloff.
// σ = 25° gives a wide heating dome — the ITCZ doesn't track the
// subsolar point 1:1, it lags and is damped by ocean thermal inertia.
const dSolar = (lat - subsolarLat) * RAD; // degrees from subsolar
const solarScore = Math.exp(-0.5 * (dSolar / 25) ** 2);
// (b) Land thermal boost: uses multi-scale sampling.
// Only truly continental-scale landmasses pull the ITCZ significantly.
// Islands, thin peninsulas, and coastlines near ocean register low at
// the wide (30°) radius and get suppressed by the steep ramp.
const localLand = local.landFrac;
const wideLand = wide.landFrac;
// Also sample poleward of this latitude: a massive continent extending
// poleward (like Asia beyond 20°N) creates an enormous heat reservoir
// that pulls the ITCZ toward it even if the scan point itself is at
// the continent's edge. Sample 15° poleward in the summer hemisphere.
const polewardLat = lat + sign * 15 * DEG;
const poleward = geoSample(polewardLat, lon, wideRadius);
// Combined land signal: max of local-wide and poleward-wide.
// Poleward land contributes at 70% strength (heat diffuses equatorward).
const effectiveWideLand = Math.max(wideLand, poleward.landFrac * 0.7);
// Wide-scale land must exceed ~20% before any real pull kicks in.
const continentalScale = smoothstep(0.20, 0.45, effectiveWideLand);
// Square it so moderate land fractions still contribute little.
const scaledLand = continentalScale * continentalScale;
// Local land gate: require >25% local land fraction to activate.
// At 5° radius (~560 km), ocean near thin islands stays well below this.
const landGate = smoothstep(0.25, 0.55, localLand);
// Strong max boost so massive continents pull ITCZ toward 25-30°
const landBoost = landGate * scaledLand * 1.0;
// (c) Elevation boost: high plateaus heat more intensely
// (thinner atmosphere, stronger surface insolation).
// Also scaled by continental size — isolated volcanic peaks don't pull ITCZ.
const elevKm = elevToHeightKm(Math.max(0, wide.avgElev));
const elevBoost = Math.min(0.30, elevKm * 0.12) * scaledLand;
// (d) Cross-equatorial anchoring: if this latitude is in the
// winter hemisphere but there's significant land, it anchors
// the ITCZ closer to the equator (resists poleward migration).
const isWinterHemi = (sign > 0 && latDeg < 0) || (sign < 0 && latDeg > 0);
const anchorBoost = isWinterHemi ? landBoost * 0.4 : 0;
// (e) Ocean baseline: slight poleward bias in summer hemisphere
// even over open ocean (~6-8° from equator on average).
const isSummerHemi = !isWinterHemi;
const oceanBias = isSummerHemi && localLand < 0.1
? 0.08 * Math.exp(-0.5 * ((Math.abs(latDeg) - 7) / 5) ** 2)
: 0;
const score = solarScore + landBoost + elevBoost + anchorBoost + oceanBias;
if (score > bestScore) {
bestScore = score;
bestLat = lat;
}
}
rawLats[i] = bestLat;
}
// Pull extreme outliers toward the zonal mean before longitude smoothing.
// The ITCZ is a planetary-scale feature — individual longitude columns
// shouldn't deviate too far from the overall trend.
const lats = new Float64Array(rawLats);
const tmp = new Float64Array(NUM_LON);
// Wide periodic moving average (kernel = 5 neighbors) for heavy smoothing,
// then narrow (kernel = 3) for fine cleanup. More passes = smoother ITCZ.
// Wide kernel: weights [0.1, 0.2, 0.4, 0.2, 0.1] over 5 neighbors
for (let pass = 0; pass < 4; pass++) {
for (let i = 0; i < NUM_LON; i++) {
const p2 = (i - 2 + NUM_LON) % NUM_LON;
const p1 = (i - 1 + NUM_LON) % NUM_LON;
const n1 = (i + 1) % NUM_LON;
const n2 = (i + 2) % NUM_LON;
tmp[i] = 0.1 * lats[p2] + 0.2 * lats[p1] + 0.4 * lats[i] + 0.2 * lats[n1] + 0.1 * lats[n2];
}
lats.set(tmp);
}
// Narrow cleanup passes
for (let pass = 0; pass < 3; pass++) {
for (let i = 0; i < NUM_LON; i++) {
const p = (i - 1 + NUM_LON) % NUM_LON;
const n = (i + 1) % NUM_LON;
tmp[i] = 0.25 * lats[p] + 0.5 * lats[i] + 0.25 * lats[n];
}
lats.set(tmp);
}
// Clamp to ±30° (ITCZ never migrates beyond the tropics)
for (let i = 0; i < NUM_LON; i++) {
lats[i] = Math.max(-30 * DEG, Math.min(30 * DEG, lats[i]));
}
const spline = buildPeriodicSpline(lons, lats);
return { spline, lons, lats };
}
// ── Pressure field ───────────────────────────────────────────────────────────
/**
* Compute pressure at a single region.
*/
function regionPressure(lat, lon, itczSpline, season, landFrac, elevation, noiseFn, px, py, pz) {
const itczLat = evaluateSpline(itczSpline, lon);
const latDeg = lat * RAD;
const seasonSign = season === 'summer' ? 1 : -1;
let p = 1013; // baseline hPa
// (a) ITCZ low — follows thermal equator
const dItcz = (lat - itczLat) * RAD; // degrees from ITCZ
p -= 15 * Math.exp(-0.5 * (dItcz / 8) ** 2);
// (b) Subtropical highs — shift with season, weaker over hot land
const shiftDeg = seasonSign * 5;
const nhSubHigh = 30 + shiftDeg;
const shSubHigh = -(30 - shiftDeg);
const highIntensity = 12 * (1 - 0.3 * landFrac);
p += highIntensity * Math.exp(-0.5 * ((latDeg - nhSubHigh) / 10) ** 2);
p += highIntensity * Math.exp(-0.5 * ((latDeg - shSubHigh) / 10) ** 2);
// (c) Subpolar lows
p -= 10 * Math.exp(-0.5 * ((latDeg - 60) / 10) ** 2);
p -= 10 * Math.exp(-0.5 * ((latDeg + 60) / 10) ** 2);
// (d) Polar highs
p += 8 * Math.exp(-0.5 * ((latDeg - 85) / 8) ** 2);
p += 8 * Math.exp(-0.5 * ((latDeg + 85) / 8) ** 2);
// (e) Land/sea thermal modifier
// landFrac here is actually continentality (0 at coast → ~1 deep interior).
// Only continental-scale landmasses produce meaningful thermal pressure:
// small islands (continentality < 0.2) → 0, ramps to full at 0.5+.
const continentalScale = smoothstep(0.2, 0.5, landFrac);
if (continentalScale > 0.001) {
// Continental thermal effect profile:
// 0 at 0-15°, rises to ~0.75 at 30°, plateau ~1.0 at 45-60°, falls to ~0.5 at 75°, 0 at 90°
const absLatDeg = Math.abs(lat) * RAD;
const latFactor = absLatDeg < 15 ? 0
: absLatDeg < 30 ? 0.75 * smoothstep(15, 30, absLatDeg)
: absLatDeg < 45 ? 0.75 + 0.25 * smoothstep(30, 45, absLatDeg)
: absLatDeg < 60 ? 1
: absLatDeg < 90 ? smoothstep(90, 60, absLatDeg)
: 0;
const isSummerHemisphere = (seasonSign > 0 && lat > 0) || (seasonSign < 0 && lat < 0);
if (isSummerHemisphere) {
// Thermal low over hot continent
p -= 10 * latFactor * continentalScale;
} else {
// Thermal high over cold continent (stronger — Siberian/Canadian highs)
p += 14 * latFactor * continentalScale;
}
}
// (f) Elevation (barometric) — mild effect; real weather maps use
// sea-level-reduced pressure so elevation doesn't dominate zonal bands
p -= 3 * elevToHeightKm(Math.max(0, elevation));
// (g) Noise perturbation
if (noiseFn) {
p += noiseFn.fbm(px * 2, py * 2, pz * 2, 3) * 2;
}
return p;
}
// ── Pressure gradient on mesh ────────────────────────────────────────────────
export function computeGradients(mesh, r_xyz, r_pressure,
r_eastX, r_eastY, r_eastZ, r_northX, r_northY, r_northZ,
r_gradE, r_gradN) {
const { adjOffset, adjList, numRegions } = mesh;
for (let r = 0; r < numRegions; r++) {
const px = r_xyz[3 * r], py = r_xyz[3 * r + 1], pz = r_xyz[3 * r + 2];
const ex = r_eastX[r], ey = r_eastY[r], ez = r_eastZ[r];
const nx = r_northX[r], ny = r_northY[r], nz = r_northZ[r];
const pHere = r_pressure[r];
let sumEP = 0, sumEE = 0, sumNP = 0, sumNN = 0;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
const dx = r_xyz[3 * nb] - px;
const dy = r_xyz[3 * nb + 1] - py;
const dz = r_xyz[3 * nb + 2] - pz;
const de = dx * ex + dy * ey + dz * ez;
const dn = dx * nx + dy * ny + dz * nz;
const dp = r_pressure[nb] - pHere;
sumEP += de * dp;
sumEE += de * de;
sumNP += dn * dp;
sumNN += dn * dn;
}
r_gradE[r] = sumEE > 1e-12 ? sumEP / sumEE : 0;
r_gradN[r] = sumNN > 1e-12 ? sumNP / sumNN : 0;
}
}
// ── Pressure gradient → wind ─────────────────────────────────────────────────
function pressureToWind(r_gradE, r_gradN, r_sinLat,
r_windE, r_windN, r_windSpeed, numRegions) {
const sin5 = Math.sin(5 * DEG);
for (let r = 0; r < numRegions; r++) {
// PGF: from high to low = negative gradient
const pgfE = -r_gradE[r];
const pgfN = -r_gradN[r];
const sinLat = r_sinLat[r];
const absSinLat = Math.abs(sinLat);
// Geostrophic deflection: 0° at equator → 70° at ≥5° latitude
const geoAngle = 70 * DEG * smoothstep(0, sin5, absSinLat);
// Surface friction turns wind 20° back toward low pressure
const frictionAngle = 20 * DEG;
// Net rotation: NH = clockwise (negative), SH = counterclockwise (positive)
// The rotation matrix [cosθ,-sinθ; sinθ,cosθ] is counterclockwise for +θ,
// so NH right-deflection needs negative angle, SH left-deflection needs positive.
const sign = sinLat >= 0 ? -1 : 1;
const totalAngle = sign * (geoAngle - frictionAngle);
const cosA = Math.cos(totalAngle);
const sinA = Math.sin(totalAngle);
// Rotate PGF vector and apply friction speed reduction
const we = (pgfE * cosA - pgfN * sinA) * 0.6;
const wn = (pgfE * sinA + pgfN * cosA) * 0.6;
r_windE[r] = we;
r_windN[r] = wn;
r_windSpeed[r] = Math.sqrt(we * we + wn * wn);
}
}
// ── Main entry point ─────────────────────────────────────────────────────────
/**
* Compute seasonal pressure fields and wind vectors.
*
* @param {SphereMesh} mesh
* @param {Float32Array} r_xyz - per-region 3D positions (3 * numRegions)
* @param {Float32Array} r_elevation - per-region elevation
* @param {Set} plateIsOcean - ocean plate seed set
* @param {Int32Array} r_plate - per-region plate ID
* @param {SimplexNoise} noise - seeded noise instance
* @param {number} [axialTilt=23.5] - axial tilt in degrees
* @returns {object} pressure and wind arrays for both seasons
*/
export function computeWind(mesh, r_xyz, r_elevation, plateIsOcean, r_plate, noise, axialTilt = 23.5) {
const numRegions = mesh.numRegions;
const avgEdgeKm = (Math.PI * 6371) / Math.sqrt(numRegions);
const tiltRad = axialTilt * DEG;
const timing = [];
// ── Step 0: Precompute per-region properties ──
let t0 = performance.now();
const r_lat = new Float32Array(numRegions);
const r_lon = new Float32Array(numRegions);
const r_sinLat = new Float32Array(numRegions);
const r_cosLat = new Float32Array(numRegions);
const r_isLand = new Uint8Array(numRegions);
// Tangent frame arrays
const r_eastX = new Float32Array(numRegions);
const r_eastY = new Float32Array(numRegions);
const r_eastZ = new Float32Array(numRegions);
const r_northX = new Float32Array(numRegions);
const r_northY = new Float32Array(numRegions);
const r_northZ = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const x = r_xyz[3 * r], y = r_xyz[3 * r + 1], z = r_xyz[3 * r + 2];
// Y-up convention (matches map projection)
r_lat[r] = Math.asin(Math.max(-1, Math.min(1, y)));
r_lon[r] = Math.atan2(x, z);
r_sinLat[r] = y;
r_cosLat[r] = Math.sqrt(1 - y * y) || 0.01;
r_isLand[r] = r_elevation[r] > 0 ? 1 : 0;
// East = normalize(Ŷ × P) = normalize(z, 0, -x)
let ex = z, ey = 0, ez = -x;
let elen = Math.sqrt(ex * ex + ez * ez);
if (elen < 1e-10) { ex = 1; ez = 0; elen = 1; } // pole fallback
ex /= elen; ez /= elen;
// North = P × East
let nx = y * ez - z * ey;
let ny = z * ex - x * ez;
let nz = x * ey - y * ex;
const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
nx /= nlen; ny /= nlen; nz /= nlen;
r_eastX[r] = ex; r_eastY[r] = ey; r_eastZ[r] = ez;
r_northX[r] = nx; r_northY[r] = ny; r_northZ[r] = nz;
}
timing.push({ stage: 'Wind: precompute lat/lon/tangent', ms: performance.now() - t0 });
// ── Step 1: Build geographic index + compute ITCZ ──
t0 = performance.now();
const geoSample = buildGeoIndex(r_lat, r_lon, r_sinLat, r_cosLat, r_elevation, r_isLand, numRegions);
const itczSummer = computeITCZ(geoSample, 'summer', tiltRad);
const itczWinter = computeITCZ(geoSample, 'winter', tiltRad);
timing.push({ stage: 'Wind: ITCZ computation', ms: performance.now() - t0 });
// ── Step 2–5: Compute pressure & wind for each season ──
const seasons = [
{ name: 'summer', itcz: itczSummer },
{ name: 'winter', itcz: itczWinter }
];
const result = {};
// Precompute continentality via BFS coast distance.
// Laplacian smoothing of binary r_isLand converges too fast — interior
// cells hit 0.95+ within a few hundred km. Instead, compute actual
// hop distance from coast through land, convert to km, and map with
// smoothstep for a wide, tunable gradient.
// 0 km (coast): cont ≈ 0.0
// 500 km: cont ≈ 0.16
// 1000 km: cont ≈ 0.50
// 1500 km: cont ≈ 0.84
// 2000 km+: cont ≈ 1.0
// Ocean cells near coast get a small value (~0.05–0.15) via a few
// smoothing passes, giving a natural land/sea thermal gradient.
t0 = performance.now();
const { adjOffset, adjList } = mesh;
// Find the main ocean: largest connected component of non-land cells.
// Inland seas / small lakes don't count as "ocean" for continentality.
const r_oceanLabel = new Int32Array(numRegions);
r_oceanLabel.fill(-1);
let mainOceanLabel = -1, mainOceanSize = 0;
let nextLabel = 0;
for (let r = 0; r < numRegions; r++) {
if (r_isLand[r] || r_oceanLabel[r] >= 0) continue;
const label = nextLabel++;
let size = 0;
const floodQueue = [r];
r_oceanLabel[r] = label;
let fHead = 0;
while (fHead < floodQueue.length) {
const cur = floodQueue[fHead++];
size++;
const end = adjOffset[cur + 1];
for (let ni = adjOffset[cur]; ni < end; ni++) {
const nb = adjList[ni];
if (!r_isLand[nb] && r_oceanLabel[nb] === -1) {
r_oceanLabel[nb] = label;
floodQueue.push(nb);
}
}
}
if (size > mainOceanSize) {
mainOceanSize = size;
mainOceanLabel = label;
}
}
// BFS coast distance through land, seeded only from main-ocean coastline
const r_coastDist = new Int32Array(numRegions);
r_coastDist.fill(-1);
const bfsQueue = [];
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r]) continue;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (!r_isLand[nb] && r_oceanLabel[nb] === mainOceanLabel) {
r_coastDist[r] = 0;
bfsQueue.push(r);
break;
}
}
}
let head = 0;
while (head < bfsQueue.length) {
const r = bfsQueue[head++];
const d = r_coastDist[r] + 1;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (r_isLand[nb] && r_coastDist[nb] === -1) {
r_coastDist[nb] = d;
bfsQueue.push(nb);
}
}
}
// Map BFS distance to continentality [0, 1]
const CONT_RANGE_KM = 2000; // distance at which cont reaches ~1.0
const r_continentality = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
if (r_isLand[r] && r_coastDist[r] >= 0) {
const distKm = r_coastDist[r] * avgEdgeKm;
r_continentality[r] = smoothstep(0, CONT_RANGE_KM, distKm);
}
// Ocean cells stay at 0; a few smooth passes below will bleed
// small values onto nearshore ocean for thermal gradient.
}
// Light smoothing (~100 km) to soften BFS stepping artifacts and
// bleed a small thermal signal onto nearshore ocean cells.
const contSmoothPasses = Math.max(1, Math.round(100 / avgEdgeKm));
smoothField(mesh, r_continentality, contSmoothPasses);
// Plate-based continentality: uses plate type (continental vs oceanic)
// instead of actual land/ocean. Same BFS approach for wide gradient.
const r_plateContinentality = new Float32Array(numRegions);
// BFS through continental-plate cells
const r_plateDist = new Int32Array(numRegions);
r_plateDist.fill(-1);
const plateBfsQueue = [];
for (let r = 0; r < numRegions; r++) {
if (plateIsOcean.has(r_plate[r])) continue; // skip oceanic plate cells
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
if (plateIsOcean.has(r_plate[adjList[ni]])) {
r_plateDist[r] = 0;
plateBfsQueue.push(r);
break;
}
}
}
head = 0;
while (head < plateBfsQueue.length) {
const r = plateBfsQueue[head++];
const d = r_plateDist[r] + 1;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (!plateIsOcean.has(r_plate[nb]) && r_plateDist[nb] === -1) {
r_plateDist[nb] = d;
plateBfsQueue.push(nb);
}
}
}
for (let r = 0; r < numRegions; r++) {
if (!plateIsOcean.has(r_plate[r]) && r_plateDist[r] >= 0) {
const distKm = r_plateDist[r] * avgEdgeKm;
r_plateContinentality[r] = smoothstep(0, CONT_RANGE_KM, distKm);
}
}
smoothField(mesh, r_plateContinentality, contSmoothPasses);
timing.push({ stage: 'Wind: continentality BFS', ms: performance.now() - t0 });
// Shared gradient scratch arrays
const r_gradE = new Float32Array(numRegions);
const r_gradN = new Float32Array(numRegions);
// Smooth pressure field ~75 km (scale-invariant) — constant across seasons
const pressSmoothPasses = Math.max(1, Math.round(75 / avgEdgeKm));
for (const { name, itcz } of seasons) {
// Step 2: Pressure field
t0 = performance.now();
const r_pressure = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
r_pressure[r] = regionPressure(
r_lat[r], r_lon[r], itcz.spline, name,
r_continentality[r], r_elevation[r], noise,
r_xyz[3 * r], r_xyz[3 * r + 1], r_xyz[3 * r + 2]
);
}
smoothField(mesh, r_pressure, pressSmoothPasses);
timing.push({ stage: `Wind: pressure field (${name})`, ms: performance.now() - t0 });
// Step 3: Gradient
t0 = performance.now();
r_gradE.fill(0);
r_gradN.fill(0);
computeGradients(mesh, r_xyz, r_pressure,
r_eastX, r_eastY, r_eastZ, r_northX, r_northY, r_northZ,
r_gradE, r_gradN);
timing.push({ stage: `Wind: gradient (${name})`, ms: performance.now() - t0 });
// Step 4: Wind
t0 = performance.now();
const r_windE = new Float32Array(numRegions);
const r_windN = new Float32Array(numRegions);
const r_windSpeed = new Float32Array(numRegions);
pressureToWind(r_gradE, r_gradN, r_sinLat,
r_windE, r_windN, r_windSpeed, numRegions);
// Step 5: Normalize wind speed to 0-1
const maxSpeed = percentile(r_windSpeed, 0.95);
for (let r = 0; r < numRegions; r++) {
r_windSpeed[r] = Math.min(1, r_windSpeed[r] / maxSpeed);
}
timing.push({ stage: `Wind: pressure→wind (${name})`, ms: performance.now() - t0 });
// Store pressure as deviation from 1013 for visualization (blue=low, red=high)
const r_pressureDev = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
r_pressureDev[r] = r_pressure[r] - 1013;
}
const S = name === 'summer' ? 'Summer' : 'Winter';
result[`r_pressure_${name}`] = r_pressureDev;
result[`r_wind_east_${name}`] = r_windE;
result[`r_wind_north_${name}`] = r_windN;
result[`r_wind_speed_${name}`] = r_windSpeed;
}
// Pre-evaluate ITCZ splines at 360 longitude points for visualization
const ITCZ_SAMPLES = 360;
const itczLons = new Float32Array(ITCZ_SAMPLES);
const itczLatsSummer = new Float32Array(ITCZ_SAMPLES);
const itczLatsWinter = new Float32Array(ITCZ_SAMPLES);
for (let i = 0; i < ITCZ_SAMPLES; i++) {
const lon = -Math.PI + (i + 0.5) * (2 * Math.PI / ITCZ_SAMPLES);
itczLons[i] = lon;
itczLatsSummer[i] = evaluateSpline(itczSummer.spline, lon);
itczLatsWinter[i] = evaluateSpline(itczWinter.spline, lon);
}
result.itczLons = itczLons;
result.itczLatsSummer = itczLatsSummer;
result.itczLatsWinter = itczLatsWinter;
// Expose precomputed geographic data for downstream modules (ocean.js)
result.r_lat = r_lat;
result.r_lon = r_lon;
result.r_sinLat = r_sinLat;
result.r_isLand = r_isLand;
result.r_continentality = r_continentality;
result.r_coastDistLand = r_coastDist;
result.r_plateContinentality = r_plateContinentality;
result.r_eastX = r_eastX;
result.r_eastY = r_eastY;
result.r_eastZ = r_eastZ;
result.r_northX = r_northX;
result.r_northY = r_northY;
result.r_northZ = r_northZ;
result._windTiming = timing;
return result;
}
+49
View File
@@ -0,0 +1,49 @@
# World Orogen
> Procedural planet generator — free, browser-based, no signup required.
World Orogen generates realistic procedural planets shaped by tectonic plate simulation, erosion, and climate modeling. It runs entirely in the browser using Three.js and requires no installation, account, or payment.
## What it does
- Generates unique terrestrial planets with realistic continents, mountains, ocean trenches, and volcanic islands
- Simulates tectonic plates with convergent, divergent, and transform boundaries
- Applies glacial, hydraulic, and thermal erosion to carve fjords, river valleys, and talus slopes
- Simulates seasonal climate: wind patterns, ocean currents, precipitation, and Köppen classification
- Creates hotspot volcanism with drift-trail island chains (like Hawaii)
- Allows interactive editing — select multiple tectonic plates for batch reshaping with visual preview before rebuild
- Import your own equirectangular B&W heightmaps (Earth, Mars, hand-drawn) onto a 3D globe with automatic climate simulation
- Import a painted map whose colours are uplift rates and erodibilities (with a legend JSON) and solve it with stream-power erosion into terrain with real rivers, divides and drainage basins; export class, uplift, erodibility, drainage, slope, basin and overlay maps
- Show each class's typical and divide hillslope angle before solving, so a legend's uplift rates can be read as terrain rather than as numbers
- Annotate a painted world with an overlay layer of forests, settlements, roads and coastlines, drawn as a texture over the globe and the map, where one mark property pins or roughens the shore
- Export a window of the planet as Unreal Engine landscape tiles — per-tile 16-bit heights at 255*N+1 vertices, an 8-bit weightmap per paint layer, and a manifest that records the scale, the window in degrees and the projection, so the game engine side never has to guess metres-per-pixel
## Who it's for
- Worldbuilders creating fantasy or sci-fi settings
- Game developers who need heightmaps for Unity, Unreal, or other engines
- Tabletop RPG players building campaign worlds (D&D, Pathfinder, etc.)
- Fantasy/sci-fi authors designing believable planets
- Artists and hobbyists who enjoy procedural generation
- Educators teaching plate tectonics and planetary science
## Key capabilities
- Multiple views: terrain, satellite biome, Köppen climate, heightmap
- 26 detailed inspection layers (geology, atmosphere, ocean, climate)
- Export high-resolution equirectangular maps up to 65,536px wide
- Shareable planet codes — copy a compact code to reproduce any planet exactly
- Works on desktop and mobile browsers
- No build step, no dependencies to install — pure ES modules
## URL
https://orogen.studio/
## Technical details
- Built with Three.js (WebGL), vanilla JavaScript ES modules
- Fibonacci sphere meshing with Voronoi tessellation
- Braun-Willett stream power erosion, Barnes et al. pit-filling algorithm
- Dual-model precipitation (advection simulation + zonal heuristic)
- Fully client-side — no server, no data collection
+242
View File
@@ -0,0 +1,242 @@
# Seasonal Wind Simulation — Pressure-Driven with Longitude-Varying ITCZ
## Context
World Orogen has zero climate/atmospheric simulation. This adds seasonal wind driven by high/low pressure zones — the core physical mechanism behind all planetary wind. The ITCZ (low pressure convergence) tracks a longitude-varying "thermal equator" that hugs the equator over ocean but pushes 15-20° poleward over continents, creating monsoons and seasonal wind reversals. Inspired by Worldbuilding Pasta's climate methodology and Madeline James's pressure band approach.
---
## Algorithm
### Step 1: Compute the Thermal Equator / ITCZ Latitude (per season)
The ITCZ is NOT at a fixed latitude — it follows the hottest zone at each longitude.
**Approach**: Sample ~72 evenly-spaced longitudes (every 5°). At each longitude, scan latitudes from -30° to +30° to find the thermal maximum, then smooth with a periodic spline.
For each longitude sample, compute an "effective heating" at each latitude:
```
heating(lat, lon, season) = solarFlux(lat, season)
× (1 + 0.3 * landFraction(lat, lon, radius=10°))
- 0.006 * avgElevation(lat, lon, radius=10°)
```
Where:
- `solarFlux(lat, season)` = `cos(lat - subsolarLat)` clamped to [0,1]. `subsolarLat = tilt * sin(seasonAngle)` — 23.5° in summer, -23.5° in winter
- `landFraction` is sampled by scanning nearby regions within a ~10° great-circle radius. Land amplifies heating by up to 30% (land heats faster than ocean — Madeline James's core insight)
- `avgElevation` applies a lapse-rate cooling for high terrain
The latitude with maximum heating at each longitude = ITCZ position at that longitude.
**Result constraints** (inspired by both references):
- Over ocean: ITCZ stays ~5° from equator in summer hemisphere
- Over large land: ITCZ pushes to 15-20° from equator
- Default with no land: ~5° toward summer hemisphere (Earth's observed default)
**Smoothing**: Fit a periodic cubic spline through the 72 longitude samples. This guarantees smooth, non-jagged ITCZ contours.
**Data structure**: `itczLatAtLon(lon)` — returns ITCZ latitude in radians for any longitude.
### Step 2: Build Pressure Field (per season, per region)
Five additive components centered on the ITCZ position:
**a) ITCZ Low** (follows the thermal equator):
```
p_itcz = -15 * exp(-0.5 * ((lat - itczLat(lon)) / σ_itcz)²)
```
σ_itcz = 8° (~0.14 rad). A broad Gaussian trough that tracks the ITCZ.
**b) Subtropical Highs** (~25° winter, ~35° summer — per Worldbuilding Pasta):
- NH subtropical high at `+30 + seasonShift*5` degrees
- SH subtropical high at `-30 - seasonShift*5` degrees
- These are NOT a continuous belt — they're strongest over cool ocean. Modulate intensity:
```
highIntensity = 12 * (1 - 0.3 * landFraction) // weaker over hot land
```
- Gaussian with σ = 10°
**c) Subpolar Lows** at ~±60°:
```
p_subpolar = -10 * exp(-0.5 * ((lat ∓ 60°) / 10°)²)
```
**d) Polar Highs** at ~±85°:
```
p_polar = +8 * exp(-0.5 * ((lat ∓ 85°) / 8°)²)
```
**e) Land/Sea Thermal Modifier** (seasonal continental pressure — Madeline James):
- Summer hemisphere continents: thermal low (up to -8 hPa at mid-latitudes)
- Winter hemisphere continents: thermal high (up to +6 hPa)
- Modulated by `sin(2 * |lat|)` (peaks at 45°, weak at equator/poles)
- Scaled by land fraction in local area
**f) Elevation (barometric)**:
```
p_elev = -100 * max(0, elevation)
```
High plateaus = persistent low pressure. Mountains deflect wind naturally.
**g) Noise**: Low-frequency seeded Simplex fBm, ±2 hPa amplitude.
**h) Smoothing**: 3 Laplacian passes over mesh neighbors. Removes discretization artifacts and naturally diffuses land/sea contrast inward from coasts.
### Step 3: Compute Pressure Gradient (per region)
Least-squares fit over mesh neighbors, projecting onto local tangent plane:
For each region r with neighbors n₁..nₖ:
- Project displacement (nᵢ - r) onto east/north tangent vectors
- Accumulate `Σ(δe·δp)/Σ(δe²)` for eastward gradient, same for northward
- This gives `gradE`, `gradN` in the tangent plane
### Step 4: Pressure → Wind with Cross-Equatorial Handling
**Core conversion**: PGF direction = `-∇P` (high→low). Coriolis rotates this.
**The key insight for cross-equatorial flow**: `f = 2Ω·sin(lat)` naturally changes sign at the equator. We use this directly — no special-casing needed for monsoon winds. The SE trades in the SH (deflected left by negative f) naturally become SW monsoon winds in the NH (deflected right by positive f) as they cross the equator chasing the ITCZ.
**Implementation**:
```
f_coriolis = sin(lat) // proportional to Coriolis parameter
absSinLat = |f_coriolis|
// Geostrophic deflection angle: 0° at equator → 70° at mid-latitudes
// Ramps up over ~10° latitude (equatorial Rossby radius)
geoAngle = 70° * smoothstep(0, sin(10°), absSinLat)
// Surface friction: turns wind 20° back toward low pressure, reduces speed 40%
frictionAngle = 20°
// Net rotation from PGF: sign determines NH (right) vs SH (left)
sign = (lat >= 0) ? +1 : -1
totalAngle = sign * (geoAngle - frictionAngle)
// Rotate PGF vector
windE = pgfE·cos(totalAngle) - pgfN·sin(totalAngle)
windN = pgfE·sin(totalAngle) + pgfN·cos(totalAngle)
// Speed reduction from friction
wind *= 0.6
```
**Why this works for cross-equatorial flow**:
- At 10°S: sign=-1, geoAngle≈70° → rotation = -50° (leftward). SE trades.
- At 0°: geoAngle=0° → no rotation. Wind follows PGF directly (northward toward ITCZ).
- At 5°N: sign=+1, geoAngle≈35° → rotation = +15° (rightward). Wind turns from S to SW.
- At 10°N: sign=+1, geoAngle≈70° → rotation = +50°. Full SW monsoon westerlies.
The transition happens naturally over ~10° of latitude — smooth, physically correct, no heuristic needed.
### Step 5: Normalize
Scale wind speed to 0-1 using 95th percentile for visualization.
### Coordinate Convention
Map projection uses Y-up: `lat = asin(r_xyz[3*r+1])`, `lon = atan2(r_xyz[3*r], r_xyz[3*r+2])`.
Tangent frame (Y-up polar axis):
- East = normalize(z, 0, -x) [fallback at poles where x²+z² < ε]
- North = cross(position, east)
---
## File Changes
### New: `js/wind.js` (~300 lines)
Exported:
- `computeWind(mesh, r_xyz, r_elevation, plateIsOcean, r_plate, noise, axialTilt=23.5)`
→ returns pressure/wind arrays for both seasons
Internal helpers:
- `computeITCZ(lonSamples, r_xyz, r_elevation, r_isLand, season, tilt)` — scan latitudes per longitude, find thermal max, return spline
- `evaluateITCZSpline(lon, splineData)` — periodic cubic interpolation
- `zonalPressure(lat, lon, itczSpline, season, landFrac)` — all Gaussian bands + thermal modifier
- `smoothPressure(mesh, pressure, passes)` — Laplacian over neighbors
- `computeGradients(...)` — least-squares pressure gradient
- `pressureToWind(gradE, gradN, sinLat)` — geostrophic + friction + cross-equatorial
### Modified: `js/planet-worker.js` (~25 lines)
- Import `computeWind` from `./wind.js`
- In `handleGenerate`: call after terrain post-processing, before triangle elevations. Add pressure/speed arrays to `debugLayers`, add wind vectors to result + transfer list. Store in retained state `W`.
- In `handleReapply`: recompute wind (elevation changed)
- In `handleEditRecompute`: recompute wind (plates/elevation changed)
### Modified: `js/generate.js` (~15 lines)
- In `case 'done'`, `'reapplyDone'`, `'editDone'`: store wind vectors in `state.curData`
- In synchronous fallback: call `computeWind` directly
### Modified: `index.html` (~4 lines)
Add to `#debugLayer` select:
```html
<option value="pressureSummer">Pressure (Summer)</option>
<option value="pressureWinter">Pressure (Winter)</option>
<option value="windSpeedSummer">Wind Speed (Summer)</option>
<option value="windSpeedWinter">Wind Speed (Winter)</option>
```
### Modified: `js/planet-mesh.js` (~100 lines)
- `buildWindArrows(season)`: subsample ~400 regions, draw line segments for wind direction/magnitude
- **Globe view**: 3D arrows on sphere at r=1.07, oriented via tangent frame
- **Map view**: 2D arrows on equirectangular projection
- Auto-shown when any wind/pressure debug layer is selected
- Season inferred from selected layer name
### Modified: `js/main.js` (~15 lines)
- Wire debug layer change → show/hide wind arrows
- Toggle arrows on globe/map mode switch
- Dispose arrows on new generation
### Modified: `js/state.js` (~2 lines)
- Add `windArrowGroup: null`
### Modified: `README.md`
- Document wind simulation, debug layers, wind arrows
### NOT modified: `js/planet-code.js`
No new sliders (axial tilt fixed at 23.5°).
---
## Performance Budget (200K regions)
| Step | Estimated Time |
|------|---------------|
| ITCZ computation (72 lon samples × lat scan) | ~15ms |
| Precompute lat + tangent frames | ~5ms |
| Pressure field (2 seasons) | ~20ms |
| Noise perturbation (2 seasons) | ~30ms |
| Smoothing (3 passes × 2) | ~20ms |
| Gradient computation (2 seasons) | ~25ms |
| Pressure → wind (2 seasons) | ~10ms |
| **Total** | **~125ms** |
Well within 500ms target. ITCZ computation adds ~15ms (scanning regions in geographic bins).
---
## Verification
### Visual checks
1. **Pressure (Summer)**: Blue ITCZ band that hugs ~5° over ocean but pushes 15-20° north over continents. Red subtropical highs at ~30-35° (weaker over continents). Blue subpolar lows at ~60°.
2. **Pressure (Winter)**: ITCZ shifts south, NH continents show red (thermal highs). Subtropical highs at ~25° (shifted equatorward).
3. **Wind arrows (Summer)**: NE trades in NH tropics, SE trades in SH tropics. Westerlies at 40-60°. Near large NH continents: SW monsoon winds where SH trades cross the equator.
4. **Cross-equatorial test**: Find a longitude where ITCZ is at ~15°N (over land). Verify arrows: SE at 10°S → S at equator → SW at 5°N → W at 15°N.
5. **Season comparison**: Toggle between summer/winter pressure layers. Verify ITCZ migration and continental pressure reversal.
### Performance
- Console timing: wind step < 200ms at 200K, < 500ms at 640K
### Determinism
- Same seed → identical pressure/wind arrays
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3f34e94034396aed82e3c624bec9653f213eefa85f00fefdcd00fbc9faff9f11
size 530622
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://orogen.studio/sitemap.xml
+16
View File
@@ -0,0 +1,16 @@
{
"name": "World Orogen",
"short_name": "Orogen",
"description": "Procedural planet generator with tectonic plates, erosion, and climate simulation",
"start_url": "/",
"display": "browser",
"background_color": "#030308",
"theme_color": "#0a0e17",
"icons": [
{
"src": "preview.png",
"sizes": "1200x630",
"type": "image/png"
}
]
}
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://orogen.studio/</loc>
<lastmod>2026-09-20</lastmod>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://orogen.studio/import</loc>
<lastmod>2026-09-20</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
</urlset>
File diff suppressed because it is too large Load Diff
+164
View File
@@ -0,0 +1,164 @@
/**
* Autonomous terrain tuning script.
*
* Runs the app headlessly with fixed seeds, collects metrics, saves results.
* Designed to be driven by Claude Code — modify terrain-config.js between runs.
*
* Usage: node tuning/auto-tune.mjs [label]
* Output: tuning/results/<label>.json with metrics from all seeds
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, '..');
const RESULTS_DIR = path.join(__dirname, 'results');
fs.mkdirSync(RESULTS_DIR, { recursive: true });
const label = process.argv[2] || `run-${Date.now()}`;
const MIME = {
'.html': 'text/html', '.js': 'application/javascript', '.mjs': 'application/javascript',
'.css': 'text/css', '.json': 'application/json', '.png': 'image/png',
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.wasm': 'application/wasm',
'.txt': 'text/plain', '.xml': 'application/xml',
};
function startServer() {
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
let urlPath = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
if (urlPath === '/' || urlPath === '') urlPath = '/index.html';
const filePath = path.join(PROJECT_ROOT, urlPath);
if (!filePath.startsWith(PROJECT_ROOT)) { res.writeHead(403); res.end(); return; }
fs.readFile(filePath, (err, data) => {
if (err) { res.writeHead(404); res.end('Not found'); return; }
const ext = path.extname(filePath).toLowerCase();
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
res.end(data);
});
});
server.listen(0, '127.0.0.1', () => {
resolve({ server, port: server.address().port });
});
});
}
// Seeds chosen for diversity: different plate configs, land coverage, etc.
const SEEDS = [42, 100, 200, 300, 400];
async function runSeed(browser, baseUrl, seed) {
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 900 });
page.on('dialog', (d) => d.dismiss());
page.on('pageerror', () => {}); // suppress
try {
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await new Promise((r) => setTimeout(r, 2000));
// Dismiss overlays
await page.evaluate(() => {
for (const id of ['tutorialOverlay', 'whatsNewOverlay']) {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
}
});
// Set low detail for speed
await page.evaluate(() => {
const el = document.getElementById('sN');
el.value = 400;
el.dispatchEvent(new Event('input', { bubbles: true }));
});
// Patch seed
await page.evaluate((s) => {
const origPost = Worker.prototype.postMessage;
Worker.prototype.postMessage = function(msg, ...rest) {
if (msg && msg.cmd === 'generate' && msg.seed === undefined) msg.seed = Number(s);
return origPost.call(this, msg, ...rest);
};
}, seed);
// Generate
const genDone = page.evaluate((timeout) => {
return new Promise((resolve, reject) => {
const btn = document.getElementById('generate');
const timer = setTimeout(() => reject(new Error('Generation timed out')), timeout);
btn.addEventListener('generate-done', () => { clearTimeout(timer); resolve(); }, { once: true });
});
}, 120_000);
await new Promise((r) => setTimeout(r, 100));
await page.click('#generate');
await genDone;
await new Promise((r) => setTimeout(r, 500));
const metrics = await page.evaluate(() => window.__terrainMetrics);
return { seed, metrics: metrics || { _error: 'no metrics' } };
} finally {
await page.close().catch(() => {});
}
}
async function main() {
const { server, port } = await startServer();
const baseUrl = `http://127.0.0.1:${port}`;
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--enable-webgl',
'--use-gl=angle', '--use-angle=swiftshader-webgl', '--enable-unsafe-swiftshader'],
});
const results = [];
const t0 = performance.now();
try {
for (const seed of SEEDS) {
const r = await runSeed(browser, baseUrl, seed);
results.push(r);
process.stdout.write(` seed ${seed}: ${r.metrics._error ? 'ERROR' : 'OK'} (${(r.metrics._metrics_ms || 0).toFixed(0)}ms metrics)\n`);
}
} finally {
await browser.close();
server.close();
}
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
// Compute cross-seed averages for key metrics
const validMetrics = results.filter(r => !r.metrics._error).map(r => r.metrics);
const avg = {};
if (validMetrics.length > 0) {
const keys = Object.keys(validMetrics[0]).filter(k => !k.startsWith('_') && typeof validMetrics[0][k] === 'number');
for (const k of keys) {
const vals = validMetrics.map(m => m[k]).filter(v => v != null && !isNaN(v));
avg[k] = vals.length > 0 ? +(vals.reduce((a, b) => a + b, 0) / vals.length).toFixed(4) : null;
}
}
const output = { label, elapsed_s: +elapsed, seeds: SEEDS, results, averages: avg };
const outPath = path.join(RESULTS_DIR, `${label}.json`);
fs.writeFileSync(outPath, JSON.stringify(output, null, 2));
console.log(`\nResults saved: ${outPath} (${elapsed}s total)`);
// Print summary
console.log('\n=== Cross-seed Averages ===');
const highlight = [
'continent_count', 'island_count_total', 'flat_ocean_plate_land_fraction',
'relief_headroom', 'coast_complexity_index', 'hypsometry_trough_depth',
'mountain_boundary_ratio', 'orogenic_elev_correlation', 'erosion_slope_correlation',
'coastal_lowland_fraction', 'land_band_500m_plus_frac',
'shelf_width_passive_km', 'shelf_width_active_km',
];
for (const k of highlight) {
if (avg[k] != null) console.log(` ${k}: ${avg[k]}`);
}
}
main().catch((err) => { console.error(err); process.exit(1); });
+323
View File
@@ -0,0 +1,323 @@
/**
* Headless rendering harness for World Orogen.
*
* Launches a local HTTP server, drives the app with Puppeteer, generates
* planets from fixed seeds/slider combos, and saves globe screenshots to
* tuning/screenshots/.
*
* Usage: node tuning/render-harness.mjs
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, '..');
const SCREENSHOT_DIR = path.join(__dirname, 'screenshots');
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
// ---------------------------------------------------------------------------
// MIME types for the static server
// ---------------------------------------------------------------------------
const MIME = {
'.html': 'text/html',
'.js': 'application/javascript',
'.mjs': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2':'font/woff2',
'.webmanifest': 'application/manifest+json',
'.txt': 'text/plain',
'.xml': 'application/xml',
'.wasm': 'application/wasm',
};
// ---------------------------------------------------------------------------
// Static file server
// ---------------------------------------------------------------------------
function startServer() {
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
let urlPath = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
if (urlPath === '/' || urlPath === '') urlPath = '/index.html';
const filePath = path.join(PROJECT_ROOT, urlPath);
// Security: stay inside project root
if (!filePath.startsWith(PROJECT_ROOT)) {
res.writeHead(403); res.end(); return;
}
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not found');
return;
}
const ext = path.extname(filePath).toLowerCase();
const mime = MIME[ext] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': mime });
res.end(data);
});
});
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
console.log(`Static server listening on http://127.0.0.1:${port}`);
resolve({ server, port });
});
});
}
// ---------------------------------------------------------------------------
// Test cases
// ---------------------------------------------------------------------------
const DETAIL_SLIDER_VALUE = 400; // ~31 000 regions — fast iteration
const TEST_CASES = [
{
name: 'default',
seed: '42',
sliders: {},
},
{
name: 'few-plates-high-land',
seed: '100',
sliders: { sP: 8, sLc: 0.6 },
},
{
name: 'many-plates-low-land',
seed: '200',
sliders: { sP: 80, sLc: 0.25 },
},
{
name: 'high-erosion',
seed: '300',
sliders: { sGl: 0.8, sHEr: 0.8, sTEr: 0.8 },
},
{
name: 'mountainous-sharp-ridges',
seed: '400',
sliders: { sNs: 0.4, sRs: 0.8 },
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Set a slider's value and dispatch an 'input' event so the app reacts. */
async function setSlider(page, id, value) {
await page.evaluate(({ id, value }) => {
const el = document.getElementById(id);
if (!el) throw new Error(`Slider #${id} not found`);
el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
}, { id, value: String(value) });
}
/**
* Install a one-shot listener for 'generate-done' on #generate BEFORE
* clicking the button, and return a promise that resolves when it fires.
* Call this, store the promise, click the button, then await the promise.
*/
function installGenerationWaiter(page, timeoutMs = 120_000) {
// page.evaluate returns a promise that resolves when the inner promise does
return page.evaluate((timeout) => {
return new Promise((resolve, reject) => {
const btn = document.getElementById('generate');
const timer = setTimeout(() => reject(new Error('Generation timed out')), timeout);
btn.addEventListener('generate-done', () => { clearTimeout(timer); resolve(); }, { once: true });
});
}, timeoutMs);
}
/** Rotate the globe by dragging horizontally (yaw) and/or vertically (pitch). */
async function rotateGlobe(page, yawRadians, pitchRadians = 0) {
await page.evaluate(({ yaw, pitch }) => {
const canvas = document.getElementById('canvas');
const w = canvas.clientWidth;
const h = canvas.clientHeight;
const cx = w / 2;
const cy = h / 2;
// OrbitControls maps 2*PI rotation to a full canvas-width/height drag.
const dx = (yaw / (2 * Math.PI)) * w;
const dy = (pitch / (Math.PI)) * h;
const pointerDown = new PointerEvent('pointerdown', {
clientX: cx, clientY: cy, button: 0, bubbles: true, pointerId: 1,
});
const pointerMove = new PointerEvent('pointermove', {
clientX: cx - dx, clientY: cy - dy, button: 0, bubbles: true, pointerId: 1,
});
const pointerUp = new PointerEvent('pointerup', {
clientX: cx - dx, clientY: cy - dy, button: 0, bubbles: true, pointerId: 1,
});
canvas.dispatchEvent(pointerDown);
canvas.dispatchEvent(pointerMove);
canvas.dispatchEvent(pointerUp);
}, { yaw: yawRadians, pitch: pitchRadians });
// Let the render loop catch up.
await new Promise((r) => setTimeout(r, 1500));
}
/** Take a screenshot of the canvas element. */
async function screenshotCanvas(page, filePath) {
const canvas = await page.$('#canvas');
if (!canvas) throw new Error('Canvas not found');
await canvas.screenshot({ path: filePath });
console.log(` Saved: ${path.relative(PROJECT_ROOT, filePath)}`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const { server, port } = await startServer();
const baseUrl = `http://127.0.0.1:${port}`;
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--enable-webgl',
'--use-gl=angle',
'--use-angle=swiftshader-webgl',
'--enable-unsafe-swiftshader',
],
});
try {
for (const tc of TEST_CASES) {
console.log(`\n=== Test case: ${tc.name} (seed ${tc.seed}) ===`);
let page;
try {
page = await browser.newPage();
await page.setViewport({ width: 1200, height: 900 });
// Suppress dialogs / permission prompts
page.on('dialog', (d) => d.dismiss());
// Forward page console and errors for debugging
page.on('console', (msg) => {
if (msg.type() === 'error') console.log(` [PAGE ERROR] ${msg.text()}`);
});
page.on('pageerror', (err) => console.log(` [PAGE EXCEPTION] ${err.message}`));
// Navigate and wait for initial load
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
// Wait for ES modules and Three.js to initialize
await new Promise((r) => setTimeout(r, 2000));
// Close any overlay that may be showing (tutorial / what's new)
await page.evaluate(() => {
for (const id of ['tutorialOverlay', 'whatsNewOverlay']) {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
}
});
// Set detail slider low for fast iteration
await setSlider(page, 'sN', DETAIL_SLIDER_VALUE);
// Set any custom sliders for this test case
for (const [id, val] of Object.entries(tc.sliders)) {
await setSlider(page, id, val);
}
// Intercept the Web Worker postMessage to inject our fixed seed.
// The generate() function passes seed as `undefined` for fresh builds,
// and the worker fills it with Math.random(). We patch postMessage so
// the next 'generate' command carries our chosen seed instead.
await page.evaluate((seed) => {
const origPost = Worker.prototype.postMessage;
Worker.prototype.postMessage = function(msg, ...rest) {
if (msg && msg.cmd === 'generate' && msg.seed === undefined) {
msg.seed = Number(seed);
}
return origPost.call(this, msg, ...rest);
};
}, tc.seed);
// Install the completion listener BEFORE clicking, then click, then await.
const t0 = performance.now();
const genDone = installGenerationWaiter(page, 120_000);
// Small delay so the evaluate above has time to register the listener
await new Promise((r) => setTimeout(r, 100));
await page.click('#generate');
// Wait for generation to finish
await genDone;
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
console.log(` Generation completed in ${elapsed}s`);
// Let rendering settle
await new Promise((r) => setTimeout(r, 1000));
// Extract terrain metrics scorecard
const metrics = await page.evaluate(() => window.__terrainMetrics);
if (metrics) {
const metricsPath = path.join(SCREENSHOT_DIR, `seed-${tc.seed}_${tc.name}_metrics.json`);
fs.writeFileSync(metricsPath, JSON.stringify(metrics, null, 2));
console.log(` Metrics: ${path.relative(PROJECT_ROOT, metricsPath)}`);
if (metrics._error) console.warn(` Metrics error: ${metrics._error}`);
} else {
console.warn(' No terrain metrics available');
}
// Collapse the side panel to maximize canvas area
await page.click('#sidebarToggle');
// Let the panel animate closed and Three.js resize
await new Promise((r) => setTimeout(r, 800));
// Take globe screenshots covering the full planet:
// 4 equatorial rotations (0°, 90°, 180°, 270°) + north pole + south pole
const base = `seed-${tc.seed}_${tc.name}`;
// Equatorial views — rotate around Y axis
for (let i = 0; i < 4; i++) {
if (i > 0) await rotateGlobe(page, Math.PI / 2, 0);
await screenshotCanvas(page, path.join(SCREENSHOT_DIR, `${base}_eq-${i * 90}.png`));
}
// North pole — tilt camera up
await rotateGlobe(page, 0, -Math.PI / 2.2);
await screenshotCanvas(page, path.join(SCREENSHOT_DIR, `${base}_north-pole.png`));
// South pole — tilt camera down (reset first, then go down)
await rotateGlobe(page, 0, Math.PI / 1.1);
await screenshotCanvas(page, path.join(SCREENSHOT_DIR, `${base}_south-pole.png`));
} catch (err) {
console.error(` FAILED: ${err.message}`);
} finally {
if (page) await page.close().catch(() => {});
}
}
} finally {
await browser.close();
server.close();
console.log('\nDone. Screenshots saved to tuning/screenshots/');
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+99
View File
@@ -0,0 +1,99 @@
# Terrain Tuning Session — Findings
## Current Best: v7 (`config-combined-v7.js`)
### All Changes from Original Baseline
```
# Mountain Structure
FOLD_FREQ_PRIMARY: 120 → 160 # tighter fold ridges
FOLD_FREQ_SECONDARY: 300 → 400 # finer secondary folds
FOLD_FREQ_MULT_SCALE: 1.5 → 2.0 # more chaotic fold belts
RIDGE_STRENGTH: 0.12 → 0.15 # taller convergent ridges
DISSECT_THRESHOLD: 0.12 → 0.10 # more mountain valley carving
DISSECT_AMP: 0.4 → 0.55 # deeper dissection valleys
SUMMIT_THRESHOLD: 0.65 → 0.55 # peaks on slightly lower mountains
SUMMIT_STRESS_MIN: 0.05 → 0.03 # peaks with less stress requirement
SUMMIT_SPIKE_OFFSET: 0.45 → 0.40 # more frequent summit spikes
SUMMIT_STRESS_FLOOR: 0.3 → 0.25 # lower stress floor for peaks
# Interior Terrain
INTERIOR_BASE_SHIELD: 0.10 → 0.14 # higher stable cratons
INTERIOR_BASE_BASIN: 0.06 → 0.04 # lower sedimentary basins
INTERIOR_TECTONIC: 0.16 → 0.20 # higher tectonic interiors
PLATEAU_BOOST: 0.025 → 0.04 # more prominent plateaus
CRATON_AMP_SUPPRESS: 0.4 → 0.25 # more texture on stable interiors
BASIN_AMP_SUPPRESS: 0.7 → 0.5 # more texture in basins
# Tectonic Features
RIFT_AXIS_DEPTH: -0.15 → -0.18 # deeper rift valleys
RIFT_AXIS_VOLCANIC_AMP: 0.04 → 0.06 # more rift volcanism texture
RIFT_SHOULDER_UPLIFT: 0.03 → 0.05 # higher rift shoulders
BACK_ARC_DEPTH: 0.10 → 0.14 # deeper back-arc basins
TRENCH_BASE_DEPTH: 0.15 → 0.20 # deeper ocean trenches
TRENCH_STRESS_DEPTH: 0.15 → 0.20 # more trench variation with stress
# Post-Processing
PEAK_COMPRESS_POWER: 0.85 → 0.90 # less peak compression = taller peaks
WARP_MAX_AMP_MULT: 0.12 → 0.13 # slightly more domain warp
SMOOTH_EDGE_SENSITIVITY: 8 → 12 # more edge preservation in smoothing
RIDGE_SHARPEN_CAP: 1.5 → 2.0 # sharper ridge post-processing
VALLEY_DEEPEN_FACTOR: 0.4 → 0.5 # deeper valley carving
```
### Metrics Comparison: Baseline → Best (v7)
| Metric | Baseline | v7 | Change |
|--------|----------|-----|--------|
| relief_headroom | 0.487 | 0.547 | +12% more dramatic |
| coast_complexity | 27.9 | 28.5 | +2% more complex |
| hypsometry_trough | 0.79 | 0.78 | ~same (good) |
| mountain_boundary_ratio | 0.52 | 0.52 | same |
| land_500m_plus_frac | 0.26 | 0.31 | slightly more highland |
| flat_ocean_plate_land | 0.65 | 0.59 | -9% improved |
| island_count | 286 | 288 | same |
| erosion_slope_corr | 0.46 | 0.46 | same |
| shelf_width_active_km | 293 | 222 | -24% narrower (more realistic) |
| shelf_width_passive_km | 478 | 443 | -7% narrower |
### Visual Improvements (confirmed at 31K and 90K regions)
1. **Mountain ridges** more defined with visible linear structure
2. **Continental interiors** have more elevation variety (craton vs basin contrast)
3. **Rift valleys** more visible as distinct features
4. **Ocean floor** more differentiated (deeper trenches, visible ridges)
5. **Coastlines** slightly more complex
6. **Peaks** more prominent and frequent
## Saved Config Snapshots
All in `tuning/results/`:
- `config-sharper-mountains.js` — fold freq + ridge + dissection only
- `config-sharper-mtn-interior-contrast.js` — + interior contrast
- `config-combined-v2.js` — + peak compress + less craton suppress
- `config-combined-v3-rifts-summits.js` — + rifts + summits
- `config-combined-v4-ocean.js` — + deeper trenches/back-arcs
- `config-combined-v5-warp.js` — + subtle warp boost
- `config-combined-v6-full.js` — + edge preserve + ridge sharpen
- `config-combined-v7.js` — + basin suppress + chaotic folds (**BEST**)
## Key Learnings
1. **Elevation thresholds must use quartic mapping** — elevToHeightKm is t^4-based, so 500m = elev 0.40, not 0.0625
2. **Hypsometric curve blend has minimal effect** — pre-existing distribution dominates
3. **Dissection is the #1 lever** for breaking up blobby mountains into realistic ridges
4. **Stress decay is delicate** — 0.5 original is right; 0.6 spreads too wide
5. **Deeper ocean features improve shelf differentiation** — more room for gradient
6. **Volcanic feature boosts backfire** — more arc uplift = more flat land, not taller islands
7. **Hotspot increases reduce island count** — merging features into fewer larger masses
8. **Interior shield/basin contrast** creates visual variety on continents
9. **Edge sensitivity in smoothing** preserves features that other steps create
10. **Fold frequency boost** is most visible at low detail levels (default view)
## Parameters Still Worth Exploring
- Glacial erosion parameters (only tested at user-slider level, not internal constants)
- Coastal plain width and depression (small effect individually)
- Island arc geometry (ARC_DIST_BASE, ARC_SIGMA_BASE_VAL)
- Super-plate blend weights (SMALL_W, SUPER_W)
- Hydraulic erosion deposit fraction and slope sensitivity
@@ -0,0 +1,351 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 1.5;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.15;
export const RIFT_AXIS_VOLCANIC_AMP = 0.04;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.03;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.10;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.7;
export const CRATON_AMP_SUPPRESS = 0.25;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.65;
export const SUMMIT_STRESS_MIN = 0.05;
export const SUMMIT_SPIKE_OFFSET = 0.45;
export const SUMMIT_STRESS_FLOOR = 0.3;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.14;
export const INTERIOR_BASE_BASIN = 0.04;
export const INTERIOR_TECTONIC = 0.20;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.04;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.15;
export const TRENCH_STRESS_DEPTH = 0.15;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.25;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.18;
export const ISLAND_SUBDUCT_MAX = 0.3;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.90;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.12;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 8;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.02;
export const GLACIAL_CONVERGENCE_BONUS = 0.01;
export const GLACIAL_DEPOSIT_AMOUNT = 0.005;
export const GLACIAL_FJORD_CARVE = 0.015;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 1.5;
export const VALLEY_DEEPEN_FACTOR = 0.4;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
@@ -0,0 +1,351 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 1.5;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.18;
export const RIFT_AXIS_VOLCANIC_AMP = 0.06;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.05;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.10;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.7;
export const CRATON_AMP_SUPPRESS = 0.25;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.55;
export const SUMMIT_STRESS_MIN = 0.03;
export const SUMMIT_SPIKE_OFFSET = 0.40;
export const SUMMIT_STRESS_FLOOR = 0.25;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.14;
export const INTERIOR_BASE_BASIN = 0.04;
export const INTERIOR_TECTONIC = 0.20;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.04;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.15;
export const TRENCH_STRESS_DEPTH = 0.15;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.25;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.18;
export const ISLAND_SUBDUCT_MAX = 0.3;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.90;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.12;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 8;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.02;
export const GLACIAL_CONVERGENCE_BONUS = 0.01;
export const GLACIAL_DEPOSIT_AMOUNT = 0.005;
export const GLACIAL_FJORD_CARVE = 0.015;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 1.5;
export const VALLEY_DEEPEN_FACTOR = 0.4;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
@@ -0,0 +1,351 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 1.5;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.18;
export const RIFT_AXIS_VOLCANIC_AMP = 0.06;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.05;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.14;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.7;
export const CRATON_AMP_SUPPRESS = 0.25;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.55;
export const SUMMIT_STRESS_MIN = 0.03;
export const SUMMIT_SPIKE_OFFSET = 0.40;
export const SUMMIT_STRESS_FLOOR = 0.25;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.14;
export const INTERIOR_BASE_BASIN = 0.04;
export const INTERIOR_TECTONIC = 0.20;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.04;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.20;
export const TRENCH_STRESS_DEPTH = 0.20;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.25;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.18;
export const ISLAND_SUBDUCT_MAX = 0.3;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.90;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.12;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 8;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.02;
export const GLACIAL_CONVERGENCE_BONUS = 0.01;
export const GLACIAL_DEPOSIT_AMOUNT = 0.005;
export const GLACIAL_FJORD_CARVE = 0.015;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 1.5;
export const VALLEY_DEEPEN_FACTOR = 0.4;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
@@ -0,0 +1,351 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 1.5;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.18;
export const RIFT_AXIS_VOLCANIC_AMP = 0.06;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.05;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.14;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.7;
export const CRATON_AMP_SUPPRESS = 0.25;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.55;
export const SUMMIT_STRESS_MIN = 0.03;
export const SUMMIT_SPIKE_OFFSET = 0.40;
export const SUMMIT_STRESS_FLOOR = 0.25;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.14;
export const INTERIOR_BASE_BASIN = 0.04;
export const INTERIOR_TECTONIC = 0.20;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.04;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.20;
export const TRENCH_STRESS_DEPTH = 0.20;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.25;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.18;
export const ISLAND_SUBDUCT_MAX = 0.3;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.90;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.13;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 8;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.02;
export const GLACIAL_CONVERGENCE_BONUS = 0.01;
export const GLACIAL_DEPOSIT_AMOUNT = 0.005;
export const GLACIAL_FJORD_CARVE = 0.015;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 1.5;
export const VALLEY_DEEPEN_FACTOR = 0.4;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
@@ -0,0 +1,351 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 1.5;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.18;
export const RIFT_AXIS_VOLCANIC_AMP = 0.06;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.05;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.14;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.7;
export const CRATON_AMP_SUPPRESS = 0.25;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.55;
export const SUMMIT_STRESS_MIN = 0.03;
export const SUMMIT_SPIKE_OFFSET = 0.40;
export const SUMMIT_STRESS_FLOOR = 0.25;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.14;
export const INTERIOR_BASE_BASIN = 0.04;
export const INTERIOR_TECTONIC = 0.20;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.04;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.20;
export const TRENCH_STRESS_DEPTH = 0.20;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.25;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.18;
export const ISLAND_SUBDUCT_MAX = 0.3;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.90;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.13;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 12;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.02;
export const GLACIAL_CONVERGENCE_BONUS = 0.01;
export const GLACIAL_DEPOSIT_AMOUNT = 0.005;
export const GLACIAL_FJORD_CARVE = 0.015;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 2.0;
export const VALLEY_DEEPEN_FACTOR = 0.5;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
@@ -0,0 +1,351 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 2.0;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.18;
export const RIFT_AXIS_VOLCANIC_AMP = 0.06;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.05;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.14;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.5;
export const CRATON_AMP_SUPPRESS = 0.25;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.55;
export const SUMMIT_STRESS_MIN = 0.03;
export const SUMMIT_SPIKE_OFFSET = 0.40;
export const SUMMIT_STRESS_FLOOR = 0.25;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.14;
export const INTERIOR_BASE_BASIN = 0.04;
export const INTERIOR_TECTONIC = 0.20;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.04;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.20;
export const TRENCH_STRESS_DEPTH = 0.20;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.25;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.18;
export const ISLAND_SUBDUCT_MAX = 0.3;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.90;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.13;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 12;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.02;
export const GLACIAL_CONVERGENCE_BONUS = 0.01;
export const GLACIAL_DEPOSIT_AMOUNT = 0.005;
export const GLACIAL_FJORD_CARVE = 0.015;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 2.0;
export const VALLEY_DEEPEN_FACTOR = 0.5;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
@@ -0,0 +1,351 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 2.0;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.18;
export const RIFT_AXIS_VOLCANIC_AMP = 0.06;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.05;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.14;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.5;
export const CRATON_AMP_SUPPRESS = 0.25;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.55;
export const SUMMIT_STRESS_MIN = 0.03;
export const SUMMIT_SPIKE_OFFSET = 0.40;
export const SUMMIT_STRESS_FLOOR = 0.25;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.14;
export const INTERIOR_BASE_BASIN = 0.04;
export const INTERIOR_TECTONIC = 0.20;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.04;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.20;
export const TRENCH_STRESS_DEPTH = 0.20;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.25;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.18;
export const ISLAND_SUBDUCT_MAX = 0.3;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.90;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.13;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 12;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.025;
export const GLACIAL_CONVERGENCE_BONUS = 0.015;
export const GLACIAL_DEPOSIT_AMOUNT = 0.007;
export const GLACIAL_FJORD_CARVE = 0.020;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 2.0;
export const VALLEY_DEEPEN_FACTOR = 0.5;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
@@ -0,0 +1,351 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 1.5;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.15;
export const RIFT_AXIS_VOLCANIC_AMP = 0.04;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.03;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.10;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.7;
export const CRATON_AMP_SUPPRESS = 0.4;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.65;
export const SUMMIT_STRESS_MIN = 0.05;
export const SUMMIT_SPIKE_OFFSET = 0.45;
export const SUMMIT_STRESS_FLOOR = 0.3;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.10;
export const INTERIOR_BASE_BASIN = 0.06;
export const INTERIOR_TECTONIC = 0.16;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.025;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.15;
export const TRENCH_STRESS_DEPTH = 0.15;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.25;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.18;
export const ISLAND_SUBDUCT_MAX = 0.3;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.85;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.12;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 8;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.02;
export const GLACIAL_CONVERGENCE_BONUS = 0.01;
export const GLACIAL_DEPOSIT_AMOUNT = 0.005;
export const GLACIAL_FJORD_CARVE = 0.015;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 1.5;
export const VALLEY_DEEPEN_FACTOR = 0.4;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
@@ -0,0 +1,351 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 1.5;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.15;
export const RIFT_AXIS_VOLCANIC_AMP = 0.04;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.03;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.10;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.7;
export const CRATON_AMP_SUPPRESS = 0.4;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.65;
export const SUMMIT_STRESS_MIN = 0.05;
export const SUMMIT_SPIKE_OFFSET = 0.45;
export const SUMMIT_STRESS_FLOOR = 0.3;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.14;
export const INTERIOR_BASE_BASIN = 0.04;
export const INTERIOR_TECTONIC = 0.20;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.04;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.15;
export const TRENCH_STRESS_DEPTH = 0.15;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.25;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.18;
export const ISLAND_SUBDUCT_MAX = 0.3;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.85;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.12;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 8;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.02;
export const GLACIAL_CONVERGENCE_BONUS = 0.01;
export const GLACIAL_DEPOSIT_AMOUNT = 0.005;
export const GLACIAL_FJORD_CARVE = 0.015;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 1.5;
export const VALLEY_DEEPEN_FACTOR = 0.4;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
+804 -26
View File
@@ -16,14 +16,20 @@ import (
"math"
"os"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"time"
"salty/terrain/internal/coast"
"salty/terrain/internal/field"
"salty/terrain/internal/fluvial"
"salty/terrain/internal/manifest"
"salty/terrain/internal/planet"
"salty/terrain/internal/plates"
"salty/terrain/internal/stats"
"salty/terrain/internal/studio"
"salty/terrain/internal/thermal"
"salty/terrain/internal/uplift"
)
@@ -39,6 +45,36 @@ func main() {
fmt.Fprintln(os.Stderr, "terrain:", err)
os.Exit(1)
}
case "plan":
if err := planCmd(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "terrain:", err)
os.Exit(1)
}
case "bake":
if err := bakeCmd(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "terrain:", err)
os.Exit(1)
}
case "tiles":
if err := tilesCmd(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "terrain:", err)
os.Exit(1)
}
case "overlay":
if err := overlayCmd(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "terrain:", err)
os.Exit(1)
}
case "studio":
if err := studioCmd(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "terrain:", err)
os.Exit(1)
}
case "palette":
if err := paletteCmd(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "terrain:", err)
os.Exit(1)
}
case "-h", "--help", "help":
usage()
default:
@@ -51,16 +87,71 @@ func main() {
func usage() {
fmt.Fprint(os.Stderr, `terrain - the world's heightmap generator (Docs/Terrain.md)
terrain generate [flags]
terrain generate [flags] the square canvas, from a seed
terrain plan [flags] a painted planet: read the template, cut it into regions, solve nothing
terrain bake [flags] a painted planet: solve every region and composite the world
terrain tiles [flags] the detail passes over a bake, a batch of tiles at a time
terrain palette PATH write the default preview palette out, to copy and change
--manifest PATH default RawContent/World/World.json, found by walking up from the working directory
--seed N override the noise seed for this run
--size N run the geology grid at N instead of the manifest's, for iterating
--stage NAME stop after a stage: uplift, fluvial (default: the last one built)
--steps N override the fluvial step count
--mfd P multiple-flow exponent for drainage area; 0 reverts to D8's single receiver
--smooth-passes N post-solve edge-preserving smooth; 0 is off (the default)
--out DIR where the PNGs go (default: beside the manifest, or Preview/ for a --size run)
--quiet only the summary
--no-coast skip the coastal pass: a flat sea floor and an unworked shoreline
plan:
--manifest PATH a manifest with a planet block; default RawContent/World/Planet.json
--out DIR where the maps go (default: beside the manifest, in Plan/)
--map-size N width in pixels of the maps it writes (default 2400)
--margin-km F override planet.ocean_margin_km for this run
--massif-km F override planet.massif_wavelength_km for this run
--coast-jitter F override the outline jitter amplitude, template px; 0 projects the painting as drawn
--coast-wavelength F --coast-octaves N --coast-gain F the rest of the outline jitter
--quiet only the tables
bake:
--manifest PATH default RawContent/World/Planet.json
--out DIR where the maps go (default: the next free Bake_NNN beside the manifest)
--only 3,11 solve only these regions, for iterating on one landmass
--steps N override the fluvial step count
--mfd P multiple-flow exponent for drainage area; 0 reverts to D8's single receiver
--smooth-passes N post-solve edge-preserving smooth; 0 is off (the default)
--jobs N how many regions to solve at once (default 3)
--map-size N width in pixels of the preview and data maps (default 3000)
--margin-km F override planet.ocean_margin_km for this run
--massif-km F override planet.massif_wavelength_km for this run
--coast-jitter F override the outline jitter amplitude, template px; 0 projects the painting as drawn
--coast-wavelength F --coast-octaves N --coast-gain F the rest of the outline jitter
--quiet only the summary
overlay:
--manifest PATH default RawContent/World/Planet.json
--bake DIR the bake to read the terrain from (default: the newest Bake_NNN beside the manifest)
--out PATH where the sheet goes (default: the next Map_NNN.overlay.png beside the template)
--replace start from a blank sheet instead of filling in around what is painted
--no-save write nothing and only say what it would place
--seed N override source.seed for this run
--quiet only the summary
studio:
--manifest PATH default RawContent/World/Planet.json
--addr HOST:PORT where to listen (default 127.0.0.1:8099)
tiles:
--manifest PATH default RawContent/World/Planet.json
--bake DIR where planet_height.png is (default: the newest Bake_NNN beside the manifest)
--out DIR where the tiles go (default: <bake>/tiles)
--only x0,y0,x1,y1 a rectangle of tile indices; the default is all of them
--prefix NAME the tile file stem (default Planet)
--jobs N how many tiles to bake at once (default 4)
--no-detail write the geology upsampled and nothing else: is it the solve or the detail passes?
--no-shore skip the coastal detail pass: is it the shore pass or what it was handed?
--quiet only the summary
`)
}
@@ -93,6 +184,9 @@ func generate(args []string) error {
criticalSlope := fs.Float64("critical-slope", -1, "override Sc in the nonlinear hillslope law, degrees; 0 reverts to linear diffusion and the in-loop clamp")
slopeCap := fs.Float64("slope-cap", 0, "override where the nonlinear flux stops stiffening, as a fraction of Sc")
hillslopeSub := fs.Int("hillslope-substeps", 0, "override the nonlinear hillslope sub-step budget")
mfd := fs.Float64("mfd", -1, "override the multiple-flow exponent for drainage area; 0 reverts to D8's single receiver")
smoothPasses := fs.Int("smooth-passes", -1, "override the post-solve edge-preserving smooth; 0 is off")
smoothSlopeRef := fs.Float64("smooth-slope-ref", 0, "override the slope the smooth preserves, rise over run")
mapSize := fs.Int("map-size", 1400, "side, in pixels, of the false-colour data maps")
outlineOctaves := fs.Int("outline-octaves", 0, "override how much detail the coastline outline has")
outlineGain := fs.Float64("outline-gain", 0, "override the coastline outline's octave gain: how crenellated it is")
@@ -100,6 +194,7 @@ func generate(args []string) error {
surfReach := fs.Float64("surf-reach", 0, "override how far inland the surf planes on open coast, m")
cutFraction := fs.Float64("cut-fraction", 0, "override how completely the surf planes the shore platform, 0..1")
shelfKm := fs.Float64("shelf-km", 0, "override the widest continental shelf, km")
breakM := fs.Float64("break-m", 0, "override the depth at the shelf break, m")
driftM := fs.Float64("drift", 0, "override how far sediment is carried along the shore, m")
riverSediment := fs.Float64("river-sediment", -1, "override the river load per km2 of catchment, m3; 0 disables deltas")
if err := fs.Parse(args); err != nil {
@@ -129,6 +224,15 @@ func generate(args []string) error {
if *diffusion > 0 {
m.Pipeline.Fluvial.DiffusionM2Yr = *diffusion
}
if *mfd >= 0 {
m.Pipeline.Fluvial.MFDExponent = *mfd
}
if *smoothPasses >= 0 {
m.Pipeline.Smooth.Passes = *smoothPasses
}
if *smoothSlopeRef > 0 {
m.Pipeline.Smooth.SlopeRef = *smoothSlopeRef
}
if *talusDeg > 0 {
m.Pipeline.Thermal.TalusDeg = *talusDeg
}
@@ -186,6 +290,9 @@ func generate(args []string) error {
if *shelfKm > 0 {
m.Pipeline.Coast.ShelfKm[1] = *shelfKm
}
if *breakM > 0 {
m.Pipeline.Coast.BreakM = *breakM
}
if *driftM > 0 {
m.Pipeline.Coast.DriftM = *driftM
}
@@ -258,6 +365,7 @@ func generate(args []string) error {
CriticalSlope: thermal.TalusFromDegrees(m.Pipeline.Fluvial.CriticalSlopeDeg),
SlopeCap: m.Pipeline.Fluvial.SlopeCap,
MaxHillslopeSub: m.Pipeline.Fluvial.MaxHillslopeSub,
MFDExponent: m.Pipeline.Fluvial.MFDExponent,
}
hillslope := fmt.Sprintf("linear D %.3f m2/yr, repose clamp every %d steps", p.Diffusion, m.Pipeline.Thermal.Every)
if p.CriticalSlope > 0 {
@@ -267,6 +375,11 @@ func generate(args []string) error {
log("fluvial %d steps of %.0f yr (%.1f Myr), K %.1e, m %.2f, n %.2f, fill every %d",
p.Steps, p.DtYr, float64(p.Steps)*p.DtYr/1e6, p.K, p.M, p.N, p.FillEvery)
log("hillslope: %s", hillslope)
if p.MFDExponent > 0 {
log("drainage area: multiple-flow, exponent %.2f", p.MFDExponent)
} else {
log("drainage area: D8 single receiver")
}
grid = fluvial.NewGrid(geoSize, geoSize, geoCell, up.Base)
grid.SetSeed(m.Source.Seed) // the flat-routing jitter; see internal/fluvial/jitter.go
// Size the flood's bucket queue to the elevation the run can actually reach: the manifest's range,
@@ -283,6 +396,18 @@ func generate(args []string) error {
log(" %3.0f%% step %d/%d height %.0f..%.0f m eta %s", pct, step, total, lo, hi, eta.Round(time.Second))
})
log("fluvial done [%s]", since(solveStart))
// The edge-preserving pass, before the coast so the shore is worked on the surface that ships. Land
// is "above sea level and not flagged as ocean"; the coastal pass owns everything else.
if sm := m.Pipeline.Smooth; sm.Passes > 0 {
land := make([]bool, len(h.Data))
for i := range land {
land[i] = h.Data[i] > float32(m.SeaLevelM) && (up.Base == nil || !up.Base[i])
}
smoothStart := time.Now()
field.SmoothEdgePreserving(h.Data, h.W, h.H, h.CellM, land, sm.Passes, sm.SlopeRef, grid.Scratch())
log("smooth: %d edge-preserving passes, slope ref %.2f [%s]", sm.Passes, sm.SlopeRef, since(smoothStart))
}
}
// The coast, last, on the terrain the solve produced: the sea floor, the surf and the sediment it moves.
@@ -295,12 +420,14 @@ func generate(args []string) error {
}
cs := coast.Build(coast.Input{
Height: h, Sea: up.Base, SeaLevelM: m.SeaLevelM,
BreakM: -m.Pipeline.Continent.SeaFloorM.Hi(), AbyssM: -m.Pipeline.Continent.SeaFloorM.Lo(),
BreakM: m.ShelfBreakM(), AbyssM: -m.Pipeline.Continent.SeaFloorM.Lo(),
Flow: flow, Seed: m.Source.Seed, Cfg: m.Pipeline.Coast,
})
if m.Pipeline.Coast.Enabled {
log("coast: shelf %.1f..%.1f km, surf reach %.0f m, drift %.0f m, %d fetch rays to %.0f m [%s]",
m.Pipeline.Coast.ShelfKm.Lo(), m.Pipeline.Coast.ShelfKm.Hi(), m.Pipeline.Coast.SurfReachM,
log("coast: shelf %.1f..%.1f km to a break at %.0f m, surf reach %.0f m, drift %.0f m, "+
"%d fetch rays to %.0f m [%s]",
m.Pipeline.Coast.ShelfKm.Lo(), m.Pipeline.Coast.ShelfKm.Hi(), m.ShelfBreakM(),
m.Pipeline.Coast.SurfReachM,
m.Pipeline.Coast.DriftM, m.Pipeline.Coast.FetchDirections, m.Pipeline.Coast.FetchRangeM,
since(coastStart))
}
@@ -310,25 +437,24 @@ func generate(args []string) error {
// statistics, the preview and the data maps use is the one the coast pass finished with.
sea := cs.Sea
land := invert(sea)
landFrac = fractionTrue(land)
hLo, hHi = h.MinMax()
landLo, landHi := minMaxWhere(h.Data, land)
rep := stats.Report{
LandFraction: landFrac,
ClipFraction: m.ClipFraction(h.Data),
MinM: float64(hLo), MaxM: float64(hHi), ReliefM: float64(hHi - hLo),
LandMinM: landLo, LandMaxM: landHi, LandReliefM: landHi - landLo,
Slopes: stats.ComputeSlopes(h, land),
Hypsometry: stats.ComputeHypsometry(h, land),
Buckets: stats.UpliftBuckets(h, up.Rate.Data, land, m.Pipeline.Thermal.TalusDeg, *reliefWindowM),
}
// One accumulator over one grid. The square canvas is a single piece, so this is the degenerate case of
// what a planet does with twenty - and it is the same code, which is what makes a number measured here
// comparable with the same number measured on a bake.
stats.SetExpected(m.Pipeline.Fluvial.M, m.Pipeline.Fluvial.N)
acc := stats.New(stats.Options{
ElevMin: m.ElevationM.Min, ElevMax: m.ElevationM.Max,
TalusDeg: m.Pipeline.Thermal.TalusDeg, ReliefWindowM: *reliefWindowM,
ChannelM2: *channelKm2 * 1e6, // the incoming spec's channel definition is 1 km²
K: m.Pipeline.Fluvial.K, M: m.Pipeline.Fluvial.M, N: m.Pipeline.Fluvial.N,
})
sin := stats.Input{H: h, Land: land, UpliftMYr: up.Rate.Data, KLocal: kField}
if grid != nil {
threshold := *channelKm2 * 1e6 // the incoming spec's channel definition is 1 km²
stats.SetExpected(m.Pipeline.Fluvial.M, m.Pipeline.Fluvial.N)
rep.SlopeArea = stats.ComputeSlopeArea(h, grid.Area, grid.Receiver, grid.Length, land,
up.Rate.Data, kField, m.Pipeline.Fluvial.K, m.Pipeline.Fluvial.N, threshold)
rep.DrainageDensity = stats.DrainageDensity(grid.Area, land, geoCell, threshold)
sin.Area, sin.Receiver, sin.Length = grid.Area, grid.Receiver, grid.Length
}
acc.Add(sin)
acc.AddExtent(h.Data, land, m.ClipCells(h.Data))
rep := acc.Report(geoCell)
landFrac = rep.LandFraction
fmt.Println(rep.Summary())
if m.Pipeline.Coast.Enabled {
fmt.Println()
@@ -353,7 +479,7 @@ func generate(args []string) error {
copy(flow.Data, grid.Area)
pv.Flow = flow
}
if err := field.WritePreview(filepath.Join(outDir, "preview.png"), h, pv); err != nil {
if _, err := field.WritePreview(filepath.Join(outDir, "preview.png"), h, pv); err != nil {
return err
}
// A detail crop as well, always. The whole continent at 1500 px cannot show whether the lowlands read as
@@ -363,7 +489,7 @@ func generate(args []string) error {
detail.Size = 1400
detail.RiverKm2 = 0.15
detail.Exaggeration = 2.0
if err := field.WritePreview(filepath.Join(outDir, "preview_detail.png"), h, detail); err != nil {
if _, err := field.WritePreview(filepath.Join(outDir, "preview_detail.png"), h, detail); err != nil {
return err
}
// The geology-grid height, so a preview run has something to look at. The full-resolution height belongs
@@ -466,7 +592,32 @@ func since(t time.Time) string { return time.Since(t).Round(time.Millisecond).St
// findManifest walks up from the working directory, so the command works from anywhere in the repository
// rather than only from the root.
func findManifest(explicit string) (string, error) {
func findManifest(explicit string) (string, error) { return findNamedManifest(explicit, "World.json") }
// studioCmd serves the painting tool. It is the one command that does not finish: it holds the template in
// memory and waits for a browser.
func studioCmd(args []string) error {
fs := flag.NewFlagSet("studio", flag.ExitOnError)
manifestPath := fs.String("manifest", "", "path to a manifest with a planet block")
addr := fs.String("addr", "127.0.0.1:8099", "address to listen on")
if err := fs.Parse(args); err != nil {
return err
}
path, err := findNamedManifest(*manifestPath, "Planet.json")
if err != nil {
return err
}
log := func(format string, a ...any) { fmt.Printf(format+"\n", a...) }
srv, err := studio.New(path, log)
if err != nil {
return err
}
defer srv.Close()
fmt.Printf("terrain studio %s\n", path)
return srv.Listen(*addr)
}
func findNamedManifest(explicit, name string) (string, error) {
if explicit != "" {
return explicit, nil
}
@@ -475,7 +626,7 @@ func findManifest(explicit string) (string, error) {
return "", err
}
for i := 0; i < 8; i++ {
candidate := filepath.Join(dir, "RawContent", "World", "World.json")
candidate := filepath.Join(dir, "RawContent", "World", name)
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
@@ -485,7 +636,617 @@ func findManifest(explicit string) (string, error) {
}
dir = parent
}
return "", fmt.Errorf("no RawContent/World/World.json above %q; pass --manifest", mustWd())
return "", fmt.Errorf("no RawContent/World/%s above %q; pass --manifest", name, mustWd())
}
// planCmd reads a painted template and reports what baking it would involve, without eroding anything.
//
// It is the cheap half of the loop and it is deliberately a separate command rather than a flag on the bake:
// the two decisions that can waste an hour - how the legend read the painting, and how the planet was cut
// into regions - are both settled before the first erosion step, and both are pictures.
func planCmd(args []string) error {
fs := flag.NewFlagSet("plan", flag.ExitOnError)
manifestPath := fs.String("manifest", "", "path to a manifest with a planet block")
out := fs.String("out", "", "where the maps go")
mapSize := fs.Int("map-size", 2400, "width in pixels of the maps")
marginKm := fs.Float64("margin-km", 0, "override the ocean margin, km")
massifKm := fs.Float64("massif-km", 0, "override the upland fabric's wavelength, km")
coastJitter := fs.Float64("coast-jitter", -1, "override the outline jitter amplitude, template px; 0 is off")
coastWave := fs.Float64("coast-wavelength", 0, "override the outline jitter's coarsest octave, template px")
coastOct := fs.Int("coast-octaves", 0, "override the outline jitter's octave count")
coastGain := fs.Float64("coast-gain", 0, "override the outline jitter's octave gain")
plateCount := fs.Int("plates", 0, "override how many plates the lithosphere is in; 0 keeps the manifest's, which is off unless it says otherwise")
proposePlates := fs.Bool("propose-plates", false, "write plates_proposal.png and .json: a tectonic layer to open, edit and point planet.plates.layer at")
beltKm := fs.Float64("belt-km", 0, "override the deformation half-width around a plate margin, km; the zone the belt faults are placed in")
beltDensity := fs.Float64("belt-density", 0, "override the belt fault density, traces per 1000 km2 of deformation zone")
seed := fs.Int64("seed", 0, "re-roll everything the painting does not fix: the massifs, the rock, the faults and the coastline detail")
quiet := fs.Bool("quiet", false, "only the tables")
if err := fs.Parse(args); err != nil {
return err
}
path, err := findNamedManifest(*manifestPath, "Planet.json")
if err != nil {
return err
}
m, err := manifest.Load(path)
if err != nil {
return err
}
applySeed(fs, seed, m)
if !m.IsPlanet() {
return fmt.Errorf("%s has no planet block; `terrain generate` is the command for the square canvas", path)
}
if *massifKm > 0 {
m.Planet.MassifWavelengthKm = *massifKm
}
if *coastJitter >= 0 {
m.Planet.CoastJitterPx = *coastJitter
}
if *coastWave > 0 {
m.Planet.CoastJitterWavelengthPx = *coastWave
}
if *coastOct > 0 {
m.Planet.CoastJitterOctaves = *coastOct
}
if *coastGain > 0 {
m.Planet.CoastJitterGain = *coastGain
}
if *plateCount > 0 {
m.Planet.Plates.Count = *plateCount
}
if *beltKm > 0 || *beltDensity > 0 {
// Either flag switches the belt set on, so the other one has to come from somewhere: the defaults,
// rather than zero, which would be "on but asking for nothing".
b := m.Planet.Plates.Faults
if !b.Wanted() {
b = plates.DefaultBelt()
}
if *beltKm > 0 {
b.ZoneKm = *beltKm
}
if *beltDensity > 0 {
b.Per1000Km2 = *beltDensity
}
m.Planet.Plates.Faults = b
}
if *marginKm > 0 {
m.Planet.OceanMarginKm = *marginKm
if err := m.Validate(); err != nil {
return err
}
}
outDir := *out
if outDir == "" {
outDir = filepath.Join(filepath.Dir(path), "Plan")
}
log := func(format string, a ...any) { fmt.Printf(format+"\n", a...) }
if *quiet {
log = func(string, ...any) {}
}
fmt.Printf("terrain plan %s -> %s\n", path, outDir)
in, err := planet.Plan(m, outDir, *mapSize, log)
if err != nil {
return err
}
in.Report().Print(os.Stdout)
fmt.Printf(" wrote %s and plan.json to %s\n", strings.Join(in.MapNames(), ", "), outDir)
if *proposePlates {
if in.Plates == nil {
return fmt.Errorf("--propose-plates needs a tectonic model to propose from; give it --plates N " +
"or a planet.plates.count")
}
if err := planet.WritePlateProposal(outDir, in, *mapSize); err != nil {
return err
}
fmt.Printf(" wrote plates_proposal.png and plates_proposal.json: edit them, then point\n" +
" planet.plates.layer and planet.plates.legend at them\n")
}
fmt.Println()
return nil
}
// bakeCmd solves a painted planet. Run it detached: a full bake is an hour, and a tool timeout that kills it
// part way leaves nothing useful behind.
func bakeCmd(args []string) error {
fs := flag.NewFlagSet("bake", flag.ExitOnError)
manifestPath := fs.String("manifest", "", "path to a manifest with a planet block")
out := fs.String("out", "", "output directory")
only := fs.String("only", "", "solve only these region ids, comma separated")
steps := fs.Int("steps", 0, "override the fluvial step count")
mapSize := fs.Int("map-size", 3000, "width in pixels of the preview and data maps")
jobs := fs.Int("jobs", 3, "how many regions to solve at once")
marginKm := fs.Float64("margin-km", 0, "override the ocean margin, km")
massifKm := fs.Float64("massif-km", 0, "override the upland fabric's wavelength, km")
coastJitter := fs.Float64("coast-jitter", -1, "override the outline jitter amplitude, template px; 0 is off")
coastWave := fs.Float64("coast-wavelength", 0, "override the outline jitter's coarsest octave, template px")
coastOct := fs.Int("coast-octaves", 0, "override the outline jitter's octave count")
coastGain := fs.Float64("coast-gain", 0, "override the outline jitter's octave gain")
breakM := fs.Float64("break-m", 0, "override the depth at the shelf break, m")
shelfKm := fs.Float64("shelf-km", 0, "override the widest continental shelf, km")
slopeKm := fs.Float64("slope-km", 0, "override how far the continental slope runs to the abyss, km")
seed := fs.Int64("seed", 0, "re-roll everything the painting does not fix")
mfd := fs.Float64("mfd", -1, "override the multiple-flow exponent for drainage area; 0 reverts to D8's single receiver")
smoothPasses := fs.Int("smooth-passes", -1, "override the post-solve edge-preserving smooth; 0 is off")
smoothSlopeRef := fs.Float64("smooth-slope-ref", 0, "override the slope the smooth preserves, rise over run")
quiet := fs.Bool("quiet", false, "only the summary")
if err := fs.Parse(args); err != nil {
return err
}
path, err := findNamedManifest(*manifestPath, "Planet.json")
if err != nil {
return err
}
m, err := manifest.Load(path)
if err != nil {
return err
}
applySeed(fs, seed, m)
if !m.IsPlanet() {
return fmt.Errorf("%s has no planet block; `terrain generate` is the command for the square canvas", path)
}
if *massifKm > 0 {
m.Planet.MassifWavelengthKm = *massifKm
}
if *coastJitter >= 0 {
m.Planet.CoastJitterPx = *coastJitter
}
if *coastWave > 0 {
m.Planet.CoastJitterWavelengthPx = *coastWave
}
if *coastOct > 0 {
m.Planet.CoastJitterOctaves = *coastOct
}
if *coastGain > 0 {
m.Planet.CoastJitterGain = *coastGain
}
if *breakM > 0 {
m.Pipeline.Coast.BreakM = *breakM
}
if *shelfKm > 0 {
m.Pipeline.Coast.ShelfKm[1] = *shelfKm
}
if *slopeKm > 0 {
m.Pipeline.Coast.SlopeKm = *slopeKm
}
if *mfd >= 0 {
m.Pipeline.Fluvial.MFDExponent = *mfd
}
if *smoothPasses >= 0 {
m.Pipeline.Smooth.Passes = *smoothPasses
}
if *smoothSlopeRef > 0 {
m.Pipeline.Smooth.SlopeRef = *smoothSlopeRef
}
if *marginKm > 0 {
m.Planet.OceanMarginKm = *marginKm
if err := m.Validate(); err != nil {
return err
}
}
ids, err := parseIDs(*only)
if err != nil {
return err
}
outDir := *out
if outDir == "" {
outDir = planet.NextBakeDir(filepath.Dir(path))
}
log := func(format string, a ...any) { fmt.Printf(format+"\n", a...) }
if *quiet {
log = func(string, ...any) {}
}
fmt.Printf("terrain bake %s -> %s (GOMAXPROCS %d)\n", path, outDir, runtime.GOMAXPROCS(0))
in, err := planet.Prepare(m, log)
if err != nil {
return err
}
res, err := planet.Bake(in, planet.BakeOptions{Only: ids, Steps: *steps, Jobs: *jobs, Log: log})
if err != nil {
return err
}
if err := res.Write(outDir, *mapSize, log); err != nil {
return err
}
fmt.Print("\n" + res.Summary())
fmt.Printf(" wrote %s\n\n", outDir)
return nil
}
// overlayCmd proposes an annotation layer from a finished bake.
//
// The overlay starts blank and stays blank until somebody paints it, which is right for a layer whose whole
// purpose is authorial - but it means every forest, town and road begins as a guess about terrain the author
// cannot see. This reads a bake and fills the blanks: woodland where trees would grow, settlements where the
// rivers, the flat ground and the coast agree, and the least-cost roads between them.
//
// **It never touches a painted pixel.** The sheet on disk is loaded first and generation fills around it, so
// running this against a half-painted overlay adds to it rather than replacing it, and running it twice is
// safe. --replace is the explicit way to throw the last generation away.
func overlayCmd(args []string) error {
fs := flag.NewFlagSet("overlay", flag.ExitOnError)
manifestPath := fs.String("manifest", "", "path to a manifest with a planet block")
bakeDir := fs.String("bake", "", "the bake to read the terrain from")
out := fs.String("out", "", "where the generated sheet goes")
replace := fs.Bool("replace", false, "ignore the overlay on disk instead of filling in around it")
noSave := fs.Bool("no-save", false, "say what would be placed and write nothing")
seed := fs.Int64("seed", 0, "override source.seed for this run")
quiet := fs.Bool("quiet", false, "only the summary")
if err := fs.Parse(args); err != nil {
return err
}
path, err := findNamedManifest(*manifestPath, "Planet.json")
if err != nil {
return err
}
m, err := manifest.Load(path)
if err != nil {
return err
}
applySeed(fs, seed, m)
if !m.IsPlanet() {
return fmt.Errorf("%s has no planet block", path)
}
dir := *bakeDir
if dir == "" {
dir = latestBakeDir(filepath.Dir(path))
if dir == "" {
return fmt.Errorf("no %sNNN directory beside %s; the overlay is generated from a baked world, "+
"so run `terrain bake` first, or pass --bake", bakePrefix, path)
}
}
log := func(format string, a ...any) { fmt.Printf(format+"\n", a...) }
if *quiet {
log = func(string, ...any) {}
}
fmt.Printf("terrain overlay %s\n", dir)
in, err := planet.Prepare(m, log)
if err != nil {
return err
}
ras, rep, err := planet.GenerateOverlay(planet.OverlayGenOptions{
In: in, BakeDir: dir, Replace: *replace, Log: log,
})
if err != nil {
return err
}
fmt.Println()
for _, line := range planet.OverlaySummary(rep, ras.W, ras.H) {
fmt.Println(line)
}
if *noSave {
fmt.Printf("\nwrote nothing (--no-save)\n\n")
return nil
}
dest := *out
if dest == "" {
// Beside the template and versioned the same way the studio versions its saves, so a generated sheet
// never destroys the one before it: the interesting question is almost always "what did that change".
base := m.OverlayPath()
if base == "" {
base = strings.TrimSuffix(m.TemplatePath(), filepath.Ext(m.TemplatePath())) + ".overlay.png"
}
dest, err = nextOverlayVersion(base)
if err != nil {
return err
}
}
px, alpha := in.Overlay.Encode(ras)
if err := field.WriteRGBA(dest, ras.W, ras.H, px, alpha, png.DefaultCompression); err != nil {
return err
}
fmt.Printf("wrote %s\n", dest)
// Point the manifest at it, so the next plan, bake and studio all read what was just written. Patched as
// text, like every other write to these files, so the commentary survives.
rel, err := filepath.Rel(filepath.Dir(path), dest)
if err != nil {
rel = dest
}
rel = filepath.ToSlash(rel)
if err := repointOverlay(path, rel); err != nil {
return fmt.Errorf("the sheet was written but %s could not be repointed at it: %w", path, err)
}
fmt.Printf(" planet.overlay -> %s\n\n", rel)
return nil
}
// nextOverlayVersion is the first <stem>_NNN.overlay.png beside a path that does not exist yet. It matches
// the studio's numbering so the two write into the same series.
func nextOverlayVersion(src string) (string, error) {
dir := filepath.Dir(src)
base := filepath.Base(src)
stem := strings.TrimSuffix(base, ".overlay.png")
if stem == base {
stem = strings.TrimSuffix(base, filepath.Ext(base))
}
stem = regexp.MustCompile(`_[0-9]{3}$`).ReplaceAllString(stem, "")
for n := 1; n < 1000; n++ {
p := filepath.Join(dir, fmt.Sprintf("%s_%03d.overlay.png", stem, n))
if _, err := os.Stat(p); os.IsNotExist(err) {
return p, nil
} else if err != nil {
return "", err
}
}
return "", fmt.Errorf("%s_001 through _999 all exist; tidy some up", stem)
}
// repointOverlay sets planet.overlay in the manifest text without disturbing anything else in the file.
func repointOverlay(manifestPath, rel string) error {
raw, err := os.ReadFile(manifestPath)
if err != nil {
return err
}
text := string(raw)
key := regexp.MustCompile(`("overlay"\s*:\s*)"[^"]*"`)
if key.MatchString(text) {
text = key.ReplaceAllString(text, `${1}"`+rel+`"`)
} else {
// No key yet: add one beside the legend it belongs with, which is where an author would look for it.
anchor := regexp.MustCompile(`("overlay_legend"\s*:\s*"[^"]*")`)
if !anchor.MatchString(text) {
return fmt.Errorf("neither planet.overlay nor planet.overlay_legend is in the file")
}
text = anchor.ReplaceAllString(text, `"overlay": "`+rel+`",\n ${1}`)
}
return os.WriteFile(manifestPath, []byte(text), 0o644)
}
// tilesCmd runs the detail passes over a geology bake, in batches.
//
// It reads the bake's heightmap from disk rather than solving anything, which is the point: the geology is
// hours and a tile is seconds, so the ground somebody actually wants to stand on can be baked first.
func tilesCmd(args []string) error {
fs := flag.NewFlagSet("tiles", flag.ExitOnError)
manifestPath := fs.String("manifest", "", "path to a manifest with a planet block")
bakeDir := fs.String("bake", "", "where planet_height.png is")
out := fs.String("out", "", "where the tiles go")
only := fs.String("only", "", "a rectangle of tile indices: x0,y0,x1,y1")
prefix := fs.String("prefix", "Planet", "the tile file stem")
jobs := fs.Int("jobs", 4, "how many tiles to bake at once")
noDetail := fs.Bool("no-detail", false, "write the geology upsampled and nothing else, as a diagnostic")
noShore := fs.Bool("no-shore", false, "skip the coastal detail pass, as a diagnostic")
seed := fs.Int64("seed", 0, "the seed the bake was made with; it has to match")
quiet := fs.Bool("quiet", false, "only the summary")
if err := fs.Parse(args); err != nil {
return err
}
path, err := findNamedManifest(*manifestPath, "Planet.json")
if err != nil {
return err
}
m, err := manifest.Load(path)
if err != nil {
return err
}
applySeed(fs, seed, m)
if !m.IsPlanet() {
return fmt.Errorf("%s has no planet block", path)
}
dir := *bakeDir
if dir == "" {
dir = latestBakeDir(filepath.Dir(path))
if dir == "" {
return fmt.Errorf("no %sNNN directory beside %s; run `terrain bake` first, or pass --bake",
bakePrefix, path)
}
}
outDir := *out
if outDir == "" {
outDir = filepath.Join(dir, "tiles")
}
log := func(format string, a ...any) { fmt.Printf(format+"\n", a...) }
if *quiet {
log = func(string, ...any) {}
}
fmt.Printf("terrain tiles %s -> %s\n", dir, outDir)
in, err := planet.Prepare(m, log)
if err != nil {
return err
}
if err := planet.CheckBake(dir, m, log); err != nil {
return err
}
hpath := filepath.Join(dir, "planet_height.png")
values, w, h, err := field.ReadHeightmap(hpath, 0)
if err != nil {
return fmt.Errorf("%s: %w (run `terrain bake` first)", hpath, err)
}
if w != in.P.W || h != in.P.PaintH() {
return fmt.Errorf("%s is %dx%d but the manifest describes a %dx%d planet; the bake and the manifest "+
"have drifted apart", hpath, w, h, in.P.W, in.P.PaintH())
}
height := &field.Field{W: w, H: h, CellM: in.P.CellM, Data: m.Decode(values)}
sea := make([]bool, w*h)
for i, v := range height.Data {
sea[i] = float64(v) < m.SeaLevelM
}
log("read %s: %d x %d at %.1f m", filepath.Base(hpath), w, h, in.P.CellM)
// The fetch field the coastal pass measured over the whole cylinder. A tile cannot compute it - see
// detail.CoastalParams - so a bake that did not write one leaves every shore treated as fully exposed,
// which is said once here rather than discovered in the output.
var exposure *field.Field
epath := filepath.Join(dir, "coast_exposure.png")
if ev, ew, eh, err := field.ReadHeightmap(epath, 0); err == nil {
if ew != w || eh != h {
return fmt.Errorf("%s is %dx%d but the heightmap beside it is %dx%d", epath, ew, eh, w, h)
}
exposure = &field.Field{W: ew, H: eh, CellM: in.P.CellM, Data: make([]float32, len(ev))}
for i, v := range ev {
exposure.Data[i] = float32(v) / 65535
}
log("read coast_exposure.png: the shelter the coastal pass measured, %d x %d", ew, eh)
} else if !os.IsNotExist(err) {
return err
} else {
log("warning %s has no coast_exposure.png, so the coastal detail pass will treat every shore as "+
"fully exposed. Rebake to get sheltered bays their own beaches", dir)
}
opt := planet.TileOptions{
In: in, HeightM: height, Sea: sea, Exposure: exposure, Out: outDir, Prefix: *prefix, Jobs: *jobs,
NoDetail: *noDetail, NoShore: *noShore, Log: log,
}
if *only != "" {
ids, err := parseIDs(*only)
if err != nil {
return err
}
if len(ids) != 4 {
return fmt.Errorf("--only wants four numbers, x0,y0,x1,y1; got %q", *only)
}
opt.Only = [4]int{ids[0], ids[1], ids[2], ids[3]}
opt.OnlySet = true
}
started := time.Now()
idx, err := planet.BakeTiles(opt)
if err != nil {
return err
}
total, lo, hi := 0.0, 1e30, -1e30
worstClip := 0.0
for _, t := range idx.Tiles {
total += t.Seconds
lo = math.Min(lo, t.MinM)
hi = math.Max(hi, t.MaxM)
worstClip = math.Max(worstClip, t.ClipFrac)
}
fmt.Printf("\n %d tiles of %d px at %.1f m in %s (%.0f s of work)\n",
len(idx.Tiles), idx.TilePx, idx.CellM, time.Since(started).Round(time.Second), total)
fmt.Printf(" %.0f..%.0f m, worst clip %.3f%%\n", lo, hi, worstClip*100)
if s := coastalSummary(idx); s != "" {
fmt.Print(s)
}
fmt.Printf(" wrote %s and tiles.json\n\n", outDir)
return nil
}
// coastalSummary pools pass 11b's accounting over the tiles that had a shore in them.
//
// The backshore pair is the line to read and it is deliberately not just the cliff fraction: a batch with no
// cliffs in it is either a coast with no cliffs on it or a threshold in the wrong place, and only the height
// of the land behind the shore tells the two apart. On the first painted template it reads 0 m median and 2 m
// P90, which is what a coastal plain is - every land class in that legend ramps its uplift up from the
// waterline over a kilometre or more, so its coasts are plains by construction and its beaches are beaches.
func coastalSummary(idx *planet.TileIndex) string {
var shore, tiles int
var cliff, cut, scree, beach, p50, p90 float64
for _, t := range idx.Tiles {
c := t.Coastal
if c == nil {
continue
}
tiles++
shore += c.ShoreCells
w := float64(c.ShoreCells)
cliff += c.CliffFrac * w
p50 += c.BackshoreP50M * w
p90 += c.BackshoreP90M * w
cut += c.CutM3
scree += c.ScreeM3
beach += c.BeachM3
}
if shore == 0 {
return ""
}
w := float64(shore)
km := w * idx.CellM / 1000
return fmt.Sprintf(
" shore: %.0f km of waterline over %d tiles, backshore %.1f m median and %.1f m P90, so %.0f%% of it\n"+
" is cliff; the faces lost %.0f m3 and their aprons gained %.0f m3, beaches net %+.0f m3\n",
km, tiles, p50/w, p90/w, cliff/w*100, cut, scree, beach)
}
// paletteCmd writes the built-in palette out as a file.
//
// It exists so the defaults are something you can read and copy rather than something you have to find in
// the source. A palette changes no height - two bakes of the same world under two palettes are the same
// terrain - so swapping one is cheap and reversible, which is exactly the kind of thing that should be a
// file.
func paletteCmd(args []string) error {
fs := flag.NewFlagSet("palette", flag.ExitOnError)
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 1 {
return fmt.Errorf("usage: terrain palette <path to write>")
}
path := fs.Arg(0)
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("%s already exists; pick another name rather than overwrite a palette somebody "+
"may have edited", path)
}
p := field.DefaultPalette()
if err := p.Write(path); err != nil {
return err
}
fmt.Printf("wrote the default palette to %s\n", path)
fmt.Println(`point a planet manifest at it with "palette": "<path relative to the manifest>"`)
return nil
}
// Bake directories are versioned, so a re-bake never destroys the one before it.
//
// A bake is an hour and a half and the interesting question is almost always "what did that change", which
// needs both. `--out` still overrides, and `terrain tiles` defaults to the newest, so the common case needs
// no flags at all.
const bakePrefix = "Bake_"
// latestBakeDir is the highest version that exists, or "" when there is none.
func latestBakeDir(base string) string {
entries, err := os.ReadDir(base)
if err != nil {
return ""
}
best, bestN := "", -1
for _, e := range entries {
if !e.IsDir() || !strings.HasPrefix(e.Name(), bakePrefix) {
continue
}
n, err := strconv.Atoi(strings.TrimPrefix(e.Name(), bakePrefix))
if err == nil && n > bestN {
best, bestN = filepath.Join(base, e.Name()), n
}
}
return best
}
// parseIDs reads a comma-separated region list.
func parseIDs(s string) ([]int, error) {
if s == "" {
return nil, nil
}
var out []int
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
n, err := strconv.Atoi(part)
if err != nil {
return nil, fmt.Errorf("--only %q: %w", s, err)
}
out = append(out, n)
}
return out, nil
}
func mustWd() string {
@@ -615,3 +1376,20 @@ func localRelief(h *field.Field, windowM float64) *field.Field {
}
return out
}
// applySeed overrides the manifest's seed when --seed was actually given.
//
// `fs.Visit` rather than a sentinel value, because a seed is an arbitrary int64 and every sentinel is a seed
// somebody could legitimately want. Visit reports only the flags the command line actually set, which is
// exactly the question being asked.
//
// It is on `tiles` as well as on `plan` and `bake`, and not as a convenience: the detail passes hash the seed
// into every droplet, so a tile run has to be told the same seed the heightmap was baked under. CheckBake
// refuses the mismatch rather than producing a tile whose gullies belong to a different world.
func applySeed(fs *flag.FlagSet, seed *int64, m *manifest.Manifest) {
fs.Visit(func(f *flag.Flag) {
if f.Name == "seed" {
m.Source.Seed = *seed
}
})
}
+114
View File
@@ -0,0 +1,114 @@
package check
import (
"math"
"testing"
"salty/terrain/internal/fluvial"
"salty/terrain/internal/uplift"
"salty/terrain/internal/world"
)
// The claim the whole fault feature rests on, end to end: a difference in uplift rate across a line survives
// the solve as an escarpment, on the side the fault raises.
//
// It is here rather than in internal/uplift because everything up there tests the *rate* field - that it is
// asymmetric, that two frames agree about it, that it tapers at the tips - and none of that says the solve
// leaves anything behind. A fault is applied as a rate precisely so that erosion cannot remove it, and
// "erosion cannot remove it" is a statement about a thousand steps of stream power, not about a weight
// function. Measured on the real planet it comes out at 2.7 to 50 m of scarp for throws of 139 to 399 m, all
// five facing the right way; this is that in miniature and fast enough to run every time.
func TestAFaultLeavesAScarpAfterTheSolve(t *testing.T) {
const w, h = 400, 400
const cellM = 8.0
const steps = 400
const dtYr = 1500.0
const runYears = steps * dtYr
p := world.Planet{CellM: cellM, W: w, H: h, PadY: 0, NoisePeriodM: float64(w) * cellM}
if err := p.Validate(); err != nil {
t.Fatal(err)
}
f := world.Whole(p)
// One straight east-west trace across the middle of the grid. Straight on purpose: the question is what
// the solve does to the step, and a curve would only make the measurement harder to read.
midM := float64(h) * cellM / 2
pts := make([][2]float64, 17)
for i := range pts {
pts[i] = [2]float64{float64(i) * float64(w) * cellM / 16, midM}
}
trace := uplift.FaultTrace{PointsM: pts, ThrowM: 300, LengthM: float64(w) * cellM}
delta := uplift.FaultDelta(f, []uplift.FaultTrace{trace}, runYears)
if delta == nil {
t.Fatal("the trace reached nothing")
}
// A quiet landscape to put it in: the sea along the left edge as base level, and a low uniform rate
// everywhere else so that anything standing up is the fault's doing and not the background's.
base := make([]bool, w*h)
rate := make([]float32, w*h)
height := make([]float32, w*h)
const backgroundMYr = 4.5e-5 // 0.045 mm/yr, the shipped highland foreland
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if x < 12 {
base[i] = true
continue
}
r := backgroundMYr + float64(delta[i])
if r < 0 {
r = 0
}
rate[i] = float32(r)
height[i] = float32(20 + 4*math.Sin(float64(x)/23)*math.Cos(float64(y)/31))
}
}
g := fluvial.NewGrid(w, h, cellM, base)
g.SetElevationRange(-2000, 4000)
g.Run(height, rate, nil, fluvial.Params{
K: 5e-5, M: 0.5, N: 1, DtYr: dtYr, Steps: steps, Diffusion: 0.02, FillEvery: 1,
TalusSlope: math.Tan(35 * math.Pi / 180), ThermalEvery: 4, ThermalPasses: 24,
CriticalSlope: math.Tan(35 * math.Pi / 180), SlopeCap: 0.9, MaxHillslopeSub: 24,
}, nil)
// The trace runs east-west, so the two sides are north and south of it. nearestOnTrace signs a point by
// the cross product, which for a west-to-east trace puts the *north* side at d > 0 - the steep, upthrown
// side of a fault that is not reversed.
const offCells = 75 // 600 m either side, the same offset the planet-scale measurement used
midCell := h / 2
mean := func(row int) float64 {
sum, n := 0.0, 0
for x := 40; x < w-40; x++ {
sum += float64(height[row*w+x])
n++
}
return sum / float64(n)
}
up := mean(midCell - offCells)
down := mean(midCell + offCells)
if up <= down {
t.Fatalf("no scarp: the upthrown side averages %.1f m and the downthrown side %.1f m", up, down)
}
// Big enough to be terrain rather than noise, and well under the throw, because erosion takes most of a
// fault's displacement away - which is the whole reason a fault has to be applied as a rate and not as a
// shape. The planet-scale measurement puts the survivor at a few per cent to a fifth of the throw.
if step := up - down; step < 5 {
t.Errorf("the scarp is only %.1f m across a 300 m throw; that is not an escarpment", step)
} else if step > trace.ThrowM {
t.Errorf("the scarp is %.1f m against a %.0f m throw; nothing should exceed its own displacement",
step, trace.ThrowM)
}
// And it is *at the fault*, not a general tilt of the map: the step across the trace has to be far
// sharper than the same distance measured entirely on one side of it.
across := up - down
within := math.Abs(mean(midCell-offCells) - mean(midCell-2*offCells))
if across <= within {
t.Errorf("the step across the trace is %.1f m and a step of the same span on one side of it is "+
"%.1f m; that is a tilted map, not a fault", across, within)
}
}
+185
View File
@@ -0,0 +1,185 @@
package check
import (
"runtime"
"testing"
"salty/terrain/internal/fluvial"
"salty/terrain/internal/manifest"
"salty/terrain/internal/region"
"salty/terrain/internal/template"
"salty/terrain/internal/thermal"
"salty/terrain/internal/uplift"
"salty/terrain/internal/world"
)
const planetLegend = `{"classes":[
{"name":"sea","rgb":[0,0,255],"sea":true,"depth_m":400},
{"name":"plain","rgb":[150,200,100],"uplift_mm_yr":0.08,"k_mult":1.0},
{"name":"range","rgb":[60,160,100],"uplift_mm_yr":0.9,"k_mult":0.6}
]}`
// syntheticPlanet paints a small world with three landmasses, one of them across the seam, and returns it
// classified and projected. It is the smallest thing that exercises everything a real bake does: a cylinder,
// several regions, a seam, and two uplift classes.
func syntheticPlanet(t *testing.T, seed int64) (*manifest.Manifest, *template.Map, *region.Partition) {
t.Helper()
lg, err := template.Parse([]byte(planetLegend))
if err != nil {
t.Fatal(err)
}
const w, paintH, pad = 128, 64, 6
p := world.Planet{CellM: 40, W: w, H: paintH + 2*pad, PadY: pad, NoisePeriodM: w * 40}
if err := p.Validate(); err != nil {
t.Fatal(err)
}
sea := uint8(lg.Index("sea"))
plain := uint8(lg.Index("plain"))
rng := uint8(lg.Index("range"))
m := &template.Map{P: p, L: lg, Class: make([]uint8, p.W*p.H), Sea: make([]bool, p.W*p.H)}
for i := range m.Class {
m.Class[i], m.Sea[i] = sea, true
}
put := func(x0, y0, w0, h0 int, c uint8) {
for y := y0; y < y0+h0; y++ {
for x := x0; x < x0+w0; x++ {
i := (y+pad)*p.W + p.WrapX(x)
m.Class[i], m.Sea[i] = c, false
}
}
}
put(20, 10, 30, 24, plain) // a plain
put(30, 16, 12, 10, rng) // with a range in it
put(70, 30, 22, 20, rng) // a mountainous island
put(-4, 44, 10, 12, plain) // and one across the seam
part, err := region.Build(m, 4, 4)
if err != nil {
t.Fatal(err)
}
if len(part.Regions) < 3 {
t.Fatalf("got %d regions, want at least 3", len(part.Regions))
}
seam := false
for _, r := range part.Regions {
seam = seam || r.Seam
}
if !seam {
t.Fatal("no region straddles the seam; the test is not testing what it claims")
}
man := manifest.Defaults()
man.Source.Seed = seed
man.Planet = &manifest.Planet{UpliftVariation: 0.3}
return man, m, part
}
// solvePlanet runs the whole painted path: cut each region, build its painted geology, solve it, composite
// the land back. It is deliberately the same sequence internal/planet uses.
func solvePlanet(t *testing.T, seed int64, steps int) []float32 {
t.Helper()
man, m, part := syntheticPlanet(t, seed)
rates, ks := m.L.Rates(), m.L.Erodibilities()
out := make([]float32, m.P.W*m.P.H)
params := fluvial.Params{
K: 5e-5, M: 0.5, N: 1, DtYr: 1500, Steps: steps, Diffusion: 0.02, FillEvery: 1,
TalusSlope: thermal.TalusFromDegrees(35), ThermalEvery: 4, ThermalPasses: 2,
CriticalSlope: thermal.TalusFromDegrees(35), SlopeCap: 0.9, MaxHillslopeSub: 24,
}
for _, rg := range part.Regions {
class, land := part.Cut(m, rg)
up := uplift.FromTemplate(uplift.Paint{
Frame: rg.Frame, Class: class, Land: land,
Rates: rates, Ks: ks, Variation: man.Planet.UpliftVariation,
}, man)
h := up.Height.Clone()
g := fluvial.NewGrid(rg.Frame.W, rg.Frame.H, rg.Frame.P.CellM, up.Base)
g.SetSeed(man.Source.Seed)
g.SetFrame(rg.Frame)
g.SetElevationRange(-2000, 4000)
g.Run(h.Data, up.Rate.Data, up.K.Data, params, nil)
part.Composite(out, m, rg, h.Data)
}
return out
}
// The painted path's half of cross-cutting rule 12. The square canvas already has this assertion; a planet
// adds three ways to break it that the square canvas cannot reach - the classifier's parallel reduction, the
// region flood, and regions solved several at a time - so it gets its own.
func TestPaintedPlanetIsDeterministicAcrossGOMAXPROCS(t *testing.T) {
was := runtime.GOMAXPROCS(1)
defer runtime.GOMAXPROCS(was)
var want string
for _, procs := range []int{1, 2, 4, 8, 16} {
runtime.GOMAXPROCS(procs)
got := hash(solvePlanet(t, 7, 60))
if want == "" {
want = got
continue
}
if got != want {
t.Fatalf("GOMAXPROCS %d gives %s, GOMAXPROCS 1 gives %s", procs, got, want)
}
}
}
func TestSameSeedSamePlanet(t *testing.T) {
a := hash(solvePlanet(t, 11, 40))
b := hash(solvePlanet(t, 11, 40))
if a != b {
t.Fatalf("two runs of the same seed differ: %s and %s", a, b)
}
if c := hash(solvePlanet(t, 12, 40)); c == a {
t.Fatal("two different seeds give the same planet")
}
}
// The invariant the whole per-landmass decomposition rests on, asserted directly.
//
// Solving a landmass in a box of its own is only the same answer as solving the planet whole because ocean
// cells are held fixed at sea level and nothing in the solve can move them: ComputeReceivers makes every
// outlet its own receiver, so no flow path crosses water, and StreamPower, both diffusions, the repose clamp
// and thermal all skip a fixed cell. If that ever stopped being true, regions would start lying to each
// other and nothing else in the suite would say so.
func TestOceanCellsAreUntouchedByTheSolve(t *testing.T) {
man, m, part := syntheticPlanet(t, 7)
rates, ks := m.L.Rates(), m.L.Erodibilities()
params := fluvial.Params{
K: 5e-5, M: 0.5, N: 1, DtYr: 1500, Steps: 80, Diffusion: 0.02, FillEvery: 1,
TalusSlope: thermal.TalusFromDegrees(35), ThermalEvery: 4, ThermalPasses: 2,
CriticalSlope: thermal.TalusFromDegrees(35), SlopeCap: 0.9, MaxHillslopeSub: 24,
}
checked := 0
for _, rg := range part.Regions {
class, land := part.Cut(m, rg)
up := uplift.FromTemplate(uplift.Paint{
Frame: rg.Frame, Class: class, Land: land,
Rates: rates, Ks: ks, Variation: 0.3,
}, man)
h := up.Height.Clone()
g := fluvial.NewGrid(rg.Frame.W, rg.Frame.H, rg.Frame.P.CellM, up.Base)
g.SetSeed(man.Source.Seed)
g.SetFrame(rg.Frame)
g.SetElevationRange(-2000, 4000)
g.Run(h.Data, up.Rate.Data, up.K.Data, params, nil)
for i, isBase := range up.Base {
if !isBase {
continue
}
checked++
if h.Data[i] != float32(man.SeaLevelM) {
t.Fatalf("region %d: ocean cell %d came out at %g m, not sea level. The composite writes "+
"only land for exactly this reason, and it is now unsafe", rg.ID, i, h.Data[i])
}
}
}
if checked == 0 {
t.Fatal("no ocean cells were checked")
}
}
+242 -58
View File
@@ -98,10 +98,57 @@ type Input struct {
Sea []bool // the continent mask's ocean: the cells the solve held at base level
SeaLevelM float64 // the base level the solve used, and the datum every depth here is measured from
BreakM float64 // depth at the shelf break, positive metres
AbyssM float64 // depth of the abyssal floor, positive metres
Flow []float32
Seed int64
Cfg manifest.Coast
// AbyssM is how deep the open ocean is, in positive metres, and Abyss is the same thing per cell when a
// world has one. A painted planet does: its sea classes carry their own `depth_m`, so the ocean is
// already laid at several depths before this pass runs, and a derived shelf that bottomed out at one
// global abyss would put a step at the shelf break wherever the two disagreed. Nil falls back to AbyssM,
// which is what the square canvas has and what every caller had before.
AbyssM float64
Abyss []float32
// WrapX says the grid is a cylinder: column W-1 and column 0 are neighbours. A planet is measured once,
// whole, so every march, every ray and every running sum in this pass has to cross the seam - the
// alternative is a shelf, a fetch and a sediment budget that all stop dead at one meridian.
WrapX bool
// NoisePeriodM is how far the sea-floor roughness runs before it repeats. It has to divide the
// circumference exactly on a cylinder or the noise breaks at the seam like every other field; zero means
// the flat-grid default, which is a multiple of the roughness wavelength and repeats wherever it likes
// because a flat grid has no seam to break.
NoisePeriodM float64
Flow []float32
Seed int64
Cfg manifest.Coast
}
// abyssAt is how deep the open ocean is at one cell.
func (in Input) abyssAt(i int) float64 {
if in.Abyss != nil {
return float64(in.Abyss[i])
}
return in.AbyssM
}
// col brings a column index onto the grid: wrapped on a cylinder, refused past the edge of a flat one.
func (g *Geometry) col(x int) (int, bool) {
if g.WrapX {
return ((x % g.W) + g.W) % g.W, true
}
if x < 0 || x >= g.W {
return 0, false
}
return x, true
}
// distAt reads the signed distance field with X wrapped on a cylinder and clamped otherwise. Y always clamps,
// because the top and bottom of the map are the poles and not each other.
func (g *Geometry) distAt(x, y int) float64 {
if g.WrapX {
x = ((x % g.W) + g.W) % g.W
}
return float64(g.Dist.AtClamped(x, y))
}
// Result is the geometry the pass built and the accounting it kept.
@@ -167,7 +214,7 @@ func Build(in Input) *Result {
w, ht := h.W, h.H
cellArea := h.CellM * h.CellM
g := Measure(in.Sea, w, ht, h.CellM)
g := MeasureWrapped(in.Sea, w, ht, h.CellM, in.WrapX)
res := &Result{Geometry: g, Exposure: field.NewLike(h), Change: field.NewLike(h)}
// Disabled, or a map with no coast on it: the sea floor is the flat plane at the abyssal depth, which is
@@ -175,10 +222,11 @@ func Build(in Input) *Result {
if !in.Cfg.Enabled || len(g.Waterline) == 0 {
for i := range in.Sea {
if in.Sea[i] {
h.Data[i] = float32(in.SeaLevelM - in.AbyssM)
h.Data[i] = float32(in.SeaLevelM - in.abyssAt(i))
}
}
res.finish(h.Clone(), in)
copy(res.Change.Data, h.Data)
res.finish(in)
return res
}
@@ -189,20 +237,23 @@ func Build(in Input) *Result {
// earlier it would be a map of the sea floor: the ocean cells go from sea level to -180 m in one step, and
// a few hundred metres of that swamps the few metres the surf and the sediment move, which is the thing
// the map exists to show.
before := h.Clone()
// The "before" snapshot and the change map are the same array. Change is h minus before, so the snapshot
// is taken *into* the field that will hold the answer and subtracted from in place at the end - one field
// of 304 MB at planet scale rather than two, for a picture.
copy(res.Change.Data, h.Data)
shoreExposure := fetch(g, in)
res.Stats.ExposureP10, res.Stats.ExposureP50, res.Stats.ExposureP90 = shorePercentiles(shoreExposure, g)
res.Stats.ExposureP10, res.Stats.ExposureP50, res.Stats.ExposureP90 = shorePercentiles(shoreExposure)
carried := field.NewLike(h)
for i, ref := range g.Ref {
if ref >= 0 {
carried.Data[i] = shoreExposure.Data[ref]
carried.Data[i] = shoreExposure[ref]
}
}
// Smoothed for the same reason the shelf width is: carrying a per-shore value by "the stretch nearest to
// you" partitions the map into Voronoi wedges, and a wedge boundary inside the deposition band would put
// a straight edge through a beach.
res.Exposure = boxMean(carried, int(exposureSmoothM/h.CellM+0.5), 2)
res.Exposure = boxMean(carried, int(exposureSmoothM/h.CellM+0.5), 2, g.WrapX)
cut := plane(h, g, res.Exposure, in)
@@ -210,14 +261,21 @@ func Build(in Input) *Result {
// waterline cell, so a parallel loop would be accumulating into the same slot from several goroutines and
// the float sum would depend on who got there first. Cross-cutting rule 12 is not negotiable here, and
// one linear pass over the grid costs nothing next to the solve.
supply := make([]float64, w*ht)
// One entry per *waterline cell*, not per grid cell. There are a few hundred thousand of the first and
// tens of millions of the second, and this used to be the second: 608 MB at planet scale for an array
// that is only ever read at the shore. See Geometry.Ref.
supply := make([]float64, len(g.Waterline))
var cutM3, planedCells float64
for i, c := range cut.Data {
if c <= 0 {
continue
}
ref := g.Ref[i]
if ref < 0 {
continue // no shore to credit it to; cannot happen for a cell the surf reached, but cheap to say
}
v := float64(c) * cellArea
supply[g.Ref[i]] += v
supply[ref] += v
cutM3 += v
planedCells++
}
@@ -235,7 +293,7 @@ func Build(in Input) *Result {
res.Stats.BackshoreM = backshore
res.Stats.BackshoreP90M = backshoreP90
res.Stats.ShelfPctSea = shelfFraction(g, in, shelfW)
res.finish(before, in)
res.finish(in)
return res
}
@@ -245,12 +303,13 @@ func Build(in Input) *Result {
// beach the pass built out of cliff debris is land, and a low headland it planed under the waterline is not.
// The statistics and the preview both ask what is above sea level, so they get an answer about the terrain
// rather than about the mask that seeded it.
func (r *Result) finish(before *field.Field, in Input) {
func (r *Result) finish(in Input) {
h := in.Height
r.Sea = make([]bool, len(h.Data))
sea, beach, drowned := 0, 0, 0
for i := range h.Data {
r.Change.Data[i] = h.Data[i] - before.Data[i]
// Change came in holding the *before* heights; it leaves holding the difference.
r.Change.Data[i] = h.Data[i] - r.Change.Data[i]
r.Sea[i] = float64(h.Data[i]) < in.SeaLevelM
if r.Sea[i] {
sea++
@@ -283,7 +342,7 @@ func (r *Result) finish(before *field.Field, in Input) {
// hundred metres turns the wedge boundaries back into what they should have been, a shelf whose width varies
// smoothly along the coast.
func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
out := field.NewLike(h)
out := make([]float32, len(g.Waterline))
steps := int(backshoreM/h.CellM + 0.5)
lo := in.Cfg.ShelfKm.Lo() * 1000
hi := in.Cfg.ShelfKm.Hi() * 1000
@@ -295,8 +354,8 @@ func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
for n := a; n < b; n++ {
i := int(g.Waterline[n])
x, y := i%g.W, i/g.W
dx := float64(g.Dist.AtClamped(x+1, y) - g.Dist.AtClamped(x-1, y))
dy := float64(g.Dist.AtClamped(x, y+1) - g.Dist.AtClamped(x, y-1))
dx := g.distAt(x+1, y) - g.distAt(x-1, y)
dy := g.distAt(x, y+1) - g.distAt(x, y-1)
l := math.Hypot(dx, dy)
if l < 1e-6 {
dx, dy, l = 1, 0, 1
@@ -304,9 +363,9 @@ func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
dx, dy = dx/l, dy/l
var relief float64
for t := 1; t <= steps; t++ {
px := x + int(math.Round(dx*float64(t)))
px, ok := g.col(x + int(math.Round(dx*float64(t))))
py := y + int(math.Round(dy*float64(t)))
if px < 0 || py < 0 || px >= g.W || py >= g.H {
if !ok || py < 0 || py >= g.H {
break
}
if e := float64(h.Data[py*g.W+px]) - in.SeaLevelM; e > relief {
@@ -317,19 +376,19 @@ func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
if t > 1 {
t = 1
}
out.Data[i] = float32(hi + (lo-hi)*noise.Smoothstep(t))
out[n] = float32(hi + (lo-hi)*noise.Smoothstep(t))
}
})
carried := field.NewLike(h)
for i, ref := range g.Ref {
if ref >= 0 {
carried.Data[i] = out.Data[ref]
carried.Data[i] = out[ref]
} else {
carried.Data[i] = float32(hi)
}
}
return boxMean(carried, int(shelfSmoothM/h.CellM+0.5), 2)
return boxMean(carried, int(shelfSmoothM/h.CellM+0.5), 2, g.WrapX)
}
// layShelf writes the sea floor: a gentle shelf out to the break, then the continental slope to the abyss.
@@ -339,10 +398,21 @@ func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
// land in every statistic downstream.
func layShelf(h *field.Field, g *Geometry, in Input, shelfW *field.Field) {
cfg := in.Cfg
// The lattice has to come back to itself at the seam, so on a cylinder the period is the planet's and not
// a multiple of the roughness wavelength. Without it the sea floor gains a metre-scale discontinuity down
// one meridian - small, and exactly the kind of thing nobody finds by looking at the middle of the map.
period := cfg.RoughWaveM * 256
u, v := noise.WorldUV(g.W, g.H, h.CellM, 0, 0, period)
rough := noise.FBMAt(u, v, noise.NewSource(in.Seed, srcShelf),
noise.Params{BaseCells: 256, Octaves: 3, Gain: 0.5})
if in.NoisePeriodM > 0 {
period = in.NoisePeriodM
}
cells := 256
if in.NoisePeriodM > 0 && cfg.RoughWaveM > 0 {
cells = int(period/cfg.RoughWaveM + 0.5)
if cells < 1 {
cells = 1
}
}
rough := shelfRoughness(g, in, period, cells)
exp := cfg.ShelfExponent
if exp <= 0 {
@@ -364,15 +434,24 @@ func layShelf(h *field.Field, g *Geometry, in Input, shelfW *field.Field) {
if width <= 0 {
width = cfg.ShelfKm.Hi() * 1000
}
// The open-ocean depth at *this* cell, so the derived slope arrives exactly where the ocean
// already is rather than at one global number it may be hundreds of metres from. And the
// break cannot be deeper than the water it is a break in: painted shallows - a 20 m surf
// class against a 30 m break - are shelf all the way out, with no slope to run down.
abyss := in.abyssAt(i)
brk := in.BreakM
if abyss < brk {
brk = abyss
}
var depth float64
if d < width {
depth = in.BreakM * math.Pow(d/width, exp)
depth = brk * math.Pow(d/width, exp)
} else {
t := (d - width) / slopeW
if t > 1 {
t = 1
}
depth = in.BreakM + (in.AbyssM-in.BreakM)*noise.Smoothstep(t)
depth = brk + (abyss-brk)*noise.Smoothstep(t)
}
taper := depth / 10
if taper > 1 {
@@ -385,6 +464,28 @@ func layShelf(h *field.Field, g *Geometry, in Input, shelfW *field.Field) {
})
}
// shelfRoughness is the noise on the sea floor, built in row bands.
//
// In bands because at planet scale the two coordinate fields and the result are three arrays of 76 million
// floats - 900 MB for a field whose amplitude is ten metres. The lattices are rebuilt from the same seeded
// source for every band, so the bands agree exactly where they meet; that is the same trick, for the same
// reason, as internal/planet's ocean roughness.
func shelfRoughness(g *Geometry, in Input, period float64, cells int) *field.Field {
out := field.New(g.W, g.H, g.CellM)
const bandRows = 512
params := noise.Params{BaseCells: cells, Octaves: 3, Gain: 0.5}
for y0 := 0; y0 < g.H; y0 += bandRows {
y1 := y0 + bandRows
if y1 > g.H {
y1 = g.H
}
u, v := noise.WorldUV(g.W, y1-y0, g.CellM, 0, float64(y0)*g.CellM, period)
band := noise.FBMAt(u, v, noise.NewSource(in.Seed, srcShelf), params)
copy(out.Data[y0*g.W:y1*g.W], band.Data)
}
return out
}
// fetch is how open the water is in front of each waterline cell: rays cast seaward until they hit land,
// weighted by the cosine of their angle from the shore normal, and averaged.
//
@@ -402,8 +503,8 @@ func layShelf(h *field.Field, g *Geometry, in Input, shelfW *field.Field) {
// sheltered lagoon. A percentile is also a global statistic, which rule 1 of the tiling plan rules out: two
// tiles would stretch by different anchors and their shared bay would be two different colours. So the
// anchors are fixed and physical, and the units are "fraction of the fetch range the rays got".
func fetch(g *Geometry, in Input) *field.Field {
out := field.New(g.W, g.H, g.CellM)
func fetch(g *Geometry, in Input) []float32 {
out := make([]float32, len(g.Waterline))
dirs := in.Cfg.FetchDirections
if dirs < 4 {
dirs = 4
@@ -424,8 +525,8 @@ func fetch(g *Geometry, in Input) *field.Field {
x0, y0 := i%g.W, i/g.W
// The seaward normal: the distance field increases inland, so its gradient points away from the
// water and the negative of it is the direction this stretch of shore faces.
nx := -float64(g.Dist.AtClamped(x0+1, y0) - g.Dist.AtClamped(x0-1, y0))
ny := -float64(g.Dist.AtClamped(x0, y0+1) - g.Dist.AtClamped(x0, y0-1))
nx := -(g.distAt(x0+1, y0) - g.distAt(x0-1, y0))
ny := -(g.distAt(x0, y0+1) - g.distAt(x0, y0-1))
if l := math.Hypot(nx, ny); l > 1e-6 {
nx, ny = nx/l, ny/l
} else {
@@ -441,10 +542,13 @@ func fetch(g *Geometry, in Input) *field.Field {
}
reach := maxSteps
for t := 1; t <= maxSteps; t++ {
px := x0 + int(math.Round(cs[k]*float64(t)))
px, ok := g.col(x0 + int(math.Round(cs[k]*float64(t))))
py := y0 + int(math.Round(sn[k]*float64(t)))
if px < 0 || py < 0 || px >= g.W || py >= g.H {
break // off the map is open water, and the mask keeps the border at sea
if !ok || py < 0 || py >= g.H {
// Off the map is open water, and the mask keeps the border at sea. On a cylinder a
// ray never runs off in X at all - it comes round - so this is the poles, where the
// synthetic polar ocean is genuinely open.
break
}
if !in.Sea[py*g.W+px] {
reach = t
@@ -464,20 +568,20 @@ func fetch(g *Geometry, in Input) *field.Field {
} else if t > 1 {
t = 1
}
out.Data[i] = float32(noise.Smoothstep(t))
out[n] = float32(noise.Smoothstep(t))
}
})
return out
}
// shorePercentiles reports the fetch distribution over the waterline itself, before it is carried anywhere.
func shorePercentiles(shore *field.Field, g *Geometry) (p10, p50, p90 float64) {
if len(g.Waterline) == 0 {
func shorePercentiles(shore []float32) (p10, p50, p90 float64) {
if len(shore) == 0 {
return 0, 0, 0
}
vals := make([]float64, 0, len(g.Waterline))
for _, i := range g.Waterline {
vals = append(vals, float64(shore.Data[i]))
vals := make([]float64, 0, len(shore))
for _, v := range shore {
vals = append(vals, float64(v))
}
sort.Float64s(vals)
at := func(f float64) float64 {
@@ -630,16 +734,35 @@ func deposit(h *field.Field, g *Geometry, exposure *field.Field, in Input, suppl
continue
}
shallow := (cfg.DepositDepthM - depth) / cfg.DepositDepthM
shelter := shelterFloor + (1-shelterFloor)*math.Pow(1-float64(exposure.Data[i]), cfg.ShelterBias)
// Clamped, and not defensively. `ShelterBias` is fractional, so `math.Pow` of a negative base is NaN
// - and one NaN here spreads through the drift kernel into every cell of the budget and comes out as
// a laid volume of NaN with no other symptom. Exposure is a smoothed field, so it is 0..1 only to
// within the rounding of however it was smoothed; relying on the smoother to bound it is relying on
// an invariant a hundred lines away. Found when the coverage became separable and the divisor changed
// from float32 to float64: the ratio went over 1 by five parts in a hundred thousand, and 1720 cells
// of a 200x40 test came out NaN.
e := float64(exposure.Data[i])
if e < 0 {
e = 0
} else if e > 1 {
e = 1
}
shelter := shelterFloor + (1-shelterFloor)*math.Pow(1-e, cfg.ShelterBias)
want.Data[i] = float32(shelter * shallow)
}
norm := boxBlur(want, radius, 3)
norm := boxBlur(want, radius, 3, g.WrapX)
// want is still needed below; norm and share are not, past the loops that read them. Dropping the
// references is what lets the collector reclaim 304 MB apiece at planet scale before the next one is
// allocated, rather than after.
// The supply is per waterline cell and the blur works on a grid, so it is scattered back onto the cells
// its stretches of shore sit at. Distinct slots are distinct cells, so nothing collides.
share := field.NewLike(h)
for i, v := range supply {
for slot, v := range supply {
if v <= 0 {
continue
}
i := int(g.Waterline[slot])
nb := float64(norm.Data[i])
if nb < 1e-9 {
unplaced += v // nowhere within a drift length will take it
@@ -647,7 +770,8 @@ func deposit(h *field.Field, g *Geometry, exposure *field.Field, in Input, suppl
}
share.Data[i] = float32(v / nb)
}
spread := boxBlur(share, radius, 3)
spread := boxBlur(share, radius, 3, g.WrapX)
share, norm = nil, nil
// place walks the grid in index order, which keeps the running totals deterministic: the writes are to
// distinct cells but the sums are not, so this one stays serial.
@@ -734,25 +858,65 @@ func shelfFraction(g *Geometry, in Input, shelfW *field.Field) float64 {
// width with the mass-preserving kernel shrank every shelf near the border to nothing and put the whole
// margin below the break. Blurring a field of ones with the same kernel gives exactly the coverage to divide
// by, so the two share their arithmetic and cannot drift apart.
func boxMean(f *field.Field, radius, passes int) *field.Field {
// The coverage is *separable*, which is what keeps this affordable at planet scale.
//
// Blurring a field of ones is the obvious way to get the divisor, and it was the first way: two more full
// fields plus a second boxBlur's two temporaries, which at 76 million cells is 1.2 GB for a quantity that
// depends on nothing but the distance to the edge. But the blur is a row pass and a column pass, and applying
// a 1-D operation to a field that is constant along the other axis leaves it constant along that axis - so
// the coverage factorises as cx(x)*cy(y) for every pass count, exactly. Two vectors of W and H entries say
// everything the field said.
func boxMean(f *field.Field, radius, passes int, wrapX bool) *field.Field {
if radius < 1 || passes < 1 {
return f.Clone()
}
ones := field.NewLike(f)
ones.Fill(1)
sum := boxBlur(f, radius, passes)
cover := boxBlur(ones, radius, passes)
out := field.NewLike(f)
for i := range out.Data {
if c := cover.Data[i]; c > 1e-6 {
out.Data[i] = sum.Data[i] / c
} else {
out.Data[i] = f.Data[i]
cx := boxCover(f.W, radius, passes, wrapX)
cy := boxCover(f.H, radius, passes, false) // Y never wraps: the top and bottom of a map are the poles
out := boxBlur(f, radius, passes, wrapX)
for y := 0; y < f.H; y++ {
row := y * f.W
for x := 0; x < f.W; x++ {
if c := cx[x] * cy[y]; c > 1e-6 {
out.Data[row+x] /= float32(c)
} else {
out.Data[row+x] = f.Data[row+x]
}
}
}
return out
}
// boxCover is what a line of ones comes back as after the same running-sum passes boxBlur applies: 1 in the
// middle and less than 1 within a kernel of each end, or 1 everywhere when the line wraps.
func boxCover(n, radius, passes int, wrap bool) []float64 {
cur := make([]float64, n)
for i := range cur {
cur[i] = 1
}
if wrap {
return cur // every cell has a full window; nothing runs off a cylinder
}
next := make([]float64, n)
inv := 1 / float64(2*radius+1)
for p := 0; p < passes; p++ {
var sum float64
for i := 0; i <= radius && i < n; i++ {
sum += cur[i]
}
for i := 0; i < n; i++ {
next[i] = sum * inv
if hi := i + radius + 1; hi < n {
sum += cur[hi]
}
if lo := i - radius; lo >= 0 {
sum -= cur[lo]
}
}
cur, next = next, cur
}
return cur
}
// boxBlur is a separable running-sum box blur: O(n) whatever the radius, which is what makes a 300 m drift
// kernel cost the same as a 30 m one.
//
@@ -762,7 +926,7 @@ func boxMean(f *field.Field, radius, passes int) *field.Field {
// neighbour's share of it — and dividing each output by its own truncated window size breaks that symmetry at
// the border, which cost 4 % of the sediment budget on a coast that ran off the edge of the map. Zero padding
// keeps K(i,j) = K(j,i) everywhere, and a cell outside the map has no want, so nothing is owed to it.
func boxBlur(f *field.Field, radius, passes int) *field.Field {
func boxBlur(f *field.Field, radius, passes int, wrapX bool) *field.Field {
cur := f.Clone()
if radius < 1 || passes < 1 {
return cur
@@ -773,6 +937,22 @@ func boxBlur(f *field.Field, radius, passes int) *field.Field {
field.Rows(f.H, func(y0, y1 int) {
for y := y0; y < y1; y++ {
row := y * f.W
if wrapX {
// On a cylinder every cell has a *full* window in X, so the running sum wraps instead of
// being truncated. That makes the row pass lossless rather than zero-padded, which the
// mass balance is happy with for the same reason it was happy before: the kernel stays
// symmetric, K(i,j) = K(j,i), and now nothing runs off the side at all.
var sum float64
for k := -radius; k <= radius; k++ {
sum += float64(cur.Data[row+wrapCol(k, f.W)])
}
for x := 0; x < f.W; x++ {
next.Data[row+x] = float32(sum * inv)
sum += float64(cur.Data[row+wrapCol(x+radius+1, f.W)])
sum -= float64(cur.Data[row+wrapCol(x-radius, f.W)])
}
continue
}
var sum float64
for x := 0; x <= radius && x < f.W; x++ {
sum += float64(cur.Data[row+x])
@@ -810,3 +990,7 @@ func boxBlur(f *field.Field, radius, passes int) *field.Field {
}
return cur
}
// wrapCol brings a column index onto a cylinder of width w. A free function rather than a Geometry method
// because boxBlur is handed a plain field and has no geometry to ask.
func wrapCol(x, w int) int { return ((x % w) + w) % w }
+231 -48
View File
@@ -8,52 +8,7 @@ import (
"salty/terrain/internal/manifest"
)
// TestEdtMatchesBruteForce is the one test the whole package rests on. Everything else is written in terms of
// "how far is this cell from the waterline and which stretch does it belong to", so a distance transform that
// is subtly wrong would not fail loudly, it would put the shelf break in slightly the wrong place everywhere.
// Felzenszwalb's transform is exact, so the comparison is against an exhaustive search and the tolerance is
// float32 rounding, not a percentage.
func TestEdtMatchesBruteForce(t *testing.T) {
const w, h = 41, 37
seed := uint32(99)
seeds := make([]bool, w*h)
for i := range seeds {
seed = seed*1664525 + 1013904223
seeds[i] = seed>>20&7 == 0
}
seeds[0] = true // guarantee at least one
d2, near := edt(seeds, w, h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
best := math.Inf(1)
for sy := 0; sy < h; sy++ {
for sx := 0; sx < w; sx++ {
if !seeds[sy*w+sx] {
continue
}
dx, dy := float64(x-sx), float64(y-sy)
if d := dx*dx + dy*dy; d < best {
best = d
}
}
}
i := y*w + x
if math.Abs(float64(d2[i])-best) > 1e-3 {
t.Fatalf("cell (%d,%d): d2 %g, brute force %g", x, y, d2[i], best)
}
// The feature index must be a seed, and it must be one at exactly that distance.
n := int(near[i])
if n < 0 || !seeds[n] {
t.Fatalf("cell (%d,%d): nearest %d is not a seed", x, y, n)
}
dx, dy := float64(x-n%w), float64(y-n/w)
if math.Abs(dx*dx+dy*dy-best) > 1e-3 {
t.Fatalf("cell (%d,%d): nearest seed %d is at %g, not %g", x, y, n, dx*dx+dy*dy, best)
}
}
}
}
// The exact distance transform this pass is built on is tested in internal/dt, where it now lives.
// TestSignedDistanceIsMetresEitherWay checks the sign convention and the unit on a straight coast, where the
// answer is arithmetic. The cells asked about are named explicitly: the map's own border is forced to sea by
@@ -312,7 +267,7 @@ func TestBoxBlurIsMassPreservingAndSymmetric(t *testing.T) {
before += float64(f.Data[y*64+x])
}
}
out := boxBlur(f, 5, 3)
out := boxBlur(f, 5, 3, false)
var after float64
for _, v := range out.Data {
after += float64(v)
@@ -323,7 +278,7 @@ func TestBoxBlurIsMassPreservingAndSymmetric(t *testing.T) {
one := field.New(64, 64, 1)
one.Data[32*64+32] = 1
k := boxBlur(one, 5, 3)
k := boxBlur(one, 5, 3, false)
for d := 1; d <= 16; d++ {
l, r := k.Data[32*64+32-d], k.Data[32*64+32+d]
if math.Abs(float64(l-r)) > 1e-7 {
@@ -349,3 +304,231 @@ func TestDisabledIsThePreCoastBehaviour(t *testing.T) {
}
}
}
// --- the cylinder ------------------------------------------------------------------------------------
//
// A planet is measured once, whole, so every march, every ray and every running sum in this pass has to cross
// the seam. The twins below are the flat-grid tests' questions asked again on a cylinder, and the shape of
// each one is the same: build a world, build the *same* world rotated half a turn, and require the answer to
// follow the ground rather than the grid. A pass that stops at column zero passes every flat test there is.
// rotate shifts a grid half a turn in X. On a cylinder that is not a change to the world at all, so anything
// this pass measures has to come out rotated with it and not otherwise different.
func rotate(f *field.Field, sea []bool, by int) (*field.Field, []bool) {
w, h := f.W, f.H
g := field.New(w, h, f.CellM)
s := make([]bool, len(sea))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
src := y*w + x
dst := y*w + (x+by)%w
g.Data[dst] = f.Data[src]
s[dst] = sea[src]
}
}
return g, s
}
// islandFixture is a round island on an otherwise open ocean, centred where the caller asks. Put the centre at
// x=0 and it straddles the seam.
func islandFixture(w, h, cx, cy, radius int, cellM, heightM float64) (*field.Field, []bool) {
f := field.New(w, h, cellM)
sea := make([]bool, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
dx := x - cx
if dx > w/2 {
dx -= w
} else if dx < -w/2 {
dx += w
}
dy := y - cy
if dx*dx+dy*dy <= radius*radius {
f.Data[i] = float32(heightM)
} else {
sea[i] = true
}
}
}
return f, sea
}
// The whole pass, twice, on the same island in two places. Everything it produces has to be the same world
// rotated - which is the one assertion that catches a march, a ray or a running sum stopping at the seam,
// because on a flat grid the two would differ and nobody would know which was right.
func TestTheWholePassIsRotationInvariantOnACylinder(t *testing.T) {
const w, h, r = 256, 96, 22
const cellM = 40.0
cfg := testCfg()
// Away from the seam.
a, aSea := islandFixture(w, h, w/2, h/2, r, cellM, 60)
ra := Build(Input{Height: a, Sea: aSea, SeaLevelM: 0, BreakM: 30, AbyssM: 180,
WrapX: true, Seed: 7, Cfg: cfg})
// The same island astride it, which is the same island.
b, bSea := islandFixture(w, h, 0, h/2, r, cellM, 60)
rb := Build(Input{Height: b, Sea: bSea, SeaLevelM: 0, BreakM: 30, AbyssM: 180,
WrapX: true, Seed: 7, Cfg: cfg})
want, _ := rotate(a, aSea, w/2) // a rotated to sit where b does
worst, at := 0.0, -1
for i := range want.Data {
if d := math.Abs(float64(want.Data[i] - b.Data[i])); d > worst {
worst, at = d, i
}
}
// Exactly zero when everything wraps, measured: the same island in two places is the same arithmetic in a
// different order, and the order happens not to matter here. The tolerance is set just under what each
// broken piece actually costs rather than at a comfortable round number - forcing the ray march flat gives
// 0.224 m, forcing the box blur flat gives 7.6e-5 m, and a tolerance loose enough to pass the second is a
// test that does not cover the running sums it claims to.
if worst > 2e-5 {
t.Errorf("the same island at the seam and away from it differ by %g m at cell %d (%d,%d); "+
"something in the pass stops at column zero", worst, at, at%w, at/w)
}
// And the accounting follows the ground too.
for _, c := range []struct {
name string
a, b float64
tolRel float64
}{
{"shoreline", ra.Stats.ShorelineKm, rb.Stats.ShorelineKm, 1e-9},
{"surf cut", ra.Stats.CutM3, rb.Stats.CutM3, 1e-3},
{"laid", ra.Stats.LaidM3, rb.Stats.LaidM3, 1e-3},
{"shelf share", ra.Stats.ShelfPctSea, rb.Stats.ShelfPctSea, 1e-6},
{"exposure p50", ra.Stats.ExposureP50, rb.Stats.ExposureP50, 1e-6},
} {
if c.a == 0 && c.b == 0 {
t.Errorf("%s is zero in both runs; this comparison measured nothing", c.name)
continue
}
if rel := math.Abs(c.a-c.b) / math.Max(math.Abs(c.a), 1e-12); rel > c.tolRel {
t.Errorf("%s: %.6g at the seam against %.6g away from it", c.name, c.b, c.a)
}
}
}
// The flat grid must not have changed. A cylinder is opt-in, and every template drawn before it existed was
// drawn against the old behaviour.
func TestAFlatGridIsUnchangedByTheCylinderOption(t *testing.T) {
const w, h, split = 200, 40, 120
f1, sea1 := coastFixture(w, h, split, 8, 5)
r1 := Build(Input{Height: f1, Sea: sea1, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: testCfg()})
// Land at both ends and water in the middle: on a flat grid the two coasts are unrelated, on a cylinder
// they are one landmass. The flat answer has to be the flat answer.
if r1.Geometry.WrapX {
t.Fatal("a caller that asked for nothing got a cylinder")
}
f2, sea2 := coastFixture(w, h, split, 8, 5)
r2 := Build(Input{Height: f2, Sea: sea2, SeaLevelM: 0, BreakM: 30, AbyssM: 180, WrapX: false,
Seed: 7, Cfg: testCfg()})
for i := range f1.Data {
if f1.Data[i] != f2.Data[i] {
t.Fatalf("cell %d differs between two flat runs", i)
}
}
_ = r2
}
// The drift kernel on a cylinder: still mass-preserving, still symmetric, and now symmetric *across the seam*
// as well. The deposition balance rests on K(i,j) = K(j,i), and a row pass that truncated at column zero
// would break it exactly where a coast crosses the meridian.
func TestBoxBlurWrapsWithoutLosingMass(t *testing.T) {
const w, h = 64, 64
f := field.New(w, h, 1)
// Support astride the seam, which on a flat grid would run off both ends.
var before float64
for y := 20; y < 44; y++ {
for _, x := range []int{w - 3, w - 2, w - 1, 0, 1, 2} {
f.Data[y*w+x] = 1
before++
}
}
out := boxBlur(f, 5, 3, true)
var after float64
for _, v := range out.Data {
after += float64(v)
}
if rel := math.Abs(after-before) / before; rel > 1e-4 {
t.Errorf("wrapping moved the total from %.4f to %.4f (%.4f%%)", before, after, rel*100)
}
// And the flat kernel would have lost some of it, which is what says this test measures the wrap.
flat := boxBlur(f, 5, 3, false)
var flatSum float64
for _, v := range flat.Data {
flatSum += float64(v)
}
if flatSum >= before*0.999 {
t.Error("the flat kernel kept everything too; move the support onto the seam")
}
one := field.New(w, h, 1)
one.Data[32*w+0] = 1 // a single grain exactly on the seam
k := boxBlur(one, 5, 3, true)
for d := 1; d <= 16; d++ {
l, r := k.Data[32*w+wrapCol(-d, w)], k.Data[32*w+wrapCol(d, w)]
if math.Abs(float64(l-r)) > 1e-7 {
t.Fatalf("the wrapped kernel is not symmetric at offset %d: %g against %g", d, l, r)
}
}
}
// A per-cell abyss is what lets a derived shelf meet a *painted* ocean floor. Without it the slope runs down
// to one global depth and steps to whatever the painting said, which on a planet whose sea classes carry
// 20, 120 and 512 m is a cliff at the shelf break in every strait.
func TestThePerCellAbyssIsWhereTheSlopeEnds(t *testing.T) {
// A tall coast, so the shelf comes out at its narrowest (600 m) and the 3.2 km of ocean has room for the
// 1.6 km of continental slope behind it. On a low coast the shelf is 3 km wide and the slope never
// finishes, which is correct behaviour and would read here as a failure.
const w, h, split = 700, 24, 400
const cellM = 8.0
f, sea := coastFixture(w, h, split, cellM, 400)
abyss := make([]float32, w*h)
for i := range abyss {
abyss[i] = 400 // deeper than the 180 m a global AbyssM would give
}
cfg := shelfOnlyCfg()
cfg.RoughnessM = 0
Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Abyss: abyss,
Seed: 7, Cfg: cfg})
// The far end of the ocean, well past shelf plus slope, has to be at the painted depth and not at AbyssM.
deepest := 0.0
for y := 0; y < h; y++ {
if d := -float64(f.Data[y*w+0]); d > deepest {
deepest = d
}
}
if math.Abs(deepest-400) > 1 {
t.Errorf("the sea floor bottoms out at %.1f m; the painted abyss is 400 m", deepest)
}
}
// The separable coverage has to be the field it replaced, exactly. It is an optimisation of a divisor, and an
// optimisation of a divisor that is only nearly right moves every smoothed value on the map.
func TestTheSeparableCoverageIsTheFieldItReplaced(t *testing.T) {
for _, wrapX := range []bool{false, true} {
for _, radius := range []int{1, 4, 11, 40, 97} { // including radii past the grid, where the coast pass really runs
for _, passes := range []int{1, 2, 3} {
const w, h = 37, 29
ones := field.New(w, h, 1)
ones.Fill(1)
want := boxBlur(ones, radius, passes, wrapX)
cx := boxCover(w, radius, passes, wrapX)
cy := boxCover(h, radius, passes, false)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
got := cx[x] * cy[y]
if d := math.Abs(got - float64(want.Data[y*w+x])); d > 1e-6 {
t.Fatalf("wrap=%v r=%d p=%d at (%d,%d): %.8f against the blurred field's %.8f",
wrapX, radius, passes, x, y, got, want.Data[y*w+x])
}
}
}
}
}
}
}
+126 -119
View File
@@ -3,6 +3,7 @@ package coast
import (
"math"
"salty/terrain/internal/dt"
"salty/terrain/internal/field"
)
@@ -16,113 +17,35 @@ import (
// whatever the radius, so there is nothing to buy by approximating, and a chamfer's 2 % anisotropy would show
// up directly as a shelf that is wider along the grid axes than across them.
// edt returns, for every cell, the squared distance in cells to the nearest seed cell and the index of that
// seed. A column pass finds the nearest seed in each column; a row pass takes the lower envelope of the
// parabolas those distances define.
//
// Cells in a column with no seed at all are given a cost above any real distance rather than an infinity, so
// the envelope arithmetic never sees a NaN; they are then never chosen unless the map has no seeds anywhere,
// which the caller checks for.
func edt(seed []bool, w, h int) (d2 []float32, near []int32) {
d2 = make([]float32, w*h)
near = make([]int32, w*h)
bigF := float64(w*w+h*h) * 4 // above any achievable dx² + dy²
bigD := float32(math.Sqrt(bigF))
colD := make([]float32, w*h) // distance in cells to the nearest seed in this column
colN := make([]int32, w*h) // that seed's row, or -1
field.Rows(w, func(x0, x1 int) {
for x := x0; x < x1; x++ {
best := -1
for y := 0; y < h; y++ {
i := y*w + x
if seed[i] {
best = y
}
if best < 0 {
colD[i], colN[i] = bigD, -1
} else {
colD[i], colN[i] = float32(y-best), int32(best)
}
}
best = -1
for y := h - 1; y >= 0; y-- {
i := y*w + x
if seed[i] {
best = y
}
if best >= 0 {
if d := float32(best - y); d < colD[i] {
colD[i], colN[i] = d, int32(best)
}
}
}
}
})
field.Rows(h, func(y0, y1 int) {
f := make([]float64, w)
v := make([]int, w)
z := make([]float64, w+1)
for y := y0; y < y1; y++ {
row := y * w
for x := 0; x < w; x++ {
d := float64(colD[row+x])
f[x] = d * d
}
k := 0
v[0] = 0
z[0] = math.Inf(-1)
z[1] = math.Inf(1)
for q := 1; q < w; q++ {
s := intersect(f, v[k], q)
for s <= z[k] {
k--
s = intersect(f, v[k], q)
}
k++
v[k] = q
z[k] = s
z[k+1] = math.Inf(1)
}
k = 0
for q := 0; q < w; q++ {
for z[k+1] < float64(q) {
k++
}
dx := float64(q - v[k])
d2[row+q] = float32(dx*dx + f[v[k]])
if n := colN[row+v[k]]; n < 0 {
near[row+q] = -1
} else {
near[row+q] = n*int32(w) + int32(v[k])
}
}
}
})
return d2, near
}
// intersect is where the parabolas rooted at p and q cross.
func intersect(f []float64, p, q int) float64 {
return ((f[q] + float64(q*q)) - (f[p] + float64(p*p))) / float64(2*q-2*p)
}
// The transform itself lives in internal/dt, because three unrelated things need it: this pass, the region
// partitioner that decides which landmasses are close enough to solve together, and the template classifier
// that dissolves an artist's decorative stroke into the nearest class that means something. It also knows
// how to wrap, which is what a planet needs and what wrapX below asks for.
// Geometry is the coastline as the rest of the pass sees it.
type Geometry struct {
W, H int
CellM float64
// WrapX is set when the grid is a cylinder: column W-1 and column 0 are neighbours, so the shoreline,
// the distance field and the perimeter all cross the seam.
WrapX bool
// Dist is metres to the waterline: positive inland, negative offshore.
Dist *field.Field
// Ref is, for every cell, the waterline cell whose stretch of shore it belongs to. A land cell takes the
// sea cell nearest to it, which is on the waterline by construction; a sea cell takes the waterline cell
// nearest to the land cell nearest to it, which is the stretch of shore facing it. Every per-shore
// quantity — shelter, shelf width, the backshore relief — is computed once on the waterline and read
// everywhere else through this.
// Ref is, for every cell, an index into Waterline: the stretch of shore that cell belongs to, or -1. A
// land cell takes the sea cell nearest to it, which is on the waterline by construction; a sea cell takes
// the waterline cell nearest to the land cell nearest to it, which is the stretch of shore facing it.
// Every per-shore quantity - shelter, shelf width, the sediment supply - is computed once per waterline
// cell and read everywhere else through this.
//
// **An index into Waterline rather than a cell index**, which is worth a sentence because it decides what
// the pass costs. There are tens of millions of cells and a few hundred thousand waterline cells, so a
// per-shore quantity indexed by *slot* is a couple of megabytes where one indexed by cell is hundreds:
// the sediment supply used to be a `[]float64` over the whole grid, 608 MB at planet scale for an array
// that is only ever read at the waterline. RefCell turns one back into the other where a cell is what is
// wanted.
Ref []int32
// Waterline is the sea cells that touch land, in row-major order so anything iterating them is
@@ -134,8 +57,17 @@ type Geometry struct {
ShoreM float64
}
// Measure builds the signed distance field and the shore reference from a land/sea mask.
// Measure builds the signed distance field and the shore reference from a land/sea mask on a flat grid.
func Measure(sea []bool, w, h int, cellM float64) *Geometry {
return MeasureWrapped(sea, w, h, cellM, false)
}
// MeasureWrapped is Measure with the option of a cylinder, where the left and right edges of the grid are
// neighbours. A planet is measured once, whole, rather than a landmass at a time: the pass costs tens of
// nanoseconds a cell, and cutting it up would truncate the fetch across every strait, split the sediment
// budget whose conservation is the one thing here that is not derived from something already measured, and
// leave the shoreline length and the exposure percentiles as statistics that do not pool.
func MeasureWrapped(sea []bool, w, h int, cellM float64, wrapX bool) *Geometry {
anySea, anyLand := false, false
land := make([]bool, len(sea))
for i, s := range sea {
@@ -146,7 +78,7 @@ func Measure(sea []bool, w, h int, cellM float64) *Geometry {
anyLand = true
}
}
g := &Geometry{W: w, H: h, CellM: cellM, Dist: field.New(w, h, cellM), Ref: make([]int32, w*h)}
g := &Geometry{W: w, H: h, CellM: cellM, WrapX: wrapX, Dist: field.New(w, h, cellM), Ref: make([]int32, w*h)}
for i := range g.Ref {
g.Ref[i] = -1
}
@@ -154,33 +86,50 @@ func Measure(sea []bool, w, h int, cellM float64) *Geometry {
return g // an all-land or all-sea map has no coast; every pass below is a no-op on it
}
d2Sea, nearSea := edt(sea, w, h) // for a land cell: how far to water, and where
d2Land, nearLand := edt(land, w, h) // for a sea cell: how far to land, and where
for i := range sea {
if sea[i] {
g.Dist.Data[i] = float32(-math.Sqrt(float64(d2Land[i])) * cellM)
} else {
g.Dist.Data[i] = float32(math.Sqrt(float64(d2Sea[i])) * cellM)
}
}
// The waterline: sea cells with land in the eight-neighbourhood, which is d2Land of 1 or 2.
for i := range sea {
if sea[i] && d2Land[i] <= 2.001 {
// The waterline first, and straight off the mask rather than out of a transform. It is "a sea cell with
// land in its eight-neighbourhood", which is a local question, and asking it here rather than reading it
// out of d2Land is what lets the two transforms below be released in turn instead of held together.
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if !sea[i] || !touchesLand(sea, w, h, x, y, wrapX) {
continue
}
g.Waterline = append(g.Waterline, int32(i))
}
}
// Land first: how far to water, and which waterline stretch that is.
//
// The two transforms are never both alive. At planet scale each one is a distance array and a feature
// index over 76 million cells - 600 MB the pair - and holding all four at once was 1.2 GB on top of the
// 600 MB this function returns. The order below is what avoids it, and it needs one observation: a sea
// cell's stretch of shore is the stretch its *nearest land cell* already belongs to, so the second pass
// can read the answer out of Ref rather than out of the first pass's feature index.
d2Sea, nearSea := dt.Transform(sea, w, h, wrapX)
for i := range sea {
if sea[i] {
if l := nearLand[i]; l >= 0 {
g.Ref[i] = nearSea[l]
}
} else {
g.Ref[i] = nearSea[i]
continue
}
g.Dist.Data[i] = float32(math.Sqrt(float64(d2Sea[i])) * cellM)
if n := nearSea[i]; n >= 0 {
g.Ref[i] = slotOf(g.Waterline, n)
}
}
d2Sea, nearSea = nil, nil
// Then sea: how far to land, and the shore that land already answered for.
d2Land, nearLand := dt.Transform(land, w, h, wrapX)
for i := range sea {
if !sea[i] {
continue
}
g.Dist.Data[i] = float32(-math.Sqrt(float64(d2Land[i])) * cellM)
if l := nearLand[i]; l >= 0 {
g.Ref[i] = g.Ref[l]
}
}
d2Land, nearLand = nil, nil
// Perimeter by boundary edges, which is what a shoreline length means on a grid.
edges := 0
@@ -189,6 +138,8 @@ func Measure(sea []bool, w, h int, cellM float64) *Geometry {
i := y*w + x
if x+1 < w && sea[i] != sea[i+1] {
edges++
} else if x+1 == w && wrapX && sea[i] != sea[y*w] {
edges++
}
if y+1 < h && sea[i] != sea[i+w] {
edges++
@@ -198,3 +149,59 @@ func Measure(sea []bool, w, h int, cellM float64) *Geometry {
g.ShoreM = float64(edges) * cellM
return g
}
// RefCell is the cell index of the waterline stretch a cell belongs to, or -1. Ref itself is a slot; this is
// for the few places that want the cell.
func (g *Geometry) RefCell(i int) int32 {
if r := g.Ref[i]; r >= 0 {
return g.Waterline[r]
}
return -1
}
// touchesLand reports whether a cell has land in its eight-neighbourhood: X wrapped on a cylinder, Y bounded,
// because the top and bottom of the map are the poles and not each other.
func touchesLand(sea []bool, w, h, x, y int, wrapX bool) bool {
for dy := -1; dy <= 1; dy++ {
ny := y + dy
if ny < 0 || ny >= h {
continue
}
for dx := -1; dx <= 1; dx++ {
if dx == 0 && dy == 0 {
continue
}
nx := x + dx
if wrapX {
nx = ((nx % w) + w) % w
} else if nx < 0 || nx >= w {
continue
}
if !sea[ny*w+nx] {
return true
}
}
}
return false
}
// slotOf finds a cell's index in the waterline, or -1.
//
// A binary search rather than a cell-indexed lookup table, which would be another four bytes a cell - 300 MB
// at planet scale for an array read once. The waterline is built in row-major order and is therefore sorted,
// so the search is eighteen comparisons against a few hundred thousand entries and runs only on land cells.
func slotOf(waterline []int32, cell int32) int32 {
lo, hi := 0, len(waterline)
for lo < hi {
mid := int(uint(lo+hi) >> 1)
if waterline[mid] < cell {
lo = mid + 1
} else {
hi = mid
}
}
if lo < len(waterline) && waterline[lo] == cell {
return int32(lo)
}
return -1
}
@@ -0,0 +1,104 @@
package coast
import (
"testing"
"salty/terrain/internal/field"
)
// paintedSeaFixture is a straight coast whose whole sea is painted at one depth, the way a planet's ocean
// class is. The sea is made wide enough to hold the derived margin and a stretch of open water past it, so
// the test can ask the question that matters: how much of what the author painted survives.
func paintedSeaFixture(w, h, split int, cellM, backshoreM, paintedM float64) (*field.Field, []bool, []float32) {
f, sea := coastFixture(w, h, split, cellM, backshoreM)
abyss := make([]float32, w*h)
for i := range abyss {
if sea[i] {
abyss[i] = float32(paintedM)
}
}
return f, sea, abyss
}
// TestTheDerivedMarginDoesNotSwallowThePaintedOcean is the D-64 regression, stated as the property rather
// than as the number that was wrong.
//
// The sea floor near a shore is derived and the sea floor away from it is the painting; the break depth is
// what joins them. Set the break far shallower than the paint and the join stops being a join: the derived
// profile is then a shallow bench that runs from the waterline out to the full reach of the margin, and on a
// planet whose straits are narrower than twice that reach it *is* the ocean. That is not visible in a profile
// test - the shape is monotone and correct at any break depth - so this measures the volume instead.
//
// 512 m is the first template's `ocean` class. 30 m was the inherited square-canvas break, 130 m is the
// planet default.
func TestTheDerivedMarginDoesNotSwallowThePaintedOcean(t *testing.T) {
const w, h, split = 1400, 20, 1200
const cellM, painted = 8.0, 512.0
cfg := shelfOnlyCfg()
measure := func(breakM float64) (shallow float64, atBreak, far float64) {
f, sea, abyss := paintedSeaFixture(w, h, split, cellM, 5, painted)
Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: breakM, AbyssM: painted,
Abyss: abyss, Seed: 7, Cfg: cfg})
y := h / 2
n, under50 := 0, 0
for x := 0; x < split; x++ {
n++
if -float64(f.Data[y*w+x]) < 50 {
under50++
}
}
// Just inside the widest shelf, and well past the shelf and the slope together.
shelfCells := int(cfg.ShelfKm.Hi()*1000/cellM) - 2
reachCells := int((cfg.ShelfKm.Hi() + cfg.SlopeKm) * 1000 / cellM)
return float64(under50) / float64(n),
-float64(f.Data[y*w+split-1-shelfCells]),
-float64(f.Data[y*w+split-1-reachCells-20])
}
oldShallow, oldBreak, oldFar := measure(30)
newShallow, newBreak, newFar := measure(130)
t.Logf("break 30 m: %.0f%% of this sea shallower than 50 m, %.0f m at the break, %.0f m offshore",
oldShallow*100, oldBreak, oldFar)
t.Logf("break 130 m: %.0f%% of this sea shallower than 50 m, %.0f m at the break, %.0f m offshore",
newShallow*100, newBreak, newFar)
// Both must reach the painting in open water: the margin is a join, never a replacement.
for _, c := range []struct {
name string
far float64
}{{"30 m", oldFar}, {"130 m", newFar}} {
if c.far < painted-2 {
t.Errorf("break %s: open water is %.0f m, want the painted %.0f m", c.name, c.far, painted)
}
}
// The break is where it was asked for, which is what makes it the knob worth having.
if newBreak < 110 || newBreak > 140 {
t.Errorf("the shelf break is at %.0f m, want about 130 m", newBreak)
}
// And the shallow bench shrinks. This is the whole defect: at a 30 m break every cell of the derived
// margin is shallower than 50 m by construction, so the bench is as wide as the margin reaches.
if !(newShallow < oldShallow*0.75) {
t.Errorf("shallow water is %.0f%% of the sea at a 130 m break against %.0f%% at 30 m; deepening the "+
"break has to shrink the bench or it is not doing anything", newShallow*100, oldShallow*100)
}
}
// TestAPaintedShallowStraitIsStillShallow is the other half, and it is what stops the fix above from being a
// blunt instrument: the break can never be deeper than the water it is a break in. An author who paints a
// 20 m surf class gets 20 m of water, not a 130 m trench dug through it.
func TestAPaintedShallowStraitIsStillShallow(t *testing.T) {
const w, h, split = 1400, 20, 1200
const cellM, painted = 8.0, 20.0
f, sea, abyss := paintedSeaFixture(w, h, split, cellM, 5, painted)
Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 130, AbyssM: painted,
Abyss: abyss, Seed: 7, Cfg: shelfOnlyCfg()})
y := h / 2
for x := 0; x < split; x++ {
if d := -float64(f.Data[y*w+x]); d > painted+1 {
t.Fatalf("%.0f m offshore: %.1f m of water over a sea painted at %.0f m",
float64(split-x)*cellM, d, painted)
}
}
}
+50
View File
@@ -0,0 +1,50 @@
package detail
// Classes is what the painted class asks of the detail passes, per cell, already blended.
//
// It exists because two classes can have the same uplift rate and the same erodibility - which is everything
// the geology grid knows about them - and still be completely different ground. A desert and a wet lowland
// are both "low, slowly rising"; what separates them is at two metres, in how much running water crosses
// them, how sharp their ledges stay and how much of them is dune.
//
// **Four fields rather than a class index and a lookup table**, which is what this was. The index is the
// right thing to carry - a class is a name, and a name is never interpolated - but the *numbers* it stands
// for are quantities, and quantities interpolate. Kept as a lookup, a desert meeting a lowland changed from
// seven metres of dune amplitude to two in the width of one cell, along a line the painter drew with a mouse,
// and it read exactly as what it was: a boundary in a picture rather than a change in the ground. Blended,
// the same boundary is a few hundred metres of one becoming the other, which is what the edge of a sand sea
// looks like from the ground.
//
// The blending happens where the fields are built (see planet.blendedClasses), because that is where the
// class raster and the tile's margin both are; by the time a pass reads one it is just a number per cell.
//
// Nil means every cell uses the pipeline's own numbers, which is what happens on a template whose legend
// overrides nothing.
type Classes struct {
Droplets []float32 // per cell: droplets a cell spawns
AmpLo []float32 // per cell: detail noise amplitude on flat ground
AmpHi []float32 // per cell: and on steep ground
Contrast []float32 // per cell: strata hardness contrast
}
// droplets, amp and contrast read a cell, falling back to the uniform value when there is no table.
func (c *Classes) droplets(i int, def float64) float64 {
if c == nil || c.Droplets == nil {
return def
}
return float64(c.Droplets[i])
}
func (c *Classes) amp(i int, defLo, defHi float64) (float64, float64) {
if c == nil || c.AmpLo == nil {
return defLo, defHi
}
return float64(c.AmpLo[i]), float64(c.AmpHi[i])
}
func (c *Classes) contrast(i int, def float64) float64 {
if c == nil || c.Contrast == nil {
return def
}
return float64(c.Contrast[i])
}
+720
View File
@@ -0,0 +1,720 @@
package detail
import (
"math"
"sort"
"salty/terrain/internal/dt"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
// Pass 11b: the shore at two metres.
//
// The coastal pass on the geology grid (internal/coast) decides where the shore *is*: it lays the shelf,
// planes a platform within a reach of the waterline, leaves a cliff where that reach ends, and carries the
// sediment it cut along the shore into the bays. All of that is right and almost none of it is visible,
// because the surf reach is 110 m and a geology cell is 8: a beach is fourteen cells wide, a berm is a
// quarter of one cell high, and a wave-cut notch is a fifth of one.
//
// The surf reach is the only length in the generator set by physics rather than by the canvas - it is how far
// a wave runs up, and a wave does not know how big the map is - so it does not shrink when the cell does. At
// the 2 m detail cell the same 110 m is 55 cells, which is enough to hold a real profile. That is the whole
// argument for this being a pass of its own rather than a knob on the one above.
//
// Everything here is measured against that reach and against the exposure the geology pass computed, so the
// two cannot disagree about where the shore is: this pass re-evaluates the same
// reach = SurfReachM * (0.35 + 0.65*exposure) that plane() used, and draws the profile the geology grid was
// too coarse to hold.
//
// It is local, which is what lets it run per tile: nothing here reads or writes further from the waterline
// than two surf reaches, which is 220 m against a tile margin of 244. Measured rather than reasoned - the
// pass reaches 110 to 136 m on the fixtures in TestThePassFitsInsideTheTileMargin - but the 220 is a hard
// limit rather than a measurement, because past it a cell has no stretch of shore to belong to at all.
// CoastalParams is pass 11b's input.
type CoastalParams struct {
Cfg manifest.CoastDetail
Surf manifest.Coast // the geology pass's own numbers: the reach and the platform grade come from it
Seed int64
Frame world.Frame
PeriodM float64 // the detail noise period, for the crenulation lattice
SeaLevelM float64
// Exposure is the geology pass's fetch field sampled onto this tile, 0 sheltered to 1 open water.
//
// It cannot be computed here and must not be: fetch is cast fifteen hundred metres in sixteen directions
// and a tile is five kilometres across, so a tile has no way of knowing whether the water in front of it
// is a bay or an ocean. It is exactly the quantity D-53's rule says has to come from the pass that ran
// over the whole cylinder. Nil means the bake predates the field, and then every coast is treated as
// fully exposed - which is what the geology pass's own percentiles say most coast is anyway.
Exposure []float32
Hardness *Hardness
}
// CoastalStats is what the pass moved, for the tile record. The cliff branch conserves: what it cuts off the
// face it lays at the foot, per stretch of shore, and ScreeM3 is reported beside CutM3 so a run where the two
// have drifted apart says so rather than quietly losing rock.
type CoastalStats struct {
ShoreCells int `json:"shore_cells"`
CliffFrac float64 `json:"cliff_fraction"`
CutM3 float64 `json:"cliff_cut_m3"`
ScreeM3 float64 `json:"scree_laid_m3"`
BeachM3 float64 `json:"beach_net_m3"`
// How high the land stands behind this tile's shore, over its waterline cells. It is the input the
// beach-or-cliff decision is made from, so it is reported rather than left to be inferred from the
// fraction: a run with no cliffs anywhere is either a coast with no cliffs on it or a threshold in the
// wrong place, and these two numbers are the only thing that tells the two apart.
BackshoreP50M float64 `json:"backshore_p50_m"`
BackshoreP90M float64 `json:"backshore_p90_m"`
}
// coastalTaper is how far past the surf reach the profile fades out, as a fraction of the reach. The taper
// exists so the pass hands back to the droplets rather than ending in a line across the ground.
const coastalTaper = 0.5
// beachFace is the slope of the swash face of a sand beach, which is what sets where the berm crest sits: a
// berm bh metres high has its crest bh/beachFace metres inland. 1:10 is the ordinary figure for medium sand,
// and it is the one number here that is a property of the sediment rather than of the wave.
const beachFace = 0.1
// RunCoastal cuts the shore profile. Height is modified in place; land is the detail land mask as the passes
// above left it and is not updated - the waterline this pass works from is the one they agreed on.
func RunCoastal(h *field.Field, land []bool, p CoastalParams) CoastalStats {
var st CoastalStats
cfg := p.Cfg
if !cfg.Enabled {
return st
}
reachMax := p.Surf.SurfReachM
if reachMax <= 0 {
return st
}
w, ht := h.W, h.H
cellM := h.CellM
// The shoreline, which is not the land mask's boundary.
//
// On a coastal plain the ground crosses sea level at a grade of about one in a hundred, so whether a cell
// is land is decided by centimetres over a strip forty metres wide and the mask's boundary is a band of
// speckle rather than a curve. Everything this pass does is measured from that boundary, and measuring
// from speckle went wrong twice: it put a separate two-metre berm on every island in the band, and - less
// visibly and worse - it wrecked the backshore, because a cell two hundred metres inland had its nearest
// waterline cell in a puddle beside it rather than out at the coast, so the real shore was left measuring
// the height of the land behind almost nothing.
//
// So the shoreline is derived: the signed distance to the raw boundary, smoothed, thresholded back. That
// is a curve, it is within a few metres of the mask's own boundary, and everything below is measured from
// it. Taking the waterline on the land side of it is a half-cell choice, recorded rather than hidden.
rough := boundaryOf(land, w, ht)
sd := signedDistance(rough, land, w, ht, cellM)
smoothShore(sd, w, ht, int(cfg.ShoreSmoothM/cellM+0.5))
wet := make([]bool, len(sd))
for i, v := range sd {
wet[i] = v > 0
}
line := boundaryOf(wet, w, ht)
shore := make([]int32, 0, 4096)
for i, on := range line {
if on {
shore = append(shore, int32(i))
}
}
if len(shore) == 0 {
return st
}
st.ShoreCells = len(shore)
// One transform, seeded on the waterline itself, answers both halves of every question this pass asks:
// how far a cell is from the shore, and which stretch of shore it belongs to. The geology pass needs two
// because it wants the sea side and the land side to answer different things; here they answer the same.
//
// wrapX is false and has to be: a tile is a rectangle cut out of the cylinder with a margin on it, and
// the seam is the tiling's business rather than the pass's. A tile that wrapped its own left edge onto
// its own right would be inventing a shore.
d2, near := dt.Transform(line, w, ht, false)
// Per stretch of shore: how open it is, how far the surf reaches, how high the land behind it stands, and
// how far the whole profile is displaced in or out. Indexed by slot rather than by cell, which is the
// same economy the geology pass keeps - a tile has millions of cells and thousands of shore cells.
n := len(shore)
expo := make([]float64, n)
reach := make([]float64, n)
cren := make([]float64, n)
crenNoise := p.crenulation(h)
for s, ci := range shore {
e := 1.0
if p.Exposure != nil {
e = float64(p.Exposure[ci])
if e < 0 {
e = 0
} else if e > 1 {
e = 1
}
}
expo[s] = e
reach[s] = reachMax * (0.35 + 0.65*e)
if crenNoise != nil {
cren[s] = cfg.CrenulationM * (2*float64(crenNoise.Data[ci]) - 1)
}
}
// The signed distance to that shoreline, which needs no smoothing of its own: the curve it is measured
// from is already smooth.
dist := make([]float32, len(d2))
for i := range d2 {
dm := math.Sqrt(float64(d2[i])) * cellM
if wet[i] {
dist[i] = float32(dm)
} else {
dist[i] = float32(-dm)
}
}
// Which stretch of shore each cell belongs to.
slot := make([]int32, len(d2))
// Two surf reaches is the outer limit of the whole pass, on both sides, and it is a limit rather than a
// consequence: it is the window the backshore is measured in, so it is the furthest any cell has a stretch
// of shore to belong to at all, and it is what makes the margin claim one number. 220 m at the default
// reach, against a tile margin of 244.
backOuter := 2 * reachMax
for i := range d2 {
dm := math.Sqrt(float64(d2[i])) * cellM
slot[i] = -1
if dm > backOuter || near[i] < 0 {
continue
}
if s := slotOf(shore, near[i]); s >= 0 {
slot[i] = int32(s)
}
}
back := marchBackshore(h, dist, wet, shore, reach, p.SeaLevelM)
cliff := make([]float64, n)
for s := range back {
cliff[s] = cliffiness(back[s], cfg.CliffFromM, cfg.CliffToM)
st.CliffFrac += cliff[s]
}
st.CliffFrac /= float64(n)
st.BackshoreP50M, st.BackshoreP90M = percentiles(back)
// The roughness fade, before the profile is drawn on top of it.
//
// The profile is only a few tens of metres wide, so on its own the ground goes from a drawn beach to full
// dune amplitude and droplet rills within the width of its taper, and the beach reads as a ribbon laid on
// the terrain rather than as part of it. This blends the surface towards a smoothed copy of itself over a
// wider band: the relief is untouched - the smoothing radius is metres, not tens of them - and what fades
// is the metre-scale texture, so the backshore comes out smoother than the hillside behind it. Which is
// what a backshore is: sand and dune over whatever the hillside is made of.
smoothShoreRoughness(h, dist, wet, reachMax, cfg.SmoothReachM)
// The profile. Two targets blended by how high the land behind stands, and the result blended into the
// surface by how far the cell is from the shore, so the pass fades out rather than ending in a line.
cut := make([]float64, n)
for i := range dist {
s := slot[i]
if s < 0 {
continue
}
x := float64(dist[i]) - cren[s]
r := reach[s]
now := float64(h.Data[i])
bh := p.Surf.BermM * (0.35 + 0.65*expo[s])
// The two branches carry their own reach as well as their own shape, which the first version of this
// did not: a beach is over within a few tens of metres of the water, and holding its berm out to the
// full surf reach cut a ninety-metre terrace into the land behind every beach on the map.
crest := bh / beachFace
face := math.Min(back[s], cfg.CliffMaxM)
wb := branchWeight(x, crest, math.Min(crest+cfg.BermBackM, backOuter), r*0.5, math.Min(r, backOuter))
wc := branchWeight(x, r,
math.Min(r+face/max64(cfg.CliffGrade, 1e-3), backOuter),
r*0.5, math.Min(r*(1+coastalTaper), backOuter))
if wb <= 0 && wc <= 0 {
continue
}
// A beach is a veneer of sediment, not a landform that fills a fjord. Without the cap the equilibrium
// profile is a *target depth*, so a shore with forty metres of water a hundred metres off it - a
// drowned valley, which is an ordinary thing on a real coast - gets thirty-seven metres of sand
// invented to bring the floor up to the curve. Capped, the beach is a few metres of sediment laid on
// whatever is there, and where the water is deep it simply runs out. That is what a steep-to shore is.
tb := beachTarget(x, bh, cfg.DeanA, p.SeaLevelM)
if fill := now + cfg.BeachFillM; tb > fill {
tb = fill
}
tc := cliffTarget(x, r, face, p.Surf.PlatformGrade, cfg.CliffGrade, p.SeaLevelM)
// The platform is rock, and rock does not plane flat: hard bands stand out as ledges and reefs and
// soft ones cut down into runnels. It goes into the cliff target *before* the clamp below, which is
// the difference between a ledge and a wall built out of the sea: a band that resisted is rock the
// surf did not take, so it is still below where the ground started.
if p.Hardness != nil && cfg.PlatformReliefM > 0 {
if win := platformWindow(x, r); win > 0 {
hard := p.Hardness.At(i, now/cellM)
tc += cfg.PlatformReliefM * (2*hard - 1) * win
}
}
// The cliff branch never builds, on either side of the waterline. A shore platform and the face above
// it are what is left after the sea took rock away, so a target above the ground is the pass
// proposing to invent a headland, and the honest answer to that is to leave the ground where it is.
// It is also what keeps the platform from being laid out across deep water: it planes what is
// shallower than it and passes over what is not.
if tc > now {
tc = now
}
dCliff := cliff[s] * wc * (tc - now) // never positive, by the clamp above
dBeach := (1 - cliff[s]) * wb * (tb - now)
h.Data[i] = float32(now + dCliff + dBeach)
cut[s] -= dCliff
st.BeachM3 += dBeach
}
area := cellM * cellM
for _, c := range cut {
st.CutM3 += c * area
}
st.BeachM3 *= area
st.ScreeM3 = layScree(h, dist, shore, reach, cut, cfg, area)
return st
}
// cliffiness is how much of a cliff a stretch of shore is: 0 where the land behind it is at beach height, 1
// where it stands a cliff's worth above the water, smooth in between so the two profiles do not switch over
// from one shore cell to the next.
func cliffiness(backM, from, to float64) float64 {
if to <= from {
if backM >= to {
return 1
}
return 0
}
t := (backM - from) / (to - from)
if t <= 0 {
return 0
}
if t >= 1 {
return 1
}
return noise.Smoothstep(t)
}
// beachTarget is the equilibrium beach: a swash face rising to a berm crest above water, and Dean's profile
// below it.
//
// depth = A * x^(2/3) is the standard equilibrium profile, and A is a property of the sand rather than of the
// wave - it is the shape a beach returns to whatever the last storm did to it, which is exactly the right
// thing for a generator to draw, because what a generator has is the long-run average and never the storm.
// The berm is the other half: its crest sits at the wave runup limit, runup scales with wave height and wave
// height with fetch, so a berm on an exposed coast stands higher than one at the back of a bay. That is why
// the crest height arrives already scaled by exposure.
func beachTarget(x, bermM, deanA, seaLevelM float64) float64 {
if x >= 0 {
crest := bermM / beachFace
if crest <= 0 {
return seaLevelM
}
if x >= crest {
return seaLevelM + bermM
}
return seaLevelM + bermM*x/crest
}
return seaLevelM - deanA*math.Pow(-x, 2.0/3.0)
}
// cliffTarget is a shore platform out to the foot and a face above it, up to faceM high.
//
// faceM is capped rather than being the backshore itself, and the cap is what stops the pass carving a
// seventy-degree wall four hundred metres up a coastal range: the only other thing that stops the face is the
// ground rising faster than it does, and ground behind a mountain coast does. A sea cliff is what the surf
// undercut; above that height the face is a hillslope and it belongs to the solve.
//
// The foot is at the surf reach, which is not a choice: it is where plane() stopped cutting on the geology
// grid, so the cliff is already there and already in the right place. What this does is give it a *face*. At
// 8 m the step from the platform to the backshore is one cell, and upsampled by four it is a four-cell ramp
// at whatever angle the interpolation chose; at 2 m the same height can stand at the angle a cliff stands at.
//
// Seaward of the waterline the platform simply continues at its own grade, which is what a shore platform
// does - it is cut across the intertidal and runs on a little way below low water before the sea floor takes
// over.
func cliffTarget(x, reachM, faceM, platformGrade, cliffGrade, seaLevelM float64) float64 {
if x < 0 {
return seaLevelM - platformGrade*(-x)
}
if x <= reachM {
return seaLevelM + platformGrade*x
}
foot := seaLevelM + platformGrade*reachM
t := foot + cliffGrade*(x-reachM)
if top := seaLevelM + faceM; t > top {
return top
}
return t
}
// branchWeight is how much of a branch's target a cell takes: all of it inside that branch's core, and
// smoothstepping to none at its outer limit, so the pass hands back to the droplets and the noise instead of
// ending in a line across the ground.
func branchWeight(x, coreLand, outLand, coreSea, outSea float64) float64 {
if x >= 0 {
return taperTo(x, coreLand, outLand)
}
return taperTo(-x, coreSea, outSea)
}
func taperTo(d, core, out float64) float64 {
if d <= core {
return 1
}
if d >= out || out <= core {
return 0
}
return noise.Smoothstep((out - d) / (out - core))
}
// platformWindow fades the strata relief in across the shore platform and out at both ends of it: nothing at
// the foot of the cliff, where the face takes over, and nothing where the platform runs out under water.
//
// It reaches seaward as well as inland, because a shore platform does: it is cut across the intertidal and
// carries on a little below low water, and that submerged half is where the ledges and the reefs are.
func platformWindow(x, reachM float64) float64 {
if reachM <= 0 {
return 0
}
lo, hi := -reachM*0.5, reachM
if x <= lo || x >= hi {
return 0
}
t := (x - lo) / (hi - lo)
return noise.Smoothstep(math.Min(t*4, 1)) * noise.Smoothstep(math.Min((1-t)*4, 1))
}
// layScree puts back what the face lost, at the foot, at the angle of repose.
//
// The cliff branch only ever cuts, so it has a volume to account for, and a cliff that shed its face into
// nothing would be the one place in this generator where rock disappears. It goes where it goes on a real
// coast: an apron at the foot, thickest against the face and thinning seaward, at the angle blocky debris
// stands at. The volume is matched per stretch of shore rather than per tile, so the apron under a cliff is
// the apron that cliff produced.
//
// Marched along the shore normal, for the same reason marchBackshore is: a stretch of shore inside a bay owns
// no cells at all a hundred metres out, because the nearest-shore wedges converge there, so an apron scattered
// over those cells simply had nowhere to go. Measured on region 11 before the change, the aprons gained 2085
// of the 3030 cubic metres the faces lost and the rest was silently dropped. A march has a line of cells to
// put it on whatever the coast does, and the normalisation is the same one: a stretch of shore owns a strip
// one cell wide, so a scattered wedge and a marched line cover the same area on a straight coast and agree.
func layScree(h *field.Field, dist []float32, shore []int32, reach, cut []float64,
cfg manifest.CoastDetail, area float64) float64 {
if cfg.ScreeDeg <= 0 || cfg.ScreeReachM <= 0 {
return 0
}
w, ht := h.W, h.H
cellM := h.CellM
at := func(x, y int) float64 {
if x < 0 {
x = 0
} else if x >= w {
x = w - 1
}
if y < 0 {
y = 0
} else if y >= ht {
y = ht - 1
}
return float64(dist[y*w+x])
}
var laid float64
var line [128]int32
var wgt [128]float64
for s, ci := range shore {
if cut[s] <= 0 {
continue
}
x, y := int(ci)%w, int(ci)/w
dx := at(x+1, y) - at(x-1, y)
dy := at(x, y+1) - at(x, y-1)
l := math.Hypot(dx, dy)
if l < 1e-9 {
continue
}
dx, dy = dx/l, dy/l
lo := int((reach[s]-cfg.ScreeReachM)/cellM + 0.5)
hi := int(reach[s]/cellM + 0.5)
if lo < 0 {
lo = 0
}
nsteps, total := 0, 0.0
for t := lo; t <= hi && nsteps < len(line); t++ {
px := x + int(math.Round(dx*float64(t)))
py := y + int(math.Round(dy*float64(t)))
if px < 0 || px >= w || py < 0 || py >= ht {
break
}
v := screeWedge(float64(t)*cellM, reach[s], cfg.ScreeReachM)
if v <= 0 {
continue
}
line[nsteps], wgt[nsteps] = int32(py*w+px), v
total += v
nsteps++
}
if total <= 0 {
continue
}
for k := 0; k < nsteps; k++ {
add := cut[s] * wgt[k] / total
h.Data[line[k]] += float32(add)
laid += add
}
}
return laid * area
}
// crenulation is the noise that moves the whole profile in and out along the shore.
//
// It is applied to the *distance* rather than to the height, which is what makes it a crenulate coastline
// rather than a rough one: the profile stays a profile and the shoreline wanders. And it is read at the
// nearest waterline cell rather than at the cell being written, so it varies along the shore and not across
// it - read per cell, a two-dimensional noise field would ripple the profile in the cross-shore direction
// too, and a beach with corrugations up its face is not a beach.
func (p CoastalParams) crenulation(h *field.Field) *field.Field {
if p.Cfg.CrenulationM <= 0 || p.Cfg.CrenulationWaveM <= 0 || p.PeriodM <= 0 {
return nil
}
f := p.Frame
u, v := noise.WorldUV(f.W, f.H, h.CellM, f.OriginXM(), f.OriginYM(), p.PeriodM)
base := int(p.PeriodM/p.Cfg.CrenulationWaveM + 0.5)
if base < 2 {
base = 2
}
return noise.FBMAt(u, v, noise.NewSource(p.Seed, srcCoastal),
noise.Params{BaseCells: base, Octaves: 3, Gain: 0.5})
}
// slotOf is where a waterline cell sits in the shore list, which is sorted because it was built by scanning.
// -1 for a cell that is not on the list, which the distance transform should never hand back and which is
// cheaper to rule out here than to debug as an index out of range at planet scale.
func slotOf(shore []int32, cell int32) int {
k := sort.Search(len(shore), func(k int) bool { return shore[k] >= cell })
if k < len(shore) && shore[k] == cell {
return k
}
return -1
}
// smoothShore blurs a signed distance field, in place.
//
// Smoothing the *distance* is the point, and it is worth saying what the two obvious alternatives do instead.
// Smoothing the mask only moves the speckle around: it is a majority vote over a band that is half land and
// half water, so it produces different speckle. Smoothing the heightmap flattens the berm along with it. The
// distance is the one field whose smoothing has exactly the wanted effect - the shoreline becomes a curve, a
// few metres from where the mask put it, and nothing else about the ground changes at all.
//
// Two passes rather than one, because one leaves a box kernel's corners in the isolines and they show in a
// hillshade on ground this flat.
func smoothShore(sd []float32, w, h, radius int) {
field.BoxSmooth(sd, w, h, radius, 2)
}
// percentiles sorts a copy and reads the median and the P90 off it. A few thousand shore cells a tile, so a
// sort is nothing; this is the one place in the detail passes where that is true, and it is why there is no
// histogram here the way there is in internal/stats.
func percentiles(v []float64) (p50, p90 float64) {
if len(v) == 0 {
return 0, 0
}
c := append([]float64(nil), v...)
sort.Float64s(c)
return c[len(c)/2], c[int(float64(len(c)-1)*0.9)]
}
func max64(a, b float64) float64 {
if a > b {
return a
}
return b
}
// boundaryOf is the cells of a mask that are orthogonally against a cell that is not, which is to say its
// edge on the inside.
func boundaryOf(mask []bool, w, h int) []bool {
out := make([]bool, len(mask))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if !mask[i] {
continue
}
if (x > 0 && !mask[i-1]) || (x < w-1 && !mask[i+1]) ||
(y > 0 && !mask[i-w]) || (y < h-1 && !mask[i+w]) {
out[i] = true
}
}
}
return out
}
// signedDistance is metres to the nearest boundary cell, positive inside the mask.
//
// Distance2 rather than Transform, because this one is thrown away after it has been smoothed and thresholded
// back into a shoreline: nothing asks it which stretch of shore a cell belongs to, and the feature index and
// the scratch it needs are two more arrays of four bytes a cell.
func signedDistance(boundary, mask []bool, w, h int, cellM float64) []float32 {
d2 := dt.Distance2(boundary, w, h, false)
out := make([]float32, len(d2))
for i := range d2 {
d := float32(math.Sqrt(float64(d2[i])) * cellM)
if mask[i] {
out[i] = d
} else {
out[i] = -d
}
}
return out
}
// marchBackshore is how high the land stands behind each stretch of shore: the mean height between one and
// two surf reaches inland, walked in along the shore normal.
//
// It is the window measureBackshore uses on the geology grid and for the same reason - it is clear of
// everything the surf planed, whatever the exposure there was - and it is what decides whether a stretch of
// shore is a beach or the foot of a cliff.
//
// **Walked rather than gathered**, and that is the whole of this function. The obvious implementation is to
// scatter every cell in the band onto the stretch of shore nearest to it, which costs one pass and no marches
// at all; it was the first one, and it is wrong in a way that only shows up on a real coastline. A cell two
// hundred metres inland belongs to exactly one shore cell, so on a concave shore - the inside of every bay,
// which is half of any coastline - the wedges converge and most shore cells are left owning nothing at all in
// the band. Their backshore then reads zero, which is not "the land behind is at sea level", it is "I did not
// look", and the two are indistinguishable afterwards. Measured on region 11: the median backshore over
// 69 km of waterline read 0.0 m while the mean height of the land 110 to 220 m inland was 1.9 m.
//
// A march gives every stretch of shore its own samples, whichever way the coast bends. Where it walks off the
// land - a spit narrower than a surf reach - the count stops rising, and a backshore of zero then means what
// it says.
func marchBackshore(h *field.Field, dist []float32, wet []bool, shore []int32, reach []float64,
seaLevelM float64) []float64 {
w, ht := h.W, h.H
cellM := h.CellM
at := func(x, y int) float64 {
if x < 0 {
x = 0
} else if x >= w {
x = w - 1
}
if y < 0 {
y = 0
} else if y >= ht {
y = ht - 1
}
return float64(dist[y*w+x])
}
out := make([]float64, len(shore))
for s, ci := range shore {
x, y := int(ci)%w, int(ci)/w
// Inland is up the gradient of the signed distance, which is smooth here because the shoreline it is
// measured from is a curve rather than the raw mask's boundary.
dx := at(x+1, y) - at(x-1, y)
dy := at(x, y+1) - at(x, y-1)
l := math.Hypot(dx, dy)
if l < 1e-9 {
continue
}
dx, dy = dx/l, dy/l
lo := int(reach[s]/cellM + 0.5)
hi := 2 * lo
var sum float64
var count int
for t := lo; t <= hi; t++ {
px := x + int(math.Round(dx*float64(t)))
py := y + int(math.Round(dy*float64(t)))
if px < 0 || px >= w || py < 0 || py >= ht {
break
}
j := py*w + px
if !wet[j] {
break
}
sum += float64(h.Data[j]) - seaLevelM
count++
}
if count > 0 {
out[s] = sum / float64(count)
}
}
return out
}
// screeWedge is the shape of the apron along the march: a wedge under the foot of the cliff, thickest against
// the face and thinning to nothing a scree reach seaward of it. Zero past the foot, because an apron lying
// *on* the cliff is not an apron.
func screeWedge(x, reachM, screeM float64) float64 {
if x > reachM {
return 0
}
d := reachM - x
if d >= screeM {
return 0
}
return 1 - d/screeM
}
// smoothShoreRoughness damps the metre-scale texture near the shore, in place.
//
// A blur of a few cells, mixed in by how close a cell is to the waterline. The radius is what keeps it a
// *roughness* fade rather than a shape one: at six metres it takes the top off the detail noise and the
// droplet rills and leaves everything the solve built, which is tens of metres across at the very least.
//
// Full strength within half a surf reach either side, then off over reachM more. Both sides on purpose - the
// shallows get the same treatment as the backshore, because a shore is a *place* rather than a line and it is
// smoother than either the land or the sea bed away from it.
//
// **Masked, and that is not a detail.** A plain blur across the waterline does not damp texture, it bridges
// the shoreline: the step there is a landform and not roughness. Measured on a fixture with forty metres of
// water against the land, an unmasked blur lifted the sea floor by twenty metres, which is a beach the size
// of the drowned valley it was supposed to leave alone.
func smoothShoreRoughness(h *field.Field, dist []float32, wet []bool, surfReachM, reachM float64) {
if reachM <= 0 {
return
}
radius := int(shoreRoughM/h.CellM + 0.5)
if radius < 1 {
return
}
soft := append([]float32(nil), h.Data...)
dry := make([]bool, len(wet))
for i, on := range wet {
dry[i] = !on
}
field.BoxSmoothMasked(soft, wet, h.W, h.H, radius, 2)
field.BoxSmoothMasked(soft, dry, h.W, h.H, radius, 2)
core := surfReachM * 0.5
out := core + reachM
for i := range h.Data {
d := math.Abs(float64(dist[i]))
if d >= out {
continue
}
w := 1.0
if d > core {
w = noise.Smoothstep((out - d) / (out - core))
}
h.Data[i] += float32(w * (float64(soft[i]) - float64(h.Data[i])))
}
}
// shoreRoughM is the wavelength the shore fade takes off. It is deliberately short: this is meant to remove
// the texture the detail passes added and nothing the solve built, and the solve's finest feature is a gully
// tens of metres across.
const shoreRoughM = 6
@@ -0,0 +1,402 @@
package detail
import (
"math"
"testing"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/world"
)
const testCellM = 2.0
// coastalCfg is the manifest's own block, so the tests fail when a default moves rather than measuring a copy
// of it that nothing ships.
func coastalCfg() (manifest.CoastDetail, manifest.Coast) {
m := manifest.Defaults()
return m.Pipeline.CoastDetail, m.Pipeline.Coast
}
func coastPlanet(w, h int) world.Planet {
return world.Planet{CellM: testCellM, W: w, H: h, PadY: 0, NoisePeriodM: float64(w) * testCellM}
}
// straightCoast is a world cut in half: land to the left of shoreM, sea to the right. The land rises to backM
// over one surf reach and then holds, so the backshore window the pass measures in is exactly backM and the
// beach-or-cliff decision in a test is the number the test set.
//
// A straight coast rather than an island on purpose: the profile is then one dimensional, so "what did the
// pass do" is a column that can be read off and compared against the arithmetic it is meant to be.
func straightCoast(w, h int, shoreM, backM, reachM, seaDepthM float64) (*field.Field, []bool) {
f := field.New(w, h, testCellM)
land := make([]bool, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
inland := shoreM - float64(x)*testCellM
if inland >= 0 {
land[i] = true
t := inland / reachM
if t > 1 {
t = 1
}
f.Data[i] = float32(backM * t * t * (3 - 2*t))
} else {
f.Data[i] = float32(-seaDepthM)
}
}
}
return f, land
}
func runCoastal(t *testing.T, f *field.Field, land []bool, p world.Planet, x0, y0 int, backM float64) CoastalStats {
t.Helper()
cfg, surf := coastalCfg()
return RunCoastal(f, land, CoastalParams{
Cfg: cfg, Surf: surf, Seed: 7,
Frame: world.Frame{P: p, X0: x0, Y0: y0, W: f.W, H: f.H},
PeriodM: 1000, SeaLevelM: 0,
})
}
// Rule 1, for this pass: everything is keyed on absolute world position - the crenulation lattice through
// noise.WorldUV, the distance through a transform whose seeds are the same cells - so a window cut out of a
// bigger world and run on its own comes back bit-identical inside its margin.
//
// This is the test for the mistake the rule exists for: a noise field indexed by grid index instead of world
// position looks perfect on any one tile and puts a seam down every tile boundary. Measured by breaking it -
// passing a zero origin to WorldUV moves the interior by up to 3.6 m.
//
// It is *not* the test for the margin being big enough; the coast here is in the middle of the window, so the
// answer would be the same with no margin at all. TestThePassFitsInsideTheTileMargin is that one.
func TestATileInteriorIsWhatOneWholeRunWouldHaveGiven(t *testing.T) {
const w, h = 512, 192
p := coastPlanet(w, h)
whole, land := straightCoast(w, h, 420, 40, 110, 6)
runCoastal(t, whole, land, p, 0, 0, 40)
// The same world, cut out with a margin and run on its own. 130 cells is 260 m, which is past the pass's
// own outer limit of two surf reaches.
const margin = 130
const cx0, cw = 160, 192
cut := field.New(cw+2*margin, h, testCellM)
cutLand := make([]bool, len(cut.Data))
src, srcLand := straightCoast(w, h, 420, 40, 110, 6)
for y := 0; y < h; y++ {
for x := 0; x < cut.W; x++ {
sx := cx0 - margin + x
cut.Data[y*cut.W+x] = src.Data[y*w+sx]
cutLand[y*cut.W+x] = srcLand[y*w+sx]
}
}
runCoastal(t, cut, cutLand, p, cx0-margin, 0, 40)
var worst float64
for y := 0; y < h; y++ {
for x := 0; x < cw; x++ {
a := whole.Data[y*w+cx0+x]
b := cut.Data[y*cut.W+margin+x]
if d := math.Abs(float64(a) - float64(b)); d > worst {
worst = d
}
}
}
if worst != 0 {
t.Fatalf("a tile's interior differs from the whole run by up to %g m; every hash and lattice in this "+
"pass is supposed to be keyed on world position", worst)
}
}
// The cliff branch only cuts, so it owes an apron. This is the one hard conservation statement in the pass:
// what comes off the face is what lands at its foot, per stretch of shore rather than per tile, so the debris
// under a cliff is that cliff's debris.
func TestTheScreeIsExactlyWhatTheCliffLost(t *testing.T) {
const w, h = 320, 128
p := coastPlanet(w, h)
f, land := straightCoast(w, h, 400, 60, 110, 6)
st := runCoastal(t, f, land, p, 0, 0, 60)
if st.CutM3 <= 0 {
t.Fatalf("a 60 m backshore cut nothing off its face; cliff fraction %.2f", st.CliffFrac)
}
if st.CliffFrac < 0.99 {
t.Fatalf("a 60 m backshore is %.0f%% cliff, not a cliff coast", st.CliffFrac*100)
}
// Float32 heights, so the tolerance is the accumulation of a few million of them rather than zero.
if rel := math.Abs(st.ScreeM3-st.CutM3) / st.CutM3; rel > 1e-9 {
t.Fatalf("the face lost %.3f m3 and the apron gained %.3f m3, a relative gap of %g",
st.CutM3, st.ScreeM3, rel)
}
}
// A beach coast and a cliff coast are the same code with one number changed, and the number is the height of
// the land behind the shore. This checks the two come out as different landforms rather than as the same one
// scaled: a berm above the waterline on the beach, and no berm at all on the cliff.
func TestTheBackshoreDecidesBetweenABeachAndACliff(t *testing.T) {
const w, h = 320, 96
p := coastPlanet(w, h)
cfg, surf := coastalCfg()
// The swash zone: the strip just inland of the waterline. A berm is ground *standing* above the water
// there, so the measurement is a height and not a change - the first version of this measured how much
// the pass raised the ground and read 6 m on a beach, all of it the foreshore being filled up from the
// flat sea floor the fixture starts with. What was being measured was the fixture.
crest := func(f *field.Field) float64 {
var top float64
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
inland := 400 - float64(x)*testCellM
if inland < 0 || inland > float64(cfg.BermBackM) {
continue
}
if v := float64(f.Data[y*w+x]); v > top {
top = v
}
}
}
return top
}
beach, beachLand := straightCoast(w, h, 400, 3, 110, 6)
beachStats := runCoastal(t, beach, beachLand, p, 0, 0, 3)
cliff, cliffLand := straightCoast(w, h, 400, 60, 110, 6)
cliffStats := runCoastal(t, cliff, cliffLand, p, 0, 0, 60)
if beachStats.CliffFrac > 0.01 {
t.Errorf("a 3 m backshore came out %.0f%% cliff", beachStats.CliffFrac*100)
}
if cliffStats.CliffFrac < 0.99 {
t.Errorf("a 60 m backshore came out only %.0f%% cliff", cliffStats.CliffFrac*100)
}
// With no exposure field every shore is treated as fully exposed, so the berm stands at the manifest's
// full height.
gotBerm := crest(beach)
if want := surf.BermM; gotBerm < want*0.8 || gotBerm > want*1.2 {
t.Errorf("the beach's swash zone tops out at %.2f m; a berm should stand about %.2f", gotBerm, want)
}
// The cliff coast has a shore platform there instead, which runs up at the platform grade and nothing
// more: a cliff does not get a berm, it gets the rock the surf planed.
gotPlatform := crest(cliff)
if want := surf.PlatformGrade * cfg.BermBackM; gotPlatform > want*1.5 {
t.Errorf("the cliff's swash zone tops out at %.2f m; the platform should reach about %.2f",
gotPlatform, want)
}
if gotPlatform >= gotBerm {
t.Errorf("the cliff coast (%.2f m) stands as high in the swash zone as the beach (%.2f m); the two "+
"branches are not producing different landforms", gotPlatform, gotBerm)
}
}
// The claim that lets the pass run per tile at all: it never reaches further from the waterline than the tile
// margin, so a tile's margin holds everything its interior needed.
//
// The margin is the droplets' - three lifetimes, 244 m at the defaults - and this pass has to fit inside a
// number that was measured for something else. Two surf reaches is its own hard limit, and it is a limit
// rather than a consequence: past it a cell has no stretch of shore to belong to at all.
//
// The test asserts both ends. Past the margin, nothing may move; and something must move a good way out, or
// the test would pass just as well on a pass that did nothing.
func TestThePassFitsInsideTheTileMargin(t *testing.T) {
const w, h = 512, 96
p := coastPlanet(w, h)
m := manifest.Defaults()
marginM := float64(MarginCells(m.Pipeline.Particle)) * testCellM
for _, backM := range []float64{3, 40, 300, 600} {
f, land := straightCoast(w, h, 500, backM, 110, 6)
before := f.Clone()
runCoastal(t, f, land, p, 0, 0, backM)
var reachedM float64
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if f.Data[i] == before.Data[i] {
continue
}
if d := math.Abs(500 - float64(x)*testCellM); d > reachedM {
reachedM = d
}
}
}
if reachedM > marginM {
t.Errorf("backshore %.0f m: the pass reached %.0f m from the waterline, past the %.0f m tile "+
"margin it has to fit inside", backM, reachedM, marginM)
}
if reachedM < 40 {
t.Errorf("backshore %.0f m: the pass only reached %.0f m, which is not a shore profile",
backM, reachedM)
}
t.Logf("backshore %3.0f m: reached %3.0f m of the %.0f m margin", backM, reachedM, marginM)
}
}
// Dean's profile is the one piece of published geomorphology in this pass, so it is worth checking that what
// comes out is actually it rather than something that merely slopes the right way. Away from the crenulation
// and inside the full-weight strip, the depth under water must be A*x^(2/3).
func TestTheForeshoreIsDeansProfile(t *testing.T) {
const w, h = 320, 64
p := coastPlanet(w, h)
cfg, surf := coastalCfg()
// Shallow water on purpose. A beach may lay at most BeachFillM of sediment on what is already there, so a
// fixture with a deep flat floor would measure the cap rather than the curve - which is what the first
// version of this did, at 40 m, and it read a flat profile 3 m above the floor. At 3 m the equilibrium
// curve sits above the floor by less than the cap everywhere it is sampled.
f, land := straightCoast(w, h, 300, 3, 110, 3)
runCoastal(t, f, land, p, 0, 0, 3)
// One row, and the crenulation read off the pass's own noise by inverting the profile at a known depth
// would be circular - so instead the check is against the *shape*: the ratio of depths at two offsets
// must be (x1/x2)^(2/3) whatever the crenulation shifted them by, and that is what is asserted.
y := h / 2
depthAt := func(offsetM float64) float64 {
x := int((300 + offsetM) / testCellM)
return -float64(f.Data[y*w+x])
}
d1, d2 := depthAt(20), depthAt(45)
if d1 <= 0 || d2 <= d1 {
t.Fatalf("the foreshore is not going down: %.2f m at 20 m out, %.2f m at 45 m", d1, d2)
}
// Solve for the shift the crenulation applied, then check A.
// d1 = A*(20+s)^(2/3), d2 = A*(45+s)^(2/3)
var best, bestErr = 0.0, math.Inf(1)
for s := -cfg.CrenulationM; s <= cfg.CrenulationM; s += 0.01 {
want := math.Pow((45+s)/(20+s), 2.0/3.0)
if e := math.Abs(d2/d1 - want); e < bestErr {
best, bestErr = s, e
}
}
if bestErr > 0.02 {
t.Fatalf("the two depths %.3f and %.3f are not in a 2/3-power ratio at any crenulation inside "+
"+/-%.0f m (best miss %.3f)", d1, d2, cfg.CrenulationM, bestErr)
}
gotA := d1 / math.Pow(20+best, 2.0/3.0)
if math.Abs(gotA-cfg.DeanA) > 0.01 {
t.Fatalf("Dean's A came out %.3f against the manifest's %.3f (crenulation %.2f m)",
gotA, cfg.DeanA, best)
}
_ = surf
}
// speckledCoast is a coastal plain: land rising at one in a hundred, with a little roughness on it. That is
// enough to make the land mask a forty-metre band of speckle rather than a line, which is what a real one is
// - measured on region 11 of the first painted planet, where the shore wandered eighteen cells between rows
// three apart and a row crossed sea level three times.
func speckledCoast(w, h int, shoreM, grade, roughM float64) (*field.Field, []bool) {
f := field.New(w, h, testCellM)
land := make([]bool, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
inland := shoreM - float64(x)*testCellM
// A hash of the cell, so the roughness is the same every run and has no structure in it.
k := uint32(x*374761393+y*668265263) * 2246822519
k ^= k >> 13
u := float64(k%10007)/10007.0 - 0.5
v := grade*inland + roughM*u
f.Data[i] = float32(v)
land[i] = v > 0
}
}
return f, land
}
// What a coastal plain does to a shoreline, and the reason the signed distance is smoothed before the profile
// is measured from it.
//
// The pass rebuilds the surface as a monotonic function of that distance, so its output crosses sea level
// once along any line across the shore however ragged the input was. Without the smoothing it instead builds
// a separate berm on every island in the speckle, which is what the first run of the pass did: a string of
// beads down the whole coast.
func TestACoastalPlainComesOutWithOneShorelineAndNotABeadedOne(t *testing.T) {
const w, h = 320, 128
p := coastPlanet(w, h)
f, land := speckledCoast(w, h, 400, 0.01, 0.30)
crossings := func(g *field.Field) float64 {
total := 0
for y := 0; y < h; y++ {
n := 0
for x := 1; x < w; x++ {
a, b := g.Data[y*w+x-1], g.Data[y*w+x]
if (a <= 0) != (b <= 0) {
n++
}
}
total += n
}
return float64(total) / float64(h)
}
before := crossings(f)
if before < 3 {
t.Fatalf("the fixture is not speckled: %.1f sea-level crossings a row", before)
}
runCoastal(t, f, land, p, 0, 0, 4)
after := crossings(f)
if after > 1.05 {
t.Errorf("the shore came out with %.2f sea-level crossings a row (%.1f before); a shoreline crosses "+
"once, and more than that is a bead on the beach for every island in the mask", after, before)
}
t.Logf("sea-level crossings a row: %.1f before, %.2f after", before, after)
}
// A beach is a veneer of sediment and not a landform that fills a fjord.
//
// The equilibrium profile is a target *depth*, so on a shore with forty metres of water a hundred metres off
// it - a drowned valley, which is an ordinary thing on a real coast - an uncapped beach branch invents
// thirty-seven metres of sand to bring the floor up to the curve. Capped, the beach lays a few metres on
// whatever is there and runs out where the water gets deep, which is what a steep-to shore is.
func TestABeachDoesNotFillADrownedValley(t *testing.T) {
const w, h = 320, 96
p := coastPlanet(w, h)
cfg, _ := coastalCfg()
f, land := straightCoast(w, h, 400, 3, 110, 40)
before := f.Clone()
runCoastal(t, f, land, p, 0, 0, 3)
var worst float64
for i := range f.Data {
if d := float64(f.Data[i]) - float64(before.Data[i]); d > worst {
worst = d
}
}
if worst > cfg.BeachFillM+0.01 {
t.Fatalf("the beach laid %.2f m of sediment where the cap is %.2f; a shore with deep water close in "+
"is a steep-to shore, not a bay to be filled", worst, cfg.BeachFillM)
}
if worst < cfg.BeachFillM*0.5 {
t.Fatalf("the beach laid only %.2f m; the fixture is meant to press against the %.2f m cap",
worst, cfg.BeachFillM)
}
}
// The pass is off when the manifest says so, and off means nothing at all rather than a cheaper version of
// itself. Worth a test because it is the switch somebody reaches for when a coast looks wrong, and a switch
// that half works is worse than no switch.
func TestTheSwitchTurnsItOff(t *testing.T) {
const w, h = 128, 64
p := coastPlanet(w, h)
f, land := straightCoast(w, h, 150, 40, 110, 6)
before := f.Clone()
cfg, surf := coastalCfg()
cfg.Enabled = false
st := RunCoastal(f, land, CoastalParams{
Cfg: cfg, Surf: surf, Seed: 7,
Frame: world.Frame{P: p, X0: 0, Y0: 0, W: w, H: h},
PeriodM: 1000, SeaLevelM: 0,
})
if st.ShoreCells != 0 {
t.Errorf("a disabled pass reported %d shore cells", st.ShoreCells)
}
for i := range f.Data {
if f.Data[i] != before.Data[i] {
t.Fatalf("a disabled pass moved cell %d from %g to %g", i, before.Data[i], f.Data[i])
}
}
}
+29
View File
@@ -0,0 +1,29 @@
package detail
import "salty/terrain/internal/manifest"
// MarginCells is the overlap a tile must carry for the particle pass, in detail cells.
//
// Rule 2 of the tiling plan says to size a margin by how far the pass can move material, and for droplets
// that is not simply the lifetime. Within one round a droplet travels at most its lifetime, plus one cell for
// the cut brush. Across rounds the error compounds: a droplet in round two reads heights the round-one
// droplets moved, so the cut edge's influence walks a lifetime further in with every round.
//
// Taking that literally would make the margin `rounds * lifetime`, which at the defaults is 640 cells against
// a 2500-cell tile. Measured instead, at lifetime 12 and 8 rounds (TestHowFarTheCutEdgeReachesIn), the worst
// difference between a tile and the same ground in one whole run falls off much faster than that:
//
// cells in from the cut edge: 0 4 8 12 16 20 24 32 40
// worst difference, metres: 7.97 2.53 0.72 0.49 0.44 0.18 0.03 0.00 0.00
//
// It is the first lifetime that carries almost all of it, and by three the error is gone - a droplet has to be
// unlucky in the same way several rounds running for it to keep propagating, and that stops happening. Three
// lifetimes plus the brush is the margin, which at the default lifetime of 40 is 122 detail cells, 244 m, or
// about five per cent of a 5 km tile on each side.
func MarginCells(cfg manifest.Particle) int {
life := cfg.Lifetime
if life < 1 {
life = 1
}
return 3*life + 2
}
+142
View File
@@ -0,0 +1,142 @@
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))
}
}
+419
View File
@@ -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)
}
@@ -0,0 +1,386 @@
package detail
import (
"math"
"runtime"
"testing"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/world"
)
func testPlanet(t *testing.T) world.Planet {
t.Helper()
// 256 detail columns of 2 m is a 512 m circumference. Small, and a whole number of cells.
p := world.Planet{CellM: 2, W: 256, H: 96, PadY: 0, NoisePeriodM: 512}
if err := p.Validate(); err != nil {
t.Fatal(err)
}
return p
}
// a ridge running down the middle with some texture, so the droplets have something to cut.
func testTerrain(f world.Frame) (*field.Field, []bool) {
h := field.New(f.W, f.H, f.P.CellM)
land := make([]bool, f.W*f.H)
for y := 0; y < f.H; y++ {
for x := 0; x < f.W; x++ {
wx, wy := f.PlanetXY(x, y)
fx := float64(wx)
fy := float64(wy)
v := 120 * math.Exp(-math.Pow((fy-48)/22, 2))
v += 9 * math.Sin(fx*0.21) * math.Cos(fy*0.17)
v += 4 * math.Sin(fx*0.63+fy*0.41)
i := y*f.W + x
h.Data[i] = float32(v)
land[i] = v > 3
}
}
return h, land
}
func testCfg() manifest.Particle {
c := manifest.Defaults().Pipeline.Particle
c.DropletsPerCell = 1.5 // dense, so a small grid still gets a meaningful number
c.Lifetime = 12
c.Rounds = 1
return c
}
// The seam property the whole tiling rests on: a cell in a tile's interior must come out exactly as it would
// have in one big run, because every droplet that can reach it spawned inside the tile's margin.
//
// Rounds is 1 here, which is where the margin of lifetime+2 is *exactly* sufficient: a droplet that affects an
// interior cell passed within brush range of it, so it spawned at most lifetime cells away and every height it
// read on the way is inside the margin. With more rounds the margin's own heights start to matter and the
// match becomes very close rather than exact, which the test below measures instead of assuming.
func TestATilesInteriorMatchesTheWholeMap(t *testing.T) {
p := testPlanet(t)
cfg := testCfg()
const margin = 14 // lifetime 12 + 2
whole := world.Whole(p)
hw, landw := testTerrain(whole)
RunParticle(hw, landw, ParticleParams{Cfg: cfg, Seed: 7, Frame: whole})
// A tile covering columns 40..119, with the margin either side.
const x0, w = 40, 80
tf := world.Frame{P: p, X0: x0 - margin, Y0: 0, W: w + 2*margin, H: p.H}
ht, landt := testTerrain(tf)
RunParticle(ht, landt, ParticleParams{Cfg: cfg, Seed: 7, Frame: tf})
worst, at := 0.0, [2]int{}
for y := margin; y < p.H-margin; y++ {
for x := margin; x < margin+w; x++ {
got := float64(ht.Data[y*tf.W+x])
want := float64(hw.Data[y*p.W+(x0-margin+x)])
if d := math.Abs(got - want); d > worst {
worst, at = d, [2]int{x, y}
}
}
}
if worst > 1e-4 {
t.Errorf("the tile's interior differs from the whole map by %.6f m at %v; the margin is not doing "+
"its job, or something is keyed on a tile-local index", worst, at)
}
}
// With more than one round the margin's own heights feed back, so the match stops being exact and the
// question becomes how deep into a tile the edge's influence reaches. That is a measurement, not a guess:
// this runs a wide margin and reports the worst error at each depth, and the assertion is set at the depth
// the bake actually uses.
func TestHowFarTheCutEdgeReachesIn(t *testing.T) {
p := testPlanet(t)
cfg := testCfg()
cfg.Rounds = 8
const margin = 48
whole := world.Whole(p)
hw, landw := testTerrain(whole)
RunParticle(hw, landw, ParticleParams{Cfg: cfg, Seed: 7, Frame: whole})
const x0, w = 60, 60
tf := world.Frame{P: p, X0: x0 - margin, Y0: 0, W: w + 2*margin, H: p.H}
ht, landt := testTerrain(tf)
RunParticle(ht, landt, ParticleParams{Cfg: cfg, Seed: 7, Frame: tf})
// worst error among cells exactly d columns in from the cut's left edge.
at := func(d int) float64 {
worst := 0.0
x := d
// The whole run and the tile run share their top and bottom edges, so those cancel; only a couple of
// rows are dropped to keep the bilinear sampler's own clamp out of it.
for y := 2; y < p.H-2; y++ {
got := float64(ht.Data[y*tf.W+x])
want := float64(hw.Data[y*p.W+p.WrapX(x0-margin+x)])
if e := math.Abs(got - want); e > worst {
worst = e
}
}
return worst
}
for _, d := range []int{0, 4, 8, 12, 16, 20, 24, 32, 40, 48} {
t.Logf(" %2d cells in from the cut edge (%.0f m): worst %.4f m", d, float64(d)*p.CellM, at(d))
}
// At the margin the bake uses, the edge must have stopped mattering.
if e := at(MarginCells(cfg)); e > 0.05 {
t.Errorf("at the bake's margin of %d cells the edge still moves the ground by %.4f m",
MarginCells(cfg), e)
}
}
// A tile that straddles the seam must get the same answer as one that does not, which is what keying every
// hash on the world position buys.
func TestTheSeamIsNotSpecial(t *testing.T) {
p := testPlanet(t)
cfg := testCfg()
a := world.Frame{P: p, X0: 0, Y0: 0, W: 64, H: p.H}
ha, landa := testTerrain(a)
RunParticle(ha, landa, ParticleParams{Cfg: cfg, Seed: 7, Frame: a})
// The same physical columns, reached from a frame that starts on the far side of the seam.
b := world.Frame{P: p, X0: p.W - 32, Y0: 0, W: 64, H: p.H}
hb, landb := testTerrain(b)
RunParticle(hb, landb, ParticleParams{Cfg: cfg, Seed: 7, Frame: b})
// Frame b's column 32+k is planet column k, which is frame a's column k. Only compare cells far enough
// from both frames' edges that they saw the same droplets.
const edge = 14
checked := 0
for y := edge; y < p.H-edge; y++ {
for k := edge; k < 32-edge; k++ {
got := hb.Data[y*b.W+32+k]
want := ha.Data[y*a.W+k]
if math.Abs(float64(got-want)) > 1e-4 {
t.Fatalf("planet column %d row %d: %.6f across the seam, %.6f at the origin", k, y, got, want)
}
checked++
}
}
if checked == 0 {
t.Fatal("nothing was compared")
}
}
func TestParticleIsDeterministicAcrossGOMAXPROCS(t *testing.T) {
was := runtime.GOMAXPROCS(1)
defer runtime.GOMAXPROCS(was)
p := testPlanet(t)
cfg := testCfg()
cfg.Rounds = 4
f := world.Whole(p)
var want []float32
for _, procs := range []int{1, 2, 4, 8, 16} {
runtime.GOMAXPROCS(procs)
h, land := testTerrain(f)
RunParticle(h, land, ParticleParams{Cfg: cfg, Seed: 7, Frame: f})
if want == nil {
want = append([]float32(nil), h.Data...)
continue
}
for i := range want {
if h.Data[i] != want[i] {
t.Fatalf("GOMAXPROCS %d differs at cell %d: %v against %v", procs, i, h.Data[i], want[i])
}
}
}
}
// The brakes are lessons, not choices, and this is the one that matters most: below the slope gate water
// deposits but barely cuts, so lowland soil holds and meadows stay meadows instead of coming out brushed with
// rills.
func TestTheSlopeGateProtectsFlatGround(t *testing.T) {
p := testPlanet(t)
f := world.Whole(p)
cfg := testCfg()
cfg.DropletsPerCell = 4
// A gentle ramp well below min_erode_slope 0.25: 0.05 m over a 2 m cell is a slope of 0.025.
flat := func() (*field.Field, []bool) {
h := field.New(f.W, f.H, f.P.CellM)
land := make([]bool, f.W*f.H)
for y := 0; y < f.H; y++ {
for x := 0; x < f.W; x++ {
i := y*f.W + x
h.Data[i] = float32(40 + 0.05*float64(y))
land[i] = true
}
}
return h, land
}
h, land := flat()
before := append([]float32(nil), h.Data...)
_, st := RunParticle(h, land, ParticleParams{Cfg: cfg, Seed: 7, Frame: f})
if st.Droplets == 0 {
t.Fatal("no droplets spawned")
}
worst := 0.0
for i := range h.Data {
if d := math.Abs(float64(h.Data[i] - before[i])); d > worst {
worst = d
}
}
t.Logf("%d droplets over flat ground moved at most %.4f m", st.Droplets, worst)
if worst > 0.25 {
t.Errorf("flat ground moved %.3f m; the slope gate is not holding", worst)
}
// And with the gate opened right up, the same ground does get cut - so the test above is measuring the
// gate and not simply a pass that does nothing.
open := cfg
open.MinErodeSlope = 0.001
h2, land2 := flat()
RunParticle(h2, land2, ParticleParams{Cfg: open, Seed: 7, Frame: f})
moved := 0.0
for i := range h2.Data {
if d := math.Abs(float64(h2.Data[i] - before[i])); d > moved {
moved = d
}
}
if moved <= worst {
t.Errorf("opening the gate moved %.4f m against %.4f m closed; the test is not measuring the gate",
moved, worst)
}
}
// The sea is a sink, and it is the land mask that says so rather than a height comparison: the sea floor is
// held at sea level while the detail passes run, exactly as the fluvial solve holds it, so there is no depth
// to compare against. What a droplet reaching the water does is drop its whole load, which is what builds a
// fan at a river mouth.
func TestADropletEndsAtTheWaterAndLeavesItsLoadThere(t *testing.T) {
p := testPlanet(t)
f := world.Whole(p)
cfg := testCfg()
cfg.DropletsPerCell = 3
h := field.New(f.W, f.H, f.P.CellM)
land := make([]bool, f.W*f.H)
const shore = 60
for y := 0; y < f.H; y++ {
for x := 0; x < f.W; x++ {
i := y*f.W + x
if y >= shore {
h.Data[i] = 0 // the sea, held at sea level
continue
}
// A slope running down to the shore, steep enough to be well past the cutting gate.
h.Data[i] = float32(2 * float64(shore-y))
land[i] = true
}
}
before := append([]float32(nil), h.Data...)
maps, st := RunParticle(h, land, ParticleParams{Cfg: cfg, Seed: 7, Frame: f})
if st.Droplets == 0 {
t.Fatal("no droplets spawned")
}
// Nothing in the water moved.
for y := shore; y < f.H; y++ {
for x := 0; x < f.W; x++ {
i := y*f.W + x
if h.Data[i] != before[i] {
t.Fatalf("sea cell (%d,%d) moved from %v to %v", x, y, before[i], h.Data[i])
}
}
}
// And the last row of land carries more deposit than the slope above it: that is the fan.
rowDeposit := func(y int) float64 {
s := 0.0
for x := 0; x < f.W; x++ {
s += float64(maps.Deposit[y*f.W+x])
}
return s
}
atShore := rowDeposit(shore - 1)
upslope := rowDeposit(shore / 2)
t.Logf("deposit at the shore %.2f m against %.2f m halfway up the slope", atShore, upslope)
if atShore <= upslope {
t.Errorf("the shore row took %.3f m of deposit and the mid-slope row %.3f m; the sea is not acting "+
"as a sink", atShore, upslope)
}
}
// A desert and a wet lowland can have the same uplift rate and the same erodibility - which is everything the
// geology grid knows about them - and still be completely different ground. The per-class detail tables are
// where that difference lives, and the droplet density is the load-bearing one: drop it and the dendritic
// gully network thins out to isolated channels.
func TestAClassCanAskForLessRunningWater(t *testing.T) {
p := testPlanet(t)
f := world.Whole(p)
cfg := testCfg()
cfg.DropletsPerCell = 2.0
// Two classes over the same terrain: the left half wet, the right half arid.
run := func(classes *Classes) (ParticleStats, float64) {
h, land := testTerrain(f)
before := append([]float32(nil), h.Data...)
_, st := RunParticle(h, land, ParticleParams{Cfg: cfg, Seed: 7, Frame: f, Classes: classes})
moved := 0.0
for i := range h.Data {
moved += math.Abs(float64(h.Data[i] - before[i]))
}
return st, moved
}
wet, wetMoved := run(nil)
arid := uniformClasses(f.W*f.H, 0.1)
dry, dryMoved := run(arid)
t.Logf("wet %d droplets moved %.0f m of material; arid %d droplets moved %.0f m",
wet.Droplets, wetMoved, dry.Droplets, dryMoved)
if dry.Droplets >= wet.Droplets/10 {
t.Errorf("the arid class spawned %d droplets against %d wet; a twentieth of the density should show",
dry.Droplets, wet.Droplets)
}
if dryMoved >= wetMoved/2 {
t.Errorf("the arid class moved %.0f m against %.0f m wet; it should be far less dissected",
dryMoved, wetMoved)
}
if dry.Droplets == 0 {
t.Error("the arid class spawned nothing at all; that is not a desert, that is a table")
}
}
// And with no override, a class table changes nothing - which is what keeps every template that does not use
// one exactly where it was.
func TestClassTablesMatchingThePipelineChangeNothing(t *testing.T) {
p := testPlanet(t)
f := world.Whole(p)
cfg := testCfg()
a, landA := testTerrain(f)
RunParticle(a, landA, ParticleParams{Cfg: cfg, Seed: 7, Frame: f})
b, landB := testTerrain(f)
RunParticle(b, landB, ParticleParams{Cfg: cfg, Seed: 7, Frame: f,
Classes: uniformClasses(f.W*f.H, cfg.DropletsPerCell)})
for i := range a.Data {
if a.Data[i] != b.Data[i] {
t.Fatalf("cell %d differs: %v against %v", i, a.Data[i], b.Data[i])
}
}
}
// uniformClasses is a class table that says the same thing everywhere, which is what the two tests above
// want: one to make the whole map arid, the other to say nothing at all and prove it changes nothing.
func uniformClasses(n int, droplets float64) *Classes {
c := &Classes{
Droplets: make([]float32, n),
AmpLo: make([]float32, n),
AmpHi: make([]float32, n),
Contrast: make([]float32, n),
}
for i := range c.Droplets {
c.Droplets[i] = float32(droplets)
}
return c
}
+90
View File
@@ -0,0 +1,90 @@
// 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
}
+174
View File
@@ -0,0 +1,174 @@
// Package dt is the exact Euclidean distance transform, with a feature index and an optional cylinder.
//
// It lives on its own because three different things need it and two of them are nowhere near the coast:
// the coastal pass writes every one of its processes as "how far is this cell from the waterline and which
// stretch of shore does it belong to"; the region partitioner dilates the land mask to decide which
// landmasses are close enough to be solved together; and the template classifier dissolves the decorative
// stroke an artist drew by handing each of its pixels to the nearest pixel that means something.
//
// Exact, not a chamfer approximation: Felzenszwalb and Huttenlocher's transform is two 1-D passes and O(n)
// whatever the radius, so there is nothing to buy by approximating, and a chamfer's 2 % anisotropy would
// show up directly as a shelf wider along the grid axes than across them.
package dt
import (
"math"
"salty/terrain/internal/field"
)
// Transform returns, for every cell, the squared distance in cells to the nearest seed cell and the flat
// index of that seed. A column pass finds the nearest seed in each column; a row pass takes the lower
// envelope of the parabolas those distances define.
//
// With wrapX the row pass is periodic, so the left and right edges of the grid are neighbours. That is what
// a planet needs: a landmass straddling the seam is one landmass, and the shelf in front of it is one shelf.
//
// Cells in a column with no seed at all are given a cost above any real distance rather than an infinity, so
// the envelope arithmetic never sees a NaN; they are then never chosen unless the grid has no seeds
// anywhere, in which case every near index comes back -1.
func Transform(seed []bool, w, h int, wrapX bool) (d2 []float32, near []int32) {
return transform(seed, w, h, wrapX, true)
}
// Distance2 is Transform without the feature index, for a caller that only wants "how far".
//
// It is a separate entry point rather than a nil argument because the saving is the point: at planet scale
// the index and the column scratch it needs are two more arrays of four bytes a cell, which is most of a
// gigabyte for an answer nobody reads. The region partitioner only asks whether a cell is within a margin
// of land.
func Distance2(seed []bool, w, h int, wrapX bool) []float32 {
d2, _ := transform(seed, w, h, wrapX, false)
return d2
}
func transform(seed []bool, w, h int, wrapX, wantNear bool) (d2 []float32, near []int32) {
d2 = make([]float32, w*h)
if wantNear {
near = make([]int32, w*h)
}
bigF := float64(w*w+h*h) * 4 // above any achievable dx² + dy²
bigD := float32(math.Sqrt(bigF))
colD := make([]float32, w*h) // distance in cells to the nearest seed in this column
var colN []int32 // that seed's row, or -1; only needed for the feature index
if wantNear {
colN = make([]int32, w*h)
}
field.Rows(w, func(x0, x1 int) {
for x := x0; x < x1; x++ {
best := -1
for y := 0; y < h; y++ {
i := y*w + x
if seed[i] {
best = y
}
if best < 0 {
colD[i] = bigD
if wantNear {
colN[i] = -1
}
} else {
colD[i] = float32(y - best)
if wantNear {
colN[i] = int32(best)
}
}
}
best = -1
for y := h - 1; y >= 0; y-- {
i := y*w + x
if seed[i] {
best = y
}
if best >= 0 {
if d := float32(best - y); d < colD[i] {
colD[i] = d
if wantNear {
colN[i] = int32(best)
}
}
}
}
}
})
// The row pass. On a cylinder the row is laid out three times - one turn to the left, the row itself,
// one turn to the right - and the answer is read out of the middle copy. From a cell in the middle copy
// the three images of any column sit at offsets d, d-w and d+w, whose smallest absolute value is the
// cyclic distance, so the envelope returns exactly the wrapped answer with no special cases in it.
span := w
off := 0
if wrapX {
span = 3 * w
off = w
}
field.Rows(h, func(y0, y1 int) {
f := make([]float64, span)
v := make([]int, span)
z := make([]float64, span+1)
for y := y0; y < y1; y++ {
row := y * w
for j := 0; j < span; j++ {
d := float64(colD[row+srcX(j, off, w)])
f[j] = d * d
}
k := 0
v[0] = 0
z[0] = math.Inf(-1)
z[1] = math.Inf(1)
for q := 1; q < span; q++ {
s := intersect(f, v[k], q)
for s <= z[k] {
k--
s = intersect(f, v[k], q)
}
k++
v[k] = q
z[k] = s
z[k+1] = math.Inf(1)
}
k = 0
for q := 0; q < span; q++ {
for z[k+1] < float64(q) {
k++
}
if q < off || q >= off+w {
continue // a replica column; only the middle copy is the answer
}
dx := float64(q - v[k])
o := row + q - off
d2[o] = float32(dx*dx + f[v[k]])
if !wantNear {
continue
}
sx := srcX(v[k], off, w)
if n := colN[row+sx]; n < 0 {
near[o] = -1
} else {
near[o] = n*int32(w) + int32(sx)
}
}
}
})
return d2, near
}
// srcX maps a column of the (possibly replicated) row back to a real column.
func srcX(j, off, w int) int {
x := j - off
for x < 0 {
x += w
}
for x >= w {
x -= w
}
return x
}
// intersect is where the parabolas rooted at p and q cross.
func intersect(f []float64, p, q int) float64 {
return ((f[q] + float64(q*q)) - (f[p] + float64(p*p))) / float64(2*q-2*p)
}
+116
View File
@@ -0,0 +1,116 @@
package dt
import (
"math"
"testing"
)
func scatter(w, h int, seed uint32) []bool {
seeds := make([]bool, w*h)
for i := range seeds {
seed = seed*1664525 + 1013904223
seeds[i] = seed>>20&7 == 0
}
seeds[0] = true // guarantee at least one
return seeds
}
// brute is the definition: the smallest squared distance to any seed, with dx measured the short way round
// when the grid is a cylinder.
func brute(seeds []bool, w, h, x, y int, wrapX bool) float64 {
best := math.Inf(1)
for sy := 0; sy < h; sy++ {
for sx := 0; sx < w; sx++ {
if !seeds[sy*w+sx] {
continue
}
dx := float64(x - sx)
if wrapX {
if d := math.Abs(dx); d > float64(w)/2 {
dx = float64(w) - d
}
}
dy := float64(y - sy)
if d := dx*dx + dy*dy; d < best {
best = d
}
}
}
return best
}
func check(t *testing.T, w, h int, wrapX bool) {
t.Helper()
seeds := scatter(w, h, 99)
d2, near := Transform(seeds, w, h, wrapX)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
want := brute(seeds, w, h, x, y, wrapX)
i := y*w + x
if math.Abs(float64(d2[i])-want) > 1e-3 {
t.Fatalf("wrap=%v cell (%d,%d): d2 %g, brute force %g", wrapX, x, y, d2[i], want)
}
// The feature index must be a seed, and it must be one at exactly that distance.
n := int(near[i])
if n < 0 || !seeds[n] {
t.Fatalf("wrap=%v cell (%d,%d): nearest %d is not a seed", wrapX, x, y, n)
}
got := brute(onlyAt(w, h, n), w, h, x, y, wrapX)
if math.Abs(got-want) > 1e-3 {
t.Fatalf("wrap=%v cell (%d,%d): nearest seed %d is at %g, not %g", wrapX, x, y, n, got, want)
}
}
}
}
func onlyAt(w, h, i int) []bool {
s := make([]bool, w*h)
s[i] = true
return s
}
// The one test the coastal pass rests on. Everything there is written in terms of "how far is this cell from
// the waterline and which stretch does it belong to", so a distance transform that is subtly wrong would not
// fail loudly - it would put the shelf break in slightly the wrong place everywhere. The transform is exact,
// so the comparison is against an exhaustive search and the tolerance is float32 rounding.
func TestMatchesBruteForce(t *testing.T) { check(t, 41, 37, false) }
// And the same on a cylinder, which is what a planet is. The failure this catches is a shelf that stops dead
// at the seam.
func TestMatchesBruteForceOnACylinder(t *testing.T) { check(t, 41, 37, true) }
// A seed on one edge must be found from the other edge, and by the short way round.
func TestWrapFindsTheSeedAcrossTheSeam(t *testing.T) {
const w, h = 9, 3
seeds := make([]bool, w*h)
seeds[h/2*w+0] = true // one seed, at column 0 of the middle row
d2, near := Transform(seeds, w, h, true)
// Column 8 is one step from column 0 the short way round, eight steps the long way.
if got := d2[h/2*w+8]; math.Abs(float64(got)-1) > 1e-6 {
t.Errorf("d2 at column 8 = %g, want 1", got)
}
if got := near[h/2*w+8]; got != int32(h/2*w) {
t.Errorf("near at column 8 = %d, want %d", got, h/2*w)
}
// The far side of the cylinder is four steps away either way.
if got := d2[h/2*w+4]; math.Abs(float64(got)-16) > 1e-6 {
t.Errorf("d2 at column 4 = %g, want 16", got)
}
// Without the wrap the same grid gives eight.
d2f, _ := Transform(seeds, w, h, false)
if got := d2f[h/2*w+8]; math.Abs(float64(got)-64) > 1e-6 {
t.Errorf("unwrapped d2 at column 8 = %g, want 64", got)
}
}
func TestNoSeedsAtAll(t *testing.T) {
const w, h = 5, 4
seeds := make([]bool, w*h)
_, near := Transform(seeds, w, h, true)
for i, n := range near {
if n != -1 {
t.Fatalf("cell %d reports a nearest seed %d on an empty grid", i, n)
}
}
}
+17 -7
View File
@@ -20,7 +20,8 @@ import (
type DataMapOptions struct {
// Sea marks cells to render as flat water rather than data. Optional.
Sea []bool
// Size is the output side in pixels; the field is point-sampled down to it.
// Size is the output width in pixels; the field is point-sampled down to it and the height follows the
// field's own aspect.
Size int
// Log renders log10 of the value, for anything with a heavy tail — drainage area spans seven decades and
// is unreadable linearly.
@@ -64,9 +65,10 @@ func WriteDataMap(path string, f *Field, opt DataMapOptions) error {
span = 1
}
img := image.NewRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
sy := y * f.H / size
sizeH := aspectH(f, size)
img := image.NewRGBA(image.Rect(0, 0, size, sizeH))
for y := 0; y < sizeH; y++ {
sy := y * f.H / sizeH
for x := 0; x < size; x++ {
sx := x * f.W / size
i := sy*f.W + sx
@@ -123,9 +125,13 @@ func WriteBasinMap(path string, w, h int, receiver []int32, sea []bool, size int
}
}
img := image.NewRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
sy := y * h / size
sizeH := int(float64(size)*float64(h)/float64(w) + 0.5)
if sizeH < 1 {
sizeH = 1
}
img := image.NewRGBA(image.Rect(0, 0, size, sizeH))
for y := 0; y < sizeH; y++ {
sy := y * h / sizeH
for x := 0; x < size; x++ {
sx := x * w / size
i := sy*w + sx
@@ -191,6 +197,10 @@ func sampleStops(s [][3]float64, t float64) [3]float64 {
return [3]float64{a[0] + (b[0]-a[0])*u, a[1] + (b[1]-a[1])*u, a[2] + (b[2]-a[2])*u}
}
// HSV is exported because the region map colours its regions the same way the basin map colours its basins:
// a hash of the id straight to a hue, so neighbours get unrelated colours and a boundary is a hard edge.
func HSV(hue, sat, val float64) [3]float64 { return hsv(hue, sat, val) }
func hsv(hue, sat, val float64) [3]float64 {
h6 := hue * 6
i := int(h6)
+95 -10
View File
@@ -176,26 +176,111 @@ func (f *Field) Blur(passes int) *Field {
// starts and each writes only into its own, so the output is identical at any GOMAXPROCS. Every parallel
// loop in the generator goes through here; none spawns goroutines of its own.
func Rows(h int, fn func(y0, y1 int)) {
workers := runtime.GOMAXPROCS(0)
if workers > h {
workers = h
}
if workers <= 1 {
fn(0, h)
RowsIndexed(h, func(_, y0, y1 int) { fn(y0, y1) })
}
// RowsIndexed is Rows with the band number, which is what a parallel loop needs when it has to reduce
// something rather than only write into its own rows: it gives each goroutine a pre-allocated indexed
// slot to accumulate into, so the reduction can be replayed in band order afterwards instead of
// depending on which goroutine finished first. Size the slots with BandCount.
func RowsIndexed(h int, fn func(band, y0, y1 int)) {
step := rowStep(h)
if step >= h {
fn(0, 0, h)
return
}
var wg sync.WaitGroup
step := (h + workers - 1) / workers
band := 0
for y0 := 0; y0 < h; y0 += step {
y1 := y0 + step
if y1 > h {
y1 = h
}
wg.Add(1)
go func(a, b int) {
go func(k, a, b int) {
defer wg.Done()
fn(a, b)
}(y0, y1)
fn(k, a, b)
}(band, y0, y1)
band++
}
wg.Wait()
}
// FixedBands is RowsIndexed with a partition that does not depend on the core count: bands of exactly rows
// rows, run by however many workers there are.
//
// It exists for one reason. A parallel loop that only writes into its own rows can be partitioned any way at
// all, which is what Rows does. A loop that *reduces* into overlapping buffers cannot: floating-point addition
// is not associative, so summing a cell's contributions in a different grouping gives a different last bit,
// and the result would depend on GOMAXPROCS. The particle pass is that loop. Fix the partition and the
// arithmetic is fixed with it.
func FixedBands(h, rows int, fn func(band, y0, y1 int)) {
if rows < 1 {
rows = 1
}
n := FixedBandCount(h, rows)
workers := runtime.GOMAXPROCS(0)
if workers > n {
workers = n
}
if workers <= 1 {
for b := 0; b < n; b++ {
y0 := b * rows
y1 := min(y0+rows, h)
fn(b, y0, y1)
}
return
}
next := make(chan int)
go func() {
for b := 0; b < n; b++ {
next <- b
}
close(next)
}()
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for b := range next {
y0 := b * rows
y1 := min(y0+rows, h)
fn(b, y0, y1)
}
}()
}
wg.Wait()
}
// FixedBandCount is how many bands FixedBands will make.
func FixedBandCount(h, rows int) int {
if rows < 1 {
rows = 1
}
if h <= 0 {
return 0
}
return (h + rows - 1) / rows
}
// BandCount is how many ranges Rows and RowsIndexed split h into. It is fixed by h and GOMAXPROCS, so it
// can be called to size a reduction before the loop starts.
func BandCount(h int) int {
step := rowStep(h)
if step >= h {
return 1
}
return (h + step - 1) / step
}
func rowStep(h int) int {
workers := runtime.GOMAXPROCS(0)
if workers > h {
workers = h
}
if workers <= 1 {
return h
}
return (h + workers - 1) / workers
}
+271
View File
@@ -0,0 +1,271 @@
package field
import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
)
// Palette is how a preview is drawn: the hypsometric ramp, the water, the rivers, the ice and the light.
//
// It is a file rather than a set of constants because it is the one part of a bake that is purely a matter of
// taste, and taste is the thing most likely to want swapping. Nothing in it changes a height; a palette is
// read only by WritePreview, and two bakes of the same world under two palettes are the same terrain.
//
// The zero value is not usable - use DefaultPalette, which holds the numbers the generator shipped with.
type Palette struct {
// LandStops is the hypsometric ramp, from sea level at t = 0 to the top of the land at t = 1. Stops must
// be in ascending t; the ends are clamped rather than extrapolated.
LandStops []Stop `json:"land_stops"`
SeaShallow RGB `json:"sea_shallow"`
SeaDeep RGB `json:"sea_deep"`
River RGB `json:"river"`
// Ice is drawn wherever the snow mask says so, whatever height the ground is. Pure white is a poor
// choice: it has nowhere left to go under the hillshade, so an ice sheet comes out as a flat cut-out
// with no shape in it.
Ice RGB `json:"ice"`
// LandTopPercentile is where the ramp's top is taken from, over land elevations. Not the maximum: one
// 2800 m summit over a continent whose land is mostly under 300 m puts every other cell in the bottom
// tenth of the ramp, and the map then says far more about one pixel than about the terrain.
LandTopPercentile float64 `json:"land_top_percentile"`
// LandTopM puts the top of the ramp at a fixed height instead, in metres above sea level. Zero keeps
// the percentile, which is what every preview did before this existed.
//
// It is here because a relative ramp is a picture that lies about scale, and it lies hardest exactly
// where it matters. A lowland continent 47 m high, drawn against its own 99.5th percentile, gets the
// whole ramp - green, tan, bare rock and snow - so its 40 m hills come out with the same white caps a
// 2800 m range would, and a plain whose median slope is 0.6 degrees reads as an alpine massif. That was
// measured on this planet's central landmass and it is the most misleading thing the generator draws.
//
// The percentile stays the default all the same, because the alternative fails the other way: an
// absolute ramp over a world with no mountains is a flat green shape with nothing legible on it, and
// judging "is there drainage here" needs the contrast. What is added is the *choice*, plus a line in
// the run summary saying which ceiling a picture was drawn against - a relative picture is fine as long
// as nobody reads it as an absolute one.
LandTopM float64 `json:"land_top_m"`
// The light. Azimuth is degrees clockwise from north and altitude is degrees above the horizon; the
// north-west at 45 degrees is the convention every DEM hillshade uses and is what these default to.
SunAzimuthDeg float64 `json:"sun_azimuth_deg"`
SunAltitudeDeg float64 `json:"sun_altitude_deg"`
// Ambient is how lit the fully shaded side is and Gain how much the lit side brightens. Ambient at zero
// makes a shadow a hole.
Ambient float64 `json:"ambient"`
Gain float64 `json:"gain"`
}
// Stop is one entry in the hypsometric ramp.
type Stop struct {
T float64 `json:"t"`
RGB RGB `json:"rgb"`
}
// RGB is a colour in 0..255, kept as float64 so the hillshade can multiply it before it is clamped.
type RGB [3]float64
func (c RGB) String() string { return fmt.Sprintf("[%s, %s, %s]", num(c[0]), num(c[1]), num(c[2])) }
// num prints a float without trailing zeros, so a palette reads as numbers rather than as measurements.
func num(v float64) string { return strconv.FormatFloat(v, 'g', -1, 64) }
// DefaultPalette is what the generator ships with: salt-marsh green at sea level through farmland and rock to
// snow, with the stops chosen so the lowland does not read as one flat colour - which is where most of a map
// is, and where a badly chosen ramp hides everything.
func DefaultPalette() *Palette {
return &Palette{
LandStops: []Stop{
{0.00, RGB{72, 106, 68}},
{0.08, RGB{104, 132, 74}},
{0.20, RGB{142, 152, 88}},
{0.38, RGB{164, 148, 104}},
{0.58, RGB{150, 128, 106}},
{0.75, RGB{138, 130, 128}},
{0.88, RGB{176, 174, 174}},
{1.00, RGB{246, 246, 250}},
},
SeaShallow: RGB{56, 104, 136},
SeaDeep: RGB{18, 40, 72},
River: RGB{70, 132, 180},
Ice: RGB{232, 238, 245},
LandTopPercentile: 99.5,
SunAzimuthDeg: 315, // north-west
SunAltitudeDeg: 45,
Ambient: 0.45,
Gain: 0.75,
}
}
// LoadPalette reads a palette, filling anything the file leaves out from the default. Unknown keys are an
// error: a misspelt colour that silently keeps the default is a palette that does not do what it says.
func LoadPalette(path string) (*Palette, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
clean, err := StripJSONComments(raw)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
p := DefaultPalette()
dec := json.NewDecoder(strings.NewReader(string(clean)))
dec.DisallowUnknownFields()
if err := dec.Decode(p); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if err := p.Validate(); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return p, nil
}
// Validate refuses a palette that would draw nonsense.
func (p *Palette) Validate() error {
if len(p.LandStops) < 2 {
return fmt.Errorf("land_stops needs at least two entries, got %d", len(p.LandStops))
}
for i, s := range p.LandStops {
if i > 0 && s.T <= p.LandStops[i-1].T {
return fmt.Errorf("land_stops must ascend: stop %d is at t %.3f, after %.3f",
i, s.T, p.LandStops[i-1].T)
}
for k, v := range s.RGB {
if v < 0 || v > 255 {
return fmt.Errorf("land_stops[%d].rgb[%d] is %v, outside 0..255", i, k, v)
}
}
}
if p.LandTopPercentile <= 0 || p.LandTopPercentile > 100 {
return fmt.Errorf("land_top_percentile is %v, outside 0..100", p.LandTopPercentile)
}
if p.LandTopM < 0 {
return fmt.Errorf("land_top_m is %v; it is metres above sea level, so positive, or zero to use "+
"land_top_percentile instead", p.LandTopM)
}
if p.SunAltitudeDeg <= 0 || p.SunAltitudeDeg >= 90 {
return fmt.Errorf("sun_altitude_deg is %v; it is degrees above the horizon", p.SunAltitudeDeg)
}
if p.Ambient < 0 || p.Ambient > 1 {
return fmt.Errorf("ambient is %v, outside 0..1", p.Ambient)
}
if p.Gain < 0 {
return fmt.Errorf("gain is %v", p.Gain)
}
return nil
}
// ramp samples the hypsometric stops, clamped at both ends.
func (p *Palette) ramp(t float64) RGB {
if t <= p.LandStops[0].T {
return p.LandStops[0].RGB
}
for i := 1; i < len(p.LandStops); i++ {
if t <= p.LandStops[i].T {
a, b := p.LandStops[i-1], p.LandStops[i]
u := (t - a.T) / (b.T - a.T)
return RGB{
a.RGB[0] + (b.RGB[0]-a.RGB[0])*u,
a.RGB[1] + (b.RGB[1]-a.RGB[1])*u,
a.RGB[2] + (b.RGB[2]-a.RGB[2])*u,
}
}
}
return p.LandStops[len(p.LandStops)-1].RGB
}
// Write saves a palette as something a person can read and edit.
//
// Hand-formatted rather than through MarshalIndent, and that is not stubbornness: MarshalIndent re-indents
// whatever a custom marshaler returns, so there is no way to keep a colour on one line through it, and its
// default puts every channel of every stop on a line of its own - sixty lines for eight stops, a table whose
// shape is invisible. The comments are the other half: this is a file somebody opens to change one number,
// and it should say what the numbers are.
func (p *Palette) Write(path string) error {
var b strings.Builder
line := func(format string, a ...any) { fmt.Fprintf(&b, format+"\n", a...) }
line("{")
line(` "_comment": "How a preview is drawn. Nothing here changes a height - two bakes of the same ` +
`world under two palettes are the same terrain. Point a planet manifest at this file with ` +
`\"palette\": \"<path relative to the manifest>\"; leave it out and these numbers are used ` +
`anyway. Keys beginning with an underscore are comments.",`)
line("")
line(` "_comment_land_stops": "The hypsometric ramp: sea level at t 0 to the top of the land at t 1. ` +
`The top is a percentile rather than the maximum, so one high summit cannot push a whole continent ` +
`into the bottom of the ramp.",`)
line(` "land_stops": [`)
for i, s := range p.LandStops {
comma := ","
if i == len(p.LandStops)-1 {
comma = ""
}
line(` { "t": %-6s "rgb": %s }%s`, num(s.T)+",", s.RGB, comma)
}
line(" ],")
line("")
line(` "sea_shallow": %s,`, p.SeaShallow)
line(` "sea_deep": %s,`, p.SeaDeep)
line(` "river": %s,`, p.River)
line(` "_comment_ice": "Drawn wherever a class is marked snow, whatever height the ground stands at. ` +
`Not pure white: white has nowhere left to go under the hillshade, so an ice sheet comes out as a ` +
`flat cut-out with no shape in it at all.",`)
line(` "ice": %s,`, p.Ice)
line("")
line(` "_comment_top": "Where the top of the hypsometric ramp sits. The percentile is relative to the ` +
`world being drawn, which is the only way a low continent is legible at all and is also a picture ` +
`that lies about scale: a 47 m lowland gets the same rock and snow a 2800 m range would. Set ` +
`land_top_m to a height in metres for an absolute ramp instead; the run summary says which ceiling ` +
`every preview was drawn against.",`)
line(` "land_top_percentile": %s,`, num(p.LandTopPercentile))
line(` "land_top_m": %s,`, num(p.LandTopM))
line("")
line(` "_comment_light": "Azimuth is degrees clockwise from north and altitude degrees above the ` +
`horizon; north-west at 45 is what every DEM hillshade uses. Ambient is how lit the shaded side ` +
`is - at zero a shadow is a hole - and gain how much the lit side brightens.",`)
line(` "sun_azimuth_deg": %s,`, num(p.SunAzimuthDeg))
line(` "sun_altitude_deg": %s,`, num(p.SunAltitudeDeg))
line(` "ambient": %s,`, num(p.Ambient))
line(` "gain": %s`, num(p.Gain))
line("}")
return os.WriteFile(path, []byte(b.String()), 0o644)
}
// StripJSONComments removes every object key beginning with an underscore, at any depth.
//
// Every manifest in this repository carries its commentary that way, and a loader that refuses unknown
// fields - which both the legend and the palette do, because a misspelt key silently ignored is a setting
// that does not do what it says - has to let them through.
func StripJSONComments(data []byte) ([]byte, error) {
var v any
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return json.Marshal(stripUnderscored(v))
}
func stripUnderscored(v any) any {
switch t := v.(type) {
case map[string]any:
out := make(map[string]any, len(t))
for k, val := range t {
if strings.HasPrefix(k, "_") {
continue
}
out[k] = stripUnderscored(val)
}
return out
case []any:
for i := range t {
t[i] = stripUnderscored(t[i])
}
return t
default:
return v
}
}
+89 -4
View File
@@ -7,6 +7,7 @@ import (
"image"
"image/png"
"io"
"math"
"os"
"path/filepath"
)
@@ -45,6 +46,48 @@ func WriteGray8(path string, w, h int, values []uint8, level png.CompressionLeve
return encode(path, img, level)
}
// WriteRGB writes tightly packed 8-bit RGB, three bytes a pixel. It is what a categorical map wants: a class
// raster has no ramp to run through a palette, only a colour per class.
// WriteRGBA writes colour with a separate alpha plane, which is what an overlay sheet is: strokes on a
// transparent background, where blank is decided by alpha rather than by a reserved colour.
//
// Non-premultiplied (NRGBA), deliberately. A mark's colour has to come back out of the file exactly as it
// went in, because the classifier reads exact colours against a tolerance; premultiplying would scale every
// channel by the alpha and round on the way, and a mark would classify as something else or as nothing.
func WriteRGBA(path string, w, h int, px []uint8, alpha []uint8, level png.CompressionLevel) error {
if len(px) != w*h*3 {
return fmt.Errorf("%s: %d bytes for a %dx%d RGB image", path, len(px), w, h)
}
if len(alpha) != w*h {
return fmt.Errorf("%s: %d alpha bytes for a %dx%d image", path, len(alpha), w, h)
}
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
row := img.Pix[y*img.Stride:]
src := px[y*w*3:]
a := alpha[y*w:]
for x := 0; x < w; x++ {
row[x*4], row[x*4+1], row[x*4+2], row[x*4+3] = src[x*3], src[x*3+1], src[x*3+2], a[x]
}
}
return encode(path, img, level)
}
func WriteRGB(path string, w, h int, px []uint8, level png.CompressionLevel) error {
if len(px) != w*h*3 {
return fmt.Errorf("%s: %d bytes for a %dx%d RGB image", path, len(px), w, h)
}
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
row := img.Pix[y*img.Stride:]
src := px[y*w*3:]
for x := 0; x < w; x++ {
row[x*4], row[x*4+1], row[x*4+2], row[x*4+3] = src[x*3], src[x*3+1], src[x*3+2], 255
}
}
return encode(path, img, level)
}
func encode(path string, img image.Image, level png.CompressionLevel) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
@@ -163,18 +206,60 @@ func isqrt(n int) int {
return r
}
// WriteHillshade writes an 8-bit relief shade of a height field, at any scale.
//
// It is not WriteThumbnail with a bigger number, and the difference is the whole reason it exists.
// WriteThumbnail's shading term is a raw gradient over the cell size, which is fine on a 512-pixel picture of
// a whole map where the gradients are small, and saturates to pure black and white the moment it is used on
// real ground at two metres a cell. The result reads as flat-topped terraces with hard edges - a mountainside
// rendered as a staircase - and it is convincing enough to be mistaken for a defect in the terrain. It was.
//
// This is the standard DEM hillshade instead: the surface normal against a light from the north-west at 45
// degrees, which is bounded by construction and says the same thing at any cell size.
func WriteHillshade(path string, h *Field, size int, exaggeration float64) error {
sizeH := aspectH(h, size)
small := h
if size != h.W || sizeH != h.H {
small = h.Resample(size, sizeH)
}
exag := exaggeration
if exag <= 0 {
exag = 1
}
px := make([]uint8, size*sizeH)
Rows(sizeH, func(y0, y1 int) {
for y := y0; y < y1; y++ {
for x := 0; x < size; x++ {
gx := float64(small.AtClamped(x+1, y)-small.AtClamped(x-1, y)) * exag
gy := float64(small.AtClamped(x, y+1)-small.AtClamped(x, y-1)) * exag
slope := math.Atan(math.Hypot(gx, gy) / (2 * small.CellM))
aspect := math.Atan2(gy, -gx)
lum := math.Cos(slope)*math.Cos(math.Pi/4) +
math.Sin(slope)*math.Sin(math.Pi/4)*math.Cos(3*math.Pi/4-aspect)
v := 0.25 + 0.75*math.Max(0, lum)
if v > 1 {
v = 1
}
px[y*size+x] = uint8(v * 255)
}
}
})
return WriteGray8(path, size, sizeH, px, png.BestSpeed)
}
// WriteThumbnail writes a small 8-bit preview of a height field, hillshaded so the drainage is actually
// visible: a flat grey ramp hides exactly the thing this generator exists to produce.
func WriteThumbnail(path string, h *Field, size int) error {
small := h.Resample(size, size)
sizeH := aspectH(h, size)
small := h.Resample(size, sizeH)
lo, hi := small.MinMax()
span := float64(hi - lo)
if span < 1e-6 {
span = 1
}
// Light from the north-west at 45 degrees, the convention every DEM hillshade uses.
px := make([]uint8, size*size)
for y := 0; y < size; y++ {
px := make([]uint8, size*sizeH)
for y := 0; y < sizeH; y++ {
for x := 0; x < size; x++ {
gx := float64(small.AtClamped(x+1, y) - small.AtClamped(x-1, y))
gy := float64(small.AtClamped(x, y+1) - small.AtClamped(x, y-1))
@@ -189,7 +274,7 @@ func WriteThumbnail(path string, h *Field, size int) error {
px[y*size+x] = uint8(lum * 255)
}
}
return WriteGray8(path, size, size, px, png.BestSpeed)
return WriteGray8(path, size, sizeH, px, png.BestSpeed)
}
var _ io.Writer = (*bufio.Writer)(nil)
+78 -71
View File
@@ -24,65 +24,39 @@ type PreviewOptions struct {
// Sea marks cells below sea level. Optional.
Sea []bool
SeaLevelM float64
// Snow marks land that is permanently under ice. Optional, and it exists because the hypsometric ramp
// tops out at snow by *elevation*: a polar cap fifty metres above the water therefore comes out the same
// green as a meadow, and an ice sheet that reads as a meadow is a map lying about the one thing it is
// for. No height is touched; only the colour.
Snow []bool
// RiverKm2 is the drainage area at which a channel starts being drawn.
RiverKm2 float64
Size int
// Size is the output width in pixels. The height follows the field's own aspect, so a 2:1 planet comes
// out 2:1 rather than squashed into a square; on the square canvas the two are the same number and
// nothing changes.
Size int
// Crop is a sub-rectangle in map coordinates (x0, y0, x1, y1 in 0..1), rendered at full resolution.
// A whole continent at 1500 px puts ten kilometres into a hundred pixels, which is enough to see that
// there is drainage and not nearly enough to see whether it is the right *kind* of drainage. Judging
// hill country against real hill country needs a crop.
Crop [4]float64
// Palette is how the picture is drawn: the ramp, the water, the rivers, the ice and the light. Nil is
// the generator's own, which is what every caller wanted before this was a file.
Palette *Palette
// Hillshade exaggerates the vertical before shading. Lowland relief is a few tens of metres over
// kilometres and disappears at true scale, which is the same reason every printed relief map lies.
Exaggeration float64
}
// rgb is a colour in 0..255 kept as float64 so the hillshade can multiply it before it is clamped.
type rgb = [3]float64
type stop struct {
t float64
c rgb
}
var (
// A hypsometric ramp: salt-marsh green at sea level through farmland and rock to snow. Stops are chosen
// so the lowland does not read as one flat colour, which is where most of the map is.
landStops = []stop{
{0.00, rgb{72, 106, 68}},
{0.08, rgb{104, 132, 74}},
{0.20, rgb{142, 152, 88}},
{0.38, rgb{164, 148, 104}},
{0.58, rgb{150, 128, 106}},
{0.75, rgb{138, 130, 128}},
{0.88, rgb{176, 174, 174}},
{1.00, rgb{246, 246, 250}},
}
seaShallow = rgb{56, 104, 136}
seaDeep = rgb{18, 40, 72}
riverTint = rgb{70, 132, 180}
)
func ramp(t float64) rgb {
if t <= 0 {
return landStops[0].c
}
for i := 1; i < len(landStops); i++ {
if t <= landStops[i].t {
a, b := landStops[i-1], landStops[i]
u := (t - a.t) / (b.t - a.t)
return rgb{
a.c[0] + (b.c[0]-a.c[0])*u,
a.c[1] + (b.c[1]-a.c[1])*u,
a.c[2] + (b.c[2]-a.c[2])*u,
}
}
}
return landStops[len(landStops)-1].c
}
// rgb is the palette's colour type under the name the drawing code uses.
type rgb = RGB
// WritePreview renders the field at opt.Size and writes an RGB PNG.
func WritePreview(path string, h *Field, opt PreviewOptions) error {
//
// It returns the height the hypsometric ramp topped out at, in metres, which a caller is expected to print.
// The ramp is relative by default and a relative picture is only honest when the reader is told so: without
// that line, a 47 m plain drawn with snow on its hills is indistinguishable from an alpine one.
func WritePreview(path string, h *Field, opt PreviewOptions) (topM float64, err error) {
size := opt.Size
if size <= 0 {
size = 1024
@@ -103,7 +77,8 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
size = h.W
}
}
small := h.Resample(size, size)
sizeH := aspectH(h, size)
small := h.Resample(size, sizeH)
exag := opt.Exaggeration
if exag <= 0 {
exag = 1
@@ -116,7 +91,12 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
// uniform green with a white dot on it — which says far more about one pixel than about the terrain. The
// percentile lets the tint span the distribution that is actually there; the few cells above it clamp to
// snow, which is what they should look like anyway.
sea := resampleMask(opt.Sea, h.W, h.H, size)
pal := opt.Palette
if pal == nil {
pal = DefaultPalette()
}
sea := resampleMask(opt.Sea, h.W, h.H, size, sizeH)
snow := resampleMask(opt.Snow, h.W, h.H, size, sizeH)
landVals := make([]float64, 0, len(small.Data))
for i, v := range small.Data {
if sea != nil && sea[i] {
@@ -125,9 +105,11 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
landVals = append(landVals, float64(v))
}
landMax := 1.0
if len(landVals) > 0 {
if pal.LandTopM > 0 {
landMax = pal.LandTopM
} else if len(landVals) > 0 {
sort.Float64s(landVals)
landMax = landVals[int(0.995*float64(len(landVals)-1))]
landMax = landVals[int(pal.LandTopPercentile/100*float64(len(landVals)-1))]
}
if landMax <= 0 {
landMax = 1
@@ -142,11 +124,11 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
var flow *Field
riverA := opt.RiverKm2 * 1e6
if opt.Flow != nil && riverA > 0 {
flow = opt.Flow.Resample(size, size)
flow = opt.Flow.Resample(size, sizeH)
}
img := image.NewRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
img := image.NewRGBA(image.Rect(0, 0, size, sizeH))
for y := 0; y < sizeH; y++ {
for x := 0; x < size; x++ {
i := y*size + x
elev := float64(small.Data[i])
@@ -158,21 +140,30 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
d = math.Min(1, (opt.SeaLevelM-elev)/(opt.SeaLevelM-seaMin))
}
c = rgb{
seaShallow[0] + (seaDeep[0]-seaShallow[0])*d,
seaShallow[1] + (seaDeep[1]-seaShallow[1])*d,
seaShallow[2] + (seaDeep[2]-seaShallow[2])*d,
pal.SeaShallow[0] + (pal.SeaDeep[0]-pal.SeaShallow[0])*d,
pal.SeaShallow[1] + (pal.SeaDeep[1]-pal.SeaShallow[1])*d,
pal.SeaShallow[2] + (pal.SeaDeep[2]-pal.SeaShallow[2])*d,
}
} else {
c = ramp(math.Min(1, math.Max(0, elev)/landMax))
c = pal.ramp(math.Min(1, math.Max(0, elev)/landMax))
if snow != nil && snow[i] {
// Ice, whatever height it stands at. It still takes the hillshade below rather than
// being stamped flat, so a dome and the valleys cut into it still read.
c = pal.Ice
}
// Hillshade from the north-west at 45 degrees, the DEM convention. Applied to land only;
// shading the sea floor would draw attention to bathymetry nobody will ever see.
gx := float64(small.AtClamped(x+1, y)-small.AtClamped(x-1, y)) * exag
gy := float64(small.AtClamped(x, y+1)-small.AtClamped(x, y-1)) * exag
slope := math.Atan(math.Hypot(gx, gy) / (2 * small.CellM))
aspect := math.Atan2(gy, -gx)
lum := math.Cos(slope)*math.Cos(math.Pi/4) +
math.Sin(slope)*math.Sin(math.Pi/4)*math.Cos(3*math.Pi/4-aspect)
lum = 0.45 + 0.75*math.Max(0, lum)
alt := pal.SunAltitudeDeg * math.Pi / 180
// Azimuth is clockwise from north; the shading wants the direction the light comes *from*
// measured the way Atan2 returns it, which is this quarter turn away.
az := (90 - pal.SunAzimuthDeg) * math.Pi / 180
lum := math.Cos(slope)*math.Sin(alt) +
math.Sin(slope)*math.Cos(alt)*math.Cos(az-aspect)
lum = pal.Ambient + pal.Gain*math.Max(0, lum)
for k := range c {
c[k] *= lum
}
@@ -185,7 +176,7 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
w := math.Min(1, math.Log10(a/riverA)/2.2)
blend := 0.45 + 0.55*w
for k := range c {
c[k] = c[k]*(1-blend) + riverTint[k]*blend
c[k] = c[k]*(1-blend) + pal.River[k]*blend
}
}
}
@@ -195,38 +186,54 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
return landMax, err
}
f, err := os.Create(path)
if err != nil {
return err
return landMax, err
}
defer f.Close()
bw := bufio.NewWriterSize(f, 1<<20)
enc := png.Encoder{CompressionLevel: png.DefaultCompression}
if err := enc.Encode(bw, img); err != nil {
return err
return landMax, err
}
return bw.Flush()
return landMax, bw.Flush()
}
// resampleMask takes a boolean mask down to the preview size by nearest neighbour; a mask has no meaningful
// average.
func resampleMask(mask []bool, w, h, size int) []bool {
func resampleMask(mask []bool, w, h, sw, sh int) []bool {
if mask == nil {
return nil
}
out := make([]bool, size*size)
for y := 0; y < size; y++ {
sy := y * (h - 1) / (size - 1)
for x := 0; x < size; x++ {
sx := x * (w - 1) / (size - 1)
out[y*size+x] = mask[sy*w+sx]
out := make([]bool, sw*sh)
for y := 0; y < sh; y++ {
sy := 0
if sh > 1 {
sy = y * (h - 1) / (sh - 1)
}
for x := 0; x < sw; x++ {
sx := 0
if sw > 1 {
sx = x * (w - 1) / (sw - 1)
}
out[y*sw+x] = mask[sy*w+sx]
}
}
return out
}
// aspectH is the output height that keeps a field's shape. Every writer in this package uses it, so a
// rectangular world is never silently squashed into a square image.
func aspectH(f *Field, w int) int {
h := int(float64(w)*float64(f.H)/float64(f.W) + 0.5)
if h < 1 {
h = 1
}
return h
}
func clamp8(v float64) uint8 {
if v <= 0 {
return 0
@@ -0,0 +1,291 @@
package field
import (
"image"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
)
// The hypsometric ramp tops out at snow by *elevation*, so an ice cap fifty metres above the water came out
// the same green as a meadow - a map lying about the one thing it is for. The snow mask fixes the colour and
// nothing else, and it must still take the hillshade rather than being stamped flat, or a dome and the
// valleys cut into it read as a white cut-out.
func TestSnowRendersAsIceAndStillTakesTheHillshade(t *testing.T) {
// The ramp's top is the 99.5th percentile of *land* elevation, so the ice cap only reads as meadow when
// there is real high ground on the map to set that percentile. A cap alone on an empty map is the highest
// thing there is and the ramp would call it snow anyway - which is how the first version of this test
// managed to pass for the wrong reason.
const w, h = 96, 64
f := New(w, h, 8)
sea := make([]bool, w*h)
snow := make([]bool, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
d2 := float64((x-24)*(x-24) + (y-32)*(y-32))
v := 40 - d2/60 // a low ice dome on the left
if x > 56 {
// and a 700 m range on the right, which is what sets the top of the ramp
v = 700 - float64((x-76)*(x-76)+(y-32)*(y-32))*0.7
}
if v < 0 {
v = 0
sea[i] = true
}
f.Data[i] = float32(v)
snow[i] = !sea[i] && x <= 56
}
}
dir := t.TempDir()
plain := filepath.Join(dir, "plain.png")
iced := filepath.Join(dir, "iced.png")
opt := PreviewOptions{Sea: sea, SeaLevelM: 0, Size: w}
if _, err := WritePreview(plain, f, opt); err != nil {
t.Fatal(err)
}
opt.Snow = snow
if _, err := WritePreview(iced, f, opt); err != nil {
t.Fatal(err)
}
a, b := readRGBA(t, plain), readRGBA(t, iced)
cx, cy := 24, 32 // the ice dome's summit
pr, pg, pb, _ := a.At(cx, cy).RGBA()
sr, sg, sb, _ := b.At(cx, cy).RGBA()
t.Logf("land at the summit: plain rgb(%d,%d,%d), iced rgb(%d,%d,%d)",
pr>>8, pg>>8, pb>>8, sr>>8, sg>>8, sb>>8)
// Ice is much lighter than the ramp's low-ground green, and it is not green: blue is at least green.
if sr <= pr || sb <= pb {
t.Errorf("the iced summit is not lighter than the plain one")
}
if sb < sg {
t.Errorf("the ice reads green (b %d < g %d); it should be neutral to slightly blue", sb>>8, sg>>8)
}
// It still takes the hillshade: the lit and shaded flanks of the dome must differ.
lr, _, _, _ := b.At(cx-12, cy-12).RGBA() // north-west flank, towards the light
dr, _, _, _ := b.At(cx+12, cy+12).RGBA() // south-east flank, away from it
t.Logf("ice flanks: lit %d, shaded %d", lr>>8, dr>>8)
if lr <= dr {
t.Errorf("the ice is flat: lit flank %d against shaded %d, so it was stamped rather than shaded",
lr>>8, dr>>8)
}
// And the water is untouched. The probe has to be a cell that really is sea - the first version used the
// corner, which on this map is land, so it was comparing two ice pixels and calling the difference a bug.
sx, sy := -1, -1
for i, isSea := range sea {
if isSea {
sx, sy = i%w, i/w
break
}
}
if sx < 0 {
t.Fatal("the test terrain has no sea in it")
}
wr, wg, wb, _ := a.At(sx, sy).RGBA()
xr, xg, xb, _ := b.At(sx, sy).RGBA()
if wr != xr || wg != xg || wb != xb {
t.Errorf("the sea at %d,%d changed: rgb(%d,%d,%d) became rgb(%d,%d,%d); the mask should only touch land",
sx, sy, wr>>8, wg>>8, wb>>8, xr>>8, xg>>8, xb>>8)
}
}
func readRGBA(t *testing.T, path string) image.Image {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
t.Fatal(err)
}
return img
}
// A palette is a file somebody edits, so it has to survive the trip to disk and back unchanged - and it has
// to keep the comments the writer puts in, because a loader that refuses unknown keys would otherwise choke
// on its own output.
func TestPaletteRoundTripsThroughDiskWithItsComments(t *testing.T) {
path := filepath.Join(t.TempDir(), "p.json")
want := DefaultPalette()
if err := want.Write(path); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "_comment") {
t.Error("the written palette carries no commentary")
}
got, err := LoadPalette(path)
if err != nil {
t.Fatalf("reading back what Write produced: %v", err)
}
if len(got.LandStops) != len(want.LandStops) {
t.Fatalf("%d stops, want %d", len(got.LandStops), len(want.LandStops))
}
for i := range want.LandStops {
if got.LandStops[i] != want.LandStops[i] {
t.Errorf("stop %d: %v, want %v", i, got.LandStops[i], want.LandStops[i])
}
}
if got.Ice != want.Ice || got.SeaDeep != want.SeaDeep || got.River != want.River {
t.Errorf("colours differ: %v %v %v", got.Ice, got.SeaDeep, got.River)
}
if got.SunAzimuthDeg != want.SunAzimuthDeg || got.Ambient != want.Ambient {
t.Errorf("light differs: %v %v", got.SunAzimuthDeg, got.Ambient)
}
}
// A palette fills what it leaves out from the default, so a two-line file is a valid one.
func TestAPartialPaletteKeepsTheDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "p.json")
if err := os.WriteFile(path, []byte(`{"_why": "just the sea", "sea_deep": [1, 2, 3]}`), 0o644); err != nil {
t.Fatal(err)
}
got, err := LoadPalette(path)
if err != nil {
t.Fatal(err)
}
if got.SeaDeep != (RGB{1, 2, 3}) {
t.Errorf("sea_deep = %v, want the file's", got.SeaDeep)
}
if got.Ice != DefaultPalette().Ice {
t.Errorf("ice = %v, want the default", got.Ice)
}
}
// And a misspelt key is an error rather than a setting that silently does nothing.
func TestAMisspeltPaletteKeyIsRefused(t *testing.T) {
path := filepath.Join(t.TempDir(), "p.json")
if err := os.WriteFile(path, []byte(`{"sea_dep": [1,2,3]}`), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadPalette(path); err == nil {
t.Fatal("accepted a misspelt key")
}
}
// The palette actually reaches the picture: swapping the sea colour changes the sea.
func TestThePaletteIsWhatGetsDrawn(t *testing.T) {
const w, h = 32, 32
f := New(w, h, 8)
sea := make([]bool, w*h)
for i := range sea {
sea[i] = i%w < w/2
if !sea[i] {
f.Data[i] = 50
}
}
dir := t.TempDir()
a := filepath.Join(dir, "a.png")
b := filepath.Join(dir, "b.png")
if _, err := WritePreview(a, f, PreviewOptions{Sea: sea, Size: w}); err != nil {
t.Fatal(err)
}
pal := DefaultPalette()
pal.SeaShallow, pal.SeaDeep = RGB{255, 0, 0}, RGB{255, 0, 0}
if _, err := WritePreview(b, f, PreviewOptions{Sea: sea, Size: w, Palette: pal}); err != nil {
t.Fatal(err)
}
ar, ag, ab, _ := readRGBA(t, a).At(2, 2).RGBA()
br, bg, bb, _ := readRGBA(t, b).At(2, 2).RGBA()
if br>>8 != 255 || bg>>8 != 0 || bb>>8 != 0 {
t.Errorf("the sea is rgb(%d,%d,%d), want the palette's red", br>>8, bg>>8, bb>>8)
}
if ar == br && ag == bg && ab == bb {
t.Error("the palette changed nothing")
}
}
// The ramp is relative by default and that is a picture which lies about scale: a lowland continent 47 m high
// gets the same rock and snow a 2800 m range would, because the top of the ramp is a percentile of whatever
// world it is drawing. land_top_m is the way out, and the point of the test is that the two differ.
func TestAnAbsoluteRampDrawsALowContinentAsLowGround(t *testing.T) {
const w = 96
f := New(w, w, 10)
sea := make([]bool, w*w)
for y := 0; y < w; y++ {
for x := 0; x < w; x++ {
i := y*w + x
dx, dy := float64(x-w/2)/float64(w/2), float64(y-w/2)/float64(w/2)
d := dx*dx + dy*dy
if d > 0.8 {
sea[i] = true
f.Data[i] = -50
continue
}
// A 40 m hill on a continent, which is a plain by any reading.
f.Data[i] = float32(40 * (1 - d/0.8))
}
}
dir := t.TempDir()
rel := filepath.Join(dir, "relative.png")
abs := filepath.Join(dir, "absolute.png")
top, err := WritePreview(rel, f, PreviewOptions{Sea: sea, Size: w})
if err != nil {
t.Fatal(err)
}
if top > 45 {
t.Fatalf("the relative ramp should top out near the highest land, about 40 m; got %.1f", top)
}
pal := DefaultPalette()
pal.LandTopM = 2000
top, err = WritePreview(abs, f, PreviewOptions{Sea: sea, Size: w, Palette: pal})
if err != nil {
t.Fatal(err)
}
if top != 2000 {
t.Fatalf("an absolute ramp tops out where it is told: got %.1f, want 2000", top)
}
// And the pictures differ: the summit is high on the ramp in one and at the bottom of it in the other.
relTop := brightestLand(t, rel, sea, w)
absTop := brightestLand(t, abs, sea, w)
if relTop <= absTop {
t.Errorf("the relative picture should carry the summit far higher up the ramp: %d vs %d",
relTop, absTop)
}
}
// brightestLand is the highest luma any land pixel reached, which is how far up the hypsometric ramp the
// summit got: the ramp ends in near-white snow and starts in dark green.
func brightestLand(t *testing.T, path string, sea []bool, w int) int {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
t.Fatal(err)
}
best := 0
b := img.Bounds()
for y := 0; y < b.Dy(); y++ {
for x := 0; x < b.Dx(); x++ {
if sea[y*w+x] {
continue
}
r, g, bl, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
if v := int(r+g+bl) >> 8; v > best {
best = v
}
}
}
return best
}
+236
View File
@@ -0,0 +1,236 @@
package field
// SlidingMax is the maximum over a square window, separable and O(1) a cell whatever the radius.
//
// The naive form is a loop over the window, which is what internal/stats' localRelief used to do and is fine
// on the 500 m window it uses at 8 m cells - until the map is a planet. 28 million land cells times a 63-cell
// radius is 1.1e11 comparisons, which is not a slow diagnostic, it is one nobody will ever see the end of.
// The monotonic deque is the standard answer: each index enters and leaves once, so the row pass is linear in
// the row however wide the window.
//
// wrapX makes the row pass periodic, which is what a cylinder needs; the column pass always clamps, because
// the top and bottom of the map are the poles and not each other.
func SlidingMax(f *Field, radius int, wrapX bool) *Field {
return sliding(f, radius, wrapX, func(inDeque, arriving float32) bool { return inDeque <= arriving })
}
// SlidingMin is the same window, the other way up. The pair is what local relief is made of.
func SlidingMin(f *Field, radius int, wrapX bool) *Field {
return sliding(f, radius, wrapX, func(inDeque, arriving float32) bool { return inDeque >= arriving })
}
// LocalRelief is max minus min over a square window: the standard field measure of how rugged a place is, and
// the one thing slope cannot tell you. A 5 m hummock and a 500 m mountainside both stand at 30 degrees.
//
// Two sliding passes and a subtract, so it costs the same as one of them twice and nothing per radius. It
// holds two fields at once at the peak, which at planet scale is 600 MB - worth saying, because the naive
// version held none and could not finish.
func LocalRelief(f *Field, radius int, wrapX bool) *Field {
hi := SlidingMax(f, radius, wrapX)
lo := SlidingMin(f, radius, wrapX)
for i := range hi.Data {
hi.Data[i] -= lo.Data[i]
}
return hi
}
// sliding is the shared separable pass. keep reports whether the value already at the back of the deque can
// be dropped when a new one arrives, which is the only thing that differs between the maximum and the
// minimum: the deque holds indices whose values are monotone, so its front is always the answer for the live
// window and anything the arriving value dominates can never be the answer again.
func sliding(f *Field, radius int, wrapX bool, keep func(inDeque, arriving float32) bool) *Field {
if radius < 1 {
return f.Clone()
}
w, h := f.W, f.H
row := New(w, h, f.CellM)
buf := make([]float32, 0, w+2*radius)
idx := make([]int, 0, w+2*radius)
for y := 0; y < h; y++ {
// The row, extended by the radius at each end so the deque never has to special-case an edge.
buf = buf[:0]
for x := -radius; x < w+radius; x++ {
sx := x
if wrapX {
sx = ((sx % w) + w) % w
} else if sx < 0 {
sx = 0
} else if sx >= w {
sx = w - 1
}
buf = append(buf, f.Data[y*w+sx])
}
slide(buf, idx[:0], 2*radius+1, keep, func(i int, v float32) {
if i < w {
row.Data[y*w+i] = v
}
})
}
out := New(w, h, f.CellM)
col := make([]float32, 0, h+2*radius)
for x := 0; x < w; x++ {
col = col[:0]
for y := -radius; y < h+radius; y++ {
sy := y
if sy < 0 {
sy = 0
} else if sy >= h {
sy = h - 1
}
col = append(col, row.Data[sy*w+x])
}
slide(col, idx[:0], 2*radius+1, keep, func(i int, v float32) {
if i < h {
out.Data[i*w+x] = v
}
})
}
return out
}
// slide walks a padded line with a monotonic deque and reports the window's answer ending at each output
// position.
func slide(line []float32, dq []int, window int, keep func(inDeque, arriving float32) bool,
emit func(i int, v float32)) {
dq = dq[:0]
for i, v := range line {
for len(dq) > 0 && keep(line[dq[len(dq)-1]], v) {
dq = dq[:len(dq)-1]
}
dq = append(dq, i)
if dq[0] <= i-window {
dq = dq[1:]
}
if out := i - window + 1; out >= 0 {
emit(out, line[dq[0]])
}
}
}
// BoxSmooth blurs a field in place with `passes` of a separable box blur of the given radius, clamping at the
// edges. Two passes are near enough to a Gaussian for anything here and cost four linear sweeps.
//
// Deterministic by construction: fixed traversal order, running sums, no goroutines. It lives here rather than
// in the pass that first wanted it because two now do - the coastal detail pass smooths the signed distance to
// the shoreline, and the tile bake smooths the interpolated sea floor.
func BoxSmooth(data []float32, w, h, radius, passes int) {
if radius < 1 || passes < 1 || len(data) < w*h {
return
}
tmp := make([]float32, len(data))
for p := 0; p < passes; p++ {
boxRows(data, tmp, w, h, radius)
boxCols(tmp, data, w, h, radius)
}
}
func boxRows(src, dst []float32, w, h, radius int) {
n := float32(2*radius + 1)
for y := 0; y < h; y++ {
row := y * w
var sum float32
for k := -radius; k <= radius; k++ {
sum += src[row+clampIdx(k, w)]
}
for x := 0; x < w; x++ {
dst[row+x] = sum / n
sum += src[row+clampIdx(x+radius+1, w)] - src[row+clampIdx(x-radius, w)]
}
}
}
func boxCols(src, dst []float32, w, h, radius int) {
n := float32(2*radius + 1)
for x := 0; x < w; x++ {
var sum float32
for k := -radius; k <= radius; k++ {
sum += src[clampIdx(k, h)*w+x]
}
for y := 0; y < h; y++ {
dst[y*w+x] = sum / n
sum += src[clampIdx(y+radius+1, h)*w+x] - src[clampIdx(y-radius, h)*w+x]
}
}
}
func clampIdx(i, n int) int {
if i < 0 {
return 0
}
if i >= n {
return n - 1
}
return i
}
// BoxSmoothMasked is BoxSmooth restricted to the cells the mask selects: a cell outside it is neither read
// nor written, so the blur never averages across the boundary.
//
// That distinction is the whole reason it exists. The coastal detail pass damps the metre-scale texture near
// the shore, and an unmasked blur there does not damp texture, it bridges the waterline: measured on a
// fixture with forty metres of water against the land, the plain blur lifted the sea floor by twenty metres.
// The step at a shoreline is a landform, not roughness, and a filter that cannot tell them apart is the wrong
// filter.
//
// Separable and weighted: the row pass carries a running sum of values and of weights, the column pass sums
// those, and the quotient is the mean over the masked cells in the window. Deterministic, like BoxSmooth.
func BoxSmoothMasked(data []float32, mask []bool, w, h, radius, passes int) {
if radius < 1 || passes < 1 || len(data) < w*h || len(mask) < w*h {
return
}
n := w * h
val := make([]float32, n)
wgt := make([]float32, n)
tv := make([]float32, n)
tw := make([]float32, n)
for p := 0; p < passes; p++ {
for i := 0; i < n; i++ {
if mask[i] {
val[i], wgt[i] = data[i], 1
} else {
val[i], wgt[i] = 0, 0
}
}
boxRowsSum(val, tv, w, h, radius)
boxRowsSum(wgt, tw, w, h, radius)
boxColsSum(tv, val, w, h, radius)
boxColsSum(tw, wgt, w, h, radius)
for i := 0; i < n; i++ {
if mask[i] && wgt[i] > 0 {
data[i] = val[i] / wgt[i]
}
}
}
}
// boxRowsSum and boxColsSum are the running sums BoxSmooth uses, without the division: a masked blur needs
// the weight sum as well as the value sum, and dividing in the middle would be dividing by the wrong thing.
func boxRowsSum(src, dst []float32, w, h, radius int) {
for y := 0; y < h; y++ {
row := y * w
var sum float32
for k := -radius; k <= radius; k++ {
sum += src[row+clampIdx(k, w)]
}
for x := 0; x < w; x++ {
dst[row+x] = sum
sum += src[row+clampIdx(x+radius+1, w)] - src[row+clampIdx(x-radius, w)]
}
}
}
func boxColsSum(src, dst []float32, w, h, radius int) {
for x := 0; x < w; x++ {
var sum float32
for k := -radius; k <= radius; k++ {
sum += src[clampIdx(k, h)*w+x]
}
for y := 0; y < h; y++ {
dst[y*w+x] = sum
sum += src[clampIdx(y+radius+1, h)*w+x] - src[clampIdx(y-radius, h)*w+x]
}
}
}

Some files were not shown because too many files have changed in this diff Show More