Visual regression test a three.js scene at an exact frame, with a saved baseline and a pixel diff

How a coding agent visual regression tests a three.js or WebGL scene with the paprika3d plugin: a picture saved as a baseline at an exact frame of a paused, seeded clock, compared after each edit with the share of pixels that differ, a diff picture and a grid of regions, and why a seeded random layout can still move when the edit creates fewer objects.

Why WebGL screenshots differ between runs

A visual regression test takes a picture, keeps it as the baseline, and compares later pictures against it. On a three.js page the later picture differs even when nothing changed, for reasons that have nothing to do with the code: the animation was at another moment when the picture was taken, Math.random() scattered things elsewhere, the first frames were still loading, or another GPU or driver shaded the edges a little differently. Tests then either fail at random or get a tolerance so loose that real changes pass.

paprika3d is a Claude Code plugin and MCP server that runs the page in its own headless Chrome on a paused clock, stepped 1/60 s a frame, with Math.random seeded. A picture taken at frame 90 is the same picture on every load, on the same machine. look with save keeps a picture as a named baseline, with the frame and page size it was taken at; look with compare reloads the page, steps it to that frame, and replies with the share of pixels that differ and a picture of the differences in red. reference with compare: true puts the baseline and the page side by side and splits the difference into a grid of regions.

Install

paprika3d needs Google Chrome and Node 20.11 or later, and the Claude Code plugin needs Claude Code 2.1.118 or later.

claude plugin marketplace add paprika3d/paprika3d
claude plugin install paprika3d@paprika3d
npm install three

A scene that moves and scatters

A windmill whose sails turn one radian a second, and fourteen trees placed with Math.random(): the two things that make screenshots of a scene differ from run to run.

<!-- verify -->
<!doctype html>
<html><head><meta charset="utf-8"><title>Mill</title>
<style>html,body{margin:0;overflow:hidden}canvas{display:block}</style></head>
<body>
<script type="importmap">{"imports":{"three":"./node_modules/three/build/three.module.js"}}</script>
<script type="module">
import * as THREE from 'three';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x9cc4e4);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.set(6, 5, 10);
camera.lookAt(0, 1.5, 0);
scene.add(new THREE.HemisphereLight(0xffffff, 0x556644, 1.8));
const sun = new THREE.DirectionalLight(0xfff0d0, 1.8);
sun.position.set(5, 10, 4);
scene.add(sun);

const field = new THREE.Mesh(new THREE.CircleGeometry(9, 48).rotateX(-Math.PI / 2), new THREE.MeshStandardMaterial({ color: 0x6a9a48 }));
field.name = 'field';
scene.add(field);

const mill = new THREE.Group();
mill.name = 'mill';
const tower = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 1, 3, 24), new THREE.MeshStandardMaterial({ color: 0xe8dcc8 }));
tower.position.y = 1.5;
const roof = new THREE.Mesh(new THREE.ConeGeometry(0.95, 1, 24), new THREE.MeshStandardMaterial({ color: 0xa04030 }));
roof.name = 'roof';
roof.position.y = 3.5;
const sails = new THREE.Group();
sails.name = 'sails';
sails.position.set(0, 3, 0.9);
for (let i = 0; i < 4; i++) {
  const sail = new THREE.Mesh(new THREE.BoxGeometry(0.3, 1.8, 0.05), new THREE.MeshStandardMaterial({ color: 0xf4f0e8 }));
  sail.geometry.translate(0, 0.95, 0);
  sail.rotation.z = (i * Math.PI) / 2;
  sails.add(sail);
}
mill.add(tower, roof, sails);
scene.add(mill);

// Trees scattered at random: the same layout on every load under a seeded Math.random.
const trees = new THREE.Group();
trees.name = 'trees';
for (let i = 0; i < 14; i++) {
  const a = Math.random() * Math.PI * 2, r = 3 + Math.random() * 5;
  const tree = new THREE.Mesh(new THREE.ConeGeometry(0.4, 1.4, 12), new THREE.MeshStandardMaterial({ color: 0x2f5a2a }));
  tree.position.set(Math.cos(a) * r, 0.7, Math.sin(a) * r);
  trees.add(tree);
}
scene.add(trees);

