Reducing False Positives from WebGL Rendering Artifacts

Hardware acceleration, driver-level texture sampling, sub-pixel anti-aliasing, and OS compositor scheduling introduce micro-variations in WebGL rasterization that surface as false positives in pixel-diff comparisons. The map is correct, the geometry is unchanged, yet the comparator flags hundreds of differing pixels and reds out the build. This page is the exact procedure for forcing a WebGL map into a deterministic, byte-stable render path and tuning the diff so the only failures left are genuine cartographic regressions.

This task is one concrete recipe within Noise Reduction for Map Artifacts, the page that defines how to attenuate non-semantic variation before the comparator runs. The broader capture, synchronization, and comparison architecture it sits inside lives in the parent reference, Screenshot Capture, Sync & Comparison Logic.

Prerequisites

Step-by-Step Procedure

1. Force a deterministic WebGL context

Browsers default to a hardware-accelerated, driver-optimized pipeline whose output varies across GPU architectures. Pin a controlled context at map instantiation so anti-aliasing and buffer handling stop drifting between runs:

const map = new maplibregl.Map({
  container: 'map',
  style: 'https://demotiles.maplibre.org/style.json',
  antialias: false,
  alpha: false,
  preserveDrawingBuffer: true,
  powerPreference: 'high-performance',
  failIfMajorPerformanceCaveat: false,
  stencil: false,
  depth: false
});

Disabling antialias eliminates the sub-pixel edge smoothing that shifts label and stroke pixels by 1–2 positions across frames. Setting preserveDrawingBuffer: true stops the browser clearing the framebuffer after each swap, which is what makes a later canvas.toBlob() extraction repeatable. These attributes map directly onto the WebGL API context-attribute contract.

Which WebGL context attributes are safe defaults and which must be pinned Six context attributes are listed with the value a visual-regression run should request and the artifact left behind if the default is accepted. Antialias set to false removes multisample resolve, which differs between drivers. PreserveDrawingBuffer set to true keeps the back buffer readable after present, so a read-back does not return a cleared surface. PowerPreference set to low-power avoids a discrete GPU being selected on some hosts and a different one on others. FailIfMajorPerformanceCaveat set to false keeps the software path usable rather than refusing a context. PremultipliedAlpha set to false makes composited edges reproducible. Desynchronized set to false keeps the canvas on the normal present path. A footer notes that each of these is a variable the diff would otherwise attribute to the code under test. Six context attributes, and the artifact each default leaves behind antialias preserveDrawingBuffer powerPreference failIfMajorPerformanceCaveat premultipliedAlpha desynchronized false true low-power false false false multisample resolve differs per driver read-back can return a cleared surface a discrete GPU may be picked on some hosts context refused instead of falling back composited edge values become host-specific present path bypasses the normal commit each unpinned attribute is a variable the diff will attribute to the code under test

2. Pin the rendering backend in headless CI

A controlled context is worthless if the GPU underneath it changes between the developer laptop and the CI runner. Lock the backend to a software rasterizer so every machine walks the same code path. Launch Chromium with deterministic flags:

chromium \
  --headless=new \
  --disable-gpu-compositing \
  --disable-software-rasterizer \
  --use-gl=swiftshader \
  --force-device-scale-factor=1

On Linux containers --use-gl=angle --use-angle=swiftshader is the more current spelling; --use-gl=egl keeps a real GPU path if your runners have pinned drivers. The goal is identical, not necessarily fast: bypass OS compositor interference and lock the rendering backend to one predictable path across the whole matrix.

3. Gate capture on a stable render state

Fractional zoom and dynamic container scaling cause projection-matrix drift, so first lock the container to integer pixels (for example 1024×768), pin DPR to 1, and snap zoom with Math.round(map.getZoom()). Then defer the shutter until the engine reports it is genuinely idle and the compositor has flushed the final draw, by chaining one requestAnimationFrame after the terminal idle event:

await page.evaluate(() => new Promise((resolve) => {
  const map = window.mapInstance;
  const fire = () => requestAnimationFrame(resolve);
  if (map.loaded() && !map.isMoving()) {
    fire();
  } else {
    map.once('idle', fire);
  }
}));

4. Freeze asynchronous layer state before the shutter

WebGL maps load raster tiles, vector geometry, and dynamic features on worker threads that finish after load fires. Hold capture behind a multi-stage barrier so the GPU is compositing a complete frame:

  1. Network quiescence. Intercept tile fetches and resolve them from a deterministic cache-warm pass, tracking map.on('data', { dataType: 'tile' }) across the visible extent.
  2. Source resolution. For custom sources (GeoJSON, WFS, KML) assert map.isSourceLoaded('layer-id') === true; vector sources also need their worker-side protobuf parse to finish.
  3. Layer-ordering lock. Disable dynamic reordering during the run and freeze z-index and opacity with map.setLayoutProperty() so compositor-driven blending cannot vary.
WebGL false-positive suppression decision flow A captured WebGL frame enters at the top and is routed down one of two pinned render paths — a hardware GPU path or a SwiftShader software path. Both converge into a render-state gate that waits for the map to go idle then one requestAnimationFrame, and then into a layer-state barrier requiring network quiescence, loaded sources, and locked ordering. The settled frame reaches a perceptual diff diamond computing SSIM or pHash: within tolerance routes to a Pass terminal, a structural delta routes to a Flag-regression terminal. Captured WebGL frame Hardware GPU path SwiftShader (software) Render-state gate map idle → one rAF Layer-state barrier network quiescent · sources loaded · ordering locked Perceptual diff SSIM / pHash Flag regression structural delta Pass within tolerance

