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, because additive light needs no
21//! occlusion (ADR-0044).
22//!
23//! # Depth of field is per primitive, not per pixel
24//!
25//! [`Lens::coc`] is the thin-lens circle of confusion at a view depth,
26//! `aperture * |depth - focus| / depth`, in pixels and clamped to the tier's
27//! cap. A pipeline evaluates it at each endpoint of what it draws and widens the
28//! primitive by it, so one line can be sharp at the focal plane and soft at both
29//! ends (ADR-0257). The additive scenes this serves have no single depth per
30//! pixel for a post-process blur to read.
31//!
32//! # The framing controls keep their meaning
33//!
34//! The aspect is the **render target's**, handed in by the caller, never an
35//! internal grid's (ADR-0037). The engine-wide `zoom` divides the field of
36//! view, and `pan_x` / `pan_y` shift after the projection, in the same units
37//! the 2D [`ViewTransform`](crate::render::scenes::lines::ViewTransform) pans
38//! in: `pan_x` is divided by the aspect on the way to clip space, so one unit of
39//! pan moves the picture the same number of pixels on both axes.
40
41// Hot-path panic-denial pragma (the hygiene guard scans `render/`). A view is
42// built once per frame and every primitive is projected through it.
43#![deny(
44    clippy::unwrap_used,
45    clippy::expect_used,
46    clippy::indexing_slicing,
47    clippy::panic,
48    clippy::unreachable
49)]
50
51/// The WGSL half: the `Camera` uniform's shape and `project()`, prepended to
52/// each 3D pipeline's own shader. It declares no binding.
53pub(crate) const CAMERA_WGSL: &str = include_str!("camera.wgsl");
54
55/// How close to the eye a primitive may reach before it is clipped, in world
56/// units. A point nearer than this projects through a divide by nearly zero.
57pub const NEAR: f32 = 0.05;
58
59/// The widest field of view the lens opens to, in radians, after `zoom` has
60/// divided it. Past about 170 degrees the tangent grows without bound and the
61/// picture is all edge.
62const MAX_FOV: f32 = 3.0;
63
64/// The narrowest, in radians. `zoom` divides the field of view, so a large
65/// `zoom` would otherwise reach a lens that magnifies without limit.
66const MIN_FOV: f32 = 0.01;
67
68/// The steepest `pitch` the orbit reaches, in radians, just short of straight
69/// up or down. At a quarter turn the view axis is parallel to world `+y` and
70/// the screen's right-hand direction is undefined.
71const MAX_PITCH: f32 = 1.55;
72
73/// An orbit camera, as a preset binds it (ADR-0257).
74#[derive(Debug, Clone, Copy, PartialEq)]
75pub struct Camera3d {
76    /// Turn about world `+y`, in radians. A preset writing `time * 0.05` here
77    /// orbits slowly.
78    pub yaw: f32,
79    /// Elevation above the `xz` plane, in radians; clamped short of a quarter
80    /// turn either way.
81    pub pitch: f32,
82    /// Eye to orbit target, in world units.
83    pub distance: f32,
84    /// The vertical field of view, in radians, before `zoom` divides it.
85    pub fov: f32,
86    /// Where the focal plane sits in the scene's volume: `0` at its nearest
87    /// extent, `1` at its farthest. Normalized so an author never deals in world
88    /// units; [`CameraView::focal_depth`] resolves it against the volume.
89    pub focus: f32,
90    /// The lens's aperture, as the circle of confusion in pixels a point would
91    /// have at infinite depth. `0` is a pinhole and blurs nothing.
92    pub aperture: f32,
93}
94
95impl Camera3d {
96    /// The view this camera gives onto a render target of `aspect` (width over
97    /// height), under the engine-wide `zoom` and `pan` (ADR-0018).
98    ///
99    /// Every input is taken as a raw bound value and made safe here, once: a
100    /// non-finite or out-of-range yaw, pitch, distance, field of view, zoom or
101    /// aspect resolves to the nearest usable camera rather than a NaN matrix.
102    pub fn view(&self, aspect: f32, zoom: f32, pan: [f32; 2]) -> CameraView {
103        let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback };
104        let yaw = finite(self.yaw, 0.0);
105        let pitch = finite(self.pitch, 0.0).clamp(-MAX_PITCH, MAX_PITCH);
106        let distance = finite(self.distance, 1.0).max(NEAR * 2.0);
107        let zoom = finite(zoom, 1.0).max(1e-3);
108        let fov = (finite(self.fov, 1.0) / zoom).clamp(MIN_FOV, MAX_FOV);
109        let aspect = finite(aspect, 1.0).max(0.1);
110        let [pan_x, pan_y] = [finite(pan[0], 0.0), finite(pan[1], 0.0)];
111
112        let (sy, cy) = yaw.sin_cos();
113        let (sp, cp) = pitch.sin_cos();
114        let eye = [distance * cp * sy, distance * sp, distance * cp * cy];
115        // The view axis, from the eye toward the origin: `-eye / distance`,
116        // written from the angles so it is unit length without a square root.
117        let forward = [-cp * sy, -sp, -cp * cy];
118        // Screen right: `forward x up`, which for up = +y is the horizontal
119        // (cos yaw, 0, -sin yaw) whatever the pitch.
120        let right = [cy, 0.0, -sy];
121        // Screen up: `right x forward`, unit because the two are orthonormal.
122        let up = cross(right, forward);
123
124        let s = 1.0 / (0.5 * fov).tan();
125        let sx = s / aspect;
126        let (re, ue, fe) = (dot(right, eye), dot(up, eye), dot(forward, eye));
127        // Rows of the world-to-clip matrix acting on (p, 1):
128        //   clip.x = (right . (p - eye)) * s / aspect + (pan_x / aspect) * depth
129        //   clip.y = (up    . (p - eye)) * s          +  pan_y           * depth
130        //   clip.z = 0
131        //   clip.w = forward . (p - eye)                            -- the depth
132        // so the divide by `w` lands the pan as a constant shift in NDC.
133        let px = pan_x / aspect;
134        let row0 = [
135            right[0] * sx + forward[0] * px,
136            right[1] * sx + forward[1] * px,
137            right[2] * sx + forward[2] * px,
138            -re * sx - fe * px,
139        ];
140        let row1 = [
141            up[0] * s + forward[0] * pan_y,
142            up[1] * s + forward[1] * pan_y,
143            up[2] * s + forward[2] * pan_y,
144            -ue * s - fe * pan_y,
145        ];
146        let row3 = [forward[0], forward[1], forward[2], -fe];
147        let rows = [row0, row1, [0.0; 4], row3];
148        // Column-major, as WGSL's `mat4x4` is laid out.
149        let mut view_proj = [[0.0f32; 4]; 4];
150        for (c, column) in view_proj.iter_mut().enumerate() {
151            for (r, cell) in column.iter_mut().enumerate() {
152                *cell = rows
153                    .get(r)
154                    .and_then(|row| row.get(c))
155                    .copied()
156                    .unwrap_or(0.0);
157            }
158        }
159        CameraView {
160            view_proj,
161            aspect,
162            distance,
163        }
164    }
165}
166
167/// One frame's projection: the matrix `CAMERA_WGSL` multiplies by, and what
168/// the CPU needs to clip against it.
169#[derive(Debug, Clone, Copy, PartialEq)]
170pub struct CameraView {
171    /// World to clip, column-major.
172    pub view_proj: [[f32; 4]; 4],
173    /// The aspect the view was built for, after its clamp.
174    pub aspect: f32,
175    /// Eye to orbit target, after its clamp.
176    pub distance: f32,
177}
178
179impl CameraView {
180    /// `p` in clip space. **The CPU mirror of `project()` in `CAMERA_WGSL`**,
181    /// the same four dot products in the same order: a column-major matrix times
182    /// `(p, 1)`.
183    pub fn clip(&self, p: [f32; 3]) -> [f32; 4] {
184        let [c0, c1, c2, c3] = self.view_proj;
185        let mut out = [0.0f32; 4];
186        for (r, cell) in out.iter_mut().enumerate() {
187            let at = |c: &[f32; 4]| c.get(r).copied().unwrap_or(0.0);
188            *cell = at(&c0) * p[0] + at(&c1) * p[1] + at(&c2) * p[2] + at(&c3);
189        }
190        out
191    }
192
193    /// The view depth of the focal plane, for a volume of bounding radius
194    /// `radius` about the orbit target and a normalized `focus`: `0` the
195    /// volume's nearest extent, `1` its farthest. Never nearer than [`NEAR`].
196    ///
197    /// Written as `near + focus * (far - near)` and not as
198    /// `near + 2 * focus * radius`: the two differ in the last bit, and a
199    /// reference depth that moves by a bit moves every stroke width with it.
200    pub fn focal_depth(&self, focus: f32, radius: f32) -> f32 {
201        let near = self.distance - radius;
202        let far = self.distance + radius;
203        let focus = if focus.is_finite() { focus } else { 0.5 };
204        (near + focus * (far - near)).max(NEAR)
205    }
206
207    /// The view depth of `p`: its distance in front of the eye along the view
208    /// axis. Negative behind the eye.
209    pub fn depth(&self, p: [f32; 3]) -> f32 {
210        self.clip(p)[3]
211    }
212
213    /// The segment `a -> b` with any part nearer than [`NEAR`] cut away, or
214    /// `None` when all of it is.
215    ///
216    /// **Clipped, not culled**: a segment crossing the near plane keeps the
217    /// part in front of it, so an edge the camera orbits through shortens
218    /// instead of blinking out. The cut is taken in world space, where depth is
219    /// linear along the segment, so the new endpoint lies exactly on the plane.
220    pub fn clip_near(&self, a: [f32; 3], b: [f32; 3]) -> Option<([f32; 3], [f32; 3])> {
221        let (da, db) = (self.depth(a), self.depth(b));
222        match (da >= NEAR, db >= NEAR) {
223            (true, true) => Some((a, b)),
224            (false, false) => None,
225            (a_in, _) => {
226                let t = (NEAR - da) / (db - da);
227                let cut = lerp3(a, b, t);
228                Some(if a_in { (a, cut) } else { (cut, b) })
229            }
230        }
231    }
232
233    /// Whether the segment `a -> b`, both ends at or past [`NEAR`], lies wholly
234    /// outside one edge of the frame, allowing `margin` in normalized device
235    /// units for the stroke's own width.
236    ///
237    /// Conservative on purpose: a segment both of whose ends are outside
238    /// **different** edges may still cross the frame, and is kept.
239    pub fn outside(&self, a: [f32; 3], b: [f32; 3], margin: f32) -> bool {
240        let ndc = |p: [f32; 3]| {
241            let c = self.clip(p);
242            [c[0] / c[3], c[1] / c[3]]
243        };
244        let ([ax, ay], [bx, by]) = (ndc(a), ndc(b));
245        let edge = 1.0 + margin;
246        (ax > edge && bx > edge)
247            || (ax < -edge && bx < -edge)
248            || (ay > edge && by > edge)
249            || (ay < -edge && by < -edge)
250    }
251}
252
253/// A lens: the CPU half of `coc()` in `CAMERA_WGSL`, with its inputs made
254/// safe once.
255#[derive(Debug, Clone, Copy, PartialEq)]
256pub struct Lens {
257    /// Aperture in pixels, finite and non-negative.
258    pub aperture: f32,
259    /// The focal plane's view depth.
260    pub focal_depth: f32,
261    /// The largest circle of confusion drawn, in pixels — the tier's cap.
262    pub max_coc: f32,
263}
264
265impl Lens {
266    /// A lens with `aperture` and `max_coc` held finite and non-negative.
267    pub fn new(aperture: f32, focal_depth: f32, max_coc: f32) -> Self {
268        let safe = |v: f32| if v.is_finite() { v.max(0.0) } else { 0.0 };
269        Self {
270            aperture: safe(aperture),
271            focal_depth: focal_depth.max(NEAR),
272            max_coc: safe(max_coc),
273        }
274    }
275
276    /// The circle of confusion at view depth `depth`, as a radius in pixels.
277    /// **Mirrors `coc()` in `CAMERA_WGSL`**, term for term.
278    pub fn coc(&self, depth: f32) -> f32 {
279        let blur = self.aperture * (depth - self.focal_depth).abs() / depth;
280        blur.clamp(0.0, self.max_coc)
281    }
282}
283
284/// The `Camera` uniform `CAMERA_WGSL` declares, as it is uploaded.
285///
286/// **Field order is the WGSL struct's**, and both are `vec4`-aligned, so the
287/// Rust layout is the uniform layout with no padding to keep in step.
288#[repr(C)]
289#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
290pub struct CameraUniform {
291    /// [`CameraView::view_proj`].
292    pub view_proj: [[f32; 4]; 4],
293    /// `[target width px, target height px, reference depth, unused]`.
294    pub viewport: [f32; 4],
295    /// `[aperture px, focal depth, max circle of confusion px, unused]`.
296    pub lens: [f32; 4],
297}
298
299impl CameraUniform {
300    /// The uniform for `view` on a `width` x `height` target through `lens`,
301    /// with pixel widths stated at the lens's focal plane.
302    pub fn new(view: &CameraView, width: u32, height: u32, lens: Lens) -> Self {
303        Self {
304            view_proj: view.view_proj,
305            viewport: [
306                width.max(1) as f32,
307                height.max(1) as f32,
308                lens.focal_depth,
309                0.0,
310            ],
311            lens: [lens.aperture, lens.focal_depth, lens.max_coc, 0.0],
312        }
313    }
314}
315
316fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
317    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
318}
319
320fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
321    [
322        a[1] * b[2] - a[2] * b[1],
323        a[2] * b[0] - a[0] * b[2],
324        a[0] * b[1] - a[1] * b[0],
325    ]
326}
327
328fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
329    [
330        a[0] + (b[0] - a[0]) * t,
331        a[1] + (b[1] - a[1]) * t,
332        a[2] + (b[2] - a[2]) * t,
333    ]
334}
335
336#[cfg(test)]
337mod tests;