Math & geometry

Math & geometry

Vectors, matrices, quaternions, ray primitives, mesh topology, splines, noise, and robust geometric predicates in the engine's core math library.

The engine’s core math library lives under @woosh/meep-engine/src/core/geom/ and @woosh/meep-engine/src/core/math/. Every function in it follows the same design contract: results are written into a caller-supplied output parameter, never returned as new allocations. This keeps hot paths - physics, animation, rendering - allocation-free at steady state.

There are no wrapper classes for matrices. There is no Matrix4: a 4x4 matrix is a 16-element column-major array (number[], Float32Array or Float64Array), a 3x3 is nine elements, and every m4_* / m3_* function reads and writes those directly. Vector3, Quaternion and friends do exist as observable value objects, but the flat-array functions below never require them.

Naming and deprecated aliases

Function names follow a subject_verb / name_to_name convention: v3_dot, m4_invert, quat3_multiply. A number of alternative spellings ship as deprecated re-exports - v3_quat3_apply for v3_quaternion_apply, compose_matrix4_array for m4_compose, quat3_createFromAxisAngle for quat3_from_axis_angle, and so on. Every table below lists the canonical name; the deprecated file sits next to it and does nothing but re-export.

Vectors

Vectors are passed as loose scalars, not objects. Most functions take an output buffer plus an integer offset first, then the inputs as individual components.

Vec2

Utilities under core/geom/vec2/:

FunctionDescription
v2_dot(x0, y0, x1, y1)Dot product - returns a scalar
v2_length(x, y)Euclidean length
v2_length_sqr(x, y)Squared length (avoids a square root)
v2_distance(x0, y0, x1, y1)Distance between two points
v2_distance_sqr(x0, y0, x1, y1)Squared distance
v2_cross_product(x0, y0, x1, y1)2D cross product (scalar)
v2_angle_between(x0, y0, x1, y1)Angle between two vectors in radians
v2_rotate(out, out_offset, x, y, angle)Rotate a point about the origin
v2_matrix3_cm_multiply(out, out_offset, x, y, m3)Transform64 by a column-major 3x3 matrix

Vec3

Utilities under core/geom/vec3/:

FunctionDescription
v3_dot(x0, y0, z0, x1, y1, z1)Dot product
v3_cross(result, result_offset, ax, ay, az, bx, by, bz)Cross product
v3_length(x, y, z)Euclidean length
v3_length_sqr(x, y, z)Squared length
v3_distance(x0, y0, z0, x1, y1, z1) / v3_distance_sqr(…)Distance and squared distance
v3_lerp(result, ax, ay, az, bx, by, bz, fraction)Linear interpolation; writes through result.set(x, y, z)
v3_slerp(result, ax, ay, az, bx, by, bz, fraction)Spherical linear interpolation
v3_angle(x0, y0, z0, x1, y1, z1)Angle in radians (v3_angle_between is the deprecated alias)
v3_displace_in_direction(result, distance, ox, oy, oz, dx, dy, dz)Move a point along a direction; returns false for a zero-magnitude direction
v3_quaternion_apply(out, out_offset, vx, vy, vz, qx, qy, qz, qw)Rotate a vector by a unit quaternion
v3_quaternion_apply_inverse(out, out_offset, vx, vy, vz, qx, qy, qz, qw)Rotate by the conjugate (inverse rotation)
v3_matrix4_multiply(out, out_offset, input, input_offset, m4)Full 4x4 transform including translation
v3_matrix4_rotate_unit(out, out_offset, x, y, z, m4)Rotate a unit direction; handles non-uniform scale, output is normalised
v3_matrix4_rotate_normal(out, out_offset, x, y, z, m4)Rotate a surface normal (inverse-transpose semantics)

The out-parameter convention is uniform: the output buffer and an integer offset come first, followed by the inputs. For example:

import { v3_quaternion_apply } from "@woosh/meep-engine/src/core/geom/vec3/v3_quaternion_apply.js";

const out = new Float32Array(3);
// Rotate (0, 1, 0) by a unit quaternion stored as (qx, qy, qz, qw):
v3_quaternion_apply(out, 0, 0, 1, 0, qx, qy, qz, qw);

