Implementing WebGL2 Fallbacks When WebGPU Fails
The precise sub-problem here is narrow: a spatial session has reached the moment where navigator.gpu.requestAdapter() rejects, times out under driver load, or resolves an adapter whose limits cannot carry the tile buffers you intend to upload — and you must hand that session a working WebGL2 renderer that draws the same coordinate-transformed geometry, at the same spatial precision, without a page reload. This is the Tier 2 leg of the broader browser support and fallback routing state machine, and it sits one level below the WebGPU Architecture for Spatial Visualization pipeline as a whole. Getting it right means three things must translate cleanly across the API boundary: context acquisition, shader supply, and buffer layout. Each is covered below with a complete reference implementation.
Runnable reference implementation
The fallback decision must execute during context acquisition so it never blocks the main thread. GPURequestAdapterOptions does not accept an AbortSignal, so the probe is gated with Promise.race instead — a hung driver cannot stall startup. For GIS workloads, spatial index buffers, terrain meshes, and 3D tile streams routinely exceed 64 MB, which makes maxBufferSize and maxStorageBufferBindingSize the gating limits rather than mere feature flags. The full handshake belongs in front of your normal device initialization path:
type RenderTier = "webgpu" | "webgl2";
interface TierResult {
tier: RenderTier;
device?: GPUDevice;
gl?: WebGL2RenderingContext;
}
async function acquireRenderContext(canvas: HTMLCanvasElement): Promise<TierResult> {
const device = await probeWebGPU();
if (device) return { tier: "webgpu", device };
// Tier 2: explicit context parameters tuned for fallback determinism.
// antialias:false drops a framebuffer attachment; preserveDrawingBuffer keeps
// the last frame visible while spatial buffers re-upload after a context loss.
const gl = canvas.getContext("webgl2", {
antialias: false,
preserveDrawingBuffer: true,
powerPreference: "low-power",
});
if (!gl) throw new Error("Neither WebGPU nor WebGL2 is available");
// Float render targets are mandatory for precision-preserving spatial output.
if (!gl.getExtension("EXT_color_buffer_float")) {
throw new Error("EXT_color_buffer_float missing: cannot hold projected coords");
}
gl.getExtension("OES_texture_float_linear");
return { tier: "webgl2", gl };
}
async function probeWebGPU(): Promise<GPUDevice | null> {
const timeoutMs = 1500;
const withTimeout = <T>(p: Promise<T>): Promise<T> =>
Promise.race([
p,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error("adapter probe timed out")), timeoutMs)
),
]);
try {
if (!navigator.gpu) throw new Error("navigator.gpu unavailable");
const adapter = await withTimeout(navigator.gpu.requestAdapter());
if (!adapter) throw new Error("adapter unavailable");
const { maxBufferSize, maxStorageBufferBindingSize } = adapter.limits;
if (maxBufferSize < 128_000_000 || maxStorageBufferBindingSize < 64_000_000) {
throw new Error("buffer limits below spatial dataset threshold");
}
return await adapter.requestDevice();
} catch (err) {
console.warn("WebGPU probe failed, routing to WebGL2:", (err as Error).message);
return null;
}
}
WebGL2 has no compute shaders, so any compute-pipeline work — KD-tree construction, raster-to-vector conversion, batch matrix transforms — must be emulated. The cleanest emulation for coordinate transformation is a vertex shader that writes to a TRANSFORM_FEEDBACK_BUFFER instead of rasterizing, preserving the data-parallel execution model on top of the fixed-function pipeline:
function runCoordinateTransform(
gl: WebGL2RenderingContext,
program: WebGLProgram, // linked with TRANSFORM_FEEDBACK_VARYINGS, SEPARATE_ATTRIBS
inputBuffer: WebGLBuffer,
outputBuffer: WebGLBuffer,
vertexCount: number,
): void {
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
// Input lon/lat (or source-CRS) coordinates as tightly packed float32 triples.
gl.bindBuffer(gl.ARRAY_BUFFER, inputBuffer);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
// Capture the projected output instead of drawing pixels.
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, outputBuffer);
gl.enable(gl.RASTERIZER_DISCARD); // we only want the feedback, not fragments
gl.useProgram(program);
gl.beginTransformFeedback(gl.POINTS);
gl.drawArrays(gl.POINTS, 0, vertexCount);
gl.endTransformFeedback();
gl.disable(gl.RASTERIZER_DISCARD);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, null);
}
The vertex shader that backs this transform must declare highp float; anything narrower introduces visible geospatial jitter once zoom exceeds level 15, because the mantissa can no longer resolve sub-metre offsets in Web Mercator. The same highp discipline that the WGSL compute kernel relied on has to be restated explicitly in GLSL ES 3.0:
#version 300 es
precision highp float;
layout(location = 0) in vec3 a_lonLatZ; // source CRS, e.g. EPSG:4326
uniform vec4 u_mercatorScale; // precomputed scale/offset
out vec3 v_projected; // captured by transform feedback
void main() {
// EPSG:4326 -> Web Mercator (EPSG:3857), kept in highp end to end.
float x = radians(a_lonLatZ.x) * u_mercatorScale.x + u_mercatorScale.z;
float lat = clamp(a_lonLatZ.y, -85.05112878, 85.05112878);
float y = log(tan(radians(45.0 + lat * 0.5))) * u_mercatorScale.y + u_mercatorScale.w;
v_projected = vec3(x, y, a_lonLatZ.z);
}
Multi-pass operations — iterative spatial clustering, heat-map density accumulation — have no single-pass feedback analogue and must use framebuffer ping-ponging between two float textures, reading the previous pass as a samplerBuffer or sampler2D and writing the next into the bound attachment.
Parameter and configuration reference
Every tunable value in the implementation above, with the spatial-workload reasoning behind it:
| Parameter | Value | Why this value for spatial data |
|---|---|---|
| Adapter probe timeout | 1500 ms |
Long enough to clear a recovering driver, short enough that startup never visibly stalls; pair with the device-polling retry loop before committing to Tier 2. |
maxBufferSize gate |
128 MB |
A single continental vector-tile or terrain-mesh buffer commonly lands between 64–256 MB; demoting below this avoids a mid-session allocation failure. |
maxStorageBufferBindingSize gate |
64 MB |
Storage bindings back the spatial index; below this the index must be chunked, which is the signal to drop to the simpler render path. |
antialias |
false |
Removes an MSAA framebuffer attachment, cutting VRAM and keeping rasterization identical across tiers for visual-regression diffing. |
preserveDrawingBuffer |
true |
Keeps the last good frame on screen while buffers re-upload after a context loss, so the map does not flash blank. |
powerPreference |
"low-power" |
Fallback sessions are frequently on integrated GPUs already under pressure; favouring the low-power adapter improves frame stability. |
| Shader precision | highp float |
Required above zoom 15 to avoid Mercator jitter; mediump tears geometry silently. |
| Vertex attribute alignment | 4-byte (float32) |
Matches the GPU-side memory alignment the WebGPU tier expects, so one source buffer feeds both paths. |
| Transform-feedback varyings mode | SEPARATE_ATTRIBS |
Lets the projected output bind directly as a vertex buffer in the draw pass without a re-pack. |
WebGPU enforces 256-byte alignment for dynamic uniform-buffer offsets and type-based alignment for struct members; WebGL2’s rules are more relaxed but it needs explicit TEXTURE_BUFFER objects (read via samplerBuffer) for datasets too large for a uniform block. When migrating a storage binding, map it to either a chunked UNIFORM_BUFFER or a TEXTURE_BUFFER (which is itself gated on EXT_color_buffer_float). Deriving the fallback stride from the shared alignment rules — rather than maintaining two hand-written layouts — is what keeps the two tiers byte-compatible.
Failure modes specific to this sub-topic
- Transient
nulladapter under driver load. A busy or recovering driver returnsnullon otherwise capable hardware, so a single probe miss wrongly demotes the session permanently. Detection: the 1500 ms timeout fires while a later retry would succeed. Fix: retry with backoff via the device-polling pattern before committing to Tier 2; never demote a capable device on one transient miss. - Silent precision loss with no thrown error. A wrong fallback stride or a
mediumpvertex shader produces no exception at all — geometry simply tears at high zoom. Detection: belongs in CI, not runtime; snapshot a known tile on each tier and diff against a reference render. Fix: forcehighp floatand derive the stride from the alignment rules. EXT_color_buffer_floatunavailable. Without float render targets, projected coordinates cannot round-trip through a framebuffer, collapsing the multi-pass clustering path. Detection:gl.getExtension("EXT_color_buffer_float")returnsnullat context creation. Fix: fail the Tier 2 acquisition explicitly and route to a Canvas2D correctness floor rather than rendering wrong pixels.- WebGL2 context loss during a long pan/zoom. The Tier 2 path is not immune to GPU resets under memory pressure. Detection: the
webglcontextlostevent fires. Fix: callpreventDefault(), awaitwebglcontextrestored, and re-upload every spatial buffer — which is why the upload step must be idempotent and re-runnable. Prioritise viewport-visible tiles withIntersectionObserveron restore to bound the re-upload cost.
Backend / Python interop note
The fallback only stays byte-compatible if the Python service that emits the geometry pre-aligns it to the layout both tiers read. When generating GeoParquet, binary tile payloads, or point-cloud streams, pack vertex attributes to 4-byte boundaries with explicit little-endian float32 so the same buffer feeds WebGPU storage bindings and WebGL2 array/texture buffers without a re-pack:
import numpy as np
import pyarrow.parquet as pq
# Read projected coordinates from GeoParquet and emit a GPU-ready blob.
table = pq.read_table("tiles/zoom14.parquet", columns=["x", "y", "z"])
# '<f4' = little-endian float32, the only layout safe for both WebGPU and WebGL2.
coords = np.column_stack([
table["x"].to_numpy().astype("<f4"),
table["y"].to_numpy().astype("<f4"),
table["z"].to_numpy().astype("<f4"),
])
# Interleaved, tightly packed: stride is exactly 12 bytes (3 * float32),
# which matches vertexAttribPointer(0, 3, FLOAT, false, 0, 0) on the GL side.
assert coords.dtype == np.dtype("<f4") and coords.shape[1] == 3
coords.tobytes() # ship this directly; no per-tier transcoding needed
For point-cloud streams, compress the spatial indices with Draco or Meshopt before upload to relieve the bandwidth ceiling that integrated-GPU fallback contexts hit first. Keep highp-relevant magnitudes (already-projected metres, not raw degrees stored as float64) out of the wire format where possible, so the float32 round-trip never loses the precision the shader depends on.
For authoritative API behaviour, consult the W3C WebGPU Specification for the adapter/limits semantics and MDN’s WebGL2RenderingContext reference for context creation, extension querying, and context-loss handling.
Related
- Browser Support & Fallback Routing Strategies — the tiered state machine this Tier 2 path plugs into.
- WebGPU Compute vs Render Pipeline Fundamentals — the compute work that transform feedback must emulate here.
- Memory Alignment for Spatial Data Buffers — the source layout the cross-tier re-stride must honour.
- Initializing WebGPU Devices for GIS Workloads — the device handshake the probe sits in front of.
- Setting Up WebGPU Device Polling for GIS Apps — retry orchestration that prevents demoting a capable device on a transient miss.