webgpu-impl-webgl-migration

Map every WebGL concept to its WebGPU equivalent and avoid the two ports that silently break: clip-space Z range and the removed automatic mipmap generation. Targets WebGPU 1.0-stable (Chrome 113+, Safari 26+, Firefox 141+).

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI

Agent Skills format with YAML frontmatter. Claude Code reads it as-is.

---
name: "webgpu-impl-webgl-migration"
description: "Map every WebGL concept to its WebGPU equivalent and avoid the two ports that silently break: clip-space Z range and the removed automatic mipmap generation. Targets WebGPU 1.0-stable (Chrome 113+, Safari 26+, Firefox 141+)."
license: "MIT"
---

# WebGL to WebGPU Migration

Map every WebGL concept to its WebGPU equivalent and avoid the two ports that silently break: clip-space Z range and the removed automatic mipmap generation. Targets WebGPU 1.0-stable (Chrome 113+, Safari 26+, Firefox 141+).

## Quick Reference

| WebGL concept | WebGPU equivalent |
|---|---|
| Immediate-mode `gl.draw*` against global state | Record commands into a `GPUCommandEncoder`, submit a `GPUCommandBuffer` via `queue.submit` |
| Global state machine (`gl.enable`, `gl.bindBuffer`, `gl.blendFunc`) | Immutable `GPURenderPipeline` capturing blend, topology, depth, cull state |
| GLSL shaders | WGSL shaders |
| Uniforms set by name (`gl.getUniformLocation`) | `GPUBindGroup` + `GPUBindGroupLayout`, positional `@group(g) @binding(b)` |
| Framebuffer objects (FBOs) | Render pass `colorAttachments` / `depthStencilAttachment` |
| `gl.getError()` synchronous | Asynchronous `pushErrorScope` / `popErrorScope` + `uncapturederror` event |
| `gl.generateMipmap()` | Manual mipmap generation (render-pass downsample chain or compute pass) |
| Resizable buffers/textures via re-upload | Immutable size/format; destroy and recreate the resource to resize |
| Uniform buffer 64 KB limit (`maxUniformBufferBindingSize` 65536) | Storage buffers (`maxStorageBufferBindingSize` 134217728, 128 MiB+) |
| `gl.viewport` / clip Z `[-1, 1]` | Clip Z `[0, 1]`; rebuild the projection matrix |
| `gl.texImage2D` upload | `queue.writeTexture` or `copyExternalImageToTexture` |

## Decision Tree

```
Porting a WebGL app to WebGPU?
├─ Goal is "render the same output, same draw structure"
│  └─ Direct port: translate each gl call to its mapping above.
│     WORKS, but keeps WebGL's per-object CPU cost. Acceptable
│     ONLY for small scenes or a first migration milestone.
│
├─ Goal is "gain WebGPU's performance"
│  └─ Restructure: sort draws by pipeline then bind group,
│     build pipelines once at load time, pack uniforms into one
│     buffer with dynamic offsets, record static scenes into
│     render bundles. See webgpu-impl-performance.
│
└─ Texture uses mipmaps in WebGL?
   └─ gl.generateMipmap has NO equivalent. Add an explicit
      mipmap generation pass (see Core Patterns) BEFORE the
      texture is sampled. See references/methods.md.
```

## Core Patterns

### ALWAYS rebuild the projection matrix for clip-space Z `[0, 1]`

WebGL clip space has Z in `[-1, 1]` (OpenGL convention). WebGPU clip space has Z in `[0, 1]` (Metal convention) and the framebuffer is Y-down. A WebGL projection matrix used unchanged in WebGPU places the entire scene in the wrong half of the depth range, so depth testing produces wrong results or nothing renders.

```js
// NEVER: a WebGL [-1, 1] gl-matrix perspective matrix used as-is in WebGPU.
mat4.perspective(proj, fovy, aspect, near, far);        // OpenGL [-1, 1] Z

// ALWAYS: use a [0, 1] Z projection. gl-matrix exposes the *ZO variant.
mat4.perspectiveZO(proj, fovy, aspect, near, far);      // WebGPU [0, 1] Z
mat4.orthoZO(proj, left, right, bottom, top, near, far);
```

### ALWAYS record commands into a `GPUCommandEncoder`, never expect immediate execution

WebGL's `gl.drawArrays` executes against global state immediately. WebGPU records into an encoder and runs nothing until `queue.submit`.

```js
const encoder = device.createCommandEncoder({ label: "frame" });
const pass = encoder.beginRenderPass({
  colorAttachments: [{
    view: context.getCurrentTexture().createView(),     // fresh every frame
    loadOp: "clear", storeOp: "store", clearValue: [0, 0, 0, 1],
  }],
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(3);
pass.end();                                             // NEVER omit end()
device.queue.submit([encoder.finish()]);                // nothing runs before this
```

### ALWAYS bind by position, never by name

