Tutorial 34: 3D Camera Setup and Control

3D Rendering  ·  Intermediate

A 3D camera is just a view matrix computed from a position and orientation. CNA provides all the building blocks — this tutorial shows how to assemble them into a reusable camera class and wire it to keyboard and mouse input.

View Matrix with CreateLookAt

Matrix::CreateLookAt takes three parameters and returns the view matrix for that camera configuration.

Matrix view = Matrix::CreateLookAt(
    Vector3(0.0f, 5.0f, 10.0f),  // camera position in world space
    Vector3(0.0f, 0.0f,  0.0f),  // point the camera is looking at
    Vector3::Up                   // which direction is "up" (0,1,0)
);

For a static camera, compute this once and reuse it. For a dynamic camera, recompute every frame from your stored position and orientation.

FPS Camera (Yaw / Pitch)

A first-person camera stores two angles:

  • Yaw — rotation around the world Y axis (left/right)
  • Pitch — rotation around the camera's local X axis (up/down)

Mouse horizontal movement drives yaw; vertical movement drives pitch. Pitch must be clamped to approximately ±89° to prevent the camera from flipping.

// Accumulate mouse delta each frame
yaw_   += mouseDeltaX * sensitivity_;
pitch_ += mouseDeltaY * sensitivity_;

// Clamp pitch to avoid gimbal lock at poles
float maxPitch = MathHelper::ToRadians(89.0f);
pitch_ = MathHelper::Clamp(pitch_, -maxPitch, maxPitch);

// Compute forward direction from yaw + pitch
Vector3 forward = {
    std::cos(pitch_) * std::sin(yaw_),
    std::sin(pitch_),          // negative because Y is up
   -std::cos(pitch_) * std::cos(yaw_)
};
// Rebuild view matrix
Matrix view = Matrix::CreateLookAt(position_, position_ + forward, Vector3::Up);

WASD movement uses the forward and right vectors derived from yaw:

Vector3 right = Vector3::Normalize(Vector3::Cross(forward, Vector3::Up));

if (kb.IsKeyDown(Keys::W)) position_ += forward * speed * dt;
if (kb.IsKeyDown(Keys::S)) position_ -= forward * speed * dt;
if (kb.IsKeyDown(Keys::A)) position_ -= right   * speed * dt;
if (kb.IsKeyDown(Keys::D)) position_ += right   * speed * dt;

Orbit Camera

An orbit camera revolves around a fixed target point — ideal for inspecting objects or strategy-game views.

// Store: target, distance, yaw, pitch
// Mouse drag → yaw/pitch; scroll wheel → distance

float x = distance_ * std::cos(pitch_) * std::sin(yaw_);
float y = distance_ * std::sin(pitch_);
float z = distance_ * std::cos(pitch_) * std::cos(yaw_);

Vector3 cameraPos = target_ + Vector3(x, y, z);
Matrix view = Matrix::CreateLookAt(cameraPos, target_, Vector3::Up);

Arc-Ball Camera

The arc-ball camera is a variant of the orbit camera where drag rotates around the world axes rather than the camera's local axes. This gives the natural "tumble" feel used in 3D modeling tools. The implementation maps the 2D mouse position to a point on an imaginary sphere surrounding the target, then computes a rotation quaternion between the drag start and end points. Use Quaternion::CreateFromAxisAngle and convert to a matrix with Matrix::CreateFromQuaternion.

Mouse-Look

For FPS-style mouse look, read raw delta (relative) mouse movement rather than absolute position. CNA exposes this through MouseState:

MouseState prev = Mouse::GetState();
// ... next frame ...
MouseState curr = Mouse::GetState();

int dx = curr.X - prev.X;
int dy = curr.Y - prev.Y;

// Re-center the cursor to avoid hitting screen edges
int cx = graphics_.getPreferredBackBufferWidth()  / 2;
int cy = graphics_.getPreferredBackBufferHeight() / 2;
Mouse::SetPosition(cx, cy);

