Skip to main content
← PSoXide SDK guides and examples · SDK f2fec1c301f1 · All Cargo features enabled; check feature gates before use.

psx_gte_core/
transform.rs

1//! Composable 3D transform math operating on [`Mat3I16`] / [`Vec3I16`].
2//!
3//! Lives next to the type definitions because the inherent `impl`s
4//! belong in the same crate as the type. Higher-level GTE register
5//! access (`write_data!` / `write_control!` macros) lives in `psx-gte`, which
6//! re-exports this module so callers can keep importing it from there.
7//!
8//! The PS1 has no hardware sin/cos -- rotations use the shared
9//! [`psx_math::sincos::SIN_TABLE`] lookup. Angles are in
10//! **256-per-revolution** units (`u16` with implicit wrap at 256)
11//! rather than radians or degrees, matching the convention most PSX
12//! homebrew uses. A full turn fits in a single byte, which indexes
13//! every sixteenth entry of the SDK's Q0.12 sine table.
14//!
15//! Fixed-point conventions (inherited from the GTE):
16//! - Matrix cells: **1.3.12** signed -- `0x1000` = 1.0.
17//! - Translation / screen offset / H: integers in engine-chosen
18//!   scaling; the math here just passes them through.
19//! - Vectors: **1.3.12** `i16`; accumulator outputs are `i32` in MAC
20//!   or `i16` in IR post-saturation.
21
22#![allow(clippy::needless_range_loop)]
23
24use crate::math::{Mat3I16, Vec3I16};
25use psx_math::SIN_TABLE;
26
27/// Sine of `angle` in 256-per-revolution units, returned in 1.3.12.
28///
29/// Angles wrap modulo 256. Values are piecewise-linear approximations
30/// of `sin` -- accurate to within ±1 LSB of the 1.3.12 representation,
31/// which is good enough for real-time 3D but not for scientific use.
32#[inline]
33pub const fn sin_1_3_12(angle: u16) -> i16 {
34    SIN_TABLE[(angle & 0xFF) as usize]
35}
36
37/// Cosine of `angle` in 256-per-revolution units, returned in 1.3.12.
38#[inline]
39pub const fn cos_1_3_12(angle: u16) -> i16 {
40    // cos(θ) = sin(θ + π/2). Our angle units put π/2 at 64.
41    sin_1_3_12(angle.wrapping_add(64))
42}
43
44impl Mat3I16 {
45    /// Matrix × matrix, with 1.3.12 fixed-point scaling applied so the
46    /// output stays in 1.3.12. Saturates each cell to `i16` range --
47    /// rotation compositions stay well within, but callers doing
48    /// scale-by-100 before compose should watch for truncation.
49    pub fn mul(&self, other: &Self) -> Self {
50        let mut out = [[0i16; 3]; 3];
51        for i in 0..3 {
52            for j in 0..3 {
53                let column = [other.m[0][j], other.m[1][j], other.m[2][j]];
54                out[i][j] = clamp_i32_to_i16(dot_q12_i16(self.m[i], column));
55            }
56        }
57        Self { m: out }
58    }
59
60    /// Compose X, then Y, then Z rotations using the PS1 matrix convention.
61    ///
62    /// Angles use the same 256-per-revolution units as [`Self::rotate_x`].
63    /// The explicit order matters for camera/model transforms with more than
64    /// one non-zero Euler component.
65    #[inline]
66    pub fn rotate_xyz(x: u16, y: u16, z: u16) -> Self {
67        Self::rotate_x(x)
68            .mul(&Self::rotate_y(y))
69            .mul(&Self::rotate_z(z))
70    }
71
72    /// Scale matrix rows by independent Q12 factors.
73    ///
74    /// This matches the classic PS1 `ScaleMatrixL` convention: row 0 receives
75    /// X, row 1 receives Y, and row 2 receives Z. Results saturate like the
76    /// GTE IR registers instead of wrapping at `i16` boundaries.
77    pub fn scale_rows_q12(&self, scale: [i32; 3]) -> Self {
78        let mut out = self.m;
79        for row in 0..3 {
80            for col in 0..3 {
81                let value = ((self.m[row][col] as i64 * scale[row] as i64) >> 12)
82                    .clamp(i16::MIN as i64, i16::MAX as i64);
83                out[row][col] = value as i16;
84            }
85        }
86        Self { m: out }
87    }
88
89    /// Scale matrix columns by independent Q12 factors.
90    ///
91    /// This matches the classic PS1 `ScaleMatrix` convention: column 0
92    /// receives X, column 1 receives Y, and column 2 receives Z.
93    pub fn scale_columns_q12(&self, scale: [i32; 3]) -> Self {
94        let mut out = self.m;
95        for row in 0..3 {
96            for col in 0..3 {
97                let value = ((self.m[row][col] as i64 * scale[col] as i64) >> 12)
98                    .clamp(i16::MIN as i64, i16::MAX as i64);
99                out[row][col] = value as i16;
100            }
101        }
102        Self { m: out }
103    }
104
105    /// Matrix × vector, with 1.3.12 scaling. Returns `i32` per
106    /// component so the caller can inspect the un-saturated product
107    /// (useful for normal generation / light calculations before
108    /// feeding back into the GTE as V0).
109    pub fn transform(&self, v: Vec3I16) -> [i32; 3] {
110        let mut out = [0i32; 3];
111        for i in 0..3 {
112            out[i] = dot_q12_i16(self.m[i], [v.x, v.y, v.z]);
113        }
114        out
115    }
116
117    /// Matrix times a wide vector, using wrapping i32 dot products followed
118    /// by an arithmetic Q12 shift. Inputs should keep each dot product in i32
119    /// when a non-wrapping result is required.
120    pub fn transform_i32(&self, v: [i32; 3]) -> [i32; 3] {
121        self.m.map(|r| {
122            i32::from(r[0])
123                .wrapping_mul(v[0])
124                .wrapping_add(i32::from(r[1]).wrapping_mul(v[1]))
125                .wrapping_add(i32::from(r[2]).wrapping_mul(v[2]))
126                >> 12
127        })
128    }
129
130    /// X rotation in 4096-per-turn units, with interpolated Q12 trig.
131    pub fn rotate_x_q12(angle: u16) -> Self {
132        let c = psx_math::cos_q12(angle) as i16;
133        let s = psx_math::sin_q12(angle) as i16;
134        Self {
135            m: [[4096, 0, 0], [0, c, -s], [0, s, c]],
136        }
137    }
138
139    /// Y rotation in 4096-per-turn units, with interpolated Q12 trig.
140    pub fn rotate_y_q12(angle: u16) -> Self {
141        let c = psx_math::cos_q12(angle) as i16;
142        let s = psx_math::sin_q12(angle) as i16;
143        Self {
144            m: [[c, 0, s], [0, 4096, 0], [-s, 0, c]],
145        }
146    }
147
148    /// Rotation around the X axis by `angle` (256-per-revolution units).
149    ///
150    /// ```text
151    ///   | 1    0     0  |
152    ///   | 0   cos  -sin |
153    ///   | 0   sin   cos |
154    /// ```
155    pub const fn rotate_x(angle: u16) -> Self {
156        let c = cos_1_3_12(angle);
157        let s = sin_1_3_12(angle);
158        Self {
159            m: [[0x1000, 0, 0], [0, c, -s], [0, s, c]],
160        }
161    }
162
163    /// Rotation around the Y axis.
164    ///
165    /// ```text
166    ///   |  cos  0  sin |
167    ///   |   0   1   0  |
168    ///   | -sin  0  cos |
169    /// ```
170    pub const fn rotate_y(angle: u16) -> Self {
171        let c = cos_1_3_12(angle);
172        let s = sin_1_3_12(angle);
173        Self {
174            m: [[c, 0, s], [0, 0x1000, 0], [-s, 0, c]],
175        }
176    }
177
178    /// Rotation around the Z axis.
179    ///
180    /// ```text
181    ///   | cos  -sin  0 |
182    ///   | sin   cos  0 |
183    ///   |  0     0   1 |
184    /// ```
185    pub const fn rotate_z(angle: u16) -> Self {
186        let c = cos_1_3_12(angle);
187        let s = sin_1_3_12(angle);
188        Self {
189            m: [[c, -s, 0], [s, c, 0], [0, 0, 0x1000]],
190        }
191    }
192}
193
194#[inline]
195fn dot_q12_i16(a: [i16; 3], b: [i16; 3]) -> i32 {
196    let mut sum = 0i32;
197    let mut i = 0;
198    while i < 3 {
199        sum = sum.saturating_add((a[i] as i32) * (b[i] as i32));
200        i += 1;
201    }
202    sum >> 12
203}
204
205#[inline]
206fn clamp_i32_to_i16(value: i32) -> i16 {
207    value.clamp(i16::MIN as i32, i16::MAX as i32) as i16
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn interpolated_rotations_preserve_full_angle_resolution() {
216        for a in 0..4096 {
217            let c = psx_math::cos_q12(a) as i16;
218            let sn = psx_math::sin_q12(a) as i16;
219            assert_eq!(
220                Mat3I16::rotate_x_q12(a).m,
221                [[4096, 0, 0], [0, c, -sn], [0, sn, c]]
222            );
223            assert_eq!(
224                Mat3I16::rotate_y_q12(a).m,
225                [[c, 0, sn], [0, 4096, 0], [-sn, 0, c]]
226            );
227            let m = Mat3I16::rotate_y_q12(a);
228            let v = [12345, -34567, 8901];
229            let expected = m.m.map(|r| {
230                (i32::from(r[0]) * v[0] + i32::from(r[1]) * v[1] + i32::from(r[2]) * v[2]) >> 12
231            });
232            assert_eq!(m.transform_i32(v), expected);
233        }
234    }
235
236    #[test]
237    fn sin_cos_identity_at_cardinal_angles() {
238        // Exact values at 0, π/2, π, 3π/2.
239        assert_eq!(sin_1_3_12(0), 0);
240        assert_eq!(cos_1_3_12(0), 0x1000);
241        assert_eq!(sin_1_3_12(64), 0x1000);
242        assert_eq!(cos_1_3_12(64), 0);
243        assert_eq!(sin_1_3_12(128), 0);
244        assert_eq!(cos_1_3_12(128), -0x1000);
245        assert_eq!(sin_1_3_12(192), -0x1000);
246        assert_eq!(cos_1_3_12(192), 0);
247    }
248
249    #[test]
250    fn scene_rtps_matches_captured_gameplay_outputs() {
251        // Register sets captured from a live cortex_ignition_v1 gameplay frame
252        // (probe_gameplay_gte_trace). These vertices saturate the screen coords
253        // and hit perspective-divide overflow (FLAG bit 17) -- the regime that
254        // drives the on-hardware vertex explosion and that the synthetic GTE
255        // battery never reaches. Host mirror of the hardware-tests disc cases:
256        // locks PSoXide's RTPS outputs for the scene's exact inputs, so the
257        // disc cases are green in-emulator and any hardware divergence is real.
258        fn rtps(vxy0: u32, vz0: u32) -> (u32, u32) {
259            let mut g = crate::Gte::new();
260            g.write_control(0, 0x0000_0f19); // R11,R12
261            g.write_control(1, 0x016e_fab4); // R13,R21
262            g.write_control(2, 0x0411_f098); // R22,R23
263            g.write_control(3, 0xfbb1_fae7); // R31,R32
264            g.write_control(4, 0xffff_f177); // R33
265            g.write_control(24, 0x00a0_0000); // OFX
266            g.write_control(25, 0x0078_0000); // OFY
267            g.write_control(26, 0x0000_0140); // H
268            g.write_data(0, vxy0); // VXY0
269            g.write_data(1, vz0); // VZ0
270            g.execute(0x4a08_0001); // RTPS (sf=1)
271            (g.read_data(14), g.read_control(31)) // (SXY2, FLAG)
272        }
273        assert_eq!(rtps(0x0c3e_0000, 0x0000_0a4d), (0xfc00_fc00, 0x8006_6000));
274        assert_eq!(rtps(0x0c3e_0529, 0x0000_08eb).0, 0xfc00_03ff);
275        assert_eq!(rtps(0x0c3e_0526, 0xffff_f714), (0xfc00_03b4, 0x8000_2000));
276        assert_eq!(rtps(0x099c_f335, 0x0000_0000), (0xfc00_fc00, 0x8000_6000));
277    }
278
279    #[test]
280    fn scene_ops_and_corners_match_psx_gte_core() {
281        // Host mirror of the hardware-tests GTE conformance battery (part 2):
282        // scene-captured MVMVA / RTPT / NCLIP plus the LZCS corner case. Locks
283        // PSoXide's outputs for the exact inputs the disc replays, so the disc
284        // cases are green in-emulator and any hardware divergence is real.
285        fn tri(a: u32, b: u32, c: u32) -> u32 {
286            a ^ b.rotate_left(11) ^ c.rotate_left(22)
287        }
288        fn seed_xform(g: &mut crate::Gte) {
289            g.write_control(31, 0);
290            g.write_control(0, 0x0000_0f19);
291            g.write_control(1, 0x016e_fab4);
292            g.write_control(2, 0x0411_f098);
293            g.write_control(3, 0xfbb1_fae7);
294            g.write_control(4, 0xffff_f177);
295            g.write_control(5, 0xffff_eabc);
296            g.write_control(6, 0xffff_fdb9);
297            g.write_control(7, 0x0000_35be);
298            g.write_control(24, 0x00a0_0000);
299            g.write_control(25, 0x0078_0000);
300            g.write_control(26, 0x0000_0140);
301            g.write_control(27, 0);
302            g.write_control(28, 0);
303        }
304
305        // MVMVA (RT*V0 + TR, sf=1): digest of MAC1/2/3.
306        let mvmva = |vxy0: u32, vz0: u32| {
307            let mut g = crate::Gte::new();
308            seed_xform(&mut g);
309            g.write_data(0, vxy0);
310            g.write_data(1, vz0);
311            g.execute(0x4a08_0012);
312            tri(g.read_data(25), g.read_data(26), g.read_data(27))
313        };
314        assert_eq!(mvmva(0x2040_0340, 0x0000_09c0), 0xca74_6d65);
315        assert_eq!(mvmva(0x2040_0340, 0x0000_16c0), 0xd65a_09bf);
316        assert_eq!(mvmva(0x0b00_0340, 0x0000_23c0), 0x511b_ee0c);
317        assert_eq!(mvmva(0x2040_09c0, 0x0000_09c0), 0x46ef_d743);
318
319        // RTPT: SXY digest + FLAG + SZ3.
320        let rtpt = |v: [u32; 6]| {
321            let mut g = crate::Gte::new();
322            seed_xform(&mut g);
323            for (i, &w) in v.iter().enumerate() {
324                g.write_data(i as u8, w);
325            }
326            g.execute(0x4a08_0030);
327            (
328                tri(g.read_data(12), g.read_data(13), g.read_data(14)),
329                g.read_control(31),
330                g.read_data(19),
331            )
332        };
333        assert_eq!(
334            rtpt([
335                0x0480_2d80,
336                0x0000_2700,
337                0x0480_2080,
338                0x0000_2d80,
339                0x0480_1a00,
340                0x0000_2d80
341            ]),
342            (0xfc1f_e61f, 0x8000_6000, 0x0000_02e9)
343        );
344        assert_eq!(
345            rtpt([
346                0x0480_2700,
347                0x0000_2d80,
348                0x0480_2d80,
349                0x0000_2d80,
350                0x0480_2080,
351                0x0000_3400
352            ]),
353            (0xfbe0_066f, 0x8006_6000, 0x0000_0000)
354        );
355        assert_eq!(
356            rtpt([
357                0x10c0_0000,
358                0x0000_0d00,
359                0x10c0_0680,
360                0x0000_0d00,
361                0x1dc0_0680,
362                0x0000_0d00
363            ]),
364            (0xaf36_5a05, 0x0000_0000, 0x0000_1fd9)
365        );
366
367        // NCLIP: real scene backface MAC0.
368        let nclip = |s0: u32, s1: u32, s2: u32| {
369            let mut g = crate::Gte::new();
370            g.write_control(31, 0);
371            g.write_data(12, s0);
372            g.write_data(13, s1);
373            g.write_data(14, s2);
374            g.execute(0x4a00_0006);
375            g.read_data(24)
376        };
377        assert_eq!(nclip(0x006e_0095, 0xffe2_0094, 0xffde_00dc), 0x0000_2764);
378        assert_eq!(nclip(0x0073_00d5, 0xffde_00dc, 0xffd8_0130), 0x0000_30ba);
379        assert_eq!(nclip(0x0079_011f, 0xffd8_0130, 0xffd2_0194), 0x0000_3e7e);
380
381        // LZCS/LZCR leading-bit count.
382        let lzcr = |v: u32| {
383            let mut g = crate::Gte::new();
384            g.write_data(30, v);
385            g.read_data(31)
386        };
387        assert_eq!(lzcr(0x00ff_ffff), 8);
388        assert_eq!(lzcr(0xffff_0000), 16);
389        assert_eq!(lzcr(0x0000_0001), 31);
390        assert_eq!(lzcr(0x7fff_ffff), 1);
391        assert_eq!(lzcr(0x8000_0000), 1);
392
393        // MVMVA bugged FC mode (cv=2): the far-color translation AND the
394        // first matrix column are dropped -> MAC_i = (Mx_i2*Vy + Mx_i3*Vz).
395        // Confirmed against real hardware (cortex GTE disc 2026-06-09).
396        {
397            let mut g = crate::Gte::new();
398            seed_xform(&mut g);
399            g.write_control(21, 0x0000_1000); // FCX
400            g.write_control(22, 0x0000_2000); // FCY
401            g.write_control(23, 0x0000_3000); // FCZ
402            g.write_data(0, 0x2040_0340);
403            g.write_data(1, 0x0000_09c0);
404            g.execute(0x4a08_4012); // MVMVA mx=RT vx=V0 cv=FC sf=1 lm=0
405            assert_eq!(
406                (g.read_data(25), g.read_data(26), g.read_data(27)),
407                (0xffff_fcc5, 0xffff_e36c, 0xffff_ee75)
408            );
409        }
410    }
411
412    #[test]
413    fn sin_wraps_modulo_256() {
414        // Angles outside 0..256 must wrap.
415        assert_eq!(sin_1_3_12(256), sin_1_3_12(0));
416        assert_eq!(sin_1_3_12(320), sin_1_3_12(64));
417        assert_eq!(sin_1_3_12(65535), sin_1_3_12(255));
418    }
419
420    #[test]
421    fn sin_cos_pythagorean_identity_holds_approximately() {
422        // sin²+cos² = 1.0² = 0x1000² = 0x100_0000.
423        // Our values are approximate so allow a small tolerance.
424        for angle in (0..256).step_by(7) {
425            let s = sin_1_3_12(angle as u16) as i32;
426            let c = cos_1_3_12(angle as u16) as i32;
427            let sum = s * s + c * c;
428            let expected = 0x0100_0000;
429            // ±0.5% tolerance -- quarter-sine table rounds each entry.
430            let tolerance = expected / 200;
431            assert!(
432                (sum - expected).abs() <= tolerance,
433                "angle {angle}: sin²+cos² = {sum:#x}, expected ≈ {expected:#x}"
434            );
435        }
436    }
437
438    #[test]
439    fn identity_mul_identity_is_identity() {
440        let id = Mat3I16::IDENTITY;
441        let prod = id.mul(&id);
442        assert_eq!(prod, id);
443    }
444
445    #[test]
446    fn rotate_y_zero_is_identity() {
447        assert_eq!(Mat3I16::rotate_y(0), Mat3I16::IDENTITY);
448    }
449
450    #[test]
451    fn rotate_xyz_uses_x_then_y_then_z_order() {
452        let expected = Mat3I16::rotate_x(17)
453            .mul(&Mat3I16::rotate_y(39))
454            .mul(&Mat3I16::rotate_z(73));
455        assert_eq!(Mat3I16::rotate_xyz(17, 39, 73), expected);
456        assert_ne!(
457            Mat3I16::rotate_xyz(17, 39, 73),
458            Mat3I16::rotate_z(73)
459                .mul(&Mat3I16::rotate_y(39))
460                .mul(&Mat3I16::rotate_x(17))
461        );
462    }
463
464    #[test]
465    fn row_and_column_scaling_are_explicit_and_saturating() {
466        let matrix = Mat3I16 {
467            m: [
468                [0x1000, 0x0800, 0],
469                [0, 0x1000, 0x0400],
470                [0x0200, 0, 0x1000],
471            ],
472        };
473        assert_eq!(
474            matrix.scale_rows_q12([0x0800, 0x1000, 0x2000]).m,
475            [
476                [0x0800, 0x0400, 0],
477                [0, 0x1000, 0x0400],
478                [0x0400, 0, 0x2000]
479            ]
480        );
481        assert_eq!(
482            matrix.scale_columns_q12([0x0800, 0x1000, 0x2000]).m,
483            [
484                [0x0800, 0x0800, 0],
485                [0, 0x1000, 0x0800],
486                [0x0100, 0, 0x2000]
487            ]
488        );
489        assert_eq!(
490            Mat3I16::IDENTITY
491                .scale_rows_q12([i32::MAX, 0x1000, 0x1000])
492                .m[0][0],
493            i16::MAX
494        );
495    }
496
497    #[test]
498    fn rotate_y_quarter_turn_swaps_axes() {
499        // 90° Y rotation: (1,0,0) → (0,0,-1), (0,0,1) → (1,0,0).
500        let m = Mat3I16::rotate_y(64);
501        assert_eq!(m.m[0][0], 0);
502        assert_eq!(m.m[0][2], 0x1000);
503        assert_eq!(m.m[2][0], -0x1000);
504        assert_eq!(m.m[2][2], 0);
505        // Y axis untouched.
506        assert_eq!(m.m[1][1], 0x1000);
507    }
508
509    #[test]
510    fn rotate_x_then_inverse_round_trips() {
511        // Rotating by θ then by -θ (256-θ in our units) should yield
512        // the identity within rounding error.
513        let r = Mat3I16::rotate_x(30);
514        let r_inv = Mat3I16::rotate_x(226);
515        let prod = r.mul(&r_inv);
516        // Diagonal should be ≈ 0x1000, off-diagonal ≈ 0.
517        for i in 0..3 {
518            for j in 0..3 {
519                let expected = if i == j { 0x1000 } else { 0 };
520                let diff = (prod.m[i][j] as i32 - expected).abs();
521                assert!(
522                    diff < 8,
523                    "rotate_x ∘ rotate_x⁻¹ cell ({i},{j}) = {:#x}, expected ≈ {expected:#x}",
524                    prod.m[i][j]
525                );
526            }
527        }
528    }
529
530    #[test]
531    fn transform_identity_leaves_vector_unchanged() {
532        let v = Vec3I16::new(100, -200, 300);
533        let r = Mat3I16::IDENTITY.transform(v);
534        assert_eq!(r, [100, -200, 300]);
535    }
536
537    #[test]
538    fn transform_rotate_y_quarter_turn() {
539        // (1, 0, 0) after 90° Y = (0, 0, -1) in our convention.
540        let v = Vec3I16::new(0x1000, 0, 0);
541        let r = Mat3I16::rotate_y(64).transform(v);
542        assert_eq!(r, [0, 0, -0x1000]);
543    }
544}