1
Math
Jaroslav Beneš edited this page 2026-08-08 18:11:58 +02:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 GribbHartmann 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-tests crate (stage1::fuzz_transform_chains_stay_stable builds long random transform chains and verifies stability and inverse round-trips).
  • Benchmark: cargo bench -p oxide-engine runs the transform benchmark. 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.