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_color`] 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 colored surfaces supply a closure returning `(sdf_value, [r, g, b, a])`:
22//!
23//! ```rust,ignore
24//! let img = march_implicit_surface_color(&camera, &opts, |p| {
25//!     let d = p.length() - 1.5;
26//!     let color = [200u8, 100, 50, 255];
27//!     (d, color)
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 color used by [`march_implicit_surface`].
75    ///
76    /// Ignored by [`march_implicit_surface_color`] (color comes from the closure).
77    /// Default: light grey `[200, 200, 200, 255]`.
78    pub surface_color: [u8; 4],
79    /// RGBA8 background color 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_color: [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 color = options.surface_color;
124    march_impl(camera, options, move |p| (sdf(p), color))
125}
126
127/// Sphere-march a colored signed-distance function and produce a depth-composited
128/// [`ScreenImageItem`].
129///
130/// The closure `sdf_color` returns `(sdf_value, rgba8_color)`. The SDF value
131/// drives the ray-march; the color is modulated by the same diffuse + ambient
132/// shading as [`march_implicit_surface`]. The color 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_color<F>(
141    camera: &Camera,
142    options: &ImplicitRenderOptions,
143    sdf_color: F,
144) -> ScreenImageItem
145where
146    F: Fn(Vec3) -> (f32, [u8; 4]) + Sync,
147{
148    march_impl(camera, options, sdf_color)
149}
150
151// ---------------------------------------------------------------------------
152// Core implementation
153// ---------------------------------------------------------------------------
154
155fn march_impl<F>(camera: &Camera, options: &ImplicitRenderOptions, sdf_color: 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
217                    + right * (ndc_x * half_w_persp)
218                    + up * (ndc_y * half_h_persp))
219                    .normalize();
220                (eye, d)
221            };
222
223            // Sphere-march.
224            let mut t = znear;
225            let mut hit = false;
226            let mut hit_pos = Vec3::ZERO;
227            let mut hit_color = options.surface_color;
228
229            for _ in 0..options.max_steps {
230                let pos = ray_o + ray_d * t;
231                let (d, color) = sdf_color(pos);
232                if d.abs() < options.hit_threshold {
233                    hit = true;
234                    hit_pos = pos;
235                    hit_color = color;
236                    break;
237                }
238                t += d * options.step_scale;
239                if t > options.max_distance {
240                    break;
241                }
242            }
243
244            if hit {
245                // Normal from central differences.
246                let gx = sdf_color(hit_pos + Vec3::X * eps).0
247                    - sdf_color(hit_pos - Vec3::X * eps).0;
248                let gy = sdf_color(hit_pos + Vec3::Y * eps).0
249                    - sdf_color(hit_pos - Vec3::Y * eps).0;
250                let gz = sdf_color(hit_pos + Vec3::Z * eps).0
251                    - sdf_color(hit_pos - Vec3::Z * eps).0;
252                let normal = Vec3::new(gx, gy, gz).normalize_or_zero();
253
254                // Diffuse + ambient shading.
255                let diffuse = normal.dot(LIGHT).max(0.0);
256                let shade = (AMBIENT + (1.0 - AMBIENT) * diffuse).min(1.0);
257
258                *pix = [
259                    (hit_color[0] as f32 * shade) as u8,
260                    (hit_color[1] as f32 * shade) as u8,
261                    (hit_color[2] as f32 * shade) as u8,
262                    hit_color[3],
263                ];
264
265                // NDC depth (wgpu: 0 = near plane, 1 = far plane).
266                // Formula from Phase 12 showcase: zfar*(d-znear)/(d*(zfar-znear))
267                // where d is the positive view-space depth.
268                let view_depth = (hit_pos - eye).dot(forward);
269                *dep = if view_depth > znear {
270                    (effective_zfar * (view_depth - znear)
271                        / (view_depth * (effective_zfar - znear)))
272                        .clamp(0.0, 1.0)
273                } else {
274                    0.0
275                };
276            } else {
277                *pix = options.background;
278                // Far-plane depth: never occludes scene geometry.
279                *dep = 1.0;
280            }
281        });
282
283    ScreenImageItem {
284        pixels,
285        width: w,
286        height: h,
287        anchor: ImageAnchor::TopLeft,
288        scale: 1.0,
289        alpha: 1.0,
290        depth: Some(depths),
291    }
292}
293
294// ---------------------------------------------------------------------------
295// Tests
296// ---------------------------------------------------------------------------
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::Camera;
302
303    fn default_cam() -> Camera {
304        Camera {
305            center: glam::Vec3::ZERO,
306            distance: 6.0,
307            orientation: glam::Quat::IDENTITY,
308            fov_y: std::f32::consts::FRAC_PI_4,
309            aspect: 1.0,
310            znear: Some(0.1),
311            zfar: 100.0,
312            ..Camera::default()
313        }
314    }
315
316    #[test]
317    fn march_sphere_hits_center() {
318        let cam = default_cam();
319        let opts = ImplicitRenderOptions {
320            width: 64,
321            height: 64,
322            max_steps: 256,
323            hit_threshold: 1e-4,
324            max_distance: 200.0,
325            surface_color: [255, 0, 0, 255],
326            ..Default::default()
327        };
328        // Unit sphere at origin — camera is at z=6, looking at origin.
329        let img = march_implicit_surface(&cam, &opts, |p| p.length() - 1.0);
330
331        assert_eq!(img.pixels.len(), 64 * 64);
332        assert_eq!(img.depth.as_ref().map(|d| d.len()), Some(64 * 64));
333
334        // Centre pixel should have hit the sphere (alpha > 0).
335        let cx = 32usize;
336        let cy = 32usize;
337        let center_px = img.pixels[cy * 64 + cx];
338        assert!(
339            center_px[3] == 255,
340            "centre pixel should have alpha=255 (sphere hit), got {:?}",
341            center_px
342        );
343    }
344
345    #[test]
346    fn march_sphere_depth_in_range() {
347        let cam = default_cam();
348        let opts = ImplicitRenderOptions {
349            width: 32,
350            height: 32,
351            max_steps: 256,
352            hit_threshold: 1e-4,
353            max_distance: 200.0,
354            ..Default::default()
355        };
356        let img = march_implicit_surface(&cam, &opts, |p| p.length() - 1.0);
357
358        let depths = img.depth.as_ref().unwrap();
359        let cx = 16usize;
360        let cy = 16usize;
361        let d = depths[cy * 32 + cx];
362        assert!(d > 0.0 && d < 1.0, "centre depth should be in (0,1), got {d}");
363    }
364
365    #[test]
366    fn march_miss_returns_background() {
367        let cam = default_cam();
368        let opts = ImplicitRenderOptions {
369            width: 8,
370            height: 8,
371            max_steps: 64,
372            max_distance: 0.01, // effectively no march — all rays miss
373            background: [0, 0, 0, 0],
374            ..Default::default()
375        };
376        let img = march_implicit_surface(&cam, &opts, |p| p.length() - 1.0);
377
378        for (i, px) in img.pixels.iter().enumerate() {
379            assert_eq!(
380                *px,
381                [0, 0, 0, 0],
382                "pixel {i} should be background colour"
383            );
384        }
385        let depths = img.depth.as_ref().unwrap();
386        for d in depths.iter() {
387            assert!(
388                (d - 1.0).abs() < 1e-6,
389                "missed pixels should have far-plane depth (1.0), got {d}"
390            );
391        }
392    }
393
394    #[test]
395    fn march_color_closure_applies_color() {
396        let cam = default_cam();
397        let opts = ImplicitRenderOptions {
398            width: 32,
399            height: 32,
400            max_steps: 256,
401            hit_threshold: 1e-4,
402            max_distance: 200.0,
403            ..Default::default()
404        };
405        let target_alpha = 200u8;
406        let img = march_implicit_surface_color(&cam, &opts, |p| {
407            (p.length() - 1.0, [0, 255, 0, target_alpha])
408        });
409
410        // Centre pixel: alpha must match what the closure returns.
411        let cx = 16usize;
412        let cy = 16usize;
413        let px = img.pixels[cy * 32 + cx];
414        assert_eq!(px[3], target_alpha, "alpha should pass through unchanged");
415        // Green channel should be non-zero (diffuse shading dims it, but it started at 255).
416        assert!(px[1] > 0, "green channel should survive shading");
417        // Red and blue should be zero (closure returns 0 for both).
418        assert_eq!(px[0], 0, "red should be 0");
419        assert_eq!(px[2], 0, "blue should be 0");
420    }
421
422    #[test]
423    fn output_dimensions_match_options() {
424        let cam = default_cam();
425        let opts = ImplicitRenderOptions {
426            width: 17,
427            height: 11,
428            ..Default::default()
429        };
430        let img = march_implicit_surface(&cam, &opts, |p| p.length() - 1.0);
431        assert_eq!(img.width, 17);
432        assert_eq!(img.height, 11);
433        assert_eq!(img.pixels.len(), 17 * 11);
434        assert_eq!(img.depth.as_ref().unwrap().len(), 17 * 11);
435    }
436
437    #[test]
438    fn orthographic_camera_hits_sphere() {
439        let mut cam = default_cam();
440        cam.projection = Projection::Orthographic;
441        let opts = ImplicitRenderOptions {
442            width: 32,
443            height: 32,
444            max_steps: 256,
445            hit_threshold: 1e-4,
446            max_distance: 200.0,
447            surface_color: [255, 255, 255, 255],
448            ..Default::default()
449        };
450        let img = march_implicit_surface(&cam, &opts, |p| p.length() - 1.0);
451        let cx = 16usize;
452        let cy = 16usize;
453        assert_eq!(
454            img.pixels[cy * 32 + cx][3],
455            255,
456            "orthographic centre pixel should hit the sphere"
457        );
458    }
459}