M9.13: VR Converter Facebook / EAC cube maps and FullDome fisheye, VR Digital Glitch full controls; PARTIAL list empty

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 07:15:36 -04:00
parent 863ff22dc4
commit 70e7800b74
4 changed files with 172 additions and 27 deletions

View File

@ -7,7 +7,7 @@ use crate::{CATEGORIES, registry};
/// Effects whose implementation is known to fall short of the reference behaviour:
/// `(effect id, what is missing)`. Everything else in the registry is implemented in full as far
/// as the public behaviour documentation describes it.
pub const PARTIAL: &[(&str, &str)] = &[("ec.vr.digitalglitch", "reduced control set"), ("ec.vr.converter", "common layouts only")];
pub const PARTIAL: &[(&str, &str)] = &[];
/// Whether Effect Controls shows parameter (or twirl-down group) `param` (spec id path,
/// `group/param`) of an instance of effect `effect`, given the instance's current values

View File

@ -601,6 +601,8 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("heightMapControls", "Height Map Controls"),
("wireframeControls", "Wireframe Controls"),
("ground", "Ground"),
("geometric", "Geometric Distortion"),
("target", "Target Point"),
("simulation", "Simulation"),
("producer1", "Producer 1"),
("producer2", "Producer 2"),

View File

@ -154,7 +154,20 @@ fn mtv(m: &[[f64; 3]; 3], v: V3) -> V3 {
// ---------------------------------------------------------------- projections (VR Converter)
pub const PROJECTIONS: [&str; 6] = ["Equirectangular 2:1", "Cube-map 4:3", "Cube-map Pano2VR 3:2", "Cube-map GearVR 6:1", "Sphere-map", "2D Source"];
/// 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] = [
@ -173,6 +186,8 @@ fn cube_grid(kind: u32) -> ((u32, u32), [(u32, u32); 6]) {
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)]),
}
@ -191,16 +206,34 @@ impl Proj {
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 => {
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 a = (x - cx * cw) / cw * 2.0 - 1.0;
let b = (y - cy * ch) / ch * 2.0 - 1.0;
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();
@ -231,17 +264,32 @@ impl Proj {
let (x, y) = equi_px(d, w, h);
Some((x, y, None))
}
1..=3 => {
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 (a, b) = (dot(d, r) / k, -dot(d, u) / k);
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();
@ -578,7 +626,7 @@ 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 {
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);
@ -825,39 +873,86 @@ fn vr_fractal(ctx: &EffectCtx, mut b: Buf) -> Buf {
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;
// 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;
// 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 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 };
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 sx = x as f64 + 0.5 - shift;
let sy = y as f64 + 0.5;
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 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])];
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
@ -947,9 +1042,22 @@ pub fn specs() -> Vec<EffectSpec> {
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,
@ -1056,7 +1164,7 @@ mod tests {
#[test]
fn projections_are_inverse() {
for kind in 0..6u32 {
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)] {
@ -1070,7 +1178,7 @@ mod tests {
#[test]
fn converter_round_trips_equirect_through_cube_maps() {
let img = sphere_img(128, 64);
for target in [1u32, 2, 3] {
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",
@ -1098,6 +1206,41 @@ mod tests {
}
}
#[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);

View File

@ -3,11 +3,11 @@
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
EffectCraft ships 303 effects with 2942 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
EffectCraft ships 303 effects with 2955 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
- **GPU**: 24 effects also run on the GPU compositor with identical results.
- **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping.
- **Status**: 301 implemented in full, 2 partial (what is missing is listed).
- **Status**: 303 implemented in full, 0 partial (what is missing is listed).
Effects are addressed by id (`ec.<category>.<name>`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`).
@ -221,9 +221,9 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| VR Blur | `ec.vr.blur` | 2 | | 32 | Implemented |
| VR Chromatic Aberrations | `ec.vr.chromaticaberrations` | 8 | | 32 | Implemented |
| VR Color Gradients | `ec.vr.colorgradients` | 19 | | 32 | Implemented |
| VR Converter | `ec.vr.converter` | 8 | | 32 | Partial: common layouts only |
| VR Converter | `ec.vr.converter` | 8 | | 32 | Implemented |
| VR De-Noise | `ec.vr.denoise` | 4 | | 32 | Implemented |
| VR Digital Glitch | `ec.vr.digitalglitch` | 8 | | 32 | Partial: reduced control set |
| VR Digital Glitch | `ec.vr.digitalglitch` | 21 | | 32 | Implemented |
| VR Fractal Noise | `ec.vr.fractalnoise` | 16 | | 32 | Implemented |
| VR Glow | `ec.vr.glow` | 7 | | 32 | Implemented |
| VR Plane to Sphere | `ec.vr.planetosphere` | 6 | | 32 | Implemented |