Skip to content

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.

PropertyTypeRequiredDescription
$type"dh.object"noType identifier
namestringnoDisplay name
descriptionstringnoDescription
modelstringnoThe 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
inheritsstringnoBarcode of the object this file varies. Everything else in the file is then an override over it
position, rotation, scale[x, y, z]noThe root’s own transform. scale also takes one number for a uniform scale
staticboolnoWhether the object is baked into GI and lightmaps and stands still. Unset inherits, then true
componentsarraynoThe root’s components
childrenarraynoOverrides of the model’s parts, only the departures
objectsarraynoRecords 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 $type is not needed in source files. The compiler adds it automatically during builds, along with a resolved member holding the whole object as the compiler resolved it (inheritance and added records included, every reference a barcode). A source never states resolved.

ears.dh-obj
{
"name": "Cat Ears",
"model": "models/ears.glb",
"children": [
{
"path": "Ears",
"components": [
{ "$type": "dh.renderer", "materials": { "Fur": "materials/fur.dh-mat" } }
]
}
]
}

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.

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:

WhereHow it is writtenWhat it makes
At an asset’s root: a variantinherits plus this file’s own membersA new object that departs from a base object
Inside another object: an added recordan entry in objectsAn instance inside the parent, keeping its own barcode
In a map: an instancethe map’s instance record, whose source names the objectA 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 materials naming one slot keeps every other slot the source binds;
  • a scalar replaces, and a list replaces whole: a reaction’s actions restated is the whole new list;
  • unset or null inherits;
  • "removed": true removes: 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.

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.

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:

mltn-taidum-female.dh-avatar
{
"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.


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:

robot.fbx.dh-model
{
"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:

robot.fbx.dh-model
{
"drop": ["LOD1", "LOD2", "CollisionProxy"]
}
KindFieldsEffect
drop—Severs the target and its whole subtree from the scene graph.
renamenameGives the target a new node name; its subtree stays attached.
transformposition, rotation, scaleOverrides 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.
reparentparent, keepWorldTransformMoves 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.
mergewithFolds 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.


A model’s COLOR_0 and COLOR_1 attributes are read, for an object and for a map’s geometry alike:

AttributeWhat it doesWithout it
COLOR_0Multiplies the surface’s base color, RGB and, on an alphaMode: "blend" surface, alpha — the glTF meaningWhite
COLOR_1The layer weights a material’s detail.vertexWeight reads one channel ofZero

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 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).

FieldTypeRequiredDescription
pathstringyesThe part’s path in the model (how a part is named)
idguidnoThe part’s recorded id, where an editor wrote one
namestringnoA display name shown in place of the model’s node name
parentstringnoThe 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
firstboolnoWith parent: the part becomes its new parent’s first child rather than its last
position[x, y, z]noThe part’s local position
rotation[x, y, z]noThe part’s local rotation, euler degrees
scalenumber or [x, y, z]noThe part’s local scale; one number is uniform
enabledboolnoWhether the part is in the world
staticboolnoWhether the part is baked into GI and lightmaps
removedboolnotrue drops the part and its subtree
componentsarraynoThe 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.

taidum.dh-avatar
"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 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.

FieldTypeRequiredDescription
$type"instance"noThe record type; the only one an object adds
idstringyesThe 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
namestringnoThe record’s display name
sourcestringyesWhat it places: a .dh-obj, a .dh-avatar, or a .glb / .fbx, relative, root-relative or a barcode
parentstringnoThe path of the part it is filed under; unset is the object’s root
position, rotation, scale[x, y, z]noIts local transform
componentsarraynoComponents on its root, over its source’s
childrenarraynoOverrides of its source’s parts, in the shape above
removedboolnotrue 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.


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:

ComponentRootPartWhat 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.

What a drawn part wears: a material per slot of its mesh, a weight per blendshape, and the reflection probe it samples.

FieldTypeDescription
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
reflectionProbestringThe 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.

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.

FieldTypeDescription
rigstringThe rig definition, relative or a barcode
meshstringThe 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" } ]

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.

FieldTypeDescription
lookobjectEye look: on/off and personality (see below)
blinkobjectProcedural 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.

PropertyTypeDefaultDescription
enabledbooltrueWhether 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.
confidence0-10.50 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.
activity0-10.50 is calm, 1 is excited: how long idle points hold and how often the eyes flick within a target.
faceGazeShare0-1from confidenceShare of attention faces draw (0.25 shy to 0.75 confident).
faceDwellMeansecondsfrom confidenceMean time on a face (1 s shy to 3.5 s confident).
breakOnMutualGaze0-1from confidenceChance of looking away when a face looks back (0.7 shy to 0.1 confident).
aversionDownBias0-1from confidenceChance a look-away goes down rather than sideways (0.7 shy to 0.15 confident).
idleHoldMeansecondsfrom activityMean time an idle point holds (2.5 s calm to 1.2 s excited).
microSaccadeIntervalsecondsfrom activityMean 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.

PropertyTypeDefaultDescription
enabledbooltrueWhether procedural blinks run. false never writes the blink shapes, so a weight a dh.renderer sets on them stands.
rateper minute17Spontaneous blinks at rest. Speaking raises it by half; concentrating lowers it.
depthMin0-10.85The shallowest blink. Each blink closes to a depth between this and 1.
minIntervalseconds1The shortest time between two blinks. A rare deliberate double blink is the only exception.
closeTimeseconds0.08How long the lids take to close.
holdTimeseconds0.02How long they stay closed.
openTimeseconds0.16How 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:

mayu-tora.dh-avatar
"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:

pitr-mayu.dh-avatar
"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.

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.

FieldTypeRequiredDescription
onevent tokenyesdied, revived, dying, jumped, footstep, landedHard or damaged
tiertier tokennoNarrows 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
actionslistyesWhat 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.

An action: a sound from the avatar’s body, never the mouth. A bell on a collar, a prop, a footstep.

PropertyTypeRequiredDescription
$type"dh.playSound"yes
clipslist of pathsyesStored audio files; one is picked at random each time
bonestringnoThe bone the sound comes from (a humanoid role such as Head, or a custom bone in the rig); unset, the avatar’s center

An action: a sound from the avatar’s mouth, positioned at the head. Pain, effort, the dying cry.

PropertyTypeRequiredDescription
$type"dh.speakSound"yes
clipslist of pathsyesStored audio files; one is picked at random each time
mouthboolnoWhether 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.

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.

FieldTypeDescription
targetstringThe path of the armature to link to (it must have a dh.skeleton); "" is the object’s root
bonestringThe bone a rigless owner hangs on: a standard bone name or one of the target rig’s custom bones
alignboolWhether a rigless owner is reset onto its bone (default true) or keeps its local transform
keepWorldTransformboolKeep 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 }

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.

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.

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.

