Grégoire Locqueville | Animation in Bevy: The Big Picture

💥 Explore this must-read post from Hacker News 📖

📂 **Category**:

✅ **What You’ll Learn**:

August 27, 2026

So you just downloaded an animated 3D character .glb file on a free assets website. You have a basic Bevy application running. Now you want to spawn your character in your Bevy app. Easy enough, there’s probably a function for that. You look up the basic Animated Mesh example on Bevy’s website… and it quickly becomes apparent that things are not as simple as you’ve hoped. There’s a lot of setting things up, animation-related types whose exact purpose is not obvious… You might be able to tweak the examples to fit your needs reasonably fast, but actually forming a mental model of how animation works in Bevy, based on the examples, is going to take some serious pondering.

This post is what I wish I had when trying to understand animation in Bevy a couple weeks ago. I start by building up, step by step, enough of a mental model to deal with basic animation in Bevy. Then I walk you through the official example, explaining how it relates to that mental model, and also holding your hand a bit when encountering methods you might not have encountered yet.

If you are comfortable with the basics of Bevy’s ECS, you’re in this post’s target audience 🙂

First Intuition

Let’s forget about Bevy and ECS for a second, and think (very abstractly) about what it takes to get an animated 3D model to move. You need two ingredients:

  1. A spawned 3D model (or some kind of reference to one),
  2. An animation to be played (or some kind of reference to one).

So at first, the way you might expect to play an animation is something like that:

my_model.play_animation(my_animation);

Here’s what our mental model looks like so far:

A diagram with a blue box titled 3D Model on the left, a red box titled animation data on the right, and a yellow oval titled play connecting both boxes

Now this is actually somewhat close to the way an actual Bevy function works, namely, the play method from the AnimationPlayer type… or at least it would be, if its self actually referred to a 3D model, and its animation argument actually referred to an animation. Right now, though, it’s not clear that they do. But let’s look in more detail into the way our two ingredients are represented in Bevy, and maybe we can reconcile our intuition with the way the play function works.

AnimationPlayer: A way to control a 3D model’s animations

Say you spawn a 3D model from a .glb file. Ideally, you’d like to refer to it using the ID of the spawned entity, except 3D models are generally not spawned as a single entity, but rather as a hierarchy of entities:

A diagram with a blue box titled 3D Model enclosing a tree of boxes representing entities, each containing a couple boxes representing components

To animate your model, you need to refer to it some other way.

Bevy has a mechanism to do just that — refer to a 3D model for animation — , in the form of the AnimationPlayer type. AnimationPlayer is a Component that is automatically inserted in an Entitysomewhere in the entity hierarchy corresponding to an animatable 3D model, when that model is spawned.

A diagram representing the same hierarchy of entities, except one of the components is yellow and represents the animation player component

Once you have gotten a hold of an AnimationPlayer, you can tell it to play/pause an animation, access the one currently playing… but that’s assuming you have animations in the first place! Let’s focus on those now.

AnimationGraph: A way to store and combine animations

The way animations are represented in Bevy is a little complex, because what you’ll generally manipulate is not single animations, but instances of a data structure able to store several animations at once and combine them with one another. That structure is the AnimationGraph.

I won’t get into the details of how AnimationGraphs are used here; once you’re comfortable with simple animations, you can learn more about general animation graphs with the the Animation Graph example. For now we’ll focus on cases where our graph contains only one animation that we’re interested in. In those cases, the data that identifies your animation will be:

  1. (A reference to) an AnimationGraph,
  2. An identifier for where your animation actually sits inside the graph — that’s what the NodeIndex type is for.

Putting it Together

Ok, say you have an AnimationPlayer, as well as an AnimationGraph and a NodeIndex. Here’s how you connect them to play your animation:

  1. Insert a reference to your AnimationGraph as a component on the same entity as your AnimationPlayer. That’s exactly what the AnimationGraphHandle is for: it holds a Handle to an AnimationGraph, and it implements the Component trait.
  2. Call my_animation_player.play(my_node_index);.

That’s it! Upon that call, my_animation_player knows to look for the AnimationGraph that’s a component on the same entity as itself, it looks for the animation with index NodeIndex in that graph, and animates the model it belongs to.

Here’s how I picture things at this point:

Same diagram representing an entity hierarchy enclosed in a blue box, with one of the components colored yellow to represent the AnimationPlayer component, but now there's an additional component in the same entity as the AnimationPlayer. That new component is colored red and stands for the AnimationGraphHandle component. On the right of all that, there's a red box titled Animation Data, containing a subbox titled AnimationGraph and a subbox named NodeIndex. A red arrow connects the AnimationGraphHandle to the AnimationGraph box. An oval titled play connects the AnimationPlayer component with the NodeIndex box.

Now let’s look at the example code again.

A Walk through the Example Code

In this section, I’ll simply walk through Animated Mesh, from Bevy’s official examples, explaining how it relates to what I said above along the way. I’ll mostly go in chronological order, though I may reorder or even skip over some sections. My aim is to provide an explanation that’s a little more in-depth than the existing inline comments (which are already pretty thorough as far as comments go!), and connect it to what we’ve talked about in the abstract until now.

