Files
figcraft/crates/render/src/fx.rs
T
2026-10-15 11:12:25 +00:00

273 lines
10 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.
//! Raster effects on premultiplied RGBA8 pixmaps: blur (3× box ≈ Gaussian), dilate/erode (spread),
//! alpha extraction and tinting.
use tiny_skia::Pixmap;
/// Box radii for three passes approximating a Gaussian of `sigma` (Kovesi).
fn box_sizes(sigma: f64) -> [usize; 3] {
let n = 3.0;
let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt();
let mut wl = w_ideal.floor() as i64;
if wl % 2 == 0 {
wl -= 1;
}
let wu = wl + 2;
let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f64 - 4.0 * n * wl as f64 - 3.0 * n) / (-4.0 * wl as f64 - 4.0);
let m = m_ideal.round() as i64;
let r = |i: i64| -> usize {
let w = if i < m { wl } else { wu };
((w.max(1) - 1) / 2) as usize
};
[r(0), r(1), r(2)]
}
fn box_blur_h(src: &[u8], dst: &mut [u8], w: usize, h: usize, r: usize) {
if r == 0 {
dst.copy_from_slice(src);
return;
}
let div = (2 * r + 1) as u32;
for y in 0..h {
let row = y * w * 4;
let mut acc = [0u32; 4];
// Edge: transparent outside.
for x in 0..=r.min(w.saturating_sub(1)) {
for c in 0..4 {
acc[c] += u32::from(src.get(row + x * 4 + c).copied().unwrap_or(0));
}
}
for x in 0..w {
for c in 0..4 {
if let Some(d) = dst.get_mut(row + x * 4 + c) {
*d = (acc[c] / div) as u8;
}
}
let add = x + r + 1;
if add < w {
for c in 0..4 {
acc[c] += u32::from(src.get(row + add * 4 + c).copied().unwrap_or(0));
}
}
if x >= r {
let sub = x - r;
for c in 0..4 {
acc[c] = acc[c].saturating_sub(u32::from(src.get(row + sub * 4 + c).copied().unwrap_or(0)));
}
}
}
}
}
fn transpose(src: &[u8], dst: &mut [u8], w: usize, h: usize) {
for y in 0..h {
for x in 0..w {
let s = (y * w + x) * 4;
let d = (x * h + y) * 4;
if let (Some(sp), Some(dp)) = (src.get(s..s + 4), dst.get_mut(d..d + 4)) {
dp.copy_from_slice(sp);
}
}
}
}
/// The largest blur standard deviation drawn (pixels): far wider than any layer (at most 16,384
/// pixels a side), so wider blurs look the same (everything averages out to nothing). The box
/// passes cost the same at any radius, so the documented blur radii (up to 10,000 units) draw
/// in full at any zoom.
pub const MAX_SIGMA: f64 = 1_000_000.0;
/// Gaussian-like blur of the whole pixmap with standard deviation `sigma` (pixels).
pub fn blur(p: &mut Pixmap, sigma: f64) {
if !(sigma.is_finite() && sigma > 0.3) {
return;
}
let sigma = sigma.min(MAX_SIGMA);
let (w, h) = (p.width() as usize, p.height() as usize);
let radii = box_sizes(sigma);
let mut a = p.data().to_vec();
let mut b = vec![0u8; a.len()];
for r in radii {
box_blur_h(&a, &mut b, w, h, r);
std::mem::swap(&mut a, &mut b);
}
transpose(&a, &mut b, w, h);
std::mem::swap(&mut a, &mut b);
for r in radii {
box_blur_h(&a, &mut b, h, w, r);
std::mem::swap(&mut a, &mut b);
}
transpose(&a, &mut b, h, w);
p.data_mut().copy_from_slice(&b);
}
/// Grow (`r > 0`) or shrink (`r < 0`) the alpha of a pixmap by `r` pixels (square structuring
/// element, separable max/min). Colour channels follow alpha (tinted afterwards anyway).
pub fn morph(p: &mut Pixmap, r: i32) {
if r == 0 {
return;
}
let grow = r > 0;
let r = r.unsigned_abs().min(400) as usize;
let (w, h) = (p.width() as usize, p.height() as usize);
let alpha: Vec<u8> = p.data().as_chunks::<4>().0.iter().map(|c| c.get(3).copied().unwrap_or(0)).collect();
let pass = |src: &[u8], w: usize, h: usize, horizontal: bool| -> Vec<u8> {
let mut out = vec![0u8; src.len()];
for y in 0..h {
for x in 0..w {
let mut v: u8 = if grow { 0 } else { 255 };
let (lo, hi) = (x.saturating_sub(r), (x + r).min(w.saturating_sub(1)));
for k in lo..=hi {
let idx = if horizontal { y * w + k } else { k * h + y };
let s = src.get(idx).copied().unwrap_or(0);
v = if grow { v.max(s) } else { v.min(s) };
}
// Shrinking treats outside as empty.
if !grow && (x < r || x + r >= w) {
v = 0;
}
let o = if horizontal { y * w + x } else { x * h + y };
if let Some(d) = out.get_mut(o) {
*d = v;
}
}
}
out
};
let a1 = pass(&alpha, w, h, true);
// Vertical pass: treat columns as rows by swapping roles.
let mut a2 = vec![0u8; a1.len()];
for x in 0..w {
for y in 0..h {
let mut v: u8 = if grow { 0 } else { 255 };
let (lo, hi) = (y.saturating_sub(r), (y + r).min(h.saturating_sub(1)));
for k in lo..=hi {
let s = a1.get(k * w + x).copied().unwrap_or(0);
v = if grow { v.max(s) } else { v.min(s) };
}
if !grow && (y < r || y + r >= h) {
v = 0;
}
if let Some(d) = a2.get_mut(y * w + x) {
*d = v;
}
}
}
for (px, a) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().zip(a2) {
px.copy_from_slice(&[a, a, a, a]);
}
}
/// Replace every pixel with `color` (non-premultiplied RGBA 0..1) at the pixel's alpha × color alpha.
pub fn tint(p: &mut Pixmap, color: [f32; 4]) {
let [r, g, b, a] = color;
for px in p.data_mut().as_chunks_mut::<4>().0 {
let alpha = f32::from(px.get(3).copied().unwrap_or(0)) / 255.0 * a;
let q = |c: f32| (c * alpha * 255.0).round().clamp(0.0, 255.0) as u8;
px.copy_from_slice(&[q(r), q(g), q(b), (alpha * 255.0).round().clamp(0.0, 255.0) as u8]);
}
}
/// Invert the alpha (premultiplied white with alpha 1-a): used for inner shadows.
pub fn invert_alpha(p: &mut Pixmap) {
for px in p.data_mut().as_chunks_mut::<4>().0 {
let a = 255 - px.get(3).copied().unwrap_or(0);
px.copy_from_slice(&[a, a, a, a]);
}
}
/// Multiply every pixel of `p` by the alpha of `m` (same size); `invert` uses 1-alpha.
pub fn mask_by(p: &mut Pixmap, m: &Pixmap, invert: bool) {
for (px, mp) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().zip(m.data().chunks_exact(4)) {
let ma = u32::from(mp.get(3).copied().unwrap_or(0));
let ma = if invert { 255 - ma } else { ma };
for c in px.iter_mut() {
*c = ((u32::from(*c) * ma + 127) / 255) as u8;
}
}
}
/// Call `f(dst_row, src_row, row, first_col)` for each row where `src`, with its top-left at `at`
/// on `dst`, overlaps it (premultiplied RGBA bytes of the overlapping columns; `row` and
/// `first_col` in `src`'s pixels). Nothing past either edge is visited.
pub(crate) fn overlap_rows(dst: &mut Pixmap, at: (i32, i32), src: &Pixmap, mut f: impl FnMut(&mut [u8], &[u8], i64, i64)) {
let (dw, dh) = (i64::from(dst.width()), i64::from(dst.height()));
let (sw, sh) = (i64::from(src.width()), i64::from(src.height()));
let (ax, ay) = (i64::from(at.0), i64::from(at.1));
let (c0, c1) = ((-ax).max(0), sw.min(dw - ax));
let (r0, r1) = ((-ay).max(0), sh.min(dh - ay));
if c1 <= c0 || r1 <= r0 {
return;
}
let len = (c1 - c0) as usize * 4;
let s = src.data();
let d = dst.data_mut();
for r in r0..r1 {
let (si, di) = ((r * sw + c0) as usize * 4, ((r + ay) * dw + ax + c0) as usize * 4);
if let (Some(sp), Some(dp)) = (s.get(si..si + len), d.get_mut(di..di + len)) {
f(dp, sp, r, c0);
}
}
}
/// Composite `src` at `opacity` onto `dst` (top-left at `at`) with plus-darker (linear burn:
/// `max(0, backdrop + source - 1)`, through the W3C general compositing formula), which
/// tiny-skia lacks.
pub fn plus_darker(dst: &mut Pixmap, at: (i32, i32), src: &Pixmap, opacity: f32) {
let o = if opacity.is_finite() { opacity.clamp(0.0, 1.0) } else { 0.0 };
overlap_rows(dst, at, src, |dp, sp, _, _| {
for (d, s) in dp.chunks_exact_mut(4).zip(sp.chunks_exact(4)) {
let f = |v: u8| f32::from(v) / 255.0;
let sa = f(s[3]) * o;
if sa <= 0.0 {
continue;
}
let da = f(d[3]);
for c in 0..3 {
let (sc, dc) = (f(s[c]) * o, f(d[c]));
let v = sc * (1.0 - da) + dc * (1.0 - sa) + (sa * dc + da * sc - sa * da).max(0.0);
d[c] = (v.clamp(0.0, 1.0) * 255.0).round() as u8;
}
d[3] = ((sa + da - sa * da).clamp(0.0, 1.0) * 255.0).round() as u8;
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blur_spreads_and_conserves() {
let mut p = Pixmap::new(64, 64).unwrap();
let i = (32 * 64 + 32) * 4;
p.data_mut()[i..i + 4].copy_from_slice(&[255, 255, 255, 255]);
let before: u32 = p.data().iter().map(|v| u32::from(*v)).sum();
blur(&mut p, 3.0);
let after: u32 = p.data().iter().map(|v| u32::from(*v)).sum();
assert!(p.data()[i + 3] < 255);
// Integer rounding loses a little.
assert!(after <= before && after > 0, "{before} {after}");
}
#[test]
fn morph_grows() {
let mut p = Pixmap::new(20, 20).unwrap();
let i = (10 * 20 + 10) * 4;
p.data_mut()[i..i + 4].copy_from_slice(&[255, 255, 255, 255]);
morph(&mut p, 2);
let count = p.data().as_chunks::<4>().0.iter().filter(|c| c[3] == 255).count();
assert_eq!(count, 25);
morph(&mut p, -2);
let count = p.data().as_chunks::<4>().0.iter().filter(|c| c[3] == 255).count();
assert_eq!(count, 1);
}
#[test]
fn box_sizes_sane() {
for s in [0.5, 1.0, 4.0, 50.0] {
let b = box_sizes(s);
assert!(b.iter().all(|r| *r < 1000));
}
}
}