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.

Web_Worker.htmlview raw
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Konva Offscreen Canvas Demo</title>
<style>
body {
margin: 0;
padding: 0;
overflow: hidden;
background-color: #f0f0f0;
}
</style>
</head>

<body>
<canvas id="canvas"></canvas>
<script>
var htmlCanvas = document.getElementById('canvas');
htmlCanvas.width = window.innerWidth;
htmlCanvas.height = window.innerHeight;

var hasOffscreenSupport = !!htmlCanvas.transferControlToOffscreen;
if (hasOffscreenSupport) {
var offscreen = htmlCanvas.transferControlToOffscreen();

var w = new Worker('./Web_Worker.js');
// pass canvas into webworker, so we can do all rendering inside it
w.postMessage({ canvas: offscreen }, [offscreen]);

// "proxy" all DOM events from canvas into Konva engine
var EVENTS = [
'mouseenter',
'mousedown',
'mousemove',
'mouseup',
'mouseout',
'touchstart',
'touchmove',
'touchend',
'mouseover',
'wheel',
'contextmenu',
'pointerdown',
'pointermove',
'pointerup',
'pointercancel',
'lostpointercapture'
];
EVENTS.forEach(eventName => {
htmlCanvas.addEventListener(eventName, e => {
w.postMessage({
eventName,
event: {
clientX: e.clientX,
clientY: e.clientY
}
});
});
});
} else {
htmlCanvas
.getContext('2d')
.fillText(
'🛑 Sorry, your browser does not support Offscreen rendering...',
20,
20
);
}
</script>
</body>
</html>

And the code of the worker:

Worker Codeview raw
// load konva framework
importScripts('https://unpkg.com/[email protected]/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);
layer.draw();

self.onmessage = function(evt) {
// when canvas is passes we can start our worker
// we can try to use that canvas for the layer with some manual replacement (and probably better performance)
// but for simplicity we will just copy layer content into passed canvas
if (evt.data.canvas) {
var canvas = evt.data.canvas;
// adapt stage size
// we may need to add extra event to resize stage on a fly
stage.setSize({
width: canvas.width,
height: canvas.height
});

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

// Konva.Layer has support for "draw" event
// so every time the layer is re-rendered we need to update the canvas
layer.on('draw', () => {
// clear content
ctx.clearRect(0, 0, canvas.width, canvas.height);
// draw layer content
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) {
stage['_' + evt.data.eventName](evt.data.event);
}
};

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

// that function is large and adapted from bunnies demo
// the only interesting part here is how to load images to use for Konva.Image
async function runBunnies() {
const imgBlob = await fetch('https://konvajs.org/assets/bunny.png').then(r =>
r.blob()
);
// use "createImageBitmap" instead of "new window.Image()"
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();
layer.draw();

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();
Next