The first lines are standard Bevy stuff; just note that we add the system setup_mesh_and_animation, where we’ll put the animation logic. Let’s look at that system now, which runs at startup. First thing we do is extract animation data from our .glb file:

let (graph, index) = AnimationGraph::from_clip(
    asset_server.load(GltfAssetLabel::Animation(2).from_asset(GLTF_PATH)),
);

There’s some boilerplate involved to load the animation clip; you don’t need to understand the detail of each function call, however you can note a few things:

  • The GltfAssetLabel::Animation constructor takes an integer that refers to your animation inside the .glb file. If you don’t know the exact structure of your file, you’ll have to guess: try putting in numbers like 0, 1, 2, and see if your animation shows up.
  • The from_clip method returns both an AnimationGraph and a NodeIndex in that graph — exactly the data you need to refer to an animation.

Then we add the graph we get to the asset store, and keep a Handle to it:

let graph_handle = graphs.add(graph);

We also load our mesh; like for the graph, there’s some boilerplate involved which you don’t need to completely understand right away:

let mesh_scene =
    WorldAssetRoot(asset_server.load(GltfAssetLabel::Scene(0).from_asset(GLTF_PATH)));

It’s enough to know that the mesh_scene you get can be spawned right away with the spawn method. Here, however, for convenience, we bundle it with a custom component, animation_to_play (which has custom type AnimationToPlay, defined earlier in the example). animation_to_play contains the two pieces of animation data we will need, namely the animation graph handle and the graph index:

commands
    .spawn((animation_to_play, mesh_scene))

And then we pass control to the play_animation_when_ready system once things are properly spawned:

.observe(play_animation_when_ready);

Because it is called on the result of a spawn call, this observe call injects the spawned entity to the system it is given as argument. In other words, the root entity of our mesh scene, which we just spawned, can be accessed, as we will see, from the body of play_animation_when_ready, in the form of the entity field of the scene_ready argument.

The remaining tasks are a little less straightforward than what we’ve done so far. We need to:

  1. Locate the entity that has the AnimationPlayer component,
  2. Add a handle to our animation graph as a component to it,
  3. Tell the animation player to play the animation we want.

We’ve got a problem with step 1, though: we loaded our mesh scene from a .glb file, which spawns a hierarchy of entities; the animation player will have been attached to some entity in that hierarchy, but we don’t know which! Maybe if we knew very well how our .glb file is structured, and how GLTFs are represented in Bevy’s ECS, we could infer what entity has the animation player…? Eh, forget that, we’ll just manually visit all descendants of the mesh scene root, and check whether they have an AnimationPlayer component.

We start by getting the root entity of our mesh scene hierarchy. As mentioned earlier, this is injected into this system as the entity field of the scene_ready argument. That entity should have a component of type AnimationToPlay containing the animation data we need, so we can directly get it:

if let Ok(animation_to_play) = animations_to_play.get(scene_ready.entity) {

(Don’t confuse animations_to_play, a Query for components of type AnimationToPlay, and animation_to_play, the actual AnimationToPlay that we get by looking at our entity through the prism of that query using the get method.)

We have the scene root (as well as our animation data), but the scene root is not where the AnimationPlayer component lives: all we know is that it will have been attached to some entity in our scene hierarchy, in a way that’s dependent on our .glb file and the specifics of how Bevy instantiates those files in its ECS. We don’t need to know about all that, though: we can just go through all entities in the scene (that is, all descendants of the root entity), and check whether they have an AnimationPlayer component:

for child in children.iter_descendants(scene_ready.entity) {
    if let Ok(mut player) = players.get_mut(child) {

(In case you’re not familiar with iter_descendants, it’s a method which you must call on a Query<&Children> (though you can replace Children with another RelationshipTarget if you’re interested in another kind of hierarchy) and which does what you’d expect, that is, iterate over all entities that descend from the argument you give it.)

Phew! We’ve done the hard part, find the entity with the animation player. Now we can add the animation graph to that entity (the example tells the player to play the animation before the graph is added, which I guess works, but makes less intuitive sense):

First we get the EntityCommands corresponding to the animation player entity, which will allow us to do stuff like add a component to it:

commands.entity(child)

and then we add to it, as a component, the handle to the animation graph contained in animation_to_play:

.insert(AnimationGraphHandle(animation_to_play.graph_handle.clone()));

Finally, we can tell our player to start playing the animation at the index specified in animation_to_play, and also tell it to loop:

player.play(animation_to_play.index).repeat();

🔥 **What’s your take?**
Share your thoughts in the comments below!

#️⃣ **#Grégoire #Locqueville #Animation #Bevy #Big #Picture**

🕒 **Posted on**: 1788905890

🌟 **Want more?** Click here for more info! 🌟

By

Leave a Reply

Your email address will not be published. Required fields are marked *