Skip to main content

Offscreen canvas inside Web Worker

How to run Konva in a Web Worker?

Warning! This demo is VERY EXPERIMENTAL! It may not work in many browsers. Check Offscreen canvas capability tabletv.

With some extra work we can render Konva stage inside a Web Worker using Offscreen Canvas for performance reasons or for some crazy ideas.

You can use a webworker to make some visualizations with Konva.

But one of the main features of Konva is its interactivity (full events support for canvas shapes). And there are no DOM events inside a web worker. So we have to write some kind of "proxy" to pass all DOM events inside Konva engine. That way we can have interactive objects inside web worker too.

This demo is adopted from Jumping bunnies performance stress test.

You may need to write more code to cover more functions and different edge cases (such as HDPI screen support).

Instructions: there are two interactive objects on the stage. "Add buttons" and a draggable red circle. Try to add more bunnies or drag the circle.

All you see on that screen is rendered inside another javascript thread!. So it should not block main JS thread of the current page.

// main.js
const workerCode = `
// load konva framework
importScripts('https://unpkg.com/konva@9/konva.min.js');

// monkeypatch Konva for offscreen canvas usage
Konva.Util.createCanvasElement = () => {
  const canvas = new OffscreenCanvas(1, 1);
  canvas.style = {};
  return canvas;
};

// now we can create our canvas content
var stage = new Konva.Stage({
  width: 200,
  height: 200,
});

var layer = new Konva.Layer();
stage.add(layer);

var topGroup = new Konva.Group();
layer.add(topGroup);

// counter will show number of bunnies
var counter = new Konva.Text({
  x: 5,
  y: 35,
});
topGroup.add(counter);

// "add more bunnies" button
var button = new Konva.Label({
  x: 5,
  y: 5,
  opacity: 0.75,
});
topGroup.add(button);

button.add(
  new Konva.Tag({
    fill: 'black',
  })
);

button.add(
  new Konva.Text({
    text: 'Push me to add bunnies',
    fontFamily: 'Calibri',
    fontSize: 18,
    padding: 5,
    fill: 'white',
  })
);

// draggable circle to show interactivity
var circle = new Konva.Circle({
  x: stage.width() / 2,
  y: stage.height() / 2,
  radius: 20,
  fill: 'red',
  draggable: true,
});
topGroup.add(circle);

self.onmessage = function (evt) {
  // when canvas is passes we can start our worker
  if (evt.data.canvas) {
    var canvas = evt.data.canvas;
    stage.setSize({
      width: canvas.width,
      height: canvas.height,
    });

    const ctx = canvas.getContext('2d');

    layer.on('draw', () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.drawImage(layer.getCanvas()._canvas, 0, 0);
    });
  }
  // emulate some drag&drop events
  if (evt.data.eventName === 'mouseup') {
    Konva.DD._endDragBefore(evt.data.event);
  }
  if (evt.data.eventName === 'touchend') {
    Konva.DD._endDragBefore(evt.data.event);
  }
  if (evt.data.eventName === 'mousemove') {
    Konva.DD._drag(evt.data.event);
  }
  if (evt.data.eventName === 'touchmove') {
    Konva.DD._drag(evt.data.event);
  }
  if (evt.data.eventName === 'mouseup') {
    Konva.DD._endDragAfter(evt.data.event);
  }
  if (evt.data.eventName === 'touchend') {
    Konva.DD._endDragAfter(evt.data.event);
  }

  // pass incoming events into the stage
  if (evt.data.eventName) {
    const event = evt.data.eventName.replace('mouse', 'pointer');
    stage['_' + event](evt.data.event);
  }
};

function requestAnimationFrame(cb) {
  setTimeout(cb, 16);
}

async function runBunnies() {
  const imgBlob = await fetch('https://konvajs.org/assets/bunny.png').then(
    (r) => r.blob()
  );
  const img = await createImageBitmap(imgBlob);

  var bunnys = [];
  var gravity = 0.75;

  var startBunnyCount = 100;
  var isAdding = false;
  var count = 0;
  var amount = 10;

  button.on('mousedown', function () {
    isAdding = true;
  });

  button.on('mouseup', function () {
    isAdding = false;
  });

  for (var i = 0; i < startBunnyCount; i++) {
    var bunny = new Konva.Image({
      image: img,
      transformsEnabled: 'position',
      x: 10,
      y: 10,
      listening: false,
    });

    bunny.speedX = Math.random() * 10;
    bunny.speedY = Math.random() * 10 - 5;

    bunnys.push(bunny);
    counter.text('Bunnies number: ' + bunnys.length);
    layer.add(bunny);
  }
  topGroup.moveToTop();

  function update() {
    var maxX = stage.width() - 10;
    var minX = 0;
    var maxY = stage.height() - 10;
    var minY = 0;
    if (isAdding) {
      for (var i = 0; i < amount; i++) {
        var bunny = new Konva.Image({
          image: img,
          transformsEnabled: 'position',
          x: 0,
          y: 0,
          listening: false,
        });
        bunny.speedX = Math.random() * 10;
        bunny.speedY = Math.random() * 10 - 5;
        bunnys.push(bunny);
        layer.add(bunny);
        counter.text('Bunnies number: ' + bunnys.length);
        count++;
      }
      topGroup.moveToTop();
    }

    for (var i = 0; i < bunnys.length; i++) {
      var bunny = bunnys[i];
      bunny.setX(bunny.getX() + bunny.speedX);
      bunny.setY(bunny.getY() + bunny.speedY);
      bunny.speedY += gravity;
      if (bunny.getX() > maxX - img.width) {
        bunny.speedX *= -1;
        bunny.setX(maxX - img.width);
      } else if (bunny.getX() < minX) {
        bunny.speedX *= -1;
        bunny.setX(minX);
      }

      if (bunny.getY() > maxY - img.height) {
        bunny.speedY *= -0.85;
        bunny.setY(maxY - img.height);
        if (Math.random() > 0.5) {
          bunny.speedY -= Math.random() * 6;
        }
      } else if (bunny.getY() < minY) {
        bunny.speedY = 0;
        bunny.setY(minY);
      }
    }
    layer.drawScene();
    requestAnimationFrame(update);
  }
  update();
}

runBunnies();
`;

// Create a blob from the worker code
const blob = new Blob([workerCode], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));

const canvas = document.createElement('canvas');
document.body.appendChild(canvas);
canvas.style.border = '1px solid black';
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

// transfer control of the canvas to the worker
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);

// proxy all events
const events = [
  'mousedown',
  'mouseup',
  'mousemove',
  'mouseenter',
  'mouseleave',
  // 'click',
  // 'dblclick',
  'touchstart',
  'touchend',
  'touchmove',
];

events.forEach((eventName) => {
  canvas.addEventListener(eventName, (event) => {
    worker.postMessage({
      eventName,
      event: {
        clientX: event.clientX,
        clientY: event.clientY,
        type: event.type,
        button: event.button,
      },
    });
  });
});