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-depth

v3.1.0

Published

Zero-GC Canvas2D software-projected 3D (flat-shaded, painter-sorted, pseudo-3D), arena-backed and allocation-free per frame. Optional `/motion` subpath adds a zero-GC keyframe + quaternion-slerp animation mixer.

Readme

@zakkster/lite-depth

DEPTH // SCOPE — the three-scene oscilloscope demo. Primitive gallery, a zero-GC stress field with live telemetry, and the Z-bias / layer escape-hatch playground. Run it with npx serve . and open demo/demo.html.

DEPTH // MOTION (v1.3.0) — a clock-driven timeline scrubber: scrub, play, and pingpong a field of props animated with keyframed position, quaternion slerp, and scale. Open demo/motion.html.

Zero-GC Canvas2D software-projected 3D. Zdog's niche — flat-shaded, painter-sorted, pseudo-3D on a 2D canvas — but arena-backed and allocation-free per frame.

npm version sponsor Zero-GC npm bundle size npm downloads npm total downloads Tree-Shakeable TypeScript license

Zdog allocates; lite-depth doesn't. Same flat-shaded pseudo-3D on a 2D canvas — projection, painter's-algorithm depth sort, primitives, hierarchy, strokes — but every per-frame array is pre-allocated once and mutated in place. The render loop (transform → project → cull → radix sort → paint) produces zero garbage collections, verified frame-by-frame with @zakkster/lite-gc-profiler.

Highlights

  • Zero-GC frame loop. Structure-of-arrays node store, a global pre-allocated frame arena, and an LSD radix sort keep the hot path allocation-free. Under --expose-gc, a 2000-node scene with every node re-oriented every frame runs 0 major / 0 minor GC across thousands of frames — cleaner than an empty control loop.
  • Pure Canvas2D painter engine. No WebGL, no depth buffer, no renderer abstraction. Faces are depth-sorted by centroid and painted back-to-front, batched into style-runs so identical consecutive fills collapse to one fill().
  • Arena-backed nodes. Built on @zakkster/lite-arena: generational handles (a stale handle after remove invalidates instead of aliasing a recycled slot), SoA sparse-set storage, swap-and-pop compaction that keeps the transform pass dense and cache-warm.
  • One packed sort key, three jobs. key = (layer << 26) | quantize26(viewZ + depthBias). A per-node depthBias is the manual escape hatch for popping on interpenetrating meshes; a layer lane forces ordering outright (HUD props always over scenery). Same radix passes, zero extra cost.
  • Seven primitives + custom meshes. Box, plane, sphere, cylinder, cone, polyline, heightfield (grid-surface relief), and custom(verts, faces) — quads and n-gons are first-class (a box is 6 quad faces, not 12 tris). Face normals precomputed with Newell's method.
  • Screen-space sprites (billboards). A node marked billboard draws one flat camera-facing quad instead of its geometry -- and its spriteW/spriteH are in SCREEN PIXELS, so a marker or HUD label holds a constant on-screen size at any camera distance (world-space scale shrinks with perspective; a sprite does not). Sorted, layered, and pickable exactly like a face. Zero added per-frame cost when no node is a billboard.
  • Presentation polish, zero hot-path cost (v3.1.0). Three additive material/stage options for richer scenes: hemisphere + rim shade modes (material({ mode }) -- sky/ground gradient by world normal, or a view-dependent silhouette term), blob ground shadows (stage.setShadowMode(1) -- one 12-gon per caster instead of the full planar flatten), and dash line styles (material({ dash }) baked once, applied per stroke run). All additive: LANE_VERSION stays 2, no frozen contract re-cut. A plain material, a planar shadow, and an undashed stroke are byte-identical to 3.0.0 -- the only hot-body change is one per-node compare on the shade dispatch.
  • Flat shading without string churn. Each material pre-bakes a K-step hex ramp at creation (K capped at 256 — the shade lane is a Uint8Array, so a longer ramp throws rather than wrapping). Shading is lit from the node's world matrix (the same transform the face is drawn from, so a child of a rotated/scaled parent is lit correctly), computed once per node during collect and baked into a per-draw shade lane; the paint loop is a single material.lut[shade] read — no per-face normal math, no rgb(...) built in the loop.
  • Hybrid precision. Float32Array for static geometry stores (read-only at project time, memory-dense); Float64Array for frame arenas and math registers, so screen coordinates never silently up-cast before moveTo/lineTo.
  • Real runtime dependencies. lite-arena · lite-fastbit32 (flag namespace) · lite-aabb (per-face viewport cull). Optional cold-path peers: lite-signal (DI bindings), lite-raf, lite-clock, lite-sprite-cache.

