Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 da8b42400b 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 <[email protected]>
2026-10-04 23:30:55 -04:00
Brandon ThomasandClaude Opus 5.5 19febc65c3 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 <[email protected]>
2026-10-04 23:30:55 -04:00
8 changed files with 1174 additions and 13 deletions

No files matched your search

@@ -228,6 +228,113 @@ fn main() {
v
});
}
// Image › Adjustments dialog preview, per settings change (#77). Before: the command re-run
// on a downsampled proxy (plus its flatten for upload). After: the document with a temporary
// clipped adjustment layer, recomposited over the target's area by the canvas compositor.
for selection in [false, true] {
if selection {
exec(&mut s, "select.rect", json!({"x": w / 4, "y": h / 4, "width": w / 2, "height": h / 2, "ellipse": true}));
}
let sel = if selection { ", selection" } else { "" };
let mut v = 0;
b.time(&format!("Curves dialog change, CPU proxy preview{sel}"), |_| {
v = (v + 7) % 120;
let st = s.active().expect("doc");
let k = photocraft_ui_egui::proxy::factor(&st.doc);
let t = Instant::now();
let p = photocraft_ui_egui::filter_dialog::preview_document(
&st.doc,
st.active_layer,
"image.adjustments.curves",
&json!({"points": [[0, 0], [100, 100 + v], [255, 255]]}),
k,
)
.expect("preview");
std::hint::black_box(photocraft_compose::flatten(&p));
ms(t)
});
// Once per dialog session (the app caches both per document revision).
let (base, region) = {
let st = s.active().expect("doc");
let target = st.active_layer.expect("layer");
let t = Instant::now();
let base = photocraft_ui_egui::adjust_preview::base_document(&st.doc, target).expect("base");
let region = photocraft_ui_egui::adjust_preview::region(&st.doc, target);
println!(" (layer preview session setup: {:.1} ms, region {region:?})", ms(t));
(base, region)
};
b.time(&format!("Curves dialog change, layer preview{sel}"), |b| {
v = (v + 7) % 120;
let t = Instant::now();
let p =
photocraft_ui_egui::adjust_preview::with_settings(&base, "curves", &json!({"points": [[0, 0], [100, 100 + v], [255, 255]]})).expect("preview");
let build = ms(t);
let t = Instant::now();
match &mut b.gpu {
Some(g) => {
if g.comp.render(&g.device, &g.queue, &p, region, |_, _| {}).is_err() {
std::hint::black_box(photocraft_compose::render(&p, region));
}
let _ = g.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
}
None => {
std::hint::black_box(photocraft_compose::render(&p, region));
}
}
build + ms(t)
});
// Zoomed in, the canvas composites the region's visible part (here a 2560×1440 view at
// 100 %) and catches up elsewhere when the view moves.
let view = Rect::from_xywh(w as i32 / 3, h as i32 / 3, 2560, 1440).intersect(&region);
b.time(&format!("Curves dialog change, layer preview 100 % view{sel}"), |b| {
v = (v + 7) % 120;
let t = Instant::now();
let p =
photocraft_ui_egui::adjust_preview::with_settings(&base, "curves", &json!({"points": [[0, 0], [100, 100 + v], [255, 255]]})).expect("preview");
match &mut b.gpu {
Some(g) => {
if g.comp.render(&g.device, &g.queue, &p, view, |_, _| {}).is_err() {
std::hint::black_box(photocraft_compose::render(&p, view));
}
let _ = g.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
}
None => {
std::hint::black_box(photocraft_compose::render(&p, view));
}
}
ms(t)
});
// Zoomed out (fit to a 1600-pixel-wide view on a 2× display: k = 2 for 36 MP), the canvas
// previews on a reduced copy built once per session.
let zoom = 1600.0 / w as f32 * 2.0;
let k = photocraft_ui_egui::adjust_preview::proxy_factor(zoom);
let t = Instant::now();
let proxy = photocraft_ui_egui::adjust_preview::proxy_base(&base, k);
println!(" (zoomed-out proxy k {k} setup: {:.1} ms)", ms(t));
let kk = k as i32;
let pregion = Rect::new(region.x0 / kk, region.y0 / kk, region.x1 / kk + 1, region.y1 / kk + 1).intersect(&proxy.bounds());
b.time(&format!("Curves dialog change, layer preview zoomed out{sel}"), |b| {
v = (v + 7) % 120;
let t = Instant::now();
let p = photocraft_ui_egui::adjust_preview::proxy_with_settings(&proxy, "curves", &json!({"points": [[0, 0], [100, 100 + v], [255, 255]]}))
.expect("preview");
match &mut b.gpu {
Some(g) => {
if g.comp.render(&g.device, &g.queue, &p, pregion, |_, _| {}).is_err() {
std::hint::black_box(photocraft_compose::render(&p, pregion));
}
let _ = g.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
}
None => {
std::hint::black_box(photocraft_compose::render(&p, pregion));
}
}
ms(t)
});
if selection {
exec(&mut s, "select.deselect", json!({}));
}
}
exec(&mut s, "layer.newAdjustmentLayer.levels", json!({"inBlack": 10, "inWhite": 240, "gamma": 1.1}));
let mut g = 1.0;
b.time("Levels layer tweak (gamma) + full refresh", |b| {
+4 -3
View File
@@ -3,9 +3,10 @@
//!
//! The dialog's fields are the command's complete parameter set (so automation can read and set
//! them with `ui.dialog.set`) plus private `__` keys. While it is open the canvas previews the
//! *real command* on the proxy document through the filter-preview machinery (`__filter` +
//! `__preview`); OK runs the command once (one history step) and Cancel drops the preview, leaving
//! the document untouched.
//! settings as a temporary adjustment layer clipped to the target (`adjust_preview`, composited on
//! the GPU); where that can't match the command, the *real command* runs on the proxy document
//! through the filter-preview machinery (`__filter` + `__preview`). OK runs the command once (one
//! history step) and Cancel drops the preview, leaving the document untouched.
use serde_json::{Map, Value, json};
+434
View File
@@ -0,0 +1,434 @@
//! Live canvas preview for the Image › Adjustments dialogs (`adjust_dialog`).
//!
//! While a dialog is open on a pixel layer the canvas shows the committed document with a
//! temporary adjustment layer (the dialog's settings, the selection as its mask) clipped to the
//! target layer. That composite equals what the destructive command will produce (see the tests:
//! within 1/255 for every kind, depth and mode below), but it is drawn by the regular canvas path,
//! so on the GPU canvas a change re-runs the wgpu compositor instead of the command on a CPU
//! proxy (the layer textures stay resident; just the adjustment's uniforms / LUT change):
//! - over the target's area within the selection only (`switch_region`), and of that only what
//! the view shows; the rest catches up as the view moves (`uncovered`);
//! - zoomed out on a large document, on a copy reduced to about the screen's resolution, built
//! once per session ([`gpu_proxy`]).
//!
//! OK still runs the real command (one history step); Cancel just drops the preview. The base
//! document (with the preview layer) and the region are built once per dialog session; a change
//! only swaps the layer's adjustment in a shallow copy.
//!
//! Cases where an adjustment-layer composite differs from the destructive result, or where it
//! would not be cheap, keep the CPU proxy preview (`canvas::ensure_filter_preview`), see
//! [`unsupported`]:
//! - the target is not a plain pixel layer (smart object, type, shape, group: the command edits
//! a smart object's filters or refuses), or is itself clipped (a clipped adjustment would act on
//! the whole clipping group, not just the target);
//! - the target uses Blend If (its "This Layer" ranges test the layer's adjusted colour);
//! - the target's untouched area is visible (a surface without alpha whose content doesn't cover
//! the canvas: the command only edits content);
//! - the command targets an alpha channel, the Quick Mask or a single colour channel;
//! - CMYK, Lab and the other non-RGB modes except Grayscale: the command converts each pixel to
//! RGB, adjusts and converts back (CMYK even changes when the settings are neutral), which a
//! composite in display RGB can't reproduce;
//! - in Grayscale documents, kinds that make colour from gray (Vibrance, Color Balance, Black &
//! White, Photo Filter, Channel Mixer, Gradient Map, colorized Hue/Saturation): the command
//! folds each pixel back to gray, the composite would carry the colour through;
//! - on the GPU canvas, documents the wgpu compositor doesn't cover (it would fall back to a
//! full CPU composite per change); on the CPU canvas, documents above the proxy size.
use std::sync::Arc;
use photocraft_color::ColorMode;
use photocraft_doc::{Adjustment, DocId, Document, Layer, LayerContent, LayerId, LayerMask};
use photocraft_geom::Rect;
use serde_json::Value;
use crate::PhotocraftApp;
/// Id of the temporary preview layer. Fixed so the GPU compositor keeps its mask (the selection)
/// resident from one change to the next. Never reaches a committed document.
pub const PREVIEW_LAYER: LayerId = LayerId(u64::MAX - 77);
/// Tag of the preview keys handed to the canvas (`canvas::display_doc`).
const TAG: u64 = 1 << 59;
/// Bits of a preview key that identify the session (document state + target); below them the
/// settings hash.
const SESSION_SHIFT: u32 = 40;
/// Why `target` in `doc` can't preview as a clipped adjustment layer, or None when it can.
pub fn unsupported(doc: &Document, target: LayerId) -> Option<&'static str> {
if !matches!(doc.mode, ColorMode::Rgb | ColorMode::Grayscale) {
return Some("colour mode composites through display RGB");
}
let Some(l) = doc.layer(target) else { return Some("no target layer") };
let LayerContent::Raster(s) = &l.content else { return Some("not a pixel layer") };
if l.clipped {
return Some("the target is clipped");
}
if photocraft_compose::blend_if_active(l, doc.mode) {
return Some("Blend If");
}
let shows_default = !s.format().alpha || s.default_pixel().last().is_some_and(|a| *a > 0.0);
if shows_default && !s.content_bounds().contains_rect(&doc.bounds()) {
return Some("untouched area is visible");
}
None
}
/// Whether `adj` maps gray to gray. Others make colour in a grayscale document, which the command
/// folds back to gray per pixel while the composite carries it to the end.
fn keeps_gray(adj: &Adjustment) -> bool {
match adj {
Adjustment::BrightnessContrast { .. }
| Adjustment::Levels { .. }
| Adjustment::Curves { .. }
| Adjustment::Exposure { .. }
| Adjustment::Posterize { .. }
| Adjustment::Threshold { .. }
| Adjustment::Invert => true,
Adjustment::HueSaturation { colorize, .. } => !colorize,
_ => false,
}
}
/// `doc` with a placeholder temporary adjustment layer clipped to `target` (the selection as its
/// mask): built once per dialog session, then [`with_settings`] per change. Errors when the target
/// isn't eligible ([`unsupported`]).
pub fn base_document(doc: &Document, target: LayerId) -> Result<Document, String> {
if let Some(why) = unsupported(doc, target) {
return Err(why.into());
}
let mut l = Layer::new("Adjustment Preview", LayerContent::Adjustment(Adjustment::Invert));
l.id = PREVIEW_LAYER;
l.clipped = true;
if let Some(sel) = &doc.selection {
l.mask = Some(LayerMask { surface: sel.clone(), enabled: true, linked: false, density: 1.0, feather: 0.0 });
}
let mut out = doc.clone();
out.insert_above(Some(target), l);
Ok(out)
}
/// A [`base_document`] with the dialog's settings in its preview layer (a shallow copy: pixel
/// tiles are shared). Errors on invalid settings and on kinds the layer can't reproduce here.
pub fn with_settings(base: &Document, kind: &str, params: &Value) -> Result<Document, String> {
set_adjustment(base, PREVIEW_LAYER, kind, params)
}
fn set_adjustment(base: &Document, layer: LayerId, kind: &str, params: &Value) -> Result<Document, String> {
let adj = photocraft_engine::adjust_params::from_params(kind, params, None, base.mode).map_err(|e| e.to_string())?;
if base.mode == ColorMode::Grayscale && !keeps_gray(&adj) {
return Err("colour result in a grayscale document".into());
}
let mut out = base.clone();
let l = out.layer_mut(layer).ok_or("no preview layer")?;
l.content = LayerContent::Adjustment(adj);
Ok(out)
}
/// `doc` with the dialog's adjustment as a temporary layer clipped to `target` (the selection as
/// its mask): [`base_document`] then [`with_settings`].
pub fn preview_document(doc: &Document, target: LayerId, kind: &str, params: &Value) -> Result<Document, String> {
with_settings(&base_document(doc, target)?, kind, params)
}
/// The canvas area the preview can change: the target's content within the selection, grown by
/// the reach of layer effects (the target's and its groups').
pub fn region(doc: &Document, target: LayerId) -> Rect {
let Some(s) = doc.layer(target).and_then(Layer::surface) else { return Rect::EMPTY };
let mut r = s.content_bounds().intersect(&doc.bounds());
if let Some(sel) = &doc.selection {
r = r.intersect(&sel.content_bounds());
}
if r.is_empty() {
return r;
}
r.inflate(crate::canvas::effect_reach(&doc.layers)).intersect(&doc.bounds())
}
/// Per-dialog preview state on the app.
pub struct AdjustPreview {
doc: DocId,
revision: u64,
/// Session part of the keys (document, revision, target, channel target).
session: u64,
/// The session's [`base_document`], or None when it previews on the CPU proxy.
base: Option<Arc<Document>>,
region: Rect,
/// Hash of the kind, settings and Preview checkbox (0: no dialog).
hash: u64,
/// Whether the layer path handles these settings (false: CPU proxy preview).
layer_path: bool,
/// The preview document and its key, or None (proxy path, or the Preview checkbox is off).
shown: Option<(Arc<Document>, u64)>,
/// When the settings last changed (ms), for throttling secondary views.
changed_ms: f64,
/// The dialog's kind and parameters.
settings: (String, Value),
/// Reduced-resolution copy for zoomed-out views (see [`gpu_proxy`]).
proxy: Option<GpuProxy>,
/// The main canvas texture shows preview `key` only within this rect (the view when it was
/// composited, see [`switch_damage`]); elsewhere in the region it is older.
covered: Option<(u64, Rect)>,
}
/// A [`base_document`] scaled down by `k` for zoomed-out GPU previews, with its own document and
/// layer ids (so its textures live beside the full-size document's), plus the preview built from
/// it for the current settings.
struct GpuProxy {
k: u32,
base: Arc<Document>,
/// (settings hash, preview).
shown: Option<(u64, Arc<Document>)>,
/// Settings hash last composited into the proxy's texture.
drawn: Option<u64>,
}
/// The open adjustment dialog: (kind, params, Preview on).
fn dialog(app: &PhotocraftApp) -> Option<(String, Value, bool)> {
let d = app.ui.dialogs.iter().find(|d| crate::adjust_dialog::owns(&d.fields))?;
let kind = d.fields.get("__adjust")?.as_str()?.to_string();
let preview = d.fields.get("__preview").and_then(Value::as_bool).unwrap_or(true);
Some((kind, crate::filter_dialog::params_of(&d.fields), preview))
}
/// FNV-1a over `parts`, never 0.
fn hash(parts: &[&[u8]]) -> u64 {
parts.iter().flat_map(|p| p.iter()).fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)) | 1
}
/// Whether the layer path can draw `doc` cheaply on this app's canvas.
fn cheap(app: &PhotocraftApp, doc: &Document) -> bool {
match &app.gpu {
Some(g) => g.supports(doc),
None => crate::proxy::factor(doc) <= 1,
}
}
/// Bring the cached preview up to date for document `idx` (the active one) and return it.
fn update(app: &mut PhotocraftApp, idx: usize) -> Option<&AdjustPreview> {
if app.session.active_index() != Some(idx) {
return None;
}
let Some((kind, params, preview)) = dialog(app) else {
// Keep a closed dialog's session for the canvas's switch back (see `switch_region`) but
// never show it.
if let Some(p) = app.adjust_preview.as_mut() {
(p.hash, p.layer_path, p.shown) = (0, false, None);
}
return None;
};
let st = app.session.documents().get(idx)?;
let (doc_id, revision, target) = (st.doc.id, st.revision, st.active_layer?);
// Commands that target a channel (Channels panel / Quick Mask) keep the proxy preview.
let channel_target = st.channel_view.target != photocraft_engine::channel_cmds::ChannelTarget::Composite || st.doc.quick_mask.is_some();
let session = hash(&[&doc_id.0.to_le_bytes(), &revision.to_le_bytes(), &target.0.to_le_bytes(), &[u8::from(channel_target)]]);
let h = hash(&[kind.as_bytes(), params.to_string().as_bytes(), &[u8::from(preview)]]);
if let Some(p) = &app.adjust_preview
&& (p.doc, p.session, p.hash) == (doc_id, session, h)
{
return app.adjust_preview.as_ref();
}
if app.adjust_preview.as_ref().is_none_or(|p| (p.doc, p.session) != (doc_id, session)) {
let doc = st.doc.clone();
let base = if channel_target { None } else { base_document(&doc, target).ok().filter(|b| cheap(app, b)).map(Arc::new) };
let region = if base.is_some() { region(&doc, target) } else { Rect::EMPTY };
app.adjust_preview = Some(AdjustPreview {
doc: doc_id,
revision,
session,
base,
region,
hash: 0,
layer_path: false,
shown: None,
changed_ms: crate::gpu_canvas::now_ms(),
settings: (String::new(), Value::Null),
proxy: None,
covered: None,
});
}
let p = app.adjust_preview.as_mut()?;
let built = p.base.as_ref().and_then(|b| with_settings(b, &kind, &params).ok());
let key = TAG | ((session & 0x7_ffff) << SESSION_SHIFT) | (h & ((1 << SESSION_SHIFT) - 1));
p.layer_path = built.is_some();
p.shown = built.filter(|_| preview).map(|d| (Arc::new(d), key));
p.hash = h;
p.changed_ms = crate::gpu_canvas::now_ms();
p.settings = (kind, params);
app.adjust_preview.as_ref()
}
/// The document the canvas shows for document `idx` while an adjustment dialog previews through
/// the layer path: (document, preview key).
pub fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Document>, u64)> {
update(app, idx)?.shown.clone()
}
/// Whether the open adjustment dialog previews through the layer path (so the CPU proxy preview
/// must stay off).
pub fn on_layer(app: &mut PhotocraftApp, idx: usize) -> bool {
update(app, idx).is_some_and(|p| p.layer_path)
}
/// The canvas area to recomposite when document `idx` (at `revision`) switches from the preview
/// key `from` to `to` (raw keys, display transform removed): both must be this dialog's previews
/// or the committed document (0). None means "everything".
pub fn switch_region(app: &PhotocraftApp, doc: DocId, revision: u64, from: u64, to: u64) -> Option<Rect> {
let p = app.adjust_preview.as_ref()?;
if p.doc != doc || p.revision != revision {
return None;
}
let prefix = TAG | ((p.session & 0x7_ffff) << SESSION_SHIFT);
let ours = |k: u64| k == 0 || k >> SESSION_SHIFT == prefix >> SESSION_SHIFT;
(ours(from) && ours(to) && (from != 0 || to != 0)).then_some(p.region)
}
/// The key of the preview currently shown, if any.
pub fn shown_key(app: &PhotocraftApp) -> Option<u64> {
app.adjust_preview.as_ref()?.shown.as_ref().map(|s| s.1)
}
/// The part of a switch's region (from [`switch_region`]) to composite now: to a preview, only
/// what the view shows (`visible`, document pixels), remembering it so [`uncovered`] catches up
/// as the view moves; back to the committed document (key 0), the whole region.
pub fn switch_damage(app: &mut PhotocraftApp, to: u64, region: Rect, visible: Rect) -> Rect {
let Some(p) = app.adjust_preview.as_mut() else { return region };
if to == 0 {
p.covered = None;
return region;
}
let shown = region.intersect(&visible);
p.covered = Some((to, shown));
shown
}
/// Where the main canvas texture (showing preview `key`) is out of date within `visible`: the
/// region's visible part when the view moved past what was composited. Marks it covered.
pub fn uncovered(app: &mut PhotocraftApp, doc: DocId, key: u64, visible: Rect) -> Option<Rect> {
let p = app.adjust_preview.as_mut().filter(|p| p.doc == doc)?;
let (k, cov) = p.covered?;
if k != key {
return None;
}
let want = p.region.intersect(&visible);
if want.is_empty() || cov.contains_rect(&want) {
return None;
}
p.covered = Some((k, want));
Some(want)
}
/// True while the preview's settings changed within the last `ms` milliseconds (secondary views
/// such as the Navigator wait for the settings to settle instead of recompositing every change).
pub fn settling(app: &PhotocraftApp, ms: f64) -> bool {
app.adjust_preview.as_ref().is_some_and(|p| p.shown.is_some() && crate::gpu_canvas::now_ms() - p.changed_ms < ms)
}
/// Documents above this size preview zoomed-out views on a reduced copy ([`gpu_proxy`]).
const PROXY_MIN_PIXELS: u64 = 4_000_000;
/// Proxy document ids: the document's id with this bit flipped.
const PROXY_DOC_BIT: u64 = 1 << 60;
/// Proxy layer ids: the layer's id with this bit flipped (document layer ids are small counters;
/// [`PREVIEW_LAYER`] has it set).
const PROXY_LAYER_BIT: u64 = 1 << 62;
fn flip_ids(layers: &mut [Layer]) {
for l in layers {
l.id = LayerId(l.id.0 ^ PROXY_LAYER_BIT);
if let Some(ch) = l.children_mut() {
flip_ids(ch);
}
}
}
/// Proxy factor for a view at `zoom` (physical screen pixels per document pixel): the largest
/// power of two that keeps the proxy at least at the screen's resolution; 1 when the view is too
/// close for a proxy.
pub fn proxy_factor(zoom: f32) -> u32 {
if zoom.is_nan() || zoom <= 0.0 {
return 1;
}
let mut k = 1u32;
while k < 64 && (k * 2) as f32 * zoom <= 1.0 {
k *= 2;
}
k
}
/// A [`base_document`] scaled down by `k` with its own document and layer ids (so the GPU keeps
/// its textures beside the full-size document's).
pub fn proxy_base(base: &Document, k: u32) -> Document {
let mut d = photocraft_compose::proxy::proxy_document(base, k);
d.id = photocraft_doc::DocId(base.id.0 ^ PROXY_DOC_BIT);
flip_ids(&mut d.layers);
d
}
/// [`with_settings`] for a [`proxy_base`].
pub fn proxy_with_settings(proxy: &Document, kind: &str, params: &Value) -> Result<Document, String> {
set_adjustment(proxy, LayerId(PREVIEW_LAYER.0 ^ PROXY_LAYER_BIT), kind, params)
}
/// A frame of the zoomed-out preview: composite `doc` (the proxy preview, its own document id)
/// into its GPU texture over `damage` (None: everything), then draw it scaled by `k`.
pub struct ProxyFrame {
pub k: u32,
pub doc: Arc<Document>,
pub damage: Option<Rect>,
/// False when the texture already shows this preview.
pub stale: bool,
hash: u64,
}
/// The zoomed-out preview for document `idx` at `zoom`, when the dialog previews through the layer
/// and the view is far enough out that a reduced copy shows the same detail as the full-size
/// composite: each change then composites ~1/k² of the pixels. Built once per session and factor.
pub fn gpu_proxy(app: &mut PhotocraftApp, idx: usize, zoom: f32) -> Option<ProxyFrame> {
let gpu = app.gpu.clone()?;
update(app, idx)?;
let p = app.adjust_preview.as_mut()?;
p.shown.as_ref()?;
let base = p.base.clone()?;
let k = proxy_factor(zoom);
if k < 2 || base.size.area() <= PROXY_MIN_PIXELS || !photocraft_compose::proxy::proxy_faithful(&base) {
return None;
}
if p.proxy.as_ref().is_none_or(|g| g.k != k) {
let d = proxy_base(&base, k);
if !gpu.supports(&d) {
return None;
}
p.proxy = Some(GpuProxy { k, base: Arc::new(d), shown: None, drawn: None });
}
let (kind, params) = &p.settings;
let h = p.hash;
let g = p.proxy.as_mut()?;
if g.shown.as_ref().is_none_or(|(sh, _)| *sh != h) {
let d = proxy_with_settings(&g.base, kind, params).ok()?;
g.shown = Some((h, Arc::new(d)));
}
let doc = g.shown.as_ref()?.1.clone();
let r = p.region;
let kk = k as i32;
let scaled = Rect::new(r.x0.div_euclid(kk), r.y0.div_euclid(kk), r.x1.div_euclid(kk) + 1, r.y1.div_euclid(kk) + 1).intersect(&doc.bounds());
let damage = g.drawn.map(|_| scaled);
Some(ProxyFrame { k, doc, damage, stale: g.drawn != Some(h), hash: h })
}
/// Record that `frame` was composited into the proxy's texture.
pub fn proxy_drawn(app: &mut PhotocraftApp, frame: &ProxyFrame) {
if let Some(g) = app.adjust_preview.as_mut().and_then(|p| p.proxy.as_mut())
&& g.k == frame.k
{
g.drawn = Some(frame.hash);
}
}
/// GPU texture keys the preview uses besides the documents' own (keep them alive).
pub fn gpu_keys(app: &PhotocraftApp) -> Option<u64> {
let p = app.adjust_preview.as_ref().filter(|p| p.hash != 0)?;
p.proxy.as_ref().map(|g| g.base.id.0)
}
#[cfg(test)]
#[path = "adjust_preview_tests.rs"]
mod tests;
+335
View File
@@ -0,0 +1,335 @@
use super::*;
use photocraft_color::{BlendMode, Color, PixelFormat, SampleType};
use photocraft_doc::Size;
use serde_json::json;
const KINDS: [&str; 13] = crate::adjust_editors::KINDS;
/// Settings that move every kind well away from neutral.
pub(crate) 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 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 fill_noise(s: &mut photocraft_raster::Surface, r: Rect, seed: u32, min_alpha: f32) {
let fmt = s.format();
let ch = fmt.channels();
let n = r.width() * r.height();
let mut data = Vec::with_capacity(n as usize * ch);
for i in 0..n {
for c in 0..ch {
let v = rnd(seed + c as u32 * 7919, i);
let alpha = fmt.alpha && c == ch - 1;
data.push(if alpha { min_alpha + (1.0 - min_alpha) * v } else { v });
}
}
s.write_region(r, &data);
}
/// Background, the target (semi-transparent noise, 80 % Multiply, layer mask), a layer clipped to
/// the target and a Screen layer on top: what the preview layer is inserted into.
pub(crate) fn document(mode: ColorMode, depth: SampleType, selection: bool) -> (Document, LayerId) {
let (w, h) = (40u32, 32u32);
let mut doc = Document::with_background("t", Size::new(w, h), mode, depth, Color::rgba(0.7, 0.6, 0.5, 1.0));
let fmt = doc.pixel_format();
if let Some(s) = doc.layers[0].surface_mut() {
fill_noise(s, Rect::from_xywh(0, 0, w, h / 2), 3, 1.0);
}
let mut target = Layer::raster("target", fmt);
if let Some(s) = target.surface_mut() {
fill_noise(s, Rect::from_xywh(4, 2, 30, 26), 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, 10, 10), &[0.3]);
target.mask = Some(m);
let id = target.id;
let mut clip = Layer::raster("clipped", fmt);
if let Some(s) = clip.surface_mut() {
fill_noise(s, Rect::from_xywh(20, 10, 12, 12), 17, 0.5);
}
clip.clipped = true;
clip.opacity = 0.6;
let mut top = Layer::raster("top", fmt);
if let Some(s) = top.surface_mut() {
fill_noise(s, Rect::from_xywh(0, 20, 40, 12), 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, 24, 20), &[1.0]);
sel.fill_rect(Rect::from_xywh(8, 4, 6, 20), &[0.5]);
doc.selection = Some(sel);
}
(doc, id)
}
/// The document after running the real destructive command on `target`.
pub(crate) fn destructive(doc: &Document, target: LayerId, kind: &str, params: &Value) -> Option<Document> {
let mut s = photocraft_engine::Session::new();
s.add_document(doc.clone(), None);
s.select_layer(target).ok()?;
s.execute(&format!("image.adjustments.{kind}"), params.clone()).ok()?;
Some((*s.active()?.doc).clone())
}
/// Largest premultiplied difference between two composites.
pub(crate) fn max_diff(a: &Document, b: &Document) -> f32 {
let (ra, rb) = (photocraft_compose::render(a, a.bounds()), photocraft_compose::render(b, b.bounds()));
let pm = |p: &[f32; 4]| [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]];
ra.px.iter().zip(&rb.px).map(|(x, y)| (0..4).map(|c| (pm(x)[c] - pm(y)[c]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max)
}
/// Kinds that make colour from gray: grayscale documents preview them on the CPU proxy.
const GRAY_COLOUR: [&str; 6] = ["vibrance", "colorBalance", "blackWhite", "photoFilter", "channelMixer", "gradientMap"];
#[test]
fn preview_composite_matches_the_destructive_command() {
let tol = 1.0 / 255.0 + 1e-4;
let mut worst = (0.0f32, String::new());
let mut fails = Vec::new();
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 KINDS {
for params in [sample(kind), json!({})] {
let what = format!("{kind} {mode:?} {depth:?} selection {selection} {params}");
let want = destructive(&doc, target, kind, &params);
let got = preview_document(&doc, target, kind, &params);
let gray_colour = mode == ColorMode::Grayscale && GRAY_COLOUR.contains(&kind);
let (Some(want), Ok(got)) = (want, got) else {
assert!(gray_colour, "{what}: command and preview disagree on validity");
continue;
};
assert!(!gray_colour, "{what}: should use the proxy preview");
let d = max_diff(&got, &want);
if d > worst.0 {
worst = (d, what.clone());
}
if d > tol {
fails.push(format!("{what}: preview differs from the command by {:.2}/255", d * 255.0));
}
}
}
}
}
}
eprintln!("worst preview difference {:.3}/255 ({})", worst.0 * 255.0, worst.1);
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
#[test]
fn preview_leaves_the_document_alone_and_marks_its_layer() {
let (doc, target) = document(ColorMode::Rgb, SampleType::U8, true);
let p = preview_document(&doc, target, "curves", &sample("curves")).unwrap();
assert_eq!(p.layer_count(), doc.layer_count() + 1);
let l = p.layer(PREVIEW_LAYER).unwrap();
assert!(l.clipped && l.mask.is_some());
// Directly above the target, below the layer already clipped to it.
let path = p.path_of(PREVIEW_LAYER).unwrap();
assert_eq!(p.layer_at(&[path[0] - 1]).unwrap().id, target);
assert!(doc.layer(PREVIEW_LAYER).is_none());
// Region: the target's content within the selection.
assert_eq!(region(&doc, target), Rect::from_xywh(8, 4, 24, 20));
}
#[test]
fn ineligible_targets_use_the_proxy_preview() {
let (mut doc, target) = document(ColorMode::Rgb, SampleType::U8, false);
let clipped = doc.layers[2].id;
assert_eq!(unsupported(&doc, clipped), Some("the target is clipped"));
assert_eq!(unsupported(&doc, LayerId(u64::MAX - 1)), Some("no target layer"));
doc.layers.push(Layer::new("adj", LayerContent::Adjustment(photocraft_doc::Adjustment::Invert)));
let adj = doc.layers[4].id;
assert_eq!(unsupported(&doc, adj), Some("not a pixel layer"));
assert!(unsupported(&doc, target).is_none());
// Bad settings are an error, not a panic.
assert!(preview_document(&doc, target, "levels", &json!({"gamma": "x"})).is_err());
assert!(preview_document(&doc, target, "nope", &json!({})).is_err());
assert!(preview_document(&doc, target, "curves", &json!([1, 2])).is_err());
// CMYK and Lab adjust through RGB per pixel: proxy preview.
for mode in [ColorMode::Cmyk, ColorMode::Lab] {
let (d, t) = document(mode, SampleType::U8, false);
assert!(unsupported(&d, t).is_some(), "{mode:?}");
}
let (gray, t) = document(ColorMode::Grayscale, SampleType::U16, false);
assert!(preview_document(&gray, t, "hueSaturation", &json!({"colorize": true, "hue": 30, "saturation": 40})).is_err());
assert!(preview_document(&gray, t, "hueSaturation", &json!({"lightness": 20})).is_ok());
}
/// The canvas recomposites only `region` between previews: nothing outside it may change.
#[test]
fn preview_changes_nothing_outside_its_region() {
for selection in [false, true] {
let (doc, target) = document(ColorMode::Rgb, SampleType::U8, selection);
let r = region(&doc, target);
let before = photocraft_compose::render(&doc, doc.bounds());
for kind in KINDS {
let p = preview_document(&doc, target, kind, &sample(kind)).unwrap();
let after = photocraft_compose::render(&p, doc.bounds());
for (i, (a, b)) in before.px.iter().zip(&after.px).enumerate() {
let (x, y) = ((i % 40) as i32, (i / 40) as i32);
if !r.contains(x, y) {
assert_eq!(a, b, "{kind} selection {selection}: ({x}, {y}) outside {r:?}");
}
}
}
}
}
fn app_with(doc: Document, target: LayerId) -> PhotocraftApp {
let mut s = photocraft_engine::Session::new();
s.add_document(doc, None);
s.select_layer(target).unwrap();
PhotocraftApp::new(s, crate::Services::default())
}
/// The dialog previews through the layer (no CPU proxy run), each change only swaps the
/// preview document; Cancel leaves the document untouched, OK is one history step.
#[test]
fn dialog_previews_through_the_layer() {
use egui_kittest::Harness;
for confirm in [false, true] {
let (doc, target) = document(ColorMode::Rgb, SampleType::U16, true);
let mut harness =
Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with(doc, target));
PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]);
let id = crate::adjust_dialog::open(harness.state_mut(), "image.adjustments.levels").unwrap();
harness.run_steps(2);
let committed = harness.state().session.active().unwrap().doc.clone();
let steps = harness.state().session.active().unwrap().history.past_len();
let app = harness.state_mut();
assert!(on_layer(app, 0));
let (first, k1) = display_doc(app, 0).unwrap();
assert!(first.layer(PREVIEW_LAYER).is_some());
// Unchanged settings: the cached preview, same key.
let (again, k1b) = display_doc(app, 0).unwrap();
assert!(Arc::ptr_eq(&first, &again) && k1 == k1b);
// A change: new key, and the canvas only recomposites the target's area.
let d = app.ui.dialog_mut(id).unwrap();
d.fields.insert("gamma".into(), json!(1.6));
let (second, k2) = display_doc(app, 0).unwrap();
assert_ne!(k1, k2);
let rev = app.session.active().unwrap().revision;
let doc_id = committed.id;
assert_eq!(switch_region(app, doc_id, rev, k1, k2), Some(Rect::from_xywh(8, 4, 24, 20)));
assert_eq!(switch_region(app, doc_id, rev, 0, k2), Some(Rect::from_xywh(8, 4, 24, 20)));
assert_eq!(switch_region(app, doc_id, rev, 1 << 41, k2), None, "another preview kind");
assert_eq!(switch_region(app, doc_id, rev + 1, k1, k2), None, "another revision");
// Only the visible part composites now; the rest when the view gets there.
let region = Rect::from_xywh(8, 4, 24, 20);
let (corner, all) = (Rect::from_xywh(0, 0, 16, 16), Rect::from_xywh(0, 0, 40, 32));
assert_eq!(switch_damage(app, k2, region, corner), Rect::new(8, 4, 16, 16));
assert_eq!(uncovered(app, doc_id, k2, corner), None);
assert_eq!(uncovered(app, doc_id, k1, all), None, "another preview");
assert_eq!(uncovered(app, doc_id, k2, all), Some(region));
assert_eq!(uncovered(app, doc_id, k2, all), None);
assert_eq!(switch_damage(app, 0, region, corner), region, "back to the document: everything");
assert_eq!(uncovered(app, doc_id, k2, all), None);
// The preview equals the command with the dialog's settings.
let params = crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields);
let want = destructive(&committed, target, "levels", &params).unwrap();
assert!(max_diff(&second, &want) <= 1.0 / 255.0);
// Preview off: the committed document.
app.ui.dialog_mut(id).unwrap().fields.insert("__preview".into(), json!(false));
assert!(display_doc(app, 0).is_none());
assert!(on_layer(app, 0), "still the layer path, just hidden");
harness.run_steps(2);
assert!(Arc::ptr_eq(&harness.state().session.active().unwrap().doc, &committed), "previewing leaves the document alone");
if confirm {
crate::dialogs::confirm(harness.state_mut(), id).unwrap();
let st = harness.state().session.active().unwrap();
assert_eq!(st.history.past_len(), steps + 1, "one history step");
assert!(st.doc.layer(PREVIEW_LAYER).is_none());
assert!(max_diff(&st.doc, &want) <= 1e-6, "OK runs the real command");
} else {
harness.state_mut().ui.close_dialog(id);
harness.run_steps(2);
let st = harness.state().session.active().unwrap();
assert!(Arc::ptr_eq(&st.doc, &committed), "Cancel leaves the document untouched");
assert_eq!(st.history.past_len(), steps);
}
let app = harness.state_mut();
assert!(display_doc(app, 0).is_none() && !on_layer(app, 0));
}
}
/// Targets the layer path can't reproduce keep the CPU proxy preview.
#[test]
fn ineligible_dialogs_keep_the_proxy_preview() {
// A clipped target.
let (doc, _) = document(ColorMode::Rgb, SampleType::U8, false);
let clipped = doc.layers[2].id;
let mut app = app_with(doc, clipped);
crate::adjust_dialog::open(&mut app, "image.adjustments.curves").unwrap();
assert!(!on_layer(&mut app, 0) && display_doc(&mut app, 0).is_none());
// An alpha-channel target.
let (doc, target) = document(ColorMode::Rgb, SampleType::U8, false);
let mut app = app_with(doc, target);
app.session.execute("select.all", json!({})).unwrap();
app.session.execute("select.saveSelection", json!({})).unwrap();
if let Some(st) = app.session.active_mut() {
st.channel_view.target = photocraft_engine::channel_cmds::ChannelTarget::Alpha(0);
}
crate::adjust_dialog::open(&mut app, "image.adjustments.levels").unwrap();
assert!(!on_layer(&mut app, 0));
}
#[test]
fn proxy_factor_follows_the_zoom() {
assert_eq!(proxy_factor(1.0), 1);
assert_eq!(proxy_factor(0.6), 1);
assert_eq!(proxy_factor(0.5), 2);
assert_eq!(proxy_factor(0.2), 4);
assert_eq!(proxy_factor(0.1), 8);
assert_eq!(proxy_factor(0.0001), 64);
assert_eq!(proxy_factor(0.0), 1);
assert_eq!(proxy_factor(f32::NAN), 1);
}
/// The zoomed-out proxy keeps the preview layer (with the dialog's settings) under its own ids.
#[test]
fn proxy_preview_has_its_own_ids() {
let (doc, target) = document(ColorMode::Rgb, SampleType::U8, true);
let base = base_document(&doc, target).unwrap();
let proxy = proxy_base(&base, 2);
assert_eq!((proxy.size.width, proxy.size.height), (20, 16));
assert_ne!(proxy.id, doc.id);
assert!(proxy.walk().iter().all(|(_, _, l)| doc.layer(l.id).is_none() && l.id != PREVIEW_LAYER));
let p = proxy_with_settings(&proxy, "curves", &sample("curves")).unwrap();
let full = with_settings(&base, "curves", &sample("curves")).unwrap();
let (a, b) = (photocraft_compose::render(&p, p.bounds()), photocraft_compose::render(&full, full.bounds()));
// Nearest-neighbour proxy: pixel (x, y) shows the full composite at (2x, 2y).
for y in 0..16 {
for x in 0..20 {
let (pa, pb) = (a.px[y * 20 + x], b.px[y * 2 * 40 + x * 2]);
assert!((0..4).all(|c| (pa[c] - pb[c]).abs() < 2.0 / 255.0), "({x}, {y}) {pa:?} vs {pb:?}");
}
}
assert!(proxy_with_settings(&proxy, "levels", &json!({"gamma": "x"})).is_err());
}
+82 -10
View File
@@ -211,11 +211,15 @@ fn canvas_display(app: &PhotocraftApp, doc: &Document) -> (Option<std::sync::Arc
/// The document to render: the committed one, or a clone with the live adjustment preview applied.
fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Arc<Document>, u64) {
let st = &app.session.documents()[idx];
// Puppet / Perspective Warp previews hide the layer they draw on a mesh.
if let Some(shown) = crate::distort_ui::display_doc(app, idx) {
return shown;
}
// Image › Adjustments dialog: a temporary adjustment layer clipped to the target.
if let Some(shown) = crate::adjust_preview::display_doc(app, idx) {
return shown;
}
let st = &app.session.documents()[idx];
if let Some(l) = live_stroke(app, idx) {
return (l.stroke.doc.clone(), l.display_key());
}
@@ -264,6 +268,8 @@ fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Arc<Document>
/// Longest side of the Navigator panel's image (about twice the panel's width, for HiDPI).
pub const NAVIGATOR_SIDE: u32 = 512;
/// How long adjustment-dialog settings must stay unchanged before the navigator shows them.
const NAVIGATOR_SETTLE_MS: f64 = 200.0;
/// 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
@@ -283,6 +289,15 @@ pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz
{
return Some(t.id());
}
// While an adjustment dialog's settings are changing, the navigator keeps its image and
// catches up once they settle (a thumbnail composite per change would cost more than the canvas).
if let Some((r, _, t)) = &cached
&& *r == revision
&& crate::adjust_preview::settling(app, NAVIGATOR_SETTLE_MS)
{
ctx.request_repaint_after(std::time::Duration::from_millis(NAVIGATOR_SETTLE_MS as u64));
return Some(t.id());
}
let t0 = crate::gpu_canvas::now_ms();
let image = display_image(display.as_deref(), &photocraft_compose::thumbnail_buffer(&doc, NAVIGATOR_SIDE));
let tex = match cached {
@@ -303,7 +318,15 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize)
let st = app.session.documents().get(idx)?;
(st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id)
};
let (doc, preview_key) = display_doc(app, idx);
let (mut doc, mut preview_key) = display_doc(app, idx);
// A flipped view draws a GPU machine's canvas through here: an adjustment dialog's preview of
// a large document (taken by the GPU path) would cost a full-size CPU composite per change.
if crate::adjust_preview::shown_key(app) == Some(preview_key)
&& let Some(st) = app.session.documents().get(idx)
&& crate::proxy::factor(&st.doc) > 1
{
(doc, preview_key) = (st.doc.clone(), 0);
}
let (display, display_key) = canvas_display(app, &doc);
let seen = app.canvases.get(&id).map(|c| (c.tex_revision, c.tex_preview_key));
let damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, preview_key), display_key, last_damage));
@@ -359,6 +382,13 @@ fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), now: (u64, u6
if seen.1 == now.1 ^ display_key && seen.0 + 1 == now.0 {
return last_damage;
}
// Between an adjustment dialog's previews (and the document): the target's area.
if seen.0 == now.0
&& let Some(st) = app.session.documents().get(idx)
&& let Some(r) = crate::adjust_preview::switch_region(app, st.doc.id, now.0, seen.1 ^ display_key, now.1)
{
return Some(r);
}
let l = live_stroke(app, idx).filter(|l| seen.0 == now.0 && l.display_key() == now.1)?;
let r = l.since(seen.1 ^ display_key)?;
Some(if r.is_empty() { r } else { r.inflate(effect_reach(&l.stroke.doc.layers)) })
@@ -366,7 +396,7 @@ fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), now: (u64, u6
/// How far beyond an edit's damage rect the composite can change: layer effects (shadows, glows,
/// strokes, …) on the edited layer and on the groups around it reach that far.
fn effect_reach(layers: &[photocraft_doc::Layer]) -> i32 {
pub(crate) fn effect_reach(layers: &[photocraft_doc::Layer]) -> i32 {
layers
.iter()
.filter(|l| l.visible)
@@ -385,7 +415,7 @@ fn effect_reach(layers: &[photocraft_doc::Layer]) -> i32 {
/// GPU path: make sure document `idx` is current in the GPU canvas. Brush strokes re-composite
/// and upload only their damage rect; everything else re-composites the whole document.
/// Returns false if there is no GPU canvas.
fn ensure_gpu(app: &mut PhotocraftApp, idx: usize) -> bool {
fn ensure_gpu(app: &mut PhotocraftApp, idx: usize, visible: DRect) -> bool {
let Some(gpu) = app.gpu.clone() else { return false };
let Some((revision, last_damage, id)) = app
.session
@@ -395,11 +425,17 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize) -> bool {
else {
return false;
};
let (doc, preview_key) = display_doc(app, idx);
let (doc, raw_key) = display_doc(app, idx);
let (display, display_key) = canvas_display(app, &doc);
let seen = app.canvases.get(&id).map(|c| (c.revision, c.preview_key));
let damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, preview_key), display_key, last_damage));
let preview_key = preview_key ^ display_key;
let mut damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, raw_key), display_key, last_damage));
// 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))
{
damage = Some(crate::adjust_preview::switch_damage(app, raw_key, r, visible));
}
let preview_key = raw_key ^ display_key;
let size = [doc.size.width, doc.size.height];
let cache = app.canvases.entry(id).or_insert(CanvasCache {
revision: 0,
@@ -412,6 +448,11 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize) -> bool {
});
let present = cache.on_gpu && gpu.has(id.0, size);
if present && cache.revision == revision && cache.preview_key == preview_key {
// An adjustment preview composited where the view was: catch up where it moved to.
if let Some(r) = crate::adjust_preview::uncovered(app, id, raw_key, 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;
}
let partial = present && damage.is_some();
@@ -437,6 +478,9 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize) -> bool {
/// Live preview for an open filter dialog: run the filter on the proxy and upload it.
fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64)> {
if crate::adjust_preview::on_layer(app, idx) {
return None;
}
let d = app.ui.dialogs.iter().find(|d| d.fields.contains_key("__filter"))?;
if d.fields.get("__preview").and_then(serde_json::Value::as_bool) != Some(true) {
return None;
@@ -466,6 +510,31 @@ fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u6
app.filter_preview.as_ref().filter(|p| p.result.is_some()).map(|p| (p.k, key))
}
/// The document pixels a (non-rotated) view shows, with a margin for filtering.
fn visible_doc_rect(xf: &ViewXform) -> DRect {
let (a, b) = (xf.to_doc(xf.rect.min), xf.to_doc(xf.rect.max));
let c = |v: f64| v.clamp(-1e9, 1e9) as i32;
DRect::new(c(a[0].min(b[0]).floor()) - 2, c(a[1].min(b[1]).floor()) - 2, c(a[0].max(b[0]).ceil()) + 2, c(a[1].max(b[1]).ceil()) + 2)
}
/// Zoomed-out Image › Adjustments preview on a large document: the wgpu compositor renders the
/// reduced preview document (`adjust_preview::gpu_proxy`) into its own texture, over the target's
/// area after the first frame. Returns (factor, gpu key) to draw.
fn ensure_adjust_proxy(app: &mut PhotocraftApp, idx: usize, zoom: f32) -> Option<(u32, u64)> {
let frame = crate::adjust_preview::gpu_proxy(app, idx, zoom)?;
let key = frame.doc.id.0;
let size = [frame.doc.size.width, frame.doc.size.height];
let gpu = app.gpu.clone()?;
if frame.stale || !gpu.has(key, size) {
let doc = app.session.documents().get(idx)?.doc.clone();
let (display, _) = canvas_display(app, &doc);
let r = gpu.refresh(key, &frame.doc, frame.damage, display.as_deref());
app.perf.record(if r.kind.starts_with("gpu") { "gpu-adjust-proxy" } else { "adjust-proxy" }, r.px, r.composite_ms, r.upload_ms);
crate::adjust_preview::proxy_drawn(app, &frame);
}
Some((frame.k, key))
}
/// If a live adjustment preview is active on a large document, composite it on the proxy and upload
/// it under its own GPU key. Returns (factor, gpu key) when the proxy should be drawn.
fn ensure_proxy_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64)> {
@@ -521,7 +590,8 @@ pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if let Some(gpu) = &app.gpu {
// Keep each document's texture plus its preview textures (filter preview, adjustment proxy);
// retaining only document ids freed the previews every frame (blank canvas while previewing).
let live: Vec<u64> = app.session.documents().iter().flat_map(|st| [st.doc.id.0, st.doc.id.0 ^ (1u64 << 61), st.doc.id.0 ^ (1u64 << 62)]).collect();
let mut live: Vec<u64> = app.session.documents().iter().flat_map(|st| [st.doc.id.0, st.doc.id.0 ^ (1u64 << 61), st.doc.id.0 ^ (1u64 << 62)]).collect();
live.extend(crate::adjust_preview::gpu_keys(app));
gpu.retain(&live);
}
if app.ui.chrome.shows_home(app.session.documents().len()) {
@@ -839,7 +909,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// A flipped view draws through the CPU path (the GPU canvas shader has no mirroring).
if app.gpu.is_some()
&& !flip
&& let Some((k, key)) = ensure_filter_preview(app, idx).or_else(|| ensure_proxy_preview(app, idx))
&& let Some((k, key)) = ensure_adjust_proxy(app, idx, view.zoom * ctx.pixels_per_point())
.or_else(|| ensure_filter_preview(app, idx))
.or_else(|| ensure_proxy_preview(app, idx))
{
on_gpu = true;
let params = crate::gpu_canvas::ViewParams {
@@ -857,7 +929,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
hdr: hdr_preview(app, &doc),
};
crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params);
} else if !flip && ensure_gpu(app, idx) {
} else if !flip && ensure_gpu(app, idx, visible_doc_rect(&xf)) {
on_gpu = true;
app.perf.gpu = true;
// Shadow, checkerboard, document and pixel grid in one custom shader (gpu_canvas.rs).
+20
View File
@@ -161,6 +161,26 @@ impl GpuCanvas {
r.callback_resources.get::<Resources>().and_then(|res| res.docs.get(&doc)).is_some_and(|d| d.size == size)
}
/// Whether [`GpuCanvas::composite`] would draw `doc` with the wgpu compositor (rather than
/// fall back to the CPU compositor).
pub fn supports(&self, doc: &photocraft_doc::Document) -> bool {
if std::env::var_os("PHOTOCRAFT_CPU_COMPOSE").is_some() || doc.size.width == 0 || doc.size.height == 0 {
return false;
}
let r = self.rs.renderer.read();
let Some(res) = r.callback_resources.get::<Resources>() else { return false };
if res.compositor_failed.is_some() {
return false;
}
match &res.compositor {
Some(c) => c.supports(doc).is_ok(),
None => {
let max = self.rs.device.limits().max_texture_dimension_2d;
doc.size.width <= max && doc.size.height <= max && photocraft_gpu::plan(doc).is_ok()
}
}
}
/// Replace the whole document image with an `Rgba8Unorm` texture. `rgba` is premultiplied
/// RGBA8, `size[0] * size[1] * 4` bytes (anything else is ignored).
pub fn upload_full(&self, doc: u64, size: [u32; 2], rgba: &[u8]) {
+4
View File
@@ -10,6 +10,7 @@
pub mod actions;
pub mod adjust_dialog;
pub mod adjust_editors;
pub mod adjust_preview;
pub mod adjust_ui;
pub mod analysis_ui;
pub mod artboard_ui;
@@ -220,6 +221,8 @@ pub struct PhotocraftApp {
pub(crate) filter_preview: Option<filter_dialog::FilterPreview>,
/// Select › Color Range dialog preview (proxy document + mask / image textures).
pub(crate) color_range: Option<color_range_ui::Preview>,
/// Image › Adjustments dialog preview through a temporary clipped adjustment layer.
pub(crate) adjust_preview: Option<adjust_preview::AdjustPreview>,
/// Synthetic input events queued by automation (`ui.click`, `ui.key`, …), injected next frame.
pub(crate) synthetic: Vec<egui::Event>,
/// True only while a frame is processing synthetic automation input. It
@@ -301,6 +304,7 @@ impl PhotocraftApp {
outline_cache: None,
filter_preview: None,
color_range: None,
adjust_preview: None,
synthetic: Vec::new(),
automation_input: false,
channel_thumbs: None,
+188
View File
@@ -0,0 +1,188 @@
//! 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);
}
}
}