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.
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.
Related
- Up to the parent guide Open-Source Visual Testing Stacks, and the reference Web Map Visual Testing Fundamentals & Toolchains.
- Cost analysis of cloud visual testing for mapping apps — when to self-host these tools versus pay for managed capture.
- Containerized rendering environments for map tests — pinning the GL backend and fonts in the image.
- Cross-browser baseline matrix — tracking per-engine divergence that survives a software backend.