Thanks to visit codestin.com
Credit goes to lib.rs

#ecs #skeletal #animation #gamedev

anymotion

Prototype skeletal animation library for ECS-native game engines

2 releases

Uses new Rust 2024

0.1.1 Dec 31, 2025
0.1.0 Dec 29, 2025

#288 in Game dev

Apache-2.0

1MB
21K SLoC

AnyMotion

A skeletal animation library that might work for your game engine

Crates.io Documentation

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 .glb files

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:

  1. animation_sampling_system - Samples animations → LocalTransform
  2. animation_blending_system - Blends animations (if needed)
  3. transform_hierarchy_system - Propagates LocalTransformGlobalTransform
  4. skinning_palette_system - Computes skinning matrices → JointPalette
  5. JointUploadSystem - 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:

  1. Implement equivalent components (LocalTransform, GlobalTransform, etc.)
  2. Run the systems manually in the correct order
  3. Handle resource storage yourself

The core math (AnimationClip::sample, etc.) is ECS-agnostic.

Performance Notes

What We've Tested:

  • ✅ Zero allocations in AnimationPipeline::update hot 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::update instead 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:

  1. Did you set up Parent components for the bone hierarchy?
  2. Are you calling AnimationPipeline::update every frame?
  3. Is the Animator component's player.is_playing set to true?
  4. Does your animation clip actually have tracks for those bones?

"Transforms are wrong!"

Check:

  1. Are you using LocalTransform from archetype_ecs?
  2. Did you spawn entities with both LocalTransform AND GlobalTransform?
  3. Is transform_hierarchy_system running after animation_sampling_system?

"GPU skinning doesn't work!"

Check:

  1. Did you set up SkinnedMesh component with correct bone_entities?
  2. Is JointPalette component present?
  3. 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 test passes
  • cargo clippy is 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