No intermediate object is created. out may be a plain array, a Float32Array, or any array-like.

Vec4

Minimal utilities under core/geom/vec4/:

FunctionDescription
v4_dot(x0, y0, z0, w0, x1, y1, z1, w1)Dot product
v4_length(x, y, z, w)Euclidean length
v4_length_sqr(x, y, z, w)Squared length
v4_matrix4_multiply(result, input, mat4)Multiply by a 4x4 matrix (v4_multiply_mat4 is the deprecated alias)

Matrices

Mat3

Column-major 3x3 utilities under core/geom/mat3/:

FunctionDescription
m3_multiply(r, a, b)Matrix product r = a x b
m3_invert(out, a)Invert a 3x3 matrix (m3_cm_invert is the deprecated alias)
m3_determinant(a, b, c, d, e, f, g, h, i)Determinant from nine loose scalars
m3_cm_compose_transform(result, tX, tY, sX, sY, cx, cy, angle)Build a 2D TRS matrix about a rotation centre
m3_cm_extract_rotation(m3)Read the rotation angle (a scalar) back out of a 2D transform
m3_rm_extract_scale(result, m3)Read the scale factors out of a row-major 2D transform
m3_make_rotation_TBN(output, forward, up_dir)Build an orthonormal basis from a forward and an up vector
m3_scale_columns_by_vec3(out, a, v)Scale each column by a vector (m3_multiply_vec3 is the deprecated alias - despite that name it does not transform a vector)

Mat4

Column-major 4x4 utilities under core/geom/3d/mat4/:

FunctionDescription
m4_multiply(out, a, b)Matrix product; returns out
m4_invert(out, input)General inverse
m4_transpose(out, m)Transpose; out may alias m. Also what carries a matrix between column-major and row-major
m4_compose(result, position, rotation, scale)Build T * R * S from {x,y,z} / {x,y,z,w} objects (compose_matrix4_array is the deprecated alias)
m4_decompose(mat4, position, rotation, scale)Decompose through the destinations’ setters, so change signals fire (decompose_matrix_4_array is the deprecated alias)
m4_decompose_array(mat4, ...)The same decomposition into flat arrays
m4_from_rotation_translation_scale_scalar(result, qx, qy, qz, qw, tx, ty, tz, sx, sy, sz)Compose from loose scalars
m4_make_translation(output, translation)Translation matrix from a 3-element array-like
m4_make_scale(output, scale)Scale matrix from a 3-element array-like
m4_make_rotation_x(output, angle) / _y / _zRight-handed rotation about one axis; compose several with m4_multiply
m4_rotation_translation(output, rotation_matrix, translation)Assemble from a 3x3 rotation and a 3-vector
m4_inverse_rotation_translation(output, rotation_matrix, translation)The inverse of the same, without a general invert
m4_extract_scale(out, out_offset, mat4)Extract scale factors
m4_linear_matrix3(out, m)The 3x3 linear part - carries a tangent direction; out and m must be distinct
m4_normal_matrix3(out, m)The inverse-transpose 3x3 - carries a normal (m4_compute_normal_matrix3 is the deprecated alias)
m4_look_at(out, eye, center, up)View matrix; up must not be parallel to center - eye
m4_perspective(out, fov_y, aspect, near, far)Right-handed perspective with a -1..1 clip depth, finite far
m4_orthographic_off_center_z0(output, left, right, bottom, top, z_near, z_far)Orthographic projection, depth mapped to 0..1
m4_transform_v3_buffer(source, source_offset, destination, destination_offset, vertex_count, mat4)Batch-transform packed positions (apply_mat4_transform_to_v3_array is the deprecated alias)
m4_transform_direction_v3_buffer(...)The same for directions, ignoring translation (apply_mat4_transform_to_direction_v3_array is the deprecated alias)
m4_rigidity_defect(mat4)How far the upper 3x3 is from orthonormal: 0 for a rigid motion, |s - 1| for a uniform scale s, the cosine of the shear angle for a shear. A test statistic to check against a tolerance before handing a matrix to something that reads it as a rotation, such as dual_quat_from_m4. A reflection reports 0 - check the determinant’s sign for handedness

