Skip to content

Animation

Skinned character animation in Freya: Assimp load → local poses → AnimGraph → skin matrix palette → GPU vertex LBS. Namespace is fra (FREYA_NAMESPACE).

#include <Freya/Freya.hpp>   // poses, graph, bake, GpuAnimation, Renderer GPU anim

Mesh load entry points remain under Assets (MeshPool). This page is the full animation stack.

Architecture

There is no CPU vertex skinning. Bind-pose vertices stay on the GPU; shaders apply Linear Blend Skinning (4 influences) from a bone palette SSBO (bones[] / prevBones[] for TAA).

flowchart LR
  load[SkinnedModel load] --> bake[Optional BakeClip]
  bake --> graph[AnimGraph tick]
  graph --> cpuPath[CPU: PoseToSkinMatrices]
  graph --> gpuPath[GPU: GpuAnimPass compute]
  cpuPath --> palette[BoneMatrixResources]
  gpuPath --> palette
  palette --> vs[Vertex LBS]
Stage Where
Load skeleton + clips CPU (MeshPool)
Dense clip bake CPU (BakeClip)
Graph time / transitions / events CPU (AnimGraph)
Local pose + layers + look/IK CPU or GPU compute
Skin matrices (global * inverseBind) CPU upload or compute write
Vertex deformation Always vertex shaders

Layer stack (both paths): locomotion (Clip / Blend1D / Blend2D) → optional masked overlay → optional additive → look-at / two-bone IK.

GPU overlay contract: at most one OverrideMasked and one Additive slot per GpuAnimInstance. Extra same-mode layers are ignored on GPU (first wins). CPU SampleCurrent can still stack more layers.

Loading skinned models

fra::SkinnedModel fox =
    meshPool->CreateSkinnedModelFromFile("./Resources/Models/Fox.glb");
// fox.submeshes, fox.skeleton, fox.clips

Assimp path skips PreTransformVertices and limits bone weights to 4.

Type Role
SkinnedModel submeshes + shared skeleton + clips
Skeleton names, parents (−1 = root), inverseBind, restLocal
AnimationClip name, duration, ticksPerSecond, channels, events
EvaluateSkeletonPose(skel, clip, t) One-liner: sample clip → skin matrices
EnsureDefaultFootstepEvents(clip) If empty: Footstep.L/R ≈ 15% / 65% duration

Static instances use SceneInstanceUpload::boneOffset = fra::kNoSkin.

Local poses and blending

Headers: Pose.hpp.

Types: JointTRS, LocalPose, BoneMask, RootMotionDelta, Blend1DSample / Blend1DSpan, Blend2DSample / Blend2DTriangle.

API Role
FindJointIndex / RestLocalPose Lookup / bind rest
SampleClip Keyframe local pose
BlendLocalPoses Crossfade
BlendMasked Override with BoneMask
BlendAdditive (clip − rest) (optional mask)
EvaluateBlend1D / EvaluateBlend2D Blend spaces (+ phase sync helpers)
LocalToGlobal / PoseToSkinMatrices FK → skin palette
ExtractRootDelta Root motion from local pose

BoneMask: Resize, SetJoint, SetSubtree, Filled.

AnimGraph

State machine over clips / blend spaces with params, transitions, layers, and optional baking.

Tick API

Method Behavior
Advance(dt, outEvents?) Transitions + phase/time; no pose
SampleCurrent() Sample base (+ crossfade) then layers → LocalPose
Evaluate(dt, outEvents?) Advance + SampleCurrent

Crowd GPU skin typically calls Advance at animation LOD Hz with accumulated dt (not every display frame). Clip markers fire at that tick rate; pose bake stays on the GPU.

Params: SetFloat / SetBool / SetTrigger, GetFloat / GetBool.
Layers: SetLayerEnabled / SetLayerWeight / IsLayerEnabled.

Conditions and transitions

AnimCondition kinds: FloatGreater, FloatLessEqual, BoolTrue, BoolFalse, Trigger (factories: FloatGreater, FloatLessEqual, BoolTrue, BoolFalse, OnTrigger).

Transition(from, to, condition, blendDuration = 0.2f).

Layers

AnimLayerMode::OverrideMasked (BlendMasked) or Additive (BlendAdditive vs rest).

GPU pack: TryGetLayerGpuSlots(AnimLayerGpuSlots&) — first masked + first additive with weight > 0. Legacy: TryGetPrimaryLayerGpuSample.

Locomotion GPU pack

TryGetLocoGpuSample(AnimLocoGpuSample&) — up to three clips + barycentric wA/wB/wC (Blend1D sets wC = 0). Prefer over TryGetBlend1DGpuSample.

