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 playing the Run cycle on a loop, and orbits a Camera3D around it under a DirectionalLight3D so the motion is visible.

Asset references use the canonical Resource(package, name) form, which resolves through importlib.resources. The Fox directory under examples/features/3d/assets/ is a real Python package (it ships an empty __init__.py); we add examples/features/3d/assets to sys.path so the package is importable even when the demo is launched via a bare file path.

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 playing a clip on loop

  • A fixed DirectionalLight3D + auto-orbiting Camera3D framing the model

Controls: Left-click drag: orbit camera Scroll wheel: zoom in/out Escape: quit

Usage: uv run python examples/features/3d/animated_model.py

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`` playing the Run cycle on a loop, and orbits a Camera3D
 12around it under a DirectionalLight3D so the motion is visible.
 13
 14Asset references use the canonical ``Resource(package, name)`` form, which
 15resolves through ``importlib.resources``. The ``Fox`` directory under
 16``examples/features/3d/assets/`` is a real Python package (it ships an empty
 17``__init__.py``); we add ``examples/features/3d/assets`` to ``sys.path`` so
 18the package is importable even when the demo is launched via a bare file path.
 19
 20## What it demonstrates
 21  - Importing a rigged, skinned glTF model with ``import_gltf``
 22  - Locating the imported skeleton and its baked SkeletalAnimationClips
 23  - Driving the skeleton with an ``AnimationPlayer`` playing a clip on loop
 24  - A fixed DirectionalLight3D + auto-orbiting Camera3D framing the model
 25
 26Controls:
 27    Left-click drag: orbit camera
 28    Scroll wheel: zoom in/out
 29    Escape: quit
 30
 31Usage:
 32    uv run python examples/features/3d/animated_model.py
 33"""
 34
 35import math
 36import sys
 37import urllib.request
 38from pathlib import Path
 39
 40# Make ``examples/features/3d/assets`` importable so ``Resource("Fox", ...)``
 41# resolves to the package next door, mirroring how a game project ships its
 42# own assets dir on ``sys.path``.
 43_ASSETS_PARENT = (Path(__file__).parent / "assets").resolve()
 44if str(_ASSETS_PARENT) not in sys.path:
 45    sys.path.insert(0, str(_ASSETS_PARENT))
 46
 47from simvx.core import (  # noqa: E402
 48    Camera3D,
 49    DirectionalLight3D,
 50    Input,
 51    InputMap,
 52    Key,
 53    MouseButton,
 54    Node,
 55    Resource,
 56    Skeleton,
 57)
 58from simvx.core.animation.player import AnimationPlayer  # noqa: E402
 59from simvx.graphics import App  # noqa: E402
 60
 61# One canonical handle per asset. Construction is lazy: the underlying file is
 62# not touched until ``.path`` is accessed.
 63FOX_GLTF = Resource("Fox", "Fox.gltf")
 64FOX_BIN = Resource("Fox", "Fox.bin")
 65FOX_TEXTURE = Resource("Fox", "Texture.png")
 66
 67# Khronos glTF-Sample-Assets raw URLs.
 68_BASE_URL = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets" "/main/Models/Fox/glTF"
 69_DOWNLOAD_TARGETS = [FOX_GLTF, FOX_BIN, FOX_TEXTURE]
 70
 71# Clip to play. The Fox sample ships "Survey", "Walk" and "Run"; Run is the
 72# liveliest cycle. Falls back to whatever clips exist if names ever change.
 73_PREFERRED_CLIP = "Run"
 74
 75
 76def _download_assets() -> None:
 77    """Download the Fox package contents if any file is missing."""
 78    asset_dir = (Path(__file__).parent / "assets" / "Fox").resolve()
 79    asset_dir.mkdir(parents=True, exist_ok=True)
 80    for resource in _DOWNLOAD_TARGETS:
 81        dest = asset_dir / resource.name
 82        if dest.exists():
 83            continue
 84        url = f"{_BASE_URL}/{resource.name}"
 85        print(f"Downloading {resource.name}...")
 86        urllib.request.urlretrieve(url, dest)
 87
 88
 89class AnimatedModel(Node):
 90    def on_ready(self):
 91        InputMap.add_action("quit", [Key.ESCAPE])
 92
 93        # Orbit state. The Fox is authored large (it spans ~155 units nose to
 94        # tail and ~79 tall), so frame its centre from a good distance. A near-
 95        # side yaw shows it in profile so the running gait is easy to read.
 96        self._yaw = 85.0
 97        self._pitch = 10.0
 98        self._distance = 135.0
 99        self._auto_rotate = True
100        self._target = (0.0, 42.0, 0.0)
101
102        # Camera.
103        self._cam = self.add_child(Camera3D(name="Camera", fov=45, near=1.0, far=2000.0))
104
105        # Key light so the skinned mesh is lit as it animates.
106        key = DirectionalLight3D(name="KeyLight", intensity=1.6)
107        key.look_at((-1.0, -2.0, -1.0))
108        self.add_child(key)
109
110        fill = DirectionalLight3D(name="FillLight", intensity=0.4, colour=(0.6, 0.7, 1.0))
111        fill.look_at((1.0, -1.0, 2.0))
112        self.add_child(fill)
113
114        self._player: AnimationPlayer | None = None
115        self._load_model()
116        self._update_camera()
117
118    def _load_model(self) -> None:
119        from simvx.graphics.assets.scene_import import import_gltf
120
121        # Desktop-only download: ``urllib`` is not available under Pyodide, so
122        # browser builds must bundle the assets alongside the game.
123        if sys.platform != "emscripten":
124            _download_assets()
125
126        # ``Resource.path`` resolves the package handle to a real filesystem
127        # path the glTF parser can open; the sibling .bin / .png referenced
128        # from inside the .gltf are picked up relative to it automatically.
129        model = import_gltf(str(FOX_GLTF.path))
130        model.name = "Fox"
131        self.add_child(model)
132
133        # The importer attaches the parsed Skeleton to the skinned
134        # MeshInstance3D (``node.skeleton``) and hangs the baked clips off the
135        # imported root as ``_skeletal_clips``.
136        skeleton = self._find_skeleton(model)
137        clips = getattr(model, "_skeletal_clips", [])
138        if skeleton is None or not clips:
139            print("No skeletal animation found in the imported model.")
140            return
141
142        # An AnimationPlayer drives the skeleton: each frame it evaluates the
143        # active clip and writes per-bone local transforms, then re-poses.
144        player = AnimationPlayer(skeleton=skeleton)
145        for clip in clips:
146            player.add_clip(clip)
147        clip_names = {c.name for c in clips}
148        clip_name = _PREFERRED_CLIP if _PREFERRED_CLIP in clip_names else clips[0].name
149        player.play(clip_name, loop=True)
150        self.add_child(player)
151        self._player = player
152        print(f"Playing skeletal clip {clip_name!r} ({skeleton.bone_count} bones, {len(clips)} clips).")
153
154    @staticmethod
155    def _find_skeleton(root) -> Skeleton | None:
156        """Return the first imported node's attached Skeleton, if any."""
157        node = root
158        stack = [root]
159        while stack:
160            node = stack.pop()
161            skel = getattr(node, "skeleton", None)
162            if isinstance(skel, Skeleton) and skel.bone_count:
163                return skel
164            stack.extend(node.children)
165        return None
166
167    def on_update(self, dt):
168        if self._auto_rotate:
169            self._yaw += 25.0 * dt
170
171        # Left-drag orbit.
172        if Input.is_mouse_button_pressed(MouseButton.LEFT):
173            delta = Input.mouse_delta
174            dx, dy = float(delta.x), float(delta.y)
175            if abs(dx) > 0.1 or abs(dy) > 0.1:
176                self._auto_rotate = False
177                self._yaw -= dx * 0.3
178                self._pitch += dy * 0.3
179                self._pitch = max(-89.0, min(89.0, self._pitch))
180
181        # Scroll zoom.
182        scroll = Input.scroll_delta
183        if scroll[1] != 0.0:
184            self._distance -= scroll[1] * 20.0
185            self._distance = max(120.0, min(900.0, self._distance))
186
187        if Input.is_action_just_pressed("quit"):
188            self.app.quit()
189            return
190
191        self._update_camera()
192
193    def _update_camera(self):
194        yaw_rad = math.radians(self._yaw)
195        pitch_rad = math.radians(self._pitch)
196        cp = math.cos(pitch_rad)
197        x = self._target[0] + self._distance * cp * math.sin(yaw_rad)
198        y = self._target[1] + self._distance * math.sin(pitch_rad)
199        z = self._target[2] + self._distance * cp * math.cos(yaw_rad)
200        self._cam.position = (x, y, z)
201        self._cam.look_at(self._target)
202
203
204if __name__ == "__main__":
205    App(title="Animated Model: Fox", width=800, height=600).run(AnimatedModel())