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:
- The CPU packs precomputed delta vectors (\(\Delta x, \Delta y, \Delta z\)) into RGB texels.
- The vertex shader determines its vertex ID using the built-in
gl_VertexIDvariable. - 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.