Tutorial 43: Quaternions for Smooth Rotation

CNA — C++ XNA 4.0 reimplementation

ℹ

What you’ll learn

  • What gimbal lock is and how a quaternion avoids it.
  • CreateFromAxisAngle, CreateFromYawPitchRoll and composing rotations with Multiply.
  • Interpolating rotations with Slerp, and converting back to a matrix to render.

Before you start — Tutorial 42: Matrix Operations and Transforms (rotation matrices and their limits) and Tutorial 34: 3D Camera Setup and Control (the yaw/pitch camera this improves on).

Quaternions represent 3D rotations as a four-component value (X, Y, Z, W). They are the standard tool for smooth 3D rotation in games because they avoid gimbal lock, interpolate cleanly, and compose without numerical drift.

Why quaternions?

Euler angles (yaw/pitch/roll) are intuitive but suffer from gimbal lock: when two rotation axes align, you lose a degree of freedom and animation breaks. Rotation matrices avoid gimbal lock but are expensive to interpolate and accumulate floating-point error after many multiplications. Quaternions give you:

  • No gimbal lock
  • Smooth spherical interpolation with Slerp
  • Compact storage (4 floats vs 9 for a matrix)
  • Stable composition via multiplication

Gimbal lock explained

Consider a flight model using three independent Euler angles. If you pitch up 90 degrees, your local roll axis now aligns with the world yaw axis — any subsequent roll rotation has the same effect as a yaw. You have lost one degree of freedom. This manifests as uncontrollable sudden jumps when rotations approach singularities. Quaternions represent all orientations uniformly and never hit this singularity.

CreateFromAxisAngle

#include "Microsoft/Xna/Framework/Quaternion.hpp"
using namespace Microsoft::Xna::Framework;

// Rotate 45 degrees around the Y axis
Quaternion q = Quaternion::CreateFromAxisAngle(Vector3::Up,
                   MathHelper::ToRadians(45.0f));

// Identity (no rotation)
Quaternion identity = Quaternion::Identity; // (0, 0, 0, 1)

CreateFromYawPitchRoll

// Convert Euler angles to a quaternion. The result is qYaw * qPitch * qRoll, so (by the
// composition rule in the next section) the roll is applied first, then pitch, then yaw.
float yaw   = MathHelper::ToRadians(90.0f);  // rotate around Y
float pitch = MathHelper::ToRadians(30.0f);  // rotate around X
float roll  = MathHelper::ToRadians(0.0f);   // rotate around Z

Quaternion q2 = Quaternion::CreateFromYawPitchRoll(yaw, pitch, roll);

Multiply — compose rotations

Multiplying quaternions combines their rotations, and order matters. operator* (and Quaternion::Multiply) is the ordinary Hamilton product, so rotating a vector by q1 * q2 applies q2 first, then q1. Equivalently, Matrix::CreateFromQuaternion(q1 * q2) equals CreateFromQuaternion(q2) * CreateFromQuaternion(q1) with CNA’s row-vector matrices. If you would rather read the arguments in the order the rotations happen, use Quaternion::Concatenate(first, second), which is documented as “value1 followed by value2” and equals second * first.

Quaternion spinY   = Quaternion::CreateFromAxisAngle(Vector3::Up,    0.01f);
Quaternion tiltX   = Quaternion::CreateFromAxisAngle(Vector3::Right, 0.005f);

// Apply tilt first, then spin
Quaternion combined = Quaternion::Concatenate(tiltX, spinY);  // same as spinY * tiltX

// Accumulate orientation each frame: `combined` is applied first (in the object's own
// frame), then the orientation built up so far
orientation_ = Quaternion::Normalize(orientation_ * combined);

Slerp — smooth interpolation

Slerp (Spherical Linear Interpolation) traces the shortest arc between two orientations at constant angular velocity. Use it for camera transitions, AI state blending, or any smooth rotation tween.

Quaternion startRot = Quaternion::Identity;
Quaternion endRot   = Quaternion::CreateFromAxisAngle(Vector3::Up,
                          MathHelper::Pi); // 180 degrees

float t = 0.5f; // halfway through the rotation
Quaternion mid = Quaternion::Slerp(startRot, endRot, t);
// mid ≈ 90-degree rotation around Y

CreateFromQuaternion — convert for rendering

Quaternion q3 = Quaternion::CreateFromAxisAngle(Vector3::Forward, 1.2f);
Matrix rotMat = Matrix::CreateFromQuaternion(q3);

// Use as the rotation part of a world matrix
Matrix world = Matrix::CreateScale(1.0f)
             * rotMat
             * Matrix::CreateTranslation(0, 0, -5);

Conjugate and Inverse

Quaternion q4 = Quaternion::CreateFromAxisAngle(Vector3::Up, 1.0f);

// Conjugate: same axis, negated angle (flips X,Y,Z, keeps W)
Quaternion conj = Quaternion::Conjugate(q4);

