Add dedicated skinned rendering, pose restoration, shared Material and Material Instance slots, registry-driven components, Surface/Solari integration, transactional schema upgrades, navigation authoring, documentation, and evaluation evidence.
396 lines
13 KiB
Rust
396 lines
13 KiB
Rust
//! Persistent animation authoring data and generated import-manifest contract.
|
|
|
|
use bevy::prelude::*;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::EditorAssetRef;
|
|
|
|
pub const ANIMATION_MANIFEST_SCHEMA_VERSION: u32 = 3;
|
|
pub const ANIMATION_ARTIFACT_DIR: &str = "assets/animations/generated";
|
|
pub const ANIMATION_CLIP_SUB_ASSET_PREFIX: &str = "animation:clip:";
|
|
pub const ANIMATION_SKELETON_SUB_ASSET_PREFIX: &str = "animation:skeleton:";
|
|
pub const COMPONENT_SKINNED_MESH_RENDERER: &str = "shared::animation::SkinnedMeshRenderer";
|
|
pub const COMPONENT_ANIMATION_CONTROLLER_DESC: &str = "shared::animation::AnimationControllerDesc";
|
|
|
|
const DEFAULT_CROSSFADE_SECONDS: f32 = 0.2;
|
|
const DEFAULT_PLAYBACK_SPEED: f32 = 1.0;
|
|
|
|
pub fn animation_clip_sub_asset_id(source_index: usize, label: &str) -> String {
|
|
animation_sub_asset_id(ANIMATION_CLIP_SUB_ASSET_PREFIX, source_index, label)
|
|
}
|
|
|
|
pub fn animation_skeleton_sub_asset_id(source_index: usize, label: &str) -> String {
|
|
animation_sub_asset_id(ANIMATION_SKELETON_SUB_ASSET_PREFIX, source_index, label)
|
|
}
|
|
|
|
pub fn animation_clip_source_index(sub_asset_id: &str) -> Option<usize> {
|
|
animation_source_index(sub_asset_id, ANIMATION_CLIP_SUB_ASSET_PREFIX)
|
|
}
|
|
|
|
pub fn animation_skeleton_source_index(sub_asset_id: &str) -> Option<usize> {
|
|
animation_source_index(sub_asset_id, ANIMATION_SKELETON_SUB_ASSET_PREFIX)
|
|
}
|
|
|
|
fn animation_sub_asset_id(prefix: &str, source_index: usize, label: &str) -> String {
|
|
let slug = stable_slug(label);
|
|
if slug.is_empty() {
|
|
format!("{prefix}{source_index}:unnamed")
|
|
} else {
|
|
format!("{prefix}{source_index}:{slug}")
|
|
}
|
|
}
|
|
|
|
fn animation_source_index(sub_asset_id: &str, prefix: &str) -> Option<usize> {
|
|
sub_asset_id
|
|
.strip_prefix(prefix)?
|
|
.split_once(':')?
|
|
.0
|
|
.parse()
|
|
.ok()
|
|
}
|
|
|
|
fn stable_slug(label: &str) -> String {
|
|
let mut slug = String::new();
|
|
for character in label.chars() {
|
|
if character.is_ascii_alphanumeric() {
|
|
slug.push(character.to_ascii_lowercase());
|
|
} else if !slug.ends_with('_') {
|
|
slug.push('_');
|
|
}
|
|
}
|
|
slug.trim_matches('_').to_string()
|
|
}
|
|
|
|
/// Exact identity of an imported skeleton's ordered joint paths and bind-pose structure.
|
|
#[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct AnimationSkeletonSignature(pub String);
|
|
|
|
impl AnimationSkeletonSignature {
|
|
pub fn new(signature: impl Into<String>) -> Self {
|
|
Self(signature.into())
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.0.trim().is_empty()
|
|
}
|
|
}
|
|
|
|
/// Authoring renderer for rigged geometry that must retain its imported joint hierarchy.
|
|
///
|
|
/// Unlike [`crate::StaticMeshRenderer`], this component never flattens source primitives into
|
|
/// independent mesh slots. Hydration instantiates the selected model scene so Bevy can preserve
|
|
/// its `SkinnedMesh`, joint entities, inverse bind poses, and animation player bindings.
|
|
#[derive(Component, Reflect, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct SkinnedMeshRenderer {
|
|
/// Stable asset-registry UUID. Empty only for legacy or unresolved references.
|
|
#[serde(default)]
|
|
pub asset_id: String,
|
|
/// Asset path relative to `assets/`, e.g. `models/character.glb`.
|
|
pub path: String,
|
|
/// Scene index whose hierarchy owns the skinned primitives and joints.
|
|
#[serde(default)]
|
|
pub scene_index: usize,
|
|
/// Stable material slots for every draw binding in the imported animated hierarchy.
|
|
#[serde(default)]
|
|
pub materials: crate::RendererMaterialSet,
|
|
}
|
|
|
|
impl SkinnedMeshRenderer {
|
|
pub fn new(path: impl Into<String>) -> Self {
|
|
Self {
|
|
asset_id: String::new(),
|
|
path: path.into(),
|
|
scene_index: 0,
|
|
materials: crate::RendererMaterialSet::default(),
|
|
}
|
|
}
|
|
|
|
pub fn with_asset_id(mut self, asset_id: impl Into<String>) -> Self {
|
|
self.asset_id = asset_id.into();
|
|
self
|
|
}
|
|
|
|
/// Asset-server path for FBX `#SceneN` labels (see `bevy_ufbx`).
|
|
pub fn fbx_scene_asset_path(path: &str, scene_index: usize) -> String {
|
|
format!("{path}#Scene{scene_index}")
|
|
}
|
|
}
|
|
|
|
impl Default for SkinnedMeshRenderer {
|
|
fn default() -> Self {
|
|
Self::new(String::new())
|
|
}
|
|
}
|
|
|
|
/// Authored playback window inside an imported clip.
|
|
#[derive(Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
|
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct AnimationPlaybackRange {
|
|
#[serde(default)]
|
|
pub start_seconds: f32,
|
|
/// `None` plays to the imported clip duration.
|
|
#[serde(default)]
|
|
pub end_seconds: Option<f32>,
|
|
}
|
|
|
|
impl Default for AnimationPlaybackRange {
|
|
fn default() -> Self {
|
|
Self {
|
|
start_seconds: 0.0,
|
|
end_seconds: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One normalized event marker imported with an animation clip.
|
|
#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct AnimationEventDesc {
|
|
pub id: String,
|
|
pub time_seconds: f32,
|
|
#[serde(default)]
|
|
pub payload: Option<String>,
|
|
}
|
|
|
|
/// One stable, gameplay-addressable state in the v1 single-layer controller.
|
|
#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct AnimationStateDesc {
|
|
/// Stable runtime key. Display labels are not gameplay identifiers.
|
|
pub id: String,
|
|
#[serde(default)]
|
|
pub label: String,
|
|
pub clip: EditorAssetRef,
|
|
#[serde(default = "default_true")]
|
|
pub looping: bool,
|
|
#[serde(default = "default_playback_speed")]
|
|
pub speed: f32,
|
|
#[serde(default)]
|
|
pub range: AnimationPlaybackRange,
|
|
}
|
|
|
|
impl Default for AnimationStateDesc {
|
|
fn default() -> Self {
|
|
Self {
|
|
id: String::new(),
|
|
label: String::new(),
|
|
clip: EditorAssetRef::default(),
|
|
looping: true,
|
|
speed: DEFAULT_PLAYBACK_SPEED,
|
|
range: AnimationPlaybackRange::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Scene-authored single-layer animation controller for a sibling [`SkinnedMeshRenderer`].
|
|
///
|
|
/// Bevy graph, player, transition, and instantiated-world state are derived at runtime.
|
|
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct AnimationControllerDesc {
|
|
#[serde(default)]
|
|
pub skeleton: Option<EditorAssetRef>,
|
|
#[serde(default)]
|
|
pub states: Vec<AnimationStateDesc>,
|
|
/// Stable state ID selected when the hydrated model becomes ready.
|
|
#[serde(default)]
|
|
pub default_state: String,
|
|
#[serde(default = "default_crossfade_seconds")]
|
|
pub default_crossfade_seconds: f32,
|
|
}
|
|
|
|
impl Default for AnimationControllerDesc {
|
|
fn default() -> Self {
|
|
Self {
|
|
skeleton: None,
|
|
states: Vec::new(),
|
|
default_state: String::new(),
|
|
default_crossfade_seconds: DEFAULT_CROSSFADE_SECONDS,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AnimationControllerDesc {
|
|
pub fn state(&self, id: &str) -> Option<&AnimationStateDesc> {
|
|
self.states.iter().find(|state| state.id == id)
|
|
}
|
|
|
|
pub fn state_mut(&mut self, id: &str) -> Option<&mut AnimationStateDesc> {
|
|
self.states.iter_mut().find(|state| state.id == id)
|
|
}
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_playback_speed() -> f32 {
|
|
DEFAULT_PLAYBACK_SPEED
|
|
}
|
|
|
|
fn default_crossfade_seconds() -> f32 {
|
|
DEFAULT_CROSSFADE_SECONDS
|
|
}
|
|
|
|
/// Versioned generated artifact for one imported model source.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct AnimationManifest {
|
|
pub schema_version: u32,
|
|
pub asset_id: String,
|
|
pub label: String,
|
|
/// Explicit imported clip shown as the asset's edit-mode rest presentation.
|
|
///
|
|
/// This is a stable clip sub-asset ID. `None` deliberately preserves the imported node pose;
|
|
/// importers and placement code must never guess the first clip.
|
|
#[serde(default)]
|
|
pub default_animation_clip_id: Option<String>,
|
|
pub source: AnimationManifestSource,
|
|
/// Whether this source format can hydrate the extracted animation data at runtime.
|
|
pub runtime_supported: bool,
|
|
#[serde(default)]
|
|
pub skeletons: Vec<AnimationSkeletonRecord>,
|
|
#[serde(default)]
|
|
pub clips: Vec<AnimationClipRecord>,
|
|
#[serde(default)]
|
|
pub diagnostics: Vec<AnimationImportDiagnostic>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct AnimationManifestSource {
|
|
pub path: String,
|
|
pub format: String,
|
|
pub fingerprint: AnimationSourceFingerprint,
|
|
#[serde(default)]
|
|
pub dependencies: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct AnimationSourceFingerprint {
|
|
pub byte_len: u64,
|
|
pub modified_unix_secs: u64,
|
|
/// BLAKE3 hash of the source file bytes.
|
|
pub content_hash: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct AnimationSkeletonRecord {
|
|
/// Stable imported sub-asset ID used by [`EditorAssetRef`].
|
|
pub id: String,
|
|
pub label: String,
|
|
pub source_index: usize,
|
|
pub signature: AnimationSkeletonSignature,
|
|
/// Normalized root-to-joint target paths in source joint order.
|
|
pub joint_paths: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct AnimationClipRecord {
|
|
/// Stable imported sub-asset ID used by [`EditorAssetRef`].
|
|
pub id: String,
|
|
pub label: String,
|
|
pub source_index: usize,
|
|
pub duration_seconds: f32,
|
|
#[serde(default)]
|
|
pub target_skeleton_signature: Option<AnimationSkeletonSignature>,
|
|
#[serde(default)]
|
|
pub events: Vec<AnimationEventDesc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum AnimationDiagnosticSeverity {
|
|
Info,
|
|
Warning,
|
|
Error,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct AnimationImportDiagnostic {
|
|
pub severity: AnimationDiagnosticSeverity,
|
|
pub code: String,
|
|
pub message: String,
|
|
pub repair: String,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn controller_defaults_are_single_layer_and_non_destructive() {
|
|
let controller = AnimationControllerDesc::default();
|
|
assert!(controller.skeleton.is_none());
|
|
assert!(controller.states.is_empty());
|
|
assert!(controller.default_state.is_empty());
|
|
assert_eq!(controller.default_crossfade_seconds, 0.2);
|
|
}
|
|
|
|
#[test]
|
|
fn authored_controller_round_trips_stable_refs_and_playback_settings() {
|
|
let controller = AnimationControllerDesc {
|
|
skeleton: Some(EditorAssetRef::new(
|
|
"model-id",
|
|
animation_skeleton_sub_asset_id(0, "Rig"),
|
|
"Rig",
|
|
)),
|
|
states: vec![AnimationStateDesc {
|
|
id: "locomotion.idle".into(),
|
|
label: "Idle".into(),
|
|
clip: EditorAssetRef::new(
|
|
"model-id",
|
|
animation_clip_sub_asset_id(2, "Idle"),
|
|
"Idle",
|
|
),
|
|
looping: true,
|
|
speed: 0.75,
|
|
range: AnimationPlaybackRange {
|
|
start_seconds: 0.1,
|
|
end_seconds: Some(1.2),
|
|
},
|
|
}],
|
|
default_state: "locomotion.idle".into(),
|
|
default_crossfade_seconds: 0.15,
|
|
};
|
|
|
|
let encoded = ron::to_string(&controller).unwrap();
|
|
let decoded: AnimationControllerDesc = ron::from_str(&encoded).unwrap();
|
|
assert_eq!(decoded, controller);
|
|
assert_eq!(decoded.state("locomotion.idle"), decoded.states.first());
|
|
assert!(decoded.state("missing").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn subasset_ids_encode_source_indices_deterministically() {
|
|
let clip = animation_clip_sub_asset_id(7, "Idle Loop");
|
|
let skeleton = animation_skeleton_sub_asset_id(3, "Hero Rig");
|
|
assert_eq!(clip, "animation:clip:7:idle_loop");
|
|
assert_eq!(skeleton, "animation:skeleton:3:hero_rig");
|
|
assert_eq!(animation_clip_source_index(&clip), Some(7));
|
|
assert_eq!(animation_skeleton_source_index(&skeleton), Some(3));
|
|
assert_eq!(animation_clip_source_index(&skeleton), None);
|
|
}
|
|
|
|
#[test]
|
|
fn schema_v2_manifest_defaults_to_imported_rest_pose() {
|
|
let legacy = r#"(
|
|
schema_version: 2,
|
|
asset_id: "model-id",
|
|
label: "Legacy",
|
|
source: (
|
|
path: "assets/models/legacy.glb",
|
|
format: "glb",
|
|
fingerprint: (byte_len: 1, modified_unix_secs: 0, content_hash: "hash"),
|
|
dependencies: [],
|
|
),
|
|
runtime_supported: true,
|
|
skeletons: [],
|
|
clips: [],
|
|
diagnostics: [],
|
|
)"#;
|
|
|
|
let manifest: AnimationManifest = ron::from_str(legacy).unwrap();
|
|
assert!(manifest.default_animation_clip_id.is_none());
|
|
}
|
|
}
|