math3d

tape
import math3d from "math3d";

// a model 3 units in front of a camera, turned 30 degrees, drawn in perspective
let model = math3d.mat4_trs(math3d.vec3(0.0, 0.0, -3.0),
                            math3d.quat_from_axis_angle(math3d.vec3(0.0, 1.0, 0.0), math3d.radians(30.0)),
                            math3d.splat3(1.0));
let eye_view = math3d.mat4_look_at(math3d.vec3(0.0, 1.0, 2.0), math3d.splat3(0.0), math3d.vec3(0.0, 1.0, 0.0));
let proj = math3d.mat4_perspective(math3d.radians(60.0), 16.0 / 9.0, 0.1, 100.0);
let mvp = proj * eye_view * model;                       // a Mat4 a gpu uniform takes as it is
let clip = mvp * math3d.vec4(0.5, 0.5, 0.5, 1.0);

math3d is pure and freestanding: it allocates nothing and keeps no state, so every profile can use it, kernel code included. Everything is f32. The same types are gpu’s: gpu.Vec3 is math3d.Vec3, so what the CPU computes goes to a shader with nothing converted.

The conventions are the GPU’s:

  • right-handed, y up, a camera looking down -z;
  • column vectors: m * v, so a * b does b first (quaternions too);
  • matrices stored column by column, c0 to c3, as GLSL and MSL keep them (a glTF matrix’s 16 floats copy straight in);
  • angles in radians (radians(deg), degrees(rad));
  • clip space with y up and depth from 0 (near) to 1 (far).

Vectors

Vec2, Vec3, Vec4 (f32), IVec2..IVec4 (i32) and UVec2..UVec4 (u32): plain structs with x, y, z, w.

