perf(ui): the Navigator draws the GPU canvas's texture instead of a CPU thumbnail (#4518) (#4701)

With the GPU canvas, the Navigator still composited the document on
the CPU on the UI thread after every edit: render_reduced_damage over
the full-resolution damage, or a full thumbnail_buffer composite when
the document has effects. On the #209 layered 20 MP document that was
126–137 ms after a small opacity edit and about 700 ms for a
full-canvas layer, against 3.4 ms for the canvas itself.

While the main canvas shows the plain document on the GPU canvas, the
Navigator now paints that same texture through the canvas's own
shader (GpuCanvas::paint) at its size: no composite, no readback, with
the display transform the canvas uses. Everywhere else (a CPU canvas,
a flipped view, an adjustment proxy or preview) it keeps the CPU
thumbnail. Until the canvas has drawn a new document it waits up to
three frames rather than build a CPU thumbnail it would use once.

The preview is resampled through the display mips instead of the CPU
thumbnail's area sampling: thin lines average to even grey instead of
aliasing, and edges are a little softer.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FinnNk
2026-10-13 12:29:25 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 13697cf5f5
commit cdfec72f71
4 changed files with 158 additions and 3 deletions
+63
View File
@@ -1189,6 +1189,54 @@ fn navigator_waits_for_drag(app: &PhotocraftApp, idx: usize) -> bool {
/// The Navigator panel's image of document `idx`. With the CPU canvas that is the canvas texture
/// itself; with the GPU canvas a thumbnail cached per revision, so an edit to a huge document
/// doesn't also pay a full-resolution CPU composite for the navigator.
/// The Navigator's preview from the GPU canvas (#4518): the main canvas's texture of the
/// document, drawn at `rect` with its own mip chain, so an edit costs no CPU composite. `None`
/// while the canvas isn't showing the plain document on the GPU (the CPU thumbnail then).
pub fn navigator_gpu_view(app: &PhotocraftApp, idx: usize, rect: Rect, ctx: &egui::Context) -> Option<crate::gpu_canvas::ViewParams> {
app.gpu.as_ref()?;
let st = app.session.documents().get(idx)?;
let params = *app.navigator_gpu.get(&st.doc.id)?;
let [w, h] = params.doc_size;
if w == 0 || h == 0 || !rect.is_positive() || [st.doc.size.width, st.doc.size.height] != params.doc_size {
return None;
}
Some(crate::gpu_canvas::ViewParams {
zoom: rect.width() / w as f32,
center: [w as f32 / 2.0, h as f32 / 2.0],
rotation: 0.0,
shadow: false,
pixel_grid: false,
view_key: egui::Id::new(("pc-navigator", ctx.viewport_id(), idx)).value(),
offset: [0.0, 0.0],
..params
})
}
/// Frames the Navigator waits for the GPU canvas to draw a document it hasn't drawn yet, rather
/// than build a CPU thumbnail it would only use once (a full composite on a large document).
const NAVIGATOR_WAIT_FRAMES: u32 = 3;
/// Whether the Navigator should wait a frame for the GPU canvas (#4518): the GPU canvas is on but
/// hasn't drawn document `idx` yet (the Navigator can draw before the canvas in a frame). After
/// [`NAVIGATOR_WAIT_FRAMES`] it stops waiting, so it never stays empty.
pub fn navigator_waits_for_gpu(app: &PhotocraftApp, idx: usize, ctx: &egui::Context) -> bool {
if app.gpu.is_none() {
return false;
}
let Some(id) = app.session.documents().get(idx).map(|st| st.doc.id) else { return false };
if app.canvases.keys().any(|(d, _)| *d == id) {
return false;
}
let key = egui::Id::new(("navigator-waits", id.0));
let waited = ctx.data(|d| d.get_temp::<u32>(key)).unwrap_or(0);
if waited >= NAVIGATOR_WAIT_FRAMES {
return false;
}
ctx.data_mut(|d| d.insert_temp(key, waited + 1));
ctx.request_repaint();
true
}
pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Option<egui::TextureId> {
if app.gpu.is_none() {
return ensure_texture(app, ctx, idx, app.session.color.main_display).map(|(t, _)| t);
@@ -1463,6 +1511,14 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize, visible: DRect) -> bool {
damage = Some(crate::adjust_preview::switch_damage(app, raw_key, r, visible));
}
let preview_key = raw_key ^ display_key;
// The Navigator may draw this texture only while it holds the plain document (no live
// preview, which can be composited only where the view is).
if raw_key == 0 {
app.navigator_plain.insert(id);
} else {
app.navigator_plain.remove(&id);
app.navigator_gpu.remove(&id);
}
let size = [doc.size.width, doc.size.height];
let cache = app.canvases.entry((id, GPU_OUTPUT)).or_insert(CanvasCache {
revision: 0,
@@ -2794,6 +2850,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
ensure_adjust_proxy(app, idx, view.zoom).or_else(|| ensure_filter_preview(app, idx, &ctx)).or_else(|| ensure_proxy_preview(app, idx))
{
on_gpu = true;
app.navigator_gpu.remove(&doc.id);
let original_size = [doc.size.width.div_ceil(k), doc.size.height.div_ceil(k)];
let params = crate::gpu_canvas::ViewParams {
doc: key,
@@ -2838,8 +2895,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
hdr: hdr_preview(app, &doc),
offset: [0.0, 0.0],
};
if app.navigator_plain.contains(&doc.id) {
app.navigator_gpu.insert(doc.id, params);
} else {
app.navigator_gpu.remove(&doc.id);
}
paint_gpu_view(&painter, rect, params, &pattern);
} else {
app.navigator_gpu.remove(&doc.id);
if !crate::theme::Tokens::get(&ctx).bevel && drop_shadow {
painter.add(egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(150) }.as_shape(img_rect, 0));
}
+10
View File
@@ -502,6 +502,11 @@ pub struct PhotocraftApp {
/// `canvas::display_doc`'s live-adjust and Duotone documents per document: (snapshot,
/// preview key, the document shown).
display_memo: HashMap<DocId, (std::sync::Weak<Document>, u64, std::sync::Arc<Document>)>,
/// The main canvas's last GPU view of each document while it shows the plain document on
/// the GPU canvas: the Navigator draws the same texture at its own size (#4518).
navigator_gpu: HashMap<DocId, crate::gpu_canvas::ViewParams>,
/// Documents whose GPU canvas texture holds the plain document (`canvas::ensure_gpu`).
navigator_plain: std::collections::HashSet<DocId>,
/// Display profile readings (#569).
monitors: monitor_status::State,
checker: Option<egui::TextureHandle>,
@@ -755,6 +760,8 @@ impl PhotocraftApp {
canvases: HashMap::new(),
navigator_textures: HashMap::new(),
display_memo: HashMap::new(),
navigator_gpu: HashMap::new(),
navigator_plain: std::collections::HashSet::new(),
monitors: Default::default(),
checker: None,
drag: None,
@@ -2437,6 +2444,9 @@ impl PhotocraftApp {
}
}
#[cfg(test)]
mod navigator_gpu_tests;
#[cfg(test)]
mod color_swatch_tests;
+75
View File
@@ -0,0 +1,75 @@
//! The Navigator draws the GPU canvas's own texture while the canvas is on the GPU (#4518), so an
//! edit costs it no CPU composite; elsewhere (a flipped view draws on the CPU) it keeps the CPU
//! thumbnail. Renders the real app offscreen; skips without a GPU adapter.
use crate::PhotocraftApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, PhotocraftApp>;
fn harness() -> Option<Harness> {
let built = std::panic::catch_unwind(|| {
egui_kittest::Harness::builder().with_size(egui::vec2(1400.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(), crate::Services::default());
if let Some(rs) = cc.wgpu_render_state.as_ref() {
app.set_wgpu(rs.clone());
}
app.ui.panels.navigator = true;
app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap();
app.run("select.rect", json!({"x": 0, "y": 0, "width": 200, "height": 300})).unwrap();
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
app.run("select.deselect", json!({})).unwrap();
app.sync_views();
app
})
});
match built {
Ok(h) => Some(h),
Err(_) => {
eprintln!("skipping: no GPU adapter");
None
}
}
}
/// The colour at fractions (`fx`, `fy`) of the Navigator preview.
fn preview_px(h: &mut Harness, fx: f32, fy: f32) -> [u8; 4] {
let r = crate::panels::last_navigator_preview(&h.ctx).expect("navigator preview drawn");
let img = h.render().expect("render");
let (x, y) = ((r.left() + r.width() * fx) as u32, (r.top() + r.height() * fy) as u32);
img.get_pixel(x, y).0
}
fn near(a: [u8; 4], b: [u8; 3]) -> bool {
(0..3).all(|i| a[i].abs_diff(b[i]) <= 8)
}
#[test]
fn navigator_draws_the_gpu_canvas_texture_and_follows_edits() {
let Some(mut h) = harness() else { return };
h.run_steps(8);
if !h.state().perf.gpu {
eprintln!("skipping: the canvas fell back to the CPU here");
return;
}
let id = h.state().session.active().expect("doc").doc.id;
assert!(h.state().navigator_gpu.contains_key(&id), "the Navigator uses the GPU canvas view");
assert!(!h.state().navigator_textures.contains_key(&id), "no CPU thumbnail was built");
assert!(near(preview_px(&mut h, 0.25, 0.5), [255, 0, 0]), "left half red");
assert!(near(preview_px(&mut h, 0.75, 0.5), [255, 255, 255]), "right half white");
// An edit shows in the Navigator, still without a CPU thumbnail.
h.state_mut().run("select.rect", json!({"x": 200, "y": 0, "width": 200, "height": 300})).unwrap();
h.state_mut().run("edit.fill", json!({"color": "#0000ff"})).unwrap();
h.run_steps(6);
assert!(near(preview_px(&mut h, 0.75, 0.5), [0, 0, 255]), "right half now blue");
assert!(!h.state().navigator_textures.contains_key(&id));
// A flipped view draws on the CPU: the Navigator goes back to its CPU thumbnail.
h.state_mut().ui.view.flip_horizontal = true;
h.run_steps(6);
assert!(!h.state().navigator_gpu.contains_key(&id), "no GPU view while the canvas draws on the CPU");
assert!(h.state().navigator_textures.contains_key(&id), "the CPU thumbnail again");
assert!(near(preview_px(&mut h, 0.25, 0.5), [255, 0, 0]) && near(preview_px(&mut h, 0.75, 0.5), [0, 0, 255]), "same picture, unflipped");
}
+10 -3
View File
@@ -1879,7 +1879,6 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
return;
};
let ctx = ui.ctx().clone();
let Some(tex) = crate::canvas::navigator_texture(app, &ctx, idx) else { return };
let size = app.session.documents()[idx].doc.size;
let avail = ui.available_width();
// The preview fills the group's height above the zoom controls (their height as measured
@@ -1891,8 +1890,16 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.painter().rect_filled(frame, t.radius_sm, t.canvas);
let rect = navigator_fit(frame, size.width, size.height);
ui.data_mut(|d| d.insert_temp(navigator_preview_id(), rect));
widgets::checker(ui.painter(), rect, 6.0);
ui.painter().image(tex, rect, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
// On the GPU canvas, its own texture at this size (#4518); otherwise the CPU thumbnail.
if let Some(params) = crate::canvas::navigator_gpu_view(app, idx, rect, &ctx) {
crate::gpu_canvas::GpuCanvas::paint(ui.painter(), rect, params);
} else if crate::canvas::navigator_waits_for_gpu(app, idx, &ctx) {
widgets::checker(ui.painter(), rect, 6.0);
} else {
let Some(tex) = crate::canvas::navigator_texture(app, &ctx, idx) else { return };
widgets::checker(ui.painter(), rect, 6.0);
ui.painter().image(tex, rect, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
}
let s = rect.width() / size.width.max(1) as f32;
if (resp.clicked() || resp.dragged())
&& let Some(pp) = resp.interact_pointer_pos()