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

nordisk/facett

facett-graphview/src/gpu.rs

//! The **live GPU rasterizer** (`vello` on wgpu 29) — the fast path for large graphs.
//! The *same* display list the CPU path builds ([`crate::scene::build`]) is lowered into
//! a [`vello::Scene`] and submitted to vello's GPU compute pipeline on a wgpu 29 device
//! (exactly the wgpu the egui 0.34 stack pins — vello 0.9 ↔ wgpu 29 ↔ egui-wgpu 0.34, so
//! no version war inside a facett host).
//!
//! Two ways a host consumes this (TODO(#17)):
//!
//! 1. **Readback to RGBA** — [`GpuRenderer::render_to_rgba`] renders the scene into an
//!    `Rgba8Unorm` storage texture then copies it back to a straight-RGBA8 [`Rendered`],
//!    byte-for-byte the same contract [`crate::cpu::render`] returns. This is what the
//!    `GpuVello` arm of [`crate::render_to_rgba`] drives (via [`render_gpu`]).
//! 2. **egui_wgpu paint callback** — a host that already owns an egui `RenderState`
//!    builds a renderer on egui's device with [`GpuRenderer::with_device`], renders the
//!    scene into a vello storage texture with [`GpuRenderer::render_scene_to_view`], then
//!    blits that texture into egui's surface with [`wgpu::util::TextureBlitter`] inside an
//!    `egui_wgpu::CallbackTrait::paint`. graphview stays egui_wgpu-free (a headless engine
//!    crate); the host owns the ~10-line blit. See the module test + the wiring note below.
//!
//! No usable adapter (headless CI, the `oden` build box) → construction returns `None` and
//! the caller falls back to the proven CPU reference. The AMD path is reached through
//! **Vulkan (Mesa RADV)**: `wgpu::Instance::default()` enables all backends incl. VULKAN,
//! so no AMD-specific code is needed — do NOT add ROCm (that is HIP compute, not graphics).

#![cfg(feature = "gpu")]

// vello 0.9 renders on wgpu 29; the rest of facett (egui 0.36) is on wgpu 30. This
// module opens its OWN device, so it takes vello's wgpu under the workspace alias
// `wgpu29` and every `wgpu::` below names that one. Drop the alias when vello ships
// on wgpu 30.
use wgpu29 as wgpu;

use std::sync::{Mutex, OnceLock};

use crate::cpu::Rendered;
use crate::model::{Camera, Color, Decorations, GraphModel};
use crate::scene::{Prim, Seg};

/// Probe wgpu for a usable GPU adapter — the input to [`crate::backend::decide`].
/// Returns `true` if a wgpu device can actually be created (not just an adapter
/// enumerated). Cheap to call once at host start-up.
pub fn probe_usable_gpu() -> bool {
    let instance = wgpu::Instance::default();
    let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
        power_preference: wgpu::PowerPreference::HighPerformance,
        force_fallback_adapter: false,
        compatible_surface: None,
    }));
    match adapter {
        Ok(adapter) => pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
            label: Some("facett-graphview probe"),
            required_features: wgpu::Features::empty(),
            required_limits: wgpu::Limits::downlevel_defaults(),
            memory_hints: wgpu::MemoryHints::Performance,
            experimental_features: wgpu::ExperimentalFeatures::default(),
            trace: wgpu::Trace::Off,
        }))
        .is_ok(),
        Err(_) => false,
    }
}