The underlying SDL3 relative mouse mode (SDL_SetRelativeMouseMode) is also available if you need raw OS-level delta without cursor warping.

Camera Class Implementation

Encapsulate the camera logic in a standalone class that the Game owns and calls each frame.

class FpsCamera {
public:
    Vector3 position   = {0, 2, 5};
    float   yaw        = 0.0f;
    float   pitch      = 0.0f;
    float   moveSpeed  = 8.0f;
    float   sensitivity = 0.002f;
    float   fovDegrees  = 60.0f;
    float   nearPlane   = 0.1f;
    float   farPlane    = 500.0f;

    void Update(const KeyboardState& kb,
                int mouseDx, int mouseDy,
                float dt)
    {
        yaw_   += mouseDx * sensitivity;
        pitch_ += mouseDy * sensitivity;
        float maxP = MathHelper::ToRadians(89.0f);
        pitch_ = MathHelper::Clamp(pitch_, -maxP, maxP);

        Vector3 fwd = GetForward();
        Vector3 right = Vector3::Normalize(Vector3::Cross(fwd, Vector3::Up));

        if (kb.IsKeyDown(Keys::W)) position += fwd   * moveSpeed * dt;
        if (kb.IsKeyDown(Keys::S)) position -= fwd   * moveSpeed * dt;
        if (kb.IsKeyDown(Keys::A)) position -= right * moveSpeed * dt;
        if (kb.IsKeyDown(Keys::D)) position += right * moveSpeed * dt;
        if (kb.IsKeyDown(Keys::Q)) position.Y -= moveSpeed * dt;
        if (kb.IsKeyDown(Keys::E)) position.Y += moveSpeed * dt;
    }

    Matrix GetViewMatrix() const {
        return Matrix::CreateLookAt(position, position + GetForward(), Vector3::Up);
    }

    Matrix GetProjectionMatrix(float aspect) const {
        return Matrix::CreatePerspectiveFieldOfView(
            MathHelper::ToRadians(fovDegrees), aspect, nearPlane, farPlane);
    }

private:
    float yaw_   = 0.0f;
    float pitch_ = 0.0f;

    Vector3 GetForward() const {
        return Vector3::Normalize({
             std::cos(pitch_) * std::sin(yaw_),
            -std::sin(pitch_),
            -std::cos(pitch_) * std::cos(yaw_)
        });
    }
};
💡

Cursor locking. For FPS games, call Mouse::SetPosition(cx, cy) every frame to keep the cursor centered so it never hits a screen edge. For menu screens, stop centering and restore the system cursor. Toggle this with Escape.

Full FPS Camera Demo

#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/BasicEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexBuffer.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexPositionColor.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Microsoft/Xna/Framework/Input/Mouse.hpp"
#include "Microsoft/Xna/Framework/MathHelper.hpp"
#include <cmath>

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
using namespace Microsoft::Xna::Framework::Input;

class FpsCameraGame final : public Game {
public:
    FpsCameraGame() : graphics_(this) {
        graphics_.setPreferredBackBufferWidth(800);
        graphics_.setPreferredBackBufferHeight(600);
    }

protected:
    void LoadContent() override {
        auto& gd = getGraphicsDeviceProperty();
        effect_ = std::make_unique<BasicEffect>(gd);
        effect_->setVertexColorEnabled(true);

        // Simple ground grid (set of line-list crosses)
        std::vector<VertexPositionColor> lines;
        for (int i = -10; i <= 10; ++i) {
            Color c = (i == 0) ? Color::White : Color(80,80,80);
            lines.push_back({{(float)i, 0, -10}, c});
            lines.push_back({{(float)i, 0, +10}, c});
            lines.push_back({{-10, 0, (float)i}, c});
            lines.push_back({{+10, 0, (float)i}, c});
        }
        gridCount_ = (int)lines.size();
        gridVB_ = std::make_unique<VertexBuffer>(gd,
            VertexPositionColor::VertexDeclaration,
            gridCount_, BufferUsage::None);
        gridVB_->SetData(lines.data(), gridCount_);

        // Center the mouse to avoid jump on first frame
        centerX_ = graphics_.getPreferredBackBufferWidth()  / 2;
        centerY_ = graphics_.getPreferredBackBufferHeight() / 2;
        Mouse::SetPosition(centerX_, centerY_);
        prevMouse_ = Mouse::GetState();
    }

