animation-layers

Play two clips on one skeleton, split by bone-mask. The Babylon half of walk-and-aim: legs keep the locomotion, arms and head take the aim, and the boundary is feathered rather than cut.

Demo — walk and wave at once

walk on the legs, wave on the arms, spine feathered between them. Drag falloff to 0 to see the mannequin sawn in half, and to 5 to see the twist spread so far the wave loses its authority. The middle is the point.

layer picks any of the rig's sixteen clips as the upper-body one, so you can try salute, talk or look — the last is the closest thing the stock rig has to an aim pose.

import { b3d, b3dLight, b3dSkybox, b3dLoader, slider3d, select3d, label3d, toggle3d } from 'tosijs-3d'
import { boneHierarchy, layerGroups, boneMask, findBone, UPPER_BODY_ROOTS } from 'tosijs-3d'
import { orbitCam } from 'tosijs-3d/demo-utils'
import { tosi } from 'tosijs'

// Bound, so the panel survives being rebuilt (maximise, or entering VR).
const demo = tosi({ layerDemo: { base: 'walk', layer: 'wave', falloff: 2, layered: true } })
const s = demo.layerDemo

let rig = null
let live = null

// Re-split and replay. Cheap — the tiers are new groups over the SAME
// animations, so this allocates groups, not keyframes.
const relayer = () => {
  if (rig == null) return
  live?.stop()
  live?.dispose()
  live = null
  const base = rig.groups.find((g) => g.name === s.base.valueOf())
  const layer = rig.groups.find((g) => g.name === s.layer.valueOf())
  if (base == null || layer == null) return
  for (const g of rig.groups) g.stop()
  if (!s.layered.valueOf()) {
    base.play(true)
    return
  }
  const mask = boneMask(rig.bones, { root: rig.root, falloff: Number(s.falloff) })
  live = layerGroups(rig.scene, base, layer, mask)
  live.play(true)
}

const panel = () => [
  label3d({ text: 'Layered animation' }),
  toggle3d({ label: 'layered', value: s.layered, handleChange: (v) => { s.layered = v; relayer() } }),
  select3d({
    label: 'legs', value: s.base, options: rig ? rig.names : ['walk'],
    handleChange: (v) => { s.base = v; relayer() },
  }),
  select3d({
    label: 'arms', value: s.layer, options: rig ? rig.names : ['wave'],
    handleChange: (v) => { s.layer = v; relayer() },
  }),
  slider3d({
    label: 'falloff', value: s.falloff, min: 0, max: 5, step: 1, showValue: 'always',
    handleChange: (v) => { s.falloff = Math.round(v); relayer() },
  }),
  label3d({ text: '0 = hard cut at one joint', muted: true }),
]

preview.append(
  b3d(
    {
      style: 'width:100%;height:100%',
      scenePanelOpen: true,
      scenePanel: panel,
      sceneCreated(el) {
        // Target offset LEFT so the figure sits right of centre — the settings panel
        // covers the left half of the viewport, and a demo hidden behind its own
        // controls is a demo nobody looks at.
        orbitCam(el, { alpha: -1.9, beta: 1.28, radius: 4.4, target: [-0.9, 1, 0] })
        // `b3dLoader` below does the loading — the proven path, with the
        // library's own material conventions. The rig simply APPEARS in the
        // scene when it is ready, so poll for it rather than racing a callback
        // the element does not offer.
        const wait = setInterval(() => {
          const skeleton = el.scene?.skeletons?.[0]
          const groups = el.scene?.animationGroups ?? []
          if (skeleton == null || groups.length === 0) return
          clearInterval(wait)
          const bones = boneHierarchy(skeleton)
          const root = findBone(bones, UPPER_BODY_ROOTS)
          // A rig whose split root cannot be found must SAY so — a mask over
          // nothing animates nothing and looks exactly like a failed load.
          if (root == null) { console.error('no upper-body root on this rig'); return }
          // omnidude is 0.88m — half a person. See CLAUDE.md on scale.
          for (const m of el.scene.meshes) if (m.skeleton != null) m.scaling.setAll(2)
          rig = {
            scene: el.scene, skeleton, bones, root, groups,
            names: groups.map((g) => g.name).sort(),
          }
          relayer()
          el.refreshScenePanel?.()
        }, 100)
      },
    },
    b3dSkybox({ timeOfDay: 10 }),
    b3dLight({ intensity: 0.9 }),
    b3dLoader({ url: '/omnidude.glb' })
  )
)
.preview { height: 100%; }

How it is done, and the one thing Babylon does not give you

A Babylon AnimationGroup is a list of TargetedAnimation — an animation and the node it drives — and its weight applies to the WHOLE group. There is no per-bone weight.

So a graded mask becomes one group per weight tier. With falloff: 2 the mask has weights 1/3, 2/3 and 1, which is three groups played at those weights, plus the base clip playing at the complement. Public API throughout, no patching, and the tier count is small because the ramp is short.

⚠️ Babylon has no native ADDITIVE layer. Its animation weights BLEND — a weighted average of poses — where an additive layer would apply a delta on top of whatever is underneath. bone-mask offers MaskMode.additive because it is the right model and the mask maths is the same either way; what this module implements is the blended form, which is what the engine supports.

The practical difference is smaller than it sounds, and worth knowing rather than discovering: with a feathered boundary the lower spine is a genuine blend of walk and aim, so some of the walk's counter-rotation survives into the torso. What you do not get is the full counter-rotation under a fully-authored aim pose. If that turns out to matter, true additive means computing per-bone deltas each frame — real work, and worth measuring the blended version first.

Names are the seam, so they are checked

Targets are TransformNodes named after their bone (mixamorig:Spine2 on the stock rig), and a mask keyed on names nobody has is a mask that drives nothing — silently, and identically to a clip that failed to load. layerGroups reports what it matched so a caller can refuse rather than animate air.