Repository: bitshifter/glam-rs
Pull request #157 (2021-04-06 12:12:56)

Affine3D
Part of https://github.com/bitshifter/glam-rs/issues/25. A simpler version of <https://github.com/bitshifter/glam-rs/pull/156>.

This PR introduces the `Affine3D` type, implemented as `3x Vec4` (NOTE: only `f32` version included).

Banchmark results on my Intel MacBook (best of a few runs):

| op  | `Mat4 sse2` | `Affine3D sse2` | | `Mat4 scalar` | `Affine3D scalar` |
| -------------------- | --------- | -------- | - | ------ | -------- |
| `inverse`            |  `12 ns`  | `9 ns` 🥇   | | `31 ns`  | `16 ns` 🥇   |
| `Self * Self`          |  `6.1 ns`  | `4.2 ns` 🥇 | | `21 ns`  | `14 ns` 🥇   |
| `transform point3`   |  `2.6 ns` 🥇  | `3.8 ns` | | `3.8 ns` | `3.5 ns` 🥇  |
| `transform vector3`  |  `2.5 ns` 🥇  | `3.6 ns` | | `3.3 ns`  | `2.9 ns` 🥇 |
 
( 🥇  is the fastest in each pair of columns)

It would be nice to speed up the sse2 vector transforms more, as those are probably among the most common operations to do with a matrix. I've tried several different approaches, but I can't get further than this. Unless someone has a better idea, I think we should just recommend turning your `Affine3D` into a `Mat4` on SSE2 targets when doing a lot of `mat * vec` transforms.

It is also possible that for some platforms (in particular spirv) a `4x Vec3` may actually be faster.

bitshifter: Thanks for all your work on this!

There are a couple of things I'm not sure about as it currently stands, on naming and being row major.

Naming wise I was thinking of making this a special type, e.g. `Transform3D` or `Affine3D` or something along those lines. There are a few reasons:

* It doesn't tie you to an internal representation, e.g. 4x3 vs 3x4 or rows vs columns
* Methods like `inverse`, `determinant` etc. don't make sense for non square matrices, to be fair I have helper methods on `Mat4` like `transform_point3` etc. that don't make mathematical sense either
* It doesn't make sense to multiply two 3x4 matrices but these transforms be able to be multiplied.
* Making it a special type makes it really clear that it contains a valid affine transform and nothing else

I'm not totally sure about using row major when everything else is column major, it might cause confusion when debugging if the internals behave differently to the other matrix types. For scalar code, the size would be the same for 4x3 or 3x4 and I suspect it wouldn't make a huge difference one way or another for scalar performance. At the moment the SSE2 code isn't benefiting from row major, although I think switching to `Vec3A` in the implementation would get it closer to the `Mat4` performance. Another concern I have is I think accessing the translation and the basis vectors is a very common thing to do and this might be slower if the data is organised in rows, also direct access is nice. Also converting to a `Mat4` or `Mat3` would require a transpose with row major.

emilk: > Naming wise I was thinking of making this a special type, e.g. `Transform3D` or `Affine3D` or something along those lines.

I agree. I think `Affine3D` is the better name here (since it is more specific).

> I'm not totally sure about using row major when everything else is column major.

I made this choice because I wanted to represent the transform using `12xf32`, to save memory (for better cache locality). If we don't care about that then `4xVec3A` columns makes much more sense. What is "best" choice will depend a lot on the circumstance. The extra saved memory can make a big difference to decrease cache misses, but for applications where you can read data in order, it won't matter much at all.

I can make a competing `4xVec3A` column major implementation so we can compare raw numbers. 
emilk: I think if we rename this `Affine3D` we should also hide all functions related to columns and rows to make it clear that this is a higher abstraction.

I've started work on a 4xVec3A version, but am having some problems. Maybe it can wait a bit and come as a follow-up PR where we switch to a (potentially) faster implementation?
bitshifter: I think it makes sense to remove those methods. My approach has usually been to provide a pretty minimal amount of functionality and add things that people are requesting.

No problem skipping the 4xVec3A implementation for now if you are having problems with it.
bitshifter: One thing I was planning on experimenting with was loading scalar types into SIMD to perform math heavy operations, so it wouldn't just be things like `Vec3A` that are `SIMD` optimised. If doing this improve performance of `Mat3` inverse and vector multiplication functions potentially an `Affine3D` type could just be a composition `Mat3` and `Vec3`. It would be similar in essence to the existing transform type https://github.com/bitshifter/glam-rs/blob/master/src/transform.rs#L217-L224. This does depend a bit on the results of my experiments though :)

The SSE2 dot product is not super fast. The column major vector multiplication doesn't require dot product so it should be a lot faster if  SIMD is available, see https://github.com/bitshifter/glam-rs/blob/master/src/core/sse2/matrix.rs#L406-L421.
bitshifter: Related to my comment above, I wrote a bit more detail about where I'm thinking of going with glam here https://github.com/bitshifter/glam-rs/discussions/159. You don't really have to do anything with this information for this PR, just thought it might be useful to know what I'm thinking on the performance side of things.
emilk: Current public interface:

<img width="786" alt="Screen Shot 2021-04-07 at 15 25 33" src="https://user-images.githubusercontent.com/1148717/113874022-a64ef800-97b5-11eb-914b-f4f8a13f7a47.png">

emilk: I have a working 4xVec3A implementation now too, but so far it is slower at simd self*self than the 3xVec4 version. It is faster at transforming vectors though. I'll keep tweaking at it, but it can be a separate PR if this gets merged.
bitshifter: The weird clippy error was just a run of the mill warning due to the method not being used by the `DQuat` implementation. If you still wanted to use it internally you could make it `#[allow(dead_code)]`.

I've been asked to do a release by another embarker, so I might hold off merging this until I've done that. If you wanted you could put it on a feature so it won't affect the release, or just wait. I want to hold off having this on by default until the column major PR lands. I don't expect it to take long to do a release.
bitshifter: The release has been done, so should be good to merge once the conflicts are resolved.
emilk: Rebased to fix merge conflicts in `CHANGELOG.md`

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5a50ad3..145e492 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog], and this project adheres to
 [Semantic Versioning].
 
+## Unreleased
+
+### Added
+
+* Added `Affine3D`: an affine 3D transform.
+
 ## [0.14.0] - 2021-04-09
 
 ### Breaking changes
@@ -20,7 +26,7 @@ The format is based on [Keep a Changelog], and this project adheres to
 
 ### Changed
 
-* Updated dependency versions of `bytemuck` to 1.5, `rand` to 0.8, 
+* Updated dependency versions of `bytemuck` to 1.5, `rand` to 0.8,
   `rand_xoshiro` to 0.6 and `spriv-std` to 0.4.0-alpha4.
 
 ## [0.13.1] - 2021-03-24
diff --git a/Cargo.toml b/Cargo.toml
index 81a8ce2..6a6c998 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -61,6 +61,10 @@ harness = false
 name = "mat3"
 harness = false
 
+[[bench]]
+name = "affine3d"
+harness = false
+
 [[bench]]
 name = "mat4"
 harness = false
