Skip to main content

embedded_3dgfx/bsp/
mod.rs

1//! BSP world rendering — `K3dengine::record_bsp` and `render_bsp_direct`.
2//!
3//! # API overview
4//!
5//! ```text
6//! // Record/execute split (fits the existing pipeline):
7//! engine.record_bsp(&world, &mut scratch, &mut commands, Some(&mut tel))?;
8//! engine.execute_bsp_textured(fb, &mut frame, &commands, &tex_mgr, None)?;
9//!
10//! // One-shot direct render (skips command buffer, slightly lower overhead):
11//! engine.render_bsp_direct(&world, &mut scratch, &tex_mgr, fb, &mut frame, Some(&mut tel))?;
12//!
13//! // M5: no z-buffer (requires front-to-back guarantee from BSP):
14//! engine.render_bsp_coverage(&world, &mut scratch, &tex_mgr, fb, &mut cov, Some(&mut tel))?;
15//! ```
16
17#[cfg(feature = "std")]
18pub mod builder;
19pub mod coverage;
20pub mod data;
21pub mod pvs;
22pub mod scratch;
23pub mod traverse;
24
25use core::fmt::Debug;
26
27use embedded_graphics_core::{
28    draw_target::DrawTarget, pixelcolor::Rgb565, prelude::OriginDimensions,
29};
30use heapless::Vec as HVec;
31use nalgebra::{Point2, Vector4};
32
33use crate::{
34    DrawPrimitive, K3dengine,
35    command_buffer::{CommandBuffer, RenderCommand},
36    error::RenderError,
37    lights::PointLight,
38    renderer::FrameCtx,
39    sector_lights::{SectorLight, light_level_u8_at},
40    texture::TextureManager,
41};
42
43use coverage::CoverageBuffer;
44use data::{BspWorld, Face};
45use scratch::BspScratch;
46use traverse::{ClipVert, frustum_from_vp, project_to_screen, walk_front_to_back};
47
48// ---------------------------------------------------------------------------
49// Telemetry
50// ---------------------------------------------------------------------------
51
52/// Per-frame BSP rendering statistics.
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
54pub struct BspTelemetry {
55    /// Number of BSP leaves visited during the walk.
56    pub leaves_visited: usize,
57    /// Leaves rejected by PVS.
58    pub leaves_culled_pvs: usize,
59    /// Leaves rejected by frustum culling.
60    pub leaves_culled_frustum: usize,
61    /// Total faces in the visible set after de-duplication.
62    pub faces_visible: usize,
63    /// Faces actually emitted to the command buffer (excludes degenerate/clipped-away).
64    pub faces_emitted: usize,
65    /// Triangles emitted after near-plane clipping.
66    pub triangles_emitted: usize,
67}
68
69// ---------------------------------------------------------------------------
70// Triangle fan → clip → project → emit
71// ---------------------------------------------------------------------------
72
73/// Internal result of projecting one triangle from a BSP face fan.
74struct ProjTri {
75    points: [Point2<i32>; 3],
76    depths: [f32; 3],
77    ws: [f32; 3],
78    uvs: [[f32; 2]; 3],
79    lm_uvs: [[f32; 2]; 3],
80}
81
82#[inline]
83fn lerp_clip_vert(a: ClipVert, b: ClipVert, t: f32) -> ClipVert {
84    ClipVert {
85        clip: a.clip + (b.clip - a.clip) * t,
86        uv: [
87            a.uv[0] + (b.uv[0] - a.uv[0]) * t,
88            a.uv[1] + (b.uv[1] - a.uv[1]) * t,
89        ],
90        lm_uv: [
91            a.lm_uv[0] + (b.lm_uv[0] - a.lm_uv[0]) * t,
92            a.lm_uv[1] + (b.lm_uv[1] - a.lm_uv[1]) * t,
93        ],
94    }
95}
96
97fn clip_polygon_plane(
98    input: &HVec<ClipVert, 10>,
99    output: &mut HVec<ClipVert, 10>,
100    dist: impl Fn(ClipVert) -> f32,
101) {
102    output.clear();
103    let n = input.len();
104    if n == 0 {
105        return;
106    }
107    for i in 0..n {
108        let prev = input[(n + i - 1) % n];
109        let curr = input[i];
110        let d_prev = dist(prev);
111        let d_curr = dist(curr);
112        if d_curr >= 0.0 {
113            if d_prev < 0.0 {
114                let denom = d_prev - d_curr;
115                if denom.abs() > 1e-6 {
116                    let t = d_prev / denom;
117                    let _ = output.push(lerp_clip_vert(prev, curr, t));
118                }
119            }
120            let _ = output.push(curr);
121        } else if d_prev >= 0.0 {
122            let denom = d_prev - d_curr;
123            if denom.abs() > 1e-6 {
124                let t = d_prev / denom;
125                let _ = output.push(lerp_clip_vert(prev, curr, t));
126            }
127        }
128    }
129}
130
131/// Clip and project a triangle into 0–8 screen-space triangles.
132///
133/// Vertices are given in clip space with associated UV and lightmap UV
134/// coordinates. This performs full clip-space frustum clipping (near/far and
135/// side planes), then fan-triangulates the clipped polygon.
136fn clip_and_project(
137    v: [ClipVert; 3],
138    width: u16,
139    height: u16,
140    near: f32,
141    far: f32,
142) -> HVec<ProjTri, 8> {
143    let mut result: HVec<ProjTri, 8> = HVec::new();
144
145    // Build a 3-vertex polygon and clip against the canonical clip frustum.
146    let mut a: HVec<ClipVert, 10> = HVec::new();
147    let mut b: HVec<ClipVert, 10> = HVec::new();
148    for vert in &v {
149        let _ = a.push(*vert);
150    }
151
152    // w >= 0
153    clip_polygon_plane(&a, &mut b, |p| p.clip.w);
154    if b.len() < 3 {
155        return result;
156    }
157    core::mem::swap(&mut a, &mut b);
158
159    // near: z >= -w  => z + w >= 0
160    clip_polygon_plane(&a, &mut b, |p| p.clip.z + p.clip.w);
161    if b.len() < 3 {
162        return result;
163    }
164    core::mem::swap(&mut a, &mut b);
165
166    // far: z <= w  => w - z >= 0
167    clip_polygon_plane(&a, &mut b, |p| p.clip.w - p.clip.z);
168    if b.len() < 3 {
169        return result;
170    }
171    core::mem::swap(&mut a, &mut b);
172
173    // left: x >= -w  => x + w >= 0
174    clip_polygon_plane(&a, &mut b, |p| p.clip.x + p.clip.w);
175    if b.len() < 3 {
176        return result;
177    }
178    core::mem::swap(&mut a, &mut b);
179
180    // right: x <= w  => w - x >= 0
181    clip_polygon_plane(&a, &mut b, |p| p.clip.w - p.clip.x);
182    if b.len() < 3 {
183        return result;
184    }
185    core::mem::swap(&mut a, &mut b);
186
187    // bottom: y >= -w  => y + w >= 0
188    clip_polygon_plane(&a, &mut b, |p| p.clip.y + p.clip.w);
189    if b.len() < 3 {
190        return result;
191    }
192    core::mem::swap(&mut a, &mut b);
193
194    // top: y <= w  => w - y >= 0
195    clip_polygon_plane(&a, &mut b, |p| p.clip.w - p.clip.y);
196    if b.len() < 3 {
197        return result;
198    }
199    core::mem::swap(&mut a, &mut b);
200
201    // Fan-triangulate projected clipped polygon.
202    let n = a.len();
203    for i in 1..(n - 1) {
204        let pts = [&a[0], &a[i], &a[i + 1]];
205
206        let mut screen = [Point2::new(0i32, 0i32); 3];
207        let mut depths = [0.0f32; 3];
208        let mut ws = [0.0f32; 3];
209        let mut uvs = [[0.0f32; 2]; 3];
210        let mut lm_uvs = [[0.0f32; 2]; 3];
211        let mut ok = true;
212
213        for (k, &pt) in pts.iter().enumerate() {
214            if let Some((sp, depth, w)) = project_to_screen(pt.clip, width, height, near, far) {
215                screen[k] = sp;
216                depths[k] = depth;
217                ws[k] = w;
218                uvs[k] = pt.uv;
219                lm_uvs[k] = pt.lm_uv;
220            } else {
221                ok = false;
222                break;
223            }
224        }
225
226        if ok {
227            let _ = result.push(ProjTri {
228                points: screen,
229                depths,
230                ws,
231                uvs,
232                lm_uvs,
233            });
234        }
235    }
236
237    result
238}
239
240/// Build the 3 clip-space vertices for triangle `k` of a fan starting at
241/// `face.first_vert` in `world`, applying `vp` matrix.
242///
243/// Returns `None` if vertex indices are out of bounds.
244fn face_fan_triangle(
245    world: &BspWorld<'_>,
246    face: &Face,
247    k: usize,
248    vp: &nalgebra::Matrix4<f32>,
249) -> Option<[ClipVert; 3]> {
250    let base = face.first_vert as usize;
251    let i0 = base;
252    let i1 = base + k + 1;
253    let i2 = base + k + 2;
254
255    if i2 >= world.vertices.len() {
256        return None;
257    }
258
259    let to_clip = |idx: usize| -> Vector4<f32> {
260        let v = &world.vertices[idx];
261        vp * Vector4::new(v[0], v[1], v[2], 1.0)
262    };
263
264    let uv = |idx: usize| -> [f32; 2] { world.uvs.get(idx).copied().unwrap_or([0.0, 0.0]) };
265
266    let lm_uv = |idx: usize| -> [f32; 2] { world.lm_uvs.get(idx).copied().unwrap_or([0.0, 0.0]) };
267
268    Some([
269        ClipVert {
270            clip: to_clip(i0),
271            uv: uv(i0),
272            lm_uv: lm_uv(i0),
273        },
274        ClipVert {
275            clip: to_clip(i1),
276            uv: uv(i1),
277            lm_uv: lm_uv(i1),
278        },
279        ClipVert {
280            clip: to_clip(i2),
281            uv: uv(i2),
282            lm_uv: lm_uv(i2),
283        },
284    ])
285}
286
287// ---------------------------------------------------------------------------
288// Helpers
289// ---------------------------------------------------------------------------
290
291/// Compute the world-space centroid of a BSP face and accumulate the additive
292/// RGB565 tint from a slice of point lights.
293fn face_dynamic_tint(world: &BspWorld<'_>, face: &Face, lights: &[PointLight]) -> Rgb565 {
294    use embedded_graphics_core::pixelcolor::RgbColor;
295    if lights.is_empty() {
296        return Rgb565::new(0, 0, 0);
297    }
298    let base = face.first_vert as usize;
299    let n = face.num_verts as usize;
300    let mut cx = 0.0f32;
301    let mut cy = 0.0f32;
302    let mut cz = 0.0f32;
303    for i in 0..n {
304        if let Some(v) = world.vertices.get(base + i) {
305            cx += v[0];
306            cy += v[1];
307            cz += v[2];
308        }
309    }
310    if n == 0 {
311        return Rgb565::new(0, 0, 0);
312    }
313    let inv = 1.0 / n as f32;
314    let world_pos = nalgebra::Point3::new(cx * inv, cy * inv, cz * inv);
315    let mut r = 0u32;
316    let mut g = 0u32;
317    let mut b = 0u32;
318    for light in lights {
319        let c = light.contribution_at(world_pos);
320        r += c.r() as u32;
321        g += c.g() as u32;
322        b += c.b() as u32;
323    }
324    Rgb565::new(r.min(31) as u8, g.min(63) as u8, b.min(31) as u8)
325}
326
327// ---------------------------------------------------------------------------
328// K3dengine BSP methods
329// ---------------------------------------------------------------------------
330
331impl K3dengine {
332    /// Record BSP world geometry into a command buffer for later execution.
333    ///
334    /// Produces [`DrawPrimitive::TexturedTriangleWithDepth`] commands for faces
335    /// without lightmaps and [`DrawPrimitive::LightmappedTriangle`] for faces
336    /// with `face.lightmap_id != 0xFFFF`.
337    ///
338    /// Execute the resulting buffer with [`K3dengine::execute_bsp_textured`].
339    pub fn record_bsp<const MAX: usize>(
340        &self,
341        world: &BspWorld<'_>,
342        scratch: &mut BspScratch<'_>,
343        commands: &mut CommandBuffer<MAX>,
344        telemetry: Option<&mut BspTelemetry>,
345    ) -> Result<(), RenderError> {
346        self.record_bsp_with_sector_lights(world, scratch, commands, None, 0.0, telemetry)
347    }
348
349    /// Record BSP geometry while applying optional animated sector lights.
350    pub fn record_bsp_with_sector_lights<const MAX: usize>(
351        &self,
352        world: &BspWorld<'_>,
353        scratch: &mut BspScratch<'_>,
354        commands: &mut CommandBuffer<MAX>,
355        sector_lights: Option<&[SectorLight]>,
356        time_seconds: f32,
357        telemetry: Option<&mut BspTelemetry>,
358    ) -> Result<(), RenderError> {
359        commands.clear();
360        commands.push(RenderCommand::ClearDepth(u32::MAX))?;
361        scratch.mark_new_frame();
362
363        let cam = [
364            self.camera.position.x,
365            self.camera.position.y,
366            self.camera.position.z,
367        ];
368        let cam_leaf = world.leaf_for_point(cam);
369        let cam_cluster = world.leaves.get(cam_leaf).map(|l| l.cluster).unwrap_or(-1);
370
371        let frustum = frustum_from_vp(&self.camera.vp_matrix);
372        let vp = &self.camera.vp_matrix;
373
374        let mut tel = BspTelemetry::default();
375        let mut first_err: Option<RenderError> = None;
376
377        walk_front_to_back(world, scratch, cam, cam_cluster, &frustum, |_fi, face| {
378            if first_err.is_some() {
379                return;
380            }
381            tel.faces_visible += 1;
382
383            let dynamic_tint = face_dynamic_tint(world, face, &self.point_lights);
384
385            let num_tris = face.num_verts.saturating_sub(2) as usize;
386            let mut emitted_any = false;
387
388            for k in 0..num_tris {
389                let tri = match face_fan_triangle(world, face, k, vp) {
390                    Some(t) => t,
391                    None => continue,
392                };
393                let projected = clip_and_project(
394                    tri,
395                    self.width,
396                    self.height,
397                    self.camera.near,
398                    self.camera.far,
399                );
400
401                for pt in projected.iter() {
402                    let brightness = if let Some(lights) = sector_lights {
403                        if face.sector_light_id == u16::MAX {
404                            255
405                        } else {
406                            lights
407                                .get(face.sector_light_id as usize)
408                                .map_or(255, |light| light_level_u8_at(light, time_seconds))
409                        }
410                    } else {
411                        255
412                    }
413                    // Keep a small floor to avoid complete black-outs on low-light sectors.
414                    .max(32);
415                    let prim = DrawPrimitive::LightmappedTriangle {
416                        points: [pt.points[0], pt.points[1], pt.points[2]],
417                        depths: pt.depths,
418                        ws: pt.ws,
419                        surface_uvs: pt.uvs,
420                        lm_uvs: pt.lm_uvs,
421                        texture_id: face.texture_id,
422                        lightmap_id: if face.lightmap_id == 0xFFFF {
423                            u32::MAX
424                        } else {
425                            face.lightmap_id as u32
426                        },
427                        brightness,
428                        dynamic_tint,
429                    };
430
431                    if let Err(e) = commands.push(RenderCommand::Draw(prim)) {
432                        first_err = Some(e);
433                        return;
434                    }
435                    tel.triangles_emitted += 1;
436                    emitted_any = true;
437                }
438            }
439
440            if emitted_any {
441                tel.faces_emitted += 1;
442            }
443        });
444
445        if let Some(err) = first_err {
446            return Err(err);
447        }
448
449        if let Some(t) = telemetry {
450            *t = tel;
451        }
452        Ok(())
453    }
454
455    /// Execute a BSP command buffer with texture support.
456    ///
457    /// Handles both [`DrawPrimitive::TexturedTriangleWithDepth`] and
458    /// [`DrawPrimitive::LightmappedTriangle`] using `texture_manager`.
459    pub fn execute_bsp_textured<D, const MAX: usize, const N: usize>(
460        &self,
461        fb: &mut D,
462        frame: &mut FrameCtx<'_>,
463        commands: &CommandBuffer<MAX>,
464        texture_manager: &TextureManager<N>,
465        telemetry: Option<&mut crate::telemetry::ExecuteTelemetry>,
466    ) -> Result<Option<crate::renderer::DirtyRegion>, RenderError>
467    where
468        D: DrawTarget<Color = Rgb565> + OriginDimensions,
469        D::Error: Debug,
470    {
471        use crate::draw::{draw_zbuffered_lightmapped_mapped, draw_zbuffered_with_textures_mapped};
472        use crate::renderer::DirtyRegion;
473
474        frame.validate()?;
475        let mut dirty: Option<(i32, i32, i32, i32)> = None;
476
477        if let Some(t) = telemetry {
478            t.commands_total = commands.len();
479            t.draw_commands = commands
480                .iter()
481                .filter(|c| matches!(c, RenderCommand::Draw(_)))
482                .count();
483        }
484
485        for cmd in commands.iter() {
486            match cmd {
487                RenderCommand::ClearDepth(v) => frame.zbuffer.fill(*v),
488                RenderCommand::ClearColor(color) => {
489                    let w = frame.width as i32;
490                    let h = frame.height as i32;
491                    for y in 0..h {
492                        for x in 0..w {
493                            fb.draw_iter([embedded_graphics_core::Pixel(
494                                embedded_graphics_core::prelude::Point::new(x, y),
495                                *color,
496                            )])
497                            .map_err(|_| RenderError::InvalidInput("clear write failed"))?;
498                        }
499                    }
500                }
501                RenderCommand::Draw(prim) => {
502                    let (x0, y0, x1, y1) = prim_bounds(prim);
503                    match prim.clone() {
504                        DrawPrimitive::LightmappedTriangle {
505                            points,
506                            depths,
507                            ws,
508                            surface_uvs,
509                            lm_uvs,
510                            texture_id,
511                            lightmap_id,
512                            brightness,
513                            dynamic_tint,
514                        } => {
515                            draw_zbuffered_lightmapped_mapped(
516                                points,
517                                depths,
518                                ws,
519                                surface_uvs,
520                                lm_uvs,
521                                texture_id,
522                                lightmap_id,
523                                brightness,
524                                dynamic_tint,
525                                self.fog.as_ref(),
526                                texture_manager,
527                                fb,
528                                frame.zbuffer,
529                                frame.width,
530                                self.texture_mapping,
531                                self.stipple_mode,
532                                self.screen_tint,
533                                self.palette_mode,
534                            );
535                        }
536                        other => {
537                            draw_zbuffered_with_textures_mapped(
538                                other,
539                                fb,
540                                frame.zbuffer,
541                                frame.width,
542                                texture_manager,
543                                self.fog.as_ref(),
544                                self.dither.as_ref(),
545                                self.texture_mapping,
546                                self.stipple_mode,
547                                self.screen_tint,
548                                self.palette_mode,
549                            );
550                        }
551                    }
552                    if let Some((cx0, cy0, cx1, cy1)) = dirty {
553                        dirty = Some((cx0.min(x0), cy0.min(y0), cx1.max(x1), cy1.max(y1)));
554                    } else {
555                        dirty = Some((x0, y0, x1, y1));
556                    }
557                }
558            }
559        }
560
561        let region = dirty.and_then(|(x0, y0, x1, y1)| {
562            if x1 < x0 || y1 < y0 {
563                return None;
564            }
565            Some(DirtyRegion {
566                x: x0 as usize,
567                y: y0 as usize,
568                width: (x1 - x0 + 1) as usize,
569                height: (y1 - y0 + 1) as usize,
570            })
571        });
572        Ok(region)
573    }
574
575    /// One-shot BSP render: traverse + rasterize without an intermediate command buffer.
576    ///
577    /// Lower overhead than record+execute for world geometry that must be
578    /// re-traversed every frame (camera always moves).
579    pub fn render_bsp_direct<D, const N: usize>(
580        &self,
581        world: &BspWorld<'_>,
582        scratch: &mut BspScratch<'_>,
583        texture_manager: &TextureManager<N>,
584        fb: &mut D,
585        frame: &mut FrameCtx<'_>,
586        telemetry: Option<&mut BspTelemetry>,
587    ) -> Result<(), RenderError>
588    where
589        D: DrawTarget<Color = Rgb565> + OriginDimensions,
590        D::Error: Debug,
591    {
592        use crate::draw::draw_zbuffered_lightmapped_mapped;
593
594        frame.validate()?;
595        frame.zbuffer.fill(u32::MAX);
596        scratch.mark_new_frame();
597
598        let cam = [
599            self.camera.position.x,
600            self.camera.position.y,
601            self.camera.position.z,
602        ];
603        let cam_leaf = world.leaf_for_point(cam);
604        let cam_cluster = world.leaves.get(cam_leaf).map(|l| l.cluster).unwrap_or(-1);
605        let frustum = frustum_from_vp(&self.camera.vp_matrix);
606        let vp = &self.camera.vp_matrix;
607
608        let mut tel = BspTelemetry::default();
609
610        walk_front_to_back(world, scratch, cam, cam_cluster, &frustum, |_fi, face| {
611            tel.faces_visible += 1;
612
613            let dynamic_tint = face_dynamic_tint(world, face, &self.point_lights);
614
615            let num_tris = face.num_verts.saturating_sub(2) as usize;
616            let mut emitted_any = false;
617
618            for k in 0..num_tris {
619                let tri = match face_fan_triangle(world, face, k, vp) {
620                    Some(t) => t,
621                    None => continue,
622                };
623                let projected = clip_and_project(
624                    tri,
625                    self.width,
626                    self.height,
627                    self.camera.near,
628                    self.camera.far,
629                );
630
631                for pt in projected.iter() {
632                    draw_zbuffered_lightmapped_mapped(
633                        [pt.points[0], pt.points[1], pt.points[2]],
634                        pt.depths,
635                        pt.ws,
636                        pt.uvs,
637                        pt.lm_uvs,
638                        face.texture_id,
639                        if face.lightmap_id == 0xFFFF {
640                            u32::MAX
641                        } else {
642                            face.lightmap_id as u32
643                        },
644                        255,
645                        dynamic_tint,
646                        self.fog.as_ref(),
647                        texture_manager,
648                        fb,
649                        frame.zbuffer,
650                        frame.width,
651                        self.texture_mapping,
652                        self.stipple_mode,
653                        self.screen_tint,
654                        self.palette_mode,
655                    );
656                    tel.triangles_emitted += 1;
657                    emitted_any = true;
658                }
659            }
660
661            if emitted_any {
662                tel.faces_emitted += 1;
663            }
664        });
665
666        if let Some(t) = telemetry {
667            *t = tel;
668        }
669        Ok(())
670    }
671
672    /// M5: One-shot BSP render using a coverage bitmap instead of a z-buffer.
673    ///
674    /// Requires `frame` was created with a valid zbuffer (the zbuffer is *not*
675    /// used for depth testing — the coverage buffer takes its role).  The
676    /// zbuffer slice is only needed to satisfy the `FrameCtx` API; pass a
677    /// zero-length slice and skip `frame.validate()` if RAM is tight, or
678    /// supply a 1-element dummy.
679    ///
680    /// Front-to-back traversal guarantees correctness without depth testing.
681    pub fn render_bsp_coverage<D, const N: usize>(
682        &self,
683        world: &BspWorld<'_>,
684        scratch: &mut BspScratch<'_>,
685        texture_manager: &TextureManager<N>,
686        fb: &mut D,
687        coverage: &mut CoverageBuffer<'_>,
688        telemetry: Option<&mut BspTelemetry>,
689    ) -> Result<(), RenderError>
690    where
691        D: DrawTarget<Color = Rgb565> + OriginDimensions,
692        D::Error: Debug,
693    {
694        use crate::draw::draw_bsp_coverage;
695
696        coverage.clear();
697        scratch.mark_new_frame();
698
699        let cam = [
700            self.camera.position.x,
701            self.camera.position.y,
702            self.camera.position.z,
703        ];
704        let cam_leaf = world.leaf_for_point(cam);
705        let cam_cluster = world.leaves.get(cam_leaf).map(|l| l.cluster).unwrap_or(-1);
706        let frustum = frustum_from_vp(&self.camera.vp_matrix);
707        let vp = &self.camera.vp_matrix;
708
709        let mut tel = BspTelemetry::default();
710
711        walk_front_to_back(world, scratch, cam, cam_cluster, &frustum, |_fi, face| {
712            // Early-out when every pixel is already covered
713            if coverage.is_full() {
714                return;
715            }
716
717            tel.faces_visible += 1;
718            let num_tris = face.num_verts.saturating_sub(2) as usize;
719            let mut emitted_any = false;
720
721            for k in 0..num_tris {
722                let tri = match face_fan_triangle(world, face, k, vp) {
723                    Some(t) => t,
724                    None => continue,
725                };
726                let projected = clip_and_project(
727                    tri,
728                    self.width,
729                    self.height,
730                    self.camera.near,
731                    self.camera.far,
732                );
733
734                for pt in projected.iter() {
735                    draw_bsp_coverage(
736                        [pt.points[0], pt.points[1], pt.points[2]],
737                        pt.ws,
738                        pt.uvs,
739                        face.texture_id,
740                        texture_manager,
741                        fb,
742                        coverage,
743                        self.texture_mapping,
744                        self.stipple_mode,
745                        self.screen_tint,
746                        self.palette_mode,
747                    );
748                    tel.triangles_emitted += 1;
749                    emitted_any = true;
750                }
751            }
752
753            if emitted_any {
754                tel.faces_emitted += 1;
755            }
756        });
757
758        if let Some(t) = telemetry {
759            *t = tel;
760        }
761        Ok(())
762    }
763}
764
765// ---------------------------------------------------------------------------
766// Primitive bounding box helper (mirrors renderer.rs::primitive_bounds)
767// ---------------------------------------------------------------------------
768
769fn prim_bounds(p: &DrawPrimitive) -> (i32, i32, i32, i32) {
770    match p {
771        DrawPrimitive::ColoredPoint(pt, _) => (pt.x, pt.y, pt.x, pt.y),
772        DrawPrimitive::Line([a, b], _) => (a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)),
773        DrawPrimitive::ColoredTriangle(pts, _)
774        | DrawPrimitive::ColoredTriangleWithDepth { points: pts, .. }
775        | DrawPrimitive::TranslucentTriangleWithDepth { points: pts, .. }
776        | DrawPrimitive::GouraudTriangle { points: pts, .. }
777        | DrawPrimitive::GouraudTriangleWithDepth { points: pts, .. }
778        | DrawPrimitive::TexturedTriangle { points: pts, .. }
779        | DrawPrimitive::TexturedTriangleWithDepth { points: pts, .. }
780        | DrawPrimitive::LightmappedTriangle { points: pts, .. } => {
781            let min_x = pts.iter().map(|p| p.x).min().unwrap_or(0);
782            let min_y = pts.iter().map(|p| p.y).min().unwrap_or(0);
783            let max_x = pts.iter().map(|p| p.x).max().unwrap_or(0);
784            let max_y = pts.iter().map(|p| p.y).max().unwrap_or(0);
785            (min_x, min_y, max_x, max_y)
786        }
787    }
788}
789
790// ---------------------------------------------------------------------------
791// Test-only two-room level fixture
792// ---------------------------------------------------------------------------
793
794/// A minimal hand-built level for use in tests and integration examples.
795///
796/// Layout:
797/// ```text
798///     Room A              Room B
799///  (-5,-2,-3)           (0,-2,-3)
800///  ┌──────────┐   X=0   ┌──────────┐
801///  │          │         │          │
802///  │ cluster 0│         │ cluster 1│
803///  └──────────┘         └──────────┘
804///  (0, 2, 3)            (5, 2, 3)
805/// ```
806/// Both rooms are fully mutually visible (no zero-run PVS entries).
807#[allow(dead_code)]
808pub mod test_level {
809    use super::data::*;
810
811    pub static PLANES: [Plane; 1] = [Plane {
812        normal: [1.0, 0.0, 0.0],
813        dist: 0.0,
814    }];
815
816    // Node: X=0 split. children[0]=front(d>=0)=leaf1, children[1]=back(d<0)=leaf0
817    pub static NODES: [Node; 1] = [Node {
818        plane: 0,
819        children: [!1i32, !0i32],
820        mins: [-5, -2, -3],
821        maxs: [5, 2, 3],
822        first_face: 0,
823        num_faces: 0,
824    }];
825
826    pub static LEAVES: [Leaf; 2] = [
827        Leaf {
828            cluster: 0,
829            mins: [-5, -2, -3],
830            maxs: [0, 2, 3],
831            first_marksurface: 0,
832            num_marksurfaces: 2,
833        },
834        Leaf {
835            cluster: 1,
836            mins: [0, -2, -3],
837            maxs: [5, 2, 3],
838            first_marksurface: 2,
839            num_marksurfaces: 2,
840        },
841    ];
842
843    // Room A floor (fan = 2 triangles) + Room B floor (fan = 2 triangles)
844    pub static FACES: [Face; 4] = [
845        // Room A floor
846        Face {
847            first_vert: 0,
848            num_verts: 4,
849            texture_id: 0,
850            lightmap_id: 0xFFFF,
851            plane: 0,
852            side: 0,
853            sector_light_id: u16::MAX,
854        },
855        // Room A ceiling
856        Face {
857            first_vert: 4,
858            num_verts: 4,
859            texture_id: 0,
860            lightmap_id: 0xFFFF,
861            plane: 0,
862            side: 0,
863            sector_light_id: u16::MAX,
864        },
865        // Room B floor
866        Face {
867            first_vert: 8,
868            num_verts: 4,
869            texture_id: 0,
870            lightmap_id: 0xFFFF,
871            plane: 0,
872            side: 0,
873            sector_light_id: u16::MAX,
874        },
875        // Room B ceiling
876        Face {
877            first_vert: 12,
878            num_verts: 4,
879            texture_id: 0,
880            lightmap_id: 0xFFFF,
881            plane: 0,
882            side: 0,
883            sector_light_id: u16::MAX,
884        },
885    ];
886
887    pub static MARKSURFACES: [u16; 4] = [0, 1, 2, 3];
888
889    // Room A floor quad (y=-2 plane, fan: v0,v1,v2 + v0,v2,v3)
890    pub static VERTICES: [[f32; 3]; 16] = [
891        // Room A floor (y=-2)
892        [-5.0, -2.0, -3.0],
893        [0.0, -2.0, -3.0],
894        [0.0, -2.0, 3.0],
895        [-5.0, -2.0, 3.0],
896        // Room A ceiling (y=2)
897        [-5.0, 2.0, -3.0],
898        [0.0, 2.0, -3.0],
899        [0.0, 2.0, 3.0],
900        [-5.0, 2.0, 3.0],
901        // Room B floor (y=-2)
902        [0.0, -2.0, -3.0],
903        [5.0, -2.0, -3.0],
904        [5.0, -2.0, 3.0],
905        [0.0, -2.0, 3.0],
906        // Room B ceiling (y=2)
907        [0.0, 2.0, -3.0],
908        [5.0, 2.0, -3.0],
909        [5.0, 2.0, 3.0],
910        [0.0, 2.0, 3.0],
911    ];
912
913    pub static UVS: [[f32; 2]; 16] = [
914        [0.0, 0.0],
915        [1.0, 0.0],
916        [1.0, 1.0],
917        [0.0, 1.0],
918        [0.0, 0.0],
919        [1.0, 0.0],
920        [1.0, 1.0],
921        [0.0, 1.0],
922        [0.0, 0.0],
923        [1.0, 0.0],
924        [1.0, 1.0],
925        [0.0, 1.0],
926        [0.0, 0.0],
927        [1.0, 0.0],
928        [1.0, 1.0],
929        [0.0, 1.0],
930    ];
931
932    // Full-vis PVS: 2 clusters, each byte = 0b00000011 = both visible
933    pub static VIS: [u8; 2] = [0b00000011, 0b00000011];
934    pub static VIS_OFFSETS: [u32; 2] = [0, 1];
935
936    pub fn world() -> BspWorld<'static> {
937        BspWorld::new(
938            &PLANES,
939            &NODES,
940            &LEAVES,
941            &FACES,
942            &MARKSURFACES,
943            &VERTICES,
944            &UVS,
945            &[], // no lightmaps in test level
946            &VIS,
947            &VIS_OFFSETS,
948            2,
949        )
950    }
951}
952
953// ---------------------------------------------------------------------------
954// Tests
955// ---------------------------------------------------------------------------
956
957#[cfg(test)]
958mod tests {
959    extern crate std;
960    use super::*;
961    use crate::bsp::scratch::BspScratch;
962    use crate::bsp::test_level;
963    use crate::sector_lights::{LightEffectKind, SectorLight};
964    use nalgebra::Point3;
965
966    #[test]
967    fn test_level_leaf_location() {
968        let world = test_level::world();
969        let leaf_a = world.leaf_for_point([-2.0, 0.0, 0.0]);
970        let leaf_b = world.leaf_for_point([2.0, 0.0, 0.0]);
971        assert_eq!(leaf_a, 0);
972        assert_eq!(leaf_b, 1);
973    }
974
975    #[test]
976    fn test_level_cluster_visibility() {
977        let world = test_level::world();
978        assert!(world.cluster_visible(0, 0));
979        assert!(world.cluster_visible(0, 1));
980        assert!(world.cluster_visible(1, 0));
981        assert!(world.cluster_visible(1, 1));
982    }
983
984    #[test]
985    fn record_bsp_emits_draw_commands() {
986        let world = test_level::world();
987        let mut visframe = [0u32; 4];
988        let mut scratch = BspScratch::new(&mut visframe);
989
990        let mut engine = K3dengine::new(320, 240);
991        engine.camera.set_position(Point3::new(-2.0, 0.0, 0.0));
992        engine.camera.set_target(Point3::new(0.0, 0.0, 0.0));
993
994        let mut commands: CommandBuffer<512> = CommandBuffer::new();
995        let mut tel = BspTelemetry::default();
996
997        engine
998            .record_bsp(&world, &mut scratch, &mut commands, Some(&mut tel))
999            .unwrap();
1000
1001        // Should have at least one draw command (floor/ceiling quads)
1002        let draw_count = commands
1003            .iter()
1004            .filter(|c| matches!(c, RenderCommand::Draw(_)))
1005            .count();
1006        assert!(draw_count > 0, "expected draw commands, got 0");
1007        assert!(tel.faces_visible > 0);
1008        assert!(tel.triangles_emitted > 0);
1009    }
1010
1011    #[test]
1012    fn record_bsp_deduplicates_faces() {
1013        let world = test_level::world();
1014        let mut vf1 = [0u32; 4];
1015        let mut vf2 = [0u32; 4];
1016        let mut s1 = BspScratch::new(&mut vf1);
1017        let mut s2 = BspScratch::new(&mut vf2);
1018
1019        let mut engine = K3dengine::new(320, 240);
1020        engine.camera.set_position(Point3::new(-2.0, 0.0, 0.0));
1021        engine.camera.set_target(Point3::new(0.0, 0.0, 0.0));
1022
1023        let mut c1: CommandBuffer<512> = CommandBuffer::new();
1024        engine.record_bsp(&world, &mut s1, &mut c1, None).unwrap();
1025        let count1 = c1
1026            .iter()
1027            .filter(|c| matches!(c, RenderCommand::Draw(_)))
1028            .count();
1029
1030        // Second record (same frame config) should produce identical count
1031        let mut c2: CommandBuffer<512> = CommandBuffer::new();
1032        engine.record_bsp(&world, &mut s2, &mut c2, None).unwrap();
1033        let count2 = c2
1034            .iter()
1035            .filter(|c| matches!(c, RenderCommand::Draw(_)))
1036            .count();
1037
1038        assert_eq!(count1, count2);
1039    }
1040
1041    #[test]
1042    fn record_bsp_with_sector_lights_keeps_output_when_faces_unmapped() {
1043        let world = test_level::world();
1044        let mut vf_a = [0u32; 4];
1045        let mut vf_b = [0u32; 4];
1046        let mut scratch_a = BspScratch::new(&mut vf_a);
1047        let mut scratch_b = BspScratch::new(&mut vf_b);
1048
1049        let mut engine = K3dengine::new(320, 240);
1050        engine.camera.set_position(Point3::new(-2.0, 0.0, 0.0));
1051        engine.camera.set_target(Point3::new(0.0, 0.0, 0.0));
1052
1053        let mut commands_base: CommandBuffer<512> = CommandBuffer::new();
1054        engine
1055            .record_bsp(&world, &mut scratch_a, &mut commands_base, None)
1056            .unwrap();
1057        let base_draw_count = commands_base
1058            .iter()
1059            .filter(|c| matches!(c, RenderCommand::Draw(_)))
1060            .count();
1061
1062        let lights = [SectorLight {
1063            base: 255,
1064            alt: 48,
1065            speed: 0.5,
1066            duration: 0.0,
1067            sync: 0.0,
1068            effect: Some(LightEffectKind::Glow),
1069        }];
1070
1071        let mut commands_with_lights: CommandBuffer<512> = CommandBuffer::new();
1072        engine
1073            .record_bsp_with_sector_lights(
1074                &world,
1075                &mut scratch_b,
1076                &mut commands_with_lights,
1077                Some(&lights),
1078                1.0,
1079                None,
1080            )
1081            .unwrap();
1082        let lit_draw_count = commands_with_lights
1083            .iter()
1084            .filter(|c| matches!(c, RenderCommand::Draw(_)))
1085            .count();
1086
1087        // test_level faces use u16::MAX sector_light_id, so command emission should match.
1088        assert_eq!(base_draw_count, lit_draw_count);
1089    }
1090
1091    #[test]
1092    fn coverage_buffer_basic() {
1093        let mut data = [0u8; CoverageBuffer::bytes_for(32, 32)];
1094        let mut cov = CoverageBuffer::new(&mut data, 32, 32).unwrap();
1095        cov.clear();
1096        assert!(!cov.is_full());
1097        assert!(!cov.is_covered(5, 5));
1098        cov.mark_covered(5, 5);
1099        assert!(cov.is_covered(5, 5));
1100    }
1101
1102    #[test]
1103    fn clip_and_project_triangle_inside_frustum_emits_one() {
1104        let tri = [
1105            ClipVert {
1106                clip: nalgebra::Vector4::new(-0.5, -0.5, 0.0, 1.0),
1107                uv: [0.0, 0.0],
1108                lm_uv: [0.0, 0.0],
1109            },
1110            ClipVert {
1111                clip: nalgebra::Vector4::new(0.5, -0.5, 0.0, 1.0),
1112                uv: [1.0, 0.0],
1113                lm_uv: [1.0, 0.0],
1114            },
1115            ClipVert {
1116                clip: nalgebra::Vector4::new(0.0, 0.5, 0.0, 1.0),
1117                uv: [0.5, 1.0],
1118                lm_uv: [0.5, 1.0],
1119            },
1120        ];
1121        let out = clip_and_project(tri, 320, 240, 0.1, 40.0);
1122        assert_eq!(out.len(), 1);
1123    }
1124
1125    #[test]
1126    fn clip_and_project_triangle_crossing_side_plane_is_clipped() {
1127        let tri = [
1128            ClipVert {
1129                clip: nalgebra::Vector4::new(0.0, -0.2, 0.0, 1.0),
1130                uv: [0.0, 0.0],
1131                lm_uv: [0.0, 0.0],
1132            },
1133            ClipVert {
1134                clip: nalgebra::Vector4::new(1.6, 0.0, 0.0, 1.0), // outside right plane
1135                uv: [1.0, 0.5],
1136                lm_uv: [1.0, 0.5],
1137            },
1138            ClipVert {
1139                clip: nalgebra::Vector4::new(0.0, 0.4, 0.0, 1.0),
1140                uv: [0.0, 1.0],
1141                lm_uv: [0.0, 1.0],
1142            },
1143        ];
1144        let out = clip_and_project(tri, 320, 240, 0.1, 40.0);
1145        assert!(!out.is_empty(), "expected clipped output triangles");
1146        for tri in out.iter() {
1147            for p in tri.points {
1148                assert!(p.x >= 0 && p.x <= 320);
1149                assert!(p.y >= 0 && p.y <= 240);
1150            }
1151        }
1152    }
1153
1154    #[test]
1155    fn clip_and_project_triangle_behind_near_plane_is_discarded() {
1156        let tri = [
1157            ClipVert {
1158                clip: nalgebra::Vector4::new(-0.2, -0.2, -2.0, 1.0),
1159                uv: [0.0, 0.0],
1160                lm_uv: [0.0, 0.0],
1161            },
1162            ClipVert {
1163                clip: nalgebra::Vector4::new(0.2, -0.2, -2.2, 1.0),
1164                uv: [1.0, 0.0],
1165                lm_uv: [1.0, 0.0],
1166            },
1167            ClipVert {
1168                clip: nalgebra::Vector4::new(0.0, 0.2, -2.1, 1.0),
1169                uv: [0.5, 1.0],
1170                lm_uv: [0.5, 1.0],
1171            },
1172        ];
1173        let out = clip_and_project(tri, 320, 240, 0.1, 40.0);
1174        assert_eq!(out.len(), 0);
1175    }
1176}