Skip to content

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 whose TransformComponent drives one instance's position/rotation/scale.

Creating a group at runtime

Use requestCreate with kind "instancedMesh" from any Behaviour:

typescript
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"

FieldTypeDefaultDescription
namestring"Instanced Mesh"Display name of the group entity.
transformTransformDataidentityPosition / rotation (radians) / scale of the group anchor.
parentIdEntityId | nullnullOptional parent entity.
options.meshKindInstancedMeshKind"cube"Geometry for all instances.
options.meshParametersMeshGeometryParametersgeometry defaultsDimensions. See table below.
options.castShadowbooleanfalseAll instances cast shadows.
options.receiveShadowbooleanfalseAll instances receive shadows.
options.modelPathstring""GLTF/GLB asset URL — only used when meshKind === "model".

meshParameters by kind

meshKindParametersDefaults
"cube"width, height, depth1 × 1 × 1
"sphere"radius0.5
"plane"width, height1 × 1
"cylinder"radius, height0.5 × 1
"capsule"radius, height0.5 × 1
"model"(none — geometry comes from GLTF)

Using a GLTF/GLB model

Set meshKind to "model" and supply modelPath:

typescript
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:

typescript
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"

FieldTypeDefaultDescription
namestring"Instance N"Display name of the slot entity.
transformTransformDataidentityPosition / rotation (radians) / scale for this instance.
options.groupEntityIdnumber-1Entity ID of the owning group.
options.slotIndexnumber0Index 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

typescript
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:

typescript
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:

typescript
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:

typescript
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.