Tutorial 27: Scrolling Backgrounds and Parallax

2D Techniques  ·  Intermediate

What is Parallax?

Parallax scrolling is a technique where background layers move at different speeds relative to the camera, creating a convincing illusion of depth. Objects further away appear to move more slowly than objects close to the viewer — the same effect you see when driving past a landscape where distant hills scroll slowly and nearby fences fly past.

It is one of the most cost-effective visual tricks in 2D games. A side-scrolling platformer can go from flat to atmospheric with just three or four well-chosen layers: a static sky, slowly drifting clouds, mid-distance mountains, and a faster foreground treeline.

The rule of thumb: slower scroll = further away. A layer that doesn't scroll at all sits at infinity (like a sky gradient). A layer that scrolls at the same speed as the camera is at the same depth as the player.

Layer System

Model each layer as a self-contained object with three data members:

  • texture — the Texture2D image for this layer.
  • scrollFactor — a multiplier in the range 0.0–1.0. 0.0 = stationary, 1.0 = moves with the camera.
  • offset — accumulated horizontal scroll position in pixels, tracked per layer.

Layers are drawn back-to-front (sky first, foreground last). You can also stack vertical layers for top-down games or combine horizontal and vertical scrolling for diagonal perspectives.

struct ParallaxLayer {
    Texture2D*  texture;
    float       scrollFactor;  // 0.0 = fixed, 1.0 = full camera speed
    float       offset;        // current horizontal offset in pixels
    int         screenHeight;  // draw at full screen height
};

Scroll Speed per Layer

Each frame, update the layer's offset based on how fast the camera is moving and the layer's scroll factor:

// dt = delta time in seconds, cameraSpeedX = camera pixels/sec
layer.offset += cameraSpeedX * layer.scrollFactor * dt;

Typical scroll factor values:

  • Sky / distant clouds: 0.050.15
  • Far mountains: 0.30.4
  • Mid-ground trees: 0.60.7
  • Near foreground: 0.850.95

The camera itself (the gameplay layer the player sees) scrolls at factor 1.0 — but that layer is usually not a parallax layer, it's just the tile map or level geometry.

Infinite Tiling Technique

Parallax layers need to tile horizontally without a visible seam. The trick is to keep the offset in the range [0, textureWidth) using fmod, then draw the texture twice: once at -offset and once at textureWidth - offset. At least one copy is always visible no matter the scroll position.

void Update(float cameraSpeedX, float dt) {
    offset += cameraSpeedX * scrollFactor * dt;
    // Wrap offset so it never grows unbounded
    offset = std::fmod(offset, static_cast<float>(texture->getWidth()));
}

void Draw(SpriteBatch& sb, int screenHeight) {
    float w = static_cast<float>(texture->getWidth());
    float h = static_cast<float>(screenHeight);
    // Draw two copies to cover the seam
    sb.Draw(*texture, Vector2(-offset,     0.0f), Color::White);
    sb.Draw(*texture, Vector2(w - offset,  0.0f), Color::White);
}

If your texture is narrower than the screen you may need to draw a third copy. In practice, layer textures are usually made wide enough (e.g. 1920 px) to avoid this.

SpriteBatch with Wrap Address

An alternative to drawing two copies manually is to set the sampler to TextureAddressMode::Wrap before calling SpriteBatch::Begin and then draw a destination rectangle wider than the texture using a source rectangle with U coordinates beyond 1.0. CNA exposes this through SamplerState:

// Custom sampler with wrapping enabled
SamplerState wrapSampler;
wrapSampler.AddressU = TextureAddressMode::Wrap;
wrapSampler.AddressV = TextureAddressMode::Clamp;

spriteBatch->Begin(SpriteSortMode::Deferred, BlendState::AlphaBlend,
                   wrapSampler);
// Draw oversized destination; source rectangle UV wraps automatically
Rectangle dest(0, 0, screenWidth, screenHeight);
spriteBatch->Draw(*layer.texture, dest, Color::White);
spriteBatch->End();

The manual two-copy approach is simpler and works on all backends, so prefer it unless you need to tile a very narrow texture many times.

Camera Integration

Rather than tracking an explicit camera speed, you can drive parallax directly from the camera's world-space X position. Each frame, compute the layer offset as camera.X * scrollFactor — no accumulation needed, no drift:

struct Camera2D {
    Vector2 position;  // world-space camera centre
};

