Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Playing entity keyframe animations

A mod or resource pack can ship a NeoForge-format entity animation. Resolve it through the client's RenderAssets::animation, bake its bone names against your ModelPart tree once, and keep the result for playback. Native and wasm mods use the same types and sampler; vanilla mobs use that sampler too.

Write the resource

Put this file at assets/demo/neoforge/animations/entity/arm/wave.json. Its animation ID is demo:arm/wave:

{
  "length": 1.0,
  "loop": true,
  "animations": [
    {
      "bone": "arm",
      "target": "minecraft:rotation",
      "keyframes": [
        {"timestamp": 0.0, "target": [0, 0, 0], "interpolation": "minecraft:linear"},
        {"timestamp": 0.5, "target": [90, 0, 0], "interpolation": "minecraft:linear"},
        {"timestamp": 1.0, "target": [0, 0, 0], "interpolation": "minecraft:linear"}
      ]
    }
  ]
}

length and timestamp are seconds. Keep keyframes in time order and use finite values. loop defaults to false. The built-in targets use vanilla's builder conventions:

  • minecraft:position: texels, with the JSON y value negated when loaded.
  • minecraft:rotation: degrees in the file, converted to radians when loaded.
  • minecraft:scale: scale factors in the file; [1, 1, 1] adds no scale offset.

The built-in interpolations are minecraft:linear and minecraft:catmullrom. An identifier without a namespace uses minecraft:. An unknown target or interpolation rejects that file with a named diagnostic; other animation files still load. Lookup returns None when the ID is absent or its file failed to load.

Resolve and bake after resources are published

Resolve during client_setup, and resolve again during client_resources_reloaded. These are published-resource reads, not per-frame work. For example, renderer-owned state can keep its model and baked animation together:

#![allow(unused)]
fn main() {
use mod_api::{
    AnimationState, Identifier, KeyframeAnimation, ModelPart, RenderAssets,
};

struct AnimatedModel {
    root: ModelPart,
    animation: Option<KeyframeAnimation>,
    state: AnimationState,
}

impl AnimatedModel {
    fn new(root: ModelPart) -> Self {
        Self { root, animation: None, state: AnimationState::new() }
    }

    fn reload(&mut self, assets: &dyn RenderAssets, id: &Identifier) {
        self.animation = assets.animation(id).map(|holder| holder.bake(&self.root));
    }

    fn start(&mut self, tick_count: i32) {
        self.state.start(tick_count);
    }

    fn pose(&mut self, age_in_ticks: f32) {
        self.root.reset_all_poses();
        if let Some(animation) = &self.animation {
            animation.apply(&mut self.root, &self.state, age_in_ticks);
        }
    }
}
}

Call reload from those lifecycle hooks with the published assets and ID, and start when that entity begins the action. Keep a separate animation state for each animated entity. A new state is stopped; start_if_stopped does not restart an already playing animation, animate_when starts or stops with a condition, and stop ends playback.

Baking follows vanilla 26.3's ModelPart.createPartLookup: the name root always means the actual root, even if a child is also named root. Other names select the first match, visiting each part's immediate children before their descendants. A missing bone is logged and dropped at bake time; the other channels still play. Re-bake if the model hierarchy changes, not just when animation resources change.

Apply a pose each frame

The model's pose is mutable, and animation offsets add to it. Reset to the initial pose before applying that frame's animations, as in pose above; otherwise offsets accumulate. apply measures elapsed time from the state's start tick at 50 ms per tick. apply_scaled changes playback speed. For a walk cycle use apply_walk(root, walk_pos, walk_speed, speed_factor, scale_factor): its time is walk_pos * 50 * speed_factor milliseconds, and its strength is min(walk_speed * scale_factor, 1). apply_static applies the first frame without a state. Looping animations wrap elapsed time by their length.

Do not look up, clone or bake the animation every frame: those steps copy or allocate data. Playback uses the retained child-index paths and channels without allocating or searching bone names. Pose application alone does not draw anything; submit your model through its renderer, alongside the resolved-model drawing facilities.