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.
// 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.
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.
// 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.
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.
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.
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
Directly translating WebGL's per-object
bindBuffer+drawloop 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.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.Expecting
gl.generateMipmapto have an equivalent. WebGPU has no automatic mipmap generation. A texture created withmipLevelCount > 1but no generation pass samples uninitialized higher mip levels and renders black or garbage at distance.Treating WebGPU errors as synchronous like
gl.getError. Polling for errors finds nothing because errors resolve asynchronously throughpopErrorScopeor fire on theuncapturederrorevent 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_ATTACHMENTusage and a generation pass.gl.generateMipmapdoes not exist in WebGPU. - NEVER match bindings by name. WebGPU bindings are positional; the WGSL
@bindingindex and theGPUBindGroupLayoutbindingmust agree exactly. - NEVER expect
gl.draw*semantics. Nothing runs untilqueue.submit, and a render pass MUST be closed withpass.end()beforeencoder.finish(). - NEVER resize a
GPUBufferorGPUTexturein 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).