Canvas: the CPU canvas shows crisp pixels when zoomed in (fixes #4810) (#4825)

This commit is contained in:
Marco Arnulfo 2026-10-13 18:02:51 +02:00 committed by GitHub
parent 665cb581ad
commit 78b70df227
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 51 additions and 3 deletions

View File

@ -1256,6 +1256,14 @@ pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz
Some(tex.id())
}
/// The CPU canvas texture's sampling (#4810). Zoomed in, a full-resolution texture shows each
/// document pixel as a crisp square, as the GPU canvas's sharp nearest filter and Photoshop do;
/// zoomed out it is still filtered. A reduced texture (`factor` > 1) stays bilinear: its texels
/// are averages of several pixels, and squares of them would be blocks the image doesn't have.
fn canvas_texture_options(factor: u32) -> TextureOptions {
if factor > 1 { TextureOptions::LINEAR } else { TextureOptions { magnification: egui::TextureFilter::Nearest, ..TextureOptions::LINEAR } }
}
/// Make sure the canvas texture for document `idx` on `display` is current; returns (texture
/// id, scale).
pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, display: Option<u32>) -> Option<(egui::TextureId, f32)> {
@ -1300,7 +1308,7 @@ 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), TextureOptions::LINEAR);
t.set_partial([r.x0 as usize, r.y0 as usize], display_image(display.as_deref(), &buf), 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);
}
@ -1310,8 +1318,8 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize,
let t1 = crate::gpu_canvas::now_ms();
let (img, scale) = (display_image(display.as_deref(), &full), 1.0 / factor as f32);
match cache.texture.as_mut() {
Some(t) if t.size() == img.size => t.set(img, TextureOptions::LINEAR),
_ => cache.texture = Some(ctx.load_texture(format!("canvas-{}-{}", id.0, cache_key(id, output).1), img, TextureOptions::LINEAR)),
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))),
}
cache.scale = scale;
app.perf.record("full", doc.size.width as u64 * doc.size.height as u64, t1 - t0, crate::gpu_canvas::now_ms() - t1);
@ -5853,6 +5861,46 @@ mod tests {
assert_eq!((app.canvases[&(id, 1)].tex_revision, app.canvases[&(id, 4)].tex_revision), (rev, rev));
}
#[test]
fn cpu_canvas_shows_crisp_pixels_when_zoomed_in() {
// #4810: above 100% the CPU canvas shows each pixel as a square, like the GPU canvas's
// sharp nearest filter; below 100% it still filters. Both the first upload and a
// damage-rect update carry the sampling, since the renderer takes it from each delta.
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 2, "height": 2})).unwrap();
let ctx = egui::Context::default();
let tex = ensure_texture(&mut app, &ctx, 0, None).unwrap().0;
let uploads = |ctx: &egui::Context| {
let mut delta = ctx.tex_manager().write().take_delta();
let seen = delta.set.get(&tex).into_iter().flatten().map(|d| (d.pos.is_some(), d.options)).collect::<Vec<_>>();
delta.clear();
seen
};
let crisp = TextureOptions { magnification: egui::TextureFilter::Nearest, ..TextureOptions::LINEAR };
assert_eq!(uploads(&ctx), vec![(false, crisp)]);
app.run("select.rect", json!({"x": 0, "y": 0, "width": 1, "height": 1})).unwrap();
ensure_texture(&mut app, &ctx, 0, None).unwrap();
uploads(&ctx);
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
assert_eq!(ensure_texture(&mut app, &ctx, 0, None).unwrap().0, tex);
assert_eq!(uploads(&ctx), vec![(true, crisp)], "a partial update keeps the crisp sampling");
}
#[test]
fn cpu_canvas_of_a_reduced_document_stays_bilinear() {
// Over MAX_TEXTURE the texture holds one texel per `factor` pixels: a texel is not a
// document pixel, so showing it as a square would draw blocks that aren't in the image.
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": MAX_TEXTURE + 1, "height": 2})).unwrap();
let ctx = egui::Context::default();
let (tex, scale) = ensure_texture(&mut app, &ctx, 0, None).unwrap();
assert!(scale < 1.0);
let mut delta = ctx.tex_manager().write().take_delta();
let uploads: Vec<_> = delta.set.get(&tex).into_iter().flatten().map(|d| d.options).collect();
delta.clear();
assert_eq!(uploads, vec![TextureOptions::LINEAR]);
}
#[test]
fn gpu_display_luts_are_per_display() {
let Ok(rs) = std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default())) else {