workshop rework
This commit is contained in:
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.
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.
@@ -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}")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)]
|
||||
@@ -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)
|
||||
@@ -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.")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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.
Binary file not shown.
BIN
Binary file not shown.
Reference in new issue
Block a user