diff --git a/crates/compose/src/gradient_fill.rs b/crates/compose/src/gradient_fill.rs index 70728165..0e7fe319 100644 --- a/crates/compose/src/gradient_fill.rs +++ b/crates/compose/src/gradient_fill.rs @@ -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) { 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) -> 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(f: &Fill, rect: Rect, frame: Rect, ramp: Option>) -> Vec<[f32; N]> { +fn render_ramp(f: &Fill, rect: Rect, frame: Rect, ramp: Option>, quantum: Option) -> 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(f: &Fill, rect: Rect, frame: Rect, ramp: Option Option { 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) -> 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) }, diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index b2276ab8..e777ad33 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -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); diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index b85df6fb..59677a16 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -747,9 +747,11 @@ fn layer_texel(d: vec2) -> vec4 { 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; } diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 9a2d0a63..9ba5f13f 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -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 { diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index ee04cd89..742ba926 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -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;