From b5dbf608b3ff38f6cd9b20d43c3c9c22a03b9d0d Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 14:23:06 +1100 Subject: [PATCH 1/2] Treat Dissolve as a per-pixel choice for adjustments, clipped fills, and effects. Those paths mixed the layer by its opacity, so a 50 percent Dissolve exported as flat gray. Each pixel is now either the layer or the backdrop. Fixes #3050, #3051, and #3052. --- crates/compose/src/effects.rs | 3 ++- crates/compose/src/lib.rs | 25 ++++++++++++++++++ crates/compose/src/tests.rs | 49 +++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 97d08f00da..196c37eba6 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -2095,7 +2095,8 @@ pub(crate) fn composite_with_effects_prepared( let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize; let a = before.px[i]; let b = if x >= big.x0 && x < big.x1 && y >= big.y0 && y < big.y1 { work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize] } else { a }; - backdrop.px[i] = mix_premul(a, b, op); + // Dissolve chooses the finished layer or the backdrop. Mixing by opacity grays it (#3051). + backdrop.px[i] = if layer.blend == BlendMode::Dissolve { if super::dissolve_noise(x, y) < op { b } else { a } } else { mix_premul(a, b, op) }; } } } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3047cb4881..abd257e979 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1315,6 +1315,13 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer // Only `adjusted` has changed; this pixel's original backdrop is still in place. let b = backdrop.px[i]; let a = adjusted.px[i]; + // Dissolve is a per-pixel choice, not a partial mix (#3050). + if layer.blend == BlendMode::Dissolve { + if dissolve_noise(x, y) < k { + backdrop.px[i] = [a[0], a[1], a[2], b[3]]; + } + continue; + } let blended = blend::blend_rgb(layer.blend, [b[0], b[1], b[2]], [a[0], a[1], a[2]]); backdrop.px[i] = [b[0] + (blended[0] - b[0]) * k, b[1] + (blended[1] - b[1]) * k, b[2] + (blended[2] - b[2]) * k, b[3]]; } @@ -1653,6 +1660,14 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { for (i, p) in base.px.iter_mut().enumerate() { let k = layer.opacity * layer.fill_opacity * mask_k(&mv, i); let a = adjusted.px[i]; + if layer.blend == BlendMode::Dissolve { + let x = rect.x0 + (i as i32 % rect.width() as i32); + let y = rect.y0 + (i as i32 / rect.width() as i32); + if dissolve_noise(x, y) < k { + *p = [a[0], a[1], a[2], p[3]]; + } + continue; + } let bl = blend::blend_rgb(layer.blend, [p[0], p[1], p[2]], [a[0], a[1], a[2]]); for c in 0..3 { p[c] += (bl[c] - p[c]) * k; @@ -1708,6 +1723,16 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { } let s = content.px[i]; // Blend as if the base were opaque, then keep the base's alpha. + // Dissolve picks the source or the base; a partial mix would turn a clipped fill gray (#3052). + if layer.blend == BlendMode::Dissolve { + let x = rect.x0 + (i as i32 % rect.width() as i32); + let y = rect.y0 + (i as i32 / rect.width() as i32); + if dissolve_noise(x, y) < s[3] * opacity * alpha { + let r = blend::composite(BlendMode::Normal, [p[0], p[1], p[2], 1.0], [s[0], s[1], s[2], 1.0], 1.0); + *p = [r[0], r[1], r[2], alpha]; + } + continue; + } let r = blend::composite_gamma(layer.blend, [p[0], p[1], p[2], 1.0], s, opacity, gamma); *p = [r[0], r[1], r[2], alpha]; } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index c13a423ca8..fac76ce5b2 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -576,6 +576,55 @@ fn dissolve_coverage_matches_opacity() { assert_eq!(flatten(&d), b); } +/// Invert at 50% Dissolve is black or white, not the gray of a Normal mix (#3050). +#[test] +fn dissolve_adjustment_is_a_per_pixel_choice() { + let mut d = doc_white(16, 16); + let mut inv = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)); + inv.blend = photocraft_color::BlendMode::Dissolve; + inv.opacity = 0.5; + d.layers.push(inv); + let b = flatten(&d); + let (black, white, other) = b.px.iter().fold((0, 0, 0), |(k, w, o), p| { + if close4(*p, [0.0, 0.0, 0.0, 1.0]) { + (k + 1, w, o) + } else if close4(*p, [1.0, 1.0, 1.0, 1.0]) { + (k, w + 1, o) + } else { + (k, w, o + 1) + } + }); + assert_eq!(other, 0, "a partial mix would be gray"); + assert!(black > 40 && white > 40, "black {black} white {white}"); +} + +/// A clipped black fill at 50% Dissolve stays black or white on an opaque base (#3052). +#[test] +fn clipped_dissolve_fill_is_a_per_pixel_choice() { + let mut d = doc_white(16, 16); + let mut l = solid_layer("k", Rect::new(0, 0, 16, 16), [0.0, 0.0, 0.0, 1.0]); + l.clipped = true; + l.blend = photocraft_color::BlendMode::Dissolve; + l.opacity = 0.5; + d.layers.push(l); + let b = flatten(&d); + assert!(b.px.iter().all(|p| close4(*p, [0.0, 0.0, 0.0, 1.0]) || close4(*p, [1.0; 4]))); +} + +/// A Color Overlay must not turn Dissolve coverage into a Normal mix (#3051). +#[test] +fn color_overlay_keeps_dissolve_coverage() { + use photocraft_doc::effects::{Effect, FxCommon}; + let mut d = doc_white(16, 16); + let mut l = Layer::new("ink", LayerContent::Fill(Fill::Solid(photocraft_color::Color::BLACK))); + l.effects.items.push(Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: photocraft_color::Color::BLACK }); + l.blend = photocraft_color::BlendMode::Dissolve; + l.opacity = 0.5; + d.layers.push(l); + let b = flatten(&d); + assert!(b.px.iter().all(|p| close4(*p, [0.0, 0.0, 0.0, 1.0]) || close4(*p, [1.0; 4]))); +} + #[test] fn render_is_tile_independent() { // Rendering a sub-rect must equal the same region of a full render. From b157affd9e323f159ed2f2ec31a5f313c5a6ac60 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 17:04:30 +1100 Subject: [PATCH 2/2] Match Dissolve on the GPU compositor for effects, adjustments, and clipped fills. The CPU compositor already chose the layer or the backdrop per pixel. The GPU path still mixed those same cases by opacity, so a Dissolve layer with effects disagreed with the CPU result. --- crates/gpu/src/compose.wgsl | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 19e6dc3cfc..b490fef646 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -828,6 +828,14 @@ fn fs_atop(in: VOut) -> @location(0) vec4 { let base = textureLoad(tex_a, p, 0); if (base.a <= 0.0) { return base; } let s = textureLoad(tex_b, p, 0); + // A clipped Dissolve fill picks the source or the base. Mixing by coverage grays it (#3052). + if (op.mode == M_DISSOLVE) { + if (dissolve_noise(doc_px(p)) < s.a * op.opacity * base.a) { + let r = composite(M_NORMAL, vec4(base.rgb, 1.0), vec4(s.rgb, 1.0), 1.0); + return vec4(r.rgb, base.a); + } + return base; + } let r = composite_g(op.mode, vec4(base.rgb, 1.0), s, op.opacity, (op.flags & F_TEXT_GAMMA) != 0u); return vec4(r.rgb, base.a); } @@ -850,6 +858,11 @@ fn fs_adjmix(in: VOut) -> @location(0) vec4 { let k = op.opacity * mask_value(doc_px(p)); if (k <= 0.0) { return b; } let a = textureLoad(tex_b, p, 0); + // Dissolve keeps the adjusted colour or the backdrop (#3050). + if (op.mode == M_DISSOLVE) { + if (dissolve_noise(doc_px(p)) < k) { return vec4(a.rgb, b.a); } + return b; + } let bl = blend_rgb(op.mode, b.rgb, a.rgb); return vec4(b.rgb + (bl - b.rgb) * k, b.a); } @@ -1103,7 +1116,13 @@ fn fs_fxmerge(in: VOut) -> @location(0) vec4 { let atop = (op.flags & F_ATOP) != 0u; var before = c; if (atop) { before = vec4(c.rgb, 1.0); } - let o = mix_premul(before, w, op.opacity); + // Dissolve chooses the finished layer or the backdrop. Mixing by opacity grays it (#3051). + var o: vec4; + if (op.mode == M_DISSOLVE) { + o = select(before, w, dissolve_noise(doc_px(p)) < op.opacity); + } else { + o = mix_premul(before, w, op.opacity); + } if (atop) { if (c.a > 0.0) { return vec4(o.rgb, c.a); } return c;