This ADR defines the standard for adding spring bone physics to Decentraland wearables. Spring
bones are extra bones — not part of the base avatar armature — whose transforms are driven by
a physics simulation rather than animation clips, enabling hair, earrings, capes, belts, and
similar wearable elements to move dynamically in response to avatar locomotion and gravity.
Parameters are stored in the wearable item's metadata at
wearable.data.springBones, keyed by GLB content hash; the .glb file
itself carries only the bone names and hierarchy. A spring root bone is identified by a node
name containing springbone (case-insensitive) combined with an
isRoot: true entry for that node in the metadata. All descendant bones of a root
automatically form its spring chain. The parameter set mirrors the VRM
VRMC_springBone convention. Colliders are out of scope for this version.
Currently all wearable elements that extend beyond the base avatar armature — hair, earrings, ponytails, capes, belts, and similar accessories — are completely static. They do not react to the avatar's movement, animations, or environmental forces such as gravity. This is a significant visual quality gap compared to industry-standard avatar formats such as VRM and MMD, which have supported spring/physics bones for years.
Decentraland avatars use a custom armature in gltf format (see
ADR-57). Each wearable carries only the bones it needs and is fully
autonomous. This architecture is compatible with adding optional spring bone definitions
without affecting wearables that do not use them.
The implementation will use the UniVRM VRMSpringBone component in the Unity-based
Explorer. The parameter set defined here is chosen to be directly compatible with that
component's expectations, avoiding any translation layer between stored data and the
simulation runtime.
This standard affects every system that renders avatars: the Explorer (local and remote players), the Builder wearable editor, the Marketplace avatar preview, and the login/backpack screen. A shared spec is required for all of these to interoperate correctly.
Vocabulary:
springbone (case-insensitive, anywhere in the name) combined with an
isRoot: true entry for that node in the wearable's
data.springBones metadata.
center: An optional reference node name. When specified, inertia is
calculated relative to that node's space rather than world space, preventing excessive sway
during locomotion.
Multiple approaches were considered for where to store spring bone parameters.
extras
Data lives in the root glTF object's extras field under a
DCL_springBones key, with an explicit springs array listing chains
and joints by node index. This mirrors how VRM's extension is structured.
Not chosen because it does not allow creators to set parameters directly in Blender using bone Custom Properties, and shares the issues of any in-GLB storage approach described in Option C.
extras
Physics parameters live directly in each spring root bone's glTF node
extras field. Chains are inferred at load time from the node hierarchy.
Not chosen. Native to standard glTF and allows node-level configuration, but using
extras for structured data is less formal than a vendor extension. Shares the
issues of any in-GLB storage approach described in Option C.
DCL_spring_bone_joint
Physics parameters live in a formal glTF vendor extension (DCL_spring_bone_joint)
within each spring root bone's extensions field. Chains are inferred at load time
from the node hierarchy. The extension is declared in the root
extensionsUsed array.
Not chosen. This was the original choice, reversed during implementation. It offers clear intent and a Blender-native authoring path, but binds parameter tuning to GLB modification, which produces volatile content hashes on every parameter tweak and AB converter overload, because every parameter save re-enters the asset-bundle conversion queue even when the underlying model geometry is unchanged.
These effects are observed in practice and cannot be engineered away without separating parameters from the binary asset, which is what Option D does.
Spring bone parameters live on the wearable item's metadata at
wearable.data.springBones. The .glb carries only the bone
names (with the springbone token) and the hierarchy. Parameters
are stored as JSON, validated by @dcl/schemas'
SpringBonesData schema, and read by the Explorer at avatar load time.
{
"version": 1,
"models": {
"<glbContentHash>": {
"SpringBone_hair_left": {
"stiffness": 2.0,
"gravityPower": 0.8,
"gravityDir": [0, -1, 0],
"drag": 0.3,
"center": "Avatar_Hips",
"isRoot": true
}
}
}
}
This approach provides:
.glb. The model's content
hash and uploaded bytes are stable across tuning sessions.
.glb and therefore do not trigger asset-bundle conversion.
The trade-off is that creators cannot author spring bone parameters in Blender or any external
DCC tool. This is accepted: bone discovery still happens through the GLB (the
springbone naming convention is required upstream), but parameter
tuning is Builder-only.
All spring bone nodes MUST have a name containing the substring
springbone (case-insensitive). The substring can appear at any position in the
name. The naming convention is the only signal that travels in the .glb; physics
parameters travel separately in item metadata.
Examples:
| Node name | Valid? | Meaning |
|---|---|---|
SpringBone_hair_left |
✓ | Left hair chain root |
hair_springbone_l |
✓ | Left hair chain root (variant) |
springbone_earring_r |
✓ | Right earring chain root |
ponytail_SPRINGBONE |
✓ | Ponytail chain root (case-insensitive) |
skirt_1 |
✗ | Not a spring bone (missing "springbone") |
SpringBoneCollider |
✓ |
Valid name, but only acts as a root when its metadata entry has
isRoot: true
|
The springbone substring is the convention by which artists and tooling identify
spring bone nodes in the hierarchy. By itself, the name marks a node as a
candidate spring bone. The Explorer treats a candidate as a spring root only
when the wearable's data.springBones metadata contains an entry for that node
with isRoot: true.
A node is a spring root bone if and only if:
springbone (case-insensitive), ANDdata.springBones.models[<glbContentHash>] contains an
entry for the node's name with isRoot: true.
Both signals are required. A node that satisfies the naming rule but has no matching metadata
entry (or whose entry sets isRoot: false) is treated as a chain member, not a
root.
The spring chain owned by a root consists of the root bone itself and all its glTF node descendants, traversed depth-first.
Each node in a spring chain SHOULD have at most one child that is also part of the chain, forming a linear sequence from root to tip. Branching topologies — where a node has more than one spring bone child — are not enforced against but MAY produce unexpected simulation behavior.
Spring bone parameters live on wearable.data.springBones and follow the JSON
schema defined by SpringBonesData in @dcl/schemas:
{
"version": 1,
"models": {
"<glbContentHash>": {
"SpringBone_hair_left": {
"stiffness": 2.0,
"gravityPower": 0.8,
"gravityDir": [0, -1, 0],
"drag": 0.3,
"center": "Avatar_Hips",
"isRoot": true
}
}
}
}
version MUST be exactly 1.models is keyed by the GLB content hash — the value found in
WearableEntity.content[<representation.mainFile>]. Wearables whose male
and female representations point at the same .glb bytes resolve to one entry;
entries are stable across path renames.
.glb (case-sensitive match against the GLB node name).
version is a top-level field of SpringBonesData and MUST be
1.
The following per-bone parameters apply to each entry in models[<hash>]:
| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
stiffness |
float | 0–4 |
2.0 |
Rigidity; how strongly the bone returns to its rest pose. 0 = fully loose,
follows gravity only. Higher values = firmer, follows the body more closely.
|
gravityPower |
float | 0–2 |
0 |
Magnitude of the gravity force applied to the bone every frame. 0 =
unaffected by gravity.
|
gravityDir |
[number, number, number] |
unit vector | [0, -1, 0] |
Direction of the gravity force in world space. Default simulates natural downward gravity. Can be used to simulate wind or a floating effect. The Explorer MUST normalize the vector if it is not already a unit vector. |
drag |
float | 0–1 |
0.5 |
Deceleration / damping. 0 = bone swings freely for a long time.
1 = bone settles almost instantly.
|
isRoot |
boolean | — | OPTIONAL |
Declares whether this node is the root of a spring chain (true) or a chain
member that overrides inherited parameters (false or absent). Required to
be true for a node to be treated as a spring root (see Spring Root Bone).
|
center |
string (node name) | valid node name | OPTIONAL | Name of a reference bone node. When set, inertia is evaluated relative to that node's space, preventing excessive sway during locomotion. The referenced node MUST exist and MUST NOT be part of any spring chain. |
Parameter ranges (stiffness, gravityPower, drag) are
enforced by the JSON schema in @dcl/schemas and verified at deploy time by the
content validator (see Deploy-time Validation).
gravityDir reference values:
| Value | Effect |
|---|---|
[0, -1, 0] |
Downward (natural gravity, default) |
[0, 1, 0] |
Upward (floating / supernatural effect) |
[1, 0, 0] |
Leftward |
[-1, 0, 0] |
Rightward |
[0, 0, 1] |
Forward |
[0, 0, -1] |
Backward |
Axes are in world space. Diagonal values are valid.
The Explorer MUST reconstruct spring chains at load time using the following procedure:
mainFile.
mainFile in the entity's
content array to obtain the GLB content hash.
wearable.data.springBones?.models[hash]. If
absent, no spring bones apply for this representation.
wearable.data.springBones.version === 1. If
not, log a warning and skip spring bone simulation for this wearable.
.glb. A node is a spring bone
candidate iff its name contains springbone (case-insensitive). For each
candidate, look up its name in the metadata entry. A candidate is a spring root iff
its metadata entry has isRoot: true.
center is specified, resolve the node name. If the
name does not correspond to any node in the file, or if it points to a spring bone node, the
Explorer SHOULD log a warning and MUST fall back to world space.
The Explorer MUST apply spring bone simulation to all spring root bones found in any loaded
wearable. This applies to the local player's avatar and all other players' avatars in the
scene. The Explorer reads spring bone parameters from the wearable's item metadata
(data.springBones); the .glb file is consulted only for bone names
and hierarchy.
For performance reasons, the Explorer MAY disable spring bone simulation for avatars beyond a distance threshold from the local player. The threshold value is an implementation detail.
Spring bone simulation MUST also be active in all avatar renderers outside the main world, including but not limited to the login screen and the backpack/wearables preview.
The Builder MUST detect spring bone candidates in an uploaded .glb by scanning
for nodes whose name contains springbone (case-insensitive). For each detected
bone, the Builder MUST expose its parameters as editable controls and MAY prefill them with
sensible defaults; existing values from the wearable's data.springBones metadata
SHOULD be used to prefill when present.
When a creator saves changes, the Builder MUST write the updated parameter values into
item.data.springBones.models[<hash>], where <hash> is
the content hash of the representation's mainFile. The .glb MUST NOT
be modified by parameter edits.
The Builder MUST drop entries from models whose hash no longer matches any
current representation's mainFile, so stale parameters from a prior model upload
do not persist into a later save.
When a wearable's male and female representations point at the same .glb bytes,
the Builder MUST write a single entry under that shared hash; there is exactly one parameter
set per distinct GLB.
Catalyst's content validator (@dcl/content-validator) enforces the following
rules on data.springBones at deployment time. A wearable that fails any of these
is rejected:
springBones value MUST conform to the SpringBonesData JSON
schema in @dcl/schemas (parameter ranges, required fields,
gravityDir as a 3-tuple of numbers, optional
center/isRoot).
models MUST equal the content hash of some current
representation's mainFile. Filename-keyed entries and stale hashes from prior
uploads are rejected.
models[<hash>] MUST contain the
springbone token (case-insensitive), matching the discovery rule.
Structural and per-parameter range validation is delegated to the schema; the validator only enforces the cross-references that the schema cannot express on its own.
The following are explicitly excluded and MAY be addressed in a future revision:
gravityDir can approximate per-wearable wind, but
there is no scene-level wind force.
The schema is designed to allow future addition of colliders logic and other parameters without breaking existing files.
A complete spring-bone wearable is the combination of two artifacts: the
.glb (carrying bone names and hierarchy) and the wearable item metadata (carrying
parameters).
.glb node hierarchy:
{
"asset": { "version": "2.0" },
"nodes": [
{ "name": "Avatar_Head", "children": [1, 2] },
{ "name": "SpringBone_earring_r", "children": [3] },
{ "name": "SpringBone_hair_left", "children": [4] },
{ "name": "springbone_earring_r_tip" },
{ "name": "SpringBone_hair_left_tip" }
]
}
Wearable item metadata (wearable.data.springBones):
{
"version": 1,
"models": {
"bafkreialsvt77jvpy673cnugp5ggnxfaalfncufweayuk3jbxskh3pelkm": {
"SpringBone_earring_r": {
"stiffness": 0.5,
"gravityPower": 1.0,
"gravityDir": [0, -1, 0],
"drag": 0.6,
"isRoot": true,
"center": "Avatar_Hips"
},
"SpringBone_hair_left": {
"stiffness": 2.0,
"gravityPower": 0.8,
"gravityDir": [0, -1, 0],
"drag": 0.4,
"isRoot": true
}
}
}
}
In this example:
.glb's content hash is bafkrei...pelkm, used as the outer key
of models.
SpringBone_earring_r and SpringBone_hair_left satisfy the
naming rule and have metadata entries with isRoot: true, so they are spring
roots.
SpringBone_earring_r -> springbone_earring_r_tip and
SpringBone_hair_left -> SpringBone_hair_left_tip each form a single chain by
hierarchy.
Avatar_Hips by name as their center._tip nodes are chain tips. They have no metadata entry; the simulation
ignores any parameters that might be present on tips.
.glb is shared across male and female representations,
models still has a single entry — one per distinct GLB hash.
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.