/// Lower the shared display list into a [`vello::Scene`] — the GPU twin of
/// [`crate::cpu`]'s paint step. Geometry comes from the SAME [`crate::scene::build`] the
/// CPU path uses, so the two backends paint pixel-for-pixel the same picture (the only
/// difference is the rasterizer's anti-aliasing). The page background is NOT in the scene:
/// it is supplied to vello as `RenderParams::base_color` at submit time.
pub fn build_scene(model: &GraphModel, decorations: &Decorations, camera: &Camera) -> vello::Scene {
    use vello::kurbo::{Affine, BezPath, Cap, Join, PathEl, Point, RoundedRect, Stroke};
    use vello::peniko::{Color as VColor, Fill};

    fn vcolor(c: Color) -> VColor {
        VColor::from_rgba8(c.r, c.g, c.b, c.a)
    }
    fn bezpath(segs: &[Seg]) -> BezPath {
        let mut p = BezPath::new();
        for s in segs {
            match *s {
                Seg::MoveTo(x, y) => p.push(PathEl::MoveTo(Point::new(x, y))),
                Seg::LineTo(x, y) => p.push(PathEl::LineTo(Point::new(x, y))),
                Seg::CurveTo(c1x, c1y, c2x, c2y, x, y) => {
                    p.push(PathEl::CurveTo(Point::new(c1x, c1y), Point::new(c2x, c2y), Point::new(x, y)))
                }
            }
        }
        p
    }

    let mut scene = vello::Scene::new();
    for prim in crate::scene::build(model, decorations, camera) {
        match prim {
            Prim::FillRoundRect { x0, y0, x1, y1, radius, color } => {
                let rect = RoundedRect::new(x0, y0, x1, y1, radius);
                scene.fill(Fill::NonZero, Affine::IDENTITY, vcolor(color), None, &rect);
            }
            Prim::StrokeRoundRect { x0, y0, x1, y1, radius, width, color } => {
                let rect = RoundedRect::new(x0, y0, x1, y1, radius);
                let stroke = Stroke::new(width).with_join(Join::Round);
                scene.stroke(&stroke, Affine::IDENTITY, vcolor(color), None, &rect);
            }
            Prim::StrokePath { segs, width, color } => {
                let path = bezpath(&segs);
                let stroke = Stroke::new(width).with_caps(Cap::Round).with_join(Join::Round);
                scene.stroke(&stroke, Affine::IDENTITY, vcolor(color), None, &path);
            }
        }
    }
    scene
}

/// A live GPU renderer: owns (or adopts) a wgpu `Device`/`Queue`, a [`vello::Renderer`],
/// and a lazily-(re)sized `Rgba8Unorm` render target + readback buffer. Construct once and
/// reuse across frames — creating the device and compiling vello's shaders is expensive.
pub struct GpuRenderer {
    device: wgpu::Device,
    queue: wgpu::Queue,
    renderer: vello::Renderer,
    target: Option<Target>,
}

/// The `w × h` render target: an `Rgba8Unorm` `STORAGE_BINDING | COPY_SRC` texture vello
/// writes into, plus the padded readback buffer + its aligned row stride.
struct Target {
    width: u32,
    height: u32,
    texture: wgpu::Texture,
    view: wgpu::TextureView,
    readback: wgpu::Buffer,
    padded_bytes_per_row: u32,
}