tape
let a = math3d.vec3(1.0, 2.0, 3.0);          // vec2, vec4, ivec3, uvec2, ...
let b = math3d.splat3(2.0);                  // (2, 2, 2); splat2, isplat4, usplat3, ...
let c = a + b * 0.5 - math3d.vec3(1.0, 0.0, 0.0);   // (1, 3, 4)
let d = a.dot(b);                            // 12
let n = a.cross(math3d.vec3(0.0, 0.0, 1.0)); // (2, -1, 0)
let xy = math3d.vec4(1.0, 2.0, 3.0, 4.0).xy; // (1, 2); .xyz, .zyx, .yx ...
let i = math3d.ivec2(7, 3) % math3d.isplat2(4);   // (3, 3)
On a vectorGives
+ - * / with a vector or a lane value (either side), unary -, % (integers)Lane by lane
==Whether every lane is equal
v.min(o), v.max(o), v.clamp(lo, hi), v.abs()Lane by lane
v.floor(), ceil(), round(), fract(), sign()Lane by lane (f32)
v.to_i(), to_u(), to_f()The other lane types, as on each lane
v.dot(o), v.length(), v.length_sq()The dot product; the length; its square (no root)
v.normalize()v at length 1 (a zero vector gives NaN)
v.distance(o), v.distance_sq(o)How far apart; its square
v.mix(o, t)v + (o - v) * t
v.reflect(n)v mirrored in the plane with unit normal n (GLSL’s reflect)
v.project(onto)The part of v along onto
v.approx_eq(o, eps)Whether every lane is within eps
v.step(edge), v.smoothstep(e0, e1)GLSL’s, lane by lane
v.ddx(), v.ddy(), v.fwidth()In a pixel stage, how it changes across pixels (#shader); on the CPU they stop the program
a.cross(b) (Vec3)The cross product
a.cross(b) (Vec2)The 2D cross product, a scalar: positive when b is counterclockwise of a
v.perp() (Vec2)v turned a quarter counterclockwise: (-y, x)
a.angle(b) (Vec2, Vec3)The angle between them, 0..pi
v.extend(z) (Vec2), v.extend(w) (Vec3)The vector one longer
tape
let r = math3d.vec3(1.0, -1.0, 0.0).reflect(math3d.vec3(0.0, 1.0, 0.0));   // (1, 1, 0)
let p = math3d.vec3(2.0, 3.0, 0.0).project(math3d.vec3(1.0, 0.0, 0.0));    // (2, 0, 0)
let t = math3d.vec2(3.0, 4.0).perp();                                      // (-4, 3)
let k = math3d.vec2(1.0, 0.0).cross(math3d.vec2(0.0, 1.0));                // 1
let q = math3d.vec3(1.0, 0.0, 0.0).angle(math3d.vec3(0.0, 1.0, 0.0));      // pi / 2
let near = math3d.vec3(1.0, 2.0, 3.0).approx_eq(math3d.vec3(1.0, 2.0, 3.000001), 1e-5);   // true
let h = math3d.vec2(1.0, 2.0).extend(0.0).extend(1.0);                     // (1, 2, 0, 1)

Vectors, matrices and quaternions take compound assignment wherever their operator gives back the same type:

tape
pos += vel * dt;                  // Vec3 + (Vec3 * f32)
color *= 0.5;
model *= math3d.mat4_translate(step);     // Mat4 * Mat4
orientation *= turn;                      // Quat * Quat

Matrices

Mat4 { c0; c1; c2; c3 } holds four Vec4 columns; Mat3 { c0; c1; c2 } three Vec3 columns.

FunctionGives
mat4_identity(), mat3_identity()The matrix that changes nothing
m * v, m * nA vector transformed; the product (n first)
m == n, m.approx_eq(n, eps)Exactly equal; every entry within eps
m.transpose(), m.determinant()Rows and columns swapped; the determinant
m.inverse() -> ?Mat4 (?Mat3)The matrix that undoes m; null when it squashes space flat (abs(det) < SINGULAR_EPSILON)
m.transform_point(p)A Vec3 point moved by a transform (w = 1)
m.transform_vector(v)A Vec3 direction turned and scaled, not moved (w = 0)
m.project_point(p)A point through a projection, divided by w
m.normal_matrix()The Mat3 normals transform by (the inverse transpose of the upper left 3x3)
m.to_mat3(), m3.to_mat4()The upper left 3x3; a 3x3 in a 4x4
tape
let m = math3d.mat4_translate(math3d.vec3(1.0, 2.0, 3.0)) * math3d.mat4_scale(math3d.vec3(2.0, 2.0, 2.0));
let p = m.transform_point(math3d.vec3(1.0, 1.0, 1.0));     // (3, 4, 5): scaled, then moved
let v = m.transform_vector(math3d.vec3(1.0, 1.0, 1.0));    // (2, 2, 2): not moved
let back = m.inverse() or { return; };                     // null only for a flat matrix
let same = (m * back).approx_eq(math3d.mat4_identity(), 1e-6);
let det = m.determinant();                                 // 8
let nm = m.normal_matrix();                                // a Mat3 for the shader's normals
let flat = math3d.mat4_scale(math3d.vec3(1.0, 0.0, 1.0)).inverse();   // null

Transforms

FunctionGives
mat4_translate(t), mat4_scale(s)A move; a scale about the origin
mat4_rotate_x(a), mat4_rotate_y(a), mat4_rotate_z(a)A turn about an axis, counterclockwise seen from its positive end
mat4_rotate_axis(axis, a)A turn about any axis
mat4_trs(t, r, s)Scale by s, turn by the quaternion r, move by t (a glTF node’s transform)
Trs { t; r; s; }.to_mat4()The same from a Trs
m.decompose_trs()The Trs a transform is made of
tape
let spin = math3d.mat4_rotate_z(math3d.PI / 2.0);
let up = spin.transform_vector(math3d.vec3(1.0, 0.0, 0.0));   // (0, 1, 0), to rounding
let tilt = math3d.mat4_rotate_axis(math3d.vec3(1.0, 1.0, 0.0), 0.5);
let node = math3d.mat4_trs(math3d.vec3(0.0, 1.0, 0.0), math3d.quat_identity(), math3d.splat3(2.0));
let parts = node.decompose_trs();                             // t (0, 1, 0), r identity, s (2, 2, 2)
let again = parts.to_mat4();

decompose_trs expects a transform without shear (what mat4_trs and products of turns, moves and positive scales make). A mirror comes back as a negative s.x; a zero scale leaves no rotation to find, and r is the identity.

Cameras

FunctionGives
mat4_look_at(eye, target, up)The view matrix of a camera at eye looking at target
mat4_perspective(fov_y, aspect, near, far)A perspective projection: vertical field of view, width / height, the near and far distances
mat4_ortho_3d(left, right, bottom, top, near, far)An orthographic projection of a box in front of the camera
mat4_ortho(left, right, bottom, top)The 2D one: pixels to clip space, z kept
tape
let eye_view = math3d.mat4_look_at(math3d.vec3(0.0, 0.0, 5.0), math3d.splat3(0.0), math3d.vec3(0.0, 1.0, 0.0));
// the same as mat4_translate(vec3(0, 0, -5)): the camera looks down -z
let proj = math3d.mat4_perspective(math3d.PI / 2.0, 2.0, 1.0, 101.0);
let at_near = proj.project_point(math3d.vec3(0.0, 0.0, -1.0));    // depth 0
let at_far = proj.project_point(math3d.vec3(0.0, 0.0, -101.0));   // depth 1
let box = math3d.mat4_ortho_3d(-2.0, 2.0, -1.0, 1.0, 1.0, 11.0);
let pixels = math3d.mat4_ortho(0.0, 800.0, 0.0, 600.0);

Depth runs from 0 at near to 1 at far, and y points up in clip space: the convention gpu gives on every backend.

Quaternions

A Quat { x; y; z; w; } is a rotation: x, y, z the vector part, w the scalar (glTF’s order). Keep them at length 1; q and -q are the same rotation.

FunctionGives
quat(x, y, z, w), quat_identity()A quaternion from its parts; no turn
quat_from_axis_angle(axis, a)A turn of a radians about axis
quat_from_euler(x, y, z)Pitch, yaw, roll about x, y, z, applied z first, then x, then y
quat_from_mat3(m), quat_from_mat4(m)The rotation of a rotation matrix (no scale)
quat_from_to(a, b)The shortest turn from direction a to direction b
quat_look_rotation(forward, up)The rotation that points -z along forward, y toward up
a * bb, then a
q * v, q.rotate(v)A Vec3 turned
q.to_mat3(), q.to_mat4()The rotation matrix
q.conjugate(), q.inverse()The opposite turn (unit q); the inverse of any non-zero q
q.normalize(), q.length(), a.dot(b), -qLength 1; the length; the dot product; every part negated
a.slerp(b, t)The constant-speed turn from a (t = 0) to b (t = 1), the shorter way
a.nlerp(b, t)The normalized straight blend, the shorter way: cheaper, not constant speed
a == b, a.approx_eq(b, eps)Exactly equal; every part within eps
tape
let yaw = math3d.quat_from_axis_angle(math3d.vec3(0.0, 1.0, 0.0), math3d.radians(90.0));
let fwd = yaw.rotate(math3d.vec3(0.0, 0.0, -1.0));          // (-1, 0, 0) to rounding: turned left
let both = yaw * math3d.quat_from_euler(0.2, 0.0, 0.0);      // pitch, then yaw
let half = math3d.quat_identity().slerp(yaw, 0.5);           // 45 degrees about y
let quick = math3d.quat_identity().nlerp(yaw, 0.5);
let aim = math3d.quat_from_to(math3d.vec3(1.0, 0.0, 0.0), math3d.vec3(0.0, 1.0, 0.0));
let cam = math3d.quat_look_rotation(math3d.vec3(0.0, 0.0, -1.0), math3d.vec3(0.0, 1.0, 0.0));   // identity
let m = yaw.to_mat4();
let from_m = math3d.quat_from_mat4(m);                       // yaw again (or -yaw)
let undo = yaw.conjugate();
let same = (yaw * undo).approx_eq(math3d.quat_identity(), 1e-6);

slerp gives a and b exactly at t = 0 and 1. When a.dot(b) is negative it blends toward -b, the same rotation the short way round, so t = 1 gives -b.

Scalar functions

FunctionGives
PI, TAU, radians(deg), degrees(rad)The constants in f32; angle conversion
approx_eq(a, b, eps)abs(a - b) <= eps
abs, sign, floor, ceil, round, fract, sqrt, inverse_sqrtOn an f32; round takes a half away from zero (round(2.5) is 3.0, round(-0.25) is -0.0), as math.round_f32 does
min, max, clamp(x, lo, hi), mix(a, b, t), step(edge, x), smoothstep(e0, e1, x), fmod(x, y)GLSL’s, on f32 (fmod is GLSL’s mod)
sin, cos, tan, asin, acos, atan, atan2(y, x), pow, exp, logmath’s f32 functions
min_i, max_i, clamp_i, abs_i, min_u, max_u, clamp_uOn i32 and u32
tape
let a = math3d.radians(180.0);                  // PI
let s = math3d.smoothstep(0.0, 1.0, 0.5);        // 0.5
let w = math3d.fmod(-1.0, 3.0);                  // 2
let ok = math3d.approx_eq(math3d.sin(math3d.PI), 0.0, 1e-6);
let n = math3d.clamp_i(12, 0, 10);               // 10

On the GPU

The vectors and Mat4 cross to a GPU buffer as they are: each has the size and alignment GLSL’s std430 and Metal give it (a Vec3 is 16 bytes, a Mat3 three 16-byte columns, a Quat a Vec4’s 16). A Vec3 in a struct that goes to the GPU is an error there (std430 packs a scalar after it); use a Vec4.

In a #shader stage, the vector operations and methods gpu lists, Mat4 * Vec4, mat4_identity, mat4_ortho and the scalar functions compile to the GPU, written through math3d or gpu. The rest (inverses, projections, quaternions) is CPU code: compute the matrices in the program and pass them as uniforms.

Last modified: