all_is_cubes/
raytracer.rs

1//! Note: This module is hidden, and its contents re-exported as `all_is_cubes_render::raytracer`.
2//! It is located in this crate so that it can be used by unit tests.
3
4use alloc::boxed::Box;
5use alloc::string::String;
6use alloc::vec::Vec;
7use core::fmt;
8use core::marker::PhantomData;
9
10use euclid::{Vector3D, vec3};
11use manyfmt::Fmt;
12/// Acts as polyfill for float methods
13#[cfg(not(any(feature = "std", test)))]
14#[allow(
15    unused_imports,
16    reason = "unclear why this warns even though it is needed"
17)]
18use num_traits::float::Float as _;
19use rand::SeedableRng;
20
21#[cfg(feature = "auto-threads")]
22use rayon::iter::{IntoParallelIterator as _, ParallelIterator as _};
23
24use crate::block::{AIR, Evoxels, Resolution};
25use crate::camera::{Camera, GraphicsOptions, TransparencyOption};
26use crate::camera::{FogOption, NdcPoint2};
27#[cfg(not(any(feature = "std", test)))]
28#[allow(
29    unused_imports,
30    reason = "unclear why this warns even though it is needed"
31)]
32use crate::math::Euclid as _;
33use crate::math::{
34    Cube, Face6, Face7, FreeCoordinate, FreePoint, FreeVector, GridAab, GridMatrix, Intensity, Rgb,
35    Rgba, Vol, ZeroOne, rgb_const, smoothstep,
36};
37use crate::raycast::{self, Ray, RayIsh};
38use crate::space::{self, BlockIndex, BlockSky, PackedLight, Sky, SpaceBlockData};
39use crate::util::StatusText;
40
41#[cfg(doc)]
42use crate::space::Space;
43
44// -------------------------------------------------------------------------------------------------
45
46mod accum;
47pub use accum::*;
48mod surface;
49use surface::{DepthIter, DepthStep, Span, Surface, SurfaceIter, TraceStep};
50// TODO: pub use surface::*;
51mod text;
52pub use text::*;
53pub use updating::*;
54mod updating;
55
56// -------------------------------------------------------------------------------------------------
57
58/// Precomputed data for raytracing a single frame of a single [`Space`], and bearer of
59/// the methods for actually performing raytracing.
60#[allow(clippy::module_name_repetitions, reason = "TODO: find better name")]
61pub struct SpaceRaytracer<D: RtBlockData> {
62    blocks: Vec<TracingBlock<D>>,
63    cubes: Vol<Box<[TracingCubeData]>>,
64
65    graphics_options: GraphicsOptions,
66    custom_options: D::Options,
67    sky: Sky,
68    sky_data: D,
69    block_sky: BlockSky,
70}
71
72impl<D: RtBlockData> SpaceRaytracer<D> {
73    /// Snapshots the given [`Space`] to prepare for raytracing it.
74    pub fn new(
75        space: &space::Read<'_>,
76        graphics_options: GraphicsOptions,
77        custom_options: D::Options,
78    ) -> Self {
79        let options = RtOptionsRef {
80            graphics_options: &graphics_options,
81            custom_options: &custom_options,
82        };
83        let sky = space.physics().sky.clone();
84        SpaceRaytracer {
85            blocks: space
86                .block_data()
87                .iter()
88                .map(|sbd| TracingBlock::<D>::from_block(options, sbd))
89                .collect(),
90            cubes: prepare_cubes(space),
91            block_sky: sky.for_blocks(),
92            sky,
93            sky_data: D::exception(Exception::Sky, options),
94
95            graphics_options,
96            custom_options,
97        }
98    }
99
100    /// Construct a [`SpaceRaytracer`] with nothing to render.
101    pub(crate) fn new_empty(
102        sky: Sky,
103        graphics_options: GraphicsOptions,
104        custom_options: D::Options,
105    ) -> Self {
106        let options = RtOptionsRef {
107            graphics_options: &graphics_options,
108            custom_options: &custom_options,
109        };
110        SpaceRaytracer {
111            blocks: Vec::new(),
112            cubes: Vol::from_elements(GridAab::ORIGIN_EMPTY, []).unwrap(),
113            block_sky: sky.for_blocks(),
114            sky,
115            sky_data: D::exception(Exception::Sky, options),
116
117            graphics_options,
118            custom_options,
119        }
120    }
121
122    /// Computes a single image pixel from the given ray, adding it to `accumulator`.
123    pub fn trace_ray<P: Accumulate<BlockData = D>>(
124        &self,
125        ray: Ray,
126        accumulator: &mut P,
127        include_sky: bool,
128    ) -> RaytraceInfo {
129        self.trace_ray_impl::<P, Ray>(ray, accumulator, include_sky, true)
130    }
131
132    /// Computes a single image pixel from the given ray.
133    ///
134    /// This is identical to [`Self::trace_ray()`] except that it can be more efficient
135    /// by being restricted to a single axis.
136    pub fn trace_axis_aligned_ray<P: Accumulate<BlockData = D>>(
137        &self,
138        ray: raycast::AaRay,
139        accumulator: &mut P,
140        include_sky: bool,
141    ) -> RaytraceInfo {
142        self.trace_ray_impl::<P, raycast::AaRay>(ray, accumulator, include_sky, true)
143    }
144
145    fn trace_ray_impl<P: Accumulate<BlockData = D>, R: RayIsh>(
146        &self,
147        ray: R,
148        accumulator: &mut P,
149        include_sky: bool,
150        allow_ray_bounce: bool,
151    ) -> RaytraceInfo {
152        let options = RtOptionsRef {
153            graphics_options: &self.graphics_options,
154            custom_options: &self.custom_options,
155        };
156        let ray_direction = ray.direction();
157
158        let sky_light = include_sky.then(|| self.sky.sample(ray.direction()));
159        let t_to_absolute_distance = ray.direction().length();
160        let mut state: TracingState<'_, P> = TracingState {
161            t_to_absolute_distance,
162            t_to_view_distance: (t_to_absolute_distance
163                / self.graphics_options.view_distance.into_inner())
164                as f32,
165            distance_fog_light: match self.graphics_options.fog {
166                FogOption::None => None,
167                _ => sky_light,
168            },
169            distance_fog_blend: match self.graphics_options.fog {
170                FogOption::Abrupt => 1.0,
171                FogOption::Compromise => 0.5,
172                FogOption::Physical => 0.0,
173                /* FogOption::None | */ _ => 0.0,
174            },
175            primary_cubes_traced: 0,
176            secondary_info: RaytraceInfo::default(),
177            accumulator,
178            ray_bounce_rng: allow_ray_bounce.then(|| {
179                // Computing the random bounces from the ray direction makes the bounce pattern,
180                // and thus the produced image, deterministic. This is useful for the current
181                // experimentation, but will no longer be desirable in the event that we add
182                // temporal accumulation. At that point we would want to either reuse a single
183                // RNG for all rendering, or mix something like a frame number into this seed.
184                rand::rngs::SmallRng::seed_from_u64(
185                    ray_direction
186                        .x
187                        .to_bits()
188                        .wrapping_add(ray_direction.y.to_bits())
189                        .wrapping_add(ray_direction.z.to_bits()),
190                )
191            }),
192        };
193        let surface_iter: SurfaceIter<'_, D, R::Caster> = SurfaceIter::new(self, ray);
194
195        // Use the more expensive volumetric tracing strategy only if we need it.
196        match self.graphics_options.transparency {
197            TransparencyOption::Volumetric => {
198                for step in DepthIter::new(surface_iter) {
199                    if state.count_step_should_stop(options) {
200                        break;
201                    }
202
203                    match step {
204                        DepthStep::Invisible { .. } => {
205                            // Side effect: called count_step_should_stop.
206                        }
207                        DepthStep::Span(span) => {
208                            debug_assert!(span.surface.visible());
209                            state.trace_through_span(span, self);
210                        }
211                        DepthStep::EnterBlock {
212                            t_distance: _,
213                            block_data,
214                        } => state.accumulator.enter_block(block_data),
215                    }
216                }
217            }
218            _ => {
219                for step in surface_iter {
220                    if state.count_step_should_stop(options) {
221                        break;
222                    }
223
224                    use TraceStep::*;
225                    match step {
226                        Invisible { .. } => {
227                            // Side effect: called count_step_should_stop.
228                        }
229                        EnterBlock {
230                            t_distance: _,
231                            block_data,
232                        } => state.accumulator.enter_block(block_data),
233                        EnterSurface(surface) => {
234                            debug_assert!(surface.visible());
235                            state.trace_through_surface(&surface, self);
236                        }
237                    }
238                }
239            }
240        }
241        state.finish(
242            if let Some(sky_light) = sky_light {
243                sky_light.with_alpha_one()
244            } else {
245                Rgba::TRANSPARENT
246            },
247            &self.sky_data,
248            self.graphics_options.debug_pixel_cost,
249            options,
250        )
251    }
252
253    #[inline]
254    fn get_packed_light(&self, cube: Cube) -> PackedLight {
255        match self.cubes.get(cube) {
256            Some(b) => b.lighting,
257            None => self.block_sky.light_outside(self.cubes.bounds(), cube),
258        }
259    }
260
261    fn get_interpolated_light(&self, point: FreePoint, face: Face7) -> Rgb {
262        // This implementation is duplicated in WGSL in interpolated_space_light()
263        // in all-is-cubes-gpu/src/in_wgpu/shaders/blocks-and-lines.wgsl.
264
265        // About half the size of the smallest permissible voxel.
266        let above_surface_epsilon = 0.5 / 256.0;
267
268        // The position we should start with for light lookup and interpolation.
269        let origin = point.to_vector() + face.vector(above_surface_epsilon);
270
271        // Find linear interpolation coefficients based on where we are relative to
272        // a half-cube-offset grid.
273        let reference_frame = match Face6::try_from(face) {
274            Ok(face) => face.face_transform(0).to_matrix(),
275            Err(_) => GridMatrix::ZERO,
276        }
277        .to_free();
278        let reference_frame_x = reference_frame.transform_vector3d(vec3(1., 0., 0.));
279        let reference_frame_y = reference_frame.transform_vector3d(vec3(0., 1., 0.));
280
281        let mut mix_1 = (origin.dot(reference_frame_x) - 0.5).rem_euclid(1.0);
282        let mut mix_2 = (origin.dot(reference_frame_y) - 0.5).rem_euclid(1.0);
283
284        // Ensure that mix <= 0.5, i.e. the 'near' side below is the side we are on
285        fn flip_mix(mix: &mut FreeCoordinate, dir: FreeVector) -> FreeVector {
286            if *mix > 0.5 {
287                *mix = 1.0 - *mix;
288                -dir
289            } else {
290                dir
291            }
292        }
293        let dir_1 = flip_mix(&mut mix_1, reference_frame_x);
294        let dir_2 = flip_mix(&mut mix_2, reference_frame_y);
295
296        // Modify interpolation by smoothstep to change the visual impression towards
297        // "blurred blocks" and away from the diamond-shaped gradients of linear interpolation
298        // which, being so familiar, can give an unfortunate impression of "here is
299        // a closeup of a really low-resolution texture".
300        let mix_1 = smoothstep(mix_1);
301        let mix_2 = smoothstep(mix_2);
302
303        // Retrieve light data, again using the half-cube-offset grid (this way we won't have edge artifacts).
304        let get_light = |p: FreeVector| match Cube::containing(origin.to_point() + p) {
305            Some(cube) => self.get_packed_light(cube),
306            // Numerical overflow case -- shouldn't be terribly relevant.
307            None => self.block_sky.mean(),
308        };
309        let lin_lo = -0.5;
310        let lin_hi = 0.5;
311        let near12 = get_light(dir_1 * lin_lo + dir_2 * lin_lo);
312        let near1far2 = get_light(dir_1 * lin_lo + dir_2 * lin_hi);
313        let near2far1 = get_light(dir_1 * lin_hi + dir_2 * lin_lo);
314        let mut far12 = get_light(dir_1 * lin_hi + dir_2 * lin_hi);
315
316        if !near1far2.valid() && !near2far1.valid() {
317            // The far corner is on the other side of a diagonal wall, so should be
318            // ignored to prevent light leaks.
319            far12 = near12;
320        }
321
322        // Apply ambient occlusion.
323        let near12 = near12.value_with_ambient_occlusion();
324        let near1far2 = near1far2.value_with_ambient_occlusion();
325        let near2far1 = near2far1.value_with_ambient_occlusion();
326        let far12 = far12.value_with_ambient_occlusion();
327
328        // Perform bilinear interpolation.
329        // TODO: most of the prior math for the mix values should be f32 already
330        let [r, g, b, final_weight] = mix4(
331            mix4(near12, near1far2, mix_2 as f32),
332            mix4(near2far1, far12, mix_2 as f32),
333            mix_1 as f32,
334        );
335        // Apply weight
336        Rgb::try_from(Vector3D::from([r, g, b]) / final_weight.max(0.1)).unwrap()
337    }
338}
339
340/// Text-specific methods.
341impl<D: RtBlockData> SpaceRaytracer<D> {
342    /// Raytrace to text, using any [`Accumulate`] whose output can be [`String`].
343    ///
344    /// After each line (row) of the image, including the last, `line_ending` will be inserted.
345    pub fn to_text<'a, P>(
346        &'a self,
347        camera: &'a Camera,
348        line_ending: &'a str,
349    ) -> impl fmt::Display + 'a
350    where
351        P: Accumulate<BlockData = D> + Into<String> + Default + 'a,
352    {
353        struct ToText<'a, D: RtBlockData, P> {
354            rt: &'a SpaceRaytracer<D>,
355            camera: &'a Camera,
356            line_ending: &'a str,
357            _p: PhantomData<fn() -> P>,
358        }
359
360        impl<D: RtBlockData, P: Accumulate<BlockData = D> + Into<String> + Default> fmt::Display
361            for ToText<'_, D, P>
362        {
363            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
364                self.rt.trace_scene_to_text_impl::<P>(self.camera, self.line_ending, f)?;
365                Ok(())
366            }
367        }
368
369        ToText::<'a, D, P> {
370            rt: self,
371            camera,
372            line_ending,
373            _p: PhantomData,
374        }
375    }
376
377    #[cfg(feature = "auto-threads")]
378    fn trace_scene_to_text_impl<P>(
379        &self,
380        camera: &Camera,
381        line_ending: &str,
382        stream: &mut dyn fmt::Write,
383    ) -> Result<RaytraceInfo, fmt::Error>
384    where
385        P: Accumulate<BlockData = D> + Into<String> + Default,
386    {
387        let viewport = camera.viewport();
388        let viewport_size = viewport.framebuffer_size.to_usize();
389        let output_iterator = (0..viewport_size.height)
390            .into_par_iter()
391            .map(move |ych| {
392                let y = viewport.normalize_fb_y(ych);
393                (0..viewport_size.width)
394                    .into_par_iter()
395                    .map(move |xch| {
396                        let x = viewport.normalize_fb_x(xch);
397                        let mut buf = P::default();
398                        let info = self.trace_ray::<P>(
399                            camera.project_ndc_into_world(NdcPoint2::new(x, y)),
400                            &mut buf,
401                            true,
402                        );
403                        (buf.into(), info)
404                    })
405                    .chain(
406                        Some((String::from(line_ending), RaytraceInfo::default())).into_par_iter(),
407                    )
408            })
409            .flatten();
410
411        let (text, info_sum): (String, rayon_helper::ParExtSum<RaytraceInfo>) =
412            output_iterator.unzip();
413        stream.write_str(text.as_str())?;
414
415        Ok(info_sum.result())
416    }
417
418    #[cfg(not(feature = "auto-threads"))]
419    fn trace_scene_to_text_impl<P>(
420        &self,
421        camera: &Camera,
422        line_ending: &str,
423        stream: &mut dyn fmt::Write,
424    ) -> Result<RaytraceInfo, fmt::Error>
425    where
426        P: Accumulate<BlockData = D> + Into<String> + Default,
427    {
428        let mut total_info = RaytraceInfo::default();
429
430        let viewport = camera.viewport();
431        let viewport_size = viewport.framebuffer_size.to_usize();
432        for ych in 0..viewport_size.height {
433            let y = viewport.normalize_fb_y(ych);
434            for xch in 0..viewport_size.width {
435                let x = viewport.normalize_fb_x(xch);
436                let mut buf = P::default();
437                let info = self.trace_ray::<P>(
438                    camera.project_ndc_into_world(NdcPoint2::new(x, y)),
439                    &mut buf,
440                    true,
441                );
442                total_info += info;
443                stream.write_str(buf.into().as_str())?;
444            }
445            stream.write_str(line_ending)?;
446        }
447
448        Ok(total_info)
449    }
450}
451
452// manual impl avoids `D: Debug` bound and avoids printing the entire grid
453impl<D> fmt::Debug for SpaceRaytracer<D>
454where
455    D: RtBlockData<Options: fmt::Debug>,
456{
457    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
458        f.debug_struct("SpaceRaytracer")
459            .field("blocks.len", &self.blocks.len())
460            .field("cubes.bounds", &self.cubes.bounds())
461            .field("graphics_options", &self.graphics_options)
462            .field("custom_options", &self.custom_options)
463            .field("sky", &self.sky)
464            .finish_non_exhaustive()
465    }
466}
467
468fn mix4(a: [f32; 4], b: [f32; 4], amount: f32) -> [f32; 4] {
469    core::array::from_fn(|i| {
470        let a = a[i];
471        let b = b[i];
472        a + (b - a) * amount
473    })
474}
475
476// -------------------------------------------------------------------------------------------------
477
478/// Performance info from a [`SpaceRaytracer`] operation.
479///
480/// The contents of this structure are subject to change; use [`Debug`] to view it.
481/// The [`Default`] value is the zero value.
482#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
483#[non_exhaustive]
484pub struct RaytraceInfo {
485    cubes_traced: usize,
486}
487impl core::ops::Add for RaytraceInfo {
488    type Output = Self;
489    fn add(mut self, other: Self) -> Self {
490        self += other;
491        self
492    }
493}
494impl core::ops::AddAssign<RaytraceInfo> for RaytraceInfo {
495    fn add_assign(&mut self, other: Self) {
496        self.cubes_traced += other.cubes_traced;
497    }
498}
499impl core::iter::Sum for RaytraceInfo {
500    fn sum<I>(iter: I) -> Self
501    where
502        I: Iterator<Item = Self>,
503    {
504        let mut sum = Self::default();
505        for part in iter {
506            sum += part;
507        }
508        sum
509    }
510}
511
512impl Fmt<StatusText> for RaytraceInfo {
513    fn fmt(&self, fmt: &mut fmt::Formatter<'_>, _: &StatusText) -> fmt::Result {
514        let &Self { cubes_traced } = self;
515        write!(fmt, "Cubes traced: {cubes_traced}")
516    }
517}
518
519// -------------------------------------------------------------------------------------------------
520
521/// Get cube data out of [`Space`].
522#[inline]
523fn prepare_cubes(space: &space::Read<'_>) -> Vol<Box<[TracingCubeData]>> {
524    space.extract(space.bounds(), |e| TracingCubeData {
525        block_index: e.block_index(),
526        lighting: e.light(),
527        always_invisible: e.block_data().block() == &AIR,
528    })
529}
530
531#[derive(Clone, Debug)]
532struct TracingCubeData {
533    block_index: BlockIndex,
534    lighting: PackedLight,
535    /// True if the block is [`AIR`].
536    ///
537    /// This special information allows us to skip an indirect memory access in this
538    /// extremely common case. We could generalize it to any block which is fully
539    /// invisible, but only if *the block is not an indirection* since if it is, the
540    /// block data could change without signaling a cube change, and currently we don't
541    /// have a mechanism to obtain that information from the Space.
542    always_invisible: bool,
543}
544
545// -------------------------------------------------------------------------------------------------
546
547#[derive(Clone, Debug)]
548struct TracingBlock<D> {
549    block_data: D,
550    // TODO: `Evoxels` carries more data than we actually need (color). Experiment with using a packed format.
551    voxels: Evoxels,
552}
553
554impl<D: RtBlockData> TracingBlock<D> {
555    fn from_block(
556        options: RtOptionsRef<'_, D::Options>,
557        space_block_data: &SpaceBlockData,
558    ) -> Self {
559        TracingBlock {
560            block_data: D::from_block(options, space_block_data),
561            voxels: space_block_data.evaluated().voxels().clone(),
562        }
563    }
564}
565
566// -------------------------------------------------------------------------------------------------
567
568type BounceRng = rand::rngs::SmallRng;
569
570/// Holds an [`Accumulate`] and other per-ray state, and updates it
571/// according to the things it encounters.
572#[derive(Debug)]
573struct TracingState<'a, P: Accumulate> {
574    /// Conversion factor from raycaster `t` values to “true” [`Space`] distance values
575    /// where 1 unit = 1 block thickness.
576    t_to_absolute_distance: f64,
577
578    /// Conversion factor from raycaster `t` values to fractions of
579    /// `graphics_options.view_distance`.
580    t_to_view_distance: f32,
581
582    /// If fog is enabled, then this is what the light from the scene should be blended with.
583    distance_fog_light: Option<Rgb>,
584
585    /// Coefficient controlling fog density.
586    distance_fog_blend: f32,
587
588    /// Number of cubes traced through by primary rays -- controlled by the caller, so not
589    /// necessarily equal to the number of calls to [`Self::trace_through_surface()`].
590    primary_cubes_traced: usize,
591
592    /// Diagnostic info from secondary rays.
593    secondary_info: RaytraceInfo,
594
595    accumulator: &'a mut P,
596
597    /// *If* we are going to compute ray bounces, this RNG is used to decide which direction they
598    /// bounce.
599    ray_bounce_rng: Option<BounceRng>,
600}
601impl<P: Accumulate> TracingState<'_, P> {
602    #[inline]
603    fn count_step_should_stop(
604        &mut self,
605        options: RtOptionsRef<'_, <P::BlockData as RtBlockData>::Options>,
606    ) -> bool {
607        if self.primary_cubes_traced == 0 {
608            self.accumulator.add(Hit {
609                exception: Some(Exception::EnterSpace),
610                surface: Rgba::TRANSPARENT.into(),
611                t_distance: None,
612                block: &P::BlockData::exception(Exception::EnterSpace, options),
613                position: None,
614            });
615        }
616
617        self.primary_cubes_traced += 1;
618
619        // TODO: Ideally this constant would be configurable.
620        // It can't simply be `view_distance` since it counts voxel steps.
621        if self.primary_cubes_traced > 1000 {
622            // Abort excessively long traces.
623            self.accumulator.add(Hit {
624                exception: Some(Exception::Incomplete),
625                surface: Rgba::TRANSPARENT.into(),
626                t_distance: None,
627                block: &P::BlockData::exception(Exception::Incomplete, options),
628                position: None,
629            });
630            true
631        } else {
632            self.accumulator.opaque()
633        }
634    }
635
636    fn finish(
637        self,
638        sky_color: Rgba,
639        sky_data: &P::BlockData,
640        debug_steps: bool,
641        options: RtOptionsRef<'_, <P::BlockData as RtBlockData>::Options>,
642    ) -> RaytraceInfo {
643        // TODO: The Accumulator should probably be allowed to tell the presence of sky.
644        // Right now, since finish() (this function) is called regardless of whether
645        // include_sky is true, we *always* give the accumulator this infinite hit even if
646        // `sky_color` is transparent via `include_sky` being false.
647        self.accumulator.add(Hit {
648            exception: Some(Exception::Sky),
649            surface: sky_color.into(),
650            t_distance: Some(f64::INFINITY),
651            block: sky_data,
652            position: None,
653        });
654
655        // Debug visualization of number of raytracing steps.
656        if debug_steps {
657            self.accumulator.add(Hit {
658                exception: Some(Exception::DebugOverrideRg),
659                surface: (rgb_const!(0.02, 0.002, 0.0) * self.primary_cubes_traced as f32)
660                    .with_alpha_one()
661                    .into(),
662                t_distance: None,
663                block: &P::BlockData::exception(Exception::DebugOverrideRg, options),
664                position: None,
665            });
666        }
667
668        RaytraceInfo {
669            cubes_traced: self.primary_cubes_traced,
670        } + self.secondary_info
671    }
672
673    /// Apply the effect of a given surface color.
674    #[inline]
675    fn trace_through_surface(
676        &mut self,
677        surface: &Surface<'_, P::BlockData>,
678        rt: &SpaceRaytracer<P::BlockData>,
679    ) {
680        if let Some((light, info)) = surface.to_light(rt, self) {
681            self.accumulator.add(Hit {
682                exception: None,
683                surface: light,
684                t_distance: Some(surface.t_distance),
685                block: surface.block_data,
686                position: Some(Position {
687                    cube: surface.cube,
688                    resolution: surface.voxel.0,
689                    voxel: surface.voxel.1,
690                    face: surface.normal,
691                }),
692            });
693            self.secondary_info += info;
694        }
695    }
696
697    #[inline]
698    fn trace_through_span(
699        &mut self,
700        span: Span<'_, P::BlockData>,
701        rt: &SpaceRaytracer<P::BlockData>,
702    ) {
703        let Span {
704            mut surface,
705            exit_t_distance,
706        } = span;
707
708        let thickness =
709            ((exit_t_distance - surface.t_distance) * self.t_to_absolute_distance) as f32;
710
711        // Adjust colors for the thickness
712        let (adjusted_color, emission_coeff) =
713            apply_transmittance(surface.diffuse_color, thickness);
714        surface.diffuse_color = adjusted_color;
715        surface.emission = surface.emission * emission_coeff;
716
717        self.trace_through_surface(&surface, rt);
718    }
719}
720
721// -------------------------------------------------------------------------------------------------
722
723/// Given an `Atom`/`Evoxel` color, and the thickness of that material passed through,
724/// return the effective alpha that should replace the original, and the coefficient for
725/// scaling the light emission.
726#[inline]
727fn apply_transmittance(color: Rgba, thickness: f32) -> (Rgba, f32) {
728    // Distance calculations might produce small negative values; tolerate this.
729    let thickness = thickness.max(0.0);
730
731    // If the thickness is zero and the alpha is one, this is theoretically undefined.
732    // In practice, thickness has error, so we want to count this as if it were a small
733    // nonzero thickness.
734    if thickness == 0.0 {
735        return if color.fully_opaque() {
736            (color, 1.0)
737        } else {
738            (Rgba::TRANSPARENT, 0.0)
739        };
740    }
741
742    // Convert alpha to transmittance (light transmitted / light received).
743    let unit_transmittance = 1.0 - color.clamp().alpha().into_inner();
744    // Adjust transmittance for the thickness relative to an assumed 1.0 thickness.
745    let depth_transmittance = unit_transmittance.powf(thickness);
746    // Convert back to alpha.
747    // TODO: skip NaN check ... this may require refactoring Surface usage.
748    // We might also benefit from an "UncheckedRgba" concept.
749    let alpha = ZeroOne::<f32>::new_clamped(1.0 - depth_transmittance);
750    let modified_color = color.to_rgb().with_alpha(alpha);
751
752    // Compute how the emission should be scaled to account for internal absorption and thickness.
753    // Since voxel emission is defined as “emitted from the surface of a unit-thickness layer”,
754    // the emission per length must be *greater* the more opaque the material is,
755    // and yet also it is reduced the deeper we go.
756    // This formula is the integral of that process.
757    let emission_coeff = if unit_transmittance == 1.0 {
758        // This is the integral
759        //     ∫{0..thickness} unit_transmittance^x dx
760        //   = ∫{0..thickness} 1 dx
761        thickness
762    } else {
763        // This is the integral
764        //     ∫{0..thickness} unit_transmittance^x dx
765        // in the case where `unit_transmittance` is not equal to 1.
766        (depth_transmittance - 1.) / (unit_transmittance - 1.)
767    };
768
769    (modified_color, emission_coeff.max(0.0))
770}
771
772// -------------------------------------------------------------------------------------------------
773
774/// Minimal raytracing helper used by block evaluation to compute aggregate properties
775/// of voxel blocks. Compared to the regular raytracer, it:
776///
777/// * Traces through `Evoxel`s instead of a `SpaceRaytracer`.
778/// * Follows an axis-aligned ray only.
779///
780/// `origin` should be the first cube to trace through *within* the grid.
781pub(crate) fn trace_for_eval(
782    voxels: &Evoxels,
783    origin: Cube,
784    direction: Face6,
785    resolution: Resolution,
786) -> EvalTrace {
787    let thickness = resolution.recip_f32();
788    let step = direction.normal_vector();
789
790    let mut cube = origin;
791    let mut color_buf = ColorBuf::default();
792    let mut emission = Vector3D::zero();
793
794    while let Some(voxel) = voxels.get(cube) {
795        let (adjusted_color, emission_coeff) = apply_transmittance(voxel.color, thickness);
796        emission += Vector3D::from(voxel.emission * emission_coeff) * color_buf.transmittance;
797        color_buf.add(Hit {
798            exception: None,
799            surface: adjusted_color.into(),
800            t_distance: None, // we could compute this but it is not used
801            block: &(),
802            position: None, // we could compute this but it is not used
803        });
804
805        if color_buf.opaque() {
806            break;
807        }
808        cube += step;
809    }
810    EvalTrace {
811        color: color_buf.into(),
812        emission,
813    }
814}
815
816#[derive(Clone, Copy)]
817pub(crate) struct EvalTrace {
818    pub color: Rgba,
819    pub emission: Vector3D<f32, Intensity>,
820}
821
822// -------------------------------------------------------------------------------------------------
823
824#[cfg(feature = "auto-threads")]
825mod rayon_helper {
826    use core::iter::{Sum, empty, once};
827    use rayon::iter::{IntoParallelIterator, ParallelExtend, ParallelIterator as _};
828
829    /// Implements [`ParallelExtend`] to just sum things, so that
830    /// [`ParallelIterator::unzip`] can produce a sum.
831    #[derive(Clone, Copy, Debug, Default)]
832    pub(crate) struct ParExtSum<T>(Option<T>);
833
834    impl<T: Sum> ParExtSum<T> {
835        pub fn result(self) -> T {
836            self.0.unwrap_or_else(|| empty().sum())
837        }
838    }
839
840    impl<T: Sum + Send> ParallelExtend<T> for ParExtSum<T> {
841        fn par_extend<I>(&mut self, par_iter: I)
842        where
843            I: IntoParallelIterator<Item = T>,
844        {
845            let new = par_iter.into_par_iter().sum();
846            // The reason we use an `Option` at all is to make it possible to move the current
847            // value.
848            self.0 = Some(match self.0.take() {
849                None => new,
850                Some(previous) => once(previous).chain(once(new)).sum(),
851            });
852        }
853    }
854}
855
856// -------------------------------------------------------------------------------------------------
857
858/// Note: Further raytracer tests are found in
859///
860/// * child modules (particularly raytracer/text.rs)
861/// * the test-renderers package which does image comparison tests.
862#[cfg(test)]
863mod tests {
864    use super::*;
865    use crate::math::rgba_const;
866
867    #[test]
868    fn apply_transmittance_identity() {
869        let color = rgba_const!(1.0, 0.5, 0.0, 0.5);
870        assert_eq!(apply_transmittance(color, 1.0), (color, 1.0));
871    }
872
873    /// `apply_transmittance` + `ColorBuf` accumulation should add up to the identity function for
874    /// any unit thickness (except for rounding error, which we are avoiding for this test case).
875    ///
876    /// TODO: test emission equivalence too
877    #[test]
878    fn apply_transmittance_equivalence() {
879        fn case(color: Rgba, count: usize) {
880            let (modified_color, _emission_coeff) =
881                apply_transmittance(color, (count as f32).recip());
882            let mut color_buf = ColorBuf::default();
883            for _ in 0..count {
884                color_buf.add(Hit {
885                    exception: None,
886                    surface: modified_color.into(),
887                    t_distance: None,
888                    block: &(),
889                    position: None,
890                });
891            }
892            let actual = Rgba::from(color_buf);
893            let error: Vec<f32> = <[f32; 4]>::from(actual)
894                .into_iter()
895                .zip(<[f32; 4]>::from(color))
896                .map(|(a, b)| a - b)
897                .collect();
898            assert!(
899                error.iter().sum::<f32>() < 0.00001,
900                "count {count}, color {color:?}, actual {actual:?}, error {error:?}"
901            );
902        }
903
904        let color = rgba_const!(1.0, 0.5, 0.0, 0.5);
905        case(color, 1);
906        case(color, 2);
907        case(color, 8);
908    }
909
910    /// Regression test for numerical error actually encountered.
911    #[test]
912    fn apply_transmittance_negative_thickness_transparent() {
913        assert_eq!(
914            apply_transmittance(rgba_const!(1.0, 0.5, 0.0, 0.5), -0.125),
915            (Rgba::TRANSPARENT, 0.0)
916        );
917    }
918    #[test]
919    fn apply_transmittance_negative_thickness_opaque() {
920        let color = rgba_const!(1.0, 0.5, 0.0, 1.0);
921        assert_eq!(apply_transmittance(color, -0.125), (color, 1.0));
922    }
923
924    #[test]
925    fn apply_transmittance_zero_thickness_transparent() {
926        assert_eq!(
927            apply_transmittance(rgba_const!(1.0, 0.5, 0.0, 0.5), 0.0),
928            (Rgba::TRANSPARENT, 0.0)
929        );
930    }
931    #[test]
932    fn apply_transmittance_zero_thickness_opaque() {
933        let color = rgba_const!(1.0, 0.5, 0.0, 1.0);
934        assert_eq!(apply_transmittance(color, 0.0), (color, 1.0));
935    }
936}