Use OffscreenCanvas in Web Worker to generate images

How to use OffscreenCanvas in a WebWorker to generate images without blocking the browser's main thread.

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 HTML canvas on which I draw the necessary tiles. Image data are generated on the fly. If this task is performed on the main thread, the user's UI will freeze or lag. That's why I used a Web Worker to do this.

To make the Web Worker integration easier, the project used a bundling setup that could handle worker entry points cleanly.

Demo

Before we get to the technical side, here's a quick demonstration concept. Move your mouse cursor or finger inside the rectangle. With the worker activated, a red square follows your path fluidly. Otherwise, the UI freezes and the experience becomes unpleasant. The generated image used to fill the background is 4800x4800 px.

Worker enabled

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 browser must support OffscreenCanvas.

Generate tile data

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

const COLORS = [
  [127, 127, 127],
  [12, 12, 12],
  [250, 250, 250],
  [222, 222, 222],
  [20, 20, 20],
  [53, 53, 53],
];

const TILE_PX_SIZE = 32;

function getRandomInt(max: number) {
  return Math.floor(Math.random() * max);
}

function pickRandomColor() {
  return COLORS[getRandomInt(COLORS.length)];
}

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;

      for (let pC = 0; pC < TILE_PX_SIZE; ++pC) {
        for (let pR = 0; pR < TILE_PX_SIZE; ++pR) {
          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;
        }
      }
    }
  }

  return image;
}

Web Worker

The Web Worker code is very simple. It declares an onmessage handler that is 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 several important points to note about the original component implementation.

- The import of PixelWorker is handled by the build tooling. - The instantiation of the worker happens inside a useEffect, so it only runs in the browser. - The worker termination is declared in the useEffect cleanup. - A guard with generating.current avoids flooding the worker with concurrent image generation requests.

import { useEffect, useRef, useState, Touch, MouseEvent } from "react";
import PixelWorker from "./pixels-worker?worker";
import { generateTiles } from "./utils";

export default function DemoWorker() {
  const [workerEnabled, setWorkerEnabled] = useState(true);
  const [compatible, setCompatible] = useState(true);

  const canvasRef = useRef<HTMLCanvasElement>(null);
  const mouseRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
  const imageRef = useRef<ImageData>(null);
  const generating = useRef<boolean>(false);
  const [worker, setWorker] = useState<Worker | null>(null);

  const processMove = (e: Touch | MouseEvent) => {
    mouseRef.current = {
      x: e.clientX,
      y: e.clientY,
    };

    if (workerEnabled) {
      if (worker !== null && generating.current === false) {
        generating.current = true;
        worker.postMessage(null);
      }
    } else {
      imageRef.current = generateTiles(150, 150);
    }

    draw();
  };

  const draw = () => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const { top, left } = canvas.getBoundingClientRect();

    ctx.clearRect(0, 0, canvas.width, canvas.height);
    if (imageRef.current) {
      ctx.putImageData(imageRef.current, 0, 0);
    }
    ctx.fillStyle = "rgb(201, 56, 76)";
    ctx.fillRect(mouseRef.current.x - left - 50, mouseRef.current.y - top - 50, 100, 100);
  };

  draw();

  useEffect(() => {
    if (!window.Worker || !window.OffscreenCanvas) {
      setCompatible(false);
      return () => {};
    }

    const worker = new PixelWorker();
    if (worker !== null) {
      worker.onmessage = (e) => {
        imageRef.current = e.data;
        generating.current = false;
        draw();
      };
    }
    setWorker(worker);
    return () => {
      worker.terminate();
    };
  }, []);

  return (
    <>
      {compatible === true ? (
        <div>
          <div className="mb-4 text-center">
            <button
              className="bg-white hover:bg-gray-100 text-gray-800 font-semibold py-2 px-4 border border-gray-400 rounded shadow"
              type="button"
              onClick={() => {
                setWorkerEnabled((cur) => !cur);
              }}
            >
              {workerEnabled ? "Disable" : "Enable"} worker
            </button>
          </div>
          <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>
        </div>
      ) : (
        <p>Unfortunately, your browser is not compatible for this demonstration.</p>
      )}
    </>
  );
}

Conclusion

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

Don't hesitate to send me your comments or criticism on Twitter/X.