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"
);
}
}
}
}