m4_perspective is not the projection the renderer draws with. Shade’s cameras use an infinite reverse-Z with a 0..1 depth range, which is what WebGPU wants; m4_perspective exists for arithmetic that has to hold under either convention and for reading geometry authored against the OpenGL one.

Quaternions

Quaternions use (x, y, z, w) component order - w last - throughout. Utilities live under core/geom/3d/quaternion/.

FunctionDescription
quat3_from_axis_angle(axis, angle, result)Build a quaternion from an axis vector and an angle in radians (quat3_createFromAxisAngle is the deprecated alias)
quat3_multiply(out, out_offset, ax, ay, az, aw, bx, by, bz, bw)Hamilton product out = a ⊗ b; applies b first then a
quat3_nlerp(...)Normalised linear interpolation
quat3_integrate(...)Advance a rotation by an angular velocity over a timestep
quat3_to_matrix3(out, out_offset, qx, qy, qz, qw)Write a 3x3 rotation matrix (nine floats)

The quat3_multiply convention: to rotate a vector by q1 and then q2, compose q2 ⊗ q1.

import { quat3_multiply } from "@woosh/meep-engine/src/core/geom/3d/quaternion/quat3_multiply.js";

const out = [0, 0, 0, 0];
// Compose: apply q1 first, then q2.
quat3_multiply(out, 0, q2x, q2y, q2z, q2w, q1x, q1y, q1z, q1w);

Compact storage: quat3_encode_to_uint32(x, y, z, w) packs a unit quaternion into a single uint32 (the smallest-three encoding from Bungie’s Destiny animation talk) and quat3_decode_from_uint32(output, output_offset, value) unpacks it. quat_encode_to_uint32 / quat_decode_from_uint32 are the deprecated spellings.

Ray intersection primitives

Each primitive shape has a closed-form ray test under core/geom/3d/<shape>/. They share one signature - (outNormal, outOffset, ox, oy, oz, dx, dy, dz, tMax, …shape params) - and one convention:

  • the ray direction is assumed unit length, so the returned t is a true distance;
  • the first surface crossing at or after the origin within tMax is returned - a ray starting inside a solid returns its exit crossing;
  • on a miss the function returns Infinity and leaves the normal output untouched.
FunctionPathShape (in its own local frame)
box3_raycast3d/box/Box centred at the origin, from half-extents
sphere_raycast3d/sphere/Sphere centred at the origin
capsule_raycast3d/capsule/Y-aligned capsule
cylinder3_raycast3d/cylinder/Y-aligned solid cylinder with flat caps; requires radius > 0
tri3_raycast3d/triangle/Two-sided Möller-Trumbore triangle
point3_raycast3d/point/A point at the origin
convex_polyhedron3_raycast3d/polyhedron/Convex solid as face half-spaces, Cyrus-Beck clipping
import { cylinder3_raycast } from "@woosh/meep-engine/src/core/geom/3d/cylinder/cylinder3_raycast.js";

const normal = new Float64Array(3);
const t = cylinder3_raycast(
    normal, 0,
    ox, oy, oz,      // ray origin, in the cylinder's local frame
    dx, dy, dz,      // unit direction
    tMax,
    radius, half_height
);
// t === Infinity on a miss; otherwise normal[0..2] is the unit outward normal

Two conventions are worth spelling out. tri3_raycast orients its normal to face the ray - a triangle is a surface, not a solid, so its winding must not decide whether a ray bounces or passes through. convex_polyhedron3_raycast requires outward-facing plane normals, the opposite of the inward-wound convention the frustum tests use; feeding it an inward set reports misses.

Axis-aligned boxes are the exception to the shared shape: aabb3_raycast_hit_point(result, result_offset, x0, y0, z0, x1, y1, z1, ox, oy, oz, dx, dy, dz) takes the box as world-space min/max, writes a six-tuple of hit position and normal, and returns a boolean rather than a distance. (aabb3_raycast is its deprecated alias.) For a pure “does it hit” test with no hit data, see aabb3_intersects_ray in Spatial acceleration.

These are the primitives the physics narrowphase refines a broadphase candidate against - see Spatial queries.

Mesh topology

