Highlight
WebGPU is currently the only browser API that can run general-purpose GPU compute. On Apple platforms it runs on top of Metal, covering Mac, iPhone, iPad, and Vision Pro.
Core content
In the past, web 3D meant WebGL. WebGL has no compute shader. To run a physics simulation, a particle system, or neural network inference in the browser, developers had to grind through it on the CPU or move the work into a native app. WebGL’s state-machine API also carries heavy historical baggage: every draw call goes through extensive bounds checking, and performance lags far behind native.
WebGPU starts over. In this session, Mike from the Safari team draws three conclusions. First, WebGPU can do everything WebGL does for 3D graphics, with better performance and flexibility (00:16). Second, WebGPU is the only browser API that can run general-purpose GPU compute (00:28). Third, WebGPU on Apple platforms is implemented on top of Metal and covers Mac, iPhone, iPad, and Vision Pro. Most calls map one-to-one to Metal, so the ramp-up cost for Metal developers is close to zero (00:38).
The API is flat. At the top sit GPU and GPUAdapter. GPUDevice is the entry point for most calls and corresponds to Metal’s MTLDevice. The resource layer is buffers, textures, and samplers. Encoders submit commands. Pipelines describe how resources are interpreted. Bind groups bundle related resources together and map down to Metal’s argument buffer. The shading language WGSL was designed from scratch to fit the web’s safety model. It supports vertex, fragment, and compute programs. The session ties the entire pipeline together with a 100,000-triangle plus particle physics example.
Details
Device init and the f16 extension
Every WebGPU app starts with a GPUDevice. At (05:14) Mike shows how to create a device with the shader-f16 extension:
const canvas = document.querySelector('canvas');
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice({
requiredFeatures: ['shader-f16'],
});
const context = canvas.getContext('webgpu');
context.configure({ device, format: 'bgra8unorm' });
Key points:
navigator.gpu.requestAdapter()returns the GPU adapter the system makes available, equivalent to picking a physical device.requestDeviceis the entry point for most subsequent APIs, equivalent to Metal’sMTLDevice.requiredFeatures: ['shader-f16']turns on half-precision floats. It cuts memory bandwidth in half, but not every platform supports it, so check availability before using it (05:39).context.configurebinds the canvas to GPU-writable memory so the rendered result can reach the screen.
Creating buffers and textures
The two core resource types are buffers and textures, mapping to MTLBuffer and MTLTexture (06:24).
const particleBuffer = device.createBuffer({
size: byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(particleBuffer, 0, particleData);
const texture = device.createTexture({
size: [width, height],
format: 'rgba8unorm',
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
});
device.queue.copyExternalImageToTexture(
{ source: imageBitmap },
{ texture },
[width, height],
);
Key points:
usageis a bitmask that tells WebGPU what the memory is for. Once it is declared, WebGPU can avoid data races without adding extra API complexity (07:00).device.queue.writeBuffertakes a buffer, an offset, and a JSArrayBuffer, and copies data from JS into GPU-visible memory.- Textures are uploaded from
ImageBitmapviacopyExternalImageToTexture, typically for surface maps. - Textures support five shapes: 1D, 2D, 2D array, cube map (six 2D textures), and 3D (07:44).
Writing a compute shader in WGSL
WebGL has no compute shader. This is WebGPU’s biggest jump in capability (12:17). The skeleton of a particle physics compute shader looks like this (12:26):
enable f16;
@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
var p = particles[gid.x];
p.velocity += gravity * deltaTime;
p.position += p.velocity * deltaTime;
particles[gid.x] = p;
}
Key points:
enable f16turns on half-precision types in the shader. It must be paired with theshader-f16extension on the device side (15:24).@workgroup_size(64)defines the grid size of each workgroup and decides the parallelism granularity of the compute shader.@builtin(global_invocation_id)is a WGSL built-in that gives the current thread’s position in the dispatch grid, with no need to pass it from JS (12:34).var<storage, read_write>declares a read-write storage buffer. If the buffer is read-only, usereadinstead. WebGPU can then skip some of the validation (16:38).
Render bundles cache draw commands
WebGPU validates every read and write boundary on every frame. For static scenes that work is pure waste. Render bundles move the validation up front to a one-time creation step (17:04):
const encoder = device.createRenderBundleEncoder({
colorFormats: ['bgra8unorm'],
});
encoder.setPipeline(pipeline);
encoder.setBindGroup(0, bindGroup);
encoder.setVertexBuffer(0, vertexBuffer);
encoder.draw(vertexCount);
const bundle = encoder.finish();
// Replay every frame
renderPass.executeBundles([bundle]);
Key points:
createRenderBundleEncoderis just a recorder. Onlyfinish()produces a bundle that can actually be replayed.executeBundles([bundle])replays all the commands in one shot and skips per-frame validation.- It maps down to Metal’s indirect command buffer, with performance gains on par with native Metal (18:03).
Dynamic offset merges bind groups
Each bind group corresponds to an MTLBuffer. Once the count grows, Metal objects explode. Dynamic offset lets a single bind group reuse one layout and switch to different offsets at runtime (19:49):
const layout = device.createBindGroupLayout({
entries: [{
binding: 0,
visibility: GPUShaderStage.VERTEX,
buffer: { type: 'uniform', hasDynamicOffset: true },
}],
});
const bindGroup = device.createBindGroup({
layout,
entries: [{ binding: 0, resource: { buffer: bigBuffer, size: 64 } }],
});
renderPass.setBindGroup(0, bindGroup, [offset]);
Key points:
hasDynamicOffset: truerequires a custom layout. The shader’s auto layout will not work (20:07).- The third argument to
setBindGroupis the dynamic offset array. Each dynamic buffer has its own offset. - Mike’s comparison: ten 64-byte bind groups can be merged into one 640-byte buffer plus ten offsets, saving nine Metal buffers (20:43).
Command buffer and pass count
Command buffer boundaries force a sync between on-chip fast memory and unified memory, and the cost is not small. Use a single command buffer per update loop. Only split when you need to write data back to unified memory (18:28). Passes do not need to sync, but each pass burns bandwidth, so merge them where you can. Apple GPUs are tile-based deferred renderers, and the gain from merging passes is more pronounced than on desktop GPUs (19:15).
Takeaways
1. Bet new web projects on WebGPU and skip WebGL
Why it matters: WebGPU on every Apple platform runs through Metal. Its performance ceiling is in a different league from WebGL, and only WebGPU can run a compute shader. WebGL’s ceiling cannot support LLM inference, physics simulation, or complex particle systems.
How to start: Begin with libraries that already support a WebGPU backend, like three.js or Babylon.js, and switch the renderer to WebGPURenderer. To write at the lower level, use navigator.gpu.requestAdapter() directly and refer to the WebGPU Samples.
2. Move browser-side inference and physics onto compute shaders
Why it matters: Running transformer models, rigid body simulation, or fluid simulation in the browser used to mean WASM SIMD or giving up. Compute shaders let these workloads run with GPU parallelism, never leave the browser, and require almost no deployment cost.
How to start: First get on-device inference working with Transformers.js, which already supports a WebGPU backend. For your own models, look at WGSL’s @compute @workgroup_size and global_invocation_id and write a minimal viable kernel.
3. Put static and semi-static scenes through render bundles
Why it matters: For UI elements and 3D scenes that update infrequently, validating every frame is pure waste. Render bundles move the validation up front once, and performance gets close to native Metal.
How to start: Pull the commands that repeat every frame out of your existing render pass, record them once with createRenderBundleEncoder, and switch the main loop to executeBundles. Keep the dynamic parts on a regular pass.
4. On iOS and visionOS, default to f16 plus dynamic offset to control memory
Why it matters: At 15:01 Mike points out that iOS and visionOS are extremely sensitive to memory. Going over the limit gets the process killed by the system. f16 cuts bandwidth in half. Dynamic offset cuts the number of Metal objects. Together they are the most direct way to extend a process’s lifetime.
How to start: Add requiredFeatures: ['shader-f16'] to requestDevice and enable f16 in WGSL. Move large read-only data like weights and vertices to f16 first. Run all bind groups through dynamic offset and merge similar objects into a single big buffer.
Related sessions
- Discover Apple-Hosted Background Assets — Use Apple-hosted background assets to ship large model weights, textures, and similar resources to the device.
- Dive deeper into Writing Tools — Advanced integration of Writing Tools, another path for on-device AI writing.
- Dive into App Store server APIs for In-App Purchase — Server-side IAP APIs you will reach for when monetizing GPU-heavy web apps.
- Enhance child safety with PermissionKit — PermissionKit helps improve safety for children in communication scenarios.
Comments
GitHub Issues · utterances