npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Readme

@zakkster/lite-r3f-bridge

npm version npm bundle size npm downloads npm total downloads TypeScript Peer deps License: MIT

🚀 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 InstancedMesh and single hero Mesh
  • 🔋 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.instanceColor with the same active-range discipline (v1.1.0)
  • 🧩 Arbitrary instanced attributesattributes: { aHealth, aTeam } → named InstancedBufferAttributes, 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 legacy updateRange on 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
  • 🎚️ priority argument — 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.
  • instanceColor multiplies the material's base color, so set your material to white (meshBasicMaterial / meshStandardMaterial default) for pure per-instance color. No vertexColors flag needed — Three enables the color path automatically when instanceColor is 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;
  • itemSize is inferred from array.length / count for the bare-buffer shorthand. Pass the descriptor form when the buffer is over-allocated or you want normalized integer packing.
  • dynamic: false uploads 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 what useLiteInstancedAnimator does — 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. useLiteTransform lets 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 AND

Zero 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 matrix

Zero 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