reckfellENGINE

AnimationTree: states, blending and root motion

Working C# graphs: transitions, BlendSpace1D, OneShot and movement passed to physics.

On this page

Playback ownership

AnimationTree obtains libraries from its assigned AnimationPlayer. Animation holds the keys; the tree determines which clips contribute and with what weights. While the tree controls those targets, do not run parallel AnimationPlayer.Play() calls for the same properties.

Change state and weight parameters on the tree instance through Set("parameters/...", value). Prefab instances can share a graph resource; changing its structure is not an instance-only character adjustment.

Example 1: states and BlendSpace1D

This requires AnimationClips.cs. In a separate level, attach StateAnimationDemo.cs to the Entity3D root. Add Input Map action animation_jump on Space, build and run with F6.

Change Speed in the Remote Inspector. Zero stays in Idle; a positive value enters Move. Within Move, 0 selects the walk pose at Y=1, 1 selects run at Y=3, and 0.5 gives Y=2. These are explanatory cube poses, not a walking controller. Space requests Jump. With Grounded = true, the completed clip returns to Idle and then Move again if Speed is positive.

StateAnimationDemo.cs
C#
using Reckfell;

public partial class StateAnimationDemo : Entity3D
{
    [Export(PropertyHint.Range, "0,1,0.01")] public float Speed { get; set; }
    [Export] public bool Grounded { get; set; } = true;
    public AnimationTree Tree { get; private set; } = null!;
    public AnimationNodeStateMachinePlayback Playback { get; private set; } = null!;
    private bool _jumpRequested;

    private static AnimationNodeStateMachineTransition Transition(bool automatic = false) => new()
    {
        AdvanceMode = automatic ? AnimationNodeStateMachineTransition.AdvanceModeEnum.Auto
            : AnimationNodeStateMachineTransition.AdvanceModeEnum.Enabled,
        SwitchMode = automatic ? AnimationNodeStateMachineTransition.SwitchModeEnum.AtEnd
            : AnimationNodeStateMachineTransition.SwitchModeEnum.Immediate,
        XfadeTime = automatic ? 0 : .15f,
        Reset = true
    };

    public override void _Ready()
    {
        AnimationClips.Stage(this);
        var move = new AnimationNodeBlendSpace1D { MinSpace = 0, MaxSpace = 1 };
        move.AddBlendPoint(new AnimationNodeAnimation { Animation = "walk" }, 0, name: "Walk");
        move.AddBlendPoint(new AnimationNodeAnimation { Animation = "run" }, 1, name: "Run");
        var machine = new AnimationNodeStateMachine();
        machine.AddNode("Idle", new AnimationNodeAnimation { Animation = "idle" });
        machine.AddNode("Move", move);
        machine.AddNode("Jump", new AnimationNodeAnimation { Animation = "jump" });
        machine.AddTransition("Idle", "Move", Transition());
        machine.AddTransition("Move", "Idle", Transition());
        machine.AddTransition("Idle", "Jump", Transition());
        machine.AddTransition("Move", "Jump", Transition());
        var landing = Transition(automatic: true);
        landing.AdvanceCondition = "grounded";
        machine.AddTransition("Jump", "Idle", landing);
        Tree = new AnimationTree
        {
            Name = "AnimationTree",
            AnimPlayer = new EntityPath("../AnimationPlayer"),
            TreeRoot = machine,
            Active = true
        };
        AddChild(Tree);
        Playback = (AnimationNodeStateMachinePlayback)Tree.Get("parameters/playback");
        Tree.Set("parameters/conditions/grounded", Grounded);
        Playback.Start("Idle");
    }

    public void RequestJump() => _jumpRequested = true;

    public override void _Process(double delta)
    {
        float speed = Mathf.Clamp(Speed, 0, 1);
        Tree.Set("parameters/Move/blend_position", speed);
        Tree.Set("parameters/conditions/grounded", Grounded);
        if (_jumpRequested)
        {
            Playback.Travel("Jump");
            _jumpRequested = false;
        }
        else if (Playback.GetCurrentNode() != "Jump")
        {
            string target = speed > .01f ? "Move" : "Idle";
            if (Playback.GetCurrentNode() != target)
                Playback.Travel(target);
        }
    }

    public override void _UnhandledInput(InputEvent @event)
    {
        if (!@event.IsActionPressed("animation_jump"))
            return;
        RequestJump();
        GetViewport().SetInputAsHandled();
    }
}

