Skip to content

Animation Task System

Animation evaluation in Lumina does not compute poses where the graph logic runs. The graph update is a cheap logic pass that records a list of pose operations, and a second pass executes only the operations that the final output actually depends on.

That split is the whole performance story. Everything below is a consequence of it.

This page is the deep dive on the task model. For poses, root motion, notifies, and the rest of the runtime, see Animation Internals. For the authoring view, see the Animation manual page.

The model is taken from Esoterica, Bobby Anguelov’s engine, which is the clearest public implementation of a deferred animation task system. Lumina’s version was written against it in July 2026 and keeps the core ideas, including some names.

What came across unchanged:

  • Record, then execute. Graph logic never touches a pose buffer.
  • Dependencies are indices of earlier tasks, so registration order is already a valid execution order and nothing needs a topological sort at execute time.
  • A pose buffer pool with transfer semantics. A task that is the last consumer of a dependency’s buffer takes ownership of it and writes its result in place instead of asking the pool for a fresh one.
  • A pre-physics / post-physics stage tag on every task, so physics-coupled work (ragdoll write and read) can interleave with the simulation.
  • Cached poses, which Lumina calls pose snapshots.

Where the two diverge, and why:

EsotericaLumina
Task representationTask, a reflected polymorphic class with a virtual Execute, heap-allocated per registration and deleted on resetFAnimTask, a flat POD struct in a vector whose capacity persists across frames
ProducerNode graph; nodes register tasks as they updateBytecode VM; the whole program runs and every pose opcode records a task
SelectionNodes on inactive branches never update, so nothing unnecessary is registeredEvery branch records; the executor prunes by reachability from the output
Pose pool scopeOne pool per character’s TaskSystem, six buffers initiallyOne pool per worker thread, shared by every entity that thread evaluates
Buffer transferThe task decides, calling TransferDependencyPoseBuffer in its own ExecuteThe executor decides, from a use count computed during the reachability pass
Fan-outA tree, so a result has one consumerA register file, so a pose register can feed several tasks; the use count handles it
SerializationTaskSerializer compresses a task list for network replication and replayNot implemented
Secondary skeletonsSupported, multiple poses per bufferNot implemented

The two differences that matter are reachability pruning and pool scope.

Esoterica does not need reachability pruning because its producer is a node graph: an inactive state’s nodes are never updated, so their tasks never exist. Lumina’s producer is a linear bytecode program, so every branch’s opcodes run. Pruning at execute time recovers the same property without conditional jumps in the bytecode or a compiler that has to prove which branch is live.

Pool scope is a straight consequence of that. Esoterica’s pool lives on the character, so pose buffer memory scales with the number of animated characters. Lumina’s lives in thread_local storage in the executor, so it scales with the number of worker threads instead. One thread evaluates one list at a time, and the list is consumed before the next one starts, so the buffers are free to be reused.

update pass (parallel across meshes)
advance clocks, evaluate transitions, read parameters
record FAnimTaskList <- no pose math happens here
extract root motion delta
execute pass (parallel across meshes, one list per thread)
walk reachability from the output task
run the reachable tasks into pooled pose buffers
ToSkinningMatrices -> SSkeletalMeshComponent::BoneTransforms
serial pass (game thread)
apply root motion to entity transforms
dispatch typed notifies (user code)

SAnimationSystem wires this as one FTaskGraph: a ParallelFor for the simple one-clip path, a ParallelFor for the graph path that depends on it (so a dual component entity resolves deterministically rather than racing on BoneTransforms), then a single ParallelFor execute node over every skeletal mesh that depends on both. Follower pose copying hangs off the execute node as its own pass, so every leader has finished before any follower reads one.

struct FAnimTask
{
static constexpr int16 NoTask = -1;
EAnimTaskType Type;
EAnimTaskStage Stage;
int16 DepA = NoTask; // indices of earlier tasks in the same list
int16 DepB = NoTask;
CAnimation* Clip; float Time; // SampleClip
float Alpha; // blends, additives, bone ops
const TVector<float>* MaskWeights;
FAnimInertializer* Inert; FAnimDeadBlend* Dead;
FPose* Snapshot;
FVector3 T; FQuat R; FVector3 S;
uint16 BoneA, BoneB, BoneC;
uint8 Space, Mode;
};

