Animated Model¶
load a rigged glTF and play its skeletal animation.
â–¶ Run in browserTags: 3d animation gltf skeletal
Auto-downloads the Khronos Fox sample on first run. Fox is a small rigged,
skinned model that ships three baked animation clips (Survey, Walk, Run);
this viewer imports it with import_gltf, drives its skeleton with an
AnimationPlayer, and orbits a Camera3D around it under a key/fill light
rig plus a WorldEnvironment so the motion is well-lit on web and desktop.
Asset references use the canonical Resource(package, name) form, which
resolves through importlib.resources. The Fox directory beside this file
is a real Python package (it carries an empty __init__.py); we add that
assets directory to sys.path so the package is importable however the
demo is launched.
The Khronos Fox sample is third-party and is not part of the installed library;
if it is not beside this file it is fetched once into
$XDG_CACHE_HOME/simvx/example-assets/. The model is CC0-1.0 and its rigging,
animation and glTF conversion are CC-BY-4.0; see assets/ATTRIBUTION.md for
the credit those terms require.
What it demonstrates¶
Importing a rigged, skinned glTF model with
import_gltfLocating the imported skeleton and its baked SkeletalAnimationClips
Driving the skeleton with an
AnimationPlayerand switching clips liveA WorldEnvironment (procedural sky + ambient) for consistent lighting
A responsive, anchored UI overlay with touch-friendly clip and zoom buttons
Controls: Left-click drag: orbit camera Scroll wheel / on-screen +/- buttons: zoom in/out N / on-screen clip buttons: switch animation clip (Survey / Walk / Run) Space / on-screen Pause button: pause or resume the animation Escape: quit
Usage: uv run python examples/features/3d/animated_model.py uv run python examples/features/3d/animated_model.py –test # headless self-check
Source¶
1"""Animated Model: load a rigged glTF and play its skeletal animation.
2
3# /// simvx
4# tags = ["3d", "animation", "gltf", "skeletal"]
5# web = { root = "AnimatedModel", width = 800, height = 600, responsive = true }
6# ///
7
8Auto-downloads the Khronos Fox sample on first run. Fox is a small rigged,
9skinned model that ships three baked animation clips (Survey, Walk, Run);
10this viewer imports it with ``import_gltf``, drives its skeleton with an
11``AnimationPlayer``, and orbits a Camera3D around it under a key/fill light
12rig plus a WorldEnvironment so the motion is well-lit on web and desktop.
13
14Asset references use the canonical ``Resource(package, name)`` form, which
15resolves through ``importlib.resources``. The ``Fox`` directory beside this file
16is a real Python package (it carries an empty ``__init__.py``); we add that
17``assets`` directory to ``sys.path`` so the package is importable however the
18demo is launched.
19
20The Khronos Fox sample is third-party and is not part of the installed library;
21if it is not beside this file it is fetched once into
22``$XDG_CACHE_HOME/simvx/example-assets/``. The model is CC0-1.0 and its rigging,
23animation and glTF conversion are CC-BY-4.0; see ``assets/ATTRIBUTION.md`` for
24the credit those terms require.
25
26## What it demonstrates
27 - Importing a rigged, skinned glTF model with ``import_gltf``
28 - Locating the imported skeleton and its baked SkeletalAnimationClips
29 - Driving the skeleton with an ``AnimationPlayer`` and switching clips live
30 - A WorldEnvironment (procedural sky + ambient) for consistent lighting
31 - A responsive, anchored UI overlay with touch-friendly clip and zoom buttons
32
33Controls:
34 Left-click drag: orbit camera
35 Scroll wheel / on-screen +/- buttons: zoom in/out
36 N / on-screen clip buttons: switch animation clip (Survey / Walk / Run)
37 Space / on-screen Pause button: pause or resume the animation
38 Escape: quit
39
40Usage:
41 uv run python examples/features/3d/animated_model.py
42 uv run python examples/features/3d/animated_model.py --test # headless self-check
43"""
44
45import math
46import os
47import sys
48import urllib.request
49from pathlib import Path
50
51# Make ``examples/features/3d/assets`` importable so ``Resource("Fox", ...)``
52# resolves to the package next door, mirroring how a game project ships its
53# own assets dir on ``sys.path``.
54_ASSETS_PARENT = (Path(__file__).parent / "assets").resolve()
55if str(_ASSETS_PARENT) not in sys.path:
56 sys.path.insert(0, str(_ASSETS_PARENT))
57
58from simvx.core import ( # noqa: E402
59 AnchorPreset,
60 AnimationPlayer,
61 Button,
62 Camera3D,
63 Colour,
64 DirectionalLight3D,
65 HBoxContainer,
66 Input,
67 InputMap,
68 Key,
69 Label,
70 MeshInstance3D,
71 MouseButton,
72 Node,
73 Panel,
74 Resource,
75 Skeleton,
76 Vec2,
77 WorldEnvironment,
78)
79from simvx.graphics import App # noqa: E402
80
81# One canonical handle per asset. Construction is lazy: the underlying file is
82# not touched until ``.path`` is accessed.
83FOX_GLTF = Resource("Fox", "Fox.gltf")
84FOX_BIN = Resource("Fox", "Fox.bin")
85FOX_TEXTURE = Resource("Fox", "Texture.png")
86
87# Khronos glTF-Sample-Assets raw URLs.
88_BASE_URL = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets" "/main/Models/Fox/glTF"
89_DOWNLOAD_TARGETS = [FOX_GLTF, FOX_BIN, FOX_TEXTURE]
90
91# Clip to play first. The Fox sample ships "Survey", "Walk" and "Run"; Run is
92# the liveliest cycle. Falls back to whatever clips exist if names ever change.
93_PREFERRED_CLIP = "Run"
94
95# Zoom limits and step (world units along the orbit radius).
96_ZOOM_MIN, _ZOOM_MAX = 120.0, 900.0
97_ZOOM_STEP = 60.0
98
99# Overlay geometry: gap between a bar and the viewport edge, padding inside a
100# bar, and the spacing an HBoxContainer leaves between buttons.
101_GUTTER, _BAR_PAD, _BTN_GAP = 12.0, 6.0, 6.0
102
103
104def _download_assets() -> None:
105 """Fetch the Fox contents if they are not already available.
106
107 Writes into a per-user cache rather than beside this file, so it works from
108 a read-only install. The cache directory is put on ``sys.path`` so the
109 ``Resource`` lookup above finds it exactly as it finds a checkout copy.
110 """
111 if all((_ASSETS_PARENT / "Fox" / r.name).exists() for r in _DOWNLOAD_TARGETS):
112 return
113 cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
114 asset_dir = cache / "simvx" / "example-assets" / "Fox"
115 asset_dir.mkdir(parents=True, exist_ok=True)
116 (asset_dir / "__init__.py").touch()
117 if str(asset_dir.parent) not in sys.path:
118 sys.path.insert(0, str(asset_dir.parent))
119 for resource in _DOWNLOAD_TARGETS:
120 dest = asset_dir / resource.name
121 if dest.exists():
122 continue
123 url = f"{_BASE_URL}/{resource.name}"
124 print(f"Downloading {resource.name}...")
125 urllib.request.urlretrieve(url, dest)
126
127
128class AnimatedModel(Node):
129 def on_ready(self):
130 # Input actions MUST be registered here (not in ``main``): the web
131 # exporter never calls ``main``, so actions added there are lost.
132 InputMap.add_action("quit", [Key.ESCAPE])
133 InputMap.add_action("next_clip", [Key.N])
134 InputMap.add_action("toggle_pause", [Key.SPACE])
135
136 # Orbit state. The Fox is authored large (it spans ~155 units nose to
137 # tail and ~79 tall), so frame its centre from a good distance. A near-
138 # side yaw shows it in profile so the running gait is easy to read.
139 self._yaw = 85.0
140 self._pitch = 10.0
141 self._distance = 135.0
142 self._auto_rotate = True
143 self._target = (0.0, 42.0, 0.0)
144
145 # WorldEnvironment: a procedural sky drives ambient IBL and a raised
146 # ambient_light_energy lifts the fill floor. Without this the web
147 # backend falls back to a near-black flat ambient (0.03) and the Fox
148 # reads almost silhouette; this keeps desktop and web consistently lit.
149 self.add_child(
150 WorldEnvironment(
151 environment_map={"colour": (0.45, 0.55, 0.7)},
152 ambient_light_energy=0.9,
153 )
154 )
155
156 # Camera.
157 self._cam = self.add_child(Camera3D(name="Camera", fov=45, near=1.0, far=2000.0))
158
159 # Key light so the skinned mesh is lit as it animates.
160 key = DirectionalLight3D(name="KeyLight", intensity=1.6)
161 key.look_at((-1.0, -2.0, -1.0))
162 self.add_child(key)
163
164 fill = DirectionalLight3D(name="FillLight", intensity=0.4, colour=(0.6, 0.7, 1.0))
165 fill.look_at((1.0, -1.0, 2.0))
166 self.add_child(fill)
167
168 self._player: AnimationPlayer | None = None
169 self._clip_names: list[str] = []
170 self._load_model()
171
172 self._build_ui()
173 self._update_camera()
174
175 def _load_model(self) -> None:
176 from simvx.graphics.assets.scene_import import import_gltf
177
178 # Desktop-only download: ``urllib`` is not available under Pyodide, so
179 # browser builds must bundle the assets alongside the game.
180 if sys.platform != "emscripten":
181 _download_assets()
182
183 # ``Resource.path`` resolves the package handle to a real filesystem
184 # path the glTF parser can open; the sibling .bin / .png referenced
185 # from inside the .gltf are picked up relative to it automatically.
186 model = import_gltf(str(FOX_GLTF.path))
187 model.name = "Fox"
188 self.add_child(model)
189
190 # The importer attaches the parsed Skeleton to the skinned
191 # MeshInstance3D (``node.skeleton``) and hangs the baked clips off the
192 # imported root as ``_skeletal_clips``.
193 skeleton = self._find_skeleton(model)
194 clips = getattr(model, "_skeletal_clips", [])
195 if skeleton is None or not clips:
196 print("No skeletal animation found in the imported model.")
197 return
198
199 # An AnimationPlayer drives the skeleton: each frame it evaluates the
200 # active clip and writes per-bone local transforms, then re-poses.
201 player = AnimationPlayer(skeleton=skeleton)
202 for clip in clips:
203 player.add_clip(clip)
204 self._clip_names = [c.name for c in clips]
205 clip_name = _PREFERRED_CLIP if _PREFERRED_CLIP in self._clip_names else self._clip_names[0]
206 player.play(clip_name, loop=True)
207 self.add_child(player)
208 self._player = player
209 print(f"Playing skeletal clip {clip_name!r} ({skeleton.bone_count} bones, {len(clips)} clips).")
210
211 @staticmethod
212 def _find_skeleton(root) -> Skeleton | None:
213 """Return the first imported node's attached Skeleton, if any."""
214 stack = [root]
215 while stack:
216 node = stack.pop()
217 if isinstance(node, MeshInstance3D) and node.skeleton is not None and node.skeleton.bone_count:
218 return node.skeleton
219 stack.extend(node.children)
220 return None
221
222 # ------------------------------------------------------------------ UI ---
223
224 def _build_ui(self) -> None:
225 """Anchored, responsive overlay: controls hint + clip/zoom buttons.
226
227 Every top-level Control uses an AnchorPreset plus margins (never an
228 absolute position) so the overlay tracks the viewport edges on resize
229 and stays usable on web/mobile. The buttons surface touch input, which
230 the web runtime delivers as ``MouseButton.LEFT``.
231 """
232 # Controls hint, top-left.
233 hint = Label(
234 "Drag: orbit Scroll/+-: zoom N: next clip Space: pause Esc: quit",
235 name="ControlsHint",
236 )
237 hint.set_anchor_preset(AnchorPreset.TOP_LEFT)
238 hint.margin_left = 12.0
239 hint.margin_top = 10.0
240 hint.font_size = 14.0
241 hint.text_colour = Colour.WHITE
242 self.add_child(hint)
243
244 btn_w, btn_h = 86.0, 34.0
245
246 # Clip switcher, bottom-left: one Button per clip plus a Pause toggle.
247 labels = self._clip_names or [_PREFERRED_CLIP]
248 self._clip_buttons: dict[str, Button] = {}
249 for name in labels:
250 btn = Button(name, name=f"Clip_{name}", on_press=self._make_clip_handler(name))
251 btn.size = Vec2(btn_w, btn_h)
252 self._clip_buttons[name] = btn
253 self._pause_btn = Button("Pause", name="PauseButton", on_press=self._toggle_pause)
254 self._pause_btn.size = Vec2(btn_w, btn_h)
255 self._add_bar("ClipBar", [*self._clip_buttons.values(), self._pause_btn])
256
257 # Zoom buttons, bottom-right: touch-friendly equivalents of the wheel.
258 zoom_in = Button("+", name="ZoomIn", on_press=lambda: self._zoom(-_ZOOM_STEP))
259 zoom_out = Button("-", name="ZoomOut", on_press=lambda: self._zoom(_ZOOM_STEP))
260 for btn in (zoom_in, zoom_out):
261 btn.size = Vec2(40.0, btn_h)
262 btn.font_size = 20.0
263 self._add_bar("ZoomBar", [zoom_in, zoom_out], right=True)
264
265 self._refresh_clip_buttons()
266
267 def _add_bar(self, name: str, buttons: list[Button], *, right: bool = False) -> None:
268 """Anchor a bottom-corner bar and let an HBoxContainer lay its buttons out.
269
270 A BOTTOM_* anchor reads its box size from the margin pairs (width =
271 ``margin_right - margin_left``, height = ``margin_bottom - margin_top``),
272 so a bar sized to its content sits at the corner with negative margins
273 measured back from the bottom edge. The row inside fills the bar, and the
274 container does the per-button placement.
275 """
276 row = HBoxContainer(name=f"{name}Row", children=buttons)
277 row.separation = _BTN_GAP
278 content = row.get_minimum_size()
279 width, height = content.x + 2 * _BAR_PAD, content.y + 2 * _BAR_PAD
280
281 bar = Panel(name=name)
282 bar.set_anchor_preset(AnchorPreset.BOTTOM_RIGHT if right else AnchorPreset.BOTTOM_LEFT)
283 bar.margin_left = -(width + _GUTTER) if right else _GUTTER
284 bar.margin_right = -_GUTTER if right else width + _GUTTER
285 bar.margin_top = -(height + _GUTTER)
286 bar.margin_bottom = -_GUTTER
287 bar.bg_colour = Colour((0.0, 0.0, 0.0, 0.45))
288 self.add_child(bar)
289
290 row.set_anchor_preset(AnchorPreset.FULL_RECT)
291 row.margin_left = row.margin_top = row.margin_right = row.margin_bottom = _BAR_PAD
292 bar.add_child(row)
293
294 def _make_clip_handler(self, name: str):
295 return lambda: self._set_clip(name)
296
297 def _set_clip(self, name: str) -> None:
298 if self._player is None or name not in self._clip_names:
299 return
300 self._player.play(name, loop=True)
301 self._refresh_clip_buttons()
302
303 def _next_clip(self) -> None:
304 if self._player is None or not self._clip_names:
305 return
306 current = self._player.current_clip
307 idx = self._clip_names.index(current) if current in self._clip_names else -1
308 self._set_clip(self._clip_names[(idx + 1) % len(self._clip_names)])
309
310 def _toggle_pause(self) -> None:
311 if self._player is None:
312 return
313 if self._player.playing:
314 self._player.pause()
315 else:
316 self._player.resume()
317 self._refresh_clip_buttons()
318
319 def _refresh_clip_buttons(self) -> None:
320 """Highlight the active clip and reflect play/pause state."""
321 if self._player is None:
322 return
323 active = self._player.current_clip
324 for name, btn in self._clip_buttons.items():
325 state = Button.VisualState.PRESSED if name == active else None
326 btn.set_visual_state_override(state)
327 self._pause_btn.text = "Play" if not self._player.playing else "Pause"
328
329 def _zoom(self, delta: float) -> None:
330 self._distance = max(_ZOOM_MIN, min(_ZOOM_MAX, self._distance + delta))
331
332 # -------------------------------------------------------------- update ---
333
334 def on_update(self, dt):
335 if Input.is_action_just_pressed("quit"):
336 self.app.quit()
337 return
338
339 if Input.is_action_just_pressed("next_clip"):
340 self._next_clip()
341 if Input.is_action_just_pressed("toggle_pause"):
342 self._toggle_pause()
343
344 if self._auto_rotate:
345 self._yaw += 25.0 * dt
346
347 # Left-drag orbit.
348 if Input.is_mouse_button_pressed(MouseButton.LEFT):
349 delta = Input.mouse_delta
350 dx, dy = float(delta.x), float(delta.y)
351 if abs(dx) > 0.1 or abs(dy) > 0.1:
352 self._auto_rotate = False
353 self._yaw -= dx * 0.3
354 self._pitch += dy * 0.3
355 self._pitch = max(-89.0, min(89.0, self._pitch))
356
357 # Scroll zoom (desktop). On-screen +/- buttons cover touch.
358 scroll = Input.scroll_delta
359 if scroll[1] != 0.0:
360 self._zoom(-scroll[1] * 20.0)
361
362 self._update_camera()
363
364 def _update_camera(self):
365 yaw_rad = math.radians(self._yaw)
366 pitch_rad = math.radians(self._pitch)
367 cp = math.cos(pitch_rad)
368 x = self._target[0] + self._distance * cp * math.sin(yaw_rad)
369 y = self._target[1] + self._distance * math.sin(pitch_rad)
370 z = self._target[2] + self._distance * cp * math.cos(yaw_rad)
371 self._cam.position = (x, y, z)
372 self._cam.look_at(self._target)
373
374
375def _selftest() -> bool:
376 """Headless check: the Fox resolves, imports rigged, and its skeleton moves.
377
378 Two claims, and the second is the one worth guarding. A glTF that imports
379 but whose skeleton is never re-posed draws a model frozen in its bind pose,
380 which looks like a working import from a screenshot. So the joint matrices
381 are read at the first frame and again a second of playback later, and the
382 check is that they differ.
383
384 The model is third-party and is not part of the installed library, so this
385 needs one of three things: a copy beside this file, a cache warmed by an
386 earlier run, or the network. With none of them the question was never put
387 to the code, which is what ``Unsupported`` reports.
388 """
389 from urllib.error import HTTPError, URLError
390
391 import numpy as np
392
393 from simvx.core.testing.selftest import Unsupported
394 from simvx.graphics.testing import assert_not_blank, save_png
395
396 try:
397 _download_assets()
398 except HTTPError:
399 # The server answered and refused: the fetch path itself is wrong.
400 raise
401 except URLError as exc:
402 raise Unsupported(
403 f"the Fox sample is neither beside this file nor cached, and it cannot be fetched: {exc.reason}"
404 ) from exc
405
406 app = App(title="Animated Model: Fox", width=800, height=600, visible=False)
407 scene = AnimatedModel(name="AnimatedModel")
408 poses: dict[int, np.ndarray] = {}
409 LAST = 60 # a second of playback at the fixed headless step
410
411 def on_frame(idx: int, _t: float) -> bool:
412 skeleton = scene._find_skeleton(scene)
413 if skeleton is not None and idx in (0, LAST):
414 poses[idx] = np.array(skeleton.joint_matrices, copy=True)
415 return True
416
417 frames = app.run_headless(scene, frames=LAST + 1, on_frame=on_frame, capture_frames=[LAST])
418 assert_not_blank(frames[0])
419 save_png(frames[0], "/tmp/animated_model_test.png")
420
421 ok = True
422
423 def check(label: str, passed: bool, detail: str) -> None:
424 nonlocal ok
425 ok = ok and passed
426 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
427
428 check(
429 "the Resource handle resolves to a file on disk",
430 Path(FOX_GLTF.path).is_file(),
431 str(FOX_GLTF.path),
432 )
433
434 fox = next((child for child in scene.children if child.name == "Fox"), None)
435 meshes = []
436 if fox is not None:
437 stack = [fox]
438 while stack:
439 node = stack.pop()
440 if isinstance(node, MeshInstance3D) and node.mesh is not None:
441 meshes.append(node)
442 stack.extend(node.children)
443 check(
444 "import_gltf hangs a model carrying mesh geometry off the viewer",
445 fox is not None and bool(meshes),
446 f"children: {[c.name for c in scene.children]}, {len(meshes)} mesh instance(s)",
447 )
448
449 skeleton = scene._find_skeleton(scene)
450 check(
451 "the imported mesh comes rigged, with its skeleton attached",
452 skeleton is not None and skeleton.bone_count > 0,
453 f"{0 if skeleton is None else skeleton.bone_count} bones",
454 )
455 check(
456 "the baked clips are loaded and one of them is playing",
457 scene._player is not None and bool(scene._clip_names),
458 f"clips {scene._clip_names}",
459 )
460
461 moved = 0.0 if len(poses) < 2 else float(np.abs(poses[LAST] - poses[0]).max())
462 check(
463 "and the AnimationPlayer re-poses the skeleton as it runs",
464 moved > 1e-4,
465 f"largest joint-matrix change over {LAST} frames is {moved:.4f}",
466 )
467
468 print("screenshot: /tmp/animated_model_test.png")
469 print("SELFTEST:", "PASS" if ok else "FAIL")
470 return ok
471
472
473if __name__ == "__main__":
474 if "--test" in sys.argv:
475 from simvx.core.testing.selftest import run_selftest
476
477 sys.exit(run_selftest(_selftest))
478 App(title="Animated Model: Fox", width=800, height=600).run(AnimatedModel())