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.
This commit is contained in:
parent
a003e3de4f
commit
ef15ab67cf
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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<f64>)>);
|
||||
|
||||
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<Document> {
|
||||
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<Document>, 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<usize> = 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!("| {:<w$} |", "ms (mean)");
|
||||
let mut rule = format!("|{}|", "-".repeat(w + 2));
|
||||
for n in &sizes {
|
||||
head.push_str(&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:<w$} |{cells}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
//! Experimental GPU canvas renderer (a spike): the scene graph as a [vello] scene, rasterised by
|
||||
//! compute shaders on wgpu.
|
||||
//!
|
||||
//! The CPU renderer (`figcraft-render`) stays the reference, the default and the only renderer of
|
||||
//! exports. [`Canvas::draw`] returns the reason ([`Fallback`]) when a frame holds something this
|
||||
//! doesn't draw yet (effects, masks, pattern fills, outline views); the caller then renders that
|
||||
//! frame on the CPU.
|
||||
//!
|
||||
//! The scene is built in page coordinates and kept per top-level layer: a pan or zoom only
|
||||
//! re-renders it through a new transform, an edit rebuilds the top-level layers it touched.
|
||||
|
||||
pub mod scene;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::{Document, NodeId, NodeKind};
|
||||
use figcraft_geom::Rect;
|
||||
use figcraft_render::{Options, Pixmap, View};
|
||||
pub use scene::{Builder, Fallback, Images};
|
||||
use vello::{AaConfig, AaSupport, RenderParams, Renderer, RendererOptions, Scene, wgpu};
|
||||
|
||||
/// More changed layers than this rebuild the whole scene.
|
||||
const MAX_CHANGED: usize = 4096;
|
||||
|
||||
/// A wgpu device with a vello renderer and a render target (resized as needed) it reads back.
|
||||
pub struct Gpu {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
renderer: Renderer,
|
||||
target: Option<Target>,
|
||||
/// 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<Self, String> {
|
||||
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<Pixmap> {
|
||||
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<Rect>), Fallback>;
|
||||
|
||||
/// What the kept scene was built from.
|
||||
struct Kept {
|
||||
doc: Arc<Document>,
|
||||
page: NodeId,
|
||||
skip: Vec<NodeId>,
|
||||
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<Kept>,
|
||||
frame: Scene,
|
||||
}
|
||||
|
||||
impl Canvas {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
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<Document>, 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<Document>, page: NodeId, view: &View, opts: &Options) -> Result<Pixmap, Fallback> {
|
||||
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<Document>, 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<NodeId, Part> = 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<HashSet<NodeId>> {
|
||||
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;
|
||||
|
|
@ -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<usize, (Arc<Pixmap>, ImageData)>);
|
||||
|
||||
struct PixBytes(Arc<Pixmap>);
|
||||
|
||||
impl AsRef<[u8]> for PixBytes {
|
||||
fn as_ref(&self) -> &[u8] {
|
||||
self.0.data()
|
||||
}
|
||||
}
|
||||
|
||||
impl Images {
|
||||
fn get(&mut self, p: &Arc<Pixmap>) -> 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<NodeId>) -> 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<usize, BezPath> = 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<usize> = 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<Option<(peniko::Brush, Option<Affine>)>, 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<peniko::ColorStop> =
|
||||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<u8> {
|
||||
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::<u64>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ pub struct CanvasState {
|
|||
submitted: Option<JobKey>,
|
||||
/// What the last canvas render had to do (shown by `ui.inspect`).
|
||||
pub render_work: Option<figcraft_render::Work>,
|
||||
/// 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<Result<(), &'static str>>,
|
||||
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<NodeId>,
|
||||
/// 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);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ fn big_job(doc: &Arc<figcraft_doc::Document>, page: figcraft_doc::NodeId, scale:
|
|||
outlined: vec![],
|
||||
skip: vec![],
|
||||
solo: None,
|
||||
gpu: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ pub struct Job {
|
|||
pub skip: Vec<NodeId>,
|
||||
/// Only this layer (the focus view).
|
||||
pub solo: Option<NodeId>,
|
||||
/// 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<Result<(), &'static str>>,
|
||||
}
|
||||
|
||||
/// 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<Result<figcraft_render_gpu::Canvas, String>>,
|
||||
}
|
||||
|
||||
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<Result<Pixmap, &'static str>> {
|
||||
#[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<Done> {
|
||||
fn run(job: &Job, retained: &mut Retained, gpu: &mut Gpu) -> Option<Done> {
|
||||
#[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<Done> {
|
|||
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<Job>,
|
||||
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<Done> {
|
||||
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<Done> {
|
||||
run(job, &mut Retained::default())
|
||||
run(job, &mut Retained::default(), &mut Gpu::default())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue