../

WebGPU

WebGPU and WGSL typed for TypeScript: adapter and device, canvas setup, buffers, bind groups, render and compute pipelines, textures, errors and fallbacks. Status checked against MDN browser-compat-data in September 2026. For the older API see WebGL.

Support & typing

MDN status: Limited availability (not Baseline). Secure contexts only (HTTPS or localhost). Available in windows and dedicated/shared workers.

BrowserSinceScope
Chrome, Edge113Windows, macOS, ChromeOS; Linux since 144 (Intel Gen12+ GPUs only)
Chrome Android121device and GPU dependent
Safari (macOS, iOS, iPadOS, visionOS)26
Firefox141Windows; Apple-silicon macOS since 145 (Tahoe) / 147 (older); not Linux, Intel Macs or Android; not in service workers
Denobuilt innavigator.gpu
Bun, Nodevia npm webgpu (Dawn)no built-in navigator.gpu

Even in a supporting browser, requestAdapter() can resolve null (blocklisted GPU, no hardware acceleration). Always keep a fallback.

TypeScriptSetup
TS 5.xbun add -d @webgpu/types, then "types": ["@webgpu/types"] in tsconfig.json
TS 6.0lib.dom has the GPU* interfaces but not the GPUBufferUsage/GPUTextureUsage/GPUShaderStage/GPUMapMode constants, and types getContext("webgpu") as RenderingContext; keep @webgpu/types (no conflicts)
Worker filessame types; navigator.gpu is on WorkerNavigator
Usage flagsGPUBufferUsage.* etc. are plain numbers OR-ed together
Typed arrays (TS 5.7+)writeBuffer wants Float32Array<ArrayBuffer>; a bare Float32Array param is ArrayBufferLike and fails

