Skip to main content

embedded_3dgfx/
engine.rs

1use core::fmt::Debug;
2use embedded_graphics_core::draw_target::DrawTarget;
3use embedded_graphics_core::pixelcolor::{Rgb565, RgbColor};
4use embedded_graphics_core::prelude::Point;
5use nalgebra::{ComplexField, Matrix4, Point2, Point3, Vector3, Vector4};
6
7use crate::camera::Camera;
8use crate::command_buffer::{CommandBuffer, RenderCommand};
9use crate::config::{MaterialProfile, ProfileCaps, QualityTier};
10use crate::draw::{DitherConfig, FogConfig};
11use crate::error::{BudgetKind, RenderError};
12use crate::mesh::{self, K3dMesh, RenderMode};
13use crate::primitive::DrawPrimitive;
14use crate::renderer::{DirtyRegion, FrameCtx};
15use crate::retro::{LightLevels, PaletteMode, ScreenTint, SkyConfig, StippleMode, TextureMapping};
16use crate::tilebin::TileConfig;
17use crate::{ZDepth, clear_zbuffer, to_zdepth};
18
19pub struct K3dengine {
20    pub camera: Camera,
21    pub(crate) width: u16,
22    pub(crate) height: u16,
23    pub(crate) caps: Option<crate::config::ProfileCaps>,
24    pub(crate) quality_tier: crate::config::QualityTier,
25    pub(crate) material_profile: crate::config::MaterialProfile,
26    /// Depth-based fog applied during `execute` / `execute_tiled`.
27    pub(crate) fog: Option<crate::draw::FogConfig>,
28    /// Ordered dithering applied during `execute` / `execute_tiled`.
29    pub(crate) dither: Option<crate::draw::DitherConfig>,
30    /// Optional NDC snap precision for retro-style vertex jitter.
31    pub(crate) vertex_snap_bits: u8,
32    /// Texture interpolation mode for textured raster paths.
33    pub(crate) texture_mapping: crate::retro::TextureMapping,
34    /// Sector brightness behavior.
35    pub(crate) light_levels: crate::retro::LightLevels,
36    /// Optional stipple mode for textured/lightmapped passes.
37    pub(crate) stipple_mode: crate::retro::StippleMode,
38    /// Optional full-screen tint blended during rasterization.
39    pub(crate) screen_tint: Option<crate::retro::ScreenTint>,
40    /// Optional palette quantization.
41    pub(crate) palette_mode: crate::retro::PaletteMode,
42    /// Optional sky background rendered before scene geometry.
43    pub(crate) sky: Option<crate::retro::SkyConfig>,
44    /// Runtime point lights (max 16).  Applied at face-centre granularity
45    /// during `record` for mesh geometry and at face level for BSP.
46    #[cfg(feature = "lighting")]
47    pub(crate) point_lights: heapless::Vec<crate::lights::PointLight, 16>,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct BudgetFallbackOutcome {
52    pub used_fallback: bool,
53    pub primary_budget_error: Option<crate::error::BudgetKind>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct DegradationOutcome {
58    pub used_degradation: bool,
59    pub steps_applied: usize,
60    pub dropped_meshes: usize,
61    pub final_quality_tier: crate::config::QualityTier,
62    pub primary_budget_error: Option<crate::error::BudgetKind>,
63}
64
65impl K3dengine {
66    pub fn new(width: u16, height: u16) -> K3dengine {
67        K3dengine {
68            camera: Camera::new(width as f32 / height as f32),
69            width,
70            height,
71            caps: None,
72            quality_tier: crate::config::QualityTier::Balanced,
73            material_profile: crate::config::MaterialProfile::Lambert,
74            fog: None,
75            dither: None,
76            vertex_snap_bits: 0,
77            texture_mapping: crate::retro::TextureMapping::PerspectiveCorrect,
78            light_levels: crate::retro::LightLevels::Linear,
79            stipple_mode: crate::retro::StippleMode::Off,
80            screen_tint: None,
81            palette_mode: crate::retro::PaletteMode::Off,
82            sky: None,
83            #[cfg(feature = "lighting")]
84            point_lights: heapless::Vec::new(),
85        }
86    }
87
88    /// Enable depth-based fog for subsequent [`execute`][Self::execute] calls.
89    pub fn set_fog(&mut self, fog: crate::draw::FogConfig) {
90        self.fog = Some(fog);
91    }
92
93    /// Disable fog (default state).
94    pub fn clear_fog(&mut self) {
95        self.fog = None;
96    }
97
98    /// Enable ordered dithering for subsequent execute passes.
99    pub fn set_dither(&mut self, dither: crate::draw::DitherConfig) {
100        self.dither = Some(dither);
101    }
102
103    /// Disable ordered dithering.
104    pub fn clear_dither(&mut self) {
105        self.dither = None;
106    }
107
108    /// Set NDC vertex snap precision. `0` disables snapping.
109    pub fn set_vertex_snap_bits(&mut self, bits: u8) {
110        self.vertex_snap_bits = bits.min(16);
111    }
112
113    /// Select texture interpolation mode.
114    pub fn set_texture_mapping(&mut self, mapping: crate::retro::TextureMapping) {
115        self.texture_mapping = mapping;
116    }
117
118    /// Select sector light quantization model.
119    pub fn set_light_levels(&mut self, levels: crate::retro::LightLevels) {
120        self.light_levels = levels;
121    }
122
123    /// Set stipple mode used by textured/lightmapped raster paths.
124    pub fn set_stipple_mode(&mut self, mode: crate::retro::StippleMode) {
125        self.stipple_mode = mode;
126    }
127
128    /// Set an optional full-screen tint.
129    pub fn set_screen_tint(&mut self, tint: crate::retro::ScreenTint) {
130        self.screen_tint = Some(tint);
131    }
132
133    /// Disable full-screen tint.
134    pub fn clear_screen_tint(&mut self) {
135        self.screen_tint = None;
136    }
137
138    /// Set output palette quantization mode.
139    pub fn set_palette_mode(&mut self, mode: crate::retro::PaletteMode) {
140        self.palette_mode = mode;
141    }
142
143    /// Set procedural sky rendering parameters.
144    pub fn set_sky(&mut self, sky: crate::retro::SkyConfig) {
145        self.sky = Some(sky);
146    }
147
148    /// Disable procedural sky rendering.
149    pub fn clear_sky(&mut self) {
150        self.sky = None;
151    }
152
153    /// Apply a coarse retro visual preset.
154    pub fn apply_retro_style(&mut self, style: crate::retro::RetroStyle) {
155        self.fog = style.fog;
156        self.dither = style.dither;
157        self.set_vertex_snap_bits(style.vertex_snap_bits);
158        self.texture_mapping = style.texture_mapping;
159        self.light_levels = style.light_levels;
160        self.stipple_mode = style.stipple_mode;
161        self.screen_tint = style.screen_tint;
162        self.palette_mode = style.palette_mode;
163        self.sky = style.sky;
164    }
165
166    /// Add a dynamic point light.  Returns `false` when the 16-light limit
167    /// is reached.
168    #[cfg(feature = "lighting")]
169    pub fn add_point_light(&mut self, light: crate::lights::PointLight) -> bool {
170        self.point_lights.push(light).is_ok()
171    }
172
173    /// Remove all dynamic point lights.
174    #[cfg(feature = "lighting")]
175    pub fn clear_point_lights(&mut self) {
176        self.point_lights.clear();
177    }
178
179    /// Compute the summed additive RGB565 tint from all registered point
180    /// lights at `world_pos`.
181    #[cfg(feature = "lighting")]
182    #[inline]
183    pub(crate) fn light_tint_at(&self, world_pos: Point3<f32>) -> Rgb565 {
184        #[cfg(feature = "lighting")]
185        {
186            let mut acc_r = 0u16;
187            let mut acc_g = 0u16;
188            let mut acc_b = 0u16;
189            for light in &self.point_lights {
190                let tint = light.contribution_at(world_pos);
191                acc_r += tint.r() as u16;
192                acc_g += tint.g() as u16;
193                acc_b += tint.b() as u16;
194            }
195            Rgb565::new(
196                (acc_r.min(31)) as u8,
197                (acc_g.min(63)) as u8,
198                (acc_b.min(31)) as u8,
199            )
200        }
201        #[cfg(not(feature = "lighting"))]
202        {
203            let _ = world_pos;
204            Rgb565::new(0, 0, 0)
205        }
206    }
207
208    /// Additively blend `tint` into `base`, saturating per channel.
209    #[inline]
210    pub(crate) fn add_tint(base: Rgb565, tint: Rgb565) -> Rgb565 {
211        Rgb565::new(
212            (base.r() as u16 + tint.r() as u16).min(31) as u8,
213            (base.g() as u16 + tint.g() as u16).min(63) as u8,
214            (base.b() as u16 + tint.b() as u16).min(31) as u8,
215        )
216    }
217
218    /// Non-linear 32-level light ramp for Doom-style sector attenuation.
219    #[cfg(feature = "lighting")]
220    const DOOM_LIGHT_TABLE: [u8; 32] = [
221        8, 12, 16, 20, 24, 28, 34, 40, 48, 56, 64, 72, 82, 92, 102, 112, 124, 136, 148, 160, 172,
222        184, 196, 206, 216, 224, 232, 238, 244, 248, 252, 255,
223    ];
224
225    #[cfg(feature = "lighting")]
226    #[inline]
227    pub(crate) fn sector_shaded_color(
228        &self,
229        base: Rgb565,
230        brightness: u8,
231        face_center: Point3<f32>,
232    ) -> Rgb565 {
233        let level_u8 = match self.light_levels {
234            crate::retro::LightLevels::Linear => brightness,
235            crate::retro::LightLevels::Doom32 => {
236                let base_level = (brightness as usize * 31) / 255;
237                let distance = (face_center - self.camera.position).norm();
238                // 2.0 buckets per world-unit gives coarse banding similar to classic software renderers.
239                let distance_drop = (distance * 2.0) as usize;
240                let idx = base_level.saturating_sub(distance_drop).min(31);
241                Self::DOOM_LIGHT_TABLE[idx]
242            }
243        };
244
245        let factor = level_u8 as f32 / 255.0;
246        Rgb565::new(
247            (base.r() as f32 * factor) as u8,
248            (base.g() as f32 * factor) as u8,
249            (base.b() as f32 * factor) as u8,
250        )
251    }
252
253    /// Compute the world-space centroid of a triangle face.
254    #[cfg(feature = "lighting")]
255    #[inline]
256    pub(crate) fn face_world_center(
257        face: &[usize; 3],
258        vertices: &[[f32; 3]],
259        model_matrix: Matrix4<f32>,
260    ) -> Point3<f32> {
261        let v0 = vertices[face[0]];
262        let v1 = vertices[face[1]];
263        let v2 = vertices[face[2]];
264        let cx = (v0[0] + v1[0] + v2[0]) / 3.0;
265        let cy = (v0[1] + v1[1] + v2[1]) / 3.0;
266        let cz = (v0[2] + v1[2] + v2[2]) / 3.0;
267        model_matrix.transform_point(&Point3::new(cx, cy, cz))
268    }
269
270    pub fn set_caps(&mut self, caps: crate::config::ProfileCaps) {
271        self.caps = Some(caps);
272        self.apply_render_defaults(crate::config::render_defaults_for_profile(caps));
273    }
274
275    pub fn clear_caps(&mut self) {
276        self.caps = None;
277    }
278
279    pub fn set_quality_tier(&mut self, tier: crate::config::QualityTier) {
280        self.quality_tier = tier;
281    }
282
283    pub fn set_material_profile(&mut self, profile: crate::config::MaterialProfile) {
284        self.material_profile = profile;
285    }
286
287    pub fn apply_render_defaults(&mut self, defaults: crate::config::RenderDefaults) {
288        self.quality_tier = defaults.quality_tier;
289        self.material_profile = defaults.material_profile;
290    }
291
292    pub(crate) fn resolve_render_mode(&self, mode: &RenderMode) -> RenderMode {
293        #[cfg(not(feature = "lighting"))]
294        {
295            let _ = self;
296            mode.clone()
297        }
298        #[cfg(feature = "lighting")]
299        {
300            use crate::config::{MaterialProfile, QualityTier};
301            match self.quality_tier {
302                QualityTier::Fastest => match mode {
303                    #[cfg(feature = "lighting")]
304                    RenderMode::BlinnPhong { .. }
305                    | RenderMode::GouraudLightDir(_)
306                    | RenderMode::Toon(_, _)
307                    | RenderMode::SolidLightDir(_) => RenderMode::Solid,
308                    _ => mode.clone(),
309                },
310                QualityTier::Balanced => match (self.material_profile, mode) {
311                    (MaterialProfile::Unlit, RenderMode::BlinnPhong { .. })
312                    | (MaterialProfile::Unlit, RenderMode::GouraudLightDir(_))
313                    | (MaterialProfile::Unlit, RenderMode::Toon(_, _))
314                    | (MaterialProfile::Unlit, RenderMode::SolidLightDir(_)) => RenderMode::Solid,
315                    (MaterialProfile::Lambert, RenderMode::BlinnPhong { light_dir, .. }) => {
316                        RenderMode::SolidLightDir(*light_dir)
317                    }
318                    _ => mode.clone(),
319                },
320                QualityTier::Quality => match (self.material_profile, mode) {
321                    (MaterialProfile::Unlit, RenderMode::BlinnPhong { .. })
322                    | (MaterialProfile::Unlit, RenderMode::GouraudLightDir(_))
323                    | (MaterialProfile::Unlit, RenderMode::Toon(_, _))
324                    | (MaterialProfile::Unlit, RenderMode::SolidLightDir(_)) => RenderMode::Solid,
325                    (MaterialProfile::Lambert, RenderMode::BlinnPhong { light_dir, .. }) => {
326                        RenderMode::SolidLightDir(*light_dir)
327                    }
328                    _ => mode.clone(),
329                },
330            }
331        }
332    }
333
334    /// Frustum culling. Returns true if the mesh should be culled.
335    #[inline]
336    pub(crate) fn should_cull_mesh(&self, mesh: &K3dMesh) -> bool {
337        #[cfg(feature = "render-layers")]
338        if !self.camera.layers.intersects(mesh.layers) {
339            return true;
340        }
341
342        #[cfg(feature = "aabb-cull")]
343        {
344            let aabb = mesh.model_aabb();
345            let world_center = mesh
346                .model_matrix
347                .transform_point(&nalgebra::Point3::from(aabb.center));
348            let scale = mesh.similarity.scaling();
349            let radius = aabb.radius() * scale;
350            let m = self.camera.vp_matrix;
351            let planes = Self::frustum_planes_from_vp(&m);
352
353            for plane in &planes {
354                let (a, b, c, d) = (plane[0], plane[1], plane[2], plane[3]);
355                let len = (a * a + b * b + c * c).sqrt();
356                if len <= 0.0 {
357                    continue;
358                }
359                let dist = (a * world_center.x + b * world_center.y + c * world_center.z + d) / len;
360                if dist < -radius {
361                    return true;
362                }
363            }
364            for plane in &planes {
365                let (a, b, c, d) = (plane[0], plane[1], plane[2], plane[3]);
366                let len = (a * a + b * b + c * c).sqrt();
367                if len <= 0.0 {
368                    continue;
369                }
370                if aabb.plane_signed_overshoot(a, b, c, d, len, &mesh.model_matrix) < 0.0 {
371                    return true;
372                }
373            }
374            return false;
375        }
376
377        #[cfg(not(feature = "aabb-cull"))]
378        {
379            let mesh_pos = mesh.get_position();
380            let radius_sq = mesh.compute_bounding_radius_sq();
381            let radius = radius_sq.sqrt();
382            let planes = Self::frustum_planes_from_vp(&self.camera.vp_matrix);
383            for plane in &planes {
384                let (a, b, c, d) = (plane[0], plane[1], plane[2], plane[3]);
385                let len = (a * a + b * b + c * c).sqrt();
386                if len > 0.0 {
387                    let dist = (a * mesh_pos.x + b * mesh_pos.y + c * mesh_pos.z + d) / len;
388                    if dist < -radius {
389                        return true;
390                    }
391                }
392            }
393            false
394        }
395    }
396
397    #[inline]
398    fn frustum_planes_from_vp(m: &Matrix4<f32>) -> [[f32; 4]; 6] {
399        [
400            [
401                m[(3, 0)] + m[(0, 0)],
402                m[(3, 1)] + m[(0, 1)],
403                m[(3, 2)] + m[(0, 2)],
404                m[(3, 3)] + m[(0, 3)],
405            ],
406            [
407                m[(3, 0)] - m[(0, 0)],
408                m[(3, 1)] - m[(0, 1)],
409                m[(3, 2)] - m[(0, 2)],
410                m[(3, 3)] - m[(0, 3)],
411            ],
412            [
413                m[(3, 0)] + m[(1, 0)],
414                m[(3, 1)] + m[(1, 1)],
415                m[(3, 2)] + m[(1, 2)],
416                m[(3, 3)] + m[(1, 3)],
417            ],
418            [
419                m[(3, 0)] - m[(1, 0)],
420                m[(3, 1)] - m[(1, 1)],
421                m[(3, 2)] - m[(1, 2)],
422                m[(3, 3)] - m[(1, 3)],
423            ],
424            [
425                m[(3, 0)] + m[(2, 0)],
426                m[(3, 1)] + m[(2, 1)],
427                m[(3, 2)] + m[(2, 2)],
428                m[(3, 3)] + m[(2, 3)],
429            ],
430            [
431                m[(3, 0)] - m[(2, 0)],
432                m[(3, 1)] - m[(2, 1)],
433                m[(3, 2)] - m[(2, 2)],
434                m[(3, 3)] - m[(2, 3)],
435            ],
436        ]
437    }
438
439    #[inline(always)]
440    fn transform_point(&self, point: &[f32; 3], model_matrix: Matrix4<f32>) -> Option<Point3<i32>> {
441        #[cfg(feature = "fixed-transform")]
442        {
443            return self.transform_point_fixed(point, model_matrix);
444        }
445        #[cfg(not(feature = "fixed-transform"))]
446        {
447            let point = nalgebra::Vector4::new(point[0], point[1], point[2], 1.0);
448            let point = model_matrix * point;
449
450            if point.w < 0.0 {
451                return None;
452            }
453            // `point.w` is the view-space depth (distance along the view
454            // direction) for a standard perspective projection, so this is
455            // a proper "is the view depth within [near, far]" test. This
456            // used to compare pre-divide clip.z instead, which is not
457            // linear in view depth -- the "safe" window it accepted started
458            // well above `near` itself, silently culling geometry closer to
459            // the camera than roughly the midpoint of [near, far]. Matches
460            // the fix already applied to `transform_point_with_w`.
461            if point.w < self.camera.near || point.w > self.camera.far {
462                return None;
463            }
464
465            let point = Point3::from_homogeneous(point)?;
466
467            let x = ((1.0 + point.x) * 0.5 * self.width as f32) as i32;
468            let y = ((1.0 - point.y) * 0.5 * self.height as f32) as i32;
469
470            if x < 0 || x >= self.width as i32 || y < 0 || y >= self.height as i32 {
471                return None;
472            }
473
474            Some(Point3::new(
475                x,
476                y,
477                (point.z * (self.camera.far - self.camera.near) + self.camera.near) as i32,
478            ))
479        }
480    }
481
482    #[cfg(feature = "fixed-transform")]
483    #[inline(always)]
484    fn transform_point_fixed(
485        &self,
486        point: &[f32; 3],
487        model_matrix: Matrix4<f32>,
488    ) -> Option<Point3<i32>> {
489        use embedded_dsp::fixed_point::{Q16, from_q16, to_q16};
490
491        // Q16.16 division that returns `None` on a zero divisor, matching
492        // the early-return-via-`?` control flow below (`div_q16` itself
493        // just returns 0 on divide-by-zero).
494        #[inline(always)]
495        fn div_checked(a: Q16, b: Q16) -> Option<Q16> {
496            if b == 0 {
497                None
498            } else {
499                Some(embedded_dsp::fixed_point::div_q16(a, b))
500            }
501        }
502
503        let point = nalgebra::Vector4::new(point[0], point[1], point[2], 1.0);
504        let point = model_matrix * point;
505
506        if point.w <= 0.0 {
507            return None;
508        }
509        // Same fix as `transform_point`/`transform_point_with_w`: test the
510        // view-space depth (`point.w`) against `[near, far]`, not the
511        // post-divide NDC z (which is confined to roughly `[-1, 1]` and can
512        // never satisfy a "world scale" near/far pair).
513        if point.w < self.camera.near || point.w > self.camera.far {
514            return None;
515        }
516
517        let x_fp = div_checked(to_q16(point.x), to_q16(point.w))?;
518        let y_fp = div_checked(to_q16(point.y), to_q16(point.w))?;
519        let z_ndc = from_q16(div_checked(to_q16(point.z), to_q16(point.w))?);
520
521        let x = ((1.0 + from_q16(x_fp)) * 0.5 * self.width as f32) as i32;
522        let y = ((1.0 - from_q16(y_fp)) * 0.5 * self.height as f32) as i32;
523
524        if x < 0 || x >= self.width as i32 || y < 0 || y >= self.height as i32 {
525            return None;
526        }
527
528        Some(Point3::new(
529            x,
530            y,
531            (z_ndc * (self.camera.far - self.camera.near) + self.camera.near) as i32,
532        ))
533    }
534
535    #[inline(always)]
536    pub fn transform_points<const N: usize>(
537        &self,
538        indices: &[usize; N],
539        vertices: &[[f32; 3]],
540        model_matrix: Matrix4<f32>,
541    ) -> Option<[Point3<i32>; N]> {
542        let mut ret = [Point3::new(0, 0, 0); N];
543
544        for i in 0..N {
545            ret[i] = self.transform_point(&vertices[indices[i]], model_matrix)?;
546        }
547
548        Some(ret)
549    }
550
551    /// Like `transform_point` but also returns the clip-space W for perspective-correct interpolation.
552    /// Returns (screen_point, w_clip). w_clip is the clip-space W before perspective division.
553    fn transform_point_with_w(
554        &self,
555        point: &[f32; 3],
556        model_matrix: Matrix4<f32>,
557    ) -> Option<(Point3<i32>, f32)> {
558        let v = nalgebra::Vector4::new(point[0], point[1], point[2], 1.0);
559        let clip = model_matrix * v;
560        // clip.w is the view-space depth (distance along the view direction).
561        // Previously this compared ndc_z (range -1..+1) against camera.near/far
562        // (world-space values like 0.4 and 20.0), which is a unit mismatch that
563        // caused close particles to bleed through unrendered geometry.
564        if clip.w < self.camera.near || clip.w > self.camera.far {
565            return None;
566        }
567        let ndc_x = clip.x / clip.w;
568        let ndc_y = clip.y / clip.w;
569        let ndc_z = clip.z / clip.w;
570        let x = ((1.0 + ndc_x) * 0.5 * self.width as f32) as i32;
571        let y = ((1.0 - ndc_y) * 0.5 * self.height as f32) as i32;
572        if x < 0 || x >= self.width as i32 || y < 0 || y >= self.height as i32 {
573            return None;
574        }
575        let z = (ndc_z * (self.camera.far - self.camera.near) + self.camera.near) as i32;
576        Some((Point3::new(x, y, z), clip.w))
577    }
578
579    /// Like `transform_points` but also returns clip-space W values for perspective-correct UV.
580    #[inline(always)]
581    pub fn transform_points_with_w<const N: usize>(
582        &self,
583        indices: &[usize; N],
584        vertices: &[[f32; 3]],
585        model_matrix: Matrix4<f32>,
586    ) -> Option<([Point3<i32>; N], [f32; N])> {
587        let mut pts = [Point3::new(0, 0, 0); N];
588        let mut ws = [1.0f32; N];
589        for i in 0..N {
590            let (p, w) = self.transform_point_with_w(&vertices[indices[i]], model_matrix)?;
591            pts[i] = p;
592            ws[i] = w;
593        }
594        Some((pts, ws))
595    }
596
597    /// Position-based backface cull.
598    ///
599    /// Returns `true` when the face should be skipped (camera is on the
600    /// back/outer side of the surface).
601    ///
602    /// Using the camera *position* rather than *direction* means the result is
603    /// independent of where the camera looks — a horizontal floor stays visible
604    /// even when the camera pitches up or down.  The direction-based test
605    /// (`camera.get_direction() · normal`) incorrectly flips sign as soon as
606    /// pitch ≠ 0, culling the floor on upward tilt and the ceiling on downward.
607    #[inline]
608    pub(crate) fn is_backface(
609        &self,
610        face: &[usize; 3],
611        vertices: &[[f32; 3]],
612        model_matrix: Matrix4<f32>,
613        world_normal: &Vector3<f32>,
614    ) -> bool {
615        let v0 = vertices[face[0]];
616        let v0_world = if model_matrix == Matrix4::identity() {
617            Point3::new(v0[0], v0[1], v0[2])
618        } else {
619            model_matrix.transform_point(&Point3::new(v0[0], v0[1], v0[2]))
620        };
621        (self.camera.position - v0_world).dot(world_normal) < 0.0
622    }
623
624    // ── Near-plane triangle clipping ──────────────────────────────────────────
625    //
626    // The engine's vertex-by-vertex `transform_point` returns `None` for any
627    // vertex that projects outside the screen, causing the whole triangle to
628    // be dropped.  For interior scenes (floor, ceiling, walls close to the
629    // camera) this makes large surfaces invisible.
630    //
631    // The fix is a one-plane Sutherland-Hodgman clip against w = CLIP_NEAR_W
632    // before perspective divide.  Clipped triangles are emitted directly after
633    // projection; the rasterizer's existing scanline bounds-checks handle any
634    // remaining X/Y screen overshoot safely.
635
636    /// Project a single homogeneous clip-space vertex to integer screen coords.
637    /// Unlike `transform_point`, this does NOT reject off-screen X/Y — the
638    /// rasterizer clips scanlines to screen bounds already.
639    /// Screen coordinates are guard-banded to ±8× the framebuffer dimension so
640    /// the rasterizer scanline loop is always bounded even for near-plane clips.
641    #[inline]
642    fn clip_to_screen(&self, c: Vector4<f32>) -> Option<Point3<i32>> {
643        if c.w <= 0.0 {
644            return None;
645        }
646        let mut ndc = Point3::from_homogeneous(c)?;
647        if self.vertex_snap_bits > 0 {
648            let scale = (1u32 << self.vertex_snap_bits) as f32;
649            ndc.x = (ndc.x * scale).round() / scale;
650            ndc.y = (ndc.y * scale).round() / scale;
651        }
652        let w = self.width as f32;
653        let h = self.height as f32;
654        let x = ((1.0 + ndc.x) * 0.5 * w).clamp(-w * 8.0, w * 9.0) as i32;
655        let y = ((1.0 - ndc.y) * 0.5 * h).clamp(-h * 8.0, h * 9.0) as i32;
656        let depth = (ndc.z * (self.camera.far - self.camera.near) + self.camera.near) as i32;
657        Some(Point3::new(x, y, depth))
658    }
659
660    /// Project three clip-space vertices and emit one `ColoredTriangleWithDepth`
661    /// command if all three project successfully.
662    #[inline]
663    fn project_and_emit<F>(
664        &self,
665        c0: Vector4<f32>,
666        c1: Vector4<f32>,
667        c2: Vector4<f32>,
668        color: Rgb565,
669        callback: &mut F,
670    ) where
671        F: FnMut(DrawPrimitive),
672    {
673        if let (Some(p0), Some(p1), Some(p2)) = (
674            self.clip_to_screen(c0),
675            self.clip_to_screen(c1),
676            self.clip_to_screen(c2),
677        ) {
678            callback(DrawPrimitive::ColoredTriangleWithDepth {
679                points: [p0.xy(), p1.xy(), p2.xy()],
680                depths: [p0.z as f32, p1.z as f32, p2.z as f32],
681                color,
682            });
683        }
684    }
685
686    /// One Sutherland-Hodgman pass against a single clip-space plane.
687    ///
688    /// `dist(v) >= 0.0` means the vertex is on the inside of the plane.
689    /// Returns the number of vertices written into `output`.
690    fn clip_polygon_plane(
691        input: &[Vector4<f32>],
692        output: &mut [Vector4<f32>; 8],
693        dist: impl Fn(Vector4<f32>) -> f32,
694    ) -> usize {
695        let n = input.len();
696        let mut m = 0usize;
697        for i in 0..n {
698            let prev = input[(n + i - 1) % n];
699            let curr = input[i];
700            let d_prev = dist(prev);
701            let d_curr = dist(curr);
702            if d_curr >= 0.0 {
703                if d_prev < 0.0 {
704                    // Crossing from outside → inside: emit the boundary vertex.
705                    let t = d_prev / (d_prev - d_curr);
706                    if m < 8 {
707                        output[m] = prev + (curr - prev) * t;
708                        m += 1;
709                    }
710                }
711                if m < 8 {
712                    output[m] = curr;
713                    m += 1;
714                }
715            } else if d_prev >= 0.0 {
716                // Crossing from inside → outside: emit the boundary vertex.
717                let t = d_prev / (d_prev - d_curr);
718                if m < 8 {
719                    output[m] = prev + (curr - prev) * t;
720                    m += 1;
721                }
722            }
723        }
724        m
725    }
726
727    /// Clip a triangle against all 5 frustum planes and emit the resulting
728    /// fan of screen-space triangles.
729    ///
730    /// Uses a full Sutherland-Hodgman pass in clip space (near, left, right,
731    /// bottom, top).  Clipping against only the near plane left NDC x/y values
732    /// like ±13 for near-clipped vertices touching a wall at a grazing angle,
733    /// causing the projected triangle to cover only a sliver instead of the
734    /// full wall — producing the black triangular holes.
735    pub(crate) fn emit_clipped<F>(&self, clip: [Vector4<f32>; 3], color: Rgb565, callback: &mut F)
736    where
737        F: FnMut(DrawPrimitive),
738    {
739        let nw = self.camera.near;
740
741        let mut a = [Vector4::zeros(); 8];
742        let mut b = [Vector4::zeros(); 8];
743        a[0] = clip[0];
744        a[1] = clip[1];
745        a[2] = clip[2];
746
747        // near:   w >= nw
748        let n = Self::clip_polygon_plane(&a[..3], &mut b, |v| v.w - nw);
749        if n < 3 {
750            return;
751        }
752        // left:   x >= -w  →  x + w >= 0
753        let n = Self::clip_polygon_plane(&b[..n], &mut a, |v| v.x + v.w);
754        if n < 3 {
755            return;
756        }
757        // right:  x <=  w  →  w - x >= 0
758        let n = Self::clip_polygon_plane(&a[..n], &mut b, |v| v.w - v.x);
759        if n < 3 {
760            return;
761        }
762        // bottom: y >= -w  →  y + w >= 0
763        let n = Self::clip_polygon_plane(&b[..n], &mut a, |v| v.y + v.w);
764        if n < 3 {
765            return;
766        }
767        // top:    y <=  w  →  w - y >= 0
768        let n = Self::clip_polygon_plane(&a[..n], &mut b, |v| v.w - v.y);
769        if n < 3 {
770            return;
771        }
772
773        // Triangulate the clipped polygon as a fan from vertex 0.
774        for i in 1..n - 1 {
775            self.project_and_emit(b[0], b[i], b[i + 1], color, callback);
776        }
777    }
778
779    fn render<'a, MS, F>(&self, meshes: MS, mut callback: F)
780    where
781        MS: IntoIterator<Item = &'a K3dMesh<'a>>,
782        F: FnMut(DrawPrimitive),
783    {
784        for mesh in meshes {
785            if mesh.geometry.vertices.is_empty() {
786                continue;
787            }
788
789            // Frustum culling: Skip meshes that are completely outside the view frustum
790            // This can improve performance by 50-90% by avoiding transformation and rendering
791            // of off-screen objects
792            if self.should_cull_mesh(mesh) {
793                continue;
794            }
795
796            // LOD Selection: Choose geometry based on distance from camera
797            let mesh_pos = mesh.get_position();
798            let distance = (mesh_pos - self.camera.position).norm();
799            let geometry = mesh.select_lod(distance);
800            #[cfg(feature = "lod-crossfade")]
801            let alpha_override = mesh.draw_alpha.get();
802            #[cfg(feature = "lod-crossfade")]
803            let mut emit = |prim: DrawPrimitive| {
804                let prim = match alpha_override {
805                    Some(a) => apply_draw_alpha(prim, a),
806                    None => prim,
807                };
808                callback(prim);
809            };
810            #[cfg(not(feature = "lod-crossfade"))]
811            let mut emit = |prim: DrawPrimitive| {
812                callback(prim);
813            };
814
815            let transform_matrix = self.camera.vp_matrix * mesh.model_matrix;
816
817            #[cfg(feature = "textured")]
818            let is_textured = matches!(
819                self.resolve_render_mode(&mesh.render_mode),
820                RenderMode::Textured | RenderMode::TexturedGouraud(_) | RenderMode::MatCap
821            );
822            #[cfg(not(feature = "textured"))]
823            let is_textured = false;
824
825            let mut v_cache_plain: [Option<Point3<i32>>; 256] = [None; 256];
826            #[cfg(feature = "textured")]
827            let mut v_cache_w: [Option<(Point3<i32>, f32)>; 256] = [None; 256];
828
829            let cache_limit = geometry.vertices.len().min(256);
830            if is_textured {
831                #[cfg(feature = "textured")]
832                for i in 0..cache_limit {
833                    v_cache_w[i] =
834                        self.transform_point_with_w(&geometry.vertices[i], transform_matrix);
835                }
836            } else {
837                for i in 0..cache_limit {
838                    v_cache_plain[i] =
839                        self.transform_point(&geometry.vertices[i], transform_matrix);
840                }
841            }
842
843            let mut get_pt = |idx: usize| -> Option<Point3<i32>> {
844                if idx < 256 {
845                    v_cache_plain[idx]
846                } else {
847                    self.transform_point(&geometry.vertices[idx], transform_matrix)
848                }
849            };
850
851            #[cfg(feature = "textured")]
852            let get_pt_w = |idx: usize| -> Option<(Point3<i32>, f32)> {
853                if idx < 256 {
854                    v_cache_w[idx]
855                } else {
856                    self.transform_point_with_w(&geometry.vertices[idx], transform_matrix)
857                }
858            };
859
860            let tf_face = |face: &[usize; 3]| -> Option<[Point3<i32>; 3]> {
861                Some([get_pt(face[0])?, get_pt(face[1])?, get_pt(face[2])?])
862            };
863
864            #[cfg(feature = "textured")]
865            let tf_face_w = |face: &[usize; 3]| -> Option<([Point3<i32>; 3], [f32; 3])> {
866                let (p0, w0) = get_pt_w(face[0])?;
867                let (p1, w1) = get_pt_w(face[1])?;
868                let (p2, w2) = get_pt_w(face[2])?;
869                Some(([p0, p1, p2], [w0, w1, w2]))
870            };
871
872            if let Some(out_color) = mesh.outline_color {
873                if mesh.outline_width > 0.0 {
874                    let has_vertex_normals = !geometry.vertex_normals.is_empty();
875                    let has_face_normals = !geometry.normals.is_empty();
876
877                    for (face_idx, face) in geometry.faces.iter().enumerate() {
878                        let face_normal = if has_face_normals {
879                            Vector3::new(
880                                geometry.normals[face_idx][0],
881                                geometry.normals[face_idx][1],
882                                geometry.normals[face_idx][2],
883                            )
884                        } else {
885                            let v0 = Vector3::new(
886                                geometry.vertices[face[0]][0],
887                                geometry.vertices[face[0]][1],
888                                geometry.vertices[face[0]][2],
889                            );
890                            let v1 = Vector3::new(
891                                geometry.vertices[face[1]][0],
892                                geometry.vertices[face[1]][1],
893                                geometry.vertices[face[1]][2],
894                            );
895                            let v2 = Vector3::new(
896                                geometry.vertices[face[2]][0],
897                                geometry.vertices[face[2]][1],
898                                geometry.vertices[face[2]][2],
899                            );
900                            (v1 - v0).cross(&(v2 - v0)).normalize()
901                        };
902
903                        let transformed_normal = mesh.model_matrix.transform_vector(&face_normal);
904                        if !self.is_backface(
905                            face,
906                            geometry.vertices,
907                            mesh.model_matrix,
908                            &transformed_normal,
909                        ) {
910                            continue;
911                        }
912
913                        let mut pts = [Point3::origin(); 3];
914                        let mut valid = true;
915                        for i in 0..3 {
916                            let vn = if has_vertex_normals {
917                                Vector3::new(
918                                    geometry.vertex_normals[face[i]][0],
919                                    geometry.vertex_normals[face[i]][1],
920                                    geometry.vertex_normals[face[i]][2],
921                                )
922                            } else {
923                                face_normal
924                            };
925                            let vpos = geometry.vertices[face[i]];
926                            let ext_v = [
927                                vpos[0] + vn.x * mesh.outline_width,
928                                vpos[1] + vn.y * mesh.outline_width,
929                                vpos[2] + vn.z * mesh.outline_width,
930                            ];
931                            if let Some(pt) = self.transform_point(&ext_v, transform_matrix) {
932                                pts[i] = pt;
933                            } else {
934                                valid = false;
935                                break;
936                            }
937                        }
938
939                        if valid {
940                            emit(DrawPrimitive::ColoredTriangleWithDepth {
941                                points: [pts[0].xy(), pts[1].xy(), pts[2].xy()],
942                                depths: [pts[0].z as f32, pts[1].z as f32, pts[2].z as f32],
943                                color: out_color,
944                            });
945                        }
946                    }
947                }
948            }
949
950            let render_mode = self.resolve_render_mode(&mesh.render_mode);
951            match render_mode {
952                RenderMode::Points => {
953                    let screen_space_points = (0..geometry.vertices.len()).filter_map(&mut get_pt);
954
955                    if geometry.colors.len() == geometry.vertices.len() {
956                        for (point, color) in screen_space_points.zip(geometry.colors) {
957                            emit(DrawPrimitive::ColoredPoint(point.xy(), *color));
958                        }
959                    } else {
960                        for point in screen_space_points {
961                            emit(DrawPrimitive::ColoredPoint(point.xy(), mesh.color));
962                        }
963                    }
964                }
965
966                RenderMode::Lines if !geometry.lines.is_empty() => {
967                    for line in geometry.lines {
968                        if let (Some(p1), Some(p2)) = (get_pt(line[0]), get_pt(line[1])) {
969                            emit(DrawPrimitive::Line([p1.xy(), p2.xy()], mesh.color));
970                        }
971                    }
972                }
973
974                RenderMode::Lines if !geometry.faces.is_empty() => {
975                    for face in geometry.faces {
976                        if let Some([p1, p2, p3]) = tf_face(face) {
977                            emit(DrawPrimitive::Line([p1.xy(), p2.xy()], mesh.color));
978                            emit(DrawPrimitive::Line([p2.xy(), p3.xy()], mesh.color));
979                            emit(DrawPrimitive::Line([p3.xy(), p1.xy()], mesh.color));
980                        }
981                    }
982                }
983
984                RenderMode::Lines => {}
985
986                #[cfg(feature = "lighting")]
987                RenderMode::SolidLightDir(direction) => {
988                    let color_as_float = Vector3::new(
989                        mesh.color.r() as f32 / 32.0,
990                        mesh.color.g() as f32 / 64.0,
991                        mesh.color.b() as f32 / 32.0,
992                    );
993                    let ambient_color = color_as_float * 0.1;
994                    let adjusted_dir = Vector3::new(direction.x, direction.y, -direction.z);
995
996                    for (face, normal) in geometry.faces.iter().zip(geometry.normals.iter()) {
997                        let normal = Vector3::new(normal[0], normal[1], normal[2]);
998                        let transformed_normal = mesh.model_matrix.transform_vector(&normal);
999                        if self.is_backface(
1000                            face,
1001                            geometry.vertices,
1002                            mesh.model_matrix,
1003                            &transformed_normal,
1004                        ) {
1005                            continue;
1006                        }
1007
1008                        if let Some([p1, p2, p3]) = tf_face(face) {
1009                            let intensity = transformed_normal.dot(&adjusted_dir).max(0.0);
1010                            let final_color = color_as_float * intensity + ambient_color;
1011                            let final_color = Vector3::new(
1012                                final_color.x.clamp(0.0, 1.0),
1013                                final_color.y.clamp(0.0, 1.0),
1014                                final_color.z.clamp(0.0, 1.0),
1015                            );
1016                            let mut color = Rgb565::new(
1017                                (final_color.x * 31.0) as u8,
1018                                (final_color.y * 63.0) as u8,
1019                                (final_color.z * 31.0) as u8,
1020                            );
1021                            if !self.point_lights.is_empty() {
1022                                let wc = Self::face_world_center(
1023                                    face,
1024                                    geometry.vertices,
1025                                    mesh.model_matrix,
1026                                );
1027                                color = Self::add_tint(color, self.light_tint_at(wc));
1028                            }
1029                            emit(DrawPrimitive::ColoredTriangleWithDepth {
1030                                points: [p1.xy(), p2.xy(), p3.xy()],
1031                                depths: [p1.z as f32, p2.z as f32, p3.z as f32],
1032                                color,
1033                            });
1034                        }
1035                    }
1036                }
1037
1038                #[cfg(feature = "lighting")]
1039                RenderMode::GouraudLightDir(direction) => {
1040                    let color_as_float = Vector3::new(
1041                        mesh.color.r() as f32 / 32.0,
1042                        mesh.color.g() as f32 / 64.0,
1043                        mesh.color.b() as f32 / 32.0,
1044                    );
1045                    let ambient_color = color_as_float * 0.1;
1046                    let adjusted_dir = Vector3::new(direction.x, direction.y, -direction.z);
1047
1048                    for (face, face_normal) in geometry.faces.iter().zip(geometry.normals.iter()) {
1049                        let fn_vec = Vector3::new(face_normal[0], face_normal[1], face_normal[2]);
1050                        let transformed_fn = mesh.model_matrix.transform_vector(&fn_vec);
1051
1052                        if self.is_backface(
1053                            face,
1054                            geometry.vertices,
1055                            mesh.model_matrix,
1056                            &transformed_fn,
1057                        ) {
1058                            continue;
1059                        }
1060
1061                        if let Some([p1, p2, p3]) = tf_face(face) {
1062                            let vertex_colors: [Rgb565; 3] = core::array::from_fn(|k| {
1063                                let vn = if !geometry.vertex_normals.is_empty() {
1064                                    let vn_arr = geometry.vertex_normals[face[k]];
1065                                    let vn_vec = Vector3::new(vn_arr[0], vn_arr[1], vn_arr[2]);
1066                                    mesh.model_matrix.transform_vector(&vn_vec)
1067                                } else {
1068                                    transformed_fn
1069                                };
1070
1071                                let intensity = vn.dot(&adjusted_dir).max(0.0);
1072                                let c = color_as_float * intensity + ambient_color;
1073                                let mut vc = Rgb565::new(
1074                                    (c.x.clamp(0.0, 1.0) * 31.0) as u8,
1075                                    (c.y.clamp(0.0, 1.0) * 63.0) as u8,
1076                                    (c.z.clamp(0.0, 1.0) * 31.0) as u8,
1077                                );
1078                                if !self.point_lights.is_empty() {
1079                                    let vpos = geometry.vertices[face[k]];
1080                                    let wp = mesh
1081                                        .model_matrix
1082                                        .transform_point(&Point3::new(vpos[0], vpos[1], vpos[2]));
1083                                    vc = Self::add_tint(vc, self.light_tint_at(wp));
1084                                }
1085                                vc
1086                            });
1087
1088                            emit(DrawPrimitive::GouraudTriangleWithDepth {
1089                                points: [p1.xy(), p2.xy(), p3.xy()],
1090                                depths: [p1.z as f32, p2.z as f32, p3.z as f32],
1091                                colors: vertex_colors,
1092                            });
1093                        }
1094                    }
1095                }
1096
1097                #[cfg(feature = "lighting")]
1098                RenderMode::Toon(direction, bands) => {
1099                    let color_as_float = Vector3::new(
1100                        mesh.color.r() as f32 / 32.0,
1101                        mesh.color.g() as f32 / 64.0,
1102                        mesh.color.b() as f32 / 32.0,
1103                    );
1104                    let ambient_color = color_as_float * 0.15;
1105                    let adjusted_dir = Vector3::new(direction.x, direction.y, -direction.z);
1106                    let bands_f = bands.max(1) as f32;
1107
1108                    for (face, normal) in geometry.faces.iter().zip(geometry.normals.iter()) {
1109                        let normal = Vector3::new(normal[0], normal[1], normal[2]);
1110                        let transformed_normal = mesh.model_matrix.transform_vector(&normal);
1111                        if self.is_backface(
1112                            face,
1113                            geometry.vertices,
1114                            mesh.model_matrix,
1115                            &transformed_normal,
1116                        ) {
1117                            continue;
1118                        }
1119
1120                        if let Some([p1, p2, p3]) = tf_face(face) {
1121                            let raw_intensity = transformed_normal.dot(&adjusted_dir).max(0.0);
1122                            let intensity =
1123                                ((raw_intensity * bands_f).round() / bands_f).clamp(0.0, 1.0);
1124
1125                            let final_color = color_as_float * intensity + ambient_color;
1126                            let final_color = Vector3::new(
1127                                final_color.x.clamp(0.0, 1.0),
1128                                final_color.y.clamp(0.0, 1.0),
1129                                final_color.z.clamp(0.0, 1.0),
1130                            );
1131                            let mut color = Rgb565::new(
1132                                (final_color.x * 31.0) as u8,
1133                                (final_color.y * 63.0) as u8,
1134                                (final_color.z * 31.0) as u8,
1135                            );
1136                            if !self.point_lights.is_empty() {
1137                                let wc = Self::face_world_center(
1138                                    face,
1139                                    geometry.vertices,
1140                                    mesh.model_matrix,
1141                                );
1142                                color = Self::add_tint(color, self.light_tint_at(wc));
1143                            }
1144                            emit(DrawPrimitive::ColoredTriangleWithDepth {
1145                                points: [p1.xy(), p2.xy(), p3.xy()],
1146                                depths: [p1.z as f32, p2.z as f32, p3.z as f32],
1147                                color,
1148                            });
1149                        }
1150                    }
1151                }
1152
1153                #[cfg(feature = "lighting")]
1154                RenderMode::BlinnPhong {
1155                    light_dir,
1156                    specular_intensity,
1157                    shininess,
1158                } => {
1159                    // Pre-compute lighting constants (once per mesh, not per face)
1160                    let color_as_float = Vector3::new(
1161                        mesh.color.r() as f32 / 32.0,
1162                        mesh.color.g() as f32 / 64.0,
1163                        mesh.color.b() as f32 / 32.0,
1164                    );
1165
1166                    // Pre-compute ambient lighting term
1167                    let ambient_color = color_as_float * 0.1;
1168
1169                    // Pre-compute adjusted light direction
1170                    // Negate only Z component of direction to fix front/back while keeping left/right
1171                    let adjusted_light_dir = Vector3::new(light_dir.x, light_dir.y, -light_dir.z);
1172
1173                    // Normalize light direction
1174                    let light_dir_normalized = adjusted_light_dir.normalize();
1175
1176                    for (face, normal) in geometry.faces.iter().zip(geometry.normals.iter()) {
1177                        //Backface culling
1178                        let normal = Vector3::new(normal[0], normal[1], normal[2]);
1179                        let transformed_normal = mesh.model_matrix.transform_vector(&normal);
1180                        let normalized_normal = transformed_normal.normalize();
1181
1182                        // Backface culling: cull faces pointing away from camera
1183                        if self.is_backface(
1184                            face,
1185                            geometry.vertices,
1186                            mesh.model_matrix,
1187                            &normalized_normal,
1188                        ) {
1189                            continue;
1190                        }
1191
1192                        if let Some([p1, p2, p3]) = tf_face(face) {
1193                            // Calculate face center in world space for view direction
1194                            let v0 = geometry.vertices[face[0]];
1195                            let v1 = geometry.vertices[face[1]];
1196                            let v2 = geometry.vertices[face[2]];
1197                            let face_center = Point3::new(
1198                                (v0[0] + v1[0] + v2[0]) / 3.0,
1199                                (v0[1] + v1[1] + v2[1]) / 3.0,
1200                                (v0[2] + v1[2] + v2[2]) / 3.0,
1201                            );
1202                            let face_center_world = mesh.model_matrix.transform_point(&face_center);
1203
1204                            // View direction: from face to camera
1205                            let view_dir = (self.camera.position - face_center_world).normalize();
1206
1207                            // Blinn-Phong half vector: H = normalize(L + V)
1208                            let half_vector = (light_dir_normalized + view_dir).normalize();
1209
1210                            // Diffuse term: N·L
1211                            let diffuse_intensity =
1212                                normalized_normal.dot(&light_dir_normalized).max(0.0);
1213
1214                            // Specular term: (N·H)^shininess
1215                            let specular_term =
1216                                normalized_normal.dot(&half_vector).max(0.0).powf(shininess);
1217
1218                            // Compute final color: ambient + diffuse + specular
1219                            let diffuse_color = color_as_float * diffuse_intensity;
1220                            let specular_color =
1221                                Vector3::new(1.0, 1.0, 1.0) * specular_term * specular_intensity;
1222                            let final_color = ambient_color + diffuse_color + specular_color;
1223
1224                            let final_color = Vector3::new(
1225                                final_color.x.clamp(0.0, 1.0),
1226                                final_color.y.clamp(0.0, 1.0),
1227                                final_color.z.clamp(0.0, 1.0),
1228                            );
1229
1230                            let mut color = Rgb565::new(
1231                                (final_color.x * 31.0) as u8,
1232                                (final_color.y * 63.0) as u8,
1233                                (final_color.z * 31.0) as u8,
1234                            );
1235                            if !self.point_lights.is_empty() {
1236                                color =
1237                                    Self::add_tint(color, self.light_tint_at(face_center_world));
1238                            }
1239                            emit(DrawPrimitive::ColoredTriangleWithDepth {
1240                                points: [p1.xy(), p2.xy(), p3.xy()],
1241                                depths: [p1.z as f32, p2.z as f32, p3.z as f32],
1242                                color,
1243                            });
1244                        }
1245                    }
1246                }
1247
1248                RenderMode::Solid => {
1249                    if geometry.normals.is_empty() {
1250                        for face in geometry.faces.iter() {
1251                            #[cfg(feature = "lighting")]
1252                            let color = if !self.point_lights.is_empty() {
1253                                let wc = Self::face_world_center(
1254                                    face,
1255                                    geometry.vertices,
1256                                    mesh.model_matrix,
1257                                );
1258                                Self::add_tint(mesh.color, self.light_tint_at(wc))
1259                            } else {
1260                                mesh.color
1261                            };
1262                            #[cfg(not(feature = "lighting"))]
1263                            let color = mesh.color;
1264                            let v = &geometry.vertices;
1265                            let clip = [
1266                                transform_matrix
1267                                    * Vector4::new(
1268                                        v[face[0]][0],
1269                                        v[face[0]][1],
1270                                        v[face[0]][2],
1271                                        1.0,
1272                                    ),
1273                                transform_matrix
1274                                    * Vector4::new(
1275                                        v[face[1]][0],
1276                                        v[face[1]][1],
1277                                        v[face[1]][2],
1278                                        1.0,
1279                                    ),
1280                                transform_matrix
1281                                    * Vector4::new(
1282                                        v[face[2]][0],
1283                                        v[face[2]][1],
1284                                        v[face[2]][2],
1285                                        1.0,
1286                                    ),
1287                            ];
1288                            self.emit_clipped(clip, color, &mut callback);
1289                        }
1290                    } else {
1291                        for (face, normal) in geometry.faces.iter().zip(geometry.normals) {
1292                            let normal = Vector3::new(normal[0], normal[1], normal[2]);
1293                            let transformed_normal = mesh.model_matrix.transform_vector(&normal);
1294                            if self.is_backface(
1295                                face,
1296                                geometry.vertices,
1297                                mesh.model_matrix,
1298                                &transformed_normal,
1299                            ) {
1300                                continue;
1301                            }
1302                            #[cfg(feature = "lighting")]
1303                            let color = if !self.point_lights.is_empty() {
1304                                let wc = Self::face_world_center(
1305                                    face,
1306                                    geometry.vertices,
1307                                    mesh.model_matrix,
1308                                );
1309                                Self::add_tint(mesh.color, self.light_tint_at(wc))
1310                            } else {
1311                                mesh.color
1312                            };
1313                            #[cfg(not(feature = "lighting"))]
1314                            let color = mesh.color;
1315                            let v = &geometry.vertices;
1316                            let clip = [
1317                                transform_matrix
1318                                    * Vector4::new(
1319                                        v[face[0]][0],
1320                                        v[face[0]][1],
1321                                        v[face[0]][2],
1322                                        1.0,
1323                                    ),
1324                                transform_matrix
1325                                    * Vector4::new(
1326                                        v[face[1]][0],
1327                                        v[face[1]][1],
1328                                        v[face[1]][2],
1329                                        1.0,
1330                                    ),
1331                                transform_matrix
1332                                    * Vector4::new(
1333                                        v[face[2]][0],
1334                                        v[face[2]][1],
1335                                        v[face[2]][2],
1336                                        1.0,
1337                                    ),
1338                            ];
1339                            self.emit_clipped(clip, color, &mut callback);
1340                        }
1341                    }
1342                }
1343
1344                #[cfg(feature = "lighting")]
1345                RenderMode::SectorBright(brightness) => {
1346                    if geometry.normals.is_empty() {
1347                        for face in geometry.faces.iter() {
1348                            let wc =
1349                                Self::face_world_center(face, geometry.vertices, mesh.model_matrix);
1350                            let mut color = self.sector_shaded_color(mesh.color, brightness, wc);
1351                            if !self.point_lights.is_empty() {
1352                                color = Self::add_tint(color, self.light_tint_at(wc));
1353                            }
1354                            let v = &geometry.vertices;
1355                            let clip = [
1356                                transform_matrix
1357                                    * Vector4::new(
1358                                        v[face[0]][0],
1359                                        v[face[0]][1],
1360                                        v[face[0]][2],
1361                                        1.0,
1362                                    ),
1363                                transform_matrix
1364                                    * Vector4::new(
1365                                        v[face[1]][0],
1366                                        v[face[1]][1],
1367                                        v[face[1]][2],
1368                                        1.0,
1369                                    ),
1370                                transform_matrix
1371                                    * Vector4::new(
1372                                        v[face[2]][0],
1373                                        v[face[2]][1],
1374                                        v[face[2]][2],
1375                                        1.0,
1376                                    ),
1377                            ];
1378                            self.emit_clipped(clip, color, &mut callback);
1379                        }
1380                    } else {
1381                        for (face, normal) in geometry.faces.iter().zip(geometry.normals) {
1382                            let normal = Vector3::new(normal[0], normal[1], normal[2]);
1383                            let transformed_normal = mesh.model_matrix.transform_vector(&normal);
1384                            if self.is_backface(
1385                                face,
1386                                geometry.vertices,
1387                                mesh.model_matrix,
1388                                &transformed_normal,
1389                            ) {
1390                                continue;
1391                            }
1392                            let wc =
1393                                Self::face_world_center(face, geometry.vertices, mesh.model_matrix);
1394                            let mut color = self.sector_shaded_color(mesh.color, brightness, wc);
1395                            if !self.point_lights.is_empty() {
1396                                color = Self::add_tint(color, self.light_tint_at(wc));
1397                            }
1398                            let v = &geometry.vertices;
1399                            let clip = [
1400                                transform_matrix
1401                                    * Vector4::new(
1402                                        v[face[0]][0],
1403                                        v[face[0]][1],
1404                                        v[face[0]][2],
1405                                        1.0,
1406                                    ),
1407                                transform_matrix
1408                                    * Vector4::new(
1409                                        v[face[1]][0],
1410                                        v[face[1]][1],
1411                                        v[face[1]][2],
1412                                        1.0,
1413                                    ),
1414                                transform_matrix
1415                                    * Vector4::new(
1416                                        v[face[2]][0],
1417                                        v[face[2]][1],
1418                                        v[face[2]][2],
1419                                        1.0,
1420                                    ),
1421                            ];
1422                            self.emit_clipped(clip, color, &mut callback);
1423                        }
1424                    }
1425                }
1426
1427                #[cfg(feature = "textured")]
1428                RenderMode::Textured => {
1429                    // Requires both a texture and per-vertex UVs; silently
1430                    // skip the mesh (move to the next one) if either is
1431                    // missing, same graceful-degradation style as `Lines`
1432                    // with no `lines`/`faces` data.
1433                    let Some(texture_id) = geometry.texture_id else {
1434                        continue;
1435                    };
1436                    if geometry.uvs.is_empty() {
1437                        continue;
1438                    }
1439
1440                    // Unlike `Solid`, this doesn't route through
1441                    // `emit_clipped` (which only carries a flat `Rgb565`,
1442                    // not per-vertex UVs) -- a face with any vertex behind
1443                    // the near plane or outside the frustum is dropped
1444                    // whole, same as `GouraudLightDir`/`BlinnPhong`.
1445                    if geometry.normals.is_empty() {
1446                        for face in geometry.faces.iter() {
1447                            if let Some((points, ws)) = tf_face_w(face) {
1448                                emit(DrawPrimitive::TexturedTriangleWithDepth {
1449                                    points: [points[0].xy(), points[1].xy(), points[2].xy()],
1450                                    depths: [
1451                                        points[0].z as f32,
1452                                        points[1].z as f32,
1453                                        points[2].z as f32,
1454                                    ],
1455                                    ws,
1456                                    uvs: [
1457                                        geometry.uvs[face[0]],
1458                                        geometry.uvs[face[1]],
1459                                        geometry.uvs[face[2]],
1460                                    ],
1461                                    texture_id,
1462                                });
1463                            }
1464                        }
1465                    } else {
1466                        for (face, normal) in geometry.faces.iter().zip(geometry.normals) {
1467                            let normal = Vector3::new(normal[0], normal[1], normal[2]);
1468                            let transformed_normal = mesh.model_matrix.transform_vector(&normal);
1469                            if self.is_backface(
1470                                face,
1471                                geometry.vertices,
1472                                mesh.model_matrix,
1473                                &transformed_normal,
1474                            ) {
1475                                continue;
1476                            }
1477                            if let Some((points, ws)) = tf_face_w(face) {
1478                                emit(DrawPrimitive::TexturedTriangleWithDepth {
1479                                    points: [points[0].xy(), points[1].xy(), points[2].xy()],
1480                                    depths: [
1481                                        points[0].z as f32,
1482                                        points[1].z as f32,
1483                                        points[2].z as f32,
1484                                    ],
1485                                    ws,
1486                                    uvs: [
1487                                        geometry.uvs[face[0]],
1488                                        geometry.uvs[face[1]],
1489                                        geometry.uvs[face[2]],
1490                                    ],
1491                                    texture_id,
1492                                });
1493                            }
1494                        }
1495                    }
1496                }
1497                #[cfg(feature = "textured")]
1498                RenderMode::TexturedGouraud(direction) => {
1499                    let Some(texture_id) = geometry.texture_id else {
1500                        continue;
1501                    };
1502                    if geometry.uvs.is_empty() {
1503                        continue;
1504                    }
1505                    let color_as_float = Vector3::new(
1506                        mesh.color.r() as f32 / 32.0,
1507                        mesh.color.g() as f32 / 64.0,
1508                        mesh.color.b() as f32 / 32.0,
1509                    );
1510                    let ambient_color = color_as_float * 0.1;
1511                    let adjusted_dir = Vector3::new(direction.x, direction.y, -direction.z);
1512
1513                    if geometry.normals.is_empty() {
1514                        for face in geometry.faces.iter() {
1515                            if let Some((points, ws)) = tf_face_w(face) {
1516                                let vertex_colors = [mesh.color, mesh.color, mesh.color];
1517                                emit(DrawPrimitive::TexturedGouraudTriangleWithDepth {
1518                                    points: [points[0].xy(), points[1].xy(), points[2].xy()],
1519                                    depths: [
1520                                        points[0].z as f32,
1521                                        points[1].z as f32,
1522                                        points[2].z as f32,
1523                                    ],
1524                                    ws,
1525                                    uvs: [
1526                                        geometry.uvs[face[0]],
1527                                        geometry.uvs[face[1]],
1528                                        geometry.uvs[face[2]],
1529                                    ],
1530                                    colors: vertex_colors,
1531                                    texture_id,
1532                                });
1533                            }
1534                        }
1535                    } else {
1536                        for (face, face_normal) in
1537                            geometry.faces.iter().zip(geometry.normals.iter())
1538                        {
1539                            let fn_vec =
1540                                Vector3::new(face_normal[0], face_normal[1], face_normal[2]);
1541                            let transformed_fn = mesh.model_matrix.transform_vector(&fn_vec);
1542
1543                            if self.is_backface(
1544                                face,
1545                                geometry.vertices,
1546                                mesh.model_matrix,
1547                                &transformed_fn,
1548                            ) {
1549                                continue;
1550                            }
1551
1552                            if let Some((points, ws)) = tf_face_w(face) {
1553                                let vertex_colors: [Rgb565; 3] = core::array::from_fn(|k| {
1554                                    let vn = if !geometry.vertex_normals.is_empty() {
1555                                        let vn_arr = geometry.vertex_normals[face[k]];
1556                                        let vn_vec = Vector3::new(vn_arr[0], vn_arr[1], vn_arr[2]);
1557                                        mesh.model_matrix.transform_vector(&vn_vec)
1558                                    } else {
1559                                        transformed_fn
1560                                    };
1561
1562                                    let intensity = vn.dot(&adjusted_dir).max(0.0);
1563                                    let c = color_as_float * intensity + ambient_color;
1564                                    let mut vc = Rgb565::new(
1565                                        (c.x.clamp(0.0, 1.0) * 31.0) as u8,
1566                                        (c.y.clamp(0.0, 1.0) * 63.0) as u8,
1567                                        (c.z.clamp(0.0, 1.0) * 31.0) as u8,
1568                                    );
1569                                    if !self.point_lights.is_empty() {
1570                                        let vpos = geometry.vertices[face[k]];
1571                                        let wp = mesh.model_matrix.transform_point(&Point3::new(
1572                                            vpos[0], vpos[1], vpos[2],
1573                                        ));
1574                                        vc = Self::add_tint(vc, self.light_tint_at(wp));
1575                                    }
1576                                    vc
1577                                });
1578
1579                                emit(DrawPrimitive::TexturedGouraudTriangleWithDepth {
1580                                    points: [points[0].xy(), points[1].xy(), points[2].xy()],
1581                                    depths: [
1582                                        points[0].z as f32,
1583                                        points[1].z as f32,
1584                                        points[2].z as f32,
1585                                    ],
1586                                    ws,
1587                                    uvs: [
1588                                        geometry.uvs[face[0]],
1589                                        geometry.uvs[face[1]],
1590                                        geometry.uvs[face[2]],
1591                                    ],
1592                                    colors: vertex_colors,
1593                                    texture_id,
1594                                });
1595                            }
1596                        }
1597                    }
1598                }
1599                #[cfg(feature = "textured")]
1600                RenderMode::MatCap => {
1601                    let Some(texture_id) = geometry.texture_id else {
1602                        continue;
1603                    };
1604                    let has_vertex_normals = !geometry.vertex_normals.is_empty();
1605                    let has_face_normals = !geometry.normals.is_empty();
1606                    if !has_vertex_normals && !has_face_normals {
1607                        continue;
1608                    }
1609
1610                    let mv = self.camera.view_matrix * mesh.model_matrix;
1611                    let normal_matrix = nalgebra::Matrix3::new(
1612                        mv[(0, 0)],
1613                        mv[(0, 1)],
1614                        mv[(0, 2)],
1615                        mv[(1, 0)],
1616                        mv[(1, 1)],
1617                        mv[(1, 2)],
1618                        mv[(2, 0)],
1619                        mv[(2, 1)],
1620                        mv[(2, 2)],
1621                    );
1622
1623                    for (face_idx, face) in geometry.faces.iter().enumerate() {
1624                        let face_normal = if has_face_normals {
1625                            Vector3::new(
1626                                geometry.normals[face_idx][0],
1627                                geometry.normals[face_idx][1],
1628                                geometry.normals[face_idx][2],
1629                            )
1630                        } else {
1631                            let v0 = Vector3::new(
1632                                geometry.vertices[face[0]][0],
1633                                geometry.vertices[face[0]][1],
1634                                geometry.vertices[face[0]][2],
1635                            );
1636                            let v1 = Vector3::new(
1637                                geometry.vertices[face[1]][0],
1638                                geometry.vertices[face[1]][1],
1639                                geometry.vertices[face[1]][2],
1640                            );
1641                            let v2 = Vector3::new(
1642                                geometry.vertices[face[2]][0],
1643                                geometry.vertices[face[2]][1],
1644                                geometry.vertices[face[2]][2],
1645                            );
1646                            (v1 - v0).cross(&(v2 - v0)).normalize()
1647                        };
1648
1649                        let transformed_normal = mesh.model_matrix.transform_vector(&face_normal);
1650                        if self.is_backface(
1651                            face,
1652                            geometry.vertices,
1653                            mesh.model_matrix,
1654                            &transformed_normal,
1655                        ) {
1656                            continue;
1657                        }
1658
1659                        if let Some((points, ws)) = tf_face_w(face) {
1660                            let mut uvs = [[0.0f32; 2]; 3];
1661                            for i in 0..3 {
1662                                let vertex_normal = if has_vertex_normals {
1663                                    Vector3::new(
1664                                        geometry.vertex_normals[face[i]][0],
1665                                        geometry.vertex_normals[face[i]][1],
1666                                        geometry.vertex_normals[face[i]][2],
1667                                    )
1668                                } else {
1669                                    face_normal
1670                                };
1671                                let view_normal = (normal_matrix * vertex_normal).normalize();
1672                                let u = view_normal.x * 0.5 + 0.5;
1673                                let v = -view_normal.y * 0.5 + 0.5;
1674                                uvs[i] = [u, v];
1675                            }
1676
1677                            emit(DrawPrimitive::TexturedTriangleWithDepth {
1678                                points: [points[0].xy(), points[1].xy(), points[2].xy()],
1679                                depths: [
1680                                    points[0].z as f32,
1681                                    points[1].z as f32,
1682                                    points[2].z as f32,
1683                                ],
1684                                ws,
1685                                uvs,
1686                                texture_id,
1687                            });
1688                        }
1689                    }
1690                }
1691            }
1692        }
1693    }
1694
1695    pub fn record<'a, MS, const MAX: usize>(
1696        &self,
1697        meshes: MS,
1698        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
1699        telemetry: Option<&mut crate::telemetry::RecordTelemetry>,
1700    ) -> Result<(), crate::error::RenderError>
1701    where
1702        MS: IntoIterator<Item = &'a K3dMesh<'a>>,
1703    {
1704        self.record_impl(meshes, commands, telemetry)
1705    }
1706
1707    /// Record a projected drop shadow decal for a mesh grounded to a specific floor height.
1708    /// Uses an 8-sided flat polygon projected via camera view-projection matrix to form 8 translucent triangles.
1709    pub fn record_drop_shadow<const MAX: usize>(
1710        &self,
1711        mesh: &K3dMesh,
1712        floor_y: f32,
1713        shadow_radius: f32,
1714        max_fade_distance: f32,
1715        shadow_opacity: u8,
1716        color: Rgb565,
1717        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
1718    ) -> Result<(), crate::error::RenderError> {
1719        let pos = mesh.get_position();
1720        let height = pos.y - floor_y;
1721        if height < 0.0 || height >= max_fade_distance {
1722            return Ok(());
1723        }
1724
1725        let fade = 1.0 - (height / max_fade_distance).clamp(0.0, 1.0);
1726        let radius = shadow_radius * fade;
1727        let opacity = (shadow_opacity as f32 * fade) as u8;
1728        if opacity == 0 {
1729            return Ok(());
1730        }
1731
1732        let y_pos = floor_y + 0.01;
1733        let center_world = [pos.x, y_pos, pos.z];
1734        let center_proj = self.transform_point_with_w(&center_world, self.camera.vp_matrix);
1735        let Some((c_pt, _c_w)) = center_proj else {
1736            return Ok(());
1737        };
1738
1739        let mut outer_proj: [Option<(Point3<i32>, f32)>; 8] = [None; 8];
1740        for i in 0..8 {
1741            let angle = (i as f32) * (core::f32::consts::PI / 4.0);
1742            let px = pos.x + radius * micromath::F32Ext::cos(angle);
1743            let pz = pos.z + radius * micromath::F32Ext::sin(angle);
1744            outer_proj[i] = self.transform_point_with_w(&[px, y_pos, pz], self.camera.vp_matrix);
1745        }
1746
1747        for i in 0..8 {
1748            let next_idx = (i + 1) % 8;
1749            if let (Some((p1, _w1)), Some((p2, _w2))) = (outer_proj[i], outer_proj[next_idx]) {
1750                commands.push(crate::command_buffer::RenderCommand::Draw(
1751                    DrawPrimitive::TranslucentTriangleWithDepth {
1752                        points: [c_pt.xy(), p1.xy(), p2.xy()],
1753                        depths: [c_pt.z as f32, p1.z as f32, p2.z as f32],
1754                        color,
1755                        alpha: opacity,
1756                    },
1757                ))?;
1758            }
1759        }
1760
1761        Ok(())
1762    }
1763
1764    fn record_impl<'a, MS, const MAX: usize>(
1765        &self,
1766        meshes: MS,
1767        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
1768        telemetry: Option<&mut crate::telemetry::RecordTelemetry>,
1769    ) -> Result<(), crate::error::RenderError>
1770    where
1771        MS: IntoIterator<Item = &'a K3dMesh<'a>>,
1772    {
1773        use crate::command_buffer::RenderCommand;
1774
1775        commands.clear();
1776        commands.push(RenderCommand::ClearDepth(crate::Z_MAX_VALUE))?;
1777        if let Some(caps) = self.caps {
1778            caps.validate_framebuffer(self.width as usize, self.height as usize)?;
1779        }
1780
1781        let mut first_error = None;
1782        let mut visible_meshes = 0usize;
1783        let mut used_texture_ids: heapless::Vec<u32, 64> = heapless::Vec::new();
1784        let mut meshes_total = 0usize;
1785
1786        #[cfg(feature = "record-sort")]
1787        {
1788            let mut sorted: heapless::Vec<(u8, i32, &K3dMesh<'a>), 256> = heapless::Vec::new();
1789            for mesh in meshes {
1790                meshes_total += 1;
1791                if mesh.geometry.vertices.is_empty() {
1792                    continue;
1793                }
1794                if self.should_cull_mesh(mesh) {
1795                    continue;
1796                }
1797                let distance = (mesh.get_position() - self.camera.position).norm();
1798                let dist_key = (distance * 1000.0) as i32;
1799                if sorted.push((mesh.priority, dist_key, mesh)).is_err() {
1800                    break;
1801                }
1802            }
1803            sorted.sort_unstable_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
1804
1805            for &(_, _, mesh) in sorted.iter() {
1806                Self::record_one_mesh(
1807                    self,
1808                    mesh,
1809                    commands,
1810                    &mut first_error,
1811                    &mut visible_meshes,
1812                    &mut used_texture_ids,
1813                )?;
1814                if let Some(err) = first_error.take() {
1815                    return Err(err);
1816                }
1817            }
1818        }
1819
1820        #[cfg(not(feature = "record-sort"))]
1821        {
1822            for mesh in meshes {
1823                meshes_total += 1;
1824                if mesh.geometry.vertices.is_empty() {
1825                    continue;
1826                }
1827                if self.should_cull_mesh(mesh) {
1828                    continue;
1829                }
1830                Self::record_one_mesh(
1831                    self,
1832                    mesh,
1833                    commands,
1834                    &mut first_error,
1835                    &mut visible_meshes,
1836                    &mut used_texture_ids,
1837                )?;
1838                if let Some(err) = first_error.take() {
1839                    return Err(err);
1840                }
1841            }
1842        }
1843
1844        if let Some(t) = telemetry {
1845            t.meshes_total = meshes_total;
1846            t.meshes_visible = visible_meshes;
1847            t.unique_textures = used_texture_ids.len();
1848            t.draw_commands = commands
1849                .iter()
1850                .filter(|cmd| matches!(cmd, RenderCommand::Draw(_)))
1851                .count();
1852            t.fallback_used = false;
1853            t.degradation_steps_applied = 0;
1854            t.dropped_meshes = 0;
1855        }
1856
1857        Ok(())
1858    }
1859
1860    fn record_one_mesh<'a, const MAX: usize>(
1861        &self,
1862        mesh: &'a K3dMesh<'a>,
1863        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
1864        first_error: &mut Option<crate::error::RenderError>,
1865        visible_meshes: &mut usize,
1866        used_texture_ids: &mut heapless::Vec<u32, 64>,
1867    ) -> Result<(), crate::error::RenderError> {
1868        use crate::command_buffer::RenderCommand;
1869        use crate::error::{BudgetKind, RenderError};
1870
1871        let distance = (mesh.get_position() - self.camera.position).norm();
1872        let geometry = mesh.select_lod(distance);
1873
1874        if let Some(caps) = self.caps {
1875            *visible_meshes += 1;
1876            if *visible_meshes > caps.max_meshes_per_frame {
1877                return Err(RenderError::OutOfBudget(BudgetKind::MeshesPerFrame {
1878                    attempted: *visible_meshes,
1879                    max: caps.max_meshes_per_frame,
1880                }));
1881            }
1882
1883            if geometry.vertices.len() > caps.max_vertices_per_mesh {
1884                return Err(RenderError::OutOfBudget(BudgetKind::VerticesPerMesh {
1885                    attempted: geometry.vertices.len(),
1886                    max: caps.max_vertices_per_mesh,
1887                }));
1888            }
1889
1890            if geometry.faces.len() > caps.max_triangles_per_mesh {
1891                return Err(RenderError::OutOfBudget(BudgetKind::TrianglesPerMesh {
1892                    attempted: geometry.faces.len(),
1893                    max: caps.max_triangles_per_mesh,
1894                }));
1895            }
1896
1897            if let Some(texture_id) = geometry.texture_id
1898                && !used_texture_ids.contains(&texture_id)
1899            {
1900                let attempted = used_texture_ids.len() + 1;
1901                if attempted > caps.max_textures {
1902                    return Err(RenderError::OutOfBudget(BudgetKind::Textures {
1903                        attempted,
1904                        max: caps.max_textures,
1905                    }));
1906                }
1907
1908                if used_texture_ids.push(texture_id).is_err() {
1909                    return Err(RenderError::OutOfBudget(BudgetKind::Textures {
1910                        attempted,
1911                        max: caps.max_textures,
1912                    }));
1913                }
1914            }
1915        } else {
1916            *visible_meshes += 1;
1917        }
1918
1919        let mut push_draw = |primitive: DrawPrimitive, first_error: &mut Option<RenderError>| {
1920            if first_error.is_none()
1921                && let Err(e) = commands.push(RenderCommand::Draw(primitive))
1922            {
1923                *first_error = Some(e);
1924            }
1925        };
1926
1927        #[cfg(feature = "lod-crossfade")]
1928        {
1929            match mesh.select_lod_pick(distance) {
1930                mesh::LodPick::Single(_) => {
1931                    mesh.lod_force.set(None);
1932                    mesh.draw_alpha.set(None);
1933                    self.render(core::iter::once(mesh), |primitive| {
1934                        push_draw(primitive, first_error);
1935                    });
1936                }
1937                mesh::LodPick::Crossfade { near, far, t } => {
1938                    let near_lvl = mesh.lod_level_of(near);
1939                    let far_lvl = mesh.lod_level_of(far);
1940                    if t < 0.5 {
1941                        mesh.lod_force.set(Some(near_lvl));
1942                        mesh.draw_alpha.set(None);
1943                        self.render(core::iter::once(mesh), |primitive| {
1944                            push_draw(primitive, first_error);
1945                        });
1946                        let a = (t * 255.0) as u8;
1947                        if a > 16 {
1948                            mesh.lod_force.set(Some(far_lvl));
1949                            mesh.draw_alpha.set(Some(a));
1950                            self.render(core::iter::once(mesh), |primitive| {
1951                                push_draw(primitive, first_error);
1952                            });
1953                        }
1954                    } else {
1955                        mesh.lod_force.set(Some(far_lvl));
1956                        mesh.draw_alpha.set(None);
1957                        self.render(core::iter::once(mesh), |primitive| {
1958                            push_draw(primitive, first_error);
1959                        });
1960                        let a = ((1.0 - t) * 255.0) as u8;
1961                        if a > 16 {
1962                            mesh.lod_force.set(Some(near_lvl));
1963                            mesh.draw_alpha.set(Some(a));
1964                            self.render(core::iter::once(mesh), |primitive| {
1965                                push_draw(primitive, first_error);
1966                            });
1967                        }
1968                    }
1969                    mesh.lod_force.set(None);
1970                    mesh.draw_alpha.set(None);
1971                }
1972            }
1973        }
1974        #[cfg(not(feature = "lod-crossfade"))]
1975        {
1976            self.render(core::iter::once(mesh), |primitive| {
1977                push_draw(primitive, first_error);
1978            });
1979        }
1980        Ok(())
1981    }
1982
1983    pub fn record_with_fallback<'a, MS, FS, const MAX: usize>(
1984        &self,
1985        primary: MS,
1986        fallback: FS,
1987        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
1988        telemetry: Option<&mut crate::telemetry::RecordTelemetry>,
1989    ) -> Result<BudgetFallbackOutcome, crate::error::RenderError>
1990    where
1991        MS: IntoIterator<Item = &'a K3dMesh<'a>>,
1992        FS: IntoIterator<Item = &'a K3dMesh<'a>>,
1993    {
1994        use crate::error::RenderError;
1995
1996        let mut local_telemetry = crate::telemetry::RecordTelemetry::default();
1997        match self.record_impl(primary, commands, Some(&mut local_telemetry)) {
1998            Ok(()) => {
1999                if let Some(t) = telemetry {
2000                    *t = local_telemetry;
2001                    t.fallback_used = false;
2002                }
2003                Ok(BudgetFallbackOutcome {
2004                    used_fallback: false,
2005                    primary_budget_error: None,
2006                })
2007            }
2008            Err(RenderError::OutOfBudget(kind)) => {
2009                let mut fallback_telemetry = crate::telemetry::RecordTelemetry::default();
2010                self.record_impl(fallback, commands, Some(&mut fallback_telemetry))?;
2011                if let Some(t) = telemetry {
2012                    *t = fallback_telemetry;
2013                    t.fallback_used = true;
2014                }
2015                Ok(BudgetFallbackOutcome {
2016                    used_fallback: true,
2017                    primary_budget_error: Some(kind),
2018                })
2019            }
2020            Err(e) => Err(e),
2021        }
2022    }
2023
2024    fn downgraded_quality_tier(tier: crate::config::QualityTier) -> crate::config::QualityTier {
2025        use crate::config::QualityTier;
2026        match tier {
2027            QualityTier::Quality => QualityTier::Balanced,
2028            QualityTier::Balanced => QualityTier::Fastest,
2029            QualityTier::Fastest => QualityTier::Fastest,
2030        }
2031    }
2032
2033    pub fn record_with_degradation<'a, const MAX: usize>(
2034        &mut self,
2035        meshes: &[&'a K3dMesh<'a>],
2036        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
2037        policy: crate::config::DegradationPolicy<'_>,
2038        telemetry: Option<&mut crate::telemetry::RecordTelemetry>,
2039    ) -> Result<DegradationOutcome, crate::error::RenderError> {
2040        use crate::config::DegradationStep;
2041        use crate::error::RenderError;
2042
2043        let original_quality = self.quality_tier;
2044        let mut active_quality = self.quality_tier;
2045
2046        let mut outcome = DegradationOutcome {
2047            used_degradation: false,
2048            steps_applied: 0,
2049            dropped_meshes: 0,
2050            final_quality_tier: active_quality,
2051            primary_budget_error: None,
2052        };
2053
2054        let mut local_telemetry = crate::telemetry::RecordTelemetry::default();
2055        match self.record_impl(meshes.iter().copied(), commands, Some(&mut local_telemetry)) {
2056            Ok(()) => {
2057                if let Some(t) = telemetry {
2058                    *t = local_telemetry;
2059                }
2060                return Ok(outcome);
2061            }
2062            Err(RenderError::OutOfBudget(kind)) => {
2063                outcome.primary_budget_error = Some(kind);
2064            }
2065            Err(e) => return Err(e),
2066        }
2067
2068        for step in policy.steps {
2069            outcome.used_degradation = true;
2070            outcome.steps_applied += 1;
2071
2072            let mut selected: heapless::Vec<&K3dMesh<'_>, 512> = heapless::Vec::new();
2073            match *step {
2074                DegradationStep::RaisePriorityFloor(min_priority) => {
2075                    for mesh in meshes {
2076                        if mesh.priority >= min_priority {
2077                            let _ = selected.push(*mesh);
2078                        } else {
2079                            outcome.dropped_meshes += 1;
2080                        }
2081                    }
2082                }
2083                DegradationStep::MeshDecimationStride(stride) => {
2084                    if stride == 0 {
2085                        self.quality_tier = original_quality;
2086                        return Err(RenderError::InvalidInput(
2087                            "mesh decimation stride must be >= 1",
2088                        ));
2089                    }
2090                    for (idx, mesh) in meshes.iter().enumerate() {
2091                        if idx % stride == 0 {
2092                            let _ = selected.push(*mesh);
2093                        } else {
2094                            outcome.dropped_meshes += 1;
2095                        }
2096                    }
2097                }
2098                DegradationStep::DowngradeQuality => {
2099                    active_quality = Self::downgraded_quality_tier(active_quality);
2100                    self.quality_tier = active_quality;
2101                    for mesh in meshes {
2102                        let _ = selected.push(*mesh);
2103                    }
2104                }
2105            }
2106
2107            if selected.is_empty() {
2108                continue;
2109            }
2110
2111            let mut step_telemetry = crate::telemetry::RecordTelemetry::default();
2112            let attempt = self.record_impl(
2113                selected.iter().copied(),
2114                commands,
2115                Some(&mut step_telemetry),
2116            );
2117
2118            if let Ok(()) = attempt {
2119                outcome.final_quality_tier = self.quality_tier;
2120                if let Some(t) = telemetry {
2121                    *t = step_telemetry;
2122                    t.fallback_used = true;
2123                    t.degradation_steps_applied = outcome.steps_applied;
2124                    t.dropped_meshes = outcome.dropped_meshes;
2125                }
2126                self.quality_tier = original_quality;
2127                return Ok(outcome);
2128            }
2129        }
2130
2131        self.quality_tier = original_quality;
2132        Err(crate::error::RenderError::Recoverable {
2133            fault: crate::error::RuntimeFaultKind::Budget(outcome.primary_budget_error.unwrap_or(
2134                crate::error::BudgetKind::DrawPrimitives {
2135                    attempted: commands.len(),
2136                    max: MAX,
2137                },
2138            )),
2139            action: crate::error::RecoveryAction::SkipFrame,
2140        })
2141    }
2142
2143    pub fn execute<D, const MAX: usize>(
2144        &self,
2145        fb: &mut D,
2146        frame: &mut crate::renderer::FrameCtx<'_>,
2147        commands: &crate::command_buffer::CommandBuffer<MAX>,
2148        telemetry: Option<&mut crate::telemetry::ExecuteTelemetry>,
2149    ) -> Result<Option<crate::renderer::DirtyRegion>, crate::error::RenderError>
2150    where
2151        D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>
2152            + embedded_graphics_core::prelude::OriginDimensions,
2153        <D as embedded_graphics_core::draw_target::DrawTarget>::Error: core::fmt::Debug,
2154    {
2155        if let Some(t) = telemetry {
2156            t.commands_total = commands.len();
2157            t.draw_commands = commands
2158                .iter()
2159                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::Draw(_)))
2160                .count();
2161            t.clear_color_commands = commands
2162                .iter()
2163                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::ClearColor(_)))
2164                .count();
2165            t.clear_depth_commands = commands
2166                .iter()
2167                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::ClearDepth(_)))
2168                .count();
2169        }
2170        let camera_dir = self.camera.get_direction();
2171        crate::renderer::execute_commands_with_dirty_region_effects(
2172            fb,
2173            frame,
2174            commands,
2175            self.fog.as_ref(),
2176            self.dither.as_ref(),
2177            self.screen_tint,
2178            self.stipple_mode,
2179            self.palette_mode,
2180            self.sky,
2181            [camera_dir.x, camera_dir.y, camera_dir.z],
2182        )
2183    }
2184
2185    /// Like [`Self::execute`], but resolves [`RenderMode::Textured`] meshes'
2186    /// `DrawPrimitive::TexturedTriangleWithDepth`/`LightmappedTriangle`
2187    /// primitives via `texture_manager` instead of silently dropping them.
2188    ///
2189    /// `record()` doesn't need a texture manager (it only transforms
2190    /// geometry into primitives, it doesn't sample pixels), so a scene
2191    /// mixing textured and flat-colored/lit meshes still goes through one
2192    /// `record()` call -- only `execute()` needs to change to
2193    /// `execute_with_textures()` once any mesh in the batch uses
2194    /// `RenderMode::Textured`.
2195    #[cfg(feature = "textured")]
2196    pub fn execute_with_textures<D, const MAX: usize, const N: usize>(
2197        &self,
2198        fb: &mut D,
2199        frame: &mut crate::renderer::FrameCtx<'_>,
2200        commands: &crate::command_buffer::CommandBuffer<MAX>,
2201        texture_manager: &crate::texture::TextureManager<N>,
2202        telemetry: Option<&mut crate::telemetry::ExecuteTelemetry>,
2203    ) -> Result<Option<crate::renderer::DirtyRegion>, crate::error::RenderError>
2204    where
2205        D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>
2206            + embedded_graphics_core::prelude::OriginDimensions,
2207        <D as embedded_graphics_core::draw_target::DrawTarget>::Error: core::fmt::Debug,
2208    {
2209        if let Some(t) = telemetry {
2210            t.commands_total = commands.len();
2211            t.draw_commands = commands
2212                .iter()
2213                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::Draw(_)))
2214                .count();
2215            t.clear_color_commands = commands
2216                .iter()
2217                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::ClearColor(_)))
2218                .count();
2219            t.clear_depth_commands = commands
2220                .iter()
2221                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::ClearDepth(_)))
2222                .count();
2223        }
2224        let camera_dir = self.camera.get_direction();
2225        crate::renderer::execute_commands_with_dirty_region_effects_textured(
2226            fb,
2227            frame,
2228            commands,
2229            texture_manager,
2230            self.fog.as_ref(),
2231            self.dither.as_ref(),
2232            self.screen_tint,
2233            self.stipple_mode,
2234            self.palette_mode,
2235            self.sky,
2236            [camera_dir.x, camera_dir.y, camera_dir.z],
2237        )
2238    }
2239
2240    pub fn execute_tiled<D, const MAX: usize, const BIN_CAP: usize>(
2241        &self,
2242        fb: &mut D,
2243        frame: &mut crate::renderer::FrameCtx<'_>,
2244        commands: &crate::command_buffer::CommandBuffer<MAX>,
2245        tile: crate::tilebin::TileConfig,
2246    ) -> Result<crate::tilebin::TileBinStats, crate::error::RenderError>
2247    where
2248        D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>
2249            + embedded_graphics_core::prelude::OriginDimensions,
2250        <D as embedded_graphics_core::draw_target::DrawTarget>::Error: core::fmt::Debug,
2251    {
2252        let camera_dir = self.camera.get_direction();
2253        crate::renderer::execute_commands_tiled_effects::<D, MAX, BIN_CAP>(
2254            fb,
2255            frame,
2256            commands,
2257            tile,
2258            self.fog.as_ref(),
2259            self.dither.as_ref(),
2260            self.screen_tint,
2261            self.stipple_mode,
2262            self.palette_mode,
2263            self.sky,
2264            [camera_dir.x, camera_dir.y, camera_dir.z],
2265        )
2266    }
2267
2268    /// Emit a model-space AABB wireframe into a command buffer (debug).
2269    #[cfg(feature = "gizmos")]
2270    pub fn record_aabb_gizmo<const MAX: usize>(
2271        &self,
2272        aabb: &crate::bounds::Aabb,
2273        model_matrix: &Matrix4<f32>,
2274        color: Rgb565,
2275        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
2276    ) -> Result<(), crate::error::RenderError> {
2277        let mut err = None;
2278        crate::gizmos::emit_aabb_wireframe_projected(
2279            aabb,
2280            model_matrix,
2281            |p| self.transform_point(&p, self.camera.vp_matrix),
2282            color,
2283            |prim| {
2284                if err.is_none()
2285                    && let Err(e) = commands.push(crate::command_buffer::RenderCommand::Draw(prim))
2286                {
2287                    err = Some(e);
2288                }
2289            },
2290        );
2291        match err {
2292            Some(e) => Err(e),
2293            None => Ok(()),
2294        }
2295    }
2296
2297    /// Emit the camera frustum wireframe into a command buffer (debug).
2298    #[cfg(feature = "gizmos")]
2299    pub fn record_frustum_gizmo<const MAX: usize>(
2300        &self,
2301        color: Rgb565,
2302        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
2303    ) -> Result<(), crate::error::RenderError> {
2304        let mut err = None;
2305        crate::gizmos::emit_frustum_wireframe(
2306            &self.camera,
2307            |p| self.transform_point(&p, self.camera.vp_matrix),
2308            color,
2309            |prim| {
2310                if err.is_none()
2311                    && let Err(e) = commands.push(crate::command_buffer::RenderCommand::Draw(prim))
2312                {
2313                    err = Some(e);
2314                }
2315            },
2316        );
2317        match err {
2318            Some(e) => Err(e),
2319            None => Ok(()),
2320        }
2321    }
2322}
2323
2324#[cfg(feature = "lod-crossfade")]
2325fn apply_draw_alpha(prim: DrawPrimitive, alpha: u8) -> DrawPrimitive {
2326    match prim {
2327        DrawPrimitive::ColoredTriangleWithDepth {
2328            points,
2329            depths,
2330            color,
2331        } => DrawPrimitive::TranslucentTriangleWithDepth {
2332            points,
2333            depths,
2334            color,
2335            alpha,
2336        },
2337        DrawPrimitive::TranslucentTriangleWithDepth {
2338            points,
2339            depths,
2340            color,
2341            alpha: prev,
2342        } => DrawPrimitive::TranslucentTriangleWithDepth {
2343            points,
2344            depths,
2345            color,
2346            alpha: ((prev as u16 * alpha as u16) / 255) as u8,
2347        },
2348        other => other,
2349    }
2350}