impl GpuRenderer {
    /// Construct a self-owned renderer: request a HighPerformance adapter (Vulkan/RADV on
    /// AMD) via the default instance, create a device + a [`vello::Renderer`]. Returns
    /// `None` when no usable adapter exists (headless CI) or shader init fails — the caller
    /// then falls back to the CPU reference.
    pub fn new() -> Option<GpuRenderer> {
        let instance = wgpu::Instance::default();
        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
            power_preference: wgpu::PowerPreference::HighPerformance,
            force_fallback_adapter: false,
            compatible_surface: None,
        }))
        .ok()?;
        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
            label: Some("facett-graphview gpu renderer"),
            required_features: wgpu::Features::empty(),
            // The adapter's own advertised limits — always satisfiable, and enough headroom
            // for vello's compute pipeline (downlevel_defaults can be too small for it).
            required_limits: adapter.limits(),
            memory_hints: wgpu::MemoryHints::Performance,
            experimental_features: wgpu::ExperimentalFeatures::disabled(),
            trace: wgpu::Trace::Off,
        }))
        .ok()?;
        Self::with_device(device, queue)
    }

    /// Adopt an existing wgpu device/queue (e.g. an egui `RenderState`'s) and build a
    /// [`vello::Renderer`] on it — the entry a host uses for the **egui_wgpu paint-callback**
    /// path so vello and egui share one device. Returns `None` if vello shader init fails.
    pub fn with_device(device: wgpu::Device, queue: wgpu::Queue) -> Option<GpuRenderer> {
        let renderer = vello::Renderer::new(
            &device,
            vello::RendererOptions {
                use_cpu: false,
                // Area (analytic) AA — the closest match to vello_cpu's analytic AA, and it
                // avoids compiling the MSAA pipeline permutations we never ask for.
                antialiasing_support: vello::AaSupport::area_only(),
                num_init_threads: std::num::NonZeroUsize::new(1),
                pipeline_cache: None,
            },
        )
        .ok()?;
        Some(GpuRenderer { device, queue, renderer, target: None })
    }

    /// Borrow the underlying wgpu device (e.g. to create the storage texture a host blits
    /// from in its egui_wgpu callback).
    pub fn device(&self) -> &wgpu::Device {
        &self.device
    }

    /// (Re)create the `w × h` `Rgba8Unorm` storage target + readback buffer if the size
    /// changed. Idempotent per size.
    fn ensure_target(&mut self, width: u32, height: u32) {
        if self.target.as_ref().map(|t| (t.width, t.height)) == Some((width, height)) {
            return;
        }
        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
            label: Some("facett-graphview vello target"),
            size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
            mip_level_count: 1,
            sample_count: facett_core::render::gpu::NO_MSAA_SAMPLES,
            dimension: wgpu::TextureDimension::D2,
            format: wgpu::TextureFormat::Rgba8Unorm,
            // vello writes via compute → STORAGE_BINDING; COPY_SRC so we can read it back.
            usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC,
            view_formats: &[],
        });
        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
        let padded_bytes_per_row = (width * 4).div_ceil(align) * align;
        let readback = self.device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("facett-graphview readback"),
            size: (padded_bytes_per_row * height) as u64,
            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
            mapped_at_creation: false,
        });
        self.target = Some(Target { width, height, texture, view, readback, padded_bytes_per_row });
    }

    /// Render `scene` into `view` (which MUST be an `Rgba8Unorm` `STORAGE_BINDING` texture
    /// of `width × height`) with `background` as the base colour. The raw vello submit,
    /// exposed so an egui_wgpu `CallbackTrait::paint` can render into a vello storage
    /// texture before blitting it to egui's surface. Returns `false` on a vello error.
    pub fn render_scene_to_view(
        &mut self,
        scene: &vello::Scene,
        view: &wgpu::TextureView,
        width: u32,
        height: u32,
        background: Color,
    ) -> bool {
        let base = vello::peniko::Color::from_rgba8(background.r, background.g, background.b, background.a);
        self.renderer
            .render_to_texture(
                &self.device,
                &self.queue,
                scene,
                view,
                &vello::RenderParams {
                    base_color: base,
                    width,
                    height,
                    antialiasing_method: vello::AaConfig::Area,
                },
            )
            .is_ok()
    }

    /// Build the shared scene, render it on the GPU, and read the pixels back to a straight
    /// RGBA8 [`Rendered`] — the same pixel contract [`crate::cpu::render`] returns. `None`
    /// on a zero-sized frame or a GPU error.
    pub fn render_to_rgba(
        &mut self,
        model: &GraphModel,
        decorations: &Decorations,
        camera: &Camera,
        width: u32,
        height: u32,
        background: Color,
    ) -> Option<Rendered> {
        if width == 0 || height == 0 {
            return None;
        }
        self.ensure_target(width, height);
        let scene = build_scene(model, decorations, camera);

        // Submit the vello pipeline into the storage target. (Clone the view — a cheap
        // Arc handle — to release the borrow on `self.target` before the &mut self call.)
        let view = self.target.as_ref().unwrap().view.clone();
        if !self.render_scene_to_view(&scene, &view, width, height, background) {
            return None;
        }

        // Copy the storage texture into the padded readback buffer, then map + unpad.
        let target = self.target.as_ref().unwrap();
        let mut encoder = self
            .device
            .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("facett-graphview readback-enc") });
        encoder.copy_texture_to_buffer(
            wgpu::TexelCopyTextureInfo {
                texture: &target.texture,
                mip_level: 0,
                origin: wgpu::Origin3d::ZERO,
                aspect: wgpu::TextureAspect::All,
            },
            wgpu::TexelCopyBufferInfo {
                buffer: &target.readback,
                layout: wgpu::TexelCopyBufferLayout {
                    offset: 0,
                    bytes_per_row: Some(target.padded_bytes_per_row),
                    rows_per_image: Some(height),
                },
            },
            wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
        );
        self.queue.submit(Some(encoder.finish()));

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

        let data = slice.get_mapped_range();
        let unpadded = (width * 4) as usize;
        let mut rgba = Vec::with_capacity((width * height * 4) as usize);
        for row in 0..height {
            let start = (row * target.padded_bytes_per_row) as usize;
            rgba.extend_from_slice(&data[start..start + unpadded]);
        }
        drop(data);
        target.readback.unmap();

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

