A live gradient's dither adds a fraction of a level of noise, but gradient fill layers kept it in float. Color Dodge divides by 1 - cs, so under a bright dodge layer that sub-level noise came out as speckle several levels high, and channels at 0 were lifted off black (coloured fringes). Dithered gradient pixels are now rounded to the document depth's levels (8/16-bit; float documents unchanged), as a painted gradient stores them. Same rounding in the GPU shader. Regression tests in compose and a GPU parity case for stacked dithered gradients under Color Dodge. Claude-Session: https://claude.ai/code/session_01Qpa5z8GksXAFiDavFkmh4R Co-authored-by: Leel Wasanatha <260480244+kavishkashehan726-sudo@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ede3da019d
commit
96ec16ed7d
|
|
@ -103,15 +103,32 @@ pub fn dither(c: &mut [f32; 4], x: i32, y: i32) {
|
|||
}
|
||||
|
||||
fn dither_channels(c: &mut [f32], x: i32, y: i32) {
|
||||
dither_channels_to(c, x, y, None);
|
||||
}
|
||||
|
||||
/// [`dither_channels`], then rounded to `quantum` steps when given. The noise is a fraction of a
|
||||
/// level that only means something once the value lands on a level, as it does in a painted
|
||||
/// gradient: left in float, a dark channel lifted 0.002 above black is still 0.002 to the next
|
||||
/// blend, and Color Dodge under a bright layer turns that into visible speckle (#2755).
|
||||
fn dither_channels_to(c: &mut [f32], x: i32, y: i32, quantum: Option<f32>) {
|
||||
let n = (photocraft_color::dither_noise(x, y) - 0.5) / 255.0;
|
||||
for ch in c {
|
||||
*ch = (*ch + n).clamp(0.0, 1.0);
|
||||
if let Some(q) = quantum {
|
||||
*ch = (*ch * q + 0.5).floor() / q;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a gradient fill over `rect`, laid out in `frame` (row-major RGBA).
|
||||
pub fn render(f: &Fill, rect: Rect, frame: Rect) -> Vec<[f32; 4]> {
|
||||
render_ramp(f, rect, frame, Ramp::new(f))
|
||||
render_quantized(f, rect, frame, None)
|
||||
}
|
||||
|
||||
/// [`render`] with dithered pixels rounded to `quantum` steps (the document depth's levels, see
|
||||
/// [`crate::adjustment_quantum`]), as a gradient painted into a layer of that depth stores them.
|
||||
pub fn render_quantized(f: &Fill, rect: Rect, frame: Rect, quantum: Option<f32>) -> Vec<[f32; 4]> {
|
||||
render_ramp(f, rect, frame, Ramp::new(f), quantum)
|
||||
}
|
||||
|
||||
/// Native CMYK solid/gradient pixels at document depth; patterns return `None`.
|
||||
|
|
@ -125,7 +142,7 @@ pub fn render_cmyk_fill(f: &Fill, rect: Rect, frame: Rect, depth: photocraft_col
|
|||
match f {
|
||||
Fill::Solid(c) => surface.fill_rect(rect, &native(*c)),
|
||||
Fill::Gradient { .. } => {
|
||||
let px = render_ramp(f, rect, frame, Ramp::with_colors(f, native));
|
||||
let px = render_ramp(f, rect, frame, Ramp::with_colors(f, native), None);
|
||||
surface.write_region(rect, px.as_flattened());
|
||||
}
|
||||
Fill::Pattern { .. } => return None,
|
||||
|
|
@ -134,7 +151,7 @@ pub fn render_cmyk_fill(f: &Fill, rect: Rect, frame: Rect, depth: photocraft_col
|
|||
Some(surface)
|
||||
}
|
||||
|
||||
fn render_ramp<const N: usize>(f: &Fill, rect: Rect, frame: Rect, ramp: Option<Ramp<N>>) -> Vec<[f32; N]> {
|
||||
fn render_ramp<const N: usize>(f: &Fill, rect: Rect, frame: Rect, ramp: Option<Ramp<N>>, quantum: Option<f32>) -> Vec<[f32; N]> {
|
||||
let mut px = vec![[0.0f32; N]; rect.width() as usize * rect.height() as usize];
|
||||
let (Some(ramp), Fill::Gradient { angle, scale, style, reverse, offset, dither: dith, .. }) = (ramp, f) else { return px };
|
||||
let w = rect.width() as usize;
|
||||
|
|
@ -149,7 +166,7 @@ fn render_ramp<const N: usize>(f: &Fill, rect: Rect, frame: Rect, ramp: Option<R
|
|||
let t = crate::effects::gradient_t(*style, *angle, *scale, *reverse, *offset, frame, x as f32 + 0.5, y as f32 + 0.5);
|
||||
*p = ramp.sample(t);
|
||||
if *dith {
|
||||
dither_channels(p.get_mut(..N.saturating_sub(1)).unwrap_or_default(), x, y);
|
||||
dither_channels_to(p.get_mut(..N.saturating_sub(1)).unwrap_or_default(), x, y, quantum);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -740,10 +740,10 @@ fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Buffer> {
|
|||
match gradient_fill::render_cmyk_fill(f, native_rect, frame, cx.depth) {
|
||||
Some(s) if matches!(f, Fill::Solid(_)) => Buffer::filled(rect, s.rgba(0, 0)),
|
||||
Some(s) => surface_to_buffer(&s, rect),
|
||||
None => render_fill(f, rect, frame, cx.patterns),
|
||||
None => render_fill(f, rect, frame, cx.patterns, adjustment_quantum(cx.depth)),
|
||||
}
|
||||
}
|
||||
_ => render_fill(f, rect, fill_frame(layer, cx.canvas), cx.patterns),
|
||||
_ => render_fill(f, rect, fill_frame(layer, cx.canvas), cx.patterns, adjustment_quantum(cx.depth)),
|
||||
},
|
||||
LayerContent::Adjustment(_) => return None,
|
||||
_ => match layer.surface() {
|
||||
|
|
@ -823,17 +823,19 @@ pub fn fill_frame(layer: &Layer, canvas: Rect) -> Rect {
|
|||
/// compositor does) but without applying the masks: the pixels a PSD fill layer stores.
|
||||
pub fn render_fill_content(layer: &Layer, f: &Fill, canvas: Rect, patterns: &[Pattern]) -> Buffer {
|
||||
let prepared = pattern::PreparedPatterns::new(patterns, pattern::PREPARED_PATTERN_BYTES);
|
||||
render_fill(f, canvas, fill_frame(layer, canvas), &prepared)
|
||||
render_fill(f, canvas, fill_frame(layer, canvas), &prepared, None)
|
||||
}
|
||||
|
||||
fn render_fill(f: &Fill, rect: Rect, canvas: Rect, patterns: &pattern::PreparedPatterns<'_>) -> Buffer {
|
||||
/// `quantum`: the document depth's levels, which a dithered gradient is rounded to
|
||||
/// ([`gradient_fill::render_quantized`]); `None` keeps it in float.
|
||||
fn render_fill(f: &Fill, rect: Rect, canvas: Rect, patterns: &pattern::PreparedPatterns<'_>, quantum: Option<f32>) -> Buffer {
|
||||
match f {
|
||||
Fill::Solid(c) => {
|
||||
let rgb = c.to_rgb();
|
||||
Buffer::filled(rect, [rgb[0], rgb[1], rgb[2], c.alpha])
|
||||
}
|
||||
// Gradient geometry relative to the layer's frame, independent of the render rect.
|
||||
Fill::Gradient { .. } => Buffer { rect, px: gradient_fill::render(f, rect, canvas) },
|
||||
Fill::Gradient { .. } => Buffer { rect, px: gradient_fill::render_quantized(f, rect, canvas, quantum) },
|
||||
// Laid out from the layer's frame when linked; transparent if the pattern is missing.
|
||||
Fill::Pattern { name, scale, id, angle, link, phase } => match patterns.get(id, name) {
|
||||
Some(tile) => Buffer { rect, px: pattern::render(&tile, &pattern::Placement::new(canvas, *link, *phase, *scale, *angle), rect) },
|
||||
|
|
|
|||
|
|
@ -367,13 +367,60 @@ fn solid_and_gradient_fill_layers() {
|
|||
|
||||
let g = Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, photocraft_doc::GradientStyle::Linear, false);
|
||||
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
|
||||
let buf = render_fill(&g, Rect::new(0, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns);
|
||||
let buf = render_fill(&g, Rect::new(0, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns, None);
|
||||
// tile independence: a 1px render of the right edge equals the full render
|
||||
let one = render_fill(&g, Rect::new(9, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns);
|
||||
let one = render_fill(&g, Rect::new(9, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns, None);
|
||||
assert_eq!(one.px[0], buf.px[9]);
|
||||
assert!(buf.px[0][0] < buf.px[9][0], "left dark, right light");
|
||||
}
|
||||
|
||||
fn dithered_gradient(stops: Vec<(f32, Color)>, angle: f32) -> Fill {
|
||||
let mut f = Fill::gradient(stops, angle, 1.0, photocraft_doc::GradientStyle::Linear, false);
|
||||
if let Fill::Gradient { dither, .. } = &mut f {
|
||||
*dither = true;
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// #2755: a dithered gradient fill under a bright Color Dodge gradient. Dodge divides by
|
||||
/// `1 − cs`, so the dither's fraction of a level, left in float, came out as speckle several
|
||||
/// levels high (a black channel lifted to 4/255: coloured fringes). On an 8-bit document the
|
||||
/// dithered pixels sit on levels, as a painted gradient's do, so a channel at 0 stays 0.
|
||||
#[test]
|
||||
fn dithered_gradient_fills_under_color_dodge_keep_black_channels() {
|
||||
let mut d = doc_white(128, 32);
|
||||
// Red to blue: green is 0 everywhere.
|
||||
let bottom = dithered_gradient(vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], 0.0);
|
||||
d.layers.push(Layer::new("bottom", LayerContent::Fill(bottom)));
|
||||
let top = dithered_gradient(vec![(0.0, Color::rgb(0.88, 0.88, 0.88)), (1.0, Color::rgb(0.97, 0.97, 0.97))], 90.0);
|
||||
let mut l = Layer::new("top", LayerContent::Fill(top));
|
||||
l.blend = BlendMode::ColorDodge;
|
||||
d.layers.push(l);
|
||||
for (i, p) in flatten(&d).px.iter().enumerate() {
|
||||
assert_eq!(p[1], 0.0, "pixel {i}: green lifted off black to {} levels", p[1] * 255.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dithered gradient pixels land on the depth's levels; float documents and undithered
|
||||
/// gradients keep their float values.
|
||||
#[test]
|
||||
fn dithered_gradient_fill_is_rounded_to_the_document_depth() {
|
||||
let stops = vec![(0.0, Color::rgb(0.1, 0.4, 0.0)), (1.0, Color::rgb(0.9, 0.2, 1.0))];
|
||||
let rect = Rect::new(0, 0, 64, 8);
|
||||
let f = dithered_gradient(stops.clone(), 0.0);
|
||||
for depth in [SampleType::U8, SampleType::U16] {
|
||||
let q = adjustment_quantum(depth).unwrap();
|
||||
for p in gradient_fill::render_quantized(&f, rect, rect, Some(q)) {
|
||||
for v in &p[..3] {
|
||||
assert_eq!((v * q).round() / q, *v, "{depth:?}: {v} is between levels");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(gradient_fill::render_quantized(&f, rect, rect, None), gradient_fill::render(&f, rect, rect));
|
||||
let plain = Fill::gradient(stops, 0.0, 1.0, photocraft_doc::GradientStyle::Linear, false);
|
||||
assert_eq!(gradient_fill::render_quantized(&plain, rect, rect, Some(255.0)), gradient_fill::render(&plain, rect, rect));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dissolve_coverage_matches_opacity() {
|
||||
let mut d = Document::new("d", Size::new(64, 64), ColorMode::Rgb, SampleType::U8);
|
||||
|
|
|
|||
|
|
@ -747,9 +747,11 @@ fn layer_texel(d: vec2<i32>) -> vec4<f32> {
|
|||
if ((op.flags & F_GRADIENT) != 0u) {
|
||||
let t = gradient_t(d);
|
||||
var c = vec4(lut(0, t), lut(1, t), lut(2, t), lut(3, t));
|
||||
// p2.w: dither (gradient fills).
|
||||
// p2.w: dither (gradient fills), rounded to p2.z levels on integer documents.
|
||||
if (op.p2.w > 0.5) {
|
||||
c = vec4(clamp(c.rgb + (dither_noise(d) - 0.5) / 255.0, vec3(0.0), vec3(1.0)), c.a);
|
||||
var rgb = clamp(c.rgb + (dither_noise(d) - 0.5) / 255.0, vec3(0.0), vec3(1.0));
|
||||
if (op.p2.z > 0.0) { rgb = floor(rgb * op.p2.z + 0.5) / op.p2.z; }
|
||||
c = vec4(rgb, c.a);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -846,8 +846,11 @@ impl<'a> Planner<'a> {
|
|||
p.params[0] = [angle, scale, if *reverse { 1.0 } else { 0.0 }, style_index(*style)];
|
||||
let c = frame;
|
||||
p.params[1] = [c.x0 as f32, c.y0 as f32, c.width() as f32, c.height() as f32];
|
||||
// p2.xy: centre offset; p2.w: dither (the shared position hash, see the shader).
|
||||
p.params[2] = [offset.0, offset.1, 0.0, if *dither { 1.0 } else { 0.0 }];
|
||||
// p2.xy: centre offset; p2.z: the depth's levels a dithered pixel is rounded to
|
||||
// (0: float, compose::gradient_fill::render_quantized); p2.w: dither (the shared
|
||||
// position hash, see the shader).
|
||||
let quantum = photocraft_compose::adjustment_quantum(self.cx.depth).unwrap_or(0.0);
|
||||
p.params[2] = [offset.0, offset.1, quantum, if *dither { 1.0 } else { 0.0 }];
|
||||
let ramp = photocraft_compose::gradient_fill::Ramp::new(f);
|
||||
let mut rows = vec![[0.0f32; 4096]; 4];
|
||||
for k in 0..4096 {
|
||||
|
|
|
|||
|
|
@ -584,6 +584,22 @@ fn fills_and_dissolve() {
|
|||
d.layers.push(clip);
|
||||
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
|
||||
check(&mut g, &d, "opaque gradient over everything");
|
||||
// Stacked dithered gradients, the top one in Color Dodge (#2755): the dither is rounded to
|
||||
// the document's levels on both sides, so the dodge doesn't blow it up into speckle.
|
||||
let mut d = base_doc(64, 48);
|
||||
for (name, stops, angle, blend) in [
|
||||
("under", vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], 0.0, BlendMode::Normal),
|
||||
("dodge", vec![(0.0, Color::rgb(0.88, 0.88, 0.88)), (1.0, Color::rgb(0.97, 0.97, 0.97))], 90.0, BlendMode::ColorDodge),
|
||||
] {
|
||||
let mut f = Fill::gradient(stops, angle, 1.0, GradientStyle::Linear, false);
|
||||
if let Fill::Gradient { dither, .. } = &mut f {
|
||||
*dither = true;
|
||||
}
|
||||
let mut l = Layer::new(name, LayerContent::Fill(f));
|
||||
l.blend = blend;
|
||||
d.layers.push(l);
|
||||
}
|
||||
check(&mut g, &d, "dithered gradients under Color Dodge");
|
||||
let mut d = base_doc(64, 48);
|
||||
let mut l = noise_layer("dis", PixelFormat::RGBA8, Rect::new(0, 0, 64, 48), 41, 0.2);
|
||||
l.blend = BlendMode::Dissolve;
|
||||
|
|
|
|||
Loading…
Reference in New Issue