BinaryTopology is the engine’s editable mesh representation: an interconnected vertex / edge / loop / face structure, modelled on Blender’s BMesh, stored in contiguous binary element pools rather than as objects. A “loop” is one corner of a face, and it is what threads the radial cycles (all faces around an edge) and the face cycles (all corners of a face).

import { BinaryTopology } from
  "@woosh/meep-engine/src/core/geom/3d/topology/struct/binary/BinaryTopology.js";
import { bt_mesh_from_indexed_geometry } from
  "@woosh/meep-engine/src/core/geom/3d/topology/struct/binary/io/bt_mesh_from_indexed_geometry.js";
import { bt_mesh_to_indexed_geometry } from
  "@woosh/meep-engine/src/core/geom/3d/topology/struct/binary/io/bt_mesh_to_indexed_geometry.js";

const mesh = new BinaryTopology();

bt_mesh_from_indexed_geometry(mesh, indices, positions /*, normals */);

// … edit, query, simplify …

const { positions: out_positions, normals, indices: out_indices } =
    bt_mesh_to_indexed_geometry(mesh);

bt_mesh_from_indexed_geometry clears the destination first and expects triangles. bt_mesh_to_indexed_geometry is the inverse: it renumbers vertices into a dense range (pools have holes once anything is released), emits Uint32Array indices regardless of vertex count, and throws if any face is not a triangle - run bt_mesh_triangulate first. Every allocated vertex is emitted, including ones no face uses; call bt_mesh_cleanup_faceless_references first to drop those.

Everything else is free functions taking the topology as the first argument, in two families under the same directory:

FamilyDirectoryExamples
Editingstruct/binary/io/, plus io/edge/, io/face/, io/vertex/bt_mesh_triangulate, bt_mesh_resolve_t_junctions, bt_mesh_close_boundary_holes, bt_mesh_compact; edge/bt_edge_collapse, edge/bt_edge_split, edge/bt_edge_flip, edge/bt_mesh_fuse_duplicate_edges; face/bt_face_poke; vertex/bt_merge_vertices_by_distance
Querystruct/binary/query/bt_edge_is_manifold, bt_edge_is_boundary, bt_face_area, bt_face_read_triangle, bt_mesh_is_manifold, bt_mesh_compute_bounding_sphere, bt_mesh_compute_bounding_box, bt_mesh_build_face_bvh, bt_mesh_find_nearest_face, bt_mesh_walk_boundary_loops

BinaryTopology and the bt_* functions are the only mesh topology in the engine; there is no object-based half-edge mesh.

Simplification and quadrics

bt_mesh_simplify(mesh, target_face_count, restricted_vertices, protected_faces) collapses edges in place until the face count reaches the target, driven by the quadric error metric. It returns the object-space distance the surface moved, or 0 when no collapse was performed.

import { bt_mesh_simplify } from
  "@woosh/meep-engine/src/core/geom/3d/topology/struct/binary/io/bt_mesh_simplify.js";

const deviation = bt_mesh_simplify(mesh, 2000);

Both optional Set<number> arguments are guards: restricted_vertices names vertices that must not move or be removed, protected_faces names faces that must still exist when the call returns. Pinning vertices does not by itself protect the edges between them - a collapse elsewhere in the fan can take away the face carrying such an edge - so a caller that needs a specific polyline to survive protects the faces that carry it. bt_mesh_simplify_with_quadrics is the variant that takes a caller-owned quadric set, which is how deviation is measured against a mesh several passes back rather than against this call’s input.

The quadric itself lives at core/geom/3d/quadric/. Quadric3 extends Float64Array and holds the upper triangle of a symmetric 4x4 form plus an accumulated weight; the quadric3_* functions (quadric3_add, quadric3_sub, quadric3_scale, quadric3_evaluate, quadric3_optimize, quadric3_distance, quadric3_to_tensor_m3, …) read that same layout out of any array-like at any offset, so a caller holding many quadrics keeps them in one flat buffer instead of one object each.

quadric3_evaluate returns the sum of squared distances to every plane folded in, so its magnitude grows with how much history the quadric carries. quadric3_distance divides by the weight first and takes the root, which is the value that is an object-space length.

Catmull-Rom splines

