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

@nickpaustian/catalyst

v0.1.1

Published

A small modular engine for three.js experiences: a frame loop with update, render and resize signals, a node tree with cached world transforms, cameras and debug shapes in the core, and physics, input, Gaussian splats and prefabs as separate entry points

Readme

@nickpaustian/catalyst

A small modular engine for three.js experiences. The core is a scene, a renderer and a view camera, a frame loop that emits onUpdate, onRender and onPostRender, a node tree whose world transforms are cached and only recomputed when something moved, cameras with a real lens, curves, meshes and debug shapes. Everything else is a module on a separate entry point, so a project imports physics, input, networking, resources, Gaussian splats or the prefabs only when it uses them, and the rest never reaches its bundle.

import { Catalyst, Camera, Mesh } from "@nickpaustian/catalyst"
import { Physics, BoxCollider, RigidBody, SphereCollider } from "@nickpaustian/catalyst/physics"
import { Input } from "@nickpaustian/catalyst/input"

const engine = new Catalyst({ container: "#stage", modules: [new Physics({ gravity: 9.8, floor: 0 }), new Input()] })

const floor = engine.add(new BoxCollider({ width: 20, height: 1, depth: 20, layers: 1 }))
floor.transform.position.y = -0.5

const ball = engine.add(new RigidBody({ collider: new SphereCollider({ radius: 0.3, layers: 1, collidesWith: 1 }) }))
ball.addNode(new Mesh({ geometry: new THREE.SphereGeometry(0.3), material: new THREE.MeshStandardMaterial() }))
ball.transform.position.set(0, 4, 0)

const camera = engine.add(new Camera({ fov: 60 }))
camera.transform.position.set(0, 2, 6)
camera.lookAt(ball)
engine.camera.target = camera

engine.get(Input).bind("reset", "r")
engine.get(Input).on("reset", { start: () => ball.teleport(new THREE.Vector3(0, 4, 0)) })
engine.start()

The engine

new Catalyst(options) makes the scene, a renderer on a canvas in container (the body by default, sized to the container; pass renderer: false to run without drawing, as tests do, or an existing WebGLRenderer), and the view camera. start() runs the loop, stop() ends it, pause() and resume() hold it (a paused frame emits only onIdle, where input keeps running so a key can resume), step(dt) runs one frame by hand, dispose() tears everything down. targetFPS holds a rate; maxDelta caps a frame's seconds so a tab coming back from the background does not take one enormous step.

Each frame emits signals.onUpdate, then the view camera copies its target, then onRender (nodes place their three.js objects here at priority 50) and the scene is drawn, then onPostRender. Each carries { dt, elapsed, frame } in seconds. Listeners take a priority, higher first: input lands at 100, physics steps at -100, so game logic at 0 sees this frame's input and physics sees the logic's intent. onResize carries the new size; onStart, onStop, onPause and onResume the lifecycle.

engine.camera is the view: a perspective camera the scene is rendered with, which follows its target, a Camera node or any three.js camera. isDirty, positionDelta, rotationDelta and velocity say how it moved this frame, computed once per frame however often they are read. Setting target cuts; transitionTo(camera, { duration, easing }) blends position, rotation and field of view from wherever the view is to wherever the target is each frame, eased in and out by default (Easing has the usual curves), and resolves true when it arrives or false when another target cut it short. isTransitioning and cut().

engine.loader fetches files for scenes and splats; fetch by default, a Requester through the requester entry. log is where messages go (nowhere by default); unit is your scene's scale for grids. Catalyst._ is the most recent engine, for apps with one.

Tweens

engine.tween(target, to, { duration, easing, delay, repeat, yoyo, onUpdate }) moves values on any object over time on the engine's frames: numbers, and anything with clone and lerp (vectors, colours) or slerp (quaternions), each from where it is when the tween starts. It returns a Tween with a promise that resolves true when it finishes and false when cancel() stops it, complete() to jump to the end, and progress. node.tween({ position, rotation, scale }, options) is the same on a node's transform, cancelled when the node is disposed; engine.tweens.cancel(target) and cancelAll() clear others. Tweens stand still while the engine is paused.

