Animation Tech Intro Part 3: Blending

In the previous post, I described how animation sampling works. We can switch between animation clips. Now, what’s next? We need blending! Animation blending is essential for creating smooth and realistic animations in computer graphics. The process involves interpolating between animation clips to generate intermediate poses. In this blog post, we’ll dive into the details of transform interpolation, how ease-in/ease-out blends can improve the quality of our animations, and what type of blends we can use.

Switching between idle and run animations

Transform interpolation

A transform has three components: position (3D vector), orientation (quaternion), and scale (either uniform or 3D vector).

For position interpolation, we use linear interpolation, commonly known as LERP (Linear Interpolation). It’s a straightforward method that calculates the intermediate positions by blending the coordinates of two vectors.

As for orientation, we could use linear interpolation when we know the difference between two quaternions is small (for example, frame-to-frame interpolation in animation sampling). However, in clip-to-clip blending, we need spherical interpolation (SLERP for short). To build intuition around this, let’s imagine a line and an arc:

Linear Interpolation (left), Spherical Linear Interpolation (right)

As you can see, with LERP, the intermediate point might not preserve the distance from the origin when the target points are far apart. While SLERP does keep it. Only unit quaternions describe rotation. This means we would need to normalise quaternion after every interpolation. Combined with linearity, it would cause slight discrepancies at the start and end of the interpolation. This is fine if the rotation difference is tiny, but with clip-to-clip blending, that’s rarely the case.

Both LERP and SLERP functions can be found in any math library that deals with 3D vectors and quaternions.

Scale interpolation is not commonly used for game animation, but it’s worth mentioning briefly. The appropriate method for scale interpolation is exponential interpolation. It ensures that the rate of change in scale remains consistent throughout the interpolation.

Ease-in / Ease-out

Now that we have the necessary tools to interpolate transforms, we need to decide how to apply the alpha for blend operation. We can either use a linear blend or an ease-in/ease-out blend. In theory, you can imagine any blending curve, but these two are the most common. Linear blending is suitable for robotic motion, whereas ease-in/ease-out blending provides more organic and natural-looking motion, as it simulates the way living beings accelerate and decelerate in real life.

Linear (left), Ease-in / Ease-out (right)
Linear (top), Ease-in / Ease-out (bottom)

Several mathematical functions can be employed to achieve the desired effect. These include sine, cosine, quadratic, and cubic functions, each offering unique characteristics. However, the function I find the simplest and most effective is a uniform S-curve function. In the attached code snippet, you can see how to implement one.

static inline float fm_curve_uniform_s(float alpha) { float sqt = alpha * alpha; return sqt / (2.0f * (sqt - alpha) + 1.0f); }

With these tools we can blend transforms. But what about entire poses?

Full-body blend

We’ll begin by discussing the simplest form of blending: a full-body blend. This method provides a base pose for the entire character. Transforms from two input poses are blended one-to-one with a single alpha value.

Blending between idle and run animations
void fa_pose_blend(fa_pose_t* out, const fa_pose_t* b, const fa_pose_t* a, float alpha) { FUR_ASSERT(out->numXforms == a->numXforms && a->numXforms == b->numXforms); FUR_ASSERT(out->weightsXforms || out->numXforms == 0); const uint32_t numXforms = out->numXforms; const fm_xform* a_xforms = a->xforms; const fm_xform* b_xforms = b->xforms; fm_xform* out_xforms = out->xforms; for(uint32_t i=0; i<numXforms; ++i) { fm_xform_slerp(a_xforms, b_xforms, alpha, out_xforms); a_xforms++; b_xforms++; out_xforms++; } }

However, there might be situations where we need to animate specific parts of the body, such as the upper body for certain actions. In such cases, a more targeted approach is required, which is where masked blending comes into play.

Masked blend

Occasionally referred to as the override blend (since it overrides a portion of the pose), the concept behind this technique is relatively straightforward: imagine assigning a weight to each bone in the character’s skeleton. These individual weights constitute the mask, allowing for more precise and targeted blending in the animation process.

Masked blend of sword holding animation on top of idle and run animations
void fa_pose_blend_masked(fa_pose_t* out, const fa_pose_t* b, const fa_pose_t* a, float alpha, const uint8_t* mask) { FUR_ASSERT(out->numXforms == a->numXforms && a->numXforms == b->numXforms); FUR_ASSERT(out->weightsXforms || out->numXforms == 0); const uint32_t numXforms = out->numXforms; const fm_xform* a_xforms = a->xforms; const fm_xform* b_xforms = b->xforms; fm_xform* out_xforms = out->xforms; for(uint32_t i=0; i<numXforms; ++i) { const float maskedAlpha = mask[i] / 255.0f; fm_xform_slerp(a_xforms, b_xforms, maskedAlpha * alpha, out_xforms); a_xforms++; b_xforms++; out_xforms++; } }

