Blacksite/crates/shared/src/animation.rs
Rbanh 0798aa5d57 Build renderer and material component foundations
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.
2026-07-12 00:24:06 -04:00

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());
}
}