THREE.WebGLRenderer.render: camera is not an instance of THREE.Camera.

What three.js means by "THREE.WebGLRenderer.render: camera is not an instance of THREE.Camera.", the isCamera test in WebGLRenderer.js that prints it, the objects that end up in the camera argument, and how a coding agent spots the wrong argument with the paprika3d plugin's observe and eval replies.

renderer.render(scene, camera) prints this and draws nothing when its second argument is an object that is not a camera. It is printed again on every frame of an animation loop, which is why people see it repeat hundreds of times. The fix is always at the call site: find what the code passed and pass the camera.

Where three.js prints it

The test is the first thing render does in src/renderers/WebGLRenderer.js (three r186):

this.render = function ( scene, camera ) {

	if ( camera !== undefined && camera.isCamera !== true ) {

		error( 'WebGLRenderer.render: camera is not an instance of THREE.Camera.' );
		return;

	}

	if ( _isContextLost === true ) return;

Two details of the test explain most of what people see. It reads the isCamera flag that every Camera subclass sets, not instanceof: older releases used instanceof, so a camera made by a second copy of three failed, and the old threads about two copies are about a check r186 no longer makes. And it skips undefined, so a missing camera passes this line and fails later inside the renderer with a TypeError instead.

Why the argument is not a camera

A page that reproduces it

A camera sits in a group that turns, and the loop renders the group:

const rig = new THREE.Group(); // turns the camera around the scene
rig.add(camera);
scene.add(rig);
renderer.setAnimationLoop(() => {
  rig.rotation.y += 0.01;
  renderer.render(scene, rig);
});

paprika3d is a Claude Code plugin and MCP server that runs three.js pages in a headless Chrome it owns, with the clock stopped at frame 0 until a tool steps it. The stable build answered these calls on 2026-10-28 on a computer with no GPU; lines not discussed here are left out, no line is reworded, and <project> stands for the folder.

open {"url":"index.html","cwd":"<project>"}

Opened http://127.0.0.1:41367/index.html in 0.3 s, paused at frame 0.
Page text outside the canvas: none
{"three":"186","scenes":1,"entities":["rig","cube"]}

look {}

Cannot read properties of undefined (reading 'elements')

observe {}

{"entities":{"rig":{"type":"Group","position":[0,0,0]},"cube":{"type":"Mesh","position":[0,0,0]}},"harness":{"frame":1,"renderer":{"calls":0,"triangles":0,"textures":0,"geometries":1,"programs":0},"scene":{"objects":3,"meshes":1,"lights":0,"cameras":1},"errors":["THREE.WebGLRenderer.render: camera is not an instance of THREE.Camera. (×3)"],"changed":null}}

eval {"code":"renderer.render(scene, find(\"rig\")); return 1"}

1
errors: THREE.WebGLRenderer.render: camera is not an instance of THREE.Camera.

open lists rig as an entity, a first hint that the page has a named group around its camera. look returns a TypeError and no picture: it redraws the frame with whatever the page last passed to render as its camera, and a Group has no projection matrix to read. That reply is the plugin failing on the same wrong argument, not a second bug in the page. observe holds the real message under harness.errors, counted three times, and shows "calls":0: three returned before drawing anything. Its scene block counts one camera in the scene, so the camera exists; it just never reached render. The eval call repeats the page's own call with the object found by name and gets the message back on its errors line, which confirms that the group is what triggers it.

The fix

Render with the camera and let the group move it. The camera's world matrix includes the group's rotation, so nothing else changes:

<!-- verify -->
<!doctype html>
<html><head><meta charset="utf-8"><title>Rig</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","three/addons/":"./node_modules/three/examples/jsm/"}}</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(0x1d2128);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 1, 4);
camera.lookAt(0, 0, 0);
const rig = new THREE.Group(); // turns the camera around the scene
rig.name = 'rig';
rig.add(camera);
scene.add(rig);
const cube = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshNormalMaterial());
cube.name = 'cube';
scene.add(cube);

addEventListener('resize', () => {
  renderer.setSize(innerWidth, innerHeight);
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
});
renderer.setAnimationLoop(() => {
  rig.rotation.y += 0.01;
  renderer.render(scene, camera); // the camera inside the rig, not the rig
});
</script>
</body></html>

With the camera passed, the cube is listed on screen, calls is 1, and errors is empty:

look {}

On screen (1):
cube 5.8% [517, 284, 760, 538]
Never visible: none

observe {}

{"entities":{"rig":{"type":"Group","position":[0,0,0]},"cube":{"type":"Mesh","position":[0,0,0]}},"harness":{"frame":1,"renderer":{"calls":1,"triangles":12,"textures":0,"geometries":1,"programs":2},"scene":{"objects":3,"meshes":1,"lights":0,"cameras":1},"errors":[],"changed":null}}

When the object comes from elsewhere, a one-line check in eval names it: return [x.type, x.isCamera] for the value the code passes. If it prints Group or Object3D, search its children with x.getObjectByProperty('isCamera', true), which returns the first camera under it.

Where people ask about it

These threads quote the message; each count was read on 2026-10-28.