neurodoom 0.6.7

Deterministic no_std Doom engine with semantic and depth perception buffers for AI
Documentation
// Sprite projection + per-column rasterizer on the hot path — see
// rationale in src/render/mod.rs.
#![allow(clippy::indexing_slicing)]

use crate::math::*;
use crate::texture::TextureData;

use super::*;

/// Minimum depth to render a sprite (avoids divide-by-zero).
const MINZ: Fixed = 4 * FRACUNIT;

/// A thing to project as a sprite (minimal info needed from mobj system).
pub struct ThingRef {
    pub x: Fixed,
    pub y: Fixed,
    pub z: Fixed,
    pub angle: Angle,
    pub sprite_num: usize,
    pub frame: usize,
    pub flags: u32,
    pub mobj_type: u16,
    pub light_level: i16,
}

impl Renderer {
    /// Project and render all things. Called after BSP traversal and plane drawing.
    pub fn draw_things(
        &mut self,
        things: &[ThingRef],
        textures: &TextureData,
    ) {
        // Project all things into vissprites
        self.vissprites.clear();
        for thing in things {
            self.project_sprite(thing, textures);
        }

        // Sort back to front (lowest scale = farthest first)
        self.vissprites
            .sort_by(|a, b| a.scale.cmp(&b.scale));

        // Draw each vissprite
        let vis_count = self.vissprites.len();
        for i in 0..vis_count {
            self.draw_vissprite(i, textures);
        }
    }

    /// Project a thing into a vissprite.
    fn project_sprite(&mut self, thing: &ThingRef, textures: &TextureData) {
        // Transform to viewer-relative coordinates
        let tr_x = thing.x - self.view_x;
        let tr_y = thing.y - self.view_y;

        let gxt = fixed_mul(tr_x, self.view_cos);
        let gyt = -fixed_mul(tr_y, self.view_sin);
        let tz = gxt - gyt; // depth (toward camera)

        if tz < MINZ {
            return; // behind camera
        }

        let xscale = fixed_div(self.projection, tz);
        if xscale <= 0 {
            return;
        }

        // Perpendicular offset from view axis
        let gxt2 = -fixed_mul(tr_x, self.view_sin);
        let gyt2 = fixed_mul(tr_y, self.view_cos);
        let tx_base = -(gyt2 + gxt2);

        // Too far to the side?
        if tx_base.abs() > tz << 2 {
            return;
        }

        // Get sprite frame data
        if thing.sprite_num >= textures.sprites.len() {
            return;
        }
        let sprdef = &textures.sprites[thing.sprite_num];
        let frame_idx = thing.frame & 0x7FFF; // strip FF_FULLBRIGHT
        if frame_idx >= sprdef.frames.len() {
            return;
        }
        let sprframe = &sprdef.frames[frame_idx];

        // Select rotation
        let (lump, flip) = if sprframe.rotate {
            let ang = crate::render::bsp::point_to_angle_2(
                self.view_x, self.view_y, thing.x, thing.y,
            );
            let rot = ang
                .wrapping_sub(thing.angle)
                .wrapping_add((ANG45 / 2).wrapping_mul(9))
                >> 29;
            let rot = (rot as usize) & 7;
            (sprframe.lump[rot], sprframe.flip[rot])
        } else {
            (sprframe.lump[0], sprframe.flip[0])
        };

        if lump < 0 || lump as usize >= textures.sprite_info.len() {
            return;
        }
        let lump_idx = lump as usize;
        let si = &textures.sprite_info[lump_idx];

        // Compute screen X range
        let tx = tx_base - si.left_offset;
        let x1 = (self.center_x_frac + fixed_mul(tx, xscale)) >> FRACBITS;
        if x1 >= SCREENWIDTH as i32 {
            return;
        }

        let tx2 = tx + si.width;
        let x2 = ((self.center_x_frac + fixed_mul(tx2, xscale)) >> FRACBITS) - 1;
        if x2 < 0 {
            return;
        }

        // Build vissprite
        let iscale = fixed_div(FRACUNIT, xscale);
        let texturemid = thing.z + si.top_offset - self.view_z;

        let start_frac = if flip {
            si.width - 1
        } else {
            0
        };
        let x_iscale = if flip { -iscale } else { iscale };

        let vis_x1 = x1.max(0);
        let vis_x2 = x2.min(SCREENWIDTH as i32 - 1);

        let mut sf = start_frac;
        if vis_x1 > x1 {
            sf += x_iscale * (vis_x1 - x1);
        }

        // Light level from sector, with distance-based falloff
        let sector_light = ((thing.light_level >> LIGHTSEGSHIFT as i16) as usize)
            .min(LIGHTLEVELS - 1);
        let light_idx = ((xscale >> LIGHTSCALESHIFT) as usize).min(MAXLIGHTSCALE - 1);
        let colormap_idx = self.scalelight[sector_light][light_idx];

        // Semantic classification
        let semantic = classify_mobj_type(thing.mobj_type);

        self.vissprites.push(VisSprite {
            x1: vis_x1,
            x2: vis_x2,
            scale: xscale,
            x_iscale,
            start_frac: sf,
            texture_mid: texturemid,
            patch_lump: lump_idx,
            colormap_idx,
            mobj_flags: thing.flags,
            semantic,
            gz: thing.z,
            gzt: thing.z + si.top_offset,
            tz,
        });
    }