Nodes

Node is a name, children kept by name (a repeated name is numbered: hand, hand2) and tags. addNode(node, key?), removeNode, getNode, find("player/camera"), getChildrenOfType, findAncestor, walk, getNodesByTag; node._.camera reads a child as a property. A node knows its engine once it is in the tree under engine.root (engine.add(node) puts it there), and onEnter(engine) and onExit(engine) are where a node subscribes to the engine's signals and lets go. listen(signal, fn, priority) subscribes for as long as the node is in the tree, so a removed node stops hearing the frame. dispose() takes a node and its children out for good; remove() just leaves the parent, to be added again elsewhere. rename(name) re-keys a node under its parent, insertNode(node, index) places a child among its siblings, indexOf and contains ask about the tree. A node marked internal was made by its parent's own code (a prefab's camera, a body's collider) and is left out of scene files and editors. active = false keeps a node's place in the tree but takes it and its subtree out of the engine as if removed, so it stops hearing the frame, its objects leave the scene and its colliders leave the world, until it is active again; isActive says whether it and every ancestor are.

Node3D adds a transform: position, rotation (a quaternion; setEuler for angles) and scale, local to the nearest spatial ancestor. Setting any component marks the local matrix dirty and invalidates the world matrices below; transform.worldMatrix, getWorldPosition, getWorldRotation and getWorldScale recompute only then. setWorldPosition, setWorldRotation and setWorldMatrix place a node in world space under whatever parent it has. Override onWorldTransformChanged to hear when the node or an ancestor moved. three.js objects go through attachObject: they are put in the scene while the node is in the tree and moved to the node's world transform before each render, only when it changed. visible = false stops the node and everything below it drawing while they carry on updating; isVisible includes the ancestors, and onVisibilityChanged is the hook for a node that renders through something other than attached objects. visualised = true draws a debug shape for the node (createVisualisation decides which).

  • Camera: a perspective camera whose lens is a focalLength in millimetres over a sensor size, so the field of view follows the aspect ratio as a real lens does; setFocalLength, blendFocalLength(target, seconds), lookAt(target) and getLookAtRotation. Make it the engine's with engine.camera.target = camera. syncRotation = false lets something drive the view directly (pointer lock) while the node follows it.
  • Mesh, Plane, Billboard (a plane that faces the camera).
  • Model: a glTF file (url, a .glb or a .gltf with everything embedded) fetched through the engine's loader when the node enters, placed at its transform, disposed when it leaves; set url to load another.
  • BezierCurve: a smooth curve through control points, in the node's space, answering world-space getPointAt(t), getTangentAt, getPointAtDistance, and named control points for parameterOf, isBetween and parameterBetween.
  • DebugShapes: wireframe lines, cubes, boxes, capsules, spheres, cones, polyhedra, arrows, axes and grids, as objects to attach or add.

Scene files

engine.create(json) builds a tree from { type, name, options, transform: { position, rotation, scale }, tags, properties, children }, looking each type up in engine.registry. The core nodes are registered, every module registers its own, and engine.register({ Door }) adds yours. engine.loadScene(url) fetches one through the loader and adds it under the root. engine.serialise(node) writes a tree back out the same way, keeping whatever the node was created from that the registry does not know about, so a file survives a round trip through an editor. (config is read as the old name of options.)

Editing

Node classes describe themselves to editors. static exposed: ExposedProperty[] lists properties with a key, type (number, boolean, string, tags, layers, colour), optional label, min, max, step, readonly, and where the value is saved (options, the default; properties; or false). Subclasses add to their base's list and override by key; exposedProperties(node) merges the chain, readExposed and writeExposed move values in and out. static icon = svgIcon("<path …/>") gives a class a glyph, inherited by subclasses; editorIcon(node) reads it. The core nodes, colliders, interactables and prefabs declare theirs, and collider sizes are settable so an editor can drag them.