Setting "types" turns off automatic @types/* loading, so list the others too (["@webgpu/types", "bun"]), or use /// <reference types="@webgpu/types" /> in one file.

Keeping WGSL in files
src/gpu/shaders/triangle.wgslblur.wgslrenderer.ts  # imports the .wgsl files as textwgsl.d.ts        # types for *.wgsl imports

Bun imports text files with import code from "./shaders/triangle.wgsl" with { type: "text" }; Vite uses "./x.wgsl?raw". Type them once in wgsl.d.ts: declare module "*.wgsl" { const s: string; export default s }.

Adapter & device

export async function initGPU() {
  if (!navigator.gpu) throw new Error("WebGPU unsupported");
  const adapter = await navigator.gpu.requestAdapter({
    powerPreference: "high-performance",
  });
  if (!adapter) throw new Error("No suitable GPU adapter");
 
  const hasF16 = adapter.features.has("shader-f16");
  const device = await adapter.requestDevice({
    label: "main",
    requiredFeatures: hasF16 ? ["shader-f16"] : [],
    requiredLimits: {
      // ask for more than the default only when needed
      maxStorageBufferBindingSize:
        adapter.limits.maxStorageBufferBindingSize,
    },
  });
  return { adapter, device, hasF16 };
}
MemberMeaning
requestAdapter({ powerPreference })"high-performance" or "low-power"; a hint
requestAdapter({ featureLevel: "compatibility" })OpenGL ES 3.1 / D3D11-class GPUs (Chromium)
adapter.features / .limitswhat the hardware could give you
adapter.infovendor, architecture, description, isFallbackAdapter
requestDevice({ requiredFeatures, requiredLimits })you only get what you ask for; defaults otherwise
device.queuesubmits work and uploads data
device.lostpromise, see Error handling
device.destroy()free everything; lost resolves with reason "destroyed"

An adapter is single-use: after requestDevice (or a device loss) request a fresh adapter.

Default limitValue
maxTextureDimension2D8192
maxBindGroups4
maxUniformBufferBindingSize64 KiB
maxStorageBufferBindingSize128 MiB
maxBufferSize256 MiB
maxVertexBuffers / maxColorAttachments8 / 8
minUniformBufferOffsetAlignment256 bytes
maxComputeInvocationsPerWorkgroup256
maxComputeWorkgroupSizeX / Y / Z256 / 256 / 64
maxComputeWorkgroupsPerDimension65535
maxComputeWorkgroupStorageSize16 KiB

Canvas configuration

export function configureCanvas(
  canvas: HTMLCanvasElement, device: GPUDevice,
) {
  const context = canvas.getContext("webgpu");
  if (!context) throw new Error("webgpu context failed");
  const format = navigator.gpu.getPreferredCanvasFormat();
  context.configure({
    device,
    format,              // "bgra8unorm" or "rgba8unorm"
    alphaMode: "premultiplied", // default "opaque"
  });
  return { context, format };
}
configure() optionNotes
devicerequired
formatuse getPreferredCanvasFormat(); other formats cost an extra copy
alphaMode"opaque" (default) or "premultiplied" for see-through canvases
usagedefault RENDER_ATTACHMENT; add COPY_SRC for screenshots
viewFormatse.g. ["bgra8unorm-srgb"] to render through an sRGB view
colorSpace"srgb" (default) or "display-p3"
toneMapping{ mode: "extended" } for HDR output (Chromium)
FactDetail
Per framecall context.getCurrentTexture() every frame; the texture expires when the frame is presented
Sizethe texture follows canvas.width/height; no reconfigure needed on resize
HiDPIsize the canvas as in Canvas 2D
One context typea canvas that returned "webgpu" can never give "webgl2" or "2d"
Workerscanvas.transferControlToOffscreen() then offscreen.getContext("webgpu")

WGSL basics

textured.wgsl
struct Uniforms {
  mvp: mat4x4f,
  tint: vec4f,
};
 
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var tex: texture_2d<f32>;
@group(0) @binding(2) var samp: sampler;
 
struct VSOut {
  @builtin(position) pos: vec4f,
  @location(0) uv: vec2f,
};
 
@vertex
fn vs(
  @location(0) pos: vec3f,
  @location(1) uv: vec2f,
) -> VSOut {
  var out: VSOut;
  out.pos = u.mvp * vec4f(pos, 1.0);
  out.uv = uv;
  return out;
}
 
@fragment
fn fs(v: VSOut) -> @location(0) vec4f {
  return textureSample(tex, samp, v.uv) * u.tint;
}
CategorySyntax
Scalarsbool, i32, u32, f32, f16 (needs enable f16; + shader-f16)
Literals1 (abstract int), 1u, 1i, 1.0, 1f, 1h; no implicit conversion: f32(i)
Vectorsvec2f, vec3i, vec4u, vec2h = vec2<f32> etc.; swizzle .xyzw / .rgba
Matricesmat4x4f, mat3x3f: column-major, m * v
Arraysarray<f32, 4>; runtime-sized array<f32> only as the last storage member
Structsstruct S { a: f32, b: vec3f }; fields may carry @location / @builtin
Atomicsatomic<u32>, atomicAdd(&x, 1u); storage or workgroup only
Texturestexture_2d<f32>, texture_cube<f32>, texture_depth_2d, texture_storage_2d<rgba8unorm, write>, texture_external
Samplerssampler, sampler_comparison
Declarationslet (immutable), var (mutable), const (compile time), override (set from JS)
Address spacesvar<uniform>, var<storage, read>, var<storage, read_write>, var<workgroup>, var<private>
Bindings@group(g) @binding(b) match bind group g, entry b
Control flowif, switch, for, while, loop, break, continue; no ternary: select(f, t, cond)
Entry pointAttributeCommon built-ins
Vertex@vertexin: vertex_index, instance_index; out: position
Fragment@fragmentin: position (px), front_facing, sample_index; out: @location(n), frag_depth
Compute@compute @workgroup_size(x, y?, z?)global_invocation_id, local_invocation_id, local_invocation_index, workgroup_id, num_workgroups
Alignment (host-shareable)Size / align in bytes
f32, i32, u324 / 4
vec2f8 / 8
vec3f12 / 16 (next field packs into the gap)
vec4f16 / 16
mat4x4f64 / 16
mat3x3f48 / 16 (each column padded to 16)
Structrounded up to its largest member alignment

Mirror layouts on the JS side by hand, or generate them with webgpu-utils or TypeGPU.

Buffers & mapping

const verts = new Float32Array([0, .5, -.5, -.5, .5, -.5]);
 
// 1. Create + queue upload (most common)
const vbuf = device.createBuffer({
  label: "triangle",
  size: verts.byteLength,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(vbuf, 0, verts);
 
// 2. Mapped at creation (static data, no COPY_DST needed)
const ibuf = device.createBuffer({
  size: 8, // multiple of 4
  usage: GPUBufferUsage.INDEX,
  mappedAtCreation: true,
});
new Uint16Array(ibuf.getMappedRange()).set([0, 1, 2]);
ibuf.unmap();
GPUBufferUsageFor
VERTEX, INDEXsetVertexBuffer, setIndexBuffer
UNIFORMvar<uniform>, up to 64 KiB per binding by default
STORAGEvar<storage>, large and writable from shaders
INDIRECTdrawIndirect / dispatchWorkgroupsIndirect arguments
COPY_SRC, COPY_DSTsource / target of copies and writeBuffer
MAP_READCPU readback; only combinable with COPY_DST
MAP_WRITECPU upload staging; only combinable with COPY_SRC
QUERY_RESOLVEresolveQuerySet target (timestamps)
RuleDetail
SizeswriteBuffer and mapped ranges work in multiples of 4 bytes
Uniform structspad the buffer to the WGSL struct size (multiple of 16)
Dynamic offsetsmultiples of minUniformBufferOffsetAlignment (256)
mapAsync(GPUMapMode.READ)resolves after the GPU is done with the buffer
getMappedRange()ArrayBuffer detached on unmap(): copy with .slice(0) first
mapState"unmapped", "pending", "mapped"
A mapped buffercannot be used in a submit
buffer.destroy()free now instead of waiting for GC

Bind groups & layouts

A bind group is the set of resources (buffers, textures, samplers) a pipeline reads; its layout is the matching shape. layout: "auto" derives it from the shader but ties bind groups to one pipeline; explicit layouts let pipelines share bind groups.

const bgl = device.createBindGroupLayout({
  entries: [
    { binding: 0, buffer: { type: "uniform" },
      visibility: GPUShaderStage.VERTEX
        | GPUShaderStage.FRAGMENT },
    { binding: 1, texture: { sampleType: "float" },
      visibility: GPUShaderStage.FRAGMENT },
    { binding: 2, sampler: { type: "filtering" },
      visibility: GPUShaderStage.FRAGMENT },
  ],
});
const layout = device.createPipelineLayout({
  bindGroupLayouts: [bgl], // index = @group
});
const bindGroup = device.createBindGroup({
  layout: bgl,
  entries: [
    { binding: 0, resource: { buffer: ubuf } },
    { binding: 1, resource: texture.createView() },
    { binding: 2, resource: sampler },
  ],
});
Layout entryWGSL
buffer: { type: "uniform" }var<uniform>
buffer: { type: "read-only-storage" }var<storage, read>
buffer: { type: "storage" }var<storage, read_write>
buffer: { hasDynamicOffset: true }offset passed in setBindGroup(i, bg, [offset])
texture: { sampleType: "float" | "unfilterable-float" | "depth" | "uint" | "sint" }texture_2d<f32> etc.
storageTexture: { format, access }texture_storage_2d<…>
sampler: { type: "filtering" | "non-filtering" | "comparison" }sampler / sampler_comparison
externalTexture: {}texture_external (video frames)

Group by update frequency: @group(0) per frame (camera), @group(1) per material, @group(2) per object. Switching a low group index is cheapest to keep stable.

Render pipeline

const pipeline = device.createRenderPipeline({
  label: "mesh",
  layout, // or "auto"
  vertex: {
    module,
    entryPoint: "vs", // optional if the module has one
    buffers: [{
      arrayStride: 5 * 4, // bytes per vertex
      stepMode: "vertex", // "instance" for per-instance
      attributes: [
        { shaderLocation: 0, offset: 0,
          format: "float32x3" }, // pos
        { shaderLocation: 1, offset: 12,
          format: "float32x2" }, // uv
      ],
    }],
  },
  fragment: {
    module,
    entryPoint: "fs",
    targets: [{ format }],
  },
  primitive: { topology: "triangle-list", cullMode: "back" },
  depthStencil: {
    format: "depth24plus",
    depthWriteEnabled: true,
    depthCompare: "less",
  },
  multisample: { count: 1 }, // 1 or 4
});
Descriptor fieldValues
primitive.topologypoint-list, line-list, line-strip, triangle-list, triangle-strip
primitive.stripIndexFormatuint16/uint32 for strips with an index buffer
primitive.cullMode / frontFacenone, front, back / ccw (default), cw
Vertex formatfloat32, float32x2..4, uint32, sint32x2, unorm8x4, float16x4, …
targets[].blend{ color: { srcFactor, dstFactor, operation }, alpha: {…} }
targets[].writeMaskGPUColorWrite.ALL (default), RED, GREEN, BLUE, ALPHA
depthStencil.depthCompareless, less-equal, greater (reverse-Z), always, …
vertex.constants / fragment.constantsvalues for WGSL override declarations
// Straight alpha blending for a target
const blend: GPUBlendState = {
  color: {
    srcFactor: "src-alpha",
    dstFactor: "one-minus-src-alpha",
    operation: "add",
  },
  alpha: {
    srcFactor: "one",
    dstFactor: "one-minus-src-alpha",
  },
};

Pipelines are immutable and expensive to build. Create them at load time; prefer createRenderPipelineAsync() to avoid a main-thread stall.

Compute pipeline & workgroups

sum-rows.wgsl
@group(0) @binding(0) var<storage, read> src: array<f32>;
@group(0) @binding(1) var<storage, read_write> o: array<f32>;
 
var<workgroup> tile: array<f32, 64>;
 
@compute @workgroup_size(64)
fn main(
  @builtin(global_invocation_id) gid: vec3u,
  @builtin(local_invocation_index) lid: u32,
  @builtin(workgroup_id) wid: vec3u,
) {
  let n = arrayLength(&src);
  tile[lid] = select(0.0, src[gid.x], gid.x < n);
  workgroupBarrier();
  if (lid == 0u) {
    var s = 0.0;
    for (var i = 0u; i < 64u; i++) { s += tile[i]; }
    o[wid.x] = s; // one partial sum per workgroup
  }
}
ConceptMeaning
Invocationone run of the entry point (a GPU "thread")
Workgroup@workgroup_size(x, y, z) invocations that share var<workgroup> memory and barriers
pass.dispatchWorkgroups(x, y?, z?)how many workgroups to launch
global_invocation_idworkgroup_id * workgroup_size + local_invocation_id
Size choice64 is a safe default; total per workgroup max 256 by default
CountMath.ceil(n / 64); guard if (id.x >= n) { return; } for the tail
workgroupBarrier()sync workgroup memory; must be in uniform control flow
storageBarrier()order storage accesses within a workgroup
No global barriersplit dependent steps into separate dispatches or passes
const cp = device.createComputePipeline({
  layout: "auto",
  compute: { module, constants: { /* overrides */ } },
});

