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

Pitfalls

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:

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.