Off-Thread Recording drew one frame in a worker. A full worker renderer adds a loop, using the worker's own requestAnimationFrame(), and resizing: the main thread still owns the layout, so it posts sizes and the worker sets them on its OffscreenCanvas:
let context, device, format, frame = 0;
onmessage = async ({ data }) => {
if (data.canvas) { // first message: the transferred canvas
device = await (await navigator.gpu.requestAdapter()).requestDevice();
format = navigator.gpu.getPreferredCanvasFormat();
context = data.canvas.getContext('webgpu');
context.configure({ device, format });
requestAnimationFrame(draw);
}
if (data.width) Object.assign(context.canvas, { width: data.width, height: data.height });
};
function draw() {
const encoder = device.createCommandEncoder(), t = ++frame / 60;
const view = context.getCurrentTexture().createView();
encoder.beginRenderPass({ colorAttachments: [{ view, loadOp: 'clear', storeOp: 'store',
clearValue: [0.08, 0.40, 0.75 + 0.2 * Math.sin(t), 1] }] }).end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(draw); // presented when this callback returns
}The page transfers the canvas with transferControlToOffscreen(), posts it with the canvas in the transfer list, then posts { width, height } from the ResizeObserver of Resizing the Context. In a test harness here the worker drew 60 frames a second and followed a resize from 480x120 to 240x60; input events must be forwarded with postMessage().
<!doctype html>
<style>
body { margin: 0; background: #f7f4ee; font: 13px system-ui, sans-serif; color: #333; }
canvas { display: block; width: 100%; max-width: 600px; height: 300px; }
p { margin: 6px 8px; max-width: 584px; }
</style>
<canvas id="view"></canvas>
<p id="status">Starting the worker...</p>
<script>
// The renderer's source: it runs as a worker, kept in the page so the example is self-contained.
const rendererSource = `
let context, device, pipeline, uniform, group, frame = 0;
async function handle(data) {
if (data.probe) { postMessage({ gpu: 'gpu' in navigator }); return; }
if (data.canvas) { // first real message: the transferred canvas
Object.assign(data.canvas, { width: data.width, height: data.height });
const adapter = await navigator.gpu?.requestAdapter();
if (!adapter) { postMessage({ text: 'WebGPU is not available here' }); return; }
device = await adapter.requestDevice();
const format = navigator.gpu.getPreferredCanvasFormat();
context = data.canvas.getContext('webgpu');
context.configure({ device, format });
const module = device.createShaderModule({ code: \`
struct U { time: f32, aspect: f32 }
@group(0) @binding(0) var<uniform> u: U;
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> @builtin(position) vec4f {
let q = vec2f(f32(v & 1), f32(v >> 1)) - 0.5;
let a = u.time + f32(i) * 1.047; // six books circling the centre
let c = vec2f(cos(a), sin(a)) * 0.55;
return vec4f((c + q * vec2f(0.12, 0.3)) * vec2f(1 / u.aspect, 1), 0, 1);
}
@fragment fn fs() -> @location(0) vec4f { return vec4f(0.98, 0.95, 0.86, 1); }\` });
pipeline = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
vertex: { module }, fragment: { module, targets: [{ format }] } });
uniform = device.createBuffer({ size: 8, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
group = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniform } }] });
requestAnimationFrame(draw); // the worker's own animation frames
}
if (data.width && context) Object.assign(context.canvas, { width: data.width, height: data.height });
}
function draw() {
const t = ++frame / 60, canvas = context.canvas;
device.queue.writeBuffer(uniform, 0, new Float32Array([t, canvas.width / canvas.height]));
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
loadOp: 'clear', storeOp: 'store', clearValue: [0.08, 0.40, 0.75 + 0.2 * Math.sin(t), 1] }] });
pass.setPipeline(pipeline);
pass.setBindGroup(0, group);
pass.draw(4, 6);
pass.end();
device.queue.submit([encoder.finish()]);
if (frame % 30 === 0) postMessage({ text: \`frame \${frame}, canvas \${canvas.width}x\${canvas.height}\` });
requestAnimationFrame(draw); // presented when this callback returns
}
if (typeof WorkerGlobalScope !== 'undefined') onmessage = (event) => handle(event.data);`;
const canvas = document.getElementById('view');
const status = document.getElementById('status');
function showMessage(text) { // 2D fallback when WebGPU is missing
const ctx = canvas.getContext('2d');
Object.assign(canvas, { width: canvas.clientWidth, height: canvas.clientHeight });
ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#8a2b2b'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'center';
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}
function start(send, where) { // hand the canvas over and keep posting sizes
const offscreen = canvas.transferControlToOffscreen();
send({ canvas: offscreen, width: canvas.clientWidth, height: canvas.clientHeight }, [offscreen]);
new ResizeObserver(() => send({ width: canvas.clientWidth, height: canvas.clientHeight })).observe(canvas);
status.dataset.where = where;
}
if (!navigator.gpu || !canvas.transferControlToOffscreen) {
showMessage('WebGPU is not available in this browser');
status.textContent = '';
} else {
const worker = new Worker(URL.createObjectURL(new Blob([rendererSource], { type: 'text/javascript' })));
worker.onmessage = ({ data }) => {
if ('gpu' in data && data.gpu) start((m, t) => worker.postMessage(m, t), 'Rendering in a worker');
else if ('gpu' in data) {
// Workers of a sandboxed, opaque-origin page are not secure contexts, so they lack
// navigator.gpu. Run the same renderer on the main thread, still through an OffscreenCanvas.
worker.terminate();
const handle = new Function('postMessage', rendererSource + '\nreturn handle;')(
(m) => { if (m.text) status.textContent = `${status.dataset.where}: ${m.text}`; });
start((m) => handle(m), 'No WebGPU in workers of this sandboxed frame, so the same code runs on the main thread');
} else status.textContent = `${status.dataset.where}: ${data.text}`;
};
worker.postMessage({ probe: true }); // does WebGPU exist inside a worker here?
}
</script>