workshop rework

This commit is contained in:
Vysyr committed 2026-10-31 17:03:41 +01:00
1 parent a3a80dfa16
commit fb39e290c0
115 files changed
+1865 -497

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-130
View File
@@ -1,130 +0,0 @@
"""Run inside Unreal (Python console / Execute Python Script).
Watches a disk folder for FBX + textures named by convention (Name.fbx,
Name_BC/_N/_M/_R/_E.png), imports everything without opening the import
dialog, and wires the textures into a Material Instance per mesh.
"""
import os, unreal
DEMO_ROOT = r"C:\Users\koleg\Documents\Unreal Projects\ExampleProject\DemoAssets"
DEST_ROOT = "/Game/ImportedAssets"
MASTER_MATERIAL = f"{DEST_ROOT}/M_Master"
# suffix -> (texture parameter name, material property, sampled channel)
PARAMS = {
"_BC": ("BaseColor", unreal.MaterialProperty.MP_BASE_COLOR, "RGB"),
"_N": ("Normal", unreal.MaterialProperty.MP_NORMAL, "RGB"),
"_M": ("Metallic", unreal.MaterialProperty.MP_METALLIC, "R"),
"_R": ("Roughness", unreal.MaterialProperty.MP_ROUGHNESS, "R"),
"_E": ("Emissive", unreal.MaterialProperty.MP_EMISSIVE_COLOR, "RGB"),
}
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
mel = unreal.MaterialEditingLibrary
def get_or_create_master_material():
if unreal.EditorAssetLibrary.does_asset_exist(MASTER_MATERIAL):
return unreal.EditorAssetLibrary.load_asset(MASTER_MATERIAL)
material = asset_tools.create_asset("M_Master", DEST_ROOT, unreal.Material, unreal.MaterialFactoryNew())
for i, (suffix, (param, prop, channel)) in enumerate(PARAMS.items()):
tex = mel.create_material_expression(material, unreal.MaterialExpressionTextureSampleParameter2D, -400, i * 200)
tex.set_editor_property("parameter_name", param)
mel.connect_material_property(tex, channel, prop)
mel.recompile_material(material)
return material
def import_task(filename, name, dest_path, options=None):
task = unreal.AssetImportTask()
task.filename = filename
task.destination_path = dest_path
task.destination_name = name
task.automated = True
task.save = True
task.replace_existing = True
if options:
task.options = options
asset_tools.import_asset_tasks([task])
return unreal.load_asset(f"{dest_path}/{name}")
def import_fbx(filename, name, dest_path):
options = unreal.FbxImportUI()
options.mesh_type_to_import = unreal.FBXImportType.FBXIT_SKELETAL_MESH
options.import_mesh = True
options.import_as_skeletal = True
options.import_animations = True
options.create_physics_asset = False
options.import_materials = False
options.import_textures = False
return import_task(filename, name, dest_path, options)
def assign_material(skeletal_mesh, material):
existing_slots = skeletal_mesh.get_editor_property("materials")
new_slots = []
for slot in existing_slots:
new_slot = unreal.SkeletalMaterial()
new_slot.set_editor_property("material_interface", material)
new_slot.set_editor_property("material_slot_name", slot.get_editor_property("material_slot_name"))
new_slots.append(new_slot)
if not new_slots:
# FBX had no material assigned to the mesh, so no slot was created on import.
fallback = unreal.SkeletalMaterial()
fallback.set_editor_property("material_interface", material)
fallback.set_editor_property("material_slot_name", "Material")
new_slots.append(fallback)
skeletal_mesh.modify()
skeletal_mesh.set_editor_property("materials", new_slots)
unreal.EditorAssetLibrary.save_asset(skeletal_mesh.get_path_name())
master = get_or_create_master_material()
asset_count = 0
for asset_folder in os.listdir(DEMO_ROOT):
src_dir = os.path.join(DEMO_ROOT, asset_folder)
if not os.path.isdir(src_dir):
continue
dest_path = f"{DEST_ROOT}/{asset_folder}"
# group source files by base name: {"RobotPolice": {"fbx": "...", "_BC": "...", ...}}
groups = {}
for f in os.listdir(src_dir):
stem, ext = os.path.splitext(f)
if ext.lower() == ".fbx":
groups.setdefault(stem, {})["fbx"] = f
continue
for suffix in PARAMS:
if stem.endswith(suffix):
groups.setdefault(stem[: -len(suffix)], {})[suffix] = f
break
for base_name, files in groups.items():
if "fbx" not in files:
continue
mesh = import_fbx(os.path.join(src_dir, files["fbx"]), base_name, dest_path)
mi = asset_tools.create_asset(f"MI_{base_name}", dest_path, unreal.MaterialInstanceConstant,
unreal.MaterialInstanceConstantFactoryNew())
mel.set_material_instance_parent(mi, master)
textures = {}
for suffix, (param, _, _) in PARAMS.items():
if suffix not in files:
continue
texture = import_task(os.path.join(src_dir, files[suffix]), f"T_{base_name}{suffix}", dest_path)
textures[suffix] = texture
mel.set_material_instance_texture_parameter_value(mi, param, texture)
if "_E" not in files and "_BC" in textures:
mel.set_material_instance_texture_parameter_value(mi, "Emissive", textures["_BC"])
unreal.EditorAssetLibrary.save_asset(mi.get_path_name())
assign_material(mesh, mi)
asset_count += 1
print(f"Imported {asset_count} asset(s) into {DEST_ROOT}")
-5
View File
@@ -1,5 +0,0 @@
import unreal
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
actors = sub.get_all_level_actors()
unreal.log(f"{len(actors)} actors in level")
-16
View File
@@ -1,16 +0,0 @@
import unreal
OLD = unreal.load_asset("/Game/ImportedAssets/Trees/SM_tree04.SM_tree04")
NEW = unreal.load_asset("/Game/ImportedAssets/Trees/SM_tree01.SM_tree01")
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
actors = sub.get_all_level_actors()
old_mesh_count = 0
for actor in actors:
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
if comp and comp.static_mesh == OLD:
old_mesh_count += 1
print(f"Found {old_mesh_count} instances of the old mesh")
-19
View File
@@ -1,19 +0,0 @@
import unreal
OLD = unreal.load_asset("/Game/ImportedAssets/Trees/SM_tree04.SM_tree04")
NEW = unreal.load_asset("/Game/ImportedAssets/Trees/SM_tree01.SM_tree01")
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
actors = sub.get_all_level_actors()
old_mesh_count = 0
with unreal.ScopedEditorTransaction("Swap meshes"):
for actor in actors:
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
if comp and comp.static_mesh == OLD:
comp.set_editor_property("static_mesh", NEW)
# comp.set_static_mesh(NEW) -- tak tez mozna ale undo nie zadziala bo zmiana nie jest robiona przez edytor wiec jej on nie rejestruje
old_mesh_count += 1
unreal.log(f"Swapped {old_mesh_count} meshes")
@@ -1,288 +0,0 @@
"""Generate turntable Level Sequences (and optionally queue Movie Render Queue jobs).
Run this from Unreal's Output Log with::
py ".../Content/Python/generate_blueprint_turntable.py" /Game/Blueprints/BP_MyAsset
Append ``--render-now`` to start Movie Render Queue and
``--all-animations`` to render every AnimSequence using the Blueprint's
skeleton.
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 sys
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
CAMERA_Z = 40.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_skeletal_mesh_asset()]
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_skeletal_mesh_asset()
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()
anim_sequence_class = unreal.TopLevelAssetPath("/Script/Engine", "AnimSequence")
for data in registry.get_assets_by_class(anim_sequence_class, 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)
# UE 5.8 exposes section channels through the SequencerScripting extension
# library (MovieScene3DTransformSection itself has no get_channels method).
channels = unreal.MovieSceneSectionExtensions.get_channels_by_type(
section, unreal.MovieSceneScriptingDoubleChannel)
if len(channels) != 9:
raise RuntimeError("Expected 9 transform channels, got {0}".format(len(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)
# UE 5.8 creates master tracks through MovieSceneSequence.add_track.
cut_track = sequence.add_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_skeletal_mesh_asset()
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):
# Use a concrete directory rather than an MRQ format token, so its location
# is deterministic both in the queue UI and during PIE execution.
if directory == DEFAULT_RENDER_DIRECTORY:
directory = unreal.Paths.project_saved_dir().rstrip("/\\") + "/TurntableRenders"
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)
output.override_existing_output = True
# A queue constructed in Python has no implicit render pass. Without a
# Deferred Pass MRQ has no frames to submit or write.
config.find_or_add_setting_by_class(unreal.MoviePipelineDeferredPassBase)
config.find_or_add_setting_by_class(unreal.MoviePipelineImageSequenceOutput_PNG)
return subsystem
def _remove_previous_turntable_jobs(bp_name):
"""Replace prior generated jobs without touching the user's unrelated MRQ jobs."""
subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem)
queue = subsystem.get_queue()
prefix = "LS_TT_" + bp_name
for job in queue.get_jobs():
if job.job_name.startswith(prefix):
queue.delete_job(job)
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_location = unreal.Vector(origin.x - distance, origin.y, CAMERA_Z)
camera_target = unreal.Vector(origin.x, origin.y, CAMERA_Z)
camera = unreal.EditorLevelLibrary.spawn_actor_from_class(
unreal.CineCameraActor, camera_location, unreal.Rotator(0, 0, 0))
camera.set_actor_label("TT_Camera_" + bp_name)
camera.set_actor_rotation(
unreal.MathLibrary.find_look_at_rotation(camera.get_actor_location(), camera_target), False)
cine_camera = camera.get_cine_camera_component()
cine_camera.set_editor_property("current_focal_length", 50.0)
# A library turntable must stay sharp across the whole asset, not only on a
# small cinematic focal plane.
cine_camera.set_editor_property(
"focus_settings",
unreal.CameraFocusSettings(focus_method=unreal.CameraFocusMethod.DISABLE),
)
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()
_remove_previous_turntable_jobs(bp_name)
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
def _run_from_console_arguments():
"""Support a concise `py "script.py" /Game/Path/BP_Name [flags]` command."""
render_now = "--render-now" in sys.argv
all_animations = "--all-animations" in sys.argv
for argument in sys.argv[1:]:
# Dragging an asset into the Output Log can produce
# /Script/Engine.Blueprint'/Game/Foo/BP_X.BP_X'; accept that form too.
game_index = argument.find("/Game/")
if game_index < 0:
continue
asset_path = argument[game_index:].split("'", 1)[0].split(".", 1)[0]
generate_turntables(
asset_path,
render_immediately=render_now,
render_all_skeleton_animations=all_animations,
)
return
_run_from_console_arguments()
@@ -1,159 +0,0 @@
"""Run inside Unreal (Python console / Execute Python Script).
Reads terrain_config.json (next to this script) and:
1. builds a heightfield mesh with GeometryScript (fractal value noise),
bakes it to a StaticMesh, and drops it into the level;
2. scatters tree/rock StaticMeshActors across it per "scatter_layers",
using the same noise field so foliage height always matches the
ground beneath it (no raycasting needed).
Run import_environment_assets.py first so the mesh paths referenced in
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"),
)
# ---------------------------------------------------------------------------
# Fractal value noise (pure Python, no engine dependency) -------------------
# ---------------------------------------------------------------------------
class ValueNoise2D:
"""Seeded, smooth, tileable-enough value noise for terrain-style height fields."""
def __init__(self, seed):
rng = random.Random(seed)
table = list(range(256))
rng.shuffle(table)
self._perm = table + table # avoid overflow when indexing i+1
@staticmethod
def _fade(t):
return t * t * t * (t * (t * 6 - 15) + 10)
@staticmethod
def _lerp(a, b, t):
return a + t * (b - a)
def _gradient(self, ix, iy):
h = self._perm[(self._perm[ix & 255] + iy) & 255]
angle = (h / 255.0) * 2.0 * math.pi
return math.cos(angle), math.sin(angle)
def sample(self, x, y):
x0, y0 = math.floor(x), math.floor(y)
x1, y1 = x0 + 1, y0 + 1
sx, sy = x - x0, y - y0
def dot_grid(ix, iy, px, py):
gx, gy = self._gradient(ix, iy)
return gx * (px - ix) + gy * (py - iy)
n00 = dot_grid(x0, y0, x, y)
n10 = dot_grid(x1, y0, x, y)
n01 = dot_grid(x0, y1, x, y)
n11 = dot_grid(x1, y1, x, y)
u, v = self._fade(sx), self._fade(sy)
return self._lerp(self._lerp(n00, n10, u), self._lerp(n01, n11, u), v)
class FractalHeightField:
def __init__(self, noise_cfg, height_scale):
self.height_scale = height_scale
self.frequency = noise_cfg["frequency"]
self.octaves = noise_cfg["octaves"]
self.lacunarity = noise_cfg["lacunarity"]
self.gain = noise_cfg["gain"]
self.noise = ValueNoise2D(noise_cfg["seed"])
def height(self, x, y):
amplitude, frequency, total, max_amp = 1.0, self.frequency, 0.0, 0.0
for _ in range(self.octaves):
total += amplitude * self.noise.sample(x * frequency, y * frequency)
max_amp += amplitude
amplitude *= self.gain
frequency *= self.lacunarity
return (total / max_amp) * self.height_scale
def normal(self, x, y, step=200.0):
return tc.finite_difference_normal(self.height, x, y, step)
# ---------------------------------------------------------------------------
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.
"""
field = FractalHeightField(terrain_cfg["noise"], terrain_cfg["height_scale"])
base_folder = f"ProceduralTerrain/{tile_label}"
print(f"[{tile_label}] building terrain mesh (seed={terrain_cfg['noise']['seed']})...")
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, f"Terrain_{tile_label}")
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:
tc.place_explicit(explicit_entries, field, origin, base_folder)
def main():
with open(CONFIG_PATH, "r") as f:
config = json.load(f)
base_terrain_cfg = config["terrain"]
variants = config.get("variants")
if not variants:
# Single-tile mode: unchanged behavior for existing configs.
generate_tile(config, base_terrain_cfg, "Main")
print("Done.")
return
# Variant mode: one JSON list of seeds becomes N independent, deterministic
# tiles laid out side by side in a single run - each a full reproducible
# world driven by nothing but that seed. There's no "batch-produce N baked,
# comparable layouts from one data file" workflow in the Foliage tool or a
# PCG graph; you'd re-author or re-seed each one by hand, one at a time.
gap = base_terrain_cfg["size_x"] * 1.2
base_seed = base_terrain_cfg["noise"]["seed"]
base_layer_seeds = {layer["name"]: layer.get("seed", 0) for layer in config.get("scatter_layers", [])}
for i, seed_offset in enumerate(variants):
tile_label = f"Variant_{seed_offset}"
terrain_cfg = json.loads(json.dumps(base_terrain_cfg)) # cheap deep copy
terrain_cfg["noise"]["seed"] = base_seed + seed_offset
terrain_cfg["asset_name"] = f"{base_terrain_cfg['asset_name']}_{tile_label}"
terrain_cfg["location"] = [
base_terrain_cfg["location"][0] + i * gap,
base_terrain_cfg["location"][1],
base_terrain_cfg["location"][2],
]
tile_config = json.loads(json.dumps(config))
tile_config["terrain"] = terrain_cfg
for layer_cfg in tile_config.get("scatter_layers", []):
layer_cfg["seed"] = base_layer_seeds.get(layer_cfg["name"], 0) + seed_offset
generate_tile(tile_config, terrain_cfg, tile_label)
print(f"Done: {len(variants)} variant(s) generated side by side.")
main()
@@ -1,160 +0,0 @@
"""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
@@ -1,51 +0,0 @@
{
"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
}
]
}
-3
View File
@@ -1,3 +0,0 @@
import unreal
print("Hello World!")
@@ -1,66 +0,0 @@
"""Run inside Unreal (Python console / Execute Python Script).
Imports the tree (FBX) and rock (OBJ) meshes from DemoAssets as StaticMesh
assets, so generate_procedural_terrain.py has something to scatter.
"""
import os, unreal
DEMO_ROOT = r"C:\Users\koleg\Documents\Unreal Projects\ExampleProject\DemoAssets"
DEST_ROOT = "/Game/ImportedAssets"
# folder under DEMO_ROOT -> (destination subfolder, file extension)
SOURCES = {
"trees": ("Trees", ".fbx"),
"rocks": ("Rocks", ".obj"),
}
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
def import_task(filename, name, dest_path, options=None):
task = unreal.AssetImportTask()
task.filename = filename
task.destination_path = dest_path
task.destination_name = name
task.automated = True
task.save = True
task.replace_existing = True
if options:
task.options = options
asset_tools.import_asset_tasks([task])
return unreal.load_asset(f"{dest_path}/{name}")
def import_static_mesh(filename, name, dest_path):
ext = os.path.splitext(filename)[1].lower()
options = None
if ext == ".fbx":
options = unreal.FbxImportUI()
options.mesh_type_to_import = unreal.FBXImportType.FBXIT_STATIC_MESH
options.import_mesh = True
options.import_as_skeletal = False
options.import_materials = True
options.import_textures = True
options.static_mesh_import_data.combine_meshes = True
# OBJ has no import-options struct exposed to Python; Interchange picks sane defaults.
return import_task(filename, name, dest_path, options)
imported = []
for src_folder, (dest_subfolder, ext) in SOURCES.items():
src_dir = os.path.join(DEMO_ROOT, src_folder)
if not os.path.isdir(src_dir):
continue
dest_path = f"{DEST_ROOT}/{dest_subfolder}"
for f in sorted(os.listdir(src_dir)):
stem, file_ext = os.path.splitext(f)
if file_ext.lower() != ext:
continue
name = f"SM_{stem}"
mesh = import_static_mesh(os.path.join(src_dir, f), name, dest_path)
imported.append(f"{dest_path}/{name}")
print(f"Imported {len(imported)} static mesh(es):")
for path in imported:
print(f" {path}")
-48
View File
@@ -1,48 +0,0 @@
import re, unreal
FOLDERS = {
"World": "Maps",
"LevelSequence": "Sequences",
"Material": "Materials",
"MaterialInstanceConstant": "Materials",
"NiagaraSystem": "Effects",
"Blueprint": "Blueprints",
}
PREFIXES = {
"World": "",
"Material": "M_",
"MaterialInstanceConstant": "MI_",
"StaticMesh": "SM_",
"SkeletalMesh": "SK_",
"Blueprint": "BP_",
"NiagaraSystem": "NS_",
"LevelSequence": "LS_",
}
def to_pascal_case(name):
parts = re.split(r"[_\-\s]+", name)
return "".join(p[:1].upper() + p[1:] for p in parts if p)
lib = unreal.EditorAssetLibrary
for path in lib.list_assets("/Game", recursive=True):
path = path.split(".")[0]
name = path.rsplit("/", 1)[-1]
data = lib.find_asset_data(path)
cls = str(data.asset_class_path.asset_name)
# instance assets are renamed after their parent, not their own name
if cls == "MaterialInstanceConstant":
parent = lib.load_asset(path).get_editor_property("parent")
base = re.sub(r"^(M_|MI_)", "", parent.get_name()) if parent else name
suffix = name[len(base):] if name.lower().startswith(base.lower()) else ""
name = base + (f"_{suffix.capitalize()}" if suffix else "")
name = re.sub(r"^(" + "|".join(p for p in PREFIXES.values() if p) + ")", "", name)
name = to_pascal_case(name)
name = PREFIXES.get(cls, "") + name
folder = FOLDERS.get(cls, "Misc")
dest = f"/Game/{folder}/{name}"
if path != dest:
lib.rename_asset(path, dest)
-86
View File
@@ -1,86 +0,0 @@
"""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
@@ -1,252 +0,0 @@
"""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
-71
View File
@@ -1,71 +0,0 @@
{
"terrain": {
"location": [0.0, 0.0, 0.0],
"size_x": 8000.0,
"size_y": 8000.0,
"resolution": 96,
"height_scale": 600.0,
"dest_path": "/Game/ImportedAssets/Terrain",
"asset_name": "SM_ProceduralTerrain",
"noise": {
"seed": 1337,
"octaves": 5,
"frequency": 0.00035,
"lacunarity": 2.0,
"gain": 0.5
}
},
"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": 250,
"min_height": -50.0,
"max_height": 350.0,
"max_slope_degrees": 25.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"
],
"count": 150,
"min_height": -100.0,
"max_height": 600.0,
"max_slope_degrees": 45.0,
"min_scale": 0.5,
"max_scale": 2.0,
"align_to_normal": true,
"min_spacing": 150.0,
"seed": 42
}
],
"explicit_placements": [
{
"name": "LandmarkTree_Overlook",
"mesh": "/Game/ImportedAssets/Trees/SM_tree01",
"location": [1200.0, 800.0, 0.0],
"snap_to_terrain": true,
"rotation": [0.0, 0.0, 30.0],
"scale": 2.5
},
{
"name": "RockCairn_Waypoint",
"mesh": "/Game/ImportedAssets/Rocks/SM_rock1",
"location": [-1500.0, -600.0, 0.0],
"snap_to_terrain": true,
"rotation": [0.0, 0.0, 0.0],
"scale": [1.0, 1.0, 1.8]
}
]
}
@@ -1,54 +0,0 @@
{
"terrain": {
"location": [0.0, 0.0, 0.0],
"size_x": 3000.0,
"size_y": 3000.0,
"resolution": 48,
"height_scale": 500.0,
"dest_path": "/Game/ImportedAssets/Terrain",
"asset_name": "SM_TerrainDemo",
"noise": {
"seed": 1000,
"octaves": 5,
"frequency": 0.0006,
"lacunarity": 2.0,
"gain": 0.5
}
},
"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": 60,
"min_height": -50.0,
"max_height": 300.0,
"max_slope_degrees": 25.0,
"min_scale": 0.85,
"max_scale": 1.3,
"align_to_normal": true,
"min_spacing": 200.0,
"seed": 7
},
{
"name": "Rocks",
"meshes": [
"/Game/ImportedAssets/Rocks/SM_rock1"
],
"count": 30,
"min_height": -100.0,
"max_height": 500.0,
"max_slope_degrees": 45.0,
"min_scale": 0.5,
"max_scale": 2.0,
"align_to_normal": true,
"min_spacing": 150.0,
"seed": 42
}
],
"variants": [0, 1, 2, 3]
}
+15
View File
@@ -0,0 +1,15 @@
"""Python rendering workshop for Unreal Engine 5.7."""
import importlib
__all__ = ["Scene", "Sequence", "Renderer", "camera", "lighting", "materials", "assets", "blender", "render", "video"]
def __getattr__(name):
# Plain Python launchers can import paths/video/blender without importing unreal.
if name in {"Scene", "Sequence"}:
return getattr(importlib.import_module("." + name.lower(), __name__), name)
if name == "Renderer":
return importlib.import_module(".render", __name__).Renderer
if name in __all__:
return importlib.import_module("." + name, __name__)
raise AttributeError(name)
+105
View File
@@ -0,0 +1,105 @@
"""Synchronize basic glTF PBR properties preserved by UE mesh reimports."""
import json
import base64
import re
import struct
import unreal
from .paths import PROJECT
def link_textures(source, mesh, destination):
if source.suffix.lower() == ".glb":
data = source.read_bytes()
size, kind = struct.unpack_from("<II", data, 12)
if kind != 0x4E4F534A:
raise ValueError("GLB nie zawiera poczatkowego chunka JSON.")
document = json.loads(data[20:20 + size])
binary = data[28 + size:]
else:
document = json.loads(source.read_text(encoding="utf-8"))
binary = b""
def sanitized(value):
return re.sub(r"[^A-Za-z0-9_]", "_", value)
textures = {}
def load_texture(info, parameter):
index = document["textures"][info["index"]]["source"]
role = "Normal" if parameter == "NormalTexture" else (
"Color" if parameter in {"BaseColorTexture", "EmissiveTexture"} else "Linear")
key = (index, role)
if key in textures:
return textures[key]
image = document["images"][index]
name = f"T_{index:03d}_{sanitized(image.get('name', 'Image'))}_{role}"
uri = image.get("uri", "")
if "bufferView" in image:
view = document["bufferViews"][image["bufferView"]]
offset = view.get("byteOffset", 0)
payload = binary[offset:offset + view["byteLength"]]
elif uri.startswith("data:"):
payload = base64.b64decode(uri.split(",", 1)[1])
else:
from urllib.parse import unquote
payload = (source.parent / unquote(uri)).read_bytes()
extension = ".png" if payload.startswith(b"\x89PNG") else ".jpg" if payload.startswith(b"\xff\xd8") else None
if extension is None:
raise ValueError("Tekstury warsztatowe musza byc zapisane jako PNG lub JPEG.")
file = PROJECT / "build/GltfTextures" / source.stem / (name + extension)
file.parent.mkdir(parents=True, exist_ok=True)
file.write_bytes(payload)
task = unreal.AssetImportTask()
task.filename = str(file)
task.destination_path = destination + "/WorkshopTextures"
task.destination_name = name
task.automated = True
task.replace_existing = True
task.save = True
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
imported = [obj for obj in task.get_objects() if isinstance(obj, unreal.Texture2D)]
if len(imported) != 1:
raise RuntimeError("Nie mozna zaimportowac tekstury " + name)
texture = imported[0]
texture.set_editor_property("srgb", role == "Color")
texture.set_editor_property("flip_green_channel", role == "Normal")
texture.set_editor_property("compression_settings", unreal.TextureCompressionSettings.TC_NORMALMAP
if role == "Normal" else unreal.TextureCompressionSettings.TC_DEFAULT
if role == "Color" else unreal.TextureCompressionSettings.TC_MASKS)
unreal.EditorAssetLibrary.save_loaded_asset(texture)
textures[key] = texture
return texture
materials = {sanitized(item.get("name", "")): item for item in document.get("materials", [])}
lib = unreal.MaterialEditingLibrary
for slot in mesh.static_materials:
material = slot.material_interface
if material is None or material.get_name() not in materials:
continue
spec = materials[material.get_name()]
pbr = spec.get("pbrMetallicRoughness", {})
lib.set_material_instance_vector_parameter_value(
material, "BaseColorFactor", unreal.LinearColor(*pbr.get("baseColorFactor", [1, 1, 1, 1])))
lib.set_material_instance_vector_parameter_value(
material, "EmissiveFactor", unreal.LinearColor(*spec.get("emissiveFactor", [0, 0, 0]), 1))
lib.set_material_instance_scalar_parameter_value(material, "MetallicFactor", pbr.get("metallicFactor", 1))
lib.set_material_instance_scalar_parameter_value(material, "RoughnessFactor", pbr.get("roughnessFactor", 1))
maps = {"BaseColorTexture": pbr.get("baseColorTexture"),
"MetallicRoughnessTexture": pbr.get("metallicRoughnessTexture"),
"NormalTexture": spec.get("normalTexture"),
"OcclusionTexture": spec.get("occlusionTexture"),
"EmissiveTexture": spec.get("emissiveTexture")}
for parameter, info in maps.items():
lib.set_material_instance_static_switch_parameter_value(material, "bHas" + parameter, info is not None)
lib.update_material_instance(material)
for parameter, info in maps.items():
if info is None:
continue
texture = load_texture(info, parameter)
# UE 5.7's setter returns False even when it succeeds; verify by reading.
lib.set_material_instance_texture_parameter_value(material, parameter, texture)
if lib.get_material_instance_texture_parameter_value(material, parameter) != texture:
raise RuntimeError(f"Nie mozna przypisac {parameter} w {material.get_name()}.")
lib.update_material_instance(material)
unreal.EditorAssetLibrary.save_loaded_asset(material)
+68
View File
@@ -0,0 +1,68 @@
"""Automated static mesh imports using the engine's enabled import pipelines."""
from pathlib import Path
import unreal
from .paths import GENERATED, slug
def import_model(filename, name="Character"):
source = Path(filename).resolve()
if not source.is_file():
raise FileNotFoundError(f"Brak modelu: {source}")
if source.suffix.lower() not in {".glb", ".gltf", ".fbx", ".obj"}:
raise ValueError("Model musi byc plikiem GLB, glTF, FBX lub OBJ.")
destination = f"{GENERATED}/Imports/{slug(name)}"
task = unreal.AssetImportTask()
task.set_editor_property("filename", str(source))
task.set_editor_property("destination_path", destination)
task.set_editor_property("destination_name", name)
task.set_editor_property("automated", True)
task.set_editor_property("replace_existing", True)
task.set_editor_property("replace_existing_settings", True)
task.set_editor_property("save", True)
task.set_editor_property("async_", False)
if source.suffix.lower() in {".glb", ".gltf"}:
# Defaults reuse existing materials without updating them. Our generated
# import folder belongs to the source file, so apply source properties.
template = unreal.load_asset("/Interchange/Pipelines/DefaultGLTFAssetsPipeline")
pipeline = unreal.load_asset(GENERATED + "/Pipelines/GLTFWorkshop")
if pipeline is None:
pipeline = unreal.AssetToolsHelpers.get_asset_tools().duplicate_asset(
"GLTFWorkshop", GENERATED + "/Pipelines", template)
pipeline.set_editor_property("reimport_strategy", unreal.ReimportStrategyFlags.APPLY_PIPELINE_PROPERTIES)
pipeline.material_pipeline.set_editor_property("import_materials", True)
# PBR images are imported explicitly into WorkshopTextures below. This
# avoids Interchange's dependency-preserving material reimport behavior.
pipeline.material_pipeline.texture_pipeline.set_editor_property("import_textures", False)
pipeline.set_editor_property("scene_name_sub_folder", True)
pipeline.set_editor_property("asset_type_sub_folders", True)
pipeline.material_pipeline.set_editor_property(
"search_location", unreal.InterchangeMaterialSearchLocation.DO_NOT_SEARCH)
pipeline.material_pipeline.set_editor_property(
"material_import", unreal.InterchangeMaterialImportOption.IMPORT_AS_MATERIAL_INSTANCES)
unreal.EditorAssetLibrary.save_loaded_asset(pipeline)
options = unreal.InterchangePipelineStackOverride()
options.set_editor_property("override_pipelines", [
unreal.SoftObjectPath(pipeline.get_path_name()),
unreal.SoftObjectPath("/Interchange/Pipelines/DefaultGLTFPipeline.DefaultGLTFPipeline")])
task.set_editor_property("options", options)
if source.suffix.lower() == ".fbx":
options = unreal.FbxImportUI()
options.set_editor_property("import_as_skeletal", False)
options.set_editor_property("mesh_type_to_import", unreal.FBXImportType.FBXIT_STATIC_MESH)
options.set_editor_property("automated_import_should_detect_type", False)
options.static_mesh_import_data.set_editor_property("combine_meshes", True)
task.set_editor_property("options", options)
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
meshes = [obj for obj in task.get_objects() if isinstance(obj, unreal.StaticMesh)]
if len(meshes) != 1:
raise RuntimeError(
f"Import {source.name}: oczekiwano jednego StaticMesh, otrzymano {len(meshes)}. "
"Polacz obiekty przed eksportem albo uzyj workshop.blender.import_character().")
if source.suffix.lower() in {".glb", ".gltf"}:
# UE preserves material edits on mesh reimport. In this workflow Blender
# owns the look; explicitly synchronize the supported PBR parameters.
from ._gltf import link_textures
link_textures(source, meshes[0], destination)
return meshes[0]
+25
View File
@@ -0,0 +1,25 @@
"""Export saved Blender files in a separate process, then import them in UE."""
from pathlib import Path
import subprocess
from .paths import PROJECT, executable, slug
def export(filename, name="Character", collection="Character", blender=None):
source = Path(filename).resolve()
if not source.is_file() or source.suffix.lower() != ".blend":
raise FileNotFoundError(f"Brak pliku .blend: {source}")
destination = PROJECT / "build" / slug(name) / (name + ".glb")
destination.parent.mkdir(parents=True, exist_ok=True)
command = [executable("blender", blender), "--background", str(source),
"--python-exit-code", "1", "--python", str(PROJECT / "tools" / "blender_export.py"),
"--", "--output", str(destination), "--collection", collection]
result = subprocess.run(command, capture_output=True, text=True, timeout=180)
if result.returncode or not destination.is_file():
raise RuntimeError("Eksport Blendera nie powiodl sie:\n" + (result.stdout + result.stderr)[-6000:])
return destination
def import_character(filename, name="Character", collection="Character", blender=None):
from .assets import import_model
return import_model(export(filename, name, collection, blender), name)
+30
View File
@@ -0,0 +1,30 @@
import unreal
def look_at(actor, target):
actor.set_actor_rotation(unreal.MathLibrary.find_look_at_rotation(
actor.get_actor_location(), unreal.Vector(*target)), False)
def create(scene, location=(450, -550, 300), target=(0, 0, 100), focal_length=45):
actor = scene.spawn(unreal.CineCameraActor, "Camera", location)
component = actor.get_cine_camera_component()
filmback = component.get_editor_property("filmback")
filmback.set_editor_property("sensor_width", 36.0)
filmback.set_editor_property("sensor_height", 20.25)
component.set_editor_property("filmback", filmback)
component.set_editor_property("current_focal_length", float(focal_length))
focus = component.get_editor_property("focus_settings")
focus.set_editor_property("focus_method", unreal.CameraFocusMethod.DISABLE)
component.set_editor_property("focus_settings", focus)
settings = component.get_editor_property("post_process_settings")
settings.set_editor_property("override_auto_exposure_bias", True)
settings.set_editor_property("auto_exposure_bias", 0.0)
settings.set_editor_property("override_auto_exposure_min_brightness", True)
settings.set_editor_property("override_auto_exposure_max_brightness", True)
# Extended luminance range interprets these as EV100, not linear brightness.
settings.set_editor_property("auto_exposure_min_brightness", 8.0)
settings.set_editor_property("auto_exposure_max_brightness", 8.0)
component.set_editor_property("post_process_settings", settings)
look_at(actor, target)
return actor
+23
View File
@@ -0,0 +1,23 @@
import unreal
from .camera import look_at
def area(scene, name="Light", location=(200, -200, 350), target=(0, 0, 100),
intensity=12000, color=(1, 1, 1), size=180):
actor = scene.spawn(unreal.RectLight, name, location)
light = actor.get_component_by_class(unreal.RectLightComponent)
light.set_mobility(unreal.ComponentMobility.MOVABLE)
light.set_editor_property("intensity", float(intensity))
light.set_editor_property("source_width", float(size))
light.set_editor_property("source_height", float(size))
light.set_editor_property("attenuation_radius", 2000.0)
light.set_light_color(unreal.LinearColor(*color, 1.0))
look_at(actor, target)
return actor
def studio(scene, key_color=(1.0, 0.82, 0.65), fill_color=(0.5, 0.7, 1.0)):
return [area(scene, "Key", (200, -250, 350), color=key_color),
area(scene, "Fill", (100, 300, 250), intensity=7000, color=fill_color),
area(scene, "Rim", (-200, 0, 300), intensity=15000)]
+34
View File
@@ -0,0 +1,34 @@
"""Small opaque PBR materials, generated without external assets."""
import unreal
from .paths import GENERATED, slug
def color(name, rgb=(0.15, 0.5, 0.8), roughness=0.55, metallic=0.0):
path = f"{GENERATED}/Materials/M_{slug(name)}"
material = unreal.load_asset(path)
if material is None:
material = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
"M_" + name, GENERATED + "/Materials", unreal.Material, unreal.MaterialFactoryNew())
if not isinstance(material, unreal.Material):
raise TypeError(f"{path} nie jest materialem.")
lib = unreal.MaterialEditingLibrary
lib.delete_all_material_expressions(material)
base = lib.create_material_expression(material, unreal.MaterialExpressionConstant3Vector, -300, 0)
base.set_editor_property("constant", unreal.LinearColor(*rgb, 1.0))
lib.connect_material_property(base, "", unreal.MaterialProperty.MP_BASE_COLOR)
for offset, value, prop in [(160, roughness, unreal.MaterialProperty.MP_ROUGHNESS),
(280, metallic, unreal.MaterialProperty.MP_METALLIC)]:
node = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -300, offset)
node.set_editor_property("r", float(value))
lib.connect_material_property(node, "", prop)
lib.recompile_material(material)
unreal.EditorAssetLibrary.save_loaded_asset(material)
return material
def assign(actor, material, slot=0):
component = actor.get_component_by_class(unreal.StaticMeshComponent)
if component is None:
raise TypeError("Material mozna przypisac do aktora ze StaticMeshComponent.")
component.set_material(slot, material)
+23
View File
@@ -0,0 +1,23 @@
"""Paths and process helpers shared by UE, Blender launchers and plain Python."""
import os
from pathlib import Path
import re
import shutil
PROJECT = Path(__file__).resolve().parents[3]
GENERATED = "/Game/Workshop/Generated"
def slug(value):
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", value):
raise ValueError("Nazwa musi zaczynac sie litera i zawierac tylko litery ASCII, cyfry lub _.")
return value
def executable(name, explicit=None):
candidate = explicit or os.environ.get("WORKSHOP_" + name.upper()) or shutil.which(name)
if candidate and Path(candidate).is_file():
return str(Path(candidate).resolve())
raise FileNotFoundError(
f"Nie znaleziono {name}. Dodaj program do PATH lub ustaw WORKSHOP_{name.upper()} "
"na pelna sciezke do pliku wykonywalnego.")
+16
View File
@@ -0,0 +1,16 @@
"""Small append-only progress events shared with the external launcher."""
import json
import os
from pathlib import Path
def report(state, **details):
target = os.environ.get("WORKSHOP_PROGRESS")
if not target:
return
try:
with Path(target).open("a", encoding="utf-8") as stream:
stream.write(json.dumps(dict(state=state, **details), ensure_ascii=False) + "\n")
except OSError:
# Progress reporting must not interrupt a render or hide its result.
pass
+183
View File
@@ -0,0 +1,183 @@
"""MRQ rendering with retained delegates, isolated output and completion status."""
from datetime import datetime
from dataclasses import dataclass
import json
import os
from pathlib import Path
import uuid
import unreal
from .paths import PROJECT, executable
from .video import encode
from .progress import report
_active = None
PRESETS = {"preview": (640, 360, 1, 8), "final": (1280, 720, 4, 32)}
@dataclass
class Renderer:
"""Reusable render settings. Dimensions in pixels; warmup in frames."""
width: int = 640
height: int = 360
samples: int = 1
warmup: int = 8
mp4: bool = True
ffmpeg: str = None
def __post_init__(self):
self._validate()
def _validate(self):
for name, minimum in (("width", 1), ("height", 1), ("samples", 1), ("warmup", 0)):
value = getattr(self, name)
if type(value) is not int or value < minimum:
raise ValueError(f"{name} musi byc liczba calkowita >= {minimum}.")
@classmethod
def from_preset(cls, name="preview", **overrides):
if name not in PRESETS:
raise ValueError("Nieznany preset. Dostepne: " + ", ".join(PRESETS))
settings = dict(zip(("width", "height", "samples", "warmup"), PRESETS[name]))
settings.update(overrides)
return cls(**settings)
def render(self, sequence, *, single_frame=None):
self._validate()
return _start(sequence, self, single_frame=single_frame)
def is_rendering():
return _active is not None
def _write_status(data):
target = os.environ.get("WORKSHOP_STATUS")
if target:
path = Path(target)
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(path)
def _on_error(executor, pipeline, fatal, message):
if _active is not None:
_active["errors"].append(str(message))
unreal.log_error("[Workshop] MRQ: " + str(message))
def _on_finished(executor, success):
global _active
state = _active
if state is None:
return
result = {"success": False, "output": str(state["output"]), "settings": state["settings"]}
try:
if not success or state["errors"]:
raise RuntimeError("Render nie powiodl sie: " + "; ".join(state["errors"]))
frames = state["output"] / "frames"
count = len(list(frames.glob("frame_*.png")))
if count != state["frames"]:
raise RuntimeError(f"Zapisano {count} klatek zamiast {state['frames']}.")
if state["mp4"]:
report("encoding")
result["video"] = str(encode(frames, state["fps"], ffmpeg=state["ffmpeg"],
expected_frames=state["frames"]))
result["success"] = True
unreal.log("[Workshop] Gotowe: " + str(state["output"]))
except Exception as exc:
result["error"] = str(exc)
unreal.log_error("[Workshop] " + str(exc))
finally:
(state["output"] / "result.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
_write_status(result)
_active = None
if os.environ.get("WORKSHOP_BATCH") == "1":
report("closing")
unreal.EditorPythonScripting.set_keep_python_script_alive(False)
def _begin_render(delta_seconds):
"""Start after the script and pending Slate window updates have returned."""
if _active is None:
return
_active["ticks"] -= 1
if _active["ticks"] > 0:
return
unreal.unregister_slate_post_tick_callback(_active.pop("tick_handle"))
try:
subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem)
report("rendering", output=str(_active["output"]), frames=_active["frames"])
# This installs the Sequencer playback-context guard needed during PIE.
subsystem.render_queue_instance_with_executor_instance(_active["queue"], _active["executor"])
except Exception as exc:
_active["errors"].append(str(exc))
_on_finished(_active["executor"], False)
def start(sequence, preset="preview", mp4=True, ffmpeg=None, single_frame=None):
"""Shortcut preserved for existing workshop scripts."""
return Renderer.from_preset(preset, mp4=mp4, ffmpeg=ffmpeg).render(sequence, single_frame=single_frame)
def _start(sequence, renderer, single_frame=None):
global _active
if _active is not None:
raise RuntimeError("Trwa juz render warsztatowy.")
if single_frame is not None and not 0 <= single_frame < sequence.frames:
raise ValueError("Klatka poza zakresem sekwencji.")
mp4 = renderer.mp4 and single_frame is None
ffmpeg_path = executable("ffmpeg", renderer.ffmpeg) if mp4 else None
subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem)
if subsystem.is_rendering():
raise RuntimeError("Movie Render Queue jest zajete.")
report("preparing_render")
# Own queue: do not clear the user's Movie Render Queue.
queue = unreal.MoviePipelineQueue()
job = queue.allocate_new_job(unreal.MoviePipelineExecutorJob)
sequence.save(open_editor=False)
unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
job.map = unreal.SoftObjectPath(sequence.scene.map_path)
job.sequence = unreal.SoftObjectPath(sequence.asset.get_path_name())
job.job_name = sequence.scene.name
width, height, samples, warmup = renderer.width, renderer.height, renderer.samples, renderer.warmup
stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
output = PROJECT / "renders" / sequence.scene.name / stamp
(output / "frames").mkdir(parents=True)
config = job.get_configuration()
settings = config.find_or_add_setting_by_class(unreal.MoviePipelineOutputSetting)
settings.output_directory = unreal.DirectoryPath(str(output / "frames"))
settings.file_name_format = "frame_{frame_number_rel}"
settings.zero_pad_frame_numbers = 4
settings.output_resolution = unreal.IntPoint(width, height)
settings.use_custom_frame_rate = True
settings.output_frame_rate = unreal.FrameRate(sequence.fps, 1)
settings.use_custom_playback_range = True
settings.custom_start_frame = single_frame if single_frame is not None else 0
settings.custom_end_frame = single_frame + 1 if single_frame is not None else sequence.frames
settings.flush_disk_writes_per_shot = True
config.find_or_add_setting_by_class(unreal.MoviePipelineImageSequenceOutput_PNG)
config.find_or_add_setting_by_class(unreal.MoviePipelineDeferredPassBase)
aa = config.find_or_add_setting_by_class(unreal.MoviePipelineAntiAliasingSetting)
aa.spatial_sample_count = samples
aa.temporal_sample_count = 1
aa.engine_warm_up_count = warmup
aa.render_warm_up_count = warmup
aa.render_warm_up_frames = True
executor = unreal.MoviePipelinePIEExecutor()
executor.on_executor_errored_delegate.add_callable_unique(_on_error)
executor.on_executor_finished_delegate.add_callable_unique(_on_finished)
_active = dict(executor=executor, queue=queue, output=output, errors=[], fps=sequence.fps,
frames=1 if single_frame is not None else sequence.frames,
mp4=mp4, ffmpeg=ffmpeg_path, ticks=2,
settings=dict(width=width, height=height, samples=samples, warmup=warmup, fps=sequence.fps))
try:
_write_status({"state": "rendering", "output": str(output)})
_active["tick_handle"] = unreal.register_slate_post_tick_callback(_begin_render)
except Exception:
_active = None
raise
unreal.log("[Workshop] Render: " + str(output))
return output
+86
View File
@@ -0,0 +1,86 @@
"""An isolated generated map, simple meshes and a ready-to-use character."""
import unreal
from .paths import GENERATED, slug
class Scene:
"""Owns one map. Building again replaces only that generated map's actors."""
def __init__(self, name="MyShot"):
from . import render
if render.is_rendering() or unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem).is_rendering():
raise RuntimeError("Poczekaj na zakonczenie renderu przed przebudowa sceny.")
# Discard Sequencer's live bindings before replacing their actors.
current_sequence = unreal.LevelSequenceEditorBlueprintLibrary.get_current_level_sequence()
if current_sequence and current_sequence.get_path_name().startswith(GENERATED + "/"):
unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
self.name = slug(name)
self.map_path = f"{GENERATED}/{name}/Map"
self.subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
editor = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
current = editor.get_current_level()
current_path = current.get_outer().get_path_name().split(".")[0] if current else ""
if current_path == self.map_path:
# This map is wholly generated by this exercise, never a user's other map.
for actor in self.subsystem.get_all_level_actors():
if "Workshop" in [str(tag) for tag in actor.tags]:
self.subsystem.destroy_actor(actor)
else:
if unreal.EditorLoadingAndSavingUtils.get_dirty_map_packages():
raise RuntimeError("Zapisz zmiany aktualnej mapy przed utworzeniem sceny warsztatowej.")
if unreal.EditorAssetLibrary.does_asset_exist(self.map_path):
if not editor.load_level(self.map_path):
raise RuntimeError("Nie mozna otworzyc " + self.map_path)
for actor in self.subsystem.get_all_level_actors():
if "Workshop" in [str(tag) for tag in actor.tags]:
self.subsystem.destroy_actor(actor)
elif not editor.new_level(self.map_path):
raise RuntimeError("Nie mozna utworzyc " + self.map_path)
def spawn(self, actor_class, name, location=(0, 0, 0), rotation=(0, 0, 0)):
actor = self.subsystem.spawn_actor_from_class(actor_class, unreal.Vector(*location),
unreal.Rotator(*rotation))
if actor is None:
raise RuntimeError("Nie mozna utworzyc aktora " + name)
actor.set_actor_label(name)
actor.tags = ["Workshop"]
actor.set_folder_path("Workshop")
return actor
def mesh(self, asset, name="Model", location=(0, 0, 0), scale=(1, 1, 1), material=None):
mesh = unreal.load_asset(asset) if isinstance(asset, str) else asset
if not isinstance(mesh, unreal.StaticMesh):
raise TypeError(f"Oczekiwano StaticMesh: {asset}")
actor = self.spawn(unreal.StaticMeshActor, name, location)
actor.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE)
actor.static_mesh_component.set_static_mesh(mesh)
actor.set_actor_scale3d(unreal.Vector(*scale))
if material is not None:
actor.static_mesh_component.set_material(0, material)
return actor
def primitive(self, shape="Cube", **kwargs):
if shape not in {"Cube", "Sphere", "Cylinder", "Cone", "Plane"}:
raise ValueError("Ksztalt: Cube, Sphere, Cylinder, Cone lub Plane.")
return self.mesh(f"/Engine/BasicShapes/{shape}.{shape}", **kwargs)
def character(self, material=None, head_scale=1.0):
root = self.spawn(unreal.StaticMeshActor, "Character")
root.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE)
for shape, name, position, scale in [
("Cube", "Body", (0, 0, 90), (0.55, 0.8, 0.9)),
("Sphere", "Head", (0, 0, 175), (0.7 * head_scale,) * 3),
("Cube", "LeftLeg", (0, -23, 25), (0.35, 0.3, 0.5)),
("Cube", "RightLeg", (0, 23, 25), (0.35, 0.3, 0.5)),
("Sphere", "Nose", (34, 0, 177), (0.24, 0.2, 0.2)),
]:
actor = self.primitive(shape, name=name, location=position, scale=scale, material=material)
actor.attach_to_actor(root, "", unreal.AttachmentRule.KEEP_WORLD,
unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False)
return root
def save(self):
editor = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
if not editor.save_current_level():
raise RuntimeError("Nie mozna zapisac mapy " + self.map_path)
+81
View File
@@ -0,0 +1,81 @@
"""Frame-based Sequencer helpers. Ranges are [start, end), rotations roll/pitch/yaw."""
import unreal
from .paths import GENERATED, slug
class Sequence:
def __init__(self, scene, name="Shot", fps=24, frames=48):
if not isinstance(fps, int) or fps < 1 or not isinstance(frames, int) or frames < 1:
raise ValueError("FPS i liczba klatek musza byc dodatnimi liczbami calkowitymi.")
self.scene, self.fps, self.frames = scene, fps, frames
path = f"{GENERATED}/{scene.name}/LS_{slug(name)}"
self.asset = unreal.load_asset(path)
if self.asset and unreal.LevelSequenceEditorBlueprintLibrary.get_current_level_sequence() == self.asset:
unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
if self.asset is None:
self.asset = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
"LS_" + name, f"{GENERATED}/{scene.name}", unreal.LevelSequence,
unreal.LevelSequenceFactoryNew())
for binding in self.asset.get_bindings():
binding.remove()
for track in self.asset.get_tracks():
self.asset.remove_track(track)
self.asset.set_display_rate(unreal.FrameRate(fps, 1))
self.asset.set_playback_start(0)
self.asset.set_playback_end(frames)
self._bindings = {}
self._transforms = {}
def bind(self, actor):
key = actor.get_path_name()
if key not in self._bindings:
self._bindings[key] = self.asset.add_possessable(actor)
return self._bindings[key]
def camera(self, actor):
binding = self.bind(actor)
tracks = self.asset.find_tracks_by_type(unreal.MovieSceneCameraCutTrack)
track = tracks[0] if tracks else self.asset.add_track(unreal.MovieSceneCameraCutTrack)
for old in track.get_sections():
track.remove_section(old)
section = track.add_section()
section.set_range(0, self.frames)
binding_id = unreal.MovieSceneObjectBindingID()
binding_id.set_editor_property("guid", binding.get_id())
section.set_camera_binding_id(binding_id)
def transform_key(self, actor, frame, location=None, rotation=None, scale=None, interpolation="linear"):
if not isinstance(frame, int) or not 0 <= frame < self.frames:
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.")
modes = {"linear": unreal.MovieSceneKeyInterpolation.LINEAR,
"auto": unreal.MovieSceneKeyInterpolation.AUTO,
"constant": unreal.MovieSceneKeyInterpolation.CONSTANT}
if interpolation not in modes:
raise ValueError("Interpolacja: linear, auto lub constant.")
key = actor.get_path_name()
if key not in self._transforms:
track = self.bind(actor).add_track(unreal.MovieScene3DTransformTrack)
section = track.add_section()
section.set_range(0, self.frames)
channels = section.get_all_channels()
initial = actor.get_actor_transform()
r = initial.rotation.rotator()
defaults = [*initial.translation.to_tuple(), r.roll, r.pitch, r.yaw,
*initial.scale3d.to_tuple()]
for channel, value in zip(channels, defaults):
channel.set_default(float(value))
self._transforms[key] = channels
channels = self._transforms[key]
for offset, values in [(0, location), (3, rotation), (6, scale)]:
if values is not None:
if len(values) != 3:
raise ValueError("Transformacja wymaga trzech wartosci.")
for channel, value in zip(channels[offset:offset + 3], values):
channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=modes[interpolation])
def save(self, open_editor=True):
self.scene.save()
unreal.EditorAssetLibrary.save_loaded_asset(self.asset)
if open_editor:
unreal.LevelSequenceEditorBlueprintLibrary.open_level_sequence(self.asset)
+27
View File
@@ -0,0 +1,27 @@
"""FFmpeg encoding; this module also works outside Unreal."""
from pathlib import Path
import subprocess
from .paths import executable
def encode(frames, fps, output=None, ffmpeg=None, expected_frames=None):
frames = Path(frames).resolve()
if fps <= 0:
raise ValueError("FPS musi byc dodatni.")
images = sorted(frames.glob("frame_*.png"))
count = len(images)
if not count or any(p.name != f"frame_{i:04d}.png" for i, p in enumerate(images)):
raise RuntimeError(f"Brak kompletnej numeracji frame_0000.png, frame_0001.png... w {frames}")
if expected_frames is not None and count != expected_frames:
raise RuntimeError(f"Render zapisal {count} klatek zamiast {expected_frames}.")
output = Path(output) if output else frames.parent / "film.mp4"
output.parent.mkdir(parents=True, exist_ok=True)
command = [executable("ffmpeg", ffmpeg), "-hide_banner", "-loglevel", "error", "-y",
"-framerate", str(fps), "-start_number", "0", "-i", str(frames / "frame_%04d.png"),
"-frames:v", str(count), "-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-movflags", "+faststart", str(output)]
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode:
raise RuntimeError("FFmpeg: " + result.stderr[-4000:])
return output.resolve()
Binary file not shown.
Binary file not shown.