// In ParallaxLayer::Draw, derive offset from camera directly:
void Draw(SpriteBatch& sb, const Camera2D& cam, int screenH) {
    float w   = static_cast<float>(texture->getWidth());
    float off = std::fmod(cam.position.X * scrollFactor, w);
    sb.Draw(*texture, Vector2(-off,       0.0f), Color::White);
    sb.Draw(*texture, Vector2(w - off,    0.0f), Color::White);
}

This approach is deterministic: if you teleport the camera, the background instantly snaps to the correct position with no accumulated error.

Load layer textures through ContentManager so they participate in the standard asset pipeline. Call Content.Load<Texture2D>("backgrounds/sky") in LoadContent() and the content manager handles lifetime and caching automatically.

Full Example

A complete three-layer parallax background. Press the right arrow key to scroll the camera.

#include <cmath>
#include <array>
#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/Graphics/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"

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

// -------------------------------------------------------
struct Camera2D {
    Vector2 position{ 0.0f, 0.0f };
};

// -------------------------------------------------------
class ParallaxLayer {
public:
    ParallaxLayer(Texture2D* tex, float factor)
        : texture_(tex), scrollFactor_(factor) {}

    void Draw(SpriteBatch& sb, const Camera2D& cam, int screenH) {
        float w   = static_cast<float>(texture_->getWidth());
        float h   = static_cast<float>(texture_->getHeight());
        float off = std::fmod(cam.position.X * scrollFactor_, w);
        if (off < 0.0f) off += w;

        // Scale height to fill the screen
        float scaleY = static_cast<float>(screenH) / h;
        Vector2 scale{ 1.0f, scaleY };

        sb.Draw(*texture_, Vector2(-off,      0.0f), Color::White);
        sb.Draw(*texture_, Vector2(w - off,   0.0f), Color::White);
    }

private:
    Texture2D* texture_;
    float      scrollFactor_;
};

// -------------------------------------------------------
class ParallaxBackground {
public:
    void Init(Texture2D* sky, Texture2D* mountains, Texture2D* trees) {
        layers_[0] = std::make_unique<ParallaxLayer>(sky,       0.10f);
        layers_[1] = std::make_unique<ParallaxLayer>(mountains, 0.40f);
        layers_[2] = std::make_unique<ParallaxLayer>(trees,     0.80f);
    }

    void Draw(SpriteBatch& sb, const Camera2D& cam, int screenH) {
        for (auto& layer : layers_)
            layer->Draw(sb, cam, screenH);
    }

private:
    std::array<std::unique_ptr<ParallaxLayer>, 3> layers_;
};

// -------------------------------------------------------
class ParallaxGame final : public Game {
public:
    ParallaxGame() : graphics_(this) {
        graphics_.setPreferredBackBufferWidth(800);
        graphics_.setPreferredBackBufferHeight(480);
    }

protected:
    void LoadContent() override {
        spriteBatch_ = std::make_unique<SpriteBatch>(getGraphicsDeviceProperty());

        texSky_       = std::make_unique<Texture2D>("assets/bg_sky.png",
                             getGraphicsDeviceProperty());
        texMountains_ = std::make_unique<Texture2D>("assets/bg_mountains.png",
                             getGraphicsDeviceProperty());
        texTrees_     = std::make_unique<Texture2D>("assets/bg_trees.png",
                             getGraphicsDeviceProperty());

        background_.Init(texSky_.get(), texMountains_.get(), texTrees_.get());
    }

    void Update(GameTime& gameTime) override {
        float dt = static_cast<float>(gameTime.getElapsedGameTime().TotalSeconds());

        auto kb = Keyboard::GetState();
        const float cameraSpeed = 200.0f; // pixels per second

        if (kb.IsKeyDown(Keys::Right)) camera_.position.X += cameraSpeed * dt;
        if (kb.IsKeyDown(Keys::Left))  camera_.position.X -= cameraSpeed * dt;
        if (camera_.position.X < 0.0f) camera_.position.X = 0.0f;
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color(135, 206, 235, 255)); // sky blue fallback

        spriteBatch_->Begin();
        background_.Draw(*spriteBatch_, camera_, 480);
        spriteBatch_->End();

        gd.Present();
    }

private:
    GraphicsDeviceManager graphics_;
    std::unique_ptr<SpriteBatch> spriteBatch_;
    std::unique_ptr<Texture2D>   texSky_;
    std::unique_ptr<Texture2D>   texMountains_;
    std::unique_ptr<Texture2D>   texTrees_;
    ParallaxBackground            background_;
    Camera2D                      camera_;
};

int main() {
    ParallaxGame game;
    game.Run();
    return 0;
}