    void Update(GameTime& gameTime) override {
        float dt = (float)gameTime.getElapsedGameTime().TotalSeconds();
        auto kb = Keyboard::GetState();
        auto ms = Mouse::GetState();

        // Toggle mouse capture with Escape
        if (kb.IsKeyDown(Keys::Escape) && !escWasDown_) {
            captureMouse_ = !captureMouse_;
        }
        escWasDown_ = kb.IsKeyDown(Keys::Escape);

        int dx = 0, dy = 0;
        if (captureMouse_) {
            dx = ms.X - centerX_;
            dy = ms.Y - centerY_;
            Mouse::SetPosition(centerX_, centerY_);
        }

        camera_.Update(kb, dx, dy, dt);
        prevMouse_ = Mouse::GetState();
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color(30, 30, 50));

        float aspect = 800.0f / 600.0f;
        effect_->setWorld(Matrix::Identity);
        effect_->setView(camera_.GetViewMatrix());
        effect_->setProjection(camera_.GetProjectionMatrix(aspect));

        gd.setVertexBuffer(*gridVB_);
        for (auto& pass : effect_->getCurrentTechnique().Passes) {
            pass.Apply();
            gd.DrawPrimitives(PrimitiveType::LineList, 0, gridCount_ / 2);
        }
        gd.Present();
    }

private:
    struct FpsCamera {
        Vector3 position  = {0, 2, 5};
        float   yaw       = 0.0f;
        float   pitch     = 0.0f;
        float   moveSpeed = 8.0f;
        float   sens      = 0.002f;

        void Update(const KeyboardState& kb, int dx, int dy, float dt) {
            yaw   += dx * sens;
            pitch += dy * sens;
            float maxP = MathHelper::ToRadians(89.0f);
            pitch = MathHelper::Clamp(pitch, -maxP, maxP);

            Vector3 fwd   = GetForward();
            Vector3 right = Vector3::Normalize(Vector3::Cross(fwd, Vector3::Up));
            if (kb.IsKeyDown(Keys::W)) position += fwd   * moveSpeed * dt;
            if (kb.IsKeyDown(Keys::S)) position -= fwd   * moveSpeed * dt;
            if (kb.IsKeyDown(Keys::A)) position -= right * moveSpeed * dt;
            if (kb.IsKeyDown(Keys::D)) position += right * moveSpeed * dt;
        }

        Matrix GetViewMatrix() const {
            return Matrix::CreateLookAt(position, position + GetForward(), Vector3::Up);
        }
        Matrix GetProjectionMatrix(float aspect) const {
            return Matrix::CreatePerspectiveFieldOfView(
                MathHelper::ToRadians(60.0f), aspect, 0.1f, 500.0f);
        }
    private:
        Vector3 GetForward() const {
            return Vector3::Normalize({
                 std::cos(pitch) * std::sin(yaw),
                -std::sin(pitch),
                -std::cos(pitch) * std::cos(yaw)
            });
        }
    };

    GraphicsDeviceManager graphics_;
    std::unique_ptr<BasicEffect> effect_;
    std::unique_ptr<VertexBuffer> gridVB_;
    int gridCount_ = 0;

    FpsCamera camera_;
    MouseState prevMouse_{};
    int  centerX_ = 400, centerY_ = 300;
    bool captureMouse_ = true;
    bool escWasDown_   = false;
};

int main() { FpsCameraGame game; game.Run(); }

Next Steps