    /// Draw a vissprite using drawseg-based clipping and actual patch data.
    fn draw_vissprite(&mut self, vis_idx: usize, textures: &TextureData) {
        let vis = self.vissprites[vis_idx].clone();

        let si = if vis.patch_lump < textures.sprite_info.len() {
            &textures.sprite_info[vis.patch_lump]
        } else {
            return;
        };

        let Some(patch) = si.patch.as_ref() else {
            return;
        };

        let sprite_scale = vis.scale;
        let dc_iscale = vis.x_iscale.unsigned_abs() as Fixed;
        let sprtop = self.center_y_frac - fixed_mul(vis.texture_mid, sprite_scale);

        let cmap = if vis.colormap_idx < textures.colormaps.len() {
            &textures.colormaps[vis.colormap_idx]
        } else if !textures.colormaps.is_empty() {
            &textures.colormaps[0]
        } else {
            return;
        };

        // Build per-column clip bounds from drawsegs
        let mut clipbot = [self.view_height; SCREENWIDTH];
        let mut cliptop = [-1i32; SCREENWIDTH];

        // Scan drawsegs from nearest to farthest (reverse order)
        for ds in self.drawsegs.iter().rev() {
            if ds.x1 > vis.x2 || ds.x2 < vis.x1 {
                continue;
            }

            let r1 = ds.x1.max(vis.x1);
            let r2 = ds.x2.min(vis.x2);

            let mut sil = ds.silhouette;

            // Refine: sprite above the bottom silhouette? No bottom clip needed.
            if (sil & SIL_BOTTOM) != 0 && vis.gz >= ds.bsilheight {
                sil &= !SIL_BOTTOM;
            }
            if (sil & SIL_TOP) != 0 && vis.gzt <= ds.tsilheight {
                sil &= !SIL_TOP;
            }
            if sil == SIL_NONE {
                continue;
            }

            for x in r1..=r2 {
                let xu = x as usize;

                // Scale comparison: only clip if drawseg is closer (higher scale)
                let ds_scale = ds.scale1 + ds.scalestep * (x - ds.x1);
                if vis.scale >= ds_scale {
                    continue; // sprite is closer
                }

                if (sil & SIL_BOTTOM) != 0
                    && ds.sprite_bottom_clip[xu] < clipbot[xu] as i16
                {
                    clipbot[xu] = ds.sprite_bottom_clip[xu] as i32;
                }
                if (sil & SIL_TOP) != 0
                    && ds.sprite_top_clip[xu] > cliptop[xu] as i16
                {
                    cliptop[xu] = ds.sprite_top_clip[xu] as i32;
                }
            }
        }

        // Draw sprite columns
        let mut frac = vis.start_frac;

        for x in vis.x1..=vis.x2 {
            let xu = x as usize;
            if xu >= SCREENWIDTH {
                break;
            }

            let tex_col = (frac >> FRACBITS) as usize;
            frac += vis.x_iscale;

            if tex_col >= patch.columns.len() {
                continue;
            }

            let col_top = cliptop[xu] + 1;
            let col_bot = clipbot[xu] - 1;

            for post in &patch.columns[tex_col] {
                let pixels = post.pixels(&patch.pixel_data);
                let top_delta = post.top_delta as i32;
                let length = pixels.len() as i32;

                let top_screen = sprtop + fixed_mul(top_delta << FRACBITS, sprite_scale);
                let bottom_screen = top_screen + fixed_mul(length << FRACBITS, sprite_scale);

                let mut yl = (top_screen + FRACUNIT - 1) >> FRACBITS;
                let mut yh = (bottom_screen - 1) >> FRACBITS;

                // Clip to drawseg bounds and screen
                yl = yl.max(col_top).max(0);
                yh = yh.min(col_bot).min(SCREENHEIGHT as i32 - 1);

                if yl > yh {
                    continue;
                }

                let mut tex_frac = if yl > (top_screen >> FRACBITS) {
                    dc_iscale as i64 * (yl as i64 - (top_screen >> FRACBITS) as i64)
                } else {
                    0
                };

                for y in yl..=yh {
                    let tex_y = (tex_frac >> FRACBITS) as usize;
                    if tex_y < pixels.len() {
                        let pixel = pixels[tex_y];
                        let idx = (y * SCREENWIDTH as i32 + x) as usize;
                        self.screen[idx] = cmap[pixel as usize];
                        self.semantic[idx] = vis.semantic as u8;
                        self.depth[idx] = vis.tz;
                    }
                    tex_frac += dc_iscale as i64;
                }
            }
        }
    }

