Instancer: Treating Babylon Lite Instances Like Real Application Objects

npm install @litools/instancer

Rendering hundreds or thousands of repeated 3D objects is one of the places where thin instances shine. A forest of trees, a field of crates, a fleet of ships, or a crowd of animated creatures can all share mesh data and still render efficiently.

The GPU-friendly representation is not always application-friendly, though. Thin instances are stored in buffer slots. Those slots are great for rendering, but they are a fragile thing to put in application state: removing or hiding an instance can move another instance into its slot, and a rebuilt hierarchy can recreate the underlying buffers entirely.

@litools/instancer is a small layer for Babylon Lite that closes that gap. It lets an application work with stable instance IDs while it manages the changing render slots underneath.

The important distinction: IDs are stable, slots are not

The central idea is simple:

Keep InstanceId values in your application. Let Instancer handle slot indices.

Creating an object returns a numeric ID:

const id = boxes.create({ position: [0, 0, 0], scale: 1 }, { team: "blue" });

That ID remains the identity of the object even if the renderer reorganizes its buffers. You can move it, hide it, attach metadata, or remove it without having to update every reference held by UI, selection, networking, or game logic.

When low-level code really needs the current buffer slot, ask for it at that moment:

const slot = boxes.getSlot(id);

Start with one mesh

For the most common case — drawing one mesh many times — use createInstanceSet.

import { createInstanceSet } from "@litools/instancer";

const boxes = createInstanceSet<{ selected: boolean }>(boxMesh, {
  capacity: 500,
  colors: true,
  visibleStrategy: "active-count",
});

const id = boxes.create({ position: [2, 0, 1], scale: 1 }, { selected: false });

boxes.setPosition(id, [4, 0, 1]);
boxes.setColor(id, [0.2, 0.7, 1, 1]);
boxes.updateMetadata(id, (metadata) => (metadata ? { ...metadata, selected: true } : metadata));

The code reads as if id referred to an ordinary object, while the implementation still uses compact thin-instance data. Instancer also provides matrix-level methods when an application already has a Babylon Lite Mat4, plus convenience helpers for positions, translations, and scale.

Choose a visibility strategy deliberately

Visibility is one reason slots cannot be treated as permanent IDs. Instancer offers two useful strategies:

  • "active-count" keeps visible instances packed at the front of the buffer. It is efficient for rendering, but show, hide, and remove operations may move slots.
  • "scale-zero" keeps slot positions more stable by writing zero-scale transforms for hidden objects. This is useful when another slot-indexed data set must stay aligned with the instances.

For normal rigid repeated meshes, "active-count" is a strong default. For a system with companion slot-based data—such as animation playback parameters—"scale-zero" is often the safer choice.

Batch work without losing the object model

Real scenes rarely update one object at a time. Instancer exposes bulk helpers such as createMany, setTransforms, setMatrices, setVisibleMany, and removeMany, as well as a batch API that defers flushing until the work is complete.

boxes.batch((writer) => {
  for (const id of movingIds) {
    writer.setTransform(id, {
      position: nextPositionFor(id),
      scale: 1,
    });
  }
});

The benefit is not just fewer uploads. It keeps the application working in IDs and transforms instead of leaking buffer bookkeeping into every update loop.

For UI state, remote messages, or delayed events, the non-throwing try* helpers are particularly useful:

boxes.trySetVisible(idFromAnOldSelection, false);
boxes.trySetPosition(idFromNetwork, [0, 1, 0]);

They make stale IDs a normal, manageable condition rather than an exception path scattered through the app.

Loaded models can be one logical object

Many assets are not a single mesh. A loaded GLB might contain multiple child meshes, nested transforms, and materials, but an application still wants to select, move, and remove it as one thing.

createHierarchyInstanceSet applies the same stable-ID model to a scene-node hierarchy:

import { createHierarchyInstanceSet } from "@litools/instancer";

const boomboxes = createHierarchyInstanceSet(glbRoot, {
  capacity: 100,
  grow: "rebuild",
});

const id = boomboxes.create({ position: [0, 0, 0], scale: 1 });
boomboxes.translate(id, [3, 0, 0]);

This is valuable because it keeps the distinction between source/prototype meshes and logical instances out of the rest of the application.

Picking resolves back to a stable ID

Picking has the same identity problem. Babylon Lite can report a picked mesh and thin-instance slot, but an application usually wants to know which logical object was selected.

For rigid thin instances, register the set with a picking registry:

import { createPickingRegistry } from "@litools/instancer";

const registry = createPickingRegistry();
registry.register(boxMesh, boxes);

const picked = registry.fromPick(scenePick);

picked can then be treated as an application-level result rather than a renderer-specific slot lookup. For hierarchy assets, use the pick as a filter and resolve the appropriate logical instance. For VAT, skinned, or otherwise deformed visuals, Instancer also provides screen-space logical picking based on projected instance centers.

Animated crowds with VAT

createVatInstanceSet is designed for repeated skinned meshes whose animation has been baked into vertex animation textures. The set exposes the familiar transforms, visibility, metadata, iteration, and batching methods directly, while adding animation controls such as per-instance clips, phase offsets, and playback rate.

