M9.3: Perspective + Utility effects (CC Environment, 3D Glasses, HDR Compander)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 21:58:24 -04:00
parent 70c7f7c616
commit 685d6bcd04
2 changed files with 363 additions and 6 deletions

View File

@ -1,7 +1,286 @@
//! Perspective effects, batch 2.
//! Perspective effects, batch 2: CC Environment (reflection mapping of an environment layer
//! onto the layer's luminance/alpha relief) and 3D Glasses (stereo pairs and anaglyphs from a
//! left and right view layer).
use crate::EffectSpec;
use std::f64::consts::PI;
use effectcraft_color::luminance;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::util::{Plane, fit_layer, gauss_plane, premul, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, LayerPixels, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Perspective", params, render, gpu: false, float: true }
}
// ---------------------------------------------------------------- CC Environment
/// Environment direction (x right, y down, z towards the viewer) → normalised map coordinates.
fn env_uv(mapping: u32, r: [f64; 3]) -> (f64, f64) {
match mapping {
1 => {
// Probe (mirror ball).
let m = 2.0 * (r[0] * r[0] + r[1] * r[1] + (r[2] + 1.0).powi(2)).sqrt().max(1e-9);
(0.5 + r[0] / m, 0.5 + r[1] / m)
}
2 => {
// Vertical cross cube map: 3 faces wide, 4 tall.
let (ax, ay, az) = (r[0].abs(), r[1].abs(), r[2].abs());
let (col, row, u, v) = if ax >= ay && ax >= az {
if r[0] > 0.0 { (2.0, 1.0, -r[2] / ax, r[1] / ax) } else { (0.0, 1.0, r[2] / ax, r[1] / ax) }
} else if ay >= az {
if r[1] < 0.0 { (1.0, 0.0, r[0] / ay, -r[2] / ay) } else { (1.0, 2.0, r[0] / ay, r[2] / ay) }
} else if r[2] > 0.0 {
(1.0, 1.0, r[0] / az, r[1] / az)
} else {
(1.0, 3.0, r[0] / az, -r[1] / az)
};
((col + (u + 1.0) * 0.5) / 3.0, (row + (v + 1.0) * 0.5) / 4.0)
}
_ => {
// Spherical (equirectangular).
let lon = r[0].atan2(r[2]);
let lat = r[1].clamp(-1.0, 1.0).asin();
(0.5 + lon / (2.0 * PI), 0.5 + lat / PI)
}
}
}
fn cc_environment(ctx: &EffectCtx, mut b: Buf) -> Buf {
let env = ctx.layer_param("environment", true).unwrap_or_else(|| LayerPixels { buf: b.clone(), size: ctx.layer_size });
let env_img = if ctx.params.b("filterEnvironment") {
let s = 2.0 * env.buf.scale;
effectcraft_raster::gaussian_blur(&env.buf.img, s, s, true)
} else {
env.buf.img.clone()
};
let mapping = ctx.params.e("mapping");
let height = ctx.params.f("height") / 100.0 * 20.0;
// Relief: luminance inside, rounded off by the alpha edge.
let relief = Plane::from_image(&b.img, |px| {
let (c, a) = unpremul(px);
(luminance(c[0], c[1], c[2]) * 0.5 + 0.5) * a
});
let soft = 1.5 * b.scale.max(0.25);
let hmap = gauss_plane(&relief, soft, soft);
let (es, eo, esz) = (env.buf.scale, env.buf.offset, env.size);
let src = b.img.clone();
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let a = src.get(x as i64, y as i64)[3];
if a <= 0.0 {
*px = [0.0; 4];
continue;
}
let (xi, yi) = (x as i64, y as i64);
let gx = (hmap.get_clamped(xi + 1, yi) - hmap.get_clamped(xi - 1, yi)) as f64 * 0.5 / b.scale.max(1e-6);
let gy = (hmap.get_clamped(xi, yi + 1) - hmap.get_clamped(xi, yi - 1)) as f64 * 0.5 / b.scale.max(1e-6);
let n = [-gx * height, -gy * height, 1.0];
let l = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
let n = [n[0] / l, n[1] / l, n[2] / l];
// Reflect the view ray (towards −z) about the normal.
let d = -n[2];
let r = [-2.0 * d * n[0], -2.0 * d * n[1], -1.0 - 2.0 * d * n[2]];
let r = [r[0], r[1], -r[2]];
let (u, v) = env_uv(mapping, r);
let ex = u.rem_euclid(1.0) * esz[0] * es + eo[0];
let ey = v.clamp(0.0, 1.0) * esz[1] * es + eo[1];
let e = env_img.sample_bilinear_clamped(ex, ey);
let (ec, _) = unpremul(e);
*px = premul(ec, a);
}
});
b
}
// ---------------------------------------------------------------- 3D Glasses
const VIEWS: [&str; 9] = [
"Stereo Pair (Side by Side)",
"Over Under",
"Interlace Upper L Lower R",
"Red Green LR",
"Red Blue LR",
"Balanced Colored Red Blue",
"Balanced Red Green LR",
"Balanced Red Blue LR",
"Difference",
];
fn shift(img: &Image, dx: f64, dy: f64) -> Image {
if dx == 0.0 && dy == 0.0 {
return img.clone();
}
crate::util::gen_image(img.width, img.height, |x, y| img.sample_bilinear(x as f64 + 0.5 - dx, y as f64 + 0.5 - dy))
}
fn lum(p: Px) -> f32 {
luminance(p[0], p[1], p[2])
}
fn glasses_3d(ctx: &EffectCtx, mut b: Buf) -> Buf {
let get = |id: &str| match ctx.layer_param(id, true) {
Some(o) => fit_layer(ctx, &b, &o, false),
None => b.img.clone(),
};
let (mut l, mut r) = (get("leftView"), get("rightView"));
if ctx.params.b("leftRightSwap") {
std::mem::swap(&mut l, &mut r);
}
let pct = ctx.params.e("units") == 1;
let unit = if pct { ctx.layer_size[0] / 100.0 } else { 1.0 } * b.scale;
let conv = ctx.params.f("sceneConvergence") * unit;
let va = ctx.params.f("verticalAlignment") * unit;
let l = shift(&l, conv * 0.5, 0.0);
let r = shift(&r, -conv * 0.5, va);
let view = ctx.params.e("view3d");
let k = (ctx.params.f("balance") / 20.0).clamp(0.0, 1.0) as f32;
let (w, h) = (b.img.width as f64, b.img.height as f64);
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
*px = match view {
0 => {
if fx < w * 0.5 {
l.sample_bilinear(fx * 2.0, fy)
} else {
r.sample_bilinear((fx - w * 0.5) * 2.0, fy)
}
}
1 => {
if fy < h * 0.5 {
l.sample_bilinear(fx, fy * 2.0)
} else {
r.sample_bilinear(fx, (fy - h * 0.5) * 2.0)
}
}
2 => {
if y % 2 == 0 {
l.get(x as i64, y as i64)
} else {
r.get(x as i64, y as i64)
}
}
_ => {
let lp = l.get(x as i64, y as i64);
let rp = r.get(x as i64, y as i64);
let (lc, la) = unpremul(lp);
let (rc, ra) = unpremul(rp);
let a = la.max(ra);
let (ll, rl) = (lum(premul(lc, 1.0)), lum(premul(rc, 1.0)));
let c = match view {
3 => [ll, rl, 0.0],
4 => [ll, 0.0, rl],
5 => [lc[0] + (ll - lc[0]) * k, rc[1], rc[2]],
6 => [ll * (1.0 - 0.5 * k) + lc[0] * 0.5 * k, rl, 0.0],
7 => [ll * (1.0 - 0.5 * k) + lc[0] * 0.5 * k, 0.0, rl],
_ => [(lc[0] - rc[0]).abs(), (lc[1] - rc[1]).abs(), (lc[2] - rc[2]).abs()],
};
premul(c, a)
}
};
}
});
b
}
pub fn specs() -> Vec<EffectSpec> {
Vec::new()
vec![
spec(
"ec.perspective.ccenvironment",
"CC Environment",
vec![
p("environment", "Environment", Value::Layer(None), ParamUi::Layer),
p("mapping", "Mapping", Value::Enum(0), popup(&["Spherical", "Probe", "Vertical Cross"])),
p("filterEnvironment", "Filter Environment", Value::Bool(true), ParamUi::Checkbox),
p("height", "Height", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
cc_environment,
),
spec(
"ec.perspective.3dglasses",
"3D Glasses",
vec![
p("leftView", "Left View", Value::Layer(None), ParamUi::Layer),
p("rightView", "Right View", Value::Layer(None), ParamUi::Layer),
p("sceneConvergence", "Scene Convergence", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)),
p("verticalAlignment", "Vertical Alignment", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)),
p("units", "Units", Value::Enum(0), popup(&["Pixels", "% of Source"])),
p("leftRightSwap", "Left Right Swap", Value::Bool(false), ParamUi::Checkbox),
p("view3d", "3D View", Value::Enum(5), popup(&VIEWS)),
p("balance", "Balance", num(7.0), slider(0.0, 20.0, 0.0, 20.0, 1)),
],
glasses_3d,
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, EffectHost, run_fx};
fn img() -> Image {
crate::util::gen_image(32, 24, |x, y| {
let a = if (4..28).contains(&x) && (4..20).contains(&y) { 1.0 } else { 0.0 };
[x as f32 / 32.0 * a, y as f32 / 24.0 * a, 0.4 * a, a]
})
}
struct Host(LayerPixels);
impl EffectHost for Host {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
Some(self.0.clone())
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
}
#[test]
fn environment_reflects_env_layer() {
// A flat layer reflects the environment straight back: with a uniform green environment,
// opaque pixels become green, transparent stay transparent.
let env = LayerPixels { buf: Buf { img: Image::filled(16, 8, [0.0, 1.0, 0.0, 1.0]), offset: [0.0; 2], scale: 1.0 }, size: [16.0, 8.0] };
let host = Host(env);
let e = EffectEnv { host: Some(&host), ..Default::default() };
let o = run_fx("ec.perspective.ccenvironment", &[("environment", Value::Layer(Some(9)))], img(), 0.0, e);
let px = o.img.get(16, 12);
assert!((px[1] - 1.0).abs() < 1e-4 && px[0].abs() < 1e-4, "{px:?}");
assert_eq!(o.img.get(0, 0)[3], 0.0);
let again = run_fx("ec.perspective.ccenvironment", &[("environment", Value::Layer(Some(9)))], img(), 0.0, e);
assert_eq!(o.img, again.img);
for m in 0..3 {
let o = run_fx("ec.perspective.ccenvironment", &[("mapping", Value::Enum(m))], img(), 0.0, EffectEnv::default());
assert!(o.img.data.iter().all(|p| p.iter().all(|v| v.is_finite())));
}
}
#[test]
fn glasses_anaglyph_and_pairs() {
let im = img();
// Same view on both eyes: Red Blue LR puts the luminance in red and blue equally.
let o = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(4))], im.clone(), 0.0, EffectEnv::default());
let px = o.img.get(16, 12);
assert!((px[0] - px[2]).abs() < 1e-5 && px[1] == 0.0 && px[0] > 0.0);
// Difference of identical views is black.
let o = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(8))], im.clone(), 0.0, EffectEnv::default());
assert!(o.img.data.iter().all(|p| p[0].abs() < 1e-6 && p[1].abs() < 1e-6));
// Convergence separates the eyes: difference becomes non-zero.
let o = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(8)), ("sceneConvergence", num(4.0))], im.clone(), 0.0, EffectEnv::default());
assert!(o.img.data.iter().any(|p| p[0] > 0.05));
// Every view is deterministic.
for v in 0..VIEWS.len() as u32 {
let a = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(v))], im.clone(), 0.0, EffectEnv::default());
let b = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(v))], im.clone(), 0.0, EffectEnv::default());
assert_eq!(a.img, b.img);
}
// Interlace: even rows = left view (self).
let o = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(2))], im.clone(), 0.0, EffectEnv::default());
assert_eq!(o.img.get(10, 10), im.get(10, 10));
}
}

