WebGL Compatibility Matrix for Open-Source Visual Testing Tools

Not every open-source visual testing tool can actually see a WebGL map. MapLibre GL and Mapbox GL draw the basemap onto a <canvas> element backed by a GL context, and a surprising number of capture tools either grab a transparent rectangle where the map should be, or capture host-GPU output that differs on every runner. Before you commit a stack, you need to know which tools capture the GL canvas correctly, which can be forced onto a software GL backend for determinism, which run headless without a display server, and what diff engine each ships with. This page runs six popular tools — the Playwright test runner, jest-image-snapshot, pixelmatch, Resemble.js, BackstopJS, and Loki — against the same WebGL map, records the results in a matrix, and picks a stack.

This is one task within Open-Source Visual Testing Stacks, the guide that surveys self-hosted capture-and-diff architectures, and it sits under the broader Web Map Visual Testing Fundamentals & Toolchains reference. It assumes you have already decided to self-host rather than pay for a managed service — the trade-off worked through in the cost analysis of cloud visual testing for mapping apps.

Prerequisites

Step-by-step procedure

1. Enumerate the compatibility axes

A tool is only usable for WebGL map testing if it clears four independent axes. List them explicitly so the matrix has columns that mean something:

  • GL canvas capture — does the screenshot contain the rendered map pixels, or a blank/transparent rectangle where the canvas is? Some DOM-serialization tools rasterize the page tree and skip GPU-backed canvas content entirely.
  • Software GL backend — can the tool run against SwiftShader (--use-gl=angle --use-angle=swiftshader) so output is byte-identical across runners regardless of host GPU? A tool that only drives a real GPU cannot give you stable baselines in CI.
  • Headless operation — does it capture without a physical or virtual display (xvfb), which is what a container runner actually provides?
  • Diff engine — what comparison algorithm ships in the box: raw pixelmatch, a Resemble.js perceptual comparison, an SSIM implementation, or nothing (bring-your-own)?
// The four axes, encoded as the shape each tool is scored against.
const axes = {
  glCanvasCapture: null,   // 'full' | 'blank' | 'partial'
  softwareGL:      null,   // 'swiftshader' | 'gpu-only'
  headless:        null,   // 'native' | 'needs-xvfb'
  diffEngine:      null    // 'pixelmatch' | 'resemble' | 'ssim' | 'byo'
};

2. Stand up one WebGL map fixture for every tool

Run all six tools against the identical page so differences are the tool’s, not the map’s. Serve a minimal MapLibre page, lock the camera, and expose the instance.

// map-fixture.js — served at http://localhost:8080/map.html
const map = new maplibregl.Map({
  container: 'map',
  style: '/fixtures/style.json',   // local, deterministic
  center: [-0.1278, 51.5074],
  zoom: 12,
  interactive: false,
  fadeDuration: 0
});
window.__map = map;
map.on('idle', () => { window.__mapIdle = true; });

Every tool below navigates to this page, waits on window.__mapIdle, and captures #map.

3. Test each tool against the WebGL canvas

Drive the same capture through each tool and record what lands on disk. The two capture engines are Playwright and Puppeteer; the rest are diff libraries or orchestrators layered on top of one of them.

// Playwright: launches Chromium with the software GL backend itself.
const browser = await chromium.launch({
  args: ['--use-gl=angle', '--use-angle=swiftshader']
});
const page = await browser.newPage();
await page.goto('http://localhost:8080/map.html');
await page.waitForFunction(() => window.__mapIdle === true);
const buf = await page.locator('#map').screenshot();   // full GL pixels
// pixelmatch / Resemble.js: pure diff libraries — they never capture.
// Feed them the buffer Playwright produced.
const changed = pixelmatch(base.data, cur.data, diff.data, w, h,
  { threshold: 0.1, includeAA: false });

BackstopLoki and Loki wrap a browser driver of their own: BackstopJS drives Puppeteer (or Playwright) and diffs with Resemble.js; Loki drives Chrome and diffs with pixelmatch, and its --chrome-flags pass-through is what lets you force SwiftShader. jest-image-snapshot is a Jest matcher wrapping pixelmatch — it captures nothing itself and must be handed a Playwright or Puppeteer buffer.

4. Record the results in a compatibility matrix

Score each tool against the four axes. The Captures GL canvas column is the gate: a tool that captures blank canvas is unusable for maps no matter how good its diff engine.

Tool Role Captures GL canvas Software GL backend Headless Diff engine
Playwright test runner Capture + diff Full — locator.screenshot() reads the GL buffer Yes — --use-gl=angle --use-angle=swiftshader at launch Native Built-in toHaveScreenshot (pixelmatch-based)
jest-image-snapshot Diff matcher Inherits capturer — full via Playwright/Puppeteer Yes — depends on the browser you pass it Native pixelmatch (SSIM option)
pixelmatch Diff library N/A — never captures N/A N/A pixelmatch (self)
Resemble.js Diff library N/A — never captures N/A N/A Resemble perceptual
BackstopJS Capture + diff Full via Puppeteer/Playwright engine Yes — engineOptions.args flags Native (Puppeteer) Resemble.js
Loki Capture + diff Full via Chrome driver Yes — --chrome-flags pass-through Native / Docker pixelmatch

The pattern is clear: the three orchestrators (Playwright, BackstopJS, Loki) all capture the GL canvas correctly and all can be forced onto SwiftShader, because each ultimately drives a Chromium-family browser whose GL backend is selectable by flag. The pure diff libraries (pixelmatch, Resemble.js) are backend-agnostic — they compare whatever bytes you hand them and inherit the capturer’s fidelity.

