vidar.games

Dual-quaternion skinning

2026-11-08 · Discuss on X

Same arm, same two bones, same weights.
Left: linear blend skinning. Twist the forearm 180° and the joint pinches to nothing.
Right: dual quaternion skinning. Volume holds at 99.9%.

The problem

Most real-time skinning is linear blend skinning (LBS). Each vertex near a joint is moved by a weighted average of its bones' matrices. Averaging rotation matrices does not give a rotation. At a 180° twist the two bones point the vertices in opposite directions, the average lands on the bone axis, and the joint collapses into the "candy wrapper". Bends lose volume on the inside of the elbow for the same reason.

How it works

Dual quaternion skinning (DQS) stores each bone as a rotation quaternion plus a translation part. The per-vertex blend averages those instead of matrices:

  1. For every bone, skinned DQ = pose DQ × bind-inverse DQ.
  2. Flip any bone DQ whose rotation points the "long way" from the strongest bone (shortest path).
  3. Weighted sum of rotation and translation parts, then divide both by the length of the rotation part.
  4. Rotate the vertex, then add the translation pulled back out of the dual part.

The result is always a rigid rotation plus a translation, so the twist spreads along the blend zone instead of shrinking it.

What I hit

My first build gave the two methods different bone spaces, so they disagreed even at rest. Binding the built-in skinned mesh with an identity bind matrix and feeding both methods the same bone matrices fixed it.

The source takes Unity's bone weights, which come sorted largest first. That matters: step 2 uses the first bone as the reference, so I sort mine the same way.

The source also has an experimental bulge compensation. In my tests its result depended on which axis I declared as the bone direction (mesh volume 103.7% one way, 96.2% the other), so it is off in the video, as it is by default in the source.

My build

three.js (r187). A procedural cylinder arm: radius 0.3, length 2.6, 120 x 64 segments, 15,488 triangles. Weights ramp over ±0.38 around the elbow with a smoothstep. The left half is three.js's own skinning. The right half is my GLSL port of the blend from DQ-skinning-for-Unity, in the vertex shader.

The readouts come from a CPU copy of each blend: area of the joint ring and closed-mesh volume, both against rest.

Twist 180°: cross-section 0% vs 100%, volume 84.9% vs 99.9%.
Bend 129°: cross-section 43% vs 100%.
Twist 90° + bend 100°: cross-section 32% vs 93%, volume 92.5% vs 100.0%.

Trade-offs

When to use it

Forearms, wrists, necks, spines, tails and anything that twists along its own length. Keep LBS where joints only hinge a little, or where you already use helper twist bones.

New breakdowns go out on X first: @vidar_0208. If this one helped, you can buy me a coffee.

Source and credits

Technique source: DQ-skinning-for-Unity by KosRud, https://github.com/KosRud/DQ-skinning-for-Unity, BSD-3-Clause license, commit 9d096e2db57677f0f67a823432f8b735ad622404. The dual quaternion math (quaternion multiply, shortest path, matrix to dual quaternion, skinned DQ = pose x bind inverse) and the per-vertex blend from its compute shaders were ported to GLSL and JavaScript. No files were copied unchanged.

Render framework: three.js by mrdoob and contributors, https://github.com/mrdoob/three.js, MIT license, branch dev at commit fa01dd6ad164c48554f1bb9456f6500db052ec4f. Used for the scene and for the linear blend skinning shown on the left (SkinnedMesh, unchanged). It is not the source of the dual quaternion technique.

My own code: the procedural arm mesh and weights, the pose timeline, the GLSL vertex shader port, the CPU copies of both blends for the cross-section and volume readouts, the stage and the labels. No external models or assets. No IA model.