Modules

A module is { name, nodes?, install(engine), uninstall?(engine) }. engine.use(module) installs it (or pass modules to the constructor), engine.get(Physics) finds it by class and throws a clear error when it is missing, engine.find returns null instead, engine.has("physics") checks by name. A module's signals are grouped under its name: engine.signals.input.onActionStart. The app can group its own with engine.registerSignals("game", { onLevelLoaded }).

Physics (@nickpaustian/catalyst/physics, needs @nickpaustian/velora)

new Physics({ gravity, drag, floor, fixedStep, maxSubSteps }) owns a Velora World, stepped at the end of each update, after which signals.onStep fires; paused = true holds it still, for an editor. BoxCollider, CapsuleCollider and SphereCollider nodes sit where their node is (layers, collidesWith, trigger, restitution, friction) and join the world when they enter the tree; their owner is the node, so a hit or a trigger leads back to it, and their collider's signals (onTriggerEnter, onCollisionEnter, …) are on the node's signals. RigidBody wraps a Velora body and by default moves its node: after each step the node is placed where the body is, and a transform set by hand in between moves the body there first. drives: "node" is the other way round, for a kinematic lift animated by code; moves: "parent" places the parent instead, for a body nested under the thing it carries. The collider given as collider becomes the child collider and is carried into the world by the body. Raycast casts from its node along a local direction: cast() returns hits nearest first, first() the nearest.

Input (@nickpaustian/catalyst/input, needs @nickpaustian/helm)

new Input(helmConfig) owns a Helm updated by the engine before game logic each frame, and on onIdle while the engine is paused so a bound key can resume it, its signals grouped as signals.input. bind, on, isActive and force read through; helm is the instance.

Network (@nickpaustian/catalyst/requester, needs @nickpaustian/requester)

new Network(requesterOptions) makes a Requester the engine's loader, so scene files and splat slices go through its queue, priorities and cache, and resource loads report bytes as they arrive through its onProgress. requesterLoader(requester) is the loader on its own.

Resources (@nickpaustian/catalyst/resources)

Assets are defined once and know how to fetch and ready themselves. A resource is a file: { key, url, type?, size?, priority? }, registered with resources.register. An asset is a thing made from files: resources.define(new ImageAsset({ key: "splash", resources: ["splash-jpg"] })). asset.ensure() resolves with the value, fetching and readying only what is missing and sharing one attempt among concurrent callers; isReady and status (idle, fetching, fetched, readying, ready, failed) say where it is; prefetch() fetches the bytes ahead without readying; unload() gives the memory back, and the next ensure readies it again. dependencies are assets readied first; a GroupAsset is a scene's worth under one key. Two assets that name the same resource share one fetch.

resources.load(["scene-one"]) returns a Load with progress across every resource in the tree (loaded, failed, total, bytes, percentage, speed, eta, stableEta that only falls, current) on signals.onProgress, and promise resolving when all are ready, failures in errors. Bytes stream through the engine's loader, so a Requester's queue and cache apply.

Memory: retain() and release() count an asset's users (a GaussianSplat node retains its asset while in the tree). resources.budget in bytes and trim() unload unretained ready assets least recently used first until under it; with a budget set, readying past it trims by itself and signals.onOverBudget fires. usage is what the ready assets hold. Built in: BufferAsset, JSONAsset, TextAsset, ImageAsset (an ImageBitmap or a blob URL), TextureAsset, AudioAsset, GroupAsset; the splats entry adds SplatAsset (parsed splat data a GaussianSplat node shows through asset: "key") and PartitionAsset (a .vgsp injected into a volumetric splat as it readies). Subclass Asset<T> with ready(fetched, dependencies), and unready and measure when the value holds memory, for anything else.

