dh.object
Extension: .dh-obj
Type ID: dh.object
An object is a reusable building block: an accessory, outfit, prop, or any composable piece. An object file is a saved instance: the model it places, the components its root carries, overrides of the model’s parts, and the records it adds of its own. It is the same shape a map’s instance record has, so an object, a variant of one and a placement of one are read by one rule.
dh.avatar extends this type.
Properties
Section titled “Properties”| Property | Type | Required | Description |
|---|---|---|---|
$type | "dh.object" | no | Type identifier |
name | string | no | Display name |
description | string | no | Description |
model | string | no | The model whose parts are this object’s children: a .glb or .fbx, relative, root-relative (/) or a barcode. FBX files are converted to GLB through Blender when the pallet compiles |
inherits | string | no | Barcode of the object this file varies. Everything else in the file is then an override over it |
position, rotation, scale | [x, y, z] | no | The root’s own transform. scale also takes one number for a uniform scale |
static | bool | no | Whether the object is baked into GI and lightmaps and stands still. Unset inherits, then true |
components | array | no | The root’s components |
children | array | no | Overrides of the model’s parts, only the departures |
objects | array | no | Records the object adds: an instance of another object, avatar or model, filed under one of its parts |
The type is inferred from the file extension, so
$typeis not needed in source files. The compiler adds it automatically during builds, along with aresolvedmember holding the whole object as the compiler resolved it (inheritance and added records included, every reference a barcode). A source never statesresolved.
Example
Section titled “Example”{ "name": "Cat Ears", "model": "models/ears.glb", "children": [ { "path": "Ears", "components": [ { "$type": "dh.renderer", "materials": { "Fur": "materials/fur.dh-mat" } } ] } ]}Collision
Section titled “Collision”A .dh-obj collides only through the components it states. A dh.collider goes on its root or on any part’s override, and a root dh.box, dh.collider and dh.physicsProp make it a physics body. An object with none of them collides only through the map that places it. The spring colliders below are a separate thing: they push spring chains, never players.
Records and overrides
Section titled “Records and overrides”An object is also the thing every placement of it is made of. A record is a source and one layer of overrides, nothing else, so there is no stack of layers to compose in your head:
record := { source, components, children }The same record appears in three places, written three ways, and it means the same thing in all of them:
| Where | How it is written | What it makes |
|---|---|---|
| At an asset’s root: a variant | inherits plus this file’s own members | A new object that departs from a base object |
| Inside another object: an added record | an entry in objects | An instance inside the parent, keeping its own barcode |
| In a map: an instance | the map’s instance record, whose source names the object | A placement in the world that still knows what it came from |
An override states departures only. Every field it does not name inherits from the source, so changing the source moves every placement except where the placement said otherwise. A record whose source is itself a record rebases: resolution walks to the root source and lays each layer over the one beneath, outermost last. One rule merges every layer, the same one a map’s part overrides use:
- a map merges key by key: a renderer’s
materialsnaming one slot keeps every other slot the source binds; - a scalar replaces, and a list replaces whole: a reaction’s
actionsrestated is the whole new list; - unset or
nullinherits; "removed": trueremoves: on a part’s override it drops the part, on an added record the record, on a component the component it names. Nothing else ever removes anything.
A component is matched to the one it overrides by its type, and by its key where the type has one: a dh.springCollider by its id, a dh.reaction by its on and tier. An owner holds one of each type per key; a second is a build error.
How a part is named
Section titled “How a part is named”An override names its part by path, the part’s hierarchical path in the model as it was exported:
"Body": a root-level node named Body"Armature": a root-level armature"Armature/Hips/Spine": a nested path through the hierarchy
A path is never moved by another override: a part reparented under another part is still named by where the model put it. The compiler also records a stable id for every part an object brings in, in a sidecar beside the object’s source, so a part renamed in the model keeps its identity. A map instance’s override may name a part by that id beside its path; the id must be one the object recorded.
A part inside a record the object adds is overridden through that record’s own children, never from the outer list.
Replacing an inherited model
Section titled “Replacing an inherited model”A variant that writes model replaces the model it inherits, in place. Every inherited override (parents, skeletons, renderers, springs, armature links) then lands on the new model’s parts unchanged:
{ "inherits": "io.mltn.avatars.mltn-taidum:mltn-taidum.dh-avatar", "model": "models/taidum-lf-f.fbx"}The new model should carry every part the inherited overrides name; extra nodes, bones and blendshapes are fine.
Sidecar Files
Section titled “Sidecar Files”A model file (.fbx or .glb) can carry a list of edits to its scene graph — dropping, renaming,
moving and folding nodes — without hand-editing the model itself. Name the sidecar by appending
.dh-model to the model’s own filename:
Directorymodels/
- robot.fbx
- robot.fbx.dh-model
The sidecar is a JSON file carrying an ops list. Every op names its kind and the target node
it acts on:
{ "ops": [ { "kind": "drop", "target": "CollisionProxy" }, { "kind": "rename", "target": "mesh_001", "name": "Hull" }, { "kind": "transform", "target": "Antenna", "position": [0, 1.2, 0], "rotation": [0, 45, 0], "scale": 2 }, { "kind": "reparent", "target": "Antenna", "parent": "Head" }, { "kind": "merge", "target": "TreeA", "with": ["TreeB", "TreeC"] } ]}A sidecar that only drops nodes may use the short form instead, which is exactly one drop op per
entry:
{ "drop": ["LOD1", "LOD2", "CollisionProxy"]}The ops
Section titled “The ops”| Kind | Fields | Effect |
|---|---|---|
drop | — | Severs the target and its whole subtree from the scene graph. |
rename | name | Gives the target a new node name; its subtree stays attached. |
transform | position, rotation, scale | Overrides the named components of the target’s local transform, in the model’s own space. rotation is [x, y, z] degrees in DigitalHeaven’s euler order; scale takes one number or three. Components left out keep their authored value. |
reparent | parent, keepWorldTransform | Moves the target under another node. keepWorldTransform defaults to true, recomputing the local transform so the geometry does not move — the same choice a part override’s parent makes. |
merge | with | Folds the listed sibling nodes into the target, which survives. Their primitives move onto the target’s mesh transformed into its space, their children reparent under it, and the nodes themselves leave the graph. |
The list is a set keyed by kind and target, never an append log: one entry decides what
happens to one node under one op, and a second entry for the same pair is a compile error naming
both entries rather than a silent last-writer-wins. Different kinds may of course share a target —
the transform and reparent above both act on Antenna.
A target is an exact, case-sensitive node name, or a slash-joined path from a scene root when the
name alone is ambiguous. Matching is by name only, so re-exporting the FBX from Unity (adding or
moving unrelated geometry) never requires touching the sidecar — only a renamed or deleted target
node does. A target that matches nothing is a warning naming the sidecar entry and the closest
existing path; a bare name matching more than one node is an error naming every match. A merge
whose nodes do not all draw with the same material is an error. Any error abandons the whole
sidecar, so a half-edited model never reaches the pallet.
The ops apply to the packaged GLB, at the same seat for a .glb source and for an FBX converted by
Blender, before any other compile-time pass touches the model — so collision cook, lightmap UV2
generation, and the part table never see a dropped node. This makes .dh-model the model
equivalent of .dh-tex-settings: the source file survives
untouched, and the edit lives entirely in the sidecar.
Sidecar files are consumed during compilation and don’t appear in the compiled pallet. They are part of the packaged model’s bytes, so editing one changes the pallet’s content hash and recompiles anything that depends on it.
Vertex colors
Section titled “Vertex colors”A model’s COLOR_0 and COLOR_1 attributes are read, for an object and for a map’s geometry alike:
| Attribute | What it does | Without it |
|---|---|---|
COLOR_0 | Multiplies the surface’s base color, RGB and, on an alphaMode: "blend" surface, alpha — the glTF meaning | White |
COLOR_1 | The layer weights a material’s detail.vertexWeight reads one channel of | Zero |
Both are linear RGBA in [0, 1], in any encoding glTF allows (VEC3 or VEC4, float, unorm8 or
unorm16). A set whose every value is its default is treated as absent, so Blender’s habit of
exporting a white COLOR_0 on every mesh costs nothing, and a model with no colors at all uploads
exactly the bytes it did before colors were read. A mesh that has one carries a 12-byte run per
vertex on the GPU (see GPU vertex streams).
COLOR_0’s alpha never cuts an alphaMode: "mask" surface: the depth passes that cut the same
silhouette read no vertex color, and a cutout that disagreed with its own shadow would be worse than
one that ignores the paint. Vertex color does not reach the lightmap baker either, so a bake bounces
the material’s color unpainted.
Lots of content already carries these attributes from Blender: measured on 2026-10-06, every color on
mltn-city, night-city, the taidum and Mayu avatars and the round glasses is a white COLOR_0
(dropped) plus a COLOR_1 holding Blender’s second color attribute. Those renders are unchanged byte
for byte.
A map keeps COLOR_1 only when a material in its materials table reads it through
detail.vertexWeight. The client resolves the
table once, with the map’s sibling pallets, before the first read of the geometry, so a map whose
materials read none never allocates the set: mltn-city (3.1 million vertices) decodes 48 MiB less on
the CPU and uploads no color run, where keeping it would have cost 36 MiB of GPU memory. The decision is
taken at load, so a live edit that adds a vertexWeight takes effect on the next load of the map.
Objects and avatars keep their COLOR_1, since their materials are bound per part after the model is
read.
The core pallet’s maps/vertex-color-eval photographs both: a wall painted through COLOR_0 and a
ground slab whose COLOR_1.r paints a second layer on.
children
Section titled “children”children overrides the model’s parts, one entry per part, stating only what departs. It is the shape a map’s part override has, minus the fields only a map’s world takes (connections and the bake fields).
| Field | Type | Required | Description |
|---|---|---|---|
path | string | yes | The part’s path in the model (how a part is named) |
id | guid | no | The part’s recorded id, where an editor wrote one |
name | string | no | A display name shown in place of the model’s node name |
parent | string | no | The path of another part to file this one under, "" for the object’s root. The part keeps where it stands in the world; only what it follows changes |
first | bool | no | With parent: the part becomes its new parent’s first child rather than its last |
position | [x, y, z] | no | The part’s local position |
rotation | [x, y, z] | no | The part’s local rotation, euler degrees |
scale | number or [x, y, z] | no | The part’s local scale; one number is uniform |
enabled | bool | no | Whether the part is in the world |
static | bool | no | Whether the part is baked into GI and lightmaps |
removed | bool | no | true drops the part and its subtree |
components | array | no | The part’s components |
Only the transform fields a part states are changed. A part is reparented before its transform is set, so a position on a moved part is local to its new parent.
"children": [ { "path": "Armature/Body", "parent": "", "first": true }, { "path": "Armature/MuzzleBone", "removed": true }, { "path": "Armature/Hips/TailCaddy/TailRoot", "components": [ { "$type": "dh.springBone", "pull": 0.55, "damping": 0.4, "gravity": 0.1 } ] }]An override naming a part the model does not have costs one warning when the object loads, naming the override, and the object still loads without it.
objects
Section titled “objects”objects lists the records an object adds of its own: an instance of another dh.object or avatar, or of a model, filed under one of its parts. The added record keeps its source’s barcode. It is never flattened into the parent’s parts, so it still knows what it came from and follows its source when the source changes.
| Field | Type | Required | Description |
|---|---|---|---|
$type | "instance" | no | The record type; the only one an object adds |
id | string | yes | The record’s minted id: camelCase, at most 64 characters, unique among the object’s records. A variant names it to override or remove the record |
name | string | no | The record’s display name |
source | string | yes | What it places: a .dh-obj, a .dh-avatar, or a .glb / .fbx, relative, root-relative or a barcode |
parent | string | no | The path of the part it is filed under; unset is the object’s root |
position, rotation, scale | [x, y, z] | no | Its local transform |
components | array | no | Components on its root, over its source’s |
children | array | no | Overrides of its source’s parts, in the shape above |
removed | bool | no | true removes an inherited record |
"objects": [ { "$type": "instance", "id": "glasses", "name": "Glasses", "source": "io.mltn.avatars.mltn-shared:accessories/round-glasses/round-glasses.dh-obj", "position": [0, 0.15, -0.03], "components": [ { "$type": "dh.armatureLink", "target": "", "bone": "Head", "align": false } ] }]A record’s children reach only parts its own source has, so a placement cannot reach into another placement and override or remove a piece of it.
Components
Section titled “Components”A component is one { "$type": "dh.<noun>", …fields } entry in a components list: on the object’s root, on a part’s override, or on an added record’s root. Which owner takes which component:
| Component | Root | Part | What it is |
|---|---|---|---|
dh.renderer | ✅ | ✅ | Materials by slot name, blendshape weights, the reflection probe |
dh.skeleton | ✅ | ✅ | The rig an armature is described by |
dh.eyes | ✅ | ❌ | How the eyes look around and blink |
dh.springBone | ❌ | ✅ | Spring physics on the chain the part roots |
dh.springCollider | ✅ | ❌ | A sphere or capsule spring chains are pushed out of |
dh.armatureLink | ✅ | ✅ | Links the owner to an armature |
dh.reaction | ✅ | ❌ | What the avatar does on a game event |
dh.box | ✅ | ❌ | Box geometry and its material |
dh.collider | ✅ | ✅ | What collides |
dh.physicsProp | ✅ | ✅ | A rigid body |
dh.mover | ✅ | ❌ | The object itself slides, turns or spins: a door, a lift, a fan |
dh.button | ✅ | ❌ | The object is something a player presses |
dh.pressurePlate | ✅ | ❌ | The object is a plate that senses what stands on it |
dh.voxelBlock | ✅ | ❌ | The object is drawn as the voxel cells it stands for |
dh.bake | ❌ | ✅ | Lightmap scale, bake zone and baked emission; an object does not bake yet |
dh.lod | — | — | Reserved; refused |
A component on an owner that does not take it is a build error naming what that owner takes. A component’s fields are closed: a field it does not define is a build error listing the ones it does. A $type no component has is a build error too.
Forward compatibility. A pallet compiled by a newer build may carry a component an older engine, mod or editor does not know. The object still loads without it, and the loader says so once per type:
'dh.example' is not a component this build knows; skipped.dh.renderer
Section titled “dh.renderer”What a drawn part wears: a material per slot of its mesh, a weight per blendshape, and the reflection probe it samples.
| Field | Type | Description |
|---|---|---|
materials | { slot: path } | A dh.material per slot, keyed by the slot’s material name in the model. #index keys a slot whose name is missing or shared by two slots of one mesh |
shapes | { name: float } | A weight per blendshape, keyed by its name, typically 0 to 1 |
reflectionProbe | string | The name of the map object carrying the reflection probe this part samples. Unset, the nearest |
{ "path": "Armature/Body", "components": [ { "$type": "dh.renderer", "materials": { "Fur": "materials/main-fur.dh-mat", "Eye Background": "materials/eye.dh-mat" }, "shapes": { "Cat_Eyes": 0.9, "Eyelashes": 1 } } ]}Both maps merge key by key, so a variant binding one slot keeps every other slot its source binds. A slot is keyed by name rather than position on purpose: an index slides onto the wrong slot the moment the model is re-exported with its materials in another order. The engine’s level editor addresses a renderer the same way, by the part and the slot’s name, so a rebind made there is written back here verbatim.
On the root, a dh.renderer’s reflectionProbe binds every part that names none of its own, which is how a placed Source prop reflects the env_cubemap nearest its lighting origin. One draw samples its part’s own probe, else its record’s root’s, else the probe its glTF primitive names, else the nearest.
The compiler refuses a material that does not resolve, and warns on a blendshape the mesh does not have.
A map writes a dh.renderer of its own into every drawn part of its part table, marked "generated": true. That mark is the compiler’s alone: a source stating it is a build error.
dh.skeleton
Section titled “dh.skeleton”The dh.rig an armature is described by. On a part it describes that armature; on the root, the whole model. A skeleton is humanoid when its rig maps humanoid bones, which is what IK, the first-person neck and retargeting read.
| Field | Type | Description |
|---|---|---|
rig | string | The rig definition, relative or a barcode |
mesh | string | The path of the skinned mesh visemes and eyelid blendshapes are read from, such as Body |
viewPosition | [x, y, z] | Where the view sits, in the armature’s frame, over the rig’s own viewPosition |
"components": [ { "$type": "dh.skeleton", "rig": "rigs/taidum.dh-rig" } ]dh.eyes
Section titled “dh.eyes”How the avatar’s eyes behave: whether they look around and blink, the eye-look personality, and the blink timing. The rig says what the eyes are (eye bones, eyeRotationLimits, eyelids); this component says what the avatar does with them, so a variant can change it without copying the rig. Shyness and excitement are per-avatar taste, not a property of the model.
| Field | Type | Description |
|---|---|---|
look | object | Eye look: on/off and personality (see below) |
blink | object | Procedural blinks: on/off and timing (see below) |
Precedence. Every field is optional and merges on its own down the inherits chain: the last layer to set a field wins, and a field no layer sets takes the default below. "removed": true on a variant’s dh.eyes drops every inherited one, returning the eyes to the defaults. The engine, the Unity runtime and import, the VRChat export and the compiler all read the resolved component through one Core helper, EyeBehavior.Of.
DigitalHeaven drives the eyes itself, so an avatar looks around in a game with no look-at of its own. With nothing to look at, the eyes wander in small saccades around a point ahead and drift back to center. When a game passes nearby points of interest (faces, hands, objects, mirrors), the eyes pick among them by weighted chance, never always the most interesting one, and do not return to a point they just left. A game that has its own look target keeps it: DigitalHeaven still adds the saccades, the lid follow and the blinks.
| Property | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Whether the eyes move at all. false keeps them at rest and drops the lid follow, and a game’s forced look target is ignored too, since an avatar that turns its look off is usually one whose eyes are closed. |
confidence | 0-1 | 0.5 | 0 is shy, 1 is confident: how often the eyes go to faces, how long they stay, and whether they break away when looked back at. |
activity | 0-1 | 0.5 | 0 is calm, 1 is excited: how long idle points hold and how often the eyes flick within a target. |
faceGazeShare | 0-1 | from confidence | Share of attention faces draw (0.25 shy to 0.75 confident). |
faceDwellMean | seconds | from confidence | Mean time on a face (1 s shy to 3.5 s confident). |
breakOnMutualGaze | 0-1 | from confidence | Chance of looking away when a face looks back (0.7 shy to 0.1 confident). |
aversionDownBias | 0-1 | from confidence | Chance a look-away goes down rather than sideways (0.7 shy to 0.15 confident). |
idleHoldMean | seconds | from activity | Mean time an idle point holds (2.5 s calm to 1.2 s excited). |
microSaccadeInterval | seconds | from activity | Mean time between small saccades within one target (1.2 s calm to 0.4 s excited). |
The derived fields follow the two axes unless an avatar sets them. Everything else (target distances, saccade speed, lid follow gains) is a client setting.
| Property | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Whether procedural blinks run. false never writes the blink shapes, so a weight a dh.renderer sets on them stands. |
rate | per minute | 17 | Spontaneous blinks at rest. Speaking raises it by half; concentrating lowers it. |
depthMin | 0-1 | 0.85 | The shallowest blink. Each blink closes to a depth between this and 1. |
minInterval | seconds | 1 | The shortest time between two blinks. A rare deliberate double blink is the only exception. |
closeTime | seconds | 0.08 | How long the lids take to close. |
holdTime | seconds | 0.02 | How long they stay closed. |
openTime | seconds | 0.16 | How long they take to open. |
Large gaze shifts sometimes bring a blink with them, and a glance onto a face holds a blink off until it lands. The shapes a blink closes are the rig’s eyelids.blink.
A base avatar setting its personality:
"components": [ { "$type": "dh.eyes", "look": { "confidence": 0.4, "activity": 0.9 }, "blink": { "rate": 12 } }]A variant whose eyes are closed for good (its lid shapes held at 1 by its body’s dh.renderer), keeping the base’s personality for anything inheriting it:
"components": [ { "$type": "dh.eyes", "look": { "enabled": false }, "blink": { "enabled": false } }]Per host, with both off: the engine resolves a face with nothing to drive, so the eye bones stay at rest and no eyelid weight is written; DigitalHeavenEyeLooks.Attach attaches nothing; the VRChat export turns enableEyeLook off and exports no eyelid shapes. With only blink off, the eyes still look around and the lid follow still runs; with only look off, the eyes stay at rest and blinks still run.
The compiler refuses an axis or share outside 0 to 1 and a negative time or rate, and warns when an avatar states dh.eyes but its rig has no eyes at all, or sets look on a rig that cannot turn its eyes.
dh.reaction
Section titled “dh.reaction”What the avatar does when a game reports an event about it. A reaction holds the actions run at the event: the two sound actions below. The events, their tiers and the default each leaves on the face are on the avatar game events page.
| Field | Type | Required | Description |
|---|---|---|---|
on | event token | yes | died, revived, dying, jumped, footstep, landedHard or damaged |
tier | tier token | no | Narrows the reaction to one tier: footstep has walk and run, damaged has small and big. Unset, the reaction answers every tier the tiered ones do not |
actions | list | yes | What runs at the event, in order |
Precedence. A reaction is keyed by its on and tier. A variant stating a reaction with the same key replaces its actions whole, so a derived avatar restates the list it changes; one stating "removed": true drops the inherited reaction. Every host reads the resolved reactions through one Core helper, AvatarReactions.Of.
{ "$type": "dh.reaction", "on": "damaged", "tier": "big", "actions": [ { "$type": "dh.speakSound", "clips": ["sounds/growl-1.mp3", "sounds/growl-2.mp3"] } ]}A variant replacing the base’s dying cry with a clip from a dependency pallet, and silencing its jumps:
"components": [ { "$type": "dh.reaction", "on": "dying", "actions": [ { "$type": "dh.speakSound", "clips": ["dev.pitr:sounds/hiss.ogg"] } ] }, { "$type": "dh.reaction", "on": "jumped", "removed": true }]The compiler refuses an unknown event or tier, an action that is not one of the two sounds, and a clip that is not a stored audio file (.wav, .ogg, .mp3, .flac, .aif) here or in a declared dependency, and warns on a sound with no clips. Clip paths follow the one rule every reference does: from the definition’s folder, / for the pallet root, pallet:path across pallets.
dh.playSound
Section titled “dh.playSound”An action: a sound from the avatar’s body, never the mouth. A bell on a collar, a prop, a footstep.
| Property | Type | Required | Description |
|---|---|---|---|
$type | "dh.playSound" | yes | |
clips | list of paths | yes | Stored audio files; one is picked at random each time |
bone | string | no | The bone the sound comes from (a humanoid role such as Head, or a custom bone in the rig); unset, the avatar’s center |
dh.speakSound
Section titled “dh.speakSound”An action: a sound from the avatar’s mouth, positioned at the head. Pain, effort, the dying cry.
| Property | Type | Required | Description |
|---|---|---|---|
$type | "dh.speakSound" | yes | |
clips | list of paths | yes | Stored audio files; one is picked at random each time |
mouth | bool | no | Whether the clip’s amplitude drives the mouth while it plays, on a host that drives one. Default true |
Which platforms play each action, and from where, is tabled per action on the avatar game events page.
dh.armatureLink
Section titled “dh.armatureLink”Links its owner (an added record, or a part) to an armature of the object it belongs to. Two modes, depending on whether the owner has a rig:
Bone-to-bone matching: when the owner has a dh.skeleton, bones are matched by standard name. Use this to merge clothing armatures with body armatures.
Single-bone attachment: when the owner has no rig, it attaches to a specific bone on the target. Requires bone.
| Field | Type | Description |
|---|---|---|
target | string | The path of the armature to link to (it must have a dh.skeleton); "" is the object’s root |
bone | string | The bone a rigless owner hangs on: a standard bone name or one of the target rig’s custom bones |
align | bool | Whether a rigless owner is reset onto its bone (default true) or keeps its local transform |
keepWorldTransform | bool | Keep the owner exactly where it stands, recomputing its local transform so only what it follows changes (default false). Single-bone mode; takes precedence over align |
graft | { chainRoot: bone } | Bone-to-bone mode. Names the base bone an unmatched owner bone chain hangs from, by the chain’s root bone name |
Bone-to-bone, on an added outfit:
{ "$type": "instance", "id": "outfit", "source": "clothes/outfit.dh-obj", "components": [ { "$type": "dh.armatureLink", "target": "" } ]}Single-bone attachment, on a part of the model:
{ "path": "Backpack", "components": [ { "$type": "dh.armatureLink", "target": "Armature", "bone": "Chest", "align": false } ]}When align is true (default), the owner’s local transform is reset to match the target bone. Set it to false to preserve the owner’s original transform.
Set keepWorldTransform to true to link without moving the asset at all: the local transform is recomputed so the world placement is identical before and after, and the accessory stays where the pallet put it instead of having to be measured against a bone by hand. It takes precedence over align.
{ "$type": "dh.armatureLink", "target": "", "bone": "Head", "keepWorldTransform": true }Grafting accessory bone chains
Section titled “Grafting accessory bone chains”In bone-to-bone mode only bones the rig NAMES have somewhere to land. An accessory’s own extra chain — a bell on a collar, ear piercings, a charm — deforms a skin but matches nothing on the base armature, and left where it is it does not follow the body. Every such chain is grafted: reparented onto its nearest matched ancestor in the owner’s hierarchy and given the local transform that reproduces the offset its author modeled, so it lands exactly where it was drawn. Each graft is one warning naming the chain root and the bone it now hangs from.
A chain that has no matched ancestor — a bone sitting beside Hips rather than under a mapped bone — would otherwise graft onto the armature root and follow the body instead of the part it belongs to. graft names the base bone for it:
{ "$type": "dh.armatureLink", "target": "", "graft": { "CollarRoot": "Neck" } }In the Unity runtimes, a chain already under a matched bone simply rides along with it, and a named graft hangs the chain from the owner’s own copy of that bone, measured in the authored pose, before the bones are linked. The Unity runtimes also rename every linked bone that is kept (Neck becomes Neck__dhlink, see below), every kept bone hung straight from a target bone, and every unmatched owner bone that shares a target bone’s name. A humanoid Animator finds its skeleton by name, and Unity writes an Animator’s recorded values back when it is disabled: on the avatar’s first deactivation, the target’s Animator wrote the body’s local transforms onto the same-named copies, which put each linked bone one bone off its target. The humanoid Animator that the owner’s dh.skeleton gave it is removed before linking, since the owner no longer owns those bones.
The key is the chain’s ROOT bone name as the owner’s model spells it; the value is a bone the rig maps (a standard humanoid name, or one of its customBones). Naming a chain root the rig already matches, or a base bone the rig does not have, is a build error naming both.
Merging the copied bones
Section titled “Merging the copied bones”An accessory that ships a full copy of the body’s armature (Taidum’s clothes model carries all of it) would otherwise add that whole copy to every avatar, player rig and NPC wearing it. Once its bones are linked, every copy is merged into the body bone it matches: the accessory’s skins are bound to the body bone directly, and the copy is removed. A bone the rig maps matches by its standard name; any other bone matches the child of its parent’s match that carries its name, so the copy’s unmapped finger joints, tail bones and the like fold in too. Merging is the same in the engine and the Unity runtimes, and planned by one shared helper (BoneMerge in Core).
A skin keeps deforming exactly as before. A copy that sits exactly on its body bone (every bone the rig maps, and a name match modeled where the body’s is) keeps its bind pose. A name match modeled elsewhere has its bind pose rebound by its offset from the body bone, on the skin’s own copy of the mesh, and merges only when nothing under it stays, since a child that stays is hung from the body bone in its place.
A copy stays when something besides a skin holds it: a spring chain’s bone, a renderer, a script or a rig, an instance placed on it, or (in Unity) the skin’s root bone when it does not sit exactly on its match. A bone that matches nothing stays too and hangs from the body bone its parent merged into. The accessory’s own rig then answers with the body bone for a merged bone, so whatever asks it for one finds the bone its skin follows.
An object puts on its own skeletons, renderers and spring chains before the records it adds are built, so a humanoid Avatar is built over the body’s bones before an accessory’s same-named copies join the tree; links wait for the whole tree.
dh.springBone
Section titled “dh.springBone”Spring bone physics on the bone chain its part roots, for secondary motion (hair, tails, ears, skirts). It sits on the override of the chain’s root bone, one per chain root; every bone below joins the chain but those ignore names, and is simulated with Verlet integration.
| Field | Type | Default | Description |
|---|---|---|---|
ignore | string[] | Child bone names to exclude (along with their descendants) | |
mode | "jiggle" | "physBones" | "jiggle" | Which solver model the numbers below are authored for |
pull | float | 0.3 | Pull toward the animated pose (0 = floppy, 1 = locked) |
damping | float | 0.7 | jiggle only. Oscillation decay (0 = bouncy, 1 = no motion) |
spring | float | 0.3 | physBones only. Bounciness — the complement of damping on the same axis |
gravity | float | 0.0 | Downward pull strength in world space |
gravityFalloff | float | 0.0 | How much of gravity the chain root is spared (0 = even, 1 = none at the root, full at the tip) |
immobile | float | 0.0 | How much of the reference frame’s own motion the chain does not feel (0 = swings from every movement, 1 = rides along rigidly) |
immobileFrame | "none" | "parent" | "sceneRoot" | "node" | "none" | Whose motion immobile cancels |
limitType | "none" | "angle" | "hinge" | "polar" | "none" | The shape of the cone each bone may swing inside |
maxAngleX | float | 90 | Half-angle in degrees across the limit frame’s X axis. Read by angle, hinge and polar |
maxAngleZ | float | 90 | Half-angle in degrees across the limit frame’s Z axis. Read by polar alone |
limitRotation | [x, y, z] | [0, 0, 0] | Euler degrees tilting the limit frame off the bone’s rest direction. Tilts the cone without moving the bone |
endpointPosition | [x, y, z] | [0, 0, 0] | A virtual segment hung past every tip, in the tip’s own local space. Zero is none |
maxStretch | float | 0.0 | How far past its rest length a segment may be pulled, as a fraction (0.25 = a quarter longer) |
maxSquish | float | 0.0 | How far under its rest length a segment may be pushed, as a fraction |
radius | float | 0.0 | The radius every bone of the chain collides with, in object-space meters. 0 is a point |
colliders | string[] | none | The ids of the spring colliders this chain collides with, applied in this order. At most 32 |
allowCollision | "none" | "self" | "others" | "both" | "none" | Whose hands push the chain, on top of colliders: the wearer’s, everyone else’s, or both |
allowGrabbing | the same tokens | "none" | Whose hands may grab a bone of the chain |
allowPosing | the same tokens | "none" | Whose hands may leave a grabbed chain posed. Only a hand allowGrabbing admits can pose |
grabMovement | float | 0.5 | 0: a held bone is carried to the hand by the chain’s own pull and damping. 1: it goes there at once |
snapToHand | bool | false | Whether a held bone moves onto the palm instead of keeping its offset from it |
Every field is optional and an unset one inherits, then takes its default, so a variant tuning one number of an inherited chain states only that number.
The two modes are two names for one solver: jiggle authors what a frame loses, physBones
authors what it keeps. Authoring a field from the other mode is a compile error, not a silent
default.
pull means a different fraction in each mode. physBones reads it exactly as VRChat’s own
PhysBones do: the fraction of the gap to the animated pose one 1/60 s step closes, with no scaling.
jiggle folds in a tenfold gain on top of that same 60 Hz frame, matching the Unity runtime’s
stiffness * 10 * dt convention — so the same authored number pulls much harder in physBones mode
than in jiggle mode. This is why authoring the wrong mode’s fields is refused outright rather than
silently reinterpreted: the numbers are not interchangeable.
Every number is a sixty-hertz number, exactly as it always was: damping: 0.7 means “seven
tenths of the velocity is gone after one frame at sixty”, and a physBones pull: 0.2 closes a fifth
of the gap on one VRChat-style 1/60 s step. The runtime converts them once into rates per second and
simulates on a fixed tick, so the same motion comes out at any frame rate.
A limit is enforced after the integrator has placed the particles, as a rotation of each segment
about its parent: angle is a circular cone of maxAngleX about the rest direction, hinge pins the
bone into one plane and swings it inside maxAngleX there, and polar is the elliptical cone
maxAngleX across X and maxAngleZ across Z. none — the default — costs nothing.
A tip has nothing to aim at, so without endpointPosition the last bone of a chain never swings on
its own; it only carries its parent’s rotation. An authored endpoint hangs one virtual segment past
every tip, simulated like any other bone and drawn as nothing, which gives every real bone a child.
A chain needs its anchor and one thing to aim it at, so a lone bone swings only with an endpoint; with
one it swings the way a VRChat PhysBone on a single bone does, which is how a breast or a dangling charm
is sprung.
maxStretch and maxSquish are both 0 by default, and a chain then holds its rest lengths
exactly. Authoring either lets a segment lengthen or shorten within that fraction under motion before
it is clamped, which is what makes a ponytail read as elastic rather than as a rod.
The springs are an overlay on the animated pose, in the engine and in Unity alike: each frame the chain reads whatever animation and IK left on the bones and swings from there, and nothing it computes is ever kept as a bone’s rest pose. An animated tail keeps its animation and gains a wobble.
{ "path": "Body/Armature/Tail1", "components": [ { "$type": "dh.springBone", "pull": 0.2, "gravity": 0.3, "gravityFalloff": 0.5 } ]}The same chain in the other model:
{ "path": "Body/Armature/Tail1", "components": [ { "$type": "dh.springBone", "mode": "physBones", "pull": 0.2, "spring": 0.3, "immobile": 0.6, "immobileFrame": "parent" } ]}Several chains on one avatar are one override each:
"children": [ { "path": "Body/Armature/Tail1", "components": [ { "$type": "dh.springBone", "pull": 0.2, "gravity": 0.3 } ] }, { "path": "Body/Armature/EarL1", "components": [ { "$type": "dh.springBone" } ] }, { "path": "Body/Armature/EarR1", "components": [ { "$type": "dh.springBone" } ] }]Excluding specific children:
{ "$type": "dh.springBone", "ignore": ["Tail_IK_Target", "Tail_Collider"] }A chain collides only with the colliders it lists. Leaving colliders out means none, so a chain written before colliders keeps exactly the motion it had. Listing more than 32 ids is a compile error rather than a truncation (32 is VRChat’s own per-PhysBone cap, which VRChat truncates past silently). The list is resolved when the avatar loads, against whoever wears the chain, so an accessory’s ["chest"] hits the chest of the avatar it is worn on.
{ "path": "Armature/Hips/Tail Root", "components": [ { "$type": "dh.springBone", "pull": 0.2, "radius": 0.02, "colliders": ["hips", "spine", "leftUpperLeg", "rightUpperLeg", "leftLowerLeg", "rightLowerLeg"] } ]}The engine pushes a chain out of its colliders inside every fixed tick, after the pull and before the swing limit. Every slot but the anchor is a sphere of the chain’s radius; each push is followed by restoring the segment’s length, and the pair repeats until a pass moves nothing, at most client.anim.springCollisionPasses times (default 4). Colliders ride their bones, and every radius scales with the entity: the avatar’s fit times the player’s Scale. A collider that crosses a slot within one tick, such as a kicking leg past a thin tail, leaves the slot on the side it entered rather than carrying it through; client.anim.springSweep (default on) turns that off. The avatar service, the offscreen captures and the mobile shells run the same solver.
When the avatar loads, each listed id that names no collider is a warning naming the chain, and so is each slot that already rests inside a collider it lists: “slot 2 rests 6.0 mm inside collider ‘head’”. The first tick would push it out, so this is a content fault to fix in the numbers, not something the solver hides.
The Unity runtime collides the same way, through the same Core push and the same tick schedule, with the collision passes and the sweep as settings on the spring component (see Unity: spring bones). VRChat translates the colliders a chain lists into PhysBone colliders. See design-notes/spring-bone-colliders.md.
Touch and grab
Section titled “Touch and grab”Hands are opt-in per chain. allowCollision, allowGrabbing and allowPosing each name whose hands may push, grab or pose the chain: self is the wearer’s own, others everyone else’s, both either. Unset is none, so no chain written before hands changes, and an ear or whisker costs nothing until it is marked. A host’s own preference can only narrow what a pallet allowed, never widen it. An unknown token is a compile error naming the field; posing a hand that may not grab is a warning, since that part of it does nothing.
Hands push with the chain’s radius, the same way a listed collider does. A grab takes the bone nearest the palm, and how far a held tail reaches before it pulls on its wearer is set by maxStretch: a rigid chain (0) still bends at its joints, so its reach is its own length.
{ "$type": "dh.springBone", "radius": 0.03, "maxStretch": 0.1, "colliders": ["spine", "leftUpperLeg", "rightUpperLeg", "leftLowerLeg", "rightLowerLeg"], "allowCollision": "both", "allowGrabbing": "both", "allowPosing": "both"}VRChat translates these to a PhysBone’s own toggles and filters (see VRCPhysBone). Which hosts read them is in BONELAB: spring bones.
Game mods for platforms with native bone physics (DynamicBone, VRCPhysBone, etc.) can read the spring bone data and translate it to native components, disabling the built-in solver.
dh.springCollider
Section titled “dh.springCollider”A sphere or capsule hung on a bone, which every chain that lists its id is pushed out of. It sits on the object’s root and is keyed by its own id: it adds no bone to the skeleton, and two colliders can share a bone.
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | The collider’s camelCase id, which chains list and overrides name |
bone | string | one of | The humanoid role it hangs on (Chest), resolved through the avatar’s rig |
node | string | one of | A bone the rig does not map, by its path |
shape | "sphere" | "capsule" | yes, or inherited | plane, insideSphere and insideCapsule are reserved names and refused |
offset | [x, y, z] | no | The sphere’s center, or the capsule’s first end |
tail | [x, y, z] | capsule | The capsule’s second end |
radius | float | yes, or inherited | Meters |
offset and tail are object-space meters at the bind pose, relative to the bone’s rest position: “2 cm up and 1 cm forward, in T-pose”, never along a bone’s own axes, so a rig’s bone roll cannot change what they mean. Every size scales with the avatar.
{ "$type": "dh.springCollider", "id": "chest", "bone": "Chest", "shape": "capsule", "offset": [0, 0.02, 0.01], "tail": [0, 0.17, 0.02], "radius": 0.11 }Every field inherits. Every humanoid avatar already carries fitted colliders, which are the lowest layer of its colliders, and a component with the same id overrides one field by field, in inherits order. A plush variant with a fatter chest writes only what changes:
{ "$type": "dh.springCollider", "id": "chest", "radius": 0.14 }bone and node are one anchor: a collider that sets either replaces both, so moving a collider to another bone is an override, not a remove and an add. "removed": true drops a collider an inherited layer authored; a fitted collider is not removed that way, and collides with nothing until a chain lists it.
The compiler refuses a missing or malformed id, both bone and node, a bone that is not a humanoid role, a reserved or unknown shape, a negative radius and a malformed point. Against the avatar’s whole inherits chain it also refuses a role the rig does not map, a mapped bone the model does not have, a collider with no anchor or no shape, and a capsule with no tail. A node path is checked when the avatar loads.
An object’s body
Section titled “An object’s body”An object becomes a physics prop the way a map’s own crate does: a root dh.box (its size and material), a dh.collider and a dh.physicsProp (its mass and which machine simulates it). The core pallet’s crate is exactly that:
{ "name": "Physics Cube", "components": [ { "$type": "dh.box", "size": [0.5, 0.5, 0.5], "material": "/materials/crate-orange.dh-mat" }, { "$type": "dh.collider" }, { "$type": "dh.physicsProp", "mass": 30, "sim": "server" } ]}Every field inherits, so a placement that wants a lighter blue crate states only the material of its dh.box and the mass of its dh.physicsProp. An object owns no map and therefore no material slot table, so a box’s material names an asset; the map’s slot for it is resolved where the instance is placed. A placement that should stand still states "removed": true on the dh.physicsProp it inherits.
A door, a button or a plate as an object
Section titled “A door, a button or a plate as an object”An object becomes a door, a button or a plate by carrying the component on its root: dh.mover, dh.button or dh.pressurePlate. A map that places it as an instance gets a logic entity, one node for each placed copy, so a thousand copies of one door object are a thousand doors and pressing one moves only that copy. The object’s pose is its closed pose, and a mover’s startOpen spawns a copy open.
{ "name": "Door", "components": [ { "$type": "dh.mover", "motion": "rotate", "axis": [0, 1, 0], "angle": 90, "duration": 0.01 } ] }
{ "name": "Interactive Door", "inherits": "/objects/base/door.dh-obj", "components": [ { "$type": "dh.button" }, { "$type": "dh.collider", "shapes": [ { "type": "box", "halfExtents": [0.5, 1, 0.1] } ] } ] }The body is the same one the map’s own logic takes: the root’s dh.collider, which must hold exactly one box shape. A mover with no body at all is allowed where the placement gives it something to carry: another record whose parent is the placed copy’s id rides it. A button or a plate needs its body, and a mover on a part of an object is refused.
The object cannot yet wire its own button to its own mover; the placement does, in the instance’s connections, by the instance’s own name.
dh.voxelBlock
Section titled “dh.voxelBlock”The object is drawn as the voxel cells it stands for. A voxel volume’s placement of an object (a Minecraft door, trapdoor, plate, button or lever) is that object filed under the volume, and this component says which of the volume’s placements it is. Its picture is those cells as they stand now, meshed alone through the volume’s own block models, atlas and materials, at the object’s transform, so the mover beside it on the same root turns them. It draws a door or trapdoor shut (its open switched off), since the mover supplies the turn, and a lever, button or plate as its cell stands, thrown or pressed; and nothing beside the cells hides a face of them, so a slab swung out shows the side its wall had hidden.
| Field | Type | Meaning |
|---|---|---|
placement | string | The volume placement the object stands for, grid:x,y,z (its grid and its keying cell, a door’s lower half), in the volume the object is filed under. Empty on a pallet object |
{ "name": "Door", "components": [ { "$type": "dh.mover", "motion": "rotate", "axis": [0, 1, 0], "angle": 90, "duration": 0.01 }, { "$type": "dh.voxelBlock" }, { "$type": "dh.collider", "shapes": [ { "type": "box", "center": [0.40625, 1, 0], "halfExtents": [0.5, 1, 0.09375] } ] } ] }
// Generated at load, never saved: filed under the volume, posed at the placement, named by it.{ "$type": "instance", "name": "world/0:50,14,27", "parent": "<the volume's id>", "source": "com.mojang.minecraft:objects/oak_door.dh-obj", "components": [ { "$type": "dh.voxelBlock", "placement": "0:50,14,27" }, { "$type": "dh.mover", "angle": -90 } ], "connections": [ { "output": "onPressed", "actions": [ { "call": { "target": "world/0:50,14,27", "input": "mover.toggle" } } ] } ] }A pallet object carries it empty, and an object no volume placed draws nothing through it. It replicates under its own wire id with nothing live, as the picture section of its record: the record’s archetype is VoxelBlock, and a mover beside it writes its pose onto the record’s transform exactly as it does for an instance standing as its model.
dh.lod
Section titled “dh.lod”Reserved and deferred. Level-of-detail data is not designed yet, and an object carrying dh.lod is a compile error naming the deferral. The type parses so you get that sentence rather than an unknown-component error.