reckfellENGINE

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
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 смешивание занимает 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
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) первым указывается получатель. 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
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();
    }
}

Дорожка Position3D с путём Visual выбирается как RootMotionTrack. Её движение извлекается из визуальной позы. GetRootMotionPosition() возвращает смещение за обработанный шаг, не скорость и не мировую позицию. В примере дерево обновляется вручную внутри _PhysicsProcess(), смещение переводится в мировое направление и делится на delta для Velocity; MoveAndSlide() затем сам учитывает шаг физики.

Не передавайте в Velocity сырую дельту и не умножайте её на delta ещё раз. Не добавляйте то же движение напрямую к Position: иначе оно применится дважды. Для поворота и масштаба есть отдельные root-motion значения; этот пример их не применяет. Accumulator — накопленная поза дорожки, а не замена дельты текущего шага.

Диагностика

СимптомПроверка
Дерево ничего не меняетActive, AnimPlayer, TreeRoot и соединение с output
Параметр не действуетПолный путь, регистр имени, нужный экземпляр дерева
Переход не автоматическийAdvanceMode, условие и AtEnd; завершился ли клип
Поза обнуляется при смешиванииНабор дорожек и RESET-поза, веса и правила дискретного смешивания
Метод анимации вызван дваждыДва активных владельца воспроизведения, повторный запрос или перемотка
Персонаж двигается с другой скоростьюДельта против скорости, ручное плюс автоматическое обновление, трансформация и коллизии

AnimationTree сам по себе не является логикой персонажа, физикой, сетевой синхронизацией или системой стриминга. Он вычисляет анимационный результат; игровые решения и применение движения остаются в соответствующих системах.

Документация Reckfell EngineНаверх ↑
БЫСТРЫЙ ПЕРЕХОДНачало работыEntity и иерархияC# & .NET 10Сцены и префабы
Руководства и полный API Reckfell K
reckfellENGINE
RECKFELL ACCOUNT

Вход в аккаунт

Войдите в аккаунт Reckfell.

Один аккаунт для всей платформы Reckfell.