Math - SethKitchen/ThreeMojo GitHub Wiki
Math
math/vector2.mojo, math/vector3.mojo, math/vector4.mojo, math/matrix3.mojo, math/matrix4.mojo, math/bounds.mojo, math/frustum.mojo, math/ray.mojo, math/projection.mojo, math/triangle.mojo, math/spherical.mojo, math/matrix2.mojo and math/utils.mojo. Ported from three.js with the same conventions.

three.js: Vector2, Vector3, Vector4, Quaternion, Matrix3, Matrix4, Matrix4.makePerspective, makeOrthographic, lookAt, Box3, Sphere, Plane, Frustum, FrustumArray, Ray, Triangle, Line3, Spherical, Cylindrical, Matrix2, Box2, MathUtils.
The three.js math API
Each class has the members of three.js's src/math class of the same name. A three.js name in camel case is a Mojo name in snake case: distanceTo is distance_to. The tables below list the members. The numbers match three.js 0.180 to Float32 precision. The tests check them against three.js run in node.
Some three.js members have a different form here:
- A setter that builds a value from another value is a static constructor.
setFromMatrixPositionisVector3.from_matrix_position(m).setFromCenterAndSizeisBox3.from_center_and_size(c, s). - A
Matrix4builder is a free function, astranslationis:compose,rotation_axis,shear,basis,rotation_from_quaternionandrotation_from_euler. equalsis==.multiplyMatricesandmultiplyQuaternionsare*.divideScalaris/.- A random member takes a
SeededRandom. A seed gives the same numbers as three.js'sseededRandom. setFromSphericalandsetFromCylindricalareSpherical.to_vector3()andCylindrical.to_vector3().- A member that reads a scene is in
core.object_bounds:Box3.setFromObject,expandByObject,Frustum.intersectsObjectandintersectsSprite. See Bounds of scene content. ColorisFloatColor. See Render target and framebuffer.
A question with no answer raises, where three.js returns NaN, an infinity or a zero vector. Examples are decompose of a flat matrix, get_parameter of a flat box and the nearest point of an empty sphere. Each table says where.
These three.js members are not ported:
fromArray,toArrayandfromBufferAttributeread JavaScript arrays and attributes.BufferAttributehas its own readers.Quaternion.slerpFlatandmultiplyQuaternionsFlatwork on flat arrays.Vector3.setFromColorandColor.setFromVector3join two packages that do not import each other.Triangle.getInterpolatedAttributereads an attribute.
Scalar lengths and directions
math.norm provides length2, length3, and length4 for scalar components.
The matching normalized2, normalized3, and normalized4 functions return
component tuples. Each function keeps the input precision, either Float32
or Float64.
Finite nonzero directions normalize to unit length even when a direct squared norm overflows or underflows. Zero directions stay zero. A length can return infinity when the true length cannot fit in the input type. Ordinary inputs keep the direct sum-of-squares arithmetic. Vector and quaternion normalization use these shared functions. A zero quaternion still becomes the identity.
Matrix axis lengths, rotation extraction, and decomposition use the same
scale-safe directions. Decomposition refuses nonfinite entries, zero axes,
and axis lengths that cannot fit in Float32. It leaves output arguments
unchanged on these errors. A mirrored transform keeps its negative x scale
even when the direct determinant underflows or overflows.
Rotation tests inspect only the linear 3-by-3 block. They refuse a nonfinite axis or tolerance and a negative tolerance. Extreme finite uniform scales still qualify as scaled rotations.
Box2.distance_to_point uses the shared length calculation. A finite gap
can have a representable distance even when its squared distance overflows.
A distance beyond the Float32 range returns infinity.
Squared lengths and dot products return their direct arithmetic result. They can overflow or underflow even when a length or unit direction is representable.
The completed consumer audit lists geometry, loader, render, control, and postprocessing consumers. It records range corrections and the bounded or already-wide arithmetic that remains unchanged.
Vector2 and Vector3
Vector2(x, y) and Vector3(x, y, z) hold Float32 components. Both are value types. Assignment copies.
Vector2 has the same members in two dimensions, except the ones that need a third. It adds cross(other) -> Float32, which is the one component a cross product has in a plane. It is positive when other lies to the left of self. It also adds angle(), the angle from +x, and rotate_around(center, angle). The curves asked for the first members; see Curves and paths.
Vector2.angle() returns zero for two positive zero components. Signed zeros
follow atan2; two negative zero components give a half turn. angle_to
returns a right angle when either vector is zero.
A method that changes the vector changes self in place, as in three.js.
| Vector3 member | Meaning |
|---|---|
dot(other) -> Float32 |
The dot product. |
length(), length_sq(), manhattan_length() |
The Euclidean length, its square, and the sum of the absolute components. |
distance_to(p), distance_to_squared(p), manhattan_distance_to(p) |
The distance to another point. |
angle_to(v) -> Angle |
The angle between two vectors. A zero vector gives a right angle, as in three.js. |
add(other), sub(other), add_scaled_vector(v, s), negate() |
Change self in place. |
add_scalar(s), sub_scalar(s) |
Add a number to each component, or subtract it. Vector2 has them too. |
cross(other) |
self = self × other. |
normalize(), set_length(l) |
Scale to unit length, or to l. A zero vector stays zero. |
lerp(v, alpha), lerp_vectors(a, b, alpha) |
A point on the line between two vectors. |
reflect(normal) |
Reflect off a plane with a unit normal. |
apply_matrix4(m), apply_matrix3(m), apply_normal_matrix(m) |
Multiply by a matrix. apply_matrix4 divides by w, but not by a w of zero. apply_normal_matrix makes the result unit length. |
apply_quaternion(q), apply_euler(e), apply_axis_angle(axis, angle) |
Turn the vector. |
transform_direction(m) |
Turn by the rotation and scale of m, then make unit length. Matrix4.transform_direction does not normalize. |
project(view, projection), unproject(view, projection) |
To and from normalized device space. cameras.camera.project_point(p, camera, scene) and unproject_point take a camera. |
min(v), max(v), clamp(low, high), clamp_scalar(low, high), clamp_length(low, high) |
Hold the components, or the length, in a range. |
multiply(v), divide(v) |
Component by component. |
floor(), ceil(), round(), round_to_zero() |
Round each component. round rounds a half up, as JavaScript's Math.round does. |
project_on_vector(v), project_on_plane(normal) |
Keep the part along v, or remove the part along normal. A zero v gives a zero vector. |
get_component(i), set_component(i, value) |
One component by index. An index other than 0, 1 or 2 raises. |
Vector3.from_matrix_position(m), from_matrix_scale(m), from_matrix_column(m, i), from_matrix3_column(m, i) |
Read a matrix. A column index out of range raises. |
Vector3.random(rng), Vector3.random_direction(rng) |
Random vectors, from a SeededRandom. |
a + b, a - b, a * f, a / f, -a, a == b |
Return a new vector, or compare exactly. |
Vector4
Vector4(x, y, z, w) holds four Float32 components. w is the homogeneous coordinate: one for a position, zero for a direction. Vector4(of=v, w=1) builds one from a Vector3.
It has the Vector3 members that make sense in four dimensions, with the same arithmetic over four components.
| Member | Meaning |
|---|---|
xyz() -> Vector3 |
The first three components. |
dot(other), length(), length_sq(), manhattan_length() |
Over all four components. |
add(other), sub(other), add_scaled_vector(v, s), negate() |
Change self in place. |
normalize(), set_length(l) |
Scale to unit length, or to l. A zero vector stays zero. |
lerp(v, alpha), lerp_vectors(a, b, alpha) |
A point on the line between two vectors. |
multiply(v), divide(v) |
Component by component. |
min(v), max(v), clamp(low, high), clamp_scalar(low, high), clamp_length(low, high) |
Hold the components, or the length, in a range. |
floor(), ceil(), round(), round_to_zero() |
Round each component. round rounds a half up. |
get_component(i), set_component(i, value) |
One component by index. An index other than 0 to 3 raises. |
apply_matrix4(m) |
self = m * self, with nothing divided. A projection leaves the clip-space w in w. |
set_from_matrix_position(m) |
The last column of m: the translation, and the last element of the bottom row in w. |
set_axis_angle_from_quaternion(q) |
The axis of a unit quaternion in x, y and z, and its angle in radians in w. A turn too small to have an axis gets +x. |
set_axis_angle_from_rotation_matrix(m) |
The same, from a rotation matrix, with three.js's thresholds. No turn gives (1, 0, 0, 0). A half turn takes its axis from the largest diagonal element. |
axis_angle() -> Angle |
w as an Angle, after one of the two setters above. |
a + b, a - b, a * f, a / f, -a, a == b |
Return a new vector, or compare exactly. |
Matrix4.transform_point divides by w and discards it. apply_matrix4 keeps the whole product.
The two axis-angle setters are three.js's setAxisAngleFromQuaternion and setAxisAngleFromRotationMatrix. Their thresholds and their fallback axes are three.js's, and the tests compare them with three.js 0.180. A half turn whose largest diagonal element is below a hundredth gets one of three.js's three fixed axes, such as (0, 0.707106781, 0.707106781).
Matrix inverses
Matrix inverses retain the direct Float32 cofactor arithmetic for ordinary inputs. Extreme component ranges and strong determinant cancellation use a shared Float64 cofactor fallback. For finite inputs, error-free expansions retain the exact determinant sign and zero. The fallback sums all expansion components before division. It does not reject a small nonzero determinant.
This preserves representable inverses when Float32 intermediate products overflow, underflow or cancel. The ordinary-range screen bounds all determinant products, including cancellation inside cofactors. Nonfinite inputs retain the previous propagation behavior.
A kernel for an Apple GPU cannot use the fallback, because Apple GPUs have no Float64. It uses _inverse_scaled_float32 instead. That function picks a power of two for each column and each row from the entries' exponents, before it changes any entry. It divides each entry by its two powers once, in steps using normal power-of-two factors. The division is exact unless the scaled entry is subnormal.
It inverts the result by Gauss-Jordan elimination with partial pivoting, then multiplies the powers back. That is exact unless an inverse entry leaves the normal range.
The function decides singularity by a zero pivot. So a matrix within rounding of singular can go either way, where the fallback is exact. An Apple GPU flushes subnormal values to zero, so an inverse entry below the normal range reads zero there.
The singular contracts stay the same: Matrix2.inverse raises, and Matrix3
and Matrix4 invert to zeros. An inverse entry outside the Float32 range
becomes signed infinity. It does not make the matrix singular.
The two normal-matrix methods share one checked inverse-transpose path. They
refuse a singular or nonfinite linear block and an inverse that cannot fit
in Float32.
Matrix3
Matrix3 is column-major, as Matrix4 is. Element (row, col) is at col * 3 + row. set takes nine arguments in row-major order.
It has two jobs, as in three.js. It is the rotation and scale part of a Matrix4, which is what a normal is transformed by. And it is a 2D affine transform, which is what a texture's repeat, offset and rotation come to.
| Member | Meaning |
|---|---|
Matrix3() |
The identity. |
set(n11, ..., n33) |
Every element, row by row. |
get(row, col), put(row, col, value) |
One element. |
multiply(other), premultiply(other) |
self * other and other * self. |
transpose(), invert(), determinant() |
As named. A singular matrix inverts to zeros. |
transform(v) -> Vector3 |
self * v. three.js's Vector3.applyMatrix3. |
transform_point(p) -> Vector2 |
A 2D point with a third coordinate of one. three.js's Vector2.applyMatrix3. |
scale(x, y), rotate(angle), translate(x, y) |
Apply a 2D scale, turn or move after this transform, in place. |
as_matrix4() -> Matrix4 |
This matrix in the upper-left corner, with no translation. |
a == b, a != b |
Whether every element is equal, exactly. three.js's equals. |
a * b, multiply_scalar(f) |
The product of two matrices, or every element times a number. |
extract_basis(x, y, z) |
Write the three columns into three vectors. |
Builders, as static methods:
| Builder | Meaning |
|---|---|
Matrix3.from_matrix4(m) |
The upper-left 3 by 3 of a Matrix4. |
Matrix3.normal_matrix(m) |
The inverse transpose of that corner, for normals. Raises for a collapsed transform. |
Matrix3.translation(x, y), Matrix3.scaling(x, y), Matrix3.rotation(angle) |
The 2D transforms. A rotation takes an Angle. |
Matrix3.uv_transform(offset, repeat, rotation, center) |
three.js's setUvTransform: turn about center by minus rotation, then scale by repeat about it, then move by offset. |
rotate(angle) turns by -angle, as three.js does. It exists to build a texture transform, where a turn of the coordinates one way shows the image turned the other. Matrix3.rotation(angle) turns the coordinates themselves.
Matrix4.normal_matrix() gives the same answer as a Matrix4. The renderer uses that one.
Matrix4
Column-major storage, as three.js and OpenGL. Element (row, col) is at col * 4 + row. The translation is at elements 12 to 14.
set takes its sixteen arguments in row-major order, so a matrix in source reads as it does on paper. That asymmetry is three.js's.
| Member | Meaning |
|---|---|
Matrix4() |
The identity. |
set(n11, n12, ..., n44) |
Every element, row by row. |
get(row, col), put(row, col, value) |
One element. |
multiply(other) |
self = self * other. The right-hand matrix applies first. |
premultiply(other) |
self = other * self. |
transpose(), invert(), determinant() |
As named. A singular matrix inverts to zeros. |
normal_matrix() -> Matrix4 |
The inverse transpose, for normals. |
extract_rotation() -> Matrix4 |
The rotation with scale and translation removed. A shear or a mirror stays. |
is_rotation(tolerance=1e-4) -> Bool |
Whether the upper-left 3 by 3 is a rotation: unit axes at right angles, right-handed. |
is_scaled_rotation(tolerance=1e-4) -> Bool |
Whether it is a rotation times a positive uniform scale. |
max_scale() -> Float32 |
The longest axis column. three.js's getMaxScaleOnAxis. |
max_stretch() -> Float32 |
A bound on the most any direction is stretched. It holds under shear, where the longest axis does not. |
is_affine() -> Bool |
Whether the bottom row is (0, 0, 0, 1). |
transform_point(p) -> Vector3 |
Apply with w = 1 and divide by w. |
transform_w(p) -> Float32 |
The w that transform_point divides by. |
transform_direction(d) -> Vector3 |
Apply with w = 0. |
a == b, a * b, multiply_scalar(f) |
Compare exactly, multiply, or multiply every element by a number. |
scale(v) |
Scale the three axis columns: a scale applied first. |
set_position(v), copy_position(m) |
Set the translation column. |
set_from_matrix3(m) |
A Matrix3 in the upper-left corner, with no translation. The same as Matrix3.as_matrix4. |
extract_basis(x, y, z) |
Write the three axis columns into three vectors. |
look_at(eye, target, up) |
Set the rotation so that +z points from target to eye. The translation is kept. math.projection.look_at builds a view matrix instead. |
decompose(position, quaternion, scale) |
Split into a translation, a rotation and a scale. A mirror gives a negative x scale. An axis of zero length raises; three.js writes NaN. |
Builders: translation(x, y, z), scaling(x, y, z), rotation_x(angle), rotation_y(angle), rotation_z(angle), rotation_axis(axis, angle), shear(xy, xz, yx, yz, zx, zy), basis(x, y, z), compose(position, quaternion, scale), rotation_from_quaternion(q) and rotation_from_euler(e). A rotation takes an Angle, so rotation_z(90.0) does not compile.
var m = translation(10, 0, 0)
m.multiply(rotation_z(Angle(90.0, DEGREE)))
m.multiply(translation(-10, 0, 0)) # a rotation about (10, 0, 0)
Box3, Sphere and Plane
math/bounds.mojo. The volumes a renderer tests against, and the plane a frustum is made of. All three hold bare Float32 meters, as Vector3 does.
An empty box or sphere holds no points. It is a value, not an error. A box is empty when a corner is inside out on any axis. A sphere is empty when its radius is negative. Every operation treats an empty bound as the set it is.
Empty boxes keep the same set behavior whether their inside-out corners are finite or infinite. Every box contains an empty box, and an empty box intersects no box. Expanding or translating an empty box keeps it empty. A negative size creates an empty box even when rounding would make its two corners equal.
A contraction larger than a finite box extent leaves an empty box. This also holds when rounded corners would be equal. Exact half-extent contraction still leaves a nonempty point or face.
Box2.center and Box3.center add finite coordinates in Float64 before
they divide by two. They then round once to Float32. This keeps a finite
midpoint when a Float32 sum would overflow.
Box3.bounding_sphere measures corner distances from that stored center.
It includes a proved arithmetic error bound and rounds the radius outward. Thus
rounding cannot put a finite corner outside the sphere. The radius can be
one Float32 step larger than the smallest enclosing radius. For subnormal
radii, that step can be a large share of the radius.
A full size() component can overflow while the radius remains finite. A radius that
cannot fit in Float32 is infinity. Empty and nonfinite bounds keep their
prior behavior.
If outward arithmetic overflows, an exact corner test checks the largest finite radius first. This preserves bounds just inside the type limit.
Expanding an empty bound by a point gives the bound of that one point. A union with one changes nothing. An overlap test with one is false. A transform leaves one empty.
A question that needs a point of an empty bound raises: clamp_point, distance_to_point and distance_to_sphere.
Both transforms take an affine matrix, one that keeps w at one. A projection raises. A transformed sphere grows by max_stretch, which holds under shear. three.js grows by the longest axis, which can fall short of a point the sphere held.
| Box3 member | Meaning |
|---|---|
Box3(min, max), Box3.empty() |
Two corners, or the inside-out box that holds nothing. |
Box3.from_points(points) |
The smallest box around the points. |
is_empty(), center(), size() |
As named. An empty box has the origin as its center and no size. |
expand_by_point(p), union(other) |
Grow in place. |
contains_point(p), clamp_point(p), distance_to_point(p) |
The faces count as inside. |
intersects_box(other), intersects_sphere(s) |
Touching counts. |
apply_matrix4(m) |
Transform the eight corners and bound them again, in place. |
bounding_sphere() -> Sphere |
A sphere enclosing the corners, with an outward-rounded radius. |
Box3.from_center_and_size(c, s) |
The box of size s centered on c. |
expand_by_vector(v), expand_by_scalar(f), translate(v) |
Move each face out, or move the box. |
intersect(other) |
Keep what both boxes hold. Boxes that do not overlap give the empty box. |
contains_box(other) |
Whether all of other is inside. The empty box is inside every box. |
get_parameter(p) -> Vector3 |
Where p lies, as a fraction of each side. A box with no extent on an axis raises; three.js divides by zero. |
intersects_plane(p), intersects_triangle(t) |
Whether the box meets a plane or a triangle. The triangle test is three.js's separating axis test. |
a == b |
Both corners equal. |
| Sphere member | Meaning |
|---|---|
Sphere(center, radius), Sphere.empty() |
A negative radius is the empty sphere. |
Sphere.from_points(points) |
Centered on the points' box, reaching the farthest point. |
is_empty(), contains_point(p), distance_to_point(p) |
The distance is signed: negative inside. |
intersects_sphere(other), intersects_box(box) |
Touching counts. |
expand_by_point(p) |
Grow in place, by as little as possible. |
apply_matrix4(m) |
Move the center. The radius grows by max_stretch. |
bounding_box() -> Box3 |
The box around the sphere. |
Sphere.from_points_around(points, center) |
three.js's setFromPoints with a center: the sphere at center that reaches every point. |
union(other), translate(v) |
Grow to hold another sphere, or move. |
clamp_point(p) |
The nearest point of the sphere. The empty sphere raises; three.js uses a radius of one. |
intersects_plane(p) |
Whether a plane passes through the sphere. |
a == b |
Centers and radii equal. |
| Plane member | Meaning |
|---|---|
Plane(normal, constant) |
The points where dot(normal, p) + constant is zero. The normal is made unit length, and the constant scaled with it. |
Plane.from_normal_and_point(n, p) |
Through p, facing n. |
Plane.from_coplanar_points(a, b, c) |
Through three points, facing the side they wind counter-clockwise from. |
distance_to_point(p), distance_to_sphere(s) |
Signed: positive in front. |
project_point(p), coplanar_point() |
The nearest point on the plane. |
negate(), translate(offset) |
Turn around, or move, in place. |
intersects_sphere(s), intersects_box(box) |
Whether the plane passes through it. |
intersect_line(line) -> Optional[Vector3] |
Where a segment crosses the plane. A segment in the plane gives its start, as in three.js. |
intersects_line(line) |
Whether the two ends lie on opposite sides. An end on the plane does not count. |
apply_matrix4(m), apply_matrix4(m, normal_matrix) |
Carry the plane through a transform. A transform that collapses a dimension raises. |
a == b |
Normals and constants equal. |
A plane refuses a zero normal. Three points on one line do not make a plane.
Three-point plane and triangle normal queries retain finite coordinate products
before normalization. This corrects range loss in three.js 0.180-style direct
cross products. For example, the points (0, 0, 7), (2**100, 0, 7), and
(0, 2**100, 7) have unit normal (0, 0, 1) and plane constant -7.
The raw Float32 cross product overflows, but these results are representable.
The same direction survives at the minimum subnormal coordinate scale.
A cold fallback sums original coordinate products with exact sign and zero. It also handles cancellation that widening only the edge differences misses. The plane constant uses the original coordinate determinant. Ordinary, well-conditioned crosses keep their Float32 arithmetic. Nonfinite coordinates keep direct IEEE behavior. This is a range correction, not a guarantee of correctly rounded normals for every input.
The three-point factory also uses original coordinates when raw dot products lose range, or its constant approaches cancellation or the Float32 limit. Its separate range guard covers 512 steps below the maximum finite output; it includes edge and cross-product rounding. The normal-and-point factory has a smaller guard because its given normal components are already the original inputs. Stored Float32 plane coefficients can still give a small nonzero distance at an original corner. The fallback cannot remove this final representation error.
A coordinate plane through the origin keeps the fast path when all three original x, y, or z coordinates are zero. Computed zeros alone do not permit this shortcut.
Exact zero cross components in the fallback use positive zero. In the fallback, an exact zero plane constant uses negative zero. These zero signs do not change the geometry.
Plane construction scales the normal and constant together, including subnormal
normals and finite normals whose length exceeds Float32. The fallback divides
in Float64 before it stores the result. A normalized constant beyond the
Float32 range returns infinity. Nonfinite inputs retain direct IEEE behavior.
from_normal_and_point widens raw dot products that lose range or approach
cancellation. An exact-product expansion keeps small terms between cancelling
large terms before division. A normalized result near the finite limit also triggers this
fallback. The guard covers the last 32 Float32 steps before the maximum
finite value, plus nonfinite results. It prevents spurious overflow and finite
saturation at this boundary.
For example, (1e-10, 0, 0) through (1e-36, 0, 0)
has constant -1e-36; an intermediate product rounded to zero cannot erase it.
An exactly zero expansion gives a negative-zero factory constant. Its sign bit can differ from the direct dot product. The represented plane is unchanged. The direct constructor preserves signed zeros.
These range corrections differ from three.js 0.180. Ordinary inputs keep their
existing arithmetic order. Coplanar-point construction still forms its cross
product in Float32; its extreme-range limits remain separate.
Frustum
math/frustum.mojo. Six planes facing inward: right, left, bottom, top, far and near, in three.js's order. RIGHT to NEAR index them in planes.
Frustum.from_projection_matrix(m) reads the planes off a matrix. Each plane is a sum or difference of the bottom row with another row. Give it a projection for camera space, or a projection times a view for world space.
| Member | Meaning |
|---|---|
Frustum.from_projection_matrix(m) |
The frustum a matrix sees. Raises for a matrix that describes no volume. |
Frustum.from_camera(clip, view, near, far) |
The sides from clip, the near and far planes from the view matrix and the two distances. Raises for a view that is not affine, or a far plane not beyond the near one. |
contains_point(p) |
Whether p is in front of every plane. A point on a plane counts. |
intersects_sphere(s) |
Whether any of s is in view. False for an empty sphere. |
intersects_box(b) |
Whether any of b is in view. False for an empty box. |
from_projection_matrix(m, coordinate_system, reversed_depth) takes three.js's two other arguments. coordinate_system is WEBGL_COORDINATES, the default, or WEBGPU_COORDINATES. It is a CoordinateSystem, not a bare integer, and an invalid one raises. A WebGPU projection puts the near plane at a depth of zero. With reversed_depth, the far plane is at a depth of zero.
cameras.frustum_array.FrustumArray is three.js's FrustumArray. Its intersects_object, intersects_sprite, intersects_sphere, intersects_box and contains_point take an ArrayCamera and a scene. Each is true when any camera sees the thing. An array with no cameras sees nothing.
Bounds of scene content
core/object_bounds.mojo holds the three.js members that read a scene. The scene must be updated first.
box_from_object(scene, assets, node, precise=False) is three.js's Box3.setFromObject. It returns the world-space box around everything drawn at node and under it. expand_by_object(box, scene, assets, node, precise) is expandByObject: it grows a box instead.
Each thing adds the bound three.js gives it:
- A mesh, a line, a set of points or a wide line adds its geometry's box.
- An instanced or a batched mesh adds the box of its instances' boxes.
- A level of detail adds every level, because three.js's levels are children.
- A skinned mesh adds the box of its posed vertices.
- A sprite adds the unit square.
With precise, each vertex goes to world space on its own. The box is tighter for a turned object. A mesh or a skinned mesh wears its morph targets there, as in three.js. The geometry's own box leaves the morph targets out. three.js's includes them.
intersects_object(frustum, scene, assets, thing) is Frustum.intersectsObject, for a Mesh, a Line, a Points or an InstancedMesh. It tests the geometry's bounding sphere, carried to world space. intersects_sprite(frustum, scene, sprite) is intersectsSprite.
A sphere or a box that crosses a plane is in view as far as the test knows. One that crosses two planes outside their corner is in view too. That is the usual bargain. The renderer uses intersects_sphere to skip meshes. See Renderer.
The renderer builds its frustum with from_camera. A far plane read back off a Float32 projection can sit meters short when far is thousands of times near. For a near plane at 0.1 and a far one at 5000 it sits at 4993. The camera's own distances put it at 5000, where the clipper has it.
Ray
math/ray.mojo. A Ray is an origin and a unit direction: a half-line. The constructor makes the direction unit length and refuses a zero one. Segment queries require that unit direction. Sphere, box, and point-distance queries support a finite nonzero stored direction.
A hit is an Optional. A miss is None. Every hit is forward of the origin. A ray inside a sphere or a box hits where it leaves.
Sphere queries use the actual stored direction, including a finite nonunit direction. For finite inputs and a nonzero direction, a roundoff bound filters clear hits and misses. Ambiguous cases use exact polynomial signs from the stored Float32 values. An exact tangent counts as a hit. An origin on the sphere returns that origin, not the opposite surface. Returned coordinates remain rounded Float32 values.
| Member | Meaning |
|---|---|
Ray(origin, direction) |
From origin, along direction. |
at(t) -> Vector3 |
The point t meters along the ray. |
look_at(target), recast(t) |
Aim at a point, or move the origin along the ray, in place. |
closest_point_to_point(p), distance_to_point(p), distance_sq_to_point(p) |
The nearest point of the ray, never behind the origin, and the distance to it. |
intersect_sphere(s), intersects_sphere(s) |
Where the ray enters the sphere. |
distance_to_plane(p), intersect_plane(p), intersects_plane(p) |
Where the ray meets the plane. A ray in the plane meets it at its origin. |
intersect_box(b), intersects_box(b) |
Where the ray enters the box. |
intersect_triangle(a, b, c, cull_back) |
Where the ray meets the triangle. With cull_back, a hit from behind is a miss. |
apply_matrix4(m) |
Carry the ray through an affine matrix, in place. |
An empty sphere or box is hit nowhere. A ray parallel to a plane meets it only when it lies in it. A ray in a triangle's plane misses the triangle, and so does a degenerate triangle.
apply_matrix4 raises for a projection, and for a matrix that flattens the direction to nothing. look_at raises for the origin itself.
look_at widens extreme point differences before normalization. Opposite finite
positions can have a difference beyond Float32 and still give a valid direction.
For example, (-3e38, 0, 0) aimed at (3e38, 3e38, 0) follows direction (2, 1, 0).
This correction differs from three.js 0.180 range behavior. Ordinary directions
keep their existing arithmetic order. This does not add new nonfinite-input errors.
core.raycaster carries a ray through a scene. See Raycasting.
Projection
| Function | Meaning |
|---|---|
perspective(left, right, top, bottom, near, far) |
Camera space to normalized device space, with perspective. |
orthographic(left, right, top, bottom, near, far) |
The same without perspective. w stays one. |
look_at(eye, target, up) |
The view matrix of a camera at eye. |
viewport(width, height) |
Normalized device space to pixels. Rows count down. |
Projection bounds must be finite and ordered. An infinite far plane is refused. The builders raise if the volume is degenerate or a required coefficient cannot fit in Float32. Intermediate overflow uses a wider calculation when the final coefficients can fit.
Both Matrix4.look_at and the view builder use the same basis. Positions must be finite. Up must be finite and nonzero. A camera at its own target uses +z as its backward axis. Parallel up is nudged off the view direction. The basis accepts very small and large finite directions.
Matrix4.look_at keeps translation and the bottom row, and leaves the matrix unchanged on failure. The view builder also refuses an offset that cannot fit in Float32. Frustum builders refuse nonfinite matrices and depth distances.
Normalized device space is unitless. World space is meters and screen space is pixels. The matrices meet in the middle.
Triangle and Line3
math/triangle.mojo. Triangle(a, b, c) holds three corners. They turn counterclockwise when seen from the front.
| Triangle member | Meaning |
|---|---|
normal() -> Vector3 |
The unit normal. three.js: getNormal. |
area() -> Float32 |
The area. |
midpoint() -> Vector3 |
The average of the corners. |
plane() -> Plane |
The plane the triangle lies in. |
barycoord(point) -> Vector3 |
The weights of a, b and c at the point's projection onto the plane. |
contains_point(point) -> Bool |
True if the projection lies inside or on an edge. |
interpolate(point, at_a, at_b, at_c) -> Vector3 |
Three corner values mixed by the barycentric weights. |
is_front_facing(direction) -> Bool |
True if a ray along the direction meets the front. |
closest_point_to_point(point) -> Vector3 |
The nearest point on the face, an edge or a corner. |
Barycentric queries use widened signed-area products. Thin triangles keep their nonzero area instead of losing it to a difference of dot products. Off-plane queries use the orthogonal projection onto the plane. Octree containment uses the same calculation.
is_degenerate tests exact collinearity of finite stored corners. It applies
no area tolerance. raw_normal and area still return their direct Float32
arithmetic results. They can underflow to zero or overflow even when the unit
normal is representable. Their rounded output does not define degeneracy.
Barycentric and intersection queries retain their separate arithmetic limits.
A degenerate triangle has its corners on one line. It has no normal, no plane and no barycentric coordinates, and those questions raise. three.js answers them with a zero vector or null. closest_point_to_point still answers: it uses the nearest point of the three edges.
Triangle.from_points_and_indices(points, a, b, c) picks three corners from a list. An index out of range raises. intersects_box(box) is Box3.intersects_triangle. a == b compares the corners in order.
Line3(start, end) is a segment. delta(), center(), distance(), distance_sq() and at(t) describe it. a == b compares the ends. closest_point_parameter(point, clamp) and closest_point(point, clamp) find the point nearest a point. A segment of no length raises for them.
distance_to_line(other) is the shortest distance between two segments. closest_points_to_line(other, on_self, on_other) is three.js's distanceSqToLine3. It writes the two nearest points and returns the squared distance. apply_matrix4(matrix) moves both ends.
Spherical and Cylindrical
math/spherical.mojo. Spherical(radius, phi, theta) names a point by its distance from the origin and two Angles. phi goes down from +y. theta turns about y from +z. Cylindrical(radius, theta, y) names a point by its distance from the y axis, the same theta and a height.
from_vector3(v) and to_vector3() convert both ways. The origin has both spherical angles zero. Spherical.make_safe() keeps phi a millionth of a radian away from each pole. OrbitControls holds its camera's offset as a Spherical.
Matrix2 and Box2
math/matrix2.mojo. Matrix2(m00, m01, m10, m11) is stored row by row and multiplies a column vector on its right. identity(), rotation(angle) and scaling(x, y) build one. a * b applies b first.
determinant(), transposed(), inverse() and transform(v) are the rest. A singular matrix raises for inverse. three.js returns zeros.
Box2(min, max) is Box3 in the plane. It has the same empty box, with its corners inside out. These members work as in Box3: from_points, from_center_and_size, expand_by_point, expand_by_vector, expand_by_scalar, union, intersect, translate, center, size, contains_point, contains_box, get_parameter, intersects_box, clamp_point, distance_to_point and ==. The empty box has its center at the origin, as in three.js.
MathUtils
math/utils.mojo. The scalar helpers of three.js's MathUtils: clamp, lerp, inverse_lerp, map_linear, damp, euclidean_modulo, pingpong, smootherstep, smooth_step, is_power_of_two, ceil_power_of_two and floor_power_of_two.
damp(x, y, rate, delta) takes the frame time as a Duration. smooth_step(x, low, high) has three.js's argument order. The shaders read smoothstep(edge0, edge1, x) from math/smoothstep.mojo, in GLSL's order.
A function that a GPU kernel calls must not call std.math.atan or atan2. They call libm, and a GPU has no libm. On a card before sm_80, atan2 does not link. Use atan_float32 and atan2_float32 from math/arc_tangent.mojo. They are Cephes's atanf in plain arithmetic, and the CPU calls them too. Signed zeros and infinite coordinates follow the IEEE quadrant rules.
sin_float32 uses the same arithmetic on the CPU and GPU. It accepts every finite Float32 angle. Ordinary angles keep the existing Cody-Waite reduction. Large angles use an integer range reduction before the same polynomial. Infinite angles and NaN return NaN.
degToRad and radToDeg are not here. An Angle converts itself.
generate_uuid(rng) is three.js's generateUUID, with the numbers from a SeededRandom.
quaternion_from_proper_euler(a, b, c, order) is three.js's setQuaternionFromProperEuler. order is a ProperEulerOrder: PROPER_XYX, PROPER_YZY, PROPER_ZXZ, PROPER_XZX, PROPER_YXY or PROPER_ZYZ. An invalid one raises; three.js warns.
normalize(value, component) and denormalize(value, component) convert between a number and a stored integer. component is a ComponentType, such as UINT8_COMPONENT. three.js reads it from the typed array.
ceil_power_of_two and floor_power_of_two require a positive integer.
The ceiling raises an error if its result cannot fit in Int.
The floor accepts every positive Int, including Int.MAX.
SeededRandom(seed) is three.js's Mulberry32 generator. next() returns a number from zero up to one. float_in(low, high), float_spread(spread) and int_in(low, high) are three.js's randFloat, randFloatSpread and randInt. The same seed gives the same numbers as three.js's seededRandom, on every platform. The core generator and Clearwater share one 32-bit step in math/random.mojo.
For finite bounds with low < high, float_in returns a finite value at least low and below high. Equal finite bounds return that bound. Each call uses one draw. The conversion handles intervals whose width overflows and clamps rounding at the upper end. Vector2.random and Vector3.random use this same conversion for each component.
Periodic scalar helpers
euclidean_modulo(n, m) accepts Float32 inputs. For finite inputs and a
nonzero divisor, its result has the divisor's sign and a magnitude below
abs(m). Exact multiples return a zero with the divisor's sign. The exact
mathematical result is rounded to Float32. If that reaches the excluded
endpoint, the result is the adjacent Float32 toward zero. A zero divisor,
an infinite operand, or a NaN operand returns NaN.
pingpong(x, length) accepts every finite Float32 input and positive finite
length. Its result is from zero to the length, with positive zero at period
boundaries. It constructs the period in Float64 and folds the exact binary
remainder before the final Float32 rounding. It keeps small phases and cannot
overflow for a positive finite length.
A negative length keeps three.js's
extension from 2 * length to length. This negative extension can round to
negative infinity when its result does not fit Float32. Zero lengths and
nonfinite operands return NaN.
math/remainder.mojo provides remainder_float64(n, m). It returns the exact
truncating remainder for every finite binary64 pair with a nonzero divisor.
The sign, including a zero's sign, comes from the dividend. A finite dividend
and infinite divisor return the dividend. Zero divisors, infinite dividends,
and NaN operands return NaN. NaN payloads are not preserved.
The shared primitive uses integer significands and powers of two. It does not
use a floating-point quotient, floating-point %, or a host library. Its
integer residual stays below a 53-bit divisor. Each shift is at most ten bits,
so the intermediate fits UInt64. There are at most 205 shifts. The final exact
remainder fits the input binary64 lattice, including subnormal values.
tools/reference_remainders.mojo decodes inputs to exact fractions. It
uses rational floor division and rounds directly to the target format. The
saved controls cover every finite exponent field, both signs, subnormals,
exact multiples, representable neighbors, and seeded random pairs.
tests/test_reference_remainders_tool.mojo checks the controls. Run
mojo run -I . tools/reference_remainders.mojo --write only for an
intentional fixture change. The native suite compares bits, without a
tolerance.
bench/periodic_remainder_bench.mojo times ordinary calls and hard finite inputs.
CPU evidence does not verify GPU runtime behavior.
Differences from three.js
The upstream baseline is three.js 0.180.0, src/math/MathUtils.js. Its
Euclidean formula is ((n % m) + m) % m. In Mojo 1.1.0, Float32 bits
0x71800000 are exactly 2^100. Both native Float32 and widened Float64 %
return zero for a divisor of 1.5. Exact rational arithmetic gives one.
The shared primitive returns one.
This is a reproduced API counterexample; it does not identify the compiler's internal cause.
The unconditional upstream add-divisor step can erase a small positive remainder or overflow. The pingpong formula can overflow its Float32 period or erase a small folded phase. These cases now follow the mathematical wave. Opposite-sign endpoint rounding uses the adjacent value toward zero, rather than wrapping to zero. Ordinary three.js fixtures remain covered. These corrections can change a caller's phase or replay at the affected boundaries.
The caller audit found no other direct calls to these two helpers in the frozen source. The animation mixer has its own quotient-based loop counter and phase reduction. Its counter range and large-step behavior need a separate contract; this change does not modify them. Integer texture wrapping and the large-angle sine reducer use separate arithmetic and are unchanged.
Large-angle sine regressions
tests/test_sine.mojo checks representable neighbors around large multiples of
pi/2 and the nearest-quadrant boundaries (k + 1/2) * pi/2.
It tests both signs in nine exponent bands, from 13 through 126.
Each target has two adjacent Float32 values below it and two above it.
The 72 targets give 288 positive cases and 576 signed sine checks.
tools/reference_sine_phases.mojo constructs the targets in exact binary
fixed point. It computes pi with Machin's convergent arctangent series.
An integer binary search selects the neighbors from exact Float32 values.
A full-turn reduction and Taylor series compute each reference sine.
The generator rounds references directly to Float32 with ties-to-even. It checks that 140-digit and 200-digit calculations give the same fixtures. It does not import the shared sine or its reduction constants.
Run mojo run -I . tools/reference_sine_phases.mojo --check to check the saved fixtures.
Use --write to replace the generated block after an intentional fixture change.
The CPU test checks the nearest quadrant, the reduced remainder, and the sine.
It compares the sine to both the saved high-precision reference and host libm.
The sine tolerance stays at 2e-6. The five-second test limit stays in effect.
The ordinary Cody-Waite arithmetic does not change.
For midpoint targets at exponent 23 and below, the inner neighbor pair isolates one nearest-quadrant increment. At higher exponents, one Float32 spacing can cross many quadrants. The targets can then share neighbors. The oracle checks the actual stored angles and their actual quadrants. It does not assign the target's phase to a rounded input.
Every finite binary value is rational. A nonzero integer multiple of pi/2
is irrational. A midpoint (2*k + 1) * pi/4 is also irrational.
An exact nonzero finite binary input therefore cannot equal either boundary.
A rounded approximation to pi does not create an exact midpoint test.
test_shared_sine_large_angle_phase_neighbors_match_bits in
tests/test_gpu.mojo compares host and device sine bits for the same signed
inputs. A GPU-target build checks compilation only. It does not establish that
the test ran on a physical device. Use the device test result for that claim.