Dual Quaternion Skinning in GLSL Vertex Shaders

Dual quaternion skinning (DQS) is an advanced skeletal deformation technique that eliminates the unsightly volume loss and "candy-wrapper" artifacts inherent to traditional Linear Blend Skinning (LBS). By encoding rigid-body transformations as unit dual quaternions—composed of a real quaternion for rotation and a dual quaternion for translation—the GLSL vertex shader can compute non-linear spatial blending directly on the GPU. This process involves fetching per-bone dual quaternions, performing an antipodality-corrected weighted sum, normalizing the result, and evaluating the final vertex position and normal.

Dual Quaternion Representation

A rigid transformation consists of a unit quaternion for rotation, \(q_r\), and a translation vector \(\vec{t}\). In dual quaternion form, this is defined as:

\[q = q_r + \epsilon q_d\]

Where \(\epsilon\) is the dual unit (\(\epsilon^2 = 0\)), and the dual part \(q_d\) is derived from the translation and rotation:

\[q_d = \frac{1}{2} t q_r\]

Here, \(t\) is the pure quaternion \((t_x, t_y, t_z, 0)\).

In GLSL, a dual quaternion is efficiently represented as two vec4 structures or a single mat2x4:

  • q_rot (vec4): The orientation quaternion \((x, y, z, w)\).
  • q_trans (vec4): The dual quaternion carrying translation data.

Shader Inputs and Layout

The vertex shader receives standard per-vertex attributes along with bone indices and weights, while the bone hierarchy transformations are supplied via a uniform buffer object (UBO) or a texture buffer.

layout(location = 0) in vec3 in_position;
layout(location = 1) in vec3 in_normal;
layout(location = 2) in ivec4 in_bone_ids;
layout(location = 3) in vec4 in_weights;

layout(std140, binding = 0) uniform BoneBlock {
    // Each bone consists of a real quaternion (rotation) 
    // and a dual quaternion (translation)
    mat2x4 bones[MAX_BONES];
};

The Blending Phase: Dual Quaternion Linear Blending (DLB)

Because antipodal quaternions (\(q\) and \(-q\)) represent the exact same spatial orientation, standard linear blending can inadvertently interpolate along the longest path if signs are mixed. This causes catastrophic collapse of the mesh.

To solve this, the shader establishes the first influencing bone as a reference frame. If the dot product of any subsequent bone's real part with the reference real part is negative, that bone's dual quaternion must be negated before accumulating.

The accumulation is calculated as:

mat2x4 blend_dq = mat2x4(vec4(0.0), vec4(0.0));
vec4 ref_rot = bones[in_bone_ids[0]][0];

for (int i = 0; i < 4; ++i) {
    int bone_id = in_bone_ids[i];
    float weight = in_weights[i];

    vec4 b_rot = bones[bone_id][0];
    vec4 b_trans = bones[bone_id][1];

    // Check antipodality against the reference bone
    if (dot(b_rot, ref_rot) < 0.0) {
        weight = -weight;
    }

    blend_dq[0] += b_rot * weight;
    blend_dq[1] += b_trans * weight;
}

Normalization

Linear combination destroys the unit length property. The blended dual quaternion must be normalized using the magnitude of the real component:

float inv_len = 1.0 / length(blend_dq[0]);
blend_dq[0] *= inv_len;
blend_dq[1] *= inv_len;

Transforming Position and Normals

Once the normalized dual quaternion \((q_r, q_d)\) is obtained, the transformed vertex position and normal are derived using vector algebra rather than converting back to a full \(4 \times 4\) transformation matrix.

Position Transformation

The spatial transformation combines pure rotation followed by translation. Using quaternion vector rotation formulas, rotating vector \(v\) by quaternion \(q_r\) is:

\[v' = v + 2.0 \times (q_{r.xyz} \times (q_{r.xyz} \times v + q_{r.w} v))\]

The translation vector \(\vec{t}\) is extracted from \(q_r\) and \(q_d\):

\[\vec{t} = 2.0 \times (q_{r.w} q_{d.xyz} - q_{d.w} q_{r.xyz} + q_{r.xyz} \times q_{d.xyz})\]

The final skinned position is simply \(v' + \vec{t}\).

Normal Transformation

Because rigid transformations preserve angles and unit lengths, normals only require the rotational component \(q_r\), bypassing the translation step:

\[n' = n + 2.0 \times (q_{r.xyz} \times (q_{r.xyz} \times n + q_{r.w} n))\]

Complete GLSL Implementation

#version 450 core

layout(location = 0) in vec3 in_position;
layout(location = 1) in vec3 in_normal;
layout(location = 2) in ivec4 in_bone_ids;
layout(location = 3) in vec4 in_weights;

layout(location = 0) out vec3 out_normal;

const int MAX_BONES = 128;

layout(std140, binding = 0) uniform BoneBlock {
    mat2x4 bones[MAX_BONES];
};

uniform mat4 u_view_projection;

vec3 rotate_vector(vec4 q, vec3 v) {
    return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v);
}

void main() {
    // 1. Dual Quaternion Linear Blending (DLB)
    vec4 ref_rot = bones[in_bone_ids[0]][0];
    mat2x4 dq = mat2x4(vec4(0.0), vec4(0.0));

    for (int i = 0; i < 4; ++i) {
        float w = in_weights[i];
        vec4 r = bones[in_bone_ids[i]][0];
        vec4 d = bones[in_bone_ids[i]][1];

        if (dot(r, ref_rot) < 0.0) {
            w = -w;
        }

        dq[0] += r * w;
        dq[1] += d * w;
    }

    // 2. Normalize
    float inv_mag = 1.0 / length(dq[0]);
    vec4 r = dq[0] * inv_mag;
    vec4 d = dq[1] * inv_mag;

    // 3. Extract translation
    vec3 t = 2.0 * (r.w * d.xyz - d.w * r.xyz + cross(r.xyz, d.xyz));

    // 4. Apply transformation to position and normal
    vec3 skinned_position = rotate_vector(r, in_position) + t;
    vec3 skinned_normal = rotate_vector(r, in_normal);

    out_normal = skinned_normal;
    gl_Position = u_view_projection * vec4(skinned_position, 1.0);
}

Performance and Constraints

Dual quaternion skinning requires 8 floats per bone instead of the 12 to 16 floats required by transformation matrices, reducing memory bandwidth between the host and the GPU. The algorithmic cost inside the shader is roughly comparable to matrix skinning due to replacing matrix-vector multiplications with vector dot and cross products.

The primary limitation of dual quaternion skinning is that it natively only handles rigid body transformations (rotation and translation). Uniform or non-uniform bone scaling cannot be directly represented in a standard dual quaternion and must either be pre-computed, decomposed, or handled using extended dual quaternion skinning variants.