Three decisions are worth calling out.

The payload is flat, not a class hierarchy. The recipe comes from a fixed opcode set, so there is nothing to extend at runtime and nothing to dispatch virtually. The list is a TVector<FAnimTask> that is cleared, not freed, so the steady state allocates nothing. Esoterica’s polymorphic Task is a new and a delete per task per frame per character; at crowd scale that alone is real money.

Fields are reused across task types. T is a component-space IK target for TwoBoneIK and FABRIK, a look-at target for LookAt, a ground offset for FootIK, and a plain offset for TranslateBone. BoneC is the end bone for two-bone IK and the iteration count for FABRIK. That keeps the struct small enough to stay cache friendly when a list holds a few dozen of them.

Dependencies are int16 indices, not pointers. The list can move in memory between frames and the indices stay valid, and the invariant that a dependency’s index is lower than its consumer’s is what makes both the reachability pass and the execution pass single linear sweeps.

TypeProduces
ReferencePoseThe skeleton’s bind pose. The fallback for anything that cannot resolve.
SampleClipOne clip sampled at a time, respecting the skeleton LOD cut.
Blend, BlendMaskedLerp of two poses, optionally weighted per bone.
MakeAdditive, ApplyAdditiveDelta extraction and application, local or mesh space.
InertializeBollo-style offset decay across a seam. Carries capture and apply flags.
DeadBlendExtrapolates the source forward from its seam velocity while it fades out.
SavePoseSnapshot, LoadPoseSnapshotNamed pose buffers that outlive the frame.
BoneTransform, TranslateBoneSingle-bone offset or override.
TwoBoneIK, FABRIK, LookAt, FootIKSolvers.

The per-entity recipe, a transient member on SSkeletalMeshComponent:

struct FAnimTaskList
{
TVector<FAnimTask> Tasks;
int16 OutputTask = FAnimTask::NoTask;
FSkeletonResource* Skeleton = nullptr;
bool bLockRoot = false;
int32 RootBoneIndex = INDEX_NONE;
int32 ActiveBoneCount = 0; // skeleton LOD cut, 0 = every bone
};

The final-pose fixups ride on the list rather than on a task, because they are not pose operations. The root pin happens once, on the final buffer, immediately before it is resolved into skinning matrices.

ActiveBoneCount is the skeleton LOD cut. Bones are stored parents-first, so any prefix of the array is a valid partial hierarchy. Sampling and blending stop at the cut and the tail rides along at bind-pose locals, but forward kinematics still walks the full hierarchy, so a bone past the cut still composes correctly against its animated ancestors and a weapon socket on a hand still follows the arm.

FAnimationGraphVM::BuildTasks is the update-side interpreter. Scalar ops, clock advance, and state machine transition logic all run immediately. Every pose opcode records a task instead of computing anything.

The bridge is a scratch array mapping pose register to producing task index:

thread_local TVector<int16> PoseTasks; // pose register -> task index
PoseTasks.assign(NumPose, FAnimTask::NoTask);

Writing a pose register records which task produces it. Reading one wires a dependency. That is the whole translation from a register machine to a dependency graph, and it costs one array write per pose opcode.

BuildTasks always leaves a valid output task, appending a ReferencePose if the program produced none, so a mesh can never keep stale skinning matrices from a previous frame.

State machines record one branch’s consumer

Section titled “State machines record one branch’s consumer”

This is where reachability comes from. EvalStateMachine looks at the state slot, picks the current state, and records exactly one task:

FAnimTask Task;
Task.Type = EAnimTaskType::Inertialize;
Task.DepA = PoseTaskFor(CurReg); // current state's pose register only
Task.Inert = &Inert;
Task.bCapture = bStart;
Task.bApply = Inert.bActive;
Task.Time = Inert.Elapsed;
SetPoseTask(Dst, OutTasks.Add(Task));

