Skeletal animation demo

articulated arm with bone-driven wave motion.

▶ Run in browser

Tags: 3d

Demonstrates:

  • Skeleton with a chain of 4 bones (root, upper_arm, forearm, hand)

  • SkeletalAnimationClip with BoneTracks for rotation keyframes

  • MeshInstance3D cubes transformed by joint matrices each frame

  • Smooth sinusoidal wave animation through the bone chain

Run: uv run python examples/features/3d/skeletal.py

Controls: Left/Right - Orbit camera Up/Down - Zoom in/out Space - Pause/resume animation Escape - Quit

Source

  1"""Skeletal animation demo -- articulated arm with bone-driven wave motion.
  2
  3Demonstrates:
  4  - Skeleton with a chain of 4 bones (root, upper_arm, forearm, hand)
  5  - SkeletalAnimationClip with BoneTracks for rotation keyframes
  6  - MeshInstance3D cubes transformed by joint matrices each frame
  7  - Smooth sinusoidal wave animation through the bone chain
  8
  9Run: uv run python examples/features/3d/skeletal.py
 10
 11Controls:
 12    Left/Right - Orbit camera
 13    Up/Down    - Zoom in/out
 14    Space      - Pause/resume animation
 15    Escape     - Quit
 16"""
 17
 18
 19import math
 20
 21import numpy as np
 22
 23from simvx.core import (
 24    Bone,
 25    BoneTrack,
 26    Camera3D,
 27    DirectionalLight3D,
 28    Input,
 29    InputMap,
 30    Key,
 31    Material,
 32    Mesh,
 33    MeshInstance3D,
 34    Node,
 35    SkeletalAnimationClip,
 36    Skeleton,
 37    Vec3,
 38)
 39from simvx.core.math import translate
 40from simvx.graphics import App
 41
 42BONE_LENGTH = 2.0
 43BONE_NAMES = ["root", "upper_arm", "forearm", "hand"]
 44BONE_COLOURS = [
 45    (0.6, 0.6, 0.6, 1),  # root: grey
 46    (0.9, 0.3, 0.2, 1),  # upper_arm: red
 47    (0.2, 0.7, 0.3, 1),  # forearm: green
 48    (0.2, 0.4, 0.9, 1),  # hand: blue
 49]
 50
 51
 52def _build_skeleton() -> Skeleton:
 53    """Create a 4-bone chain offset along X."""
 54    skel = Skeleton(name="ArmSkeleton")
 55    for i, bname in enumerate(BONE_NAMES):
 56        parent_idx = i - 1 if i > 0 else -1
 57        local = translate((BONE_LENGTH, 0, 0)) if i > 0 else np.eye(4, dtype=np.float32)
 58        inv_bind = np.linalg.inv(local) if i > 0 else np.eye(4, dtype=np.float32)
 59        skel.add_bone(Bone(name=bname, parent_index=parent_idx, inverse_bind_matrix=inv_bind, local_transform=local))
 60    return skel
 61
 62
 63def _build_wave_clip(duration: float = 2.0) -> SkeletalAnimationClip:
 64    """Create a wave animation -- each bone rotates with a phase offset."""
 65    clip = SkeletalAnimationClip(name="wave", duration=duration)
 66    max_angle = math.radians(35)
 67    for bone_idx in range(1, len(BONE_NAMES)):
 68        track = BoneTrack(bone_index=bone_idx)
 69        steps = 16
 70        for s in range(steps + 1):
 71            t = (s / steps) * duration
 72            phase = bone_idx * 0.8
 73            angle = max_angle * math.sin(2 * math.pi * t / duration + phase)
 74            # Position: preserve bone offset along X
 75            track.position_keys.append((t, np.array([BONE_LENGTH, 0, 0], dtype=np.float32)))
 76            # Quaternion (xyzw): rotate around Z axis
 77            ha = angle * 0.5
 78            quat = np.array([0, 0, math.sin(ha), math.cos(ha)], dtype=np.float32)
 79            track.rotation_keys.append((t, quat))
 80        clip.add_bone_track(track)
 81    return clip
 82
 83
 84class SkeletalDemo(Node):
 85    """Root scene for skeletal animation demo."""
 86
 87    def on_ready(self):
 88        InputMap.add_action("orbit_left", [Key.LEFT])
 89        InputMap.add_action("orbit_right", [Key.RIGHT])
 90        InputMap.add_action("zoom_in", [Key.UP])
 91        InputMap.add_action("zoom_out", [Key.DOWN])
 92        InputMap.add_action("pause", [Key.SPACE])
 93
 94        # Camera
 95        self._cam = self.add_child(Camera3D(name="Camera"))
 96        self._cam.position = Vec3(4, 4, 10)
 97        self._cam.look_at(Vec3(3, 1, 0))
 98        self._orbit_angle = 0.0
 99        self._zoom = 10.0