Command encoders & queue

Nothing runs until you submit. Record passes into an encoder, finish() it into a command buffer, submit the buffer to the queue.

function frame() {
  const encoder = device.createCommandEncoder();
  const pass = encoder.beginRenderPass({
    colorAttachments: [{
      view: context.getCurrentTexture().createView(),
      clearValue: [0.1, 0.1, 0.1, 1],
      loadOp: "clear", // or "load" to keep contents
      storeOp: "store",
    }],
  });
  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bindGroup);
  pass.setVertexBuffer(0, vbuf);
  pass.draw(3); // vertexCount, instanceCount = 1
  pass.end();
  device.queue.submit([encoder.finish()]);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
ObjectCalls
GPUCommandEncoderbeginRenderPass, beginComputePass, copyBufferToBuffer, copyBufferToTexture, copyTextureToBuffer, copyTextureToTexture, clearBuffer, resolveQuerySet, finish
GPURenderPassEncodersetPipeline, setBindGroup, setVertexBuffer, setIndexBuffer, draw, drawIndexed, drawIndirect, setViewport, setScissorRect, executeBundles, end
GPUComputePassEncodersetPipeline, setBindGroup, dispatchWorkgroups, dispatchWorkgroupsIndirect, end
GPUQueuesubmit, writeBuffer, writeTexture, copyExternalImageToTexture, onSubmittedWorkDone
TipDetail
One submit per framebatch all passes into one encoder
writeBuffer orderingruns before the next submit, not at the call site in a pass
Render bundlesdevice.createRenderBundleEncoder() prerecords static draws
copyTextureToBufferbytesPerRow must be a multiple of 256
Labelslabel on every object shows up in error messages

