Vectors, matrices, quaternions and geometry for MoonBit.
Column-major, right-handed, depth from zero to one, and laid out so that a
matrix copies straight into a WGSL buffer.
Home · Guide · API · mooncakes
let eye = @moonmath.Vec3::new(0.0, 1.6, 6.0)
let view = @moonmath.Mat4::look_at(eye, @moonmath.Vec3::zero(), @moonmath.Vec3::unit_y())
let projection = @moonmath.Mat4::perspective(@moonmath.to_radians(60.0), 16.0 / 9.0, 0.1, 100.0)
let view_projection = projection * view
let on_screen = view_projection.transform_point(@moonmath.Vec3::zero())moon add moonbitstack/moonmathThe package is moonbitstack/moonmath, imported as @moonmath. Double precision is the same library under moonbitstack/moonmath/f64, imported as @f64.
It is the arithmetic a renderer, a layout engine or a physics step needs: the vectors and matrices a shader also has, the rotations that are easier to interpolate than a matrix, the affine transforms a scene graph stores, and the geometry that says whether two things touch.
It is not a rendering library. Nothing here talks to a GPU, loads a mesh, or knows what a camera is beyond the matrix that describes one. There is no four-component SIMD trickery and nothing allocates beyond the values it returns.
Five decisions shape the whole surface, and all five are visible in every signature.
Right-handed, column-major. x runs east, y north, z towards the viewer, so cross(x, y) == z. A matrix stores its columns, c0 first, which is the order a mat4x4<f32> occupies in a buffer: to_array gives the sixteen floats in that order and from_array reads them back. A matrix product a * b applies b first.
Depth runs from zero to one. perspective, perspective_infinite, orthographic and frustum map the near plane to depth 0 and the far plane to 1, as WebGPU clips. The _gl variants of the same four map them to -1 and 1 for a pipeline that clips like OpenGL.
Angles are radians. Every angle in the library, in a signature or out of one, is in radians; to_radians and to_degrees convert at the edge, where the number came from a file or a human.
Operators take two values of the same type. a + b, a - b, a * b, a / b, -a do what they look like on two vectors, two matrices or two quaternions. MoonBit's arithmetic traits are declared fn mul(Self, Self) -> Self, so the two sides of an operator are the same type by definition: there is no Vec3 * Float to define, and no Mat4 * Vec4 either. Scaling is scale and its inverse divide, and a matrix times a vector is mul_vec4, mul_vec3 or mul_vec2.
A matrix that cannot be inverted says so. inverse returns an Option, None for a singular matrix, and the caller decides whether that is an error. The operator / has nowhere to report it, so a / b aborts when b is singular; inverse is what to reach for when the case can happen.
Two more, smaller ones. The zero vector has no direction, and normalize returns Vec3::zero() for it rather than a NaN — try_normalize and normalize_or exist for callers that need to tell the case apart. And every value type implements Eq, Show and Debug; the integer vectors also implement Hash. Equality is exact — approx is the comparison to use on results of arithmetic, and it takes an optional epsilon.
A quaternion is four numbers that interpolate; a matrix is what a shader wants. Both directions are here, and a rotation round-trips through either.
let turn = @moonmath.Quat::from_axis_angle(@moonmath.Vec3::unit_y(), @moonmath.quarter_pi)
let half_way = @moonmath.Quat::identity().slerp(turn, 0.5)
let ahead = turn.rotate(@moonmath.Vec3::unit_z())
let matrix = turn.to_mat3()
// Euler angles, in one of the twelve orders, by name.
let from_yaw = @moonmath.Quat::from_euler(
@moonmath.EulerOrder::Zyx,
@moonmath.Vec3::new(0.0, 0.0, @moonmath.to_radians(90.0)),
)
let back = from_yaw.to_euler(@moonmath.EulerOrder::Zyx)The three components of an Euler angle vector are the turns about x, y and z — the component says which axis, wherever that axis turns up in the order. Zyx above turns about z first, and the turn is in the third component because it is a turn about z.
Six of the twelve orders turn about one axis twice — Xyx and its siblings — and two turns about x cannot both live in the single component named after it. Those six have no by-axis form, so from_euler and to_euler abort on them; from_euler_turns and to_euler_turns take a Turns record whose fields are named after their place in the order, and cover all twelve:
let precessing = @moonmath.Quat::from_euler_turns(
@moonmath.EulerOrder::Xyx,
@moonmath.Turns::new(0.3, 1.2, -0.4),
)
let turns = precessing.to_euler_turns(@moonmath.EulerOrder::Xyx)Position, rotation and scale are three separate pieces, and so is a matrix built from them:
let placement = @moonmath.Trs::new(
@moonmath.Vec3::new(0.0, 2.0, 0.0),
@moonmath.Quat::from_axis_angle(@moonmath.Vec3::unit_y(), @moonmath.half_pi),
@moonmath.Vec3::splat(0.5),
)
let model = @moonmath.Mat4::from_trs(placement)
// And back out again, for an editor or an animation to edit.
let editable = model.to_trs().unwrap()Affine2 and Affine3 are the same transform without the row that does nothing: cheaper to compose and apply, and impossible to make non-affine.
Boxes, rays, planes, spheres, triangles and the frustum, with the tests a broad phase and a pick need.
let room = @moonmath.Aabb3::new(
@moonmath.Vec3::new(-4.0, 0.0, -4.0),
@moonmath.Vec3::new(4.0, 3.0, 4.0),
)
let ray = @moonmath.Ray3::new(
@moonmath.Vec3::new(0.0, 2.0, 0.0),
@moonmath.Vec3::new(0.0, -1.0, -1.0).normalize(),
)
match ray.intersect_aabb(room) {
Some(t) => println("the room is \{t} away along the ray, at \{ray.at(t)}")
None => println("the ray leaves the room alone")
}
let frustum = @moonmath.Frustum::from_matrix(view_projection).unwrap()
if frustum.intersects_aabb(room) {
println("there is something to draw")
}Rectangles are separate from boxes because a layout thinks in a corner and a size, not in two opposite corners: Rect is origin plus size, and Aabb2 is min plus max. They convert to each other with to_aabb and to_rect.
moonbitstack/moonmath/f64 holds the same types at Double, generated from the same template as the single-precision package. A simulation that runs for hours, or spans a planet, converts at the boundary rather than living in Float throughout:
let far_away = @f64.Vec3::new(1.0e9, 0.0, 0.0)
let scaled = far_away.scale(2.0)A packed vertex or a texture often carries a sixteen-bit float, and Half is that encoding: the bits in and out, the conversion to and from Float rounded to nearest with ties to even, and the special values kept.
let packed = @moonmath.Half::from_float(1.5)
let restored = packed.to_float()
let raw = packed.to_bits()Color3 and Color4 are the component arithmetic a blend or a gradient needs; rgb_to_hsv and hsv_to_rgb convert between the two forms. Shear is the off-diagonal linear map a skew is. Interval is a one-dimensional range with union, intersection and growth. Line3 is an infinite line with the closest-point and plane-intersection questions. The sampling functions turn uniform values in [0, 1) into points with the right distribution, and leave the random source to the caller:
let red = @moonmath.Color3::new(1.0, 0.0, 0.0)
let hue = @moonmath.rgb_to_hsv(red)
let skew = @moonmath.Shear::new(0.3, 0.0, 0.0).to_mat3()
let span = @moonmath.Interval::new(0.0, 1.0).union(
@moonmath.Interval::new(2.0, 3.0),
)
// The two values are a uniform random source the caller owns.
let on_sphere = @moonmath.Vec3::random_on_sphere(0.25, 0.75)Every type below is a value type: immutable, and returning a new value from every operation. Unless a row says otherwise, each also carries + - * /, unary -, Eq, Show and Debug.
| Signature | Meaning |
|---|---|
to_radians(degrees : Float) -> Float |
degrees to radians |
to_degrees(radians : Float) -> Float |
radians to degrees |
lerp(a, b, t) -> Float |
a at t = 0, b at t = 1, extrapolating outside |
inverse_lerp(start, end, value) -> Float |
where a value sits in a range; zero for an empty range |
remap(value, from_start, from_end, to_start, to_end) -> Float |
the same position in another range |
smoothstep(start, end, value) -> Float |
the cubic easing with zero slope at both ends |
wrap(value, span) -> Float |
folded into 0..span |
wrap_range(value, start, end) -> Float |
folded into start..end |
approx(a, b, eps? : Float) -> Bool |
equal to within eps, or within default_epsilon |
approx_angle(a, b, eps? : Float) -> Bool |
the same, for angles that wrap at tau |
clamp01(value) -> Float |
pinned to 0..1 |
pi, tau, half_pi, quarter_pi, default_epsilon |
the constants |
Half is the 16-bit float of a packed vertex or a texture: from_float, to_float, from_bits, to_bits, the predicates is_finite, is_nan, is_inf, is_normal, is_denormal, and the constants from zero and one to max, min and epsilon.
Vec2, Vec3 and Vec4 share this much:
| Signature | Meaning |
|---|---|
Vec::new(...), splat(v), zero(), one(), unit_x(), unit_y(), unit_z(), unit_w() |
constructors; unit_z and unit_w where the type has them |
dot(other) -> Float |
the dot product |
length(), length_squared() -> Float |
the length, and its square |
distance(other), distance_squared(other) -> Float |
between two points |
normalize() -> Vec |
to length one; the zero vector stays zero |
try_normalize() -> Vec?, normalize_or(fallback) -> Vec |
the same, reporting the case instead |
scale(factor), divide(divisor) -> Vec |
multiplied and divided by a scalar |
lerp(other, t), min(other), max(other), clamp(low, high) -> Vec |
component-wise |
abs(), floor(), ceil(), round(), fract() -> Vec |
component-wise rounding; a half rounds up |
to_array() -> FixedArray[Float] |
the components in order |
is_finite() -> Bool |
no NaN and no infinity |
approx(other, eps? : Float) -> Bool |
equal to within eps |
What each type adds:
| Type | Signature | Meaning |
|---|---|---|
Vec2 |
cross(other) -> Float |
the signed area the two span |
perp() -> Vec2 |
a quarter turn counter-clockwise | |
angle_between(other) -> Float |
the signed angle from this to that, in -pi..pi |
|
yx() -> Vec2 |
the components swapped | |
to_vec3(z), to_vec4(z, w) |
lifted into a higher dimension | |
Vec3 |
cross(other) -> Vec3 |
the perpendicular, right-handed |
angle_between(other) -> Float |
the unsigned angle, in 0..pi |
|
xy(), xz(), yz() -> Vec2 |
a plane's worth of components | |
zyx(), xzy(), yxz(), yzx(), zxy() -> Vec3 |
the six orders of the three components | |
to_vec4(w) -> Vec4 |
lifted into 4D | |
Vec4 |
xy() -> Vec2, xyz() -> Vec3 |
the leading components |
wzyx() -> Vec4 |
all four backwards |
Vec2 and Vec3 also carry reflect(normal), refract(normal, eta) — which returns None past the critical angle — project(onto) and reject(onto). reflect and refract assume unit vectors, as a shader does.
IVec2/3/4 are Int, for grid cells and pixel coordinates; UVec2/3/4 are UInt, for sizes and counts. Both carry + - * / component-wise (integer division, truncating), Eq, Hash, Show and Debug; the signed ones also carry unary - and abs.
| Signature | Meaning |
|---|---|
new(...), splat(v), zero(), one() |
constructors |
dot(other) -> Int (or UInt) |
the dot product |
min(other), max(other), clamp(low, high) |
component-wise, inclusive |
to_array() |
the components in order |
IVec::abs() -> IVec |
absolute value |
IVec::as_vec2/3/4(), UVec::as_vec2/3/4() |
the same values as floats, exactly |
IVec::from_vec2/3/4(v) |
the floats truncated towards zero |
Mat2, Mat3 and Mat4 store their columns: c0, and c1, c2, c3 where the type has them.
| Signature | Meaning |
|---|---|
identity(), zero() |
the two that need no arguments |
from_cols(...), from_rows(...) |
built one way or the other |
from_array(values) -> Mat |
from the column-major array a buffer holds; aborts on the wrong length |
col(i) -> Vec, row(i) -> Vec, get(row, column) -> Float |
reading it |
transpose() -> Mat |
rows and columns exchanged |
determinant() -> Float |
the volume it scales by |
inverse() -> Mat? |
None when it is singular |
mul_vec2/3/4(v) -> Vec |
applied to a vector |
to_array() -> FixedArray[Float] |
column-major, ready for a buffer |
is_finite(), approx(other, eps?) -> Bool |
| Type | Signature | Meaning |
|---|---|---|
Mat2 |
from_scale(Vec2), from_angle(radians), to_angle() -> Float |
a scaling, a rotation, and the angle back out |
Mat3 |
from_scale(Vec3), from_axis_angle(axis, radians), from_quat(q), from_euler(order, angles), from_euler_turns(order, turns) |
|
from_mat4(m) |
the upper-left block, without the translation | |
Mat4 |
from_scale(Vec3), from_translation(Vec3), from_axis_angle(axis, radians), from_quat(q), from_euler(order, angles), from_euler_turns(order, turns) |
|
from_mat3(m) |
the 3×3 with w = 1 and no translation |
|
from_trs(trs), to_trs() -> Trs? |
scale, then rotate, then move; and back | |
transform_point(p) -> Vec3 |
a point, divided by w when there is one |
|
transform_vector(v) -> Vec3 |
a direction, untouched by the translation | |
look_at(eye, target, up) -> Mat4 |
the view matrix of a camera | |
perspective(fov_y, aspect, near, far) |
depth 0..1 | |
perspective_infinite(fov_y, aspect, near) |
depth 0..1, no far plane | |
orthographic(left, right, bottom, top, near, far) |
depth 0..1 | |
frustum(left, right, bottom, top, near, far) |
depth 0..1, off-centre | |
perspective_gl, perspective_infinite_gl, orthographic_gl, frustum_gl |
the same four, depth -1..1 | |
Shear |
new(xy, xz, yz), zero(), to_mat3(), to_mat4() |
the off-diagonal terms as a linear map |
| Signature | Meaning |
|---|---|
Quat::identity(), Quat::new(x, y, z, w) |
no rotation, and a raw one |
Quat::from_axis_angle(axis, radians) |
the right-hand rule about an axis |
Quat::from_euler(order, angles), Quat::to_euler(order) -> Vec3 |
the turns about x, y and z, by axis; aborts for an order that turns about one axis twice |
Quat::from_euler_turns(order, turns), Quat::to_euler_turns(order) -> Turns |
the same, with the three turns in order, for all twelve orders |
Quat::from_rotation_arc(from, to) |
the shortest rotation between two directions |
Quat::from_mat3(m), Quat::from_mat4(m) |
out of a matrix |
Quat::to_mat3(), Quat::to_mat4() |
into one |
Quat::to_axis_angle() -> (Vec3, Float) |
the axis and the angle |
Quat::rotate(v) -> Vec3 |
the vector turned |
Quat::inverse() -> Quat?, conjugate(), normalize(), length(), length_squared(), dot(other) |
|
Quat::slerp(other, t), Quat::nlerp(other, t) |
at constant angular speed, and along the straight line |
Quat::to_array(), is_finite(), approx(other, eps?) |
EulerOrder names the twelve orders: Xyz, Xzy, Yxz, Yzx, Zxy, Zyx, Xyx, Xzx, Yxy, Yzy, Zxz, Zyz. Turns is the record of three turns in order — first, second, third — with Turns::new and Turns::approx.
| Signature | Meaning |
|---|---|
Affine2::new(linear, translation), Affine3::new(linear, translation) |
|
identity(), from_translation(v), from_scale(v) |
|
Affine2::from_angle(radians), from_angle_translation(radians, offset) |
|
Affine3::from_rotation(q), from_axis_angle(axis, radians), from_trs(trs), to_trs() -> Trs? |
|
transform_point(p), transform_vector(v) |
|
to_mat3() / to_mat4(), from_mat3(m) -> Affine2? / from_mat4(m) -> Affine3? |
to a matrix and back; None when the matrix is not affine |
inverse() -> Affine? |
None when the linear part is singular |
is_finite(), approx(other, eps?) |
Trs is the three parts of a transform — translation : Vec3, rotation : Quat, scale : Vec3 — built with Trs::new, and what Mat4::to_trs recovers from a matrix built the same way.
| Type | Signature | Meaning |
|---|---|---|
Rect |
new(origin, size), from_xywh(x, y, w, h), from_edges(left, top, right, bottom) |
a corner and an extent |
min(), max(), center() -> Vec2, width(), height(), area() -> Float |
||
contains(point), intersects(other), intersection(other) -> Rect?, union(other) |
||
inset(amount), translate(offset), is_empty(), corners(), to_aabb() -> Aabb2 |
||
Aabb2, Aabb3 |
new(min, max), from_center_half_extents(center, extents), from_points(points) -> Aabb?, empty() |
|
center(), size(), extents(), area() / volume(), surface_area() |
||
contains_point(p), contains(other), intersects(other), intersection(other) -> Aabb?, union(other), expand(amount) |
||
corners(), is_empty(), Aabb3::transform(m : Mat4) -> Aabb3 |
the box around the transformed box | |
Ray2, Ray3 |
new(origin, direction), at(t) -> Vec |
a start and a direction; t may be any distance |
intersect_aabb(box) -> Float?, intersect_aabb_interval(box) -> (Float, Float)? |
the first crossing, and the stretch inside | |
Ray2::intersect_rect(rect), Ray2::intersect_circle(center, radius) |
||
Ray3::intersect_sphere(sphere), intersect_plane(plane), intersect_triangle(triangle) |
||
Plane |
new(normal, distance), from_point_normal(point, normal) -> Plane?, from_points(a, b, c) -> Plane? |
dot(normal, p) == distance |
signed_distance(p), project(p) -> Vec3, intersect_ray(ray) -> Float?, flip() |
||
Sphere |
new(center, radius), contains_point(p), intersects_sphere(other), intersects_aabb(box), surface_area(), volume() |
|
Triangle |
new(a, b, c), normal() -> Vec3, area(), barycentric(p) -> Vec3?, intersect_ray(ray) -> Float? |
|
Frustum |
from_matrix(view_projection) -> Frustum?, from_matrix_gl(view_projection) -> Frustum? |
six planes facing inwards |
planes() -> FixedArray[Plane], plane(i) -> Plane |
left, right, bottom, top, near, far | |
contains_point(p), contains_aabb(box), intersects_aabb(box), contains_sphere(s), intersects_sphere(s) |
||
Line3 |
new(point, direction), at(t), closest_point_to(p), distance_to_point(p), closest_point_to_line(line), distance_to_line(line), intersect_plane(plane) -> Vec3? |
an infinite line |
Interval |
new(min, max), empty(), size(), center(), contains(x), intersects(other), intersection(other) -> Interval?, union(other), extend_by(x), expand(amount) |
a one-dimensional range |
The intersection functions return the t of the crossing rather than the point, because the distance is usually what a caller wants and the point is at(t). A ray that starts inside a shape reports the crossing on its way out.
| Type | Signature | Meaning |
|---|---|---|
Color3, Color4 |
new(...), splat(v), zero(), one() |
red, green and blue, and alpha |
min(other), max(other), lerp(other, t) |
component-wise | |
to_array(), is_finite(), approx(other, eps?) |
||
rgb_to_hsv(c), hsv_to_rgb(c) |
between the two forms; the hue in turns |
moonbitstack/moonmath/special is a subpackage for the functions a probability, a physics or a numerical caller reaches for. Every body is written once, in special/generic, against an Ops algebra of the number type; special is the double-precision face of it and moonnum's Fixed supplies the arbitrary-precision one, so the two precisions cannot drift apart.
| Function | Meaning |
|---|---|
gamma(x), lgamma(x) |
the gamma function and its log; Stirling with the reflection below one half |
beta(a, b), lbeta(a, b) |
the beta function and its log |
digamma(x), polygamma(n, x) |
the logarithmic derivative of gamma, and its n-th derivative |
gammainc(a, x), gammaincc(a, x) |
the regularised incomplete gamma, lower and upper |
incbeta(a, b, x) |
the regularised incomplete beta |
erf(x), erfc(x), erfcx(x), erfi(x) |
the error function, its complement, the scaled complement, the imaginary one |
erfinv(x), erfcinv(x) |
the inverses of the first two |
bessel_j(v, x), bessel_y(v, x) |
the Bessel functions of the first and second kind, real order |
bessel_i(v, x), bessel_k(v, x) |
the modified ones |
ellipk(m), ellipe(m), ellipkm1(m) |
the complete elliptic integrals, m the parameter |
ellipkinc(phi, m), ellipeinc(phi, m) |
the incomplete ones |
elliprf, elliprd, elliprc, elliprj, elliprg |
the Carlson symmetric forms the others are built on |
hyper0f1(b, x), hyper1f1(a, b, x), hyper2f1(a, b, c, x) |
the hypergeometric functions |
zeta(s) |
the Riemann zeta, above and below one |
let root_pi = @special.gamma(0.5)
let tail = @special.erfc(2.0)
let drum = @special.bessel_j(0.0, 2.4)The arbitrary-precision side runs the same functions on moonnum's Fixed — a fixed-point real carried as mantissa / 2^bits — at whatever width the caller asks for:
let bits = @moonnum.Fixed::bits_for_digits(50)
let ops = @generic.fixed_ops(bits)
let x = @moonnum.Fixed::of_decimal("0.5", bits)
let value = @generic.gamma(x, ops)Fixed lives in moonbitstack/moonnum, which is where a number belongs, and moonmath depends on it at the version mooncakes publishes.
The precision a caller should expect is not the same for every function, and the tests say so rather than averaging it away. In double the gamma, error, zeta and elliptic families agree with the references to about 1e-15; the Bessel functions to about 1e-12, the widest case being a nearly integer order, where the reflection divides by the sine of a small angle; and at fifty and a hundred digits the same table is replayed at 1e-45 and 1e-80.
The two hypergeometric functions are the reason the wider type is here as a working tool rather than only as a check. Both report the largest term their run formed beside the value, and the double-precision face re-runs the sum wider when that term stands far above the answer, then rounds once.
For hyper1f1 that is all it takes: its terms rise above the result by up to fourteen digits across Boost's general table, and a run at forty-five digits brings the worst row from 4e-4 to 1.1e-12. A numerator at a non-positive integer ends the sum early and it is evaluated as the polynomial it is; a denominator at one is a pole, and Kummer's transformation — valid for the infinite series — is not applied there.
hyper2f1 needed more than a wider type, because the transformation around one multiplies a gamma ratio of 10^60 by series whose own terms reach 10^55. Holding both and adding them afterwards needs a hundred and thirty digits at fixed point. So the scale is folded into the first term of the series it multiplies: every partial sum stays the size of the answer, and what is left is the cancellation the answer is actually made of. Each scale is one exponential rather than a product of two, and its gamma factors are formed as logarithms, since a single Γ(180) overflows a double while the ratio of two of them does not. Against Boost's table — parameters within a hundred of zero, the argument anywhere inside the unit interval, values from 1e-49 to 1e62 — the worst row is out by 2.5e-13, and the widest needs a hundred and thirty-five digits to keep fifteen.
What that comes to, row for row against Boost's tables:
| Function | Rows replayed | Worst relative error | Tolerance in the test |
|---|---|---|---|
hyper1f1 |
2560 | 1.1e-12 |
1e-11 |
hyper1f1, small arguments |
512 | 4.0e-14 |
1e-13 |
hyper1f1, negative integer numerator |
24 | 2.1e-15 |
1e-13 |
hyper2f1 |
500 | 2.5e-13 |
1e-12 |
A degenerate 2F1 is summed as it stands: when c - a - b is an integer the transformation around one has a logarithm in it instead of two powers, and the series at the argument answers that case, slowly as the argument approaches one.
Three of Boost's 1F1 tables are out of reach and not replayed. Their parameters run past a million — -814723.75 against -1222085.5 — where the terms rise roughly 10^450 above the answer and no series, at any width this package will pay for, holds them. That region is what uniform asymptotic expansions are for, and this package does not have them; the general, small-argument and negative-integer tables are complete.
Two independent reference sets back all of it. scripts/gen_special.py reads mpmath at a hundred and thirty working digits and fills both the double and the arbitrary-precision tables. scripts/gen_boost.py takes the tables that ship with Boost.Math's own test suite — thirty of them, twelve thousand rows — and boost_check_wbtest.mbt replays every row of every one. The tables are checked in, so a test run needs neither Python, mpmath nor a Boost checkout.
Four runnable programs, each moon run-able on its own:
| Example | What it shows |
|---|---|
src/examples/01-tour |
the whole surface in one run |
src/examples/02-camera |
a camera, what lands on screen, frustum culling, and a pick |
src/examples/03-layout |
rectangles, insets and hit testing for a window of panes |
src/examples/04-collision |
a ray narrowed from a sphere to a triangle, and a bounce |
Throughput on the native backend, moon bench --target native --release, single precision, on an x86-64 machine with the 2026-10-23 toolchain. Each row is one operation, measured 64 at a time inside a timed closure; the numbers are the best of three runs.
| Operation | ns | per second |
|---|---|---|
Vec3::dot |
1.9 | 540,000,000 |
Vec4::dot |
1.7 | 600,000,000 |
Frustum::intersects_aabb |
21 | 47,000,000 |
Vec3::normalize |
56 | 18,000,000 |
Mat4::transform_point |
56 | 18,000,000 |
Vec3::cross |
61 | 16,000,000 |
Quat::rotate |
64 | 16,000,000 |
Quat::slerp |
96 | 10,000,000 |
Ray3::intersect_aabb |
97 | 10,000,000 |
Ray3::intersect_sphere |
111 | 9,000,000 |
Quat::to_mat3 |
220 | 4,500,000 |
Mat4 * Mat4 |
282 | 3,500,000 |
Mat4::inverse |
330 | 3,000,000 |
The first two rows are arithmetic with nothing to build; the rest return a value type, and on this backend a struct is a heap value, so part of every row above them is the allocation of the result. What that means in practice is that an operation returning a Vec3 costs about the same whether it is a cross product or a normalisation, and that a loop over ten thousand vectors spends much of its time in the allocator rather than in the arithmetic.
Apache-2.0.