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