auto-rig
A character nobody rigged, walking. Four meshes as they came out of a
modelling program (no skeleton, no weights) are rigged in the browser and play
UAL's clips: eight markers per mesh place a 65-bone skeleton (fitSkeleton),
bone heat binds the skin to it (boneHeat), buildRig makes a Babylon
skeleton of the result, and retargetClip plays the clips on it. The weasels
have tails, which no clip animates, so their tails run on spring bones.
Demo
Omnidude, a weasel, a skinny weasel and a space marine (shown at 42% of his 4.4 m), by Tonio Loewald, CC BY 4.0.
Open the panel (the owl) to rig one yourself: pick a character and its front view appears with the eight markers on it. Drag a marker (the other side follows while symmetry is on) and when you let go the character is fitted, bound and walking again. guess places the markers from the silhouette alone, which is where you would start with a new mesh. The weasels' tail tips are placed in the side view. The clip, the bones and tail stiffness are below.
import {
b3d, b3dLight, assetUrl, select3d, slider3d, toggle3d, button3d, label3d,
objTriangles, fitSkeleton, meshQuery, skinSegments, boneHeat, buildRig,
readSkeleton, retargetClip, springNodes, markerField3d,
} from 'tosijs-3d'
import { tosi } from 'tosijs'
const { autorig } = tosi({
autorig: { who: 'skinny weasel', clip: 'Walk_Loop', bones: false, tail: 0.25, symmetry: true },
})
const clips = ['Idle_Loop', 'Walk_Loop', 'Jog_Fwd_Loop', 'Sprint_Loop', 'Dance_Loop', 'Crouch_Fwd_Loop']
// Mixamo's eight markers on the front view (the character's left is +X),
// given for the left side and mirrored.
const markers = (chin, groin, wrist, elbow, knee) => {
const p = ([x, y]) => ({ x, y })
const m = ([x, y]) => ({ x: -x, y })
return {
chin: p(chin), groin: p(groin),
wristL: p(wrist), wristR: m(wrist), elbowL: p(elbow), elbowR: m(elbow), kneeL: p(knee), kneeR: m(knee),
}
}
const cast = [
{ name: 'omnidude', file: 'omnidude.obj', objects: ['Omnidude', 'Head'], color: [0.55, 0.6, 0.7],
markers: markers([0, 1.338], [0, 0.467], [0.74, 1.075], [0.47, 1.095], [0.152, 0.258]) },
{ name: 'weasel', file: 'weasel.obj', objects: ['Weasel'], color: [0.6, 0.42, 0.25], tail: { x: 0, y: 0.5, z: -0.95 },
markers: markers([0, 0.967], [0, 0.358], [0.51, 0.763], [0.397, 0.763], [0.142, 0.174]) },
{ name: 'skinny weasel', file: 'weasel-skinny.obj', objects: ['Body', 'Head', 'Tail'], color: [0.8, 0.5, 0.25], tail: { x: 0, y: 0.77, z: -0.95 },
markers: markers([0, 1.259], [0, 0.64], [0.657, 1.154], [0.404, 1.14], [0.106, 0.34]) },
{ name: 'space marine', file: 'space-marine.glb', mesh: 'EvilMarineBody', color: [0.3, 0.4, 0.3], scale: 0.42,
markers: markers([0, 3.594], [0, 1.656], [1.966, 2.937], [1.18, 3.064], [0.388, 0.943]) },
]
let bodies = []
let field = null
// Tall enough for the marker panel beside the characters.
preview.closest('tosi-example')?.style.setProperty('--tosi-example-height', '560px')
preview.append(
b3d(
{
style: 'width:100%;height:100%',
clearColor: '#2a3340',
panelTitle: 'Auto-rig',
scenePanel: () => {
const i = cast.findIndex((c) => c.name === autorig.who.value)
const body = bodies[i]
field = body
? markerField3d({
positions: body.positions, indices: body.indices,
value: body.markers, tail: body.tail, symmetry: autorig.symmetry.value,
handleCommit: (m, t) => { body.markers = m; body.tail = t; body.rig() },
})
: null
return [
select3d({ label: 'rig', value: autorig.who, options: cast.map((c) => c.name) }),
field ?? label3d({ text: 'loading…' }),
toggle3d({ label: 'symmetry', value: autorig.symmetry }),
button3d({ label: 'guess', handleClick: () => field?.guess() }),
select3d({ label: 'clip', value: autorig.clip, options: clips }),
toggle3d({ label: 'bones', value: autorig.bones }),
slider3d({ label: 'tail stiffness', value: autorig.tail, min: 0, max: 1, step: 0.01 }),
]
},
async sceneCreated(el, BABYLON) {
const scene = el.scene
const cam = new BABYLON.ArcRotateCamera('c', Math.PI / 2, 1.35, 6.5, new BABYLON.Vector3(0, 0.8, 0), scene)
cam.attachControl(el.querySelector('canvas'), true)
el.setActiveCamera(cam)
const ground = BABYLON.MeshBuilder.CreateGround('g', { width: 12, height: 6 }, scene)
ground.position.y = -0.01
// The meshes, as triangles in the file's own (right-handed) frame.
const triangles = async (c) => {
const url = assetUrl('tonio/rigging/' + c.file)
if (c.file.endsWith('.obj')) return objTriangles(await (await fetch(url)).text(), c.objects)
const glb = await BABYLON.LoadAssetContainerAsync(url, scene)
const m = glb.meshes.find((m) => m.name === c.mesh)
const out = { positions: Array.from(m.getVerticesData('position')), indices: Array.from(m.getIndices()) }
glb.dispose()
return out
}
// UAL's mannequin is only the clips' source: it stays hidden.
const ual = await BABYLON.LoadAssetContainerAsync(assetUrl('quaternius/UAL1_core.glb', 2), scene)
ual.addAllToScene()
for (const g of ual.animationGroups) g.stop()
const ualRoot = ual.meshes.find((m) => m.name === '__root__')
const source = readSkeleton(ual.skeletons[0], ualRoot)
ualRoot.setEnabled(false)
// Retarget a clip to a body the first time it is asked for.
const clipFor = (body, name) => {
if (!body.clips.has(name)) {
const clip = ual.animationGroups.find((g) => g.name === name)
body.clips.set(name, clip ? retargetClip(clip, source, body.read, { scene }) : null)
}
return body.clips.get(name)
}
const play = (body) => {
body.playing?.stop()
body.playing = clipFor(body, autorig.clip.value)
body.playing?.start(true)
}
bodies = []
for (const [i, c] of cast.entries()) {
const { positions, indices } = await triangles(c)
const mat = new BABYLON.StandardMaterial(c.file, scene)
mat.diffuseColor = new BABYLON.Color3(...c.color)
// Place and scale by a node above the rig's own (mirrored) root.
const stand = new BABYLON.TransformNode(c.file + '-stand', scene)
stand.position.x = 2.4 - 1.6 * i
stand.scaling.setAll(c.scale ?? 1)
const body = { positions, indices, markers: c.markers, tail: c.tail ?? null, clips: new Map() }
// Fit, bind and build, then play: again whenever the markers move.
body.rig = () => {
for (const g of body.clips.values()) g?.dispose()
body.clips.clear()
body.playing = null
body.spring?.dispose()
body.viewer?.dispose()
body.built?.dispose()
const rig = fitSkeleton(body.markers, meshQuery(positions, indices), { tail: body.tail ?? undefined })
const weights = boneHeat(positions, indices, skinSegments(rig))
const built = buildRig(scene, c.file, positions, indices, rig, weights)
built.mesh.material = mat
built.root.parent = stand
const tail = ['tail_01', 'tail_02', 'tail_03', 'tail_04', 'tail_05', 'tail_leaf'].map((n) => built.node(n))
body.spring = tail[0] ? springNodes(scene, tail, { params: { stiffness: autorig.tail.value } }) : null
body.viewer = new BABYLON.SkeletonViewer(built.skeleton, built.mesh, scene, false, 3, { displayMode: 0 })
body.viewer.isEnabled = autorig.bones.value
body.built = built
body.read = readSkeleton(built.skeleton, built.root)
play(body)
}
body.rig()
bodies.push(body)
}
el.refreshScenePanel()
autorig.who.observe(() => el.refreshScenePanel())
autorig.symmetry.observe(() => { if (field) field.symmetry = autorig.symmetry.value })
autorig.clip.observe(() => { for (const b of bodies) play(b) })
autorig.bones.observe(() => { for (const b of bodies) b.viewer.isEnabled = autorig.bones.value })
autorig.tail.observe(() => { for (const b of bodies) if (b.spring) b.spring.params.stiffness = autorig.tail.value })
},
},
b3dLight({ intensity: 0.9 })
)
)
How it is built
AUTO-RIG, THE BABYLON SIDE: a fitted skeleton (rig-fit) and its skin
weights (bone-heat) become a real Babylon skeleton on a mesh, built the way
the glTF loader builds one, so everything that works on a loaded character
works on it: readSkeleton and retargetClip play UAL clips on it, and
springNodes simulates its tail.
The joints are TransformNodes under one container (root), each bone LINKED
to its node, with the mesh under the same container: Babylon then reads each
bone from its node, and the skin and the nodes share a frame.
THE DATA IS RIGHT-HANDED, as OBJ and glTF files are (and as rig-fit
assumes: a character facing +Z has its left on +X). Babylon is left-handed,
so the container carries the same mirror glTF's __root__ does (a half turn
about Y and z scaled by -1). Pass root to readSkeleton as you would
__root__: both measure in the file's own frame. Move the character by a
parent of root, not root itself.