Install

npm install @zakkster/lite-depth

Runtime dependencies install automatically. Optional peers (only if you use the cold-path features that need them):

npm install @zakkster/lite-signal @zakkster/lite-raf @zakkster/lite-clock

Quick start

import { createStage, geometry, material, updateCamera } from '@zakkster/lite-depth';

const canvas = document.querySelector('#scene');
const stage = createStage(canvas.getContext('2d'), {
  width: 800, height: 600, dpr: devicePixelRatio,
  maxNodes: 256, maxVerts: 8192, maxDrawFaces: 8192,
  camera: { radius: 7, theta: 0.6, phi: 1.05, near: 0.1, far: 60 },
});

// Register shared geometry + material, then instance nodes.
const box = stage.geometry(geometry.box(1.4, 1.4, 1.4));
const mat = stage.material(material({ r: 95, g: 227, b: 161, ambient: 0.32 }));
const cube = stage.addNode(box, mat, { x: 0, y: 0, z: 0 });

// Drive it. stage.frame(dt) is standalone — pair with rAF, lite-raf, or a game loop.
let f = 0;
requestAnimationFrame(function loop(now) {
  stage.setEuler(cube, f * 0.01, f * 0.013, 0);
  stage.camera.theta += 0.004; updateCamera(stage.camera);
  const stats = stage.frame(16);      // { facesDrawn, facesCulled, tSort, ... }
  f++;
  requestAnimationFrame(loop);
});

Camera interaction (drag-orbit, pinch-dolly, inertia) is deliberately not in core — it lives in a companion. The core ships spherical→Cartesian orbit math and plain setters; wire pointer events to camera.theta/phi/radius + updateCamera(camera) yourself, or hand it to a driver package.

The pipeline

Every stage.frame(dt) runs five phases over pre-allocated storage:

  1. transform — topological walk; recompute the world 3×4 affine for dirty subtrees only (a lite-fastbit32 flag lane drives the dirty query).
  2. project — world → view → perspective divide into the Float64 frame arena. A cheap per-node bounding-sphere frustum reject skips fully off-screen instances.
  3. collect + cull — per face: near-plane reject, lite-aabb viewport cull, and screen-winding back-face cull (the Y-flip inverts polygon orientation, so front faces are the negative-area ones).
  4. sort — pack each surviving face into a Uint32 key and LSD radix sort (4 × 8-bit passes, pre-allocated histograms + ping-pong index buffers). O(n), stable, comparator-free — Array.sort with a comparator is disqualified because it allocates and boxes.
  5. paint — walk sorted order back-to-front; one beginPath/fill per style-run, fillStyle from the material's pre-baked LUT, no save/restore.

API

createStage(ctx, options)

Returns a Stage. Caps (maxNodes, maxVerts, maxDrawFaces) size the arena and frame buffers up front — pick them for your worst-case scene. maxVerts is the total projected-vertex budget across all visible instances per frame (a 77-vertex sphere costs 77, not 1); undersizing silently drops geometry.

Geometry

geometry.box(w?, h?, d?), .plane(w?, d?), .sphere(radius?, segU?, segV?), .cylinder(radius?, height?, seg?), .cone(radius?, height?, seg?), .polyline(points), .heightfield(z, cols, rows, opts?), .custom(verts, faces). Each returns a shared Geometry; register once with stage.geometry(g) and instance it across many nodes. custom takes flat verts (xyz interleaved) and a faces array of convex index loops (quads and n-gons welcome).

Height fields / relief

geometry.heightfield(z, cols, rows, opts?) builds a grid surface from a row-major scalar field — terrain, surface plots, relief maps, any sampled cols x rows grid. z is a length-cols*rows numeric source (Array or TypedArray, index = row*cols + col); it is copied into the mesh, so a later caller mutation cannot invalidate the geometry. cols/rows are integers >= 2; a bad dimension or cols*rows > z.length throws a lite-depth:-tagged error naming the arg (fail-closed, like every v2.0.0 door).

The grid lies in the XZ plane and height extrudes along +y, exactly like plane — so a heightfield sits on the y=0 ground plane and existing ground shadows (setShadowMaterial + setCastShadow) project under it for free, and it flat-shades from Newell face normals like any other mesh.

