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, ¶ms)
.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");
}
}
}
}
}