WebGL resolves uniforms by name through `gl.getUniformLocation`. WebGPU matches `@group(g) @binding(b)` in WGSL to the `binding` index in the `GPUBindGroupLayout`. The shader and the layout MUST be kept in sync manually because no name lookup exists.

```js
// WGSL: @group(0) @binding(0) var<uniform> camera : Camera;
const layout = device.createBindGroupLayout({
  entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } }],
});
const bindGroup = device.createBindGroup({
  layout,
  entries: [{ binding: 0, resource: { buffer: cameraBuffer } }],   // index, not name
});
```

### ALWAYS treat buffers and textures as immutable, recreate to resize

WebGL re-uploads to grow a buffer. WebGPU buffers and textures have fixed `size` and `format` at creation. To resize, call `destroy()` and create a new resource.

```js
function resizeStorageBuffer(device, oldBuffer, newSize) {
  oldBuffer.destroy();                                  // free the old one
  return device.createBuffer({
    label: "particles", size: newSize,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  });
}
```

### ALWAYS generate mipmaps with an explicit pass, `gl.generateMipmap` has no equivalent

WebGPU has no built-in mipmap generation. Create the texture with `mipLevelCount` and `RENDER_ATTACHMENT` usage, then render each smaller mip by sampling the previous mip. See references/methods.md and references/examples.md for the full downsample chain.

```js
const mipCount = 1 + Math.floor(Math.log2(Math.max(width, height)));
const texture = device.createTexture({
  size: [width, height], format: "rgba8unorm", mipLevelCount: mipCount,
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
       | GPUTextureUsage.RENDER_ATTACHMENT,             // RENDER_ATTACHMENT is mandatory
});
// then run the downsample chain (references/examples.md)
```

### ALWAYS treat errors as asynchronous, never poll like `gl.getError`

WebGL's `gl.getError()` returns synchronously. WebGPU errors surface asynchronously through `popErrorScope` (a `Promise`) or the `uncapturederror` event. Errors are contagious: an object built from an invalid descriptor is itself invalid and every dependent operation fails.

```js
device.pushErrorScope("validation");
const pipeline = device.createRenderPipeline(descriptor);
const error = await device.popErrorScope();             // async, not synchronous
if (error) console.error("Pipeline invalid:", error.message);
```

## Common Anti-Patterns

1. **Directly translating WebGL's per-object `bindBuffer` + `draw` loop without restructuring.** It compiles and runs, but it keeps WebGL's per-object CPU cost and discards WebGPU's only advantage. Restructure into sorted draws, reused pipelines, and render bundles. See webgpu-impl-performance.

2. **Using a WebGL `[-1, 1]` projection matrix in WebGPU.** WebGPU clip-space Z is `[0, 1]`. A `[-1, 1]` matrix places geometry in the wrong depth half so depth testing fails and the scene renders wrong or blank. Use a `[0, 1]` (Metal-convention) projection.

3. **Expecting `gl.generateMipmap` to have an equivalent.** WebGPU has no automatic mipmap generation. A texture created with `mipLevelCount > 1` but no generation pass samples uninitialized higher mip levels and renders black or garbage at distance.

4. **Treating WebGPU errors as synchronous like `gl.getError`.** Polling for errors finds nothing because errors resolve asynchronously through `popErrorScope` or fire on the `uncapturederror` event later.

## Critical Warnings

- NEVER reuse a WebGL `[-1, 1]` projection matrix in WebGPU. Rebuild it for clip-space Z `[0, 1]`.
- NEVER create a mipmapped texture without `RENDER_ATTACHMENT` usage and a generation pass. `gl.generateMipmap` does not exist in WebGPU.
- NEVER match bindings by name. WebGPU bindings are positional; the WGSL `@binding` index and the `GPUBindGroupLayout` `binding` must agree exactly.
- NEVER expect `gl.draw*` semantics. Nothing runs until `queue.submit`, and a render pass MUST be closed with `pass.end()` before `encoder.finish()`.
- NEVER resize a `GPUBuffer` or `GPUTexture` in place. They are immutable; `destroy()` and recreate.
- NEVER poll for errors. WebGPU errors are asynchronous and contagious; use `pushErrorScope`/`popErrorScope`.

## Reference Files

- `references/methods.md` : full WebGL-to-WebGPU concept mapping table and manual mipmap generation methods.
- `references/examples.md` : a WebGL textured-quad pattern ported to WebGPU, plus a complete manual mipmap generation pass.
- `references/anti-patterns.md` : migration mistakes with WHY-it-fails explanations.

Cross-links: `webgpu-impl-performance` (restructure for speed, render bundles), `webgpu-syntax-render-pipeline` (pipeline state captured from global state), `webgpu-errors-validation` (asynchronous error scopes), `webgpu-syntax-textures` (texture creation, formats, samplers), `webgpu-impl-render-usecases` (full-screen quad, render workloads).

More General & Other skills

← All General & Other skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY