mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-22 23:24:07 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6536ce54df |
No files matched your search
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user