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:
- An AnimationGraph that describes which clips to play.
- An AnimationPlayer component (added automatically to rigged models).
- 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 SceneInstanceReadyA 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 = indexSetup
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()