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 segments driven by Skeleton.compute_pose world transforms

  • 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 segments driven by Skeleton.compute_pose world transforms
  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    Quat,
 36    SkeletalAnimationClip,
 37    Skeleton,
 38    Vec3,
 39    WorldEnvironment,
 40)
 41from simvx.core.math import translate
 42from simvx.graphics import App
 43
 44BONE_LENGTH = 2.0
 45BONE_NAMES = ["root", "upper_arm", "forearm", "hand"]
 46BONE_COLOURS = [
 47    (0.6, 0.6, 0.6, 1),  # root: grey
 48    (0.9, 0.3, 0.2, 1),  # upper_arm: red
 49    (0.2, 0.7, 0.3, 1),  # forearm: green
 50    (0.2, 0.4, 0.9, 1),  # hand: blue
 51]
 52
 53
 54def _build_skeleton() -> Skeleton:
 55    """Create a 4-bone chain offset along X."""
 56    skel = Skeleton(name="ArmSkeleton")
 57    for i, bname in enumerate(BONE_NAMES):
 58        parent_idx = i - 1 if i > 0 else -1
 59        local = translate((BONE_LENGTH, 0, 0)) if i > 0 else np.eye(4, dtype=np.float32)
 60        inv_bind = np.linalg.inv(local) if i > 0 else np.eye(4, dtype=np.float32)
 61        skel.add_bone(Bone(name=bname, parent_index=parent_idx, inverse_bind_matrix=inv_bind, local_transform=local))
 62    return skel
 63
 64
 65def _build_wave_clip(duration: float = 2.0) -> SkeletalAnimationClip:
 66    """Create a wave animation -- each bone rotates with a phase offset."""
 67    clip = SkeletalAnimationClip(name="wave", duration=duration)
 68    max_angle = math.radians(35)
 69    for bone_idx in range(1, len(BONE_NAMES)):
 70        track = BoneTrack(bone_index=bone_idx)
 71        steps = 16
 72        for s in range(steps + 1):
 73            t = (s / steps) * duration
 74            phase = bone_idx * 0.8
 75            angle = max_angle * math.sin(2 * math.pi * t / duration + phase)
 76            # Position: preserve bone offset along X
 77            track.position_keys.append((t, np.array([BONE_LENGTH, 0, 0], dtype=np.float32)))
 78            # Quaternion (xyzw): rotate around Z axis
 79            ha = angle * 0.5
 80            quat = np.array([0, 0, math.sin(ha), math.cos(ha)], dtype=np.float32)
 81            track.rotation_keys.append((t, quat))
 82        clip.add_bone_track(track)
 83    return clip
 84
 85
 86class SkeletalDemo(Node):
 87    """Root scene for skeletal animation demo."""
 88
 89    def on_ready(self):
 90        InputMap.add_action("orbit_left", [Key.LEFT])
 91        InputMap.add_action("orbit_right", [Key.RIGHT])
 92        InputMap.add_action("zoom_in", [Key.UP])
 93        InputMap.add_action("zoom_out", [Key.DOWN])
 94        InputMap.add_action("pause", [Key.SPACE])
 95        InputMap.add_action("quit", [Key.ESCAPE])
 96
 97        # Camera
 98        self._cam = self.add_child(Camera3D(name="Camera"))
 99        self._cam.position = Vec3(4, 4, 10)
