Files
photocraft/crates/ui-egui/tests/adjust_preview_gpu.rs
Brandon ThomasandClaude Opus 5.5 8b0ceea374 Adjustment dialogs: GPU live preview through a clipped adjustment layer (#77) (#85)
* Adjustment dialogs: preview through a clipped adjustment layer on the GPU (#77)

While an Image › Adjustments dialog is open on a pixel layer, the canvas shows the
document with a temporary adjustment layer (the dialog's settings, the selection as
its mask) clipped to the target instead of re-running the command on a CPU proxy.
The canvas compositor draws it, so on the GPU canvas a change recomposites only the
target's area within the selection, and of that only what the view shows (the rest
catches up as the view moves); zoomed out on a large document it composites a copy
reduced to about screen resolution, built once per session. The base document and
region are built once per session; a change swaps the layer's adjustment in a
shallow copy. OK still runs the real command (one history step); Cancel drops it.

Kept on the CPU proxy where the layer composite can't match the command: non-pixel
or clipped targets, Blend If, visible untouched area, channel / Quick Mask targets,
CMYK/Lab/other modes, colour-making kinds in Grayscale, and documents the GPU
compositor doesn't cover. The navigator waits for settings to settle.

Tests: preview vs command within 1/255 (worst 0.37/255) for all 13 kinds at
8/16/32-bit, RGB and Gray, with/without selection; nothing changes outside the
region; dialog flow (cache, keys, damage, Cancel untouched, OK one step); GPU
parity of the preview and of the zoomed-out proxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* interactive_bench: per-change Image › Adjustments dialog preview rows (#77)

Times the old CPU proxy preview against the clipped-layer preview at full
resolution, at 100 % view and zoomed out, with and without a selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:31:05 -04:00

189 lines
8.8 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! GPU parity of the Image › Adjustments dialog preview (`adjust_preview`): the document with
//! the temporary clipped adjustment layer must composite on the wgpu compositor like on the CPU
//! reference, and like the destructive command's result, for every kind with a dialog. Skips
//! without an adapter that renders 32-bit float targets (like `crates/gpu/tests/parity.rs`).
use eframe::wgpu;
use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Document, Layer, LayerId, LayerMask, Size};
use photocraft_geom::Rect;
use photocraft_gpu::{Compositor, render_to_vec};
use photocraft_ui_egui::adjust_preview::{PREVIEW_LAYER, preview_document};
use serde_json::{Value, json};
fn block_on<F: std::future::Future>(f: F) -> F::Output {
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
let mut f = std::pin::pin!(f);
loop {
if let std::task::Poll::Ready(v) = f.as_mut().poll(&mut cx) {
return v;
}
std::thread::yield_now();
}
}
fn gpu() -> Option<(wgpu::Device, wgpu::Queue, Compositor)> {
let adapter = match block_on(wgpu::Instance::default().request_adapter(&wgpu::RequestAdapterOptions::default())) {
Ok(a) => a,
Err(e) => {
eprintln!("skipping adjustment preview GPU parity: no adapter ({e})");
return None;
}
};
if Compositor::preferred_acc_format(&adapter) != wgpu::TextureFormat::Rgba32Float {
eprintln!("skipping adjustment preview GPU parity: adapter can't render Rgba32Float");
return None;
}
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
let comp = Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float).ok()?;
Some((device, queue, comp))
}
fn rnd(seed: u32, i: u32) -> f32 {
let mut h = seed.wrapping_mul(0x9e37_79b9) ^ i.wrapping_mul(0x85eb_ca6b);
h ^= h >> 13;
h = h.wrapping_mul(0xc2b2_ae35);
h ^= h >> 16;
(h & 0xffff) as f32 / 65535.0
}
fn noise(l: &mut Layer, r: Rect, seed: u32, min_alpha: f32) {
let s = l.surface_mut().unwrap();
let fmt = s.format();
let ch = fmt.channels();
let data: Vec<f32> = (0..r.width() * r.height())
.flat_map(|i| {
(0..ch).map(move |c| {
let v = rnd(seed + c as u32 * 7919, i);
if fmt.alpha && c == ch - 1 { min_alpha + (1.0 - min_alpha) * v } else { v }
})
})
.collect();
s.write_region(r, &data);
}
fn document(mode: ColorMode, depth: SampleType, selection: bool) -> (Document, LayerId) {
let mut doc = Document::with_background("t", Size::new(48, 40), mode, depth, Color::rgba(0.7, 0.6, 0.5, 1.0));
let fmt = doc.pixel_format();
let mut target = Layer::raster("target", fmt);
noise(&mut target, Rect::from_xywh(4, 2, 38, 34), 11, 0.2);
target.opacity = 0.8;
target.blend = BlendMode::Multiply;
let mut m = LayerMask::reveal_all();
m.surface.fill_rect(Rect::from_xywh(0, 0, 12, 12), &[0.3]);
target.mask = Some(m);
let id = target.id;
let mut clip = Layer::raster("clipped", fmt);
noise(&mut clip, Rect::from_xywh(20, 10, 14, 14), 17, 0.5);
clip.clipped = true;
clip.opacity = 0.6;
let mut top = Layer::raster("top", fmt);
noise(&mut top, Rect::from_xywh(0, 24, 48, 16), 23, 0.0);
top.blend = BlendMode::Screen;
doc.layers.extend([target, clip, top]);
if selection {
let mut sel = photocraft_raster::Surface::new(PixelFormat::GRAY8);
sel.fill_rect(Rect::from_xywh(8, 4, 30, 26), &[1.0]);
sel.fill_rect(Rect::from_xywh(8, 4, 8, 26), &[0.5]);
doc.selection = Some(sel);
}
(doc, id)
}
fn sample(kind: &str) -> Value {
match kind {
"brightnessContrast" => json!({"brightness": 40, "contrast": 30}),
"levels" => json!({"inBlack": 30, "inWhite": 220, "gamma": 1.3, "outBlack": 10}),
"curves" => json!({"points": [[0, 20], [90, 150], [255, 230]]}),
"exposure" => json!({"exposure": 0.8, "offset": 0.02, "gamma": 0.9}),
"vibrance" => json!({"vibrance": 50, "saturation": 20}),
"hueSaturation" => json!({"hue": 25, "saturation": 30, "reds": {"hue": 40}}),
"colorBalance" => json!({"midtones": [40, 0, -20], "shadows": [0, 20, 0]}),
"blackWhite" => json!({"reds": 150, "tint": true}),
"photoFilter" => json!({"filter": "cooling80", "density": 60}),
"channelMixer" => json!({"red": [50, 50, 0, 0], "blue": [0, 20, 90, 5]}),
"posterize" => json!({"levels": 3}),
"threshold" => json!({"level": 90}),
"gradientMap" => json!({"stops": [[0, "#200040"], [1, "#ffd080"]]}),
_ => json!({}),
}
}
fn pm(p: &[f32; 4]) -> [f32; 4] {
[p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]
}
fn diffs(a: &[[f32; 4]], b: &[[f32; 4]]) -> Vec<f32> {
a.iter().zip(b).map(|(x, y)| (0..4).map(|c| (pm(x)[c] - pm(y)[c]).abs()).fold(0.0, f32::max)).collect()
}
/// Largest difference, ignoring up to `skip` outliers.
fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]], skip: usize) -> f32 {
let mut d = diffs(a, b);
d.sort_by(|x, y| y.total_cmp(x));
d.get(skip).copied().unwrap_or(0.0)
}
#[test]
fn preview_layer_composites_on_the_gpu_like_the_command() {
let Some((device, queue, mut comp)) = gpu() else { return };
// GPU vs CPU of the same document (as the compositor parity tests), and GPU preview vs the
// command's CPU result (adds the command's rounding to the layer depth).
let (tol_gpu, tol_cmd) = (2.0 / 255.0, 2.5 / 255.0);
let mut worst = 0.0f32;
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for selection in [false, true] {
let (doc, target) = document(mode, depth, selection);
for kind in photocraft_ui_egui::adjust_editors::KINDS {
let params = sample(kind);
let Ok(preview) = preview_document(&doc, target, kind, &params) else {
// Grayscale kinds that make colour preview on the CPU proxy.
assert_eq!(mode, ColorMode::Grayscale, "{kind}");
continue;
};
assert!(preview.layer(PREVIEW_LAYER).is_some());
let what = format!("{kind} {mode:?} {depth:?} selection {selection}");
let b = preview.bounds();
// The GPU holds 16- and 32-bit layers as half floats: Threshold and Posterize
// may flip a few pixels sitting right at a step (1 % allowed).
let skip = if matches!(kind, "threshold" | "posterize") && depth != SampleType::U8 { (b.width() * b.height()) as usize / 100 } else { 0 };
let on_gpu = render_to_vec(&mut comp, &device, &queue, &preview, b).unwrap_or_else(|e| panic!("{what}: {e:?}"));
let on_cpu = photocraft_compose::render(&preview, b).px;
let d = max_diff(&on_gpu, &on_cpu, skip);
assert!(d <= tol_gpu, "{what}: GPU vs CPU {:.2}/255", d * 255.0);
let mut s = photocraft_engine::Session::new();
s.add_document(doc.clone(), None);
s.select_layer(target).unwrap();
s.execute(&format!("image.adjustments.{kind}"), params.clone()).unwrap();
let cmd = photocraft_compose::render(&s.active().unwrap().doc, b).px;
let d = max_diff(&on_gpu, &cmd, skip);
worst = worst.max(d);
assert!(d <= tol_cmd, "{what}: GPU preview vs command {:.2}/255", d * 255.0);
}
}
}
}
eprintln!("worst GPU preview vs command: {:.3}/255", worst * 255.0);
}
/// The zoomed-out proxy preview (own document and layer ids) composites on the GPU like the CPU.
#[test]
fn proxy_preview_composites_on_the_gpu() {
use photocraft_ui_egui::adjust_preview::{base_document, proxy_base, proxy_with_settings};
let Some((device, queue, mut comp)) = gpu() else { return };
for selection in [false, true] {
let (doc, target) = document(ColorMode::Rgb, SampleType::U8, selection);
let proxy = proxy_base(&base_document(&doc, target).unwrap(), 2);
// Render the full document too, so both share the compositor's resident textures.
render_to_vec(&mut comp, &device, &queue, &doc, doc.bounds()).unwrap();
for kind in photocraft_ui_egui::adjust_editors::KINDS {
let p = proxy_with_settings(&proxy, kind, &sample(kind)).unwrap();
let on_gpu = render_to_vec(&mut comp, &device, &queue, &p, p.bounds()).unwrap();
let on_cpu = photocraft_compose::render(&p, p.bounds()).px;
let d = max_diff(&on_gpu, &on_cpu, 0);
assert!(d <= 2.0 / 255.0, "{kind} selection {selection}: {:.2}/255", d * 255.0);
}
}
}