Files
vectorcraft/crates/effects/src/pixel/artistic.rs
T
2026-10-22 01:15:09 +07:00

877 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Effect › Artistic (Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Plastic Wrap, Poster
//! Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor). Original filters built from
//! public behaviour documentation:
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
//! <https://helpx.adobe.com/photoshop-elements/using/artistic-filters.html>.
//! These are visual approximations, not reproductions of a proprietary algorithm.
use std::f64::consts::TAU;
use super::brushstrokes::{LEFT, RIGHT, Smear, TONE_SOFTNESS, mix, streak, stroke_reach, unit, unpremultiply};
use super::edges::sobel;
use super::pixelate::{Around, Place, Soft, cell, point_in, rand, value_noise};
use super::texture::{self, Light, Texture, Tones, luma, premultiply, smooth_noise, straight, texturizer};
use super::{PixelSpace, blur_plane, pick};
use vectorcraft_geom::{Point, Vec2};
const SALT_DRY: u64 = 264;
const MARGIN: f64 = 1.0;
const SALT_PLASTIC: u64 = 512;
const SALT_PASTEL: u64 = 200;
const SALT_FILM: u64 = 256;
const SALT_DAUB: u64 = 272;
/// Paint Daubs' brush types, in the Brush Type menu's order: (label, parameter value).
/// Illustrator's list: <https://helpx.adobe.com/illustrator/using/filter-effects.html#paint_daubs>.
pub const PAINT_DAUB_BRUSH_TYPES: [(&str, &str); 6] = [
("Simple", "simple"),
("Light Rough", "lightRough"),
("Dark Rough", "darkRough"),
("Wide Sharp", "wideSharp"),
("Wide Blurry", "wideBlurry"),
("Sparkle", "sparkle"),
];
/// A Paint Daubs brush ([`PAINT_DAUB_BRUSH_TYPES`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PaintDaubBrush {
Simple,
LightRough,
DarkRough,
WideSharp,
WideBlurry,
Sparkle,
}
impl PaintDaubBrush {
const ALL: [PaintDaubBrush; 6] = [
PaintDaubBrush::Simple,
PaintDaubBrush::LightRough,
PaintDaubBrush::DarkRough,
PaintDaubBrush::WideSharp,
PaintDaubBrush::WideBlurry,
PaintDaubBrush::Sparkle,
];
/// The brush of Brush Type `value` (a [`PAINT_DAUB_BRUSH_TYPES`] value, any case); Simple otherwise.
pub fn parse(value: &str) -> Self {
if value.eq_ignore_ascii_case("wideRough") {
return PaintDaubBrush::WideSharp;
}
if value.eq_ignore_ascii_case("hue") {
return PaintDaubBrush::WideBlurry;
}
pick(&PAINT_DAUB_BRUSH_TYPES, &Self::ALL, value, PaintDaubBrush::Simple)
}
}
/// How far (document units) a dab reaches past its centre at `size` (1..50).
pub(super) fn paint_daub_radius(size: f64) -> f64 {
size.clamp(1.0, 50.0) * 0.48
}
const SALT_PENCIL: u64 = 304;
const SALT_FRESCO: u64 = 320;
/// Keep the last cell's four corner values: magnified adjacent pixels often share a cell.
/// The cache changes only cost, never the field, and needs constant storage per raster.
#[derive(Default)]
struct CellNoise {
last: Option<(i64, i64, [f64; 4])>,
}
impl CellNoise {
fn at(&mut self, salt: u64, q: Point) -> f64 {
let (Some(i), Some(j)) = (cell(q.x), cell(q.y)) else { return 0.0 };
let corners = match self.last {
Some((x, y, values)) if x == i && y == j => values,
_ => {
let at = |di: i64, dj: i64| 2.0 * rand(salt, i.wrapping_add(di), j.wrapping_add(dj), 0) - 1.0;
let values = [at(0, 0), at(1, 0), at(0, 1), at(1, 1)];
self.last = Some((i, j, values));
values
}
};
let smooth = |t: f64| t * t * (3.0 - 2.0 * t);
let (fx, fy) = (smooth(q.x - i as f64), smooth(q.y - j as f64));
let [a, b, c, d] = corners;
let top = a + (b - a) * fx;
let bottom = c + (d - c) * fx;
// Divide by the root sum of squared interpolation weights so corners and cell
// interiors have the same variance, rather than imprinting a contrast grid.
let norm = ((fx * fx + (1.0 - fx).powi(2)) * (fy * fy + (1.0 - fy).powi(2))).sqrt();
(top + (bottom - top) * fy) / norm
}
}
#[derive(Default)]
struct FilmNoise {
low: CellNoise,
high: CellNoise,
}
impl FilmNoise {
fn at(&mut self, q: Point) -> f32 {
// Fixed independent rotations (0.6 and -0.35 radians) and a second-octave offset
// keep the two lattices from lining up with pixel rows or with each other.
let low = Point::new(0.8253356149096783 * q.x - 0.5646424733950354 * q.y, 0.5646424733950354 * q.x + 0.8253356149096783 * q.y);
let high = Point::new(
2.0 * (0.9393727128473789 * q.x + 0.34289780745545134 * q.y) + 0.37,
2.0 * (-0.34289780745545134 * q.x + 0.9393727128473789 * q.y) - 0.37,
);
// Normalising the blend can exceed the corner range, so bound the final field.
(0.8 * (0.65 * self.low.at(SALT_FILM, low) + 0.35 * self.high.at(SALT_FILM + 1, high))).clamp(-1.0, 1.0) as f32
}
}
/// Bounded footprint sampling during minification, with attenuation for very large pixels.
/// Up to four samples approximate area filtering, not exact integration of every grain.
fn grain_at(place: &Place, field: &mut FilmNoise, x: usize, y: usize) -> f32 {
if place.px <= 0.5 {
return field.at(place.at(x, y));
}
let mut sample = |dx, dy| field.at(place.to_rel * Point::new(x as f64 + dx, y as f64 + dy));
let diagonal = 0.5 * (sample(0.25, 0.25) + sample(0.75, 0.75));
// Blend sampling modes rather than changing the texture's strength suddenly at a zoom.
if place.px < 0.75 {
let t = ((place.px - 0.5) / 0.25) as f32;
return field.at(place.at(x, y)) * (1.0 - t) + diagonal * t;
}
if place.px <= 1.0 {
return diagonal;
}
let other = 0.5 * (sample(0.75, 0.25) + sample(0.25, 0.75));
let t = (0.5 * ((place.px - 1.0) / 0.5).min(1.0)) as f32;
// The four samples already average about a two-point footprint. Only wider pixels need
// extra attenuation; /px alone suppressed coarse grain twice (checked against dense sampling).
(diagonal * (1.0 - t) + other * t) / (place.px / 2.0).max(1.0) as f32
}
/// Film Grain: monochrome grain in shadows and midtones, with brighter highlights as
/// `highlight` (0..20) widens their area. `intensity` (0..10) increases the highlight lift and
/// quiets highlight grain, including when the Highlight Area is zero;
/// `grain` (0..20) sets the noise strength. The smooth grain field lies relative to the object's
/// bounds' centre, following translations and redraws; changing the bounds shifts the pattern.
/// Its scale is in document units; minification uses bounded approximate footprint filtering.
/// Coverage is preserved, including fully transparent pixels. Constant work and no allocations.
pub(super) fn film_grain(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, grain: f64, highlight: f64, intensity: f64) {
if ![grain, highlight, intensity].iter().all(|v| v.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let grain = (grain / 20.0).clamp(0.0, 1.0) as f32;
let highlight = (highlight / 20.0).clamp(0.0, 1.0) as f32;
let intensity = (intensity / 10.0).clamp(0.0, 1.0) as f32;
if grain == 0.0 && (intensity == 0.0 || highlight == 0.0) {
return;
}
let mut field = FilmNoise::default();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let c = straight(*p);
let brightness = luma(c);
// Wider highlight areas include more midtones, without lifting black or growing a halo.
let threshold = 0.85 - 0.6 * highlight;
let t = ((brightness - threshold) / (1.0 - threshold)).clamp(0.0, 1.0);
let light = t * t * (3.0 - 2.0 * t) * highlight * intensity * 0.65;
// Keep the grain strength even across shadows and midtones, then quiet it smoothly
// above mid-grey to a quarter-strength floor at full intensity, before highlight
// headroom. This keeps a softer grain on cream. Black takes light speckles after
// clipping; white stays clean.
// This tonal curve is an interpretation of the public description, not a measured
// proprietary transfer function. Shared offsets stay neutral until a channel clips.
let upper = ((brightness - 0.5) * 2.0).clamp(0.0, 1.0);
let upper = upper * upper * (3.0 - 2.0 * upper);
let amplitude = (0.4 * (1.0 - upper * (0.5 + 0.5 * intensity))).max(0.1).min(1.0 - brightness);
let strength = amplitude * grain * (1.0 - light);
// With no highlight lift the starting premultiplied channels are whole bytes. A
// bounded offset below half a byte cannot change rounding, so avoid hashing it.
// Leave a small margin for straight/premultiplied floating-point roundoff.
if light == 0.0 && strength * f32::from(p[3]) < 0.45 {
return;
}
let noise = if strength > 0.0 { grain_at(&place, &mut field, x, y) * strength } else { 0.0 };
let c = c.map(|v| v + (1.0 - v) * light + noise);
*p = premultiply(c, f32::from(p[3]) / 255.0);
});
}
/// The coverage-weighted Gaussian softening of the planes `planes` (premultiplied by `alpha`) by
/// `sigma` pixels: each divided by the softened coverage, so transparency doesn't darken edges.
fn soften(planes: &mut [Vec<f32>], alpha: &[f32], w: usize, h: usize, sigma: f64) {
let mut a = alpha.to_vec();
blur_plane(&mut a, w, h, sigma);
for plane in planes {
for (v, c) in plane.iter_mut().zip(alpha) {
*v *= c;
}
blur_plane(plane, w, h, sigma);
for (v, c) in plane.iter_mut().zip(&a) {
*v = if *c > 1e-4 { (*v / c).clamp(0.0, 1.0) } else { 0.0 };
}
}
}
/// The pixels' straight colour channels and their coverage (0..1), as planes.
fn planes(px: &[[u8; 4]]) -> ([Vec<f32>; 3], Vec<f32>) {
let colours = px.iter().map(|p| straight(*p)).collect::<Vec<_>>();
(std::array::from_fn(|k| colours.iter().map(|c| c[k]).collect()), px.iter().map(|p| f32::from(p[3]) / 255.0).collect())
}
/// How far (document units) Colored Pencil reaches at pencil width `width`: soften at
/// `0.12 * width` and the Sobel neighbourhood around it.
pub(super) fn colored_pencil_reach(width: f64) -> f64 {
3.0 * 0.12 * width.clamp(1.0, 24.0) + 1.0
}
/// Colored Pencil: the object redrawn with coloured pencil strokes along local edges, keeping its
/// hues. `width` (1..24) sets the stroke spacing in document units; `pressure` (0..15) darkens
/// along the strokes; `paper` (0..50) lifts lighter areas toward the paper. Coverage is kept.
pub(super) fn colored_pencil(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, width: f64, pressure: f64, paper: f64) {
if !([width, pressure, paper].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let width = width.clamp(1.0, 24.0);
let pressure = (pressure / 15.0).clamp(0.0, 1.0) as f32;
let paper_lift = (paper / 50.0).clamp(0.0, 1.0) as f32;
if pressure == 0.0 && paper_lift == 0.0 {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let (mut colour, alpha) = planes(px);
soften(&mut colour, &alpha, w, h, (0.12 * width) / space.px);
let tones: Vec<f32> = (0..px.len()).map(|k| luma([colour[0][k], colour[1][k], colour[2][k]])).collect();
let mut edges = vec![0.0f32; px.len()];
for (e, s) in edges.iter_mut().zip(sobel(&tones, w, h)) {
*e = (s / 4.0).min(1.0);
}
let spacing = (0.35 * width).max(1.0);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let k = y * w + x;
let c = [colour[0][k], colour[1][k], colour[2][k]];
let gx = tone_at(&tones, w, h, x as isize + 1, y as isize) - tone_at(&tones, w, h, x as isize - 1, y as isize);
let gy = tone_at(&tones, w, h, x as isize, y as isize + 1) - tone_at(&tones, w, h, x as isize, y as isize - 1);
let angle = gy.atan2(gx) + std::f32::consts::FRAC_PI_2;
let (dx, dy) = (angle.cos() as f64, angle.sin() as f64);
let q = place.at(x, y);
let along = q.x * dx + q.y * dy;
let grain = value_noise(SALT_PENCIL, along / spacing, (q.x * 0.37 + q.y * 0.11) / spacing);
let stroke = (0.5 + 0.5 * (along / spacing * TAU + grain * 3.0).sin()) as f32;
let ink = pressure * (0.3 + 0.55 * stroke) * (0.55 + 0.45 * edges[k]);
let lift = paper_lift * (1.0 - luma(c)) * 0.35;
let c = c.map(|v| (v * (1.0 - ink * 0.45) + lift * (1.0 - v)).clamp(0.0, 1.0));
*p = premultiply(c, f32::from(p[3]) / 255.0);
});
}
/// Cutout: the object as flat pieces of coloured paper. Its colours, softened as `simplicity`
/// (0..10) asks and less as `fidelity` (1..3) keeps detail, fall into `levels` (2..8) steps per
/// colour channel; each step's piece is painted in the average colour of what falls into it, so
/// colours keep their hue. Slivers (the blends along softened edges) take the nearest large
/// piece's colour, so the pieces meet with crisp edges. Coverage is kept.
pub(super) fn cutout(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, levels: f64, simplicity: f64, fidelity: f64) {
if !([levels, simplicity, fidelity].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let levels = levels.round().clamp(2.0, 8.0) as usize;
// Simpler edges come from softer colours; more fidelity softens them less.
let sigma = 0.6 * simplicity.clamp(0.0, 10.0) * (4.0 - fidelity.clamp(1.0, 3.0)) / 3.0 / space.px;
let (mut colour, alpha) = planes(px);
soften(&mut colour, &alpha, w, h, sigma);
let at = |k: usize| [0, 1, 2].map(|i| colour[i].get(k).copied().unwrap_or(0.0));
let piece = |c: [f32; 3]| c.iter().fold(0, |n, v| n * levels + ((v * levels as f32) as usize).min(levels - 1));
// Each piece's colour: the coverage-weighted mean of the softened colours in it.
let mut sums = vec![[0.0f64; 4]; levels * levels * levels];
for (k, a) in alpha.iter().enumerate() {
let c = at(k);
let Some(sum) = sums.get_mut(piece(c)) else { continue };
for (s, v) in sum.iter_mut().zip(c) {
*s += f64::from(v * a);
}
sum[3] += f64::from(*a);
}
let mean = |sum: &[f64; 4]| [0, 1, 2].map(|i| (sum[i] / sum[3]) as f32);
let total: f64 = sums.iter().map(|s| s[3]).sum();
let heaviest = sums.iter().map(|s| s[3]).fold(0.0, f64::max);
// Pieces of at least 1% of the object (and always the largest one) are kept.
let large: Vec<[f32; 3]> = sums.iter().filter(|s| s[3] > 0.0 && (s[3] >= 0.01 * total || s[3] >= heaviest)).map(mean).collect();
let paint: Vec<Option<[f32; 3]>> = sums
.iter()
.map(|s| {
let c = (s[3] > 0.0).then(|| mean(s))?;
let far = |o: &[f32; 3]| o.iter().zip(c).map(|(a, b)| (a - b) * (a - b)).sum::<f32>();
large.iter().min_by(|a, b| far(a).total_cmp(&far(b))).copied()
})
.collect();
for (k, p) in px.iter_mut().enumerate() {
if p[3] == 0 {
continue;
}
if let Some(Some(c)) = paint.get(piece(at(k))) {
*p = premultiply(*c, f32::from(p[3]) / 255.0);
}
}
}
/// Poster Edges: the object's colours posterized to `posterization` + 2 (2..8) levels per
/// channel, with black lines drawn on the edges of any of its colour channels and of its outline. `thickness` (0..10) widens the lines and
/// `intensity` (0..10) darkens them and lets fainter edges take a line. Coverage is kept.
pub(super) fn poster_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, thickness: f64, intensity: f64, posterization: f64) {
if !([thickness, intensity, posterization].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let steps = (posterization.round().clamp(0.0, 6.0) + 1.0) as f32;
// The edges of each premultiplied channel (so the object's outline too), a step of 1 reading 1.
let mut edges = vec![0.0f32; px.len()];
for k in 0..4 {
let plane: Vec<f32> = px.iter().map(|p| f32::from(p[k]) / 255.0).collect();
for (e, s) in edges.iter_mut().zip(sobel(&plane, w, h)) {
*e = e.max(s / 4.0);
}
}
// Wider lines spread the edges; scale them back up so a spread line stays as dark.
let sigma = (0.3 + 0.25 * thickness.clamp(0.0, 10.0)) / space.px;
blur_plane(&mut edges, w, h, sigma);
let gain = ((0.3 + 0.25 * intensity.clamp(0.0, 10.0)) * (1.0 + 1.5 * sigma.max(0.0))) as f32;
for (p, e) in px.iter_mut().zip(edges) {
if p[3] == 0 {
continue;
}
let t = ((e * gain - 0.25) / 0.5).clamp(0.0, 1.0);
let ink = t * t * (3.0 - 2.0 * t);
let c = straight(*p).map(|v| (v * steps).round() / steps * (1.0 - ink));
*p = premultiply(c, f32::from(p[3]) / 255.0);
}
}
/// Paint Daubs: thick round dabs on a grid `size` (1..50) points apart, each dab painted in the
/// local colour around its centre, with edges as sharp as `sharpness` (0..40) and a profile from
/// `kind`. Coverage is kept.
pub(super) fn paint_daubs(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, sharpness: f64, kind: PaintDaubBrush) {
if !([size, sharpness].iter().all(|v| v.is_finite()) && size > 0.0) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let spacing = size.clamp(1.0, 50.0);
let brush_r = paint_daub_radius(size);
let edge = 1.0 + 2.5 * (sharpness.clamp(0.0, 40.0) / 40.0);
let soft = Soft::new(px, w, (spacing * 0.15) / place.px);
let dab = |i: i64, j: i64| {
let at = point_in(SALT_DAUB, i, j);
let center = Point::new(at.x * spacing, at.y * spacing);
(center, soft.at(place.index(center)))
};
let cover = |q: Point, center: Point, colour: [f32; 4]| -> f32 {
if colour[3] <= 0.0 {
return 0.0;
}
let (dx, dy) = (q.x - center.x, q.y - center.y);
let (dx, dy) = match kind {
PaintDaubBrush::WideSharp | PaintDaubBrush::WideBlurry => (dx * 0.65, dy * 1.35),
_ => (dx, dy),
};
let t = (dx.hypot(dy) / brush_r).clamp(0.0, 1.0);
let edge_k = match kind {
PaintDaubBrush::WideSharp => edge * 1.35,
PaintDaubBrush::WideBlurry => edge * 0.55,
_ => edge,
};
let mut c = (1.0 - t).powf(edge_k) as f32;
let tone = luma(unpremultiply(colour).0);
match kind {
PaintDaubBrush::LightRough => c *= 0.7 + 0.35 * rough_bristle(q, 0),
PaintDaubBrush::DarkRough => c *= 0.55 + 0.45 * (1.0 - rough_bristle(q, 1)),
PaintDaubBrush::Sparkle if rough_bristle(center, 2) > 0.92 && tone > 0.55 => c = c.max(0.85),
PaintDaubBrush::Simple | PaintDaubBrush::WideSharp | PaintDaubBrush::WideBlurry | PaintDaubBrush::Sparkle => {}
}
c
};
let mut around = Around::new();
place.each(px, |x, y, out| {
if out[3] == 0 {
return;
}
let q = place.at(x, y);
let (u, v) = (q.x / spacing, q.y / spacing);
let (Some(i), Some(j)) = (cell(u), cell(v)) else { return };
let mut sum = [0.0f32; 3];
let mut weight = 0.0f32;
for k in 0..9 {
let Some((center, colour)) = around.get((i, j), k, dab) else { continue };
let w = cover(q, center, colour);
if w <= 0.0 {
continue;
}
let c = unpremultiply(colour).0;
for (s, v) in sum.iter_mut().zip(c) {
*s += v * w;
}
weight += w;
}
if weight <= 1e-6 {
return;
}
let c = sum.map(|v| (v / weight).clamp(0.0, 1.0));
*out = premultiply(c, f32::from(out[3]) / 255.0);
});
}
fn rough_bristle(p: Point, k: u64) -> f32 {
let (Some(i), Some(j)) = (cell(p.x * 3.7), cell(p.y * 3.7)) else { return 0.5 };
rand(SALT_DAUB + 3, i, j, k) as f32
}
/// Fresco: coarse brush dabs on a plaster wall. `brush_size` (1..10) sets dab spacing,
/// `brush_detail` (0..10) keeps sharper edges when high, and `texture` (1..3) deepens the
/// relief. Coverage is kept.
pub(super) fn fresco(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, brush_size: f64, brush_detail: f64, texture: f64) {
if !([brush_size, brush_detail, texture].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let brush_size = brush_size.clamp(1.0, 10.0);
let brush_detail = brush_detail.clamp(0.0, 10.0);
let texture = texture.round().clamp(1.0, 3.0);
let side = 2.0 + 1.8 * brush_size;
let sigma = (0.15 * (10.0 - brush_detail) * brush_size / place.px).max(0.0);
let soft = Soft::new(px, w, sigma);
let tex_strength = 0.08 + 0.07 * (texture - 1.0);
let tex_scale = 3.0 / texture;
let streak_mix = 0.25 * (1.0 - brush_detail / 10.0);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let (Some(i), Some(j)) = (cell(q.x / side), cell(q.y / side)) else { return };
let jx = (rand(SALT_FRESCO, i, j, 0) - 0.5) * 0.35 * side;
let jy = (rand(SALT_FRESCO, i, j, 1) - 0.5) * 0.35 * side;
let sample = Point::new(q.x + jx, q.y + jy);
let mut sampled = soft.at(place.index(sample));
if sampled[3] <= 0.0 {
sampled = soft.at(place.index(q));
}
if sampled[3] <= 0.0 {
return;
}
let [r, g, b, alpha] = sampled;
let c = [r, g, b].map(|v| v / alpha);
let angle = TAU * rand(SALT_FRESCO, i, j, 2);
let (fx, fy) = (q.x - i as f64 * side, q.y - j as f64 * side);
let along = fx * angle.cos() + fy * angle.sin();
let streak = (1.0 - streak_mix * (0.5 + 0.5 * (along / side).sin().powi(2))) as f32;
let bump = (1.0 + tex_strength * smooth_noise(SALT_FRESCO + 3, q, tex_scale * side)) as f32;
let lit = c.map(|v| (v * streak * bump).clamp(0.0, 1.0));
*p = premultiply(lit, f32::from(p[3]) / 255.0);
});
}
/// How far (document units) a Fresco dab can reach from a pixel.
pub(super) fn fresco_reach(brush_size: f64, brush_detail: f64) -> f64 {
let brush_size = brush_size.clamp(1.0, 10.0);
let brush_detail = brush_detail.clamp(0.0, 10.0);
let side = 2.0 + 1.8 * brush_size;
side + 0.15 * (10.0 - brush_detail) * brush_size
}
/// How far (document units) Plastic Wrap's softened relief is sampled at `smoothness` (1..15).
pub(super) fn plastic_reach(smoothness: f64) -> f64 {
3.0 * (0.15 + 0.35 * smoothness.clamp(1.0, 15.0))
}
/// Plastic Wrap: the object under shiny plastic. Its tones form a relief; `detail` (0..15) adds
/// finer wrinkles in document space; `smoothness` (1..15) softens the relief; specular highlights
/// as strong as `highlight` (0..20) are screened over it. Coverage is kept.
pub(super) fn plastic_wrap(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, highlight: f64, detail: f64, smoothness: f64) {
if ![highlight, detail, smoothness].iter().all(|v| v.is_finite()) || !space.px.is_finite() || space.px <= 0.0 {
return;
}
let strength = (highlight / 20.0).clamp(0.0, 1.0) as f32;
if strength == 0.0 {
return;
}
let detail_amt = (detail / 15.0).clamp(0.0, 1.0) as f32;
let sigma = (0.15 + 0.35 * smoothness.clamp(1.0, 15.0)) / space.px;
let Some(place) = Place::new(space, w, h) else { return };
let len = px.len();
let mut height = vec![0.0f32; len];
let alpha: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
let cell_scale = 8.0 + 24.0 * (1.0 - detail_amt as f64);
let mut noise = CellNoise::default();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let i = y * w + x;
let l = luma(straight(*p));
let q = place.at(x, y);
let nq = Point::new(q.x / cell_scale, q.y / cell_scale);
height[i] = l + detail_amt * 0.35 * noise.at(SALT_PLASTIC, nq) as f32;
});
for (h, a) in height.iter_mut().zip(&alpha) {
*h *= *a;
}
blur_plane(&mut height, w, h, sigma);
let mut cov = alpha.clone();
blur_plane(&mut cov, w, h, sigma);
for (h, c) in height.iter_mut().zip(&cov) {
*h = if *c > 1e-4 { *h / *c } else { 0.0 };
}
const LIGHT: [f32; 3] = [0.35, -0.25, 0.9];
let inv = (LIGHT[0] * LIGHT[0] + LIGHT[1] * LIGHT[1] + LIGHT[2] * LIGHT[2]).sqrt().max(1e-6);
let light = LIGHT.map(|v| v / inv);
let scale = (8.0 * space.px).max(1e-6) as f32;
let sample = |x: isize, y: isize| -> f32 {
if x < 0 || y < 0 || x >= w as isize || y >= h as isize {
return 0.0;
}
height[y as usize * w + x as usize]
};
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let gx = sample(x as isize + 1, y as isize) - sample(x as isize - 1, y as isize);
let gy = sample(x as isize, y as isize + 1) - sample(x as isize, y as isize - 1);
let nx = -gx / scale;
let ny = -gy / scale;
let nz = 1.0;
let nlen = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-6);
let ndotl = (nx / nlen * light[0] + ny / nlen * light[1] + nz / nlen * light[2]).max(0.0);
let spec = ndotl.powi(12) * strength;
if spec <= 1e-5 {
return;
}
let c = straight(*p).map(|v| 1.0 - (1.0 - v) * (1.0 - spec));
*p = premultiply(c, f32::from(p[3]) / 255.0);
});
}
/// Stroke length (document units) for Dry Brush at Brush Size `size` (1..10).
fn dry_brush_length(size: f64) -> f64 {
2.0 + 2.8 * (size.clamp(1.0, 10.0) - 1.0)
}
/// How much Dry Brush softens before smearing at Brush Detail `detail` (0..10; 10 keeps the most detail).
fn dry_brush_sigma(detail: f64) -> f64 {
0.22 * (10.0 - detail.clamp(0.0, 10.0))
}
/// How far (document units) Dry Brush reads from a pixel.
pub(super) fn dry_brush_reach(size: f64, detail: f64) -> f64 {
stroke_reach(dry_brush_length(size)) + 3.0 * dry_brush_sigma(detail) + MARGIN
}
/// How far (document units) Neon Glow's glow spreads at `size` (-24..24).
pub(super) fn glow_spread(size: f64) -> f64 {
0.25 * size.abs().min(24.0)
}
/// Underpainting: broad brush strokes (`brush_size`, 0..40) over a relief `texture` surface
/// (`scaling`, `relief`, `light`, `invert`, as Texturizer uses), blended in as strongly as
/// `texture_coverage` (0..40) says. Coverage is kept.
#[allow(clippy::too_many_arguments)]
pub(super) fn underpainting(
px: &mut [[u8; 4]],
w: usize,
h: usize,
space: &PixelSpace,
brush_size: f64,
texture_coverage: f64,
texture: &Texture,
scaling: f64,
relief: f64,
light: Light,
invert: bool,
) {
if !(brush_size.is_finite() && texture_coverage.is_finite() && space.px.is_finite() && space.px > 0.0) {
return;
}
let brush_size = brush_size.clamp(0.0, 40.0);
let coverage = (texture_coverage / 40.0).clamp(0.0, 1.0) as f32;
let (mut colour, alpha) = planes(px);
let sigma = (0.1 * brush_size.max(0.25)) / space.px;
if sigma > 0.0 {
soften(&mut colour, &alpha, w, h, sigma);
}
for (k, p) in px.iter_mut().enumerate() {
if p[3] == 0 {
continue;
}
let c = [colour[0][k], colour[1][k], colour[2][k]];
*p = premultiply(c, f32::from(p[3]) / 255.0);
}
if coverage <= 0.0 {
return;
}
let softened = px.to_vec();
texture::texturizer(px, w, h, space, texture, scaling, relief, light, invert);
if coverage >= 1.0 {
return;
}
let mix = 1.0 - coverage;
for (k, p) in px.iter_mut().enumerate() {
if p[3] == 0 {
continue;
}
let a = f32::from(p[3]) / 255.0;
let tex = straight(*p);
let soft = straight(softened[k]);
let c = [
(soft[0] * mix + tex[0] * coverage).clamp(0.0, 1.0),
(soft[1] * mix + tex[1] * coverage).clamp(0.0, 1.0),
(soft[2] * mix + tex[2] * coverage).clamp(0.0, 1.0),
];
*p = premultiply(c, a);
}
}
/// How far (document units) Rough Pastels reaches with strokes `len` long.
pub(super) fn rough_pastels_reach(len: f64) -> f64 {
super::brushstrokes::stroke_reach(len).max(super::brushstrokes::TONE_REACH)
}
/// Rough Pastels: soft diagonal pastel strokes `length` (0..40) long with bristle `detail` (1..20),
/// then the shared Texturizer surface (`texture`, `scaling`, `relief`, `light`, `invert`).
/// Coverage is kept.
#[allow(clippy::too_many_arguments)]
pub(super) fn rough_pastels(
px: &mut [[u8; 4]],
w: usize,
h: usize,
space: &PixelSpace,
length: f64,
detail: f64,
texture: &Texture,
scaling: f64,
relief: f64,
light: Light,
invert: bool,
) {
if !([length, detail, scaling, relief].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let length = length.clamp(0.0, 40.0);
let detail = detail.clamp(1.0, 20.0);
if length > 0.0 {
let Some(place) = Place::new(space, w, h) else { return };
let smear = Smear::new(px, w, h, &place, length.max(1.0));
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
let row = (0.18 * smear.len).max(1.0);
let stroke_mix = (0.35 + 0.0225 * detail).clamp(0.35, 0.8) as f32;
let bristle = (0.015 + 0.0035 * detail) as f32;
let src = px.to_vec();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let pm = mix(smear.stroke(SALT_PASTEL, q, RIGHT, row), smear.stroke(SALT_PASTEL + 1, q, LEFT, row), 0.5);
let orig = unit(src.get(y * w + x).copied().unwrap_or([0; 4]));
let pm = mix(orig, pm, stroke_mix);
let (mut c, a) = unpremultiply(pm);
if a <= 0.0 {
*p = [0; 4];
return;
}
let dir = if tones.get(x, y) > 0.52 { RIGHT } else { LEFT };
let n = bristle * streak(SALT_PASTEL + 2, q, dir, smear.len.max(1.0), row) as f32;
c = c.map(|v| (v + n).clamp(0.0, 1.0));
*p = premultiply(c, orig[3]);
});
}
if relief > 0.0 {
texturizer(px, w, h, space, texture, scaling, relief, light, invert);
}
}
/// Neon Glow: the object in dimmed greys, lit by `colour` glowing from its light areas (`size`
/// above 0) or from its dark areas (below 0), spread as far as |`size`| (0..24) says and as
/// strong as `brightness` (0..50). Coverage is kept.
pub(super) fn neon_glow(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, brightness: f64, colour: [f32; 3]) {
if !([size, brightness].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let strength = (brightness / 50.0).clamp(0.0, 1.0) as f32;
let tones: Vec<f32> = px.iter().map(|p| luma(straight(*p))).collect();
let alpha: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
let mut glow = [tones.iter().map(|l| if size < 0.0 { 1.0 - l } else { *l }).collect::<Vec<_>>()];
soften(&mut glow, &alpha, w, h, glow_spread(size) / space.px);
let [glow] = glow;
for ((p, tone), g) in px.iter_mut().zip(tones).zip(glow) {
if p[3] == 0 {
continue;
}
// The greys dim as the glow brightens, and the glow is screened over them.
let base = tone * (1.0 - 0.6 * strength);
let lit = (3.0 * g * strength).clamp(0.0, 1.0);
let c = colour.map(|v| 1.0 - (1.0 - base) * (1.0 - v.clamp(0.0, 1.0) * lit));
*p = premultiply(c, f32::from(p[3]) / 255.0);
}
}
/// The tone at `(x, y)` in `tone`, `w` × `h`, or 0 outside.
fn tone_at(tone: &[f32], w: usize, h: usize, x: isize, y: isize) -> f32 {
if x < 0 || y < 0 {
return 0.0;
}
let (x, y) = (x as usize, y as usize);
if x >= w || y >= h {
return 0.0;
}
tone[y * w + x]
}
/// A unit vector along the image gradient's tangent at `(x, y)`, or horizontal when flat.
fn dry_brush_dir(tone: &[f32], w: usize, h: usize, x: usize, y: usize) -> Vec2 {
let gx = 0.5 * (tone_at(tone, w, h, x as isize + 1, y as isize) - tone_at(tone, w, h, x as isize - 1, y as isize));
let gy = 0.5 * (tone_at(tone, w, h, x as isize, y as isize + 1) - tone_at(tone, w, h, x as isize, y as isize - 1));
let v = Vec2::new(-f64::from(gy), f64::from(gx));
let len = v.hypot();
if len > 1e-6 { v / len } else { Vec2::new(1.0, 0.0) }
}
/// Premultiplied RGBA (0..1) at document `q`, bilinearly from `src`.
fn sample_pm(src: &[[f32; 4]], w: usize, h: usize, place: &Place, q: Point) -> [f32; 4] {
let p = place.to_px * q;
if !(p.x.is_finite() && p.y.is_finite()) {
return [0.0; 4];
}
let (x, y) = (p.x - 0.5, p.y - 0.5);
let (i, j) = (x.floor() as isize, y.floor() as isize);
let (fx, fy) = (x - i as f64, y - j as f64);
let mut acc = [0.0f32; 4];
for (row, wy) in [(j, 1.0 - fy), (j + 1, fy)] {
let Some(row) = usize::try_from(row).ok().filter(|r| *r < h) else { continue };
for (col, wx) in [(i, 1.0 - fx), (i + 1, fx)] {
let Some(col) = usize::try_from(col).ok().filter(|c| *c < w) else { continue };
let k = (wx * wy) as f32;
if let Some(p) = src.get(row * w + col) {
for (a, v) in acc.iter_mut().zip(p) {
*a += *v * k;
}
}
}
}
acc
}
/// Dry Brush: the object repainted with short strokes along its form, edges darkened. `size`
/// (1..10) sets the stroke length, `detail` (0..10) how much is softened first (10 keeps the most
/// detail), and `texture` (1..3) how strongly edges and bristle grain contrast. Coverage is kept.
pub(super) fn dry_brush(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, detail: f64, texture: f64) {
if !([size, detail, texture].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let len = dry_brush_length(size);
let sigma = dry_brush_sigma(detail);
let texture = texture.clamp(1.0, 3.0);
let edge_gain = (0.25 + 0.2 * (texture - 1.0)) as f32;
let bristle = (0.04 + 0.05 * (texture - 1.0)) as f32;
let (mut colour, alpha) = planes(px);
if sigma > 0.0 {
soften(&mut colour, &alpha, w, h, sigma);
}
let at = |k: usize| [0, 1, 2].map(|i| colour[i].get(k).copied().unwrap_or(0.0));
let tone: Vec<f32> = (0..w * h)
.map(|k| {
let c = at(k);
let a = alpha.get(k).copied().unwrap_or(0.0);
(0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]) * a
})
.collect();
let edges = sobel(&tone, w, h);
let src: Vec<[f32; 4]> = (0..w * h)
.map(|k| {
let c = at(k);
let a = alpha.get(k).copied().unwrap_or(0.0);
[c[0] * a, c[1] * a, c[2] * a, a]
})
.collect();
let Some(place) = Place::new(space, w, h) else { return };
const SAMPLES: usize = 5;
let mut out = px.to_vec();
for (k, p) in out.iter_mut().enumerate() {
if p[3] == 0 {
continue;
}
let (x, y) = (k % w, k / w);
let q = place.at(x, y);
let dir = dry_brush_dir(&tone, w, h, x, y);
let mut acc = [0.0f32; 4];
for i in 0..SAMPLES {
let t = (i as f64 / (SAMPLES - 1) as f64 - 0.5) * len;
let s = sample_pm(&src, w, h, &place, q + dir * t);
for (a, v) in acc.iter_mut().zip(s) {
*a += v;
}
}
for a in &mut acc {
*a /= SAMPLES as f32;
}
let a = acc[3].clamp(0.0, 1.0);
let c = if a > 1e-6 { [0, 1, 2].map(|i| (acc[i] / a).clamp(0.0, 1.0)) } else { straight(*p) };
let edge = edges[k].min(1.0);
let dark = (edge_gain * edge).clamp(0.0, 0.75);
let grain = bristle * (2.0 * value_noise(SALT_DRY, q.x / (5.0 * space.px), q.y / (5.0 * space.px)) - 1.0) as f32;
let c = c.map(|v| (v * (1.0 - dark) + grain).clamp(0.0, 1.0));
*p = premultiply(c, f32::from(p[3]) / 255.0);
}
px.copy_from_slice(&out);
}
const SALT_KNIFE: u64 = 208;
/// How far Palette Knife reaches with strokes `stroke_size` long and Softness `softness` (0..10).
pub(super) fn palette_knife_reach(stroke_size: f64, softness: f64) -> f64 {
stroke_reach(stroke_size) + 3.0 * 0.35 * softness.clamp(0.0, 10.0) + 1.0
}
/// Artistic › Palette Knife: the object repainted with short, angled knife strokes that smear its
/// colours; `stroke_size` (1..50) sets their length, `stroke_detail` (1..3) keeps more of the
/// original where it's higher, and `softness` (0..10) softens the strokes. Coverage is kept.
pub(super) fn palette_knife(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, stroke_size: f64, stroke_detail: f64, softness: f64) {
let Some(place) = Place::new(space, w, h) else { return };
if !(stroke_size.is_finite() && stroke_detail.is_finite() && softness.is_finite()) {
return;
}
let len = stroke_size.clamp(1.0, 50.0);
let keep = ((stroke_detail.clamp(1.0, 3.0) - 1.0) / 2.0) as f32;
let smear = Smear::new(px, w, h, &place, len);
let row = (0.25 * smear.len).max(1.0);
let cell = len * 0.35;
let src = px.to_vec();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let angle = TAU * value_noise(SALT_KNIFE, q.x / cell, q.y / cell);
let dir = Vec2::new(angle.cos(), angle.sin());
let pm = smear.stroke(SALT_KNIFE + 1, q, dir, row);
let orig = unit(src.get(y * w + x).copied().unwrap_or([0; 4]));
let coverage = orig[3];
let (c, _) = unpremultiply(mix(orig, pm, 1.0 - keep));
*p = premultiply(c, coverage);
});
let sigma = 0.35 * softness.clamp(0.0, 10.0) / place.px;
if sigma > 0.0 {
let (mut colour, alpha) = planes(px);
let cover = alpha.clone();
soften(&mut colour, &alpha, w, h, sigma);
for (k, p) in px.iter_mut().enumerate() {
if cover[k] == 0.0 {
continue;
}
let c = [colour[0][k], colour[1][k], colour[2][k]];
*p = premultiply(c, cover[k]);
}
}
}