AnimationTree: states, blending and root motion
Working C# graphs: transitions, BlendSpace1D, OneShot and movement passed to physics.
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
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.
| SyncMode | Time behavior |
|---|---|
None | Only points with nonzero weight receive the step |
Independent | All points advance their own time, even at zero weight |
CyclicMutable | The common cycle uses the weighted lengths of active clips |
CyclicConstant | A 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
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
| Need | Element |
|---|---|
| Three weights around a base pose | Blend3 |
| Direction in a plane | BlendSpace2D; points and triangles define the blending region |
| Select one of several inputs | Transition, using transition_request |
| Scale branch speed | TimeScale, using scale |
| Seek a branch | TimeSeek, using seek_request, not a gameplay clock |
| States with routes and conditions | StateMachine |
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
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
| Symptom | Check |
|---|---|
| Tree changes nothing | Active, AnimPlayer, TreeRoot and connection to output |
| Parameter has no effect | Full path, name case and the correct tree instance |
| Transition is not automatic | AdvanceMode, condition, AtEnd and clip completion |
| Pose resets during blending | Track roster, RESET pose, weights and discrete blending policy |
| Method event runs twice | Competing playback owners, repeated requests or seeking |
| Character speed is wrong | Displacement 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.