Skeletal Animation

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

The segments here are placed by hand so the bone transforms are visible. For the production path, import a rigged glTF with import_gltf, which attaches the parsed Skeleton to the skinned MeshInstance3D, and drive it with an AnimationPlayer so the GPU skins the real mesh: see animated_model.py.

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 -- 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
  9The segments here are placed by hand so the bone transforms are visible. For
 10the production path, import a rigged glTF with `import_gltf`, which attaches the
 11parsed Skeleton to the skinned `MeshInstance3D`, and drive it with an
 12`AnimationPlayer` so the GPU skins the real mesh: see animated_model.py.
 13
 14Run: uv run python examples/features/3d/skeletal.py
 15
 16Controls:
 17    Left/Right - Orbit camera
 18    Up/Down    - Zoom in/out
 19    Space      - Pause/resume animation
 20    Escape     - Quit
 21"""
 22
 23import math
 24
 25import numpy as np
 26
 27from simvx.core import (
 28    Bone,
 29    BoneTrack,
 30    Camera3D,
 31    DirectionalLight3D,
 32    Input,
 33    InputMap,
 34    Key,
 35    Material,
 36    Mesh,
 37    MeshInstance3D,
 38    Node,
 39    Quat,
 40    SkeletalAnimationClip,
 41    Skeleton,
 42    Text2D,
 43    Vec3,
 44    WorldEnvironment,
 45)
 46from simvx.core.math import translate
 47from simvx.graphics import App
 48
 49BONE_LENGTH = 2.0
 50BONE_NAMES = ["root", "upper_arm", "forearm", "hand"]
 51BONE_COLOURS = [
 52    (0.6, 0.6, 0.6, 1),  # root: grey
 53    (0.9, 0.3, 0.2, 1),  # upper_arm: red
 54    (0.2, 0.7, 0.3, 1),  # forearm: green
 55    (0.2, 0.4, 0.9, 1),  # hand: blue
 56]
 57
 58
 59def _build_skeleton() -> Skeleton:
 60    """Create a 4-bone chain offset along X."""
 61    skel = Skeleton(name="ArmSkeleton")
 62    for i, bname in enumerate(BONE_NAMES):
 63        parent_idx = i - 1 if i > 0 else -1
 64        local = translate((BONE_LENGTH, 0, 0)) if i > 0 else np.eye(4, dtype=np.float32)
 65        inv_bind = np.linalg.inv(local) if i > 0 else np.eye(4, dtype=np.float32)
 66        skel.add_bone(Bone(name=bname, parent_index=parent_idx, inverse_bind_matrix=inv_bind, local_transform=local))
 67    return skel
 68
 69
 70def _build_wave_clip(duration: float = 2.0) -> SkeletalAnimationClip:
 71    """Create a wave animation -- each bone rotates with a phase offset."""
 72    clip = SkeletalAnimationClip(name="wave", duration=duration)
 73    max_angle = math.radians(35)
 74    for bone_idx in range(1, len(BONE_NAMES)):
 75        track = BoneTrack(bone_index=bone_idx)
 76        steps = 16
 77        for s in range(steps + 1):
 78            t = (s / steps) * duration
 79            phase = bone_idx * 0.8
 80            angle = max_angle * math.sin(2 * math.pi * t / duration + phase)
 81            # Rotation-only track: keys go in through add_rotation_key, which
 82            # coerces dtype and keeps the list time-sorted. Keyframe quaternions
 83            # are xyzw, while Quat stores w first, hence the explicit order.
 84            q = Quat.from_axis_angle(Vec3(0, 0, 1), angle)
 85            track.add_rotation_key(t, (q.x, q.y, q.z, q.w))
 86        clip.add_bone_track(track)
 87    return clip
 88
 89
 90def _rest_poses() -> dict[int, tuple[np.ndarray, np.ndarray, np.ndarray]]:
 91    """Bind-pose TRS per animated bone, as ``clip.evaluate`` wants it.
 92
 93    The wave clip animates rotation only. Handing the rest pose to ``evaluate``
 94    keeps each bone at its bind translation along the chain; without it, a track
 95    with no position keys falls back to the origin and the arm collapses.
 96    """
 97    translation = np.array([BONE_LENGTH, 0.0, 0.0], dtype=np.float32)
 98    identity = np.array([0.0, 0.0, 0.0, 1.0], dtype=np.float32)
 99    scale = np.ones(3, dtype=np.float32)
100    return dict.fromkeys(range(1, len(BONE_NAMES)), (translation, identity, scale))
101
102
103class SkeletalDemo(Node):
104    """Root scene for skeletal animation demo."""
105
106    def on_ready(self):
107        InputMap.add_action("orbit_left", [Key.LEFT])
108        InputMap.add_action("orbit_right", [Key.RIGHT])
109        InputMap.add_action("zoom_in", [Key.UP])
110        InputMap.add_action("zoom_out", [Key.DOWN])
111        InputMap.add_action("pause", [Key.SPACE])
112        InputMap.add_action("quit", [Key.ESCAPE])
113
114        # Camera
115        self._cam = self.add_child(Camera3D(name="Camera"))
116        self._cam.position = Vec3(4, 4, 10)
117        self._cam.look_at(Vec3(3, 1, 0))
118        self._orbit_angle = 0.0
119        self._zoom = 10.0
120
121        # Light + ambient fill so unlit faces stay readable
122        light = self.add_child(DirectionalLight3D(name="Sun"))
123        light.direction = Vec3(-0.3, -1, -0.5)
124        env = self.add_child(WorldEnvironment())
125        env.ambient_light_colour = (0.18, 0.19, 0.22, 1.0)
126
127        # Ground plane so the arm's wave has a spatial reference instead of a black void
128        self.add_child(
129            MeshInstance3D(
130                name="Ground",
131                mesh=Mesh.cube(),
132                material=Material(colour=(0.30, 0.32, 0.36, 1.0), roughness=0.9, metallic=0.0),
133                position=(BONE_LENGTH * len(BONE_NAMES) * 0.5, -5.3, 0),
134                scale=(40, 0.1, 40),
135            )
136        )
137
138        # Skeleton
139        self._skeleton = self.add_child(_build_skeleton())
140        self._clip = _build_wave_clip(duration=2.0)
141        self._rest_poses = _rest_poses()
142        self._anim_time = 0.0
143        self._paused = False
144
145        # Bone visualisation: one near-full-length segment per bone...
146        self._bone_meshes: list[MeshInstance3D] = []
147        for i, bname in enumerate(BONE_NAMES):
148            mesh = MeshInstance3D(name=f"Bone_{bname}")
149            mesh.mesh = Mesh.cube()
150            mesh.material = Material(colour=BONE_COLOURS[i])
151            mesh.scale = np.array([BONE_LENGTH * 0.9, 0.3, 0.3], dtype=np.float32)
152            self.add_child(mesh)
153            self._bone_meshes.append(mesh)
154
155        # ...plus joint spheres (one per bone origin and one at the fingertip)
156        joint_mesh = Mesh.sphere(radius=0.25)
157        self._joint_meshes: list[MeshInstance3D] = []
158        for i in range(len(BONE_NAMES) + 1):
159            sphere = self.add_child(
160                MeshInstance3D(
161                    name=f"Joint_{i}",
162                    mesh=joint_mesh,
163                    material=Material(colour=(0.85, 0.85, 0.9, 1)),
164                )
165            )
166            self._joint_meshes.append(sphere)
167
168        self.add_child(
169            Text2D(
170                name="HUD",
171                text="Skeletal Animation: L/R orbit | U/D zoom | Space pause | ESC quit",
172                position=(10, 10),
173                font_scale=1.2,
174            )
175        )
176
177    def on_update(self, dt: float):
178        # Animation playback
179        if not self._paused:
180            self._anim_time = (self._anim_time + dt) % self._clip.duration
181
182        # Evaluate clip -> local transforms, let the skeleton chain them to world space
183        pose = self._clip.evaluate(self._anim_time, self._rest_poses)
184        self._skeleton.compute_pose(pose)
185
186        # Place each segment at its bone midpoint, oriented to the bone's world rotation
187        for i, mesh in enumerate(self._bone_meshes):
188            w = self._skeleton.get_bone_global_transform(i)
189            mid = w @ translate((BONE_LENGTH * 0.5, 0, 0))
190            mesh.position = Vec3(float(mid[0, 3]), float(mid[1, 3]), float(mid[2, 3]))
191            # The wave rotates purely around Z, so the angle falls straight out of the matrix
192            mesh.rotation = Quat.from_axis_angle(Vec3(0, 0, 1), math.atan2(float(w[1, 0]), float(w[0, 0])))
193            self._joint_meshes[i].position = Vec3(float(w[0, 3]), float(w[1, 3]), float(w[2, 3]))
194
195        # Fingertip joint at the end of the last bone
196        tip = self._skeleton.get_bone_global_transform(len(BONE_NAMES) - 1) @ translate((BONE_LENGTH, 0, 0))
197        self._joint_meshes[-1].position = Vec3(float(tip[0, 3]), float(tip[1, 3]), float(tip[2, 3]))
198
199        # Camera orbit
200        if Input.is_action_pressed("orbit_left"):
201            self._orbit_angle -= dt
202        if Input.is_action_pressed("orbit_right"):
203            self._orbit_angle += dt
204        if Input.is_action_pressed("zoom_in"):
205            self._zoom = max(4.0, self._zoom - dt * 5)
206        if Input.is_action_pressed("zoom_out"):
207            self._zoom = min(20.0, self._zoom + dt * 5)
208
209        cx = BONE_LENGTH * len(BONE_NAMES) * 0.5
210        self._cam.position = Vec3(
211            cx + math.sin(self._orbit_angle) * self._zoom,
212            4.0,
213            math.cos(self._orbit_angle) * self._zoom,
214        )
215        self._cam.look_at(Vec3(cx, 1, 0))
216
217        # Pause toggle
218        if Input.is_action_just_pressed("pause"):
219            self._paused = not self._paused
220
221        # Quit
222        if Input.is_action_just_pressed("quit"):
223            self.app.quit()
224
225
226def main():
227    app = App(width=1280, height=720, title="Skeletal Animation Demo")
228    app.run(SkeletalDemo())
229
230
231if __name__ == "__main__":
232    main()