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/l1/mod.rs

//! **The L1 vello beauty overlay** (feature `l1-vello`) — the OPTIONAL GPU lane
//! that draws the things the L0 SDF base deliberately skips: **gradient fills,
//! antialiased Bézier curves, and gaussian blur / glow effects**, composited on
//! top of an L0 [`Frame`](super::Frame).
//!
//! ## Doctrine (`.nornir/render-engine.md` §"L0 / L1")
//! - **L0** = facett-core SDF instanced base. The speed *and* beauty base; must
//!   look great with **zero vello** (feature off ⇒ this module does not compile in
//!   and L0 is untouched).
//! - **L1** = this overlay. `vello` = the *fat-GPU* member of the velo family
//!   (compute). **Desktop only; never required.** A device / tank / headless-CPU
//!   target never pulls it (the `l1-vello` feature stays off there); the L0 frame
//!   stands alone.
//!
//! ## How it composes
//! vello 0.9 renders a [`vello::Scene`] into a wgpu **storage** texture
//! (`render_to_texture`, `Rgba8Unorm` + `STORAGE_BINDING`). We read that texture
//! back to straight RGBA8 and **alpha-composite (source-over)** it over the L0
//! frame — so the same final frame carries L0 (SDF nodes/edges/markers) *and* L1
//! (the gradient sweep, the AA spline, the soft glow). Both lanes land in **one**
//! picture, the side-by-side the demo's "Velo L1" tab shows is L0 vs this.
//!
//! The overlay is built **headless** (its own throwaway wgpu device), so it is
//! fully testable without a window — the render-proof test injects a scene, runs
//! L0 and L0+L1, and asserts L1 lit pixels the L0 frame lacked, contained in the
//! rect, plus emits the composited PNG as a blob the test reads back.

use super::Frame;

// vello 0.9 renders on wgpu 29; facett's L0 / egui 0.36 lane is on wgpu 30. The overlay
// never shared egui's device (it opens its own headless one, below), so it takes
// vello's wgpu under the workspace alias `wgpu29`. Drop the alias when vello ships on
// wgpu 30 and go back to `gputurn::probe::open`.
use wgpu29 as wgpu;

pub use vello::{self, peniko, peniko::kurbo};

/// The recipe for an L1 overlay — the beauty primitives the SDF base can't draw,
/// expressed in **screen pixels** (the same coordinate space as the L0
/// [`prim`](super::prim) instances), so the overlay registers exactly over the L0
/// content. Deliberately small + declarative so a skin (graph / map) builds one
/// without touching vello, and a test injects a known one and asserts the result.
#[derive(Clone, Debug, Default)]
pub struct L1Overlay {
    /// Linear-gradient–filled rounded rects (the SDF base only does flat fills).
    /// `(rect_min, rect_max, corner_radius, [color0, color1])` — the gradient runs
    /// left→right across the rect from `color0` to `color1`.
    pub gradient_rects: Vec<GradientRect>,
    /// Antialiased cubic Bézier strokes (the SDF base only does straight capsules).
    pub curves: Vec<Curve>,
    /// Gaussian-blurred rounded rects — the soft **glow / effects** the L0 row has
    /// hard OFF (`render-engine.md`: "Effects is the only hard OFF on the CPU row").
    pub glows: Vec<Glow>,
}

/// One linear-gradient–filled rounded rect (screen px).
#[derive(Clone, Copy, Debug)]
pub struct GradientRect {
    pub min: [f32; 2],
    pub max: [f32; 2],
    pub corner: f32,
    /// `[r, g, b, a]` in `[0, 1]` at the left and right gradient stops.
    pub color0: [f32; 4],
    pub color1: [f32; 4],
}

/// One antialiased cubic Bézier stroke (screen px): `p0 → (c0, c1) → p1`.
#[derive(Clone, Copy, Debug)]
pub struct Curve {
    pub p0: [f32; 2],
    pub c0: [f32; 2],
    pub c1: [f32; 2],
    pub p1: [f32; 2],
    pub width: f32,
    pub color: [f32; 4],
}

