Fix Drop Shadow Darkness in source color space

This commit is contained in:
dotCooCoo
2026-10-29 14:36:41 +07:00
committed by bflatastic
parent f55cbf313c
commit dfa9f883c0
21 changed files with 587 additions and 118 deletions
+2 -2
View File
@@ -94,11 +94,11 @@ impl Renderer {
let whole = fits && k >= 1.0;
let in_view = (!whole).then(|| ViewCrop::of(f.view, room));
let stamp = self.stamp;
let channels = match f.ink {
let channels = match f.ink.base() {
Ink::Cmy => effects::pixel::Channels::CmyPlane,
Ink::K => effects::pixel::Channels::KPlane,
Ink::Display | Ink::RichBlack if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk,
Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb,
_ => effects::pixel::Channels::Rgb,
};
// De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi).
let ppi = doc_raster_ppi(f.doc);
+1 -11
View File
@@ -11,21 +11,11 @@ pub(super) fn shadow_filter(dx: f64, dy: f64, blur: f64, color: peniko::Color) -
})
}
/// The layer filter of a drop shadow with Darkness: the object's own pixels blurred.
/// The layer filter of a drop shadow with Darkness: source paints are adjusted before blur.
pub(super) fn shade_filter(blur: f64) -> Filter {
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: (blur / 2.0).max(0.0) as f32, edge_mode: EdgeMode::None })
}
/// How much darker (0..1) a drop shadow with Darkness `darkness` makes the values `ink` paints:
/// black added leaves the cyan, magenta and yellow plane of a CMYK document as it is and scales
/// the complemented black plane, as it scales screen colors, by 1 − `darkness`.
pub(super) fn shade(ink: Ink, darkness: f32) -> f32 {
match ink {
Ink::Cmy => 0.0,
Ink::K | Ink::Display | Ink::RichBlack => darkness,
}
}
/// What a shadow or glow raster is made from ([`Made::pixels`]): the silhouette's alpha, or its
/// premultiplied RGBA when the shadow takes the object's own colors (Darkness).
pub(super) fn silhouette(pm: &vello_cpu::Pixmap, colored: bool) -> Vec<u8> {
+54 -15
View File
@@ -2,7 +2,7 @@ use super::bounds::{cull_bounds, effected_path};
use super::entries::{Content, Drawn, FX_RASTER_BUDGET, FxGeom, Inputs, Kept, Made, Reads, Shadow, ShadowEntry};
use super::predicates::{evaluates_live, is_container, rasterize_bounds, reads_outside, shifted};
use super::raster::{
DRAG_PIXEL_BUDGET, MAX_RASTERIZE, PIXEL_BUDGET, ViewCrop, content_changing, doc_raster_ppi, image_size, linear_key, pan_margin, pcolor, shade,
DRAG_PIXEL_BUDGET, MAX_RASTERIZE, PIXEL_BUDGET, ViewCrop, content_changing, doc_raster_ppi, image_size, linear_key, pan_margin, pcolor,
shade_filter, shadow_filter, silhouette,
};
use super::{
@@ -115,7 +115,8 @@ impl Renderer {
let view = Affine::scale(1.0 / scale) * Affine::translate((-x0, -y0)) * f.view;
// Art is culled against the raster's own area: members of a group outside the view cast their shadows in it.
let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h)));
let crop = Frame { mt: false, view, visible, ..*f };
let ink = darkness.map_or(f.ink, |d| f.ink.darkened(d));
let crop = Frame { mt: false, view, visible, ink, ..*f };
let before = self.stats.moved;
self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy)));
// A silhouette drawn over moved rasters makes a moved shadow.
@@ -128,9 +129,8 @@ impl Renderer {
Made { pixels: silhouette(&pm, darkness.is_some()), fx: vec![fx.clone()], size: reach.size(), channels: None, outline: None };
let (wu, hu) = (w as usize, h as usize);
let sigma = blur / 2.0 / f.px / scale;
if let Some(darkness) = darkness {
if darkness.is_some() {
// The object's own colors, blurred, with the black Darkness adds (premultiplied).
let keep = 1.0 - shade(f.ink, darkness);
let mut planes: [Vec<f32>; 4] = [
pm.data().iter().map(|p| p.r as f32).collect(),
pm.data().iter().map(|p| p.g as f32).collect(),
@@ -143,7 +143,7 @@ impl Renderer {
let [r, g, b, a] = &planes;
for (k, px) in pm.data_mut().iter_mut().enumerate() {
let al = (a[k] * opacity).clamp(0.0, 255.0).round();
let value = |plane: &Vec<f32>| (plane[k] * opacity * keep).round().clamp(0.0, al) as u8;
let value = |plane: &Vec<f32>| (plane[k] * opacity).round().clamp(0.0, al) as u8;
*px = vello_cpu::color::PremulRgba8 { r: value(r), g: value(g), b: value(b), a: al as u8 };
}
} else {
@@ -219,15 +219,9 @@ impl Renderer {
self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| {
c.set_transform(fr.view);
c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter));
paint(r, c, &shifted(fr, dx, dy));
// Darkness: black over the object's own colors, keeping their alpha.
if let Some(black) = darkness.map(|d| shade(f.ink, d)).filter(|d| *d > 0.0) {
c.set_transform(fr.view);
c.set_blend_mode(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcAtop));
c.set_paint(peniko::Color::new([0.0, 0.0, 0.0, black]));
c.fill_rect(&reach);
c.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal));
}
let ink = darkness.map_or(fr.ink, |d| fr.ink.darkened(d));
let source = Frame { ink, ..*fr };
paint(r, c, &shifted(&source, dx, dy));
c.pop_layer();
});
}
@@ -278,7 +272,11 @@ impl Renderer {
let view = Affine::scale(1.0 / scale) * Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view;
// Culled against the raster's area, as `shadow_raster` draws the silhouette.
let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h)));
let crop = Frame { mt: false, view, visible, ..*f };
let ink = match fx {
RasterFx::DropShadow { darkness: Some(d), .. } => f.ink.darkened(*d),
_ => f.ink,
};
let crop = Frame { mt: false, view, visible, ink, ..*f };
self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy)));
off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h);
@@ -696,3 +694,44 @@ impl Renderer {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::RenderOptions;
use vectorcraft_color::Color;
use vectorcraft_doc::{Document, color::BlendMode};
#[test]
fn uncached_darkness_shadow_subtracts_before_separate_opacity() {
let doc = Document::new(100.0, 100.0);
let symbols = doc.symbol_boxes().quick();
let opts = RenderOptions::default();
let frame = Frame {
mt: false,
doc: &doc,
symbols: &symbols,
view: Affine::IDENTITY,
visible: Rect::new(0.0, 0.0, 100.0, 100.0),
px: 1.0,
opts: &opts,
ink: Ink::Display,
};
let mut ctx = frame.offscreen_context(100, 100);
let mut r = Renderer::new();
let fx =
RasterFx::DropShadow { mode: BlendMode::Normal, opacity: 0.5, dx: 40.0, dy: 0.0, blur: 0.0, color: Color::BLACK, darkness: Some(0.25) };
let source = Rect::new(10.0, 10.0, 30.0, 30.0);
r.draw_shadow_filter(&mut ctx, &frame, &fx, source, &mut |_, c, f| {
c.set_transform(f.view);
c.set_paint(f.ink.color(&Color::rgb(0.8, 0.4, 0.2), 1.0));
c.fill_rect(&source);
});
ctx.flush();
let mut pm = vello_cpu::Pixmap::new(100, 100);
ctx.render(&mut pm, &mut r.resources);
let px = pm.data()[20 * 100 + 60];
assert!(px.r.abs_diff(70) <= 1 && px.g.abs_diff(19) <= 1 && px.b == 0 && px.a.abs_diff(128) <= 1, "{px:?}");
assert!(r.shadows.is_empty());
}
}
+22 -7
View File
@@ -278,7 +278,7 @@ fn shaded_doc(mode: vectorcraft_doc::ColorMode, fill: Color, shade: &str, darkne
d
}
/// Darkness: the shadow is the object's own color, each channel times 1 − darkness in an RGB
/// Darkness: the shadow is the object's own color, with darkness subtracted from each RGB channel in an RGB
/// document (the reference app's guide gives the object's color at 0%,
/// <https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf> page 428), in the cached
/// raster and, at 6000 %, in the filter layer that a raster too large to cache is drawn with.
@@ -298,9 +298,8 @@ fn drop_shadow_darkness_darkens_the_objects_own_color() {
}
}
/// In a CMYK document Darkness adds black ink and keeps the object's cyan, magenta and yellow
/// (same page of the guide: at 100% an object with colors other than black gets a shadow of those colors under
/// full black, and a black object's shadow is 100% black).
/// Intermediate Darkness adds black ink while preserving C/M/Y. The top endpoint becomes
/// gray black before ink conversion; its output below uses this application's gray-color model.
#[test]
fn drop_shadow_darkness_adds_black_in_a_cmyk_document() {
let cmyk = vectorcraft_doc::ColorMode::Cmyk;
@@ -312,15 +311,31 @@ fn drop_shadow_darkness_adds_black_in_a_cmyk_document() {
let red = Color::cmyk(0.0, 1.0, 1.0, 0.0);
for (darkness, k) in [(0.0, 0), (50.0, 128), (100.0, 255)] {
let got = inks(&shaded_doc(cmyk, red, "darkness", darkness));
assert!(got[0] == 0 && got[1] == 255 && got[2] == 255 && got[3].abs_diff(k) <= 1, "{darkness}%: {got:?}");
let my = if darkness == 100.0 { 0 } else { 255 };
assert!(got[0] == 0 && got[1] == my && got[2] == my && got[3].abs_diff(k) <= 1, "{darkness}%: {got:?}");
}
// Half black ink, darkened by half: K 50 + 50 × 50% = K 75.
// Half black ink plus 50 percentage points clamps at full black.
let got = inks(&shaded_doc(cmyk, Color::cmyk(0.0, 0.0, 0.0, 0.5), "darkness", 50.0));
assert!(got[..3] == [0, 0, 0] && got[3].abs_diff(191) <= 1, "{got:?}");
assert!(got[..3] == [0, 0, 0] && got[3].abs_diff(255) <= 1, "{got:?}");
let got = inks(&shaded_doc(cmyk, Color::cmyk(0.0, 0.0, 0.0, 1.0), "darkness", 100.0));
assert_eq!(got, [0, 0, 0, 255]);
}
/// The top endpoint is gray black before document/profile conversion, independently of source model.
#[test]
fn darkness_black_endpoint_and_gray_source_follow_their_stored_models() {
for mode in [vectorcraft_doc::ColorMode::Rgb, vectorcraft_doc::ColorMode::Cmyk] {
for source in [Color::rgb(0.8, 0.4, 0.2), Color::cmyk(0.1, 0.3, 0.4, 0.2), Color::gray(0.2)] {
let got = render(&shaded_doc(mode, source, "darkness", 100.0)).pixel(66, 66);
let want = render(&shaded_doc(mode, Color::gray(1.0), "darkness", 0.0)).pixel(66, 66);
assert_eq!(got, want, "{mode:?}, {source:?}");
}
let got = render(&shaded_doc(mode, Color::gray(0.2), "darkness", 25.0)).pixel(66, 66);
let want = render(&shaded_doc(mode, Color::gray(0.45), "darkness", 0.0)).pixel(66, 66);
assert_eq!(got, want, "gray intermediate in {mode:?}");
}
}
/// Color stays the default: a shadow without the new keys, and one with Color chosen and a
/// Darkness value, draw the same pixels, which a shadow with Darkness does not.
#[test]