Files
wds/src/render/math.rs
T
2026-08-28 23:31:48 +02:00

150 lines
5.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}