/// One gaussian-blurred rounded rect — a soft glow (screen px).
#[derive(Clone, Copy, Debug)]
pub struct Glow {
    pub min: [f32; 2],
    pub max: [f32; 2],
    pub corner: f32,
    pub color: [f32; 4],
    /// Gaussian standard deviation (px) — the blur radius / glow softness.
    pub std_dev: f32,
}

impl L1Overlay {
    /// Total number of beauty primitives in the overlay (gradients + curves +
    /// glows) — the "what L1 adds over L0" count surfaced in `state_json`.
    pub fn len(&self) -> usize {
        self.gradient_rects.len() + self.curves.len() + self.glows.len()
    }
    /// Whether the overlay draws nothing (then a compose is a no-op pass-through).
    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    /// Encode this overlay into a fresh [`vello::Scene`] (screen-pixel coords map
    /// straight to the target texture, identity transform). Pure (no GPU) so the
    /// scene-build is unit-testable and a host can inspect it before rendering.
    pub fn to_scene(&self) -> vello::Scene {
        use kurbo::{Affine, BezPath, Point, Rect, Stroke};
        use peniko::{Color, Fill, Gradient};

        let to_color = |c: [f32; 4]| {
            Color::from_rgba8(
                (c[0] * 255.0).round().clamp(0.0, 255.0) as u8,
                (c[1] * 255.0).round().clamp(0.0, 255.0) as u8,
                (c[2] * 255.0).round().clamp(0.0, 255.0) as u8,
                (c[3] * 255.0).round().clamp(0.0, 255.0) as u8,
            )
        };

        let mut scene = vello::Scene::new();

        // Glows first (under) — the soft halo behind the crisp content.
        for g in &self.glows {
            let rect = Rect::new(g.min[0] as f64, g.min[1] as f64, g.max[0] as f64, g.max[1] as f64);
            scene.draw_blurred_rounded_rect(
                Affine::IDENTITY,
                rect,
                to_color(g.color),
                g.corner as f64,
                g.std_dev as f64,
            );
        }

        // Gradient-filled rounded rects.
        for gr in &self.gradient_rects {
            let rect = Rect::new(
                gr.min[0] as f64,
                gr.min[1] as f64,
                gr.max[0] as f64,
                gr.max[1] as f64,
            );
            let rounded = rect.to_rounded_rect(gr.corner as f64);
            let grad = Gradient::new_linear(
                Point::new(gr.min[0] as f64, gr.min[1] as f64),
                Point::new(gr.max[0] as f64, gr.min[1] as f64),
            )
            .with_stops([(0.0_f32, to_color(gr.color0)), (1.0_f32, to_color(gr.color1))]);
            scene.fill(Fill::NonZero, Affine::IDENTITY, &grad, None, &rounded);
        }

        // Antialiased cubic Bézier strokes (over).
        for cv in &self.curves {
            let mut path = BezPath::new();
            path.move_to(Point::new(cv.p0[0] as f64, cv.p0[1] as f64));
            path.curve_to(
                Point::new(cv.c0[0] as f64, cv.c0[1] as f64),
                Point::new(cv.c1[0] as f64, cv.c1[1] as f64),
                Point::new(cv.p1[0] as f64, cv.p1[1] as f64),
            );
            let stroke = Stroke::new(cv.width as f64);
            scene.stroke(&stroke, Affine::IDENTITY, to_color(cv.color), None, &path);
        }

        scene
    }
}

