diff --git a/addon_examples/local_anim.py b/addon_examples/local_anim.py index a2b49b3..b582dec 100644 --- a/addon_examples/local_anim.py +++ b/addon_examples/local_anim.py @@ -5,6 +5,7 @@ Local animations assuming you loaded something.anim /524 start_local_anim something /524 stop_local_anim something +/524 save_local_anim something If you want to trigger the animation from an object to simulate llStartAnimation(): llOwnerSay("@start_local_anim:something=force"); @@ -21,6 +22,7 @@ bulk upload, like changing priority or removing a joint. import asyncio import os import pathlib +from abc import abstractmethod from typing import * from hippolyzer.lib.base import serialization as se @@ -47,6 +49,8 @@ def _get_mtime(path: str): class LocalAnimAddon(BaseAddon): # name -> path, only for anims actually from files local_anim_paths: Dict[str, str] = SessionProperty(dict) + # name -> anim bytes + local_anim_bytes: Dict[str, bytes] = SessionProperty(dict) # name -> mtime or None. Only for anims from files. local_anim_mtimes: Dict[str, Optional[float]] = SessionProperty(dict) # name -> current asset ID (changes each play) @@ -83,6 +87,18 @@ class LocalAnimAddon(BaseAddon): """Stop a named local animation""" self.apply_local_anim(session, region, anim_name, new_data=None) + @handle_command(anim_name=str) + async def save_local_anim(self, _session: Session, _region: ProxiedRegion, anim_name: str): + """Save a named local anim to disk""" + anim_bytes = self.local_anim_bytes.get(anim_name) + if not anim_bytes: + return + filename = await AddonManager.UI.save_file(filter_str="SL Anim (*.anim)", default_suffix="anim") + if not filename: + return + with open(filename, "wb") as f: + f.write(anim_bytes) + async def _try_reload_anims(self, session: Session): while True: region = session.main_region @@ -144,9 +160,11 @@ class LocalAnimAddon(BaseAddon): StartAnim=True, )) cls.local_anim_playing_ids[anim_name] = next_id + cls.local_anim_bytes[anim_name] = new_data else: # No data means just stop the anim cls.local_anim_playing_ids.pop(anim_name, None) + cls.local_anim_bytes.pop(anim_name, None) region.circuit.send_message(new_msg) print(f"Changing {anim_name} to {next_id}") @@ -216,7 +234,7 @@ class LocalAnimAddon(BaseAddon): class BaseAnimManglerAddon(BaseAddon): - """Base class for addons that mangle uploaded or local animations""" + """Base class for addons that mangle uploaded or file-based local animations""" ANIM_MANGLERS: List[Callable[[Animation], Animation]] def handle_init(self, session_manager: SessionManager): @@ -233,4 +251,34 @@ class BaseAnimManglerAddon(BaseAddon): LocalAnimAddon.remangle_local_anims(session_manager) +class BaseAnimHelperAddon(BaseAddon): + """ + Base class for local creation of procedural animations + + Animation generated by build_anim() gets applied to all active sessions + """ + ANIM_NAME: str + + def handle_session_init(self, session: Session): + self._reapply_anim(session, session.main_region) + + def handle_session_closed(self, session: Session): + LocalAnimAddon.apply_local_anim(session, session.main_region, self.ANIM_NAME, None) + + def handle_unload(self, session_manager: SessionManager): + for session in session_manager.sessions: + # TODO: Nasty. Since we need to access session-local attrs we need to set the + # context even though we also explicitly pass session and region. + # Need to rethink the LocalAnimAddon API. + with addon_ctx.push(session, session.main_region): + LocalAnimAddon.apply_local_anim(session, session.main_region, self.ANIM_NAME, None) + + @abstractmethod + def build_anim(self) -> Animation: + pass + + def _reapply_anim(self, session: Session, region: ProxiedRegion): + LocalAnimAddon.apply_local_anim(session, region, self.ANIM_NAME, self.build_anim().to_bytes()) + + addons = [LocalAnimAddon()] diff --git a/addon_examples/tail_anim.py b/addon_examples/tail_anim.py new file mode 100644 index 0000000..3cf8d93 --- /dev/null +++ b/addon_examples/tail_anim.py @@ -0,0 +1,55 @@ +""" +Tail animation generator + +Demonstrates programmatic generation of local motions using BaseAnimHelperAddon + +You can use this to create an animation with a script, fiddle with it until it +looks right, then finally save it with /524 save_local_anim . + +The built animation is automatically applied to all active sessions when loaded, +and is re-generated whenever the script is edited. Unloading the script stops +the animations. +""" + +from hippolyzer.lib.base.anim_utils import shift_keyframes, smooth_rot +from hippolyzer.lib.base.datatypes import Quaternion +from hippolyzer.lib.base.llanim import Animation, Joint +from hippolyzer.lib.proxy.addons import AddonManager + +import local_anim +AddonManager.hot_reload(local_anim, require_addons_loaded=True) + + +class TailAnimator(local_anim.BaseAnimHelperAddon): + # Should be unique + ANIM_NAME = "tail_anim" + + def build_anim(self) -> Animation: + anim = Animation( + base_priority=5, + duration=5.0, + loop_out_point=5.0, + loop=True, + ) + # Iterate along tail joints 1 through 6 + for joint_num in range(1, 7): + # Give further along joints a wider range of motion + start_rot = Quaternion.from_euler(0.2, -0.3, 0.15 * joint_num) + end_rot = Quaternion.from_euler(-0.2, -0.3, -0.15 * joint_num) + rot_keyframes = [ + # Tween between start_rot and end_rot, using smooth interpolation. + # SL's keyframes only allow linear interpolation which doesn't look great + # for natural motions. `smooth_rot()` gets around that by generating + # smooth inter frames for SL to linearly interpolate between. + *smooth_rot(start_rot, end_rot, inter_frames=10, time=0.0, duration=2.5), + *smooth_rot(end_rot, start_rot, inter_frames=10, time=2.5, duration=2.5), + ] + anim.joints[f"mTail{joint_num}"] = Joint( + priority=5, + # Each joint's frames should be ahead of the previous joint's by 2 frames + rot_keyframes=shift_keyframes(rot_keyframes, joint_num * 2), + ) + return anim + + +addons = [TailAnimator()] diff --git a/hippolyzer/lib/base/anim_utils.py b/hippolyzer/lib/base/anim_utils.py new file mode 100644 index 0000000..42855ac --- /dev/null +++ b/hippolyzer/lib/base/anim_utils.py @@ -0,0 +1,91 @@ +""" +Assorted utilities to make creating animations from scratch easier +""" + +import copy +from typing import List, Union + +from hippolyzer.lib.base.datatypes import Vector3, Quaternion +from hippolyzer.lib.base.llanim import PosKeyframe, RotKeyframe + + +def smooth_step(t: float): + t = max(0.0, min(1.0, t)) + return t * t * (3 - 2 * t) + + +def rot_interp(r0: Quaternion, r1: Quaternion, t: float): + """ + Bad quaternion interpolation + + TODO: This is definitely not correct yet seems to work ok? Implement slerp. + """ + # Ignore W + r0 = r0.data(3) + r1 = r1.data(3) + return Quaternion(*map(lambda pair: ((pair[0] * (1.0 - t)) + (pair[1] * t)), zip(r0, r1))) + + +def unique_frames(frames: List[Union[PosKeyframe, RotKeyframe]]): + """Drop frames where time and coordinate are exact duplicates of another frame""" + new_frames = [] + for frame in frames: + # TODO: fudge factor for float comparison instead + if frame not in new_frames: + new_frames.append(frame) + return new_frames + + +def shift_keyframes(frames: List[Union[PosKeyframe, RotKeyframe]], num: int): + """ + Shift keyframes around by `num` frames + + Assumes keyframes occur at a set cadence, and that first and last keyframe are at the same coord. + """ + + # Get rid of duplicate frames + frames = unique_frames(frames) + pop_idx = -1 + insert_idx = 0 + if num < 0: + insert_idx = len(frames) - 1 + pop_idx = 0 + num = -num + old_times = [f.time for f in frames] + new_frames = frames.copy() + # Drop last, duped frame. We'll copy the first frame to replace it later + new_frames.pop(-1) + for _ in range(num): + new_frames.insert(insert_idx, new_frames.pop(pop_idx)) + + # Put first frame back on the end + new_frames.append(copy.copy(new_frames[0])) + + assert len(old_times) == len(new_frames) + assert new_frames[0] == new_frames[-1] + # Make the times of the shifted keyframes match up with the previous timeline + for old_time, new_frame in zip(old_times, new_frames): + new_frame.time = old_time + return new_frames + + +def smooth_pos(start: Vector3, end: Vector3, inter_frames: int, time: float, duration: float) -> List[PosKeyframe]: + """Generate keyframes to smoothly interpolate between two positions""" + frames = [PosKeyframe(time=time, pos=start)] + for i in range(0, inter_frames): + t = (i + 1) / (inter_frames + 1) + smooth_t = smooth_step(t) + pos = Vector3(smooth_t, smooth_t, smooth_t).interpolate(start, end) + frames.append(PosKeyframe(time=time + (t * duration), pos=pos)) + return frames + [PosKeyframe(time=time + duration, pos=end)] + + +def smooth_rot(start: Quaternion, end: Quaternion, inter_frames: int, time: float, duration: float)\ + -> List[RotKeyframe]: + """Generate keyframes to smoothly interpolate between two rotations""" + frames = [RotKeyframe(time=time, rot=start)] + for i in range(0, inter_frames): + t = (i + 1) / (inter_frames + 1) + smooth_t = smooth_step(t) + frames.append(RotKeyframe(time=time + (t * duration), rot=rot_interp(start, end, smooth_t))) + return frames + [RotKeyframe(time=time + duration, rot=end)]