Link Search Menu Expand Document

11: Using ImageData


Download code
11-imagedata.zip
Live example
examples/11-imagedata/index.html

In the last section, we moved our rendering calculations into a web worker, but this didn’t deliver the performance improvements we hoped for – in fact, it made everything significantly slower.

In this section, we’ll meet three new JavaScript features that we can use to speed up our rendering process.

Clamped Arrays

A clamped array works like a regular array, but will automatically clamp any values that are too big or small to fit in the datatype of each array element.

We’re going to use a Uint8ClampedArray to transfer blocks of pixel data between our worker and the main browser thread.

UInt8 is shorthand here for an unsigned 8 bit integer, which can store any value from 0 through 255 (0x00 through 0xFF) – which, by a happy coincidence, is exactly what we need for each of the red, green, and blue values in our ray-tracer’s color model.

ImageData

ImageData is a JavaScript data structure for, well, image data.

We can create a new ImageData by passing it a Uint8ClampedArray of pixel data, plus a width and height.

ImageData is expecting FOUR color channels - red, green, blue, and alpha (used for transparency). We’re not using transparency at the moment, so we need to set every fourth element in the array to 255 (fully opaque).

Note that this means the length of the array must be exactly width * height * 4 - if it’s not, the ImageData() constructor will throw an exception.

const width = 256;
const height = 128; 
let rgbaData = new Uint8ClampedArray(width * height * 4);
rgbaData.fill(0);
let imageData = new ImageData(rgbaData, width, height);

canvas.putImageData

Finally, the canvas has a method called putImageData(data, x, y), which is a heavily optimised (i.e. very fast) method for rendering a chunk of pixel data onto a canvas at a specific location.

Modifying our renderer to use ImageData

We’re going to modify our renderer code so that the worker will add pixels to an array buffer, and then when it has a full row of pixel data, it’ll send a mesage to the main thread saying “hey, here’s a row of pixels for you!” and the main thread will draw that row using putImageData.

It’s not quite as simple as just rendering every row in turn, because we can specify a step parameter to speed up renders by rendering blocks of pixels instead of each individual pixel, so we need to account for this: if step==4, say, then each callback will draw a width x 4 strip of pixels.

Here’s the modified code for our worker.js:

// worker.js

import { Renderer } from './modules/renderer.js';
import * as ExampleScenes from './scenes/examples.js';

function makeCallback(width, rowsPerCallback = 1) {    
    let rgbaData = new Uint8ClampedArray(width * 4 * rowsPerCallback);
    let yOffset = 0;
    return function(x, y, color) {
        let offset = ((y % rowsPerCallback) * width + x) * 4; // each rgba takes four array elements
        rgbaData.set(color.rgba, offset);
        if (offset + 4 == rgbaData.length) {
            let imageData = new ImageData(rgbaData, width, rowsPerCallback);
            let data = { command: 'putImageData', x: 0, y: yOffset, imageData: imageData };
            self.postMessage(data);
            yOffset += rowsPerCallback;
        }
    }    
}


self.addEventListener('message', function (message) {
    let data = message.data;
    switch (data.command) {
        case 'render':
            let renderer = new Renderer(data.width, data.height);
            let scene = ExampleScenes.AssortedShapes();
            let callback = makeCallback(data.width, 20);
            renderer.render(scene, callback);
            self.close();
            self.postMessage({ command: 'finished' });
            break;
    }
});

The makeCallback method here is a function that returns another function; when we call makeCallback, it allocates the array to stored the pixel data, and this array is then captured by the scope of the inner function and reused over and over again so we’re not having to allocate more arrays.

Once we’ve got a full row of pixels (which happens when x + step == width), we create a new ImageData using that pixel data, and post a putImageData message to the main browser thread. We don’t need to clear or reset the array; it’ll just get overwritten during the next pass.

Finally, in main.js, we need to add a new case to our message handler, so that when we receive a putImageData message, we call ctx.putImageData. We can also remove the fillRect case and the paint method, since these will never be called by our worker:

// main.js

let canvas = document.getElementById('my-canvas');
let ctx = canvas.getContext('2d');
let renderButton = document.getElementById('render-button');
let cancelButton = document.getElementById('cancel-button');

function paintPixel(x, y, color) {
    var rgb = `rgb(${color.r},${color.g},${color.b})`;
    ctx.fillStyle = rgb;
    ctx.fillRect(x, y, 1, 1);
}

function handleMessageFromWorker(message) {
    let data = message.data;
    switch (data.command) {
        case 'putImageData':
            ctx.putImageData(data.imageData, data.x, data.y);
            break;
        case 'fillRect':
            let color = { r: data.r, g: data.g, b: data.b };
            paintPixel(data.x, data.y, color);
            break;
        case 'finished':
            updateStatus(false);
            break;
    }
}

function render() {
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    let worker = new Worker('worker.js', { type: 'module' });
    worker.addEventListener('message', handleMessageFromWorker);
    cancelButton.addEventListener("click", function () {
        worker.terminate();
        updateStatus(false);
    });
    worker.postMessage({ command: 'render', width: canvas.width, height: canvas.height });
    updateStatus(true);
};


function updateStatus(running) {
    renderButton.disabled = running;
    cancelButton.disabled = !running;
}

renderButton.addEventListener("click", render);
render();

We now have a renderer that runs in a background thread, transfers blocks of pixels far more efficiently than the previous version, and which we can start and stop as required.

References:


Download code
11-imagedata.zip
Live example
examples/11-imagedata/index.html