5. Extract losslessly and diff perceptually

Capture with canvas.toBlob() for lossless PNG output—never a JPEG path, whose compression invents its own per-run noise. Then move the comparison off exact pixel equality. A perceptual metric tolerates the residual anti-aliasing shift while still catching geometric or colour regressions. The Structural Similarity Index over a window scores luminance, contrast, and structure:

Treat a region as unchanged when SSIM ≥ 0.98, and fall back to a perceptual-hash Hamming distance for whole-frame screening. Tuning this metric per layer is the job of Diff Algorithm Tuning for Cartography.

6. Stratify tolerance by layer type

A single global tolerance is wrong because each layer tolerates a different amount of benign variance. Stratify it, which is the core idea behind Dynamic Threshold Configuration:

  • Raster tiles — strict (<0.1%): served from deterministic fixtures, they should be byte-identical run to run.
  • Vector tiles and labels — adaptive (0.5–1.5%): absorb font-hinting and sub-pixel glyph differences.
  • Dynamic overlays (markers, heatmaps, attribution, scale bars) — region-mask them out of the diff entirely rather than widening the global band.
Stratified tolerance: one frame, four layer classes, four budgets A single captured frame is decomposed into four layer classes, each drawn as a band whose width represents the share of the frame it occupies and whose label carries its own tolerance. Solid fills such as water and landuse occupy the largest share and take the tightest tolerance, because a driver cannot legitimately change a flat fill. Line work such as roads and boundaries takes a moderate tolerance for join and cap rasterisation. Label glyphs take the loosest tolerance because hinting legitimately moves subpixels. Raster and hillshade imagery takes a moderate tolerance for resample differences. A footer explains that a single global tolerance has to be set for the loosest class, which is why glyph noise ends up licensing a change in a flat fill. One global tolerance is set by the noisiest layer in the frame solid fills · water, landuse tolerance 0.01% — a flat fill cannot legitimately move line work · roads 0.05% — join and cap raster label glyphs 0.30% — hinting raster · hillshade 0.08% stratified each class is judged against what its own rasteriser can legitimately do global must be set to 0.30% for the glyphs — which then licenses a 0.30% change in the water fill, a region that occupies most of the frame the region map is what makes stratification cheap: it is generated from the style, not drawn by hand

7. Pin and assert the renderer in CI

Make the renderer an explicit contract so a silent driver bump fails loudly instead of corrupting baselines. Assert the active backend matches the CI baseline:

const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
const renderer = gl.getParameter(gl.RENDERER);
const vendor = gl.getParameter(gl.VENDOR);
console.assert(
  renderer.includes('SwiftShader') || renderer.includes('ANGLE'),
  `Unexpected renderer: ${renderer} (${vendor})`
);

Tag every baseline with the commit hash, browser version, and GPU/driver identifier so diffs stay environment-aware and a renderer mismatch is visible at triage. Gate baseline promotion behind a manual review that logs the exact context attributes used to generate the new reference.

Verification

Confirm the false positives are actually gone before trusting the gate:

  1. Run the same pinned view twice back-to-back and diff the two captures with the comparator at threshold: 0.0, antialiasing: false. A clean setup yields a near-zero delta; any diffuse, channel-correlated region means the GPU path is still varying—revisit steps 1–2.
  2. Hash both captures with sha256 after toBlob(). With SwiftShader and preserveDrawingBuffer, raster-only frames should produce an identical digest across runs on the same image.
  3. Track false-positive rate on a control chart across the matrix. A green run on Chromium that flakes on the SwiftShader runner signals an environment mismatch, not a regression. Hold the rate under 2% before declaring the gate trustworthy.

Troubleshooting

Symptom Likely cause Fix
Diffuse one-level colour shift across the whole frame GPU compositing variance or a premultipliedAlpha mismatch on framebuffer readback Pin the backend to SwiftShader (step 2) and capture via toBlob() with alpha: false
Label and stroke edges flag 1–2px every run antialias left enabled, so sub-pixel edge smoothing drifts Set antialias: false at context creation and snap zoom to integers
Passes locally, fails only in CI Different GPU driver or DPR between laptop and runner Force --use-gl=swiftshader and --force-device-scale-factor=1, reproduce inside the same container image

Frequently Asked Questions

Should I disable antialiasing or just widen the diff threshold?

Disable it. Widening the threshold to absorb anti-aliasing jitter also widens the window a real regression can hide in, which is exactly the asymmetric failure noise reduction is meant to avoid. Turning antialias off removes the variance at the source, and you can then keep a tight tolerance that still catches genuine geometric changes.

Is SwiftShader software rendering too slow for a real CI suite?

For visual regression it is almost always the right trade. The suite is gated on determinism, not throughput, and a software rasterizer that produces the same pixels on every runner is worth more than a fast GPU path that drifts across driver versions. If you must keep a hardware path, pin the driver and GPU model across the entire runner fleet and tag baselines with that identifier.

Why do raster tiles diff even when they are served from fixtures?

Usually JPEG re-encoding somewhere in the capture chain, or the framebuffer being cleared before extraction. Capture with canvas.toBlob() to PNG, set preserveDrawingBuffer: true, and confirm your fixture server returns byte-identical payloads. Once those are fixed, fixture-served raster tiles should hash identically run to run.