//! Minimale f32-Matrix-Mathematik für den Renderer — bewusst handgerollt //! statt glam: nötig sind nur Multiply, View und Perspective. //! //! Konventionen: //! - rechtshändig, Kamera blickt -Z, +Y oben (wie der Blender-Export) //! - Spaltenvektoren, Speicher column-major — `Mat4.0[spalte][zeile]`, //! bytemuck-kompatibel zu WGSL `mat4x4f` //! - Clip-Z in [0,1] (wgpu/WebGPU, nicht GL-[-1,1]) #[repr(C)] #[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)] pub struct Mat4(pub [[f32; 4]; 4]); impl Mat4 { pub const IDENT: Mat4 = Mat4([ [1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0], ]); #[allow(clippy::needless_range_loop)] // Zeilen-/Spalten-Index ist bei 4×4-Mathematik klarer als Iteratoren pub fn mul(&self, rhs: &Mat4) -> Mat4 { let mut out = [[0.0f32; 4]; 4]; for c in 0..4 { for r in 0..4 { out[c][r] = (0..4).map(|k| self.0[k][r] * rhs.0[c][k]).sum(); } } Mat4(out) } /// Punkt-Transformation auf der CPU — bisher nur von den Tests /// gebraucht; der Renderer transformiert auf der GPU. #[cfg(test)] #[allow(clippy::needless_range_loop)] // Zeilen-Index ist bei der Matrix-Vektor-Mathematik klarer pub fn transform(&self, v: [f32; 4]) -> [f32; 4] { let mut out = [0.0f32; 4]; for r in 0..4 { out[r] = (0..4).map(|k| self.0[k][r] * v[k]).sum(); } out } pub fn translate(x: f32, y: f32, z: f32) -> Mat4 { let mut m = Mat4::IDENT; m.0[3] = [x, y, z, 1.0]; m } pub fn rot_x(a: f32) -> Mat4 { let (s, c) = a.sin_cos(); let mut m = Mat4::IDENT; m.0[1] = [0.0, c, s, 0.0]; m.0[2] = [0.0, -s, c, 0.0]; m } pub fn rot_y(a: f32) -> Mat4 { let (s, c) = a.sin_cos(); let mut m = Mat4::IDENT; m.0[0] = [c, 0.0, -s, 0.0]; m.0[2] = [s, 0.0, c, 0.0]; m } /// FPS-View: erst Kamera-Position abziehen, dann Yaw, dann Pitch /// herausdrehen. yaw=0 blickt -Z, positiver Pitch hebt den Blick. pub fn view(pos: [f32; 3], yaw: f32, pitch: f32) -> Mat4 { Mat4::rot_x(-pitch) .mul(&Mat4::rot_y(-yaw)) .mul(&Mat4::translate(-pos[0], -pos[1], -pos[2])) } /// Perspektive mit Clip-Z in [0,1]. `fovy` in Radiant. pub fn perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> Mat4 { let f = 1.0 / (fovy * 0.5).tan(); Mat4([ [f / aspect, 0.0, 0.0, 0.0], [0.0, f, 0.0, 0.0], [0.0, 0.0, far / (near - far), -1.0], [0.0, 0.0, near * far / (near - far), 0.0], ]) } } #[cfg(test)] mod tests { use super::*; fn close(a: [f32; 4], b: [f32; 4]) -> bool { a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5) } #[test] fn view_translates_origin_in_front() { // Kamera bei z=+5, Blick -Z → Origin liegt 5 vor der Kamera. let v = Mat4::view([0.0, 0.0, 5.0], 0.0, 0.0); assert!(close(v.transform([0.0, 0.0, 0.0, 1.0]), [0.0, 0.0, -5.0, 1.0])); } #[test] fn view_yaw_quarter_turn_looks_minus_x() { // yaw=90°: Blick Richtung -X — ein Punkt auf -X liegt vor der Kamera. let v = Mat4::view([0.0, 0.0, 0.0], std::f32::consts::FRAC_PI_2, 0.0); assert!(close(v.transform([-2.0, 0.0, 0.0, 1.0]), [0.0, 0.0, -2.0, 1.0])); } #[test] fn view_pitch_up_looks_plus_y() { let v = Mat4::view([0.0, 0.0, 0.0], 0.0, std::f32::consts::FRAC_PI_2); assert!(close(v.transform([0.0, 3.0, 0.0, 1.0]), [0.0, 0.0, -3.0, 1.0])); } /// Die Bewegungs-Basis des Players muss exakt zu der Richtung passen, die /// `view` als „vor der Kamera" definiert — sonst driften Steuerung und Bild /// auseinander. Ein Punkt `d` voraus muss im View-Space auf (0, 0, -d) /// landen, `right_dir` auf +X und ohne Y-Anteil. #[test] fn view_matches_player_basis() { use crate::engine::player::Player; for &(yaw, pitch) in &[(0.0, 0.0), (0.7, 0.0), (0.0, 0.5), (1.2, -0.4)] { let mut p = Player::new([1.0, 2.0, 3.0]); (p.yaw, p.pitch) = (yaw, pitch); let v = Mat4::view(p.pos, p.yaw, p.pitch); let f = p.look_dir(); let ahead = [p.pos[0] + f[0] * 5.0, p.pos[1] + f[1] * 5.0, p.pos[2] + f[2] * 5.0, 1.0]; assert!(close(v.transform(ahead), [0.0, 0.0, -5.0, 1.0]), "look_dir bei yaw={yaw} pitch={pitch}: {:?}", v.transform(ahead)); let r = p.right_dir(); assert!(r[1].abs() < 1e-6, "right_dir darf kein Y haben: {r:?}"); let side = v.transform([p.pos[0] + r[0] * 5.0, p.pos[1], p.pos[2] + r[2] * 5.0, 1.0]); assert!(side[0] > 4.0, "right_dir sollte nach View-+X zeigen: {side:?}"); } } #[test] fn perspective_maps_near_far_to_0_1() { let p = Mat4::perspective(1.0, 4.0 / 3.0, 0.1, 100.0); let n = p.transform([0.0, 0.0, -0.1, 1.0]); let f = p.transform([0.0, 0.0, -100.0, 1.0]); assert!((n[2] / n[3] - 0.0).abs() < 1e-5); assert!((f[2] / f[3] - 1.0).abs() < 1e-4); // w = Abstand vor der Kamera assert!((n[3] - 0.1).abs() < 1e-6 && (f[3] - 100.0).abs() < 1e-4); } }