Skip to main content

rlx_core/render/
camera.rs

1//! The shared 3D camera (ADR-0257): an orbit camera that projects world points
2//! through a perspective lens, for every pipeline that draws a 3D primitive.
3//!
4//! Two halves, one function. `CAMERA_WGSL` is prepended to each 3D pipeline's
5//! shader and projects on the GPU; [`CameraView`] is the same matrix on the CPU,
6//! where a scene clips its primitives against the near plane and culls what
7//! lies outside the frustum before anything is uploaded. The two are held equal
8//! by a test that runs the WGSL `project()` in a compute pass and compares it
9//! with [`CameraView::clip`] on a fixed set of points.
10//!
11//! # The space
12//!
13//! The camera orbits the world origin. `yaw` turns it about the world's `+y`
14//! axis, `pitch` raises it above the `xz` plane, and `distance` is how far the
15//! eye sits from the origin. At `yaw = 0, pitch = 0` the eye is on `+z` looking
16//! down `-z`, with world `+x` to the right and `+y` up on screen.
17//!
18//! Clip space puts the view depth in `w`, so the perspective divide is a divide
19//! by the distance in front of the eye along the view axis. The `z` row is zero:
20//! no 3D pipeline has a depth attachment. Additive light needs no occlusion
21//! (ADR-0044), and a `solid` frame occludes by drawing its strokes far to near
22//! rather than by a depth test (ADR-0263).
23//!
24//! # Depth of field is per primitive, not per pixel
25//!
26//! [`Lens::coc`] is the thin-lens circle of confusion at a view depth,
27//! `aperture * |depth - focus| / depth`, in pixels and clamped to the tier's
28//! cap. A pipeline evaluates it at each endpoint of what it draws and widens the
29//! primitive by it, so one line can be sharp at the focal plane and soft at both
30//! ends (ADR-0257). The additive scenes this serves have no single depth per
31//! pixel for a post-process blur to read.
32//!
33//! # The framing controls keep their meaning
34//!
35//! The aspect is the **render target's**, handed in by the caller, never an
36//! internal grid's (ADR-0037). The engine-wide `zoom` divides the field of
37//! view, and `pan_x` / `pan_y` shift after the projection, in the same units
38//! the 2D [`ViewTransform`](crate::render::scenes::lines::ViewTransform) pans
39//! in: `pan_x` is divided by the aspect on the way to clip space, so one unit of
40//! pan moves the picture the same number of pixels on both axes.
41
42// Hot-path panic-denial pragma (the hygiene guard scans `render/`). A view is
43// built once per frame and every primitive is projected through it.
44#![deny(
45    clippy::unwrap_used,
46    clippy::expect_used,
47    clippy::indexing_slicing,
48    clippy::panic,
49    clippy::unreachable
50)]
51
52use crate::render::scenes::{
53    CapOverflow, OverflowContext, ParamGroup, ParamKind, ParamSpec, default_of,
54};
55
56/// The WGSL half: the `Camera` uniform's shape and `project()`, prepended to
57/// each 3D pipeline's own shader. It declares no binding.
58pub(crate) const CAMERA_WGSL: &str = include_str!("camera.wgsl");
59
60/// How close to the eye a primitive may reach before it is clipped, in world
61/// units. A point nearer than this projects through a divide by nearly zero.
62pub const NEAR: f32 = 0.05;
63
64/// The widest field of view the lens opens to, in radians, after `zoom` has
65/// divided it. Past about 170 degrees the tangent grows without bound and the
66/// picture is all edge.
67pub(crate) const MAX_FOV: f32 = 3.0;
68
69/// The narrowest, in radians. `zoom` divides the field of view, so a large
70/// `zoom` would otherwise reach a lens that magnifies without limit.
71pub(crate) const MIN_FOV: f32 = 0.01;
72
73/// The steepest `pitch` the orbit reaches, in radians, just short of straight
74/// up or down. At a quarter turn the view axis is parallel to world `+y` and
75/// the screen's right-hand direction is undefined.
76const MAX_PITCH: f32 = 1.55;
77
78/// An orbit camera, as a preset binds it (ADR-0257).
79#[derive(Debug, Clone, Copy, PartialEq)]
80pub struct Camera3d {
81    /// Turn about world `+y`, in radians. A preset writing `time * 0.05` here
82    /// orbits slowly.
83    pub yaw: f32,
84    /// Elevation above the `xz` plane, in radians; clamped short of a quarter
85    /// turn either way.
86    pub pitch: f32,
87    /// Eye to orbit target, in world units.
88    pub distance: f32,
89    /// The vertical field of view, in radians, before `zoom` divides it.
90    pub fov: f32,
91    /// Where the focal plane sits in the scene's volume: `0` at its nearest
92    /// extent, `1` at its farthest. Normalized so an author never deals in world
93    /// units; [`CameraView::focal_depth`] resolves it against the volume.
94    pub focus: f32,
95    /// The lens's aperture, as the circle of confusion in pixels a point would
96    /// have at infinite depth. `0` is a pinhole and blurs nothing.
97    pub aperture: f32,
98}
99
100impl Camera3d {
101    /// The view this camera gives onto a render target of `aspect` (width over
102    /// height), under the engine-wide `zoom` and `pan` (ADR-0018).
103    ///
104    /// Every input is taken as a raw bound value and made safe here, once: a
105    /// non-finite or out-of-range yaw, pitch, distance, field of view, zoom or
106    /// aspect resolves to the nearest usable camera rather than a NaN matrix.
107    pub fn view(&self, aspect: f32, zoom: f32, pan: [f32; 2]) -> CameraView {
108        let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback };
109        let yaw = finite(self.yaw, 0.0);
110        let pitch = finite(self.pitch, 0.0).clamp(-MAX_PITCH, MAX_PITCH);
111        let distance = finite(self.distance, 1.0).max(NEAR * 2.0);
112        let zoom = finite(zoom, 1.0).max(1e-3);
113        let fov = (finite(self.fov, 1.0) / zoom).clamp(MIN_FOV, MAX_FOV);
114        let aspect = finite(aspect, 1.0).max(0.1);
115        let [pan_x, pan_y] = [finite(pan[0], 0.0), finite(pan[1], 0.0)];
116
117        let (sy, cy) = yaw.sin_cos();
118        let (sp, cp) = pitch.sin_cos();
119        let eye = [distance * cp * sy, distance * sp, distance * cp * cy];
120        // The view axis, from the eye toward the origin: `-eye / distance`,
121        // written from the angles so it is unit length without a square root.
122        let forward = [-cp * sy, -sp, -cp * cy];
123        // Screen right: `forward x up`, which for up = +y is the horizontal
124        // (cos yaw, 0, -sin yaw) whatever the pitch.
125        let right = [cy, 0.0, -sy];
126        // Screen up: `right x forward`, unit because the two are orthonormal.
127        let up = cross(right, forward);
128
129        let s = 1.0 / (0.5 * fov).tan();
130        let sx = s / aspect;
131        let (re, ue, fe) = (dot(right, eye), dot(up, eye), dot(forward, eye));
132        // Rows of the world-to-clip matrix acting on (p, 1):
133        //   clip.x = (right . (p - eye)) * s / aspect + (pan_x / aspect) * depth
134        //   clip.y = (up    . (p - eye)) * s          +  pan_y           * depth
135        //   clip.z = 0
136        //   clip.w = forward . (p - eye)                            -- the depth
137        // so the divide by `w` lands the pan as a constant shift in NDC.
138        let px = pan_x / aspect;
139        let row0 = [
140            right[0] * sx + forward[0] * px,
141            right[1] * sx + forward[1] * px,
142            right[2] * sx + forward[2] * px,
143            -re * sx - fe * px,
144        ];
145        let row1 = [
146            up[0] * s + forward[0] * pan_y,
147            up[1] * s + forward[1] * pan_y,
148            up[2] * s + forward[2] * pan_y,
149            -ue * s - fe * pan_y,
150        ];
151        let row3 = [forward[0], forward[1], forward[2], -fe];
152        let rows = [row0, row1, [0.0; 4], row3];
153        // Column-major, as WGSL's `mat4x4` is laid out.
154        let mut view_proj = [[0.0f32; 4]; 4];
155        for (c, column) in view_proj.iter_mut().enumerate() {
156            for (r, cell) in column.iter_mut().enumerate() {
157                *cell = rows
158                    .get(r)
159                    .and_then(|row| row.get(c))
160                    .copied()
161                    .unwrap_or(0.0);
162            }
163        }
164        CameraView {
165            view_proj,
166            aspect,
167            distance,
168        }
169    }
170}
171
172/// One frame's projection: the matrix `CAMERA_WGSL` multiplies by, and what
173/// the CPU needs to clip against it.
174#[derive(Debug, Clone, Copy, PartialEq)]
175pub struct CameraView {
176    /// World to clip, column-major.
177    pub view_proj: [[f32; 4]; 4],
178    /// The aspect the view was built for, after its clamp.
179    pub aspect: f32,
180    /// Eye to orbit target, after its clamp.
181    pub distance: f32,
182}
183
184impl CameraView {
185    /// `p` in clip space. **The CPU mirror of `project()` in `CAMERA_WGSL`**,
186    /// the same four dot products in the same order: a column-major matrix times
187    /// `(p, 1)`.
188    pub fn clip(&self, p: [f32; 3]) -> [f32; 4] {
189        let [c0, c1, c2, c3] = self.view_proj;
190        let mut out = [0.0f32; 4];
191        for (r, cell) in out.iter_mut().enumerate() {
192            let at = |c: &[f32; 4]| c.get(r).copied().unwrap_or(0.0);
193            *cell = at(&c0) * p[0] + at(&c1) * p[1] + at(&c2) * p[2] + at(&c3);
194        }
195        out
196    }
197
198    /// The view depth of the focal plane, for a volume of bounding radius
199    /// `radius` about the orbit target and a normalized `focus`: `0` the
200    /// volume's nearest extent, `1` its farthest. Never nearer than [`NEAR`].
201    ///
202    /// Written as `near + focus * (far - near)` and not as
203    /// `near + 2 * focus * radius`: the two differ in the last bit, and a
204    /// reference depth that moves by a bit moves every stroke width with it.
205    pub fn focal_depth(&self, focus: f32, radius: f32) -> f32 {
206        let near = self.distance - radius;
207        let far = self.distance + radius;
208        let focus = if focus.is_finite() { focus } else { 0.5 };
209        (near + focus * (far - near)).max(NEAR)
210    }
211
212    /// The view depth of `p`: its distance in front of the eye along the view
213    /// axis. Negative behind the eye.
214    pub fn depth(&self, p: [f32; 3]) -> f32 {
215        self.clip(p)[3]
216    }
217
218    /// The segment `a -> b` with any part nearer than [`NEAR`] cut away, or
219    /// `None` when all of it is.
220    ///
221    /// **Clipped, not culled**: a segment crossing the near plane keeps the
222    /// part in front of it, so an edge the camera orbits through shortens
223    /// instead of blinking out. The cut is taken in world space, where depth is
224    /// linear along the segment, so the new endpoint lies exactly on the plane.
225    pub fn clip_near(&self, a: [f32; 3], b: [f32; 3]) -> Option<([f32; 3], [f32; 3])> {
226        let (da, db) = (self.depth(a), self.depth(b));
227        match (da >= NEAR, db >= NEAR) {
228            (true, true) => Some((a, b)),
229            (false, false) => None,
230            (a_in, _) => {
231                let t = (NEAR - da) / (db - da);
232                let cut = lerp3(a, b, t);
233                Some(if a_in { (a, cut) } else { (cut, b) })
234            }
235        }
236    }
237
238    /// Whether the segment `a -> b`, both ends at or past [`NEAR`], lies wholly
239    /// outside one edge of the frame, allowing `margin` in normalized device
240    /// units for the stroke's own width.
241    ///
242    /// Conservative on purpose: a segment both of whose ends are outside
243    /// **different** edges may still cross the frame, and is kept.
244    pub fn outside(&self, a: [f32; 3], b: [f32; 3], margin: f32) -> bool {
245        let ndc = |p: [f32; 3]| {
246            let c = self.clip(p);
247            [c[0] / c[3], c[1] / c[3]]
248        };
249        let ([ax, ay], [bx, by]) = (ndc(a), ndc(b));
250        let edge = 1.0 + margin;
251        (ax > edge && bx > edge)
252            || (ax < -edge && bx < -edge)
253            || (ay > edge && by > edge)
254            || (ay < -edge && by < -edge)
255    }
256}
257
258/// A lens: the CPU half of `coc()` in `CAMERA_WGSL`, with its inputs made
259/// safe once.
260#[derive(Debug, Clone, Copy, PartialEq)]
261pub struct Lens {
262    /// Aperture in pixels, finite and non-negative.
263    pub aperture: f32,
264    /// The focal plane's view depth.
265    pub focal_depth: f32,
266    /// The largest circle of confusion drawn, in pixels — the tier's cap.
267    pub max_coc: f32,
268}
269
270impl Lens {
271    /// A lens with `aperture` and `max_coc` held finite and non-negative.
272    pub fn new(aperture: f32, focal_depth: f32, max_coc: f32) -> Self {
273        let safe = |v: f32| if v.is_finite() { v.max(0.0) } else { 0.0 };
274        Self {
275            aperture: safe(aperture),
276            focal_depth: focal_depth.max(NEAR),
277            max_coc: safe(max_coc),
278        }
279    }
280
281    /// The circle of confusion at view depth `depth`, as a radius in pixels.
282    /// **Mirrors `coc()` in `CAMERA_WGSL`**, term for term.
283    pub fn coc(&self, depth: f32) -> f32 {
284        let blur = self.aperture * (depth - self.focal_depth).abs() / depth;
285        blur.clamp(0.0, self.max_coc)
286    }
287}
288
289/// The `Camera` uniform `CAMERA_WGSL` declares, as it is uploaded.
290///
291/// **Field order is the WGSL struct's**, and both are `vec4`-aligned, so the
292/// Rust layout is the uniform layout with no padding to keep in step.
293#[repr(C)]
294#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
295pub struct CameraUniform {
296    /// [`CameraView::view_proj`].
297    pub view_proj: [[f32; 4]; 4],
298    /// `[target width px, target height px, reference depth, volume's nearest
299    /// view depth]`.
300    pub viewport: [f32; 4],
301    /// `[aperture px, focal depth, max circle of confusion px, volume's depth
302    /// span]`.
303    pub lens: [f32; 4],
304}
305
306impl CameraUniform {
307    /// The uniform for `view` on a `width` x `height` target through `lens`,
308    /// with pixel widths stated at the lens's focal plane. The volume is
309    /// unset, so `volume_depth()` in `CAMERA_WGSL` reads `0` everywhere until
310    /// [`with_volume`](Self::with_volume) names one.
311    pub fn new(view: &CameraView, width: u32, height: u32, lens: Lens) -> Self {
312        Self {
313            view_proj: view.view_proj,
314            viewport: [
315                width.max(1) as f32,
316                height.max(1) as f32,
317                lens.focal_depth,
318                0.0,
319            ],
320            lens: [lens.aperture, lens.focal_depth, lens.max_coc, 0.0],
321        }
322    }
323
324    /// This uniform with the scene's volume spanning view depths
325    /// `near_extent` to `near_extent + span`: the 0-nearest, 1-farthest scale
326    /// `focus` is stated on, which `fog` reads (ADR-0263).
327    pub fn with_volume(mut self, near_extent: f32, span: f32) -> Self {
328        self.viewport[3] = near_extent;
329        self.lens[3] = span;
330        self
331    }
332}
333
334/// How far past the frame a primitive may lie before it is culled, in
335/// normalized device units — room for the stroke's own width, so a line whose
336/// centre has left the frame does not take its visible edge with it.
337pub(crate) const CULL_MARGIN: f32 = 0.05;
338
339/// `yaw`, shared: the orbit's turn about the volume.
340pub(crate) const YAW: ParamSpec = ParamSpec {
341    name: "yaw",
342    default: 0.0,
343    range: Some([-std::f32::consts::PI, std::f32::consts::PI]),
344    doc: "Turns the camera around the scene's volume, in radians; bind it to a slow clock to orbit.",
345    kind: ParamKind::Modal,
346    group: ParamGroup::Motion,
347    main: false,
348};
349
350/// `pitch`, shared: the orbit's elevation.
351pub(crate) const PITCH: ParamSpec = ParamSpec {
352    name: "pitch",
353    default: 0.25,
354    range: Some([-MAX_PITCH, MAX_PITCH]),
355    doc: "Raises the camera above the scene's volume, in radians; negative looks up from below.",
356    kind: ParamKind::Modal,
357    group: ParamGroup::Motion,
358    main: false,
359};
360
361/// `distance`, shared: eye to orbit target.
362pub(crate) const DISTANCE: ParamSpec = ParamSpec {
363    name: "distance",
364    default: 3.5,
365    range: Some([1.5, 8.0]),
366    doc: "How far the camera sits from the centre of the scene's volume; nearer exaggerates the perspective.",
367    kind: ParamKind::Modal,
368    group: ParamGroup::Motion,
369    main: false,
370};
371
372/// `fov`, shared: the vertical field of view.
373pub(crate) const FOV: ParamSpec = ParamSpec {
374    name: "fov",
375    default: 0.8,
376    range: Some([0.2, 2.0]),
377    doc: "The camera's vertical field of view in radians; zoom divides it.",
378    kind: ParamKind::Modal,
379    group: ParamGroup::Motion,
380    main: false,
381};
382
383/// `focus`, shared: the focal plane, normalized across the volume's depth.
384pub(crate) const FOCUS: ParamSpec = ParamSpec {
385    name: "focus",
386    default: 0.5,
387    range: Some([0.0, 1.0]),
388    doc: "Where the focal plane sits in the depth of the scene's volume: 0 at its nearest point, 1 at its farthest.",
389    kind: ParamKind::Modal,
390    group: ParamGroup::Light,
391    main: true,
392};
393
394/// `aperture`, shared: the far field's circle of confusion, in pixels. Its
395/// range tops out at the highest tier's cap; a lower tier draws at its own cap
396/// and announces it.
397pub(crate) const APERTURE: ParamSpec = ParamSpec {
398    name: "aperture",
399    default: 0.0,
400    range: Some([0.0, crate::render::TierConfig::RICH.max_coc_px as f32]),
401    doc: "The blur of the far background, in pixels; strokes nearer than the focal plane blur more, up to the tier's cap. 0 keeps every stroke sharp, and wider costs fill.",
402    kind: ParamKind::Modal,
403    group: ParamGroup::Light,
404    main: true,
405};
406
407/// `fog`, shared: light falling toward black with depth across the volume
408/// (ADR-0263). `0` is off, and every stroke keeps the light it had.
409pub(crate) const FOG: ParamSpec = ParamSpec {
410    name: "fog",
411    default: 0.0,
412    range: Some([0.0, 1.0]),
413    doc: "Fades strokes toward black with depth: at 1 the farthest point of the scene's volume is black and the nearest keeps its light. 0 is off.",
414    kind: ParamKind::Modal,
415    group: ParamGroup::Light,
416    main: false,
417};
418
419/// `solid`, shared: whether the frame's strokes occlude (ADR-0263). Read per
420/// frame, and solid at [`SOLID_AT`] and above; `0` is the additive glow.
421pub(crate) const SOLID: ParamSpec = ParamSpec {
422    name: "solid",
423    default: 0.0,
424    range: Some([0.0, 1.0]),
425    doc: "1 paints near strokes over far ones, so the scene reads as an object and crossings stop brightening; 0 is the additive glow. Solid sorts every stroke by depth each frame.",
426    kind: ParamKind::Modal,
427    group: ParamGroup::Light,
428    main: false,
429};
430
431/// The `solid` value at and above which a frame is drawn solid: the switch is
432/// two-valued, and the threshold sits between its two values so a bound
433/// `select(...)` or an eased value flips once, half-way.
434pub(crate) const SOLID_AT: f32 = 0.5;
435
436/// The camera params a 3D system binds, as one block (ADR-0258), in the
437/// shape of [`PanParams`](crate::render::scenes::common::PanParams): the
438/// `ParamSpec`s declared once here and spliced into each system's `PARAMS`,
439/// and a setter each scene's `set_param` delegates to.
440///
441/// **Nothing here clamps.** [`Camera3d::view`] and [`Lens::new`] make every
442/// raw bound value safe where it is read.
443#[derive(Debug, Clone, Copy, PartialEq)]
444pub(crate) struct CameraParams {
445    /// `yaw`, in radians.
446    pub yaw: f32,
447    /// `pitch`, in radians.
448    pub pitch: f32,
449    /// `distance`, in world units.
450    pub distance: f32,
451    /// `fov`, in radians.
452    pub fov: f32,
453    /// `focus`, `0` the volume's nearest extent and `1` its farthest.
454    pub focus: f32,
455    /// `aperture`, in pixels.
456    pub aperture: f32,
457    /// `fog`, `0` off and `1` the farthest extent black.
458    pub fog: f32,
459    /// `solid`, solid at [`SOLID_AT`] and above.
460    pub solid: f32,
461}
462
463impl Default for CameraParams {
464    fn default() -> Self {
465        let rest = |name| default_of(&Self::SPECS, name);
466        Self {
467            yaw: rest("yaw"),
468            pitch: rest("pitch"),
469            distance: rest("distance"),
470            fov: rest("fov"),
471            focus: rest("focus"),
472            aperture: rest("aperture"),
473            fog: rest("fog"),
474            solid: rest("solid"),
475        }
476    }
477}
478
479/// One frame of the shared lens logic, for a scene to clip, cull and draw by.
480#[derive(Debug, Clone, Copy, PartialEq)]
481pub(crate) struct CameraFrame {
482    /// The projection, for clipping and culling on the CPU.
483    pub view: CameraView,
484    /// The uniform every 3D pipeline binds.
485    pub uniform: CameraUniform,
486    /// The cull margin, in normalized device units, widened by the blur cap
487    /// when the lens blurs at all.
488    pub margin: f32,
489    /// The view depth of the volume's nearest extent.
490    pub near_extent: f32,
491    /// The view depth from the volume's nearest extent to its farthest.
492    pub span: f32,
493    /// The blur clamp to announce when `aperture` is past the tier's cap.
494    pub blur: Option<CapOverflow>,
495    /// `fog`, made safe: finite and in `[0, 1]`.
496    pub fog: f32,
497    /// Whether this frame is drawn solid: near strokes painted over far ones
498    /// in a back-to-front order rather than added (ADR-0263).
499    pub solid: bool,
500}
501
502impl CameraFrame {
503    /// Where view depth `depth` lies across the volume: `0` at its nearest
504    /// extent, `1` at its farthest, clamped. **Mirrors `volume_depth()` in
505    /// `CAMERA_WGSL`**, so a scene that colours or fogs on the CPU reads the
506    /// scale the `seg3d` pipeline fogs on.
507    pub fn volume_depth(&self, depth: f32) -> f32 {
508        if self.span > 0.0 {
509            ((depth - self.near_extent) / self.span).clamp(0.0, 1.0)
510        } else {
511            0.0
512        }
513    }
514
515    /// The share of its light a point at view depth `depth` keeps under this
516    /// frame's `fog`: `1 - fog * volume_depth`. **Mirrors `fog_light()` in
517    /// `CAMERA_WGSL`**, and is exactly `1.0` at `fog = 0`.
518    pub fn fog_light(&self, depth: f32) -> f32 {
519        (1.0 - self.fog * self.volume_depth(depth)).clamp(0.0, 1.0)
520    }
521}
522
523impl CameraParams {
524    /// The specs, in the order a system splices them.
525    pub(crate) const SPECS: [ParamSpec; 8] =
526        [YAW, PITCH, DISTANCE, FOV, FOCUS, APERTURE, FOG, SOLID];
527
528    /// Store `value` if `name` is one of the camera's, and say whether it
529    /// was.
530    pub(crate) fn set(&mut self, name: &str, value: f32) -> bool {
531        match name {
532            "yaw" => self.yaw = value,
533            "pitch" => self.pitch = value,
534            "distance" => self.distance = value,
535            "fov" => self.fov = value,
536            "focus" => self.focus = value,
537            "aperture" => self.aperture = value,
538            "fog" => self.fog = value,
539            "solid" => self.solid = value,
540            _ => return false,
541        }
542        true
543    }
544
545    /// Back to the declared defaults.
546    pub(crate) fn reset(&mut self) {
547        *self = Self::default();
548    }
549
550    /// The orbit camera these params describe.
551    pub(crate) fn camera(&self) -> Camera3d {
552        Camera3d {
553            yaw: self.yaw,
554            pitch: self.pitch,
555            distance: self.distance,
556            fov: self.fov,
557            focus: self.focus,
558            aperture: self.aperture,
559        }
560    }
561
562    /// This frame's view, lens and cull margin onto a `target` of pixels at
563    /// `aspect` (the render target's, ADR-0037), for a volume of bounding
564    /// `radius` about the orbit target, with blur held to the tier's `max_coc`.
565    ///
566    /// The aperture is the far field's blur, the one the lens approaches and
567    /// never passes behind focus; past the cap that is what draws shallower,
568    /// and [`CameraFrame::blur`] carries the clamp. The near side is unbounded
569    /// and saturates at the cap by design, so it is never judged (ADR-0257).
570    pub(crate) fn frame(
571        &self,
572        aspect: f32,
573        zoom: f32,
574        pan: [f32; 2],
575        target: (u32, u32),
576        radius: f32,
577        max_coc: f32,
578    ) -> CameraFrame {
579        let camera = self.camera();
580        let view = camera.view(aspect, zoom, pan);
581        let near_extent = view.distance - radius;
582        let far_extent = view.distance + radius;
583        let lens = Lens::new(
584            camera.aperture,
585            view.focal_depth(camera.focus, radius),
586            max_coc,
587        );
588        let blur = (lens.aperture > lens.max_coc).then(|| CapOverflow {
589            dropped: 0,
590            context: OverflowContext::Blur(lens.aperture.ceil() as u32),
591            cap: lens.max_coc as usize,
592        });
593        // A blurred stroke reaches past its centreline by up to the cap, so
594        // the cull keeps that much more of the frame's surround.
595        let margin = if lens.aperture > 0.0 {
596            CULL_MARGIN + 2.0 * lens.max_coc / target.1.max(1) as f32
597        } else {
598            CULL_MARGIN
599        };
600        let span = far_extent - near_extent;
601        let fog = if self.fog.is_finite() {
602            self.fog.clamp(0.0, 1.0)
603        } else {
604            0.0
605        };
606        CameraFrame {
607            view,
608            uniform: CameraUniform::new(&view, target.0, target.1, lens)
609                .with_volume(near_extent, span),
610            margin,
611            near_extent,
612            span,
613            blur,
614            fog,
615            // `NaN >= 0.5` is false, so a non-finite binding draws the glow.
616            solid: self.solid >= SOLID_AT,
617        }
618    }
619}
620
621fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
622    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
623}
624
625fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
626    [
627        a[1] * b[2] - a[2] * b[1],
628        a[2] * b[0] - a[0] * b[2],
629        a[0] * b[1] - a[1] * b[0],
630    ]
631}
632
633fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
634    [
635        a[0] + (b[0] - a[0]) * t,
636        a[1] + (b[1] - a[1]) * t,
637        a[2] + (b[2] - a[2]) * t,
638    ]
639}
640
641#[cfg(test)]
642mod tests;