Let there be light

March 6, 2012

Just very recently, one my readers sent me a mail asking which was the most efficient way to add support for day/night cycles in tile-based games such as in Tourist Resort, the final project of my book. I promised him that I'd try to write an article about this, so here it is.

By day/night cycles, I'm guessing that he was referring to the fact that as the sun sets, the tiles should get darker, and when the sun rises the tiles should get lightened up.

Before we discuss how to achieve that effect efficienty, we first need to talk about lights.

For the purpose of this article, and for the sake of simplicity, we're going to identify just 2 different types of lights:

  • The first type of light is the "ambient" light. In this case, one (or multiple) colours blend with the original tile colour using a special blending method called "multiply" which we'll discuss later on. These sort of light is evenly distributed around the map. Hint: Day/night cycles can be accomplished by using this type of light.
  • The second type of light that we're going to discuss is the "free light". To put it simply, this kind of light can be used to illuminate an specific area located within a certain radius.

Let's start by grabbing the original snippet of code that I used on the code repository of my book that draws an isometric map where you can place some buildings (Example 13).

You'll also need the following textures:

BuildingDefault Tile

 

Before we implement lights in our tile-based map we're going to focus on how to display a single tile, or rather, just an image. Let's look at the following code:

document.addEventListener('DOMContentLoaded', function() {
    var canvas = document.getElementById('canvas'),
        c = canvas.getContext('2d'),
        building = new Image();

    building.src = "icecream.png";

	// When the building image has finished loading, resize the canvas and draw the image on it
    building.addEventListener('load', function() {
        
        canvas.width = building.width;
        canvas.height = building.height;

        c.drawImage(this, 0, 0, building.width, building.height);

    });
});

Quite simple, right? As soon as the page is loaded it displays the image of a building inside a canvas element. Nothing fancy.

Now, I wonder what would happen if we could blend that building picture with a solid colour such as dark blue or a soft orange using the 'multiply' blending algorithm...?

Blends

Ahh... that's interesting... isn't it?

The multiply effect, included by default by any decent image editing software, can be accomplished by cycling all the pixels of two images and for each RGA value inside each pixel we need to apply the following formula:

(Top Colour Value * Bottom Colour Value) / 255

However, if any of the two images (either the one on the top, or the one on the bottom) has a transparent or semi-transparent pixel, we'll also need to perform alpha compositing. The algorithm to do the alpha compositing is quite simple and looks like this:

(Top Colour Value * Alpha Value) + (Bottom Colour Value * (1 - Alpha Value))

How can we do that using canvas then? Well, it's simple: We need to use the ImageData functions getImageData() and putImageData().

But before we get to that part we're going to create a class called "RGBA":

var RGBA = function(r, g, b, a) {
    this.R = r || 0;
    this.G = g || 0;
    this.B = b || 0;
    this.A = a || 0.5;
}

The purpose of this extremely simple class will be to specify which colour we want to blend the building image with. For example, based on the image displayed before, it'd make sense to create one object for the night, another for the sunrise, sunset and so on.

Then, we're going to create two functions, multiplyValues() and alphaComposite(). The purpose of these two functions is pretty obvious.

function multiplyValues(t, b) {
    return (t * b) / 255;
}

function alphaComposite(mv, ov, a) {
    return (mv * a) + (ov * (1 - a));
}

Finally, we're going to make another function called multiplyImage() that will take as a parameter any image and an RGBA object, and will return a canvas object with the "multiplied" image.

function multiplyImage(image, targetColour) {
    var canvas = document.createElement('canvas');
         c = canvas.getContext('2d');
    
    // Resize the "multiplied building" canvas so that it has the same size as the building image
    canvas.width = image.width;
    canvas.height = image.height;

    // Draw the building on the original canvas
    c.drawImage(image, 0, 0, canvas.width, canvas.height);

    // There's a (much) faster way to cycle through all the pixels using typed arrays, 
    // but I'm playing it safe so that the example works in all browsers.
    var imageData = c.getImageData(0, 0, canvas.width, canvas.height),
         imageDataPixels = imageData.data;

    for (var i = 0, len = imageDataPixels.length; i < len; i += 4) {
        var r = multiplyValues(targetColour.R, imageDataPixels[i]),
             g = multiplyValues(targetColour.G, imageDataPixels[i + 1]),
             b = multiplyValues(targetColour.B, imageDataPixels[i + 2]);

        imageDataPixels[i] = alphaComposite(r, imageDataPixels[i], targetColour.A);
        imageDataPixels[i + 1] = alphaComposite(g, imageDataPixels[i + 1], targetColour.A);
        imageDataPixels[i + 2] = alphaComposite(b, imageDataPixels[i + 2], targetColour.A);
    }

    c.putImageData(imageData, 0, 0);

    // At this point our canvas will contain the "multiplied" image 
    // and will be ready to use with context.drawImage()
    return canvas;
}

Let's put it all together:

var RGBA = function(r, g, b, a) {
    this.R = r || 0;
    this.G = g || 0;
    this.B = b || 0;
    this.A = a || 0.5;
}

function multiplyValues(t, b) {
    return (t * b) / 255;
}

function alphaComposite(mv, ov, a) {
    return (mv * a) + (ov * (1 - a));
}

function multiplyImage(image, targetColour) {
    var canvas = document.createElement('canvas');
         c = canvas.getContext('2d');
    
    // Resize the "multiplied building" canvas so that it has the same size as the building image
    canvas.width = image.width;
    canvas.height = image.height;

    // Draw the building on the original canvas
    c.drawImage(image, 0, 0, canvas.width, canvas.height);

    // There's a (much) faster way to cycle through all the pixels using typed arrays, 
    // but I'm playing it safe so that the example works in all browsers.
    var imageData = c.getImageData(0, 0, canvas.width, canvas.height),
         imageDataPixels = imageData.data;

    for (var i = 0, len = imageDataPixels.length; i < len; i += 4) {
        var r = multiplyValues(targetColour.R, imageDataPixels[i]),
             g = multiplyValues(targetColour.G, imageDataPixels[i + 1]),
             b = multiplyValues(targetColour.B, imageDataPixels[i + 2]);

        imageDataPixels[i] = alphaComposite(r, imageDataPixels[i], targetColour.A);
        imageDataPixels[i + 1] = alphaComposite(g, imageDataPixels[i + 1], targetColour.A);
        imageDataPixels[i + 2] = alphaComposite(b, imageDataPixels[i + 2], targetColour.A);
    }

    c.putImageData(imageData, 0, 0);

    // At this point our canvas will contain the "multiplied" image 
    // and will be ready to use with context.drawImage()
    return canvas;
}

document.addEventListener('DOMContentLoaded', function() {
    var building = new Image(),
         multipliedBuilding = null;

    building.src = "icecream.png";

    // Wait until the building image has finished loading
    building.addEventListener('load', function() {
        var canvas = document.getElementById('canvas'),
             c = canvas.getContext('2d');

        multipliedBuilding = multiplyImage(building, new RGBA(255, 0, 0, 0.5));
        canvas.width = multipliedBuilding.width;
        canvas.height = multipliedBuilding.height;

        c.drawImage(multipliedBuilding, 0, 0);
    });
});

If you open this example you should see the following output:

 

At this point we can start to get creative. Try some of the examples shown below:

  • Test 1: Change the lightning conditions by clicking on the buttons
  • Test 2: Automatically cycle through all the lightning conditions

WORK IN PROGRESS