WebGL2 · WebGPU · measured, not quoted

Points of order

One particle array, one projection, two backends. In points mode the right canvas is nearly empty, because WGSL has nowhere to write a point size. Switch to quads and it comes back. Everything on this page is rendered rather than described, and the point-size numbers are read back out of the framebuffer.

primitive
WebGL2 points · gl_PointSize ···
WebGPU detecting ···
Requested size12px

What both backends were asked for, in CSS pixels.

WebGL, measuredpress measure

Lit bounding box of one particle, read back with readPixels.

WebGPU, measuredpress measure

Same particle, read back with copyTextureToBuffer.

Written instance slots1600 of 2400

Drag drawn past this and the unwritten slots still render.

What this is not. This is not a benchmark and the two panels are not competing. They render the same 2,400 particles with the same maths so that the only variable is the API, and neither number here says anything about which backend is faster. The frame counters are there to show both loops are alive, nothing more.
01 · point size

There is no gl_PointSize in WGSL, and that is the whole story

In WebGL you write one builtin in the vertex shader and a point becomes a quad the driver rasterises for you. WGSL has no equivalent output. Not renamed, not gated behind a device limit, not a Chrome gap that will close: a point-list primitive is one fragment, and the spec says so deliberately, because point sprites were a source of driver divergence for twenty years.

Nothing warns you. The port compiles, the pipeline validates, the draw call succeeds, and your particle hero renders as a dusting of single pixels that reads on a retina display as "the shader is subtly broken" rather than "the API dropped a feature". The fix is two triangles per particle with the size applied in the vertex shader, which is what the quads switch above does, and which you were probably going to need anyway the first time you wanted a soft edge.

// WebGL2: the driver sizes the point for you
gl_Position  = project(pos);
gl_PointSize = u_size;                // 12.0 -> a 12px square of fragments

// WGSL: there is no line to write here. A point is one fragment.
// So expand a quad yourself, in NDC, from the pixel size you wanted:
const C = array<vec2f,6>(vec2f(-1,-1),vec2f(1,-1),vec2f(-1,1),
                        vec2f(-1,1),vec2f(1,-1),vec2f(1,1));
var clip = project(pos);
clip.x += C[vi].x * u.size / u.res.x;   // half-size, px -> ndc
clip.y += C[vi].y * u.size / u.res.y;
02 · line width

WebGPU cannot set line width. Neither can your WebGL, probably

The source I took this from lists ignored line width as a WebGPU limitation. It is, and there is no API for it at all. The part usually left out: gl.lineWidth() has been a no-op on nearly every desktop driver for a decade. WebGL reports its real range in ALIASED_LINE_WIDTH_RANGE, which is printed live in the receipt below from your machine.

Drag the lines slider up and watch both panels. If your driver is like most, the two hairlines stay identical at every setting and the only honest conclusion is that WebGPU removed an API that never worked. That reframes the porting note: this is not a regression to route around, it is a bit of folklore finally being deleted. Thick lines were always ribbons of triangles, in both APIs, whether or not you knew it.

03 · unwritten instances

The slots you never wrote are not garbage. They are zero, and zero is a place

Push the drawn slider past the written-slots figure. The extra instances appear, all of them stacked in the centre of the scene, and the frame rate does not move because they cost almost nothing. This is the failure people describe as random geometry appearing at the origin when they raise an instance count mid-interaction.

The usual explanation is that the buffer contains garbage. It does not, and that matters when you are debugging at 1am. Both WebGL and WebGPU zero-fill new GPU allocations, because handing your page whatever another process left in video memory would be a serious information leak. What you are seeing is vec3(0,0,0) for every unwritten particle, projected to exactly the same screen position. It looks like corruption. It is arithmetic.

Which gives you a real fix rather than a superstition. Zeroing the buffer again will not help, it is already zero. Either write every slot you intend to draw before you raise the count, or seed the unused ones with a scale of zero so the degenerate geometry is culled at no cost.

01

Nothing throws

All three failures produce a successful draw call. There is no console warning to grep for, which is why they cost an afternoon each.

02

Measure the pixel

Both figures above are a framebuffer readback, not a claim. The technique costs a pipeline stall, so it sits behind a button.

03

Quads were the answer anyway

Soft edges, per-particle rotation, texture atlases: everything past a hard dot needs a quad. The WebGPU limitation only forces the thing you would have done.

04 · receipt

Verify it yourself

> measured on this machine, this visit
[WEBGL] detecting
[WEBGPU] detecting
[POINTSZ] WebGL ALIASED_POINT_SIZE_RANGE: detecting · WGSL has no point-size output at all
[LINEW] WebGL ALIASED_LINE_WIDTH_RANGE: detecting
[LINEW] WebGPU has no line-width API to report. A line is one device pixel wide by specification.
[SCENE] 2400 slots allocated · 1600 written · one Float32Array feeds both backends
[READBK] not run yet, it is behind the measure button
[NETWORK] 0 outbound requests since load, measured by PerformanceObserver
[ASSETS] 0 CDNs · 0 webfonts · 0 analytics · single HTML file · works offline
[LIMIT] The two panels are not a benchmark. Same scene, same maths, different API, nothing more.
[LIMIT] A readback stalls the pipeline. Never do this per frame in production.
[LIMIT] Measured sizes are rounded to whole device pixels, so a 1px point on a 3x display reads as 0.33 CSS px.
Back to the Labs →