Skip to content

Commit 2ea8f77

Browse files
authored
Prevent AnimationGraph from serializing AssetIds. (#19615)
# Objective - A step towards #19024. - `AnimationGraph` can serialize raw `AssetId`s. However for normal handles, this is a runtime ID. This means it is unlikely that the `AssetId` will correspond to the same asset after deserializing - effectively breaking the graph. ## Solution - Stop allowing `AssetId` to be serialized by `AnimationGraph`. Serializing a handle with no path is now an error. - Add `MigrationSerializedAnimationClip`. This is an untagged enum for serde, meaning that it will take the first variant that deserializes. So it will first try the "modern" version, then it will fallback to the legacy version. - Add some logging/error messages to explain what users should do. Note: one limitation here is that this removes the ability to serialize and deserialize UUIDs. In theory, someone could be using this to have a "default" animation. If someone inserts an empty `AnimationClip` into the `Handle::default()`, this **might** produce a T-pose. It might also do nothing though. Unclear! I think this is worth the risk for simplicity as it seems unlikely that people are sticking UUIDs in here (or that you want a default animation in **any** AnimationGraph). ## Testing - Ran `cargo r --example animation_graph -- --save` on main, then ran `cargo r --example animation_graph` on this PR. The PR was able to load the old data (after #19631).
1 parent 6eb6afe commit 2ea8f77

File tree

4 files changed

+162
-63
lines changed

4 files changed

+162
-63
lines changed

assets/animation_graphs/Fox.animgraph.ron

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,20 @@
99
(
1010
node_type: Blend,
1111
mask: 0,
12-
weight: 1.0,
12+
weight: 0.5,
1313
),
1414
(
15-
node_type: Clip(AssetPath("models/animated/Fox.glb#Animation0")),
15+
node_type: Clip("models/animated/Fox.glb#Animation0"),
1616
mask: 0,
1717
weight: 1.0,
1818
),
1919
(
20-
node_type: Clip(AssetPath("models/animated/Fox.glb#Animation1")),
20+
node_type: Clip("models/animated/Fox.glb#Animation1"),
2121
mask: 0,
2222
weight: 1.0,
2323
),
2424
(
25-
node_type: Clip(AssetPath("models/animated/Fox.glb#Animation2")),
25+
node_type: Clip("models/animated/Fox.glb#Animation2"),
2626
mask: 0,
2727
weight: 1.0,
2828
),

crates/bevy_animation/src/graph.rs

Lines changed: 134 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use bevy_ecs::{
1919
system::{Res, ResMut},
2020
};
2121
use bevy_platform::collections::HashMap;
22-
use bevy_reflect::{prelude::ReflectDefault, Reflect, ReflectSerialize};
22+
use bevy_reflect::{prelude::ReflectDefault, Reflect};
2323
use derive_more::derive::From;
2424
use petgraph::{
2525
graph::{DiGraph, NodeIndex},
@@ -29,6 +29,7 @@ use ron::de::SpannedError;
2929
use serde::{Deserialize, Serialize};
3030
use smallvec::SmallVec;
3131
use thiserror::Error;
32+
use tracing::warn;
3233

3334
use crate::{AnimationClip, AnimationTargetId};
3435

@@ -108,9 +109,8 @@ use crate::{AnimationClip, AnimationTargetId};
108109
/// [RON]: https://github.com/ron-rs/ron
109110
///
110111
/// [RFC 51]: https://github.com/bevyengine/rfcs/blob/main/rfcs/51-animation-composition.md
111-
#[derive(Asset, Reflect, Clone, Debug, Serialize)]
112-
#[reflect(Serialize, Debug, Clone)]
113-
#[serde(into = "SerializedAnimationGraph")]
112+
#[derive(Asset, Reflect, Clone, Debug)]
113+
#[reflect(Debug, Clone)]
114114
pub struct AnimationGraph {
115115
/// The `petgraph` data structure that defines the animation graph.
116116
pub graph: AnimationDiGraph,
@@ -242,20 +242,40 @@ pub enum AnimationNodeType {
242242
#[derive(Default)]
243243
pub struct AnimationGraphAssetLoader;
244244

245-
/// Various errors that can occur when serializing or deserializing animation
246-
/// graphs to and from RON, respectively.
245+
/// Errors that can occur when serializing animation graphs to RON.
246+
#[derive(Error, Debug)]
247+
pub enum AnimationGraphSaveError {
248+
/// An I/O error occurred.
249+
#[error(transparent)]
250+
Io(#[from] io::Error),
251+
/// An error occurred in RON serialization.
252+
#[error(transparent)]
253+
Ron(#[from] ron::Error),
254+
/// An error occurred converting the graph to its serialization form.
255+
#[error(transparent)]
256+
ConvertToSerialized(#[from] NonPathHandleError),
257+
}
258+
259+
/// Errors that can occur when deserializing animation graphs from RON.
247260
#[derive(Error, Debug)]
248261
pub enum AnimationGraphLoadError {
249262
/// An I/O error occurred.
250-
#[error("I/O")]
263+
#[error(transparent)]
251264
Io(#[from] io::Error),
252-
/// An error occurred in RON serialization or deserialization.
253-
#[error("RON serialization")]
265+
/// An error occurred in RON deserialization.
266+
#[error(transparent)]
254267
Ron(#[from] ron::Error),
255268
/// An error occurred in RON deserialization, and the location of the error
256269
/// is supplied.
257-
#[error("RON serialization")]
270+
#[error(transparent)]
258271
SpannedRon(#[from] SpannedError),
272+
/// The deserialized graph contained legacy data that we no longer support.
273+
#[error(
274+
"The deserialized AnimationGraph contained an AnimationClip referenced by an AssetId, \
275+
which is no longer supported. Consider manually deserializing the SerializedAnimationGraph \
276+
type and determine how to migrate any SerializedAnimationClip::AssetId animation clips"
277+
)]
278+
GraphContainsLegacyAssetId,
259279
}
260280

261281
/// Acceleration structures for animation graphs that allows Bevy to evaluate
@@ -388,18 +408,32 @@ pub struct SerializedAnimationGraphNode {
388408
#[derive(Serialize, Deserialize)]
389409
pub enum SerializedAnimationNodeType {
390410
/// Corresponds to [`AnimationNodeType::Clip`].
391-
Clip(SerializedAnimationClip),
411+
Clip(MigrationSerializedAnimationClip),
392412
/// Corresponds to [`AnimationNodeType::Blend`].
393413
Blend,
394414
/// Corresponds to [`AnimationNodeType::Add`].
395415
Add,
396416
}
397417

398-
/// A version of `Handle<AnimationClip>` suitable for serializing as an asset.
418+
/// A type to facilitate migration from the legacy format of [`SerializedAnimationGraph`] to the
419+
/// new format.
399420
///
400-
/// This replaces any handle that has a path with an [`AssetPath`]. Failing
401-
/// that, the asset ID is serialized directly.
421+
/// By using untagged serde deserialization, we can try to deserialize the modern form, then
422+
/// fallback to the legacy form. Users must migrate to the modern form by Bevy 0.18.
423+
// TODO: Delete this after Bevy 0.17.
402424
#[derive(Serialize, Deserialize)]
425+
#[serde(untagged)]
426+
pub enum MigrationSerializedAnimationClip {
427+
/// This is the new type of this field.
428+
Modern(AssetPath<'static>),
429+
/// This is the legacy type of this field. Users must migrate away from this.
430+
#[serde(skip_serializing)]
431+
Legacy(SerializedAnimationClip),
432+
}
433+
434+
/// The legacy form of serialized animation clips. This allows raw asset IDs to be deserialized.
435+
// TODO: Delete this after Bevy 0.17.
436+
#[derive(Deserialize)]
403437
pub enum SerializedAnimationClip {
404438
/// Records an asset path.
405439
AssetPath(AssetPath<'static>),
@@ -648,12 +682,13 @@ impl AnimationGraph {
648682
///
649683
/// If writing to a file, it can later be loaded with the
650684
/// [`AnimationGraphAssetLoader`] to reconstruct the graph.
651-
pub fn save<W>(&self, writer: &mut W) -> Result<(), AnimationGraphLoadError>
685+
pub fn save<W>(&self, writer: &mut W) -> Result<(), AnimationGraphSaveError>
652686
where
653687
W: Write,
654688
{
655689
let mut ron_serializer = ron::ser::Serializer::new(writer, None)?;
656-
Ok(self.serialize(&mut ron_serializer)?)
690+
let serialized_graph: SerializedAnimationGraph = self.clone().try_into()?;
691+
Ok(serialized_graph.serialize(&mut ron_serializer)?)
657692
}
658693

659694
/// Adds an animation target (bone) to the mask group with the given ID.
@@ -758,28 +793,55 @@ impl AssetLoader for AnimationGraphAssetLoader {
758793
let serialized_animation_graph = SerializedAnimationGraph::deserialize(&mut deserializer)
759794
.map_err(|err| deserializer.span_error(err))?;
760795

761-
// Load all `AssetPath`s to convert from a
762-
// `SerializedAnimationGraph` to a real `AnimationGraph`.
763-
Ok(AnimationGraph {
764-
graph: serialized_animation_graph.graph.map(
765-
|_, serialized_node| AnimationGraphNode {
766-
node_type: match serialized_node.node_type {
767-
SerializedAnimationNodeType::Clip(ref clip) => match clip {
768-
SerializedAnimationClip::AssetId(asset_id) => {
769-
AnimationNodeType::Clip(Handle::Weak(*asset_id))
770-
}
771-
SerializedAnimationClip::AssetPath(asset_path) => {
772-
AnimationNodeType::Clip(load_context.load(asset_path))
796+
// Load all `AssetPath`s to convert from a `SerializedAnimationGraph` to a real
797+
// `AnimationGraph`. This is effectively a `DiGraph::map`, but this allows us to return
798+
// errors.
799+
let mut animation_graph = DiGraph::with_capacity(
800+
serialized_animation_graph.graph.node_count(),
801+
serialized_animation_graph.graph.edge_count(),
802+
);
803+
804+
let mut already_warned = false;
805+
for serialized_node in serialized_animation_graph.graph.node_weights() {
806+
animation_graph.add_node(AnimationGraphNode {
807+
node_type: match serialized_node.node_type {
808+
SerializedAnimationNodeType::Clip(ref clip) => match clip {
809+
MigrationSerializedAnimationClip::Modern(path) => {
810+
AnimationNodeType::Clip(load_context.load(path.clone()))
811+
}
812+
MigrationSerializedAnimationClip::Legacy(
813+
SerializedAnimationClip::AssetPath(path),
814+
) => {
815+
if !already_warned {
816+
let path = load_context.asset_path();
817+
warn!(
818+
"Loaded an AnimationGraph asset at \"{path}\" which contains a \
819+
legacy-style SerializedAnimationClip. Please re-save the asset \
820+
using AnimationGraph::save to automatically migrate to the new \
821+
format"
822+
);
823+
already_warned = true;
773824
}
774-
},
775-
SerializedAnimationNodeType::Blend => AnimationNodeType::Blend,
776-
SerializedAnimationNodeType::Add => AnimationNodeType::Add,
825+
AnimationNodeType::Clip(load_context.load(path.clone()))
826+
}
827+
MigrationSerializedAnimationClip::Legacy(
828+
SerializedAnimationClip::AssetId(_),
829+
) => {
830+
return Err(AnimationGraphLoadError::GraphContainsLegacyAssetId);
831+
}
777832
},
778-
mask: serialized_node.mask,
779-
weight: serialized_node.weight,
833+
SerializedAnimationNodeType::Blend => AnimationNodeType::Blend,
834+
SerializedAnimationNodeType::Add => AnimationNodeType::Add,
780835
},
781-
|_, _| (),
782-
),
836+
mask: serialized_node.mask,
837+
weight: serialized_node.weight,
838+
});
839+
}
840+
for edge in serialized_animation_graph.graph.raw_edges() {
841+
animation_graph.add_edge(edge.source(), edge.target(), ());
842+
}
843+
Ok(AnimationGraph {
844+
graph: animation_graph,
783845
root: serialized_animation_graph.root,
784846
mask_groups: serialized_animation_graph.mask_groups,
785847
})
@@ -790,37 +852,50 @@ impl AssetLoader for AnimationGraphAssetLoader {
790852
}
791853
}
792854

793-
impl From<AnimationGraph> for SerializedAnimationGraph {
794-
fn from(animation_graph: AnimationGraph) -> Self {
795-
// If any of the animation clips have paths, then serialize them as
796-
// `SerializedAnimationClip::AssetPath` so that the
797-
// `AnimationGraphAssetLoader` can load them.
798-
Self {
799-
graph: animation_graph.graph.map(
800-
|_, node| SerializedAnimationGraphNode {
801-
weight: node.weight,
802-
mask: node.mask,
803-
node_type: match node.node_type {
804-
AnimationNodeType::Clip(ref clip) => match clip.path() {
805-
Some(path) => SerializedAnimationNodeType::Clip(
806-
SerializedAnimationClip::AssetPath(path.clone()),
807-
),
808-
None => SerializedAnimationNodeType::Clip(
809-
SerializedAnimationClip::AssetId(clip.id()),
810-
),
811-
},
812-
AnimationNodeType::Blend => SerializedAnimationNodeType::Blend,
813-
AnimationNodeType::Add => SerializedAnimationNodeType::Add,
855+
impl TryFrom<AnimationGraph> for SerializedAnimationGraph {
856+
type Error = NonPathHandleError;
857+
858+
fn try_from(animation_graph: AnimationGraph) -> Result<Self, NonPathHandleError> {
859+
// Convert all the `Handle<AnimationClip>` to AssetPath, so that
860+
// `AnimationGraphAssetLoader` can load them. This is effectively just doing a
861+
// `DiGraph::map`, except we need to return an error if any handles aren't associated to a
862+
// path.
863+
let mut serialized_graph = DiGraph::with_capacity(
864+
animation_graph.graph.node_count(),
865+
animation_graph.graph.edge_count(),
866+
);
867+
for node in animation_graph.graph.node_weights() {
868+
serialized_graph.add_node(SerializedAnimationGraphNode {
869+
weight: node.weight,
870+
mask: node.mask,
871+
node_type: match node.node_type {
872+
AnimationNodeType::Clip(ref clip) => match clip.path() {
873+
Some(path) => SerializedAnimationNodeType::Clip(
874+
MigrationSerializedAnimationClip::Modern(path.clone()),
875+
),
876+
None => return Err(NonPathHandleError),
814877
},
878+
AnimationNodeType::Blend => SerializedAnimationNodeType::Blend,
879+
AnimationNodeType::Add => SerializedAnimationNodeType::Add,
815880
},
816-
|_, _| (),
817-
),
881+
});
882+
}
883+
for edge in animation_graph.graph.raw_edges() {
884+
serialized_graph.add_edge(edge.source(), edge.target(), ());
885+
}
886+
Ok(Self {
887+
graph: serialized_graph,
818888
root: animation_graph.root,
819889
mask_groups: animation_graph.mask_groups,
820-
}
890+
})
821891
}
822892
}
823893

894+
/// Error for when only path [`Handle`]s are supported.
895+
#[derive(Error, Debug)]
896+
#[error("AnimationGraph contains a handle to an AnimationClip that does not correspond to an asset path")]
897+
pub struct NonPathHandleError;
898+
824899
/// A system that creates, updates, and removes [`ThreadedAnimationGraph`]
825900
/// structures for every changed [`AnimationGraph`].
826901
///

examples/animation/animation_graph.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,10 @@ fn setup_assets_programmatically(
182182
.spawn(async move {
183183
use std::io::Write;
184184

185+
let animation_graph: SerializedAnimationGraph = animation_graph
186+
.try_into()
187+
.expect("The animation graph failed to convert to its serialized form");
188+
185189
let serialized_graph =
186190
ron::ser::to_string_pretty(&animation_graph, PrettyConfig::default())
187191
.expect("Failed to serialize the animation graph");
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
title: `AnimationGraph` no longer supports raw AssetIds.
3+
pull_requests: []
4+
---
5+
6+
In previous versions of Bevy, `AnimationGraph` would serialize `Handle<AnimationClip>` as an asset
7+
path, and if that wasn't available it would fallback to serializing `AssetId<AnimationClip>`. In
8+
practice, this was not very useful. `AssetId` is (usually) a runtime-generated ID. This means for an
9+
arbitrary `Handle<AnimationClip>`, it was incredibly unlikely that your handle before serialization
10+
would correspond to the same asset as after serialization.
11+
12+
This confusing behavior has been removed. As a side-effect, any `AnimationGraph`s you previously
13+
saved (via `AnimationGraph::save`) will need to be re-saved. These legacy `AnimationGraph`s can
14+
still be loaded until the next Bevy version. Loading and then saving the `AnimationGraph` again will
15+
automatically migrate the `AnimationGraph`.
16+
17+
If your `AnimationGraph` contained serialized `AssetId`s, you will need to manually load the bytes
18+
of the saved graph, deserialize it into `SerializedAnimationGraph`, and then manually decide how to
19+
migrate those `AssetId`s. Alternatively, you could simply rebuild the graph from scratch and save a
20+
new instance. We expect this to be a very rare situation.

0 commit comments

Comments
 (0)