Skeletal Animation¶
articulated arm with bone-driven wave motion.
▶ Run in browserTags: 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()