fix(compose, gpu): round dithered gradient fills to the document depth (#2755) (#2781)

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:
Shehan Kavishka 2026-10-11 02:41:46 +05:30 committed by GitHub
parent ede3da019d
commit 96ec16ed7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 102 additions and 15 deletions

View File

@ -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);
}
}
};

View File

@ -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) },

View File

@ -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);

View File

@ -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;
}

View File

@ -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 {

View File

@ -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;