Every state’s blend tree already ran as bytecode and recorded its tasks. Only the current state’s chain is referenced by this Inertialize task, so only that chain is reachable from the output. Every other state’s sample and blend tasks sit in the list and cost nothing but the bytes they occupy.

Note the split: the inertializer’s control fields (bActive, Elapsed, Duration) are advanced here, update-side, because transition selection is logic. Channel capture, channel apply, and the two-frame pose history run execute-side inside the task, because they need evaluated poses.

The one accepted consequence is that a nested state machine inside an inactive state freezes its pose history, since its output task is unreachable. Reactivation can inertialize from a stale pose. That is the same trade every “inactive branches do not evaluate” design makes.

Anim::ExecuteTaskList(List, OutMatrices, OutSnapshot /* optional */);

Four phases, all linear.

Needed[List.OutputTask] = 1;
for (int32 i = List.OutputTask; i >= 0; --i)
{
if (!Needed[i]) continue;
const FAnimTask& Task = List.Tasks[i];
if (Task.DepA >= 0 && Task.DepA < i) { Needed[Task.DepA] = 1; ++UseCount[Task.DepA]; }
if (Task.DepB >= 0 && Task.DepB < i) { Needed[Task.DepB] = 1; ++UseCount[Task.DepB]; }
}

One reverse pass. Because a dependency’s index is always lower than its consumer’s, by the time the loop reaches index i every task that could have marked it has already been visited. No worklist, no recursion, no visited set beyond the flag array itself.

The same pass builds UseCount, the number of reachable tasks that read each result. That number is what drives buffer ownership.

Needed, UseCount, and ResultBuf are thread_local and reused across every list that thread executes, so the pass allocates nothing after the first list.

A task whose dependency has exactly one consumer takes that buffer and writes into it:

const bool bStealA = BufA != FAnimTask::NoTask && UseCount[Task.DepA] == 1;
Dst = bStealA ? BufA : Pool.Acquire();

This is Esoterica’s transfer model with the decision moved from the task to the executor. It works because every AnimPose kernel is aliasing-safe and stream-linear: Blend(A, B, Alpha, Out) explicitly permits Out to alias A or B, and reads each element before it writes it.

Esoterica’s tree structure guarantees one consumer per result, so a task can just transfer. Lumina’s register file allows fan-out, where one pose register feeds two downstream tasks. The use count covers both cases with one rule: steal when you are the last reader, otherwise take a fresh buffer and leave the shared result alone.

Buffers retire as soon as their last consumer has run:

if (Task.DepA >= 0 && --UseCount[Task.DepA] == 0 && BufA != Dst && BufA != NoTask)
{
Pool.Release(BufA);
}

One forward sweep from 0 to OutputTask, skipping anything not marked. Each task reads its dependencies’ result buffers by index and writes to Dst.

The final buffer gets the root pin if the list asked for one, then ToSkinningMatrices folds forward kinematics and the inverse bind matrices into the component’s BoneTransforms in a single pass. The buffer is released, the list is reset, and SkeletalUtils::PackRenderBones packs the matrices into the 3x4 layout the render gather bulk-copies.

FPosePool& GetThreadPosePool()
{
thread_local FPosePool Pool;
return Pool;
}

Linear scan for a free slot, grow on demand, never shrink. The scan is trivially cheap because the pool never gets large: peak live buffers is bounded by the depth of the reachable chain, not by the number of tasks and not by the number of entities. The worked example above, a four-state machine with an additive layer and foot IK, peaks at two.

A graph with a four-state locomotion machine, an additive aim layer, and foot IK. Say idle / walk / run / jump, with the machine currently in run.

The VM records something like:

0 SampleClip idle
1 SampleClip walk_fwd
2 SampleClip walk_back
3 Blend 1, 2 alpha 0.3
4 SampleClip run_fwd
5 SampleClip run_back
6 Blend 4, 5 alpha 0.3
7 SampleClip jump
8 Inertialize 6 <- current state is run
9 SampleClip aim_pose
10 MakeAdditive 9
11 ApplyAdditive 8, 10 alpha 1.0
12 FootIK 11
OutputTask = 12

