mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
perf(canvas): skip occluded checker on CPU textures
This commit is contained in:
@@ -142,6 +142,9 @@ pub struct CanvasCache {
|
||||
/// `revision`, so the Navigator never shows a stale CPU image after GPU-path edits.
|
||||
pub tex_revision: u64,
|
||||
pub tex_preview_key: u64,
|
||||
/// All uploaded texels are opaque: the checker behind this image cannot be seen. Updated
|
||||
/// with uploads, never by scanning the document during a pan or other unchanged frame.
|
||||
pub texture_opaque: bool,
|
||||
/// The CPU path's pixel grid for the part of the document in view (pixel_grid.rs).
|
||||
pub grid: Option<crate::pixel_grid::GridLines>,
|
||||
}
|
||||
@@ -1423,6 +1426,7 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize,
|
||||
on_gpu: false,
|
||||
tex_revision: 0,
|
||||
tex_preview_key: 0,
|
||||
texture_opaque: false,
|
||||
grid: None,
|
||||
});
|
||||
if cache.tex_revision != revision || cache.texture.is_none() || cache.tex_preview_key != preview_key {
|
||||
@@ -1438,7 +1442,11 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize,
|
||||
let r = buf.rect;
|
||||
if !r.is_empty() {
|
||||
let t1 = crate::gpu_canvas::now_ms();
|
||||
t.set_partial([r.x0 as usize, r.y0 as usize], display_image(display.as_deref(), &buf), canvas_texture_options(factor));
|
||||
let image = display_image(display.as_deref(), &buf);
|
||||
// Partial uploads can disprove opacity. Once transparent, conservatively keep
|
||||
// the checker until a full upload proves the whole texture opaque again.
|
||||
cache.texture_opaque &= image.pixels.iter().all(|p| p.is_opaque());
|
||||
t.set_partial([r.x0 as usize, r.y0 as usize], image, canvas_texture_options(factor));
|
||||
let px = (r.width() as u64 * r.height() as u64).saturating_mul(u64::from(factor).pow(2));
|
||||
app.perf.record("rect", px, t1 - t0, crate::gpu_canvas::now_ms() - t1);
|
||||
}
|
||||
@@ -1447,6 +1455,7 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize,
|
||||
let full = photocraft_compose::render_reduced(&doc, w, h);
|
||||
let t1 = crate::gpu_canvas::now_ms();
|
||||
let (img, scale) = (display_image(display.as_deref(), &full), 1.0 / factor as f32);
|
||||
cache.texture_opaque = img.pixels.iter().all(|p| p.is_opaque());
|
||||
match cache.texture.as_mut() {
|
||||
Some(t) if t.size() == img.size => t.set(img, canvas_texture_options(factor)),
|
||||
_ => cache.texture = Some(ctx.load_texture(format!("canvas-{}-{}", id.0, cache_key(id, output).1), img, canvas_texture_options(factor))),
|
||||
@@ -1612,6 +1621,7 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible:
|
||||
on_gpu: false,
|
||||
tex_revision: 0,
|
||||
tex_preview_key: 0,
|
||||
texture_opaque: false,
|
||||
grid: None,
|
||||
});
|
||||
let present = cache.on_gpu && gpu.has(id.0, size);
|
||||
@@ -3089,9 +3099,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
let rotated = view.rotation.abs() > 0.05;
|
||||
let checkerboard = app.session.prefs().transparency_and_gamut.square().map(|square| (checker(app, &ctx), square));
|
||||
let tex = ensure_texture(app, &ctx, idx, output).map(|(tex, _scale)| tex);
|
||||
paint_cpu_document(&painter, &xf, doc.bounds(), rotated, checkerboard, tex);
|
||||
let opaque = app.canvases.get(&cache_key(doc.id, output)).is_some_and(|c| c.texture_opaque) && tex.is_some();
|
||||
paint_cpu_document(&painter, &xf, doc.bounds(), rotated, checkerboard, tex, opaque);
|
||||
for &c in &pattern {
|
||||
paint_cpu_document(&painter, &pattern_xf(&xf, c, size), doc.bounds(), rotated, checkerboard, tex);
|
||||
paint_cpu_document(&painter, &pattern_xf(&xf, c, size), doc.bounds(), rotated, checkerboard, tex, opaque);
|
||||
}
|
||||
}
|
||||
// Channels panel: alpha / Quick Mask overlays and single-channel views (channel_view.rs).
|
||||
@@ -4242,11 +4253,12 @@ fn paint_cpu_document(
|
||||
rotated: bool,
|
||||
checker: Option<(egui::TextureId, f32)>,
|
||||
tex: Option<egui::TextureId>,
|
||||
opaque: bool,
|
||||
) {
|
||||
let size = [bounds.width() as f32, bounds.height() as f32];
|
||||
let img_rect = xf.doc_rect(bounds);
|
||||
match checker {
|
||||
Some((checker_id, square)) => {
|
||||
Some((checker_id, square)) if !opaque || tex.is_none() => {
|
||||
if rotated {
|
||||
let tiles = vec2(size[0], size[1]) / (2.0 * square);
|
||||
paint_mapped_image(painter, xf, checker_id, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), size, Color32::WHITE);
|
||||
@@ -4255,6 +4267,9 @@ fn paint_cpu_document(
|
||||
painter.image(checker_id, img_rect, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), Color32::WHITE);
|
||||
}
|
||||
}
|
||||
// On software window adapters this hidden full-canvas pass is CPU rasterization too.
|
||||
// Keep the white-background branch unchanged (its antialiased outer edge is visible).
|
||||
Some(_) => {}
|
||||
None => {
|
||||
if rotated {
|
||||
paint_mapped_quad(painter, xf, size, Color32::WHITE);
|
||||
@@ -8705,3 +8720,7 @@ mod rotation_preview_isolation_tests {
|
||||
#[cfg(test)]
|
||||
#[path = "canvas_display_memo_tests.rs"]
|
||||
mod display_memo_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "canvas_cpu_background_tests.rs"]
|
||||
mod cpu_background_tests;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! CPU-canvas opacity invalidation and the software renderer's hidden checker pass.
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn cpu_canvas_omits_only_a_checker_hidden_by_an_opaque_texture() {
|
||||
let ctx = egui::Context::default();
|
||||
let image = ctx.load_texture("image", egui::ColorImage::filled([2, 2], Color32::WHITE), TextureOptions::LINEAR);
|
||||
let checker = ctx.load_texture("checker-test", egui::ColorImage::filled([2, 2], Color32::GRAY), TextureOptions::NEAREST);
|
||||
for rotated in [false, true] {
|
||||
for (opaque, texture, want_checker) in [(true, Some(image.id()), false), (false, Some(image.id()), true), (true, None, true)] {
|
||||
let mut output = ctx.run_ui(Default::default(), |ui| {
|
||||
let xf = ViewXform {
|
||||
rect: Rect::from_min_size(Pos2::ZERO, vec2(300.0, 240.0)),
|
||||
zoom: 1.0,
|
||||
center: [50.0, 40.0],
|
||||
flip: false,
|
||||
rotation: if rotated { 23.0 } else { 0.0 },
|
||||
aspect: 1.0,
|
||||
};
|
||||
paint_cpu_document(ui.painter(), &xf, DRect::new(0, 0, 100, 80), rotated, Some((checker.id(), 8.0)), texture, opaque);
|
||||
});
|
||||
let drawn = output.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Mesh(m) if m.texture_id == checker.id()));
|
||||
assert_eq!(drawn, want_checker, "rotated={rotated}, opaque={opaque}, texture={texture:?}");
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_texture_opacity_follows_full_and_partial_uploads() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
app.run("file.new", json!({"width": 32, "height": 24, "depth": depth, "background": "transparent"})).unwrap();
|
||||
let key = cache_key(app.session.documents()[0].doc.id, None);
|
||||
ensure_texture(&mut app, &ctx, 0, None).unwrap();
|
||||
assert!(!app.canvases[&key].texture_opaque, "{depth}: transparent canvas needs its checker");
|
||||
app.run("edit.fill", json!({"color": "#2050d0"})).unwrap();
|
||||
// Force a full upload to classify the complete image, as the first opaque image does.
|
||||
app.canvases.get_mut(&key).unwrap().texture = None;
|
||||
ensure_texture(&mut app, &ctx, 0, None).unwrap();
|
||||
assert!(app.canvases[&key].texture_opaque);
|
||||
app.run("select.rect", json!({"x": 2, "y": 3, "width": 4, "height": 5})).unwrap();
|
||||
ensure_texture(&mut app, &ctx, 0, None).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#e04010"})).unwrap();
|
||||
ensure_texture(&mut app, &ctx, 0, None).unwrap();
|
||||
assert_eq!(app.perf.last_refresh, "rect");
|
||||
assert!(app.canvases[&key].texture_opaque, "an opaque patch keeps the guarantee");
|
||||
app.run("edit.clear", json!({})).unwrap();
|
||||
ensure_texture(&mut app, &ctx, 0, None).unwrap();
|
||||
assert_eq!(app.perf.last_refresh, "rect");
|
||||
assert!(!app.canvases[&key].texture_opaque, "erasing a patch must show the checker again");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
ensure_texture(&mut app, &ctx, 0, None).unwrap();
|
||||
app.canvases.get_mut(&key).unwrap().texture = None;
|
||||
ensure_texture(&mut app, &ctx, 0, None).unwrap();
|
||||
assert!(app.canvases[&key].texture_opaque, "a full opaque upload can omit the checker again");
|
||||
app.perf.last_refresh = "";
|
||||
ensure_texture(&mut app, &ctx, 0, None).unwrap();
|
||||
assert_eq!(app.perf.last_refresh, "", "unchanged frames do not upload or classify pixels");
|
||||
}
|
||||
}
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, PhotocraftApp>;
|
||||
|
||||
fn harness(size: [u32; 2], background: &str) -> Harness {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
|
||||
}
|
||||
// app.gpu remains None: compose on the CPU, use the harness's software window adapter.
|
||||
app.run("file.new", json!({"width": size[0], "height": size[1], "background": background})).unwrap();
|
||||
app.run("select.rect", json!({"x": size[0] / 2, "y": size[1] / 2, "width": size[0] / 10, "height": size[1] / 10})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#2050d0"})).unwrap();
|
||||
app.run("select.deselect", json!({})).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(12);
|
||||
let key = cache_key(h.state().session.documents()[0].doc.id, None);
|
||||
assert!(h.state().session.prefs().transparency_and_gamut.square().is_some());
|
||||
assert_eq!(h.state().canvases[&key].texture_opaque, background == "white", "fixture opacity");
|
||||
h
|
||||
}
|
||||
|
||||
/// Draw the unchanged reference path by temporarily disabling only the opacity guarantee.
|
||||
fn reference_frame(h: &mut Harness) {
|
||||
let flags: Vec<_> = h.state().canvases.iter().map(|(k, c)| (*k, c.texture_opaque)).collect();
|
||||
for c in h.state_mut().canvases.values_mut() {
|
||||
c.texture_opaque = false;
|
||||
}
|
||||
h.step();
|
||||
for (k, opaque) in flags {
|
||||
if let Some(c) = h.state_mut().canvases.get_mut(&k) {
|
||||
c.texture_opaque = opaque;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitting_an_occluded_checker_preserves_cpu_canvas_pixels() {
|
||||
let Ok(mut h) = std::panic::catch_unwind(|| harness([320, 240], "white")) else {
|
||||
eprintln!("skipping: no offscreen graphics adapter");
|
||||
return;
|
||||
};
|
||||
for (rotation, flip, pattern) in [(0.0, false, false), (23.0, false, false), (0.0, true, false), (0.0, false, true)] {
|
||||
h.state_mut().ui.views[0].rotation = rotation;
|
||||
h.state_mut().ui.view.flip_horizontal = flip;
|
||||
h.state_mut().ui.view.pattern_preview = pattern;
|
||||
reference_frame(&mut h);
|
||||
let reference = h.render().unwrap();
|
||||
h.step();
|
||||
let optimized = h.render().unwrap();
|
||||
assert!(reference == optimized, "rotation={rotation} flip={flip} pattern={pattern}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual release benchmark: 24 MP CPU canvas on the offscreen software adapter"]
|
||||
fn cpu_canvas_background_benchmark() {
|
||||
use std::time::Instant;
|
||||
let mut results = Vec::new();
|
||||
let output = std::env::var_os("PHOTOCRAFT_CPU_BENCH_OUTPUT").map(std::path::PathBuf::from);
|
||||
for background in ["white", "transparent"] {
|
||||
let mut h = harness([6000, 4000], background);
|
||||
let v = &mut h.state_mut().ui.views[0];
|
||||
v.zoom = 1.0;
|
||||
v.center = [3000.0, 2000.0];
|
||||
v.fit_pending = false;
|
||||
v.fill_pending = false;
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
h.render().unwrap();
|
||||
}
|
||||
let mut before = Vec::new();
|
||||
let mut after = Vec::new();
|
||||
// Alternate order to reduce warm-up/thermal bias. Same cached image and UI in both paths.
|
||||
for n in 0..24 {
|
||||
for reference in if n % 2 == 0 { [true, false] } else { [false, true] } {
|
||||
h.state_mut().ui.views[0].center[0] = 3000.0 + if n % 2 == 0 { -3.0 } else { 3.0 };
|
||||
let t = Instant::now();
|
||||
if reference {
|
||||
reference_frame(&mut h);
|
||||
} else {
|
||||
h.step();
|
||||
}
|
||||
h.render().unwrap();
|
||||
(if reference { &mut before } else { &mut after }).push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
reference_frame(&mut h);
|
||||
let old = h.render().unwrap();
|
||||
h.step();
|
||||
let new = h.render().unwrap();
|
||||
assert!(old == new, "{background}: same rendered pixels");
|
||||
if let Some(dir) = &output {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
old.save(dir.join(format!("cpu-{background}-before.png"))).unwrap();
|
||||
new.save(dir.join(format!("cpu-{background}-after.png"))).unwrap();
|
||||
}
|
||||
before.sort_by(f64::total_cmp);
|
||||
after.sort_by(f64::total_cmp);
|
||||
results.push(json!({
|
||||
"background": background, "document_pixels": 24_000_000, "viewport": [1440, 900],
|
||||
"opaque_texture": background == "white", "checker_enabled": true,
|
||||
"adapter": h.state().perf.gpu_info, "samples_per_path": before.len(),
|
||||
"reference_p50_ms": before[11], "reference_p95_ms": before[22],
|
||||
"optimized_p50_ms": after[11], "optimized_p95_ms": after[22],
|
||||
"reference_ms": before, "optimized_ms": after,
|
||||
"includes": "UI frame, cached CPU canvas rasterization and synchronous readback; no document recomposite",
|
||||
}));
|
||||
}
|
||||
let report = serde_json::to_string_pretty(&results).unwrap();
|
||||
println!("{report}");
|
||||
if let Some(dir) = output {
|
||||
std::fs::write(dir.join("cpu-background-benchmark.json"), report).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -307,6 +307,18 @@ translations do not affect the denominator. Unregistered locale TSV files are ig
|
||||
|
||||
## Performance notes
|
||||
|
||||
- The CPU canvas records whether an uploaded image is entirely opaque, so software window
|
||||
adapters do not rasterize a transparency checker that the image completely hides. An edit
|
||||
that introduces transparency restores the checker; partial uploads conservatively retain it
|
||||
until a full upload proves the image opaque again. Pan/zoom frames reuse this metadata.
|
||||
Compare the old checker pass with the optimized path on a synthetic 24 MP document using
|
||||
`cargo test --release -p photocraft-ui-egui --lib cpu_canvas_background_benchmark -- --ignored --nocapture`.
|
||||
The benchmark alternates both paths on the same cached image and reports the adapter and
|
||||
per-frame p50/p95, including offscreen rasterization and synchronous readback. It checks
|
||||
identical rendered pixels for opaque and transparent images. This measures warm canvas
|
||||
presentation, not input-to-photon latency or document recompositing. Set the test-only
|
||||
`PHOTOCRAFT_CPU_BENCH_OUTPUT` to a directory to save its JSON and before/after PNGs.
|
||||
|
||||
- The canvas is presented by a custom WGSL shader (`ui-egui/src/gpu_canvas.rs`): mip-mapped/nearest sampling, procedural checkerboard, pixel grid, tiling. Brush strokes upload only their damage rect.
|
||||
- **The canvas composites on the GPU** (`photocraft-gpu`, driven from `gpu_canvas.rs`), layer effects included. What the planner can't express returns `Unsupported` and the canvas falls back to the CPU compositor (`photocraft-compose`, also the reference for export and tests): Multichannel documents, and documents or effect regions over the texture limit. Pieces the GPU can't derive itself are rasterised once on the CPU and cached (`compose::masks` for vector masks, `compose::shape_split` for stroked shapes with clipped layers, effect distance fields). Timings of the interactive paths: `cargo run --release -p photocraft-ui-egui --example interactive_bench`; effects: `--example fx_bench` (`--compare files…` for GPU vs CPU); the cost of changing a chisel bevel's size (a dragged slider, GPU and CPU, ellipse and text): `--example bevel_bench`; large documents (open, refresh, thumbnails, a filter, a stroke, saves; one operation per run so `/usr/bin/time -l` gives its peak memory): `--example large_image_bench -- --size 14000x14000 --op psd`. The app requests the adapter's own texture limit (egui's default is 8192 px; see `gpu_canvas::use_adapter_limits`); beyond it the CPU fallback composites and uploads in bands (`compose::render_bands`), and exports, thumbnails and flattening stream bands too, so no full-size float composite is ever held. Rendering fidelity: `cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd` (the whole PSD oracle table in seconds). `ui.inspect` → `perf.timings.gpuFallback` names the reason (`null` on the GPU path).
|
||||
- **Layer effects on the GPU** (`gpu/src/fx.rs`, kernels in `gpu/src/compose.wgsl`). Every enabled effect becomes a *map program* over the layer's effect region (shift, dilate, Gaussian blur, glow ramp, bevel height and shading, contour, stroke band), mirroring `compose::effects` step by step; the chunked composite then paints through the maps, clipped to that region, and copies the result back into the backdrop in place, so a small text layer costs only its own pixels. The layer's shape (`compose::layer_shape`) and its distance fields (`compose::effects::distance_field`: a transform whose tie-breaking a parallel GPU pass can't reproduce bit for bit) come from compose on the CPU: bands computed in parallel, except the Euclidean fields of precise glows and chiselled bevels (a halo as tall as the bevel size would make most of a band's work redundant), which are one window whose passes run on all threads, the CPU compositor's own way. Everything is cached per layer state: an unrelated edit, or an effect's colour or opacity, rebuilds nothing; a brush dab recomputes the touched 256² tiles grown by the effect reach; changing one effect's geometry rebuilds that effect only. Cache budget `gpu::FX_BUDGET` (1.5 GB, least recently drawn layers evicted first). `PHOTOCRAFT_FX_TRACE=1` prints the CPU time of each rebuild.
|
||||
|
||||
Reference in New Issue
Block a user