From 6b91afc08700b88d0e1106aa145ab77659775d5b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 17:33:41 -0400 Subject: [PATCH 1/6] 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 From e3e35a7ec2303b86926802e7f9c8f183b58e9b93 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 17:43:24 -0400 Subject: [PATCH 2/6] M13.23: GPU colour management (LUTs, OCIO, Color Profile Converter, Lumetri LUT/look) Apply Color LUT, OCIO CDL / Color Space / Display / File / Look Transform and Color Profile Converter compile to colour programs (effects::color_program, built next to the CPU effects) that fx_lut.wgsl interprets per pixel; LUTs (1D, 3D, cineSpace shapers, inverses) are read from the storage buffer with the CPU's nearest / trilinear / tetrahedral interpolation. Lumetri's Input LUT and Look no longer force the CPU. Custom .ocio configs stay on the CPU. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 5 +- crates/effects/src/lib.rs | 10 + crates/effects/src/ocio.rs | 171 ++++++++++ crates/effects/src/utility.rs | 2 +- crates/gpu/src/context.rs | 2 + crates/gpu/src/effects.rs | 1 + crates/gpu/src/fx_color.rs | 11 + crates/gpu/src/fx_lut.rs | 147 +++++++++ crates/gpu/src/lib.rs | 3 + crates/gpu/src/shaders/fx_color.wgsl | 49 ++- crates/gpu/src/shaders/fx_lut.wgsl | 474 +++++++++++++++++++++++++++ crates/gpu/src/tests_fx_lut.rs | 175 ++++++++++ docs/effects.md | 16 +- 13 files changed, 1052 insertions(+), 14 deletions(-) create mode 100644 crates/gpu/src/fx_lut.rs create mode 100644 crates/gpu/src/shaders/fx_lut.wgsl create mode 100644 crates/gpu/src/tests_fx_lut.rs diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 102bd84e..ca580b9d 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -63,10 +63,7 @@ pub fn param_shown(effect: &str, param: &str, value: &dyn Fn(&str) -> Option Buf { b } +// ---- Colour programs (the GPU compositor's colour-management kernels) + +/// One per-pixel step of a colour transform, in the order the CPU effect applies them (see +/// [`color_program`]). +#[derive(Clone, Debug)] +pub enum ColorOp { + /// Transfer function, encoded → linear. + Decode(Tf), + /// Transfer function, linear → encoded. + Encode(Tf), + /// Linear-RGB matrix. + Matrix([[f32; 3]; 3]), + /// ASC CDL (forward or exact inverse; `clamp` = the v1.2 style). + Cdl { cdl: Cdl, clamp: bool, inverse: bool }, + /// The Tone Mapped view's roll-off (or its inverse). + Tonemap { inverse: bool }, + /// Color Profile Converter's perceptual gamut compression toward luminance `luma`. + Gamut([f64; 3]), + /// Negative channels to 0 (the saturation intent). + Max0, + /// A LUT (0 nearest, 1 trilinear, 2 tetrahedral) with an optional per-channel shaper, forward + /// or inverse (as [`FileXform::apply`]). + Lut { lut: Arc, shaper: Option; 3]>>, interp: u32, inverse: bool }, +} + +/// How a colour program meets premultiplied pixels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Straight { + /// `unpremul` (colour 0 below alpha 1e-6); fully transparent pixels untouched (OCIO effects). + Unpremul, + /// Colour ÷ alpha; fully transparent pixels untouched (`Image::map_straight`, Apply Color LUT). + Divide, +} + +fn xform_ops(x: &Xform, ops: &mut Vec) { + if x.dec != Tf::Linear { + ops.push(ColorOp::Decode(x.dec)); + } + if !x.ident { + ops.push(ColorOp::Matrix(x.m)); + } + if x.enc != Tf::Linear { + ops.push(ColorOp::Encode(x.enc)); + } +} + +/// Effect `id`'s per-pixel transform as a list of [`ColorOp`]s that reproduces the CPU effect +/// (empty = the layer passes through). `None` when it has no such form (a custom OCIO config, +/// another effect). +pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec, Straight)> { + let pr = ctx.params; + let mut ops = vec![]; + match id { + "ec.utility.applylut" => { + if let Some(lut) = crate::utility::load_lut(pr.s("lut")) { + ops.push(ColorOp::Lut { lut, shaper: None, interp: 2, inverse: false }); + } + return Some((ops, Straight::Divide)); + } + "ec.color.ociocolorspace" => { + if pr.e("config") == 1 { + return custom_config(ctx).is_none().then_some((ops, Straight::Unpremul)); + } + let (a, z) = (space_at(pr.e("source")), space_at(pr.e("destination"))); + let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) }; + if a.name != z.name { + xform_ops(&Xform::new(a, z), &mut ops); + } + } + "ec.color.ociocdl" => { + let cdl = cdl_from_params(pr); + if cdl != Cdl::default() { + ops.push(ColorOp::Cdl { cdl, clamp: pr.e("style") == 0, inverse: pr.e("direction") == 1 }); + } + } + "ec.color.ociodisplay" => { + if pr.e("config") == 1 { + return custom_config(ctx).is_none().then_some((ops, Straight::Unpremul)); + } + let input = space_at(pr.e("source")); + let (display, view, inverse) = (pr.e("display") as usize, pr.e("view") as usize, pr.e("direction") == 1); + let (_, prims, tf) = DISPLAYS[display.min(DISPLAYS.len() - 1)]; + if view != 2 && !input.raw { + let disp = Space { name: "display", prims: Some((prims, D65)), tf: Tf::Linear, raw: false }; + let lin = Space { tf: Tf::Linear, ..*input }; + if !inverse { + ops.push(ColorOp::Decode(input.tf)); + xform_ops(&Xform::new(&lin, &disp), &mut ops); + if view == 1 { + ops.push(ColorOp::Tonemap { inverse: false }); + } + ops.push(ColorOp::Encode(tf)); + } else { + ops.push(ColorOp::Decode(tf)); + if view == 1 { + ops.push(ColorOp::Tonemap { inverse: true }); + } + xform_ops(&Xform::new(&disp, &lin), &mut ops); + ops.push(ColorOp::Encode(input.tf)); + } + } + } + "ec.color.ociofile" => { + if let Some(x) = load_file(pr.s("file")) { + let interp = match pr.e("interpolation") { + 0 => 0, + 1 => 1, + _ => 2, + }; + let inverse = pr.e("direction") == 1; + match &*x { + FileXform::Cdl(list) => { + let id = pr.s("cccId"); + let cdl = list.iter().find(|(i, _)| !id.is_empty() && i == id).or(list.first()).map(|(_, c)| *c).unwrap_or_default(); + ops.push(ColorOp::Cdl { cdl, clamp: true, inverse }); + } + FileXform::Lut { lut, shaper } => { + ops.push(ColorOp::Lut { lut: Arc::new(lut.clone()), shaper: shaper.clone().map(Arc::new), interp, inverse }); + } + } + } + } + "ec.color.ociolook" => { + let mut list = parse_looks(pr.s("looks")); + let pick = pr.e("look"); + if pick > 0 + && let Some((_, c)) = looks().get(pick as usize - 1) + { + list.insert(0, (*c, false)); + } + if !list.is_empty() { + let (src, dst) = (space_at(pr.e("source")), space_at(pr.e("destination"))); + let inv = pr.e("direction") == 1; + let cct = space_by_name("ACEScct").expect("built-in"); + let (a, z) = if inv { (dst, src) } else { (src, dst) }; + if inv { + list.reverse(); + } + xform_ops(&Xform::new(a, cct), &mut ops); + for (cdl, li) in &list { + ops.push(ColorOp::Cdl { cdl: *cdl, clamp: false, inverse: *li != inv }); + } + xform_ops(&Xform::new(cct, z), &mut ops); + } + } + "ec.utility.colorprofileconverter" => { + let (inp, in_lin_default) = profile(ctx, pr.e("inputProfile")); + let (out, out_lin_default) = profile(ctx, pr.e("outputProfile")); + let in_lin = pr.b("linearizeInputProfile") || in_lin_default && pr.e("inputProfile") == 0; + let out_lin = pr.b("linearizeOutputProfile") || out_lin_default && pr.e("outputProfile") == 0; + let intent = pr.e("intent"); + let dec = if in_lin { Tf::Linear } else { inp.tf }; + let enc = if out_lin { Tf::Linear } else { out.tf }; + let m = if intent == 3 { mul(&invert(&out.to_ref(false)), &inp.to_ref(false)) } else { matrix(&inp, &out) }; + let identity = dec == enc && (0..3).all(|i| (0..3).all(|j| (m[i][j] - IDENTITY[i][j]).abs() < 1e-9)); + if !identity { + ops.push(ColorOp::Decode(dec)); + ops.push(ColorOp::Matrix(m.map(|r| r.map(|v| v as f32)))); + match intent { + 0 => ops.push(ColorOp::Gamut(out.to_ref(true)[1])), + 2 => ops.push(ColorOp::Max0), + _ => {} + } + ops.push(ColorOp::Encode(enc)); + } + } + _ => return None, + } + Some((ops, Straight::Unpremul)) +} + // ---- Color Stabilizer /// Mean straight colour of a square (half-size `r` layer pixels) around layer point `pt`. diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 7689050d..46780aec 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -326,7 +326,7 @@ impl Lut { type LutCache = Mutex>>>; /// Load (and cache) a LUT from inline `.cube` text or a file path. -pub(crate) fn load_lut(src: &str) -> Option> { +pub fn load_lut(src: &str) -> Option> { if src.trim().is_empty() { return None; } diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index a0f64b24..34f3204f 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -315,6 +315,7 @@ impl GpuContext { include_str!("shaders/fx_warp.wgsl"), include_str!("shaders/fx_extra.wgsl"), include_str!("shaders/fx_depth.wgsl"), + include_str!("shaders/fx_lut.wgsl"), ] .concat(); let module = @@ -375,6 +376,7 @@ impl GpuContext { .chain(crate::fx_warp::KERNELS) .chain(crate::fx_extra::KERNELS) .chain(crate::fx_depth::KERNELS) + .chain(crate::fx_lut::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 02012f11..e8ee547f 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -134,6 +134,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { _ 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), + _ if crate::fx_lut::IDS.contains(&id) => crate::fx_lut::apply(e, id, ctx, b), _ => pointwise(e, id, ctx, b), } } diff --git a/crates/gpu/src/fx_color.rs b/crates/gpu/src/fx_color.rs index 6a1a1660..b9ae5282 100644 --- a/crates/gpu/src/fx_color.rs +++ b/crates/gpu/src/fx_color.rs @@ -197,6 +197,17 @@ fn lumetri(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { (lw / lh.max(1e-6)) as f32, ]); debug_assert_eq!(d.len(), 50); + // Input LUT and Look (data[50..53]: look, input LUT offset, look LUT offset; tables after). + let look = pr.e("creative/look"); + let input_lut = if pr.e("basicCorrection/inputLut") == 1 { effectcraft_effects::load_lut(pr.s("basicCorrection/inputLutFile")) } else { None }; + let look_lut = if look == 1 { effectcraft_effects::load_lut(pr.s("creative/lookFile")) } else { None }; + d.extend([look as f32, -1.0, -1.0]); + if let Some(l) = &input_lut { + d[51] = crate::fx_lut::push_lut(&mut d, l); + } + if let Some(l) = &look_lut { + d[52] = crate::fx_lut::push_lut(&mut d, l); + } let buf = e.data(&d); let out = e.scratch(b.img.width, b.img.height); e.pixels("fxc_lumetri", &Params::default(), &b.img, None, &out, Some(&buf)); diff --git a/crates/gpu/src/fx_lut.rs b/crates/gpu/src/fx_lut.rs new file mode 100644 index 00000000..0e454a4e --- /dev/null +++ b/crates/gpu/src/fx_lut.rs @@ -0,0 +1,147 @@ +//! GPU effects, colour management (kernels in `shaders/fx_lut.wgsl`, every entry point +//! prefixed `fxl_`): Apply Color LUT, OCIO CDL / Color Space / Display / File / Look Transform +//! and Color Profile Converter; Lumetri Color's Input LUT and Look also use the LUT helpers. +//! +//! Each effect compiles to a colour program ([`effectcraft_effects::color_program`]: transfer +//! functions, matrices, ASC CDLs, tone mapping, gamut compression, LUTs) built next to the CPU +//! effect, which `fxl_point` interprets per pixel. LUTs (1D tables and 3D lattices, with a +//! cineSpace shaper) travel in the kernel's storage buffer and are interpolated in the kernel +//! exactly as the CPU does (nearest, trilinear or tetrahedral; inverse 1D tables and the +//! iterative 3D inverse): hardware 3D-texture filtering would round the interpolation weights +//! and cannot do tetrahedral interpolation. The CPU evaluates transfer functions, CDLs and +//! Color Profile Converter's matrix in f64; the kernel in f32 (within the 1e-3 tolerance). +//! Custom `.ocio` configurations render on the CPU. + +use effectcraft_effects::{ColorOp, EffectCtx, Lut, Straight, Tf}; + +use crate::context::{Enc, Params}; +use crate::effects::GBuf; + +/// Compute entry points in `fx_lut.wgsl`. +pub(crate) const KERNELS: &[&str] = &["fxl_point"]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[ + "ec.utility.applylut", + "ec.color.ociocdl", + "ec.color.ociocolorspace", + "ec.color.ociodisplay", + "ec.color.ociofile", + "ec.color.ociolook", + "ec.utility.colorprofileconverter", +]; + +/// Largest LUT the kernels take (floats in the storage buffer); bigger ones render on the CPU. +const MAX_FLOATS: usize = 1 << 24; + +/// 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 (ops, straight) = effectcraft_effects::color_program(id, ctx)?; + if ops.is_empty() { + return Some(b); + } + let data = encode(&ops)?; + let buf = e.data(&data); + let mut p = Params::default(); + p.u[0][0] = (straight == Straight::Divide) as u32; + let out = e.scratch(b.img.width, b.img.height); + e.pixels("fxl_point", &p, &b.img, None, &out, Some(&buf)); + Some(GBuf { img: out, ..b }) +} + +fn tf_code(t: Tf) -> [f32; 2] { + match t { + Tf::Linear => [0.0, 0.0], + Tf::Srgb => [1.0, 0.0], + Tf::Gamma(g) => [2.0, g as f32], + Tf::AcesCct => [3.0, 0.0], + Tf::Pq => [4.0, 0.0], + } +} + +/// A LUT in the layout `fxl_lut_*` read at the returned offset: n1, n3, domain min (3), domain +/// max (3), the 1D table (n1 × RGB), then the lattice (n3³ × RGB, red fastest). +pub(crate) fn push_lut(d: &mut Vec, lut: &Lut) -> f32 { + let at = d.len() as f32; + d.extend([lut.n1 as f32, lut.n3 as f32]); + d.extend(lut.dmin); + d.extend(lut.dmax); + for v in lut.d1.iter().chain(&lut.d3) { + d.extend(v); + } + at +} + +/// Program layout: a stream of (opcode, arguments…) ending in 0, followed by LUT data. +fn encode(ops: &[ColorOp]) -> Option> { + let mut prog: Vec = vec![]; + let mut tables: Vec> = vec![]; + // LUT offsets are patched once the program's length is known. + let mut patches: Vec<(usize, usize)> = vec![]; + for op in ops { + match op { + ColorOp::Decode(Tf::Linear) | ColorOp::Encode(Tf::Linear) => {} + ColorOp::Decode(t) => { + prog.push(1.0); + prog.extend(tf_code(*t)); + } + ColorOp::Encode(t) => { + prog.push(2.0); + prog.extend(tf_code(*t)); + } + ColorOp::Matrix(m) => { + prog.push(3.0); + for r in m { + prog.extend(r); + } + } + ColorOp::Cdl { cdl, clamp, inverse } => { + prog.push(4.0); + for v in cdl.slope.iter().chain(&cdl.offset).chain(&cdl.power) { + prog.push(*v as f32); + } + prog.extend([cdl.sat as f32, *clamp as u32 as f32, *inverse as u32 as f32]); + } + ColorOp::Tonemap { inverse } => prog.extend([5.0, *inverse as u32 as f32]), + ColorOp::Gamut(l) => prog.extend([6.0, l[0] as f32, l[1] as f32, l[2] as f32]), + ColorOp::Max0 => prog.push(7.0), + ColorOp::Lut { lut, shaper, interp, inverse } => { + prog.extend([8.0, 0.0, *interp as f32, *inverse as u32 as f32, -1.0]); + let mut t = vec![]; + push_lut(&mut t, lut); + let lut_at = prog.len() - 4; + patches.push((lut_at, tables.len())); + tables.push(t); + if let Some(s) = shaper { + // Shaper: three point counts, then each channel's (x, y) points. + let mut t: Vec = s.iter().map(|c| c.len() as f32).collect(); + for c in s.iter() { + for (x, y) in c { + t.extend([*x, *y]); + } + } + patches.push((prog.len() - 1, tables.len())); + tables.push(t); + } + } + } + } + prog.push(0.0); + let mut starts = vec![]; + let mut at = prog.len(); + for t in &tables { + starts.push(at); + at += t.len(); + } + if at > MAX_FLOATS { + return None; + } + // Offsets travel as f32: exact below 2^24. + for (slot, table) in patches { + prog[slot] = starts[table] as f32; + } + for t in tables { + prog.extend(t); + } + Some(prog) +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index b18d09d8..4cfd9ebe 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -53,6 +53,7 @@ mod fx_distort; mod fx_extra; mod fx_generate; mod fx_key; +mod fx_lut; mod fx_noise; mod fx_stylize; mod fx_tone; @@ -212,6 +213,8 @@ mod tests_fx_generate; #[cfg(test)] mod tests_fx_key; #[cfg(test)] +mod tests_fx_lut; +#[cfg(test)] mod tests_fx_noise; #[cfg(test)] mod tests_fx_stylize; diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl index 97771a46..c7d6010e 100644 --- a/crates/gpu/src/shaders/fx_color.wgsl +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -310,6 +310,43 @@ fn fxc_d3(i: u32) -> vec3 { // green, blue × shadows / midtones / highlights), 32..41 wheels (shadows, midtones, highlights), // 41 vignette amount, 42 vignette start, 43 roundness, 44 feather, 45 cx, 46 cy, 47 lw, 48 lh, // 49 aspect. +// color3::builtin_look. +fn fxc_builtin_look(look: u32, c: vec3) -> vec3 { + let l = luminance(c); + switch (look) { + case 2u: { + return vec3(c.x * 1.06 + 0.015, c.y * 1.01 + 0.01, c.z * 0.88); + } + case 3u: { + return vec3(c.x * 0.82, c.y * 0.92, c.z * 1.08 + 0.02) * 0.92; + } + case 4u: { + let d = l + (c - l) * 0.45; + var o: vec3; + if (l < 0.5) { + o = 2.0 * d * l; + } else { + o = 1.0 - 2.0 * (1.0 - d) * (1.0 - l); + } + return d * 0.5 + o * 0.5; + } + case 5u: { + let t = clamp(l - 0.5, -0.5, 0.5); + return vec3(c.x + 0.18 * t, c.y + 0.03 * t, c.z - 0.16 * t); + } + case 6u: { + let v = 0.08 + c * 0.84; + return l + (v - l) * 0.8; + } + case 7u: { + return vec3(l); + } + default: { + return c; + } + } +} + @compute @workgroup_size(16, 16) fn fxc_lumetri(@builtin(global_invocation_id) gid: vec3) { let p = fxc_pixel(gid); @@ -323,6 +360,9 @@ fn fxc_lumetri(@builtin(global_invocation_id) gid: vec3) { return; } var c = fxc_unpremul(px); + if (data[51] >= 0.0) { + c = fxl_lut_apply(u32(data[51]), c, 2u); + } let temp = data[0]; let tint = data[1]; if (temp != 0.0 || tint != 0.0) { @@ -359,8 +399,15 @@ fn fxc_lumetri(@builtin(global_invocation_id) gid: vec3) { let st = fxc_d3(14u); let ht = fxc_d3(17u); let zero = vec3(0.0); - if (intensity != 0.0 && (faded != 0.0 || vib != 0.0 || csat != 1.0 || any(st != zero) || any(ht != zero))) { + let look = u32(data[50]); + let has_look = look >= 2u || data[52] >= 0.0; + if (intensity != 0.0 && (has_look || faded != 0.0 || vib != 0.0 || csat != 1.0 || any(st != zero) || any(ht != zero))) { var d = c; + if (data[52] >= 0.0) { + d = fxl_lut_apply(u32(data[52]), c, 2u); + } else { + d = fxc_builtin_look(look, c); + } if (faded != 0.0) { d = d * (1.0 - 0.25 * faded) + 0.12 * faded; } diff --git a/crates/gpu/src/shaders/fx_lut.wgsl b/crates/gpu/src/shaders/fx_lut.wgsl new file mode 100644 index 00000000..3075bc81 --- /dev/null +++ b/crates/gpu/src/shaders/fx_lut.wgsl @@ -0,0 +1,474 @@ +// ---------------------------------------------------------------- colour management (fx_lut.rs) +// +// A colour program in `data`: opcodes with their arguments, ending in 0, then LUT tables +// (layout in fx_lut.rs::push_lut). Every step mirrors effects::ocio / effects::utility. + +fn fxl_d3(i: u32) -> vec3 { + return vec3(data[i], data[i + 1u], data[i + 2u]); +} + +// ---- LUTs (utility::Lut) + +fn fxl_lut_norm(o: u32, c: vec3) -> vec3 { + let dmin = fxl_d3(o + 2u); + let dmax = fxl_d3(o + 5u); + return clamp((c - dmin) / max(dmax - dmin, vec3(1e-9)), vec3(0.0), vec3(1.0)); +} + +fn fxl_lut_3d(base: u32, n: u32, r: u32, g: u32, b: u32) -> vec3 { + return fxl_d3(base + (r + g * n + b * n * n) * 3u); +} + +// Lut::apply_interp: 0 nearest, 1 trilinear, otherwise tetrahedral (1D tables linear unless 0). +fn fxl_lut_apply(o: u32, c0: vec3, interp: u32) -> vec3 { + let n1 = u32(data[o]); + let n3 = u32(data[o + 1u]); + let b1 = o + 8u; + let b3 = b1 + n1 * 3u; + var c = c0; + if (n1 > 1u) { + let t = fxl_lut_norm(o, c); + for (var i = 0u; i < 3u; i++) { + let x = t[i] * f32(n1 - 1u); + if (interp == 0u) { + c[i] = data[b1 + min(u32(round_away(x)), n1 - 1u) * 3u + i]; + } else { + let i0 = min(u32(floor(x)), n1 - 2u); + let f = x - f32(i0); + let a = data[b1 + i0 * 3u + i]; + let b = data[b1 + (i0 + 1u) * 3u + i]; + c[i] = a + (b - a) * f; + } + } + } + if (n3 > 1u) { + let t = fxl_lut_norm(o, c); + let s = f32(n3 - 1u); + if (interp == 0u) { + let ir = min(u32(round_away(t.x * s)), n3 - 1u); + let ig = min(u32(round_away(t.y * s)), n3 - 1u); + let ib = min(u32(round_away(t.z * s)), n3 - 1u); + return fxl_lut_3d(b3, n3, ir, ig, ib); + } + let v = t * s; + let r = min(u32(floor(v.x)), n3 - 2u); + let g = min(u32(floor(v.y)), n3 - 2u); + let b = min(u32(floor(v.z)), n3 - 2u); + let fr = v.x - f32(r); + let fg = v.y - f32(g); + let fb = v.z - f32(b); + if (interp == 1u) { + let c00 = mix_exact(fxl_lut_3d(b3, n3, r, g, b), fxl_lut_3d(b3, n3, r + 1u, g, b), fr); + let c10 = mix_exact(fxl_lut_3d(b3, n3, r, g + 1u, b), fxl_lut_3d(b3, n3, r + 1u, g + 1u, b), fr); + let c01 = mix_exact(fxl_lut_3d(b3, n3, r, g, b + 1u), fxl_lut_3d(b3, n3, r + 1u, g, b + 1u), fr); + let c11 = mix_exact(fxl_lut_3d(b3, n3, r, g + 1u, b + 1u), fxl_lut_3d(b3, n3, r + 1u, g + 1u, b + 1u), fr); + return mix_exact(mix_exact(c00, c10, fg), mix_exact(c01, c11, fg), fb); + } + let c000 = fxl_lut_3d(b3, n3, r, g, b); + let c111 = fxl_lut_3d(b3, n3, r + 1u, g + 1u, b + 1u); + var w1: f32; + var w2: f32; + var w3: f32; + var p1: vec3; + var p2: vec3; + if (fr > fg) { + if (fg > fb) { + w1 = fr; + w2 = fg; + w3 = fb; + p1 = fxl_lut_3d(b3, n3, r + 1u, g, b); + p2 = fxl_lut_3d(b3, n3, r + 1u, g + 1u, b); + } else if (fr > fb) { + w1 = fr; + w2 = fb; + w3 = fg; + p1 = fxl_lut_3d(b3, n3, r + 1u, g, b); + p2 = fxl_lut_3d(b3, n3, r + 1u, g, b + 1u); + } else { + w1 = fb; + w2 = fr; + w3 = fg; + p1 = fxl_lut_3d(b3, n3, r, g, b + 1u); + p2 = fxl_lut_3d(b3, n3, r + 1u, g, b + 1u); + } + } else if (fb > fg) { + w1 = fb; + w2 = fg; + w3 = fr; + p1 = fxl_lut_3d(b3, n3, r, g, b + 1u); + p2 = fxl_lut_3d(b3, n3, r, g + 1u, b + 1u); + } else if (fb > fr) { + w1 = fg; + w2 = fb; + w3 = fr; + p1 = fxl_lut_3d(b3, n3, r, g + 1u, b); + p2 = fxl_lut_3d(b3, n3, r, g + 1u, b + 1u); + } else { + w1 = fg; + w2 = fr; + w3 = fb; + p1 = fxl_lut_3d(b3, n3, r, g + 1u, b); + p2 = fxl_lut_3d(b3, n3, r + 1u, g + 1u, b); + } + return c000 + w1 * (p1 - c000) + w2 * (p2 - p1) + w3 * (c111 - p2); + } + return c; +} + +// a + (b - a) t, per component (the CPU's lerp, not WGSL's mix). +fn mix_exact(a: vec3, b: vec3, t: f32) -> vec3 { + return a + (b - a) * t; +} + +// ocio::shape over `n` (x, y) points at `at` (`flip`: the curve with x and y swapped). +fn fxl_shape(at: u32, n: u32, x: f32, flip: bool) -> f32 { + if (n < 2u) { + return x; + } + let kx = select(0u, 1u, flip); + let ky = 1u - kx; + // partition_point(|p| p.x < x). + var lo = 0u; + var hi = n; + while (lo < hi) { + let mid = (lo + hi) / 2u; + if (data[at + mid * 2u + kx] < x) { + lo = mid + 1u; + } else { + hi = mid; + } + } + let i = clamp(lo, 1u, n - 1u); + let ax = data[at + (i - 1u) * 2u + kx]; + let ay = data[at + (i - 1u) * 2u + ky]; + let bx = data[at + i * 2u + kx]; + let by = data[at + i * 2u + ky]; + let t = clamp((x - ax) / max(bx - ax, 1e-12), 0.0, 1.0); + return ay + (by - ay) * t; +} + +// Shaper channel k's points (three counts, then the channels' (x, y) pairs). +fn fxl_shaper_at(s: u32, k: u32) -> vec2 { + var at = s + 3u; + for (var j = 0u; j < k; j++) { + at += u32(data[s + j]) * 2u; + } + return vec2(at, u32(data[s + k])); +} + +fn fxl_file_fwd(o: u32, sh: i32, c0: vec3, interp: u32) -> vec3 { + var c = c0; + if (sh >= 0) { + for (var k = 0u; k < 3u; k++) { + let a = fxl_shaper_at(u32(sh), k); + c[k] = fxl_shape(a.x, a.y, c[k], false); + } + } + return fxl_lut_apply(o, c, interp); +} + +// The inverse of a 1D table channel (ocio::FileXform::apply: points (table, domain)). +fn fxl_inv_1d(o: u32, i: u32, y: f32) -> f32 { + let n = u32(data[o]); + let dmin = data[o + 2u + i]; + let dmax = data[o + 5u + i]; + let b1 = o + 8u; + // partition_point over the table's values. + var lo = 0u; + var hi = n; + while (lo < hi) { + let mid = (lo + hi) / 2u; + if (data[b1 + mid * 3u + i] < y) { + lo = mid + 1u; + } else { + hi = mid; + } + } + let k = clamp(lo, 1u, n - 1u); + let ax = data[b1 + (k - 1u) * 3u + i]; + let bx = data[b1 + k * 3u + i]; + let ay = dmin + (dmax - dmin) * f32(k - 1u) / f32(n - 1u); + let by = dmin + (dmax - dmin) * f32(k) / f32(n - 1u); + let t = clamp((y - ax) / max(bx - ax, 1e-12), 0.0, 1.0); + return ay + (by - ay) * t; +} + +fn fxl_file(o: u32, sh: i32, c: vec3, interp: u32, inverse: bool) -> vec3 { + if (!inverse) { + return fxl_file_fwd(o, sh, c, interp); + } + let n1 = u32(data[o]); + let n3 = u32(data[o + 1u]); + if (n3 == 0u) { + var x = c; + if (n1 > 1u) { + x = vec3(fxl_inv_1d(o, 0u, c.x), fxl_inv_1d(o, 1u, c.y), fxl_inv_1d(o, 2u, c.z)); + } + if (sh >= 0) { + for (var k = 0u; k < 3u; k++) { + let a = fxl_shaper_at(u32(sh), k); + x[k] = fxl_shape(a.x, a.y, x[k], true); + } + } + return x; + } + // Damped fixed point from the target, as the CPU. + var x = c; + for (var it = 0; it < 40; it++) { + let y = fxl_file_fwd(o, sh, x, interp); + let e = c - y; + if (all(abs(e) < vec3(1e-6))) { + break; + } + x = x + e; + } + return x; +} + +// ---- transfer functions (ocio::Tf) + +const FXL_PQ_M1: f32 = 0.1593017578125; +const FXL_PQ_M2: f32 = 78.84375; +const FXL_PQ_C1: f32 = 0.8359375; +const FXL_PQ_C2: f32 = 18.8515625; +const FXL_PQ_C3: f32 = 18.6875; + +fn fxl_copysign(m: f32, v: f32) -> f32 { + return select(abs(m), -abs(m), v < 0.0 || (v == 0.0 && bitcast(v) != 0u)); +} + +fn fxl_decode(tf: u32, g: f32, v: f32) -> f32 { + switch (tf) { + case 1u: { + let a = abs(v); + var l: f32; + if (a <= 0.04045) { + l = a / 12.92; + } else { + l = pow((a + 0.055) / 1.055, 2.4); + } + return fxl_copysign(l, v); + } + case 2u: { + return fxl_copysign(powz(abs(v), g), v); + } + case 3u: { + if (v <= 0.155251141552511) { + return (v - 0.0729055341958355) / 10.5402377416545; + } + if (v < (log2(65504.0) + 9.72) / 17.52) { + return exp2(v * 17.52 - 9.72); + } + return 65504.0; + } + case 4u: { + let e = powz(max(v, 0.0), 1.0 / FXL_PQ_M2); + let l = powz(max(e - FXL_PQ_C1, 0.0) / (FXL_PQ_C2 - FXL_PQ_C3 * e), 1.0 / FXL_PQ_M1); + return l * 100.0; + } + default: { + return v; + } + } +} + +fn fxl_encode(tf: u32, g: f32, v: f32) -> f32 { + switch (tf) { + case 1u: { + let a = abs(v); + var e: f32; + if (a <= 0.0031308) { + e = a * 12.92; + } else { + e = 1.055 * pow(a, 1.0 / 2.4) - 0.055; + } + return fxl_copysign(e, v); + } + case 2u: { + return fxl_copysign(powz(abs(v), 1.0 / g), v); + } + case 3u: { + if (v <= 0.0078125) { + return 10.5402377416545 * v + 0.0729055341958355; + } + return (log2(v) + 9.72) / 17.52; + } + case 4u: { + let y = powz(max(v / 100.0, 0.0), FXL_PQ_M1); + return powz((FXL_PQ_C1 + FXL_PQ_C2 * y) / (1.0 + FXL_PQ_C3 * y), FXL_PQ_M2); + } + default: { + return v; + } + } +} + +// ---- ASC CDL (ocio::Cdl), args at `a`: slope (3), offset (3), power (3), sat, clamp, inverse. + +const FXL_LUMA: vec3 = vec3(0.2126, 0.7152, 0.0722); + +fn fxl_cdl(a: u32, c0: vec3) -> vec3 { + let slope = fxl_d3(a); + let offset = fxl_d3(a + 3u); + let power = fxl_d3(a + 6u); + let sat = data[a + 9u]; + let clampo = data[a + 10u] != 0.0; + let zero = vec3(0.0); + let one = vec3(1.0); + if (data[a + 11u] == 0.0) { + var o = vec3(0.0); + for (var i = 0u; i < 3u; i++) { + let v = c0[i] * slope[i] + offset[i]; + if (clampo) { + o[i] = powz(clamp(v, 0.0, 1.0), power[i]); + } else if (v >= 0.0) { + o[i] = powz(v, power[i]); + } else { + o[i] = v; + } + } + let l = o.x * FXL_LUMA.x + o.y * FXL_LUMA.y + o.z * FXL_LUMA.z; + o = l + sat * (o - l); + if (clampo) { + o = clamp(o, zero, one); + } + return o; + } + var c = c0; + if (clampo) { + c = clamp(c, zero, one); + } + let l = c.x * FXL_LUMA.x + c.y * FXL_LUMA.y + c.z * FXL_LUMA.z; + var s = sat; + if (abs(s) < 1e-9) { + s = 1e-9; + } + let o = l + (c - l) / s; + var r = vec3(0.0); + for (var i = 0u; i < 3u; i++) { + var v = o[i]; + if (clampo) { + v = clamp(v, 0.0, 1.0); + } + let pw = max(power[i], 1e-9); + var u = v; + if (v >= 0.0) { + u = powz(v, 1.0 / pw); + } + var sl = slope[i]; + if (abs(sl) < 1e-9) { + sl = 1e-9; + } + r[i] = (u - offset[i]) / sl; + } + if (clampo) { + r = clamp(r, zero, one); + } + return r; +} + +fn fxl_tonemap(v: f32, inverse: bool) -> f32 { + if (!inverse) { + if (v <= 0.0) { + return v; + } + return v * (1.0 + v / 256.0) / (1.0 + v); + } + if (v <= 0.0) { + return v; + } + let a = 1.0 / 256.0; + let b = 1.0 - v; + let c = -v; + return (-b + sqrt(b * b - 4.0 * a * c)) / (2.0 * a); +} + +// Run the colour program at data[0..] on a straight colour. +fn fxl_run(c0: vec3) -> vec3 { + var c = c0; + var pc = 0u; + for (var guard = 0; guard < 256; guard++) { + let op = u32(data[pc]); + switch (op) { + case 1u, 2u: { + let tf = u32(data[pc + 1u]); + let g = data[pc + 2u]; + for (var i = 0u; i < 3u; i++) { + if (op == 1u) { + c[i] = fxl_decode(tf, g, c[i]); + } else { + c[i] = fxl_encode(tf, g, c[i]); + } + } + pc += 3u; + } + case 3u: { + let r0 = fxl_d3(pc + 1u); + let r1 = fxl_d3(pc + 4u); + let r2 = fxl_d3(pc + 7u); + c = vec3(r0.x * c.x + r0.y * c.y + r0.z * c.z, r1.x * c.x + r1.y * c.y + r1.z * c.z, r2.x * c.x + r2.y * c.y + r2.z * c.z); + pc += 10u; + } + case 4u: { + c = fxl_cdl(pc + 1u, c); + pc += 13u; + } + case 5u: { + let inv = data[pc + 1u] != 0.0; + c = vec3(fxl_tonemap(c.x, inv), fxl_tonemap(c.y, inv), fxl_tonemap(c.z, inv)); + pc += 2u; + } + case 6u: { + let luma = fxl_d3(pc + 1u); + let l = c.x * luma.x + c.y * luma.y + c.z * luma.z; + if (l <= 0.0) { + c = max(c, vec3(0.0)); + } else { + let mn = min(min(c.x, c.y), c.z); + if (mn < 0.0) { + let t = clamp(l / (l - mn), 0.0, 1.0); + c = l + (c - l) * t; + } + } + pc += 4u; + } + case 7u: { + c = max(c, vec3(0.0)); + pc += 1u; + } + case 8u: { + let o = u32(data[pc + 1u]); + let interp = u32(data[pc + 2u]); + let inv = data[pc + 3u] != 0.0; + let sh = i32(data[pc + 4u]); + c = fxl_file(o, sh, c, interp, inv); + pc += 5u; + } + default: { + return c; + } + } + } + return c; +} + +// u[0].x = 0: OCIO pixels (unpremul); 1: colour ÷ alpha (Image::map_straight). +@compute @workgroup_size(16, 16) +fn fxl_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 a = px.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + var c = px.xyz / a; + if (P.u[0].x == 0u && a <= 1e-6) { + c = vec3(0.0); + } + textureStore(out, p, vec4(fxl_run(c) * a, a)); +} diff --git a/crates/gpu/src/tests_fx_lut.rs b/crates/gpu/src/tests_fx_lut.rs new file mode 100644 index 00000000..96beaa8d --- /dev/null +++ b/crates/gpu/src/tests_fx_lut.rs @@ -0,0 +1,175 @@ +//! GPU colour management vs the CPU effects (the oracle): Apply Color LUT, the OCIO effects, +//! Color Profile Converter and Lumetri's Input LUT / Look, direct on a buffer (full and half +//! resolution, as adjustment) and composited at 8 and 32 bpc. + +use effectcraft_keyframe::Value; + +use effectcraft_project::BitDepth; +use effectcraft_render::RenderOpts; +use effectcraft_time::Tick; + +use crate::tests::{Scene, check, compare_at, effect_case, effect_direct, n, opts, set}; + +/// [`effect_case`] for nearest-neighbour lattice lookups: an 8 bpc input that lands exactly on a +/// rounding boundary between two lattice points may pick the other one (the GPU's division is +/// not correctly rounded), so up to `allow` of the composited pixels may differ. +fn case_allow(id: &str, vals: &[(&str, Value)], allow: f64) { + effect_direct(id, vals, false); + 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); + s.push(bg); + let mut l = s.footage(70, 44); + s.effect(&mut l, id, vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + check(&format!("{id} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), allow); + check(&format!("{id} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), allow); + } +} + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn s(v: &str) -> Value { + Value::Str(v.to_string()) +} + +/// A graded lattice: `f` over an `n`³ grid (red fastest). +fn lattice(n: usize, f: impl Fn([f32; 3]) -> [f32; 3]) -> String { + let mut out = String::new(); + for b in 0..n { + for g in 0..n { + for r in 0..n { + let c = f([r, g, b].map(|i| i as f32 / (n - 1) as f32)); + out += &format!("{} {} {}\n", c[0], c[1], c[2]); + } + } + } + out +} + +fn grade(c: [f32; 3]) -> [f32; 3] { + [c[0].powf(0.8) * 1.05, c[1] * 0.9 + 0.08 * (c[0] * 5.0).sin(), c[2] * 0.7 + c[0] * 0.2 + 0.03] +} + +fn cube_3d() -> String { + format!("TITLE \"grade\"\nLUT_3D_SIZE 9\n{}", lattice(9, grade)) +} + +fn cube_1d_3d() -> String { + let mut s = String::from("LUT_1D_SIZE 16\nLUT_3D_SIZE 7\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1.2 1.2 1.2\n"); + for i in 0..16 { + let v = (i as f32 / 15.0).powf(0.6); + s += &format!("{} {} {}\n", v, v * 0.95, v.powf(1.2)); + } + s + &lattice(7, grade) +} + +fn cube_1d() -> String { + let mut s = String::from("LUT_1D_SIZE 32\n"); + for i in 0..32 { + let v = i as f32 / 31.0; + s += &format!("{} {} {}\n", v.powf(0.7), v.powf(1.3), 0.05 + v * 0.9); + } + s +} + +fn csp() -> String { + let mut s = String::from("CSPLUTV100\n3D\n\n3\n0 0.5 1\n0 0.3 1\n2\n0 1\n0 1\n4\n0 0.2 0.6 1\n0 0.4 0.7 1\n\n5 5 5\n"); + for b in 0..5 { + for g in 0..5 { + for r in 0..5 { + let c = grade([r, g, b].map(|i| i as f32 / 4.0)); + s += &format!("{} {} {}\n", c[0], c[1], c[2]); + } + } + } + s +} + +#[test] +fn apply_color_lut() { + effect_case("ec.utility.applylut", &[("lut", s(&cube_3d()))]); + effect_case("ec.utility.applylut", &[("lut", s(&cube_1d_3d()))]); + effect_case("ec.utility.applylut", &[("lut", s(&cube_1d()))]); + effect_case("ec.utility.applylut", &[("lut", s("/nonexistent/grade.cube"))]); +} + +#[test] +fn ocio_file_transform() { + for interp in 1..4 { + effect_case("ec.color.ociofile", &[("file", s(&cube_3d())), ("interpolation", e(interp))]); + } + case_allow("ec.color.ociofile", &[("file", s(&cube_3d())), ("interpolation", e(0))], 0.01); + case_allow("ec.color.ociofile", &[("file", s(&cube_1d_3d())), ("interpolation", e(0))], 0.01); + effect_case("ec.color.ociofile", &[("file", s(&cube_1d())), ("direction", e(1))]); + effect_case("ec.color.ociofile", &[("file", s(&cube_3d())), ("direction", e(1))]); + effect_case("ec.color.ociofile", &[("file", s(&csp())), ("interpolation", e(1))]); + effect_case("ec.color.ociofile", &[("file", s(&csp())), ("direction", e(1))]); + let ccc = r#"1.2 1 0.90.02 0 -0.011.1 0.9 1 + 0.8 0.8 0.81.4"#; + effect_case("ec.color.ociofile", &[("file", s(ccc))]); + effect_case("ec.color.ociofile", &[("file", s(ccc)), ("cccId", s("b")), ("direction", e(1))]); +} + +#[test] +fn ocio_cdl_space_display_look() { + let cdl = [("slopeRed", n(1.3)), ("offsetGreen", n(0.05)), ("powerBlue", n(1.4)), ("saturation", n(1.3))]; + effect_case("ec.color.ociocdl", &cdl); + effect_case("ec.color.ociocdl", &[cdl.as_slice(), &[("style", e(1)), ("offsetRed", n(-0.1))]].concat()); + effect_case("ec.color.ociocdl", &[cdl.as_slice(), &[("direction", e(1))]].concat()); + effect_case("ec.color.ociocdl", &[cdl.as_slice(), &[("direction", e(1)), ("style", e(1))]].concat()); + let spaces = effectcraft_effects::find("ec.color.ociocolorspace").unwrap(); + let names = match &spaces.params.iter().find(|p| p.id == "source").unwrap().ui { + effectcraft_project::ParamUi::Popup { options } => options.clone(), + _ => vec![], + }; + for (a, z) in [("sRGB", "ACEScg"), ("ACEScct", "Display P3"), ("Gamma 2.4 Rec.2020", "ACES2065-1"), ("CIE-XYZ-D65", "sRGB"), ("Raw", "sRGB")] { + let i = |n: &str| e(names.iter().position(|x| x == n).unwrap() as u32); + effect_case("ec.color.ociocolorspace", &[("source", i(a)), ("destination", i(z))]); + effect_case("ec.color.ociocolorspace", &[("source", i(a)), ("destination", i(z)), ("direction", e(1))]); + } + for display in 0..4 { + for view in 0..2 { + effect_case("ec.color.ociodisplay", &[("display", e(display)), ("view", e(view))]); + } + } + // (Inverting PQ and the roll-off together reaches values near 1000 whose matrix mix cancels + // in the small channels: f32 agrees to ~1e-5 of the pixel's largest channel there.) + effect_case("ec.color.ociodisplay", &[("display", e(3)), ("direction", e(1))]); + effect_case("ec.color.ociodisplay", &[("display", e(2)), ("view", e(1)), ("direction", e(1))]); + effect_case("ec.color.ociodisplay", &[("display", e(1)), ("direction", e(1))]); + effect_case("ec.color.ociolook", &[("look", e(3))]); + effect_case("ec.color.ociolook", &[("look", e(1)), ("looks", s("Vivid, -Cool"))]); + effect_case("ec.color.ociolook", &[("looks", s("High Contrast")), ("direction", e(1))]); +} + +#[test] +fn color_profile_converter() { + for (inp, out, intent) in [(0, 1, 1), (1, 3, 0), (4, 5, 2), (6, 2, 3), (5, 1, 0), (3, 4, 1)] { + effect_case("ec.utility.colorprofileconverter", &[("inputProfile", e(inp)), ("outputProfile", e(out)), ("intent", e(intent))]); + } + effect_case( + "ec.utility.colorprofileconverter", + &[("inputProfile", e(1)), ("outputProfile", e(3)), ("linearizeInputProfile", Value::Bool(true)), ("linearizeOutputProfile", Value::Bool(true))], + ); +} + +#[test] +fn lumetri_luts_and_looks() { + for look in 2..8 { + effect_case("ec.color.lumetri", &[("creative/look", e(look))]); + } + effect_case("ec.color.lumetri", &[("creative/look", e(4)), ("creative/lookIntensity", n(60.0)), ("basicCorrection/tone/exposure", n(0.3))]); + effect_case("ec.color.lumetri", &[("creative/look", e(1)), ("creative/lookFile", s(&cube_3d()))]); + effect_case( + "ec.color.lumetri", + &[("basicCorrection/inputLut", e(1)), ("basicCorrection/inputLutFile", s(&cube_1d_3d())), ("basicCorrection/saturation", n(120.0))], + ); + effect_case( + "ec.color.lumetri", + &[("basicCorrection/inputLut", e(1)), ("basicCorrection/inputLutFile", s(&cube_1d())), ("creative/look", e(1)), ("creative/lookFile", s(&cube_3d()))], + ); +} diff --git a/docs/effects.md b/docs/effects.md index 0568fb21..07219d9c 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**: 174 effects also run on the GPU compositor with identical results. +- **GPU**: 181 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). @@ -112,11 +112,11 @@ Effects are addressed by id (`ec..`) or display name in commands | Levels | `ec.color.levels` | 36 | GPU | 32 | Implemented | | Levels (Individual Controls) | `ec.color.levelsic` | 27 | GPU | 32 | Implemented | | Lumetri Color | `ec.color.lumetri` | 66 | GPU | 32 | Implemented | -| OCIO CDL Transform | `ec.color.ociocdl` | 12 | | 32 | Implemented | -| OCIO Color Space Transform | `ec.color.ociocolorspace` | 7 | | 32 | Implemented | -| OCIO Display Transform | `ec.color.ociodisplay` | 9 | | 32 | Implemented | -| OCIO File Transform | `ec.color.ociofile` | 4 | | 32 | Implemented | -| OCIO Look Transform | `ec.color.ociolook` | 6 | | 32 | Implemented | +| OCIO CDL Transform | `ec.color.ociocdl` | 12 | GPU | 32 | Implemented | +| OCIO Color Space Transform | `ec.color.ociocolorspace` | 7 | GPU | 32 | Implemented | +| OCIO Display Transform | `ec.color.ociodisplay` | 9 | GPU | 32 | Implemented | +| OCIO File Transform | `ec.color.ociofile` | 4 | GPU | 32 | Implemented | +| OCIO Look Transform | `ec.color.ociolook` | 6 | GPU | 32 | Implemented | | PS Arbitrary Map | `ec.color.psarbitrarymap` | 2 | GPU | 32 | Implemented | | Photo Filter | `ec.color.photofilter` | 4 | GPU | 32 | Implemented | | Selective Color | `ec.color.selectivecolor` | 38 | GPU | 32 | Implemented | @@ -412,10 +412,10 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Apply Color LUT | `ec.utility.applylut` | 0 | | 32 | Implemented | +| Apply Color LUT | `ec.utility.applylut` | 0 | GPU | 32 | Implemented | | CC Overbrights | `ec.utility.ccoverbrights` | 2 | | 32 | Implemented | | Cineon Converter | `ec.utility.cineon` | 7 | | 32 | Implemented | -| Color Profile Converter | `ec.utility.colorprofileconverter` | 7 | | 32 | Implemented | +| Color Profile Converter | `ec.utility.colorprofileconverter` | 7 | GPU | 32 | Implemented | | Face Measurements | `ec.utility.facemeasurements` | 14 | | 32 | Implemented | | Face Track Points | `ec.utility.facetrackpoints` | 26 | | 32 | Implemented | | Grow Bounds | `ec.utility.growbounds` | 1 | | 32 | Implemented | From 2e5ea051a8e6709f91a359ce4921183b95b9f713 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 17:53:23 -0400 Subject: [PATCH 3/6] M13.23: GPU Immersive Video effects VR Blur, Chromatic Aberrations, Color Gradients, Converter (all projections and cube layouts), De-Noise, Digital Glitch, Fractal Noise, Glow, Plane to Sphere, Rotate Sphere, Sharpen and Sphere to Plane run on the GPU (fx_vr.rs / fx_vr.wgsl) with the CPU's equirectangular maths: seam-wrapping, pole-continuing sampling and latitude-widened box blurs, per eye for stereo layouts. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 14 + crates/gpu/src/context.rs | 2 + crates/gpu/src/effects.rs | 1 + crates/gpu/src/fx_noise.rs | 2 +- crates/gpu/src/fx_vr.rs | 431 ++++++++++++++++++++ crates/gpu/src/lib.rs | 3 + crates/gpu/src/shaders/fx_vr.wgsl | 640 ++++++++++++++++++++++++++++++ crates/gpu/src/tests_fx_vr.rs | 154 +++++++ docs/effects.md | 26 +- 9 files changed, 1259 insertions(+), 14 deletions(-) create mode 100644 crates/gpu/src/fx_vr.rs create mode 100644 crates/gpu/src/shaders/fx_vr.wgsl create mode 100644 crates/gpu/src/tests_fx_vr.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index d11ad975..93e3598b 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -101,6 +101,7 @@ pub use ocio::color_stabilizer_maps; pub use channel3d::{CRYPTO_LAYERS, id_color, selection_hashes}; pub use ocio::{ColorOp, Straight, Tf, color_program}; pub use utility::{Lut, load_lut}; +pub use vr::{equi_dir as vr_equi_dir, rotation as vr_rotation}; /// Effect categories in Effects & Presets order. pub const CATEGORIES: &[&str] = &[ @@ -952,6 +953,19 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.color.ociofile", "ec.color.ociolook", "ec.utility.colorprofileconverter", + // Immersive Video. + "ec.vr.blur", + "ec.vr.chromaticaberrations", + "ec.vr.colorgradients", + "ec.vr.converter", + "ec.vr.denoise", + "ec.vr.digitalglitch", + "ec.vr.fractalnoise", + "ec.vr.glow", + "ec.vr.planetosphere", + "ec.vr.rotatesphere", + "ec.vr.sharpen", + "ec.vr.spheretoplane", ]; /// 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 34f3204f..832545af 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -316,6 +316,7 @@ impl GpuContext { include_str!("shaders/fx_extra.wgsl"), include_str!("shaders/fx_depth.wgsl"), include_str!("shaders/fx_lut.wgsl"), + include_str!("shaders/fx_vr.wgsl"), ] .concat(); let module = @@ -377,6 +378,7 @@ impl GpuContext { .chain(crate::fx_extra::KERNELS) .chain(crate::fx_depth::KERNELS) .chain(crate::fx_lut::KERNELS) + .chain(crate::fx_vr::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 e8ee547f..f6f73a8f 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -135,6 +135,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { _ 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), _ if crate::fx_lut::IDS.contains(&id) => crate::fx_lut::apply(e, id, ctx, b), + _ if crate::fx_vr::IDS.contains(&id) => crate::fx_vr::apply(e, id, ctx, b), _ => pointwise(e, id, ctx, b), } } diff --git a/crates/gpu/src/fx_noise.rs b/crates/gpu/src/fx_noise.rs index a0055e4f..545b7fb4 100644 --- a/crates/gpu/src/fx_noise.rs +++ b/crates/gpu/src/fx_noise.rs @@ -154,7 +154,7 @@ pub(crate) fn fractal(e: &mut Enc, ctx: &EffectCtx, b: GBuf, turbulent: bool) -> // ---------------------------------------------------------------- Median family (noise.rs, noise2.rs) /// noise::median_image (of the straight colour with alpha 1 when `straight`). -fn median_image(e: &mut Enc, img: &GpuImage, r: usize, straight: bool) -> GpuImage { +pub(crate) fn median_image(e: &mut Enc, img: &GpuImage, r: usize, straight: bool) -> GpuImage { let q = run(e, "fxn_quant", &with_u0([straight as u32, 0, 0, 0]), img, None, None); if r <= MEDIAN_BISECT_R { return run(e, "fxn_median", &with_u0([r as u32, 0, 0, 0]), &q, None, None); diff --git a/crates/gpu/src/fx_vr.rs b/crates/gpu/src/fx_vr.rs new file mode 100644 index 00000000..e719d606 --- /dev/null +++ b/crates/gpu/src/fx_vr.rs @@ -0,0 +1,431 @@ +//! GPU effects, Immersive Video family (kernels in `shaders/fx_vr.wgsl`, every entry point +//! prefixed `fxv_`): VR Blur, VR Chromatic Aberrations, VR Color Gradients, VR Converter, VR +//! De-Noise, VR Digital Glitch, VR Fractal Noise, VR Glow, VR Plane to Sphere, VR Rotate Sphere, +//! VR Sharpen and VR Sphere to Plane. +//! +//! The equirectangular maths of `effects::vr` (view directions, the projections and cube-map +//! layouts of VR Converter, seam-wrapping and pole-continuing bilinear sampling, the +//! latitude-widened seam-aware box blurs) is ported to WGSL operation for operation; rotation +//! matrices, per-row blur radii and VR Digital Glitch's per-band offsets are computed on the CPU +//! exactly as the CPU effect does. Stereo layouts run each eye on its own cropped image. Angles +//! between directions use atan2(|a × b|, a · b), the f32-stable form of the CPU's f64 acos; +//! VR Color Gradients' inverse-distance weights are normalised by the nearest point (the same +//! ratios without f32 overflow). VR De-Noise reuses the noise family's guided filter and median. + +use effectcraft_effects::EffectCtx; +use effectcraft_raster::hash_noise; + +use crate::context::{Enc, GpuImage, Params}; +use crate::effects::GBuf; + +/// Compute entry points in `fx_vr.wgsl`. +pub(crate) const KERNELS: &[&str] = &["fxv_convert", "fxv_box_h", "fxv_box_v", "fxv_point", "fxv_gen", "fxv_wrap_pad"]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[ + "ec.vr.blur", + "ec.vr.chromaticaberrations", + "ec.vr.colorgradients", + "ec.vr.converter", + "ec.vr.denoise", + "ec.vr.digitalglitch", + "ec.vr.fractalnoise", + "ec.vr.glow", + "ec.vr.planetosphere", + "ec.vr.rotatesphere", + "ec.vr.sharpen", + "ec.vr.spheretoplane", +]; + +/// 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 { + if b.img.width == 0 || b.img.height == 0 { + return Some(b); + } + let img = match id { + "ec.vr.blur" => blur(e, ctx, &b.img, b.scale)?, + "ec.vr.sharpen" => sharpen(e, ctx, &b.img, b.scale)?, + "ec.vr.glow" => glow(e, ctx, &b.img, b.scale)?, + "ec.vr.denoise" => denoise(e, ctx, &b)?, + "ec.vr.rotatesphere" => rotate(e, ctx, &b.img)?, + "ec.vr.converter" => converter(e, ctx, &b.img)?, + "ec.vr.spheretoplane" => { + let m = rot(ctx, "tilt", "pan", "roll"); + let fov = ctx.params.f("horizontalFov") as f32; + per_eye(e, &b.img, layout(ctx), |e, img, _| Some(convert(e, img, (0, 360.0), (5, fov), &m, img.width, img.height, None)))? + } + "ec.vr.planetosphere" => { + let fov = ctx.params.f("scale").clamp(1.0, 179.0); + let m = rot(ctx, "tilt", "pan", "roll"); + let feather = (ctx.params.f("feather") / 100.0).clamp(0.0, 1.0); + let t = (fov / 2.0).to_radians().tan(); + let (w, h) = (b.img.width, b.img.height); + convert(e, &b.img, (5, fov as f32), (0, 360.0), &m, w, h, (feather > 0.0).then_some([feather as f32, t as f32])) + } + "ec.vr.chromaticaberrations" => chromatic(e, ctx, &b.img)?, + "ec.vr.colorgradients" => gradients(e, ctx, &b.img)?, + "ec.vr.fractalnoise" => fractal(e, ctx, &b.img)?, + "ec.vr.digitalglitch" => glitch(e, ctx, &b.img)?, + _ => return None, + }; + Some(GBuf { img, ..b }) +} + +fn layout(ctx: &EffectCtx) -> u32 { + ctx.params.e("frameLayout") +} + +/// vr::eyes. +fn eyes(w: u32, h: u32, layout: u32) -> Vec<(u32, u32, u32, u32)> { + match layout { + 1 if h >= 2 => vec![(0, 0, w, h / 2), (0, h / 2, w, h - h / 2)], + 2 if w >= 2 => vec![(0, 0, w / 2, h), (w / 2, 0, w - w / 2, h)], + _ => vec![(0, 0, w, h)], + } +} + +/// vr::per_eye: run `f` on each eye's own image and reassemble. +fn per_eye(e: &mut Enc, img: &GpuImage, layout: u32, mut f: impl FnMut(&mut Enc, &GpuImage, usize) -> Option) -> Option { + let rects = eyes(img.width, img.height, layout); + if rects.len() == 1 { + return f(e, img, 0); + } + let out = e.scratch(img.width, img.height); + for (i, &(x, y, w, h)) in rects.iter().enumerate() { + let crop = e.scratch(w, h); + let mut p = Params::default(); + p.u[0] = [x, y, 0, 0]; + e.pixels("fxn_crop", &p, img, None, &crop, None); + let r = f(e, &crop, i)?; + e.copy_into(&r, &out, x, y); + } + Some(out) +} + +fn rot(ctx: &EffectCtx, t: &str, p: &str, r: &str) -> [[f32; 3]; 3] { + effectcraft_effects::vr_rotation(ctx.params.f(t), ctx.params.f(p), ctx.params.f(r)).map(|r| r.map(|v| v as f32)) +} + +fn put_matrix(p: &mut Params, at: usize, m: &[[f32; 3]; 3]) { + for (i, r) in m.iter().enumerate() { + p.f[at + i] = [r[0], r[1], r[2], 0.0]; + } +} + +/// vr::convert: re-project `src` (projection `from` = (kind, fov)) into a `w`×`h` frame in `to`, +/// output direction → source direction = mᵀ · d. `feather` = (feather, tan(fov / 2)) for VR +/// Plane to Sphere's edge. +#[allow(clippy::too_many_arguments)] +fn convert(e: &mut Enc, src: &GpuImage, from: (u32, f32), to: (u32, f32), m: &[[f32; 3]; 3], w: u32, h: u32, feather: Option<[f32; 2]>) -> GpuImage { + let mut p = Params::default(); + p.u[0] = [from.0, to.0, feather.is_some() as u32, 0]; + p.f[0] = [from.1, to.1, 0.0, 0.0]; + put_matrix(&mut p, 1, m); + if let Some(f) = feather { + p.f[4] = [f[0], f[1], 0.0, 0.0]; + } + let out = e.scratch(w, h); + e.pixels("fxv_convert", &p, src, None, &out, None); + out +} + +fn rotate(e: &mut Enc, ctx: &EffectCtx, img: &GpuImage) -> Option { + let pr = ctx.params; + if pr.f("tilt") == 0.0 && pr.f("pan") == 0.0 && pr.f("roll") == 0.0 { + return Some(img.clone()); + } + let mut m = rot(ctx, "tilt", "pan", "roll"); + if pr.b("invertRotation") { + m = [[m[0][0], m[1][0], m[2][0]], [m[0][1], m[1][1], m[2][1]], [m[0][2], m[1][2], m[2][2]]]; + } + per_eye(e, img, layout(ctx), |e, img, _| Some(convert(e, img, (0, 360.0), (0, 360.0), &m, img.width, img.height, None))) +} + +fn converter(e: &mut Enc, ctx: &EffectCtx, img: &GpuImage) -> Option { + let pr = ctx.params; + let from = (pr.e("sourceProjection"), pr.f("sourceHorizontalFov")); + let to = (pr.e("targetProjection"), pr.f("targetHorizontalFov")); + if from.0 == to.0 && (!matches!(from.0, 4 | 5) || from.1 == to.1) && pr.f("tilt") == 0.0 && pr.f("pan") == 0.0 && pr.f("roll") == 0.0 { + return Some(img.clone()); + } + let m = rot(ctx, "tilt", "pan", "roll"); + let (from, to) = ((from.0, from.1 as f32), (to.0, to.1 as f32)); + per_eye(e, img, layout(ctx), |e, img, _| Some(convert(e, img, from, to, &m, img.width, img.height, None))) +} + +// ---------------------------------------------------------------- seam-aware blur + +/// vr::sphere_blur: 3 latitude-widened, seam-wrapping horizontal box passes, then 3 +/// pole-continuing vertical ones. +fn sphere_blur(e: &mut Enc, img: &GpuImage, sigma: f64) -> GpuImage { + if sigma < 0.1 { + return img.clone(); + } + let (w, h) = (img.width as usize, img.height as usize); + let radii = effectcraft_effects::util::box_radii(sigma, 3); + let mut cur = img.clone(); + for &r in &radii { + let rows: Vec = (0..h) + .map(|y| { + let lat = (0.5 - (y as f64 + 0.5) / h as f64) * std::f64::consts::PI; + let rr = ((r as f64) / lat.cos().max(1e-3)).round().min((w / 2) as f64) as usize; + // box_row_wrap's own limit. + let rr = if w == 0 { 0 } else { rr.min((w - 1) / 2) }; + rr as f32 + }) + .collect(); + if rows.iter().all(|r| *r == 0.0) { + continue; + } + let buf = e.data(&rows); + let mut p = Params::default(); + p.u[0][0] = 256; + let out = e.scratch(cur.width, cur.height); + e.dispatch("fxv_box_h", &p, &cur, None, &out, Some(&buf), (cur.height.div_ceil(64), cur.width.div_ceil(256))); + cur = out; + } + for &r in &radii { + if r == 0 { + continue; + } + let block = (4 * r).clamp(32, 256) as u32; + let mut p = Params::default(); + p.u[0] = [block, r as u32, 0, 0]; + let out = e.scratch(cur.width, cur.height); + e.dispatch("fxv_box_v", &p, &cur, None, &out, None, (cur.width.div_ceil(64), cur.height.div_ceil(block))); + cur = out; + } + cur +} + +fn op(code: u32) -> Params { + let mut p = Params::default(); + p.u[0][0] = code; + p +} + +fn point(e: &mut Enc, p: &Params, src: &GpuImage, aux: Option<&GpuImage>) -> GpuImage { + let out = e.scratch(src.width, src.height); + e.pixels("fxv_point", p, src, aux, &out, None); + out +} + +fn blur(e: &mut Enc, ctx: &EffectCtx, img: &GpuImage, scale: f64) -> Option { + let s = ctx.params.f("blurriness").max(0.0) * scale * 0.5; + if s < 0.1 { + return Some(img.clone()); + } + per_eye(e, img, layout(ctx), |e, img, _| Some(sphere_blur(e, img, s))) +} + +fn sharpen(e: &mut Enc, ctx: &EffectCtx, img: &GpuImage, scale: f64) -> Option { + let amt = ctx.params.f("sharpenAmount") as f32 / 100.0; + if amt == 0.0 { + return Some(img.clone()); + } + let s = (1.5 * scale).max(0.5); + per_eye(e, img, layout(ctx), |e, img, _| { + let bl = sphere_blur(e, img, s); + let mut p = op(2); + p.f[0][0] = amt; + Some(point(e, &p, img, Some(&bl))) + }) +} + +fn glow(e: &mut Enc, ctx: &EffectCtx, img: &GpuImage, scale: f64) -> Option { + let pr = ctx.params; + let bright = pr.f("glowBrightness") as f32 / 100.0; + if bright <= 0.0 { + return Some(img.clone()); + } + let radius = pr.f("glowRadius").max(0.0) * scale; + let tc = pr.color("tintColor"); + let mut hp = op(0); + hp.u[0][1] = pr.b("useTintColor") as u32; + hp.f[0] = [pr.f("luminanceThreshold") as f32 / 100.0, pr.f("glowSaturation") as f32 / 100.0, 0.0, 0.0]; + hp.f[1] = [tc[0], tc[1], tc[2], 0.0]; + let mut add = op(1); + add.f[0][0] = bright; + per_eye(e, img, layout(ctx), |e, img, _| { + let hi = point(e, &hp, img, None); + let g = sphere_blur(e, &hi, (radius * 0.5).max(0.5)); + Some(point(e, &add, img, Some(&g))) + }) +} + +fn denoise(e: &mut Enc, ctx: &EffectCtx, b: &GBuf) -> Option { + let pr = ctx.params; + let level = pr.f("noiseLevel").max(0.0) / 100.0; + if level <= 0.0 { + return Some(b.img.clone()); + } + let r = ((1.0 + level * 4.0) * b.scale).max(1.0) as usize; + if pr.e("noiseType") == 1 { + let mr = ((level * 3.0 * b.scale).round() as usize).max(1); + return per_eye(e, &b.img, layout(ctx), |e, img, _| Some(crate::fx_noise::median_image(e, img, mr, false))); + } + let eps = (level * level * 0.02) as f32; + let detail = (pr.f("detail") / 100.0).clamp(0.0, 1.0) as f32; + per_eye(e, &b.img, layout(ctx), |e, img, _| { + let pad = (r * 2 + 1) as u32; + let (pw, ph) = (img.width + 2 * pad, img.height + 2 * pad); + if !e.g.fits(pw, ph) { + return None; + } + let padded = e.scratch(pw, ph); + let mut p = Params::default(); + p.u[0][0] = pad; + e.pixels("fxv_wrap_pad", &p, img, None, &padded, None); + let guide = point(e, &op(4), &padded, None); + let f = crate::fx_noise::guided_filter(e, &guide, &padded, r, eps.max(1e-6)); + let crop = e.scratch(img.width, img.height); + let mut p = Params::default(); + p.u[0] = [pad, pad, 0, 0]; + e.pixels("fxn_crop", &p, &f, None, &crop, None); + let mut p = op(3); + p.f[0][0] = detail; + Some(point(e, &p, &crop, Some(img))) + }) +} + +// ---------------------------------------------------------------- generators + +/// BLEND_OPTS → (none, blend mode id). +fn blend(i: u32) -> (u32, u32) { + use effectcraft_color::BlendMode::*; + let m = match i { + 0 => return (1, 0), + 2 => Add, + 3 => Multiply, + 4 => Screen, + 5 => Overlay, + _ => Normal, + }; + (0, crate::ops::mode_id(m)) +} + +fn chromatic(e: &mut Enc, ctx: &EffectCtx, img: &GpuImage) -> Option { + let pr = ctx.params; + let k = [pr.f("aberrationRed"), pr.f("aberrationGreen"), pr.f("aberrationBlue")].map(|v| (v / 100.0 * 0.1) as f32); + if k.iter().all(|v| *v == 0.0) { + return Some(img.clone()); + } + let r = effectcraft_effects::vr_rotation(pr.f("centerTilt"), pr.f("centerPan"), 0.0); + let c = [r[0][2], r[1][2], r[2][2]].map(|v| v as f32); + let mut p = op(0); + p.u[0][1] = pr.b("falloffInvert") as u32; + p.f[0] = [k[0], k[1], k[2], (pr.f("falloff") / 100.0).clamp(0.0, 1.0) as f32]; + p.f[1] = [c[0], c[1], c[2], 0.0]; + per_eye(e, img, layout(ctx), |e, img, _| Some(gen_pass(e, &p, img, None))) +} + +fn gen_pass(e: &mut Enc, p: &Params, img: &GpuImage, data: Option<&wgpu::Buffer>) -> GpuImage { + let out = e.scratch(img.width, img.height); + e.pixels("fxv_gen", p, img, None, &out, data); + out +} + +fn gradients(e: &mut Enc, ctx: &EffectCtx, img: &GpuImage) -> Option { + let pr = ctx.params; + let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); + let mut d = vec![]; + for i in 1..=5 { + if !pr.b(&format!("enablePoint{i}")) { + continue; + } + let pt = pr.v2(&format!("point{i}")); + let dir = effectcraft_effects::vr_equi_dir(pt[0], pt[1], lw, lh); + let c = pr.color(&format!("color{i}")); + d.extend([dir[0] as f32, dir[1] as f32, dir[2] as f32, c[0], c[1], c[2]]); + } + if d.is_empty() { + return Some(img.clone()); + } + let (none, mode) = blend(pr.e("blendingMode")); + let mut p = op(1); + p.u[0] = [1, (d.len() / 6) as u32, none, mode]; + p.f[0] = [pr.f("blend").max(0.1) as f32, (pr.f("opacity") / 100.0) as f32, 0.0, 0.0]; + let buf = e.data(&d); + per_eye(e, img, layout(ctx), |e, img, _| Some(gen_pass(e, &p, img, Some(&buf)))) +} + +fn fractal(e: &mut Enc, ctx: &EffectCtx, img: &GpuImage) -> Option { + let pr = ctx.params; + let g = |id: &str, d: f64| pr.get(id).map(|v| v.as_f64()).unwrap_or(d); + let octaves = pr.f("complexity").clamp(1.0, 20.0); + let scale = (pr.f("transform/scale") / 100.0).max(0.01) as f32; + let (none, mode) = blend(pr.e("blendingMode")); + let mut p = op(2); + p.u[0] = [2, pr.e("fractalType"), none, mode]; + p.u[1] = [octaves.ceil() as u32, pr.f("randomSeed") as u32, pr.b("invert") as u32, 0]; + p.f[0] = [ + pr.f("contrast") as f32 / 100.0, + pr.f("brightness") as f32 / 100.0, + (g("subSettings/subInfluence", 50.0) / 100.0).clamp(0.0, 1.0) as f32, + (g("subSettings/subScaling", 50.0) / 100.0).clamp(0.1, 1.0) as f32, + ]; + p.f[1] = [4.0 / scale, (pr.f("evolution") / 360.0) as f32, (pr.f("opacity") / 100.0) as f32, octaves.fract() as f32]; + put_matrix(&mut p, 2, &rot(ctx, "transform/tilt", "transform/pan", "transform/roll")); + per_eye(e, img, layout(ctx), |e, img, _| Some(gen_pass(e, &p, img, None))) +} + +fn glitch(e: &mut Enc, ctx: &EffectCtx, img: &GpuImage) -> Option { + let pr = ctx.params; + let amp = (pr.f("masterAmplitude") / 100.0).max(0.0); + if amp <= 0.0 { + return Some(img.clone()); + } + let g = |id: &str, d: f64| pr.get(id).map(|v| v.as_f64()).unwrap_or(d); + let rate = pr.f("distortionRate").max(0.0); + let geo = pr.f("geometricDistortion") / 100.0 * amp; + let (hdisp, vdisp) = (g("geometric/horizontalDisplacement", 100.0) / 100.0, g("geometric/verticalDisplacement", 0.0) / 100.0); + let complexity = pr.f("distortionComplexity").clamp(1.0, 100.0); + let rgb = pr.f("colorDistortion") / 100.0 * amp; + let offs = [g("color/redOffset", -1.0), g("color/greenOffset", 0.0), g("color/blueOffset", 1.0)]; + let scan = (pr.f("scanlines") / 100.0 * amp) as f32; + let spacing = g("scanlineSpacing", 2.0).round().max(2.0) as usize; + let seed = (pr.f("randomSeed") as u32) ^ ctx.seed; + let mut evo = g("distortionEvolution", 0.0) / 360.0; + if pr.b("evolutionOptions/cycleEvolution") { + evo = evo.rem_euclid(g("evolutionOptions/cycle", 1.0).round().max(1.0)); + } + let base = if rate > 0.0 { ctx.time * rate } else { 0.0 } + evo; + let slot = base.floor() as i64 as u32; + let frac = base - base.floor(); + let tr = effectcraft_effects::vr_rotation(g("target/tilt", 0.0), g("target/pan", 0.0), 0.0); + let target = [tr[0][2], tr[1][2], tr[2][2]].map(|v| v as f32); + let radius = g("target/radius", 0.0).to_radians() as f32; + let feather = g("target/feather", 0.0).to_radians() as f32; + per_eye(e, img, layout(ctx), |e, img, eye| { + let (w, h) = (img.width as f64, img.height as f64); + let bands = complexity.round() as u32; + // Per row: horizontal / vertical shift, channel split and scan-line darkening. + let mut d = Vec::with_capacity(img.height as usize * 4); + for y in 0..img.height as usize { + let band = ((y as f64 / h) * bands as f64) as u32; + let at = |s: u32| { + let r0 = hash_noise(band, s, seed ^ (eye as u32 * 977)); + let on = r0 < 0.35; + let shift = if on { (hash_noise(band, s + 1, seed) as f64 * 2.0 - 1.0) * geo } else { 0.0 }; + let split = if on { rgb * w * 0.01 * (1.0 + hash_noise(band, s + 2, seed) as f64) } else { 0.0 }; + (shift, split) + }; + let (s0, p0) = at(slot); + let (shift, split) = if frac > 0.0 { + let (s1, p1) = at(slot + 1); + (s0 + (s1 - s0) * frac, p0 + (p1 - p0) * frac) + } else { + (s0, p0) + }; + let dark = if y % spacing == 0 { 1.0 - scan * 0.5 } else { 1.0 }; + d.extend([(shift * w * 0.15 * hdisp) as f32, (shift * h * 0.15 * vdisp) as f32, split as f32, dark]); + } + let buf = e.data(&d); + let mut p = op(3); + p.u[0][0] = 3; + p.f[0] = [offs[0] as f32, offs[1] as f32, offs[2] as f32, 0.0]; + p.f[1] = [target[0], target[1], target[2], 0.0]; + p.f[2] = [radius, feather, 0.0, 0.0]; + Some(gen_pass(e, &p, img, Some(&buf))) + }) +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 4cfd9ebe..ca82a5a3 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -57,6 +57,7 @@ mod fx_lut; mod fx_noise; mod fx_stylize; mod fx_tone; +mod fx_vr; mod fx_warp; mod ops; mod particles; @@ -221,6 +222,8 @@ mod tests_fx_stylize; #[cfg(test)] mod tests_fx_tone; #[cfg(test)] +mod tests_fx_vr; +#[cfg(test)] mod tests_fx_warp; #[cfg(test)] mod tests_particles; diff --git a/crates/gpu/src/shaders/fx_vr.wgsl b/crates/gpu/src/shaders/fx_vr.wgsl new file mode 100644 index 00000000..3a1d06d0 --- /dev/null +++ b/crates/gpu/src/shaders/fx_vr.wgsl @@ -0,0 +1,640 @@ +// ---------------------------------------------------------------- Immersive Video (fx_vr.rs) +// +// Equirectangular frames: longitude across, latitude down, the front (+Z) at the centre, y up +// (effects::vr). Every function mirrors its CPU namesake. + +const FXV_PI: f32 = 3.14159265358979; + +fn fxv_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)); +} + +// vr::equi_dir. +fn fxv_equi_dir(x: f32, y: f32, w: f32, h: f32) -> vec3 { + let lon = (x / w - 0.5) * 2.0 * FXV_PI; + let lat = (0.5 - y / h) * FXV_PI; + return vec3(cos(lat) * sin(lon), sin(lat), cos(lat) * cos(lon)); +} + +// vr::equi_px. +fn fxv_equi_px(d: vec3, w: f32, h: f32) -> vec2 { + let lon = atan2(d.x, d.z); + let lat = asin(clamp(d.y, -1.0, 1.0)); + return vec2((lon / (2.0 * FXV_PI) + 0.5) * w, (0.5 - lat / FXV_PI) * h); +} + +// Angle between two unit directions (the CPU's acos of the dot, in its f32-stable form). +fn fxv_angle(a: vec3, b: vec3) -> f32 { + return atan2(length(cross(a, b)), dot(a, b)); +} + +// vr::sample_equi's texel fetch: wraps horizontally, continues over the poles. +fn fxv_equi_at(xi0: i32, yi0: i32, w: i32, h: i32) -> vec4 { + var xi = xi0; + var yi = yi0; + if (yi < 0) { + yi = -1 - yi; + xi += w / 2; + } else if (yi >= h) { + yi = 2 * h - 1 - yi; + xi += w / 2; + } + let xm = ((xi % w) + w) % w; + return textureLoad(src, vec2(xm, clamp(yi, 0, h - 1)), 0); +} + +// vr::sample_equi (bilinear). +fn fxv_sample_equi(x: f32, y: f32) -> vec4 { + let d = vec2(textureDimensions(src)); + let fx = x - 0.5; + let fy = y - 0.5; + let x0 = floor(fx); + let y0 = floor(fy); + let tx = fx - x0; + let ty = fy - y0; + let xi = i32(x0); + let yi = i32(y0); + let a = fxv_equi_at(xi, yi, d.x, d.y); + let b = fxv_equi_at(xi + 1, yi, d.x, d.y); + let c = fxv_equi_at(xi, yi + 1, d.x, d.y); + let e = fxv_equi_at(xi + 1, yi + 1, d.x, d.y); + let top = a + (b - a) * tx; + let bot = c + (e - c) * tx; + return top + (bot - top) * ty; +} + +// ---- VR Converter's projections (vr::Proj) + +fn fxv_face_fw(f: u32) -> vec3 { + var v = array, 6>(vec3(1.0, 0.0, 0.0), vec3(-1.0, 0.0, 0.0), vec3(0.0, 1.0, 0.0), vec3(0.0, -1.0, 0.0), vec3(0.0, 0.0, 1.0), vec3(0.0, 0.0, -1.0)); + return v[f]; +} + +fn fxv_face_r(f: u32) -> vec3 { + var v = array, 6>(vec3(0.0, 0.0, -1.0), vec3(0.0, 0.0, 1.0), vec3(1.0, 0.0, 0.0), vec3(1.0, 0.0, 0.0), vec3(1.0, 0.0, 0.0), vec3(-1.0, 0.0, 0.0)); + return v[f]; +} + +fn fxv_face_u(f: u32) -> vec3 { + var v = array, 6>(vec3(0.0, 1.0, 0.0), vec3(0.0, 1.0, 0.0), vec3(0.0, 0.0, -1.0), vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 0.0), vec3(0.0, 1.0, 0.0)); + return v[f]; +} + +// vr::cube_grid: the grid size. +fn fxv_grid(kind: u32) -> vec2 { + switch (kind) { + case 1u: { + return vec2(4.0, 3.0); + } + case 2u, 6u, 7u: { + return vec2(3.0, 2.0); + } + default: { + return vec2(6.0, 1.0); + } + } +} + +// vr::cube_grid: (column, row) of face f. +fn fxv_cell(kind: u32, f: u32) -> vec2 { + switch (kind) { + case 1u: { + var c = array, 6>(vec2(2.0, 1.0), vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 2.0), vec2(1.0, 1.0), vec2(3.0, 1.0)); + return c[f]; + } + case 2u: { + var c = array, 6>(vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(2.0, 0.0), vec2(0.0, 1.0), vec2(1.0, 1.0), vec2(2.0, 1.0)); + return c[f]; + } + case 6u, 7u: { + var c = array, 6>(vec2(2.0, 0.0), vec2(0.0, 0.0), vec2(2.0, 1.0), vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 1.0)); + return c[f]; + } + default: { + return vec2(f32(f), 0.0); + } + } +} + +fn fxv_is_cube(kind: u32) -> bool { + return (kind >= 1u && kind <= 3u) || kind == 6u || kind == 7u; +} + +// Proj::dir: the direction seen at frame position (x, y); w = 2 when outside the image. +fn fxv_dir(kind: u32, fov: f32, x: f32, y: f32, w: f32, h: f32) -> vec4 { + let none = vec4(0.0, 0.0, 0.0, 2.0); + if (kind == 0u) { + return vec4(fxv_equi_dir(x, y, w, h), 1.0); + } + if (fxv_is_cube(kind)) { + let g = fxv_grid(kind); + let cw = w / g.x; + let ch = h / g.y; + let cx = floor(x / cw); + let cy = floor(y / ch); + var f = 6u; + for (var i = 0u; i < 6u; i++) { + let c = fxv_cell(kind, i); + if (f == 6u && c.x == cx && c.y == cy) { + f = i; + } + } + if (f == 6u) { + return none; + } + var a = (x - cx * cw) / cw * 2.0 - 1.0; + var b = (y - cy * ch) / ch * 2.0 - 1.0; + if (kind == 7u) { + a = tan(a * FXV_PI / 4.0); + b = tan(b * FXV_PI / 4.0); + } + return vec4(normalize(fxv_face_fw(f) + a * fxv_face_r(f) - b * fxv_face_u(f)), 1.0); + } + if (kind == 8u) { + let s = min(w, h) / 2.0; + let u = (x - w / 2.0) / s; + let v = (y - h / 2.0) / s; + let r = sqrt(u * u + v * v); + if (r > 1.0) { + return none; + } + let th = r * FXV_PI / 2.0; + var cu = 0.0; + var cv = 0.0; + if (r > 1e-12) { + cu = u / r; + cv = v / r; + } + return vec4(sin(th) * cu, cos(th), sin(th) * cv, 1.0); + } + if (kind == 4u) { + let half = radians(clamp(fov, 1.0, 360.0) / 2.0); + let s = min(w, h) / 2.0; + let u = (x - w / 2.0) / s; + let v = (h / 2.0 - y) / s; + let r = sqrt(u * u + v * v); + if (r > 1.0) { + return none; + } + let th = r * half; + var cu = 0.0; + var cv = 0.0; + if (r > 1e-12) { + cu = u / r; + cv = v / r; + } + return vec4(sin(th) * cu, sin(th) * cv, cos(th), 1.0); + } + let t = tan(radians(clamp(fov, 1.0, 179.0) / 2.0)); + let u = (x / w * 2.0 - 1.0) * t; + let v = (1.0 - y / h * 2.0) * t * h / w; + return vec4(normalize(vec3(u, v, 1.0)), 1.0); +} + +// Proj::sample of `src` (in projection `kind`) in direction d. +fn fxv_proj_sample(kind: u32, fov: f32, d: vec3) -> vec4 { + let dims = vec2(textureDimensions(src)); + let w = dims.x; + let h = dims.y; + if (kind == 0u) { + let q = fxv_equi_px(d, w, h); + return fxv_sample_equi(q.x, q.y); + } + if (fxv_is_cube(kind)) { + // The face facing d most (the last one on ties, as max_by). + var f = 0u; + var best = dot(d, fxv_face_fw(0u)); + for (var i = 1u; i < 6u; i++) { + let k = dot(d, fxv_face_fw(i)); + if (k >= best) { + best = k; + f = i; + } + } + let fw = fxv_face_fw(f); + let k = dot(d, fw); + var a = dot(d, fxv_face_r(f)) / k; + var b = -dot(d, fxv_face_u(f)) / k; + if (kind == 7u) { + a = atan(a) / (FXV_PI / 4.0); + b = atan(b) / (FXV_PI / 4.0); + } + let g = fxv_grid(kind); + let cw = w / g.x; + let ch = h / g.y; + let c = fxv_cell(kind, f); + let x0 = c.x * cw; + let y0 = c.y * ch; + let x = clamp(x0 + (a + 1.0) * 0.5 * cw, x0 + 0.5, x0 + cw - 0.5); + let y = clamp(y0 + (b + 1.0) * 0.5 * ch, y0 + 0.5, y0 + ch - 0.5); + return sample_bilinear_clamped(src, x, y); + } + if (kind == 8u) { + let th = acos(clamp(d.y, -1.0, 1.0)); + if (th > FXV_PI / 2.0 + 1e-6) { + return vec4(0.0); + } + let s = min(w, h) / 2.0; + let r = th / (FXV_PI / 2.0); + let l = sqrt(d.x * d.x + d.z * d.z); + var cu = 0.0; + var cv = 0.0; + if (l > 1e-12) { + cu = d.x / l; + cv = d.z / l; + } + return sample_bilinear_clamped(src, w / 2.0 + cu * r * s, h / 2.0 + cv * r * s); + } + if (kind == 4u) { + let half = radians(clamp(fov, 1.0, 360.0) / 2.0); + let th = acos(clamp(d.z, -1.0, 1.0)); + if (th > half + 1e-6) { + return vec4(0.0); + } + let s = min(w, h) / 2.0; + let r = th / half; + let l = sqrt(d.x * d.x + d.y * d.y); + var cu = 0.0; + var cv = 0.0; + if (l > 1e-12) { + cu = d.x / l; + cv = d.y / l; + } + return sample_bilinear_clamped(src, w / 2.0 + cu * r * s, h / 2.0 - cv * r * s); + } + if (d.z <= 1e-9) { + return vec4(0.0); + } + let t = tan(radians(clamp(fov, 1.0, 179.0) / 2.0)); + let u = d.x / d.z / t; + let v = d.y / d.z / (t * h / w); + if (abs(u) > 1.0 || abs(v) > 1.0) { + return vec4(0.0); + } + return sample_bilinear_clamped(src, (u + 1.0) * 0.5 * w, (1.0 - v) * 0.5 * h); +} + +// mᵀ · d with m's rows in f[at..at + 3]. +fn fxv_mtv(at: u32, d: vec3) -> vec3 { + let r0 = P.f[at].xyz; + let r1 = P.f[at + 1u].xyz; + let r2 = P.f[at + 2u].xyz; + return r0 * d.x + r1 * d.y + r2 * d.z; +} + +// m · d. +fn fxv_mv(at: u32, d: vec3) -> vec3 { + return vec3(dot(P.f[at].xyz, d), dot(P.f[at + 1u].xyz, d), dot(P.f[at + 2u].xyz, d)); +} + +// vr::convert. u[0] = (from kind, to kind, feather); f[0] = (from fov, to fov); +// f[1..4] = rotation rows; f[4] = (feather, tan(fov / 2)) (VR Plane to Sphere). +@compute @workgroup_size(16, 16) +fn fxv_convert(@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 w = f32(dims.x); + let h = f32(dims.y); + let x = f32(p.x) + 0.5; + let y = f32(p.y) + 0.5; + let dd = fxv_dir(P.u[0].y, P.f[0].y, x, y, w, h); + var o = vec4(0.0); + if (dd.w < 1.5) { + o = fxv_proj_sample(P.u[0].x, P.f[0].x, fxv_mtv(1u, dd.xyz)); + } + if (P.u[0].z != 0u) { + let d = fxv_mtv(1u, fxv_equi_dir(x, y, w, h)); + if (d.z > 0.0) { + let t = P.f[4].y; + let u = abs(d.x / d.z / t); + let v = abs(d.y / d.z / (t * h / w)); + let edge = 1.0 - max(u, v); + o = o * clamp(edge / P.f[4].x, 0.0, 1.0); + } + } + textureStore(out, p, o); +} + +// ---- seam-aware box blurs + +// Horizontal wrap-around box mean (vr::box_row_wrap); each row's radius in data[y]. +// u[0].x = block; dispatched over (row, block). +@compute @workgroup_size(64, 1) +fn fxv_box_h(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let y = i32(gid.x); + let block = i32(P.u[0].x); + let b0 = i32(gid.y) * block; + let w = dims.x; + if (y >= dims.y || b0 >= w) { + return; + } + let r = i32(data[y]); + let b1 = min(b0 + block, w); + if (r == 0) { + for (var x = b0; x < b1; x++) { + textureStore(out, vec2(x, y), textureLoad(src, vec2(x, y), 0)); + } + return; + } + let norm = 1.0 / f32(2 * r + 1); + var acc = vec4(0.0); + for (var i = b0 - r; i <= b0 + r; i++) { + acc += textureLoad(src, vec2(((i % w) + w) % w, y), 0); + } + for (var x = b0; x < b1; x++) { + textureStore(out, vec2(x, y), acc * norm); + let add = textureLoad(src, vec2((x + r + 1) % w, y), 0); + let sub = textureLoad(src, vec2(((x - r) % w + w) % w, y), 0); + acc += add - sub; + } +} + +// vr::box_cols_pole's fetch. +fn fxv_pole_at(x0: i32, y0: i32, w: i32, h: i32) -> vec4 { + var x = x0; + var y = y0; + if (y < 0) { + y = -1 - y; + x = (x + w / 2) % w; + } else if (y >= h) { + y = 2 * h - 1 - y; + x = (x + w / 2) % w; + } + return textureLoad(src, vec2(x, clamp(y, 0, h - 1)), 0); +} + +// Vertical box mean continuing over the poles. u[0] = (block, radius); dispatched over +// (column, block). +@compute @workgroup_size(64, 1) +fn fxv_box_v(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let x = i32(gid.x); + let block = i32(P.u[0].x); + let r = i32(P.u[0].y); + let b0 = i32(gid.y) * block; + if (x >= dims.x || b0 >= dims.y) { + return; + } + let norm = 1.0 / f32(2 * r + 1); + var acc = vec4(0.0); + for (var j = b0 - r; j <= b0 + r; j++) { + acc += fxv_pole_at(x, j, dims.x, dims.y); + } + let b1 = min(b0 + block, dims.y); + for (var y = b0; y < b1; y++) { + textureStore(out, vec2(x, y), acc * norm); + acc += fxv_pole_at(x, y + r + 1, dims.x, dims.y) - fxv_pole_at(x, y - r, dims.x, dims.y); + } +} + +// vr::wrap_pad: u[0].x = pad. +@compute @workgroup_size(16, 16) +fn fxv_wrap_pad(@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 s = vec2(textureDimensions(src)); + let pad = i32(P.u[0].x); + var sx = p.x - pad; + var sy = p.y - pad; + if (sy < 0) { + sy = -1 - sy; + sx += s.x / 2; + } else if (sy >= s.y) { + sy = 2 * s.y - 1 - sy; + sx += s.x / 2; + } + textureStore(out, p, textureLoad(src, vec2(((sx % s.x) + s.x) % s.x, clamp(sy, 0, s.y - 1)), 0)); +} + +// Per-pixel steps. u[0].x = 0: VR Glow's highlights (u[0].y tint; f[0] = (threshold, +// saturation); f[1] = tint); 1: add the glow (aux) × f[0].x; 2: VR Sharpen (aux = blurred, +// f[0].x = amount); 3: VR De-Noise's detail mix (src = filtered, aux = original, f[0].x); +// 4: the guided filter's guide (straight luminance). +@compute @workgroup_size(16, 16) +fn fxv_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) { + case 0u: { + let s = fxv_unpremul(px); + let th = P.f[0].x; + let l = luminance(s.xyz); + var k = 0.0; + if (l > th) { + k = min((l - th) / max(1.0 - th, 1e-3), 4.0); + } + var c = s.xyz * k; + let lc = luminance(c); + c = lc + (c - lc) * P.f[0].y; + if (P.u[0].y != 0u) { + c = lc * P.f[1].xyz; + } + o = vec4(c * s.w, s.w * min(k, 1.0)); + } + case 1u: { + let b = P.f[0].x; + o = vec4(px.xyz + q.xyz * b, min(px.w + q.w * b, 1.0)); + } + case 2u: { + let hi = max(px.w, 0.0) * 4.0; + let c = clamp(px.xyz + (px.xyz - q.xyz) * P.f[0].x, vec3(0.0), vec3(hi)); + o = vec4(c, px.w); + } + case 3u: { + var m = px + (q - px) * P.f[0].x; + m.w = clamp(m.w, 0.0, 1.0); + o = vec4(max(m.xyz, vec3(0.0)), m.w); + } + case 4u: { + o = vec4(luminance(fxv_unpremul(px).xyz)); + } + default: {} + } + textureStore(out, p, o); +} + +// vr::composite of a generated straight colour (alpha 1) over the pixel. u[0].z = 1: mode None +// (replace); u[0].w = blend mode id; f[0].y = opacity. +fn fxv_composite(d: vec4, c: vec3, opacity: f32) -> vec4 { + let s = vec4(c * opacity, opacity); + if (P.u[0].z != 0u) { + return s; + } + var r = blend_pixel(P.u[0].w, d, s, 0.5); + let a = d.w; + if (a < 1.0) { + let k = a / max(r.w, 1e-6); + if (k < 1.0) { + r = r * k; + } + } + return r; +} + +// Generated looks. u[0].x = 0: VR Chromatic Aberrations; 1: VR Color Gradients; 2: VR Fractal +// Noise; 3: VR Digital Glitch (parameter layouts in fx_vr.rs). +@compute @workgroup_size(16, 16) +fn fxv_gen(@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 w = f32(dims.x); + let h = f32(dims.y); + let fx = f32(p.x) + 0.5; + let fy = f32(p.y) + 0.5; + let px = textureLoad(src, p, 0); + var o = px; + switch (P.u[0].x) { + // f[0] = (aberration R, G, B, falloff); f[1] = centre; u[0].y = invert. + case 0u: { + let d = fxv_equi_dir(fx, fy, w, h); + let center = P.f[1].xyz; + let ax = cross(center, d); + let al = length(ax); + let th = atan2(al, dot(center, d)); + var weight = powz(th / FXV_PI, 1.0 + P.f[0].w * 3.0); + if (P.u[0].y != 0u) { + weight = 1.0 - weight; + } + var res = vec4(0.0); + for (var c = 0; c < 3; c++) { + var s = px; + if (al >= 1e-9) { + let new_th = th * (1.0 - P.f[0][c] * weight); + let perp = normalize(d - center * cos(th)); + let nd = center * cos(new_th) + perp * sin(new_th); + let q = fxv_equi_px(nd, w, h); + s = fxv_sample_equi(q.x, q.y); + } + res[c] = s[c]; + res.w = max(res.w, s.w); + } + o = res; + } + // data: points (direction, colour); u[0].y = count; f[0] = (power, opacity). + case 1u: { + let d = fxv_equi_dir(fx, fy, w, h); + let n = P.u[0].y; + var amin = 10.0; + var exact = -1; + for (var i = 0u; i < n; i++) { + let a = fxv_angle(d, vec3(data[i * 6u], data[i * 6u + 1u], data[i * 6u + 2u])); + if (a < 1e-6 && exact < 0) { + exact = i32(i); + } + amin = min(amin, a); + } + var c: vec3; + if (exact >= 0) { + let e = u32(exact); + c = vec3(data[e * 6u + 3u], data[e * 6u + 4u], data[e * 6u + 5u]); + } else { + var acc = vec3(0.0); + var ws = 0.0; + for (var i = 0u; i < n; i++) { + let a = fxv_angle(d, vec3(data[i * 6u], data[i * 6u + 1u], data[i * 6u + 2u])); + let wgt = pow(amin / a, P.f[0].x); + acc += vec3(data[i * 6u + 3u], data[i * 6u + 4u], data[i * 6u + 5u]) * wgt; + ws += wgt; + } + c = acc / ws; + } + o = fxv_composite(px, c, P.f[0].y); + } + // u[0].y = type; u[1] = (octaves, seed, invert); f[0] = (contrast, brightness, sub + // influence, sub scaling); f[1] = (base frequency, evolution, opacity, octave fraction); + // f[2..5] = rotation rows. + case 2u: { + let d = fxv_mv(2u, fxv_equi_dir(fx, fy, w, h)); + let kind = P.u[0].y; + let n_oct = P.u[1].x; + let evo = P.f[1].y; + var v = 0.0; + var best = 0.0; + var amp = 1.0; + var freq = P.f[1].x; + var norm = 0.0; + for (var oc = 0u; oc < n_oct; oc++) { + var wgt = 1.0; + if (oc + 1u == n_oct && P.f[1].w > 0.0) { + wgt = P.f[1].w; + } + let q = vec3(d.x * freq + evo * 0.37, d.y * freq + evo * 0.71, d.z * freq + evo * 0.59); + var nv = value_noise(q.x + f32(oc) * 17.3, q.y, q.z, P.u[1].y + oc); + if (kind == 1u) { + nv = 1.0 - abs(nv * 2.0 - 1.0); + } else if (kind == 3u) { + let t = clamp(abs(nv * 2.0 - 1.0) / 0.12, 0.0, 1.0); + nv = 1.0 - t * t; + } + best = max(best, nv * amp); + v += nv * amp * wgt; + norm += amp * wgt; + amp *= P.f[0].z; + freq /= P.f[0].w; + } + if (kind == 2u) { + v = best; + } else { + v = v / max(norm, 1e-6); + } + v = (v - 0.5) * P.f[0].x + 0.5 + P.f[0].y; + if (P.u[1].z != 0u) { + v = 1.0 - v; + } + v = clamp(v, 0.0, 1.0); + o = fxv_composite(px, vec3(v), P.f[1].z); + } + // data: per row (dx, dy, split, dark); f[0] = channel offsets; f[1] = target direction; + // f[2] = (radius, feather) in radians. + case 3u: { + let y = u32(p.y); + let dx = data[y * 4u]; + let dy = data[y * 4u + 1u]; + let split = data[y * 4u + 2u]; + let dark = data[y * 4u + 3u]; + let radius = P.f[2].x; + let feather = P.f[2].y; + var k = 1.0; + if (radius > 0.0) { + let a = fxv_angle(fxv_equi_dir(fx, fy, w, h), P.f[1].xyz); + if (a <= radius) { + k = 1.0; + } else if (feather > 0.0) { + k = clamp(1.0 - (a - radius) / feather, 0.0, 1.0); + } else { + k = 0.0; + } + } + if (k > 0.0) { + let sx = fx - dx * k; + let sy = clamp(fy - dy * k, 0.5, h - 0.5); + let c0 = fxv_sample_equi(sx + P.f[0].x * split * k, sy); + let c1 = fxv_sample_equi(sx + P.f[0].y * split * k, sy); + let c2 = fxv_sample_equi(sx + P.f[0].z * split * k, sy); + let dk = 1.0 - (1.0 - dark) * k; + o = vec4(c0.x * dk, c1.y * dk, c2.z * dk, max(max(c0.w, c1.w), c2.w)); + } + } + default: {} + } + textureStore(out, p, o); +} diff --git a/crates/gpu/src/tests_fx_vr.rs b/crates/gpu/src/tests_fx_vr.rs new file mode 100644 index 00000000..1c1db57c --- /dev/null +++ b/crates/gpu/src/tests_fx_vr.rs @@ -0,0 +1,154 @@ +//! GPU Immersive Video family vs the CPU effects (the oracle): direct on a buffer (full and half +//! resolution, as adjustment) and composited at 8 and 32 bpc, in mono and both stereo layouts. +//! +//! Exception: re-projections that cross a cube-map face edge or a fisheye rim may pick the +//! neighbouring face / side for directions within f32 rounding of the edge (the CPU decides in +//! f64), so up to 0.5 % of the pixels of those cases may differ. + +use effectcraft_keyframe::Value; +use effectcraft_project::BitDepth; +use effectcraft_render::RenderOpts; +use effectcraft_time::Tick; + +use crate::tests::{Scene, c, check, compare_at, diff, gpu, n, opts, pattern, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +/// The effect alone on a buffer (`allow` = fraction of pixels allowed over 1e-3). +fn direct(id: &str, vals: &[(&str, Value)], allow: f64) { + let Some(g) = gpu() else { return }; + let spec = effectcraft_effects::find(id).unwrap(); + let size = [96.0, 48.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 adjustment in [false, true] { + for scale in [1.0, 0.5] { + let img = pattern(7, (size[0] * scale) as u32, (size[1] * scale) as u32); + let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale }; + let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.4, layer_size: size, seed: 11, adjustment, env: 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:?} adj {adjustment} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}", + d.over, + d.total, + d.max, + d.worst + ); + } + } +} + +fn case_allow(id: &str, vals: &[(&str, Value)], allow: f64) { + direct(id, vals, allow); + 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); + s.push(bg); + let mut l = s.footage(96, 48); + s.effect(&mut l, id, vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + check(&format!("{id} {vals:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), allow); + check(&format!("{id} {vals:?} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), allow); + } +} + +fn case(id: &str, vals: &[(&str, Value)]) { + case_allow(id, vals, 0.0); +} + +/// `vals` in mono and in both stereo layouts. +fn layouts(id: &str, vals: &[(&str, Value)]) { + for l in 0..3 { + case(id, &[vals, &[("frameLayout", e(l))]].concat()); + } +} + +#[test] +fn blur_sharpen_glow() { + layouts("ec.vr.blur", &[("blurriness", n(12.0))]); + case("ec.vr.blur", &[("blurriness", n(60.0))]); + layouts("ec.vr.sharpen", &[("sharpenAmount", n(60.0))]); + layouts("ec.vr.glow", &[("luminanceThreshold", n(40.0)), ("glowRadius", n(10.0))]); + case("ec.vr.glow", &[("useTintColor", on()), ("glowSaturation", n(150.0)), ("glowBrightness", n(250.0))]); +} + +#[test] +fn denoise() { + layouts("ec.vr.denoise", &[("noiseLevel", n(40.0))]); + case("ec.vr.denoise", &[("noiseLevel", n(60.0)), ("detail", n(30.0))]); + case("ec.vr.denoise", &[("noiseType", e(1)), ("noiseLevel", n(50.0))]); + case("ec.vr.denoise", &[("noiseType", e(1)), ("noiseLevel", n(80.0)), ("frameLayout", e(1))]); +} + +#[test] +fn rotate_and_planes() { + layouts("ec.vr.rotatesphere", &[("tilt", n(20.0)), ("pan", n(35.0)), ("roll", n(-10.0))]); + case("ec.vr.rotatesphere", &[("tilt", n(-40.0)), ("pan", n(170.0)), ("invertRotation", on())]); + layouts("ec.vr.spheretoplane", &[("horizontalFov", n(100.0)), ("tilt", n(10.0)), ("pan", n(-30.0))]); + case("ec.vr.planetosphere", &[("scale", n(80.0)), ("pan", n(20.0)), ("tilt", n(-15.0))]); + case("ec.vr.planetosphere", &[("scale", n(120.0)), ("feather", n(30.0)), ("roll", n(25.0))]); +} + +#[test] +fn converter() { + for from in 0..9 { + for to in [0u32, 1, 7] { + if from == to { + continue; + } + case_allow( + "ec.vr.converter", + &[("sourceProjection", e(from)), ("targetProjection", e(to)), ("sourceHorizontalFov", n(200.0)), ("pan", n(15.0))], + 0.005, + ); + } + } + for to in [2u32, 3, 4, 5, 6, 8] { + case_allow("ec.vr.converter", &[("targetProjection", e(to)), ("targetHorizontalFov", n(120.0)), ("tilt", n(12.0))], 0.005); + } + case_allow("ec.vr.converter", &[("targetProjection", e(1)), ("frameLayout", e(1))], 0.005); + case_allow("ec.vr.converter", &[("targetProjection", e(6)), ("frameLayout", e(2)), ("roll", n(30.0))], 0.005); +} + +#[test] +fn generated() { + layouts("ec.vr.chromaticaberrations", &[("aberrationRed", n(40.0)), ("aberrationBlue", n(-30.0))]); + case("ec.vr.chromaticaberrations", &[("centerTilt", n(30.0)), ("centerPan", n(-60.0)), ("falloff", n(10.0)), ("falloffInvert", on())]); + layouts("ec.vr.colorgradients", &[]); + case("ec.vr.colorgradients", &[("blend", n(5.0)), ("opacity", n(60.0)), ("blendingMode", e(5)), ("enablePoint5", on())]); + for mode in 1..5 { + case("ec.vr.colorgradients", &[("blendingMode", e(mode)), ("color2", c(0.2, 0.9, 0.4))]); + } + layouts("ec.vr.fractalnoise", &[]); + for kind in 1..4 { + case("ec.vr.fractalnoise", &[("fractalType", e(kind)), ("complexity", n(4.5)), ("evolution", n(70.0)), ("transform/pan", n(30.0))]); + } + case("ec.vr.fractalnoise", &[("blendingMode", e(4)), ("opacity", n(70.0)), ("invert", on()), ("transform/scale", n(250.0))]); + layouts("ec.vr.digitalglitch", &[]); + case( + "ec.vr.digitalglitch", + &[ + ("distortionEvolution", n(100.0)), + ("geometric/verticalDisplacement", n(80.0)), + ("target/radius", n(60.0)), + ("target/feather", n(30.0)), + ("target/pan", n(40.0)), + ("scanlineSpacing", n(3.0)), + ], + ); +} diff --git a/docs/effects.md b/docs/effects.md index 07219d9c..26310ace 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**: 181 effects also run on the GPU compositor with identical results. +- **GPU**: 193 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). @@ -219,18 +219,18 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| VR Blur | `ec.vr.blur` | 2 | | 32 | Implemented | -| VR Chromatic Aberrations | `ec.vr.chromaticaberrations` | 8 | | 32 | Implemented | -| VR Color Gradients | `ec.vr.colorgradients` | 19 | | 32 | Implemented | -| VR Converter | `ec.vr.converter` | 8 | | 32 | Implemented | -| VR De-Noise | `ec.vr.denoise` | 4 | | 32 | Implemented | -| VR Digital Glitch | `ec.vr.digitalglitch` | 21 | | 32 | Implemented | -| VR Fractal Noise | `ec.vr.fractalnoise` | 16 | | 32 | Implemented | -| VR Glow | `ec.vr.glow` | 7 | | 32 | Implemented | -| VR Plane to Sphere | `ec.vr.planetosphere` | 6 | | 32 | Implemented | -| VR Rotate Sphere | `ec.vr.rotatesphere` | 5 | | 32 | Implemented | -| VR Sharpen | `ec.vr.sharpen` | 2 | | 32 | Implemented | -| VR Sphere to Plane | `ec.vr.spheretoplane` | 5 | | 32 | Implemented | +| VR Blur | `ec.vr.blur` | 2 | GPU | 32 | Implemented | +| VR Chromatic Aberrations | `ec.vr.chromaticaberrations` | 8 | GPU | 32 | Implemented | +| VR Color Gradients | `ec.vr.colorgradients` | 19 | GPU | 32 | Implemented | +| VR Converter | `ec.vr.converter` | 8 | GPU | 32 | Implemented | +| VR De-Noise | `ec.vr.denoise` | 4 | GPU | 32 | Implemented | +| VR Digital Glitch | `ec.vr.digitalglitch` | 21 | GPU | 32 | Implemented | +| VR Fractal Noise | `ec.vr.fractalnoise` | 16 | GPU | 32 | Implemented | +| VR Glow | `ec.vr.glow` | 7 | GPU | 32 | Implemented | +| VR Plane to Sphere | `ec.vr.planetosphere` | 6 | GPU | 32 | Implemented | +| VR Rotate Sphere | `ec.vr.rotatesphere` | 5 | GPU | 32 | Implemented | +| VR Sharpen | `ec.vr.sharpen` | 2 | GPU | 32 | Implemented | +| VR Sphere to Plane | `ec.vr.spheretoplane` | 5 | GPU | 32 | Implemented | ## Keying From 1ec9336d7c4c5ba4d62fbcdebce024320afb62b3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 18:35:22 -0400 Subject: [PATCH 4/6] M13.23: GPU simulation render passes CC Rainfall, Snowfall, Star Burst, Bubbles, Drizzle, Hair, Mr. Mercury, Caustics, Wave World, Foam, Shatter, Card Dance and Card Wipe render on the GPU (fx_sim.rs / fx_sim.wgsl). The per-frame simulation / layout stays on the CPU and is shared with the CPU effect through plans (effects::sprite_plan, piece_plan, bubble_list, mercury_blobs, drizzle_drops, wave_world_plan, caustics_setup); a tiled item rasteriser composites sprites, bubbles, blob fields and textured pieces in the CPU's order and shading. Layer-dependent sprite colours (refracting rain, star-burst and hair roots) are sampled per pixel; piece plans read the frame back once. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 16 + crates/effects/src/sim.rs | 280 +++++++++---- crates/effects/src/sim2.rs | 368 ++++++++++++---- crates/effects/src/sim3.rs | 42 +- crates/effects/src/transition.rs | 31 +- crates/gpu/src/context.rs | 2 + crates/gpu/src/effects.rs | 1 + crates/gpu/src/fx_sim.rs | 424 +++++++++++++++++++ crates/gpu/src/lib.rs | 3 + crates/gpu/src/shaders/fx_sim.wgsl | 653 +++++++++++++++++++++++++++++ crates/gpu/src/tests_fx_sim.rs | 154 +++++++ docs/effects.md | 28 +- 12 files changed, 1796 insertions(+), 206 deletions(-) create mode 100644 crates/gpu/src/fx_sim.rs create mode 100644 crates/gpu/src/shaders/fx_sim.wgsl create mode 100644 crates/gpu/src/tests_fx_sim.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 93e3598b..a3d07eec 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -100,6 +100,8 @@ 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}; pub use ocio::{ColorOp, Straight, Tf, color_program}; +pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan}; +pub use sim2::{CausticsSetup, Piece, PiecePlan, PieceTex, WavePlan, Waves, caustics_setup, piece_plan, wave_world_plan}; pub use utility::{Lut, load_lut}; pub use vr::{equi_dir as vr_equi_dir, rotation as vr_rotation}; @@ -966,6 +968,20 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.vr.rotatesphere", "ec.vr.sharpen", "ec.vr.spheretoplane", + // Simulation render passes. + "ec.sim.ccrainfall", + "ec.sim.ccsnowfall", + "ec.sim.ccstarburst", + "ec.sim.ccbubbles", + "ec.sim.ccdrizzle", + "ec.sim.cchair", + "ec.sim.ccmrmercury", + "ec.sim.caustics", + "ec.sim.waveworld", + "ec.sim.foam", + "ec.sim.shatter", + "ec.sim.carddance", + "ec.transition.cardwipe", ]; /// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 18beeb76..1745dccf 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -48,7 +48,7 @@ const BAND: usize = 16; /// How rasterised items combine with what is already in the target. #[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(crate) enum Acc { +pub enum Acc { /// Premultiplied "over" (later items on top). Over, /// Additive (glows, light). @@ -128,7 +128,7 @@ pub(crate) fn raster( // ------------------------------------------------------------------ sprites #[derive(Clone, Copy, PartialEq, Debug)] -pub(crate) enum Shape { +pub enum Shape { /// Soft round dot (Gaussian-like falloff). Soft, /// Hard anti-aliased disc. @@ -151,7 +151,7 @@ pub(crate) enum Shape { /// A particle sprite in buffer pixels; `c` is straight colour + opacity. #[derive(Clone, Copy, Debug)] -pub(crate) struct Sprite { +pub struct Sprite { pub x: f32, pub y: f32, pub r: f32, @@ -166,6 +166,112 @@ impl Sprite { } } +/// Where a sprite's colour comes from when it depends on the layer's pixels (resolved by +/// [`SpritePlan::resolve`] on the CPU, per pixel by the GPU compositor). Positions are buffer +/// pixels. +#[derive(Clone, Copy, Debug)] +pub enum Tint { + /// The sprite's own colour. + Fixed, + /// CC Rainfall's refracting drops: where the layer shows (alpha > 0), colour × (layer + /// colour × 0.6 + 0.5) sampled at (x, y). + Refract { x: f64, y: f64 }, + /// CC Star Burst: the layer's colour at (x, y), opacity × its alpha (none where clear). + Source { x: f64, y: f64 }, + /// CC Hair: the root's colour at (x, y) mixed with the hair colour (`inherit` = 1 keeps the + /// root's), × `shade`, + `spec`; no strand where the root's alpha is below ½. + Root { x: f64, y: f64, hair: [f32; 3], inherit: f32, shade: f32, spec: f32 }, +} + +/// How a sprite layer meets the original layer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Post { + /// [`combine`] with this mode (0 Composite … 5 sprites only). + Combine(u32), + /// The sprites alone. + Replace, + /// The sprites, mixed toward the original by this fraction. + Lerp(f32), + /// The sprites over opaque black. + OverBlack, +} + +/// What a sprite-drawing effect draws this frame: built on the CPU (closed-form or from the +/// simulation state), rasterised by [`SpritePlan::finish`] or the GPU compositor. +#[derive(Clone, Debug)] +pub struct SpritePlan { + pub sprites: Vec, + /// Per sprite (empty = all [`Tint::Fixed`]). + pub tints: Vec, + pub acc: Acc, + pub post: Post, +} + +impl SpritePlan { + /// Resolve layer-dependent colours against the layer `src`. + pub fn resolve(&mut self, src: &Image) { + let tints = std::mem::take(&mut self.tints); + self.sprites.par_iter_mut().zip(tints.par_iter()).for_each(|(sp, t)| match *t { + Tint::Fixed => {} + Tint::Refract { x, y } => { + let (sc, sa) = unpremul(src.sample_bilinear(x, y)); + if sa > 0.0 { + let c = sp.c; + sp.c = [(sc[0] * 0.6 + 0.5) * c[0], (sc[1] * 0.6 + 0.5) * c[1], (sc[2] * 0.6 + 0.5) * c[2], c[3]]; + } + } + Tint::Source { x, y } => { + let (c, a) = unpremul(src.sample_bilinear(x, y)); + sp.c = if a <= 0.0 { [0.0; 4] } else { [c[0], c[1], c[2], a * sp.c[3]] }; + } + Tint::Root { x, y, hair, inherit, shade, spec } => { + let root = src.sample_bilinear(x, y); + if root[3] < 0.5 { + sp.c = [0.0; 4]; + return; + } + let (rc, _) = unpremul(root); + let base: [f32; 3] = std::array::from_fn(|k| rc[k] + (hair[k] - rc[k]) * (1.0 - inherit)); + sp.c = [base[0] * shade + spec, base[1] * shade + spec, base[2] * shade + spec, sp.c[3]]; + } + }); + } + + /// Rasterise (after [`SpritePlan::resolve`]) and meet the original layer `b`. + pub fn finish(&self, mut b: Buf) -> Buf { + let fx = splat(b.img.width, b.img.height, &self.sprites, self.acc); + b.img = match self.post { + Post::Combine(m) => combine(&b.img, &fx, m), + Post::Replace => fx, + Post::Lerp(t) => { + let mut o = fx; + o.data.par_iter_mut().zip(b.img.data.par_iter()).for_each(|(o, s0)| *o = crate::util::lerp4(*o, *s0, t)); + o + } + Post::OverBlack => combine(&Image::filled(b.img.width, b.img.height, [0.0, 0.0, 0.0, 1.0]), &fx, 0), + }; + b + } +} + +/// The sprite plan of a sprite-drawing simulation effect for buffer geometry `b` (its pixels +/// are not read), `None` for other effects or settings drawn otherwise (the GPU compositor's +/// render pass). +pub fn sprite_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { + match id { + "ec.sim.ccrainfall" => Some(rainfall_plan(ctx, b)), + "ec.sim.ccsnowfall" => Some(snowfall_plan(ctx, b)), + "ec.sim.ccstarburst" => Some(star_burst_plan(ctx, b)), + "ec.sim.cchair" => crate::sim3::hair_plan(ctx, b), + "ec.sim.waveworld" => crate::sim2::wave_world_plan(ctx, b).and_then(|p| match p { + crate::sim2::WavePlan::Wire(sp) => Some(sp), + crate::sim2::WavePlan::Height { .. } => None, + }), + "ec.sim.foam" => crate::sim2::foam_plan(ctx, b), + _ => None, + } +} + fn sprite_bbox(s: &Sprite) -> Option<[f32; 4]> { if s.c[3] <= 1e-5 || s.r <= 0.0 { return None; @@ -316,6 +422,12 @@ fn wrap(v: f32, lo: f32, span: f32) -> f32 { } fn rainfall(ctx: &EffectCtx, b: Buf) -> Buf { + let mut plan = rainfall_plan(ctx, &b); + plan.resolve(&b.img); + plan.finish(b) +} + +fn rainfall_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { let pr = ctx.params; let n = pr.f("drops").clamp(0.0, 100_000.0) as u32; let size = pr.f("size").max(0.0) as f32; @@ -334,11 +446,10 @@ fn rainfall(ctx: &EffectCtx, b: Buf) -> Buf { let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); let s = b.scale as f32; let t = ctx.time as f32; - let src = &b.img; let (ox, oy) = (off[0] as f32 - lw * 0.5, off[1] as f32 - lh * 0.5); - let sprites: Vec = (0..n) + let (sprites, tints): (Vec, Vec) = (0..n) .into_par_iter() - .filter_map(|i| { + .map(|i| { let z = h(i, 1, seed); let k = depth_k(z, depth); let vy = speed / 4000.0 * lh * 2.2 * k; @@ -349,29 +460,25 @@ fn rainfall(ctx: &EffectCtx, b: Buf) -> Buf { let y = wrap(h(i, 3, seed) * (lh + 2.0 * margin) + vy * t + oy, -margin, lh + 2.0 * margin); let x = wrap(h(i, 4, seed) * (lw + 2.0 * margin) + w * t + ox, -margin, lw + 2.0 * margin); let (bx, by) = b.to_px([x as f64, y as f64]); - let mut c = [color[0], color[1], color[2], opacity * (0.35 + 0.65 * k)]; - if refract { - // Refracting drops show the scene behind them, brightened and offset. - let (sc, sa) = unpremul(src.sample_bilinear(bx + 2.0 * s as f64, by)); - let m = |a: f32, cc: f32| (a * 0.6 + 0.5) * cc; - if sa > 0.0 { - c = [m(sc[0], color[0]), m(sc[1], color[1]), m(sc[2], color[2]), c[3]]; - } - } + let c = [color[0], color[1], color[2], opacity * (0.35 + 0.65 * k)]; + // Refracting drops show the scene behind them, brightened and offset. + let tint = if refract { Tint::Refract { x: bx + 2.0 * s as f64, y: by } } else { Tint::Fixed }; let r = (size * 0.6 * k * s).max(0.25); - Some(Sprite::new(bx as f32, by as f32, r, c, Shape::Line { dx: -lenx * s, dy: -len * s })) + (Sprite::new(bx as f32, by as f32, r, c, Shape::Line { dx: -lenx * s, dy: -len * s }), tint) }) - .collect(); - weather_out(b, &sprites, mode, with_orig) + .unzip(); + SpritePlan { sprites, tints, acc: Acc::Over, post: weather_post(mode, with_orig) } } -fn weather_out(mut b: Buf, sprites: &[Sprite], mode: u32, with_orig: bool) -> Buf { - let fx = splat(b.img.width, b.img.height, sprites, Acc::Over); - b.img = if with_orig { combine(&b.img, &fx, if mode == 1 { 3 } else { 0 }) } else { fx }; - b +fn weather_post(mode: u32, with_orig: bool) -> Post { + if with_orig { Post::Combine(if mode == 1 { 3 } else { 0 }) } else { Post::Replace } } fn snowfall(ctx: &EffectCtx, b: Buf) -> Buf { + snowfall_plan(ctx, &b).finish(b) +} + +fn snowfall_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { let pr = ctx.params; let n = pr.f("flakes").clamp(0.0, 200_000.0) as u32; let size = pr.f("size").max(0.0) as f32; @@ -414,35 +521,36 @@ fn snowfall(ctx: &EffectCtx, b: Buf) -> Buf { Sprite::new(bx as f32, by as f32, r * s, [color[0], color[1], color[2], opacity * (0.4 + 0.6 * k)], Shape::Soft) }) .collect(); - weather_out(b, &sprites, mode, with_orig) + SpritePlan { sprites, tints: vec![], acc: Acc::Over, post: weather_post(mode, with_orig) } } // ------------------------------------------------------------------ CC Bubbles -struct Bubble { - x: f32, - y: f32, - r: f32, +/// A CC Bubbles bubble in buffer pixels. +#[derive(Clone, Copy, Debug)] +pub struct Bubble { + pub x: f32, + pub y: f32, + pub r: f32, /// Source sample centre (buffer px). - sx: f32, - sy: f32, + pub sx: f32, + pub sy: f32, } -fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// CC Bubbles' bubbles this frame (closed-form in time) for buffer geometry `b`. +pub fn bubble_list(ctx: &EffectCtx, b: &Buf) -> Vec { let pr = ctx.params; let n = pr.f("bubbleAmount").clamp(0.0, 10_000.0) as u32; let speed = pr.f("bubbleSpeed") as f32; let wamp = pr.f("wobbleAmplitude") as f32; let wfreq = pr.f("wobbleFrequency") as f32; let bsize = pr.f("bubbleSize").max(0.0) as f32; - let metal = pr.e("reflectionType") == 1; - let shading = pr.e("shadingType"); let seed = ctx.seed.wrapping_mul(0x27d4); let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); let s = b.scale as f32; let t = ctx.time as f32; let base = lw.min(lh) * 0.035 * bsize; - let list: Vec = (0..n) + (0..n) .map(|i| { let r = base * (0.5 + h(i, 1, seed)); let travel = lh + 2.0 * r; @@ -454,7 +562,14 @@ fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf { let (sx, sy) = b.to_px([x0 as f64, y as f64]); Bubble { x: bx as f32, y: by as f32, r: r * s, sx: sx as f32, sy: sy as f32 } }) - .collect(); + .collect() +} + +fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let metal = pr.e("reflectionType") == 1; + let shading = pr.e("shadingType"); + let list = bubble_list(ctx, &b); let src = b.img.clone(); let mut out = Image::new(src.width, src.height); raster( @@ -495,28 +610,20 @@ fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf { // ------------------------------------------------------------------ CC Drizzle -fn drizzle(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// CC Drizzle's live drops this frame (buffer px: centre x, y, ring radius, amplitude); +/// `None` = no drops yet (the layer passes through). +pub fn drizzle_drops(ctx: &EffectCtx, b: &Buf) -> Option> { let pr = ctx.params; let rate = pr.f("dripRate").max(0.0); let life = pr.f("longevity").max(0.01); - let rippling = (pr.f("rippling") / 360.0).max(0.05) as f32; - let disp = pr.f("displacement") as f32; - let height = pr.f("rippleHeight") as f32 / 100.0; let spreading = pr.f("spreading") as f32; - let li = pr.f("light/lightIntensity") as f32 / 100.0; - let lc = pr.color("light/lightColor"); - let lheight = pr.f("light/lightHeight") as f32 / 100.0; - let ldir = (pr.f("light/lightDirection") as f32).to_radians(); - let ambient = pr.f("shading/ambient") as f32 / 100.0; - let diffuse = pr.f("shading/diffuse") as f32 / 100.0; - let specular = pr.f("shading/specular") as f32 / 100.0; - let rough = pr.f("shading/roughness").max(0.001) as f32; + let height = pr.f("rippleHeight") as f32 / 100.0; let seed = ctx.seed.wrapping_mul(0x165667b1); let t = ctx.time; let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); let s = b.scale as f32; if rate <= 0.0 || t < 0.0 { - return b; + return None; } // Drops born at k / rate with hashed positions; alive while younger than the longevity. let k0 = ((t - life) * rate).floor().max(0.0) as u32; @@ -531,13 +638,31 @@ fn drizzle(ctx: &EffectCtx, mut b: Buf) -> Buf { let (x, y) = b.to_px([(h(k, 1, seed) * lw) as f64, (h(k, 2, seed) * lh) as f64]); let radius = spreading * age * lh / 400.0 * s; let amp = height * (1.0 - age / life as f32) * (0.6 + 0.4 * h(k, 3, seed)); - drops.push((x as f32, y as f32, radius, amp)); + drops.push([x as f32, y as f32, radius, amp]); } + Some(drops) +} + +fn drizzle(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let Some(drops) = drizzle_drops(ctx, &b) else { return b }; + let rippling = (pr.f("rippling") / 360.0).max(0.05) as f32; + let disp = pr.f("displacement") as f32; + let li = pr.f("light/lightIntensity") as f32 / 100.0; + let lc = pr.color("light/lightColor"); + let lheight = pr.f("light/lightHeight") as f32 / 100.0; + let ldir = (pr.f("light/lightDirection") as f32).to_radians(); + let ambient = pr.f("shading/ambient") as f32 / 100.0; + let diffuse = pr.f("shading/diffuse") as f32 / 100.0; + let specular = pr.f("shading/specular") as f32 / 100.0; + let rough = pr.f("shading/roughness").max(0.001) as f32; + let lh = ctx.layer_size[1] as f32; + let s = b.scale as f32; let wavelen = (lh * 0.04 * s).max(2.0) / rippling.clamp(0.25, 4.0); let width = wavelen * (1.0 + rippling * 2.0); let field = |x: f32, y: f32| -> f32 { let mut v = 0.0; - for &(dx, dy, r, a) in &drops { + for &[dx, dy, r, a] in &drops { let d = ((x - dx).powi(2) + (y - dy).powi(2)).sqrt(); let u = (d - r) / width; if u.abs() < 1.0 { @@ -586,7 +711,13 @@ fn drizzle(ctx: &EffectCtx, mut b: Buf) -> Buf { // ------------------------------------------------------------------ CC Star Burst -fn star_burst(ctx: &EffectCtx, mut b: Buf) -> Buf { +fn star_burst(ctx: &EffectCtx, b: Buf) -> Buf { + let mut plan = star_burst_plan(ctx, &b); + plan.resolve(&b.img); + plan.finish(b) +} + +fn star_burst_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { let pr = ctx.params; let scatter = pr.f("scatter") as f32; let speed = pr.f("speed") as f32; @@ -600,17 +731,12 @@ fn star_burst(ctx: &EffectCtx, mut b: Buf) -> Buf { let t = ctx.time as f32; let nx = (lw / spacing).ceil().max(1.0) as u32; let ny = (lh / spacing).ceil().max(1.0) as u32; - let src = b.img.clone(); let (cx, cy) = (lw * 0.5, lh * 0.5); - let mut list: Vec<(f32, Sprite)> = (0..nx * ny) + let mut list: Vec<(f32, Sprite, Tint)> = (0..nx * ny) .into_par_iter() - .filter_map(|i| { + .map(|i| { let (gx, gy) = ((i % nx) as f32 * spacing + spacing * 0.5, (i / nx) as f32 * spacing + spacing * 0.5); let (sx, sy) = b.to_px([gx as f64, gy as f64]); - let (c, a) = unpremul(src.sample_bilinear(sx, sy)); - if a <= 0.0 { - return None; - } // Depth cycles towards the viewer: z in [0, 1), perspective 1 / (1 - 0.9 z). let z = (h(i, 1, seed) + t * speed * 0.25 + phase).rem_euclid(1.0); let persp = 1.0 / (1.0 - 0.9 * z); @@ -621,19 +747,13 @@ fn star_burst(ctx: &EffectCtx, mut b: Buf) -> Buf { let (bx, by) = b.to_px([x as f64, y as f64]); let r = spacing * 0.5 * size * persp * s; let fade = (z * 8.0).min(1.0) * ((1.0 - z) * 6.0).min(1.0); - Some((z, Sprite::new(bx as f32, by as f32, r, [c[0], c[1], c[2], a * fade], Shape::Disc))) + // The layer's colour at the grid point (none where it is clear). + (z, Sprite::new(bx as f32, by as f32, r, [0.0, 0.0, 0.0, fade], Shape::Disc), Tint::Source { x: sx, y: sy }) }) .collect(); list.sort_by(|a, b| a.0.total_cmp(&b.0)); - let sprites: Vec = list.into_iter().map(|(_, s)| s).collect(); - let fx = splat(src.width, src.height, &sprites, Acc::Over); - b.img = fx; - if blend > 0.0 { - b.img.data.par_iter_mut().zip(src.data.par_iter()).for_each(|(o, s0)| { - *o = crate::util::lerp4(*o, *s0, blend); - }); - } - b + let (sprites, tints) = list.into_iter().map(|(_, s, t)| (s, t)).unzip(); + SpritePlan { sprites, tints, acc: Acc::Over, post: if blend > 0.0 { Post::Lerp(blend) } else { Post::Replace } } } // ------------------------------------------------------------------ particle engine @@ -1067,13 +1187,16 @@ fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf { static MERCURY_CACHE: SimCache = SimCache::new(6); -struct Blob { - x: f32, - y: f32, - r: f32, +/// A CC Mr. Mercury blob (buffer px). +#[derive(Clone, Copy, Debug)] +pub struct Blob { + pub x: f32, + pub y: f32, + pub r: f32, } -fn mr_mercury(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`). +pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec { let pr = ctx.params; let lh = ctx.layer_size[1] as f32; let unit = lh.max(1.0); @@ -1097,10 +1220,8 @@ fn mr_mercury(ctx: &EffectCtx, mut b: Buf) -> Buf { }; let st = simulate(&MERCURY_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 3), ctx.time, &phys); let (bs, ds) = (pr.f("blobBirthSize") as f32, pr.f("blobDeathSize") as f32); - let influence = pr.f("blobInfluence") as f32 / 100.0; let s = b.scale as f32; - let blobs: Vec = st - .parts + st.parts .iter() .map(|q| { let u = (q.age / q.life.max(1e-4)).clamp(0.0, 1.0); @@ -1109,7 +1230,14 @@ fn mr_mercury(ctx: &EffectCtx, mut b: Buf) -> Buf { Blob { x: bx as f32, y: by as f32, r: (size * unit * 0.08 * s).max(0.0) } }) .filter(|q| q.r > 0.1) - .collect(); + .collect() +} + +fn mr_mercury(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let blobs = mercury_blobs(ctx, &b); + let influence = pr.f("blobInfluence") as f32 / 100.0; + let s = b.scale as f32; let (w, hh) = (b.img.width, b.img.height); // Field and gradient accumulate additively: [f, gx, gy, _]. let mut field = Image::new(w, hh); diff --git a/crates/effects/src/sim2.rs b/crates/effects/src/sim2.rs index 9e4f092e..d682a310 100644 --- a/crates/effects/src/sim2.rs +++ b/crates/effects/src/sim2.rs @@ -80,31 +80,78 @@ impl Cam { } /// A planar textured piece ready to draw. -#[derive(Clone, Copy)] -pub(crate) struct Piece { +#[derive(Clone, Copy, Debug)] +pub struct Piece { /// Screen (buffer px, homogeneous) → piece-local (a, b, w). - inv: M3, + pub inv: [[f64; 3]; 3], /// Rest centre of the piece in texture pixels. - c0: [f32; 2], + pub c0: [f32; 2], /// Convex polygon (texture pixels), counter-clockwise in y-down space. - poly: [[f32; 2]; 6], - n: u8, - bbox: [f32; 4], - shade: f32, + pub poly: [[f32; 2]; 6], + pub n: u8, + pub bbox: [f32; 4], + pub shade: f32, /// Lighting: colour multiplier and additive specular highlight. - tint: [f32; 3], - spec: [f32; 3], - pub(crate) alpha: f32, + pub tint: [f32; 3], + pub spec: [f32; 3], + pub alpha: f32, /// Flat straight colour instead of the texture. - pub(crate) flat: Option<[f32; 3]>, + pub flat: Option<[f32; 3]>, /// Facing away from the camera (draw the back texture). - pub(crate) back: bool, + pub back: bool, /// Back faces sample their texture mirrored about the piece centre: 0 no, 1 vertically /// (flipped about the X axis), 2 horizontally (about the Y axis). - pub(crate) back_mirror: u8, + pub back_mirror: u8, depth: f64, } +/// A piece texture: the layer itself or another image (buffer-aligned). +#[derive(Clone, Debug)] +pub enum PieceTex { + Layer, + Image(Image), +} + +/// What a piece-drawing effect (Card Dance, Shatter, Card Wipe) draws this frame: pieces sorted +/// far to near over a transparent frame, textured by `front` (and `back` for pieces facing +/// away; none = they take `front`). +#[derive(Clone, Debug)] +pub struct PiecePlan { + pub pieces: Vec, + pub front: PieceTex, + pub back: Option, +} + +impl PiecePlan { + /// Rasterise over a transparent frame of the layer `b`'s size. + pub fn finish(&self, mut b: Buf) -> Buf { + let mut out = Image::new(b.img.width, b.img.height); + let front = match &self.front { + PieceTex::Layer => &b.img, + PieceTex::Image(i) => i, + }; + let back = self.back.as_ref().map(|t| match t { + PieceTex::Layer => &b.img, + PieceTex::Image(i) => i, + }); + draw_pieces(&mut out, &self.pieces, front, back); + b.img = out; + b + } +} + +/// The piece plan of Card Dance, Shatter (Rendered view) or Card Wipe for layer `b` (its pixels +/// may be read: gradient maps and textures default to the layer), `None` for other effects or +/// views drawn otherwise. +pub fn piece_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { + match id { + "ec.sim.carddance" => Some(card_dance_plan(ctx, b)), + "ec.sim.shatter" if ctx.params.e("view") == 0 => shatter_impl(ctx, b, true).err(), + "ec.transition.cardwipe" => crate::transition::card_wipe_plan(ctx, b), + _ => None, + } +} + impl Piece { /// Place polygon `poly` (texture px, around rest centre `c0`) with rotation `r` and its /// centre at 3D `pos` (buffer px; z away from the camera), seen through `cam`. @@ -462,22 +509,25 @@ fn card_source(src: u32, g1: [f32; 4], g2: [f32; 4]) -> f32 { } } -fn card_dance(ctx: &EffectCtx, mut b: Buf) -> Buf { +fn card_dance(ctx: &EffectCtx, b: Buf) -> Buf { + card_dance_plan(ctx, &b).finish(b) +} + +fn card_dance_plan(ctx: &EffectCtx, b: &Buf) -> PiecePlan { let pr = ctx.params; let rows = pr.f("rows").clamp(1.0, 1000.0) as u32; let cols = if pr.e("rowsColumns") == 1 { rows } else { pr.f("columns").clamp(1.0, 1000.0) as u32 }; - let g1 = layer_or_self(ctx, &b, "gradientLayer1", true, true); - let g2 = layer_or_self(ctx, &b, "gradientLayer2", true, true); - let back = ctx.layer_param("backLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true)); + let g1 = layer_or_self(ctx, b, "gradientLayer1", true, true); + let g2 = layer_or_self(ctx, b, "gradientLayer2", true, true); + let back = ctx.layer_param("backLayer", true).map(|o| crate::util::fit_layer(ctx, b, &o, true)); let [lw, lh] = ctx.layer_size; let s = b.scale; let (cw, ch) = (lw / cols as f64, lh / rows as f64); - let cam = crate::card3d::projection(ctx, &b); + let cam = crate::card3d::projection(ctx, b); let eye = cam.eye(); - let light = Lighting::from(ctx, &b); + let light = Lighting::from(ctx, b); let props: Vec<(u32, f64, f64)> = CD_PROPS.iter().map(|(id, g)| (pr.e(&format!("{g}/{id}Source")), pr.f(&format!("{g}/{id}Multiplier")), pr.f(&format!("{g}/{id}Offset")))).collect(); - let src = b.img.clone(); let mut pieces: Vec = (0..rows * cols) .into_par_iter() .filter_map(|i| { @@ -502,10 +552,7 @@ fn card_dance(ctx: &EffectCtx, mut b: Buf) -> Buf { }) .collect(); sort_far_first(&mut pieces); - let mut out = Image::new(src.width, src.height); - draw_pieces(&mut out, &pieces, &src, back.as_ref()); - b.img = out; - b + PiecePlan { pieces, front: PieceTex::Layer, back: back.map(PieceTex::Image) } } // ------------------------------------------------------------------ Shatter @@ -638,7 +685,15 @@ fn map_key(c: [f32; 4]) -> u32 { q(c[0]) << 8 | q(c[1]) << 4 | q(c[2]) } -fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { +fn shatter(ctx: &EffectCtx, b: Buf) -> Buf { + match shatter_impl(ctx, &b, false) { + Ok(img) => Buf { img, ..b }, + Err(plan) => plan.finish(b), + } +} + +/// Shatter: the frame (`Ok`), or with `plan_only` in the Rendered view its pieces (`Err`). +fn shatter_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result { let pr = ctx.params; let view = pr.e("view"); let render = pr.e("render"); @@ -666,21 +721,21 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { let unit = lw * 0.1; let cell = lw / reps; let extrude = pr.f("shape/extrusionDepth").max(0.0) * cell * s; - let cam = crate::card3d::projection(ctx, &b); + let cam = crate::card3d::projection(ctx, b); let eye = cam.eye(); - let light = Lighting::from(ctx, &b); + let light = Lighting::from(ctx, b); // Gradient: with a gradient layer only pieces at least as bright as 1 − Shatter Threshold // (Invert Gradient: as dark) break. - let gradient = ctx.layer_param("gradient/gradientLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true)); + let gradient = ctx.layer_param("gradient/gradientLayer", true).map(|o| crate::util::fit_layer(ctx, b, &o, true)); let threshold = pr.get("gradient/shatterThreshold").map(Value::as_f64).unwrap_or(0.0) / 100.0; let invert_gradient = pr.b("gradient/invertGradient"); // Textures. let tex_color = pr.get("textures/color").map(|v| v.as_color()).unwrap_or([1.0; 4]); let tex_opacity = pr.get("textures/opacity").map(Value::as_f64).unwrap_or(1.0).clamp(0.0, 1.0) as f32; - let front = Face::from(ctx, &b, "textures/frontMode", "textures/frontLayer", 1); - let side = Face::from(ctx, &b, "textures/sideMode", "textures/sideLayer", 0); - let backf = Face::from(ctx, &b, "textures/backMode", "textures/backLayer", 1); - let custom_map = custom.then(|| layer_or_self(ctx, &b, "shape/customShatterMap", true, true)); + let front = Face::from(ctx, b, "textures/frontMode", "textures/frontLayer", 1); + let side = Face::from(ctx, b, "textures/sideMode", "textures/sideLayer", 0); + let backf = Face::from(ctx, b, "textures/backMode", "textures/backLayer", 1); + let custom_map = custom.then(|| layer_or_self(ctx, b, "shape/customShatterMap", true, true)); let white_fixed = pr.b("shape/whiteTilesFixed"); let (sd, cd) = dir.sin_cos(); let to_layer = |q: [f64; 2]| [origin[0] + q[0] * cd - q[1] * sd, origin[1] + q[0] * sd + q[1] * cd]; @@ -843,12 +898,18 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { }) .collect(); sort_far_first(&mut pieces); - let mut out = Image::new(src.width, src.height); - let front_img = front.img.as_ref().unwrap_or(&src); - let back_img = backf.img.as_ref().unwrap_or(front_img); if view == 0 { - draw_pieces(&mut out, &pieces, front_img, Some(back_img)); - } else { + let tex = |i: &Option| i.clone().map(PieceTex::Image); + let front_t = tex(&front.img).unwrap_or(PieceTex::Layer); + let back_t = tex(&backf.img).unwrap_or_else(|| front_t.clone()); + let plan = PiecePlan { pieces, front: front_t, back: Some(back_t) }; + if plan_only { + return Err(plan); + } + return Ok(plan.finish(Buf { img: src, offset: b.offset, scale: b.scale }).img); + } + let out; + { // Wireframes: piece outlines (front view: at rest; otherwise animated), plus forces. let animated = view == 2 || view == 4; let mut lines = Vec::new(); @@ -889,8 +950,7 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { } out = splat(src.width, src.height, &lines, Acc::Over); } - b.img = out; - b + Ok(out) } // ------------------------------------------------------------------ Caustics @@ -911,35 +971,62 @@ fn sample_repeat(img: &Image, x: f64, y: f64, mode: u32) -> Px { } } -fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// Everything Caustics' per-pixel pass needs, prepared on the CPU (the other layers it reads +/// and the parameter conversions); the layer's own pixels are not read. +pub struct CausticsSetup { + /// The Bottom layer fitted to the buffer, `None` = the layer itself. + pub bottom: Option, + /// Gaussian σ (buffer px) blurring the bottom (transparent edges), 0 = none. + pub bottom_sigma: f64, + /// The water surface's height field (smoothed luminance), `None` = flat. + pub height: Option, + /// The Sky layer fitted to the buffer. + pub sky: Option, + pub scaling: f64, + pub repeat: u32, + pub wave_h: f64, + /// Displacement per unit of height gradient. + pub k: f64, + /// Layer centre (buffer px). + pub cx: f64, + pub cy: f64, + /// Layer width (buffer px). + pub lw: f64, + pub surf: [f32; 4], + pub surf_op: f32, + pub cstr: f32, + pub lc: [f32; 4], + pub li: f32, + /// Light position (buffer px) and height (layer widths); `point` = from the position to + /// each pixel, else one direction from above the layer centre. + pub lx: f64, + pub ly: f64, + pub lheight: f64, + pub point: bool, + pub ambient: f32, + pub diffuse: f32, + pub specular: f32, + pub sharp: f32, + pub sky_scaling: f64, + pub sky_repeat: u32, + pub sky_int: f32, + pub convergence: f64, +} + +/// Caustics' setup for layer geometry `b` (see [`CausticsSetup`]). +pub fn caustics_setup(ctx: &EffectCtx, b: &Buf) -> CausticsSetup { let pr = ctx.params; let s = b.scale; - let mut bottom = layer_or_self(ctx, &b, "bottom/bottom", true, pr.e("bottom/bottomSizeDiffers") == 1); + let bottom = ctx.layer_param("bottom/bottom", true).map(|o| crate::util::fit_layer(ctx, b, &o, pr.e("bottom/bottomSizeDiffers") == 1)); let blur = pr.f("bottom/blur") * s; - if blur > 0.05 { - bottom = effectcraft_raster::gaussian_blur(&bottom, blur * 0.5, blur * 0.5, false); - } - let scaling = pr.f("bottom/scaling").max(0.01); - let repeat = pr.e("bottom/repeatMode"); let wave_h = pr.f("water/waveHeight"); let smoothing = pr.f("water/smoothing") * s; let depth = pr.f("water/waterDepth"); let ior = pr.f("water/refractiveIndex").max(1.0); - let surf = pr.color("water/surfaceColor"); - let surf_op = pr.f("water/surfaceOpacity") as f32; - let cstr = pr.f("water/causticsStrength") as f32; - let li = pr.f("lighting/lightIntensity") as f32; - let lc = pr.color("lighting/lightColor"); let lpos = pr.v2("lighting/lightPosition"); - let lheight = pr.f("lighting/lightHeight").max(0.01); - let ambient = pr.f("lighting/ambientLight") as f32; - let diffuse = pr.f("material/diffuse") as f32; - let specular = pr.f("material/specular") as f32; - let sharp = pr.f("material/highlightSharpness").max(1.0) as f32; - let (w, hh) = (b.img.width as usize, b.img.height as usize); // Height field of the water surface (luminance of the chosen layer; none = flat). let height = ctx.layer_param("water/waterSurface", true).map(|o| { - let img = crate::util::fit_layer(ctx, &b, &o, true); + let img = crate::util::fit_layer(ctx, b, &o, true); let pl = Plane::luma(&img); if smoothing > 0.05 { gauss_plane(&pl, smoothing * 0.5, smoothing * 0.5) } else { pl } }); @@ -947,6 +1034,7 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { let k = wave_h * depth * (1.0 - 1.0 / ior) * lw * 2.0; let (cx, cy) = (b.offset[0] + ctx.layer_size[0] * 0.5 * s, b.offset[1] + ctx.layer_size[1] * 0.5 * s); let (lx, ly) = b.to_px(lpos); + let (lc, li, lheight) = (pr.color("lighting/lightColor"), pr.f("lighting/lightIntensity") as f32, pr.f("lighting/lightHeight").max(0.01)); // Light Type: Distant Source (one direction, from the light position above the layer // centre), Point Source (from the light position to each pixel) or First Comp Light. let light_type = pr.e("lighting/lightType"); @@ -962,6 +1050,75 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { (1, _) => (lc, li, lx, ly, lheight, true), _ => (lc, li, lx, ly, lheight, false), }; + // Sky: the layer the water surface reflects (Surface Opacity 1 = a mirror of it). + let sky = ctx.layer_param("sky/sky", true).map(|o| crate::util::fit_layer(ctx, b, &o, pr.e("sky/skySizeDiffers") == 1)); + CausticsSetup { + bottom, + bottom_sigma: if blur > 0.05 { blur * 0.5 } else { 0.0 }, + height, + sky, + scaling: pr.f("bottom/scaling").max(0.01), + repeat: pr.e("bottom/repeatMode"), + wave_h, + k, + cx, + cy, + lw, + surf: pr.color("water/surfaceColor"), + surf_op: pr.f("water/surfaceOpacity") as f32, + cstr: pr.f("water/causticsStrength") as f32, + lc, + li, + lx, + ly, + lheight, + point, + ambient: pr.f("lighting/ambientLight") as f32, + diffuse: pr.f("material/diffuse") as f32, + specular: pr.f("material/specular") as f32, + sharp: pr.f("material/highlightSharpness").max(1.0) as f32, + sky_scaling: pr.get("sky/scaling").map(Value::as_f64).unwrap_or(1.0).max(0.01), + sky_repeat: pr.e("sky/repeatMode"), + sky_int: pr.get("sky/intensity").map(Value::as_f64).unwrap_or(0.3) as f32, + convergence: pr.get("sky/convergence").map(Value::as_f64).unwrap_or(0.5).clamp(0.0, 1.0), + } +} + +fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { + let st = caustics_setup(ctx, &b); + let mut bottom = st.bottom.clone().unwrap_or_else(|| b.img.clone()); + if st.bottom_sigma > 0.0 { + bottom = effectcraft_raster::gaussian_blur(&bottom, st.bottom_sigma, st.bottom_sigma, false); + } + let CausticsSetup { + scaling, + repeat, + wave_h, + k, + cx, + cy, + lw, + surf, + surf_op, + cstr, + lc, + li, + lx, + ly, + lheight, + point, + ambient, + diffuse, + specular, + sharp, + sky_scaling, + sky_repeat, + sky_int, + convergence, + .. + } = st; + let (height, sky) = (&st.height, &st.sky); + let (w, hh) = (b.img.width as usize, b.img.height as usize); let norm3 = |v: [f64; 3]| { let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); [(v[0] / n) as f32, (v[1] / n) as f32, (v[2] / n) as f32] @@ -973,14 +1130,8 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { norm3([(lx - cx) / lw.max(1.0), (ly - cy) / lw.max(1.0), lheight]) } }; - // Sky: the layer the water surface reflects (Surface Opacity 1 = a mirror of it). - let sky = ctx.layer_param("sky/sky", true).map(|o| crate::util::fit_layer(ctx, &b, &o, pr.e("sky/skySizeDiffers") == 1)); - let sky_scaling = pr.get("sky/scaling").map(Value::as_f64).unwrap_or(1.0).max(0.01); - let sky_repeat = pr.e("sky/repeatMode"); - let sky_int = pr.get("sky/intensity").map(Value::as_f64).unwrap_or(0.3) as f32; - let convergence = pr.get("sky/convergence").map(Value::as_f64).unwrap_or(0.5).clamp(0.0, 1.0); let grad = |x: usize, y: usize| -> (f64, f64) { - match &height { + match height { Some(pl) => { let gx = (pl.get_clamped(x as i64 + 1, y as i64) - pl.get_clamped(x as i64 - 1, y as i64)) as f64 * 0.5; let gy = (pl.get_clamped(x as i64, y as i64 + 1) - pl.get_clamped(x as i64, y as i64 - 1)) as f64 * 0.5; @@ -1052,12 +1203,13 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { // ------------------------------------------------------------------ Wave World +/// Wave World's simulated surface: an `nx` × `ny` grid of heights `u` and velocities `v`. #[derive(Clone, Debug)] -struct Waves { - nx: usize, - ny: usize, - u: Vec, - v: Vec, +pub struct Waves { + pub nx: usize, + pub ny: usize, + pub u: Vec, + pub v: Vec, } struct Producer { @@ -1091,7 +1243,27 @@ impl Producer { static WAVE_CACHE: SimCache = SimCache::new(4); -fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// What Wave World draws: the Height Map view of the simulated surface (grid heights and, +/// with a ground layer, water depths per grid point), or the wireframe preview's lines. +pub enum WavePlan { + Height { st: std::sync::Arc, depth: Option> }, + Wire(crate::sim::SpritePlan), +} + +fn wave_world(ctx: &EffectCtx, b: Buf) -> Buf { + match wave_world_impl(ctx, &b, false) { + Ok(img) => Buf { img, ..b }, + Err(WavePlan::Wire(plan)) => plan.finish(b), + Err(WavePlan::Height { .. }) => b, + } +} + +/// Wave World's simulation state for the frame, as a [`WavePlan`] (its pixels are not read). +pub fn wave_world_plan(ctx: &EffectCtx, b: &Buf) -> Option { + wave_world_impl(ctx, b, true).err() +} + +fn wave_world_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result { let pr = ctx.params; let [lw, lh] = ctx.layer_size; let res = pr.f("simulation/gridResolution").clamp(1.0, 400.0) as usize; @@ -1239,7 +1411,11 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { let c = g(x0, y1) + (g(x1, y1) - g(x0, y1)) * tx; a + (c - a) * ty }; + if plan_only && pr.e("view") == 1 { + return Err(WavePlan::Height { st, depth }); + } if pr.e("view") == 1 { + let mut img = Image::new(bw, bh); let bright = pr.f("heightMapControls/brightness") as f32; let contrast = pr.f("heightMapControls/contrast") as f32; let gamma = pr.f("heightMapControls/gamma").max(0.01) as f32; @@ -1251,7 +1427,7 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { let gy = (y / lw.max(1.0) * (st.nx - 1) as f64).round().clamp(0.0, (st.ny - 1) as f64) as usize; d[gy * st.nx + gx] <= 0.0 }; - b.img.rows_mut().for_each(|(y, row)| { + img.rows_mut().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { let lx = (x as f64 + 0.5 - b.offset[0]) / s; let ly = (y as f64 + 0.5 - b.offset[1]) / s; @@ -1260,6 +1436,7 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { *px = [v * al, v * al, v * al, al]; } }); + Ok(img) } else { // Wireframe preview: the grid turned by Horizontal Rotation about the vertical axis, // tilted by Vertical Rotation, heights scaled by Vertical Scale. @@ -1291,10 +1468,12 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { } } } - let wire = splat(bw, bh, &lines, Acc::Over); - b.img = crate::sim::combine(&Image::filled(bw, bh, [0.0, 0.0, 0.0, 1.0]), &wire, 0); + let plan = crate::sim::SpritePlan { sprites: lines, tints: vec![], acc: Acc::Over, post: crate::sim::Post::OverBlack }; + if plan_only { + return Err(WavePlan::Wire(plan)); + } + Ok(plan.finish(Buf { img: Image::new(bw, bh), offset: b.offset, scale: b.scale }).img) } - b } // ------------------------------------------------------------------ Foam @@ -1336,7 +1515,29 @@ fn foam_pop_age(id: u32, lifespan: f32, frailty: f32, seed: u32) -> f32 { /// other, wobble, and pop at the end of their (Strength-shortened) lifespan, pushing their /// neighbours away at Pop Velocity. Rendered bubbles use a built-in texture or the Bubble /// Texture Layer, oriented by Bubble Orientation, and reflect the Environment Map. -fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { +fn foam(ctx: &EffectCtx, b: Buf) -> Buf { + match foam_impl(ctx, &b, false) { + Ok(img) => Buf { img, ..b }, + Err(plan) => plan.finish(b), + } +} + +/// Foam's bubbles as sprites, `None` when it draws more than sprites (a User Defined bubble +/// texture, an Environment Map reflection or the Draft + Flow Map view's flow map). +pub(crate) fn foam_plan(ctx: &EffectCtx, b: &Buf) -> Option { + let pr = ctx.params; + let set = |id: &str| matches!(pr.get(id), Some(Value::Layer(Some(_)))); + let view = pr.e("view"); + if (view == 2 && pr.e("rendering/bubbleTexture") == 5) + || (view == 2 && pr.f("rendering/reflectionStrength") > 0.0 && set("rendering/environmentMap")) + || (view == 1 && set("flowMap/flowMap")) + { + return None; + } + foam_impl(ctx, b, true).err() +} + +fn foam_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result { let pr = ctx.params; let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); let unit = lw * 0.01; @@ -1558,7 +1759,11 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { sp }) .collect(); - let mut out = splat(b.img.width, b.img.height, &sprites, if blend == 0 && view == 2 { Acc::Add } else { Acc::Over }); + let acc = if blend == 0 && view == 2 { Acc::Add } else { Acc::Over }; + if plan_only { + return Err(crate::sim::SpritePlan { sprites, tints: vec![], acc, post: crate::sim::Post::Replace }); + } + let mut out = splat(b.img.width, b.img.height, &sprites, acc); // User Defined: the texture layer fills each bubble's disc. if let Some(tex) = &user_tex { for q in &order { @@ -1581,7 +1786,7 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { && strength > 0.0 && let Some(env) = ctx.layer_param("rendering/environmentMap", true) { - let env_img = crate::util::fit_layer(ctx, &b, &env, true); + let env_img = crate::util::fit_layer(ctx, b, &env, true); let conv = pr.f("rendering/reflectionConvergence").clamp(0.0, 1.0); let (w, h) = (b.img.width as f64, b.img.height as f64); for q in &order { @@ -1602,7 +1807,7 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { if view == 1 && let Some(lp) = &flow { - let fm = crate::util::fit_layer(ctx, &b, lp, true); + let fm = crate::util::fit_layer(ctx, b, lp, true); out.data.par_iter_mut().zip(fm.data.par_iter()).for_each(|(o, f)| { let k = 1.0 - o[3]; for c in 0..4 { @@ -1610,8 +1815,7 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { } }); } - b.img = out; - b + Ok(out) } /// Add `f(u, v)` (premultiplied; u, v ∈ −1..1 across the disc) over the disc of radius `r` at diff --git a/crates/effects/src/sim3.rs b/crates/effects/src/sim3.rs index 888ebd7d..cf0f5823 100644 --- a/crates/effects/src/sim3.rs +++ b/crates/effects/src/sim3.rs @@ -27,7 +27,7 @@ use effectcraft_project::ParamUi; use effectcraft_raster::{Image, Px}; use rayon::prelude::*; -use crate::sim::{Acc, SPS, Shape, Sprite, combine, splat, steps_at}; +use crate::sim::{Acc, Post, SPS, Shape, Sprite, SpritePlan, Tint, combine, splat, steps_at}; use crate::util::{SimCache, hash1, params_key, point_in_poly, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; @@ -897,12 +897,20 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---------------------------------------------------------------- CC Hair -fn cc_hair(ctx: &EffectCtx, mut b: Buf) -> Buf { +fn cc_hair(ctx: &EffectCtx, b: Buf) -> Buf { + let Some(mut plan) = hair_plan(ctx, &b) else { return b }; + plan.resolve(&b.img); + plan.finish(b) +} + +/// CC Hair's strands as line sprites (each coloured from its root, see [`Tint::Root`]); +/// `None` = no hair (the layer passes through). +pub(crate) fn hair_plan(ctx: &EffectCtx, b: &Buf) -> Option { let pr = ctx.params; let len = pr.f("length").max(0.0) as f32; let density = pr.f("density").max(0.0) as f32 / 100.0; if len <= 0.0 || density <= 0.0 { - return b; + return None; } let thick = pr.f("thickness").max(0.05) as f32; let weight = pr.f("weight") as f32; @@ -924,19 +932,13 @@ fn cc_hair(ctx: &EffectCtx, mut b: Buf) -> Buf { let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); // Roots: a jittered grid over the layer, density per 1 000 px². let count = ((lw * lh / 1000.0) * density * 10.0).min(200_000.0) as u32; - let src = b.img.clone(); let segs = 6u32; - let strands: Vec> = (0..count) + let strands: Vec> = (0..count) .into_par_iter() - .filter_map(|i| { + .map(|i| { let rx = hash1(i, 1, seed) * lw; let ry = hash1(i, 2, seed) * lh; let (bx, by) = b.to_px([rx as f64, ry as f64]); - let root = src.sample_bilinear(bx, by); - if root[3] < 0.5 { - return None; - } - let (rc, _) = unpremul(root); let mut l = len * (0.75 + 0.5 * hash1(i, 3, seed)); // Lean: random, plus the map's local gradient (hairfall map). let mut lean = [(hash1(i, 4, seed) * 2.0 - 1.0) * 0.6, -1.0 + hash1(i, 5, seed) * 0.4]; @@ -957,7 +959,6 @@ fn cc_hair(ctx: &EffectCtx, mut b: Buf) -> Buf { let dir = [lean[0] / ll, lean[1] / ll]; // Droop: heavier (or thinner with Constant Mass off) hair bends more. let droop = weight * if const_mass { 1.0 } else { 1.0 / thick.max(0.2) } * l; - let base = [rc[0] + (hc[0] - rc[0]) * (1.0 - inherit), rc[1] + (hc[1] - rc[1]) * (1.0 - inherit), rc[2] + (hc[2] - rc[2]) * (1.0 - inherit)]; let pt = |s: f32| [rx + dir[0] * l * s, ry + dir[1] * l * s + droop * s * s]; let mut out = Vec::with_capacity(segs as usize); for k in 0..segs { @@ -974,24 +975,23 @@ fn cc_hair(ctx: &EffectCtx, mut b: Buf) -> Buf { let spec = (1.0 - tl.abs()).powf(1.0 / rough) * spe; let shade = (amb + dif * diff * light_i) * bright; let tip = 1.0 - 0.6 * s1; - let c3 = [base[0] * shade + spec * light_i, base[1] * shade + spec * light_i, base[2] * shade + spec * light_i]; let (pa, pc) = (b.to_px([a[0] as f64, a[1] as f64]), b.to_px([c[0] as f64, c[1] as f64])); - out.push(Sprite { + let sp = Sprite { x: pa.0 as f32, y: pa.1 as f32, r: thick * 0.5 * b.scale as f32 * tip, - c: [c3[0], c3[1], c3[2], opacity], + c: [0.0, 0.0, 0.0, opacity], shape: Shape::Line { dx: (pc.0 - pa.0) as f32, dy: (pc.1 - pa.1) as f32 }, rot: 0.0, - }); + }; + // The root's colour mixed with the hair colour (no strand where it is clear). + out.push((sp, Tint::Root { x: bx, y: by, hair: [hc[0], hc[1], hc[2]], inherit, shade, spec: spec * light_i })); } - Some(out) + out }) .collect(); - let sprites: Vec = strands.into_iter().flatten().collect(); - let fx = splat(b.img.width, b.img.height, &sprites, Acc::Over); - b.img = combine(&b.img, &fx, 0); - b + let (sprites, tints) = strands.into_iter().flatten().unzip(); + Some(SpritePlan { sprites, tints, acc: Acc::Over, post: Post::Combine(0) }) } pub fn specs() -> Vec { diff --git a/crates/effects/src/transition.rs b/crates/effects/src/transition.rs index 65876aa8..23e54bb6 100644 --- a/crates/effects/src/transition.rs +++ b/crates/effects/src/transition.rs @@ -9,7 +9,7 @@ use effectcraft_project::ParamUi; use effectcraft_raster::Px; use rayon::prelude::*; -use crate::sim2::{Piece, draw_pieces, rot_axis, sort_far_first}; +use crate::sim2::{Piece, PiecePlan, PieceTex, rot_axis, sort_far_first}; use crate::util::{Plane, gauss_plane, hash1, layer_rect, remap, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, col, num, p, popup, slider}; @@ -175,7 +175,15 @@ fn iris_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { /// through a 180° turn about Flip Axis, as textured planes in 3D seen through the Camera /// System and lit by Lighting / Material. The flipped side shows Back Layer (none: the cards /// vanish as they turn away). Position / Rotation Jitter wobble the cards over time. -fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { +fn card_wipe(ctx: &EffectCtx, b: Buf) -> Buf { + match card_wipe_plan(ctx, &b) { + Some(plan) => plan.finish(b), + None => b, + } +} + +/// Card Wipe's cards (`None` = nothing has moved: the layer as is). +pub(crate) fn card_wipe_plan(ctx: &EffectCtx, b: &Buf) -> Option { let done = completion(ctx); let pr = ctx.params; let f = |id: &str| pr.get(id).map(Value::as_f64).unwrap_or(0.0); @@ -199,7 +207,7 @@ fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { // Nothing has moved: the layer as is (exactly). let xy = pr.get("cameraPosition/xyPosition").map(|v| v.as_vec2()); if xy.is_none_or(|p| (p[0] - ctx.layer_size[0] * 0.5).abs() < 1e-9 && (p[1] - ctx.layer_size[1] * 0.5).abs() < 1e-9) { - return b; + return None; } } let tw = (pr.f("transitionWidth") / 100.0).clamp(0.01, 1.0); @@ -212,25 +220,25 @@ fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { let order = pr.e("flipOrder"); let jitter = pr.f("timingRandomness").clamp(0.0, 1.0); let seed = pr.f("randomSeed") as u32 ^ 0xca7d; - let (x0, y0, w, h) = layer_rect(ctx, &b); + let (x0, y0, w, h) = layer_rect(ctx, b); let (cw, ch) = (w / cols as f64, h / rows as f64); let nx = |i: i64| if cols > 1 { i as f64 / (cols - 1) as f64 } else { 0.0 }; let ny = |j: i64| if rows > 1 { j as f64 / (rows - 1) as f64 } else { 0.0 }; // Flip Order "Gradient": cards flip first where the gradient layer (default: this layer) is dark. let grad = (order == 8).then(|| match ctx.layer_param("gradientLayer", false) { - Some(o) => place_layer(ctx, &b, &o, 2), + Some(o) => place_layer(ctx, b, &o, 2), None => b.img.clone(), }); // Back Layer: a chosen layer (stretched to fit), or this layer for projects saved with the // old "Self" choice; none hides the cards' backs. let back = match ctx.layer_param("backLayer", true) { - Some(o) => Some(crate::util::fit_layer(ctx, &b, &o, true)), - None if pr.b("backSelf") => Some(b.img.clone()), + Some(o) => Some(PieceTex::Image(crate::util::fit_layer(ctx, b, &o, true))), + None if pr.b("backSelf") => Some(PieceTex::Layer), None => None, }; - let cam = crate::card3d::projection(ctx, &b); + let cam = crate::card3d::projection(ctx, b); let eye = cam.eye(); - let light = crate::card3d::Lighting::from(ctx, &b); + let light = crate::card3d::Lighting::from(ctx, b); let t = ctx.time; let jit = |i: i64, j: i64, k: u32, speed: f64| -> f64 { let ph = hash1(i as u32, j as u32, seed ^ k) as f64 * std::f64::consts::TAU; @@ -303,10 +311,7 @@ fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { }) .collect(); sort_far_first(&mut pieces); - let mut out = effectcraft_raster::Image::new(b.img.width, b.img.height); - draw_pieces(&mut out, &pieces, &b.img, back.as_ref()); - b.img = out; - b + Some(PiecePlan { pieces, front: PieceTex::Layer, back }) } fn grid_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 832545af..d5c13004 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -316,6 +316,7 @@ impl GpuContext { include_str!("shaders/fx_extra.wgsl"), include_str!("shaders/fx_depth.wgsl"), include_str!("shaders/fx_lut.wgsl"), + include_str!("shaders/fx_sim.wgsl"), include_str!("shaders/fx_vr.wgsl"), ] .concat(); @@ -378,6 +379,7 @@ impl GpuContext { .chain(crate::fx_extra::KERNELS) .chain(crate::fx_depth::KERNELS) .chain(crate::fx_lut::KERNELS) + .chain(crate::fx_sim::KERNELS) .chain(crate::fx_vr::KERNELS) .map(|e| (e, &layout)) .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index f6f73a8f..c5275477 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -135,6 +135,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { _ 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), _ if crate::fx_lut::IDS.contains(&id) => crate::fx_lut::apply(e, id, ctx, b), + _ if crate::fx_sim::IDS.contains(&id) => crate::fx_sim::apply(e, id, ctx, b), _ if crate::fx_vr::IDS.contains(&id) => crate::fx_vr::apply(e, id, ctx, b), _ => pointwise(e, id, ctx, b), } diff --git a/crates/gpu/src/fx_sim.rs b/crates/gpu/src/fx_sim.rs new file mode 100644 index 00000000..517c5328 --- /dev/null +++ b/crates/gpu/src/fx_sim.rs @@ -0,0 +1,424 @@ +//! GPU effects, simulation render passes (kernels in `shaders/fx_sim.wgsl`, every entry point +//! prefixed `fxm_`): CC Rainfall, CC Snowfall, CC Star Burst, CC Bubbles, CC Drizzle, CC Hair, +//! CC Mr. Mercury, Caustics, Wave World, Foam, Shatter, Card Dance and Card Wipe. +//! +//! The simulation (or closed-form layout) of every frame stays on the CPU, shared with the CPU +//! effect: sprite plans (`effects::sprite_plan`: rain drops, snow flakes, star-burst discs, hair +//! strands, Foam's bubbles, Wave World's wireframe), piece plans (`effects::piece_plan`: Card +//! Dance's cards, Shatter's shards, Card Wipe's cards, sorted far to near), CC Bubbles' and CC +//! Mr. Mercury's lists, CC Drizzle's drops, Wave World's height grid and Caustics' prepared +//! layers. The raster / render pass runs here: items are binned into 16 × 16 tiles on the CPU +//! and `fxm_raster` composites each pixel's items in the CPU's order with the CPU's shading +//! (colours that depend on the layer's own pixels — refracting rain, star-burst and hair-root +//! colours, bubble refraction — are sampled per pixel from the layer texture). Piece plans read +//! the layer's pixels on the CPU (gradient maps and textures default to the layer), so those +//! effects read the frame back once. Settings drawn otherwise (Shatter's wireframe views, +//! Foam's User Defined texture, Environment Map and flow-map preview) render on the CPU. + +use effectcraft_effects::{Acc, EffectCtx, PieceTex, Post, Shape, SpritePlan, Tint}; +use effectcraft_raster::Image; + +use crate::context::{Enc, GpuImage, Params}; +use crate::effects::{GBuf, gaussian_blur}; + +/// Compute entry points in `fx_sim.wgsl`. +pub(crate) const KERNELS: &[&str] = &["fxm_raster", "fxm_post", "fxm_mercury", "fxm_drizzle", "fxm_wave", "fxm_caustics"]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[ + "ec.sim.ccrainfall", + "ec.sim.ccsnowfall", + "ec.sim.ccstarburst", + "ec.sim.ccbubbles", + "ec.sim.ccdrizzle", + "ec.sim.cchair", + "ec.sim.ccmrmercury", + "ec.sim.caustics", + "ec.sim.waveworld", + "ec.sim.foam", + "ec.sim.shatter", + "ec.sim.carddance", + "ec.transition.cardwipe", +]; + +/// Largest item table (floats) the raster kernel takes; bigger plans render on the CPU. +const MAX_FLOATS: usize = 1 << 24; +const TILE: i32 = 16; + +/// 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 geo = effectcraft_effects::Buf { img: Image::new(0, 0), offset: b.offset, scale: b.scale }; + match id { + "ec.sim.ccbubbles" => bubbles(e, ctx, b, &geo), + "ec.sim.ccdrizzle" => drizzle(e, ctx, b, &geo), + "ec.sim.ccmrmercury" => mercury(e, ctx, b, &geo), + "ec.sim.caustics" => caustics(e, ctx, b, &geo), + "ec.sim.waveworld" => match effectcraft_effects::wave_world_plan(ctx, &geo)? { + effectcraft_effects::WavePlan::Wire(plan) => sprites(e, b, &plan), + effectcraft_effects::WavePlan::Height { st, depth } => wave_height(e, ctx, b, &st, depth.as_deref()), + }, + "ec.sim.shatter" | "ec.sim.carddance" | "ec.transition.cardwipe" => pieces(e, id, ctx, b), + "ec.sim.cchair" => match effectcraft_effects::sprite_plan(id, ctx, &geo) { + Some(plan) => sprites(e, b, &plan), + None => Some(b), + }, + _ => { + let plan = effectcraft_effects::sprite_plan(id, ctx, &geo)?; + sprites(e, b, &plan) + } + } +} + +// ---------------------------------------------------------------- tiled item raster + +/// Items with their integer pixel bounds, binned into tiles (sim::raster's bands, finer). +struct Items { + stride: usize, + recs: Vec, + w: i32, + h: i32, + tiles: Vec>, + tx: i32, +} + +impl Items { + fn new(stride: usize, w: u32, h: u32) -> Items { + let (w, h) = (w as i32, h as i32); + let tx = (w + TILE - 1) / TILE; + let ty = (h + TILE - 1) / TILE; + Items { stride, recs: vec![], w, h, tiles: vec![vec![]; (tx * ty).max(0) as usize], tx } + } + + /// Add an item (`rec` = its fields, without the bounds) covering float bounds `bb` + /// (`None` or empty = skipped, as sim::raster). + fn push(&mut self, bb: Option<[f32; 4]>, rec: &[f32]) { + let Some(b) = bb else { return }; + if !(b[0].is_finite() && b[1].is_finite() && b[2].is_finite() && b[3].is_finite()) { + return; + } + let x0 = b[0].floor().max(0.0) as i32; + let y0 = b[1].floor().max(0.0) as i32; + let x1 = (b[2].ceil() as i32).min(self.w - 1); + let y1 = (b[3].ceil() as i32).min(self.h - 1); + if x1 < x0 || y1 < y0 || b[2] < 0.0 || b[3] < 0.0 { + return; + } + let i = (self.recs.len() / self.stride) as u32; + let start = self.recs.len(); + self.recs.extend_from_slice(rec); + self.recs.resize(start + self.stride - 4, 0.0); + self.recs.extend([x0 as f32, y0 as f32, x1 as f32, y1 as f32]); + for ty in y0 / TILE..=y1 / TILE { + for tx in x0 / TILE..=x1 / TILE { + self.tiles[(ty * self.tx + tx) as usize].push(i); + } + } + } + + /// Kernel data: tile offsets (tiles + 1), item indices, items. `None` when too large. + fn data(self) -> Option<(Vec, u32, u32)> { + let n_tiles = self.tiles.len(); + let n_idx: usize = self.tiles.iter().map(Vec::len).sum(); + let total = n_tiles + 1 + n_idx + self.recs.len(); + if total > MAX_FLOATS { + return None; + } + let mut d = Vec::with_capacity(total); + let base = (n_tiles + 1) as f32; + let mut at = 0usize; + for t in &self.tiles { + d.push(base + at as f32); + at += t.len(); + } + d.push(base + at as f32); + for t in &self.tiles { + d.extend(t.iter().map(|&i| i as f32)); + } + let items_at = d.len() as u32; + d.extend(self.recs); + Some((d, items_at, self.stride as u32)) + } +} + +/// Rasterise items over a transparent frame. `kind`: 0 sprites, 1 CC Bubbles, 2 Mr. Mercury +/// field, 3 pieces; `src` / `aux` = the textures the items sample. +#[allow(clippy::too_many_arguments)] +fn raster(e: &mut Enc, items: Items, kind: u32, add: bool, src: &GpuImage, aux: Option<&GpuImage>, mut p: Params, w: u32, h: u32) -> Option { + let tx = items.tx as u32; + let (data, items_at, stride) = items.data()?; + let buf = e.data(&data); + p.u[0] = [kind, add as u32, items_at, stride]; + p.u[1][0] = tx; + let out = e.scratch(w, h); + e.pixels("fxm_raster", &p, src, aux, &out, Some(&buf)); + Some(out) +} + +/// sim::sprite_bbox. +fn sprite_bbox(x: f32, y: f32, r: f32, c3: f32, shape: Shape) -> Option<[f32; 4]> { + if c3 <= 1e-5 || r <= 0.0 { + return None; + } + let r = r.max(0.5) + 1.0; + Some(match shape { + Shape::Line { dx, dy } => [x.min(x + dx) - r, y.min(y + dy) - r, x.max(x + dx) + r, y.max(y + dy) + r], + Shape::Star => [x - r * 2.0, y - r * 2.0, x + r * 2.0, y + r * 2.0], + _ => [x - r, y - r, x + r, y + r], + }) +} + +fn sprites(e: &mut Enc, b: GBuf, plan: &SpritePlan) -> Option { + let (w, h) = (b.img.width, b.img.height); + let mut items = Items::new(24, w, h); + for (i, s) in plan.sprites.iter().enumerate() { + let (kind, dx, dy) = match s.shape { + Shape::Soft => (0.0, 0.0, 0.0), + Shape::Disc => (1.0, 0.0, 0.0), + Shape::Sphere => (2.0, 0.0, 0.0), + Shape::Faded => (3.0, 0.0, 0.0), + Shape::Bubble => (4.0, 0.0, 0.0), + Shape::Star => (5.0, 0.0, 0.0), + Shape::Square => (6.0, 0.0, 0.0), + Shape::Tri => (7.0, 0.0, 0.0), + Shape::Line { dx, dy } => (8.0, dx, dy), + }; + let mut rec = [kind, s.x, s.y, s.r, s.c[0], s.c[1], s.c[2], s.c[3], s.rot, dx, dy, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + match plan.tints.get(i).copied().unwrap_or(Tint::Fixed) { + Tint::Fixed => {} + Tint::Refract { x, y } => rec[11..14].copy_from_slice(&[1.0, x as f32, y as f32]), + Tint::Source { x, y } => rec[11..14].copy_from_slice(&[2.0, x as f32, y as f32]), + Tint::Root { x, y, hair, inherit, shade, spec } => { + rec[11..20].copy_from_slice(&[3.0, x as f32, y as f32, hair[0], hair[1], hair[2], inherit, shade, spec]); + } + } + items.push(sprite_bbox(s.x, s.y, s.r, s.c[3], s.shape), &rec); + } + let fx = raster(e, items, 0, plan.acc == Acc::Add, &b.img, None, Params::default(), w, h)?; + let mut p = Params::default(); + match plan.post { + Post::Combine(m) => p.u[0] = [0, m, 0, 0], + Post::Replace => p.u[0] = [0, 5, 0, 0], + Post::Lerp(t) => { + p.u[0][0] = 1; + p.f[0][0] = t; + } + Post::OverBlack => p.u[0][0] = 2, + } + let out = e.scratch(w, h); + e.pixels("fxm_post", &p, &b.img, Some(&fx), &out, None); + Some(GBuf { img: out, ..b }) +} + +// ---------------------------------------------------------------- CC Bubbles / Mr. Mercury + +fn bubbles(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &effectcraft_effects::Buf) -> Option { + let (w, h) = (b.img.width, b.img.height); + let mut items = Items::new(12, w, h); + for q in effectcraft_effects::bubble_list(ctx, geo) { + items.push(Some([q.x - q.r - 1.0, q.y - q.r - 1.0, q.x + q.r + 1.0, q.y + q.r + 1.0]), &[9.0, q.x, q.y, q.r, q.sx, q.sy]); + } + let mut p = Params::default(); + p.u[1] = [0, (ctx.params.e("reflectionType") == 1) as u32, ctx.params.e("shadingType"), 0]; + let img = raster(e, items, 1, false, &b.img, None, p, w, h)?; + Some(GBuf { img, ..b }) +} + +fn mercury(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &effectcraft_effects::Buf) -> Option { + let pr = ctx.params; + let (w, h) = (b.img.width, b.img.height); + let infl = 1.0 + pr.f("blobInfluence") as f32 / 100.0; + let mut items = Items::new(8, w, h); + for q in effectcraft_effects::mercury_blobs(ctx, geo) { + let ex = q.r * 2.0 * infl; + items.push(Some([q.x - ex, q.y - ex, q.x + ex, q.y + ex]), &[10.0, q.x, q.y, q.r]); + } + let mut p = Params::default(); + p.f[0][0] = infl; + let field = raster(e, items, 2, true, &b.img, None, p, w, h)?; + let ldir = (pr.f("light/lightDirection") as f32).to_radians(); + let lheight = pr.f("light/lightHeight") as f32 / 100.0; + let l = norm3([ldir.sin(), -ldir.cos(), lheight.max(0.05)]); + let lc = pr.color("light/lightColor"); + let mut p = Params::default(); + p.f[0] = [ + pr.f("shading/ambient") as f32 / 100.0, + pr.f("shading/diffuse") as f32 / 100.0, + pr.f("shading/specular") as f32 / 100.0, + pr.f("shading/roughness").max(0.001) as f32, + ]; + p.f[1] = [pr.f("shading/metal") as f32 / 100.0, pr.f("light/lightIntensity") as f32 / 100.0, b.scale as f32, 0.0]; + p.f[2] = [l[0], l[1], l[2], 0.0]; + p.f[3] = [lc[0], lc[1], lc[2], 0.0]; + let out = e.scratch(w, h); + e.pixels("fxm_mercury", &p, &b.img, Some(&field), &out, None); + Some(GBuf { img: out, ..b }) +} + +/// sim::norm3. +fn norm3(v: [f32; 3]) -> [f32; 3] { + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + if l > 1e-9 { [v[0] / l, v[1] / l, v[2] / l] } else { [0.0, -1.0, 0.0] } +} + +// ---------------------------------------------------------------- CC Drizzle + +fn drizzle(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &effectcraft_effects::Buf) -> Option { + let pr = ctx.params; + let Some(drops) = effectcraft_effects::drizzle_drops(ctx, geo) else { return Some(b) }; + let rippling = (pr.f("rippling") / 360.0).max(0.05) as f32; + let lh = ctx.layer_size[1] as f32; + let s = b.scale as f32; + let wavelen = (lh * 0.04 * s).max(2.0) / rippling.clamp(0.25, 4.0); + let width = wavelen * (1.0 + rippling * 2.0); + let ldir = (pr.f("light/lightDirection") as f32).to_radians(); + let lheight = pr.f("light/lightHeight") as f32 / 100.0; + let (ls, lcs) = ldir.sin_cos(); + let l = { + let v = [lcs, ls, lheight.max(0.05)]; + let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + [v[0] / n, v[1] / n, v[2] / n] + }; + let hz = (l[2] + 1.0) * 0.5; + let hv = { + let v = [l[0] * 0.5, l[1] * 0.5, hz]; + let m = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + [v[0] / m, v[1] / m, v[2] / m] + }; + let lc = pr.color("light/lightColor"); + let d: Vec = drops.iter().flatten().copied().collect(); + let buf = e.data(&d); + let mut p = Params::default(); + p.u[0][0] = drops.len() as u32; + p.f[0] = [wavelen, width, pr.f("displacement") as f32 * s * 4.0, pr.f("shading/roughness").max(0.001) as f32]; + p.f[1] = [l[0], l[1], l[2], pr.f("light/lightIntensity") as f32 / 100.0]; + p.f[2] = [hv[0], hv[1], hv[2], pr.f("shading/specular") as f32 / 100.0]; + p.f[3] = [lc[0], lc[1], lc[2], 0.0]; + p.f[4] = [pr.f("shading/ambient") as f32 / 100.0, pr.f("shading/diffuse") as f32 / 100.0, 0.0, 0.0]; + let out = e.scratch(b.img.width, b.img.height); + e.pixels("fxm_drizzle", &p, &b.img, None, &out, Some(&buf)); + Some(GBuf { img: out, ..b }) +} + +// ---------------------------------------------------------------- Wave World (Height Map) + +fn wave_height(e: &mut Enc, ctx: &EffectCtx, b: GBuf, st: &effectcraft_effects::Waves, depth: Option<&[f32]>) -> Option { + let pr = ctx.params; + let mut d = st.u.clone(); + if let Some(dp) = depth { + d.extend_from_slice(dp); + } + let buf = e.data(&d); + let mut p = Params::default(); + let dry_transparent = pr.e("heightMapControls/renderDryAreasAs") == 1 && depth.is_some(); + p.u[0] = [st.nx as u32, st.ny as u32, dry_transparent as u32, 0]; + p.f[0] = [ + pr.f("heightMapControls/brightness") as f32, + pr.f("heightMapControls/contrast") as f32, + pr.f("heightMapControls/gamma").max(0.01) as f32, + 1.0 - pr.f("heightMapControls/transparency") as f32, + ]; + p.f[1] = [b.offset[0] as f32, b.offset[1] as f32, b.scale as f32, ctx.layer_size[0].max(1.0) as f32]; + let out = e.scratch(b.img.width, b.img.height); + e.pixels("fxm_wave", &p, &b.img, None, &out, Some(&buf)); + Some(GBuf { img: out, ..b }) +} + +// ---------------------------------------------------------------- pieces + +fn pieces(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + // Planning reads the layer (gradient maps and textures default to it). + let img = e.download(&b.img)?; + let cpu = effectcraft_effects::Buf { img, offset: b.offset, scale: b.scale }; + let Some(plan) = effectcraft_effects::piece_plan(id, ctx, &cpu) else { + // Card Wipe before anything moves: the layer as is; Shatter's wireframes: the CPU. + return (id == "ec.transition.cardwipe").then_some(b); + }; + let (w, h) = (b.img.width, b.img.height); + let tex = |t: &PieceTex| -> Option { + match t { + PieceTex::Layer => Some(b.img.clone()), + PieceTex::Image(i) => e.g.upload_image(i), + } + }; + let front = tex(&plan.front)?; + let back = match &plan.back { + Some(t) => Some(tex(t)?), + None => None, + }; + let mut items = Items::new(48, w, h); + for q in &plan.pieces { + let m = q.inv; + let mut rec = vec![11.0]; + for r in m { + rec.extend(r.map(|v| v as f32)); + } + rec.extend(q.c0); + for v in q.poly { + rec.extend(v); + } + rec.extend([q.n as f32, q.shade]); + rec.extend(q.tint); + rec.extend(q.spec); + rec.push(q.alpha); + match q.flat { + Some(c) => rec.extend([1.0, c[0], c[1], c[2]]), + None => rec.extend([0.0; 4]), + } + rec.extend([q.back as u32 as f32, q.back_mirror as f32]); + items.push(Some(q.bbox), &rec); + } + let mut p = Params::default(); + p.u[1][1] = back.is_some() as u32; + let img = raster(e, items, 3, false, &front, back.as_ref(), p, w, h)?; + Some(GBuf { img, ..b }) +} + +// ---------------------------------------------------------------- Caustics + +fn caustics(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &effectcraft_effects::Buf) -> Option { + let st = effectcraft_effects::caustics_setup(ctx, geo); + let (w, h) = (b.img.width, b.img.height); + let mut bottom = match &st.bottom { + Some(i) => e.g.upload_image(i)?, + None => b.img.clone(), + }; + if st.bottom_sigma > 0.0 { + bottom = gaussian_blur(e, &bottom, st.bottom_sigma, st.bottom_sigma, false); + } + let height = match &st.height { + Some(pl) => { + let mut img = Image::new(pl.w as u32, pl.h as u32); + for (px, v) in img.data.iter_mut().zip(&pl.data) { + *px = [*v, 0.0, 0.0, 0.0]; + } + Some(e.g.upload_image(&img)?) + } + None => None, + }; + let sky = match &st.sky { + Some(i) => { + let g = e.g.upload_image(i)?; + Some((e.image_rows(&g), g.width, g.height)) + } + None => None, + }; + let distant = { + let lw = st.lw.max(1.0); + let v = [(st.lx - st.cx) / lw, (st.ly - st.cy) / lw, st.lheight]; + let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); + [(v[0] / n) as f32, (v[1] / n) as f32, (v[2] / n) as f32] + }; + let mut p = Params::default(); + p.u[0] = [height.is_some() as u32, st.repeat, st.point as u32, st.sky_repeat]; + p.u[1] = [sky.is_some() as u32, sky.as_ref().map_or(0, |s| s.0.1), sky.as_ref().map_or(0, |s| s.1), sky.as_ref().map_or(0, |s| s.2)]; + p.f[0] = [st.scaling as f32, st.wave_h as f32, st.k as f32, st.lw as f32]; + p.f[1] = [st.cx as f32, st.cy as f32, st.lx as f32, st.ly as f32]; + p.f[2] = [st.surf[0], st.surf[1], st.surf[2], st.surf_op]; + p.f[3] = [st.lc[0], st.lc[1], st.lc[2], st.li]; + p.f[4] = [st.lheight as f32, st.cstr, st.ambient, st.diffuse]; + p.f[5] = [st.specular, st.sharp, st.sky_scaling as f32, st.sky_int]; + p.f[6] = [distant[0], distant[1], distant[2], st.convergence as f32]; + let out = e.scratch(w, h); + e.pixels("fxm_caustics", &p, &bottom, height.as_ref(), &out, sky.as_ref().map(|s| &s.0.0)); + Some(GBuf { img: out, ..b }) +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index ca82a5a3..59daf966 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -55,6 +55,7 @@ mod fx_generate; mod fx_key; mod fx_lut; mod fx_noise; +mod fx_sim; mod fx_stylize; mod fx_tone; mod fx_vr; @@ -218,6 +219,8 @@ mod tests_fx_lut; #[cfg(test)] mod tests_fx_noise; #[cfg(test)] +mod tests_fx_sim; +#[cfg(test)] mod tests_fx_stylize; #[cfg(test)] mod tests_fx_tone; diff --git a/crates/gpu/src/shaders/fx_sim.wgsl b/crates/gpu/src/shaders/fx_sim.wgsl new file mode 100644 index 00000000..d936861d --- /dev/null +++ b/crates/gpu/src/shaders/fx_sim.wgsl @@ -0,0 +1,653 @@ +// ---------------------------------------------------------------- simulation render passes (fx_sim.rs) +// +// Items (sprites, CC Bubbles bubbles, Mr. Mercury blobs, textured pieces) are binned into 16 × 16 +// tiles: data = tile offsets, item indices, then the item records (u[0].w floats each, the last +// four the item's integer pixel bounds). Every shading step mirrors effects::sim / sim2. + +fn fxm_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)); +} + +fn fxm_aa(edge: f32) -> f32 { + return clamp(edge + 0.5, 0.0, 1.0); +} + +struct FxmShade { + ok: bool, + c: vec4, +} + +fn fxm_none() -> FxmShade { + return FxmShade(false, vec4(0.0)); +} + +// sim::sprite_shade, after resolving the sprite's tint (sim::SpritePlan::resolve) against `src`. +fn fxm_sprite(at: u32, x: f32, y: f32) -> FxmShade { + let kind = u32(data[at]); + let sx = data[at + 1u]; + let sy = data[at + 2u]; + let r0 = data[at + 3u]; + var c = vec4(data[at + 4u], data[at + 5u], data[at + 6u], data[at + 7u]); + let tk = u32(data[at + 11u]); + if (tk != 0u) { + let smp = sample_bilinear(src, data[at + 12u], data[at + 13u]); + let s = fxm_unpremul(smp); + if (tk == 1u) { + if (s.w > 0.0) { + c = vec4((s.xyz * 0.6 + 0.5) * c.xyz, c.w); + } + } else if (tk == 2u) { + if (s.w <= 0.0) { + c = vec4(0.0); + } else { + c = vec4(s.xyz, s.w * c.w); + } + } else { + if (smp.w < 0.5) { + c = vec4(0.0); + } else { + let hair = vec3(data[at + 14u], data[at + 15u], data[at + 16u]); + let inherit = data[at + 17u]; + let base = s.xyz + (hair - s.xyz) * (1.0 - inherit); + c = vec4(base * data[at + 18u] + data[at + 19u], c.w); + } + } + } + if (c.w <= 1e-5) { + return fxm_none(); + } + var r = r0; + var fade = 1.0; + if (r0 < 0.5) { + r = 0.5; + fade = r0 / 0.5; + } + let dx = x - sx; + let dy = y - sy; + var rgb = c.xyz; + var cov = 0.0; + switch (kind) { + case 0u: { + let d2 = (dx * dx + dy * dy) / (r * r); + if (d2 >= 1.0) { + return fxm_none(); + } + let q = 1.0 - d2; + cov = q * q; + } + case 1u: { + cov = fxm_aa(r - sqrt(dx * dx + dy * dy)); + } + case 2u: { + let d = sqrt(dx * dx + dy * dy); + let cv = fxm_aa(r - d); + if (cv <= 0.0) { + return fxm_none(); + } + let nx = dx / r; + let ny = dy / r; + let nz = sqrt(max(1.0 - nx * nx - ny * ny, 0.0)); + let diff = max(nx * -0.48 + ny * -0.58 + nz * 0.66, 0.0); + let rz = 2.0 * diff * nz - 0.66; + let spec = powz(max(rz, 0.0), 20.0) * 0.6; + let k = 0.2 + 0.8 * diff; + rgb = rgb * k + spec; + cov = cv; + } + case 3u: { + let d = sqrt(dx * dx + dy * dy) / r; + cov = powz(max(1.0 - d, 0.0), 1.5); + } + case 4u: { + let d = sqrt(dx * dx + dy * dy) / r; + if (d > 1.0 + 1.0 / r) { + return fxm_none(); + } + let rr = (d - 0.9) / 0.12; + let ring = max(1.0 - rr * rr, 0.0); + let hx = dx / r + 0.4; + let hy = dy / r + 0.4; + let hl = max(1.0 - (hx * hx + hy * hy) / 0.04, 0.0); + cov = min(0.12 * select(0.0, 1.0, d <= 1.0) + ring * 0.8 + hl, 1.0); + } + case 5u: { + let sn = sin(data[at + 8u]); + let cs = cos(data[at + 8u]); + let ux = (dx * cs + dy * sn) / r; + let uy = (-dx * sn + dy * cs) / r; + let d = sqrt(ux * ux + uy * uy); + let core = max(1.0 - d, 0.0) * max(1.0 - d, 0.0); + let stx = max(1.0 - abs(ux) / 2.0, 0.0) * max(1.0 - abs(uy) * 6.0, 0.0); + let sty = max(1.0 - abs(uy) / 2.0, 0.0) * max(1.0 - abs(ux) * 6.0, 0.0); + cov = min(core + stx + sty, 1.0); + } + case 6u: { + let sn = sin(data[at + 8u]); + let cs = cos(data[at + 8u]); + let ux = dx * cs + dy * sn; + let uy = -dx * sn + dy * cs; + let e = r * 0.75; + cov = fxm_aa(e - abs(ux)) * fxm_aa(e - abs(uy)); + } + case 7u: { + let sn = sin(data[at + 8u]); + let cs = cos(data[at + 8u]); + let ux = dx * cs + dy * sn; + let uy = -dx * sn + dy * cs; + let e = r * 0.5; + var m = 3.4e38; + for (var k = 0; k < 3; k++) { + let a = 1.5707964 + f32(k) * 6.2831855 / 3.0; + m = min(m, e - (ux * cos(a) + uy * sin(a))); + } + cov = fxm_aa(m); + } + default: { + let lx = data[at + 9u]; + let ly = data[at + 10u]; + let l2 = lx * lx + ly * ly; + var t = 0.0; + if (l2 > 0.0) { + t = clamp((dx * lx + dy * ly) / l2, 0.0, 1.0); + } + let px = dx - lx * t; + let py = dy - ly * t; + cov = fxm_aa(r - sqrt(px * px + py * py)); + } + } + let a = cov * c.w * fade; + if (a <= 1e-6) { + return fxm_none(); + } + return FxmShade(true, vec4(rgb * a, a)); +} + +// CC Bubbles' bubble (sim::bubbles). u[1].y = metal, u[1].z = shading type. +fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade { + let qx = data[at + 1u]; + let qy = data[at + 2u]; + let qr = data[at + 3u]; + let dx = (x - qx) / max(qr, 0.5); + let dy = (y - qy) / max(qr, 0.5); + let d2 = dx * dx + dy * dy; + let d = sqrt(d2); + let cov = fxm_aa((1.0 - d) * qr); + if (cov <= 0.0) { + return fxm_none(); + } + let k = select(-0.7, 1.6, P.u[1].y != 0u); + let nz = sqrt(max(1.0 - d2, 0.0)); + let s = fxm_unpremul(sample_bilinear(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k)); + var c = s.xyz; + if (s.w <= 0.0) { + c = vec3(0.6, 0.7, 0.8); + } + var a = cov; + switch (P.u[1].z) { + case 1u: { + c = c * 0.7 + 0.3 * (1.0 - nz); + } + case 2u: { + c = c * (0.5 + 0.5 * nz); + } + case 3u: { + a *= powz(nz, 0.5); + } + case 4u: { + a *= 1.0 - nz * 0.8; + } + default: {} + } + let hx = dx + 0.35; + let hy = dy + 0.35; + let hl = max(1.0 - (hx * hx + hy * hy) / 0.05, 0.0) * 0.8; + c = c + hl; + return FxmShade(true, vec4(c * a, a)); +} + +// Mr. Mercury's blob field (f, ∂f/∂x, ∂f/∂y). f[0].x = 1 + influence. +fn fxm_blob(at: u32, x: f32, y: f32) -> FxmShade { + let dx = x - data[at + 1u]; + let dy = y - data[at + 2u]; + let rr = data[at + 3u] * 2.0 * P.f[0].x; + let d2 = (dx * dx + dy * dy) / (rr * rr); + if (d2 >= 1.0) { + return fxm_none(); + } + let k = 1.0 - d2; + let g = -6.0 * k * k / (rr * rr); + return FxmShade(true, vec4(k * k * k, g * dx, g * dy, 0.0)); +} + +// A textured piece (sim2::draw_pieces): src = front texture, aux = back (u[1].y = 1 when set). +fn fxm_piece(at: u32, x: f32, y: f32) -> FxmShade { + let m0 = vec3(data[at + 1u], data[at + 2u], data[at + 3u]); + let m1 = vec3(data[at + 4u], data[at + 5u], data[at + 6u]); + let m2 = vec3(data[at + 7u], data[at + 8u], data[at + 9u]); + let w = m2.x * x + m2.y * y + m2.z; + if (abs(w) < 1e-12) { + return fxm_none(); + } + let c0 = vec2(data[at + 10u], data[at + 11u]); + var u = (m0.x * x + m0.y * y + m0.z) / w + c0.x; + var v = (m1.x * x + m1.y * y + m1.z) / w + c0.y; + let n = u32(data[at + 24u]); + var md = 3.4e38; + for (var i = 0u; i < n; i++) { + let j = (i + 1u) % n; + let ax = data[at + 12u + i * 2u]; + let ay = data[at + 13u + i * 2u]; + let ex = data[at + 12u + j * 2u] - ax; + let ey = data[at + 13u + j * 2u] - ay; + let l = max(sqrt(ex * ex + ey * ey), 1e-6); + md = min(md, (ex * (v - ay) - ey * (u - ax)) / l); + } + let cov = clamp(md + 1.0, 0.0, 1.0); + if (cov <= 0.0) { + return fxm_none(); + } + var c: vec3; + var a = 1.0; + if (data[at + 33u] != 0.0) { + c = vec3(data[at + 34u], data[at + 35u], data[at + 36u]); + } else { + let back = data[at + 37u] != 0.0; + let mirror = u32(data[at + 38u]); + if (back && mirror == 1u) { + v = 2.0 * c0.y - v; + } else if (back && mirror == 2u) { + u = 2.0 * c0.x - u; + } + var t: vec4; + if (back && P.u[1].y != 0u) { + t = sample_bilinear(aux, u, v); + } else { + t = sample_bilinear(src, u, v); + } + let s = fxm_unpremul(t); + c = s.xyz; + a = s.w; + } + a = a * cov * data[at + 32u]; + if (a <= 1e-6) { + return FxmShade(false, vec4(0.0)); + } + let tint = vec3(data[at + 26u], data[at + 27u], data[at + 28u]); + let spec = vec3(data[at + 29u], data[at + 30u], data[at + 31u]); + let o = c * data[at + 25u] * tint + spec; + return FxmShade(true, vec4(o * a, a)); +} + +// sim::raster over a transparent frame. u[0] = (kind: 0 sprites, 1 bubbles, 2 blob field, +// 3 pieces; additive; items offset; item stride); u[1].x = tiles across. +@compute @workgroup_size(16, 16) +fn fxm_raster(@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 tile = u32(p.y / 16) * P.u[1].x + u32(p.x / 16); + let start = u32(data[tile]); + let end = u32(data[tile + 1u]); + let stride = P.u[0].w; + let add = P.u[0].y != 0u; + let x = f32(p.x) + 0.5; + let y = f32(p.y) + 0.5; + var d = vec4(0.0); + for (var k = start; k < end; k++) { + let at = P.u[0].z + u32(data[k]) * stride; + let bb = at + stride - 4u; + if (f32(p.x) < data[bb] || f32(p.y) < data[bb + 1u] || f32(p.x) > data[bb + 2u] || f32(p.y) > data[bb + 3u]) { + continue; + } + var s: FxmShade; + switch (P.u[0].x) { + case 0u: { + s = fxm_sprite(at, x, y); + } + case 1u: { + s = fxm_bubble(at, x, y); + } + case 2u: { + s = fxm_blob(at, x, y); + } + default: { + s = fxm_piece(at, x, y); + } + } + if (!s.ok) { + continue; + } + let c = s.c; + if (add) { + d = vec4(d.xyz + c.xyz, min(d.w + c.w, 1.0)); + } else { + d = c + d * (1.0 - c.w); + } + } + textureStore(out, p, d); +} + +// The sprite layer (aux) meets the original (src). u[0].x = 0: sim::combine with mode u[0].y; +// 1: mixed toward the original by f[0].x; 2: over opaque black. +@compute @workgroup_size(16, 16) +fn fxm_post(@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 s = textureLoad(src, p, 0); + let f = textureLoad(aux, p, 0); + var o = f; + if (P.u[0].x == 1u) { + o = f + (s - f) * P.f[0].x; + } else if (P.u[0].x == 2u) { + o = vec4(f.xyz, f.w + (1.0 - f.w)); + } else { + switch (P.u[0].y) { + case 0u: { + o = f + s * (1.0 - f.w); + } + case 1u: { + o = s + f - s * f; + } + case 2u: { + o = vec4(s.xyz + f.xyz, min(s.w + f.w, 1.0)); + } + case 3u: { + o = vec4(max(s.xyz, f.xyz), s.w + f.w - s.w * f.w); + } + case 4u: { + let fu = fxm_unpremul(f); + let m = s.xyz - (s.xyz - min(s.xyz, fu.xyz * s.w)) * fu.w; + o = vec4(m, s.w); + } + default: {} + } + } + textureStore(out, p, o); +} + +fn fxm_norm3(v: vec3) -> vec3 { + let l = sqrt(v.x * v.x + v.y * v.y + v.z * v.z); + if (l > 1e-9) { + return v / l; + } + return vec3(0.0, -1.0, 0.0); +} + +// Mr. Mercury's shading (src = layer, aux = blob field). f[0] = (ambient, diffuse, specular, +// roughness); f[1] = (metal, light intensity, render scale); f[2] = light direction; f[3] = colour. +@compute @workgroup_size(16, 16) +fn fxm_mercury(@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 fv = textureLoad(aux, p, 0); + let f = fv.x; + let thr = 0.35; + if (f <= thr * 0.8) { + textureStore(out, p, vec4(0.0)); + return; + } + let cov = clamp((f - thr * 0.8) / (thr * 0.4), 0.0, 1.0); + let n = fxm_norm3(vec3(fv.y * 3.0, fv.z * 3.0, 1.0 / (1.0 + f))); + let s = P.f[1].z; + let smp = fxm_unpremul(sample_bilinear_clamped(src, f32(p.x) + 0.5 - n.x * 12.0 * s, f32(p.y) + 0.5 - n.y * 12.0 * s)); + var base = smp.xyz; + if (smp.w <= 0.0) { + base = vec3(0.7, 0.7, 0.75); + } + let l = P.f[2].xyz; + let nl = max(dot(n, l), 0.0); + let hv = fxm_norm3(vec3(l.x, l.y, l.z + 1.0)); + let li = P.f[1].y; + let spec = powz(max(dot(n, hv), 0.0), 1.0 / P.f[0].w) * P.f[0].z * li; + let shade = P.f[0].x + P.f[0].y * nl * li; + let metal = P.f[1].x; + let cc = base * shade + spec * (P.f[3].xyz * (1.0 - metal) + base * metal); + textureStore(out, p, vec4(cc * cov, cov)); +} + +// CC Drizzle's ripple height at (x, y): data = drops (x, y, ring radius, amplitude). +fn fxm_ripples(x: f32, y: f32) -> f32 { + var v = 0.0; + for (var i = 0u; i < P.u[0].x; i++) { + let dx = data[i * 4u]; + let dy = data[i * 4u + 1u]; + let ex = x - dx; + let ey = y - dy; + let d = sqrt(ex * ex + ey * ey); + let r = data[i * 4u + 2u]; + let u = (d - r) / P.f[0].y; + if (abs(u) < 1.0) { + let env = 0.5 + 0.5 * cos(u * 3.1415927); + v += data[i * 4u + 3u] * env * cos((d - r) / P.f[0].x * 6.2831855); + } + } + return v; +} + +// CC Drizzle. u[0].x = drops; f[0] = (wavelength, ring width, displacement, roughness); +// f[1] = (light direction, intensity); f[2] = (half vector, specular); f[3] = light colour; +// f[4] = (ambient, diffuse). +@compute @workgroup_size(16, 16) +fn fxm_drizzle(@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 fx = f32(p.x) + 0.5; + let fy = f32(p.y) + 0.5; + let gx = fxm_ripples(fx + 1.0, fy) - fxm_ripples(fx - 1.0, fy); + let gy = fxm_ripples(fx, fy + 1.0) - fxm_ripples(fx, fy - 1.0); + if (gx == 0.0 && gy == 0.0) { + textureStore(out, p, textureLoad(src, p, 0)); + return; + } + let smp = sample_bilinear_clamped(src, fx - gx * P.f[0].z, fy - gy * P.f[0].z); + let v = vec3(-gx * 6.0, -gy * 6.0, 1.0); + let n = v / sqrt(v.x * v.x + v.y * v.y + 1.0); + let l = P.f[1].xyz; + let li = P.f[1].w; + let nl = max(dot(n, l), 0.0); + let sp = powz(max(dot(n, P.f[2].xyz), 0.0), 1.0 / P.f[0].w) * P.f[2].w * li; + let shade = 1.0 + (P.f[4].x + P.f[4].y * nl - P.f[4].y * l.z) * li; + let a = smp.w; + textureStore(out, p, vec4(smp.xyz * shade + sp * P.f[3].xyz * a, a)); +} + +// Wave World's Height Map view. data = grid heights (nx × ny), then water depths when +// u[0].z (dry areas transparent); f[0] = (brightness, contrast, gamma, alpha); +// f[1] = (buffer offset, render scale, layer width). +@compute @workgroup_size(16, 16) +fn fxm_wave(@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 nx = P.u[0].x; + let ny = P.u[0].y; + let lw = P.f[1].w; + let lx = (f32(p.x) + 0.5 - P.f[1].x) / P.f[1].z; + let ly = (f32(p.y) + 0.5 - P.f[1].y) / P.f[1].z; + let gx = clamp(lx / lw * f32(nx - 1u), 0.0, f32(nx - 1u)); + let gy = clamp(ly / lw * f32(nx - 1u), 0.0, f32(ny - 1u)); + let x0 = u32(floor(gx)); + let y0 = u32(floor(gy)); + let x1 = min(x0 + 1u, nx - 1u); + let y1 = min(y0 + 1u, ny - 1u); + let tx = gx - f32(x0); + let ty = gy - f32(y0); + let g00 = data[y0 * nx + x0]; + let g10 = data[y0 * nx + x1]; + let g01 = data[y1 * nx + x0]; + let g11 = data[y1 * nx + x1]; + let a = g00 + (g10 - g00) * tx; + let c = g01 + (g11 - g01) * tx; + let h = a + (c - a) * ty; + let v = powz(clamp(P.f[0].x + P.f[0].y * h, 0.0, 1.0), 1.0 / P.f[0].z); + var al = P.f[0].w; + if (P.u[0].z != 0u) { + let rx = u32(clamp(round_away(lx / lw * f32(nx - 1u)), 0.0, f32(nx - 1u))); + let ry = u32(clamp(round_away(ly / lw * f32(nx - 1u)), 0.0, f32(ny - 1u))); + if (data[nx * ny + ry * nx + rx] <= 0.0) { + al = 0.0; + } + } + textureStore(out, p, vec4(v * al, v * al, v * al, al)); +} + +// ---- Caustics + +// The water surface's height gradient at (x, y) (aux = height plane, clamped; none = flat). +fn fxm_grad(x: i32, y: i32) -> vec2 { + if (P.u[0].x == 0u) { + return vec2(0.0); + } + let gx = (tex_get_clamped(aux, x + 1, y).x - tex_get_clamped(aux, x - 1, y).x) * 0.5; + let gy = (tex_get_clamped(aux, x, y + 1).x - tex_get_clamped(aux, x, y - 1).x) * 0.5; + return vec2(gx, gy); +} + +fn fxm_rem(v: f32, n: f32) -> f32 { + return v - n * floor(v / n); +} + +fn fxm_mirror(v: f32, n: f32) -> f32 { + let r = fxm_rem(v, 2.0 * n); + return select(r, 2.0 * n - r, r > n); +} + +// sim2::sample_repeat of `src`. +fn fxm_sample_repeat(x: f32, y: f32, mode: u32) -> vec4 { + let d = vec2(textureDimensions(src)); + if (mode == 1u) { + return sample_bilinear_clamped(src, fxm_rem(x, d.x), fxm_rem(y, d.y)); + } + if (mode == 2u) { + return sample_bilinear_clamped(src, fxm_mirror(x, d.x), fxm_mirror(y, d.y)); + } + return sample_bilinear(src, x, y); +} + +// A sky texel from `data` (RGBA f32 rows of u[1].y pixels; u[1].zw = size). +fn fxm_sky_texel(x: i32, y: i32, clamped: bool) -> vec4 { + let w = i32(P.u[1].z); + let h = i32(P.u[1].w); + var xi = x; + var yi = y; + if (clamped) { + xi = clamp(x, 0, w - 1); + yi = clamp(y, 0, h - 1); + } else if (x < 0 || y < 0 || x >= w || y >= h) { + return vec4(0.0); + } + let i = (u32(yi) * P.u[1].y + u32(xi)) * 4u; + return vec4(data[i], data[i + 1u], data[i + 2u], data[i + 3u]); +} + +fn fxm_sky_bilinear(x: f32, y: f32, clamped: bool) -> vec4 { + let fx = x - 0.5; + let fy = y - 0.5; + let x0 = floor(fx); + let y0 = floor(fy); + let tx = fx - x0; + let ty = fy - y0; + let xi = i32(x0); + let yi = i32(y0); + let a = fxm_sky_texel(xi, yi, clamped); + let b = fxm_sky_texel(xi + 1, yi, clamped); + let c = fxm_sky_texel(xi, yi + 1, clamped); + let d = fxm_sky_texel(xi + 1, yi + 1, clamped); + let top = a + (b - a) * tx; + let bot = c + (d - c) * tx; + return top + (bot - top) * ty; +} + +fn fxm_sky_repeat(x: f32, y: f32, mode: u32) -> vec4 { + let w = f32(P.u[1].z); + let h = f32(P.u[1].w); + if (mode == 1u) { + return fxm_sky_bilinear(fxm_rem(x, w), fxm_rem(y, h), true); + } + if (mode == 2u) { + return fxm_sky_bilinear(fxm_mirror(x, w), fxm_mirror(y, h), true); + } + return fxm_sky_bilinear(x, y, false); +} + +// Caustics (sim2::caustics; src = the blurred bottom, aux = height plane, data = sky rows). +// u[0] = (height, repeat, point light, sky repeat); u[1] = (sky, row, width, height); +// f[0] = (scaling, wave height, displacement k, layer width); f[1] = (centre, light position); +// f[2] = (surface colour, opacity); f[3] = (light colour, intensity); f[4] = (light height, +// caustics strength, ambient, diffuse); f[5] = (specular, sharpness, sky scaling, sky +// intensity); f[6] = (distant light direction, convergence). +@compute @workgroup_size(16, 16) +fn fxm_caustics(@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 k = P.f[0].z; + let x = p.x; + let y = p.y; + let dd = -fxm_grad(x, y) * k; + let dxr = -fxm_grad(min(x + 1, dims.x - 1), y).x * k; + let dyd = -fxm_grad(x, min(y + 1, dims.y - 1)).y * k; + let dxl = -fxm_grad(max(x - 1, 0), y).x * k; + let dyu = -fxm_grad(x, max(y - 1, 0)).y * k; + let jac = (1.0 + (dxr - dxl) * 0.5) * (1.0 + (dyd - dyu) * 0.5); + let focus = clamp(1.0 / max(abs(jac), 0.2) - 1.0, -1.0, 4.0); + let cx = P.f[1].x; + let cy = P.f[1].y; + let fx = f32(x) + 0.5; + let fy = f32(y) + 0.5; + let sx = cx + (fx + dd.x - cx) / P.f[0].x; + let sy = cy + (fy + dd.y - cy) / P.f[0].x; + let bot = fxm_unpremul(fxm_sample_repeat(sx, sy, P.u[0].y)); + let g = fxm_grad(x, y); + let wh = P.f[0].y; + let v = vec3(-g.x * wh * 20.0, -g.y * wh * 20.0, 1.0); + let n = v / sqrt(v.x * v.x + v.y * v.y + 1.0); + var lv = P.f[6].xyz; + if (P.u[0].z != 0u) { + let lw = max(P.f[0].w, 1.0); + let q = vec3((P.f[1].z - fx) / lw, (P.f[1].w - fy) / lw, P.f[4].x); + lv = q / max(sqrt(q.x * q.x + q.y * q.y + q.z * q.z), 1e-12); + } + let li = P.f[3].w; + let nl = max(dot(n, lv), 0.0); + let hq = vec3(lv.x, lv.y, lv.z + 1.0); + let hv = hq / sqrt(hq.x * hq.x + hq.y * hq.y + hq.z * hq.z); + let spec = powz(max(dot(n, hv), 0.0), P.f[5].y) * P.f[5].x * li; + let ambient = P.f[4].z; + let diffuse = P.f[4].w; + let light = ambient + diffuse * li * nl + P.f[4].y * focus * li; + let surf = P.f[2].xyz; + let surf_op = P.f[2].w; + var surface: vec3; + if (P.u[1].x != 0u) { + let reach = (1.0 - P.f[6].w) * P.f[0].w * 0.5; + let rx = cx + (fx + n.x * reach - cx) / P.f[5].z; + let ry = cy + (fy + n.y * reach - cy) / P.f[5].z; + let sc = fxm_unpremul(fxm_sky_repeat(rx, ry, P.u[0].w)).xyz; + surface = sc * P.f[5].w * 3.0 * surf; + } else { + surface = surf * (ambient + diffuse * li * nl); + } + let lc = P.f[3].xyz; + let cc = bot.xyz * light * lc * (1.0 - surf_op) + surface * surf_op + spec * lc; + let a = min(bot.w + surf_op * (1.0 - bot.w), 1.0); + textureStore(out, p, vec4(cc * a, a)); +} diff --git a/crates/gpu/src/tests_fx_sim.rs b/crates/gpu/src/tests_fx_sim.rs new file mode 100644 index 00000000..55dc62c4 --- /dev/null +++ b/crates/gpu/src/tests_fx_sim.rs @@ -0,0 +1,154 @@ +//! GPU simulation render passes vs the CPU effects (the oracle): the frame's plan (sprites, +//! pieces, blobs, drops, height grid) is shared, the raster / shading pass runs on each side. +//! Direct on a buffer (full and half resolution) and composited at 8 and 32 bpc, at a time +//! where the simulations have something to show. + +use effectcraft_keyframe::Value; +use effectcraft_project::BitDepth; +use effectcraft_render::RenderOpts; +use effectcraft_time::Tick; + +use crate::tests::{Scene, c, check, compare_at, diff, gpu, n, opts, pattern, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +fn off() -> Value { + Value::Bool(false) +} + +/// The effect alone on a buffer at layer time `t` (`allow` = fraction of pixels over 1e-3). +fn direct(id: &str, vals: &[(&str, Value)], t: f64, 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 scale in [1.0, 0.5] { + let img = pattern(7, (size[0] * scale) as u32, (size[1] * scale) as u32); + let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale }; + let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: t, layer_size: size, seed: 11, adjustment: false, env: 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:?} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}", + d.over, + d.total, + d.max, + d.worst + ); + } +} + +/// A footage layer with the effect over a background and a second footage layer (the target of +/// `Value::Layer(Some(0))` parameters), composited at 8 and 32 bpc at comp time `t`. +fn composited(id: &str, vals: &[(&str, Value)], t: f64, 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); + s.push(bg); + let other = s.footage(60, 50); + let other_id = s.push(other); + let mut l = s.footage(70, 44); + let vals: Vec<(&str, Value)> = + vals.iter().map(|(k, v)| (*k, if *v == Value::Layer(Some(0)) { Value::Layer(Some(other_id.0)) } else { v.clone() })).collect(); + s.effect(&mut l, id, &vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let at = Tick::from_seconds_f64(t); + check(&format!("{id} {vals:?} {depth:?}"), compare_at(&s, opts(), at), allow); + check(&format!("{id} {vals:?} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, at), allow); + } +} + +fn case(id: &str, vals: &[(&str, Value)], t: f64, allow: f64) { + direct(id, vals, t, allow); + composited(id, vals, t, allow); +} + +#[test] +fn weather_and_star_burst() { + case("ec.sim.ccrainfall", &[("drops", n(800.0)), ("size", n(3.0))], 0.5, 0.0); + case("ec.sim.ccrainfall", &[("drops", n(500.0)), ("extras/appearance", e(1)), ("transferMode", e(0)), ("wind", n(800.0))], 0.7, 0.0); + case("ec.sim.ccrainfall", &[("drops", n(300.0)), ("compositeWithOriginal", off()), ("size", n(6.0))], 0.3, 0.0); + case("ec.sim.ccsnowfall", &[("flakes", n(600.0)), ("size", n(6.0))], 0.5, 0.0); + case("ec.sim.ccsnowfall", &[("flakes", n(400.0)), ("transferMode", e(1)), ("wiggle/wiggleAmount", n(8.0))], 1.2, 0.0); + case("ec.sim.ccstarburst", &[("gridSpacing", n(6.0))], 0.4, 0.0); + case("ec.sim.ccstarburst", &[("gridSpacing", n(4.0)), ("blendWithOriginal", n(40.0)), ("size", n(160.0))], 0.9, 0.0); +} + +#[test] +fn bubbles_drizzle_mercury() { + case("ec.sim.ccbubbles", &[("bubbleAmount", n(60.0)), ("bubbleSize", n(3.0))], 0.5, 0.0); + for shading in 1..5 { + case( + "ec.sim.ccbubbles", + &[("bubbleAmount", n(40.0)), ("bubbleSize", n(4.0)), ("shadingType", e(shading)), ("reflectionType", e(shading % 2))], + 0.8, + 0.0, + ); + } + case("ec.sim.ccdrizzle", &[("dripRate", n(12.0)), ("spreading", n(300.0))], 0.6, 0.0); + case("ec.sim.ccdrizzle", &[("dripRate", n(20.0)), ("shading/diffuse", n(40.0)), ("shading/ambient", n(-20.0)), ("light/lightHeight", n(60.0))], 1.1, 0.0); + case("ec.sim.ccmrmercury", &[("birthRate", n(4.0)), ("blobBirthSize", n(1.5)), ("blobDeathSize", n(1.0))], 1.0, 0.0); + case("ec.sim.ccmrmercury", &[("birthRate", n(3.0)), ("blobInfluence", n(60.0)), ("shading/metal", n(80.0)), ("gravity", n(2.0))], 1.5, 0.0); +} + +#[test] +fn hair_wave_world_foam() { + case("ec.sim.cchair", &[("density", n(300.0)), ("length", n(10.0))], 0.0, 0.0); + case("ec.sim.cchair", &[("density", n(200.0)), ("length", n(14.0)), ("hairColor/colorInheritance", n(30.0)), ("thickness", n(1.5))], 0.0, 0.0); + case("ec.sim.waveworld", &[], 0.5, 0.0); + case("ec.sim.waveworld", &[("view", e(1))], 0.8, 0.0); + composited("ec.sim.waveworld", &[("view", e(1)), ("ground/ground", Value::Layer(Some(0))), ("heightMapControls/renderDryAreasAs", e(1))], 0.6, 0.0); + case("ec.sim.foam", &[], 1.0, 0.0); + case("ec.sim.foam", &[("view", e(2))], 1.5, 0.0); + case("ec.sim.foam", &[("view", e(2)), ("rendering/bubbleTexture", e(4)), ("rendering/blendMode", e(2))], 1.2, 0.0); +} + +#[test] +fn pieces() { + case("ec.sim.carddance", &[("rows", n(5.0)), ("columns", n(7.0)), ("zRotation/zRotMultiplier", n(20.0))], 0.0, 0.0); + composited("ec.sim.carddance", &[("rows", n(4.0)), ("backLayer", Value::Layer(Some(0))), ("yRotation/yRotMultiplier", n(150.0))], 0.0, 0.0); + case("ec.sim.shatter", &[("view", e(0))], 1.0, 0.0); + case("ec.sim.shatter", &[("view", e(0)), ("shape/pattern", e(2)), ("physics/gravity", n(4.0))], 1.6, 0.0); + case("ec.transition.cardwipe", &[("completion", n(40.0))], 0.0, 0.0); + composited("ec.transition.cardwipe", &[("completion", n(60.0)), ("backLayer", Value::Layer(Some(0))), ("flipAxis", e(1))], 0.0, 0.0); +} + +#[test] +fn caustics() { + case("ec.sim.caustics", &[], 0.0, 0.0); + composited( + "ec.sim.caustics", + &[("water/waterSurface", Value::Layer(Some(0))), ("water/waveHeight", n(0.3)), ("water/smoothing", n(4.0)), ("lighting/lightType", e(1))], + 0.0, + 0.0, + ); + composited( + "ec.sim.caustics", + &[ + ("water/waterSurface", Value::Layer(Some(0))), + ("sky/sky", Value::Layer(Some(0))), + ("water/surfaceOpacity", n(0.6)), + ("bottom/repeatMode", e(2)), + ("bottom/scaling", n(1.4)), + ("bottom/blur", n(3.0)), + ("sky/repeatMode", e(1)), + ], + 0.0, + 0.0, + ); + let _ = (c(0.0, 0.0, 0.0), on()); +} diff --git a/docs/effects.md b/docs/effects.md index 26310ace..6adea08e 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**: 193 effects also run on the GPU compositor with identical results. +- **GPU**: 206 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). @@ -318,23 +318,23 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| | CC Ball Action | `ec.sim.ccballaction` | 8 | | 32 | Implemented | -| CC Bubbles | `ec.sim.ccbubbles` | 7 | | 32 | Implemented | -| CC Drizzle | `ec.sim.ccdrizzle` | 14 | | 32 | Implemented | -| CC Hair | `ec.sim.cchair` | 20 | | 32 | Implemented | -| CC Mr. Mercury | `ec.sim.ccmrmercury` | 23 | | 32 | Implemented | +| CC Bubbles | `ec.sim.ccbubbles` | 7 | GPU | 32 | Implemented | +| CC Drizzle | `ec.sim.ccdrizzle` | 14 | GPU | 32 | Implemented | +| CC Hair | `ec.sim.cchair` | 20 | GPU | 32 | Implemented | +| CC Mr. Mercury | `ec.sim.ccmrmercury` | 23 | GPU | 32 | Implemented | | CC Particle Systems II | `ec.sim.ccparticlesystems2` | 21 | | 32 | Implemented | | CC Particle World | `ec.sim.ccparticleworld` | 30 | | 32 | Implemented | | CC Pixel Polly | `ec.sim.ccpixelpolly` | 10 | | 32 | Implemented | -| CC Rainfall | `ec.sim.ccrainfall` | 14 | | 32 | Implemented | +| CC Rainfall | `ec.sim.ccrainfall` | 14 | GPU | 32 | Implemented | | CC Scatterize | `ec.sim.ccscatterize` | 4 | | 32 | Implemented | -| CC Snowfall | `ec.sim.ccsnowfall` | 19 | | 32 | Implemented | -| CC Star Burst | `ec.sim.ccstarburst` | 6 | | 32 | Implemented | -| Card Dance | `ec.sim.carddance` | 53 | | 32 | Implemented | -| Caustics | `ec.sim.caustics` | 28 | | 32 | Implemented | -| Foam | `ec.sim.foam` | 36 | | 32 | Implemented | +| CC Snowfall | `ec.sim.ccsnowfall` | 19 | GPU | 32 | Implemented | +| CC Star Burst | `ec.sim.ccstarburst` | 6 | GPU | 32 | Implemented | +| Card Dance | `ec.sim.carddance` | 53 | GPU | 32 | Implemented | +| Caustics | `ec.sim.caustics` | 28 | GPU | 32 | Implemented | +| Foam | `ec.sim.foam` | 36 | GPU | 32 | Implemented | | Particle Playground | `ec.sim.particleplayground` | 92 | | 32 | Implemented | -| Shatter | `ec.sim.shatter` | 60 | | 32 | Implemented | -| Wave World | `ec.sim.waveworld` | 35 | | 32 | Implemented | +| Shatter | `ec.sim.shatter` | 60 | GPU | 32 | Implemented | +| Wave World | `ec.sim.waveworld` | 35 | GPU | 32 | Implemented | ## Stylize @@ -401,7 +401,7 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Scale Wipe | `ec.transition.ccscalewipe` | 3 | | 32 | Implemented | | CC Twister | `ec.transition.cctwister` | 5 | | 32 | Implemented | | CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | | 32 | Implemented | -| Card Wipe | `ec.transition.cardwipe` | 44 | | 32 | Implemented | +| Card Wipe | `ec.transition.cardwipe` | 44 | GPU | 32 | Implemented | | Gradient Wipe | `ec.transition.gradientwipe` | 5 | GPU | 32 | Implemented | | Iris Wipe | `ec.transition.iriswipe` | 7 | GPU | 32 | Implemented | | Linear Wipe | `ec.transition.linearwipe` | 3 | GPU | 32 | Implemented | From 49d0c16c9fd4bc5e1f0668dc200ab225d469317c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 18:36:26 -0400 Subject: [PATCH 5/6] =?UTF-8?q?M13.23:=20docs=20=E2=80=94=20GPU=20effects?= =?UTF-8?q?=20part=20B=20(parity=20EFF-5,=20architecture=20GPU=20section,?= =?UTF-8?q?=20ROADMAP=20count,=20gpu=20crate=20docs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/gpu/src/lib.rs | 10 +++++++--- docs/architecture.md | 8 ++++++-- docs/parity.md | 44 ++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3df8063e..65c50924 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,7 +29,7 @@ Measured feature by feature in [docs/parity.md](docs/parity.md) (3 October 2026, - **≈ 98% of After Effects' features, weighted by importance**, counting partial features as half done (≈ 99.6% with per-feature fractions): 87 of 92 features done, 5 partial, none missing; all - 306 effects implemented in full, 166 of them on the GPU. + 306 effects implemented in full, 206 of them on the GPU. - **≈ 9–11 agent-hours of work remain**: about **2–2.5 hours of wall-clock time** with five Claude Opus 5.5 agents in parallel, ≈ 1.5 hours for the essentials and daily-use features. - What is left: more GPU effects, GPU effects and a disk cache in the web app, a template gallery diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 59daf966..f3f48c48 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -20,9 +20,13 @@ //! GPU effects ([`effectcraft_effects::GPU_EFFECTS`]) run as compute kernels with the CPU //! effect's exact steps (padding, box-blur radii, parameter conversions); chains of them are //! uploaded and read back once. Each family lives in its own module with its own WGSL file -//! (`fx_color`, `fx_distort`, `fx_extra`, `fx_generate`, `fx_key`, `fx_noise`, `fx_stylize`, -//! `fx_tone`, `fx_warp`); settings a kernel cannot match fall back to the CPU -//! (`catalog::gpu_supported`, or `None` from the family's `apply`). +//! (`fx_color`, `fx_depth`, `fx_distort`, `fx_extra`, `fx_generate`, `fx_key`, `fx_lut`, +//! `fx_noise`, `fx_sim`, `fx_stylize`, `fx_tone`, `fx_vr`, `fx_warp`); settings a kernel cannot +//! match fall back to the CPU (`catalog::gpu_supported`, or `None` from the family's `apply`). +//! The 3D Channel effects read the layer's aux channels as an extra texture (`fx_depth`), the +//! colour-management effects interpret colour programs built next to the CPU effects +//! (`fx_lut`), and simulations keep their state on the CPU and rasterise its per-frame plan here +//! (`fx_sim`). //! //! Working textures come from a pool (`context::Pool`): a frame allocates several full-frame //! RGBA f32 images per layer, and creating and zeroing those cost more than compositing a small diff --git a/docs/architecture.md b/docs/architecture.md index 3a9d5627..f9a91e46 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -193,9 +193,13 @@ GPU canvas; only the depth of field reads back its 8-byte radius range. Advanced blend modes, track mattes or Preserve Transparency and environment backgrounds take the CPU's 2D compositing path (their scenes still render through `render_3d`). Wireframe outlines draw on the GPU from the CPU's pixel list (`Renderer::wireframe_pixels`). GPU -effects (`effects::GPU_EFFECTS`, 166 of them: blurs, colour correction, keying incl. Key Light, +effects (`effects::GPU_EFFECTS`, 206 of them: blurs, colour correction, keying incl. Key Light, mattes, channel, stylize, distortion and warps (Warp, Bezier Warp, Smear, Reshape, CC Bend It, CC -Page Turn), Cartoon, bevels, shapes, transitions, generators, noise, grain and time; see +Page Turn), Cartoon, bevels, shapes, transitions, generators, noise, grain, time, 3D Channel +(aux channels as an extra texture), Immersive Video, colour management (colour programs from +`effects::color_program`, LUTs in a storage buffer) and the simulations' render passes (the +simulation stays on the CPU and hands its plan — sprites, pieces, blobs, grids — to a tiled +rasteriser); see [effects.md](effects.md)) repeat the CPU effect's steps (padding, box radii, parameters, hashes) as kernels, in one module per family (`gpu::fx_*` with `shaders/fx_*.wgsl`); consecutive GPU effects run as one chain with one upload and one readback. Statistics that need the whole frame diff --git a/docs/parity.md b/docs/parity.md index 718038d6..a31eca2b 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,7 +10,7 @@ How close EffectCraft is to After Effects 2026, feature by feature, and how much | Unweighted | ≈ 97% (half-credit) / 99% (fractions) | | P0 / P1 / P2 | ≈ 99% / 96% / 90% (half-credit); 99.8% / 99.2% / 97.5% (fractions) | | Features done / partial / missing | 87 / 5 / 0 of 92 | -| **Effects** | **306** effects (all After Effects 2026 effects), every one implemented in full ([effects.md](effects.md)); 166 run on the GPU | +| **Effects** | **306** effects (all After Effects 2026 effects), every one implemented in full ([effects.md](effects.md)); 206 run on the GPU | | Disabled menu entries left | 1, on purpose: Import ▸ Vanishing Point (.vpe), whose format has no public specification | | Remaining work | ≈ 9–11 agent-hours at the pace measured so far (≈ 29 on the conservative audit scale); ≈ 6–7 without the learned-model items | | **Wall-clock estimate** | **≈ 2–2.5 hours** with five agents in parallel; ≈ 1.5 hours for 100% of P0 + P1 | @@ -19,7 +19,7 @@ Partial features: | id | Tier | Done | What is missing | |---|---|---|---| -| EFF-5 GPU effects | P1 | 0.85 | GPU kernels for the CC light / transition families, Numbers and Timecode, 3D Channel, the VR family, OCIO / LUT, Time effects and the simulations; Advanced 3D runs with blend modes, track mattes or environment backgrounds composite on the CPU | +| EFF-5 GPU effects | P1 | 0.92 | GPU kernels for the CC light / transition families, Numbers and Timecode, Time effects and the stepped particle systems' sprite pass (3D Channel, the VR family, OCIO / LUT and the simulations' render passes landed in M13.23); Advanced 3D runs with blend modes, track mattes or environment backgrounds composite on the CPU | | WEB-1 Web app | P0 | 0.9 | GPU effects in the browser (needs WebGPU in the render workers or async readback), a disk cache in browser storage (OPFS), a storage manager, threads inside one engine instance | | UI-7 Home screen | P1 | 0.95 | A "start from a template" gallery | | MSK-4 Roto Brush | P2 | 0.85 | Segmentation is classical (graph cut + optical flow), not a learned model | @@ -155,7 +155,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Text | ≈ 96% | 0.3 | no extruded strokes (M13.12: the Variable Font Axes animator re-spaces the text — advances follow the animated axes; M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | | Web | 93% | 0.5 | GPU effects in the browser (WebGPU compositing only on the page), no disk cache, no shared-memory threads inside one engine instance (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10) | | 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 | -| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (166 run on the GPU since M13.13, EFF-5: Warp, Bezier Warp, Reshape, Smear, CC Bend It, CC Page Turn, Cartoon, Color Emboss, Circle, Ellipse, Iris Wipe, Bevel Alpha / Edges and Gaussian Blur (Legacy) joined the 152 of M13.9; simulations, 3D channel, VR, audio-driven, OCIO, text and the CC light / transition families still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | +| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (206 run on the GPU since M13.23, EFF-5: the 3D Channel and Immersive Video families, Apply Color LUT, the OCIO effects, Color Profile Converter and the simulations' render passes joined the 166 of M13.13; audio-driven, text, the stepped particle systems and the CC light / transition families still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | | Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) | | Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | @@ -373,6 +373,44 @@ in Settings (`customRgb`) and simulated like the built-in profiles, with Preserv `form.field.`, `form.ok` and `form.cancel` automation ids. Not yet: LUT-based (`A2B0`) ICC profiles. +## Update: M13.23 GPU effects, part B (EFF-5) + +40 more effects run on the GPU (206 in all), each checked against the CPU oracle (≤ 1/255 at +8 bpc, ≤ 1e-3 at 32 bpc) directly on a buffer (full and half resolution, adjustment) and +composited at 8 and 32 bpc: + +- **3D Channel** (`gpu::fx_depth`): 3D Channel Extract (every channel, Anti-alias), Cryptomatte, + Depth Matte, Depth of Field, EXtractoR, Fog 3D (with a Gradient Layer), ID Matte and + IDentifier. The layer's auxiliary channels upload as an extra texture at the aux resolution and + are resampled onto the buffer with the CPU's index arithmetic (tested at 1× and 2× aux + resolution and on padded buffers). Cryptomatte's ranks are reduced per aux pixel on the CPU + (selection coverage and ID colours), then looked up on the GPU. +- **Immersive Video** (`gpu::fx_vr`): VR Blur, Chromatic Aberrations, Color Gradients, Converter + (all nine projections and cube layouts), De-Noise (guided filter and median), Digital Glitch, + Fractal Noise, Glow, Plane to Sphere, Rotate Sphere, Sharpen and Sphere to Plane, in mono and + both stereo layouts: the equirectangular maths (seam wrap, pole continuation, latitude-widened + box blurs) ported operation for operation. Exception: VR Converter re-projections that cross a + cube-face edge or a fisheye rim may pick the neighbouring face for directions within f32 + rounding of the edge (the CPU decides in f64): up to 0.5 % of pixels allowed (measured 0). +- **Colour management** (`gpu::fx_lut`): Apply Color LUT, OCIO CDL / Color Space / Display / + File / Look Transform and Color Profile Converter compile to colour programs + (`effects::color_program`, next to the CPU effects) interpreted per pixel; 1D / 3D LUTs and + cineSpace shapers are read from a storage buffer with the CPU's nearest / trilinear / + tetrahedral interpolation and inverses (not a hardware 3D texture: its filtering rounds the + weights and cannot do tetrahedral). Lumetri's Input LUT and Look no longer force the CPU. + Custom `.ocio` configurations render on the CPU. Exception: nearest-neighbour lattice lookups + may pick the other lattice point for 8 bpc inputs exactly on a rounding boundary (up to 1 % + of pixels allowed). +- **Simulation render passes** (`gpu::fx_sim`): CC Rainfall, CC Snowfall, CC Star Burst, CC + Bubbles, CC Drizzle, CC Hair, CC Mr. Mercury, Caustics, Wave World, Foam, Shatter, Card Dance + and Card Wipe. The per-frame simulation / layout stays on the CPU and is shared with the CPU + effect as a plan (sprites, textured pieces, blobs, drops, the wave grid, Caustics' prepared + layers); a tiled rasteriser composites each pixel's items in the CPU's order and shading, with + layer-dependent colours (refracting rain, star-burst and hair roots, bubble refraction) + sampled per pixel. Piece plans read the frame back once (their gradient maps and textures + default to the layer). Shatter's wireframe views and Foam's User Defined texture, + Environment Map and flow-map preview render on the CPU. + ## Highest-value gaps, in order 1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10). From 4a2ca9115bf71e926c554f21874de678fa49a005 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 23:59:22 -0400 Subject: [PATCH 6/6] M13.23: Shatter wireframe views and Foam's texture / map extras fall back via gpu_supported (no readback attempt) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 13 +++++++++++++ crates/effects/src/sim2.rs | 8 +------- crates/gpu/src/tests_fx_sim.rs | 8 ++++++++ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index ca580b9d..1e1b4bc2 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -97,6 +97,19 @@ pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool { match id { "ec.generate.cellpattern" if (6..=10).contains(&ctx.params.e("cellPattern")) => return false, "ec.distort.turbulentdisplace" if ctx.params.e("pinning") >= 8 => return false, + // Shatter's wireframe views; Foam's User Defined texture, Environment Map and flow-map + // preview (the GPU draws the rendered pieces and the sprite bubbles only). + "ec.sim.shatter" if ctx.params.e("view") != 0 => return false, + "ec.sim.foam" => { + let set = |id: &str| matches!(ctx.params.get(id), Some(effectcraft_keyframe::Value::Layer(Some(_)))); + let view = ctx.params.e("view"); + if (view == 2 && ctx.params.e("rendering/bubbleTexture") == 5) + || (view == 2 && ctx.params.f("rendering/reflectionStrength") > 0.0 && set("rendering/environmentMap")) + || (view == 1 && set("flowMap/flowMap")) + { + return false; + } + } // Fisheye / Twist (a crease at the unit circle) bent past 50 % or with Horizontal / // Vertical Distortion: Newton's inverse wanders chaotically before converging near the // fold, so f32 lands on other pixels than the CPU's f64. diff --git a/crates/effects/src/sim2.rs b/crates/effects/src/sim2.rs index d682a310..98634b0f 100644 --- a/crates/effects/src/sim2.rs +++ b/crates/effects/src/sim2.rs @@ -1525,13 +1525,7 @@ fn foam(ctx: &EffectCtx, b: Buf) -> Buf { /// Foam's bubbles as sprites, `None` when it draws more than sprites (a User Defined bubble /// texture, an Environment Map reflection or the Draft + Flow Map view's flow map). pub(crate) fn foam_plan(ctx: &EffectCtx, b: &Buf) -> Option { - let pr = ctx.params; - let set = |id: &str| matches!(pr.get(id), Some(Value::Layer(Some(_)))); - let view = pr.e("view"); - if (view == 2 && pr.e("rendering/bubbleTexture") == 5) - || (view == 2 && pr.f("rendering/reflectionStrength") > 0.0 && set("rendering/environmentMap")) - || (view == 1 && set("flowMap/flowMap")) - { + if !crate::catalog::gpu_supported("ec.sim.foam", ctx) { return None; } foam_impl(ctx, b, true).err() diff --git a/crates/gpu/src/tests_fx_sim.rs b/crates/gpu/src/tests_fx_sim.rs index 55dc62c4..d6977779 100644 --- a/crates/gpu/src/tests_fx_sim.rs +++ b/crates/gpu/src/tests_fx_sim.rs @@ -152,3 +152,11 @@ fn caustics() { ); let _ = (c(0.0, 0.0, 0.0), on()); } + +#[test] +fn cpu_only_views_fall_back() { + // Shatter's wireframe views and Foam's User Defined texture render on the CPU; composited + // frames still match. + composited("ec.sim.shatter", &[], 1.0, 0.0); + composited("ec.sim.foam", &[("view", e(2)), ("rendering/bubbleTexture", e(5)), ("rendering/bubbleTextureLayer", Value::Layer(Some(0)))], 1.0, 0.0); +}