use super::Frame;
pub use vello::{self, peniko, peniko::kurbo};
#[derive(Clone, Debug, Default)]
pub struct L1Overlay {
pub gradient_rects: Vec<GradientRect>,
pub curves: Vec<Curve>,
pub glows: Vec<Glow>,
}
#[derive(Clone, Copy, Debug)]
pub struct GradientRect {
pub min: [f32; 2],
pub max: [f32; 2],
pub corner: f32,
pub color0: [f32; 4],
pub color1: [f32; 4],
}
#[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],
}
#[derive(Clone, Copy, Debug)]
pub struct Glow {
pub min: [f32; 2],
pub max: [f32; 2],
pub corner: f32,
pub color: [f32; 4],
pub std_dev: f32,
}
impl L1Overlay {
pub fn len(&self) -> usize {
self.gradient_rects.len() + self.curves.len() + self.glows.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
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();
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,
);
}
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);
}
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
}
}
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;
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;
}
}
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};
let instance = wgpu::Instance::default();
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: None,
})
.await
.ok()?;
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("l1-vello-headless"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default().using_resolution(adapter.limits()),
memory_hints: wgpu::MemoryHints::default(),
experimental_features: wgpu::ExperimentalFeatures::disabled(),
trace: wgpu::Trace::Off,
})
.await
.ok()?;
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: 1,
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());
let params = RenderParams {
base_color: peniko::Color::TRANSPARENT,
width,
height,
antialiasing_method: AaConfig::Area,
};
renderer
.render_to_texture(&device, &queue, &scene, &view, ¶ms)
.ok()?;
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;
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 })
}
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;
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,
}],
}
}
#[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();
assert!(scene.encoding().n_paths >= 2, "the beauty primitives encoded paths, got {}", scene.encoding().n_paths);
}
#[test]
fn composite_over_is_source_over_correct() {
let mut base = Frame { width: 3, height: 1, rgba: vec![0, 0, 0, 255, 10, 20, 30, 255, 0, 0, 0, 255] };
let over = Frame {
width: 3,
height: 1,
rgba: vec![255, 0, 0, 255, 99, 99, 99, 0, 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}");
}
#[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 => {
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}");
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");
}
}
}
}
}