Files
effectcraft/crates/effects/src/channel3d.rs
T
vangel76andClaude Opus 5.5 3b7f1ace28 #473: Cryptomatte Selection keeps quoted names with commas whole
Problem: selection_hashes split the Selection on every comma before
stripping quotes, so a name containing a comma (Blender allows a material
called `Green, Glossy`) was cut into two names that match nothing, quoted
or not; only its `<hex>` hash selected it.

Fix: a small tokenizer splits at commas outside quotes. A name in double
or single quotes stays whole, a backslash takes the next character
literally (`Green\, Glossy`), and unquoted lists, wildcards and `<hex>`
hashes parse as before. The CPU effect and the GPU path share the function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:25:24 +02:00

778 lines
34 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>> {
index_map_at(b, a, [0.5, 0.5])
}
/// [`index_map`] sampling each pixel at sub-pixel position `at` (0..1).
fn index_map_at(b: &Buf, a: &AuxChannels, at: [f64; 2]) -> 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 + at[0], (i / w) as f64 + at[1]);
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).
pub 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 };
if ctx.params.b("antialias") {
// Anti-alias: average four sub-pixel samples of the channel.
let subs = [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]];
let imgs: Vec<Image> = subs.iter().map(|s| channel_extract_at(ctx, &b, &a, &index_map_at(&b, &a, *s))).collect::<Option<Vec<_>>>().unwrap_or_default();
if imgs.len() == 4 {
let mut out = imgs[0].clone();
out.data.par_iter_mut().enumerate().for_each(|(i, p)| {
*p = std::array::from_fn(|c| imgs.iter().map(|m| m.data[i][c]).sum::<f32>() * 0.25);
});
b.img = out;
}
return b;
}
let idx = index_map(&b, &a);
if let Some(i) = channel_extract_at(ctx, &b, &a, &idx) {
b.img = i;
}
b
}
/// 3D Channel Extract through one index map (None when the channel is missing).
fn channel_extract_at(ctx: &EffectCtx, b: &Buf, a: &AuxChannels, idx: &[Option<usize>]) -> Option<Image> {
let (bp, wp) = (ctx.params.f("blackPoint") as f32, ctx.params.f("whitePoint") as f32);
// Clamp Output limits the mapped values to 0..1; Invert Depth Map flips them.
let (clamp, invert) = (ctx.params.b("clampOutput"), ctx.params.b("invertDepthMap"));
let cl = move |v: f32| if clamp { v.clamp(0.0, 1.0) } else { v };
let lv = |v: f32| cl((v - bp) / (wp - bp).abs().max(1e-9) * (wp - bp).signum());
let lv = |v: f32| if wp >= bp { lv(v) } else { 1.0 - cl((v - wp) / (bp - wp).max(1e-9)) };
let lv = |v: f32| if invert { 1.0 - lv(v) } else { lv(v) };
let get3 = |names: [&[&str]; 3]| -> Option<[&[f32]; 3]> { Some([a.find(names[0])?, a.find(names[1])?, a.find(names[2])?]) };
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]])),
}
}
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..]))
}
}
}
/// Split a selection at its commas. A name in double or single quotes stays whole, commas
/// included (Blender allows `Green, Glossy`), and a backslash takes the next character
/// literally (`Green\, Glossy`, `\"`). Unquoted text is trimmed; the quotes are dropped.
fn selection_names(sel: &str) -> Vec<String> {
let mut out = vec![];
let mut cur = String::new();
let mut quote: Option<char> = None;
let mut chars = sel.chars();
while let Some(c) = chars.next() {
match (c, quote) {
('\\', _) => {
if let Some(next) = chars.next() {
cur.push(next);
}
}
(c, Some(q)) if c == q => quote = None,
('"' | '\'', None) if cur.trim().is_empty() => {
cur.clear();
quote = Some(c);
}
(',', None) => out.push(std::mem::take(&mut cur).trim().to_string()),
(c, _) => cur.push(c),
}
}
out.push(cur.trim().to_string());
out
}
/// Parse a selection: comma-separated names (optionally quoted, see [`selection_names`]) or
/// `<hex>` hashes / wildcards.
pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec<u32> {
let mut out = vec![];
for t in selection_names(sel) {
let t = t.as_str();
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)),
p("clampOutput", "Clamp Output", Value::Bool(true), ParamUi::Checkbox),
p("invertDepthMap", "Invert Depth Map", Value::Bool(false), ParamUi::Checkbox),
p("antialias", "Anti-alias", Value::Bool(false), ParamUi::Checkbox),
],
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);
// Invert Depth Map flips the ramp; without Clamp Output values run past 1.
let inv = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0)), ("invertDepthMap", Value::Bool(true))], &h);
assert!((inv.get(2, 5)[0] - 0.9).abs() < 1e-6);
let raw = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(100.0)), ("clampOutput", Value::Bool(false))], &h);
assert!(raw.get(12, 5)[0] > 1.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")]);
}
/// #473: a name with a comma in it (Blender allows `Green, Glossy`) is selected when it is
/// quoted or its comma escaped, instead of being cut into two names that match nothing.
#[test]
fn cryptomatte_selection_keeps_quoted_names_with_commas_whole() {
let manifest = vec![("Green, Glossy".to_string(), 7), ("Green".to_string(), 1), ("Glossy".to_string(), 2), ("ring".to_string(), 3)];
let m = Some(manifest.as_slice());
assert_eq!(selection_hashes(r#""Green, Glossy""#, m), vec![7]);
assert_eq!(selection_hashes("'Green, Glossy', ring", m), vec![7, 3]);
assert_eq!(selection_hashes(r"Green\, Glossy", m), vec![7]);
assert_eq!(selection_hashes(r#"ring , "Green, Glossy""#, m), vec![3, 7]);
// Without a manifest the whole name is hashed.
assert_eq!(selection_hashes(r#""Green, Glossy""#, None), vec![crypto_hash("Green, Glossy")]);
// Plain lists, apostrophes inside names and an unclosed quote still parse.
assert_eq!(selection_hashes("Green, Glossy", m), vec![1, 2]);
assert_eq!(selection_names("Bob's mat, ring"), vec!["Bob's mat", "ring"]);
assert_eq!(selection_names(r#""Green, Glossy"#), vec!["Green, Glossy"]);
assert_eq!(selection_names(r#"say \"hi\""#), vec![r#"say "hi""#]);
}
#[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]);
}
#[test]
fn channel_extract_antialias_averages_sub_pixels() {
// Depth at twice the layer resolution, alternating 0 / 1000 per aux column.
let mut a = AuxChannels::new(40, 20, 2.0);
a.channels = vec![("Z".into(), (0..800).map(|i| if i % 2 == 0 { 0.0 } else { 1000.0 }).collect())];
let host = AuxHost(Arc::new(a));
let vals = [("blackPoint", num(0.0)), ("whitePoint", num(1000.0))];
let hard = run("ec.3d.channelextract", &vals, &host);
let v = hard.get(5, 5)[0];
assert!(v == 0.0 || v == 1.0, "{v}");
let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host);
assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5));
}
}