workshop rework
This commit is contained in:
1 parent
a3a80dfa16
commit
fb39e290c0
115 files changed
+1865
-497
No files matched your search
@@ -0,0 +1,288 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user