Screenshot a three.js scene headlessly from a coding agent
How a coding agent takes a screenshot of a three.js or WebGL canvas in headless Chrome, gets the list of objects on screen with it, and pictures one object from four sides, using the paprika3d plugin's game_open and game_look.
The problem
A coding agent that writes a three.js page wants to see what it drew before it reports the work as done. The page is a single <canvas>, so the agent needs a browser that runs WebGL with no window, a way to wait until the scene has rendered, and a picture it can open. A raw picture also leaves questions open: which of the objects in the scene are in it, and how large each one is.
This guide uses paprika3d, a Claude Code plugin that opens three.js, React Three Fiber and WebGL pages in its own headless Chrome. Its game_look tool saves a screenshot, saves an ID image with one colour per object, and lists the named objects on screen with their share of the image and their pixel box.
Install
paprika3D needs Google Chrome and Node 20.11 or later.
claude plugin marketplace add paprika3d/paprika3d
claude plugin install paprika3d@paprika3d
Install three.js into the project folder, since the page below loads it through an import map:
npm install three
The page
Any three.js page works as it is. The one change that pays off is a name on each mesh, because game_look reports objects by name. This page draws a floor, a crate and a spinning torus knot:
<!-- verify -->
<!doctype html>
<html><head><meta charset="utf-8"><title>Viewer</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);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x20232a);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.set(4, 3, 6);
camera.lookAt(0, 0.5, 0);
scene.add(new THREE.HemisphereLight(0xffffff, 0x444455, 1.5));
const sun = new THREE.DirectionalLight(0xffffff, 2);
sun.position.set(3, 5, 2);
scene.add(sun);
// Name every mesh: the harness reports what is on screen by name.
const floor = new THREE.Mesh(new THREE.PlaneGeometry(10, 10), new THREE.MeshStandardMaterial({ color: 0x556070 }));
floor.name = 'floor';
floor.rotation.x = -Math.PI / 2;
scene.add(floor);
const crate = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ color: 0xc08040 }));
crate.name = 'crate';
crate.position.set(-1.2, 0.5, 0);
scene.add(crate);
const knot = new THREE.Mesh(new THREE.TorusKnotGeometry(0.5, 0.18, 128, 16), new THREE.MeshStandardMaterial({ color: 0x40a0e0 }));
knot.name = 'knot';
knot.position.set(1.2, 0.9, 0);
scene.add(knot);
renderer.setAnimationLoop((time) => {
knot.rotation.y = time / 1000; // performance time: the harness fakes it, so every run draws the same frame
renderer.render(scene, camera);
});
</script>
</body></html>
Save it as index.html in the project folder.
The tool calls, in order
These are the calls an agent makes, each followed by the reply paprika3d gave when this page was run through it on 2026-10-14. Run folder paths are shortened to <project>.
1. Open the page. game_open serves the folder, loads the page in headless Chrome and pauses its clock at frame 0. The entities list is every named object in the scene.
game_open {"url": "index.html", "cwd": "<project>"}
Opened http://127.0.0.1:59037/index.html, paused at frame 0.
{"three":"186","scenes":1,"entities":["floor","crate","knot"],"hasConvention":false}
Procedural geometry: import { geo, scatter } from "./geo.js" (see the skill's geometry reference).
Next: game_look to see it, then game_act for each interaction the request names.
Run folder: <project>/paprika3d/runs/2026-10-14T07-47-59-715Z
2. Take the screenshot. game_look with no arguments pictures the page as the game's own camera sees it.
game_look {}
Picture: <project>/paprika3d/runs/2026-10-14T07-47-59-715Z/look-1.png
ID image: <project>/paprika3d/runs/2026-10-14T07-47-59-715Z/look-1-ids.png
On screen (3):
floor 51.7% [0, 279, 1279, 799]
knot 4.1% [653, 257, 895, 492]
crate 2.2% [435, 299, 605, 459]
Never visible: none
The picture is a PNG at the page's viewport, 1280 px by 800 px. Each line under On screen is an object's name, the share of the image it covers, and its box in pixels as left, top, right, bottom.
3. Picture one object from every side. With target, game_look draws that object alone from the front, the right, the top and a three-quarter angle, framed to its size, in one picture. It also gives the object's size and centre in world units.
game_look {"target": "knot"}
Picture: <project>/paprika3d/runs/2026-10-14T07-47-59-715Z/look-2-views.png (front, right, top, three-quarter)
knot: size 1.884 × 1.768 × 1.355, centre [1.124, 0.9, 0.026]
4. Look from another camera. camera moves the camera that draws the scene for this one picture and puts it back afterwards. This is a top-down view:
game_look {"camera": {"position": [0, 8, 0.01], "lookAt": [0, 0, 0]}}
Picture: <project>/paprika3d/runs/2026-10-14T07-47-59-715Z/look-3.png
ID image: <project>/paprika3d/runs/2026-10-14T07-47-59-715Z/look-3-ids.png
On screen (3):
floor 89.9% [36, 0, 1243, 799]
knot 2.4% [682, 319, 910, 471]
crate 2.0% [405, 331, 555, 468]
Never visible: none
5. Move time forward, then look again. The clock stays paused until something steps it, so an animation is pictured at an exact frame. game_clock steps it:
game_clock {"step": 60}
Clock at 61.017 s, frame 61.
game_look {}
Picture: <project>/paprika3d/runs/2026-10-14T07-47-59-715Z/look-4.png
ID image: <project>/paprika3d/runs/2026-10-14T07-47-59-715Z/look-4-ids.png
On screen (3):
floor 52.3% [0, 279, 1279, 799]
knot 3.1% [675, 252, 878, 482]
crate 2.2% [435, 299, 605, 459]
Never visible: none
The knot has turned, so its box and share changed; the crate did not move, and its line is the same.
What to read in the reply
On screen (N): if an object the page should draw is missing from this list, it drew no pixel.Never visible: named objects that no look has seen yet, with their size and whether they are behind the camera or in view. An object in view that draws no pixel is usually far too small for the scene, hidden behind something, or not drawn at all. The guide on finding an invisible three.js model covers this line.Picture: open the PNG to judge colour, lighting and composition, which the list does not describe.Errors so farandFailed to loadlines in thegame_openreply: a page with console errors or a model that returned 404 shows them there.
Pitfalls
- Unnamed meshes are listed by their type (
Mesh), which tells the agent little. Give each mesh aname. - Time. The page runs on a fake clock that steps 1/60 s per frame. An animation driven by
requestAnimationFrametime orperformance.nowis reproducible; one driven by a separate real-time source is not. - A page that never renders.
game_openwaits for a three.js renderer orwindow.__game; after 10 s with neither, it returns an error with the page's console errors. - Pictures go in the project. Runs are written under
paprika3d/in the project folder; add that folder to.gitignore.
Without paprika3d
A headless browser script can take the same picture. With Puppeteer or Playwright, open the page, wait for the first frames to render, and call page.screenshot(). Two things to know when doing it by hand:
- Reading the canvas back with
canvas.toDataURL()after the frame has been presented returns a blank image unless the renderer was made withpreserveDrawingBuffer: true, or the read happens right afterrenderer.render()in the same task. - Headless Chrome needs WebGL enabled. On a machine with no GPU, such as many CI runners, it falls back to the SwiftShader software renderer, which recent Chrome versions turn on with flags such as
--use-angle=swiftshaderand--enable-unsafe-swiftshader.
A script like this gives the pixels. Listing which objects are in the picture, or picturing one object from four sides, takes extra code in the page that reads the scene graph.