Idle ↔ Move cross-fades take 0.15 seconds. Travel() requests a route through enabled graph connections; it does not immediately set every property. GetCurrentNode() changes after processing. With no allowed route, Travel can jump directly to the destination; do not use it to authorize a gameplay action.

Start("Idle") starts a state directly. Nested StateMachines have nested playback paths. parameters/playback is for a root StateMachine; parameters/Move/blend_position addresses the BlendSpace inside Move. Names are case-sensitive.

Jump → Idle uses AdvanceMode.Auto, SwitchMode.AtEnd and the grounded condition. Enabled permits Travel; it does not itself mean automatic advancement. The condition parameter is parameters/conditions/grounded, while AdvanceCondition contains only grounded. Keeping Grounded false prevents automatic exit. Reset controls incoming-state time reset; XfadeTime controls cross-fade duration.

BlendSpace modes

BlendMode.Interpolated blends neighboring points; Discrete selects the nearest one. DiscreteCarry transfers the chosen clip's time when switching in None/Independent modes. This differs from discrete Value-track updates.

SyncModeTime behavior
NoneOnly points with nonzero weight receive the step
IndependentAll points advance their own time, even at zero weight
CyclicMutableThe common cycle uses the weighted lengths of active clips
CyclicConstantA positive CyclicLength defines the common cycle in seconds

Cyclic modes require direct AnimationNodeAnimation points. They synchronize phase across clips of different lengths instead of merely keeping hidden clips running. Use an appropriate noncyclic mode for composite branches. Check foot-contact timing in the source clips; time synchronization cannot repair incorrectly authored poses.

Example 2: Blend2 and a one-shot action

In another Entity3D level, use the same helper and BlendOverlayDemo.cs. Space uses the same animation_jump action. Blend = 0 gives Idle, 1 gives Run, and 0.5 gives the intermediate Y=1.5 pose. Space plays Jump over the base result; afterward the graph returns to its base blend. Abort() stops the action.

BlendOverlayDemo.cs
C#
using Reckfell;

public partial class BlendOverlayDemo : Entity3D
{
    [Export(PropertyHint.Range, "0,1,0.01")] public float Blend { get; set; }
    public AnimationTree Tree { get; private set; } = null!;

    public override void _Ready()
    {
        AnimationClips.Stage(this);
        var graph = new AnimationNodeBlendTree();
        graph.AddNode("Idle", new AnimationNodeAnimation { Animation = "idle" });
        graph.AddNode("Run", new AnimationNodeAnimation { Animation = "run" });
        graph.AddNode("Locomotion", new AnimationNodeBlend2());
        graph.AddNode("Jump", new AnimationNodeAnimation { Animation = "jump" });
        graph.AddNode("Action", new AnimationNodeOneShot { FadeInTime = .1, FadeOutTime = .1 });
        graph.ConnectNode("Locomotion", 0, "Idle");
        graph.ConnectNode("Locomotion", 1, "Run");
        graph.ConnectNode("Action", 0, "Locomotion");
        graph.ConnectNode("Action", 1, "Jump");
        graph.ConnectNode("output", 0, "Action");
        Tree = new AnimationTree
        {
            AnimPlayer = new EntityPath("../AnimationPlayer"),
            TreeRoot = graph,
            Active = true
        };
        AddChild(Tree);
    }

    public override void _Process(double delta) =>
        Tree.Set("parameters/Locomotion/blend_amount", Mathf.Clamp(Blend, 0, 1));

    public void Fire() => Tree.Set("parameters/Action/request", (int)AnimationNodeOneShot.OneShotRequest.Fire);
    public void Abort() => Tree.Set("parameters/Action/request", (int)AnimationNodeOneShot.OneShotRequest.Abort);

    public override void _UnhandledInput(InputEvent @event)
    {
        if (!@event.IsActionPressed("animation_jump"))
            return;
        Fire();
        GetViewport().SetInputAsHandled();
    }
}

ConnectNode(inputNode, inputIndex, outputNode) names the receiving node first. output is the graph output. Blend2 input 0 is the base clip, input 1 is the second clip, and parameters/Locomotion/blend_amount controls the second weight. parameters/Action/request accepts a numeric OneShotRequest; parameters/Action/active reports whether the action is running.