import { createVatInstanceSet } from "@litools/instancer";

const sharks = createVatInstanceSet(engine, skinnedMesh, animationGroups, {
  capacity: 48,
});

const id = sharks.create({
  transform: { position: [0, 0, 0], scale: 1 },
  clip: "Swim",
  offset: 0.4,
});

sharks.setPhaseOffset(id, 1.2);
sharks.update(deltaSeconds);

VAT sets default to "scale-zero" visibility because animation data is slot-based and animated vertices may extend beyond their rest bounds.

A useful boundary for large scenes

Instancer is not trying to replace Babylon Lite’s instance rendering. It keeps the fast path intact and adds an application-facing layer where it matters: identity, metadata, picking, visibility, pooling, and coordinated updates.

Use it when repeated meshes need to behave like durable objects in the rest of your system. The result is less slot bookkeeping, fewer invalid references after removals or rebuilds, and code that describes scene behavior in the same terms as the product itself.

Install it with:

npm install @litools/instancer

For API details and working examples, see the repo. Feedback and PRs are welcome!

Lite Playground examples

Examples demo (dont miss Shark Clip Mixer!)

10 Likes

I need to find time to dive in head-first into this repo :slight_smile:

1 Like

I had something similar implemented but chose a slighly different approach and much less support beyond matrix array. Yours sounds way better! Do you manage other thin instance buffers as well (like color)? If you don’t have already, would an Observable for changing an instance’s index (not id) be useful? In case a user has external parallel arrays, though external arrays might be better indexed by id rather than index.

1 Like

There are Recolor buttons in some examples.

Here - Primitive Mixed Playground - one may recolor the chosen instance or recolor all instances.

But I believe the strongest part is the VAT animations support. It may save a lot of time when one works with animated models.

1 Like

I believe you would like the Explorer Instancer Adapter :slight_smile:

2 Likes

This is really intriguing and impressive!

I wonder if this would work on my use case where I have a lot of enemies in my game. They are rather simple, but have a TransformNode hierarchy of like this:

Root TransformNode 
  => Animation TransformNode (this is animated with animation group)
    => Body Mesh
    => Left Hand TransformNode (this is animated with animation group)
      => Left Hand Mesh
      => Left Hand Weapon (Optional, changing)
    => Right Hand TransformNode (this is animated with animation group)
      => Right Hand Mesh
      => Right Hand Weapon (Optional, changing)

I feel like this hierarchy might be a bit complex for thin instancing?

There are dfferent ways to solve a weapon changing in this case.

Could you provide a link to the model with several weapons?

Would be interesting to experiment.

Need a better sword :slight_smile:

1 Like

I think I will make a Lite playground specifically for my game stuff at some point, but moving to Lite is not really that viable for me yet, since there are so many Bjs features/workflows missing!

But generally, currently, ALL the weapon meshes are currently inserted to the hand transform node, and then I just use setEnabled to visually get the correct weapon in use (might be sword, might be gun).

This however seems really intriguing to me, since I recently made a POC for Babylon.js (using AI) of replicating large masses of enemies with thin instances and it semi worked, but I didn’t really get into the black magic of that :smiley:

The solution is to bake socket transforms alongside the VAT animation.

For every animation clip:

  1. Evaluate every VAT frame.
  2. Update world matrices.
  3. Read selected socket transforms (RightHand, LeftHand, Head, etc.).
  4. Convert each socket to character-local space:
 SocketLocal = inverse(CharacterRootWorld) × SocketWorld
  1. Store one matrix per frame.

Each enemy stores:

  • world transform
  • clip
  • phase offset
  • optional FPS override

Weapons are rendered using separate instance sets.

Enemy VAT Set
   ↓
Sword Instance Set
Shield Instance Set
Bow Instance Set


weaponWorld =
    characterWorld
    × socketMatrix
    × gripOffset

Only socket transforms are sampled.

The full skeleton is never evaluated at runtime. Even hundreds of visible enemies remain practical. Later, socket matrices can be stored in textures or storage buffers and sampled directly by weapon shaders.

@labris this is epic!

1 Like

@labris Will this work with (LH) GLTF models?

If you have one I can try :slight_smile:

1 Like

Thanks for looking into this :heart_eyes:

Does SambaGirl do the trick? https://assets.babylonjs.com/meshes/HVGirl.glb (playground: https://playground.babylonjs.com/#Z6SWJU#1144). She is a bit more challenging due to the extra transforms on the “Idle” node.

Need to finalize the source code before publishing.

3 Likes

The by-side product, unarmed :slight_smile:

3000 Godot animated characters in 3 formations. Just several draw calls.

2 Likes

Samba Girl is waiting for your sword :slight_smile:

Does the processing take a while? If so, am I not allowed to tab away? Otherwise, no model will show up in these ones:

  • examples/samba-girl-vat-sword-sync/
  • examples/glb-vat-socket-configurator/

I do see the UI, but just black background.

The loading or switching may take several seconds in the beginning.

See no issues in Chrome.

1 Like

Ahh! Now it works :wink:


Now looking at the Explorer, do I see this right: you keep the gltf root node, bake the VATs etc. and everything jsut works?