Skeletal Animation¶
a four-bone arm waving under an AnimationPlayer.
▶ Run in browserTags: 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())