5. Trace the capture path per tool

The axes collapse into one picture: a map canvas feeds a capturer, the capturer feeds a diff engine. The diagram annotates which tool owns which segment.

Capture path from WebGL map canvas through capturer to diff engine, per tool A left-to-right flow with three columns. On the left, a WebGL map canvas rendered by MapLibre on a SwiftShader software GL backend. In the middle, three capturer options that each read the GL buffer: the Playwright test runner, BackstopJS driving Puppeteer, and Loki driving Chrome — all launched with a software-GL flag. On the right, the diff engine each capturer feeds: Playwright's built-in pixelmatch comparator or jest-image-snapshot, BackstopJS into Resemble.js, and Loki into pixelmatch. The pure libraries pixelmatch and Resemble.js sit under the diff column because they only compare buffers and never capture. MAP CANVAS CAPTURER DIFF ENGINE WebGL <canvas> MapLibre GL SwiftShader backend Playwright runner locator.screenshot() · --use-angle BackstopJS (Puppeteer) engineOptions.args flags Loki (Chrome) --chrome-flags pass-through pixelmatch built-in · jest-image-snapshot Resemble.js perceptual comparison pixelmatch Loki default comparator

6. Pick a stack

For a greenfield WebGL map suite, the Playwright test runner with its built-in screenshot assertion is the strongest default: it owns the browser launch (so the SwiftShader flags are yours to set), reads the GL canvas natively, runs headless without xvfb, and its toHaveScreenshot comparator already wraps pixelmatch with a maskable region API. If your team already lives in Jest, keep Playwright as the capturer and route the buffer into jest-image-snapshot for the assertion so the diff reporting matches your other suites. Reach for BackstopJS only if you specifically want its Resemble.js perceptual output and scenario-config ergonomics, and for Loki only if you are anchored to a Storybook-driven component workflow.

Whatever you pick, the capturer must launch the browser with the software GL backend, or the whole matrix is moot — covered next.

7. Enforce the SwiftShader requirement

Every “captures GL canvas” result above is conditional on running a software GL backend. On a real host GPU, WebGL rasterization varies by driver, so two runners produce different pixels for an identical map and the diff engine flags noise as regression. Force SwiftShader at launch and treat it as non-negotiable in CI.

// The only launch that makes GL output reproducible across runners.
const browser = await chromium.launch({
  headless: true,
  args: [
    '--use-gl=angle',
    '--use-angle=swiftshader',   // software rasterizer, deterministic
    '--disable-gpu',
    '--force-color-profile=srgb'
  ]
});

Pinning the backend belongs in the image, not the test — the same discipline that containerized rendering environments apply to fonts and the GPU stack. Any residual per-engine divergence that survives SwiftShader is what a cross-browser baseline matrix is built to track separately.

Verification

Confirm the tool you picked really sees the map before you trust its baselines:

Troubleshooting

Symptom Likely cause Fix
Captured frame is transparent where the map should be Capturer serialized the DOM tree and skipped the GPU-backed canvas, or preserveDrawingBuffer is false and the buffer was cleared before readback Use a real browser capturer (Playwright/Puppeteer/Chrome) that reads the framebuffer, and screenshot the element after idle rather than serializing HTML
Same map diffs dirty between two runners Host GPU rasterized WebGL differently because the software backend was not engaged Launch with --use-gl=angle --use-angle=swiftshader and verify via the RENDERER probe; pin the flags in the container image
Diff library reports blank baseline but capture looks fine A pure library (pixelmatch/Resemble.js) was handed mismatched dimensions or a still-encoding PNG buffer Decode both images to raw RGBA of identical width and height before diffing, and await the capture buffer fully before comparison

Frequently asked questions

Why does my visual testing tool capture a blank rectangle instead of the WebGL map?

Because the tool rasterizes the DOM tree rather than reading the GPU framebuffer, so GPU-backed <canvas> content is skipped. Use a browser-driven capturer — the Playwright runner, BackstopJS via Puppeteer, or Loki via Chrome — that screenshots the rendered element after the map reports idle. Pure diff libraries like pixelmatch and Resemble.js never capture, so they can only be as good as the buffer you hand them.

Do pixelmatch and Resemble.js work with WebGL maps?

Yes, but only as diff engines, not capturers. They compare two RGBA buffers and are completely agnostic to how those pixels were produced, so they handle WebGL output fine — provided a browser capturer reads the GL canvas correctly first. pixelmatch gives you a fast per-pixel diff with anti-aliasing tolerance; Resemble.js gives a perceptual comparison. Pair either with Playwright or Puppeteer for the capture.

Is a software GL backend really required, or can I test on the real GPU?

For deterministic baselines it is required. Real GPU drivers rasterize WebGL slightly differently, so an identical map produces different pixels across runners and the diff engine flags rendering noise as a regression. Forcing --use-gl=angle --use-angle=swiftshader removes host-GPU variance so the same commit yields the same frame everywhere. Verify it is engaged by reading the GL RENDERER string.

Which open-source stack should I start with for MapLibre or Mapbox GL?

Start with the Playwright test runner and its built-in screenshot assertion: it owns the browser launch so you control the SwiftShader flags, reads the GL canvas natively, runs headless without a display server, and its comparator wraps pixelmatch with a masking API. If your suite is Jest-based, capture with Playwright and assert with jest-image-snapshot. Choose BackstopJS for Resemble.js perceptual output, or Loki for Storybook-driven workflows.