2 releases
Uses new Rust 2024
| 0.1.1 | Dec 31, 2025 |
|---|---|
| 0.1.0 | Dec 29, 2025 |
#288 in Game dev
1MB
21K
SLoC
AnyMotion
A skeletal animation library that might work for your game engine
What This Is (Probably)
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.
Installation
[dependencies]
anymotion = "0.1.0"
archetype_ecs = "1.1.7" # Required for ECS integration
glam = "0.30" # Math types
Core Concepts
What It Does
- ✅ Animation Sampling: Interpolates keyframe data at any time point
- ✅ Transform Hierarchy: Propagates local transforms to global space
- ✅ Skinning Matrices: Computes GPU-ready joint matrices
- ✅ ECS Integration: Works with
archetype_ecs(should work with others too) - ✅ GLTF Loading: Reads skeletons and animations from
.glbfiles
What It Doesn't Do
- ❌ Rendering (use
ash_renderer,wgpu, or your own) - ❌ Materials/Lighting (that's your engine's job)
- ❌ Asset management (just provides loaders)
- ❌ Physics/IK (maybe someday?)
Quick Start
1. Basic Animation Sampling (No ECS)
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(())
}
2. ECS Integration (The Real Deal)
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);
}
3. Loading from GLTF
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(())
}
API Reference
Core Types
Skeleton
Represents 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>;
}
AnimationClip
Contains 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;
}
Systems
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- PropagatesLocalTransform→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);
Loaders
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:
- Only reads first skin in file
- Assumes linear interpolation
- No support for morph targets yet
Architecture
Transform Hierarchy
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
Skinning Pipeline
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.
Data Flow Diagram
┌─────────────────┐
│ 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
Integration Patterns
With ash_renderer
use 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,
);
}
With Custom ECS
If you're not using archetype_ecs, you'll need to:
- Implement equivalent components (
LocalTransform,GlobalTransform, etc.) - Run the systems manually in the correct order
- Handle resource storage yourself
The core math (AnimationClip::sample, etc.) is ECS-agnostic.
Performance Notes
What We've Tested:
- ✅ Zero allocations in
AnimationPipeline::updatehot loop - ✅ Sparse updates (only animated bones are modified)
- ✅ Single-pass hierarchy propagation
What We Haven't Tested:
- Large skeletons (100+ bones)
- Many animated characters (100+ entities)
- Complex blend trees
Optimization Tips:
- Use
AnimationPipeline::updateinstead of individual systems (better cache locality) - Keep bone hierarchies shallow when possible
- Batch character updates if you have many
Known Issues & Limitations
Current Bugs
- None known (as of v0.1.0)
Missing Features
- ❌ Animation blending (component exists, system is stubbed)
- ❌ Inverse Kinematics (IK)
- ❌ Root motion extraction
- ❌ Animation events/callbacks
- ❌ Additive animations
- ❌ Animation compression
Design Limitations
- Requires
archetype_ecs(or manual system integration) - Assumes you have a renderer that supports GPU skinning
- GLTF loader is basic (no morph targets, no sparse accessors)
Testing
# 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)
About the White Screen Example
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:
- Material system
- Lighting system
- Camera controller
- Scene management
Troubleshooting
"My bones aren't animating!"
Check:
- Did you set up
Parentcomponents for the bone hierarchy? - Are you calling
AnimationPipeline::updateevery frame? - Is the
Animatorcomponent'splayer.is_playingset totrue? - Does your animation clip actually have tracks for those bones?
"Transforms are wrong!"
Check:
- Are you using
LocalTransformfromarchetype_ecs? - Did you spawn entities with both
LocalTransformANDGlobalTransform? - Is
transform_hierarchy_systemrunning afteranimation_sampling_system?
"GPU skinning doesn't work!"
Check:
- Did you set up
SkinnedMeshcomponent with correctbone_entities? - Is
JointPalettecomponent present? - Is your renderer actually using the joint matrices?
Contributing
Contributions are welcome, though I make no promises about merge speed or quality standards.
Please ensure:
cargo testpassescargo clippyis clean- Code is reasonably documented
License
Licensed under the Apache License, Version 2.0 (LICENSE-APACHE).
Acknowledgments
This library wouldn't exist without:
- Unity DOTS (architecture inspiration)
- Unreal Engine (skinning pipeline reference)
archetype_ecs(ECS foundation)ash_renderer(rendering integration)- The Rust gamedev community
Version History
v0.1.0 (2025-12-29)
Initial Release - The "It Compiles" Edition
- ✅ Core animation sampling
- ✅ Transform hierarchy propagation
- ✅ Skinning matrix calculation
- ✅ GLTF loading (basic)
- ✅ ECS integration with
archetype_ecs - ✅ Animation pipeline orchestration
- ✅ 37 passing tests
- ✅ Zero clippy warnings
Known Issues:
- Animation blending system is stubbed
- No visual examples (white screen is expected)
- GLTF loader is minimal
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!
Dependencies
~66–92MB
~1M SLoC