lib/linear/src/affine.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 //! Affine transforms of 3D space: a linear part followed by a translation.
2 //!
3 //! An affine transform maps a point `p` to `linear p + translation` and a
4 //! direction `v` to `linear v`. It stores twelve lanes instead of a Mat4's
5 //! sixteen, and it inverts through a 3x3 inverse.
6 const std = @import("std");
7 const matrix = @import("matrix.zig");
8 const Quat = @import("quaternion.zig").Quat;
9 const Vec3 = @import("vector.zig").Vec3;
10
11 const assert = std.debug.assert;
12 const Mat3 = matrix.Mat3;
13 const Mat4 = matrix.Mat4;
14
15 pub const Affine = extern struct {
16 linear: Mat3 = .{},
17 translation: Vec3 = .{},
18
19 pub const identity: Affine = .{};
20
21 pub fn fromTranslation(translation: Vec3) Affine {
22 return .{ .translation = translation };
23 }
24
25 /// Scale, then rotate by the unit quaternion `rotation`, then translate.
26 pub fn fromScaleRotationTranslation(s: Vec3, rotation: Quat, translation: Vec3) Affine {
27 return .{
28 .linear = Mat3.fromQuat(rotation).mul(Mat3.fromScale(s)),
29 .translation = translation,
30 };
31 }
32
33 /// `a b`: applying `b`, then `a`.
34 pub fn mul(a: Affine, b: Affine) Affine {
35 return .{
36 .linear = a.linear.mul(b.linear),
37 .translation = a.transformPoint(b.translation),
38 };
39 }
40
41 pub fn transformPoint(a: Affine, p: Vec3) Vec3 {
42 return a.linear.mulVec(p).add(a.translation);
43 }
44
45 pub fn transformVector(a: Affine, v: Vec3) Vec3 {
46 return a.linear.mulVec(v);
47 }
48
49 /// The matrix that carries surface normals through `a`, or null when the
50 /// linear part is singular. Transformed normals keep their direction but
51 /// not their length.
52 pub fn normalMatrix(a: Affine) ?Mat3 {
53 return a.linear.inverseTranspose();
54 }
55
56 /// The inverse transform, or null when the linear part is singular.
57 pub fn inverse(a: Affine) ?Affine {
58 const linear = a.linear.inverse() orelse return null;
59 return .{
60 .linear = linear,
61 .translation = linear.mulVec(a.translation).negate(),
62 };
63 }
64
65 pub fn toMat4(a: Affine) Mat4 {
66 return Mat4.fromLinearTranslation(a.linear, a.translation);
67 }
68 };
69
70 comptime {
71 assert(@sizeOf(Affine) == @sizeOf([12]f32));
72 assert(@alignOf(Affine) == @alignOf(f32));
73 }
74
75 const testing = std.testing;
76 const tolerance: f32 = 1e-5;
77 const Vec4 = @import("vector.zig").Vec4;
78
79 fn expectApproxVec3(expected: Vec3, actual: Vec3) !void {
80 try testing.expectApproxEqAbs(expected.x, actual.x, tolerance);
81 try testing.expectApproxEqAbs(expected.y, actual.y, tolerance);
82 try testing.expectApproxEqAbs(expected.z, actual.z, tolerance);
83 }
84
85 const sample = Affine.fromScaleRotationTranslation(
86 Vec3.init(2, 0.5, 3),
87 Quat.fromAxisAngle(Vec3.init(0, 0.6, 0.8), 0.7),
88 Vec3.init(1, -2, 4),
89 );
90
91 test "points translate and vectors do not" {
92 const shift = Affine.fromTranslation(Vec3.init(1, 2, 3));
93 try testing.expectEqual(Vec3.init(2, 2, 3), shift.transformPoint(Vec3.init(1, 0, 0)));
94 try testing.expectEqual(Vec3.init(1, 0, 0), shift.transformVector(Vec3.init(1, 0, 0)));
95 try testing.expectEqual(Vec3.init(4, 5, 6), Affine.identity.transformPoint(Vec3.init(4, 5, 6)));
96 }
97
98 test "scale applies before rotation and translation" {
99 const a = Affine.fromScaleRotationTranslation(
100 Vec3.init(2, 1, 1),
101 Quat.fromAxisAngle(Vec3.init(0, 0, 1), std.math.pi / 2.0),
102 Vec3.init(0, 0, 5),
103 );
104 try expectApproxVec3(Vec3.init(0, 2, 5), a.transformPoint(Vec3.init(1, 0, 0)));
105 }
106
107 test "product composes right to left" {
108 const other = Affine.fromScaleRotationTranslation(
109 Vec3.init(1, 1, 2),
110 Quat.fromAxisAngle(Vec3.init(1, 0, 0), -0.4),
111 Vec3.init(0.5, 0, 0),
112 );
113 const p = Vec3.init(0.3, 0.9, -1.1);
114 try expectApproxVec3(sample.transformPoint(other.transformPoint(p)), sample.mul(other).transformPoint(p));
115 }
116
117 test "inverse undoes the transform and fails on a singular linear part" {
118 const p = Vec3.init(-0.4, 2.5, 0.8);
119 try expectApproxVec3(p, sample.inverse().?.transformPoint(sample.transformPoint(p)));
120 const flat = Affine{ .linear = Mat3.fromScale(Vec3.init(1, 1, 0)) };
121 try testing.expectEqual(@as(?Affine, null), flat.inverse());
122 try testing.expectEqual(@as(?Mat3, null), flat.normalMatrix());
123 }
124
125 test "normals stay perpendicular under nonuniform scale" {
126 const tangent = Vec3.init(1, -1, 0);
127 const normal = Vec3.init(1, 1, 0);
128 const moved_tangent = sample.transformVector(tangent);
129 const moved_normal = sample.normalMatrix().?.mulVec(normal);
130 try testing.expectApproxEqAbs(@as(f32, 0), moved_tangent.dot(moved_normal), tolerance);
131 }
132
133 test "homogeneous matrix agrees with the affine transform" {
134 const p = Vec3.init(0.7, -0.2, 1.3);
135 const moved = sample.toMat4().mulVec(Vec4.fromVec3(p, 1));
136 try expectApproxVec3(sample.transformPoint(p), moved.xyz());
137 try testing.expectEqual(@as(f32, 1), moved.w);
138 }