// Inverse: for unit quaternions, Inverse == Conjugate
Quaternion inv  = Quaternion::Inverse(q4);

// A rotation followed by its inverse gets you back to identity
Quaternion result = q4 * inv; // ≈ Quaternion::Identity (close, not bit-exact: compare with a tolerance)

Normalize

Quaternion multiplication can accumulate floating-point error over many frames. Normalise periodically (every frame is fine) to keep the rotation pure:

orientation_ = Quaternion::Normalize(orientation_);

Code example: smooth orbit with Slerp, spacecraft rotation

class SpacecraftDemo final : public Game {
public:
    SpacecraftDemo() : graphics_(this) {
        graphics_.setPreferredBackBufferWidthProperty(800);
        graphics_.setPreferredBackBufferHeightProperty(600);
    }

protected:
    void Initialize() override {
        Game::Initialize();
        orientation_ = Quaternion::Identity;
        targetOrient_ = Quaternion::Identity;
        startOrient_ = Quaternion::Identity;
        slerpT_ = 1.0f; // no pending interpolation
    }

    void LoadContent() override {
        effect_ = std::make_unique<BasicEffect>(getGraphicsDeviceProperty());
        effect_->VertexColorEnabled = true;
        // ... load spacecraft mesh into vb_ ...
    }

    void Update(GameTime& gameTime) override {
        float dt = static_cast<float>(gameTime.getElapsedGameTimeProperty().getTotalSecondsProperty());
        auto kb = Keyboard::GetState();

        // Queue a new target orientation on keypress. Remember where the tween starts:
        // Slerp must always run from this fixed start orientation to the target.
        if (kb.IsKeyDown(Keys::Left) && slerpT_ >= 1.0f) {
            startOrient_  = orientation_;
            targetOrient_ = orientation_ *
                Quaternion::CreateFromAxisAngle(Vector3::Up,
                    MathHelper::ToRadians(-90.0f));
            slerpT_ = 0.0f;
        }
        if (kb.IsKeyDown(Keys::Up) && slerpT_ >= 1.0f) {
            startOrient_  = orientation_;
            targetOrient_ = orientation_ *
                Quaternion::CreateFromAxisAngle(Vector3::Right,
                    MathHelper::ToRadians(-45.0f));
            slerpT_ = 0.0f;
        }

        // Advance slerp
        if (slerpT_ < 1.0f) {
            slerpT_ = std::min(1.0f, slerpT_ + dt * 2.0f); // 0.5s transition
            // (Slerping from the already-advanced orientation_ with a growing t would
            // ease out instead of turning at constant angular velocity.)
            orientation_ = Quaternion::Slerp(startOrient_, targetOrient_, slerpT_);
            orientation_ = Quaternion::Normalize(orientation_);
        }
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color::Black);

        Matrix world = Matrix::CreateFromQuaternion(orientation_);
        Matrix view  = Matrix::CreateLookAt(
            Vector3(0, 3, 8), Vector3::Zero, Vector3::Up);
        Matrix proj  = Matrix::CreatePerspectiveFieldOfView(
            MathHelper::PiOver4, 800.0f / 600.0f, 0.1f, 100.0f);

        effect_->setWorldProperty(world);
        effect_->setViewProperty(view);
        effect_->setProjectionProperty(proj);

        gd.SetVertexBuffer(vb_.get());
        for (auto& pass : effect_->getCurrentTechniqueProperty()->getPassesProperty()) {
            pass.Apply();
            gd.DrawPrimitives(PrimitiveType::TriangleList, 0, primitiveCount_);
        }
        // (Game presents the frame after Draw() returns; no gd.Present() needed.)
    }

private:
    GraphicsDeviceManager         graphics_;
    std::unique_ptr<BasicEffect>  effect_;
    std::unique_ptr<VertexBuffer> vb_;
    int                           primitiveCount_ = 0;
    Quaternion                    orientation_{0.0f, 0.0f, 0.0f, 1.0f};
    Quaternion                    startOrient_{0.0f, 0.0f, 0.0f, 1.0f};
    Quaternion                    targetOrient_{0.0f, 0.0f, 0.0f, 1.0f};
    float                         slerpT_ = 1.0f;
};

Orbit camera with Quaternion.Slerp

// Smooth camera orbit: interpolate between two look directions
Quaternion camOrbit = Quaternion::CreateFromAxisAngle(
    Vector3::Up, totalTime_ * 0.3f);

// Camera starts behind the player, orbits around Y
Vector3 baseCamOffset(0, 3, 8);
Vector3 orbitedOffset = Vector3::Transform(baseCamOffset,
    Matrix::CreateFromQuaternion(camOrbit));

Matrix view = Matrix::CreateLookAt(
    orbitedOffset, Vector3::Zero, Vector3::Up);