Files
effectcraft/crates/effects/src/channel3d.rs
T
Brandon ThomasandClaude Opus 5.5 9a11c577f3 M9.11: 3D Channel effects on auxiliary channels
Aux-channel model (effectcraft_raster::channels3d: depth, object/material
ID, normals, UV, coverage, Cryptomatte ranks + manifests, MurmurHash3);
EffectHost::aux; multi-layer OpenEXR channels via the exr crate
(FootageSource::aux); the compositor's depth / layer-ID / Cryptomatte pass
(Renderer::comp_aux) so the effects work on precomps of 3D comps.
3D Channel Extract, Cryptomatte, Depth Matte, Depth of Field, EXtractoR,
Fog 3D, ID Matte, IDentifier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:28:37 -04:00

682 lines
28 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.
//! 3D Channel effects: 3D Channel Extract, Cryptomatte, Depth Matte, Depth of Field, EXtractoR,
//! Fog 3D, ID Matte and IDentifier.
//!
//! They read the layer's auxiliary channels ([`AuxChannels`], via
//! [`crate::EffectHost::aux`]): multi-layer OpenEXR footage (depth, object/material IDs,
//! Cryptomatte layers, any named channel) or the compositor's depth / layer-ID / Cryptomatte
//! pass for a precomp of a 3D comp. Without auxiliary channels they pass the layer through
//! (EXtractoR still maps the layer's own R, G, B and A).
//!
//! Aux pixels are looked up nearest-neighbour (IDs must not be interpolated) at each buffer
//! pixel's layer position, so render scale and layer padding are respected.
use std::sync::Arc;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, crypto_hash};
use effectcraft_raster::{AuxChannels, Image, Px, gaussian_blur};
use rayon::prelude::*;
use crate::util::{Plane, gauss_plane, hash1, premul, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "3D Channel", params, render, gpu: false, float: true }
}
fn aux(ctx: &EffectCtx) -> Option<Arc<AuxChannels>> {
ctx.env.host?.aux()
}
/// Aux index for each buffer pixel (None outside the aux image).
fn index_map(b: &Buf, a: &AuxChannels) -> Vec<Option<usize>> {
let (w, h) = (b.img.width as usize, b.img.height as usize);
let inv = 1.0 / b.scale.max(1e-9);
(0..w * h)
.into_par_iter()
.map(|i| {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
a.index_at((x - b.offset[0]) * inv, (y - b.offset[1]) * inv)
})
.collect()
}
/// A channel sampled into the buffer grid (`fill` outside).
fn plane_of(b: &Buf, idx: &[Option<usize>], ch: &[f32], fill: f32) -> Plane {
Plane { w: b.img.width as usize, h: b.img.height as usize, data: idx.par_iter().map(|i| i.map_or(fill, |i| ch[i])).collect() }
}
/// Distinct colour for an ID (hash → hue).
fn id_color(id: f32) -> [f32; 3] {
let h = hash1(id.to_bits(), 0x1d, 0x5eed);
let (r, g, b) = effectcraft_color::hsl_to_rgb(h, 0.75, 0.5);
if id == 0.0 { [0.0; 3] } else { [r, g, b] }
}
// ---------------------------------------------------------------- 3D Channel Extract
pub const EXTRACT_CHANNELS: [&str; 8] = ["Z-Depth", "Object ID", "Texture UV", "Surface Normals", "Coverage", "Background RGB", "Unclamped RGB", "Material ID"];
fn channel_extract(ctx: &EffectCtx, mut b: Buf) -> Buf {
let Some(a) = aux(ctx) else { return b };
let idx = index_map(&b, &a);
let (bp, wp) = (ctx.params.f("blackPoint") as f32, ctx.params.f("whitePoint") as f32);
let lv = |v: f32| ((v - bp) / (wp - bp).abs().max(1e-9) * (wp - bp).signum()).clamp(0.0, 1.0);
let lv = |v: f32| if wp >= bp { lv(v) } else { 1.0 - ((v - wp) / (bp - wp).max(1e-9)).clamp(0.0, 1.0) };
let get3 = |names: [&[&str]; 3]| -> Option<[&[f32]; 3]> { Some([a.find(names[0])?, a.find(names[1])?, a.find(names[2])?]) };
let img: Option<Image> = match ctx.params.e("channel") {
0 => a.depth().map(|z| map_grey(&b, &idx, |i| lv(z[i]))),
1 => a.object_id().map(|c| map_grey(&b, &idx, |i| lv(c[i]))),
7 => a.material_id().map(|c| map_grey(&b, &idx, |i| lv(c[i]))),
4 => a.coverage().map(|c| map_grey(&b, &idx, |i| lv(c[i]))),
2 => get3([&["UV.U", "U", "uv.x"], &["UV.V", "V", "uv.y"], &["UV.W", "W", "uv.z"]])
.or_else(|| Some([a.find(&["UV.U", "U", "uv.x"])?, a.find(&["UV.V", "V", "uv.y"])?, a.find(&["UV.V", "V", "uv.y"])?]))
.map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i]), lv(c[1][i]), 0.0])),
3 => get3([&["N.X", "Normal.X", "normals.x"], &["N.Y", "Normal.Y", "normals.y"], &["N.Z", "Normal.Z", "normals.z"]])
.map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i] * 0.5 + 0.5), lv(c[1][i] * 0.5 + 0.5), lv(c[2][i] * 0.5 + 0.5)])),
5 => get3([&["BG.R", "Background.R"], &["BG.G", "Background.G"], &["BG.B", "Background.B"]])
.map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i]), lv(c[1][i]), lv(c[2][i])])),
_ => get3([&["R"], &["G"], &["B"]]).map(|c| map_rgb(&b, &idx, |i| [c[0][i], c[1][i], c[2][i]])),
};
if let Some(i) = img {
b.img = i;
}
b
}
fn map_grey(b: &Buf, idx: &[Option<usize>], f: impl Fn(usize) -> f32 + Sync) -> Image {
map_rgb(b, idx, |i| {
let v = f(i);
[v, v, v]
})
}
fn map_rgb(b: &Buf, idx: &[Option<usize>], f: impl Fn(usize) -> [f32; 3] + Sync) -> Image {
let mut img = Image::new(b.img.width, b.img.height);
img.data.par_iter_mut().zip(idx.par_iter()).for_each(|(p, i)| {
if let Some(i) = i {
let c = f(*i);
*p = [c[0], c[1], c[2], 1.0];
}
});
img
}
// ---------------------------------------------------------------- Depth Matte
fn depth_matte(ctx: &EffectCtx, mut b: Buf) -> Buf {
let Some(a) = aux(ctx) else { return b };
let Some(z) = a.depth() else { return b };
let idx = index_map(&b, &a);
let depth = ctx.params.f("depth") as f32;
let feather = ctx.params.f("feather").max(0.0) as f32;
let invert = ctx.params.b("invert");
// Keeps what lies at or beyond Depth (Invert keeps what is in front), with a soft ramp of
// `feather` depth units centred on the threshold.
let zp = plane_of(&b, &idx, z, BACKGROUND_DEPTH);
b.img.data.par_iter_mut().zip(zp.data.par_iter()).for_each(|(px, &zv)| {
let k = if feather > 0.0 {
((zv - depth) / feather + 0.5).clamp(0.0, 1.0)
} else if zv >= depth {
1.0
} else {
0.0
};
let k = if invert { 1.0 - k } else { k };
*px = px.map(|v| v * k);
});
b
}
// ---------------------------------------------------------------- Depth of Field
/// Blur radius (pixels) for depth `z`.
pub fn dof_radius(z: f32, focal: f32, thickness: f32, max_r: f32, bias: f32) -> f32 {
let d = ((z - focal).abs() - thickness * 0.5).max(0.0);
let range = focal.abs().max(1.0);
let n = (d / range).min(1.0);
// Bias 50 = linear; higher bias brings blur in sooner.
let gamma = 2f32.powf((50.0 - bias) / 25.0);
max_r * n.powf(gamma)
}
fn depth_of_field(ctx: &EffectCtx, mut b: Buf) -> Buf {
let Some(a) = aux(ctx) else { return b };
let Some(z) = a.depth() else { return b };
let max_r = (ctx.params.f("maximumRadius").max(0.0) * b.scale) as f32;
if max_r <= 0.0 {
return b;
}
let focal = ctx.params.f("focalPlane") as f32;
let thick = ctx.params.f("focalPlaneThickness").max(0.0) as f32;
let bias = ctx.params.f("focalBias") as f32;
let idx = index_map(&b, &a);
let zp = plane_of(&b, &idx, z, BACKGROUND_DEPTH);
// A stack of blurs at a few radii, interpolated per pixel by its circle of confusion.
const LEVELS: usize = 5;
let stack: Vec<Image> = (0..LEVELS)
.into_par_iter()
.map(|k| {
let r = max_r * k as f32 / (LEVELS - 1) as f32;
if r < 0.3 { b.img.clone() } else { gaussian_blur(&b.img, (r / 2.0) as f64, (r / 2.0) as f64, true) }
})
.collect();
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let r = dof_radius(zp.data[i], focal, thick, max_r, bias);
let f = (r / max_r * (LEVELS - 1) as f32).clamp(0.0, (LEVELS - 1) as f32);
let k0 = (f.floor() as usize).min(LEVELS - 2);
let t = f - k0 as f32;
let (a0, a1) = (stack[k0].data[i], stack[k0 + 1].data[i]);
*px = [0, 1, 2, 3].map(|c| a0[c] + (a1[c] - a0[c]) * t);
});
b
}
// ---------------------------------------------------------------- Fog 3D
fn fog_3d(ctx: &EffectCtx, mut b: Buf) -> Buf {
let Some(a) = aux(ctx) else { return b };
let Some(z) = a.depth() else { return b };
let pr = ctx.params;
let fc = pr.color("fogColor");
let (start, end) = (pr.f("fogStartDepth") as f32, pr.f("fogEndDepth") as f32);
let opacity = (pr.f("fogOpacity") / 100.0) as f32;
let density = (pr.f("scatteringDensity") / 100.0) as f32;
let foggy_bg = pr.b("foggyBackground");
let contrib = (pr.f("layerContribution") / 100.0) as f32;
let grad = if contrib > 0.0 { ctx.layer_param("gradientLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true)) } else { None };
let idx = index_map(&b, &a);
let zp = plane_of(&b, &idx, z, BACKGROUND_DEPTH);
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let zv = zp.data[i];
let bg = zv >= BACKGROUND_DEPTH * 0.5;
let mut f = if bg {
if foggy_bg { 1.0 } else { 0.0 }
} else {
let t = ((zv - start) / (end - start).abs().max(1e-6)).clamp(0.0, 1.0);
// Denser scattering thickens the fog sooner.
1.0 - (1.0 - t).powf(1.0 + density * 3.0)
};
if let Some(g) = &grad {
let (gc, ga) = unpremul(g.data[i]);
let l = effectcraft_color::luminance(gc[0], gc[1], gc[2]) * ga;
f *= 1.0 - contrib + contrib * l;
}
let f = (f * opacity).clamp(0.0, 1.0);
let (c, al) = unpremul(*px);
let al2 = if bg && foggy_bg { al + (1.0 - al) * f } else { al };
let out = [0, 1, 2].map(|k| c[k] + (fc[k] - c[k]) * f);
*px = premul(out, al2);
});
b
}
// ---------------------------------------------------------------- ID Matte / IDentifier
fn id_channel(a: &AuxChannels, which: u32) -> Option<&[f32]> {
if which == 1 { a.material_id() } else { a.object_id() }
}
fn id_matte(ctx: &EffectCtx, mut b: Buf) -> Buf {
let Some(a) = aux(ctx) else { return b };
let Some(ids) = id_channel(&a, ctx.params.e("auxChannel")) else { return b };
let idx = index_map(&b, &a);
let sel = ctx.params.f("idSelection").round() as f32;
let cov = if ctx.params.b("useCoverage") { a.coverage() } else { None };
let mut m = Plane {
w: b.img.width as usize,
h: b.img.height as usize,
data: idx.par_iter().map(|i| i.map_or(0.0, |i| if ids[i].round() == sel { cov.map_or(1.0, |c| c[i].clamp(0.0, 1.0)) } else { 0.0 })).collect(),
};
let feather = ctx.params.f("feather").max(0.0) * b.scale;
if feather > 0.0 {
m = gauss_plane(&m, feather * 0.5, feather * 0.5);
}
let invert = ctx.params.b("invert");
b.img.data.par_iter_mut().zip(m.data.par_iter()).for_each(|(px, &k)| {
let k = if invert { 1.0 - k } else { k };
*px = px.map(|v| v * k);
});
b
}
fn identifier(ctx: &EffectCtx, mut b: Buf) -> Buf {
let Some(a) = aux(ctx) else { return b };
let Some(ids) = id_channel(&a, ctx.params.e("channelType")) else { return b };
let idx = index_map(&b, &a);
let sel = ctx.params.f("id").round() as f32;
let display = ctx.params.e("display");
let max_id = ids.iter().cloned().fold(1.0f32, f32::max);
let src = b.img.clone();
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let Some(k) = idx[i] else {
if display != 2 {
*px = [0.0; 4];
}
return;
};
let id = ids[k].round();
let hit = id == sel;
*px = match display {
// Colors: every ID in its own colour, the selected one highlighted white.
0 => {
let c = if hit && sel != 0.0 { [1.0; 3] } else { id_color(id) };
[c[0], c[1], c[2], 1.0]
}
1 => {
let v = if hit { 1.0 } else { 0.0 };
[v, v, v, 1.0]
}
2 => {
let s = src.data[i];
if hit { s } else { [0.0; 4] }
}
_ => {
let v = id / max_id;
[v, v, v, 1.0]
}
};
});
b
}
// ---------------------------------------------------------------- Cryptomatte
/// Does `name` match a selection pattern (`*` wildcards, case-sensitive like the spec)?
fn wild(pat: &str, name: &str) -> bool {
match pat.split_once('*') {
None => pat == name,
Some((pre, rest)) => {
let Some(tail) = name.strip_prefix(pre) else { return false };
if rest.is_empty() {
return true;
}
(0..=tail.len()).filter(|&k| tail.is_char_boundary(k)).any(|k| wild(rest, &tail[k..]))
}
}
}
/// Parse a selection: comma-separated names (optionally quoted) or `<hex>` hashes / wildcards.
pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec<u32> {
let mut out = vec![];
for tok in sel.split(',') {
let t = tok.trim().trim_matches('"').trim_matches('\'');
if t.is_empty() {
continue;
}
if t.contains('*') {
if let Some(m) = manifest {
out.extend(m.iter().filter(|(n, _)| wild(t, n)).map(|(_, h)| *h));
}
continue;
}
if let Some(hex) = t.strip_prefix("<").and_then(|r| r.strip_suffix(">"))
&& let Ok(h) = u32::from_str_radix(hex, 16)
{
out.push(h);
continue;
}
out.push(manifest.and_then(|m| m.iter().find(|(n, _)| n == t).map(|(_, h)| *h)).unwrap_or_else(|| crypto_hash(t)));
}
out
}
pub const CRYPTO_LAYERS: [&str; 3] = ["CryptoObject", "CryptoMaterial", "CryptoAsset"];
fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf {
let Some(a) = aux(ctx) else { return b };
let layers = a.crypto_layers();
if layers.is_empty() {
return b;
}
let want = CRYPTO_LAYERS[(ctx.params.e("layer") as usize).min(2)];
let layer = layers.iter().find(|l| l.as_str() == want || l.ends_with(want)).unwrap_or(&layers[0]).clone();
let manifest = a.manifests.iter().find(|(l, _)| *l == layer).map(|(_, m)| m.as_slice());
let sel = selection_hashes(ctx.params.s("selection"), manifest);
let idx = index_map(&b, &a);
let display = ctx.params.e("display");
let matte_only = ctx.params.b("matteOnly");
let src = b.img.clone();
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let ranks = idx[i].map(|k| a.crypto_ranks(&layer, k)).unwrap_or_default();
let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::<f32>().clamp(0.0, 1.0);
if matte_only {
*px = [m, m, m, m];
return;
}
*px = match display {
// Colors: coverage-weighted ID colours (the spec's preview), selection brighter.
0 => {
let mut c = [0.0f32; 3];
for (id, cov) in &ranks {
let k = id_color(*id);
for j in 0..3 {
c[j] += k[j] * cov;
}
}
let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m);
[c[0], c[1], c[2], 1.0]
}
// Matte: the layer keyed by the selection.
1 => src.data[i].map(|v| v * m),
// Preview: the layer with the selection tinted.
_ => {
let s = src.data[i];
[s[0] * (1.0 - 0.5 * m) + 0.5 * m * s[3], s[1] * (1.0 - 0.5 * m) + 0.5 * m * s[3], s[2] * (1.0 - 0.5 * m), s[3]]
}
};
});
b
}
// ---------------------------------------------------------------- EXtractoR
fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf {
let a = aux(ctx);
let names = [ctx.params.s("red"), ctx.params.s("green"), ctx.params.s("blue"), ctx.params.s("alpha")];
let (bp, wp) = (ctx.params.f("blackPoint") as f32, ctx.params.f("whitePoint") as f32);
let unmult = ctx.params.b("unMult");
let clip = ctx.params.b("clip");
let idx = a.as_ref().map(|a| index_map(&b, a));
// Each output channel: an aux channel by name, or the layer's own R/G/B/A.
let own = |n: &str| match n.trim().to_ascii_uppercase().as_str() {
"R" | "RED" => Some(0),
"G" | "GREEN" => Some(1),
"B" | "BLUE" => Some(2),
"A" | "ALPHA" => Some(3),
"" => None,
_ => None,
};
let src_ch: Vec<Option<&[f32]>> = names.iter().map(|n| a.as_ref().and_then(|a| if n.trim().is_empty() { None } else { a.get(n.trim()) })).collect();
if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(n) == Some(k)) {
return b;
}
let src = b.img.clone();
let lv = |v: f32| {
let o = (v - bp) / (wp - bp).abs().max(1e-9) * (wp - bp).signum();
if clip { o.clamp(0.0, 1.0) } else { o }
};
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let (sc, sa) = unpremul(src.data[i]);
let straight = [sc[0], sc[1], sc[2], sa];
let mut o: Px = [0.0; 4];
for k in 0..4 {
o[k] = match (src_ch[k], idx.as_ref().and_then(|m| m[i])) {
(Some(ch), Some(j)) => ch[j],
(Some(_), None) => 0.0,
(None, _) => match own(names[k]) {
Some(c) => straight[c],
None => {
if k == 3 {
1.0
} else {
0.0
}
}
},
};
if k < 3 {
o[k] = lv(o[k]);
}
}
let al = o[3].clamp(0.0, 1.0);
let c = if unmult && al > 1e-6 { [o[0] / al, o[1] / al, o[2] / al] } else { [o[0], o[1], o[2]] };
*px = premul(c, al);
});
b
}
pub fn specs() -> Vec<EffectSpec> {
let text = |id: &'static str, name: &'static str, d: &str| p(id, name, Value::Str(d.into()), ParamUi::Text);
vec![
spec(
"ec.3d.channelextract",
"3D Channel Extract",
vec![
p("channel", "3D Channel", Value::Enum(0), popup(&EXTRACT_CHANNELS)),
p("blackPoint", "Black Point", num(0.0), slider(-1.0e6, 1.0e6, -10000.0, 10000.0, 2)),
p("whitePoint", "White Point", num(10000.0), slider(-1.0e6, 1.0e6, -10000.0, 10000.0, 2)),
],
channel_extract,
),
spec(
"ec.3d.cryptomatte",
"Cryptomatte",
vec![
p("layer", "Layer", Value::Enum(0), popup(&CRYPTO_LAYERS)),
text("selection", "Selection", ""),
p("display", "Display", Value::Enum(2), popup(&["Colors", "Matte", "Preview"])),
p("matteOnly", "Matte Only", Value::Bool(false), ParamUi::Checkbox),
],
cryptomatte,
),
spec(
"ec.3d.depthmatte",
"Depth Matte",
vec![
p("depth", "Depth", num(0.0), slider(-1.0e6, 1.0e6, -10000.0, 10000.0, 2)),
p("feather", "Feather", num(0.0), slider(0.0, 1.0e6, 0.0, 1000.0, 2)),
p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox),
],
depth_matte,
),
spec(
"ec.3d.depthoffield",
"Depth of Field",
vec![
p("focalPlane", "Focal Plane", num(0.0), slider(-1.0e6, 1.0e6, 0.0, 10000.0, 2)),
p("maximumRadius", "Maximum Radius", num(0.0), slider(0.0, 500.0, 0.0, 50.0, 2)),
p("focalPlaneThickness", "Focal Plane Thickness", num(0.0), slider(0.0, 1.0e6, 0.0, 1000.0, 2)),
p("focalBias", "Focal Bias", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 2)),
],
depth_of_field,
),
spec(
"ec.3d.extractor",
"EXtractoR",
vec![
text("red", "Red", "R"),
text("green", "Green", "G"),
text("blue", "Blue", "B"),
text("alpha", "Alpha", "A"),
p("blackPoint", "Black Point", num(0.0), slider(-1.0e6, 1.0e6, -1.0, 2.0, 3)),
p("whitePoint", "White Point", num(1.0), slider(-1.0e6, 1.0e6, -1.0, 2.0, 3)),
p("unMult", "UnMult", Value::Bool(false), ParamUi::Checkbox),
p("clip", "Clip", Value::Bool(false), ParamUi::Checkbox),
],
extractor,
),
spec(
"ec.3d.fog3d",
"Fog 3D",
vec![
p("fogColor", "Fog Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("fogStartDepth", "Fog Start Depth", num(0.0), slider(-1.0e6, 1.0e6, 0.0, 10000.0, 2)),
p("fogEndDepth", "Fog End Depth", num(3000.0), slider(-1.0e6, 1.0e6, 0.0, 10000.0, 2)),
p("fogOpacity", "Fog Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("scatteringDensity", "Scattering Density", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("foggyBackground", "Foggy Background", Value::Bool(true), ParamUi::Checkbox),
p("gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer),
p("layerContribution", "Layer Contribution", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
fog_3d,
),
spec(
"ec.3d.idmatte",
"ID Matte",
vec![
p("auxChannel", "Aux. Channel", Value::Enum(0), popup(&["Object ID", "Material ID"])),
p("idSelection", "ID Selection", num(0.0), slider(0.0, 65535.0, 0.0, 100.0, 0)),
p("feather", "Feather", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox),
p("useCoverage", "Use Coverage", Value::Bool(false), ParamUi::Checkbox),
],
id_matte,
),
spec(
"ec.3d.identifier",
"IDentifier",
vec![
p("channelType", "Channel Type", Value::Enum(0), popup(&["Object ID", "Material ID"])),
p("display", "Display", Value::Enum(0), popup(&["Colors", "Luma Matte", "Alpha Matte", "Raw"])),
p("id", "ID", num(0.0), slider(0.0, 65535.0, 0.0, 100.0, 0)),
],
identifier,
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, EffectHost, LayerPixels, run_fx};
use effectcraft_raster::channels3d::crypto_float;
/// A 20×10 layer: left half object 1 at depth 100, right half object 2 at depth 500; the
/// top-right corner is background. Cryptomatte: "left" / "right".
struct AuxHost(Arc<AuxChannels>);
impl EffectHost for AuxHost {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn aux(&self) -> Option<Arc<AuxChannels>> {
Some(self.0.clone())
}
}
fn scene() -> AuxHost {
let (w, h) = (20usize, 10usize);
let mut a = AuxChannels::new(w as u32, h as u32, 1.0);
let (mut z, mut id, mut r, mut g) = (vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h]);
let (hl, hr) = (crypto_hash("left"), crypto_hash("right"));
for y in 0..h {
for x in 0..w {
let i = y * w + x;
if x >= 15 && y < 3 {
z[i] = BACKGROUND_DEPTH;
} else if x < 10 {
z[i] = 100.0;
id[i] = 1.0;
r[i] = crypto_float(hl);
g[i] = 1.0;
} else {
z[i] = 500.0;
id[i] = 2.0;
r[i] = crypto_float(hr);
g[i] = 1.0;
}
}
}
a.channels = vec![("Z".into(), z), ("ObjectID".into(), id), ("CryptoObject00.R".into(), r), ("CryptoObject00.G".into(), g)];
a.manifests = vec![("CryptoObject".into(), vec![("left".into(), hl), ("right".into(), hr)])];
AuxHost(Arc::new(a))
}
fn img() -> Image {
Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0])
}
fn run(id: &str, vals: &[(&str, Value)], host: &AuxHost) -> Image {
run_fx(id, vals, img(), 0.0, EffectEnv { host: Some(host), ..Default::default() }).img
}
#[test]
fn passes_through_without_aux_channels() {
for s in specs() {
let out = run_fx(s.id, &[], img(), 0.0, EffectEnv::default());
assert_eq!(out.img.data, img().data, "{}", s.id);
}
}
#[test]
fn depth_matte_thresholds() {
let h = scene();
let o = run("ec.3d.depthmatte", &[("depth", num(300.0))], &h);
assert_eq!(o.get(2, 5)[3], 0.0);
assert_eq!(o.get(12, 5)[3], 1.0);
let inv = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("invert", Value::Bool(true))], &h);
assert_eq!(inv.get(2, 5)[3], 1.0);
assert_eq!(inv.get(12, 5)[3], 0.0);
// Feather: a ramp of 800 units centred on 300 → depth 100 at 0.25, 500 at 0.75.
let f = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("feather", num(800.0))], &h);
assert!((f.get(2, 5)[3] - 0.25).abs() < 1e-5 && (f.get(12, 5)[3] - 0.75).abs() < 1e-5);
}
#[test]
fn channel_extract_maps_depth_and_ids() {
let h = scene();
let o = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0))], &h);
assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6 && (o.get(12, 5)[0] - 0.5).abs() < 1e-6);
assert_eq!(o.get(17, 1)[0], 1.0);
let ids = run("ec.3d.channelextract", &[("channel", Value::Enum(1)), ("whitePoint", num(2.0))], &h);
assert_eq!(ids.get(2, 5)[0], 0.5);
}
#[test]
fn id_matte_and_identifier() {
let h = scene();
let o = run("ec.3d.idmatte", &[("idSelection", num(2.0))], &h);
assert_eq!(o.get(2, 5)[3], 0.0);
assert_eq!(o.get(12, 5)[3], 1.0);
let l = run("ec.3d.identifier", &[("display", Value::Enum(1)), ("id", num(1.0))], &h);
assert_eq!(l.get(2, 5)[0], 1.0);
assert_eq!(l.get(12, 5)[0], 0.0);
let c = run("ec.3d.identifier", &[], &h);
assert_ne!(c.get(2, 5), c.get(12, 5));
}
#[test]
fn cryptomatte_selects_by_name_and_wildcard() {
let h = scene();
let m = run("ec.3d.cryptomatte", &[("selection", Value::Str("right".into())), ("matteOnly", Value::Bool(true))], &h);
assert_eq!(m.get(2, 5)[3], 0.0);
assert_eq!(m.get(12, 5)[3], 1.0);
let w = run("ec.3d.cryptomatte", &[("selection", Value::Str("l*".into())), ("display", Value::Enum(1))], &h);
assert_eq!(w.get(2, 5)[3], 1.0);
assert_eq!(w.get(12, 5)[3], 0.0);
assert!(wild("*ight", "right") && wild("r*t", "right") && !wild("x*", "right"));
let hex = format!("<{:08x}>", crypto_hash("left"));
assert_eq!(selection_hashes(&hex, None), vec![crypto_hash("left")]);
}
#[test]
fn fog_and_depth_of_field_follow_depth() {
let h = scene();
let f = run("ec.3d.fog3d", &[("fogColor", col(1.0, 0.0, 0.0)), ("fogEndDepth", num(1000.0)), ("scatteringDensity", num(0.0))], &h);
// Linear fog: 10 % at depth 100, 50 % at 500, full on the background.
assert!((f.get(2, 5)[0] - 0.55).abs() < 1e-4, "{:?}", f.get(2, 5));
assert!((f.get(12, 5)[0] - 0.75).abs() < 1e-4);
assert!((f.get(17, 1)[0] - 1.0).abs() < 1e-4 && f.get(17, 1)[1] < 1e-4);
assert!((dof_radius(100.0, 100.0, 0.0, 10.0, 50.0)).abs() < 1e-6);
assert!((dof_radius(200.0, 100.0, 0.0, 10.0, 50.0) - 10.0).abs() < 1e-6);
assert_eq!(dof_radius(140.0, 100.0, 100.0, 10.0, 50.0), 0.0);
// A sharp edge at the far object blurs when focused near.
let mut pic = Image::new(20, 10);
for y in 0..10 {
for x in 0..20 {
let v = if x % 2 == 0 { 1.0 } else { 0.0 };
pic.set(x, y, [v, v, v, 1.0]);
}
}
let env = EffectEnv { host: Some(&h), ..Default::default() };
let d = run_fx("ec.3d.depthoffield", &[("focalPlane", num(100.0)), ("maximumRadius", num(4.0))], pic.clone(), 0.0, env).img;
assert_eq!(d.get(4, 5), pic.get(4, 5));
assert!((d.get(12, 5)[0] - 0.5).abs() < 0.3 && d.get(12, 5) != pic.get(12, 5));
}
#[test]
fn extractor_maps_named_channels() {
let h = scene();
let o = run("ec.3d.extractor", &[("red", Value::Str("Z".into())), ("whitePoint", num(1000.0))], &h);
assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6);
assert!((o.get(2, 5)[1] - 0.0005).abs() < 1e-6);
// Without aux: the layer's own channels can be swapped.
let s = run_fx("ec.3d.extractor", &[("red", Value::Str("A".into()))], Image::filled(2, 2, [0.2, 0.3, 0.4, 1.0]), 0.0, EffectEnv::default());
assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]);
}
}