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:
- Network quiescence. Intercept tile fetches and resolve them from a deterministic cache-warm pass, tracking
map.on('data', { dataType: 'tile' })across the visible extent. - 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. - Layer-ordering lock. Disable dynamic reordering during the run and freeze z-index and opacity with
map.setLayoutProperty()so compositor-driven blending cannot vary.
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:
- 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. - Hash both captures with
sha256aftertoBlob(). With SwiftShader andpreserveDrawingBuffer, raster-only frames should produce an identical digest across runs on the same image. - 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.
Related
- Noise Reduction for Map Artifacts — the parent cluster this recipe extends, covering the full pre-diff filtering stage.
- Configuring pixel diff thresholds for anti-aliased map labels — tolerance bands for the label glyphs WebGL anti-aliasing perturbs.
- How to wait for all map tiles to load before screenshot — the upstream synchronization gate that must hold before capture.
- Diff Algorithm Tuning for Cartography — choosing and tuning the perceptual metric this page relies on.
- Screenshot Capture, Sync & Comparison Logic — the parent reference for the end-to-end capture and comparison pipeline.