Files
Oxide/docs/math.md
T
Homer Simpson 9eead719b0 Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:41:02 +02:00

354 lines
12 KiB
Markdown
Raw Blame History

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`](https://docs.rs/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](conventions.md).
## Importing
Everything is available through the module path or the prelude:
```rust
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`](#transform) | Placement: translation + rotation + scale |
| [`Aabb`](#aabb) | Axis-aligned bounding box (geometry/bounds/culling) |
| [`Ray`](#ray) | Origin + normalized direction (picking, queries) |
| [`Plane`](#plane) | Infinite plane in Hessian normal form |
| [`Frustum`](#frustum) | Six-plane view volume for visibility culling |
| [`Color`](#color) | Linear RGBA color with sRGB conversion |
| [`Rect`](#rect) | 2D rectangle (UI, viewports, texture regions) |
| [`Range3`](#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
```rust
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:
```rust
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](conventions.md#transform-composition)).
### Applying a transform
```rust
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()`).
```rust
let undo = t.inverse();
let identity = t.mul_transform(&undo); // ≈ Transform::IDENTITY
```
### Direction and orientation helpers
```rust
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`.
```rust
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.
```rust
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.
```rust
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.
```rust
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.
```rust
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.
```rust
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.
```rust
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
```sh
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.