Animated Model¶

load a rigged glTF and play its skeletal animation.

â–¶ Run in browser

Tags: 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_gltf

  • Locating the imported skeleton and its baked SkeletalAnimationClips

  • Driving the skeleton with an AnimationPlayer and switching clips live

  • A 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())