heckx2

Sprite font for a canvas game: Part 2 - Adding color

May 17, 2020

This post is a direct continuation of the post Sprite font for a canvas game: Part 1 and I am going to continue where I left off. Make sure to check it out if you’ve missed it, because most of the code is going to be used here as well.


The entire concept of a sprite font is based on the sprite image of characters and some description of how to extract the characters from the image and render them onto a canvas.

Idea

When we draw the character we simply copy-paste it from the source image onto a canvas and apply some transformations like translation and scaling. But the color of the character always stays the same because it is basically hardcoded into the image.

The most obvious thing that comes to mind is to have a second image with the characters of different color.

Idea color

It can already be implemented using the class-based demo from the previous post - Class demo. Everything will be the same except that each instance of the font will have it’s own image source.

const blackImage = new Image();
blackImage.src = '/path/to/black-sprite.png';

const redImage = new Image();
redImage.src = '/path/to/red-sprite.png';

const blackFont = new SpriteFont(blackImage, canvas, config);
const redFont = new SpriteFont(redImage, canvas, config);

The downside is that every time you make a change to a font, you will have to update all of the images holding the versions of different colors, which can become very tedious and prone to errors.

The good news is that all of these different color versions can be created on the fly based on a single original sprite image. All of them will be stored on a separate invisible to the user canvas instance, which will be used as a source for final rendering.

Idea color canvas

In order to generate different colored versions of the original image we are going to use HTML5 Canvas composite operations.

Composite operations

Imagine a composite operation to be a function, which is called for each pixel of the canvas when you draw something. This function takes two arguments:

  • destination - the color of the pixel which is already present on the canvas
  • source - the color of the pixel from the new shape or image which is about to be drawn

After that it calculates the color of the final pixel using some formula based on the selected type, draws it onto canvas and proceeds to the next pixel.

By switching the composite operation type we can decide which color is going to be drawn. It will be taken either from destination canvas or from source image or combined from both. The question is what composite operations should be applied to the sprite image in order to colorize the characters.

Composition how

Composing color font

HTML5 Canvas has a property to control which composite operation is used for current context - context.globalCompositeOperation. Default value is source-over, which means that every time something is drawn onto canvas, it is drawn over the things that are already in there. Looking through other available types there is one operation which does exactly what we need - source-in - described as:

The new shape is drawn only where both the new shape and the destination canvas overlap. Everything else is made transparent.

For us it means that if we have an original black character drawn on the canvas and we draw some shape on top of it, the result of the operation is that the shape of the character will remain the same, but it will have the color of the new shape wherever their pixels overlap.

Composition triangle

If the shape is big enough to cover the entire character, it means that in the end the character will be fully covered with a shape color. Knowing that, we can simply draw a big rectangle of whatever color we want on top of all of the font characters and as a result they will end up having that color.

Composite all

The characters colored in red now appear on the canvas and we can use this canvas instead of the sprite image as a source for rendering the text.

Intermediary canvas

To implement all of this in code we will take a class-based demo from the previous post as a foundation.

First, we create intermediary canvas and attach it to a document. Once we are sure everything is drawn correctly, we can detach it from the document, so only the final canvas is visible to the user.

const intermediaryCanvas = document.createElement('canvas');
intermediaryCanvas.style.border = '1px solid brown';
document.body.appendChild(intermediaryCanvas);

Once the sprite image is loaded, we can access it’s width and height, and assign them to the intermediary canvas.

image.addEventListener('load', () => {
  intermediaryCanvas.width = image.naturalWidth;
  intermediaryCanvas.height = image.naturalHeight;
});

Now it has the same size as the original sprite image.

Intermediary init

Next, we draw entire sprite image onto intermediary canvas:

const intermediaryContext = intermediaryCanvas.getContext('2d');

intermediaryContext.drawImage(image, 0, 0);

Intermediary image

