Skip to main content

qualia_core_db/render/
camera.rs

1//! Orbit camera → column-major `view_projection` for the Qualia portal (CPU hot path, zero-heap).
2
3use crate::render::telemetry::CameraUniform;
4
5/// Interactive orbit state driven from JS (`set_camera`).
6#[derive(Clone, Copy, Debug, PartialEq)]
7pub struct CameraState {
8    pub yaw: f32,
9    pub pitch: f32,
10    pub zoom: f32,
11}
12
13impl Default for CameraState {
14    fn default() -> Self {
15        Self {
16            yaw: 0.0,
17            pitch: 0.25,
18            zoom: 3.5,
19        }
20    }
21}
22
23impl CameraState {
24    #[inline]
25    pub fn clamped(mut self) -> Self {
26        const PITCH_LIMIT: f32 = 1.45;
27        self.pitch = self.pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT);
28        self.zoom = self.zoom.clamp(0.35, 48.0);
29        self
30    }
31
32    /// Build GPU uniform block (128 B) including pre-multiplied view×projection.
33    pub fn to_uniform(&self, aspect: f32, tensor_mode: bool) -> CameraUniform {
34        let state = self.clamped();
35        let eye = orbit_eye_position(state.yaw, state.pitch, state.zoom);
36        let mut padding = [0.0_f32; 12];
37        padding[1] = eye[0];
38        padding[2] = eye[1];
39        padding[3] = eye[2];
40        CameraUniform {
41            view_projection: orbit_view_projection(state.yaw, state.pitch, state.zoom, aspect),
42            yaw: state.yaw,
43            pitch: state.pitch,
44            zoom: state.zoom,
45            tensor_mode: if tensor_mode { 1 } else { 0 },
46            _padding: padding,
47        }
48    }
49}
50
51/// Orbit camera eye position — matches the `look_at` used in [`orbit_view_projection`].
52#[inline]
53pub fn orbit_eye_position(yaw: f32, pitch: f32, zoom: f32) -> [f32; 3] {
54    let pitch = pitch.clamp(-1.45, 1.45);
55    let dist = zoom.clamp(0.35, 48.0);
56    let cy = yaw.cos();
57    let sy = yaw.sin();
58    let cp = pitch.cos();
59    let sp = pitch.sin();
60    [dist * cp * sy, dist * sp, dist * cp * cy]
61}
62
63/// Column-major view×projection (WGSL `mat4x4<f32>` compatible).
64pub fn orbit_view_projection(yaw: f32, pitch: f32, zoom: f32, aspect: f32) -> [[f32; 4]; 4] {
65    let yaw = yaw;
66    let pitch = pitch.clamp(-1.45, 1.45);
67    let dist = zoom.clamp(0.35, 48.0);
68    let aspect = aspect.max(0.05);
69
70    let cy = yaw.cos();
71    let sy = yaw.sin();
72    let cp = pitch.cos();
73    let sp = pitch.sin();
74
75    let eye_x = dist * cp * sy;
76    let eye_y = dist * sp;
77    let eye_z = dist * cp * cy;
78
79    let view = look_at_rh([eye_x, eye_y, eye_z], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
80    let proj = perspective_rh_gl(45.0_f32.to_radians(), aspect, 0.05, 200.0);
81    mat4_mul(proj, view)
82}
83
84#[inline]
85fn look_at_rh(eye: [f32; 3], center: [f32; 3], up: [f32; 3]) -> [[f32; 4]; 4] {
86    let f = normalize(sub(center, eye));
87    let s = normalize(cross(f, up));
88    let u = cross(s, f);
89
90    [
91        [s[0], u[0], -f[0], 0.0],
92        [s[1], u[1], -f[1], 0.0],
93        [s[2], u[2], -f[2], 0.0],
94        [-dot(s, eye), -dot(u, eye), dot(f, eye), 1.0],
95    ]
96}
97
98#[inline]
99fn perspective_rh_gl(fov_y: f32, aspect: f32, near: f32, far: f32) -> [[f32; 4]; 4] {
100    let f = 1.0 / (fov_y * 0.5).tan();
101    let nf = 1.0 / (near - far);
102    [
103        [f / aspect, 0.0, 0.0, 0.0],
104        [0.0, f, 0.0, 0.0],
105        [0.0, 0.0, (far + near) * nf, -1.0],
106        [0.0, 0.0, 2.0 * far * near * nf, 0.0],
107    ]
108}
109
110#[inline]
111fn mat4_mul(a: [[f32; 4]; 4], b: [[f32; 4]; 4]) -> [[f32; 4]; 4] {
112    let mut out = [[0.0; 4]; 4];
113    for c in 0..4 {
114        for r in 0..4 {
115            out[c][r] =
116                a[0][r] * b[c][0] + a[1][r] * b[c][1] + a[2][r] * b[c][2] + a[3][r] * b[c][3];
117        }
118    }
119    out
120}
121
122#[inline]
123fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
124    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
125}
126
127#[inline]
128fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
129    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
130}
131
132#[inline]
133fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
134    [
135        a[1] * b[2] - a[2] * b[1],
136        a[2] * b[0] - a[0] * b[2],
137        a[0] * b[1] - a[1] * b[0],
138    ]
139}
140
141#[inline]
142fn normalize(v: [f32; 3]) -> [f32; 3] {
143    let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
144    if len < 1e-6 {
145        return [0.0, 0.0, 1.0];
146    }
147    [v[0] / len, v[1] / len, v[2] / len]
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn view_projection_finite_at_defaults() {
156        let m = orbit_view_projection(0.0, 0.25, 3.5, 16.0 / 9.0);
157        assert!(m[0][0].is_finite());
158        assert!(m[3][2].is_finite());
159    }
160
161    #[test]
162    fn camera_uniform_is_128_bytes() {
163        let u = CameraState::default().to_uniform(1.0, true);
164        assert_eq!(std::mem::size_of::<CameraUniform>(), 128);
165        let bytes = bytemuck::bytes_of(&u);
166        assert_eq!(bytes.len(), 128);
167    }
168
169    #[test]
170    fn camera_uniform_carries_eye_position() {
171        let state = CameraState {
172            yaw: 0.5,
173            pitch: 0.25,
174            zoom: 4.0,
175        };
176        let u = state.to_uniform(16.0 / 9.0, true);
177        let eye = orbit_eye_position(state.yaw, state.pitch, state.zoom);
178        assert!((u._padding[1] - eye[0]).abs() < 1e-5);
179        assert!((u._padding[2] - eye[1]).abs() < 1e-5);
180        assert!((u._padding[3] - eye[2]).abs() < 1e-5);
181    }
182}