Tutorial 34: 3D Camera Setup and Control
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
- Tutorial 35: Loading 3D Models — put something worth looking at in your scene.
- Tutorial 33: Matrices and Transformations — review matrix math if you need it.
- Input System reference — full Keyboard and Mouse API.