From 5579cf5439856fed93f41a669a3fc9b56d5813cf Mon Sep 17 00:00:00 2001 From: Sir Starch Date: Tue, 20 Oct 2026 22:09:33 +0200 Subject: [PATCH] Compose: Extend Softer outer glows past Size (#7899) --- crates/compose/src/effects.rs | 98 ++++++++++++++++++++++++++++++++++- crates/gpu/src/compose.wgsl | 18 ++++++- crates/gpu/src/fx.rs | 49 ++++++++++++++++++ 3 files changed, 162 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index cbdf7f35a2..1c751225b4 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -19,7 +19,8 @@ //! Shapes come from the layer's alpha (after its mask). A stroke's outer edge, and precise glows / //! chiselled bevels, use an exact Euclidean transform: a 5 × 5 chamfer's level sets are polygons, //! which a wide stroke shows as flat facets (#3646). Inside stroke edges and spread / choke keep the -//! cheaper chamfer. Soft falloffs are two box blurs (a tent). +//! cheaper chamfer. Soft falloffs are two box blurs (a tent); Softer outer glows retain a short +//! fading tail beyond Size. use photocraft_color::blend::BlendMode; use photocraft_doc::Pattern; @@ -62,7 +63,14 @@ pub fn margin(layer: &Layer) -> i32 { for e in layer.effects.items.iter().filter(|e| e.enabled()) { let r = match e { Effect::DropShadow(s) | Effect::InnerShadow(s) => s.distance + s.size, - Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.size, + Effect::OuterGlow(g) => { + if g.technique == GlowTechnique::Softer { + g.size + softer_glow_tail_width(g.size) + } else { + g.size + } + } + Effect::InnerGlow(g) => g.size, Effect::Stroke(s) => s.size, Effect::Satin(s) => satin_distance(s) + s.size, Effect::BevelEmboss(b) => b.size + b.soften, @@ -1192,6 +1200,9 @@ fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -> Map { let (r, bw) = spread_split(g.size, g.spread); let mut m = dilate(&src, r); blur(&mut m, bw); + if !inner { + add_softer_glow_tail(&mut m, &src, g.size, g.range); + } m } }; @@ -1463,6 +1474,39 @@ fn tent_fast(m: &mut Map, w: f32) { m.v = transpose_map(&b, mh, mw); } +/// Width of the added fading tail on a Softer outer glow, capped so large settings stay local. +pub fn softer_glow_tail_width(size: f32) -> f32 { + if size.is_finite() && size > 0.0 { (size * 0.1).clamp(1.0, 6.0) } else { 0.0 } +} + +/// Raw coverage at the Size boundary, scaled through the glow's Range to a subtle one-percent tail. +pub fn softer_glow_tail_amplitude(range: f32) -> f32 { + let range = if range.is_finite() { range.clamp(0.01, 1.0) } else { 1.0 }; + 0.005 * range +} + +fn add_softer_glow_tail(map: &mut Map, shape: &Map, size: f32, range: f32) { + let width = softer_glow_tail_width(size); + if width == 0.0 { + return; + } + let amp = softer_glow_tail_amplitude(range); + let distance = dist_outside(shape); + for (coverage, distance) in map.v.iter_mut().zip(distance) { + let t = (distance + 0.5 - (size - width)) / width; + let tail = if (0.0..=1.0).contains(&t) { + amp * t * t * (3.0 - 2.0 * t) + } else if (1.0..=2.0).contains(&t) { + let u = t - 1.0; + let remaining = 1.0 - u; + amp * remaining * remaining * (1.0 + 2.0 * u) + } else { + 0.0 + }; + *coverage = coverage.max(tail); + } +} + /// Where a bevel's maps paint: inside the shape (with the layer), outside it (onto the /// backdrop), or both (emboss styles: maps 0–1 inside, 2–3 outside). #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -3222,6 +3266,56 @@ mod tests { } } + #[test] + fn softer_outer_glow_keeps_a_fading_tail_past_its_size() { + let mut shape = Map::new(220, 220, 0.0); + for y in 0..shape.h { + for x in 20..100 { + shape.v[y * shape.w + x] = 1.0; + } + } + let glow = Glow { + common: photocraft_doc::FxCommon::new(BlendMode::Screen, 1.0), + paint: FxPaint::Color(photocraft_color::Color::WHITE), + technique: GlowTechnique::Softer, + spread: 0.0, + size: 60.0, + contour: Contour::Linear, + anti_alias: false, + range: 0.5, + jitter: 0.0, + noise: 0.0, + source: GlowSource::Edge, + }; + let m = glow_map(&shape, &glow, false, (0, 0)); + let row = shape.h / 2; + let at = |distance: usize| m.v[row * m.w + 100 + distance]; + assert!(at(50) > 0.024, "the softer shoulder should stay visible: {}", at(50)); + assert!(at(60) > 1.0 / 255.0, "the glow must retain a visible tail at its Size: {}", at(60)); + assert!(at(62) > 0.5 / 255.0, "the glow must extend beyond its Size: {}", at(62)); + assert!(at(66) < 1e-6, "the added tail should fade to zero: {}", at(66)); + } + + #[test] + fn softer_outer_glow_margin_includes_its_tail_support() { + let glow = Glow { + common: photocraft_doc::FxCommon::new(BlendMode::Screen, 1.0), + paint: FxPaint::Color(photocraft_color::Color::WHITE), + technique: GlowTechnique::Softer, + spread: 0.0, + size: 60.0, + contour: Contour::Linear, + anti_alias: false, + range: 0.5, + jitter: 0.0, + noise: 0.0, + source: GlowSource::Edge, + }; + let mut layer = Layer::raster("glow", photocraft_color::PixelFormat::RGBA8); + layer.effects.items.push(Effect::OuterGlow(glow)); + assert_eq!(margin(&layer), 68); + } + #[test] fn offsets_follow_light() { let (dx, dy) = offset(120.0, 10.0); diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 6457b1192c..d23d3673ed 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -1245,6 +1245,18 @@ fn fs_mblur(in: VOut) -> @location(0) vec4 { @fragment fn fs_mglow(in: VOut) -> @location(0) vec4 { let d = textureLoad(tex_a, local(in.pos), 0).r + 0.5; + if (op.p0.w > 0.5) { + let t = (d - op.p0.x) / op.p0.y; + var tail = 0.0; + if (t >= 0.0 && t <= 1.0) { + tail = op.p0.z * t * t * (3.0 - 2.0 * t); + } else if (t > 1.0 && t <= 2.0) { + let u = t - 1.0; + let remaining = 1.0 - u; + tail = op.p0.z * remaining * remaining * (1.0 + 2.0 * u); + } + return mout(tail); + } var m = 0.5 * clamp(1.0 - (d - op.p0.x) / op.p0.y, 0.0, 1.0); if (d <= op.p0.x) { m = 1.0; } return mout(m); @@ -1256,7 +1268,11 @@ fn fs_mglow(in: VOut) -> @location(0) vec4 { fn fs_mfinish(in: VOut) -> @location(0) vec4 { let p = local(in.pos); var v = textureLoad(tex_a, p, 0).r; - if (op.p0.x > 0.5) { v = abs(v - textureLoad(tex_b, p, 0).r); } + if (op.p0.x > 1.5) { + v = max(v, textureLoad(tex_b, p, 0).r); + } else if (op.p0.x > 0.5) { + v = abs(v - textureLoad(tex_b, p, 0).r); + } if (op.p0.y > 0.5 && op.p0.y < 1.5) { v = 1.0 - v; } if (op.p0.z > 0.5) { v = lut(0, v); } if (op.p0.y > 1.5) { v = 1.0 - v; } diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 994eb5e149..d5e473be83 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -225,6 +225,24 @@ impl B { let Some(k) = blur_kernel(size) else { return src }; self.conv(src, k) } + fn max(&mut self, a: In, b: In) -> In { + self.push(stage(Kernel::MFinish, Some(a), Some(b), [2.0, 0.0, 0.0, 0.0], 0)) + } + fn softer_glow_tail(&mut self, blur: In, size: f32, range: f32) -> In { + let width = photocraft_compose::effects::softer_glow_tail_width(size); + if width == 0.0 { + return blur; + } + let distance = self.field(FieldKind::Outside, size + width); + let tail = self.push(stage( + Kernel::MGlow, + Some(distance), + None, + [size - width, width, photocraft_compose::effects::softer_glow_tail_amplitude(range), 1.0], + 0, + )); + self.max(blur, tail) + } /// Separable convolution with a symmetric kernel `(radius, weights)` (two `MBlur` passes). fn conv(&mut self, src: In, (r, w): (i32, Vec)) -> In { if r <= 0 { @@ -351,6 +369,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, m = b.dilate(src, d, r); } let m = b.blur(m, bw); + let m = if inner { m } else { b.softer_glow_tail(m, g.size, g.range) }; b.finish_glow(m, photocraft_compose::effects::glow_lut(g), inner, center); } } @@ -759,6 +778,36 @@ mod tests { assert_ne!(a.key, program(&Effect::DropShadow(s), &light, false).key); } + #[test] + fn softer_outer_glow_program_keeps_the_tent_and_adds_a_distance_tail() { + let effect = Effect::OuterGlow(photocraft_doc::Glow { + common: FxCommon::new(BlendMode::Screen, 1.0), + paint: photocraft_doc::FxPaint::Color(photocraft_color::Color::WHITE), + technique: GlowTechnique::Softer, + spread: 0.0, + size: 60.0, + contour: Contour::Linear, + anti_alias: false, + range: 0.5, + jitter: 0.0, + noise: 0.0, + source: GlowSource::Edge, + }); + let kernel = blur_kernel(60.0).unwrap(); + let p = program(&effect, &GlobalLight::default(), false); + let blur: Vec<_> = p.stages.iter().filter(|s| s.kernel == Kernel::MBlur).collect(); + assert_eq!(blur.len(), 2); + for stage in blur { + assert_eq!(stage.radius, kernel.0); + assert_eq!(stage.lut.as_deref().unwrap(), kernel.1.as_slice()); + } + let tail = p.stages.iter().find(|s| s.kernel == Kernel::MGlow).unwrap(); + assert_eq!(tail.a, Some(In::Field(FieldKind::Outside))); + assert_eq!(tail.p0, [54.0, 6.0, 0.0025, 1.0]); + assert!(p.stages.iter().any(|s| s.kernel == Kernel::MFinish && s.p0[0] == 2.0)); + assert!(p.fields.contains(&(FieldKind::Outside, reach_for(66.0)))); + } + /// A styled group's effect shape follows its own vector mask and its children's Blend If and /// Advanced Blending, so the cached shape must be rebuilt when any of them changes. #[test]