Output is an opcode, not a task. It points OutputTask at an existing task and stamps the list’s root-lock flags, so it adds nothing to execute.

Reachability marks 12, 11, 10, 9, 8, 6, 5, 4. Tasks 0, 1, 2, 3, and 7 are never touched: the idle clip, both walk clips, their blend, and the jump clip cost nothing this frame beyond the FAnimTask bytes already sitting in the vector.

Buffer traffic for the reachable set:

TaskActionLive buffers after
4 SampleClip run_fwdacquire buffer 01
5 SampleClip run_backacquire buffer 12
6 Blend 4, 5task 4 has one consumer, steal buffer 0; release buffer 11
8 Inertialize 6steal buffer 01
9 SampleClip aim_poseacquire buffer 12
10 MakeAdditive 9steal buffer 12
11 ApplyAdditive 8, 10steal buffer 0; release buffer 11
12 FootIK 11steal buffer 01

Two buffers, one copy avoided at every step, and the entire graph resolves without a single pose-sized memcpy between tasks.

This is the big one. A graph’s size is an authoring concern, not a runtime cost. A state machine with thirty states executes the tasks of one. Adding an unreached state to a graph costs a few opcodes in the update pass and a few FAnimTask bytes in the list, and nothing at all in the execute pass.

The pre-task implementation evaluated every branch’s pose math because the VM computed into pose registers as it went. Conditional execution would have needed either branch opcodes in the bytecode or a compiler that could prove liveness. Reachability pruning gets the same result with a reverse loop.

Pose memory is per-thread, not per-instance

Section titled “Pose memory is per-thread, not per-instance”

Pose buffers are the largest thing animation touches: three arrays of TRS per bone, so roughly 40 bytes per bone per buffer. Before the task system, pose registers lived on each instance’s VM state, so the cost was instances x registers x bones. Now it is worker threads x chain depth x bones.

At five thousand animated characters on a sixteen-thread machine, that is the difference between thousands of resident pose buffers and a few dozen.

The task vector, the pose pool, the reachability arrays, the curve scratch, and the event scratch are all either persistent members or thread_local and are cleared rather than freed. After the first few frames, a full evaluation of a complex graph performs no heap traffic at all.

FPose is structure-of-arrays. Translations and scales are contiguous FVector3 arrays that blend as flat float streams eight wide; rotations slerp four quaternions at a time through VQuat4’s transpose-load kernels:

SIMD::LerpArray(Out.Translations, A.Translations, B.Translations, Active * 3, Alpha);
SIMD::LerpArray(Out.Scales, A.Scales, B.Scales, Active * 3, Alpha);
SIMD::BlendQuatArray(Out.Rotations, A.Rotations, B.Rotations, Active, Alpha);

Inertialization decays its channels the same way, eight bones per iteration, with every scalar early-out turned into a lane select.

Clip sampling uses hemisphere-aligned nlerp rather than slerp between adjacent keyframes. Keys are dense enough that the difference is not visible, and it removes an acos plus three sin per rotation channel. At crowd scale that was on the order of a million transcendental calls a frame.

The buffer transfer is only a win because writing in place is as cheap as writing out of place. Every kernel reads element i before writing element i, so Out == A costs nothing extra and skips a full pose copy.

One list executes on one thread, start to finish. There is no intra-list parallelism and no synchronization inside ExecuteTaskList beyond the debug capture check, which is a single relaxed atomic load.

The honest limitation: a scene with one very complex character does not scale the way a crowd does. Splitting a skeleton across threads would cost more in synchronization than the blends are worth at typical bone counts.

SSkeletalMeshComponent is CACHE_ALIGN because the render gather writes LastRenderedTime, LastDistanceOverRadius, and the render bone cache from worker threads. Without the alignment, adjacent components false-share at parallel range boundaries.

