Chromium vs WebKit vs Firefox: MapLibre GL Rendering Divergence
Point the same MapLibre GL JS scene — same style, same camera, same fixtures — at Chromium, WebKit, and Firefox, and you will not get three identical PNGs. Glyph edges anti-alias differently, a line join rounds a pixel the other way, a label lands one integer position over, and the WebGL multisample count the driver hands you is not the same. None of these are regressions; they are the three engines legitimately disagreeing about how to rasterize the same draw calls. The engineering task is to catalogue exactly where that divergence lives, remove the parts that are host-GPU noise rather than engine behavior, and then decide — feature by feature — whether an engine deserves its own baseline or can safely share one. This guide walks that process end to end.
This is one task inside the Cross-Browser Baseline Matrix guide, which governs how many golden images a map suite maintains and why. It sits under CI/CD Integration & Visual Test Operations, the parent guide covering how visual gates run inside a pipeline. Read those first if you have not decided whether a per-engine matrix is worth its maintenance cost at all — this page assumes you have and now need to know precisely what differs.
Prerequisites
Step-by-step procedure
1. Build one locked scene and freeze every non-engine input
Divergence is only meaningful if the engine is the only variable. Pin the style hash, the exact WGS84 center and integer zoom, device pixel ratio, viewport size, and the font/sprite fixtures, then disable collision jitter and fade. Anything left unpinned will masquerade as an engine difference in the diff.
const scene = {
style: 'http://127.0.0.1:8080/fixtures/style.json', // pinned style hash
center: [-122.4194, 37.7749],
zoom: 14, // integer zoom only — no pyramid interpolation
bearing: 0,
pitch: 0,
fadeDuration: 0,
crossSourceCollisions: false,
pixelRatio: 1, // DPR fixed; document DPR-2 as a separate axis
};
const VIEWPORT = { width: 1024, height: 768 };
2. Capture the identical scene once per engine
Drive the same fixture URL through each Playwright browser type with an identical capture gate, so the only thing that changes between the three PNGs is the rendering engine underneath.
const { chromium, webkit, firefox } = require('playwright');
for (const engine of [chromium, webkit, firefox]) {
const browser = await engine.launch();
const page = await browser.newPage({ viewport: VIEWPORT, deviceScaleFactor: 1 });
await page.goto('http://127.0.0.1:8080/harness.html');
await page.evaluate((s) => window.__mountMap(s), scene);
await page.evaluate(() => window.__waitForMapIdle()); // shared tile-hydration gate
await page.locator('#map').screenshot({
path: `shots/${engine.name()}.png`,
animations: 'disabled',
});
await browser.close();
}
3. Diff pairwise, not against a single reference
Never nominate one engine as “truth” and diff the other two against it — that hides where two engines agree and one disagrees. Run all three pairs (Chromium↔WebKit, Chromium↔Firefox, WebKit↔Firefox) and keep the difference masks, so you can attribute each group of changed pixels to a specific engine pair.
const pixelmatch = require('pixelmatch');
const pairs = [['chromium','webkit'], ['chromium','firefox'], ['webkit','firefox']];
for (const [a, b] of pairs) {
const imgA = readPng(`shots/${a}.png`), imgB = readPng(`shots/${b}.png`);
const diff = new PNG({ width: imgA.width, height: imgA.height });
const changed = pixelmatch(imgA.data, imgB.data, diff.data,
imgA.width, imgA.height, { threshold: 0.1 });
console.log(`${a} vs ${b}: ${changed} px`, `${(100*changed/(imgA.width*imgA.height)).toFixed(3)}%`);
writePng(`diffs/${a}-${b}.png`, diff);
}
4. Classify each divergence cluster by cause
Open each difference mask and label where the changed pixels sit. Almost every cluster falls into one of five buckets, and the bucket — not the raw pixel count — decides whether it justifies a separate baseline.
- Glyph anti-aliasing and hinting. Text is the loudest source. Chromium (FreeType/Skia), WebKit (CoreText or FreeType depending on platform), and Firefox each hint and gamma-correct glyph edges differently, so label halos and stroke edges differ by a subpixel band. This is the single most common reason to keep engines separate.
- Line-join and stroke rasterization. MapLibre tessellates line geometry itself, but the final coverage of a miter or round join at a fractional device coordinate is rounded by the rasterizer. Roads and administrative borders show one-pixel wiggle at joins between engines.
- Symbol and label placement rounding. The collision/placement engine computes float positions; each browser rounds the final translate to a device pixel independently, so a POI icon or street label can land one integer position over. Neighboring, not wrong.
- WebGL MSAA availability. The multisample count from
gl.getParameter(gl.SAMPLES)is driver- and engine-dependent. If one engine grants 4x MSAA on the map canvas and another grants none, polygon and line edges anti-alias at different quality — a systematic, not random, difference. - Device pixel ratio handling. How each engine maps CSS pixels to backing-store pixels under
deviceScaleFactordiffers at the edges, changing which physical pixel a feature’s boundary falls on.
5. Pin the SwiftShader/ANGLE backend to strip host-GPU noise
Before you record any of these as an engine baseline, remove the confound. A hardware GPU renders the same engine differently across machines, so host-GPU variance would inflate your divergence and make baselines non-portable across CI nodes. Force Chromium onto ANGLE+SwiftShader so its WebGL output is a deterministic software raster; where WebKit and Firefox expose no equivalent flag, fix them to a single documented compositing path and containerize so the stack never shifts underneath you.
# Chromium: deterministic software WebGL, identical on every runner
chromium --headless=new \
--use-gl=angle --use-angle=swiftshader \
--disable-gpu-rasterization --force-color-profile=srgb
With the host GPU out of the loop, whatever divergence survives is genuinely the engine — which is exactly what a per-engine baseline should encode.
6. Record the surviving divergences in a decision table
For each classified feature, write down what each engine does and make the keep-separate-baseline call explicitly. This table is the deliverable — it is what future maintainers consult before adding or collapsing a baseline.
| Feature | Chromium | WebKit | Firefox | Separate baseline? |
|---|---|---|---|---|
| Glyph AA / hinting | Skia/FreeType, gamma-corrected halo | CoreText/FreeType, softer edge | FreeType, distinct hinting | Yes — text differs on every run |
| Line-join rasterization | Sub-pixel miter coverage A | Coverage B at joins | Coverage C at joins | Yes for join-dense styles; else absorb in threshold |
| Symbol/label placement | Rounds translate down | May round up | May round up | No — tolerate 1 px via threshold, not a new golden |
| WebGL MSAA | 4x with SwiftShader | Often 0x (no flag) | Driver-dependent | Yes — edge quality is systematically different |
| DPR-2 backing store | Exact 2x mapping | Edge rounding differs | Edge rounding differs | Yes — treat each DPR as its own axis entirely |
The diagram below shows the same tile rendered by the three engines with the divergence points annotated where they actually appear on the map.
Verification
Confirm the divergence map is real and not an artifact of an unpinned input:
A correct result is three self-stable engines whose pairwise diffs concentrate exactly on the five classified features — and a threshold that stays quiet on those while still catching an intentional cartographic change.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Chromium diffs against its own earlier run | Host GPU still driving WebGL; the SwiftShader flags did not take | Confirm --use-gl=angle --use-angle=swiftshader reached the process and containerize so the GL stack is fixed, per Containerized Rendering Environments |
| Every engine diffs everywhere, not just on text and edges | An input other than the engine drifted — DPR, viewport, glyph fixture, or fade | Re-pin the scene in step 1 and reduce residual variance with Noise Reduction for Map Artifacts |
| WebKit edges far harder than the others | WebKit granted 0x MSAA while Chromium got 4x | Record it as a genuine per-engine baseline; do not raise the global threshold to hide a systematic edge-quality gap |
Frequently asked questions
Should I just pick one engine as the source of truth and diff the others against it?
No. A single reference hides the case where two engines agree and one disagrees, which is precisely the signal you need to decide baselines. Diff all three pairs and keep the masks. One engine as “truth” also silently blesses whatever quirks that engine has, so a real regression that only its renderer masks would slip through.
Which divergences justify a separate baseline and which should the threshold absorb?
Systematic, every-run differences justify a separate golden: glyph anti-aliasing and hinting, MSAA edge quality, and DPR backing-store mapping fall here. One-off rounding — a label landing one integer pixel over — should be absorbed by a small diff threshold rather than multiplying your golden count, because it is neighboring rather than wrong.
Why pin SwiftShader before cataloguing divergence at all?
Because a hardware GPU renders the same engine differently across machines, host-GPU variance would inflate your measured divergence and make baselines non-portable between CI nodes. Forcing Chromium onto ANGLE+SwiftShader removes that confound so the divergence that remains is genuinely the engine — the only thing a per-engine baseline should encode.
Does this three-engine approach apply to Leaflet and OpenLayers too?
The measurement method does, but the divergence profile differs: raster and Canvas2D libraries lean on the browser’s 2D rasterizer rather than WebGL, so MSAA availability matters less and glyph/line rasterization matters more. See maintaining per-engine baselines for Leaflet and OpenLayers for how the same decision table changes shape for non-WebGL renderers.
Related
- Up to the parent guide Cross-Browser Baseline Matrix, and the grandparent CI/CD Integration & Visual Test Operations.
- Maintaining per-engine baselines for Leaflet and OpenLayers — the same decision applied to Canvas2D and raster renderers.
- Noise Reduction for Map Artifacts — filtering the subpixel variance that survives after the engine is isolated.
- Containerized Rendering Environments — pinning the browser image and GL backend so engine output is portable across runners.