WebGPU in an HTML page: draw with the GPU

WebGPU lets a web page use the graphics chip to draw and to calculate. A canvas, one WGSL shader and some async JavaScript are enough, with no library.

To use WebGPU in an HTML page, check for navigator.gpu, ask it for an adapter and then a device, and connect a <canvas> to that device. Write the shaders in WGSL, build a pipeline, record a render pass and submit it to the GPU queue.

No library or build step is needed. Here is one file that draws a triangle with a colour at each corner:

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebGPU triangle</title>
<style>
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  canvas { display: block; width: 100%; height: 230px; border-radius: 10px; background: #111827; }
  .bar { display: flex; gap: 8px; align-items: center; margin-top: 10px; }
  button { font: inherit; font-size: 14px; padding: 7px 12px; border-radius: 8px; border: 1px solid #c9cdd4; background: #fff; cursor: pointer; }
  pre { margin: 8px 0 0; font-size: 12px; white-space: pre-wrap; color: #374151; }
  .warn { color: #9a3412; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div class="bar"><button id="bad">Compile a broken shader</button></div>
<pre id="log"></pre>

<script>
  const canvas = document.getElementById('c');
  const log = document.getElementById('log');

  // One WGSL module holds both shaders
  const shaderCode = `
struct VOut {
  @builtin(position) pos : vec4f,
  @location(0) color : vec3f,
}

@vertex
fn vs(@location(0) pos : vec2f, @location(1) color : vec3f) -> VOut {
  var out : VOut;
  out.pos = vec4f(pos, 0.0, 1.0);
  out.color = color;
  return out;
}

@fragment
fn fs(v : VOut) -> @location(0) vec4f {
  return vec4f(v.color, 1.0);
}`;

  // x, y, r, g, b for each corner. x and y run from -1 to 1.
  const corners = new Float32Array([
     0.0,  0.8,   1.0, 0.3, 0.3,
    -0.8, -0.7,   0.3, 1.0, 0.4,
     0.8, -0.7,   0.3, 0.5, 1.0,
  ]);

  let device = null;

  async function start() {
    // 1. Is WebGPU here at all?
    if (!navigator.gpu) return fallback('navigator.gpu is undefined');
    let adapter = null;
    try { adapter = await navigator.gpu.requestAdapter(); } catch (err) { return fallback(err.message); }
    if (!adapter) return fallback('requestAdapter() returned null');
    device = await adapter.requestDevice();

    // 2. Connect the canvas to the device
    canvas.width = Math.round(canvas.clientWidth * devicePixelRatio);
    canvas.height = Math.round(canvas.clientHeight * devicePixelRatio);
    const context = canvas.getContext('webgpu');
    const format = navigator.gpu.getPreferredCanvasFormat();
    context.configure({ device, format });

    // 3. Copy the corners into a GPU buffer
    const buffer = device.createBuffer({
      size: corners.byteLength,
      usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
    });
    device.queue.writeBuffer(buffer, 0, corners);

    // 4. The pipeline: shaders + how to read the buffer
    const module = device.createShaderModule({ code: shaderCode });
    const pipeline = device.createRenderPipeline({
      layout: 'auto',
      vertex: {
        module, entryPoint: 'vs',
        buffers: [{
          arrayStride: 5 * 4,  // 5 floats of 4 bytes per corner
          attributes: [
            { shaderLocation: 0, offset: 0, format: 'float32x2' },
            { shaderLocation: 1, offset: 2 * 4, format: 'float32x3' },
          ],
        }],
      },
      fragment: { module, entryPoint: 'fs', targets: [{ format }] },
      primitive: { topology: 'triangle-list' },
    });

    // 5. Record the commands, then submit them
    const encoder = device.createCommandEncoder();
    const pass = encoder.beginRenderPass({
      colorAttachments: [{
        view: context.getCurrentTexture().createView(),
        clearValue: { r: 0.07, g: 0.09, b: 0.15, a: 1 },
        loadOp: 'clear',
        storeOp: 'store',
      }],
    });
    pass.setPipeline(pipeline);
    pass.setVertexBuffer(0, buffer);
    pass.draw(3);
    pass.end();
    device.queue.submit([encoder.finish()]);
    log.textContent = 'Drawn with WebGPU. Canvas format: ' + format;
  }

  // No WebGPU: draw the same triangle with canvas 2D and say why
  function fallback(reason) {
    canvas.width = canvas.clientWidth;
    canvas.height = canvas.clientHeight;
    const ctx = canvas.getContext('2d');
    const w = canvas.width, h = canvas.height;
    const px = (x, y) => [(x + 1) / 2 * w, (1 - y) / 2 * h];
    ctx.beginPath();
    ctx.moveTo(...px(0, 0.8)); ctx.lineTo(...px(-0.8, -0.7)); ctx.lineTo(...px(0.8, -0.7));
    ctx.fillStyle = '#6b7280';
    ctx.fill();
    log.className = 'warn';
    log.textContent = 'WebGPU is not available here (' + reason + ').\nFallback: the grey triangle is drawn with canvas 2D.';
  }

  start();

  // A missing ")" in the vertex shader. createShaderModule does not throw.
  document.getElementById('bad').addEventListener('click', async () => {
    if (!device) return;
    const broken = device.createShaderModule({ code: shaderCode.replace('color : vec3f) ->', 'color : vec3f ->') });
    const info = await broken.getCompilationInfo();
    log.textContent = info.messages.map(m => m.type + ' at line ' + m.lineNum + ':' + m.linePos + '\n' + m.message).join('\n');
  });
</script>
</body>
</html>
One HTML file, no library. If your browser has no WebGPU, it draws a grey fallback with canvas 2D and says why.

The whole recipe is five steps:

  1. Check for WebGPU. If navigator.gpu is undefined, show a fallback.
  2. Get an adapter and a device. Await requestAdapter(), check the result is not null, then await requestDevice().
  3. Configure the canvas. Call canvas.getContext('webgpu') and pass the device and getPreferredCanvasFormat() to configure.
  4. Build a pipeline. Put the WGSL code in createShaderModule, then describe the shaders and the buffer layout in createRenderPipeline.
  5. Record and submit. Begin a render pass on getCurrentTexture(), set the pipeline, draw, end the pass, and submit.

Which browsers run WebGPU

The WebGPU specification and its shading language, WGSL, are both W3C Candidate Recommendation Drafts. Support still depends on the browser and the operating system, so check for it in code rather than assuming it.

MDN's compatibility data lists support in these browsers:

Browser What MDN's data says
Chrome, Edge, Opera Supported. Chrome on Linux is limited to certain Intel GPUs
Chrome for Android, Samsung Internet Supported
Safari on Mac, iPhone and iPad Supported
Firefox Partial: Windows and Apple-silicon Macs, not Linux
Firefox for Android Not supported

What was tested for this page: Chrome and Edge on Windows ran all three examples, at desktop and phone width. A Chromium build with no GPU access returned null from requestAdapter(), and every example showed its fallback message.

WebGPU also needs a secure context. Pages on https, on localhost and opened from a file qualify. The same file served over plain http on a network address had no navigator.gpu at all in Chrome.

Four calls before the first draw

WebGL gives you a context straight away. WebGPU makes you ask for the hardware first, and two of the calls return promises.

Steps 1 and 2 decide between WebGPU and a fallback. Step 4 is only needed when you draw.
Steps 1 and 2 decide between WebGPU and a fallback. Step 4 is only needed when you draw.
if (!navigator.gpu) return fallback('no navigator.gpu');
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) return fallback('no adapter');
const device = await adapter.requestDevice();

const context = canvas.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format });
  • The adapter stands for a physical GPU and its driver. requestAdapter() resolves to null when no suitable one is available.
  • The device is your page's own logical view of that GPU. Every buffer, shader and pipeline is created from it.
  • An adapter hands out one device. Calling requestDevice() on it a second time rejects with an OperationError.

getPreferredCanvasFormat() returns the format that suits the screen, either bgra8unorm or rgba8unorm. It was bgra8unorm on the Windows machine used here.

A canvas starts at 300 x 150 pixels, whatever its CSS size. Set canvas.width and canvas.height from clientWidth and clientHeight times devicePixelRatio, or the drawing looks soft. HTML canvas blurry covers the size mismatch.

One WGSL module, two shaders

WebGPU shaders are written in WGSL, the WebGPU Shading Language. One module can hold both the vertex shader and the fragment shader, marked with @vertex and @fragment:

struct VOut {
  @builtin(position) pos : vec4f,
  @location(0) color : vec3f,
}

@vertex
fn vs(@location(0) pos : vec2f, @location(1) color : vec3f) -> VOut {
  var out : VOut;
  out.pos = vec4f(pos, 0.0, 1.0);
  out.color = color;
  return out;
}

@fragment
fn fs(v : VOut) -> @location(0) vec4f {
  return vec4f(v.color, 1.0);
}

The vertex shader runs once per corner and returns its position. The fragment shader runs once per covered pixel and returns its colour. @location(0) on the inputs matches shaderLocation: 0 in the buffer layout below.

Compile errors are quiet. createShaderModule does not throw on a typo. Ask the module for its messages:

const info = await module.getCompilationInfo();
for (const m of info.messages) {
  console.log(m.type, m.lineNum, m.linePos, m.message);
}

The button in the first example removes one ) and prints what Chrome returns: an error at line 8 saying a ) was expected.