/// Source-over alpha-composite a straight-RGBA8 `over` frame onto a straight-RGBA8
/// `base` frame of the same size, **in place** on `base`. The L1 overlay (which
/// has transparent background where it drew nothing) lands on top of L0 wherever
/// its alpha is non-zero, so the single output frame carries both lanes.
///
/// Pure CPU + no vello — the *composite* is testable on its own and runs even when
/// the L1 render itself was unavailable (then `over` is simply absent).
pub fn composite_over(base: &mut Frame, over: &Frame) {
    if base.width != over.width || base.height != over.height {
        return;
    }
    for (b, o) in base.rgba.chunks_exact_mut(4).zip(over.rgba.chunks_exact(4)) {
        let sa = o[3] as f32 / 255.0;
        if sa <= 0.0 {
            continue;
        }
        let inv = 1.0 - sa;
        // straight-over: out = src*sa + dst*(1-sa) for colour, alpha = sa + da*(1-sa).
        for k in 0..3 {
            let src = o[k] as f32 / 255.0;
            let dst = b[k] as f32 / 255.0;
            b[k] = ((src * sa + dst * inv) * 255.0).round().clamp(0.0, 255.0) as u8;
        }
        let da = b[3] as f32 / 255.0;
        b[3] = ((sa + da * inv) * 255.0).round().clamp(0.0, 255.0) as u8;
    }
}

/// A headless wgpu-29 device for vello **and the GPU turn** (field order = drop order:
/// the device goes first, the turn last, as in `gputurn::probe::Probe`).
struct VelloDevice {
    device: wgpu::Device,
    queue: wgpu::Queue,
    _turn: crate::render::gputurn::Turn,
}

/// The wgpu-29 twin of `gputurn::probe::open(who, OnSoftware::Skip, …)`: take the
/// turn, enumerate, rank with THE adapter policy (`AdapterSelection::decide`, software
/// last, `FACETT_GPU_ADAPTER` honoured), skip a software rasteriser unless
/// `$FACETT_ALLOW_SOFTWARE_GPU`, and open the device with the adapter's default limits
/// (vello's compute pipeline needs a few above the downlevel floor).
fn open_vello_device(who: &str) -> Option<VelloDevice> {
    use crate::render::adapter::{
        env_override, is_software_rasteriser, record_selection, AdapterFacts, AdapterKind,
        AdapterSelection,
    };
    let turn = crate::render::gputurn::hold();
    let backends = if cfg!(target_arch = "wasm32") {
        wgpu::Backends::BROWSER_WEBGPU
    } else {
        wgpu::Backends::PRIMARY
    };
    let instance = wgpu::Instance::default();
    let adapters = pollster::block_on(instance.enumerate_adapters(backends));
    let facts: Vec<AdapterFacts> = adapters
        .iter()
        .map(|a| {
            let info = a.get_info();
            AdapterFacts {
                name: info.name.clone(),
                vendor: info.vendor,
                device: info.device,
                kind: match info.device_type {
                    wgpu::DeviceType::DiscreteGpu => AdapterKind::DiscreteGpu,
                    wgpu::DeviceType::IntegratedGpu => AdapterKind::IntegratedGpu,
                    wgpu::DeviceType::VirtualGpu => AdapterKind::VirtualGpu,
                    wgpu::DeviceType::Cpu => AdapterKind::Cpu,
                    wgpu::DeviceType::Other => AdapterKind::Other,
                },
                backend: format!("{:?}", info.backend),
                driver: info.driver.clone(),
            }
        })
        .collect();
    let sel = AdapterSelection::decide(&facts, env_override().as_deref())?;
    let adapter = adapters[sel.index].clone();
    record_selection(sel.clone());
    let named = sel.chosen.describe();
    eprintln!("[{who}] adapter — {named}");
    let software_allowed = matches!(
        std::env::var("FACETT_ALLOW_SOFTWARE_GPU").ok().as_deref(),
        Some("1" | "true" | "on" | "yes")
    );
    if is_software_rasteriser(&sel.chosen) && !software_allowed {
        eprintln!(
            "[{who}] SKIPPING — {named} is a SOFTWARE rasteriser. Whatever this rendered \
             would be about llvmpipe, not about the GPU lane. Set \
             FACETT_ALLOW_SOFTWARE_GPU=1 if this host really has no card."
        );
        return None;
    }
    let required_limits = wgpu::Limits::default().using_resolution(adapter.limits());
    let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
        label: Some(who),
        required_features: wgpu::Features::empty(),
        required_limits,
        memory_hints: wgpu::MemoryHints::default(),
        experimental_features: wgpu::ExperimentalFeatures::disabled(),
        trace: wgpu::Trace::Off,
    }))
    .ok()?;
    Some(VelloDevice { device, queue, _turn: turn })
}

