Emulating an LCD display with canvas

I have been working recently on a personal project to create a Gameboy emulator in JavaScript, in order to improve my knowledge of low-level computing principles (and for fun obviously !). One of my first tasks in this project was to reproduce in a canvas the display system as it is working on the original device : a 160*144 LCD screen with a 4 level gray scale.

The basic algorithm

Once the color for each pixel has been retrieved, the screen device has to draw this pixel for the user. My first approach was to use the fillRectangle method to display each pixel one by one on the canvas that I was using for the display. This is quite easy to do as the function just excepts the coordinates, size and color of the rectangle to draw, and I had direct access to this kind of data. Just wrap this into two for loops and I was done.

At least I thought so. The Gameboy screen draws frames at a 60Hz frequency, meaning that the whole process of drawing a frame and executing the CPU instructions between two frames should take no more than 16ms. With the method described above, I observed that the application needed an average of 40ms to do this, which was not acceptable. A quick profiling of my JavaScript process immediately pointed out the use of the fillRectangle method as the bottleneck. This is actually kind of logical: when this method is called the engine directly renders the desired rectangle to the screen, and it needed to do it 160*144 times per frame ! It would therefore be more logical to prepare the image data and display it as a whole only when it’s ready. I had found the issue but as a rookie with the canvas API I struggled a bit to find a replacement.

ImageData to the rescue

An image is just a two dimensional array of pixels, each of them being a 4-uple of byte values (red, green, blue and alpha). So what if we could just manipulate this big array of pixel data and push it to the canvas when we’re done with it ? That’s the way to go, and canvas provides the tool to do it.

First, we need to get our array of data from the 2d context, in the form of an ImageData object. This is can be done by calling the getImageData function if you want the current pixel data of your canvas, or createImageData to get an empty one. As I would fill the image data for every frame from scratch I used the second one for my purpose.

var canvas = document.getElementById('canvas');
canvas.width = 160;
canvas.height = 144;
var context = canvas.getContext('2d');
var imageData = context.createImageData(canvas.width, canvas.height);

The ImageData object contains a data property, a Uint8ClampedArray in which the values should be read four at a time to get the full pixel information (i.e. RGBA channels). It is worth noting that this array has only one dimension, because it does not need to be aware of the way the pixels are organised. It’s up to the ImageData object to organize it in multiple lines depending on the size of the target rectangle to be drawn.

For each image we now need to update the data property (just an Uint8ClampedArray) of the ImageData object for every pixel. I do this by calling a custom drawPixel function after I have processed the color to use for a given pixel. As mentioned earlier, the pixel data is a set of four values, and as I work with grey only, the first ones are identical. I set the alpha value to 255 since transparency is not supported.

function drawPixel(x, y, color, imageData) {
    imageData.data[(y * 160 + x) * 4] = color;
    imageData.data[(y * 160 + x) * 4 + 1] = color;
    imageData.data[(y * 160 + x) * 4 + 2] = color;
    imageData.data[(y * 160 + x) * 4 + 3] = 255;
};

When it’s done, the putImageData function will pass the object back to the canvas that will take care of the rendering.

context.putImageData(imageData, 0, 0);

It’s possible to apply the image data to just a portion of the canvas, that’s why there are several arguments in this function allowing to define a target rectangle to be painted.

As you can expect the gain in rendering time is tremendous and on my application it dropped to around 5ms, which is totally acceptable for my needs.