@zakkster/lite-r3f-bridge
v1.1.0
Published
Zero-GC bridge for React Three Fiber. Declaratively bind bare-metal ECS buffers to WebGL InstancedMesh and Object3D transforms without React render overhead.
Maintainers
Readme
@zakkster/lite-r3f-bridge
🚀 What is lite-r3f-bridge?
@zakkster/lite-r3f-bridge is a tiny pair of React hooks that bridge a buffer-driven ECS (or any flat Float32Array simulation source) to React Three Fiber without React render overhead and without per-frame allocations.
It gives you:
- 🧬 Two hooks for two real cases — bulk
InstancedMeshand single heroMesh - 🔋 Zero-GC hot path — no allocation, no closure, no method dispatch in the per-frame loop
- 🔗 Direct memory binding — your ECS writes straight into Three.js's instanced attribute backing array
- 📡 Partial uploads — only the dirty subset of the buffer is shipped to the GPU each frame
- 🎨 Per-instance color — bind a linear-RGB buffer to
mesh.instanceColorwith the same active-range discipline (v1.1.0) - 🧩 Arbitrary instanced attributes —
attributes: { aHealth, aTeam }→ namedInstancedBufferAttributes, each with its own dirty tracking. Custom shaders, still zero-alloc (v1.1.0) - 🫧 Bounding-sphere management — correct frustum culling for simulation-driven positions: fixed radius or auto-recompute from buffer extents at a low, power-of-two cadence (v1.1.0)
- 🔬 Feature-detected — works on Three r159+ (
addUpdateRange) and gracefully falls back to legacyupdateRangeon older versions - 🧠 StrictMode safe — bind/unbind and attribute add/delete on the synthetic React 18 double-mount
- 🏷️ Optional quaternion + scale buffers — translation is required, rotation/scale are detected
- 🎚️
priorityargument — order yourself relative to your ECS step in R3F's frame loop - 0️⃣ Zero runtime dependencies (peer of
three,@react-three/fiber,react) - 🪶 ~1.5 KB min+gzip — the whole bridge, color/attributes/bounds included
Part of the @zakkster/lite-* ecosystem — micro-libraries built for deterministic, cache-friendly runtime code.
🚀 Install
npm i @zakkster/lite-r3f-bridge
# Peer deps you must already have:
npm i three @react-three/fiber react🕹️ Quick Start
Scenario A: 100,000-particle InstancedMesh
import { useLiteInstancedAnimator } from '@zakkster/lite-r3f-bridge';
function Particles({ ecsSystem, count }) {
// The ref is wired to your <instancedMesh>. The ECS writes matrices
// directly into mesh.instanceMatrix.array — no copies.
const meshRef = useLiteInstancedAnimator(count, ecsSystem);
return (
<instancedMesh ref={meshRef} args={[null, null, count]}>
<sphereGeometry args={[0.05, 6, 6]} />
<meshBasicMaterial />
</instancedMesh>
);
}That's the full integration. Your ECS continues to do its thing in its own loop or system; the matrix buffer it writes is already the Three.js attribute, so there's nothing to copy. The hook flips needsUpdate and tells the GPU which slice changed.
Scenario B: Single declarative hero Mesh
import { useLiteTransform } from '@zakkster/lite-r3f-bridge';
function Hero({ entityId, ecsSystem }) {
const meshRef = useLiteTransform(entityId, ecsSystem);
return (
<mesh ref={meshRef}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="hotpink" />
</mesh>
);
}Per frame, the hook reads one byte from the dirty buffer. If the entity hasn't changed, it returns immediately — no Three.js math runs. If it has changed, it writes position (and optionally quaternion / scale) directly from your typed arrays.
🎨 Attribute coverage (v1.1.0)
The bulk hook covers more than the transform matrix now. Everything below rides the same zero-allocation, active-range upload path and is passed through an options object on the third argument. None of it requires new methods on your ECS — you hand the hook the Float32Arrays directly, so any source works (ECS, physics readback, GPU compute).
const meshRef = useLiteInstancedAnimator(count, ecsSystem, {
priority: 1,
// Per-instance color → mesh.instanceColor (linear RGB, 3 floats/instance)
color: colorBuffer,
// Custom instanced attributes for your shader material
attributes: {
aHealth: healthBuffer, // bare buffer → itemSize inferred as length / count
aVelocity: { array: velBuffer, itemSize: 3 }, // or be explicit
},
// Correct frustum culling for simulation-driven positions
bounds: 'auto',
});The legacy priority number still works — useLiteInstancedAnimator(count, ecs, 1) is unchanged.
Per-instance color
Pass a linear-RGB Float32Array (3 floats per instance) as color. The hook wires it to mesh.instanceColor as a DynamicDrawUsage attribute and, each frame, uploads only activeCount × 3 floats.
useLiteInstancedAnimator(count, ecs, { color: colorBuffer });- Colors are linear, not sRGB.
[1, 0, 0]is pure red in linear space. instanceColormultiplies the material's base color, so set your material to white (meshBasicMaterial/meshStandardMaterialdefault) for pure per-instance color. NovertexColorsflag needed — Three enables the color path automatically wheninstanceColoris present.- A set-once palette that never changes?
color: { array: palette, dynamic: false }uploads once on mount and skips the per-frame flag entirely.
Arbitrary instanced attributes
Map GLSL attribute names to buffers. Each becomes a named InstancedBufferAttribute on the geometry with its own independent dirty tracking, so a custom ShaderMaterial / onBeforeCompile becomes first-class without leaving the zero-alloc path.
useLiteInstancedAnimator(count, ecs, {
attributes: {
aHealth: healthBuffer, // itemSize inferred: length / count
aTeam: teamBuffer,
aVelocity: { array: velBuffer, itemSize: 3 }, // explicit itemSize
aPacked: { array: packed, itemSize: 4, normalized: true, dynamic: false },
},
});// in your vertex shader
attribute float aHealth;
attribute vec3 aVelocity;itemSizeis inferred fromarray.length / countfor the bare-buffer shorthand. Pass the descriptor form when the buffer is over-allocated or you wantnormalizedinteger packing.dynamic: falseuploads once on mount; otherwise the active range re-uploads each frame like color and the matrix.- Attributes are removed on unmount (
geometry.deleteAttribute), so StrictMode's double-mount is clean.
Bounding-sphere management
An InstancedMesh whose positions come from a simulation will frustum-cull incorrectly — Three's cached bounding sphere reflects the initial matrices, not where instances have moved. Three options, cheapest first:
| bounds value | Behaviour | Cost |
|--------------------------------------------------|-----------------------------------------------------------------------------------------------|--------------------------|
| omitted / false | Untouched (v1.0.0 behaviour). You manage culling — e.g. frustumCulled={false}. | none |
| number or { mode: 'fixed', center, radius } | Static sphere. Ideal when your sim volume is known and bounded. | set once |
| 'auto' or { mode: 'auto', hz } | Recomputes the sphere from live instance-matrix extents at a low, power-of-two cadence. | one O(activeCount) scan every ~N frames |
// Known play volume — cheapest, never recomputed:
useLiteInstancedAnimator(count, ecs, { bounds: 120 });
// Unbounded / expanding swarm — auto-fit, default ~4 Hz:
useLiteInstancedAnimator(count, ecs, { bounds: 'auto' });
// Slower recompute for a large, slow field:
useLiteInstancedAnimator(count, ecs, { bounds: { mode: 'auto', hz: 1 } });The auto scan is a single pass over the active translation components (matrix elements 12/13/14), producing an AABB→sphere padded by the geometry's own radius so instances don't pop at the frustum edge. It fires on a frame-counter bitmask (hz → nearest power-of-two frame interval at 60 fps), so the per-frame cost is one & on quiet frames.
📊 Comparison
For 10k+ entities driven by a non-keyframed source (physics, ECS, simulation):
| Approach | Per-frame allocations | StrictMode safe | LOC |
|-----------------------------------|----------------------------------|-----------------|-------------|
| Plain R3F useFrame + manual | many (Vector3, Matrix4 per inst) | needs care | ~50 |
| drei <Instances> | low (designed for declarative) | yes | ~30 |
| lite-r3f-bridge | 0 | yes | ~15 |
drei's <Instances> is excellent for inline-declared instance data. This library targets the case where the truth lives in an external typed-array — physics step, ECS, GPU readback — and you don't want to copy it into a declarative tree.
⚙️ API
useLiteInstancedAnimator(count, ecsSystem, options?)
Returns a RefObject<InstancedMesh> that you assign to your <instancedMesh>. On mount, sets DynamicDrawUsage and binds the ECS to the mesh's instanceMatrix.array. On each frame, calls setUpdateRange(0, activeCount × 16) (feature-detected modern or legacy API) and flags needsUpdate — plus any color / attribute / bounds work you configured.
| Param | Type | Notes |
|--------------|-------------------------------|----------------------------------------------------------|
| count | number | Maximum instance count. Must match your ECS allocation. |
| ecsSystem | ECSSystem | See interface below. |
| options | number \| InstancedAnimatorOptions? | A legacy priority number, or the options object below. Default 0. |
InstancedAnimatorOptions
| Field | Type | Notes |
|--------------|-------------------------------|-----------------------------------------------------------------------------------------|
| priority | number? | R3F useFrame priority. Default 0. Set higher than your ECS step's priority. |
| color | Float32Array \| { array, dynamic? }? | Linear-RGB buffer (3 floats/instance) → mesh.instanceColor. |
| attributes | Record<string, Float32Array \| { array, itemSize?, normalized?, dynamic? }>? | Named custom instanced attributes. |
| bounds | false \| number \| 'auto' \| { mode?, center?, radius?, hz? }? | Frustum-culling bounding-sphere strategy. |
useLiteTransform(entityId, ecsSystem, priority?)
Returns a RefObject<Mesh>. On mount, captures references to getTranslationBuffer(), getDirtyBuffer(), and (if present) getQuaternionBuffer() / getScaleBuffer(). Per frame, dirty-checks one byte and writes only when set.
| Param | Type | Notes |
|--------------|-------------|------------------------------------------------------------------|
| entityId | number | Index into the ECS buffers. |
| ecsSystem | ECSSystem | See interface below. |
| priority | number? | R3F useFrame priority. Default 0. |
The ECSSystem interface
The library is structurally typed — anything matching this shape works.
interface ECSSystem {
// Required for InstancedMesh
bindOutputBuffer(buffer: Float32Array, count: number): void;
unbindOutputBuffer(): void;
getActiveCount(): number;
// Required for single Mesh
getTranslationBuffer(): Float32Array;
getDirtyBuffer(): Uint8Array;
// Optional for single Mesh
getQuaternionBuffer?(): Float32Array;
getScaleBuffer?(): Float32Array;
}You don't need to implement all of these. Use the bulk hook → implement the top three. Use the hero hook → implement the next two, plus optionally rotation/scale.
🔬 Why two hooks?
Different problems demand different bridges:
Bulk path (
InstancedMesh): when you have thousands or hundreds of thousands of entities, you don't want a React component per entity. You want one<instancedMesh>and you want its backing GPU buffer to be your simulation's output. That's whatuseLiteInstancedAnimatordoes — single binding, single GPU upload per frame, scoped to the dirty range.Hero path (single
Mesh): when you have a small number of high-importance objects (a player, a camera target, a featured prop) that you want to compose normally in JSX with materials, shadows, drei helpers, post-processing.useLiteTransformlets each one read its position/rotation/scale from typed arrays without the React render cycle ever touching them.
You can mix both in one scene — e.g. useLiteInstancedAnimator for ambient particles, useLiteTransform for the player.
🧪 Performance contract
The hot path of useLiteInstancedAnimator:
useFrame:
deref mesh ref ; null check
deref plan ref ; null check (built once at mount)
call getActiveCount() ; cheap method
branch if 0
setUpdateRange(matrix) ; one method, no closure
flag needsUpdate
; -- only if configured --
setUpdateRange(color) ; activeCount × 3, its own range + flag
for each custom attr: ; indexed for-loop, no iterator
setUpdateRange(attr); activeCount × itemSize, own range + flag
if bounds === 'auto':
(frame & mask) === 0 ? one-pass extent scan : single bitwise ANDZero allocations. Zero closures. The whole per-frame plan (which attributes exist, their strides, the bounds sphere and mask) is resolved once at mount and stored in a ref; the loop only reads it. The GPU upload itself is browser-internal.
The hot path of useLiteTransform:
useFrame:
deref mesh ref
deref buffers ref
null checks
read dirty[entityId] ; single byte read
branch if 0
fromArray(translation) ; Three.js writes 3 floats into mesh.position
fromArray(quaternion) ; if present, 4 floats into mesh.quaternion
fromArray(scale) ; if present, 3 floats into mesh.scale
updateMatrix() ; Three.js composes local matrixZero allocations. No object literals constructed. No new Vector3/Quaternion/Matrix4 created. The dirty check is the cheapest possible: one indexed byte read against zero.
📦 TypeScript
Full declarations included in R3fBridge.d.ts. The ECSSystem interface is exported so you can implements-style type your own ECS to match.
📚 LLM-Friendly Documentation
See llms.txt for AI-optimized metadata, common-mistake flags, and integration patterns.
License
MIT
