Skip to main content

ranim_items/camera/
mod.rs

1//! Camera state and its optional render plugin.
2
3#[cfg(feature = "render")]
4mod render;
5#[cfg(feature = "render")]
6pub use render::CameraFramePlugin;
7
8use ranim_core::glam::{self, DMat4, DVec3, dvec2};
9
10use ranim_core::{
11    prelude::{Alignable, Interpolatable},
12    traits::ApplyTransform,
13};
14
15/// The data of a camera
16///
17/// The [`CameraFrame`] has a [`CameraFrame::perspective_blend`] property (default is `0.0`),
18/// which is used to blend between orthographic and perspective projection.
19#[derive(Clone, Debug, PartialEq)]
20#[cfg_attr(feature = "render", derive(bevy_ecs::component::Component))]
21pub struct CameraFrame {
22    /// The position
23    pub pos: DVec3,
24    /// The up unit vec
25    pub up: DVec3,
26    /// The facing unit vec
27    pub facing: DVec3,
28
29    // far > near
30    /// The near pane
31    pub near: f64,
32    /// The far pane
33    pub far: f64,
34    /// The perspective blend value in [0.0, 1.0]
35    pub perspective_blend: f64,
36
37    /// **Ortho**: Top - Bottom
38    pub frame_height: f64,
39    /// **Ortho**: The scaling factor
40    pub scale: f64,
41
42    /// **Perspective**: The field of view angle, used in perspective projection
43    pub fovy: f64,
44}
45
46impl Interpolatable for CameraFrame {
47    fn lerp(&self, target: &Self, t: f64) -> Self {
48        Self {
49            pos: self.pos.lerp(target.pos, t),
50            up: self.up.lerp(target.up, t),
51            facing: self.facing.lerp(target.facing, t),
52            scale: self.scale.lerp(&target.scale, t),
53            fovy: self.fovy.lerp(&target.fovy, t),
54            near: self.near.lerp(&target.near, t),
55            far: self.far.lerp(&target.far, t),
56            frame_height: self.frame_height.lerp(&target.frame_height, t),
57            perspective_blend: self
58                .perspective_blend
59                .lerp(&target.perspective_blend, t)
60                .clamp(0.0, 1.0),
61        }
62    }
63}
64
65impl Alignable for CameraFrame {
66    fn is_aligned(&self, _other: &Self) -> bool {
67        true
68    }
69    fn align_with(&mut self, _other: &mut Self) {}
70}
71
72impl<G: Into<glam::DAffine3>> ApplyTransform<G> for CameraFrame {
73    /// `pos` transforms as a point; `up`/`facing` transform as direction
74    /// vectors (and are re-normalized).
75    fn apply(&mut self, transform: G) -> &mut Self {
76        let transform = transform.into();
77        self.pos = transform.transform_point3(self.pos);
78        self.up = transform.transform_vector3(self.up).normalize();
79        self.facing = transform.transform_vector3(self.facing).normalize();
80        self
81    }
82}
83
84impl Default for CameraFrame {
85    fn default() -> Self {
86        Self {
87            pos: DVec3::ZERO,
88            up: DVec3::Y,
89            facing: DVec3::NEG_Z,
90
91            near: -1000.0,
92            far: 1000.0,
93            perspective_blend: 0.0,
94
95            scale: 1.0,
96            frame_height: 8.0,
97
98            fovy: std::f64::consts::PI / 2.0,
99        }
100    }
101}
102
103impl CameraFrame {
104    /// Create a new CameraFrame at the origin facing to the negative z-axis and use Y as up vector with default projection settings.
105    pub fn new() -> Self {
106        Self::default()
107    }
108}
109
110impl CameraFrame {
111    /// Set the view matrix of the camera.
112    pub fn set_view_matrix(&mut self, view_matrix: DMat4) {
113        let inv = view_matrix.inverse();
114        self.pos = inv.transform_point3(DVec3::ZERO);
115        self.up = inv.transform_vector3(DVec3::Y).normalize();
116        self.facing = inv.transform_vector3(DVec3::NEG_Z).normalize();
117    }
118
119    /// Set the view matrix of the camera and return the modified `Self`.
120    pub fn with_view_matrix(mut self, view_matrix: DMat4) -> Self {
121        self.set_view_matrix(view_matrix);
122        self
123    }
124
125    /// The view matrix of the camera
126    pub fn view_matrix(&self) -> DMat4 {
127        glam::dcamera::rh::view::look_to_mat4(self.pos, self.facing, self.up)
128    }
129
130    /// Use the given frame size as `left`, `right`, `bottom`, `top` to construct an orthographic matrix
131    pub fn orthographic_mat(&self, aspect_ratio: f64) -> DMat4 {
132        let frame_size = dvec2(self.frame_height * aspect_ratio, self.frame_height);
133        let frame_size = frame_size * self.scale;
134        glam::dcamera::rh::proj::directx::orthographic(
135            -frame_size.x / 2.0,
136            frame_size.x / 2.0,
137            -frame_size.y / 2.0,
138            frame_size.y / 2.0,
139            self.near,
140            self.far,
141        )
142    }
143
144    /// Use the given frame aspect ratio to construct a perspective matrix
145    pub fn perspective_mat(&self, aspect_ratio: f64) -> DMat4 {
146        let near = self.near.max(0.1);
147        let far = self.far.max(near);
148        glam::dcamera::rh::proj::directx::perspective(self.fovy, aspect_ratio, near, far)
149    }
150
151    /// Use the given frame size to construct projection matrix
152    pub fn projection_matrix(&self, aspect_ratio: f64) -> DMat4 {
153        self.orthographic_mat(aspect_ratio)
154            .lerp(&self.perspective_mat(aspect_ratio), self.perspective_blend)
155    }
156
157    /// Use the given frame size to construct view projection matrix
158    pub fn view_projection_matrix(&self, aspect_ratio: f64) -> DMat4 {
159        self.projection_matrix(aspect_ratio) * self.view_matrix()
160    }
161}
162
163impl CameraFrame {
164    /// Create a perspective camera positioned using spherical coordinates (Z-up), looking at the origin.
165    ///
166    /// - `phi`: polar angle from +Z axis in radians (0 = straight up along +Z, π/2 = XY plane)
167    /// - `theta`: azimuth angle in radians (0 = +X direction, π/2 = +Y direction)
168    /// - `distance`: distance from the origin
169    pub fn from_spherical(phi: f64, theta: f64, distance: f64) -> Self {
170        let mut cam = Self {
171            perspective_blend: 1.0,
172            up: DVec3::Z,
173            ..Self::default()
174        };
175        cam.set_spherical(phi, theta, distance, DVec3::ZERO);
176        cam
177    }
178
179    /// Position the camera using spherical coordinates (Z-up) around a target point.
180    ///
181    /// - `phi`: polar angle from +Z axis in radians (0 = straight up along +Z, π/2 = XY plane)
182    /// - `theta`: azimuth angle in radians (0 = +X direction, π/2 = +Y direction)
183    /// - `distance`: distance from `target`
184    /// - `target`: the point the camera looks at
185    pub fn set_spherical(
186        &mut self,
187        phi: f64,
188        theta: f64,
189        distance: f64,
190        target: DVec3,
191    ) -> &mut Self {
192        self.pos = target
193            + DVec3::new(
194                distance * phi.sin() * theta.cos(),
195                distance * phi.sin() * theta.sin(),
196                distance * phi.cos(),
197            );
198        self.facing = (target - self.pos).normalize();
199        self.up = DVec3::Z;
200        self
201    }
202
203    /// Set the camera to look at a target point.
204    pub fn look_at(&mut self, target: DVec3) -> &mut Self {
205        self.facing = (target - self.pos).normalize();
206        self
207    }
208}
209
210impl CameraFrame {
211    /// Center the canvas in the frame when [`CameraFrame::perspective_blend`] is `1.0`
212    pub fn center_canvas_in_frame(
213        &mut self,
214        center: DVec3,
215        width: f64,
216        height: f64,
217        up: DVec3,
218        normal: DVec3,
219        aspect_ratio: f64,
220    ) -> &mut Self {
221        let canvas_ratio = height / width;
222        let up = up.normalize();
223        let normal = normal.normalize();
224
225        let height = if aspect_ratio > canvas_ratio {
226            height
227        } else {
228            width / aspect_ratio
229        };
230
231        let distance = height * 0.5 / (0.5 * self.fovy).tan();
232
233        self.up = up;
234        self.pos = center + normal * distance;
235        self.facing = -normal;
236        self
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use glam::dvec3;
244
245    #[test]
246    fn set_view_matrix_round_trips_pos_up_and_facing() {
247        let cases = [
248            CameraFrame::new(),
249            {
250                let mut camera = CameraFrame::new();
251                camera.pos = dvec3(5.0, 3.0, -2.0);
252                camera
253            },
254            {
255                let mut camera = CameraFrame::new();
256                camera.facing = dvec3(1.0, 0.0, 0.0);
257                camera.up = dvec3(0.0, 1.0, 0.0);
258                camera
259            },
260            {
261                let mut camera = CameraFrame::new();
262                camera.pos = dvec3(10.0, 5.0, 3.0);
263                camera.facing = dvec3(1.0, 0.0, 1.0).normalize();
264                camera.up = dvec3(0.0, 1.0, 0.0);
265                camera
266            },
267        ];
268
269        for (i, camera) in cases.into_iter().enumerate() {
270            let mut restored = CameraFrame::new();
271            restored.set_view_matrix(camera.view_matrix());
272
273            assert!(restored.pos.distance(camera.pos) < 1e-10, "case {i}: pos");
274            assert!(restored.up.angle_between(camera.up) < 1e-10, "case {i}: up");
275            assert!(
276                restored.facing.angle_between(camera.facing) < 1e-10,
277                "case {i}: facing"
278            );
279        }
280    }
281}