Three gates run before any task is recorded, so skipped meshes cost a branch:

  • Visibility. EAnimUpdateMode::TickWhenRendered skips evaluation entirely when the mesh has not been rendered within a 0.25s grace window. No tasks are recorded and the existing matrices are left untouched.
  • Update rate. Distant meshes evaluate every 2, 3, or 4 frames at camera distance-over-radius thresholds of 30, 60, and 120. The stagger is a per-component countdown seeded from the entity id, so the cost spreads across frames instead of spiking on one. Skipped time accumulates in PendingAnimTime and is consumed as one larger step, so playback speed is unaffected.
  • Skeleton LOD. Past a distance-over-radius of 60, CSkeleton::LowDetailBoneCount cuts sampling and blending to a bone prefix.

The staggering detail matters more than it looks. An earlier version used a global atomic frame counter, which breaks with multiple worlds ticking: a world only observes every Nth counter value, so entities could permanently miss their evaluation slot and freeze. The per-component countdown has no such coupling.

A profile capture of five thousand graph-driven characters put roughly ninety percent of ExecuteTaskList in self time rather than in its named child zones, with clip sampling and skinning-matrix resolution accounting for the rest. The cost was in the blend kernels and the inertialization channel math, not in scheduling.

That is the useful shape to keep in mind: once the structural wins are in, the task system is not what costs anything. It is arithmetic over bone arrays, which is why the answers have been SIMD kernels, fewer evaluated frames, and fewer evaluated bones rather than more clever scheduling. The per-task-type profiler zones in the executor exist so the next capture attributes that self time properly.

Every task carries an EAnimTaskStage:

enum class EAnimTaskStage : uint8 { AnyStage, PrePhysics, PostPhysics };

Everything is AnyStage today and the whole list executes in the pre-physics update. The tag exists so ragdoll pose write and pose read can eventually split across the simulation step, which is what Esoterica’s UpdatePrePhysics and UpdatePostPhysics pair does. The field is on the task rather than derived later because the producer is the only thing that knows.

ExecuteTaskList optionally fills an FAnimTaskSnapshot as it decides things, rather than having tooling re-derive them:

FieldRecorded
bReachableWhether the task ran, or was pruned as an inactive branch
bStoleBufferWhether it wrote in place into its dependency’s buffer
ExecOrderPosition in the executed sequence, -1 when skipped
BufferIndexWhich pool buffer holds the result
LevelDependency depth; tasks at the same level have no dependency between them
LiveBuffersPool buffers in use immediately after the task ran
MaskWeightedBonesFor BlendMasked, how many bones the mask actually weights

Capture is armed for one component at a time:

Anim::ArmTaskCapture(&Mesh);
// ...
Anim::GetTaskCapture(OutSnapshot);
Anim::DisarmTaskCapture();

Disarmed, the cost is a single relaxed atomic load per mesh, so it stays compiled in and safe to leave live in a populated world. Publishing and reading a snapshot are serialized by one mutex held only for the copy.

Two console variables cover the rest:

VariableEffect
anim.DumpGraphTasksLogs every graph-driven mesh’s recorded list each frame
anim.ValidateTasksRe-evaluates single-clip recipes the direct way and logs divergence from the executor

anim.ValidateTasks is the data-versus-logic discriminator. Divergence means a playback bug in the task path; agreement on a wrong-looking pose means the animation data itself is wrong.

SymptomCause
Poses corrupt under parallel evaluationA task list executed across more than one thread. One list, one thread, no exceptions.
Bone matrices are wrong after a ragdoll or editor writeA writer of BoneTransforms that did not set bRenderBonesDirty. Only the anim execute phase may write RenderBones or clear the flag.
A graph shows the bind pose and logs a version warningThe graph was compiled against an older kAnimBytecodeVersion. Open and re-save it.
A nested state machine pops on reactivationIts output was unreachable while the parent state was inactive, so its pose history is stale. Expected.
Bones past the LOD cut look frozen but attachments are fineWorking as intended. Sampling stops at the cut; forward kinematics does not.
Distant characters animate at the wrong speedSkipped time is not being accumulated into PendingAnimTime, or the skip counter is being reset every frame.
Frame time spikes on one frame with a crowdUpdate-rate staggering defeated. The countdown is per component and seeded from the entity id for a reason.
A pose op silently produces the bind poseA dependency resolved to NoTask. Every executor case falls back to the bind pose rather than reading an unset buffer.