Skip to main content

all_is_cubes/
camera.rs

1//! Note: This module is hidden, and its contents re-exported as `all_is_cubes_render::camera`.
2
3use euclid::{
4    Angle, Point2D, Point3D, RigidTransform3D, Rotation3D, Size2D, Transform3D, point3, vec3,
5};
6use itertools::Itertools as _;
7use num_traits::ConstOne as _;
8
9/// Acts as polyfill for float methods
10#[cfg(not(feature = "std"))]
11#[allow(unused_imports)]
12use num_traits::float::Float as _;
13
14use crate::math::{
15    Aab, Axis, Cube, FreeCoordinate, FreePoint, FreeVector, GridAab, Octant, OctantMask,
16    PositiveSign, Rgba, lines, ps64,
17};
18use crate::raycast::Ray;
19
20// TODO: It'd be nice if GraphicsOptions were moved to all-is-cubes-render,
21// but we need it because it's strongly an input to the raytracer, which we need to be able to
22// `print_space()` in tests. Perhaps the raytracer types *here* could be made generic over the
23// options input, with a wrapper that hides that?
24mod graphics_options;
25pub use graphics_options::*;
26
27#[cfg(test)]
28mod tests;
29
30/// Representation of a camera viewpoint and orientation.
31///
32/// Note that this is treated as a transform **from** the origin looking in the −Z
33/// direction (eye space) **to** the camera position and orientation (world space).
34/// This is done so that the [`RigidTransform3D::translation`] vector is equal to the
35/// world position, rather than needing to be rotated by the view direction.
36pub type ViewTransform = RigidTransform3D<FreeCoordinate, Eye, Cube>;
37
38// docs are on its re-export
39#[derive(Clone, Debug)]
40pub struct Camera {
41    /// Caller-provided options. Always validated by [`GraphicsOptions::repair`].
42    options: GraphicsOptions,
43
44    /// Caller-provided viewport.
45    viewport: Viewport,
46
47    /// Caller-provided view transform.
48    eye_to_world_transform: ViewTransform,
49
50    /// Inverse of `eye_to_world_transform` as a matrix.
51    /// Might also be called "view matrix".
52    /// Calculated by [`Self::compute_matrices`].
53    world_to_eye_matrix: Transform3D<FreeCoordinate, Cube, Eye>,
54
55    /// Projection matrix derived from viewport and options.
56    /// Calculated by [`Self::compute_matrices`].
57    projection: Transform3D<FreeCoordinate, Eye, Ndc>,
58
59    /// View point derived from view matrix.
60    /// Calculated by [`Self::compute_matrices`].
61    view_position: FreePoint,
62
63    /// Inverse of `projection * world_to_eye_matrix`.
64    /// Calculated by [`Self::compute_matrices`].
65    inverse_projection_view: Transform3D<FreeCoordinate, Ndc, Cube>,
66
67    /// Bounds of the visible area in world space.
68    /// Calculated by [`Self::compute_matrices`].
69    view_frustum: FrustumPoints,
70
71    /// Scale factor for scene brightness.
72    /// Calculated from `options.exposure` by [`Self::set_options`].
73    exposure_value: PositiveSign<f32>,
74}
75
76/// Basic creation and mutation.
77impl Camera {
78    /// Create a camera which has
79    ///
80    /// * options and viewport as given,
81    /// * a view transform of [`ViewTransform::identity()`], and
82    /// * an exposure determined based on the graphics options.
83    pub fn new(options: GraphicsOptions, viewport: Viewport) -> Self {
84        let options = options.repair();
85        let mut new_self = Self {
86            viewport,
87            eye_to_world_transform: ViewTransform::identity(),
88
89            // Overwritten immediately by compute_matrices
90            world_to_eye_matrix: Transform3D::identity(),
91            projection: Transform3D::identity(),
92            view_position: Point3D::origin(),
93            inverse_projection_view: Transform3D::identity(),
94            view_frustum: Default::default(),
95
96            exposure_value: options.exposure.initial(),
97
98            options,
99        };
100        new_self.compute_matrices();
101        new_self
102    }
103
104    /// Replace the [`GraphicsOptions`] stored in this camera with
105    /// [`options.repair()`](GraphicsOptions::repair).
106    pub fn set_options(&mut self, options: GraphicsOptions) {
107        let options = options.repair();
108        self.exposure_value = options.exposure.initial();
109        self.options = options;
110        // TODO: we only *need* to recompute if fov_y changed (currently)
111        self.compute_matrices();
112    }
113
114    /// Returns the [`GraphicsOptions`] value last provided to
115    /// [`Camera::new()`] or [`Camera::set_options()`] (possibly with
116    /// [adjusted](GraphicsOptions::repair) values).
117    pub fn options(&self) -> &GraphicsOptions {
118        &self.options
119    }
120
121    /// Sets the contained viewport value, and recalculates matrices to be suitable for
122    /// the new viewport's aspect ratio.
123    pub fn set_viewport(&mut self, viewport: Viewport) {
124        if viewport != self.viewport {
125            self.viewport = viewport;
126            // TODO: What happens if the viewport is negative sized?
127            self.compute_matrices();
128        }
129    }
130
131    /// Returns the viewport last provided to [`Camera::new()`] or [`Camera::set_viewport()`].
132    pub fn viewport(&self) -> Viewport {
133        self.viewport
134    }
135
136    /// Sets the view transform and recalculates matrices appropriately.
137    ///
138    /// Note that this is specified as the eye-to-world transform; that is, the given transform’s
139    /// translation should be equal to the view point in world coordinates.
140    ///
141    /// Besides controlling rendering, this is used to determine world coordinates for purposes
142    /// of [`view_position()`](Self::view_position) and
143    /// [`project_ndc_into_world()`](Self::project_ndc_into_world).
144    pub fn set_view_transform(&mut self, eye_to_world_transform: ViewTransform) {
145        if eye_to_world_transform.to_untyped() == self.eye_to_world_transform.to_untyped() {
146            return;
147        }
148
149        self.eye_to_world_transform = eye_to_world_transform;
150        self.compute_matrices();
151    }
152
153    /// Sets the view transform like [`Camera::set_view_transform()`], but in “look at” fashion.
154    pub fn look_at_y_up(&mut self, eye: FreePoint, target: FreePoint) {
155        self.set_view_transform(look_at_y_up(eye, target))
156    }
157
158    /// Returns the last eye-to-world transform set by [`Camera::set_view_transform()`].
159    pub fn view_transform(&self) -> ViewTransform {
160        self.eye_to_world_transform
161    }
162
163    /// Sets the exposure value that should have been determined by average scene brightness.
164    /// This may or may not affect [`Self::exposure()`] depending on the current
165    /// graphics options.
166    pub fn set_measured_exposure(&mut self, value: f32) {
167        if let Ok(value) = PositiveSign::<f32>::try_from(value) {
168            match (&self.options.exposure, &self.options.lighting_display) {
169                (ExposureOption::Fixed(_), _) => { /* nothing to do */ }
170                (ExposureOption::Automatic, LightingOption::None) => {
171                    self.exposure_value = PositiveSign::ONE;
172                }
173                (ExposureOption::Automatic, _) => {
174                    self.exposure_value = value;
175                }
176            }
177        }
178    }
179}
180
181/// Values derived from the basic parameters, and
182/// functions for applying this camera’s characteristics to points and colors.
183impl Camera {
184    /// Returns the field of view, expressed in degrees on the vertical axis (that is, the
185    /// horizontal field of view depends on the viewport's aspect ratio).
186    pub fn fov_y(&self) -> FreeCoordinate {
187        self.options.fov_y.into_inner()
188    }
189
190    /// Returns the view distance; the far plane of the view frustum, or the distance
191    /// at which rendering may be truncated.
192    pub fn view_distance(&self) -> PositiveSign<FreeCoordinate> {
193        self.options.view_distance
194    }
195
196    /// Returns the position of the near plane of the view frustum.
197    /// This is not currently configurable.
198    #[allow(clippy::unused_self)]
199    pub fn near_plane_distance(&self) -> PositiveSign<FreeCoordinate> {
200        // half a voxel at resolution=16
201        ps64((32.0f64).recip())
202    }
203
204    /// Returns a perspective projection matrix based on the configured FOV and view distance,
205    ///
206    /// It maps coordinates in “eye” space into the Normalized Device Cooordinate space whose range
207    /// is from -1 to 1 in X and Y, and 0 to 1 in Z. (This is DirectX and WebGPU style NDC, rather
208    /// than OpenGL style which has a range of -1 to 1 in Z.)
209    pub fn projection_matrix(&self) -> Transform3D<FreeCoordinate, Eye, Ndc> {
210        self.projection
211    }
212
213    /// Returns a matrix which maps coordinates in world space to coordinates in eye space.
214    /// It is the inverse of the current [`Camera::view_transform()`].
215    pub fn view_matrix(&self) -> Transform3D<FreeCoordinate, Cube, Eye> {
216        self.world_to_eye_matrix
217    }
218
219    /// Returns the eye position in world coordinates, as set by [`Camera::set_view_transform()`].
220    pub fn view_position(&self) -> FreePoint {
221        self.view_position
222    }
223
224    /// Converts a screen position in normalized device coordinates (as produced by
225    /// [`Viewport::normalize_nominal_point()`]) into a ray in world space.
226    /// Uses the view transformation given by [`set_view_transform`](Self::set_view_transform).
227    ///
228    /// The input coordinates should be within the range -1 to 1, inclusive.
229    /// If they are not, the result may be a ray whose components are NaN.
230    ///
231    /// The ray’s origin’s distance along the view direction axis is equal to
232    /// [`Camera::near_plane_distance()`].
233    /// The ray's [`Ray::unit_endpoint()`] along the view direction axis is equal to
234    /// [`Camera::view_distance()`].
235    pub fn project_ndc_into_world(&self, ndc: NdcPoint2) -> Ray {
236        let ndc_near = ndc.extend(0.0);
237        let ndc_far = ndc.extend(1.0);
238
239        // World-space endpoints of the ray.
240        let world_near = self.project_ndc3_into_world(ndc_near);
241        let world_far = self.project_ndc3_into_world(ndc_far);
242
243        let direction = world_far - world_near;
244        Ray {
245            origin: world_near,
246            direction,
247        }
248    }
249
250    fn project_ndc3_into_world(&self, p: NdcPoint3) -> FreePoint {
251        self.inverse_projection_view
252            .transform_point3d(p)
253            .unwrap_or(FreePoint::splat(FreeCoordinate::NAN))
254    }
255
256    /// Returns an [`OctantMask`] which includes all directions (in world space) visible in
257    /// images rendered as specified by this camera.
258    ///
259    /// This information may be used as a fast initial culling step, avoiding iterating over
260    /// content behind the camera.
261    pub fn view_direction_mask(&self) -> OctantMask {
262        #[rustfmt::skip]
263        let FrustumPoints { lbf, rbf, ltf, rtf, lbn, rbn, ltn, rtn, .. } = self.view_frustum;
264
265        let mut mask = OctantMask::NONE;
266        // Fill the mask with 9 representative rays from the camera.
267        // Nine should be sufficient because the FOV cannot exceed 180°.
268        // (TODO: Wait, that's 2D reasoning...)
269        // Corner points
270        let lb = lbf - lbn;
271        let lt = ltf - ltn;
272        let rb = rbf - rbn;
273        let rt = rtf - rtn;
274        mask.set(Octant::from_vector(lb));
275        mask.set(Octant::from_vector(lt));
276        mask.set(Octant::from_vector(rb));
277        mask.set(Octant::from_vector(rt));
278        // Midpoints
279        let lmid = lb + lt;
280        let rmid = rb + rt;
281        mask.set(Octant::from_vector(lmid));
282        mask.set(Octant::from_vector(rmid));
283        mask.set(Octant::from_vector(lt + rt));
284        mask.set(Octant::from_vector(lb + rb));
285        // Center line
286        mask.set(Octant::from_vector(lmid + rmid));
287
288        mask
289    }
290
291    /// Determine whether the given `Aab` is visible in this projection+view.
292    pub fn aab_in_view(&self, aab: Aab) -> bool {
293        // This algorithm uses the separating axis theorem, which states that for two
294        // convex objects (here, an AAB and a frustum), if there is some axis for which
295        // projecting the objects' points onto that axis produces non-overlapping ranges,
296        // the objects do not intersect.
297        //
298        // The separation axes which we test are the face normals of each object.
299        // This is technically not sufficient (see e.g.
300        //     https://gamedev.stackexchange.com/a/44501/9825
301        // ), but false intersections are okay here since we're trying to do view culling,
302        // not collision detection.
303
304        // Test the AAB's face normals (i.e. the coordinate axes).
305        // To save some arithmetic, we've precomputed the frustum's axis-aligned bounding
306        // box, so we can use that instead of the general separated_along().
307        if !aab.intersects(self.view_frustum.bounds) {
308            return false;
309        }
310
311        // Test the view frustum's face normals.
312        // (Benchmarking has shown testing this first to be better, though not shown that
313        // it is better for all possible view orientations.)
314        //
315        // Note that testing against the near plane (lbn, ltn, rtn) is not necessary
316        // since it is always parallel to the far plane, and we are testing ranges on
317        // the axis, not “is this object on the far side of this plane”.
318        #[rustfmt::skip]
319        let FrustumPoints { lbf, rbf, ltf, rtf, lbn, rbn, ltn, rtn, .. } = self.view_frustum;
320        for &(p1, p2, p3) in &[
321            (lbn, lbf, ltf), // left
322            (rtn, rtf, rbf), // right
323            (ltn, ltf, rtf), // top
324            (rbn, rbf, lbf), // bottom
325            (lbf, rbf, ltf), // far
326        ] {
327            let normal = (p2 - p1).cross(p3 - p1);
328            if Self::separated_along(self.view_frustum.iter(), aab.corner_points(), normal) {
329                return false;
330            }
331        }
332
333        true
334    }
335
336    /// Helper for [`aab_in_view`]; finds if two sets of points' projections onto a line intersect.
337    ///
338    /// Note: NOT `#[inline]` because profiling shows that to have a negative effect.
339    fn separated_along(
340        points1: impl IntoIterator<Item = FreePoint>,
341        points2: impl IntoIterator<Item = FreePoint>,
342        axis: FreeVector,
343    ) -> bool {
344        let (min1, max1) = projected_range(points1, axis);
345        let (min2, max2) = projected_range(points2, axis);
346        let intersection_min = min1.max(min2);
347        let intersection_max = max1.min(max2);
348        intersection_max < intersection_min
349    }
350
351    #[doc(hidden)] // used in other crates debugging; not stable API
352    pub fn view_frustum_geometry(&self) -> &(impl lines::Wireframe + '_) {
353        &self.view_frustum
354    }
355
356    /// Returns the current exposure value for scaling luminance.
357    ///
358    /// Renderers should use this value, not the fixed exposure value in the [`GraphicsOptions`].
359    /// It may or may not be equal to the last
360    /// [`set_measured_exposure()`](Self::set_measured_exposure),
361    /// depending on the graphics options.
362    pub fn exposure(&self) -> PositiveSign<f32> {
363        self.exposure_value
364    }
365
366    /// Apply postprocessing steps determined by this camera to convert a [HDR] “scene”
367    /// color into a SDR “image” color. Specifically:
368    ///
369    /// 1. Multiply the input by this camera's [`exposure()`](Camera::exposure) value.
370    /// 2. Apply the tone mapping operator specified in [`Camera::options()`].
371    ///
372    /// [HDR]: https://en.wikipedia.org/wiki/High_dynamic_range
373    pub fn post_process_color(&self, color: Rgba) -> Rgba {
374        color.map_rgb(|rgb| {
375            self.options
376                .tone_mapping
377                .apply(self.options.maximum_intensity, rgb * self.exposure())
378        })
379    }
380}
381
382// Internals
383impl Camera {
384    fn compute_matrices(&mut self) {
385        let fov_cot = (self.fov_y() / 2.).to_radians().tan().recip();
386        let aspect = self.viewport.nominal_aspect_ratio();
387
388        let near = self.near_plane_distance().into_inner();
389        let far = self.view_distance().into_inner();
390
391        // Rationale for this particular matrix formula: "that's what `cgmath` does",
392        // and we used to use `cgmath`.
393        //
394        // Note that this is an DirectX—style projection matrix — that is, the depth range
395        // is 0 to 1, not -1 to 1.
396        #[rustfmt::skip]
397        let projection = Transform3D::new(
398            fov_cot / aspect, 0.0, 0.0, 0.0,
399            0.0, fov_cot, 0.0, 0.0,
400            0.0, 0.0, far / (near - far), -1.0,
401            0.0, 0.0, (far * near) / (near - far), 0.0,
402        );
403        self.projection = projection;
404
405        self.world_to_eye_matrix = self.eye_to_world_transform.inverse().to_transform();
406
407        self.view_position = self.eye_to_world_transform.translation.to_point();
408
409        self.inverse_projection_view = self
410            .world_to_eye_matrix
411            .then(&self.projection)
412            .inverse()
413            .expect("projection and view matrix was not invertible");
414
415        // Compute the view frustum's corner points,
416        // by unprojecting the corners of clip space.
417        let xy_limit = if self.options.debug_reduce_view_frustum {
418            0.5
419        } else {
420            1.
421        };
422        self.view_frustum = FrustumPoints {
423            lbn: self.project_ndc3_into_world(point3(-xy_limit, -xy_limit, 0.)),
424            rbn: self.project_ndc3_into_world(point3(xy_limit, -xy_limit, 0.)),
425            ltn: self.project_ndc3_into_world(point3(-xy_limit, xy_limit, 0.)),
426            rtn: self.project_ndc3_into_world(point3(xy_limit, xy_limit, 0.)),
427            lbf: self.project_ndc3_into_world(point3(-xy_limit, -xy_limit, 1.)),
428            rbf: self.project_ndc3_into_world(point3(xy_limit, -xy_limit, 1.)),
429            ltf: self.project_ndc3_into_world(point3(-xy_limit, xy_limit, 1.)),
430            rtf: self.project_ndc3_into_world(point3(xy_limit, xy_limit, 1.)),
431            bounds: Aab::ZERO,
432        };
433        self.view_frustum.compute_bounds();
434    }
435}
436
437/// Unit-of-measure/coordinate-system type for points/vectors in “eye space”,
438/// the space of camera-relative coordinates that are *not* perspective-projected.
439///
440/// +X is right, +Y is up, +Z is towards-the-viewer (right-handed coordinates).
441#[expect(clippy::exhaustive_enums)]
442#[derive(Debug, Eq, PartialEq)]
443pub enum Eye {}
444
445/// Unit-of-measure type for vectors representing the on-screen dimensions of a [`Viewport`],
446/// which may be different from the “physical” pixels of the image rendered to it.
447#[expect(clippy::exhaustive_enums)]
448#[derive(Debug, Eq, PartialEq)]
449pub enum NominalPixel {}
450
451/// Unit-of-measure type for vectors representing the width and height of an image.
452///
453/// Used in [`Viewport::framebuffer_size`].
454#[expect(clippy::exhaustive_enums)]
455#[derive(Debug, Eq, PartialEq)]
456pub enum ImagePixel {}
457
458/// Unit-of-measure type for points/vectors in “normalized device coordinates”.
459///
460/// In this coordinate system,
461/// screen-space <var>x</var> and <var>y</var> have the range -1 to 1;
462/// zero is the center of the screen;
463/// and <var>z</var> has the range 0 (nearest) to 1 (farthest),
464/// and is image depth rather than an equivalent third spatial axis.
465#[expect(clippy::exhaustive_enums)]
466#[derive(Debug, Eq, PartialEq)]
467pub enum Ndc {}
468
469/// Screen-space point in [normalized device coordinates](Ndc), with depth.
470pub type NdcPoint2 = Point2D<f64, Ndc>;
471/// Screen-space point in [normalized device coordinates](Ndc), with depth.
472pub type NdcPoint3 = Point3D<f64, Ndc>;
473
474/// Width and height of an image, framebuffer, or window, as measured in actual distinct
475/// image pixels.
476///
477/// For sizes that are in nominal, or “logical” pixel units that have become separated from
478/// actual image or display resolution, use `Size2D<T, NominalPixel>`; there is no type
479/// alias for that.
480pub type ImageSize = Size2D<u32, ImagePixel>;
481
482/// Viewport dimensions for rendering and UI layout with the correct resolution and
483/// aspect ratio.
484#[expect(clippy::exhaustive_structs)]
485#[derive(Clone, Copy, Debug, PartialEq)]
486#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
487pub struct Viewport {
488    /// Viewport dimensions to use for determining aspect ratio and interpreting
489    /// pointer events.
490    pub nominal_size: Size2D<FreeCoordinate, NominalPixel>,
491    /// Viewport dimensions to use for framebuffer configuration.
492    /// This aspect ratio may differ to represent non-square pixels.
493    pub framebuffer_size: ImageSize,
494}
495
496impl Viewport {
497    #![allow(clippy::cast_lossless, reason = "lossiness depends on size of usize")]
498
499    /// Construct a Viewport from a pixel count and a scale factor.
500    ///
501    /// The `nominal_size` will be the given `framebuffer_size` divided by the given
502    /// `scale_factor`.
503    pub fn with_scale(
504        scale_factor: f64,
505        framebuffer_size: impl Into<Size2D<u32, ImagePixel>>,
506    ) -> Self {
507        let framebuffer_size = framebuffer_size.into();
508        Self {
509            framebuffer_size,
510            nominal_size: framebuffer_size.to_f64().cast_unit() / scale_factor,
511        }
512    }
513
514    /// A meaningless but valid [`Viewport`] value for use in tests which require one
515    /// but do not care about its effects.
516    #[doc(hidden)]
517    pub const ARBITRARY: Viewport = Viewport {
518        nominal_size: Size2D::new(2.0, 2.0),
519        framebuffer_size: Size2D::new(2, 2),
520    };
521
522    /// Calculates the aspect ratio (width divided by height) of the `nominal_size` of this
523    /// viewport.
524    ///
525    /// If the result would naturally be infinite or undefined then it is reported as 1
526    /// instead. This is intended to aid in robust handling of degenerate viewports which
527    /// contain no pixels.
528    #[inline]
529    pub fn nominal_aspect_ratio(&self) -> FreeCoordinate {
530        let ratio = self.nominal_size.width / self.nominal_size.height;
531        if ratio.is_finite() { ratio } else { 1.0 }
532    }
533
534    /// Convert an *x* coordinate from the range `0..self.framebuffer_size.x` (upper exclusive)
535    /// to OpenGL normalized device coordinates, range -1 to 1 (at pixel centers).
536    #[inline]
537    pub fn normalize_fb_x(&self, x: usize) -> FreeCoordinate {
538        (x as FreeCoordinate + 0.5) / FreeCoordinate::from(self.framebuffer_size.width) * 2.0 - 1.0
539    }
540
541    /// Convert a *y* coordinate from the range `0..self.framebuffer_size.y` (upper exclusive)
542    /// to OpenGL normalized device coordinates, range -1 to 1 (at pixel centers) and flipped.
543    #[inline]
544    pub fn normalize_fb_y(&self, y: usize) -> FreeCoordinate {
545        -((y as FreeCoordinate + 0.5) / FreeCoordinate::from(self.framebuffer_size.height) * 2.0
546            - 1.0)
547    }
548
549    /// Convert an *x* coordinate from the range `0..=self.framebuffer_size.x` (inclusive)
550    /// to OpenGL normalized device coordinates, range -1 to 1 (at pixel *edges*).
551    #[inline]
552    pub fn normalize_fb_x_edge(&self, x: usize) -> FreeCoordinate {
553        (x as FreeCoordinate) / FreeCoordinate::from(self.framebuffer_size.width) * 2.0 - 1.0
554    }
555
556    /// Convert a *y* coordinate from the range `0..=self.framebuffer_size.y` (inclusive)
557    /// to OpenGL normalized device coordinates, range -1 to 1 (at pixel *edges*) and flipped.
558    #[inline]
559    pub fn normalize_fb_y_edge(&self, y: usize) -> FreeCoordinate {
560        -((y as FreeCoordinate) / FreeCoordinate::from(self.framebuffer_size.height) * 2.0 - 1.0)
561    }
562
563    /// Convert a point in the [`Self::nominal_size`] coordinate system to
564    /// to OpenGL normalized device coordinates, range -1 to 1 (at pixel centers) with Y flipped.
565    ///
566    /// TODO: Some windowing APIs providing float input might have different ideas of pixel centers.
567    #[inline]
568    pub fn normalize_nominal_point(&self, nominal_point: Point2D<f64, NominalPixel>) -> NdcPoint2 {
569        Point2D::new(
570            (nominal_point.x + 0.5) / self.nominal_size.width * 2.0 - 1.0,
571            -((nominal_point.y + 0.5) / self.nominal_size.height * 2.0 - 1.0),
572        )
573    }
574
575    /// Returns whether the viewport contains no physical pixels, that is,
576    /// whether either `framebuffer_size.x` or `framebuffer_size.y` is zero.
577    ///
578    /// If this returns `false`, then both `framebuffer_size.x` and `framebuffer_size.y` must be
579    /// positive.
580    ///
581    /// Ignores `self.nominal_size`.
582    pub fn is_empty(&self) -> bool {
583        self.framebuffer_size.width == 0 || self.framebuffer_size.height == 0
584    }
585
586    /// Computes the number of pixels in the framebuffer.
587    /// Returns [`None`] if that number does not fit in a [`usize`].
588    ///
589    /// Whenever [`Viewport::is_empty()`] returns `true`, this returns `Some(0)`.
590    pub fn pixel_count(&self) -> Option<usize> {
591        let w: usize = self.framebuffer_size.width.try_into().ok()?;
592        let h: usize = self.framebuffer_size.height.try_into().ok()?;
593        w.checked_mul(h)
594    }
595
596    // TODO: Maybe have a validate() that checks if the data is not fit for producing an
597    // invertible transform.
598}
599
600/// Calculate an “eye position” (camera position) to view the entire given `bounds`.
601///
602/// `direction` points in the direction the camera should be relative to the space.
603///
604/// TODO: This function does not yet consider the effects of field-of-view,
605/// and it will need additional parameters to do so.
606pub fn eye_for_look_at(bounds: GridAab, direction: FreeVector) -> FreePoint {
607    let mut space_radius: FreeCoordinate = 0.0;
608    for axis in Axis::ALL {
609        space_radius = space_radius.max(bounds.size()[axis].into());
610    }
611    bounds.center() + direction.normalize() * space_radius // TODO: allow for camera FoV
612}
613
614/// Look-at implementation broken out for testing
615fn look_at_y_up(eye: FreePoint, target: FreePoint) -> ViewTransform {
616    let look_direction = target - eye;
617    let yaw = Angle {
618        radians: look_direction.x.atan2(-look_direction.z),
619    };
620    let pitch = Angle {
621        radians: (-look_direction.y).atan2(look_direction.xz().length()),
622    };
623    ViewTransform {
624        rotation: Rotation3D::<_, Eye, Cube>::around_x(-pitch).then(&Rotation3D::around_y(-yaw)),
625        translation: eye.to_vector(),
626    }
627}
628
629/// A view frustum, represented by its corner points.
630/// This is an underconstrained representation, but one that is useful to precompute.
631#[derive(Clone, Copy, Debug, PartialEq)]
632struct FrustumPoints {
633    lbf: FreePoint,
634    rbf: FreePoint,
635    ltf: FreePoint,
636    rtf: FreePoint,
637    lbn: FreePoint,
638    rbn: FreePoint,
639    ltn: FreePoint,
640    rtn: FreePoint,
641    bounds: Aab,
642}
643
644impl Default for FrustumPoints {
645    fn default() -> Self {
646        Self {
647            lbf: Point3D::origin(),
648            rbf: Point3D::origin(),
649            ltf: Point3D::origin(),
650            rtf: Point3D::origin(),
651            lbn: Point3D::origin(),
652            rbn: Point3D::origin(),
653            ltn: Point3D::origin(),
654            rtn: Point3D::origin(),
655            bounds: Aab::new(0., 0., 0., 0., 0., 0.),
656        }
657    }
658}
659
660impl FrustumPoints {
661    fn iter(self) -> impl Iterator<Item = FreePoint> {
662        [
663            self.lbf, self.rbf, self.ltf, self.rtf, self.lbn, self.rbn, self.ltn, self.rtn,
664        ]
665        .into_iter()
666    }
667
668    fn compute_bounds(&mut self) {
669        let (xl, xh) = projected_range(self.iter(), vec3(1., 0., 0.));
670        let (yl, yh) = projected_range(self.iter(), vec3(0., 1., 0.));
671        let (zl, zh) = projected_range(self.iter(), vec3(0., 0., 1.));
672        self.bounds = Aab::from_lower_upper([xl, yl, zl], [xh, yh, zh]);
673    }
674}
675
676impl lines::Wireframe for FrustumPoints {
677    fn wireframe_points<E: Extend<[lines::Vertex; 2]>>(&self, output: &mut E) {
678        output.extend(
679            [
680                // far plane box
681                [self.lbf, self.rbf],
682                [self.rbf, self.rtf],
683                [self.rtf, self.ltf],
684                [self.ltf, self.lbf],
685                // near plane box
686                [self.lbn, self.rbn],
687                [self.rbn, self.rtn],
688                [self.rtn, self.ltn],
689                [self.ltn, self.lbn],
690                // far-near joining lines
691                [self.lbf, self.lbn],
692                [self.rbf, self.rbn],
693                [self.rtf, self.rtn],
694                [self.ltf, self.ltn],
695            ]
696            .into_iter()
697            .map(|line| line.map(lines::Vertex::from)),
698        );
699    }
700}
701
702/// Projects a set of points onto an axis and returns the least and greatest dot product
703/// with the axis vector.
704#[inline(always)]
705fn projected_range(
706    points: impl IntoIterator<Item = FreePoint>,
707    axis: FreeVector,
708) -> (FreeCoordinate, FreeCoordinate) {
709    points
710        .into_iter()
711        .map(|p| p.to_vector().dot(axis))
712        .minmax()
713        .into_option()
714        .unwrap()
715}