Files
effectcraft/crates/effects/src/vr.rs
T

1388 lines
59 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.
//! 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)
/// VR Converter's projections / layouts. Cube-map Facebook 3:2 and Cube-map EAC 3:2 put
/// left, front, right over bottom, back, top (EAC with equi-angular faces); Fisheye
/// (FullDome) is an angular fisheye looking up at the zenith with the front at the bottom.
pub const PROJECTIONS: [&str; 9] = [
"Equirectangular 2:1",
"Cube-map 4:3",
"Cube-map Pano2VR 3:2",
"Cube-map GearVR 6:1",
"Sphere-map",
"2D Source",
"Cube-map Facebook 3:2",
"Cube-map EAC 3:2",
"Fisheye (FullDome)",
];
/// 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)]),
// 3 × 2: left, front, right / bottom, back, top (Facebook, EAC).
6 | 7 => ((3, 2), [(2, 0), (0, 0), (2, 1), (0, 1), (1, 0), (1, 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 | 6 | 7 => {
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 mut a = (x - cx * cw) / cw * 2.0 - 1.0;
let mut b = (y - cy * ch) / ch * 2.0 - 1.0;
if self.kind == 7 {
// Equi-angular faces: positions are linear in angle.
a = (a * std::f64::consts::FRAC_PI_4).tan();
b = (b * std::f64::consts::FRAC_PI_4).tan();
}
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]]))
}
8 => {
// FullDome: 180° angular fisheye centred on the zenith (+Y), front at the bottom.
let s = w.min(h) / 2.0;
let (u, v) = ((x - w / 2.0) / s, (y - h / 2.0) / s);
let r = (u * u + v * v).sqrt();
if r > 1.0 {
return None;
}
let th = r * std::f64::consts::FRAC_PI_2;
let (cu, cv) = if r > 1e-12 { (u / r, v / r) } else { (0.0, 0.0) };
// Local: forward = +Y, image right = +X, image down = +Z.
Some([th.sin() * cu, th.cos(), th.sin() * cv])
}
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 | 6 | 7 => {
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 (mut a, mut b) = (dot(d, r) / k, -dot(d, u) / k);
if self.kind == 7 {
a = a.atan() / std::f64::consts::FRAC_PI_4;
b = b.atan() / std::f64::consts::FRAC_PI_4;
}
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])))
}
8 => {
let th = d[1].clamp(-1.0, 1.0).acos();
if th > std::f64::consts::FRAC_PI_2 + 1e-9 {
return None;
}
let s = w.min(h) / 2.0;
let r = th / std::f64::consts::FRAC_PI_2;
let l = (d[0] * d[0] + d[2] * d[2]).sqrt();
let (cu, cv) = if l > 1e-12 { (d[0] / l, d[2] / l) } else { (0.0, 0.0) };
Some((w / 2.0 + cu * r * s, h / 2.0 + cv * r * s, None))
}
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;
if ctx.params.e("noiseType") == 1 {
// Salt-and-pepper speckles: a median removes isolated outliers outright.
let mr = ((level * 3.0 * b.scale).round() as usize).max(1);
b.img = per_eye(&b.img, layout(ctx), |e, _| crate::noise::median_image(e, mr));
return b;
}
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 mut m = rot_params(ctx);
if ctx.params.b("invertRotation") {
// The inverse of a rotation is its transpose.
m = [[m[0][0], m[1][0], m[2][0]], [m[0][1], m[1][1], m[2][1]], [m[0][2], m[1][2], m[2][2]]];
}
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 && (!matches!(from.kind, 4 | 5) || 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, Scale degrees wide.
let fov = ctx.params.f("scale").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);
// Falloff Invert keeps the aberration near the point of interest instead of away from it.
let invert = ctx.params.b("falloffInvert");
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 weight = if invert { 1.0 - weight } else { weight };
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("transform/scale") / 100.0).max(0.01) as f32;
let rot = rotation(pr.f("transform/tilt"), pr.f("transform/pan"), pr.f("transform/roll"));
let infl = (pr.get("subSettings/subInfluence").map(Value::as_f64).unwrap_or(50.0) / 100.0).clamp(0.0, 1.0) as f32;
let sub = (pr.get("subSettings/subScaling").map(Value::as_f64).unwrap_or(50.0) / 100.0).clamp(0.1, 1.0) 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 = mv(&rot, equi_dir(x as f64 + 0.5, y as f64 + 0.5, w, h));
let mut v = 0.0f32;
let mut best = 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 => 1.0 - (n * 2.0 - 1.0).abs(),
// Strings: thin bright lines along the noise's zero crossings.
3 => 1.0 - ((n * 2.0 - 1.0).abs() / 0.12).clamp(0.0, 1.0).powi(2),
_ => n,
};
best = best.max(n * amp);
v += n * amp * wgt;
norm += amp * wgt;
amp *= infl;
freq /= sub;
}
// Max keeps the strongest layer rather than the weighted sum.
let mut v = if kind == 2 { best } else { 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
}
/// VR Digital Glitch: horizontal bands of the panorama jump sideways (Geometric Distortion,
/// split into Horizontal / Vertical displacement), their colour channels split apart (Color
/// Distortion with per-channel offsets) and scan lines darken alternate rows (Scanline
/// Spacing apart). The pattern re-randomises Distortion Rate times a second, and Distortion
/// Evolution (optionally cycling) moves it smoothly in between. Target Point limits the glitch
/// to a region of the sphere (Radius, Feather; radius 0 = everywhere).
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 g = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d);
let rate = pr.f("distortionRate").max(0.0);
let geo = pr.f("geometricDistortion") / 100.0 * amp;
let (hdisp, vdisp) = (g("geometric/horizontalDisplacement", 100.0) / 100.0, g("geometric/verticalDisplacement", 0.0) / 100.0);
let complexity = pr.f("distortionComplexity").clamp(1.0, 100.0);
let rgb = pr.f("colorDistortion") / 100.0 * amp;
let offs = [g("color/redOffset", -1.0), g("color/greenOffset", 0.0), g("color/blueOffset", 1.0)];
let scan = (pr.f("scanlines") / 100.0 * amp) as f32;
let spacing = g("scanlineSpacing", 2.0).round().max(2.0) as usize;
let seed = (pr.f("randomSeed") as u32) ^ ctx.seed;
// Evolution: a fraction of a slot that blends towards the next pattern.
let mut evo = g("distortionEvolution", 0.0) / 360.0;
if pr.b("evolutionOptions/cycleEvolution") {
evo = evo.rem_euclid(g("evolutionOptions/cycle", 1.0).round().max(1.0));
}
let base = if rate > 0.0 { ctx.time * rate } else { 0.0 } + evo;
let slot = base.floor() as i64 as u32;
let frac = base - base.floor();
// Target Point.
let target = mv(&rotation(g("target/tilt", 0.0), g("target/pan", 0.0), 0.0), [0.0, 0.0, 1.0]);
let radius = g("target/radius", 0.0).to_radians();
let feather = g("target/feather", 0.0).to_radians();
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 at = |s: u32| {
let r0 = hash1(band, s, seed ^ (eye as u32 * 977));
let on = r0 < 0.35;
let shift = if on { (hash1(band, s + 1, seed) as f64 * 2.0 - 1.0) * geo } else { 0.0 };
let split = if on { rgb * w * 0.01 * (1.0 + hash1(band, s + 2, seed) as f64) } else { 0.0 };
(shift, split)
};
let (s0, p0) = at(slot);
let (shift, split) = if frac > 0.0 {
let (s1, p1) = at(slot + 1);
(s0 + (s1 - s0) * frac, p0 + (p1 - p0) * frac)
} else {
(s0, p0)
};
let (dx, dy) = (shift * w * 0.15 * hdisp, shift * h * 0.15 * vdisp);
let dark = if y % spacing == 0 { 1.0 - scan * 0.5 } else { 1.0 };
for (x, px) in row.iter_mut().enumerate() {
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
let k = if radius > 0.0 {
let a = angle(equi_dir(fx, fy, w, h), target);
if a <= radius {
1.0
} else if feather > 0.0 {
(1.0 - (a - radius) / feather).clamp(0.0, 1.0)
} else {
0.0
}
} else {
1.0
};
let orig = e.data[y * e.width as usize + x];
if k <= 0.0 {
*px = orig;
continue;
}
let (sx, sy) = (fx - dx * k, (fy - dy * k).clamp(0.5, h - 0.5));
// Horizontal shifts wrap around the seam (the frame is a full turn).
let c: [Px; 3] = std::array::from_fn(|i| sample_equi(e, sx + offs[i] * split * k, sy));
let d = 1.0 - (1.0 - dark) * k as f32;
*px = [c[0][0] * d, c[1][1] * d, c[2][2] * d, c[0][3].max(c[1][3]).max(c[2][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", "Gradient Power", 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 Distance", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("falloffInvert", "Falloff Invert", Value::Bool(false), ParamUi::Checkbox),
],
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("noiseType", "Noise Type", Value::Enum(0), popup(&["Random Valued", "Salt-and-Pepper"])),
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)),
ang("distortionEvolution", "Distortion Evolution"),
p("evolutionOptions/cycleEvolution", "Cycle Evolution", Value::Bool(false), ParamUi::Checkbox),
p("evolutionOptions/cycle", "Cycle (in Revolutions)", num(1.0), slider(1.0, 1000.0, 1.0, 20.0, 0)),
p("geometricDistortion", "Geometric Distortion", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("geometric/horizontalDisplacement", "Horizontal Displacement", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
p("geometric/verticalDisplacement", "Vertical Displacement", num(0.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
p("colorDistortion", "Color Distortion", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("color/redOffset", "Red Offset", num(-1.0), slider(-10.0, 10.0, -2.0, 2.0, 2)),
p("color/greenOffset", "Green Offset", num(0.0), slider(-10.0, 10.0, -2.0, 2.0, 2)),
p("color/blueOffset", "Blue Offset", num(1.0), slider(-10.0, 10.0, -2.0, 2.0, 2)),
p("scanlines", "Scanlines", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("scanlineSpacing", "Scanline Spacing", num(2.0), slider(2.0, 100.0, 2.0, 20.0, 0)),
ang("target/tilt", "Target Tilt"),
ang("target/pan", "Target Pan"),
p("target/radius", "Target Radius", num(0.0), slider(0.0, 180.0, 0.0, 180.0, 1)),
p("target/feather", "Target Feather", num(0.0), slider(0.0, 180.0, 0.0, 90.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 Sharp", "Max", "Strings"])),
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("invert", "Invert", Value::Bool(false), ParamUi::Checkbox),
p("complexity", "Complexity", num(6.0), slider(1.0, 20.0, 1.0, 20.0, 1)),
ang("evolution", "Evolution"),
p("transform/scale", "Scale", num(100.0), slider(1.0, 10000.0, 10.0, 600.0, 1)),
ang("transform/tilt", "Tilt (X Axis)"),
ang("transform/pan", "Pan (Y Axis)"),
ang("transform/roll", "Roll (Z Axis)"),
p("subSettings/subInfluence", "Sub Influence", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("subSettings/subScaling", "Sub Scaling", num(50.0), slider(10.0, 100.0, 10.0, 100.0, 1)),
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", "Luma 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 (Degrees)", num(90.0), slider(1.0, 179.0, 1.0, 179.0, 1)),
p("feather", "Feather", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
ang("tilt", "Tilt (X Axis)"),
ang("pan", "Pan (Y Axis)"),
ang("roll", "Roll (Z Axis)"),
],
vr_plane_to_sphere,
),
spec(
"ec.vr.rotatesphere",
"VR Rotate Sphere",
vec![
layout_param(),
p("invertRotation", "Invert Rotation", Value::Bool(false), ParamUi::Checkbox),
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, 100.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..9u32 {
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, 6, 7] {
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 digital_glitch_full_controls() {
let img = sphere_img(128, 64);
let run = |vals: &[(&str, Value)], t: f64| run_fx("ec.vr.digitalglitch", vals, img.clone(), t, EffectEnv::default()).img;
let base = run(&[], 0.0);
for (id, v) in
[("distortionEvolution", num(90.0)), ("geometric/verticalDisplacement", num(100.0)), ("color/redOffset", num(3.0)), ("scanlineSpacing", num(5.0))]
{
assert_ne!(run(&[(id, v.clone())], 0.0), base, "{id}");
}
// Cycle Evolution: one full cycle is the start again.
let cyc =
|e: f64| run(&[("distortionEvolution", num(e)), ("evolutionOptions/cycleEvolution", Value::Bool(true)), ("evolutionOptions/cycle", num(1.0))], 0.0);
assert_eq!(cyc(0.0), cyc(360.0));
// Target Point: a small region around the front; the back stays untouched.
let t = run(&[("target/radius", num(30.0))], 0.0);
assert_eq!(t.get(0, 32), img.get(0, 32), "behind the viewer");
}
#[test]
fn converter_fulldome_and_eac() {
let img = sphere_img(128, 64);
let dome = run_fx("ec.vr.converter", &[("targetProjection", Value::Enum(8))], img.clone(), 0.0, EffectEnv::default()).img;
// The centre looks at the zenith (+Y); the bottom edge at the front (+Z).
let c = dome.get(64, 32);
assert!((c[1] - 0.9).abs() < 0.03, "{c:?}");
let f = dome.get(64, 62);
assert!(f[2] > 0.8, "{f:?}");
// EAC faces are equi-angular: a quarter of the front face sits at 22.5 degrees.
let eac = Proj { kind: 7, fov: 90.0 };
let cw = 128.0 / 3.0;
let d = eac.dir(cw + 0.25 * cw, 16.0, 128.0, 64.0).unwrap();
assert!((d[0].atan2(d[2]).to_degrees() + 22.5).abs() < 0.5, "{d:?}");
}
#[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 rotate_invert_undoes_rotation_and_options_take_effect() {
let img = sphere_img(64, 32);
let rot = [("pan", num(40.0)), ("tilt", num(15.0))];
let a = run_fx("ec.vr.rotatesphere", &rot, img.clone(), 0.0, EffectEnv::default());
let mut inv = rot.to_vec();
inv.push(("invertRotation", Value::Bool(true)));
let back = run_fx("ec.vr.rotatesphere", &inv, a.img.clone(), 0.0, EffectEnv::default());
let c = back.img.get(32, 16);
let o = img.get(32, 16);
assert!((0..3).all(|k| (c[k] - o[k]).abs() < 0.08), "{c:?} {o:?}");
// Falloff Invert, salt-and-pepper De-Noise and the VR fractal types all change the output.
let f0 = run_fx("ec.vr.chromaticaberrations", &[], img.clone(), 0.0, EffectEnv::default());
let f1 = run_fx("ec.vr.chromaticaberrations", &[("falloffInvert", Value::Bool(true))], img.clone(), 0.0, EffectEnv::default());
assert_ne!(f0.img.data, f1.img.data);
let d0 = run_fx("ec.vr.denoise", &[], img.clone(), 0.0, EffectEnv::default());
let d1 = run_fx("ec.vr.denoise", &[("noiseType", Value::Enum(1))], img.clone(), 0.0, EffectEnv::default());
assert_ne!(d0.img.data, d1.img.data);
let n: Vec<_> =
(0..4).map(|t| run_fx("ec.vr.fractalnoise", &[("fractalType", Value::Enum(t))], img.clone(), 0.0, EffectEnv::default()).img.data).collect();
assert!(n[0] != n[1] && n[1] != n[2] && n[2] != n[3]);
}
#[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);
}
}