View File

@ -1,7 +1,85 @@
//! Utility effects, batch 2.
//! Utility effects, batch 2: HDR Compander.
//!
//! HDR Compander squeezes high-dynamic-range values into 0–1 (Compress Range) so 8/16 bpc
//! effects can process them, and restores them afterwards (Expand Range) with the same Gain and
//! Gamma; the two modes are exact inverses.
use crate::EffectSpec;
use effectcraft_keyframe::Value;
use rayon::prelude::*;
use crate::util::{premul, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
#[inline]
fn spow(v: f32, e: f32) -> f32 {
v.signum() * v.abs().powf(e)
}
fn hdr_compander(ctx: &EffectCtx, mut b: Buf) -> Buf {
let expand = ctx.params.e("mode") == 1;
let gain = ctx.params.f("gain").max(1e-6) as f32;
let gamma = ctx.params.f("gamma").max(1e-3) as f32;
b.img.data.par_iter_mut().for_each(|px| {
let (c, a) = unpremul(*px);
if a <= 0.0 {
return;
}
let c = c.map(|v| if expand { spow(v, gamma) * gain } else { spow(v / gain, 1.0 / gamma) });
*px = premul(c, a);
});
b
}
pub fn specs() -> Vec<EffectSpec> {
Vec::new()
vec![EffectSpec {
id: "ec.utility.hdrcompander",
name: "HDR Compander",
category: "Utility",
params: vec![
p("mode", "Mode", Value::Enum(0), popup(&["Compress Range", "Expand Range"])),
p("gain", "Gain", num(1.0), slider(1.0, 100.0, 1.0, 100.0, 2)),
p("gamma", "Gamma", num(1.0), slider(0.1, 10.0, 0.1, 3.0, 2)),
],
render: hdr_compander,
gpu: false,
float: true,
}]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, run_fx};
use effectcraft_raster::Image;
fn hdr() -> Image {
crate::util::gen_image(8, 4, |x, y| {
let v = x as f32 * 0.6 + y as f32 * 0.1;
[v * 0.8, v * 0.4, v * 0.2, 0.8]
})
}
#[test]
fn defaults_are_identity_and_deterministic() {
let im = hdr();
let a = run_fx("ec.utility.hdrcompander", &[], im.clone(), 0.0, EffectEnv::default());
let b = run_fx("ec.utility.hdrcompander", &[], im.clone(), 0.0, EffectEnv::default());
assert_eq!(a.img, b.img);
assert!(a.img.data.iter().zip(&im.data).all(|(p, q)| (0..4).all(|i| (p[i] - q[i]).abs() < 1e-5)));
}
#[test]
fn compress_then_expand_round_trips() {
let im = hdr();
let set = [("gain", num(5.0)), ("gamma", num(2.2))];
let c = run_fx("ec.utility.hdrcompander", &set, im.clone(), 0.0, EffectEnv::default());
// Compressed values fit in 0–1.
assert!(c.img.data.iter().all(|p| p[0] <= p[3] + 1e-5));
let e = run_fx("ec.utility.hdrcompander", &[set[0].clone(), set[1].clone(), ("mode", Value::Enum(1))], c.img, 0.0, EffectEnv::default());
for (p, q) in e.img.data.iter().zip(&im.data) {
for i in 0..4 {
assert!((p[i] - q[i]).abs() < 1e-3, "{p:?} vs {q:?}");
}
}
}
}