Skeletal Animation

a four-bone arm waving under an AnimationPlayer.

▶ Run in browser

Tags: 3d

A Skeleton is a node holding a flat list of Bone records. Each bone names its parent by index and carries a local_transform, its rest pose relative to that parent. Here four bones (root, upper_arm, forearm, hand) each sit two units along X from the one before, which makes a straight arm.

The motion is a SkeletalAnimationClip holding one BoneTrack per animated bone. A track keys position, rotation or scale over time; these key rotation only, a swing about Z that follows a sine wave, each joint a little behind its parent so a wave runs down the arm. Rotation keys are quaternions in x, y, z, w order. A bone whose track keys no position keeps the translation of its rest pose.

AnimationPlayer(skeleton=...) plays the clip: add_clip(clip) registers it, play("wave", loop=True) starts it, and setting playing pauses and resumes it without losing the playhead. Every frame the player samples the clip, poses the bones and calls compute_pose, which chains each local transform onto its parent’s and emits bone_pose_changed.

No mesh is skinned here, so the bones themselves are drawn: a coloured box per bone and a sphere per joint, placed from get_bone_global_transform(i) whenever the pose changes. For a real character, import a rigged glTF with import_gltf, which builds the skeleton with its inverse bind matrices and hands it to the skinned MeshInstance3D so the GPU deforms the mesh; see animated_model.py.

Controls: Space / button - Pause or resume the animation Left / Right / drag - Orbit the camera Up / Down / scroll - Zoom in / out