computeCatmullRomSpline samples a centripetal (or uniform) Catmull-Rom spline through an arbitrary set of N-dimensional control points.

import { computeCatmullRomSpline } from
  "@woosh/meep-engine/src/core/math/spline/computeCatmullRomSpline.js";

const controlPoints = [0,0, 1,2, 3,1, 4,3]; // 4 points in 2D
const output = new Array(20 * 2);             // 20 samples × 2 dimensions

computeCatmullRomSpline(
    output,
    controlPoints, 4,   // input, point count
    2,                  // dimensions per point
    20,                 // number of output samples
    0.5                 // alpha (0 = uniform, 0.5 = centripetal, 1 = chordal)
);

The alpha parameter controls parameterisation: 0.5 (default) is centripetal and avoids cusps or self-intersections. A non-uniform variant computeNonuniformCatmullRomSplineSample lets you sample a single segment with explicit control points.

Related spline types with bounds and intersection tests are under the same directory: spline3_hermite, spline3_bezier, and their _bounds / _derivative / _integral companions.

Simplex and curl noise

Simplex noise

create_simplex_noise_2d returns a seeded 2D noise function with output in [-1, 1]:

import { create_simplex_noise_2d } from
  "@woosh/meep-engine/src/core/math/noise/create_simplex_noise_2d.js";

const noise = create_simplex_noise_2d(Math.random);
const v = noise(x, y); // -1..1

The underlying 3D gradient-noise primitive sdnoise3 (from sdnoise.js) returns both the noise value and its analytical spatial derivative - useful for domain-warping and normal-map generation without finite differences.

noise_octaves layers multiple octaves of any noise function:

import { noise_octaves } from
  "@woosh/meep-engine/src/core/math/noise/noise_octaves.js";

const value = noise_octaves(
    noise, null,           // noise function + `this` context for the call
    x, y, z,
    6,                     // octave count
    0.5,                   // persistence (amplitude falloff)
    2.0                    // lacunarity (frequency multiplier)
);

Curl noise

curl_noise_3d computes a divergence-free 3D vector field from three offset simplex noise samples. The result is written into a caller-supplied array:

import { curl_noise_3d } from
  "@woosh/meep-engine/src/core/math/noise/curl_noise_3d.js";

const result = [0, 0, 0];
curl_noise_3d(result, x, y, z);
// result[0], result[1], result[2] - divergence-free velocity

curl_noise_3dt adds a w (time) parameter for animated curl fields, displacing each noise sample along the time axis independently.

Robust geometric predicates

Two predicates delegate to the robust-predicates package for exact arithmetic, avoiding floating-point sign errors at degenerate configurations.

orient3d_robust(points, a, b, c, d) - tests whether point d is above, on, or below the plane defined by the counterclockwise triangle (a, b, c). points is a flat array of coordinates in stride-3 format ([x0, y0, z0, x1, y1, z1, ...]). Returns positive if d is above, negative if below, zero if coplanar.

in_sphere3d_robust(points, a, b, c, d, e) - tests whether point e is inside, on, or outside the circumsphere of the positively oriented tetrahedron (a, b, c, d). Returns positive if inside.

A fast (non-robust) variant orient3d_fast is available in the same directory for contexts where degenerate cases cannot occur.

import { orient3d_robust } from
  "@woosh/meep-engine/src/core/geom/3d/plane/orient3d_robust.js";
import { in_sphere3d_robust } from
  "@woosh/meep-engine/src/core/geom/3d/sphere/in_sphere3d_robust.js";

const pts = [/* flat x,y,z per point */];
const side  = orient3d_robust(pts, 0, 1, 2, 3);   // sign tells which side
const inside = in_sphere3d_robust(pts, 0, 1, 2, 3, 4); // sign tells in/out

These are used internally by the Delaunay/tetrahedral mesh code and the physics narrowphase.

Where to go next

  • Spatial acceleration - the BVH and ray/frustum queries that consume these primitives.
  • Color management - the color-space conversions built alongside this library.
  • Spatial queries - physics raycasts and shape casts built on the BVH and the ray primitives above.
  • Navigation meshes - BinaryTopology in anger: the navmesh is built, welded and simplified with the bt_* family.