diff --git a/benches/affine3d.rs b/benches/affine3d.rs
new file mode 100644
index 0000000..c3866fe
--- /dev/null
+++ b/benches/affine3d.rs
@@ -0,0 +1,71 @@
+#[path = "support/macros.rs"]
+#[macro_use]
+mod macros;
+mod support;
+
+use criterion::{criterion_group, criterion_main, Criterion};
+use glam::Affine3D;
+use std::ops::Mul;
+use support::*;
+
+bench_unop!(affine3d_inverse, "affine3d inverse", op => inverse, from => random_srt_affine3d);
+bench_binop!(
+    affine3d_transform_point3,
+    "affine3d transform point3",
+    op => transform_point3,
+    from1 => random_srt_affine3d,
+    from2 => random_vec3
+);
+
+bench_binop!(
+    affine3d_transform_vector3,
+    "affine3d transform vector3",
+    op => transform_vector3,
+    from1 => random_srt_affine3d,
+    from2 => random_vec3
+);
+bench_binop!(affine3d_mul_affine3d, "affine3d mul affine3d", op => mul, from => random_srt_affine3d);
+bench_binop!(affine3d_mul_mat4, "affine3d mul mat4", op => mul, from1 => random_srt_affine3d, from2 => random_srt_mat4);
+bench_binop!(mat4_mul_affine3d, "mat4 mul affine3d", op => mul, from1 => random_srt_mat4, from2 => random_srt_affine3d);
+
+pub fn affine3d_from_srt(c: &mut Criterion) {
+    use glam::{Quat, Vec3};
+    const SIZE: usize = 1 << 13;
+    let mut rng = support::PCG32::default();
+    let inputs = criterion::black_box(
+        (0..SIZE)
+            .map(|_| {
+                (
+                    random_nonzero_vec3(&mut rng),
+                    random_quat(&mut rng),
+                    random_vec3(&mut rng),
+                )
+            })
+            .collect::<Vec<(Vec3, Quat, Vec3)>>(),
+    );
+    let mut outputs = vec![Affine3D::default(); SIZE];
+    let mut i = 0;
+    c.bench_function("affine3d from srt", |b| {
+        b.iter(|| {
+            i = (i + 1) & (SIZE - 1);
+            unsafe {
+                let data = inputs.get_unchecked(i);
+                *outputs.get_unchecked_mut(i) =
+                    Affine3D::from_scale_rotation_translation(data.0, data.1, data.2)
+            }
+        })
+    });
+}
+
+criterion_group!(
+    benches,
+    affine3d_inverse,
+    affine3d_transform_point3,
+    affine3d_transform_vector3,
+    affine3d_mul_affine3d,
+    affine3d_mul_mat4,
+    mat4_mul_affine3d,
+    affine3d_from_srt,
+);
+
+criterion_main!(benches);
diff --git a/benches/mat4.rs b/benches/mat4.rs
index 4e47252..f0e4748 100644
--- a/benches/mat4.rs
+++ b/benches/mat4.rs
@@ -21,6 +21,22 @@ bench_unop!(
     from => random_srt_mat4
 );
 bench_unop!(mat4_inverse, "mat4 inverse", op => inverse, from => random_srt_mat4);
+bench_binop!(mat4_mul_vec4, "mat4 mul vec4", op => mul, from1 => random_srt_mat4, from2 => random_vec4);
+bench_binop!(
+    mat4_transform_point3,
+    "mat4 transform point3",
+    op => transform_point3,
+    from1 => random_srt_mat4,
+    from2 => random_vec3
+);
+
+bench_binop!(
+    mat4_transform_vector3,
+    "mat4 transform vector3",
+    op => transform_vector3,
+    from1 => random_srt_mat4,
+    from2 => random_vec3
+);
 bench_binop!(mat4_mul_mat4, "mat4 mul mat4", op => mul, from => random_srt_mat4);
 bench_from_ypr!(mat4_from_ypr, "mat4 from ypr", ty => Mat4);
 