opts (all optional): width (default 1) and depth (default 1) set the X/Z footprint (vertices spread evenly, centered on the origin, same scale as plane); yScale (default 1) and yBase (default 0) map each sample to world y = z[i]*yScale + yBase; holes (default true) treats a non-finite height (NaN/+-Infinity) as a missing vertex and skips any quad touching it, leaving a hole (the natural fit for a field sampled outside its domain). With holes:false, a non-finite height throws instead.

// a 64x64 relief from a scalar field, cells outside the domain left as holes
const field = sampleField(64, 64);        // Float32Array, NaN where undefined
const relief = stage.geometry(geometry.heightfield(field, 64, 64, { yScale: 4 }));
stage.addNode(relief, mat, { x: 0, y: 0, z: 0 });

Materials

material({ r, g, b, ambient, steps, stroke, lineWidth, fill, mode, up, rim, dash }) bakes a K-step hex ramp at creation. materialFromRamp(hexRamp, opts) takes a ready-made ramp — e.g. an OKLCH scale from @zakkster/lite-hueforge. steps (and a ramp length) is capped at 256: the per-frame shade lane is a Uint8Array, so an over-long ramp throws at creation (fail closed) rather than silently wrapping the index to step 0. fill: false now emits no fill() (before v1.4.0 it filled anyway); a stroke colour outlines the face in the same style-run batch, so fill: false + stroke is a wireframe and fill: true + stroke is fill-then-outline.

Shade modes, blob shadows, and dashes (v3.1.0)

Three additive presentation options. Each reads the D7-frozen surface rather than re-cutting it: LANE_VERSION stays 2, LANES.md is untouched, and no per-node lane, FLAGS bit, draw sentinel, sort key, or Worker transfer key changes. The new material fields are baked once at creation (the material store is not LANE_VERSION-covered); the shadow mode is a stage-level cold toggle.

Hemisphere + rim shade modes -- material({ mode, up, rim }). mode is 0 plain (the default), 1 hemisphere, or 2 rim:

  • hemisphere shades by the world normal against an up-vector -- a sky/ground gradient. shadeL = ((dot(worldNormal, up) + 1) / 2) * (K-1), so a face whose world normal.y = +1 reads the full-sky step and normal.y = -1 reads full ground, independent of the light. up defaults to world +Y (normalized at creation).
  • rim adds a view-dependent silhouette term to the diffuse: diffuse + rim * (1 - |dot(worldNormal, cameraZ)|), so faces turned edge-on to the view brighten. rim defaults to 0.6.

The plain path (mode 0) is unchanged and byte-identical to 3.0.0; the only hot-body cost is one per-node compare that routes a mode != 0 node to a cold shade-collect clone, folding the second term into the same single shadeL write (no second lane). Both modes combine cleanly with the existing world-normal shading and its uniform / inverse-transpose paths. Fail-closed at creation (lite-depth: colon-form): a mode outside 0..2, a non-finite up, or a rim outside 0..4 throws; null/omitted is plain (null is not zero).

Blob ground shadows -- stage.setShadowMode(mode) (stage-level, cold): 0 planar flatten (the D5 default), 1 blob. Blob emits one 12-gon per caster, flattened onto y=0 along stage.light and sized to the caster's ground footprint, riding the same DRAW_SHADOW draw path and the same D5 keying as the planar pass. So stats.shadowFacesDrawn counts 1 per caster in blob mode versus F (the caster's face count) in planar mode. It needs a shadow material set (setShadowMaterial) exactly like the planar pass -- with none set, the whole shadow pass is skipped. Any value other than 0 or 1 throws (lite-depth: colon-form); the call returns the stage for chaining.

const shadowMat = stage.material(material({ r: 20, g: 20, b: 24 }));
stage.setShadowMaterial(shadowMat);   // required, exactly like the planar pass
stage.setShadowMode(1);               // 0 planar (default) | 1 blob
stage.setCastShadow(caster, true);

Dash line styles -- material({ dash }), an array of segment lengths baked once (defensively copied), applied via ctx.setLineDash once per stroke style-run flush (both the face-outline stroke and the polyline DRAW_STROKE path), 0 allocation per frame. null/omitted is a solid line. A context lacking setLineDash (a minimal stub) falls back to solid rather than throwing. Fail-closed at creation: a non-array dash, or a non-finite / negative segment, throws.

const dashed = stage.material(material({ fill: false, stroke: '#0af', dash: [6, 3] }));