Builder

auto graph = fra::AnimGraphBuilder()
                 .SetSkeleton(&fox.skeleton)
                 .ParamFloat("Strafe", 0.f, -1.f, 1.f)
                 .ParamFloat("Speed", 0.f, 0.f, 2.f)
                 .Blend2DState("Loco", "Strafe", "Speed") // syncPhase=true
                 .AddBlend2DSample(0.f, 0.f, idle, true, 1.f, &bakeIdle)
                 .AddBlend2DSample(0.f, 2.f, run, true, 1.85f, &bakeRun)
                 .Layer("Upper", idle)
                 .LayerMasked(&upperMask, 0.85f)
                 .LayerBake(&bakeIdle)
                 .Layer("AddIdle", idle)
                 .LayerAdditive(0.55f, &upperMask)
                 .LayerBake(&bakeIdle)
                 .EnableBaking(30.f) // optional; ≤0 uses keyframe SampleClip
                 .Entry("Loco")
                 .Build();

graph.SetFloat("Speed", 1.35f);
std::vector<fra::FiredAnimationEvent> events;
auto local = graph.Evaluate(dt, &events);

States: Clip, Blend1D, Blend2D (syncPhase defaults true for gait alignment).

Debug: CaptureDebugSnapshot / ApplyDebugSnapshot (AnimGraphDebugSnapshot).

Clip baking

API Notes
BakedClip Frame-major joints[frame * jointCount + j]
BakeClip(skel, clip, bakeHz = 30) bakeHz clamped ≥ 1
SampleBaked(skel, baked, t, loop) Lerp/slerp
FreyaOptions::animBakeHz Default 30; builder SetAnimBakeHz

GPU upload helpers: PackBakedClips, PackClipJointsFloat / PackClipJointsQuant, MakeGpuClipHeader, GpuClipKey (FNV-1a 64).

CPU skinning path

auto local = graph.Evaluate(dt, &events);
fra::ApplyLookAt(skel, local, model, head, target, 0.75f);
fra::SolveTwoBoneIK(skel, local, model, chain, ikTarget, pole, 0.85f);
fra::CancelRootTranslationXZ(skel, local); // after consuming root motion
auto skin = fra::PoseToSkinMatrices(skel, local);
renderer->UploadBoneMatrices(skin);

uploads.push_back({
    .model = model,
    .meshId = fox.submeshes[0].meshId,
    .materialId = fox.submeshes[0].materialId,
    .entityId = 1,
    .boneOffset = 0,
    .boneCount = fox.skeleton.JointCount(),
});
renderer->UploadSceneInstances(uploads);

BoneMatrixResources holds current + previous palettes (TAA). Default capacity 32768 mat4s. UploadBoneMatrices writes only the provided span (optional boneOffset); it does not identity-fill the rest of the palette. The written span is unmarked from GPU-owned FiF carry ranges.

GPU animation path

GpuAnimPass (frame stage before Pick/Shadow/Deferred) samples baked clips, blends loco/layers, optional look/IK, FK, then writes skin matrices into BoneMatrixResources.

Limits

Constant Value
kMaxJoints 128
kMaxInstances 2048
kMaxClips 24
kMaxBakedJointsFloat 65536 (48 B/joint)
kMaxBakedJointsQuant 196608 (16 B; same VRAM as float pool)
kMaxExtractJoints 64

Setup and frame loop

auto* gpu = renderer->GetGpuAnimPass();
gpu->UploadSkeleton(fra::PackSkeleton(skel));
gpu->EnsureClipResident(fra::GpuClipKey("Walk"), bakeWalk); // LRU cache
gpu->UploadBoneMask(upperWeights);
gpu->UploadRestJoints(...);
gpu->SetRigIndices(lookJ, ikRoot, ikMid, ikTip, rootJ);
gpu->SetCopyPrevBones(true); // sparse LOD / FiF continuity
gpu->UploadInstances(instances); // marks sticky GPU-owned bone ranges
gpu->SetEnabled(true);
// Dispatch runs in the frame graph

Mixed CPU + GPU (same frame): upload CPU skins first, then UploadInstances for GPU actors, with SetCopyPrevBones(true). Carry / copy-to-prev only touch GPU-owned ranges, so hero CPU skins are not wiped. Prefer uploading only CPU-owned matrices (not a full palette of identity padding for GPU slots). Call UploadInstances after the CPU upload so wild slots are re-marked the same frame.

