Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-core/src/render/scene3d.rs

//! **Scene3D — the CPU half of the one 3D GPU path** (flat quads, screen-width lines
//! and world-space text planes, depth-tested together). Not behind feature `wgpu`: a
//! host builds a [`Scene3D`] and a [`Camera3`] on every build, picks with
//! [`Camera3::project`] and paints its CPU fallback from the same data; the GPU lane
//! (`render::gpu::scene3d`, feature `wgpu`) draws it.

/// A flat parallelogram `origin + s·u + t·v` (`s, t ∈ 0..1`), one colour, alpha-blended,
/// depth-tested and depth-writing.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Quad3 {
    pub origin: [f32; 3],
    pub u: [f32; 3],
    pub v: [f32; 3],
    pub color: [u8; 4],
}

/// A line segment drawn as a ribbon `width_px` screen pixels wide, depth-tested. A
/// spline is the caller's: tessellate it into many of these.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Line3 {
    pub a: [f32; 3],
    pub b: [f32; 3],
    pub width_px: f32,
    pub color: [u8; 4],
}

/// Flat text on a plane in WORLD space: the first line's baseline starts at `origin`,
/// `right` is the world vector of one em along the baseline and `up` the world vector of
/// one em of height. Monospaced ([`MONO_ADVANCE`] em a glyph); `'\n'` starts a new line
/// [`LINE_ADVANCE`] em lower (along `-up`), `'\t'` is four columns.
#[derive(Clone, Debug, PartialEq)]
pub struct Text3 {
    pub origin: [f32; 3],
    pub right: [f32; 3],
    pub up: [f32; 3],
    pub text: String,
    pub color: [u8; 4],
}

/// One frame of the 3D path. Colours are egui's (gamma-space RGBA), written as they are.
#[derive(Clone, Debug, PartialEq)]
pub struct Scene3D {
    pub quads: Vec<Quad3>,
    pub lines: Vec<Line3>,
    pub texts: Vec<Text3>,
    /// **Translucent sheets** (an unfolded source panel): drawn after every quad, line
    /// and text, far to near, depth-tested but NOT depth-writing — what stands behind a
    /// sheet (a spline, a card) shows through it, dimmed by its alpha, instead of being
    /// cut away as a depth-writing [`Quad3`] would.
    pub sheets: Vec<Quad3>,
    /// The words ON the sheets, drawn after them (so a sheet never dims its own text).
    pub sheet_texts: Vec<Text3>,
    /// The background the pane is cleared to (RGBA, 0..1).
    pub clear: [f32; 4],
    /// A [`Text3`] whose em projects smaller than this many physical px is not drawn; it
    /// fades in between this and 1.5× it. `0` draws every text.
    pub min_text_px: f32,
}

impl Default for Scene3D {
    fn default() -> Self {
        Self { quads: Vec::new(), lines: Vec::new(), texts: Vec::new(), sheets: Vec::new(), sheet_texts: Vec::new(), clear: [0.02, 0.022, 0.03, 1.0], min_text_px: 6.0 }
    }
}

/// The advance of one glyph, in em — the monospace font's (Hack: 1233 / 2048).
pub const MONO_ADVANCE: f32 = 1233.0 / 2048.0;
/// The distance between two baselines, in em.
pub const LINE_ADVANCE: f32 = 1.25;

/// A perspective look-at camera, y-up world. Screen space is physical px, y down.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Camera3 {
    pub eye: [f32; 3],
    pub target: [f32; 3],
    pub up: [f32; 3],
    /// Vertical field of view, radians.
    pub fovy: f32,
    pub near: f32,
    pub far: f32,
}

impl Default for Camera3 {
    fn default() -> Self {
        Self { eye: [0.0, 0.0, 10.0], target: [0.0; 3], up: [0.0, 1.0, 0.0], fovy: 0.8, near: 0.05, far: 10_000.0 }
    }
}

impl Camera3 {
    /// The column-major view-projection (wgpu clip space: depth `0..1`).
    #[must_use]
    pub fn view_proj(&self, aspect: f32) -> [[f32; 4]; 4] {
        self.matrix(aspect).to_cols_array_2d()
    }

    fn matrix(&self, aspect: f32) -> glam::Mat4 {
        let v = |a: [f32; 3]| glam::Vec3::from_array(a);
        let view = glam::camera::rh::view::look_at_mat4(v(self.eye), v(self.target), v(self.up));
        let near = self.near.max(1e-6);
        // wgpu clip space: depth 0..1 (the DirectX convention)
        let proj = glam::camera::rh::proj::directx::perspective(self.fovy.clamp(1e-3, 3.1), aspect.max(1e-6), near, self.far.max(near * 1.001));
        proj * view
    }

