Added: Initial world generation tool
This commit is contained in:
@@ -1,15 +1,19 @@
|
||||
"""Builds /Game/Maps/L_World: a world-partitioned level with a ~200 km2 landscape from the heightmap PNGs in
|
||||
RawContent/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.
|
||||
"""Builds /Game/Maps/L_World: a world-partitioned level with the landscape that RawContent/World/World.json
|
||||
describes, imported from the PNGs in RawContent/World/Heightmaps/ and dressed as Elite_RockyMeadows dresses its
|
||||
demo maps: the pack's landscape material and three paint layers, its sun with the moving cloud shadows, its
|
||||
skybox dome, sky light, height fog and post-process settings (numbers read from the pack's maps with
|
||||
dump_level.py), plus a sea plane at sea level and two player starts on the spawn pad. Rebuilds from scratch
|
||||
every run; the level is a product of this script, the manifest and the PNGs, never hand-edited.
|
||||
|
||||
UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script=Scripts/Authoring/create_world.py
|
||||
UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script=Scripts/Authoring/create_world.py -AllowCommandletRendering
|
||||
|
||||
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.
|
||||
-AllowCommandletRendering matters: the landscape's render heightmaps come from the edit-layer merge on the GPU,
|
||||
and a commandlet without it silently skips that, leaving a landscape with collision but no visible surface.
|
||||
|
||||
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.
|
||||
Swapping the terrain: point the manifest's source at a real heightmap (or change the seed), rerun
|
||||
generate_heightmap.py, then rerun this. If the PNGs are missing or the wrong size this script generates them
|
||||
first. The landscape's component layout is the engine's choice for the resolution: 4081 vertices a side gives
|
||||
16x16 components of 255 quads, each its own streaming proxy (see RawContent/World/README.md before changing it).
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
@@ -20,20 +24,53 @@ import unreal
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, ".pylib"))
|
||||
import heightmap_io # noqa: E402
|
||||
from world_manifest import LAYER_FILES, load_manifest # noqa: E402
|
||||
|
||||
LEVEL_PATH = "/Game/Maps/L_World"
|
||||
HEIGHTMAP_DIR = os.path.normpath(os.path.join(HERE, "..", "..", "RawContent", "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",
|
||||
LAYER_INFOS = { # paint layer name -> the pack's layer info asset; the weightmap file comes from the manifest module
|
||||
"Base_Layer": f"{PACK}/Materials/Material_Layers/Base_Layer_LayerInfo",
|
||||
"Layer_02": f"{PACK}/Materials/Material_Layers/Layer_02_LayerInfo",
|
||||
"Layer_03": f"{PACK}/Materials/Material_Layers/Layer_03_LayerInfo",
|
||||
}
|
||||
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)
|
||||
SKYBOX_MESH = f"{PACK}/Materials/Skybox/Skybox_Mesh"
|
||||
SKYBOX_MATERIAL = f"{PACK}/Materials/Skybox/M_Skybox_Inst_RockyMeadows"
|
||||
CLOUD_SHADOWS = f"{PACK}/Materials/Light_Material/M_Cloud_Shadows_Inst02"
|
||||
SEA_MESH = "/Engine/BasicShapes/Plane"
|
||||
SEA_MATERIAL = "/Engine/EngineMaterials/WaterMaterial"
|
||||
|
||||
# The pack's Rocky_Meadows_01 demo map, as dump_level.py read it. Distances are scaled up where the demo's
|
||||
# 8 km scene would otherwise cut the effect short on a 14 km world.
|
||||
PACK_SUN = {
|
||||
"rotation": unreal.Rotator(roll=-51.273, pitch=-31.342, yaw=36.413), # keyword arguments: positional order is roll, pitch, yaw
|
||||
"intensity": 9.2368,
|
||||
"light_color": unreal.Color(r=223, g=245, b=255, a=255),
|
||||
"light_function_scale": unreal.Vector(1024.0, 1024.0, 1024.0),
|
||||
"light_function_fade_distance": 2000000.0, # the demo fades its cloud shadows out at 2 km; keep them to 20 km here
|
||||
"dynamic_shadow_distance_movable_light": 200000.0,
|
||||
"cascade_distribution_exponent": 3.0,
|
||||
"light_source_angle": 0.5357,
|
||||
"shadow_bias": 0.5,
|
||||
}
|
||||
PACK_SKY_LIGHT = {"intensity": 1.5, "lower_hemisphere_color": unreal.LinearColor(0.0, 0.0, 0.0, 1.0), "sky_distance_threshold": 150000.0}
|
||||
PACK_FOG = {
|
||||
"fog_density": 0.027143,
|
||||
"fog_height_falloff": 0.039076,
|
||||
"fog_inscattering_luminance": unreal.LinearColor(0.238715, 0.329426, 0.458333, 1.0),
|
||||
"directional_inscattering_luminance": unreal.LinearColor(0.25, 0.20832, 0.154948, 1.0),
|
||||
"directional_inscattering_exponent": 4.0,
|
||||
"directional_inscattering_start_distance": 10000.0,
|
||||
}
|
||||
PACK_POST_PROCESS = { # FPostProcessSettings field -> value; the override flag of each is set alongside
|
||||
"auto_exposure_min_brightness": 1.0,
|
||||
"auto_exposure_max_brightness": 1.0,
|
||||
"auto_exposure_bias": 0.263034,
|
||||
"color_saturation": unreal.Vector4(1.0, 1.0, 1.0, 1.25),
|
||||
}
|
||||
|
||||
manifest = load_manifest()
|
||||
LEVEL_PATH = manifest.level
|
||||
|
||||
level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
||||
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
@@ -41,14 +78,42 @@ editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
|
||||
asset_lib = unreal.EditorAssetLibrary
|
||||
|
||||
|
||||
def load_or_raise(asset_path):
|
||||
asset = unreal.load_asset(asset_path)
|
||||
if not asset:
|
||||
raise RuntimeError(f"{asset_path} not found; is the pack in Content/?")
|
||||
return asset
|
||||
|
||||
|
||||
def set_properties(target, values):
|
||||
for name, value in values.items():
|
||||
target.set_editor_property(name, value)
|
||||
|
||||
|
||||
def keep_always_loaded(actor):
|
||||
"""World partition streams actors by their bounds; the sky dome and the sea are the whole world and must
|
||||
not stream at all."""
|
||||
try:
|
||||
actor.set_editor_property("is_spatially_loaded", False)
|
||||
except Exception as error:
|
||||
unreal.log_warning(f"{actor.get_actor_label()}: could not clear is_spatially_loaded ({error}); it will stream by bounds")
|
||||
|
||||
|
||||
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:
|
||||
files = [manifest.heightmap_path] + [manifest.weightmap_path(name) for name in LAYER_FILES]
|
||||
reason = None
|
||||
missing = [f for f in files if not os.path.isfile(f)]
|
||||
if missing:
|
||||
reason = f"missing {missing}"
|
||||
else:
|
||||
width, height, depth, _ = heightmap_io.read_png_header(manifest.heightmap_path)
|
||||
if (width, height, depth) != (manifest.vertices_per_side, manifest.vertices_per_side, 16):
|
||||
reason = f"{manifest.heightmap_path} is {width}x{height} at {depth} bit, the manifest wants {manifest.vertices_per_side} at 16"
|
||||
if reason is None:
|
||||
return
|
||||
unreal.log(f"generating heightmaps, missing {missing}")
|
||||
unreal.log(f"generating heightmaps: {reason}")
|
||||
import generate_heightmap
|
||||
sys.argv = ["generate_heightmap.py"]
|
||||
generate_heightmap.main()
|
||||
generate_heightmap.main([])
|
||||
|
||||
|
||||
def spawn(actor_class, label, location=unreal.Vector(0, 0, 0), rotation=unreal.Rotator(0, 0, 0)):
|
||||
@@ -57,39 +122,84 @@ def spawn(actor_class, label, location=unreal.Vector(0, 0, 0), rotation=unreal.R
|
||||
return actor
|
||||
|
||||
|
||||
def recreate_level():
|
||||
KEEP_CLASSES = {"WorldSettings", "WorldDataLayers", "WorldPartitionMiniMap"} # what a fresh partitioned level has
|
||||
|
||||
|
||||
def content_path():
|
||||
return os.path.abspath(unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_content_dir()))
|
||||
|
||||
|
||||
def external_actor_dir():
|
||||
return os.path.join(content_path(), "__ExternalActors__", LEVEL_PATH.replace("/Game/", "", 1))
|
||||
|
||||
|
||||
def prepare_level():
|
||||
"""An empty level to build into. An existing level is loaded and emptied rather than deleted and recreated:
|
||||
recreating it resaves its two HLOD layer assets, and an open editor that has had the level loaded keeps
|
||||
those files locked, so the recreation ends in a nameless temp world whose save goes nowhere (2026-09-16).
|
||||
Streaming proxies the editor does not load cannot be destroyed here; sweep_stale_actor_packages removes
|
||||
their files after the save."""
|
||||
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)
|
||||
# The external actor packages of streaming proxies cannot be loaded on their own, so the asset library
|
||||
# refuses to delete the folder; remove what is left on disk instead.
|
||||
content_dir = unreal.Paths.project_content_dir()
|
||||
for folder in ("__ExternalActors__/Maps/L_World", "__ExternalObjects__/Maps/L_World"):
|
||||
path = os.path.join(content_dir, folder)
|
||||
if os.path.isdir(path):
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
level_subsystem.new_level(LEVEL_PATH, True) # world-partitioned: 200 km2 must stream
|
||||
if not level_subsystem.load_level(LEVEL_PATH):
|
||||
raise RuntimeError(f"could not load {LEVEL_PATH}")
|
||||
removed = 0
|
||||
for actor in actor_subsystem.get_all_level_actors():
|
||||
if actor.get_class().get_name() in KEEP_CLASSES:
|
||||
continue
|
||||
actor_subsystem.destroy_actor(actor)
|
||||
removed += 1
|
||||
unreal.log(f"{LEVEL_PATH} loaded and emptied: {removed} loaded actors removed")
|
||||
else:
|
||||
folder = external_actor_dir()
|
||||
if os.path.isdir(folder):
|
||||
shutil.rmtree(folder, ignore_errors=True)
|
||||
level_subsystem.new_level(LEVEL_PATH, True) # world-partitioned: 200 km2 must stream
|
||||
world_path = editor_subsystem.get_editor_world().get_path_name()
|
||||
if not world_path.startswith(LEVEL_PATH):
|
||||
raise RuntimeError(f"the editor world is {world_path}, not {LEVEL_PATH}; a save would go nowhere")
|
||||
|
||||
|
||||
def sweep_stale_actor_packages():
|
||||
"""After the save: every file under the level's external actor folder that belongs to none of the level's
|
||||
actors is a leftover of an earlier build (a proxy the editor never loaded, so it could not be destroyed).
|
||||
Left there, it would come back as a second landscape the next time the level loads."""
|
||||
keep = set()
|
||||
for actor in actor_subsystem.get_all_level_actors():
|
||||
package = actor.get_outermost().get_path_name() # /Game/__ExternalActors__/Maps/L_World/1/YM/XXXX
|
||||
keep.add(os.path.normcase(os.path.join(content_path(), package.replace("/Game/", "", 1).replace("/", os.sep) + ".uasset")))
|
||||
removed, failed = 0, 0
|
||||
for root, _, files in os.walk(external_actor_dir()):
|
||||
for name in files:
|
||||
path = os.path.join(root, name)
|
||||
if os.path.normcase(path) in keep:
|
||||
continue
|
||||
try:
|
||||
os.remove(path)
|
||||
removed += 1
|
||||
except OSError as error:
|
||||
failed += 1
|
||||
unreal.log_warning(f"stale actor package {path} could not be removed: {error}")
|
||||
unreal.log(f"stale actor packages: {removed} removed, {failed} left, {len(keep)} kept")
|
||||
if failed:
|
||||
raise RuntimeError(f"{failed} stale actor packages remain under {external_actor_dir()}; the level would load duplicates")
|
||||
|
||||
|
||||
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/?")
|
||||
material = load_or_raise(LANDSCAPE_MATERIAL)
|
||||
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")
|
||||
for layer_name, asset_path in LAYER_INFOS.items():
|
||||
entry = unreal.LandscapeAuthoringWeightmap()
|
||||
entry.set_editor_property("layer_info", layer_info)
|
||||
entry.set_editor_property("file", os.path.join(HEIGHTMAP_DIR, file_name))
|
||||
entry.set_editor_property("layer_info", load_or_raise(asset_path))
|
||||
entry.set_editor_property("file", manifest.weightmap_path(layer_name))
|
||||
weightmaps.append(entry)
|
||||
|
||||
world = editor_subsystem.get_editor_world()
|
||||
unreal.log(f"landscape: {manifest.describe()}")
|
||||
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)
|
||||
world, manifest.heightmap_path, weightmaps, material,
|
||||
unreal.Vector(0.0, 0.0, manifest.landscape_z_cm),
|
||||
unreal.Vector(manifest.quad_cm, manifest.quad_cm, manifest.z_scale),
|
||||
manifest.streaming_grid_components)
|
||||
if not landscape:
|
||||
raise RuntimeError("landscape creation failed; see LogSaltyEditor")
|
||||
return landscape
|
||||
@@ -97,7 +207,8 @@ def create_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)
|
||||
reach = manifest.elevation_max_m * 100.0 + 100000.0
|
||||
start, end = unreal.Vector(x, y, reach), unreal.Vector(x, y, -reach)
|
||||
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]:
|
||||
@@ -106,24 +217,62 @@ def ground_height_at(x, y, fallback):
|
||||
return fallback
|
||||
|
||||
|
||||
def ensure_daylight():
|
||||
# Keyword arguments on purpose: positional unreal.Rotator is (roll, pitch, yaw), and a sun pitched upward is night.
|
||||
sun = spawn(unreal.DirectionalLight, "World_Sun", unreal.Vector(0, 0, 50000), unreal.Rotator(roll=0.0, pitch=-38.0, yaw=25.0))
|
||||
sun.light_component.set_editor_property("intensity", 10.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")
|
||||
def dress_with_rocky_meadows():
|
||||
"""The pack's sky, sun, fog and grade, so L_World reads like its demo maps."""
|
||||
sun = spawn(unreal.DirectionalLight, "World_Sun", unreal.Vector(0, 0, 50000), PACK_SUN["rotation"])
|
||||
light = sun.light_component
|
||||
light.set_editor_property("mobility", unreal.ComponentMobility.MOVABLE)
|
||||
set_properties(light, {k: v for k, v in PACK_SUN.items() if k != "rotation"})
|
||||
light.set_editor_property("light_function_material", load_or_raise(CLOUD_SHADOWS))
|
||||
|
||||
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)
|
||||
sky_light.light_component.set_editor_property("mobility", unreal.ComponentMobility.MOVABLE)
|
||||
set_properties(sky_light.light_component, PACK_SKY_LIGHT)
|
||||
|
||||
# The pack's skybox is a textured dome mesh, not a sky atmosphere. Scale it so the whole world sits inside
|
||||
# with room to spare, and sink its centre so the dome's equator is below the sea from any shore.
|
||||
mesh = load_or_raise(SKYBOX_MESH)
|
||||
native_radius = max(mesh.get_bounds().sphere_radius, 1.0)
|
||||
radius = manifest.side_m * 100.0 * 1.1
|
||||
skybox = spawn(unreal.StaticMeshActor, "World_Skybox", unreal.Vector(0.0, 0.0, -radius * 0.25))
|
||||
skybox.static_mesh_component.set_editor_property("mobility", unreal.ComponentMobility.MOVABLE)
|
||||
skybox.static_mesh_component.set_static_mesh(mesh)
|
||||
skybox.static_mesh_component.set_material(0, load_or_raise(SKYBOX_MATERIAL))
|
||||
skybox.static_mesh_component.set_editor_property("cast_shadow", False)
|
||||
skybox.static_mesh_component.set_collision_enabled(unreal.CollisionEnabled.NO_COLLISION)
|
||||
skybox.set_actor_scale3d(unreal.Vector(radius / native_radius, radius / native_radius, radius / native_radius))
|
||||
keep_always_loaded(skybox)
|
||||
unreal.log(f"skybox dome radius {radius / 100000:.1f} km (mesh radius {native_radius:g} cm, scale {radius / native_radius:g})")
|
||||
|
||||
fog = spawn(unreal.ExponentialHeightFog, "World_Fog", unreal.Vector(0.0, 0.0, manifest.sea_level_z_cm))
|
||||
set_properties(fog.component, PACK_FOG)
|
||||
|
||||
post = spawn(unreal.PostProcessVolume, "World_PostProcess")
|
||||
post.set_editor_property("unbound", True)
|
||||
settings = post.get_editor_property("settings")
|
||||
for field, value in PACK_POST_PROCESS.items():
|
||||
settings.set_editor_property(f"override_{field}", True)
|
||||
settings.set_editor_property(field, value)
|
||||
post.set_editor_property("settings", settings)
|
||||
|
||||
|
||||
def ensure_sea():
|
||||
"""A flat plane at sea level with the engine's water material: enough to read as sea until a water body
|
||||
replaces it. It keeps collision so a walk off the coast is a walk, not a fall to the sea floor."""
|
||||
mesh = load_or_raise(SEA_MESH)
|
||||
material = unreal.load_asset(SEA_MATERIAL) or load_or_raise("/Engine/BasicShapes/BasicShapeMaterial")
|
||||
side = manifest.side_m * 100.0 * 1.5 / 100.0 # the plane is 100 cm; cover the world and the sea beyond its edge
|
||||
sea = spawn(unreal.StaticMeshActor, "World_Sea_Proto", unreal.Vector(0.0, 0.0, manifest.sea_level_z_cm))
|
||||
sea.static_mesh_component.set_static_mesh(mesh)
|
||||
sea.static_mesh_component.set_material(0, material)
|
||||
sea.static_mesh_component.set_editor_property("cast_shadow", False)
|
||||
sea.set_actor_scale3d(unreal.Vector(side, side, 1.0))
|
||||
keep_always_loaded(sea)
|
||||
|
||||
|
||||
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
|
||||
fallback = manifest.sea_level_z_cm + 15000.0
|
||||
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))
|
||||
@@ -131,12 +280,15 @@ def ensure_player_starts():
|
||||
|
||||
def main():
|
||||
ensure_heightmaps()
|
||||
recreate_level()
|
||||
prepare_level()
|
||||
landscape = create_landscape()
|
||||
ensure_daylight()
|
||||
dress_with_rocky_meadows()
|
||||
ensure_sea()
|
||||
ensure_player_starts()
|
||||
level_subsystem.save_current_level()
|
||||
if not level_subsystem.save_current_level():
|
||||
raise RuntimeError(f"saving {LEVEL_PATH} failed; see the log above")
|
||||
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
|
||||
sweep_stale_actor_packages()
|
||||
unreal.log(f"{LEVEL_PATH} saved with landscape {landscape.get_actor_label()}")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Dumps the actors of one or more levels to JSON, so a reference level (a pack's demo map) can be read without
|
||||
opening it in the editor: class, label, transform, and the properties that matter for lighting, sky, fog,
|
||||
post-processing and landscape. Written for reading Elite_RockyMeadows' demo maps while building L_World.
|
||||
|
||||
UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script="Scripts/Authoring/dump_level.py /Game/Elite_RockyMeadows/Maps/Rocky_Meadows_01"
|
||||
|
||||
Levels come from the script arguments, else from the SALTY_DUMP_LEVELS environment variable (semicolon
|
||||
separated), else the pack's first demo map. Output: Saved/Authoring/<LevelName>.json, one per level.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import unreal
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
OUT_DIR = os.path.normpath(os.path.join(HERE, "..", "..", "Saved", "Authoring"))
|
||||
DEFAULT_LEVELS = ["/Game/Elite_RockyMeadows/Maps/Rocky_Meadows_01"]
|
||||
|
||||
# Component class name -> editor properties worth reading. Missing ones are skipped, so the list can be generous.
|
||||
COMPONENT_PROPERTIES = {
|
||||
"DirectionalLightComponent": [
|
||||
"intensity", "light_color", "use_temperature", "temperature", "light_function_material", "light_function_scale",
|
||||
"light_function_fade_distance", "atmosphere_sun_light", "cast_shadows", "dynamic_shadow_distance_movable_light",
|
||||
"dynamic_shadow_distance_stationary_light", "num_dynamic_shadow_cascades", "cascade_distribution_exponent",
|
||||
"cascade_transition_fraction", "shadow_distance_fadeout_fraction", "light_source_angle", "shadow_bias",
|
||||
"volumetric_scattering_intensity", "cast_cloud_shadows", "cast_volumetric_shadow", "indirect_lighting_intensity",
|
||||
"mobility", "specular_scale",
|
||||
],
|
||||
"SkyLightComponent": [
|
||||
"intensity", "light_color", "source_type", "cubemap", "cubemap_resolution", "source_cubemap_angle", "real_time_capture",
|
||||
"lower_hemisphere_color", "sky_distance_threshold", "cast_shadows", "volumetric_scattering_intensity",
|
||||
"indirect_lighting_intensity", "occlusion_max_distance", "mobility",
|
||||
],
|
||||
"ExponentialHeightFogComponent": [
|
||||
"fog_density", "fog_height_falloff", "second_fog_data", "fog_inscattering_luminance", "skybox_inscattering_color_cubemap",
|
||||
"fog_inscattering_luminance_scale", "directional_inscattering_luminance", "directional_inscattering_exponent",
|
||||
"directional_inscattering_start_distance", "fog_max_opacity", "start_distance", "end_distance", "fog_cutoff_distance",
|
||||
"volumetric_fog", "volumetric_fog_scattering_distribution", "volumetric_fog_albedo", "volumetric_fog_emissive",
|
||||
"volumetric_fog_extinction_scale", "volumetric_fog_distance", "volumetric_fog_start_distance",
|
||||
"volumetric_fog_near_fade_in_distance", "volumetric_fog_static_lighting_scattering_intensity",
|
||||
],
|
||||
"StaticMeshComponent": ["static_mesh", "cast_shadow", "visible"],
|
||||
"SkyAtmosphereComponent": ["rayleigh_scattering_scale", "mie_scattering_scale", "aerial_pespective_view_distance_scale"],
|
||||
"VolumetricCloudComponent": ["layer_bottom_altitude", "layer_height", "material"],
|
||||
}
|
||||
ACTOR_PROPERTIES = {
|
||||
"Landscape": ["landscape_material", "landscape_hole_material", "component_size_quads", "subsection_size_quads", "num_subsections",
|
||||
"static_lighting_lod", "lod_distribution_setting", "lod0_distribution_setting", "lod0_screen_size",
|
||||
"streaming_distance_multiplier", "collision_mip_level", "nanite_lod_index", "enable_nanite", "target_display_order"],
|
||||
"LandscapeStreamingProxy": ["landscape_material", "component_size_quads", "subsection_size_quads", "num_subsections"],
|
||||
"PostProcessVolume": ["unbound", "enabled", "priority", "blend_weight"],
|
||||
"Actor": ["hidden"],
|
||||
}
|
||||
|
||||
|
||||
def encode(value):
|
||||
"""A JSON-able rendering of a Python-wrapped Unreal value."""
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return value
|
||||
if isinstance(value, unreal.Object):
|
||||
return value.get_path_name()
|
||||
if isinstance(value, (unreal.Name, unreal.Text)):
|
||||
return str(value)
|
||||
if isinstance(value, (unreal.Vector, unreal.Rotator, unreal.LinearColor, unreal.Color, unreal.Vector2D, unreal.IntPoint)):
|
||||
return list(value.to_tuple())
|
||||
if isinstance(value, unreal.Array):
|
||||
return [encode(v) for v in value]
|
||||
return str(value)
|
||||
|
||||
|
||||
def read_properties(obj, names):
|
||||
out = {}
|
||||
for name in names:
|
||||
try:
|
||||
out[name] = encode(obj.get_editor_property(name))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def overridden_post_process(settings):
|
||||
"""The FPostProcessSettings fields whose override flag is set, with their values."""
|
||||
out = {}
|
||||
for name in dir(settings):
|
||||
if not name.startswith("override_"):
|
||||
continue
|
||||
try:
|
||||
if not settings.get_editor_property(name):
|
||||
continue
|
||||
field = name[len("override_"):]
|
||||
out[field] = encode(settings.get_editor_property(field))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def dump_actor(actor):
|
||||
class_name = actor.get_class().get_name()
|
||||
record = {
|
||||
"class": class_name,
|
||||
"label": actor.get_actor_label(),
|
||||
"location": list(actor.get_actor_location().to_tuple()),
|
||||
"rotation": list(actor.get_actor_rotation().to_tuple()), # the tuple order is (roll, pitch, yaw)
|
||||
"scale": list(actor.get_actor_scale3d().to_tuple()),
|
||||
}
|
||||
for key, names in ACTOR_PROPERTIES.items():
|
||||
if key == "Actor" or class_name.startswith(key):
|
||||
record.update(read_properties(actor, names))
|
||||
if class_name.startswith("PostProcessVolume"):
|
||||
try:
|
||||
record["settings"] = overridden_post_process(actor.get_editor_property("settings"))
|
||||
except Exception as error:
|
||||
record["settings_error"] = str(error)
|
||||
components = {}
|
||||
for component in actor.get_components_by_class(unreal.ActorComponent):
|
||||
component_class = component.get_class().get_name()
|
||||
for key, names in COMPONENT_PROPERTIES.items():
|
||||
if component_class == key or component_class.startswith(key):
|
||||
entry = read_properties(component, names)
|
||||
if isinstance(component, unreal.SceneComponent):
|
||||
entry["relative_location"] = list(component.get_editor_property("relative_location").to_tuple())
|
||||
entry["relative_rotation"] = list(component.get_editor_property("relative_rotation").to_tuple())
|
||||
entry["relative_scale3d"] = list(component.get_editor_property("relative_scale3d").to_tuple())
|
||||
if isinstance(component, unreal.MeshComponent):
|
||||
entry["materials"] = [encode(m) for m in component.get_materials()]
|
||||
components[f"{component_class}:{component.get_name()}"] = entry
|
||||
if class_name.startswith("Landscape"):
|
||||
try:
|
||||
components["landscape_component_count"] = len(actor.get_editor_property("landscape_components"))
|
||||
except Exception:
|
||||
pass
|
||||
record["components"] = components
|
||||
return record
|
||||
|
||||
|
||||
def dump_level(level_path):
|
||||
unreal.log(f"dump_level: loading {level_path}")
|
||||
level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
||||
if not level_subsystem.load_level(level_path):
|
||||
unreal.log_error(f"dump_level: could not load {level_path}")
|
||||
return
|
||||
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
actors = [dump_actor(actor) for actor in actor_subsystem.get_all_level_actors()]
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
out_path = os.path.join(OUT_DIR, level_path.rsplit("/", 1)[-1] + ".json")
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"level": level_path, "actors": actors}, f, indent=2)
|
||||
unreal.log(f"dump_level: {len(actors)} actors of {level_path} written to {out_path}")
|
||||
|
||||
|
||||
def main():
|
||||
levels = [a for a in sys.argv[1:] if a.startswith("/")]
|
||||
if not levels and os.environ.get("SALTY_DUMP_LEVELS"):
|
||||
levels = [p for p in os.environ["SALTY_DUMP_LEVELS"].split(";") if p]
|
||||
for level in levels or DEFAULT_LEVELS:
|
||||
dump_level(level)
|
||||
|
||||
|
||||
main()
|
||||
@@ -1,161 +1,174 @@
|
||||
"""Generates a seeded heightmap and three weightmaps for L_World, as 16-bit and 8-bit greyscale PNGs.
|
||||
"""Writes the heightmap and the three weightmaps of L_World to RawContent/World/Heightmaps/ from the manifest
|
||||
RawContent/World/World.json: 16-bit greyscale height, 8-bit greyscale weights, sized for the landscape.
|
||||
|
||||
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 RawContent/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.
|
||||
Pure numpy, no engine: run it with the engine's Python (numpy lives in Scripts/Authoring/.pylib, see
|
||||
bootstrap-pylib.sh), or let create_world.py call it when the PNGs are missing.
|
||||
|
||||
UE_5.8/Engine/Binaries/ThirdParty/Python3/Win64/python.exe Scripts/Authoring/generate_heightmap.py [--seed N] [--size 4033]
|
||||
D:/UE_5.8/Engine/Binaries/ThirdParty/Python3/Win64/python.exe Scripts/Authoring/generate_heightmap.py
|
||||
... --seed 12 # another noise continent, manifest otherwise as is
|
||||
... --source-file RawContent/World/Sources/dem.png --source-elevation 0 2400 # a real heightmap, this once
|
||||
|
||||
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.
|
||||
The manifest names the source. Noise builds a continent with heightmap_noise.py. A file (16-bit PNG or raw
|
||||
.r16 from any DEM tool) is cropped to a square, read as its own elevation range, resampled onto the world and
|
||||
re-encoded into the world's range; the paint layers are then derived from the height exactly as for noise,
|
||||
so swapping to a real heightmap is a manifest edit and a rerun, nothing else. Either way a flat spawn pad is
|
||||
blended into the centre so the player starts stand on level ground.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
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
|
||||
|
||||
OUT_DIR = os.path.normpath(os.path.join(HERE, "..", "..", "RawContent", "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
|
||||
import heightmap_erosion # noqa: E402
|
||||
import heightmap_io # noqa: E402
|
||||
import heightmap_noise # noqa: E402
|
||||
from world_manifest import DERIVED_FILES, HEIGHTMAP_DIR, LAYER_FILES, MANIFEST_PATH, load_manifest # noqa: E402
|
||||
|
||||
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": 1100, # where the high rock layer starts
|
||||
"high_altitude_full_m": 1650, # and where it has taken over
|
||||
"breakup_m": 18, # noise added to the altitude before the rules, so boundaries are not contour lines
|
||||
"wear_rock_start": 0.35, # scraped bedrock (wear map, 0..1) reads as rock from here
|
||||
"ridge_rock": 0.6, # how much convex curvature (ridges, shoulders) adds rock
|
||||
"deposit_softens": 0.7, # how much laid-down sediment (deposit map, 0..1) takes rock away: fans and basins are meadow
|
||||
}
|
||||
|
||||
|
||||
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 source_height_metres(manifest, size):
|
||||
"""The world's height in metres, size x size, from whichever source the manifest names."""
|
||||
source = manifest.source
|
||||
kind = source.get("kind", "noise")
|
||||
quad_m = manifest.quad_cm / 100.0
|
||||
if kind == "noise":
|
||||
seed = int(source.get("seed", 7))
|
||||
print(f"noise source, seed {seed}")
|
||||
return heightmap_noise.generate_metres(size, seed, quad_m, manifest.sea_level_m)
|
||||
if kind == "file":
|
||||
path = manifest.resolve(source["path"])
|
||||
print(f"file source {path}")
|
||||
values = heightmap_io.read_heightmap(path, source.get("width"))
|
||||
if source.get("flip_y", False):
|
||||
values = values[::-1]
|
||||
values = heightmap_io.center_crop_square(values)
|
||||
elevation = source.get("elevation_m", {"min": manifest.elevation_min_m, "max": manifest.elevation_max_m})
|
||||
low, high = float(elevation["min"]), float(elevation["max"])
|
||||
metres = low + values.astype(np.float32) / 65535.0 * (high - low)
|
||||
print(f" {values.shape[1]}x{values.shape[0]} samples spanning {low:g}..{high:g} m, resampled to {size}x{size}")
|
||||
metres = heightmap_io.resample(metres, size)
|
||||
smooth = int(source.get("smooth_passes", 0))
|
||||
if smooth > 0:
|
||||
metres = heightmap_noise.box_blur(metres, smooth)
|
||||
return metres
|
||||
raise ValueError(f"{manifest.path}: unknown source kind {kind!r}; use 'noise' or 'file'")
|
||||
|
||||
|
||||
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)
|
||||
def apply_spawn_pad(metres, manifest):
|
||||
"""A flat disc at the centre for the player starts, blended into the terrain over a second radius."""
|
||||
if manifest.spawn_pad_m <= 0:
|
||||
return metres
|
||||
size = metres.shape[0]
|
||||
radius = manifest.spawn_pad_m / manifest.side_m
|
||||
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
|
||||
dist = np.sqrt((x - 0.5) ** 2 + (y - 0.5) ** 2)
|
||||
weight = heightmap_noise.smoothstep(np.clip(1.0 - (dist - radius) / radius, 0.0, 1.0))
|
||||
centre = float(metres[size // 2, size // 2])
|
||||
pad_height = max(centre, manifest.sea_level_m + 150.0) # never a pad in the sea
|
||||
return (metres * (1 - weight) + pad_height * weight).astype(np.float32)
|
||||
|
||||
|
||||
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()
|
||||
def derived_maps(metres, maps, manifest):
|
||||
"""The erosion's leftovers squashed to [0, 1]: flow (log scaled), wear, deposit, and a curvature map with
|
||||
0.5 flat, convex above, concave below."""
|
||||
curve = heightmap_erosion.curvature(metres, manifest.quad_cm / 100.0)
|
||||
scale = max(float(np.percentile(np.abs(curve), 99.0)), 1e-6)
|
||||
return {
|
||||
"flow": heightmap_erosion.to_unit(maps["flow"], 99.5, log_scale=True),
|
||||
"wear": heightmap_erosion.to_unit(maps["wear"], 99.0),
|
||||
"deposit": heightmap_erosion.to_unit(maps["deposit"], 99.0),
|
||||
"curvature": np.clip(0.5 + curve / scale * 0.5, 0.0, 1.0).astype(np.float32),
|
||||
}
|
||||
|
||||
|
||||
def derive_layers(metres, derived, manifest, rng):
|
||||
"""Rocky Meadows' three paint layers from the finished height and what the erosion left: meadow everywhere,
|
||||
rock by slope, on scraped bedrock and on convex ridges, less rock where sediment was laid down, high rock
|
||||
by altitude. Returns {layer name: uint8 weightmap}, the three summing to 255."""
|
||||
rules = {**LAYER_DEFAULTS, **manifest.layers}
|
||||
quad_m = manifest.quad_cm / 100.0
|
||||
size = metres.shape[0]
|
||||
|
||||
breakup = (heightmap_noise.fbm(size, rng, base_cells=24, octaves=4) - 0.5) * 2.0 * float(rules["breakup_m"])
|
||||
gy, gx = np.gradient(heightmap_noise.box_blur(metres, 2), 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))
|
||||
scraped = heightmap_noise.smoothstep(np.clip((derived["wear"] - rules["wear_rock_start"]) / (1.0 - rules["wear_rock_start"]), 0.0, 1.0))
|
||||
convex = np.clip((derived["curvature"] - 0.5) * 2.0, 0.0, 1.0) * np.clip(slope / rules["rock_slope_start"], 0.0, 1.0)
|
||||
rock = np.maximum(rock, np.maximum(scraped * 0.9, convex * rules["ridge_rock"]))
|
||||
rock = rock * (1.0 - rules["deposit_softens"] * derived["deposit"] * (slope < rules["rock_slope_full"]))
|
||||
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)
|
||||
meadow = np.clip(1.0 - rock - high, 0.0, 1.0)
|
||||
total = np.maximum(meadow + rock + high, 1e-6)
|
||||
# The pack's names are not what they sound like: Base_Layer is its rock, Layer_02 its grass, Layer_03 its
|
||||
# high rock (read from the textures its layer functions sample).
|
||||
weights = {"Base_Layer": rock, "Layer_02": meadow, "Layer_03": high}
|
||||
return {name: np.rint(w / total * 255.0).astype(np.uint8) for name, w in weights.items()}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument("--manifest", default=MANIFEST_PATH)
|
||||
parser.add_argument("--seed", type=int, help="override the noise seed for this run")
|
||||
parser.add_argument("--source-file", help="use this heightmap file for this run instead of the manifest's source")
|
||||
parser.add_argument("--source-elevation", nargs=2, type=float, metavar=("MIN_M", "MAX_M"),
|
||||
help="what 0 and 65535 mean in the source file, in metres")
|
||||
parser.add_argument("--out", default=HEIGHTMAP_DIR)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
manifest = load_manifest(args.manifest)
|
||||
if args.source_file:
|
||||
manifest.source = {"kind": "file", "path": args.source_file}
|
||||
if args.source_elevation:
|
||||
manifest.source["elevation_m"] = {"min": args.source_elevation[0], "max": args.source_elevation[1]}
|
||||
elif args.seed is not None:
|
||||
manifest.source = {"kind": "noise", "seed": args.seed}
|
||||
print(manifest.describe())
|
||||
|
||||
size = manifest.vertices_per_side
|
||||
started = time.time()
|
||||
metres = source_height_metres(manifest, size)
|
||||
print(f"uplift {metres.min():.0f}..{metres.max():.0f} m in {time.time() - started:.0f} s; eroding")
|
||||
metres, maps = heightmap_erosion.erode(metres, manifest.quad_cm / 100.0, manifest.sea_level_m, manifest.erosion)
|
||||
metres = apply_spawn_pad(metres, manifest)
|
||||
|
||||
clipped = float(((metres < manifest.elevation_min_m) | (metres > manifest.elevation_max_m)).mean()) * 100.0
|
||||
metres = np.clip(metres, manifest.elevation_min_m, manifest.elevation_max_m)
|
||||
height = np.rint(manifest.metres_to_value(metres)).clip(0, 65535).astype(np.uint16)
|
||||
derived = derived_maps(metres, maps, manifest)
|
||||
layers = derive_layers(metres, derived, manifest, np.random.default_rng(int(manifest.source.get("seed", 0)) + 1))
|
||||
|
||||
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}")
|
||||
heightmap_io.write_png(os.path.join(args.out, "L_World_Height.png"), height)
|
||||
for name, data in layers.items():
|
||||
heightmap_io.write_png(os.path.join(args.out, LAYER_FILES[name]), data)
|
||||
for name, data in derived.items():
|
||||
heightmap_io.write_png(os.path.join(args.out, DERIVED_FILES[name]), np.rint(data * 255.0).astype(np.uint8))
|
||||
|
||||
land = float((metres > manifest.sea_level_m).mean()) * 100.0
|
||||
print(f"height {metres.min():.0f}..{metres.max():.0f} m, {land:.0f}% above sea level, {clipped:.2f}% clipped to the range; "
|
||||
f"layers meadow {layers['Layer_02'].mean() / 255 * 100:.0f}% rock {layers['Base_Layer'].mean() / 255 * 100:.0f}% "
|
||||
f"high {layers['Layer_03'].mean() / 255 * 100:.0f}%; {time.time() - started:.0f} s; written to {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Geological passes over a heightmap in metres, numpy only: particle hydraulic erosion, thermal weathering with
|
||||
an angle of repose, strata hardness, and the derivative maps (flow, wear, deposition) they leave behind.
|
||||
Fractal noise gives pillowy hills; these passes give drainage, V-valleys, alluvial fans, scree aprons and rock
|
||||
shelves. Applied by generate_heightmap.py to whatever the manifest's source produced, noise or file.
|
||||
|
||||
Units inside: heights are in cell widths (metres over the cell size), so a slope of 1.0 is 45 degrees and the
|
||||
droplet constants mean the same thing at any resolution. The hydraulic pass runs twice: on a downsampled map
|
||||
(coarse cells, long droplet lives) for the valleys, then at full resolution (short lives) for the gullies;
|
||||
the coarse result is applied to the full map as a delta, so the fine detail underneath survives.
|
||||
|
||||
Droplets are simulated in vectorised batches: a batch of tens of thousands takes one step together, reading
|
||||
the map as it was at the start of the step and scattering its erosion and deposits back with np.add.at. Two
|
||||
droplets in the same cell in the same step do not see each other; at these densities that is invisible.
|
||||
"""
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
import heightmap_io
|
||||
import heightmap_noise
|
||||
|
||||
DEFAULTS = {
|
||||
"enabled": True,
|
||||
"coarse_factor": 4, # the coarse pass runs on the map downsampled by this
|
||||
"coarse_droplets": 800000,
|
||||
"coarse_lifetime": 120, # steps, one cell each: 120 coarse cells is 1.7 km of path at 4x on 3.5 m quads
|
||||
"fine_droplets": 3000000,
|
||||
"fine_lifetime": 40,
|
||||
"thermal_passes": 24,
|
||||
"talus_deg": 35.0, # angle of repose
|
||||
"inertia": 0.1,
|
||||
"capacity": 2.0, # sediment a droplet can carry, in cell-heights per unit of slope, speed and water
|
||||
"max_load": 2.0, # cell-heights: the most one droplet carries, so the mound it can leave where it stops is bounded
|
||||
"min_slope": 0.01,
|
||||
"deposit_rate": 0.2,
|
||||
"erode_rate": 0.2,
|
||||
"evaporation": 0.02,
|
||||
"gravity": 4.0,
|
||||
"strata_period_m": 160.0, # vertical period of the hard and soft bands
|
||||
"strata_contrast": 0.6, # 0 is uniform rock, 1 is hard bands that barely erode next to soft ones that melt
|
||||
"max_change": 0.2, # cell-heights one droplet may cut or fill in one step; batches of droplets share cells, so this is the brake
|
||||
"max_speed": 5.0,
|
||||
"min_erode_slope": 0.25, # below this slope (about 14 degrees) water deposits but barely cuts: lowland soil holds, so meadows stay meadows
|
||||
"fine_scale": 0.5, # the fine pass cuts at this fraction of the coarse pass's rates: gullies, not trenches, at 3.5 m cells
|
||||
"batch": 200000, # droplets stepping together, at most one per 40 cells of the map
|
||||
"seed": 11,
|
||||
}
|
||||
|
||||
|
||||
class Hardness:
|
||||
"""Rock hardness in [0, 1] as a function of position and elevation: horizontal strata with a slow tilt and a
|
||||
slow change of rock type across the map. Erosion is scaled by (1 - hardness), so hard bands hold shelves."""
|
||||
|
||||
def __init__(self, size, rng, period_cells, contrast):
|
||||
self.period = max(float(period_cells), 1e-3)
|
||||
self.contrast = float(contrast)
|
||||
self.tilt = heightmap_noise.fbm(size, rng, base_cells=3, octaves=3, gain=0.5).astype(np.float32)
|
||||
self.kind = heightmap_noise.fbm(size, rng, base_cells=2, octaves=3, gain=0.5).astype(np.float32)
|
||||
|
||||
def at(self, ix, iy, height):
|
||||
band = 0.5 + 0.5 * np.sin(2.0 * np.pi * (height / self.period + self.tilt[iy, ix] * 2.0))
|
||||
return np.clip(0.5 + self.contrast * (band - 0.5) * (0.4 + 0.8 * self.kind[iy, ix]), 0.05, 0.95).astype(np.float32)
|
||||
|
||||
|
||||
BRUSH = ((0, 0, 0.36), (0, 1, 0.12), (0, -1, 0.12), (1, 0, 0.12), (-1, 0, 0.12),
|
||||
(1, 1, 0.04), (1, -1, 0.04), (-1, 1, 0.04), (-1, -1, 0.04)) # offsets (dy, dx) and weights summing to 1
|
||||
|
||||
|
||||
def sample(h, px, py):
|
||||
"""Bilinear height and gradient at float positions; the caller keeps px, py inside [0, size - 2]."""
|
||||
x0 = px.astype(np.int32)
|
||||
y0 = py.astype(np.int32)
|
||||
fx = px - x0
|
||||
fy = py - y0
|
||||
h00 = h[y0, x0]
|
||||
h10 = h[y0, x0 + 1]
|
||||
h01 = h[y0 + 1, x0]
|
||||
h11 = h[y0 + 1, x0 + 1]
|
||||
gx = (h10 - h00) * (1 - fy) + (h11 - h01) * fy
|
||||
gy = (h01 - h00) * (1 - fx) + (h11 - h10) * fx
|
||||
hc = h00 * (1 - fx) * (1 - fy) + h10 * fx * (1 - fy) + h01 * (1 - fx) * fy + h11 * fx * fy
|
||||
return hc, gx, gy, x0, y0, fx, fy
|
||||
|
||||
|
||||
def hydraulic(h, rng, droplets, lifetime, cfg, hardness, spawn_mask, maps, sea_cells=-1e9):
|
||||
"""Particle erosion in place on h (cell units). maps: flow, wear, deposit arrays of h's shape, accumulated.
|
||||
A droplet that reaches water below `sea_cells` drops its whole load there and ends: the sea is a sink,
|
||||
and river mouths get their fans."""
|
||||
size = h.shape[0]
|
||||
ys, xs = np.nonzero(spawn_mask)
|
||||
if xs.size == 0:
|
||||
return
|
||||
inertia, capacity_factor = cfg["inertia"], cfg["capacity"]
|
||||
min_slope, deposit_rate, erode_rate = cfg["min_slope"], cfg["deposit_rate"], cfg["erode_rate"]
|
||||
evaporation, gravity = cfg["evaporation"], cfg["gravity"]
|
||||
max_change, max_speed, max_load = float(cfg["max_change"]), float(cfg["max_speed"]), float(cfg["max_load"])
|
||||
min_erode_slope = max(float(cfg["min_erode_slope"]), 1e-6)
|
||||
batch = max(min(int(cfg["batch"]), size * size // 40), 1000)
|
||||
limit = size - 2.001
|
||||
done = 0
|
||||
while done < droplets:
|
||||
n = min(batch, droplets - done)
|
||||
done += n
|
||||
pick = rng.integers(0, xs.size, n)
|
||||
px = np.clip(xs[pick] + rng.random(n, dtype=np.float32), 1.0, limit).astype(np.float32)
|
||||
py = np.clip(ys[pick] + rng.random(n, dtype=np.float32), 1.0, limit).astype(np.float32)
|
||||
dx = np.zeros(n, dtype=np.float32)
|
||||
dy = np.zeros(n, dtype=np.float32)
|
||||
speed = np.ones(n, dtype=np.float32)
|
||||
water = np.ones(n, dtype=np.float32)
|
||||
sediment = np.zeros(n, dtype=np.float32)
|
||||
|
||||
for _ in range(lifetime):
|
||||
if px.size == 0:
|
||||
break
|
||||
hc, gx, gy, x0, y0, fx, fy = sample(h, px, py)
|
||||
dx = dx * inertia - gx * (1 - inertia)
|
||||
dy = dy * inertia - gy * (1 - inertia)
|
||||
length = np.hypot(dx, dy)
|
||||
moving = length > 1e-9
|
||||
safe = np.where(moving, length, 1.0)
|
||||
dx = np.where(moving, dx / safe, 0.0).astype(np.float32)
|
||||
dy = np.where(moving, dy / safe, 0.0).astype(np.float32)
|
||||
nx = px + dx
|
||||
ny = py + dy
|
||||
inside = moving & (nx >= 1.0) & (nx <= limit) & (ny >= 1.0) & (ny <= limit)
|
||||
hn = sample(h, np.clip(nx, 1.0, limit), np.clip(ny, 1.0, limit))[0]
|
||||
dh = np.where(inside, hn - hc, 0.0).astype(np.float32)
|
||||
|
||||
slope = np.maximum(-dh, min_slope)
|
||||
capacity = np.minimum(slope * speed * water * capacity_factor, max_load)
|
||||
hard = hardness.at(x0, y0, hc) if hardness is not None else 0.0
|
||||
holds = np.clip(np.hypot(gx, gy) / min_erode_slope, 0.0, 1.0) ** 2 # flat ground resists cutting
|
||||
deposit = np.where(dh > 0.0, np.minimum(dh, sediment),
|
||||
np.where(sediment > capacity, (sediment - capacity) * deposit_rate, 0.0))
|
||||
erode = np.where((dh <= 0.0) & (sediment <= capacity),
|
||||
np.minimum((capacity - sediment) * erode_rate, -dh) * (1.0 - hard) * holds, 0.0)
|
||||
into_sea = inside & (hn < sea_cells)
|
||||
deposit = np.where(into_sea, sediment, np.minimum(deposit, max_change)).astype(np.float32)
|
||||
erode = np.where(into_sea, 0.0, np.minimum(erode, max_change)).astype(np.float32)
|
||||
# Cuts go through a 3x3 brush: a one-cell footprint leaves every path as a rill one cell wide, which
|
||||
# reads as brush strokes. Deposits land on the droplet's own bilinear cell: spread through the brush,
|
||||
# a pit's rim rises faster than its floor, the pit never fills, and every droplet that drains into it
|
||||
# adds to the rim until there is a mound.
|
||||
for oy, ox, weight in BRUSH:
|
||||
np.add.at(h, (y0 + oy, x0 + ox), -erode * weight)
|
||||
np.add.at(h, (y0, x0), deposit * (1 - fx) * (1 - fy))
|
||||
np.add.at(h, (y0, x0 + 1), deposit * fx * (1 - fy))
|
||||
np.add.at(h, (y0 + 1, x0), deposit * (1 - fx) * fy)
|
||||
np.add.at(h, (y0 + 1, x0 + 1), deposit * fx * fy)
|
||||
np.add.at(maps["flow"], (y0, x0), water)
|
||||
np.add.at(maps["wear"], (y0, x0), erode)
|
||||
np.add.at(maps["deposit"], (y0, x0), deposit)
|
||||
|
||||
sediment = sediment + erode - deposit
|
||||
speed = np.minimum(np.sqrt(np.maximum(0.0, speed * speed - dh * gravity)), max_speed).astype(np.float32) # downhill is faster
|
||||
water = water * (1.0 - evaporation)
|
||||
alive = inside & ~into_sea & (water > 0.001)
|
||||
px, py, dx, dy = nx[alive], ny[alive], dx[alive], dy[alive]
|
||||
speed, water, sediment = speed[alive], water[alive], sediment[alive]
|
||||
|
||||
|
||||
DIRECTIONS = ((0, 1, 1.0), (0, -1, 1.0), (1, 0, 1.0), (-1, 0, 1.0),
|
||||
(1, 1, np.sqrt(2.0)), (1, -1, np.sqrt(2.0)), (-1, 1, np.sqrt(2.0)), (-1, -1, np.sqrt(2.0)))
|
||||
|
||||
|
||||
def thermal(h, passes, talus):
|
||||
"""Mass-conserving thermal weathering: where a cell stands above a neighbour by more than the angle of
|
||||
repose allows, half the excess slides down, shared among the lower neighbours. Cliffs keep a face, and
|
||||
scree builds at their feet. h in cell units, talus is tan(angle of repose)."""
|
||||
size = h.shape[0]
|
||||
|
||||
def neighbour(padded, dy, dx):
|
||||
return padded[1 + dy:1 + dy + size, 1 + dx:1 + dx + size]
|
||||
|
||||
for _ in range(passes):
|
||||
start = h.copy()
|
||||
padded = np.pad(start, 1, mode="edge")
|
||||
worst = np.zeros(h.shape, dtype=np.float32)
|
||||
total = np.zeros(h.shape, dtype=np.float32)
|
||||
for dy, dx, dist in DIRECTIONS:
|
||||
excess = np.maximum(start - neighbour(padded, dy, dx) - talus * dist, 0.0)
|
||||
worst = np.maximum(worst, excess)
|
||||
total += excess
|
||||
# A cell sheds half of its largest excess per pass, split among its lower neighbours in proportion to
|
||||
# how far each is below the angle of repose. Never more than half, so slopes settle without inverting.
|
||||
scale = np.where(total > 0.0, 0.5 * worst / np.maximum(total, 1e-9), 0.0).astype(np.float32)
|
||||
for dy, dx, dist in DIRECTIONS:
|
||||
move = np.maximum(start - neighbour(padded, dy, dx) - talus * dist, 0.0) * scale
|
||||
h -= move
|
||||
h[max(dy, 0):size + min(dy, 0), max(dx, 0):size + min(dx, 0)] += move[max(-dy, 0):size + min(-dy, 0), max(-dx, 0):size + min(-dx, 0)]
|
||||
return h
|
||||
|
||||
|
||||
def erode(metres, quad_m, sea_level_m, settings, log=print):
|
||||
"""The whole sequence on a map in metres. Returns (metres, maps) where maps holds flow, wear and deposit at
|
||||
the map's resolution, in cell-height units accumulated over both passes."""
|
||||
cfg = {**DEFAULTS, **(settings or {})}
|
||||
size = metres.shape[0]
|
||||
maps = {name: np.zeros((size, size), dtype=np.float32) for name in ("flow", "wear", "deposit")}
|
||||
if not cfg["enabled"]:
|
||||
return metres, maps
|
||||
rng = np.random.default_rng(int(cfg["seed"]))
|
||||
talus = float(np.tan(np.radians(cfg["talus_deg"])))
|
||||
started = time.time()
|
||||
|
||||
factor = int(cfg["coarse_factor"])
|
||||
if factor > 1 and cfg["coarse_droplets"] > 0:
|
||||
coarse = heightmap_io.block_mean(metres, factor)
|
||||
cell_m = quad_m * factor
|
||||
hc = (coarse / cell_m).astype(np.float32)
|
||||
hardness = Hardness(hc.shape[0], rng, cfg["strata_period_m"] / cell_m, cfg["strata_contrast"])
|
||||
coarse_maps = {name: np.zeros(hc.shape, dtype=np.float32) for name in maps}
|
||||
thermal(hc, max(cfg["thermal_passes"] // 4, 1), talus)
|
||||
hydraulic(hc, rng, int(cfg["coarse_droplets"]), int(cfg["coarse_lifetime"]), cfg, hardness, coarse > sea_level_m + 2.0, coarse_maps,
|
||||
sea_cells=sea_level_m / cell_m)
|
||||
thermal(hc, max(cfg["thermal_passes"] // 2, 1), talus)
|
||||
delta = hc * cell_m - coarse
|
||||
metres = (metres + heightmap_io.resample(delta, size)).astype(np.float32)
|
||||
for name in maps:
|
||||
maps[name] += heightmap_io.resample(coarse_maps[name], size) * factor
|
||||
log(f" coarse erosion at {hc.shape[0]}x{hc.shape[0]}: {cfg['coarse_droplets']} droplets, "
|
||||
f"largest cut {-delta.min():.0f} m, largest fill {delta.max():.0f} m, {time.time() - started:.0f} s")
|
||||
|
||||
hf = (metres / quad_m).astype(np.float32)
|
||||
before = hf.copy()
|
||||
hardness = Hardness(size, rng, cfg["strata_period_m"] / quad_m, cfg["strata_contrast"])
|
||||
fine_cfg = {**cfg, "erode_rate": cfg["erode_rate"] * cfg["fine_scale"], "max_change": cfg["max_change"] * cfg["fine_scale"]}
|
||||
hydraulic(hf, rng, int(cfg["fine_droplets"]), int(cfg["fine_lifetime"]), fine_cfg, hardness, metres > sea_level_m + 2.0, maps,
|
||||
sea_cells=sea_level_m / quad_m)
|
||||
thermal(hf, int(cfg["thermal_passes"]), talus)
|
||||
delta = (hf - before) * quad_m
|
||||
log(f" fine erosion at {size}x{size}: {cfg['fine_droplets']} droplets, {cfg['thermal_passes']} thermal passes at "
|
||||
f"{cfg['talus_deg']:g} deg, largest cut {-delta.min():.0f} m, largest fill {delta.max():.0f} m, {time.time() - started:.0f} s total")
|
||||
return (hf * quad_m).astype(np.float32), maps
|
||||
|
||||
|
||||
def curvature(metres, quad_m):
|
||||
"""Laplacian of the lightly blurred height, in metres per cell squared: positive on ridges and convex
|
||||
shoulders, negative in gullies and sediment traps."""
|
||||
h = heightmap_noise.box_blur(metres, 2)
|
||||
padded = np.pad(h, 1, mode="edge")
|
||||
lap = (padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:] - 4.0 * h)
|
||||
return (lap / quad_m).astype(np.float32)
|
||||
|
||||
|
||||
def to_unit(values, percentile=99.0, log_scale=False):
|
||||
"""A map squashed into [0, 1] for painting and for an 8-bit PNG."""
|
||||
v = np.log1p(np.maximum(values, 0.0)) if log_scale else np.maximum(values, 0.0)
|
||||
top = float(np.percentile(v, percentile))
|
||||
return np.clip(v / max(top, 1e-6), 0.0, 1.0).astype(np.float32)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Reading, writing and resampling heightmaps with nothing but numpy, so the authoring scripts run on the
|
||||
engine's own Python (which has no PIL). Greyscale PNG in 8 or 16 bit, raw 16-bit little-endian (.r16 / .raw,
|
||||
what World Machine, Gaea and the engine's own exporter write), bilinear resampling and a centred square crop:
|
||||
enough to take a real heightmap from any of the usual sources and put it on the landscape.
|
||||
"""
|
||||
import math
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
import numpy as np
|
||||
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
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(PNG_SIGNATURE + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b""))
|
||||
|
||||
|
||||
def read_png_header(path):
|
||||
"""(width, height, bit_depth, colour_type) without decoding the image."""
|
||||
with open(path, "rb") as f:
|
||||
head = f.read(8 + 8 + 13)
|
||||
if head[:8] != PNG_SIGNATURE or head[12:16] != b"IHDR":
|
||||
raise ValueError(f"{path}: not a PNG")
|
||||
width, height, depth, colour_type = struct.unpack(">IIBB", head[16:26])
|
||||
return width, height, depth, colour_type
|
||||
|
||||
|
||||
def _unfilter_sequential(filter_type, row, prev, bpp):
|
||||
"""Average and Paeth depend on the byte just decoded, so they go pixel by pixel. Rare in practice; a
|
||||
4081x4081 16-bit file with every row Paeth-filtered takes some tens of seconds, once, on import."""
|
||||
out = bytearray(row)
|
||||
n = len(out)
|
||||
if filter_type == 3:
|
||||
for i in range(n):
|
||||
left = out[i - bpp] if i >= bpp else 0
|
||||
out[i] = (out[i] + ((left + prev[i]) >> 1)) & 0xFF
|
||||
else:
|
||||
for i in range(n):
|
||||
if i >= bpp:
|
||||
a, c = out[i - bpp], prev[i - bpp]
|
||||
else:
|
||||
a, c = 0, 0
|
||||
b = prev[i]
|
||||
p = a + b - c
|
||||
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
|
||||
if pa <= pb and pa <= pc:
|
||||
predictor = a
|
||||
elif pb <= pc:
|
||||
predictor = b
|
||||
else:
|
||||
predictor = c
|
||||
out[i] = (out[i] + predictor) & 0xFF
|
||||
return out
|
||||
|
||||
|
||||
def read_png(path):
|
||||
"""The first channel of a non-interlaced PNG as a 2D uint8 or uint16 array (greyscale, grey+alpha, RGB
|
||||
and RGBA are accepted; palette and interlaced files are not)."""
|
||||
with open(path, "rb") as f:
|
||||
blob = f.read()
|
||||
if blob[:8] != PNG_SIGNATURE:
|
||||
raise ValueError(f"{path}: not a PNG")
|
||||
pos, idat, ihdr = 8, [], None
|
||||
while pos + 8 <= len(blob):
|
||||
length, kind = struct.unpack(">I4s", blob[pos:pos + 8])
|
||||
body = blob[pos + 8:pos + 8 + length]
|
||||
pos += 12 + length
|
||||
if kind == b"IHDR":
|
||||
ihdr = struct.unpack(">IIBBBBB", body)
|
||||
elif kind == b"IDAT":
|
||||
idat.append(body)
|
||||
elif kind == b"IEND":
|
||||
break
|
||||
if ihdr is None:
|
||||
raise ValueError(f"{path}: no IHDR")
|
||||
width, height, depth, colour_type, _, _, interlace = ihdr
|
||||
channels = {0: 1, 2: 3, 4: 2, 6: 4}.get(colour_type)
|
||||
if channels is None or depth not in (8, 16) or interlace != 0:
|
||||
raise ValueError(f"{path}: unsupported PNG (colour type {colour_type}, {depth} bit, interlace {interlace}); "
|
||||
"use a non-interlaced 8 or 16 bit greyscale or RGB file")
|
||||
bytes_per_sample = depth // 8
|
||||
bpp = channels * bytes_per_sample
|
||||
stride = width * bpp
|
||||
data = zlib.decompress(b"".join(idat))
|
||||
if len(data) != height * (stride + 1):
|
||||
raise ValueError(f"{path}: PNG data is {len(data)} bytes, expected {height * (stride + 1)}")
|
||||
|
||||
rows = np.empty((height, stride), dtype=np.uint8)
|
||||
prev = np.zeros(stride, dtype=np.uint8)
|
||||
for y in range(height):
|
||||
start = y * (stride + 1)
|
||||
filter_type = data[start]
|
||||
row = np.frombuffer(data, dtype=np.uint8, count=stride, offset=start + 1)
|
||||
if filter_type == 0:
|
||||
out = row.copy()
|
||||
elif filter_type == 1:
|
||||
out = (np.cumsum(row.reshape(width, bpp), axis=0, dtype=np.uint64) & 0xFF).astype(np.uint8).reshape(stride)
|
||||
elif filter_type == 2:
|
||||
out = ((row.astype(np.uint16) + prev) & 0xFF).astype(np.uint8)
|
||||
elif filter_type in (3, 4):
|
||||
out = np.frombuffer(bytes(_unfilter_sequential(filter_type, bytes(row), bytes(prev), bpp)), dtype=np.uint8)
|
||||
else:
|
||||
raise ValueError(f"{path}: bad PNG filter {filter_type} on row {y}")
|
||||
rows[y] = out
|
||||
prev = rows[y]
|
||||
|
||||
dtype = ">u2" if depth == 16 else np.uint8
|
||||
samples = rows.reshape(height, width * channels * bytes_per_sample).view(dtype).reshape(height, width, channels)
|
||||
first = samples[:, :, 0]
|
||||
return first.astype(np.uint16) if depth == 16 else first.astype(np.uint8)
|
||||
|
||||
|
||||
def read_r16(path, width=None):
|
||||
"""Raw 16-bit little-endian samples, square unless a width is given."""
|
||||
values = np.fromfile(path, dtype="<u2")
|
||||
if width is None:
|
||||
width = math.isqrt(len(values))
|
||||
if width * width != len(values):
|
||||
raise ValueError(f"{path}: {len(values)} samples is not a square; give the width in the manifest")
|
||||
if len(values) % width != 0:
|
||||
raise ValueError(f"{path}: {len(values)} samples do not divide by width {width}")
|
||||
return values.reshape(len(values) // width, width).astype(np.uint16)
|
||||
|
||||
|
||||
def read_heightmap(path, width=None):
|
||||
"""Any supported file as a 2D uint16 array with the full 0..65535 range (8-bit files are widened)."""
|
||||
lower = path.lower()
|
||||
if lower.endswith(".png"):
|
||||
values = read_png(path)
|
||||
return values.astype(np.uint16) * 257 if values.dtype == np.uint8 else values
|
||||
if lower.endswith((".r16", ".raw")):
|
||||
return read_r16(path, width)
|
||||
raise ValueError(f"{path}: unknown heightmap format; use 16-bit PNG or raw .r16")
|
||||
|
||||
|
||||
def center_crop_square(values):
|
||||
height, width = values.shape
|
||||
side = min(height, width)
|
||||
y0, x0 = (height - side) // 2, (width - side) // 2
|
||||
return values[y0:y0 + side, x0:x0 + side]
|
||||
|
||||
|
||||
def block_mean(values, factor):
|
||||
"""Downsample by an integer factor with a box filter, trimming the edge that does not divide."""
|
||||
height, width = values.shape
|
||||
height, width = height // factor * factor, width // factor * factor
|
||||
trimmed = values[:height, :width].astype(np.float32)
|
||||
return trimmed.reshape(height // factor, factor, width // factor, factor).mean(axis=(1, 3))
|
||||
|
||||
|
||||
def resample(values, size):
|
||||
"""Bilinear resample of a 2D array to size x size, box-filtered first when shrinking by 2x or more."""
|
||||
source = values.astype(np.float32)
|
||||
factor = min(source.shape) // size
|
||||
if factor >= 2:
|
||||
source = block_mean(source, factor)
|
||||
src_h, src_w = source.shape
|
||||
if (src_h, src_w) == (size, size):
|
||||
return source
|
||||
ys = np.linspace(0.0, src_h - 1, size, dtype=np.float32)
|
||||
xs = np.linspace(0.0, src_w - 1, size, dtype=np.float32)
|
||||
y0 = np.floor(ys).astype(np.int64)
|
||||
x0 = np.floor(xs).astype(np.int64)
|
||||
y1 = np.minimum(y0 + 1, src_h - 1)
|
||||
x1 = np.minimum(x0 + 1, src_w - 1)
|
||||
ty = (ys - y0)[:, None]
|
||||
tx = (xs - x0)[None, :]
|
||||
top = source[np.ix_(y0, x0)] * (1 - tx) + source[np.ix_(y0, x1)] * tx
|
||||
bottom = source[np.ix_(y1, x0)] * (1 - tx) + source[np.ix_(y1, x1)] * tx
|
||||
return (top * (1 - ty) + bottom * ty).astype(np.float32)
|
||||
@@ -0,0 +1,167 @@
|
||||
"""The noise heightmap source: a seeded continent in metres, and the small numpy toolkit (value noise, fBm,
|
||||
domain warping, cellular crest lines, blur) that generate_heightmap.py also uses to derive the paint layers.
|
||||
|
||||
The shape, in metres: a continent with ragged coasts and sea around it, meadow lowlands, rolling hills, and
|
||||
mountain ranges that run as long warped chains over about two fifths of the land, foothills included, with
|
||||
ridged crests up to the manifest's ceiling. This is the uplift only; heightmap_erosion.py weathers and carves
|
||||
it afterwards. Rocky Meadows is the look: meadow between the ranges, rock on them. This is the placeholder
|
||||
until a real heightmap replaces it in the manifest; nothing downstream can tell the difference.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
|
||||
def smoothstep(t):
|
||||
return t * t * (3.0 - 2.0 * t)
|
||||
|
||||
|
||||
def value_noise(size, cells, rng):
|
||||
"""One octave on the regular grid: a random lattice of cells x cells, smoothly interpolated to size x size."""
|
||||
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 sample_lattice(lattice, u, v):
|
||||
"""One octave at arbitrary coordinates: smooth interpolation of a periodic lattice at (u, v) in cell units,
|
||||
any float arrays of one shape. Periodic, so warped or stretched coordinates never run off the edge."""
|
||||
cells = lattice.shape[0]
|
||||
i0 = np.floor(u).astype(np.int32)
|
||||
j0 = np.floor(v).astype(np.int32)
|
||||
tu = smoothstep(u - i0)
|
||||
tv = smoothstep(v - j0)
|
||||
i0 %= cells
|
||||
j0 %= cells
|
||||
i1 = (i0 + 1) % cells
|
||||
j1 = (j0 + 1) % cells
|
||||
top = lattice[j0, i0] * (1 - tu) + lattice[j0, i1] * tu
|
||||
bottom = lattice[j1, i0] * (1 - tu) + lattice[j1, i1] * tu
|
||||
return top * (1 - tv) + bottom * tv
|
||||
|
||||
|
||||
def fbm(size, rng, base_cells=4, octaves=8, gain=0.5, ridged=False):
|
||||
"""Fractional Brownian motion in [0, 1] on the regular grid: octaves of value noise, each twice as fine
|
||||
and `gain` as strong."""
|
||||
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 fbm_at(u, v, rng, base_cells=4, octaves=8, gain=0.5, ridged=False):
|
||||
"""fBm sampled at map coordinates (u, v), where 0..1 spans the map once; anything outside wraps. Feed it
|
||||
warped or anisotropic coordinates and the noise bends and stretches with them."""
|
||||
total = np.zeros(u.shape, dtype=np.float32)
|
||||
amplitude, cells, norm = 1.0, base_cells, 0.0
|
||||
for _ in range(octaves):
|
||||
lattice = rng.random((cells, cells), dtype=np.float32)
|
||||
n = sample_lattice(lattice, u * cells, v * cells)
|
||||
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 normalised(a):
|
||||
return (a - a.min()) / max(float(a.max() - a.min()), 1e-6)
|
||||
|
||||
|
||||
def cellular_edges(u, v, rng, cells=12, jitter=0.9):
|
||||
"""Worley cellular noise, F2 - F1 through periodic jittered feature points, mapped so the borders between
|
||||
cells read 1 and the interiors 0: a network of thin, branching crest lines. Sampled at map coordinates
|
||||
like fbm_at, so warped coordinates bend the network."""
|
||||
points = rng.random((cells, cells, 2), dtype=np.float32) * jitter + (1.0 - jitter) * 0.5
|
||||
su = u * cells
|
||||
sv = v * cells
|
||||
i0 = np.floor(su).astype(np.int32)
|
||||
j0 = np.floor(sv).astype(np.int32)
|
||||
fu = (su - i0).astype(np.float32)
|
||||
fv = (sv - j0).astype(np.float32)
|
||||
f1 = np.full(u.shape, np.inf, dtype=np.float32)
|
||||
f2 = f1.copy()
|
||||
for dj in (-1, 0, 1):
|
||||
for di in (-1, 0, 1):
|
||||
ci = (i0 + di) % cells
|
||||
cj = (j0 + dj) % cells
|
||||
d = np.hypot(points[cj, ci, 0] + di - fu, points[cj, ci, 1] + dj - fv)
|
||||
closer = d < f1
|
||||
f2 = np.where(closer, f1, np.minimum(f2, d))
|
||||
f1 = np.where(closer, d, f1)
|
||||
edge = 1.0 - np.clip((f2 - f1) / 0.6, 0.0, 1.0)
|
||||
return (edge * edge).astype(np.float32)
|
||||
|
||||
|
||||
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 generate_metres(size, seed, quad_m, sea_level_m=0.0, land_height_m=2560.0):
|
||||
"""A size x size continent in metres above `sea_level_m`, before erosion: the uplift. The crests approach
|
||||
`land_height_m` above the sea; the sea floor lies 30 to 180 m below it, shaped so the shore is not a step.
|
||||
|
||||
On steepness: each octave of noise contributes a slope of about amplitude over wavelength, so with gain 0.5
|
||||
every octave is as steep as the last and eight of them stack into cliffs everywhere. The gains here keep
|
||||
the meadows gentle; the ranges are meant to be steep and the erosion pass gives them their faces.
|
||||
Measure the result with a slope histogram before tuning by eye.
|
||||
"""
|
||||
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, gain=0.45) - 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))
|
||||
|
||||
# A low-frequency warp field bends everything that follows, so ridges curve and ranges are not blobs.
|
||||
warp_x = (fbm(size, rng, base_cells=3, octaves=3, gain=0.5) - 0.5) * 0.16
|
||||
warp_y = (fbm(size, rng, base_cells=3, octaves=3, gain=0.5) - 0.5) * 0.16
|
||||
|
||||
plains = fbm(size, rng, base_cells=6, octaves=4, gain=0.45) * 0.05
|
||||
hills = fbm_at(x + warp_x * 0.5, y + warp_y * 0.5, rng, base_cells=5, octaves=5, gain=0.45) * 0.18
|
||||
|
||||
# Ranges. An elongated, warped band says where they run: stretched across its grain so they come as long
|
||||
# chains, thresholded by percentile so they and their foothills cover about two fifths of the map whatever
|
||||
# the seed. Ridged noise through the same warp gives them their crests; the gamma keeps the flanks massive.
|
||||
angle = float(rng.uniform(0.0, np.pi))
|
||||
along = (x - 0.5) * np.cos(angle) + (y - 0.5) * np.sin(angle)
|
||||
across = -(x - 0.5) * np.sin(angle) + (y - 0.5) * np.cos(angle)
|
||||
band = fbm_at(0.5 + along * 0.7 + warp_x, 0.5 + across * 2.2 + warp_y, rng, base_cells=3, octaves=3, gain=0.5)
|
||||
band_lo, band_hi = np.percentile(band, [58.0, 86.0])
|
||||
range_mask = smoothstep(np.clip((band - band_lo) / max(band_hi - band_lo, 1e-6), 0.0, 1.0))
|
||||
ridges = normalised(fbm_at(x + warp_x, y + warp_y, rng, base_cells=5, octaves=6, gain=0.42, ridged=True))
|
||||
# Cellular edges through a stronger warp: a light touch of branching crest lines where cells meet. Kept
|
||||
# light on purpose: at 0.3 the ranges became a honeycomb of polygon walls with flat floors (2026-09-17).
|
||||
crests = cellular_edges(x + warp_x * 1.4, y + warp_y * 1.4, rng, cells=14, jitter=0.95)
|
||||
mountains = np.power(0.88 * ridges + 0.12 * crests, 0.8) * range_mask
|
||||
|
||||
# Ground detail at the scale of a few quads, a few metres tall: texture, not terrain.
|
||||
detail = (fbm(size, rng, base_cells=200, octaves=3, gain=0.5) - 0.5) * 2.0 * 4.0
|
||||
|
||||
land = 0.04 + plains + hills * (0.4 + 0.6 * continent) + mountains * 1.0
|
||||
height = continent * land * land_height_m + detail * continent
|
||||
sea_floor = (-0.03 - (1.0 - continent) * 0.04 + plains * 0.3) * land_height_m
|
||||
height = np.where(continent > 0.02, height, sea_floor).astype(np.float32)
|
||||
height = np.maximum(height, sea_floor.astype(np.float32))
|
||||
# Weathering and erosion are heightmap_erosion.py's job; this is the raw uplift.
|
||||
return (height + sea_level_m).astype(np.float32)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""The world manifest: RawContent/World/World.json, the one place that says how big L_World 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.
|
||||
|
||||
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")
|
||||
|
||||
HEIGHTMAP_FILE = "L_World_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_World_Base_Layer.png",
|
||||
"Layer_02": "L_World_Layer_02.png",
|
||||
"Layer_03": "L_World_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_World_Flow.png",
|
||||
"wear": "L_World_Wear.png",
|
||||
"deposit": "L_World_Deposit.png",
|
||||
"curvature": "L_World_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
|
||||
self.level = data.get("level", "/Game/Maps/L_World")
|
||||
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)
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the terrain generator (Docs/Terrain.md) to Tools/Terrain/bin/terrain.exe.
|
||||
#
|
||||
# Go is not in the repository and not in the engine's toolchain, so this is the one place that says so out
|
||||
# loud when it is missing. Everything else about the generator is engine-free and needs nothing installed.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT/Tools/Terrain"
|
||||
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
echo "build-terrain: no 'go' on PATH." >&2
|
||||
echo " The generator needs a Go toolchain (1.22 or newer, for math/rand/v2). https://go.dev/dl/" >&2
|
||||
echo " Take the windows/amd64 build: a 386 toolchain caps the process near 2 GB, and one float32" >&2
|
||||
echo " field at the full 7141 grid is 204 MB, of which the detail passes hold several at once." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "go $(go version | awk '{print $3}' | sed 's/^go//') ($(go env GOOS)/$(go env GOARCH))"
|
||||
if [ "$(go env GOARCH)" = "386" ]; then
|
||||
echo "build-terrain: WARNING - this is a 32-bit toolchain. The full-resolution run will not fit." >&2
|
||||
fi
|
||||
|
||||
gofmt -l . | (! grep .) || { echo "build-terrain: gofmt would change the files above" >&2; exit 1; }
|
||||
go vet ./...
|
||||
go test ./...
|
||||
mkdir -p bin
|
||||
go build -o bin/terrain.exe ./cmd/terrain
|
||||
echo "built $ROOT/Tools/Terrain/bin/terrain.exe"
|
||||
Reference in New Issue
Block a user