Morph Target Blending in GLSL Vertex Shaders

Morph target blending—often referred to as blend shapes or shape keys—is a vertex deformation technique used extensively in real-time computer graphics to produce expressive character animations. In a GLSL vertex shader, this process is executed by calculating and accumulating geometric delta displacement vectors, which represent the per-vertex spatial difference between a neutral base mesh and one or more target shapes. By multiplying each delta vector by a scalar weight and adding the result to the original vertex attributes, the GPU interpolates between complex geometric states in parallel with minimal computational overhead.

The Concept of Geometric Delta Displacement

In traditional keyframing, storing complete geometric coordinates for every target shape consumes significant bandwidth and prevents multiple targets from cleanly influencing the same mesh simultaneously. Instead, morph target animation relies on geometric deltas.

A displacement delta vector (\(\mathbf{d}_k\)) represents the directional difference between a morph target vertex (\(\mathbf{p}_k\)) and the neutral base vertex (\(\mathbf{p}_0\)):

\[\mathbf{d}_k = \mathbf{p}_k - \mathbf{p}_0\]

Because deltas represent isolated modifications, they can be blended additively. A character can concurrently smile, blink, and raise an eyebrow without each pose overwriting the positions of the others.

The final vertex position (\(\mathbf{p}_{\text{final}}\)) is computed as the base position plus the linear combination of all active displacement vectors weighted by their respective influence values (\(w_k\)):

\[\mathbf{p}_{\text{final}} = \mathbf{p}_0 + \sum_{k=1}^{n} (w_k \cdot \mathbf{d}_k)\]

Calculating Deltas in GLSL Vertex Shaders

Depending on hardware constraints and the number of morph targets, GLSL vertex shaders calculate and apply these deltas using either per-vertex attributes or texture memory lookups.

1. Attribute-Based Morphing

For models with a small number of morph targets (typically limited by the hardware's maximum vertex attribute limit, often 16), target deltas are precomputed on the CPU and passed directly into the vertex shader as standard attributes.

#version 300 es
layout(location = 0) in vec3 a_BasePosition;
layout(location = 1) in vec3 a_BaseNormal;

// Morph target delta attributes (TargetPosition - BasePosition)
layout(location = 2) in vec3 a_TargetDeltaPosition0;
layout(location = 3) in vec3 a_TargetDeltaPosition1;

// Normal deltas
layout(location = 4) in vec3 a_TargetDeltaNormal0;
layout(location = 5) in vec3 a_TargetDeltaNormal1;

uniform float u_Weight0;
uniform float u_Weight1;
uniform mat4 u_ModelViewProjectionMatrix;

out vec3 v_Normal;

void main() {
    // Accumulate position deltas
    vec3 displacedPosition = a_BasePosition;
    displacedPosition += a_TargetDeltaPosition0 * u_Weight0;
    displacedPosition += a_TargetDeltaPosition1 * u_Weight1;

    // Accumulate normal deltas
    vec3 displacedNormal = a_BaseNormal;
    displacedNormal += a_TargetDeltaNormal0 * u_Weight0;
    displacedNormal += a_TargetDeltaNormal1 * u_Weight1;

    v_Normal = normalize(displacedNormal);
    gl_Position = u_ModelViewProjectionMatrix * vec4(displacedPosition, 1.0);
}

2. Texture-Based Morphing (Vertex Texture Fetch)

Complex facial rigs often require dozens or hundreds of morph targets, exceeding the vertex attribute limits of the GPU. Modern engines bypass this limitation by baking geometric deltas into floating-point data textures (sampler2D or sampler2DArray).

In this approach:

  1. The CPU packs precomputed delta vectors (\(\Delta x, \Delta y, \Delta z\)) into RGB texels.
  2. The vertex shader determines its vertex ID using the built-in gl_VertexID variable.
  3. The shader samples the delta texture via texelFetch() for each active morph target.
#version 300 es
layout(location = 0) in vec3 a_BasePosition;
layout(location = 1) in vec3 a_BaseNormal;

uniform sampler2DArray u_MorphDeltaTexture; // Holds delta vectors
uniform float u_MorphWeights[64];           // Dynamic weights
uniform int u_ActiveMorphCount;
uniform mat4 u_ModelViewProjectionMatrix;

void main() {
    vec3 displacedPosition = a_BasePosition;
    ivec2 texCoord = ivec2(gl_VertexID % 2048, gl_VertexID / 2048);

    for (int i = 0; i < u_ActiveMorphCount; ++i) {
        float weight = u_MorphWeights[i];
        if (abs(weight) > 0.0001) {
            // Fetch delta from layer 'i'
            vec3 deltaPosition = texelFetch(u_MorphDeltaTexture, ivec3(texCoord, i), 0).rgb;
            displacedPosition += deltaPosition * weight;
        }
    }

    gl_Position = u_ModelViewProjectionMatrix * vec4(displacedPosition, 1.0);
}

Normal and Tangent Vector Considerations

Geometric displacement also alters surface curvature, meaning surface normals and tangents must be modified to preserve correct lighting calculations.

Unlike positions, normal deltas are differences between unit direction vectors (\(\mathbf{n}_k - \mathbf{n}_0\)). When multiple weighted normal deltas are added to the base normal, the resulting vector loses unit length:

\[\mathbf{n}_{\text{unnormalized}} = \mathbf{n}_0 + \sum_{k=1}^{n} (w_k \cdot \Delta \mathbf{n}_k)\]

To prevent shading artifacts and dark spots caused by non-uniform scaling, the shader must re-normalize the accumulated normal using the normalize() function before passing it to the fragment stage or using it in lighting equations. Tangent vectors follow an identical delta calculation and normalization pipeline.