Migrating a WebGL 2.0 Tile Renderer to WebGPU

A WebGL 2.0 tile renderer does not become a WebGPU one by translating its calls. The APIs differ in ways that matter — explicit pipelines instead of global state, bind groups instead of uniform locations, and a compute stage that has no WebGL equivalent — and a line-by-line port produces a WebGPU renderer with a WebGL architecture, which is slower than the original and harder to change. The port that works is staged: define one interface both backends implement, move the render path without changing what it does, and only then move the CPU work that motivated the migration. This page is that sequence, the mapping table between the two APIs, and the measurement that says whether each stage paid. It is one stage of WebGPU vs WebGL 2.0 for spatial workloads.

Three stages, each measurable on its own Three ordered migration stages. Stage one defines one renderer interface and reimplements the existing WebGL 2.0 renderer behind it, changing no behaviour and providing the seam everything else depends on. Stage two adds a WebGPU backend that does exactly what the WebGL one does, which is expected to be roughly break-even and proves the seam is real. Stage three moves the transform and cull work into compute passes, which is where the frame time actually improves, and is only reachable because the first two stages made it a contained change. MIGRATION · THREE STAGES 1 Define the interface reimplement WebGL behind it no behaviour change; pure seam 2 Add a WebGPU backend same behaviour, new API expect roughly break-even 3 Move work to compute transform and cull on the GPU this is where the gain is Doing stages two and three together means a regression cannot be attributed to either.
Stage two being break-even is the expected result, not a disappointment. The API change alone buys a few percent; the architecture change in stage three is what the migration was for.

Runnable reference implementation

The interface is the whole strategy. It has to be narrow enough that both backends implement it honestly and wide enough that the application never branches.

typescript
/** What the application asks a renderer to do. Both backends implement it. */
export interface TileRenderer {
  /** Upload or replace one tile's geometry. Returns a handle. */
  putTile(key: TileKey, vertices: ArrayBuffer, indices: ArrayBuffer): TileHandle;
  dropTile(handle: TileHandle): void;

  /** Draw the given tiles with the given camera. No backend types escape. */
  render(camera: Float32Array, visible: readonly TileHandle[]): void;

  /** Capabilities the application may branch on — but only here. */
  readonly caps: {
    readonly compute: boolean;      // true only on the WebGPU backend
    readonly maxResidentBytes: number;
  };
}

The caps.compute flag is the one place the application is allowed to know which backend is live, and it should gate a strategy rather than a code path — GPU-side culling when true, CPU-side culling when false, with the same interface either way. That keeps the WebGL backend a real fallback rather than a degraded copy, which is the same discipline described under implementing WebGL 2.0 fallbacks.

typescript
// The application never sees a GPUDevice or a WebGL2RenderingContext.
const renderer: TileRenderer = caps.webgpu
  ? await createWebGPURenderer(canvas)
  : createWebGL2Renderer(canvas);

const culled = renderer.caps.compute
  ? tiles                       // the backend culls on the GPU
  : cullOnCpu(tiles, camera);   // the backend cannot, so we do
renderer.render(cameraMatrix, culled);

Parameter reference

Value Setting here Guidance
Interface width one file, no backend types If a GPUBuffer appears in an application module, the abstraction has leaked.
Migration order render path, then compute Moving both at once means a regression cannot be attributed.
Capability branch one flag, one place caps.compute gates a strategy, not scattered conditionals.
Both backends kept yes, indefinitely Coverage is not universal; the fallback is not temporary.
Success measure frame time and bytes uploaded The second is what actually changes; see the benchmarking guide.
Frame time after each migration stage A bar chart in milliseconds of frame time for a 1.6 million feature tile renderer at each stage of the migration. The original WebGL 2.0 renderer takes about 18.4 milliseconds. After stage two, a WebGPU backend doing the same work, it takes about 17.1 — a small gain from lower API overhead. After stage three, with transform and culling moved into compute passes, it takes about 6.2 milliseconds, because the per-frame vertex upload has disappeared entirely. FRAME TIME BY STAGE · ms frame budget WebGL 2.0 18.4 ms WebGPU, same work 17.1 ms WebGPU + compute 6.2 ms 0 5 10 15 20 ms before stage 2 stage 3 Modelled proportions. The gain in stage three comes almost entirely from removing the per-frame upload.
The middle bar is the honest one and the one most migrations stop at. Seven percent is not why anyone ports an application; the third bar is, and reaching it means changing the architecture rather than the API.

The mapping, and where it breaks down

Most WebGL 2.0 concepts have a WebGPU counterpart, and the three that do not are exactly where the value of the migration lives.

WebGL 2.0 WebGPU Note
gl.createBuffer + bufferData createBuffer + writeBuffer Usage flags are fixed at creation rather than hinted
useProgram + uniform locations pipeline + bind groups State is bound as a group, not set individually
vertexAttribPointer GPUVertexBufferLayout Declared once at pipeline creation, not per draw
gl.drawArraysInstanced pass.draw Nearly identical
Transform feedback compute shader The WebGL workaround becomes a first-class stage
Render-to-texture for computation compute shader Same
(no equivalent) storage buffers The reason the port is worth doing
(no equivalent) indirect draw Lets a GPU-side count drive a draw

