AnimationTree: состояния, смешивание и root motion
Рабочие графы C#: переходы, BlendSpace1D, OneShot и передача движения в физику.
Кто управляет воспроизведением
AnimationTree получает библиотеки из указанного AnimationPlayer. Ключи создаются в Animation, а дерево определяет, какие клипы и с каким весом использовать. Пока дерево управляет одними и теми же целями, не вызывайте параллельный AnimationPlayer.Play() для этих свойств.
Параметры состояния и веса меняйте у экземпляра дерева через Set("parameters/...", value). Ресурс графа может быть общим у экземпляров префаба; изменение его структуры — не настройка только одного персонажа.
Пример 1: состояния и BlendSpace1D
Нужен AnimationClips.cs. В отдельном уровне прикрепите StateAnimationDemo.cs к корню Entity3D. Создайте Input Map действие animation_jump на Space, соберите и запустите F6.
В Remote Inspector измените Speed: 0 оставляет Idle, положительное значение переводит в Move. В Move значение 0 соответствует позе walk с Y=1, значение 1 — run с Y=3; 0,5 даёт Y=2. Это наглядные позы куба, не контроллер ходьбы. Space запускает Jump; при Grounded = true после конца клипа дерево возвращается в Idle, затем снова в Move, если Speed положителен.
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 смешивание занимает 0,15 с. Travel() запрашивает путь по разрешённым связям графа; это не немедленная установка всех свойств. GetCurrentNode() меняется после обработки дерева. Если разрешённого пути нет, Travel может перейти непосредственно к цели; не используйте его как проверку допустимости игрового действия.
Start("Idle") напрямую начинает состояние. Для вложенного StateMachine путь к playback также вложенный. parameters/playback верен для StateMachine в корне; parameters/Move/blend_position — для BlendSpace внутри состояния Move. Имена учитывают регистр.
У Jump → Idle стоит AdvanceMode.Auto, SwitchMode.AtEnd и условие grounded. Enabled разрешает переход для Travel; сам по себе этот режим не означает автоматический переход. Условие хранится как parameters/conditions/grounded, а в AdvanceCondition задаётся только grounded. Если Grounded остаётся false, автоматического выхода не будет. Reset определяет сброс времени входящего состояния, XfadeTime — длительность смешивания.
Режимы BlendSpace
BlendMode.Interpolated смешивает соседние точки; Discrete выбирает одну ближайшую. DiscreteCarry при переключении переносит время выбранного клипа в режимах None/Independent. Это не то же самое, что дискретное обновление Value-дорожки.
| SyncMode | Что происходит со временем |
|---|---|
None | Шаг получают только точки с ненулевым весом |
Independent | Все точки продолжают своё время, даже с нулевым весом |
CyclicMutable | Общий цикл вычисляется из взвешенных длин активных клипов |
CyclicConstant | Общая длина цикла задаётся положительным CyclicLength в секундах |
Циклические режимы требуют прямых точек AnimationNodeAnimation. У клипов разной длины они согласуют фазу, а не просто включают скрытое воспроизведение. Для составных ветвей используйте подходящий нециклический режим. Проверяйте моменты контакта стоп в самих клипах: синхронизация времени не исправляет неверно подготовленные позы.
Пример 2: Blend2 и разовое действие
В другом уровне с Entity3D используйте тот же помощник и BlendOverlayDemo.cs. Действие Space — то же animation_jump. Blend = 0 даёт Idle, 1 — Run, 0,5 — промежуточную позу Y=1,5. Space запускает разовый Jump поверх базового результата; после него граф возвращается к базовому смешиванию. Abort() обрывает разовое действие.
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) первым указывается получатель. output — выход графа. У Blend2 вход 0 — базовый клип, вход 1 — второй, а parameters/Locomotion/blend_amount задаёт вес второго. parameters/Action/request принимает числовое значение OneShotRequest, parameters/Action/active показывает, выполняется ли действие.
OneShot с незавершающимся клипом не обязан закончиться сам: проверяйте LoopMode и BreakLoopAtEnd. Для верхней части тела включите фильтр на нужных путях костей; фильтр не создаёт отсутствующую дорожку. Здесь фильтр не нужен: все клипы меняют одну визуальную позицию.
Остальные элементы графа
| Задача | Элемент |
|---|---|
| Три веса вокруг базовой позы | Blend3 |
| Направление в плоскости | BlendSpace2D; точки и треугольники задают область смешивания |
| Выбор одного из нескольких входов | Transition; запрос — transition_request |
| Изменить скорость ветви | TimeScale; параметр — scale |
| Перемотать ветвь | TimeSeek; параметр — seek_request, не счётчик игрового времени |
| Состояния с путями и условиями | StateMachine |
Не путайте граф Transition со связью AnimationNodeStateMachineTransition. Advance Expression вычисляется относительно AdvanceExpressionBaseEntity; выражение не должно молча ссылаться на скрипт другой Entity. Для обычного bool проще явный параметр conditions, как выше.
Пример 3: движение из клипа в физику
Создайте отдельный уровень с корнем CharacterBody3D, добавьте помощник и RootMotionDemo.cs. Запустите F6: тело движется по своей локальной X со скоростью 2 единицы/с. Камера остаётся на месте; тело со временем покинет кадр. Код создаёт форму и визуализацию, но не добавляет гравитацию, пол, управление и полноценный контроллер персонажа. Родительская трансформация должна быть без масштабирования.
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();
}
}Дорожка Position3D с путём Visual выбирается как RootMotionTrack. Её движение извлекается из визуальной позы. GetRootMotionPosition() возвращает смещение за обработанный шаг, не скорость и не мировую позицию. В примере дерево обновляется вручную внутри _PhysicsProcess(), смещение переводится в мировое направление и делится на delta для Velocity; MoveAndSlide() затем сам учитывает шаг физики.
Не передавайте в Velocity сырую дельту и не умножайте её на delta ещё раз. Не добавляйте то же движение напрямую к Position: иначе оно применится дважды. Для поворота и масштаба есть отдельные root-motion значения; этот пример их не применяет. Accumulator — накопленная поза дорожки, а не замена дельты текущего шага.
Диагностика
| Симптом | Проверка |
|---|---|
| Дерево ничего не меняет | Active, AnimPlayer, TreeRoot и соединение с output |
| Параметр не действует | Полный путь, регистр имени, нужный экземпляр дерева |
| Переход не автоматический | AdvanceMode, условие и AtEnd; завершился ли клип |
| Поза обнуляется при смешивании | Набор дорожек и RESET-поза, веса и правила дискретного смешивания |
| Метод анимации вызван дважды | Два активных владельца воспроизведения, повторный запрос или перемотка |
| Персонаж двигается с другой скоростью | Дельта против скорости, ручное плюс автоматическое обновление, трансформация и коллизии |
AnimationTree сам по себе не является логикой персонажа, физикой, сетевой синхронизацией или системой стриминга. Он вычисляет анимационный результат; игровые решения и применение движения остаются в соответствующих системах.