Animated Model

load a rigged glTF and play its skeletal animation.

▶ Run in browser

Tags: 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())