Skip to main content

embedded_3dgfx/
lib.rs

1#![no_std]
2#[cfg(feature = "std")]
3extern crate std;
4
5#[cfg(feature = "depth-u16")]
6pub type ZDepth = u16;
7#[cfg(feature = "depth-u16")]
8pub const Z_MAX_VALUE: ZDepth = u16::MAX;
9#[cfg(feature = "depth-u16")]
10pub const DEPTH_EPSILON: ZDepth = 1;
11
12#[cfg(not(feature = "depth-u16"))]
13pub type ZDepth = u32;
14#[cfg(not(feature = "depth-u16"))]
15pub const Z_MAX_VALUE: ZDepth = u32::MAX;
16#[cfg(not(feature = "depth-u16"))]
17pub const DEPTH_EPSILON: ZDepth = 128;
18
19#[inline(always)]
20pub const fn to_zdepth(z: u32) -> ZDepth {
21    #[cfg(feature = "depth-u16")]
22    {
23        (z >> 16) as u16
24    }
25    #[cfg(not(feature = "depth-u16"))]
26    {
27        z
28    }
29}
30
31#[cfg(feature = "dma2d")]
32unsafe extern "Rust" {
33    #[cfg(feature = "depth-u16")]
34    fn dma2d_clear_zbuffer_u16(ptr: *mut u16, len: usize, value: u16);
35    #[cfg(not(feature = "depth-u16"))]
36    fn dma2d_clear_zbuffer_u32(ptr: *mut u32, len: usize, value: u32);
37}
38
39#[inline(always)]
40pub fn clear_zbuffer(zbuffer: &mut [ZDepth], value: ZDepth) {
41    #[cfg(feature = "dma2d")]
42    {
43        #[cfg(feature = "depth-u16")]
44        unsafe {
45            dma2d_clear_zbuffer_u16(zbuffer.as_mut_ptr(), zbuffer.len(), value);
46        }
47        #[cfg(not(feature = "depth-u16"))]
48        unsafe {
49            dma2d_clear_zbuffer_u32(zbuffer.as_mut_ptr(), zbuffer.len(), value);
50        }
51    }
52    #[cfg(not(feature = "dma2d"))]
53    {
54        zbuffer.fill(value);
55    }
56}
57
58use camera::Camera;
59use embedded_graphics_core::pixelcolor::Rgb565;
60use embedded_graphics_core::pixelcolor::RgbColor;
61use mesh::K3dMesh;
62use mesh::RenderMode;
63use nalgebra::Matrix4;
64use nalgebra::Point2;
65use nalgebra::Point3;
66use nalgebra::Vector3;
67use nalgebra::Vector4;
68
69// ComplexField provides sqrt() for f32 in no_std via libm
70// It appears "unused" in tests because tests use std, but it's required for no_std builds
71#[allow(unused_imports)]
72use nalgebra::ComplexField;
73
74pub mod animation;
75pub mod billboard;
76#[cfg(feature = "aabb-cull")]
77pub mod bounds;
78pub mod bridge;
79pub mod bsp;
80pub mod camera;
81pub mod character;
82pub mod command_buffer;
83pub mod completion;
84pub mod config;
85pub mod display_backend;
86pub mod draw;
87#[cfg(feature = "embassy")]
88pub mod embassy;
89pub mod error;
90#[cfg(feature = "gizmos")]
91pub mod gizmos;
92pub mod hardware_profile;
93pub mod hud;
94pub mod input;
95pub mod lights;
96pub mod mesh;
97pub mod painters;
98pub mod particles;
99#[cfg(feature = "perfcounter")]
100pub mod perfcounter;
101#[cfg(feature = "physics")]
102pub mod physics;
103pub mod raycast;
104#[cfg(feature = "render-layers")]
105pub mod render_layers;
106pub mod renderer;
107pub mod retro;
108pub mod scene_format;
109pub mod scene_stream;
110pub mod sector_lights;
111pub mod skeleton;
112#[cfg(feature = "physics")]
113pub mod softbody;
114pub mod swapchain;
115pub mod telemetry;
116pub mod texture;
117pub mod tilebin;
118pub mod transform_anim;
119pub mod tween;
120
121// Re-export framebuffer types from external crate for user convenience
122pub use embedded_graphics_framebuf::{
123    FrameBuf,
124    backends::{DMACapableFrameBufferBackend, EndianCorrectedBuffer, EndianCorrection},
125};
126
127pub use draw::PixelRead;
128#[cfg(feature = "aa")]
129pub use draw::ReadPixel;
130
131#[cfg(feature = "aabb-cull")]
132pub use bounds::Aabb;
133pub use bridge::{
134    AsEgPoint, AsNalgebraPoint, draw_to, eg_to_nalgebra, nalgebra_to_eg, render_drawable_to_buffer,
135};
136pub use character::CharacterController;
137pub use completion::{CompletionSlot, WaitTransfer, WaitTransferFuture};
138pub use display_backend::{
139    AsyncDmaTransfer, DisplayBackend, DisplayError, DisplayRegion, DmaTransfer, SimulatorBackend,
140    TransferError,
141};
142#[cfg(feature = "aa")]
143pub use draw::draw_zbuffered_2xssaa;
144pub use draw::{
145    DitherConfig, FogConfig, fast_blend_rgb565, fast_blend_rgba8888, fast_blend_rgba8888_to_rgb565,
146    reverse_color_rgb565, reverse_color_rgba8888,
147};
148#[cfg(feature = "embassy")]
149pub use embassy::{EmbassyWaitTransfer, EmbassyWaitTransferFuture, FrameClock};
150pub use swapchain::{StandardSwapChain, SwapChain};
151#[cfg(feature = "triple-buffering")]
152pub use swapchain::{StandardTripleSwapChain, TripleSwapChain};
153// Q16.16 fixed-point math now lives in embedded-dsp (shared with
154// embedded-gui) instead of a bespoke copy in this crate. Only available
155// when the "fixed-transform" feature (which requires "dsp") is enabled.
156#[cfg(feature = "fixed-transform")]
157pub use embedded_dsp::fixed_point::{
158    FP_ONE, Q16, Q16_MAX, Q16_MIN, ScanlineInterp, abs_q16, angle_to_q16, div_f_q16, div_n_q16,
159    div_q16, from_i16_q16, from_q16, lerp_q16, mul_f_q16, mul_n_q16, mul_q16, q16_to_q31,
160    q31_to_q16, qadd_q16, qsub_q16, recip_q16, to_i16_q16, to_q16,
161};
162pub use input::InputState;
163pub use lights::{PointLight, PointLightSet};
164pub use particles::{ParticleSpawn, ParticleSystem};
165#[cfg(feature = "render-layers")]
166pub use render_layers::RenderLayers;
167pub use renderer::{DirtyRegion, FrameCtx};
168pub use retro::{
169    LightLevels, PaletteMode, RetroStyle, ScreenTint, SkyConfig, StippleMode, TextureMapping,
170};
171pub use sector_lights::{LightEffectKind, SectorLight, light_level_at, light_level_u8_at};
172pub use tilebin::{TileBinStats, TileConfig};
173pub use transform_anim::{AnimationPlayer, SampledTransform, TransformKeyframe, TransformTrack};
174pub use tween::{Easing, Tween, Tween3, apply_easing, lerp, lerp3, scale_rgb565};
175
176#[derive(Debug, Clone)]
177pub enum DrawPrimitive {
178    ColoredPoint(Point2<i32>, Rgb565),
179    Line([Point2<i32>; 2], Rgb565),
180    ColoredTriangle([Point2<i32>; 3], Rgb565),
181    ColoredTriangleWithDepth {
182        points: [Point2<i32>; 3],
183        depths: [f32; 3],
184        color: Rgb565,
185    },
186    TranslucentTriangleWithDepth {
187        points: [Point2<i32>; 3],
188        depths: [f32; 3],
189        color: Rgb565,
190        alpha: u8,
191    },
192    GouraudTriangle {
193        points: [Point2<i32>; 3],
194        colors: [Rgb565; 3],
195    },
196    GouraudTriangleWithDepth {
197        points: [Point2<i32>; 3],
198        depths: [f32; 3],
199        colors: [Rgb565; 3],
200    },
201    TexturedTriangle {
202        points: [Point2<i32>; 3],
203        uvs: [[f32; 2]; 3],
204        texture_id: u32,
205    },
206    TexturedTriangleWithDepth {
207        points: [Point2<i32>; 3],
208        depths: [f32; 3],
209        ws: [f32; 3],
210        uvs: [[f32; 2]; 3],
211        texture_id: u32,
212    },
213    TexturedGouraudTriangleWithDepth {
214        points: [Point2<i32>; 3],
215        depths: [f32; 3],
216        ws: [f32; 3],
217        uvs: [[f32; 2]; 3],
218        colors: [Rgb565; 3],
219        texture_id: u32,
220    },
221    /// Perspective-correct textured triangle with baked lightmap.
222    ///
223    /// The final pixel colour is:
224    /// `clamp(surface.sample(su,sv) × lightmap.sample(lu,lv) + dynamic_tint)`
225    /// where `×` is per-channel normalised multiply.
226    /// Set `lightmap_id = u32::MAX` to fall back to full-bright surface colour.
227    /// Set `dynamic_tint = Rgb565::new(0,0,0)` for no dynamic lighting.
228    LightmappedTriangle {
229        points: [Point2<i32>; 3],
230        depths: [f32; 3],
231        ws: [f32; 3],
232        surface_uvs: [[f32; 2]; 3],
233        lm_uvs: [[f32; 2]; 3],
234        texture_id: u32,
235        lightmap_id: u32,
236        /// Per-face brightness multiplier in 0..=255 (255 = no darkening).
237        brightness: u8,
238        /// Additive RGB565 tint from runtime point lights.
239        dynamic_tint: Rgb565,
240    },
241}
242
243pub struct K3dengine {
244    pub camera: Camera,
245    width: u16,
246    height: u16,
247    caps: Option<crate::config::ProfileCaps>,
248    quality_tier: crate::config::QualityTier,
249    material_profile: crate::config::MaterialProfile,
250    /// Depth-based fog applied during `execute` / `execute_tiled`.
251    fog: Option<crate::draw::FogConfig>,
252    /// Ordered dithering applied during `execute` / `execute_tiled`.
253    dither: Option<crate::draw::DitherConfig>,
254    /// Optional NDC snap precision for retro-style vertex jitter.
255    vertex_snap_bits: u8,
256    /// Texture interpolation mode for textured raster paths.
257    texture_mapping: crate::retro::TextureMapping,
258    /// Sector brightness behavior.
259    light_levels: crate::retro::LightLevels,
260    /// Optional stipple mode for textured/lightmapped passes.
261    stipple_mode: crate::retro::StippleMode,
262    /// Optional full-screen tint blended during rasterization.
263    screen_tint: Option<crate::retro::ScreenTint>,
264    /// Optional palette quantization.
265    palette_mode: crate::retro::PaletteMode,
266    /// Optional sky background rendered before scene geometry.
267    sky: Option<crate::retro::SkyConfig>,
268    /// Runtime point lights (max 16).  Applied at face-centre granularity
269    /// during `record` for mesh geometry and at face level for BSP.
270    point_lights: heapless::Vec<crate::lights::PointLight, 16>,
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub struct BudgetFallbackOutcome {
275    pub used_fallback: bool,
276    pub primary_budget_error: Option<crate::error::BudgetKind>,
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280pub struct DegradationOutcome {
281    pub used_degradation: bool,
282    pub steps_applied: usize,
283    pub dropped_meshes: usize,
284    pub final_quality_tier: crate::config::QualityTier,
285    pub primary_budget_error: Option<crate::error::BudgetKind>,
286}
287
288impl K3dengine {
289    pub fn new(width: u16, height: u16) -> K3dengine {
290        K3dengine {
291            camera: Camera::new(width as f32 / height as f32),
292            width,
293            height,
294            caps: None,
295            quality_tier: crate::config::QualityTier::Balanced,
296            material_profile: crate::config::MaterialProfile::Lambert,
297            fog: None,
298            dither: None,
299            vertex_snap_bits: 0,
300            texture_mapping: crate::retro::TextureMapping::PerspectiveCorrect,
301            light_levels: crate::retro::LightLevels::Linear,
302            stipple_mode: crate::retro::StippleMode::Off,
303            screen_tint: None,
304            palette_mode: crate::retro::PaletteMode::Off,
305            sky: None,
306            point_lights: heapless::Vec::new(),
307        }
308    }
309
310    /// Enable depth-based fog for subsequent [`execute`][Self::execute] calls.
311    pub fn set_fog(&mut self, fog: crate::draw::FogConfig) {
312        self.fog = Some(fog);
313    }
314
315    /// Disable fog (default state).
316    pub fn clear_fog(&mut self) {
317        self.fog = None;
318    }
319
320    /// Enable ordered dithering for subsequent execute passes.
321    pub fn set_dither(&mut self, dither: crate::draw::DitherConfig) {
322        self.dither = Some(dither);
323    }
324
325    /// Disable ordered dithering.
326    pub fn clear_dither(&mut self) {
327        self.dither = None;
328    }
329
330    /// Set NDC vertex snap precision. `0` disables snapping.
331    pub fn set_vertex_snap_bits(&mut self, bits: u8) {
332        self.vertex_snap_bits = bits.min(16);
333    }
334
335    /// Select texture interpolation mode.
336    pub fn set_texture_mapping(&mut self, mapping: crate::retro::TextureMapping) {
337        self.texture_mapping = mapping;
338    }
339
340    /// Select sector light quantization model.
341    pub fn set_light_levels(&mut self, levels: crate::retro::LightLevels) {
342        self.light_levels = levels;
343    }
344
345    /// Set stipple mode used by textured/lightmapped raster paths.
346    pub fn set_stipple_mode(&mut self, mode: crate::retro::StippleMode) {
347        self.stipple_mode = mode;
348    }
349
350    /// Set an optional full-screen tint.
351    pub fn set_screen_tint(&mut self, tint: crate::retro::ScreenTint) {
352        self.screen_tint = Some(tint);
353    }
354
355    /// Disable full-screen tint.
356    pub fn clear_screen_tint(&mut self) {
357        self.screen_tint = None;
358    }
359
360    /// Set output palette quantization mode.
361    pub fn set_palette_mode(&mut self, mode: crate::retro::PaletteMode) {
362        self.palette_mode = mode;
363    }
364
365    /// Set procedural sky rendering parameters.
366    pub fn set_sky(&mut self, sky: crate::retro::SkyConfig) {
367        self.sky = Some(sky);
368    }
369
370    /// Disable procedural sky rendering.
371    pub fn clear_sky(&mut self) {
372        self.sky = None;
373    }
374
375    /// Apply a coarse retro visual preset.
376    pub fn apply_retro_style(&mut self, style: crate::retro::RetroStyle) {
377        self.fog = style.fog;
378        self.dither = style.dither;
379        self.set_vertex_snap_bits(style.vertex_snap_bits);
380        self.texture_mapping = style.texture_mapping;
381        self.light_levels = style.light_levels;
382        self.stipple_mode = style.stipple_mode;
383        self.screen_tint = style.screen_tint;
384        self.palette_mode = style.palette_mode;
385        self.sky = style.sky;
386    }
387
388    /// Add a dynamic point light.  Returns `false` when the 16-light limit
389    /// is reached.
390    pub fn add_point_light(&mut self, light: crate::lights::PointLight) -> bool {
391        self.point_lights.push(light).is_ok()
392    }
393
394    /// Remove all dynamic point lights.
395    pub fn clear_point_lights(&mut self) {
396        self.point_lights.clear();
397    }
398
399    /// Compute the summed additive RGB565 tint from all registered point
400    /// lights at `world_pos`.
401    #[inline]
402    fn light_tint_at(&self, world_pos: Point3<f32>) -> Rgb565 {
403        let mut r = 0u32;
404        let mut g = 0u32;
405        let mut b = 0u32;
406        for light in &self.point_lights {
407            let c = light.contribution_at(world_pos);
408            r += c.r() as u32;
409            g += c.g() as u32;
410            b += c.b() as u32;
411        }
412        Rgb565::new(r.min(31) as u8, g.min(63) as u8, b.min(31) as u8)
413    }
414
415    /// Additively blend `tint` into `base`, saturating per channel.
416    #[inline]
417    fn add_tint(base: Rgb565, tint: Rgb565) -> Rgb565 {
418        Rgb565::new(
419            (base.r() as u16 + tint.r() as u16).min(31) as u8,
420            (base.g() as u16 + tint.g() as u16).min(63) as u8,
421            (base.b() as u16 + tint.b() as u16).min(31) as u8,
422        )
423    }
424
425    /// Non-linear 32-level light ramp for Doom-style sector attenuation.
426    const DOOM_LIGHT_TABLE: [u8; 32] = [
427        8, 12, 16, 20, 24, 28, 34, 40, 48, 56, 64, 72, 82, 92, 102, 112, 124, 136, 148, 160, 172,
428        184, 196, 206, 216, 224, 232, 238, 244, 248, 252, 255,
429    ];
430
431    #[inline]
432    fn sector_shaded_color(
433        &self,
434        base: Rgb565,
435        brightness: u8,
436        face_center: Point3<f32>,
437    ) -> Rgb565 {
438        let level_u8 = match self.light_levels {
439            crate::retro::LightLevels::Linear => brightness,
440            crate::retro::LightLevels::Doom32 => {
441                let base_level = (brightness as usize * 31) / 255;
442                let distance = (face_center - self.camera.position).norm();
443                // 2.0 buckets per world-unit gives coarse banding similar to classic software renderers.
444                let distance_drop = (distance * 2.0) as usize;
445                let idx = base_level.saturating_sub(distance_drop).min(31);
446                Self::DOOM_LIGHT_TABLE[idx]
447            }
448        };
449
450        let factor = level_u8 as f32 / 255.0;
451        Rgb565::new(
452            (base.r() as f32 * factor) as u8,
453            (base.g() as f32 * factor) as u8,
454            (base.b() as f32 * factor) as u8,
455        )
456    }
457
458    /// Compute the world-space centroid of a triangle face.
459    #[inline]
460    fn face_world_center(
461        face: &[usize; 3],
462        vertices: &[[f32; 3]],
463        model_matrix: Matrix4<f32>,
464    ) -> Point3<f32> {
465        let v0 = vertices[face[0]];
466        let v1 = vertices[face[1]];
467        let v2 = vertices[face[2]];
468        let cx = (v0[0] + v1[0] + v2[0]) / 3.0;
469        let cy = (v0[1] + v1[1] + v2[1]) / 3.0;
470        let cz = (v0[2] + v1[2] + v2[2]) / 3.0;
471        model_matrix.transform_point(&Point3::new(cx, cy, cz))
472    }
473
474    pub fn set_caps(&mut self, caps: crate::config::ProfileCaps) {
475        self.caps = Some(caps);
476        self.apply_render_defaults(crate::config::render_defaults_for_profile(caps));
477    }
478
479    pub fn clear_caps(&mut self) {
480        self.caps = None;
481    }
482
483    pub fn set_quality_tier(&mut self, tier: crate::config::QualityTier) {
484        self.quality_tier = tier;
485    }
486
487    pub fn set_material_profile(&mut self, profile: crate::config::MaterialProfile) {
488        self.material_profile = profile;
489    }
490
491    pub fn apply_render_defaults(&mut self, defaults: crate::config::RenderDefaults) {
492        self.quality_tier = defaults.quality_tier;
493        self.material_profile = defaults.material_profile;
494    }
495
496    fn resolve_render_mode(&self, mode: &RenderMode) -> RenderMode {
497        use crate::config::{MaterialProfile, QualityTier};
498        match self.quality_tier {
499            QualityTier::Fastest => match mode {
500                RenderMode::BlinnPhong { .. }
501                | RenderMode::GouraudLightDir(_)
502                | RenderMode::Toon(_, _)
503                | RenderMode::SolidLightDir(_) => RenderMode::Solid,
504                _ => mode.clone(),
505            },
506            QualityTier::Balanced => match (self.material_profile, mode) {
507                (MaterialProfile::Unlit, RenderMode::BlinnPhong { .. })
508                | (MaterialProfile::Unlit, RenderMode::GouraudLightDir(_))
509                | (MaterialProfile::Unlit, RenderMode::Toon(_, _))
510                | (MaterialProfile::Unlit, RenderMode::SolidLightDir(_)) => RenderMode::Solid,
511                (MaterialProfile::Lambert, RenderMode::BlinnPhong { light_dir, .. }) => {
512                    RenderMode::SolidLightDir(*light_dir)
513                }
514                _ => mode.clone(),
515            },
516            QualityTier::Quality => match (self.material_profile, mode) {
517                (MaterialProfile::Unlit, RenderMode::BlinnPhong { .. })
518                | (MaterialProfile::Unlit, RenderMode::GouraudLightDir(_))
519                | (MaterialProfile::Unlit, RenderMode::Toon(_, _))
520                | (MaterialProfile::Unlit, RenderMode::SolidLightDir(_)) => RenderMode::Solid,
521                (MaterialProfile::Lambert, RenderMode::BlinnPhong { light_dir, .. }) => {
522                    RenderMode::SolidLightDir(*light_dir)
523                }
524                _ => mode.clone(),
525            },
526        }
527    }
528
529    /// Frustum culling. Returns true if the mesh should be culled.
530    #[inline]
531    fn should_cull_mesh(&self, mesh: &K3dMesh) -> bool {
532        #[cfg(feature = "render-layers")]
533        if !self.camera.layers.intersects(mesh.layers) {
534            return true;
535        }
536
537        #[cfg(feature = "aabb-cull")]
538        {
539            let aabb = mesh.model_aabb();
540            let world_center = mesh
541                .model_matrix
542                .transform_point(&nalgebra::Point3::from(aabb.center));
543            let scale = mesh.similarity.scaling();
544            let radius = aabb.radius() * scale;
545            let m = self.camera.vp_matrix;
546            let planes = Self::frustum_planes_from_vp(&m);
547
548            for plane in &planes {
549                let (a, b, c, d) = (plane[0], plane[1], plane[2], plane[3]);
550                let len = (a * a + b * b + c * c).sqrt();
551                if len <= 0.0 {
552                    continue;
553                }
554                let dist = (a * world_center.x + b * world_center.y + c * world_center.z + d) / len;
555                if dist < -radius {
556                    return true;
557                }
558            }
559            for plane in &planes {
560                let (a, b, c, d) = (plane[0], plane[1], plane[2], plane[3]);
561                let len = (a * a + b * b + c * c).sqrt();
562                if len <= 0.0 {
563                    continue;
564                }
565                if aabb.plane_signed_overshoot(a, b, c, d, len, &mesh.model_matrix) < 0.0 {
566                    return true;
567                }
568            }
569            return false;
570        }
571
572        #[cfg(not(feature = "aabb-cull"))]
573        {
574            let mesh_pos = mesh.get_position();
575            let radius_sq = mesh.compute_bounding_radius_sq();
576            let radius = radius_sq.sqrt();
577            let planes = Self::frustum_planes_from_vp(&self.camera.vp_matrix);
578            for plane in &planes {
579                let (a, b, c, d) = (plane[0], plane[1], plane[2], plane[3]);
580                let len = (a * a + b * b + c * c).sqrt();
581                if len > 0.0 {
582                    let dist = (a * mesh_pos.x + b * mesh_pos.y + c * mesh_pos.z + d) / len;
583                    if dist < -radius {
584                        return true;
585                    }
586                }
587            }
588            false
589        }
590    }
591
592    #[inline]
593    fn frustum_planes_from_vp(m: &Matrix4<f32>) -> [[f32; 4]; 6] {
594        [
595            [
596                m[(3, 0)] + m[(0, 0)],
597                m[(3, 1)] + m[(0, 1)],
598                m[(3, 2)] + m[(0, 2)],
599                m[(3, 3)] + m[(0, 3)],
600            ],
601            [
602                m[(3, 0)] - m[(0, 0)],
603                m[(3, 1)] - m[(0, 1)],
604                m[(3, 2)] - m[(0, 2)],
605                m[(3, 3)] - m[(0, 3)],
606            ],
607            [
608                m[(3, 0)] + m[(1, 0)],
609                m[(3, 1)] + m[(1, 1)],
610                m[(3, 2)] + m[(1, 2)],
611                m[(3, 3)] + m[(1, 3)],
612            ],
613            [
614                m[(3, 0)] - m[(1, 0)],
615                m[(3, 1)] - m[(1, 1)],
616                m[(3, 2)] - m[(1, 2)],
617                m[(3, 3)] - m[(1, 3)],
618            ],
619            [
620                m[(3, 0)] + m[(2, 0)],
621                m[(3, 1)] + m[(2, 1)],
622                m[(3, 2)] + m[(2, 2)],
623                m[(3, 3)] + m[(2, 3)],
624            ],
625            [
626                m[(3, 0)] - m[(2, 0)],
627                m[(3, 1)] - m[(2, 1)],
628                m[(3, 2)] - m[(2, 2)],
629                m[(3, 3)] - m[(2, 3)],
630            ],
631        ]
632    }
633
634    #[inline(always)]
635    fn transform_point(&self, point: &[f32; 3], model_matrix: Matrix4<f32>) -> Option<Point3<i32>> {
636        #[cfg(feature = "fixed-transform")]
637        {
638            return self.transform_point_fixed(point, model_matrix);
639        }
640        #[cfg(not(feature = "fixed-transform"))]
641        {
642            let point = nalgebra::Vector4::new(point[0], point[1], point[2], 1.0);
643            let point = model_matrix * point;
644
645            if point.w < 0.0 {
646                return None;
647            }
648            // `point.w` is the view-space depth (distance along the view
649            // direction) for a standard perspective projection, so this is
650            // a proper "is the view depth within [near, far]" test. This
651            // used to compare pre-divide clip.z instead, which is not
652            // linear in view depth -- the "safe" window it accepted started
653            // well above `near` itself, silently culling geometry closer to
654            // the camera than roughly the midpoint of [near, far]. Matches
655            // the fix already applied to `transform_point_with_w`.
656            if point.w < self.camera.near || point.w > self.camera.far {
657                return None;
658            }
659
660            let point = Point3::from_homogeneous(point)?;
661
662            let x = ((1.0 + point.x) * 0.5 * self.width as f32) as i32;
663            let y = ((1.0 - point.y) * 0.5 * self.height as f32) as i32;
664
665            if x < 0 || x >= self.width as i32 || y < 0 || y >= self.height as i32 {
666                return None;
667            }
668
669            Some(Point3::new(
670                x,
671                y,
672                (point.z * (self.camera.far - self.camera.near) + self.camera.near) as i32,
673            ))
674        }
675    }
676
677    #[cfg(feature = "fixed-transform")]
678    #[inline(always)]
679    fn transform_point_fixed(
680        &self,
681        point: &[f32; 3],
682        model_matrix: Matrix4<f32>,
683    ) -> Option<Point3<i32>> {
684        use embedded_dsp::fixed_point::{Q16, from_q16, to_q16};
685
686        // Q16.16 division that returns `None` on a zero divisor, matching
687        // the early-return-via-`?` control flow below (`div_q16` itself
688        // just returns 0 on divide-by-zero).
689        #[inline(always)]
690        fn div_checked(a: Q16, b: Q16) -> Option<Q16> {
691            if b == 0 {
692                None
693            } else {
694                Some(embedded_dsp::fixed_point::div_q16(a, b))
695            }
696        }
697
698        let point = nalgebra::Vector4::new(point[0], point[1], point[2], 1.0);
699        let point = model_matrix * point;
700
701        if point.w <= 0.0 {
702            return None;
703        }
704        // Same fix as `transform_point`/`transform_point_with_w`: test the
705        // view-space depth (`point.w`) against `[near, far]`, not the
706        // post-divide NDC z (which is confined to roughly `[-1, 1]` and can
707        // never satisfy a "world scale" near/far pair).
708        if point.w < self.camera.near || point.w > self.camera.far {
709            return None;
710        }
711
712        let x_fp = div_checked(to_q16(point.x), to_q16(point.w))?;
713        let y_fp = div_checked(to_q16(point.y), to_q16(point.w))?;
714        let z_ndc = from_q16(div_checked(to_q16(point.z), to_q16(point.w))?);
715
716        let x = ((1.0 + from_q16(x_fp)) * 0.5 * self.width as f32) as i32;
717        let y = ((1.0 - from_q16(y_fp)) * 0.5 * self.height as f32) as i32;
718
719        if x < 0 || x >= self.width as i32 || y < 0 || y >= self.height as i32 {
720            return None;
721        }
722
723        Some(Point3::new(
724            x,
725            y,
726            (z_ndc * (self.camera.far - self.camera.near) + self.camera.near) as i32,
727        ))
728    }
729
730    #[inline(always)]
731    pub fn transform_points<const N: usize>(
732        &self,
733        indices: &[usize; N],
734        vertices: &[[f32; 3]],
735        model_matrix: Matrix4<f32>,
736    ) -> Option<[Point3<i32>; N]> {
737        let mut ret = [Point3::new(0, 0, 0); N];
738
739        for i in 0..N {
740            ret[i] = self.transform_point(&vertices[indices[i]], model_matrix)?;
741        }
742
743        Some(ret)
744    }
745
746    /// Like `transform_point` but also returns the clip-space W for perspective-correct interpolation.
747    /// Returns (screen_point, w_clip). w_clip is the clip-space W before perspective division.
748    fn transform_point_with_w(
749        &self,
750        point: &[f32; 3],
751        model_matrix: Matrix4<f32>,
752    ) -> Option<(Point3<i32>, f32)> {
753        let v = nalgebra::Vector4::new(point[0], point[1], point[2], 1.0);
754        let clip = model_matrix * v;
755        // clip.w is the view-space depth (distance along the view direction).
756        // Previously this compared ndc_z (range -1..+1) against camera.near/far
757        // (world-space values like 0.4 and 20.0), which is a unit mismatch that
758        // caused close particles to bleed through unrendered geometry.
759        if clip.w < self.camera.near || clip.w > self.camera.far {
760            return None;
761        }
762        let ndc_x = clip.x / clip.w;
763        let ndc_y = clip.y / clip.w;
764        let ndc_z = clip.z / clip.w;
765        let x = ((1.0 + ndc_x) * 0.5 * self.width as f32) as i32;
766        let y = ((1.0 - ndc_y) * 0.5 * self.height as f32) as i32;
767        if x < 0 || x >= self.width as i32 || y < 0 || y >= self.height as i32 {
768            return None;
769        }
770        let z = (ndc_z * (self.camera.far - self.camera.near) + self.camera.near) as i32;
771        Some((Point3::new(x, y, z), clip.w))
772    }
773
774    /// Like `transform_points` but also returns clip-space W values for perspective-correct UV.
775    #[inline(always)]
776    pub fn transform_points_with_w<const N: usize>(
777        &self,
778        indices: &[usize; N],
779        vertices: &[[f32; 3]],
780        model_matrix: Matrix4<f32>,
781    ) -> Option<([Point3<i32>; N], [f32; N])> {
782        let mut pts = [Point3::new(0, 0, 0); N];
783        let mut ws = [1.0f32; N];
784        for i in 0..N {
785            let (p, w) = self.transform_point_with_w(&vertices[indices[i]], model_matrix)?;
786            pts[i] = p;
787            ws[i] = w;
788        }
789        Some((pts, ws))
790    }
791
792    /// Position-based backface cull.
793    ///
794    /// Returns `true` when the face should be skipped (camera is on the
795    /// back/outer side of the surface).
796    ///
797    /// Using the camera *position* rather than *direction* means the result is
798    /// independent of where the camera looks — a horizontal floor stays visible
799    /// even when the camera pitches up or down.  The direction-based test
800    /// (`camera.get_direction() · normal`) incorrectly flips sign as soon as
801    /// pitch ≠ 0, culling the floor on upward tilt and the ceiling on downward.
802    #[inline]
803    fn is_backface(
804        &self,
805        face: &[usize; 3],
806        vertices: &[[f32; 3]],
807        model_matrix: Matrix4<f32>,
808        world_normal: &Vector3<f32>,
809    ) -> bool {
810        let v0 = vertices[face[0]];
811        let v0_world = if model_matrix == Matrix4::identity() {
812            Point3::new(v0[0], v0[1], v0[2])
813        } else {
814            model_matrix.transform_point(&Point3::new(v0[0], v0[1], v0[2]))
815        };
816        (self.camera.position - v0_world).dot(world_normal) < 0.0
817    }
818
819    // ── Near-plane triangle clipping ──────────────────────────────────────────
820    //
821    // The engine's vertex-by-vertex `transform_point` returns `None` for any
822    // vertex that projects outside the screen, causing the whole triangle to
823    // be dropped.  For interior scenes (floor, ceiling, walls close to the
824    // camera) this makes large surfaces invisible.
825    //
826    // The fix is a one-plane Sutherland-Hodgman clip against w = CLIP_NEAR_W
827    // before perspective divide.  Clipped triangles are emitted directly after
828    // projection; the rasterizer's existing scanline bounds-checks handle any
829    // remaining X/Y screen overshoot safely.
830
831    /// Project a single homogeneous clip-space vertex to integer screen coords.
832    /// Unlike `transform_point`, this does NOT reject off-screen X/Y — the
833    /// rasterizer clips scanlines to screen bounds already.
834    /// Screen coordinates are guard-banded to ±8× the framebuffer dimension so
835    /// the rasterizer scanline loop is always bounded even for near-plane clips.
836    #[inline]
837    fn clip_to_screen(&self, c: Vector4<f32>) -> Option<Point3<i32>> {
838        if c.w <= 0.0 {
839            return None;
840        }
841        let mut ndc = Point3::from_homogeneous(c)?;
842        if self.vertex_snap_bits > 0 {
843            let scale = (1u32 << self.vertex_snap_bits) as f32;
844            ndc.x = (ndc.x * scale).round() / scale;
845            ndc.y = (ndc.y * scale).round() / scale;
846        }
847        let w = self.width as f32;
848        let h = self.height as f32;
849        let x = ((1.0 + ndc.x) * 0.5 * w).clamp(-w * 8.0, w * 9.0) as i32;
850        let y = ((1.0 - ndc.y) * 0.5 * h).clamp(-h * 8.0, h * 9.0) as i32;
851        let depth = (ndc.z * (self.camera.far - self.camera.near) + self.camera.near) as i32;
852        Some(Point3::new(x, y, depth))
853    }
854
855    /// Project three clip-space vertices and emit one `ColoredTriangleWithDepth`
856    /// command if all three project successfully.
857    #[inline]
858    fn project_and_emit<F>(
859        &self,
860        c0: Vector4<f32>,
861        c1: Vector4<f32>,
862        c2: Vector4<f32>,
863        color: Rgb565,
864        callback: &mut F,
865    ) where
866        F: FnMut(DrawPrimitive),
867    {
868        if let (Some(p0), Some(p1), Some(p2)) = (
869            self.clip_to_screen(c0),
870            self.clip_to_screen(c1),
871            self.clip_to_screen(c2),
872        ) {
873            callback(DrawPrimitive::ColoredTriangleWithDepth {
874                points: [p0.xy(), p1.xy(), p2.xy()],
875                depths: [p0.z as f32, p1.z as f32, p2.z as f32],
876                color,
877            });
878        }
879    }
880
881    /// One Sutherland-Hodgman pass against a single clip-space plane.
882    ///
883    /// `dist(v) >= 0.0` means the vertex is on the inside of the plane.
884    /// Returns the number of vertices written into `output`.
885    fn clip_polygon_plane(
886        input: &[Vector4<f32>],
887        output: &mut [Vector4<f32>; 8],
888        dist: impl Fn(Vector4<f32>) -> f32,
889    ) -> usize {
890        let n = input.len();
891        let mut m = 0usize;
892        for i in 0..n {
893            let prev = input[(n + i - 1) % n];
894            let curr = input[i];
895            let d_prev = dist(prev);
896            let d_curr = dist(curr);
897            if d_curr >= 0.0 {
898                if d_prev < 0.0 {
899                    // Crossing from outside → inside: emit the boundary vertex.
900                    let t = d_prev / (d_prev - d_curr);
901                    if m < 8 {
902                        output[m] = prev + (curr - prev) * t;
903                        m += 1;
904                    }
905                }
906                if m < 8 {
907                    output[m] = curr;
908                    m += 1;
909                }
910            } else if d_prev >= 0.0 {
911                // Crossing from inside → outside: emit the boundary vertex.
912                let t = d_prev / (d_prev - d_curr);
913                if m < 8 {
914                    output[m] = prev + (curr - prev) * t;
915                    m += 1;
916                }
917            }
918        }
919        m
920    }
921
922    /// Clip a triangle against all 5 frustum planes and emit the resulting
923    /// fan of screen-space triangles.
924    ///
925    /// Uses a full Sutherland-Hodgman pass in clip space (near, left, right,
926    /// bottom, top).  Clipping against only the near plane left NDC x/y values
927    /// like ±13 for near-clipped vertices touching a wall at a grazing angle,
928    /// causing the projected triangle to cover only a sliver instead of the
929    /// full wall — producing the black triangular holes.
930    fn emit_clipped<F>(&self, clip: [Vector4<f32>; 3], color: Rgb565, callback: &mut F)
931    where
932        F: FnMut(DrawPrimitive),
933    {
934        let nw = self.camera.near;
935
936        let mut a = [Vector4::zeros(); 8];
937        let mut b = [Vector4::zeros(); 8];
938        a[0] = clip[0];
939        a[1] = clip[1];
940        a[2] = clip[2];
941
942        // near:   w >= nw
943        let n = Self::clip_polygon_plane(&a[..3], &mut b, |v| v.w - nw);
944        if n < 3 {
945            return;
946        }
947        // left:   x >= -w  →  x + w >= 0
948        let n = Self::clip_polygon_plane(&b[..n], &mut a, |v| v.x + v.w);
949        if n < 3 {
950            return;
951        }
952        // right:  x <=  w  →  w - x >= 0
953        let n = Self::clip_polygon_plane(&a[..n], &mut b, |v| v.w - v.x);
954        if n < 3 {
955            return;
956        }
957        // bottom: y >= -w  →  y + w >= 0
958        let n = Self::clip_polygon_plane(&b[..n], &mut a, |v| v.y + v.w);
959        if n < 3 {
960            return;
961        }
962        // top:    y <=  w  →  w - y >= 0
963        let n = Self::clip_polygon_plane(&a[..n], &mut b, |v| v.w - v.y);
964        if n < 3 {
965            return;
966        }
967
968        // Triangulate the clipped polygon as a fan from vertex 0.
969        for i in 1..n - 1 {
970            self.project_and_emit(b[0], b[i], b[i + 1], color, callback);
971        }
972    }
973
974    fn render<'a, MS, F>(&self, meshes: MS, mut callback: F)
975    where
976        MS: IntoIterator<Item = &'a K3dMesh<'a>>,
977        F: FnMut(DrawPrimitive),
978    {
979        for mesh in meshes {
980            if mesh.geometry.vertices.is_empty() {
981                continue;
982            }
983
984            // Frustum culling: Skip meshes that are completely outside the view frustum
985            // This can improve performance by 50-90% by avoiding transformation and rendering
986            // of off-screen objects
987            if self.should_cull_mesh(mesh) {
988                continue;
989            }
990
991            // LOD Selection: Choose geometry based on distance from camera
992            let mesh_pos = mesh.get_position();
993            let distance = (mesh_pos - self.camera.position).norm();
994            let geometry = mesh.select_lod(distance);
995            #[cfg(feature = "lod-crossfade")]
996            let alpha_override = mesh.draw_alpha.get();
997            #[cfg(feature = "lod-crossfade")]
998            let mut emit = |prim: DrawPrimitive| {
999                let prim = match alpha_override {
1000                    Some(a) => apply_draw_alpha(prim, a),
1001                    None => prim,
1002                };
1003                callback(prim);
1004            };
1005            #[cfg(not(feature = "lod-crossfade"))]
1006            let mut emit = |prim: DrawPrimitive| {
1007                callback(prim);
1008            };
1009
1010            let transform_matrix = self.camera.vp_matrix * mesh.model_matrix;
1011
1012            let render_mode = self.resolve_render_mode(&mesh.render_mode);
1013            let is_textured = matches!(
1014                render_mode,
1015                RenderMode::Textured | RenderMode::TexturedGouraud(_) | RenderMode::MatCap
1016            );
1017
1018            let mut v_cache_plain: [Option<Point3<i32>>; 256] = [None; 256];
1019            let mut v_cache_w: [Option<(Point3<i32>, f32)>; 256] = [None; 256];
1020
1021            let cache_limit = geometry.vertices.len().min(256);
1022            if is_textured {
1023                for i in 0..cache_limit {
1024                    v_cache_w[i] =
1025                        self.transform_point_with_w(&geometry.vertices[i], transform_matrix);
1026                }
1027            } else {
1028                for i in 0..cache_limit {
1029                    v_cache_plain[i] =
1030                        self.transform_point(&geometry.vertices[i], transform_matrix);
1031                }
1032            }
1033
1034            let mut get_pt = |idx: usize| -> Option<Point3<i32>> {
1035                if idx < 256 {
1036                    v_cache_plain[idx]
1037                } else {
1038                    self.transform_point(&geometry.vertices[idx], transform_matrix)
1039                }
1040            };
1041
1042            let get_pt_w = |idx: usize| -> Option<(Point3<i32>, f32)> {
1043                if idx < 256 {
1044                    v_cache_w[idx]
1045                } else {
1046                    self.transform_point_with_w(&geometry.vertices[idx], transform_matrix)
1047                }
1048            };
1049
1050            let tf_face = |face: &[usize; 3]| -> Option<[Point3<i32>; 3]> {
1051                Some([get_pt(face[0])?, get_pt(face[1])?, get_pt(face[2])?])
1052            };
1053
1054            let tf_face_w = |face: &[usize; 3]| -> Option<([Point3<i32>; 3], [f32; 3])> {
1055                let (p0, w0) = get_pt_w(face[0])?;
1056                let (p1, w1) = get_pt_w(face[1])?;
1057                let (p2, w2) = get_pt_w(face[2])?;
1058                Some(([p0, p1, p2], [w0, w1, w2]))
1059            };
1060
1061            if let Some(out_color) = mesh.outline_color {
1062                if mesh.outline_width > 0.0 {
1063                    let has_vertex_normals = !geometry.vertex_normals.is_empty();
1064                    let has_face_normals = !geometry.normals.is_empty();
1065
1066                    for (face_idx, face) in geometry.faces.iter().enumerate() {
1067                        let face_normal = if has_face_normals {
1068                            Vector3::new(
1069                                geometry.normals[face_idx][0],
1070                                geometry.normals[face_idx][1],
1071                                geometry.normals[face_idx][2],
1072                            )
1073                        } else {
1074                            let v0 = Vector3::new(
1075                                geometry.vertices[face[0]][0],
1076                                geometry.vertices[face[0]][1],
1077                                geometry.vertices[face[0]][2],
1078                            );
1079                            let v1 = Vector3::new(
1080                                geometry.vertices[face[1]][0],
1081                                geometry.vertices[face[1]][1],
1082                                geometry.vertices[face[1]][2],
1083                            );
1084                            let v2 = Vector3::new(
1085                                geometry.vertices[face[2]][0],
1086                                geometry.vertices[face[2]][1],
1087                                geometry.vertices[face[2]][2],
1088                            );
1089                            (v1 - v0).cross(&(v2 - v0)).normalize()
1090                        };
1091
1092                        let transformed_normal = mesh.model_matrix.transform_vector(&face_normal);
1093                        if !self.is_backface(
1094                            face,
1095                            geometry.vertices,
1096                            mesh.model_matrix,
1097                            &transformed_normal,
1098                        ) {
1099                            continue;
1100                        }
1101
1102                        let mut pts = [Point3::origin(); 3];
1103                        let mut valid = true;
1104                        for i in 0..3 {
1105                            let vn = if has_vertex_normals {
1106                                Vector3::new(
1107                                    geometry.vertex_normals[face[i]][0],
1108                                    geometry.vertex_normals[face[i]][1],
1109                                    geometry.vertex_normals[face[i]][2],
1110                                )
1111                            } else {
1112                                face_normal
1113                            };
1114                            let vpos = geometry.vertices[face[i]];
1115                            let ext_v = [
1116                                vpos[0] + vn.x * mesh.outline_width,
1117                                vpos[1] + vn.y * mesh.outline_width,
1118                                vpos[2] + vn.z * mesh.outline_width,
1119                            ];
1120                            if let Some(pt) = self.transform_point(&ext_v, transform_matrix) {
1121                                pts[i] = pt;
1122                            } else {
1123                                valid = false;
1124                                break;
1125                            }
1126                        }
1127
1128                        if valid {
1129                            emit(DrawPrimitive::ColoredTriangleWithDepth {
1130                                points: [pts[0].xy(), pts[1].xy(), pts[2].xy()],
1131                                depths: [pts[0].z as f32, pts[1].z as f32, pts[2].z as f32],
1132                                color: out_color,
1133                            });
1134                        }
1135                    }
1136                }
1137            }
1138
1139            let render_mode = self.resolve_render_mode(&mesh.render_mode);
1140            match render_mode {
1141                RenderMode::Points => {
1142                    let screen_space_points = (0..geometry.vertices.len()).filter_map(&mut get_pt);
1143
1144                    if geometry.colors.len() == geometry.vertices.len() {
1145                        for (point, color) in screen_space_points.zip(geometry.colors) {
1146                            emit(DrawPrimitive::ColoredPoint(point.xy(), *color));
1147                        }
1148                    } else {
1149                        for point in screen_space_points {
1150                            emit(DrawPrimitive::ColoredPoint(point.xy(), mesh.color));
1151                        }
1152                    }
1153                }
1154
1155                RenderMode::Lines if !geometry.lines.is_empty() => {
1156                    for line in geometry.lines {
1157                        if let (Some(p1), Some(p2)) = (get_pt(line[0]), get_pt(line[1])) {
1158                            emit(DrawPrimitive::Line([p1.xy(), p2.xy()], mesh.color));
1159                        }
1160                    }
1161                }
1162
1163                RenderMode::Lines if !geometry.faces.is_empty() => {
1164                    for face in geometry.faces {
1165                        if let Some([p1, p2, p3]) = tf_face(face) {
1166                            emit(DrawPrimitive::Line([p1.xy(), p2.xy()], mesh.color));
1167                            emit(DrawPrimitive::Line([p2.xy(), p3.xy()], mesh.color));
1168                            emit(DrawPrimitive::Line([p3.xy(), p1.xy()], mesh.color));
1169                        }
1170                    }
1171                }
1172
1173                RenderMode::Lines => {}
1174
1175                RenderMode::SolidLightDir(direction) => {
1176                    let color_as_float = Vector3::new(
1177                        mesh.color.r() as f32 / 32.0,
1178                        mesh.color.g() as f32 / 64.0,
1179                        mesh.color.b() as f32 / 32.0,
1180                    );
1181                    let ambient_color = color_as_float * 0.1;
1182                    let adjusted_dir = Vector3::new(direction.x, direction.y, -direction.z);
1183
1184                    for (face, normal) in geometry.faces.iter().zip(geometry.normals.iter()) {
1185                        let normal = Vector3::new(normal[0], normal[1], normal[2]);
1186                        let transformed_normal = mesh.model_matrix.transform_vector(&normal);
1187                        if self.is_backface(
1188                            face,
1189                            geometry.vertices,
1190                            mesh.model_matrix,
1191                            &transformed_normal,
1192                        ) {
1193                            continue;
1194                        }
1195
1196                        if let Some([p1, p2, p3]) = tf_face(face) {
1197                            let intensity = transformed_normal.dot(&adjusted_dir).max(0.0);
1198                            let final_color = color_as_float * intensity + ambient_color;
1199                            let final_color = Vector3::new(
1200                                final_color.x.clamp(0.0, 1.0),
1201                                final_color.y.clamp(0.0, 1.0),
1202                                final_color.z.clamp(0.0, 1.0),
1203                            );
1204                            let mut color = Rgb565::new(
1205                                (final_color.x * 31.0) as u8,
1206                                (final_color.y * 63.0) as u8,
1207                                (final_color.z * 31.0) as u8,
1208                            );
1209                            if !self.point_lights.is_empty() {
1210                                let wc = Self::face_world_center(
1211                                    face,
1212                                    geometry.vertices,
1213                                    mesh.model_matrix,
1214                                );
1215                                color = Self::add_tint(color, self.light_tint_at(wc));
1216                            }
1217                            emit(DrawPrimitive::ColoredTriangleWithDepth {
1218                                points: [p1.xy(), p2.xy(), p3.xy()],
1219                                depths: [p1.z as f32, p2.z as f32, p3.z as f32],
1220                                color,
1221                            });
1222                        }
1223                    }
1224                }
1225
1226                RenderMode::GouraudLightDir(direction) => {
1227                    let color_as_float = Vector3::new(
1228                        mesh.color.r() as f32 / 32.0,
1229                        mesh.color.g() as f32 / 64.0,
1230                        mesh.color.b() as f32 / 32.0,
1231                    );
1232                    let ambient_color = color_as_float * 0.1;
1233                    let adjusted_dir = Vector3::new(direction.x, direction.y, -direction.z);
1234
1235                    for (face, face_normal) in geometry.faces.iter().zip(geometry.normals.iter()) {
1236                        let fn_vec = Vector3::new(face_normal[0], face_normal[1], face_normal[2]);
1237                        let transformed_fn = mesh.model_matrix.transform_vector(&fn_vec);
1238
1239                        if self.is_backface(
1240                            face,
1241                            geometry.vertices,
1242                            mesh.model_matrix,
1243                            &transformed_fn,
1244                        ) {
1245                            continue;
1246                        }
1247
1248                        if let Some([p1, p2, p3]) = tf_face(face) {
1249                            let vertex_colors: [Rgb565; 3] = core::array::from_fn(|k| {
1250                                let vn = if !geometry.vertex_normals.is_empty() {
1251                                    let vn_arr = geometry.vertex_normals[face[k]];
1252                                    let vn_vec = Vector3::new(vn_arr[0], vn_arr[1], vn_arr[2]);
1253                                    mesh.model_matrix.transform_vector(&vn_vec)
1254                                } else {
1255                                    transformed_fn
1256                                };
1257
1258                                let intensity = vn.dot(&adjusted_dir).max(0.0);
1259                                let c = color_as_float * intensity + ambient_color;
1260                                let mut vc = Rgb565::new(
1261                                    (c.x.clamp(0.0, 1.0) * 31.0) as u8,
1262                                    (c.y.clamp(0.0, 1.0) * 63.0) as u8,
1263                                    (c.z.clamp(0.0, 1.0) * 31.0) as u8,
1264                                );
1265                                if !self.point_lights.is_empty() {
1266                                    let vpos = geometry.vertices[face[k]];
1267                                    let wp = mesh
1268                                        .model_matrix
1269                                        .transform_point(&Point3::new(vpos[0], vpos[1], vpos[2]));
1270                                    vc = Self::add_tint(vc, self.light_tint_at(wp));
1271                                }
1272                                vc
1273                            });
1274
1275                            emit(DrawPrimitive::GouraudTriangleWithDepth {
1276                                points: [p1.xy(), p2.xy(), p3.xy()],
1277                                depths: [p1.z as f32, p2.z as f32, p3.z as f32],
1278                                colors: vertex_colors,
1279                            });
1280                        }
1281                    }
1282                }
1283
1284                RenderMode::Toon(direction, bands) => {
1285                    let color_as_float = Vector3::new(
1286                        mesh.color.r() as f32 / 32.0,
1287                        mesh.color.g() as f32 / 64.0,
1288                        mesh.color.b() as f32 / 32.0,
1289                    );
1290                    let ambient_color = color_as_float * 0.15;
1291                    let adjusted_dir = Vector3::new(direction.x, direction.y, -direction.z);
1292                    let bands_f = bands.max(1) as f32;
1293
1294                    for (face, normal) in geometry.faces.iter().zip(geometry.normals.iter()) {
1295                        let normal = Vector3::new(normal[0], normal[1], normal[2]);
1296                        let transformed_normal = mesh.model_matrix.transform_vector(&normal);
1297                        if self.is_backface(
1298                            face,
1299                            geometry.vertices,
1300                            mesh.model_matrix,
1301                            &transformed_normal,
1302                        ) {
1303                            continue;
1304                        }
1305
1306                        if let Some([p1, p2, p3]) = tf_face(face) {
1307                            let raw_intensity = transformed_normal.dot(&adjusted_dir).max(0.0);
1308                            let intensity =
1309                                ((raw_intensity * bands_f).round() / bands_f).clamp(0.0, 1.0);
1310
1311                            let final_color = color_as_float * intensity + ambient_color;
1312                            let final_color = Vector3::new(
1313                                final_color.x.clamp(0.0, 1.0),
1314                                final_color.y.clamp(0.0, 1.0),
1315                                final_color.z.clamp(0.0, 1.0),
1316                            );
1317                            let mut color = Rgb565::new(
1318                                (final_color.x * 31.0) as u8,
1319                                (final_color.y * 63.0) as u8,
1320                                (final_color.z * 31.0) as u8,
1321                            );
1322                            if !self.point_lights.is_empty() {
1323                                let wc = Self::face_world_center(
1324                                    face,
1325                                    geometry.vertices,
1326                                    mesh.model_matrix,
1327                                );
1328                                color = Self::add_tint(color, self.light_tint_at(wc));
1329                            }
1330                            emit(DrawPrimitive::ColoredTriangleWithDepth {
1331                                points: [p1.xy(), p2.xy(), p3.xy()],
1332                                depths: [p1.z as f32, p2.z as f32, p3.z as f32],
1333                                color,
1334                            });
1335                        }
1336                    }
1337                }
1338
1339                RenderMode::BlinnPhong {
1340                    light_dir,
1341                    specular_intensity,
1342                    shininess,
1343                } => {
1344                    // Pre-compute lighting constants (once per mesh, not per face)
1345                    let color_as_float = Vector3::new(
1346                        mesh.color.r() as f32 / 32.0,
1347                        mesh.color.g() as f32 / 64.0,
1348                        mesh.color.b() as f32 / 32.0,
1349                    );
1350
1351                    // Pre-compute ambient lighting term
1352                    let ambient_color = color_as_float * 0.1;
1353
1354                    // Pre-compute adjusted light direction
1355                    // Negate only Z component of direction to fix front/back while keeping left/right
1356                    let adjusted_light_dir = Vector3::new(light_dir.x, light_dir.y, -light_dir.z);
1357
1358                    // Normalize light direction
1359                    let light_dir_normalized = adjusted_light_dir.normalize();
1360
1361                    for (face, normal) in geometry.faces.iter().zip(geometry.normals.iter()) {
1362                        //Backface culling
1363                        let normal = Vector3::new(normal[0], normal[1], normal[2]);
1364                        let transformed_normal = mesh.model_matrix.transform_vector(&normal);
1365                        let normalized_normal = transformed_normal.normalize();
1366
1367                        // Backface culling: cull faces pointing away from camera
1368                        if self.is_backface(
1369                            face,
1370                            geometry.vertices,
1371                            mesh.model_matrix,
1372                            &normalized_normal,
1373                        ) {
1374                            continue;
1375                        }
1376
1377                        if let Some([p1, p2, p3]) = tf_face(face) {
1378                            // Calculate face center in world space for view direction
1379                            let v0 = geometry.vertices[face[0]];
1380                            let v1 = geometry.vertices[face[1]];
1381                            let v2 = geometry.vertices[face[2]];
1382                            let face_center = Point3::new(
1383                                (v0[0] + v1[0] + v2[0]) / 3.0,
1384                                (v0[1] + v1[1] + v2[1]) / 3.0,
1385                                (v0[2] + v1[2] + v2[2]) / 3.0,
1386                            );
1387                            let face_center_world = mesh.model_matrix.transform_point(&face_center);
1388
1389                            // View direction: from face to camera
1390                            let view_dir = (self.camera.position - face_center_world).normalize();
1391
1392                            // Blinn-Phong half vector: H = normalize(L + V)
1393                            let half_vector = (light_dir_normalized + view_dir).normalize();
1394
1395                            // Diffuse term: N·L
1396                            let diffuse_intensity =
1397                                normalized_normal.dot(&light_dir_normalized).max(0.0);
1398
1399                            // Specular term: (N·H)^shininess
1400                            let specular_term =
1401                                normalized_normal.dot(&half_vector).max(0.0).powf(shininess);
1402
1403                            // Compute final color: ambient + diffuse + specular
1404                            let diffuse_color = color_as_float * diffuse_intensity;
1405                            let specular_color =
1406                                Vector3::new(1.0, 1.0, 1.0) * specular_term * specular_intensity;
1407                            let final_color = ambient_color + diffuse_color + specular_color;
1408
1409                            let final_color = Vector3::new(
1410                                final_color.x.clamp(0.0, 1.0),
1411                                final_color.y.clamp(0.0, 1.0),
1412                                final_color.z.clamp(0.0, 1.0),
1413                            );
1414
1415                            let mut color = Rgb565::new(
1416                                (final_color.x * 31.0) as u8,
1417                                (final_color.y * 63.0) as u8,
1418                                (final_color.z * 31.0) as u8,
1419                            );
1420                            if !self.point_lights.is_empty() {
1421                                color =
1422                                    Self::add_tint(color, self.light_tint_at(face_center_world));
1423                            }
1424                            emit(DrawPrimitive::ColoredTriangleWithDepth {
1425                                points: [p1.xy(), p2.xy(), p3.xy()],
1426                                depths: [p1.z as f32, p2.z as f32, p3.z as f32],
1427                                color,
1428                            });
1429                        }
1430                    }
1431                }
1432
1433                RenderMode::Solid => {
1434                    if geometry.normals.is_empty() {
1435                        for face in geometry.faces.iter() {
1436                            let color = if !self.point_lights.is_empty() {
1437                                let wc = Self::face_world_center(
1438                                    face,
1439                                    geometry.vertices,
1440                                    mesh.model_matrix,
1441                                );
1442                                Self::add_tint(mesh.color, self.light_tint_at(wc))
1443                            } else {
1444                                mesh.color
1445                            };
1446                            let v = &geometry.vertices;
1447                            let clip = [
1448                                transform_matrix
1449                                    * Vector4::new(
1450                                        v[face[0]][0],
1451                                        v[face[0]][1],
1452                                        v[face[0]][2],
1453                                        1.0,
1454                                    ),
1455                                transform_matrix
1456                                    * Vector4::new(
1457                                        v[face[1]][0],
1458                                        v[face[1]][1],
1459                                        v[face[1]][2],
1460                                        1.0,
1461                                    ),
1462                                transform_matrix
1463                                    * Vector4::new(
1464                                        v[face[2]][0],
1465                                        v[face[2]][1],
1466                                        v[face[2]][2],
1467                                        1.0,
1468                                    ),
1469                            ];
1470                            self.emit_clipped(clip, color, &mut callback);
1471                        }
1472                    } else {
1473                        for (face, normal) in geometry.faces.iter().zip(geometry.normals) {
1474                            let normal = Vector3::new(normal[0], normal[1], normal[2]);
1475                            let transformed_normal = mesh.model_matrix.transform_vector(&normal);
1476                            if self.is_backface(
1477                                face,
1478                                geometry.vertices,
1479                                mesh.model_matrix,
1480                                &transformed_normal,
1481                            ) {
1482                                continue;
1483                            }
1484                            let color = if !self.point_lights.is_empty() {
1485                                let wc = Self::face_world_center(
1486                                    face,
1487                                    geometry.vertices,
1488                                    mesh.model_matrix,
1489                                );
1490                                Self::add_tint(mesh.color, self.light_tint_at(wc))
1491                            } else {
1492                                mesh.color
1493                            };
1494                            let v = &geometry.vertices;
1495                            let clip = [
1496                                transform_matrix
1497                                    * Vector4::new(
1498                                        v[face[0]][0],
1499                                        v[face[0]][1],
1500                                        v[face[0]][2],
1501                                        1.0,
1502                                    ),
1503                                transform_matrix
1504                                    * Vector4::new(
1505                                        v[face[1]][0],
1506                                        v[face[1]][1],
1507                                        v[face[1]][2],
1508                                        1.0,
1509                                    ),
1510                                transform_matrix
1511                                    * Vector4::new(
1512                                        v[face[2]][0],
1513                                        v[face[2]][1],
1514                                        v[face[2]][2],
1515                                        1.0,
1516                                    ),
1517                            ];
1518                            self.emit_clipped(clip, color, &mut callback);
1519                        }
1520                    }
1521                }
1522
1523                RenderMode::SectorBright(brightness) => {
1524                    if geometry.normals.is_empty() {
1525                        for face in geometry.faces.iter() {
1526                            let wc =
1527                                Self::face_world_center(face, geometry.vertices, mesh.model_matrix);
1528                            let mut color = self.sector_shaded_color(mesh.color, brightness, wc);
1529                            if !self.point_lights.is_empty() {
1530                                color = Self::add_tint(color, self.light_tint_at(wc));
1531                            }
1532                            let v = &geometry.vertices;
1533                            let clip = [
1534                                transform_matrix
1535                                    * Vector4::new(
1536                                        v[face[0]][0],
1537                                        v[face[0]][1],
1538                                        v[face[0]][2],
1539                                        1.0,
1540                                    ),
1541                                transform_matrix
1542                                    * Vector4::new(
1543                                        v[face[1]][0],
1544                                        v[face[1]][1],
1545                                        v[face[1]][2],
1546                                        1.0,
1547                                    ),
1548                                transform_matrix
1549                                    * Vector4::new(
1550                                        v[face[2]][0],
1551                                        v[face[2]][1],
1552                                        v[face[2]][2],
1553                                        1.0,
1554                                    ),
1555                            ];
1556                            self.emit_clipped(clip, color, &mut callback);
1557                        }
1558                    } else {
1559                        for (face, normal) in geometry.faces.iter().zip(geometry.normals) {
1560                            let normal = Vector3::new(normal[0], normal[1], normal[2]);
1561                            let transformed_normal = mesh.model_matrix.transform_vector(&normal);
1562                            if self.is_backface(
1563                                face,
1564                                geometry.vertices,
1565                                mesh.model_matrix,
1566                                &transformed_normal,
1567                            ) {
1568                                continue;
1569                            }
1570                            let wc =
1571                                Self::face_world_center(face, geometry.vertices, mesh.model_matrix);
1572                            let mut color = self.sector_shaded_color(mesh.color, brightness, wc);
1573                            if !self.point_lights.is_empty() {
1574                                color = Self::add_tint(color, self.light_tint_at(wc));
1575                            }
1576                            let v = &geometry.vertices;
1577                            let clip = [
1578                                transform_matrix
1579                                    * Vector4::new(
1580                                        v[face[0]][0],
1581                                        v[face[0]][1],
1582                                        v[face[0]][2],
1583                                        1.0,
1584                                    ),
1585                                transform_matrix
1586                                    * Vector4::new(
1587                                        v[face[1]][0],
1588                                        v[face[1]][1],
1589                                        v[face[1]][2],
1590                                        1.0,
1591                                    ),
1592                                transform_matrix
1593                                    * Vector4::new(
1594                                        v[face[2]][0],
1595                                        v[face[2]][1],
1596                                        v[face[2]][2],
1597                                        1.0,
1598                                    ),
1599                            ];
1600                            self.emit_clipped(clip, color, &mut callback);
1601                        }
1602                    }
1603                }
1604
1605                RenderMode::Textured => {
1606                    // Requires both a texture and per-vertex UVs; silently
1607                    // skip the mesh (move to the next one) if either is
1608                    // missing, same graceful-degradation style as `Lines`
1609                    // with no `lines`/`faces` data.
1610                    let Some(texture_id) = geometry.texture_id else {
1611                        continue;
1612                    };
1613                    if geometry.uvs.is_empty() {
1614                        continue;
1615                    }
1616
1617                    // Unlike `Solid`, this doesn't route through
1618                    // `emit_clipped` (which only carries a flat `Rgb565`,
1619                    // not per-vertex UVs) -- a face with any vertex behind
1620                    // the near plane or outside the frustum is dropped
1621                    // whole, same as `GouraudLightDir`/`BlinnPhong`.
1622                    if geometry.normals.is_empty() {
1623                        for face in geometry.faces.iter() {
1624                            if let Some((points, ws)) = tf_face_w(face) {
1625                                emit(DrawPrimitive::TexturedTriangleWithDepth {
1626                                    points: [points[0].xy(), points[1].xy(), points[2].xy()],
1627                                    depths: [
1628                                        points[0].z as f32,
1629                                        points[1].z as f32,
1630                                        points[2].z as f32,
1631                                    ],
1632                                    ws,
1633                                    uvs: [
1634                                        geometry.uvs[face[0]],
1635                                        geometry.uvs[face[1]],
1636                                        geometry.uvs[face[2]],
1637                                    ],
1638                                    texture_id,
1639                                });
1640                            }
1641                        }
1642                    } else {
1643                        for (face, normal) in geometry.faces.iter().zip(geometry.normals) {
1644                            let normal = Vector3::new(normal[0], normal[1], normal[2]);
1645                            let transformed_normal = mesh.model_matrix.transform_vector(&normal);
1646                            if self.is_backface(
1647                                face,
1648                                geometry.vertices,
1649                                mesh.model_matrix,
1650                                &transformed_normal,
1651                            ) {
1652                                continue;
1653                            }
1654                            if let Some((points, ws)) = tf_face_w(face) {
1655                                emit(DrawPrimitive::TexturedTriangleWithDepth {
1656                                    points: [points[0].xy(), points[1].xy(), points[2].xy()],
1657                                    depths: [
1658                                        points[0].z as f32,
1659                                        points[1].z as f32,
1660                                        points[2].z as f32,
1661                                    ],
1662                                    ws,
1663                                    uvs: [
1664                                        geometry.uvs[face[0]],
1665                                        geometry.uvs[face[1]],
1666                                        geometry.uvs[face[2]],
1667                                    ],
1668                                    texture_id,
1669                                });
1670                            }
1671                        }
1672                    }
1673                }
1674                RenderMode::TexturedGouraud(direction) => {
1675                    let Some(texture_id) = geometry.texture_id else {
1676                        continue;
1677                    };
1678                    if geometry.uvs.is_empty() {
1679                        continue;
1680                    }
1681                    let color_as_float = Vector3::new(
1682                        mesh.color.r() as f32 / 32.0,
1683                        mesh.color.g() as f32 / 64.0,
1684                        mesh.color.b() as f32 / 32.0,
1685                    );
1686                    let ambient_color = color_as_float * 0.1;
1687                    let adjusted_dir = Vector3::new(direction.x, direction.y, -direction.z);
1688
1689                    if geometry.normals.is_empty() {
1690                        for face in geometry.faces.iter() {
1691                            if let Some((points, ws)) = tf_face_w(face) {
1692                                let vertex_colors = [mesh.color, mesh.color, mesh.color];
1693                                emit(DrawPrimitive::TexturedGouraudTriangleWithDepth {
1694                                    points: [points[0].xy(), points[1].xy(), points[2].xy()],
1695                                    depths: [
1696                                        points[0].z as f32,
1697                                        points[1].z as f32,
1698                                        points[2].z as f32,
1699                                    ],
1700                                    ws,
1701                                    uvs: [
1702                                        geometry.uvs[face[0]],
1703                                        geometry.uvs[face[1]],
1704                                        geometry.uvs[face[2]],
1705                                    ],
1706                                    colors: vertex_colors,
1707                                    texture_id,
1708                                });
1709                            }
1710                        }
1711                    } else {
1712                        for (face, face_normal) in
1713                            geometry.faces.iter().zip(geometry.normals.iter())
1714                        {
1715                            let fn_vec =
1716                                Vector3::new(face_normal[0], face_normal[1], face_normal[2]);
1717                            let transformed_fn = mesh.model_matrix.transform_vector(&fn_vec);
1718
1719                            if self.is_backface(
1720                                face,
1721                                geometry.vertices,
1722                                mesh.model_matrix,
1723                                &transformed_fn,
1724                            ) {
1725                                continue;
1726                            }
1727
1728                            if let Some((points, ws)) = tf_face_w(face) {
1729                                let vertex_colors: [Rgb565; 3] = core::array::from_fn(|k| {
1730                                    let vn = if !geometry.vertex_normals.is_empty() {
1731                                        let vn_arr = geometry.vertex_normals[face[k]];
1732                                        let vn_vec = Vector3::new(vn_arr[0], vn_arr[1], vn_arr[2]);
1733                                        mesh.model_matrix.transform_vector(&vn_vec)
1734                                    } else {
1735                                        transformed_fn
1736                                    };
1737
1738                                    let intensity = vn.dot(&adjusted_dir).max(0.0);
1739                                    let c = color_as_float * intensity + ambient_color;
1740                                    let mut vc = Rgb565::new(
1741                                        (c.x.clamp(0.0, 1.0) * 31.0) as u8,
1742                                        (c.y.clamp(0.0, 1.0) * 63.0) as u8,
1743                                        (c.z.clamp(0.0, 1.0) * 31.0) as u8,
1744                                    );
1745                                    if !self.point_lights.is_empty() {
1746                                        let vpos = geometry.vertices[face[k]];
1747                                        let wp = mesh.model_matrix.transform_point(&Point3::new(
1748                                            vpos[0], vpos[1], vpos[2],
1749                                        ));
1750                                        vc = Self::add_tint(vc, self.light_tint_at(wp));
1751                                    }
1752                                    vc
1753                                });
1754
1755                                emit(DrawPrimitive::TexturedGouraudTriangleWithDepth {
1756                                    points: [points[0].xy(), points[1].xy(), points[2].xy()],
1757                                    depths: [
1758                                        points[0].z as f32,
1759                                        points[1].z as f32,
1760                                        points[2].z as f32,
1761                                    ],
1762                                    ws,
1763                                    uvs: [
1764                                        geometry.uvs[face[0]],
1765                                        geometry.uvs[face[1]],
1766                                        geometry.uvs[face[2]],
1767                                    ],
1768                                    colors: vertex_colors,
1769                                    texture_id,
1770                                });
1771                            }
1772                        }
1773                    }
1774                }
1775                RenderMode::MatCap => {
1776                    let Some(texture_id) = geometry.texture_id else {
1777                        continue;
1778                    };
1779                    let has_vertex_normals = !geometry.vertex_normals.is_empty();
1780                    let has_face_normals = !geometry.normals.is_empty();
1781                    if !has_vertex_normals && !has_face_normals {
1782                        continue;
1783                    }
1784
1785                    let mv = self.camera.view_matrix * mesh.model_matrix;
1786                    let normal_matrix = nalgebra::Matrix3::new(
1787                        mv[(0, 0)],
1788                        mv[(0, 1)],
1789                        mv[(0, 2)],
1790                        mv[(1, 0)],
1791                        mv[(1, 1)],
1792                        mv[(1, 2)],
1793                        mv[(2, 0)],
1794                        mv[(2, 1)],
1795                        mv[(2, 2)],
1796                    );
1797
1798                    for (face_idx, face) in geometry.faces.iter().enumerate() {
1799                        let face_normal = if has_face_normals {
1800                            Vector3::new(
1801                                geometry.normals[face_idx][0],
1802                                geometry.normals[face_idx][1],
1803                                geometry.normals[face_idx][2],
1804                            )
1805                        } else {
1806                            let v0 = Vector3::new(
1807                                geometry.vertices[face[0]][0],
1808                                geometry.vertices[face[0]][1],
1809                                geometry.vertices[face[0]][2],
1810                            );
1811                            let v1 = Vector3::new(
1812                                geometry.vertices[face[1]][0],
1813                                geometry.vertices[face[1]][1],
1814                                geometry.vertices[face[1]][2],
1815                            );
1816                            let v2 = Vector3::new(
1817                                geometry.vertices[face[2]][0],
1818                                geometry.vertices[face[2]][1],
1819                                geometry.vertices[face[2]][2],
1820                            );
1821                            (v1 - v0).cross(&(v2 - v0)).normalize()
1822                        };
1823
1824                        let transformed_normal = mesh.model_matrix.transform_vector(&face_normal);
1825                        if self.is_backface(
1826                            face,
1827                            geometry.vertices,
1828                            mesh.model_matrix,
1829                            &transformed_normal,
1830                        ) {
1831                            continue;
1832                        }
1833
1834                        if let Some((points, ws)) = tf_face_w(face) {
1835                            let mut uvs = [[0.0f32; 2]; 3];
1836                            for i in 0..3 {
1837                                let vertex_normal = if has_vertex_normals {
1838                                    Vector3::new(
1839                                        geometry.vertex_normals[face[i]][0],
1840                                        geometry.vertex_normals[face[i]][1],
1841                                        geometry.vertex_normals[face[i]][2],
1842                                    )
1843                                } else {
1844                                    face_normal
1845                                };
1846                                let view_normal = (normal_matrix * vertex_normal).normalize();
1847                                let u = view_normal.x * 0.5 + 0.5;
1848                                let v = -view_normal.y * 0.5 + 0.5;
1849                                uvs[i] = [u, v];
1850                            }
1851
1852                            emit(DrawPrimitive::TexturedTriangleWithDepth {
1853                                points: [points[0].xy(), points[1].xy(), points[2].xy()],
1854                                depths: [
1855                                    points[0].z as f32,
1856                                    points[1].z as f32,
1857                                    points[2].z as f32,
1858                                ],
1859                                ws,
1860                                uvs,
1861                                texture_id,
1862                            });
1863                        }
1864                    }
1865                }
1866            }
1867        }
1868    }
1869
1870    pub fn record<'a, MS, const MAX: usize>(
1871        &self,
1872        meshes: MS,
1873        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
1874        telemetry: Option<&mut crate::telemetry::RecordTelemetry>,
1875    ) -> Result<(), crate::error::RenderError>
1876    where
1877        MS: IntoIterator<Item = &'a K3dMesh<'a>>,
1878    {
1879        self.record_impl(meshes, commands, telemetry)
1880    }
1881
1882    /// Record a projected drop shadow decal for a mesh grounded to a specific floor height.
1883    /// Uses an 8-sided flat polygon projected via camera view-projection matrix to form 8 translucent triangles.
1884    pub fn record_drop_shadow<const MAX: usize>(
1885        &self,
1886        mesh: &K3dMesh,
1887        floor_y: f32,
1888        shadow_radius: f32,
1889        max_fade_distance: f32,
1890        shadow_opacity: u8,
1891        color: Rgb565,
1892        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
1893    ) -> Result<(), crate::error::RenderError> {
1894        let pos = mesh.get_position();
1895        let height = pos.y - floor_y;
1896        if height < 0.0 || height >= max_fade_distance {
1897            return Ok(());
1898        }
1899
1900        let fade = 1.0 - (height / max_fade_distance).clamp(0.0, 1.0);
1901        let radius = shadow_radius * fade;
1902        let opacity = (shadow_opacity as f32 * fade) as u8;
1903        if opacity == 0 {
1904            return Ok(());
1905        }
1906
1907        let y_pos = floor_y + 0.01;
1908        let center_world = [pos.x, y_pos, pos.z];
1909        let center_proj = self.transform_point_with_w(&center_world, self.camera.vp_matrix);
1910        let Some((c_pt, _c_w)) = center_proj else {
1911            return Ok(());
1912        };
1913
1914        let mut outer_proj: [Option<(Point3<i32>, f32)>; 8] = [None; 8];
1915        for i in 0..8 {
1916            let angle = (i as f32) * (core::f32::consts::PI / 4.0);
1917            let px = pos.x + radius * micromath::F32Ext::cos(angle);
1918            let pz = pos.z + radius * micromath::F32Ext::sin(angle);
1919            outer_proj[i] = self.transform_point_with_w(&[px, y_pos, pz], self.camera.vp_matrix);
1920        }
1921
1922        for i in 0..8 {
1923            let next_idx = (i + 1) % 8;
1924            if let (Some((p1, _w1)), Some((p2, _w2))) = (outer_proj[i], outer_proj[next_idx]) {
1925                commands.push(crate::command_buffer::RenderCommand::Draw(
1926                    DrawPrimitive::TranslucentTriangleWithDepth {
1927                        points: [c_pt.xy(), p1.xy(), p2.xy()],
1928                        depths: [c_pt.z as f32, p1.z as f32, p2.z as f32],
1929                        color,
1930                        alpha: opacity,
1931                    },
1932                ))?;
1933            }
1934        }
1935
1936        Ok(())
1937    }
1938
1939    fn record_impl<'a, MS, const MAX: usize>(
1940        &self,
1941        meshes: MS,
1942        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
1943        telemetry: Option<&mut crate::telemetry::RecordTelemetry>,
1944    ) -> Result<(), crate::error::RenderError>
1945    where
1946        MS: IntoIterator<Item = &'a K3dMesh<'a>>,
1947    {
1948        use crate::command_buffer::RenderCommand;
1949
1950        commands.clear();
1951        commands.push(RenderCommand::ClearDepth(crate::Z_MAX_VALUE))?;
1952        if let Some(caps) = self.caps {
1953            caps.validate_framebuffer(self.width as usize, self.height as usize)?;
1954        }
1955
1956        let mut first_error = None;
1957        let mut visible_meshes = 0usize;
1958        let mut used_texture_ids: heapless::Vec<u32, 64> = heapless::Vec::new();
1959        let mut meshes_total = 0usize;
1960
1961        #[cfg(feature = "record-sort")]
1962        {
1963            let mut sorted: heapless::Vec<(u8, i32, &K3dMesh<'a>), 256> = heapless::Vec::new();
1964            for mesh in meshes {
1965                meshes_total += 1;
1966                if mesh.geometry.vertices.is_empty() {
1967                    continue;
1968                }
1969                if self.should_cull_mesh(mesh) {
1970                    continue;
1971                }
1972                let distance = (mesh.get_position() - self.camera.position).norm();
1973                let dist_key = (distance * 1000.0) as i32;
1974                if sorted.push((mesh.priority, dist_key, mesh)).is_err() {
1975                    break;
1976                }
1977            }
1978            sorted.sort_unstable_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
1979
1980            for &(_, _, mesh) in sorted.iter() {
1981                Self::record_one_mesh(
1982                    self,
1983                    mesh,
1984                    commands,
1985                    &mut first_error,
1986                    &mut visible_meshes,
1987                    &mut used_texture_ids,
1988                )?;
1989                if let Some(err) = first_error.take() {
1990                    return Err(err);
1991                }
1992            }
1993        }
1994
1995        #[cfg(not(feature = "record-sort"))]
1996        {
1997            for mesh in meshes {
1998                meshes_total += 1;
1999                if mesh.geometry.vertices.is_empty() {
2000                    continue;
2001                }
2002                if self.should_cull_mesh(mesh) {
2003                    continue;
2004                }
2005                Self::record_one_mesh(
2006                    self,
2007                    mesh,
2008                    commands,
2009                    &mut first_error,
2010                    &mut visible_meshes,
2011                    &mut used_texture_ids,
2012                )?;
2013                if let Some(err) = first_error.take() {
2014                    return Err(err);
2015                }
2016            }
2017        }
2018
2019        if let Some(t) = telemetry {
2020            t.meshes_total = meshes_total;
2021            t.meshes_visible = visible_meshes;
2022            t.unique_textures = used_texture_ids.len();
2023            t.draw_commands = commands
2024                .iter()
2025                .filter(|cmd| matches!(cmd, RenderCommand::Draw(_)))
2026                .count();
2027            t.fallback_used = false;
2028            t.degradation_steps_applied = 0;
2029            t.dropped_meshes = 0;
2030        }
2031
2032        Ok(())
2033    }
2034
2035    fn record_one_mesh<'a, const MAX: usize>(
2036        &self,
2037        mesh: &'a K3dMesh<'a>,
2038        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
2039        first_error: &mut Option<crate::error::RenderError>,
2040        visible_meshes: &mut usize,
2041        used_texture_ids: &mut heapless::Vec<u32, 64>,
2042    ) -> Result<(), crate::error::RenderError> {
2043        use crate::command_buffer::RenderCommand;
2044        use crate::error::{BudgetKind, RenderError};
2045
2046        let distance = (mesh.get_position() - self.camera.position).norm();
2047        let geometry = mesh.select_lod(distance);
2048
2049        if let Some(caps) = self.caps {
2050            *visible_meshes += 1;
2051            if *visible_meshes > caps.max_meshes_per_frame {
2052                return Err(RenderError::OutOfBudget(BudgetKind::MeshesPerFrame {
2053                    attempted: *visible_meshes,
2054                    max: caps.max_meshes_per_frame,
2055                }));
2056            }
2057
2058            if geometry.vertices.len() > caps.max_vertices_per_mesh {
2059                return Err(RenderError::OutOfBudget(BudgetKind::VerticesPerMesh {
2060                    attempted: geometry.vertices.len(),
2061                    max: caps.max_vertices_per_mesh,
2062                }));
2063            }
2064
2065            if geometry.faces.len() > caps.max_triangles_per_mesh {
2066                return Err(RenderError::OutOfBudget(BudgetKind::TrianglesPerMesh {
2067                    attempted: geometry.faces.len(),
2068                    max: caps.max_triangles_per_mesh,
2069                }));
2070            }
2071
2072            if let Some(texture_id) = geometry.texture_id
2073                && !used_texture_ids.contains(&texture_id)
2074            {
2075                let attempted = used_texture_ids.len() + 1;
2076                if attempted > caps.max_textures {
2077                    return Err(RenderError::OutOfBudget(BudgetKind::Textures {
2078                        attempted,
2079                        max: caps.max_textures,
2080                    }));
2081                }
2082
2083                if used_texture_ids.push(texture_id).is_err() {
2084                    return Err(RenderError::OutOfBudget(BudgetKind::Textures {
2085                        attempted,
2086                        max: caps.max_textures,
2087                    }));
2088                }
2089            }
2090        } else {
2091            *visible_meshes += 1;
2092        }
2093
2094        let mut push_draw = |primitive: DrawPrimitive, first_error: &mut Option<RenderError>| {
2095            if first_error.is_none()
2096                && let Err(e) = commands.push(RenderCommand::Draw(primitive))
2097            {
2098                *first_error = Some(e);
2099            }
2100        };
2101
2102        #[cfg(feature = "lod-crossfade")]
2103        {
2104            match mesh.select_lod_pick(distance) {
2105                mesh::LodPick::Single(_) => {
2106                    mesh.lod_force.set(None);
2107                    mesh.draw_alpha.set(None);
2108                    self.render(core::iter::once(mesh), |primitive| {
2109                        push_draw(primitive, first_error);
2110                    });
2111                }
2112                mesh::LodPick::Crossfade { near, far, t } => {
2113                    let near_lvl = mesh.lod_level_of(near);
2114                    let far_lvl = mesh.lod_level_of(far);
2115                    if t < 0.5 {
2116                        mesh.lod_force.set(Some(near_lvl));
2117                        mesh.draw_alpha.set(None);
2118                        self.render(core::iter::once(mesh), |primitive| {
2119                            push_draw(primitive, first_error);
2120                        });
2121                        let a = (t * 255.0) as u8;
2122                        if a > 16 {
2123                            mesh.lod_force.set(Some(far_lvl));
2124                            mesh.draw_alpha.set(Some(a));
2125                            self.render(core::iter::once(mesh), |primitive| {
2126                                push_draw(primitive, first_error);
2127                            });
2128                        }
2129                    } else {
2130                        mesh.lod_force.set(Some(far_lvl));
2131                        mesh.draw_alpha.set(None);
2132                        self.render(core::iter::once(mesh), |primitive| {
2133                            push_draw(primitive, first_error);
2134                        });
2135                        let a = ((1.0 - t) * 255.0) as u8;
2136                        if a > 16 {
2137                            mesh.lod_force.set(Some(near_lvl));
2138                            mesh.draw_alpha.set(Some(a));
2139                            self.render(core::iter::once(mesh), |primitive| {
2140                                push_draw(primitive, first_error);
2141                            });
2142                        }
2143                    }
2144                    mesh.lod_force.set(None);
2145                    mesh.draw_alpha.set(None);
2146                }
2147            }
2148        }
2149        #[cfg(not(feature = "lod-crossfade"))]
2150        {
2151            self.render(core::iter::once(mesh), |primitive| {
2152                push_draw(primitive, first_error);
2153            });
2154        }
2155        Ok(())
2156    }
2157
2158    pub fn record_with_fallback<'a, MS, FS, const MAX: usize>(
2159        &self,
2160        primary: MS,
2161        fallback: FS,
2162        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
2163        telemetry: Option<&mut crate::telemetry::RecordTelemetry>,
2164    ) -> Result<BudgetFallbackOutcome, crate::error::RenderError>
2165    where
2166        MS: IntoIterator<Item = &'a K3dMesh<'a>>,
2167        FS: IntoIterator<Item = &'a K3dMesh<'a>>,
2168    {
2169        use crate::error::RenderError;
2170
2171        let mut local_telemetry = crate::telemetry::RecordTelemetry::default();
2172        match self.record_impl(primary, commands, Some(&mut local_telemetry)) {
2173            Ok(()) => {
2174                if let Some(t) = telemetry {
2175                    *t = local_telemetry;
2176                    t.fallback_used = false;
2177                }
2178                Ok(BudgetFallbackOutcome {
2179                    used_fallback: false,
2180                    primary_budget_error: None,
2181                })
2182            }
2183            Err(RenderError::OutOfBudget(kind)) => {
2184                let mut fallback_telemetry = crate::telemetry::RecordTelemetry::default();
2185                self.record_impl(fallback, commands, Some(&mut fallback_telemetry))?;
2186                if let Some(t) = telemetry {
2187                    *t = fallback_telemetry;
2188                    t.fallback_used = true;
2189                }
2190                Ok(BudgetFallbackOutcome {
2191                    used_fallback: true,
2192                    primary_budget_error: Some(kind),
2193                })
2194            }
2195            Err(e) => Err(e),
2196        }
2197    }
2198
2199    fn downgraded_quality_tier(tier: crate::config::QualityTier) -> crate::config::QualityTier {
2200        use crate::config::QualityTier;
2201        match tier {
2202            QualityTier::Quality => QualityTier::Balanced,
2203            QualityTier::Balanced => QualityTier::Fastest,
2204            QualityTier::Fastest => QualityTier::Fastest,
2205        }
2206    }
2207
2208    pub fn record_with_degradation<'a, const MAX: usize>(
2209        &mut self,
2210        meshes: &[&'a K3dMesh<'a>],
2211        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
2212        policy: crate::config::DegradationPolicy<'_>,
2213        telemetry: Option<&mut crate::telemetry::RecordTelemetry>,
2214    ) -> Result<DegradationOutcome, crate::error::RenderError> {
2215        use crate::config::DegradationStep;
2216        use crate::error::RenderError;
2217
2218        let original_quality = self.quality_tier;
2219        let mut active_quality = self.quality_tier;
2220
2221        let mut outcome = DegradationOutcome {
2222            used_degradation: false,
2223            steps_applied: 0,
2224            dropped_meshes: 0,
2225            final_quality_tier: active_quality,
2226            primary_budget_error: None,
2227        };
2228
2229        let mut local_telemetry = crate::telemetry::RecordTelemetry::default();
2230        match self.record_impl(meshes.iter().copied(), commands, Some(&mut local_telemetry)) {
2231            Ok(()) => {
2232                if let Some(t) = telemetry {
2233                    *t = local_telemetry;
2234                }
2235                return Ok(outcome);
2236            }
2237            Err(RenderError::OutOfBudget(kind)) => {
2238                outcome.primary_budget_error = Some(kind);
2239            }
2240            Err(e) => return Err(e),
2241        }
2242
2243        for step in policy.steps {
2244            outcome.used_degradation = true;
2245            outcome.steps_applied += 1;
2246
2247            let mut selected: heapless::Vec<&K3dMesh<'_>, 512> = heapless::Vec::new();
2248            match *step {
2249                DegradationStep::RaisePriorityFloor(min_priority) => {
2250                    for mesh in meshes {
2251                        if mesh.priority >= min_priority {
2252                            let _ = selected.push(*mesh);
2253                        } else {
2254                            outcome.dropped_meshes += 1;
2255                        }
2256                    }
2257                }
2258                DegradationStep::MeshDecimationStride(stride) => {
2259                    if stride == 0 {
2260                        self.quality_tier = original_quality;
2261                        return Err(RenderError::InvalidInput(
2262                            "mesh decimation stride must be >= 1",
2263                        ));
2264                    }
2265                    for (idx, mesh) in meshes.iter().enumerate() {
2266                        if idx % stride == 0 {
2267                            let _ = selected.push(*mesh);
2268                        } else {
2269                            outcome.dropped_meshes += 1;
2270                        }
2271                    }
2272                }
2273                DegradationStep::DowngradeQuality => {
2274                    active_quality = Self::downgraded_quality_tier(active_quality);
2275                    self.quality_tier = active_quality;
2276                    for mesh in meshes {
2277                        let _ = selected.push(*mesh);
2278                    }
2279                }
2280            }
2281
2282            if selected.is_empty() {
2283                continue;
2284            }
2285
2286            let mut step_telemetry = crate::telemetry::RecordTelemetry::default();
2287            let attempt = self.record_impl(
2288                selected.iter().copied(),
2289                commands,
2290                Some(&mut step_telemetry),
2291            );
2292
2293            if let Ok(()) = attempt {
2294                outcome.final_quality_tier = self.quality_tier;
2295                if let Some(t) = telemetry {
2296                    *t = step_telemetry;
2297                    t.fallback_used = true;
2298                    t.degradation_steps_applied = outcome.steps_applied;
2299                    t.dropped_meshes = outcome.dropped_meshes;
2300                }
2301                self.quality_tier = original_quality;
2302                return Ok(outcome);
2303            }
2304        }
2305
2306        self.quality_tier = original_quality;
2307        Err(crate::error::RenderError::Recoverable {
2308            fault: crate::error::RuntimeFaultKind::Budget(outcome.primary_budget_error.unwrap_or(
2309                crate::error::BudgetKind::DrawPrimitives {
2310                    attempted: commands.len(),
2311                    max: MAX,
2312                },
2313            )),
2314            action: crate::error::RecoveryAction::SkipFrame,
2315        })
2316    }
2317
2318    pub fn execute<D, const MAX: usize>(
2319        &self,
2320        fb: &mut D,
2321        frame: &mut crate::renderer::FrameCtx<'_>,
2322        commands: &crate::command_buffer::CommandBuffer<MAX>,
2323        telemetry: Option<&mut crate::telemetry::ExecuteTelemetry>,
2324    ) -> Result<Option<crate::renderer::DirtyRegion>, crate::error::RenderError>
2325    where
2326        D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>
2327            + embedded_graphics_core::prelude::OriginDimensions,
2328        <D as embedded_graphics_core::draw_target::DrawTarget>::Error: core::fmt::Debug,
2329    {
2330        if let Some(t) = telemetry {
2331            t.commands_total = commands.len();
2332            t.draw_commands = commands
2333                .iter()
2334                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::Draw(_)))
2335                .count();
2336            t.clear_color_commands = commands
2337                .iter()
2338                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::ClearColor(_)))
2339                .count();
2340            t.clear_depth_commands = commands
2341                .iter()
2342                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::ClearDepth(_)))
2343                .count();
2344        }
2345        let camera_dir = self.camera.get_direction();
2346        crate::renderer::execute_commands_with_dirty_region_effects(
2347            fb,
2348            frame,
2349            commands,
2350            self.fog.as_ref(),
2351            self.dither.as_ref(),
2352            self.screen_tint,
2353            self.stipple_mode,
2354            self.palette_mode,
2355            self.sky,
2356            [camera_dir.x, camera_dir.y, camera_dir.z],
2357        )
2358    }
2359
2360    /// Like [`Self::execute`], but resolves [`RenderMode::Textured`] meshes'
2361    /// `DrawPrimitive::TexturedTriangleWithDepth`/`LightmappedTriangle`
2362    /// primitives via `texture_manager` instead of silently dropping them.
2363    ///
2364    /// `record()` doesn't need a texture manager (it only transforms
2365    /// geometry into primitives, it doesn't sample pixels), so a scene
2366    /// mixing textured and flat-colored/lit meshes still goes through one
2367    /// `record()` call -- only `execute()` needs to change to
2368    /// `execute_with_textures()` once any mesh in the batch uses
2369    /// `RenderMode::Textured`.
2370    pub fn execute_with_textures<D, const MAX: usize, const N: usize>(
2371        &self,
2372        fb: &mut D,
2373        frame: &mut crate::renderer::FrameCtx<'_>,
2374        commands: &crate::command_buffer::CommandBuffer<MAX>,
2375        texture_manager: &crate::texture::TextureManager<N>,
2376        telemetry: Option<&mut crate::telemetry::ExecuteTelemetry>,
2377    ) -> Result<Option<crate::renderer::DirtyRegion>, crate::error::RenderError>
2378    where
2379        D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>
2380            + embedded_graphics_core::prelude::OriginDimensions,
2381        <D as embedded_graphics_core::draw_target::DrawTarget>::Error: core::fmt::Debug,
2382    {
2383        if let Some(t) = telemetry {
2384            t.commands_total = commands.len();
2385            t.draw_commands = commands
2386                .iter()
2387                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::Draw(_)))
2388                .count();
2389            t.clear_color_commands = commands
2390                .iter()
2391                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::ClearColor(_)))
2392                .count();
2393            t.clear_depth_commands = commands
2394                .iter()
2395                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::ClearDepth(_)))
2396                .count();
2397        }
2398        let camera_dir = self.camera.get_direction();
2399        crate::renderer::execute_commands_with_dirty_region_effects_textured(
2400            fb,
2401            frame,
2402            commands,
2403            texture_manager,
2404            self.fog.as_ref(),
2405            self.dither.as_ref(),
2406            self.screen_tint,
2407            self.stipple_mode,
2408            self.palette_mode,
2409            self.sky,
2410            [camera_dir.x, camera_dir.y, camera_dir.z],
2411        )
2412    }
2413
2414    pub fn execute_tiled<D, const MAX: usize, const BIN_CAP: usize>(
2415        &self,
2416        fb: &mut D,
2417        frame: &mut crate::renderer::FrameCtx<'_>,
2418        commands: &crate::command_buffer::CommandBuffer<MAX>,
2419        tile: crate::tilebin::TileConfig,
2420    ) -> Result<crate::tilebin::TileBinStats, crate::error::RenderError>
2421    where
2422        D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>
2423            + embedded_graphics_core::prelude::OriginDimensions,
2424        <D as embedded_graphics_core::draw_target::DrawTarget>::Error: core::fmt::Debug,
2425    {
2426        let camera_dir = self.camera.get_direction();
2427        crate::renderer::execute_commands_tiled_effects::<D, MAX, BIN_CAP>(
2428            fb,
2429            frame,
2430            commands,
2431            tile,
2432            self.fog.as_ref(),
2433            self.dither.as_ref(),
2434            self.screen_tint,
2435            self.stipple_mode,
2436            self.palette_mode,
2437            self.sky,
2438            [camera_dir.x, camera_dir.y, camera_dir.z],
2439        )
2440    }
2441
2442    /// Emit a model-space AABB wireframe into a command buffer (debug).
2443    #[cfg(feature = "gizmos")]
2444    pub fn record_aabb_gizmo<const MAX: usize>(
2445        &self,
2446        aabb: &crate::bounds::Aabb,
2447        model_matrix: &Matrix4<f32>,
2448        color: Rgb565,
2449        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
2450    ) -> Result<(), crate::error::RenderError> {
2451        let mut err = None;
2452        crate::gizmos::emit_aabb_wireframe_projected(
2453            aabb,
2454            model_matrix,
2455            |p| self.transform_point(&p, self.camera.vp_matrix),
2456            color,
2457            |prim| {
2458                if err.is_none()
2459                    && let Err(e) = commands.push(crate::command_buffer::RenderCommand::Draw(prim))
2460                {
2461                    err = Some(e);
2462                }
2463            },
2464        );
2465        match err {
2466            Some(e) => Err(e),
2467            None => Ok(()),
2468        }
2469    }
2470
2471    /// Emit the camera frustum wireframe into a command buffer (debug).
2472    #[cfg(feature = "gizmos")]
2473    pub fn record_frustum_gizmo<const MAX: usize>(
2474        &self,
2475        color: Rgb565,
2476        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
2477    ) -> Result<(), crate::error::RenderError> {
2478        let mut err = None;
2479        crate::gizmos::emit_frustum_wireframe(
2480            &self.camera,
2481            |p| self.transform_point(&p, self.camera.vp_matrix),
2482            color,
2483            |prim| {
2484                if err.is_none()
2485                    && let Err(e) = commands.push(crate::command_buffer::RenderCommand::Draw(prim))
2486                {
2487                    err = Some(e);
2488                }
2489            },
2490        );
2491        match err {
2492            Some(e) => Err(e),
2493            None => Ok(()),
2494        }
2495    }
2496}
2497
2498/// Result of a ray cast against triangle geometry.
2499#[derive(Debug, Clone, Copy)]
2500pub struct MeshRayCastHit {
2501    /// Distance along the ray to the hit point
2502    pub distance: f32,
2503    /// Hit point in world space
2504    pub point: Vector3<f32>,
2505    /// Face normal (from cross product of edges, not per-vertex normals)
2506    pub normal: Vector3<f32>,
2507    /// Index of the triangle face that was hit
2508    pub face_index: usize,
2509    /// Barycentric-interpolated UV at the hit point (or [0.0, 0.0] if no UVs present)
2510    pub uv: [f32; 2],
2511}
2512
2513/// Convert opaque depth triangles to translucent for LOD crossfade.
2514#[cfg(feature = "lod-crossfade")]
2515fn apply_draw_alpha(prim: DrawPrimitive, alpha: u8) -> DrawPrimitive {
2516    match prim {
2517        DrawPrimitive::ColoredTriangleWithDepth {
2518            points,
2519            depths,
2520            color,
2521        } => DrawPrimitive::TranslucentTriangleWithDepth {
2522            points,
2523            depths,
2524            color,
2525            alpha,
2526        },
2527        DrawPrimitive::TranslucentTriangleWithDepth {
2528            points,
2529            depths,
2530            color,
2531            alpha: prev,
2532        } => DrawPrimitive::TranslucentTriangleWithDepth {
2533            points,
2534            depths,
2535            color,
2536            alpha: ((prev as u16 * alpha as u16) / 255) as u8,
2537        },
2538        other => other,
2539    }
2540}
2541
2542/// Ray-cast against triangle geometry using Möller–Trumbore intersection.
2543///
2544/// `ray_origin` and `ray_dir` are in world space. `model_matrix` transforms mesh
2545/// vertices to world space. Returns the closest hit within `max_distance`, or `None`.
2546pub fn mesh_ray_cast(
2547    ray_origin: Vector3<f32>,
2548    ray_dir: Vector3<f32>,
2549    geometry: &mesh::Geometry<'_>,
2550    model_matrix: &Matrix4<f32>,
2551    max_distance: f32,
2552) -> Option<MeshRayCastHit> {
2553    #[cfg(feature = "aabb-cull")]
2554    {
2555        return mesh_ray_cast_bounded(
2556            ray_origin,
2557            ray_dir,
2558            geometry,
2559            model_matrix,
2560            max_distance,
2561            None,
2562        );
2563    }
2564    #[cfg(not(feature = "aabb-cull"))]
2565    {
2566        mesh_ray_cast_world(ray_origin, ray_dir, geometry, model_matrix, max_distance)
2567    }
2568}
2569
2570#[cfg(not(feature = "aabb-cull"))]
2571fn mesh_ray_cast_world(
2572    ray_origin: Vector3<f32>,
2573    ray_dir: Vector3<f32>,
2574    geometry: &mesh::Geometry<'_>,
2575    model_matrix: &Matrix4<f32>,
2576    max_distance: f32,
2577) -> Option<MeshRayCastHit> {
2578    let mut nearest: Option<MeshRayCastHit> = None;
2579    let mut min_dist = max_distance;
2580
2581    for (face_index, face) in geometry.faces.iter().enumerate() {
2582        let raw_v0 = geometry.vertices[face[0]];
2583        let raw_v1 = geometry.vertices[face[1]];
2584        let raw_v2 = geometry.vertices[face[2]];
2585
2586        let v0 = model_matrix
2587            .transform_point(&Point3::new(raw_v0[0], raw_v0[1], raw_v0[2]))
2588            .coords;
2589        let v1 = model_matrix
2590            .transform_point(&Point3::new(raw_v1[0], raw_v1[1], raw_v1[2]))
2591            .coords;
2592        let v2 = model_matrix
2593            .transform_point(&Point3::new(raw_v2[0], raw_v2[1], raw_v2[2]))
2594            .coords;
2595
2596        let edge1 = v1 - v0;
2597        let edge2 = v2 - v0;
2598        let h = ray_dir.cross(&edge2);
2599        let det = edge1.dot(&h);
2600        if det.abs() < 1e-6 {
2601            continue;
2602        }
2603        let inv_det = 1.0 / det;
2604        let s = ray_origin - v0;
2605        let bary_u = inv_det * s.dot(&h);
2606        if !(0.0..=1.0).contains(&bary_u) {
2607            continue;
2608        }
2609        let q = s.cross(&edge1);
2610        let bary_v = inv_det * ray_dir.dot(&q);
2611        if bary_v < 0.0 || bary_u + bary_v > 1.0 {
2612            continue;
2613        }
2614        let t = inv_det * edge2.dot(&q);
2615        if t <= 0.0 || t >= min_dist {
2616            continue;
2617        }
2618        let normal = edge1.cross(&edge2).normalize();
2619        let bary_w = 1.0 - bary_u - bary_v;
2620        let uv = if geometry.uvs.len() > face[0]
2621            && geometry.uvs.len() > face[1]
2622            && geometry.uvs.len() > face[2]
2623        {
2624            let uv0 = geometry.uvs[face[0]];
2625            let uv1 = geometry.uvs[face[1]];
2626            let uv2 = geometry.uvs[face[2]];
2627            [
2628                bary_w * uv0[0] + bary_u * uv1[0] + bary_v * uv2[0],
2629                bary_w * uv0[1] + bary_u * uv1[1] + bary_v * uv2[1],
2630            ]
2631        } else {
2632            [0.0, 0.0]
2633        };
2634        let point = ray_origin + ray_dir * t;
2635        min_dist = t;
2636        nearest = Some(MeshRayCastHit {
2637            distance: t,
2638            point,
2639            normal,
2640            face_index,
2641            uv,
2642        });
2643    }
2644    nearest
2645}
2646
2647/// Like [`mesh_ray_cast`], with an optional model-space AABB broadphase.
2648/// Requires the `aabb-cull` feature.
2649#[cfg(feature = "aabb-cull")]
2650pub fn mesh_ray_cast_bounded(
2651    ray_origin: Vector3<f32>,
2652    ray_dir: Vector3<f32>,
2653    geometry: &mesh::Geometry<'_>,
2654    model_matrix: &Matrix4<f32>,
2655    max_distance: f32,
2656    model_aabb: Option<&Aabb>,
2657) -> Option<MeshRayCastHit> {
2658    let inv = model_matrix.try_inverse()?;
2659    let origin4 = inv * Vector4::new(ray_origin.x, ray_origin.y, ray_origin.z, 1.0);
2660    let dir4 = inv * Vector4::new(ray_dir.x, ray_dir.y, ray_dir.z, 0.0);
2661    if origin4.w.abs() < 1e-8 {
2662        return None;
2663    }
2664    let local_origin = Vector3::new(origin4.x, origin4.y, origin4.z) / origin4.w;
2665    let local_dir = Vector3::new(dir4.x, dir4.y, dir4.z);
2666    let dir_len = local_dir.norm();
2667    if dir_len < 1e-8 {
2668        return None;
2669    }
2670    let local_dir_n = local_dir / dir_len;
2671    let local_max = max_distance * dir_len;
2672
2673    if let Some(aabb) = model_aabb
2674        && aabb
2675            .intersect_ray(local_origin, local_dir_n, local_max)
2676            .is_none()
2677    {
2678        return None;
2679    }
2680
2681    let mut nearest: Option<MeshRayCastHit> = None;
2682    let mut min_dist = local_max;
2683
2684    for (face_index, face) in geometry.faces.iter().enumerate() {
2685        let v0 = Vector3::new(
2686            geometry.vertices[face[0]][0],
2687            geometry.vertices[face[0]][1],
2688            geometry.vertices[face[0]][2],
2689        );
2690        let v1 = Vector3::new(
2691            geometry.vertices[face[1]][0],
2692            geometry.vertices[face[1]][1],
2693            geometry.vertices[face[1]][2],
2694        );
2695        let v2 = Vector3::new(
2696            geometry.vertices[face[2]][0],
2697            geometry.vertices[face[2]][1],
2698            geometry.vertices[face[2]][2],
2699        );
2700
2701        let edge1 = v1 - v0;
2702        let edge2 = v2 - v0;
2703        let h = local_dir_n.cross(&edge2);
2704        let det = edge1.dot(&h);
2705        if det.abs() < 1e-6 {
2706            continue;
2707        }
2708        let inv_det = 1.0 / det;
2709        let s = local_origin - v0;
2710        let bary_u = inv_det * s.dot(&h);
2711        if !(0.0..=1.0).contains(&bary_u) {
2712            continue;
2713        }
2714        let q = s.cross(&edge1);
2715        let bary_v = inv_det * local_dir_n.dot(&q);
2716        if bary_v < 0.0 || bary_u + bary_v > 1.0 {
2717            continue;
2718        }
2719        let t_local = inv_det * edge2.dot(&q);
2720        if t_local <= 0.0 || t_local >= min_dist {
2721            continue;
2722        }
2723
2724        let normal_local = edge1.cross(&edge2).normalize();
2725        let rot = model_matrix.fixed_view::<3, 3>(0, 0);
2726        let normal = (rot * normal_local).normalize();
2727
2728        let bary_w = 1.0 - bary_u - bary_v;
2729        let uv = if geometry.uvs.len() > face[0]
2730            && geometry.uvs.len() > face[1]
2731            && geometry.uvs.len() > face[2]
2732        {
2733            let uv0 = geometry.uvs[face[0]];
2734            let uv1 = geometry.uvs[face[1]];
2735            let uv2 = geometry.uvs[face[2]];
2736            [
2737                bary_w * uv0[0] + bary_u * uv1[0] + bary_v * uv2[0],
2738                bary_w * uv0[1] + bary_u * uv1[1] + bary_v * uv2[1],
2739            ]
2740        } else {
2741            [0.0, 0.0]
2742        };
2743
2744        let local_hit = local_origin + local_dir_n * t_local;
2745        let world_hit = model_matrix
2746            .transform_point(&Point3::from(local_hit))
2747            .coords;
2748        let world_t = (world_hit - ray_origin).norm();
2749        if world_t >= max_distance {
2750            continue;
2751        }
2752
2753        min_dist = t_local;
2754        nearest = Some(MeshRayCastHit {
2755            distance: world_t,
2756            point: world_hit,
2757            normal,
2758            face_index,
2759            uv,
2760        });
2761    }
2762
2763    nearest
2764}
2765
2766/// Convenience: ray-cast a [`K3dMesh`] using its model matrix and cached AABB.
2767#[cfg(feature = "aabb-cull")]
2768pub fn mesh_ray_cast_mesh(
2769    ray_origin: Vector3<f32>,
2770    ray_dir: Vector3<f32>,
2771    mesh: &K3dMesh<'_>,
2772    max_distance: f32,
2773) -> Option<MeshRayCastHit> {
2774    let distance = (mesh.get_position() - Point3::from(ray_origin)).norm();
2775    let geometry = mesh.select_lod(distance);
2776    let aabb = mesh.model_aabb();
2777    mesh_ray_cast_bounded(
2778        ray_origin,
2779        ray_dir,
2780        geometry,
2781        &mesh.model_matrix,
2782        max_distance,
2783        Some(&aabb),
2784    )
2785}
2786
2787#[cfg(feature = "dma2d")]
2788mod dma2d_stubs {
2789    #[cfg(feature = "depth-u16")]
2790    #[unsafe(no_mangle)]
2791    extern "Rust" fn dma2d_clear_zbuffer_u16(ptr: *mut u16, len: usize, value: u16) {
2792        let slice = unsafe { core::slice::from_raw_parts_mut(ptr, len) };
2793        slice.fill(value);
2794    }
2795
2796    #[cfg(not(feature = "depth-u16"))]
2797    #[unsafe(no_mangle)]
2798    extern "Rust" fn dma2d_clear_zbuffer_u32(ptr: *mut u32, len: usize, value: u32) {
2799        let slice = unsafe { core::slice::from_raw_parts_mut(ptr, len) };
2800        slice.fill(value);
2801    }
2802}
2803
2804#[cfg(test)]
2805mod tests {
2806    extern crate std;
2807
2808    #[cfg(feature = "depth-u16")]
2809    pub type ZDepth = u16;
2810    #[cfg(feature = "depth-u16")]
2811    pub const Z_MAX_VALUE: ZDepth = u16::MAX;
2812    #[cfg(feature = "depth-u16")]
2813    pub const DEPTH_EPSILON: ZDepth = 1;
2814
2815    #[cfg(not(feature = "depth-u16"))]
2816    pub type ZDepth = u32;
2817    #[cfg(not(feature = "depth-u16"))]
2818    pub const Z_MAX_VALUE: ZDepth = u32::MAX;
2819    #[cfg(not(feature = "depth-u16"))]
2820    pub const DEPTH_EPSILON: ZDepth = 128;
2821
2822    #[inline(always)]
2823    pub const fn to_zdepth(z: u32) -> ZDepth {
2824        #[cfg(feature = "depth-u16")]
2825        {
2826            (z >> 16) as u16
2827        }
2828        #[cfg(not(feature = "depth-u16"))]
2829        {
2830            z
2831        }
2832    }
2833
2834    use super::*;
2835
2836    #[test]
2837    fn test_engine_creation() {
2838        let engine = K3dengine::new(640, 480);
2839        assert_eq!(engine.width, 640);
2840        assert_eq!(engine.height, 480);
2841        assert!((engine.camera.get_aspect_ratio() - 640.0 / 480.0).abs() < 0.001);
2842    }
2843
2844    #[test]
2845    fn test_transform_point_basic() {
2846        let engine = K3dengine::new(640, 480);
2847        // Use camera's VP matrix directly
2848        let transform_matrix = engine.camera.vp_matrix;
2849
2850        // Point in front of default camera, within view frustum
2851        // Default camera is at origin looking at origin, so we need a point in front
2852        let point = [0.0, 0.0, -5.0];
2853        let result = engine.transform_point(&point, transform_matrix);
2854
2855        if let Some(transformed) = result {
2856            // Should be within screen bounds
2857            assert!(transformed.x >= 0 && transformed.x < 640);
2858            assert!(transformed.y >= 0 && transformed.y < 480);
2859        }
2860        // If None, the point was culled which is also valid behavior
2861    }
2862
2863    #[test]
2864    fn test_transform_point_clamps_out_of_bounds() {
2865        let engine = K3dengine::new(640, 480);
2866        let model_matrix = nalgebra::Matrix4::identity();
2867
2868        // Point way outside the viewport should be clamped/rejected
2869        let point = [100.0, 100.0, -5.0];
2870        let result = engine.transform_point(&point, model_matrix);
2871        // Should return None because coordinates are clamped out
2872        assert!(result.is_none());
2873    }
2874
2875    #[test]
2876    fn test_transform_point_behind_camera() {
2877        let engine = K3dengine::new(640, 480);
2878        let transform_matrix = engine.camera.vp_matrix;
2879
2880        // Point with positive z (behind default camera orientation)
2881        let point = [0.0, 0.0, 1.0];
2882        let _result = engine.transform_point(&point, transform_matrix);
2883        // Point behind camera or outside frustum should return None
2884        // (actual behavior depends on camera setup and projection)
2885        // This test just verifies the function doesn't panic
2886    }
2887
2888    #[test]
2889    fn test_transform_point_near_plane_clipping() {
2890        let engine = K3dengine::new(640, 480);
2891        // Use the camera's real projection, not a raw identity matrix: the
2892        // near/far check now tests clip-space W (view depth), which is
2893        // only meaningful under an actual perspective projection -- an
2894        // identity "model_matrix" leaves W pinned at the homogeneous 1.0
2895        // regardless of the point's z, so it can't exercise this check.
2896        let transform_matrix = engine.camera.vp_matrix;
2897
2898        // Point too close to camera: distance 0.1, before the near=0.4 plane.
2899        let point = [0.0, 0.0, -0.1];
2900        let result = engine.transform_point(&point, transform_matrix);
2901        assert!(result.is_none());
2902    }
2903
2904    #[test]
2905    fn test_transform_point_far_plane_clipping() {
2906        let engine = K3dengine::new(640, 480);
2907        let transform_matrix = engine.camera.vp_matrix;
2908
2909        // Point too far from camera: distance 1000, beyond the far=20 plane.
2910        let point = [0.0, 0.0, -1000.0];
2911        let result = engine.transform_point(&point, transform_matrix);
2912        assert!(result.is_none());
2913    }
2914
2915    #[test]
2916    fn test_transform_point_within_near_far_not_culled() {
2917        // Regression test: `transform_point`'s near/far check used to
2918        // compare pre-divide clip.z against camera.near/far, which for the
2919        // default near=0.4/far=20 camera silently culled anything closer
2920        // than roughly 1.17 units -- even though it's well within
2921        // [near, far]. z=-0.8 falls in that dead zone.
2922        let engine = K3dengine::new(640, 480);
2923        let transform_matrix = engine.camera.vp_matrix;
2924
2925        let point = [0.0, 0.0, -0.8];
2926        let result = engine.transform_point(&point, transform_matrix);
2927        assert!(
2928            result.is_some(),
2929            "a point at distance 0.8 (within [near=0.4, far=20]) should not be culled"
2930        );
2931    }
2932
2933    #[test]
2934    fn test_transform_points_array() {
2935        let engine = K3dengine::new(640, 480);
2936        let transform_matrix = engine.camera.vp_matrix;
2937
2938        let vertices = [[0.0, 0.0, -5.0], [0.1, 0.0, -5.0], [0.0, 0.1, -5.0]];
2939        let indices = [0, 1, 2];
2940
2941        let result = engine.transform_points(&indices, &vertices, transform_matrix);
2942
2943        // If transform succeeds, verify we get 3 points
2944        if let Some(points) = result {
2945            assert_eq!(points.len(), 3);
2946        }
2947        // If None, one or more points were culled which is valid
2948    }
2949
2950    #[test]
2951    fn test_render_empty_faces_mesh() {
2952        let engine = K3dengine::new(640, 480);
2953        let vertices = [[0.0, 0.0, -5.0]]; // At least one vertex required
2954        let geometry = mesh::Geometry {
2955            vertices: &vertices,
2956            faces: &[],
2957            colors: &[],
2958            lines: &[],
2959            normals: &[],
2960            vertex_normals: &[],
2961            uvs: &[],
2962            texture_id: None,
2963        };
2964        let mesh = mesh::K3dMesh::new(geometry);
2965
2966        let mut callback_count = 0;
2967        engine.render(std::iter::once(&mesh), |_| {
2968            callback_count += 1;
2969        });
2970
2971        // Mesh with no faces/lines should trigger one point callback (default is Points mode)
2972        assert!(callback_count > 0);
2973    }
2974
2975    #[test]
2976    fn test_render_points_mode() {
2977        let engine = K3dengine::new(640, 480);
2978
2979        let vertices = [[0.0, 0.0, -5.0], [0.5, 0.0, -5.0]];
2980
2981        let geometry = mesh::Geometry {
2982            vertices: &vertices,
2983            faces: &[],
2984            colors: &[],
2985            lines: &[],
2986            normals: &[],
2987            vertex_normals: &[],
2988            uvs: &[],
2989            texture_id: None,
2990        };
2991
2992        let mut mesh = mesh::K3dMesh::new(geometry);
2993        mesh.set_render_mode(mesh::RenderMode::Points);
2994
2995        let mut primitives = std::vec::Vec::new();
2996        engine.render(std::iter::once(&mesh), |prim| {
2997            primitives.push(prim);
2998        });
2999
3000        // Should render points
3001        assert!(primitives.len() > 0);
3002        for prim in primitives {
3003            assert!(matches!(prim, DrawPrimitive::ColoredPoint(_, _)));
3004        }
3005    }
3006
3007    #[test]
3008    fn test_render_lines_mode_with_faces() {
3009        let engine = K3dengine::new(640, 480);
3010
3011        let vertices = [[0.0, 0.0, -5.0], [0.5, 0.0, -5.0], [0.0, 0.5, -5.0]];
3012
3013        let faces = [[0, 1, 2]];
3014
3015        let geometry = mesh::Geometry {
3016            vertices: &vertices,
3017            faces: &faces,
3018            colors: &[],
3019            lines: &[],
3020            normals: &[],
3021            vertex_normals: &[],
3022            uvs: &[],
3023            texture_id: None,
3024        };
3025
3026        let mut mesh = mesh::K3dMesh::new(geometry);
3027        mesh.set_render_mode(mesh::RenderMode::Lines);
3028
3029        let mut primitives = std::vec::Vec::new();
3030        engine.render(std::iter::once(&mesh), |prim| {
3031            primitives.push(prim);
3032        });
3033
3034        // Should render 3 lines (edges of triangle)
3035        assert_eq!(primitives.len(), 3);
3036        for prim in primitives {
3037            assert!(matches!(prim, DrawPrimitive::Line(_, _)));
3038        }
3039    }
3040
3041    #[test]
3042    fn test_render_gouraud_light_dir() {
3043        let mut engine = K3dengine::new(640, 480);
3044        engine.camera.set_position(Point3::new(0.0, 0.0, -10.0));
3045        engine.camera.set_target(Point3::new(0.0, 0.0, 0.0));
3046
3047        let vertices = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
3048        let faces = [[0, 1, 2]];
3049        let normals = [[0.0, 0.0, -1.0]]; // face normal pointing toward camera
3050        let vertex_normals = [[0.0, 0.0, -1.0], [0.0, 0.0, -1.0], [0.0, 0.0, -1.0]];
3051
3052        let geometry = mesh::Geometry {
3053            vertices: &vertices,
3054            faces: &faces,
3055            colors: &[],
3056            lines: &[],
3057            normals: &normals,
3058            vertex_normals: &vertex_normals,
3059            uvs: &[],
3060            texture_id: None,
3061        };
3062
3063        let mut mesh = mesh::K3dMesh::new(geometry);
3064        mesh.set_render_mode(mesh::RenderMode::GouraudLightDir(Vector3::new(
3065            0.0, 0.0, 1.0,
3066        )));
3067
3068        let mut primitives = std::vec::Vec::new();
3069        engine.render(std::iter::once(&mesh), |prim| {
3070            primitives.push(prim);
3071        });
3072
3073        // Should emit GouraudTriangleWithDepth primitives
3074        assert!(!primitives.is_empty());
3075        for prim in &primitives {
3076            assert!(matches!(
3077                prim,
3078                DrawPrimitive::GouraudTriangleWithDepth { .. }
3079            ));
3080        }
3081    }
3082
3083    /// Verify that Solid-with-normals renders an interior box correctly.
3084    ///
3085    /// Places a camera at the centre of a simple box whose face normals point
3086    /// inward (toward the camera).  The Solid render path must emit at least
3087    /// one primitive — if backface culling incorrectly fires for all faces
3088    /// this test will catch it.
3089    #[test]
3090    fn test_solid_inward_normals_interior_camera() {
3091        let mut engine = K3dengine::new(320, 240);
3092        // Camera inside the box, looking north (–Z).
3093        engine
3094            .camera
3095            .set_position(nalgebra::Point3::new(0.0, 0.0, 0.0));
3096        engine
3097            .camera
3098            .set_target(nalgebra::Point3::new(0.0, 0.0, -1.0));
3099
3100        // Single north wall: z = –2, vertices form a quad centred on the axis.
3101        // Inward normal points toward the camera = +Z.
3102        #[rustfmt::skip]
3103        let vertices: &[[f32; 3]] = &[
3104            [-1.0, -1.0, -2.0],
3105            [ 1.0, -1.0, -2.0],
3106            [ 1.0,  1.0, -2.0],
3107            [-1.0,  1.0, -2.0],
3108        ];
3109        let faces: &[[usize; 3]] = &[[0, 1, 2], [0, 2, 3]];
3110        let normals: &[[f32; 3]] = &[[0.0, 0.0, 1.0], [0.0, 0.0, 1.0]]; // inward (+Z)
3111
3112        let geometry = mesh::Geometry {
3113            vertices,
3114            faces,
3115            normals,
3116            colors: &[],
3117            lines: &[],
3118            vertex_normals: &[],
3119            uvs: &[],
3120            texture_id: None,
3121        };
3122        let mut m = mesh::K3dMesh::new(geometry);
3123        m.set_render_mode(mesh::RenderMode::Solid);
3124
3125        let mut count = 0usize;
3126        engine.render(std::iter::once(&m), |_| count += 1);
3127
3128        assert!(
3129            count > 0,
3130            "interior Solid-with-inward-normals emitted 0 primitives — culling is wrong"
3131        );
3132    }
3133}