L_World: a 200 km2 world-partitioned landscape from a seeded heightmap
Off the ladder at the user's request. Scripts/Authoring/generate_heightmap.py (numpy, installed locally by bootstrap-pylib.sh) writes a 16-bit height PNG and three 8-bit weight PNGs to Content/World/Heightmaps; create_world.py rebuilds /Game/Maps/L_World from them through ULandscapeAuthoringLibrary in the new SaltyEditor module, which wraps ALandscape::Import because the engine exposes no landscape creation to Python. Uses Elite_RockyMeadows' landscape material and layer infos (the pack itself is still uncommitted). 4033 vertices a side at 350 cm a quad; swap the PNGs and rerun to change the terrain. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
0189ed9124
commit
1945ef033b
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installs the Python packages the authoring scripts need (numpy) into Scripts/Authoring/.pylib with the
|
||||
# engine's own Python, so nothing is installed into the engine. Run once per machine. UE_ROOT overrides the engine.
|
||||
set -eu
|
||||
UE_ROOT="${UE_ROOT:-D:/UE_5.8}"
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
"$UE_ROOT/Engine/Binaries/ThirdParty/Python3/Win64/python.exe" -m pip install --quiet --target "$ROOT/Scripts/Authoring/.pylib" numpy
|
||||
echo "installed into $ROOT/Scripts/Authoring/.pylib"
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Builds /Game/Maps/L_World: a world-partitioned level with a ~200 km2 landscape from the heightmap PNGs in
|
||||
Content/World/Heightmaps/, the Elite_RockyMeadows landscape material, daylight and a player start. Rebuilds
|
||||
from scratch every run; the level is a product of this script and the PNGs, never hand-edited.
|
||||
|
||||
UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script=Scripts/Authoring/create_world.py
|
||||
|
||||
Swapping the terrain later: replace L_World_Height.png (16-bit greyscale, any resolution the landscape
|
||||
supports) and the three L_World_<layer>.png weightmaps, or rerun generate_heightmap.py with another seed,
|
||||
then rerun this. If the PNGs are missing this script generates them first.
|
||||
|
||||
Size: 4033 vertices a side at 350 cm a quad is 14.11 km a side, 199 km2. Z scale 500 spans -1280 m to
|
||||
+1280 m, so 65535 in the PNG is 1280 m above the landscape origin and sea level (0.18 of the range) is -820 m.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import unreal
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, ".pylib"))
|
||||
|
||||
LEVEL_PATH = "/Game/Maps/L_World"
|
||||
HEIGHTMAP_DIR = os.path.normpath(os.path.join(HERE, "..", "..", "Content", "World", "Heightmaps"))
|
||||
HEIGHTMAP = os.path.join(HEIGHTMAP_DIR, "L_World_Height.png")
|
||||
PACK = "/Game/Elite_RockyMeadows"
|
||||
LANDSCAPE_MATERIAL = f"{PACK}/Materials/M_Landscape_Main_Inst_RockyMeadows02"
|
||||
LAYER_INFOS = { # layer info asset -> weightmap file
|
||||
f"{PACK}/Materials/Material_Layers/Base_Layer_LayerInfo": "L_World_Base_Layer.png",
|
||||
f"{PACK}/Materials/Material_Layers/Layer_02_LayerInfo": "L_World_Layer_02.png",
|
||||
f"{PACK}/Materials/Material_Layers/Layer_03_LayerInfo": "L_World_Layer_03.png",
|
||||
}
|
||||
QUAD_SCALE_CM = 350.0
|
||||
Z_SCALE = 500.0
|
||||
GRID_SIZE_COMPONENTS = 4 # streaming proxies of 4x4 components (4 x 126 quads x 3.5 m = 1.76 km a side)
|
||||
|
||||
level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
||||
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
|
||||
asset_lib = unreal.EditorAssetLibrary
|
||||
|
||||
|
||||
def ensure_heightmaps():
|
||||
missing = [f for f in [HEIGHTMAP] + [os.path.join(HEIGHTMAP_DIR, f) for f in LAYER_INFOS.values()] if not os.path.isfile(f)]
|
||||
if not missing:
|
||||
return
|
||||
unreal.log(f"generating heightmaps, missing {missing}")
|
||||
import generate_heightmap
|
||||
sys.argv = ["generate_heightmap.py"]
|
||||
generate_heightmap.main()
|
||||
|
||||
|
||||
def spawn(actor_class, label, location=unreal.Vector(0, 0, 0), rotation=unreal.Rotator(0, 0, 0)):
|
||||
actor = actor_subsystem.spawn_actor_from_class(actor_class, location, rotation)
|
||||
actor.set_actor_label(label)
|
||||
return actor
|
||||
|
||||
|
||||
def recreate_level():
|
||||
if asset_lib.does_asset_exist(LEVEL_PATH):
|
||||
unreal.log(f"{LEVEL_PATH} exists; deleting it and its external actors")
|
||||
level_subsystem.new_level("/Temp/Untitled_Scratch", False) # do not delete the level we stand in
|
||||
asset_lib.delete_asset(LEVEL_PATH)
|
||||
for folder in ("/Game/__ExternalActors__/Maps/L_World", "/Game/__ExternalObjects__/Maps/L_World"):
|
||||
if asset_lib.does_directory_exist(folder):
|
||||
asset_lib.delete_directory(folder)
|
||||
level_subsystem.new_level(LEVEL_PATH, True) # world-partitioned: 200 km2 must stream
|
||||
|
||||
|
||||
def create_landscape():
|
||||
material = unreal.load_asset(LANDSCAPE_MATERIAL)
|
||||
if not material:
|
||||
raise RuntimeError(f"landscape material {LANDSCAPE_MATERIAL} not found; is the pack in Content/?")
|
||||
weightmaps = []
|
||||
for asset_path, file_name in LAYER_INFOS.items():
|
||||
layer_info = unreal.load_asset(asset_path)
|
||||
if not layer_info:
|
||||
raise RuntimeError(f"layer info {asset_path} not found")
|
||||
entry = unreal.LandscapeAuthoringWeightmap()
|
||||
entry.set_editor_property("layer_info", layer_info)
|
||||
entry.set_editor_property("file", os.path.join(HEIGHTMAP_DIR, file_name))
|
||||
weightmaps.append(entry)
|
||||
|
||||
world = editor_subsystem.get_editor_world()
|
||||
landscape = unreal.LandscapeAuthoringLibrary.create_landscape_from_heightmap(
|
||||
world, HEIGHTMAP, weightmaps, material,
|
||||
unreal.Vector(0, 0, 0), unreal.Vector(QUAD_SCALE_CM, QUAD_SCALE_CM, Z_SCALE), GRID_SIZE_COMPONENTS)
|
||||
if not landscape:
|
||||
raise RuntimeError("landscape creation failed; see LogSaltyEditor")
|
||||
return landscape
|
||||
|
||||
|
||||
def ground_height_at(x, y, fallback):
|
||||
world = editor_subsystem.get_editor_world()
|
||||
start, end = unreal.Vector(x, y, 200000.0), unreal.Vector(x, y, -200000.0)
|
||||
hit = unreal.SystemLibrary.line_trace_single(world, start, end, unreal.TraceTypeQuery.ECC_VISIBILITY,
|
||||
False, [], unreal.DrawDebugTrace.NONE, True)
|
||||
if hit and hit.to_tuple()[0]:
|
||||
return hit.to_tuple()[4].z
|
||||
unreal.log_warning("no landscape hit under the spawn; using the fallback height")
|
||||
return fallback
|
||||
|
||||
|
||||
def ensure_daylight():
|
||||
sun = spawn(unreal.DirectionalLight, "World_Sun", unreal.Vector(0, 0, 50000), unreal.Rotator(-38, 25, 0))
|
||||
sun.light_component.set_editor_property("intensity", 8.0)
|
||||
sun.light_component.set_editor_property("atmosphere_sun_light", True)
|
||||
sun.light_component.set_editor_property("dynamic_shadow_distance_movable_light", 60000.0)
|
||||
spawn(unreal.SkyAtmosphere, "World_Sky")
|
||||
sky_light = spawn(unreal.SkyLight, "World_SkyLight", unreal.Vector(0, 0, 50000))
|
||||
sky_light.light_component.set_editor_property("real_time_capture", True)
|
||||
spawn(unreal.VolumetricCloud, "World_Clouds")
|
||||
fog = spawn(unreal.ExponentialHeightFog, "World_Fog")
|
||||
fog.component.set_editor_property("fog_density", 0.005)
|
||||
fog.component.set_editor_property("fog_height_falloff", 0.05)
|
||||
|
||||
|
||||
def ensure_player_starts():
|
||||
# The heightmap has a flat pad at its centre; two starts so two PIE clients spawn without a warning.
|
||||
fallback = (0.24 * 65535.0 - 32768.0) / 128.0 * Z_SCALE
|
||||
z = ground_height_at(0.0, 0.0, fallback) + 120.0
|
||||
for index, y in enumerate((-200.0, 200.0)):
|
||||
spawn(unreal.PlayerStart, f"World_PlayerStart_{index}", unreal.Vector(0.0, y, z))
|
||||
|
||||
|
||||
def main():
|
||||
ensure_heightmaps()
|
||||
recreate_level()
|
||||
landscape = create_landscape()
|
||||
ensure_daylight()
|
||||
ensure_player_starts()
|
||||
level_subsystem.save_current_level()
|
||||
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
|
||||
unreal.log(f"{LEVEL_PATH} saved with landscape {landscape.get_actor_label()}")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Generates a seeded heightmap and three weightmaps for L_World, as 16-bit and 8-bit greyscale PNGs.
|
||||
|
||||
Pure numpy, no engine: run it with any Python that has numpy, or let create_world.py call it. The output is
|
||||
plain files under Content/World/Heightmaps/, so swapping the terrain later is dropping in a different PNG of
|
||||
any resolution and rerunning create_world.py; nothing else in the project knows how the terrain was made.
|
||||
|
||||
UE_5.8/Engine/Binaries/ThirdParty/Python3/Win64/python.exe Scripts/Authoring/generate_heightmap.py [--seed N] [--size 4033]
|
||||
|
||||
The shape, in metres, with the default scale in create_world.py (350 cm per quad, Z scale 500):
|
||||
a continent with ragged coasts and sea around it, lowland plains, rolling hills, one or two mountain ranges
|
||||
along a low-frequency band, thermal smoothing so slopes read as slopes, and a flat 200 m pad at the centre
|
||||
for the player start. Weightmaps: base (grass) everywhere, layer 2 (rock) by slope, layer 3 by altitude.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, ".pylib"))
|
||||
import numpy as np # noqa: E402
|
||||
|
||||
OUT_DIR = os.path.normpath(os.path.join(HERE, "..", "..", "Content", "World", "Heightmaps"))
|
||||
SEA_LEVEL = 0.18 # fraction of the 16-bit range that is sea; create_world.py places the water plane here
|
||||
PAD_RADIUS_FRACTION = 0.01 # flat spawn pad, as a fraction of the map width
|
||||
|
||||
|
||||
def write_png(path, data):
|
||||
"""Greyscale PNG, 8 or 16 bit from the array dtype. Row filter 0, one zlib stream."""
|
||||
if data.dtype == np.uint16:
|
||||
depth, payload = 16, data.astype(">u2")
|
||||
else:
|
||||
depth, payload = 8, data.astype(np.uint8)
|
||||
height, width = data.shape
|
||||
raw = b"".join(b"\x00" + payload[y].tobytes() for y in range(height))
|
||||
|
||||
def chunk(kind, body):
|
||||
return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF)
|
||||
|
||||
ihdr = struct.pack(">IIBBBBB", width, height, depth, 0, 0, 0, 0)
|
||||
with open(path, "wb") as f:
|
||||
f.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b""))
|
||||
|
||||
|
||||
def smoothstep(t):
|
||||
return t * t * (3.0 - 2.0 * t)
|
||||
|
||||
|
||||
def value_noise(size, cells, rng):
|
||||
"""One octave: a random lattice of cells x cells, smoothly interpolated to size x size. Tileable enough."""
|
||||
lattice = rng.random((cells + 1, cells + 1), dtype=np.float32)
|
||||
coords = np.linspace(0.0, cells, size, endpoint=False, dtype=np.float32)
|
||||
i = np.floor(coords).astype(np.int32)
|
||||
t = smoothstep(coords - i)
|
||||
i1 = np.minimum(i + 1, cells)
|
||||
top = lattice[i[:, None], i[None, :]] * (1 - t[None, :]) + lattice[i[:, None], i1[None, :]] * t[None, :]
|
||||
bottom = lattice[i1[:, None], i[None, :]] * (1 - t[None, :]) + lattice[i1[:, None], i1[None, :]] * t[None, :]
|
||||
return top * (1 - t[:, None]) + bottom * t[:, None]
|
||||
|
||||
|
||||
def fbm(size, rng, base_cells=4, octaves=8, gain=0.5, ridged=False):
|
||||
total = np.zeros((size, size), dtype=np.float32)
|
||||
amplitude, cells, norm = 1.0, base_cells, 0.0
|
||||
for _ in range(octaves):
|
||||
n = value_noise(size, cells, rng)
|
||||
if ridged:
|
||||
n = 1.0 - np.abs(n * 2.0 - 1.0)
|
||||
n = n * n
|
||||
total += n * amplitude
|
||||
norm += amplitude
|
||||
amplitude *= gain
|
||||
cells *= 2
|
||||
return total / norm
|
||||
|
||||
|
||||
def box_blur(h, passes):
|
||||
for _ in range(passes):
|
||||
padded = np.pad(h, 1, mode="edge")
|
||||
h = (padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:] + h) / 5.0
|
||||
return h.astype(np.float32)
|
||||
|
||||
|
||||
def thermal_smooth(h, passes, talus):
|
||||
"""Cheap erosion: where a cell is much higher than a neighbour, move a little material downhill."""
|
||||
for _ in range(passes):
|
||||
padded = np.pad(h, 1, mode="edge")
|
||||
for dy, dx in ((0, 1), (0, -1), (1, 0), (-1, 0)):
|
||||
neighbour = padded[1 + dy:1 + dy + h.shape[0], 1 + dx:1 + dx + h.shape[1]]
|
||||
diff = h - neighbour
|
||||
move = np.where(diff > talus, (diff - talus) * 0.25, 0.0).astype(np.float32)
|
||||
h -= move
|
||||
return h
|
||||
|
||||
|
||||
def generate(size, seed):
|
||||
rng = np.random.default_rng(seed)
|
||||
y, x = np.mgrid[0:size, 0:size].astype(np.float32) / (size - 1)
|
||||
|
||||
# Continent: a radial falloff with a ragged, noise-warped edge, so the coast is not a circle.
|
||||
cx, cy = 0.5 + (rng.random() - 0.5) * 0.15, 0.5 + (rng.random() - 0.5) * 0.15
|
||||
radius = np.sqrt(((x - cx) * 1.05) ** 2 + ((y - cy) * 0.95) ** 2)
|
||||
coast_warp = (fbm(size, rng, base_cells=3, octaves=5) - 0.5) * 0.35
|
||||
continent = np.clip(1.0 - (radius + coast_warp) / 0.55, 0.0, 1.0)
|
||||
continent = smoothstep(np.clip(continent * 1.6, 0.0, 1.0))
|
||||
|
||||
plains = fbm(size, rng, base_cells=6, octaves=4) * 0.06
|
||||
hills = fbm(size, rng, base_cells=12, octaves=6, gain=0.5) * 0.22
|
||||
# Mountain ranges: ridged noise, masked by a low-frequency band so they come as ranges, not everywhere.
|
||||
range_band = fbm(size, rng, base_cells=3, octaves=3)
|
||||
range_mask = smoothstep(np.clip((range_band - 0.47) / 0.2, 0.0, 1.0))
|
||||
mountains = fbm(size, rng, base_cells=10, octaves=8, gain=0.5, ridged=True) * range_mask
|
||||
|
||||
land = 0.05 + plains + hills * (0.4 + 0.6 * continent) + mountains * 0.75
|
||||
height = SEA_LEVEL + continent * land
|
||||
# The sea floor keeps a little shape so the shore is not a hard step.
|
||||
sea_floor = SEA_LEVEL - 0.03 - (1.0 - continent) * 0.04 + plains * 0.3
|
||||
height = np.where(continent > 0.02, height, np.maximum(sea_floor, 0.0)).astype(np.float32)
|
||||
height = np.maximum(height, sea_floor.astype(np.float32))
|
||||
|
||||
height = thermal_smooth(height, passes=6, talus=0.0025)
|
||||
|
||||
# A flat pad at the centre for the player start, blended into the terrain around it.
|
||||
pad_radius = PAD_RADIUS_FRACTION
|
||||
pad_dist = np.sqrt((x - 0.5) ** 2 + (y - 0.5) ** 2)
|
||||
pad_weight = smoothstep(np.clip(1.0 - (pad_dist - pad_radius) / pad_radius, 0.0, 1.0))
|
||||
pad_height = max(float(height[size // 2, size // 2]), SEA_LEVEL + 0.06)
|
||||
height = height * (1 - pad_weight) + pad_height * pad_weight
|
||||
|
||||
height = np.clip(height, 0.0, 1.0)
|
||||
|
||||
# Weightmaps from the finished shape. Slope is per quad in height-range units; the thresholds are guesses
|
||||
# to be tuned by eye in the editor.
|
||||
gy, gx = np.gradient(box_blur(height, 3))
|
||||
slope = np.sqrt(gx * gx + gy * gy) * size
|
||||
rock = smoothstep(np.clip((slope - 1.8) / 1.6, 0.0, 1.0))
|
||||
high = smoothstep(np.clip((height - 0.5) / 0.16, 0.0, 1.0)) * (1.0 - rock * 0.5)
|
||||
base = np.clip(1.0 - rock - high, 0.0, 1.0)
|
||||
total = base + rock + high
|
||||
weights = [np.rint(w / total * 255.0).astype(np.uint8) for w in (base, rock, high)]
|
||||
|
||||
return np.rint(height * 65535.0).astype(np.uint16), weights
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--seed", type=int, default=7)
|
||||
parser.add_argument("--size", type=int, default=4033, help="vertices per side; 4033 fits 32x32 components of 126 quads")
|
||||
parser.add_argument("--out", default=OUT_DIR)
|
||||
args = parser.parse_args()
|
||||
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
height, weights = generate(args.size, args.seed)
|
||||
write_png(os.path.join(args.out, "L_World_Height.png"), height)
|
||||
for name, data in zip(("Base_Layer", "Layer_02", "Layer_03"), weights):
|
||||
write_png(os.path.join(args.out, f"L_World_{name}.png"), data)
|
||||
land = float((height > SEA_LEVEL * 65535).mean()) * 100.0
|
||||
print(f"seed {args.seed}: {args.size}x{args.size}, {land:.0f}% land, written to {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user