Spawning Instanced Meshes
Instanced meshes let you render tens, hundreds, or thousands of identical objects in a single GPU draw call. Two entity kinds work together:
"instancedMesh"— the group entity that owns the geometry, material, and the list of instance slot IDs."instanceSlot"— a lightweight child entity whoseTransformComponentdrives one instance's position/rotation/scale.
Creating a group at runtime
Use requestCreate with kind "instancedMesh" from any Behaviour:
import { Behaviour } from "@relu-interactives/spatial-ecs";
export default class SpawnCubeGrid extends Behaviour {
protected init() {
const objects = this.world.getObjectManagementSystem();
objects?.requestCreate(
"instancedMesh",
{
name: "Cube Grid",
transform: { position: { x: 0, y: 0, z: 0 } },
options: {
meshKind: "cube", // "cube" | "sphere" | "plane" | "cylinder" | "capsule" | "model"
meshParameters: { width: 1, height: 1, depth: 1 },
castShadow: true,
receiveShadow: true,
},
},
(groupId) => {
console.log("Group entity created:", groupId);
},
);
}
}When requestCreate("instancedMesh") runs, it automatically creates one default slot entity (Instance 1) at the origin. You can add more slots afterwards (see Adding instance slots dynamically).
requestCreate payload for "instancedMesh"
| Field | Type | Default | Description |
|---|---|---|---|
name | string | "Instanced Mesh" | Display name of the group entity. |
transform | TransformData | identity | Position / rotation (radians) / scale of the group anchor. |
parentId | EntityId | null | null | Optional parent entity. |
options.meshKind | InstancedMeshKind | "cube" | Geometry for all instances. |
options.meshParameters | MeshGeometryParameters | geometry defaults | Dimensions. See table below. |
options.castShadow | boolean | false | All instances cast shadows. |
options.receiveShadow | boolean | false | All instances receive shadows. |
options.modelPath | string | "" | GLTF/GLB asset URL — only used when meshKind === "model". |
meshParameters by kind
meshKind | Parameters | Defaults |
|---|---|---|
"cube" | width, height, depth | 1 × 1 × 1 |
"sphere" | radius | 0.5 |
"plane" | width, height | 1 × 1 |
"cylinder" | radius, height | 0.5 × 1 |
"capsule" | radius, height | 0.5 × 1 |
"model" | (none — geometry comes from GLTF) | — |
Using a GLTF/GLB model
Set meshKind to "model" and supply modelPath:
objects?.requestCreate("instancedMesh", {
name: "Tree Group",
options: {
meshKind: "model",
modelPath: "localasset://assets/tree.glb",
castShadow: true,
receiveShadow: true,
},
});The asset loads asynchronously. InstancedMeshSystem rebuilds the instanced mesh once the model is available.
Adding instance slots dynamically
Each slot is a separate entity. To add more instances after the group is created, use requestCreate("instanceSlot") and push the new ID into the group component:
import { Behaviour, InstancedMeshComponent } from "@relu-interactives/spatial-ecs";
export default class AddInstances extends Behaviour {
protected init() {
const instanced = this.getComponent(InstancedMeshComponent);
if (!instanced) return;
const groupId = this.entityId;
const objects = this.world.getObjectManagementSystem();
for (let i = 0; i < 9; i++) {
const x = (i % 3) * 2 - 2;
const z = Math.floor(i / 3) * 2 - 2;
objects?.requestCreate(
"instanceSlot",
{
name: `Instance ${instanced.instanceEntityIds.length + 1}`,
transform: { position: { x, y: 0, z } },
options: {
groupEntityId: groupId,
slotIndex: instanced.instanceEntityIds.length,
},
},
(slotId) => {
instanced.instanceEntityIds.push(slotId);
},
);
}
}
}requestCreate payload for "instanceSlot"
| Field | Type | Default | Description |
|---|---|---|---|
name | string | "Instance N" | Display name of the slot entity. |
transform | TransformData | identity | Position / rotation (radians) / scale for this instance. |
options.groupEntityId | number | -1 | Entity ID of the owning group. |
options.slotIndex | number | 0 | Index in the group's instanceEntityIds array. |
Keep slotIndex and instanceEntityIds in sync
slotIndex must match the position of the slot's entity ID in the group's instanceEntityIds array. Always push the new slot ID immediately in the onCreated callback.
Spawning a complete grid in one script
import { Behaviour } from "@relu-interactives/spatial-ecs";
import { InstancedMeshComponent } from "@relu-interactives/spatial-ecs";
export default class CubeGrid extends Behaviour {
private groupId: number | null = null;
protected init() {
const objects = this.world.getObjectManagementSystem();
objects?.requestCreate(
"instancedMesh",
{
name: "Grid",
options: { meshKind: "cube", castShadow: true },
},
(groupId) => {
this.groupId = groupId;
const instanced = this.world.getComponent(groupId, InstancedMeshComponent);
if (!instanced) return;
const COLS = 5;
const ROWS = 5;
const SPACING = 2;
for (let row = 0; row < ROWS; row++) {
for (let col = 0; col < COLS; col++) {
if (row === 0 && col === 0) continue; // first slot created automatically
const x = col * SPACING - ((COLS - 1) * SPACING) / 2;
const z = row * SPACING - ((ROWS - 1) * SPACING) / 2;
const nextIndex = instanced.instanceEntityIds.length;
objects.requestCreate(
"instanceSlot",
{
name: `Instance ${nextIndex + 1}`,
transform: { position: { x, y: 0, z } },
options: { groupEntityId: groupId, slotIndex: nextIndex },
},
(slotId) => {
instanced.instanceEntityIds.push(slotId);
},
);
}
}
},
);
}
}Accessing and mutating instances at runtime
Move a single instance
Mutate the TransformComponent of the slot entity. InstancedMeshSystem picks up the change on the next frame:
import {
Behaviour,
InstancedMeshComponent,
TransformComponent,
} from "@relu-interactives/spatial-ecs";
export default class OrbitInstances extends Behaviour {
private elapsed = 0;
protected onUpdate() {
const instanced = this.getComponent(InstancedMeshComponent);
if (!instanced) return;
this.elapsed += this.deltaTime;
for (let i = 0; i < instanced.instanceEntityIds.length; i++) {
const slotId = instanced.instanceEntityIds[i];
const transform = this.world.getComponentById(slotId, TransformComponent);
if (!transform) continue;
const angle = this.elapsed + (i / instanced.instanceEntityIds.length) * Math.PI * 2;
const radius = 3;
transform.position.x = Math.cos(angle) * radius;
transform.position.z = Math.sin(angle) * radius;
}
}
}Change geometry or material at runtime
Update the group component's fields. InstancedMeshSystem detects the change and rebuilds:
import { Behaviour, InstancedMeshComponent } from "@relu-interactives/spatial-ecs";
export default class ExpandSpheres extends Behaviour {
protected onUpdate() {
const instanced = this.getComponent(InstancedMeshComponent);
if (!instanced || instanced.meshKind !== "sphere") return;
// Grow radius over time
const params = instanced.meshParameters as { radius: number };
params.radius = Math.min(2, params.radius + this.deltaTime * 0.1);
}
}Remove an instance at runtime
Remove the slot entity from the world and splice its ID out of the group:
import { Behaviour, InstancedMeshComponent } from "@relu-interactives/spatial-ecs";
export default class RemoveLastInstance extends Behaviour {
protected init() {
const instanced = this.getComponent(InstancedMeshComponent);
if (!instanced || instanced.instanceEntityIds.length === 0) return;
const slotId = instanced.instanceEntityIds.pop()!;
this.world.destroyEntity(slotId);
// InstancedMeshSystem rebuilds the draw call on the next frame
}
}Components attached to the group entity
Transform, Object3DRef, Name, EntityType, ParentId, Selectable, InstancedMesh.
Components attached to each slot entity
Transform, Name, EntityType, ParentId, Selectable, InstanceSlot.

