Export format
Normative. This page is the profile every Dramatis GLB and VRM export
follows, version 1. The writer (writeCharacterGlb), the three.js adapter
(characterObject) and the gate-1 harness (test/gate1/) are all built to
it. Where it says MUST, a file that does otherwise is a bug in the writer;
the harness fails it. The same names are in code, in
src/contracts/profile.ts (UE_JOINTS, MIXAMO_NAMES, FACE_UNITS,
checkExportExtras), and a test pins them to tosijs-3d.
Where today's writer falls short of it (each is a known bug, tracked on the board, and listed on Export under "Not yet"):
- Materials are only partly baked: the skin is one flat colour, and
bodyandeyelashescarry noTEXCOORD_0. Theeyesare textured: Tonio Loewald's eye art (CC0,basicoranimeby the character'seyeStyle), its iris recoloured toirisColour, both eyeballs mapped onto one picture with the iris straight ahead. - The floor. The body is placed so that its whole skin would touch y = 0, but where shoes cover the feet that skin is cut away, so the exported skin's lowest point is higher and the shoe soles go below the floor (by up to 36 mm on the samples).
The file
- A GLB (binary glTF 2.0): one JSON chunk and one binary chunk. Every buffer and image is embedded; there are no external URIs.
extensionsRequiredis empty, so every glTF loader opens the file.KHR_materials_sheenmay be inextensionsUsed(cloth); a loader that lacks it draws the cloth without sheen.- The Khronos glTF validator reports zero errors. Warnings are allowed but are bugs to fix.
- One scene. Its top nodes are the
rootjoint and one node per mesh. No node carries a transform except the joints, and mesh nodes are never parented under a joint (glTF ignores a skinned mesh's parent transforms, and engines disagree about it).
Units, axes, root
- Metres, Y up, right-handed, as glTF defines it.
- The character faces +Z, its left hand on +X.
- Root at the feet. The
rootjoint is at the origin, on the floor directly under the pelvis. Its rotation is UAL's: -90° about X, a relic of Blender's Z-up armature, which its children's rest rotations undo (the profile copies UAL's joint frames, so it copies this too). The soles touch y = 0: the lowest point of the body's skin is at y = 0 within a millimetre.
Nodes and meshes
One mesh per part of the character, each its own node, the node and the mesh sharing a name:
| Name | What |
|---|---|
body |
The skin, with covered skin removed (coverBody), not hidden |
eyes |
Both eyeballs |
eyelashes, teeth, tongue, brows, beard |
When the character has them |
hair |
The hair, whichever kind |
garment_<asset> |
Each worn garment, by its asset name (garment_toigo_harem_pants) |
Names are ASCII letters, digits and _. Each mesh has one primitive per
material (usually one), mode 4 (triangles), indexed, with POSITION,
NORMAL, TEXCOORD_0 (when textured), JOINTS_0 and WEIGHTS_0. The
order of the meshes is the order above, garments by asset name.
Skeleton
- One skin, named
skeleton, used by every mesh node, withskeletonset to therootjoint andinverseBindMatricesgiven. - Its
jointsare exactly the 65 below, in this order, named per the file's naming profile (ue, the default, ormixamo). Parents are as listed; a parent always comes before its children. - These are UE mannequin names as Quaternius' Universal Animation Library
(UAL) spells them: tosijs-3d's
HM08_UAL. NoteHead(capital H) and the_leaftips, which carry no skin weight and exist so engines know where a finger or a toe ends. - Joints carry
translationandrotationonly (scale 1). - The Mixamo profile renames joints and nothing else. Mixamo has no
joint above the hips, so
rootkeeps its name there, as a non-humanoid parent. three.js strips the:when binding (mixamorigHips), as it does for Mixamo's own files. Whether Unity's Humanoid needs it at all is spike 1's question.
| # | ue | parent | mixamo |
|---|---|---|---|
| 0 | root |
none | root |
| 1 | pelvis |
root |
mixamorig:Hips |
| 2 | spine_01 |
pelvis |
mixamorig:Spine |
| 3 | spine_02 |
spine_01 |
mixamorig:Spine1 |
| 4 | spine_03 |
spine_02 |
mixamorig:Spine2 |
| 5 | neck_01 |
spine_03 |
mixamorig:Neck |
| 6 | Head |
neck_01 |
mixamorig:Head |
| 7 | clavicle_l |
spine_03 |
mixamorig:LeftShoulder |
| 8 | upperarm_l |
clavicle_l |
mixamorig:LeftArm |
| 9 | lowerarm_l |
upperarm_l |
mixamorig:LeftForeArm |
| 10 | hand_l |
lowerarm_l |
mixamorig:LeftHand |
| 11 | thumb_01_l |
hand_l |
mixamorig:LeftHandThumb1 |
| 12 | thumb_02_l |
thumb_01_l |
mixamorig:LeftHandThumb2 |
| 13 | thumb_03_l |
thumb_02_l |
mixamorig:LeftHandThumb3 |
| 14 | thumb_04_leaf_l |
thumb_03_l |
mixamorig:LeftHandThumb4 |
| 15 | index_01_l |
hand_l |
mixamorig:LeftHandIndex1 |
| 16 | index_02_l |
index_01_l |
mixamorig:LeftHandIndex2 |
| 17 | index_03_l |
index_02_l |
mixamorig:LeftHandIndex3 |
| 18 | index_04_leaf_l |
index_03_l |
mixamorig:LeftHandIndex4 |
| 19 | middle_01_l |
hand_l |
mixamorig:LeftHandMiddle1 |
| 20 | middle_02_l |
middle_01_l |
mixamorig:LeftHandMiddle2 |
| 21 | middle_03_l |
middle_02_l |
mixamorig:LeftHandMiddle3 |
| 22 | middle_04_leaf_l |
middle_03_l |
mixamorig:LeftHandMiddle4 |
| 23 | ring_01_l |
hand_l |
mixamorig:LeftHandRing1 |
| 24 | ring_02_l |
ring_01_l |
mixamorig:LeftHandRing2 |
| 25 | ring_03_l |
ring_02_l |
mixamorig:LeftHandRing3 |
| 26 | ring_04_leaf_l |
ring_03_l |
mixamorig:LeftHandRing4 |
| 27 | pinky_01_l |
hand_l |
mixamorig:LeftHandPinky1 |
| 28 | pinky_02_l |
pinky_01_l |
mixamorig:LeftHandPinky2 |
| 29 | pinky_03_l |
pinky_02_l |
mixamorig:LeftHandPinky3 |
| 30 | pinky_04_leaf_l |
pinky_03_l |
mixamorig:LeftHandPinky4 |
| 31 | thigh_l |
pelvis |
mixamorig:LeftUpLeg |
| 32 | calf_l |
thigh_l |
mixamorig:LeftLeg |
| 33 | foot_l |
calf_l |
mixamorig:LeftFoot |
| 34 | ball_l |
foot_l |
mixamorig:LeftToeBase |
| 35 | ball_leaf_l |
ball_l |
mixamorig:LeftToe_End |
| 36 | clavicle_r |
spine_03 |
mixamorig:RightShoulder |
| 37 | upperarm_r |
clavicle_r |
mixamorig:RightArm |
| 38 | lowerarm_r |
upperarm_r |
mixamorig:RightForeArm |
| 39 | hand_r |
lowerarm_r |
mixamorig:RightHand |
| 40 | thumb_01_r |
hand_r |
mixamorig:RightHandThumb1 |
| 41 | thumb_02_r |
thumb_01_r |
mixamorig:RightHandThumb2 |
| 42 | thumb_03_r |
thumb_02_r |
mixamorig:RightHandThumb3 |
| 43 | thumb_04_leaf_r |
thumb_03_r |
mixamorig:RightHandThumb4 |
| 44 | index_01_r |
hand_r |
mixamorig:RightHandIndex1 |
| 45 | index_02_r |
index_01_r |
mixamorig:RightHandIndex2 |
| 46 | index_03_r |
index_02_r |
mixamorig:RightHandIndex3 |
| 47 | index_04_leaf_r |
index_03_r |
mixamorig:RightHandIndex4 |
| 48 | middle_01_r |
hand_r |
mixamorig:RightHandMiddle1 |
| 49 | middle_02_r |
middle_01_r |
mixamorig:RightHandMiddle2 |
| 50 | middle_03_r |
middle_02_r |
mixamorig:RightHandMiddle3 |
| 51 | middle_04_leaf_r |
middle_03_r |
mixamorig:RightHandMiddle4 |
| 52 | ring_01_r |
hand_r |
mixamorig:RightHandRing1 |
| 53 | ring_02_r |
ring_01_r |
mixamorig:RightHandRing2 |
| 54 | ring_03_r |
ring_02_r |
mixamorig:RightHandRing3 |
| 55 | ring_04_leaf_r |
ring_03_r |
mixamorig:RightHandRing4 |
| 56 | pinky_01_r |
hand_r |
mixamorig:RightHandPinky1 |
| 57 | pinky_02_r |
pinky_01_r |
mixamorig:RightHandPinky2 |
| 58 | pinky_03_r |
pinky_02_r |
mixamorig:RightHandPinky3 |
| 59 | pinky_04_leaf_r |
pinky_03_r |
mixamorig:RightHandPinky4 |
| 60 | thigh_r |
pelvis |
mixamorig:RightUpLeg |
| 61 | calf_r |
thigh_r |
mixamorig:RightLeg |
| 62 | foot_r |
calf_r |
mixamorig:RightFoot |
| 63 | ball_r |
foot_r |
mixamorig:RightToeBase |
| 64 | ball_leaf_r |
ball_r |
mixamorig:RightToe_End |
Skinning
- At most four influences per vertex:
JOINTS_0(unsigned byte or short) andWEIGHTS_0(float), weights renormalised to sum to 1 and unused slots 0. - Garments, hair, brows and the rest are skinned to the same skeleton as the body, so clothing deforms with it.
Rest pose: the T-pose
The bind pose is a T-pose: arms straight out along ±X, palms down, legs straight and slightly apart, as UAL's mannequin stands. Every joint's rest rotation is UAL's own joint frame (the bone along the joint's local +Y, as Blender exports it); only the translations are the character's.
Why, decided for version 1:
- UAL clips then play by name, unretargeted. A clip's rotation keys are
relative to its skeleton's rest frames. With the same frames, they mean the
same thing on our skeleton, so a three.js
AnimationMixeror a GodotAnimationPlayerplays UAL's clips on an export as is. This is the gate's test (below). - VRM requires a T-pose, and Unity's Humanoid and Unreal's IK Retargeter both prefer one: their auto-mapping assumes it.
- UAL is a T-pose already (measured from
UAL1.glb: the hands at the shoulders' height, 0.74 m out). MakeHuman's own rest holds the arms angled down, so the export re-poses MakeHuman's bind to the T-pose; that is linear-blend skinning of the baked mesh, done once at export.
restPose: 'source' (an option, not the default) keeps MakeHuman's rest
instead, with UAL's frames re-aimed along its bones. Clips then need
retargeting; it exists for tools that want the sculpted rest.
Playing UAL clips
A UAL clip's translation keys hold the mannequin's bone lengths, so
played on another body they stretch it. Play its rotation tracks on
every joint and its translation tracks on root and pelvis only
(ideally scaling the pelvis's by the ratio of the hips' heights). That is
what characterClip in dramatis/three does (its translationScale is the
ratio), and the gate's clip check plays the clip through it.
Morph targets: the face units
- The face goes out as morph targets, one per face unit: MakeHuman's face
pose units as the hm08 pack bakes them, named exactly as below (no
prefix; MakeHuman's own spelling,
lowerLipUpandLeftEyeturnRightincluded). - Names are in
mesh.extras.targetNames, one per target, in the order below. That is the convention three.js, Babylon, Blender, Godot, Unity's glTFast and Unreal's Interchange read. - Each mesh carries the units that move it: the body most, the lashes,
brows, beard, teeth, tongue and eyes theirs. One name on several meshes
is one unit: drive them together.
mesh.weightsare all 0. POSITIONdeltas, metres;NORMALdeltas optional. Sparse accessors are allowed (most units move a few hundred vertices).- The units in the file are listed in
asset.extras.dramatis.faceUnits; the harness checks that they are exactly the ones on the meshes. Exporting without face units (faceUnits: false) lists none and writes no targets.
The 59 units, in order:
LeftBrowDown, RightBrowDown, LeftOuterBrowUp, RightOuterBrowUp, LeftInnerBrowUp, RightInnerBrowUp, NoseWrinkler, LeftUpperLidOpen, RightUpperLidOpen, LeftUpperLidClosed, RightUpperLidClosed, LeftLowerLidUp, RightLowerLidUp, LeftEyeDown, RightEyeDown, LeftEyeUp, RightEyeUp, LeftEyeturnRight, RightEyeturnRight, LeftEyeturnLeft, RightEyeturnLeft, LeftCheekUp, RightCheekUp, CheeksPump, CheeksSuck, NasolabialDeepener, ChinLeft, ChinRight, ChinDown, ChinForward, lowerLipUp, lowerLipDown, lowerLipBackward, lowerLipForward, UpperLipUp, UpperLipBackward, UpperLipForward, UpperLipStretched, JawDrop, JawDropStretched, LipsKiss, MouthMoveLeft, MouthMoveRight, MouthLeftPullUp, MouthRightPullUp, MouthLeftPullSide, MouthRightPullSide, MouthLeftPullDown, MouthRightPullDown, MouthLeftPlatysma, MouthRightPlatysma, TongueOut, TongueUshape, TongueUp, TongueDown, TongueLeft, TongueRight, TonguePointUp, TonguePointDown
Materials
- glTF metallic-roughness PBR, nothing else required. Base colour textures are sRGB, PNG or JPEG, embedded.
- The creator's Babylon shading (skin scatter, hair highlights, cloth) does not travel. What it computes is baked into textures: skin albedo with lips and stubble, eyes drawn into an eye texture, recoloured cloth. Losing scatter is the honest cost; the creator keeps it.
- Fabric MAY add
KHR_materials_sheen. Hair cards and lashes usealphaMode: MASK(withalphaCutoff);BLENDonly where a mask cannot work.doubleSidedwhere the asset says so. - A material is named for its mesh (
body,garment_<asset>).
Licences in the file
Every asset in the file is listed, with its source and licence, in
asset.extras.dramatis. asset.copyright repeats it as one line for
tools that show only that: <title>: <licence> per asset, joined with
; . A CC BY asset's credit line (attribution) is copied word for word.
asset.extras.dramatis MUST match this schema (checkExportExtras is the
same schema in code):
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://dramatis.tosijs.net/schema/export-extras-1.json",
"type": "object",
"required": ["format", "version", "generator", "naming", "restPose",
"faceUnits", "character", "licences"],
"properties": {
"format": { "const": "dramatis-export" },
"version": { "const": 1 },
"generator": { "type": "string", "minLength": 1,
"description": "dramatis@<version>" },
"naming": { "enum": ["ue", "mixamo"] },
"restPose": { "enum": ["t-pose", "source"] },
"faceUnits": { "type": "array", "uniqueItems": true,
"items": { "$ref": "#/$defs/faceUnit" } },
"character": { "type": "string" },
"licences": {
"type": "array", "minItems": 1,
"items": { "$ref": "#/$defs/licence" },
"contains": { "properties": { "kind": { "const": "base" } } }
}
},
"$defs": {
"faceUnit": { "type": "string",
"description": "one of the 59 face units above" },
"licence": {
"type": "object",
"required": ["asset", "title", "kind", "licence", "licenceUrl",
"source", "author"],
"properties": {
"asset": { "type": "string", "minLength": 1,
"description": "<source>:<kind>/<name>, e.g. makehuman:base/hm08" },
"title": { "type": "string", "minLength": 1 },
"kind": { "enum": ["base", "eyes", "eyelashes", "teeth", "tongue",
"brows", "beard", "hair", "garment", "texture",
"animation", "skin-tone", "other"] },
"licence": { "type": "string", "minLength": 1,
"description": "SPDX id: CC0-1.0, CC-BY-4.0, ..." },
"licenceUrl": { "type": "string", "minLength": 1 },
"source": { "type": "string", "minLength": 1 },
"author": { "type": "string", "minLength": 1 },
"attribution": { "type": "string", "minLength": 1,
"description": "required for any CC BY licence" },
"modified": { "type": "boolean" }
}
}
}
}
An asset with no entry in the licence manifest is never exported:
buildCharacter throws UnlicensedAssetError instead.
What VRM adds
A VRM export (exportCharacterVrm, .vrm) is this profile, always ue
naming and the T-pose, plus the VRMC_vrm 1.0 extension (in
extensionsUsed):
humanoid.humanBonesmaps VRM's bones to the joints:hipspelvis,spinespine_01,chestspine_02,upperChestspine_03,neckneck_01,headHead; per sideshoulderclavicle,upperArmupperarm,lowerArmlowerarm,handhand,upperLegthigh,lowerLegcalf,footfoot,toesball; fingersthumbMetacarpal,thumbProximal,thumbDistalonthumb_01..03, andProximal,Intermediate,Distalonindex|middle|ring|pinky_01..03.rootand the_leafjoints are not human bones.expressions.presetfrom the face units:blinkLeftLeftUpperLidClosed,blinkRightRightUpperLidClosed,blinkboth, the visemesaa ih ou ee ohand the moodshappy angry sad relaxed surprisedas weighted sets of units (spike 3 settles the weights).lookAtof typeexpression, through the eye units (LeftEyeUp,LeftEyeDown,LeftEyeturnLeft,LeftEyeturnRightand the right's) aslookUp,lookDown,lookLeft,lookRight. UAL's skeleton has no eye joints, so look-at is by expression, not by bone.meta:name,authors,licenseUrl(https://vrm.dev/licenses/1.0/), andotherLicenseUrlandcopyrightInformationcarrying the licence entries above (which stay inasset.extras.dramatistoo).
How it is checked
bun test test/gate1 runs six checks on every GLB in samples/out/: the
validator (zero errors); the skeleton's names, order and parents; the morph
targets' names; the licence block; three.js's GLTFLoader loading it under
Bun; and UAL's Walk_Loop (Quaternius, CC0) binding by name to the
major joints (spine, neck, head, each limb to the hand and foot) and moving
them. With nothing in samples/out/, every check reports NOT RUN.
Each check is shown able to fail on a hand-made bad file
(test/gate1/fixtures.test.ts).
The engine imports are spike 1's (tools/engines/, reports/spike-1.md):
Godot 4.5, Babylon.js 9.33 (headless) and Blender 5.2 have passed on every
sample; Unity and Unreal 5 have not been run. Each engine's guide says what
was checked there.