Tooling
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
"""Cuts the window that RawContent/World/Region.json describes out of a cylindrical planet heightmap and writes
|
||||
it as a grid of Unreal-ready tiles in RawContent/World/RegionTiles/: a 16-bit height and one 8-bit weightmap
|
||||
per enabled paint layer, sized for one Landscape actor each.
|
||||
|
||||
Pure numpy, no engine: run it with the engine's Python (numpy lives in Scripts/Authoring/.pylib, see
|
||||
bootstrap-pylib.sh), or let create_region_world.py call it when the tiles are missing.
|
||||
|
||||
D:/UE_5.8/Engine/Binaries/ThirdParty/Python3/Win64/python.exe Scripts/Authoring/generate_region_tiles.py
|
||||
... --scout # measure the window and print what it holds, write nothing
|
||||
... --tiles 2,2 3,2 # just these tiles, for a look before committing to all ninety-eight
|
||||
... --all-layers # build every paint layer, including the biomes nothing can import yet
|
||||
|
||||
Seams. Neighbouring tiles share their edge vertices, and every vertex is sampled from the source by its
|
||||
*global* position in the window, so the shared column is computed from the same source coordinates twice and
|
||||
comes out bit-identical. The paint-layer break-up noise is sampled the same way, through fbm_at at global
|
||||
coordinates, for the same reason. Nothing here is per-tile except which slice of the window it covers, which is
|
||||
what makes a tile's interior what it would have been had the whole window been done in one piece.
|
||||
|
||||
Resampling. The window is the whole 8192-pixel export and the grid is 35701 vertices, so this is a fourfold
|
||||
*upsample* and the filter shows. It is Catmull-Rom, clamped to the two central taps: a plain cubic overshoots
|
||||
wherever the source has a step, and the source's steps are its coastlines, where it falls from land to abyss in
|
||||
a single pixel. Unclamped, every shore would get a raised lip on the land side and a trench on the sea side.
|
||||
Bilinear would not ring but would crease, leaving a visible facet edge along every source pixel boundary.
|
||||
|
||||
Paint layers. Three read the height alone - rock by slope, high rock by altitude, and one layer that is the
|
||||
remainder - and the rest read a *biome*: a whole-planet mask that `mapart biomes` rendered from the painting's
|
||||
classes and the Koppen climate, sampled here at the same global coordinates as the height. Which layers exist
|
||||
is Region.json's `layers.paint`, and a layer that is not enabled is not built (D-74), so the three-layer world
|
||||
this began as is exactly what an unchanged manifest still produces.
|
||||
|
||||
What this does not do. It does not erode, and there is no wear, flow or deposit map here to paint from, which
|
||||
is why the shore layer is an approximation from height and slope rather than a reading of where the coast pass
|
||||
actually laid a beach. The source's own detail is about 200 m (Orogen solves on a 204 K-region sphere mesh), so
|
||||
below that scale the ground is smooth, and it will stay smooth until the tiles come from `terrain tiles`
|
||||
instead. See Docs/World-Pipeline.md for the routes and Docs/Terrain-Next.md for where the real ground comes from.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, ".pylib"))
|
||||
import numpy as np # noqa: E402
|
||||
|
||||
import heightmap_io # noqa: E402
|
||||
import heightmap_noise # noqa: E402
|
||||
from region_manifest import MANIFEST_PATH, TILE_DIR, load_manifest # noqa: E402
|
||||
|
||||
# Vertices sampled beyond a tile on every side before the paint layers are derived, and thrown away after.
|
||||
# The layers read slope, np.gradient takes a one-sided difference at an array edge, and a one-sided difference
|
||||
# is not what the neighbouring tile computes for the same vertex: without this every tile boundary came out as
|
||||
# a one-vertex line of different paint. One vertex is all a central difference needs.
|
||||
LAYER_MARGIN = 1
|
||||
|
||||
LAYER_DEFAULTS = {
|
||||
"rock_slope_start": 0.55, # rise over run where rock starts to show through the meadow (about 29 degrees)
|
||||
"rock_slope_full": 1.05, # and where it is all rock (about 46 degrees)
|
||||
"high_altitude_start_m": 1400, # where the high rock layer starts
|
||||
"high_altitude_full_m": 2000, # and where it has taken over
|
||||
"breakup_m": 18, # noise added to the altitude before the rules, so boundaries are not contour lines
|
||||
"breakup_cells": 24, # the break-up noise's coarsest lattice across the whole window
|
||||
"breakup_seed": 7,
|
||||
}
|
||||
|
||||
# The shore rule. Height alone would run sand up every cliff that meets the sea, so `max_slope` is what makes
|
||||
# it a beach and not a contour band.
|
||||
BEACH_DEFAULTS = {
|
||||
"above_sea_m": 12.0, # full strength up to here
|
||||
"fade_m": 25.0, # and gone this much above it
|
||||
"max_slope": 0.18, # rise over run, about 10 degrees
|
||||
}
|
||||
|
||||
|
||||
def _cubic_weights(t):
|
||||
"""Catmull-Rom, for taps at -1, 0, +1, +2."""
|
||||
t2 = t * t
|
||||
t3 = t2 * t
|
||||
return (-0.5 * t3 + t2 - 0.5 * t,
|
||||
1.5 * t3 - 2.5 * t2 + 1.0,
|
||||
-1.5 * t3 + 2.0 * t2 + 0.5 * t,
|
||||
0.5 * t3 - 0.5 * t2)
|
||||
|
||||
|
||||
def resample_axis(src, coords, axis, wrap):
|
||||
"""One separable pass of clamped Catmull-Rom along `axis` at float `coords`. `wrap` takes the index modulo
|
||||
the axis length, for the source's east-west seam; otherwise it clamps to the edge. The result is held
|
||||
between the two central taps, which is what stops the filter ringing at a coastline."""
|
||||
i = np.floor(coords).astype(np.int64)
|
||||
weights = _cubic_weights((coords - i).astype(np.float32))
|
||||
length = src.shape[axis]
|
||||
indices = [i - 1, i, i + 1, i + 2]
|
||||
indices = [k % length if wrap else np.clip(k, 0, length - 1) for k in indices]
|
||||
taps = [np.take(src, k, axis=axis) for k in indices]
|
||||
shape = [1, 1]
|
||||
shape[axis] = -1
|
||||
out = sum(tap * weight.reshape(shape) for tap, weight in zip(taps, weights))
|
||||
return np.clip(out, np.minimum(taps[1], taps[2]), np.maximum(taps[1], taps[2]))
|
||||
|
||||
|
||||
def load_source_metres(manifest):
|
||||
"""The whole source map in metres, with everything below sea level scaled by `sea_scale`."""
|
||||
path = manifest.source_path
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"{manifest.path}: source {path} not found")
|
||||
values = heightmap_io.read_png(path)
|
||||
low, high = manifest.source_elevation
|
||||
metres = (low + values.astype(np.float32) / 65535.0 * (high - low)).astype(np.float32)
|
||||
scale = manifest.sea_scale
|
||||
if scale != 1.0:
|
||||
below = metres < 0.0
|
||||
metres[below] *= scale
|
||||
return metres
|
||||
|
||||
|
||||
def window_coords(manifest, indices, axis):
|
||||
"""Global vertex indices along one axis to source-pixel coordinates. The window's first and last pixel
|
||||
centres land on the window's first and last vertices, so the whole rectangle is used and no edge is
|
||||
extrapolated. Axis 0 is world X (the source's columns), axis 1 world Y (its rows)."""
|
||||
_, _, width, height = manifest.source_window
|
||||
span = (width if axis == 0 else height) - 1
|
||||
return indices.astype(np.float64) * span / manifest.quads_along(axis)
|
||||
|
||||
|
||||
def tile_vertices(manifest, tile, margin):
|
||||
"""Global vertex indices along one axis for a tile, with `margin` extra on each side. A margin runs off the
|
||||
window at the grid's outside edge, which is well defined: the source coordinate simply lands just outside
|
||||
the window, where the source still has pixels."""
|
||||
return np.arange(-margin, manifest.vertices_per_tile + margin) + tile * manifest.quads_per_tile
|
||||
|
||||
|
||||
def tile_metres(manifest, source, tx, ty, margin=0):
|
||||
"""One tile's height in metres, sampled from the source by global position."""
|
||||
x0, y0, _, _ = manifest.source_window
|
||||
width = source.shape[1]
|
||||
sx = x0 + window_coords(manifest, tile_vertices(manifest, tx, margin), 0)
|
||||
sy = y0 + window_coords(manifest, tile_vertices(manifest, ty, margin), 1)
|
||||
|
||||
# Only the source rows this tile reaches, with all columns kept so the x wrap is a plain modulo.
|
||||
row0 = int(np.floor(sy[0])) - 1
|
||||
row1 = int(np.floor(sy[-1])) + 3
|
||||
rows = np.clip(np.arange(row0, row1), 0, source.shape[0] - 1)
|
||||
band = source[rows]
|
||||
band = resample_axis(band, sx % width, axis=1, wrap=True)
|
||||
return resample_axis(band, sy - row0, axis=0, wrap=False).astype(np.float32)
|
||||
|
||||
|
||||
def apply_spawn_pad(manifest, metres, tx, ty, margin=0):
|
||||
"""The flat disc at the centre of the *window* for the player starts, blended over a second radius. It is
|
||||
computed from global position, so where it crosses a tile boundary the two tiles agree."""
|
||||
if manifest.spawn_pad_m <= 0:
|
||||
return metres
|
||||
quad_m = manifest.quad_cm / 100.0
|
||||
dx = (tile_vertices(manifest, tx, margin) - manifest.quads_x / 2.0) * quad_m
|
||||
dy = (tile_vertices(manifest, ty, margin) - manifest.quads_y / 2.0) * quad_m
|
||||
dist = np.sqrt(dx[None, :] ** 2 + dy[:, None] ** 2)
|
||||
radius = manifest.spawn_pad_m
|
||||
weight = heightmap_noise.smoothstep(np.clip(1.0 - (dist - radius) / radius, 0.0, 1.0)).astype(np.float32)
|
||||
if not weight.any():
|
||||
return metres
|
||||
return (metres * (1.0 - weight) + pad_height(manifest) * weight).astype(np.float32)
|
||||
|
||||
|
||||
def pad_height(manifest):
|
||||
"""Height of the spawn pad, in metres. Read from the source at the window's exact centre rather than from a
|
||||
tile, so every tile the pad touches lifts to the same level."""
|
||||
if not hasattr(manifest, "_pad_height"):
|
||||
raise RuntimeError("pad height not measured; measure_pad_height first")
|
||||
return manifest._pad_height
|
||||
|
||||
|
||||
def measure_pad_height(manifest, source):
|
||||
x0, y0, win_w, win_h = manifest.source_window
|
||||
sx = np.array([x0 + (win_w - 1) / 2.0], dtype=np.float64) % source.shape[1]
|
||||
sy = np.array([y0 + (win_h - 1) / 2.0], dtype=np.float64)
|
||||
row0 = int(np.floor(sy[0])) - 1
|
||||
rows = np.clip(np.arange(row0, row0 + 4), 0, source.shape[0] - 1)
|
||||
band = resample_axis(source[rows], sx, axis=1, wrap=True)
|
||||
height = float(resample_axis(band, sy - row0, axis=0, wrap=False)[0, 0])
|
||||
manifest._pad_height = max(height, manifest.sea_level_m + 30.0) # never a pad in the sea
|
||||
return manifest._pad_height
|
||||
|
||||
|
||||
def load_biome_masks(manifest, layers):
|
||||
"""The blurred 0..1 masks `mapart biomes` wrote, one per class- or climate-driven layer.
|
||||
|
||||
Read whole and kept in memory: the largest is 7738x3761 of uint8, 29 MB, and every tile samples all of it.
|
||||
They are 8-bit greyscale, which is the only kind of PNG heightmap_io decodes quickly - the classification
|
||||
that produced them had to happen in Go because the painting is RGB (D-74)."""
|
||||
masks = {}
|
||||
for layer in layers:
|
||||
if not layer.reads_mask:
|
||||
continue
|
||||
path = manifest.mask_path(layer)
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(
|
||||
f"paint layer {layer.name!r} reads a {layer.rule} mask and {path} is not there.\n"
|
||||
f" Render the masks first: cd Tools/MapArt && go run . biomes")
|
||||
masks[layer.name] = heightmap_io.read_png(path)
|
||||
return masks
|
||||
|
||||
|
||||
def sample_planet_map(manifest, planet, source_shape, tx, ty, margin=0):
|
||||
"""A whole-planet map sampled at one tile's vertices, by the same global coordinates the height uses.
|
||||
|
||||
`planet` may be any resolution: it is addressed in normalised u,v, which is what lets a 7738-wide painting
|
||||
and an 8192-wide heightmap describe the same ground without either being resampled to match the other.
|
||||
Same filter as the height, so a mask's edge lands where the slope under it does, and seam-exact for the
|
||||
same reason: a shared vertex is computed from the same global coordinates in both tiles."""
|
||||
src_h, src_w = source_shape
|
||||
ph, pw = planet.shape
|
||||
x0, y0, _, _ = manifest.source_window
|
||||
sx = (x0 + window_coords(manifest, tile_vertices(manifest, tx, margin), 0)) / src_w * pw
|
||||
sy = (y0 + window_coords(manifest, tile_vertices(manifest, ty, margin), 1)) / src_h * ph
|
||||
|
||||
row0 = int(np.floor(sy[0])) - 1
|
||||
row1 = int(np.floor(sy[-1])) + 3
|
||||
rows = np.clip(np.arange(row0, row1), 0, ph - 1)
|
||||
band = planet[rows].astype(np.float32)
|
||||
band = resample_axis(band, sx % pw, axis=1, wrap=True)
|
||||
return resample_axis(band, sy - row0, axis=0, wrap=False).astype(np.float32)
|
||||
|
||||
|
||||
def beach_weight(metres, slope, rules):
|
||||
"""The shore layer: low ground that is also flat. Height alone would put sand up every cliff that happens
|
||||
to meet the sea, which is most of a rocky coast, so the slope term is what makes it a beach rather than a
|
||||
contour band. An approximation, and knowingly so - the bake's coast pass knows where beaches actually are
|
||||
and this route does not carry it (D-74)."""
|
||||
beach = dict(BEACH_DEFAULTS, **rules.get("beach", {}))
|
||||
above = float(beach["above_sea_m"])
|
||||
fade = max(float(beach["fade_m"]), 1e-6)
|
||||
by_height = np.clip(1.0 - (metres - above) / fade, 0.0, 1.0)
|
||||
by_height = np.where(metres < 0.0, 0.0, by_height) # underwater is not a beach
|
||||
by_slope = np.clip(1.0 - slope / max(float(beach["max_slope"]), 1e-6), 0.0, 1.0)
|
||||
return (heightmap_noise.smoothstep(by_height) * heightmap_noise.smoothstep(by_slope)).astype(np.float32)
|
||||
|
||||
|
||||
def derive_layers(manifest, metres, tx, ty, margin=0, masks=None, source_shape=None, layers=None):
|
||||
"""Every enabled paint layer's weight for one tile, as uint8 summing to 255.
|
||||
|
||||
Three kinds of rule. `slope` and `altitude` read the tile's own height, as they always have, with an fBm
|
||||
break-up so neither boundary is a contour line. `beach` reads height and slope together. `class` and
|
||||
`climate` read a whole-planet mask sampled at the same global coordinates as the height.
|
||||
|
||||
The composition is a priority, not a blend: rock takes steep ground whatever biome it is in, high rock
|
||||
takes altitude, the shore takes what is left near the sea, the biomes divide what remains, and one layer
|
||||
is the remainder and absorbs everything nobody claimed. A layer that is not enabled is simply not in the
|
||||
competition, so today's three-layer world is exactly what it was before the biomes existed.
|
||||
|
||||
`metres` carries `margin` vertices of its neighbours on every side; everything is computed over the lot
|
||||
and the margin cropped at the end, so the slope at a tile's edge is the central difference its neighbour
|
||||
computes there too."""
|
||||
rules = {**LAYER_DEFAULTS, **manifest.layers}
|
||||
layers = list(layers if layers is not None else manifest.enabled_layers)
|
||||
quad_m = manifest.quad_cm / 100.0
|
||||
# Both axes are divided by the *longer* one, so the noise stays square on the ground and, because neither
|
||||
# coordinate then exceeds 1, it never repeats across the window - fbm_at is periodic with period 1, so
|
||||
# dividing by anything smaller (a tile, say) would stamp the same pattern out every few kilometres.
|
||||
span = float(max(manifest.quads_x, manifest.quads_y))
|
||||
u = (tile_vertices(manifest, tx, margin) / span).astype(np.float32)
|
||||
v = (tile_vertices(manifest, ty, margin) / span).astype(np.float32)
|
||||
u, v = np.broadcast_arrays(u[None, :], v[:, None])
|
||||
|
||||
rng = np.random.default_rng(int(rules["breakup_seed"]))
|
||||
noise = heightmap_noise.fbm_at(u, v, rng, base_cells=int(rules["breakup_cells"]), octaves=4)
|
||||
breakup = (noise - 0.5) * 2.0 * float(rules["breakup_m"])
|
||||
|
||||
gy, gx = np.gradient(metres, quad_m)
|
||||
slope = np.sqrt(gx * gx + gy * gy)
|
||||
slope_breakup = breakup / float(rules["breakup_m"]) * 0.12 if rules["breakup_m"] else 0.0
|
||||
|
||||
rock = heightmap_noise.smoothstep(np.clip(
|
||||
(slope + slope_breakup - rules["rock_slope_start"])
|
||||
/ (rules["rock_slope_full"] - rules["rock_slope_start"]), 0.0, 1.0))
|
||||
high = heightmap_noise.smoothstep(np.clip(
|
||||
(metres + breakup - rules["high_altitude_start_m"])
|
||||
/ (rules["high_altitude_full_m"] - rules["high_altitude_start_m"]), 0.0, 1.0))
|
||||
high = high * (1.0 - rock * 0.5)
|
||||
|
||||
weights = {}
|
||||
for layer in layers:
|
||||
if layer.rule == "slope":
|
||||
weights[layer.name] = rock
|
||||
elif layer.rule == "altitude":
|
||||
weights[layer.name] = high
|
||||
# What no rule has claimed yet. Everything below takes its share out of this and never out of thin air,
|
||||
# which is what keeps the weights summing to one without a final renormalise changing anyone's meaning.
|
||||
free = np.ones_like(metres)
|
||||
for value in weights.values():
|
||||
free = free - value
|
||||
free = np.clip(free, 0.0, 1.0)
|
||||
|
||||
for layer in layers:
|
||||
if layer.rule == "beach":
|
||||
beach = beach_weight(metres + breakup, slope, rules) * free
|
||||
weights[layer.name] = beach
|
||||
free = np.clip(free - beach, 0.0, 1.0)
|
||||
|
||||
# The biome layers divide what is left. They can overlap - a tropical desert is painted desert inside a
|
||||
# Koppen savanna band - so where they sum past one they are scaled down together rather than one of them
|
||||
# being picked; that keeps a boundary a blend instead of a decision.
|
||||
biome_layers = [layer for layer in layers if layer.reads_mask]
|
||||
if biome_layers:
|
||||
raw = {}
|
||||
for layer in biome_layers:
|
||||
value = sample_planet_map(manifest, masks[layer.name], source_shape, tx, ty, margin) / 255.0
|
||||
raw[layer.name] = np.clip(value, 0.0, 1.0)
|
||||
stack = sum(raw.values())
|
||||
scale = np.where(stack > 1.0, 1.0 / np.maximum(stack, 1e-6), 1.0).astype(np.float32)
|
||||
for name, value in raw.items():
|
||||
weights[name] = value * scale * free
|
||||
free = np.clip(free - sum(weights[name] for name in raw), 0.0, 1.0)
|
||||
|
||||
for layer in layers:
|
||||
if layer.rule == "remainder":
|
||||
weights[layer.name] = free
|
||||
|
||||
total = np.maximum(sum(weights.values()), 1e-6)
|
||||
inside = slice(margin, metres.shape[0] - margin) if margin else slice(None)
|
||||
scaled = {name: w[inside, inside] / total[inside, inside] * 255.0 for name, w in weights.items()}
|
||||
rounded = {name: np.rint(value).astype(np.int32) for name, value in scaled.items()}
|
||||
|
||||
# The weights sum to exactly 255 before rounding and to 255 give or take a couple after it, because eight
|
||||
# layers round independently. The remainder layer absorbs the difference: it is the one whose meaning is
|
||||
# "whatever is left", so a unit of rounding error belongs to it and to nobody else. Where it is already
|
||||
# zero there is nothing to take the error out of, which is the one place a tile can still be a unit short.
|
||||
remainder = next((layer.name for layer in layers if layer.rule == "remainder"), None)
|
||||
if remainder is not None:
|
||||
residual = 255 - sum(rounded.values())
|
||||
rounded[remainder] = np.clip(rounded[remainder] + residual, 0, 255)
|
||||
return {name: value.astype(np.uint8) for name, value in rounded.items()}
|
||||
|
||||
|
||||
def write_tile(manifest, metres, layers, tx, ty, out_dir):
|
||||
clipped = float(((metres < manifest.elevation_min_m) | (metres > manifest.elevation_max_m)).mean())
|
||||
bounded = np.clip(metres, manifest.elevation_min_m, manifest.elevation_max_m)
|
||||
height = np.rint(manifest.metres_to_value(bounded)).clip(0, 65535).astype(np.uint16)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
heightmap_io.write_png(os.path.join(out_dir, os.path.basename(manifest.height_path(tx, ty))), height)
|
||||
for name, data in layers.items():
|
||||
heightmap_io.write_png(os.path.join(out_dir, os.path.basename(manifest.weight_path(tx, ty, name))), data)
|
||||
return clipped
|
||||
|
||||
|
||||
def scout(manifest, source):
|
||||
"""What the window holds, without writing anything: the numbers that decide whether it is the right window."""
|
||||
x0, y0, win_w, win_h = manifest.source_window
|
||||
height, width = source.shape
|
||||
columns = (np.arange(x0, x0 + win_w) % width)
|
||||
window = source[y0:y0 + win_h][:, columns]
|
||||
land = window > manifest.sea_level_m
|
||||
mx, my = manifest.metres_per_pixel(0), manifest.metres_per_pixel(1)
|
||||
print(f"window {win_w}x{win_h} px at ({x0}, {y0}) over "
|
||||
f"{manifest.width_m / 1000:.2f} x {manifest.height_m / 1000:.2f} km of landscape")
|
||||
print(f" {mx:.4f} m a pixel in X, {my:.4f} in Y"
|
||||
+ (" -- EQUAL, so the ground is not stretched" if abs(mx - my) < 1e-6 else
|
||||
f" -- UNEQUAL: the ground is stretched {abs(mx / my - 1) * 100:.1f}% in X against Y; "
|
||||
f"make columns/rows match the window's width/height"))
|
||||
if win_w == width and win_h == height:
|
||||
print(" this is the whole export, not a crop of it")
|
||||
else:
|
||||
latitude = (0.5 - (y0 + win_h / 2.0) / height) * 180.0
|
||||
stretch = 1.0 / np.cos(np.deg2rad(latitude)) - 1.0
|
||||
print(f" centre latitude {latitude:+.2f} on the source, so a flat reading stretches it "
|
||||
f"{stretch * 100:.1f}% east-west against the globe it came from")
|
||||
print(f" land {land.mean() * 100:.2f}% = {land.mean() * manifest.area_km2:.0f} km2 "
|
||||
f"of {manifest.area_km2:.0f} km2")
|
||||
print(f" elevation {window.min():.0f}..{window.max():.0f} m "
|
||||
f"(land median {np.median(window[land]):.0f} m, 99th {np.percentile(window[land], 99):.0f} m)")
|
||||
outside = float(((window < manifest.elevation_min_m) | (window > manifest.elevation_max_m)).mean())
|
||||
print(f" {outside * 100:.3f}% of the source window falls outside elevation_m and would clip")
|
||||
step = np.abs(np.diff(window, axis=1)).max()
|
||||
print(f" steepest single-pixel step {step:.0f} m over {mx:.1f} m: the filter is clamped so it does not ring")
|
||||
print(f" {mx:.2f} m a pixel resampled to {manifest.quad_cm / 100:.0f} m quads: "
|
||||
f"a {mx / (manifest.quad_cm / 100):.1f}x upsample")
|
||||
print(f" {manifest.tile_count} tiles, {manifest.tile_count * (manifest.quads_per_tile // 255) ** 2} "
|
||||
f"components, {manifest.vertices_x * manifest.vertices_y / 1e6:.0f} M vertices")
|
||||
|
||||
|
||||
def parse_tiles(spec, manifest):
|
||||
if not spec:
|
||||
return list(manifest.tiles())
|
||||
chosen = []
|
||||
for item in spec:
|
||||
tx, ty = (int(part) for part in item.split(","))
|
||||
if not (0 <= tx < manifest.tiles_x and 0 <= ty < manifest.tiles_y):
|
||||
raise SystemExit(f"tile {item} is outside the {manifest.tiles_x}x{manifest.tiles_y} grid")
|
||||
chosen.append((tx, ty))
|
||||
return chosen
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument("--manifest", default=MANIFEST_PATH)
|
||||
parser.add_argument("--out", default=TILE_DIR)
|
||||
parser.add_argument("--scout", action="store_true", help="measure the window and print what it holds, write nothing")
|
||||
parser.add_argument("--tiles", nargs="*", metavar="TX,TY", help="only these tiles; all of them by default")
|
||||
parser.add_argument("--all-layers", action="store_true",
|
||||
help="build every paint layer, not only the enabled ones. For looking at a biome "
|
||||
"before there is a substance for it; the level cannot import what the material "
|
||||
"does not blend, so this is a preview switch and not a build one.")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
manifest = load_manifest(args.manifest)
|
||||
print(manifest.describe())
|
||||
started = time.time()
|
||||
source = load_source_metres(manifest)
|
||||
print(f"source {os.path.basename(manifest.source_path)}: {source.shape[1]}x{source.shape[0]}, "
|
||||
f"{source.min():.0f}..{source.max():.0f} m after sea_scale {manifest.sea_scale:g} "
|
||||
f"({time.time() - started:.0f} s)")
|
||||
|
||||
if args.scout:
|
||||
scout(manifest, source)
|
||||
return
|
||||
|
||||
measure_pad_height(manifest, source)
|
||||
paint = manifest.paint_layers if args.all_layers else manifest.enabled_layers
|
||||
masks = load_biome_masks(manifest, paint)
|
||||
waiting = [layer.name for layer in manifest.paint_layers if layer not in paint]
|
||||
print("layers: " + ", ".join(f"{layer.name}({layer.rule})" for layer in paint)
|
||||
+ (f"; not enabled: {', '.join(waiting)}" if waiting else ""))
|
||||
if masks:
|
||||
print(f"biome masks: {', '.join(sorted(masks))} from {manifest.masks_dir} "
|
||||
f"({manifest.biome_blend_m:g} m blend)")
|
||||
chosen = parse_tiles(args.tiles, manifest)
|
||||
lowest, highest, land_total, clipped_worst = 1e9, -1e9, 0.0, 0.0
|
||||
for index, (tx, ty) in enumerate(chosen, 1):
|
||||
tile_started = time.time()
|
||||
margined = tile_metres(manifest, source, tx, ty, LAYER_MARGIN)
|
||||
margined = apply_spawn_pad(manifest, margined, tx, ty, LAYER_MARGIN)
|
||||
layers = derive_layers(manifest, margined, tx, ty, LAYER_MARGIN,
|
||||
masks=masks, source_shape=source.shape, layers=paint)
|
||||
metres = margined[LAYER_MARGIN:-LAYER_MARGIN, LAYER_MARGIN:-LAYER_MARGIN]
|
||||
clipped = write_tile(manifest, metres, layers, tx, ty, args.out)
|
||||
land = float((metres > manifest.sea_level_m).mean())
|
||||
lowest, highest = min(lowest, float(metres.min())), max(highest, float(metres.max()))
|
||||
land_total += land
|
||||
clipped_worst = max(clipped_worst, clipped)
|
||||
print(f" [{index:2d}/{len(chosen)}] {manifest.tile_name(tx, ty)}: "
|
||||
f"{metres.min():7.1f}..{metres.max():7.1f} m, {land * 100:5.1f}% land, "
|
||||
f"{clipped * 100:.3f}% clipped, {time.time() - tile_started:.1f} s")
|
||||
|
||||
area = manifest.area_km2 * len(chosen) / manifest.tile_count
|
||||
print(f"{len(chosen)} tiles: {lowest:.0f}..{highest:.0f} m, "
|
||||
f"{land_total / len(chosen) * 100:.1f}% land = {land_total / len(chosen) * area:.0f} km2 of {area:.0f} km2, "
|
||||
f"worst tile {clipped_worst * 100:.3f}% clipped; {time.time() - started:.0f} s; written to {args.out}")
|
||||
if clipped_worst > 0.001:
|
||||
print(" clipping above 0.1% means elevation_m is too narrow for this window; widen it and rerun")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user