three.js InstancedMesh disappears when the camera moves? Check its bounding sphere

Why a three.js InstancedMesh vanishes when the camera turns, or draws instances behind the camera, and how a coding agent finds the stale bounding sphere with the paprika3d plugin's look and eval, fixes it with computeBoundingSphere, and counts what splitting the mesh into chunks saves with perf.

One sphere for every instance

three.js decides whether to draw a mesh by testing its bounding sphere against the camera's view. An InstancedMesh is one object with one sphere, so the test is all or nothing: when the sphere is in view every instance is drawn, including the ones behind the camera, and when it is out of view none is. That one rule explains both kinds of thread.

The fix for the first is one call after the matrices change: mesh.computeBoundingSphere(). frustumCulled = false also stops the vanishing, by never culling at all. The second has no switch in InstancedMesh: split the set into several meshes by area, each with its own sphere, or use a BatchedMesh, which tests each of its objects on its own (perObjectFrustumCulled, on by default).

paprika3d is a Claude Code plugin and MCP server that opens a three.js page in its own headless Chrome. Its look names a named mesh that never drew a pixel, and a cameras grid shows the angles at which it draws; eval puts the sphere beside the span of the instances; perf counts the triangles a frame sends, so the cost of drawing instances off screen is a number.

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 forest whose positions arrive late

900 trees on a grid 200 units across, one InstancedMesh. The positions are set on the second frame, as they would be after a fetch, so the mesh has already drawn once with every instance at the origin. The page recomputes the sphere after placing them; ?bug=stale leaves that line out, and ?chunks builds the forest as 16 meshes, one per area. The camera, named walker, stands at the forest's edge looking along it, away from the origin.

<!-- verify -->
<!doctype html>
<html><head><meta charset="utf-8"><title>Forest</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';

// ?bug=stale places the trees without recomputing the bounding sphere; ?chunks splits the forest into 16 meshes.
const params = new URLSearchParams(location.search);
const STALE = params.get('bug') === 'stale', CHUNKS = params.has('chunks');

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x9fc4e0);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.5, 400);
camera.name = 'walker';
camera.position.set(90, 5, 108);
camera.lookAt(90, 2, 60);
scene.add(new THREE.HemisphereLight(0xffffff, 0x556644, 2));

const ground = new THREE.Mesh(new THREE.PlaneGeometry(220, 220), new THREE.MeshStandardMaterial({ color: 0x6b8f4e }));
ground.name = 'ground';
ground.rotation.x = -Math.PI / 2;
scene.add(ground);

// 900 trees on a 30 x 30 grid, 200 units across: one InstancedMesh, or 16 by area with ?chunks.
const cone = new THREE.ConeGeometry(1.5, 6, 16).translate(0, 3, 0);
const leaves = new THREE.MeshStandardMaterial({ color: 0x2f5d34 });
const forest = new THREE.Group();
forest.name = 'forest';
scene.add(forest);
const meshes = new Map();
const meshFor = (x, z) => {
  const cell = (v) => Math.min(3, Math.floor((v + 100) / 50));
  const key = CHUNKS ? `${cell(x)},${cell(z)}` : 'all';
  if (!meshes.has(key)) meshes.set(key, []);
  return meshes.get(key);
};
for (let i = 0; i < 30; i++) for (let j = 0; j < 30; j++) {
  const x = -100 + i * 200 / 29, z = -100 + j * 200 / 29;
  meshFor(x, z).push([x, z]);
}
const spot = new THREE.Object3D();
const built = [...meshes.values()].map(points => {
  const m = new THREE.InstancedMesh(cone, leaves, points.length);
  m.name = 'trees';
  forest.add(m);
  return { m, points };
});

// The positions arrive after the first frame, as from a fetch: the meshes have drawn once with every instance at the origin.
function place() {
  for (const { m, points } of built) {
    points.forEach(([x, z], k) => { spot.position.set(x, 0, z); spot.updateMatrix(); m.setMatrixAt(k, spot.matrix); });
    m.instanceMatrix.needsUpdate = true;
    if (!STALE) m.computeBoundingSphere();   // the line the stale version leaves out
  }
}

let frame = 0;
renderer.setAnimationLoop(() => {
  if (++frame === 2) place();
  renderer.render(scene, camera);
});
addEventListener('resize', () => {
  renderer.setSize(innerWidth, innerHeight);
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
});
</script>
</body></html>

The tool calls, in order

These replies were captured on 2026-10-28 on a Linux server with no GPU, so Chrome drew the page in software (SwiftShader). Culling happens before anything reaches the GPU, so the pictures and counts below are the same on a machine with one; the frame times in the perf reply are not used. Run folder paths are shortened to <project>, and replies are cut to the lines this guide discusses, with nothing reworded.

