diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 86f76eaee8..829d2360f3 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -1151,3 +1151,48 @@ fn half_coverage_radius_is_the_tips_50_percent_contour() { assert_eq!(half_coverage_radius(10.5, 1.0), 10.5); assert!(half_coverage_radius(10.5, f32::NAN).is_finite()); } + +/// A soft, low-flow stroke (the banding case) on a white `fmt` surface, and its first channel. +fn soft_low_flow(fmt: PixelFormat) -> Surface { + let mut s = Surface::with_default(fmt, &[1.0, 1.0, 1.0, 1.0]); + let b = BrushSettings { size: 120.0, hardness: 0.0, flow: 0.05, opacity: 0.3, color: [0.0, 0.0, 0.0, 1.0], pressure_size: false, ..Default::default() }; + render_stroke(&mut s, &b, &line(0.0, 300.0, 100.0), None, false, 1.0); + s +} + +/// Largest error, in 8-bit levels, of `got`'s row averages against `want`'s (red channel). Along a +/// straight horizontal stroke every pixel of a row has the same true value, so plain rounding +/// gives the whole row one wrong level (a band) while dithering keeps the row's average true. +fn worst_row_error(got: &Surface, want: &Surface) -> f32 { + let mut worst = 0.0f32; + for y in 30..170 { + let (mut g, mut w) = (0.0, 0.0); + for x in 100..200 { + g += got.rgba(x, y)[0]; + w += want.rgba(x, y)[0]; + } + worst = worst.max((g - w).abs() / 100.0 * 255.0); + } + worst +} + +#[test] +fn an_8bit_soft_stroke_is_dithered_instead_of_banded() { + let reference = soft_low_flow(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true)); + let eight = soft_low_flow(PixelFormat::RGBA8); + let err = worst_row_error(&eight, &reference); + assert!(err < 0.15, "row averages are {err} levels off the float render"); +} + +#[test] +fn dithering_never_moves_an_exact_colour_or_an_untouched_pixel() { + let mut s = Surface::with_default(PixelFormat::RGBA8, &[1.0, 1.0, 1.0, 1.0]); + let b = BrushSettings { size: 20.0, hardness: 1.0, color: [0.2, 0.4, 0.6, 1.0], pressure_size: false, ..Default::default() }; + render_stroke(&mut s, &b, &line(10.0, 90.0, 50.0), None, false, 1.0); + let q = |v: f32| (v * 255.0).round() as i32; + for x in 15..85 { + let p = s.rgba(x, 50); + assert_eq!((q(p[0]), q(p[1]), q(p[2])), (q(0.2), q(0.4), q(0.6)), "solid paint at x {x}: {p:?}"); + } + assert_eq!(s.rgba(50, 5), [1.0, 1.0, 1.0, 1.0], "far from the stroke: untouched"); +} diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 9d1d7fa5fa..cdb076856a 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -10,7 +10,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use photocraft_color::{BlendMode, PixelFormat}; +use photocraft_color::{BlendMode, PixelFormat, SampleType}; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; @@ -21,6 +21,22 @@ use crate::rng::hash2; use crate::tile::{Mips, PatternImage}; use crate::{Dab, StrokePoint, dab_coverage, tip_falloff}; +/// Salt of the 8-bit dither's per-pixel noise. +const DITHER_SALT: u64 = 0x0000_6469_7468_6572; + +/// Before a stroke's result is quantised to 8 bits, offset every channel of the pixel by the same +/// deterministic noise of under half a level, so fractional values round up or down in proportion +/// (a soft, low-flow stroke's fine gradient survives as grain instead of bands). A value already on +/// an 8-bit level still rounds back to itself, and because the noise depends only on the pixel's +/// position, the live preview, the commit and a replay all agree. +#[inline] +fn dither8(px: &mut [f32], x: i32, y: i32) { + let d = (hash2(x, y, DITHER_SALT) - 0.5) / 255.0; + for v in px.iter_mut() { + *v += d; + } +} + /// Edge of a stroke-buffer tile. pub const COV_TILE: i32 = 64; /// Computed tips with a smaller radius (in pixels) are rasterised by area, not at pixel centres. @@ -768,6 +784,9 @@ impl StrokeRenderer { if let Some(a) = a_idx { src[a] = b.color[3]; } + // 8-bit targets: the pixels the stroke changes are dithered as they are quantised, so + // soft, low-flow strokes don't band (see `dither8`). + let dither = fmt.sample == SampleType::U8; let mut dmg = Rect::EMPTY; let mut region = Vec::new(); for (tx, ty) in keys { @@ -804,6 +823,9 @@ impl StrokeRenderer { if let (Some(a), false) = (a_idx, lock_transparency) { px[a] *= 1.0 - k; } + if dither { + dither8(px, x, y); + } continue; } if lock_transparency && a_idx.is_some_and(|a| px[a] <= 0.0) { @@ -828,6 +850,9 @@ impl StrokeRenderer { from_rgba_into(&fmt, o, &mut enc); px.copy_from_slice(&enc[..n]); } + if dither { + dither8(px, x, y); + } } } target.write_region(tr, ®ion);