Known limitation (dash style-run aliasing). Dash inherits the pre-existing style-run batching: two materials with equal fill/stroke/lineWidth but different dash share one style run, because the run-break predicate never compares dash. The dash is set only when a run opens, so in the back-to-front paint order the first-painted (farthest) node's dash wins for the whole run -- not "whichever was set last in time". Give distinct dashes distinct stroke styles if they must coexist in one run.

Nodes

addNode(geomId, matId, init) → a generational NodeHandle. Setters: setPosition, setScale (uniform or per-axis), setQuaternion, setEuler, setParent, setLayer (0–63), setDepthBias, setVisible, remove. All setters mark the node dirty; structural changes (add / remove / reparent) flag a cold topological rebuild before the next frame.

A parent handle that has been removed (its slot recycled) is resolved generationally, not by hand: the orphaned child reparents to ROOT and bumps stats.nodesOrphaned rather than silently inheriting a stranger's world matrix. A parent cycle throws from the cold rebuild, naming both nodes.

Screen-space sprites (billboards)

A node marked billboard does not draw its geometry faces: it emits one flat camera-facing screen-aligned quad, centered on the node's projected position and flat-filled by its material's fully-lit color (a camera-facing quad has no meaningful world normal, so a sprite reads at its designed color, not dot(normal, light)). No textures -- the fill is a solid quad.

The headline is spriteW/spriteH in screen pixels: a sprite holds a constant on-screen size at any camera distance. World-space scale shrinks with perspective; a screen-sized marker, label, or HUD pin does not. This is why the feature takes a major (LANE_VERSION 1 -> 2): a constant pixel size needs new per-node data that the existing world-space pose lanes cannot express.

const marker = stage.addNode(box, mat, {
  x: 0, y: 1.5, z: 0, billboard: true, spriteW: 24, spriteH: 24,
});
stage.setSpriteSize(marker, 32, 16);   // screen px (w, h); COLD, fail-closed

// Orbit / dolly the camera as far as you like -- the marker stays 32 x 16 px on
// screen while a normal mesh at the same position shrinks with distance.
stage.camera.radius = 40; updateCamera(stage.camera);
const s = stage.frame(16);             // s.spritesDrawn counts painted quads

geomId is still required by addNode but is unused for a billboard (its faces never draw); setBillboard(h, true) marks an existing node the same way. Default size is 16 x 16 px. Fail-closed: setSpriteSize and the { spriteW, spriteH } init throw a lite-depth: colon-form error naming the arg on a dead handle or a non-finite / <= 0 size (null/NaN is not zero); a billboard whose center is behind the near plane or non-finite emits nothing, never a phantom quad.

A sprite sorts via the frozen packed key at its center viewZ + depthBias, so it obeys layers and depth ordering against real faces; its screen quad populates the per-node screen-box lane, so pick / nearest / pickSet resolve it under the same lane requirement as faces (a bound spatial index or dirtyRect). The spriteW/spriteH lanes are never in the Worker transfer set, so an off-thread transform round trip produces identical sprite output.

Lifecycle (cold path)

For scene reloads and capacity growth, without rebuilding the whole stage:

stage.clear();            // remove all nodes in place; keep capacity, geometries,
                          // materials, camera, frame arenas; 0 allocation.
                          // every handle minted before clear() is now invalid.
stage.reserve(8192);      // grow all node-capacity lanes; returns false when
                          // n <= capacity, true after a grow; throws on a bad arg.
stage.remainingNodes;     // free node slots (capacity - live count)
stage.structureEpoch;     // Uint32, bumped by addNode/remove/setParent/clear --
                          // the invalidation signal for any cached dense index.

Camera

createCamera(opts) / stage.camera carry { theta, phi, radius, tx/ty/tz, fov, near, far, ortho, orthoScale }. Mutate them, then call updateCamera(camera) (cold) to rebuild the world→view affine. An optional stage.view2d (a Float64Array[6]) applies one setTransform per frame — the composition hook for a 2D screen-space camera (shake, framing) over the 3D scene.

stage.frame(dt) → stats

Runs the pipeline and returns { facesDrawn, facesCulled, nodesCulled, drawCalls, tTransform, tProject, tSort, tPaint, facesOverflowed, nodesInvalid, nodesNonUniform, nodesOrphaned, nodesTotal }. nodesCulled counts nodes rejected by the coarse depth/frustum reject or the per-node screen-space AABB cull (see below) -- a screen-box-culled node runs zero face-loop iterations, so its faces are not counted in facesCulled (the v1.5.0 semantics shift; a node at least partially on screen is byte-identical to 1.4.0). facesOverflowed counts nodes the frame-arena overflow door skipped (size up maxVerts / maxDrawFaces when it is nonzero); nodesInvalid counts nodes the fail-closed collect door rejected for a non-finite (NaN/Infinity) pose lane, centroid, radius or bias — the whole node is skipped, never laundered into a far-plane draw; nodesNonUniform counts drawn nodes with a non-uniform local scale (the NON_UNIFORM_SCALE inverse-transpose feature — a locally-uniform child under a non-uniform ancestor is still lit through the inverse-transpose, but is not itself counted); nodesOrphaned counts dead-parent reparents; nodesTotal is the live count.

