Skip to main content

viewport_lib/geometry/
implicit.rs

1//! CPU sphere-marching of implicit surfaces (signed-distance functions).
2//!
3//! [`march_implicit_surface`] and [`march_implicit_surface_colour`] accept a
4//! user-supplied SDF closure, fire rays from the camera for each pixel, and
5//! produce a [`crate::renderer::types::ScreenImageItem`] with per-pixel NDC
6//! depth suitable for depth-compositing against scene geometry via Phase 12.
7//!
8//! # Usage
9//!
10//! ```rust,ignore
11//! let opts = ImplicitRenderOptions {
12//!     width: 320,
13//!     height: 240,
14//!     ..Default::default()
15//! };
16//! // Sphere of radius 1.5:
17//! let img = march_implicit_surface(&camera, &opts, |p| p.length() - 1.5);
18//! fd.scene.screen_images.push(img);
19//! ```
20//!
21//! For coloured surfaces supply a closure returning `(sdf_value, [r, g, b, a])`:
22//!
23//! ```rust,ignore
24//! let img = march_implicit_surface_colour(&camera, &opts, |p| {
25//!     let d = p.length() - 1.5;
26//!     let colour = [200u8, 100, 50, 255];
27//!     (d, colour)
28//! });
29//! ```
30//!
31//! The returned item has `depth: Some(depths)` and `anchor: TopLeft` with
32//! `scale: 1.0`. Adjust `scale` on the returned item if you rendered at a
33//! reduced resolution (e.g. `scale = 2.0` for half-resolution rendering that
34//! still covers the full viewport).
35
36use crate::camera::camera::{Camera, Projection};
37use crate::renderer::{ImageAnchor, ScreenImageItem};
38use glam::Vec3;
39use rayon::prelude::*;
40
41// ---------------------------------------------------------------------------
42// Public types
43// ---------------------------------------------------------------------------
44
45/// Configuration for sphere-marching an implicit surface.
46///
47/// Resolution, step quality, and appearance can all be tuned here. Reducing
48/// `width`/`height` is the most effective way to improve performance — halving
49/// both dimensions cuts render time to ~1/4 while still producing a readable
50/// result.
51#[derive(Clone, Debug)]
52pub struct ImplicitRenderOptions {
53    /// Output image width in pixels.
54    pub width: u32,
55    /// Output image height in pixels.
56    pub height: u32,
57    /// Maximum number of sphere-march steps per ray.
58    ///
59    /// Increase for thin or complex surfaces; decrease for performance. Default: 128.
60    pub max_steps: u32,
61    /// Fraction of the SDF value to advance per step.
62    ///
63    /// Must be in `(0.0, 1.0]`. Use `< 1.0` for SDFs that are not exact (e.g.
64    /// smooth-min blends). Default: `0.9`.
65    pub step_scale: f32,
66    /// Distance threshold for declaring a surface hit: `|sdf(pos)| < hit_threshold`.
67    ///
68    /// Smaller values give sharper edges but may need more steps or a smaller
69    /// `step_scale`. Default: `5e-4`.
70    pub hit_threshold: f32,
71    /// Maximum ray travel distance; rays that exceed this without a hit are
72    /// treated as background. Default: `1000.0`.
73    pub max_distance: f32,
74    /// RGBA8 surface colour used by [`march_implicit_surface`].
75    ///
76    /// Ignored by [`march_implicit_surface_colour`] (colour comes from the closure).
77    /// Default: light grey `[200, 200, 200, 255]`.
78    pub surface_colour: [u8; 4],
79    /// RGBA8 background colour for pixels that miss the surface. Default: fully
80    /// transparent black `[0, 0, 0, 0]`.
81    pub background: [u8; 4],
82}
83
84impl Default for ImplicitRenderOptions {
85    fn default() -> Self {
86        Self {
87            width: 512,
88            height: 512,
89            max_steps: 128,
90            step_scale: 0.9,
91            hit_threshold: 5e-4,
92            max_distance: 1000.0,
93            surface_colour: [200, 200, 200, 255],
94            background: [0, 0, 0, 0],
95        }
96    }
97}
98
99// ---------------------------------------------------------------------------
100// Public API
101// ---------------------------------------------------------------------------
102
103/// Sphere-march a signed-distance function and produce a depth-composited
104/// [`ScreenImageItem`].
105///
106/// Each pixel fires a ray from the camera and sphere-marches by calling
107/// `sdf`. Hit points are shaded with simple diffuse + ambient lighting derived
108/// from the SDF gradient (6 extra SDF evaluations per hit point via central
109/// differences).
110///
111/// The returned item has `depth: Some(depths)`. Background pixels carry depth
112/// `1.0` (far plane) so scene geometry is never occluded by them.
113///
114/// See the module-level documentation for a usage example.
115pub fn march_implicit_surface<F>(
116    camera: &Camera,
117    options: &ImplicitRenderOptions,
118    sdf: F,
119) -> ScreenImageItem
120where
121    F: Fn(Vec3) -> f32 + Sync,
122{
123    let colour = options.surface_colour;
124    march_impl(camera, options, move |p| (sdf(p), colour))
125}
126
127/// Sphere-march a coloured signed-distance function and produce a depth-composited
128/// [`ScreenImageItem`].
129///
130/// The closure `sdf_colour` returns `(sdf_value, rgba8_colour)`. The SDF value
131/// drives the ray-march; the colour is modulated by the same diffuse + ambient
132/// shading as [`march_implicit_surface`]. The colour closure is also called
133/// (6 times per hit point) for normal estimation — only the SDF value is
134/// used in those calls.
135///
136/// The returned item has `depth: Some(depths)`. Background pixels carry depth
137/// `1.0` (far plane) so scene geometry is never occluded by them.
138///
139/// See the module-level documentation for a usage example.
140pub fn march_implicit_surface_colour<F>(
141    camera: &Camera,
142    options: &ImplicitRenderOptions,
143    sdf_colour: F,
144) -> ScreenImageItem
145where
146    F: Fn(Vec3) -> (f32, [u8; 4]) + Sync,
147{
148    march_impl(camera, options, sdf_colour)
149}
150
151// ---------------------------------------------------------------------------
152// Core implementation
153// ---------------------------------------------------------------------------
154
155fn march_impl<F>(camera: &Camera, options: &ImplicitRenderOptions, sdf_colour: F) -> ScreenImageItem
156where
157    F: Fn(Vec3) -> (f32, [u8; 4]) + Sync,
158{
159    let w = options.width.max(1);
160    let h = options.height.max(1);
161
162    let eye = camera.eye_position();
163    // Look direction: eye -> center.  Fall back to orientation when eye==center.
164    let forward = {
165        let diff = camera.center - eye;
166        if diff.length_squared() > 1e-10 {
167            diff.normalize()
168        } else {
169            -(camera.orientation * Vec3::Z)
170        }
171    };
172    let right = camera.orientation * Vec3::X;
173    let up = camera.orientation * Vec3::Y;
174
175    // Perspective: half-extents of the image plane at unit distance.
176    let half_h_persp = (camera.fov_y / 2.0).tan();
177    let half_w_persp = half_h_persp * camera.aspect;
178
179    // Orthographic: half-extents in world units.
180    let orth_half_h = camera.distance * half_h_persp;
181    let orth_half_w = camera.distance * half_w_persp;
182
183    let is_ortho = matches!(camera.projection, Projection::Orthographic);
184
185    let znear = camera.effective_znear();
186    // Effective far matches Camera::proj_matrix so NDC depths are consistent.
187    let effective_zfar = camera.effective_zfar();
188
189    // Finite-difference step for normal estimation.
190    let eps = (options.hit_threshold * 100.0).max(1e-5_f32);
191
192    // Simple diffuse light in world space.
193    const LIGHT: Vec3 = Vec3::new(0.577_350_26, 0.577_350_26, 0.577_350_26);
194    const AMBIENT: f32 = 0.25_f32;
195
196    let count = (w * h) as usize;
197    let mut pixels = vec![[0u8; 4]; count];
198    let mut depths = vec![1.0_f32; count];
199
200    pixels
201        .par_iter_mut()
202        .zip(depths.par_iter_mut())
203        .enumerate()
204        .for_each(|(idx, (pix, dep))| {
205            let px = (idx as u32) % w;
206            let py = (idx as u32) / w;
207
208            // NDC: x in [-1, 1] left->right, y in [-1, 1] bottom->top.
209            let ndc_x = (px as f32 + 0.5) / w as f32 * 2.0 - 1.0;
210            let ndc_y = 1.0 - (py as f32 + 0.5) / h as f32 * 2.0;
211
212            let (ray_o, ray_d): (Vec3, Vec3) = if is_ortho {
213                let o = eye + right * (ndc_x * orth_half_w) + up * (ndc_y * orth_half_h);
214                (o, forward)
215            } else {
216                let d = (forward + right * (ndc_x * half_w_persp) + up * (ndc_y * half_h_persp))
217                    .normalize();
218                (eye, d)
219            };
220
221            // Sphere-march.
222            let mut t = znear;
223            let mut hit = false;
224            let mut hit_pos = Vec3::ZERO;
225            let mut hit_colour = options.surface_colour;
226
227            for _ in 0..options.max_steps {
228                let pos = ray_o + ray_d * t;
229                let (d, colour) = sdf_colour(pos);
230                if d.abs() < options.hit_threshold {
231                    hit = true;
232                    hit_pos = pos;
233                    hit_colour = colour;
234                    break;
235                }
236                t += d * options.step_scale;
237                if t > options.max_distance {
238                    break;
239                }
240            }
241
242            if hit {
243                // Normal from central differences.
244                let gx =
245                    sdf_colour(hit_pos + Vec3::X * eps).0 - sdf_colour(hit_pos - Vec3::X * eps).0;
246                let gy =
247                    sdf_colour(hit_pos + Vec3::Y * eps).0 - sdf_colour(hit_pos - Vec3::Y * eps).0;
248                let gz =
249                    sdf_colour(hit_pos + Vec3::Z * eps).0 - sdf_colour(hit_pos - Vec3::Z * eps).0;
250                let normal = Vec3::new(gx, gy, gz).normalize_or_zero();
251
252                // Diffuse + ambient shading.
253                let diffuse = normal.dot(LIGHT).max(0.0);
254                let shade = (AMBIENT + (1.0 - AMBIENT) * diffuse).min(1.0);
255
256                *pix = [
257                    (hit_colour[0] as f32 * shade) as u8,
258                    (hit_colour[1] as f32 * shade) as u8,
259                    (hit_colour[2] as f32 * shade) as u8,
260                    hit_colour[3],
261                ];
262
263                // NDC depth (wgpu: 0 = near plane, 1 = far plane).
264                // Formula from Phase 12 showcase: zfar*(d-znear)/(d*(zfar-znear))
265                // where d is the positive view-space depth.
266                let view_depth = (hit_pos - eye).dot(forward);
267                *dep = if view_depth > znear {
268                    (effective_zfar * (view_depth - znear)
269                        / (view_depth * (effective_zfar - znear)))
270                        .clamp(0.0, 1.0)
271                } else {
272                    0.0
273                };
274            } else {
275                *pix = options.background;
276                // Far-plane depth: never occludes scene geometry.
277                *dep = 1.0;
278            }
279        });
280
281    ScreenImageItem {
282        pixels,
283        width: w,
284        height: h,
285        anchor: ImageAnchor::TopLeft,
286        scale: 1.0,
287        alpha: 1.0,
288        depth: Some(depths),
289        ..Default::default()
290    }
291}
292
293// ---------------------------------------------------------------------------
294// Tests
295// ---------------------------------------------------------------------------
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::Camera;
301
302    fn default_cam() -> Camera {
303        Camera {
304            center: glam::Vec3::ZERO,
305            distance: 6.0,
306            orientation: glam::Quat::IDENTITY,
307            fov_y: std::f32::consts::FRAC_PI_4,
308            aspect: 1.0,
309            znear: Some(0.1),
310            zfar: 100.0,
311            ..Camera::default()
312        }
313    }
314
315    #[test]
316    fn march_sphere_hits_center() {
317        let cam = default_cam();
318        let opts = ImplicitRenderOptions {
319            width: 64,
320            height: 64,
321            max_steps: 256,
322            hit_threshold: 1e-4,
323            max_distance: 200.0,
324            surface_colour: [255, 0, 0, 255],
325            ..Default::default()
326        };
327        // Unit sphere at origin — camera is at z=6, looking at origin.
328        let img = march_implicit_surface(&cam, &opts, |p| p.length() - 1.0);
329
330        assert_eq!(img.pixels.len(), 64 * 64);
331        assert_eq!(img.depth.as_ref().map(|d| d.len()), Some(64 * 64));
332
333        // Centre pixel should have hit the sphere (alpha > 0).
334        let cx = 32usize;
335        let cy = 32usize;
336        let center_px = img.pixels[cy * 64 + cx];
337        assert!(
338            center_px[3] == 255,
339            "centre pixel should have alpha=255 (sphere hit), got {:?}",
340            center_px
341        );
342    }
343
344    #[test]
345    fn march_sphere_depth_in_range() {
346        let cam = default_cam();
347        let opts = ImplicitRenderOptions {
348            width: 32,
349            height: 32,
350            max_steps: 256,
351            hit_threshold: 1e-4,
352            max_distance: 200.0,
353            ..Default::default()
354        };
355        let img = march_implicit_surface(&cam, &opts, |p| p.length() - 1.0);
356
357        let depths = img.depth.as_ref().unwrap();
358        let cx = 16usize;
359        let cy = 16usize;
360        let d = depths[cy * 32 + cx];
361        assert!(
362            d > 0.0 && d < 1.0,
363            "centre depth should be in (0,1), got {d}"
364        );
365    }
366
367    #[test]
368    fn march_miss_returns_background() {
369        let cam = default_cam();
370        let opts = ImplicitRenderOptions {
371            width: 8,
372            height: 8,
373            max_steps: 64,
374            max_distance: 0.01, // effectively no march — all rays miss
375            background: [0, 0, 0, 0],
376            ..Default::default()
377        };
378        let img = march_implicit_surface(&cam, &opts, |p| p.length() - 1.0);
379
380        for (i, px) in img.pixels.iter().enumerate() {
381            assert_eq!(*px, [0, 0, 0, 0], "pixel {i} should be background colour");
382        }
383        let depths = img.depth.as_ref().unwrap();
384        for d in depths.iter() {
385            assert!(
386                (d - 1.0).abs() < 1e-6,
387                "missed pixels should have far-plane depth (1.0), got {d}"
388            );
389        }
390    }
391
392    #[test]
393    fn march_colour_closure_applies_colour() {
394        let cam = default_cam();
395        let opts = ImplicitRenderOptions {
396            width: 32,
397            height: 32,
398            max_steps: 256,
399            hit_threshold: 1e-4,
400            max_distance: 200.0,
401            ..Default::default()
402        };
403        let target_alpha = 200u8;
404        let img = march_implicit_surface_colour(&cam, &opts, |p| {
405            (p.length() - 1.0, [0, 255, 0, target_alpha])
406        });
407
408        // Centre pixel: alpha must match what the closure returns.
409        let cx = 16usize;
410        let cy = 16usize;
411        let px = img.pixels[cy * 32 + cx];
412        assert_eq!(px[3], target_alpha, "alpha should pass through unchanged");
413        // Green channel should be non-zero (diffuse shading dims it, but it started at 255).
414        assert!(px[1] > 0, "green channel should survive shading");
415        // Red and blue should be zero (closure returns 0 for both).
416        assert_eq!(px[0], 0, "red should be 0");
417        assert_eq!(px[2], 0, "blue should be 0");
418    }
419
420    #[test]
421    fn output_dimensions_match_options() {
422        let cam = default_cam();
423        let opts = ImplicitRenderOptions {
424            width: 17,
425            height: 11,
426            ..Default::default()
427        };
428        let img = march_implicit_surface(&cam, &opts, |p| p.length() - 1.0);
429        assert_eq!(img.width, 17);
430        assert_eq!(img.height, 11);
431        assert_eq!(img.pixels.len(), 17 * 11);
432        assert_eq!(img.depth.as_ref().unwrap().len(), 17 * 11);
433    }
434
435    #[test]
436    fn orthographic_camera_hits_sphere() {
437        let mut cam = default_cam();
438        cam.projection = Projection::Orthographic;
439        let opts = ImplicitRenderOptions {
440            width: 32,
441            height: 32,
442            max_steps: 256,
443            hit_threshold: 1e-4,
444            max_distance: 200.0,
445            surface_colour: [255, 255, 255, 255],
446            ..Default::default()
447        };
448        let img = march_implicit_surface(&cam, &opts, |p| p.length() - 1.0);
449        let cx = 16usize;
450        let cy = 16usize;
451        assert_eq!(
452            img.pixels[cy * 32 + cx][3],
453            255,
454            "orthographic centre pixel should hit the sphere"
455        );
456    }
457}