Draw on a Canvas from a Web Worker.
This is definitely not optimized for real-time application, although possible. The main goal is to render a visualization of millions of data which usually take some seconds to render.
By doing it off-the-main-thread, in a Worker, it will never block the UI.
As the OffscreenCanvas
API is still experimental,
we draw directly in
a SharedArrayBuffer
.
The drawing is done via WebAssembly
thanks to
the embedded_graphics
Rust crate, which is
instantiated in a Web Worker
.
That's why everything is asynchronous.
import { init, asyncThrottle, Style, Color, Point } from "framebuffer-worker";
const canvas = document.getElementById("canvas");
const layer = await init(canvas);
layer().then(async ({ clear, render, line }) => {
await clear();
await line({
startPoint: new Point(0, 0),
endPoint: new Point(canvas.width, canvas.height),
style: new Style(undefined, new Color(127, 127, 127), 1),
});
await render();
});
layer().then(async ({ clear, render, line }) => {
const cb = async (event) => {
const x = event.offsetX;
const y = event.offsetY;
await clear();
await Promise.all([
line({
startPoint: new Point(x, 0),
endPoint: new Point(x, canvas.height),
style: new Style(undefined, new Color(65, 105, 225), 1),
}),
line({
startPoint: new Point(0, y),
endPoint: new Point(canvas.width, y),
style: new Style(undefined, new Color(65, 105, 225), 1),
}),
]);
await render();
};
canvas.addEventListener("pointermove", asyncThrottle(cb, 16));
});
Every time you create a new layer, it will instantiate a new Worker. Every layer has to be rendered individually, though. So the time that every layer will take to render, will never affect the other layers rendering speed. At every render the layers are merged together, in the order of creation at the moment, so that you do not have to sync between layers yourself.
Currently, the rendering is not optimized if you have multiple real-time layers, because every render call its
own requestAnimationFrame
and merge layers together.
Opacity is not supported at the moment.
const canvas = document.getElementById("canvas");
const layer = await init(canvas);
layer().then(async ({ clear, render, line, circle, rectangle }) => {
// -- snip --
});
// OR
const { clear, render, line, circle, rectangle } = await layer();
Calling await clear();
will simply fill the SharedArrayBuffer
with OxO
.
It is way faster than "drawing" all pixels one by one with a specific color.
Colors are defined as (red, green, blue, alpha)
. So here it will be a transparent black.
Call await render();
every time you want the pixels to appear on the screen.
It will merge all layers together, by the order of creation. Last layer on top.
Although at every drawings (clear
, line
, ...), the buffer is modified, we keep a copy of the previous one to draw
it, until you call render
.
await line({
startPoint: new Point(0, 0),
endPoint: new Point(canvas.width, canvas.height),
// no fillColor for the line
style: new Style(undefined, new Color(255, 105, 180), 1),
});
await circle({
topLeftPoint: new Point(10, 20),
diameter: 20,
style: new Style(new Color(176, 230, 156), new Color(255, 105, 180), 2),
});
await rectangle({
topLeftPoint: new Point(50, 100),
size: new Size(100, 40),
style: new Style(new Color(176, 230, 156), new Color(255, 105, 180), 1),
radius: 3, //optional
});
await rounded_rectangle({
topLeftPoint: new Point(50, 100),
size: new Size(300, 40),
style: new Style(new Color(255, 255, 255), new Color(255, 10, 18), 1),
corners: new Corners(new Size(3, 6), new Size(9, 12), new Size(10, 10), new Size(4, 4)),
});
await ellipse({
topLeftPoint: new Point(10, 20),
size: new Size(300, 40),
style: new Style(new Color(176, 230, 156), new Color(255, 105, 180), 2),
});
await arc({
topLeftPoint: new Point(100, 240),
diameter: 130,
angleStart: new Angle(0),
angleSweep: new Angle(72),
// no fillColor for the polyline
style: new Style(undefined, new Color(127, 127, 127), 5),
});
await sector({
topLeftPoint: new Point(80, 260),
diameter: 130,
angleStart: new Angle(35),
angleSweep: new Angle(300),
style: new Style(new Color(253, 216, 53)),
});
await triangle({
vertex1: new Point(10, 64),
vertex2: new Point(50, 64),
vertex3: new Point(60, 44),
style: new Style(new Color(48, 120, 214)),
});
await polyline({
points: [
new Point(10, 64),
new Point(50, 64),
new Point(60, 44),
new Point(70, 64),
new Point(80, 64),
new Point(90, 74),
new Point(100, 10),
new Point(110, 84),
new Point(120, 64),
new Point(300, 64),
],
// no fillColor for the polyline
style: new Style(undefined, new Color(176, 230, 156), 3),
});
Only a single monospaced font is available: ProFont. There is no italic nor bold version. But the bigger the font, the bolder.
Only few sizes are available: 7, 9, 10, 12, 14, 18, and 24 pixels. You can see the rendering on the GitHub page.
The textStyle
argument is optional. The default alignment is left
and the default baseline is alphabetic
.
await text({
position: new Point(20, 20),
label: `Hello, world!`,
size: 9,
textColor: new Color(33, 33, 33),
textStyle: new TextStyle(Alignment.Center, Baseline.Middle), // optional
});
You can, since v1.1, add some interactivity. Each primitive returns a bounding box, a rectangle, which allow you to check the intersection with the pointer.
const canvas = document.getElementById("canvas");
const layer = await init(canvas);
let otherLayerApi;
layer().then(async ({ clear, render, circle }) => {
let cursor;
let boundingBoxes = new Map();
let hoverBounding;
await clear();
for (let i = 0; i < 900; i++) {
let id = `circle-${i}`;
const diameter = 10;
const perLine = Math.floor(canvas.width / (diameter + 2)) - 1;
await circle({
topLeftPoint: new Point(
(diameter + 2) * (i % perLine) + 5,
5 + (diameter + 2) * Math.floor(i / perLine),
),
diameter,
style: new Style(new Color(176, 230, 156), new Color(255, 105, 180), 1),
}).then((bounding) => {
if (bounding) boundingBoxes.set(id, bounding);
});
}
await render();
canvas.addEventListener(
"pointermove",
asyncThrottle(async (event) => {
hoverBounding = undefined;
cursor = new Point(event.offsetX, event.offsetY);
for (const bounding of boundingBoxes.values()) {
if (bounding.intersect(cursor)) {
hoverBounding = bounding.as_js();
}
}
await otherLayerApi?.clear();
if (hoverBounding) {
await otherLayerApi?.rectangle({
topLeftPoint: new Point(hoverBounding.top_left.x, hoverBounding.top_left.y),
size: new Size(hoverBounding.size.width, hoverBounding.size.height),
style: new Style(undefined, new Color(100, 180, 255), 2),
});
}
await otherLayerApi?.render();
}, 16),
);
});
layer().then(async (api) => {
otherLayerApi = api;
});
You need to set two HTTP Headers:
Header | Value |
---|---|
Cross-Origin-Opener-Policy | same-origin |
Cross-Origin-Embedder-Policy | require-corp |
You need to exclude the framebuffer-worker
module from the dependency pre-bundling as this module is an ES module
and use import.meta.url
internally to load the worker and wasm files.
You also need to set the mandatory headers to support SharedArrayBuffer
.
import { defineConfig } from "vite";
export default defineConfig({
optimizeDeps: {
exclude: ["framebuffer-worker"],
},
server: {
headers: {
"Cross-Origin-Embedder-Policy": "require-corp",
"Cross-Origin-Opener-Policy": "same-origin",
},
},
});