Inspect a three.js scene graph and live values from a coding agent
How a coding agent inspects a three.js scene graph from outside the page with the paprika3d plugin: the object tree, world positions, materials, camera and renderer counts read with eval, live values changed and stepped on a paused clock, and the object under a pixel from look.
Why the console shows nothing
A three.js page written as an ES module keeps scene, camera and renderer in module scope. Typing scene in the browser console gives a ReferenceError, because nothing in the module put it on window. The three.js developer tools extension shows the scene graph in a browser panel, for a person at the browser. A coding agent works from text: it needs the same tree, positions and values as a reply it can read.
paprika3d is a Claude Code plugin and MCP server that opens a three.js page in its own headless Chrome and finds the page's renderer and scene by itself, with no change to the page. Its eval tool runs JavaScript in the page with scene, camera, renderer, find(name) and stats(target) in scope (and THREE, on a page that loads three through an import map), and returns what the code returns. observe lists the scene's named objects and the renderer's counts as JSON, and look names the object under any pixel.
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 page with a nested scene
A rover made of named parts two levels deep: a body, a mast group holding a pole and a head, and a group of four wheels. Every variable is in module scope. The rover's speed is kept on rover.userData, and its heading in a plain let, to show the difference.
<!-- verify -->
<!doctype html>
<html><head><meta charset="utf-8"><title>Rover</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';
// Everything here is module scope: none of it is on window.
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(0xd8b48a);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.set(4, 3, 6);
camera.lookAt(0, 0.6, 0);
scene.add(new THREE.HemisphereLight(0xfff4e0, 0x6a4a30, 2));
const sun = new THREE.DirectionalLight(0xffffff, 1.5);
sun.position.set(5, 8, 3);
scene.add(sun);
const ground = new THREE.Mesh(new THREE.PlaneGeometry(30, 30), new THREE.MeshStandardMaterial({ color: 0xb5835a }));
ground.name = 'ground';
ground.rotation.x = -Math.PI / 2;
scene.add(ground);
// A rover: a group with named parts, nested two levels deep.
const paint = new THREE.MeshStandardMaterial({ color: 0xe8e8e8, metalness: 0.3, roughness: 0.5 });
const rubber = new THREE.MeshStandardMaterial({ color: 0x222222 });
const rover = new THREE.Group();
rover.name = 'rover';
const body = new THREE.Mesh(new THREE.BoxGeometry(2, 0.5, 1.2), paint);
body.name = 'body';
body.position.y = 0.6;
const mast = new THREE.Group();
mast.name = 'mast';
mast.position.set(0.6, 0.85, 0);
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.8), paint);
pole.position.y = 0.4;
const head = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.2, 0.4), paint);
head.name = 'head';
head.position.y = 0.9;
mast.add(pole, head);
const wheels = new THREE.Group();
wheels.name = 'wheels';
for (const [x, z] of [[-0.7, 0.7], [0.7, 0.7], [-0.7, -0.7], [0.7, -0.7]]) {
const wheel = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.3, 0.2, 24).rotateX(Math.PI / 2), rubber);
wheel.name = `wheel ${x < 0 ? 'rear' : 'front'} ${z > 0 ? 'left' : 'right'}`;
wheel.position.set(x, 0.3, z);
wheels.add(wheel);
}
rover.add(body, mast, wheels);
rover.userData.speed = 0.4; // units per second, kept on the object so it can be read and set from outside
scene.add(rover);
let heading = 0; // a module variable: nothing outside this script can reach it
let last = null;
renderer.setAnimationLoop((now) => {
const dt = last === null ? 1 / 60 : Math.min((now - last) / 1000, 0.1);
last = now;
heading += 0.3 * dt;
mast.rotation.y = Math.sin(heading) * 0.8;
rover.position.x += rover.userData.speed * dt;
renderer.render(scene, camera);
});
addEventListener('resize', () => {
renderer.setSize(innerWidth, innerHeight);
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
});
</script>
</body></html>
The tool calls, in order
Each call is followed by the reply paprika3d gave for this page on 2026-10-28. Run folder paths are shortened to <project>. The machine that captured them has no GPU, which the GPU line in the open reply says; none of the values below depend on it.
1. Open the page. entities lists the named objects at the top of the scene.
open {"url": "index.html", "cwd": "<project>"}
Opened http://127.0.0.1:36481/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":["ground","rover"]}
Next: `look` to see it, then `interact` for each interaction the request names.
Run folder: <project>/paprika3d/runs/2026-10-28T05-15-13-181Z
2. Read the scene as JSON. On a page with no window.__game, observe falls back to those named objects, with the renderer's counts and the scene's totals:
observe {}
{"entities":{"ground":{"type":"Mesh","position":[0,0,0]},"rover":{"type":"Group","position":[0,0,0]}},"harness":{"frame":0,"renderer":{"calls":0,"triangles":0,"textures":0,"geometries":0,"programs":0},"scene":{"objects":13,"meshes":8,"lights":2,"cameras":0},"errors":[],"changed":null}}
3. Print the whole tree. scene is in scope in eval, so a short walk prints every object with its type, name and geometry:
eval {"code": "const lines = []; const walk = (o, d) => { lines.push(`${' '.repeat(d)}${o.type} ${o.name || '(no name)'}${o.isMesh ? ' ' + o.geometry.type : ''}`); o.children.forEach(c => walk(c, d + 1)); }; walk(scene, 0); return lines.join('\\n')"}
Scene (no name)
HemisphereLight (no name)
DirectionalLight (no name)
Mesh ground PlaneGeometry
Group rover
Mesh body BoxGeometry
Group mast
Mesh (no name) CylinderGeometry
Mesh head BoxGeometry
Group wheels
Mesh wheel rear left CylinderGeometry
Mesh wheel front left CylinderGeometry
Mesh wheel rear right CylinderGeometry
Mesh wheel front right CylinderGeometry
4. Read one object. find(name) returns the object with that name at any depth. Its local position is relative to its parent; getWorldPosition gives where it is in the scene:
eval {"code": "const h = find('head'); return { parent: h.parent.name, local: h.position.toArray(), world: h.getWorldPosition(new THREE.Vector3()).toArray(), material: { color: '#' + h.material.color.getHexString(), metalness: h.material.metalness, roughness: h.material.roughness } }"}
{"parent":"mast","local":[0,0.9,0],"world":[0.607,1.75,0],"material":{"color":"#e8e8e8","metalness":0.3,"roughness":0.5}}
5. Read the camera and the renderer.
eval {"code": "return { camera: { position: camera.position.toArray(), fov: camera.fov, near: camera.near, far: camera.far }, render: renderer.info.render, memory: renderer.info.memory }"}
{"camera":{"position":[4,3,6],"fov":45,"near":0.1,"far":100},"render":{"frame":1,"calls":8,"triangles":538,"points":0,"lines":0},"memory":{"geometries":8,"textures":1}}
6. Module variables stay out of reach. eval reaches what the renderer draws, not the script's own variables. heading is a let in the module, and speed lives on the rover:
eval {"code": "return { heading: typeof heading, speed: find('rover').userData.speed }"}
{"heading":"undefined","speed":0.4}
A value you want to read or tune from outside belongs on an object in the scene (userData), on window, or in the page's window.__game.observe().
7. Change a value and let time run. The page runs on a paused clock. Code in eval that awaits a timer steps the clock until the timer fires, so a second of game time passes in one call:
eval {"code": "const rover = find('rover'); rover.userData.speed = 2; const before = rover.position.x; await new Promise(r => setTimeout(r, 1000)); return { before, after: rover.position.x }"}
{"before":0.007,"after":2.04}
(the code awaited the paused clock, so the harness stepped it 61 frames, 1.02 s of game time, without drawing, until the code finished)
observe then reports what moved since the last time it was called:
observe {}
{"entities":{"ground":{"type":"Mesh","position":[0,0,0]},"rover":{"type":"Group","position":[2.04,0,0]}},"harness":{"frame":62,"renderer":{"calls":8,"triangles":538,"textures":1,"geometries":8,"programs":1},"scene":{"objects":13,"meshes":8,"lights":2,"cameras":0},"errors":[],"changed":{"entities":{"rover":{"position":[[0,0,0],[2.04,0,0]]}},"harness":{"renderer":{"calls":[0,8],"triangles":[0,538],"textures":[0,1],"geometries":[0,8],"programs":[0,1]}}}}}
A change made through eval lasts until the page reloads. To keep it, write it into the source.
8. Measure a subtree. stats(name) reports each mesh under the named object: vertices, triangles, whether it is closed, its volume, its genus and its world size.
eval {"code": "return stats('wheels')"}
total 4 meshes 592 v 384 tri
wheel rear left 148 v 96 tri closed vol 0.056 genus 0 size 0.6×0.6×0.2 [position, normal, uv]
wheel front left 148 v 96 tri closed vol 0.056 genus 0 size 0.6×0.6×0.2 [position, normal, uv]
wheel rear right 148 v 96 tri closed vol 0.056 genus 0 size 0.6×0.6×0.2 [position, normal, uv]
wheel front right 148 v 96 tri closed vol 0.056 genus 0 size 0.6×0.6×0.2 [position, normal, uv]
9. Ask what is under a pixel. look with at lists the objects drawn at each point, nearest first, with the distance from the camera and the point hit in world space. It returns text and no picture:
look {"at": [[640, 400], [100, 700]]}
At (640, 400): body 6.84 m at [0.4, 0.84, 0.6]; behind it wheel rear right 8.37 m; then ground 9.49 m; background beyond.
At (100, 700): ground 5.83 m at [-0.6, 0, 4.05]; background beyond.
look with target pictures one object alone from the front, right, top and a three-quarter angle, and gives its size and centre:
look {"target": "rover"}
Picture: <project>/paprika3d/runs/2026-10-28T05-15-13-181Z/look-1-views.png (front, right, top, three-quarter)
rover: size 2 × 1.85 × 1.6, centre [2.04, 0.925, 0]
What to read in the replies
open,entities: the named objects at the top of the scene, at frame 0.observe: named objects with their type and position,harness.renderer(draw calls, triangles, textures, geometries and shader programs of the last frame),harness.scene(object, mesh, light and camera totals),errors, andchangedsince the lastobserve.eval: whatever the code returns, as JSON or text. Replies are capped at 2,048 characters; the full text is saved in the run folder.lookat: the object under a pixel, its distance and the world point.
Pitfalls
- Name what you want to find.
find,observe,lookandstatswork by name. An unnamed mesh appears as(Mesh)or under its nearest named parent. - Keep tunable values reachable. A module
letcannot be read from outside the module;userData,windoworwindow.__gamecan. - Use
getWorldPositionfor where a child is.positionis relative to the parent; the head above sits at y 0.9 in its group and at y 1.75 in the world. - Return plain values.
evalreturns JSON, so return arrays and numbers (position.toArray()) rather than whole three.js objects. - paprika3d reads the page; it is not an editor. It has no panel or tree view, and nothing it changes through
evalsurvives a reload.
Without paprika3d
In the page, window.scene = scene (and the same for camera and renderer) makes them reachable from the browser console; remove it before shipping if you prefer not to expose them. scene.traverse((o) => console.log(o.type, o.name)) prints the tree, and scene.getObjectByName(name) is what find does. renderer.info holds the draw calls, triangles and GPU memory counts. The three.js developer tools extension works through a hook three.js keeps for it: when a __THREE_DEVTOOLS__ event target exists on the page, each new Scene and WebGLRenderer sends it an observe event with the object, which a panel can then list.