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}