codecraft 0.1.1

A minimalist 3D game engine built on parts of Bevy (ECS, color) with wgpu and winit: OpenPBR materials, clustered lighting, an immediate-mode UI, audio and gamepad haptics
Documentation
//! The one texture the UI samples: icons in the top band, glyphs below.
//!
//! Both are **white coverage masks**, which is what lets them share a texture
//! and a shader: the quad carries the colour and the atlas only says where the
//! ink is. See `ui.wgsl` -- a quad with an empty rectangle is a flat colour, and
//! anything else is a mask tinted by whatever asked for it.
//!
//! Two bands rather than one grid, because the two want different cells. An
//! icon is square and is drawn small. A glyph is about half as wide as it is
//! tall and has to survive being drawn at a heading's size, so its cell is
//! twice the icon's and there are fewer of them.
//!
//! Nothing is rasterised until something asks for it. A scene that draws a
//! dozen icons and the letters of `NEW GAME` pays for those and no more.
use std::collections::HashMap;

use super::font::Face;

/// How wide and tall the atlas is, in pixels.
pub const ATLAS_SIZE: u32 = 1024;

/// How big an icon is drawn, and rasterised, in pixels.
pub const ICON_SIZE: u32 = 32;

/// The icon band: the top eight rows of 32px cells.
const ICON_COLUMNS: u32 = ATLAS_SIZE / ICON_SIZE;
const ICON_ROWS: u32 = 8;
const ICON_SLOTS: u32 = ICON_COLUMNS * ICON_ROWS;

/// Where the glyph band starts, which is directly under the icon band.
const GLYPH_TOP: u32 = ICON_SIZE * ICON_ROWS;

/// How big a glyph cell is.
///
/// The largest text in the UI is a heading at five pixels to the unit, which
/// is a cap height of thirty-five and an em of about fifty-three. Sixty-four
/// holds that without upscaling and leaves room for the tall brackets.
pub const GLYPH_SIZE: u32 = 64;

const GLYPH_COLUMNS: u32 = ATLAS_SIZE / GLYPH_SIZE;
const GLYPH_ROWS: u32 = (ATLAS_SIZE - GLYPH_TOP) / GLYPH_SIZE;
const GLYPH_SLOTS: u32 = GLYPH_COLUMNS * GLYPH_ROWS;

/// Where one thing sits in the atlas, in texture coordinates.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Uv {
    pub min: [f32; 2],
    pub max: [f32; 2],
}

/// What has been rasterised so far, and where each one landed.
///
/// A resource, so a system building rows can ask for an icon or a letter as it
/// goes; the texture itself belongs to the UI renderer, which uploads this
/// whenever it changes.
#[derive(bevy_ecs::prelude::Resource)]
pub struct Atlas {
    icons: HashMap<&'static str, Uv>,
    glyphs: HashMap<char, Uv>,
    /// The next free cell in each band, left to right and top to bottom.
    next_icon: u32,
    next_glyph: u32,
    /// The whole atlas, kept so a newly rasterised cell can be written into it
    /// without reading the texture back.
    pixels: Vec<u8>,
    /// Set when `pixels` has changed and the texture needs sending again.
    dirty: bool,
}

impl Default for Atlas {
    fn default() -> Self {
        Self::new()
    }
}

impl Atlas {
    pub fn new() -> Self {
        Self {
            icons: HashMap::new(),
            glyphs: HashMap::new(),
            next_icon: 0,
            next_glyph: 0,
            pixels: vec![0; (ATLAS_SIZE * ATLAS_SIZE * 4) as usize],
            dirty: true,
        }
    }

    /// Where `icon` is in the atlas, rasterising it if this is the first ask.
    ///
    /// `None` means the icon is not one of the ones compiled in, or the band is
    /// full. A caller draws nothing rather than a wrong icon.
    pub fn icon(&mut self, icon: &'static str) -> Option<Uv> {
        if let Some(uv) = self.icons.get(icon) {
            return Some(*uv);
        }
        if self.next_icon >= ICON_SLOTS {
            log::warn!("the icon band is full; {icon} will not be drawn");
            return None;
        }

        let Some(svg) = super::icons::source(icon) else {
            log::warn!("{icon} is not compiled in; add it to `icons::embedded`");
            return None;
        };
        let Some(raster) = super::icons::rasterize(svg.as_bytes(), ICON_SIZE) else {
            log::warn!("cannot rasterise {icon}");
            return None;
        };

        let slot = self.next_icon;
        self.next_icon += 1;
        let (column, row) = (slot % ICON_COLUMNS, slot / ICON_COLUMNS);
        let (x0, y0) = (column * ICON_SIZE, row * ICON_SIZE);
        self.blit(&raster, x0, y0, ICON_SIZE);

        let uv = uv_of(x0, y0, ICON_SIZE);
        self.icons.insert(icon, uv);
        Some(uv)
    }

