basic advanced example

This commit is contained in:
Vysyr committed 2026-10-31 23:11:57 +01:00
1 parent 6fe57915e8
commit bd8edf5afb
29 files changed
+1501 -38

No files matched your search

@@ -0,0 +1,89 @@
"""Maintain static editor enums as models and animations are imported."""
import ast
import importlib
import math
from pathlib import Path
import sys
import tempfile
from .paths import slug
CATALOG = Path(__file__).with_name("animations.py")
HEADER = '''"""Automatically generated clip names and durations for VS Code.
Updated by scene.skeletal_model() using imported UE animation assets.
This module works without Unreal. Do not edit durations manually.
"""
from .animation_clip import AnimationClip
'''
def _read_classes(text):
classes = {}
for node in ast.parse(text).body:
if not isinstance(node, ast.ClassDef):
continue
data = {}
for statement in node.body:
if isinstance(statement, ast.Assign) and len(statement.targets) == 1:
key, seconds = ast.literal_eval(statement.value)
data[key] = float(seconds)
classes[node.name] = data
return classes
def _class_source(name, clips):
lines = [f'class {name}(AnimationClip):',
f' """Clip names and durations: type {name}. for editor completion."""']
members = set()
for key, seconds in sorted(clips.items()):
member = slug(key).upper()
if member in members:
raise ValueError(f"Powtorzona nazwa enuma: {name}.{member}")
members.add(member)
if not math.isfinite(seconds) or seconds <= 0:
raise ValueError(f"Nieprawidlowa dlugosc animacji {key}: {seconds}")
lines.extend([f' {member} = ({key!r}, {seconds!r})',
f' """{seconds:.3f} seconds at speed=1; clip: {key}."""'])
return '\n'.join(lines)
def _publish_class(source, filename):
# Refresh only this class, preserving other enum classes and existing imports.
# Names are validated and data are emitted with repr; source is generated here.
module = sys.modules.get(__package__ + ".animations")
if module is not None:
exec(compile(source, str(filename), "exec"), vars(module))
importlib.invalidate_caches()
def update_catalog(name, clips, *, replace=True, output=None):
"""Write NameAnimations. Partial selections merge; full discovery replaces it.
Other models are preserved. Identical metadata does not rewrite the file.
clips maps actual playback keys to durations in seconds, without Unreal objects.
"""
name = slug(name)
class_name = name[0].upper() + name[1:] + "Animations"
path = CATALOG if output is None else Path(output)
old_text = path.read_text(encoding="utf-8") if path.is_file() else ""
classes = _read_classes(old_text)
data = {} if replace else dict(classes.get(class_name, {}))
data.update({key: float(seconds) for key, seconds in clips.items()})
classes[class_name] = data
sources = {key: _class_source(key, value) for key, value in sorted(classes.items())}
text = HEADER + '\n\n' + '\n\n\n'.join(sources.values()) + '\n'
if text != old_text:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n",
dir=path.parent, prefix=path.name + ".", suffix=".tmp",
delete=False) as temporary:
temporary.write(text)
temporary_path = Path(temporary.name)
try:
temporary_path.replace(path)
finally:
temporary_path.unlink(missing_ok=True)
if output is None:
_publish_class(sources[class_name], path)
return class_name
@@ -0,0 +1,33 @@
"""Animation metadata usable in normal Python and VS Code without Unreal."""
from enum import Enum
import math
class AnimationClip(Enum):
"""A named clip with its exported duration. Subclasses list concrete clips."""
def __init__(self, key: str, seconds: float):
self._key = key
self._seconds = seconds
@property
def key(self) -> str:
"""Clip name used by shot.animation(), derived from the FBX filename."""
return self._key
@property
def seconds(self) -> float:
"""Duration of one playback at speed=1, exported from Unreal."""
return self._seconds
def frames(self, fps: int = 24, speed: float = 1.0) -> int:
"""Frames needed for one playback at this sequence FPS and speed."""
if type(fps) is not int or fps < 1:
raise ValueError("fps musi byc dodatnia liczba calkowita.")
if isinstance(speed, bool) or not isinstance(speed, (int, float)) or not math.isfinite(speed) or speed <= 0:
raise ValueError("speed musi byc dodatnia liczba.")
value = self.seconds * fps / speed
# Unreal stores durations as float32; avoid adding a whole extra frame
# for values such as 3.200000047683716 seconds at 30 FPS.
nearest = round(value)
return max(1, nearest if math.isclose(value, nearest, rel_tol=0, abs_tol=0.0001) else math.ceil(value))
+140
View File
@@ -0,0 +1,140 @@
"""Automatically generated clip names and durations for VS Code.
Updated by scene.skeletal_model() using imported UE animation assets.
This module works without Unreal. Do not edit durations manually.
"""
from .animation_clip import AnimationClip
class CreatureAnimations(AnimationClip):
"""Clip names and durations: type CreatureAnimations. for editor completion."""
LEFT_TURN_45 = ('left_turn_45', 1.3333333730697632)
"""1.333 seconds at speed=1; clip: left_turn_45."""
MUTANT_BREATHING_IDLE = ('mutant_breathing_idle', 4.0333333015441895)
"""4.033 seconds at speed=1; clip: mutant_breathing_idle."""
MUTANT_DYING = ('mutant_dying', 4.599999904632568)
"""4.600 seconds at speed=1; clip: mutant_dying."""
MUTANT_FLEXING_MUSCLES = ('mutant_flexing_muscles', 4.666666507720947)
"""4.667 seconds at speed=1; clip: mutant_flexing_muscles."""
MUTANT_IDLE = ('mutant_idle', 5.300000190734863)
"""5.300 seconds at speed=1; clip: mutant_idle."""
MUTANT_JUMPING = ('mutant_jumping', 3.1666667461395264)
"""3.167 seconds at speed=1; clip: mutant_jumping."""
MUTANT_LEFT_TURN_45 = ('mutant_left_turn_45', 1.3333333730697632)
"""1.333 seconds at speed=1; clip: mutant_left_turn_45."""
MUTANT_RIGHT_TURN_45 = ('mutant_right_turn_45', 1.9666666984558105)
"""1.967 seconds at speed=1; clip: mutant_right_turn_45."""
MUTANT_RIGHT_TURN_45_2 = ('mutant_right_turn_45_2', 1.3333333730697632)
"""1.333 seconds at speed=1; clip: mutant_right_turn_45_2."""
MUTANT_RUN = ('mutant_run', 0.8666666746139526)
"""0.867 seconds at speed=1; clip: mutant_run."""
MUTANT_SWIPING = ('mutant_swiping', 2.6666667461395264)
"""2.667 seconds at speed=1; clip: mutant_swiping."""
MUTANT_WALKING = ('mutant_walking', 1.4333332777023315)
"""1.433 seconds at speed=1; clip: mutant_walking."""
class FighterAnimations(AnimationClip):
"""Clip names and durations: type FighterAnimations. for editor completion."""
DRAW_A_GREAT_SWORD_1 = ('draw_a_great_sword_1', 0.5)
"""0.500 seconds at speed=1; clip: draw_a_great_sword_1."""
DRAW_A_GREAT_SWORD_2 = ('draw_a_great_sword_2', 0.800000011920929)
"""0.800 seconds at speed=1; clip: draw_a_great_sword_2."""
GREAT_SWORD_180_TURN = ('great_sword_180_turn', 0.800000011920929)
"""0.800 seconds at speed=1; clip: great_sword_180_turn."""
GREAT_SWORD_180_TURN_2 = ('great_sword_180_turn_2', 0.5666666626930237)
"""0.567 seconds at speed=1; clip: great_sword_180_turn_2."""
GREAT_SWORD_ATTACK = ('great_sword_attack', 1.2000000476837158)
"""1.200 seconds at speed=1; clip: great_sword_attack."""
GREAT_SWORD_BLOCKING = ('great_sword_blocking', 0.5)
"""0.500 seconds at speed=1; clip: great_sword_blocking."""
GREAT_SWORD_BLOCKING_2 = ('great_sword_blocking_2', 0.9666666388511658)
"""0.967 seconds at speed=1; clip: great_sword_blocking_2."""
GREAT_SWORD_BLOCKING_3 = ('great_sword_blocking_3', 0.5)
"""0.500 seconds at speed=1; clip: great_sword_blocking_3."""
GREAT_SWORD_CASTING = ('great_sword_casting', 4.800000190734863)
"""4.800 seconds at speed=1; clip: great_sword_casting."""
GREAT_SWORD_CROUCHING = ('great_sword_crouching', 0.6666666865348816)
"""0.667 seconds at speed=1; clip: great_sword_crouching."""
GREAT_SWORD_CROUCHING_2 = ('great_sword_crouching_2', 0.699999988079071)
"""0.700 seconds at speed=1; clip: great_sword_crouching_2."""
GREAT_SWORD_CROUCHING_3 = ('great_sword_crouching_3', 1.8333333730697632)
"""1.833 seconds at speed=1; clip: great_sword_crouching_3."""
GREAT_SWORD_CROUCHING_4 = ('great_sword_crouching_4', 0.36666667461395264)
"""0.367 seconds at speed=1; clip: great_sword_crouching_4."""
GREAT_SWORD_CROUCHING_5 = ('great_sword_crouching_5', 1.399999976158142)
"""1.400 seconds at speed=1; clip: great_sword_crouching_5."""
GREAT_SWORD_CROUCHING_6 = ('great_sword_crouching_6', 0.46666666865348816)
"""0.467 seconds at speed=1; clip: great_sword_crouching_6."""
GREAT_SWORD_HIGH_SPIN_ATTACK = ('great_sword_high_spin_attack', 1.8666666746139526)
"""1.867 seconds at speed=1; clip: great_sword_high_spin_attack."""
GREAT_SWORD_IDLE = ('great_sword_idle', 2.0)
"""2.000 seconds at speed=1; clip: great_sword_idle."""
GREAT_SWORD_IDLE_2 = ('great_sword_idle_2', 3.700000047683716)
"""3.700 seconds at speed=1; clip: great_sword_idle_2."""
GREAT_SWORD_IDLE_3 = ('great_sword_idle_3', 3.6666667461395264)
"""3.667 seconds at speed=1; clip: great_sword_idle_3."""
GREAT_SWORD_IDLE_4 = ('great_sword_idle_4', 3.7666666507720947)
"""3.767 seconds at speed=1; clip: great_sword_idle_4."""
GREAT_SWORD_IDLE_5 = ('great_sword_idle_5', 7.566666603088379)
"""7.567 seconds at speed=1; clip: great_sword_idle_5."""
GREAT_SWORD_IMPACT = ('great_sword_impact', 0.8666666746139526)
"""0.867 seconds at speed=1; clip: great_sword_impact."""
GREAT_SWORD_IMPACT_2 = ('great_sword_impact_2', 1.2000000476837158)
"""1.200 seconds at speed=1; clip: great_sword_impact_2."""
GREAT_SWORD_IMPACT_3 = ('great_sword_impact_3', 1.2666666507720947)
"""1.267 seconds at speed=1; clip: great_sword_impact_3."""
GREAT_SWORD_IMPACT_4 = ('great_sword_impact_4', 0.6666666865348816)
"""0.667 seconds at speed=1; clip: great_sword_impact_4."""
GREAT_SWORD_IMPACT_5 = ('great_sword_impact_5', 0.9666666388511658)
"""0.967 seconds at speed=1; clip: great_sword_impact_5."""
GREAT_SWORD_JUMP = ('great_sword_jump', 0.6333333253860474)
"""0.633 seconds at speed=1; clip: great_sword_jump."""
GREAT_SWORD_JUMP_2 = ('great_sword_jump_2', 0.8999999761581421)
"""0.900 seconds at speed=1; clip: great_sword_jump_2."""
GREAT_SWORD_JUMP_ATTACK = ('great_sword_jump_attack', 2.1666667461395264)
"""2.167 seconds at speed=1; clip: great_sword_jump_attack."""
GREAT_SWORD_KICK = ('great_sword_kick', 1.5)
"""1.500 seconds at speed=1; clip: great_sword_kick."""
GREAT_SWORD_KICK_2 = ('great_sword_kick_2', 1.7333333492279053)
"""1.733 seconds at speed=1; clip: great_sword_kick_2."""
GREAT_SWORD_POWER_UP = ('great_sword_power_up', 3.5)
"""3.500 seconds at speed=1; clip: great_sword_power_up."""
GREAT_SWORD_RUN = ('great_sword_run', 0.7333333492279053)
"""0.733 seconds at speed=1; clip: great_sword_run."""
GREAT_SWORD_RUN_2 = ('great_sword_run_2', 0.6000000238418579)
"""0.600 seconds at speed=1; clip: great_sword_run_2."""
GREAT_SWORD_SLASH = ('great_sword_slash', 1.2666666507720947)
"""1.267 seconds at speed=1; clip: great_sword_slash."""
GREAT_SWORD_SLASH_2 = ('great_sword_slash_2', 3.5333333015441895)
"""3.533 seconds at speed=1; clip: great_sword_slash_2."""
GREAT_SWORD_SLASH_3 = ('great_sword_slash_3', 1.8333333730697632)
"""1.833 seconds at speed=1; clip: great_sword_slash_3."""
GREAT_SWORD_SLASH_4 = ('great_sword_slash_4', 1.7999999523162842)
"""1.800 seconds at speed=1; clip: great_sword_slash_4."""
GREAT_SWORD_SLASH_5 = ('great_sword_slash_5', 1.4333332777023315)
"""1.433 seconds at speed=1; clip: great_sword_slash_5."""
GREAT_SWORD_SLIDE_ATTACK = ('great_sword_slide_attack', 2.133333444595337)
"""2.133 seconds at speed=1; clip: great_sword_slide_attack."""
GREAT_SWORD_STRAFE = ('great_sword_strafe', 1.100000023841858)
"""1.100 seconds at speed=1; clip: great_sword_strafe."""
GREAT_SWORD_STRAFE_2 = ('great_sword_strafe_2', 1.1666666269302368)
"""1.167 seconds at speed=1; clip: great_sword_strafe_2."""
GREAT_SWORD_STRAFE_3 = ('great_sword_strafe_3', 0.5666666626930237)
"""0.567 seconds at speed=1; clip: great_sword_strafe_3."""
GREAT_SWORD_STRAFE_4 = ('great_sword_strafe_4', 0.6333333253860474)
"""0.633 seconds at speed=1; clip: great_sword_strafe_4."""
GREAT_SWORD_TURN = ('great_sword_turn', 0.7333333492279053)
"""0.733 seconds at speed=1; clip: great_sword_turn."""
GREAT_SWORD_TURN_2 = ('great_sword_turn_2', 0.8333333134651184)
"""0.833 seconds at speed=1; clip: great_sword_turn_2."""
GREAT_SWORD_WALK = ('great_sword_walk', 1.3666666746139526)
"""1.367 seconds at speed=1; clip: great_sword_walk."""
GREAT_SWORD_WALK_2 = ('great_sword_walk_2', 1.2999999523162842)
"""1.300 seconds at speed=1; clip: great_sword_walk_2."""
SPELL_CAST = ('spell_cast', 1.1333333253860474)
"""1.133 seconds at speed=1; clip: spell_cast."""
TWO_HANDED_SWORD_DEATH = ('two_handed_sword_death', 2.4000000953674316)
"""2.400 seconds at speed=1; clip: two_handed_sword_death."""
TWO_HANDED_SWORD_DEATH_2 = ('two_handed_sword_death_2', 2.5999999046325684)
"""2.600 seconds at speed=1; clip: two_handed_sword_death_2."""
+39 -5
View File
@@ -3,21 +3,55 @@ import unreal
from .camera import look_at
def _setup(actor, component_class, intensity, color):
light = actor.get_component_by_class(component_class)
light.set_mobility(unreal.ComponentMobility.MOVABLE)
light.set_editor_property("intensity", float(intensity))
light.set_light_color(unreal.LinearColor(*color, 1.0))
return light
def area(scene, name="Light", location=(200, -200, 350), target=(0, 0, 100),
intensity=12000, color=(1, 1, 1), size=180):
actor = scene.spawn(unreal.RectLight, name, location)
light = actor.get_component_by_class(unreal.RectLightComponent)
light.set_mobility(unreal.ComponentMobility.MOVABLE)
light.set_editor_property("intensity", float(intensity))
light = _setup(actor, unreal.RectLightComponent, intensity, color)
light.set_editor_property("source_width", float(size))
light.set_editor_property("source_height", float(size))
light.set_editor_property("attenuation_radius", 2000.0)
light.set_light_color(unreal.LinearColor(*color, 1.0))
look_at(actor, target)
return actor
def point(scene, name="Point", location=(0, 0, 250), intensity=10000, color=(1, 1, 1)):
"""Bulb shining in all directions."""
actor = scene.spawn(unreal.PointLight, name, location)
light = _setup(actor, unreal.PointLightComponent, intensity, color)
light.set_editor_property("attenuation_radius", 2000.0)
return actor
def spot(scene, name="Spot", location=(200, -200, 400), target=(0, 0, 100),
intensity=20000, color=(1, 1, 1), angle=30):
"""Cone of light; angle is measured in degrees from the cone axis."""
actor = scene.spawn(unreal.SpotLight, name, location)
light = _setup(actor, unreal.SpotLightComponent, intensity, color)
light.set_editor_property("attenuation_radius", 2000.0)
light.set_editor_property("outer_cone_angle", float(angle))
light.set_editor_property("inner_cone_angle", float(angle) * 0.7)
look_at(actor, target)
return actor
def sun(scene, name="Sun", location=(300, -300, 500), target=(0, 0, 0),
intensity=500, color=(1, 1, 1)):
"""Parallel light for the whole scene; only the location-target direction matters."""
actor = scene.spawn(unreal.DirectionalLight, name, location)
_setup(actor, unreal.DirectionalLightComponent, intensity, color)
look_at(actor, target)
return actor
def studio(scene, key_color=(1.0, 0.82, 0.65), fill_color=(0.5, 0.7, 1.0)):
return [area(scene, "Key", (200, -250, 350), color=key_color),
return [area(scene, "Key", (200, -500, 350), color=key_color),
area(scene, "Fill", (100, 300, 250), intensity=7000, color=fill_color),
area(scene, "Rim", (-200, 0, 300), intensity=15000)]
+58
View File
@@ -0,0 +1,58 @@
"""Transfer horizontal root motion to Sequencer actor keys without changing source clips."""
import math
import hashlib
import unreal
from .paths import GENERATED
def prepare(clip, child, scene_name):
component = child.skeletal_mesh_component
bone = component.get_bone_name(0)
count = int(clip.get_editor_property("number_of_sampled_keys"))
duration = clip.get_play_length()
if count < 2 or duration <= 0:
raise ValueError("keep_end_position wymaga animacji z co najmniej dwiema probkami.")
poses = [unreal.AnimationLibrary.get_bone_pose_for_time(
clip, bone, duration * index / (count - 1), False) for index in range(count)]
first = poses[0].translation
# Extract only horizontal travel. Keep vertical motion (jumps/bobbing),
# every root rotation and scale, and all other bone tracks unchanged.
positions = [unreal.Vector(first.x, first.y, pose.translation.z) for pose in poses]
folder = f"{GENERATED}/{scene_name}/RootMotion"
identity = hashlib.sha1(clip.get_path_name().encode("utf-8")).hexdigest()[:8]
name = clip.get_name() + "_" + identity + "_InPlace"
path = folder + "/" + name
if unreal.EditorAssetLibrary.does_asset_exist(path):
# This path contains only our generated derivative for this scene.
if not unreal.EditorAssetLibrary.delete_asset(path):
raise RuntimeError("Nie mozna odswiezyc wygenerowanego klipu " + path)
copy = unreal.AssetToolsHelpers.get_asset_tools().duplicate_asset(name, folder, clip)
if copy is None:
raise RuntimeError("Nie mozna utworzyc kopii animacji dla keep_end_position.")
controller = copy.get_editor_property("controller")
if not controller.set_bone_track_keys(bone, positions, [pose.rotation for pose in poses],
[pose.scale3d for pose in poses], should_transact=False):
raise RuntimeError("Nie mozna przeniesc ruchu kosci glownej do Transform.")
unreal.EditorAssetLibrary.save_loaded_asset(copy)
return copy, poses, duration
def displacement(poses, duration, elapsed, reverse=False):
"""Interpolate horizontal root travel, accumulating complete playback loops."""
elapsed = max(0.0, elapsed)
loops = math.floor(elapsed / duration)
time = elapsed - loops * duration
if reverse:
time = duration - time
index = min(time / duration * (len(poses) - 1), len(poses) - 1)
lower = int(index)
upper = min(lower + 1, len(poses) - 1)
alpha = index - lower
a, b = poses[lower].translation, poses[upper].translation
start, end = poses[0].translation, poses[-1].translation
if reverse:
start, end = end, start
return unreal.Vector(a.x + (b.x - a.x) * alpha - start.x + loops * (end.x - start.x),
a.y + (b.y - a.y) * alpha - start.y + loops * (end.y - start.y), 0)
+2
View File
@@ -111,6 +111,8 @@ class Scene:
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.
Also writes NameAnimations with durations to ue_workshop.animations for
editor completion, including custom models (name="Ninja" -> NinjaAnimations).
"""
from .skeletal import import_model
if height is not None and (not isinstance(height, (int, float)) or
+194 -9
View File
@@ -4,6 +4,16 @@ import math
import unreal
from .paths import GENERATED, slug
from .animation_clip import AnimationClip
def _key_mode(interpolation):
modes = {"linear": unreal.MovieSceneKeyInterpolation.LINEAR,
"auto": unreal.MovieSceneKeyInterpolation.AUTO,
"constant": unreal.MovieSceneKeyInterpolation.CONSTANT}
if interpolation not in modes:
raise ValueError("Interpolacja: linear, auto lub constant.")
return modes[interpolation]
class Sequence:
@@ -28,7 +38,11 @@ class Sequence:
self.asset.set_playback_end(frames)
self._bindings = {}
self._transforms = {}
self._light_tracks = {}
self._animations = {}
self._root_motion = {}
self._root_motion_copies = {}
self._pose_holds = {}
def bind(self, actor):
key = actor.get_path_name()
@@ -66,19 +80,41 @@ class Sequence:
weight.add_key(unreal.FrameNumber(frame), value,
interpolation=unreal.MovieSceneKeyInterpolation.LINEAR)
def animation(self, actor, clip=None, start=0, end=None, speed=1.0):
def animation(self, actor, clip: AnimationClip | str | unreal.AnimSequence | None = None,
start=0, end=None, speed=1.0, keep_end_position=False, reverse=False,
keep_end_pose=False):
"""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.
Pass a FighterAnimations/CreatureAnimations member, a filename key, an
AnimSequence, or None for the only available animation. With an enum and
no end, play once using its stored duration, sequence FPS and speed.
Strings/AnimSequence/None keep looping to the sequence end if end is omitted.
An explicit end sets the section range and can repeat the clip.
Overlapping clips automatically crossfade over their entire shared range.
Add clips in timeline order; at most two clips can overlap at a time.
keep_end_position transfers horizontal root travel into actor Transform
keys, keeping the final position for subsequent clips. Defaults to False.
reverse=True plays the clip backwards, including transferred root travel.
keep_end_pose=True retains the final pose with a frozen section after
playback and Sequencer Keep State. A later clip ends this pose hold.
"""
if type(start) is not int:
raise ValueError("start musi byc numerem klatki (liczba calkowita).")
if isinstance(clip, AnimationClip):
if end is None:
end = start + clip.frames(self.fps, speed)
clip = clip.key
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.")
if type(keep_end_position) is not bool:
raise ValueError("keep_end_position musi byc True lub False.")
if type(reverse) is not bool:
raise ValueError("reverse musi byc True lub False.")
if type(keep_end_pose) is not bool:
raise ValueError("keep_end_pose musi byc True lub False.")
entry = self.scene._skeletal_models.get(actor.get_path_name())
if entry is None:
raise ValueError("Animacja wymaga aktora utworzonego przez scene.skeletal_model().")
@@ -109,29 +145,97 @@ class Sequence:
previous = overlaps[0]
blend_frames = previous[1] - start
track = sections[0][2] if sections else self.bind(child).add_track(unreal.MovieSceneSkeletalAnimationTrack)
hold = self._pose_holds.pop(key, None)
if hold is not None:
hold_start, hold_section = hold
if start <= hold_start:
track.remove_section(hold_section)
else:
hold_section.set_range(hold_start, start)
if keep_end_position:
from .root_motion import prepare
source_key = clip.get_path_name()
if source_key not in self._root_motion_copies:
self._root_motion_copies[source_key] = prepare(
clip, child, self.scene.name + "/" + self.asset.get_name())
clip, poses, duration = self._root_motion_copies[source_key]
section = track.add_section()
section.set_range(start, end)
params = section.get_editor_property("params")
params.set_editor_property("animation", clip)
params.set_editor_property("reverse", reverse)
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)
# UE may rescale the range when play_rate changes. Restore the requested
# display-frame bounds after applying parameters (including speed).
section.set_range(start, end)
if keep_end_pose:
section.set_completion_mode(unreal.MovieSceneCompletionMode.KEEP_STATE)
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))
if keep_end_position:
self._keep_end_position(actor, child, start, end, speed, poses, duration, reverse)
if keep_end_pose and end < self.frames:
length = clip.get_play_length()
final_time = min((end - start) / self.fps * speed, length)
if reverse:
final_time = length - final_time
self._pose_holds[key] = (end, self._hold_pose(track, clip, end, final_time))
return section
def _hold_pose(self, track, clip, start, time):
section = track.add_section()
params = section.get_editor_property("params")
params.set_editor_property("animation", clip)
rate = unreal.MovieSceneTimeWarpVariant()
rate.set_fixed_play_rate(0.0)
params.set_editor_property("play_rate", rate)
ticks = self.asset.get_tick_resolution()
tick_rate = ticks.numerator / ticks.denominator
# Stay just inside the asset end to avoid modulo wrapping to frame zero.
time = max(0.0, min(time, clip.get_play_length() - 1 / tick_rate))
params.set_editor_property("first_loop_start_frame_offset", unreal.FrameNumber(round(time * tick_rate)))
params.set_editor_property("force_custom_mode", True)
section.set_editor_property("params", params)
section.set_range(start, self.frames)
section.set_completion_mode(unreal.MovieSceneCompletionMode.KEEP_STATE)
self._animation_blend(section, start, self.frames, 0, 0)
return section
def _keep_end_position(self, actor, child, start, end, speed, poses, duration, reverse=False):
from .root_motion import displacement
key = actor.get_path_name()
if key not in self._root_motion:
self._root_motion[key] = {
"origin": actor.get_actor_location(),
"mesh_transform": child.get_actor_transform(),
"clips": [],
}
motion = self._root_motion[key]
motion["clips"].append((start, end, speed, poses, duration, reverse))
mesh = motion["mesh_transform"]
zero = mesh.transform_location(unreal.Vector(0, 0, 0))
# These are ordinary Transform keys. The final key holds the position
# after playback; subsequent animation calls do not reset it.
first = min(item[0] for item in motion["clips"])
last = min(self.frames - 1, max(item[1] for item in motion["clips"]))
for frame in range(first, last + 1):
total = unreal.Vector(0, 0, 0)
for clip_start, clip_end, clip_speed, clip_poses, clip_duration, clip_reverse in motion["clips"]:
elapsed = min(max(frame - clip_start, 0), clip_end - clip_start) / self.fps * clip_speed
total += displacement(clip_poses, clip_duration, elapsed, reverse=clip_reverse)
world_delta = mesh.transform_location(total) - zero
self.transform_key(actor, frame, location=(motion["origin"] + world_delta).to_tuple())
def transform_key(self, actor, frame, location=None, rotation=None, scale=None, interpolation="linear"):
if not isinstance(frame, int) or not 0 <= frame < self.frames:
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.")
modes = {"linear": unreal.MovieSceneKeyInterpolation.LINEAR,
"auto": unreal.MovieSceneKeyInterpolation.AUTO,
"constant": unreal.MovieSceneKeyInterpolation.CONSTANT}
if interpolation not in modes:
raise ValueError("Interpolacja: linear, auto lub constant.")
mode = _key_mode(interpolation)
key = actor.get_path_name()
if key not in self._transforms:
track = self.bind(actor).add_track(unreal.MovieScene3DTransformTrack)
@@ -151,7 +255,88 @@ class Sequence:
if len(values) != 3:
raise ValueError("Transformacja wymaga trzech wartosci.")
for channel, value in zip(channels[offset:offset + 3], values):
channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=modes[interpolation])
channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=mode)
def light_key(self, actor, frame, color=None, intensity=None, interpolation="linear"):
"""Key a light's color (linear RGB 0-1) and/or intensity at a sequence frame."""
if not isinstance(frame, int) or not 0 <= frame < self.frames:
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.")
mode = _key_mode(interpolation)
if color is not None and len(color) != 3:
raise ValueError("Kolor wymaga trzech wartosci.")
light = actor.get_component_by_class(unreal.LightComponent)
if light is None:
raise ValueError("light_key wymaga aktora swiatla, np. z lighting.spot().")
if color is not None:
channels = self._light_channels(light, unreal.MovieSceneColorTrack, "LightColor")
for channel, value in zip(channels, color):
channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=mode)
if intensity is not None:
channels = self._light_channels(light, unreal.MovieSceneFloatTrack, "Intensity")
channels[0].add_key(unreal.FrameNumber(frame), float(intensity), interpolation=mode)
def _light_channels(self, light, track_class, name):
key = (light.get_path_name(), name)
if key not in self._light_tracks:
track = self.bind(light).add_track(track_class)
track.set_property_name_and_path(name, name)
section = track.add_section()
section.set_range(0, self.frames)
channels = section.get_all_channels()
if name == "LightColor":
channels[3].set_default(1.0) # Alpha is never keyed.
self._light_tracks[key] = channels
return self._light_tracks[key]
def position(self, actor, frame: int) -> tuple[float, float, float]:
"""Actor Transform position at a sequence frame, including keep_end_position.
Evaluates Sequencer curves without moving the playhead. Does not include
motion of bones inside an animation that was not transferred to Transform.
"""
if type(frame) is not int or not 0 <= frame < self.frames:
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.")
channels = self._transforms.get(actor.get_path_name())
if channels is None:
return actor.get_actor_location().to_tuple()
interval = self.asset.make_range(frame, 1)
rate = unreal.FrameRate(self.fps, 1)
values = [channel.evaluate_keys(interval, rate) for channel in channels[:3]]
if any(not axis for axis in values):
raise RuntimeError("Nie mozna odczytac pozycji aktora z kanalow Transform.")
return tuple(float(axis[0]) for axis in values)
def move(self, actor, start: int, end: int, offset: tuple[float, float, float],
interpolation="auto") -> tuple[float, float, float]:
"""Move by offset (world cm) from the actor's position at start.
Replaces position keys in the range and shifts later position keys by
the resulting change, preserving later motion. Call after animation().
Returns the target position. Rotation/scale keys are kept.
"""
if (type(start) is not int or type(end) is not int or
not 0 <= start < end < self.frames):
raise ValueError(f"Ruch wymaga 0 <= start < end < {self.frames}.")
if interpolation not in {"linear", "auto", "constant"}:
raise ValueError("Interpolacja: linear, auto lub constant.")
if len(offset) != 3 or any(not math.isfinite(value) for value in offset):
raise ValueError("offset wymaga trzech skonczonych wartosci w centymetrach.")
origin = self.position(actor, start)
target = tuple(value + delta for value, delta in zip(origin, offset))
channels = self._transforms.get(actor.get_path_name())
if channels is not None:
previous_end = self.position(actor, end)
for axis, channel in enumerate(channels[:3]):
correction = target[axis] - previous_end[axis]
for key in list(channel.get_keys()):
time = key.get_time().frame_number.value
if start <= time <= end:
channel.remove_key(key)
elif time > end:
key.set_value(key.get_value() + correction)
self.transform_key(actor, start, location=origin, interpolation=interpolation)
self.transform_key(actor, end, location=target, interpolation=interpolation)
return target
def save(self, open_editor=True):
self.scene.save()
+5 -1
View File
@@ -6,6 +6,7 @@ import unreal
from .paths import GENERATED, PROJECT, slug
from .characters import _clips
from .animation_catalog import update_catalog
@dataclass
@@ -111,7 +112,8 @@ def import_model(filename, name="Character", animations=None, collection="Charac
"""
source = Path(filename).expanduser()
source = (source if source.is_absolute() else PROJECT / source).resolve()
if animations is None:
discover_all = animations is None
if discover_all:
animations = _clips(source)
name = slug(name)
destination = f"{GENERATED}/Skeletal/{name}"
@@ -131,4 +133,6 @@ def import_model(filename, name="Character", animations=None, collection="Charac
for alias, filename in (animations or {}).items():
clips[alias] = import_animation(filename, skeleton, name=slug(alias),
destination=destination + "/Animations", reimport=reimport)
update_catalog(name, {key: clip.get_play_length() for key, clip in clips.items()},
replace=discover_all)
return SkeletalImport(meshes[0], clips)