Files
UnrealPrototyping/Scripts/Authoring/world_manifest.py
T
2026-09-25 17:02:24 +03:00

141 lines
6.8 KiB
Python

"""The canvas manifest: RawContent/World/World.json, the one place that says how big L_Canvas_Proto is, what a
heightmap value means in metres, and where the height comes from. Pure Python (no numpy, no engine), shared by
generate_heightmap.py (writes the PNGs) and create_world.py (imports them), so both agree without either
knowing about the other.
**This is not the world.** `L_World` is the planet-map region in Region.json (D-72); this pipeline is the numpy
square canvas, legacy by D-47 and kept only because it is still the one path that carries the erosion pass's
flow, wear and deposit maps into Unreal. The level name is deliberately not the real world's: create_world.py
empties whatever level it is handed, so a manifest pointing at L_World would replace ninety-eight landscapes
with a 14 km square on one run.
The height contract. The landscape is `vertices_per_side` vertices a side at `quad_cm` a quad. The 16-bit
heightmap spans `elevation_m.min` (value 0) to `elevation_m.max` (value 65535), and the level places the
landscape so that world Z 0 is elevation 0 m: sea level, when `sea_level_m` is 0. From that the engine's
Z scale and the actor's Z offset follow; nothing else in the project needs to know them.
Sources. `{"kind": "noise", "seed": N}` builds a continent with heightmap_noise.py. `{"kind": "file", "path":
..., "elevation_m": {"min": ..., "max": ...}}` takes a real heightmap (16-bit greyscale PNG or raw 16-bit
little-endian .r16), whose 0..65535 spans its own elevation range, and resamples it onto the world. Either way
the paint layers are derived from the finished height, so a real heightmap needs no weightmaps of its own.
"""
import json
import os
HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.normpath(os.path.join(HERE, "..", ".."))
WORLD_DIR = os.path.join(PROJECT_ROOT, "RawContent", "World")
MANIFEST_PATH = os.path.join(WORLD_DIR, "World.json")
HEIGHTMAP_DIR = os.path.join(WORLD_DIR, "Heightmaps")
# Named for the level this pipeline builds, which is L_Canvas_Proto and not L_World (D-72). The region
# tiles in RegionTiles/ are L_World_x0_y0_Height.png and the like; two files a folder apart differing only
# in a tile suffix is a confusion worth one rename.
HEIGHTMAP_FILE = "L_Canvas_Proto_Height.png"
# Paint layer name (as the Elite_RockyMeadows landscape material calls it) -> weightmap file. The names mislead:
# in this pack Base_Layer is the rock, Layer_02 the grass (the meadow) and Layer_03 the high rock.
LAYER_FILES = {
"Base_Layer": "L_Canvas_Proto_Base_Layer.png",
"Layer_02": "L_Canvas_Proto_Layer_02.png",
"Layer_03": "L_Canvas_Proto_Layer_03.png",
}
# Derivative maps the erosion pass leaves behind, 8-bit, for painting and for a material that wants them later:
# how much water passed (log scaled), how much bedrock was scraped, how much sediment was laid down, and the
# curvature (128 flat, brighter convex, darker concave).
DERIVED_FILES = {
"flow": "L_Canvas_Proto_Flow.png",
"wear": "L_Canvas_Proto_Wear.png",
"deposit": "L_Canvas_Proto_Deposit.png",
"curvature": "L_Canvas_Proto_Curvature.png",
}
# The engine maps heightmap value v to local height (v - 32768) / 128 * ZScale cm, so ZScale 100 spans 512 m.
ENGINE_SPAN_M_AT_SCALE_100 = 512.0
class WorldManifest:
def __init__(self, data, path=MANIFEST_PATH):
self.path = path
# Not L_World: that is the region's now. A manifest with no `level` must not default to the world the
# game uses, because create_world.py empties whatever level it is handed before rebuilding it.
self.level = data.get("level", "/Game/Maps/L_Canvas_Proto")
self.vertices_per_side = int(data["vertices_per_side"])
self.quad_cm = float(data["quad_cm"])
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", 1))
self.source = dict(data.get("source", {"kind": "noise", "seed": 7}))
self.erosion = dict(data.get("erosion", {})) # keys and defaults in heightmap_erosion.DEFAULTS
self.layers = dict(data.get("layers", {}))
if self.vertices_per_side < 2 or self.elevation_max_m <= self.elevation_min_m or self.quad_cm <= 0:
raise ValueError(f"{path}: vertices_per_side, quad_cm and elevation_m must be positive and ordered")
# Derived geometry.
@property
def quads_per_side(self):
return self.vertices_per_side - 1
@property
def side_m(self):
return self.quads_per_side * self.quad_cm / 100.0
@property
def area_km2(self):
return (self.side_m / 1000.0) ** 2
@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 that 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):
"""The landscape actor's world 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
# The height encoding, in plain floats so the numpy side can vectorise the same formula.
def metres_to_value(self, metres):
return (metres - self.elevation_min_m) / self.elevation_span_m * 65535.0
def value_to_metres(self, value):
return self.elevation_min_m + value / 65535.0 * self.elevation_span_m
# Files.
@property
def heightmap_path(self):
return os.path.join(HEIGHTMAP_DIR, HEIGHTMAP_FILE)
def weightmap_path(self, layer_name):
return os.path.join(HEIGHTMAP_DIR, LAYER_FILES[layer_name])
def derived_path(self, map_name):
return os.path.join(HEIGHTMAP_DIR, DERIVED_FILES[map_name])
def resolve(self, relative):
"""A manifest path is relative to the project root unless absolute."""
return relative if os.path.isabs(relative) else os.path.normpath(os.path.join(PROJECT_ROOT, relative))
def describe(self):
return (f"{self.vertices_per_side} vertices a side at {self.quad_cm:g} cm: {self.side_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); source {self.source}")
def load_manifest(path=MANIFEST_PATH):
with open(path, "r", encoding="utf-8") as f:
return WorldManifest(json.load(f), path)