citro3d/math/
projection.rs

1use std::mem::MaybeUninit;
2use std::ops::Range;
3
4use super::Matrix4;
5
6/// Configuration for a 3D [projection](https://en.wikipedia.org/wiki/3D_projection).
7/// See specific `Kind` implementations for constructors, e.g.
8/// [`Projection::perspective`] and [`Projection::orthographic`].
9///
10/// To use the resulting projection, convert it to a [`Matrix4`] with [`From`]/[`Into`].
11#[derive(Clone, Debug)]
12pub struct Projection<Kind> {
13    coordinates: CoordinateOrientation,
14    rotation: ScreenOrientation,
15    inner: Kind,
16}
17
18impl<Kind> Projection<Kind> {
19    fn new(inner: Kind) -> Self {
20        Self {
21            coordinates: CoordinateOrientation::default(),
22            rotation: ScreenOrientation::default(),
23            inner,
24        }
25    }
26
27    /// Set the coordinate system's orientation for the projection.
28    /// See [`CoordinateOrientation`] for more details.
29    ///
30    /// # Example
31    ///
32    /// ```
33    /// # let _runner = test_runner::GdbRunner::default();
34    /// # use citro3d::math::{Projection, AspectRatio, CoordinateOrientation, Matrix4, ClipPlanes};
35    /// let clip_planes = ClipPlanes {
36    ///     near: 0.1,
37    ///     far: 100.0,
38    /// };
39    /// let mtx: Matrix4 = Projection::perspective(40.0,  AspectRatio::TopScreen, clip_planes)
40    ///     .coordinates(CoordinateOrientation::LeftHanded)
41    ///     .into();
42    /// ```
43    pub fn coordinates(mut self, orientation: CoordinateOrientation) -> Self {
44        self.coordinates = orientation;
45        self
46    }
47
48    /// Set the screen rotation for the projection.
49    /// See [`ScreenOrientation`] for more details.
50    ///
51    /// # Example
52    ///
53    /// ```
54    /// # let _runner = test_runner::GdbRunner::default();
55    /// # use citro3d::math::{Projection, AspectRatio, ScreenOrientation, Matrix4, ClipPlanes};
56    /// let clip_planes = ClipPlanes {
57    ///     near: 0.1,
58    ///     far: 100.0,
59    /// };
60    /// let mtx: Matrix4 = Projection::perspective(40.0, AspectRatio::TopScreen, clip_planes)
61    ///     .screen(ScreenOrientation::None)
62    ///     .into();
63    /// ```
64    pub fn screen(mut self, orientation: ScreenOrientation) -> Self {
65        self.rotation = orientation;
66        self
67    }
68}
69
70/// See [`Projection::perspective`].
71#[derive(Clone, Debug)]
72pub struct Perspective {
73    vertical_fov_radians: f32,
74    aspect_ratio: AspectRatio,
75    clip_planes: ClipPlanes,
76    stereo: Option<StereoDisplacement>,
77}
78
79impl Projection<Perspective> {
80    /// Construct a projection matrix suitable for projecting 3D world space onto
81    /// the 3DS screens.
82    ///
83    /// # Parameters
84    ///
85    /// * `vertical_fov`: the vertical field of view, measured in radians
86    /// * `aspect_ratio`: the aspect ratio of the projection
87    /// * `clip_planes`: the near and far clip planes of the view frustum.
88    ///   [`ClipPlanes`] are always defined by near and far values, regardless
89    ///   of the projection's [`CoordinateOrientation`].
90    ///
91    /// # Examples
92    ///
93    /// ```
94    /// # use citro3d::math::*;
95    /// # use std::f32::consts::PI;
96    /// #
97    /// # let _runner = test_runner::GdbRunner::default();
98    /// #
99    /// let clip_planes = ClipPlanes {
100    ///     near: 0.01,
101    ///     far: 100.0,
102    /// };
103    ///
104    /// let bottom: Matrix4 =
105    ///     Projection::perspective(PI / 4.0, AspectRatio::BottomScreen, clip_planes).into();
106    ///
107    /// let top: Matrix4 =
108    ///     Projection::perspective(PI / 4.0, AspectRatio::TopScreen, clip_planes).into();
109    /// ```
110    #[doc(alias = "Mtx_Persp")]
111    #[doc(alias = "Mtx_PerspTilt")]
112    pub fn perspective(
113        vertical_fov_radians: f32,
114        aspect_ratio: AspectRatio,
115        clip_planes: ClipPlanes,
116    ) -> Self {
117        Self::new(Perspective {
118            vertical_fov_radians,
119            aspect_ratio,
120            clip_planes,
121            stereo: None,
122        })
123    }
124
125    /// Helper function to build both eyes' perspective projection matrices
126    /// at once. See [`StereoDisplacement`] for details on how to configure
127    /// stereoscopy.
128    ///
129    /// ```
130    /// # use std::f32::consts::PI;
131    /// # use citro3d::math::*;
132    /// #
133    /// # let _runner = test_runner::GdbRunner::default();
134    /// #
135    /// let (left, right) = StereoDisplacement::new(0.5, 2.0);
136    /// let (left_eye, right_eye) = Projection::perspective(
137    ///     PI / 4.0,
138    ///     AspectRatio::TopScreen,
139    ///     ClipPlanes {
140    ///         near: 0.01,
141    ///         far: 100.0,
142    ///     },
143    /// )
144    /// .stereo_matrices(left, right);
145    /// ```
146    #[doc(alias = "Mtx_PerspStereo")]
147    #[doc(alias = "Mtx_PerspStereoTilt")]
148    pub fn stereo_matrices(
149        self,
150        left_eye: StereoDisplacement,
151        right_eye: StereoDisplacement,
152    ) -> (Matrix4, Matrix4) {
153        // TODO: we might be able to avoid this clone if there was a conversion
154        // from &Self to Matrix4 instead of Self... but it's probably fine for now
155        let left = self.clone().stereo(left_eye);
156        let right = self.stereo(right_eye);
157        // Also, we could consider just returning (Self, Self) here? idk
158        (left.into(), right.into())
159    }
160
161    fn stereo(mut self, displacement: StereoDisplacement) -> Self {
162        self.inner.stereo = Some(displacement);
163        self
164    }
165}
166
167impl From<Projection<Perspective>> for Matrix4 {
168    fn from(projection: Projection<Perspective>) -> Self {
169        let Perspective {
170            vertical_fov_radians,
171            aspect_ratio,
172            clip_planes,
173            stereo,
174        } = projection.inner;
175
176        let mut result = MaybeUninit::uninit();
177
178        if let Some(stereo) = stereo {
179            let make_mtx = match projection.rotation {
180                ScreenOrientation::Rotated => citro3d_sys::Mtx_PerspStereoTilt,
181                ScreenOrientation::None => citro3d_sys::Mtx_PerspStereo,
182            };
183            unsafe {
184                make_mtx(
185                    result.as_mut_ptr(),
186                    vertical_fov_radians,
187                    aspect_ratio.into(),
188                    clip_planes.near,
189                    clip_planes.far,
190                    stereo.displacement,
191                    stereo.screen_depth,
192                    projection.coordinates.is_left_handed(),
193                );
194            }
195        } else {
196            let make_mtx = match projection.rotation {
197                ScreenOrientation::Rotated => citro3d_sys::Mtx_PerspTilt,
198                ScreenOrientation::None => citro3d_sys::Mtx_Persp,
199            };
200            unsafe {
201                make_mtx(
202                    result.as_mut_ptr(),
203                    vertical_fov_radians,
204                    aspect_ratio.into(),
205                    clip_planes.near,
206                    clip_planes.far,
207                    projection.coordinates.is_left_handed(),
208                );
209            }
210        }
211
212        unsafe { Self::from_raw(result.assume_init()) }
213    }
214}
215
216/// See [`Projection::orthographic`].
217#[derive(Clone, Debug)]
218pub struct Orthographic {
219    clip_planes_x: Range<f32>,
220    clip_planes_y: Range<f32>,
221    clip_planes_z: ClipPlanes,
222}
223
224impl Projection<Orthographic> {
225    /// Construct an orthographic projection. The X and Y clip planes are passed
226    /// as ranges because their coordinates are always oriented the same way
227    /// (+X right, +Y up).
228    ///
229    /// The Z [`ClipPlanes`], however, are always defined by
230    /// near and far values, regardless of the projection's [`CoordinateOrientation`].
231    ///
232    /// # Example
233    ///
234    /// ```
235    /// # let _runner = test_runner::GdbRunner::default();
236    /// # use citro3d::math::{Projection, ClipPlanes, Matrix4};
237    /// #
238    /// let mtx: Matrix4 = Projection::orthographic(
239    ///     0.0..240.0,
240    ///     0.0..400.0,
241    ///     ClipPlanes {
242    ///         near: 0.0,
243    ///         far: 100.0,
244    ///     },
245    /// )
246    /// .into();
247    /// ```
248    #[doc(alias = "Mtx_Ortho")]
249    #[doc(alias = "Mtx_OrthoTilt")]
250    pub fn orthographic(
251        clip_planes_x: Range<f32>,
252        clip_planes_y: Range<f32>,
253        clip_planes_z: ClipPlanes,
254    ) -> Self {
255        Self::new(Orthographic {
256            clip_planes_x,
257            clip_planes_y,
258            clip_planes_z,
259        })
260    }
261}
262
263impl From<Projection<Orthographic>> for Matrix4 {
264    fn from(projection: Projection<Orthographic>) -> Self {
265        let make_mtx = match projection.rotation {
266            ScreenOrientation::Rotated => citro3d_sys::Mtx_OrthoTilt,
267            ScreenOrientation::None => citro3d_sys::Mtx_Ortho,
268        };
269
270        let Orthographic {
271            clip_planes_x,
272            clip_planes_y,
273            clip_planes_z,
274        } = projection.inner;
275
276        let mut out = MaybeUninit::uninit();
277        unsafe {
278            make_mtx(
279                out.as_mut_ptr(),
280                clip_planes_x.start,
281                clip_planes_x.end,
282                clip_planes_y.start,
283                clip_planes_y.end,
284                clip_planes_z.near,
285                clip_planes_z.far,
286                projection.coordinates.is_left_handed(),
287            );
288            Self::from_raw(out.assume_init())
289        }
290    }
291}
292
293// region: Projection configuration
294
295/// The [orientation](https://en.wikipedia.org/wiki/Orientation_(geometry))
296/// (or "handedness") of the coordinate system. Coordinates are always +Y-up,
297/// +X-right.
298#[derive(Clone, Copy, Debug)]
299pub enum CoordinateOrientation {
300    /// A left-handed coordinate system. +Z points into the screen.
301    LeftHanded,
302    /// A right-handed coordinate system. +Z points out of the screen.
303    RightHanded,
304}
305
306impl CoordinateOrientation {
307    pub(crate) fn is_left_handed(self) -> bool {
308        matches!(self, Self::LeftHanded)
309    }
310}
311
312impl Default for CoordinateOrientation {
313    /// This is an opinionated default, but [`RightHanded`](Self::RightHanded)
314    /// seems to be the preferred coordinate system for most
315    /// [examples](https://github.com/devkitPro/3ds-examples)
316    /// from upstream, and is also fairly common in other applications.
317    fn default() -> Self {
318        Self::RightHanded
319    }
320}
321
322/// Whether to rotate a projection to account for the 3DS screen orientation.
323/// Both screens on the 3DS are oriented such that the "top-left" of the screen
324/// in framebuffer coordinates is the physical bottom-left of the screen
325/// (i.e. the "width" is smaller than the "height").
326#[derive(Clone, Copy, Debug)]
327pub enum ScreenOrientation {
328    /// Rotate 90° clockwise to account for the 3DS screen rotation. Most
329    /// applications will use this variant.
330    Rotated,
331    /// Do not apply any extra rotation to the projection.
332    None,
333}
334
335impl Default for ScreenOrientation {
336    fn default() -> Self {
337        Self::Rotated
338    }
339}
340
341/// Configuration for calculating stereoscopic projections.
342// TODO: not totally happy with this name + API yet, but it works for now.
343#[derive(Clone, Copy, Debug)]
344pub struct StereoDisplacement {
345    /// The horizontal offset of the eye from center. Negative values
346    /// correspond to the left eye, and positive values to the right eye.
347    pub displacement: f32,
348    /// The position of the screen, which determines the focal length. Objects
349    /// closer than this depth will appear to pop out of the screen, and objects
350    /// further than this will appear inside the screen.
351    pub screen_depth: f32,
352}
353
354impl StereoDisplacement {
355    /// Construct displacement for the left and right eyes simulataneously.
356    /// The given `interocular_distance` describes the distance between the two
357    /// rendered "eyes". A negative value will be treated the same as a positive
358    /// value of the same magnitude.
359    ///
360    /// See struct documentation for details about the
361    /// [`screen_depth`](Self::screen_depth) parameter.
362    pub fn new(interocular_distance: f32, screen_depth: f32) -> (Self, Self) {
363        let displacement = interocular_distance.abs() / 2.0;
364
365        let left_eye = Self {
366            displacement: -displacement,
367            screen_depth,
368        };
369        let right_eye = Self {
370            displacement,
371            screen_depth,
372        };
373
374        (left_eye, right_eye)
375    }
376}
377
378/// Configuration for the clipping planes of a projection.
379///
380/// For [`Perspective`] projections, this is used for the near and far clip planes
381/// of the [view frustum](https://en.wikipedia.org/wiki/Viewing_frustum).
382///
383/// For [`Orthographic`] projections, this is used for the Z clipping planes of
384/// the projection.
385///
386/// Note that the `near` value should always be less than `far`, regardless of
387/// [`CoordinateOrientation`]. In other words, these values will be negated
388/// when used with a [`RightHanded`](CoordinateOrientation::RightHanded)
389/// orientation.
390#[derive(Clone, Copy, Debug)]
391pub struct ClipPlanes {
392    /// The Z-depth of the near clip plane, usually close or equal to zero.
393    pub near: f32,
394    /// The Z-depth of the far clip plane, usually greater than zero.
395    pub far: f32,
396}
397
398/// The aspect ratio of a projection plane.
399#[derive(Clone, Copy, Debug)]
400#[non_exhaustive]
401#[doc(alias = "C3D_AspectRatioTop")]
402#[doc(alias = "C3D_AspectRatioBot")]
403pub enum AspectRatio {
404    /// The aspect ratio of the 3DS' top screen (per-eye).
405    #[doc(alias = "C3D_AspectRatioTop")]
406    TopScreen,
407    /// The aspect ratio of the 3DS' bottom screen.
408    #[doc(alias = "C3D_AspectRatioBot")]
409    BottomScreen,
410    /// A custom aspect ratio (should be calcualted as `width / height`).
411    Other(f32),
412}
413
414impl From<AspectRatio> for f32 {
415    fn from(ratio: AspectRatio) -> Self {
416        match ratio {
417            AspectRatio::TopScreen => citro3d_sys::C3D_AspectRatioTop as f32,
418            AspectRatio::BottomScreen => citro3d_sys::C3D_AspectRatioBot as f32,
419            AspectRatio::Other(ratio) => ratio,
420        }
421    }
422}
423
424// endregion