character builder
Make a person, and watch them walk. The first steps of the character creator (CHARACTER-DESIGN.md): MakeHuman's CC0 body (human-pack), reshaped by sliders for gender, age, muscle, weight, height and ancestry (human-morph), with UAL's skeleton placed from its own joint cubes (human-rig) so it plays UAL's clips at any shape.
Demo
Open the settings (the owl): the body sliders reshape it while it walks, since every body carries its own joints. The skin weights are bone heat's, worked out from the mesh once when the page loads. The eyes (human-face) follow the camera, and the hair (human-hair) is Quaternius' CC0 hairstyles, made for another head and fitted to this one.
import { b3d, b3dSun, b3dLight, slider3d, select3d, toggle3d, label3d, assetUrl, loadHumanPack, macroWeights, morphHuman, DEFAULT_HUMAN_BODY, humanRig, humanSkin, skinMaterial, lipTint, humanFace, eyeGaze, eyeTexture, eyeMaterial, lashMaterial, loadHairLibrary, headScalp, hairMaterial, humanHair, skinSegments, boneHeat, buildRig, readSkeleton, retargetClip } from 'tosijs-3d'
import { tosi, elements } from 'tosijs'
const { div } = elements
const status = div({ class: 'status' }, 'loading the animations…')
const { walker } = tosi({ walker: { ...DEFAULT_HUMAN_BODY, gender: 1, clip: 'Walk_Loop', tone: 0.3, redness: 0.5, iris: 0.2, hair: 'Hair_SimpleParted', beard: 'none', brows: 'Eyebrows_Regular', hairLight: 0.25, hairRed: 0.15, paused: false } })
let mat = null
let paint = () => {}
let eyes = null
let dress = () => {}
// Filled when the hair library loads.
const styles = { hair: ['none'], beard: ['none'], brows: ['none'] }
const clips = ['Idle_Loop', 'Walk_Loop', 'Jog_Fwd_Loop', 'Sprint_Loop', 'Dance_Loop', 'Crouch_Fwd_Loop']
let rebuild = () => {}
const slider = (label, key) =>
slider3d({ label, value: walker[key], min: 0, max: 1, step: 0.01 })
preview.append(
b3d(
{
clearColor: '#2a3340',
panelTitle: 'Walker',
scenePanel: () => [
select3d({ label: 'clip', value: walker.clip, options: clips }),
toggle3d({ label: 'pause', value: walker.paused }),
slider('gender', 'gender'),
slider('age', 'age'),
slider('muscle', 'muscle'),
slider('weight', 'weight'),
slider('height', 'height'),
label3d({ text: 'Skin' }),
slider3d({ label: 'tone', value: walker.tone, min: 0, max: 1, step: 0.01 }),
slider3d({ label: 'redness', value: walker.redness, min: 0, max: 1, step: 0.01 }),
slider3d({ label: 'eyes', value: walker.iris, min: 0, max: 1, step: 0.01 }),
label3d({ text: 'Hair' }),
select3d({ label: 'hair', value: walker.hair, options: styles.hair }),
select3d({ label: 'beard', value: walker.beard, options: styles.beard }),
select3d({ label: 'brows', value: walker.brows, options: styles.brows }),
slider3d({ label: 'light', value: walker.hairLight, min: 0, max: 1, step: 0.01 }),
slider3d({ label: 'red', value: walker.hairRed, min: 0, max: 1, step: 0.01 }),
label3d({ text: 'Ancestry' }),
slider('african', 'african'),
slider('asian', 'asian'),
slider('caucasian', 'caucasian'),
],
async sceneCreated(el, BABYLON) {
const scene = el.scene
const cam = new BABYLON.ArcRotateCamera('c', Math.PI / 2 - 0.5, 1.35, 4, new BABYLON.Vector3(0, 0.9, 0), scene)
cam.minZ = 0.05
cam.attachControl(el.querySelector('canvas'), true)
el.setActiveCamera(cam)
BABYLON.MeshBuilder.CreateGround('g', { width: 8, height: 8 }, scene)
// UAL's mannequin is only the clips' source: it stays hidden.
const ual = await BABYLON.LoadAssetContainerAsync(assetUrl('quaternius/UAL1_core.glb', 2), scene)
status.textContent = 'loading the body…'
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)
const pack = await loadHumanPack(assetUrl('makehuman/hm08/hm08.json', 4)).catch((e) => {
status.textContent = `could not load the body: ${e.message}`
throw e
})
// Quaternius' hairstyles, made for its own head, fitted to each body.
const hairLib = await loadHairLibrary(scene, assetUrl('quaternius/libraries/hairstyles.glb'))
const adult = (n) => !n.endsWith('_Teen')
const facial = (n) => /Beard|Moustache|MuttonChops/.test(n)
styles.hair.push(...hairLib.names('hair').filter((n) => adult(n) && !facial(n)))
styles.beard.push(...hairLib.names('hair').filter((n) => adult(n) && facial(n)))
styles.brows.push(...hairLib.names('eyebrows').filter(adult))
const tints = new Map()
const tint = (part) => {
const from = part.material
if (!tints.has(from)) tints.set(from, hairMaterial(scene, from, `hair-${tints.size}`))
return tints.get(from)
}
status.textContent = 'binding the skin…'
// Let the message paint before the binding takes the thread.
await new Promise((r) => setTimeout(r, 50))
const skin = humanSkin(pack)
const [lips, eyePart, lashPart] = await Promise.all([pack.region('lips'), pack.part('eyes'), pack.part('eyelashes')])
eyes = eyeTexture(scene, { iris: walker.iris.value })
const faceMaterials = { eye: eyeMaterial(scene, eyes), lashes: lashMaterial(scene) }
let face = null
let scalp = null
let hairMeshes = []
dress = () => {
for (const m of tints.values()) m.setHair(walker.hairLight.value, walker.hairRed.value)
if (!built || !scalp) return
for (const m of hairMeshes) m.dispose()
hairMeshes = []
for (const key of ['hair', 'beard', 'brows']) {
const parts = hairLib.piece(walker[key].value)
if (!parts) continue
// Brows lie on the skin; hair stands a little off it.
const margin = key === 'brows' ? 0.0015 : 0.003
const centreY = key === 'brows' ? scalp.brow : undefined
hairMeshes.push(...humanHair(scene, built, built.rig, scalp, parts, tint, { margin, name: key, centreY }))
}
el.register({ meshes: hairMeshes })
}
for (const key of ['hair', 'beard', 'brows']) walker[key].observe(() => dress())
// Colours repaint whoever sets them: a slider, or code.
const recolour = () => { for (const m of tints.values()) m.setHair(walker.hairLight.value, walker.hairRed.value) }
walker.hairLight.observe(recolour)
walker.hairRed.observe(recolour)
for (const key of Object.keys(DEFAULT_HUMAN_BODY)) walker[key].observe(() => rebuild())
walker.tone.observe(() => paint())
walker.redness.observe(() => paint())
walker.iris.observe(() => eyes.setIris(walker.iris.value))
// The eyes find the camera.
eyeGaze(scene, { get eyes() { return face?.eyes ?? [] }, get forward() { return face?.forward ?? [] } }, () => cam.position)
const body = () => {
const b = {}
for (const k of Object.keys(DEFAULT_HUMAN_BODY)) b[k] = Number(walker[k].value)
return macroWeights(b, (n) => pack.has(n))
}
// The weights depend only on the topology: once, on the first body.
const first = await morphHuman(pack, body())
// The weights depend only on the topology: once, on the first body.
const rig0 = humanRig(pack.manifest, first)
const weights = boneHeat(skin.positions(first), skin.triangles, skinSegments(rig0))
mat = skinMaterial(scene, { tone: walker.tone.value, redness: walker.redness.value })
// The skin's colour, and the lips' as vertex colours over it.
paint = () => {
mat.setSkin(walker.tone.value, walker.redness.value)
if (lips && built) {
built.mesh.setVerticesData('color', lipTint(skin, lips, walker.tone.value, walker.redness.value))
}
}
const stand = new BABYLON.TransformNode('stand', scene)
let built = null
let playing = null
let read = null
const play = () => {
playing?.dispose()
const clip = ual.animationGroups.find((g) => g.name === walker.clip.value)
playing = clip ? retargetClip(clip, source, read, { scene }) : null
playing?.start(true)
if (walker.paused.value) playing?.pause()
}
let busy = false
let again = false
rebuild = async () => {
if (busy) { again = true; return }
busy = true
const full = await morphHuman(pack, body())
const rig = humanRig(pack.manifest, full)
face?.dispose()
built?.dispose()
built = buildRig(scene, 'walker', skin.positions(full), skin.triangles, rig, weights)
face = humanFace(scene, built, rig, pack.manifest, full, { eyes: eyePart, eyelashes: lashPart }, faceMaterials)
built.rig = rig
scalp = headScalp(pack.manifest, full, pack.triangles, hairLib.reference)
dress()
built.mesh.material = mat
built.root.parent = stand
stand.position.y = -rig.at('root').y
el.register({ meshes: [built.mesh, ...face.meshes] })
paint()
read = readSkeleton(built.skeleton, built.root)
play()
busy = false
if (again) { again = false; rebuild() }
}
await rebuild()
status.remove()
walker.clip.observe(play)
walker.paused.observe(() => (walker.paused.value ? playing?.pause() : playing?.play(true)))
},
},
b3dSun({ timeOfDay: 15 }),
b3dLight({ intensity: 0.5 })
),
status
)
tosi-b3d { width: 100%; height: 100%; }
.status { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.6); color: white; padding: 6px 12px; border-radius: 6px; font: 13px sans-serif; }
humanBody
humanBody(scene, pack) draws a pack's body as one unrigged mesh, and
update(positions) reshapes it in place: for a body that only needs to be
looked at.
The pack is RIGHT-HANDED (as OBJ and glTF are), so the body sits under a
container that mirrors it the way glTF's __root__ does, and stands with its
feet at the container's origin. Move the body by moving root. The mesh keeps
every vertex the targets move (helpers and joint cubes too), but draws only the
body's triangles.