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

+6
View File
@@ -0,0 +1,6 @@
{
"python.analysis.extraPaths": ["${workspaceFolder}/Content/Python"],
"editor.hover.enabled": true,
"editor.inlayHints.enabled": "on",
"python.analysis.inlayHints.callArgumentNames": "all"
}
+15
View File
@@ -1,5 +1,20 @@
"""Python rendering workshop for Unreal Engine 5.7.""" """Python rendering workshop for Unreal Engine 5.7."""
import importlib 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"] __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 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 = Path(filename).expanduser()
source = (source if source.is_absolute() else PROJECT / source).resolve() source = (source if source.is_absolute() else PROJECT / source).resolve()
if not source.is_file(): if not source.is_file():
+10 -4
View File
@@ -5,21 +5,27 @@ import subprocess
from .paths import PROJECT, executable, slug 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() source = Path(filename).resolve()
if not source.is_file() or source.suffix.lower() != ".blend": if not source.is_file() or source.suffix.lower() != ".blend":
raise FileNotFoundError(f"Brak pliku .blend: {source}") 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) destination.parent.mkdir(parents=True, exist_ok=True)
command = [executable("blender", blender), "--background", str(source), command = [executable("blender", blender), "--background", str(source),
"--python-exit-code", "1", "--python", str(PROJECT / "tools" / "blender_export.py"), "--python-exit-code", "1", "--python", str(PROJECT / "tools" / "blender_export.py"),
"--", "--output", str(destination), "--collection", collection] "--", "--output", str(destination), "--collection", collection]
if skeletal:
command.append("--skeletal")
result = subprocess.run(command, capture_output=True, text=True, timeout=180) result = subprocess.run(command, capture_output=True, text=True, timeout=180)
if result.returncode or not destination.is_file(): if result.returncode or not destination.is_file():
raise RuntimeError("Eksport Blendera nie powiodl sie:\n" + (result.stdout + result.stderr)[-6000:]) raise RuntimeError("Eksport Blendera nie powiodl sie:\n" + (result.stdout + result.stderr)[-6000:])
return destination return destination
def import_character(filename, name="Character", collection="Character", blender=None): def import_character(filename, name="Character", collection="Character", blender=None, reimport=True):
from .assets import import_model 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) return import_model(export(filename, name, collection, blender), name)
+10 -2
View File
@@ -1,3 +1,5 @@
import math
import unreal import unreal
@@ -6,11 +8,17 @@ def look_at(actor, target):
actor.get_actor_location(), unreal.Vector(*target)), False) 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) actor = scene.spawn(unreal.CineCameraActor, "Camera", location)
component = actor.get_cine_camera_component() component = actor.get_cine_camera_component()
filmback = component.get_editor_property("filmback") 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) filmback.set_editor_property("sensor_height", 20.25)
component.set_editor_property("filmback", filmback) component.set_editor_property("filmback", filmback)
component.set_editor_property("current_focal_length", float(focal_length)) 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 + "/"): if current_sequence and current_sequence.get_path_name().startswith(GENERATED + "/"):
unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence() unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
self.name = slug(name) self.name = slug(name)
self._skeletal_models = {}
self.map_path = f"{GENERATED}/{name}/Map" self.map_path = f"{GENERATED}/{name}/Map"
self.subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) self.subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
editor = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem) editor = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
@@ -68,7 +69,7 @@ class Scene:
raise ValueError("Ksztalt: Cube, Sphere, Cylinder, Cone lub Plane.") raise ValueError("Ksztalt: Cube, Sphere, Cylinder, Cone lub Plane.")
return self.mesh(f"/Engine/BasicShapes/{shape}.{shape}", **kwargs) 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. """Import a model, fit its height (cm), and give it a floor-centered pivot.
Returns a parent actor for transform animation; imported materials stay 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 source = source if source.is_absolute() else PROJECT / source
if source.suffix.lower() == ".blend": if source.suffix.lower() == ".blend":
from .blender import import_character from .blender import import_character
mesh = import_character(source, name=name) mesh = import_character(source, name=name, reimport=reimport)
else: else:
from .assets import import_model 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") actor = self.mesh(mesh, name=name + "Mesh")
roll, pitch, yaw = rotation roll, pitch, yaw = rotation
actor.set_actor_rotation(unreal.Rotator(roll=roll, pitch=pitch, yaw=yaw), False) 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) unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False)
return root 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): def character(self, material=None, head_scale=1.0):
root = self.spawn(unreal.StaticMeshActor, "Character") root = self.spawn(unreal.StaticMeshActor, "Character")
root.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE) 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.""" """Frame-based Sequencer helpers. Ranges are [start, end), rotations roll/pitch/yaw."""
import math
import unreal import unreal
from .paths import GENERATED, slug from .paths import GENERATED, slug
@@ -26,6 +28,7 @@ class Sequence:
self.asset.set_playback_end(frames) self.asset.set_playback_end(frames)
self._bindings = {} self._bindings = {}
self._transforms = {} self._transforms = {}
self._animations = {}
def bind(self, actor): def bind(self, actor):
key = actor.get_path_name() key = actor.get_path_name()
@@ -45,6 +48,82 @@ class Sequence:
binding_id.set_editor_property("guid", binding.get_id()) binding_id.set_editor_property("guid", binding.get_id())
section.set_camera_binding_id(binding_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"): 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: if not isinstance(frame, int) or not 0 <= frame < self.frames:
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.") 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)
+122 -19
View File
@@ -2,7 +2,8 @@
UE **5.7**, Windows / Linux. Biblioteka `ue_workshop` automatyzuje scenę, UE **5.7**, Windows / Linux. Biblioteka `ue_workshop` automatyzuje scenę,
Sequencer, Movie Render Queue i składanie MP4. Nie wymaga kompilacji C++ ani pip. Sequencer, Movie Render Queue i składanie MP4. Nie wymaga kompilacji C++ ani pip.
Pierwsze ćwiczenie korzysta wyłącznie z brył silnika. Blender jest opcjonalny. Plik do pokazu podstaw korzysta z własnego modelu z Blendera i obrotu kamery.
Zadanie z animacjami korzysta z dołączonych postaci Mixamo i nie wymaga Blendera.
Jeśli używasz wcześniejszej kopii projektu z C++ i edytor zgłasza Jeśli używasz wcześniejszej kopii projektu z C++ i edytor zgłasza
`Target Upgrade Required`, zamknij UE i usuń pozostałości starej kompilacji: `Target Upgrade Required`, zamknij UE i usuń pozostałości starej kompilacji:
@@ -23,19 +24,40 @@ automatyzacji edytora; nie służy tu do gameplayu.
## Pierwszy skrypt w edytorze ## Pierwszy skrypt w edytorze
Pliki do prowadzenia zajęć:
- `exercises/basics.py` — prosty pokaz: `character_custom.blend`, podłoga, światła
i kamera robiąca pełne 360° w 8 sekund. Postać pozostaje nieruchoma.
- `exercises/animation_starter.py` — punkt wyjścia dla studentów: jedna creature,
dwie animacje (`mutant_idle`, `mutant_swiping`) i 15 sekund na eksperymenty. Własną scenę walki
tworzą przez dodanie klipów, ruchu, drugiej postaci i zmian kamery.
- `exercises/my_shot.py` — dotychczasowy rozbudowany plik z wyborem modelu.
- `examples/mixamo_characters.py` — fighter i creature z pełnymi słownikami klipów;
zachowany układ sceny i dotychczasowe klipy, gotowe do własnej sceny walki.
W nowych plikach render jest zakomentowany. Najpierw obejrzyj scenę w Sequencerze,
potem odkomentuj `renderer.render(shot)`. Każdy plik tworzy własną nazwaną mapę.
Przed edycją zrób kopię pliku; ponowne uruchomienie przebudowuje jego scenę.
`REIMPORT = False` używa istniejących modeli i klipów w UE. Brakujące assety
importowane są przy pierwszym uruchomieniu. `REIMPORT = True` wymusza ponowny
import wszystkich wskazanych modeli i klipów, również eksport `.blend` z Blendera.
Po odświeżeniu ustaw ponownie `False`. Zmiany plików nie są wykrywane automatycznie.
Zapisz aktualną mapę. Wybierz **File → Execute Python Script** i wskaż Zapisz aktualną mapę. Wybierz **File → Execute Python Script** i wskaż
`exercises/my_shot.py`. Możesz też wpisać w konsoli UE: `exercises/basics.py`. Możesz też wpisać w konsoli UE:
```text ```text
py "C:/sciezka/do/projektu/exercises/my_shot.py" py "C:/sciezka/do/projektu/exercises/basics.py"
``` ```
Powstanie prosta postać, podłoga, trzy światła, kamera i dwusekundowa animacja. Powstanie własna postać z Blendera, podłoga, trzy światła i ośmiosekundowy objazd kamerą.
Obejrzyj ją w Sequencerze. Zmień kolor, `head_scale`, kamerę albo klucze i uruchom Obejrzyj go w Sequencerze. Zmień model, kolor podłogi, kamerę albo klucze i uruchom
plik ponownie. Skrypt jest odczytywany ponownie z dysku. plik ponownie. Skrypt jest odczytywany ponownie z dysku.
Ustaw `RENDER = True`, aby wyrenderować film. Domyślny preset `preview` to W `my_shot.py` ustaw `RENDER = True`, aby wyrenderować film. Preset `preview` to 640×360,
640×360, 24 FPS, 48 klatek. `final` to 1280×720 i więcej próbek. a `final` to 1280×720 i więcej próbek. FPS i długość filmu pochodzą z `Sequence`.
Ustawienia własnego renderera są na końcu ćwiczenia.
Szybkie sprawdzenie obrazu: zamiast pełnego renderu użyj Szybkie sprawdzenie obrazu: zamiast pełnego renderu użyj
`renderer.render(shot, single_frame=24)`. `renderer.render(shot, single_frame=24)`.
@@ -135,15 +157,17 @@ W `exercises/my_shot.py` wybierz `MODEL`:
| Wartość | Postać | | Wartość | Postać |
|---|---| |---|---|
| `"primitive"` | Dotychczasowa postać z brył, bez importu. | | `"primitive"` | Dotychczasowa postać z brył, bez importu. |
| `"robot"` | RobotPolice z dołączonego `DemoAssets`. | | `"fighter"` | Wojowniczka z `source_assets/character/fighter`. Domyślna postać. |
| `"pilot"` | Humanoid z `DemoAssets/pilot`. | | `"creature"` | Stwór z `source_assets/character/creature`. |
| `"file"` | Własny plik wskazany w `MODEL_FILE`. | | `"file"` | Własny plik wskazany w `MODEL_FILE`. |
Przykład własnego modelu: Przykład własnego modelu:
```python ```python
MODEL = "file" MODEL = "file"
MODEL_FILE = PROJECT / "source_assets/models/my_character.glb" MODEL_FILE = PROJECT / "source_assets/character/my_character.fbx"
SKELETAL = True
ANIMATION_FILE = None # animacja zawarta w FBX; albo ścieżka do osobnego klipu FBX
MODEL_HEIGHT = 200 MODEL_HEIGHT = 200
MODEL_ROTATION = (0, 0, 90) MODEL_ROTATION = (0, 0, 90)
``` ```
@@ -161,16 +185,89 @@ dopasowaniem wysokości. Pivot animacji znajduje się na środku podstawy.
Dla pojedynczego atlasu FBX/OBJ można dołączyć mapy `NazwaModelu_BC.png`, Dla pojedynczego atlasu FBX/OBJ można dołączyć mapy `NazwaModelu_BC.png`,
`_N`, `_R`, `_M`, `_E` (również JPG/JPEG). Biblioteka odtworzy z nich jeden `_N`, `_R`, `_M`, `_E` (również JPG/JPEG). Biblioteka odtworzy z nich jeden
materiał dla wszystkich slotów. Robot i pilot korzystają z tej konwencji. materiał dla wszystkich slotów w statycznym przepływie `scene.model(...)`.
Bez pliku `_BC` pozostają materiały zaimportowane przez UE. W GLB zachowywany Bez pliku `_BC` pozostają materiały zaimportowane przez UE. W GLB zachowywany
jest osobny materiał dla każdego slotu. jest osobny materiał dla każdego slotu.
Po zmianie postaci reszta skryptu działa tak samo: klucze `location`, `rotation` Dla `fighter` i `creature` użyj nazwy pliku bez rozszerzenia, ze spacjami zamienionymi
i `scale` animują cały model. W przykładzie jest też opcjonalny podskok. na podkreślenia: np. `great_sword_walk` lub `mutant_swiping`. Dostępny jest
Modele z rigami są w tej wersji importowane jako statyczna geometria: [pełny katalog animacji](docs/animations.md): 51 klipów fighter i 12 klipów creature.
animacje kości, chodzenie z Mixamo i retargeting nie są jeszcze obsługiwane. `scene.skeletal_model(...)` samo znajduje wszystkie pliki FBX w folderze postaci
poza modelem. Nie wymaga `animations` ani ręcznych aliasów. Import tworzy SkeletalMesh, szkielet,
materiały i wszystkie klipy. Sequencer odtwarza
animację kości przez 120 klatek (5 s); krótszy klip zapętla się w swojej sekcji.
Klucze `location`, `rotation` i `scale` mogą dodatkowo poruszać całym modelem.
Przykład dwóch postaci z różnymi klipami: `examples/mixamo_characters.py`.
```python
character = scene.skeletal_model(
PROJECT / "source_assets/character/creature/Maw J Laygo.fbx",
name="Creature", height=200, location=(150, 0, 0),
)
shot.animation(character, "mutant_walking")
```
`shot.animation(character, "mutant_walking", start=0, end=48, speed=0.8)` odtwarza klip
w zakresie `[0, 48)` z prędkością 80%. Następny klip może zacząć się w klatce 48.
Nakładające się klipy tej samej postaci automatycznie tworzą płynne przejście
przez cały wspólny zakres. Helper ustawia narastanie wagi nowego klipu
i zanikanie poprzedniego; nie trzeba podawać dodatkowego parametru:
```python
shot.animation(character, "mutant_idle", start=0, end=60)
shot.animation(character, "mutant_walking", start=48, end=120)
```
Przy 24 FPS przejście na klatkach 48–60 trwa 0,5 sekundy. Dodawaj klipy w kolejności
odtwarzania; naraz mogą mieszać się dwie animacje tej samej postaci.
Klipy stykające się końcami przechodzą bez blendu. Nie trzeba ponownie importować animacji.
Przy kolejnych przejściach środkowy klip zachowa zarówno wejście, jak i wyjście.
Pełne zawieranie jednego klipu w drugim oraz identyczne zakresy są odrzucane.
`shot.animation(character)`
wybiera jedyny klip zawarty w modelu. Każda postać zachowuje swój szkielet;
automatyczny retargeting między różnymi postaciami nie jest wykonywany.
W Mixamo pobierz FBX **With Skin**, aby mieć model, szkielet i animację w jednym
pliku. Kolejne klipy pobieraj dla tej samej wybranej postaci; mogą być **Without Skin**.
Umieść je obok pliku modelu — są wykrywane automatycznie, pod nazwami plików.
Opcjonalne `animations={"mutant_idle": path1, "mutant_walking": path2}` ogranicza
wybór do konkretnych klipów. Puste `animations={}` pomija animacje z folderu
i pozostawia tylko klipy zawarte w pliku modelu.
Jeśli samodzielnie animujesz trasę przez `transform_key`, wybierz **In Place**,
gdy jest dostępne, aby animacja kości nie dodawała własnego przesuwania.
Importy szkieletowe trafiają do `Content/Workshop/Generated/Skeletal`.
Przy `REIMPORT=True` aktualizowane są te same assety. Pivot jest na środku podstawy
pozy referencyjnej; podczas animacji obrys i pozycja stóp mogą się zmieniać.
Dla statycznego GLB, FBX, OBJ lub `.blend` ustaw `MODEL="file"`, `SKELETAL=False`.
Dotychczasowe `scene.model(...)` zachowuje statyczny przepływ importu.
Wybrany model działa zarówno w edytorze, jak i przez `my_shot_headless.py`. Wybrany model działa zarówno w edytorze, jak i przez `my_shot_headless.py`.
### Mixamo → Blender → Unreal
Zaimportuj do Blendera FBX ze szkieletem i animacją. Zmieniaj geometrię, zachowując
Armature, wagi wierzchołków i modyfikator Armature. Siatki umieść w kolekcji
`Character`; muszą być przypisane do jednego Armature. Wybierz aktywną akcję
i ustaw zakres klatek sceny obejmujący animację. Zapisz `.blend`, a w ćwiczeniu ustaw:
```python
MODEL = "file"
MODEL_FILE = PROJECT / "source_assets/character/my_animated_character.blend"
SKELETAL = True
ANIMATION_FILE = None
```
Python uruchomi Blendera w tle, wyeksportuje siatki, szkielet i aktywną animację
do FBX, a następnie zaimportuje je w UE. Eksport obejmuje aktywną akcję w zakresie
sceny; nie eksportuje osobnych akcji i pasków NLA jako wielu klipów. Materiały FBX
przenoszą podstawowe kolory i obsługiwane tekstury. Proceduralne materiały wypal
do tekstur; statyczne modele z pełniejszym PBR nadal eksportuj do GLB.
Blender potrzebuje binarnego FBX 7.1 lub nowszego. Dołączone fighter i creature
są w starym FBX 6.1: UE importuje je bezpośrednio, ale Blender 5.1 ich nie otworzy.
Do edycji w Blenderze pobierz współczesny wariant FBX albo wyeksportuj SkeletalMesh
i animację z UE do nowego FBX.
## Blender → UE → film ## Blender → UE → film
Własną kopię wybierzesz też bezpośrednio w `my_shot.py`: Własną kopię wybierzesz też bezpośrednio w `my_shot.py`:
@@ -178,9 +275,10 @@ Własną kopię wybierzesz też bezpośrednio w `my_shot.py`:
```python ```python
MODEL = "file" MODEL = "file"
MODEL_FILE = PROJECT / "source_assets/character/character_custom.blend" MODEL_FILE = PROJECT / "source_assets/character/character_custom.blend"
SKELETAL = False # ten przykład to statyczne siatki bez Armature
``` ```
Zapisz zmiany w Blenderze i ponownie uruchom ćwiczenie. Za każdym razem plik Zapisz zmiany w Blenderze i uruchom ćwiczenie z `REIMPORT=True`. Plik
jest eksportowany w tle i aktualizuje ten sam asset `CustomCharacter` w UE. jest eksportowany w tle i aktualizuje ten sam asset `CustomCharacter` w UE.
Nie trzeba ręcznie usuwać poprzedniego modelu. Eksport obejmuje kolekcję `Character`. Nie trzeba ręcznie usuwać poprzedniego modelu. Eksport obejmuje kolekcję `Character`.
Na Windows biblioteka wykrywa też standardowe instalacje w `Blender Foundation`, Na Windows biblioteka wykrywa też standardowe instalacje w `Blender Foundation`,
@@ -212,13 +310,16 @@ Tekstury PNG/JPEG są wyciągane z GLB i aktualizowane w wydzielonym katalogu
`WorkshopTextures`. Parametry PBR synchronizujemy jawnie, ponieważ standardowy `WorkshopTextures`. Parametry PBR synchronizujemy jawnie, ponieważ standardowy
reimport siatki w UE zachowuje wcześniejsze materiały. Zmiany w tych importowanych reimport siatki w UE zachowuje wcześniejsze materiały. Zmiany w tych importowanych
materiałach wykonuj w Blenderze; skrypt nadpisuje wspierane parametry ich źródłem. materiałach wykonuj w Blenderze; skrypt nadpisuje wspierane parametry ich źródłem.
Dowolne grafy proceduralne, rig, animacje szkieletowe, włosy i shader skóry nie Dowolne grafy proceduralne, włosy i shader skóry nie są przenoszone w tym przepływie.
wchodzą w zakres v1. Materiały proceduralne należy wypalić do tekstur. Materiały proceduralne należy wypalić do tekstur. Modele z Armature obsługuje
`scene.skeletal_model(...)`, opisane wyżej.
## Układ i zasady ## Układ i zasady
- `Content/Python/ue_workshop/` — biblioteka, automatycznie dostępna w Pythonie UE. - `Content/Python/ue_workshop/` — biblioteka, automatycznie dostępna w Pythonie UE.
- `exercises/my_shot.py` — jedyny wymagany plik uczestnika; zrób kopię przed edycją. - `exercises/basics.py` — pokaz podstaw na własnym modelu z Blendera.
- `exercises/animation_starter.py` — plik startowy zadania z dwiema animacjami.
- `exercises/my_shot.py` — rozbudowany wariant z wyborem postaci.
- `examples/` — dodatkowa kamera i Blender; ćwiczenie startowe jest też rozwiązaniem bazowym. - `examples/` — dodatkowa kamera i Blender; ćwiczenie startowe jest też rozwiązaniem bazowym.
- `tools/` — launcher, eksport/generator Blendera i testy integracyjne. - `tools/` — launcher, eksport/generator Blendera i testy integracyjne.
- `docs/` — ściąga API i scenariusz spotkania. - `docs/` — ściąga API i scenariusz spotkania.
@@ -241,6 +342,8 @@ python -m unittest discover -s tests -v
python tools/run_workshop.py tools/smoke_scene.py python tools/run_workshop.py tools/smoke_scene.py
python tools/run_workshop.py tools/smoke_blender.py python tools/run_workshop.py tools/smoke_blender.py
python tools/run_workshop.py tools/smoke_renderer.py python tools/run_workshop.py tools/smoke_renderer.py
python tools/run_workshop.py tools/smoke_skeletal.py --render
python tools/run_workshop.py tools/smoke_workshop_files.py
``` ```
Pierwszy test integracyjny wykonuje ćwiczenie dwa razy, sprawdza duplikaty, Pierwszy test integracyjny wykonuje ćwiczenie dwa razy, sprawdza duplikaty,
+111
View File
@@ -0,0 +1,111 @@
# Animacje postaci
`scene.skeletal_model(...)` automatycznie znajduje każdy FBX z folderu postaci
poza plikiem samego modelu: 51 klipów fighter i 12 klipów creature.
Wykorzystuje funkcję `_clips()` z `Content/Python/ue_workshop/characters.py`.
Nowy FBX dodany do folderu zostanie uwzględniony przy następnym uruchomieniu skryptu.
Trzymaj model i animacje tej samej postaci w osobnym folderze.
Nazwy poniżej są kluczami do `animations` i `shot.animation(...)`.
Spacje i nawiasy z nazw plików są zamieniane na podkreślenia. Nie ma skrótów
`idle`, `walk` i `attack`; używaj nazw takich jak `great_sword_idle` czy `mutant_swiping`.
Pełny przykład `examples/mixamo_characters.py` importuje wszystkie klipy.
Plik zadania `exercises/animation_starter.py` importuje tylko dwie wybrane animacje.
Aby udostępnić wszystkie klipy, pomiń argument `animations`:
```python
character = scene.skeletal_model(CREATURE_MODEL, name="Creature", reimport=False)
shot.animation(character, "mutant_jumping", start=240, end=360)
```
Opcjonalne `animations={...}` ogranicza wybór do wskazanych plików;
`animations={}` pozostawia tylko klipy osadzone w samym modelu.
Krótsze klipy zapętlają się w swoim zakresie. Nakładanie zakresów automatycznie
tworzy płynne przejście z Pythona:
```python
shot.animation(character, "mutant_idle", start=0, end=60)
shot.animation(character, "mutant_swiping", start=48, end=120)
```
Wspólny zakres 48–60 miesza obie animacje przez 12 klatek (0,5 s przy 24 FPS).
Nowy klip narasta od wagi 0 do 1, a poprzedni zanika od 1 do 0.
Dodawaj klipy w kolejności odtwarzania. Długość blendu wynika wyłącznie z zakresów.
Klipy stykające się końcami przechodzą bez blendu; zakresy nie mogą być identyczne
ani całkowicie zawierać się jeden w drugim.
Obsługiwane są przejścia między dwiema animacjami; trzy jednoczesne klipy są odrzucane.
## Fighter
| Clip | FBX |
|---|---|
| `draw_a_great_sword_1` | `draw a great sword 1.fbx` |
| `draw_a_great_sword_2` | `draw a great sword 2.fbx` |
| `great_sword_180_turn` | `great sword 180 turn.fbx` |
| `great_sword_180_turn_2` | `great sword 180 turn (2).fbx` |
| `great_sword_attack` | `great sword attack.fbx` |
| `great_sword_blocking` | `great sword blocking.fbx` |
| `great_sword_blocking_2` | `great sword blocking (2).fbx` |
| `great_sword_blocking_3` | `great sword blocking (3).fbx` |
| `great_sword_casting` | `great sword casting.fbx` |
| `great_sword_crouching` | `great sword crouching.fbx` |
| `great_sword_crouching_2` | `great sword crouching (2).fbx` |
| `great_sword_crouching_3` | `great sword crouching (3).fbx` |
| `great_sword_crouching_4` | `great sword crouching (4).fbx` |
| `great_sword_crouching_5` | `great sword crouching (5).fbx` |
| `great_sword_crouching_6` | `great sword crouching (6).fbx` |
| `great_sword_high_spin_attack` | `great sword high spin attack.fbx` |
| `great_sword_idle` | `great sword idle.fbx` |
| `great_sword_idle_2` | `great sword idle (2).fbx` |
| `great_sword_idle_3` | `great sword idle (3).fbx` |
| `great_sword_idle_4` | `great sword idle (4).fbx` |
| `great_sword_idle_5` | `great sword idle (5).fbx` |
| `great_sword_impact` | `great sword impact.fbx` |
| `great_sword_impact_2` | `great sword impact (2).fbx` |
| `great_sword_impact_3` | `great sword impact (3).fbx` |
| `great_sword_impact_4` | `great sword impact (4).fbx` |
| `great_sword_impact_5` | `great sword impact (5).fbx` |
| `great_sword_jump` | `great sword jump.fbx` |
| `great_sword_jump_2` | `great sword jump (2).fbx` |
| `great_sword_jump_attack` | `great sword jump attack.fbx` |
| `great_sword_kick` | `great sword kick.fbx` |
| `great_sword_kick_2` | `great sword kick (2).fbx` |
| `great_sword_power_up` | `great sword power up.fbx` |
| `great_sword_run` | `great sword run.fbx` |
| `great_sword_run_2` | `great sword run (2).fbx` |
| `great_sword_slash` | `great sword slash.fbx` |
| `great_sword_slash_2` | `great sword slash (2).fbx` |
| `great_sword_slash_3` | `great sword slash (3).fbx` |
| `great_sword_slash_4` | `great sword slash (4).fbx` |
| `great_sword_slash_5` | `great sword slash (5).fbx` |
| `great_sword_slide_attack` | `great sword slide attack.fbx` |
| `great_sword_strafe` | `great sword strafe.fbx` |
| `great_sword_strafe_2` | `great sword strafe (2).fbx` |
| `great_sword_strafe_3` | `great sword strafe (3).fbx` |
| `great_sword_strafe_4` | `great sword strafe (4).fbx` |
| `great_sword_turn` | `great sword turn.fbx` |
| `great_sword_turn_2` | `great sword turn (2).fbx` |
| `great_sword_walk` | `great sword walk.fbx` |
| `great_sword_walk_2` | `great sword walk (2).fbx` |
| `spell_cast` | `spell cast.fbx` |
| `two_handed_sword_death` | `two handed sword death.fbx` |
| `two_handed_sword_death_2` | `two handed sword death (2).fbx` |
## Creature
| Clip | FBX |
|---|---|
| `left_turn_45` | `left turn 45.fbx` |
| `mutant_breathing_idle` | `mutant breathing idle.fbx` |
| `mutant_dying` | `mutant dying.fbx` |
| `mutant_flexing_muscles` | `mutant flexing muscles.fbx` |
| `mutant_idle` | `mutant idle.fbx` |
| `mutant_jumping` | `mutant jumping.fbx` |
| `mutant_left_turn_45` | `mutant left turn 45.fbx` |
| `mutant_right_turn_45` | `mutant right turn 45.fbx` |
| `mutant_right_turn_45_2` | `mutant right turn 45 (2).fbx` |
| `mutant_run` | `mutant run.fbx` |
| `mutant_swiping` | `mutant swiping.fbx` |
| `mutant_walking` | `mutant walking.fbx` |
+35 -4
View File
@@ -2,6 +2,15 @@
Import: `from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials, assets, blender, render` Import: `from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials, assets, blender, render`
`REIMPORT=False` w plikach ćwiczeń używa istniejących assetów UE; brakujące są importowane.
`REIMPORT=True` ponownie importuje modele i klipy, także po edycji `.blend`.
Własny skrypt: `scene.skeletal_model(..., reimport=True)` lub `scene.model(..., reimport=True)`.
Nie ma automatycznego wykrywania zmian źródeł.
`camera.create(..., fov=60)` ustawia poziomy kąt widzenia w stopniach: większy FOV
daje szerszy kadr. Zakres: `0 < fov < 180`. Domyślne `43.6` odpowiada obiektywowi
45 mm na sensorze o szerokości 36 mm.
| Operacja | Przykład | | Operacja | Przykład |
|---|---| |---|---|
| Własna mapa | `scene = Scene("MyShot")` | | Własna mapa | `scene = Scene("MyShot")` |
@@ -15,7 +24,7 @@ Import: `from ue_workshop import Scene, Sequence, Renderer, camera, lighting, ma
| Korekta osi modelu przed dopasowaniem | `actor = scene.model("hero.fbx", rotation=(0, 0, 90))` | | Korekta osi modelu przed dopasowaniem | `actor = scene.model("hero.fbx", rotation=(0, 0, 90))` |
| Światła studyjne | `lights = lighting.studio(scene)` | | Światła studyjne | `lights = lighting.studio(scene)` |
| Pojedyncze światło | `lighting.area(scene, location=(200, -200, 350), intensity=10000, color=(1, 0.5, 0.2))` | | Pojedyncze światło | `lighting.area(scene, location=(200, -200, 350), intensity=10000, color=(1, 0.5, 0.2))` |
| Kamera | `cam = camera.create(scene, location=(450, -550, 300), target=(0, 0, 100), focal_length=45)` | | Kamera | `cam = camera.create(scene, location=(450, -550, 300), target=(0, 0, 100), fov=43.6)` |
| Zmiana kierunku | `camera.look_at(cam, (0, 0, 150))` | | Zmiana kierunku | `camera.look_at(cam, (0, 0, 150))` |
| Sekwencja | `shot = Sequence(scene, fps=24, frames=48)` | | Sekwencja | `shot = Sequence(scene, fps=24, frames=48)` |
| Aktywna kamera | `shot.camera(cam)` | | Aktywna kamera | `shot.camera(cam)` |
@@ -37,9 +46,31 @@ Cały obrót: klucze 0 i 360, nie 0 i 0. Kamera i modele to oryginalne obiekty U
możesz wywoływać ich metody, np. `actor.set_actor_label("MyHero")`. możesz wywoływać ich metody, np. `actor.set_actor_label("MyHero")`.
`shot.asset` to oryginalny LevelSequence, `shot.bind(actor)` zwraca binding UE. `shot.asset` to oryginalny LevelSequence, `shot.bind(actor)` zwraca binding UE.
W `my_shot.py`: `MODEL="robot"` lub `MODEL="pilot"` wybiera dołączony model. W `my_shot.py`: `MODEL="fighter"` lub `MODEL="creature"` wybiera postać ze szkieletem.
`MODEL="file"` wczytuje `MODEL_FILE`. Obiekt z `scene.model()` ma pivot na podstawie Nazwy klipów pochodzą z plików, np. `great_sword_walk` i `mutant_swiping`.
i zachowuje materiały; animacja dotyczy całego modelu, nie jego kości. `scene.skeletal_model(...)` samo znajduje wszystkie FBX obok modelu — bez `animations`.
Dostępne jest 51 animacji fighter i 12 animacji creature; [pełny katalog](animations.md).
Własny FBX lub `.blend`
z Armature: `MODEL="file"`, `SKELETAL=True`, `MODEL_FILE=...`.
`ANIMATION_FILE=None` odtwarza animację z pliku modelu; można wskazać osobny FBX
z animacją dla tego samego szkieletu. Dla statycznego modelu ustaw `SKELETAL=False`.
```python
character = scene.skeletal_model("source_assets/character/my_character.fbx", name="Hero")
shot.animation(character) # tylko gdy postac ma jedna dostepna animacje
# Gdy jest ich wiecej: shot.animation(character, "nazwa_pliku_animacji")
# Albo nazwane klipy: scene.skeletal_model(..., animations={"walk": walk_fbx, "idle": idle_fbx})
# shot.animation(character, "walk", start=0, end=48, speed=1.0)
# shot.animation(character, "idle", start=48, end=120)
# Plynne przejscie: pierwszy klip 0..60, drugi 48..120:
# shot.animation(character, "walk", start=0, end=60)
# shot.animation(character, "idle", start=48, end=120) # automatyczny blend 12 klatek
```
Krótsza animacja zapętla się w swojej sekcji. `transform_key` porusza całym aktorem
równolegle z animacją kości. Do własnych tras wybieraj klipy In Place.
Blender: kolekcja `Character`, siatki przypisane do jednego Armature, aktywna akcja
i zakres klatek sceny. Zapisz plik przed ponownym uruchomieniem skryptu w UE.
Przy pojedynczym podglądzie nie uruchamiaj dodatkowo pełnego renderu: Przy pojedynczym podglądzie nie uruchamiaj dodatkowo pełnego renderu:
jednocześnie może pracować tylko jeden renderer warsztatowy. jednocześnie może pracować tylko jeden renderer warsztatowy.
+62
View File
@@ -38,3 +38,65 @@ Nie uruchamiano testów na Linuksie ani na Blenderze 4.x.
Uruchamianie sprawdzeń jest opisane w README. Wyniki każdego renderu są zapisane Uruchamianie sprawdzeń jest opisane w README. Wyniki każdego renderu są zapisane
w jego `result.json`; logi UE znajdują się w `Saved/Logs`. w jego `result.json`; logi UE znajdują się w `Saved/Logs`.
Skrypty integracyjne generują własne mapy i assety w `Workshop/Generated`. Skrypty integracyjne generują własne mapy i assety w `Workshop/Generated`.
## Postacie szkieletowe — 31 października 2026
- 13 testów poza UE: w tym zakresy klipów, wykrywanie nakładania, prędkość,
wybór klipu i odrzucanie animacji z innym szkieletem.
- UE 5.7: import fighter i creature jako SkeletalMesh z materiałami i teksturami,
własny szkielet każdej postaci oraz klipy idle, walk i attack z osobnych FBX.
- W klipach walk sprawdzone rzeczywiste zmiany rotacji kości między klatkami.
- Ponowny import tych samych assetów po restarcie UE oraz zapis zależności
(szkielet, materiały, tekstury). FBX używa importera zgodnego z FbxImportUI;
poprzednia wartość flagi Interchange jest przywracana po imporcie.
- Sequencer: klipy w kolejnych zakresach, prędkość 0.75 i jednoczesne klucze
transformacji rodzica. Render 48 klatek, 640×360, dla obu postaci.
- Blender 5.1.2: wczytanie fighter i chodzenia z nowych FBX wyeksportowanych
przez UE, zmiana geometrii z zachowaniem wag, zapis `.blend`, eksport Armature
i aktywnej akcji, import do UE i render 24 klatek, 640×360.
- Dołączone źródła są w FBX 6.1, nieobsługiwanym przez importer Blendera 5.1;
bezpośredni import w UE działa. Test Blendera korzysta z eksportu UE do FBX 7.3.
Odtworzenie testu Blendera (po teście `smoke_skeletal.py`):
```text
blender --background --python-exit-code 1 --python tools/create_skeletal_fixture.py
python tools/run_workshop.py tools/smoke_skeletal_blender.py --render
```
Eksport szkieletowy obejmuje jedną postać i aktywną akcję w zakresie sceny.
Nie sprawdzano retargetingu, Animation Blueprintów ani eksportu wielu akcji/NLA.
## Pliki na zajęcia i pełne katalogi
- `tools/smoke_workshop_files.py`: w UE zaimportowano wszystkie 51 klipów fighter
i 12 klipów creature; sprawdzono kompletność słowników i dodatnią długość klipów.
- `exercises/basics.py`: eksport własnego `.blend`, 192 klatki kamery na okręgu,
stały promień, zgodne końce trasy i ciągły yaw obejmujący pełne 360°.
- `exercises/animation_starter.py`: jedna postać, dokładnie dwa klipy, 360 klatek.
- Wszystkie trzy sceny zostały zbudowane i zapisane w oddzielnych mapach.
## Blendowanie z Pythona
- 17 testów poza UE: dodatkowo poprawne nakładanie, wagi obu klipów, zachowanie
wejścia i wyjścia środkowego klipu oraz odrzucanie trzech równoczesnych klipów.
- `tools/smoke_blending.py`: w UE 5.7 utworzono przejścia idle → attack → walk,
po 12 klatek każde. Sprawdzone zapisane klucze Weight i wyrenderowana klatka 54,
w środku pierwszego przejścia, równolegle z kluczami pozycji rodzica.
- Przejście korzysta z kanałów wag; chronione pola MovieSceneEasingSettings
nie są modyfikowane. Modele i klipy są ponownie używane z `reimport=False`.
## Automatyczne wykrywanie bez aliasów
- 23 testy poza UE: dodatkowo pomijanie pliku modelu, nazwy z plików, wykrywanie
kolizji nazw, automatyczny wybór wszystkich klipów, jawny wybór i puste `animations={}`.
- Sprawdzone przekazywanie `reimport=False`/`True` oraz użycie istniejącego modelu.
- `tools/smoke_workshop_files.py` zaliczony w UE: obie postacie wczytane bez
argumentu `animations`, pełne 51 + 12 klipów i plik zadania z dwoma pełnymi nazwami.
## Automatyczny blend
- 26 testów poza UE: blend wynika ze wspólnego zakresu, także przy jednej klatce
nakładania; stykające się klipy nie dostają fade. Sprawdzone łańcuchy trzech
klipów oraz odrzucanie niejednoznacznych zakresów i dodawania w odwrotnej kolejności.
- Parametr `blend_in` usunięty z API i przykładów. Zakresy scen pozostają takie same.
+26
View File
@@ -0,0 +1,26 @@
"""Dwie postacie Mixamo: osobne szkielety, klipy i pozycje na jednej scenie."""
from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials
from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL
REIMPORT = False # True: ponowny import modelow i klipow; False: istniejace assety UE
scene = Scene("MixamoCharacters")
fighter = scene.skeletal_model(
FIGHTER_MODEL, name="Fighter", location=(-160, 0, 0),
rotation=(0, 0, 180), reimport=REIMPORT)
creature = scene.skeletal_model(
CREATURE_MODEL, name="Creature", location=(160, 0, 0),
rotation=(0, 0, 180), reimport=REIMPORT)
floor = materials.color("Floor", (0.07, 0.08, 0.11))
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor)
lighting.studio(scene)
cam = camera.create(scene, location=(500, -850, 350), target=(0, 0, 100), fov=50)
shot = Sequence(scene, fps=24, frames=120)
shot.camera(cam)
shot.animation(fighter, "great_sword_attack", start=0, end=60)
shot.animation(fighter, "great_sword_idle", start=60, end=120)
shot.animation(creature, "mutant_idle", start=0, end=60)
shot.animation(creature, "mutant_swiping", start=60, end=120)
shot.save()
renderer = Renderer()
# Po obejrzeniu w Sequencerze: renderer.render(shot)
+57
View File
@@ -0,0 +1,57 @@
"""Uruchom plik w UE: File > Execute Python Script. Zmieniaj i uruchamiaj ponownie."""
import importlib
import sys
# Ponowny import bibliotek (ue cachuje zaimportowaną biblioteke i jeśli nie bedzie
# jawnego reloadu/restartu edytora to będzie wykonywał stary kod)
if "ue_workshop" in sys.modules:
_render_module = sys.modules.get("ue_workshop.render")
if _render_module is not None and _render_module.is_rendering():
raise RuntimeError("Poczekaj na zakonczenie renderu przed przeladowaniem biblioteki.")
# Najpierw zaleznosci, potem moduly, ktore z nich korzystaja.
for _name in ("paths", "progress", "video", "materials", "camera", "lighting", "assets",
"_gltf", "blender", "characters", "skeletal", "scene", "sequence", "render"):
_module = sys.modules.get("ue_workshop." + _name)
if _module is not None:
importlib.reload(_module)
importlib.reload(sys.modules["ue_workshop"])
from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials
from ue_workshop.paths import PROJECT
from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL
# POSTACIE
# 1. MODEL I SCENA. Jednostki: centymetry. Os Z wskazuje gore.
scene = Scene("MyShot")
floor = materials.color("Floor", rgb=(0.07, 0.08, 0.11))
character_fighter = scene.skeletal_model(FIGHTER_MODEL, name="Fighter", height=200)
#character_creature = scene.skeletal_model(CREATURE_MODEL, name="Creature", height=300)
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor)
lighting.studio(scene, key_color=(1.0, 0.75, 0.5), fill_color=(0.3, 0.6, 1.0))
cam = camera.create(scene, location=(450, -550, 300), fov=40)
# 2. ANIMACJA. 120 klatek przy 24 FPS = 5 sekund. Ostatnia klatka ma numer 119.
shot = Sequence(scene, fps=24, frames=120)
shot.camera(cam)
shot.animation(character_fighter, "draw_a_great_sword_1", start=0, end=60)
# shot.animation(character_fighter, "great_sword_idle", start=0, end=60)
# shot.animation(character_fighter, "great_sword_attack", start=48, end=120)
# shot.transform_key(character_fighter, 70, location=(0, 0, 0), interpolation="auto")
# shot.transform_key(character_fighter, 100, location=(0, 200, 0), interpolation="auto")
shot.save()
# 3. WYNIK. Najpierw obejrzyj animacje w Sequencerze, potem zmien RENDER na True.
RENDER = False
renderer = Renderer(width=1920, height=1080, samples=4, warmup=32, mp4=True)
# Mozesz tez zaczac od presetu i nadpisac wybrane wartosci:
# renderer = Renderer.from_preset("final", width=1920, height=1080)
if RENDER:
renderer.render(shot)
# Szybki podglad zamiast filmu: renderer.render(shot, single_frame=24)
+35
View File
@@ -0,0 +1,35 @@
"""Zadanie: stworz ciekawa 15-sekundowa scene. Punkt wyjscia: jedna postac i dwa klipy.
Uruchom w UE: File > Execute Python Script. Zrob wlasna kopie pliku przed edycja.
"""
from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials
from ue_workshop.characters import CREATURE_MODEL, _clips
REIMPORT = False # True: ponowny import modelu i klipow; False: istniejace assety UE
scene = Scene("AnimationStarter")
clips = _clips(CREATURE_MODEL)
character = scene.skeletal_model(
CREATURE_MODEL, name="Creature", height=200, rotation=(0, 0, 180), reimport=REIMPORT,
animations={name: clips[name] for name in ("mutant_idle", "mutant_swiping")},
)
floor = materials.color("Floor", (0.07, 0.08, 0.11))
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor)
lighting.studio(scene)
cam = camera.create(scene, location=(450, -550, 300), fov=45)
# 360 klatek przy 24 FPS = 15 sekund. Koniec zakresu jest wylaczny.
shot = Sequence(scene, fps=24, frames=360)
shot.camera(cam)
# 12 wspolnych klatek = plynne przejscie przez 0.5 sekundy.
shot.animation(character, "mutant_idle", start=0, end=132)
shot.animation(character, "mutant_swiping", start=120, end=360)
# Dodaj ruch postaci, kamery, kolejne klipy lub druga postac.
# shot.transform_key(character, 120, location=(0, 0, 0))
# shot.transform_key(character, 239, location=(150, 0, 0))
# Wszystkie klipy: docs/animations.md. Usun animations=... powyzej, aby udostepnic wszystkie.
shot.save()
renderer = Renderer(width=1280, height=720, samples=4, warmup=16, mp4=True)
# renderer.render(shot)
+43
View File
@@ -0,0 +1,43 @@
"""Podstawy: wlasny model z Blendera i kamera robiaca pelne 360 stopni.
Uruchom w UE: File > Execute Python Script. Zapisz model w Blenderze przed uruchomieniem.
"""
import math
from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials
from ue_workshop.paths import PROJECT
REIMPORT = False # True po zmianie modelu w Blenderze; False uzywa assetu z UE
# 1. SCENA. Zmien model, kolor podlogi i swiatla.
scene = Scene("Basics")
character = scene.model(
PROJECT / "source_assets/character/character_custom.blend",
name="CustomCharacter", height=200, reimport=REIMPORT,
)
floor = materials.color("Floor", (0.07, 0.08, 0.11))
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor)
lighting.studio(scene)
# 2. KAMERA. Jednostki: centymetry; FOV w stopniach.
radius = 550
height = 260
target = (0, 0, 100)
cam = camera.create(scene, location=(radius, 0, height), target=target, fov=45)
# 3. ANIMACJA. 192 klatki przy 24 FPS = 8 sekund.
shot = Sequence(scene, fps=24, frames=192)
shot.camera(cam)
pitch = -math.degrees(math.atan2(height - target[2], radius))
for frame in range(shot.frames):
angle = 360 * frame / (shot.frames - 1)
x = radius * math.cos(math.radians(angle))
y = radius * math.sin(math.radians(angle))
# Rotacja: roll, pitch, yaw. Ciagly yaw zapobiega obrotowi wstecz przy 180 stopniach.
shot.transform_key(cam, frame, location=(x, y, height), rotation=(0, pitch, angle + 180))
shot.save()
# 4. RENDER. Najpierw obejrzyj scene w Sequencerze.
renderer = Renderer(width=1280, height=720, samples=4, warmup=16, mp4=True)
# renderer.render(shot)
# Zamiast filmu: renderer.render(shot, single_frame=96)
+46 -22
View File
@@ -10,7 +10,7 @@ if "ue_workshop" in sys.modules:
raise RuntimeError("Poczekaj na zakonczenie renderu przed przeladowaniem biblioteki.") raise RuntimeError("Poczekaj na zakonczenie renderu przed przeladowaniem biblioteki.")
# Najpierw zaleznosci, potem moduly, ktore z nich korzystaja. # Najpierw zaleznosci, potem moduly, ktore z nich korzystaja.
for _name in ("paths", "progress", "video", "materials", "camera", "lighting", "assets", for _name in ("paths", "progress", "video", "materials", "camera", "lighting", "assets",
"_gltf", "blender", "scene", "sequence", "render"): "_gltf", "blender", "characters", "skeletal", "scene", "sequence", "render"):
_module = sys.modules.get("ue_workshop." + _name) _module = sys.modules.get("ue_workshop." + _name)
if _module is not None: if _module is not None:
importlib.reload(_module) importlib.reload(_module)
@@ -18,12 +18,17 @@ if "ue_workshop" in sys.modules:
from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials
from ue_workshop.paths import PROJECT from ue_workshop.paths import PROJECT
from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL
# WYBOR POSTACI: "primitive", "robot", "pilot" lub "file". # WYBOR POSTACI: "fighter", "creature", "primitive" lub "file".
MODEL = "file" MODEL = "fighter"
MODEL_FILE = PROJECT / "source_assets/character/character_custom.blend" REIMPORT = False # True: ponowny import modelu i klipow; False: istniejace assety UE
ANIMATION = "great_sword_attack" # wszystkie nazwy: docs/animations.md; creature: mutant_swiping
MODEL_FILE = PROJECT / "source_assets/character/my_mixamo_character.fbx"
SKELETAL = True # dla MODEL="file"; False dla statycznego GLB/FBX/OBJ/.blend
ANIMATION_FILE = None # None: animacja w MODEL_FILE; albo FBX Without Skin tej samej postaci
MODEL_HEIGHT = 200 # centymetry; None zachowuje oryginalny rozmiar MODEL_HEIGHT = 200 # centymetry; None zachowuje oryginalny rozmiar
MODEL_ROTATION = (0, 0, 0) # roll, pitch, yaw; np. (0, 0, 90) obraca model MODEL_ROTATION = (0, 0, 180) # roll, pitch, yaw; obrot modeli Mixamo przodem do kamery
# 1. MODEL I SCENA. Jednostki: centymetry. Os Z wskazuje gore. # 1. MODEL I SCENA. Jednostki: centymetry. Os Z wskazuje gore.
scene = Scene("MyShot") scene = Scene("MyShot")
@@ -32,33 +37,52 @@ if MODEL == "primitive":
blue = materials.color("Character", rgb=(0.04, 0.35, 0.8), metallic=0.2) blue = materials.color("Character", rgb=(0.04, 0.35, 0.8), metallic=0.2)
character = scene.character(material=blue, head_scale=1.0) character = scene.character(material=blue, head_scale=1.0)
else: else:
sources = { presets = {
"robot": (PROJECT / "DemoAssets/RobotPolice/RobotPolice.fbx", "RobotPolice"), "fighter": (FIGHTER_MODEL, "Fighter"),
"pilot": (PROJECT / "DemoAssets/pilot/pilot.fbx", "Pilot"), "creature": (CREATURE_MODEL, "Creature"),
"file": (MODEL_FILE, "CustomCharacter"),
} }
if MODEL not in sources: if MODEL in presets:
raise ValueError("MODEL: primitive, robot, pilot lub file.") source, name = presets[MODEL]
source, name = sources[MODEL] character = scene.skeletal_model(source, name=name, height=MODEL_HEIGHT,
character = scene.model(source, name=name, height=MODEL_HEIGHT, rotation=MODEL_ROTATION) rotation=MODEL_ROTATION,
reimport=REIMPORT)
elif MODEL == "file":
if SKELETAL:
character = scene.skeletal_model(MODEL_FILE, name="CustomCharacter", height=MODEL_HEIGHT,
rotation=MODEL_ROTATION,
animations={"custom": ANIMATION_FILE} if ANIMATION_FILE else None,
reimport=REIMPORT)
else:
character = scene.model(MODEL_FILE, name="CustomCharacter", height=MODEL_HEIGHT,
rotation=MODEL_ROTATION, reimport=REIMPORT)
else:
raise ValueError("MODEL: fighter, creature, primitive lub file.")
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor) scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor)
lighting.studio(scene, key_color=(1.0, 0.75, 0.5), fill_color=(0.3, 0.6, 1.0)) lighting.studio(scene, key_color=(1.0, 0.75, 0.5), fill_color=(0.3, 0.6, 1.0))
cam = camera.create(scene, location=(450, -550, 300), focal_length=45) cam = camera.create(scene, location=(450, -550, 300), fov=40)
# 2. ANIMACJA. 48 klatek przy 24 FPS = 2 sekundy. Ostatnia klatka ma numer 47. # 2. ANIMACJA. 120 klatek przy 24 FPS = 5 sekund. Ostatnia klatka ma numer 119.
shot = Sequence(scene, fps=24, frames=48) shot = Sequence(scene, fps=24, frames=120)
shot.camera(cam) shot.camera(cam)
# Rotacja: (roll, pitch, yaw), w stopniach. if MODEL in ("fighter", "creature"):
idle = "great_sword_idle" if MODEL == "fighter" else "mutant_idle"
shot.animation(character, idle, start=0, end=60)
shot.animation(character, ANIMATION, start=48, end=120)
elif MODEL == "file" and SKELETAL:
shot.animation(character, "custom" if ANIMATION_FILE else None)
else:
shot.transform_key(character, 0, rotation=(0, 0, 0)) shot.transform_key(character, 0, rotation=(0, 0, 0))
shot.transform_key(character, 47, rotation=(0, 0, 360)) shot.transform_key(character, 119, rotation=(0, 0, 360))
# Opcjonalny podskok (dziala tak samo dla robota i wlasnego modelu): # Rotacja: (roll, pitch, yaw), w stopniach.
# Klucze transformacji dzialaja tez razem z animacja kosci.
# Opcjonalny podskok:
# shot.transform_key(character, 0, location=(0, 0, 0), interpolation="auto") # shot.transform_key(character, 0, location=(0, 0, 0), interpolation="auto")
# shot.transform_key(character, 24, location=(0, 0, 80), interpolation="auto") # shot.transform_key(character, 60, location=(0, 0, 80), interpolation="auto")
# shot.transform_key(character, 47, location=(0, 0, 0), interpolation="auto") # shot.transform_key(character, 119, location=(0, 0, 0), interpolation="auto")
shot.save() shot.save()
# 3. WYNIK. Najpierw obejrzyj animacje w Sequencerze, potem zmien RENDER na True. # 3. WYNIK. Najpierw obejrzyj animacje w Sequencerze, potem zmien RENDER na True.
RENDER = True RENDER = False
renderer = Renderer(width=1920, height=1080, samples=4, warmup=32, mp4=True) renderer = Renderer(width=1920, height=1080, samples=4, warmup=32, mp4=True)
# Mozesz tez zaczac od presetu i nadpisac wybrane wartosci: # Mozesz tez zaczac od presetu i nadpisac wybrane wartosci:
# renderer = Renderer.from_preset("final", width=1920, height=1080) # renderer = Renderer.from_preset("final", width=1920, height=1080)
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

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.

After

Width:  |  Height:  |  Size: 5.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

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.
+81
View File
@@ -0,0 +1,81 @@
"""Automatic folder discovery, explicit selection and the manual reimport switch."""
import importlib.util
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "Content/Python"))
from ue_workshop.characters import _clips
class Mesh:
def get_editor_property(self, name):
return "Skeleton"
class Clip:
def get_name(self):
return "EmbeddedTake"
class DiscoveryTests(unittest.TestCase):
def setUp(self):
self.folder = tempfile.TemporaryDirectory()
self.addCleanup(self.folder.cleanup)
self.model = Path(self.folder.name) / "Hero.fbx"
self.walk = self.model.with_name("great sword walk.fbx")
self.attack = self.model.with_name("great sword attack (2).fbx")
for path in (self.model, self.walk, self.attack):
path.touch()
unreal = MagicMock()
unreal.SkeletalMesh, unreal.AnimSequence = Mesh, Clip
name = "ue_workshop._skeletal_discovery_test"
path = Path(__file__).resolve().parents[1] / "Content/Python/ue_workshop/skeletal.py"
spec = importlib.util.spec_from_file_location(name, path)
self.module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {"unreal": unreal, name: self.module}):
spec.loader.exec_module(self.module)
self.cache = patch.object(self.module, "_cached", return_value=[Mesh()]).start()
self.importer = patch.object(self.module, "_import", return_value=[Mesh()]).start()
self.animation = patch.object(self.module, "import_animation", side_effect=lambda *a, **kw: Clip()).start()
self.addCleanup(patch.stopall)
def test_discovery_excludes_model_and_derives_names_without_aliases(self):
self.assertEqual(_clips(self.model), {
"great_sword_attack_2": self.attack,
"great_sword_walk": self.walk,
})
def test_omitted_animations_loads_every_neighbor_and_reuses_model(self):
result = self.module.import_model(self.model, name="Hero")
self.assertEqual(set(result.animations), {"great_sword_attack_2", "great_sword_walk"})
self.importer.assert_not_called()
self.assertEqual(self.animation.call_count, 2)
for call in self.animation.call_args_list:
self.assertFalse(call.kwargs["reimport"])
def test_empty_dictionary_disables_discovery_and_keeps_embedded_name(self):
self.cache.return_value = [Mesh(), Clip()]
result = self.module.import_model(self.model, name="Hero", animations={})
self.assertEqual(set(result.animations), {"EmbeddedTake"})
self.animation.assert_not_called()
def test_explicit_selection_ignores_other_files(self):
result = self.module.import_model(self.model, name="Hero", animations={"selected": self.walk})
self.assertEqual(set(result.animations), {"selected"})
self.animation.assert_called_once()
self.assertEqual(self.animation.call_args.args[0], self.walk)
def test_reimport_true_reimports_model_and_discovered_clips(self):
self.module.import_model(self.model, name="Hero", reimport=True)
self.cache.assert_not_called()
self.importer.assert_called_once()
self.assertTrue(self.importer.call_args.kwargs["reimport"])
self.assertTrue(all(call.kwargs["reimport"] for call in self.animation.call_args_list))
def test_colliding_normalized_names_are_rejected(self):
self.model.with_name("great-sword-walk.fbx").touch()
with self.assertRaises(ValueError):
_clips(self.model)
+145
View File
@@ -0,0 +1,145 @@
"""Validate clip scheduling without an Unreal installation."""
import importlib.util
from pathlib import Path
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "Content/Python"))
class Clip:
def __init__(self, skeleton):
self.skeleton = skeleton
def get_editor_property(self, name):
assert name == "skeleton"
return self.skeleton
class AnimationTests(unittest.TestCase):
def setUp(self):
unreal = MagicMock()
unreal.AnimSequence = Clip
unreal.FrameNumber.side_effect = lambda frame: frame
path = Path(__file__).resolve().parents[1] / "Content/Python/ue_workshop/sequence.py"
spec = importlib.util.spec_from_file_location("ue_workshop._sequence_test", path)
module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {"unreal": unreal}):
spec.loader.exec_module(module)
self.unreal = unreal
self.shot = module.Sequence.__new__(module.Sequence)
self.shot.frames = 120
self.shot.fps = 24
self.shot.asset = MagicMock()
self.shot.asset.get_tick_resolution.return_value = SimpleNamespace(numerator=24000, denominator=1)
self.shot._animations = {}
self.shot.bind = MagicMock()
self.actor = MagicMock()
self.actor.get_path_name.return_value = "Parent"
self.child = MagicMock()
self.child.get_path_name.return_value = "SkeletalChild"
self.skeleton = object()
self.clip = Clip(self.skeleton)
self.imported = SimpleNamespace(mesh=MagicMock(), animations={"walk": self.clip})
self.imported.mesh.get_editor_property.return_value = self.skeleton
self.shot.scene = SimpleNamespace(_skeletal_models={"Parent": (self.child, self.imported)})
def test_adjacent_clips_share_child_track_and_set_speed(self):
self.shot.animation(self.actor, "walk", start=0, end=48)
self.shot.animation(self.actor, "walk", start=48, end=120, speed=0.8)
self.shot.bind.assert_called_once_with(self.child)
track = self.shot.bind.return_value.add_track.return_value
self.assertEqual(track.add_section.call_count, 2)
track.add_section.return_value.set_range.assert_called_with(48, 120)
self.unreal.MovieSceneTimeWarpVariant.return_value.set_fixed_play_rate.assert_called_with(0.8)
def test_nested_clip_is_rejected_without_creating_section(self):
self.shot.animation(self.actor, "walk", start=0, end=48)
with self.assertRaises(ValueError):
self.shot.animation(self.actor, "walk", start=12, end=36)
self.assertEqual(self.shot.bind.return_value.add_track.return_value.add_section.call_count, 1)
def test_invalid_ranges_and_speeds_do_not_create_track(self):
for options in ({"start": -1}, {"end": 121}, {"start": 48, "end": 48},
{"start": True}, {"speed": 0}, {"speed": float("nan")},
{"speed": float("inf")}):
with self.subTest(options=options), self.assertRaises(ValueError):
self.shot.animation(self.actor, "walk", **options)
self.shot.bind.assert_not_called()
def test_crossfade_sets_complementary_weight_curves(self):
track = self.shot.bind.return_value.add_track.return_value
first, second = MagicMock(), MagicMock()
track.add_section.side_effect = [first, second]
self.shot.animation(self.actor, "walk", start=0, end=60)
self.shot.animation(self.actor, "walk", start=48, end=120)
outgoing = first.get_all_channels.return_value[0].add_key
incoming = second.get_all_channels.return_value[0].add_key
outgoing.assert_any_call(48, 1.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
outgoing.assert_any_call(60, 0.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
incoming.assert_any_call(48, 0.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
incoming.assert_any_call(60, 1.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
self.shot.bind.assert_called_once_with(self.child)
def test_ambiguous_overlaps_are_rejected_before_creating_section(self):
self.shot.animation(self.actor, start=0, end=60)
for start, end in ((0, 60), (0, 120), (12, 36), (30, 60)):
with self.subTest(start=start, end=end), self.assertRaises(ValueError):
self.shot.animation(self.actor, start=start, end=end)
self.assertEqual(self.shot.bind.return_value.add_track.return_value.add_section.call_count, 1)
def test_three_clip_chain_preserves_middle_fade_in_and_adds_fade_out(self):
track = self.shot.bind.return_value.add_track.return_value
first, middle, last = MagicMock(), MagicMock(), MagicMock()
track.add_section.side_effect = [first, middle, last]
self.shot.animation(self.actor, start=0, end=48)
self.shot.animation(self.actor, start=36, end=84)
self.shot.animation(self.actor, start=72, end=120)
weight = middle.get_all_channels.return_value[0].add_key
weight.assert_any_call(36, 0.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
weight.assert_any_call(48, 1.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
weight.assert_any_call(72, 1.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
weight.assert_any_call(84, 0.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
def test_triple_overlap_is_rejected(self):
self.shot.animation(self.actor, start=0, end=60)
self.shot.animation(self.actor, start=48, end=100)
with self.assertRaises(ValueError):
self.shot.animation(self.actor, start=54, end=120)
def test_single_frame_overlap_automatically_blends(self):
self.shot.animation(self.actor, start=0, end=60)
self.shot.animation(self.actor, start=59, end=120)
self.assertEqual(self.shot._animations["SkeletalChild"][1][4], 1)
def test_adjacent_clips_do_not_fade(self):
self.shot.animation(self.actor, start=0, end=60)
self.shot.animation(self.actor, start=60, end=120)
weight = self.shot.bind.return_value.add_track.return_value.add_section.return_value.get_all_channels.return_value[0]
weight.add_key.assert_not_called()
def test_out_of_order_overlap_is_rejected(self):
self.shot.animation(self.actor, start=30, end=90)
with self.assertRaises(ValueError):
self.shot.animation(self.actor, start=0, end=60)
def test_unknown_clip_and_foreign_skeleton_are_rejected(self):
with self.assertRaises(ValueError):
self.shot.animation(self.actor, "missing")
with self.assertRaises(ValueError):
self.shot.animation(self.actor, Clip(object()))
self.shot.bind.assert_not_called()
def test_default_requires_exactly_one_clip(self):
self.shot.animation(self.actor)
self.imported.animations["idle"] = Clip(self.skeleton)
with self.assertRaises(ValueError):
self.shot.animation(self.actor)
def test_static_actor_cannot_receive_skeletal_animation(self):
self.shot.scene._skeletal_models.clear()
with self.assertRaises(ValueError):
self.shot.animation(self.actor, "walk")
self.shot.bind.assert_not_called()
+22 -1
View File
@@ -1,4 +1,4 @@
"""Executed by Blender, never by Unreal. Export an evaluated, joined static mesh.""" """Executed by Blender: static GLB or skinned FBX with the active animation."""
import argparse import argparse
from pathlib import Path from pathlib import Path
import sys import sys
@@ -10,6 +10,7 @@ def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--output", required=True) parser.add_argument("--output", required=True)
parser.add_argument("--collection", default="Character") parser.add_argument("--collection", default="Character")
parser.add_argument("--skeletal", action="store_true")
args = parser.parse_args(sys.argv[sys.argv.index("--") + 1:]) args = parser.parse_args(sys.argv[sys.argv.index("--") + 1:])
collection = bpy.data.collections.get(args.collection) collection = bpy.data.collections.get(args.collection)
if collection is None: if collection is None:
@@ -18,6 +19,26 @@ def main():
if not meshes: if not meshes:
raise RuntimeError("Kolekcja Character nie zawiera siatek.") raise RuntimeError("Kolekcja Character nie zawiera siatek.")
bpy.ops.object.select_all(action="DESELECT") bpy.ops.object.select_all(action="DESELECT")
if args.skeletal:
armatures = {modifier.object for obj in meshes for modifier in obj.modifiers
if modifier.type == "ARMATURE" and modifier.object is not None}
if len(armatures) != 1:
raise RuntimeError("Eksport szkieletowy wymaga jednego Armature przypisanego do siatek.")
armature = next(iter(armatures))
for obj in [*meshes, armature]:
obj.hide_set(False)
obj.select_set(True)
bpy.context.view_layer.objects.active = armature
output = Path(args.output).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.fbx(
filepath=str(output), use_selection=True, object_types={"MESH", "ARMATURE"},
add_leaf_bones=False, use_armature_deform_only=False, bake_anim=True,
bake_anim_use_all_actions=False, bake_anim_use_nla_strips=False,
bake_anim_force_startend_keying=True, bake_anim_simplify_factor=0.0,
axis_forward="-Y", axis_up="Z", path_mode="COPY", embed_textures=True)
print("WORKSHOP_EXPORTED=" + str(output))
return
depsgraph = bpy.context.evaluated_depsgraph_get() depsgraph = bpy.context.evaluated_depsgraph_get()
copies = [] copies = []
for obj in meshes: for obj in meshes:
+38
View File
@@ -0,0 +1,38 @@
"""Run in Blender: edit a copy of the Mixamo fighter and save an animated .blend."""
from pathlib import Path
import bpy
root = Path(__file__).resolve().parents[1]
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
bpy.ops.import_scene.fbx(filepath=str(root / "build/SkeletalFixture/fighter.fbx"))
meshes = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
rig = next(obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE")
original_objects = set(bpy.context.scene.objects)
bpy.ops.import_scene.fbx(filepath=str(root / "build/SkeletalFixture/walk.fbx"))
animation_rig = next(obj for obj in bpy.context.scene.objects
if obj not in original_objects and obj.type == "ARMATURE")
rig.animation_data_create()
rig.animation_data.action = animation_rig.animation_data.action
if hasattr(animation_rig.animation_data, "action_slot"):
rig.animation_data.action_slot = animation_rig.animation_data.action_slot
for obj in list(bpy.context.scene.objects):
if obj not in original_objects:
bpy.data.objects.remove(obj, do_unlink=True)
collection = bpy.data.collections.new("Character")
bpy.context.scene.collection.children.link(collection)
for obj in [*meshes, rig]:
if obj.name not in collection.objects:
collection.objects.link(obj)
# A small mesh edit while retaining the imported weights/armature modifier.
for vertex in meshes[0].data.vertices:
vertex.co.x *= 1.02
action = rig.animation_data.action
bpy.context.scene.frame_start = int(action.frame_range[0])
bpy.context.scene.frame_end = int(action.frame_range[1])
bpy.context.scene.frame_set(bpy.context.scene.frame_start)
output = root / "build/SkeletalFixture/edited_fighter.blend"
output.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(output))
print("WORKSHOP_SKELETAL_FIXTURE=" + str(output))
+36
View File
@@ -0,0 +1,36 @@
"""Real UE test of Python-authored crossfades using existing character assets."""
import json
import unreal
from ue_workshop import Scene, Sequence, Renderer, camera, lighting
from ue_workshop.characters import CREATURE_MODEL
from ue_workshop.paths import PROJECT
scene = Scene("BlendingSmoke")
character = scene.skeletal_model(
CREATURE_MODEL, name="Creature", rotation=(0, 0, 180), reimport=False)
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2))
lighting.studio(scene)
cam = camera.create(scene, location=(450, -550, 300), fov=45)
shot = Sequence(scene, frames=144, fps=24)
shot.camera(cam)
first = shot.animation(character, "mutant_idle", start=0, end=60)
middle = shot.animation(character, "mutant_swiping", start=48, end=108)
last = shot.animation(character, "mutant_walking", start=96, end=144)
def keys(section):
return [(key.get_time().frame_number.value, key.get_value())
for key in section.get_all_channels()[0].get_keys()]
assert keys(first) == [(0, 1.0), (48, 1.0), (60, 0.0)]
assert keys(middle) == [(48, 0.0), (60, 1.0), (96, 1.0), (108, 0.0)]
assert keys(last) == [(96, 0.0), (108, 1.0)]
shot.transform_key(character, 48, location=(0, 0, 0))
shot.transform_key(character, 107, location=(100, 0, 0))
shot.save(open_editor=False)
unreal.LevelSequenceEditorBlueprintLibrary.open_level_sequence(shot.asset)
unreal.LevelSequenceEditorBlueprintLibrary.set_current_time(54)
unreal.LevelSequenceEditorBlueprintLibrary.refresh_current_level_sequence()
(PROJECT / "Saved/Workshop/blending_smoke.json").write_text(
json.dumps({"blend_display_frames": 12, "clips": 3}), encoding="utf-8")
renderer = Renderer(width=640, height=360, samples=1, warmup=0, mp4=False)
+4
View File
@@ -22,6 +22,10 @@ assert len(second["shot"].asset.get_bindings()) == 2
assert len(second["shot"].asset.find_tracks_by_type(unreal.MovieSceneCameraCutTrack)) == 1 assert len(second["shot"].asset.find_tracks_by_type(unreal.MovieSceneCameraCutTrack)) == 1
short = Sequence(second["scene"], "Smoke", fps=24, frames=2) short = Sequence(second["scene"], "Smoke", fps=24, frames=2)
short.camera(second["cam"]) short.camera(second["cam"])
if second["character"].get_path_name() in second["scene"]._skeletal_models:
clip = second["ANIMATION"] if second["MODEL"] in ("fighter", "creature") else (
"custom" if second["ANIMATION_FILE"] else None)
short.animation(second["character"], clip)
short.transform_key(second["character"], 0, rotation=(0, 0, 0)) short.transform_key(second["character"], 0, rotation=(0, 0, 0))
short.transform_key(second["character"], 1, rotation=(0, 0, 30)) short.transform_key(second["character"], 1, rotation=(0, 0, 30))
render.start(short, mp4=True) render.start(short, mp4=True)
+69
View File
@@ -0,0 +1,69 @@
"""Real UE check: both Mixamo skeletons, animation-only FBXs and Sequencer poses."""
import json
import unreal
from ue_workshop import Scene, Sequence, camera, lighting, Renderer
from ue_workshop.paths import PROJECT
scene = Scene("SkeletalSmoke")
base = PROJECT / "source_assets/character"
characters = []
for name, model, walk, idle, attack, position in (
("FighterSkeletalSmoke", "fighter/Maria WProp J J Ong.fbx", "fighter/great sword walk.fbx",
"fighter/great sword idle.fbx", "fighter/great sword attack.fbx", (-160, 0, 0)),
("CreatureSkeletalSmoke", "creature/Maw J Laygo.fbx", "creature/mutant walking.fbx",
"creature/mutant idle.fbx", "creature/mutant swiping.fbx", (160, 0, 0)),
):
actor = scene.skeletal_model(base / model, name=name, location=position,
animations={"walk": base / walk, "idle": base / idle, "attack": base / attack})
child, imported = scene._skeletal_models[actor.get_path_name()]
assert isinstance(child, unreal.SkeletalMeshActor)
assert isinstance(imported.mesh, unreal.SkeletalMesh)
for alias in ("walk", "idle", "attack"):
clip = imported.animations[alias]
assert clip.get_editor_property("skeleton") == imported.mesh.get_editor_property("skeleton")
assert clip.get_editor_property("sequence_length") > 0
if alias == "walk":
tracks = unreal.AnimationLibrary.get_animation_track_names(clip)
assert tracks, "Walk has no bone tracks"
poses_differ = False
for bone in tracks:
first = unreal.AnimationLibrary.get_bone_pose_for_frame(clip, bone, 0, False)
later = unreal.AnimationLibrary.get_bone_pose_for_frame(clip, bone, 10, False)
if not first.rotation.equals(later.rotation, 0.001):
poses_differ = True
break
assert poses_differ, "Walk has no changing bone rotations"
characters.append(actor)
if name == "FighterSkeletalSmoke":
# Bundled sources are FBX 6.1, too old for Blender's FBX importer.
# Export modern FBXs for the Blender round-trip fixture.
folder = PROJECT / "build/SkeletalFixture"
folder.mkdir(parents=True, exist_ok=True)
for obj, filename in ((imported.mesh, "fighter.fbx"), (imported.animations["walk"], "walk.fbx")):
task = unreal.AssetExportTask()
task.set_editor_property("object", obj)
task.set_editor_property("filename", str(folder / filename))
task.set_editor_property("automated", True)
task.set_editor_property("prompt", False)
task.set_editor_property("replace_identical", True)
task.set_editor_property("options", unreal.FbxExportOption())
assert unreal.Exporter.run_asset_export_task(task)
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2))
lighting.studio(scene)
cam = camera.create(scene, location=(600, -850, 400), target=(0, 0, 100), fov=55)
shot = Sequence(scene, frames=48)
shot.camera(cam)
for actor in characters:
first = shot.animation(actor, "walk", start=0, end=16)
second = shot.animation(actor, "idle", start=16, end=32, speed=0.75)
shot.animation(actor, "attack", start=32, end=48)
assert first.get_editor_property("params").animation is not None
assert abs(second.get_editor_property("params").play_rate.to_fixed_play_rate() - 0.75) < 1e-6
shot.transform_key(actor, 0, rotation=(0, 0, 0))
shot.transform_key(actor, 47, rotation=(0, 0, 90))
shot.save(open_editor=False)
summary = {"characters": [actor.get_actor_label() for actor in characters], "frames": shot.frames}
(PROJECT / "Saved/Workshop/skeletal_smoke.json").write_text(json.dumps(summary), encoding="utf-8")
renderer = Renderer(width=640, height=360, samples=1, warmup=0, mp4=False)
+21
View File
@@ -0,0 +1,21 @@
"""UE integration test after tools/create_skeletal_fixture.py was run in Blender."""
import unreal
from ue_workshop import Scene, Sequence, Renderer, camera, lighting
from ue_workshop.paths import PROJECT
scene = Scene("SkeletalBlenderSmoke")
actor = scene.skeletal_model(PROJECT / "build/SkeletalFixture/edited_fighter.blend",
name="EditedFighterSmoke", animations={})
child, imported = scene._skeletal_models[actor.get_path_name()]
assert isinstance(imported.mesh, unreal.SkeletalMesh)
assert len(imported.animations) == 1
assert next(iter(imported.animations.values())).get_editor_property("sequence_length") > 0
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2))
lighting.studio(scene)
cam = camera.create(scene)
shot = Sequence(scene, frames=24)
shot.camera(cam)
shot.animation(actor)
shot.save(open_editor=False)
renderer = Renderer(width=640, height=360, samples=1, warmup=0, mp4=False)
+42
View File
@@ -0,0 +1,42 @@
"""UE integration: teaching orbit, two-clip starter and complete animation catalogs."""
import json
import math
import runpy
from ue_workshop import Renderer
from ue_workshop.paths import PROJECT
from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL, _clips
fighter_clips, creature_clips = _clips(FIGHTER_MODEL), _clips(CREATURE_MODEL)
basics = runpy.run_path(str(PROJECT / "exercises/basics.py"))
shot = basics["shot"]
assert shot.frames == 192
channels = shot._transforms[basics["cam"].get_path_name()]
x = [key.get_value() for key in channels[0].get_keys()]
y = [key.get_value() for key in channels[1].get_keys()]
yaw = [key.get_value() for key in channels[5].get_keys()]
assert len(x) == shot.frames == len(yaw)
assert abs(yaw[-1] - yaw[0] - 360) < 0.001
assert all(b > a for a, b in zip(yaw, yaw[1:])), "Camera reverses direction"
assert abs(x[0] - x[-1]) < 0.001 and abs(y[0] - y[-1]) < 0.001
assert all(abs(math.hypot(px, py) - basics["radius"]) < 0.01 for px, py in zip(x, y))
starter = runpy.run_path(str(PROJECT / "exercises/animation_starter.py"))
assert starter["shot"].frames == 360
models = starter["scene"]._skeletal_models
assert len(models) == 1
assert set(next(iter(models.values()))[1].animations) == {"mutant_idle", "mutant_swiping"}
example = runpy.run_path(str(PROJECT / "examples/mixamo_characters.py"))
for actor_name, clips in (("fighter", fighter_clips), ("creature", creature_clips)):
actor = example[actor_name]
child, imported = example["scene"]._skeletal_models[actor.get_path_name()]
assert set(imported.animations) == set(clips)
assert all(clip.get_editor_property("sequence_length") > 0 for clip in imported.animations.values())
summary = {"fighter_clips": len(fighter_clips), "creature_clips": len(creature_clips),
"orbit_frames": basics["shot"].frames, "starter_frames": starter["shot"].frames}
(PROJECT / "Saved/Workshop/workshop_files_smoke.json").write_text(json.dumps(summary), encoding="utf-8")
shot = example["shot"]
renderer = Renderer(width=640, height=360, samples=1, warmup=0, mp4=False)