Skip to main content

steel_core/chunk/light/
sky_sources.rs

1use steel_registry::{blocks::block_state_ext::BlockStateExt, vanilla_blocks};
2use steel_utils::{BlockStateId, Direction, SectionPos};
3
4use crate::chunk::section::Sections;
5
6use super::{
7    CHUNK_COLUMN_COUNT, CHUNK_EDGE, LightSectionRangeError, NEGATIVE_INFINITY, light_face_occludes,
8};
9
10/// Per-chunk cache of the lowest skylight source edge in each X/Z column.
11///
12/// Vanilla stores this in a 256-entry `SimpleBitStorage`. Steel keeps absolute
13/// `i32` Y values instead; the cached semantics are the same, and this avoids a
14/// new bit-storage abstraction before another system needs one.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ChunkSkyLightSources {
17    min_y: i32,
18    pub(super) heightmap: [i32; CHUNK_COLUMN_COUNT],
19}
20
21impl ChunkSkyLightSources {
22    /// Creates an empty skylight-source cache for a level height.
23    pub const fn new(min_y: i32, height: i32) -> Result<Self, LightSectionRangeError> {
24        if height <= 0 || min_y.checked_sub(1).is_none() || min_y.checked_add(height).is_none() {
25            return Err(LightSectionRangeError { min_y, height });
26        }
27
28        let min_y = min_y - 1;
29        Ok(Self {
30            min_y,
31            heightmap: [min_y; CHUNK_COLUMN_COUNT],
32        })
33    }
34
35    /// Creates a cache for world heights already accepted by chunk construction.
36    ///
37    /// Invalid world heights are fatal because chunks and light sections cannot
38    /// be indexed coherently without a valid vertical range.
39    ///
40    /// # Panics
41    ///
42    /// Panics when the supplied world height cannot form a valid light-section range.
43    #[must_use]
44    pub fn for_valid_world_height(min_y: i32, height: i32) -> Self {
45        Self::new(min_y, height).expect("invalid world height for skylight sources")
46    }
47
48    /// Fills this cache from a chunk's sections.
49    pub fn fill_from_sections(&mut self, sections: &Sections) {
50        let Some(top_section_index) = sections
51            .sections
52            .iter()
53            .rposition(|section| !section.read().is_empty())
54        else {
55            self.fill(self.min_y);
56            return;
57        };
58
59        for z in 0..CHUNK_EDGE {
60            for x in 0..CHUNK_EDGE {
61                let initial_edge_y = self.find_lowest_source_y(sections, top_section_index, x, z);
62                self.set(Self::index(x, z), initial_edge_y.max(self.min_y));
63            }
64        }
65    }
66
67    /// Updates one column after a block change.
68    ///
69    /// `state_at` is called with section-local X/Z and world Y coordinates.
70    /// Returns true when the cached source edge changed.
71    pub fn update(
72        &mut self,
73        x: usize,
74        y: i32,
75        z: usize,
76        mut state_at: impl FnMut(usize, i32, usize) -> BlockStateId,
77    ) -> bool {
78        debug_assert!(x < CHUNK_EDGE);
79        debug_assert!(z < CHUNK_EDGE);
80
81        let Some(upper_edge_y) = y.checked_add(1) else {
82            return false;
83        };
84        let index = Self::index(x, z);
85        let current_lowest_source_y = self.get(index);
86        if upper_edge_y < current_lowest_source_y {
87            return false;
88        }
89
90        let top_state = state_at(x, upper_edge_y, z);
91        let middle_state = state_at(x, y, z);
92        if self.update_edge(
93            index,
94            current_lowest_source_y,
95            x,
96            z,
97            upper_edge_y,
98            top_state,
99            y,
100            middle_state,
101            &mut state_at,
102        ) {
103            return true;
104        }
105
106        let Some(bottom_y) = y.checked_sub(1) else {
107            return false;
108        };
109        let bottom_state = state_at(x, bottom_y, z);
110        self.update_edge(
111            index,
112            current_lowest_source_y,
113            x,
114            z,
115            y,
116            middle_state,
117            bottom_y,
118            bottom_state,
119            &mut state_at,
120        )
121    }
122
123    /// Returns the lowest skylight source Y for a local X/Z column.
124    #[must_use]
125    pub const fn get_lowest_source_y(&self, x: usize, z: usize) -> i32 {
126        self.extend_sources_below_world(self.get(Self::index(x, z)))
127    }
128
129    /// Returns the highest cached lowest-source Y across all columns.
130    #[must_use]
131    pub fn get_highest_lowest_source_y(&self) -> i32 {
132        let mut max_value = NEGATIVE_INFINITY;
133        for value in self.heightmap {
134            if value > max_value {
135                max_value = value;
136            }
137        }
138        self.extend_sources_below_world(max_value)
139    }
140
141    fn find_lowest_source_y(
142        &self,
143        sections: &Sections,
144        top_section_index: usize,
145        x: usize,
146        z: usize,
147    ) -> i32 {
148        let mut top_y =
149            Self::section_to_block_coord(self.section_y_from_index(top_section_index) + 1);
150        let mut bottom_y = top_y - 1;
151        let mut top_state = Self::air_state();
152
153        for section_index in (0..=top_section_index).rev() {
154            let section = sections.sections[section_index].read();
155            if section.is_empty() {
156                top_state = Self::air_state();
157                top_y = Self::section_to_block_coord(self.section_y_from_index(section_index));
158                bottom_y = top_y - 1;
159                continue;
160            }
161
162            for y in (0..CHUNK_EDGE).rev() {
163                let bottom_state = section.states().get(x, y, z);
164                if Self::is_edge_occluded(top_state, bottom_state) {
165                    return top_y;
166                }
167
168                top_state = bottom_state;
169                top_y = bottom_y;
170                bottom_y -= 1;
171            }
172        }
173
174        self.min_y
175    }
176
177    #[expect(
178        clippy::too_many_arguments,
179        reason = "mirrors vanilla's updateEdge inputs without bundling temporary positions"
180    )]
181    fn update_edge(
182        &mut self,
183        index: usize,
184        old_top_edge_y: i32,
185        x: usize,
186        z: usize,
187        checked_edge_y: i32,
188        top_state: BlockStateId,
189        bottom_y: i32,
190        bottom_state: BlockStateId,
191        state_at: &mut impl FnMut(usize, i32, usize) -> BlockStateId,
192    ) -> bool {
193        if Self::is_edge_occluded(top_state, bottom_state) {
194            if checked_edge_y > old_top_edge_y {
195                self.set(index, checked_edge_y);
196                return true;
197            }
198        } else if checked_edge_y == old_top_edge_y {
199            let new_source_y =
200                self.find_lowest_source_below(x, z, bottom_y, bottom_state, state_at);
201            self.set(index, new_source_y);
202            return true;
203        }
204
205        false
206    }
207
208    fn find_lowest_source_below(
209        &self,
210        x: usize,
211        z: usize,
212        start_y: i32,
213        start_state: BlockStateId,
214        state_at: &mut impl FnMut(usize, i32, usize) -> BlockStateId,
215    ) -> i32 {
216        let mut top_y = start_y;
217        let mut top_state = start_state;
218        let Some(mut bottom_y) = start_y.checked_sub(1) else {
219            return self.min_y;
220        };
221
222        while bottom_y >= self.min_y {
223            let bottom_state = state_at(x, bottom_y, z);
224            if Self::is_edge_occluded(top_state, bottom_state) {
225                return top_y;
226            }
227
228            top_state = bottom_state;
229            top_y = bottom_y;
230            let Some(next_bottom_y) = bottom_y.checked_sub(1) else {
231                break;
232            };
233            bottom_y = next_bottom_y;
234        }
235
236        self.min_y
237    }
238
239    fn is_edge_occluded(top_state: BlockStateId, bottom_state: BlockStateId) -> bool {
240        if bottom_state.get_light_dampening() != 0 {
241            return true;
242        }
243
244        light_face_occludes(top_state, bottom_state, Direction::Down)
245    }
246
247    fn fill(&mut self, lowest_source_y: i32) {
248        self.heightmap.fill(lowest_source_y);
249    }
250
251    const fn set(&mut self, index: usize, value: i32) {
252        self.heightmap[index] = value;
253    }
254
255    const fn get(&self, index: usize) -> i32 {
256        self.heightmap[index]
257    }
258
259    const fn extend_sources_below_world(&self, value: i32) -> i32 {
260        if value == self.min_y {
261            NEGATIVE_INFINITY
262        } else {
263            value
264        }
265    }
266
267    const fn section_y_from_index(&self, section_index: usize) -> i32 {
268        SectionPos::block_to_section_coord(self.min_y + 1) + section_index as i32
269    }
270
271    const fn section_to_block_coord(section_y: i32) -> i32 {
272        section_y << 4
273    }
274
275    const fn index(x: usize, z: usize) -> usize {
276        x + z * CHUNK_EDGE
277    }
278
279    fn air_state() -> BlockStateId {
280        vanilla_blocks::AIR.default_state()
281    }
282}