Separate meshes cost about 80 µs each per frame on a mid-range phone, so a few hundred of them use up a whole frame. <Instances> draws every copy of a shape in one draw call: seats in a cinema, trees in a forest, coins, posts, products on a shelf.
import { Box, Instances, Scene } from "@apricot/react";
const ITEMS = Array.from({ length: 60 }, (_, i) => ({
position: [(i % 12) * 0.6 - 3.3, Math.floor(i / 12) * 0.8 - 1.6, 0] as const,
color: i % 3 === 0 ? "#ff9f59" : "#cfd3d8",
}));
export function Shelf() {
return (
<Scene style={{ flex: 1 }} camera={{ position: [0, 0, 8] }}>
<Instances items={ITEMS} material="plastic">
<Box size={0.4} radius={0.05} />
</Instances>
</Scene>
);
}What to copy#
Give one of these:
| Prop | Copies | Example |
|---|---|---|
| children | The shapes inside, together, as one copy. Their positions place them within each copy. | A seat made of a cushion and a back: two <Box> children |
geometry |
One three.js geometry | A geometry you built |
object |
Every mesh of an object | A loaded model: useModel(src).object |
Each part is one draw call for all copies: a two-part seat is two draw calls, however many seats.
The copies: items#
items is an array. Each item describes one copy:
| Field | Type | Default |
|---|---|---|
position |
[x, y, z] |
[0, 0, 0] |
rotation |
[x, y, z] radians |
[0, 0, 0] |
scale |
number or [x, y, z] |
1 |
color |
colour: tints this copy (multiplies the material's colour) | none |
visible |
boolean | true |
Your own fields may ride along (an id, a state): they come back in onPress. Changing items updates the copies; it is fine for selections and state changes, not for per-frame animation (see below).
Materials#
material and color work as on shapes. With object or shape children, the copies keep their own materials unless you pass material or color. When copies have their own colours, the base material is white by default so the colours show true.
Taps: which copy#
import { Box, CameraRig, type InstanceItem, Instances, Plane, Scene } from "@apricot/react";
import { useMemo, useState } from "react";
interface Seat extends InstanceItem {
id: string;
taken: boolean;
}
const SEATS: Seat[] = Array.from({ length: 10 * 16 }, (_, i) => {
const row = Math.floor(i / 16);
const col = i % 16;
return {
id: `${String.fromCharCode(65 + row)}${col + 1}`,
taken: (i * 7) % 5 === 0,
position: [(col - 7.5) * 1.1, row * 0.38, row * 1.2],
};
});
export function Cinema() {
const [picked, setPicked] = useState<ReadonlySet<string>>(new Set());
const items = useMemo(
() => SEATS.map((s) => ({ ...s, color: s.taken ? "#5b6068" : picked.has(s.id) ? "#22c55e" : "#c2410c" })),
[picked],
);
const toggle = (seat: Seat) =>
setPicked((current) => {
const next = new Set(current);
if (next.has(seat.id)) next.delete(seat.id);
else next.add(seat.id);
return next;
});
return (
<Scene style={{ flex: 1 }} background="#14161b" shadows>
<Plane size={60} rotation={[-Math.PI / 2, 0, 0]} material="matte" color="#24272e" />
<Instances items={items} material="plastic" onPress={({ item }) => !item.taken && toggle(item)}>
<Box size={[0.9, 0.25, 0.85]} position={[0, 0.45, 0]} />
<Box size={[0.9, 0.9, 0.18]} position={[0, 0.85, 0.42]} />
</Instances>
<CameraRig subject={null} view={{ azimuth: 0, elevation: 32, distance: 30, target: [0, 2, 6] }} orbit={{ turn: 55 }} />
</Scene>
);
}onPress receives { index, item, hit }: the copy's index in items, the item itself (with your fields) and the PressHit.
Animating copies every frame#
For motion, do not rebuild items every frame. Use the ref, an InstancesHandle, and set(index, item) in useFrame: it changes one copy with no React render.
import { Instances, type InstancesHandle, Scene, Sphere, useFrame } from "@apricot/react";
import { seededRandom } from "@apricot/core";
import { useMemo, useRef } from "react";
const COUNT = 300;
function Flakes() {
const random = useMemo(() => seededRandom(3), []);
const flakes = useMemo(
() => Array.from({ length: COUNT }, () => ({ x: random() * 10 - 5, y: random() * 8 - 4, z: random() * -6, speed: 0.4 + random() * 0.6 })),
[random],
);
const items = useMemo(() => flakes.map((f) => ({ position: [f.x, f.y, f.z] as const, scale: 0.05 })), [flakes]);
const copies = useRef<InstancesHandle>(null);
useFrame(({ delta }) => {
const handle = copies.current;
if (!handle) return true;
flakes.forEach((f, i) => {
f.y -= f.speed * delta;
if (f.y < -4) f.y += 8;
handle.set(i, { position: [f.x, f.y, f.z], scale: 0.05 });
});
return true;
});
return (
<Instances ref={copies} items={items} color="#ffffff">
<Sphere radius={1} segments={8} />
</Instances>
);
}
export function Snow() {
return (
<Scene style={{ flex: 1 }} look="flat" background="#1b2333">
<Flakes />
</Scene>
);
}handle.group is the group holding the copies, if you need to move them all.
Shadows#
With <Scene shadows>, instanced copies cast and receive like any mesh, and all copies are drawn in the shadow pass in one call too. That is why repeated casters (pillars, trees, posts) should always be instanced. Override with castShadow / receiveShadow.