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 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_gltfLocating the imported skeleton and its baked SkeletalAnimationClips
Driving the skeleton with an
AnimationPlayerplaying a clip on loopA 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())