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.
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.
/** 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.
// 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. |
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
GPUBufferor aWebGLTexturein 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.
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.
Related
- WebGPU vs WebGL 2.0 for spatial workloads — the topic this page belongs to.
- Benchmarking WebGPU and WebGL 2.0 point rendering — how to measure whether each stage paid.
- Implementing WebGL 2.0 fallbacks when WebGPU fails — why both backends stay.
- WebGPU compute vs render pipeline fundamentals — the stage that has no WebGL equivalent.
- Browser support and fallback routing strategies — the capability gate that chooses a backend.