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:
| 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.
<!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>