Per-node screen cull + opt-in dirty-rect (v1.5.0)

Each visible node accumulates a screen-space AABB over its front-of-near vertices (z <= -near) during projection. If that box misses the viewport the whole node is culled before its face loop runs (nodesCulled +1, zero face-loop work). Two deliberately opposite doors: a face-bound NaN makes a viewport compare false, so the face is culled -- fail closed, losing a degenerate face is safe; a node whose box is empty or non-finite (every vertex behind the near plane) is drawn, never node-culled and never counted in nodesInvalid -- fail open, because a wrongly-fired geometry cull loses picture (its faces are then rejected by the per-face near door, exactly as 1.4.0).

stage.dirtyRect = true;         // opt-in (default false); OFF => zero added hot cost
const s = stage.frame(16);
repaintRegion(stage.sceneBox);  // Float32Array [minX,minY,maxX,maxY] this frame
// stage.prevSceneBox holds last frame's union, for a redraw delta

When dirtyRect is enabled, each drawn node's screen box is stored (rounded outward to f32 so the stored box never clips the true box) and merged once per frame into stage.sceneBox; the previous frame's union stays in stage.prevSceneBox. The per-node cull itself is always on and independent of the flag. FORMAT_VERSION re-exports the @zakkster/lite-aabb packed-format contract version and is asserted === 1 in createStage.

lite-signal DI (cold path)

import { effect } from '@zakkster/lite-signal';
stage.useSignals({ effect });
stage.bind(handle, 'position', () => positionSignal());   // cold effect writes lanes + marks dirty

The effect runs on the cold path; the frame loop only ever reads lanes.

Zero-GC frame loop

The headline claim, and it's falsifiable. Measured with @zakkster/lite-gc-profiler (perf_hooks precise GC events) — with settle(), because the observer delivers entries asynchronously and a naive synchronous read reports a false 0.

// node --expose-gc, container sandbox, 2000 nodes, every node dirty every frame:
//
//   window              major   minor   verdict
//   control (empty)       0       1      — (V8 self-noise)
//   steady-state render   0       0      PASS  {maxMajor:0, maxMinor:0}
//
// The render loop produced fewer collections than doing nothing.

Structural mutation (spawn / despawn / reparent) is cold by contract and may allocate a small, bounded amount — it triggers zero major GCs and never runs away.

Against Zdog (matched rotating box fields, identical no-op ctx — both hit the same Canvas2D backend, so this isolates the JS pipeline; container-indicative):

| N boxes | lite-depth | Zdog | Zdog GC (major/minor) | speedup | |--------:|-----------:|-----:|:---------------------:|--------:| | 500 | 0.4 ms, 0/0 GC | 11 ms | 0 / 345 | ~30× | | 1000 | 0.8 ms, 0/0 GC | 22 ms | 1 / 691 | ~29× | | 2000 | 1.5 ms, 0/0 GC | 48 ms | 2 / 1384 | ~31× |

Zdog's minor-GC count scales with the scene because it allocates Vector objects per shape per frame — structural to its design, not a tuning artifact.

Timings are indicative, not the pinned bars. They were taken in a Linux container on unknown silicon. Allocation (0 bytes/frame) is machine-independent and final; millisecond figures are for ratios and scaling shape. The official performance bars are pinned on a 10-year-old MacBook Pro (primary) and iPhone 11 / mid Android (secondary).

Motion — the animation layer (v1.3.0)

@zakkster/lite-depth/motion is an optional subpath: a zero-GC keyframe mixer that drives node lanes over time. It's a thin composer over the stack, not a re-implementation — lite-clock is the deterministic time base, lite-keyframe evaluates scalar channels, lite-ease is the easing bank. Motion adds only what the stack lacks: quaternion slerp tracks (with an nlerp fast path — interpolating a quaternion as four scalar keyframe rows would denormalise and never actually slerp), loop/pingpong wrapping, and the clip table that binds channels to lite-depth nodes.