After toggling quantizeGpuAnimJoints, call fra::Advanced(renderer).GpuAnimation().RebuildPass() and re-upload skeleton / clips / mask / rest / rig.

GpuAnimInstance

Per-actor job: loco clipA/B/C, time*, wA/B/C; flags (GpuAnimFlags::Loop, MaskedOverlay, Additive, CancelRootXZ); mask / additive slots; modelWorld; look (lookTarget, lookWeight, lookJoint, lookLocalForward, lookMaxYaw / lookMaxPitch); IK (ikRoot / Mid / Tip, ikTarget, ikPole, ikWeight). Disabled joint index: 0xffffffffu.

Joint storage: GpuFloatJoint (48 B) or GpuQuantJoint (16 B, smallest-three quat + half floats) via quantizeGpuAnimJoints.

Clip streaming

Cache of 24 slots. EnsureClipResident may evict unpinned LRU entries. PinClipSlot / TouchClipSlot / EvictClipSlot / ResetClipCache. Bulk UploadBakes fills slots 0..n−1 pinned.

Joint extract and timing (N+1)

API Role
SetJointExtractList / PollJointExtract Async copy of a few skin mats
Renderer::PollGpuAnimJointExtract Wrapper at Update start
PollTiming / PollGpuAnimTiming Carry + bake GPU timestamps

Extract and timing are one frame late. Same-frame gameplay should use CPU FK for sockets or sync ReadbackBones (debug / golden only). Prefer a handful of hero joints over full-palette readback.

DispatchImmediate + ReadbackBones: sync golden compares (Fox0).

Animation LOD and quality

Wall-clock pose update rates, independent of display FPS. Skipped frames keep the last skin palette; playback does not freeze when you accumulate dt into Advance.

Field Role
enableAnimLod Master switch (default true)
animLodHz[4] Near → Far Hz
animLodExitDist[3] / animLodEnterDist[3] Hysteresis bands
animBakeHz Dense bake rate
quantizeGpuAnimJoints 16 B joint storage

FreyaOptionsBuilder::SetAnimationQuality(AnimationQuality) applies presets (ApplyAnimationQuality):

Quality LOD Hz (N→F) Exit dists Enter dists bakeHz
Low on 30/15/8/4 8/18/32 6/14/26 20
Medium on 45/22/12/6 12/24/42 10/20/36 30
High on 60/30/15/8 20/38/55 17/32/48 30
Ultra / Off off every frame 30

Helpers: AnimLodHz, AnimLodMinHz, ConsumeAnimLodTick(accum, dt, hz), UpdateAnimLodTier(o, tier, dist).

Crowd pattern: gate both Advance(pendingDt) and GPU instance upload on the same mustEval / LOD tick.

Clip events

Type / API Role
AnimationEvent Marker on clip (name, timeSec)
FiredAnimationEvent Fired this tick (+ clip*)
CollectClipEvents / CollectFiredClipEvents Manual range collect
Advance / Evaluate outEvents Markers crossed this tick
AnimEventRing Fixed log (default 64); Push / PushAll / CopyChronological

Events are always evaluated on the CPU graph tick, never inside the compute shader.

Look-at, IK, root motion

Header: Rig.hpp.

API Notes
ApplyLookAt(...) weight=1, maxYaw=1.2, maxPitch=0.8, localForward=+Z; maxYawRad < 0 disables clamp
SolveTwoBoneIK(..., TwoBoneChain, target, pole, weight) Analytical
IntegrateRootMotion(model, delta, planar=true) Planar XZ + yaw
CancelRootTranslationXZ After driving the actor transform
FindRootJoint First parentless, or −1
DrivePlanarLocomotion(..., localForward=−Z) In-place clips

GPU mirrors look/IK on each GpuAnimInstance; shared defaults via GpuAnimPass::SetRigIndices / SetLookClamp.

Debugging

Header Content
AnimDebug.hpp SkeletonDebugSnapshot, PoseWorldDebugSnapshot, AnimEventRing
AnimGraphDebug.hpp AnimGraphDebugSnapshot (params, layers, loco, apply flags)
GpuAnimDebug.hpp GpuAnimDebugSnapshot (clip cache, caps, quantize)

Overlay draw (post-composite):

renderer->SetDebugDrawEnabled(true);
auto& dd = renderer->GetDebugDraw();
dd.DrawSkeleton(skel, local, model, { 0.2f, 0.95f, 0.45f, 1.f });
dd.DrawLookRay(skel, local, model, head, cameraPos, { 0.3f, 0.85f, 1.f, 1.f });
dd.DrawTwoBoneIk(skel, local, model, chain.root, chain.mid, chain.tip,
                 target, pole, { 1.f, 0.55f, 0.15f, 1.f },
                 { 1.f, 0.25f, 0.35f, 1.f });

