spring bones

Hair, tails and cloaks that follow the body. A chain of bones nobody animates — a ponytail, a tail, a strand of a cape — swings, trails and settles on its own, after the body's animation has posed it each frame. The body is animated; the hair is simulated.

Demo

A UAL mannequin with a ponytail it was not made with: six bones hung from the head, a tube skinned to them by bone heat, and the chain simulated. Change the clip, or the stiffness and damping, in the panel.

import { b3d, b3dLight, assetUrl, select3d, slider3d, springBones, boneHeat, sceneDelta } from 'tosijs-3d'
import { tosi } from 'tosijs'

const { hair } = tosi({ hair: { clip: 'Jog_Fwd_Loop', stiffness: 0.1, damping: 0.6 } })
const clips = ['Idle_Loop', 'Walk_Loop', 'Jog_Fwd_Loop', 'Sprint_Loop', 'Dance_Loop']
const speeds = { Idle_Loop: 0, Walk_Loop: 1.4, Jog_Fwd_Loop: 3, Sprint_Loop: 5.5, Dance_Loop: 0 }

preview.append(
  b3d(
    {
      clearColor: '#2a3340',
      scenePanelOpen: true,
      panelTitle: 'Ponytail',
      scenePanel: () => [
        select3d({ label: 'clip', value: hair.clip, options: clips }),
        slider3d({ label: 'stiffness', value: hair.stiffness, min: 0, max: 1, step: 0.01 }),
        slider3d({ label: 'damping', value: hair.damping, min: 0, max: 0.99, step: 0.01 }),
      ],
      async sceneCreated(el, BABYLON) {
        const scene = el.scene
        const cam = new BABYLON.ArcRotateCamera('c', Math.PI / 3, 1.3, 3.2, new BABYLON.Vector3(0, 1, 0), scene)
        cam.attachControl(el.querySelector('canvas'), true)
        el.setActiveCamera(cam)
        BABYLON.MeshBuilder.CreateGround('g', { width: 12, height: 12 }, scene)

        const ual = await BABYLON.LoadAssetContainerAsync(assetUrl('quaternius/UAL1_core.glb', 2), scene)
        ual.addAllToScene()
        for (const g of ual.animationGroups) g.stop()
        const root = ual.meshes.find((m) => m.name === '__root__')
        const node = (n) => ual.transformNodes.find((t) => t.name === n)
        const head = node('Head')

        // Where the ponytail hangs, measured off the body in its rest pose:
        // forward is from the heel to the toes, up the back of the skull.
        root.computeWorldMatrix(true)
        for (const t of ual.transformNodes) t.computeWorldMatrix(true)
        const at = (n) => node(n).getAbsolutePosition().clone()
        const fwd = at('ball_leaf_l').subtract(at('foot_l')).multiplyByFloats(1, 0, 1).normalize()
        const up = new BABYLON.Vector3(0, 1, 0)
        const start = at('Head').add(up.scale(0.13)).subtract(fwd.scale(0.1))
        const dir = fwd.scale(-0.45).add(up.scale(-0.9)).normalize()
        const points = [0, 1, 2, 3, 4, 5, 6].map((i) => start.add(dir.scale(0.075 * i)))

        const pony = springBones(scene, head, points, {
          colliders: [
            { node: head, radius: 0.12, offset: up.scale(0.09) },
            { node: node('spine_03'), radius: 0.17 },
            { node: node('clavicle_l'), radius: 0.09 },
            { node: node('clavicle_r'), radius: 0.09 },
          ],
          params: { stiffness: hair.stiffness.value, damping: hair.damping.value },
        })
        hair.stiffness.observe(() => (pony.params.stiffness = hair.stiffness.value))
        hair.damping.observe(() => (pony.params.damping = hair.damping.value))

        // A tube around the chain, skinned to it by bone heat.
        const tube = pony.skinTube({ radius: 0.035, tipRadius: 0.012, sides: 8, boneHeat })
        const mat = new BABYLON.StandardMaterial('hair', scene)
        mat.diffuseColor = new BABYLON.Color3(0.45, 0.25, 0.1)
        mat.backFaceCulling = false
        tube.material = mat

        // Run round a circle at the clip's own speed, so the hair trails.
        // (A node above glTF's root, whose mirror and turn stay as loaded.)
        const runner = new BABYLON.TransformNode('runner', scene)
        root.parent = runner
        const yaw0 = Math.atan2(fwd.x, fwd.z)
        // The camera follows the runner, at head height.
        const look = new BABYLON.TransformNode('look', scene)
        look.parent = runner
        look.position.y = 1.35
        cam.setTarget(look)
        let angle = 0
        const radius = 2.2
        scene.onBeforeAnimationsObservable.add(() => {
          angle += ((speeds[hair.clip.value] ?? 0) * sceneDelta(scene)) / radius
          runner.position.set(Math.cos(angle) * radius, 0, Math.sin(angle) * radius)
          runner.rotation.y = Math.atan2(-Math.sin(angle), Math.cos(angle)) - yaw0
        })
        let playing = null
        const play = (name) => {
          playing?.stop()
          playing = ual.animationGroups.find((g) => g.name === name)
          playing?.start(true)
        }
        play(hair.clip.value)
        hair.clip.observe(() => play(hair.clip.value))
      },
    },
    b3dLight({ intensity: 0.9 })
  )
)

Use

springBones(scene, anchor, points, options) hangs a chain from anchor (any TransformNode: a glTF bone's node, or a mesh) through points (world, in the rest pose), makes a TransformNode per joint under the anchor, and simulates them every frame after animations run. Options:

option
params stiffness (0 hangs free, 1 holds its rest shape), damping (velocity lost per second), gravity, radius
colliders spheres the chain stays out of: { node, radius, offset? }

It returns { nodes, params, skinTube(options), reset(), dispose() }. nodes are the chain's joints, to parent your own meshes to; skinTube builds a tube skinned to them (pass boneHeat to weight it). Call reset() after a teleport. The pure model is spring-chain: Verlet at a fixed 120 Hz step, lengths solved from the root with Müller's follow-the-leader damping (otherwise the tip cracks like a whip for seconds).