addEventListener('resize', () => {
  renderer.setSize(innerWidth, innerHeight);
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
});
renderer.setAnimationLoop((t) => {
  sails.rotation.z = t / 1000;   // one radian a second
  renderer.render(scene, camera);
});
</script>
</body></html>

The tool calls, in order

These replies were captured on 2026-10-28 on a Linux server with no GPU, where Chrome draws in software (SwiftShader). Baselines taken there compare against pictures taken there; see the last section. Run folder paths are shortened to <project>.

1. Open the page and step to the moment to keep. The page loads paused at frame 0; 90 frames is 1.5 s of the sails turning.

open {"url": "index.html", "cwd": "<project>"}

Opened http://127.0.0.1:41485/index.html in 0.1 s, paused at frame 0.
GPU: ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver). A software renderer (no GPU): frame times here say nothing of a real GPU's.
Page text outside the canvas: none
{"three":"186","scenes":1,"entities":["field","mill","trees"]}
Next: `look` to see it, then `interact` for each interaction the request names.

Run folder: <project>/paprika3d/runs/2026-10-28T05-52-04-864Z

(The reply's lines about the geo.js and fx.js helpers it writes into the project are left out here.)

interact {"steps": [{"wait": 90}]}

{"after":null,"changed":{}}

The page has no window.__game, so there is no state to report; the clock is now at frame 90.

2. Save the baseline.

look {"save": "mill-1_5s"}

Saved as baseline mill-1_5s: <project>/paprika3d/baselines/mill-1_5s.png
Picture: <project>/paprika3d/runs/2026-10-28T05-52-04-864Z/look-1.png
Pixels: brightness 65%, colour saturation 44%, 0% near black, 0% blown out.
Hues: green 49%, blue 46% of the frame; over the objects (53.5% of it): green 91%, grey 8%.
Canvas: 1280x800 buffer for 1280x800 CSS at DPR 1 (renderer pixel ratio 1); resize handler ran.
Surface: "field" covers 45% with almost no detail (0 of 255): it reads as flat colour. Unless the user asked for flat colour, add texture, normal detail or lighting variation.
World's edge: 33% of the picture is background below the horizon, in [0, 111, 1279, 483], past the far end of the ground: extend the ground, keep the camera over it, or fade it out with scene.fog.
On screen (19):
field 45% [0, 349, 1279, 799] 18 × 18 units
mill 2.8% [564, 292, 716, 542]
trees ×12 4.1% [253, 306, 1279, 751]
roof 0.45% [562, 199, 710, 294]
sails ×4 1.4% [475, 126, 747, 449] 1.8 × 0.3 units
Never visible: none

The picture is kept as paprika3d/baselines/mill-1_5s.png in the project, with a .json beside it holding the frame (90), the page size and the pixel ratio.

3. Compare with nothing changed. compare takes the picture again at the baseline's frame, reloading and stepping there when the page is at another frame:

look {"compare": "mill-1_5s"}

Compare: 0% of pixels differ from mill-1_5s (saved 2026-10-28, frame 90, 1280x800@1); diff at <project>/paprika3d/runs/2026-10-28T05-52-04-864Z/look-2-diff.png
Picture: <project>/paprika3d/runs/2026-10-28T05-52-04-864Z/look-2.png
Pixels, Hues: as the last look; 0% of the picture changed since it.
Surface: the same line as the last look, still open.
World's edge: 33% of the picture is background below the horizon, in [0, 111, 1279, 483], past the far end of the ground: extend the ground, keep the camera over it, or fade it out with scene.fog.
On screen (19):
field 45% [0, 349, 1279, 799] 18 × 18 units
mill 2.8% [564, 292, 716, 542]
trees ×12 4.1% [253, 306, 1279, 751]
roof 0.45% [562, 199, 710, 294]
sails ×4 1.4% [475, 126, 747, 449] 1.8 × 0.3 units
Never visible: none

No pixel differs. The sails are at the same angle because the clock is at the same frame, and the trees are in the same places because Math.random gave the same numbers.

4. A small change: the roof a shade more orange.

-const roof = new THREE.Mesh(new THREE.ConeGeometry(0.95, 1, 24), new THREE.MeshStandardMaterial({ color: 0xa04030 }));
+const roof = new THREE.Mesh(new THREE.ConeGeometry(0.95, 1, 24), new THREE.MeshStandardMaterial({ color: 0xa04a30 }));
look {"compare": "mill-1_5s"}

Reloaded (sources changed), ran on the new code from frame 0 (ready in 0.1 s).
Compare: 0.44% of pixels differ from mill-1_5s (saved 2026-10-28, frame 90, 1280x800@1); diff at <project>/paprika3d/runs/2026-10-28T05-52-04-864Z/look-3-diff.png
Picture: <project>/paprika3d/runs/2026-10-28T05-52-04-864Z/look-3.png
Pixels, Hues: as the last look; 0% of the picture changed since it.
Surface: the same line as the last look, still open.
World's edge: 33% of the picture is background below the horizon, in [0, 111, 1279, 483], past the far end of the ground: extend the ground, keep the camera over it, or fade it out with scene.fog.
On screen (19):
field 45% [0, 349, 1279, 799] 18 × 18 units
mill 2.8% [564, 292, 716, 542]
trees ×12 4.1% [253, 306, 1279, 751]
roof 0.45% [562, 199, 710, 294]
sails ×4 1.4% [475, 126, 747, 449] 1.8 × 0.3 units
Never visible: none

0.44% of the pixels differ: about the roof's share of the picture. A pixel counts as different when one of its colour channels moved by more than 8 of 255: edge noise stays below that, and this change of shade is above it.

5. A change that moved more than it should: three sails instead of four. (The roof is back to its first colour.)

-for (let i = 0; i < 4; i++) {
+for (let i = 0; i < 3; i++) {
-  sail.rotation.z = (i * Math.PI) / 2;
+  sail.rotation.z = (i * Math.PI * 2) / 3;
look {"compare": "mill-1_5s"}

Reloaded (sources changed), ran on the new code from frame 0 (ready in 0.1 s).
Compare: 8.5% of pixels differ from mill-1_5s (saved 2026-10-28, frame 90, 1280x800@1); diff at <project>/paprika3d/runs/2026-10-28T05-52-04-864Z/look-4-diff.png
Picture: <project>/paprika3d/runs/2026-10-28T05-52-04-864Z/look-4.png
Pixels, Hues: as the last look; 10% of the picture changed since it.
Surfaces out of view: two unnamed ConeGeometrys in "trees" share a face plane (y = 0.00) over 0.5 × 0.65 units, on faces turned away from this camera: they flicker when seen from below. Look from there; move one at least 0.00024 units off the plane or remove the hidden face, unless that side is never seen. (1 more pairs out of view)
Surface: "field" covers 45% with almost no detail (0 of 255): it reads as flat colour. Unless the user asked for flat colour, add texture, normal detail or lighting variation.
World's edge: 33% of the picture is background below the horizon, in [0, 111, 1279, 483], past the far end of the ground: extend the ground, keep the camera over it, or fade it out with scene.fog.
On screen (18):
field 45% [0, 349, 1279, 799] 18 × 18 units
mill 2.9% [564, 292, 716, 542]
trees ×12 4.1% [0, 333, 1033, 731]
roof 0.48% [562, 199, 710, 294]
sails ×3 1.1% [498, 161, 747, 438] 1.8 × 0.3 units
Never visible: none

8.5% of the picture differs, far more than the sails cover, and the trees line has a new box: the trees moved. The Surfaces out of view line is new too, about two trees whose bases now overlap.

6. Where the difference is. reference with compare: true reads a baseline by name and splits the difference into regions:

reference {"cwd": "<project>", "file": "mill-1_5s", "compare": true, "cells": 4}

baseline mill-1_5s is 1280 × 800 px.
Shown: the picture, the page's look (1280 × 800) and their differences in red, each 480 px tall.
8% of pixels differ by more than 24/255 in a channel at 480 px tall. By region (4 × 4), rows top to bottom and cells left to right: 0% 4% 0% 0% / 2% 24% 3% 1% / 3% 17% 21% 14% / 0% 13% 15% 13%. Most different: upper-middle centre-left (24%).

The difference is spread over the lower half of the picture, where the trees stand, as well as around the sails at the top middle.

Why the trees moved

paprika3d seeds Math.random, so the sequence of numbers is the same on every load. But three.js draws from that same sequence: every Object3D, BufferGeometry and Material gets a UUID made from four Math.random() calls when it is created. One sail fewer is one mesh, one geometry and one material fewer, so the trees, made after the sails, started at a different point in the sequence and landed elsewhere.

A layout that should stay put while the rest of the scene changes needs a generator of its own, seeded once:

// Trees scattered by a generator of their own, so the layout does not depend on what was made before it.
function mulberry32(seed) {
  return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
const rand = mulberry32(7);
const trees = new THREE.Group();
trees.name = 'trees';
for (let i = 0; i < 14; i++) {
  const a = rand() * Math.PI * 2, r = 3 + rand() * 5;

With that change, a new baseline (mill-seeded, saved the same way at frame 90), and the same three-sail edit:

look {"compare": "mill-seeded"}

Reloaded (sources changed), ran on the new code from frame 0 (ready in 0.1 s).
Compare: 1.4% of pixels differ from mill-seeded (saved 2026-10-28, frame 90, 1280x800@1); diff at <project>/paprika3d/runs/2026-10-28T05-52-39-807Z/look-2-diff.png
Picture: <project>/paprika3d/runs/2026-10-28T05-52-39-807Z/look-2.png
Pixels, Hues: as the last look; 0% of the picture changed since it.
Surfaces, Surface: the same 2 lines as the last look, still open.
World's edge: 33% of the picture is background below the horizon, in [0, 111, 1279, 483], past the far end of the ground: extend the ground, keep the camera over it, or fade it out with scene.fog.
On screen (19):
field 42% [0, 349, 1279, 799] 18 × 18 units
mill 2.9% [564, 292, 716, 542]
trees ×13 6.4% [37, 323, 1250, 799]
roof 0.48% [562, 199, 710, 294]
sails ×3 1.1% [498, 161, 747, 438] 1.8 × 0.3 units
Never visible: none
reference {"cwd": "<project>", "file": "mill-seeded", "compare": true, "cells": 4}

baseline mill-seeded is 1280 × 800 px.
Shown: the picture, the page's look (1280 × 800) and their differences in red, each 480 px tall.
1% of pixels differ by more than 24/255 in a channel at 480 px tall. By region (4 × 4), rows top to bottom and cells left to right: 0% 4% 0% 0% / 0% 13% 0% 0% / 0% 1% 0% 0% / 0% 0% 0% 0%. Most different: upper-middle centre-left (13%).

1.4% of the pixels differ, the trees' box is unchanged, and the regions that differ are the top middle ones around the sails and the mill.

What paprika3d does not do

Pitfalls

Without paprika3d

Screenshot assertions in Playwright (expect(page).toHaveScreenshot() with maxDiffPixelRatio and a per-pixel threshold), or pixelmatch on two PNGs, compare pictures the same way: a share of pixels past a colour threshold. ImageMagick's compare -metric SSIM scores structural similarity, which tolerates small shading shifts better than a per-pixel count. Whichever does the comparison, the page needs fixed time and fixed randomness for the pictures to agree: the three.js repository's own screenshot tests inject a script that replaces the clock and Math.random before each example runs, for the same reason.