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

155 lines
5.2 KiB
Go

// Package template reads a painted world map and turns it into the fields the geology solve needs.
//
// The map is an image; the legend beside it says what each colour means. The image is cylindrical: X
// wraps, Y does not, so the left and right edges are the same meridian and the top and bottom rows are
// the poles. Nothing here knows how big the world is - that is the planet package's job. This package
// only answers "what did the author paint here".
//
// The one rule that governs the whole design is Docs/Terrain-Next.md 3.2: paint the uplift, never the
// height. A painted heightmap is handed to a solver that erodes it into something else and throws away
// the drainage network, which is the reason the generator exists. So a class carries an uplift rate and
// an erodibility, and the solve makes the terrain.
package template
import (
"bufio"
"fmt"
"image"
"os"
// Registered for image.Decode. JPEG is here because the first template anyone painted was a JPEG;
// PNG is what a template should be, because JPEG bleeds colour across every class boundary and the
// classifier then has to clean up after it.
_ "image/jpeg"
_ "image/png"
)
// DecodeRGB reads an image and returns tightly packed 8-bit RGB, three bytes a pixel, row-major.
//
// field.ReadHeightmap cannot be used for this and deliberately is not extended: it decodes PNG only, and
// its fallback branch collapses colour to luma, which is right for a DEM and destroys a painted map -
// two different classes can share a luma and here several nearly do.
func DecodeRGB(path string) (px []uint8, w, h int, err error) {
px, _, w, h, err = decode(path, false)
return px, w, h, err
}
// DecodeRGBA is DecodeRGB with the alpha channel kept alongside.
//
// It exists for the annotation layer and only for it. A class template is opaque by definition - every pixel
// is some class - so throwing alpha away there costs nothing. An overlay is the opposite: it is a transparent
// sheet with strokes on it, most of it is nothing, and "nothing" is exactly what alpha records. An image with
// no alpha comes back fully opaque, which is the right reading of a flattened export.
func DecodeRGBA(path string) (px, alpha []uint8, w, h int, err error) {
return decode(path, true)
}
func decode(path string, wantAlpha bool) (px, alpha []uint8, w, h int, err error) {
f, err := os.Open(path)
if err != nil {
return nil, nil, 0, 0, err
}
defer f.Close()
img, _, err := image.Decode(bufio.NewReaderSize(f, 1<<20))
if err != nil {
return nil, nil, 0, 0, fmt.Errorf("%s: %w", path, err)
}
b := img.Bounds()
w, h = b.Dx(), b.Dy()
if w <= 0 || h <= 0 {
return nil, nil, 0, 0, fmt.Errorf("%s: empty image", path)
}
px = make([]uint8, w*h*3)
if wantAlpha {
alpha = make([]uint8, w*h)
for i := range alpha {
alpha[i] = 255
}
}
// The fast paths matter: a 7738x3761 template is 29 million pixels, and going through the At()
// interface for every one of them costs seconds rather than milliseconds.
switch src := img.(type) {
case *image.RGBA:
// Premultiplied: the RGB bytes are already scaled by alpha, so a half-transparent red reads as a
// darker red. Nothing here un-multiplies it, because every consumer that cares about alpha treats a
// non-opaque pixel as blank and never looks at its colour.
for y := 0; y < h; y++ {
row := src.Pix[(y+b.Min.Y-src.Rect.Min.Y)*src.Stride:]
off := (b.Min.X - src.Rect.Min.X) * 4
for x := 0; x < w; x++ {
o := (y*w + x) * 3
px[o], px[o+1], px[o+2] = row[off+x*4], row[off+x*4+1], row[off+x*4+2]
if alpha != nil {
alpha[y*w+x] = row[off+x*4+3]
}
}
}
case *image.NRGBA:
for y := 0; y < h; y++ {
row := src.Pix[(y+b.Min.Y-src.Rect.Min.Y)*src.Stride:]
off := (b.Min.X - src.Rect.Min.X) * 4
for x := 0; x < w; x++ {
o := (y*w + x) * 3
px[o], px[o+1], px[o+2] = row[off+x*4], row[off+x*4+1], row[off+x*4+2]
if alpha != nil {
alpha[y*w+x] = row[off+x*4+3]
}
}
}
case *image.YCbCr:
// What image/jpeg returns.
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
yi := src.YOffset(b.Min.X+x, b.Min.Y+y)
ci := src.COffset(b.Min.X+x, b.Min.Y+y)
r, g, bl := ycbcrToRGB(src.Y[yi], src.Cb[ci], src.Cr[ci])
o := (y*w + x) * 3
px[o], px[o+1], px[o+2] = r, g, bl
}
}
default:
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
r, g, bl, a := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
o := (y*w + x) * 3
px[o], px[o+1], px[o+2] = uint8(r>>8), uint8(g>>8), uint8(bl>>8)
if alpha != nil {
alpha[y*w+x] = uint8(a >> 8)
}
}
}
}
return px, alpha, w, h, nil
}
// ycbcrToRGB is image/color's conversion, inlined so the YCbCr path does not allocate a color.Color per
// pixel. Same arithmetic, same rounding.
func ycbcrToRGB(y, cb, cr uint8) (uint8, uint8, uint8) {
yy := int32(y) * 0x10101
cb1 := int32(cb) - 128
cr1 := int32(cr) - 128
r := yy + 91881*cr1
if uint32(r)&0xff000000 == 0 {
r >>= 16
} else {
r = ^(r >> 31) & 0xffff >> 8
}
g := yy - 22554*cb1 - 46802*cr1
if uint32(g)&0xff000000 == 0 {
g >>= 16
} else {
g = ^(g >> 31) & 0xffff >> 8
}
b := yy + 116130*cb1
if uint32(b)&0xff000000 == 0 {
b >>= 16
} else {
b = ^(b >> 31) & 0xffff >> 8
}
return uint8(r), uint8(g), uint8(b)
}