Skeletal animation demo¶
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
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()