Textures & samplers

async function loadTexture(device: GPUDevice, url: string) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
  const bmp = await createImageBitmap(await res.blob());
  const texture = device.createTexture({
    size: [bmp.width, bmp.height],
    format: "rgba8unorm-srgb",
    usage: GPUTextureUsage.TEXTURE_BINDING
      | GPUTextureUsage.COPY_DST
      // RENDER_ATTACHMENT is required by the copy below
      | GPUTextureUsage.RENDER_ATTACHMENT,
  });
  device.queue.copyExternalImageToTexture(
    { source: bmp }, { texture }, [bmp.width, bmp.height],
  );
  bmp.close();
  return texture;
}
 
const sampler = device.createSampler({
  magFilter: "linear",
  minFilter: "linear",
  mipmapFilter: "linear",
  addressModeU: "repeat",
  addressModeV: "repeat",
});
FormatUse
rgba8unorm / rgba8unorm-srgbdata / color images (sRGB decode on sample)
bgra8unormtypical canvas format
rgba16floatHDR, render targets, blendable
rgba32floatdata; filtering needs float32-filterable
r32float, r32uintsingle-channel data, storage textures
depth24plus, depth32floatdepth buffers (depth24plus-stencil8 with stencil)
bc*, etc2-*, astc-*compressed; need texture-compression-* features
GPUTextureUsageFor
TEXTURE_BINDINGsample in shaders
STORAGE_BINDINGwrite from shaders (texture_storage_*)
RENDER_ATTACHMENTrender target, depth buffer, copyExternalImageToTexture destination
COPY_SRC, COPY_DSTcopies and writeTexture
FactDetail
Mipmapsno generateMipmap: set mipLevelCount, then fill levels with a render or compute pass (webgpu-utils has one)
Viewstexture.createView({ dimension, baseMipLevel, format }); bind groups take views
Raw bytesqueue.writeTexture({ texture }, data, { bytesPerRow }, size)
Videodevice.importExternalTexture({ source: video }) → texture_external; valid for the current task only
Texture origintexel (0, 0) is top-left; framebuffer y points down, NDC y points up
Comparison samplercompare: "less" + texture_depth_2d for shadow maps
MSAAsampleCount: 4 texture as view, canvas texture as resolveTarget

