⚠️ Beta State

PyBevy is in an early and experimental stage. The API is incomplete, subject to breaking changes without notice, and you should expect bugs. Many features are still under development.

3D Skinned Animation

Play animations on a 3D model with a skeleton.

Introduction

Many 3D models, especially characters, have an internal skeleton (or "armature") that allows them to be animated. This is called "skinned animation". pybevy can play these animations directly from a loaded glTF file.

The animation workflow requires three things:

  1. An AnimationGraph that describes which clips to play.
  2. An AnimationPlayer component (added automatically to rigged models).
  3. A way to detect when the scene is ready, since models load asynchronously.
from pybevy.prelude import *
from pybevy.animation import (
    AnimationClip,
    AnimationGraph,
    AnimationGraphHandle,
    AnimationNodeIndex,
    AnimationPlayer,
)
from pybevy.scene import SceneInstanceReady

A Component to Store Animation Info

Since the model loads asynchronously, we store the graph handle and animation index on the scene entity so we can find them later when the scene is ready.

FOX_PATH = "models/animated/Fox.glb"
 
@component
class AnimationToPlay(Component):
    def __init__(
        self,
        graph_handle: Handle[AnimationGraph],
        index: AnimationNodeIndex,
    ):
        self.graph_handle = graph_handle
        self.index = index

Setup

We load the Fox model and create an AnimationGraph from one of its animation clips. The graph tells the animation system which clips are available and how they relate to each other. AnimationGraph.from_clip() creates a simple graph with a single clip.

def setup(
    commands: Commands,
    asset_server: Res[AssetServer],
    graphs: ResMut[Assets[AnimationGraph]],
    meshes: ResMut[Assets[Mesh]],
    materials: ResMut[Assets[StandardMaterial]],
):
    # Create an AnimationGraph from the Fox's "Run" animation (index 2)
    graph, index = AnimationGraph.from_clip(
        asset_server.load(
            GltfAssetLabel.Animation(2).from_asset(FOX_PATH), AnimationClip
        ),
    )
    graph_handle = graphs.add(graph)
 
    # Spawn the Fox model with our animation info attached
    commands.spawn(
        AnimationToPlay(graph_handle, index),
        SceneRoot(asset_server.load(
            GltfAssetLabel.Scene(0).from_asset(FOX_PATH), Scene
        )),
    )
 
    # Camera and light
    commands.spawn(
        Camera3d(),
        Transform.from_xyz(100.0, 100.0, 150.0).looking_at(Vec3(0.0, 20.0, 0.0), Vec3.Y),
    )
    commands.spawn(
        DirectionalLight(illuminance=5000.0, shadows_enabled=True),
        Transform.from_rotation(Quat.from_euler(EulerRot.ZYX, 0.0, 1.0, -0.785)),
    )
    commands.spawn(
        Mesh3d(meshes.add(Plane3d(Vec3.Y, Vec2(5000.0, 5000.0)))),
        MeshMaterial3d(materials.add(Color.srgb(0.3, 0.5, 0.3))),
    )

Starting Animation When the Scene Is Ready

GLB scenes load asynchronously. When a scene finishes spawning all its entities, PyBevy sends a SceneInstanceReady message. We listen for this message, then walk the entity hierarchy to find the AnimationPlayer component that Bevy automatically added to the skeleton entity.

Once found, we call player.play(index).repeat() and attach the AnimationGraphHandle so the animation system knows which graph to use.

def play_animation_when_ready(
    commands: Commands,
    scene_ready: MessageReader[SceneInstanceReady],
    animations_to_play: Query[AnimationToPlay],
    children_query: Query[Children],
    players: Query[Mut[AnimationPlayer]],
):
    for event in scene_ready:
        anim = animations_to_play.get(event.entity)
        if anim is None:
            continue
 
        # Walk the hierarchy to find the AnimationPlayer
        for child in iter_descendants(event.entity, children_query):
            player = players.get(child)
            if player is not None:
                player.play(anim.index).repeat()
                commands.entity(child).insert(
                    AnimationGraphHandle(anim.graph_handle)
                )
 
def iter_descendants(entity: Entity, children_query: Query[Children]):
    children_list = children_query.get(entity)
    if children_list is None:
        return
    for child in children_list:
        yield child
        yield from iter_descendants(child, children_query)

Running the App

When the app runs, the Fox model will load asynchronously. Once the scene is ready, play_animation_when_ready finds the skeleton's AnimationPlayer and starts the "Run" animation on loop.

@entrypoint
def main(app: App) -> App:
    return (
        app
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .add_systems(Update, play_animation_when_ready)
    )
 
if __name__ == "__main__":
    main().run()