100        self._cam.look_at(Vec3(3, 1, 0))
101        self._orbit_angle = 0.0
102        self._zoom = 10.0
103
104        # Light + ambient fill so unlit faces stay readable
105        light = self.add_child(DirectionalLight3D(name="Sun"))
106        light.direction = Vec3(-0.3, -1, -0.5)
107        env = self.add_child(WorldEnvironment())
108        env.ambient_light_colour = (0.18, 0.19, 0.22, 1.0)
109
110        # Ground plane so the arm's wave has a spatial reference instead of a black void
111        self.add_child(MeshInstance3D(
112            name="Ground",
113            mesh=Mesh.cube(),
114            material=Material(colour=(0.30, 0.32, 0.36, 1.0), roughness=0.9, metallic=0.0),
115            position=(BONE_LENGTH * len(BONE_NAMES) * 0.5, -5.3, 0),
116            scale=(40, 0.1, 40),
117        ))
118
119        # Skeleton
120        self._skeleton = self.add_child(_build_skeleton())
121        self._clip = _build_wave_clip(duration=2.0)
122        self._anim_time = 0.0
123        self._paused = False
124
125        # Bone visualisation: one near-full-length segment per bone...
126        self._bone_meshes: list[MeshInstance3D] = []
127        for i, bname in enumerate(BONE_NAMES):
128            mesh = MeshInstance3D(name=f"Bone_{bname}")
129            mesh.mesh = Mesh.cube()
130            mesh.material = Material(colour=BONE_COLOURS[i])
131            mesh.scale = np.array([BONE_LENGTH * 0.9, 0.3, 0.3], dtype=np.float32)
132            self.add_child(mesh)
133            self._bone_meshes.append(mesh)
134
135        # ...plus joint spheres (one per bone origin and one at the fingertip)
136        joint_mesh = Mesh.sphere(radius=0.25)
137        self._joint_meshes: list[MeshInstance3D] = []
138        for i in range(len(BONE_NAMES) + 1):
139            sphere = self.add_child(MeshInstance3D(
140                name=f"Joint_{i}",
141                mesh=joint_mesh,
142                material=Material(colour=(0.85, 0.85, 0.9, 1)),
143            ))
144            self._joint_meshes.append(sphere)
145
146        from simvx.core import Text2D
147        self.add_child(Text2D(name="HUD", text="Skeletal Animation: L/R orbit | U/D zoom | Space pause | ESC quit",
148                               position=(10, 10), font_scale=1.2))
149
150    def on_update(self, dt: float):
151        # Animation playback
152        if not self._paused:
153            self._anim_time = (self._anim_time + dt) % self._clip.duration
154
155        # Evaluate clip -> local transforms, let the skeleton chain them to world space
156        pose = self._clip.evaluate(self._anim_time)
157        self._skeleton.compute_pose(pose)
158
159        # Place each segment at its bone midpoint, oriented to the bone's world rotation
160        for i, mesh in enumerate(self._bone_meshes):
161            w = self._skeleton.get_bone_global_transform(i)
162            mid = w @ translate((BONE_LENGTH * 0.5, 0, 0))
163            mesh.position = Vec3(float(mid[0, 3]), float(mid[1, 3]), float(mid[2, 3]))
164            # The wave rotates purely around Z, so the angle falls straight out of the matrix
165            mesh.rotation = Quat.from_axis_angle(Vec3(0, 0, 1), math.atan2(float(w[1, 0]), float(w[0, 0])))
166            self._joint_meshes[i].position = Vec3(float(w[0, 3]), float(w[1, 3]), float(w[2, 3]))
167
168        # Fingertip joint at the end of the last bone
169        tip = self._skeleton.get_bone_global_transform(len(BONE_NAMES) - 1) @ translate((BONE_LENGTH, 0, 0))
170        self._joint_meshes[-1].position = Vec3(float(tip[0, 3]), float(tip[1, 3]), float(tip[2, 3]))
171
172        # Camera orbit
173        if Input.is_action_pressed("orbit_left"):
174            self._orbit_angle -= dt
175        if Input.is_action_pressed("orbit_right"):
176            self._orbit_angle += dt
177        if Input.is_action_pressed("zoom_in"):
178            self._zoom = max(4.0, self._zoom - dt * 5)
179        if Input.is_action_pressed("zoom_out"):
180            self._zoom = min(20.0, self._zoom + dt * 5)
181
182        cx = BONE_LENGTH * len(BONE_NAMES) * 0.5
183        self._cam.position = Vec3(
184            cx + math.sin(self._orbit_angle) * self._zoom,
185            4.0,
186            math.cos(self._orbit_angle) * self._zoom,
187        )
188        self._cam.look_at(Vec3(cx, 1, 0))
189
190        # Pause toggle
191        if Input.is_action_just_pressed("pause"):
192            self._paused = not self._paused
193
194        # Quit
195        if Input.is_action_just_pressed("quit"):
196            self.app.quit()
197
198
199def main():
200    app = App(width=1280, height=720, title="Skeletal Animation Demo")
201    app.run(SkeletalDemo())
202
203
204if __name__ == "__main__":
205    main()