A OneShot with a perpetual clip need not finish on its own; check LoopMode and BreakLoopAtEnd. For upper-body overlays, enable filtering on the required bone paths; a filter cannot create missing tracks. No filter is required here because every clip changes the same visual position.

Other graph elements

NeedElement
Three weights around a base poseBlend3
Direction in a planeBlendSpace2D; points and triangles define the blending region
Select one of several inputsTransition, using transition_request
Scale branch speedTimeScale, using scale
Seek a branchTimeSeek, using seek_request, not a gameplay clock
States with routes and conditionsStateMachine

Do not confuse the Transition graph node with AnimationNodeStateMachineTransition connections. Advance Expression evaluates relative to AdvanceExpressionBaseEntity; it must not silently assume a script on another entity. An explicit conditions parameter is simpler for ordinary booleans, as above.

Example 3: clip movement passed to physics

Create a separate level with a CharacterBody3D root and add the helper and RootMotionDemo.cs. Run with F6: the body moves along local X at 2 units/second. The camera stays fixed, so the body eventually leaves the view. Code creates the shape and visuals but does not add gravity, a floor, controls or a complete character controller. The parent transform must have no scaling.

RootMotionDemo.cs
C#
using Reckfell;

public partial class RootMotionDemo : CharacterBody3D
{
    public AnimationTree Tree { get; private set; } = null!;

    public override void _Ready()
    {
        AddChild(new CollisionShape3D { Shape = new CapsuleShape3D { Radius = .3f, Height = 1 } });
        AnimationPlayer player = AnimationClips.Stage(this);
        GetEntity<Camera3D>("Camera").TopLevel = true;
        var motion = new Animation { Length = 1, LoopMode = Animation.LoopModeEnum.Linear };
        int track = motion.AddTrack(Animation.TrackType.Position3D);
        motion.TrackSetPath(track, new EntityPath("Visual"));
        motion.PositionTrackInsertKey(track, 0, Vector3.Zero);
        motion.PositionTrackInsertKey(track, 1, new Vector3(2,0,0));
        player.GetAnimationLibrary("").AddAnimation("motion", motion);
        Tree = new AnimationTree
        {
            AnimPlayer = new EntityPath("../AnimationPlayer"),
            TreeRoot = new AnimationNodeAnimation { Animation = "motion" },
            RootMotionTrack = new EntityPath("Visual"),
            RootMotionLocal = true,
            CallbackModeProcess = AnimationMixer.AnimationCallbackModeProcess.Manual,
            Active = true
        };
        AddChild(Tree);
    }

    public override void _PhysicsProcess(double delta)
    {
        Tree.Advance(delta);
        if (delta <= 0)
            return;
        Vector3 displacement = Tree.GetRootMotionPosition();
        Velocity = GlobalBasis * (displacement / (float)delta);
        MoveAndSlide();
    }
}

The Position3D track targeting Visual is selected as RootMotionTrack, extracting its motion from the visual pose. GetRootMotionPosition() returns displacement for the processed step, not velocity or a world position. The example manually advances the tree inside _PhysicsProcess(), transforms displacement to world direction and divides by delta for Velocity; MoveAndSlide() applies the physics interval itself.

Do not assign raw displacement to Velocity or multiply it by delta again. Do not also add the motion directly to Position, which applies it twice. Rotation and scale have separate root-motion values; this example does not apply them. An accumulator is the accumulated track pose, not a replacement for the current step delta.

Diagnostics

SymptomCheck
Tree changes nothingActive, AnimPlayer, TreeRoot and connection to output
Parameter has no effectFull path, name case and the correct tree instance
Transition is not automaticAdvanceMode, condition, AtEnd and clip completion
Pose resets during blendingTrack roster, RESET pose, weights and discrete blending policy
Method event runs twiceCompeting playback owners, repeated requests or seeking
Character speed is wrongDisplacement versus velocity, manual plus automatic steps, transforms and collisions

AnimationTree is not character logic, physics, network synchronization or world streaming. It evaluates animation results; gameplay decisions and movement application remain in the corresponding systems.

Reckfell Engine documentationBack to top ↑
QUICK LINKSGetting startedEntity hierarchyC# & .NET 10Scenes and prefabs
Reckfell guides and full API K
reckfellENGINE
RECKFELL ACCOUNT

Sign in

Sign in to your Reckfell account.

One account for the entire Reckfell platform.