Added: Initial world generation tool
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user