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;