@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.
Maintainers
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 opendemo/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.
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 afterremoveinvalidates 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-nodedepthBiasis the manual escape hatch for popping on interpenetrating meshes; alayerlane 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), andcustom(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
billboarddraws one flat camera-facing quad instead of its geometry -- and itsspriteW/spriteHare in SCREEN PIXELS, so a marker or HUD label holds a constant on-screen size at any camera distance (world-spacescaleshrinks 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_VERSIONstays 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 singlematerial.lut[shade]read — no per-face normal math, norgb(...)built in the loop. - Hybrid precision.
Float32Arrayfor static geometry stores (read-only at project time, memory-dense);Float64Arrayfor frame arenas and math registers, so screen coordinates never silently up-cast beforemoveTo/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-depthRuntime 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-clockQuick 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:
- transform — topological walk; recompute the world 3×4 affine for dirty subtrees only (a
lite-fastbit32flag lane drives the dirty query). - project — world → view → perspective divide into the Float64 frame arena. A cheap per-node bounding-sphere frustum reject skips fully off-screen instances.
- collect + cull — per face: near-plane reject,
lite-aabbviewport cull, and screen-winding back-face cull (the Y-flip inverts polygon orientation, so front faces are the negative-area ones). - sort — pack each surviving face into a
Uint32key and LSD radix sort (4 × 8-bit passes, pre-allocated histograms + ping-pong index buffers). O(n), stable, comparator-free —Array.sortwith a comparator is disqualified because it allocates and boxes. - paint — walk sorted order back-to-front; one
beginPath/fillper style-run,fillStylefrom the material's pre-baked LUT, nosave/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 worldnormal.y = +1reads the full-sky step andnormal.y = -1reads full ground, independent of the light.updefaults 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.rimdefaults to0.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/lineWidthbut differentdashshare 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 quadsgeomId 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 deltaWhen 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 dirtyThe 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-easeThe 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.
