three.js

Verified with three.js 0.186: gate 1 runs on every sample export under Bun, and the viewer is tested in headless Chromium (see "What is checked", below).

A Dramatis GLB is an ordinary glTF file. three.js opens it with its own GLTFLoader, with no plugin and no fixes. Quaternius' Universal Animation Library (UAL) clips play on it by name, the face is a set of morph targets, and the licence of every asset is written inside the file.

This page covers a GLB you exported. If you want to build the character in the page and keep it live as sliders move, read dramatis/three.

Try it: the three.js viewer opens any GLB you drop on it. The creator's View in three.js button sends its export there. The three.js demo walks a character round a scene with UAL's idle, walk, jog and sprint loops: a character saved in this browser (built to a GLB in the page), or a GLB or character file you open.

Load it

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'

const gltf = await new GLTFLoader().loadAsync('character.glb')
scene.add(gltf.scene)

That is all the setup there is. The file is in metres, Y up, facing +Z, with its feet on y = 0 (the export format gives the full profile; shoe soles sit a little below it for now, see Export). Its meshes are named body, eyes, hair, garment_<asset> and so on. Every mesh is skinned to one 65-joint skeleton, and the materials are plain metallic-roughness PBR, so they need no special lighting. A HemisphereLight and a DirectionalLight are enough, and an environment map (RoomEnvironment) makes them look better.

Play an animation

The skeleton is UAL's: the same joint names, and each joint at rest in UAL's own frame. A UAL clip's rotation keys therefore mean the same thing on your character as on Quaternius' mannequin, so the clip plays by name, without retargeting.

There is one catch. A clip's translation keys hold the mannequin's bone lengths, and played as they are they stretch your character to the mannequin's proportions. So keep the rotations on every joint, keep the translations only on root and pelvis, and scale those by the ratio of the two hips' heights. characterClip from dramatis/three does exactly this:

import { AnimationMixer } from 'three'
import { characterClip } from 'dramatis/three'

const ual = await loader.loadAsync('UAL1.glb') // Quaternius, CC0
const walk = ual.animations.find((a) => a.name === 'Walk_Loop')!

const naming = gltf.asset.extras?.dramatis?.naming ?? 'ue'
const hips = (root, name) =>
  root.getObjectByName(name).getWorldPosition(new Vector3()).y
const translationScale =
  hips(gltf.scene, naming === 'ue' ? 'pelvis' : 'mixamorigHips') /
  hips(ual.scene, 'pelvis')

const mixer = new AnimationMixer(gltf.scene)
mixer.clipAction(characterClip(walk, { naming, translationScale })).play()
// and every frame: mixer.update(delta)

If you would rather not depend on Dramatis at run time, the same filter is about fifteen lines (src/three/clip.ts). Walk the clip's tracks, keep *.quaternion and the .position of root and pelvis, and drop the rest.

Mixamo names. An export made with Mixamo names (the creator's naming menu) has the same joints and the same rest frames under other names, such as mixamorig:Hips. three.js drops the : when it binds (mixamorigHips), as it does for Mixamo's own files. A UAL clip then needs its tracks renamed, which characterClip(clip, { naming: 'mixamo' }) does. Nothing else changes, so this is still not retargeting.

Clips from elsewhere. A clip made for another skeleton, with other names or other rest frames, needs real retargeting. three.js has SkeletonUtils.retargetClip for that, but Dramatis doesn't do it for you. The viewer says which joints a clip could not find, rather than guessing.

Drive the face

The face goes out as morph targets, one per face unit, named as MakeHuman names them (JawDrop, LeftUpperLidClosed, MouthLeftPullUp and the rest: 59 in all). three.js reads the names from the file into morphTargetDictionary. One unit can be on several meshes (the lids are on the body and on the eyelashes), and they move together:

function setFaceUnit(unit: string, weight: number) {
  gltf.scene.traverse((o) => {
    const i = o.morphTargetDictionary?.[unit]
    if (i !== undefined) o.morphTargetInfluences[i] = weight
  })
}

setFaceUnit('JawDrop', 0.3)
setFaceUnit('LeftUpperLidClosed', 1) // and the right, to blink

gltf.asset.extras.dramatis.faceUnits lists the units the file has.

Show the licences

Every asset in the file has an entry in gltf.asset.extras.dramatis.licences giving its title, author, source, SPDX licence id and licence URL. A CC BY asset also has attribution, the credit line its licence asks for, word for word. Show that line wherever you show the character:

for (const l of gltf.asset.extras.dramatis.licences)
  console.log(
    `${l.title}: ${l.licence}, by ${l.author}`,
    l.attribution ?? ''
  )

gltf.asset.copyright repeats the same list as one line, for tools that show only that. The animation you play is not in the character's file, so credit it yourself. UAL is CC0, which asks for nothing, but naming Quaternius is kind.

The viewer

The viewer is this page as a working tool. It is three.js only: its build fails if any Babylon code ends up in its bundle. It opens a GLB in any of four ways:

It plays UAL's Walk_Loop at once, and More UAL clips fetches the other 38 clips of UAL volume 1 (6.6 MB, CC0) from the tosijs CDN. Under Motion it says how the clip bound: by name with no retargeting, with the names translated for Mixamo, or not at all, naming the joints it could not find. Face sets any face unit, and blink runs the lid units every four seconds. The licence list is read from the file. A GLB that is not a Dramatis export still opens, and the viewer says so.

The sample

samples/three-glb/ is a whole integration on one page, written to be copied: a loader, lights, orbit controls, a UAL walk, a face unit, a blink, and the licence list. Run it from a checkout:

bun samples/three-glb/serve.ts                  # a sample export
bun samples/three-glb/serve.ts --glb my.glb     # yours

Its main.ts imports characterClip from the source so that it runs without a build. In your project, import it from dramatis/three.

What is checked

Not checked here: how the character looks on a real GPU (the browser tests draw with SwiftShader), and other browsers.