Canvas Uploads

Uploading Canvas and OffscreenCanvas Content

Anything a 2D canvas can draw, such as text, charts, SVG images or video frames with overlays, becomes a texture through the same call: device.queue.copyExternalImageToTexture({ source: canvas, flipY }, { texture, colorSpace, premultipliedAlpha }, [width, height]). First Cover Quad painted its atlas this way, and an OffscreenCanvas works identically in a worker. The destination texture must have COPY_DST and RENDER_ATTACHMENT usage: without the latter, the copy failed with "Destination texture needs to have CopyDst and RenderAttachment usage", because the copy may run as a GPU draw that converts color space, flips and premultiplies on the way.

For content that changes every frame, such as a live price ticker or a signature pad, keep one texture and recopy only when the canvas changed; for a video, prefer importExternalTexture() (GPUExternalTexture), which avoids the copy altogether. A 2D canvas used this way should be created with willReadFrequently: false (the default) so it stays GPU-backed, and sized like the texture, because the copy never scales.

A live 2D-canvas price ticker copied into a texture only when it changes, shown on a tilted shelf signHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
  .stage { position: relative; width: 100%; max-width: 600px; }
  .stage canvas { display: block; width: 100%; }
  .stage canvas + canvas { position: absolute; inset: 0; pointer-events: none; }
</style>
<div class="stage">
  <canvas id="view" width="600" height="330"></canvas>
  <canvas id="labels" width="600" height="330"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = document.getElementById('labels').getContext('2d');

function showMessage(text) {                     // 2D fallback when WebGPU is missing
  const ctx = canvas.getContext('2d');
  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);
}

const books = [['The Quiet Harbor', 14.99], ['Small Steps to Big Summits', 39.50], ['Salt and Saffron', 24.00],
               ["The Clockmaker's Paradox", 18.75], ['Night Trains', 16.20], ['Paper Gardens', 21.30]];
// The sign: a quad leaning back in perspective, textured with the ticker.
const code = /* wgsl */ `
@group(0) @binding(0) var ticker: texture_2d<f32>;
@group(0) @binding(1) var smp: sampler;
@group(0) @binding(2) var<uniform> time: f32;
struct Out { @builtin(position) pos: vec4f, @location(0) uv: vec2f }
@vertex fn vs(@builtin(vertex_index) v: u32) -> Out {
  let q = vec2f(f32(v & 1), f32(v >> 1));
  let yaw = sin(time * 0.6) * 0.35;
  let x = (q.x - 0.5) * 3.2;  let y = (q.y - 0.5) * 1.6;
  let z = 3.2 + x * sin(yaw) + y * 0.45;                // turned a little, leaning back
  return Out(vec4f(x * cos(yaw) * 0.9, y * 0.9 * 1.8, 0.5 * z, z * 0.62), vec2f(q.x, 1 - q.y));
}
@fragment fn fs(in: Out) -> @location(0) vec4f { return textureSample(ticker, smp, in.uv); }`;

async function main() {
  const adapter = await navigator.gpu?.requestAdapter();
  if (!adapter) return showMessage('WebGPU is not available in this browser');
  const device = await adapter.requestDevice();
  const context = canvas.getContext('webgpu');
  const format = navigator.gpu.getPreferredCanvasFormat();
  context.configure({ device, format });

  const sign = new OffscreenCanvas(512, 256), g = sign.getContext('2d');   // sized like the texture: copies never scale
  function paint(index) {
    const [title, price] = books[index];
    g.fillStyle = '#16384f'; g.fillRect(0, 0, 512, 256);
    g.fillStyle = '#f5c542'; g.font = 'bold 28px system-ui, sans-serif'; g.fillText('BookNest deal of the moment', 24, 48);
    g.fillStyle = '#ffffff'; g.font = 'bold 34px Georgia, serif'; g.fillText(title, 24, 124);
    g.fillStyle = '#9fe0a8'; g.font = 'bold 64px system-ui, sans-serif'; g.fillText(`$${(price * 0.8).toFixed(2)}`, 24, 212);
    g.fillStyle = '#c9d6df'; g.font = '26px system-ui, sans-serif'; g.fillText(`was $${price.toFixed(2)}`, 290, 206);
  }
  // Destination needs COPY_DST and RENDER_ATTACHMENT: the copy may run as a GPU draw.
  const texture = device.createTexture({ size: [512, 256], format: 'rgba8unorm',
    usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT });
  const strict = device.createTexture({ size: [512, 256], format: 'rgba8unorm', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
  paint(0);
  let usageError = 'accepted';
  device.pushErrorScope('validation');             // an expected error, kept out of the console
  try { device.queue.copyExternalImageToTexture({ source: sign }, { texture: strict }, [512, 256]); } catch (e) { usageError = e.message; }
  const scoped = await device.popErrorScope();
  if (scoped) usageError = scoped.message.split('\n')[0];

  const module = device.createShaderModule({ code });
  const pipeline = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
    vertex: { module }, fragment: { module, targets: [{ format }] } });
  const time = device.createBuffer({ size: 4, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
  const group = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
    { binding: 0, resource: texture.createView() }, { binding: 1, resource: device.createSampler({ magFilter: 'linear', minFilter: 'linear' }) },
    { binding: 2, resource: { buffer: time } }] });

  let shown = -1, copies = 0;
  function frame(now) {
    const index = Math.floor(now / 1500) % books.length;
    if (index !== shown) {                              // recopy only when the canvas changed
      shown = index; paint(index);
      device.queue.copyExternalImageToTexture({ source: sign }, { texture }, [512, 256]);
      copies++;
    }
    device.queue.writeBuffer(time, 0, new Float32Array([now / 1000]));
    const encoder = device.createCommandEncoder();
    const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
      clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
    pass.setPipeline(pipeline);
    pass.setBindGroup(0, group);
    pass.draw(4);
    pass.end();
    device.queue.submit([encoder.finish()]);
    ink.clearRect(0, 0, 600, 330);
    ink.font = '12px ui-monospace, monospace'; ink.fillStyle = '#222';
    ink.fillText(`copyExternalImageToTexture({ source: offscreenCanvas }): ${copies} copies so far`, 10, 18);
    ink.fillStyle = '#8a2b2b'; ink.font = '11px ui-monospace, monospace';
    ink.fillText(`without RENDER_ATTACHMENT: ${usageError}`.slice(0, 92), 10, 322);
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}
main();
</script>