1. Open the stale page and draw a few frames. open pauses at frame 0, before the trees are placed. interact runs three frames and draws each one (the wait step's draw switch), as a browser would, so the first frame's sphere is the one the page keeps:

open {"url": "index.html?bug=stale", "cwd": "<project>"}

Opened http://127.0.0.1:40893/index.html?bug=stale in 0.2 s, paused at frame 0.

interact {"steps": [{"wait": 3, "draw": true}]}

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

look {}

Picture: <project>/paprika3d/runs/2026-10-28T10-15-49-703Z/look-1.png
On screen (1):
ground 50% [0, 366, 1279, 799] 220 × 220 units
Never visible: trees (203 units, in view, behind ground)
In view but no pixel: too small for the scene (check its size against the scene), hidden behind something, or not drawn.

The picture is bare ground. Never visible names trees, gives its size from the instances' real positions, and says it is in view. Its guess, behind ground, is not the cause here: the last of the three reasons on the next line, not drawn, is. look measures where the instances are; three culls by a sphere that says something else.

2. Turn the camera. A cameras grid pictures the page from other places without moving the page's own camera:

look {"cameras": [{"label": "toward the origin", "position": [90, 5, 108], "lookAt": [0, 2, 0]}, {"label": "the walker", "position": [90, 5, 108], "lookAt": [90, 2, 60]}]}

Picture: <project>/paprika3d/runs/2026-10-28T10-15-49-703Z/look-3-cameras.png (2 cameras, left to right, top to bottom, at frame 3, no reload; the game's own camera is not moved)
toward the origin: on screen: ground, trees* ×371; pixels: brightness 49%, colour saturation 41%, 0% near black, 0% blown out
the walker: on screen: ground; world's edge 6% of the picture; pixels: brightness 57%, colour saturation 38%, 0% near black, 0% blown out

From the same spot, facing the origin shows 371 trees and facing along the forest shows none. A set that comes and goes as a whole with the camera's direction, and comes back when the origin is in view, is the stale sphere's signature.

3. Put the sphere beside the instances. eval reads the sphere and the span of the instance positions from their matrices:

eval {"code": "const m = find(\"trees\");\nconst box = new THREE.Box3(), p = new THREE.Vector3(), mat = new THREE.Matrix4();\nfor (let i = 0; i < m.count; i++) { m.getMatrixAt(i, mat); box.expandByPoint(p.setFromMatrixPosition(mat)); }\nconst s = m.boundingSphere;\nreturn { instances: m.count, frustumCulled: m.frustumCulled, sphere: s && { center: s.center.toArray().map(Math.round), radius: +s.radius.toFixed(1) }, instancesFrom: box.min.toArray().map(Math.round), instancesTo: box.max.toArray().map(Math.round) }"}

{"instances":900,"frustumCulled":true,"sphere":{"center":[0,3,0],"radius":3.4},"instancesFrom":[-100,0,-100],"instancesTo":[100,0,100]}

A sphere 3.4 units across the origin for instances spread from -100 to 100 is the whole answer. A sphere of null would mean the mesh has not been tested yet, which is no fault; frustumCulled: false would rule culling out.

4. Try the fix on the live page. Recompute the sphere, draw a frame, and look again:

eval {"code": "find(\"trees\").computeBoundingSphere();\nconst s = find(\"trees\").boundingSphere;\nreturn { center: s.center.toArray().map(Math.round), radius: +s.radius.toFixed(1) }"}

{"center":[-48,3,-2],"radius":183}

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

look {}

Picture: <project>/paprika3d/runs/2026-10-28T10-15-49-703Z/look-4.png
Pixels: brightness 48%, colour saturation 42%, 0% near black, 0% blown out; 42% of the picture changed since the last look.
On screen (214):
ground 17% [0, 366, 1279, 799] 220 × 220 units
trees* ×213 39% [0, 239, 1279, 799]
Never visible: none

213 trees are on screen from the walker's view. In the source the fix is the line the stale version leaves out: computeBoundingSphere() after every batch of setMatrixAt calls that moves instances, next to instanceMatrix.needsUpdate = true. The new sphere is larger than the forest needs (three builds it by merging one sphere per instance), which costs nothing but a little culling.

5. Count what the frame sends. With the sphere right, the forest is one object in view, so every frame draws all 900 trees, the ones behind the walker included. perf with a query variant loads the 16-chunk version on a fresh page and compares the counts:

perf {"variants": [{"label": "16 chunks", "query": "chunks"}]}

baseline: 167.4 ms median (p95 248.2), work 967.3 ms, 2 draw calls, 28802 triangles; over budget: frameMs 167.4 of 16.7 ms; desktop budget
16 chunks: 137.8 ms median (p95 460.8), work 600.4 ms, 12 draw calls, 19810 triangles; −18% vs baseline; +500% draw calls, −31% triangles
Host load 36.6 on 64 cores. 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.

The counts are the result. The single mesh sends every tree in one draw call, 28802 triangles with the ground's two. The chunked version sends 19810 triangles in 12 draw calls: the ground and the 11 chunks whose spheres touch the view. The frame times come from a software renderer on a loaded host and decide nothing. On a GPU, whether 31% fewer triangles is worth ten more draw calls depends on the triangles per instance: a few thousand trees of a few dozen triangles each, as here, rarely need it; a crowd of detailed characters may. Run the same call on the machine that will play the page.

Which fix to use

Situation Fix
Instances placed or moved after the first frame computeBoundingSphere() after the matrices change
Instances that move every frame (a flock, particles as meshes) frustumCulled = false, or a sphere computed once to cover their whole range
A large field where most instances are off screen several meshes by area, each with its own sphere
Many objects that should each be culled BatchedMesh, which culls per object by default

What paprika3d does not do

Pitfalls

Without paprika3d

In the console, mesh.boundingSphere beside a loop over getMatrixAt gives the same comparison as step 3. A THREE.Box3Helper of mesh.boundingBox, or a wireframe sphere placed at mesh.boundingSphere.center with its radius, shows the culling volume in the scene. The three.js docs for InstancedMesh say the sphere may need recomputing after setMatrixAt, and the webgl_mesh_batch example shows a BatchedMesh with per-object culling.