retrofire_core/render/
cam.rs

1//! Cameras and camera transforms.
2
3use core::ops::Range;
4
5#[cfg(feature = "fp")]
6use crate::math::{
7    Angle, Vec3, orient_z, rotate_x, rotate_y, spherical, translate, turns,
8};
9use crate::math::{
10    Apply, Lerp, Mat4x4, Point3, SphericalVec, Vary, mat::RealToReal,
11    orthographic, perspective, pt2, viewport,
12};
13use crate::util::{Dims, rect::Rect};
14
15use super::{
16    Clip, Context, NdcToScreen, RealToProj, Render, Shader, Target, View,
17    ViewToProj, World, WorldToView,
18};
19
20/// Trait for different modes of camera motion.
21pub trait Transform {
22    /// Returns the current world-to-view matrix.
23    fn world_to_view(&self) -> Mat4x4<WorldToView>;
24}
25
26/// Camera field of view.
27///
28/// Specifies how wide or narrow the *angle of view* of the camera is.
29/// The smaller the angle, the more "zoomed in" the image is.
30#[derive(Copy, Clone, Debug, PartialEq)]
31pub enum Fov {
32    /// Ratio of focal length to aperture size.
33    ///
34    /// This value is also called the 𝑓-number. The value of 1.0 corresponds
35    /// to a horizontal angle of view of 90°. Values less than 1.0 correspond
36    /// to wider and values greater than 1.0 to narrower angles of view.
37    FocalRatio(f32),
38    /// Focal length in [35mm-equivalent millimeters.][1]
39    ///
40    /// For instance, the value of 28.0 corresponds to the moderate wide-angle
41    /// view of a 28mm "full-frame" lens.
42    ///
43    /// [1]: https://en.wikipedia.org/wiki/35_mm_equivalent_focal_length
44    Equiv35mm(f32),
45    /// Angle of view as measured from the left to the right edge of the image.
46    #[cfg(feature = "fp")]
47    Horizontal(Angle),
48    /// Angle of view as measured from the top to the bottom edge of the image.
49    #[cfg(feature = "fp")]
50    Vertical(Angle),
51    /// Angle of view as measured between two opposite corners of the image.
52    #[cfg(feature = "fp")]
53    Diagonal(Angle),
54}
55
56/// Type to manage the world-to-viewport transformation.
57#[derive(Copy, Clone, Debug, Default)]
58pub struct Camera<Tf> {
59    /// World-to-view transform.
60    pub transform: Tf,
61    /// Viewport width and height.
62    pub dims: Dims,
63    /// Projection matrix.
64    pub project: Mat4x4<ViewToProj>,
65    /// Viewport matrix.
66    pub viewport: Mat4x4<NdcToScreen>,
67}
68
69/// First-person camera transform.
70///
71/// This is the familiar "FPS" movement mode, based on camera
72/// position and heading (look-at vector).
73#[derive(Copy, Clone, Debug)]
74pub struct FirstPerson {
75    /// Current position of the camera in **world** space.
76    pub pos: Point3<World>,
77    /// Current heading of the camera in **world** space.
78    pub heading: SphericalVec<World>,
79}
80
81pub type ViewToWorld = RealToReal<3, View, World>;
82
83/// Creates a unit `SphericalVec` from azimuth and altitude.
84#[cfg(feature = "fp")]
85fn az_alt<B>(az: Angle, alt: Angle) -> SphericalVec<B> {
86    spherical(1.0, az, alt)
87}
88/// Orbiting camera transform.
89///
90/// Keeps the camera centered on a **world-space** point, and allows free
91/// 360°/180° azimuth/altitude rotation around that point as well as setting
92/// the distance from the point.
93#[derive(Copy, Clone, Debug)]
94pub struct Orbit {
95    /// The camera's target point in **world** space.
96    pub target: Point3<World>,
97    /// The camera's direction in **world** space.
98    pub dir: SphericalVec<World>,
99}
100
101//
102// Inherent impls
103//
104
105impl Fov {
106    /// TODO
107    pub fn focal_ratio(self, aspect_ratio: f32) -> f32 {
108        use Fov::*;
109        #[cfg(feature = "fp")]
110        fn ratio(a: Angle) -> f32 {
111            1.0 / (a / 2.0).tan()
112        }
113        match self {
114            FocalRatio(r) => r,
115            Equiv35mm(mm) => mm / (36.0 / 2.0), // half frame width
116
117            #[cfg(feature = "fp")]
118            Horizontal(a) => ratio(a),
119
120            #[cfg(feature = "fp")]
121            Vertical(a) => ratio(a) / aspect_ratio,
122
123            #[cfg(feature = "fp")]
124            Diagonal(a) => {
125                use crate::math::float::f32;
126                let diag = f32::sqrt(1.0 + 1.0 / aspect_ratio / aspect_ratio);
127                ratio(a) * diag
128            }
129        }
130    }
131}
132
133impl Camera<()> {
134    /// Creates a camera with the given resolution.
135    pub fn new(dims: Dims) -> Self {
136        Self {
137            dims,
138            viewport: viewport(pt2(0, 0)..pt2(dims.0, dims.1)),
139            ..Self::default()
140        }
141    }
142
143    /// Sets the world-to-view transform of this camera.
144    pub fn transform<T: Transform>(self, tf: T) -> Camera<T> {
145        let Self { dims, project, viewport, .. } = self;
146        Camera {
147            transform: tf,
148            dims,
149            project,
150            viewport,
151        }
152    }
153}
154
155impl<T> Camera<T> {
156    /// Sets the viewport bounds of this camera.
157    pub fn viewport(self, bounds: impl Into<Rect<u32>>) -> Self {
158        let (w, h) = self.dims;
159
160        let Rect {
161            left: Some(l),
162            top: Some(t),
163            right: Some(r),
164            bottom: Some(b),
165        } = bounds.into().intersect(&(0..w, 0..h).into())
166        else {
167            unreachable!("bounded ∩ bounded should be bounded")
168        };
169
170        Self {
171            dims: (r.abs_diff(l), b.abs_diff(t)),
172            viewport: viewport(pt2(l, t)..pt2(r, b)),
173            ..self
174        }
175    }
176
177    /// Sets up perspective projection with the given field of view
178    /// and near–far range.
179    ///
180    /// The endpoints of `near_far` denote the distance of the near and far
181    /// clipping planes.
182    ///
183    /// # Panics
184    /// * If any parameter value is non-positive.
185    /// * If `near_far` is an empty range.
186    pub fn perspective(mut self, fov: Fov, near_far: Range<f32>) -> Self {
187        let aspect = self.dims.0 as f32 / self.dims.1 as f32;
188
189        self.project = perspective(fov.focal_ratio(aspect), aspect, near_far);
190        self
191    }
192
193    /// Sets up orthographic projection.
194    pub fn orthographic(mut self, bounds: Range<Point3>) -> Self {
195        self.project = orthographic(bounds.start, bounds.end);
196        self
197    }
198}
199
200impl<T: Transform> Camera<T> {
201    /// Returns the composed camera and projection matrix.
202    pub fn world_to_project(&self) -> Mat4x4<RealToProj<World>> {
203        self.transform.world_to_view().then(&self.project)
204    }
205
206    /// Renders the given geometry from the viewpoint of this camera.
207    pub fn render<B, Prim, Vtx: Clone, Var: Lerp + Vary, Uni: Copy, Shd>(
208        &self,
209        prims: impl AsRef<[Prim]>,
210        verts: impl AsRef<[Vtx]>,
211        to_world: &Mat4x4<RealToReal<3, B, World>>,
212        shader: &Shd,
213        uniform: Uni,
214        target: &mut impl Target,
215        ctx: &Context,
216    ) where
217        Prim: Render<Var> + Clone,
218        [<Prim>::Clip]: Clip<Item = Prim::Clip>,
219        Shd: for<'a> Shader<Vtx, Var, (&'a Mat4x4<RealToProj<B>>, Uni)>,
220    {
221        let tf = to_world.then(&self.world_to_project());
222
223        super::render(
224            prims.as_ref(),
225            verts.as_ref(),
226            shader,
227            (&tf, uniform),
228            self.viewport,
229            target,
230            ctx,
231        );
232    }
233}
234
235#[cfg(feature = "fp")]
236impl FirstPerson {
237    /// Creates a first-person transform with position in the origin
238    /// and heading in the direction of the positive x-axis.
239    pub fn new() -> Self {
240        Self {
241            pos: Point3::origin(),
242            heading: az_alt(turns(0.0), turns(0.0)),
243        }
244    }
245
246    /// Rotates the camera to center the view on a **world-space** point.
247    pub fn look_at(&mut self, pt: Point3<World>) {
248        let head = (pt - self.pos).to_spherical();
249        self.rotate_to(head.az(), head.alt());
250    }
251
252    /// Rotates the camera by relative azimuth and altitude.
253    pub fn rotate(&mut self, delta_az: Angle, delta_alt: Angle) {
254        let head = self.heading;
255        self.rotate_to(head.az() + delta_az, head.alt() + delta_alt);
256    }
257
258    /// Rotates the camera to an absolute orientation in **world** space.
259    // TODO may confuse camera and world space
260    pub fn rotate_to(&mut self, az: Angle, alt: Angle) {
261        self.heading = az_alt(
262            az.wrap(turns(-0.5), turns(0.5)),
263            alt.clamp(turns(-0.25), turns(0.25)),
264        );
265    }
266
267    /// Translates the camera by a relative offset in **view** space.
268    // TODO Explain that up/down is actually in world space (dir of gravity)
269    pub fn translate(&mut self, delta: Vec3<View>) {
270        // Zero azimuth means parallel to the x-axis
271        let fwd = az_alt(self.heading.az(), turns(0.0)).to_cart();
272        let up = Vec3::Y;
273        let right = up.cross(&fwd);
274
275        let to_world = Mat4x4::from_linear(right, up, fwd);
276        self.pos += to_world.apply(&delta);
277    }
278}
279
280#[cfg(feature = "fp")]
281impl Orbit {
282    /// Adds the azimuth and altitude to the camera's current direction.
283    ///
284    /// Wraps the resulting azimuth to [-180°, 180°) and clamps the altitude to [-90°, 90°].
285    pub fn rotate(&mut self, az_delta: Angle, alt_delta: Angle) {
286        self.rotate_to(self.dir.az() + az_delta, self.dir.alt() + alt_delta);
287    }
288
289    /// Rotates the camera to the **world**-space azimuth and altitude given.
290    ///
291    /// Wraps the azimuth to [-180°, 180°) and clamps the altitude to [-90°, 90°].
292    pub fn rotate_to(&mut self, az: Angle, alt: Angle) {
293        self.dir = spherical(
294            self.dir.r(),
295            az.wrap(turns(-0.5), turns(0.5)),
296            alt.clamp(turns(-0.25), turns(0.25)),
297        );
298    }
299
300    /// Translates the camera's target point in **world** space.
301    pub fn translate(&mut self, delta: Vec3<World>) {
302        self.target += delta;
303    }
304
305    /// Moves the camera towards or away from the target.
306    ///
307    /// Multiplies the current camera distance by `factor`. The distance is
308    /// clamped to zero. Note that if the distance becomes zero, you cannot use
309    /// this method to make it nonzero again!
310    ///
311    /// To set an absolute zoom distance, use [`zoom_to`][Self::zoom_to].
312    ///
313    /// # Panics
314    /// If `factor < 0`.
315    pub fn zoom(&mut self, factor: f32) {
316        assert!(factor >= 0.0, "zoom factor cannot be negative");
317        self.zoom_to(self.dir.r() * factor);
318    }
319    /// Moves the camera to the given distance from the target.
320    ///
321    /// # Panics
322    /// If `r < 0`.
323    pub fn zoom_to(&mut self, r: f32) {
324        assert!(r >= 0.0, "camera distance cannot be negative");
325        self.dir[0] = r.max(0.0);
326    }
327}
328
329//
330// Local trait impls
331//
332
333#[cfg(feature = "fp")]
334impl Transform for FirstPerson {
335    fn world_to_view(&self) -> Mat4x4<WorldToView> {
336        let &Self { pos, heading, .. } = self;
337        let fwd_move = az_alt(heading.az(), turns(0.0)).to_cart();
338        let fwd = heading.to_cart();
339        let right = Vec3::Y.cross(&fwd_move);
340
341        // World-to-view is inverse of camera's world transform
342        let transl = translate(-pos.to_vec().to());
343        let orient = orient_z(fwd.to(), right).transpose();
344
345        transl.then(&orient).to()
346    }
347}
348
349#[cfg(feature = "fp")]
350impl Transform for Orbit {
351    fn world_to_view(&self) -> Mat4x4<WorldToView> {
352        // TODO Figure out how to do this with orient
353        //let fwd = self.dir.to_cart().normalize();
354        //let o = orient_z(fwd, Vec3::X - 0.1 * Vec3::Z);
355
356        // TODO Work out how and whether this is the correct inverse
357        //      of the view-to-world transform
358        translate(self.target.to_vec().to()) // to world-space target
359            .then(&rotate_y(self.dir.az())) // to world-space az
360            .then(&rotate_x(self.dir.alt())) // to world-space alt
361            .then(&translate(self.dir.r() * Vec3::Z)) // view space
362            .to()
363    }
364}
365
366impl Transform for Mat4x4<WorldToView> {
367    fn world_to_view(&self) -> Mat4x4<WorldToView> {
368        *self
369    }
370}
371
372//
373// Foreign trait impls
374//
375
376#[cfg(feature = "fp")]
377impl Default for FirstPerson {
378    /// Returns [`FirstPerson::new`].
379    fn default() -> Self {
380        Self::new()
381    }
382}
383
384#[cfg(feature = "fp")]
385impl Default for Orbit {
386    fn default() -> Self {
387        Self {
388            target: Point3::default(),
389            dir: az_alt(turns(0.0), turns(0.0)),
390        }
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    use Fov::*;
399
400    #[test]
401    fn camera_tests_here() {
402        // TODO
403    }
404
405    #[test]
406    fn fov_focal_ratio() {
407        assert_eq!(FocalRatio(2.345).focal_ratio(1.0), 2.345);
408        assert_eq!(FocalRatio(2.345).focal_ratio(2.0), 2.345);
409
410        assert_eq!(Equiv35mm(18.0).focal_ratio(1.0), 1.0);
411        assert_eq!(Equiv35mm(36.0).focal_ratio(1.5), 2.0);
412    }
413
414    #[cfg(feature = "fp")]
415    #[test]
416    fn angle_of_view_focal_ratio_with_unit_aspect_ratio() {
417        use crate::math::degs;
418        use core::f32::consts::SQRT_2;
419        const SQRT_3: f32 = 1.7320509;
420
421        assert_eq!(Horizontal(degs(60.0)).focal_ratio(1.0), SQRT_3);
422        assert_eq!(Vertical(degs(60.0)).focal_ratio(1.0), SQRT_3);
423        assert_eq!(Diagonal(degs(60.0)).focal_ratio(1.0), SQRT_3 * SQRT_2);
424    }
425
426    #[cfg(feature = "fp")]
427    #[test]
428    fn angle_of_view_focal_ratio_with_other_aspect_ratio() {
429        use crate::math::degs;
430        const SQRT_3: f32 = 1.7320509;
431
432        assert_eq!(Horizontal(degs(60.0)).focal_ratio(SQRT_3), SQRT_3);
433        assert_eq!(Vertical(degs(60.0)).focal_ratio(SQRT_3), 1.0);
434        assert_eq!(Diagonal(degs(60.0)).focal_ratio(SQRT_3), 2.0);
435    }
436}