mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
Seam-aware equirectangular processing (wrap-around, pole continuation, latitude-scaled filters), per-eye stereo layouts, VR Converter between equirectangular, cube maps (4:3, 3:2, 6:1), sphere map and 2D. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1189 lines
48 KiB
Rust
1189 lines
48 KiB
Rust
//! Immersive Video effects (VR Blur, VR Chromatic Aberrations, VR Color Gradients, VR Converter,
|
||
//! VR De-Noise, VR Digital Glitch, VR Fractal Noise, VR Glow, VR Plane to Sphere, VR Rotate
|
||
//! Sphere, VR Sharpen, VR Sphere To Plane).
|
||
//!
|
||
//! All of them treat the layer as a 360° equirectangular frame (longitude across, latitude down;
|
||
//! the front, +Z, at the centre) and are **seam-aware**: filters wrap around the left/right edge,
|
||
//! continue across the poles (a column continues on the opposite meridian), and spatial filters
|
||
//! widen their horizontal footprint by 1/cos(latitude) so a blur is the same size on the sphere
|
||
//! everywhere. Stereo footage (Frame Layout: over/under or side by side) is processed per eye.
|
||
//!
|
||
//! VR Converter maps between equirectangular, cube maps (4:3 cross, 3:2 and 6:1 strips), an
|
||
//! angular fisheye ("sphere map") and a rectilinear 2D view, through a common view direction.
|
||
|
||
use std::f64::consts::PI;
|
||
|
||
use effectcraft_color::{BlendMode, blend_pixel, luminance};
|
||
use effectcraft_keyframe::Value;
|
||
use effectcraft_project::ParamUi;
|
||
use effectcraft_raster::{Image, Px};
|
||
use rayon::prelude::*;
|
||
|
||
use crate::generate::value_noise;
|
||
use crate::util::{hash1, unpremul};
|
||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||
|
||
type V3 = [f64; 3];
|
||
|
||
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
|
||
EffectSpec { id, name, category: "Immersive Video", params, render, gpu: false, float: true }
|
||
}
|
||
|
||
pub const FRAME_LAYOUTS: [&str; 3] = ["Monoscopic", "Stereo - Over/Under", "Stereo - Side by Side"];
|
||
|
||
fn layout_param() -> crate::ParamSpec {
|
||
p("frameLayout", "Frame Layout", Value::Enum(0), popup(&FRAME_LAYOUTS))
|
||
}
|
||
|
||
/// Eye rectangles (x, y, w, h) of a frame.
|
||
fn eyes(w: u32, h: u32, layout: u32) -> Vec<(u32, u32, u32, u32)> {
|
||
match layout {
|
||
1 if h >= 2 => vec![(0, 0, w, h / 2), (0, h / 2, w, h - h / 2)],
|
||
2 if w >= 2 => vec![(0, 0, w / 2, h), (w / 2, 0, w - w / 2, h)],
|
||
_ => vec![(0, 0, w, h)],
|
||
}
|
||
}
|
||
|
||
/// Run `f` on every eye of `img` and reassemble.
|
||
fn per_eye(img: &Image, layout: u32, f: impl Fn(&Image, usize) -> Image + Sync) -> Image {
|
||
let rects = eyes(img.width, img.height, layout);
|
||
if rects.len() == 1 {
|
||
return f(img, 0);
|
||
}
|
||
let outs: Vec<Image> = rects.par_iter().enumerate().map(|(i, &(x, y, w, h))| f(&img.crop(x as i64, y as i64, w, h), i)).collect();
|
||
let mut out = Image::new(img.width, img.height);
|
||
for (&(x0, y0, w, _), o) in rects.iter().zip(&outs) {
|
||
for (yy, row) in o.data.chunks(w.max(1) as usize).enumerate() {
|
||
let start = out.idx(x0, y0 + yy as u32);
|
||
out.data[start..start + row.len()].copy_from_slice(row);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
// ---------------------------------------------------------------- sphere geometry
|
||
|
||
#[inline]
|
||
fn norm(v: V3) -> V3 {
|
||
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-300);
|
||
[v[0] / l, v[1] / l, v[2] / l]
|
||
}
|
||
|
||
#[inline]
|
||
fn dot(a: V3, b: V3) -> f64 {
|
||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||
}
|
||
|
||
/// Direction (y up, +z front) of an equirectangular position in pixels.
|
||
#[inline]
|
||
pub fn equi_dir(x: f64, y: f64, w: f64, h: f64) -> V3 {
|
||
let lon = (x / w - 0.5) * 2.0 * PI;
|
||
let lat = (0.5 - y / h) * PI;
|
||
[lat.cos() * lon.sin(), lat.sin(), lat.cos() * lon.cos()]
|
||
}
|
||
|
||
/// Equirectangular pixel position of a direction.
|
||
#[inline]
|
||
pub fn equi_px(d: V3, w: f64, h: f64) -> (f64, f64) {
|
||
let lon = d[0].atan2(d[2]);
|
||
let lat = d[1].clamp(-1.0, 1.0).asin();
|
||
((lon / (2.0 * PI) + 0.5) * w, (0.5 - lat / PI) * h)
|
||
}
|
||
|
||
/// Bilinear sample of an equirectangular image: wraps horizontally, continues over the poles.
|
||
pub fn sample_equi(img: &Image, x: f64, y: f64) -> Px {
|
||
let (w, h) = (img.width as i64, img.height as i64);
|
||
if w == 0 || h == 0 {
|
||
return [0.0; 4];
|
||
}
|
||
let fx = x - 0.5;
|
||
let fy = y - 0.5;
|
||
let (x0, y0) = (fx.floor(), fy.floor());
|
||
let (tx, ty) = ((fx - x0) as f32, (fy - y0) as f32);
|
||
let at = |xi: i64, yi: i64| -> Px {
|
||
let (mut xi, mut yi) = (xi, yi);
|
||
if yi < 0 {
|
||
yi = -1 - yi;
|
||
xi += w / 2;
|
||
} else if yi >= h {
|
||
yi = 2 * h - 1 - yi;
|
||
xi += w / 2;
|
||
}
|
||
img.data[(yi.clamp(0, h - 1) * w + xi.rem_euclid(w)) as usize]
|
||
};
|
||
let (x0, y0) = (x0 as i64, y0 as i64);
|
||
let (a, b, c, d) = (at(x0, y0), at(x0 + 1, y0), at(x0, y0 + 1), at(x0 + 1, y0 + 1));
|
||
let mut o = [0.0f32; 4];
|
||
for k in 0..4 {
|
||
let top = a[k] + (b[k] - a[k]) * tx;
|
||
let bot = c[k] + (d[k] - c[k]) * tx;
|
||
o[k] = top + (bot - top) * ty;
|
||
}
|
||
o
|
||
}
|
||
|
||
/// Rotation matrix (row-major) for tilt (about X), pan (about Y) and roll (about Z), degrees:
|
||
/// R = Ry(pan) · Rx(tilt) · Rz(roll).
|
||
pub fn rotation(tilt: f64, pan: f64, roll: f64) -> [[f64; 3]; 3] {
|
||
let (a, b, c) = (tilt.to_radians(), pan.to_radians(), roll.to_radians());
|
||
let rx = [[1.0, 0.0, 0.0], [0.0, a.cos(), -a.sin()], [0.0, a.sin(), a.cos()]];
|
||
let ry = [[b.cos(), 0.0, b.sin()], [0.0, 1.0, 0.0], [-b.sin(), 0.0, b.cos()]];
|
||
let rz = [[c.cos(), -c.sin(), 0.0], [c.sin(), c.cos(), 0.0], [0.0, 0.0, 1.0]];
|
||
mm(&ry, &mm(&rx, &rz))
|
||
}
|
||
|
||
fn mm(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
|
||
let mut o = [[0.0; 3]; 3];
|
||
for i in 0..3 {
|
||
for j in 0..3 {
|
||
o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum();
|
||
}
|
||
}
|
||
o
|
||
}
|
||
|
||
#[inline]
|
||
fn mv(m: &[[f64; 3]; 3], v: V3) -> V3 {
|
||
[dot(m[0], v), dot(m[1], v), dot(m[2], v)]
|
||
}
|
||
|
||
#[inline]
|
||
fn mtv(m: &[[f64; 3]; 3], v: V3) -> V3 {
|
||
[m[0][0] * v[0] + m[1][0] * v[1] + m[2][0] * v[2], m[0][1] * v[0] + m[1][1] * v[1] + m[2][1] * v[2], m[0][2] * v[0] + m[1][2] * v[1] + m[2][2] * v[2]]
|
||
}
|
||
|
||
// ---------------------------------------------------------------- projections (VR Converter)
|
||
|
||
pub const PROJECTIONS: [&str; 6] = ["Equirectangular", "Cube-map 4:3", "Cube-map 3:2", "Cube-map 6:1", "Sphere Map", "2D Rectilinear"];
|
||
|
||
/// Cube faces: (forward, right, up).
|
||
const FACES: [(V3, V3, V3); 6] = [
|
||
([1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]), // +X right
|
||
([-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]), // -X left
|
||
([0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]), // +Y top
|
||
([0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]), // -Y bottom
|
||
([0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), // +Z front
|
||
([0.0, 0.0, -1.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), // -Z back
|
||
];
|
||
|
||
/// Grid size and the (column, row) of each face (+X, −X, +Y, −Y, +Z, −Z) for a cube layout.
|
||
fn cube_grid(kind: u32) -> ((u32, u32), [(u32, u32); 6]) {
|
||
match kind {
|
||
// Horizontal cross: top over the front; left, front, right, back across the middle.
|
||
1 => ((4, 3), [(2, 1), (0, 1), (1, 0), (1, 2), (1, 1), (3, 1)]),
|
||
// 3 × 2: right, left, top / bottom, front, back.
|
||
2 => ((3, 2), [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]),
|
||
// 6 × 1 strip in face order.
|
||
_ => ((6, 1), [(0, 0), (1, 0), (2, 0), (3, 0), (4, 0), (5, 0)]),
|
||
}
|
||
}
|
||
|
||
/// A projection: frame position (pixels) ↔ view direction.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct Proj {
|
||
pub kind: u32,
|
||
/// Horizontal field of view in degrees (sphere map and 2D only).
|
||
pub fov: f64,
|
||
}
|
||
|
||
impl Proj {
|
||
/// Direction seen at frame position (x, y) of a `w`×`h` frame (None outside the image).
|
||
pub fn dir(&self, x: f64, y: f64, w: f64, h: f64) -> Option<V3> {
|
||
match self.kind {
|
||
0 => Some(equi_dir(x, y, w, h)),
|
||
1..=3 => {
|
||
let ((gw, gh), cells) = cube_grid(self.kind);
|
||
let (cw, ch) = (w / gw as f64, h / gh as f64);
|
||
let (cx, cy) = ((x / cw).floor(), (y / ch).floor());
|
||
let f = cells.iter().position(|&(c, r)| c as f64 == cx && r as f64 == cy)?;
|
||
let a = (x - cx * cw) / cw * 2.0 - 1.0;
|
||
let b = (y - cy * ch) / ch * 2.0 - 1.0;
|
||
let (fw, r, u) = FACES[f];
|
||
Some(norm([fw[0] + a * r[0] - b * u[0], fw[1] + a * r[1] - b * u[1], fw[2] + a * r[2] - b * u[2]]))
|
||
}
|
||
4 => {
|
||
// Angular fisheye centred on +Z; radius ∝ angle, `fov` across the image width.
|
||
let half = (self.fov.clamp(1.0, 360.0) / 2.0).to_radians();
|
||
let s = w.min(h) / 2.0;
|
||
let (u, v) = ((x - w / 2.0) / s, (h / 2.0 - y) / s);
|
||
let r = (u * u + v * v).sqrt();
|
||
if r > 1.0 {
|
||
return None;
|
||
}
|
||
let th = r * half;
|
||
let (cu, cv) = if r > 1e-12 { (u / r, v / r) } else { (0.0, 0.0) };
|
||
Some([th.sin() * cu, th.sin() * cv, th.cos()])
|
||
}
|
||
_ => {
|
||
let t = (self.fov.clamp(1.0, 179.0) / 2.0).to_radians().tan();
|
||
let u = (x / w * 2.0 - 1.0) * t;
|
||
let v = (1.0 - y / h * 2.0) * t * h / w;
|
||
Some(norm([u, v, 1.0]))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Frame position (pixels) of direction `d`, and for cube maps the face rectangle to clamp
|
||
/// samples into. None when the direction is not visible.
|
||
pub fn pos(&self, d: V3, w: f64, h: f64) -> Option<(f64, f64, Option<[f64; 4]>)> {
|
||
match self.kind {
|
||
0 => {
|
||
let (x, y) = equi_px(d, w, h);
|
||
Some((x, y, None))
|
||
}
|
||
1..=3 => {
|
||
let f = (0..6).max_by(|&i, &j| dot(d, FACES[i].0).total_cmp(&dot(d, FACES[j].0)))?;
|
||
let (fw, r, u) = FACES[f];
|
||
let k = dot(d, fw);
|
||
let (a, b) = (dot(d, r) / k, -dot(d, u) / k);
|
||
let ((gw, gh), cells) = cube_grid(self.kind);
|
||
let (cw, ch) = (w / gw as f64, h / gh as f64);
|
||
let (c, rr) = cells[f];
|
||
let (x0, y0) = (c as f64 * cw, rr as f64 * ch);
|
||
Some((x0 + (a + 1.0) * 0.5 * cw, y0 + (b + 1.0) * 0.5 * ch, Some([x0, y0, x0 + cw, y0 + ch])))
|
||
}
|
||
4 => {
|
||
let half = (self.fov.clamp(1.0, 360.0) / 2.0).to_radians();
|
||
let th = d[2].clamp(-1.0, 1.0).acos();
|
||
if th > half + 1e-9 {
|
||
return None;
|
||
}
|
||
let s = w.min(h) / 2.0;
|
||
let r = th / half;
|
||
let l = (d[0] * d[0] + d[1] * d[1]).sqrt();
|
||
let (cu, cv) = if l > 1e-12 { (d[0] / l, d[1] / l) } else { (0.0, 0.0) };
|
||
Some((w / 2.0 + cu * r * s, h / 2.0 - cv * r * s, None))
|
||
}
|
||
_ => {
|
||
if d[2] <= 1e-9 {
|
||
return None;
|
||
}
|
||
let t = (self.fov.clamp(1.0, 179.0) / 2.0).to_radians().tan();
|
||
let u = d[0] / d[2] / t;
|
||
let v = d[1] / d[2] / (t * h / w);
|
||
if u.abs() > 1.0 || v.abs() > 1.0 {
|
||
return None;
|
||
}
|
||
Some(((u + 1.0) * 0.5 * w, (1.0 - v) * 0.5 * h, None))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Sample image `img` (in this projection) in direction `d`.
|
||
pub fn sample(&self, img: &Image, d: V3) -> Px {
|
||
let (w, h) = (img.width as f64, img.height as f64);
|
||
match self.pos(d, w, h) {
|
||
None => [0.0; 4],
|
||
Some((x, y, None)) => {
|
||
if self.kind == 0 {
|
||
sample_equi(img, x, y)
|
||
} else {
|
||
img.sample_bilinear_clamped(x, y)
|
||
}
|
||
}
|
||
Some((x, y, Some(r))) => {
|
||
// Stay inside the face so neighbouring faces don't bleed in.
|
||
let x = x.clamp(r[0] + 0.5, r[2] - 0.5);
|
||
let y = y.clamp(r[1] + 0.5, r[3] - 0.5);
|
||
img.sample_bilinear_clamped(x, y)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Re-project `src` (in `from`) into a `w`×`h` frame in `to`, rotating the view by `rot`
|
||
/// (output direction → source direction = rotᵀ · d).
|
||
pub fn convert(src: &Image, from: Proj, to: Proj, w: u32, h: u32, rot: Option<&[[f64; 3]; 3]>) -> Image {
|
||
let mut out = Image::new(w, h);
|
||
let (fw, fh) = (w as f64, h as f64);
|
||
out.rows_mut().for_each(|(y, row)| {
|
||
for (x, px) in row.iter_mut().enumerate() {
|
||
*px = match to.dir(x as f64 + 0.5, y as f64 + 0.5, fw, fh) {
|
||
Some(d) => {
|
||
let d = match rot {
|
||
Some(m) => mtv(m, d),
|
||
None => d,
|
||
};
|
||
from.sample(src, d)
|
||
}
|
||
None => [0.0; 4],
|
||
};
|
||
}
|
||
});
|
||
out
|
||
}
|
||
|
||
// ---------------------------------------------------------------- seam-aware filters
|
||
|
||
/// Horizontal box mean with wrap-around, radius `r` (pixels, ≤ w/2) on one row.
|
||
fn box_row_wrap(src: &[Px], dst: &mut [Px], r: usize) {
|
||
let n = src.len();
|
||
if n == 0 {
|
||
return;
|
||
}
|
||
if r == 0 {
|
||
dst.copy_from_slice(src);
|
||
return;
|
||
}
|
||
let r = r.min((n - 1) / 2);
|
||
if r == 0 {
|
||
dst.copy_from_slice(src);
|
||
return;
|
||
}
|
||
let k = (2 * r + 1) as f64;
|
||
let mut acc = [0.0f64; 4];
|
||
for i in 0..=2 * r {
|
||
let p = src[(i + n - r) % n];
|
||
for c in 0..4 {
|
||
acc[c] += p[c] as f64;
|
||
}
|
||
}
|
||
for x in 0..n {
|
||
for c in 0..4 {
|
||
dst[x][c] = (acc[c] / k) as f32;
|
||
}
|
||
let add = src[(x + r + 1) % n];
|
||
let sub = src[(x + n - r) % n];
|
||
for c in 0..4 {
|
||
acc[c] += add[c] as f64 - sub[c] as f64;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Vertical box mean with pole continuation, radius `r`, over all columns.
|
||
fn box_cols_pole(img: &Image, r: usize) -> Image {
|
||
let (w, h) = (img.width as usize, img.height as usize);
|
||
if r == 0 || h == 0 {
|
||
return img.clone();
|
||
}
|
||
let at = |x: usize, y: i64| -> Px {
|
||
let (mut x, mut y) = (x, y);
|
||
if y < 0 {
|
||
y = -1 - y;
|
||
x = (x + w / 2) % w;
|
||
} else if y >= h as i64 {
|
||
y = 2 * h as i64 - 1 - y;
|
||
x = (x + w / 2) % w;
|
||
}
|
||
img.data[y.clamp(0, h as i64 - 1) as usize * w + x]
|
||
};
|
||
let k = (2 * r + 1) as f64;
|
||
let cols: Vec<Vec<Px>> = (0..w)
|
||
.into_par_iter()
|
||
.map(|x| {
|
||
let mut out = vec![[0.0f32; 4]; h];
|
||
let mut acc = [0.0f64; 4];
|
||
for j in -(r as i64)..=(r as i64) {
|
||
let p = at(x, j);
|
||
for c in 0..4 {
|
||
acc[c] += p[c] as f64;
|
||
}
|
||
}
|
||
for y in 0..h {
|
||
for c in 0..4 {
|
||
out[y][c] = (acc[c] / k) as f32;
|
||
}
|
||
let add = at(x, y as i64 + r as i64 + 1);
|
||
let sub = at(x, y as i64 - r as i64);
|
||
for c in 0..4 {
|
||
acc[c] += add[c] as f64 - sub[c] as f64;
|
||
}
|
||
}
|
||
out
|
||
})
|
||
.collect();
|
||
let mut out = Image::new(img.width, img.height);
|
||
out.rows_mut().for_each(|(y, row)| {
|
||
for (x, px) in row.iter_mut().enumerate() {
|
||
*px = cols[x][y];
|
||
}
|
||
});
|
||
out
|
||
}
|
||
|
||
/// Gaussian-like blur of an equirectangular image with standard deviation `sigma` pixels at
|
||
/// the equator (3 box passes per axis), wrapping at the seam and continuing over the poles.
|
||
pub fn sphere_blur(img: &Image, sigma: f64) -> Image {
|
||
if sigma < 0.1 || img.is_empty() {
|
||
return img.clone();
|
||
}
|
||
let (w, h) = (img.width as usize, img.height as usize);
|
||
let radii = crate::util::box_radii(sigma, 3);
|
||
let mut cur = img.clone();
|
||
let mut tmp = Image::new(img.width, img.height);
|
||
for &r in &radii {
|
||
tmp.rows_mut().for_each(|(y, row)| {
|
||
let lat = (0.5 - (y as f64 + 0.5) / h as f64) * PI;
|
||
let rr = ((r as f64) / lat.cos().max(1e-3)).round().min((w / 2) as f64) as usize;
|
||
box_row_wrap(&cur.data[y * w..(y + 1) * w], row, rr);
|
||
});
|
||
std::mem::swap(&mut cur, &mut tmp);
|
||
}
|
||
for &r in &radii {
|
||
cur = box_cols_pole(&cur, r);
|
||
}
|
||
cur
|
||
}
|
||
|
||
// ---------------------------------------------------------------- effects
|
||
|
||
fn layout(ctx: &EffectCtx) -> u32 {
|
||
ctx.params.e("frameLayout")
|
||
}
|
||
|
||
fn vr_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
let s = ctx.params.f("blurriness").max(0.0) * b.scale * 0.5;
|
||
if s < 0.1 {
|
||
return b;
|
||
}
|
||
b.img = per_eye(&b.img, layout(ctx), |e, _| sphere_blur(e, s));
|
||
b
|
||
}
|
||
|
||
fn vr_sharpen(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
let amt = ctx.params.f("sharpenAmount") as f32 / 100.0;
|
||
if amt == 0.0 {
|
||
return b;
|
||
}
|
||
let s = (1.5 * b.scale).max(0.5);
|
||
b.img = per_eye(&b.img, layout(ctx), |e, _| {
|
||
let bl = sphere_blur(e, s);
|
||
let mut o = e.clone();
|
||
o.data.par_iter_mut().zip(bl.data.par_iter()).for_each(|(p, q)| {
|
||
for c in 0..3 {
|
||
p[c] = (p[c] + (p[c] - q[c]) * amt).clamp(0.0, p[3].max(0.0) * 4.0);
|
||
}
|
||
});
|
||
o
|
||
});
|
||
b
|
||
}
|
||
|
||
fn vr_denoise(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
let level = ctx.params.f("noiseLevel").max(0.0) / 100.0;
|
||
if level <= 0.0 {
|
||
return b;
|
||
}
|
||
let r = ((1.0 + level * 4.0) * b.scale).max(1.0) as usize;
|
||
let eps = (level * level * 0.02) as f32;
|
||
let detail = (ctx.params.f("detail") / 100.0).clamp(0.0, 1.0) as f32;
|
||
b.img = per_eye(&b.img, layout(ctx), |e, _| {
|
||
// Guided filter (self-guided, per channel) on a frame padded with wrapped / pole-continued
|
||
// pixels so the seam and poles are filtered like any other place.
|
||
let pad = r * 2 + 1;
|
||
let padded = wrap_pad(e, pad);
|
||
let guide = crate::util::Plane::luma(&padded);
|
||
let ch = crate::util::split(&padded);
|
||
let f: Vec<crate::util::Plane> = ch.par_iter().map(|c| crate::util::guided_filter(&guide, c, r, eps.max(1e-6))).collect();
|
||
let joined = crate::util::join(&[f[0].clone(), f[1].clone(), f[2].clone(), f[3].clone()]);
|
||
let mut o = joined.crop(pad as i64, pad as i64, e.width, e.height);
|
||
o.data.par_iter_mut().zip(e.data.par_iter()).for_each(|(p, s)| {
|
||
for c in 0..4 {
|
||
p[c] = p[c] + (s[c] - p[c]) * detail;
|
||
}
|
||
p[3] = p[3].clamp(0.0, 1.0);
|
||
for c in 0..3 {
|
||
p[c] = p[c].max(0.0);
|
||
}
|
||
});
|
||
o
|
||
});
|
||
b
|
||
}
|
||
|
||
/// Pad an equirectangular image by `pad` pixels: wrapped columns, pole-continued rows.
|
||
pub fn wrap_pad(img: &Image, pad: usize) -> Image {
|
||
let (w, h) = (img.width as i64, img.height as i64);
|
||
let p = pad as i64;
|
||
let mut out = Image::new((w + 2 * p) as u32, (h + 2 * p) as u32);
|
||
out.rows_mut().for_each(|(y, row)| {
|
||
for (x, px) in row.iter_mut().enumerate() {
|
||
let (mut sx, mut sy) = (x as i64 - p, y as i64 - p);
|
||
if sy < 0 {
|
||
sy = -1 - sy;
|
||
sx += w / 2;
|
||
} else if sy >= h {
|
||
sy = 2 * h - 1 - sy;
|
||
sx += w / 2;
|
||
}
|
||
*px = img.data[(sy.clamp(0, h - 1) * w + sx.rem_euclid(w.max(1))) as usize];
|
||
}
|
||
});
|
||
out
|
||
}
|
||
|
||
fn vr_glow(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
let th = ctx.params.f("luminanceThreshold") as f32 / 100.0;
|
||
let radius = ctx.params.f("glowRadius").max(0.0) * b.scale;
|
||
let bright = ctx.params.f("glowBrightness") as f32 / 100.0;
|
||
let sat = ctx.params.f("glowSaturation") as f32 / 100.0;
|
||
let tint = ctx.params.b("useTintColor");
|
||
let tc = ctx.params.color("tintColor");
|
||
if bright <= 0.0 {
|
||
return b;
|
||
}
|
||
b.img = per_eye(&b.img, layout(ctx), |e, _| {
|
||
let mut hi = e.clone();
|
||
hi.data.par_iter_mut().for_each(|p| {
|
||
let (c, a) = unpremul(*p);
|
||
let l = luminance(c[0], c[1], c[2]);
|
||
let k = if l > th { ((l - th) / (1.0 - th).max(1e-3)).min(4.0) } else { 0.0 };
|
||
let mut c = c.map(|v| v * k);
|
||
let lc = luminance(c[0], c[1], c[2]);
|
||
c = c.map(|v| lc + (v - lc) * sat);
|
||
if tint {
|
||
c = [lc * tc[0], lc * tc[1], lc * tc[2]];
|
||
}
|
||
*p = [c[0] * a, c[1] * a, c[2] * a, a * k.min(1.0)];
|
||
});
|
||
let g = sphere_blur(&hi, (radius * 0.5).max(0.5));
|
||
let mut o = e.clone();
|
||
o.data.par_iter_mut().zip(g.data.par_iter()).for_each(|(p, q)| {
|
||
for c in 0..3 {
|
||
p[c] += q[c] * bright;
|
||
}
|
||
p[3] = (p[3] + q[3] * bright).min(1.0);
|
||
});
|
||
o
|
||
});
|
||
b
|
||
}
|
||
|
||
fn rot_params(ctx: &EffectCtx) -> [[f64; 3]; 3] {
|
||
rotation(ctx.params.f("tilt"), ctx.params.f("pan"), ctx.params.f("roll"))
|
||
}
|
||
|
||
fn vr_rotate(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
let (t, pn, r) = (ctx.params.f("tilt"), ctx.params.f("pan"), ctx.params.f("roll"));
|
||
if t == 0.0 && pn == 0.0 && r == 0.0 {
|
||
return b;
|
||
}
|
||
let m = rot_params(ctx);
|
||
let eq = Proj { kind: 0, fov: 360.0 };
|
||
b.img = per_eye(&b.img, layout(ctx), |e, _| convert(e, eq, eq, e.width, e.height, Some(&m)));
|
||
b
|
||
}
|
||
|
||
fn vr_converter(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
let from = Proj { kind: ctx.params.e("sourceProjection"), fov: ctx.params.f("sourceHorizontalFov") };
|
||
let to = Proj { kind: ctx.params.e("targetProjection"), fov: ctx.params.f("targetHorizontalFov") };
|
||
let (t, pn, r) = (ctx.params.f("tilt"), ctx.params.f("pan"), ctx.params.f("roll"));
|
||
if from.kind == to.kind && (from.kind <= 3 || from.fov == to.fov) && t == 0.0 && pn == 0.0 && r == 0.0 {
|
||
return b;
|
||
}
|
||
let m = rot_params(ctx);
|
||
b.img = per_eye(&b.img, layout(ctx), |e, _| convert(e, from, to, e.width, e.height, Some(&m)));
|
||
b
|
||
}
|
||
|
||
fn vr_sphere_to_plane(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
let fov = ctx.params.f("horizontalFov");
|
||
let m = rot_params(ctx);
|
||
let eq = Proj { kind: 0, fov: 360.0 };
|
||
let flat = Proj { kind: 5, fov };
|
||
b.img = per_eye(&b.img, layout(ctx), |e, _| convert(e, eq, flat, e.width, e.height, Some(&m)));
|
||
b
|
||
}
|
||
|
||
fn vr_plane_to_sphere(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
// The layer is a flat picture placed on the sphere: horizontal FOV = 90° × scale.
|
||
let fov = (90.0 * ctx.params.f("scale") / 100.0).clamp(1.0, 179.0);
|
||
let m = rot_params(ctx);
|
||
let feather = (ctx.params.f("feather") / 100.0).clamp(0.0, 1.0);
|
||
let flat = Proj { kind: 5, fov };
|
||
let eq = Proj { kind: 0, fov: 360.0 };
|
||
let src = b.img.clone();
|
||
let mut out = convert(&src, flat, eq, src.width, src.height, Some(&m));
|
||
if feather > 0.0 {
|
||
let (w, h) = (src.width as f64, src.height as f64);
|
||
let t = (fov / 2.0).to_radians().tan();
|
||
out.rows_mut().for_each(|(y, row)| {
|
||
for (x, px) in row.iter_mut().enumerate() {
|
||
let d = mtv(&m, equi_dir(x as f64 + 0.5, y as f64 + 0.5, w, h));
|
||
if d[2] <= 0.0 {
|
||
continue;
|
||
}
|
||
let u = (d[0] / d[2] / t).abs();
|
||
let v = (d[1] / d[2] / (t * h / w)).abs();
|
||
let edge = 1.0 - u.max(v);
|
||
let k = (edge / feather).clamp(0.0, 1.0) as f32;
|
||
for c in px.iter_mut() {
|
||
*c *= k;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
b.img = out;
|
||
b
|
||
}
|
||
|
||
/// Angle (radians) between two directions.
|
||
#[inline]
|
||
fn angle(a: V3, b: V3) -> f64 {
|
||
dot(a, b).clamp(-1.0, 1.0).acos()
|
||
}
|
||
|
||
fn vr_chromatic(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
let k = [ctx.params.f("aberrationRed"), ctx.params.f("aberrationGreen"), ctx.params.f("aberrationBlue")].map(|v| v / 100.0 * 0.1);
|
||
if k.iter().all(|v| *v == 0.0) {
|
||
return b;
|
||
}
|
||
let center = mv(&rotation(ctx.params.f("centerTilt"), ctx.params.f("centerPan"), 0.0), [0.0, 0.0, 1.0]);
|
||
let falloff = (ctx.params.f("falloff") / 100.0).clamp(0.0, 1.0);
|
||
b.img = per_eye(&b.img, layout(ctx), |e, _| {
|
||
let (w, h) = (e.width as f64, e.height as f64);
|
||
let mut o = Image::new(e.width, e.height);
|
||
o.rows_mut().for_each(|(y, row)| {
|
||
for (x, px) in row.iter_mut().enumerate() {
|
||
let d = equi_dir(x as f64 + 0.5, y as f64 + 0.5, w, h);
|
||
let th = angle(d, center);
|
||
// Axis perpendicular to centre and d: rotate d about it toward/away from centre.
|
||
let ax = [center[1] * d[2] - center[2] * d[1], center[2] * d[0] - center[0] * d[2], center[0] * d[1] - center[1] * d[0]];
|
||
let al = dot(ax, ax).sqrt();
|
||
let weight = (th / PI).powf(1.0 + falloff * 3.0);
|
||
let mut out = [0.0f32; 4];
|
||
for c in 0..3 {
|
||
let s = if al < 1e-9 {
|
||
e.data[(y * e.width as usize) + x]
|
||
} else {
|
||
let new_th = th * (1.0 - k[c] * weight);
|
||
// Direction at angle new_th from the centre in the plane of (centre, d).
|
||
let perp = norm([d[0] - center[0] * th.cos(), d[1] - center[1] * th.cos(), d[2] - center[2] * th.cos()]);
|
||
let nd = [
|
||
center[0] * new_th.cos() + perp[0] * new_th.sin(),
|
||
center[1] * new_th.cos() + perp[1] * new_th.sin(),
|
||
center[2] * new_th.cos() + perp[2] * new_th.sin(),
|
||
];
|
||
let (sx, sy) = equi_px(nd, w, h);
|
||
sample_equi(e, sx, sy)
|
||
};
|
||
out[c] = s[c];
|
||
out[3] = out[3].max(s[3]);
|
||
}
|
||
*px = out;
|
||
}
|
||
});
|
||
o
|
||
});
|
||
b
|
||
}
|
||
|
||
const BLEND_OPTS: [&str; 6] = ["None", "Normal", "Add", "Multiply", "Screen", "Overlay"];
|
||
|
||
fn blend_mode(i: u32) -> Option<BlendMode> {
|
||
match i {
|
||
0 => None,
|
||
2 => Some(BlendMode::Add),
|
||
3 => Some(BlendMode::Multiply),
|
||
4 => Some(BlendMode::Screen),
|
||
5 => Some(BlendMode::Overlay),
|
||
_ => Some(BlendMode::Normal),
|
||
}
|
||
}
|
||
|
||
/// Composite a generated eye image over the original with a blend mode and opacity (mode None
|
||
/// replaces the layer).
|
||
fn composite(orig: &Image, layer: &Image, mode: u32, opacity: f32) -> Image {
|
||
let mut o = orig.clone();
|
||
o.data.par_iter_mut().zip(layer.data.par_iter()).for_each(|(d, s)| {
|
||
let s = [s[0] * opacity, s[1] * opacity, s[2] * opacity, s[3] * opacity];
|
||
*d = match blend_mode(mode) {
|
||
None => s,
|
||
Some(m) => {
|
||
let mut r = blend_pixel(m, *d, s, 0.5);
|
||
// Keep the layer's own coverage: generated pixels show only where it is opaque.
|
||
let a = d[3];
|
||
if a < 1.0 {
|
||
let k = a / r[3].max(1e-6);
|
||
if k < 1.0 {
|
||
r = r.map(|v| v * k);
|
||
}
|
||
}
|
||
r
|
||
}
|
||
};
|
||
});
|
||
o
|
||
}
|
||
|
||
fn vr_gradients(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
let pr = ctx.params;
|
||
let blend = pr.f("blend").max(0.1);
|
||
let opacity = (pr.f("opacity") / 100.0) as f32;
|
||
let mode = pr.e("blendingMode");
|
||
let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0));
|
||
let pts: Vec<(V3, [f32; 4])> = (1..=5)
|
||
.filter(|i| pr.b(&format!("enablePoint{i}")))
|
||
.map(|i| {
|
||
let p = pr.v2(&format!("point{i}"));
|
||
(equi_dir(p[0], p[1], lw, lh), pr.color(&format!("color{i}")))
|
||
})
|
||
.collect();
|
||
if pts.is_empty() {
|
||
return b;
|
||
}
|
||
b.img = per_eye(&b.img, layout(ctx), |e, _| {
|
||
let (w, h) = (e.width as f64, e.height as f64);
|
||
let mut g = Image::new(e.width, e.height);
|
||
g.rows_mut().for_each(|(y, row)| {
|
||
for (x, px) in row.iter_mut().enumerate() {
|
||
let d = equi_dir(x as f64 + 0.5, y as f64 + 0.5, w, h);
|
||
// Inverse-distance weighting on great-circle distance.
|
||
let mut acc = [0.0f64; 3];
|
||
let mut ws = 0.0;
|
||
let mut exact = None;
|
||
for (pd, c) in &pts {
|
||
let a = angle(d, *pd);
|
||
if a < 1e-6 {
|
||
exact = Some(*c);
|
||
break;
|
||
}
|
||
let wgt = 1.0 / a.powf(blend);
|
||
for k in 0..3 {
|
||
acc[k] += c[k] as f64 * wgt;
|
||
}
|
||
ws += wgt;
|
||
}
|
||
let c = exact.map(|c| [c[0], c[1], c[2]]).unwrap_or([(acc[0] / ws) as f32, (acc[1] / ws) as f32, (acc[2] / ws) as f32]);
|
||
*px = [c[0], c[1], c[2], 1.0];
|
||
}
|
||
});
|
||
composite(e, &g, mode, opacity)
|
||
});
|
||
b
|
||
}
|
||
|
||
fn vr_fractal(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
let pr = ctx.params;
|
||
let kind = pr.e("fractalType");
|
||
let invert = pr.b("invert");
|
||
let contrast = pr.f("contrast") as f32 / 100.0;
|
||
let brightness = pr.f("brightness") as f32 / 100.0;
|
||
let scale = (pr.f("scale") / 100.0).max(0.01) as f32;
|
||
let octaves = pr.f("complexity").clamp(1.0, 20.0);
|
||
let evo = (pr.f("evolution") / 360.0) as f32;
|
||
let opacity = (pr.f("opacity") / 100.0) as f32;
|
||
let mode = pr.e("blendingMode");
|
||
let seed = pr.f("randomSeed") as u32;
|
||
let base = 4.0 / scale;
|
||
b.img = per_eye(&b.img, layout(ctx), |e, _| {
|
||
let (w, h) = (e.width as f64, e.height as f64);
|
||
let mut g = Image::new(e.width, e.height);
|
||
g.rows_mut().for_each(|(y, row)| {
|
||
for (x, px) in row.iter_mut().enumerate() {
|
||
let d = equi_dir(x as f64 + 0.5, y as f64 + 0.5, w, h);
|
||
let mut v = 0.0f32;
|
||
let mut amp = 1.0f32;
|
||
let mut freq = base;
|
||
let mut norm = 0.0f32;
|
||
let n_oct = octaves.ceil() as usize;
|
||
for o in 0..n_oct {
|
||
let wgt = if o + 1 == n_oct && octaves.fract() > 0.0 { octaves.fract() as f32 } else { 1.0 };
|
||
let q = [d[0] as f32 * freq + evo * 0.37, d[1] as f32 * freq + evo * 0.71, d[2] as f32 * freq + evo * 0.59];
|
||
let n = value_noise(q[0] + o as f32 * 17.3, q[1], q[2], seed.wrapping_add(o as u32));
|
||
let n = match kind {
|
||
1 => (n * 2.0 - 1.0).abs(),
|
||
2 => 1.0 - (n * 2.0 - 1.0).abs(),
|
||
_ => n,
|
||
};
|
||
v += n * amp * wgt;
|
||
norm += amp * wgt;
|
||
amp *= 0.5;
|
||
freq *= 2.0;
|
||
}
|
||
let mut v = v / norm.max(1e-6);
|
||
v = (v - 0.5) * contrast + 0.5 + brightness;
|
||
if invert {
|
||
v = 1.0 - v;
|
||
}
|
||
let v = v.clamp(0.0, 1.0);
|
||
*px = [v, v, v, 1.0];
|
||
}
|
||
});
|
||
composite(e, &g, mode, opacity)
|
||
});
|
||
b
|
||
}
|
||
|
||
fn vr_glitch(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
let pr = ctx.params;
|
||
let amp = (pr.f("masterAmplitude") / 100.0).max(0.0);
|
||
if amp <= 0.0 {
|
||
return b;
|
||
}
|
||
let rate = pr.f("distortionRate").max(0.0);
|
||
let geo = pr.f("geometricDistortion") / 100.0 * amp;
|
||
let complexity = pr.f("distortionComplexity").clamp(1.0, 100.0);
|
||
let rgb = pr.f("colorDistortion") / 100.0 * amp;
|
||
let scan = (pr.f("scanlines") / 100.0 * amp) as f32;
|
||
let seed = (pr.f("randomSeed") as u32) ^ ctx.seed;
|
||
// The glitch pattern changes `rate` times a second (held between changes).
|
||
let slot = if rate > 0.0 { (ctx.time * rate).floor() as i64 } else { 0 } as u32;
|
||
b.img = per_eye(&b.img, layout(ctx), |e, eye| {
|
||
let (w, h) = (e.width as f64, e.height as f64);
|
||
let bands = complexity.round() as u32;
|
||
let mut o = Image::new(e.width, e.height);
|
||
o.rows_mut().for_each(|(y, row)| {
|
||
let band = ((y as f64 / h) * bands as f64) as u32;
|
||
let r0 = hash1(band, slot, seed ^ (eye as u32 * 977));
|
||
let on = r0 < 0.35;
|
||
let shift = if on { (hash1(band, slot + 1, seed) as f64 * 2.0 - 1.0) * geo * w * 0.15 } else { 0.0 };
|
||
let split = if on { rgb * w * 0.01 * (1.0 + hash1(band, slot + 2, seed) as f64) } else { 0.0 };
|
||
let dark = if y % 2 == 0 { 1.0 - scan * 0.5 } else { 1.0 };
|
||
for (x, px) in row.iter_mut().enumerate() {
|
||
let sx = x as f64 + 0.5 - shift;
|
||
let sy = y as f64 + 0.5;
|
||
// Horizontal shifts wrap around the seam (the frame is a full turn).
|
||
let g = sample_equi(e, sx, sy);
|
||
let r = sample_equi(e, sx - split, sy);
|
||
let bb = sample_equi(e, sx + split, sy);
|
||
*px = [r[0] * dark, g[1] * dark, bb[2] * dark, g[3].max(r[3]).max(bb[3])];
|
||
}
|
||
});
|
||
o
|
||
});
|
||
b
|
||
}
|
||
|
||
fn ang(id: &'static str, name: &'static str) -> crate::ParamSpec {
|
||
p(id, name, num(0.0), ParamUi::Angle)
|
||
}
|
||
|
||
pub fn specs() -> Vec<EffectSpec> {
|
||
let pct = |d: f64| (num(d), slider(0.0, 100.0, 0.0, 100.0, 1));
|
||
let mut grad = vec![layout_param()];
|
||
let defaults = [
|
||
([0.25, 0.5], [1.0, 0.0, 0.0]),
|
||
([0.5, 0.25], [1.0, 1.0, 0.0]),
|
||
([0.75, 0.5], [0.0, 0.0, 1.0]),
|
||
([0.5, 0.75], [0.0, 1.0, 0.0]),
|
||
([0.0, 0.5], [1.0, 0.0, 1.0]),
|
||
];
|
||
const IDS: [[&str; 6]; 5] = [
|
||
["enablePoint1", "Enable Point 1", "point1", "Point 1", "color1", "Color 1"],
|
||
["enablePoint2", "Enable Point 2", "point2", "Point 2", "color2", "Color 2"],
|
||
["enablePoint3", "Enable Point 3", "point3", "Point 3", "color3", "Color 3"],
|
||
["enablePoint4", "Enable Point 4", "point4", "Point 4", "color4", "Color 4"],
|
||
["enablePoint5", "Enable Point 5", "point5", "Point 5", "color5", "Color 5"],
|
||
];
|
||
for (i, (ids, (pt, c))) in IDS.iter().zip(defaults).enumerate() {
|
||
grad.push(p(ids[0], ids[1], Value::Bool(i < 4), ParamUi::Checkbox));
|
||
grad.push(p(ids[2], ids[3], Value::Vec2(pt), ParamUi::Point));
|
||
grad.push(p(ids[4], ids[5], col(c[0], c[1], c[2]), ParamUi::Color));
|
||
}
|
||
grad.push(p("blend", "Blend", num(2.0), slider(0.1, 10.0, 0.5, 6.0, 2)));
|
||
let (v, u) = pct(100.0);
|
||
grad.push(p("opacity", "Opacity", v, u));
|
||
grad.push(p("blendingMode", "Blending Mode", Value::Enum(0), popup(&BLEND_OPTS)));
|
||
vec![
|
||
spec("ec.vr.blur", "VR Blur", vec![layout_param(), p("blurriness", "Blurriness", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1))], vr_blur),
|
||
spec(
|
||
"ec.vr.chromaticaberrations",
|
||
"VR Chromatic Aberrations",
|
||
vec![
|
||
layout_param(),
|
||
ang("centerTilt", "Center Tilt"),
|
||
ang("centerPan", "Center Pan"),
|
||
p("aberrationRed", "Aberration (Red)", num(10.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
|
||
p("aberrationGreen", "Aberration (Green)", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
|
||
p("aberrationBlue", "Aberration (Blue)", num(-10.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
|
||
p("falloff", "Falloff", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||
],
|
||
vr_chromatic,
|
||
),
|
||
spec("ec.vr.colorgradients", "VR Color Gradients", grad, vr_gradients),
|
||
spec(
|
||
"ec.vr.converter",
|
||
"VR Converter",
|
||
vec![
|
||
layout_param(),
|
||
p("sourceProjection", "Source Projection", Value::Enum(0), popup(&PROJECTIONS)),
|
||
p("sourceHorizontalFov", "Source Horizontal FOV", num(90.0), slider(1.0, 360.0, 1.0, 360.0, 1)),
|
||
p("targetProjection", "Target Projection", Value::Enum(2), popup(&PROJECTIONS)),
|
||
p("targetHorizontalFov", "Target Horizontal FOV", num(90.0), slider(1.0, 360.0, 1.0, 360.0, 1)),
|
||
ang("tilt", "Tilt (X Axis)"),
|
||
ang("pan", "Pan (Y Axis)"),
|
||
ang("roll", "Roll (Z Axis)"),
|
||
],
|
||
vr_converter,
|
||
),
|
||
spec(
|
||
"ec.vr.denoise",
|
||
"VR De-Noise",
|
||
vec![
|
||
layout_param(),
|
||
p("noiseLevel", "Noise Level", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||
p("detail", "Detail", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||
],
|
||
vr_denoise,
|
||
),
|
||
spec(
|
||
"ec.vr.digitalglitch",
|
||
"VR Digital Glitch",
|
||
vec![
|
||
layout_param(),
|
||
p("masterAmplitude", "Master Amplitude", num(100.0), slider(0.0, 500.0, 0.0, 200.0, 1)),
|
||
p("distortionRate", "Distortion Rate", num(5.0), slider(0.0, 60.0, 0.0, 30.0, 2)),
|
||
p("distortionComplexity", "Distortion Complexity", num(16.0), slider(1.0, 100.0, 1.0, 64.0, 0)),
|
||
p("geometricDistortion", "Geometric Distortion", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||
p("colorDistortion", "Color Distortion", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||
p("scanlines", "Scanlines", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||
p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 0)),
|
||
],
|
||
vr_glitch,
|
||
),
|
||
spec(
|
||
"ec.vr.fractalnoise",
|
||
"VR Fractal Noise",
|
||
vec![
|
||
layout_param(),
|
||
p("fractalType", "Fractal Type", Value::Enum(0), popup(&["Basic", "Turbulent Smooth", "Turbulent Sharp"])),
|
||
p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox),
|
||
p("contrast", "Contrast", num(100.0), slider(0.0, 10000.0, 0.0, 400.0, 1)),
|
||
p("brightness", "Brightness", num(0.0), slider(-10000.0, 10000.0, -200.0, 200.0, 1)),
|
||
p("scale", "Scale", num(100.0), slider(1.0, 10000.0, 10.0, 600.0, 1)),
|
||
p("complexity", "Complexity", num(6.0), slider(1.0, 20.0, 1.0, 20.0, 1)),
|
||
ang("evolution", "Evolution"),
|
||
p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 0)),
|
||
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||
p("blendingMode", "Blending Mode", Value::Enum(0), popup(&BLEND_OPTS)),
|
||
],
|
||
vr_fractal,
|
||
),
|
||
spec(
|
||
"ec.vr.glow",
|
||
"VR Glow",
|
||
vec![
|
||
layout_param(),
|
||
p("luminanceThreshold", "Luminance Threshold", num(60.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||
p("glowRadius", "Glow Radius", num(20.0), slider(0.0, 1000.0, 0.0, 200.0, 1)),
|
||
p("glowBrightness", "Glow Brightness", num(100.0), slider(0.0, 1000.0, 0.0, 400.0, 1)),
|
||
p("glowSaturation", "Glow Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
||
p("useTintColor", "Use Tint Color", Value::Bool(false), ParamUi::Checkbox),
|
||
p("tintColor", "Tint Color", col(1.0, 0.8, 0.5), ParamUi::Color),
|
||
],
|
||
vr_glow,
|
||
),
|
||
spec(
|
||
"ec.vr.planetosphere",
|
||
"VR Plane to Sphere",
|
||
vec![
|
||
layout_param(),
|
||
p("scale", "Scale", num(100.0), slider(1.0, 198.0, 1.0, 198.0, 1)),
|
||
ang("tilt", "Tilt (X Axis)"),
|
||
ang("pan", "Pan (Y Axis)"),
|
||
ang("roll", "Roll (Z Axis)"),
|
||
p("feather", "Feather", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||
],
|
||
vr_plane_to_sphere,
|
||
),
|
||
spec(
|
||
"ec.vr.rotatesphere",
|
||
"VR Rotate Sphere",
|
||
vec![layout_param(), ang("tilt", "Tilt (X Axis)"), ang("pan", "Pan (Y Axis)"), ang("roll", "Roll (Z Axis)")],
|
||
vr_rotate,
|
||
),
|
||
spec(
|
||
"ec.vr.sharpen",
|
||
"VR Sharpen",
|
||
vec![layout_param(), p("sharpenAmount", "Sharpen Amount", num(20.0), slider(0.0, 500.0, 0.0, 100.0, 1))],
|
||
vr_sharpen,
|
||
),
|
||
spec(
|
||
"ec.vr.spheretoplane",
|
||
"VR Sphere To Plane",
|
||
vec![
|
||
layout_param(),
|
||
p("horizontalFov", "Horizontal FOV", num(90.0), slider(1.0, 179.0, 10.0, 170.0, 1)),
|
||
ang("tilt", "Tilt (X Axis)"),
|
||
ang("pan", "Pan (Y Axis)"),
|
||
ang("roll", "Roll (Z Axis)"),
|
||
],
|
||
vr_sphere_to_plane,
|
||
),
|
||
]
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::{EffectEnv, run_fx};
|
||
|
||
/// A smooth function of the view direction rendered as an equirectangular image.
|
||
fn sphere_img(w: u32, h: u32) -> Image {
|
||
let mut img = Image::new(w, h);
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let d = equi_dir(x as f64 + 0.5, y as f64 + 0.5, w as f64, h as f64);
|
||
img.set(x, y, [(0.5 + 0.4 * d[0]) as f32, (0.5 + 0.4 * d[1]) as f32, (0.5 + 0.4 * d[2]) as f32, 1.0]);
|
||
}
|
||
}
|
||
img
|
||
}
|
||
|
||
#[test]
|
||
fn projections_are_inverse() {
|
||
for kind in 0..6u32 {
|
||
let pr = Proj { kind, fov: if kind == 4 { 360.0 } else { 100.0 } };
|
||
let (w, h) = (240.0, 120.0);
|
||
for (x, y) in [(10.5, 20.5), (100.5, 60.5), (230.5, 110.5), (130.5, 33.5)] {
|
||
let Some(d) = pr.dir(x, y, w, h) else { continue };
|
||
let (px, py, _) = pr.pos(d, w, h).unwrap();
|
||
assert!((px - x).abs() < 1e-6 && (py - y).abs() < 1e-6, "kind {kind}: ({x},{y}) → ({px},{py})");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn converter_round_trips_equirect_through_cube_maps() {
|
||
let img = sphere_img(128, 64);
|
||
for target in [1u32, 2, 3] {
|
||
let cube = run_fx("ec.vr.converter", &[("targetProjection", Value::Enum(target))], img.clone(), 0.0, EffectEnv::default());
|
||
let back = run_fx(
|
||
"ec.vr.converter",
|
||
&[("sourceProjection", Value::Enum(target)), ("targetProjection", Value::Enum(0))],
|
||
cube.img.clone(),
|
||
0.0,
|
||
EffectEnv::default(),
|
||
);
|
||
let mut worst = 0.0f32;
|
||
let mut sum = 0.0f32;
|
||
for (a, b) in back.img.data.iter().zip(&img.data) {
|
||
let e = (0..3).map(|k| (a[k] - b[k]).abs()).fold(0.0, f32::max);
|
||
worst = worst.max(e);
|
||
sum += e;
|
||
}
|
||
let mean = sum / img.data.len() as f32;
|
||
// The 6:1 strip squeezes each face into a narrow cell, so its seams are coarser.
|
||
let tol = if target == 3 { 0.25 } else { 0.08 };
|
||
assert!(mean < 0.01 && worst < tol, "target {target}: mean {mean} worst {worst}");
|
||
// Cube faces carry the expected directions: the front face centre looks at +Z.
|
||
let pr = Proj { kind: target, fov: 90.0 };
|
||
let (x, y, _) = pr.pos([0.0, 0.0, 1.0], 128.0, 64.0).unwrap();
|
||
let px = cube.img.sample_bilinear_clamped(x, y);
|
||
assert!((px[2] - 0.9).abs() < 0.02, "{px:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rotate_sphere_pans_and_round_trips() {
|
||
let img = sphere_img(96, 48);
|
||
let a = run_fx("ec.vr.rotatesphere", &[("pan", num(90.0))], img.clone(), 0.0, EffectEnv::default());
|
||
let b = run_fx("ec.vr.rotatesphere", &[("pan", num(-90.0))], a.img.clone(), 0.0, EffectEnv::default());
|
||
let err: f32 = b.img.data.iter().zip(&img.data).map(|(p, q)| (p[0] - q[0]).abs()).sum::<f32>() / img.data.len() as f32;
|
||
assert!(err < 0.01, "{err}");
|
||
// A 90° pan is a horizontal shift by a quarter turn.
|
||
let (x, y) = (10u32, 24u32);
|
||
let shifted = img.get(((x + 24) % 96) as i64, y as i64);
|
||
let got = a.img.get(x as i64, y as i64);
|
||
let alt = img.get(((x + 72) % 96) as i64, y as i64);
|
||
assert!((got[0] - shifted[0]).abs() < 0.02 || (got[0] - alt[0]).abs() < 0.02);
|
||
}
|
||
|
||
#[test]
|
||
fn blur_is_seamless() {
|
||
// A frame whose left and right edges differ: after blurring, the two edge columns agree
|
||
// (they are neighbours on the sphere).
|
||
let mut img = Image::new(64, 32);
|
||
for y in 0..32 {
|
||
for x in 0..64 {
|
||
let v = if x < 4 {
|
||
1.0
|
||
} else if x >= 60 {
|
||
0.0
|
||
} else {
|
||
0.5
|
||
};
|
||
img.set(x, y, [v, v, v, 1.0]);
|
||
}
|
||
}
|
||
let out = run_fx("ec.vr.blur", &[("blurriness", num(8.0))], img.clone(), 0.0, EffectEnv::default());
|
||
let (l, r) = (out.img.get(0, 16)[0], out.img.get(63, 16)[0]);
|
||
assert!((l - r).abs() < 0.2, "{l} {r}");
|
||
assert!(r > 0.2 && l < 0.85);
|
||
// Pole rows are uniform (every pixel of the top row is the same point).
|
||
let top: Vec<f32> = (0..64).map(|x| out.img.get(x, 0)[0]).collect();
|
||
let spread = top.iter().cloned().fold(f32::MIN, f32::max) - top.iter().cloned().fold(f32::MAX, f32::min);
|
||
assert!(spread < 0.1, "{spread}");
|
||
// Constant images stay constant.
|
||
let flat = Image::filled(32, 16, [0.3, 0.3, 0.3, 1.0]);
|
||
let o = run_fx("ec.vr.blur", &[], flat, 0.0, EffectEnv::default());
|
||
assert!(o.img.data.iter().all(|p| (p[0] - 0.3).abs() < 1e-4));
|
||
}
|
||
|
||
#[test]
|
||
fn stereo_layouts_process_each_eye() {
|
||
let mut img = Image::new(32, 32);
|
||
for y in 0..32 {
|
||
for x in 0..32 {
|
||
let v = if y < 16 { 0.2 } else { 0.8 };
|
||
img.set(x, y, [v, v, v, 1.0]);
|
||
}
|
||
}
|
||
// Over/under: blurring each eye keeps the eyes separate.
|
||
let o = run_fx("ec.vr.blur", &[("frameLayout", Value::Enum(1)), ("blurriness", num(10.0))], img.clone(), 0.0, EffectEnv::default());
|
||
assert!((o.img.get(5, 15)[0] - 0.2).abs() < 1e-4 && (o.img.get(5, 16)[0] - 0.8).abs() < 1e-4);
|
||
let mono = run_fx("ec.vr.blur", &[("blurriness", num(10.0))], img, 0.0, EffectEnv::default());
|
||
assert!((mono.img.get(5, 15)[0] - 0.2).abs() > 0.01);
|
||
}
|
||
|
||
#[test]
|
||
fn sphere_to_plane_and_back() {
|
||
let img = sphere_img(128, 64);
|
||
// The centre of a 2D view looking forward sees +Z (blue 0.9).
|
||
let v = run_fx("ec.vr.spheretoplane", &[], img.clone(), 0.0, EffectEnv::default());
|
||
let c = v.img.get(64, 32);
|
||
assert!((c[2] - 0.9).abs() < 0.02, "{c:?}");
|
||
// Looking up (tilt 90°): +Y (green 0.9).
|
||
let up = run_fx("ec.vr.spheretoplane", &[("tilt", num(-90.0))], img.clone(), 0.0, EffectEnv::default());
|
||
let u = up.img.get(64, 32);
|
||
let dn = run_fx("ec.vr.spheretoplane", &[("tilt", num(90.0))], img, 0.0, EffectEnv::default());
|
||
let d = dn.img.get(64, 32);
|
||
assert!((u[1] - 0.9).abs() < 0.03 || (d[1] - 0.9).abs() < 0.03, "{u:?} {d:?}");
|
||
// Plane to sphere puts the picture in front, transparent behind.
|
||
let pic = Image::filled(64, 32, [1.0, 0.0, 0.0, 1.0]);
|
||
let s = run_fx("ec.vr.planetosphere", &[], pic, 0.0, EffectEnv::default());
|
||
assert!(s.img.get(32, 16)[3] > 0.99 && s.img.get(1, 16)[3] < 0.01);
|
||
}
|
||
|
||
#[test]
|
||
fn generators_and_filters_are_deterministic_and_seamless() {
|
||
let img = sphere_img(64, 32);
|
||
for (id, vals) in [
|
||
("ec.vr.fractalnoise", vec![]),
|
||
("ec.vr.colorgradients", vec![]),
|
||
("ec.vr.glow", vec![("luminanceThreshold", num(50.0))]),
|
||
("ec.vr.sharpen", vec![]),
|
||
("ec.vr.denoise", vec![]),
|
||
("ec.vr.chromaticaberrations", vec![]),
|
||
("ec.vr.digitalglitch", vec![]),
|
||
] {
|
||
let a = run_fx(id, &vals, img.clone(), 0.5, EffectEnv::default());
|
||
let b = run_fx(id, &vals, img.clone(), 0.5, EffectEnv::default());
|
||
assert_eq!(a.img.data, b.img.data, "{id}");
|
||
assert!(a.img.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "{id}");
|
||
}
|
||
// Fractal noise is continuous across the seam: the first and last columns are close.
|
||
let n = run_fx("ec.vr.fractalnoise", &[], img.clone(), 0.0, EffectEnv::default());
|
||
let mut worst = 0.0f32;
|
||
for y in 0..32 {
|
||
worst = worst.max((n.img.get(0, y)[0] - n.img.get(63, y)[0]).abs());
|
||
}
|
||
assert!(worst < 0.15, "{worst}");
|
||
// Gradients reach the point colour at the point.
|
||
let pts = [
|
||
("point1", Value::Vec2([16.0, 16.0])),
|
||
("point2", Value::Vec2([32.0, 8.0])),
|
||
("point3", Value::Vec2([48.0, 16.0])),
|
||
("point4", Value::Vec2([32.0, 24.0])),
|
||
];
|
||
let g = run_fx("ec.vr.colorgradients", &pts, img.clone(), 0.0, EffectEnv::default());
|
||
let px = g.img.get(16, 16);
|
||
assert!(px[0] > 0.8 && px[1] < 0.3, "{px:?}");
|
||
// Glitch changes over time.
|
||
let g0 = run_fx("ec.vr.digitalglitch", &[], img.clone(), 0.0, EffectEnv::default());
|
||
let g1 = run_fx("ec.vr.digitalglitch", &[], img.clone(), 3.3, EffectEnv::default());
|
||
assert_ne!(g0.img.data, g1.img.data);
|
||
}
|
||
}
|