📜 Jelle Pelgrims

Parallax scrolling

Recently I put some work into writing a parallax scrolling implementation with javascript and HTML5 canvases. This is a short write-up on how it works.

Basics

What makes a parallax scrolling background different from a usual flat background is that it gives the impression of depth. Each background layer scrolls at a separate speed, with layers that are further away moving slower than layers that are closer by. This emulates the same effect as you would have in real life while looking out of the side window of a moving car; things that are close by will flash past while things on the horizon will move at an almost unnoticeable pace. You can increase the "realness" of this effect by adding more moving layers to the background.

Demo

Here's a demo of the finished work. You can scroll the background using the arrow keys.

Credit for the background images goes to ansimuz.

Implementation

To get some parallax scrolling going, all you need is a few background layers and a little bit of code to correctly scroll and repeat the layers. In this document I'll only describe how to implement horizontal scrolling, but a vertically scrolling parallax background would work just the same. I will be using javascript and HTML5 canvas.

First of all we'll need a canvas to draw on:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Parallax scrolling demo</title>
</head>
<body>
    <canvas id="canvas" width=320 height=200></canvas>
    <script src="index.js"></script>
</body>
</html>

In the script file index.js we first load the necessary assets and grab the canvas element:

const canvas = document.getElementById("test");

let layer1data = "";
let layer1data = "";
let layer1data = "";
let layer1data = "";

We define some enums we'll need later:

const DrawMode = Object.freeze({
    Foreground: 0,
    Background: 1
});

Then we write the parallax scrolling logic (encapsulated in a class in this case):


class ParallaxBackground {

    context;        // This is a HTML context from the canvas we will be drawing on
    layers = [];    // Here we store our background/foreground layers