    /// Where `ch` is in the atlas, rasterising it from `face` if this is the
    /// first ask.
    ///
    /// `None` means the face has nothing to draw for it -- a space, or a
    /// character it has no glyph for -- or the band is full. Either way the
    /// caller draws nothing and moves the cursor on, so a string with an
    /// unknown character in it still reads.
    pub fn glyph(&mut self, face: &Face, ch: char) -> Option<Uv> {
        if let Some(uv) = self.glyphs.get(&ch) {
            return Some(*uv);
        }
        if self.next_glyph >= GLYPH_SLOTS {
            log::warn!("the glyph band is full; {ch:?} will not be drawn");
            return None;
        }

        let raster = face.rasterize(ch, GLYPH_SIZE)?;

        let slot = self.next_glyph;
        self.next_glyph += 1;
        let (column, row) = (slot % GLYPH_COLUMNS, slot / GLYPH_COLUMNS);
        let (x0, y0) = (column * GLYPH_SIZE, GLYPH_TOP + row * GLYPH_SIZE);
        self.blit(&raster, x0, y0, GLYPH_SIZE);

        let uv = uv_of(x0, y0, GLYPH_SIZE);
        self.glyphs.insert(ch, uv);
        Some(uv)
    }

    /// Forgets every glyph, so the next ask rasterises from whatever face is
    /// current. What the band holds is wiped with it -- a stale `A` in Neon
    /// under a fresh `B` in Radon would be worse than a blank frame.
    ///
    /// The icons are left alone: they are not the font's, and re-rasterising
    /// fifteen SVGs to change a typeface is work for nothing.
    pub fn forget_glyphs(&mut self) {
        self.glyphs.clear();
        self.next_glyph = 0;
        let from = (GLYPH_TOP * ATLAS_SIZE * 4) as usize;
        self.pixels[from..].fill(0);
        self.dirty = true;
    }

    /// Writes one square raster into the atlas at `(x0, y0)`.
    fn blit(&mut self, raster: &[u8], x0: u32, y0: u32, size: u32) {
        for y in 0..size {
            let from = (y * size * 4) as usize;
            let to = (((y0 + y) * ATLAS_SIZE + x0) * 4) as usize;
            let width = (size * 4) as usize;
            self.pixels[to..to + width].copy_from_slice(&raster[from..from + width]);
        }
        self.dirty = true;
    }

    /// The atlas as RGBA8, and whether it has changed since it was last taken.
    /// The renderer uploads it only when it has.
    pub fn take_if_dirty(&mut self) -> Option<&[u8]> {
        match std::mem::take(&mut self.dirty) {
            true => Some(&self.pixels),
            false => None,
        }
    }

    /// How many icons have been rasterised.
    pub fn len(&self) -> usize {
        self.icons.len()
    }

    pub fn is_empty(&self) -> bool {
        self.icons.is_empty()
    }

    /// How many glyphs have been rasterised.
    pub fn glyphs(&self) -> usize {
        self.glyphs.len()
    }
}

