Tutorial 17: 2D Camera and Viewport
What you’ll learn
- Building a scroll transform with
Matrix::CreateTranslation. - Passing that matrix to the
transformMatrixparameter ofSpriteBatch::Begin(). - Packaging it as a reusable
Camera2Dclass with follow and screen-shake behaviour.
Before you start — Tutorial 06: Drawing Your First 2D Shape (the Begin parameter list) and Tutorial 12: Moving Sprites and Basic Animation (something for the camera to follow). Later tutorials reuse the Camera2D class built here.
XNA does not have a built-in 2D camera class, but SpriteBatch::Begin() accepts an optional transformMatrix parameter that makes implementing one straightforward. By passing a translation, scale, and rotation matrix you can pan, zoom, and rotate the entire world without touching any individual sprite position.
What is a 2D camera?
A 2D camera is simply a mathematical transform that maps world-space coordinates to screen-space coordinates. If the camera is at world position (400, 300), then an object at world (400, 300) should appear at the centre of the screen. You achieve this by translating the entire scene by -cameraPosition (and optionally scaling and rotating before or after).
In XNA/CNA the transform is expressed as a Matrix and passed to SpriteBatch::Begin(). SpriteBatch multiplies every sprite's position by this matrix before rendering. This means all world coordinates stay unchanged — only the rendering shifts.
Matrix.CreateTranslation for scroll
For a pure pan (no zoom or rotation), a translation matrix is all you need:
#include "Microsoft/Xna/Framework/Matrix.hpp"
// Camera looks at world position (camX, camY)
// We translate the scene by the negative of that position
Matrix transform = Matrix::CreateTranslation(-camX, -camY, 0.0f);
spriteBatch_->Begin(SpriteSortMode::Deferred,
BlendState::AlphaBlend, // BlendState is a value, not a pointer
nullptr, // SamplerState
nullptr, // DepthStencilState
nullptr, // RasterizerState
nullptr, // Effect
transform);
An object at world position (500, 300) will now appear at screen position (500 - camX, 300 - camY).
SpriteBatch::Begin() transformMatrix parameter
The overload that takes a transform (the XNA 4.0 one, with every parameter) is:
void SpriteBatch::Begin(
SpriteSortMode sortMode,
BlendState blendState, // by value; a const BlendState* overload exists too
const SamplerState* samplerState, // nullptr = LinearClamp
const DepthStencilState* depthStencil, // nullptr = None
const RasterizerState* rasterizerState, // nullptr = CullCounterClockwise
Effect* effect, // nullptr = the built-in sprite effect
Matrix transformMatrix // by value
);
There are no default arguments: Begin() with no arguments, Begin(sortMode, blendState), and the longer forms (with and without effect / transformMatrix) are separate overloads, so to pass a matrix you spell out every parameter before it, as in the example above. Passing a Matrix in the last argument is the standard hook for 2D cameras, parallax scrolling, and screen shake. The matrix is applied uniformly to all sprites drawn between this Begin()/End() pair.
Viewport struct
Viewport describes the portion of the back buffer that receives rendering output. It also provides Project() and Unproject() for converting between 3D world space and screen space (for a 2D camera the matrix code below is simpler):
#include "Microsoft/Xna/Framework/Graphics/Viewport.hpp"
Viewport vp = getGraphicsDeviceProperty().getViewportProperty();
// Viewport properties (accessors, as everywhere in CNA)
int x = vp.getXProperty(); // left edge in pixels
int y = vp.getYProperty(); // top edge in pixels
int width = vp.getWidthProperty(); // width in pixels
int height = vp.getHeightProperty(); // height in pixels
float minZ = vp.getMinDepthProperty(); // usually 0.0f
float maxZ = vp.getMaxDepthProperty(); // usually 1.0f
// Screen centre
Vector2 centre(static_cast<float>(vp.getWidthProperty()) / 2.0f,
static_cast<float>(vp.getHeightProperty()) / 2.0f);
Camera2D class pattern
A reusable camera class encapsulates position, zoom, rotation, and produces a transform matrix on demand:
// Camera2D.hpp
#pragma once
#include <algorithm>
#include "Microsoft/Xna/Framework/Matrix.hpp"
#include "Microsoft/Xna/Framework/Vector2.hpp"
#include "Microsoft/Xna/Framework/Vector3.hpp"
#include "Microsoft/Xna/Framework/MathHelper.hpp"
// CNA's types live in namespaces; using-declarations keep the class short.
using Microsoft::Xna::Framework::Matrix;
using Microsoft::Xna::Framework::Vector2;
using Microsoft::Xna::Framework::Vector3;
class Camera2D {
public:
Camera2D(int viewportWidth, int viewportHeight)
: viewportW_(viewportWidth), viewportH_(viewportHeight) {}
// Getters / setters
void setPositionProperty(Vector2 p) { position_ = p; dirty_ = true; }
Vector2 getPositionProperty() const { return position_; }
void setZoom(float z) { zoom_ = std::max(0.1f, z); dirty_ = true; }
float getZoom() const { return zoom_; }
void setRotation(float r) { rotation_ = r; dirty_ = true; }
float getRotation() const { return rotation_; }
// Build transform matrix (lazy)
Matrix getTransform() {
if (dirty_) {
Vector2 origin(static_cast<float>(viewportW_) / 2.0f,
static_cast<float>(viewportH_) / 2.0f);
cached_ =
Matrix::CreateTranslation(Vector3(-position_.X, -position_.Y, 0.0f)) *
Matrix::CreateRotationZ(-rotation_) *
Matrix::CreateScale(zoom_, zoom_, 1.0f) *
Matrix::CreateTranslation(Vector3(origin.X, origin.Y, 0.0f));
dirty_ = false;
}
return cached_;
}
// Convert a screen point (e.g. the mouse position) to a world position
Vector2 ScreenToWorld(Vector2 screen) {
Matrix inv = Matrix::Invert(getTransform());
return Vector2::Transform(screen, inv);
}
// Clamp camera so it does not scroll outside a world rectangle
void ClampToWorld(int worldWidth, int worldHeight) {
float halfW = static_cast<float>(viewportW_) / (2.0f * zoom_);
float halfH = static_cast<float>(viewportH_) / (2.0f * zoom_);
position_.X = std::clamp(position_.X, halfW, static_cast<float>(worldWidth) - halfW);
position_.Y = std::clamp(position_.Y, halfH, static_cast<float>(worldHeight) - halfH);
dirty_ = true;
}
private:
int viewportW_, viewportH_;
Vector2 position_ = Vector2::Zero;
float zoom_ = 1.0f;
float rotation_ = 0.0f;
bool dirty_ = true;
Matrix cached_;
};
Follow-player camera
#include <algorithm>
#include <memory>
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Microsoft/Xna/Framework/Input/Keys.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"
#include "Camera2D.hpp"
using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
using namespace Microsoft::Xna::Framework::Input;
static constexpr int WORLD_W = 3200;
static constexpr int WORLD_H = 2400;
class CameraDemo final : public Game {
public:
CameraDemo() : graphics_(this) {
graphics_.setPreferredBackBufferWidthProperty(800);
graphics_.setPreferredBackBufferHeightProperty(600);
}
protected:
void LoadContent() override {
spriteBatch_ = std::make_unique<SpriteBatch>(getGraphicsDeviceProperty());
playerTex_ = getContentProperty().Load<Texture2D>("Sprites/player");
worldTex_ = getContentProperty().Load<Texture2D>("Sprites/world_background");
camera_ = std::make_unique<Camera2D>(800, 600);
playerPos_ = Vector2(static_cast<float>(WORLD_W) / 2.0f,
static_cast<float>(WORLD_H) / 2.0f);
}
void Update(GameTime& gt) override {
auto kb = Keyboard::GetState();
float dt = static_cast<float>(gt.getElapsedGameTimeProperty().getTotalSecondsProperty());
// Move player in world space
const float speed = 250.0f;
if (kb.IsKeyDown(Keys::Left)) playerPos_.X -= speed * dt;
if (kb.IsKeyDown(Keys::Right)) playerPos_.X += speed * dt;
if (kb.IsKeyDown(Keys::Up)) playerPos_.Y -= speed * dt;
if (kb.IsKeyDown(Keys::Down)) playerPos_.Y += speed * dt;
playerPos_.X = std::clamp(playerPos_.X, 0.0f, static_cast<float>(WORLD_W));
playerPos_.Y = std::clamp(playerPos_.Y, 0.0f, static_cast<float>(WORLD_H));
// Smooth follow: lerp camera toward player centre.
// (On the very first Update dt is 0, so the camera simply does not move yet.)
Vector2 target = playerPos_ + Vector2(24.0f, 32.0f);
Vector2 camPos = Vector2::Lerp(camera_->getPositionProperty(), target, 5.0f * dt);
camera_->setPositionProperty(camPos);
// Zoom with Z/X
if (kb.IsKeyDown(Keys::Z)) camera_->setZoom(camera_->getZoom() + dt);
if (kb.IsKeyDown(Keys::X)) camera_->setZoom(camera_->getZoom() - dt);
// Clamp camera to world bounds
camera_->ClampToWorld(WORLD_W, WORLD_H);
}
void Draw(const GameTime&) override {
auto& gd = getGraphicsDeviceProperty();
gd.Clear(Color::Black);
// Draw world with camera transform
spriteBatch_->Begin(SpriteSortMode::Deferred,
BlendState::AlphaBlend, nullptr, nullptr, nullptr, nullptr,
camera_->getTransform());
spriteBatch_->Draw(worldTex_, Vector2::Zero, Color::White);
spriteBatch_->Draw(playerTex_, playerPos_, Color::White);
spriteBatch_->End();
// HUD — draw without camera transform (screen space)
spriteBatch_->Begin();
// Draw minimap, health bar, etc. here
spriteBatch_->End();
// Game::EndDraw() presents the frame after Draw() returns.
}
private:
GraphicsDeviceManager graphics_;
std::unique_ptr<SpriteBatch> spriteBatch_;
Texture2D playerTex_, worldTex_;
std::unique_ptr<Camera2D> camera_;
Vector2 playerPos_;
};
int main() { CameraDemo game; game.Run(); }
Screen shake
Screen shake is a small random offset added to the camera position each frame, decaying over time:
// Add to your Game class (needs <cstdlib> for std::rand):
float shakeIntensity_ = 0.0f;
float shakeDuration_ = 0.0f;
float originalDuration_ = 1.0f;
Vector2 basePosition_; // where the camera would be without shake
void Shake(float intensity, float duration) {
shakeIntensity_ = intensity;
shakeDuration_ = duration;
originalDuration_ = duration;
}
// In Update(), after the camera has been moved to basePosition_:
if (shakeDuration_ > 0) {
shakeDuration_ -= dt;
float s = shakeIntensity_ * (shakeDuration_ / originalDuration_);
Vector2 offset(
(static_cast<float>(std::rand()) / RAND_MAX * 2.0f - 1.0f) * s,
(static_cast<float>(std::rand()) / RAND_MAX * 2.0f - 1.0f) * s
);
camera_->setPositionProperty(basePosition_ + offset);
}