mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 01:57:53 -06:00
183 lines
6.1 KiB
Rust
183 lines
6.1 KiB
Rust
//! Projective warps composited straight into a destination (the layer transform step).
|
|
|
|
use effectcraft_color::{BlendMode, blend_pixel};
|
|
use effectcraft_geom::{Mat3, Rect, vec2};
|
|
use rayon::prelude::*;
|
|
|
|
use crate::{Image, hash_noise};
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
pub enum Sampling {
|
|
Nearest,
|
|
#[default]
|
|
Bilinear,
|
|
Bicubic,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct WarpOpts {
|
|
pub sampling: Sampling,
|
|
pub opacity: f32,
|
|
pub mode: BlendMode,
|
|
pub seed: u32,
|
|
/// Only touch destination pixels inside this rect (dst pixel coords).
|
|
pub clip: Option<Rect>,
|
|
}
|
|
|
|
impl Default for WarpOpts {
|
|
fn default() -> Self {
|
|
WarpOpts { sampling: Sampling::Bilinear, opacity: 1.0, mode: BlendMode::Normal, seed: 0, clip: None }
|
|
}
|
|
}
|
|
|
|
/// Box-filter halving (for minification).
|
|
fn half(src: &Image) -> Image {
|
|
let w = src.width.div_ceil(2).max(1);
|
|
let h = src.height.div_ceil(2).max(1);
|
|
let mut out = Image::new(w, h);
|
|
out.rows_mut().for_each(|(y, row)| {
|
|
for (x, px) in row.iter_mut().enumerate() {
|
|
let (sx, sy) = (2 * x as i64, 2 * y as i64);
|
|
let mut acc = [0.0f32; 4];
|
|
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
|
|
let p = src.get_clamped(sx + dx, sy + dy);
|
|
for c in 0..4 {
|
|
acc[c] += p[c];
|
|
}
|
|
}
|
|
*px = acc.map(|v| v * 0.25);
|
|
}
|
|
});
|
|
out
|
|
}
|
|
|
|
/// Draw `src` transformed by `m` (src pixel space → dst pixel space) onto `dst` with blending.
|
|
pub fn composite_warp(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) {
|
|
if src.is_empty() || dst.is_empty() || opts.opacity <= 0.0 {
|
|
return;
|
|
}
|
|
let Some(inv) = m.inverse() else { return };
|
|
// Minification: pick a pre-filtered level so sampling doesn't alias.
|
|
let scale = m.mean_scale();
|
|
let mut level_img;
|
|
let mut img = src;
|
|
let mut inv = inv;
|
|
if scale < 0.5 && m.is_affine() {
|
|
let mut s = scale;
|
|
let mut f = 1.0;
|
|
level_img = half(src);
|
|
f *= 0.5;
|
|
s *= 2.0;
|
|
while s < 0.5 && level_img.width > 1 && level_img.height > 1 {
|
|
level_img = half(&level_img);
|
|
f *= 0.5;
|
|
s *= 2.0;
|
|
}
|
|
img = &level_img;
|
|
inv = Mat3::scale(vec2(f, f)) * inv;
|
|
}
|
|
let bounds = m.map_rect(&Rect::from_size(src.width as f64, src.height as f64)).inflate(1.0).round_out();
|
|
let full = Rect::from_size(dst.width as f64, dst.height as f64);
|
|
let mut area = bounds.intersect(&full);
|
|
if let Some(c) = opts.clip {
|
|
area = area.intersect(&c);
|
|
}
|
|
if area.is_empty() {
|
|
return;
|
|
}
|
|
let (x0, x1) = (area.x0.max(0.0) as usize, area.x1.min(dst.width as f64) as usize);
|
|
let (y0, y1) = (area.y0.max(0.0) as usize, area.y1.min(dst.height as f64) as usize);
|
|
let w = dst.width as usize;
|
|
let affine = inv.is_affine();
|
|
let mode = opts.mode;
|
|
let op = opts.opacity;
|
|
dst.data.par_chunks_mut(w).enumerate().skip(y0).take(y1 - y0).for_each(|(y, row)| {
|
|
let fy = y as f64 + 0.5;
|
|
let mut sp = inv.apply(vec2(x0 as f64 + 0.5, fy));
|
|
let step = inv.apply_vec(vec2(1.0, 0.0));
|
|
for x in x0..x1 {
|
|
let p = if affine {
|
|
let p = sp;
|
|
sp += step;
|
|
p
|
|
} else {
|
|
inv.apply(vec2(x as f64 + 0.5, fy))
|
|
};
|
|
if p.x < -1.0 || p.y < -1.0 || p.x > img.width as f64 + 1.0 || p.y > img.height as f64 + 1.0 {
|
|
if mode.is_stencil() {
|
|
row[x] = blend_pixel(mode, row[x], [0.0; 4], 0.5);
|
|
}
|
|
continue;
|
|
}
|
|
let mut s = match opts.sampling {
|
|
Sampling::Nearest => img.get(p.x.floor() as i64, p.y.floor() as i64),
|
|
Sampling::Bilinear => img.sample_bilinear(p.x, p.y),
|
|
Sampling::Bicubic => img.sample_bicubic(p.x, p.y),
|
|
};
|
|
if s[3] <= 0.0 && !mode.is_stencil() {
|
|
continue;
|
|
}
|
|
if op < 1.0 {
|
|
for c in s.iter_mut() {
|
|
*c *= op;
|
|
}
|
|
}
|
|
let n = if matches!(mode, BlendMode::Dissolve | BlendMode::DancingDissolve) { hash_noise(x as u32, y as u32, opts.seed) } else { 0.5 };
|
|
row[x] = blend_pixel(mode, row[x], s, n);
|
|
}
|
|
});
|
|
// Stencil modes also clear everything outside the layer bounds.
|
|
if mode.is_stencil() && matches!(mode, BlendMode::StencilAlpha | BlendMode::StencilLuma) {
|
|
for (y, row) in dst.data.chunks_mut(w).enumerate() {
|
|
for (x, px) in row.iter_mut().enumerate() {
|
|
if x < x0 || x >= x1 || y < y0 || y >= y1 {
|
|
*px = [0.0; 4];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resize with bilinear filtering (box pre-filter when shrinking).
|
|
pub fn resample(src: &Image, w: u32, h: u32) -> Image {
|
|
let mut out = Image::new(w, h);
|
|
if src.is_empty() || w == 0 || h == 0 {
|
|
return out;
|
|
}
|
|
let m = Mat3::scale(vec2(w as f64 / src.width as f64, h as f64 / src.height as f64));
|
|
composite_warp(&mut out, src, &m, &WarpOpts::default());
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn identity_warp_copies() {
|
|
let mut src = Image::new(4, 4);
|
|
src.set(1, 2, [0.5, 0.5, 0.5, 1.0]);
|
|
let mut dst = Image::new(4, 4);
|
|
composite_warp(&mut dst, &src, &Mat3::IDENTITY, &WarpOpts::default());
|
|
assert_eq!(dst.get(1, 2), [0.5, 0.5, 0.5, 1.0]);
|
|
assert_eq!(dst.get(0, 0), [0.0; 4]);
|
|
}
|
|
|
|
#[test]
|
|
fn translate_by_integer() {
|
|
let mut src = Image::new(4, 4);
|
|
src.set(0, 0, [1.0; 4]);
|
|
let mut dst = Image::new(8, 8);
|
|
composite_warp(&mut dst, &src, &Mat3::translate(vec2(3.0, 2.0)), &WarpOpts::default());
|
|
assert_eq!(dst.get(3, 2), [1.0; 4]);
|
|
}
|
|
|
|
#[test]
|
|
fn downscale_preserves_average() {
|
|
let src = Image::filled(64, 64, [0.25, 0.5, 0.75, 1.0]);
|
|
let out = resample(&src, 8, 8);
|
|
let p = out.get(4, 4);
|
|
assert!((p[1] - 0.5).abs() < 1e-4 && (p[3] - 1.0).abs() < 1e-4, "{p:?}");
|
|
}
|
|
}
|