Error handling

Most WebGPU errors are asynchronous: calls don't throw, invalid objects poison whatever uses them, and the message arrives later. Label objects and listen.

device.lost.then((info) => {
  console.error(`GPU lost (${info.reason})`, info.message);
  if (info.reason !== "destroyed") void restartRenderer();
});
 
device.onuncapturederror = (e) => {
  console.error("WebGPU error:", e.error.message);
};
 
// Catch errors from a specific block of calls
device.pushErrorScope("validation");
const p = device.createRenderPipeline(desc);
const err = await device.popErrorScope();
if (err) throw new Error(`Invalid pipeline: ${err.message}`);
// Shader diagnostics with line numbers
const shader = device.createShaderModule({ code });
const { messages } = await shader.getCompilationInfo();
for (const m of messages) {
  const where = `${m.lineNum}:${m.linePos}`;
  if (m.type === "error") console.error(where, m.message);
}
MechanismCatches
pushErrorScope("validation")invalid descriptors, bad bindings, wrong usage
pushErrorScope("out-of-memory")allocation failures
pushErrorScope("internal")driver or implementation failures
onuncapturederror / uncapturederror eventanything not caught by a scope; the console also logs it
createRenderPipelineAsync()rejects with GPUPipelineError (reason: validation, internal)
device.lostresolves once: reason "destroyed" (you called destroy) or "unknown" (driver reset, GPU process crash)

After a loss, request a new adapter and device and recreate every resource; keep CPU-side sources so this is possible.

