From 6b91afc08700b88d0e1106aa145ab77659775d5b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 17:33:41 -0400 Subject: [PATCH] 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 --- crates/effects/src/channel3d.rs | 2 +- crates/effects/src/lib.rs | 11 + crates/gpu/src/context.rs | 2 + crates/gpu/src/effects.rs | 1 + crates/gpu/src/fx_depth.rs | 335 +++++++++++++++++++++++++++ crates/gpu/src/lib.rs | 3 + crates/gpu/src/shaders/fx_depth.wgsl | 307 ++++++++++++++++++++++++ crates/gpu/src/tests_fx_depth.rs | 263 +++++++++++++++++++++ docs/effects.md | 18 +- 9 files changed, 932 insertions(+), 10 deletions(-) create mode 100644 crates/gpu/src/fx_depth.rs create mode 100644 crates/gpu/src/shaders/fx_depth.wgsl create mode 100644 crates/gpu/src/tests_fx_depth.rs diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 3515204e..b1cd4a60 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -53,7 +53,7 @@ fn plane_of(b: &Buf, idx: &[Option], 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] } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index feed53bf..8e62915f 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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 diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 3d8d2916..a0f64b24 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -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)| { diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index d8ab4746..02012f11 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -133,6 +133,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { _ 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), } } diff --git a/crates/gpu/src/fx_depth.rs b/crates/gpu/src/fx_depth.rs new file mode 100644 index 00000000..2e55573c --- /dev/null +++ b/crates/gpu/src/fx_depth.rs @@ -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 { + 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 { + 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 { + 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 { + 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 = 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 { + 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 { + 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 { + 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 { + 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 = (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 { + 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 { + 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 { + 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::().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 { + 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> = 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 }) +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index ca9bdbfb..b18d09d8 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -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; diff --git a/crates/gpu/src/shaders/fx_depth.wgsl b/crates/gpu/src/shaders/fx_depth.wgsl new file mode 100644 index 00000000..468bd126 --- /dev/null +++ b/crates/gpu/src/shaders/fx_depth.wgsl @@ -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) -> vec4 { + if (p.w > 1e-6) { + return vec4(p.xyz / p.w, p.w); + } + return vec4(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) { + let dims = out_dims(); + let p = vec2(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(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(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 { + if (id == 0.0) { + return vec3(0.0); + } + let h = hash_noise(bitcast(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) { + let dims = out_dims(); + let p = vec2(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(0.0); + } else { + var c = q.xyz; + switch (P.u[0].y) { + case 1u: { + c = vec3(fxd_lv(c.x), fxd_lv(c.y), 0.0); + } + case 2u: { + c = vec3(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(fxd_lv(c.x), fxd_lv(c.y), fxd_lv(c.z)); + } + } + o = vec4(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(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(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(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(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(1.0); + } + o = vec4(c, 1.0); + } + case 1u: { + let v = select(0.0, 1.0, hit); + o = vec4(v, v, v, 1.0); + } + case 2u: { + o = select(vec4(0.0), px, hit); + } + default: { + let v = id / P.f[0].y; + o = vec4(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(m); + } else if (P.u[0].y == 0u) { + o = vec4(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(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(s.xyz, s.w); + var v = vec4(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(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) { + let dims = out_dims(); + let p = vec2(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(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) { + let dims = out_dims(); + let p = vec2(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) { + let dims = out_dims(); + let p = vec2(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); + } +} diff --git a/crates/gpu/src/tests_fx_depth.rs b/crates/gpu/src/tests_fx_depth.rs new file mode 100644 index 00000000..b84b4849 --- /dev/null +++ b/crates/gpu/src/tests_fx_depth.rs @@ -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); + +impl EffectHost for AuxHost { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn aux(&self) -> Option> { + 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)> = [ + "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 = 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: ¶ms, + 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> { + Pattern.frame(item, f, t) + } + fn aux(&self, _item: ItemId, f: &Footage, _t: Tick) -> Option> { + Some(Arc::new(aux_scene(f.width, f.height, 1.0))) + } +} + +fn compare(s: &Scene, o: RenderOpts) -> Option { + 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()))]); +} diff --git a/docs/effects.md b/docs/effects.md index 1ad51686..0568fb21 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -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..`) 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