Skip to content

When evaluating an animation target, only evaluate the subgraph of the animation graph relevant to that target. - #25292

Open
pcwalton wants to merge 1 commit into
bevyengine:mainfrom
pcwalton:prune-graph
Open

When evaluating an animation target, only evaluate the subgraph of the animation graph relevant to that target.#25292
pcwalton wants to merge 1 commit into
bevyengine:mainfrom
pcwalton:prune-graph

Conversation

@pcwalton

@pcwalton pcwalton commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Currently, Bevy evaluates the entire animation graph for each target, including nodes that don't contribute to the transform of the target being evaluated. This can be inefficient for many common animation graphs. For example, suppose you have a graph for an animated character that contains blend nodes for facial animation and locomotion. Facial bones don't care about the locomotion blending, so they don't need to evaluate the portions of the graph that only affect targets involved in locomotion. At worst, this can result in O(n²) operation, where n is the number of targets, if the entire graph consists of a single blend node attached to one clip for every target, and each such clip animates only that target.

This PR fixes the issue by generating subgraphs of each animation graph for each target. During the graph threading step, we create subgraphs of the animation graph specific to each target. The target subgraph of a graph consists of all clips that animate that target as well as all blend and add nodes that transitively take those clips into account. Because we already have a postorder traversal of the graph, creating those subgraphs can be done in O(n) time for each target, where n is the number of nodes in the graph.

Note that this optimization is a time/space tradeoff, and this patch does increase the memory usage of animation graphs. For cases like zero_day, this tradeoff is well worth it as it makes the difference between the demo being usable and the demo being unusable. There is a memory usage cost here, however. As follow-up work, we may wish to de-duplicate identical graphs, and/or to avoid creating subgraphs entirely if the subgraph in question is identical to the main graph in the first place.

animation graph relevant to that target.

Currently, Bevy evaluates the entire animation graph for each target,
including nodes that don't contribute to the transform of the target
being evaluated. This can be inefficient for many common animation
graphs. For example, suppose you have a graph for an animated character
that contains blend nodes for facial animation and locomotion. Facial
bones don't care about the locomotion blending, so they don't need to
evaluate the portions of the graph that only affect targets involved in
locomotion. At worst, this can result in O(n²) operation, where n is the
number of targets, if the entire graph consists of a single blend node
attached to one clip for every target, and each such clip animates only
that target.

This PR fixes the issue by generating *subgraphs* of each animation
graph for each target. During the graph threading step, we create
subgraphs of the animation graph specific to each target. The target
subgraph of a graph consists of all clips that animate that target as
well as all blend and add nodes that transitively take those clips into
account. Because we already have a postorder traversal of the graph,
creating those subgraphs can be done in O(n) time for each target, where
n is the number of nodes in the graph.

Note that this optimization is a time/space tradeoff, and this patch
does increase the memory usage of animation graphs. For cases like
`zero_day`, this tradeoff is well worth it as it makes the difference
between the demo being usable and the demo being unusable. There *is* a
memory usage cost here, however. As follow-up work, we may wish to
de-duplicate identical graphs, and/or to avoid creating subgraphs
entirely if the subgraph in question is identical to the main graph in
the first place.
@pcwalton pcwalton added the A-Animation Make things move and change over time label Aug 4, 2026
@github-project-automation github-project-automation Bot moved this to Needs SME Triage in Animation Aug 4, 2026
@pcwalton pcwalton added C-Performance A change motivated by improving speed, memory usage or compile times S-Needs-Benchmarking This set of changes needs performance benchmarking to double-check that they help S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Aug 4, 2026
@JMS55

JMS55 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Profiling on my system shows ~35ms -> ~0.39ms for animate_targets. Nice work!

@JMS55 JMS55 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about AnimationClip events? Do they get pruned too?

self.threaded_graph.clear();
self.sorted_edge_ranges.clear();
self.sorted_edges.clear();
self.animation_clips.clear();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also need to clear animation_target_to_threaded_subgraph?

@stuartparmenter stuartparmenter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for putting this together. It makes a huge difference on Zero Day!

/// See [`ThreadedAnimationGraph::threaded_graph`] for more information.
pub threaded_graph: Vec<AnimationNodeIndex>,

/// A mapping from each parent node index to the range within

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This vec is parallel to threaded_graph, not indexed by node index.

smallvec = { version = "1", default-features = false }
tracing = { version = "0.1", default-features = false, features = ["std"] }
bitvec = { version = "1" }
itertools = "0.14"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only used in tests. Can it move to [dev-dependencies]

}
}
}
AssetEvent::Unused { .. } | AssetEvent::Removed { .. } => {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clip_to_graphs can leak... removed clips keep their entries, and removed graphs leave empty sets behind. Could remove_from_clip_to_graphs_table drop an entry when its set goes empty? (Handling clip Removed here doesn't work, since a graph may still reference the clip and a re-add under the same id needs this entry to trigger a rebuild.)

mut animation_clip_asset_events: MessageReader<AssetEvent<AnimationClip>>,
mut animation_target_graphs_rebuilt_this_frame: Local<HashSet<AssetId<AnimationGraph>>>,
) {
let threaded_animation_graphs = threaded_animation_graphs.into_inner();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

into_inner marks the resource as changed every time this system runs, even on frames with no events. An early return when both event readers are empty seems better.

.get(&target_id)
else {
trace!(
"Failed to find threaded subgraph for {:?}; animation won't play",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like before this, a clip loading into an already-threaded graph took effect the same frame, since animate_targets reads clip assets directly. Now the target has no subgraph until the clip event is processed next frame, so it sits unanimated for one frame. Is that OK?

/// that reference that clip.
///
/// Note that, because graphs can load before their clips do, when a clip
/// loads, we have to invalidate portions of this table.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: subgraphs get invalidated when a clip loads, not this table

// this target.
if clips
.get(clip_handle)
.is_some_and(|clip| clip.curves_for_target(animation_target_id).is_some())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mask skip in animate_targets uses only data that is fixed at rebuild time (mask_groups and computed_masks). Could we also clip nodes that are fully masked out for the target be excluded here too?

AnimationNodeType::Clip(clip_handle) => {
// Only add this node if the clip has curves that affect
// this target.
if clips

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clip lookups run for every clip node times every target, so I think rebuilds are O(n^2) hash lookups for Zero Day. Would a one-time pass in rebuild_target_subgraphs building clip node -> &AnimationClip (or target -> Vec<clip node>) be worth it?

/// [`Self::sorted_edges`].
///
/// See [`ThreadedAnimationGraph::sorted_edge_ranges`] for more information.
pub sorted_edge_ranges: Vec<Range<u32>>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: These ranges are contiguous, since each add_node starts where the last one ended. A Vec<u32> of end offsets would halve the array.


let Some(threaded_animation_subgraph) = threaded_animation_graph
.animation_target_to_threaded_subgraph
.get(&target_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this breaks add_event_to_target. Events fire before the curves_for_target check today, so a clip can have events for a target it has no curves for. This pass would prune that clip node (and a target with no curves anywhere never gets a subgraph), so those events just stop firing. Maybe treat a clip as relevant if clip.events mentions the target? Same for rebuild_target_subgraphs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Animation Make things move and change over time C-Performance A change motivated by improving speed, memory usage or compile times S-Needs-Benchmarking This set of changes needs performance benchmarking to double-check that they help S-Needs-Review Needs reviewer attention (from anyone!) to move forward

Projects

Status: Needs SME Triage

Development

Successfully merging this pull request may close these issues.

3 participants