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:
parent
70c7f7c616
commit
685d6bcd04
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue