Bone-Mask Animation Layering
Layer multiple animations on a single skeletal mesh, with each slot scoped to a subset of bones and blended in either Replace or Additive mode.
The classic motivating case: a character running with their legs while attacking, aiming, or reacting with their upper body — without authoring a custom "run-and-attack" clip for every combination.
TL;DR
-- Lower body: full-body locomotion
mesh:PlayAnimation("Idle", true, 1, 1.0 - runAlpha, 0)
mesh:PlayAnimation("Run", true, 1, runAlpha, 1)
-- Upper body: masked attack, plays once and self-removes
mesh:PlayAnimationMasked("Punch", 2, UpperBodyMask, false, 1.0, 1.0)
-- Hit reaction on top of everything, additive
mesh:PlayAnimationAdditive("HitReact_add", 3, UpperBodyMask, false, 1.0, 1.0)
Slot 0/1 drive every bone with weighted Idle↔Run blend. Slot 2 overwrites masked-in bones only (Replace mode). Slot 3 adds a delta on top of whatever's below (Additive mode).
Authoring a BoneMaskAsset
- Right-click in the asset browser → New Asset → Bone Mask, or use the View menu → Bone Mask Editor.
- Assign a Target Mesh (the rig you want to author against). Masks are name-keyed, so any rig with matching bone names will use this mask correctly; the target mesh is for authoring preview only.
- Right-click bones in the tree to Include or Exclude subtrees. Include adds the bone and all descendants; Exclude removes the bone and all its descendants from the resolved set.
- Self Only flips both lists from "include this bone and descendants" to "include only this bone". Useful for finger or twist-bone tweaks.
- Save (top of panel, lights up when dirty).
The live 3D preview shows your target mesh with the resolved-included bones tinted bright green and excluded bones dim grey, drawn as a wireframe overlay on top of the rendered mesh. Pick an animation and hit Play to verify the mask follows the deforming pose in real time.
Typical mask presets:
| Mask | Include | Exclude |
|---|---|---|
| Upper Body | Spine_01 (or root spine bone) |
— |
| Upper Body, no head | Spine_01 |
Head |
| Lower Body | Hips |
Spine_01 |
| Left Arm | LeftShoulder |
— |
| Both Arms | LeftShoulder, RightShoulder |
— |
| Head only | Neck |
— |
Bone names depend on your rig's exporter conventions (Mixamo's mixamorig:Spine, ARP's c_spine_01, Rigify's DEF-spine, etc). The mask stores the name verbatim and resolves at runtime.
Playing masked animations
The blend system has 8 simultaneous slots per SkeletalMesh3D. Each slot holds one clip plus its own weight, speed, mask, and layer mode.
-- Replace mode (default for PlayAnimationMasked):
-- masked-in bones are weighted-mixed with the slot's clip,
-- masked-out bones stay at whatever lower slots provided.
mesh:PlayAnimationMasked(clipName, slot, mask, loop, speed, weight)
-- Additive mode:
-- the slot's clip is treated as a DELTA FROM BIND POSE.
-- (clip - bind) * weight is added on top of the accumulator.
-- Use for breathing, recoil, hit reactions, leans — never for full-pose clips.
mesh:PlayAnimationAdditive(clipName, slot, mask, loop, speed, weight)
loop = false is usually correct for one-shot upper-body clips — the slot self-removes when the clip finishes, leaving the lower-body blend untouched.
mask = nil is equivalent to no mask (full body).
Slot management at runtime
-- Swap or clear the mask on an already-playing slot
mesh:SetSlotMask(slot, mask) -- nil to clear
mesh:SetSlotMask(slot, nil)
-- Snap the slot's weight
mesh:SetSlotWeight(slot, 0.5)
-- Fade weight over time. Slot self-removes when target == 0 and fade completes.
mesh:FadeSlotWeight(slot, 0.0, 0.25) -- fade out over 0.25 s
mesh:FadeSlotWeight(slot, 1.0, 0.10) -- fade in over 0.10 s
-- Change layer mode without re-issuing the play call
mesh:SetSlotLayerMode(slot, AnimLayerMode.Additive)
mesh:SetSlotLayerMode(slot, AnimLayerMode.Replace)
AnimLayerMode is a global Lua enum table: AnimLayerMode.Replace == 0, AnimLayerMode.Additive == 1.
How the blend math works
For each slot in iteration order, for each animated bone:
- If the slot has a mask and the bone is not in the resolved bitset → skip.
- If this bone hasn't been touched yet this frame → seed
sDecompTransforms[bone]from the mesh's decomposed bind pose. - Replace mode:
accum.position = mix(accum.position, clip.position, weight) accum.rotation = slerp(accum.rotation, clip.rotation, weight) accum.scale = mix(accum.scale, clip.scale, weight) - Additive mode:
accum.position += (clip.position - bind.position) * weight accum.rotation = slerp(identity, clip.rotation * inv(bind.rotation), weight) * accum.rotation accum.scale += (clip.scale - bind.scale) * weight
Then the composed local matrices go through FinalizeBoneTransforms (the parent-chained world walk) and from there into every platform's skinning pipeline. All masking and additive math happens before the chain step, so every backend — Vulkan, GX (Wii / GC), C3D (3DS), PSP CPU skinning, addon-target — works without per-platform changes.
Replace vs Additive — choosing the right mode
| Clip type | Mode | Example |
|---|---|---|
| Full-pose animation: every bone has explicit position/rotation/scale | Replace | Punch, Kick, Wave, Reload, Dance |
| Delta animation: starts at bind pose, deviates by some delta | Additive | Breathing oscillation, Recoil pulse, Hit flinch, Lean-into-turn, Aim offset |
The most common mistake: pointing an Additive slot at a normal full-pose clip. The math treats every bone's pose as a delta from bind, so the resulting pose double-transforms and looks broken. Use Replace mode for full-pose attacks with a mask; Additive is reserved for clips authored specifically as offsets (usually distributed with _add or Additive in the name).
A complete character pattern
-- In Tick:
function Player:UpdateAnimation(dt)
local runAlpha = Math.Clamp(self.speed / self.maxSpeed, 0, 1)
-- Lower-body locomotion (slots 0+1, full body, weighted)
self.mesh:PlayAnimation("Idle", true, 1, 1.0 - runAlpha, 0)
self.mesh:PlayAnimation("Run", true, 1, runAlpha, 1)
end
-- On attack (one-shot):
function Player:M_Attack()
-- Masked Replace, no loop, self-removes when clip ends.
self.mesh:PlayAnimationMasked("Attack", 2, self.upperBodyMask, false, 1.0, 1.0)
self.attackTime = 0.4
end
-- On take-hit (one-shot additive):
function Player:OnHit()
self.mesh:PlayAnimationAdditive("HitReact_add", 3, self.upperBodyMask, false, 2.0, 1.0)
end
-- On aim hold/release (smooth weight):
function Player:SetAiming(on)
if on then
self.mesh:PlayAnimationMasked("AimPose", 4, self.armsMask, true, 1.0, 0.0)
self.mesh:FadeSlotWeight(4, 1.0, 0.15)
else
self.mesh:FadeSlotWeight(4, 0.0, 0.20) -- slot self-removes
end
end
Gotchas
- Slot collisions: any per-tick
PlayAnimation(name, ..., slot=N)will overwrite a masked or additive slot you placed on N. Use a free slot for layers (typically 2+ if your locomotion uses 0/1). PlayAnimationwithloop = truefor layers is rarely correct. Most masked layers are one-shot reactions and should befalse.- Don't apply Additive to full-pose clips. It will double-transform. Use Replace with a mask instead.
- Empty mask resolves to zero bones in scope. If a
BoneMaskAssethas no includes and no excludes, the slot is valid but drives nothing. Add at least one include. - Mask names depend on your rig. A mask authored against a Mixamo skeleton with bones named
mixamorig:Spinewill not resolve on a custom rig that names the same boneSpine_01. Re-author or use a HumanoidAvatarAsset bone-name remap. SkeletalMesh3D::Tickdoes not drive the blend — the renderer's frustum-cull pass does, gated byAnimationUpdateMode. Out-of-frustum characters skip the blend by default unless you changemAnimationUpdateModeon the node.
File reference
Internal architecture, blend-loop refactor proof, asset-version gate, per-platform notes: see .llm/BoneMask.md.