/// The process-wide cached renderer, built once. A **static** (never dropped at thread or
/// process exit) on purpose: a `thread_local` would run the wgpu `Device`'s destructor
/// during TLS teardown, and wgpu's own Drop reaches into already-destroyed thread-local
/// state → an `AccessError` panic. A leaked-for-life static device is the standard wgpu
/// cache pattern and sidesteps that entirely. `None` inside the `Mutex` = no usable adapter
/// on this box (built once; a missing GPU does not appear mid-run).
fn cached_renderer() -> &'static Mutex<Option<GpuRenderer>> {
    static GPU: OnceLock<Mutex<Option<GpuRenderer>>> = OnceLock::new();
    GPU.get_or_init(|| Mutex::new(GpuRenderer::new()))
}

/// The `GpuVello`-arm entry: render `model` on the GPU and read it back to RGBA. `None`
/// when this box has no usable adapter (or the GPU render errors) — [`crate::render_to_rgba`]
/// then falls back to the CPU reference cleanly. Reuses the cached [`GpuRenderer`] so the
/// device + vello shaders are built once, not per frame.
pub fn render_gpu(
    model: &GraphModel,
    decorations: &Decorations,
    camera: &Camera,
    width: u32,
    height: u32,
    background: Color,
) -> Option<Rendered> {
    let mut guard = cached_renderer().lock().ok()?;
    guard
        .as_mut()?
        .render_to_rgba(model, decorations, camera, width, height, background)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{Color, GraphEdge, GraphModel, GraphNode, Pos};

    fn sample() -> GraphModel {
        GraphModel {
            nodes: vec![
                GraphNode { id: "a".into(), label: "a".into(), fill: Color::rgb(60, 90, 160), stroke: Color::WHITE, pos: Pos::new(0.0, 0.0) },
                GraphNode { id: "b".into(), label: "b".into(), fill: Color::rgb(60, 140, 90), stroke: Color::WHITE, pos: Pos::new(300.0, 40.0) },
            ],
            edges: vec![GraphEdge { from: "a".into(), to: "b".into(), color: Color::rgb(200, 200, 220), dashed: false, label: None }],
        }
    }

    /// The seam must build regardless of hardware: `build_scene` lowers the shared display
    /// list into a vello Scene with no device needed.
    #[test]
    fn build_scene_lowers_shared_geometry() {
        let m = sample();
        let cam = Camera::fit(&m, 320.0, 200.0, 20.0);
        let scene = build_scene(&m, &Decorations::default(), &cam);
        // A non-empty scene encodes path-tag bytes; an empty graph would encode none.
        assert!(!scene.encoding().is_empty(), "scene encodes the chips + edge");
    }

    /// HONEST hardware contract: construction + render either succeed (a real adapter, e.g.
    /// t14s's AMD) or cleanly return `None` (this headless box). Never panics; a produced
    /// frame is exactly `w*h*4` straight-RGBA8. This is the test the task asks for — it
    /// proves the wiring + the absent-adapter fallback without requiring a GPU here.
    #[test]
    fn construct_and_render_or_fall_back() {
        // THE GPU TURN (facett_core::render::gputurn): `GpuRenderer::new` and the cached
        // renderer behind `render_gpu` each bring a device up; neither takes the turn itself.
        let _gpu = facett_core::render::gputurn::hold();
        let m = sample();
        let (w, h) = (128u32, 96u32);
        let cam = Camera::fit(&m, w as f32, h as f32, 12.0);
        let bg = Color::rgb(18, 20, 28);

        match GpuRenderer::new() {
            Some(mut r) => {
                // A live adapter exists → the readback path must yield a full, non-blank frame.
                let frame = r
                    .render_to_rgba(&m, &Decorations::default(), &cam, w, h, bg)
                    .expect("live GPU render");
                assert_eq!(frame.rgba.len(), (w * h * 4) as usize);
                let lit = frame.rgba.as_chunks::<4>().0.iter().filter(|p| p[3] != 0).count();
                assert!(lit > 0, "GPU drew a real frame");
            }
            None => {
                // No usable adapter (this box) → the arm entry falls back, returning None.
                assert!(
                    render_gpu(&m, &Decorations::default(), &cam, w, h, bg).is_none(),
                    "no adapter → render_gpu is None so render_to_rgba uses the CPU reference"
                );
            }
        }
    }
}