OffscreenCanvas

Presenting from an OffscreenCanvas in a Worker

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:

renderer-worker.js: a worker render loop that follows the page's resizesJavaScript
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().

A worker renders every frame into a transferred OffscreenCanvas and follows the page's resizesHTMLLive
<!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>