mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 02:30:43 -06:00
Limit blend mode hover refresh to the visible canvas
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user