Record the commands, then submit

The corner data goes into a GPU buffer, and the pipeline describes how to read it. Each corner here is five 4-byte floats, so the stride is 20 bytes and the colour starts 8 bytes in:

const buffer = device.createBuffer({
  size: corners.byteLength,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(buffer, 0, corners);

const pipeline = device.createRenderPipeline({
  layout: 'auto',
  vertex: { module, entryPoint: 'vs', buffers: [{
    arrayStride: 20,
    attributes: [
      { shaderLocation: 0, offset: 0, format: 'float32x2' },
      { shaderLocation: 1, offset: 8, format: 'float32x3' },
    ],
  }] },
  fragment: { module, entryPoint: 'fs', targets: [{ format }] },
});

Drawing is two phases. First you record commands into an encoder. Nothing reaches the GPU until you submit the finished command buffer to device.queue.

The encoder records. The queue runs. Every frame gets a new encoder and a new texture.
The encoder records. The queue runs. Every frame gets a new encoder and a new texture.
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
  colorAttachments: [{
    view: context.getCurrentTexture().createView(),
    clearValue: { r: 0.07, g: 0.09, b: 0.15, a: 1 },
    loadOp: 'clear',
    storeOp: 'store',
  }],
});
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, buffer);
pass.draw(3);
pass.end();
device.queue.submit([encoder.finish()]);

