perf(canvas): skip occluded checker on CPU textures

This commit is contained in:
hogawish
2026-10-17 19:28:31 +09:00
parent de73a4da18
commit c907301d27
3 changed files with 215 additions and 4 deletions
+23 -4
View File
@@ -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();
}
}
+12
View File
@@ -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.