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}