Skip to main content

embedded_3dgfx/bsp/
builder.rs

1//! BSP content builder helpers (std-only authoring path).
2//!
3//! This module provides a small, deterministic pipeline helper for generating
4//! valid [`BspWorld`] data from high-level room
5//! descriptions. It is intended for offline/tooling and examples, not hot-path
6//! runtime generation on constrained devices.
7
8use std::ops::Range;
9use std::vec::Vec;
10
11use super::data::{BspWorld, Face, Leaf, Node, Plane};
12
13/// Axis-aligned room descriptor used by [`build_room_strip`].
14#[derive(Debug, Clone, Copy)]
15pub struct RoomSpec {
16    pub mins: [f32; 3],
17    pub maxs: [f32; 3],
18    pub floor_texture_id: u32,
19    pub ceiling_texture_id: u32,
20    /// `0xFFFF` means no baked lightmap.
21    pub lightmap_id: u16,
22}
23
24/// Build errors for BSP helper generation.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum BuildError {
27    NeedAtLeastOneRoom,
28    InvalidBounds,
29    RoomsNotSortedOrOverlappingX,
30    TooManyRooms,
31    TooManyNodes,
32    TooManyFaces,
33    TooManyMarksurfaces,
34}
35
36/// Owned BSP lump storage that can be borrowed as a [`BspWorld`].
37#[derive(Debug, Default)]
38pub struct OwnedBspWorld {
39    pub planes: Vec<Plane>,
40    pub nodes: Vec<Node>,
41    pub leaves: Vec<Leaf>,
42    pub faces: Vec<Face>,
43    pub marksurfaces: Vec<u16>,
44    pub vertices: Vec<[f32; 3]>,
45    pub uvs: Vec<[f32; 2]>,
46    pub lm_uvs: Vec<[f32; 2]>,
47    pub vis: Vec<u8>,
48    pub vis_offsets: Vec<u32>,
49    pub num_clusters: u16,
50}
51
52impl OwnedBspWorld {
53    /// Borrow owned lumps as a `BspWorld`.
54    pub fn as_world(&self) -> BspWorld<'_> {
55        BspWorld::new(
56            &self.planes,
57            &self.nodes,
58            &self.leaves,
59            &self.faces,
60            &self.marksurfaces,
61            &self.vertices,
62            &self.uvs,
63            &self.lm_uvs,
64            &self.vis,
65            &self.vis_offsets,
66            self.num_clusters,
67        )
68    }
69}
70
71#[inline]
72fn to_i16_clamped(v: f32) -> i16 {
73    v.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16
74}
75
76fn range_bounds_i16(rooms: &[RoomSpec], r: Range<usize>) -> ([i16; 3], [i16; 3]) {
77    let mut mins = [f32::INFINITY; 3];
78    let mut maxs = [f32::NEG_INFINITY; 3];
79    for room in &rooms[r] {
80        for a in 0..3 {
81            mins[a] = mins[a].min(room.mins[a]);
82            maxs[a] = maxs[a].max(room.maxs[a]);
83        }
84    }
85    (
86        [
87            to_i16_clamped(mins[0]),
88            to_i16_clamped(mins[1]),
89            to_i16_clamped(mins[2]),
90        ],
91        [
92            to_i16_clamped(maxs[0]),
93            to_i16_clamped(maxs[1]),
94            to_i16_clamped(maxs[2]),
95        ],
96    )
97}
98
99fn build_node_recursive(
100    rooms: &[RoomSpec],
101    range: Range<usize>,
102    planes: &mut Vec<Plane>,
103    nodes: &mut Vec<Node>,
104) -> Result<i32, BuildError> {
105    if range.end - range.start == 1 {
106        return Ok(!(range.start as i32));
107    }
108
109    let mid = (range.start + range.end) / 2;
110    let split_dist = (rooms[mid - 1].maxs[0] + rooms[mid].mins[0]) * 0.5;
111
112    let plane_index = planes.len();
113    if plane_index > u16::MAX as usize {
114        return Err(BuildError::TooManyNodes);
115    }
116    planes.push(Plane {
117        normal: [1.0, 0.0, 0.0],
118        dist: split_dist,
119    });
120
121    let node_index = nodes.len();
122    if node_index > i32::MAX as usize {
123        return Err(BuildError::TooManyNodes);
124    }
125    nodes.push(Node {
126        plane: plane_index as u16,
127        children: [0, 0],
128        mins: [0, 0, 0],
129        maxs: [0, 0, 0],
130        first_face: 0,
131        num_faces: 0,
132    });
133
134    // For +X normal, front child is the higher-X partition.
135    let front = build_node_recursive(rooms, mid..range.end, planes, nodes)?;
136    let back = build_node_recursive(rooms, range.start..mid, planes, nodes)?;
137
138    let (mins, maxs) = range_bounds_i16(rooms, range);
139    let n = &mut nodes[node_index];
140    n.children = [front, back];
141    n.mins = mins;
142    n.maxs = maxs;
143
144    Ok(node_index as i32)
145}
146
147fn push_quad_face(
148    out: &mut OwnedBspWorld,
149    verts: [[f32; 3]; 4],
150    texture_id: u32,
151    lightmap_id: u16,
152) -> Result<u16, BuildError> {
153    let first_vert = out.vertices.len() as u32;
154    out.vertices.extend_from_slice(&verts);
155    const QUAD_UVS: [[f32; 2]; 4] = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
156    out.uvs.extend_from_slice(&QUAD_UVS);
157    out.lm_uvs.extend_from_slice(&QUAD_UVS);
158
159    let face_index = out.faces.len();
160    if face_index > u16::MAX as usize {
161        return Err(BuildError::TooManyFaces);
162    }
163    out.faces.push(Face {
164        first_vert,
165        num_verts: 4,
166        texture_id,
167        lightmap_id,
168        plane: 0,
169        side: 0,
170        sector_light_id: u16::MAX,
171    });
172    Ok(face_index as u16)
173}
174
175fn push_leaf_marksurface(out: &mut OwnedBspWorld, face_idx: u16) -> Result<(), BuildError> {
176    if out.marksurfaces.len() > u16::MAX as usize {
177        return Err(BuildError::TooManyMarksurfaces);
178    }
179    out.marksurfaces.push(face_idx);
180    Ok(())
181}
182
183/// Build a simple BSP world from an X-sorted strip of axis-aligned rooms.
184///
185/// Generated geometry includes floor, ceiling, and side/end walls per room,
186/// full-visibility PVS, and an X-axis BSP split tree with root at node 0.
187pub fn build_room_strip(rooms: &[RoomSpec]) -> Result<OwnedBspWorld, BuildError> {
188    if rooms.is_empty() {
189        return Err(BuildError::NeedAtLeastOneRoom);
190    }
191    if rooms.len() > i16::MAX as usize {
192        return Err(BuildError::TooManyRooms);
193    }
194
195    for room in rooms {
196        if room.mins[0] >= room.maxs[0]
197            || room.mins[1] >= room.maxs[1]
198            || room.mins[2] >= room.maxs[2]
199        {
200            return Err(BuildError::InvalidBounds);
201        }
202    }
203
204    for i in 1..rooms.len() {
205        if rooms[i - 1].maxs[0] > rooms[i].mins[0] {
206            return Err(BuildError::RoomsNotSortedOrOverlappingX);
207        }
208    }
209
210    let mut out = OwnedBspWorld::default();
211    const EPS_X_GAP: f32 = 1e-4;
212
213    for (room_idx, room) in rooms.iter().enumerate() {
214        let first_marksurface = out.marksurfaces.len();
215        if first_marksurface > u16::MAX as usize {
216            return Err(BuildError::TooManyMarksurfaces);
217        }
218        let mut leaf_faces: Vec<u16> = Vec::with_capacity(6);
219
220        // Floor (y = mins.y) and ceiling (y = maxs.y)
221        leaf_faces.push(push_quad_face(
222            &mut out,
223            [
224                [room.mins[0], room.mins[1], room.mins[2]],
225                [room.maxs[0], room.mins[1], room.mins[2]],
226                [room.maxs[0], room.mins[1], room.maxs[2]],
227                [room.mins[0], room.mins[1], room.maxs[2]],
228            ],
229            room.floor_texture_id,
230            room.lightmap_id,
231        )?);
232        leaf_faces.push(push_quad_face(
233            &mut out,
234            [
235                [room.mins[0], room.maxs[1], room.mins[2]],
236                [room.maxs[0], room.maxs[1], room.mins[2]],
237                [room.maxs[0], room.maxs[1], room.maxs[2]],
238                [room.mins[0], room.maxs[1], room.maxs[2]],
239            ],
240            room.ceiling_texture_id,
241            room.lightmap_id,
242        )?);
243
244        // Side walls (z min / z max).
245        leaf_faces.push(push_quad_face(
246            &mut out,
247            [
248                [room.mins[0], room.mins[1], room.mins[2]],
249                [room.maxs[0], room.mins[1], room.mins[2]],
250                [room.maxs[0], room.maxs[1], room.mins[2]],
251                [room.mins[0], room.maxs[1], room.mins[2]],
252            ],
253            room.floor_texture_id,
254            room.lightmap_id,
255        )?);
256        leaf_faces.push(push_quad_face(
257            &mut out,
258            [
259                [room.mins[0], room.mins[1], room.maxs[2]],
260                [room.maxs[0], room.mins[1], room.maxs[2]],
261                [room.maxs[0], room.maxs[1], room.maxs[2]],
262                [room.mins[0], room.maxs[1], room.maxs[2]],
263            ],
264            room.floor_texture_id,
265            room.lightmap_id,
266        )?);
267
268        // End-cap walls are only emitted when the room does not directly touch
269        // a neighbor on that side. Shared boundaries become open portals.
270        let has_left_cap = room_idx == 0 || rooms[room_idx - 1].maxs[0] < room.mins[0] - EPS_X_GAP;
271        if has_left_cap {
272            leaf_faces.push(push_quad_face(
273                &mut out,
274                [
275                    [room.mins[0], room.mins[1], room.mins[2]],
276                    [room.mins[0], room.mins[1], room.maxs[2]],
277                    [room.mins[0], room.maxs[1], room.maxs[2]],
278                    [room.mins[0], room.maxs[1], room.mins[2]],
279                ],
280                room.floor_texture_id,
281                room.lightmap_id,
282            )?);
283        }
284
285        let has_right_cap =
286            room_idx + 1 == rooms.len() || room.maxs[0] < rooms[room_idx + 1].mins[0] - EPS_X_GAP;
287        if has_right_cap {
288            leaf_faces.push(push_quad_face(
289                &mut out,
290                [
291                    [room.maxs[0], room.mins[1], room.mins[2]],
292                    [room.maxs[0], room.mins[1], room.maxs[2]],
293                    [room.maxs[0], room.maxs[1], room.maxs[2]],
294                    [room.maxs[0], room.maxs[1], room.mins[2]],
295                ],
296                room.floor_texture_id,
297                room.lightmap_id,
298            )?);
299        }
300
301        for face_idx in &leaf_faces {
302            push_leaf_marksurface(&mut out, *face_idx)?;
303        }
304
305        out.leaves.push(Leaf {
306            cluster: room_idx as i16,
307            mins: [
308                to_i16_clamped(room.mins[0]),
309                to_i16_clamped(room.mins[1]),
310                to_i16_clamped(room.mins[2]),
311            ],
312            maxs: [
313                to_i16_clamped(room.maxs[0]),
314                to_i16_clamped(room.maxs[1]),
315                to_i16_clamped(room.maxs[2]),
316            ],
317            first_marksurface: first_marksurface as u16,
318            num_marksurfaces: leaf_faces.len() as u16,
319        });
320    }
321
322    // Build node tree. Root will be node 0 when `rooms.len() > 1`.
323    if rooms.len() > 1 {
324        let _root = build_node_recursive(rooms, 0..rooms.len(), &mut out.planes, &mut out.nodes)?;
325    }
326
327    // Full visibility PVS.
328    out.num_clusters = rooms.len() as u16;
329    let clusters = out.num_clusters as usize;
330    let row_bytes = clusters.div_ceil(8);
331    out.vis_offsets.reserve(clusters);
332    out.vis.reserve(clusters * row_bytes);
333    for cluster in 0..clusters {
334        out.vis_offsets.push((cluster * row_bytes) as u32);
335        for byte_idx in 0..row_bytes {
336            let mut byte = 0u8;
337            for bit in 0..8 {
338                let target = byte_idx * 8 + bit;
339                if target < clusters {
340                    byte |= 1 << bit;
341                }
342            }
343            out.vis.push(byte);
344        }
345    }
346
347    Ok(out)
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::K3dengine;
354    use crate::bsp::scratch::BspScratch;
355    use crate::command_buffer::{CommandBuffer, RenderCommand};
356    use nalgebra::Point3;
357
358    #[test]
359    fn build_room_strip_basic_world_is_traversable() {
360        let rooms = [
361            RoomSpec {
362                mins: [-5.0, -2.0, -3.0],
363                maxs: [0.0, 2.0, 3.0],
364                floor_texture_id: 0,
365                ceiling_texture_id: 1,
366                lightmap_id: 0xFFFF,
367            },
368            RoomSpec {
369                mins: [0.0, -2.0, -3.0],
370                maxs: [5.0, 2.0, 3.0],
371                floor_texture_id: 0,
372                ceiling_texture_id: 1,
373                lightmap_id: 0xFFFF,
374            },
375        ];
376        let owned = build_room_strip(&rooms).expect("builder should succeed");
377        assert_eq!(owned.nodes.len(), 1);
378        assert_eq!(owned.leaves.len(), 2);
379        assert_eq!(owned.faces.len(), 10);
380        assert_eq!(owned.marksurfaces.len(), 10);
381
382        let world = owned.as_world();
383        assert_eq!(world.leaf_for_point([-2.0, 0.0, 0.0]), 0);
384        assert_eq!(world.leaf_for_point([2.0, 0.0, 0.0]), 1);
385
386        let mut visframe = [0u32; 16];
387        let mut scratch = BspScratch::new(&mut visframe);
388        let mut engine = K3dengine::new(320, 240);
389        engine.camera.set_position(Point3::new(-2.0, 0.0, 0.0));
390        engine.camera.set_target(Point3::new(0.0, 0.0, 0.0));
391        let mut commands: CommandBuffer<512> = CommandBuffer::new();
392
393        engine
394            .record_bsp(&world, &mut scratch, &mut commands, None)
395            .expect("record_bsp should succeed");
396        let draw_count = commands
397            .iter()
398            .filter(|c| matches!(c, RenderCommand::Draw(_)))
399            .count();
400        assert!(draw_count > 0);
401    }
402
403    #[test]
404    fn build_room_strip_rejects_unsorted_or_overlapping() {
405        let rooms = [
406            RoomSpec {
407                mins: [0.0, -2.0, -3.0],
408                maxs: [5.0, 2.0, 3.0],
409                floor_texture_id: 0,
410                ceiling_texture_id: 0,
411                lightmap_id: 0xFFFF,
412            },
413            RoomSpec {
414                mins: [-1.0, -2.0, -3.0],
415                maxs: [2.0, 2.0, 3.0],
416                floor_texture_id: 0,
417                ceiling_texture_id: 0,
418                lightmap_id: 0xFFFF,
419            },
420        ];
421        let err = build_room_strip(&rooms).expect_err("expected ordering failure");
422        assert_eq!(err, BuildError::RoomsNotSortedOrOverlappingX);
423    }
424
425    #[test]
426    fn build_room_strip_single_room_is_valid() {
427        let rooms = [RoomSpec {
428            mins: [-5.0, -2.0, -3.0],
429            maxs: [0.0, 2.0, 3.0],
430            floor_texture_id: 0,
431            ceiling_texture_id: 1,
432            lightmap_id: 0xFFFF,
433        }];
434        let owned = build_room_strip(&rooms).expect("single room should build");
435        assert!(owned.nodes.is_empty());
436        assert_eq!(owned.leaves.len(), 1);
437        assert_eq!(owned.faces.len(), 6);
438        assert_eq!(owned.marksurfaces.len(), 6);
439    }
440}