Source

  1"""Skeletal Animation: a four-bone arm waving under an AnimationPlayer.
  2
  3A ``Skeleton`` is a node holding a flat list of ``Bone`` records. Each bone
  4names its parent by index and carries a ``local_transform``, its rest pose
  5relative to that parent. Here four bones (root, upper_arm, forearm, hand) each
  6sit two units along X from the one before, which makes a straight arm.
  7
  8The motion is a ``SkeletalAnimationClip`` holding one ``BoneTrack`` per animated
  9bone. A track keys position, rotation or scale over time; these key rotation
 10only, a swing about Z that follows a sine wave, each joint a little behind its
 11parent so a wave runs down the arm. Rotation keys are quaternions in x, y, z, w
 12order. A bone whose track keys no position keeps the translation of its rest
 13pose.
 14
 15``AnimationPlayer(skeleton=...)`` plays the clip: ``add_clip(clip)`` registers
 16it, ``play("wave", loop=True)`` starts it, and setting ``playing`` pauses and
 17resumes it without losing the playhead. Every frame the player samples the clip,
 18poses the bones and calls ``compute_pose``, which chains each local transform
 19onto its parent's and emits ``bone_pose_changed``.
 20
 21No mesh is skinned here, so the bones themselves are drawn: a coloured box per
 22bone and a sphere per joint, placed from ``get_bone_global_transform(i)``
 23whenever the pose changes. For a real character, import a rigged glTF with
 24``import_gltf``, which builds the skeleton with its inverse bind matrices and
 25hands it to the skinned ``MeshInstance3D`` so the GPU deforms the mesh; see
 26``animated_model.py``.
 27
 28Controls:
 29    Space / button        - Pause or resume the animation
 30    Left / Right / drag   - Orbit the camera
 31    Up / Down / scroll    - Zoom in / out
 32"""
 33
 34import math
 35
 36from simvx.core import (
 37    Anchor,
 38    AnimationPlayer,
 39    Axis1D,
 40    Bone,
 41    BoneTrack,
 42    Button,
 43    CanvasLayer,
 44    DirectionalLight3D,
 45    Input,
 46    Key,
 47    Label,
 48    Material,
 49    Mesh,
 50    MeshInstance3D,
 51    Node3D,
 52    OrbitCamera3D,
 53    Quat,
 54    SkeletalAnimationClip,
 55    Skeleton,
 56    WorldEnvironment,
 57    on_input,
 58)
 59from simvx.core.math import translate
 60from simvx.graphics import App
 61
 62BONE_LENGTH = 2.0
 63BONE_COLOURS = {
 64    "root": (0.6, 0.6, 0.6),
 65    "upper_arm": (0.9, 0.3, 0.2),
 66    "forearm": (0.2, 0.7, 0.3),
 67    "hand": (0.2, 0.4, 0.9),
 68}
 69WAVE_SECONDS = 2.0
 70WAVE_ANGLE = math.radians(35)
 71WAVE_KEYS = 16
 72
 73
 74def arm_skeleton() -> Skeleton:
 75    """A chain of bones, each BONE_LENGTH along X from its parent."""
 76    root, *chain = BONE_COLOURS
 77    # The root sits at the skeleton's origin; a parent index of -1 means it has no parent.
 78    bones = [Bone(name=root, parent_index=-1, local_transform=translate((0.0, 0.0, 0.0)))]
 79    for name in chain:
 80        bones.append(Bone(name=name, parent_index=len(bones) - 1, local_transform=translate((BONE_LENGTH, 0.0, 0.0))))
 81    return Skeleton(bones=bones)
 82
 83
 84def wave_clip() -> SkeletalAnimationClip:
 85    """Every bone but the root swings about Z, each 0.8 radians of phase behind its parent."""
 86    clip = SkeletalAnimationClip("wave", duration=WAVE_SECONDS)
 87    for bone in range(1, len(BONE_COLOURS)):
 88        track = BoneTrack(bone)
 89        for k in range(WAVE_KEYS + 1):
 90            angle = WAVE_ANGLE * math.sin(2 * math.pi * k / WAVE_KEYS - 0.8 * bone)
 91            q = Quat.from_axis_angle((0, 0, 1), angle)
 92            track.add_rotation_key(WAVE_SECONDS * k / WAVE_KEYS, (q.x, q.y, q.z, q.w))
 93        clip.add_bone_track(track)
 94    return clip
 95
 96
 97class SkeletalArm(Node3D):
 98    input_context = {
 99        "pause": [Key.SPACE],
100        "orbit": [Axis1D(low=Key.LEFT, high=Key.RIGHT)],
101        "zoom": [Axis1D(low=Key.DOWN, high=Key.UP)],
102    }
103
104    def on_ready(self):
105        self.camera = self.add_child(
106            OrbitCamera3D(
107                pivot=(4, 1, 0), distance=10.4, yaw=0.0, pitch=math.radians(-17), min_distance=4, max_distance=20
108            )
109        )
110        self.add_child(DirectionalLight3D(direction=(-0.3, -1, -0.5)))
111        self.add_child(WorldEnvironment(ambient_light_colour=(0.18, 0.19, 0.22, 1.0)))
112        ground = Material(colour=(0.30, 0.32, 0.36), roughness=0.9)
113        self.add_child(MeshInstance3D(mesh=Mesh.box((40, 0.1, 40)), material=ground, position=(4, -5.3, 0)))
114
115        self.skeleton = self.add_child(arm_skeleton())
116        self.player = self.add_child(AnimationPlayer(skeleton=self.skeleton))
117        self.player.add_clip(wave_clip())
118        self.player.play("wave", loop=True)
119
120        # One box per bone, and a joint sphere at each bone's origin plus one at the fingertip.
121        segment = Mesh.box((BONE_LENGTH * 0.9, 0.3, 0.3))
122        self.segments = [
123            self.add_child(MeshInstance3D(mesh=segment, material=Material(colour=colour)))
124            for colour in BONE_COLOURS.values()
125        ]
126        joint, joint_material = Mesh.sphere(radius=0.25), Material(colour=(0.85, 0.85, 0.9))
127        self.joints = [
128            self.add_child(MeshInstance3D(mesh=joint, material=joint_material)) for _ in range(len(BONE_COLOURS) + 1)
129        ]
130        self.skeleton.bone_pose_changed.connect(self.follow_bones)
131
132        hud = self.add_child(CanvasLayer())
133        self.pause_button = hud.add_child(Button("Pause", action="pause", anchor=Anchor.TOP_LEFT, inset=12))
134
135        # Example help overlay: not part of the technique shown.
136        help_text = "Left/Right / drag: orbit   Up/Down / scroll: zoom"
137        hud.add_child(Label(help_text, anchor=Anchor.BOTTOM_LEFT, inset=12, font_size=14))
138
139    def follow_bones(self):
140        for i, segment in enumerate(self.segments):
141            world = self.skeleton.get_bone_global_transform(i)
142            self.joints[i].position = world[:3, 3]
143            segment.position = (world @ (BONE_LENGTH / 2, 0, 0, 1))[:3]
144            # The wave turns about Z alone, so the bone's world rotation is a single angle.
145            segment.rotation = Quat.from_axis_angle((0, 0, 1), math.atan2(world[1, 0], world[0, 0]))
146        # The last joint is the fingertip, at the hand bone's far end.
147        hand = self.skeleton.get_bone_global_transform(len(self.segments) - 1)
148        self.joints[-1].position = (hand @ (BONE_LENGTH, 0, 0, 1))[:3]
149
150    @on_input("pause")
151    def toggle_pause(self, _event):
152        self.player.playing = not self.player.playing
153        self.pause_button.checked = not self.player.playing
154
155    def on_update(self, dt):
156        self.camera.orbit(Input.value("orbit") * dt, 0.0)
157        self.camera.zoom(Input.value("zoom") * 5.0 * dt)
158
159
160if __name__ == "__main__":
161    App(title="Skeletal Animation", width=1280, height=720).run(SkeletalArm())