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}