The bottom three rows are the migration’s justification, and the trap is finishing the port without reaching them. A WebGPU renderer that still uploads transformed vertices every frame has paid the whole cost of the migration for a few percent of API overhead — which is why the staging matters: stage one is expected to be roughly break-even, and stage two is where the numbers move.

Keeping both backends honest

The WebGL path is not a stepping stone. WebGPU coverage is good and not universal, so the fallback is permanent — and a permanent path that nobody exercises rots within months.

Three habits keep it alive. Run both backends in continuous integration against the same fixture and compare rendered output: not pixel-exact, since rasterisation differs, but structurally — the same features present, the same extents, the same feature count. That catches the common rot, which is a change to the shared interface that only one backend was updated for.

Make the backend selectable by a query parameter in every build. A developer who can force the WebGL path in one keystroke will notice when it breaks; one who has to edit code and rebuild will not.

And keep the capability branch to a single flag. The moment the application has three or four backend-specific conditionals scattered through it, the two paths have started to diverge behaviourally, and the fallback stops being the same product with less throughput and starts being a different, worse one.

Shaders, and the one part that does not port

Everything above treats the port as an architectural exercise, and there is one place where it is genuinely a rewrite: the shaders.

GLSL ES 3.0 and WGSL differ enough that mechanical translation is unreliable. Types are spelled differently, the entry-point convention is different, uniform blocks become bind group entries with explicit bindings, and the texture-sampler split has no GLSL equivalent. There are translators, and they work, and their output is not code anyone wants to maintain.

The approach that holds up is to treat the shaders as the one duplicated thing in the codebase and to keep them small enough that duplication is tolerable. That is easier than it sounds, because most of what a tile shader does is a matrix multiply and a colour lookup; the complexity in a spatial renderer lives in the passes and the buffers rather than in the shader source.

Where a shader genuinely is large — a terrain shader with several sampling modes — the maintainable answer is to factor the shared logic into a small preprocessor-driven include set rather than to translate at build time. Two hand-written variants of a fifty-line function are easier to keep correct than one translated four-hundred-line file.

Failure modes

  • The WebGPU backend is slower than the WebGL one. Usually per-frame pipeline or bind group creation. Both are start-up work in WebGPU and were effectively free in WebGL.
  • A regression appears and cannot be attributed. Both stages were done at once. Land the render-path port first, measure, then move the compute work.
  • The abstraction leaks. A GPUBuffer or a WebGLTexture in an application module. The interface has to hide backend types completely or the fallback rots.
  • The WebGL path stops working after a few months. It is not being exercised. Run both in CI against the same fixture and compare screenshots.
  • Uniform updates became expensive. Per-draw uniform writes were cheap in WebGL and are a bind group change in WebGPU. Group uniforms by update frequency instead.
What each API makes cheap, and what it makes expensive A table of four operations comparing their cost between the two APIs. Setting a uniform per draw is cheap in WebGL 2.0 and expensive in WebGPU, where it is a bind group change. Creating a pipeline is effectively free in WebGL and costs tens of milliseconds in WebGPU. Changing render state is cheap and global in WebGL and requires a different pipeline in WebGPU. Running general computation is impossible in WebGL without abusing transform feedback, and is a first-class compute pass in WebGPU. COST PROFILE · WebGL 2.0 vs WebGPU WebGL 2.0 WebGPU Uniform per draw cheap a bind group change Pipeline creation effectively free tens of ms Render state change cheap, global a different pipeline General computation workarounds only a compute pass A port that keeps WebGL habits pays every cost in this table and collects none of the benefit.
The first three rows are why a naive port can be slower: patterns that were free in WebGL are not. The fourth is why the port is worth making anyway.

Backend / Python interop note

A migration is also an opportunity to change the wire format, and it is worth taking deliberately rather than by accident.

A WebGL tile pipeline usually receives geometry in whatever form the CPU path found convenient, because the CPU was going to touch every vertex anyway. A WebGPU pipeline does not touch them, which makes the layout the server chooses the layout the GPU stores — and that is the moment to move from GeoJSON to Arrow, from f64 to f32, and from absolute coordinates to tile-local residuals.

There is a second opportunity in the same place: the tile boundaries themselves. A WebGL renderer that culls on the CPU has an incentive to ship large tiles, because each one costs a draw call and CPU-side work per tile. A WebGPU renderer that culls in a compute pass has the opposite incentive — smaller tiles cull more precisely and cost nothing extra, because they are all dispatched together. Re-tiling at a finer granularity is often worth more than any shader change, and it is a server-side decision the port makes available.

Doing it in the same change as the API port is tempting and unwise, for the same attribution reason: two variables at once means a regression cannot be localised. The sequence that works is to land the wire-format change on the WebGL backend first, where it is a straightforward improvement, and then to port a renderer that is already receiving the right bytes. The WebGL path gets faster, the port gets simpler, and each step is measurable on its own.