diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..841bf13 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "python.analysis.extraPaths": ["${workspaceFolder}/Content/Python"], + "editor.hover.enabled": true, + "editor.inlayHints.enabled": "on", + "python.analysis.inlayHints.callArgumentNames": "all" +} diff --git a/Content/Python/ue_workshop/__init__.py b/Content/Python/ue_workshop/__init__.py index 3df02ba..a6b989e 100644 --- a/Content/Python/ue_workshop/__init__.py +++ b/Content/Python/ue_workshop/__init__.py @@ -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"] diff --git a/Content/Python/ue_workshop/assets.py b/Content/Python/ue_workshop/assets.py index c5cc8b5..ee86f66 100644 --- a/Content/Python/ue_workshop/assets.py +++ b/Content/Python/ue_workshop/assets.py @@ -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(): diff --git a/Content/Python/ue_workshop/blender.py b/Content/Python/ue_workshop/blender.py index e000729..fc4b62a 100644 --- a/Content/Python/ue_workshop/blender.py +++ b/Content/Python/ue_workshop/blender.py @@ -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) diff --git a/Content/Python/ue_workshop/camera.py b/Content/Python/ue_workshop/camera.py index 95561af..9235f7b 100644 --- a/Content/Python/ue_workshop/camera.py +++ b/Content/Python/ue_workshop/camera.py @@ -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)) diff --git a/Content/Python/ue_workshop/characters.py b/Content/Python/ue_workshop/characters.py new file mode 100644 index 0000000..ba6d5d9 --- /dev/null +++ b/Content/Python/ue_workshop/characters.py @@ -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 + + diff --git a/Content/Python/ue_workshop/scene.py b/Content/Python/ue_workshop/scene.py index 4d58be7..548d19a 100644 --- a/Content/Python/ue_workshop/scene.py +++ b/Content/Python/ue_workshop/scene.py @@ -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) diff --git a/Content/Python/ue_workshop/sequence.py b/Content/Python/ue_workshop/sequence.py index 0e26ace..566bc2b 100644 --- a/Content/Python/ue_workshop/sequence.py +++ b/Content/Python/ue_workshop/sequence.py @@ -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}.") diff --git a/Content/Python/ue_workshop/skeletal.py b/Content/Python/ue_workshop/skeletal.py new file mode 100644 index 0000000..564d74f --- /dev/null +++ b/Content/Python/ue_workshop/skeletal.py @@ -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) diff --git a/README.md b/README.md index 1398bd1..2770457 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ UE **5.7**, Windows / Linux. Biblioteka `ue_workshop` automatyzuje scenę, 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 `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 +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ż -`exercises/my_shot.py`. Możesz też wpisać w konsoli UE: +`exercises/basics.py`. Możesz też wpisać w konsoli UE: ```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. -Obejrzyj ją w Sequencerze. Zmień kolor, `head_scale`, kamerę albo klucze i uruchom +Powstanie własna postać z Blendera, podłoga, trzy światła i ośmiosekundowy objazd kamerą. +Obejrzyj go w Sequencerze. Zmień model, kolor podłogi, kamerę albo klucze i uruchom plik ponownie. Skrypt jest odczytywany ponownie z dysku. -Ustaw `RENDER = True`, aby wyrenderować film. Domyślny preset `preview` to -640×360, 24 FPS, 48 klatek. `final` to 1280×720 i więcej próbek. +W `my_shot.py` ustaw `RENDER = True`, aby wyrenderować film. Preset `preview` to 640×360, +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 `renderer.render(shot, single_frame=24)`. @@ -135,15 +157,17 @@ W `exercises/my_shot.py` wybierz `MODEL`: | Wartość | Postać | |---|---| | `"primitive"` | Dotychczasowa postać z brył, bez importu. | -| `"robot"` | RobotPolice z dołączonego `DemoAssets`. | -| `"pilot"` | Humanoid z `DemoAssets/pilot`. | +| `"fighter"` | Wojowniczka z `source_assets/character/fighter`. Domyślna postać. | +| `"creature"` | Stwór z `source_assets/character/creature`. | | `"file"` | Własny plik wskazany w `MODEL_FILE`. | Przykład własnego modelu: ```python 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_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`, `_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 jest osobny materiał dla każdego slotu. -Po zmianie postaci reszta skryptu działa tak samo: klucze `location`, `rotation` -i `scale` animują cały model. W przykładzie jest też opcjonalny podskok. -Modele z rigami są w tej wersji importowane jako statyczna geometria: -animacje kości, chodzenie z Mixamo i retargeting nie są jeszcze obsługiwane. +Dla `fighter` i `creature` użyj nazwy pliku bez rozszerzenia, ze spacjami zamienionymi +na podkreślenia: np. `great_sword_walk` lub `mutant_swiping`. Dostępny jest +[pełny katalog animacji](docs/animations.md): 51 klipów fighter i 12 klipów creature. +`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`. +### 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 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 MODEL = "file" 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. Nie trzeba ręcznie usuwać poprzedniego modelu. Eksport obejmuje kolekcję `Character`. 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 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. -Dowolne grafy proceduralne, rig, animacje szkieletowe, włosy i shader skóry nie -wchodzą w zakres v1. Materiały proceduralne należy wypalić do tekstur. +Dowolne grafy proceduralne, włosy i shader skóry nie są przenoszone w tym przepływie. +Materiały proceduralne należy wypalić do tekstur. Modele z Armature obsługuje +`scene.skeletal_model(...)`, opisane wyżej. ## Układ i zasady - `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. - `tools/` — launcher, eksport/generator Blendera i testy integracyjne. - `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_blender.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, diff --git a/docs/animations.md b/docs/animations.md new file mode 100644 index 0000000..3a293d0 --- /dev/null +++ b/docs/animations.md @@ -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` | diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index b34a22a..e19554e 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -2,6 +2,15 @@ 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 | |---|---| | 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))` | | Ś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))` | -| 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))` | | Sekwencja | `shot = Sequence(scene, fps=24, frames=48)` | | 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")`. `shot.asset` to oryginalny LevelSequence, `shot.bind(actor)` zwraca binding UE. -W `my_shot.py`: `MODEL="robot"` lub `MODEL="pilot"` wybiera dołączony model. -`MODEL="file"` wczytuje `MODEL_FILE`. Obiekt z `scene.model()` ma pivot na podstawie -i zachowuje materiały; animacja dotyczy całego modelu, nie jego kości. +W `my_shot.py`: `MODEL="fighter"` lub `MODEL="creature"` wybiera postać ze szkieletem. +Nazwy klipów pochodzą z plików, np. `great_sword_walk` i `mutant_swiping`. +`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: jednocześnie może pracować tylko jeden renderer warsztatowy. diff --git a/docs/validation.md b/docs/validation.md index f994191..c2451c0 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -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 w jego `result.json`; logi UE znajdują się w `Saved/Logs`. 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. diff --git a/examples/mixamo_characters.py b/examples/mixamo_characters.py new file mode 100644 index 0000000..ea3da02 --- /dev/null +++ b/examples/mixamo_characters.py @@ -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) diff --git a/exercises/advanced_example.py b/exercises/advanced_example.py new file mode 100644 index 0000000..5c1f44e --- /dev/null +++ b/exercises/advanced_example.py @@ -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) diff --git a/exercises/animation_starter.py b/exercises/animation_starter.py new file mode 100644 index 0000000..fa98dd2 --- /dev/null +++ b/exercises/animation_starter.py @@ -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) diff --git a/exercises/basics.py b/exercises/basics.py new file mode 100644 index 0000000..d757cc2 --- /dev/null +++ b/exercises/basics.py @@ -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) diff --git a/exercises/my_shot.py b/exercises/my_shot.py index 4cee837..c4c1b58 100644 --- a/exercises/my_shot.py +++ b/exercises/my_shot.py @@ -10,7 +10,7 @@ if "ue_workshop" in sys.modules: 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", "scene", "sequence", "render"): + "_gltf", "blender", "characters", "skeletal", "scene", "sequence", "render"): _module = sys.modules.get("ue_workshop." + _name) if _module is not None: 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.paths import PROJECT +from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL -# WYBOR POSTACI: "primitive", "robot", "pilot" lub "file". -MODEL = "file" -MODEL_FILE = PROJECT / "source_assets/character/character_custom.blend" +# WYBOR POSTACI: "fighter", "creature", "primitive" lub "file". +MODEL = "fighter" +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_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. scene = Scene("MyShot") @@ -32,33 +37,52 @@ if MODEL == "primitive": blue = materials.color("Character", rgb=(0.04, 0.35, 0.8), metallic=0.2) character = scene.character(material=blue, head_scale=1.0) else: - sources = { - "robot": (PROJECT / "DemoAssets/RobotPolice/RobotPolice.fbx", "RobotPolice"), - "pilot": (PROJECT / "DemoAssets/pilot/pilot.fbx", "Pilot"), - "file": (MODEL_FILE, "CustomCharacter"), + presets = { + "fighter": (FIGHTER_MODEL, "Fighter"), + "creature": (CREATURE_MODEL, "Creature"), } - if MODEL not in sources: - raise ValueError("MODEL: primitive, robot, pilot lub file.") - source, name = sources[MODEL] - character = scene.model(source, name=name, height=MODEL_HEIGHT, rotation=MODEL_ROTATION) + if MODEL in presets: + source, name = presets[MODEL] + character = scene.skeletal_model(source, name=name, height=MODEL_HEIGHT, + 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) 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. -shot = Sequence(scene, fps=24, frames=48) +# 2. ANIMACJA. 120 klatek przy 24 FPS = 5 sekund. Ostatnia klatka ma numer 119. +shot = Sequence(scene, fps=24, frames=120) shot.camera(cam) +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, 119, rotation=(0, 0, 360)) # Rotacja: (roll, pitch, yaw), w stopniach. -shot.transform_key(character, 0, rotation=(0, 0, 0)) -shot.transform_key(character, 47, rotation=(0, 0, 360)) -# Opcjonalny podskok (dziala tak samo dla robota i wlasnego modelu): +# 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, 24, location=(0, 0, 80), interpolation="auto") -# shot.transform_key(character, 47, location=(0, 0, 0), interpolation="auto") +# shot.transform_key(character, 60, location=(0, 0, 80), interpolation="auto") +# shot.transform_key(character, 119, location=(0, 0, 0), interpolation="auto") shot.save() # 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) # Mozesz tez zaczac od presetu i nadpisac wybrane wartosci: # renderer = Renderer.from_preset("final", width=1920, height=1080) diff --git a/source_assets/character/creature/Maw J Laygo.fbm/MAW_diffuse.png b/source_assets/character/creature/Maw J Laygo.fbm/MAW_diffuse.png new file mode 100644 index 0000000..c8d2c0a Binary files /dev/null and b/source_assets/character/creature/Maw J Laygo.fbm/MAW_diffuse.png differ diff --git a/source_assets/character/creature/Maw J Laygo.fbm/MAW_glow.png b/source_assets/character/creature/Maw J Laygo.fbm/MAW_glow.png new file mode 100644 index 0000000..de6b9ae Binary files /dev/null and b/source_assets/character/creature/Maw J Laygo.fbm/MAW_glow.png differ diff --git a/source_assets/character/creature/Maw J Laygo.fbm/MAW_normal.png b/source_assets/character/creature/Maw J Laygo.fbm/MAW_normal.png new file mode 100644 index 0000000..dd27d34 Binary files /dev/null and b/source_assets/character/creature/Maw J Laygo.fbm/MAW_normal.png differ diff --git a/source_assets/character/creature/Maw J Laygo.fbm/maria_specular.png b/source_assets/character/creature/Maw J Laygo.fbm/maria_specular.png new file mode 100644 index 0000000..db82fed Binary files /dev/null and b/source_assets/character/creature/Maw J Laygo.fbm/maria_specular.png differ diff --git a/source_assets/character/creature/Maw J Laygo.fbx b/source_assets/character/creature/Maw J Laygo.fbx new file mode 100644 index 0000000..454fd2f Binary files /dev/null and b/source_assets/character/creature/Maw J Laygo.fbx differ diff --git a/source_assets/character/creature/left turn 45.fbx b/source_assets/character/creature/left turn 45.fbx new file mode 100644 index 0000000..1913644 Binary files /dev/null and b/source_assets/character/creature/left turn 45.fbx differ diff --git a/source_assets/character/creature/mutant breathing idle.fbx b/source_assets/character/creature/mutant breathing idle.fbx new file mode 100644 index 0000000..6fb5203 Binary files /dev/null and b/source_assets/character/creature/mutant breathing idle.fbx differ diff --git a/source_assets/character/creature/mutant dying.fbx b/source_assets/character/creature/mutant dying.fbx new file mode 100644 index 0000000..bb69396 Binary files /dev/null and b/source_assets/character/creature/mutant dying.fbx differ diff --git a/source_assets/character/creature/mutant flexing muscles.fbx b/source_assets/character/creature/mutant flexing muscles.fbx new file mode 100644 index 0000000..83532b7 Binary files /dev/null and b/source_assets/character/creature/mutant flexing muscles.fbx differ diff --git a/source_assets/character/creature/mutant idle.fbx b/source_assets/character/creature/mutant idle.fbx new file mode 100644 index 0000000..565d481 Binary files /dev/null and b/source_assets/character/creature/mutant idle.fbx differ diff --git a/source_assets/character/creature/mutant jumping.fbx b/source_assets/character/creature/mutant jumping.fbx new file mode 100644 index 0000000..c002418 Binary files /dev/null and b/source_assets/character/creature/mutant jumping.fbx differ diff --git a/source_assets/character/creature/mutant left turn 45.fbx b/source_assets/character/creature/mutant left turn 45.fbx new file mode 100644 index 0000000..c832483 Binary files /dev/null and b/source_assets/character/creature/mutant left turn 45.fbx differ diff --git a/source_assets/character/creature/mutant right turn 45 (2).fbx b/source_assets/character/creature/mutant right turn 45 (2).fbx new file mode 100644 index 0000000..6f99275 Binary files /dev/null and b/source_assets/character/creature/mutant right turn 45 (2).fbx differ diff --git a/source_assets/character/creature/mutant right turn 45.fbx b/source_assets/character/creature/mutant right turn 45.fbx new file mode 100644 index 0000000..57a48f1 Binary files /dev/null and b/source_assets/character/creature/mutant right turn 45.fbx differ diff --git a/source_assets/character/creature/mutant run.fbx b/source_assets/character/creature/mutant run.fbx new file mode 100644 index 0000000..145a642 Binary files /dev/null and b/source_assets/character/creature/mutant run.fbx differ diff --git a/source_assets/character/creature/mutant swiping.fbx b/source_assets/character/creature/mutant swiping.fbx new file mode 100644 index 0000000..5be53dc Binary files /dev/null and b/source_assets/character/creature/mutant swiping.fbx differ diff --git a/source_assets/character/creature/mutant walking.fbx b/source_assets/character/creature/mutant walking.fbx new file mode 100644 index 0000000..892fe8d Binary files /dev/null and b/source_assets/character/creature/mutant walking.fbx differ diff --git a/source_assets/character/fighter/Maria WProp J J Ong.fbm/maria_diffuse.png b/source_assets/character/fighter/Maria WProp J J Ong.fbm/maria_diffuse.png new file mode 100644 index 0000000..011db1a Binary files /dev/null and b/source_assets/character/fighter/Maria WProp J J Ong.fbm/maria_diffuse.png differ diff --git a/source_assets/character/fighter/Maria WProp J J Ong.fbm/maria_normal.png b/source_assets/character/fighter/Maria WProp J J Ong.fbm/maria_normal.png new file mode 100644 index 0000000..20201e8 Binary files /dev/null and b/source_assets/character/fighter/Maria WProp J J Ong.fbm/maria_normal.png differ diff --git a/source_assets/character/fighter/Maria WProp J J Ong.fbm/maria_specular.png b/source_assets/character/fighter/Maria WProp J J Ong.fbm/maria_specular.png new file mode 100644 index 0000000..db82fed Binary files /dev/null and b/source_assets/character/fighter/Maria WProp J J Ong.fbm/maria_specular.png differ diff --git a/source_assets/character/fighter/Maria WProp J J Ong.fbx b/source_assets/character/fighter/Maria WProp J J Ong.fbx new file mode 100644 index 0000000..738d036 Binary files /dev/null and b/source_assets/character/fighter/Maria WProp J J Ong.fbx differ diff --git a/source_assets/character/fighter/draw a great sword 1.fbx b/source_assets/character/fighter/draw a great sword 1.fbx new file mode 100644 index 0000000..77f97f4 Binary files /dev/null and b/source_assets/character/fighter/draw a great sword 1.fbx differ diff --git a/source_assets/character/fighter/draw a great sword 2.fbx b/source_assets/character/fighter/draw a great sword 2.fbx new file mode 100644 index 0000000..e7c2baa Binary files /dev/null and b/source_assets/character/fighter/draw a great sword 2.fbx differ diff --git a/source_assets/character/fighter/great sword 180 turn (2).fbx b/source_assets/character/fighter/great sword 180 turn (2).fbx new file mode 100644 index 0000000..98fc967 Binary files /dev/null and b/source_assets/character/fighter/great sword 180 turn (2).fbx differ diff --git a/source_assets/character/fighter/great sword 180 turn.fbx b/source_assets/character/fighter/great sword 180 turn.fbx new file mode 100644 index 0000000..11f6f2e Binary files /dev/null and b/source_assets/character/fighter/great sword 180 turn.fbx differ diff --git a/source_assets/character/fighter/great sword attack.fbx b/source_assets/character/fighter/great sword attack.fbx new file mode 100644 index 0000000..54449eb Binary files /dev/null and b/source_assets/character/fighter/great sword attack.fbx differ diff --git a/source_assets/character/fighter/great sword blocking (2).fbx b/source_assets/character/fighter/great sword blocking (2).fbx new file mode 100644 index 0000000..70cda3c Binary files /dev/null and b/source_assets/character/fighter/great sword blocking (2).fbx differ diff --git a/source_assets/character/fighter/great sword blocking (3).fbx b/source_assets/character/fighter/great sword blocking (3).fbx new file mode 100644 index 0000000..8e9281a Binary files /dev/null and b/source_assets/character/fighter/great sword blocking (3).fbx differ diff --git a/source_assets/character/fighter/great sword blocking.fbx b/source_assets/character/fighter/great sword blocking.fbx new file mode 100644 index 0000000..1624555 Binary files /dev/null and b/source_assets/character/fighter/great sword blocking.fbx differ diff --git a/source_assets/character/fighter/great sword casting.fbx b/source_assets/character/fighter/great sword casting.fbx new file mode 100644 index 0000000..cbd4cf3 Binary files /dev/null and b/source_assets/character/fighter/great sword casting.fbx differ diff --git a/source_assets/character/fighter/great sword crouching (2).fbx b/source_assets/character/fighter/great sword crouching (2).fbx new file mode 100644 index 0000000..92a706c Binary files /dev/null and b/source_assets/character/fighter/great sword crouching (2).fbx differ diff --git a/source_assets/character/fighter/great sword crouching (3).fbx b/source_assets/character/fighter/great sword crouching (3).fbx new file mode 100644 index 0000000..134896a Binary files /dev/null and b/source_assets/character/fighter/great sword crouching (3).fbx differ diff --git a/source_assets/character/fighter/great sword crouching (4).fbx b/source_assets/character/fighter/great sword crouching (4).fbx new file mode 100644 index 0000000..17ef11f Binary files /dev/null and b/source_assets/character/fighter/great sword crouching (4).fbx differ diff --git a/source_assets/character/fighter/great sword crouching (5).fbx b/source_assets/character/fighter/great sword crouching (5).fbx new file mode 100644 index 0000000..ec4cc18 Binary files /dev/null and b/source_assets/character/fighter/great sword crouching (5).fbx differ diff --git a/source_assets/character/fighter/great sword crouching (6).fbx b/source_assets/character/fighter/great sword crouching (6).fbx new file mode 100644 index 0000000..8294185 Binary files /dev/null and b/source_assets/character/fighter/great sword crouching (6).fbx differ diff --git a/source_assets/character/fighter/great sword crouching.fbx b/source_assets/character/fighter/great sword crouching.fbx new file mode 100644 index 0000000..5bc6614 Binary files /dev/null and b/source_assets/character/fighter/great sword crouching.fbx differ diff --git a/source_assets/character/fighter/great sword high spin attack.fbx b/source_assets/character/fighter/great sword high spin attack.fbx new file mode 100644 index 0000000..d4e6327 Binary files /dev/null and b/source_assets/character/fighter/great sword high spin attack.fbx differ diff --git a/source_assets/character/fighter/great sword idle (2).fbx b/source_assets/character/fighter/great sword idle (2).fbx new file mode 100644 index 0000000..4ba8a4f Binary files /dev/null and b/source_assets/character/fighter/great sword idle (2).fbx differ diff --git a/source_assets/character/fighter/great sword idle (3).fbx b/source_assets/character/fighter/great sword idle (3).fbx new file mode 100644 index 0000000..3ac7704 Binary files /dev/null and b/source_assets/character/fighter/great sword idle (3).fbx differ diff --git a/source_assets/character/fighter/great sword idle (4).fbx b/source_assets/character/fighter/great sword idle (4).fbx new file mode 100644 index 0000000..71b28c9 Binary files /dev/null and b/source_assets/character/fighter/great sword idle (4).fbx differ diff --git a/source_assets/character/fighter/great sword idle (5).fbx b/source_assets/character/fighter/great sword idle (5).fbx new file mode 100644 index 0000000..6241a8d Binary files /dev/null and b/source_assets/character/fighter/great sword idle (5).fbx differ diff --git a/source_assets/character/fighter/great sword idle.fbx b/source_assets/character/fighter/great sword idle.fbx new file mode 100644 index 0000000..53ec0ae Binary files /dev/null and b/source_assets/character/fighter/great sword idle.fbx differ diff --git a/source_assets/character/fighter/great sword impact (2).fbx b/source_assets/character/fighter/great sword impact (2).fbx new file mode 100644 index 0000000..0a7c9c1 Binary files /dev/null and b/source_assets/character/fighter/great sword impact (2).fbx differ diff --git a/source_assets/character/fighter/great sword impact (3).fbx b/source_assets/character/fighter/great sword impact (3).fbx new file mode 100644 index 0000000..c245877 Binary files /dev/null and b/source_assets/character/fighter/great sword impact (3).fbx differ diff --git a/source_assets/character/fighter/great sword impact (4).fbx b/source_assets/character/fighter/great sword impact (4).fbx new file mode 100644 index 0000000..a926f8e Binary files /dev/null and b/source_assets/character/fighter/great sword impact (4).fbx differ diff --git a/source_assets/character/fighter/great sword impact (5).fbx b/source_assets/character/fighter/great sword impact (5).fbx new file mode 100644 index 0000000..d86c48e Binary files /dev/null and b/source_assets/character/fighter/great sword impact (5).fbx differ diff --git a/source_assets/character/fighter/great sword impact.fbx b/source_assets/character/fighter/great sword impact.fbx new file mode 100644 index 0000000..ef0e595 Binary files /dev/null and b/source_assets/character/fighter/great sword impact.fbx differ diff --git a/source_assets/character/fighter/great sword jump (2).fbx b/source_assets/character/fighter/great sword jump (2).fbx new file mode 100644 index 0000000..e2455dc Binary files /dev/null and b/source_assets/character/fighter/great sword jump (2).fbx differ diff --git a/source_assets/character/fighter/great sword jump attack.fbx b/source_assets/character/fighter/great sword jump attack.fbx new file mode 100644 index 0000000..78cc395 Binary files /dev/null and b/source_assets/character/fighter/great sword jump attack.fbx differ diff --git a/source_assets/character/fighter/great sword jump.fbx b/source_assets/character/fighter/great sword jump.fbx new file mode 100644 index 0000000..cc7affe Binary files /dev/null and b/source_assets/character/fighter/great sword jump.fbx differ diff --git a/source_assets/character/fighter/great sword kick (2).fbx b/source_assets/character/fighter/great sword kick (2).fbx new file mode 100644 index 0000000..1389637 Binary files /dev/null and b/source_assets/character/fighter/great sword kick (2).fbx differ diff --git a/source_assets/character/fighter/great sword kick.fbx b/source_assets/character/fighter/great sword kick.fbx new file mode 100644 index 0000000..0586e5f Binary files /dev/null and b/source_assets/character/fighter/great sword kick.fbx differ diff --git a/source_assets/character/fighter/great sword power up.fbx b/source_assets/character/fighter/great sword power up.fbx new file mode 100644 index 0000000..d46f669 Binary files /dev/null and b/source_assets/character/fighter/great sword power up.fbx differ diff --git a/source_assets/character/fighter/great sword run (2).fbx b/source_assets/character/fighter/great sword run (2).fbx new file mode 100644 index 0000000..eefc4e8 Binary files /dev/null and b/source_assets/character/fighter/great sword run (2).fbx differ diff --git a/source_assets/character/fighter/great sword run.fbx b/source_assets/character/fighter/great sword run.fbx new file mode 100644 index 0000000..01f825f Binary files /dev/null and b/source_assets/character/fighter/great sword run.fbx differ diff --git a/source_assets/character/fighter/great sword slash (2).fbx b/source_assets/character/fighter/great sword slash (2).fbx new file mode 100644 index 0000000..9971bcd Binary files /dev/null and b/source_assets/character/fighter/great sword slash (2).fbx differ diff --git a/source_assets/character/fighter/great sword slash (3).fbx b/source_assets/character/fighter/great sword slash (3).fbx new file mode 100644 index 0000000..c2622b4 Binary files /dev/null and b/source_assets/character/fighter/great sword slash (3).fbx differ diff --git a/source_assets/character/fighter/great sword slash (4).fbx b/source_assets/character/fighter/great sword slash (4).fbx new file mode 100644 index 0000000..c658c73 Binary files /dev/null and b/source_assets/character/fighter/great sword slash (4).fbx differ diff --git a/source_assets/character/fighter/great sword slash (5).fbx b/source_assets/character/fighter/great sword slash (5).fbx new file mode 100644 index 0000000..9518a2c Binary files /dev/null and b/source_assets/character/fighter/great sword slash (5).fbx differ diff --git a/source_assets/character/fighter/great sword slash.fbx b/source_assets/character/fighter/great sword slash.fbx new file mode 100644 index 0000000..c97ce71 Binary files /dev/null and b/source_assets/character/fighter/great sword slash.fbx differ diff --git a/source_assets/character/fighter/great sword slide attack.fbx b/source_assets/character/fighter/great sword slide attack.fbx new file mode 100644 index 0000000..473616a Binary files /dev/null and b/source_assets/character/fighter/great sword slide attack.fbx differ diff --git a/source_assets/character/fighter/great sword strafe (2).fbx b/source_assets/character/fighter/great sword strafe (2).fbx new file mode 100644 index 0000000..9de80bf Binary files /dev/null and b/source_assets/character/fighter/great sword strafe (2).fbx differ diff --git a/source_assets/character/fighter/great sword strafe (3).fbx b/source_assets/character/fighter/great sword strafe (3).fbx new file mode 100644 index 0000000..c4f6c05 Binary files /dev/null and b/source_assets/character/fighter/great sword strafe (3).fbx differ diff --git a/source_assets/character/fighter/great sword strafe (4).fbx b/source_assets/character/fighter/great sword strafe (4).fbx new file mode 100644 index 0000000..8ffcdf1 Binary files /dev/null and b/source_assets/character/fighter/great sword strafe (4).fbx differ diff --git a/source_assets/character/fighter/great sword strafe.fbx b/source_assets/character/fighter/great sword strafe.fbx new file mode 100644 index 0000000..0a68d54 Binary files /dev/null and b/source_assets/character/fighter/great sword strafe.fbx differ diff --git a/source_assets/character/fighter/great sword turn (2).fbx b/source_assets/character/fighter/great sword turn (2).fbx new file mode 100644 index 0000000..a5be62b Binary files /dev/null and b/source_assets/character/fighter/great sword turn (2).fbx differ diff --git a/source_assets/character/fighter/great sword turn.fbx b/source_assets/character/fighter/great sword turn.fbx new file mode 100644 index 0000000..d2cb23d Binary files /dev/null and b/source_assets/character/fighter/great sword turn.fbx differ diff --git a/source_assets/character/fighter/great sword walk (2).fbx b/source_assets/character/fighter/great sword walk (2).fbx new file mode 100644 index 0000000..1d699d9 Binary files /dev/null and b/source_assets/character/fighter/great sword walk (2).fbx differ diff --git a/source_assets/character/fighter/great sword walk.fbx b/source_assets/character/fighter/great sword walk.fbx new file mode 100644 index 0000000..273f313 Binary files /dev/null and b/source_assets/character/fighter/great sword walk.fbx differ diff --git a/source_assets/character/fighter/spell cast.fbx b/source_assets/character/fighter/spell cast.fbx new file mode 100644 index 0000000..90f427c Binary files /dev/null and b/source_assets/character/fighter/spell cast.fbx differ diff --git a/source_assets/character/fighter/two handed sword death (2).fbx b/source_assets/character/fighter/two handed sword death (2).fbx new file mode 100644 index 0000000..5632203 Binary files /dev/null and b/source_assets/character/fighter/two handed sword death (2).fbx differ diff --git a/source_assets/character/fighter/two handed sword death.fbx b/source_assets/character/fighter/two handed sword death.fbx new file mode 100644 index 0000000..0076c7c Binary files /dev/null and b/source_assets/character/fighter/two handed sword death.fbx differ diff --git a/tests/test_clip_discovery.py b/tests/test_clip_discovery.py new file mode 100644 index 0000000..a0ff71c --- /dev/null +++ b/tests/test_clip_discovery.py @@ -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) diff --git a/tests/test_skeletal_sequence.py b/tests/test_skeletal_sequence.py new file mode 100644 index 0000000..f6fc24f --- /dev/null +++ b/tests/test_skeletal_sequence.py @@ -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() diff --git a/tools/blender_export.py b/tools/blender_export.py index 5aa8383..2cb6980 100644 --- a/tools/blender_export.py +++ b/tools/blender_export.py @@ -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 from pathlib import Path import sys @@ -10,6 +10,7 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument("--output", required=True) parser.add_argument("--collection", default="Character") + parser.add_argument("--skeletal", action="store_true") args = parser.parse_args(sys.argv[sys.argv.index("--") + 1:]) collection = bpy.data.collections.get(args.collection) if collection is None: @@ -18,6 +19,26 @@ def main(): if not meshes: raise RuntimeError("Kolekcja Character nie zawiera siatek.") 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() copies = [] for obj in meshes: diff --git a/tools/create_skeletal_fixture.py b/tools/create_skeletal_fixture.py new file mode 100644 index 0000000..a45aa37 --- /dev/null +++ b/tools/create_skeletal_fixture.py @@ -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)) diff --git a/tools/smoke_blending.py b/tools/smoke_blending.py new file mode 100644 index 0000000..d64eb3e --- /dev/null +++ b/tools/smoke_blending.py @@ -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) diff --git a/tools/smoke_scene.py b/tools/smoke_scene.py index 516ed40..a5f6ca2 100644 --- a/tools/smoke_scene.py +++ b/tools/smoke_scene.py @@ -22,6 +22,10 @@ assert len(second["shot"].asset.get_bindings()) == 2 assert len(second["shot"].asset.find_tracks_by_type(unreal.MovieSceneCameraCutTrack)) == 1 short = Sequence(second["scene"], "Smoke", fps=24, frames=2) 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"], 1, rotation=(0, 0, 30)) render.start(short, mp4=True) diff --git a/tools/smoke_skeletal.py b/tools/smoke_skeletal.py new file mode 100644 index 0000000..5a0cdb6 --- /dev/null +++ b/tools/smoke_skeletal.py @@ -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) diff --git a/tools/smoke_skeletal_blender.py b/tools/smoke_skeletal_blender.py new file mode 100644 index 0000000..d03b369 --- /dev/null +++ b/tools/smoke_skeletal_blender.py @@ -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) diff --git a/tools/smoke_workshop_files.py b/tools/smoke_workshop_files.py new file mode 100644 index 0000000..d600411 --- /dev/null +++ b/tools/smoke_workshop_files.py @@ -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)