Pitfalls

PitfallSymptomFix
Plain HTTP (not localhost)navigator.gpu undefinedserve over HTTPS
Caching getCurrentTexture()"destroyed texture" errorsfetch it every frame
Depth texture not resizedattachment size mismatchrecreate it when the canvas size changes
vec3f in a uniform structfields shiftedit aligns to 16; use vec4f or pad
Uniform buffer smaller than the structbinding validation errorround size up to 16
Reading mapped data after unmap()detached ArrayBuffergetMappedRange().slice(0)
textureSample inside a non-uniform ifshader compile errorsample first, or textureSampleLevel
rgba32float with a filtering samplerlayout error"unfilterable-float" + "non-filtering", or the float32-filterable feature
Passing a GPUBuffer or GPUTexture straight as resource/viewworks in Chromium onlyuse { buffer } and createView()
Integer / float mixing in WGSLtype errorexplicit f32(x), u32(y)
Expecting WebGL NDCgeometry clippeddepth is 0..1; use wgpu-matrix or adjust projection

WebGPU vs WebGL

WebGL2WebGPU equivalent
GLSL ES 3.00WGSL
createProgram + global state (enable, blendFunc, depthFunc)one immutable GPURenderPipeline
uniform*() callsuniform buffer + bind group
texture units + uniform1ibind group entries
VAOvertex.buffers layout + setVertexBuffer
FBOrender pass colorAttachments / depthStencilAttachment
gl.clear()loadOp: "clear" + clearValue
drawElementsInstanceddrawIndexed(count, instances)
readPixels (sync)copyTextureToBuffer + mapAsync
transform feedback / GPGPU hackscompute shaders + storage buffers
getError()error scopes, uncapturederror
webglcontextlostdevice.lost
NDC z -1..1, framebuffer origin bottom-leftNDC z 0..1, framebuffer origin top-left
ChooseWhen
WebGL2you need every browser today, or a library already abstracts it
WebGPUcompute, many draw calls, modern features; ship a WebGL2 fallback
An enginethree.js and Babylon.js pick the backend for you

More on the WebGL side in WebGL.

Libraries

LibraryWhat it gives
three.js (opens in a new tab)WebGPURenderer (three/webgpu) with automatic WebGL2 fallback; TSL node shaders compile to WGSL or GLSL
Babylon.js (opens in a new tab)full engine with a WebGPU backend (v9)
PixiJS (opens in a new tab)2D; v8 picks WebGPU or WebGL
TypeGPU (opens in a new tab)type-safe buffers, bind groups and WGSL from TypeScript (0.x)
webgpu-utils (opens in a new tab)buffer views from WGSL reflection, mipmap generation
wgpu-matrix (opens in a new tab)mat4/vec3 math with WebGPU's 0..1 depth
webgpu (npm) (opens in a new tab)Dawn for Node and Bun: headless compute and tests
@webgpu/typesTypeScript definitions (TS 5.x)

Recipes

Hello triangle

Use as a first smoke test; builds on initGPU and configureCanvas above.

const code = /* wgsl */ `
@vertex fn vs(@builtin(vertex_index) i: u32)
    -> @builtin(position) vec4f {
  var p = array(vec2f(0, .5), vec2f(-.5), vec2f(.5, -.5));
  return vec4f(p[i], 0, 1); }
@fragment fn fs() -> @location(0) vec4f {
  return vec4f(1, .4, .2, 1); }`;
const { device } = await initGPU();
const canvas = document.querySelector("canvas")!;
const { context, format } = configureCanvas(canvas, device);
const module = device.createShaderModule({ code });
const pipeline = device.createRenderPipeline({
  layout: "auto",
  vertex: { module },
  fragment: { module, targets: [{ format }] },
});
const enc = device.createCommandEncoder();
const pass = enc.beginRenderPass({
  colorAttachments: [{ view: context.getCurrentTexture()
    .createView(), loadOp: "clear", storeOp: "store" }],
});
pass.setPipeline(pipeline);
pass.draw(3);
pass.end();
device.queue.submit([enc.finish()]);

Read a GPU buffer back to JS

Use to get compute results or debug data; copies through a MAP_READ staging buffer.