/// Render an [`L1Overlay`] **headless** to a straight-RGBA8 [`Frame`] of
/// `width × height` over a fully transparent background, using vello on a throwaway
/// wgpu device. `None` when no usable GPU adapter exists (a CPU-only / sandboxed
/// box) — the caller then keeps the L0 frame alone (L1 is never required).
///
/// The texture is `Rgba8Unorm` + `STORAGE_BINDING` (what `render_to_texture`
/// requires) + `COPY_SRC` (for read-back). Premultiplied vello output is
/// un-premultiplied on read-back so it matches the straight-RGBA8 L0 [`Frame`].
pub fn render_overlay(overlay: &L1Overlay, width: u32, height: u32) -> Option<Frame> {
    if width == 0 || height == 0 {
        return Some(Frame { width, height, rgba: vec![0u8; (width * height * 4) as usize] });
    }
    pollster::block_on(render_overlay_async(overlay, width, height))
}

async fn render_overlay_async(overlay: &L1Overlay, width: u32, height: u32) -> Option<Frame> {
    use vello::{AaConfig, RenderParams, Renderer, RendererOptions};

    // THE GPU TURN + the adapter policy, via the one bring-up writer
    // (`facett_core::render::gputurn::probe`). `OnSoftware::Skip`, not `Refuse`: this
    // returns `Option`, L1 is never required, and both callers below already fall back
    // to the L0 frame on `None` — so a CPU-only host keeps working and a pixel test on a
    // saturated box skips loudly instead of measuring llvmpipe.
    let gpu = open_vello_device("l1-vello-headless")?;
    let (device, queue) = (&gpu.device, &gpu.queue);

    let mut renderer = Renderer::new(
        &device,
        RendererOptions {
            use_cpu: false,
            antialiasing_support: vello::AaSupport::area_only(),
            num_init_threads: None,
            pipeline_cache: None,
        },
    )
    .ok()?;

    let scene = overlay.to_scene();

    let target = device.create_texture(&wgpu::TextureDescriptor {
        label: Some("l1-vello-target"),
        size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
        mip_level_count: 1,
        sample_count: crate::render::gpu::NO_MSAA_SAMPLES,
        dimension: wgpu::TextureDimension::D2,
        format: wgpu::TextureFormat::Rgba8Unorm,
        usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC,
        view_formats: &[],
    });
    let view = target.create_view(&wgpu::TextureViewDescriptor::default());

    // Transparent base so only what vello drew has non-zero alpha → the overlay
    // composites cleanly over L0.
    let params = RenderParams {
        base_color: peniko::Color::TRANSPARENT,
        width,
        height,
        antialiasing_method: AaConfig::Area,
    };
    renderer
        .render_to_texture(&device, &queue, &scene, &view, &params)
        .ok()?;

    // Read back: copy the texture into a row-padded buffer, map, unpad, unpremul.
    let bytes_per_pixel = 4u32;
    let unpadded = width * bytes_per_pixel;
    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
    let padded = unpadded.div_ceil(align) * align;
    let readback = device.create_buffer(&wgpu::BufferDescriptor {
        label: Some("l1-vello-readback"),
        size: (padded * height) as u64,
        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
        mapped_at_creation: false,
    });
    let mut encoder =
        device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("l1-vello-enc") });
    encoder.copy_texture_to_buffer(
        wgpu::TexelCopyTextureInfo {
            texture: &target,
            mip_level: 0,
            origin: wgpu::Origin3d::ZERO,
            aspect: wgpu::TextureAspect::All,
        },
        wgpu::TexelCopyBufferInfo {
            buffer: &readback,
            layout: wgpu::TexelCopyBufferLayout {
                offset: 0,
                bytes_per_row: Some(padded),
                rows_per_image: Some(height),
            },
        },
        wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
    );
    queue.submit(Some(encoder.finish()));

    let slice = readback.slice(..);
    let (tx, rx) = std::sync::mpsc::channel();
    slice.map_async(wgpu::MapMode::Read, move |r| {
        let _ = tx.send(r);
    });
    device.poll(wgpu::PollType::wait_indefinitely()).ok()?;
    rx.recv().ok()?.ok()?;

    let data = slice.get_mapped_range();
    let mut rgba = Vec::with_capacity((width * height * 4) as usize);
    for row in 0..height {
        let start = (row * padded) as usize;
        // vello writes PREMULTIPLIED Rgba8Unorm; un-premultiply to straight RGBA8
        // so it matches the L0 frame convention before compositing.
        for px in data[start..start + unpadded as usize].chunks_exact(4) {
            let a = px[3];
            if a == 0 {
                rgba.extend_from_slice(&[0, 0, 0, 0]);
            } else {
                let un = |c: u8| ((c as u32 * 255 + (a as u32) / 2) / a as u32).min(255) as u8;
                rgba.extend_from_slice(&[un(px[0]), un(px[1]), un(px[2]), a]);
            }
        }
    }
    drop(data);
    readback.unmap();

    Some(Frame { width, height, rgba })
}