/// The rectangle of a cell, half a texel in from each edge: sampling exactly on
/// the boundary picks up the neighbouring cell along the seam.
fn uv_of(x0: u32, y0: u32, size: u32) -> Uv {
    let atlas = ATLAS_SIZE as f32;
    let inset = 0.5 / atlas;
    Uv {
        min: [x0 as f32 / atlas + inset, y0 as f32 / atlas + inset],
        max: [
            (x0 + size) as f32 / atlas - inset,
            (y0 + size) as f32 / atlas - inset,
        ],
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ui::font::Family;

    /// The two bands have to tile the texture without meeting in the middle of
    /// a cell, or one writes over the other's last row.
    #[test]
    fn the_bands_do_not_overlap() {
        assert_eq!(GLYPH_TOP % ICON_SIZE, 0, "the icon band ends on a cell");
        assert_eq!(GLYPH_TOP % GLYPH_SIZE, 0, "the glyph band starts on one");
        assert!(GLYPH_TOP + GLYPH_ROWS * GLYPH_SIZE <= ATLAS_SIZE);
    }

    /// Every printable character has to fit, or a label goes blank part way
    /// through for no reason a caller could have predicted.
    #[test]
    fn the_glyph_band_holds_printable_ascii() {
        let printable = (0x20u8..0x7f).count() as u32;
        assert!(
            GLYPH_SLOTS >= printable,
            "{GLYPH_SLOTS} cells for {printable} characters",
        );
    }

    #[test]
    fn an_icon_is_rasterised_once_and_kept() {
        let mut atlas = Atlas::new();
        let first = atlas.icon(super::super::icons::path::CUBE).expect("a cube");
        let again = atlas.icon(super::super::icons::path::CUBE).expect("again");
        assert_eq!(first, again, "the same icon should keep its cell");
        assert_eq!(atlas.len(), 1);

        let other = atlas
            .icon(super::super::icons::path::LIGHTBULB)
            .expect("a light");
        assert_ne!(first, other, "two icons cannot share a cell");
        assert_eq!(atlas.len(), 2);
    }

    #[test]
    fn an_icon_that_is_not_compiled_in_is_nothing_rather_than_wrong() {
        let mut atlas = Atlas::new();
        assert!(atlas.icon("icons/no-such-icon.svg").is_none());
        assert!(atlas.is_empty());
    }

    /// The two bands are addressed separately, so a full alphabet must not
    /// push an icon out of its cell.
    #[test]
    fn icons_and_glyphs_do_not_share_cells() {
        let mut atlas = Atlas::new();
        let face = Face::new(Family::Neon);
        let icon = atlas.icon(super::super::icons::path::CUBE).expect("a cube");
        let glyph = atlas.glyph(&face, 'A').expect("an A");

        assert!(
            glyph.min[1] > icon.max[1],
            "the glyph band sits below the icon band: {glyph:?} against {icon:?}",
        );
    }

    #[test]
    fn a_glyph_is_rasterised_once_and_remembered() {
        let mut atlas = Atlas::new();
        let face = Face::new(Family::Neon);

        let first = atlas.glyph(&face, 'A').expect("A is in the font");
        assert_eq!(atlas.glyphs(), 1);
        let again = atlas.glyph(&face, 'A').expect("A is still there");
        assert_eq!(first, again, "the same cell, not a second one");
        assert_eq!(atlas.glyphs(), 1, "and nothing new was rasterised");
    }

    /// A space has no ink. It still has to advance the cursor, which is the
    /// caller's business -- here it just must not take a cell.
    #[test]
    fn a_space_takes_no_cell() {
        let mut atlas = Atlas::new();
        let face = Face::new(Family::Neon);

        assert!(atlas.glyph(&face, ' ').is_none());
        assert_eq!(atlas.glyphs(), 0);
    }

    #[test]
    fn changing_face_forgets_the_glyphs_and_keeps_the_icons() {
        let mut atlas = Atlas::new();
        let face = Face::new(Family::Neon);
        atlas.glyph(&face, 'A');
        atlas.icon(crate::ui::icons::path::CUBE);
        assert_eq!((atlas.glyphs(), atlas.len()), (1, 1));

        atlas.forget_glyphs();
        assert_eq!(atlas.glyphs(), 0, "the letters go");
        assert_eq!(atlas.len(), 1, "the icons stay");
    }

    /// The glyph band is wiped when it is forgotten, so a letter from the old
    /// face cannot show through under a new one.
    #[test]
    fn forgetting_the_glyphs_wipes_what_they_drew() {
        let mut atlas = Atlas::new();
        atlas.glyph(&Face::new(Family::Neon), 'A');
        atlas.take_if_dirty();

        atlas.forget_glyphs();
        let pixels = atlas.take_if_dirty().expect("wiping is a change");
        let band = &pixels[(GLYPH_TOP * ATLAS_SIZE * 4) as usize..];
        assert!(band.iter().all(|&b| b == 0), "the band is blank again");
    }
}