Compare commits

...
Author SHA1 Message Date
Tanish Manku 6536ce54df 8-bit documents: soft, low-flow brush strokes are dithered instead of banded
Brush phase 6c. A stroke composites in float, then each pixel is rounded to 8 bits, so a soft,
low-flow stroke's gentle gradient collapses into flat bands (the audit counted 35 levels against
119 at 16-bit). The pixels the stroke changes now get the same deterministic per-pixel offset of
under half a level on every channel before quantisation:
- fractional values round up or down in proportion, so bands become fine grain;
- a value already on an 8-bit level rounds back to itself, so exact colours never change, and
  pixels the stroke doesn't touch are left alone;
- the offset depends only on the pixel's position, so the live preview, the commit and a replay
  agree.
16-bit and float targets are unchanged.
2026-10-10 23:29:10 -07:00
2 changed files with 71 additions and 1 deletions

No files matched your search

+45
View File
@@ -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");
}
+26 -1
View File
@@ -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, &region);