When evaluating an animation target, only evaluate the subgraph of the animation graph relevant to that target. - #25292
When evaluating an animation target, only evaluate the subgraph of the animation graph relevant to that target.#25292pcwalton wants to merge 1 commit into
Conversation
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.
|
Profiling on my system shows ~35ms -> ~0.39ms for animate_targets. Nice work! |
JMS55
left a comment
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
Also need to clear animation_target_to_threaded_subgraph?
stuartparmenter
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
Only used in tests. Can it move to [dev-dependencies]
| } | ||
| } | ||
| } | ||
| AssetEvent::Unused { .. } | AssetEvent::Removed { .. } => {} |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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()) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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>>, |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
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.