/// **The L0 + L1 compose** — render the L1 `overlay` headlessly and source-over it
/// onto the given L0 `base` frame, returning the single combined frame. When no
/// GPU is available (`render_overlay` → `None`) the L0 frame is returned unchanged
/// — L1 is never required, so the seam degrades, it does not fail.
///
/// Returns `(frame, l1_drawn)`: `l1_drawn` is whether the L1 lane actually ran
/// (a real GPU produced the overlay) — the observable "did L1 light up" signal the
/// demo surfaces in `state_json` and the proof test asserts.
pub fn compose_l0_l1(mut base: Frame, overlay: &L1Overlay) -> (Frame, bool) {
    let (w, h) = (base.width, base.height);
    match render_overlay(overlay, w, h) {
        Some(over) => {
            composite_over(&mut base, &over);
            (base, true)
        }
        None => (base, false),
    }
}

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

    /// A representative overlay covering all three L1 beauty kinds, drawn well
    /// inside a `w × h` rect (so a "contained in rect" oracle is meaningful).
    fn sample_overlay(w: u32, h: u32) -> L1Overlay {
        let pad = 0.15;
        let (x0, y0) = (w as f32 * pad, h as f32 * pad);
        let (x1, y1) = (w as f32 * (1.0 - pad), h as f32 * (1.0 - pad));
        L1Overlay {
            gradient_rects: vec![GradientRect {
                min: [x0, y0],
                max: [x1, (y0 + y1) * 0.5],
                corner: 12.0,
                color0: [0.10, 0.45, 0.95, 1.0],
                color1: [0.95, 0.30, 0.55, 1.0],
            }],
            curves: vec![Curve {
                p0: [x0, y1],
                c0: [(x0 + x1) * 0.5, y0],
                c1: [(x0 + x1) * 0.5, y1 + 30.0],
                p1: [x1, (y0 + y1) * 0.5],
                width: 6.0,
                color: [0.95, 0.85, 0.20, 1.0],
            }],
            glows: vec![Glow {
                min: [(x0 + x1) * 0.5 - 30.0, (y0 + y1) * 0.5 - 30.0],
                max: [(x0 + x1) * 0.5 + 30.0, (y0 + y1) * 0.5 + 30.0],
                corner: 16.0,
                color: [0.30, 0.95, 0.70, 1.0],
                std_dev: 8.0,
            }],
        }
    }

    /// INJECT-ASSERT (pure, no GPU): the overlay encodes the right number of vello
    /// scene ops, and `len()`/`is_empty()` report the real primitive count. The
    /// scene's encoding carries non-zero data once primitives are added (proof the
    /// build did real work, not "didn't panic").
    #[test]
    fn overlay_builds_a_nonempty_scene() {
        let empty = L1Overlay::default();
        assert!(empty.is_empty());
        assert_eq!(empty.to_scene().encoding().n_paths, 0, "empty overlay → empty scene");

        let ov = sample_overlay(400, 300);
        assert_eq!(ov.len(), 3, "one gradient + one curve + one glow");
        assert!(!ov.is_empty());
        let scene = ov.to_scene();
        // Gradient fill + curve stroke each encode a path; the blurred-rect glow
        // encodes a path too — so the scene carries real path data.
        assert!(scene.encoding().n_paths >= 2, "the beauty primitives encoded paths, got {}", scene.encoding().n_paths);
    }

    /// INJECT-ASSERT (pure, no GPU): `composite_over` is source-over correct — an
    /// opaque overlay pixel replaces the base, a transparent one leaves it, and a
    /// half-alpha one blends. Return value (the mutated base frame) proves it.
    #[test]
    fn composite_over_is_source_over_correct() {
        let mut base = Frame { width: 3, height: 1, rgba: vec![0, 0, 0, 255, /*p1*/ 10, 20, 30, 255, /*p2*/ 0, 0, 0, 255] };
        let over = Frame {
            width: 3,
            height: 1,
            // p0: opaque red over black → red. p1: transparent → leave base.
            // p2: half-alpha white over black → ~128 grey.
            rgba: vec![255, 0, 0, 255, /*p1*/ 99, 99, 99, 0, /*p2*/ 255, 255, 255, 128],
        };
        composite_over(&mut base, &over);
        assert_eq!(&base.rgba[0..4], &[255, 0, 0, 255], "opaque over → source");
        assert_eq!(&base.rgba[4..8], &[10, 20, 30, 255], "transparent over → base unchanged");
        let g = base.rgba[8];
        assert!((g as i32 - 128).abs() <= 2, "half-alpha white over black ≈ 128, got {g}");
    }

    /// INJECT-ASSERT (the L1 render lane, GPU): render the sample overlay headless
    /// and assert it (a) actually lit pixels and (b) every lit pixel is inside the
    /// rect (the clip oracle). SKIPPED gracefully (asserted as a no-op) when no GPU
    /// adapter is present, so a CPU-only box still passes — L1 is never required.
    #[test]
    fn l1_overlay_renders_and_is_contained_in_rect() {
        let (w, h) = (256u32, 192u32);
        let ov = sample_overlay(w, h);
        match render_overlay(&ov, w, h) {
            None => {
                // No GPU on this box — L1 degrades, not fails (the spec's promise).
                eprintln!("no GPU adapter — L1 render skipped (degraded, not failed)");
            }
            Some(frame) => {
                assert_eq!(frame.rgba.len(), (w * h * 4) as usize);
                let lit = frame.lit_px();
                assert!(lit > 200, "L1 overlay lit real pixels, got {lit}");
                // Containment: no lit pixel outside the [0,w)×[0,h) frame is trivially
                // true; the meaningful oracle is the lit pixels sit inside the padded
                // content band — assert the outer 1-px border ring is fully clear.
                let at = |x: u32, y: u32| frame.rgba[((y * w + x) * 4 + 3) as usize];
                for x in 0..w {
                    assert_eq!(at(x, 0), 0, "top edge clear");
                    assert_eq!(at(x, h - 1), 0, "bottom edge clear");
                }
                for y in 0..h {
                    assert_eq!(at(0, y), 0, "left edge clear");
                    assert_eq!(at(w - 1, y), 0, "right edge clear");
                }
            }
        }
    }
}