    /// Where `p` lands on a `w × h` px screen (y down) and its depth (`0..1`, near to
    /// far) — the CPU twin of the shader's projection. `None` behind the camera.
    #[must_use]
    pub fn project(&self, p: [f32; 3], w: f32, h: f32) -> Option<([f32; 2], f32)> {
        let c = self.matrix(w / h.max(1e-6)) * glam::Vec4::new(p[0], p[1], p[2], 1.0);
        if c.w <= 1e-6 {
            return None;
        }
        let n = c.truncate() / c.w;
        Some(([(n.x * 0.5 + 0.5) * w, (0.5 - n.y * 0.5) * h], n.z))
    }

    /// How many px one em of `up` at `at` spans on a `w × h` screen (`0` when it cannot
    /// be projected).
    #[must_use]
    pub fn em_px(&self, at: [f32; 3], up: [f32; 3], w: f32, h: f32) -> f32 {
        let top = [at[0] + up[0], at[1] + up[1], at[2] + up[2]];
        match (self.project(at, w, h), self.project(top, w, h)) {
            (Some((a, _)), Some((b, _))) => ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt(),
            _ => 0.0,
        }
    }
}

/// One glyph of a laid-out [`Text3`]: the character and the world point its pen starts at
/// (baseline-left).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GlyphAt {
    pub ch: char,
    pub pen: [f32; 3],
}

/// Lay a text out: every visible glyph with its pen position (spaces advance and are not
/// listed).
#[must_use]
pub fn layout_text(t: &Text3) -> Vec<GlyphAt> {
    let mut out = Vec::with_capacity(t.text.len());
    let (mut col, mut row) = (0usize, 0usize);
    for ch in t.text.chars() {
        match ch {
            '\n' => {
                col = 0;
                row += 1;
            }
            '\t' => col = (col / 4 + 1) * 4,
            c if c.is_whitespace() || c.is_control() => col += 1,
            c => {
                let (dx, dy) = (col as f32 * MONO_ADVANCE, -(row as f32) * LINE_ADVANCE);
                let pen = std::array::from_fn(|k| t.origin[k] + t.right[k] * dx + t.up[k] * dy);
                out.push(GlyphAt { ch: c, pen });
                col += 1;
            }
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_target_projects_to_the_screen_centre() {
        let cam = Camera3 { eye: [3.0, 4.0, 12.0], target: [1.0, -2.0, 0.5], ..Camera3::default() };
        let (xy, depth) = cam.project(cam.target, 800.0, 600.0).expect("in front");
        assert!((xy[0] - 400.0).abs() < 1e-2 && (xy[1] - 300.0).abs() < 1e-2, "{xy:?}");
        assert!(depth > 0.0 && depth < 1.0, "{depth}");
        // up in the world is up on the screen (y down), and behind the eye is nothing
        let above = cam.project([1.0, -1.0, 0.5], 800.0, 600.0).unwrap().0;
        assert!(above[1] < 300.0, "{above:?}");
        assert!(cam.project([3.0 * 2.0 - 1.0, 4.0 * 2.0 + 2.0, 12.0 * 2.0 - 0.5], 800.0, 600.0).is_none());
        // nearer is a smaller depth
        let near = cam.project([2.0, 1.0, 6.25], 800.0, 600.0).unwrap().1;
        assert!(near < depth, "{near} {depth}");
    }

    #[test]
    fn text_advances_monospaced_and_wraps_on_newline() {
        let t = Text3 { origin: [1.0, 2.0, 3.0], right: [2.0, 0.0, 0.0], up: [0.0, 2.0, 0.0], text: "fn a\nb".into(), color: [255; 4] };
        let g = layout_text(&t);
        let chars: String = g.iter().map(|g| g.ch).collect();
        assert_eq!(chars, "fnab", "spaces and newlines are not glyphs");
        let adv = 2.0 * MONO_ADVANCE;
        assert!((g[1].pen[0] - g[0].pen[0] - adv).abs() < 1e-5, "{g:?}");
        assert!((g[2].pen[0] - (1.0 + 3.0 * adv)).abs() < 1e-5, "the space advanced: {g:?}");
        assert_eq!(g[3].pen, [1.0, 2.0 - 2.0 * LINE_ADVANCE, 3.0], "a new line starts under the first");
    }

    #[test]
    fn an_em_measures_its_projected_height() {
        let cam = Camera3 { eye: [0.0, 0.0, 10.0], ..Camera3::default() };
        let near = cam.em_px([0.0; 3], [0.0, 1.0, 0.0], 800.0, 600.0);
        let far = Camera3 { eye: [0.0, 0.0, 30.0], ..cam }.em_px([0.0; 3], [0.0, 1.0, 0.0], 800.0, 600.0);
        assert!(near > 10.0 && (near / far - 3.0).abs() < 0.2, "{near} {far}");
    }
}