Leave out the last line and every call still succeeds, but the canvas stays empty. In the test, a pass that cleared to red and was never submitted left the canvas fully transparent.

Animate it with a uniform buffer

A uniform is a value that every corner and pixel in a draw can read, set from JavaScript. In WebGPU it lives in a buffer, and a bind group tells the pipeline which buffer to use.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebGPU uniform buffer</title>
<style>
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  canvas { display: block; width: 100%; height: 250px; border-radius: 10px; background: #111827; }
  .bar { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; margin-top: 10px; font-size: 14px; }
  label { display: flex; gap: 6px; align-items: center; }
  input[type=range] { width: 120px; }
  p { margin: 8px 0 0; font-size: 12px; color: #9a3412; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div class="bar">
  <label>Color <input type="color" id="color" value="#f59e0b"></label>
  <label>Speed <input type="range" id="speed" min="0" max="4" step="0.1" value="1"></label>
</div>
<p id="msg"></p>

<script>
  const canvas = document.getElementById('c');
  const colorInput = document.getElementById('color');
  const speedInput = document.getElementById('speed');

  const shaderCode = `
struct Params {
  angleAspect : vec4f,  // x = angle, y = width / height
  color : vec4f,
}
@group(0) @binding(0) var<uniform> u : Params;

@vertex
fn vs(@builtin(vertex_index) i : u32) -> @builtin(position) vec4f {
  // Three corners written in the shader, no vertex buffer needed
  var p = array<vec2f, 3>(vec2f(0.0, 0.7), vec2f(-0.6, -0.45), vec2f(0.6, -0.45));
  let a = u.angleAspect.x;
  let r = vec2f(p[i].x * cos(a) - p[i].y * sin(a), p[i].x * sin(a) + p[i].y * cos(a));
  return vec4f(r.x / u.angleAspect.y, r.y, 0.0, 1.0);
}

@fragment
fn fs() -> @location(0) vec4f {
  return u.color;
}`;

  async function start() {
    const adapter = navigator.gpu && await navigator.gpu.requestAdapter().catch(() => null);
    if (!adapter) {
      document.getElementById('msg').textContent = 'WebGPU is not available in this browser, so nothing is drawn. Try a browser listed in the article.';
      return;
    }
    const device = await adapter.requestDevice();
    const context = canvas.getContext('webgpu');
    const format = navigator.gpu.getPreferredCanvasFormat();
    context.configure({ device, format });

    // 8 floats = 32 bytes: two vec4f
    const params = new Float32Array(8);
    const uniformBuffer = device.createBuffer({
      size: params.byteLength,
      usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
    });

    const module = device.createShaderModule({ code: shaderCode });
    const pipeline = device.createRenderPipeline({
      layout: 'auto',
      vertex: { module, entryPoint: 'vs' },
      fragment: { module, entryPoint: 'fs', targets: [{ format }] },
    });

    // Tells the pipeline which buffer is @group(0) @binding(0)
    const bindGroup = device.createBindGroup({
      layout: pipeline.getBindGroupLayout(0),
      entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
    });

    let angle = 0, last = performance.now();

    function frame(now) {
      // Keep the drawing buffer the same size as the element
      const w = Math.round(canvas.clientWidth * devicePixelRatio);
      const h = Math.round(canvas.clientHeight * devicePixelRatio);
      if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }

      angle += (now - last) / 1000 * Number(speedInput.value);
      last = now;
      const hex = colorInput.value;
      params.set([angle, w / h, 0, 0,
        parseInt(hex.slice(1, 3), 16) / 255, parseInt(hex.slice(3, 5), 16) / 255, parseInt(hex.slice(5, 7), 16) / 255, 1]);
      device.queue.writeBuffer(uniformBuffer, 0, params);  // new values every frame

      const encoder = device.createCommandEncoder();
      const pass = encoder.beginRenderPass({
        colorAttachments: [{
          view: context.getCurrentTexture().createView(),  // a fresh texture each frame
          clearValue: { r: 0.07, g: 0.09, b: 0.15, a: 1 },
          loadOp: 'clear', storeOp: 'store',
        }],
      });
      pass.setPipeline(pipeline);
      pass.setBindGroup(0, bindGroup);
      pass.draw(3);
      pass.end();
      device.queue.submit([encoder.finish()]);
      requestAnimationFrame(frame);
    }
    requestAnimationFrame(frame);
  }

  start();
</script>
</body>
</html>
The angle, the aspect ratio and the colour are written to a uniform buffer every frame. Set the speed to 0 and it stops.
struct Params {
  angleAspect : vec4f,  // x = angle, y = width / height
  color : vec4f,
}
@group(0) @binding(0) var<uniform> u : Params;
const uniformBuffer = device.createBuffer({
  size: 32,  // two vec4f
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const bindGroup = device.createBindGroup({
  layout: pipeline.getBindGroupLayout(0),
  entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
});

function frame(now) {
  device.queue.writeBuffer(uniformBuffer, 0, params);
  // ...begin a pass on a fresh getCurrentTexture()...
  pass.setBindGroup(0, bindGroup);
  requestAnimationFrame(frame);
}

With layout: 'auto', the pipeline builds its bind group layout from the @group and @binding lines in the shader, and getBindGroupLayout(0) hands it back.

getCurrentTexture() returns the next texture the canvas will show, so call it inside the frame function, not once at the start. The loop itself is ordinary requestAnimationFrame.

This example has no vertex buffer at all. The three corners are written in the shader, and @builtin(vertex_index) says which corner is being processed.

Compute: use the GPU as a calculator

WebGPU has a second kind of pipeline that draws nothing. A compute shader reads and writes buffers, and many copies of it run in parallel.

The GPU writes into a storage buffer. JavaScript can only read a MAP_READ buffer, so the result is copied across first.
The GPU writes into a storage buffer. JavaScript can only read a MAP_READ buffer, so the result is copied across first.
@group(0) @binding(0) var<storage, read_write> data : array<f32>;

@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id : vec3u) {
  if (id.x >= arrayLength(&data)) { return; }
  data[id.x] = data[id.x] * data[id.x];
}

Each workgroup covers 64 numbers, so the page dispatches Math.ceil(n / 64) workgroups. The last group can reach past the end of the array, which is why the shader checks the index first.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebGPU compute shader</title>
<style>
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; font-size: 14px; }
  select, button { font: inherit; font-size: 14px; padding: 7px 10px; border-radius: 8px; border: 1px solid #c9cdd4; background: #fff; }
  button { cursor: pointer; }
  .out { margin-top: 12px; padding: 12px; border-radius: 10px; background: #fff; border: 1px solid #e1e4ea; min-height: 150px; }
  .out b { display: block; font-size: 13px; margin-bottom: 6px; }
  .nums { font: 13px/1.6 ui-monospace, Consolas, monospace; word-break: break-all; color: #374151; }
  .ok { color: #0f5132; font-size: 13px; margin-top: 8px; }
  .warn { color: #9a3412; font-size: 13px; }
</style>
</head>
<body>
<div class="bar">
  <label>Numbers
    <select id="count">
      <option value="1000">1,000</option>
      <option value="100000" selected>100,000</option>
      <option value="1000000">1,000,000</option>
    </select>
  </label>
  <button id="run">Square them on the GPU</button>
</div>
<div class="out" id="out"><span class="nums">Press the button.</span></div>

<script>
  const out = document.getElementById('out');

  // One compute shader: each invocation squares one number
  const shaderCode = `
@group(0) @binding(0) var<storage, read_write> data : array<f32>;

@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id : vec3u) {
  if (id.x >= arrayLength(&data)) { return; }
  data[id.x] = data[id.x] * data[id.x];
}`;

  let device = null, pipeline = null;

  async function setup() {
    const adapter = navigator.gpu && await navigator.gpu.requestAdapter().catch(() => null);
    if (!adapter) return false;
    device = await adapter.requestDevice();
    pipeline = device.createComputePipeline({
      layout: 'auto',
      compute: { module: device.createShaderModule({ code: shaderCode }), entryPoint: 'main' },
    });
    return true;
  }

  async function run() {
    if (!device && !(await setup())) {
      out.innerHTML = '<span class="warn">WebGPU is not available in this browser, so the shader cannot run here.</span>';
      return;
    }
    const n = Number(document.getElementById('count').value);
    const input = new Float32Array(n).map((_, i) => i);

    // The GPU works on a storage buffer...
    const work = device.createBuffer({
      size: input.byteLength,
      usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
    });
    device.queue.writeBuffer(work, 0, input);
    // ...and JavaScript can only read a MAP_READ buffer
    const readback = device.createBuffer({
      size: input.byteLength,
      usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
    });

    const bindGroup = device.createBindGroup({
      layout: pipeline.getBindGroupLayout(0),
      entries: [{ binding: 0, resource: { buffer: work } }],
    });

    const encoder = device.createCommandEncoder();
    const pass = encoder.beginComputePass();
    pass.setPipeline(pipeline);
    pass.setBindGroup(0, bindGroup);
    pass.dispatchWorkgroups(Math.ceil(n / 64));  // 64 numbers per workgroup
    pass.end();
    encoder.copyBufferToBuffer(work, 0, readback, 0, input.byteLength);
    device.queue.submit([encoder.finish()]);

    // Wait until the GPU is done, then copy the result out
    await readback.mapAsync(GPUMapMode.READ);
    const result = new Float32Array(readback.getMappedRange().slice(0));
    readback.unmap();
    work.destroy(); readback.destroy();

    // Check every value against plain JavaScript
    let wrong = 0;
    for (let i = 0; i < n; i++) if (result[i] !== Math.fround(i * i)) wrong++;
    out.innerHTML = '<b>First 10 results</b><div class="nums">' + Array.from(result.slice(0, 10)).join(', ') +
      '</div><b style="margin-top:8px">Last result</b><div class="nums">' + (n - 1) + '² = ' + result[n - 1] +
      '</div><div class="ok">' + (wrong ? wrong + ' values differ from JavaScript' : 'All ' + n.toLocaleString('en') + ' values match JavaScript.') + '</div>';
  }

  document.getElementById('run').addEventListener('click', run);
</script>
</body>
</html>
Squares up to 1,000,000 numbers on the GPU, reads them back, and checks every one against JavaScript.

Reading the result back takes three steps:

  1. Copy the storage buffer into a buffer made with MAP_READ | COPY_DST. A buffer with MAP_READ can have no other usage except COPY_DST, so it cannot be the storage buffer itself.
  2. Await mapAsync(GPUMapMode.READ).
  3. Copy the data out of getMappedRange() before calling unmap(). After unmap(), that ArrayBuffer is detached and its length is 0.

A long compute job can also run in a Web Worker, where navigator.gpu is available too.

WebGPU or WebGL

Both draw on a <canvas> with the GPU and need no library. WebGL in HTML walks through the same triangle in WebGL.

WebGL WebGPU
Getting started getContext('webgl2') returns a context at once Await an adapter and a device, then configure the canvas
Shader language GLSL WGSL
Shaders Separate vertex and fragment sources linked into a program One module can hold both entry points
General computation Not designed for it Compute pipelines
Errors getError(), which waits for the GPU Error scopes that resolve a promise

To catch a validation error from a specific call, wrap it in device.pushErrorScope('validation') and await device.popErrorScope(). The promise resolves to the first error in the scope, or null.

When it does not work

What you see Cause Fix
navigator.gpu is undefined No WebGPU in this browser, or the page is not a secure context Serve over https or localhost, and show a fallback
requestAdapter() gives null No GPU adapter available to the page Show a fallback
Canvas stays empty, no error The command buffer was never submitted Pass encoder.finish() to queue.submit()
InvalidStateError from getCurrentTexture() The context is not configured yet Call context.configure first
Nothing drawn after a shader edit WGSL compile error, and createShaderModule does not throw Await getCompilationInfo() and print the messages
OperationError from requestDevice() A second device from the same adapter Request a new adapter first
getContext('2d') returns null The canvas already has a webgpu context Use one context type per canvas
Validation error on createBuffer MAP_READ combined with STORAGE Use a separate read-back buffer
Result array has length 0 It was read after unmap() Copy it with slice() before unmapping
Soft or stretched drawing The canvas is still 300 x 150 Set width and height from the element size

A WebGPU page is hard to show in a screenshot. The triangle stops turning, and the viewer cannot drag the speed slider or run the compute shader on their own GPU.

An .html attachment may open as plain code on a phone. Opening an HTML file on a phone explains why.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to see the shape move and can press the buttons themselves.

Anyone whose browser has no WebGPU sees your fallback message instead. If you change the code later, the same link shows the new version.

Inside NOS, a page cannot load images, frames or data from other sites, so keep everything in the one file. The shaders here are plain strings in the script, and every number is generated in the page.

Questions people ask

Do I need a library to use WebGPU?

No. In browsers that support it, WebGPU is built in and starts at navigator.gpu. The first example on this page draws a triangle with no library and no build step.

Why is navigator.gpu undefined?

Either the browser has no WebGPU, or the page is not a secure context. WebGPU is only exposed on secure pages: https, localhost and file URLs count. A page opened over plain http on a network address does not get navigator.gpu at all.

Why does requestAdapter() return null?

The browser found no GPU adapter it is willing to hand out. It happened here in a Chromium build running without GPU access, while Chrome and Edge on the same machine returned an adapter. Check for null and show a fallback.

Does WebGPU replace WebGL?

MDN describes WebGPU as the successor to WebGL, with support for general-purpose GPU computation. WebGL is not marked deprecated in MDN's compatibility data, and it remains a way to draw on the GPU from a canvas.

Can I use WebGPU in a Web Worker?

Yes. Inside a worker, WorkerNavigator.gpu gives the same GPU object that navigator.gpu gives on the page. A compute job can then run without touching the page's main thread.

Keep reading