@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
Maintainers
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 afocalLengthin millimetres over asensorsize, so the field of view follows the aspect ratio as a real lens does;setFocalLength,blendFocalLength(target, seconds),lookAt(target)andgetLookAtRotation. Make it the engine's withengine.camera.target = camera.syncRotation = falselets 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.glbor a.gltfwith everything embedded) fetched through the engine's loader when the node enters, placed at its transform, disposed when it leaves; seturlto load another.BezierCurve: a smooth curve through control points, in the node's space, answering world-spacegetPointAt(t),getTangentAt,getPointAtDistance, and named control points forparameterOf,isBetweenandparameterBetween.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), acameraat eye height driven by pointer lock (a click on the canvas locks;canLocksays when the app allows it), araycastfrom the camera for interaction, and head bob.signals.onPointerLockChange;character.signals.onJumpandonLand.GuidedCamera: a camera on a rail, moved by you (a curve, a scroll), that turns toward the nearest enabledFocalPointor looks along the way it has been moving; the turn is eased and, withturnWithInput, 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/depthorradius/heightresize both,coloursets the material,layers,collidesWith,trigger,frictionandrestitutiongo to the collider, andmaterialbrings your own. The two children are internal, so a scene file holds the prefab and its sizes.PointOfInterest: anInteractablewith a marker element over it that fades with distance, hides when occluded (a ray againstoccluders) and pulses;signals.onInteract,focused, apayloadfor 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.