    /**
     * @param {*} context   A canvas context
     */
    constructor(context) {
        this.context = context;
    }

Adding a layer is simply pushing an object with the right attributes into the layers list. To describe a layer we need an image for the background, an index (so we know which layers are supposed to be in front of others), a horizontal offset (from what x-coordinate on the image we start drawing), a vertical offset (from what y-coordinate on the image we start drawing), a horizontal scroll speed (so we can make layers in front move faster than the ones in the back) and a vertical scroll speed (same as horizontal scroll speed, but now vertically).

/**
 * Adds a layer
 * @param {*} img                           Image to be used
 * @param {*} index                         Determines the stack order of the layer. Can be positive (background) or negative (foreground)
 * @param {*} horizontalOffset              Horizontal offset used to determine where to start drawing the image
 * @param {*} verticalOffset                Vertical offset used to determine where to start drawing the image
 * @param {*} horizontalScrollSpeed         How many pixels the layer moves horizontally on every scroll operation
 * @param {*} verticalScrollSpeed           How many pixels the layer moves vertically on every scroll operation
 */
addLayer(img, index=1, horizontalOffset=0, verticalOffset = 0, horizontalScrollSpeed=1, verticalScrollSpeed=1) {
    const layer = { img, index, horizontalOffset, verticalOffset, horizontalScrollSpeed, verticalScrollSpeed }
    this.layers.push(layer);
    this.layers.sort((a, b) => a.index - b.index < 0);
}

We can add a convencience function so we only need to specify the images we want as layers (index and speed attributes are based on the order of the given images):

/**
    * Adds multiple layers at once, with front layers having faster scrolling speeds than back layers
    * @param {*} images    Images to be turned into layers
    */
addLayers(images) {
    images.forEach((img, i) => this.addLayer(img, i+1, 0, 0, images.length - 1*i, images.length - 1*i));
}

Now that we have some layers we can get started with rendering them.

To render a single layer we start of with rendering the layer image at the left of the canvas. We slide this image to the left somewhat (that is what the horizontalOffset variable is for, so we know how far to slide the image to the left). Then we basically just keep on drawing the same image next to the ones we already drew, until we reach the right edge of the canvas.

/**
 * Renders the given layer
 * @param {*} img 
 * @param {*} horizontalOffset 
 * @param {*} verticalOffset 
 */
renderLayer({ img, verticalOffset, horizontalOffset}) {
    const canvasWidth = this.context.canvas.clientWidth;
    const canvasHeight = this.context.canvas.clientHeight;

    // Calculate the number of times we will need to draw the image horizontally (also partially)
    let repeats = 1 + Math.floor(canvasWidth / img.width); 
    const remainder = canvasWidth % img.width;
    if (remainder > 0) {
        repeats++;
    }

    let currentPosX = 0;
    for (let i=0; i < repeats; i++) {

        // Calculate which slice of the image we will render
        let sourceX;
        let sourceWidth;
        if (i === 0) {
            sourceX = horizontalOffset;
            sourceWidth = img.width - horizontalOffset;
        } else if (i === repeats - 1) {
            sourceX = 0;
            sourceWidth = canvasWidth - currentPosX;
        } else {
            sourceX = 0;
            sourceWidth = img.width;
        }

        const sourceY = 0;
        const sourceHeight = img.height - verticalOffset;

        // Calculate where the image is drawn on the background
        let destinationX = currentPosX;
        let destinationY = canvasHeight - img.height + verticalOffset

        this.context.drawImage(img, sourceX, sourceY, sourceWidth, sourceHeight, destinationX, destinationY, sourceWidth, sourceHeight);

        // Update current position on canvas, so we now where to draw the next horizontal repetition of the background
        currentPosX += sourceWidth;
    }
}

To get the parallax effect we'll have to draw multiple layers. The only thing important here is that we keep the rendering order of the layers in mind. The layers that are furthest away should be drawn first, and layers that are closer should be drawn over those that are farther away.

/**
 * Renders the parallax back- or foreground
 * @param {*} mode  Flag indicating whether to draw the foreground or background
 */
render(mode=DrawMode.Background) {
    let layers;
    if (mode == DrawMode.Foreground) {
        layers = this.layers.filter(layer => layer.index < 0);
    } else {
        layers = this.layers.filter(layer => layer.index > 0);
    }

    // We draw from the back to front, so that layers in front display over the ones in the back
    for (let i=layers.length-1; i >= 0; i--) {
        this.renderLayer(layers[i]);
    }
}

The only thing left now is to implement the scrolling logic. We do this by manipulating the horizontalOffset and verticalOffset variables. These decide from which point on the image we start drawing (both horizontally and vertically). This only applies for the first image in the layer though, the other (repeated) images are simply drawn behind this first image (so their position on the canvas is also indirectly influenced by the offset).

Horizontal scrolling

If the horizontalOffset variable were zero, we would start drawing from the leftmost horizontal point of the image, and every positive number added to the offset variable (scrolling to the right) would move that point to the right. Once the offset is equal or larger than the width of the image we have to wrap it around and make it start from zero again. If the offset would ever become smaller than zero (due scrolling to the left), we would have to wrap it around the other way and make it equal to the width of the image.

/**
 * Scroll the parallax background horizontally by given amount
 * @param {*} amount Can be negative or positive (scrolls respectively left or right)
 */
scrollHorizontal(amount) {
    for (let i=0; i < this.layers.length; i++) {
        const modifiedScrollAmount = Math.floor(amount * this.layers[i].horizontalScrollSpeed);
        let newOffset = this.layers[i].horizontalOffset + modifiedScrollAmount;

        // Correct the offset, this needs to happen if the image moves offscreen (on the left or right side)
        if (newOffset >= this.layers[i].img.width) {
            newOffset -= this.layers[i].img.width;
        } else if (newOffset < 0) {
            newOffset += this.layers[i].img.width;
        }
        this.layers[i].horizontalOffset = newOffset;
    }
}

Vertical scrolling

In this case I've also implemented a form of vertical scrolling, but the layers only repeat horizontally so the rendering and scrolling logic is somewhat different. For the vertical scrolling the offset doesn't wrap around, it simply stops once it reaches certain values (it can only have values between zero and the height of the image). We do this so the layer doesn't "float" in the middle of the canvas somewhere (i.e. the bottom of the layer always needs to always be below the canvas bottom).

/**
 * Scroll the parallax background vertically by given amount
 * @param {*} amount Can be negative or positive (scrolls respectively up or down)
 */
scrollVertical(amount) {
    for (let i=0; i < this.layers.length; i++) {
        const modifiedScrollAmount = Math.floor(amount * this.layers[i].verticalScrollSpeed);
        let newOffset = this.layers[i].verticalOffset + modifiedScrollAmount;
        // Only scroll if the images don't move offscreen
        if ((newOffset >= this.layers[i].img.height) || newOffset < 0) {
            return;
        } else {
            this.layers[i].verticalOffset = newOffset;
        }
    }
}

That's basically all of the logic we need.

Now to actually show a parallax background and be able to scroll it manually we have to do some more setup:

let background;

function setup() {
    const context = canvas.getContext('2d');
    //context.fillRect(50,50,500,500);
    background = new ParallaxBackground(context);
    background.addLayers(layers);
    //background.addLayer(layers[1], -1, 0, 0, 10);
    console.log(background.layers);

    background.render(DrawMode.Background);
    background.render(DrawMode.Foreground);
}

window.onload = function() {
    setup();
};

// Scroll the background with the arrow keys
document.addEventListener('keydown', function(event) {
    if(event.key == "ArrowLeft") {
        background.scrollHorizontal(-1);
    } else if(event.key == "ArrowRight") {
        background.scrollHorizontal(1);
    } else if (event.key == "ArrowUp") {
        background.scrollVertical(1);
    } else if (event.key == "ArrowDown") {
        background.scrollVertical(-1);
    }
    background.render(DrawMode.Background);
    background.render(DrawMode.Foreground);
});