Additive blend

First, we need to have the additive pose. In some game engines, you might find additive animation as an import option. However, there’s an easy way we can make any animation into an additive one on the fly. For that, we need to modify the animation sampling function. We take the difference between the first and current frame of the animation for each transform. This way, we have an ‘offset’ from the first frame pose, that we can apply on any other pose. Add this code at the end of the sampling, and you will get additive animation.

Pure additive animation applied on A-Pose
if(asAdditive) { fm_xform firstKey; fa_decompress_rotation_key(&curve->rotKeys[0], &firstKey.rot); fa_decompress_position_key(&curve->posKeys[0], &firstKey.pos); fm_quat_conj(&firstKey.rot); fm_quat_mul(&firstKey.rot, &xform->rot, &xform->rot); fm_vec4_sub(&xform->pos, &firstKey.pos, &xform->pos); }

Here’s how to apply an additive pose on top of the regular pose. It differs from the regular blend. If the weight is one, then we can multiply transforms. If the weight is non-one, then we need to first blend between identity and the additive transform, then apply this blended transform on the transform in the pose.

Additive applied on idle and run animations
void fa_pose_apply_additive(fa_pose_t* out, const fa_pose_t* base, const fa_pose_t* add, float weight) { const uint32_t numXforms = out->numXforms; const fm_xform* base_xforms = base->xforms; const fm_xform* add_xforms = add->xforms; fm_xform* out_xforms = out->xforms; if(weight == 1.0f) { for(uint32_t i=0; i<numXforms; ++i) { fm_xform_mul(&base_xforms[i], &add_xforms[i], &out_xforms[i]); } } else { for(uint32_t i=0; i<numXforms; ++i) { fm_xform addXform = add_xforms[i]; fm_vec4_mulf(&addXform.pos, weight, &addXform.pos); fm_quat identity; fm_quat_identity(&identity); fm_quat_slerp(&identity, &addXform.rot, weight, &addXform.rot); fm_xform_mul(&base_xforms[i], &addXform, &out_xforms[i]); } } }

Partial Pose

This isn’t a standard solution used across engines, but I find it interesting and valuable. The idea is to have an embedded mask within a pose structure. Eight bits per transform is enough to express the weight of the blend. A pose can have this mask assigned as soon as it’s sampled from an animation clip. This means the clip itself might be dedicated to a particular body part. Imagine having the main body transforms animated (around 30 bones), but hands being animated separately and blended through partial pose. This can be an excellent technique for composing animations on the character and memory optimisation (savings on animation clip data).

Partial pose blending is a bit trickier than regular masked blend, as now both poses can have different weights, and there’s alpha on the input as well. Some transforms might not be even used in a pose (their weight being zero). This means we have to consider all the possible cases.

const uint8_t a_byte = a_weightsBase ? *a_weights : 255; const uint8_t b_byte = b_weightsBase ? *b_weights : 255; const bool a_valid = (a_byte != 0); const bool b_valid = (b_byte != 0); if(a_valid && b_valid) { const float a_weight = a_byte * (1.0f / 255.0f); const float b_weight = b_byte * (1.0f / 255.0f); float blend = 0.0f; if(b_weight > a_weight) { blend = (b_weight - a_weight + alpha * a_weight) / b_weight; } else { blend = alpha * b_weight / a_weight; } const float outWeight = (1.0f - blend) * a_weight + blend * b_weight; fm_xform_slerp(a_xforms, b_xforms, blend, out_xforms); *out_weights = (uint8_t)(outWeight * 255.0f + 0.5f); } else if(a_valid) { *out_xforms = *a_xforms; *out_weights = *a_weights; } else if(b_valid) { *out_xforms = *b_xforms; *out_weights = *b_weights; } else { *out_weights = 0x00; }

Conclusion

With these blending techniques, you can build simple animation logic. All these blends can be wrapped into an animation graph, but this is a topic for another post. It’s also worth noting that the blending order is crucial when dealing with more than two poses, as it can significantly impact the final outcome.

References

Leave a Reply

%d bloggers like this: