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

247 lines
7.2 KiB
Go

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 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.
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
}
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
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
}
}
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
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}
}
// 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)
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)
}