@@ -58,6 +74,9 @@ criterion_group!(
     mat4_transpose,
     mat4_determinant,
     mat4_inverse,
+    mat4_mul_vec4,
+    mat4_transform_point3,
+    mat4_transform_vector3,
     mat4_mul_mat4,
     mat4_from_ypr,
     mat4_from_srt,
diff --git a/benches/support/mod.rs b/benches/support/mod.rs
index 5ca6953..f2fc35e 100644
--- a/benches/support/mod.rs
+++ b/benches/support/mod.rs
@@ -1,6 +1,6 @@
 #![allow(dead_code)]
 use core::f32;
-use glam::{Mat2, Mat3, Mat4, Quat, Vec2, Vec3, Vec3A, Vec4};
+use glam::{Affine3D, Mat2, Mat3, Mat4, Quat, Vec2, Vec3, Vec3A, Vec4};
 
 pub struct PCG32 {
     state: u64,
@@ -107,6 +107,15 @@ pub fn random_srt_mat3(rng: &mut PCG32) -> Mat3 {
         random_vec2(rng),
     )
 }
+
+pub fn random_srt_affine3d(rng: &mut PCG32) -> Affine3D {
+    Affine3D::from_scale_rotation_translation(
+        random_nonzero_vec3(rng),
+        random_quat(rng),
+        random_vec3(rng),
+    )
+}
+
 pub fn random_srt_mat4(rng: &mut PCG32) -> Mat4 {
     Mat4::from_scale_rotation_translation(
         random_nonzero_vec3(rng),
diff --git a/benches/vec3.rs b/benches/vec3.rs
index 7d12065..5f7366a 100644
--- a/benches/vec3.rs
+++ b/benches/vec3.rs
@@ -6,7 +6,7 @@ mod support;
 use criterion::{criterion_group, criterion_main, Criterion};
 use glam::Vec3;
 use std::ops::{Add, Mul};
-use support::{random_mat3, random_quat, random_srt_mat4, random_vec3};
+use support::{random_mat3, random_quat, random_vec3};
 
 bench_binop!(
     vec3_add_vec3,
@@ -32,22 +32,6 @@ bench_binop!(
     from2 => random_vec3
 );
 
-bench_binop!(
-    mat4_transform_point3,
-    "mat4 transform point3",
-    op => transform_point3,
-    from1 => random_srt_mat4,
-    from2 => random_vec3
-);
-
-bench_binop!(
-    mat4_transform_vector3,
-    "mat4 transform vector3",
-    op => transform_vector3,
-    from1 => random_srt_mat4,
-    from2 => random_vec3
-);
-
 #[inline]
 fn vec3_to_rgb_op(v: Vec3) -> u32 {
     let (red, green, blue) = (v.min(Vec3::ONE).max(Vec3::ZERO) * 255.0).into();
@@ -184,8 +168,6 @@ bench_select!(
 criterion_group!(
     benches,
     mat3_mul_vec3,
-    mat4_transform_point3,
-    mat4_transform_vector3,
     quat_mul_vec3,
     vec3_add_vec3,
     vec3_angle_between,
diff --git a/benches/vec4.rs b/benches/vec4.rs
index 83973ca..d936c01 100644
--- a/benches/vec4.rs
+++ b/benches/vec4.rs
@@ -5,16 +5,7 @@ mod support;
 
 use criterion::{criterion_group, criterion_main, Criterion};
 use glam::Vec4;
-use std::ops::Mul;
-use support::{random_srt_mat4, random_vec4};
-
-bench_binop!(
-    vec4_mul_mat4,
-    "vec4 mul mat4",
-    op => mul,
-    from1 => random_srt_mat4,
-    from2 => random_vec4
-);
+use support::random_vec4;
 
 bench_select!(
     vec4_select,
@@ -24,6 +15,6 @@ bench_select!(
     from => random_vec4
 );
 
-criterion_group!(benches, vec4_mul_mat4, vec4_select,);
+criterion_group!(benches, vec4_select);
 
 criterion_main!(benches);
diff --git a/src/affine3d.rs b/src/affine3d.rs
new file mode 100644
index 0000000..c9c5fdc
--- /dev/null
+++ b/src/affine3d.rs
@@ -0,0 +1,676 @@
+use crate::{Mat3, Mat4, Quat, Vec3, Vec4};
+
+#[cfg(not(feature = "std"))]
+use num_traits::Float;
+
+#[cfg(all(
+    target_feature = "sse2",
+    not(feature = "scalar-math"),
+    target_arch = "x86"
+))]
+use core::arch::x86::*;
+#[cfg(all(
+    target_feature = "sse2",
+    not(feature = "scalar-math"),
+    target_arch = "x86_64"
+))]
+use core::arch::x86_64::*;
+
+/// An affine transform, which can represent translation, rotation, scaling and shear.
+#[derive(Copy, Clone, PartialEq)]
+pub struct Affine3D {
+    // TODO: explore different representations, such as 4x Vec3A or Mat3 + Vec3.
+    x_row: Vec4,
+    y_row: Vec4,
+    z_row: Vec4,
+}
+
+impl Default for Affine3D {
+    #[inline(always)]
+    fn default() -> Self {
+        Self::IDENTITY
+    }
+}
+
+impl Affine3D {
+    /// The degenerate zero transform.
+    ///
+    /// This transforms any finite vector and point to zero.
+    /// The zero transform is non-invertible.
+    pub const ZERO: Self = Self {
+        x_row: Vec4::ZERO,
+        y_row: Vec4::ZERO,
+        z_row: Vec4::ZERO,
+    };
+
+    /// The identity transform.
+    ///
+    /// Multiplying a vector with this returns the same vector.
+    pub const IDENTITY: Self = Self {
+        x_row: Vec4::X,
+        y_row: Vec4::Y,
+        z_row: Vec4::Z,
+    };
+
+    /// Construct a matrix from its three rows.
+    #[inline(always)]
+    fn from_rows(x_row: Vec4, y_row: Vec4, z_row: Vec4) -> Self {
+        Self {
+            x_row,
+            y_row,
+            z_row,
+        }
+    }
+
+    /// Construct a matrix from its four columns.
+    #[inline(always)]
+    fn from_cols(x_col: Vec3, y_col: Vec3, z_col: Vec3, w_col: Vec3) -> Self {
+        Self {
+            x_row: Vec4::new(x_col.x, y_col.x, z_col.x, w_col.x),
+            y_row: Vec4::new(x_col.y, y_col.y, z_col.y, w_col.y),
+            z_row: Vec4::new(x_col.z, y_col.z, z_col.z, w_col.z),
+        }
+    }
+
+    /// Creates a transformation matrix that changes scale.
+    /// Note that if any scale is zero the transform will be non-invertible.
+    #[inline(always)]
+    pub fn from_scale(scale: Vec3) -> Self {
+        let [x, y, z] = scale.to_array();
+        Self::from_rows(
+            Vec4::new(x, 0.0, 0.0, 0.0),
+            Vec4::new(0.0, y, 0.0, 0.0),
+            Vec4::new(0.0, 0.0, z, 0.0),
+        )
+    }
+
+    /// Creates an affine transformation matrix from the given `rotation` quaternion.
+    ///
+    /// The result can be used to transform 3D points and vectors.
+    /// See [`Self::transform_point3()`] and [`Self::transform_vector3()`].
+    #[inline]
+    pub fn from_quat(rotation: Quat) -> Self {
+        glam_assert!(rotation.is_normalized());
+
+        let x = rotation.x;
+        let y = rotation.y;
+        let z = rotation.z;
+        let w = rotation.w;
+        let x2 = x + x;
+        let y2 = y + y;
+        let z2 = z + z;
+        let xx = x * x2;
+        let xy = x * y2;
+        let xz = x * z2;
+        let yy = y * y2;
+        let yz = y * z2;
+        let zz = z * z2;
+        let wx = w * x2;
+        let wy = w * y2;
+        let wz = w * z2;
+
+        let x_row = Vec4::new(1.0 - (yy + zz), xy - wz, xz + wy, 0.0);
+        let y_row = Vec4::new(xy + wz, 1.0 - (xx + zz), yz - wx, 0.0);
+        let z_row = Vec4::new(xz - wy, yz + wx, 1.0 - (xx + yy), 0.0);
+        Self {
+            x_row,
+            y_row,
+            z_row,
+        }
+    }
+
+    /// Creates an affine transformation matrix containing a 3D rotation around a normalized
+    /// rotation `axis` of `angle` (in radians).
+    ///
+    /// The result can be used to transform 3D points and vectors.
+    /// See [`Self::transform_point3()`] and [`Self::transform_vector3()`].
+    #[inline]
+    pub fn from_axis_angle(axis: Vec3, angle: f32) -> Self {
+        glam_assert!(axis.is_normalized());
+        let (sin, cos) = angle.sin_cos();
+        let axis_sin = axis * sin;
+        let axis_sq = axis * axis;
+        let omc = 1.0 - cos;
+        let xyomc = axis.x * axis.y * omc;
+        let xzomc = axis.x * axis.z * omc;
+        let yzomc = axis.y * axis.z * omc;
+        Self::from_rows(
+            Vec4::new(
+                axis_sq.x * omc + cos,
+                xyomc - axis_sin.z,
+                xzomc - axis_sin.y,
+                0.0,
+            ),
+            Vec4::new(
+                xyomc + axis_sin.z,
+                axis_sq.y * omc + cos,
+                yzomc + axis_sin.x,
+                0.0,
+            ),
+            Vec4::new(
+                xzomc - axis_sin.y,
+                yzomc - axis_sin.x,
+                axis_sq.z * omc + cos,
+                0.0,
+            ),
+        )
+    }
+
+    /// Creates an affine transformation matrix containing a 3D rotation around the x axis of
+    /// `angle` (in radians).
+    ///
+    /// The result can be used to transform 3D points and vectors.
+    /// See [`Self::transform_point3()`] and [`Self::transform_vector3()`].
+    #[inline]
+    pub fn from_rotation_x(angle: f32) -> Self {
+        let (sina, cosa) = angle.sin_cos();
+        Self::from_rows(
+            Vec4::new(1.0, 0.0, 0.0, 0.0),
+            Vec4::new(0.0, cosa, -sina, 0.0),
+            Vec4::new(0.0, sina, cosa, 0.0),
+        )
+    }
+
+    /// Creates an affine transformation matrix containing a 3D rotation around the y axis of
+    /// `angle` (in radians).
+    ///
+    /// The result can be used to transform 3D points and vectors.
+    /// See [`Self::transform_point3()`] and [`Self::transform_vector3()`].
+    #[inline]
+    pub fn from_rotation_y(angle: f32) -> Self {
+        let (sina, cosa) = angle.sin_cos();
+        Self::from_rows(
+            Vec4::new(cosa, 0.0, sina, 0.0),
+            Vec4::new(0.0, 1.0, 0.0, 0.0),
+            Vec4::new(-sina, 0.0, cosa, 0.0),
+        )
+    }
+
+    /// Creates an affine transformation matrix containing a 3D rotation around the z axis of
+    /// `angle` (in radians).
+    ///
+    /// The result can be used to transform 3D points and vectors.
+    /// See [`Self::transform_point3()`] and [`Self::transform_vector3()`].
+    #[inline]
+    pub fn from_rotation_z(angle: f32) -> Self {
+        let (sina, cosa) = angle.sin_cos();
+        Self::from_rows(
+            Vec4::new(cosa, -sina, 0.0, 0.0),
+            Vec4::new(sina, cosa, 0.0, 0.0),
+            Vec4::new(0.0, 0.0, 1.0, 0.0),
+        )
+    }
+
+    /// Creates an affine transformation from the given 3D `translation`.
+    ///
+    /// The result can be used to transform 3D points and vectors.
+    /// See [`Self::transform_point3()`] and [`Self::transform_vector3()`].
+    #[inline(always)]
+    pub fn from_translation(translation: Vec3) -> Self {
+        Self::from_rows(
+            Vec4::new(1.0, 0.0, 0.0, translation.x),
+            Vec4::new(0.0, 1.0, 0.0, translation.y),
+            Vec4::new(0.0, 0.0, 1.0, translation.z),
+        )
+    }
+
+    /// Creates an affine transformation from a `Mat3` (expressing scale, shear and rotation)
+    #[inline(always)]
+    pub fn from_mat3(mat3: Mat3) -> Self {
+        let t = mat3.transpose();
+        Self {
+            x_row: t.x_axis.extend(0.0),
+            y_row: t.y_axis.extend(0.0),
+            z_row: t.z_axis.extend(0.0),
+        }
+    }
+
+    /// Creates an affine transformation from a `Mat3` (expressing scale, shear and rotation)
+    /// and a translation vector.
+    ///
+    /// Equivalent to `Affine3D::from_translation(translation) * Affine3D::from_mat3(mat3)`
+    ///
+    /// The result can be used to transform 3D points and vectors.
+    /// See [`Self::transform_point3()`] and [`Self::transform_vector3()`].
+    #[inline(always)]
+    pub fn from_mat3_translation(mat3: Mat3, translation: Vec3) -> Self {
+        let t = mat3.transpose();
+        Self {
+            x_row: t.x_axis.extend(translation.x),
+            y_row: t.y_axis.extend(translation.y),
+            z_row: t.z_axis.extend(translation.z),
+        }
+    }
+
+    /// Creates an affine transformation from the given 3D `scale`, `rotation` and
+    /// `translation`.
+    ///
+    /// Equivalent to `Affine3D::from_translation(translation) * Affine3D::from_quat(rotation) * Affine3D::from_scale(scale)`
+    ///
+    /// The result can be used to transform 3D points and vectors.
+    /// See [`Self::transform_point3()`] and [`Self::transform_vector3()`].
+    #[inline(always)]
+    pub fn from_scale_rotation_translation(scale: Vec3, rotation: Quat, translation: Vec3) -> Self {
+        let scale4 = scale.extend(0.0);
+        let mut m = Self::from_quat(rotation);
+        m.x_row *= scale4;
+        m.y_row *= scale4;
+        m.z_row *= scale4;
+        m.x_row.w = translation.x;
+        m.y_row.w = translation.y;
+        m.z_row.w = translation.z;
+        m
+    }
+
+    /// Creates an affine transformation from the given 3D `rotation` and `translation`.
+    ///
+    /// Equivalent to `Affine3D::from_translation(translation) * Affine3D::from_quat(rotation)`
+    ///
+    /// The result can be used to transform 3D points and vectors.
+    /// See [`Self::transform_point3()`] and [`Self::transform_vector3()`].
+    #[inline(always)]
+    pub fn from_rotation_translation(rotation: Quat, translation: Vec3) -> Self {
+        let mut m = Self::from_quat(rotation);
+        m.x_row.w = translation.x;
+        m.y_row.w = translation.y;
+        m.z_row.w = translation.z;
+        m
+    }
+
+    /// The given `Mat4` must be an affine transform,
+    /// i.e. contain no persepctive transform.
+    #[inline]
+    pub fn from_mat4(m: Mat4) -> Self {
+        Self::from_cols(
+            m.x_axis.truncate(),
+            m.y_axis.truncate(),
+            m.z_axis.truncate(),
+            m.w_axis.truncate(),
+        )
+    }
+
+    /// The first column.
+    #[inline(always)]
+    fn x_col(&self) -> Vec3 {
+        Vec3::new(self.x_row.x, self.y_row.x, self.z_row.x)
+    }
+
+    /// The second column.
+    #[inline(always)]
+    fn y_col(&self) -> Vec3 {
+        Vec3::new(self.x_row.y, self.y_row.y, self.z_row.y)
+    }
+
+    /// The thirs column.
+    #[inline(always)]
+    fn z_col(&self) -> Vec3 {
+        Vec3::new(self.x_row.z, self.y_row.z, self.z_row.z)
+    }
+
+    /// The translation expressed by this transform.
+    /// The translation is applied last, so is separatable from scale, shear and rotation.
+    #[inline(always)]
+    pub fn translation(&self) -> Vec3 {
+        Vec3::new(self.x_row.w, self.y_row.w, self.z_row.w)
+    }
+
+    /// Set the translation part of this transform.
+    /// The translation is applied last, so is separatable from scale, shear and rotation.
+    #[inline(always)]
+    pub fn set_translation(&mut self, translation: Vec3) {
+        self.x_row.w = translation.x;
+        self.y_row.w = translation.y;
+        self.z_row.w = translation.z;
+    }
+
+    /// The scale, shear and rotation expressed by this transform.
+    #[inline(always)]
+    pub fn mat3(&self) -> Mat3 {
+        Mat3::from_cols(
+            self.x_row.truncate(),
+            self.y_row.truncate(),
+            self.z_row.truncate(),
+        )
+        .transpose()
+    }
+
+    /// Set the scale, shear and rotation expressed by this transform.
+    #[inline(always)]
+    pub fn set_mat3(&mut self, mat3: Mat3) {
+        let t = mat3.transpose();
+        self.x_row = t.x_axis.extend(self.x_row.w);
+        self.y_row = t.y_axis.extend(self.y_row.w);
+        self.z_row = t.z_axis.extend(self.z_row.w);
+    }
+
+    /// The first column extended with `0`.
+    #[inline(always)]
+    fn x_col_extended_0(&self) -> Vec4 {
+        Vec4::new(self.x_row.x, self.y_row.x, self.z_row.x, 0.0)
+    }
+
+    /// The second column extended with `0`.
+    #[inline(always)]
+    fn y_col_extended_0(&self) -> Vec4 {
+        Vec4::new(self.x_row.y, self.y_row.y, self.z_row.y, 0.0)
+    }
+
+    /// The thirs column extended with `0`.
+    #[inline(always)]
+    fn z_col_extended_0(&self) -> Vec4 {
+        Vec4::new(self.x_row.z, self.y_row.z, self.z_row.z, 0.0)
+    }
+
+    /// The fourth column extended with `1`.
+    #[inline(always)]
+    fn w_col_extended_1(&self) -> Vec4 {
+        Vec4::new(self.x_row.w, self.y_row.w, self.z_row.w, 1.0)
+    }
+
+    /// Extracts `scale`, `rotation` and `translation` from `self`.
+    ///
+    /// The transform is expected to be non-degenerate and without shearing, or the output will be invalid.
+    #[inline(always)]
+    pub fn to_scale_rotation_translation(&self) -> (Vec3, Quat, Vec3) {
+        let det = self.determinant();
+        glam_assert!(det != 0.0);
+
+        let x_col = self.x_col();
+        let y_col = self.y_col();
+        let z_col = self.z_col();
+
+        let scale = Vec3::new(
+            x_col.length() * det.signum(),
+            y_col.length(),
+            z_col.length(),
+        );
+
+        glam_assert!(scale.cmpne(Vec3::ZERO).all());
+
+        let inv_scale = scale.recip();
+
+        let rotation = Quat::from_rotation_mat3(&Mat3::from_cols(
+            x_col * inv_scale.x,
+            y_col * inv_scale.y,
+            z_col * inv_scale.z,
+        ));
+
+        (scale, rotation, self.translation())
+    }
+
+    /// If this is zero, or close to zero, it is a singular transform (zero-scale on at least one axis).
+    #[inline]
+    fn determinant(&self) -> f32 {
+        // As if the last row was `[0, 0, 0, 1]`.
+        let [m00, m01, m02, _] = self.x_row.to_array();
+        let [m10, m11, m12, _] = self.y_row.to_array();
+        let [m20, m21, m22, _] = self.z_row.to_array();
+
+        m00 * m11 * m22 - m00 * m12 * m21 - m01 * m10 * m22 + m01 * m12 * m20 + m02 * m10 * m21
+            - m02 * m11 * m20
+    }
+
+    /// Multiply with a column vector (`m * v`)
+    #[inline(always)]
+    fn mul_vec4(&self, rhs: Vec4) -> Vec3 {
+        Vec3::new(
+            self.x_row.dot(rhs),
+            self.y_row.dot(rhs),
+            self.z_row.dot(rhs),
+        )
+    }
+
+    /// Multiply with a row vector (`x * m`)
+    /// as if we were a 4x4 matrix (implict `[0, 0, 0, 1]` last row)
+    #[inline(always)]
+    fn mul_row_vec4_from_left(&self, lhs: Vec4) -> Vec4 {
+        Vec4::new(
+            self.x_col_extended_0().dot(lhs),
+            self.y_col_extended_0().dot(lhs),
+            self.z_col_extended_0().dot(lhs),
+            self.w_col_extended_1().dot(lhs),
+        )
+    }
+
+    #[inline]
+    fn look_to_lh(eye: Vec3, dir: Vec3, up: Vec3) -> Self {
+        let f = dir.normalize();
+        let s = up.cross(f).normalize();
+        let u = f.cross(s);
+        Self::from_rows(
+            s.extend(-s.dot(eye)),
+            u.extend(-u.dot(eye)),
+            f.extend(-f.dot(eye)),
+        )
+    }
+
+    /// Creates a left-handed view transform using a camera position, an up direction, and a focal
+    /// point.
+    /// For a view coordinate system with `+X=right`, `+Y=up` and `+Z=forward`.
+    #[inline]
+    pub fn look_at_lh(eye: Vec3, center: Vec3, up: Vec3) -> Self {
+        glam_assert!(up.is_normalized());
+        Self::look_to_lh(eye, center - eye, up)
+    }
+
+    /// Creates a right-handed view transform using a camera position, an up direction, and a focal
+    /// point.
+    /// For a view coordinate system with `+X=right`, `+Y=up` and `+Z=back`.
+    #[inline]
+    pub fn look_at_rh(eye: Vec3, center: Vec3, up: Vec3) -> Self {
+        glam_assert!(up.is_normalized());
+        Self::look_to_lh(eye, eye - center, up)
+    }
+
+    /// Transforms the given 3D points, applying shear, scale, rotation and translatio.
+    #[inline]
+    pub fn transform_point3(&self, point: Vec3) -> Vec3 {
+        self.mul_vec4(point.extend(1.0))
+    }
+
+    /// Transforms the give 3D vector, applying shear, scale and rotation (but NOT translation).
+    ///
+    /// To also apply translation, use [`Self::transform_point3`] instead.
+    #[inline]
+    #[cfg(any(
+        target_arch = "spirv",
+        all(target_feature = "sse2", not(feature = "scalar-math"))
+    ))]
+    pub fn transform_vector3(&self, vec: Vec3) -> Vec3 {
+        self.mul_vec4(vec.extend(0.0))
+    }
+
+    /// Transforms the give 3D vector, applying shear, scale and rotation (but NOT translation).
+    ///
+    /// To also apply translation, use [`Self::transform_point3`] instead.
+    #[inline]
+    #[cfg(not(any(
+        target_arch = "spirv",
+        all(target_feature = "sse2", not(feature = "scalar-math"))
+    )))]
+    pub fn transform_vector3(&self, vec: Vec3) -> Vec3 {
+        Vec3::new(
+            Vec3::from(self.x_row).dot(vec),
+            Vec3::from(self.y_row).dot(vec),
+            Vec3::from(self.z_row).dot(vec),
+        )
+    }
+
+    /// Returns `true` if, and only if, all elements are finite.
+    /// If any element is either `NaN`, positive or negative infinity, this will return `false`.
+    #[inline]
+    pub fn is_finite(&self) -> bool {
+        self.x_row.is_finite() && self.y_row.is_finite() && self.z_row.is_finite()
+    }
+
+    /// Returns `true` if any elements are `NaN`.
+    #[inline]
+    pub fn is_nan(&self) -> bool {
+        self.x_row.is_nan() || self.y_row.is_nan() || self.z_row.is_nan()
+    }
+
+    /// Returns true if the absolute difference of all elements between `self` and `other`
+    /// is less than or equal to `max_abs_diff`.
+    ///
+    /// This can be used to compare if two 3x4 matrices contain similar elements. It works
+    /// best when comparing with a known value. The `max_abs_diff` that should be used used
+    /// depends on the values being compared against.
+    ///
+    /// For more see
+    /// [comparing floating point numbers](https://randomascii.wordpress.com/2012/02/25/comparing-floating-point-numbers-2012-edition/).
+    #[inline]
+    pub fn abs_diff_eq(&self, other: Self, max_abs_diff: f32) -> bool {
+        self.x_row.abs_diff_eq(other.x_row, max_abs_diff)
+            && self.y_row.abs_diff_eq(other.y_row, max_abs_diff)
+            && self.z_row.abs_diff_eq(other.z_row, max_abs_diff)
+    }
+
+    /// Return the inverse of this transform.
+    ///
+    /// The result of this is only valid if [`Self::is_invertible`] is true.
+    pub fn inverse(&self) -> Self {
+        // invert 3x3 matrix:
+        use crate::Vec3A;
+        let x_row3: Vec3A = self.x_row.into();
+        let y_row3: Vec3A = self.y_row.into();
+        let z_row3: Vec3A = self.z_row.into();
+
+        let x_col = y_row3.cross(z_row3);
+        let y_col = z_row3.cross(x_row3);
+        let z_col = x_row3.cross(y_row3);
+        let det = z_row3.dot(z_col);
+        let inv_det = det.recip();
+        let x_col = x_col * inv_det;
+        let y_col = y_col * inv_det;
+        let z_col = z_col * inv_det;
+
+        // transform negative translation by the 3x3 inverse:
+        let w_col = -(x_col * self.x_row.w + y_col * self.y_row.w + z_col * self.z_row.w);
+
+        Self {
+            x_row: Vec4::new(x_col.x, y_col.x, z_col.x, w_col.x),
+            y_row: Vec4::new(x_col.y, y_col.y, z_col.y, w_col.y),
+            z_row: Vec4::new(x_col.z, y_col.z, z_col.z, w_col.z),
+        }
+    }
+}
+
+#[cfg(not(target_arch = "spirv"))]
+impl core::fmt::Debug for Affine3D {
+    fn fmt(&self, fmt: &mut core::fmt::Formatter) -> core::fmt::Result {
+        fmt.debug_struct(stringify!($affine3d))
+            .field("x_row", &self.x_row)
+            .field("y_row", &self.y_row)
+            .field("z_row", &self.z_row)
+            .finish()
+    }
+}
+
+impl From<Affine3D> for Mat4 {
+    #[inline]
+    fn from(m: Affine3D) -> Mat4 {
+        Mat4::from_cols(
+            m.x_col_extended_0(),
+            m.y_col_extended_0(),
+            m.z_col_extended_0(),
+            m.w_col_extended_1(),
+        )
+    }
+}
+
+impl core::ops::Mul for Affine3D {
+    type Output = Affine3D;
+
+    #[cfg(all(target_feature = "sse2", not(feature = "scalar-math")))]
+    #[inline(always)]
+    fn mul(self, rhs: Affine3D) -> Self::Output {
+        use crate::core::traits::vector::Vector4;
+        #[cfg(target_arch = "x86")]
+        use core::arch::x86::*;
+        #[cfg(target_arch = "x86_64")]
+        use core::arch::x86_64::*;
+
+        unsafe {
+            let lhs_x_row: __m128 = self.x_row.into();
+            let lhs_y_row: __m128 = self.y_row.into();
+            let lhs_z_row: __m128 = self.z_row.into();
+
+            let rhs_x_row: __m128 = rhs.x_row.into();
+            let rhs_y_row: __m128 = rhs.y_row.into();
+            let rhs_z_row: __m128 = rhs.z_row.into();
+            let rhs_w_row = _mm_set_ps(1.0, 0.0, 0.0, 0.0); // TODO: optimize based on this being `[0, 0, 0, 1]`.
+
+            // Based on https://github.com/microsoft/DirectXMath XMMatrixMultiply
+
+            let v_x = _mm_mul_ps(lhs_x_row.splat_x(), rhs_x_row);
+            let v_y = _mm_mul_ps(lhs_x_row.splat_y(), rhs_y_row);
+            let v_z = _mm_mul_ps(lhs_x_row.splat_z(), rhs_z_row);
+            let v_w = _mm_mul_ps(lhs_x_row.splat_w(), rhs_w_row);
+            let out_x_row = _mm_add_ps(_mm_add_ps(v_x, v_z), _mm_add_ps(v_y, v_w));
+
+            let v_x = _mm_mul_ps(lhs_y_row.splat_x(), rhs_x_row);
+            let v_y = _mm_mul_ps(lhs_y_row.splat_y(), rhs_y_row);
+            let v_z = _mm_mul_ps(lhs_y_row.splat_z(), rhs_z_row);
+            let v_w = _mm_mul_ps(lhs_y_row.splat_w(), rhs_w_row);
+            let out_y_row = _mm_add_ps(_mm_add_ps(v_x, v_z), _mm_add_ps(v_y, v_w));
+
+            let v_x = _mm_mul_ps(lhs_z_row.splat_x(), rhs_x_row);
+            let v_y = _mm_mul_ps(lhs_z_row.splat_y(), rhs_y_row);
+            let v_z = _mm_mul_ps(lhs_z_row.splat_z(), rhs_z_row);
+            let v_w = _mm_mul_ps(lhs_z_row.splat_w(), rhs_w_row);
+            let out_z_row = _mm_add_ps(_mm_add_ps(v_x, v_z), _mm_add_ps(v_y, v_w));
+
+            Self {
+                x_row: out_x_row.into(),
+                y_row: out_y_row.into(),
+                z_row: out_z_row.into(),
+            }
+        }
+    }
+
+    #[cfg(target_arch = "spirv")]
+    #[inline(always)]
+    fn mul(self, rhs: Affine3D) -> Self::Output {
+        Affine3D::from_mat4(Mat4::from(self) * Mat4::from(rhs))
+    }
+
+    #[cfg(not(any(
+        target_arch = "spirv",
+        all(target_feature = "sse2", not(feature = "scalar-math"))
+    )))]
+    #[inline(always)]
+    fn mul(self, rhs: Affine3D) -> Self::Output {
+        Self {
+            x_row: rhs.mul_row_vec4_from_left(self.x_row),
+            y_row: rhs.mul_row_vec4_from_left(self.y_row),
+            z_row: rhs.mul_row_vec4_from_left(self.z_row),
+        }
+    }
+}
+
+impl core::ops::Mul<Mat4> for Affine3D {
+    type Output = Mat4;
+
+    #[inline(always)]
+    fn mul(self, rhs: Mat4) -> Self::Output {
+        Mat4::from(self) * rhs
+    }
+}
+
+impl core::ops::Mul<Affine3D> for Mat4 {
+    type Output = Mat4;
+
+    #[inline(always)]
+    fn mul(self, rhs: Affine3D) -> Self::Output {
+        self * Mat4::from(rhs)
+    }
+}
+
+impl<'a> core::iter::Product<&'a Self> for Affine3D {
+    fn product<I>(iter: I) -> Self
+    where
+        I: Iterator<Item = &'a Self>,
+    {
+        iter.fold(Self::IDENTITY, |a, &b| a * b)
+    }
+}
diff --git a/src/core/traits/quaternion.rs b/src/core/traits/quaternion.rs
index eb37f4f..427e906 100644
--- a/src/core/traits/quaternion.rs
+++ b/src/core/traits/quaternion.rs
@@ -55,6 +55,7 @@ pub trait Quaternion<T: FloatEx>: FloatVector4<T> {
         Self::new(T::ZERO, T::ZERO, s, c)
     }
 