Now let’s switch composition operation type to source-in and draw a rectangle of any color on top of the image. Make sure to restore default composite operation type to source-over after drawing the rectangle to avoid unexpected behavior later on.

intermediaryContext.globalCompositeOperation = 'source-in';
intermediaryContext.fillStyle = 'red';
intermediaryContext.fillRect(0, 0, image.naturalWidth, image.naturalHeight);
intermediaryContext.globalCompositeOperation = 'source-over';

As promised, we get the sprite font in a different color.

Intermediary color

Finally, we use this intermediary canvas as a source image for the font by simply passing the canvas instance instead of the image instance.

const spriteFont = new SpriteFont(
  intermediaryCanvas,
  destinationCanvas,
  config,
  options,
);

… and voila!

Colored single

Live demo can be found here: Color demo

Multiple colors

Most likely you will need more than one extra color and creating a new canvas instance for each color is a bit wasteful. Instead of creating new canvases we are going to reuse a single canvas instance and just stack all of the color versions next to each other.

Canvas multiple colors

First, let’s bring together all of the code responsible for creating a color version of the characters into a single function, which can be called multiple times to generate new colors.

function generateColor(image, intermediaryCanvas, color) {
  const intermediaryContext = intermediaryCanvas.getContext('2d');

  intermediaryCanvas.width = image.naturalWidth;
  intermediaryCanvas.height = image.naturalHeight;

  intermediaryContext.drawImage(image, 0, 0);

  intermediaryContext.globalCompositeOperation = 'source-in';
  intermediaryContext.fillStyle = color;
  intermediaryContext.fillRect(0, 0, image.naturalWidth, image.naturalHeight);
  intermediaryContext.globalCompositeOperation = 'source-over';
}

It accepts an instance of original sprite image, an instance of intermediary canvas where the colored versions of the font will be stored and a color string you want to get.

generateColor(image, intermediaryCanvas, 'red');
generateColor(image, intermediaryCanvas, 'green');
generateColor(image, intermediaryCanvas, 'blue');

If at this point you call it multiple times with different colors, the new color will be drawn on top of the first one. To stack them, every time the function is called we need to resize the intermediary canvas so it could hold one more version of the characters.

Canvas multiple colors process

We also should update a call to drawImage so that each color version will be drawn with offset and not on top of each other. As we draw them vertically stacked, we only need vertical offset. The characters of the next color will start where the characters of the previous color have ended which is exactly the height of the previous state of the canvas.

function generateColor(image, intermediaryCanvas, color) {
  // ...

  const offsetX = 0;
  const offsetY = intermediaryCanvas.height;

  intermediaryCanvas.width = image.naturalWidth;
  intermediaryCanvas.height = intermediaryCanvas.height + image.naturalHeight;

  intermediaryContext.drawImage(image, offsetX, offsetY);

  // ...
}

Every time we draw an image we add up it’s height to the canvas height. There is a small issue with it though, because we might assume that by default canvas has no size and the first image will be drawn at position (0,0), but actually default canvas size is 300x150, and it will mess with our height extension logic. It can be fixed by resetting canvas width and height to 0 at the moment it is created.

const intermediaryCanvas = document.createElement('canvas');
intermediaryCanvas.width = 0;
intermediaryCanvas.height = 0;

Now we can see that if we call the function multiple times, the canvas will actually resize. But where the heck are all the characters? Why only the last one is present?

Multiple resize empty

It happens because of another HTML5 Canvas behavior - whenever canvas size is changed, it gets cleared. This is why we see only the characters of the last color - because only them are drawn after the last canvas resize. To workaround it we can copy all of the canvas content before resizing and then draw it back after resizing. Canvas methods context.getImageData and context.putImageData will help us do exactly that. The former allows to grab an area of the canvas and returns an array with each pixel color in that area, and the latter allows to put an array of pixel colors into specific area of the canvas. Note that if the canvas width or height are 0, getImageData will complain, so we should add an extra check before doing that.

