Recursive AI

Bake deterministic root motion in C#

onegame is a free, open-source game engine for .NET from Recursive AI. MIT or Apache-2.0, no editor required.

Root motion moves a character by the movement authored in its animation. Bake it once into fixed-point steps before the simulation starts.

Terminal
dotnet add package RecursiveAI.OneGame.Gltf

Requires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.

Keep figure.glb, the skinned sample in the zip, next to Program.cs and run from that folder. To use your own model, replace the file path, "walk" with your clip’s name, and "Root" with the node carrying its translation.

C#
using OneGame.Core;
using OneGame.Gltf;
var rig = new Rig(
    GltfLoader.LoadFile("figure.glb"));
var track = RootMotion.Bake(
    rig.Clip("walk"), rig.Node("Root"));
var at = Transform3.Identity;
for (int tick = 0;
    tick < track.Length; tick++)
    at = track.Step(at, tick);
Console.WriteLine(at.Position == track.Total);

Prints True: one pass moves by exactly the baked total.

Text
True

Unzip the example, then dotnet run.

Full program

Floating-point animation results can differ across machines and disrupt lockstep simulation or replays; stepping the same baked fixed-point track gives every replica and replay the same movement.

World advances the simulation, an Entity is an object in that world, and [Synced] marks its state for saving and replication.

The scene-submission example also uses the host package:

Terminal
dotnet add package RecursiveAI.OneGame.Host

The core package’s source generator creates OneGame.Generated.Example.EntityTypes from [Entity] classes. Example is the project’s assembly name; keep that name or update the registry call. The zip’s Example.csproj contains:

XML
<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net10.0</TargetFramework>
  <ImplicitUsings>enable</ImplicitUsings>
  <Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
  <PackageReference Include="RecursiveAI.OneGame.Gltf" Version="2.0.0" />
  <PackageReference Include="RecursiveAI.OneGame.Host" Version="2.0.0" />
</ItemGroup>
C#
using OneGame.Core;
using OneGame.Gltf;
using OneGame.Host;
using RootExample;
 
var rig = new Rig(GltfLoader.LoadFile("figure.glb"));
Walker.Track = RootMotion.Bake(rig.Clip("walk"), rig.Node("Root"));
var registry = new EntityRegistry();
OneGame.Generated.Example.EntityTypes.Register(registry);
var world = new World(1, registry);
var walker = world.Add(new Walker());
int ticks = Walker.Track.Length * 1000;
for (int i = 0; i < ticks; i++) world.Step();
Console.WriteLine($"Ticks per loop: {Walker.Track.Length}");
Console.WriteLine($"Loops: {walker.MotionTick / Walker.Track.Length}");
Console.WriteLine($"Exact total: {walker.Transform.Position == Walker.Track.Total * Fixed.FromInt(1000)}");
var animator = new Animator(rig, rig.Node("Root"));
animator.AddState("walk", new ClipMotion(rig.Clip("walk")));
animator.Play("walk", (float)walker.MotionTick / Walker.Track.Length);
using var view = new SceneView3D();
view.Models.Add("hero", rig.Model);
view.Describer = (_, scene) => scene.Model("hero", walker.Transform, animator.Pose);
view.Prepare(world, world.Tick, Fixed.Zero);
Console.WriteLine($"Root held at rest: {animator.Pose.Translation[rig.Node("Root")] == rig.RestTranslation(rig.Node("Root"))}");
Console.WriteLine($"Models submitted: {view.ListModels().Count}");
 
namespace RootExample
{
    [Entity]
    public partial class Walker : Entity
    {
        public static RootMotionTrack Track { get; set; } = null!;
        [Synced] public partial Transform3 Transform { get; private set; }
        [Synced] public partial int MotionTick { get; private set; }
        protected override void OnTick()
        {
            if (MotionTick == 0) Transform = Transform3.Identity;
            Transform = Track.Step(Transform, MotionTick);
            MotionTick++;
        }
    }
}
Text
Ticks per loop: 60
Loops: 1000
Exact total: True
Root held at rest: True
Models submitted: 1

The full program keeps the transform and motion tick in synced properties, steps a world through repeated walk cycles, and submits a view pose derived from that tick. The Rig and Animator stay outside simulation code. The sample initializes the transform on its first motion tick.

A missing clip or node name throws KeyNotFoundException listing the available names.

The fixture is the public repository’s code-generated box figure, redistributed under MIT or Apache-2.0 with both license texts in the zip. Its generator source shows the skeleton and animation inputs.

Variations

Stop after one pass. Append this to the snippet at the top:

C#
Console.WriteLine(track.DeltaAt(track.Length, loop: false) == FVec3.Zero);

The additional line printed is:

Text
True

The short answer

Bake the walk clip with RootMotion.Bake, then apply the resulting RootMotionTrack.Step once per simulation tick. Keep the character’s transform and integer motion tick in synced state. Play the view clip from that state, with the animated root translation held at rest. Do not take a floating-point position from an animator and assign it to simulation state.

The console’s first result compares a single pass with track.Total. The full program repeats the same process through a real world and entity. Its exact-total result is a fixed-point equality, not a rounded distance printed for display. It checks movement over the bundled walk clip; it is not a claim about every animation exported by every tool.

What is root motion in animation?

Root motion is the movement authored on the character’s root node, rather than only the limb motion around a stationary origin. Here that translation becomes the character’s simulated displacement. An in-place clip with no root translation cannot supply movement through this API.

Separate loading, simulation and drawing. During loading, import the model, resolve the movement node and bake the clip at the simulation’s tick rate. The default baking rate is 60 ticks per second. The result lives in OneGame.Core and contains fixed-point vectors, while baking itself uses floats. Reuse the same baked track across characters that use that clip.

During simulation, index the track with the number of ticks since that movement started, not an unrelated global clock. Step rotates the displacement by the character’s rotation and applies its scale before adding it to position. The example starts with an identity transform so the sum can be compared directly with the track total. A turned or scaled character should not be compared to that untransformed total.

During drawing, derive the clip phase from the synced motion tick. The full program uses Play to select an exact phase after many ticks without replaying all the intervening view frames. In a running view, include interpolation alpha when choosing the displayed phase. Keep the animator and its poses in view fields, not synced properties. The animation guide explicitly separates these floating-point objects from deterministic simulation.

How do I loop a root-motion animation without drift?

Bake samples root translations, rounds the sampled positions to fixed point, and subtracts consecutive positions. Those steps sum exactly to the quantized endpoint difference. It stretches the clip to a whole number of ticks. DeltaAt and Step wrap by default, so the synced counter can continue across loop boundaries rather than restarting the entity position.

This endpoint property does not make float-based baking identical on every machine. The published guide says baking runs once on the loading machine and describes a pinned expected raw total to notice a platform disagreement. Deterministic playback consumes the baked numbers; do not run the animator as part of the simulation to regenerate them each tick.

Only the selected node’s translation is baked. Its parents should not rotate or scale, and a clip without a translation channel for that node produces zero steps. Root rotation extraction is not part of this API. For a non-looping action, pass loop: false: later ticks return zero movement rather than starting another pass. This does not stop the simulation clock or decide when your gameplay state leaves that action.

All onegame recipes