Tutorial 43: Quaternions for Smooth Rotation

CNA — C++ XNA 4.0 reimplementation

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 (applied as Yaw * Pitch * Roll)
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. Order matters: q1 * q2 applies q1 first, then q2 (same as matrix multiplication with row vectors).

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

// Apply tilt first, then spin (think: local then world)
Quaternion combined = tiltX * spinY;

// Accumulate orientation each frame
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

ToMatrix — 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);

// Applying q then inv gets you back to identity
Quaternion result = q4 * inv; // ≈ Quaternion::Identity

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_.setPreferredBackBufferWidth(800);
        graphics_.setPreferredBackBufferHeight(600);
    }

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

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

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

        // Queue a new target orientation on keypress
        if (kb.IsKeyDown(Keys::Left) && slerpT_ >= 1.0f) {
            targetOrient_ = orientation_ *
                Quaternion::CreateFromAxisAngle(Vector3::Up,
                    MathHelper::ToRadians(-90.0f));
            slerpT_ = 0.0f;
        }
        if (kb.IsKeyDown(Keys::Up) && slerpT_ >= 1.0f) {
            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
            orientation_ = Quaternion::Slerp(orientation_, 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_->setWorld(world);
        effect_->setView(view);
        effect_->setProjection(proj);

        gd.setVertexBuffer(*vb_);
        for (auto& pass : effect_->getCurrentTechnique().Passes) {
            pass.Apply();
            gd.DrawPrimitives(PrimitiveType::TriangleList, 0, primitiveCount_);
        }
        gd.Present();
    }

private:
    GraphicsDeviceManager         graphics_;
    std::unique_ptr<BasicEffect>  effect_;
    std::unique_ptr<VertexBuffer> vb_;
    int                           primitiveCount_ = 0;
    Quaternion                    orientation_;
    Quaternion                    targetOrient_;
    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);