Gradient dithering (reduces 8-bit banding), wired in the UI

paint_gradient gains a dither flag: one quantisation step of monochromatic
position-hash noise, breaking banding without colour speckle. paint.gradient
takes "dither":bool (default true, like Photoshop); the options-bar Dither
checkbox now persists (gradient_dither) and is sent. New gradient_dither_
breaks_banding test; clippy + wasm green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 18:26:30 -04:00
parent c89c33c704
commit 5f84a7add8
5 changed files with 44 additions and 7 deletions

View File

@ -111,6 +111,16 @@ fn composite_area(
}
/// Gradient tool over `area` (the selection bounds or canvas).
#[allow(clippy::too_many_arguments)]
/// Per-pixel dither noise in [0, 1) from a position hash (decorrelated, no visible pattern).
fn dither_noise(x: i32, y: i32) -> f32 {
let mut h = (x as u32).wrapping_mul(0x9E37_79B1) ^ (y as u32).wrapping_mul(0x85EB_CA77);
h ^= h >> 15;
h = h.wrapping_mul(0x2C1B_3C6D);
h ^= h >> 12;
f32::from((h & 0xFFFF) as u16) / 65535.0
}
#[allow(clippy::too_many_arguments)]
pub fn paint_gradient(
s: &mut Surface,
@ -122,6 +132,7 @@ pub fn paint_gradient(
reverse: bool,
opacity: f32,
blend_mode: BlendMode,
dither: bool,
selection: Option<&Surface>,
) {
composite_area(
@ -131,7 +142,15 @@ pub fn paint_gradient(
|x, y| opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)),
|x, y| {
let t = tool_gradient_t(shape, from, to, x as f32 + 0.5, y as f32 + 0.5);
sample_stops(stops, if reverse { 1.0 - t } else { t })
let mut c = sample_stops(stops, if reverse { 1.0 - t } else { t });
if dither {
// One quantisation step of monochromatic noise breaks 8-bit banding without speckle.
let n = (dither_noise(x, y) - 0.5) / 255.0;
for ch in c.iter_mut().take(3) {
*ch = (*ch + n).clamp(0.0, 1.0);
}
}
c
},
false,
);
@ -202,7 +221,7 @@ mod tests {
let a = Rect::new(0, 0, 11, 2);
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(Rect::new(0, 0, 11, 1), &[1.0]);
paint_gradient(&mut s, a, (0.5, 0.0), (10.5, 0.0), GradientShape::Linear, &[(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], false, 1.0, BlendMode::Normal, Some(&sel));
paint_gradient(&mut s, a, (0.5, 0.0), (10.5, 0.0), GradientShape::Linear, &[(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], false, 1.0, BlendMode::Normal, false, Some(&sel));
assert!(s.pixel(0, 0)[0] < 0.01 && s.pixel(10, 0)[0] > 0.99);
assert!((s.pixel(5, 0)[0] - 0.5).abs() < 0.01);
assert_eq!(s.pixel(5, 1)[3], 0.0, "outside the selection");

View File

@ -94,11 +94,12 @@ fn gradient(s: &mut Session, p: &Value) -> Result<Value> {
let reverse = b(p, "reverse", false);
let opacity = f(p, "opacity", 100.0) / 100.0;
let blend = p.get("mode").and_then(Value::as_str).and_then(blend_from_str).unwrap_or(photocraft_color::BlendMode::Normal);
let dither = b(p, "dither", true); // Photoshop dithers gradients by default (reduces banding).
s.edit("Gradient", |doc, active| {
let sel = doc.selection.clone();
let area = sel.as_ref().map(|m| m.content_bounds()).filter(|r| !r.is_empty()).unwrap_or_else(|| doc.bounds()).intersect(&doc.bounds());
let (surf, _) = crate::channel_cmds::target_surface(doc, *active, p)?;
paint_gradient(surf, area, from, to, shape, &stops, reverse, opacity, blend, sel.as_ref());
paint_gradient(surf, area, from, to, shape, &stops, reverse, opacity, blend, dither, sel.as_ref());
Ok(())
})?;
Ok(Value::Null)
@ -122,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Gradient",
menu: &[],
shortcut: None,
params: r##"{"from":[x,y],"to":[x,y],"style":"linear|radial|angle|reflected|diamond"="linear","colors":["#rrggbb",…]? (evenly spaced),"gradient":preset name?,"stops":[[t,"#rrggbb"|"foreground"|"background"],…]?,"transparency":[[t,0..100],…]? (default: the current gradient, see gradient.presets.select),"reverse":bool=false,"opacity":1..100=100,"mode":"normal|multiply|…"="normal","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##,
params: r##"{"from":[x,y],"to":[x,y],"style":"linear|radial|angle|reflected|diamond"="linear","colors":["#rrggbb",…]? (evenly spaced),"gradient":preset name?,"stops":[[t,"#rrggbb"|"foreground"|"background"],…]?,"transparency":[[t,0..100],…]? (default: the current gradient, see gradient.presets.select),"reverse":bool=false,"dither":bool=true,"opacity":1..100=100,"mode":"normal|multiply|…"="normal","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##,
enabled: crate::commands::has_paintable,
run: gradient,
journal: true,
@ -191,4 +192,19 @@ mod tests {
assert!(px(&s, 10, 5)[1] > 0.4, "outside the selection untouched");
assert!(s.execute("paint.gradient", json!({"from": [0, 0]})).is_err());
}
#[test]
fn gradient_dither_breaks_banding() {
// A flat gradient (same colour both ends) is perfectly smooth with dither off, and gains
// per-pixel noise with dither on (which breaks 8-bit banding).
let run = |dither: bool| {
let mut s = session();
s.execute("paint.gradient", json!({"from": [0, 0], "to": [19, 0], "colors": [[0.5, 0.5, 0.5, 1.0], [0.5, 0.5, 0.5, 1.0]], "dither": dither})).unwrap();
(0..19).map(|x| px(&s, x, 5)[0]).collect::<Vec<f32>>()
};
let smooth = run(false);
assert!(smooth.iter().all(|&v| v == smooth[0]), "no dither: perfectly flat");
let noisy = run(true);
assert!(noisy.windows(2).any(|w| w[0] != w[1]), "dither: per-pixel variation");
}
}

View File

@ -1375,7 +1375,7 @@ fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
let bg = app.session.tools.background;
let _ = app.run(
"paint.gradient",
json!({"from": [d.start[0], d.start[1]], "to": [end[0], end[1]], "style": o.gradient_style, "reverse": o.gradient_reverse, "colors": [hex(fg), hex(bg)], "opacity": o.fill_opacity, "target": paint_target(app)}),
json!({"from": [d.start[0], d.start[1]], "to": [end[0], end[1]], "style": o.gradient_style, "reverse": o.gradient_reverse, "dither": o.gradient_dither, "colors": [hex(fg), hex(bg)], "opacity": o.fill_opacity, "target": paint_target(app)}),
);
}
}

View File

@ -453,8 +453,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
opt_label(ui, "Opacity");
widgets::value_field(ui, &mut app.ui.tool_options.fill_opacity, 1.0..=100.0, "%", 62.0);
widgets::checkbox(ui, &mut app.ui.tool_options.gradient_reverse, "Reverse");
let mut dither = true;
widgets::checkbox(ui, &mut dither, "Dither");
widgets::checkbox(ui, &mut app.ui.tool_options.gradient_dither, "Dither");
}
Tool::Crop if t.pro => {
let o = &mut app.ui.tool_options;

View File

@ -275,6 +275,8 @@ pub struct ToolOptions {
/// linear | radial | angle | reflected | diamond
pub gradient_style: String,
pub gradient_reverse: bool,
/// Gradient tool: dither to reduce 8-bit banding (Photoshop default on).
pub gradient_dither: bool,
pub fill_opacity: f32,
/// Paint Bucket fill source: false = Foreground colour, true = Pattern (Patterns panel selection).
pub bucket_fill_pattern: bool,
@ -360,6 +362,7 @@ impl Default for ToolOptions {
feather: 0.0,
gradient_style: "linear".into(),
gradient_reverse: false,
gradient_dither: true,
fill_opacity: 100.0,
bucket_fill_pattern: false,
type_font: "Inter".into(),