Animated Model¶
load a rigged glTF and play its skeletal animation.
▶ Run in browserTags: 3d animation gltf skeletal
import_gltf(path) turns a glTF file into a node tree. For a rigged model
each skinned MeshInstance3D carries its Skeleton in mesh.skeleton,
and the file’s baked animations come in as SkeletalAnimationClip objects.
An AnimationPlayer built with skeleton= plays them: add_clip(clip)
registers each one, play(name, loop=True) starts or switches clip, and
setting playing pauses and resumes without losing the playhead. Every
frame the player samples the clip and re-poses the bones, and the skinned mesh
follows.
The model is the Khronos Fox sample, which has three clips: Survey, Walk and
Run. It is third-party, so the installed example library does not ship it: if
it is not beside this file it is downloaded once into
$XDG_CACHE_HOME/simvx/example-assets/Fox (~/.cache by default). The
model is CC0-1.0, and its rigging, animation and glTF conversion are CC-BY-4.0;
assets/ATTRIBUTION.md has the credit those terms require.
An OrbitCamera3D circles the Fox until you take hold of it, a key and fill
light plus a WorldEnvironment sky light it, and a button bar switches clips.
Controls: Drag: orbit the camera Scroll / pinch / + and - buttons: zoom Clip buttons, N: switch clip Pause button, Space: pause or resume the animation
Source¶
1# /// simvx
2# tags = ["3d", "animation", "gltf", "skeletal"]
3# web = { width = 800, height = 600, responsive = true }
4# ///
5"""Animated Model: load a rigged glTF and play its skeletal animation.
6
7``import_gltf(path)`` turns a glTF file into a node tree. For a rigged model
8each skinned ``MeshInstance3D`` carries its ``Skeleton`` in ``mesh.skeleton``,
9and the file's baked animations come in as ``SkeletalAnimationClip`` objects.
10An ``AnimationPlayer`` built with ``skeleton=`` plays them: ``add_clip(clip)``
11registers each one, ``play(name, loop=True)`` starts or switches clip, and
12setting ``playing`` pauses and resumes without losing the playhead. Every
13frame the player samples the clip and re-poses the bones, and the skinned mesh
14follows.
15
16The model is the Khronos Fox sample, which has three clips: Survey, Walk and
17Run. It is third-party, so the installed example library does not ship it: if
18it is not beside this file it is downloaded once into
19``$XDG_CACHE_HOME/simvx/example-assets/Fox`` (``~/.cache`` by default). The
20model is CC0-1.0, and its rigging, animation and glTF conversion are CC-BY-4.0;
21``assets/ATTRIBUTION.md`` has the credit those terms require.
22
23An ``OrbitCamera3D`` circles the Fox until you take hold of it, a key and fill
24light plus a ``WorldEnvironment`` sky light it, and a button bar switches clips.
25
26Controls:
27 Drag: orbit the camera
28 Scroll / pinch / + and - buttons: zoom
29 Clip buttons, N: switch clip
30 Pause button, Space: pause or resume the animation
31"""
32
33import math
34import os
35import urllib.request
36from pathlib import Path
37
38from simvx.core import (
39 Anchor,
40 AnimationPlayer,
41 Button,
42 ButtonGroup,
43 CanvasLayer,
44 DirectionalLight3D,
45 HBoxContainer,
46 Key,
47 Label,
48 MeshInstance3D,
49 Node,
50 OrbitCamera3D,
51 WorldEnvironment,
52 on_input,
53)
54from simvx.graphics import App, import_gltf
55
56FOX_URL = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/Fox/glTF"
57FOX_FILES = ("Fox.gltf", "Fox.bin", "Texture.png")
58SPIN_SPEED = math.radians(25.0) # auto-orbit, radians per second
59ZOOM_STEP = 60.0 # world units per press of + or -
60
61
62def fox_dir() -> Path:
63 """The Fox beside this file if it is there, else a per-user cached download."""
64 here = Path(__file__).parent / "assets" / "Fox"
65 if all((here / name).exists() for name in FOX_FILES):
66 return here
67 cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "simvx" / "example-assets" / "Fox"
68 cache.mkdir(parents=True, exist_ok=True)
69 for name in FOX_FILES:
70 if not (cache / name).exists():
71 urllib.request.urlretrieve(f"{FOX_URL}/{name}", cache / name)
72 return cache
73
74
75class AnimatedModel(Node):
76 input_context = {"next_clip": [Key.N], "pause": [Key.SPACE]}
77
78 def on_ready(self):
79 self.add_child(WorldEnvironment(environment_map={"colour": (0.45, 0.55, 0.7)}, ambient_light_energy=0.9))
80 self.add_child(DirectionalLight3D(direction=(-1, -2, -1), intensity=1.6))
81 self.add_child(DirectionalLight3D(direction=(1, -1, 2), intensity=0.4, colour=(0.6, 0.7, 1.0)))
82
83 # The Fox is authored large (about 155 units nose to tail), so the camera
84 # orbits the middle of its body from well back, starting side-on.
85 self.camera = self.add_child(
86 OrbitCamera3D(
87 pivot=(0, 42, 0),
88 distance=135.0,
89 min_distance=120.0,
90 max_distance=900.0,
91 yaw=math.radians(85),
92 pitch=math.radians(-10),
93 far=2000.0,
94 )
95 )
96 self.spinning = True
97
98 model = self.add_child(import_gltf(str(fox_dir() / "Fox.gltf")))
99 skeleton = next(mesh.skeleton for mesh in model.find_all(MeshInstance3D) if mesh.skeleton)
100 self.player = model.add_child(AnimationPlayer(skeleton=skeleton))
101 # import_gltf keeps the clips on a private attribute: it has no public
102 # way to hand them back.
103 for clip in model._skeletal_clips:
104 self.player.add_clip(clip)
105 self.clips = list(self.player.skeletal_clips)
106
107 hud = self.add_child(CanvasLayer())
108 clip_bar = hud.add_child(HBoxContainer(anchor=Anchor.BOTTOM_LEFT, inset=12))
109 group = ButtonGroup()
110 self.clip_buttons = {
111 name: clip_bar.add_child(Button(name, on_press=lambda name=name: self.play(name), group=group))
112 for name in self.clips
113 }
114 self.pause_button = clip_bar.add_child(Button("Pause", action="pause"))
115 zoom_bar = hud.add_child(HBoxContainer(anchor=Anchor.BOTTOM_RIGHT, inset=12))
116 zoom_bar.add_child(Button("+", on_press=lambda: self.camera.zoom(ZOOM_STEP)))
117 zoom_bar.add_child(Button("-", on_press=lambda: self.camera.zoom(-ZOOM_STEP)))
118
119 self.play("Run")
120
121 # Example help overlay: not part of the technique shown.
122 hud.add_child(Label("Drag: orbit Scroll: zoom N: next clip", anchor=Anchor.TOP_LEFT, inset=12))
123
124 def play(self, name: str) -> None:
125 self.player.play(name, loop=True)
126 self.clip_buttons[name].checked = True
127
128 @on_input("next_clip")
129 def next_clip(self, _event):
130 self.play(self.clips[(self.clips.index(self.player.current_clip) + 1) % len(self.clips)])
131
132 @on_input("pause")
133 def toggle_pause(self, _event):
134 self.player.playing = not self.player.playing
135 self.pause_button.text = "Pause" if self.player.playing else "Play"
136
137 @on_input("orbit_camera.drag")
138 def stop_spinning(self, _event):
139 self.spinning = False
140
141 def on_update(self, dt):
142 if self.spinning:
143 self.camera.orbit(SPIN_SPEED * dt, 0.0)
144
145
146if __name__ == "__main__":
147 App(title="Animated Model", width=800, height=600).run(AnimatedModel())