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>
This commit is contained in:
Homer Simpson
2026-07-05 20:41:02 +02:00
parent 2afb56b329
commit 9eead719b0
157 changed files with 47270 additions and 2 deletions
+372
View File
@@ -0,0 +1,372 @@
//! Affine [`Transform`]: translation, rotation, and (non-uniform) scale.
//!
//! A `Transform` is the canonical way to place an object in space. It composes
//! as `parent * child`, matching the convention used by the scene graph in
//! later stages. Internally it is stored in decomposed (TRS) form so that
//! individual components stay editable without matrix round-trips.
use glam::{Affine3A, Mat4, Quat, Vec3};
use serde::{Deserialize, Serialize};
/// A 3D affine transform stored as translation, rotation, and scale.
///
/// The effective matrix is `T * R * S` (scale applied first, then rotation,
/// then translation), which is the standard convention for scene hierarchies.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, crate::reflect::Reflect)]
pub struct Transform {
/// World/local-space position.
pub translation: Vec3,
/// Orientation as a unit quaternion.
pub rotation: Quat,
/// Per-axis scale. May be non-uniform; zero or negative components are
/// permitted but make the transform non-invertible / mirror-inducing.
pub scale: Vec3,
}
impl Default for Transform {
/// The identity transform: no translation, no rotation, unit scale.
fn default() -> Self {
Self::IDENTITY
}
}
impl Transform {
/// The identity transform.
pub const IDENTITY: Self = Self {
translation: Vec3::ZERO,
rotation: Quat::IDENTITY,
scale: Vec3::ONE,
};
/// Creates a transform from a translation only (identity rotation, unit scale).
#[inline]
pub const fn from_translation(translation: Vec3) -> Self {
Self {
translation,
rotation: Quat::IDENTITY,
scale: Vec3::ONE,
}
}
/// Creates a transform from a rotation only.
#[inline]
pub const fn from_rotation(rotation: Quat) -> Self {
Self {
translation: Vec3::ZERO,
rotation,
scale: Vec3::ONE,
}
}
/// Creates a transform from a uniform scale.
#[inline]
pub const fn from_scale(scale: Vec3) -> Self {
Self {
translation: Vec3::ZERO,
rotation: Quat::IDENTITY,
scale,
}
}
/// Creates a transform from all three components.
#[inline]
pub const fn from_trs(translation: Vec3, rotation: Quat, scale: Vec3) -> Self {
Self {
translation,
rotation,
scale,
}
}
/// Decomposes a 4x4 matrix back into a TRS transform.
///
/// Negative determinants (mirrored matrices) are handled by `glam`'s
/// decomposition, which folds the sign into the scale.
#[inline]
pub fn from_matrix(matrix: Mat4) -> Self {
let (scale, rotation, translation) = matrix.to_scale_rotation_translation();
Self {
translation,
rotation,
scale,
}
}
/// Returns the equivalent 4x4 homogeneous matrix.
#[inline]
pub fn to_matrix(&self) -> Mat4 {
Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
}
/// Returns the equivalent [`Affine3A`], which is cheaper to compose than a
/// full `Mat4` and is what the renderer/scene graph use internally.
#[inline]
pub fn to_affine(&self) -> Affine3A {
Affine3A::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
}
/// Composes two transforms: `self * rhs` applies `rhs` first, then `self`.
///
/// This is exact for the translation and rotation channels. When either
/// operand carries non-uniform scale combined with rotation, the true
/// product is no longer a pure TRS transform; in that case the result is
/// re-decomposed from the composed matrix so the returned `Transform`
/// remains the closest TRS approximation. For uniform scale (the common
/// scene-graph case) the composition is exact.
#[inline]
pub fn mul_transform(&self, rhs: &Transform) -> Transform {
// Fast path: uniform scale composes exactly in TRS form.
if is_uniform(self.scale) {
let scale = self.scale * rhs.scale;
let rotation = self.rotation * rhs.rotation;
let translation = self.translation + self.rotation * (self.scale * rhs.translation);
Transform {
translation,
rotation,
scale,
}
} else {
Transform::from_matrix(self.to_matrix() * rhs.to_matrix())
}
}
/// Transforms a point (affected by translation, rotation, and scale).
#[inline]
pub fn transform_point(&self, point: Vec3) -> Vec3 {
self.translation + self.rotation * (self.scale * point)
}
/// Transforms a direction vector (rotation and scale only, no translation).
#[inline]
pub fn transform_vector(&self, vector: Vec3) -> Vec3 {
self.rotation * (self.scale * vector)
}
/// Returns the inverse transform, such that
/// `t.mul_transform(&t.inverse())` is approximately the identity.
///
/// # Panics
/// Does not panic, but if any scale component is zero the inverse scale
/// will contain infinities — the transform is not invertible in that case.
#[inline]
pub fn inverse(&self) -> Transform {
let inv_scale = Vec3::ONE / self.scale;
let inv_rotation = self.rotation.inverse();
let inv_translation = inv_rotation * (inv_scale * -self.translation);
Transform {
translation: inv_translation,
rotation: inv_rotation,
scale: inv_scale,
}
}
/// The local forward direction (`-Z`) rotated into this transform's space.
#[inline]
pub fn forward(&self) -> Vec3 {
self.rotation * Vec3::NEG_Z
}
/// The local up direction (`+Y`) rotated into this transform's space.
#[inline]
pub fn up(&self) -> Vec3 {
self.rotation * Vec3::Y
}
/// The local right direction (`+X`) rotated into this transform's space.
#[inline]
pub fn right(&self) -> Vec3 {
self.rotation * Vec3::X
}
/// Builds a transform positioned at `eye` looking toward `target`.
///
/// `up` is the reference up vector. Returns the identity rotation if `eye`
/// and `target` coincide.
pub fn looking_at(eye: Vec3, target: Vec3, up: Vec3) -> Transform {
let forward = target - eye;
let rotation = if forward.length_squared() <= f32::EPSILON {
Quat::IDENTITY
} else {
// glam's look_to is right-handed with -Z forward; invert the view
// rotation to get an object-space orientation.
Quat::from_mat4(&Mat4::look_to_rh(eye, forward.normalize(), up)).inverse()
};
Transform {
translation: eye,
rotation,
scale: Vec3::ONE,
}
}
/// Returns `true` if every component is finite (no NaN/inf).
#[inline]
pub fn is_finite(&self) -> bool {
self.translation.is_finite() && self.rotation.is_finite() && self.scale.is_finite()
}
}
/// Returns `true` if all three components of `scale` are equal.
#[inline]
fn is_uniform(scale: Vec3) -> bool {
(scale.x - scale.y).abs() <= f32::EPSILON && (scale.y - scale.z).abs() <= f32::EPSILON
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::{FRAC_PI_2, PI};
const EPS: f32 = 1e-4;
fn approx_vec(a: Vec3, b: Vec3) -> bool {
(a - b).length() <= EPS
}
#[test]
fn identity_is_default() {
assert_eq!(Transform::default(), Transform::IDENTITY);
let p = Vec3::new(1.0, 2.0, 3.0);
assert_eq!(Transform::IDENTITY.transform_point(p), p);
}
#[test]
fn translation_moves_points() {
let t = Transform::from_translation(Vec3::new(1.0, 2.0, 3.0));
assert!(approx_vec(
t.transform_point(Vec3::ZERO),
Vec3::new(1.0, 2.0, 3.0)
));
// Vectors ignore translation.
assert!(approx_vec(t.transform_vector(Vec3::X), Vec3::X));
}
#[test]
fn rotation_rotates_points() {
let t = Transform::from_rotation(Quat::from_rotation_z(FRAC_PI_2));
assert!(approx_vec(t.transform_point(Vec3::X), Vec3::Y));
}
#[test]
fn scale_scales_points() {
let t = Transform::from_scale(Vec3::new(2.0, 3.0, 4.0));
assert!(approx_vec(
t.transform_point(Vec3::ONE),
Vec3::new(2.0, 3.0, 4.0)
));
}
#[test]
fn matrix_round_trip() {
let t = Transform::from_trs(
Vec3::new(5.0, -2.0, 1.0),
Quat::from_euler(glam::EulerRot::XYZ, 0.3, -0.7, 1.1),
Vec3::new(2.0, 2.0, 2.0),
);
let back = Transform::from_matrix(t.to_matrix());
assert!(approx_vec(t.translation, back.translation));
assert!(approx_vec(t.scale, back.scale));
// Quaternions q and -q represent the same rotation.
let dot = t.rotation.dot(back.rotation).abs();
assert!((dot - 1.0).abs() <= EPS, "rotation mismatch: dot={dot}");
}
#[test]
fn inverse_cancels() {
let t = Transform::from_trs(
Vec3::new(3.0, 4.0, 5.0),
Quat::from_rotation_y(0.9),
Vec3::splat(2.0),
);
let id = t.mul_transform(&t.inverse());
assert!(approx_vec(id.translation, Vec3::ZERO));
assert!(approx_vec(id.scale, Vec3::ONE));
assert!(approx_vec(
id.transform_point(Vec3::new(7.0, 8.0, 9.0)),
Vec3::new(7.0, 8.0, 9.0)
));
}
#[test]
fn composition_matches_matrix() {
let a = Transform::from_trs(
Vec3::new(1.0, 0.0, -2.0),
Quat::from_rotation_x(0.4),
Vec3::splat(1.5),
);
let b = Transform::from_trs(
Vec3::new(-3.0, 2.0, 1.0),
Quat::from_rotation_z(-0.8),
Vec3::splat(0.5),
);
let composed = a.mul_transform(&b);
let p = Vec3::new(2.0, -1.0, 3.0);
let via_transform = composed.transform_point(p);
let via_matrix = (a.to_matrix() * b.to_matrix()).transform_point3(p);
assert!(approx_vec(via_transform, via_matrix));
}
#[test]
fn nonuniform_composition_falls_back_to_matrix() {
let a = Transform::from_trs(
Vec3::new(0.0, 1.0, 0.0),
Quat::from_rotation_z(FRAC_PI_2),
Vec3::new(2.0, 1.0, 1.0),
);
let b = Transform::from_trs(
Vec3::new(1.0, 0.0, 0.0),
Quat::IDENTITY,
Vec3::new(1.0, 3.0, 1.0),
);
let composed = a.mul_transform(&b);
let p = Vec3::new(1.0, 2.0, -1.0);
let via_transform = composed.transform_point(p);
let via_matrix = (a.to_matrix() * b.to_matrix()).transform_point3(p);
// Re-decomposition keeps this close even with non-uniform scale.
assert!(
approx_vec(via_transform, via_matrix),
"{via_transform} vs {via_matrix}"
);
}
#[test]
fn zero_scale_is_non_invertible() {
let t = Transform::from_scale(Vec3::new(0.0, 1.0, 1.0));
let inv = t.inverse();
assert!(!inv.scale.x.is_finite());
assert!(t.is_finite()); // the forward transform itself is still finite
}
#[test]
fn gimbal_lock_path_stays_stable() {
// Pitch to +90° (a classic gimbal-lock orientation) and confirm the
// basis vectors remain orthonormal after round-tripping through a matrix.
let t =
Transform::from_rotation(Quat::from_euler(glam::EulerRot::YXZ, 0.0, FRAC_PI_2, 0.0));
let back = Transform::from_matrix(t.to_matrix());
assert!(approx_vec(back.forward(), t.forward()));
assert!(approx_vec(back.up(), t.up()));
// Orthonormality.
assert!(t.forward().dot(t.up()).abs() <= EPS);
assert!(t.right().dot(t.up()).abs() <= EPS);
}
#[test]
fn looking_at_faces_target() {
let t = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
// Forward should point toward the target (-Z world direction).
assert!(approx_vec(t.forward(), Vec3::NEG_Z));
}
#[test]
fn looking_at_degenerate_is_identity_rotation() {
let t = Transform::looking_at(Vec3::ONE, Vec3::ONE, Vec3::Y);
assert_eq!(t.rotation, Quat::IDENTITY);
}
#[test]
fn basis_vectors_for_half_turn() {
let t = Transform::from_rotation(Quat::from_rotation_y(PI));
assert!(approx_vec(t.forward(), Vec3::Z));
assert!(approx_vec(t.right(), Vec3::NEG_X));
}
}