    /// Draw the player's weapon sprite using psprite state machine coordinates.
    /// `psp_state`: current weapon animation state.
    /// `psp_sx`, `psp_sy`: screen position in fixed-point (from bob + state).
    pub fn draw_weapon_psp(
        &mut self,
        psp_state: crate::game_data::StateNum,
        psp_sx: Fixed,
        psp_sy: Fixed,
        textures: &TextureData,
    ) {
        let Some(state) = psp_state.get() else { return };
        let sprite_num = state.sprite.0 as usize;
        let frame = (state.frame & 0x7FFF) as usize;
        let fullbright = (state.frame & 0x8000) != 0;

        if sprite_num >= textures.sprites.len() { return; }
        let sprdef = &textures.sprites[sprite_num];
        if frame >= sprdef.frames.len() { return; }
        let sprframe = &sprdef.frames[frame];
        let lump = sprframe.lump[0];
        if lump < 0 || lump as usize >= textures.sprite_info.len() { return; }
        let si = &textures.sprite_info[lump as usize];
        let Some(patch) = si.patch.as_ref() else { return; };

        // Screen coordinates using Doom's R_DrawPSprite formula:
        // tx = psp_sx - 160*FRACUNIT - spriteoffset[lump]
        // x1 = centerx + tx * pspritescale
        let pspritescale = FRACUNIT; // 1:1 for 320px width
        let tx = psp_sx - 160 * FRACUNIT - si.left_offset;
        let x1 = ((self.center_x_frac + fixed_mul(tx, pspritescale)) >> FRACBITS) as i32;
        let tx2 = tx + si.width;
        let x2 = (((self.center_x_frac + fixed_mul(tx2, pspritescale)) >> FRACBITS) - 1) as i32;

        // Y: texturemid = BASEYCENTER + 0.5 - (psp_sy - top_offset)
        let base_y_center = 100 * FRACUNIT; // BASEYCENTER << FRACBITS
        let texturemid = base_y_center + FRACUNIT / 2 - (psp_sy - si.top_offset);

        // Colormap: full bright or ambient light
        let cmap_idx = if fullbright { 0 } else {
            // Use the brightest scale (weapon always lit by sector light)
            0 // simplified: full bright for now
        };
        let cmap = if cmap_idx < textures.colormaps.len() {
            &textures.colormaps[cmap_idx]
        } else {
            return;
        };

        // Draw columns
        let x1c = x1.max(0);
        let x2c = x2.min(SCREENWIDTH as i32 - 1);
        if x1c > x2c { return; }

        let iscale = FRACUNIT; // 1:1 scale

        for x in x1c..=x2c {
            // Map screen column to texture column
            let tex_col = ((x - x1) as i64 * patch.width as i64
                / (x2 - x1 + 1).max(1) as i64) as usize;
            if tex_col >= patch.columns.len() { continue; }

            // Draw each post in this column
            for post in &patch.columns[tex_col] {
                let top_delta = post.top_delta as i32;
                let pixels = post.pixels(&patch.pixel_data);

                for (py, &pixel) in pixels.iter().enumerate() {
                    // Map texture Y to screen Y
                    // texturemid corresponds to center_y on screen
                    let tex_y = top_delta + py as i32;
                    let screen_y = self.center_y
                        + ((tex_y as i64 * iscale as i64 - texturemid as i64)
                            >> FRACBITS) as i32;

                    if screen_y >= 0 && screen_y < SCREENHEIGHT as i32 {
                        let idx = (screen_y * SCREENWIDTH as i32 + x) as usize;
                        if idx < self.screen.len() {
                            self.screen[idx] = cmap[pixel as usize];
                        }
                    }
                }
            }
        }
    }
}

/// Classify a mobj type number into a semantic class.
pub fn classify_mobj_type(mobj_type: u16) -> SemanticClass {
    // Based on Doom's mobjtype_t enum values
    match mobj_type {
        // Enemies (MT_POSSESSED=3 through various monster types)
        3..=6 | 9 | 16 | 19..=22 | 31 | 40 | 49 | 58 | 64 | 65 | 66 | 67 | 68 | 69 | 71
        | 84 | 85 | 87 | 88 => SemanticClass::Enemy,
        // Projectiles
        10..=15 | 34..=39 | 51 => SemanticClass::Projectile,
        // Remote/other players
        0 => SemanticClass::Player, // MT_PLAYER
        // Everything else
        _ => SemanticClass::Item,
    }
}