This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+453
View File
@@ -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()