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.

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.

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.