function generateColor(image, intermediaryCanvas, color) {
  // ...

  // Before resizing

  let prevImageData = null;
  if (intermediaryCanvas.width > 0 && intermediaryCanvas.height > 0) {
    prevImageData = intermediaryContext.getImageData(
      0,
      0,
      intermediaryCanvas.width,
      intermediaryCanvas.height,
    );
  }

  // Resizing: code stays the same
  intermediaryCanvas.width = image.naturalWidth;
  intermediaryCanvas.height = intermediaryCanvas.height + image.naturalHeight;

  // ...

  // After resizing and generating colors - in the end of the function
  if (prevImageData !== null) {
    intermediaryContext.putImageData(prevImageData, 0, 0);
  }
}

After that all the characters are finally present and have respective colors.

Multiple resize filled


As a side note, in case your sprite image URL uses another domain than you website where you run the code, you might need to add an extra property to an image instance to avoid CORS issues when calling getImageData. To read more about that check out Allowing cross-origin use of images and canvas. The line to fix the issue is the following and should be placed near the image creation code.

image.crossOrigin = 'anonymous';

The only thing left to do is to update a SpriteFont class to be able to extract the characters from offset position. Originaly when we used a sprite image the characters started at position (0,0), but now only the very first colored character set starts at this position. The rest of the colored character sets are vertically offset depending on how much character sets are above them. To obtain the offset we can use the offsetX and offsetY variables that were already calculated in generateColor. We update the function to return the offset and them pass it on to SpriteFont constructor.

function generateColor(image, intermediaryCanvas, color) {
  // ...

  const offset = {
    x: offsetX,
    y: offsetY,
  };

  return offset;
}

The changes to SpriteFont class are very minimal as well: it has to accept the offset as an option and apply it to the calculations of the source image position. This position is calculated in drawCharacter method.

class SpriteFont {
  static DEFAULT_OPTIONS = {
    // ...
    offset: { x: 0, y: 0 },
  };

  drawCharacter(character, position = { x: 0, y: 0 }) {
    // ...

    const { offset } = this.options;

    const sourceX =
      offset.x + columnIndex * (characterWidth + horizontalSpacing);
    const sourceY = offset.y + rowIndex * (characterHeight + verticalSpacing);

    // ...
  }
}

Finally, when creating a new color, we pass on the offset to the instance of a font which is going to render text in that color.

const redOffset = generateColor(image, intermediaryCanvas, 'red');
const greenOffset = generateColor(image, intermediaryCanvas, 'green');
const blueOffset = generateColor(image, intermediaryCanvas, 'blue');

const redSpriteFont = new SpriteFont(
  intermediaryCanvas,
  destinationCanvas,
  config,
  { scale: 3, offset: redOffset },
);
const greenSpriteFont = new SpriteFont(
  intermediaryCanvas,
  destinationCanvas,
  config,
  { scale: 3, offset: greenOffset },
);
const blueSpriteFont = new SpriteFont(
  intermediaryCanvas,
  destinationCanvas,
  config,
  { scale: 3, offset: blueOffset },
);

redSpriteFont.drawText('THE DOG BARKS', { x: 10, y: 20 });
greenSpriteFont.drawText('VERY LOUDLY', { x: 10, y: 60 });
blueSpriteFont.drawText('AT THE FENCE', { x: 10, y: 100 });

Colored all

Live demo can be found here: Multi-color demo

Summary

The ideas I’ve covered in these two posts should be enough for most basic games or prototypes, but there is always a lot more to explore, like text animation and effects.

Once again, this solution might negatively influence the performance of your game, especially if you don’t have any optimization tricks to reduce the number of calls to the canvas rendering methods like drawImage.

Check out the game where I used this technique for text rendering - it’s a clone of a game called Battle City (1985) by Namco written from scratch in TypeScript:

https://dogballs.github.io/cattle-bity/

Source code is open and available on GitHub:

https://github.com/dogballs/cattle-bity


Personal blog by Michael Radionov
Software development stories