@zakkster/lite-math
v1.0.0
Published
Zero-GC 3D transform math: quaternion + 4x4 column-major matrix. Float32Array-only, fully unrolled, three.js-compatible compose order. Designed for tight per-frame budgets (Twitch extensions, WebGL/WebGPU, game loops).
Maintainers
Readme
@zakkster/lite-math
Zero-GC 3D transform math — quaternion + 4×4 matrix, nothing else. Two namespaces (quat, mat4) over plain Float32Array buffers. Fully unrolled, no helper-call boundary crossings, three.js-compatible compose order. ~440 lines of code, zero dependencies.
import { quat, mat4 } from '@zakkster/lite-math';
// Allocate once at setup.
const rotation = quat.create();
const position = new Float32Array([0, 0, 0]);
const scale = new Float32Array([1, 1, 1]);
const world = mat4.create();
const tmpQuat = quat.create();
const tmpTarget = quat.create();
quat.fromEuler(tmpTarget, 0, Math.PI, 0);
// In the per-frame loop — zero allocations.
function tick(t) {
quat.slerp(rotation, tmpQuat, tmpTarget, t);
position[0] += velocityX;
mat4.compose(world, position, rotation, scale);
// gl.uniformMatrix4fv(uWorld, false, world);
}Contents
- Why · Install · Quick start
- Conventions
- API reference:
quat - API reference:
mat4 - Aliasing rules
- Composition order: why
T * R * S - Recipes
- Comparison with gl-matrix and three.js
- Testing
- License
Why
You almost certainly already have a math library. So why this one?
Because most math libraries allocate, and you can't see them doing it. gl-matrix is the gold standard for the zero-alloc approach in browser JS, but it's also five separate namespaces (vec2, vec3, vec4, mat3, mat4, quat, quat2) and ships with class-based wrappers, dual-quat support, and SIMD scaffolding you almost never need. Three.js's Matrix4/Quaternion classes are ergonomic but allocate scratch objects internally — fine for general 3D, fatal for a 60Hz Twitch extension with 1 MB of bundle budget and a 3 s cold-start.
@zakkster/lite-math is what's left after you strip everything you can:
- Two namespaces.
quatandmat4. That's it. If you needvec3ops, three lines of inline arithmetic will outperform any function call. - Float32Array everything. No
{ x, y, z, w }object wrappers. Noclass Quaternion. Just flat buffers laid out for V8's typed-array fast path. - Every op writes into
out. No exceptions.create()andclone()are the only allocators — call them at setup, never in the loop. - Fully unrolled. No
.set(), no.fill(), noforloops crossing the C++ boundary in inner ops.copywrites 16 fields by hand because that's what TurboFan inlines best. - One file. ~440 lines. You can read the whole library in five minutes and audit every byte you ship.
It is not a replacement for gl-matrix if you need vec3.cross, mat3.normalFromMat4, or quaternion-from-axis-angle. It is what you reach for when you've already decided that allocations matter and you want the smallest possible surface that still handles a TRS pipeline.
Install
npm install @zakkster/lite-mathZero dependencies. ESM only. Node ≥14, all modern browsers.
Quick start
A typical animated transform — interpolate rotation, build a world matrix, upload to the GPU:
import { quat, mat4 } from '@zakkster/lite-math';
// === Setup (allocate once) ===
const startRot = quat.create();
const endRot = quat.create();
quat.fromEuler(endRot, 0, Math.PI / 2, 0); // 90° around Y
const rot = quat.create();
const pos = new Float32Array([0, 0, -5]); // 5 units in front of camera
const scale = new Float32Array([1, 1, 1]);
const world = mat4.create();
// === Per-frame (zero allocations) ===
function tick(elapsedSec) {
const t = Math.min(elapsedSec / 2.0, 1.0); // 2-second blend
quat.slerp(rot, startRot, endRot, t);
mat4.compose(world, pos, rot, scale);
// gl.uniformMatrix4fv(uWorld, false, world);
}For a camera view matrix:
// view = inverse(camera-world)
const camWorld = mat4.create();
const view = mat4.create();
mat4.compose(camWorld, camPos, camRot, ONE_SCALE);
if (!mat4.invert(view, camWorld)) {
// singular — bad camera state, skip frame
}Conventions
| | |
|---|---|
| Quaternion layout | [x, y, z, w] (Hamilton, w-last) — same as gl-matrix and three.js |
| Matrix layout | Float32Array(16), column-major — same as WebGL / WebGPU / gl-matrix |
| Compose order | mat4.compose(out, pos, rot, scale) produces T * R * S — same as three.js |
| Rotation order | quat.fromEuler(out, x, y, z) is YXZ (yaw-pitch-roll) — same as three.js default |
| Angle units | All angles in radians |
| Coordinate system | Right-handed |
If you've used three.js, all four signatures match. If you've used gl-matrix, the layouts match but composition is (pos, rot, scale) instead of (rot, pos, scale) — see the comparison table.
API reference: quat
A quaternion is a Float32Array(4) laid out as [x, y, z, w].
| Method | Allocates? | Returns | Description |
|---|---|---|---|
| create(x?, y?, z?, w?) | ✅ once | Quat | New quaternion, defaults to identity (0,0,0,1). |
| clone(a) | ✅ once | Quat | New independent copy of a. |
| copy(out, a) | no | out | Copies a into out. |
| set(out, x, y, z, w) | no | out | Writes explicit components. |
| identity(out) | no | out | Writes (0, 0, 0, 1). |
| multiply(out, a, b) | no | out | Hamilton product a * b. Aliasing-safe. |
| normalize(out, a) | no | out | Unit-length; returns identity if a has zero length. |
| dot(a, b) | no | number | Dot product. |dot| measures rotation closeness; sign indicates hemisphere. |
| conjugate(out, a) | no | out | (-x, -y, -z, w). For unit quaternions this is the rotational inverse. |
| slerp(out, a, b, t) | no | out | Spherical linear interpolation along the shortest arc. Falls back to normalized lerp when cos(θ/2) > 0.9995. Aliasing-safe. |
| fromEuler(out, x, y, z) | no | out | YXZ-order Euler → unit quaternion. Angles in radians. |
Notes
slerp shortest-path correction. When dot(a, b) < 0 the two quaternions are in opposite hemispheres of the 4-sphere (they represent the same rotation but rotate "the long way around"). slerp negates b internally so you always interpolate along the shorter arc — the standard fix, but worth knowing if you ever try to manually unpack the math.
fromEuler order. Quaternions don't have a single canonical Euler-angle decomposition; somebody has to pick a convention. We use YXZ (apply yaw, then pitch, then roll) because it matches three.js's default Euler.order and is the most intuitive for free-look cameras and tumbling objects.
API reference: mat4
A matrix is a Float32Array(16) in column-major order. Element layout:
| column 0 | column 1 | column 2 | column 3 |
| X axis | Y axis | Z axis | translation
indices: | 0 1 2 | 4 5 6 | 8 9 10 | 12 13 14
| 3 (pad) | 7 (pad) | 11 (pad) | 15 (= 1) || Method | Allocates? | Returns | Description |
|---|---|---|---|
| create() | ✅ once | Mat4 | New identity matrix via fast typed-array memcpy. |
| clone(a) | ✅ once | Mat4 | New independent copy of a. |
| copy(out, a) | no | out | Copies a into out. |
| identity(out) | no | out | Writes the identity matrix. |
| multiply(out, a, b) | no | out | a * b. Aliasing-safe. |
| invert(out, a) | no | out \| null | Full 4×4 inverse, or null if singular (det ≈ 0). |
| fromQuat(out, q) | no | out | Pure rotation matrix from quaternion. |
| compose(out, pos, rot, scale) | no | out | T * R * S from translation [x,y,z], quaternion [x,y,z,w], scale [x,y,z]. |
Notes
invert returns null on singular matrices. Always check the return value before using out. A common cause: a camera with zero scale, or a hierarchy where a parent has collapsed to zero size.
fromQuat assumes a unit quaternion. If q isn't normalized, the resulting matrix has scale. Normalize first if you're not certain — slerp and fromEuler already produce unit quaternions, so you only need to worry about hand-set inputs.
Aliasing rules
Every binary operation is safe when out aliases an input:
quat.multiply(a, a, b); // ✓ a *= b
mat4.multiply(world, world, local); // ✓ accumulate transform
quat.slerp(rot, rot, target, t); // ✓ interpolate in placeThe unrolled implementations read every input slot into a local before writing any output, so there's no read-after-write hazard. copy, normalize, conjugate, invert, fromQuat, and compose all support out === input for the same reason.
Composition order: why T * R * S
mat4.compose(out, pos, rot, scale) produces:
world = T * R * SApplied to a point p, this evaluates right-to-left: scale first, then rotate, then translate. That's the order you almost always want — local space p gets sized, oriented, then placed in the world.
flowchart LR
p["p (local)"] --> S["S — scale"]
S --> R["R — rotate"]
R --> T["T — translate"]
T --> world["p (world)"]
style p fill:#1e293b,color:#e2e8f0,stroke:#475569
style S fill:#334155,color:#e2e8f0,stroke:#475569
style R fill:#334155,color:#e2e8f0,stroke:#475569
style T fill:#334155,color:#e2e8f0,stroke:#475569
style world fill:#1e293b,color:#e2e8f0,stroke:#475569The parameter order (pos, rot, scale) matches three.js's Matrix4.compose, not gl-matrix's fromRotationTranslationScale(out, q, v, s). If you're porting from gl-matrix, the output matrix is the same — only the call-site argument order changes.
Recipes
Build a perspective projection
@zakkster/lite-math deliberately doesn't ship perspective — write it once, inline, and you'll have something tighter than any library call:
function perspective(out, fovYRad, aspect, near, far) {
const f = 1 / Math.tan(fovYRad / 2);
const nf = 1 / (near - far);
out[0] = f / aspect; out[1] = 0; out[2] = 0; out[3] = 0;
out[4] = 0; out[5] = f; out[6] = 0; out[7] = 0;
out[8] = 0; out[9] = 0; out[10] = (far + near) * nf; out[11] = -1;
out[12] = 0; out[13] = 0; out[14] = 2 * far * near * nf; out[15] = 0;
return out;
}MVP matrix from world + view + projection
const mvp = mat4.create();
mat4.multiply(mvp, projection, view); // VP
mat4.multiply(mvp, mvp, world); // VP * W = MVPSmooth rotation toward a target
const blendSpeed = 4; // higher = snappier
function update(dt) {
const t = 1 - Math.exp(-blendSpeed * dt); // framerate-independent damping
quat.slerp(rot, rot, targetRot, t);
}Inverse of a rigid transform
For a matrix that's only translation + rotation (no scale, no skew), you don't need the full invert — but if you're not 100% sure your matrix is rigid, just use mat4.invert. The cost is constant (~30 ns) and the code is one line.
Comparison with gl-matrix and three.js
| | @zakkster/lite-math | gl-matrix | three.js |
|---|---|---|---|
| Quat layout | [x, y, z, w] | [x, y, z, w] | { x, y, z, w } getters |
| Mat4 layout | column-major Float32Array | column-major Float32Array | column-major Float32Array |
| Compose call | compose(out, pos, rot, scale) | fromRotationTranslationScale(out, q, v, s) | m.compose(pos, rot, scale) |
| Compose math | T * R * S | T * R * S | T * R * S |
| Vec ops | inline yourself | vec2, vec3, vec4 namespaces | Vector3 class |
| Per-frame allocs | zero | zero | depends on method |
| Lines of code (mat4+quat) | ~440 | ~2000+ | ~1500+ |
| Class wrappers | no | optional (glMatrix.glMatrix) | yes (default API) |
If you need vector arithmetic, axis-angle constructors, dual quaternions, or mat3.normalFromMat4, use gl-matrix. If you need scene-graph ergonomics, use three.js. If you need just the TRS pipeline with the smallest possible footprint, this is it.
Testing
npm testRuns the suite under Node with --expose-gc:
- 50 unit tests across quat (identity / multiply / normalize / dot / conjugate / slerp / fromEuler) and mat4 (identity / multiply / invert / fromQuat / compose).
- Hamilton-product, shortest-arc slerp, near-parallel slerp fallback, singular-matrix
nullreturn. - Aliasing safety (
multiply(a, a, b),conjugate(q, q), etc.) on every binary op. - A 100,000-iteration hot loop running
slerp + compose + multiplyper iteration, verifying total heap growth stays under 512 KB (typical run: ~10 KB of V8 internal churn). If anything were allocating, you'd see megabytes.
License
MIT © Zahary Shinikchiev
