Drawing GBA Tiles on an HTML5 Canvas

I've recently been working on an electron application that disassembles and displays various components of Gameboy Advanced (GBA) games, like graphics, sounds, and text strings. The graphics rendering has easily been the most obnoxious part, in no small part due to my terrible design decisions. I hope to prevent pain for anyone who attempts this in the future.

A section of Metroid Fusion's data

Tiles

GBA games store graphics in two forms: uncompressed and compressed. While this will focus on uncompressed graphics, the same ideas can be applied to compressed graphics - you just need to uncompress them first.

Graphics are laid out from left to right in memory, but constructed into 8x8 tiles. Each byte read represents 2 pixels, with the hi 4 bits representing one pixel and the lo 4 bits representing the other. Because the GBA uses a little-endian encoding scheme, the lo bits are the first pixel, with the high being the second.

 hi  lo
1234 6789
 2    1   pixel

To get these values, we can use a bit-mask. For example, in Javascript you could do something like:

for( var i = 0; i < section.length; i++ ) {
    let pixel1 = section[i] & 0b00001111);
    let pixel2 = (section[i] & 0b11110000) >> 4);
}

You could also convert section to a string and then use substring, if you make poor choices like I originally did. My excuse is that I did not know EMCAScript 6 added support for binary numbers.

Since the data does not tell you block boundaries, managing that information is your responsibility. The only assumption you can accurately make is that all blocks will lie on a byte boundary. Structuring this data is also going to be based on your language of choice and how you intend to display it. Due to Javascript's abysmal array insertion times, I decided to go with a hashmap, where each incrementing key points to an 2-dimensional array representing each block. The first axis represented the row, the second the individual pixel:

tileMap = {};

tileMap[blockIndex][rowIndex].push(pixel);

The only requirement is that you track when you have reached the end of a row, and the end of the tile, while looping through your data. It looks hideous, but it has reasonable execution speeds. The majority of your slow-down will come from rendering.


Rendering

One of the major mistakes I made when originally writing this code was to use divs to represent each pixel. This has a terrible rendering cost, both in time and resources, and I do not suggest it. The only caveat is if you are trying to quickly test your parsing code.

The better way to do this is to use a canvas element. The initialization code is pretty straight-forward:

<canvas id="canvas">
let canvas = document.getElementById("canvas");
let ctx = canvas.getContext("2d"); 

To render the tiles, we first need to assign colors to each, as the pixel data only contains a number representing the index in a color palette to display. These can be found in the rom, but for easy testing, we can assign them to various shades of gray. With 4 bits, there are 16 possible colors:

// Init pallete
for( let i = 0; i < 16; i++ ) {
    pallete[i] = "rgb(" + i * 16 + "," + i * 16 + "," + i * 16 + ")";
}

// Helper function to grab pixel color
getPalleteColor: function(pixel) {
    return pallete[pixel];
}

To draw the pallet, we can follow the parsing code:

let pixelIndex = 0;
let tileIndex = 0;
let rowIndex = 0;
let tileRowIndex = 0;

const TILESIZE = 8;

// Fastest way to clear the display of data
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
   
// Loop through all the tiles in the map and render them  
for( const tile in tileMap ) {
    for( const row in tileMap[tile] ) {
        for( const pixel in tileMap[tile][row] ) {
            let pixelData = tileMap[tile][row][pixel];
            
            ctx.fillStyle = this.getPalleteColor(pixelData); 
            ctx.fillRect(
                            pixelIndex + (tileIndex * TILESIZE), 
                            rowIndex + (tileRowIndex * TILESIZE), 
                            1, 1);
            pixelIndex++;
        }
        pixelIndex = 0;
        rowIndex++;
    }
    rowIndex = 0;
    tileIndex++;
    if( tileIndex >= tilesPerRow ) {
        tileRowIndex++;
        tileIndex = 0;
    }
} 

The major changes is the addition of the tileIndex and tileRowIndex, which are used to offset the pixel blocks as they are drawn from left to right on the canvas. tilesPerRow can either be hard-coded, or calculated based on the canvas-size by:

let tilesPerRow = Math.floor(canvas.width / (this.tileSize); 

Scaling

To scale the display, you can introduce a pixelSize member that you can pass into fillRect:

ctx.fillRect(
    pixelIndex + (tileIndex * pixelSize * TILESIZE), 
    rowIndex + (tileRowIndex * pixelSize * TILESIZE), 
    pixelSize, pixelSize);