export async function readBuffer(
  device: GPUDevice, src: GPUBuffer, size = src.size,
): Promise<ArrayBuffer> {
  const staging = device.createBuffer({
    size,
    usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
  });
  const enc = device.createCommandEncoder();
  enc.copyBufferToBuffer(src, 0, staging, 0, size);
  device.queue.submit([enc.finish()]);
  await staging.mapAsync(GPUMapMode.READ);
  const copy = staging.getMappedRange().slice(0); // own it
  staging.unmap();
  staging.destroy();
  return copy;
}

Compute: double an array

Use as the template for any data-parallel job: upload, dispatch, read back with readBuffer. await gpuDouble(device, Float32Array.of(1, 2, 3)) gives [2, 4, 6].

const code = /* wgsl */ `
@group(0) @binding(0) var<storage, read_write> d: array<f32>;
@compute @workgroup_size(64) fn main(
    @builtin(global_invocation_id) id: vec3u) {
  if (id.x < arrayLength(&d)) { d[id.x] *= 2.0; } }`;
async function gpuDouble(dev: GPUDevice, xs: Float32Array) {
  const pipe = dev.createComputePipeline({ layout: "auto",
    compute: { module: dev.createShaderModule({ code }) } });
  const buf = dev.createBuffer({ size: xs.byteLength,
    mappedAtCreation: true, usage: GPUBufferUsage.STORAGE
      | GPUBufferUsage.COPY_SRC });
  new Float32Array(buf.getMappedRange()).set(xs); // upload
  buf.unmap();
  const enc = dev.createCommandEncoder();
  const pass = enc.beginComputePass();
  pass.setPipeline(pipe);
  pass.setBindGroup(0, dev.createBindGroup({
    layout: pipe.getBindGroupLayout(0),
    entries: [{ binding: 0, resource: { buffer: buf } }],
  }));
  pass.dispatchWorkgroups(Math.ceil(xs.length / 64));
  pass.end();
  dev.queue.submit([enc.finish()]);
  return new Float32Array(await readBuffer(dev, buf));
}

Resize-safe render loop with depth

Use for 3D scenes; the canvas texture follows the canvas size, the depth texture must be rebuilt.

let depth: GPUTexture | undefined;
function frame() {
  const color = context.getCurrentTexture();
  const { width, height } = color;
  if (depth?.width !== width || depth.height !== height) {
    depth?.destroy();
    depth = device.createTexture({
      size: [width, height],
      format: "depth24plus",
      usage: GPUTextureUsage.RENDER_ATTACHMENT,
    });
  }
  const enc = device.createCommandEncoder();
  const pass = enc.beginRenderPass({
    colorAttachments: [{ view: color.createView(),
      loadOp: "clear", storeOp: "store" }],
    depthStencilAttachment: { view: depth.createView(),
      depthClearValue: 1, depthLoadOp: "clear",
      depthStoreOp: "store" },
  });
  drawScene(pass); // setPipeline, setBindGroup, draw…
  pass.end();
  device.queue.submit([enc.finish()]);
  requestAnimationFrame(frame);
}

Size the canvas itself with the ResizeObserver recipe from Canvas 2D, clamped to device.limits.maxTextureDimension2D.

Feature detection with WebGL fallback

Use when the app must run everywhere; get the device before touching the canvas, since a canvas keeps the first context type it hands out.

type Backend =
  | { kind: "webgpu"; device: GPUDevice;
      context: GPUCanvasContext }
  | { kind: "webgl2"; gl: WebGL2RenderingContext };
 
async function pickBackend(
  canvas: HTMLCanvasElement,
): Promise<Backend> {
  try {
    const adapter = await navigator.gpu?.requestAdapter();
    const device = await adapter?.requestDevice();
    const context = device && canvas.getContext("webgpu");
    if (device && context) {
      context.configure({ device,
        format: navigator.gpu.getPreferredCanvasFormat() });
      return { kind: "webgpu", device, context };
    }
  } catch { /* fall through to WebGL2 */ }
  const gl = canvas.getContext("webgl2");
  if (gl) return { kind: "webgl2", gl };
  throw new Error("Neither WebGPU nor WebGL2 is available");
}

References