three.js InstancedMesh color per instance, and why setColorAt changes nothing

How to give each instance of a three.js InstancedMesh its own colour with setColorAt, and the three faults behind "setColorAt doesn't work" (vertexColors on the material, a mesh made with no room, a change never uploaded), each told apart by one eval call and checked against the drawn pixels with the paprika3d plugin's look sample.

How an instance gets its colour

An InstancedMesh draws many copies of one geometry with one material in one draw call. Each copy has its own matrix in instanceMatrix, and it can have its own colour in a second attribute, instanceColor. That attribute does not exist until the first setColorAt(i, color) call creates it, sized for the mesh's capacity: the third argument of the constructor, fixed for the life of the mesh. The shader multiplies each instance's colour into the material's colour, so a white material.color shows the instance colours as they are and any other colour tints them all.

Three things go wrong often enough to fill the threads:

  1. vertexColors: true on the material. It is the switch for a color attribute on the geometry, not for instance colours. On a geometry with no such attribute every instance draws black.
  2. A mesh made with no room. new THREE.InstancedMesh(geometry, material, 0), with the plan of raising count later, gives a matrix buffer and a colour buffer with no slots. setMatrixAt and setColorAt write nowhere, raising count adds no room, and nothing draws.
  3. A change never uploaded. Colours set before the first frame upload with it. A colour changed after that stays on the CPU until mesh.instanceColor.needsUpdate = true, the same rule as instanceMatrix.needsUpdate for positions.

paprika3d is a Claude Code plugin and MCP server that opens a three.js page in its own headless Chrome. No reply line names these three faults. What the tools give is the two readings that tell them apart: eval returns the mesh's capacity, the colour buffer's size, its upload version and its first value, and look with sample returns the colour drawn at a point on the page. A colour buffer that holds one colour while the pixel shows another is fault 3; an empty buffer is fault 2; black pixels over a full buffer with vertexColors on is fault 1.

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 shelf of jars

64 jars in four colours, one InstancedMesh. With no query string the page draws them correctly. ?bug=vertexcolors, ?bug=empty and ?bug=noupdate each switch on one of the three faults; in the last, every jar is meant to turn grey on the second frame, as a "sold out" state would.

<!-- verify -->
<!doctype html>
<html><head><meta charset="utf-8"><title>Shelf</title>
<style>html,body{margin:0;overflow:hidden;background:#1b1e24}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=vertexcolors, ?bug=empty or ?bug=noupdate shows one of the three faults; no query draws it right.
const BUG = new URLSearchParams(location.search).get('bug');

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(0x1b1e24);
const camera = new THREE.PerspectiveCamera(40, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 0, 16);
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 2.5));

// 64 jars on a shelf: one geometry, one material, one draw call.
const COUNT = 64;
const jarMaterial = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.6, vertexColors: BUG === 'vertexcolors' });
const jars = new THREE.InstancedMesh(new THREE.SphereGeometry(0.5, 24, 16), jarMaterial, BUG === 'empty' ? 0 : COUNT);
jars.name = 'jars';
scene.add(jars);

const palette = [0xe63946, 0xf4a261, 0x2a9d8f, 0x457b9d];
const place = new THREE.Object3D(), color = new THREE.Color();
function fill(n) {
  jars.count = n;
  for (let i = 0; i < n; i++) {
    place.position.set((i % 16) - 7.5, 1.5 - Math.floor(i / 16), 0);
    place.updateMatrix();
    jars.setMatrixAt(i, place.matrix);
    jars.setColorAt(i, color.setHex(palette[i % 4]));
  }
  jars.instanceMatrix.needsUpdate = true;
  if (jars.instanceColor) jars.instanceColor.needsUpdate = true;
}
// The 'empty' bug: made with count 0, filled later (as a loader that does not know the count yet would).
fill(COUNT);