Interaction (@nickpaustian/catalyst/interaction)

new Interaction({ source, maxDistance }) picks the nearest Interactable under the pointer, or under the centre of the screen while the pointer is locked (source: "auto"; "pointer" and "centre" force one), each frame with the view camera, and turns a click or tap on it into onInteract. An Interactable is a sphere (radius) or a box (size) around its node, or a three.js object hit exactly; it has enabled, maxDistance, a payload, a cursor, focused and pressed, hitPoint and hitDistance, and signals.onFocus, onBlur, onPress, onRelease and onInteract. interaction.focused is what the ray is on, interaction.interact() acts on it (bind a key to that), pick(ndc?) answers without changing anything, and the module's signals are grouped as signals.interaction. Subclass for things that decide when they can be focused (canFocus) or react to focus (onFocusChanged); PointOfInterest in the prefabs is one. No physics or input module is needed.

Splats (@nickpaustian/catalyst/splats, needs @nickpaustian/stelligra)

new Splats({ capacity, sortBackend, … }) owns one Stelligra renderer that GaussianSplat nodes share, and the page's GPU budget: a number of splats, or "probe" to measure the device once (the renderer's textures allocated for real at each size, kept per device in localStorage, crash-safe). Every renderer takes a share of that one number: a volumetric splat reserves what its scene needs as it enters and gives it back as it leaves, the shared renderer is capped at what is left, and splats.budget reports capacity, reserved and available. GaussianSplat({ url }) loads a PLY or SOG file, parses it in a worker and follows the node's transform (set url to swap the file); several files as lods switch with setLevel. VolumetricGaussianSplat({ url }) streams a scene too large for the GPU: a grid of cells with slices ordered by importance; each frame the camera moved, the cells in view are given an ideal quality by distance, the budget (splatBudget, at most 85% of the reserved capacity so slices in flight have room) is spent from the centre of the screen outward, missing slices are fetched nearest first and decoded in a worker pool (the SOG decoder, wasm included, is bundled into the package), and the GPU evicts what has not been wanted for longest; what is resident is bounded by the reserved capacity, so injected partitions may sit above the planning budget. inspect() reports the state, visualised draws the cells coloured by quality, loadPartition(buffer) injects a .vgsp bundle of slices at once. The node's transform places the scene: cells are planned with the camera brought into the node's space, and slices are baked through the transform as they decode, so set it before loading rather than animating it.

Prefabs (@nickpaustian/catalyst/prefabs, needs physics, input and interaction)

  • FirstPersonController: a Velora character controller for the body (speeds, jump, step height, slope limit, coyote time), a camera at eye height driven by pointer lock (a click on the canvas locks; canLock says when the app allows it), a raycast from the camera for interaction, and head bob. signals.onPointerLockChange; character.signals.onJump and onLand.
  • GuidedCamera: a camera on a rail, moved by you (a curve, a scroll), that turns toward the nearest enabled FocalPoint or looks along the way it has been moving; the turn is eased and, with turnWithInput, scaled by the input force so it turns as the user scrolls and rests when they stop.
  • FocalPointTrigger: a box that turns its focal points on while a body is inside.
  • Box, Sphere, Capsule: a mesh and a collider that agree, for blocking in an environment; width/height/depth or radius/height resize both, colour sets the material, layers, collidesWith, trigger, friction and restitution go to the collider, and material brings your own. The two children are internal, so a scene file holds the prefab and its sizes.
  • PointOfInterest: an Interactable with a marker element over it that fades with distance, hides when occluded (a ray against occluders) and pulses; signals.onInteract, focused, a payload for the app.

Development

npm run dev opens the example: a room with physics, a first-person player, points of interest, a guided camera on a rail and a splat. npm test runs the suite. npm run build bundles the slice worker (with the SOG decoder from @nickpaustian/stelligra/sog) into a string and compiles the package.