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.
- Everything vanishes at some camera angles. The sphere is computed the first time the renderer tests the mesh, from the instance matrices at that moment, and never again by itself.
setMatrixAtdoes not touch it. Instances placed after the first frame (positions from a fetch, a level loaded later) or moved far from where they started leave a sphere that no longer covers them. A sphere computed while every matrix was still the identity sits at the origin with the size of one instance, so the whole set draws when the camera faces the origin and vanishes when it turns away. - Instances behind the camera are still drawn. With a correct sphere around a large field, the sphere is almost always in view, so the frame sends every instance to the GPU and lets it clip the ones off screen.
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
- No line names a stale bounding sphere.
Never visiblenames a named mesh that drew nothing while in view, and its guess at a reason can be wrong, as in step 1; theevalin step 3 is the check. Never visiblelists meshes by their own name. An unnamedInstancedMeshinside a named group is left out of that line, thoughOn screencounts it under the group's name; name the mesh itself.- It does not cull for you or split a mesh into chunks. The page does that in its own code.
perf's cost table (costs: true) counts every visible object with no frustum culling, so it lists all 900 trees either way; the first line and the variant lines are the frame as drawn.
Pitfalls
- Raycasts use the same sphere.
InstancedMesh.raycasttests the bounding sphere before any instance, so a stale sphere makes clicks miss the instances it does not cover. - The bounding box has the same rule.
computeBoundingBox()is the box's version; code that readsmesh.boundingBoxafter moving instances needs it. countchanges the sphere too. The sphere covers the firstcountinstances when it is computed; raisingcountlater adds instances it does not cover.- Draw a frame before you look. A paused page shows its last drawn frame, so an
evalchange shows after aninteractstep draws one.
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.