M13.23: GPU 3D Channel effects (aux channels as textures)

3D Channel Extract, Cryptomatte, Depth Matte, Depth of Field, EXtractoR, Fog 3D,
ID Matte and IDentifier run on the GPU (fx_depth.rs / fx_depth.wgsl): aux planes
upload as a texture and are resampled onto the buffer grid with the CPU's index
arithmetic; tests compare against the CPU at 1x / 2x aux resolution, padded
buffers, half resolution and 8 / 32 bpc composites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 17:33:41 -04:00
co-authored by Claude Opus 5.5
parent 081ce79de3
commit 6b91afc087
9 changed files with 932 additions and 10 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ fn plane_of(b: &Buf, idx: &[Option<usize>], ch: &[f32], fill: f32) -> Plane {
}
/// Distinct colour for an ID (hash → hue).
fn id_color(id: f32) -> [f32; 3] {
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] }
+11
View File
@@ -97,6 +97,8 @@ pub use noise3::{FractalGpu, fractal_gpu};
pub use time_fx::{posterized_time, time_frames};
// effectcraft-gpu fx_tone.
pub use ocio::color_stabilizer_maps;
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
pub use channel3d::{CRYPTO_LAYERS, id_color, selection_hashes};
/// Effect categories in Effects & Presets order.
pub const CATEGORIES: &[&str] = &[
@@ -931,6 +933,15 @@ pub const GPU_EFFECTS: &[&str] = &[
"ec.perspective.bevelalpha",
"ec.perspective.beveledges",
"ec.obsolete.gaussianlegacy",
// GPU effects, part B: 3D Channel.
"ec.3d.channelextract",
"ec.3d.cryptomatte",
"ec.3d.depthmatte",
"ec.3d.depthoffield",
"ec.3d.extractor",
"ec.3d.fog3d",
"ec.3d.idmatte",
"ec.3d.identifier",
];
/// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated
+2
View File
@@ -314,6 +314,7 @@ impl GpuContext {
include_str!("shaders/fx_tone.wgsl"),
include_str!("shaders/fx_warp.wgsl"),
include_str!("shaders/fx_extra.wgsl"),
include_str!("shaders/fx_depth.wgsl"),
]
.concat();
let module =
@@ -373,6 +374,7 @@ impl GpuContext {
.chain(crate::fx_tone::KERNELS)
.chain(crate::fx_warp::KERNELS)
.chain(crate::fx_extra::KERNELS)
.chain(crate::fx_depth::KERNELS)
.map(|e| (e, &layout))
.chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext)))
.map(|(e, layout)| {
+1
View File
@@ -133,6 +133,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
_ if crate::fx_tone::IDS.contains(&id) => crate::fx_tone::apply(e, id, ctx, b),
_ if crate::fx_warp::IDS.contains(&id) => crate::fx_warp::apply(e, id, ctx, b),
_ if crate::fx_extra::IDS.contains(&id) => crate::fx_extra::apply(e, id, ctx, b),
_ if crate::fx_depth::IDS.contains(&id) => crate::fx_depth::apply(e, id, ctx, b),
_ => pointwise(e, id, ctx, b),
}
}
+335
View File
@@ -0,0 +1,335 @@
//! GPU effects, 3D Channel family (kernels in `shaders/fx_depth.wgsl`, every entry point
//! prefixed `fxd_`): 3D Channel Extract, Cryptomatte, Depth Matte, Depth of Field, EXtractoR,
//! Fog 3D, ID Matte and IDentifier.
//!
//! The layer's auxiliary channels ([`effectcraft_raster::AuxChannels`]) go to the GPU as an
//! extra texture at the aux resolution: the planes an effect reads are packed into its four
//! channels (alpha = 1 marks "inside the aux image"). `fxd_lookup` resamples it nearest-neighbour
//! onto the buffer grid with the CPU's exact index arithmetic (layer position × aux scale,
//! floored), and the effect kernels work on that buffer-sized plane. Cryptomatte packs a
//! per-aux-pixel reduction of its rank layers (selection coverage and the coverage-weighted ID
//! colours, measured on the CPU) instead of the raw ranks. Fog 3D's Gradient Layer is rendered
//! by the host on the CPU (it is another layer) and uploaded.
use effectcraft_effects::{Buf, EffectCtx};
use effectcraft_keyframe::Value;
use effectcraft_raster::channels3d::BACKGROUND_DEPTH;
use effectcraft_raster::{AuxChannels, Image};
use crate::context::{Enc, GpuImage, Params};
use crate::effects::{GBuf, gaussian_blur};
/// Compute entry points in `fx_depth.wgsl`.
pub(crate) const KERNELS: &[&str] = &["fxd_lookup", "fxd_point", "fxd_dof_f", "fxd_dof_level", "fxd_avg"];
/// Effect ids implemented here.
pub(crate) const IDS: &[&str] = &[
"ec.3d.channelextract",
"ec.3d.cryptomatte",
"ec.3d.depthmatte",
"ec.3d.depthoffield",
"ec.3d.extractor",
"ec.3d.fog3d",
"ec.3d.idmatte",
"ec.3d.identifier",
];
/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU.
pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let aux = ctx.env.host.and_then(|h| h.aux());
if id == "ec.3d.extractor" {
return extractor(e, ctx, b, aux.as_deref());
}
let Some(a) = aux else { return Some(b) };
match id {
"ec.3d.channelextract" => channel_extract(e, ctx, b, &a),
"ec.3d.cryptomatte" => cryptomatte(e, ctx, b, &a),
"ec.3d.depthmatte" => depth_matte(e, ctx, b, &a),
"ec.3d.depthoffield" => depth_of_field(e, ctx, b, &a),
"ec.3d.fog3d" => fog(e, ctx, b, &a),
"ec.3d.idmatte" => id_matte(e, ctx, b, &a),
"ec.3d.identifier" => identifier(e, ctx, b, &a),
_ => None,
}
}
fn op(code: u32) -> Params {
let mut p = Params::default();
p.u[0][0] = code;
p
}
/// Pack up to four aux planes (aux resolution; `None` planes are 0, alpha defaults to 1 = inside)
/// and upload them.
fn aux_texture(e: &mut Enc, a: &AuxChannels, planes: [Option<&[f32]>; 4]) -> Option<GpuImage> {
let n = (a.width * a.height) as usize;
if planes.iter().flatten().any(|p| p.len() < n) {
return None;
}
let mut img = Image::new(a.width, a.height);
for (i, px) in img.data.iter_mut().enumerate() {
*px = [0, 1, 2, 3].map(|k| planes[k].map_or(if k == 3 { 1.0 } else { 0.0 }, |p| p[i]));
}
e.g.upload_image(&img)
}
/// Resample an aux texture onto the buffer grid (nearest; `fill` outside the aux image),
/// sampling each buffer pixel at sub-pixel position `at`.
fn lookup(e: &mut Enc, b: &GBuf, a: &AuxChannels, tex: &GpuImage, at: [f64; 2], fill: [f32; 4]) -> GpuImage {
let inv = 1.0 / b.scale.max(1e-9);
let mut p = Params::default();
p.f[0] = [b.offset[0] as f32, b.offset[1] as f32, inv as f32, a.scale as f32];
p.f[1] = [at[0] as f32, at[1] as f32, 0.0, 0.0];
p.f[2] = fill;
let out = e.scratch(b.img.width, b.img.height);
e.pixels("fxd_lookup", &p, tex, None, &out, None);
out
}
fn point(e: &mut Enc, p: &Params, b: GBuf, plane: &GpuImage) -> Option<GBuf> {
let out = e.scratch(b.img.width, b.img.height);
e.pixels("fxd_point", p, &b.img, Some(plane), &out, None);
Some(GBuf { img: out, ..b })
}
// ---------------------------------------------------------------- 3D Channel Extract
fn channel_extract(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option<GBuf> {
let pr = ctx.params;
let get3 = |n: [&[&str]; 3]| -> Option<[&[f32]; 3]> { Some([a.find(n[0])?, a.find(n[1])?, a.find(n[2])?]) };
let u = ["UV.U", "U", "uv.x"];
let v = ["UV.V", "V", "uv.y"];
// (planes, mode): mode 0 grey (lv), 1 UV (lv on R, G; B = 0), 2 normals (lv of ×0.5 + 0.5),
// 3 RGB (lv), 4 unclamped RGB (no mapping).
let (planes, mode): ([&[f32]; 3], u32) = match pr.e("channel") {
0 => (a.depth().map(|z| [z, z, z])?, 0),
1 => (a.object_id().map(|c| [c, c, c])?, 0),
7 => (a.material_id().map(|c| [c, c, c])?, 0),
4 => (a.coverage().map(|c| [c, c, c])?, 0),
2 => (get3([&u, &v, &["UV.W", "W", "uv.z"]]).or_else(|| Some([a.find(&u)?, a.find(&v)?, a.find(&v)?]))?, 1),
3 => (get3([&["N.X", "Normal.X", "normals.x"], &["N.Y", "Normal.Y", "normals.y"], &["N.Z", "Normal.Z", "normals.z"]])?, 2),
5 => (get3([&["BG.R", "Background.R"], &["BG.G", "Background.G"], &["BG.B", "Background.B"]])?, 3),
_ => (get3([&["R"], &["G"], &["B"]])?, 4),
};
let tex = aux_texture(e, a, [Some(planes[0]), Some(planes[1]), Some(planes[2]), None])?;
let mut p = op(0);
p.u[0] = [0, mode, pr.b("clampOutput") as u32, pr.b("invertDepthMap") as u32];
p.f[0] = [pr.f("blackPoint") as f32, pr.f("whitePoint") as f32, 0.0, 0.0];
if !pr.b("antialias") {
let plane = lookup(e, &b, a, &tex, [0.5, 0.5], [0.0; 4]);
return point(e, &p, b, &plane);
}
// Anti-alias: four sub-pixel samples, summed in order and scaled once (as the CPU does).
let (w, h) = (b.img.width, b.img.height);
let mut acc: Option<GpuImage> = None;
for s in [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]] {
let plane = lookup(e, &b, a, &tex, s, [0.0; 4]);
let one = e.scratch(w, h);
e.pixels("fxd_point", &p, &b.img, Some(&plane), &one, None);
acc = Some(match acc {
None => one,
Some(prev) => {
let out = e.scratch(w, h);
e.pixels("fxd_avg", &op(0), &prev, Some(&one), &out, None);
out
}
});
}
let acc = acc?;
let out = e.scratch(w, h);
e.pixels("fxd_avg", &op(1), &acc, None, &out, None);
Some(GBuf { img: out, ..b })
}
// ---------------------------------------------------------------- Depth Matte / Fog 3D
fn depth_plane(e: &mut Enc, b: &GBuf, a: &AuxChannels) -> Option<GpuImage> {
let z = a.depth()?;
let tex = aux_texture(e, a, [Some(z), None, None, None])?;
Some(lookup(e, b, a, &tex, [0.5, 0.5], [BACKGROUND_DEPTH, 0.0, 0.0, 0.0]))
}
fn depth_matte(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option<GBuf> {
let Some(plane) = depth_plane(e, &b, a) else { return if a.depth().is_none() { Some(b) } else { None } };
let mut p = op(1);
p.u[0][1] = ctx.params.b("invert") as u32;
p.f[0] = [ctx.params.f("depth") as f32, ctx.params.f("feather").max(0.0) as f32, 0.0, 0.0];
point(e, &p, b, &plane)
}
fn fog(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option<GBuf> {
let pr = ctx.params;
let Some(plane) = depth_plane(e, &b, a) else { return if a.depth().is_none() { Some(b) } else { None } };
let contrib = (pr.f("layerContribution") / 100.0) as f32;
let grad = if contrib > 0.0 && matches!(pr.get("gradientLayer"), Some(Value::Layer(Some(_)))) {
ctx.layer_param("gradientLayer", true).map(|o| {
let cpu = Buf { img: Image::new(b.img.width, b.img.height), offset: b.offset, scale: b.scale };
effectcraft_effects::util::fit_layer(ctx, &cpu, &o, true)
})
} else {
None
};
let grad = match grad {
Some(g) => Some(e.g.upload_image(&g)?),
None => None,
};
let fc = pr.color("fogColor");
let mut p = op(2);
p.u[0] = [2, pr.b("foggyBackground") as u32, grad.is_some() as u32, 0];
p.f[0] = [pr.f("fogStartDepth") as f32, pr.f("fogEndDepth") as f32, (pr.f("fogOpacity") / 100.0) as f32, (pr.f("scatteringDensity") / 100.0) as f32];
p.f[1] = [fc[0], fc[1], fc[2], contrib];
p.f[2][0] = BACKGROUND_DEPTH;
let Some(g) = grad else { return point(e, &p, b, &plane) };
// The gradient's factor joins the depth plane (G) first: kernels read two images.
let mut q = op(3);
q.f[0][0] = contrib;
let merged = e.scratch(b.img.width, b.img.height);
e.pixels("fxd_point", &q, &g, Some(&plane), &merged, None);
point(e, &p, b, &merged)
}
// ---------------------------------------------------------------- Depth of Field
fn depth_of_field(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option<GBuf> {
let pr = ctx.params;
if a.depth().is_none() {
return Some(b);
}
let max_r = (pr.f("maximumRadius").max(0.0) * b.scale) as f32;
if max_r <= 0.0 {
return Some(b);
}
let plane = depth_plane(e, &b, a)?;
const LEVELS: usize = 5;
let (w, h) = (b.img.width, b.img.height);
let stack: Vec<GpuImage> = (0..LEVELS)
.map(|k| {
let r = max_r * k as f32 / (LEVELS - 1) as f32;
if r < 0.3 { b.img.clone() } else { gaussian_blur(e, &b.img, (r / 2.0) as f64, (r / 2.0) as f64, true) }
})
.collect();
// Per-pixel stack position, then each level pair writes its own pixels.
let mut p = Params::default();
let gamma = 2f32.powf((50.0 - pr.f("focalBias") as f32) / 25.0);
p.f[0] = [pr.f("focalPlane") as f32, pr.f("focalPlaneThickness").max(0.0) as f32, max_r, gamma];
p.u[0][0] = LEVELS as u32;
let fpl = e.scratch(w, h);
e.pixels("fxd_dof_f", &p, &plane, None, &fpl, None);
let (rows, row_len) = e.image_rows(&fpl);
let out = e.scratch(w, h);
for k in 0..LEVELS - 1 {
let mut q = Params::default();
q.u[0] = [k as u32, row_len, LEVELS as u32, 0];
e.pixels("fxd_dof_level", &q, &stack[k], Some(&stack[k + 1]), &out, Some(&rows));
}
Some(GBuf { img: out, ..b })
}
// ---------------------------------------------------------------- ID Matte / IDentifier
fn id_plane(a: &AuxChannels, which: u32) -> Option<&[f32]> {
if which == 1 { a.material_id() } else { a.object_id() }
}
fn id_matte(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option<GBuf> {
let pr = ctx.params;
let Some(ids) = id_plane(a, pr.e("auxChannel")) else { return Some(b) };
let cov = if pr.b("useCoverage") { a.coverage() } else { None };
let tex = aux_texture(e, a, [Some(ids), cov, None, None])?;
let plane = lookup(e, &b, a, &tex, [0.5, 0.5], [0.0; 4]);
let (w, h) = (b.img.width, b.img.height);
let mut p = op(4);
p.u[0][1] = cov.is_some() as u32;
p.f[0][0] = pr.f("idSelection").round() as f32;
let mut m = e.scratch(w, h);
e.pixels("fxd_point", &p, &plane, None, &m, None);
let feather = pr.f("feather").max(0.0) * b.scale;
if feather > 0.0 {
m = gaussian_blur(e, &m, feather * 0.5, feather * 0.5, true);
}
let mut p = op(5);
p.u[0][1] = pr.b("invert") as u32;
point(e, &p, b, &m)
}
fn identifier(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option<GBuf> {
let pr = ctx.params;
let Some(ids) = id_plane(a, pr.e("channelType")) else { return Some(b) };
let max_id = ids.iter().cloned().fold(1.0f32, f32::max);
let tex = aux_texture(e, a, [Some(ids), None, None, None])?;
let plane = lookup(e, &b, a, &tex, [0.5, 0.5], [0.0; 4]);
let mut p = op(6);
p.u[0][1] = pr.e("display");
p.f[0] = [pr.f("id").round() as f32, max_id, 0.0, 0.0];
point(e, &p, b, &plane)
}
// ---------------------------------------------------------------- Cryptomatte
fn cryptomatte(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option<GBuf> {
let pr = ctx.params;
let layers = a.crypto_layers();
if layers.is_empty() {
return Some(b);
}
let want = effectcraft_effects::CRYPTO_LAYERS[(pr.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 = effectcraft_effects::selection_hashes(pr.s("selection"), manifest);
// Per aux pixel: coverage-weighted ID colours (RGB) and the selection's coverage (A).
let n = (a.width * a.height) as usize;
let mut img = Image::new(a.width, a.height);
for (i, px) in img.data.iter_mut().enumerate().take(n) {
let ranks = a.crypto_ranks(&layer, i);
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);
let mut c = [0.0f32; 3];
for (id, cov) in &ranks {
let k = effectcraft_effects::id_color(*id);
for j in 0..3 {
c[j] += k[j] * cov;
}
}
*px = [c[0], c[1], c[2], m];
}
let tex = e.g.upload_image(&img)?;
let plane = lookup(e, &b, a, &tex, [0.5, 0.5], [0.0; 4]);
let mut p = op(7);
p.u[0] = [7, pr.e("display"), pr.b("matteOnly") as u32, 0];
point(e, &p, b, &plane)
}
// ---------------------------------------------------------------- EXtractoR
fn extractor(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: Option<&AuxChannels>) -> Option<GBuf> {
let pr = ctx.params;
let names = [pr.s("red"), pr.s("green"), pr.s("blue"), pr.s("alpha")];
let (bp, wp) = (pr.f("blackPoint") as f32, pr.f("whitePoint") as f32);
let unmult = pr.b("unMult");
let own = |n: &str| match n.trim().to_ascii_uppercase().as_str() {
"R" | "RED" => Some(0u32),
"G" | "GREEN" => Some(1),
"B" | "BLUE" => Some(2),
"A" | "ALPHA" => Some(3),
_ => None,
};
let src_ch: Vec<Option<&[f32]>> = names.iter().map(|n| a.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 as u32)) {
return Some(b);
}
// Per output channel: 0..3 = the layer's own channel, 4 = aux, 5 = default (0, alpha 1).
let sources: [u32; 4] = std::array::from_fn(|k| if src_ch[k].is_some() { 4 } else { own(names[k]).unwrap_or(5) });
let plane = match a {
Some(a) if src_ch.iter().any(Option::is_some) => {
let tex = aux_texture(e, a, [src_ch[0], src_ch[1], src_ch[2], src_ch[3]])?;
Some(lookup(e, &b, a, &tex, [0.5, 0.5], [0.0; 4]))
}
_ => None,
};
let mut p = op(8);
p.u[0] = [8, unmult as u32, pr.b("clip") as u32, 0];
p.u[1] = sources;
p.f[0] = [bp, wp, 0.0, 0.0];
let out = e.scratch(b.img.width, b.img.height);
e.pixels("fxd_point", &p, &b.img, plane.as_ref(), &out, None);
Some(GBuf { img: out, ..b })
}
+3
View File
@@ -48,6 +48,7 @@ mod classic3d;
mod context;
mod effects;
mod fx_color;
mod fx_depth;
mod fx_distort;
mod fx_extra;
mod fx_generate;
@@ -201,6 +202,8 @@ mod tests_adv3d;
#[cfg(test)]
mod tests_fx_color;
#[cfg(test)]
mod tests_fx_depth;
#[cfg(test)]
mod tests_fx_distort;
#[cfg(test)]
mod tests_fx_extra;
+307
View File
@@ -0,0 +1,307 @@
// ---------------------------------------------------------------- 3D Channel family (fx_depth.rs)
//
// `aux` planes are buffer-sized resamplings of the layer's auxiliary channels (fxd_lookup); the
// alpha of a plane is 1 inside the aux image and 0 outside (where the effect's fill applies).
fn fxd_unpremul(p: vec4<f32>) -> vec4<f32> {
if (p.w > 1e-6) {
return vec4<f32>(p.xyz / p.w, p.w);
}
return vec4<f32>(0.0, 0.0, 0.0, max(p.w, 0.0));
}
// f32::signum (+0 → 1).
fn fxd_signum(v: f32) -> f32 {
return select(-1.0, 1.0, v >= 0.0);
}
// Nearest aux texel under each buffer pixel (AuxChannels::index_at), `fill` outside.
// f[0] = (offset x, offset y, 1 / buffer scale, aux scale); f[1].xy = sub-pixel position; f[2] = fill.
@compute @workgroup_size(16, 16)
fn fxd_lookup(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let lx = (f32(p.x) + P.f[1].x - P.f[0].x) * P.f[0].z;
let ly = (f32(p.y) + P.f[1].y - P.f[0].y) * P.f[0].z;
let ax = floor(lx * P.f[0].w);
let ay = floor(ly * P.f[0].w);
let ad = vec2<f32>(textureDimensions(src));
var v = P.f[2];
if (ax >= 0.0 && ay >= 0.0 && ax < ad.x && ay < ad.y) {
v = textureLoad(src, vec2<i32>(i32(ax), i32(ay)), 0);
}
textureStore(out, p, v);
}
// 3D Channel Extract's black / white point mapping.
fn fxd_lv(v: f32) -> f32 {
let bp = P.f[0].x;
let wp = P.f[0].y;
let clampo = P.u[0].z != 0u;
var o: f32;
if (wp >= bp) {
o = (v - bp) / max(abs(wp - bp), 1e-9) * fxd_signum(wp - bp);
if (clampo) {
o = clamp(o, 0.0, 1.0);
}
} else {
var q = (v - wp) / max(bp - wp, 1e-9);
if (clampo) {
q = clamp(q, 0.0, 1.0);
}
o = 1.0 - q;
}
if (P.u[0].w != 0u) {
o = 1.0 - o;
}
return o;
}
// channel3d::id_color.
fn fxd_id_color(id: f32) -> vec3<f32> {
if (id == 0.0) {
return vec3<f32>(0.0);
}
let h = hash_noise(bitcast<u32>(id), 0x1du, 0x5eedu);
return hsl_to_rgb(h, 0.75, 0.5);
}
// u[0].x = op; the effects' per-pixel steps (src = layer, aux = plane unless noted).
@compute @workgroup_size(16, 16)
fn fxd_point(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let px = textureLoad(src, p, 0);
let q = tex_get(aux, p.x, p.y);
var o = px;
switch (P.u[0].x) {
// 3D Channel Extract. u[0] = (op, mode, clamp, invert); f[0] = (black, white).
case 0u: {
if (q.w == 0.0) {
o = vec4<f32>(0.0);
} else {
var c = q.xyz;
switch (P.u[0].y) {
case 1u: {
c = vec3<f32>(fxd_lv(c.x), fxd_lv(c.y), 0.0);
}
case 2u: {
c = vec3<f32>(fxd_lv(c.x * 0.5 + 0.5), fxd_lv(c.y * 0.5 + 0.5), fxd_lv(c.z * 0.5 + 0.5));
}
case 4u: {}
default: {
c = vec3<f32>(fxd_lv(c.x), fxd_lv(c.y), fxd_lv(c.z));
}
}
o = vec4<f32>(c, 1.0);
}
}
// Depth Matte. u[0].y = invert; f[0] = (depth, feather).
case 1u: {
let zv = q.x;
let depth = P.f[0].x;
let feather = P.f[0].y;
var k: f32;
if (feather > 0.0) {
k = clamp((zv - depth) / feather + 0.5, 0.0, 1.0);
} else {
k = select(0.0, 1.0, zv >= depth);
}
if (P.u[0].y != 0u) {
k = 1.0 - k;
}
o = px * k;
}
// Fog 3D. u[0] = (op, foggy background, gradient in aux.y); f[0] = (start, end, opacity,
// density); f[1] = (fog colour, contribution); f[2].x = background depth.
case 2u: {
let zv = q.x;
let bg = zv >= P.f[2].x * 0.5;
let foggy = P.u[0].y != 0u;
var f: f32;
if (bg) {
f = select(0.0, 1.0, foggy);
} else {
let t = clamp((zv - P.f[0].x) / max(abs(P.f[0].y - P.f[0].x), 1e-6), 0.0, 1.0);
f = 1.0 - powz(1.0 - t, 1.0 + P.f[0].w * 3.0);
}
if (P.u[0].z != 0u) {
f *= q.y;
}
f = clamp(f * P.f[0].z, 0.0, 1.0);
let s = fxd_unpremul(px);
var al = s.w;
if (bg && foggy) {
al = s.w + (1.0 - s.w) * f;
}
let c = s.xyz + (P.f[1].xyz - s.xyz) * f;
o = vec4<f32>(c * al, al);
}
// Fog 3D gradient factor (src = fitted gradient layer, aux = depth plane). f[0].x = contribution.
case 3u: {
let g = fxd_unpremul(px);
let l = luminance(g.xyz) * g.w;
o = vec4<f32>(q.x, 1.0 - P.f[0].x + P.f[0].x * l, 0.0, 0.0);
}
// ID Matte's matte (src = plane). u[0].y = use coverage; f[0].x = ID.
case 4u: {
var m = 0.0;
if (px.w != 0.0 && round_away(px.x) == P.f[0].x) {
m = select(1.0, clamp(px.y, 0.0, 1.0), P.u[0].y != 0u);
}
o = vec4<f32>(m);
}
// ID Matte applied (aux = blurred matte). u[0].y = invert.
case 5u: {
var k = q.x;
if (P.u[0].y != 0u) {
k = 1.0 - k;
}
o = px * k;
}
// IDentifier. u[0].y = display; f[0] = (ID, largest ID).
case 6u: {
let display = P.u[0].y;
if (q.w == 0.0) {
if (display != 2u) {
o = vec4<f32>(0.0);
}
} else {
let id = round_away(q.x);
let sel = P.f[0].x;
let hit = id == sel;
switch (display) {
case 0u: {
var c = fxd_id_color(id);
if (hit && sel != 0.0) {
c = vec3<f32>(1.0);
}
o = vec4<f32>(c, 1.0);
}
case 1u: {
let v = select(0.0, 1.0, hit);
o = vec4<f32>(v, v, v, 1.0);
}
case 2u: {
o = select(vec4<f32>(0.0), px, hit);
}
default: {
let v = id / P.f[0].y;
o = vec4<f32>(v, v, v, 1.0);
}
}
}
}
// Cryptomatte (aux = (ID colours, selection coverage)). u[0] = (op, display, matte only).
case 7u: {
let m = q.w;
if (P.u[0].z != 0u) {
o = vec4<f32>(m);
} else if (P.u[0].y == 0u) {
o = vec4<f32>(q.xyz * (1.0 - 0.5 * m) + 0.5 * m, 1.0);
} else if (P.u[0].y == 1u) {
o = px * m;
} else {
o = vec4<f32>(px.x * (1.0 - 0.5 * m) + 0.5 * m * px.w, px.y * (1.0 - 0.5 * m) + 0.5 * m * px.w, px.z * (1.0 - 0.5 * m), px.w);
}
}
// EXtractoR. u[0] = (op, unmult, clip); u[1] = per-channel source (0..3 own, 4 aux, 5 default);
// f[0] = (black, white).
case 8u: {
let s = fxd_unpremul(px);
let straight = vec4<f32>(s.xyz, s.w);
var v = vec4<f32>(0.0);
for (var k = 0; k < 4; k++) {
let kind = P.u[1][k];
var x: f32;
if (kind == 4u) {
x = q[k];
} else if (kind < 4u) {
x = straight[kind];
} else {
x = select(0.0, 1.0, k == 3);
}
if (k < 3) {
let bp = P.f[0].x;
let wp = P.f[0].y;
x = (x - bp) / max(abs(wp - bp), 1e-9) * fxd_signum(wp - bp);
if (P.u[0].z != 0u) {
x = clamp(x, 0.0, 1.0);
}
}
v[k] = x;
}
let al = clamp(v.w, 0.0, 1.0);
var c = v.xyz;
if (P.u[0].y != 0u && al > 1e-6) {
c = v.xyz / al;
}
o = vec4<f32>(c * al, al);
}
default: {}
}
textureStore(out, p, o);
}
// Depth of Field: each pixel's position in the blur stack (src = depth plane).
// u[0].x = levels; f[0] = (focal plane, thickness, maximum radius, bias exponent).
@compute @workgroup_size(16, 16)
fn fxd_dof_f(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let z = textureLoad(src, p, 0).x;
let focal = P.f[0].x;
let max_r = P.f[0].z;
let d = max(abs(z - focal) - P.f[0].y * 0.5, 0.0);
let range = max(abs(focal), 1.0);
let n = min(d / range, 1.0);
let r = max_r * powz(n, P.f[0].w);
let top = f32(P.u[0].x - 1u);
let f = clamp(r / max_r * top, 0.0, top);
textureStore(out, p, vec4<f32>(f, 0.0, 0.0, 0.0));
}
// Depth of Field: pixels between stack levels k (src) and k + 1 (aux); the positions come
// through `data` (rows of RGBA f32). u[0] = (k, row length, levels).
@compute @workgroup_size(16, 16)
fn fxd_dof_level(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let f = data[(u32(p.y) * P.u[0].y + u32(p.x)) * 4u];
let k0 = min(u32(floor(f)), P.u[0].z - 2u);
if (k0 != P.u[0].x) {
return;
}
let t = f - f32(k0);
let a0 = textureLoad(src, p, 0);
let a1 = textureLoad(aux, p, 0);
textureStore(out, p, a0 + (a1 - a0) * t);
}
// u[0].x = 0: src + aux; 1: src × 0.25.
@compute @workgroup_size(16, 16)
fn fxd_avg(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let a = textureLoad(src, p, 0);
if (P.u[0].x == 0u) {
textureStore(out, p, a + textureLoad(aux, p, 0));
} else {
textureStore(out, p, a * 0.25);
}
}
+263
View File
@@ -0,0 +1,263 @@
//! GPU 3D Channel family vs the CPU effects (the oracle): direct on a buffer (full and half
//! resolution, padded offsets, aux channels at 1× and 2× the layer resolution) and composited
//! at 8 and 32 bpc from footage that carries auxiliary channels.
use std::sync::Arc;
use effectcraft_effects::{EffectEnv, EffectHost, LayerPixels};
use effectcraft_keyframe::Value;
use effectcraft_project::{BitDepth, Footage, ItemId};
use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, crypto_float, crypto_hash};
use effectcraft_raster::{AuxChannels, Image};
use effectcraft_render::{Backend, FootageSource, RenderOpts, Renderer};
use effectcraft_time::Tick;
use crate::tests::{Pattern, Scene, c, check, diff, gpu, n, opts, pattern, set, tolerance};
pub(crate) struct AuxHost(pub(crate) 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())
}
}
const NAMES: [&str; 5] = ["alpha", "beta", "gamma", "delta", "epsilon"];
/// Aux channels for a `w`×`h` layer at `scale` aux pixels per layer pixel: a depth ramp with a
/// background corner, object / material ID blocks, coverage, UVs, normals, background RGB, raw
/// RGB and a two-rank Cryptomatte layer with a manifest.
pub(crate) fn aux_scene(w: u32, h: u32, scale: f64) -> AuxChannels {
let (aw, ah) = ((w as f64 * scale) as u32, (h as f64 * scale) as u32);
let mut a = AuxChannels::new(aw, ah, scale);
let n = (aw * ah) as usize;
let mut ch: Vec<(String, Vec<f32>)> = [
"Z",
"ObjectID",
"MaterialID",
"Coverage",
"UV.U",
"UV.V",
"N.X",
"N.Y",
"N.Z",
"BG.R",
"BG.G",
"BG.B",
"R",
"G",
"B",
"CryptoObject00.R",
"CryptoObject00.G",
"CryptoObject00.B",
"CryptoObject00.A",
]
.iter()
.map(|s| (s.to_string(), vec![0.0; n]))
.collect();
let hashes: Vec<u32> = NAMES.iter().map(|s| crypto_hash(s)).collect();
for y in 0..ah {
for x in 0..aw {
let i = (y * aw + x) as usize;
let (u, v) = (x as f32 / aw as f32, y as f32 / ah as f32);
let bg = u > 0.8 && v < 0.25;
let obj = ((x as f64 / scale) as u32 / 11 + (y as f64 / scale) as u32 / 9 * 3) % 5;
let vals = [
if bg { BACKGROUND_DEPTH } else { 50.0 + 900.0 * u + 300.0 * (v * 7.0).sin() },
if bg { 0.0 } else { obj as f32 + 1.0 },
((obj * 7) % 3) as f32,
0.5 + 0.5 * (u * 9.0).sin(),
u,
v,
(u - 0.5) * 1.6,
(0.5 - v) * 1.4,
0.7,
0.2 + 0.6 * v,
0.4,
0.9 - 0.5 * u,
u * 1.8,
v * 0.7,
0.3 + u * v * 2.5,
crypto_float(hashes[obj as usize]),
0.7,
crypto_float(hashes[(obj as usize + 1) % 5]),
0.3,
];
for (k, val) in vals.iter().enumerate() {
ch[k].1[i] = *val;
}
}
}
a.channels = ch;
a.manifests = vec![("CryptoObject".into(), NAMES.iter().zip(&hashes).map(|(n, h)| (n.to_string(), *h)).collect())];
a
}
/// The effect alone on a buffer with aux channels (`allow` = fraction of pixels that may
/// differ by more than 1e-3).
pub(crate) fn direct(id: &str, vals: &[(&str, Value)], allow: f64) {
let Some(g) = gpu() else { return };
let spec = effectcraft_effects::find(id).unwrap();
let size = [70.0, 44.0];
let mut params =
effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() };
for (k, v) in vals {
params.values.insert(k.to_string(), v.clone());
}
for aux_scale in [1.0, 2.0] {
let host = AuxHost(Arc::new(aux_scene(70, 44, aux_scale)));
for (scale, offset) in [(1.0, [0.0, 0.0]), (0.5, [0.0, 0.0]), (1.0, [3.0, 2.0])] {
let (w, h) = ((size[0] * scale) as u32, (size[1] * scale) as u32);
let mut img = pattern(7, w + 2 * offset[0] as u32, h + 2 * offset[1] as u32);
if offset[0] > 0.0 {
// A padded buffer: transparent margins.
for (i, px) in img.data.iter_mut().enumerate() {
let (x, y) = (i as u32 % img.width, i as u32 / img.width);
if x < offset[0] as u32 || y < offset[1] as u32 || x >= w + offset[0] as u32 || y >= h + offset[1] as u32 {
*px = [0.0; 4];
}
}
}
let buf = effectcraft_effects::Buf { img, offset, scale };
let ctx = || effectcraft_effects::EffectCtx {
params: &params,
time: 0.25,
layer_size: size,
seed: 11,
adjustment: false,
env: EffectEnv { host: Some(&host), ..Default::default() },
};
let cpu = (spec.render)(&ctx(), buf.clone());
let out =
effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect");
assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "{id}: geometry");
let d = diff(&cpu.img, &out.img, 1e-3);
assert!(
d.over as f64 <= allow * d.total as f64,
"{id} {vals:?} aux {aux_scale} scale {scale} offset {offset:?}: {} of {} pixels over 1e-3 (max {}); worst {:?}",
d.over,
d.total,
d.max,
d.worst
);
}
}
}
/// Footage frames from [`Pattern`] with [`aux_scene`] channels.
struct AuxSource;
impl FootageSource for AuxSource {
fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
Pattern.frame(item, f, t)
}
fn aux(&self, _item: ItemId, f: &Footage, _t: Tick) -> Option<Arc<AuxChannels>> {
Some(Arc::new(aux_scene(f.width, f.height, 1.0)))
}
}
fn compare(s: &Scene, o: RenderOpts) -> Option<crate::tests::Diff> {
let g = gpu()?;
let cpu = Renderer::new(&s.p, &AuxSource, RenderOpts { backend: Backend::Cpu, ..o }).comp_frame_cpu(s.cid, Tick::ZERO);
let mut r = Renderer::new(&s.p, &AuxSource, RenderOpts { backend: Backend::Gpu, ..o });
r.accel = Some(g);
let img = g.render(&r, s.cid, Tick::ZERO).expect("the GPU renders the frame");
let mut d = diff(&cpu, &img, tolerance(s.p.settings.bit_depth));
d.quantized = s.p.settings.bit_depth != BitDepth::Bpc32;
Some(d)
}
/// Direct, then on a rotated footage layer over a background at 8 and 32 bpc (full and half
/// resolution).
fn case(id: &str, vals: &[(&str, Value)]) {
direct(id, vals, 0.0);
composited(id, vals, 0.0);
}
fn composited(id: &str, vals: &[(&str, Value)], allow: f64) {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = Scene::new(depth);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
let bg_id = s.push(bg);
let mut l = s.footage(70, 44);
let mut vals = vals.to_vec();
for v in vals.iter_mut() {
if v.1 == Value::Layer(Some(0)) {
v.1 = Value::Layer(Some(bg_id.0));
}
}
s.effect(&mut l, id, &vals);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
check(&format!("{id} {vals:?} {depth:?}"), compare(&s, opts()), allow);
check(&format!("{id} {vals:?} {depth:?} half"), compare(&s, RenderOpts { scale: 0.5, ..opts() }), allow);
}
}
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
#[test]
fn channel_extract() {
case("ec.3d.channelextract", &[("blackPoint", n(0.0)), ("whitePoint", n(1200.0))]);
case("ec.3d.channelextract", &[("whitePoint", n(400.0)), ("clampOutput", Value::Bool(false)), ("invertDepthMap", on())]);
case("ec.3d.channelextract", &[("blackPoint", n(900.0)), ("whitePoint", n(100.0))]);
case("ec.3d.channelextract", &[("channel", e(1)), ("whitePoint", n(5.0))]);
case("ec.3d.channelextract", &[("channel", e(2)), ("whitePoint", n(1.0))]);
case("ec.3d.channelextract", &[("channel", e(3)), ("whitePoint", n(1.0))]);
case("ec.3d.channelextract", &[("channel", e(4)), ("whitePoint", n(1.0)), ("antialias", on())]);
case("ec.3d.channelextract", &[("channel", e(5)), ("whitePoint", n(1.0))]);
case("ec.3d.channelextract", &[("channel", e(6))]);
case("ec.3d.channelextract", &[("channel", e(7)), ("whitePoint", n(2.0))]);
case("ec.3d.channelextract", &[("blackPoint", n(0.0)), ("whitePoint", n(1000.0)), ("antialias", on())]);
}
#[test]
fn depth_matte_fog_and_dof() {
case("ec.3d.depthmatte", &[("depth", n(500.0))]);
case("ec.3d.depthmatte", &[("depth", n(400.0)), ("feather", n(300.0)), ("invert", on())]);
case("ec.3d.fog3d", &[]);
case("ec.3d.fog3d", &[("fogColor", c(0.3, 0.5, 0.9)), ("fogStartDepth", n(200.0)), ("fogEndDepth", n(900.0)), ("scatteringDensity", n(10.0))]);
case("ec.3d.fog3d", &[("foggyBackground", Value::Bool(false)), ("fogOpacity", n(70.0))]);
composited("ec.3d.fog3d", &[("gradientLayer", Value::Layer(Some(0))), ("layerContribution", n(60.0))], 0.0);
case("ec.3d.depthoffield", &[("focalPlane", n(400.0)), ("maximumRadius", n(8.0))]);
case("ec.3d.depthoffield", &[("focalPlane", n(200.0)), ("maximumRadius", n(5.0)), ("focalPlaneThickness", n(150.0)), ("focalBias", n(80.0))]);
}
#[test]
fn ids_and_cryptomatte() {
case("ec.3d.idmatte", &[("idSelection", n(2.0))]);
case("ec.3d.idmatte", &[("idSelection", n(3.0)), ("feather", n(3.0)), ("useCoverage", on()), ("invert", on())]);
case("ec.3d.idmatte", &[("auxChannel", e(1)), ("idSelection", n(1.0))]);
case("ec.3d.identifier", &[]);
case("ec.3d.identifier", &[("id", n(2.0))]);
case("ec.3d.identifier", &[("display", e(1)), ("id", n(4.0))]);
case("ec.3d.identifier", &[("display", e(2)), ("id", n(1.0))]);
case("ec.3d.identifier", &[("display", e(3)), ("channelType", e(1))]);
case("ec.3d.cryptomatte", &[("selection", Value::Str("beta, delta".into()))]);
case("ec.3d.cryptomatte", &[("selection", Value::Str("*a".into())), ("display", e(0))]);
case("ec.3d.cryptomatte", &[("selection", Value::Str("gamma".into())), ("display", e(1))]);
case("ec.3d.cryptomatte", &[("selection", Value::Str("alpha".into())), ("matteOnly", on())]);
}
#[test]
fn extractor() {
case("ec.3d.extractor", &[("red", Value::Str("Z".into())), ("green", Value::Str("UV.U".into())), ("whitePoint", n(1000.0))]);
case(
"ec.3d.extractor",
&[("red", Value::Str("G".into())), ("blue", Value::Str("Coverage".into())), ("alpha", Value::Str("UV.V".into())), ("unMult", on())],
);
case("ec.3d.extractor", &[("red", Value::Str("A".into())), ("blackPoint", n(0.2)), ("whitePoint", n(0.8)), ("clip", on())]);
case("ec.3d.extractor", &[("green", Value::Str("".into())), ("alpha", Value::Str("nothing".into()))]);
}
+9 -9
View File
@@ -5,7 +5,7 @@
EffectCraft ships 306 effects with 3009 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**: 166 effects also run on the GPU compositor with identical results.
- **GPU**: 174 effects also run on the GPU compositor with identical results.
- **32**: 306 effects process 32-bit float (HDR, overbright) pixels without clamping.
- **Status**: 306 implemented in full, 0 partial (what is missing is listed).
@@ -15,14 +15,14 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| 3D Channel Extract | `ec.3d.channelextract` | 6 | | 32 | Implemented |
| Cryptomatte | `ec.3d.cryptomatte` | 4 | | 32 | Implemented |
| Depth Matte | `ec.3d.depthmatte` | 3 | | 32 | Implemented |
| Depth of Field | `ec.3d.depthoffield` | 4 | | 32 | Implemented |
| EXtractoR | `ec.3d.extractor` | 8 | | 32 | Implemented |
| Fog 3D | `ec.3d.fog3d` | 8 | | 32 | Implemented |
| ID Matte | `ec.3d.idmatte` | 5 | | 32 | Implemented |
| IDentifier | `ec.3d.identifier` | 3 | | 32 | Implemented |
| 3D Channel Extract | `ec.3d.channelextract` | 6 | GPU | 32 | Implemented |
| Cryptomatte | `ec.3d.cryptomatte` | 4 | GPU | 32 | Implemented |
| Depth Matte | `ec.3d.depthmatte` | 3 | GPU | 32 | Implemented |
| Depth of Field | `ec.3d.depthoffield` | 4 | GPU | 32 | Implemented |
| EXtractoR | `ec.3d.extractor` | 8 | GPU | 32 | Implemented |
| Fog 3D | `ec.3d.fog3d` | 8 | GPU | 32 | Implemented |
| ID Matte | `ec.3d.idmatte` | 5 | GPU | 32 | Implemented |
| IDentifier | `ec.3d.identifier` | 3 | GPU | 32 | Implemented |
## Audio