THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index 1. All geometries must have compatible attributes; make sure index attribute exists among all geometries, or in none of them.

What the three.js error "BufferGeometryUtils: .mergeGeometries() failed with geometry at index N. All geometries must have compatible attributes" means, the checks in BufferGeometryUtils.js behind it, which built-in geometries are indexed and which are not, and how the paprika3d plugin's open, observe and eval replies show the merge returning null.

mergeGeometries() from the addons combines several geometries into one so they draw in a single call. It prints this console error and returns null when the geometry at the given position in the array is built differently from the first one. The word after make sure names the difference: index when one geometry has an index buffer and another has none, or a quoted attribute name such as "uv" or "color" when one geometry has an attribute the first lacks. The page usually fails a line later, when the null reaches a Mesh.

Where three.js prints it

mergeGeometries() in examples/jsm/utils/BufferGeometryUtils.js (three r186) takes its reference from geometry 0, then compares every geometry in the array with it, index first and attribute names second:

const isIndexed = geometries[ 0 ].index !== null;

const attributesUsed = new Set( Object.keys( geometries[ 0 ].attributes ) );

// ...

	// ensure that all geometries are indexed, or none

	if ( isIndexed !== ( geometry.index !== null ) ) {

		console.error( 'THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index ' + i + '. All geometries must have compatible attributes; make sure index attribute exists among all geometries, or in none of them.' );
		return null;

	}

	// gather attributes, exit early if they're different

	for ( const name in geometry.attributes ) {

		if ( ! attributesUsed.has( name ) ) {

			console.error( 'THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index ' + i + '. All geometries must have compatible attributes; make sure "' + name + '" attribute exists among all geometries, or in none of them.' );
			return null;

		}

A third check follows, for a geometry that has fewer attributes than the first, with its own wording: Make sure all geometries have the same number of attributes. The morph attributes and morphTargetsRelative must agree too. In r186 every one of these failures uses console.error directly and returns null; none of them throws.

Why the geometries are not compatible

A page that reproduces it

A signpost is a cylinder for the post and an extruded arrow for the sign, merged into one mesh:

const post = new THREE.CylinderGeometry(0.06, 0.06, 2.4, 12).translate(0, 1.2, 0); // indexed
const sign = new THREE.ExtrudeGeometry(arrow, { depth: 0.06, bevelEnabled: false }).translate(0.2, 2, 0.06); // non-indexed
const geometry = mergeGeometries([post, sign]); // null
const signpost = new THREE.Mesh(geometry, material);

paprika3d is a Claude Code plugin and MCP server that opens a three.js page in its own headless Chrome on a paused clock. The calls were made on 2026-10-28 with the stable build, on a machine with no GPU; <project> replaces the run folder, and the replies keep the lines this section covers, as printed.

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

Opened http://127.0.0.1:35329/index.html in 3.1 s, paused at frame 0.
Page text outside the canvas: none
{"three":"186","scenes":1,"entities":[]}
Errors so far:
THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index 1. All geometries must have compatible attributes; make sure index attribute exists among all geometries, or in none of them.
Cannot read properties of null (reading 'morphAttributes') (index.html:26:18)

observe {}

{"entities":{},"harness":{"frame":101,"renderer":{"calls":0,"triangles":0,"textures":0,"geometries":0,"programs":0},"scene":{"objects":1,"meshes":0,"lights":1,"cameras":0},"errors":["THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index 1. All geometries must have compatible attributes; make sure index attribute exists among all geometries, or in none of them.","Cannot read properties of null (reading 'morphAttributes') (index.html:26:18)"],"changed":null}}

open lists two errors in the order they happened. The first is three's, with index 1 pointing at the sign, the second entry in the array. The second is the crash it causes: new THREE.Mesh(null, ...) reads geometry.morphAttributes in its constructor, and line 26 of the page is that new THREE.Mesh call. That exception stops the module, so no mesh is added, entities is empty and the renderer never draws. A look on this page waits for a frame and reports that none came.

Which built-in geometries carry an index is easy to check in the page with eval:

eval {"code": "const s = new THREE.Shape([new THREE.Vector2(0, 0), new THREE.Vector2(1, 0), new THREE.Vector2(0, 1)]); const g = { Box: new THREE.BoxGeometry(), Cylinder: new THREE.CylinderGeometry(), Sphere: new THREE.SphereGeometry(), Shape: new THREE.ShapeGeometry(s), Extrude: new THREE.ExtrudeGeometry(s), Icosahedron: new THREE.IcosahedronGeometry(), Tube: new THREE.TubeGeometry() }; return Object.entries(g).map(([k, v]) => k + (v.index ? \" indexed\" : \" non-indexed\"))"}

["Box indexed","Cylinder indexed","Sphere indexed","Shape indexed","Extrude non-indexed","Icosahedron non-indexed","Tube indexed"]

The replies name the position in the array, not the variable, so with a long list of parts the agent maps the number back to the array the page passed in.

The fix

Bring every part to the same form before merging. Expanding the indexed parts with toNonIndexed() works whenever the attribute names already match; welding the non-indexed ones with mergeVertices() from the same addon keeps the index but changes vertex counts. The page below expands the geometries that have an index and leaves the rest as they are, which also avoids the warning toNonIndexed() prints for a geometry that is already non-indexed, and it stops with a clear error if the merge still returns null:

<!-- verify -->
<!doctype html>
<html><head><meta charset="utf-8"><title>Signpost</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';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';

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);
scene.add(new THREE.HemisphereLight(0xffffff, 0x334455, 2));
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 1.5, 5);
camera.lookAt(0, 1.2, 0);

// a post (indexed) and an arrow-shaped sign (ExtrudeGeometry: non-indexed), merged into one draw
const post = new THREE.CylinderGeometry(0.06, 0.06, 2.4, 12).translate(0, 1.2, 0);
const arrow = new THREE.Shape([[-0.6, -0.2], [0.4, -0.2], [0.7, 0], [0.4, 0.2], [-0.6, 0.2]].map(([x, y]) => new THREE.Vector2(x, y)));
const sign = new THREE.ExtrudeGeometry(arrow, { depth: 0.06, bevelEnabled: false }).translate(0.2, 2, 0.06);
// mergeGeometries() needs all indexed or none: expand the indexed ones, and stop on a null result
const parts = [post, sign].map(g => g.index ? g.toNonIndexed() : g);
const geometry = mergeGeometries(parts);
if (!geometry) throw new Error('signpost: mergeGeometries failed, see the console line above');

const signpost = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color: 0xc8a060 }));
signpost.name = 'signpost';
scene.add(signpost);

addEventListener('resize', () => {
  renderer.setSize(innerWidth, innerHeight);
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
});
renderer.setAnimationLoop(() => renderer.render(scene, camera));
</script>
</body></html>

With both parts non-indexed, the merge succeeds and the signpost is one mesh drawn in one call:

look {}

On screen (1):
signpost 2.1% [569, 190, 798, 604] 2.4 × 1.3 units
Never visible: none

eval {"code": "const g = find(\"signpost\").geometry; return { index: g.index, attributes: Object.keys(g.attributes), vertices: g.attributes.position.count }"}

{"index":null,"attributes":["position","normal","uv"],"vertices":192}

The merged geometry has no index and the three attributes both parts share. For the attribute form of the message, the same idea applies: delete the extra attribute from the parts that have it (geometry.deleteAttribute('color')) or add a matching one to the parts that lack it, before calling mergeGeometries().

Where people ask about it

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