From ef15ab67cf8c573dbc38ace3fd8361d70037b78e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 16:30:13 +0700 Subject: [PATCH] Spike (gap #7): GPU canvas (vello) behind a feature flag New figcraft-render-gpu crate (L4): the scene graph as a vello scene on wgpu 30 (the version eframe 0.36 uses), kept per top-level layer, culled to the view, read back to the canvas texture. Off by default: the gpu-canvas cargo feature plus Preferences > GPU canvas or ui.set {gpuCanvas}. Effects, masks, pattern fills and outline views fall back to the CPU renderer per frame (ui.inspect canvas.gpuFallback). Exports, CLI and MCP stay on the CPU. Parity test against the CPU renderer (ignored: needs a GPU) and a CPU vs GPU frame-time bench example. --- Cargo.lock | 81 ++++++ Cargo.toml | 1 + apps/figcraft/Cargo.toml | 4 + crates/render-gpu/Cargo.toml | 26 ++ crates/render-gpu/examples/bench.rs | 150 +++++++++++ crates/render-gpu/src/lib.rs | 307 +++++++++++++++++++++++ crates/render-gpu/src/scene.rs | 307 +++++++++++++++++++++++ crates/render-gpu/src/tests.rs | 51 ++++ crates/render-gpu/tests/parity.rs | 188 ++++++++++++++ crates/ui-egui/Cargo.toml | 6 + crates/ui-egui/src/canvas.rs | 10 + crates/ui-egui/src/canvas_fixes_tests.rs | 1 + crates/ui-egui/src/control.rs | 4 + crates/ui-egui/src/dialogs.rs | 4 + crates/ui-egui/src/idle_tests.rs | 1 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/render_worker.rs | 51 +++- xtask/src/layers.rs | 2 + 18 files changed, 1188 insertions(+), 8 deletions(-) create mode 100644 crates/render-gpu/Cargo.toml create mode 100644 crates/render-gpu/examples/bench.rs create mode 100644 crates/render-gpu/src/lib.rs create mode 100644 crates/render-gpu/src/scene.rs create mode 100644 crates/render-gpu/src/tests.rs create mode 100644 crates/render-gpu/tests/parity.rs diff --git a/Cargo.lock b/Cargo.lock index dda6513..77ecadb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1609,6 +1609,23 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "figcraft-render-gpu" +version = "0.3.1" +dependencies = [ + "figcraft-color", + "figcraft-doc", + "figcraft-engine", + "figcraft-geom", + "figcraft-pathops", + "figcraft-render", + "figcraft-text", + "log", + "pollster 0.4.0", + "tiny-skia", + "vello", +] + [[package]] name = "figcraft-server" version = "0.3.1" @@ -1668,6 +1685,7 @@ dependencies = [ "figcraft-layout", "figcraft-pathops", "figcraft-render", + "figcraft-render-gpu", "figcraft-text", "image", "log", @@ -1818,6 +1836,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.34" @@ -2047,6 +2076,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" dependencies = [ "euclid", + "svg_fmt", ] [[package]] @@ -4190,6 +4220,12 @@ dependencies = [ "write-fonts", ] +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + [[package]] name = "svgtypes" version = "0.16.1" @@ -4623,6 +4659,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vello" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913abde56cab6131b62c0cc69b1a93b671d3c34b3097beb3714fd1dc93be8115" +dependencies = [ + "bytemuck", + "futures-intrusive", + "log", + "peniko", + "png", + "skrifa 0.44.0", + "static_assertions", + "thiserror 2.0.21", + "vello_encoding", + "vello_shaders", + "wgpu", +] + [[package]] name = "vello_common" version = "0.1.0" @@ -4650,6 +4705,32 @@ dependencies = [ "vello_common", ] +[[package]] +name = "vello_encoding" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1fb9548d2b46b0f79137b5b1d208ecbd76d432f74e7a2ba838615d117f11532" +dependencies = [ + "bytemuck", + "guillotiere", + "peniko", + "skrifa 0.44.0", + "smallvec", +] + +[[package]] +name = "vello_shaders" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e653222cee49f2571729c3e4b0fa99bc4f826c6bb9487dbb88b825d552b3a27" +dependencies = [ + "bytemuck", + "log", + "naga", + "thiserror 2.0.21", + "vello_encoding", +] + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 27c61ec..a896c33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ figcraft-layout = { path = "crates/layout" } figcraft-text = { path = "crates/text" } figcraft-pathops = { path = "crates/pathops" } figcraft-render = { path = "crates/render" } +figcraft-render-gpu = { path = "crates/render-gpu" } figcraft-svg = { path = "crates/svg" } figcraft-pdf = { path = "crates/pdf" } figcraft-format = { path = "crates/format" } diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index f96097e..19c90fd 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -29,5 +29,9 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col [target.'cfg(windows)'.dependencies] windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement"] } +[features] +# The experimental GPU canvas (see figcraft-ui-egui's `gpu-canvas`). +gpu-canvas = ["figcraft-ui-egui/gpu-canvas"] + [build-dependencies] winresource = "0.1" diff --git a/crates/render-gpu/Cargo.toml b/crates/render-gpu/Cargo.toml new file mode 100644 index 0000000..577186d --- /dev/null +++ b/crates/render-gpu/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "figcraft-render-gpu" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false +description = "FigCraft experimental GPU canvas renderer (vello on wgpu); the CPU renderer stays the reference and the fallback" + +[lints] +workspace = true + +[dependencies] +figcraft-geom = { workspace = true } +figcraft-color = { workspace = true } +figcraft-doc = { workspace = true } +figcraft-text = { workspace = true } +figcraft-pathops = { workspace = true } +figcraft-render = { workspace = true } +tiny-skia = { workspace = true } +log = { workspace = true } +# vello 0.11 is on wgpu 30, the version eframe 0.36 uses; peniko/kurbo 0.13 are already in the tree. +vello = "0.11" +pollster = "0.4" + +[dev-dependencies] +figcraft-engine = { workspace = true } diff --git a/crates/render-gpu/examples/bench.rs b/crates/render-gpu/examples/bench.rs new file mode 100644 index 0000000..3d22626 --- /dev/null +++ b/crates/render-gpu/examples/bench.rs @@ -0,0 +1,150 @@ +//! CPU vs GPU canvas frame times on the synthetic pages of `cargo xtask bench`: +//! `cargo run --release -p figcraft-render-gpu --example bench [-- 1000,10000,50000]`. +//! +//! CPU rows are the canvas path (`Retained`, levels of detail on). GPU rows wait for the GPU to +//! finish ("gpu"), then also copy the image back to the CPU ("+ readback", what the prototype's +//! canvas does before uploading it as a texture). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; +use std::time::Instant; + +use figcraft_doc::{Document, NodeId}; +use figcraft_render::{Options, Retained, View, render_view}; +use figcraft_render_gpu::Canvas; + +fn mean(n: usize, mut f: impl FnMut(usize)) -> f64 { + let t = Instant::now(); + for i in 0..n { + f(i); + } + t.elapsed().as_secs_f64() * 1000.0 / n.max(1) as f64 +} + +fn canvas_opts() -> Options { + Options { lod: true, ..Default::default() } +} + +struct Rows(Vec<(String, Vec)>); + +impl Rows { + fn put(&mut self, col: usize, name: String, v: f64) { + let i = self.0.iter().position(|r| r.0 == name).unwrap_or_else(|| { + self.0.push((name, Vec::new())); + self.0.len() - 1 + }); + let row = &mut self.0[i].1; + row.resize(col + 1, f64::NAN); + row[col] = v; + } +} + +/// A copy of `doc` with the last layer of the screen nearest `view`'s centre moved by `k` × 6 units. +fn moved(doc: &Document, page: NodeId, view: &View, k: f64) -> Arc { + let c = view.page_rect().center(); + let screens = doc.node(page).map(|p| p.children.clone()).unwrap_or_default(); + let nearest = screens.into_iter().filter_map(|s| doc.world_bounds(s).map(|b| (s, (b.center() - c).hypot2()))).min_by(|a, b| a.1.total_cmp(&b.1)); + let mut d = doc.clone(); + if let Some(target) = nearest.and_then(|(s, _)| doc.node(s)?.children.last().copied()) + && let Some(n) = d.node_mut(target) + { + n.transform = figcraft_geom::Affine::translate((k * 6.0, 0.0)) * n.transform; + } + Arc::new(d) +} + +fn measure(rows: &mut Rows, col: usize, gpu: &mut Canvas, layers: usize) { + let (doc, page) = figcraft_engine::synthetic::large_document(layers).expect("synthetic document"); + let bounds = doc.page_content_bounds(page).expect("content"); + let doc = Arc::new(doc); + let fit = |w: u32, h: u32, dpr: f64| { + let s = (f64::from(w) / bounds.width()).min(f64::from(h) / bounds.height()) * 2.0; + View { origin: (bounds.x0, bounds.y0), scale: s.min(dpr), width: w, height: h } + }; + let views = [ + ("1440×900 @100%", View { origin: (0.0, 0.0), scale: 1.0, width: 1440, height: 900 }), + ("1440×900 zoomed out (2× fit)", fit(1440, 900, 1.0)), + ("4K @2x, 100%", View { origin: (0.0, 0.0), scale: 2.0, width: 3840, height: 2160 }), + ("4K @2x, zoomed out (2× fit)", fit(3840, 2160, 2.0)), + ]; + let opts = canvas_opts(); + for (vname, view) in views { + let pan = + |v: &View, i: usize| View { origin: (v.origin.0 + if i.is_multiple_of(2) { 40.0 / v.scale } else { 0.0 }, v.origin.1), ..v.clone() }; + let zoom = |v: &View, i: usize| View { scale: v.scale * if i.is_multiple_of(2) { 1.1 } else { 1.0 }, ..v.clone() }; + let edits = [moved(&doc, page, &view, 1.0), moved(&doc, page, &view, 0.0)]; + // CPU, as the canvas renders today. + rows.put(col, format!("{vname}: CPU full render"), mean(3, |_| _ = render_view(&doc, page, &view, &opts))); + rows.put( + col, + format!("{vname}: CPU full render, exact (no LOD, as the GPU draws)"), + mean(3, |_| _ = render_view(&doc, page, &view, &Options::default())), + ); + let mut r = Retained::default(); + let _ = r.render(&doc, page, &view, &opts); + rows.put(col, format!("{vname}: CPU pan 40 px (retained)"), mean(10, |i| _ = r.render(&doc, page, &pan(&view, i), &opts))); + rows.put(col, format!("{vname}: CPU zoom step (retained)"), mean(4, |i| _ = r.render(&doc, page, &zoom(&view, i), &opts))); + let _ = r.render(&doc, page, &view, &opts); + rows.put(col, format!("{vname}: CPU one-layer edit (retained)"), mean(10, |i| _ = r.render(&edits[i % 2], page, &view, &opts))); + // GPU. + let ok = gpu.draw(&doc, page, &view, &opts); + if let Err(why) = ok { + eprintln!("{vname}: GPU falls back: {why}"); + continue; + } + gpu.gpu.wait(); + for (suffix, read) in [("gpu", false), ("gpu + readback", true)] { + let frame = |gpu: &mut Canvas, d: &Arc, v: &View| { + if gpu.draw(d, page, v, &opts).is_ok() { + if read { + let _ = gpu.gpu.read(); + } else { + gpu.gpu.wait(); + } + } + }; + rows.put( + col, + format!("{vname}: GPU full render, scene built ({suffix})"), + mean(3, |_| { + gpu.forget(); + frame(gpu, &doc, &view); + }), + ); + frame(gpu, &doc, &view); + rows.put(col, format!("{vname}: GPU pan 40 px ({suffix})"), mean(10, |i| frame(gpu, &doc, &pan(&view, i)))); + rows.put(col, format!("{vname}: GPU zoom step ({suffix})"), mean(10, |i| frame(gpu, &doc, &zoom(&view, i)))); + rows.put(col, format!("{vname}: GPU one-layer edit ({suffix})"), mean(10, |i| frame(gpu, &edits[i % 2], &view))); + } + } +} + +fn main() { + let sizes: Vec = std::env::args() + .nth(1) + .unwrap_or_else(|| "1000,10000,50000".into()) + .split(',') + .filter_map(|s| s.trim().replace('_', "").parse().ok()) + .collect(); + figcraft_text::scan_system_fonts(); + let t = Instant::now(); + let mut gpu = Canvas::new().expect("a GPU adapter"); + println!("GPU: {} (device + renderer {:.0} ms)", gpu.gpu.adapter, t.elapsed().as_secs_f64() * 1000.0); + let mut rows = Rows(Vec::new()); + for (col, n) in sizes.iter().enumerate() { + eprintln!("measuring {n} layers…"); + measure(&mut rows, col, &mut gpu, *n); + } + let w = rows.0.iter().map(|r| r.0.chars().count()).max().unwrap_or(10); + let mut head = format!("| {:12} |", format!("{n} layers"))); + rule.push_str(&format!("{}:|", "-".repeat(13))); + } + println!("{head}\n{rule}"); + for (name, vals) in &rows.0 { + let cells: String = vals.iter().map(|v| format!(" {:>12} |", if v.is_finite() { format!("{v:.2}") } else { "-".into() })).collect(); + println!("| {name:, + /// The adapter's name and backend (for reports). + pub adapter: String, +} + +struct Target { + width: u32, + height: u32, + texture: wgpu::Texture, + view: wgpu::TextureView, + /// Rows padded to wgpu's copy alignment. + readback: wgpu::Buffer, + row: u32, +} + +impl Gpu { + /// A device of its own on the default (high-performance) adapter. + pub fn new() -> Result { + let instance = wgpu::Instance::default(); + let adapter = pollster::block_on( + instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }), + ) + .map_err(|e| e.to_string())?; + let info = adapter.get_info(); + let (device, queue) = + pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("figcraft-gpu-canvas"), ..Default::default() })) + .map_err(|e| e.to_string())?; + // wgpu panics on device errors by default; a canvas frame must not take the app down. + device.on_uncaptured_error(Arc::new(|e| log::error!("gpu canvas: {e}"))); + let renderer = Renderer::new( + &device, + RendererOptions { + use_cpu: false, + antialiasing_support: AaSupport::area_only(), + num_init_threads: NonZeroUsize::new(1), + pipeline_cache: None, + }, + ) + .map_err(|e| e.to_string())?; + Ok(Self { device, queue, renderer, target: None, adapter: format!("{} ({:?})", info.name, info.backend) }) + } + + /// Render `scene` into the `width`×`height` target over `base` (submitted, not waited for). + pub fn render(&mut self, scene: &Scene, width: u32, height: u32, base: figcraft_color::Color) -> Result<(), Fallback> { + let max = self.device.limits().max_texture_dimension_2d; + if width == 0 || height == 0 || width > max || height > max { + return Err("view larger than the GPU's textures"); + } + if self.target.as_ref().is_none_or(|t| t.width != width || t.height != height) { + self.target = Some(self.target(width, height)); + } + let Some(t) = &self.target else { return Err("no render target") }; + let params = RenderParams { + base_color: vello::peniko::Color::new([base.r, base.g, base.b, base.a]), + width, + height, + antialiasing_method: AaConfig::Area, + }; + self.renderer.render_to_texture(&self.device, &self.queue, scene, &t.view, ¶ms).map_err(|e| { + log::warn!("gpu canvas: {e}"); + "GPU render failed" + }) + } + + fn target(&self, width: u32, height: u32) -> Target { + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("figcraft-gpu-canvas-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 | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let row = (width * 4).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let readback = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("figcraft-gpu-canvas-readback"), + size: u64::from(row) * u64::from(height), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + Target { width, height, texture, view, readback, row } + } + + /// Block until the GPU has finished what was submitted. + pub fn wait(&self) { + let _ = self.device.poll(wgpu::PollType::wait_indefinitely()); + } + + /// The last render as a premultiplied pixmap (copied back from the GPU; blocks). + pub fn read(&self) -> Option { + let t = self.target.as_ref()?; + let mut enc = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("figcraft-gpu-canvas-read") }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { texture: &t.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyBufferInfo { + buffer: &t.readback, + layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(t.row), rows_per_image: None }, + }, + wgpu::Extent3d { width: t.width, height: t.height, depth_or_array_layers: 1 }, + ); + self.queue.submit([enc.finish()]); + let slice = t.readback.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| _ = tx.send(r.is_ok())); + self.wait(); + if rx.recv() != Ok(true) { + return None; + } + let mut pix = Pixmap::new(t.width, t.height)?; + { + let Ok(data) = slice.get_mapped_range() else { + t.readback.unmap(); + return None; + }; + let w = t.width as usize * 4; + for (dst, src) in pix.data_mut().chunks_exact_mut(w).zip(data.chunks(t.row as usize)) { + let Some(src) = src.get(..w) else { break }; + // vello writes straight alpha; pixmaps are premultiplied. + for (d, s) in dst.chunks_exact_mut(4).zip(src.chunks_exact(4)) { + let a = u32::from(s.get(3).copied().unwrap_or(0)); + if a == 255 { + d.copy_from_slice(s); + } else { + for (dc, sc) in d.iter_mut().zip(s).take(3) { + *dc = ((u32::from(*sc) * a + 127) / 255) as u8; + } + if let Some(da) = d.get_mut(3) { + *da = a as u8; + } + } + } + } + } + t.readback.unmap(); + Some(pix) + } +} + +/// A top-level layer's scene and the page area it paints (`None`: anywhere). +type Part = Result<(Scene, Option), Fallback>; + +/// What the kept scene was built from. +struct Kept { + doc: Arc, + page: NodeId, + skip: Vec, + fonts: u64, + /// Each top-level layer's scene (or why it can't be drawn), in page order. + parts: Vec<(NodeId, Part)>, +} + +/// The GPU canvas: a device plus the scene kept between frames. +pub struct Canvas { + pub gpu: Gpu, + images: Images, + kept: Option, + frame: Scene, +} + +impl Canvas { + pub fn new() -> Result { + Ok(Self { gpu: Gpu::new()?, images: Images::default(), kept: None, frame: Scene::new() }) + } + + /// Drop the kept scene (the next frame builds it from scratch). + pub fn forget(&mut self) { + self.kept = None; + } + + /// Render `view` of `page` on the GPU (submitted, not read back), or say why it can't. + pub fn draw(&mut self, doc: &Arc, page: NodeId, view: &View, opts: &Options) -> Result<(), Fallback> { + if opts.outline_mode || !opts.outlined.is_empty() { + return Err("outline view"); + } + let base = opts + .background + .or_else(|| match doc.node(page).map(|p| &p.kind) { + Some(NodeKind::Page { background, .. }) => Some(*background), + _ => None, + }) + .unwrap_or(figcraft_color::Color::TRANSPARENT); + self.frame.reset(); + let xf = view.transform(); + if opts.solo.is_some() { + let mut sc = Scene::new(); + Builder { doc, skip: &opts.skip, images: &mut self.images }.page(&mut sc, page, opts.solo)?; + self.frame.append(&sc, Some(xf)); + } else { + self.update(doc, page, &opts.skip)?; + // Only the top-level layers in view go to the GPU. + let shown = view.page_rect(); + for (sc, at) in self.kept.iter().flat_map(|k| k.parts.iter()).filter_map(|(_, p)| p.as_ref().ok()) { + if at.is_none_or(|r| r.overlaps(shown)) { + self.frame.append(sc, Some(xf)); + } + } + } + self.gpu.render(&self.frame, view.width, view.height, base) + } + + /// [`Self::draw`], then read the image back. + pub fn render(&mut self, doc: &Arc, page: NodeId, view: &View, opts: &Options) -> Result { + self.draw(doc, page, view, opts)?; + self.gpu.read().ok_or("GPU readback failed") + } + + /// The kept page scene brought up to date with `doc`. + fn update(&mut self, doc: &Arc, page: NodeId, skip: &[NodeId]) -> Result<(), Fallback> { + let fonts = figcraft_text::layout_generation(); + let old = self.kept.take().filter(|k| k.page == page && k.skip == skip && k.fonts == fonts && k.doc.color_profile == doc.color_profile); + let kept = match old { + Some(k) if Arc::ptr_eq(&k.doc, doc) => k, + old => { + // Top-level layers to rebuild; `None`: all of them. + let dirty = old.as_ref().and_then(|k| dirty_tops(&k.doc, doc, page)); + let mut reuse: HashMap = match (old, &dirty) { + (Some(k), Some(_)) => k.parts.into_iter().collect(), + _ => HashMap::new(), + }; + let mut b = Builder { doc, skip, images: &mut self.images }; + let kids = doc.node(page).map(|p| p.children.as_slice()).unwrap_or_default(); + let parts = kids + .iter() + .map(|c| { + let cached = if dirty.as_ref().is_some_and(|d| !d.contains(c)) { reuse.remove(c) } else { None }; + let part = cached.unwrap_or_else(|| { + let mut sc = Scene::new(); + // A page unit of margin for anti-aliasing. + b.node(&mut sc, *c, figcraft_geom::Affine::IDENTITY, 0) + .map(|()| (sc, figcraft_render::paint_bounds(doc, *c).map(|r| r.inflate(1.0, 1.0)))) + }); + (*c, part) + }) + .collect(); + Kept { doc: doc.clone(), page, skip: skip.to_vec(), fonts, parts } + } + }; + let kept = self.kept.insert(kept); + if let Some((_, Err(why))) = kept.parts.iter().find(|(_, p)| p.is_err()) { + return Err(why); + } + Ok(()) + } +} + +/// The top-level layers of `page` an edit from `old` to `new` touched; `None` when the whole page +/// scene has to be rebuilt (the page itself changed, or too much did). +fn dirty_tops(old: &Document, new: &Document, page: NodeId) -> Option> { + let mut out = HashSet::new(); + for id in new.changed_ids(old, MAX_CHANGED)? { + if id == page { + return None; + } + for d in [old, new] { + if d.page_of(id) != Some(page) { + continue; + } + // The ancestor right under the page. + let mut top = id; + for a in d.ancestors(id) { + if a == page { + break; + } + top = a; + } + out.insert(top); + } + } + Some(out) +} + +#[cfg(test)] +mod tests; diff --git a/crates/render-gpu/src/scene.rs b/crates/render-gpu/src/scene.rs new file mode 100644 index 0000000..53cf993 --- /dev/null +++ b/crates/render-gpu/src/scene.rs @@ -0,0 +1,307 @@ +//! The scene graph as a vello scene, in page coordinates. Mirrors `figcraft_render::draw` for +//! the subset the GPU draws; anything else is a [`Fallback`] and the frame renders on the CPU. + +use std::collections::HashMap; +use std::sync::Arc; + +use figcraft_color::{BlendMode, Color, Gradient, GradientKind, Paint, PaintKind}; +use figcraft_doc::{Document, Node, NodeId, NodeKind, StrokeAlign}; +use figcraft_geom::{Affine, BezPath, Rect, Shape}; +use figcraft_render::paint::{Prepared, prepare_at}; +use figcraft_render::{FillRule, Pixmap}; +use vello::Scene; +use vello::peniko::{self, Blob, Compose, Extend, Fill, ImageAlphaType, ImageBrush, ImageData, ImageFormat, ImageQuality, ImageSampler, Mix}; + +/// Why a frame falls back to the CPU renderer. +pub type Fallback = &'static str; + +/// Decoded images handed to vello, one blob per pixmap so its atlas keeps them across frames. +#[derive(Default)] +pub struct Images(HashMap, ImageData)>); + +struct PixBytes(Arc); + +impl AsRef<[u8]> for PixBytes { + fn as_ref(&self) -> &[u8] { + self.0.data() + } +} + +impl Images { + fn get(&mut self, p: &Arc) -> ImageData { + // The map holds the Arc, so its address can't be reused while the entry lives. + let key = Arc::as_ptr(p) as usize; + if let Some((_, d)) = self.0.get(&key) { + return d.clone(); + } + let d = ImageData { + data: Blob::new(Arc::new(PixBytes(p.clone()))), + format: ImageFormat::Rgba8, + alpha_type: ImageAlphaType::AlphaPremultiplied, + width: p.width(), + height: p.height(), + }; + if self.0.len() > 512 { + self.0.clear(); + } + self.0.insert(key, (p.clone(), d.clone())); + d + } +} + +/// Builds the scene of one document. +pub struct Builder<'a> { + pub doc: &'a Document, + /// Layers not to draw (text being edited). + pub skip: &'a [NodeId], + pub images: &'a mut Images, +} + +fn pc(c: Color) -> peniko::Color { + peniko::Color::new([c.r, c.g, c.b, c.a]) +} + +fn fill_rule(r: FillRule) -> Fill { + match r { + FillRule::EvenOdd => Fill::EvenOdd, + FillRule::Winding => Fill::NonZero, + } +} + +/// The CPU renderer's blend modes (`figcraft_render::paint::ts_blend`), plus darker included. +fn mode(b: BlendMode) -> peniko::BlendMode { + let m = match b { + BlendMode::PassThrough | BlendMode::Normal => Mix::Normal, + BlendMode::Darken => Mix::Darken, + BlendMode::Multiply | BlendMode::PlusDarker => Mix::Multiply, + BlendMode::ColorBurn => Mix::ColorBurn, + BlendMode::Lighten => Mix::Lighten, + BlendMode::Screen => Mix::Screen, + BlendMode::PlusLighter => return peniko::BlendMode::new(Mix::Normal, Compose::Plus), + BlendMode::ColorDodge => Mix::ColorDodge, + BlendMode::Overlay => Mix::Overlay, + BlendMode::SoftLight => Mix::SoftLight, + BlendMode::HardLight => Mix::HardLight, + BlendMode::Difference => Mix::Difference, + BlendMode::Exclusion => Mix::Exclusion, + BlendMode::Hue => Mix::Hue, + BlendMode::Saturation => Mix::Saturation, + BlendMode::Color => Mix::Color, + BlendMode::Luminosity => Mix::Luminosity, + }; + m.into() +} + +fn normal(b: BlendMode) -> bool { + matches!(b, BlendMode::Normal | BlendMode::PassThrough) +} + +/// A gradient's handles: the unit box → local mapping (as `figcraft_render::paint`). +fn handle_frame(g: &Gradient, w: f64, h: f64) -> Affine { + let f = (g.from.0 * w, g.from.1 * h); + let t = (g.to.0 * w, g.to.1 * h); + let k = (g.width.0 * w, g.width.1 * h); + Affine::new([t.0 - f.0, t.1 - f.1, k.0 - f.0, k.1 - f.1, f.0, f.1]) +} + +fn ts_affine(t: tiny_skia::Transform) -> Affine { + Affine::new([f64::from(t.sx), f64::from(t.ky), f64::from(t.kx), f64::from(t.sy), f64::from(t.tx), f64::from(t.ty)]) +} + +impl Builder<'_> { + /// The page's layers (or only `solo`, where it is). + pub fn page(&mut self, sc: &mut Scene, page: NodeId, solo: Option) -> Result<(), Fallback> { + let doc = self.doc; + if let Some(id) = solo.filter(|id| *id != page && doc.page_of(*id) == Some(page)) { + let parent = doc.node(id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY); + return self.node(sc, id, parent, 0); + } + for c in doc.node(page).map(|p| p.children.as_slice()).unwrap_or_default() { + self.node(sc, *c, Affine::IDENTITY, 0)?; + } + Ok(()) + } + + /// Node `id`, where `parent_xf` maps its parent's space to the page. + pub fn node(&mut self, sc: &mut Scene, id: NodeId, parent_xf: Affine, depth: usize) -> Result<(), Fallback> { + if depth > 256 || self.skip.contains(&id) { + return Ok(()); + } + let doc = self.doc; + let Some(n) = doc.node(id) else { return Ok(()) }; + if !n.visible || n.opacity <= 0.0 { + return Ok(()); + } + if n.is_mask { + return Err("masks"); + } + if n.effects.iter().any(|e| e.visible) { + return Err("effects (shadows, blurs, glass, noise, texture)"); + } + let xf = parent_xf * n.transform; + let layer = n.opacity < 0.999 || !normal(n.blend); + if layer { + // An isolated group over what the node paints (page coordinates). + let b = figcraft_render::paint_bounds(doc, id).unwrap_or(Rect::ZERO).inflate(1.0, 1.0); + sc.push_layer(Fill::NonZero, mode(n.blend), n.opacity.clamp(0.0, 1.0), Affine::IDENTITY, &b); + } + self.content(sc, n, xf, depth)?; + if layer { + sc.pop_layer(); + } + Ok(()) + } + + fn children(&mut self, sc: &mut Scene, n: &Node, xf: Affine, depth: usize) -> Result<(), Fallback> { + for c in &n.children { + self.node(sc, *c, xf, depth + 1)?; + } + Ok(()) + } + + /// Fills, children (clipped), strokes and text of a node (`draw::draw_content`). + fn content(&mut self, sc: &mut Scene, n: &Node, xf: Affine, depth: usize) -> Result<(), Fallback> { + let doc = self.doc; + let (w, h) = (n.width, n.height); + match &n.kind { + NodeKind::Page { .. } | NodeKind::Group => self.children(sc, n, xf, depth), + NodeKind::Text(_) => self.text(sc, n, xf), + _ => { + let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. }); + let clips = n.clip && n.kind.is_frame_like() && !n.children.is_empty(); + let outline = figcraft_render::node_outline(doc, n); + if closed && let Some((path, rule)) = &outline { + self.fills(sc, path, *rule, &n.fills, w, h, xf)?; + } + if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.children.is_empty() && !n.hides_contents() { + if clips { + // The CPU draws clipped children into a layer of their own: an isolated group. + match &outline { + Some((p, _)) if n.corners.uniform() != Some(0.0) => sc.push_layer(Fill::NonZero, Mix::Normal, 1.0, xf, p), + _ => sc.push_layer(Fill::NonZero, Mix::Normal, 1.0, xf, &n.local_rect()), + } + self.children(sc, n, xf, depth)?; + sc.pop_layer(); + } else { + self.children(sc, n, xf, depth)?; + } + } + if let Some((area, rule)) = figcraft_render::stroke_fill(doc, n) { + self.fills(sc, &area, rule, &n.strokes, w, h, xf)?; + } + Ok(()) + } + } + } + + /// Text as glyph outlines, grouped by style (as the CPU renderer does). + fn text(&mut self, sc: &mut Scene, n: &Node, xf: Affine) -> Result<(), Fallback> { + let Some((layout, dy)) = figcraft_text::layout_node(n) else { return Ok(()) }; + let gxf = xf * Affine::translate((0.0, dy)); + let mut by_style: HashMap = HashMap::new(); + for g in &layout.glyphs { + by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied()); + } + for d in &layout.decorations { + by_style.entry(d.style).or_default().extend(d.rect.to_path(0.1).elements().iter().copied()); + } + let mut keys: Vec = by_style.keys().copied().collect(); + keys.sort_unstable(); + for k in &keys { + let Some(path) = by_style.get(k) else { continue }; + let fills = layout.styles.get(*k).and_then(|s| s.fills.as_ref()).unwrap_or(&n.fills); + self.fills(sc, path, FillRule::Winding, fills, n.width, n.height, gxf)?; + } + if n.has_visible_stroke() { + let props = figcraft_doc::StrokeProps { align: StrokeAlign::Center, ..n.stroke.clone() }; + for path in keys.iter().filter_map(|k| by_style.get(k)) { + let s = figcraft_pathops::stroke_center(path, props.weight, &props); + self.fills(sc, &s, FillRule::Winding, &n.strokes, n.width, n.height, gxf)?; + } + } + Ok(()) + } + + fn fills(&mut self, sc: &mut Scene, path: &BezPath, rule: FillRule, paints: &[Paint], w: f64, h: f64, xf: Affine) -> Result<(), Fallback> { + let scale = xf.determinant().abs().sqrt(); + let style = fill_rule(rule); + for p in paints { + let Some((brush, brush_xf)) = self.brush(p, w, h, scale, xf)? else { continue }; + let blend = !normal(p.blend); + if blend { + sc.push_layer(style, mode(p.blend), 1.0, xf, path); + } + sc.fill(style, xf, &brush, brush_xf, path); + if blend { + sc.pop_layer(); + } + } + Ok(()) + } + + /// A paint as a vello brush for a `w`×`h` local box that `xf` maps to the page (`None`: draws + /// nothing). + fn brush(&mut self, p: &Paint, w: f64, h: f64, scale: f64, xf: Affine) -> Result)>, Fallback> { + if !p.visible || p.opacity <= 0.0 { + return Ok(None); + } + let op = p.opacity.clamp(0.0, 1.0); + match &p.kind { + PaintKind::Solid { color } => { + let c = color.with_alpha(color.a * op); + Ok((c.a > 0.0).then(|| (peniko::Brush::Solid(pc(c)), None))) + } + PaintKind::Gradient(g) if g.kind != GradientKind::Diamond => { + let frame = handle_frame(g, w.max(1e-6), h.max(1e-6)); + let det = frame.determinant().abs(); + if det.is_nan() || det <= 1e-12 { + return Ok(None); + } + let stops: Vec = + g.sorted_stops().into_iter().map(|s| (s.position, pc(s.color.with_alpha(s.color.a * op))).into()).collect(); + let (grad, brush_xf) = match g.kind { + GradientKind::Linear => { + // vello projects linear gradients onto the end points' line after the + // transform, which loses a skewed handle frame: give it the line in page + // space instead (the view only scales and moves, keeping angles). + let m = xf * frame; + let det = m.determinant(); + if !det.is_finite() || det.abs() <= 1e-12 || xf.determinant().abs() <= 1e-12 { + return Ok(None); + } + let c = m.inverse().as_coeffs(); + let (nx, ny) = (c[0], c[2]); + let p0 = m * figcraft_geom::Point::ZERO; + let k = 1.0 / (nx * nx + ny * ny); + (peniko::Gradient::new_linear(p0, (p0.x + nx * k, p0.y + ny * k)), xf.inverse()) + } + GradientKind::Radial => (peniko::Gradient::new_radial((0.0, 0.0), 1.0), frame), + _ => (peniko::Gradient::new_sweep((0.0, 0.0), 0.0, std::f32::consts::TAU), frame), + }; + Ok(Some((peniko::Brush::Gradient(grad.with_stops(stops.as_slice())), Some(brush_xf)))) + } + // Pattern cells are drawn at the zoom they show at: not in a zoom-independent scene. + PaintKind::Pattern(_) => Err("pattern fills"), + // Images and diamond gradients: the CPU renderer's pixmap, sampled on the GPU. + _ => match prepare_at(p, w, h, scale, self.doc, 0.0) { + Some(Prepared::Pattern { pixmap, mode, quality, opacity, transform }) => { + let extend = match mode { + tiny_skia::SpreadMode::Pad => Extend::Pad, + tiny_skia::SpreadMode::Repeat => Extend::Repeat, + tiny_skia::SpreadMode::Reflect => Extend::Reflect, + }; + let quality = match quality { + tiny_skia::FilterQuality::Nearest => ImageQuality::Low, + tiny_skia::FilterQuality::Bilinear => ImageQuality::Medium, + tiny_skia::FilterQuality::Bicubic => ImageQuality::High, + }; + let sampler = ImageSampler { x_extend: extend, y_extend: extend, quality, alpha: opacity }; + let image = ImageBrush { image: self.images.get(&pixmap), sampler }; + Ok(Some((peniko::Brush::Image(image), Some(ts_affine(transform))))) + } + Some(Prepared::Shader(_)) => Err("paint"), + None => Ok(None), + }, + } + } +} diff --git a/crates/render-gpu/src/tests.rs b/crates/render-gpu/src/tests.rs new file mode 100644 index 0000000..0da62d5 --- /dev/null +++ b/crates/render-gpu/src/tests.rs @@ -0,0 +1,51 @@ +//! What falls back to the CPU and what an edit rebuilds (no GPU needed; the pixel comparison +//! against the CPU renderer is `tests/parity.rs`). + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Document, Effect, NodeId, NodeKind}; +use vello::Scene; + +use super::{Builder, Images, dirty_tops}; + +fn doc() -> (Document, NodeId, NodeId, NodeId) { + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + let r = d.create(f, NodeKind::Rectangle, "R", 10.0, 10.0, 20.0, 20.0).unwrap(); + d.get_mut(r).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + (d, p, f, r) +} + +fn build(d: &Document, p: NodeId) -> Result<(), &'static str> { + Builder { doc: d, skip: &[], images: &mut Images::default() }.page(&mut Scene::new(), p, None) +} + +#[test] +fn unsupported_features_fall_back() { + let (mut d, p, _, r) = doc(); + assert_eq!(build(&d, p), Ok(())); + d.get_mut(r).unwrap().effects.push(Effect::drop_shadow()); + assert!(build(&d, p).is_err_and(|why| why.starts_with("effects"))); + d.get_mut(r).unwrap().effects[0].visible = false; + assert_eq!(build(&d, p), Ok(()), "hidden effects draw nothing"); + d.get_mut(r).unwrap().is_mask = true; + assert_eq!(build(&d, p), Err("masks")); + d.get_mut(r).unwrap().is_mask = false; + d.get_mut(r).unwrap().fills = vec![Paint::pattern(1)]; + assert_eq!(build(&d, p), Err("pattern fills")); + // Hidden layers aren't looked at. + d.get_mut(r).unwrap().visible = false; + assert_eq!(build(&d, p), Ok(())); +} + +#[test] +fn an_edit_rebuilds_only_its_top_level_layer() { + let (d, p, f, r) = doc(); + let mut moved = d.clone(); + moved.get_mut(r).unwrap().opacity = 0.5; + assert_eq!(dirty_tops(&d, &moved, p), Some([f].into())); + // A new top-level layer changes the page: everything is rebuilt. + let mut added = d.clone(); + added.create(p, NodeKind::Rectangle, "S", 0.0, 0.0, 5.0, 5.0).unwrap(); + assert_eq!(dirty_tops(&d, &added, p), None); +} diff --git a/crates/render-gpu/tests/parity.rs b/crates/render-gpu/tests/parity.rs new file mode 100644 index 0000000..3b14de9 --- /dev/null +++ b/crates/render-gpu/tests/parity.rs @@ -0,0 +1,188 @@ +//! GPU (vello) vs CPU (tiny-skia, the oracle) on the render test scenes. Needs a GPU adapter, so +//! it's ignored by default: `cargo test -p figcraft-render-gpu --release -- --ignored --nocapture`. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use figcraft_color::{BlendMode, Color, ColorStop, Gradient, GradientKind, ImageScaleMode, Paint}; +use figcraft_doc::{BoolOp, Document, ImageData, NodeId, NodeKind, StrokeAlign, TextData}; +use figcraft_geom::Affine; +use figcraft_render::{Options, Pixmap, View, render_view}; +use figcraft_render_gpu::Canvas; + +fn rect(d: &mut Document, parent: NodeId, x: f64, y: f64, w: f64, h: f64, c: &str) -> NodeId { + let r = d.create(parent, NodeKind::Rectangle, "R", x, y, w, h).unwrap(); + d.get_mut(r).unwrap().fills = vec![Paint::solid(Color::from_hex(c).unwrap())]; + r +} + +fn ellipse() -> NodeKind { + NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 } +} + +fn new_doc() -> (Document, NodeId) { + let d = Document::new("parity"); + let p = d.first_page().unwrap(); + (d, p) +} + +fn gradient(kind: GradientKind) -> Paint { + let g = Gradient { + kind, + stops: vec![ + ColorStop { position: 0.0, color: Color::from_hex("FF0000").unwrap() }, + ColorStop { position: 0.5, color: Color::from_hex("00CC66").unwrap() }, + ColorStop { position: 1.0, color: Color::from_hex("0000FF").unwrap() }, + ], + from: (0.5, 0.5), + to: (1.0, 0.5), + width: (0.5, 1.0), + }; + Paint::gradient(if kind == GradientKind::Linear { Gradient { from: (0.0, 0.0), to: (1.0, 1.0), width: (0.0, 1.0), ..g } } else { g }) +} + +fn photo() -> Vec { + let mut p = Pixmap::new(64, 64).unwrap(); + for (i, px) in p.data_mut().chunks_exact_mut(4).enumerate() { + let (x, y) = ((i % 64) as u8, (i / 64) as u8); + px.copy_from_slice(&[x * 4, y * 4, 255 - x * 2, 255]); + } + figcraft_render::encode_png(&p).unwrap() +} + +/// (name, document, page, view) for every scene. +fn scenes() -> Vec<(String, Document, NodeId, View)> { + let v = |w, h, s| View { origin: (0.0, 0.0), scale: s, width: w, height: h }; + let mut out = Vec::new(); + { + let (mut d, p) = new_doc(); + rect(&mut d, p, 10.0, 10.0, 50.0, 30.0, "FF0000"); + out.push(("solid rect".into(), d.clone(), p, v(100, 100, 1.0))); + out.push(("solid rect @2x".into(), d, p, v(200, 200, 2.0))); + } + { + let (mut d, p) = new_doc(); + let a = rect(&mut d, p, 10.0, 10.0, 60.0, 60.0, "000000"); + d.get_mut(a).unwrap().opacity = 0.5; + let b = rect(&mut d, p, 40.0, 40.0, 60.0, 60.0, "FF8800"); + d.get_mut(b).unwrap().blend = BlendMode::Multiply; + out.push(("opacity + multiply".into(), d, p, v(120, 120, 1.0))); + } + for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular, GradientKind::Diamond] { + let (mut d, p) = new_doc(); + let r = rect(&mut d, p, 10.0, 10.0, 160.0, 100.0, "000000"); + d.get_mut(r).unwrap().fills = vec![gradient(kind)]; + out.push((format!("{kind:?} gradient"), d, p, v(180, 120, 1.0))); + } + { + let (mut d, p) = new_doc(); + let f = d.create(p, NodeKind::Frame, "F", 10.0, 10.0, 80.0, 80.0).unwrap(); + rect(&mut d, f, 40.0, 40.0, 80.0, 80.0, "222222"); + let g = d.create(p, NodeKind::Frame, "G", 110.0, 10.0, 80.0, 80.0).unwrap(); + d.get_mut(g).unwrap().corners.radii = [20.0; 4]; + let r = rect(&mut d, g, -20.0, 10.0, 120.0, 40.0, "3366FF"); + let n = d.get_mut(r).unwrap(); + n.transform = Affine::rotate(0.4) * n.transform; + out.push(("frame clips (square, rounded)".into(), d, p, v(200, 100, 1.0))); + } + { + let (mut d, p) = new_doc(); + for (i, align) in [StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside].into_iter().enumerate() { + let e = d.create(p, ellipse(), "E", 10.0 + i as f64 * 70.0, 10.0, 50.0, 40.0).unwrap(); + let n = d.get_mut(e).unwrap(); + n.fills = vec![Paint::solid(Color::WHITE)]; + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = 5.0; + n.stroke.align = align; + } + let r = rect(&mut d, p, 10.0, 70.0, 190.0, 20.0, "FFFFFF"); + let n = d.get_mut(r).unwrap(); + n.strokes = vec![Paint::solid(Color::from_hex("CC0000").unwrap())]; + n.stroke.weight = 3.0; + n.stroke.dashes = vec![8.0, 4.0]; + out.push(("strokes (inside, center, outside, dashed)".into(), d, p, v(220, 100, 1.0))); + } + { + let (mut d, p) = new_doc(); + let b = d.create(p, NodeKind::BooleanOp { op: BoolOp::Subtract }, "B", 0.0, 0.0, 100.0, 100.0).unwrap(); + d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + d.create(b, NodeKind::Rectangle, "A", 0.0, 0.0, 100.0, 100.0).unwrap(); + d.create(b, ellipse(), "Hole", 25.0, 25.0, 50.0, 50.0).unwrap(); + out.push(("boolean subtract".into(), d, p, v(100, 100, 1.0))); + } + { + let (mut d, p) = new_doc(); + let mut td = TextData::new("Hello, GPU canvas"); + td.style.font_size = 28.0; + d.create(p, NodeKind::Text(td), "T", 4.0, 4.0, 300.0, 40.0).unwrap(); + out.push(("text".into(), d.clone(), p, v(300, 48, 1.0))); + out.push(("text @2x".into(), d, p, v(600, 96, 2.0))); + } + { + let (mut d, p) = new_doc(); + d.images.insert("parity-photo".into(), ImageData { bytes: Arc::new(photo()), width: 64, height: 64 }); + let e = d.create(p, ellipse(), "E", 10.0, 10.0, 120.0, 90.0).unwrap(); + d.get_mut(e).unwrap().fills = vec![Paint::image("parity-photo", ImageScaleMode::Fill)]; + let r = rect(&mut d, p, 140.0, 10.0, 90.0, 90.0, "000000"); + d.get_mut(r).unwrap().fills = vec![Paint::image("parity-photo", ImageScaleMode::Tile)]; + out.push(("image fills (fill, tile)".into(), d, p, v(240, 110, 1.0))); + } + { + let (mut d, p) = new_doc(); + let g = d.create(p, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap(); + for i in 0..5 { + let e = d.create(g, ellipse(), "E", 20.0 + f64::from(i) * 15.0, 20.0, 40.0, 40.0).unwrap(); + d.get_mut(e).unwrap().fills = vec![Paint::solid(Color::new(f64::from(i) as f32 / 5.0, 0.3, 0.8, 0.7))]; + } + let n = d.get_mut(g).unwrap(); + n.transform = Affine::translate((100.0, 0.0)) * Affine::rotate(0.5) * Affine::scale_non_uniform(1.3, 0.8); + out.push(("rotated, scaled group".into(), d, p, v(200, 160, 1.0))); + } + { + let (d, p) = figcraft_engine::synthetic::large_document(1000).unwrap(); + let b = d.page_content_bounds(p).unwrap(); + out.push(("synthetic 1k @100%".into(), d.clone(), p, View { origin: (0.0, 0.0), scale: 1.0, width: 1440, height: 900 })); + let s = (1440.0 / b.width()).min(900.0 / b.height()); + out.push(("synthetic 1k fit".into(), d, p, View { origin: (b.x0, b.y0), scale: s, width: 1440, height: 900 })); + } + out +} + +/// (mean |difference| per channel, max, share of pixels off by more than 16 in any channel). +fn diff(a: &Pixmap, b: &Pixmap) -> (f64, u8, f64) { + let (mut sum, mut max, mut off) = (0u64, 0u8, 0u64); + for (pa, pb) in a.data().chunks_exact(4).zip(b.data().chunks_exact(4)) { + let d = pa.iter().zip(pb).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0); + sum += pa.iter().zip(pb).map(|(x, y)| u64::from(x.abs_diff(*y))).sum::(); + max = max.max(d); + off += u64::from(d > 16); + } + let px = (a.data().len() / 4).max(1) as f64; + (sum as f64 / (px * 4.0), max, off as f64 / px) +} + +#[test] +#[ignore = "needs a GPU adapter"] +fn gpu_matches_cpu() { + figcraft_text::scan_system_fonts(); + let Ok(mut gpu) = Canvas::new() else { + eprintln!("no GPU adapter: skipped"); + return; + }; + eprintln!("adapter: {}", gpu.gpu.adapter); + let dir = std::env::var_os("FIGCRAFT_PARITY_DIR").map(std::path::PathBuf::from); + println!("| scene | mean diff | max diff | pixels off > 16 |\n|---|---:|---:|---:|"); + for (name, doc, page, view) in scenes() { + let cpu = render_view(&doc, page, &view, &Options::default()).unwrap(); + let g = gpu.render(&Arc::new(doc), page, &view, &Options::default()).unwrap_or_else(|why| panic!("{name}: falls back: {why}")); + let (mean, max, off) = diff(&cpu, &g); + println!("| {name} | {mean:.3} | {max} | {:.2}% |", off * 100.0); + if let Some(dir) = &dir { + let slug: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect(); + std::fs::write(dir.join(format!("{slug}-cpu.png")), figcraft_render::encode_png(&cpu).unwrap()).unwrap(); + std::fs::write(dir.join(format!("{slug}-gpu.png")), figcraft_render::encode_png(&g).unwrap()).unwrap(); + } + // Anti-aliasing and gradient interpolation differ in the low bits; layout must not. + assert!(mean < 2.0 && off < 0.05, "{name}: mean {mean}, {:.2}% off", off * 100.0); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 2cc298c..bf2aabb 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -24,6 +24,12 @@ log = { workspace = true } base64 = { workspace = true } image = { workspace = true } egui_extras = { workspace = true } +figcraft-render-gpu = { workspace = true, optional = true } + +[features] +# Experimental GPU canvas (vello), off by default: Preferences ▸ GPU canvas, or `ui.set +# {"gpuCanvas": true}`. Desktop only; exports and the CLI/MCP keep the CPU renderer. +gpu-canvas = ["dep:figcraft-render-gpu"] [build-dependencies] serde_json = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b5db77e..0442a73 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -20,6 +20,10 @@ pub struct CanvasState { submitted: Option, /// What the last canvas render had to do (shown by `ui.inspect`). pub render_work: Option, + /// Render the canvas on the GPU (the experimental `gpu-canvas` feature; CPU otherwise). + pub gpu: bool, + /// The last render: on the GPU, or why it fell back to the CPU (`None`: not asked for). + pub gpu_status: Option>, dragging: bool, space_pan: bool, middle_pan: Option<(Pos2, f64, f64)>, @@ -41,6 +45,8 @@ impl Default for CanvasState { tex_view: None, submitted: None, render_work: None, + gpu: false, + gpu_status: None, dragging: false, space_pan: false, middle_pan: None, @@ -103,6 +109,7 @@ struct JobKey { solo: Option, /// Text lays out anew when fonts arrive (system fonts load in the background). fonts: u64, + gpu: bool, } impl FigApp { @@ -174,6 +181,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { } app.canvas.tex_view = Some(done.view); app.canvas.render_work = Some(done.work); + app.canvas.gpu_status = done.gpu; app.ui.render_ms = done.ms; } let painter = ui.painter_at(rect); @@ -301,6 +309,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { skip: vec![], solo: ds.focused(), fonts: figcraft_text::layout_generation(), + gpu: app.canvas.gpu, }; if app.canvas.submitted.as_ref() == Some(&key) { return; @@ -313,6 +322,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { outlined, skip: vec![], solo: key.solo, + gpu: key.gpu, }; worker.submit(job); app.canvas.submitted = Some(key); diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index 0e62a5d..8cdb300 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -60,6 +60,7 @@ fn big_job(doc: &Arc, page: figcraft_doc::NodeId, scale: outlined: vec![], skip: vec![], solo: None, + gpu: false, } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7e34f65..d497ed4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -287,6 +287,10 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if let Some(t) = p.get("theme").and_then(Value::as_str) { app.session.prefs.theme = t.to_string(); } + #[cfg(feature = "gpu-canvas")] + if let Some(on) = p.get("gpuCanvas").and_then(Value::as_bool) { + app.canvas.gpu = on; + } if let Some(h) = p.get("home").and_then(Value::as_bool) { app.ui.home = h; } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c270442..e4577bb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -135,6 +135,10 @@ pub fn show(app: &mut FigApp, ctx: &Context) { for c in toggles { app.run(c, json!({})); } + #[cfg(feature = "gpu-canvas")] + if crate::widgets::checkbox(ui, app.canvas.gpu, "GPU canvas (experimental)") { + app.canvas.gpu = !app.canvas.gpu; + } // Where the app registers itself as the opener of invite links (Windows). if let Some(on) = app.session.invite_links_on() { if crate::widgets::checkbox(ui, on, "Open invite links in FigCraft") { diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 18e4a2c..8ba1a52 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -210,6 +210,7 @@ fn a_finished_background_render_wakes_the_ui() { outlined: vec![], skip: vec![], solo: None, + gpu: false, }); let start = Instant::now(); while wakes.load(Ordering::SeqCst) == 0 { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cdf7234..02fb934 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -856,6 +856,8 @@ impl FigApp { figcraft_render::Work::Partial { pixels } => json!({"kind": "partial", "pixels": pixels}), figcraft_render::Work::Full => json!({"kind": "full"}), }), + "gpu": self.canvas.gpu, + "gpuFallback": self.canvas.gpu_status.and_then(Result::err), }, "live": live::inspect(self), "tool": self.session.tool_info(), diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index 3ec7a1b..744a236 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -21,6 +21,8 @@ pub struct Job { pub skip: Vec, /// Only this layer (the focus view). pub solo: Option, + /// Render on the GPU where it can (the experimental `gpu-canvas` feature). + pub gpu: bool, } pub struct Done { @@ -30,6 +32,31 @@ pub struct Done { pub view: View, pub ms: f64, pub work: Work, + /// Rendered on the GPU, or why it fell back to the CPU (`None`: not asked for). + pub gpu: Option>, +} + +/// The GPU canvas, made on first use (`gpu-canvas` feature, desktop). +#[derive(Default)] +struct Gpu { + #[cfg(all(feature = "gpu-canvas", not(target_arch = "wasm32")))] + canvas: Option>, +} + +impl Gpu { + /// `job` rendered on the GPU, or why not (`None`: not asked for). + fn render(&mut self, job: &Job, opts: &figcraft_render::Options) -> Option> { + #[cfg(all(feature = "gpu-canvas", not(target_arch = "wasm32")))] + if job.gpu { + let canvas = self.canvas.get_or_insert_with(|| figcraft_render_gpu::Canvas::new().inspect_err(|e| log::warn!("gpu canvas: {e}"))); + return Some(match canvas { + Ok(c) => c.render(&job.doc, job.page, &job.view, opts), + Err(_) => Err("no GPU adapter"), + }); + } + let _ = (self, job, opts); + None + } } /// A render of a document in `profile` as a texture image for the screen (sRGB). @@ -43,7 +70,7 @@ pub fn screen_image(pix: &Pixmap, profile: ColorProfile) -> egui::ColorImage { egui::ColorImage::from_rgba_premultiplied(size, &data) } -fn run(job: &Job, retained: &mut Retained) -> Option { +fn run(job: &Job, retained: &mut Retained, gpu: &mut Gpu) -> Option { #[cfg(not(target_arch = "wasm32"))] let t0 = std::time::Instant::now(); // The canvas is a preview: what's too small to see in detail at this zoom is approximated. @@ -55,13 +82,19 @@ fn run(job: &Job, retained: &mut Retained) -> Option { lod: true, ..Default::default() }; - let (view, pix, work) = retained.render(&job.doc, job.page, &job.view, &opts)?; - let image = screen_image(pix, job.doc.color_profile); + let tried = gpu.render(job, &opts); + let (view, image, work) = match &tried { + Some(Ok(pix)) => (job.view.clone(), screen_image(pix, job.doc.color_profile), Work::Full), + _ => { + let (view, pix, work) = retained.render(&job.doc, job.page, &job.view, &opts)?; + (view, screen_image(pix, job.doc.color_profile), work) + } + }; #[cfg(not(target_arch = "wasm32"))] let ms = t0.elapsed().as_secs_f64() * 1000.0; #[cfg(target_arch = "wasm32")] let ms = 0.0; - Some(Done { image, view, ms, work }) + Some(Done { image, view, ms, work, gpu: tried.map(|r| r.map(|_| ())) }) } #[cfg(not(target_arch = "wasm32"))] @@ -84,12 +117,13 @@ impl Worker { let done_with = finished.clone(); let spawned = std::thread::Builder::new().name("figcraft-render".into()).spawn(move || { let mut retained = Retained::default(); + let mut gpu = Gpu::default(); while let Ok((mut seq, mut job)) = jobs.recv() { // Skip to the newest queued job. while let Ok(newer) = jobs.try_recv() { (seq, job) = newer; } - let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&job, &mut retained))); + let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&job, &mut retained, &mut gpu))); let done = match res { Ok(d) => d, Err(_) => { @@ -138,12 +172,13 @@ impl Worker { pub struct Worker { pending: Option, retained: Retained, + gpu: Gpu, } #[cfg(target_arch = "wasm32")] impl Worker { pub fn new(_ctx: egui::Context) -> Self { - Self { pending: None, retained: Retained::default() } + Self { pending: None, retained: Retained::default(), gpu: Gpu::default() } } pub fn submit(&mut self, job: Job) { self.pending = Some(job); @@ -153,11 +188,11 @@ impl Worker { } pub fn poll(&mut self) -> Option { let job = self.pending.take()?; - run(&job, &mut self.retained) + run(&job, &mut self.retained, &mut self.gpu) } } /// Render synchronously from scratch (tests, screenshots). pub fn render_now(job: &Job) -> Option { - run(job, &mut Retained::default()) + run(job, &mut Retained::default(), &mut Gpu::default()) } diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 612c31e..066c099 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -15,6 +15,8 @@ const TABLE: &[(&str, u8)] = &[ ("collab", 2), ("plugin", 2), ("render", 3), + // The experimental GPU canvas (vello): above the CPU renderer it falls back to. + ("render-gpu", 4), ("format", 3), ("svg", 4), ("pdf", 4),