createImageBitmap

Loading Images with createImageBitmap

copyExternalImageToTexture() accepts an ImageBitmap, ImageData, HTMLImageElement, HTMLVideoElement, VideoFrame, HTMLCanvasElement or OffscreenCanvas, but not a Blob (passing one threw a TypeError). The fastest route from a file is fetch(), blob() and createImageBitmap(blob, options), which decodes off the main thread, as Cover Texture Array does. The options are applied during decoding:

createImageBitmap options that matter for textures
Option Values Use
imageOrientation from-image, flipY Flip for bottom-up texture coordinates
premultiplyAlpha default, premultiply, none Keep straight alpha for blending in shaders
colorSpaceConversion default, none none for normal maps and other data
resizeWidth, resizeHeight, resizeQuality pixels; pixelated, low (default) to high Decode straight to a smaller size

Decoding one 256 x 384 cover took about 3 ms here whatever the options, and resizeWidth: 128, resizeHeight: 192 returned a 128 x 192 bitmap: an easy way to build lower levels on the CPU or to load a small preview first. A decoded <img> element also works, but img.decode() runs on the main thread's image cache. Close bitmaps with bitmap.close() once uploaded.

One cover decoded by createImageBitmap() with different options (as is, flipped, resized) and uploaded with copyExternalImageToTexture()HTMLLive
<!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 code = /* wgsl */ `
@group(0) @binding(0) var image: texture_2d<f32>;
@group(0) @binding(1) var smp: sampler;
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));
  return Out(vec4f(q * 2 - 1, 0, 1), vec2f(q.x, 1 - q.y));
}
@fragment fn fs(in: Out) -> @location(0) vec4f { return textureSample(image, 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 });

  // Stand-in for a downloaded PNG: an encoded Blob made from a painted canvas (no network needed).
  const art = new OffscreenCanvas(256, 384), g = art.getContext('2d');
  g.fillStyle = '#b5452e'; g.fillRect(0, 0, 256, 384);
  g.fillStyle = '#f3e6c8'; g.fillRect(0, 60, 256, 120);
  g.fillStyle = '#3a1d12'; g.font = 'bold 36px Georgia, serif'; g.textAlign = 'center';
  g.fillText('Salt and', 128, 112); g.fillText('Saffron', 128, 156);
  g.fillStyle = '#f3e6c8'; g.font = '22px Georgia, serif'; g.fillText('TOP', 128, 36); g.fillText('R. Okafor', 128, 350);
  const blob = await art.convertToBlob({ type: 'image/png' });

  // A Blob is not a valid source: decode it first.
  let blobError = 'accepted?';
  const probe = device.createTexture({ size: [1, 1], format: 'rgba8unorm', usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT });
  try { device.queue.copyExternalImageToTexture({ source: blob }, { texture: probe }, [1, 1]); } catch (e) { blobError = `${e.name}`; }

  // Each label line is drawn separately above its cover.
  const variants = [
    [['{ }'], {}],
    [['imageOrientation:', "  'flipY'"], { imageOrientation: 'flipY' }],
    [['resizeWidth: 32,', 'resizeHeight: 48,', "resizeQuality:", "  'pixelated'"], { resizeWidth: 32, resizeHeight: 48, resizeQuality: 'pixelated' }],
    [["premultiplyAlpha:", "  'none'", 'colorSpaceConversion:', "  'none'"], { premultiplyAlpha: 'none', colorSpaceConversion: 'none' }]];
  const module = device.createShaderModule({ code });
  const pipeline = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
    vertex: { module }, fragment: { module, targets: [{ format }] } });
  const nearest = device.createSampler();
  // Decode everything first: awaiting inside a frame would let the canvas texture expire.
  const decoded = [];
  for (const [name, options] of variants) {
    const t0 = performance.now();
    const bitmap = await createImageBitmap(blob, options);     // decoded off the main thread
    decoded.push([name, bitmap, performance.now() - t0]);
  }
  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);
  ink.font = '10.5px ui-monospace, monospace'; ink.fillStyle = '#222';
  for (const [i, [name, bitmap, ms]] of decoded.entries()) {
    const texture = device.createTexture({ size: [bitmap.width, bitmap.height], format: 'rgba8unorm',
      usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT });
    device.queue.copyExternalImageToTexture({ source: bitmap }, { texture }, [bitmap.width, bitmap.height]);
    bitmap.close();                                            // free it once uploaded
    pass.setViewport(14 + i * 148, 70, 128, 192, 0, 1);         // all shown at the same size
    pass.setBindGroup(0, device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
      { binding: 0, resource: texture.createView() }, { binding: 1, resource: nearest }] }));
    pass.draw(4);
    name.forEach((line, k) => ink.fillText(line, 14 + i * 148, 14 + k * 13));
    ink.fillText(`${texture.width} x ${texture.height}, ${ms.toFixed(1)} ms`, 14 + i * 148, 280);
  }
  pass.end();
  device.queue.submit([encoder.finish()]);
  ink.font = '12px system-ui, sans-serif'; ink.fillStyle = '#8a2b2b';
  ink.fillText(`copyExternalImageToTexture({ source: blob }) throws ${blobError}: decode with createImageBitmap first.`, 14, 314);
}
main();
</script>