Added: Initial world generation tool

This commit is contained in:
Rainer Leit
2026-09-17 17:55:48 +03:00
parent d64748f76f
commit cc43ed8dc8
2065 changed files with 23664 additions and 1011 deletions
+236
View File
@@ -0,0 +1,236 @@
package field
import (
"image"
"image/color"
"image/png"
"math"
"sort"
)
// False-colour maps of the fields a run works from, as opposed to the field it produces.
//
// preview.png answers "does this look like a landscape". These answer the question that comes next, which is
// "why does it look like that" — the uplift field, the erodibility, the slope and the basins are the inputs
// and the structure, and when a run comes out wrong it is almost always one of them that says so first. The
// uplift map in particular is the one that would have shown, without any arithmetic, that the plains were
// being raised at mountain rates.
// DataMapOptions controls one false-colour render.
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 int
// Log renders log10 of the value, for anything with a heavy tail — drainage area spans seven decades and
// is unreadable linearly.
Log bool
// Lo and Hi bound the colour ramp. Left at zero they are taken from the 1st and 99th percentile of the
// data, which keeps one outlier cell from flattening the whole image.
Lo, Hi float64
// Palette maps 0..1 to a colour. Nil is Viridis.
Palette func(float64) [3]float64
}
// WriteDataMap renders one scalar field as a false-colour PNG.
func WriteDataMap(path string, f *Field, opt DataMapOptions) error {
size := opt.Size
if size <= 0 || size > f.W {
size = f.W
}
pal := opt.Palette
if pal == nil {
pal = Viridis
}
vals := make([]float64, len(f.Data))
for i, v := range f.Data {
x := float64(v)
if opt.Log {
if x < 1 {
x = 1
}
x = math.Log10(x)
}
vals[i] = x
}
lo, hi := opt.Lo, opt.Hi
if lo == 0 && hi == 0 {
lo, hi = percentiles(vals, opt.Sea, 1, 99)
}
span := hi - lo
if span < 1e-12 {
span = 1
}
img := image.NewRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
sy := y * f.H / size
for x := 0; x < size; x++ {
sx := x * f.W / size
i := sy*f.W + sx
if opt.Sea != nil && opt.Sea[i] {
img.Set(x, y, color.RGBA{24, 44, 74, 255})
continue
}
t := (vals[i] - lo) / span
if t < 0 {
t = 0
} else if t > 1 {
t = 1
}
c := pal(t)
img.Set(x, y, color.RGBA{clamp8(c[0]), clamp8(c[1]), clamp8(c[2]), 255})
}
}
return encode(path, img, png.DefaultCompression)
}
// WriteBasinMap colours each drainage basin, which is the one picture that shows whether the solve produced a
// *network* rather than a set of scratches: real basins tile the land, meet along divides that sit where the
// two catchments either side put them, and come in a spread of sizes. A map of noisy speckle means the router
// is re-deciding where the water goes every few cells.
//
// receiver is the D8 receiver array; a cell whose receiver is itself is a basin root.
func WriteBasinMap(path string, w, h int, receiver []int32, sea []bool, size int) error {
if size <= 0 || size > w {
size = w
}
// Walk each cell down to its root with path compression, so the whole thing stays O(n).
root := make([]int32, w*h)
for i := range root {
root[i] = -1
}
var stack []int32
for i := range root {
if root[i] >= 0 {
continue
}
stack = stack[:0]
c := int32(i)
for root[c] < 0 && receiver[c] != c {
stack = append(stack, c)
c = receiver[c]
}
r := root[c]
if r < 0 {
r = c
root[c] = r
}
for _, s := range stack {
root[s] = r
}
}
img := image.NewRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
sy := y * h / size
for x := 0; x < size; x++ {
sx := x * w / size
i := sy*w + sx
if sea != nil && sea[i] {
img.Set(x, y, color.RGBA{24, 44, 74, 255})
continue
}
// A hash of the root id, so neighbouring basins get unrelated colours and a divide is a hard
// edge rather than a gradient.
k := uint64(uint32(root[i]))*0x9e3779b97f4a7c15 + 0x2545f4914f6cdd1d
k ^= k >> 29
k *= 0xbf58476d1ce4e5b9
k ^= k >> 32
c := hsv(float64(k%3600)/3600, 0.45+float64((k>>12)%40)/100, 0.55+float64((k>>24)%40)/100)
img.Set(x, y, color.RGBA{clamp8(c[0]), clamp8(c[1]), clamp8(c[2]), 255})
}
}
return encode(path, img, png.DefaultCompression)
}
// Viridis, sampled at sixteen stops. Perceptually uniform and legible in greyscale, which matters because
// these get pasted into notes and printed.
var viridisStops = [][3]float64{
{68, 1, 84}, {72, 26, 108}, {71, 47, 125}, {65, 68, 135},
{57, 86, 140}, {49, 104, 142}, {42, 120, 142}, {35, 136, 142},
{31, 152, 139}, {34, 168, 132}, {53, 183, 121}, {84, 197, 104},
{122, 209, 81}, {165, 219, 54}, {210, 226, 27}, {253, 231, 37},
}
func Viridis(t float64) [3]float64 { return sampleStops(viridisStops, t) }
// Inferno, for anything where "how much" reads better as heat: slope and local relief.
var infernoStops = [][3]float64{
{0, 0, 4}, {12, 8, 38}, {36, 12, 79}, {66, 10, 104},
{93, 18, 110}, {120, 28, 109}, {147, 38, 103}, {174, 48, 92},
{199, 62, 76}, {221, 81, 58}, {237, 105, 37}, {247, 133, 17},
{251, 164, 10}, {249, 196, 41}, {243, 228, 96}, {252, 255, 164},
}
func Inferno(t float64) [3]float64 { return sampleStops(infernoStops, t) }
// Divergent is for a field with a meaningful zero and a sign: cool below, near-white at zero, warm above.
// The change map is the one that needs it — where the surf cut and where it laid are the same magnitude and
// opposite in meaning, and a sequential ramp renders them as the same colour.
func Divergent(t float64) [3]float64 { return sampleStops(divergentStops, t) }
var divergentStops = [][3]float64{
{30, 64, 120}, {64, 126, 180}, {150, 196, 220}, {238, 238, 236},
{236, 196, 140}, {206, 132, 62}, {140, 66, 22},
}
func sampleStops(s [][3]float64, t float64) [3]float64 {
if t <= 0 {
return s[0]
}
if t >= 1 {
return s[len(s)-1]
}
x := t * float64(len(s)-1)
i := int(x)
u := x - float64(i)
a, b := s[i], s[i+1]
return [3]float64{a[0] + (b[0]-a[0])*u, a[1] + (b[1]-a[1])*u, a[2] + (b[2]-a[2])*u}
}
func hsv(hue, sat, val float64) [3]float64 {
h6 := hue * 6
i := int(h6)
f := h6 - float64(i)
p := val * (1 - sat)
q := val * (1 - sat*f)
t := val * (1 - sat*(1-f))
var r, g, b float64
switch i % 6 {
case 0:
r, g, b = val, t, p
case 1:
r, g, b = q, val, p
case 2:
r, g, b = p, val, t
case 3:
r, g, b = p, q, val
case 4:
r, g, b = t, p, val
default:
r, g, b = val, p, q
}
return [3]float64{r * 255, g * 255, b * 255}
}
func percentiles(vals []float64, sea []bool, loPct, hiPct float64) (float64, float64) {
keep := make([]float64, 0, len(vals))
for i, v := range vals {
if sea != nil && sea[i] {
continue
}
keep = append(keep, v)
}
if len(keep) == 0 {
return 0, 1
}
sort.Float64s(keep)
at := func(p float64) float64 {
i := int(p / 100 * float64(len(keep)-1))
return keep[i]
}
return at(loPct), at(hiPct)
}
+201
View File
@@ -0,0 +1,201 @@
// Package field is the one array type the whole generator passes around: a square-ish grid of float32 in a
// known unit, with the cell size in metres attached so no pass has to be told the scale twice.
//
// Determinism (cross-cutting rule 12) is a property of this package as much as of the passes. Everything
// parallel here partitions rows into disjoint, contiguous ranges and writes only into its own range, so the
// result does not depend on how the goroutines were scheduled. Nothing reduces through a channel.
package field
import (
"math"
"runtime"
"sort"
"sync"
)
// Field is a W x H grid, row-major, with CellM metres between neighbouring samples.
type Field struct {
W, H int
CellM float64
Data []float32
}
func New(w, h int, cellM float64) *Field {
return &Field{W: w, H: h, CellM: cellM, Data: make([]float32, w*h)}
}
// NewLike is an empty field with another's shape and scale.
func NewLike(f *Field) *Field { return New(f.W, f.H, f.CellM) }
func (f *Field) Idx(x, y int) int { return y*f.W + x }
func (f *Field) At(x, y int) float32 { return f.Data[y*f.W+x] }
func (f *Field) Set(x, y int, v float32) { f.Data[y*f.W+x] = v }
func (f *Field) Len() int { return len(f.Data) }
// AtClamped samples with edge clamping, which is what every stencil in the generator wants at the border.
func (f *Field) AtClamped(x, y int) float32 {
if x < 0 {
x = 0
} else if x >= f.W {
x = f.W - 1
}
if y < 0 {
y = 0
} else if y >= f.H {
y = f.H - 1
}
return f.Data[y*f.W+x]
}
func (f *Field) Clone() *Field {
c := New(f.W, f.H, f.CellM)
copy(c.Data, f.Data)
return c
}
func (f *Field) Fill(v float32) {
for i := range f.Data {
f.Data[i] = v
}
}
func (f *Field) MinMax() (float32, float32) {
if len(f.Data) == 0 {
return 0, 0
}
lo, hi := f.Data[0], f.Data[0]
for _, v := range f.Data {
if v < lo {
lo = v
}
if v > hi {
hi = v
}
}
return lo, hi
}
func (f *Field) Mean() float64 {
if len(f.Data) == 0 {
return 0
}
// Summed as float64 in index order: the same total every run, whatever the machine.
var sum float64
for _, v := range f.Data {
sum += float64(v)
}
return sum / float64(len(f.Data))
}
// Percentile sorts a copy, so it costs a copy and a sort; used for thresholds, not in inner loops.
func (f *Field) Percentile(p float64) float32 {
if len(f.Data) == 0 {
return 0
}
c := make([]float32, len(f.Data))
copy(c, f.Data)
sort.Slice(c, func(i, j int) bool { return c[i] < c[j] })
i := int(p / 100 * float64(len(c)-1))
if i < 0 {
i = 0
} else if i >= len(c) {
i = len(c) - 1
}
return c[i]
}
// Normalise maps the field onto [0, 1]. A flat field becomes zero rather than a division by nothing.
func (f *Field) Normalise() {
lo, hi := f.MinMax()
span := float64(hi - lo)
if span < 1e-9 {
f.Fill(0)
return
}
for i, v := range f.Data {
f.Data[i] = float32((float64(v) - float64(lo)) / span)
}
}
// Slope returns rise over run per cell, the central difference used by the layer rules and the statistics.
func (f *Field) Slope() *Field {
out := NewLike(f)
inv := float32(1.0 / (2.0 * f.CellM))
Rows(f.H, func(y0, y1 int) {
for y := y0; y < y1; y++ {
for x := 0; x < f.W; x++ {
gx := (f.AtClamped(x+1, y) - f.AtClamped(x-1, y)) * inv
gy := (f.AtClamped(x, y+1) - f.AtClamped(x, y-1)) * inv
out.Data[out.Idx(x, y)] = float32(math.Hypot(float64(gx), float64(gy)))
}
}
})
return out
}
// Curvature is the Laplacian in metres per cell squared: positive on ridges and convex shoulders, negative in
// gullies and sediment traps. Ported from heightmap_erosion.curvature, which blurs lightly first.
func (f *Field) Curvature() *Field {
h := f.Blur(2)
out := NewLike(f)
inv := float32(1.0 / f.CellM)
Rows(f.H, func(y0, y1 int) {
for y := y0; y < y1; y++ {
for x := 0; x < f.W; x++ {
lap := h.AtClamped(x-1, y) + h.AtClamped(x+1, y) + h.AtClamped(x, y-1) + h.AtClamped(x, y+1) - 4*h.At(x, y)
out.Data[out.Idx(x, y)] = lap * inv
}
}
})
return out
}
// Blur is the five-point box blur the numpy pipeline used, repeated. Edge-clamped, so it does not darken
// the border the way a zero-padded one would.
func (f *Field) Blur(passes int) *Field {
cur := f.Clone()
if passes <= 0 {
return cur
}
next := NewLike(f)
for p := 0; p < passes; p++ {
Rows(f.H, func(y0, y1 int) {
for y := y0; y < y1; y++ {
for x := 0; x < cur.W; x++ {
s := cur.At(x, y) + cur.AtClamped(x-1, y) + cur.AtClamped(x+1, y) + cur.AtClamped(x, y-1) + cur.AtClamped(x, y+1)
next.Data[next.Idx(x, y)] = s / 5
}
}
})
cur, next = next, cur
}
return cur
}
// Rows runs fn over disjoint contiguous row ranges, one per core. The ranges are fixed before any goroutine
// 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)
return
}
var wg sync.WaitGroup
step := (h + workers - 1) / workers
for y0 := 0; y0 < h; y0 += step {
y1 := y0 + step
if y1 > h {
y1 = h
}
wg.Add(1)
go func(a, b int) {
defer wg.Done()
fn(a, b)
}(y0, y1)
}
wg.Wait()
}
+195
View File
@@ -0,0 +1,195 @@
package field
import (
"bufio"
"encoding/binary"
"fmt"
"image"
"image/png"
"io"
"os"
"path/filepath"
)
// Greyscale PNG in and out, plus the raw 16-bit little-endian .r16 that World Machine, Gaea and the engine's
// own exporter write. The numpy pipeline hand-rolled all of this because the engine's Python has no PIL;
// image/png covers it, so the only thing worth carrying over is the behaviour, not the code.
// WriteGray16 writes values already in 0..65535. Compression matters at this size: one 7141 x 7141 map is
// 102 MB of samples, so the level is a parameter and the caller pays for what it needs. The height map is
// imported by the editor and worth compressing; the derivative maps are rebuilt from a seed and are not.
func WriteGray16(path string, w, h int, values []uint16, level png.CompressionLevel) error {
if len(values) != w*h {
return fmt.Errorf("%s: %d values for a %dx%d image", path, len(values), w, h)
}
img := image.NewGray16(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
row := img.Pix[y*img.Stride : y*img.Stride+w*2]
src := values[y*w : y*w+w]
for x, v := range src {
binary.BigEndian.PutUint16(row[x*2:], v) // PNG is big-endian; image.Gray16 stores it that way too
}
}
return encode(path, img, level)
}
// WriteGray8 writes values already in 0..255.
func WriteGray8(path string, w, h int, values []uint8, level png.CompressionLevel) error {
if len(values) != w*h {
return fmt.Errorf("%s: %d values for a %dx%d image", path, len(values), w, h)
}
img := image.NewGray(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
copy(img.Pix[y*img.Stride:y*img.Stride+w], values[y*w:y*w+w])
}
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
}
// Written beside the target and renamed, so a killed run never leaves create_world.py a half-written PNG
// to import. The numpy pipeline learned this the hard way with a killed commandlet.
tmp := path + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
bw := bufio.NewWriterSize(f, 1<<20)
enc := png.Encoder{CompressionLevel: level}
if err := enc.Encode(bw, img); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := bw.Flush(); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
// ReadHeightmap reads a 16-bit greyscale PNG, an 8-bit greyscale PNG (widened, as the numpy pipeline widened
// it) or a raw 16-bit little-endian .r16/.raw, and returns values in 0..65535 with the image's dimensions.
// A raw file needs its width when it is not square.
func ReadHeightmap(path string, width int) (values []uint16, w, h int, err error) {
switch ext := filepath.Ext(path); ext {
case ".r16", ".raw":
return readRaw(path, width)
default:
return readPNG(path)
}
}
func readPNG(path string) ([]uint16, int, int, error) {
f, err := os.Open(path)
if err != nil {
return nil, 0, 0, err
}
defer f.Close()
img, err := png.Decode(bufio.NewReaderSize(f, 1<<20))
if err != nil {
return nil, 0, 0, fmt.Errorf("%s: %w", path, err)
}
b := img.Bounds()
w, h := b.Dx(), b.Dy()
out := make([]uint16, w*h)
switch src := img.(type) {
case *image.Gray16:
for y := 0; y < h; y++ {
row := src.Pix[y*src.Stride:]
for x := 0; x < w; x++ {
out[y*w+x] = binary.BigEndian.Uint16(row[x*2:])
}
}
case *image.Gray:
// Widened the way the numpy reader widened it: 8-bit 255 must become 65535, not 65280, or a DEM
// comes in a whisker short of its own ceiling.
for y := 0; y < h; y++ {
row := src.Pix[y*src.Stride:]
for x := 0; x < w; x++ {
out[y*w+x] = uint16(row[x]) * 257
}
}
default:
// Anything else (RGB, paletted) goes through the generic accessor, which already returns 16-bit.
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
r, g, bl, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
out[y*w+x] = uint16((r*299 + g*587 + bl*114) / 1000)
}
}
}
return out, w, h, nil
}
func readRaw(path string, width int) ([]uint16, int, int, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, 0, 0, err
}
if len(raw)%2 != 0 {
return nil, 0, 0, fmt.Errorf("%s: %d bytes is not a whole number of 16-bit samples", path, len(raw))
}
n := len(raw) / 2
w := width
if w <= 0 {
w = isqrt(n)
if w*w != n {
return nil, 0, 0, fmt.Errorf("%s: %d samples is not square; give the width", path, n)
}
}
if n%w != 0 {
return nil, 0, 0, fmt.Errorf("%s: %d samples do not divide by width %d", path, n, w)
}
out := make([]uint16, n)
for i := 0; i < n; i++ {
out[i] = binary.LittleEndian.Uint16(raw[i*2:])
}
return out, w, n / w, nil
}
func isqrt(n int) int {
r := 0
for (r+1)*(r+1) <= n {
r++
}
return r
}
// 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)
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++ {
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))
shade := (0.5*gx + 0.5*gy) / (2 * small.CellM)
lum := 0.35 + 0.65*(float64(small.At(x, y))-float64(lo))/span
lum += shade * 0.35
if lum < 0 {
lum = 0
} else if lum > 1 {
lum = 1
}
px[y*size+x] = uint8(lum * 255)
}
}
return WriteGray8(path, size, size, px, png.BestSpeed)
}
var _ io.Writer = (*bufio.Writer)(nil)
+259
View File
@@ -0,0 +1,259 @@
package field
import (
"bufio"
"image"
"image/color"
"image/png"
"math"
"os"
"path/filepath"
"sort"
)
// A colour preview of a height field: hypsometric tint, hillshade, and the drainage network drawn on top.
//
// The grey thumbnail is nearly useless for judging this generator, which is a problem, because the thing it
// exists to produce is a drainage network and a flat grey ramp is exactly what hides one. Rivers are drawn
// from the flow accumulation with a width that grows with drainage area, so a glance says whether the network
// branches like a river system or like noise.
type PreviewOptions struct {
// Flow is drainage area per cell, m². Optional; without it no rivers are drawn.
Flow *Field
// Sea marks cells below sea level. Optional.
Sea []bool
SeaLevelM float64
// RiverKm2 is the drainage area at which a channel starts being drawn.
RiverKm2 float64
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
// 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
}
// WritePreview renders the field at opt.Size and writes an RGB PNG.
func WritePreview(path string, h *Field, opt PreviewOptions) error {
size := opt.Size
if size <= 0 {
size = 1024
}
if size > h.W {
size = h.W
}
if opt.Crop[2] > opt.Crop[0] && opt.Crop[3] > opt.Crop[1] {
fullW, fullH := h.W, h.H
h = h.Sub(opt.Crop)
if opt.Flow != nil {
opt.Flow = opt.Flow.Sub(opt.Crop)
}
if opt.Sea != nil {
opt.Sea = subMask(opt.Sea, fullW, fullH, opt.Crop)
}
if size > h.W {
size = h.W
}
}
small := h.Resample(size, size)
exag := opt.Exaggeration
if exag <= 0 {
exag = 1
}
// Land elevations only: letting the sea floor into the range squashes the whole land ramp.
//
// And the top of the ramp is a high percentile, not the maximum. One 2800 m summit over a continent whose
// land is mostly under 300 m puts every other cell into the bottom tenth of the ramp, and the map reads as
// 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)
landVals := make([]float64, 0, len(small.Data))
for i, v := range small.Data {
if sea != nil && sea[i] {
continue
}
landVals = append(landVals, float64(v))
}
landMax := 1.0
if len(landVals) > 0 {
sort.Float64s(landVals)
landMax = landVals[int(0.995*float64(len(landVals)-1))]
}
if landMax <= 0 {
landMax = 1
}
var seaMin float64
for i, v := range small.Data {
if sea != nil && sea[i] && float64(v) < seaMin {
seaMin = float64(v)
}
}
var flow *Field
riverA := opt.RiverKm2 * 1e6
if opt.Flow != nil && riverA > 0 {
flow = opt.Flow.Resample(size, size)
}
img := image.NewRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
i := y*size + x
elev := float64(small.Data[i])
var c rgb
if sea != nil && sea[i] {
d := 0.0
if seaMin < 0 {
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,
}
} else {
c = ramp(math.Min(1, math.Max(0, elev)/landMax))
// 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)
for k := range c {
c[k] *= lum
}
}
// Rivers on top, their strength growing with the log of drainage area so a trunk reads darker
// than a headwater without needing a width in pixels.
if flow != nil {
if a := float64(flow.Data[i]); a >= riverA {
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
}
}
}
img.Set(x, y, color.RGBA{clamp8(c[0]), clamp8(c[1]), clamp8(c[2]), 255})
}
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return 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 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 {
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]
}
}
return out
}
func clamp8(v float64) uint8 {
if v <= 0 {
return 0
}
if v >= 255 {
return 255
}
return uint8(v + 0.5)
}
// subMask is Sub for a boolean mask.
func subMask(mask []bool, w, h int, crop [4]float64) []bool {
clamp := func(v float64) float64 { return math.Min(1, math.Max(0, v)) }
x0 := int(clamp(crop[0]) * float64(w-1))
y0 := int(clamp(crop[1]) * float64(h-1))
x1 := int(clamp(crop[2]) * float64(w-1))
y1 := int(clamp(crop[3]) * float64(h-1))
if x1 <= x0 {
x1 = x0 + 1
}
if y1 <= y0 {
y1 = y0 + 1
}
cw, ch := x1-x0+1, y1-y0+1
out := make([]bool, cw*ch)
for y := 0; y < ch; y++ {
copy(out[y*cw:(y+1)*cw], mask[(y0+y)*w+x0:(y0+y)*w+x0+cw])
}
return out
}
+182
View File
@@ -0,0 +1,182 @@
package field
import "math"
// The vertex convention, which every resample here obeys: a field of N samples a side spans N-1 quads, so
// sample i sits at parameter i/(N-1) and the four corners are fixed points of any resize. Getting this wrong
// shifts the whole map by half a cell per resize and the error compounds over a pipeline.
// Resample returns the field at a new resolution: block means when shrinking by an exact integer factor
// (which is what the geology grid wants, and what preserves mass), bilinear otherwise. Ported from
// heightmap_io.resample, which chose the same two paths for the same reasons.
func (f *Field) Resample(w, h int) *Field {
if w == f.W && h == f.H {
return f.Clone()
}
cell := f.CellM * float64(f.W-1) / float64(w-1)
if w < f.W && (f.W-1)%(w-1) == 0 && (f.H-1)%(h-1) == 0 && (f.W-1)/(w-1) == (f.H-1)/(h-1) {
return f.blockMean((f.W-1)/(w-1), w, h, cell)
}
return f.bilinear(w, h, cell)
}
// blockMean averages each factor x factor block of quads onto one output sample. The last row and column are
// half-blocks under the vertex convention, which is why the accumulation counts what it actually summed.
func (f *Field) blockMean(factor, w, h int, cell float64) *Field {
out := New(w, h, cell)
Rows(h, func(y0, y1 int) {
for oy := y0; oy < y1; oy++ {
for ox := 0; ox < w; ox++ {
var sum float64
var n int
for dy := 0; dy < factor; dy++ {
sy := oy*factor + dy - factor/2
if sy < 0 || sy >= f.H {
continue
}
for dx := 0; dx < factor; dx++ {
sx := ox*factor + dx - factor/2
if sx < 0 || sx >= f.W {
continue
}
sum += float64(f.At(sx, sy))
n++
}
}
if n > 0 {
out.Data[out.Idx(ox, oy)] = float32(sum / float64(n))
}
}
}
})
return out
}
func (f *Field) bilinear(w, h int, cell float64) *Field {
out := New(w, h, cell)
sx := float64(f.W-1) / float64(w-1)
sy := float64(f.H-1) / float64(h-1)
Rows(h, func(y0, y1 int) {
for oy := y0; oy < y1; oy++ {
fy := float64(oy) * sy
iy := int(fy)
ty := float32(fy - float64(iy))
for ox := 0; ox < w; ox++ {
fx := float64(ox) * sx
ix := int(fx)
tx := float32(fx - float64(ix))
a := f.AtClamped(ix, iy)
b := f.AtClamped(ix+1, iy)
c := f.AtClamped(ix, iy+1)
d := f.AtClamped(ix+1, iy+1)
top := a + (b-a)*tx
bot := c + (d-c)*tx
out.Data[out.Idx(ox, oy)] = top + (bot-top)*ty
}
}
})
return out
}
// UpsampleInt is the geology-to-detail step: an exact integer factor on the quad count, so 1786 at factor 4
// becomes (1786-1)*4+1 = 7141 with every source sample landing exactly on an output sample and no resample
// phase error at all. Catmull-Rom between them, which is the bicubic the spec asks for and does not overshoot
// into ringing the way a plain cubic does on a ridge.
func (f *Field) UpsampleInt(factor int) *Field {
if factor <= 1 {
return f.Clone()
}
w := (f.W-1)*factor + 1
h := (f.H-1)*factor + 1
out := New(w, h, f.CellM/float64(factor))
inv := 1.0 / float64(factor)
Rows(h, func(y0, y1 int) {
for oy := y0; oy < y1; oy++ {
sy := oy / factor
ty := float64(oy%factor) * inv
for ox := 0; ox < w; ox++ {
sx := ox / factor
tx := float64(ox%factor) * inv
var col [4]float64
for k := 0; k < 4; k++ {
col[k] = catmullRom(
float64(f.AtClamped(sx-1, sy-1+k)),
float64(f.AtClamped(sx, sy-1+k)),
float64(f.AtClamped(sx+1, sy-1+k)),
float64(f.AtClamped(sx+2, sy-1+k)), tx)
}
out.Data[out.Idx(ox, oy)] = float32(catmullRom(col[0], col[1], col[2], col[3], ty))
}
}
})
return out
}
func catmullRom(p0, p1, p2, p3, t float64) float64 {
t2 := t * t
t3 := t2 * t
return 0.5 * ((2 * p1) +
(-p0+p2)*t +
(2*p0-5*p1+4*p2-p3)*t2 +
(-p0+3*p1-3*p2+p3)*t3)
}
// ToUnit squashes a field into [0, 1] against a percentile, optionally through log1p first: what the four
// derivative maps (flow, wear, deposit) need before they become 8-bit PNGs. Ported from
// heightmap_erosion.to_unit.
func (f *Field) ToUnit(percentile float64, logScale bool) *Field {
out := NewLike(f)
for i, v := range f.Data {
x := float64(v)
if x < 0 {
x = 0
}
if logScale {
x = math.Log1p(x)
}
out.Data[i] = float32(x)
}
top := float64(out.Percentile(percentile))
if top < 1e-6 {
top = 1e-6
}
for i, v := range out.Data {
x := float64(v) / top
if x > 1 {
x = 1
}
out.Data[i] = float32(x)
}
return out
}
// Sub extracts a sub-rectangle given in map coordinates (x0, y0, x1, y1 in 0..1), at the source resolution.
// Used by the preview to look at a piece of the map closely, which is the only way to judge whether hill
// country reads as hill country rather than as small mountains.
func (f *Field) Sub(crop [4]float64) *Field {
clamp := func(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
x0 := int(clamp(crop[0]) * float64(f.W-1))
y0 := int(clamp(crop[1]) * float64(f.H-1))
x1 := int(clamp(crop[2]) * float64(f.W-1))
y1 := int(clamp(crop[3]) * float64(f.H-1))
if x1 <= x0 {
x1 = x0 + 1
}
if y1 <= y0 {
y1 = y0 + 1
}
w, h := x1-x0+1, y1-y0+1
out := New(w, h, f.CellM)
for y := 0; y < h; y++ {
copy(out.Data[y*w:(y+1)*w], f.Data[(y0+y)*f.W+x0:(y0+y)*f.W+x0+w])
}
return out
}