| Crates.io | anymotion |
| lib.rs | anymotion |
| version | 0.1.1 |
| created_at | 2025-12-29 10:36:37.646677+00 |
| updated_at | 2025-12-31 15:28:03.688771+00 |
| description | Prototype skeletal animation library for ECS-native game engines |
| homepage | |
| repository | |
| max_upload_size | |
| id | 2010407 |
| size | 1,047,416 |
A skeletal animation library that might work for your game engine
AnyMotion is a prototype skeletal animation library for Rust game engines. It follows Unix philosophy (does one thing, hopefully well) and provides the animation pipeline components you'd need to integrate into a larger engine.
Fair Warning: This is a library component, not a complete solution. You'll need to bring your own rendering, materials, lighting, and scene management. Think of it as a transmission for your car - it works, but you can't drive it alone.
[dependencies]
anymotion = "0.1.0"
archetype_ecs = "1.1.7" # Required for ECS integration
glam = "0.30" # Math types
archetype_ecs (should work with others too).glb filesash_renderer, wgpu, or your own)use anymotion::prelude::*;
fn main() -> Result<()> {
// Create test data (or load from GLTF)
let skeleton = create_test_skeleton();
let clip = create_test_walk_clip()?;
// Sample animation at specific time
let time = 0.5; // seconds
if let Some((pos, rot, scale)) = clip.sample_bone("Hips", time) {
println!("Hips at t={time}s: pos={pos:?}");
}
Ok(())
}
use anymotion::prelude::*;
use archetype_ecs::{World, GlobalTransform, LocalTransform};
fn setup_animation(world: &mut World) -> Result<()> {
// 1. Load skeleton and animation
let skeleton = create_test_skeleton();
let clip = create_test_walk_clip()?;
// 2. Store in asset resources
let skeleton_handle = world
.resource_mut::<SkeletonAssets>()?
.add(skeleton.clone());
let clip_handle = world
.resource_mut::<AnimationClipAssets>()?
.add(clip);
// 3. Spawn bone entities
let mut bone_entities = Vec::new();
for (i, _bone) in skeleton.bones.iter().enumerate() {
let entity = world.spawn((
BoneJoint {
bone_index: i,
skeleton: skeleton_handle,
},
LocalTransform::default(),
GlobalTransform::identity(),
));
bone_entities.push(entity);
}
// 4. Set up parent relationships (CRITICAL!)
// Example: Root (0) -> Hips (1) -> Spine (2)
let _ = world.add_component(
bone_entities[1],
Parent { entity: bone_entities[0] }
);
let _ = world.add_component(
bone_entities[2],
Parent { entity: bone_entities[1] }
);
// 5. Spawn animated character
world.spawn((
Animator::new(skeleton_handle, clip_handle),
SkinnedMesh {
skeleton: skeleton_handle,
mesh_handle: 0, // Your mesh ID
bone_entities: bone_entities.clone(),
joint_offset: 0,
},
GlobalTransform::identity(),
JointPalette::new(skeleton.bones.len()),
));
Ok(())
}
fn update_loop(world: &mut World, dt: f32) {
// This runs the entire animation pipeline:
// 1. Sample animations -> LocalTransform
// 2. Propagate hierarchy -> GlobalTransform
// 3. Compute skinning matrices -> JointPalette
// 4. Upload to GPU (if renderer is set up)
AnimationPipeline::update(world, dt);
}
use anymotion::loader::load_gltf;
fn load_character() -> Result<()> {
let (skeleton, clips) = load_gltf("assets/character.glb")?;
println!("Loaded skeleton with {} bones", skeleton.bones.len());
println!("Loaded {} animation clips", clips.len());
for clip in &clips {
println!(" - {}: {:.2}s", clip.name, clip.duration);
}
Ok(())
}
SkeletonRepresents a bone hierarchy with inverse bind matrices.
pub struct Skeleton {
pub bones: Vec<Bone>,
}
impl Skeleton {
pub fn new() -> Self;
pub fn add_bone(&mut self, name: String, parent: Option<usize>, inverse_bind: Mat4);
pub fn validate(&self) -> Result<()>;
pub fn find_bone(&self, name: &str) -> Option<usize>;
}
AnimationClipContains keyframe data for multiple bones.
pub struct AnimationClip {
pub name: String,
pub duration: f32,
// ... (internal)
}
impl AnimationClip {
pub fn sample_bone(&self, bone_name: &str, time: f32) -> Option<(Vec3, Quat, Vec3)>;
pub fn sample_all(&self, time: f32) -> HashMap<String, (Vec3, Quat, Vec3)>;
}
Animator (Component)Drives animation playback.
pub struct Animator {
pub skeleton: SkeletonHandle,
pub current_clip: AnimationClipHandle,
pub player: AnimationPlayer,
}
// AnimationPlayer controls playback
pub struct AnimationPlayer {
pub time: f32,
pub speed: f32, // Default: 1.0
pub is_playing: bool, // Default: true
pub is_looping: bool, // Default: true
}
BoneJoint (Component)Marks an entity as a bone in a skeleton.
pub struct BoneJoint {
pub bone_index: usize,
pub skeleton: SkeletonHandle,
}
Parent (Component)Defines parent-child relationships for transform hierarchy.
pub struct Parent {
pub entity: EntityId,
}
SkinnedMesh (Component)Links a mesh to a skeleton for GPU skinning.
pub struct SkinnedMesh {
pub skeleton: SkeletonHandle,
pub mesh_handle: u32,
pub bone_entities: Vec<EntityId>,
pub joint_offset: u32,
}
JointPalette (Component)Stores computed skinning matrices for GPU upload.
pub struct JointPalette {
pub matrices: Vec<Mat4>,
}
impl JointPalette {
pub fn new(count: usize) -> Self;
}
AnimationPipeline::update(world, dt)The main entry point. Runs all animation systems in the correct order:
animation_sampling_system - Samples animations → LocalTransformanimation_blending_system - Blends animations (if needed)transform_hierarchy_system - Propagates LocalTransform → GlobalTransformskinning_palette_system - Computes skinning matrices → JointPaletteJointUploadSystem - Uploads to GPU (if renderer available)// In your game loop
fn update(&mut self, dt: f32) {
AnimationPipeline::update(&mut self.world, dt);
}
You can also run systems individually if needed:
use anymotion::{
animation_sampling_system,
animation_blending_system,
skinning_palette_system,
};
animation_sampling_system(&mut world, dt);
transform_hierarchy_system(&mut world);
skinning_palette_system(&mut world);
create_test_skeleton() / create_test_walk_clip()Helper functions for testing/prototyping.
let skeleton = create_test_skeleton();
// Creates: Root -> Hips -> Spine, LeftLeg, RightLeg
let clip = create_test_walk_clip()?;
// Animates the "Hips" bone
load_gltf(path)Loads skeleton and animations from GLTF/GLB files.
let (skeleton, clips) = load_gltf("character.glb")?;
Known Limitations:
The library uses a standard parent-child transform hierarchy:
LocalTransform (per-bone) → Parent links → GlobalTransform (computed)
CRITICAL: You must set up Parent components correctly, or transforms won't propagate!
// Example: 3-bone chain
let root = world.spawn((LocalTransform::default(), GlobalTransform::identity()));
let child1 = world.spawn((LocalTransform::default(), GlobalTransform::identity()));
let child2 = world.spawn((LocalTransform::default(), GlobalTransform::identity()));
world.add_component(child1, Parent { entity: root });
world.add_component(child2, Parent { entity: child1 });
// Now: root -> child1 -> child2
Skeleton (bind pose) + GlobalTransform (animated) → Skinning Matrices → GPU
The skinning matrix for bone i is:
JointMatrix[i] = GlobalTransform[i] * InverseBindMatrix[i]
This is computed by skinning_palette_system and stored in JointPalette.
┌─────────────────┐
│ AnimationClip │
│ (keyframes) │
└────────┬────────┘
│ sample(time)
↓
┌─────────────────┐
│ LocalTransform │ ← animation_sampling_system
│ (per bone) │
└────────┬────────┘
│ + Parent links
↓
┌─────────────────┐
│ GlobalTransform │ ← transform_hierarchy_system
│ (world space) │
└────────┬────────┘
│ + InverseBindMatrix
↓
┌─────────────────┐
│ JointPalette │ ← skinning_palette_system
│ (GPU matrices) │
└────────┬────────┘
│
↓
GPU Skinning
ash_rendereruse anymotion::prelude::*;
use ash_renderer::prelude::*;
use std::sync::{Arc, Mutex};
// 1. Store renderer in ECS world
world.insert_resource(Arc::new(Mutex::new(renderer)));
// 2. Run animation pipeline
AnimationPipeline::update(&mut world, dt);
// 3. Renderer automatically uploads joint matrices
// (JointUploadSystem handles this)
// 4. Draw skinned mesh
if let Ok(mut renderer) = renderer_arc.lock() {
renderer.draw_skinned_mesh(
mesh_handle,
material_handle,
transform_matrix,
joint_offset,
);
}
If you're not using archetype_ecs, you'll need to:
LocalTransform, GlobalTransform, etc.)The core math (AnimationClip::sample, etc.) is ECS-agnostic.
What We've Tested:
AnimationPipeline::update hot loopWhat We Haven't Tested:
Optimization Tips:
AnimationPipeline::update instead of individual systems (better cache locality)archetype_ecs (or manual system integration)# Run all tests
cargo test
# Run with output
cargo test -- --nocapture
# Run specific test
cargo test test_animation_pipeline_propagation
# Run examples
cargo run --example 01_basic_animation
cargo run --example 02_skinned_mesh # (white screen is normal - see below)
The 02_skinned_mesh example shows a white screen. This is expected!
The example proves the animation pipeline works (verified by passing tests), but doesn't set up materials, lighting, or a proper scene. It's a minimal integration demo, not a visual showcase.
To actually see animated characters, you'd need to integrate this library into a game engine with:
Check:
Parent components for the bone hierarchy?AnimationPipeline::update every frame?Animator component's player.is_playing set to true?Check:
LocalTransform from archetype_ecs?LocalTransform AND GlobalTransform?transform_hierarchy_system running after animation_sampling_system?Check:
SkinnedMesh component with correct bone_entities?JointPalette component present?Contributions are welcome, though I make no promises about merge speed or quality standards.
Please ensure:
cargo test passescargo clippy is cleanLicensed under the Apache License, Version 2.0 (LICENSE-APACHE).
This library wouldn't exist without:
archetype_ecs (ECS foundation)ash_renderer (rendering integration)Initial Release - The "It Compiles" Edition
archetype_ecsKnown Issues:
Questions? Issues? Open an issue on GitHub. I'll try to respond, but no guarantees.
Want to help? PRs welcome. The codebase is reasonably clean (I think).
Using this in production? You're braver than I am. Let me know how it goes!