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 deviceScaleFactor differs 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.

The same MapLibre tile rendered by Chromium, WebKit, and Firefox with divergence points Three side-by-side panels show the identical map tile — a diagonal road, a polygon, and a street label — as rendered by Chromium, WebKit, and Firefox. Chromium is annotated with 4x MSAA edges and gamma-corrected glyph halos. WebKit is annotated with 0x MSAA giving harder polygon edges and a softer glyph edge. Firefox is annotated with distinct glyph hinting and a label placed one pixel over. A caption notes that glyph anti-aliasing and MSAA justify separate baselines, while one-pixel label placement is absorbed by the diff threshold. Chromium Main St 4x MSAA edge gamma halo WebKit Main St 0x MSAA · hard edge softer glyph Firefox Main St label +1 px distinct hinting Glyph AA and MSAA → separate baselines  ·  1‑px label placement → absorbed by threshold Same style, camera, DPR and fixtures — the engine is the only variable

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.

← Back to CI/CD Integration & Visual Test Operations