From d7a29ee1dc7eef30047f14449ee0353f0a55dc62 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Fri, 13 Mar 2026 18:52:37 -0400 Subject: [PATCH] Double CPU performance by using tiny-skia instead of vello CPU --- .../lightningbeam-core/Cargo.toml | 4 +- .../lightningbeam-core/src/renderer.rs | 488 +++++++++++++++++- .../lightningbeam-editor/src/panes/stage.rs | 96 +++- 3 files changed, 564 insertions(+), 24 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/Cargo.toml b/lightningbeam-ui/lightningbeam-core/Cargo.toml index a763e7b..7dea28c 100644 --- a/lightningbeam-ui/lightningbeam-core/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-core/Cargo.toml @@ -66,5 +66,7 @@ windows-sys = { version = "0.60", features = [ "Win32_System_Memory", ] } +[dependencies.tiny-skia] +version = "0.11" + [dev-dependencies] -tiny-skia = "0.11" diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 645de2c..94bd361 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -143,8 +143,11 @@ pub enum RenderedLayerType { pub struct RenderedLayer { /// The layer's unique identifier pub layer_id: Uuid, - /// Vello scene — only populated for `RenderedLayerType::Vector`. + /// Vello scene — only populated for `RenderedLayerType::Vector` in GPU mode. pub scene: Scene, + /// CPU-rendered pixmap — `Some` for `RenderedLayerType::Vector` in CPU mode, `None` otherwise. + /// When `Some`, `scene` is empty; the pixmap is uploaded directly to the GPU texture. + pub cpu_pixmap: Option, /// Layer opacity (0.0 to 1.0) pub opacity: f32, /// Blend mode for compositing @@ -161,6 +164,7 @@ impl RenderedLayer { Self { layer_id, scene: Scene::new(), + cpu_pixmap: None, opacity: 1.0, blend_mode: BlendMode::Normal, has_content: false, @@ -173,6 +177,7 @@ impl RenderedLayer { Self { layer_id, scene: Scene::new(), + cpu_pixmap: None, opacity, blend_mode, has_content: false, @@ -186,6 +191,7 @@ impl RenderedLayer { Self { layer_id, scene: Scene::new(), + cpu_pixmap: None, opacity, blend_mode: BlendMode::Normal, has_content, @@ -196,8 +202,10 @@ impl RenderedLayer { /// Result of rendering a document for compositing pub struct CompositeRenderResult { - /// Background scene (rendered separately for potential optimization) + /// Background scene — GPU mode only; empty in CPU mode. pub background: Scene, + /// CPU-rendered background pixmap — `Some` in CPU mode, `None` in GPU mode. + pub background_cpu: Option, /// Rendered layers in bottom-to-top order pub layers: Vec, /// Document dimensions @@ -271,6 +279,7 @@ pub fn render_document_for_compositing( let float_entry = RenderedLayer { layer_id: Uuid::nil(), // sentinel — not a real document layer scene: Scene::new(), + cpu_pixmap: None, opacity: 1.0, blend_mode: crate::gpu::BlendMode::Normal, has_content: !float_sel.pixels.is_empty(), @@ -290,6 +299,7 @@ pub fn render_document_for_compositing( CompositeRenderResult { background, + background_cpu: None, layers: rendered_layers, width: document.width, height: document.height, @@ -1191,6 +1201,480 @@ fn render_vector_layer( } } +// ============================================================================ +// CPU Render Path (tiny-skia) +// ============================================================================ +// +// When Vello's CPU renderer is too slow (fixed per-call overhead), we render +// vector layers to `tiny_skia::Pixmap` and upload via `queue.write_texture`. +// The GPU compositor pipeline (sRGB→linear, blend modes) is unchanged. + +/// Convert a kurbo `Affine` to a tiny-skia `Transform`. +/// +/// kurbo `as_coeffs()` → `[a, b, c, d, e, f]` where the matrix is: +/// ```text +/// | a c e | +/// | b d f | +/// | 0 0 1 | +/// ``` +/// tiny-skia `from_row(sx, ky, kx, sy, tx, ty)` fills the same layout. +fn affine_to_ts(affine: Affine) -> tiny_skia::Transform { + let [a, b, c, d, e, f] = affine.as_coeffs(); + tiny_skia::Transform::from_row(a as f32, b as f32, c as f32, d as f32, e as f32, f as f32) +} + +/// Convert a kurbo `BezPath` to a tiny-skia `Path`. Returns `None` if the path +/// produces no segments (tiny-skia requires at least one segment). +fn bezpath_to_ts(path: &kurbo::BezPath) -> Option { + use kurbo::PathEl; + let mut pb = tiny_skia::PathBuilder::new(); + for el in path.iter() { + match el { + PathEl::MoveTo(p) => pb.move_to(p.x as f32, p.y as f32), + PathEl::LineTo(p) => pb.line_to(p.x as f32, p.y as f32), + PathEl::QuadTo(p1, p2) => { + pb.quad_to(p1.x as f32, p1.y as f32, p2.x as f32, p2.y as f32) + } + PathEl::CurveTo(p1, p2, p3) => pb.cubic_to( + p1.x as f32, p1.y as f32, + p2.x as f32, p2.y as f32, + p3.x as f32, p3.y as f32, + ), + PathEl::ClosePath => pb.close(), + } + } + pb.finish() +} + +/// Build a tiny-skia `Paint` with a solid colour and optional opacity. +fn solid_paint(r: u8, g: u8, b: u8, a: u8, opacity: f32) -> tiny_skia::Paint<'static> { + let alpha = ((a as f32 / 255.0) * opacity * 255.0).round().clamp(0.0, 255.0) as u8; + let mut paint = tiny_skia::Paint::default(); + paint.set_color_rgba8(r, g, b, alpha); + paint.anti_alias = true; + paint +} + +/// Build a tiny-skia `Paint` with a gradient shader. +fn gradient_paint<'a>( + grad: &crate::gradient::ShapeGradient, + start: kurbo::Point, + end: kurbo::Point, + opacity: f32, +) -> Option> { + use crate::gradient::GradientType; + use tiny_skia::{Color, GradientStop, SpreadMode}; + + let spread_mode = match grad.extend { + crate::gradient::GradientExtend::Pad => SpreadMode::Pad, + crate::gradient::GradientExtend::Reflect => SpreadMode::Reflect, + crate::gradient::GradientExtend::Repeat => SpreadMode::Repeat, + }; + + let stops: Vec = grad.stops.iter().map(|s| { + let a = ((s.color.a as f32 / 255.0) * opacity * 255.0).round().clamp(0.0, 255.0) as u8; + GradientStop::new(s.position, Color::from_rgba8(s.color.r, s.color.g, s.color.b, a)) + }).collect(); + + let shader = match grad.kind { + GradientType::Linear => { + tiny_skia::LinearGradient::new( + tiny_skia::Point { x: start.x as f32, y: start.y as f32 }, + tiny_skia::Point { x: end.x as f32, y: end.y as f32 }, + stops, + spread_mode, + tiny_skia::Transform::identity(), + )? + } + GradientType::Radial => { + let mid = kurbo::Point::new((start.x + end.x) * 0.5, (start.y + end.y) * 0.5); + let dx = end.x - start.x; + let dy = end.y - start.y; + let radius = ((dx * dx + dy * dy).sqrt() * 0.5) as f32; + tiny_skia::RadialGradient::new( + tiny_skia::Point { x: mid.x as f32, y: mid.y as f32 }, + tiny_skia::Point { x: mid.x as f32, y: mid.y as f32 }, + radius, + stops, + spread_mode, + tiny_skia::Transform::identity(), + )? + } + }; + + let mut paint = tiny_skia::Paint::default(); + paint.shader = shader; + paint.anti_alias = true; + Some(paint) +} + +/// Render the document background to a CPU pixmap. +fn render_background_cpu( + document: &Document, + pixmap: &mut tiny_skia::PixmapMut<'_>, + base_transform: Affine, + draw_checkerboard: bool, +) { + let ts_transform = affine_to_ts(base_transform); + let bg_rect = tiny_skia::Rect::from_xywh( + 0.0, 0.0, document.width as f32, document.height as f32, + ); + let Some(bg_rect) = bg_rect else { return }; + + let bg = &document.background_color; + + // Draw checkerboard behind transparent backgrounds + if draw_checkerboard && bg.a < 255 { + // Build a 32×32 checkerboard pixmap (16×16 px light/dark squares) + // in document space — each square = 16 document units. + if let Some(mut checker) = tiny_skia::Pixmap::new(32, 32) { + let light = tiny_skia::Color::from_rgba8(204, 204, 204, 255); + let dark = tiny_skia::Color::from_rgba8(170, 170, 170, 255); + for py in 0u32..32 { + for px in 0u32..32 { + let is_light = ((px / 16) + (py / 16)) % 2 == 0; + let color = if is_light { light } else { dark }; + checker.pixels_mut()[(py * 32 + px) as usize] = + tiny_skia::PremultipliedColorU8::from_rgba( + (color.red() * 255.0) as u8, + (color.green() * 255.0) as u8, + (color.blue() * 255.0) as u8, + (color.alpha() * 255.0) as u8, + ).unwrap(); + } + } + let pattern = tiny_skia::Pattern::new( + checker.as_ref(), + tiny_skia::SpreadMode::Repeat, + tiny_skia::FilterQuality::Nearest, + 1.0, + tiny_skia::Transform::identity(), + ); + let mut paint = tiny_skia::Paint::default(); + paint.shader = pattern; + pixmap.fill_rect(bg_rect, &paint, ts_transform, None); + } + } + + // Draw the background colour + let alpha = bg.a; + let paint = solid_paint(bg.r, bg.g, bg.b, alpha, 1.0); + pixmap.fill_rect(bg_rect, &paint, ts_transform, None); +} + +/// Render a DCEL to a CPU pixmap. +fn render_dcel_cpu( + dcel: &crate::dcel::Dcel, + pixmap: &mut tiny_skia::PixmapMut<'_>, + transform: tiny_skia::Transform, + opacity: f32, + _document: &Document, + _image_cache: &mut ImageCache, +) { + // 1. Faces (fills) + for (i, face) in dcel.faces.iter().enumerate() { + if face.deleted || i == 0 { + continue; + } + if face.fill_color.is_none() && face.image_fill.is_none() && face.gradient_fill.is_none() { + continue; + } + + let face_id = crate::dcel::FaceId(i as u32); + let path = dcel.face_to_bezpath_with_holes(face_id); + let Some(ts_path) = bezpath_to_ts(&path) else { continue }; + + let fill_type = match face.fill_rule { + crate::shape::FillRule::NonZero => tiny_skia::FillRule::Winding, + crate::shape::FillRule::EvenOdd => tiny_skia::FillRule::EvenOdd, + }; + + let mut filled = false; + + // Gradient fill (takes priority over solid) + if let Some(ref grad) = face.gradient_fill { + let bbox: kurbo::Rect = vello::kurbo::Shape::bounding_box(&path); + let (start, end) = match (grad.start_world, grad.end_world) { + (Some((sx, sy)), Some((ex, ey))) => match grad.kind { + crate::gradient::GradientType::Linear => { + (kurbo::Point::new(sx, sy), kurbo::Point::new(ex, ey)) + } + crate::gradient::GradientType::Radial => { + let opp = kurbo::Point::new(2.0 * sx - ex, 2.0 * sy - ey); + (opp, kurbo::Point::new(ex, ey)) + } + }, + _ => gradient_bbox_endpoints(grad.angle, bbox), + }; + if let Some(paint) = gradient_paint(grad, start, end, opacity) { + pixmap.fill_path(&ts_path, &paint, fill_type, transform, None); + filled = true; + } + } + + // Image fill — not yet implemented for CPU renderer; fall through to solid or skip + // TODO: decode image to Pixmap and use as Pattern shader + + // Solid colour fill + if !filled { + if let Some(fc) = &face.fill_color { + let paint = solid_paint(fc.r, fc.g, fc.b, fc.a, opacity); + pixmap.fill_path(&ts_path, &paint, fill_type, transform, None); + } + } + } + + // 2. Edges (strokes) + for edge in &dcel.edges { + if edge.deleted { + continue; + } + if let (Some(stroke_color), Some(stroke_style)) = (&edge.stroke_color, &edge.stroke_style) { + let mut path = kurbo::BezPath::new(); + path.move_to(edge.curve.p0); + path.curve_to(edge.curve.p1, edge.curve.p2, edge.curve.p3); + let Some(ts_path) = bezpath_to_ts(&path) else { continue }; + + let paint = solid_paint(stroke_color.r, stroke_color.g, stroke_color.b, stroke_color.a, opacity); + let stroke = tiny_skia::Stroke { + width: stroke_style.width as f32, + line_cap: match stroke_style.cap { + crate::shape::Cap::Butt => tiny_skia::LineCap::Butt, + crate::shape::Cap::Round => tiny_skia::LineCap::Round, + crate::shape::Cap::Square => tiny_skia::LineCap::Square, + }, + line_join: match stroke_style.join { + crate::shape::Join::Miter => tiny_skia::LineJoin::Miter, + crate::shape::Join::Round => tiny_skia::LineJoin::Round, + crate::shape::Join::Bevel => tiny_skia::LineJoin::Bevel, + }, + miter_limit: stroke_style.miter_limit as f32, + ..Default::default() + }; + pixmap.stroke_path(&ts_path, &paint, &stroke, transform, None); + } + } +} + +/// Render a vector layer to a CPU pixmap. +fn render_vector_layer_cpu( + document: &Document, + time: f64, + layer: &crate::layer::VectorLayer, + pixmap: &mut tiny_skia::PixmapMut<'_>, + base_transform: Affine, + parent_opacity: f64, + image_cache: &mut ImageCache, +) { + let layer_opacity = parent_opacity * layer.layer.opacity; + + for clip_instance in &layer.clip_instances { + let group_end_time = document.vector_clips.get(&clip_instance.clip_id) + .filter(|vc| vc.is_group) + .map(|_| { + let frame_duration = 1.0 / document.framerate; + layer.group_visibility_end(&clip_instance.id, clip_instance.timeline_start, frame_duration) + }); + render_clip_instance_cpu( + document, time, clip_instance, layer_opacity, pixmap, base_transform, + &layer.layer.animation_data, image_cache, group_end_time, + ); + } + + if let Some(dcel) = layer.dcel_at_time(time) { + render_dcel_cpu(dcel, pixmap, affine_to_ts(base_transform), layer_opacity as f32, document, image_cache); + } +} + +/// Render a clip instance (and its nested layers) to a CPU pixmap. +fn render_clip_instance_cpu( + document: &Document, + time: f64, + clip_instance: &crate::clip::ClipInstance, + parent_opacity: f64, + pixmap: &mut tiny_skia::PixmapMut<'_>, + base_transform: Affine, + animation_data: &crate::animation::AnimationData, + image_cache: &mut ImageCache, + group_end_time: Option, +) { + let Some(vector_clip) = document.vector_clips.get(&clip_instance.clip_id) else { return }; + + let clip_time = if vector_clip.is_group { + let end = group_end_time.unwrap_or(clip_instance.timeline_start); + if time < clip_instance.timeline_start || time >= end { return; } + 0.0 + } else { + let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration); + let Some(t) = clip_instance.remap_time(time, clip_dur) else { return }; + t + }; + + let transform = &clip_instance.transform; + let x = animation_data.eval(&crate::animation::AnimationTarget::Object { id: clip_instance.id, property: TransformProperty::X }, time, transform.x); + let y = animation_data.eval(&crate::animation::AnimationTarget::Object { id: clip_instance.id, property: TransformProperty::Y }, time, transform.y); + let rotation = animation_data.eval(&crate::animation::AnimationTarget::Object { id: clip_instance.id, property: TransformProperty::Rotation }, time, transform.rotation); + let scale_x = animation_data.eval(&crate::animation::AnimationTarget::Object { id: clip_instance.id, property: TransformProperty::ScaleX }, time, transform.scale_x); + let scale_y = animation_data.eval(&crate::animation::AnimationTarget::Object { id: clip_instance.id, property: TransformProperty::ScaleY }, time, transform.scale_y); + let skew_x = animation_data.eval(&crate::animation::AnimationTarget::Object { id: clip_instance.id, property: TransformProperty::SkewX }, time, transform.skew_x); + let skew_y = animation_data.eval(&crate::animation::AnimationTarget::Object { id: clip_instance.id, property: TransformProperty::SkewY }, time, transform.skew_y); + let opacity = animation_data.eval(&crate::animation::AnimationTarget::Object { id: clip_instance.id, property: TransformProperty::Opacity }, time, clip_instance.opacity); + + let center_x = vector_clip.width / 2.0; + let center_y = vector_clip.height / 2.0; + let skew_transform = if skew_x != 0.0 || skew_y != 0.0 { + let sx = if skew_x != 0.0 { Affine::new([1.0, 0.0, skew_x.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + let sy = if skew_y != 0.0 { Affine::new([1.0, skew_y.to_radians().tan(), 0.0, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + Affine::translate((center_x, center_y)) * sx * sy * Affine::translate((-center_x, -center_y)) + } else { Affine::IDENTITY }; + + let clip_transform = Affine::translate((x, y)) * Affine::rotate(rotation.to_radians()) * Affine::scale_non_uniform(scale_x, scale_y) * skew_transform; + let instance_transform = base_transform * clip_transform; + let clip_opacity = parent_opacity * opacity; + + for layer_node in vector_clip.layers.iter() { + if !layer_node.data.visible() { continue; } + render_vector_content_cpu(document, clip_time, &layer_node.data, pixmap, instance_transform, clip_opacity, image_cache); + } +} + +/// Render only vector/group content from a layer to a CPU pixmap. +/// Video, Audio, Effect, and Raster variants are intentionally skipped — +/// they are handled by the compositor via other paths. +fn render_vector_content_cpu( + document: &Document, + time: f64, + layer: &AnyLayer, + pixmap: &mut tiny_skia::PixmapMut<'_>, + base_transform: Affine, + parent_opacity: f64, + image_cache: &mut ImageCache, +) { + match layer { + AnyLayer::Vector(vector_layer) => { + render_vector_layer_cpu(document, time, vector_layer, pixmap, base_transform, parent_opacity, image_cache); + } + AnyLayer::Group(group_layer) => { + for child in &group_layer.children { + render_vector_content_cpu(document, time, child, pixmap, base_transform, parent_opacity, image_cache); + } + } + AnyLayer::Audio(_) | AnyLayer::Video(_) | AnyLayer::Effect(_) | AnyLayer::Raster(_) => {} + } +} + +/// Render a single layer to its own isolated CPU pixmap. +fn render_layer_isolated_cpu( + document: &Document, + time: f64, + layer: &AnyLayer, + base_transform: Affine, + width: u32, + height: u32, + image_cache: &mut ImageCache, + video_manager: &std::sync::Arc>, + camera_frame: Option<&crate::webcam::CaptureFrame>, +) -> RenderedLayer { + // Reuse the GPU path for non-vector layer types (they don't use the Vello scene anyway) + let mut rendered = render_layer_isolated(document, time, layer, base_transform, image_cache, video_manager, camera_frame); + + // For vector layers, replace the empty scene with a CPU pixmap + if matches!(rendered.layer_type, RenderedLayerType::Vector) { + let opacity = layer.opacity() as f64; + if let Some(mut pixmap) = tiny_skia::Pixmap::new(width.max(1), height.max(1)) { + { + let mut pm = pixmap.as_mut(); + render_vector_content_cpu(document, time, layer, &mut pm, base_transform, opacity, image_cache); + } + rendered.has_content = true; + rendered.cpu_pixmap = Some(pixmap); + } + } + + rendered +} + +/// Render a document for compositing using the CPU (tiny-skia) path. +/// +/// Produces the same `CompositeRenderResult` shape as `render_document_for_compositing`, +/// but vector layers are rendered to `Pixmap`s instead of Vello `Scene`s. +/// `viewport_width` / `viewport_height` set the pixmap dimensions (should match +/// the wgpu render buffer size). +pub fn render_document_for_compositing_cpu( + document: &Document, + base_transform: Affine, + viewport_width: u32, + viewport_height: u32, + image_cache: &mut ImageCache, + video_manager: &std::sync::Arc>, + camera_frame: Option<&crate::webcam::CaptureFrame>, + floating_selection: Option<&crate::selection::RasterFloatingSelection>, + draw_checkerboard: bool, +) -> CompositeRenderResult { + let time = document.current_time; + let w = viewport_width.max(1); + let h = viewport_height.max(1); + + // Render background + let background_cpu = tiny_skia::Pixmap::new(w, h).map(|mut pixmap| { + render_background_cpu(document, &mut pixmap.as_mut(), base_transform, draw_checkerboard); + pixmap + }); + + // Solo check + let any_soloed = document.visible_layers().any(|layer| layer.soloed()); + + let layers_to_render: Vec<_> = document + .visible_layers() + .filter(|layer| if any_soloed { layer.soloed() } else { true }) + .collect(); + + let mut rendered_layers = Vec::with_capacity(layers_to_render.len()); + for layer in layers_to_render { + let rendered = render_layer_isolated_cpu( + document, time, layer, base_transform, w, h, + image_cache, video_manager, camera_frame, + ); + rendered_layers.push(rendered); + } + + // Insert floating raster selection at the correct z-position (same logic as GPU path) + if let Some(float_sel) = floating_selection { + if let Some(pos) = rendered_layers.iter().position(|l| l.layer_id == float_sel.layer_id) { + let parent_transform = match &rendered_layers[pos].layer_type { + RenderedLayerType::Raster { transform, .. } => *transform, + _ => Affine::IDENTITY, + }; + let float_entry = RenderedLayer { + layer_id: Uuid::nil(), + scene: Scene::new(), + cpu_pixmap: None, + opacity: 1.0, + blend_mode: crate::gpu::BlendMode::Normal, + has_content: !float_sel.pixels.is_empty(), + layer_type: RenderedLayerType::Float { + canvas_id: float_sel.canvas_id, + x: float_sel.x, + y: float_sel.y, + width: float_sel.width, + height: float_sel.height, + transform: parent_transform, + pixels: std::sync::Arc::clone(&float_sel.pixels), + }, + }; + rendered_layers.insert(pos + 1, float_entry); + } + } + + CompositeRenderResult { + background: Scene::new(), + background_cpu, + layers: rendered_layers, + width: document.width, + height: document.height, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 614b058..1820c7b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -17,6 +17,30 @@ use std::sync::atomic::{AtomicBool, Ordering}; /// rendering path regardless of GPU capability. pub static FORCE_CPU_RENDERER: AtomicBool = AtomicBool::new(false); +/// Upload a tiny-skia `Pixmap` directly to a wgpu texture (no Vello involved). +/// Used by the CPU render path to bypass `render_to_texture` overhead. +fn upload_pixmap_to_texture(queue: &wgpu::Queue, texture: &wgpu::Texture, pixmap: &tiny_skia::Pixmap) { + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + pixmap.data(), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4 * pixmap.width()), + rows_per_image: None, + }, + wgpu::Extent3d { + width: pixmap.width(), + height: pixmap.height(), + depth_or_array_layers: 1, + }, + ); +} + /// Enable HDR compositing pipeline (per-layer rendering with proper opacity) /// Set to true to use the new pipeline, false for legacy single-scene rendering const USE_HDR_COMPOSITING: bool = true; // Enabled for testing @@ -45,6 +69,9 @@ struct SharedVelloResources { gpu_brush: Mutex, /// Canvas blit pipeline (renders GPU canvas to layer sRGB buffer) canvas_blit: crate::gpu_brush::CanvasBlitPipeline, + /// True when Vello is running its CPU software renderer (either forced or GPU fallback). + /// Used to select cheaper antialiasing — Msaa16 on CPU costs 16× as much as Area. + is_cpu_renderer: bool, } /// Per-instance Vello resources (created for each Stage pane) @@ -92,8 +119,8 @@ impl SharedVelloResources { ) })) }; - let renderer = match gpu_result { - Ok(Ok(r)) => r, + let (renderer, is_cpu_renderer) = match gpu_result { + Ok(Ok(r)) => (r, false), Ok(Err(e)) => return Err(format!("Failed to create Vello renderer: {e}")), Err(_) => { if !use_cpu { @@ -102,7 +129,7 @@ impl SharedVelloResources { capability). Falling back to CPU renderer — performance may be reduced." ); } - vello::Renderer::new( + let r = vello::Renderer::new( device, vello::RendererOptions { use_cpu: true, @@ -110,7 +137,8 @@ impl SharedVelloResources { num_init_threads: std::num::NonZeroUsize::new(1), pipeline_cache: None, }, - ).map_err(|e| format!("CPU fallback renderer also failed: {e}"))? + ).map_err(|e| format!("CPU fallback renderer also failed: {e}"))?; + (r, true) } }; @@ -271,6 +299,7 @@ impl SharedVelloResources { srgb_to_linear, gpu_brush: Mutex::new(gpu_brush), canvas_blit, + is_cpu_renderer: use_cpu || is_cpu_renderer, }) } } @@ -570,6 +599,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Prints to stderr when any section exceeds 2 ms, or total > 8 ms. let _t_prepare_start = std::time::Instant::now(); + // On the CPU renderer Msaa16 runs the rasterizer 16× per frame; use Area instead. + let aa_method = if shared.is_cpu_renderer { vello::AaConfig::Area } else { vello::AaConfig::Msaa16 }; + // Choose rendering path based on HDR compositing flag let mut scene = if USE_HDR_COMPOSITING { // HDR Compositing Pipeline: render each layer separately for proper opacity @@ -934,15 +966,29 @@ impl egui_wgpu::CallbackTrait for VelloCallback { let mut image_cache = shared.image_cache.lock().unwrap(); - let composite_result = lightningbeam_core::renderer::render_document_for_compositing( - &self.ctx.document, - camera_transform, - &mut image_cache, - &shared.video_manager, - self.ctx.webcam_frame.as_ref(), - self.ctx.selection.raster_floating.as_ref(), - true, // Draw checkerboard for transparent backgrounds in the UI - ); + let composite_result = if shared.is_cpu_renderer { + lightningbeam_core::renderer::render_document_for_compositing_cpu( + &self.ctx.document, + camera_transform, + width, + height, + &mut image_cache, + &shared.video_manager, + self.ctx.webcam_frame.as_ref(), + self.ctx.selection.raster_floating.as_ref(), + true, + ) + } else { + lightningbeam_core::renderer::render_document_for_compositing( + &self.ctx.document, + camera_transform, + &mut image_cache, + &shared.video_manager, + self.ctx.webcam_frame.as_ref(), + self.ctx.selection.raster_floating.as_ref(), + true, // Draw checkerboard for transparent backgrounds in the UI + ) + }; drop(image_cache); let _t_after_scene_build = std::time::Instant::now(); @@ -961,7 +1007,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { base_color: vello::peniko::Color::TRANSPARENT, width, height, - antialiasing_method: vello::AaConfig::Msaa16, + antialiasing_method: aa_method, }; // HDR buffer spec for linear buffers @@ -982,10 +1028,14 @@ impl egui_wgpu::CallbackTrait for VelloCallback { base_color: vello::peniko::Color::TRANSPARENT, width, height, - antialiasing_method: vello::AaConfig::Msaa16, + antialiasing_method: aa_method, }; - if let Ok(mut renderer) = shared.renderer.lock() { + if let Some(pixmap) = &composite_result.background_cpu { + if let Some(tex) = buffer_pool.get_texture(bg_srgb_handle) { + upload_pixmap_to_texture(queue, tex, pixmap); + } + } else if let Ok(mut renderer) = shared.renderer.lock() { renderer.render_to_texture(device, queue, &composite_result.background, bg_srgb_view, &bg_render_params).ok(); } @@ -1184,7 +1234,11 @@ impl egui_wgpu::CallbackTrait for VelloCallback { buffer_pool.get_view(hdr_layer_handle), &instance_resources.hdr_texture_view, ) { - if let Ok(mut renderer) = shared.renderer.lock() { + if let Some(pixmap) = &rendered_layer.cpu_pixmap { + if let Some(tex) = buffer_pool.get_texture(srgb_handle) { + upload_pixmap_to_texture(queue, tex, pixmap); + } + } else if let Ok(mut renderer) = shared.renderer.lock() { renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params).ok(); } let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { @@ -1473,7 +1527,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { let dim_params = vello::RenderParams { base_color: vello::peniko::Color::TRANSPARENT, width, height, - antialiasing_method: vello::AaConfig::Msaa16, + antialiasing_method: aa_method, }; if let Ok(mut renderer) = shared.renderer.lock() { renderer.render_to_texture(device, queue, &dim_scene, dim_srgb_view, &dim_params).ok(); @@ -1514,7 +1568,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { let clip_params = vello::RenderParams { base_color: vello::peniko::Color::TRANSPARENT, width, height, - antialiasing_method: vello::AaConfig::Msaa16, + antialiasing_method: aa_method, }; if let Ok(mut renderer) = shared.renderer.lock() { renderer.render_to_texture(device, queue, &clip_scene, clip_srgb_view, &clip_params).ok(); @@ -2520,7 +2574,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { base_color: vello::peniko::Color::TRANSPARENT, width, height, - antialiasing_method: vello::AaConfig::Msaa16, + antialiasing_method: aa_method, }; if let Ok(mut renderer) = shared.renderer.lock() { @@ -2592,7 +2646,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { base_color: vello::peniko::Color::from_rgb8(45, 45, 48), // Dark background width, height, - antialiasing_method: vello::AaConfig::Msaa16, + antialiasing_method: aa_method, }; if let Ok(mut renderer) = shared.renderer.lock() {