npm install @zakkster/lite-depth @zakkster/lite-clock @zakkster/lite-keyframe @zakkster/lite-ease

The motion deps are optional peers — the core (@zakkster/lite-depth) installs without them; add them only if you import the /motion subpath.

import { createStage, geometry, material } from '@zakkster/lite-depth';
import { createClock } from '@zakkster/lite-clock';
import { createMixer } from '@zakkster/lite-depth/motion';

const clock = createClock({ capacity: 512 });
const mixer = createMixer(stage, { clock, maxClips: 512 });

mixer.clip(diceHandle)
  .posKey(0, -3, 0, 0)
  .posKey(1,  0, 2, 0, 'easeOutCubic')
  .posKey(2,  3, 0, 0, 'easeInQuad')
  .quatEuler(0, 0, 0, 0)
  .quatEuler(2, 0, Math.PI, 0, 'easeInOutCubic')
  .scaleKey(0, 1).scaleKey(2, 1.4, 'easeInOutSine')
  .play({ duration: 2, loop: 'pingpong' });

// deterministic drive — fixed advance(dt) gives golden-frame reproducibility
requestAnimationFrame(function loop() {
  clock.advance(1 / 60);   // pause/seek/replay all live on the clock
  mixer.sync();            // eval channels -> write node lanes (zero alloc)
  stage.frame(16);
  requestAnimationFrame(loop);
});

Channels per clip: posKey, scaleKey (uniform or per-axis), quatKey / quatEuler, biasKey (depth-bias). Each key takes a time in seconds and an optional easing name. play({ duration, loop, timescale, start }) — duration is inferred from the last key when omitted; loop is 'loop' / 'pingpong' / a numeric mode. Lifecycle: pause / resume / stop / seek(local), plus clip.done / clip.playing.

Time base. In clock mode the mixer reads clock.simTime, so global pause/seek/replay and golden-frame determinism come from the clock, not a re-implementation. Standalone mode (mixer.update(dt)) keeps its own time when you don't want a clock.

Zero-GC, measured honestly. The update path evaluates channels straight into the arena's Float64 lane arrays (never boxing an eval result across a setter call boundary) and slerps quaternions through a single module-scratch register. Gated on allocated bytes per op (measureOps heap-delta, not a raw scavenge count — a scavenge count tracks wall-clock time, not heap growth): a 1500-clip scene animating position + quaternion + scale every frame holds at the bytes/op noise floor with 0 GCs in the steady phase — the same bar as the render loop.

TypeScript

Full declarations ship as Depth.d.ts — Stage, Geometry, Material, Camera, StageStats, NodeHandle, FlagName, and the geometry / material / mathKernels namespaces are all typed. The motion subpath ships Motion.d.ts (Mixer, Clip, MixerOptions, PlayOptions, loop-mode constants).

Testing

node:test (no test-runner dependency), nine files — core (01–04) and motion (05–09):

npm test            # correctness; the zero-GC tests skip without --expose-gc
npm run test:gc     # adds --expose-gc; the zero-GC contracts engage
npm run bench       # vs-Zdog head-to-head + throughput sweep

| File | Covers | |---|---| | test/01-math.test.js | composeTRS / quatRotate / mulAffine vs gl-matrix (forced Float64) — bit-exact | | test/02-geometry.test.js | primitive vertex/face counts, unit face normals, CSR offset validity | | test/03-pipeline.test.js | back-face cull == true facing (500 poses), radix sort (spread + full-order depth monotonicity), near/frustum cull, generational-handle safety, hierarchy world transform | | test/04-zero-gc.test.js | steady-state render loop 0 major / 0 minor GC (skips without --expose-gc) | | test/05-motion-scalar.test.js | position / scale / bias channel interpolation, per-segment easing, duration inference | | test/06-motion-quaternion.test.js | slerp vs gl-matrix (bit-exact on Float32-stored inputs), unit-normalisation, nlerp fast path | | test/07-motion-timeline.test.js | once / loop / pingpong wrapping, pause / resume, timescale | | test/08-motion-determinism.test.js | golden-frame: identical advance(dt) sequence → byte-identical lane state | | test/09-motion-zero-gc.test.js | animation update path at bytes/op noise floor, 0 steady GC (skips without --expose-gc) |

The two zero-gc tests skip without --expose-gc so npm test runs cleanly; npm run test:gc engages them.

License

MIT — © Zahary Shinikchiev.

Built on the @zakkster/lite-* zero-runtime-dependency ecosystem.