math
Write data-oriented TypeScript on top of the npm math package: plain data, free functions, no classes, no allocation in hot paths.
API docs are in API.md — every export with its signature, grouped by module and flat enough to grep. Find it at node_modules/math/API.md in a consuming project, or at the repo root when working on math itself.
Types
Every type is a plain fixed-length tuple of numbers — no classes, no wrappers, and not a typed array:
Vec2 [x, y], Vec3 [x, y, z], Vec4 [x, y, z, w]
Quat [x, y, z, w], Quat2 [x, y, z, w, x2, y2, z2, w2]
Euler [x, y, z, order?], radians, order defaulting to 'xyz'
Mat2 (4), Mat2d (6), Mat3 (9), Mat4 (16) — contiguous and column-major, with Mat4 translation in m[12], m[13], m[14]
Polar [r, theta], Spherical [r, theta, phi]
Style
- Functions over data. Export
function declarations that take typed data and operate on it. Never classes for data, never closures that hold state. The one exception is a small fixed set of classes implementing a single structural type — a collector handed to a query to receive its hits, say — where the call site is polymorphic and a stable hidden class keeps it fast.
out first, return out for composite results: fn(out: Vec3, a: Vec3, b: Vec3): Vec3. Scalars and booleans return directly.
- Use result objects and
out params over returning new objects. When a result doesn't fit a vector, define a result type with a createXResult() factory beside it; report failure with a boolean or status enum rather than out | null.
- Assume the caller aliases — the same array may arrive as both
out and an input, as in vec3.normalize(v, v) or vec3.cross(a, a, b). Read every input component into a local before the first write to out, so a write can't clobber an input still needed.
- Caller-owned state. Long-lived state is plain data the caller allocates and owns. Functions receive it, mutate it in place, and return it. The library never owns the data lifecycle, so allocation happens once, ownership is explicit, and the object keeps one stable shape. Naming and file layout are up to the codebase. One common shape:
export function createWorld(capacity: number) {
return { capacity, count: 0, positions: new Float32Array(capacity * 3) };
}
export type World = ReturnType<typeof createWorld>;
export function stepWorld(world: World, delta: number): World { /* mutate, return world */ }
export function getWorldPosition(out: Vec3, world: World, i: number): Vec3 { /* write out, return it */ }
- Monomorphic state. Build the object with the same keys in the same order every time. No optional fields, no keys added later.
- Allocate at creation, never per call. Preallocate flat or typed arrays to capacity. When full, return a count, sentinel, or status rather than growing inside a hot loop.
- Compose
math primitives (vec3, mat4, quat, and the math/shapes, math/geometry, math/noise, math/random, math/time subpaths).
- Hoist invariants out of loops
Gotchas
- Module-level scratch is named
_owner_purpose. Grow-once buffers carry an explicit size counter rather than push/pop/length = 0.
- Module-level scratch variables are not reentrant. Pass caller-owned workspace for recursive, nested, or worker code.
- One epsilon does not fit every operation or scale. Choose each tolerance and say why.
- Define behavior for empty input, zero-length vectors, degenerate geometry, NaN, and exact boundary contact.
- Compare squared distances; reach for
squaredLength / squaredDistance over their square-rooted pairs.
- Integers in the range [-2^30, 2^30) are stored in the pointer itself (V8 Smi), with no heap object. Use them for indices, handles, packed IDs, bitmasks, and counts.
- Where possible avoid plain array element kind transitions (small integers, doubles, array with holes). Especially avoid unnecessary SMI or PACKED_DOUBLE to holey transitions when holey arrays are not desired.
- Don't assume a typed array is faster. A packed plain array is already unboxed and can still grow. Typed arrays buy footprint, a fixed layout, and zero-copy interop with workers, Wasm, and the GPU; they cost a capacity fixed up front and, for
Float32Array, a narrowing conversion on every write. Choose one for interop or memory, not on a hunch about speed.
- In a library, annotate module-level factory calls
/* @__PURE__ */ so a consumer's bundler can drop the scratch when the function is tree-shaken out. Application code does not need it.
Deliver the implementation with its assumptions, complexity, edge cases, and focused tests.
Working with other libs
Marshal in, compute, marshal out — and allocate on neither crossing. Keep the scratch math types at module scope, fill them from the other library's values, run the algorithm as plain math calls, then write the results back. The seam is a few lines at each end of a function; everything between them is flat data.
- A
Float32Array is not a Vec3. The tuple types don't accept one, and casting past that gets you a value the rest of the codebase can't rely on. Marshal across the boundary instead.
- Marshal with whatever writes into memory you already own. For flat buffers — an instanced attribute, a packed particle array — that's
vec3.fromBuffer(out, buffer, i * 3) and vec3.toBuffer(buffer, v, i * 3) (also on vec2, vec4, quat), or buffer.set(m, i * 16) for a whole matrix, since TypedArray.set takes any array-like.
- State that crosses a worker, Wasm, or GPU boundary lives in a typed array from the start. A plain-array
Vec3 can't be transferred or shared, so back the long-lived data with Float32Array / SharedArrayBuffer at creation and marshal at the edges.
three.js
Vector3, Quaternion, Matrix4, and Euler all marshal through toArray(target) and fromArray(source), and the component order matches math's in every case. Always pass your scratch to toArray — called bare, it allocates a fresh array.
Orbit camera. The camera's state is a Spherical and a target the caller owns; three only ever sees the resulting position.
import { spherical, vec3 } from 'math';
import type { Camera } from 'three';
const MIN_RADIUS = 1;
const MAX_RADIUS = 100;
export function createOrbit() {
return { target: vec3.create(), orbit: spherical.fromValues(10, 0, Math.PI / 3) };
}
export type Orbit = ReturnType<typeof createOrbit>;
const _orbit_position = vec3.create();
/** Apply a drag in radians and a zoom factor, then place the camera. */
export function updateOrbit(camera: Camera, orbit: Orbit, dragX: number, dragY: number, zoom: number): void {
const s = orbit.orbit;
s[0] = Math.min(MAX_RADIUS, Math.max(MIN_RADIUS, s[0] * zoom));
s[1] -= dragX;
s[2] -= dragY;
spherical.makeSafe(s, s); // keeps phi off the poles, where the frame degenerates
vec3.add(_orbit_position, spherical.toVec3(_orbit_position, s), orbit.target);
camera.position.fromArray(_orbit_position);
camera.lookAt(orbit.target[0], orbit.target[1], orbit.target[2]);
}
Camera-relative character move. Takes the yaw straight from that orbit state, so stick-forward means away-from-camera.
import { quat, vec3, type Vec3 } from 'math';
import type { Object3D } from 'three';
const UP: Vec3 = [0, 1, 0];
const TURN_RATE = 15;
const DEADZONE_SQ = 0.01;
const _move_yaw = quat.create();
const _move_facing = quat.create();
const _move_rotation = quat.create();
const _move_position = vec3.create();
const _move_direction = vec3.create();
/** Move `character` by `inputX` / `inputZ` (a stick, in [-1, 1]) relative to a camera at `yaw`. */
export function moveCharacter(character: Object3D, inputX: number, inputZ: number, yaw: number, speed: number, delta: number): void {
vec3.set(_move_direction, inputX, 0, inputZ);
if (vec3.squaredLength(_move_direction) < DEADZONE_SQ) return; // idle: leave the facing alone
// swing the stick into camera space, then step along it
quat.setAxisAngle(_move_yaw, UP, yaw);
vec3.transformQuat(_move_direction, _move_direction, _move_yaw);
vec3.normalize(_move_direction, _move_direction);
character.position.toArray(_move_position);
vec3.scaleAndAdd(_move_position, _move_position, _move_direction, speed * delta);
// turn toward travel, rather than snapping
character.quaternion.toArray(_move_rotation);
quat.setAxisAngle(_move_facing, UP, Math.atan2(_move_direction[0], _move_direction[2]));
quat.slerp(_move_rotation, _move_rotation, _move_facing, 1 - TURN_RATE ** -delta);
character.position.fromArray(_move_position);
character.quaternion.fromArray(_move_rotation);
}
1---2name: math3description: Use when writing or reviewing geometry, simulation, collision, navigation, culling, procedural generation, transforms, or other performance-sensitive algorithms with the npm `math` package, or when the user invokes /math. Not for routine arithmetic or textbook explanations.4---56# math78Write data-oriented TypeScript on top of the npm `math` package: plain data, free functions, no classes, no allocation in hot paths.910API docs are in `API.md` — every export with its signature, grouped by module and flat enough to grep. Find it at `node_modules/math/API.md` in a consuming project, or at the repo root when working on `math` itself.1112## Types1314Every type is a plain fixed-length tuple of numbers — no classes, no wrappers, and not a typed array:1516- `Vec2` `[x, y]`, `Vec3` `[x, y, z]`, `Vec4` `[x, y, z, w]`17- `Quat` `[x, y, z, w]`, `Quat2` `[x, y, z, w, x2, y2, z2, w2]`18- `Euler` `[x, y, z, order?]`, radians, order defaulting to `'xyz'`19- `Mat2` (4), `Mat2d` (6), `Mat3` (9), `Mat4` (16) — contiguous and column-major, with `Mat4` translation in `m[12]`, `m[13]`, `m[14]`20- `Polar` `[r, theta]`, `Spherical` `[r, theta, phi]`2122## Style2324- **Functions over data.** Export `function` declarations that take typed data and operate on it. Never classes for data, never closures that hold state. The one exception is a small fixed set of classes implementing a single structural type — a collector handed to a query to receive its hits, say — where the call site is polymorphic and a stable hidden class keeps it fast.25- **`out` first, return `out`** for composite results: `fn(out: Vec3, a: Vec3, b: Vec3): Vec3`. Scalars and booleans return directly.26- **Use result objects and `out` params over returning new objects.** When a result doesn't fit a vector, define a result type with a `createXResult()` factory beside it; report failure with a boolean or status enum rather than `out | null`.27- **Assume the caller aliases** — the same array may arrive as both `out` and an input, as in `vec3.normalize(v, v)` or `vec3.cross(a, a, b)`. Read every input component into a local before the first write to `out`, so a write can't clobber an input still needed.28- **Caller-owned state.** Long-lived state is plain data the caller allocates and owns. Functions receive it, mutate it in place, and return it. The library never owns the data lifecycle, so allocation happens once, ownership is explicit, and the object keeps one stable shape. Naming and file layout are up to the codebase. One common shape:2930```ts31export function createWorld(capacity: number) {32 return { capacity, count: 0, positions: new Float32Array(capacity * 3) };33}34export type World = ReturnType<typeof createWorld>;3536export function stepWorld(world: World, delta: number): World { /* mutate, return world */ }37export function getWorldPosition(out: Vec3, world: World, i: number): Vec3 { /* write out, return it */ }38```3940- **Monomorphic state.** Build the object with the same keys in the same order every time. No optional fields, no keys added later.41- **Allocate at creation, never per call.** Preallocate flat or typed arrays to capacity. When full, return a count, sentinel, or status rather than growing inside a hot loop.42- **Compose `math` primitives** (`vec3`, `mat4`, `quat`, and the `math/shapes`, `math/geometry`, `math/noise`, `math/random`, `math/time` subpaths).43- **Hoist invariants out of loops**4445## Gotchas4647- Module-level scratch is named `_owner_purpose`. Grow-once buffers carry an explicit size counter rather than `push`/`pop`/`length = 0`.48- Module-level scratch variables are not reentrant. Pass caller-owned workspace for recursive, nested, or worker code.49- One epsilon does not fit every operation or scale. Choose each tolerance and say why.50- Define behavior for empty input, zero-length vectors, degenerate geometry, NaN, and exact boundary contact.51- Compare squared distances; reach for `squaredLength` / `squaredDistance` over their square-rooted pairs.52- Integers in the range [-2^30, 2^30) are stored in the pointer itself (V8 Smi), with no heap object. Use them for indices, handles, packed IDs, bitmasks, and counts.53- Where possible avoid plain array element kind transitions (small integers, doubles, array with holes). Especially avoid unnecessary SMI or PACKED_DOUBLE to holey transitions when holey arrays are not desired.54- Don't assume a typed array is faster. A packed plain array is already unboxed and can still grow. Typed arrays buy footprint, a fixed layout, and zero-copy interop with workers, Wasm, and the GPU; they cost a capacity fixed up front and, for `Float32Array`, a narrowing conversion on every write. Choose one for interop or memory, not on a hunch about speed.55- In a **library**, annotate module-level factory calls `/* @__PURE__ */` so a consumer's bundler can drop the scratch when the function is tree-shaken out. Application code does not need it.5657Deliver the implementation with its assumptions, complexity, edge cases, and focused tests.5859## Working with other libs6061Marshal in, compute, marshal out — and allocate on neither crossing. Keep the scratch `math` types at module scope, fill them from the other library's values, run the algorithm as plain `math` calls, then write the results back. The seam is a few lines at each end of a function; everything between them is flat data.6263- **A `Float32Array` is not a `Vec3`.** The tuple types don't accept one, and casting past that gets you a value the rest of the codebase can't rely on. Marshal across the boundary instead.64- **Marshal with whatever writes into memory you already own.** For flat buffers — an instanced attribute, a packed particle array — that's `vec3.fromBuffer(out, buffer, i * 3)` and `vec3.toBuffer(buffer, v, i * 3)` (also on `vec2`, `vec4`, `quat`), or `buffer.set(m, i * 16)` for a whole matrix, since `TypedArray.set` takes any array-like.65- **State that crosses a worker, Wasm, or GPU boundary lives in a typed array from the start.** A plain-array `Vec3` can't be transferred or shared, so back the long-lived data with `Float32Array` / `SharedArrayBuffer` at creation and marshal at the edges.6667### three.js6869`Vector3`, `Quaternion`, `Matrix4`, and `Euler` all marshal through `toArray(target)` and `fromArray(source)`, and the component order matches `math`'s in every case. Always pass your scratch to `toArray` — called bare, it allocates a fresh array.7071**Orbit camera.** The camera's state is a `Spherical` and a target the caller owns; three only ever sees the resulting position.7273```ts74import { spherical, vec3 } from 'math';75import type { Camera } from 'three';7677const MIN_RADIUS = 1;78const MAX_RADIUS = 100;7980export function createOrbit() {81 return { target: vec3.create(), orbit: spherical.fromValues(10, 0, Math.PI / 3) };82}83export type Orbit = ReturnType<typeof createOrbit>;8485const _orbit_position = vec3.create();8687/** Apply a drag in radians and a zoom factor, then place the camera. */88export function updateOrbit(camera: Camera, orbit: Orbit, dragX: number, dragY: number, zoom: number): void {89 const s = orbit.orbit;9091 s[0] = Math.min(MAX_RADIUS, Math.max(MIN_RADIUS, s[0] * zoom));92 s[1] -= dragX;93 s[2] -= dragY;94 spherical.makeSafe(s, s); // keeps phi off the poles, where the frame degenerates9596 vec3.add(_orbit_position, spherical.toVec3(_orbit_position, s), orbit.target);9798 camera.position.fromArray(_orbit_position);99 camera.lookAt(orbit.target[0], orbit.target[1], orbit.target[2]);100}101```102103**Camera-relative character move.** Takes the yaw straight from that orbit state, so stick-forward means away-from-camera.104105```ts106import { quat, vec3, type Vec3 } from 'math';107import type { Object3D } from 'three';108109const UP: Vec3 = [0, 1, 0];110const TURN_RATE = 15;111const DEADZONE_SQ = 0.01;112113const _move_yaw = quat.create();114const _move_facing = quat.create();115const _move_rotation = quat.create();116const _move_position = vec3.create();117const _move_direction = vec3.create();118119/** Move `character` by `inputX` / `inputZ` (a stick, in [-1, 1]) relative to a camera at `yaw`. */120export function moveCharacter(character: Object3D, inputX: number, inputZ: number, yaw: number, speed: number, delta: number): void {121 vec3.set(_move_direction, inputX, 0, inputZ);122 if (vec3.squaredLength(_move_direction) < DEADZONE_SQ) return; // idle: leave the facing alone123124 // swing the stick into camera space, then step along it125 quat.setAxisAngle(_move_yaw, UP, yaw);126 vec3.transformQuat(_move_direction, _move_direction, _move_yaw);127 vec3.normalize(_move_direction, _move_direction);128129 character.position.toArray(_move_position);130 vec3.scaleAndAdd(_move_position, _move_position, _move_direction, speed * delta);131132 // turn toward travel, rather than snapping133 character.quaternion.toArray(_move_rotation);134 quat.setAxisAngle(_move_facing, UP, Math.atan2(_move_direction[0], _move_direction[2]));135 quat.slerp(_move_rotation, _move_rotation, _move_facing, 1 - TURN_RATE ** -delta);136137 character.position.fromArray(_move_position);138 character.quaternion.fromArray(_move_rotation);139}140```