Tooling
This commit is contained in:
@@ -0,0 +1,686 @@
|
||||
package planet
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"salty/terrain/internal/detail"
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/noise"
|
||||
"salty/terrain/internal/overlay"
|
||||
"salty/terrain/internal/thermal"
|
||||
"salty/terrain/internal/tile"
|
||||
)
|
||||
|
||||
// The detail bake: the geology grid becomes ground somebody can stand on, one tile at a time.
|
||||
//
|
||||
// It reads the heightmap a geology bake left behind rather than solving anything itself, which is what makes
|
||||
// it batchable. The geology is hours; a tile is seconds, and the islands somebody cares about can be baked
|
||||
// first and the rest later or never.
|
||||
//
|
||||
// Every pass here is local, and every hash and noise lattice in them is keyed on absolute world position, so
|
||||
// a tile's interior comes out the same as it would have in one impossible whole-world run. That is measured
|
||||
// rather than asserted: internal/detail's TestHowFarTheCutEdgeReachesIn is where the margin comes from.
|
||||
|
||||
// TileOptions steer a detail bake.
|
||||
type TileOptions struct {
|
||||
In *Inputs
|
||||
HeightM *field.Field // the geology heightmap, painted rows only, in metres
|
||||
Sea []bool // painted rows
|
||||
|
||||
// Exposure is the coastal pass's fetch field, painted rows, 0 sheltered to 1 open water, or nil when the
|
||||
// bake predates it. A tile cannot compute this - see detail.CoastalParams - so without it the coastal
|
||||
// detail pass treats every shore as fully exposed and the run says so once.
|
||||
Exposure *field.Field
|
||||
|
||||
Out string
|
||||
Prefix string
|
||||
Only [4]int // x0,y0,x1,y1 in tile indices; zero means all
|
||||
OnlySet bool
|
||||
|
||||
// NoDetail writes the tile as the geology upsampled and nothing else. It is a diagnostic and it earns
|
||||
// its place: when ground looks wrong at two metres, the first question is always whether the detail
|
||||
// passes did it or whether they are faithfully magnifying something the solve produced, and there is no
|
||||
// other way to ask.
|
||||
NoDetail bool
|
||||
|
||||
// NoShore skips pass 11b and nothing else, for the same reason NoDetail exists one level up: when a
|
||||
// coastline looks wrong the first question is whether the shore pass did it or whether it is faithfully
|
||||
// magnifying what the geology handed it, and a diff between two runs is the only way to ask.
|
||||
NoShore bool
|
||||
|
||||
Jobs int
|
||||
Log func(string, ...any)
|
||||
}
|
||||
|
||||
// TileRecord is one tile in the index.
|
||||
type TileRecord struct {
|
||||
IX int `json:"ix"`
|
||||
IY int `json:"iy"`
|
||||
File string `json:"file"`
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
OriginXM float64 `json:"origin_x_m"`
|
||||
OriginYM float64 `json:"origin_y_m"`
|
||||
MinM float64 `json:"min_m"`
|
||||
MaxM float64 `json:"max_m"`
|
||||
ClipFrac float64 `json:"clip_fraction"`
|
||||
Droplets int `json:"droplets"`
|
||||
Rounds int `json:"rounds"`
|
||||
LargestCutM float64 `json:"largest_cut_m"`
|
||||
LargestFillM float64 `json:"largest_fill_m"`
|
||||
Seconds float64 `json:"seconds"`
|
||||
|
||||
// Coastal is what pass 11b moved on this tile, or nil on a tile with no shore in it.
|
||||
Coastal *detail.CoastalStats `json:"coastal,omitempty"`
|
||||
|
||||
// OverlayFile is the annotation mask beside this tile - one mark index a detail cell, zero for nothing -
|
||||
// or empty when the planet has no overlay. The key is in overlay.json in the same directory.
|
||||
OverlayFile string `json:"overlay_file,omitempty"`
|
||||
}
|
||||
|
||||
// TileIndex is tiles.json: everything a consumer needs to place the tiles back into a world.
|
||||
type TileIndex struct {
|
||||
When time.Time `json:"when"`
|
||||
Prefix string `json:"prefix"`
|
||||
CellM float64 `json:"cell_m"`
|
||||
TilePx int `json:"tile_px"`
|
||||
MarginPx int `json:"margin_px"`
|
||||
NX int `json:"nx"`
|
||||
NY int `json:"ny"`
|
||||
WrapX bool `json:"wrap_x"`
|
||||
WorldWM float64 `json:"world_w_m"`
|
||||
WorldHM float64 `json:"world_h_m"`
|
||||
ElevationM struct {
|
||||
Min float64 `json:"min"`
|
||||
Max float64 `json:"max"`
|
||||
} `json:"elevation_m"`
|
||||
Tiles []TileRecord `json:"tiles"`
|
||||
}
|
||||
|
||||
// CheckBake refuses a detail bake whose geology was produced by a different manifest.
|
||||
//
|
||||
// The failure it exists for is silent and total. A heightmap is 16-bit samples over an elevation range, so a
|
||||
// bake made under one range and decoded under another comes out shifted - and if the shift takes the land
|
||||
// below sea level, every tile decides it is ocean, holds itself at sea level, and writes a flat zero. That
|
||||
// happened on the first run of this command and there was nothing in the output to say why.
|
||||
func CheckBake(dir string, m *manifest.Manifest, warn func(string, ...any)) error {
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w (run `terrain bake` first)", filepath.Join(dir, "meta.json"), err)
|
||||
}
|
||||
// Pointers, so that a field a bake did not record is distinguishable from one it recorded as zero. An
|
||||
// absent field is a bake older than this check, which is a reason to say so and carry on; a different
|
||||
// field is a reason to stop. The first version conflated the two and refused a perfectly good bake.
|
||||
var meta struct {
|
||||
Seed *int64 `json:"seed"`
|
||||
Plan struct {
|
||||
CircumferenceKm *float64 `json:"circumference_km"`
|
||||
CellM *float64 `json:"cell_m"`
|
||||
ElevationMinM *float64 `json:"elevation_min_m"`
|
||||
ElevationMaxM *float64 `json:"elevation_max_m"`
|
||||
} `json:"plan"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &meta); err != nil {
|
||||
return fmt.Errorf("%s: %w", filepath.Join(dir, "meta.json"), err)
|
||||
}
|
||||
if warn == nil {
|
||||
warn = func(string, ...any) {}
|
||||
}
|
||||
|
||||
unknown := 0
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
was *float64
|
||||
now float64
|
||||
}{
|
||||
{"elevation_m.min", meta.Plan.ElevationMinM, m.ElevationM.Min},
|
||||
{"elevation_m.max", meta.Plan.ElevationMaxM, m.ElevationM.Max},
|
||||
{"circumference_km", meta.Plan.CircumferenceKm, m.Planet.CircumferenceKm},
|
||||
{"the geology cell", meta.Plan.CellM, m.GeologyCellM()},
|
||||
} {
|
||||
if c.was == nil {
|
||||
unknown++
|
||||
continue
|
||||
}
|
||||
if *c.was != c.now {
|
||||
return fmt.Errorf("%s was baked with %s %v and the manifest now says %v. The heightmap on disk "+
|
||||
"means something different from what this run would read it as; rebake, or put the manifest "+
|
||||
"back", dir, c.what, *c.was, c.now)
|
||||
}
|
||||
}
|
||||
if meta.Seed != nil && *meta.Seed != m.Source.Seed {
|
||||
return fmt.Errorf("%s was baked with seed %d and the manifest now says %d; the detail passes would "+
|
||||
"be hashing a different world from the one in the heightmap", dir, *meta.Seed, m.Source.Seed)
|
||||
}
|
||||
if unknown > 0 {
|
||||
warn("warning %s predates this check and does not record %d of the numbers it would be checked "+
|
||||
"against; if the manifest has moved since it was baked, the heights will be read as something "+
|
||||
"they are not", dir, unknown)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BakeTiles runs the detail passes over a rectangle of tiles and writes them.
|
||||
func BakeTiles(opt TileOptions) (*TileIndex, error) {
|
||||
log := opt.Log
|
||||
if log == nil {
|
||||
log = func(string, ...any) {}
|
||||
}
|
||||
in := opt.In
|
||||
m := in.M
|
||||
cfg := m.Pipeline
|
||||
|
||||
marginPx := detail.MarginCells(cfg.Particle)
|
||||
g, err := tile.NewGrid(in.P, cfg.GeologyFactor, cfg.Detail.TilePx, marginPx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detailCellM := in.P.CellM / float64(cfg.GeologyFactor)
|
||||
log("tiles %d x %d of %d px at %.1f m (%.2f km), margin %d px (%.0f m)",
|
||||
g.NX, g.NY, cfg.Detail.TilePx, detailCellM,
|
||||
float64(cfg.Detail.TilePx)*detailCellM/1000, g.MarginGeo*g.Factor, float64(g.MarginGeo)*in.P.CellM)
|
||||
if want := int(cfg.Detail.ClassBlendM/in.P.CellM + 0.5); want > g.MarginGeo/2 {
|
||||
log("warning class_blend_m is %.0f m, which a tile cannot reach past its own margin; it will blend "+
|
||||
"over %.0f m instead. Two passes of the blur reach twice its radius, and the margin is %.0f m",
|
||||
cfg.Detail.ClassBlendM, float64(g.MarginGeo/2)*in.P.CellM, float64(g.MarginGeo)*in.P.CellM)
|
||||
}
|
||||
if cd := cfg.CoastDetail; cd.Enabled && !opt.NoShore {
|
||||
log("shore the coastal detail pass is on: %.0f m of surf reach is %.0f cells here, a beach below "+
|
||||
"%.0f m of backshore and a cliff above %.0f", cfg.Coast.SurfReachM,
|
||||
cfg.Coast.SurfReachM/detailCellM, cd.CliffFromM, cd.CliffToM)
|
||||
} else {
|
||||
log("shore the coastal detail pass is off; the shore is the geology upsampled")
|
||||
}
|
||||
|
||||
all := g.Tiles()
|
||||
wanted := all[:0:0]
|
||||
for _, t := range all {
|
||||
if opt.OnlySet {
|
||||
if t.IX < opt.Only[0] || t.IX > opt.Only[2] || t.IY < opt.Only[1] || t.IY > opt.Only[3] {
|
||||
continue
|
||||
}
|
||||
}
|
||||
wanted = append(wanted, t)
|
||||
}
|
||||
if len(wanted) == 0 {
|
||||
return nil, fmt.Errorf("no tiles selected; the grid is %d x %d", g.NX, g.NY)
|
||||
}
|
||||
if err := os.MkdirAll(opt.Out, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prefix := opt.Prefix
|
||||
if prefix == "" {
|
||||
prefix = "Planet"
|
||||
}
|
||||
jobs := opt.Jobs
|
||||
if jobs <= 0 {
|
||||
jobs = 4
|
||||
}
|
||||
if jobs > len(wanted) {
|
||||
jobs = len(wanted)
|
||||
}
|
||||
|
||||
recs := make([]TileRecord, len(wanted))
|
||||
errs := make([]error, len(wanted))
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
next := make(chan int)
|
||||
go func() {
|
||||
for i := range wanted {
|
||||
next <- i
|
||||
}
|
||||
close(next)
|
||||
}()
|
||||
for w := 0; w < jobs; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := range next {
|
||||
rec, err := bakeOneTile(g, wanted[i], opt, prefix)
|
||||
recs[i], errs[i] = rec, err
|
||||
mu.Lock()
|
||||
if err != nil {
|
||||
log("tile %s FAILED: %v", wanted[i].Name(prefix), err)
|
||||
} else {
|
||||
log("tile %s %d x %d %.0f..%.0f m %.3f%% clipped [%.1f s]",
|
||||
rec.File, rec.W, rec.H, rec.MinM, rec.MaxM, rec.ClipFrac*100, rec.Seconds)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
idx := &TileIndex{
|
||||
When: time.Now().UTC().Truncate(time.Second), Prefix: prefix,
|
||||
CellM: detailCellM, TilePx: cfg.Detail.TilePx, MarginPx: g.MarginGeo * g.Factor,
|
||||
NX: g.NX, NY: g.NY, WrapX: true,
|
||||
WorldWM: in.P.CircumferenceM(), WorldHM: in.P.HeightM(),
|
||||
Tiles: recs,
|
||||
}
|
||||
idx.ElevationM.Min, idx.ElevationM.Max = m.ElevationM.Min, m.ElevationM.Max
|
||||
data, err := json.MarshalIndent(idx, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(opt.Out, "tiles.json"), append(data, '\n'), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The key to every *_overlay.png, plus the features in world metres, written beside them so an importer
|
||||
// reads one directory rather than two. It is the same document the plan and the bake write; it is small,
|
||||
// it describes the whole planet, and a tile batch that did not carry it would be a folder of masks with
|
||||
// no legend.
|
||||
if in.OverlayDoc != nil {
|
||||
if err := in.OverlayDoc.WriteJSON(opt.Out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// bakeOneTile is passes 8 to 12 and 14 over one tile.
|
||||
func bakeOneTile(g *tile.Grid, t tile.Tile, opt TileOptions, prefix string) (TileRecord, error) {
|
||||
in := opt.In
|
||||
m := in.M
|
||||
cfg := m.Pipeline
|
||||
|
||||
// Pass 8: cut with the margin and upsample. UpsampleInt is exact-factor Catmull-Rom, so every geology
|
||||
// sample lands exactly on a detail sample and there is no phase error to accumulate along a tile row.
|
||||
//
|
||||
// The sea is flattened to sea level *before* the upsample, not after, and both halves of that matter. The
|
||||
// geology raster drops from the shore to the painted ocean depth in a single cell, so a Catmull-Rom
|
||||
// upsample of it rings at every coastline - hundreds of metres of overshoot in the water and a wave of it
|
||||
// back into the land. And with the sea flat, the interpolated height crosses sea level on a smooth
|
||||
// contour, so the detail land mask can be read off the height itself; taken up from the geology mask by
|
||||
// nearest neighbour instead, the coastline comes out as a staircase of 8 m blocks and it is plainly
|
||||
// visible in a hillshade.
|
||||
//
|
||||
// It is the same invariant the fluvial solve keeps, for the same reason: with the floor in place a cell at
|
||||
// the waterline stands five hundred metres above its neighbour, and thermal weathering would find the
|
||||
// whole coastline past the angle of repose and pour it into the sea.
|
||||
geo, _, _ := g.Cut(t, opt.HeightM, 0)
|
||||
geoSea := g.CutMask(t, opt.Sea, opt.HeightM.W, 0)
|
||||
floor := geo.Clone()
|
||||
for i, isSea := range geoSea {
|
||||
if isSea {
|
||||
geo.Data[i] = float32(m.SeaLevelM)
|
||||
}
|
||||
}
|
||||
|
||||
h := geo.UpsampleInt(cfg.GeologyFactor)
|
||||
land := make([]bool, len(h.Data))
|
||||
for i, v := range h.Data {
|
||||
land[i] = float64(v) > m.SeaLevelM
|
||||
}
|
||||
|
||||
f := g.Frame(t)
|
||||
periodM := m.Planet.DetailNoisePeriodKm * 1000
|
||||
|
||||
classes := blendedClasses(g, t, opt, geo, h)
|
||||
|
||||
if opt.NoDetail {
|
||||
restoreSeaFloor(h, land, geo, floor, m, cfg.GeologyFactor)
|
||||
return finishTile(g, t, opt, prefix, h, land, nil, nil)
|
||||
}
|
||||
|
||||
// Pass 9.
|
||||
detail.RunDetailNoise(h, land, detail.DetailNoiseParams{
|
||||
Cfg: cfg.Detail, Seed: m.Source.Seed, Frame: f, PeriodM: periodM, SeaLevelM: m.SeaLevelM,
|
||||
Classes: classes,
|
||||
})
|
||||
|
||||
// Pass 10 feeds pass 11 rather than standing alone: strata is hardness, and hardness is what the
|
||||
// droplets scale their cutting by, which is how a hard band ends up holding a shelf on a cut face.
|
||||
hard := detail.NewHardness(f, m.Source.Seed, m.Planet.NoisePeriodKm*1000,
|
||||
cfg.Strata.PeriodM, cfg.Strata.Contrast, classes)
|
||||
|
||||
// Pass 11.
|
||||
maps, _ := detail.RunParticle(h, land, detail.ParticleParams{
|
||||
Cfg: cfg.Particle, Seed: m.Source.Seed, Frame: f, SeaLevelM: m.SeaLevelM, Hardness: hard,
|
||||
Classes: classes,
|
||||
})
|
||||
|
||||
// Pass 12: the same mass-conserving weathering the coarse grid gets, at the cell size where scree and a
|
||||
// cliff face are actually resolved.
|
||||
fixed := make([]bool, len(land))
|
||||
for i := range land {
|
||||
fixed[i] = !land[i]
|
||||
}
|
||||
thermal.Apply(h.Data, h.W, h.H, h.CellM, thermal.TalusFromDegrees(cfg.Thermal.TalusDeg),
|
||||
cfg.Thermal.FinePasses, fixed, nil)
|
||||
|
||||
// The sea floor goes back *here*, before the shore is drawn, rather than on the way out. Pass 11b works
|
||||
// on both sides of the waterline - a foreshore is below it and a berm is above it - so a shore laid onto
|
||||
// water that is about to be overwritten would be half a shore.
|
||||
restoreSeaFloor(h, land, geo, floor, m, cfg.GeologyFactor)
|
||||
|
||||
// Pass 9b: the same texture as pass 9, under water, now that there is a sea bed to put it on. It runs
|
||||
// before the shore rather than after, so the beach the shore pass draws is smooth sand over it rather
|
||||
// than sand with noise on top.
|
||||
detail.RunSeabedNoise(h, detail.DetailNoiseParams{
|
||||
Cfg: cfg.Detail, Seed: m.Source.Seed, Frame: f, PeriodM: periodM, SeaLevelM: m.SeaLevelM,
|
||||
Classes: classes,
|
||||
})
|
||||
|
||||
// Pass 11b: the shore. It runs last of the detail passes because marine processes are the last thing to
|
||||
// act on a coast and they act faster than anything inland: a berm is rebuilt by every tide, while the
|
||||
// hillslope creep that pass 12 stands for takes the age of the cliff behind it. Running it before the
|
||||
// fine thermal would have that creep immediately relax the one face on the map that is meant to be
|
||||
// steeper than the angle of repose.
|
||||
var coastal *detail.CoastalStats
|
||||
if cfg.CoastDetail.Enabled && !opt.NoShore {
|
||||
st := detail.RunCoastal(h, land, detail.CoastalParams{
|
||||
Cfg: cfg.CoastDetail, Surf: cfg.Coast, Seed: m.Source.Seed, Frame: f, PeriodM: periodM,
|
||||
SeaLevelM: m.SeaLevelM, Exposure: cutExposure(g, t, opt, geo, h), Hardness: hard,
|
||||
})
|
||||
coastal = &st
|
||||
}
|
||||
|
||||
return finishTile(g, t, opt, prefix, h, land, maps, coastal)
|
||||
}
|
||||
|
||||
// cutExposure lifts the coastal pass's fetch field onto this tile's detail grid, or nil when the bake did not
|
||||
// carry one. Interpolated rather than nearest: it is a smooth field and a staircase in it would put a
|
||||
// staircase into the berm height along every beach.
|
||||
func cutExposure(g *tile.Grid, t tile.Tile, opt TileOptions, geo, h *field.Field) []float32 {
|
||||
if opt.Exposure == nil {
|
||||
return nil
|
||||
}
|
||||
cut, _, _ := g.Cut(t, opt.Exposure, 0)
|
||||
up := cut.UpsampleInt(opt.In.M.Pipeline.GeologyFactor)
|
||||
if len(up.Data) != len(h.Data) {
|
||||
return nil
|
||||
}
|
||||
return up.Data
|
||||
}
|
||||
|
||||
// restoreSeaFloor puts the water back after the land passes, which ran with the sea flattened to sea level.
|
||||
//
|
||||
// It used to be nearest neighbour, unconditionally, and the comment said why: the geology raster dropped from
|
||||
// the shore to the painted ocean depth in a single cell, and interpolating a five-hundred-metre step is
|
||||
// exactly what the flattening exists to avoid. The cost was a four-fold staircase over the whole sea floor,
|
||||
// which nobody could see while the shore was a cliff into five hundred metres of water.
|
||||
//
|
||||
// D-60 changed the input. There is a continental shelf now, and a surf-cut platform, and a beach, and between
|
||||
// them they carry the sea floor down from the waterline to the shelf break over kilometres rather than over
|
||||
// one cell. So the shallow water is interpolated - from the *unflattened* cut, which still holds the land
|
||||
// heights, so the surface runs across the waterline with no seam in it - and only the drop past the break is
|
||||
// still nearest. The two are blended over a depth band rather than switched between, because a hard switch
|
||||
// would put back a smaller version of the step it exists to avoid.
|
||||
func restoreSeaFloor(h *field.Field, land []bool, geo, floor *field.Field, m *manifest.Manifest, factor int) {
|
||||
breakM := m.ShelfBreakM()
|
||||
if breakM <= 0 {
|
||||
breakM = 30
|
||||
}
|
||||
// The clamp sits at the *far* end of the blend band rather than at the break, so that everywhere the blend
|
||||
// is still reading the interpolation, the interpolation is of the real sea floor. Clamped at the break
|
||||
// instead, the smooth half of the blend was a flat surface at break depth while the nearest half followed
|
||||
// the slope down, and the mixture lifted the floor by up to half the band - ten metres of invented shelf
|
||||
// in exactly the strip the blend exists to make invisible.
|
||||
const bandM = 40.0
|
||||
deepest := float32(m.SeaLevelM - (breakM + bandM))
|
||||
|
||||
shallow := floor.Clone()
|
||||
for i, v := range shallow.Data {
|
||||
if v < deepest {
|
||||
shallow.Data[i] = deepest
|
||||
}
|
||||
}
|
||||
smooth := shallow.UpsampleInt(factor)
|
||||
|
||||
// Not smoothed, and it is worth saying why not, because the first version was.
|
||||
//
|
||||
// The interpolated sea floor comes out of a hillshade covered in dotted contour lines, which look exactly
|
||||
// like an interpolation artefact and are not: measured, an eighty by hundred patch of open water takes
|
||||
// three distinct 8-bit shade values, 94 % of them the same one. It is the hillshade's own quantisation on
|
||||
// a surface that slopes at one in three hundred, it was there before and it is in the picture rather than
|
||||
// in the ground. A box blur over the floor was tried against it and changed the tile by a fifth of a
|
||||
// height quantum on average - its only real effect was to soften genuine one-cell steps in the geology,
|
||||
// which is not what it was for.
|
||||
for y := 0; y < h.H; y++ {
|
||||
sy := y / factor
|
||||
if sy >= geo.H {
|
||||
sy = geo.H - 1
|
||||
}
|
||||
for x := 0; x < h.W; x++ {
|
||||
i := y*h.W + x
|
||||
if land[i] {
|
||||
continue
|
||||
}
|
||||
sx := x / factor
|
||||
if sx >= geo.W {
|
||||
sx = geo.W - 1
|
||||
}
|
||||
near := float64(floor.Data[sy*geo.W+sx])
|
||||
depth := m.SeaLevelM - near
|
||||
t := (depth - breakM) / bandM
|
||||
if t <= 0 {
|
||||
h.Data[i] = smooth.Data[i]
|
||||
continue
|
||||
}
|
||||
if t >= 1 {
|
||||
h.Data[i] = float32(near)
|
||||
continue
|
||||
}
|
||||
w := noise.Smoothstep(t)
|
||||
h.Data[i] = float32((1-w)*float64(smooth.Data[i]) + w*near)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// finishTile restores the sea floor, crops the margin away and writes everything out.
|
||||
func finishTile(g *tile.Grid, t tile.Tile, opt TileOptions, prefix string, h *field.Field, land []bool,
|
||||
maps *detail.Maps, coastal *detail.CoastalStats) (TileRecord, error) {
|
||||
|
||||
start := time.Now()
|
||||
_ = land
|
||||
m := opt.In.M
|
||||
cfg := m.Pipeline
|
||||
|
||||
// Pass 14: crop the margin away and write. Everything outside the interior was only ever there so the
|
||||
// passes above had somewhere to read from.
|
||||
ix, iy := g.MarginGeo*g.Factor, g.MarginGeo*g.Factor
|
||||
iw, ih := g.DetailW(t), g.DetailH(t)
|
||||
out := crop(h, ix, iy, iw, ih)
|
||||
|
||||
rec := TileRecord{
|
||||
IX: t.IX, IY: t.IY, File: t.Name(prefix) + ".png", W: iw, H: ih,
|
||||
OriginXM: g.OriginXM(t), OriginYM: g.OriginYM(t),
|
||||
}
|
||||
if coastal != nil && coastal.ShoreCells > 0 {
|
||||
rec.Coastal = coastal
|
||||
}
|
||||
|
||||
// The annotation layer, sampled onto this tile's interior. It is written before the height, because it is
|
||||
// the cheap one and a failure here should not leave a heightmap with no mask beside it.
|
||||
//
|
||||
// Nothing in the detail passes read it and nothing here consults it: it is the author's layer travelling
|
||||
// through to whatever builds the level. The values are mark indices, zero for nothing, and the key is in
|
||||
// overlay.json beside tiles.json.
|
||||
if ov := opt.In.OverlayRaster; ov != nil {
|
||||
marks := ov.SampleWorld(rec.OriginXM, rec.OriginYM, h.CellM, iw, ih, opt.In.OverlayScale())
|
||||
rec.OverlayFile = t.Name(prefix) + "_overlay.png"
|
||||
if err := overlay.WriteMask(filepath.Join(opt.Out, rec.OverlayFile), iw, ih, marks); err != nil {
|
||||
return rec, err
|
||||
}
|
||||
}
|
||||
lo, hi := out.MinMax()
|
||||
rec.MinM, rec.MaxM = float64(lo), float64(hi)
|
||||
rec.ClipFrac = m.ClipFraction(out.Data)
|
||||
|
||||
if err := field.WriteGray16(filepath.Join(opt.Out, rec.File), iw, ih,
|
||||
m.Encode(out.Data), png.DefaultCompression); err != nil {
|
||||
return rec, err
|
||||
}
|
||||
// A hillshade beside the heightmap, at full resolution. A 16-bit grey PNG of a hundred metres of relief
|
||||
// is a flat grey rectangle to look at, and the whole reason these passes exist is what they do to the
|
||||
// surface - which cannot be judged from a number.
|
||||
// Shaded with the water clamped at the shelf break rather than at sea level. Clamping at sea level was
|
||||
// right while the shore was a step into five hundred metres of water and there was nothing below the
|
||||
// waterline worth looking at; now there is a shore platform, a foreshore and a beach down there, and
|
||||
// they are most of what pass 11b does. The break is still clamped, because a continental slope in the
|
||||
// corner of a tile would otherwise set the whole hillshade's contrast.
|
||||
shade := out.Clone()
|
||||
shadeFloor := float32(m.SeaLevelM - m.ShelfBreakM())
|
||||
for i := range shade.Data {
|
||||
if shade.Data[i] < shadeFloor {
|
||||
shade.Data[i] = shadeFloor
|
||||
}
|
||||
}
|
||||
if err := field.WriteHillshade(filepath.Join(opt.Out, t.Name(prefix)+"_shade.png"), shade, iw, 1); err != nil {
|
||||
return rec, err
|
||||
}
|
||||
// A slice, not a map: map iteration order is randomised in Go and nothing in this generator is allowed
|
||||
// to depend on it (cross-cutting rule 12). Here it would only reorder two file writes, which is exactly
|
||||
// the kind of "it does not matter this time" that makes the rule worth keeping without exception.
|
||||
// The full-scale values are fixed constants, not percentiles of the tile.
|
||||
//
|
||||
// Field.ToUnit takes the 99th percentile of whatever it is given, which is exactly right for one map of
|
||||
// one world and exactly wrong here: it is a statistic of the tile's own extent, so two tiles would stretch
|
||||
// by different anchors and their shared valley would come out two different greys. That is the same
|
||||
// mistake the coastal pass's exposure made and had withdrawn, and the same rule - no pass computes a
|
||||
// statistic of the piece of the world it happens to be looking at.
|
||||
//
|
||||
// Flow is water-units accumulated and runs over decades, so it is log-scaled; wear and deposit are metres
|
||||
// and a metre of either is a great deal at a 2 m cell.
|
||||
flowFull := 40 * cfg.Particle.DropletsPerCell * float64(cfg.Particle.Lifetime)
|
||||
var derived []struct {
|
||||
name string
|
||||
data []float32
|
||||
full float64
|
||||
log bool
|
||||
}
|
||||
if maps != nil {
|
||||
derived = []struct {
|
||||
name string
|
||||
data []float32
|
||||
full float64
|
||||
log bool
|
||||
}{
|
||||
{"flow", maps.Flow, flowFull, true},
|
||||
{"wear", maps.Wear, 1.0, false},
|
||||
{"deposit", maps.Deposit, 1.0, false},
|
||||
}
|
||||
}
|
||||
for _, d := range derived {
|
||||
c := crop(&field.Field{W: h.W, H: h.H, CellM: h.CellM, Data: d.data}, ix, iy, iw, ih)
|
||||
if err := field.WriteGray8(filepath.Join(opt.Out, t.Name(prefix)+"_"+d.name+".png"), iw, ih,
|
||||
toBytes(normalise(c.Data, d.full, d.log)), png.BestSpeed); err != nil {
|
||||
return rec, err
|
||||
}
|
||||
}
|
||||
rec.Seconds = time.Since(start).Seconds()
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// upsampleClass takes the class raster up by an integer factor, nearest. A class index is a name and not a
|
||||
// quantity: interpolating one would invent a class that is neither of its neighbours.
|
||||
func crop(f *field.Field, x0, y0, w, h int) *field.Field {
|
||||
out := field.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
|
||||
}
|
||||
|
||||
// normalise maps values onto 0..1 against a fixed full-scale, never a percentile of the data. See the note
|
||||
// where the constants are chosen.
|
||||
func normalise(data []float32, full float64, logScale bool) []float32 {
|
||||
if full <= 0 {
|
||||
full = 1
|
||||
}
|
||||
top := full
|
||||
if logScale {
|
||||
top = math.Log1p(full)
|
||||
}
|
||||
out := make([]float32, len(data))
|
||||
for i, v := range data {
|
||||
x := float64(v)
|
||||
if x < 0 {
|
||||
x = 0
|
||||
}
|
||||
if logScale {
|
||||
x = math.Log1p(x)
|
||||
}
|
||||
out[i] = float32(x / top)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toBytes(data []float32) []uint8 {
|
||||
out := make([]uint8, len(data))
|
||||
for i, v := range data {
|
||||
x := v
|
||||
if x < 0 {
|
||||
x = 0
|
||||
} else if x > 1 {
|
||||
x = 1
|
||||
}
|
||||
out[i] = uint8(x*255 + 0.5)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// blendedClasses turns the painted class raster into the four numbers the detail passes read, per cell, with
|
||||
// the boundaries between classes faded rather than stepped.
|
||||
//
|
||||
// **Why the fade.** A class is a name and a name is never interpolated - the mask that travels to whatever
|
||||
// builds the level is still nearest neighbour, and it has to be. But the numbers a class stands for are
|
||||
// quantities. Kept as a lookup on the class index, a desert meeting a lowland went from seven metres of dune
|
||||
// amplitude to two, and from a fifth of the running water to all of it, in the width of one cell, along a
|
||||
// line somebody drew with a mouse. It read as what it was: a boundary in a picture rather than a change in
|
||||
// the ground. Faded over `class_blend_m`, 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 inside it.
|
||||
//
|
||||
// **Why at the geology grid.** The class raster is a geology-resolution field, so blurring it there costs a
|
||||
// four-hundredth of blurring at detail resolution, and the upsample afterwards is the same exact-factor
|
||||
// Catmull-Rom every other field gets - so the result is smoother than a blur at detail resolution would have
|
||||
// been, not coarser.
|
||||
//
|
||||
// **Why the radius is clamped.** A blur reads outside the cell it writes, and a tile only has its margin to
|
||||
// read from. Two passes of a box blur of radius r reach 2r, so r is capped at half the margin and the run
|
||||
// says so once when the manifest asks for more. Past that cap a tile would be blending against its own cut
|
||||
// edge and two tiles would disagree about the same ground, which is the one thing the tiling may not do.
|
||||
func blendedClasses(g *tile.Grid, t tile.Tile, opt TileOptions, geo, h *field.Field) *detail.Classes {
|
||||
in := opt.In
|
||||
cfg := in.M.Pipeline
|
||||
if !in.Legend.Overrides() {
|
||||
return nil
|
||||
}
|
||||
tbl := in.Legend.DetailTables(cfg.Particle.DropletsPerCell,
|
||||
cfg.Detail.AmplitudeM.Lo(), cfg.Detail.AmplitudeM.Hi(), cfg.Strata.Contrast)
|
||||
cls := g.CutClass(t, in.Map.Class, in.P.W, in.P.PadY)
|
||||
|
||||
radius := int(cfg.Detail.ClassBlendM/in.P.CellM + 0.5)
|
||||
if max := g.MarginGeo / 2; radius > max {
|
||||
radius = max
|
||||
}
|
||||
lift := func(table []float64) []float32 {
|
||||
f := field.New(geo.W, geo.H, geo.CellM)
|
||||
for i, k := range cls {
|
||||
f.Data[i] = float32(table[k])
|
||||
}
|
||||
if radius > 0 {
|
||||
field.BoxSmooth(f.Data, f.W, f.H, radius, 2)
|
||||
}
|
||||
return f.UpsampleInt(cfg.GeologyFactor).Data
|
||||
}
|
||||
return &detail.Classes{
|
||||
Droplets: lift(tbl.Droplets), AmpLo: lift(tbl.AmpLo),
|
||||
AmpHi: lift(tbl.AmpHi), Contrast: lift(tbl.Contrast),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user