Double CPU performance by using tiny-skia instead of vello CPU

This commit is contained in:
Skyler Lehmkuhl 2026-03-13 18:52:37 -04:00
parent be8514e2e6
commit d7a29ee1dc
3 changed files with 564 additions and 24 deletions

View File

@ -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"

View File

@ -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<tiny_skia::Pixmap>,
/// 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<tiny_skia::Pixmap>,
/// Rendered layers in bottom-to-top order
pub layers: Vec<RenderedLayer>,
/// 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<tiny_skia::Path> {
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<tiny_skia::Paint<'a>> {
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<GradientStop> = 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<f64>,
) {
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<std::sync::Mutex<crate::video::VideoManager>>,
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<std::sync::Mutex<crate::video::VideoManager>>,
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::*;

View File

@ -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<crate::gpu_brush::GpuBrushEngine>,
/// 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() {