// One frame in, every jar turns grey to mark it sold out; the 'noupdate' bug forgets to upload the change.
let frame = 0;
renderer.setAnimationLoop(() => {
  if (++frame === 2 && BUG === 'noupdate') {
    for (let i = 0; i < COUNT; i++) jars.setColorAt(i, color.set(0x777777));
  }
  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); instance colours are drawn the same way on a GPU, and nothing below depends on timing. Run folder paths are shortened to <project>, and replies are cut to the lines this guide discusses, with nothing reworded. The four sample points are the centres of the first four jars, read off the box look gives for jars.

1. The correct page. look with sample reads the drawn colour at each point, so "is each jar its own colour" is four hex values rather than a judgement of a picture:

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

Opened http://127.0.0.1:34715/index.html in 0.2 s, paused at frame 0.

look {"sample": [[121, 296], [190, 296], [259, 296], [328, 296]]}

Hues: blue 82%, orange 6%, cyan 6%, red 6% of the frame; over the objects (23.8% of it): cyan 25%, orange 25%, blue 25%, red 25%.
On screen (64):
jars* ×64 24% [86, 262, 1193, 537]
Never visible: none
Colours (5 × 5 mean, in the page's pixels): (121, 296) #95212a, (190, 296) #9e683c, (259, 296) #17645b, (328, 296) #294d64.

jars* ×64 is one object drawn 64 times, and the Hues line splits the jars into four equal colours. The sampled colours are darker than the palette's hex values because the jars are shaded by the light; what matters is that the four differ.

2. Fault 1: every jar black. The same calls on index.html?bug=vertexcolors, then the eval that reads everything the three faults depend on:

look {"sample": [[121, 296], [190, 296], [259, 296], [328, 296]]}

Hues: blue 76%, grey 24% of the frame; over the objects (23.8% of it): grey 100%.
On screen (64):
jars* ×64 24% [86, 262, 1193, 537]
Colours (5 × 5 mean, in the page's pixels): (121, 296) #000000, (190, 296) #000000, (259, 296) #000000, (328, 296) #000000.

eval {"code": "const m = find(\"jars\");\nreturn { count: m.count, capacity: m.instanceMatrix.count, instanceColor: m.instanceColor && { slots: m.instanceColor.count, version: m.instanceColor.version, first: [...m.instanceColor.array.slice(0, 3)].map(v => +v.toFixed(3)) }, vertexColors: m.material.vertexColors, geometryHasColor: !!m.geometry.attributes.color }"}

{"count":64,"capacity":64,"instanceColor":{"slots":64,"version":1,"first":[0.791,0.041,0.061]},"vertexColors":true,"geometryHasColor":false}

The colour buffer is full and right (the first jar's red, in linear values), and the pixels are #000000. vertexColors: true with geometryHasColor: false is the cause: the shader multiplies in a vertex colour the geometry does not have. Try the fix on the live page before editing the source; a material switch needs needsUpdate so three builds its shader again, and a frame has to draw before look can see it:

eval {"code": "const m = find(\"jars\");\nm.material.vertexColors = false;\nm.material.needsUpdate = true;"}

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

look {"sample": [[121, 296], [190, 296], [259, 296], [328, 296]]}

Colours (5 × 5 mean, in the page's pixels): (121, 296) #95212a, (190, 296) #9e683c, (259, 296) #17645b, (328, 296) #294d64.

The four colours match step 1.

3. Fault 2: nothing draws. On index.html?bug=empty:

look {"sample": [[121, 296], [190, 296], [259, 296], [328, 296]]}

On screen (0):
Never visible: jars (null units, in view)
In view but no pixel: too small for the scene (check its size against the scene), hidden behind something, or not drawn.
Colours (5 × 5 mean, in the page's pixels): (121, 296) #1b1e24, (190, 296) #1b1e24, (259, 296) #1b1e24, (328, 296) #1b1e24.

eval {"code": "const m = find(\"jars\");\nreturn { count: m.count, capacity: m.instanceMatrix.count, instanceColor: m.instanceColor && { slots: m.instanceColor.count, version: m.instanceColor.version, first: [...m.instanceColor.array.slice(0, 3)].map(v => +v.toFixed(3)) }, vertexColors: m.material.vertexColors, geometryHasColor: !!m.geometry.attributes.color }"}

{"count":64,"capacity":0,"instanceColor":{"slots":0,"version":1,"first":[]},"vertexColors":false,"geometryHasColor":false}

Never visible names the mesh, and the samples are the background colour. The eval says why: count is 64 but capacity is 0, and the colour buffer has 0 slots. Raising count asks for 64 instances from buffers that hold none. The fix is in the constructor: make the mesh with the most instances it will ever draw, and lower count to show fewer. There is no eval fix short of building a new mesh, so this one goes straight into the source.

4. Fault 3: the change never shows. On index.html?bug=noupdate, the page turns every jar grey on its second frame. The wait step's draw switch makes interact draw each of the five frames, as a browser would; without it a step draws its last frame alone, and the first upload would already carry the grey.

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

look {"sample": [[121, 296], [190, 296], [259, 296], [328, 296]]}

Colours (5 × 5 mean, in the page's pixels): (121, 296) #95212a, (190, 296) #9e683c, (259, 296) #17645b, (328, 296) #294d64.

eval {"code": "const m = find(\"jars\");\nreturn { count: m.count, capacity: m.instanceMatrix.count, instanceColor: m.instanceColor && { slots: m.instanceColor.count, version: m.instanceColor.version, first: [...m.instanceColor.array.slice(0, 3)].map(v => +v.toFixed(3)) }, vertexColors: m.material.vertexColors, geometryHasColor: !!m.geometry.attributes.color }"}

{"count":64,"capacity":64,"instanceColor":{"slots":64,"version":1,"first":[0.184,0.184,0.184]},"vertexColors":false,"geometryHasColor":false}

The buffer holds grey (0x777777 is 0.184 in linear) and the pixels are still the four colours: the array changed and its version did not, so the renderer never sent it again. Setting the flag bumps the version, and the next drawn frame uploads it:

eval {"code": "find(\"jars\").instanceColor.needsUpdate = true;\nreturn find(\"jars\").instanceColor.version"}

2

interact {"steps": [{"wait": 1}], "look": true}

Hues: blue 76%, grey 24% of the frame; over the objects (23.8% of it): grey 100%.

look {"sample": [[121, 296], [190, 296], [259, 296], [328, 296]]}

Colours (5 × 5 mean, in the page's pixels): (121, 296) #4b4b4b, (190, 296) #4b4b4b, (259, 296) #4b4b4b, (328, 296) #4b4b4b.

What the eval tells apart

What you see What the eval returns The fix
Every instance black vertexColors: true, geometryHasColor: false, a full colour buffer vertexColors: false on the material
Nothing drawn capacity 0 or below count, colour slots the same the largest count in the constructor, then lower count
The old colours after a change the buffer's first value is the new colour, version unchanged instanceColor.needsUpdate = true after the change
Colours all tinted one way a full buffer and a non-white material.color a white material colour
Every instance white instanceColor: null setColorAt was never called

The last two rows were not reproduced on this page; they follow from the multiply in the shader and from setColorAt being what creates the buffer.

What paprika3d does not do

Pitfalls

Without paprika3d

In the browser console, mesh.instanceColor (null or an InstancedBufferAttribute), mesh.instanceMatrix.count, mesh.count and mesh.material.vertexColors give the same readout as the eval above. To read a drawn colour, render once and call gl.readPixels on the WebGL context at a point, or take a screenshot and use a colour picker. The three.js docs for InstancedMesh list setColorAt, instanceColor and the needsUpdate rule, and the webgl_instancing_raycast example changes an instance's colour when the pointer is over it.