Limit blend mode hover refresh to the visible canvas

This commit is contained in:
jonathnwxh
2026-10-14 12:41:22 +08:00
parent 369704ac55
commit e2ed5cdcb7
3 changed files with 185 additions and 2 deletions
+30 -1
View File
@@ -29,6 +29,8 @@ pub(crate) struct BlendPreview {
area: Option<Rect>,
/// The document shown for `mode`, with its key.
shown: Option<(u64, Arc<Document>)>,
/// GPU preview key, affected region (including effects), and the area last refreshed.
covered: Option<(u64, Rect, Rect)>,
}
/// The Layers panel, each frame it draws `layer`'s blend-mode dropdown: the mode under the
@@ -47,7 +49,7 @@ pub(crate) fn hover(app: &mut PhotocraftApp, layer: LayerId, mode: Option<BlendM
}
app.blend_preview = mode.and_then(|mode| {
let area = photocraft_compose::change_bounds(st.doc.layer(layer)?, st.doc.bounds());
Some(BlendPreview { doc, revision, layer, mode: Some(mode), frame, area, shown: None })
Some(BlendPreview { doc, revision, layer, mode: Some(mode), frame, area, shown: None, covered: None })
});
}
@@ -81,6 +83,33 @@ pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, seen: u64,
(seen != now && ours(seen) && ours(now) && (is_preview_key(seen) || is_preview_key(now))).then_some(p.area)?
}
/// Limit an active preview to the view. Returning to the document restores the whole region.
pub(crate) fn clip_damage(app: &mut PhotocraftApp, doc: DocId, revision: u64, key: u64, region: Rect, visible: Rect) -> Rect {
let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision) else { return region };
if !p.shown.as_ref().is_some_and(|(shown, _)| *shown == key) {
p.covered = None;
return region;
}
let shown = region.intersect(&visible);
p.covered = Some((key, region, shown));
shown
}
/// A pan or zoom can expose pixels not yet rendered for the current preview key.
pub(crate) fn uncovered(app: &mut PhotocraftApp, doc: DocId, revision: u64, key: u64, visible: Rect) -> Option<Rect> {
let p = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision)?;
let (covered_key, region, covered) = p.covered?;
if covered_key != key {
return None;
}
let want = region.intersect(&visible);
if want.is_empty() || covered.contains_rect(&want) {
return None;
}
p.covered = Some((key, region, want));
Some(want)
}
fn is_preview_key(key: u64) -> bool {
(BASE..BASE << 1).contains(&key)
}
+13 -1
View File
@@ -1545,6 +1545,9 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible:
let display_key = texture_key(display.as_deref());
let seen = app.canvases.get(&(id, GPU_OUTPUT)).map(|c| (c.revision, c.preview_key));
let mut damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, raw_key), display_key, last_damage));
if let Some(region) = damage {
damage = Some(crate::blend_preview::clip_damage(app, id, revision, raw_key, region, visible));
}
// To an adjustment dialog's preview: only what the view shows now (the rest as it moves there).
if damage.is_some()
&& let Some(r) = seen.filter(|s| s.0 == revision).and_then(|s| crate::adjust_preview::switch_region(app, id, revision, s.1 ^ display_key, raw_key))
@@ -1580,7 +1583,12 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible:
let r = gpu.refresh(id.0, &doc, Some(r), display.as_deref());
app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms);
}
return true;
// Blend previews keep offscreen pixels untouched until the view reaches them.
// Use the normal refresh path so CPU fallback and pending redraws stay synchronized.
damage = crate::blend_preview::uncovered(app, id, revision, raw_key, visible);
if damage.is_none() {
return true;
}
}
// Display textures for a canvas this large would not fit the GPU: the reduced CPU canvas
// draws it (checked before the memory budget, which walks the history, every frame).
@@ -8003,3 +8011,7 @@ mod rotation_preview_isolation_tests {
#[cfg(test)]
#[path = "canvas_display_memo_tests.rs"]
mod display_memo_tests;
#[cfg(test)]
#[path = "canvas/blend_viewport_tests.rs"]
mod blend_viewport_regression;
@@ -0,0 +1,142 @@
use super::*;
use photocraft_color::BlendMode;
use serde_json::json;
fn render_state() -> Option<eframe::egui_wgpu::RenderState> {
match std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default())) {
Ok(rs) => Some(rs),
Err(error) => {
if std::env::var_os("PHOTOCRAFT_REQUIRE_GPU_TESTS").is_some() {
std::panic::resume_unwind(error);
}
eprintln!("skipping blend viewport regression: no GPU adapter");
None
}
}
}
#[test]
fn blend_hover_refresh_is_limited_to_visible_canvas() {
check_blend_viewport(false);
}
#[test]
fn blend_hover_cpu_fallback_refresh_tracks_visible_canvas() {
check_blend_viewport(true);
}
#[test]
fn blend_hover_during_background_redraw_restores_committed_pixels() {
let _guard = photocraft_testkit::gpu_lock();
let Some(rs) = render_state() else { return };
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.set_wgpu(rs);
app.background_jobs = true;
app.run("file.new", json!({"width":2600,"height":1700,"background":"#336699"})).unwrap();
let layer = app.run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
app.run("edit.fill", json!({"color":"#cc8844"})).unwrap();
app.run("layer.layerStyle.blendingOptions", json!({"knockout":"shallow"})).unwrap();
let ctx = egui::Context::default();
let visible = DRect::new(256, 256, 512, 512);
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
assert!(!app.redraws.is_empty(), "fixture must start a deferred full redraw");
crate::blend_preview::hover(&mut app, photocraft_doc::LayerId(layer), Some(BlendMode::Multiply));
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
let panned = DRect::new(2048, 1024, 2304, 1280);
assert!(ensure_gpu(&mut app, &ctx, 0, panned));
settle_redraw(&mut app);
assert_visible_pixels(&mut app, panned);
crate::blend_preview::hover(&mut app, photocraft_doc::LayerId(layer), None);
assert!(ensure_gpu(&mut app, &ctx, 0, panned));
settle_redraw(&mut app);
let bounds = app.session.active().unwrap().doc.bounds();
assert_visible_pixels(&mut app, bounds);
}
fn settle_redraw(app: &mut PhotocraftApp) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
let gpu = app.gpu.clone().unwrap();
let id = app.session.active().unwrap().doc.id;
while !app.redraws.is_empty() {
crate::background_redraw::poll(app, &gpu, id);
assert!(std::time::Instant::now() < deadline, "background redraw did not finish");
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
fn assert_visible_pixels(app: &mut PhotocraftApp, visible: DRect) {
let (doc, _) = display_doc(app, 0);
let gpu = app.gpu.clone().unwrap();
let reference = doc.id.0 ^ (1u64 << 60);
let (display, _) = canvas_display(app, &doc, None);
gpu.upload_composite(reference, &doc, display.as_deref());
let (_, size, shown) = gpu.read_texels(doc.id.0).unwrap();
let (_, _, want) = gpu.read_texels(reference).unwrap();
for y in visible.y0..visible.y1 {
for x in visible.x0..visible.x1 {
let index = y as usize * size[0] as usize + x as usize;
for channel in 0..4 {
assert!((shown[index][channel] - want[index][channel]).abs() <= 2.0 / 255.0, "preview differs at ({x},{y}) channel{channel}");
}
}
}
}
fn check_blend_viewport(cpu_fallback: bool) {
let _guard = photocraft_testkit::gpu_lock();
let Some(rs) = render_state() else { return };
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.set_wgpu(rs);
app.run("file.new", json!({"width":1024,"height":768,"background":"#336699"})).unwrap();
let layer = app.run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
app.run("edit.fill", json!({"color":"#cc8844"})).unwrap();
if cpu_fallback {
app.run("layer.layerStyle.blendingOptions", json!({"knockout":"shallow"})).unwrap();
}
let ctx = egui::Context::default();
let visible = DRect::new(256, 256, 512, 512);
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
assert!(app.redraws.is_empty(), "small fixture must finish synchronously");
if cpu_fallback {
assert!(!app.perf.last_refresh.starts_with("gpu"), "fixture must exercise CPU composition fallback");
}
let id = app.session.active().unwrap().doc.id;
let gpu = app.gpu.clone().unwrap();
let original = gpu.read_texels(id.0).unwrap();
crate::blend_preview::hover(&mut app, photocraft_doc::LayerId(layer), Some(BlendMode::Multiply));
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
println!("BLEND_VIEWPORT kind={} pixels={} expected={} adapter={:?}", app.perf.last_refresh, app.perf.last_refresh_px, 256 * 256, app.perf.gpu_info);
assert_eq!(app.perf.last_refresh_px, 256 * 256, "hover must not recomposite offscreen pixels of a full-canvas layer");
assert_visible_pixels(&mut app, visible);
app.perf.last_refresh_px = 0;
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
assert_eq!(app.perf.last_refresh_px, 0, "unchanged view and mode must reuse the texture");
let zoomed_out = DRect::new(128, 128, 640, 640);
assert!(ensure_gpu(&mut app, &ctx, 0, zoomed_out));
assert_eq!(app.perf.last_refresh_px, 512 * 512, "zooming out catches up the larger visible area");
let panned = DRect::new(640, 256, 896, 512);
assert!(ensure_gpu(&mut app, &ctx, 0, panned));
assert_eq!(app.perf.last_refresh_px, 256 * 256, "newly exposed pixels must catch up without a mode change");
crate::blend_preview::hover(&mut app, photocraft_doc::LayerId(layer), Some(BlendMode::Screen));
assert!(ensure_gpu(&mut app, &ctx, 0, panned));
assert_eq!(app.perf.last_refresh_px, 256 * 256);
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
assert_eq!(app.perf.last_refresh_px, 256 * 256, "returning view must show the current mode");
assert_visible_pixels(&mut app, visible);
assert_visible_pixels(&mut app, panned);
crate::blend_preview::hover(&mut app, photocraft_doc::LayerId(layer), None);
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
assert_eq!(gpu.read_texels(id.0).unwrap(), original, "cancel must restore the whole texture, including previously previewed offscreen pixels");
crate::blend_preview::hover(&mut app, photocraft_doc::LayerId(layer), Some(BlendMode::Multiply));
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
app.run("layer.setProps", json!({"layer":layer,"blend":"Multiply"})).unwrap();
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
let bounds = app.session.active().unwrap().doc.bounds();
assert_visible_pixels(&mut app, bounds);
app.run("file.new", json!({"width":1024,"height":768,"background":"white"})).unwrap();
assert!(ensure_gpu(&mut app, &ctx, 1, visible));
app.session.set_active(0);
app.sync_views();
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
assert_visible_pixels(&mut app, bounds);
}