Browse / github.com/storytold/photocraft / patch
compose, gpu: layer-effect Opacity acts like Fill for Photoshop's special-eight blend modes
compose, gpu: layer-effect Opacity acts like Fill for Photoshop's special-eight blend modes ## What differed from Photoshop Layer effects (inner/drop shadow, glows, satin, overlays, strokes, bevel colours) set to Vivid Light, Linear Light, Color Dodge, Linear Dodge, Color Burn, Linear Burn, Hard Mix or Pin Light were mixed in linearly: the full-strength result was blended in by `coverage x Opacity`. In Photoshop these modes treat an effect's strength like a layer's Fill: the colour is pulled toward the mode's neutral and blended at full strength (the same rule `psblend::composite_fill` already implements for the layer Fill). Observation (Photoshop, a pixel layer filled with RGB 1
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- morioka185 (imported from pull request #9613, open)
- Base
d3b0a76c3f6ca22250850f0dafd93279cad85e41- Size
- +75 −2 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
c5e9649e86ae17eec33517a4
How it went
No other operator has reported using it yet.
Diff
raw · crates/compose/src/effects.rs crates/compose/src/tests.rs crates/gpu/src/compose.wgsl crates/gpu/tests/it/parity.rs
@@ -1142,6 +1142,16 @@ fn paint(dst: &mut Buffer, m: &Map, color: impl Fn(usize) -> [f32; 4], blend: Bl } continue; }+ if psblend::fill_is_special(blend) {+ // Photoshop's "special eight" (Vivid Light, Color Dodge, ...): an effect's strength+ // (coverage x Opacity), like a layer's Fill, pulls the colour toward the mode's+ // neutral and blends it at full strength, instead of mixing the full-strength+ // result in linearly. Linear mixing turned a Vivid Light white inner shadow on a+ // near-black fill into mid grey, and a Vivid Light white satin on gold into white,+ // where Photoshop keeps the fill dark / the gold saturated.+ *p = psblend::composite_fill(blend, *p, [c[0], c[1], c[2], c[3]], 1.0, a.min(1.0), 1.0);+ continue;+ } *p = psblend::composite(blend, *p, [c[0], c[1], c[2], c[3] * a], 1.0); } }@@ -1248,6 +1248,43 @@ fn stroke(size: f32, position: StrokePosition) -> Effect { }) } +/// The middle pixel of a grey square carrying `effect`, in a white document of `depth`.+fn effect_on_grey(depth: SampleType, grey: f32, effect: Effect) -> [f32; 4] {+ let mut d = Document::with_background("t", Size::new(40, 40), ColorMode::Rgb, depth, Color::WHITE);+ let fmt = d.pixel_format();+ let mut l = Layer::raster("sq", fmt);+ l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 30), &photocraft_raster::from_rgba(&fmt, [grey, grey, grey, 1.0]));+ l.effects.items = vec![effect];+ d.layers.push(l);+ px(&d, 20, 20)+}++#[test]+fn special_eight_effect_strength_acts_like_fill() {+ // Photoshop shows a white Vivid Light effect at 56 % on a dark grey (16/255) as+ // 16 / (1 - 0.56) = 36.4 / 255: the strength pulls the colour toward the mode's neutral and+ // the blend runs at full strength, as with a layer's Fill. Mixing the full-strength result+ // in linearly gave about 150.+ let grey = 16.0 / 255.0;+ let want = grey / (1.0 - 0.56);+ let overlay = |mode, opacity| Effect::ColorOverlay { common: FxCommon::new(mode, opacity), color: Color::WHITE };+ for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {+ let got = effect_on_grey(depth, grey, overlay(BlendMode::VividLight, 0.56));+ let tolerance = if depth == SampleType::U8 { 1.5 / 255.0 } else { 0.5 / 255.0 };+ assert!((got[0] - want).abs() <= tolerance, "{depth:?}: {got:?} vs {want}");+ assert!(close4(got, [got[0], got[0], got[0], 1.0]), "{depth:?}: stays neutral {got:?}");+ // Full strength is the plain mode; no strength leaves the fill alone.+ let full = effect_on_grey(depth, grey, overlay(BlendMode::VividLight, 1.0));+ let plain = blend_px(depth, BlendMode::VividLight, [grey; 3], [1.0; 3]);+ assert!(close4(full, plain), "{depth:?}: {full:?} vs {plain:?}");+ let none = effect_on_grey(depth, grey, overlay(BlendMode::VividLight, 0.0));+ assert!((none[0] - grey).abs() <= 0.5 / 255.0, "{depth:?}: {none:?}");+ // Modes outside the special eight still mix linearly.+ let soft = effect_on_grey(depth, grey, overlay(BlendMode::Normal, 0.56));+ let linear = grey + (1.0 - grey) * 0.56;+ assert!((soft[0] - linear).abs() <= 1.0 / 255.0, "{depth:?}: {soft:?} vs {linear}");+ }+} #[test] fn outside_stroke_width() { let d = fx_doc(vec![stroke(3.0, StrokePosition::Outside)]);@@ -1171,6 +1171,15 @@ fn fx_composite(d: vec2<i32>, dst: vec4<f32>, s: vec4<f32>) -> vec4<f32> { return composite(op.mode, dst, s, 1.0); } +// The same with the effect's strength `k` (coverage x opacity) kept apart from the colour's own+// alpha `s.a`: Photoshop's special-eight modes treat it like a layer's Fill (effects::paint).+fn fx_composite_k(d: vec2<i32>, dst: vec4<f32>, s: vec4<f32>, k: f32) -> vec4<f32> {+ if (op.mode != M_DISSOLVE && fill_is_special(op.mode)) {+ return composite_fill(op.mode, dst, s, 1.0, min(k, 1.0), false);+ }+ return fx_composite(d, dst, vec4(s.rgb, s.a * k));+}+ // Effect chain steps (`kind`): 0 A at `opacity` × alpha; 1 an opaque copy of A (a clipping base // for clipped layers' effects); 2 A's colour with alpha `opacity` inside B's shape (the layer // before its interior effects); 3 A with its alpha × B's alpha (the shape's own alpha); 4 the@@ -1239,12 +1248,12 @@ fn fs_fxpaint(in: VOut) -> @location(0) vec4<f32> { let k = min(m * op.p0.x, 1.0) * op.opacity; if (k <= 0.0) { return dst; } let t = 1.0 - clamp(m, 0.0, 1.0);- return fx_composite(d, dst, vec4(lut(0, t), lut(1, t), lut(2, t), lut(3, t) * k));+ return fx_composite_k(d, dst, vec4(lut(0, t), lut(1, t), lut(2, t), lut(3, t)), k); } let k = m * op.opacity; if (k <= 0.0) { return dst; } let c = fx_color(d);- return fx_composite(d, dst, vec4(c.rgb, c.a * k));+ return fx_composite_k(d, dst, c, k); } // Outside strokes (A = the exterior result before them, B = the layer, C = coverage so far in@@ -1738,6 +1738,23 @@ fn effect_cases() -> Vec<(&'static str, Vec<Effect>)> { Effect::Stroke(stroke(6.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.0, 0.0, 0.0)))), ], ),+ (+ "special-eight effects act like Fill",+ vec![+ Effect::InnerShadow(shadow(BlendMode::VividLight, 0.56, 120.0, 4.0, 6.0, 0.0)),+ Effect::Satin(Satin {+ common: FxCommon::new(BlendMode::VividLight, 1.0),+ color: Color::rgb(1.0, 1.0, 1.0),+ angle: 19.0,+ distance: 7.0,+ size: 8.0,+ contour: Contour::Linear,+ anti_alias: false,+ invert: false,+ }),+ Effect::ColorOverlay { common: FxCommon::new(BlendMode::LinearDodge, 0.35), color: Color::rgb(0.9, 0.5, 0.1) },+ ],+ ), ( "multiple instances", vec![