SkinnedFox anim_prof: ~1 Hz stdout line with CPU split (adv_ms / eval_ms / pack_ms / skin_ms / …) and GPU timestamps (gpu_carry_ms / gpu_bake_ms), plus gpu_inst, LOD histogram, and estimated palette_MiB.

cd build/Examples/SkinnedFox
./SkinnedFox 2>&1 | grep anim_prof

SkinnedFox example

Location: Examples/SkinnedFox/ (binary under build/Examples/SkinnedFox/). Always cd to the binary directory before running (relative ./Resources/...).

  • Grid 40×25 (1000 actors), Mixamo-style Idle / Walk / Run
  • Blend2D "Loco" on Strafe×Speed; optional Upper masked + AddIdle additive; fox0 look/IK
  • Fox.glb look axis: local +X (kLookLocalForward)

GPU modes (F12)

Mode Behavior
Off Full CPU evaluate + skin + upload
Fox0 Crowd CPU; fox0 also GPU + staged golden compare
Crowd GPU bake for LOD-due actors; Advance at animLodHz

Controls

Key Action
F1 Help / status
F2 Cast shadows
F3 Debug draw
F4 AnimGraph on/off (off = rest pose)
F5 Upper masked layer
F6 Additive upper
F7 Look-at
F8 Two-bone IK
F9 Toggle quantize + fra::Advanced(renderer).GpuAnimation().RebuildPass()
F10 Clip events / footsteps (Crowd @ animLodHz)
F11 Cycle AnimationQuality
F12 Cycle GPU anim mode
R Root / planar locomotion drive
T Stream next clip into GPU LRU ring
1/2/3, Up/Down Speed
Q/E Strafe

Options builders

Setter Effect
SetAnimationQuality LOD Hz + distance bands + bake Hz
SetAnimLodEnabled Toggle distance LOD
SetAnimBakeHz Dense bake rate
SetQuantizeGpuAnimJoints 16 B vs 48 B clip joints

Renderer helpers: UploadBoneMatrices; GPU anim via fra::Advanced(renderer).GpuAnimation() (RebuildPass, DispatchImmediate, ReadbackBones, SetJointExtract / PollJointExtract, PollTiming).

Shaders

Path Role
Shaders/Anim/skin_bake.comp Float joint bake
Shaders/Anim/skin_bake_quant.comp Quantized joints (FREYA_JOINT_QUANT)
Shaders/Anim/skin_bake_body.inc Shared compute body
Shaders/DeferredCompressed/gbuffer.vert (etc.) Vertex LBS from BoneBuffer

Build compiles these via cmake/CompileShaders.cmake (do not invoke glslc by hand).

File map

Path Responsibility
include/Freya/Asset/Skeleton.hpp Joint hierarchy
include/Freya/Asset/AnimationClip.hpp Clips, events
include/Freya/Asset/SkinnedModel.hpp Load result
include/Freya/Asset/Pose.hpp Local pose + blend spaces
include/Freya/Asset/Rig.hpp Look, IK, root / planar drive
include/Freya/Asset/BakedAnimation.hpp CPU bake table
include/Freya/Asset/AnimGraph.hpp Graph + builder + GPU sample packs
include/Freya/Asset/GpuAnimation.hpp Instance / flags / pack / extract
include/Freya/Asset/BoneMatrixResources.hpp Palette SSBO
include/Freya/Core/GpuAnimPass.hpp Compute bake pass
include/Freya/FreyaOptions.hpp LOD / bake / quantize
include/Freya/Asset/AnimDebug.hpp Skeleton / pose / event ring
include/Freya/Asset/AnimGraphDebug.hpp Graph UI snapshot
include/Freya/Asset/GpuAnimDebug.hpp Pass cache snapshot
include/Freya/Asset/MeshPool.hpp CreateSkinnedModelFromFile
Examples/SkinnedFox/ End-to-end reference

Design notes

  • Prefer CPU FK / proxies for gameplay sockets; do not sync-read the full GPU palette every frame.
  • Hero / cutscene actors that need stacked layers beyond the dual GPU slot should stay on the CPU skin path.
  • Sparse Crowd updates require SetCopyPrevBones(true) so FiF slots do not flicker for actors not dispatched this frame. Carry is scoped to sticky GPU-owned bone ranges from UploadInstances; CPU UploadBoneMatrices spans are excluded so mixed CPU/GPU frames are safe.