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
+350
View File
@@ -0,0 +1,350 @@
"""The region manifest: RawContent/World/Region.json, which says how a window of a cylindrical planet map
becomes a tiled Unreal landscape. Pure Python (no numpy, no engine), shared by generate_region_tiles.py (writes
the PNGs) and create_region_world.py (imports them), so both agree without either knowing about the other.
Why a second manifest beside World.json. World.json describes one landscape built by the numpy pipeline from
noise; this describes a *window cut from a finished planet map* and laid out as a grid of landscapes, because
2549 km2 at a 2 m quad is 637 M vertices and no single Landscape actor is going to take that in one import. The
two share the height contract (`elevation_m` spans the 16-bit range, world Z 0 is elevation 0 m) and nothing
else.
**The grid is rectangular.** `tiles.columns` run along world X and `tiles.rows` along world Y, because a
cylindrical planet map is 2:1 and forcing it into a square either crops it or stretches it. Set the two so that
columns/rows matches the window's width/height in pixels and the ground is undistorted;
`generate_region_tiles.py --scout` prints the metres-per-pixel of each axis and complains when they disagree.
The window. The source is read as a *flat* image: `source.metres_per_pixel` says what one of its pixels is worth
and `source.window` is a rectangle of pixels in it (the whole image, by default). A cylindrical map read flat is
stretched east-west by 1/cos(latitude), which is a few per cent at middle latitudes and severe near the poles;
reading it flat is deliberate, because unprojecting needs a circumference the map does not carry and every
projection of a window this size distorts more than the flat reading does.
The tiles. `columns` x `rows` landscapes of `tiles.vertices` a side, each its own actor at its own place in one
world-partitioned level. Neighbours *share* their edge vertices: tile (tx, ty) covers global vertices
[tx * quads_per_tile, tx * quads_per_tile + quads_per_tile], so the last column of one tile is the first column
of the next and the seam is closed by construction rather than by blending. Pick `tiles.vertices` as 255 * N + 1
so the engine gives every tile N x N components of 255 quads; see RawContent/World/README.md for why the
component count is what matters.
"""
import json
import os
from world_manifest import ENGINE_SPAN_M_AT_SCALE_100, PROJECT_ROOT, WORLD_DIR
MANIFEST_PATH = os.path.join(WORLD_DIR, "Region.json")
TILE_DIR = os.path.join(WORLD_DIR, "RegionTiles")
# The paint layers a manifest gets when it names none, which is every manifest written before D-74: the three
# Elite_RockyMeadows' landscape material blends. The names mislead and are the pack's - Base_Layer is the rock,
# Layer_02 the meadow grass, Layer_03 the high rock - and they are kept because the material blends by name.
LEGACY_PAINT = (
{"name": "Base_Layer", "rule": "slope"},
{"name": "Layer_03", "rule": "altitude"},
{"name": "Layer_02", "rule": "remainder"},
)
# Still exported: region_overview.py and create_region_world.py imported it before the paint list existed, and
# a manifest with no `paint` block still produces exactly this.
LAYER_SUFFIXES = {entry["name"]: entry["name"] for entry in LEGACY_PAINT}
# Every rule a paint layer may carry. `slope`, `altitude` and `beach` are read off the tile's own height;
# `class` and `climate` are read off a biome mask that Tools/MapArt wrote; `remainder` is whatever no other
# layer claimed, and exactly one layer must be it or the weights do not sum to 255.
PAINT_RULES = ("slope", "altitude", "beach", "class", "climate", "remainder")
class PaintLayer:
"""One paint layer: what the landscape material blends, what drives it, and whether it is built yet.
`enabled` is what lets the biome layers land before the substances do. A layer that is not enabled is
written by nobody and imported by nobody, so today's three-layer world is untouched, but `mapart biomes`
still renders its mask - which is what makes a biome inspectable before there is any material for it.
"""
def __init__(self, data):
self.name = data["name"]
self.rule = data.get("rule", "remainder")
# The file suffix defaults to the layer's name, so L_World_x0_y0_Sand.png needs no second spelling.
self.suffix = data.get("suffix", self.name)
self.enabled = bool(data.get("enabled", True))
self.classes = list(data.get("classes", []))
self.koppen = list(data.get("koppen", []))
self.note = data.get("note", "")
if self.rule not in PAINT_RULES:
raise ValueError(f"paint layer {self.name!r}: unknown rule {self.rule!r}; expected one of {PAINT_RULES}")
if self.rule == "class" and not self.classes:
raise ValueError(f"paint layer {self.name!r}: rule 'class' names no classes")
if self.rule == "climate" and not self.koppen:
raise ValueError(f"paint layer {self.name!r}: rule 'climate' names no Koppen codes")
@property
def reads_mask(self):
"""True when this layer's weight comes from a mask file rather than from the tile's own height."""
return self.rule in ("class", "climate")
def __repr__(self):
return f"PaintLayer({self.name!r}, {self.rule!r}, enabled={self.enabled})"
HEIGHT_SUFFIX = "Height"
MARKS_SUFFIX = "Marks" # reserved for the overlay's 8-bit mark index (D-57). Nothing writes one yet
class RegionManifest:
def __init__(self, data, path=MANIFEST_PATH):
self.path = path
# The one world level. Tile files are named after its last segment, so changing `level` renames every
# tile the manifest expects - rename the PNGs in RegionTiles/ to match or they are all regenerated.
self.level = data.get("level", "/Game/Maps/L_World")
self.quad_cm = float(data["quad_cm"])
tiles = data["tiles"]
# `count` is the old square spelling; columns/rows supersede it.
square = int(tiles["count"]) if "count" in tiles else None
self.tiles_x = int(tiles.get("columns", square))
self.tiles_y = int(tiles.get("rows", square))
self.vertices_per_tile = int(tiles["vertices"])
self.elevation_min_m = float(data["elevation_m"]["min"])
self.elevation_max_m = float(data["elevation_m"]["max"])
self.sea_level_m = float(data.get("sea_level_m", 0.0))
self.spawn_pad_m = float(data.get("spawn_pad_m", 150.0))
self.streaming_grid_components = int(data.get("streaming_grid_components", 4))
self.source = dict(data["source"])
self.overlay = dict(data.get("overlay", {}))
self.layers = dict(data.get("layers", {}))
# The paint layers, in order. A manifest with no `paint` block gets the three the pack's material
# blends, which is what every manifest written before D-74 means.
self.paint_layers = [PaintLayer(entry) for entry in self.layers.get("paint", LEGACY_PAINT)]
names = [layer.name for layer in self.paint_layers]
if len(set(names)) != len(names):
raise ValueError(f"{path}: two paint layers share a name: {names}")
remainders = [layer.name for layer in self.paint_layers if layer.rule == "remainder"]
if len(remainders) != 1:
# Not a warning. With none the weights do not reach 255 and the ground shows the material's first
# layer wherever nothing claimed it; with two they fight over the same leftover.
raise ValueError(f"{path}: exactly one paint layer must have rule 'remainder', found {remainders}")
if not any(layer.enabled for layer in self.paint_layers):
raise ValueError(f"{path}: no paint layer is enabled, so the landscape would have no weightmaps")
# Where the biome masks come from and where Tools/MapArt puts them.
self.biomes = dict(self.layers.get("biomes", {}))
if min(self.tiles_x, self.tiles_y) < 1 or self.vertices_per_tile < 2:
raise ValueError(f"{path}: tiles.columns/rows must be at least 1 and tiles.vertices at least 2")
if self.elevation_max_m <= self.elevation_min_m or self.quad_cm <= 0:
raise ValueError(f"{path}: quad_cm must be positive and elevation_m ordered")
# Geometry: one tile, then the whole window.
@property
def quads_per_tile(self):
return self.vertices_per_tile - 1
@property
def tile_side_m(self):
return self.quads_per_tile * self.quad_cm / 100.0
@property
def quads_x(self):
return self.quads_per_tile * self.tiles_x
@property
def quads_y(self):
return self.quads_per_tile * self.tiles_y
@property
def vertices_x(self):
"""Distinct vertices across the window in X. Tiles share their edges, so it is not columns * vertices."""
return self.quads_x + 1
@property
def vertices_y(self):
return self.quads_y + 1
@property
def width_m(self):
return self.quads_x * self.quad_cm / 100.0
@property
def height_m(self):
return self.quads_y * self.quad_cm / 100.0
@property
def area_km2(self):
return (self.width_m / 1000.0) * (self.height_m / 1000.0)
@property
def side_m(self):
"""How big this world is, for anything that only needs one number: the sky dome's radius, the sea
plane's size, the fog's density. The longer axis, so those all still cover the whole world.
rocky_meadows.py reads this and World.json's manifest has it too."""
return max(self.width_m, self.height_m)
def centre_vertex(self):
"""(tx, ty, i, j) of the vertex at the centre of the window: which tile it is in and where in that
tile's PNG, columns then rows. On an odd grid the centre is mid-tile rather than on a corner, which is
why this is worked out rather than assumed to be tile (n/2, n/2) at [0, 0]."""
gi, gj = self.quads_x // 2, self.quads_y // 2
tx, i = divmod(gi, self.quads_per_tile)
ty, j = divmod(gj, self.quads_per_tile)
if tx >= self.tiles_x: # the far edge belongs to the last tile's last vertex
tx, i = self.tiles_x - 1, self.quads_per_tile
if ty >= self.tiles_y:
ty, j = self.tiles_y - 1, self.quads_per_tile
return tx, ty, i, j
@property
def tile_count(self):
return self.tiles_x * self.tiles_y
def quads_along(self, axis):
"""Quads across the whole window along axis 0 (world X, the columns) or 1 (world Y, the rows)."""
return self.quads_x if axis == 0 else self.quads_y
def tiles_along(self, axis):
return self.tiles_x if axis == 0 else self.tiles_y
# The height contract, identical to World.json's.
@property
def elevation_span_m(self):
return self.elevation_max_m - self.elevation_min_m
@property
def elevation_mid_m(self):
return (self.elevation_max_m + self.elevation_min_m) / 2.0
@property
def z_scale(self):
"""The landscape actor's Z scale, so the 16-bit range spans exactly the manifest's elevation range."""
return self.elevation_span_m / ENGINE_SPAN_M_AT_SCALE_100 * 100.0
@property
def landscape_z_cm(self):
"""Every tile's Z: value 32768 sits at elevation_mid, so elevation 0 m lands on world Z 0."""
return self.elevation_mid_m * 100.0
@property
def sea_level_z_cm(self):
return self.sea_level_m * 100.0
def metres_to_value(self, metres):
return (metres - self.elevation_min_m) / self.elevation_span_m * 65535.0
# The source window.
@property
def source_path(self):
"""The image the tiles are cut from. Not every manifest has one: World Orogen's Unreal export
renders the tiles itself and records where they came from instead of naming a file, so `kind` is
`orogen_render` and there is nothing here to re-cut from. Only generate_region_tiles.py asks for
this, and it is the one thing that cannot run against such a manifest - said plainly here, because
the path into it is `create_region_world.ensure_tiles` noticing a *missing tile file*, and a bare
KeyError three frames down does not explain that the tiles have to come from Orogen again."""
if "path" not in self.source:
raise RuntimeError(
f"{self.path}: source.kind is {self.source.get('kind', 'unset')!r} and names no file, so the "
f"tiles cannot be cut here - they were written by World Orogen's Unreal landscape export. "
f"Re-export them from Orogen into {TILE_DIR}, or point source.path at a planet heightmap to "
f"use generate_region_tiles.py instead."
)
return self.resolve(self.source["path"])
@property
def source_elevation(self):
"""What the source's 0 and 65535 mean in metres. Orogen's heightmap export is a fixed -5000..6000 ramp
whatever the planet, which is why this is a manifest number rather than something read from the file."""
elevation = self.source.get("elevation_m", {"min": -5000.0, "max": 6000.0})
return float(elevation["min"]), float(elevation["max"])
@property
def source_window(self):
"""(x, y, width, height) in source pixels. x is taken modulo the image width, so a window may cross the
map's seam. `size` is the old square spelling."""
window = self.source["window"]
if "size" in window:
return int(window["x"]), int(window["y"]), int(window["size"]), int(window["size"])
return int(window["x"]), int(window["y"]), int(window["width"]), int(window["height"])
@property
def sea_scale(self):
"""Everything below sea level in the source is multiplied by this. The Orogen export puts its abyss at
-5000 m on a fixed ramp built for a whole planet; left alone it would either clip against
`elevation_m.min` or force an elevation range so wide the land loses its precision."""
return float(self.source.get("sea_scale", 1.0))
def metres_per_pixel(self, axis):
"""Ground metres one source pixel is worth along axis 0 (X) or 1 (Y). Derived from the window and the
world, so the two can never drift apart; the manifest's own `metres_per_pixel` is documentation."""
_, _, width, height = self.source_window
return (self.width_m / width) if axis == 0 else (self.height_m / height)
# Files.
def tile_name(self, tx, ty):
return f"{os.path.basename(self.level)}_x{tx}_y{ty}"
def tile_path(self, tx, ty, suffix):
return os.path.join(TILE_DIR, f"{self.tile_name(tx, ty)}_{suffix}.png")
def height_path(self, tx, ty):
return self.tile_path(tx, ty, HEIGHT_SUFFIX)
# The paint layers.
@property
def enabled_layers(self):
"""The layers that are written, imported and blended. The others exist in the manifest and have masks,
and are waiting for a substance (D-74)."""
return [layer for layer in self.paint_layers if layer.enabled]
def find_layer(self, layer_name):
for layer in self.paint_layers:
if layer.name == layer_name:
return layer
raise KeyError(f"{self.path}: no paint layer named {layer_name!r}")
def weight_path(self, tx, ty, layer_name):
return self.tile_path(tx, ty, self.find_layer(layer_name).suffix)
def marks_path(self, tx, ty):
return self.tile_path(tx, ty, MARKS_SUFFIX)
def tile_files(self, tx, ty):
"""Every file a tile needs to exist before it can be imported. Only the enabled layers: a tile is not
missing because a biome nobody has a substance for has no weightmap."""
return ([self.height_path(tx, ty)]
+ [self.weight_path(tx, ty, layer.name) for layer in self.enabled_layers])
# The biome masks, which Tools/MapArt writes and generate_region_tiles.py samples.
@property
def masks_dir(self):
return self.resolve(self.biomes.get("masks_dir", "RawContent/World/Biomes"))
def mask_path(self, layer):
"""The mask for a class- or climate-driven layer. Named after the layer, not after what it reads, so
two layers reading the same class still get one file each."""
return os.path.join(self.masks_dir, f"mask_{layer.name.lower()}.png")
@property
def biome_blend_m(self):
return float(self.biomes.get("blend_m", 400.0))
def tiles(self):
for ty in range(self.tiles_y):
for tx in range(self.tiles_x):
yield tx, ty
# Placement. The landscape library centres a landscape on the Location it is given, so a tile's location is
# its own centre, measured from the window's centre so the whole grid straddles the origin.
def tile_centre_cm(self, tx, ty):
cx = (tx * self.quads_per_tile + self.quads_per_tile / 2.0) - self.quads_x / 2.0
cy = (ty * self.quads_per_tile + self.quads_per_tile / 2.0) - self.quads_y / 2.0
return cx * self.quad_cm, cy * self.quad_cm
def resolve(self, relative):
"""A manifest path is relative to the project root unless it is absolute."""
return relative if os.path.isabs(relative) else os.path.normpath(os.path.join(PROJECT_ROOT, relative))
def describe(self):
return (f"{self.tiles_x}x{self.tiles_y} tiles of {self.vertices_per_tile} vertices at {self.quad_cm:g} cm: "
f"{self.tile_side_m / 1000:.3f} km a tile, {self.width_m / 1000:.2f} x {self.height_m / 1000:.2f} km, "
f"{self.area_km2:.0f} km2; elevation {self.elevation_min_m:g}..{self.elevation_max_m:g} m "
f"(Z scale {self.z_scale:g}, actor Z {self.landscape_z_cm:g} cm)")
def load_manifest(path=MANIFEST_PATH):
with open(path, "r", encoding="utf-8") as f:
return RegionManifest(json.load(f), path)