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        match Self::new(min_y, height) {
46            Ok(sources) => sources,
47            Err(error) => panic!("invalid world height for skylight sources: {error:?}"),
48        }
49    }
50
51    /// Fills this cache from a chunk's sections.
52    pub fn fill_from_sections(&mut self, sections: &Sections) {
53        let Some(top_section_index) = sections
54            .sections
55            .iter()
56            .rposition(|section| !section.read().is_empty())
57        else {
58            self.fill(self.min_y);
59            return;
60        };
61
62        for z in 0..CHUNK_EDGE {
63            for x in 0..CHUNK_EDGE {
64                let initial_edge_y = self.find_lowest_source_y(sections, top_section_index, x, z);
65                self.set(Self::index(x, z), initial_edge_y.max(self.min_y));
66            }
67        }
68    }
69
70    /// Updates one column after a block change.
71    ///
72    /// `state_at` is called with section-local X/Z and world Y coordinates.
73    /// Returns true when the cached source edge changed.
74    pub fn update(
75        &mut self,
76        x: usize,
77        y: i32,
78        z: usize,
79        mut state_at: impl FnMut(usize, i32, usize) -> BlockStateId,
80    ) -> bool {
81        debug_assert!(x < CHUNK_EDGE);
82        debug_assert!(z < CHUNK_EDGE);
83
84        let Some(upper_edge_y) = y.checked_add(1) else {
85            return false;
86        };
87        let index = Self::index(x, z);
88        let current_lowest_source_y = self.get(index);
89        if upper_edge_y < current_lowest_source_y {
90            return false;
91        }
92
93        let top_state = state_at(x, upper_edge_y, z);
94        let middle_state = state_at(x, y, z);
95        if self.update_edge(
96            index,
97            current_lowest_source_y,
98            x,
99            z,
100            upper_edge_y,
101            top_state,
102            y,
103            middle_state,
104            &mut state_at,
105        ) {
106            return true;
107        }
108
109        let Some(bottom_y) = y.checked_sub(1) else {
110            return false;
111        };
112        let bottom_state = state_at(x, bottom_y, z);
113        self.update_edge(
114            index,
115            current_lowest_source_y,
116            x,
117            z,
118            y,
119            middle_state,
120            bottom_y,
121            bottom_state,
122            &mut state_at,
123        )
124    }
125
126    /// Returns the lowest skylight source Y for a local X/Z column.
127    #[must_use]
128    pub const fn get_lowest_source_y(&self, x: usize, z: usize) -> i32 {
129        self.extend_sources_below_world(self.get(Self::index(x, z)))
130    }
131
132    /// Returns the highest cached lowest-source Y across all columns.
133    #[must_use]
134    pub fn get_highest_lowest_source_y(&self) -> i32 {
135        let mut max_value = NEGATIVE_INFINITY;
136        for value in self.heightmap {
137            if value > max_value {
138                max_value = value;
139            }
140        }
141        self.extend_sources_below_world(max_value)
142    }
143
144    fn find_lowest_source_y(
145        &self,
146        sections: &Sections,
147        top_section_index: usize,
148        x: usize,
149        z: usize,
150    ) -> i32 {
151        let mut top_y =
152            Self::section_to_block_coord(self.section_y_from_index(top_section_index) + 1);
153        let mut bottom_y = top_y - 1;
154        let mut top_state = Self::air_state();
155
156        for section_index in (0..=top_section_index).rev() {
157            let section = sections.sections[section_index].read();
158            if section.is_empty() {
159                top_state = Self::air_state();
160                top_y = Self::section_to_block_coord(self.section_y_from_index(section_index));
161                bottom_y = top_y - 1;
162                continue;
163            }
164
165            for y in (0..CHUNK_EDGE).rev() {
166                let bottom_state = section.states.get(x, y, z);
167                if Self::is_edge_occluded(top_state, bottom_state) {
168                    return top_y;
169                }
170
171                top_state = bottom_state;
172                top_y = bottom_y;
173                bottom_y -= 1;
174            }
175        }
176
177        self.min_y
178    }
179
180    #[expect(
181        clippy::too_many_arguments,
182        reason = "mirrors vanilla's updateEdge inputs without bundling temporary positions"
183    )]
184    fn update_edge(
185        &mut self,
186        index: usize,
187        old_top_edge_y: i32,
188        x: usize,
189        z: usize,
190        checked_edge_y: i32,
191        top_state: BlockStateId,
192        bottom_y: i32,
193        bottom_state: BlockStateId,
194        state_at: &mut impl FnMut(usize, i32, usize) -> BlockStateId,
195    ) -> bool {
196        if Self::is_edge_occluded(top_state, bottom_state) {
197            if checked_edge_y > old_top_edge_y {
198                self.set(index, checked_edge_y);
199                return true;
200            }
201        } else if checked_edge_y == old_top_edge_y {
202            let new_source_y =
203                self.find_lowest_source_below(x, z, bottom_y, bottom_state, state_at);
204            self.set(index, new_source_y);
205            return true;
206        }
207
208        false
209    }
210
211    fn find_lowest_source_below(
212        &self,
213        x: usize,
214        z: usize,
215        start_y: i32,
216        start_state: BlockStateId,
217        state_at: &mut impl FnMut(usize, i32, usize) -> BlockStateId,
218    ) -> i32 {
219        let mut top_y = start_y;
220        let mut top_state = start_state;
221        let Some(mut bottom_y) = start_y.checked_sub(1) else {
222            return self.min_y;
223        };
224
225        while bottom_y >= self.min_y {
226            let bottom_state = state_at(x, bottom_y, z);
227            if Self::is_edge_occluded(top_state, bottom_state) {
228                return top_y;
229            }
230
231            top_state = bottom_state;
232            top_y = bottom_y;
233            let Some(next_bottom_y) = bottom_y.checked_sub(1) else {
234                break;
235            };
236            bottom_y = next_bottom_y;
237        }
238
239        self.min_y
240    }
241
242    fn is_edge_occluded(top_state: BlockStateId, bottom_state: BlockStateId) -> bool {
243        if bottom_state.get_light_dampening() != 0 {
244            return true;
245        }
246
247        light_face_occludes(top_state, bottom_state, Direction::Down)
248    }
249
250    fn fill(&mut self, lowest_source_y: i32) {
251        self.heightmap.fill(lowest_source_y);
252    }
253
254    const fn set(&mut self, index: usize, value: i32) {
255        self.heightmap[index] = value;
256    }
257
258    const fn get(&self, index: usize) -> i32 {
259        self.heightmap[index]
260    }
261
262    const fn extend_sources_below_world(&self, value: i32) -> i32 {
263        if value == self.min_y {
264            NEGATIVE_INFINITY
265        } else {
266            value
267        }
268    }
269
270    const fn section_y_from_index(&self, section_index: usize) -> i32 {
271        SectionPos::block_to_section_coord(self.min_y + 1) + section_index as i32
272    }
273
274    const fn section_to_block_coord(section_y: i32) -> i32 {
275        section_y << 4
276    }
277
278    const fn index(x: usize, z: usize) -> usize {
279        x + z * CHUNK_EDGE
280    }
281
282    fn air_state() -> BlockStateId {
283        vanilla_blocks::AIR.default_state()
284    }
285}