100
101        # Light
102        light = self.add_child(DirectionalLight3D(name="Sun"))
103        light.direction = Vec3(-0.3, -1, -0.5)
104
105        # Skeleton
106        self._skeleton = self.add_child(_build_skeleton())
107        self._clip = _build_wave_clip(duration=2.0)
108        self._anim_time = 0.0
109        self._paused = False
110
111        # Bone visualisation cubes
112        self._bone_meshes: list[MeshInstance3D] = []
113        for i, bname in enumerate(BONE_NAMES):
114            mesh = MeshInstance3D(name=f"Bone_{bname}")
115            mesh.mesh = Mesh.cube()
116            mesh.material = Material(colour=BONE_COLOURS[i])
117            mesh.scale = np.array([BONE_LENGTH * 0.45, 0.3, 0.3], dtype=np.float32)
118            self.add_child(mesh)
119            self._bone_meshes.append(mesh)
120
121        from simvx.core import Text2D
122        self.add_child(Text2D(name="HUD", text="Skeletal Animation: L/R orbit | U/D zoom | Space pause | ESC quit",
123                               x=10, y=10, font_scale=1.2))
124
125    def on_process(self, dt: float):
126        # Animation playback
127        if not self._paused:
128            self._anim_time = (self._anim_time + dt) % self._clip.duration
129
130        # Evaluate clip → local transforms per bone
131        pose = self._clip.evaluate(self._anim_time)
132
133        # Compute world transforms by chaining parent * local
134        bones = self._skeleton.bones
135        world = [np.eye(4, dtype=np.float32) for _ in bones]
136        for i, bone in enumerate(bones):
137            local = pose.get(i, bone.local_transform)
138            if bone.parent_index >= 0:
139                world[i] = world[bone.parent_index] @ local
140            else:
141                world[i] = local.copy()
142
143        # Position cubes at bone midpoints
144        for i, mesh in enumerate(self._bone_meshes):
145            w = world[i]
146            # Midpoint offset along local X
147            mid = w @ translate((BONE_LENGTH * 0.5, 0, 0))
148            mesh.position = Vec3(float(mid[0, 3]), float(mid[1, 3]), float(mid[2, 3]))
149
150        # Camera orbit
151        if Input.is_action_pressed("orbit_left"):
152            self._orbit_angle -= dt
153        if Input.is_action_pressed("orbit_right"):
154            self._orbit_angle += dt
155        if Input.is_action_pressed("zoom_in"):
156            self._zoom = max(4.0, self._zoom - dt * 5)
157        if Input.is_action_pressed("zoom_out"):
158            self._zoom = min(20.0, self._zoom + dt * 5)
159
160        cx = BONE_LENGTH * len(BONE_NAMES) * 0.5
161        self._cam.position = Vec3(
162            cx + math.sin(self._orbit_angle) * self._zoom,
163            4.0,
164            math.cos(self._orbit_angle) * self._zoom,
165        )
166        self._cam.look_at(Vec3(cx, 1, 0))
167
168        # Pause toggle
169        if Input.is_action_just_pressed("pause"):
170            self._paused = not self._paused
171
172
173def main():
174    app = App(width=1280, height=720, title="Skeletal Animation Demo")
175    app.run(SkeletalDemo())
176
177
178if __name__ == "__main__":
179    main()