FieldTypeDefaultDescription
ignorestring[]Child bone names to exclude (along with their descendants)
mode"jiggle" | "physBones""jiggle"Which solver model the numbers below are authored for
pullfloat0.3Pull toward the animated pose (0 = floppy, 1 = locked)
dampingfloat0.7jiggle only. Oscillation decay (0 = bouncy, 1 = no motion)
springfloat0.3physBones only. Bounciness — the complement of damping on the same axis
gravityfloat0.0Downward pull strength in world space
gravityFallofffloat0.0How much of gravity the chain root is spared (0 = even, 1 = none at the root, full at the tip)
immobilefloat0.0How 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
maxAngleXfloat90Half-angle in degrees across the limit frame’s X axis. Read by angle, hinge and polar
maxAngleZfloat90Half-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
maxStretchfloat0.0How far past its rest length a segment may be pulled, as a fraction (0.25 = a quarter longer)
maxSquishfloat0.0How far under its rest length a segment may be pushed, as a fraction
radiusfloat0.0The radius every bone of the chain collides with, in object-space meters. 0 is a point
collidersstring[]noneThe 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
allowGrabbingthe same tokens"none"Whose hands may grab a bone of the chain
allowPosingthe same tokens"none"Whose hands may leave a grabbed chain posed. Only a hand allowGrabbing admits can pose
grabMovementfloat0.50: a held bone is carried to the hand by the chain’s own pull and damping. 1: it goes there at once
snapToHandboolfalseWhether 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.

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.

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.

FieldTypeRequiredDescription
idstringyesThe collider’s camelCase id, which chains list and overrides name
bonestringone ofThe humanoid role it hangs on (Chest), resolved through the avatar’s rig
nodestringone ofA bone the rig does not map, by its path
shape"sphere" | "capsule"yes, or inheritedplane, insideSphere and insideCapsule are reserved names and refused
offset[x, y, z]noThe sphere’s center, or the capsule’s first end
tail[x, y, z]capsuleThe capsule’s second end
radiusfloatyes, or inheritedMeters

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 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:

core:objects/physicsCube.dh-obj
{
"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.

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.

a swinging door, and the same door with a button on it
{ "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.

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.

FieldTypeMeaning
placementstringThe 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
a pallet's door, and the record a volume makes of it
{ "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.

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.