advanced example

This commit is contained in:
Vysyr committed 2026-10-31 20:16:32 +01:00
1 parent 8c0a639fba
commit 6fe57915e8
99 files changed
+1329 -56

No files matched your search

+15
View File
@@ -1,5 +1,20 @@
"""Python rendering workshop for Unreal Engine 5.7."""
import importlib
from typing import TYPE_CHECKING
if TYPE_CHECKING:
# Static exports for Pylance; runtime imports remain lazy so launchers do
# not need Unreal's Python module installed in their regular interpreter.
from .scene import Scene as Scene
from .sequence import Sequence as Sequence
from .render import Renderer as Renderer
from . import camera as camera
from . import lighting as lighting
from . import materials as materials
from . import assets as assets
from . import blender as blender
from . import render as render
from . import video as video
__all__ = ["Scene", "Sequence", "Renderer", "camera", "lighting", "materials", "assets", "blender", "render", "video"]
+14 -1
View File
@@ -6,7 +6,20 @@ import unreal
from .paths import GENERATED, PROJECT, slug
def import_model(filename, name="Character"):
def _existing_model(name):
destination = f"{GENERATED}/Imports/{slug(name)}"
if not unreal.EditorAssetLibrary.does_directory_exist(destination):
return None
meshes = [unreal.load_asset(path) for path in unreal.EditorAssetLibrary.list_assets(destination, recursive=True)]
meshes = [obj for obj in meshes if isinstance(obj, unreal.StaticMesh)]
return meshes[0] if len(meshes) == 1 else None
def import_model(filename, name="Character", reimport=True):
if not reimport:
mesh = _existing_model(name)
if mesh is not None:
return mesh
source = Path(filename).expanduser()
source = (source if source.is_absolute() else PROJECT / source).resolve()
if not source.is_file():
+10 -4
View File
@@ -5,21 +5,27 @@ import subprocess
from .paths import PROJECT, executable, slug
def export(filename, name="Character", collection="Character", blender=None):
def export(filename, name="Character", collection="Character", blender=None, skeletal=False):
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 = PROJECT / "build" / slug(name) / (name + (".fbx" if skeletal else ".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]
if skeletal:
command.append("--skeletal")
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
def import_character(filename, name="Character", collection="Character", blender=None, reimport=True):
from .assets import import_model, _existing_model
if not reimport:
mesh = _existing_model(name)
if mesh is not None:
return mesh
return import_model(export(filename, name, collection, blender), name)
+10 -2
View File
@@ -1,3 +1,5 @@
import math
import unreal
@@ -6,11 +8,17 @@ def look_at(actor, target):
actor.get_actor_location(), unreal.Vector(*target)), False)
def create(scene, location=(450, -550, 300), target=(0, 0, 100), focal_length=45):
def create(scene, location=(450, -550, 300), target=(0, 0, 100), fov=43.6):
"""Create a camera with a horizontal field of view in degrees (0 < fov < 180)."""
fov = float(fov)
if not 0 < fov < 180:
raise ValueError("fov must be between 0 and 180 degrees (exclusive)")
sensor_width = 36.0
focal_length = sensor_width / (2 * math.tan(math.radians(fov) / 2))
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_width", sensor_width)
filmback.set_editor_property("sensor_height", 20.25)
component.set_editor_property("filmback", filmback)
component.set_editor_property("current_focal_length", float(focal_length))
+23
View File
@@ -0,0 +1,23 @@
"""Bundled characters and every animation FBX in their source folders."""
import re
from .paths import PROJECT, slug
BASE = PROJECT / "source_assets/character"
FIGHTER_MODEL = BASE / "fighter/Maria WProp J J Ong.fbx"
CREATURE_MODEL = BASE / "creature/Maw J Laygo.fbx"
def _clips(model):
clips = {}
for path in sorted(model.parent.glob("*.fbx")):
if path == model:
continue
key = re.sub(r"[^a-z0-9]+", "_", path.stem.lower()).strip("_")
slug(key)
if key in clips:
raise ValueError(f"Powtorzona nazwa animacji: {key}")
clips[key] = path
return clips
+43 -3
View File
@@ -19,6 +19,7 @@ class Scene:
if current_sequence and current_sequence.get_path_name().startswith(GENERATED + "/"):
unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
self.name = slug(name)
self._skeletal_models = {}
self.map_path = f"{GENERATED}/{name}/Map"
self.subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
editor = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
@@ -68,7 +69,7 @@ class Scene:
raise ValueError("Ksztalt: Cube, Sphere, Cylinder, Cone lub Plane.")
return self.mesh(f"/Engine/BasicShapes/{shape}.{shape}", **kwargs)
def model(self, filename, name="Character", height=200, rotation=(0, 0, 0)):
def model(self, filename, name="Character", height=200, rotation=(0, 0, 0), reimport=False):
"""Import a model, fit its height (cm), and give it a floor-centered pivot.
Returns a parent actor for transform animation; imported materials stay
@@ -81,10 +82,10 @@ class Scene:
source = source if source.is_absolute() else PROJECT / source
if source.suffix.lower() == ".blend":
from .blender import import_character
mesh = import_character(source, name=name)
mesh = import_character(source, name=name, reimport=reimport)
else:
from .assets import import_model
mesh = import_model(source, name=name)
mesh = import_model(source, name=name, reimport=reimport)
actor = self.mesh(mesh, name=name + "Mesh")
roll, pitch, yaw = rotation
actor.set_actor_rotation(unreal.Rotator(roll=roll, pitch=pitch, yaw=yaw), False)
@@ -103,6 +104,45 @@ class Scene:
unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False)
return root
def skeletal_model(self, filename, name="Character", height=200, rotation=(0, 0, 0),
location=(0, 0, 0), animations=None, collection="Character", reimport=False):
"""Import an animated FBX/.blend; return a parent actor for transform keys.
By default all animation FBXs beside the model are available by filename:
shot.animation(actor, "great_sword_walk"). Optional animations={...}
selects specific files; animations={} keeps only embedded animations.
"""
from .skeletal import import_model
if height is not None and (not isinstance(height, (int, float)) or
not math.isfinite(height) or height <= 0):
raise ValueError("Wysokosc modelu musi byc dodatnia; None zachowuje rozmiar pliku.")
imported = import_model(filename, name, animations, collection, reimport=reimport)
child = self.spawn(unreal.SkeletalMeshActor, name + "Mesh")
component = child.skeletal_mesh_component
component.set_mobility(unreal.ComponentMobility.MOVABLE)
component.set_skeletal_mesh_asset(imported.mesh)
component.set_update_animation_in_editor(True)
component.set_editor_property("visibility_based_anim_tick_option",
unreal.VisibilityBasedAnimTickOption.ALWAYS_TICK_POSE_AND_REFRESH_BONES)
roll, pitch, yaw = rotation
child.set_actor_rotation(unreal.Rotator(roll=roll, pitch=pitch, yaw=yaw), False)
origin, extent = child.get_actor_bounds(False)
if extent.z <= 0:
self.subsystem.destroy_actor(child)
raise ValueError("Model ma zerowa wysokosc. Sprawdz geometrie i rotacje.")
if height is not None:
factor = float(height) / (2 * extent.z)
child.set_actor_scale3d(unreal.Vector(factor, factor, factor))
origin, extent = child.get_actor_bounds(False)
child.set_actor_location(unreal.Vector(-origin.x, -origin.y, extent.z - origin.z), False, False)
root = self.spawn(unreal.StaticMeshActor, name)
root.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE)
child.attach_to_actor(root, "", unreal.AttachmentRule.KEEP_WORLD,
unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False)
root.set_actor_location(unreal.Vector(*location), False, False)
self._skeletal_models[root.get_path_name()] = (child, imported)
return root
def character(self, material=None, head_scale=1.0):
root = self.spawn(unreal.StaticMeshActor, "Character")
root.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE)
+79
View File
@@ -1,4 +1,6 @@
"""Frame-based Sequencer helpers. Ranges are [start, end), rotations roll/pitch/yaw."""
import math
import unreal
from .paths import GENERATED, slug
@@ -26,6 +28,7 @@ class Sequence:
self.asset.set_playback_end(frames)
self._bindings = {}
self._transforms = {}
self._animations = {}
def bind(self, actor):
key = actor.get_path_name()
@@ -45,6 +48,82 @@ class Sequence:
binding_id.set_editor_property("guid", binding.get_id())
section.set_camera_binding_id(binding_id)
def _animation_blend(self, section, start, end, fade_in, fade_out):
# The skeletal animation section exposes its Weight channel to Python.
# add_key uses display frames and converts them to the sequence tick rate.
weight = section.get_all_channels()[0]
for key in weight.get_keys():
weight.remove_key(key)
weight.set_default(1.0)
if fade_in or fade_out:
keys = {start: 0.0 if fade_in else 1.0}
if fade_in:
keys[start + fade_in] = 1.0
if fade_out:
keys[end - fade_out] = 1.0
keys[end] = 0.0
for frame, value in sorted(keys.items()):
weight.add_key(unreal.FrameNumber(frame), value,
interpolation=unreal.MovieSceneKeyInterpolation.LINEAR)
def animation(self, actor, clip=None, start=0, end=None, speed=1.0):
"""Play/loop an imported clip over [start, end) in sequence frames.
clip is an alias from scene.skeletal_model(animations=...), an AnimSequence,
or None for the only embedded animation. Transform keys still move the parent.
Overlapping clips automatically crossfade over their entire shared range.
Add clips in timeline order; at most two clips can overlap at a time.
"""
end = self.frames if end is None else end
if type(start) is not int or type(end) is not int or not 0 <= start < end <= self.frames:
raise ValueError(f"Zakres animacji: 0 <= start < end <= {self.frames}.")
if not isinstance(speed, (int, float)) or not math.isfinite(speed) or speed <= 0:
raise ValueError("Predkosc animacji musi byc dodatnia.")
entry = self.scene._skeletal_models.get(actor.get_path_name())
if entry is None:
raise ValueError("Animacja wymaga aktora utworzonego przez scene.skeletal_model().")
child, imported = entry
if clip is None:
if len(imported.animations) != 1:
raise ValueError("Wybierz animacje: " + ", ".join(imported.animations))
clip = next(iter(imported.animations.values()))
elif isinstance(clip, str):
if clip not in imported.animations:
raise ValueError("Nieznana animacja. Dostepne: " + ", ".join(imported.animations))
clip = imported.animations[clip]
if not isinstance(clip, unreal.AnimSequence):
raise TypeError("clip musi byc nazwa zaimportowanej animacji lub AnimSequence.")
if clip.get_editor_property("skeleton") != imported.mesh.get_editor_property("skeleton"):
raise ValueError("Animacja i model musza uzywac tego samego szkieletu.")
key = child.get_path_name()
sections = self._animations.setdefault(key, [])
overlaps = [old for old in sections if start < old[1] and old[0] < end]
previous = None
blend_frames = 0
if overlaps:
if (len(overlaps) != 1 or not overlaps[0][0] < start or
overlaps[0][1] >= end or
overlaps[0][0] + overlaps[0][4] > start):
raise ValueError("Dodawaj klipy w kolejnosci odtwarzania. Blend wymaga czesciowego "
"nakladania dwoch klipow, bez trzech jednoczesnych animacji.")
previous = overlaps[0]
blend_frames = previous[1] - start
track = sections[0][2] if sections else self.bind(child).add_track(unreal.MovieSceneSkeletalAnimationTrack)
section = track.add_section()
section.set_range(start, end)
params = section.get_editor_property("params")
params.set_editor_property("animation", clip)
rate = unreal.MovieSceneTimeWarpVariant()
rate.set_fixed_play_rate(float(speed))
params.set_editor_property("play_rate", rate)
params.set_editor_property("force_custom_mode", True)
section.set_editor_property("params", params)
self._animation_blend(section, start, end, blend_frames, 0)
if previous:
self._animation_blend(previous[3], previous[0], previous[1], previous[4], blend_frames)
sections.append((start, end, track, section, blend_frames))
return section
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}.")
+134
View File
@@ -0,0 +1,134 @@
"""FBX skeletal meshes and animation clips, including exports from Blender."""
from dataclasses import dataclass
from pathlib import Path
import unreal
from .paths import GENERATED, PROJECT, slug
from .characters import _clips
@dataclass
class SkeletalImport:
mesh: unreal.SkeletalMesh
animations: dict[str, unreal.AnimSequence]
def _source(filename):
source = Path(filename).expanduser()
source = (source if source.is_absolute() else PROJECT / source).resolve()
if not source.is_file() or source.suffix.lower() != ".fbx":
raise ValueError(f"Wymagany istniejacy FBX ze szkieletem: {source}")
return source
def _cached(destination, name, skeleton=None):
asset = unreal.load_asset(destination + "/" + name)
expected = unreal.SkeletalMesh if skeleton is None else unreal.AnimSequence
if not isinstance(asset, expected):
return None
objects = [asset]
if skeleton is None:
for path in unreal.EditorAssetLibrary.list_assets(destination, recursive=False, include_folder=False):
clip = unreal.load_asset(path)
if isinstance(clip, unreal.AnimSequence):
objects.append(clip)
elif asset.get_editor_property("skeleton") != skeleton:
return None
return objects
def _import(source, destination, name, skeleton=None, reimport=False):
if not reimport:
objects = _cached(destination, name, skeleton)
if objects is not None:
return objects
options = unreal.FbxImportUI()
options.set_editor_property("automated_import_should_detect_type", False)
options.set_editor_property("import_as_skeletal", True)
options.set_editor_property("import_mesh", skeleton is None)
options.set_editor_property("import_animations", True)
options.set_editor_property("create_physics_asset", False)
options.set_editor_property("import_materials", skeleton is None)
options.set_editor_property("import_textures", skeleton is None)
options.set_editor_property("override_animation_name", name + "_Anim" if skeleton is None else name)
options.set_editor_property("mesh_type_to_import", unreal.FBXImportType.FBXIT_SKELETAL_MESH
if skeleton is None else unreal.FBXImportType.FBXIT_ANIMATION)
if skeleton is not None:
options.set_editor_property("skeleton", skeleton)
options.anim_sequence_import_data.set_editor_property(
"animation_length", unreal.FBXAnimationLengthImportType.FBXALIT_EXPORTED_TIME)
options.anim_sequence_import_data.set_editor_property("snap_to_closest_frame_boundary", True)
task = unreal.AssetImportTask()
for key, value in {"filename": str(source), "destination_path": destination,
"destination_name": name, "automated": True, "replace_existing": True,
"replace_existing_settings": True, "save": True, "async_": False,
"options": options, "factory": unreal.FbxFactory()}.items():
task.set_editor_property(key, value)
# UE 5.7 otherwise routes FBX reimports through Interchange, which does not
# consistently honor FbxImportUI (notably the target animation skeleton).
flag = "Interchange.FeatureFlags.Import.FBX"
previous = unreal.SystemLibrary.get_console_variable_int_value(flag)
unreal.SystemLibrary.execute_console_command(None, flag + " 0")
try:
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
objects = list(task.get_objects())
# Saving the mesh alone can leave its new skeleton/materials unsaved.
unreal.EditorAssetLibrary.save_directory(destination, only_if_is_dirty=True, recursive=True)
# Legacy FBX reports the primary mesh only; embedded animation assets
# are registered alongside it rather than included in task.get_objects().
paths = {obj.get_path_name() for obj in objects}
embedded = (unreal.EditorAssetLibrary.list_assets(destination, recursive=False, include_folder=False)
if skeleton is None else [])
for path in embedded:
obj = unreal.load_asset(path)
if isinstance(obj, unreal.AnimSequence) and obj.get_path_name() not in paths:
objects.append(obj)
return objects
finally:
unreal.SystemLibrary.execute_console_command(None, f"{flag} {previous}")
def import_animation(filename, skeleton, name="Clip", destination=None, reimport=False):
"""Import a clip for this exact skeleton; no automatic retargeting."""
source = _source(filename)
name = slug(name)
objects = _import(source, destination or f"{GENERATED}/Skeletal/{name}/Animations", name, skeleton,
reimport=reimport)
clips = [obj for obj in objects if isinstance(obj, unreal.AnimSequence)]
if len(clips) != 1:
raise RuntimeError(f"{source.name}: oczekiwano jednej animacji, otrzymano {len(clips)}.")
if clips[0].get_editor_property("skeleton") != skeleton:
raise ValueError("Animacja ma inny szkielet. Uzyj animacji dla tej samej postaci z Mixamo.")
return clips[0]
def import_model(filename, name="Character", animations=None, collection="Character", reimport=False):
"""Import an FBX/.blend and discover animation FBXs beside the source model.
animations=None loads all neighboring clips using names derived from filenames.
Pass a dictionary to select files explicitly; {} skips neighboring clips.
"""
source = Path(filename).expanduser()
source = (source if source.is_absolute() else PROJECT / source).resolve()
if animations is None:
animations = _clips(source)
name = slug(name)
destination = f"{GENERATED}/Skeletal/{name}"
objects = None if reimport else _cached(destination, name)
if objects is None:
if source.suffix.lower() == ".blend":
from .blender import export
source = export(source, name=name, collection=collection, skeletal=True)
source = _source(source)
objects = _import(source, destination, name, reimport=True)
meshes = [obj for obj in objects if isinstance(obj, unreal.SkeletalMesh)]
if len(meshes) != 1:
raise RuntimeError(f"{source.name}: oczekiwano jednego SkeletalMesh, otrzymano {len(meshes)}. "
"Eksportuj jedna postac ze szkieletem i wagami (Mixamo: With Skin).")
clips = {obj.get_name(): obj for obj in objects if isinstance(obj, unreal.AnimSequence)}
skeleton = meshes[0].get_editor_property("skeleton")
for alias, filename in (animations or {}).items():
clips[alias] = import_animation(filename, skeleton, name=slug(alias),
destination=destination + "/Animations", reimport=reimport)
return SkeletalImport(meshes[0], clips)