Skip to content

Instanced Mesh

Groups one or more instance-slot entities into a single GPU-instanced draw call. Each instance's transform comes from its corresponding InstanceSlotComponent entity; InstancedMeshSystem reads those transforms every frame and writes the instance matrices into the underlying THREE.InstancedMesh.

Use instanced meshes when you need tens, hundreds, or thousands of identical objects (trees, tiles, particles, projectiles, obstacles) without paying individual draw-call cost per object.

Managed by: InstancedMeshSystem

Editor inspector

Instanced Mesh inspector

Properties

PropertyTypeDescription
meshKindInstancedMeshKindGeometry used for every instance. One of "cube", "sphere", "plane", "cylinder", "capsule", "model".
modelPathstringSource URL or localasset:// path of the GLTF/GLB asset. Only read when meshKind === "model".
meshParametersMeshGeometryParametersGeometry dimensions (width/height/depth for box, radius/height for cylinder/capsule, radius for sphere, width/height for plane). Ignored when meshKind === "model".
castShadowbooleanWhether all instances cast shadows.
receiveShadowbooleanWhether all instances receive shadows.
instanceEntityIdsnumber[]Ordered list of entity IDs for the InstanceSlotComponent entities owned by this group. Each slot carries its own TransformComponent.
instancedMeshTHREE.Object3D | nullLive runtime object managed by InstancedMeshSystem. null until the system builds the mesh. Not serialized — do not persist or clone this field.

API reference

Scripting examples

Component access patterns

Use this.getComponent(X) to access a component on the entity this script is attached to. Use this.world.getEntityByName to find another entity by name, then this.world.getComponent to read its component:

typescript
// Component on the entity this script is on
const instanced = this.getComponent(InstancedMeshComponent);

// Component on another named entity
const other = this.world.getEntityByName("Tree Group");
if (other) {
  const instanced = this.world.getComponent(other.entityId, InstancedMeshComponent);
}

Read instance count

typescript
import { Behaviour, InstancedMeshComponent } from "@relu-interactives/spatial-ecs";

export default class InstanceCounter extends Behaviour {
  protected init() {
    const instanced = this.getComponent(InstancedMeshComponent);
    if (instanced) {
      console.log(`This group has ${instanced.instanceEntityIds.length} instances`);
    }
  }
}

Toggle shadows on all instances at runtime

typescript
import { Behaviour, InstancedMeshComponent } from "@relu-interactives/spatial-ecs";
import type { BooleanInput } from "@relu-interactives/spatial-ecs";

export default class ToggleShadows extends Behaviour {
  data = {
    castShadow: true as BooleanInput,
    receiveShadow: true as BooleanInput,
  };

  protected init() {
    const instanced = this.getComponent(InstancedMeshComponent);
    if (!instanced) return;
    instanced.castShadow = this.data.castShadow;
    instanced.receiveShadow = this.data.receiveShadow;
  }
}

Animate every instance's Y position

Move each slot entity up and down using a sine wave. InstancedMeshSystem reads the TransformComponent of each slot every frame, so mutating the transform is all that is needed.

typescript
import {
  Behaviour,
  InstancedMeshComponent,
  TransformComponent,
} from "@relu-interactives/spatial-ecs";

export default class WaveInstances 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) {
        transform.position.y = Math.sin(this.elapsed + i * 0.5) * 2;
      }
    }
  }
}

Accept a group reference via ComponentInput

Use a ComponentInput field so the inspector can wire any InstancedMeshComponent entity to this script — useful when the script lives on a different entity than the group.

typescript
import {
  Behaviour,
  InstancedMeshComponent,
  TransformComponent,
} from "@relu-interactives/spatial-ecs";
import type { ComponentInput } from "@relu-interactives/spatial-ecs";

export default class ScatterInstances extends Behaviour {
  data = {
    group: {
      type: "component",
      componentKind: "InstancedMesh",
      value: null,
    } as unknown as ComponentInput<InstancedMeshComponent>,
  };

  protected init() {
    const instanced = this.data.group.value as InstancedMeshComponent | null;
    if (!instanced) return;

    // Scatter each instance randomly within a 10-unit area
    for (const slotId of instanced.instanceEntityIds) {
      const transform = this.world.getComponentById(slotId, TransformComponent);
      if (transform) {
        transform.position.x = (Math.random() - 0.5) * 10;
        transform.position.z = (Math.random() - 0.5) * 10;
      }
    }
  }
}

Spawn a new instance slot at runtime

Add a new slot entity to an existing group from a script. The new slot's transform is registered with the group and InstancedMeshSystem picks it up on the next frame.

typescript
import {
  Behaviour,
  InstancedMeshComponent,
} from "@relu-interactives/spatial-ecs";

export default class DynamicSpawner extends Behaviour {
  protected init() {
    const instanced = this.getComponent(InstancedMeshComponent);
    if (!instanced) return;

    const groupId = this.entityId;
    const objects = this.world.getObjectManagementSystem();
    objects?.requestCreate(
      "instanceSlot",
      {
        name: `Instance ${instanced.instanceEntityIds.length + 1}`,
        transform: { position: { x: 2, y: 0, z: 0 } },
        options: {
          groupEntityId: groupId,
          slotIndex: instanced.instanceEntityIds.length,
        },
      },
      (slotId) => {
        instanced.instanceEntityIds.push(slotId);
      },
    );
  }
}

See the Spawning Instanced Meshes guide for the full requestCreate API reference.