diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3047cb4881..f1625b7a22 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1121,7 +1121,7 @@ fn blend_if_weight(layer: &Layer, mode: photocraft_color::ColorMode, this: Optio let mut k = 1.0; for (side, px) in [this, under].into_iter().enumerate() { let Some(p) = px else { continue }; - let v = |c: usize| p[c].clamp(0.0, 1.0) * 255.0; + let v = |c: usize| blend_if_level(p[c]); match mode { // Gray is the colour channels' luma (Rec. 601 weights), then R, G, B. M::Rgb => { @@ -1142,6 +1142,17 @@ fn blend_if_weight(layer: &Layer, mode: photocraft_color::ColorMode, this: Optio k } +/// A channel value on Blend If's 0..=255 scale. Compositing can leave an 8- or 16-bit level a few +/// millionths off (a level-20 backdrop composited at partial alpha reads 19.999998), which would +/// put it on the wrong side of a hard point at that level: a value that close to a level is that +/// level. Values of those depths that aren't on a level are at least 1/257 away from one. The GPU +/// compositor (`compose.wgsl` `blend_if_weight`) snaps alike. +fn blend_if_level(x: f32) -> f32 { + let v = x.clamp(0.0, 1.0) * 255.0; + let r = v.round(); + if (v - r).abs() < 1e-4 { r } else { v } +} + /// Apply `layer`'s Blend If to a finished composite: `out` (the backdrop with the layer drawn) /// is mixed back towards `before` (the backdrop without it) where the ranges hide the layer. /// Mixing premultiplied colour by `k` equals compositing the layer at `k` × its alpha, since diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 5193c21983..69504e4a93 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1513,6 +1513,22 @@ fn blend_if_underlying_layer_hides_by_the_backdrop_value() { assert!(close4(px(&d, 6, 1), [1.0, 1.0, 1.0, 1.0])); } +/// An 8-bit backdrop at level `k` counts as `k` for a hard Underlying black point at `k`, also +/// over a semi-transparent backdrop, where compositing leaves the value a few millionths off the +/// level (19.999998 for 20 before #4395); the GPU compositor agrees. +#[test] +fn blend_if_hard_point_includes_its_own_level() { + for k in 0..=255u8 { + let v = f32::from(k) / 255.0; + let mut d = Document::new("t", Size::new(2, 1), ColorMode::Rgb, SampleType::U8); + d.layers.push(solid_layer("under", Rect::new(0, 0, 2, 1), [v, v, v, 184.0 / 255.0])); + let mut l = solid_layer("top", Rect::new(0, 0, 2, 1), [1.0, 0.0, 0.0, 1.0]); + l.blend_if.set(1, [FULL, range([k, k], [255, 255])]); + d.layers.push(l); + assert!(close4(px(&d, 0, 0), [1.0, 0.0, 0.0, 1.0]), "level {k} is hidden by a black point at {k}"); + } +} + #[test] fn blend_if_split_point_fades_like_opacity() { // A split black point (Alt-drag) at 50/150 shows a value-100 layer at half strength, which diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 19e6dc3cfc..e9d1b370ca 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -51,6 +51,7 @@ const F_LAB: u32 = 65536u; // Lab document: Normal mixes in CIELAB const F_HDR: u32 = 262144u; // 32-bit float document: HDR blends and Exposure retain values above 1 const F_QUANT: u32 = 32768u; // lerp: A rounded to p0.x steps (adjustment results, integer docs) const F_ADD_DIFF: u32 = 16384u; // lerp: A + (B - C) premultiplied (clips on pass-through groups) +const F_BLEND_IF: u32 = 524288u; // lerp: B back towards A by the Blend If ranges in p0..p3 const F_TEXT_GAMMA: u32 = 8192u; // blend / atop / fx merge: type layer, mix coverage at gamma p4.w @group(0) @binding(0) var chunk: Chunk; @@ -874,6 +875,21 @@ fn fs_lerp(in: VOut) -> @location(0) vec4 { if (al <= 0.0) { return vec4(0.0); } return vec4(clamp(pm.rgb / al, vec3(0.0), vec3(1.0)), al); } + if ((op.flags & F_BLEND_IF) != 0u) { + // compose::apply_blend_if: "This Layer" is C (the layer's own content with its masks), + // or B itself for an adjustment layer; "Underlying Layer" is A. + var this_px = b; + if (op.p4.y < 0.5) { + this_px = textureLoad(tex_c, p, 0); + } + let k = blend_if_weight(this_px, a); + if (k >= 1.0) { + return b; + } + let alpha = a.a + (b.a - a.a) * k; + if (alpha <= 0.0) { return vec4(0.0); } + return vec4(clamp((a.rgb * a.a + (b.rgb * b.a - a.rgb * a.a) * k) / alpha, vec3(0.0), vec3(1.0)), alpha); + } let k = op.opacity * mask_value(doc_px(p)); let wa = a.a * (1.0 - k); let wb = b.a * k; @@ -882,6 +898,57 @@ fn fs_lerp(in: VOut) -> @location(0) vec4 { return vec4((a.rgb * wa + b.rgb * wb) / alpha, alpha); } +// ---- Blend If (photocraft_doc::BlendRange, compose::blend_if_weight) --------------------------- + +// 0 at or below `lo`, 1 at or above `hi`; a hard step at `lo` when unsplit. +fn bi_ramp(lo: f32, hi: f32, x: f32) -> f32 { + if (hi <= lo) { + return select(0.0, 1.0, x >= lo); + } + return clamp((x - lo) / (hi - lo), 0.0, 1.0); +} + +// How much of value `v` (0..255) shows through range `e` (0 = Gray, then the channels) on side +// `s` (0 = this layer, 1 = underlying). The ranges are packed by `plan::blend_if_params`. +fn bi_weight(e: u32, s: u32, v: f32) -> f32 { + var f = array, 4>(op.p0, op.p1, op.p2, op.p3); + let i = (e * 2u + s) * 2u; + let black = f[i / 4u][i % 4u]; + let white = f[(i + 1u) / 4u][(i + 1u) % 4u]; + let b_lo = floor(black / 256.0); + let b_hi = black - b_lo * 256.0; + let w_lo = floor(white / 256.0); + let w_hi = white - w_lo * 256.0; + return bi_ramp(b_lo, b_hi, v) * bi_ramp(255.0 - w_hi, 255.0 - w_lo, 255.0 - v); +} + +// The product of every range over the layer's own colour and the colour beneath it (a side +// without content, alpha 0, doesn't count). `p4.x` marks a one-channel document. +fn blend_if_weight(this_px: vec4, under: vec4) -> f32 { + var k = 1.0; + for (var s = 0u; s < 2u; s = s + 1u) { + let px = select(this_px, under, s == 1u); + if (px.a <= 0.0) { + continue; + } + // The CPU gets 8- and 16-bit levels exactly (k / 255 × 255 = k), but GPU arithmetic can + // land a few millionths below, which flips a hard threshold at that level: values that + // close to a level are that level. Real 8/16-bit values off a level are ≥ 1/257 away. + let raw = clamp(px.rgb, vec3(0.0), vec3(1.0)) * 255.0; + let v = select(raw, round(raw), abs(raw - round(raw)) < vec3(1e-4)); + if (op.p4.x > 0.5) { + k = k * bi_weight(0u, s, v.r) * bi_weight(1u, s, v.r); + } else { + let gray = 0.299 * v.r + 0.587 * v.g + 0.114 * v.b; + k = k * bi_weight(0u, s, gray) * bi_weight(1u, s, v.r) * bi_weight(2u, s, v.g) * bi_weight(3u, s, v.b); + } + if (k <= 0.0) { + return 0.0; + } + } + return k; +} + // ---- layer effects (compose::effects::composite_with_effects) ------------------------------- const INSIDE_EPS: f32 = 0.5 / 255.0; diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 60a57b83bc..72ffaa3c06 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -388,9 +388,30 @@ pub const F_HDR: u32 = 262144; pub const F_QUANT: u32 = 32768; /// `Lerp` as A + (B − C) premultiplied (layers clipped to pass-through groups). pub const F_ADD_DIFF: u32 = 16384; +/// `Lerp`: B mixed back towards A (the backdrop) by the layer's Blend If weight +/// (`compose::apply_blend_if`), from C ("This Layer", or B itself for an adjustment layer, `p4.y`) +/// and A ("Underlying Layer"). The ranges are packed in `p0..p3` (`blend_if_params`); `p4.x` marks +/// a one-channel document. +pub const F_BLEND_IF: u32 = 524288; /// Blend / Atop / FxMerge of a type layer: coverage mixed at `psblend::TEXT_GAMMA`. pub const F_TEXT_GAMMA: u32 = 8192; +/// A layer's Blend If ranges for `F_BLEND_IF`: Gray, then the colour channels, each as +/// [this layer, underlying layer]; every range as two floats, `black low × 256 + black high` and +/// `white low × 256 + white high` (exact in f32), so entry `e`, side `s` starts at float +/// `(e × 2 + s) × 2`. Entries past the fourth never apply (RGB has four, one-channel modes two). +fn blend_if_params(bi: &photocraft_doc::BlendIf) -> [[f32; 4]; 4] { + let mut f = [0.0f32; 16]; + for e in 0..4 { + for (s, r) in bi.get(e).iter().enumerate() { + let i = (e * 2 + s) * 2; + f[i] = f32::from(r.black[0]) * 256.0 + f32::from(r.black[1]); + f[i + 1] = f32::from(r.white[0]) * 256.0 + f32::from(r.white[1]); + } + } + [[f[0], f[1], f[2], f[3]], [f[4], f[5], f[6], f[7]], [f[8], f[9], f[10], f[11]], [f[12], f[13], f[14], f[15]]] +} + /// `F_TEXT_GAMMA` for type layers while text gamma blending is on; the gamma goes in `p4.w`. fn gamma_flag(layer: &Layer) -> u32 { if photocraft_compose::text_gamma(layer) != 1.0 { F_TEXT_GAMMA } else { 0 } @@ -515,12 +536,44 @@ impl<'a> Planner<'a> { }) } - /// Blending Options › Blend If has no GPU pass yet: such documents use the CPU compositor. - fn check_blend_if(&self, layer: &Layer) -> Result<(), Unsupported> { - if photocraft_compose::blend_if_active(layer, self.cx.mode) { - return Err(Unsupported(format!("Blend If on `{}` (composited on the CPU)", layer.name))); + /// `compose::apply_blend_if`: `after` (the backdrop with `layer` drawn) mixed back towards + /// `before` (the backdrop without it) where the layer's Blend If ranges hide it. "This Layer" + /// is the layer's own content with its masks; an adjustment layer is judged by its result. + /// Consumes both slots. + fn blend_if(&mut self, layer: &'a Layer, before: Slot, after: Slot) -> Result { + let own = match layer.content { + LayerContent::Adjustment(_) => None, + _ => Some(self.content(layer)?), + }; + let mut p = Pass::new(Kernel::Lerp, 0); + p.a = Some(before); + p.b = Some(after); + p.c = own; + p.flags = F_BLEND_IF; + p.params = blend_if_params(&layer.blend_if); + let one_channel = self.cx.mode != photocraft_color::ColorMode::Rgb; + p.extra = [f32::from(u8::from(one_channel)), f32::from(u8::from(own.is_none())), 0.0, 0.0]; + Ok(self.emit(p)) + } + + /// Channel restrictions and Blend If around `draw`, which composites `layer` onto `backdrop` + /// (`compose::composite_layer` / `composite_atop`: both are relative to the same backdrop). + fn restricted(&mut self, layer: &'a Layer, backdrop: Slot, draw: impl FnOnce(&mut Self, Slot) -> Result) -> Result { + let w = photocraft_compose::channel_weights(layer, self.cx.mode); + let blend_if = photocraft_compose::blend_if_active(layer, self.cx.mode); + if w.is_none() && !blend_if { + return draw(self, backdrop); } - Ok(()) + let before = self.retain(backdrop); + let mut after = draw(self, backdrop)?; + if let Some(w) = w { + let b = if blend_if { self.retain(before) } else { before }; + after = self.restore_channels(b, after, w); + } + if blend_if { + after = self.blend_if(layer, before, after)?; + } + Ok(after) } /// Blending Options › Advanced Blending (knockout, clipped layers blended individually, blend @@ -536,16 +589,8 @@ impl<'a> Planner<'a> { /// composite_layer, honouring the layer's channel restrictions. fn layer(&mut self, layer: &'a Layer, clipped: &'a [Layer], backdrop: Slot) -> Result { - self.check_blend_if(layer)?; self.check_advanced(layer, clipped)?; - match photocraft_compose::channel_weights(layer, self.cx.mode) { - Some(w) => { - let before = self.retain(backdrop); - let after = self.layer_any(layer, clipped, backdrop)?; - Ok(self.restore_channels(before, after, w)) - } - None => self.layer_any(layer, clipped, backdrop), - } + self.restricted(layer, backdrop, |s, b| s.layer_any(layer, clipped, b)) } /// `compose::restore_channels`: `after` with the channels a layer leaves out taken from @@ -711,7 +756,6 @@ impl<'a> Planner<'a> { if clip.is_some_and(|c| c.is_empty()) { // Nothing to draw (an empty layer, or a group of them). for c in &visible_clipped { - self.check_blend_if(c)?; self.check_advanced(c, &[])?; } return Ok(backdrop); @@ -894,16 +938,8 @@ impl<'a> Planner<'a> { /// composite_atop: `layer` onto `base`, restricted to the base's alpha, honouring the /// layer's channel restrictions. fn atop(&mut self, layer: &'a Layer, base: Slot) -> Result { - self.check_blend_if(layer)?; self.check_advanced(layer, &[])?; - match photocraft_compose::channel_weights(layer, self.cx.mode) { - Some(w) => { - let before = self.retain(base); - let after = self.atop_any(layer, base)?; - Ok(self.restore_channels(before, after, w)) - } - None => self.atop_any(layer, base), - } + self.restricted(layer, base, |s, b| s.atop_any(layer, b)) } fn atop_any(&mut self, layer: &'a Layer, base: Slot) -> Result { @@ -1973,17 +2009,32 @@ mod tests { } #[test] - fn blend_if_falls_back_to_the_cpu() { + fn blend_if_plans_on_the_gpu() { let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE); let mut l = Layer::raster("bi", d.pixel_format()); + l.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]); l.blend_if.set(0, [photocraft_doc::BlendRange { black: [40, 40], white: [255, 255] }, photocraft_doc::BlendRange::FULL]); d.layers.push(l.clone()); - assert!(plan(&d).unwrap_err().0.contains("Blend If")); + let blend_if = |d: &Document| plan(d).unwrap().passes.iter().filter(|p| p.flags & F_BLEND_IF != 0).count(); + assert_eq!(blend_if(&d), 1); // Clipped layers too. l.clipped = true; d.layers[1].blend_if = Default::default(); d.layers.push(l); - assert!(plan(&d).is_err()); + assert_eq!(blend_if(&d), 1); + // Outside RGB, Grayscale and Duotone the setting round-trips without applying. + d.mode = ColorMode::Lab; + assert_eq!(blend_if(&d), 0); + } + + #[test] + fn blend_if_params_pack_every_range_exactly() { + let mut bi = photocraft_doc::BlendIf::default(); + bi.set(3, [photocraft_doc::BlendRange { black: [255, 255], white: [0, 1] }, photocraft_doc::BlendRange { black: [12, 200], white: [201, 254] }]); + let f: Vec = blend_if_params(&bi).iter().flatten().copied().collect(); + // Entry 3 (B), this layer then underlying; unset entries are full ranges. + assert_eq!(&f[12..16], &[255.0 * 256.0 + 255.0, 1.0, 12.0 * 256.0 + 200.0, 201.0 * 256.0 + 254.0]); + assert_eq!(&f[0..2], &[0.0, 255.0 * 256.0 + 255.0]); } #[test] diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 2bf8bd82b3..944c756662 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -370,7 +370,7 @@ fn prefix_partial_damage_context_occlusion_and_budget_are_conservative() { check(&mut g, &doc, "bounded admission"); assert!(g.comp.composite_cache_bytes() <= 1 << 20); assert!(diff_rect(&mut g, &doc, doc.bounds(), "bounded cache keeps its hits").unwrap().prefix_hits > 0); - doc.layers[0].blend_if.set(0, [photocraft_doc::BlendRange { black: [40, 40], white: [255, 255] }, photocraft_doc::BlendRange::FULL]); + doc.layers[0].advanced.knockout = photocraft_doc::Knockout::Deep; assert!(render_to_vec(&mut g.comp, &g.device, &g.queue, &doc, doc.bounds()).is_err()); assert_eq!(g.comp.composite_cache_bytes(), 0); } @@ -1699,6 +1699,78 @@ fn stroke_effects_on_filled_and_stroked_shapes() { } } +/// Blend If on the GPU matches `compose::apply_blend_if` (#4395): this-layer and underlying +/// ranges, hard and split points, per channel, on raster, masked, adjustment and clipped layers, +/// with channel restrictions, and in grayscale. Hard points are compared on 8-bit documents only: +/// 16/32-bit layers reach the GPU as RGBA16F, so a value within about a tenth of a level of a +/// hard point can land on its other side (as with the GPU Threshold adjustment, also compared at +/// 8 bits); split points are compared at every depth. +#[test] +fn blend_if() { + use photocraft_doc::BlendRange; + let Some(mut g) = gpu() else { return }; + let r = |b0: u8, b1: u8, w0: u8, w1: u8| BlendRange { black: [b0, b1], white: [w0, w1] }; + // (name, entry, [this layer, underlying], has a hard point) + let cases: [(&str, usize, [BlendRange; 2], bool); 7] = [ + ("gray this hard", 0, [r(90, 90, 255, 255), BlendRange::FULL], true), + ("gray under split", 0, [BlendRange::FULL, r(30, 140, 150, 230)], false), + ("red both", 1, [r(0, 60, 200, 200), r(20, 20, 180, 250)], true), + ("red both split", 1, [r(0, 60, 190, 230), r(10, 50, 180, 250)], false), + ("green white split", 2, [r(0, 0, 100, 220), BlendRange::FULL], false), + ("blue under hard", 3, [BlendRange::FULL, r(128, 128, 255, 255)], true), + ("everything hidden", 0, [r(255, 255, 0, 0), BlendRange::FULL], true), + ]; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let fmt = PixelFormat { sample: depth, ..PixelFormat::RGBA8 }; + for (name, e, pair, hard) in cases { + if hard && depth != SampleType::U8 { + continue; + } + let mut d = Document::new("bi", Size::new(64, 48), ColorMode::Rgb, depth); + d.layers.push(noise_layer("bg", fmt, Rect::new(0, 0, 64, 48), 1, 0.3)); + let mut l = noise_layer("top", fmt, Rect::new(4, 3, 60, 45), 2, 0.0); + l.blend = BlendMode::Multiply; + l.opacity = 0.8; + l.blend_if.set(e, pair); + d.layers.push(l.clone()); + check(&mut g, &d, &format!("{depth:?} {name}")); + // Edits above it reuse the lower stack, Blend If included (prefix checkpoints). + let mut above = d.clone(); + above.layers.push(noise_layer("above", fmt, Rect::new(20, 0, 40, 48), 4, 0.5)); + check(&mut g, &above, &format!("{depth:?} {name} under another layer")); + above.layers[2].opacity = 0.4; + let hits = diff_rect(&mut g, &above, above.bounds(), &format!("{depth:?} {name} edit above")).unwrap_or_else(|e| panic!("{e}")); + assert!(hits.prefix_hits > 0, "{depth:?} {name}: the edit above reuses the lower stack"); + + // Masked, with channel restrictions. + let mut m = l.clone(); + m.mask = Some(mask(Rect::new(0, 0, 64, 48), 5, 1.0)); + m.excluded_channels = 0b010; + d.layers[1] = m; + check(&mut g, &d, &format!("{depth:?} {name} masked, channels")); + + // An adjustment judged by its result, and a clipped layer. + let mut adj = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)); + adj.blend_if.set(e, pair); + d.layers[1] = l.clone(); + d.layers[1].blend_if = Default::default(); + d.layers.push(adj); + let mut c = noise_layer("clip", fmt, Rect::new(10, 10, 50, 40), 3, 0.2); + c.clipped = true; + c.blend_if.set(e, pair); + d.layers.push(c); + check(&mut g, &d, &format!("{depth:?} {name} adjustment, clipped")); + } + } + // Grayscale: the one channel's entries. + let mut d = Document::new("g", Size::new(32, 32), ColorMode::Grayscale, SampleType::U8); + d.layers.push(noise_layer("bg", PixelFormat::GRAYA8, Rect::new(0, 0, 32, 32), 9, 1.0)); + let mut l = noise_layer("top", PixelFormat::GRAYA8, Rect::new(0, 0, 32, 32), 10, 1.0); + l.blend_if.set(1, [r(60, 120, 255, 255), r(0, 0, 140, 200)]); + d.layers.push(l); + check(&mut g, &d, "gray blend if"); +} + #[test] fn channel_restrictions() { let Some(mut g) = gpu() else { return }; diff --git a/docs/gpu-composite-reuse.md b/docs/gpu-composite-reuse.md index 47b1f5c06d..39dad36107 100644 --- a/docs/gpu-composite-reuse.md +++ b/docs/gpu-composite-reuse.md @@ -13,10 +13,12 @@ existing planner before this boundary. Only the accumulated backdrop is live the it into the checkpoint's slot is sufficient to execute the remaining passes. No checkpoint is placed inside a group, clipping unit or adjustment program. -Blend-If currently returns `Unsupported` from the GPU planner and uses the existing CPU path. -The same holds for Advanced Blending (on main since b0680fd): it must keep falling back to the -CPU, or keep the checkpoint boundary rule — a later GPU implementation of advanced blending -preserves the boundary invariant either way. +Blend If runs on the GPU (#4395) within this rule: like channel restrictions, it reads only the +backdrop from before its own unit (retained across the unit, released at its end), so nothing +else is live at the boundary. Advanced Blending (on main since b0680fd) returns `Unsupported` +from the GPU planner and uses the CPU path: it must keep falling back to the CPU, or keep the +checkpoint boundary rule — a later GPU implementation of advanced blending preserves the +boundary invariant either way. ## Invalidation