This commit is contained in:
Vysyr committed 2026-09-05 13:33:04 +02:00
1 parent 2a2cea4fd0
commit 3a9b791a6a
8 files changed
+788 -207

No files matched your search

@@ -0,0 +1,225 @@
"""Generate turntable Level Sequences (and optionally queue Movie Render Queue jobs).
Run this from Unreal's Output Log with::
exec(open(r".../Content/Python/generate_blueprint_turntable.py").read())
generate_turntables("/Game/Blueprints/BP_MyAsset")
The script creates/saves a small presentation map, so the bindings in the
sequences and MRQ jobs are reproducible. Open any generated sequence in
Sequencer to show the normal camera, camera-cut and transform tracks.
"""
import unreal
# Project-specific defaults. They can be overridden in generate_turntables().
DEFAULT_OUTPUT_ROOT = "/Game/Sequences/Turntables"
DEFAULT_STAGE_MAP = "/Game/Maps/TurntableStage"
DEFAULT_RENDER_DIRECTORY = "{project_dir}/Saved/TurntableRenders"
FPS = 30
TURN_DURATION_SECONDS = 5.0
QUEUE_RENDER_IMMEDIATELY = False # True starts MRQ immediately after queueing.
RENDER_ALL_SKELETON_ANIMATIONS = False
def _log(message):
unreal.log("[Turntable] " + message)
def _asset_name(path):
return path.rsplit("/", 1)[-1].split(".")[0]
def _ensure_folder(path):
if not unreal.EditorAssetLibrary.does_directory_exist(path):
unreal.EditorAssetLibrary.make_directory(path)
def _create_or_open_stage(stage_map):
"""Open the dedicated map; creating it only on the first run."""
if unreal.EditorAssetLibrary.does_asset_exist(stage_map):
unreal.EditorLoadingAndSavingUtils.load_map(stage_map)
else:
_ensure_folder(stage_map.rsplit("/", 1)[0])
unreal.EditorLevelLibrary.new_level(stage_map)
unreal.EditorLevelLibrary.save_current_level()
def _find_skeletal_components(actor):
components = actor.get_components_by_class(unreal.SkeletalMeshComponent)
return [component for component in components if component.get_editor_property("skeletal_mesh")]
def _animations_set_on_blueprint(actor):
"""Return AnimSequences explicitly selected on components in the Blueprint.
Anim Blueprints are procedural at runtime and do not expose a finite list of
clips, so they are deliberately not guessed here. A component using
"Use Animation Asset" is exported as one sequence per assigned clip.
"""
animations = []
for component in _find_skeletal_components(actor):
try:
animation_data = component.get_editor_property("animation_data")
animation = animation_data.get_editor_property("anim_to_play")
if animation and animation not in animations:
animations.append(animation)
except Exception:
pass
return animations
def _all_skeleton_animations(actor):
"""Optional broad mode: render every AnimSequence sharing the actor skeleton."""
skeletons = set()
for component in _find_skeletal_components(actor):
mesh = component.get_editor_property("skeletal_mesh")
skeleton = mesh.get_editor_property("skeleton") if mesh else None
if skeleton:
skeletons.add(skeleton.get_path_name())
animations = []
registry = unreal.AssetRegistryHelpers.get_asset_registry()
for data in registry.get_assets_by_class("AnimSequence", True):
animation = data.get_asset()
if animation and animation.get_editor_property("skeleton").get_path_name() in skeletons:
animations.append(animation)
return animations
def _set_transform_keys(track, frames, start_location, start_rotation):
section = track.add_section()
section.set_range(0, frames)
channels = section.get_channels()
# Translation XYZ, rotation XYZ, scale XYZ. Key all channels explicitly so
# the result remains readable/editable in Sequencer.
values = [start_location.x, start_location.y, start_location.z,
start_rotation.roll, start_rotation.pitch, start_rotation.yaw,
1.0, 1.0, 1.0]
for index, value in enumerate(values):
channels[index].add_key(unreal.FrameNumber(0), value)
end_value = start_rotation.yaw + 360.0 if index == 5 else value
channels[index].add_key(unreal.FrameNumber(frames), end_value)
def _add_camera(sequence, camera, frames):
camera_binding = sequence.add_possessable(camera)
cut_track = sequence.add_master_track(unreal.MovieSceneCameraCutTrack)
cut = cut_track.add_section()
cut.set_range(0, frames)
# Camera cuts require a MovieSceneObjectBindingID, not the binding proxy.
cut.set_camera_binding_id(sequence.get_binding_id(camera_binding))
def _add_animation_track(sequence, actor, animation, frames):
"""Bind the matching skeletal component and play its animation clip."""
for component in _find_skeletal_components(actor):
mesh = component.get_editor_property("skeletal_mesh")
skeleton = mesh.get_editor_property("skeleton") if mesh else None
if skeleton != animation.get_editor_property("skeleton"):
continue
binding = sequence.add_possessable(component)
track = binding.add_track(unreal.MovieSceneSkeletalAnimationTrack)
section = track.add_section()
section.set_range(0, frames)
params = section.get_editor_property("params")
params.set_editor_property("animation", animation)
section.set_editor_property("params", params)
return True
return False
def _make_sequence(sequence_name, output_folder, actor, camera, animation=None):
tools = unreal.AssetToolsHelpers.get_asset_tools()
existing_path = output_folder + "/" + sequence_name
if unreal.EditorAssetLibrary.does_asset_exist(existing_path):
unreal.EditorAssetLibrary.delete_asset(existing_path)
sequence = tools.create_asset(sequence_name, output_folder, unreal.LevelSequence,
unreal.LevelSequenceFactoryNew())
frames = max(1, round(FPS * max(TURN_DURATION_SECONDS,
animation.get_play_length() if animation else 0.0)))
sequence.set_display_rate(unreal.FrameRate(FPS, 1))
sequence.set_playback_start(0)
sequence.set_playback_end(frames)
actor_binding = sequence.add_possessable(actor)
transform_track = actor_binding.add_track(unreal.MovieScene3DTransformTrack)
_set_transform_keys(transform_track, frames, actor.get_actor_location(), actor.get_actor_rotation())
_add_camera(sequence, camera, frames)
if animation:
_add_animation_track(sequence, actor, animation, frames)
unreal.EditorAssetLibrary.save_asset(sequence.get_path_name())
return sequence
def _queue_render(sequence, stage_map, directory):
subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem)
queue = subsystem.get_queue()
job = queue.allocate_new_job(unreal.MoviePipelineExecutorJob)
job.job_name = sequence.get_name()
job.sequence = unreal.SoftObjectPath(sequence.get_path_name())
job.map = unreal.SoftObjectPath(stage_map)
config = job.get_configuration()
output = config.find_or_add_setting_by_class(unreal.MoviePipelineOutputSetting)
output.output_directory = unreal.DirectoryPath(directory)
output.file_name_format = "{job_name}/{frame_number}"
output.output_resolution = unreal.IntPoint(1920, 1080)
config.find_or_add_setting_by_class(unreal.MoviePipelineImageSequenceOutput_PNG)
return subsystem
def generate_turntables(blueprint_path, output_root=DEFAULT_OUTPUT_ROOT,
stage_map=DEFAULT_STAGE_MAP, render_directory=DEFAULT_RENDER_DIRECTORY,
render_immediately=QUEUE_RENDER_IMMEDIATELY,
render_all_skeleton_animations=RENDER_ALL_SKELETON_ANIMATIONS):
"""Create a turntable plus one turntable per explicitly assigned animation.
`blueprint_path` must be an asset path, e.g. /Game/Blueprints/BP_Robot.
The return value is the generated LevelSequence list.
"""
blueprint = unreal.load_asset(blueprint_path)
if not blueprint or not isinstance(blueprint, unreal.Blueprint):
raise RuntimeError("Expected a Blueprint asset path, got: " + blueprint_path)
bp_name = _asset_name(blueprint_path)
# A separate stage per asset prevents other turntable actors from appearing
# in the same render. Supplying a custom stage_map preserves that exact map.
if stage_map == DEFAULT_STAGE_MAP:
stage_map = stage_map + "_" + bp_name
_create_or_open_stage(stage_map)
folder = output_root + "/" + bp_name
_ensure_folder(folder)
# Re-running the script replaces only its own presentation actors in the
# selected stage map. This keeps repeated demos deterministic.
generated_labels = {"TT_" + bp_name, "TT_Camera_" + bp_name}
for existing_actor in unreal.EditorLevelLibrary.get_all_level_actors():
if existing_actor.get_actor_label() in generated_labels:
unreal.EditorLevelLibrary.destroy_actor(existing_actor)
actor = unreal.EditorLevelLibrary.spawn_actor_from_object(blueprint, unreal.Vector(0, 0, 0))
actor.set_actor_label("TT_" + bp_name)
origin, extent = actor.get_actor_bounds(False)
distance = max(extent.x, extent.y, extent.z) * 3.0 + 150.0
camera = unreal.EditorLevelLibrary.spawn_actor_from_class(
unreal.CineCameraActor, unreal.Vector(origin.x - distance, origin.y, origin.z), unreal.Rotator(0, 0, 0))
camera.set_actor_label("TT_Camera_" + bp_name)
camera.set_actor_rotation((origin - camera.get_actor_location()).rotation(), False)
camera.get_cine_camera_component().set_editor_property("current_focal_length", 50.0)
sequences = [_make_sequence("LS_TT_" + bp_name, folder, actor, camera)]
animations = _all_skeleton_animations(actor) if render_all_skeleton_animations else _animations_set_on_blueprint(actor)
for animation in animations:
name = "LS_TT_{0}_{1}".format(bp_name, animation.get_name())
sequences.append(_make_sequence(name, folder, actor, camera, animation))
unreal.EditorLevelLibrary.save_current_level()
subsystem = None
for sequence in sequences:
subsystem = _queue_render(sequence, stage_map, render_directory)
if render_immediately and subsystem:
subsystem.render_queue_with_executor(unreal.MoviePipelinePIEExecutor)
_log("Created {0} sequence(s) and queued {0} PNG job(s) for {1}.".format(len(sequences), bp_name))
return sequences
+9 -206
View File
@@ -12,17 +12,18 @@ terrain_config.json actually exist.
Set the TERRAIN_CONFIG env var to point at a different config file, e.g. to
run terrain_config_variants_demo.json without touching the default config.
See generate_terrain_from_heightmap.py for the same pipeline driven by a real
heightmap image instead of procedural noise - both share terrain_common.py.
"""
import os, json, math, random, unreal
import terrain_common as tc
CONFIG_PATH = os.environ.get(
"TERRAIN_CONFIG",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "terrain_config_variants_demo.json"),
)
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
# ---------------------------------------------------------------------------
# Fractal value noise (pure Python, no engine dependency) -------------------
@@ -86,206 +87,10 @@ class FractalHeightField:
return (total / max_amp) * self.height_scale
def normal(self, x, y, step=200.0):
h_l = self.height(x - step, y)
h_r = self.height(x + step, y)
h_d = self.height(x, y - step)
h_u = self.height(x, y + step)
# tangents along X and Y, cross product gives the surface normal.
tangent_x = unreal.Vector(2 * step, 0.0, h_r - h_l)
tangent_y = unreal.Vector(0.0, 2 * step, h_u - h_d)
nx = tangent_x.y * tangent_y.z - tangent_x.z * tangent_y.y
ny = tangent_x.z * tangent_y.x - tangent_x.x * tangent_y.z
nz = tangent_x.x * tangent_y.y - tangent_x.y * tangent_y.x
length = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0
return unreal.Vector(nx / length, ny / length, nz / length)
return tc.finite_difference_normal(self.height, x, y, step)
# ---------------------------------------------------------------------------
# Terrain mesh ----------------------------------------------------------------
# ---------------------------------------------------------------------------
def build_terrain_mesh(cfg, field):
steps = max(2, cfg["resolution"])
mesh = unreal.DynamicMesh()
primitive_options = unreal.GeometryScriptPrimitiveOptions()
mesh = unreal.GeometryScript_Primitives.append_rectangle_xy(
mesh, primitive_options, unreal.Transform(),
dimension_x=cfg["size_x"], dimension_y=cfg["size_y"],
steps_width=steps, steps_height=steps,
)
mesh, position_list, _has_gaps = unreal.GeometryScript_MeshQueries.get_all_vertex_positions(mesh, False)
positions = unreal.GeometryScript_List.convert_vector_list_to_array(position_list)
min_x = min_y = float("inf")
max_x = max_y = float("-inf")
displaced = []
for p in positions:
z = field.height(p.x, p.y)
displaced.append(unreal.Vector(p.x, p.y, z))
min_x, max_x = min(min_x, p.x), max(max_x, p.x)
min_y, max_y = min(min_y, p.y), max(max_y, p.y)
new_list = unreal.GeometryScript_List.convert_array_to_vector_list(displaced)
mesh = unreal.GeometryScript_MeshEdits.set_all_mesh_vertex_positions(mesh, new_list)
mesh = unreal.GeometryScript_Normals.set_per_vertex_normals(mesh)
dest_path = cfg["dest_path"]
asset_name = cfg["asset_name"]
asset_path = f"{dest_path}/{asset_name}"
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
unreal.EditorAssetLibrary.delete_asset(asset_path)
create_options = unreal.GeometryScriptCreateNewStaticMeshAssetOptions()
static_mesh, _outcome = unreal.GeometryScript_NewAssetUtils.create_new_static_mesh_asset_from_mesh(
mesh, asset_path, create_options,
)
unreal.EditorAssetLibrary.save_asset(static_mesh.get_path_name())
bounds = (min_x, max_x, min_y, max_y)
return static_mesh, bounds
_mesh_bounds_cache = {}
def mesh_local_bounds(mesh):
path = mesh.get_path_name()
if path not in _mesh_bounds_cache:
box = mesh.get_bounding_box()
_mesh_bounds_cache[path] = (box.min, box.max)
return _mesh_bounds_cache[path]
def ground_offset_for(mesh, quat, scale_vec):
"""World-space Z distance from the actor's pivot down to the lowest point of
its (scaled, rotated) mesh, so the base can be planted exactly on the ground
no matter where the source asset's pivot was authored, or how it's tilted."""
box_min, box_max = mesh_local_bounds(mesh)
min_z = None
for x in (box_min.x, box_max.x):
for y in (box_min.y, box_max.y):
for z in (box_min.z, box_max.z):
corner = unreal.Vector(x * scale_vec.x, y * scale_vec.y, z * scale_vec.z)
world_z = quat.rotate_vector(corner).z
if min_z is None or world_z < min_z:
min_z = world_z
return -min_z
def align_and_yaw_quat(normal, yaw_degrees, align_to_normal):
"""Random yaw about the object's own up axis, then (optionally) tilt that
up axis to match the terrain normal - in that order, since tilting first
would rotate the yaw axis out from under the random spin."""
yaw_quat = unreal.Rotator(roll=0.0, pitch=0.0, yaw=yaw_degrees).quaternion()
if not align_to_normal:
return yaw_quat
align_quat = unreal.Vector(0.0, 0.0, 1.0).find_quat_between_normals(normal)
return align_quat * yaw_quat
# ---------------------------------------------------------------------------
# Foliage scatter --------------------------------------------------------------
# ---------------------------------------------------------------------------
def scatter_layer(layer_cfg, field, bounds, origin, base_folder):
min_x, max_x, min_y, max_y = bounds
rng = random.Random(layer_cfg.get("seed", 0))
meshes = [unreal.load_asset(path) for path in layer_cfg["meshes"]]
meshes = [m for m in meshes if m is not None]
if not meshes:
print(f" [{layer_cfg['name']}] no valid meshes found, skipping")
return 0
max_slope_cos = math.cos(math.radians(layer_cfg.get("max_slope_degrees", 90.0)))
min_spacing = layer_cfg.get("min_spacing", 0.0)
min_spacing_sqr = min_spacing * min_spacing
placed_points = []
spawned = 0
max_attempts = layer_cfg["count"] * 25
attempts = 0
while spawned < layer_cfg["count"] and attempts < max_attempts:
attempts += 1
x = rng.uniform(min_x, max_x)
y = rng.uniform(min_y, max_y)
if min_spacing_sqr > 0.0 and any((x - px) ** 2 + (y - py) ** 2 < min_spacing_sqr for px, py in placed_points):
continue
z = field.height(x, y)
if not (layer_cfg["min_height"] <= z <= layer_cfg["max_height"]):
continue
normal = field.normal(x, y)
if normal.z < max_slope_cos:
continue
placed_points.append((x, y))
mesh = rng.choice(meshes)
scale = rng.uniform(layer_cfg["min_scale"], layer_cfg["max_scale"])
scale_vec = unreal.Vector(scale, scale, scale)
yaw = rng.uniform(0.0, 360.0)
quat = align_and_yaw_quat(normal, yaw, layer_cfg.get("align_to_normal", False))
rotation = quat.rotator()
ground_offset = ground_offset_for(mesh, quat, scale_vec)
location = unreal.Vector(origin.x + x, origin.y + y, origin.z + z + ground_offset)
actor = actor_subsystem.spawn_actor_from_object(mesh, location, rotation)
actor.set_actor_scale3d(scale_vec)
actor.set_folder_path(f"{base_folder}/{layer_cfg['name']}")
actor.set_actor_label(f"{layer_cfg['name']}_{spawned:04d}")
spawned += 1
print(f" [{layer_cfg['name']}] placed {spawned}/{layer_cfg['count']} ({attempts} attempts)")
return spawned
def place_explicit(entries, field, origin, base_folder):
"""Places an exact, hand-authored (or externally-generated) list of instances.
Unlike scatter_layer's density/slope rules, this reproduces a literal
manifest byte-for-byte: the same JSON always yields the same named
landmarks at the same coordinates - useful for hero props, quest markers,
or a layout exported from an external design tool, none of which a
rule-driven scatter (ours or a graph-based tool like PCG) is meant to pin down.
"""
placed = 0
for entry in entries:
mesh = unreal.load_asset(entry["mesh"])
if mesh is None:
print(f" [explicit] missing mesh asset: {entry['mesh']}")
continue
x, y, z_offset = entry.get("location", [0.0, 0.0, 0.0])
scale = entry.get("scale", 1.0)
if isinstance(scale, list):
scale_vec = unreal.Vector(*scale)
else:
scale_vec = unreal.Vector(scale, scale, scale)
roll, pitch, yaw = entry.get("rotation", [0.0, 0.0, 0.0])
rotation = unreal.Rotator(roll=roll, pitch=pitch, yaw=yaw)
quat = rotation.quaternion()
z = z_offset
if entry.get("snap_to_terrain", True):
z += field.height(x, y) + ground_offset_for(mesh, quat, scale_vec)
location = unreal.Vector(origin.x + x, origin.y + y, origin.z + z)
actor = actor_subsystem.spawn_actor_from_object(mesh, location, rotation)
actor.set_actor_scale3d(scale_vec)
actor.set_folder_path(f"{base_folder}/Explicit")
actor.set_actor_label(entry.get("name", f"Explicit_{placed:04d}"))
placed += 1
print(f" [explicit] placed {placed}/{len(entries)}")
return placed
def generate_tile(config, terrain_cfg, tile_label):
"""Builds one terrain + its scatter layers + its explicit placements, all
under a dedicated Outliner folder so multiple tiles can coexist in one level.
@@ -294,20 +99,18 @@ def generate_tile(config, terrain_cfg, tile_label):
base_folder = f"ProceduralTerrain/{tile_label}"
print(f"[{tile_label}] building terrain mesh (seed={terrain_cfg['noise']['seed']})...")
static_mesh, bounds = build_terrain_mesh(terrain_cfg, field)
static_mesh, bounds = tc.build_terrain_mesh(terrain_cfg, field)
loc = terrain_cfg["location"]
origin = unreal.Vector(loc[0], loc[1], loc[2])
terrain_actor = actor_subsystem.spawn_actor_from_object(static_mesh, origin, unreal.Rotator(0, 0, 0))
terrain_actor.set_folder_path(base_folder)
terrain_actor.set_actor_label(f"Terrain_{tile_label}")
tc.spawn_terrain_actor(static_mesh, origin, base_folder, f"Terrain_{tile_label}")
for layer_cfg in config.get("scatter_layers", []):
scatter_layer(layer_cfg, field, bounds, origin, base_folder)
tc.scatter_layer(layer_cfg, field, bounds, origin, base_folder)
explicit_entries = config.get("explicit_placements", [])
if explicit_entries:
place_explicit(explicit_entries, field, origin, base_folder)
tc.place_explicit(explicit_entries, field, origin, base_folder)
def main():
@@ -0,0 +1,160 @@
"""Run inside Unreal (Python console / Execute Python Script).
Same pipeline as generate_procedural_terrain.py, but the height field comes
from an actual image (e.g. DemoAssets/poland_shaded.png) instead of procedural
noise. Reads heightmap_config.json (next to this script).
By default darker pixels = higher elevation and brighter pixels = lower, to
match shaded-relief style maps (set "invert": false in the config for the
opposite, standard-heightmap convention where white = high).
If the image has an alpha channel, pixels with alpha below "alpha_threshold"
(0-1, default 0.5) are cut out of the terrain mesh entirely (real holes, not
just low ground) and are excluded from foliage scatter/placement - useful for
a heightmap shaped like a country/island silhouette with a transparent
background.
Run import_environment_assets.py first so the mesh paths referenced in the
config actually exist. Set the HEIGHTMAP_CONFIG env var to point at a
different config file.
"""
import os, json, unreal
import terrain_common as tc
from png_reader import decode_png
CONFIG_PATH = os.environ.get(
"HEIGHTMAP_CONFIG",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "heightmap_config.json"),
)
class HeightmapField:
def __init__(self, cfg):
image_path = cfg["image_path"]
print(f"Decoding heightmap image: {image_path}")
width, height, channels, pixels = decode_png(image_path)
print(f" {width}x{height}, {channels} channel(s)")
self.width = width
self.height_px = height
self.invert = cfg.get("invert", True) # darker = higher, by default (shaded-relief convention)
self.height_scale = cfg["height_scale"]
self.size_x = cfg["size_x"]
self.size_y = cfg["size_y"]
self.alpha_threshold = cfg.get("alpha_threshold", 0.5)
self.exclude_white = cfg.get("exclude_white", True)
self.white_threshold = cfg.get("white_threshold", 0.98)
self._lum = self._build_luminance(pixels, width, height, channels)
self._alpha = self._build_alpha(pixels, width, height, channels)
self._min_channel = self._build_min_channel(pixels, width, height, channels)
@staticmethod
def _build_luminance(pixels, width, height, channels):
count = width * height
lum = [0.0] * count
if channels <= 2:
for i in range(count):
lum[i] = pixels[i * channels] / 255.0
else:
for i in range(count):
o = i * channels
lum[i] = (0.299 * pixels[o] + 0.587 * pixels[o + 1] + 0.114 * pixels[o + 2]) / 255.0
return lum
@staticmethod
def _build_min_channel(pixels, width, height, channels):
"""Per-pixel min(R,G,B) (or the sole channel, if grayscale) in [0,1] -
used to detect "fully white" fill pixels regardless of luminance
formula quirks (e.g. pure yellow has high luminance but isn't white)."""
count = width * height
arr = [0.0] * count
if channels <= 2:
for i in range(count):
arr[i] = pixels[i * channels] / 255.0
else:
for i in range(count):
o = i * channels
arr[i] = min(pixels[o], pixels[o + 1], pixels[o + 2]) / 255.0
return arr
@staticmethod
def _build_alpha(pixels, width, height, channels):
# Only grayscale+alpha (2) and RGBA (4) carry an alpha channel; anything
# else is fully opaque, so there is nothing to mask out.
if channels not in (2, 4):
return None
count = width * height
alpha = [0.0] * count
last = channels - 1
for i in range(count):
alpha[i] = pixels[i * channels + last] / 255.0
return alpha
def _world_to_image(self, x, y):
# Map world (x, y) in [-size/2, size/2] to continuous image space, with
# +Y world mapped toward row 0 (image top), matching a north-up map.
u = (x + self.size_x / 2.0) / self.size_x
v = (y + self.size_y / 2.0) / self.size_y
u = min(max(u, 0.0), 1.0)
v = min(max(v, 0.0), 1.0)
return u * (self.width - 1), (1.0 - v) * (self.height_px - 1)
def _sample_bilinear(self, channel_array, x, y):
px, py = self._world_to_image(x, y)
x0, y0 = int(px), int(py)
x1, y1 = min(x0 + 1, self.width - 1), min(y0 + 1, self.height_px - 1)
fx, fy = px - x0, py - y0
def S(xx, yy):
return channel_array[yy * self.width + xx]
s00, s10, s01, s11 = S(x0, y0), S(x1, y0), S(x0, y1), S(x1, y1)
s0 = s00 + (s10 - s00) * fx
s1 = s01 + (s11 - s01) * fx
return s0 + (s1 - s0) * fy
def height(self, x, y):
lum = self._sample_bilinear(self._lum, x, y)
if self.invert:
lum = 1.0 - lum
return lum * self.height_scale
def normal(self, x, y, step=200.0):
return tc.finite_difference_normal(self.height, x, y, step)
def is_masked(self, x, y):
if self._alpha is not None and self._sample_bilinear(self._alpha, x, y) < self.alpha_threshold:
return True
if self.exclude_white and self._sample_bilinear(self._min_channel, x, y) >= self.white_threshold:
return True
return False
def main():
with open(CONFIG_PATH, "r") as f:
config = json.load(f)
terrain_cfg = config["terrain"]
field = HeightmapField(terrain_cfg)
base_folder = "HeightmapTerrain"
print("Building terrain mesh from heightmap...")
static_mesh, bounds = tc.build_terrain_mesh(terrain_cfg, field)
loc = terrain_cfg["location"]
origin = unreal.Vector(loc[0], loc[1], loc[2])
tc.spawn_terrain_actor(static_mesh, origin, base_folder, "HeightmapTerrain")
print("Scattering foliage layers...")
for layer_cfg in config.get("scatter_layers", []):
tc.scatter_layer(layer_cfg, field, bounds, origin, base_folder)
explicit_entries = config.get("explicit_placements", [])
if explicit_entries:
print("Placing explicit manifest entries...")
tc.place_explicit(explicit_entries, field, origin, base_folder)
print("Done.")
main()
+51
View File
@@ -0,0 +1,51 @@
{
"terrain": {
"image_path": "C:\\Users\\koleg\\Documents\\Unreal Projects\\ExampleProject\\DemoAssets\\poland_shaded.png",
"invert": true,
"alpha_threshold": 0.5,
"location": [0.0, 0.0, 0.0],
"size_x": 20000.0,
"size_y": 16580.0,
"resolution": 128,
"height_scale": 3000.0,
"dest_path": "/Game/ImportedAssets/Terrain",
"asset_name": "SM_HeightmapTerrain"
},
"scatter_layers": [
{
"name": "Trees",
"meshes": [
"/Game/ImportedAssets/Trees/SM_tree01",
"/Game/ImportedAssets/Trees/SM_tree02",
"/Game/ImportedAssets/Trees/SM_tree03",
"/Game/ImportedAssets/Trees/SM_tree04"
],
"count": 300,
"min_height": 0.0,
"max_height": 1500.0,
"max_slope_degrees": 30.0,
"min_scale": 0.85,
"max_scale": 1.3,
"align_to_normal": true,
"min_spacing": 250.0,
"seed": 7
},
{
"name": "Rocks",
"meshes": [
"/Game/ImportedAssets/Rocks/SM_rock1",
"/Game/ImportedAssets/Rocks/SM_rock2",
"/Game/ImportedAssets/Rocks/SM_rock3"
],
"count": 150,
"min_height": 800.0,
"max_height": 3000.0,
"max_slope_degrees": 50.0,
"min_scale": 0.5,
"max_scale": 2.0,
"align_to_normal": true,
"min_spacing": 200.0,
"seed": 42
}
]
}
+86
View File
@@ -0,0 +1,86 @@
"""Minimal pure-Python PNG decoder (stdlib only: zlib + struct).
Supports 8-bit, non-interlaced grayscale / grayscale+alpha / RGB / RGBA PNGs -
which covers ordinary exported heightmap/relief images. Deliberately does not
depend on Pillow or any other package, since Unreal's embedded Python has no
pip packages installed by default.
"""
import struct, zlib
_CHANNELS_BY_COLOR_TYPE = {0: 1, 2: 3, 4: 2, 6: 4} # 3 (palette) is unsupported
def _paeth(a, b, c):
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
if pa <= pb and pa <= pc:
return a
if pb <= pc:
return b
return c
def decode_png(path):
"""Returns (width, height, channels, pixels) where pixels is a flat
bytearray of width*height*channels bytes, row-major, top row first."""
with open(path, "rb") as f:
data = f.read()
if data[:8] != b"\x89PNG\r\n\x1a\n":
raise ValueError(f"Not a PNG file: {path}")
width = height = bit_depth = color_type = interlace = None
idat = bytearray()
pos = 8
while pos < len(data):
length = struct.unpack(">I", data[pos:pos + 4])[0]
ctype = data[pos + 4:pos + 8]
chunk = data[pos + 8:pos + 8 + length]
if ctype == b"IHDR":
width, height, bit_depth, color_type, _comp, _filt, interlace = struct.unpack(">IIBBBBB", chunk)
elif ctype == b"IDAT":
idat.extend(chunk)
elif ctype == b"IEND":
break
pos += 8 + length + 4 # length + type + data + crc
if bit_depth != 8:
raise ValueError(f"Only 8-bit PNGs are supported (got bit depth {bit_depth}) - re-export as 8-bit: {path}")
if interlace != 0:
raise ValueError(f"Interlaced PNGs are not supported - re-export as non-interlaced: {path}")
if color_type not in _CHANNELS_BY_COLOR_TYPE:
raise ValueError(f"Unsupported PNG color type {color_type} (palette?) - re-export as grayscale/RGB/RGBA: {path}")
channels = _CHANNELS_BY_COLOR_TYPE[color_type]
raw = zlib.decompress(bytes(idat))
stride = width * channels
pixels = bytearray(width * height * channels)
prev_row = bytearray(stride)
row_start = 0
for y in range(height):
filter_type = raw[row_start]
row = bytearray(raw[row_start + 1: row_start + 1 + stride])
row_start += 1 + stride
if filter_type == 1: # Sub
for i in range(channels, stride):
row[i] = (row[i] + row[i - channels]) & 0xFF
elif filter_type == 2: # Up
for i in range(stride):
row[i] = (row[i] + prev_row[i]) & 0xFF
elif filter_type == 3: # Average
for i in range(stride):
a = row[i - channels] if i >= channels else 0
row[i] = (row[i] + ((a + prev_row[i]) >> 1)) & 0xFF
elif filter_type == 4: # Paeth
for i in range(stride):
a = row[i - channels] if i >= channels else 0
b = prev_row[i]
c = prev_row[i - channels] if i >= channels else 0
row[i] = (row[i] + _paeth(a, b, c)) & 0xFF
# filter_type == 0 (None): no-op
pixels[y * stride:(y + 1) * stride] = row
prev_row = row
return width, height, channels, pixels
+252
View File
@@ -0,0 +1,252 @@
"""Shared by generate_procedural_terrain.py and generate_terrain_from_heightmap.py.
Everything here is agnostic to where the height values come from - it only
needs a "field" object with:
field.height(x, y) -> float
field.normal(x, y) -> unreal.Vector (unit length)
so noise-driven and heightmap-driven terrains can reuse the exact same mesh
building, ground-snapping, and scatter/placement logic.
"""
import math, random, unreal
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
def finite_difference_normal(height_fn, x, y, step=200.0):
"""Surface normal from four height samples around (x, y). `step` should be
large enough to average over small-scale bumps - otherwise high-frequency
noise/heightmap detail produces exaggerated per-instance tilt."""
h_l = height_fn(x - step, y)
h_r = height_fn(x + step, y)
h_d = height_fn(x, y - step)
h_u = height_fn(x, y + step)
tangent_x = unreal.Vector(2 * step, 0.0, h_r - h_l)
tangent_y = unreal.Vector(0.0, 2 * step, h_u - h_d)
nx = tangent_x.y * tangent_y.z - tangent_x.z * tangent_y.y
ny = tangent_x.z * tangent_y.x - tangent_x.x * tangent_y.z
nz = tangent_x.x * tangent_y.y - tangent_x.y * tangent_y.x
length = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0
return unreal.Vector(nx / length, ny / length, nz / length)
# ---------------------------------------------------------------------------
# Terrain mesh ----------------------------------------------------------------
# ---------------------------------------------------------------------------
def build_terrain_mesh(cfg, field):
steps = max(2, cfg["resolution"])
mesh = unreal.DynamicMesh()
primitive_options = unreal.GeometryScriptPrimitiveOptions()
mesh = unreal.GeometryScript_Primitives.append_rectangle_xy(
mesh, primitive_options, unreal.Transform(),
dimension_x=cfg["size_x"], dimension_y=cfg["size_y"],
steps_width=steps, steps_height=steps,
)
mesh, position_list, _has_gaps = unreal.GeometryScript_MeshQueries.get_all_vertex_positions(mesh, False)
positions = unreal.GeometryScript_List.convert_vector_list_to_array(position_list)
min_x = min_y = float("inf")
max_x = max_y = float("-inf")
displaced = []
for p in positions:
z = field.height(p.x, p.y)
displaced.append(unreal.Vector(p.x, p.y, z))
min_x, max_x = min(min_x, p.x), max(max_x, p.x)
min_y, max_y = min(min_y, p.y), max(max_y, p.y)
new_list = unreal.GeometryScript_List.convert_array_to_vector_list(displaced)
mesh = unreal.GeometryScript_MeshEdits.set_all_mesh_vertex_positions(mesh, new_list)
is_masked = getattr(field, "is_masked", None)
if is_masked is not None:
masked_vertex_ids = {vid for vid, p in enumerate(positions) if is_masked(p.x, p.y)}
if masked_vertex_ids:
mesh, triangle_list, _has_tri_gaps = unreal.GeometryScript_MeshQueries.get_all_triangle_indices(mesh, False)
triangles = unreal.GeometryScript_List.convert_triangle_list_to_array(triangle_list)
triangles_to_delete = [
tid for tid, tri in enumerate(triangles)
if tri.x in masked_vertex_ids or tri.y in masked_vertex_ids or tri.z in masked_vertex_ids
]
if triangles_to_delete:
index_list = unreal.GeometryScript_List.convert_array_to_index_list(triangles_to_delete)
mesh, num_deleted = unreal.GeometryScript_MeshEdits.delete_triangles_from_mesh(mesh, index_list)
print(f" cut {num_deleted} triangle(s) out of masked/transparent regions")
mesh = unreal.GeometryScript_Normals.set_per_vertex_normals(mesh)
dest_path = cfg["dest_path"]
asset_name = cfg["asset_name"]
asset_path = f"{dest_path}/{asset_name}"
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
unreal.EditorAssetLibrary.delete_asset(asset_path)
create_options = unreal.GeometryScriptCreateNewStaticMeshAssetOptions()
static_mesh, _outcome = unreal.GeometryScript_NewAssetUtils.create_new_static_mesh_asset_from_mesh(
mesh, asset_path, create_options,
)
unreal.EditorAssetLibrary.save_asset(static_mesh.get_path_name())
bounds = (min_x, max_x, min_y, max_y)
return static_mesh, bounds
def spawn_terrain_actor(static_mesh, origin, base_folder, label):
terrain_actor = actor_subsystem.spawn_actor_from_object(static_mesh, origin, unreal.Rotator(0, 0, 0))
terrain_actor.set_folder_path(base_folder)
terrain_actor.set_actor_label(label)
return terrain_actor
# ---------------------------------------------------------------------------
# Ground-snapping / alignment ------------------------------------------------
# ---------------------------------------------------------------------------
_mesh_bounds_cache = {}
def mesh_local_bounds(mesh):
path = mesh.get_path_name()
if path not in _mesh_bounds_cache:
box = mesh.get_bounding_box()
_mesh_bounds_cache[path] = (box.min, box.max)
return _mesh_bounds_cache[path]
def ground_offset_for(mesh, quat, scale_vec):
"""World-space Z distance from the actor's pivot down to the lowest point of
its (scaled, rotated) mesh, so the base can be planted exactly on the ground
no matter where the source asset's pivot was authored, or how it's tilted."""
box_min, box_max = mesh_local_bounds(mesh)
min_z = None
for x in (box_min.x, box_max.x):
for y in (box_min.y, box_max.y):
for z in (box_min.z, box_max.z):
corner = unreal.Vector(x * scale_vec.x, y * scale_vec.y, z * scale_vec.z)
world_z = quat.rotate_vector(corner).z
if min_z is None or world_z < min_z:
min_z = world_z
return -min_z
def align_and_yaw_quat(normal, yaw_degrees, align_to_normal):
"""Random yaw about the object's own up axis, then (optionally) tilt that
up axis to match the terrain normal - in that order, since tilting first
would rotate the yaw axis out from under the random spin."""
yaw_quat = unreal.Rotator(roll=0.0, pitch=0.0, yaw=yaw_degrees).quaternion()
if not align_to_normal:
return yaw_quat
align_quat = unreal.Vector(0.0, 0.0, 1.0).find_quat_between_normals(normal)
return align_quat * yaw_quat
# ---------------------------------------------------------------------------
# Foliage scatter --------------------------------------------------------------
# ---------------------------------------------------------------------------
def scatter_layer(layer_cfg, field, bounds, origin, base_folder):
min_x, max_x, min_y, max_y = bounds
rng = random.Random(layer_cfg.get("seed", 0))
meshes = [unreal.load_asset(path) for path in layer_cfg["meshes"]]
meshes = [m for m in meshes if m is not None]
if not meshes:
print(f" [{layer_cfg['name']}] no valid meshes found, skipping")
return 0
max_slope_cos = math.cos(math.radians(layer_cfg.get("max_slope_degrees", 90.0)))
min_spacing = layer_cfg.get("min_spacing", 0.0)
min_spacing_sqr = min_spacing * min_spacing
is_masked = getattr(field, "is_masked", None)
placed_points = []
spawned = 0
max_attempts = layer_cfg["count"] * 25
attempts = 0
while spawned < layer_cfg["count"] and attempts < max_attempts:
attempts += 1
x = rng.uniform(min_x, max_x)
y = rng.uniform(min_y, max_y)
if is_masked is not None and is_masked(x, y):
continue
if min_spacing_sqr > 0.0 and any((x - px) ** 2 + (y - py) ** 2 < min_spacing_sqr for px, py in placed_points):
continue
z = field.height(x, y)
if not (layer_cfg["min_height"] <= z <= layer_cfg["max_height"]):
continue
normal = field.normal(x, y)
if normal.z < max_slope_cos:
continue
placed_points.append((x, y))
mesh = rng.choice(meshes)
scale = rng.uniform(layer_cfg["min_scale"], layer_cfg["max_scale"])
scale_vec = unreal.Vector(scale, scale, scale)
yaw = rng.uniform(0.0, 360.0)
quat = align_and_yaw_quat(normal, yaw, layer_cfg.get("align_to_normal", False))
rotation = quat.rotator()
ground_offset = ground_offset_for(mesh, quat, scale_vec)
location = unreal.Vector(origin.x + x, origin.y + y, origin.z + z + ground_offset)
actor = actor_subsystem.spawn_actor_from_object(mesh, location, rotation)
actor.set_actor_scale3d(scale_vec)
actor.set_folder_path(f"{base_folder}/{layer_cfg['name']}")
actor.set_actor_label(f"{layer_cfg['name']}_{spawned:04d}")
spawned += 1
print(f" [{layer_cfg['name']}] placed {spawned}/{layer_cfg['count']} ({attempts} attempts)")
return spawned
def place_explicit(entries, field, origin, base_folder):
"""Places an exact, hand-authored (or externally-generated) list of instances.
Unlike scatter_layer's density/slope rules, this reproduces a literal
manifest byte-for-byte: the same JSON always yields the same named
landmarks at the same coordinates - useful for hero props, quest markers,
or a layout exported from an external design tool, none of which a
rule-driven scatter (ours or a graph-based tool like PCG) is meant to pin down.
"""
is_masked = getattr(field, "is_masked", None)
placed = 0
for entry in entries:
mesh = unreal.load_asset(entry["mesh"])
if mesh is None:
print(f" [explicit] missing mesh asset: {entry['mesh']}")
continue
x, y, z_offset = entry.get("location", [0.0, 0.0, 0.0])
if is_masked is not None and is_masked(x, y):
print(f" [explicit] {entry.get('name', '?')} falls in a masked/transparent region, skipping")
continue
scale = entry.get("scale", 1.0)
if isinstance(scale, list):
scale_vec = unreal.Vector(*scale)
else:
scale_vec = unreal.Vector(scale, scale, scale)
roll, pitch, yaw = entry.get("rotation", [0.0, 0.0, 0.0])
rotation = unreal.Rotator(roll=roll, pitch=pitch, yaw=yaw)
quat = rotation.quaternion()
z = z_offset
if entry.get("snap_to_terrain", True):
z += field.height(x, y) + ground_offset_for(mesh, quat, scale_vec)
location = unreal.Vector(origin.x + x, origin.y + y, origin.z + z)
actor = actor_subsystem.spawn_actor_from_object(mesh, location, rotation)
actor.set_actor_scale3d(scale_vec)
actor.set_folder_path(f"{base_folder}/Explicit")
actor.set_actor_label(entry.get("name", f"Explicit_{placed:04d}"))
placed += 1
print(f" [explicit] placed {placed}/{len(entries)}")
return placed
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

+5 -1
View File
@@ -78,6 +78,10 @@
{
"Name": "EditorScriptingUtilities",
"Enabled": true
},
{
"Name": "MovieRenderPipeline",
"Enabled": true
}
]
}
}