Math & Core Primitives
The oxide_engine::math module is the foundation every other system depends on.
It builds on glam for vectors, quaternions, and
matrices, and adds the engine's higher-level geometric and utility types.
This document is the usage reference for the module as delivered in Stage 1. For the conventions these types follow (handedness, units, color space), see conventions.md.
Importing
Everything is available through the module path or the prelude:
use oxide_engine::math::{Transform, Aabb, Ray, Plane, Frustum, Color, Rect, Range3};
// or, more commonly:
use oxide_engine::prelude::*;
The prelude also re-exports the glam types you need for everyday work — Vec2,
Vec3, Vec4, Quat, Mat3, Mat4, and EulerRot — so downstream crates
need no direct glam dependency.
Contents
| Type | Role |
|---|---|
Transform |
Placement: translation + rotation + scale |
Aabb |
Axis-aligned bounding box (geometry/bounds/culling) |
Ray |
Origin + normalized direction (picking, queries) |
Plane |
Infinite plane in Hessian normal form |
Frustum |
Six-plane view volume for visibility culling |
Color |
Linear RGBA color with sRGB conversion |
Rect |
2D rectangle (UI, viewports, texture regions) |
Range3 |
3D value range (clamp, lerp, remap) |
All types are Copy, PartialEq, and serde-(de)serializable.
Transform
A 3D affine transform stored in decomposed form — translation (Vec3),
rotation (Quat), and scale (Vec3) — so each channel stays editable
without matrix round-trips. The effective matrix is T * R * S.
Construction
use oxide_engine::prelude::*;
let a = Transform::IDENTITY;
let b = Transform::from_translation(Vec3::new(0.0, 1.0, 0.0));
let c = Transform::from_rotation(Quat::from_rotation_y(90_f32.to_radians()));
let d = Transform::from_scale(Vec3::splat(2.0));
let e = Transform::from_trs(
Vec3::new(1.0, 2.0, 3.0),
Quat::from_euler(EulerRot::XYZ, 0.1, 0.2, 0.3),
Vec3::splat(1.0),
);
// From / to a 4x4 matrix:
let m = e.to_matrix(); // glam::Mat4
let back = Transform::from_matrix(m);
let affine = e.to_affine(); // glam::Affine3A (cheaper to compose)
Composition and hierarchy
mul_transform composes parent-first, so this is how you resolve a child into
its parent's space:
let parent = Transform::from_trs(
Vec3::new(10.0, 0.0, 0.0),
Quat::from_rotation_y(90_f32.to_radians()),
Vec3::splat(2.0),
);
let child_local = Transform::from_translation(Vec3::new(0.0, 0.0, 1.0));
let child_world = parent.mul_transform(&child_local);
// child_world.translation == (12, 0, 0)
Composition is exact for uniform scale and a closest-fit approximation for non-uniform scale combined with rotation (see conventions).
Applying a transform
let t = Transform::from_trs(
Vec3::new(1.0, 0.0, 0.0),
Quat::from_rotation_z(90_f32.to_radians()),
Vec3::splat(2.0),
);
let p = t.transform_point(Vec3::new(1.0, 0.0, 0.0)); // affected by T, R, S
let v = t.transform_vector(Vec3::new(1.0, 0.0, 0.0)); // R and S only (no translation)
Inverse
inverse() returns a transform that undoes this one. It is exact for uniform
scale; with any zero scale component the transform is not invertible and the
inverse scale will contain infinities (check with is_finite()).
let undo = t.inverse();
let identity = t.mul_transform(&undo); // ≈ Transform::IDENTITY
Direction and orientation helpers
let dir_forward = t.forward(); // local -Z
let dir_up = t.up(); // local +Y
let dir_right = t.right(); // local +X
// Build an orientation that looks from `eye` toward `target`:
let cam = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
// cam.forward() points at the target. Degenerate (eye == target) → identity rotation.
Edge cases handled
- Zero scale → non-invertible; the forward transform is still finite.
- Gimbal-lock orientations (e.g. ±90° pitch) round-trip through a matrix without losing orthonormality of the basis vectors.
- Degenerate
looking_at(eye == target) returns identity rotation rather than producing NaNs.
Aabb
An axis-aligned bounding box defined by min and max corners. Used for bounds,
broad-phase overlap, and frustum culling. A box is empty when any min
component exceeds its max; Aabb::EMPTY is the identity for union.
use oxide_engine::prelude::*;
let a = Aabb::new(Vec3::new(1.0, 5.0, -2.0), Vec3::new(-1.0, 0.0, 3.0)); // corners auto-sorted
let b = Aabb::from_center_half_extents(Vec3::ZERO, Vec3::splat(1.0));
let c = Aabb::from_points([Vec3::ZERO, Vec3::new(2.0, -1.0, 4.0), Vec3::new(-3.0, 5.0, 1.0)]);
// Queries:
let center = b.center();
let size = b.size();
let inside = b.contains_point(Vec3::ZERO);
let near = b.closest_point(Vec3::new(5.0, 0.0, 0.0));
// Set operations:
let u = a.union(&b);
let i = a.intersection(&b); // Aabb::EMPTY if disjoint
let hit = a.intersects(&b); // bool (touching counts)
// Acceleration-structure metrics:
let area = b.surface_area(); // for SAH
let vol = b.volume();
let pts = b.corners(); // [Vec3; 8]
// Ray test (slab method): returns entry distance t, or None.
let ray = Ray::new(Vec3::new(-5.0, 0.0, 0.0), Vec3::X);
if let Some(t) = b.ray_intersection(&ray) {
let point = ray.at(t);
}
A ray whose origin is inside the box returns Some(0.0).
Ray
A half-line with an origin and a normalized direction. Because the
direction is unit-length, the parameter t in at(t) is a true distance.
use oxide_engine::prelude::*;
let r = Ray::new(Vec3::ZERO, Vec3::new(0.0, 5.0, 0.0)); // direction normalized to +Y
let r2 = Ray::from_to(Vec3::ZERO, Vec3::new(0.0, 0.0, 10.0));
let p = r.at(4.0); // origin + direction * 4
let valid = r.is_valid(); // false if direction was zero-length
let near = r.closest_point(target); // clamped to t >= 0
let dist = r.distance_to_point(target);
If you pass a zero-length direction, the ray is left degenerate; check
is_valid() before relying on it.
Plane
An infinite plane in Hessian normal form: a unit normal and a signed distance
d, such that every point on the plane satisfies normal·p + d = 0. The
positive half-space is the side the normal points toward.
use oxide_engine::prelude::*;
let p1 = Plane::from_point_normal(Vec3::new(0.0, 2.0, 0.0), Vec3::Y);
let p2 = Plane::from_points(Vec3::ZERO, Vec3::X, Vec3::Y); // normal via right-hand rule → +Z
let p3 = Plane::new(Vec3::new(0.0, 0.0, 4.0), 8.0); // normalized on construction
let sd = p1.signed_distance(Vec3::new(0.0, 5.0, 0.0)); // +3.0 (in front)
let proj = p1.project_point(Vec3::new(3.0, 7.0, -2.0)); // orthogonal projection onto plane
let flipped = p1.flipped(); // same plane, reversed normal
// Ray test: None if parallel or pointing away.
let ray = Ray::new(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y);
if let Some(t) = p1.ray_intersection(&ray) {
let hit = ray.at(t);
}
Frustum
A view volume represented by six planes (left, right, bottom, top, near, far), each with its normal pointing inward. A point is inside when it lies in the positive half-space of every plane. Built from a combined view-projection matrix via the Gribb–Hartmann method; works for perspective and orthographic projections alike.
use oxide_engine::prelude::*;
let proj = Mat4::perspective_rh(60_f32.to_radians(), 16.0 / 9.0, 0.1, 100.0);
let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
let frustum = Frustum::from_view_projection(proj * view);
let visible_point = frustum.contains_point(Vec3::ZERO);
let bb = Aabb::from_center_half_extents(Vec3::ZERO, Vec3::splat(1.0));
let visible_box = frustum.intersects_aabb(&bb);
let visible_sphere = frustum.intersects_sphere(Vec3::ZERO, 1.0);
intersects_aabb is conservative: it never culls a box that is actually
visible, though it may very rarely keep one that is just outside a corner. That
is the correct trade-off for rendering, where false positives cost a wasted draw
but false negatives cause visible pop-out.
Color
Linear RGBA color with f32 channels. The engine works in linear space;
conversions to/from 8-bit sRGB are explicit. Values may exceed 1.0 to represent
HDR/emissive intensity and are not clamped in storage.
use oxide_engine::prelude::*;
let white = Color::WHITE;
let custom = Color::rgba(0.2, 0.4, 0.6, 1.0);
let from_pdf = Color::from_srgb_u8(135, 206, 235); // sRGB bytes → linear
let from_hex = Color::from_hex(0x87CEEB); // #87CEEB → linear
let bytes = custom.to_srgb_u8(); // [u8; 4] sRGB, clamped to [0,1]
let v4 = custom.to_vec4(); // Vec4 [r,g,b,a]
let v3 = custom.to_vec3(); // Vec3 rgb
let faded = custom.with_alpha(0.5);
let mid = Color::BLACK.lerp(Color::WHITE, 0.5); // t clamped to [0,1]
Constants: BLACK, WHITE, RED, GREEN, BLUE, TRANSPARENT.
Rect
A 2D axis-aligned rectangle for UI, viewports, and texture regions.
use oxide_engine::prelude::*;
let r1 = Rect::new(Vec2::new(4.0, 1.0), Vec2::new(0.0, 5.0)); // corners auto-sorted
let r2 = Rect::from_min_size(Vec2::ZERO, Vec2::new(3.0, 4.0));
let r3 = Rect::from_center_size(Vec2::ZERO, Vec2::splat(2.0));
let (w, h) = (r2.width(), r2.height());
let size = r2.size();
let area = r2.area();
let c = r2.center();
let empty = Rect::ZERO.is_empty();
let inside = r2.contains_point(Vec2::ONE);
let hit = r2.intersects(&r3);
let i = r2.intersection(&r3); // Rect::ZERO if disjoint
let u = r2.union(&r3);
let near = r2.closest_point(Vec2::new(5.0, -1.0));
let grown = r2.expanded(1.0); // negative shrinks
Range3
A 3D value range — an inclusive [min, max] interval per axis. Unlike
Aabb (which models geometry), Range3 models a value range for clamping,
interpolation, and remapping. It deliberately provides lerp/inverse_lerp/
remap, which an Aabb does not.
use oxide_engine::prelude::*;
let r = Range3::new(Vec3::ZERO, Vec3::splat(100.0));
let unit = Range3::UNIT; // [0, 1] per axis
let sym = Range3::symmetric(Vec3::splat(2.0)); // [-2, 2] per axis
let span = r.span();
let center = r.center();
let clamped = r.clamp(Vec3::new(-5.0, 50.0, 200.0)); // → (0, 50, 100)
let inside = r.contains(Vec3::splat(50.0));
let v = r.lerp(Vec3::splat(0.25)); // NOT clamped — extrapolates outside [0,1]
let t = r.inverse_lerp(Vec3::splat(25.0)); // → 0.25 per axis (0.0 on zero-span axes)
let remapped = r.remap(Vec3::splat(50.0), &unit); // 50 in [0,100] → 0.5 in [0,1]
inverse_lerp guards against division by zero: an axis with zero span yields
0.0 rather than NaN/inf.
Testing and performance
- Unit tests for every type live in that type's source file
(
engine/src/math/*.rs), covering core behavior and edge cases (zero scale, gimbal lock, degenerate rays/planes, empty boxes, zero-span ranges). - Integration / fuzz tests live in the
oxide-testscrate (stage1::fuzz_transform_chains_stay_stablebuilds long random transform chains and verifies stability and inverse round-trips). - Benchmark:
cargo bench -p oxide-engineruns thetransformbenchmark. The Stage 1 budget — 1,000,000 transform compositions in under 10 ms — is met with margin (~5 ms on a typical desktop).
Runnable example
cargo run -p oxide-examples --bin math_demo
examples/src/bin/math_demo.rs exercises every type above and prints the
results — a good place to see the API in use end to end.