+    /// From the columns of a 3x3 rotation matrix.
     #[inline]
     fn from_rotation_axes(x_axis: XYZ<T>, y_axis: XYZ<T>, z_axis: XYZ<T>) -> Self {
         // Based on https://github.com/microsoft/DirectXMath `XM$quaternionRotationMatrix`
diff --git a/src/features/impl_serde.rs b/src/features/impl_serde.rs
index 7c1c2b3..ed259f3 100644
--- a/src/features/impl_serde.rs
+++ b/src/features/impl_serde.rs
@@ -607,3 +607,111 @@ mod u32 {
 
     impl_serde_vec_types!(u32, UVec2, UVec3, UVec4);
 }
+
+mod affine3d {
+    use crate::{Affine3D, Mat3, Vec3};
+    use core::fmt;
+    use serde::{
+        de::{self, Deserialize, Deserializer, SeqAccess, Visitor},
+        ser::{Serialize, SerializeTupleStruct, Serializer},
+    };
+
+    impl Serialize for Affine3D {
+        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+        where
+            S: Serializer,
+        {
+            // Serialize column-wise as 3x4 matrix:
+            let mat3 = self.mat3();
+            let w_axis = self.translation();
+            let mut state = serializer.serialize_tuple_struct("Affine3D", 12)?;
+            state.serialize_field(&mat3.x_axis.x)?;
+            state.serialize_field(&mat3.x_axis.y)?;
+            state.serialize_field(&mat3.x_axis.z)?;
+            state.serialize_field(&mat3.y_axis.x)?;
+            state.serialize_field(&mat3.y_axis.y)?;
+            state.serialize_field(&mat3.y_axis.z)?;
+            state.serialize_field(&mat3.z_axis.x)?;
+            state.serialize_field(&mat3.z_axis.y)?;
+            state.serialize_field(&mat3.z_axis.z)?;
+            state.serialize_field(&w_axis.x)?;
+            state.serialize_field(&w_axis.y)?;
+            state.serialize_field(&w_axis.z)?;
+            state.end()
+        }
+    }
+
+    impl<'de> Deserialize<'de> for Affine3D {
+        fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+        where
+            D: Deserializer<'de>,
+        {
+            struct Affine3DVisitor;
+
+            impl<'de> Visitor<'de> for Affine3DVisitor {
+                type Value = Affine3D;
+
+                fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+                    formatter.write_str("struct Affine3D")
+                }
+
+                fn visit_seq<V>(self, mut seq: V) -> Result<Affine3D, V::Error>
+                where
+                    V: SeqAccess<'de>,
+                {
+                    let mut f = { [0.0; 12] };
+                    for (i, v) in f.iter_mut().enumerate() {
+                        *v = seq
+                            .next_element()?
+                            .ok_or_else(|| de::Error::invalid_length(i, &self))?;
+                    }
+                    let m = Mat3::from_cols_array(&[
+                        f[0], f[1], f[2], //
+                        f[3], f[4], f[5], //
+                        f[6], f[7], f[8], //
+                    ]);
+                    let t = Vec3::new(f[9], f[10], f[11]);
+                    Ok(Affine3D::from_mat3_translation(m, t))
+                }
+            }
+
+            deserializer.deserialize_tuple_struct("Affine3D", 12, Affine3DVisitor)
+        }
+    }
+
+    #[test]
+    fn test_affine3d_serde() {
+        use crate::{Quat, Vec3};
+
+        let a = Affine3D::from_scale_rotation_translation(
+            Vec3::new(1.0, 2.0, 3.0),
+            Quat::IDENTITY,
+            Vec3::new(4.0, 5.0, 6.0),
+        );
+        let serialized = serde_json::to_string(&a).unwrap();
+        assert_eq!(
+            serialized,
+            "[1.0,0.0,0.0,0.0,2.0,0.0,0.0,0.0,3.0,4.0,5.0,6.0]"
+        );
+        let deserialized = serde_json::from_str(&serialized).unwrap();
+        assert_eq!(a, deserialized);
+
+        let deserialized = serde_json::from_str::<Affine3D>("[]");
+        assert!(deserialized.is_err());
+        let deserialized = serde_json::from_str::<Affine3D>("[1.0]");
+        assert!(deserialized.is_err());
+        let deserialized = serde_json::from_str::<Affine3D>("[1.0,2.0]");
+        assert!(deserialized.is_err());
+        let deserialized = serde_json::from_str::<Affine3D>("[1.0,2.0,3.0]");
+        assert!(deserialized.is_err());
+        let deserialized = serde_json::from_str::<Affine3D>("[1.0,2.0,3.0,4.0,5.0]");
+        assert!(deserialized.is_err());
+        let deserialized =
+            serde_json::from_str::<Affine3D>("[[1.0,2.0,3.0],[4.0,5.0,6.0],[7.0,8.0,9.0]]");
+        assert!(deserialized.is_err());
+        let deserialized = serde_json::from_str::<Affine3D>(
+            "[[1.0,2.0,3.0,4.0],[5.0,6.0,7.0,8.0],[9.0,10.0,11.0,12.0][13.0,14.0,15.0,16.0]]",
+        );
+        assert!(deserialized.is_err());
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index ca760c9..20bdf14 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -196,6 +196,7 @@ mod vec;
 #[doc(hidden)]
 pub mod cast;
 
+mod affine3d;
 mod core;
 mod mat2;
 mod mat3;
@@ -224,6 +225,7 @@ pub use self::bool::*;
 
 /** `f32` vector, quaternion and matrix types. */
 pub mod f32 {
+    pub use super::affine3d::Affine3D;
     pub use super::mat2::{mat2, Mat2};
     pub use super::mat3::{mat3, Mat3};
     pub use super::mat4::{mat4, Mat4};
diff --git a/src/mat4.rs b/src/mat4.rs
index 7642138..3e4602c 100644
--- a/src/mat4.rs
+++ b/src/mat4.rs
@@ -339,6 +339,7 @@ macro_rules! impl_mat4_methods {
 
         /// Creates a left-handed view matrix using a camera position, an up direction, and a focal
         /// point.
+        /// For a view coordinate system with `+X=right`, `+Y=up` and `+Z=forward`.
         #[inline(always)]
         pub fn look_at_lh(eye: $vec3, center: $vec3, up: $vec3) -> Self {
             Self($inner::look_at_lh(eye.0, center.0, up.0))
@@ -346,6 +347,7 @@ macro_rules! impl_mat4_methods {
 
         /// Creates a right-handed view matrix using a camera position, an up direction, and a focal
         /// point.
+        /// For a view coordinate system with `+X=right`, `+Y=up` and `+Z=back`.
         #[inline(always)]
         pub fn look_at_rh(eye: $vec3, center: $vec3, up: $vec3) -> Self {
             Self($inner::look_at_rh(eye.0, center.0, up.0))
diff --git a/src/vec2.rs b/src/vec2.rs
index c3cf913..0d87ef9 100644
--- a/src/vec2.rs
+++ b/src/vec2.rs
@@ -53,6 +53,12 @@ macro_rules! impl_vec2_common_methods {
             $vec3::new(self.x, self.y, z)
         }
 
+        /// `[x, y]`
+        #[inline(always)]
+        pub fn to_array(&self) -> [$t; 2] {
+            [self.x, self.y]
+        }
+
         impl_vecn_common_methods!($t, $vec2, $mask, $inner, Vector2);
     };
 }
diff --git a/src/vec3.rs b/src/vec3.rs
index fb02465..02cae76 100644
--- a/src/vec3.rs
+++ b/src/vec3.rs
@@ -100,6 +100,12 @@ macro_rules! impl_vec3_common_methods {
             Self(self.0.cross(other.0))
         }
 
+        /// `[x, y, z]`
+        #[inline(always)]
+        pub fn to_array(&self) -> [$t; 3] {
+            [self.x, self.y, self.z]
+        }
+
         impl_vecn_common_methods!($t, $vec3, $mask, $inner, Vector3);
     };
 }
diff --git a/src/vec4.rs b/src/vec4.rs
index 41a6be6..86e87bd 100644
--- a/src/vec4.rs
+++ b/src/vec4.rs
@@ -95,6 +95,12 @@ macro_rules! impl_vec4_common_methods {
             $vec3::new(self.x, self.y, self.z)
         }
 
+        /// `[x, y, z, w]`
+        #[inline(always)]
+        pub fn to_array(&self) -> [$t; 4] {
+            [self.x, self.y, self.z, self.w]
+        }
+
         impl_vecn_common_methods!($t, $vec4, $mask, $inner, Vector4);
     };
 }


Test patch:
diff --git a/tests/affine3d.rs b/tests/affine3d.rs
new file mode 100644
index 0000000..3f64ba7
--- /dev/null
+++ b/tests/affine3d.rs
@@ -0,0 +1,252 @@
+#[macro_use]
+mod support;
+
+macro_rules! impl_affine3d_tests {
+    ($t:ident, $affine3d:ident, $quat:ident, $vec4:ident, $vec3:ident) => {
+        use core::$t::NAN;
+        use core::$t::NEG_INFINITY;
+
+        #[test]
+        fn test_affine3d_identity() {
+            assert_eq!(
+                $affine3d::IDENTITY,
+                $affine3d::IDENTITY * $affine3d::IDENTITY
+            );
+            assert_eq!($affine3d::IDENTITY, $affine3d::default());
+        }
+
+        #[test]
+        fn test_affine3d_zero() {
+            assert_eq!(
+                $affine3d::ZERO.transform_point3($vec3::new(1., 2., 3.)),
+                $vec3::ZERO
+            );
+        }
+
+        #[test]
+        fn test_affine3d_translation() {
+            let translate = $affine3d::from_translation($vec3::new(1.0, 2.0, 3.0));
+            assert_eq!(translate.translation(), $vec3::new(1.0, 2.0, 3.0));
+            assert_eq!(
+                translate.transform_point3($vec3::new(2.0, 3.0, 4.0)),
+                $vec3::new(3.0, 5.0, 7.0),
+            );
+        }
+
+        #[test]
+        fn test_from_rotation() {
+            let eps = 2.0 * core::f32::EPSILON;
+            let rot_x1 = $affine3d::from_rotation_x(deg(180.0));
+            let rot_x2 = $affine3d::from_axis_angle($vec3::X, deg(180.0));
+            assert_approx_eq!(rot_x1, rot_x2, eps);
+            let rot_y1 = $affine3d::from_rotation_y(deg(180.0));
+            let rot_y2 = $affine3d::from_axis_angle($vec3::Y, deg(180.0));
+            assert_approx_eq!(rot_y1, rot_y2, eps);
+            let rot_z1 = $affine3d::from_rotation_z(deg(180.0));
+            let rot_z2 = $affine3d::from_axis_angle($vec3::Z, deg(180.0));
+            assert_approx_eq!(rot_z1, rot_z2, eps);
+        }
+
+        #[test]
+        fn test_affine3d_mul() {
+            let m = $affine3d::from_axis_angle($vec3::Z, deg(90.0));
+            let result3 = m.transform_vector3($vec3::Y);
+            assert_approx_eq!($vec3::new(-1.0, 0.0, 0.0), result3);
+
+            let m = $affine3d::from_scale_rotation_translation(
+                $vec3::new(0.5, 1.5, 2.0),
+                $quat::from_rotation_x(deg(90.0)),
+                $vec3::new(1.0, 2.0, 3.0),
+            );
+            let result3 = m.transform_vector3($vec3::Y);
+            assert_approx_eq!($vec3::new(0.0, 0.0, 1.5), result3, 1.0e-6);
+
+            let result3 = m.transform_point3($vec3::Y);
+            assert_approx_eq!($vec3::new(1.0, 2.0, 4.5), result3, 1.0e-6);
+        }
+
+        #[test]
+        fn test_from_scale() {
+            let m = $affine3d::from_scale($vec3::new(2.0, 4.0, 8.0));
+            assert_approx_eq!(
+                m.transform_point3($vec3::new(1.0, 1.0, 1.0)),
+                $vec3::new(2.0, 4.0, 8.0)
+            );
+        }
+
+        #[test]
+        fn test_affine3d_inverse() {
+            let inv = $affine3d::IDENTITY.inverse();
+            assert_approx_eq!($affine3d::IDENTITY, inv);
+
+            let rotz = $affine3d::from_rotation_z(deg(90.0));
+            let rotz_inv = rotz.inverse();
+            assert_approx_eq!($affine3d::IDENTITY, rotz * rotz_inv);
+            assert_approx_eq!($affine3d::IDENTITY, rotz_inv * rotz);
+
+            let trans = $affine3d::from_translation($vec3::new(1.0, 2.0, 3.0));
+            let trans_inv = trans.inverse();
+            assert_approx_eq!($affine3d::IDENTITY, trans * trans_inv);
+            assert_approx_eq!($affine3d::IDENTITY, trans_inv * trans);
+
+            let scale = $affine3d::from_scale($vec3::new(4.0, 5.0, 6.0));
+            let scale_inv = scale.inverse();
+            assert_approx_eq!($affine3d::IDENTITY, scale * scale_inv);
+            assert_approx_eq!($affine3d::IDENTITY, scale_inv * scale);
+
+            let m = scale * rotz * trans;
+            let m_inv = m.inverse();
+            assert_approx_eq!($affine3d::IDENTITY, m * m_inv, 1.0e-5);
+            assert_approx_eq!($affine3d::IDENTITY, m_inv * m, 1.0e-5);
+            assert_approx_eq!(m_inv, trans_inv * rotz_inv * scale_inv, 1.0e-6);
+
+            // Make sure we can invert a shear matrix:
+            let m = $affine3d::from_axis_angle($vec3::X, 0.5)
+                * $affine3d::from_scale($vec3::new(1.0, 0.5, 2.0))
+                * $affine3d::from_axis_angle($vec3::X, -0.5);
+            let m_inv = m.inverse();
+            assert_approx_eq!($affine3d::IDENTITY, m * m_inv, 1.0e-5);
+            assert_approx_eq!($affine3d::IDENTITY, m_inv * m, 1.0e-5);
+        }
+
+        #[test]
+        fn test_affine3d_decompose() {
+            // identity
+            let (out_scale, out_rotation, out_translation) =
+                $affine3d::IDENTITY.to_scale_rotation_translation();
+            assert_approx_eq!($vec3::ONE, out_scale);
+            assert!(out_rotation.is_near_identity());
+            assert_approx_eq!($vec3::ZERO, out_translation);
+
+            // no scale
+            let in_scale = $vec3::ONE;
+            let in_translation = $vec3::new(-2.0, 4.0, -0.125);
+            let in_rotation = $quat::from_rotation_ypr(
+                $t::to_radians(-45.0),
+                $t::to_radians(180.0),
+                $t::to_radians(270.0),
+            );
+            let in_mat =
+                $affine3d::from_scale_rotation_translation(in_scale, in_rotation, in_translation);
+            let (out_scale, out_rotation, out_translation) = in_mat.to_scale_rotation_translation();
+            assert_approx_eq!(in_scale, out_scale, 1e-6);
+            // out_rotation is different but produces the same matrix
+            // assert_approx_eq!(in_rotation, out_rotation);
+            assert_approx_eq!(in_translation, out_translation);
+            assert_approx_eq!(
+                in_mat,
+                $affine3d::from_scale_rotation_translation(
+                    out_scale,
+                    out_rotation,
+                    out_translation
+                ),
+                1e-6
+            );
+
+            // positive scale
+            let in_scale = $vec3::new(1.0, 2.0, 4.0);
+            let in_mat =
+                $affine3d::from_scale_rotation_translation(in_scale, in_rotation, in_translation);
+            let (out_scale, out_rotation, out_translation) = in_mat.to_scale_rotation_translation();
+            assert_approx_eq!(in_scale, out_scale, 1e-6);
+            // out_rotation is different but produces the same matrix
+            // assert_approx_eq!(in_rotation, out_rotation);
+            assert_approx_eq!(in_translation, out_translation);
+            assert_approx_eq!(
+                in_mat,
+                $affine3d::from_scale_rotation_translation(
+                    out_scale,
+                    out_rotation,
+                    out_translation
+                ),
+                1e-6
+            );
+
+            // negative scale
+            let in_scale = $vec3::new(-4.0, 1.0, 2.0);
+            let in_mat =
+                $affine3d::from_scale_rotation_translation(in_scale, in_rotation, in_translation);
+            let (out_scale, out_rotation, out_translation) = in_mat.to_scale_rotation_translation();
+            assert_approx_eq!(in_scale, out_scale, 1e-6);
+            // out_rotation is different but produces the same matrix
+            // assert_approx_eq!(in_rotation, out_rotation);
+            assert_approx_eq!(in_translation, out_translation);
+            assert_approx_eq!(
+                in_mat,
+                $affine3d::from_scale_rotation_translation(
+                    out_scale,
+                    out_rotation,
+                    out_translation
+                ),
+                1e-5
+            );
+
+            // negative scale
+            let in_scale = $vec3::new(4.0, -1.0, -2.0);
+            let in_mat =
+                $affine3d::from_scale_rotation_translation(in_scale, in_rotation, in_translation);
+            let (out_scale, out_rotation, out_translation) = in_mat.to_scale_rotation_translation();
+            // out_scale and out_rotation are different but they produce the same matrix
+            // assert_approx_eq!(in_scale, out_scale, 1e-6);
+            // assert_approx_eq!(in_rotation, out_rotation);
+            assert_approx_eq!(in_translation, out_translation);
+            assert_approx_eq!(
+                in_mat,
+                $affine3d::from_scale_rotation_translation(
+                    out_scale,
+                    out_rotation,
+                    out_translation
+                ),
+                1e-6
+            );
+        }
+
+        #[test]
+        fn test_affine3d_look_at() {
+            let eye = $vec3::new(0.0, 0.0, -5.0);
+            let center = $vec3::new(0.0, 0.0, 0.0);
+            let up = $vec3::new(1.0, 0.0, 0.0);
+            let lh = $affine3d::look_at_lh(eye, center, up);
+            let rh = $affine3d::look_at_rh(eye, center, up);
+            let point = $vec3::new(1.0, 0.0, 0.0);
+            assert_approx_eq!(lh.transform_point3(point), $vec3::new(0.0, 1.0, 5.0));
+            assert_approx_eq!(rh.transform_point3(point), $vec3::new(0.0, 1.0, -5.0));
+        }
+
+        #[cfg(feature = "std")]
+        #[test]
+        fn test_product() {
+            let ident = $affine3d::IDENTITY;
+            assert_eq!(
+                vec![ident, ident].iter().product::<$affine3d>(),
+                ident * ident
+            );
+        }
+
+        #[test]
+        fn test_affine3d_is_finite() {
+            assert!($affine3d::from_scale($vec3::new(1.0, 1.0, 1.0)).is_finite());
+            assert!($affine3d::from_scale($vec3::new(0.0, 1.0, 1.0)).is_finite());
+            assert!(!$affine3d::from_scale($vec3::new(1.0, NAN, 1.0)).is_finite());
+            assert!(!$affine3d::from_scale($vec3::new(1.0, 1.0, NEG_INFINITY)).is_finite());
+        }
+    };
+}
+
+mod affine3d {
+    use super::support::deg;
+    use glam::{Affine3D, Quat, Vec3, Vec4};
+
+    #[test]
+    fn test_align() {
+        use std::mem;
+        assert_eq!(48, mem::size_of::<Affine3D>());
+        if cfg!(feature = "scalar-math") {
+            assert_eq!(4, mem::align_of::<Affine3D>());
+        } else {
+            assert_eq!(16, mem::align_of::<Affine3D>());
+        }
+    }
+
+    impl_affine3d_tests!(f32, Affine3D, Quat, Vec4, Vec3);
+}
diff --git a/tests/mat4.rs b/tests/mat4.rs
index 5dcddb5..ccf0b3b 100644
--- a/tests/mat4.rs
+++ b/tests/mat4.rs
@@ -302,6 +302,17 @@ macro_rules! impl_mat4_tests {
             assert_approx_eq!($mat4::IDENTITY, m * m_inv, 1.0e-5);
             assert_approx_eq!($mat4::IDENTITY, m_inv * m, 1.0e-5);
             assert_approx_eq!(m_inv, trans_inv * rotz_inv * scale_inv, 1.0e-6);
+
+            // Make sure we can invert a "random" matrix:
+            let m = $mat4::from_cols(
+                $newvec4(1.0, -0.3, 1.0, 1.0),
+                $newvec4(0.5, 0.6, 0.7, 0.8),
+                $newvec4(-0.9, -0.3, 0.0, 12.0),
+                $newvec4(0.13, 0.14, 0.15, 0.16),
+            );
+            let m_inv = m.inverse();
+            assert_approx_eq!($mat4::IDENTITY, m * m_inv, 1.0e-5);
+            assert_approx_eq!($mat4::IDENTITY, m_inv * m, 1.0e-5);
         }
 
         #[test]
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
index 9a7e3a5..67cc9ab 100644
--- a/tests/support/mod.rs
+++ b/tests/support/mod.rs
@@ -2,8 +2,8 @@
 mod macros;
 
 use glam::{
-    DMat2, DMat3, DMat4, DQuat, DVec2, DVec3, DVec4, Mat2, Mat3, Mat4, Quat, Vec2, Vec3, Vec3A,
-    Vec4,
+    Affine3D, DMat2, DMat3, DMat4, DQuat, DVec2, DVec3, DVec4, Mat2, Mat3, Mat4, Quat, Vec2, Vec3,
+    Vec3A, Vec4,
 };
 
 #[cfg(feature = "transform-types")]
@@ -145,6 +145,20 @@ impl FloatCompare for DMat4 {
     }
 }
 
+impl FloatCompare for Affine3D {
+    #[inline]
+    fn approx_eq(&self, other: &Self, max_abs_diff: f32) -> bool {
+        self.abs_diff_eq(*other, max_abs_diff)
+    }
+    #[inline]
+    fn abs_diff(&self, other: &Self) -> Self {
+        Self::from_mat3_translation(
+            self.mat3().abs_diff(&other.mat3()),
+            self.translation().abs_diff(&other.translation()),
+        )
+    }
+}
+
 impl FloatCompare for Mat4 {
     #[inline]
     fn approx_eq(&self, other: &Self, max_abs_diff: f32) -> bool {