diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index adf0b7a7b0..908b8de1a7 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -41,7 +41,7 @@ mod paint; pub use bevel::{BEVEL_H_EPS, BevelGeom, BevelPaint, TextureCtx, bevel_chisel_h, bevel_geom, satin_distance}; #[cfg(test)] use bevel::{bevel_maps, satin_map}; -pub use blur::tent_kernel; +pub use blur::{tent_kernel, softer_glow_tail_width, softer_glow_tail_amplitude}; #[cfg(test)] use blur::{blur, box_line, dilate, tent, tent_direct}; pub(crate) use composite::{VectorStroke, build_maps_prepared, composite_with_effects_prepared, map_count}; @@ -89,7 +89,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, diff --git a/crates/compose/src/effects/blur.rs b/crates/compose/src/effects/blur.rs index edea7285d2..4185e5be20 100644 --- a/crates/compose/src/effects/blur.rs +++ b/crates/compose/src/effects/blur.rs @@ -210,3 +210,14 @@ fn tent_fast(m: &mut Map, w: f32) { rows_par(&t, &mut b, mh, twice); m.v = transpose_map(&b, mh, mw); } + +/// 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 +} + +/// 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 } +} diff --git a/crates/compose/src/effects/paint.rs b/crates/compose/src/effects/paint.rs index 99b0c6aa0b..78cc1a33f2 100644 --- a/crates/compose/src/effects/paint.rs +++ b/crates/compose/src/effects/paint.rs @@ -1,4 +1,4 @@ -use super::blur::{blur, dilate}; +use super::blur::{blur, dilate, softer_glow_tail_width, softer_glow_tail_amplitude}; use super::distance::{dist_inside, dist_outside}; use super::{ BlendMode, Buffer, Contour, Effect, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Map, Placement, PreparedPatterns, Rect, @@ -303,6 +303,9 @@ pub(super) fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) - 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 } }; @@ -402,3 +405,26 @@ pub(super) fn paint_glow(dst: &mut Buffer, m: &Map, g: &Glow, shape_bounds: Rect let strength = Map { w: m.w, h: m.h, v: m.v.iter().map(|v| (v * gain).min(1.0)).collect() }; paint(dst, &strength, |i| prepared.sample(1.0 - m.v.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0)), g.common.blend, g.common.opacity); } + +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); + } +} + diff --git a/crates/compose/src/effects/tests.rs b/crates/compose/src/effects/tests.rs index 4aea00004f..8c2c71ea3a 100644 --- a/crates/compose/src/effects/tests.rs +++ b/crates/compose/src/effects/tests.rs @@ -858,6 +858,56 @@ fn glows_match_photoshop() { } } +#[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 09dd4cc415..8b7701e3c1 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -1449,6 +1449,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); @@ -1460,7 +1472,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 ceff607686..4e23b7d332 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -226,6 +226,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 { diff --git a/crates/gpu/src/fx/builders.rs b/crates/gpu/src/fx/builders.rs index 39bc9c4fba..ea0d074075 100644 --- a/crates/gpu/src/fx/builders.rs +++ b/crates/gpu/src/fx/builders.rs @@ -55,7 +55,8 @@ impl B { GlowTechnique::Softer => { // Inner glows spread 1 - alpha. let src = if inner { self.shift(In::Shape, 0.0, 0.0, 0.0, true) } else { In::Shape }; - self.spread_blur(src, (g.size, g.spread), inner, 0.0, 0.0) + let m=self.spread_blur(src, (g.size,g.spread),inner,0.0,0.0); + if inner {m} else {self.softer_glow_tail(m,g.size,g.range)} } }; self.finish_glow(m, fx::glow_lut(g), inner, center); diff --git a/crates/gpu/src/fx/tests.rs b/crates/gpu/src/fx/tests.rs index 1622da28e2..1127cdf19e 100644 --- a/crates/gpu/src/fx/tests.rs +++ b/crates/gpu/src/fx/tests.rs @@ -51,6 +51,36 @@ fn programs_key_on_geometry_not_colour() { 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] @@ -308,3 +338,33 @@ fn shape_tiles_and_group_pins_hold_addresses_not_pixels() { drop(group); assert!(pins.iter().all(|w| w.strong_count() == 0), "a group's pins keep none of its pixels"); } + +#[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)))); + }