all_is_cubes/space/light/
updater.rs

1//! Lighting algorithms for `Space`. This module is closely tied to `Space`
2//! and separated out for readability, not modularity.
3
4use alloc::boxed::Box;
5use alloc::vec::Vec;
6use core::cmp::Ordering;
7use core::{fmt, mem};
8
9use bevy_ecs::prelude as ecs;
10use manyfmt::Fmt;
11
12#[cfg(feature = "auto-threads")]
13use rayon::iter::{IntoParallelRefMutIterator as _, ParallelIterator as _};
14
15use super::debug::LightComputeOutput;
16use crate::block::{self, EvaluatedBlock};
17use crate::math::{
18    Cube, CubeFace, Face6, Face7, FaceMap, OpacityCategory, PositiveSign, Rgb, Rgba, Vol,
19};
20use crate::space::light::debug::LightUpdateRayInfo;
21use crate::space::light::{LightUpdateQueue, LightUpdateRequest, Priority, chart};
22use crate::space::palette::Palette;
23use crate::space::{
24    BlockIndex, BlockSky, ChangeBuffer, GridAab, LightPhysics, LightStatus, PackedLight,
25    PackedLightScalar, Sky, SpaceChange, SpacePhysics,
26};
27use crate::time;
28use crate::util::StatusText;
29
30/// Storage and update queue for a [`Space`]'s light.
31///
32/// Design note: Currently this is simply owned by the ECS, but eventually we want to make
33/// it accessible by a background thread which can continuously update it.
34#[derive(ecs::Component)]
35pub(crate) struct LightStorage {
36    /// Per-cube light data.
37    ///
38    /// The bounds of this are always either equal to the owning [`Space`]'s,
39    /// or zero if light is disabled. Therefore, the same cube indices may be used.
40    // ---
41    // TODO: stop making this pub
42    pub(crate) contents: Vol<Box<[PackedLight]>>,
43
44    /// Queue of cubes whose light values should be updated.
45    pub(in crate::space) light_update_queue: LightUpdateQueue,
46
47    /// Debug log of the updated cubes from last frame.
48    /// Empty unless this debug function is enabled.
49    pub(crate) last_light_updates: Vec<Cube>,
50
51    /// Estimated ratio of (wall-time seconds / light update cost units).
52    light_cost_scale: f32,
53
54    /// Redundant copy of `SpacePhysics::light` for local access.
55    /// The Space will keep this updated via  [`Self::reinitialize_for_physics_change()`].
56    physics: LightPhysics,
57
58    pub(in crate::space) sky: Sky,
59    pub(in crate::space) block_sky: BlockSky,
60}
61
62/// Methods on Space that specifically implement the lighting algorithm.
63impl LightStorage {
64    pub(crate) fn new(
65        physics: &SpacePhysics,
66        contents: Vol<Box<[PackedLight]>>,
67        light_update_queue: LightUpdateQueue,
68    ) -> Self {
69        Self {
70            contents,
71            light_update_queue,
72            last_light_updates: Vec::new(),
73            light_cost_scale: 1e-6,
74            physics: physics.light.clone(),
75            sky: physics.sky.clone(),
76            block_sky: physics.sky.for_blocks(),
77        }
78    }
79
80    pub(in crate::space) fn maybe_reinitialize_for_physics_change(
81        &mut self,
82        uc: UpdateCtx<'_>,
83        physics: &SpacePhysics,
84        opacity: OpacityCategory,
85    ) {
86        let old_physics = mem::replace(&mut self.physics, physics.light.clone());
87        self.sky = physics.sky.clone();
88        self.block_sky = physics.sky.for_blocks();
89
90        if self.physics != old_physics {
91            // TODO: If the new physics is broadly similar, then reuse the old data as a
92            // starting point instead of immediately throwing it out.
93            // TODO: propagate allocation failure cleanly instead of unwrap()
94            self.contents = self
95                .physics
96                .initialize_lighting(uc.contents.without_elements(), opacity)
97                .unwrap();
98
99            match self.physics {
100                LightPhysics::None => {
101                    self.light_update_queue.clear();
102                }
103                LightPhysics::Rays { .. } => {
104                    self.fast_evaluate_light(uc);
105                }
106            }
107        } else {
108            // TODO: if only sky color is different, trigger light updates
109        }
110    }
111
112    pub(crate) fn light_needs_update(&mut self, cube: Cube, priority: Priority) {
113        if self.contents.bounds().contains_cube(cube) {
114            self.light_update_queue.insert(LightUpdateRequest { priority, cube });
115        }
116    }
117
118    #[mutants::skip] // lots of ways for this to still work when modified
119    pub(crate) fn light_needs_update_in_region(&mut self, region: GridAab, priority: Priority) {
120        let Some(region) = region.intersection_cubes(self.contents.bounds()) else {
121            return;
122        };
123        if region.volume().is_none_or(|v| v > 400) {
124            self.light_update_queue.sweep(region, priority);
125        } else {
126            for cube in region.interior_iter() {
127                self.light_needs_update(cube, priority);
128            }
129        }
130    }
131
132    pub(in crate::space) fn modified_cube_needs_update(
133        &mut self,
134        uc: UpdateCtx<'_>,
135        change_buffer: &mut ChangeBuffer<'_>,
136        cube: Cube,
137        evaluated: &EvaluatedBlock,
138        contents_index: usize,
139    ) {
140        if self.physics == LightPhysics::None {
141            return;
142        }
143
144        if opaque_for_light_computation(evaluated) {
145            // Since we already have the information, immediately update light value
146            // to zero rather than putting it in the queue.
147            // (It would be mostly okay to skip doing this entirely, but doing it gives
148            // more determinism, and the old value could be temporarily revealed when
149            // the block is removed.)
150            self.contents.as_linear_mut()[contents_index] = PackedLight::OPAQUE;
151
152            // Cancel any previously scheduled light update.
153            // (Note: This does not empirically have any significant effect on overall
154            // lighting performance — these trivial updates are not most of the cost.
155            // But it'll at least save a little bit of memory.)
156            self.light_update_queue.remove(cube);
157
158            change_buffer.push(SpaceChange::CubeLight { cube });
159        } else {
160            self.light_needs_update(cube, Priority::NEWLY_VISIBLE);
161        }
162        for face in Face6::ALL {
163            if let Some(neighbor) = cube.checked_add(face.normal_vector()) {
164                // Perform neighbor light updates if they can be affected by us
165                if !uc.get_evaluated(neighbor).opaque()[face.opposite()] {
166                    self.light_needs_update(neighbor, Priority::NEWLY_VISIBLE);
167                }
168            }
169        }
170    }
171
172    #[allow(unused, reason = "currently only used on feature=save and tests")]
173    pub(crate) fn in_light_update_queue(&self, cube: Cube) -> bool {
174        self.light_update_queue.contains(cube)
175    }
176
177    /// Do some lighting updates.
178    pub(in crate::space) fn update_lighting_from_queue(
179        &mut self,
180        uc: UpdateCtx<'_>,
181        change_buffer: &mut ChangeBuffer<'_>,
182        budget: Option<time::Duration>,
183    ) -> LightUpdatesInfo {
184        let mut light_update_count: usize = 0;
185        self.last_light_updates.clear();
186        let mut max_difference: PackedLightScalar = 0;
187
188        if self.physics != LightPhysics::None && !budget.is_some_and(|d| d.is_zero()) {
189            let t0 = time::Instant::now();
190            let mut cost = 0;
191            // We convert the time budget to an arbitrary cost value in order to avoid
192            // the overhead of frequently making syscalls to check the clock.
193            //
194            // The unit of measure is one raycast step; other operations are arbitrarily assigned
195            // higher cost values. (TODO: Profile to assign more consistent cost values.)
196            //
197            // TODO: Is this worthwhile?
198            let max_cost = match budget {
199                Some(budget) => (budget.as_secs_f32() / self.light_cost_scale) as usize,
200                None => usize::MAX,
201            };
202
203            // TODO: More efficient threading. Instead of creating batches up front,
204            // run a continuous pipeline of calculations in the background and only apply
205            // them when we have this current opportunity.
206            // This will require making stored light data `Arc`ed and double-buffered or atomic,
207            // so it can be consulted by the calculation.
208            #[cfg(feature = "auto-threads")]
209            while self.light_update_queue.len() > 0 {
210                use core::array::from_fn;
211
212                enum Calc {
213                    None,
214                    In(LightUpdateRequest),
215                    Out(ComputedLight<()>),
216                }
217
218                // TODO: empirical tuning suggests that 128 is a good minimum batch size,
219                // but is too big for the amount of time we want to take
220                let mut data: [Calc; 32] =
221                    from_fn(|_| self.light_update_queue.pop().map_or(Calc::None, Calc::In));
222
223                data.par_iter_mut().for_each(|calc| {
224                    if let Calc::In(LightUpdateRequest { cube, .. }) = *calc {
225                        *calc = Calc::Out(self.compute_lighting(uc, cube));
226                    }
227                });
228                for calc in data {
229                    let output = match calc {
230                        Calc::None => continue,
231                        Calc::In(_) => unreachable!(),
232                        Calc::Out(output) => output,
233                    };
234
235                    if false {
236                        // Log cubes that were updated for debug visualization.
237                        self.last_light_updates.push(output.cube);
238                    }
239                    light_update_count += 1;
240                    let (difference, cube_cost) =
241                        self.apply_lighting_update(uc, change_buffer, output);
242                    max_difference = max_difference.max(difference);
243                    cost += cube_cost;
244                }
245
246                if cost >= max_cost {
247                    break;
248                }
249            }
250
251            #[cfg(not(feature = "auto-threads"))]
252            while let Some(LightUpdateRequest { cube, .. }) = self.light_update_queue.pop() {
253                if false {
254                    // Log cubes that were updated for debug visualization.
255                    self.last_light_updates.push(cube);
256                }
257                light_update_count += 1;
258
259                let computation = self.compute_lighting(uc, cube);
260
261                let (difference, cube_cost) =
262                    self.apply_lighting_update(uc, change_buffer, computation);
263                max_difference = max_difference.max(difference);
264                cost += cube_cost;
265                if cost >= max_cost {
266                    break;
267                }
268            }
269
270            let t1 = time::Instant::now();
271            let cost_scale = t1.saturating_duration_since(t0).as_secs_f32() / cost as f32;
272            if cost_scale.is_finite() {
273                // TODO(time-budget): don't let this grow or shrink too fast due to outliers
274                self.light_cost_scale = 0.125 * cost_scale + 0.875 * self.light_cost_scale;
275            }
276        }
277
278        #[allow(clippy::bool_to_int_with_if)]
279        LightUpdatesInfo {
280            total_spaces: 1,
281            active_spaces: if light_update_count > 0 { 1 } else { 0 },
282            update_count: light_update_count,
283            max_update_difference: max_difference,
284            queue_count: self.light_update_queue.len(),
285            max_queue_priority: self.light_update_queue.peek_priority(),
286        }
287    }
288
289    /// Given a fresh [`ComputedLight`], actually insert it into the space light data
290    /// and enqueue more cubes, then return a cost value accounting for the update.
291    #[inline]
292    fn apply_lighting_update(
293        &mut self,
294        uc: UpdateCtx<'_>,
295        change_buffer: &mut ChangeBuffer<'_>,
296        computation: ComputedLight<()>,
297    ) -> (PackedLightScalar, usize) {
298        let ComputedLight {
299            cube,
300            light: new_light_value,
301            dependencies,
302            mut cost,
303            debug: (),
304        } = computation;
305
306        let old_light_value: PackedLight = self.contents[cube];
307        // Compare and set new value. Note that we MUST compare only the packed value so
308        // that changes are detected in terms of that rounding, not float values.
309        let difference_priority = new_light_value.difference_priority(old_light_value);
310        if difference_priority > 0 {
311            cost += 200;
312            // TODO: compute volume index of the cube only once
313            self.contents[cube] = new_light_value;
314            change_buffer.push(SpaceChange::CubeLight { cube });
315
316            // If neighbors have missing (not just stale) light values, fill them in too.
317            for dir in Face6::ALL {
318                let neighbor_cube = cube + dir.normal_vector();
319                let Some(neighbor_light) = self.contents.get_mut(neighbor_cube) else {
320                    // neighbor is out of bounds
321                    continue;
322                };
323                match neighbor_light.status() {
324                    LightStatus::Uninitialized => {
325                        if *neighbor_light == new_light_value {
326                            continue;
327                        }
328                        if uc.get_evaluated(neighbor_cube).opaque() == FaceMap::splat(true) {
329                            // neighbor is fully opaque — don't light it
330                            continue;
331                        }
332                        *neighbor_light = PackedLight::guess(new_light_value.value());
333                        change_buffer.push(SpaceChange::CubeLight {
334                            cube: neighbor_cube,
335                        });
336                        // We don't put the neighbor on the update queue because it should
337                        // already be there.
338                    }
339                    LightStatus::Opaque | LightStatus::Visible | LightStatus::NoRays => {
340                        // Already valid, or possibly valid; do nothing.
341                    }
342                }
343            }
344
345            // The light algorithm, in its current form, can spend a very long time
346            // evaluating 1-unit differences and possibly even loop infinitely. As a
347            // pragmatic solution, don't bother queueing them at all. This means that
348            // there may be 1-unit random differences lying around, but then, the
349            // "reevaluate all the dependencies of the current evaluation" strategy
350            // is also not perfect at updating everything that theoretically should be,
351            // since the rays are not perfectly reciprocal.
352            if difference_priority > 1 {
353                let priority = Priority::from_difference(difference_priority);
354                for dep_cube in dependencies {
355                    self.light_needs_update(dep_cube, priority);
356                }
357            }
358        }
359        (difference_priority, cost)
360    }
361
362    /// Compute the new lighting value for a cube, returning it rather than storing it.
363    #[inline]
364    #[doc(hidden)] // pub to be used by all-is-cubes-gpu for debugging
365    pub(in crate::space) fn compute_lighting<D>(
366        &self,
367        uc: UpdateCtx<'_>,
368        cube: Cube,
369    ) -> ComputedLight<D>
370    where
371        D: LightComputeOutput,
372    {
373        let maximum_distance = match self.physics {
374            LightPhysics::None => 0,
375            LightPhysics::Rays { maximum_distance } => maximum_distance,
376        };
377        let mut cube_buffer = LightBuffer::new(maximum_distance);
378        let mut info_rays = D::RayInfoBuffer::default();
379
380        let ev_origin = uc.get_evaluated(cube);
381        let origin_is_opaque = ev_origin.opaque() == FaceMap::splat(true);
382        if origin_is_opaque {
383            // Opaque blocks are always dark inside — unless they are light sources.
384            if !opaque_for_light_computation(ev_origin) {
385                cube_buffer.add_weighted_light(ev_origin.light_emission(), 1.0);
386            }
387        } else {
388            let ev_neighbors =
389                FaceMap::from_fn(|face| uc.get_evaluated(cube + face.normal_vector()));
390            let direction_weights = directions_to_seek_light(ev_origin, ev_neighbors);
391
392            self.walk_ray_tree::<D>(
393                uc,
394                &mut info_rays,
395                &mut cube_buffer,
396                cube,
397                cube,
398                Face7::Within,
399                chart::get(),
400                0,
401                None,
402                LightRayState::new(direction_weights),
403            );
404        }
405
406        let new_light_value = cube_buffer.finish(origin_is_opaque);
407
408        ComputedLight {
409            cube,
410            light: new_light_value,
411            dependencies: cube_buffer.dependencies,
412            cost: cube_buffer.cost,
413            debug: D::new(cube, new_light_value, info_rays),
414        }
415    }
416
417    /// Traverse space according to the light propagation chart, which describes bundles of light rays.
418    ///
419    /// * `light_from_previous_cube` is the light value fetched, if it was, from the previous
420    ///   step (parent tree node).
421    /// * `ray_state` describes TODO
422    ///
423    /// Returns the total weight that was added to `cube_buffer.total_ray_weight`
424    #[expect(clippy::too_many_arguments)]
425    fn walk_ray_tree<D: LightComputeOutput>(
426        &self,
427        uc: UpdateCtx<'_>,
428        info_rays: &mut D::RayInfoBuffer,
429        cube_buffer: &mut LightBuffer,
430        origin_cube: Cube,
431        cube_entered: Cube,
432        face_entered: Face7,
433        chart: &[chart::FlatNode],
434        node_index: usize,
435        light_from_previous_cube: Option<PackedLight>,
436        mut ray_state: LightRayState,
437    ) -> f32 {
438        let node: &chart::FlatNode = &chart[node_index];
439
440        let ray_bundle_weight = (node.weight() * ray_state.direction_weights).sum();
441        if ray_bundle_weight <= 0.0 {
442            // This direction has zero effective weight, so it contributes nothing. Stop recursing.
443            return ray_bundle_weight;
444        }
445
446        let distance_squared = (cube_entered.center() - origin_cube.center()).square_length();
447        if distance_squared > cube_buffer.maximum_distance_squared {
448            cube_buffer.end_of_ray(
449                &ray_state,
450                &self.block_sky,
451                ray_bundle_weight,
452                node.weight(),
453            );
454            return ray_bundle_weight;
455        }
456
457        cube_buffer.cost += 1;
458        let Some(cube_entered_index) = self.contents.index(cube_entered) else {
459            // Stop (and display the sky) if we exit the space bounds.
460
461            // Rays that didn't hit anything close enough will be treated
462            // as sky. TODO: We should have a better policy in case of large
463            // indoor spaces.
464            cube_buffer.end_of_ray(
465                &ray_state,
466                &self.block_sky,
467                ray_bundle_weight,
468                node.weight(),
469            );
470            return ray_bundle_weight;
471        };
472
473        let mut light_ahead_cache = None;
474        cube_buffer.traverse::<D>(
475            &mut ray_state,
476            info_rays,
477            self,
478            CubeFace {
479                cube: cube_entered,
480                face: face_entered,
481            },
482            uc.get_evaluated_by_index(cube_entered_index),
483            &mut light_ahead_cache,
484            light_from_previous_cube,
485            node.weight(),
486        );
487        if ray_state.alpha.partial_cmp(&0.0) != Some(Ordering::Greater) {
488            cube_buffer.end_of_ray(
489                &ray_state,
490                &self.block_sky,
491                ray_bundle_weight,
492                node.weight(),
493            );
494            return ray_bundle_weight;
495        }
496
497        let mut child_weight_sum: f32 = 0.0;
498        for (child_direction, child_index) in node.children() {
499            if let Some(child_index) = child_index {
500                // Note we pass ray_state, *not* &mut ray_state.
501                // This way, each branch of the tree gets its own.
502                child_weight_sum += self.walk_ray_tree::<D>(
503                    uc,
504                    info_rays,
505                    cube_buffer,
506                    origin_cube,
507                    cube_entered + child_direction,
508                    child_direction.opposite().into(),
509                    chart,
510                    child_index.get() as usize,
511                    light_ahead_cache,
512                    ray_state,
513                );
514            }
515        }
516
517        // Some or all of the rays in the tree-branch/bundle may have ended.
518        // Their total weight is however much of this node’s weight is not accounted for
519        // by its children.
520        cube_buffer.end_of_ray(
521            &ray_state,
522            &self.block_sky,
523            (ray_bundle_weight - child_weight_sum).max(0.0),
524            node.weight(),
525        );
526        ray_bundle_weight
527    }
528
529    /// Clear and recompute light data and update queue, in a way which gets fast approximate
530    /// results suitable for flat landscapes mostly lit from above (the +Y axis).
531    ///
532    /// Does not send any change notifications.
533    ///
534    /// TODO: Revisit whether this is a good public API.
535    pub(in crate::space) fn fast_evaluate_light(&mut self, uc: UpdateCtx<'_>) {
536        self.light_update_queue.clear(); // Going to refill it
537
538        if self.physics == LightPhysics::None {
539            return;
540        }
541
542        let bounds = self.contents.bounds();
543        for x in bounds.x_range() {
544            for z in bounds.z_range() {
545                let mut covered = false;
546                for y in bounds.y_range().rev() {
547                    let cube = Cube::new(x, y, z);
548                    let index = self.contents.index(cube).unwrap();
549
550                    let this_cube_evaluated = uc.get_evaluated_by_index(index);
551                    self.contents.as_linear_mut()[index] =
552                        if opaque_for_light_computation(this_cube_evaluated) {
553                            covered = true;
554                            PackedLight::OPAQUE
555                        } else if this_cube_evaluated.visible_or_animated()
556                            || Face6::ALL.into_iter().any(|face| {
557                                uc.get_evaluated(cube + face.normal_vector()).visible_or_animated()
558                            })
559                        {
560                            // In this case (and only this case), we are guessing rather than being certain,
561                            // so we need to schedule a proper update.
562                            // (Bypassing `self.light_needs_update()` to skip bounds checks).
563                            self.light_update_queue.insert(LightUpdateRequest {
564                                priority: Priority::ESTIMATED,
565                                cube,
566                            });
567
568                            if covered {
569                                PackedLight::UNINITIALIZED_AND_BLACK
570                            } else {
571                                self.block_sky.in_direction(Face6::PY)
572                            }
573                        } else {
574                            // Not visible at all; does not interact with rays.
575                            PackedLight::NO_RAYS
576                        };
577                }
578            }
579        }
580    }
581
582    #[inline(always)]
583    pub fn get(&self, cube: Cube) -> PackedLight {
584        match self.physics {
585            LightPhysics::None => PackedLight::ONE,
586            _ => self
587                .contents
588                .get(cube)
589                .copied()
590                .unwrap_or_else(|| self.block_sky.light_outside(self.contents.bounds(), cube)),
591        }
592    }
593
594    #[cfg(test)]
595    pub fn consistency_check(&self) {
596        if self.physics == LightPhysics::None {
597            assert_eq!(self.contents.volume(), 0);
598        }
599
600        // TODO: validate light update queue
601        // - consistency with space bounds
602        // - contains all cubes with LightStatus::UNINIT
603    }
604}
605
606/// Argument passed to [`LightStorage`] methods to provide immutable and shareable access to the
607/// rest of the space. (Don't try to add any `&mut` references to this!)
608#[derive(Clone, Copy, Debug)]
609pub(in crate::space) struct UpdateCtx<'a> {
610    pub(in crate::space) contents: Vol<&'a [BlockIndex]>,
611    pub(in crate::space) palette: &'a Palette,
612}
613
614impl<'a> UpdateCtx<'a> {
615    fn get_evaluated(&self, cube: Cube) -> &'a EvaluatedBlock {
616        if let Some(&block_index) = self.contents.get(cube) {
617            self.palette.entry(block_index).evaluated()
618        } else {
619            block::AIR_EVALUATED_REF
620        }
621    }
622
623    fn get_evaluated_by_index(&self, cube_index: usize) -> &'a EvaluatedBlock {
624        self.palette.entry(self.contents.as_linear()[cube_index]).evaluated()
625    }
626}
627
628impl LightPhysics {
629    /// Generate the lighting data array that a [`Space`] with this light physics should have.
630    ///
631    /// `opacity` specifies Whether the blacks in the space are uniformly fully opaque or
632    /// uniformly fully transparent, in which case the initialization can be optimal.
633    ///
634    /// TODO: Also return whether light updates are needed.
635    pub(crate) fn initialize_lighting(
636        &self,
637        space_bounds: Vol<()>,
638        opacity: OpacityCategory,
639    ) -> Result<Vol<Box<[PackedLight]>>, crate::space::builder::Error> {
640        let (storage_bounds, value) = match self {
641            LightPhysics::None => (
642                GridAab::ORIGIN_EMPTY.to_vol().unwrap(),
643                PackedLight::UNINITIALIZED_AND_BLACK,
644            ),
645            LightPhysics::Rays { .. } => (
646                space_bounds,
647                match opacity {
648                    OpacityCategory::Invisible => PackedLight::NO_RAYS,
649                    OpacityCategory::Partial => PackedLight::UNINITIALIZED_AND_BLACK,
650                    OpacityCategory::Opaque => PackedLight::OPAQUE,
651                },
652            ),
653        };
654
655        // Vec::try_reserve is roundabout, but currently the only stable way to get fallible memory
656        // allocation for a slice.
657        let mut storage = Vec::new();
658        let volume = storage_bounds.volume();
659        storage
660            .try_reserve_exact(volume)
661            .map_err(|_| crate::space::builder::Error::OutOfMemory {})?;
662        storage.resize(volume, value);
663
664        Ok(Vol::with_elements(storage_bounds, storage.into_boxed_slice()).unwrap())
665    }
666}
667
668/// Given a block and its neighbors, which directions should we cast rays to find light
669/// falling on it?
670fn directions_to_seek_light(
671    origin: &EvaluatedBlock,
672    neighborhood: FaceMap<&EvaluatedBlock>,
673) -> FaceMap<f32> {
674    if origin.visible_or_animated() {
675        // Non-opaque blocks should work the same as blocks which have all six adjacent faces present.
676        FaceMap::splat(1.0)
677    } else {
678        FaceMap::from_fn(|face| {
679            // We want directions that either face away from visible faces, or towards light sources.
680            if neighborhood[face.opposite()].visible_or_animated()
681                || neighborhood[face].light_emission() != Rgb::ZERO
682            {
683                // TODO: Once we have fancier block opacity precomputations, use them to
684                // have weights besides 1.0
685                1.0f32
686            } else {
687                0.0
688            }
689        })
690    }
691}
692
693/// Accumulation buffer for the light falling on a single cube.
694#[derive(Debug)]
695struct LightBuffer {
696    /// Accumulator of incoming light encountered.
697    /// TODO: Make this a vector of `f32` to save NaN checks?
698    incoming_light: Rgb,
699    /// Number of rays, weighted by the ray angle versus local cube faces.
700    total_ray_weight: f32,
701    /// Cubes whose lighting value contributed to the `incoming_light` value.
702    dependencies: Vec<Cube>,
703    /// Approximation of CPU cost of doing the calculation, with one unit defined as
704    /// one raycast step.
705    cost: usize,
706
707    /// Maximum distance to traverse.
708    maximum_distance_squared: f64,
709}
710
711/// Companion to [`LightBuffer`] that tracks state for a ray-bundle that makes part of
712/// the sum.
713#[derive(Clone, Copy, Debug)]
714struct LightRayState {
715    /// Fraction of the light value that is to be determined by future, rather than past,
716    /// tracing; starts at 1.0 and decreases as opaque surfaces are encountered.
717    alpha: f32,
718
719    /// Weighting factors (from [`directions_to_seek_light`], currently either 1 or 0) that
720    /// are to be multiplied by the weights in the chart to determine the final weight.
721    ///
722    /// It is split up by faces because the light chart bundles many rays in different directions;
723    /// multiplying this by the chart's weights and summing produces the actual weight to use.
724    ///
725    /// Note that this is unrelated to alpha — it is *not* reduced by opacity.
726    /// It determines what proportion of the final light value is produced by this ray
727    /// relative to other rays.
728    direction_weights: FaceMap<f32>,
729}
730
731impl LightRayState {
732    /// * `ray_weight_by_faces`: how much influence this ray should have on the
733    ///   total illumination
734    fn new(direction_weights: FaceMap<f32>) -> Self {
735        LightRayState {
736            alpha: 1.0,
737            direction_weights,
738        }
739    }
740}
741
742impl LightBuffer {
743    fn new(maximum_distance: u8) -> Self {
744        let maximum_distance = f64::from(maximum_distance);
745        Self {
746            incoming_light: Rgb::ZERO,
747            total_ray_weight: 0.0,
748            dependencies: Vec::new(),
749            cost: 0,
750            maximum_distance_squared: maximum_distance * maximum_distance,
751        }
752    }
753
754    /// Process a ray (or bundle of rays) intersecting a single cube.
755    ///
756    /// The caller should check `ray_state.alpha` to decide when to stop calling this.
757    ///
758    /// Note: to avoid redundant lookups as a ray proceeds, `current_light` is used only to fill
759    /// `light_ahead_cache` or `light_behind_cache`.
760    #[inline]
761    #[expect(clippy::too_many_arguments)]
762    fn traverse<D>(
763        &mut self,
764        ray_state: &mut LightRayState,
765        info: &mut D::RayInfoBuffer,
766        current_light: &LightStorage,
767        hit: CubeFace,
768        ev_hit: &EvaluatedBlock,
769        light_ahead_cache: &mut Option<PackedLight>,
770        light_behind_cache: Option<PackedLight>,
771        chart_weights: FaceMap<f32>,
772    ) where
773        D: LightComputeOutput,
774    {
775        if !ev_hit.visible_or_animated() {
776            // Completely transparent block is passed through, disregarding its stored light
777            // (which it typically will not have).
778            return;
779        }
780
781        // Compute whether we hit an opaque face which should totally stop propagation.
782        //
783        // TODO: Also count the opacity of the face we *exited* of the previous block
784        // (but should we do that here?).
785        //
786        // Note that hit_opaque_face is not necessarily true when hit_alpha is 1.0, because
787        // the former is strict “watertightness” and the latter is merely an averaged image of
788        // the block. (TODO: But maybe we should always use hit_alpha anyway?)
789        let face_opacity = ev_hit.opaque();
790        let hit_opaque_face: bool = match Face6::try_from(hit.face) {
791            Ok(face) => face_opacity[face],
792            Err(_) => face_opacity == FaceMap::splat(true),
793        };
794
795        if hit_opaque_face && hit.face == Face7::Within {
796            // We are inside the block the ray started in. Don't use its existing light value!
797            // Just consider it a total absence of light.
798            // (TODO: In principle, a block could have light emission inside itself while being
799            // fully opaque, and we should be able to support that, but we're not trying for now.)
800            //
801            // (Note that not reading *transparent* block light is handled separately below.)
802
803            // Setting the weight to 0 cancels its future effect,
804            // and there were no past effects.
805            ray_state.direction_weights = FaceMap::splat(0.0);
806            ray_state.alpha = 0.0;
807            return;
808        }
809
810        let hit_surface_color: Rgba = ev_hit.face7_color(hit.face).clamp();
811        // The alpha of the hit block face is also what fraction of the light ray we assume to hit
812        // the block, as opposed to passing through it.
813        let hit_alpha: f32 = hit_surface_color.alpha().into_inner();
814
815        // On striking a (semi-)opaque block face, we use the light value from its
816        // adjacent cube as the light falling on, thus being reflected by, that face.
817        // The ray still might continue through.
818        if hit_alpha > 0.0 && hit.face != Face7::Within {
819            let light_cube = hit.adjacent();
820            let stored_light = light_behind_cache.unwrap_or_else(|| current_light.get(light_cube));
821
822            // Note: I tried stopping here if (!stored_light.valid() && hit_alpha == 1.0) in order
823            // to ignore not-yet-initialized light values from the weighting, but not only does that
824            // create unsightly incorrect bright areas (at least starting from
825            // `fast_evaluate_light()`), it also converges slower.
826
827            let light_from_struck_face =
828                ev_hit.light_emission() + hit_surface_color.reflect(stored_light.value());
829
830            self.incoming_light += light_from_struck_face
831                * ray_state.alpha
832                * (ray_state.direction_weights * chart_weights).sum();
833
834            self.cost += 10;
835            if self.dependencies.last() != Some(&light_cube) {
836                // add dep only if not already present from previous step's ahead
837                self.dependencies.push(light_cube);
838            }
839
840            // If we hit a truly light-proof face, it's the end of the ray.
841            if hit_opaque_face {
842                // This terminates the raycast; we don't bounce rays
843                // (diffuse reflections, not specular/mirror).
844                ray_state.alpha = 0.0;
845
846                // Diagnostics:
847                // Iff this is the hit that terminates the ray, record it.
848                // TODO: Record transparency too.
849                _ = info;
850                D::push_ray(info, || LightUpdateRayInfo {
851                    trigger_cube: hit.cube,
852                    value_cube: light_cube,
853                    value: stored_light,
854                    light_from_struck_face,
855                });
856            } else {
857                // Account for surface alpha in the future of this ray's state
858                ray_state.alpha *= 1.0 - hit_alpha;
859            }
860        }
861
862        // Block is partly transparent and light should be picked up from the block's cube itself,
863        // but the ray is also not stopped.
864        if hit_alpha < 1.0 {
865            let light_cube = hit.cube;
866
867            let stored_light = if hit.face == Face7::Within {
868                // Don't read the value we're trying to recalculate.
869                Rgb::ZERO
870            } else {
871                light_ahead_cache.get_or_insert_with(|| current_light.get(light_cube)).value()
872            };
873            // Note that light emission is *not* multiplied by the alpha, because alpha is about
874            // reflection/transmission. It's perfectly okay to have a totally transparent (alpha
875            // equals zero), yet emissive, block.
876            let light_from_traversed_block = ev_hit.light_emission() + stored_light * hit_alpha;
877            self.incoming_light += light_from_traversed_block
878                * ray_state.alpha
879                * (ray_state.direction_weights * chart_weights).sum();
880
881            self.cost += 10;
882            self.dependencies.push(light_cube);
883
884            ray_state.alpha *= 1.0 - hit_alpha;
885        }
886    }
887
888    /// The raycast exited the world or hit an opaque block; finish up by applying
889    /// sky and adding to the total weight.
890    #[inline]
891    fn end_of_ray(
892        &mut self,
893        ray_state: &LightRayState,
894        block_sky: &BlockSky,
895        ray_bundle_weight: f32,
896        chart_weights: FaceMap<f32>,
897    ) {
898        // TODO: set *info even if we hit the sky
899
900        // Note: this condition is key to allowing some cases to
901        // not count this as a successful ray.
902        // TODO: clarify signaling flow?
903        if ray_bundle_weight > 0. {
904            // Compute the incoming sky light using the BlockSky. Note: This is a flawed
905            // approximate sampling because it includes the entire face direction, even if the
906            // current ray bundle is a much narrower cone.
907            let sky_light: Rgb = (FaceMap::from_fn(|face| {
908                block_sky.in_direction(face).value() * (chart_weights[face])
909            }))
910            .sum()
911                * chart_weights.sum().recip();
912
913            // Note that if ray_state.alpha has reached zero, the sky color has no effect.
914            self.add_weighted_light(
915                // TODO: this is the wrong set of weights, we should be using the chart's weights
916                sky_light * ray_state.alpha,
917                ray_bundle_weight,
918            );
919        }
920    }
921
922    /// Add the given color to the sum counting it as having the weight of the given
923    /// [`LightRayState`].
924    ///
925    /// Note that this does not mean this is the sole contribution of that ray;
926    /// and it must be called only once per ray (to avoid double-counting weight)
927    /// even when the ray passes through transparent blocks that reflect or emit light.
928    fn add_weighted_light(&mut self, color: Rgb, weight: f32) {
929        self.incoming_light += color * weight;
930        self.total_ray_weight += weight;
931    }
932
933    /// Return the [`PackedLight`] value accumulated here
934    fn finish(&self, origin_is_opaque: bool) -> PackedLight {
935        // if total_ray_weight is zero then incoming_light is zero so the result will be zero.
936        // We just need to avoid dividing by zero.
937        let scale = PositiveSign::<f32>::new_clamped(1.0 / self.total_ray_weight.max(1.0));
938        let new_light_value: PackedLight = if self.total_ray_weight > 0.0 {
939            PackedLight::some(self.incoming_light * scale)
940        } else if origin_is_opaque {
941            PackedLight::OPAQUE
942        } else {
943            PackedLight::NO_RAYS
944        };
945        new_light_value
946    }
947}
948
949/// Result of [`Space::compute_lighting()`] — new light for one cube.
950/// TODO: better name
951#[derive(Clone, Debug)]
952#[doc(hidden)] // used for debug rendering
953#[expect(unnameable_types)]
954pub struct ComputedLight<D> {
955    pub cube: Cube,
956
957    pub light: PackedLight,
958    /// Cubes which the computed value depends on (imprecisely; empty cubes passed through
959    /// are not listed).
960    ///
961    /// Note: I tried making this allocation reused and it didn't help.
962    dependencies: Vec<Cube>,
963
964    cost: usize,
965
966    pub debug: D,
967}
968
969/// Performance data for bulk light updates.
970#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
971#[non_exhaustive]
972pub struct LightUpdatesInfo {
973    /// Number of spaces whose light data updates are aggregated in this data.
974    pub total_spaces: usize,
975    /// Number of spaces which had any light updates at all.
976    pub active_spaces: usize,
977    /// Number of cubes whose light data updates are aggregated in this data.
978    pub update_count: usize,
979    /// The largest change in light value that occurred.
980    pub max_update_difference: u8,
981    /// Number of entries in the light update queue.
982    pub queue_count: usize,
983    /// The largest update priority in the queue (corresponds to the size of
984    /// difference that caused the cube to be added).
985    pub(crate) max_queue_priority: Priority,
986}
987impl core::ops::AddAssign<LightUpdatesInfo> for LightUpdatesInfo {
988    #[mutants::skip] // hard to test completely
989    fn add_assign(&mut self, other: Self) {
990        let Self {
991            total_spaces,
992            active_spaces,
993            update_count,
994            max_update_difference,
995            queue_count,
996            max_queue_priority,
997        } = self;
998        *total_spaces += other.total_spaces;
999        *active_spaces += other.active_spaces;
1000        *update_count += other.update_count;
1001        *max_update_difference = (*max_update_difference).max(other.max_update_difference);
1002        *queue_count += other.queue_count;
1003        *max_queue_priority = (*max_queue_priority).max(other.max_queue_priority);
1004    }
1005}
1006impl Fmt<StatusText> for LightUpdatesInfo {
1007    fn fmt(&self, fmt: &mut fmt::Formatter<'_>, _: &StatusText) -> fmt::Result {
1008        let Self {
1009            total_spaces,
1010            active_spaces,
1011            update_count,
1012            max_update_difference,
1013            queue_count,
1014            max_queue_priority,
1015        } = self;
1016        write!(
1017            fmt,
1018            "{update_count:4} (max diff {max_update_difference:3}) of {queue_count:4} cubes \
1019            (max pri {max_queue_priority:3?}) \
1020            in {active_spaces}/{total_spaces} spaces",
1021        )?;
1022        Ok(())
1023    }
1024}
1025
1026/// A special definition of opacity for the lighting algorithm:
1027/// we want to treat opaque light-emitting blocks similarly to transparent blocks
1028/// *when deciding to compute light for them*, because this produces better results
1029/// for smooth (interpolated) lighting.
1030///
1031/// This function is fairly straightforward; it exists for purposes of *documenting
1032/// the places that care about this* rather than for code reduction.
1033pub(crate) fn opaque_for_light_computation(block: &EvaluatedBlock) -> bool {
1034    block.opaque() == FaceMap::splat(true) && block.light_emission() == Rgb::ZERO
1035}