gyx1000.dev

Use OffscreenCanvas in Web Worker to generate images

Context

My personal project PixZle lets anyone fill in a 32x32 pixel tile to participate in the creation of a potentially infinite image. The project's home page displays the entire image, and allows users to move around it using mouse or gesture movements. The large image is generated as the user browses as the user moves through it. To do this, I use an HTMLCanvas on which I draw the necessary tiles. ImageData are generated on the fly. If this task is performed on the main thread, the user's UI will freeze/lag. That's why I used a WebWorker to do this.

As Remix is for me one of the best current DX Frameworks, PixZle was created with it. To make it easier for me to work with WebWorkers, vite is used to generate bundles. vite:worker does the job of managing the WebWorker.

Demo

Before we get to the technical side, here's a quick demonstration. Move your mouse cursor or finger inside the rectangle below. With the worker activated, a red square will follow your path and the movement of it will be fluid. Otherwise, the UI will freeze and the experience will be uninteresting. The image generated to fill the background is 4800x4800px!

✨ Don't overdo it or your CPU will burn out ✨

Implementation

We'll now go through the various parts of the implementation. Please note that in order to generate ImageData from a Worker, the web browser must support OffscreenCanvas.

Generate Tile

First, here's the function I created to generate the background image. This function receives a number of tiles x and y to generate and returns a randomized rgba ImageData.

const TILE_PX_SIZE = 32;
 
export function generateTiles(x: number, y: number) {
  const imageWidth = x*TILE_PX_SIZE;
  const imageHeight =  y*TILE_PX_SIZE;
  const canvas = new OffscreenCanvas(imageWidth, imageHeight);
  const ctx = canvas.getContext('2d');
  if (!ctx) return undefined;
 
  const image = ctx.createImageData(imageWidth, imageHeight);
  for (let tC=0; tC<x; ++tC) {
    for (let tR=0; tR<y; ++tR) {
      const color = pickRandomColor();
      const [r, g, b] = color;
      // generate pixels
      for (let pC=0; pC<TILE_PX_SIZE; ++pC) {
        for (let pR=0; pR<TILE_PX_SIZE; ++pR) {
          // get the current pixels image data position
          const pixIdx = (
            (tR * TILE_PX_SIZE * x * TILE_PX_SIZE) +
            (tC * TILE_PX_SIZE) +
            (pR * TILE_PX_SIZE * x) +
            pC) * 4;
 
          image.data[pixIdx] = r;
          image.data[pixIdx+1] = g;
          image.data[pixIdx+2] = b;
          image.data[pixIdx+3] = 255; // alpha 1
        }
      }
    }
  }
 
  return image;
}

Web worker

The web worker code is very simple. It declares an onmessage function that will be called by the component and returns the image generated using the above function.

import { generateTiles } from "./utils"
 
onmessage = function() {
  this.postMessage(
    generateTiles(150, 150)
  )
}

Component

There are a number of important points to note about the component.

  • The import of PixelWorker suffixed with ?worker vite:worker 🤩
  • The instantiation of the worker in a useEffect, as Remix's SSR would not be able to do this.
  • The worker terminate declared in the useEffect cleanup.
import {
    useEffect,
    useRef,
    useState,
    Touch,
    MouseEvent
} from 'react';
// vite:worker magical import 🤩
import PixelWorker from './pixels-worker?worker';
import { generateTiles } from './utils';
 
export default function DemoPixels() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const mouseRef = useRef<{x: number, y: number}>({
    x: 0,
    y: 0
  });
  const imageRef = useRef<ImageData>(null);
  const [worker, setWorker] = useState<Worker | null>(null);
 
  // handle touch | mouse move
  const processMove = (e: Touch | MouseEvent) => {
    mouseRef.current = {
      x: e.clientX,
      y: e.clientY
    };
 
    if (worker !== null) {
      // send message to worker
      worker.postMessage(null);
    }
 
    draw();
  }
 
  const draw = () => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    if (!ctx) return;
 
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    if (imageRef.current) {
      ctx.putImageData(imageRef.current, 0, 0);
    }
 
    // draw the cursor
    ctx.fillRect(
      mouseRef.current.x - 50,
      mouseRef.current.y - 50,
      100,
      100
    );
  }
 
  draw();
 
  useEffect(() => {
    const worker = new PixelWorker();
    if (worker !== null) {
      worker.onmessage = (e) => {
        // this callback is called when
        // the worker finish the job
        imageRef.current = e.data;
        draw();
      };
    }
    setWorker(worker);
    return () => {
      // don't forget to kill your worker
      worker.terminate();
    }
  }, []);
 
  return <div
      className='border border-sky-500 py-4'
      onMouseMove={(e) => {
        processMove(e);
      }}
      onTouchMove={(e) => {
        const touch = e.touches[0];
        if (touch) {
          processMove(touch);
        }
      }}>
      <canvas
        className='mx-auto touch-none select-none'
        height={300}
        width={300}
        ref={canvasRef}></canvas>
    </div>
}

Conclusion

In order to keep the code as small as possible in this post, I've deliberately removed the parts of the component that handle compatibility checks. If you implement this type of solution for yourself, don't forget to integrate them.

Don't hesitate to send me your comments / criticisms on Twitter|X

And don't forget to create your 32x32 pixels tile on PixZle