Skip to main content

ranim_core/core_item/
camera_frame.rs

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