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 cubes transformed by joint matrices each frame
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 cubes transformed by joint matrices each frame
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 SkeletalAnimationClip,
36 Skeleton,
37 Vec3,
38)
39from simvx.core.math import translate
40from simvx.graphics import App
41
42BONE_LENGTH = 2.0
43BONE_NAMES = ["root", "upper_arm", "forearm", "hand"]
44BONE_COLOURS = [
45 (0.6, 0.6, 0.6, 1), # root: grey
46 (0.9, 0.3, 0.2, 1), # upper_arm: red
47 (0.2, 0.7, 0.3, 1), # forearm: green
48 (0.2, 0.4, 0.9, 1), # hand: blue
49]
50
51
52def _build_skeleton() -> Skeleton:
53 """Create a 4-bone chain offset along X."""
54 skel = Skeleton(name="ArmSkeleton")
55 for i, bname in enumerate(BONE_NAMES):
56 parent_idx = i - 1 if i > 0 else -1
57 local = translate((BONE_LENGTH, 0, 0)) if i > 0 else np.eye(4, dtype=np.float32)
58 inv_bind = np.linalg.inv(local) if i > 0 else np.eye(4, dtype=np.float32)
59 skel.add_bone(Bone(name=bname, parent_index=parent_idx, inverse_bind_matrix=inv_bind, local_transform=local))
60 return skel
61
62
63def _build_wave_clip(duration: float = 2.0) -> SkeletalAnimationClip:
64 """Create a wave animation -- each bone rotates with a phase offset."""
65 clip = SkeletalAnimationClip(name="wave", duration=duration)
66 max_angle = math.radians(35)
67 for bone_idx in range(1, len(BONE_NAMES)):
68 track = BoneTrack(bone_index=bone_idx)
69 steps = 16
70 for s in range(steps + 1):
71 t = (s / steps) * duration
72 phase = bone_idx * 0.8
73 angle = max_angle * math.sin(2 * math.pi * t / duration + phase)
74 # Position: preserve bone offset along X
75 track.position_keys.append((t, np.array([BONE_LENGTH, 0, 0], dtype=np.float32)))
76 # Quaternion (xyzw): rotate around Z axis
77 ha = angle * 0.5
78 quat = np.array([0, 0, math.sin(ha), math.cos(ha)], dtype=np.float32)
79 track.rotation_keys.append((t, quat))
80 clip.add_bone_track(track)
81 return clip
82
83
84class SkeletalDemo(Node):
85 """Root scene for skeletal animation demo."""
86
87 def on_ready(self):
88 InputMap.add_action("orbit_left", [Key.LEFT])
89 InputMap.add_action("orbit_right", [Key.RIGHT])
90 InputMap.add_action("zoom_in", [Key.UP])
91 InputMap.add_action("zoom_out", [Key.DOWN])
92 InputMap.add_action("pause", [Key.SPACE])
93
94 # Camera
95 self._cam = self.add_child(Camera3D(name="Camera"))
96 self._cam.position = Vec3(4, 4, 10)
97 self._cam.look_at(Vec3(3, 1, 0))
98 self._orbit_angle = 0.0
99 self._zoom = 10.0
100
101 # Light
102 light = self.add_child(DirectionalLight3D(name="Sun"))
103 light.direction = Vec3(-0.3, -1, -0.5)
104
105 # Skeleton
106 self._skeleton = self.add_child(_build_skeleton())
107 self._clip = _build_wave_clip(duration=2.0)
108 self._anim_time = 0.0
109 self._paused = False
110
111 # Bone visualisation cubes
112 self._bone_meshes: list[MeshInstance3D] = []
113 for i, bname in enumerate(BONE_NAMES):
114 mesh = MeshInstance3D(name=f"Bone_{bname}")
115 mesh.mesh = Mesh.cube()
116 mesh.material = Material(colour=BONE_COLOURS[i])
117 mesh.scale = np.array([BONE_LENGTH * 0.45, 0.3, 0.3], dtype=np.float32)
118 self.add_child(mesh)
119 self._bone_meshes.append(mesh)
120
121 from simvx.core import Text2D
122 self.add_child(Text2D(name="HUD", text="Skeletal Animation: L/R orbit | U/D zoom | Space pause | ESC quit",
123 x=10, y=10, font_scale=1.2))
124
125 def on_process(self, dt: float):
126 # Animation playback
127 if not self._paused:
128 self._anim_time = (self._anim_time + dt) % self._clip.duration
129
130 # Evaluate clip → local transforms per bone
131 pose = self._clip.evaluate(self._anim_time)
132
133 # Compute world transforms by chaining parent * local
134 bones = self._skeleton.bones
135 world = [np.eye(4, dtype=np.float32) for _ in bones]
136 for i, bone in enumerate(bones):
137 local = pose.get(i, bone.local_transform)
138 if bone.parent_index >= 0:
139 world[i] = world[bone.parent_index] @ local
140 else:
141 world[i] = local.copy()
142
143 # Position cubes at bone midpoints
144 for i, mesh in enumerate(self._bone_meshes):
145 w = world[i]
146 # Midpoint offset along local X
147 mid = w @ translate((BONE_LENGTH * 0.5, 0, 0))
148 mesh.position = Vec3(float(mid[0, 3]), float(mid[1, 3]), float(mid[2, 3]))
149
150 # Camera orbit
151 if Input.is_action_pressed("orbit_left"):
152 self._orbit_angle -= dt
153 if Input.is_action_pressed("orbit_right"):
154 self._orbit_angle += dt
155 if Input.is_action_pressed("zoom_in"):
156 self._zoom = max(4.0, self._zoom - dt * 5)
157 if Input.is_action_pressed("zoom_out"):
158 self._zoom = min(20.0, self._zoom + dt * 5)
159
160 cx = BONE_LENGTH * len(BONE_NAMES) * 0.5
161 self._cam.position = Vec3(
162 cx + math.sin(self._orbit_angle) * self._zoom,
163 4.0,
164 math.cos(self._orbit_angle) * self._zoom,
165 )
166 self._cam.look_at(Vec3(cx, 1, 0))
167
168 # Pause toggle
169 if Input.is_action_just_pressed("pause"):
170 self._paused = not self._paused
171
172
173def main():
174 app = App(width=1280, height=720, title="Skeletal Animation Demo")
175 app.run(SkeletalDemo())
176
177
178if __name__ == "__main__":
179 main()