From aafe46e091556336f764a7ca54eec34202e1cd56 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:15:01 -0400 Subject: [PATCH 1/9] M12.7: Classic 3D runs on the GPU (per-pixel depth sort, lights, ray-cast shadows, DOF); Auto sends 3D comps to the GPU Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft-cli/src/main.rs | 15 +- crates/gpu/src/classic3d.rs | 244 ++++++++++++++ crates/gpu/src/context.rs | 69 +++- crates/gpu/src/effects.rs | 2 +- crates/gpu/src/lib.rs | 3 + crates/gpu/src/shaders/classic3d.wgsl | 460 ++++++++++++++++++++++++++ crates/gpu/src/tests.rs | 54 +-- crates/gpu/src/tests_3d.rs | 220 ++++++++++++ crates/gpu/src/walk.rs | 21 +- crates/render/src/lib.rs | 12 +- crates/render/src/tests.rs | 9 +- crates/render/src/three_d/compose.rs | 179 ++++++++-- crates/render/src/three_d/mod.rs | 1 + 13 files changed, 1214 insertions(+), 75 deletions(-) create mode 100644 crates/gpu/src/classic3d.rs create mode 100644 crates/gpu/src/shaders/classic3d.wgsl create mode 100644 crates/gpu/src/tests_3d.rs diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 4f2192fc..9e1a4a8b 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -761,7 +761,10 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> { None => s.project.comps().map(|(id, _)| *id).collect(), }; eprintln!("GPU: {} — median of {n} runs (ms/frame)", effectcraft_render::Accelerator::name(&gpu)); - eprintln!("{:<28} {:>5} {:>10} {:>9} {:>9} {:>9} {:>9} {:>9}", "comp", "res", "size", "cpu cold", "gpu cold", "cpu warm", "gpu warm", "gpu view"); + eprintln!( + "{:<28} {:>5} {:>10} {:>9} {:>9} {:>9} {:>9} {:>9} {:>8} {:>9}", + "comp", "res", "size", "cpu cold", "gpu cold", "cpu warm", "gpu warm", "gpu view", "speedup", "gpu≠cpu" + ); for cid in comps { let Some(comp) = s.project.comp(cid) else { continue }; let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); @@ -797,7 +800,15 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> { gpu.wait(); }); let size = format!("{}x{}", (comp.width as f64 * scale).round(), (comp.height as f64 * scale).round()); - eprintln!("{name:<28} {label:>5} {size:>10} {cpu_cold:>9.2} {gpu_cold:>9.2} {cpu_warm:>9.2} {gpu_warm:>9.2} {view:>9.2}"); + // Agreement: share of pixels where the GPU frame differs from the CPU reference by + // more than 1/255 on any channel. + let (a, b) = (mk(Backend::Cpu, Some(&cache)), mk(Backend::Gpu, Some(&gcache))); + let off = a.data.iter().zip(&b.data).filter(|(p, q)| (0..4).any(|c| (p[c] - q[c]).abs() > 1.0 / 255.0 + 1e-6)).count(); + let pct = 100.0 * off as f64 / a.data.len().max(1) as f64; + let speedup = cpu_warm / gpu_warm.max(1e-9); + eprintln!( + "{name:<28} {label:>5} {size:>10} {cpu_cold:>9.2} {gpu_cold:>9.2} {cpu_warm:>9.2} {gpu_warm:>9.2} {view:>9.2} {speedup:>7.2}x {pct:>8.3}%" + ); } } Ok(()) diff --git a/crates/gpu/src/classic3d.rs b/crates/gpu/src/classic3d.rs new file mode 100644 index 00000000..0d141c92 --- /dev/null +++ b/crates/gpu/src/classic3d.rs @@ -0,0 +1,244 @@ +//! Classic 3D runs on the GPU (`classic3d.wgsl`): the CPU prepares the planes +//! ([`Renderer::prepare_3d_run`](effectcraft_render::Renderer::prepare_3d_run): layer buffers, +//! homographies, materials, lights, track mattes), the GPU blurs depth of field, packs the +//! buffers into one atlas texture and runs the per-pixel depth sort, lighting, ray-cast shadows +//! and blending of `render::three_d::compose` in one kernel. + +use std::collections::HashMap; + +use effectcraft_geom::Mat4; +use effectcraft_project::LightKind; +use effectcraft_render::three_d::{LightState, PlaneGeo, Run3d}; + +use crate::context::{Enc, GpuImage, Params}; +use crate::ops::mode_id; + +/// Fragments kept per pixel (`MAX_FRAGS` in the shader). Runs that could stack more planes on +/// one pixel render on the CPU. +const MAX_FRAGS: usize = 32; + +/// Little-endian words for the shader's storage structs. +#[derive(Default)] +struct Words(Vec); + +impl Words { + fn f(&mut self, v: [f32; 4]) { + for x in v { + self.0.extend_from_slice(&x.to_le_bytes()); + } + } + fn u(&mut self, v: [u32; 4]) { + for x in v { + self.0.extend_from_slice(&x.to_le_bytes()); + } + } + fn d(&mut self, v: [f64; 4]) { + self.f(v.map(|x| x as f32)); + } + /// Pad an empty array to one element (`size` bytes) so its binding is valid. + fn at_least(mut self, size: usize) -> Vec { + if self.0.len() < size { + self.0.resize(size, 0); + } + self.0 + } +} + +fn affine_rows(w: &mut Words, m: &Mat4) { + for r in 0..3 { + w.d(m.0[r]); + } +} + +/// Shelf-pack rectangles into an atlas no larger than `max` on a side: (width, height, +/// positions). +fn pack(sizes: &[(u32, u32)], max: u32) -> Option<(u32, u32, Vec<(u32, u32)>)> { + let maxw = sizes.iter().map(|s| s.0).max()?; + if maxw > max { + return None; + } + let area: f64 = sizes.iter().map(|s| s.0 as f64 * s.1 as f64).sum(); + let limit = maxw.max((area.sqrt() * 1.25).ceil() as u32).min(max); + let mut order: Vec = (0..sizes.len()).collect(); + order.sort_by_key(|&i| std::cmp::Reverse(sizes[i].1)); + let mut pos = vec![(0, 0); sizes.len()]; + let (mut x, mut y, mut shelf, mut width) = (0u32, 0u32, 0u32, 0u32); + for i in order { + let (w, h) = sizes[i]; + if x + w > limit { + y += shelf; + x = 0; + shelf = 0; + } + pos[i] = (x, y); + x += w; + shelf = shelf.max(h); + width = width.max(x); + } + let height = y + shelf; + (height <= max).then_some((width.max(1), height.max(1), pos)) +} + +/// Upper bound of the planes drawn on any one pixel: a pixel covered by a set of planes lies in +/// each one's box, so every plane of the set overlaps the box of any member. +fn max_overlap(boxes: &[[i64; 4]]) -> usize { + boxes.iter().map(|a| boxes.iter().filter(|b| a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3]).count()).max().unwrap_or(0) +} + +fn light_kind(k: LightKind) -> u32 { + match k { + LightKind::Ambient | LightKind::Environment => 0, + LightKind::Parallel => 1, + LightKind::Point => 2, + LightKind::Spot => 3, + } +} + +fn write_geo(w: &mut Words, g: &PlaneGeo) { + // The homography is defined up to scale: normalise it (by a positive factor, keeping the + // sign tests) into f32's comfortable range. + let m = g.hinv.0.iter().flatten().fold(0.0f64, |a, v| a.max(v.abs())); + let k = if m > 0.0 { 1.0 / m } else { 1.0 }; + for r in 0..3 { + w.d([g.hinv.0[r][0] * k, g.hinv.0[r][1] * k, g.hinv.0[r][2] * k, 0.0]); + } + w.d([g.depth[0], g.depth[1], g.depth[2], 0.0]); + affine_rows(w, &g.world); + w.d([g.normal.x, g.normal.y, g.normal.z, 0.0]); + w.d([g.cam.eye.x, g.cam.eye.y, g.cam.eye.z, if g.cam.ortho { 1.0 } else { 0.0 }]); + let f = g.cam.forward(); + w.d([f.x, f.y, f.z, 0.0]); +} + +fn write_light(w: &mut Words, l: &LightState) { + w.u([light_kind(l.kind), l.falloff, l.casts_shadows as u32, 0]); + w.d([l.pos.x, l.pos.y, l.pos.z, 0.0]); + w.d([l.dir.x, l.dir.y, l.dir.z, 0.0]); + w.f([l.color[0], l.color[1], l.color[2], 0.0]); + w.d([l.cone_half, l.feather, l.radius, l.falloff_distance]); + w.d([l.shadow_darkness, l.shadow_diffusion, 0.0, 0.0]); +} + +const PLANE_BYTES: usize = 12 * 16; +const GEO_BYTES: usize = 10 * 16; +const LIGHT_BYTES: usize = 6 * 16; + +/// Composite a prepared Classic 3D run over `canvas`. `None` when it cannot run here (the +/// caller draws it on the CPU). +pub(crate) fn draw_run(e: &mut Enc, run: &Run3d, canvas: &GpuImage) -> Option { + let (w, h) = (canvas.width, canvas.height); + let boxes: Vec<[i64; 4]> = run.planes.iter().filter(|p| p.draw).map(|p| p.bbox).collect(); + if boxes.is_empty() { + return Some(canvas.clone()); + } + if max_overlap(&boxes) > MAX_FRAGS + || run.planes.iter().any(|p| p.winv.0[3] != [0.0, 0.0, 0.0, 1.0] || p.geos.iter().any(|g| g.world.0[3] != [0.0, 0.0, 0.0, 1.0])) + { + return None; + } + // Plane textures: the (cached) layer upload, depth-of-field blurred when asked. + let mut texs: Vec<(GpuImage, [f64; 2])> = Vec::with_capacity(run.planes.len()); + for p in &run.planes { + let mut img = e.g.upload_buf(&p.buf)?; + let mut off = p.buf.offset; + if let Some((sigma, pad)) = p.dof { + let (pw, ph) = (img.width + 2 * pad, img.height + 2 * pad); + if !e.g.fits(pw, ph) { + return None; + } + let padded = e.image(pw, ph); + e.copy_into(&img, &padded, pad, pad); + img = crate::effects::gaussian_blur(e, &padded, sigma, sigma, false); + off = [off[0] + pad as f64, off[1] + pad as f64]; + } + texs.push((img, off)); + } + let sizes: Vec<(u32, u32)> = texs.iter().map(|(t, _)| (t.width, t.height)).collect(); + let (aw, ah, pos) = pack(&sizes, e.g.max_dim)?; + if (aw as u64) * (ah as u64) * 16 > 2 << 30 { + return None; + } + let atlas = e.image(aw, ah); + for ((t, _), &(x, y)) in texs.iter().zip(&pos) { + e.copy_into(t, &atlas, x, y); + } + // Track mattes (shared by the planes of one layer). + let mut mattes: Vec = vec![]; + let mut matte_ix: HashMap = HashMap::new(); + let n_px = w as usize * h as usize; + let mut planes = Words::default(); + let mut geos = Words::default(); + let mut n_geos = 0u32; + for ((p, (t, off)), &(ax, ay)) in run.planes.iter().zip(&texs).zip(&pos) { + let matte = match &p.matte { + Some(m) if m.len() == n_px => { + let key = std::sync::Arc::as_ptr(m) as usize; + let next = matte_ix.len() as u32 + 1; + let ix = *matte_ix.entry(key).or_insert(next); + if ix == next { + mattes.extend_from_slice(m); + } + ix + } + Some(_) => return None, + None => 0, + }; + planes.f([ax as f32, ay as f32, t.width as f32, t.height as f32]); + planes.d([off[0], off[1], p.buf.scale, if p.bicubic { 1.0 } else { 0.0 }]); + planes.d(p.bbox.map(|v| v as f64)); + planes.d(p.bounds); + affine_rows(&mut planes, &p.winv); + planes.f([p.mat.ambient, p.mat.diffuse, p.mat.specular, p.mat.exponent() as f32]); + planes.f([p.mat.metal, p.mat.light_transmission, p.opacity, 0.0]); + let flags = p.draw as u32 | (p.preserve as u32) << 1 | (p.mat.accepts_lights as u32) << 2; + planes.u([n_geos, p.geos.len() as u32, flags, mode_id(p.mode)]); + planes.u([p.mat.casts_shadows, p.mat.accepts_shadows, p.layer_id, p.order as u32]); + planes.u([matte, run.seed ^ p.layer_id, 0, 0]); + for g in &p.geos { + write_geo(&mut geos, g); + } + n_geos += p.geos.len() as u32; + } + if (mattes.len() * 4) as u64 > e.g.device.limits().max_storage_buffer_binding_size as u64 { + return None; + } + let mut lights = Words::default(); + for l in &run.lights { + write_light(&mut lights, l); + } + let mut casters = Words::default(); + for c in &run.casters { + casters.0.extend_from_slice(&(*c as u32).to_le_bytes()); + } + let pb = e.bytes(planes.at_least(PLANE_BYTES)); + let gb = e.bytes(geos.at_least(GEO_BYTES)); + let lb = e.bytes(lights.at_least(LIGHT_BYTES)); + let cb = e.bytes(casters.at_least(16)); + let mb = e.data(&mattes); + let mut p = Params::default(); + p.u[0] = [run.planes.len() as u32, run.lights.len() as u32, run.casters.len() as u32, 0]; + let out = e.image(w, h); + e.dispatch_ext("classic3d", &p, &atlas, Some(canvas), &out, Some(&mb), (w.div_ceil(16), h.div_ceil(16)), Some([&pb, &gb, &lb, &cb])); + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shelf_packing_fits_and_does_not_overlap() { + let sizes = [(100, 40), (30, 90), (60, 60), (100, 10), (5, 5)]; + let (w, h, pos) = pack(&sizes, 256).unwrap(); + for (i, (&(x, y), &(sw, sh))) in pos.iter().zip(&sizes).enumerate() { + assert!(x + sw <= w && y + sh <= h); + for (j, (&(x2, y2), &(sw2, sh2))) in pos.iter().zip(&sizes).enumerate() { + if i != j { + assert!(x + sw <= x2 || x2 + sw2 <= x || y + sh <= y2 || y2 + sh2 <= y, "{i} and {j} overlap"); + } + } + } + assert!(pack(&[(300, 10)], 256).is_none()); + assert_eq!(max_overlap(&[[0, 0, 10, 10], [5, 5, 15, 15], [20, 20, 30, 30]]), 2); + } +} diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 46868104..d549149d 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -28,6 +28,10 @@ const ENTRIES: &[&str] = &[ "pointwise", ]; +/// Entry points that also bind group 1 (four read-only storage buffers; see +/// [`Enc::dispatch_ext`]). +const EXT_ENTRIES: &[&str] = &["classic3d"]; + /// Pixel format of every working texture: premultiplied RGBA, 32-bit float (32 bpc headroom; /// 8/16 bpc are emulated by clamping and quantising, as on the CPU). pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; @@ -88,6 +92,7 @@ pub struct GpuContext { pub(crate) name: String, pub(crate) max_dim: u32, bgl: wgpu::BindGroupLayout, + bgl_ext: wgpu::BindGroupLayout, pipelines: HashMap<&'static str, wgpu::ComputePipeline>, display_bgl: wgpu::BindGroupLayout, display: wgpu::ComputePipeline, @@ -135,7 +140,7 @@ impl GpuContext { } } let name = format!("{} ({:?})", info.name, info.backend); - let src = [include_str!("shaders/common.wgsl"), include_str!("shaders/kernels.wgsl")].concat(); + let src = [include_str!("shaders/common.wgsl"), include_str!("shaders/kernels.wgsl"), include_str!("shaders/classic3d.wgsl")].concat(); let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft kernels"), source: wgpu::ShaderSource::Wgsl(src.into()) }); let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { @@ -167,12 +172,29 @@ impl GpuContext { bind_group_layouts: &[Some(&bgl)], immediate_size: 0, }); + let storage = |binding: u32| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None }, + count: None, + }; + let bgl_ext = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("effectcraft kernels (ext)"), + entries: &[storage(0), storage(1), storage(2), storage(3)], + }); + let layout_ext = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("effectcraft kernels (ext)"), + bind_group_layouts: &[Some(&bgl), Some(&bgl_ext)], + immediate_size: 0, + }); let pipelines = ENTRIES .iter() - .map(|e| { + .map(|e| (e, &layout)) + .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) + .map(|(e, layout)| { let p = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { label: Some(e), - layout: Some(&layout), + layout: Some(layout), module: &module, entry_point: Some(e), compilation_options: Default::default(), @@ -221,6 +243,7 @@ impl GpuContext { name, max_dim, bgl, + bgl_ext, pipelines, display_bgl, display, @@ -249,7 +272,7 @@ impl GpuContext { let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() })) .ok()?; - device.on_uncaptured_error(Arc::new(|e| log::error!("wgpu: {e}"))); + device.on_uncaptured_error(Arc::new(|e| eprintln!("wgpu: {e}"))); GpuContext::new(&adapter, device, queue).map_err(|e| log::info!("gpu: {e}")).ok() } @@ -374,6 +397,22 @@ impl<'g> Enc<'g> { out: &GpuImage, data: Option<&wgpu::Buffer>, groups: (u32, u32), + ) { + self.dispatch_ext(entry, p, src, aux, out, data, groups, None); + } + + /// [`Enc::dispatch`] for the kernels in `EXT_ENTRIES`, with their group 1 storage buffers. + #[allow(clippy::too_many_arguments)] + pub fn dispatch_ext( + &mut self, + entry: &str, + p: &Params, + src: &GpuImage, + aux: Option<&GpuImage>, + out: &GpuImage, + data: Option<&wgpu::Buffer>, + groups: (u32, u32), + ext: Option<[&wgpu::Buffer; 4]>, ) { let g = self.g; let Some(pipe) = g.pipelines.get(entry) else { @@ -395,11 +434,26 @@ impl<'g> Enc<'g> { wgpu::BindGroupEntry { binding: 4, resource: data.unwrap_or(&g.dummy_buf).as_entire_binding() }, ], }); + let bg_ext = ext.map(|b| { + g.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &g.bgl_ext, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: b[0].as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: b[1].as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: b[2].as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: b[3].as_entire_binding() }, + ], + }) + }); let enc = self.encoder(); { let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(entry), timestamp_writes: None }); pass.set_pipeline(pipe); pass.set_bind_group(0, &bg, &[]); + if let Some(b) = &bg_ext { + pass.set_bind_group(1, b, &[]); + } pass.dispatch_workgroups(groups.0.max(1), groups.1.max(1), 1); } self.pending += 1; @@ -525,7 +579,12 @@ impl<'g> Enc<'g> { for x in v { bytes.extend_from_slice(&x.to_le_bytes()); } - while bytes.len() < 16 { + self.bytes(bytes) + } + + /// A read-only storage buffer holding `bytes` (padded to 16 bytes). + pub fn bytes(&self, mut bytes: Vec) -> wgpu::Buffer { + while bytes.len() < 16 || bytes.len() % 4 != 0 { bytes.push(0); } self.g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("data"), contents: &bytes, usage: wgpu::BufferUsages::STORAGE }) diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index fdc317b3..b1cc308f 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -111,7 +111,7 @@ fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize], repeat: b } /// raster::gaussian_blur (3 box passes per axis). -fn gaussian_blur(e: &mut Enc, img: &GpuImage, sx: f64, sy: f64, repeat: bool) -> GpuImage { +pub(crate) fn gaussian_blur(e: &mut Enc, img: &GpuImage, sx: f64, sy: f64, repeat: bool) -> GpuImage { let rx = if sx > 0.05 { box_radii(sx, 3) } else { vec![] }; let ry = if sy > 0.05 { box_radii(sy, 3) } else { vec![] }; box_passes(e, img, &rx, &ry, repeat) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 43474e9d..7a2634b3 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -22,6 +22,7 @@ //! egui-wgpu to draw. mod adv3d; +mod classic3d; mod context; mod effects; mod ops; @@ -140,4 +141,6 @@ impl Accelerator for Gpu { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_3d; +#[cfg(test)] mod tests_adv3d; diff --git a/crates/gpu/src/shaders/classic3d.wgsl b/crates/gpu/src/shaders/classic3d.wgsl new file mode 100644 index 00000000..4e8d2139 --- /dev/null +++ b/crates/gpu/src/shaders/classic3d.wgsl @@ -0,0 +1,460 @@ +// Classic 3D on the GPU: the per-pixel compositor of `render::three_d::compose` (draw_run). +// +// Every plane covering an output pixel is inverse-mapped through its homography (one per +// motion-blur sub-sample), sampled from the plane atlas (`src`), shaded by the comp's lights +// (Blinn-Phong, ray-cast shadows against the caster planes) and multiplied by its track matte; +// the pixel's fragments are insertion-sorted far → near (coplanar fragments in stack order) and +// blended over the canvas (`aux`) with each layer's mode and opacity. +// +// Bindings: group 0 as every kernel (P, src = atlas, aux = canvas, out, data = track mattes); +// group 1 = planes, geometries, lights, caster indices. +// P.u[0] = (planes, lights, casters, _) + +struct Plane { + // Atlas rectangle (x, y, w, h) in texels. + atlas: vec4, + // Buffer offset (x, y), scale, bicubic (0 / 1). + tex: vec4, + // Output pixel bounds [x0, y0, x1, y1). + bbox: vec4, + // Layer-space bounds of the buffer. + bounds: vec4, + // World → layer (affine rows). + winv0: vec4, + winv1: vec4, + winv2: vec4, + // Ambient, diffuse, specular, Blinn-Phong exponent. + mat0: vec4, + // Metal, light transmission, opacity. + mat1: vec4, + // First geometry, geometry count, flags (1 draw, 2 preserve transparency, 4 accepts + // lights), blend mode. + ints: vec4, + // Casts shadows, accepts shadows (0 off, 1 on, 2 only), layer id, stack order. + ints2: vec4, + // Track matte (index + 1; 0 = none), dissolve seed. + ints3: vec4, +} + +struct Geo { + // Output pixel → homogeneous layer coordinates (rows). + h0: vec4, + h1: vec4, + h2: vec4, + // Camera depth = d.x·u + d.y·v + d.z. + depth: vec4, + // Layer → world (affine rows). + w0: vec4, + w1: vec4, + w2: vec4, + // Unit world normal. + normal: vec4, + // Camera eye (xyz) and orthographic flag (w). + eye: vec4, + // Camera forward (xyz). + fwd: vec4, +} + +struct Light { + // Kind (0 ambient, 1 parallel, 2 point, 3 spot), falloff, casts shadows. + ints: vec4, + pos: vec4, + dir: vec4, + color: vec4, + // Cone half-angle, feather, radius, falloff distance. + cone: vec4, + // Shadow darkness, shadow diffusion. + shadow: vec4, +} + +@group(1) @binding(0) var planes: array; +@group(1) @binding(1) var geos: array; +@group(1) @binding(2) var lights: array; +@group(1) @binding(3) var casters: array; + +const NEAR3: f32 = 1.0; +const MAX_FRAGS: u32 = 32u; + +fn atlas_get(i: u32, x: i32, y: i32) -> vec4 { + let r = planes[i].atlas; + if (x < 0 || y < 0 || x >= i32(r.z) || y >= i32(r.w)) { + return vec4(0.0); + } + return textureLoad(src, vec2(i32(r.x) + x, i32(r.y) + y), 0); +} + +// Image::sample_bilinear / sample_bicubic on a plane's buffer at layer coordinates (u, v). +fn texel(i: u32, u: f32, v: f32) -> vec4 { + let t = planes[i].tex; + let x = u * t.z + t.x; + let y = v * t.z + t.y; + let fx = x - 0.5; + let fy = y - 0.5; + let x0 = floor(fx); + let y0 = floor(fy); + let xi = i32(x0); + let yi = i32(y0); + if (t.w > 0.5) { + let wx = cubic_w(fx - x0); + let wy = cubic_w(fy - y0); + var o = vec4(0.0); + for (var j = 0; j < 4; j++) { + for (var k = 0; k < 4; k++) { + o += atlas_get(i, xi - 1 + k, yi - 1 + j) * (wx[k] * wy[j]); + } + } + return max(o, vec4(0.0)); + } + let tx = fx - x0; + let ty = fy - y0; + let a = atlas_get(i, xi, yi); + let b = atlas_get(i, xi + 1, yi); + let c = atlas_get(i, xi, yi + 1); + let d = atlas_get(i, xi + 1, yi + 1); + let top = a + (b - a) * tx; + let bot = c + (d - c) * tx; + return top + (bot - top) * ty; +} + +fn affine(r0: vec4, r1: vec4, r2: vec4, p: vec3) -> vec3 { + return vec3(dot(r0.xyz, p) + r0.w, dot(r1.xyz, p) + r1.w, dot(r2.xyz, p) + r2.w); +} + +fn linear3(r0: vec4, r1: vec4, r2: vec4, v: vec3) -> vec3 { + return vec3(dot(r0.xyz, v), dot(r1.xyz, v), dot(r2.xyz, v)); +} + +// compose::unproject: (u, v, depth, ok). +fn unproject(g: u32, sx: f32, sy: f32) -> vec4 { + let gm = geos[g]; + let ortho = gm.eye.w > 0.5; + let qz = gm.h2.x * sx + gm.h2.y * sy + gm.h2.z; + if ((!ortho && qz <= 0.0) || qz == 0.0) { + return vec4(0.0); + } + let u = (gm.h0.x * sx + gm.h0.y * sy + gm.h0.z) / qz; + let v = (gm.h1.x * sx + gm.h1.y * sy + gm.h1.z) / qz; + let z = gm.depth.x * u + gm.depth.y * v + gm.depth.z; + if (!ortho && z < NEAR3) { + return vec4(0.0); + } + return vec4(u, v, z, 1.0); +} + +// LightState::to_light: unit vector towards the light (xyz) and the distance (w; -1 = ∞). +fn to_light(l: u32, p: vec3) -> vec4 { + let li = lights[l]; + if (li.ints.x == 1u) { + return vec4(-li.dir.xyz, -1.0); + } + let d = li.pos.xyz - p; + let len = length(d); + if (len < 1e-9) { + return vec4(0.0, 0.0, -1.0, 0.0); + } + return vec4(d / len, len); +} + +fn smoothstep01(t: f32) -> f32 { + return 1.0 - t * t * (3.0 - 2.0 * t); +} + +// LightState::attenuation. +fn attenuation(l: u32, p: vec3) -> f32 { + let li = lights[l]; + let tl = to_light(l, p); + let dist = tl.w; + let finite = dist >= 0.0; + let radius = li.cone.z; + let fd = li.cone.w; + var k = 1.0; + if (li.ints.y == 1u && finite) { + if (dist <= radius) { + k = 1.0; + } else if (fd <= 0.0) { + k = 0.0; + } else { + k = smoothstep01(clamp((dist - radius) / fd, 0.0, 1.0)); + } + } else if (li.ints.y == 2u && finite) { + if (dist <= radius || (radius <= 0.0 && dist <= 1.0)) { + k = 1.0; + } else { + let q = max(radius, 1.0) / dist; + k = q * q; + } + } + if (li.ints.x == 3u) { + let cos_a = clamp(dot(-tl.xyz, li.dir.xyz), -1.0, 1.0); + let a = acos(cos_a); + let outer = li.cone.x; + let inner = outer * (1.0 - li.cone.y); + if (a <= inner) { + } else if (a >= outer) { + k = 0.0; + } else { + k *= smoothstep01((a - inner) / max(outer - inner, 1e-9)); + } + } + return k; +} + +// compose::occlusion: RGB transmittance of light `l` reaching `p` past caster `c`. +fn occlusion(c: u32, p: vec3, l: u32) -> vec3 { + let pl = planes[c]; + let li = lights[l]; + let a = affine(pl.winv0, pl.winv1, pl.winv2, p); + var hit: vec3; + var d_pc: f32; + var d_cl: f32; + if (li.ints.x == 1u) { + // Towards the light, 1e6 away (in direction form: exact in f32). + let dl = linear3(pl.winv0, pl.winv1, pl.winv2, -li.dir.xyz); + if (dl.z == 0.0) { + return vec3(1.0); + } + let s = -a.z / dl.z; + if (s <= 1.0 || s >= 1.0e6) { + return vec3(1.0); + } + hit = a + dl * s; + d_pc = length(li.dir.xyz) * s; + d_cl = 1000.0; + } else { + let b = affine(pl.winv0, pl.winv1, pl.winv2, li.pos.xyz); + if (a.z == b.z || (a.z < 0.0) == (b.z < 0.0)) { + return vec3(1.0); + } + let t = a.z / (a.z - b.z); + if (t <= 1e-6 || t >= 1.0) { + return vec3(1.0); + } + hit = a + (b - a) * t; + let seg = length(li.pos.xyz - p); + d_pc = seg * t; + d_cl = max(seg * (1.0 - t), 1.0); + } + let r = li.shadow.y * min(d_pc / d_cl, 4.0); + let bd = pl.bounds; + if (hit.x < bd.x - r || hit.y < bd.y - r || hit.x > bd.z + r || hit.y > bd.w + r) { + return vec3(1.0); + } + var px = vec4(0.0); + if (r * pl.tex.z < 0.5) { + px = texel(c, hit.x, hit.y); + } else { + for (var j = -1; j <= 1; j++) { + for (var i = -1; i <= 1; i++) { + px += texel(c, hit.x + f32(i) * r * 0.66, hit.y + f32(j) * r * 0.66) / 9.0; + } + } + } + let alpha = clamp(px.w * pl.mat1.z, 0.0, 1.0); + if (alpha <= 0.0) { + return vec3(1.0); + } + let tr = pl.mat1.y; + let dark = li.shadow.x; + var o = vec3(1.0); + for (var k = 0; k < 3; k++) { + var st = 0.0; + if (px.w > 0.0) { + st = clamp(px[k] / px.w, 0.0, 1.0); + } + let through = (1.0 - alpha) + alpha * tr * st; + o[k] = 1.0 - dark * (1.0 - through); + } + return o; +} + +fn shadow_of(it: u32, l: u32, p: vec3) -> vec3 { + var tr = vec3(1.0); + let n = P.u[0].z; + let lid = planes[it].ints2.z; + for (var k = 0u; k < n; k++) { + let c = casters[k]; + if (c == it || planes[c].ints2.z == lid) { + continue; + } + tr *= occlusion(c, p, l); + } + return tr; +} + +// compose::shade_texel (light::shade). +fn shade_texel(it: u32, g: u32, u: f32, v: f32, t: vec4) -> vec4 { + let pl = planes[it]; + let n_lights = P.u[0].y; + let accepts_shadows = pl.ints2.y; + if (n_lights == 0u || (pl.ints.z & 4u) == 0u) { + if (accepts_shadows == 2u) { + return vec4(0.0); + } + return t; + } + let gm = geos[g]; + let a = t.w; + let rgb = t.xyz / a; + let p = affine(gm.w0, gm.w1, gm.w2, vec3(u, v, 0.0)); + var to_viewer: vec3; + if (gm.eye.w > 0.5) { + to_viewer = -gm.fwd.xyz; + } else { + to_viewer = normalize(gm.eye.xyz - p); + } + var n = gm.normal.xyz; + if (dot(n, to_viewer) < 0.0) { + n = -n; + } + var amb = vec3(0.0); + var diff = vec3(0.0); + var spec = vec3(0.0); + var shadow_amt = 0.0; + let ex = pl.mat0.w; + let metal = pl.mat1.x; + let transmission = pl.mat1.y; + let specular = pl.mat0.z; + for (var i = 0u; i < n_lights; i++) { + let li = lights[i]; + if (li.ints.x == 0u) { + amb += li.color.xyz; + continue; + } + let k = attenuation(i, p); + if (k <= 0.0) { + continue; + } + let lv = to_light(i, p).xyz; + let ndl = dot(n, lv); + let front = ndl > 0.0; + var lambert = ndl; + if (!front) { + lambert = -ndl * transmission; + } + if (lambert <= 0.0) { + continue; + } + var sh = vec3(1.0); + if (li.ints.z != 0u && accepts_shadows != 0u) { + sh = shadow_of(it, i, p); + } + shadow_amt = max(shadow_amt, 1.0 - (sh.x + sh.y + sh.z) / 3.0); + diff += li.color.xyz * (lambert * k) * sh; + if (front && specular > 0.0) { + let h = normalize(lv + to_viewer); + let s = powz(max(dot(n, h), 0.0), ex) * k; + let tint = vec3(1.0) + (rgb - vec3(1.0)) * metal; + spec += li.color.xyz * s * sh * tint; + } + } + let lit = rgb * (pl.mat0.x * amb + pl.mat0.y * diff) + specular * spec; + if (accepts_shadows == 2u) { + return vec4(0.0, 0.0, 0.0, a * shadow_amt); + } + return vec4(lit * a, a); +} + +// compose::before: a draws before b (farther; coplanar → lower stack order first). +fn before(za: f32, oa: u32, zb: f32, ob: u32) -> bool { + let tol = 1e-5 * max(max(abs(za), abs(zb)), 1.0); + if (abs(za - zb) <= tol) { + return oa < ob; + } + return za > zb; +} + +@compute @workgroup_size(16, 16) +fn classic3d(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let x = i32(gid.x); + let y = i32(gid.y); + if (x >= dims.x || y >= dims.y) { + return; + } + let pix = vec2(x, y); + var d = textureLoad(aux, pix, 0); + let fx = f32(x); + let fy = f32(y); + let sx = fx + 0.5; + let sy = fy + 0.5; + var fz: array; + var fo: array; + var fi: array; + var fp: array, MAX_FRAGS>; + var nf = 0u; + let n_planes = P.u[0].x; + for (var i = 0u; i < n_planes; i++) { + let pl = planes[i]; + if ((pl.ints.z & 1u) == 0u) { + continue; + } + if (fx < pl.bbox.x || fx >= pl.bbox.z || fy < pl.bbox.y || fy >= pl.bbox.w) { + continue; + } + // compose::fragment: average of the motion-blur sub-samples. + let g0 = pl.ints.x; + let ng = pl.ints.y; + var acc = vec4(0.0); + var depth = 0.0; + var hit = false; + for (var k = 0u; k < ng; k++) { + let q = unproject(g0 + k, sx, sy); + if (q.w == 0.0) { + continue; + } + let t = texel(i, q.x, q.y); + if (t.w <= 1e-6) { + continue; + } + if (!hit) { + depth = q.z; + hit = true; + } + acc += shade_texel(i, g0 + k, q.x, q.y, t); + } + if (!hit) { + continue; + } + if (ng > 1u) { + acc *= 1.0 / f32(ng); + } + let m = pl.ints3.x; + if (m != 0u) { + let f = data[(m - 1u) * u32(dims.x) * u32(dims.y) + u32(y) * u32(dims.x) + u32(x)]; + acc *= f; + } + if (!(acc.w > 0.0 || acc.x != 0.0 || acc.y != 0.0 || acc.z != 0.0) || nf >= MAX_FRAGS) { + continue; + } + // Insertion: far → near, coplanar in stack order (stable, as the CPU's insertion sort). + let order = pl.ints2.w; + var j = nf; + while (j > 0u && before(depth, order, fz[j - 1u], fo[j - 1u])) { + fz[j] = fz[j - 1u]; + fo[j] = fo[j - 1u]; + fi[j] = fi[j - 1u]; + fp[j] = fp[j - 1u]; + j--; + } + fz[j] = depth; + fo[j] = order; + fi[j] = i; + fp[j] = acc; + nf++; + } + for (var k = 0u; k < nf; k++) { + let pl = planes[fi[k]]; + var op = pl.mat1.z; + if ((pl.ints.z & 2u) != 0u) { + op *= d.w; + } + let s = fp[k] * op; + let mode = pl.ints.w; + var n = 0.5; + if (mode == 1u || mode == 2u) { + n = hash_noise(u32(x), u32(y), pl.ints3.y); + } + d = blend_pixel(mode, d, s, n); + } + textureStore(out, pix, d); +} diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index c6fd84f0..137fdec3 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -26,7 +26,7 @@ use effectcraft_time::{FrameRate, Tick}; use crate::Gpu; -fn gpu() -> Option<&'static Gpu> { +pub(crate) fn gpu() -> Option<&'static Gpu> { static G: OnceLock> = OnceLock::new(); G.get_or_init(|| { let g = Gpu::headless(); @@ -40,9 +40,9 @@ fn gpu() -> Option<&'static Gpu> { /// Procedural footage: smooth colour gradients, fine noise and an alpha with soft, opaque and /// fully transparent regions (different per item). -struct Pattern; +pub(crate) struct Pattern; -fn pattern(seed: u32, w: u32, h: u32) -> Image { +pub(crate) fn pattern(seed: u32, w: u32, h: u32) -> Image { let mut img = Image::new(w, h); let k = seed as f32 * 0.37; for y in 0..h { @@ -66,14 +66,14 @@ impl FootageSource for Pattern { } } -struct Scene { - p: Project, - cid: ItemId, - comp: Comp, +pub(crate) struct Scene { + pub(crate) p: Project, + pub(crate) cid: ItemId, + pub(crate) comp: Comp, } impl Scene { - fn new(depth: BitDepth) -> Scene { + pub(crate) fn new(depth: BitDepth) -> Scene { let mut p = Project::default(); p.settings.bit_depth = depth; let comp = Comp::new(97, 61, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); @@ -81,7 +81,7 @@ impl Scene { Scene { p, cid, comp } } - fn footage(&mut self, w: u32, h: u32) -> Layer { + pub(crate) fn footage(&mut self, w: u32, h: u32) -> Layer { let f = Footage { path: "pattern.png".into(), kind: FootageKind::Still, @@ -106,19 +106,19 @@ impl Scene { build::layer(&mut self.p, &self.comp, "Pattern", LayerSource::Footage { item: fid }, (w, h), None) } - fn solid(&mut self, color: [f32; 3], w: u32, h: u32) -> Layer { + pub(crate) fn solid(&mut self, color: [f32; 3], w: u32, h: u32) -> Layer { let sid = self.p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); build::layer(&mut self.p, &self.comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None) } /// Add on top of the stack. - fn push(&mut self, l: Layer) -> effectcraft_project::LayerId { + pub(crate) fn push(&mut self, l: Layer) -> effectcraft_project::LayerId { let id = l.id; self.p.comp_mut(self.cid).unwrap().layers.insert(0, l); id } - fn effect(&mut self, l: &mut Layer, id: &str, vals: &[(&str, Value)]) { + pub(crate) fn effect(&mut self, l: &mut Layer, id: &str, vals: &[(&str, Value)]) { let spec = effectcraft_effects::find(id).unwrap(); let mut next = self.p.next_id; let size = effectcraft_render::source_size(&self.p, l); @@ -131,27 +131,27 @@ impl Scene { } } -fn set(l: &mut Layer, path: &str, v: Value) { +pub(crate) fn set(l: &mut Layer, path: &str, v: Value) { l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v; } -fn v3(x: f64, y: f64) -> Value { +pub(crate) fn v3(x: f64, y: f64) -> Value { Value::Vec3([x, y, 0.0]) } /// Differences of a GPU render against the CPU render. #[derive(Debug)] -struct Diff { - max: f32, +pub(crate) struct Diff { + pub(crate) max: f32, /// Pixels over the tolerance. - over: usize, - total: usize, - worst: (u32, u32, [f32; 4], [f32; 4]), + pub(crate) over: usize, + pub(crate) total: usize, + pub(crate) worst: (u32, u32, [f32; 4], [f32; 4]), /// 8/16 bpc render. - quantized: bool, + pub(crate) quantized: bool, } -fn diff(cpu: &Image, gpu: &Image, tol: f32) -> Diff { +pub(crate) fn diff(cpu: &Image, gpu: &Image, tol: f32) -> Diff { assert_eq!((cpu.width, cpu.height), (gpu.width, gpu.height), "size"); let mut d = Diff { max: 0.0, over: 0, total: cpu.data.len(), worst: (0, 0, [0.0; 4], [0.0; 4]), quantized: false }; for (i, (a, b)) in cpu.data.iter().zip(&gpu.data).enumerate() { @@ -169,7 +169,7 @@ fn diff(cpu: &Image, gpu: &Image, tol: f32) -> Diff { d } -fn tolerance(depth: BitDepth) -> f32 { +pub(crate) fn tolerance(depth: BitDepth) -> f32 { match depth { BitDepth::Bpc8 => 1.0 / 255.0 + 1e-6, _ => 1e-3, @@ -177,7 +177,7 @@ fn tolerance(depth: BitDepth) -> f32 { } /// Render on the CPU and the GPU; `Some(diff)` (None without an adapter). -fn compare_at(s: &Scene, opts: RenderOpts, t: Tick) -> Option { +pub(crate) fn compare_at(s: &Scene, opts: RenderOpts, t: Tick) -> Option { let g = gpu()?; let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..opts }).comp_frame_cpu(s.cid, t); let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts }); @@ -188,12 +188,12 @@ fn compare_at(s: &Scene, opts: RenderOpts, t: Tick) -> Option { Some(d) } -fn opts() -> RenderOpts { +pub(crate) fn opts() -> RenderOpts { RenderOpts { motion_blur: true, ..Default::default() } } /// Assert agreement; `allow` = fraction of pixels allowed over the tolerance. -fn check(label: &str, d: Option, allow: f64) { +pub(crate) fn check(label: &str, d: Option, allow: f64) { let Some(d) = d else { return }; let frac = d.over as f64 / d.total as f64; // Quantised depths: rounding-boundary flips (see the module docs). @@ -417,11 +417,11 @@ fn effect_case(id: &str, vals: &[(&str, Value)]) { } } -fn n(v: f64) -> Value { +pub(crate) fn n(v: f64) -> Value { Value::Scalar(v) } -fn c(r: f64, g: f64, b: f64) -> Value { +pub(crate) fn c(r: f64, g: f64, b: f64) -> Value { Value::Color([r, g, b, 1.0]) } diff --git a/crates/gpu/src/tests_3d.rs b/crates/gpu/src/tests_3d.rs new file mode 100644 index 00000000..f10368d2 --- /dev/null +++ b/crates/gpu/src/tests_3d.rs @@ -0,0 +1,220 @@ +//! Classic 3D on the GPU vs the CPU compositor: intersecting planes, blend modes, lights, +//! ray-cast shadows, depth of field, motion blur, track mattes, collapsed precomps and +//! adjustment layers splitting a run. Same tolerances as `tests.rs`; pixels on the exact +//! intersection line of two planes may sort differently (f32 vs f64 depths), so intersecting +//! scenes allow 0.5 % of pixels over. + +use effectcraft_color::{BlendMode, Label}; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::build; +use effectcraft_project::{BitDepth, Comp, ItemKind, Layer, LayerSource, LightKind, MatteKind, TrackMatte}; +use effectcraft_render::{Backend, RenderOpts, Renderer}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::tests::{Pattern, Scene, check, compare_at, gpu, n, opts, set}; + +const W: u32 = 160; +const H: u32 = 100; + +fn scene(depth: BitDepth) -> Scene { + let mut s = Scene::new(depth); + let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + *s.p.comp_mut(s.cid).unwrap() = comp.clone(); + s.comp = comp; + s +} + +fn three_d(mut l: Layer, pos: [f64; 3], rot_y: f64) -> Layer { + l.switches.three_d = true; + set(&mut l, "transform/position", Value::Vec3(pos)); + set(&mut l, "transform/rotationY", Value::Scalar(rot_y)); + l +} + +fn camera(s: &mut Scene, pos: [f64; 3], dof: Option<(f64, f64)>) { + let mut cam = build::layer(&mut s.p, &s.comp, "Camera", LayerSource::Camera, (W, H), None); + set(&mut cam, "cameraOptions/zoom", Value::Scalar(W as f64 * 50.0 / 36.0)); + set(&mut cam, "transform/position", Value::Vec3(pos)); + set(&mut cam, "transform/poi", Value::Vec3([W as f64 / 2.0, H as f64 / 2.0, 0.0])); + if let Some((focus, aperture)) = dof { + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(aperture)); + } + s.push(cam); +} + +fn light(s: &mut Scene, kind: LightKind, pos: [f64; 3], vals: &[(&str, Value)]) { + let mut l = build::layer(&mut s.p, &s.comp, "Light", LayerSource::Light { kind }, (W, H), None); + if kind != LightKind::Ambient { + set(&mut l, "transform/position", Value::Vec3(pos)); + } + if l.props.prop_mut("transform/poi").is_some() { + set(&mut l, "transform/poi", Value::Vec3([W as f64 / 2.0, H as f64 / 2.0, 0.0])); + } + for (k, v) in vals { + set(&mut l, &format!("lightOptions/{k}"), v.clone()); + } + s.push(l); +} + +/// Background, a far plane, and two planes crossing each other (one in `mode`). +fn crossing(depth: BitDepth, mode: BlendMode) -> Scene { + let mut s = scene(depth); + let bg = s.solid([0.1, 0.12, 0.2], W, H); + s.push(bg); + let far = three_d(s.footage(150, 90), [80.0, 50.0, 120.0], 0.0); + s.push(far); + let a = three_d(s.footage(90, 70), [80.0, 50.0, 0.0], 35.0); + s.push(a); + let mut b = three_d(s.solid([0.9, 0.5, 0.2], 90, 70), [80.0, 52.0, 0.0], -35.0); + b.blend_mode = mode; + set(&mut b, "transform/opacity", Value::Scalar(80.0)); + s.push(b); + s +} + +#[test] +fn intersecting_planes_and_blend_modes() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for mode in [BlendMode::Normal, BlendMode::Screen, BlendMode::Multiply, BlendMode::Add, BlendMode::Dissolve] { + let mut s = crossing(depth, mode); + check(&format!("crossing {mode:?} {depth:?} (default camera)"), compare_at(&s, opts(), Tick::ZERO), 0.005); + camera(&mut s, [30.0, 20.0, -260.0], None); + check(&format!("crossing {mode:?} {depth:?} (camera)"), compare_at(&s, opts(), Tick::ZERO), 0.005); + } + } +} + +#[test] +fn lights_specular_transmission_and_shadows() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = crossing(depth, BlendMode::Normal); + // Every layer casts shadows; the crossing pair transmits light. + for l in &mut s.p.comp_mut(s.cid).unwrap().layers { + if l.switches.three_d { + set(l, "materialOptions/castsShadows", Value::Enum(1)); + set(l, "materialOptions/specularIntensity", Value::Scalar(60.0)); + set(l, "materialOptions/specularShininess", Value::Scalar(30.0)); + set(l, "materialOptions/lightTransmission", Value::Scalar(40.0)); + } + } + camera(&mut s, [-20.0, -10.0, -250.0], None); + light(&mut s, LightKind::Ambient, [0.0, 0.0, 0.0], &[("intensity", n(25.0))]); + light( + &mut s, + LightKind::Spot, + [20.0, -60.0, -150.0], + &[("castsShadows", Value::Bool(true)), ("shadowDarkness", n(80.0)), ("coneAngle", n(70.0)), ("coneFeather", n(40.0)), ("intensity", n(120.0))], + ); + light( + &mut s, + LightKind::Point, + [150.0, 20.0, -80.0], + &[ + ("castsShadows", Value::Bool(true)), + ("shadowDiffusion", n(6.0)), + ("falloff", Value::Enum(1)), + ("radius", n(120.0)), + ("falloffDistance", n(200.0)), + ], + ); + light(&mut s, LightKind::Parallel, [-100.0, -100.0, -100.0], &[("castsShadows", Value::Bool(true)), ("color", Value::Color([0.5, 0.7, 1.0, 1.0]))]); + check(&format!("lights + shadows {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + // Accepts Shadows: Only, Accepts Lights off, Casts Shadows: Only. + for l in &mut s.p.comp_mut(s.cid).unwrap().layers { + match l.name.as_str() { + "Pattern" => set(l, "materialOptions/acceptsShadows", Value::Enum(2)), + "Solid" if l.switches.three_d => set(l, "materialOptions/acceptsLights", Value::Bool(false)), + _ => {} + } + } + check(&format!("shadow-only materials {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + } +} + +#[test] +fn depth_of_field_and_motion_blur() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = crossing(depth, BlendMode::Normal); + camera(&mut s, [80.0, 50.0, -220.0], Some((220.0, 30.0))); + check(&format!("dof {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + // Animated plane with motion blur (sub-sampled geometry, equal weights). + let mut s = scene(depth); + let mut l = three_d(s.footage(60, 40), [40.0, 50.0, 0.0], 20.0); + l.switches.motion_blur = true; + l.props.prop_mut("transform/position").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([40.0, 50.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([140.0, 40.0, -60.0]))]; + s.push(l); + s.p.comp_mut(s.cid).unwrap().enable_motion_blur = true; + light(&mut s, LightKind::Point, [80.0, 0.0, -100.0], &[]); + check(&format!("3d motion blur {depth:?}"), compare_at(&s, opts(), Tick::from_seconds_f64(0.5)), 0.005); + } +} + +#[test] +fn mattes_preserve_transparency_precomps_and_adjustment_splits() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = crossing(depth, BlendMode::Normal); + // A 2D luma matte for the top plane, preserve transparency on the far plane. + let m = s.solid([0.7, 0.7, 0.7], 70, 50); + let mid = s.push(m); + let layers = &mut s.p.comp_mut(s.cid).unwrap().layers; + layers[1].track_matte = Some(TrackMatte { layer: mid, kind: MatteKind::Luma }); + if let Some(far) = layers.iter_mut().find(|l| l.switches.three_d && l.name == "Pattern") { + far.preserve_transparency = true; + } + // Move the matte below the bottom so the 3D run stays one run. + let ml = layers.remove(0); + layers.push(ml); + layers.last_mut().unwrap().switches.video = false; + check(&format!("matte + preserve {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + + // An adjustment layer in the middle of the run splits it. + let mut s = crossing(depth, BlendMode::Normal); + let mut adj = s.solid([1.0, 1.0, 1.0], W, H); + adj.switches.adjustment = true; + adj.switches.three_d = true; + s.effect(&mut adj, "ec.channel.invert", &[]); + s.p.comp_mut(s.cid).unwrap().layers.insert(1, adj); + check(&format!("adjustment split {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + + // A collapsed 3D precomp: its layers join the parent's 3D space. + let mut s = crossing(depth, BlendMode::Normal); + let inner = Comp::new(80, 60, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let inner_id = s.p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.clone().into())); + let mut a = s.footage(50, 40); + a.switches.three_d = true; + set(&mut a, "transform/rotationX", Value::Scalar(40.0)); + let b = s.solid([0.2, 0.8, 0.4], 30, 20); + s.p.comp_mut(inner_id).unwrap().layers = vec![b, a]; + let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (80, 60), None); + pre.switches.collapse = true; + pre.switches.three_d = true; + set(&mut pre, "transform/position", Value::Vec3([70.0, 45.0, -10.0])); + s.push(pre); + check(&format!("collapsed 3d precomp {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + } +} + +#[test] +fn the_gpu_draws_classic_3d_runs_itself() { + let Some(g) = gpu() else { return }; + let s = crossing(BitDepth::Bpc32, BlendMode::Normal); + let r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + let ctx = r.eval_ctx(s.cid, Tick::ZERO).unwrap(); + let vis = r.visible_layers(&ctx); + let run: Vec<_> = vis.iter().copied().filter(|l| l.is_3d()).collect(); + let prep = r.prepare_3d_run(&ctx, &run, (W, H)).expect("a classic run is prepared for the GPU"); + assert_eq!(prep.planes.iter().filter(|p| p.draw).count(), 3); + let mut e = crate::context::Enc::new(g.context()); + let canvas = e.image(W, H); + let out = crate::classic3d::draw_run(&mut e, &prep, &canvas).expect("the kernel draws the run"); + let img = e.download(&out).unwrap(); + let mut cpu = effectcraft_raster::Image::new(W, H); + r.draw_3d_run(&ctx, &run, &mut cpu); + let covered = cpu.data.iter().filter(|p| p[3] > 0.5).count(); + assert!(covered > (W * H / 3) as usize, "the planes cover the frame ({covered} px)"); + let d = crate::tests::diff(&cpu, &img, 1e-3); + assert!(d.over * 200 <= d.total, "{d:?}"); +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index ea56df7a..a0b51f1f 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -69,7 +69,7 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut while j < visible.len() && visible[j].is_3d() { j += 1; } - on_cpu(e, canvas, |img| r.draw_3d_run(ctx, &visible[i..j], img))?; + draw_3d(e, r, ctx, &visible[i..j], canvas)?; i = j; } else { match r.collapsed(ctx, visible[i]) { @@ -86,6 +86,25 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut Some(()) } +/// A run of consecutive 3D layers: Classic 3D on the GPU (`classic3d`), with 3D adjustment +/// layers splitting the run as on the CPU; Advanced 3D, wireframes and runs the kernel cannot +/// hold draw on the CPU. +fn draw_3d<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], canvas: &mut GpuImage) -> Option<()> { + if let Some(k) = run.iter().position(|l| l.switches.adjustment) { + draw_3d(e, r, ctx, &run[..k], canvas)?; + draw_layer(e, r, ctx, run[k], canvas)?; + return draw_3d(e, r, ctx, &run[k + 1..], canvas); + } + if run.is_empty() { + return Some(()); + } + match r.prepare_3d_run(ctx, run, (canvas.width, canvas.height)).and_then(|prep| crate::classic3d::draw_run(e, &prep, canvas)) { + Some(img) => *canvas = img, + None => on_cpu(e, canvas, |img| r.draw_3d_run(ctx, run, img))?, + } + Some(()) +} + fn draw_collapsed<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut GpuImage) -> Option<()> { let opacity = r.layer_opacity(ctx, layer); if opacity <= 0.0 { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ff71fb57..2e4028d4 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -494,11 +494,8 @@ impl<'a> Renderer<'a> { /// Render a composition at comp time `t` (transparent background, comp size × scale). /// Top-level frames go to the GPU when [`Self::active_accel`] is set and handles them. pub fn comp_frame(&self, comp_id: ItemId, t: Tick) -> Image { - // Auto keeps comps with 3D layers on the CPU compositor: 3D runs are still composited on - // the CPU, and the GPU path's readback/upload around them makes such comps slower. - let auto_3d = self.opts.backend == Backend::Auto && self.project.comp(comp_id).is_some_and(|c| c.has_3d()); + // Classic 3D runs composite on the GPU too (M12.7), so Auto sends 3D comps there as well. if self.depth == 0 - && !auto_3d && let Some(a) = self.active_accel() && let Some(img) = a.comp_frame(self, comp_id, t) { @@ -1347,6 +1344,13 @@ impl<'a> Renderer<'a> { three_d::compose::draw_run(self, ctx, run, canvas); } + /// A run of consecutive 3D layers prepared for an accelerator (planes, lights, shadow + /// casters; see [`three_d::Run3d`]) at output size `out`. `None` = draw it with + /// [`Self::draw_3d_run`] (adjustment or wireframe layers in the run, Advanced 3D). + pub fn prepare_3d_run(&self, ctx: &EvalCtx<'a>, run: &[&Layer], out: (u32, u32)) -> Option { + three_d::compose::gpu_run(self, ctx, run, out) + } + /// Draw one 2D layer into `canvas` on the CPU (any kind: adjustment, wireframe, styled…). pub fn draw_layer_cpu(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) { self.draw_layer(ctx, layer, canvas, false); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 02c7f496..55373714 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -390,7 +390,7 @@ impl crate::Accelerator for ProbeAccel { } #[test] -fn auto_backend_keeps_3d_comps_on_the_cpu() { +fn auto_backend_sends_2d_and_3d_comps_to_the_gpu() { let (mut p, cid, comp) = setup(); p.settings.gpu_acceleration = true; let l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 50, 50); @@ -404,8 +404,11 @@ fn auto_backend_keeps_3d_comps_on_the_cpu() { }; // 2D comp: Auto uses the GPU compositor. assert!(asked(&p, crate::Backend::Auto)); - // A 3D layer: Auto stays on the CPU; an explicit Gpu request still goes to the GPU. + // A 3D layer: Classic 3D runs on the GPU too (M12.7), so Auto still asks it. p.comp_mut(cid).unwrap().layers[0].switches.three_d = true; - assert!(!asked(&p, crate::Backend::Auto)); + assert!(asked(&p, crate::Backend::Auto)); assert!(asked(&p, crate::Backend::Gpu)); + // Software Only: never. + p.settings.gpu_acceleration = false; + assert!(!asked(&p, crate::Backend::Auto)); } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 2647fadd..37fe5911 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -29,16 +29,18 @@ use crate::{EvalCtx, Renderer}; /// One position of a plane (several with motion blur). #[derive(Clone, Copy, Debug)] -pub(crate) struct Geo { +pub struct Geo { /// Output pixel → homogeneous layer coordinates. - hinv: Mat3, + pub hinv: Mat3, /// Camera-space depth as a function of layer (u, v): `d0·u + d1·v + d2`. - depth: [f64; 3], - world: Mat4, - normal: Vec3, + pub depth: [f64; 3], + /// Layer → world. + pub world: Mat4, + /// Unit world normal. + pub normal: Vec3, /// Output pixel bounds [x0, y0, x1, y1) covered by the plane. - bbox: [i64; 4], - cam: CameraState, + pub bbox: [i64; 4], + pub cam: CameraState, } /// A prepared 3D layer. @@ -60,6 +62,9 @@ pub(crate) struct Item<'a> { winv: Mat4, bounds: [f64; 4], bbox: [i64; 4], + /// Depth of field left to an accelerator: Gaussian sigma and padding (buffer pixels) of the + /// blur `buf` still needs (`None` = `buf` is final). + dof: Option<(f64, u32)>, } impl Item<'_> { @@ -72,7 +77,7 @@ impl Item<'_> { } /// Build the plane geometry of a layer for a camera. -fn geo(cam: &CameraState, world: Mat4, buf: &Buf, comp: (f64, f64), scale: f64, out: (u32, u32)) -> Option { +fn geo(cam: &CameraState, world: Mat4, b: [f64; 4], comp: (f64, f64), scale: f64, out: (u32, u32)) -> Option { let proj = Mat4::scale(vec3(scale, scale, 1.0)) * cam.projection(comp.0, comp.1); let full = proj * world; let h = full.plane_to_mat3(); @@ -89,7 +94,6 @@ fn geo(cam: &CameraState, world: Mat4, buf: &Buf, comp: (f64, f64), scale: f64, return None; } let normal = normal.normalize(); - let b = buf_bounds(buf); let corners = [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])]; let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); let mut behind = 0; @@ -170,20 +174,25 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { } /// Prepare a layer (buffer, geometry, material). `None` when it shows nothing. -pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option> { +pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, defer_dof: bool) -> Option> { let buf = r.blend_layer_buf(ctx, layer)?; - prepare_with(r, ctx, layer, order, out, in_run, buf, Mat4::IDENTITY) + prepare_with(r, ctx, layer, order, out, in_run, buf, Mat4::IDENTITY, defer_dof) } /// Prepare a layer as planes: one per character for per-character 3D text, else one. pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec> { + prepare_all_opt(r, ctx, layer, order, out, in_run, false) +} + +/// [`prepare_all`], optionally leaving the depth-of-field blur to an accelerator. +fn prepare_all_opt<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, defer_dof: bool) -> Vec> { if crate::text::per_char_3d(ctx, layer) { return crate::text::per_char_planes(ctx, layer, r.opts.scale) .into_iter() - .filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, r.to_blend(Arc::new(buf), None), m)) + .filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, r.to_blend(Arc::new(buf), None), m, defer_dof)) .collect(); } - prepare(r, ctx, layer, order, out, in_run).into_iter().collect() + prepare(r, ctx, layer, order, out, in_run, defer_dof).into_iter().collect() } /// Prepare a plane showing `buf`, whose space maps into the layer through `local`. @@ -197,6 +206,7 @@ fn prepare_with<'a>( in_run: bool, mut buf: Arc, local: Mat4, + defer_dof: bool, ) -> Option> { if buf.img.is_empty() { return None; @@ -207,6 +217,8 @@ fn prepare_with<'a>( let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world); let world = outer * ctx.world_matrix(layer) * local; // Depth of field: blur the layer by its circle of confusion at its centre. + let mut bounds = buf_bounds(&buf); + let mut deferred = None; if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) { let b = buf_bounds(&buf); let centre = world.apply(vec3((b[0] + b[2]) * 0.5, (b[1] + b[3]) * 0.5, 0.0)); @@ -218,18 +230,27 @@ fn prepare_with<'a>( if radius > 0.3 { let sigma = radius / 1.5; let pad = (sigma * 3.0).ceil() as u32 + 1; - let padded = buf.img.padded(pad); - // The (cached, shared) layer buffer stays untouched; the blurred copy is per frame. - buf = Arc::new(Buf { - img: effectcraft_raster::gaussian_blur(&padded, sigma, sigma, false), - offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], - scale: buf.scale, - }); + if defer_dof { + let s = buf.scale.max(1e-9); + let (w, h) = ((buf.img.width + 2 * pad) as f64, (buf.img.height + 2 * pad) as f64); + let o = [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64]; + bounds = [-o[0] / s, -o[1] / s, (w - o[0]) / s, (h - o[1]) / s]; + deferred = Some((sigma, pad)); + } else { + let padded = buf.img.padded(pad); + // The (cached, shared) layer buffer stays untouched; the blurred copy is per frame. + buf = Arc::new(Buf { + img: effectcraft_raster::gaussian_blur(&padded, sigma, sigma, false), + offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], + scale: buf.scale, + }); + bounds = buf_bounds(&buf); + } } } let n = if in_run { mb_samples(r, ctx, layer) } else { 1 }; let geos: Vec = if n <= 1 { - geo(&cam, world, &buf, comp, s, out).into_iter().collect() + geo(&cam, world, bounds, comp, s, out).into_iter().collect() } else { let fd = ctx.comp.frame_duration().seconds(); let angle = ctx.comp.shutter_angle / 360.0; @@ -239,7 +260,7 @@ fn prepare_with<'a>( let f = phase + angle * i as f64 / (n - 1) as f64; let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)); let c = camera_for(r, &sub); - geo(&c, outer * sub.world_matrix(layer) * local, &buf, comp, s, out) + geo(&c, outer * sub.world_matrix(layer) * local, bounds, comp, s, out) }) .collect() }; @@ -251,7 +272,6 @@ fn prepare_with<'a>( bbox = [bbox[0].min(g.bbox[0]), bbox[1].min(g.bbox[1]), bbox[2].max(g.bbox[2]), bbox[3].max(g.bbox[3])]; } let mat = Material::of(ctx, layer); - let bounds = buf_bounds(&buf); Some(Item { layer, order, @@ -266,6 +286,7 @@ fn prepare_with<'a>( winv: world.inverse().unwrap_or(Mat4::IDENTITY), bounds, bbox, + dof: deferred, }) } @@ -330,6 +351,23 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut return; } let out = (canvas.width, canvas.height); + let g = gather(r, ctx, run, out, false); + let casters: Vec<&Item> = g.items.iter().chain(g.extra.iter()).filter(|i| i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect(); + composite(canvas, &g.items, &g.lights, &casters, run[0].id.0 as u32); + for l in run.iter().filter(|l| l.switches.quality == effectcraft_project::Quality::Wireframe) { + r.draw_layer(ctx, l, canvas, false); + } +} + +/// The prepared planes of a run: drawn items (stack order), shadow casters outside the run, and +/// the lights. +struct Gathered<'a> { + items: Vec>, + extra: Vec>, + lights: Vec, +} + +fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, u32), defer_dof: bool) -> Gathered<'a> { // A collapsed precomp's 3D layers are lit by the outer comp's lights. let lights = scene_lights(&r.collapse3d.map_or(*ctx, |c| c.parent)); let mut items: Vec = run @@ -340,9 +378,9 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut // Stack order with room for a collapsed precomp's layers in between. let i = i * 1024; if let Some(item) = r.collapsed(ctx, l) { - return collapsed_items(r, ctx, l, item, i, out); + return collapsed_items(r, ctx, l, item, i, out, defer_dof); } - let mut its = prepare_all(r, ctx, l, i, out, true); + let mut its = prepare_all_opt(r, ctx, l, i, out, true, defer_dof); if !its.is_empty() { let m = matte_for(r, ctx, l, out); for it in &mut its { @@ -362,15 +400,11 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut .par_iter() .filter(|l| l.switches.three_d && l.has_video() && l.is_active_at(ctx.time) && !in_run.contains(&l.id) && !l.switches.adjustment) .filter(|l| Material::of(ctx, l).casts_shadows != 0) - .flat_map_iter(|l| prepare_all(r, ctx, l, 0, out, false)) + .flat_map_iter(|l| prepare_all_opt(r, ctx, l, 0, out, false, defer_dof)) .collect(); } items.sort_by_key(|i| i.order); - let casters: Vec<&Item> = items.iter().chain(extra.iter()).filter(|i| i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect(); - composite(canvas, &items, &lights, &casters, run[0].id.0 as u32); - for l in run.iter().filter(|l| l.switches.quality == effectcraft_project::Quality::Wireframe) { - r.draw_layer(ctx, l, canvas, false); - } + Gathered { items, extra, lights } } /// A collapsed 3D precomp layer inside a 3D run: its nested layers become planes in this comp's @@ -384,6 +418,7 @@ fn collapsed_items<'a>( item: effectcraft_project::ItemId, order: usize, out: (u32, u32), + defer_dof: bool, ) -> Vec> { let op = ctx.opacity(layer) as f32 * r.opacity_mul(); let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] }; @@ -401,7 +436,7 @@ fn collapsed_items<'a>( if nctx.opacity(l) <= 0.0 { continue; } - let mut its = prepare_all(&sub, &nctx, l, order + 1 + k.min(1022), out, true); + let mut its = prepare_all_opt(&sub, &nctx, l, order + 1 + k.min(1022), out, true, defer_dof); if !its.is_empty() { let m = matte_for(&sub, &nctx, l, out); for it in &mut its { @@ -590,6 +625,86 @@ fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] { out } +// ------------------------------------------------------------------ accelerator export + +/// A Classic 3D run prepared for an accelerator ([`Renderer::prepare_3d_run`]): every plane +/// with its geometry, material and buffer, ready for the per-pixel depth sort, lighting and +/// ray-cast shadows of [`draw_run`] on another device. +#[derive(Clone, Debug)] +pub struct Run3d { + /// Drawn planes in stack order, then shadow casters from outside the run (`draw` false). + pub planes: Vec, + /// Indices (into `planes`) of the shadow casters, in the CPU's order. + pub casters: Vec, + pub lights: Vec, + /// Dissolve seed of the run. + pub seed: u32, +} + +/// One plane of a [`Run3d`]. +#[derive(Clone, Debug)] +pub struct Plane3d { + /// The layer buffer (blending space). When `dof` is set, the plane shows this buffer padded + /// by `dof.1` pixels on every side and Gaussian-blurred with sigma `dof.0` + /// (`raster::gaussian_blur`, no edge repeat). + pub buf: Arc, + pub dof: Option<(f64, u32)>, + pub bicubic: bool, + /// One per motion-blur sub-sample (equal weights). + pub geos: Vec, + pub mat: Material, + pub opacity: f32, + pub draw: bool, + /// Screen-space track matte factor (output size, row-major). + pub matte: Option>>, + pub preserve: bool, + /// World → layer, and the (blurred) buffer's layer-space bounds. + pub winv: Mat4, + pub bounds: [f64; 4], + /// Output pixel bounds of all geometries. + pub bbox: [i64; 4], + /// Stack order (coplanar fragments draw lower orders first). + pub order: usize, + pub layer_id: u32, + pub mode: BlendMode, +} + +/// Prepare a run of 3D layers for an accelerator. `None` when the run must be drawn on the CPU +/// (adjustment or wireframe layers, Advanced 3D). +pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Option { + if run.is_empty() || run.iter().any(|l| l.switches.adjustment || l.switches.quality == effectcraft_project::Quality::Wireframe) { + return None; + } + if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D && r.collapse3d.is_none() { + return None; + } + let g = gather(r, ctx, run, out, true); + let planes: Vec = g + .items + .into_iter() + .chain(g.extra) + .map(|it| Plane3d { + layer_id: it.layer.id.0 as u32, + mode: it.layer.blend_mode, + buf: it.buf, + dof: it.dof, + bicubic: it.bicubic, + geos: it.geos, + mat: it.mat, + opacity: it.opacity, + draw: it.draw, + matte: it.matte, + preserve: it.preserve, + winv: it.winv, + bounds: it.bounds, + bbox: it.bbox, + order: it.order, + }) + .collect(); + let casters = planes.iter().enumerate().filter(|(_, p)| p.mat.casts_shadows != 0 && !p.geos.is_empty()).map(|(i, _)| i).collect(); + Some(Run3d { planes, casters, lights: g.lights, seed: run[0].id.0 as u32 }) +} + // ------------------------------------------------------------------ auxiliary render pass /// One surface seen through an output pixel (aux pass). diff --git a/crates/render/src/three_d/mod.rs b/crates/render/src/three_d/mod.rs index e97c67fe..4bd7dcf4 100644 --- a/crates/render/src/three_d/mod.rs +++ b/crates/render/src/three_d/mod.rs @@ -16,6 +16,7 @@ mod tests; mod tests_orient; pub use camera::{CameraState, Dof, Rig, View3D, ViewCam, Views3D, active_camera, default_camera, default_view_cam}; +pub use compose::{Geo as PlaneGeo, Plane3d, Run3d}; pub use light::{LightState, Material, lights_at}; use effectcraft_geom::Mat3; From 878b47b5edabb21298e9be451bddf71550b7deeb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:23:01 -0400 Subject: [PATCH 2/9] M12.7: adjustment layers on the GPU (effect stacks resident on the device, CPU fallback per non-GPU effect) Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/context.rs | 22 ++++++ crates/gpu/src/effects.rs | 42 +++++++++++ crates/gpu/src/lib.rs | 2 + crates/gpu/src/ops.rs | 11 +++ crates/gpu/src/shaders/kernels.wgsl | 16 +++++ crates/gpu/src/tests_adjust.rs | 105 ++++++++++++++++++++++++++++ crates/gpu/src/walk.rs | 38 +++++++++- crates/render/src/lib.rs | 85 ++++++++++++++++++---- 8 files changed, 306 insertions(+), 15 deletions(-) create mode 100644 crates/gpu/src/tests_adjust.rs diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index d549149d..66bee99d 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -26,6 +26,7 @@ const ENTRIES: &[&str] = &[ "shadow_make", "shadow_combine", "pointwise", + "adjust_mix", ]; /// Entry points that also bind group 1 (four read-only storage buffers; see @@ -582,6 +583,27 @@ impl<'g> Enc<'g> { self.bytes(bytes) } + /// Copy an image into a storage buffer (RGBA f32 rows) for kernels that read a third image + /// through `data`: (buffer, row length in pixels). + pub fn to_buffer(&mut self, img: &GpuImage) -> (wgpu::Buffer, u32) { + let row = (img.width * 16).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let buf = self.g.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("image rows"), + size: row as u64 * img.height as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + self.encoder().copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { texture: &img.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: Some(img.height) }, + }, + wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 }, + ); + (buf, row / 16) + } + /// A read-only storage buffer holding `bytes` (padded to 16 bytes). pub fn bytes(&self, mut bytes: Vec) -> wgpu::Buffer { while bytes.len() < 16 || bytes.len() % 4 != 0 { diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index fdc317b3..36b692f1 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -62,6 +62,48 @@ pub(crate) fn run_chain(e: &mut Enc, chain: &[FxStep], buf: &Buf, levels: Option Some(Buf { img, offset: b.offset, scale: b.scale }) } +/// An effect stack running on a GPU-resident image (adjustment layers): GPU chains stay on +/// the device; CPU effects read the image back, run, and upload the result. +pub(crate) struct GpuFx<'e, 'g> { + e: &'e mut Enc<'g>, + b: Option, + levels: Option, +} + +impl<'e, 'g> GpuFx<'e, 'g> { + pub fn new(e: &'e mut Enc<'g>, img: GpuImage, offset: [f64; 2], scale: f64, levels: Option) -> Self { + GpuFx { e, b: Some(GBuf { img, offset, scale }), levels } + } + + /// The result: (image, offset, scale); `None` when a step failed. + pub fn finish(self) -> Option<(GpuImage, [f64; 2], f64)> { + self.b.map(|b| (b.img, b.offset, b.scale)) + } +} + +impl effectcraft_render::FxTarget for GpuFx<'_, '_> { + fn gpu(&mut self, steps: &[FxStep]) -> bool { + let Some(b) = &self.b else { return false }; + let mut cur = GBuf { img: b.img.clone(), offset: b.offset, scale: b.scale }; + for step in steps { + let Some(next) = apply(self.e, step.spec.id, &step.ctx, cur) else { return false }; + cur = next; + if let Some(l) = self.levels { + cur.img = ops::quantize(self.e, &cur.img, l); + } + } + self.b = Some(cur); + true + } + + fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf) { + let Some(b) = self.b.take() else { return }; + let Some(img) = self.e.download(&b.img) else { return }; + let out = f(Buf { img, offset: b.offset, scale: b.scale }); + self.b = self.e.g.upload_image(&out.img).map(|img| GBuf { img, offset: out.offset, scale: out.scale }); + } +} + fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { match id { "ec.blur.gaussian" => gaussian(e, ctx, b), diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 7a2634b3..a7c9cdc4 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -143,4 +143,6 @@ mod tests; #[cfg(test)] mod tests_3d; #[cfg(test)] +mod tests_adjust; +#[cfg(test)] mod tests_adv3d; diff --git a/crates/gpu/src/ops.rs b/crates/gpu/src/ops.rs index 775e86c1..cd743fbf 100644 --- a/crates/gpu/src/ops.rs +++ b/crates/gpu/src/ops.rs @@ -134,6 +134,17 @@ pub fn channel_mix(e: &mut Enc, canvas: &GpuImage, tmp: &GpuImage, channels: [bo out } +/// Adjustment layer finish: move `canvas` toward `adjusted` by `matte`'s alpha × `opacity`. +pub fn adjust_mix(e: &mut Enc, canvas: &GpuImage, adjusted: &GpuImage, matte: &GpuImage, opacity: f32) -> GpuImage { + let (rows, stride) = e.to_buffer(matte); + let mut p = Params::default(); + p.u[0][0] = stride; + p.f[0][0] = opacity; + let out = e.image(canvas.width, canvas.height); + e.pixels("adjust_mix", &p, canvas, Some(adjusted), &out, Some(&rows)); + out +} + /// Clamp and quantise to `levels` steps (8/16 bpc). pub fn quantize(e: &mut Enc, img: &GpuImage, levels: f32) -> GpuImage { let mut p = Params::default(); diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index c94076a0..443abcf9 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -828,3 +828,19 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { } textureStore(out, p, px); } + +// Adjustment layer finish (Renderer::draw_adjustment): canvas `src` moves toward the adjusted +// comp `aux` by the footprint's alpha × opacity. The footprint is `data` (RGBA f32 rows of +// u[0].x pixels); f[0].x = opacity. +@compute @workgroup_size(16, 16) +fn adjust_mix(@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 c = textureLoad(src, p, 0); + let a = textureLoad(aux, p, 0); + let k = data[(gid.y * P.u[0].x + gid.x) * 4u + 3u] * P.f[0].x; + textureStore(out, p, c + (a - c) * k); +} diff --git a/crates/gpu/src/tests_adjust.rs b/crates/gpu/src/tests_adjust.rs new file mode 100644 index 00000000..ae7f88da --- /dev/null +++ b/crates/gpu/src/tests_adjust.rs @@ -0,0 +1,105 @@ +//! Adjustment layers on the GPU vs the CPU: GPU-only stacks (resident on the device), mixed +//! stacks (CPU effects in between read back and upload), masks, opacity, colour management. + +use effectcraft_keyframe::{ShapePath, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{BitDepth, ColorSpace, MaskMode}; +use effectcraft_render::{Backend, FxStep, FxTarget, RenderOpts, Renderer}; +use effectcraft_time::Tick; + +use crate::tests::{Pattern, Scene, c, check, compare_at, gpu, n, opts, set}; + +fn adjustment_scene(depth: BitDepth, stack: &[(&str, Vec<(&str, Value)>)], masked: bool) -> Scene { + let mut s = Scene::new(depth); + let bg = s.footage(97, 61); + s.push(bg); + let mut l = s.footage(60, 40); + set(&mut l, "transform/rotation", Value::Scalar(15.0)); + s.push(l); + let mut adj = s.solid([1.0, 1.0, 1.0], 80, 50); + adj.switches.adjustment = true; + set(&mut adj, "transform/opacity", Value::Scalar(75.0)); + set(&mut adj, "transform/rotation", Value::Scalar(-6.0)); + for (id, vals) in stack { + s.effect(&mut adj, id, vals); + } + if masked { + let mut next = s.p.next_id; + let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::ellipse([40.0, 25.0], 60.0, 40.0), MaskMode::Add, [255, 255, 0]); + s.p.next_id = next; + adj.props.sub_mut("masks").unwrap().children.push(m.into()); + } + s.push(adj); + let top = s.solid([0.2, 0.6, 0.9], 20, 20); + s.push(top); + s +} + +fn gpu_stack() -> Vec<(&'static str, Vec<(&'static str, Value)>)> { + vec![ + ("ec.blur.gaussian", vec![("blurriness", n(6.0))]), + ("ec.color.levels", vec![("inBlack", n(0.1)), ("gamma", n(1.4))]), + ("ec.color.tint", vec![("black", c(0.1, 0.0, 0.2)), ("amount", n(60.0))]), + ] +} + +#[test] +fn gpu_adjustment_chains_match_the_cpu() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] { + for masked in [false, true] { + let s = adjustment_scene(depth, &gpu_stack(), masked); + check(&format!("gpu adjustment {depth:?} masked {masked}"), compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("gpu adjustment {depth:?} masked {masked} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} + +#[test] +fn mixed_adjustment_chains_fall_back_per_effect() { + let mut stack = gpu_stack(); + stack.insert(1, ("ec.stylize.posterize", vec![])); + stack.push(("ec.stylize.glow", vec![("threshold", n(30.0))])); + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let s = adjustment_scene(depth, &stack, true); + check(&format!("mixed adjustment {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0); + } + // Colour-managed: effects run in the working space, compositing in linear light. + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = adjustment_scene(depth, &stack, false); + s.p.settings.working_space = Some(ColorSpace::Rec709); + s.p.settings.blend_linear = true; + check(&format!("colour-managed adjustment {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0); + } +} + +/// Counts where each run of the stack goes. +#[derive(Default)] +struct Probe { + gpu: usize, + cpu: usize, +} + +impl FxTarget for Probe { + fn gpu(&mut self, steps: &[FxStep]) -> bool { + self.gpu += steps.len(); + true + } + fn cpu(&mut self, _f: &mut dyn FnMut(effectcraft_effects::Buf) -> effectcraft_effects::Buf) { + self.cpu += 1; + } +} + +#[test] +fn adjustment_stacks_split_into_gpu_runs_and_cpu_steps() { + let Some(g) = gpu() else { return }; + let mut stack = gpu_stack(); + stack.insert(1, ("ec.stylize.posterize", vec![])); + let s = adjustment_scene(BitDepth::Bpc32, &stack, false); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(g); + let ctx = r.eval_ctx(s.cid, Tick::ZERO).unwrap(); + let adj = ctx.comp.layers.iter().find(|l| l.switches.adjustment).unwrap(); + let mut p = Probe::default(); + r.run_effects_on(&ctx, adj, true, &mut p); + assert_eq!((p.gpu, p.cpu), (3, 1)); +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index a0b51f1f..cfec880d 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -124,9 +124,14 @@ fn draw_collapsed<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: & } fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> { - if layer.switches.adjustment || layer.switches.quality == Quality::Wireframe { - // Adjustment layers run their effects on the comp below (CPU, with GPU effects where - // available); wireframes draw outlines. + if layer.switches.adjustment { + if draw_adjustment(e, r, ctx, layer, canvas).is_some() { + return Some(()); + } + return on_cpu(e, canvas, |img| r.draw_layer_cpu(ctx, layer, img)); + } + if layer.switches.quality == Quality::Wireframe { + // Wireframes draw outlines on the CPU. return on_cpu(e, canvas, |img| r.draw_layer_cpu(ctx, layer, img)); } let opacity = r.layer_opacity(ctx, layer); @@ -162,6 +167,33 @@ fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: & Some(()) } +/// Adjustment layer (`Renderer::draw_adjustment`): the effect stack runs on the comp below, +/// resident on the GPU (non-GPU effects read back, run on the CPU and upload), and the result +/// replaces the canvas inside the layer's footprint × opacity. `None` = draw it on the CPU. +fn draw_adjustment(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> { + let opacity = r.layer_opacity(ctx, layer); + let Some(foot) = r.adjustment_footprint(ctx, layer) else { return Some(()) }; + let (w, h) = (canvas.width, canvas.height); + let matte = place(e, r, ctx, layer, &Arc::new(foot), &e.image(w, h), BlendMode::Normal, 1.0)?; + let pipe = r.pipe(); + // Effects run in the working space, not the linear blending space. + let below = match pipe.from_blend() { + Some(c) => ops::convert(e, canvas, &c), + None => canvas.clone(), + }; + let mut fx = crate::effects::GpuFx::new(e, below, [0.0; 2], r.opts.scale, pipe.levels); + r.run_effects_on(ctx, layer, true, &mut fx); + let (mut adjusted, _, _) = fx.finish()?; + if adjusted.width != w || adjusted.height != h { + return Some(()); + } + if let Some(c) = pipe.to_blend() { + adjusted = ops::convert(e, &adjusted, &c); + } + *canvas = ops::adjust_mix(e, canvas, &adjusted, &matte, opacity); + Some(()) +} + fn composite_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, buf: &Arc, canvas: &mut GpuImage, opacity: f32) -> Option<()> { let matte = r.track_matte(ctx, layer).is_some(); if !matte && !layer.preserve_transparency { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5ae4ebc5..ffb55936 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -242,6 +242,41 @@ pub trait Accelerator: Send + Sync { } } +/// Where an effect stack runs (see [`Renderer::run_effects_on`]): a CPU buffer, or an image +/// resident on an accelerator. +pub trait FxTarget { + /// Run a chain of accelerator-supported effects (quantising after each to the bit depth). + /// `false` = not handled: the effects then run one by one through [`FxTarget::cpu`]. + fn gpu(&mut self, steps: &[FxStep]) -> bool; + /// Run one CPU effect: `f` maps the buffer. + fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf); +} + +/// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback). +struct CpuFx<'a> { + accel: Option<&'a dyn Accelerator>, + levels: Option, + buf: Option, +} + +impl FxTarget for CpuFx<'_> { + fn gpu(&mut self, steps: &[FxStep]) -> bool { + let (Some(a), Some(b)) = (self.accel, &self.buf) else { return false }; + match a.effects(steps, b, self.levels) { + Some(out) => { + self.buf = Some(out); + true + } + None => false, + } + } + fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf) { + if let Some(b) = self.buf.take() { + self.buf = Some(f(b)); + } + } +} + #[derive(Clone, Copy, Debug)] pub struct RenderOpts { /// Output scale relative to comp pixels (1 = Full, 0.5 = Half…). @@ -592,19 +627,28 @@ impl<'a> Renderer<'a> { } /// Run the first `limit` effects of the layer's stack (by position; disabled ones count). - fn apply_effects_timed( + fn apply_effects_timed(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, adjustment: bool, limit: usize, timing: Option<&mut Vec<(String, f64)>>) -> Buf { + let mut t = CpuFx { accel: self.active_accel(), levels: self.pipe.levels, buf: Some(buf) }; + self.run_effect_stack(ctx, layer, adjustment, limit, timing, &mut t); + t.buf.unwrap_or_else(|| Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: self.opts.scale }) + } + + /// Run the layer's video effect stack on `target`: runs of GPU-capable effects go to + /// [`FxTarget::gpu`] (when an accelerator is active and supports them), the rest to + /// [`FxTarget::cpu`] one by one (quantised to the bit depth after each). + fn run_effect_stack( &self, ctx: &EvalCtx, layer: &Layer, - mut buf: Buf, adjustment: bool, limit: usize, mut timing: Option<&mut Vec<(String, f64)>>, - ) -> Buf { + target: &mut dyn FxTarget, + ) { if !layer.switches.effects { - return buf; + return; } - let Some(fx) = layer.effects() else { return buf }; + let Some(fx) = layer.effects() else { return }; let lt = layer.layer_time(ctx.time); let size = source_size(self.project, layer); let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; @@ -635,7 +679,7 @@ impl<'a> Renderer<'a> { let mut k = 0; while k < stack.len() { let (i, g, spec) = stack[k]; - // A run of GPU effects goes to the accelerator in one upload / readback. + // A run of GPU effects goes to the accelerator in one go. if let Some(a) = accel && spec.gpu && a.supports_effect(spec.id) @@ -651,8 +695,7 @@ impl<'a> Renderer<'a> { }) .collect(); let t0 = web_time::Instant::now(); - if let Some(out) = a.effects(&steps, &buf, self.pipe.levels) { - buf = out; + if target.gpu(&steps) { if let Some(v) = timing.as_deref_mut() { let ms = t0.elapsed().as_secs_f64() * 1e3 / run.len() as f64; v.extend(run.iter().map(|(_, _, s)| (format!("{} (gpu)", s.id), ms))); @@ -667,14 +710,16 @@ impl<'a> Renderer<'a> { let env = EffectEnv { effect_index: i, ..env }; let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env }; let t0 = web_time::Instant::now(); - buf = effectcraft_effects::apply(spec, &ectx, buf); - // 8/16 bpc effects write integer pixels. - self.pipe.quantize(&mut buf.img); + target.cpu(&mut |buf| { + let mut buf = effectcraft_effects::apply(spec, &ectx, buf); + // 8/16 bpc effects write integer pixels. + self.pipe.quantize(&mut buf.img); + buf + }); if let Some(v) = timing.as_deref_mut() { v.push((spec.id.to_string(), t0.elapsed().as_secs_f64() * 1e3)); } } - buf } /// Source pixels of a layer in layer space (before masks/effects). @@ -1389,6 +1434,22 @@ impl<'a> Renderer<'a> { three_d::compose::gpu_run(self, ctx, run, out) } + /// Run the layer's effect stack on `target` (an accelerator's resident image): GPU-capable + /// runs through [`FxTarget::gpu`], the others through [`FxTarget::cpu`], with the CPU's + /// parameters and environment. `adjustment`: the stack runs on the comp below (adjustment + /// layers). + pub fn run_effects_on(&self, ctx: &EvalCtx, layer: &Layer, adjustment: bool, target: &mut dyn FxTarget) { + self.run_effect_stack(ctx, layer, adjustment, usize::MAX, None, target); + } + + /// An adjustment layer's footprint (its source with masks applied, layer space): where + /// its effects show. `None` = the layer changes nothing. + pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + let mut foot = self.source(ctx, layer)?; + let _ = masks::apply(ctx, layer, &mut foot); + Some(foot) + } + /// Draw one 2D layer into `canvas` on the CPU (any kind: adjustment, wireframe, styled…). pub fn draw_layer_cpu(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) { self.draw_layer(ctx, layer, canvas, false); From 9a6389db86a0109fddff225533b900141d77e6b0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:25:12 -0400 Subject: [PATCH 3/9] M12.7: GPU effect family modules (colour, distort, generate) scaffolding; clippy fixes Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/classic3d.rs | 2 +- crates/gpu/src/context.rs | 17 ++++++++++++++--- crates/gpu/src/effects.rs | 21 ++++++++++++--------- crates/gpu/src/fx_color.rs | 18 ++++++++++++++++++ crates/gpu/src/fx_distort.rs | 18 ++++++++++++++++++ crates/gpu/src/fx_generate.rs | 18 ++++++++++++++++++ crates/gpu/src/lib.rs | 3 +++ crates/gpu/src/ops.rs | 2 +- crates/gpu/src/shaders/fx_color.wgsl | 2 ++ crates/gpu/src/shaders/fx_distort.wgsl | 2 ++ crates/gpu/src/shaders/fx_generate.wgsl | 2 ++ crates/gpu/src/tests.rs | 4 ++-- 12 files changed, 93 insertions(+), 16 deletions(-) create mode 100644 crates/gpu/src/fx_color.rs create mode 100644 crates/gpu/src/fx_distort.rs create mode 100644 crates/gpu/src/fx_generate.rs create mode 100644 crates/gpu/src/shaders/fx_color.wgsl create mode 100644 crates/gpu/src/shaders/fx_distort.wgsl create mode 100644 crates/gpu/src/shaders/fx_generate.wgsl diff --git a/crates/gpu/src/classic3d.rs b/crates/gpu/src/classic3d.rs index 9e73edb3..0c72dd60 100644 --- a/crates/gpu/src/classic3d.rs +++ b/crates/gpu/src/classic3d.rs @@ -188,7 +188,7 @@ pub(crate) fn draw_run(e: &mut Enc, run: &Run3d, canvas: &GpuImage) -> Option e.g.device.limits().max_storage_buffer_binding_size as u64 { + if (mattes.len() * 4) as u64 > e.g.device.limits().max_storage_buffer_binding_size { return None; } let mut lights = Words::default(); diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 66bee99d..e7cda93e 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -141,7 +141,15 @@ impl GpuContext { } } let name = format!("{} ({:?})", info.name, info.backend); - let src = [include_str!("shaders/common.wgsl"), include_str!("shaders/kernels.wgsl"), include_str!("shaders/classic3d.wgsl")].concat(); + let src = [ + include_str!("shaders/common.wgsl"), + include_str!("shaders/kernels.wgsl"), + include_str!("shaders/classic3d.wgsl"), + include_str!("shaders/fx_color.wgsl"), + include_str!("shaders/fx_distort.wgsl"), + include_str!("shaders/fx_generate.wgsl"), + ] + .concat(); let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft kernels"), source: wgpu::ShaderSource::Wgsl(src.into()) }); let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { @@ -190,6 +198,9 @@ impl GpuContext { }); let pipelines = ENTRIES .iter() + .chain(crate::fx_color::KERNELS) + .chain(crate::fx_distort::KERNELS) + .chain(crate::fx_generate::KERNELS) .map(|e| (e, &layout)) .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) .map(|(e, layout)| { @@ -585,7 +596,7 @@ impl<'g> Enc<'g> { /// Copy an image into a storage buffer (RGBA f32 rows) for kernels that read a third image /// through `data`: (buffer, row length in pixels). - pub fn to_buffer(&mut self, img: &GpuImage) -> (wgpu::Buffer, u32) { + pub fn image_rows(&mut self, img: &GpuImage) -> (wgpu::Buffer, u32) { let row = (img.width * 16).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; let buf = self.g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("image rows"), @@ -606,7 +617,7 @@ impl<'g> Enc<'g> { /// A read-only storage buffer holding `bytes` (padded to 16 bytes). pub fn bytes(&self, mut bytes: Vec) -> wgpu::Buffer { - while bytes.len() < 16 || bytes.len() % 4 != 0 { + while bytes.len() < 16 || !bytes.len().is_multiple_of(4) { bytes.push(0); } self.g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("data"), contents: &bytes, usage: wgpu::BufferUsages::STORAGE }) diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 36b692f1..2ef75c48 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -16,19 +16,19 @@ pub fn supports(id: &str) -> bool { } /// A layer buffer on the GPU (see [`Buf`]). -struct GBuf { - img: GpuImage, - offset: [f64; 2], - scale: f64, +pub(crate) struct GBuf { + pub img: GpuImage, + pub offset: [f64; 2], + pub scale: f64, } impl GBuf { - fn to_px(&self, p: [f64; 2]) -> (f64, f64) { + pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) { (p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1]) } /// Buf::pad: grow by `pad` transparent pixels on every side. - fn pad(&mut self, e: &mut Enc, pad: u32) -> Option<()> { + pub fn pad(&mut self, e: &mut Enc, pad: u32) -> Option<()> { if pad == 0 { return Some(()); } @@ -114,6 +114,9 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { "ec.distort.transform" => transform(e, ctx, b), "ec.color.curves" => curves(e, ctx, b), _ if id.starts_with("ec.control.") => Some(b), + _ if crate::fx_color::IDS.contains(&id) => crate::fx_color::apply(e, id, ctx, b), + _ if crate::fx_distort::IDS.contains(&id) => crate::fx_distort::apply(e, id, ctx, b), + _ if crate::fx_generate::IDS.contains(&id) => crate::fx_generate::apply(e, id, ctx, b), _ => pointwise(e, id, ctx, b), } } @@ -121,7 +124,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { // ---------------------------------------------------------------- blurs /// raster::blur::box_radii. -fn box_radii(sigma: f64, n: usize) -> Vec { +pub(crate) fn box_radii(sigma: f64, n: usize) -> Vec { if sigma <= 0.0 { return vec![0; n]; } @@ -137,7 +140,7 @@ fn box_radii(sigma: f64, n: usize) -> Vec { } /// Horizontal passes with radii `rx`, then vertical passes with radii `ry` (raster::blur). -fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize], repeat: bool) -> GpuImage { +pub(crate) fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize], repeat: bool) -> GpuImage { let mut cur = img.clone(); let passes = rx.iter().map(|&r| (false, r)).chain(ry.iter().map(|&r| (true, r))).filter(|p| p.1 > 0); for (vertical, r) in passes { @@ -153,7 +156,7 @@ fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize], repeat: b } /// raster::gaussian_blur (3 box passes per axis). -fn gaussian_blur(e: &mut Enc, img: &GpuImage, sx: f64, sy: f64, repeat: bool) -> GpuImage { +pub(crate) fn gaussian_blur(e: &mut Enc, img: &GpuImage, sx: f64, sy: f64, repeat: bool) -> GpuImage { let rx = if sx > 0.05 { box_radii(sx, 3) } else { vec![] }; let ry = if sy > 0.05 { box_radii(sy, 3) } else { vec![] }; box_passes(e, img, &rx, &ry, repeat) diff --git a/crates/gpu/src/fx_color.rs b/crates/gpu/src/fx_color.rs new file mode 100644 index 00000000..8205f042 --- /dev/null +++ b/crates/gpu/src/fx_color.rs @@ -0,0 +1,18 @@ +//! GPU effects, colour family (kernels in `shaders/fx_color.wgsl`): the CPU effects' exact +//! steps with the pixel loops as compute kernels. + +use effectcraft_effects::EffectCtx; + +use crate::context::Enc; +use crate::effects::GBuf; + +/// Compute entry points in `fx_color.wgsl`. +pub(crate) const KERNELS: &[&str] = &[]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[]; + +/// 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 { + None +} diff --git a/crates/gpu/src/fx_distort.rs b/crates/gpu/src/fx_distort.rs new file mode 100644 index 00000000..fe92ad91 --- /dev/null +++ b/crates/gpu/src/fx_distort.rs @@ -0,0 +1,18 @@ +//! GPU effects, distortion and stylize family (kernels in `shaders/fx_distort.wgsl`): the CPU effects' exact +//! steps with the pixel loops as compute kernels. + +use effectcraft_effects::EffectCtx; + +use crate::context::Enc; +use crate::effects::GBuf; + +/// Compute entry points in `fx_distort.wgsl`. +pub(crate) const KERNELS: &[&str] = &[]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[]; + +/// 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 { + None +} diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs new file mode 100644 index 00000000..61c18def --- /dev/null +++ b/crates/gpu/src/fx_generate.rs @@ -0,0 +1,18 @@ +//! GPU effects, blur, transition and generate family (kernels in `shaders/fx_generate.wgsl`): the CPU effects' exact +//! steps with the pixel loops as compute kernels. + +use effectcraft_effects::EffectCtx; + +use crate::context::Enc; +use crate::effects::GBuf; + +/// Compute entry points in `fx_generate.wgsl`. +pub(crate) const KERNELS: &[&str] = &[]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[]; + +/// 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 { + None +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index a7c9cdc4..9ebce209 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -25,6 +25,9 @@ mod adv3d; mod classic3d; mod context; mod effects; +mod fx_color; +mod fx_distort; +mod fx_generate; mod ops; mod walk; diff --git a/crates/gpu/src/ops.rs b/crates/gpu/src/ops.rs index cd743fbf..e7a9c406 100644 --- a/crates/gpu/src/ops.rs +++ b/crates/gpu/src/ops.rs @@ -136,7 +136,7 @@ pub fn channel_mix(e: &mut Enc, canvas: &GpuImage, tmp: &GpuImage, channels: [bo /// Adjustment layer finish: move `canvas` toward `adjusted` by `matte`'s alpha × `opacity`. pub fn adjust_mix(e: &mut Enc, canvas: &GpuImage, adjusted: &GpuImage, matte: &GpuImage, opacity: f32) -> GpuImage { - let (rows, stride) = e.to_buffer(matte); + let (rows, stride) = e.image_rows(matte); let mut p = Params::default(); p.u[0][0] = stride; p.f[0][0] = opacity; diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl new file mode 100644 index 00000000..3e42688d --- /dev/null +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -0,0 +1,2 @@ +// GPU effects (color family): see src/fx_color.rs. Each entry point mirrors the CPU effect in +// effectcraft-effects operation for operation; parameter layouts are documented per entry. diff --git a/crates/gpu/src/shaders/fx_distort.wgsl b/crates/gpu/src/shaders/fx_distort.wgsl new file mode 100644 index 00000000..b3ce3463 --- /dev/null +++ b/crates/gpu/src/shaders/fx_distort.wgsl @@ -0,0 +1,2 @@ +// GPU effects (distort family): see src/fx_distort.rs. Each entry point mirrors the CPU effect in +// effectcraft-effects operation for operation; parameter layouts are documented per entry. diff --git a/crates/gpu/src/shaders/fx_generate.wgsl b/crates/gpu/src/shaders/fx_generate.wgsl new file mode 100644 index 00000000..bc67b93a --- /dev/null +++ b/crates/gpu/src/shaders/fx_generate.wgsl @@ -0,0 +1,2 @@ +// GPU effects (generate family): see src/fx_generate.rs. Each entry point mirrors the CPU effect in +// effectcraft-effects operation for operation; parameter layouts are documented per entry. diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 137fdec3..f795b58d 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -379,7 +379,7 @@ fn layer_styles_collapsed_precomps_and_cpu_fallbacks() { /// The effect alone on a buffer: GPU kernels vs the CPU effect (32-bit float), including the /// buffer geometry (padding, offset). -fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool) { +pub(crate) fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool) { let Some(g) = gpu() else { return }; let spec = effectcraft_effects::find(id).unwrap(); let size = [70.0, 44.0]; @@ -401,7 +401,7 @@ fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool) { } /// One footage layer with `fx` applied, composited over a background, at 8 and 32 bpc. -fn effect_case(id: &str, vals: &[(&str, Value)]) { +pub(crate) fn effect_case(id: &str, vals: &[(&str, Value)]) { effect_direct(id, vals, false); effect_direct(id, vals, true); for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { From fcc09cf09555c91c12584386514cc1ffcb45378c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:42:43 -0400 Subject: [PATCH 4/9] M12.7: GPU distort/stylize effects (Turbulent Displace, Displacement Map, Wave Warp, Ripple, Twirl, Bulge, CC Lens, Mesh Warp, Mosaic, Find Edges, Emboss) Compute kernels in shaders/fx_distort.wgsl mirroring the CPU effects; per-row/column structure (map coordinates, wave phase, tile bounds, mesh triangle buckets) is computed in f64 on the CPU and uploaded. Displacement Map fetches its map layer through the effect host as the CPU does and samples it on the GPU. CPU oracle tests in tests_fx_distort.rs. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/distort2.rs | 3 +- crates/effects/src/lib.rs | 12 + crates/gpu/src/fx_distort.rs | 405 +++++++++++++++++- crates/gpu/src/lib.rs | 2 + crates/gpu/src/shaders/fx_distort.wgsl | 552 +++++++++++++++++++++++++ crates/gpu/src/tests_fx_distort.rs | 119 ++++++ 6 files changed, 1087 insertions(+), 6 deletions(-) create mode 100644 crates/gpu/src/tests_fx_distort.rs diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 66f038e0..2d52f039 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -414,7 +414,8 @@ fn grid_warp(src: &Image, w: u32, h: u32, nx: usize, ny: usize, dest: &[Pt], src out } -fn parse_mesh(s: &str) -> Vec { +/// Mesh Warp's `mesh` parameter: the vertex offsets (layer pixels), row-major. +pub fn parse_mesh(s: &str) -> Vec<(f64, f64)> { s.split(|c: char| c.is_whitespace() || c == ';') .filter(|t| !t.is_empty()) .filter_map(|t| { diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a5b4f346..7c6e0574 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -62,6 +62,7 @@ use std::sync::OnceLock; pub use color_fx::{exposure_settings, levels_clip}; pub use color2::Curve; +pub use distort2::parse_mesh; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; @@ -641,6 +642,17 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.color.exposure", "ec.channel.invert", "ec.distort.transform", + "ec.distort.turbulentdisplace", + "ec.distort.displacementmap", + "ec.distort.wavewarp", + "ec.distort.ripple", + "ec.distort.twirl", + "ec.distort.bulge", + "ec.distort.cclens", + "ec.distort.meshwarp", + "ec.stylize.mosaic", + "ec.stylize.findedges", + "ec.stylize.emboss", ]; /// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated diff --git a/crates/gpu/src/fx_distort.rs b/crates/gpu/src/fx_distort.rs index fe92ad91..bcd7214f 100644 --- a/crates/gpu/src/fx_distort.rs +++ b/crates/gpu/src/fx_distort.rs @@ -1,18 +1,413 @@ //! GPU effects, distortion and stylize family (kernels in `shaders/fx_distort.wgsl`): the CPU effects' exact //! steps with the pixel loops as compute kernels. +//! +//! Parameter conversions, padding and anything with a per-row or per-column structure (map +//! coordinates, tile bounds, Mesh Warp's triangle buckets) are computed here in f64 exactly as +//! the CPU does and uploaded; the kernels do the per-pixel work. + +use std::f64::consts::TAU; use effectcraft_effects::EffectCtx; +use effectcraft_effects::util::{Src, src_at}; -use crate::context::Enc; +use crate::context::{Enc, Params}; use crate::effects::GBuf; /// Compute entry points in `fx_distort.wgsl`. -pub(crate) const KERNELS: &[&str] = &[]; +pub(crate) const KERNELS: &[&str] = &[ + "dst_twirl", + "dst_bulge", + "dst_wavewarp", + "dst_ripple", + "dst_cclens", + "dst_turbulent", + "dst_displace", + "dst_mesh", + "dst_mosaic_rows", + "dst_mosaic_tiles", + "dst_mosaic_out", + "dst_mosaic_sharp", + "dst_findedges", + "dst_emboss", +]; /// Effect ids implemented here. -pub(crate) const IDS: &[&str] = &[]; +pub(crate) const IDS: &[&str] = &[ + "ec.distort.turbulentdisplace", + "ec.distort.displacementmap", + "ec.distort.wavewarp", + "ec.distort.ripple", + "ec.distort.twirl", + "ec.distort.bulge", + "ec.distort.cclens", + "ec.distort.meshwarp", + "ec.stylize.mosaic", + "ec.stylize.findedges", + "ec.stylize.emboss", +]; /// 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 { - None +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + match id { + "ec.distort.turbulentdisplace" => turbulent(e, ctx, b), + "ec.distort.displacementmap" => displacement_map(e, ctx, b), + "ec.distort.wavewarp" => wave_warp(e, ctx, b), + "ec.distort.ripple" => ripple(e, ctx, b), + "ec.distort.twirl" => twirl(e, ctx, b), + "ec.distort.bulge" => bulge(e, ctx, b), + "ec.distort.cclens" => cc_lens(e, ctx, b), + "ec.distort.meshwarp" => mesh_warp(e, ctx, b), + "ec.stylize.mosaic" => mosaic(e, ctx, b), + "ec.stylize.findedges" => find_edges(e, ctx, b), + "ec.stylize.emboss" => emboss(e, ctx, b), + _ => None, + } +} + +/// Run a same-size per-pixel kernel over the buffer. +fn run(e: &mut Enc, entry: &str, p: &Params, mut b: GBuf, data: Option<&wgpu::Buffer>) -> Option { + let out = e.image(b.img.width, b.img.height); + e.pixels(entry, p, &b.img, None, &out, data); + b.img = out; + Some(b) +} + +/// Buf::layer_w / layer_h (distort.rs). +fn layer_wh(ctx: &EffectCtx) -> (f64, f64) { + (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)) +} + +/// util::layer_rect. +fn layer_rect(ctx: &EffectCtx, b: &GBuf) -> [f32; 4] { + [b.offset[0] as f32, b.offset[1] as f32, (ctx.layer_size[0] * b.scale) as f32, (ctx.layer_size[1] * b.scale) as f32] +} + +// ---------------------------------------------------------------- distort.rs + +fn twirl(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let ang = ctx.params.f("angle").to_radians(); + let (lw, lh) = layer_wh(ctx); + let r = (ctx.params.f("radius") / 100.0 * lw.max(lh) * b.scale).max(1.0); + let c = b.to_px(ctx.params.v2("center")); + let mut p = Params::default(); + p.f[0] = [c.0 as f32, c.1 as f32, r as f32, ang as f32]; + run(e, "dst_twirl", &p, b, None) +} + +fn bulge(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let rx = (ctx.params.f("hradius") * b.scale).max(1.0); + let ry = (ctx.params.f("vradius") * b.scale).max(1.0); + let c = b.to_px(ctx.params.v2("center")); + let h = ctx.params.f("height"); + let taper = 1.0 + ctx.params.f("taperRadius").max(0.0) / 100.0 * 3.0; + let pin = ctx.params.b("pinAllEdges"); + let (lw, lh) = layer_wh(ctx); + let (lw, lh) = (lw * b.scale, lh * b.scale); + let edge = (lw.min(lh) * 0.1).max(1.0); + let mut p = Params::default(); + p.f[0] = [c.0 as f32, c.1 as f32, rx as f32, ry as f32]; + p.f[1] = [h as f32, taper as f32, if pin { 1.0 } else { 0.0 }, edge as f32]; + p.f[2] = [b.offset[0] as f32, b.offset[1] as f32, lw as f32, lh as f32]; + run(e, "dst_bulge", &p, b, None) +} + +fn wave_warp(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let h = ctx.params.f("height") * b.scale; + let w = (ctx.params.f("width") * b.scale).max(1.0); + let dir = ctx.params.f("direction").to_radians(); + let speed = ctx.params.f("speed"); + let phase = ctx.params.f("phase").to_radians() + ctx.time * speed * TAU; + let (dx, dy) = (dir.sin(), -dir.cos()); + if !ctx.adjustment { + b.pad(e, h.abs().ceil() as u32 + 1)?; + } + let (lw, lh) = layer_wh(ctx); + let rect = [b.offset[0] as f32, b.offset[1] as f32, (lw * b.scale).max(1.0) as f32, (lh * b.scale).max(1.0) as f32]; + // The wave phase in cycles, (x·dx + y·dy) / width + phase / 2π, is separable: per column + // and per row (fraction, whole cycles) in f64, so the kernel's sum keeps the fraction exact + // to f32 (the circle shapes have vertical tangents where a phase error is amplified). + let (bw, bh) = (b.img.width as usize, b.img.height as usize); + let mut data = vec![0.0f32; 2 * (bw + bh)]; + let split = |v: f64| (v - v.floor(), v.floor()); + for x in 0..bw { + let (f, i) = split((x as f64 + 0.5) * dx / w); + data[x] = f as f32; + data[bw + x] = i as f32; + } + for y in 0..bh { + let (f, i) = split((y as f64 + 0.5) * dy / w + phase / TAU); + data[2 * bw + y] = f as f32; + data[2 * bw + bh + y] = i as f32; + } + let mut p = Params::default(); + p.u[0] = [ctx.params.e("waveType"), ctx.params.e("pinning"), ctx.seed ^ 0x3a7e, bw as u32]; + p.f[0] = [dx as f32, dy as f32, h as f32, 0.0]; + p.f[2] = rect; + let buf = e.data(&data); + run(e, "dst_wavewarp", &p, b, Some(&buf)) +} + +fn ripple(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let (lw, lh) = layer_wh(ctx); + let r = (ctx.params.f("radius") / 100.0 * lw.max(lh) * 0.5 * b.scale).max(1.0); + let asymmetric = ctx.params.e("conversion") == 0; + let c = b.to_px(ctx.params.v2("center")); + let w = (ctx.params.f("waveWidth") * b.scale).max(1.0); + let h = ctx.params.f("waveHeight") * b.scale; + let phase = ctx.params.f("phase").to_radians() - ctx.time * ctx.params.f("speed") * TAU; + let mut p = Params::default(); + p.u[0][0] = asymmetric as u32; + p.f[0] = [c.0 as f32, c.1 as f32, r as f32, w as f32]; + p.f[1] = [h as f32, phase.rem_euclid(TAU) as f32, 0.0, 0.0]; + run(e, "dst_ripple", &p, b, None) +} + +// ---------------------------------------------------------------- distort2.rs + +fn cc_lens(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let c = b.to_px(ctx.params.v2("center")); + let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + let r = (ctx.params.f("size") / 100.0 * lw.max(lh) * 0.5).max(0.5); + let k = 2f64.powf(ctx.params.f("convergence") / 50.0); + let mut p = Params::default(); + p.f[0] = [c.0 as f32, c.1 as f32, r as f32, k as f32]; + run(e, "dst_cclens", &p, b, None) +} + +fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let amount = ctx.params.f("amount") * b.scale; + if amount.abs() < 1e-9 { + return Some(b); + } + let kind = ctx.params.e("displacement"); + let size = (ctx.params.f("size") * b.scale).max(1.0); + if ctx.params.b("resizeLayer") && !ctx.adjustment { + b.pad(e, amount.abs().ceil() as u32 + 1)?; + } + let off = b.to_px(ctx.params.v2("offset")); + let oct = ctx.params.f("complexity").clamp(1.0, 10.0); + let evo = ctx.params.f("evolution") / 360.0; + let cycle = ctx.params.b("evolutionOptions/cycleEvolution").then(|| ctx.params.f("evolutionOptions/cycle").round().max(1.0)); + let falloff = if (3..=5).contains(&kind) { 0.3 } else { 0.5 }; + let rect = layer_rect(ctx, &b); + let edge = ((ctx.layer_size[0] * b.scale).min(ctx.layer_size[1] * b.scale) * 0.1).max(1.0); + let seed = (ctx.seed ^ 0x7d15).wrapping_add((ctx.params.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(0x9e37_79b9)); + let (z0, z1, t) = match cycle { + Some(c) => { + let z = evo.rem_euclid(c); + (z as f32, (z - c) as f32, (z / c) as f32) + } + None => (evo as f32, 0.0, 0.0), + }; + let mut p = Params::default(); + p.u[0] = [kind, ctx.params.e("pinning"), seed, oct.ceil().max(1.0) as u32]; + p.u[1][0] = cycle.is_some() as u32; + p.f[0] = [off.0 as f32, off.1 as f32, size as f32, amount as f32]; + p.f[1] = [z0, z1, t, (oct - oct.floor()) as f32]; + p.f[2] = rect; + p.f[3] = [edge as f32, falloff, 0.0, 0.0]; + run(e, "dst_turbulent", &p, b, None) +} + +fn displacement_map(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let hs = src_at(ctx.params.e("useForHorizontal")); + let vs = src_at(ctx.params.e("useForVertical")); + let mh = ctx.params.f("maxHorizontal") * b.scale; + let mv = ctx.params.f("maxVertical") * b.scale; + let wrap = ctx.params.b("wrapPixelsAround"); + if ctx.params.b("expandOutput") && !wrap && !ctx.adjustment { + b.pad(e, mh.abs().max(mv.abs()).ceil() as u32)?; + } + let layer = ctx.layer_param("displacementMapLayer", false); + let behavior = ctx.params.e("displacementMapBehavior"); + let (w, h) = (b.img.width as usize, b.img.height as usize); + let inv = 1.0 / b.scale.max(1e-9); + let ls = ctx.layer_size; + // Per column / row: the map layer's buffer coordinate (fit_layer / Tile Map) and whether + // the pixel lies inside the centred map (Center Map). + let mut data = vec![0.0f32; 2 * (w + h)]; + let mut mode = 0u32; + let mut center = false; + let map = match &layer { + Some(o) => { + if o.buf.img.is_empty() { + return None; + } + let os = o.size; + let axis = |i: usize, k: usize| -> (f64, bool) { + let q = (i as f64 + 0.5 - b.offset[k]) * inv; + let map_q = match behavior { + 2 => (q + (os[k] - ls[k]) * 0.5).rem_euclid(os[k].max(1.0)), + 1 if ls[0] > 0.0 && ls[1] > 0.0 => q * (os[k] / ls[k]), + 1 => q, + _ => q + (os[k] - ls[k]) * 0.5, + }; + let c = q + (os[k] - ls[k]) * 0.5; + (map_q * o.buf.scale + o.buf.offset[k], (0.0..os[k]).contains(&c)) + }; + for x in 0..w { + let (m, inside) = axis(x, 0); + data[x] = m as f32; + data[w + h + x] = inside as u32 as f32; + } + for y in 0..h { + let (m, inside) = axis(y, 1); + data[w + y] = m as f32; + data[2 * w + h + y] = inside as u32 as f32; + } + mode = if behavior == 2 { 2 } else { 1 }; + center = behavior == 0; + Some(e.g.upload_image(&o.buf.img)?) + } + None => None, + }; + let idx = |s: Src| effectcraft_effects::util::SRC_ORDER.iter().position(|x| *x == s).unwrap_or(10) as u32; + let mut p = Params::default(); + p.u[0] = [idx(hs), idx(vs), mode, wrap as u32 | (center as u32) << 1]; + p.u[1] = [w as u32, h as u32, 0, 0]; + p.f[0] = [mh as f32, mv as f32, 0.0, 0.0]; + let buf = e.data(&data); + let out = e.image(b.img.width, b.img.height); + e.pixels("dst_displace", &p, &b.img, map.as_ref(), &out, Some(&buf)); + b.img = out; + Some(b) +} + +fn mesh_warp(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let rows = ctx.params.f("rows").round().clamp(1.0, 31.0) as usize; + let cols = ctx.params.f("columns").round().clamp(1.0, 31.0) as usize; + let q = ctx.params.f("quality").round().clamp(1.0, 10.0) as usize; + let offs = effectcraft_effects::parse_mesh(ctx.params.s("mesh")); + if offs.len() != (rows + 1) * (cols + 1) || offs.iter().all(|o| o.0 == 0.0 && o.1 == 0.0) { + return Some(b); + } + let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]); + let vert = |i: usize, j: usize| { + let o = offs[j * (cols + 1) + i]; + b.to_px([i as f64 / cols as f64 * lw + o.0, j as f64 / rows as f64 * lh + o.1]) + }; + let (nx, ny) = (cols * q, rows * q); + let mut dest = Vec::with_capacity((nx + 1) * (ny + 1)); + let mut srcp = Vec::with_capacity((nx + 1) * (ny + 1)); + for gj in 0..=ny { + for gi in 0..=nx { + let (ci, cj) = ((gi / q).min(cols - 1), (gj / q).min(rows - 1)); + let (u, v) = ((gi - ci * q) as f64 / q as f64, (gj - cj * q) as f64 / q as f64); + let (p00, p10, p01, p11) = (vert(ci, cj), vert(ci + 1, cj), vert(ci, cj + 1), vert(ci + 1, cj + 1)); + let x = (p00.0 * (1.0 - u) + p10.0 * u) * (1.0 - v) + (p01.0 * (1.0 - u) + p11.0 * u) * v; + let y = (p00.1 * (1.0 - u) + p10.1 * u) * (1.0 - v) + (p01.1 * (1.0 - u) + p11.1 * u) * v; + dest.push((x, y)); + srcp.push(b.to_px([gi as f64 / nx as f64 * lw, gj as f64 / ny as f64 * lh])); + } + } + // distort2::grid_warp: triangles in order, bucketed per output row. + let idx = |i: usize, j: usize| j * (nx + 1) + i; + let hh = b.img.height as usize; + let mut tri_data: Vec = Vec::with_capacity(nx * ny * 24); + let mut buckets: Vec> = vec![Vec::new(); hh]; + let mut t = 0u32; + for j in 0..ny { + for i in 0..nx { + let (a, b2, c, d) = (idx(i, j), idx(i + 1, j), idx(i + 1, j + 1), idx(i, j + 1)); + for tri in [[a, b2, c], [a, c, d]] { + let [ia, ib, ic] = tri; + let (pa, pb, pc) = (dest[ia], dest[ib], dest[ic]); + let den: f64 = (pb.1 - pc.1) * (pa.0 - pc.0) + (pc.0 - pb.0) * (pa.1 - pc.1); + // Degenerate triangles are skipped per pixel on the CPU; leaving them out of + // the buckets is the same. + if den.abs() < 1e-12 { + continue; + } + let ys = [pa.1, pb.1, pc.1]; + let y0 = (ys[0].min(ys[1]).min(ys[2]) - 0.5).floor().max(0.0) as usize; + let y1 = (ys[0].max(ys[1]).max(ys[2]) + 0.5).ceil().max(0.0) as usize; + for bucket in buckets.iter_mut().take(y1.min(hh)).skip(y0) { + bucket.push(t); + } + let (sa, sb, sc) = (srcp[ia], srcp[ib], srcp[ic]); + tri_data.extend( + [(pb.1 - pc.1) / den, (pc.0 - pb.0) / den, (pc.1 - pa.1) / den, (pa.0 - pc.0) / den, pc.0, pc.1, sa.0, sa.1, sb.0, sb.1, sc.0, sc.1] + .map(|v| v as f32), + ); + t += 1; + } + } + } + let row_starts = tri_data.len(); + let list_start = row_starts + hh + 1; + let total: usize = buckets.iter().map(Vec::len).sum(); + if list_start + total >= 1 << 24 { + // Indices past 2^24 are not exact in the f32 data buffer. + return None; + } + let mut data = tri_data; + data.reserve(hh + 1 + total); + let mut n = 0usize; + for bucket in &buckets { + data.push(n as f32); + n += bucket.len(); + } + data.push(n as f32); + for bucket in &buckets { + data.extend(bucket.iter().map(|&t| t as f32)); + } + let mut p = Params::default(); + p.u[0] = [row_starts as u32, list_start as u32, 0, 0]; + let buf = e.data(&data); + run(e, "dst_mesh", &p, b, Some(&buf)) +} + +// ---------------------------------------------------------------- misc.rs (Stylize) + +fn mosaic(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let hb = ctx.params.f("horizontal").round().max(1.0) as usize; + let vb = ctx.params.f("vertical").round().max(1.0) as usize; + let (w, h) = (b.img.width as usize, b.img.height as usize); + if w == 0 || h == 0 { + return Some(b); + } + let cw = (w as f64 / hb as f64).max(1.0); + let ch = (h as f64 / vb as f64).max(1.0); + let mut p = Params::default(); + if ctx.params.b("sharpColors") { + let centre = |i: usize, c: f64| (((i as f64 / c).floor() + 0.5) * c) as i64 as f32; + let data: Vec = (0..w).map(|x| centre(x, cw)).chain((0..h).map(|y| centre(y, ch))).collect(); + p.u[0][0] = w as u32; + let buf = e.data(&data); + return run(e, "dst_mosaic_sharp", &p, b, Some(&buf)); + } + let (nx, ny) = ((w as f64 / cw).ceil() as usize, (h as f64 / ch).ceil() as usize); + let tx: Vec = (0..w).map(|x| ((x as f64 / cw) as usize).min(nx - 1)).collect(); + let ty: Vec = (0..h).map(|y| ((y as f64 / ch) as usize).min(ny - 1)).collect(); + // Tile bounds: tile i covers the columns with tx == i (tx is non-decreasing). + let bounds = |t: &[usize], n: usize| -> Vec { (0..=n).map(|i| t.partition_point(|&v| v < i) as f32).collect() }; + let mut data: Vec = tx.iter().chain(&ty).map(|&v| v as f32).collect(); + data.extend(bounds(&tx, nx)); + data.extend(bounds(&ty, ny)); + p.u[0] = [w as u32, h as u32, nx as u32, ny as u32]; + let buf = e.data(&data); + let rows = e.image(nx as u32, h as u32); + e.pixels("dst_mosaic_rows", &p, &b.img, None, &rows, Some(&buf)); + let tiles = e.image(nx as u32, ny as u32); + e.pixels("dst_mosaic_tiles", &p, &rows, None, &tiles, Some(&buf)); + let out = e.image(b.img.width, b.img.height); + e.pixels("dst_mosaic_out", &p, &tiles, None, &out, Some(&buf)); + b.img = out; + Some(b) +} + +fn find_edges(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let mut p = Params::default(); + p.u[0][0] = ctx.params.b("invert") as u32; + p.f[0][0] = ctx.params.f("blend") as f32 / 100.0; + run(e, "dst_findedges", &p, b, None) +} + +fn emboss(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let ang = ctx.params.f("direction").to_radians(); + let relief = ctx.params.f("relief") * b.scale; + let (dx, dy) = (ang.cos() * relief, -ang.sin() * relief); + let mut p = Params::default(); + p.f[0] = [dx as f32, dy as f32, ctx.params.f("contrast") as f32 / 100.0, ctx.params.f("blend") as f32 / 100.0]; + run(e, "dst_emboss", &p, b, None) } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 9ebce209..b49dbcc6 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -149,3 +149,5 @@ mod tests_3d; mod tests_adjust; #[cfg(test)] mod tests_adv3d; +#[cfg(test)] +mod tests_fx_distort; diff --git a/crates/gpu/src/shaders/fx_distort.wgsl b/crates/gpu/src/shaders/fx_distort.wgsl index b3ce3463..a5493621 100644 --- a/crates/gpu/src/shaders/fx_distort.wgsl +++ b/crates/gpu/src/shaders/fx_distort.wgsl @@ -1,2 +1,554 @@ // GPU effects (distort family): see src/fx_distort.rs. Each entry point mirrors the CPU effect in // effectcraft-effects operation for operation; parameter layouts are documented per entry. +// Coordinates are pixel centres (x + 0.5, y + 0.5) in buffer pixels, as in util::remap. + +const DST_TAU: f32 = 6.2831855; +const DST_HALF_PI: f32 = 1.5707964; + +fn dst_centre(p: vec2) -> vec2 { + return vec2(f32(p.x) + 0.5, f32(p.y) + 0.5); +} + +fn dst_inside(p: vec2) -> bool { + let d = out_dims(); + return p.x < d.x && p.y < d.y; +} + +// ---------------------------------------------------------------- Twirl (distort::twirl) +// f[0] = (cx, cy, radius, angle in radians) +@compute @workgroup_size(16, 16) +fn dst_twirl(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let c = P.f[0].xy; + let r = P.f[0].z; + let dx = q.x - c.x; + let dy = q.y - c.y; + let d = sqrt(dx * dx + dy * dy); + var s = q; + if (d < r) { + let t = 1.0 - d / r; + let a = -P.f[0].w * t * t; + let sn = sin(a); + let co = cos(a); + s = vec2(c.x + dx * co - dy * sn, c.y + dx * sn + dy * co); + } + textureStore(out, p, sample_bilinear(src, s.x, s.y)); +} + +// ---------------------------------------------------------------- Bulge (distort::bulge) +// f[0] = (cx, cy, rx, ry); f[1] = (height, taper, pin, edge); f[2] = layer rect (x, y, w, h) +@compute @workgroup_size(16, 16) +fn dst_bulge(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let c = P.f[0].xy; + let rx = P.f[0].z; + let ry = P.f[0].w; + let dx = (q.x - c.x) / rx; + let dy = (q.y - c.y) / ry; + let d2 = dx * dx + dy * dy; + var s = q; + if (d2 < 1.0) { + var amt = P.f[1].x * pow(1.0 - d2, P.f[1].y) * 0.5; + if (P.f[1].z > 0.5) { + let l = P.f[2]; + let edge = P.f[1].w; + let ex = clamp(min(q.x - l.x - 0.5, l.x + l.z - 0.5 - q.x) / edge, 0.0, 1.0); + let ey = clamp(min(q.y - l.y - 0.5, l.y + l.w - 0.5 - q.y) / edge, 0.0, 1.0); + amt *= min(ex, ey); + } + let k = 1.0 - amt; + s = vec2(c.x + dx * rx * k, c.y + dy * ry * k); + } + textureStore(out, p, sample_bilinear(src, s.x, s.y)); +} + +// ---------------------------------------------------------------- Wave Warp (distort::wave_warp) + +fn dst_half_circle(u: f32) -> f32 { + let v = 2.0 * u - 1.0; + return sqrt(max(1.0 - v * v, 0.0)); +} + +fn dst_wave_noise(i: f32, seed: u32) -> f32 { + return hash_noise(bitcast(i32(i)), 0x5eedu, seed) * 2.0 - 1.0; +} + +// distort::wave_shape at the phase `cf + ci` in cycles (t / 2π), split into a fraction cf in +// [0, 2) and whole cycles ci so the fraction keeps full precision. +fn dst_wave_shape(kind: u32, cf: f32, ci: f32, seed: u32) -> f32 { + let ph = cf - floor(cf); + switch kind { + case 1u: { + return select(-1.0, 1.0, ph < 0.5); + } + case 2u: { + return 1.0 - 4.0 * abs(ph - 0.5); + } + case 3u: { + return 2.0 * ph - 1.0; + } + case 4u: { + if (ph < 0.5) { + return dst_half_circle(ph * 2.0); + } + return -dst_half_circle(ph * 2.0 - 1.0); + } + case 5u: { + return 2.0 * dst_half_circle(fract(ph * 2.0)) - 1.0; + } + case 6u: { + return 1.0 - 2.0 * dst_half_circle(fract(ph * 2.0)); + } + case 7u: { + return dst_wave_noise(floor(cf * 2.0) + 2.0 * ci, seed); + } + case 8u: { + let u = cf * 2.0; + let i = floor(u); + let f = u - i; + let s = f * f * (3.0 - 2.0 * f); + let n = i + 2.0 * ci; + return dst_wave_noise(n, seed) * (1.0 - s) + dst_wave_noise(n + 1.0, seed) * s; + } + default: { + return sin(ph * DST_TAU); + } + } +} + +// distort::wave_pin. +fn dst_wave_pin(pin: u32, x: f32, y: f32, r: vec4) -> f32 { + let u = clamp((x - r.x - 0.5) / max(r.z - 1.0, 1.0), 0.0, 1.0); + let v = clamp((y - r.y - 0.5) / max(r.w - 1.0, 1.0), 0.0, 1.0); + let horiz = clamp(min(v, 1.0 - v) * 2.0, 0.0, 1.0); + let vert = clamp(min(u, 1.0 - u) * 2.0, 0.0, 1.0); + switch pin { + case 1u: { return min(horiz, vert); } + case 2u: { return min(sqrt((u - 0.5) * (u - 0.5) + (v - 0.5) * (v - 0.5)) * 2.0, 1.0); } + case 3u: { return u; } + case 4u: { return v; } + case 5u: { return 1.0 - u; } + case 6u: { return 1.0 - v; } + case 7u: { return horiz; } + case 8u: { return vert; } + default: { return 1.0; } + } +} + +// u[0] = (wave type, pinning, seed, buffer width); f[0] = (dx, dy, height); f[2] = layer rect +// (x, y, w, h). data: the phase in cycles (along / width + phase / 2π) as per-column and per-row +// (fraction, whole cycles) pairs: fx (w), ix (w), fy (h), iy (h). +@compute @workgroup_size(16, 16) +fn dst_wavewarp(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let dx = P.f[0].x; + let dy = P.f[0].y; + let w = P.u[0].w; + let x = u32(p.x); + let y = u32(p.y); + let cf = data[x] + data[2u * w + y]; + let ci = data[w + x] + data[2u * w + u32(out_dims().y) + y]; + let disp = dst_wave_shape(P.u[0].x, cf, ci, P.u[0].z) * P.f[0].z * dst_wave_pin(P.u[0].y, q.x, q.y, P.f[2]); + textureStore(out, p, sample_bilinear(src, q.x + dy * disp, q.y - dx * disp)); +} + +// ---------------------------------------------------------------- Ripple (distort::ripple) +// u[0].x = asymmetric; f[0] = (cx, cy, radius, wave width); f[1] = (wave height, phase mod 2π) +@compute @workgroup_size(16, 16) +fn dst_ripple(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let c = P.f[0].xy; + let r = P.f[0].z; + let dx = q.x - c.x; + let dy = q.y - c.y; + let d = sqrt(dx * dx + dy * dy); + var s = q; + if (d < r && d != 0.0) { + let h = P.f[1].x; + let fall = 1.0 - d / r; + let arg = d / P.f[0].w * DST_TAU + P.f[1].y; + let o = sin(arg) * h * fall; + var t = 0.0; + if (P.u[0].x != 0u) { + t = cos(arg) * h * fall * 0.5; + } + let ux = dx / d; + let uy = dy / d; + s = vec2(q.x + ux * o - uy * t, q.y + uy * o + ux * t); + } + textureStore(out, p, sample_bilinear(src, s.x, s.y)); +} + +// ---------------------------------------------------------------- CC Lens (distort2::cc_lens) +// f[0] = (cx, cy, radius, convergence exponent) +@compute @workgroup_size(16, 16) +fn dst_cclens(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let c = P.f[0].xy; + let r = P.f[0].z; + let dx = q.x - c.x; + let dy = q.y - c.y; + let d = sqrt(dx * dx + dy * dy); + if (d >= r) { + textureStore(out, p, vec4(0.0)); + return; + } + var s = q; + if (d >= 1e-9) { + let g = powz(asin(min(d / r, 1.0)) / DST_HALF_PI, P.f[0].w); + let k = r * g / d; + s = vec2(c.x + dx * k, c.y + dy * k); + } + textureStore(out, p, sample_bilinear(src, s.x, s.y)); +} + +// ---------------------------------------------------------------- Turbulent Displace (distort2) + +// distort2::fbm. u[0].w = octave count; f[1].w = fraction of the last octave; f[3].y = falloff. +fn dst_fbm(u: f32, v: f32, z: f32, seed: u32) -> f32 { + let n = P.u[0].w; + let frac = P.f[1].w; + let falloff = P.f[3].y; + var sum = 0.0; + var norm = 0.0; + var amp = 1.0; + var f = 1.0; + for (var o = 0u; o < n; o++) { + var w = 1.0; + if (o + 1u == n && frac > 0.0) { + w = frac; + } + sum += value_noise(u * f, v * f, z + f32(o) * 5.17, seed + o * 101u) * amp * w; + norm += amp * w; + amp *= falloff; + f *= 2.0; + } + return sum / max(norm, 1e-6); +} + +// The centred noise `n(u, v, s)` (cross-faded over the evolution cycle when u[1].x is set). +// f[1] = (z, z - cycle, cycle position t). +fn dst_turb_n(u: f32, v: f32, s: u32) -> f32 { + let seed = P.u[0].z + s; + if (P.u[1].x != 0u) { + let t = P.f[1].z; + return dst_fbm(u, v, P.f[1].x, seed) * (1.0 - t) + dst_fbm(u, v, P.f[1].y, seed) * t - 0.5; + } + return dst_fbm(u, v, P.f[1].x, seed) - 0.5; +} + +// u[0] = (displacement kind, pinning, seed, octaves); u[1].x = cycle evolution; +// f[0] = (offset x, offset y, size, amount); f[1] = (z0, z1, t, last-octave fraction); +// f[2] = layer rect (x, y, w, h); f[3] = (edge, falloff) +@compute @workgroup_size(16, 16) +fn dst_turbulent(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let kind = P.u[0].x; + let amount = P.f[0].w; + let u = (q.x - P.f[0].x) / P.f[0].z; + let v = (q.y - P.f[0].y) / P.f[0].z; + var d = vec2(0.0); + switch kind { + case 1u, 2u, 4u, 5u: { + let e = 0.05; + let gx = (dst_turb_n(u + e, v, 0u) - dst_turb_n(u - e, v, 0u)) / (2.0 * e); + let gy = (dst_turb_n(u, v + e, 0u) - dst_turb_n(u, v - e, 0u)) / (2.0 * e); + let k = amount * 0.25; + if (kind == 1u || kind == 4u) { + d = vec2(gx * k, gy * k); + } else { + d = vec2(-gy * k, gx * k); + } + } + case 6u: { + d = vec2(0.0, dst_turb_n(u, v, 17u) * amount); + } + case 7u: { + d = vec2(dst_turb_n(u, v, 0u) * amount, 0.0); + } + case 8u: { + let a = dst_turb_n(u, v, 0u) * amount; + d = vec2(a, a); + } + default: { + d = vec2(dst_turb_n(u, v, 0u) * amount, dst_turb_n(u, v, 17u) * amount); + } + } + let r = P.f[2]; + let edge = P.f[3].x; + let l = clamp((q.x - r.x - 0.5) / edge, 0.0, 1.0); + let rr = clamp((r.x + r.z - 0.5 - q.x) / edge, 0.0, 1.0); + let t = clamp((q.y - r.y - 0.5) / edge, 0.0, 1.0); + let bt = clamp((r.y + r.w - 0.5 - q.y) / edge, 0.0, 1.0); + var k = 1.0; + switch P.u[0].y { + case 1u: { k = min(min(l, rr), min(t, bt)); } + case 2u: { k = min(t, bt); } + case 3u: { k = min(l, rr); } + case 4u: { k = l; } + case 5u: { k = t; } + case 6u: { k = rr; } + case 7u: { k = bt; } + default: {} + } + textureStore(out, p, sample_bilinear(src, q.x + d.x * k, q.y + d.y * k)); +} + +// ---------------------------------------------------------------- Displacement Map (distort2) + +// util::pick for a straight colour (source index in util::SRC_ORDER). +fn dst_pick(s: u32, c: vec3, a: f32) -> f32 { + switch s { + case 0u: { return c.x; } + case 1u: { return c.y; } + case 2u: { return c.z; } + case 3u: { return a; } + case 4u: { return luminance(c); } + case 5u: { return rgb_to_hsl(c).x; } + case 6u: { return rgb_to_hsl(c).z; } + case 7u: { return rgb_to_hsl(c).y; } + case 8u: { return 1.0; } + case 9u: { return 0.5; } + default: { return 0.0; } + } +} + +// u[0] = (horizontal source, vertical source, map mode: 0 self, 1 fitted layer (transparent +// outside), 2 tiled layer (edges repeat), flags: 1 wrap, 2 Center Map bounds); +// u[1] = (buffer width, buffer height); f[0] = (max horizontal, max vertical). +// data: per-column map x, per-row map y (the map layer's buffer pixels), per-column and +// per-row "inside the centred map" flags. aux = the map layer. +@compute @workgroup_size(16, 16) +fn dst_displace(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let w = P.u[1].x; + let h = P.u[1].y; + let x = u32(p.x); + let y = u32(p.y); + let px = textureLoad(src, p, 0); + let flags = P.u[0].w; + if ((flags & 2u) != 0u && (data[w + h + x] < 0.5 || data[2u * w + h + y] < 0.5)) { + textureStore(out, p, px); + return; + } + var m = px; + let mode = P.u[0].z; + if (mode == 1u) { + m = sample_bilinear(aux, data[x], data[w + y]); + } else if (mode == 2u) { + m = sample_bilinear_clamped(aux, data[x], data[w + y]); + } + var c = vec3(0.0); + let a = m.w; + if (a > 1e-6) { + c = m.xyz / a; + } + let al = max(a, 0.0); + var dx = 0.0; + var dy = 0.0; + if (P.u[0].x != 10u) { + dx = (dst_pick(P.u[0].x, c, al) - 0.5) * 2.0 * P.f[0].x; + } + if (P.u[0].y != 10u) { + dy = (dst_pick(P.u[0].y, c, al) - 0.5) * 2.0 * P.f[0].y; + } + if (dx == 0.0 && dy == 0.0) { + textureStore(out, p, px); + return; + } + var sx = f32(p.x) + 0.5 + dx; + var sy = f32(p.y) + 0.5 + dy; + if ((flags & 1u) != 0u) { + let fw = f32(w); + let fh = f32(h); + sx = sx - floor(sx / fw) * fw; + sy = sy - floor(sy / fh) * fh; + textureStore(out, p, sample_bilinear_clamped(src, sx, sy)); + } else { + textureStore(out, p, sample_bilinear(src, sx, sy)); + } +} + +// ---------------------------------------------------------------- Mesh Warp (distort2::grid_warp) +// u[0] = (row starts offset, list offset). data: 12 floats per triangle (the barycentric +// coefficients k11 k12 k21 k22 relative to vertex c, c, then the source points a, b, c), then +// per output row the start of its triangle list (height + 1 entries), then the lists (triangle +// indices in the CPU's bucket order). +@compute @workgroup_size(16, 16) +fn dst_mesh(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let rs = P.u[0].x; + let lo = P.u[0].y; + let i0 = u32(data[rs + u32(p.y)]); + let i1 = u32(data[rs + u32(p.y) + 1u]); + var cur = vec4(0.0); + for (var i = i0; i < i1; i++) { + if (cur.w > 0.0) { + break; + } + let b = u32(data[lo + i]) * 12u; + let ex = q.x - data[b + 4u]; + let ey = q.y - data[b + 5u]; + let l1 = data[b] * ex + data[b + 1u] * ey; + let l2 = data[b + 2u] * ex + data[b + 3u] * ey; + let l3 = 1.0 - l1 - l2; + // The CPU tests against -1e-9 in f64; f32 rounding needs a wider margin so pixels on + // shared edges are not dropped by both triangles. + let e = -1e-5; + if (l1 < e || l2 < e || l3 < e) { + continue; + } + let sx = data[b + 6u] * l1 + data[b + 8u] * l2 + data[b + 10u] * l3; + let sy = data[b + 7u] * l1 + data[b + 9u] * l2 + data[b + 11u] * l3; + cur = sample_bilinear(src, sx, sy); + } + textureStore(out, p, cur); +} + +// ---------------------------------------------------------------- Mosaic (misc::mosaic) +// data: column tile index (w), row tile index (h), tile column bounds (nx + 1), tile row bounds +// (ny + 1); u[0] = (w, h, nx, ny). + +// Row sums of each tile's columns: out is nx × h. +@compute @workgroup_size(16, 16) +fn dst_mosaic_rows(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let w = P.u[0].x; + let h = P.u[0].y; + let xb = w + h + u32(p.x); + var s = vec4(0.0); + for (var x = u32(data[xb]); x < u32(data[xb + 1u]); x++) { + s += textureLoad(src, vec2(i32(x), p.y), 0); + } + textureStore(out, p, s); +} + +// Tile averages: out is nx × ny (src = the row sums). +@compute @workgroup_size(16, 16) +fn dst_mosaic_tiles(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let w = P.u[0].x; + let h = P.u[0].y; + let xb = w + h + u32(p.x); + let yb = w + h + P.u[0].z + 1u + u32(p.y); + let y0 = u32(data[yb]); + let y1 = u32(data[yb + 1u]); + var s = vec4(0.0); + for (var y = y0; y < y1; y++) { + s += textureLoad(src, vec2(p.x, i32(y)), 0); + } + let n = f32((u32(data[xb + 1u]) - u32(data[xb])) * (y1 - y0)); + textureStore(out, p, s / max(n, 1.0)); +} + +// Each pixel takes its tile's average (src = the tile averages). +@compute @workgroup_size(16, 16) +fn dst_mosaic_out(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let tx = i32(data[u32(p.x)]); + let ty = i32(data[P.u[0].x + u32(p.y)]); + textureStore(out, p, textureLoad(src, vec2(tx, ty), 0)); +} + +// Sharp Colors: data = per-column / per-row source pixel; u[0].x = w. +@compute @workgroup_size(16, 16) +fn dst_mosaic_sharp(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let sx = i32(data[u32(p.x)]); + let sy = i32(data[P.u[0].x + u32(p.y)]); + textureStore(out, p, tex_get_clamped(src, sx, sy)); +} + +// ---------------------------------------------------------------- Find Edges / Emboss (misc) + +fn dst_blend_with(orig: vec4, fx: vec4, k: f32) -> vec4 { + return fx + (orig - fx) * k; +} + +fn dst_lum_at(x: i32, y: i32) -> f32 { + return luminance(tex_get_clamped(src, x, y).xyz); +} + +// u[0].x = invert; f[0].x = blend with original. +@compute @workgroup_size(16, 16) +fn dst_findedges(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let x = p.x; + let y = p.y; + let gx = dst_lum_at(x + 1, y - 1) + 2.0 * dst_lum_at(x + 1, y) + dst_lum_at(x + 1, y + 1) - dst_lum_at(x - 1, y - 1) - 2.0 * dst_lum_at(x - 1, y) + - dst_lum_at(x - 1, y + 1); + let gy = dst_lum_at(x - 1, y + 1) + 2.0 * dst_lum_at(x, y + 1) + dst_lum_at(x + 1, y + 1) - dst_lum_at(x - 1, y - 1) - 2.0 * dst_lum_at(x, y - 1) + - dst_lum_at(x + 1, y - 1); + let e = min(sqrt(gx * gx + gy * gy), 1.0); + var v = 1.0 - e; + if (P.u[0].x != 0u) { + v = e; + } + let px = textureLoad(src, p, 0); + let a = px.w; + textureStore(out, p, dst_blend_with(px, vec4(v * a, v * a, v * a, a), P.f[0].x)); +} + +// f[0] = (dx, dy, contrast, blend with original). +@compute @workgroup_size(16, 16) +fn dst_emboss(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let d = P.f[0].xy; + let a = sample_bilinear_clamped(src, q.x + d.x, q.y + d.y); + let c = sample_bilinear_clamped(src, q.x - d.x, q.y - d.y); + let l = luminance(a.xyz) - luminance(c.xyz); + let v = clamp(0.5 + l * P.f[0].z, 0.0, 1.0); + let px = textureLoad(src, p, 0); + let al = px.w; + textureStore(out, p, dst_blend_with(px, vec4(v * al, v * al, v * al, al), P.f[0].w)); +} diff --git a/crates/gpu/src/tests_fx_distort.rs b/crates/gpu/src/tests_fx_distort.rs new file mode 100644 index 00000000..9732f99c --- /dev/null +++ b/crates/gpu/src/tests_fx_distort.rs @@ -0,0 +1,119 @@ +//! Distort / stylize GPU effects vs the CPU effects (the oracle): see `fx_distort.rs`. + +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, n, opts, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +#[test] +fn twirl_bulge_ripple_wave_warp() { + effect_case("ec.distort.twirl", &[("angle", n(120.0)), ("radius", n(40.0))]); + effect_case("ec.distort.twirl", &[("angle", n(-400.0)), ("radius", n(70.0)), ("center", Value::Vec2([25.0, 18.0]))]); + effect_case("ec.distort.bulge", &[("hradius", n(30.0)), ("vradius", n(20.0)), ("height", n(1.8))]); + effect_case("ec.distort.bulge", &[("height", n(-2.5)), ("taperRadius", n(60.0)), ("pinAllEdges", on()), ("center", Value::Vec2([30.0, 25.0]))]); + effect_case("ec.distort.ripple", &[("radius", n(80.0)), ("waveWidth", n(9.0)), ("waveHeight", n(4.0))]); + effect_case("ec.distort.ripple", &[("conversion", e(1)), ("phase", n(70.0)), ("speed", n(3.0)), ("center", Value::Vec2([20.0, 30.0]))]); + effect_case("ec.distort.wavewarp", &[("height", n(6.0)), ("width", n(25.0)), ("direction", n(30.0))]); + effect_case("ec.distort.wavewarp", &[("waveType", e(2)), ("pinning", e(1)), ("phase", n(400.0)), ("speed", n(-2.0))]); + effect_case("ec.distort.wavewarp", &[("waveType", e(8)), ("pinning", e(2)), ("height", n(-5.0)), ("direction", n(200.0))]); + effect_case("ec.distort.wavewarp", &[("waveType", e(4)), ("pinning", e(7)), ("width", n(33.0))]); +} + +#[test] +fn turbulent_displace_and_cc_lens() { + effect_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]); + effect_case( + "ec.distort.turbulentdisplace", + &[("displacement", e(1)), ("complexity", n(3.5)), ("evolution", n(90.0)), ("size", n(25.0)), ("pinning", e(2)), ("resizeLayer", on())], + ); + effect_case( + "ec.distort.turbulentdisplace", + &[ + ("displacement", e(5)), + ("evolutionOptions/cycleEvolution", on()), + ("evolutionOptions/cycle", n(2.0)), + ("evolution", n(500.0)), + ("evolutionOptions/randomSeed", n(7.0)), + ("pinning", e(0)), + ("amount", n(-30.0)), + ], + ); + effect_case("ec.distort.turbulentdisplace", &[("displacement", e(8)), ("size", n(15.0)), ("pinning", e(6))]); + effect_case("ec.distort.cclens", &[("size", n(70.0))]); + effect_case("ec.distort.cclens", &[("size", n(45.0)), ("convergence", n(60.0)), ("center", Value::Vec2([30.0, 20.0]))]); + effect_case("ec.distort.cclens", &[("convergence", n(-70.0))]); +} + +#[test] +fn mesh_warp() { + // 2 rows × 3 columns: 12 vertices. + let mesh = "0,0 0,0 0,0 0,0 0,0 6,-4 -5,3 0,0 0,0 0,0 0,0 0,0"; + effect_case("ec.distort.meshwarp", &[("rows", n(2.0)), ("columns", n(3.0)), ("quality", n(3.0)), ("mesh", Value::Str(mesh.into()))]); + let mesh = "-4,-3 2,0 8,5 0,-2 9,9 1,0 -3,4 2,2 4,-6"; + effect_case("ec.distort.meshwarp", &[("rows", n(2.0)), ("columns", n(2.0)), ("quality", n(5.0)), ("mesh", Value::Str(mesh.into()))]); +} + +#[test] +fn mosaic_find_edges_emboss() { + effect_case("ec.stylize.mosaic", &[]); + effect_case("ec.stylize.mosaic", &[("horizontal", n(7.0)), ("vertical", n(3.0))]); + effect_case("ec.stylize.mosaic", &[("horizontal", n(13.0)), ("vertical", n(9.0)), ("sharpColors", on())]); + effect_case("ec.stylize.findedges", &[]); + effect_case("ec.stylize.findedges", &[("invert", on()), ("blend", n(30.0))]); + effect_case("ec.stylize.emboss", &[]); + effect_case("ec.stylize.emboss", &[("direction", n(160.0)), ("relief", n(4.0)), ("contrast", n(250.0)), ("blend", n(20.0))]); +} + +#[test] +fn displacement_map_self() { + effect_case("ec.distort.displacementmap", &[("maxHorizontal", n(8.0)), ("maxVertical", n(-5.0))]); + effect_case("ec.distort.displacementmap", &[("useForHorizontal", e(4)), ("useForVertical", e(3)), ("wrapPixelsAround", on())]); + effect_case( + "ec.distort.displacementmap", + &[("useForHorizontal", e(10)), ("useForVertical", e(6)), ("maxVertical", n(12.0)), ("expandOutput", Value::Bool(false))], + ); +} + +/// Displacement Map reading another layer (Center / Stretch / Tile), composited. +#[test] +fn displacement_map_layer() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for (behavior, wrap) in [(0, false), (1, false), (2, true), (2, false)] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut map = s.footage(50, 36); + map.switches.video = false; + let mid = s.push(map); + let mut l = s.footage(70, 44); + s.effect( + &mut l, + "ec.distort.displacementmap", + &[ + ("displacementMapLayer", Value::Layer(Some(mid.0))), + ("useForHorizontal", e(0)), + ("useForVertical", e(4)), + ("maxHorizontal", n(9.0)), + ("maxVertical", n(6.0)), + ("displacementMapBehavior", e(behavior)), + ("wrapPixelsAround", Value::Bool(wrap)), + ], + ); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let label = format!("displacement map {behavior} {wrap} {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} From 0e3b354520ec7b62979f7a0362c7f1485beff9b9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:44:36 -0400 Subject: [PATCH 5/9] M12.7: GPU particles (CC Particle World, CC Particle Systems II, Particle Playground cannon): compute-shader simulation with GPU checkpoints, CPU as the oracle Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 8 + crates/effects/src/psim.rs | 106 ++++++++++ crates/effects/src/sim.rs | 88 ++++++++- crates/effects/src/sim3.rs | 76 +++++++- crates/gpu/src/context.rs | 5 + crates/gpu/src/lib.rs | 13 ++ crates/gpu/src/particles.rs | 209 ++++++++++++++++++++ crates/gpu/src/shaders/particles.wgsl | 270 ++++++++++++++++++++++++++ crates/gpu/src/tests_particles.rs | 155 +++++++++++++++ crates/render/src/lib.rs | 8 + 10 files changed, 925 insertions(+), 13 deletions(-) create mode 100644 crates/effects/src/psim.rs create mode 100644 crates/gpu/src/particles.rs create mode 100644 crates/gpu/src/shaders/particles.wgsl create mode 100644 crates/gpu/src/tests_particles.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a5b4f346..8840a383 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -40,6 +40,7 @@ mod ocio; pub mod paint; mod perspective; mod perspective2; +pub mod psim; pub mod puppet; pub mod roto; mod sim; @@ -67,6 +68,8 @@ use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::{AuxChannels, Image}; pub use misc::{INVERT_ALPHA, INVERT_CHANNELS}; +pub use sim::particle_state; +pub use sim3::playground_state; /// Effect categories in Effects & Presets order. pub const CATEGORIES: &[&str] = &[ @@ -260,6 +263,11 @@ pub trait EffectHost: Sync { fn aux(&self) -> Option> { None } + /// A particle simulation backend (GPU particles; see [`psim`]). `None` = simulate on the + /// CPU. + fn particles(&self) -> Option<&dyn psim::ParticleSim> { + None + } } /// Extra context the renderer may supply (all optional; `Default` is "nothing known"). diff --git a/crates/effects/src/psim.rs b/crates/effects/src/psim.rs new file mode 100644 index 00000000..aabbcdce --- /dev/null +++ b/crates/effects/src/psim.rs @@ -0,0 +1,106 @@ +//! Pluggable particle simulation (GPU particles, M12.7). +//! +//! The stepped particle systems (CC Particle World, CC Particle Systems II and Particle +//! Playground's Cannon without interacting forces) evolve every particle independently: births +//! come from a rate (the same `carry` arithmetic as the CPU step), and each particle integrates +//! its own motion with the same fixed step. An accelerator ([`ParticleSim`], reached through +//! [`crate::EffectHost::particles`]) can therefore simulate all of them in parallel and return +//! the state the CPU's `SimCache` would reach. The CPU stays the oracle: anything the backend +//! declines (interacting forces, caps that would drop births) simulates on the CPU. + +/// CC Particle World / CC Particle Systems II physics (see `sim::Phys`), with the per-step and +/// per-spawn constants already evaluated on the CPU (bit-identical to the CPU's). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct EngineDesc { + pub life: f32, + pub producer: [f32; 3], + pub radius: [f32; 3], + /// Animation (0 Explosive … 9 Fractal Uni). + pub anim: u32, + pub speed: f32, + pub gravity: [f32; 3], + /// Normalised direction axis. + pub axis: [f32; 3], + pub extra: f32, + /// Cone Axis spread factor `|tan(extra angle / 2)|` (≤ 20). + pub cone: f32, + /// Velocity damping per step `exp(−drag · dt)`. + pub damp: f32, + pub seed: u32, + /// 2D systems keep z = 0. + pub flat: bool, +} + +/// Particle Playground's Cannon with gravity only (see `sim3::Pg`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CannonDesc { + pub pos: [f32; 2], + pub barrel_angle: f32, + pub barrel_radius: f32, + pub dir_spread: f32, + pub vel: f32, + pub vel_spread: f32, + pub gravity: [f32; 2], + pub grav_spread: f32, + pub seed: u32, + /// Particles leaving `[x0, y0, x1, y1]` are removed. + pub bounds: [f32; 4], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ParticleSystem { + Engine(EngineDesc), + Cannon(CannonDesc), +} + +/// One simulation request: the state after `steps` steps of system `key`. +#[derive(Clone, Debug)] +pub struct SimRequest<'a> { + /// Identifies the system (parameters, seed); checkpoints are reused per key. + pub key: u64, + pub steps: u64, + /// Birth step of every particle id born before `steps` (ascending). + pub births: &'a [u32], + pub system: ParticleSystem, +} + +/// A live particle (ascending ids, as the CPU state keeps them). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SimParticle { + pub p: [f32; 3], + pub v: [f32; 3], + pub age: f32, + pub life: f32, + pub rnd: f32, + pub id: u32, +} + +/// A particle simulation backend (the GPU). +pub trait ParticleSim: Send + Sync { + /// The live particles after `req.steps` steps, or `None` (simulate on the CPU). + fn simulate(&self, req: &SimRequest) -> Option>; +} + +/// Birth step of every particle born in the first `steps` steps at `rate` particles per second +/// (the CPU step's `carry` arithmetic, exactly). `None` when more than `cap` would be born (the +/// CPU drops births at its live-particle cap, which an independent simulation can't know). +pub fn births(rate: f64, steps: u64, cap: usize) -> Option> { + let per = rate / SPS; + if per * steps as f64 > cap as f64 + 2.0 { + return None; + } + let mut out = Vec::new(); + let mut carry = 0.0f64; + for i in 0..steps { + carry += per; + let n = carry.floor() as u32; + carry -= n as f64; + for _ in 0..n { + out.push(i as u32); + } + } + (out.len() <= cap).then_some(out) +} + +/// Steps per second of the stepped simulations. +pub const SPS: f64 = crate::sim::SPS; diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 7f90af6b..18beeb76 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -818,6 +818,60 @@ impl Phys { } } +impl Phys { + /// The backend description (per-step and per-spawn constants evaluated here, in f32). + pub(crate) fn desc(&self) -> crate::psim::EngineDesc { + let mut drag = self.resistance; + if self.anim == 3 { + drag += 3.0 * self.extra.max(0.1); + } + let dt = (1.0 / SPS) as f32; + crate::psim::EngineDesc { + life: self.life, + producer: self.producer, + radius: self.radius, + anim: self.anim, + speed: self.speed, + gravity: self.gravity, + axis: norm3(self.axis), + extra: self.extra, + cone: (self.extra_angle.to_radians() * 0.5).tan().abs().min(20.0), + damp: (-drag * dt).exp(), + seed: self.seed, + flat: self.flat, + } + } +} + +/// The state at layer time `ctx.time` from the host's particle backend (GPU particles), or +/// `None` (simulate on the CPU). +fn accelerated(ctx: &EffectCtx, key: u64, phys: &Phys) -> Option> { + let backend = ctx.env.host?.particles()?; + let steps = steps_at(ctx.time); + let births = crate::psim::births(phys.rate, steps, phys.max)?; + let req = crate::psim::SimRequest { key, steps, births: &births, system: crate::psim::ParticleSystem::Engine(phys.desc()) }; + let parts = backend.simulate(&req)?; + Some(std::sync::Arc::new(PState { + parts: parts.into_iter().map(|q| Particle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect(), + carry: 0.0, + next_id: births.len() as u32, + })) +} + +/// The live particles of a stepped particle effect (`ec.sim.ccparticleworld`, +/// `ec.sim.ccparticlesystems2`) at `ctx.time`: from the host's particle backend when it has +/// one (GPU particles), else from the CPU simulation. For comparing the two. +pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option> { + let (phys, cache, salt) = match id { + "ec.sim.ccparticleworld" => (pw_phys(ctx), &PW_CACHE, 1), + "ec.sim.ccparticlesystems2" => (ps2_phys(ctx), &PS2_CACHE, 2), + _ => return None, + }; + let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt); + let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(cache, key, ctx.time, &phys)); + Some(st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect()) +} + /// Run a particle system to layer time `t` through `cache`. pub(crate) fn simulate(cache: &SimCache, key: u64, t: f64, phys: &Phys) -> std::sync::Arc { cache.run(key, steps_at(t), PState::default, |st, i| phys.step(st, i)) @@ -859,10 +913,10 @@ fn type_sprite(kind: u32, x: f32, y: f32, r: f32, c: [f32; 4], vx: f32, vy: f32, static PW_CACHE: SimCache = SimCache::new(6); -fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// CC Particle World's physics. +fn pw_phys(ctx: &EffectCtx) -> Phys { let pr = ctx.params; - let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); - let phys = Phys { + Phys { rate: pr.f("birthRate").max(0.0) * 120.0, life: pr.f("longevity").max(0.0) as f32, producer: [pr.f("producer/producerX") as f32, pr.f("producer/producerY") as f32, pr.f("producer/producerZ") as f32], @@ -882,11 +936,18 @@ fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf { seed: (pr.f("extras/randomSeed") as u32).wrapping_mul(0x9e3779b9) ^ ctx.seed, max: 40_000, flat: false, - }; + } +} + +fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let phys = pw_phys(ctx); if phys.rate <= 0.0 && ctx.time <= 0.0 { return b; } - let st = simulate(&PW_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1), ctx.time, &phys); + let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1); + let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PW_CACHE, key, ctx.time, &phys)); let kind = pr.e("particle/particleType"); let birth = pr.color("particle/birthColor"); let death = pr.color("particle/deathColor"); @@ -936,13 +997,14 @@ fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf { static PS2_CACHE: SimCache = SimCache::new(6); -fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// CC Particle Systems II's physics. +fn ps2_phys(ctx: &EffectCtx) -> Phys { let pr = ctx.params; let lh = ctx.layer_size[1] as f32; let unit = lh.max(1.0); let pos = pr.v2("producer/position"); let dir = (pr.f("physics/direction") as f32).to_radians(); - let phys = Phys { + Phys { rate: pr.f("birthRate").max(0.0) * 60.0, life: pr.f("longevity").max(0.0) as f32, producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0], @@ -958,8 +1020,16 @@ fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf { seed: (pr.f("randomSeed") as u32).wrapping_mul(0x85ebca6b) ^ ctx.seed, max: 40_000, flat: true, - }; - let st = simulate(&PS2_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 2), ctx.time, &phys); + } +} + +fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let lh = ctx.layer_size[1] as f32; + let unit = lh.max(1.0); + let phys = ps2_phys(ctx); + let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 2); + let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys)); let kind = pr.e("particle/particleType"); let birth = pr.color("particle/birthColor"); let death = pr.color("particle/deathColor"); diff --git a/crates/effects/src/sim3.rs b/crates/effects/src/sim3.rs index 5560917a..f7425df7 100644 --- a/crates/effects/src/sim3.rs +++ b/crates/effects/src/sim3.rs @@ -231,6 +231,38 @@ impl Pg { static PG_CACHE: SimCache = SimCache::new(6); +/// Particle Playground cannons with gravity only, simulated by the host's particle backend. +fn pg_accelerated(ctx: &EffectCtx, key: u64, pg: &Pg) -> Option> { + let backend = ctx.env.host?.particles()?; + let steps = steps_at(ctx.time); + let births = crate::psim::births(pg.rate, steps, 60_000)?; + let desc = crate::psim::CannonDesc { + pos: pg.pos, + barrel_angle: pg.barrel_angle, + barrel_radius: pg.barrel_radius, + dir_spread: pg.dir_spread, + vel: pg.vel, + vel_spread: pg.vel_spread, + gravity: pg.gravity, + grav_spread: pg.grav_spread, + seed: pg.seed, + bounds: pg.bounds, + }; + let req = crate::psim::SimRequest { key, steps, births: &births, system: crate::psim::ParticleSystem::Cannon(desc) }; + let parts = backend.simulate(&req)?; + Some(std::sync::Arc::new(PgState { + parts: parts + .into_iter() + .map(|q| { + let born = pg.spawn(q.id); + PgParticle { p: [q.p[0], q.p[1]], v: [q.v[0], q.v[1]], ..born } + }) + .collect(), + carry: 0.0, + next_id: births.len() as u32, + })) +} + fn image_key(img: &Image) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -244,14 +276,15 @@ fn image_key(img: &Image) -> u64 { h.finish() } -fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// Particle Playground's producers and forces. +fn pg_of(ctx: &EffectCtx) -> Pg { let pr = ctx.params; let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); let grav_dir = (pr.f("gravity/gravityDirection") as f32).to_radians(); let gf = pr.f("gravity/gravityForce") as f32; let wall = (pr.f("wall/wallBoundary").round() as usize).checked_sub(1).and_then(|i| ctx.env.masks.get(i)).map(|m| m.points.clone()); let mapper = if pr.b("mapperEnabled") { ctx.layer_param("persistentPropertyMapper/useLayerAsMap", true).map(|lp| MapImg::from(ctx, lp)) } else { None }; - let pg = Pg { + Pg { cannon: pr.b("cannonEnabled"), pos: { pr.v2("cannon/cannonPosition").map(|v| v as f32) }, barrel_angle: pr.f("cannon/barrelAngle") as f32, @@ -275,7 +308,34 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { ], seed: (pr.f("randomSeed") as u32).wrapping_mul(0x9e37_79b9) ^ ctx.seed, bounds: [-lw * 2.0, -lh * 2.0, lw * 3.0, lh * 3.0], - }; + } +} + +/// The live particles of a Particle Playground whose cannon is the only producer and force +/// besides gravity, at `ctx.time`: from the host's particle backend when it has one (GPU +/// particles), else from the CPU simulation (`None` for other set-ups). For comparing the two. +pub fn playground_state(ctx: &EffectCtx) -> Option> { + let pg = pg_of(ctx); + let pr = ctx.params; + let grid = pr.b("gridEnabled") && pr.f("grid/particlesAcross").round() > 0.0 && pr.f("grid/particlesDown").round() > 0.0; + let exploder = pr.b("exploderEnabled") && pr.get("layerExploder/explodeLayer").and_then(Value::as_layer).is_some(); + if !pg.cannon || grid || exploder || (pg.repel != 0.0 && pg.repel_radius > 0.0) || pg.wall.is_some() || pg.mapper.is_some() { + return None; + } + let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 0x7067); + let st = pg_accelerated(ctx, key, &pg).unwrap_or_else(|| PG_CACHE.run(key, steps_at(ctx.time), PgState::default, |st, _| pg.step(st))); + Some( + st.parts + .iter() + .map(|q| crate::psim::SimParticle { p: [q.p[0], q.p[1], 0.0], v: [q.v[0], q.v[1], 0.0], age: 0.0, life: 0.0, rnd: 0.0, id: q.id }) + .collect(), + ) +} + +fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let pg = pg_of(ctx); // Initial particles: Grid and Layer Exploder. let grid_on = pr.b("gridEnabled"); let across = pr.f("grid/particlesAcross").max(0.0).round() as u32; @@ -347,7 +407,15 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { } st }; - let st = PG_CACHE.run(key, steps_at(ctx.time), init, |st, _| pg.step(st)); + // A cannon with gravity only: every particle moves on its own (GPU particles). + let independent = pg.cannon + && !(grid_on && across > 0 && down > 0) + && exploder.is_none() + && !(pg.repel != 0.0 && pg.repel_radius > 0.0) + && pg.wall.is_none() + && pg.mapper.is_none(); + let gpu = if independent { pg_accelerated(ctx, key, &pg) } else { None }; + let st = gpu.unwrap_or_else(|| PG_CACHE.run(key, steps_at(ctx.time), init, |st, _| pg.step(st))); let s = b.scale as f32; let sprites: Vec = st .parts diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index e7cda93e..7dd14ba8 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -102,6 +102,9 @@ pub struct GpuContext { uploads: Mutex, /// Advanced 3D render pipelines (built on first use). pub(crate) adv3d: std::sync::OnceLock, + /// GPU particle pipeline (built on first use) and simulation checkpoints. + pub(crate) particles: crate::particles::PipesCell, + pub(crate) particle_states: crate::particles::StatesCell, } fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { @@ -263,6 +266,8 @@ impl GpuContext { dummy_buf, uploads: Mutex::new(Uploads::default()), adv3d: std::sync::OnceLock::new(), + particles: Default::default(), + particle_states: Default::default(), }) } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 9ebce209..83f27b26 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -29,6 +29,7 @@ mod fx_color; mod fx_distort; mod fx_generate; mod ops; +mod particles; mod walk; use std::sync::Arc; @@ -139,6 +140,16 @@ impl Accelerator for Gpu { fn raster_3d(&self, scene: &effectcraft_render::three_d::adv::Scene) -> Option { adv3d::render(&self.ctx, scene) } + + fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> { + self.ctx.can_readback().then_some(self as &dyn effectcraft_effects::psim::ParticleSim) + } +} + +impl effectcraft_effects::psim::ParticleSim for Gpu { + fn simulate(&self, req: &effectcraft_effects::psim::SimRequest) -> Option> { + particles::simulate(&self.ctx, req) + } } #[cfg(test)] @@ -149,3 +160,5 @@ mod tests_3d; mod tests_adjust; #[cfg(test)] mod tests_adv3d; +#[cfg(test)] +mod tests_particles; diff --git a/crates/gpu/src/particles.rs b/crates/gpu/src/particles.rs new file mode 100644 index 00000000..669a4ba0 --- /dev/null +++ b/crates/gpu/src/particles.rs @@ -0,0 +1,209 @@ +//! GPU particles (`particles.wgsl`): the stepped particle systems of `effectcraft-effects` +//! ([`effectcraft_effects::psim`]) simulated with one invocation per particle. States stay on +//! the GPU as checkpoints (per system key, like the CPU's `SimCache`), so playback advances from +//! the previous frame's state instead of simulating from layer time 0. + +use std::sync::{Mutex, OnceLock}; + +use effectcraft_effects::psim::{ParticleSystem, SimParticle, SimRequest}; +use wgpu::util::DeviceExt; + +use crate::context::GpuContext; + +/// Bytes of one particle state (`PState` in the shader). +const STATE_BYTES: u64 = 48; +/// Checkpoints kept (all keys). +const CHECKPOINTS: usize = 24; + +pub(crate) struct Pipes { + bgl: wgpu::BindGroupLayout, + pipe: wgpu::ComputePipeline, +} + +struct Checkpoint { + key: u64, + steps: u64, + n: u32, + buf: wgpu::Buffer, + used: u64, +} + +#[derive(Default)] +pub(crate) struct Checkpoints { + list: Vec, + clock: u64, +} + +fn pipes(g: &GpuContext) -> &Pipes { + g.particles.get_or_init(|| { + let d = &g.device; + let src = [include_str!("shaders/common.wgsl"), include_str!("shaders/kernels.wgsl"), include_str!("shaders/particles.wgsl")].concat(); + let module = + d.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft particles"), source: wgpu::ShaderSource::Wgsl(src.into()) }); + let buffer = |binding: u32, ty: wgpu::BufferBindingType| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { ty, has_dynamic_offset: false, min_binding_size: None }, + count: None, + }; + let bgl = d.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("effectcraft particles"), + entries: &[ + buffer(0, wgpu::BufferBindingType::Uniform), + buffer(1, wgpu::BufferBindingType::Storage { read_only: true }), + buffer(2, wgpu::BufferBindingType::Storage { read_only: true }), + buffer(3, wgpu::BufferBindingType::Storage { read_only: false }), + ], + }); + let layout = d.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("effectcraft particles"), + bind_group_layouts: &[None, Some(&bgl)], + immediate_size: 0, + }); + let pipe = d.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("particles"), + layout: Some(&layout), + module: &module, + entry_point: Some("particles"), + compilation_options: Default::default(), + cache: None, + }); + Pipes { bgl, pipe } + }) +} + +/// The shader's parameter block. +fn uniforms(req: &SimRequest, n: u32, s0: u64, prev_n: u32) -> Vec { + let dt = (1.0 / effectcraft_effects::psim::SPS) as f32; + let mut u = [n, s0 as u32, req.steps as u32, 0]; + let u2; + let mut f = [[0.0f32; 4]; 6]; + match &req.system { + ParticleSystem::Engine(e) => { + u2 = [e.anim, e.flat as u32, e.seed, prev_n]; + f[0] = [e.life, e.speed, e.extra, e.cone]; + f[1] = [e.producer[0], e.producer[1], e.producer[2], 0.0]; + f[2] = [e.radius[0], e.radius[1], e.radius[2], 0.0]; + f[3] = [e.gravity[0], e.gravity[1], e.gravity[2], 0.0]; + f[4] = [e.axis[0], e.axis[1], e.axis[2], 0.0]; + f[5] = [e.damp, dt, 0.0, 0.0]; + } + ParticleSystem::Cannon(c) => { + u[3] = 1; + u2 = [0, 1, c.seed, prev_n]; + f[0] = [c.pos[0], c.pos[1], c.barrel_angle, c.barrel_radius]; + f[1] = [c.dir_spread, c.vel, c.vel_spread, c.grav_spread]; + f[2] = [c.gravity[0], c.gravity[1], dt, 0.0]; + f[3] = c.bounds; + } + } + let mut out = Vec::with_capacity(128); + for x in u.iter().chain(&u2) { + out.extend_from_slice(&x.to_le_bytes()); + } + for x in f.iter().flatten() { + out.extend_from_slice(&x.to_le_bytes()); + } + out +} + +/// Simulate `req` (see [`effectcraft_effects::psim::ParticleSim`]). +pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option> { + if !g.can_readback() || req.steps > u32::MAX as u64 { + return None; + } + let n = req.births.len() as u32; + if n == 0 { + return Some(vec![]); + } + let size = n as u64 * STATE_BYTES; + if size > g.device.limits().max_storage_buffer_binding_size { + return None; + } + let p = pipes(g); + // The latest checkpoint at or before the target step. + let (s0, prev, prev_n) = { + let mut c = g.particle_states.lock().ok()?; + c.clock += 1; + let now = c.clock; + match c.list.iter_mut().filter(|k| k.key == req.key && k.steps <= req.steps).max_by_key(|k| k.steps) { + Some(k) => { + k.used = now; + (k.steps, Some(k.buf.clone()), k.n) + } + None => (0, None, 0), + } + }; + let d = &g.device; + let births: Vec = req.births.iter().flat_map(|b| b.to_le_bytes()).collect(); + let births = d.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("births"), contents: &births, usage: wgpu::BufferUsages::STORAGE }); + let prev = prev.unwrap_or_else(|| { + d.create_buffer(&wgpu::BufferDescriptor { label: Some("particles"), size: STATE_BYTES, usage: wgpu::BufferUsages::STORAGE, mapped_at_creation: false }) + }); + let next = d.create_buffer(&wgpu::BufferDescriptor { + label: Some("particles"), + size, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let ub = d.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("particles"), + contents: &uniforms(req, n, s0, prev_n), + usage: wgpu::BufferUsages::UNIFORM, + }); + let bg = d.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &p.bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: births.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: prev.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: next.as_entire_binding() }, + ], + }); + let read = d.create_buffer(&wgpu::BufferDescriptor { + label: Some("particles readback"), + size, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = d.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("particles") }); + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("particles"), timestamp_writes: None }); + pass.set_pipeline(&p.pipe); + pass.set_bind_group(1, &bg, &[]); + pass.dispatch_workgroups(n.div_ceil(64), 1, 1); + } + enc.copy_buffer_to_buffer(&next, 0, &read, 0, size); + g.queue.submit([enc.finish()]); + let (tx, rx) = std::sync::mpsc::channel(); + read.map_async(wgpu::MapMode::Read, .., move |r| { + let _ = tx.send(r); + }); + g.device.poll(wgpu::PollType::wait_indefinitely()).ok()?; + rx.recv().ok()?.ok()?; + let view = read.get_mapped_range(..).ok()?; + let f: Vec = view.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect(); + drop(view); + read.unmap(); + let parts = f + .chunks_exact(12) + .enumerate() + .filter(|(_, s)| s[9] != 0.0) + .map(|(id, s)| SimParticle { p: [s[0], s[1], s[2]], v: [s[4], s[5], s[6]], age: s[3], life: s[7], rnd: s[8], id: id as u32 }) + .collect(); + // Keep the new state as a checkpoint. + if let Ok(mut c) = g.particle_states.lock() { + let now = c.clock; + c.list.retain(|k| !(k.key == req.key && k.steps == req.steps)); + c.list.push(Checkpoint { key: req.key, steps: req.steps, n, buf: next, used: now }); + while c.list.len() > CHECKPOINTS { + let Some(i) = c.list.iter().enumerate().min_by_key(|(_, k)| k.used).map(|(i, _)| i) else { break }; + c.list.remove(i); + } + } + Some(parts) +} + +pub(crate) type PipesCell = OnceLock; +pub(crate) type StatesCell = Mutex; diff --git a/crates/gpu/src/shaders/particles.wgsl b/crates/gpu/src/shaders/particles.wgsl new file mode 100644 index 00000000..694d6fb6 --- /dev/null +++ b/crates/gpu/src/shaders/particles.wgsl @@ -0,0 +1,270 @@ +// GPU particles: effects::psim systems, one invocation per particle id. Mirrors the CPU step +// (effects::sim::Phys::spawn / step, effects::sim3::Pg::spawn / step) operation for operation. +// +// Concatenated after common.wgsl and kernels.wgsl (hash_noise, value_noise, powz); bindings in +// group 1 (group 0 is unused here). + +struct PSim { + // Particles, from step, to step, system (0 engine, 1 cannon). + u: vec4, + // Animation, flat, seed, particles in `prev`. + u2: vec4, + // Engine: f[0] = (life, speed, extra, cone), f[1] = producer, f[2] = radius, + // f[3] = gravity, f[4] = axis, f[5] = (damp, dt). + // Cannon: f[0] = (pos.x, pos.y, barrel angle, barrel radius), f[1] = (dir spread, vel, + // vel spread, grav spread), f[2] = (gravity.x, gravity.y, dt), f[3] = bounds. + f: array, 6>, +} + +struct PState { + // Position, age. + p: vec4, + // Velocity, life. + v: vec4, + // rnd, alive (1 / 0), id. + m: vec4, +} + +@group(1) @binding(0) var S: PSim; +@group(1) @binding(1) var births: array; +@group(1) @binding(2) var prev: array; +@group(1) @binding(3) var next: array; + +const PS_TAU: f32 = 6.2831855; + +fn ps_h(i: u32, k: u32) -> f32 { + return hash_noise(i, k, S.u2.z); +} + +fn ps_hs(i: u32, k: u32) -> f32 { + return hash_noise(i, k, S.u2.z) * 2.0 - 1.0; +} + +// sim::norm3. +fn ps_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); +} + +// sim::rand_dir. +fn ps_rand_dir(i: u32, k: u32, flat: bool) -> vec3 { + if (flat) { + let a = ps_h(i, k) * PS_TAU; + return vec3(cos(a), sin(a), 0.0); + } + let z = ps_hs(i, k); + let a = ps_h(i, k + 1u) * PS_TAU; + let r = sqrt(max(1.0 - z * z, 0.0)); + return vec3(r * cos(a), r * sin(a), z); +} + +struct Part { + p: vec3, + v: vec3, + age: f32, + life: f32, + rnd: f32, +} + +// sim::Phys::spawn (+ the step's sub-step birth offset). +fn ps_spawn_engine(id: u32) -> Part { + let flat = S.u2.y != 0u; + let anim = S.u2.x; + let producer = S.f[1].xyz; + let radius = S.f[2].xyz; + let u = ps_rand_dir(id, 1u, flat); + var e = 1.0 / 3.0; + if (flat) { + e = 0.5; + } + let rr = powz(ps_h(id, 3u), e); + var p = producer + u * radius * rr; + if (flat) { + p.z = 0.0; + } + let ax = S.f[4].xyz; + let jitter = ps_rand_dir(id, 5u, flat); + var dir = jitter; + switch anim { + case 1u, 9u: { + dir = ps_norm3(ax + jitter * 0.08); + } + case 2u: { + let k = S.f[0].w; + dir = ps_norm3(ax + jitter * k * ps_h(id, 8u)); + } + case 5u: { + var z = jitter.z; + if (flat) { + z = 0.0; + } + dir = vec3(jitter.x, 0.0, z); + } + case 6u: { + dir = ps_norm3(vec3(jitter.x * 0.25, -1.0, jitter.z * 0.25)); + } + case 7u: { + dir = ps_norm3(vec3(1.0, jitter.y * 0.05, jitter.z * 0.05)); + } + default: {} + } + let sp = S.f[0].y * (0.5 + 0.5 * ps_h(id, 9u)); + var v = dir * sp; + if (flat) { + v.z = 0.0; + } + let life = S.f[0].x * (0.8 + 0.4 * ps_h(id, 10u)); + let dt = S.f[5].y; + let frac = ps_h(id, 12u) * dt; + return Part(p + v * frac, v, frac, life, ps_h(id, 11u)); +} + +// sim::Phys::step for one particle at step `j`. +fn ps_step_engine(q: Part, j: u32) -> Part { + let flat = S.u2.y != 0u; + let anim = S.u2.x; + let seed = S.u2.z; + let dt = S.f[5].y; + let t = f32(j) * dt; + let producer = S.f[1].xyz; + let gravity = S.f[3].xyz; + let extra = S.f[0].z; + var a = gravity; + switch anim { + case 4u: { + let dx = q.p.x - producer.x; + let dz = q.p.z - producer.z; + let k = 4.0 * extra; + a.x += -dz * k; + a.z += dx * k; + if (flat) { + let dy = q.p.y - producer.y; + a.x += -dy * k; + a.y += dx * k; + } + } + case 5u: { + let dx = q.p.x - producer.x; + let dy = q.p.y - producer.y; + let k = 6.0 * extra; + a.x += -dy * k - dx * 2.0; + a.y += dx * k - dy * 2.0; + } + case 6u: { + let n = value_noise(q.p.x * 8.0, q.p.y * 8.0, t * 3.0, seed) - 0.5; + a.x += n * 4.0 * extra; + a.y -= gravity.y * 1.5 + 0.3 * extra; + } + case 8u, 9u: { + let f = 6.0 * extra; + a.x += (value_noise(q.p.x * 5.0, q.p.y * 5.0, q.p.z * 5.0 + t, seed) - 0.5) * f; + a.y += (value_noise(q.p.x * 5.0 + 17.0, q.p.y * 5.0, q.p.z * 5.0 + t, seed) - 0.5) * f; + if (!flat) { + a.z += (value_noise(q.p.x * 5.0, q.p.y * 5.0 + 31.0, q.p.z * 5.0 + t, seed) - 0.5) * f; + } + } + default: {} + } + let damp = S.f[5].x; + var o = q; + o.v = (q.v + a * dt) * damp; + o.p = q.p + o.v * dt; + if (flat) { + o.p.z = 0.0; + o.v.z = 0.0; + } + o.age = q.age + dt; + return o; +} + +// sim3::Pg::spawn (+ the sub-step birth offset). +fn ps_spawn_cannon(id: u32) -> Part { + let pos = S.f[0].xy; + let angle = S.f[0].z; + let r = S.f[0].w; + let spread = S.f[1].x; + let vel = S.f[1].y; + let vel_spread = S.f[1].z; + let a = radians(angle + ps_hs(id, 1u) * spread * 0.5); + let d = vec2(sin(a), -cos(a)); + let speed = max(vel + ps_hs(id, 3u) * vel_spread, 0.0); + var off: vec2; + if (r >= 0.0) { + off = vec2(ps_hs(id, 2u) * r, ps_hs(id, 4u) * r); + } else { + let aa = ps_h(id, 5u) * PS_TAU; + let rr = sqrt(ps_h(id, 6u)) * -r; + off = vec2(cos(aa) * rr, sin(aa) * rr); + } + let v = d * speed; + let dt = S.f[2].z; + let frac = ps_h(id, 9u) * dt; + let p = pos + off + v * frac; + return Part(vec3(p, 0.0), vec3(v, 0.0), 0.0, 0.0, 0.0); +} + +// sim3::Pg::step for one particle (gravity only); returns false when it leaves the bounds. +fn ps_step_cannon(q: ptr, id: u32) -> bool { + let dt = S.f[2].z; + let spread = S.f[1].w; + var g = 1.0; + if (spread > 0.0) { + g = 1.0 + (ps_h(id, 7u) * 2.0 - 1.0) * spread; + } + let a = S.f[2].xy * g; + let v = ((*q).v.xy + a * dt) * 1.0; + let p = (*q).p.xy + v * dt; + (*q).v = vec3(v, 0.0); + (*q).p = vec3(p, 0.0); + let b = S.f[3]; + return p.x > b.x && p.x < b.z && p.y > b.y && p.y < b.w; +} + +@compute @workgroup_size(64, 1) +fn particles(@builtin(global_invocation_id) gid: vec3) { + let id = gid.x; + if (id >= S.u.x) { + return; + } + let s0 = S.u.y; + let s1 = S.u.z; + let cannon = S.u.w == 1u; + let b = births[id]; + var q: Part; + var j: u32; + if (b < s0 && id < S.u2.w) { + let o = prev[id]; + if (o.m.y == 0.0) { + next[id] = o; + return; + } + q = Part(o.p.xyz, o.v.xyz, o.p.w, o.v.w, o.m.x); + j = s0; + } else { + if (cannon) { + q = ps_spawn_cannon(id); + } else { + q = ps_spawn_engine(id); + } + j = b + 1u; + } + var alive = true; + for (; j < s1; j++) { + if (cannon) { + if (!ps_step_cannon(&q, id)) { + alive = false; + break; + } + } else { + q = ps_step_engine(q, j); + if (q.age >= q.life) { + alive = false; + break; + } + } + } + next[id] = PState(vec4(q.p, q.age), vec4(q.v, q.life), vec4(q.rnd, select(0.0, 1.0, alive), 0.0, 0.0)); +} diff --git a/crates/gpu/src/tests_particles.rs b/crates/gpu/src/tests_particles.rs new file mode 100644 index 00000000..e3ef97a8 --- /dev/null +++ b/crates/gpu/src/tests_particles.rs @@ -0,0 +1,155 @@ +//! GPU particles vs the CPU simulation (the oracle): CC Particle World, CC Particle Systems II +//! and Particle Playground's cannon at fixed seeds must give the same live particles (same +//! ids) at positions within tolerance, from scratch and when advancing / seeking from GPU +//! checkpoints. + +use effectcraft_effects::psim::{ParticleSim, SimParticle}; +use effectcraft_effects::{EffectCtx, EffectEnv, EffectHost, LayerPixels, Params}; +use effectcraft_keyframe::Value; + +use crate::Gpu; +use crate::tests::{gpu, n}; + +struct Host(&'static Gpu); + +impl EffectHost for Host { + fn layer(&self, _id: u64, _masks_and_effects: bool) -> Option { + None + } + fn audio(&self, _id: u64, _start: f64, _frames: usize, _rate: u32) -> Option> { + None + } + fn particles(&self) -> Option<&dyn ParticleSim> { + Some(self.0) + } +} + +fn params(id: &str, vals: &[(&str, Value)]) -> Params { + let spec = effectcraft_effects::find(id).unwrap(); + let size = [320.0, 240.0]; + let mut p = Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() }; + for (k, v) in vals { + assert!(p.values.contains_key(*k), "{id}: no {k}"); + p.values.insert(k.to_string(), v.clone()); + } + p +} + +/// (CPU, GPU) particles of effect `id` at layer time `t`. +fn states(id: &str, p: &Params, t: f64, host: &Host) -> (Vec, Vec) { + let ctx = |env| EffectCtx { params: p, time: t, layer_size: [320.0, 240.0], seed: 5, adjustment: false, env }; + let run = + |c: &EffectCtx| if id == "ec.sim.particleplayground" { effectcraft_effects::playground_state(c) } else { effectcraft_effects::particle_state(id, c) }; + let cpu = run(&ctx(EffectEnv::default())).expect("cpu state"); + let gpu = run(&ctx(EffectEnv { host: Some(host), ..Default::default() })).expect("gpu state"); + (cpu, gpu) +} + +/// Same ids alive (up to `id_slack` particles dying a step apart), positions within `tol` +/// (world units) for 99 % of them and on average within `tol / 10`. +fn agree(label: &str, cpu: &[SimParticle], gpu: &[SimParticle], tol: f32, id_slack: usize) { + assert!(cpu.len() > 20, "{label}: the scene has particles ({})", cpu.len()); + let gi: std::collections::HashMap = gpu.iter().map(|q| (q.id, q)).collect(); + let mut missing = 0; + let mut errs = vec![]; + for q in cpu { + match gi.get(&q.id) { + Some(g) => errs.push((0..3).map(|k| (q.p[k] - g.p[k]).abs()).fold(0.0f32, f32::max)), + None => missing += 1, + } + } + let extra = gpu.len() + missing - cpu.len(); + assert!(missing + extra <= id_slack, "{label}: {missing} CPU particles missing on the GPU, {extra} extra (of {})", cpu.len()); + errs.sort_by(f32::total_cmp); + let mean = errs.iter().sum::() / errs.len() as f32; + let p99 = errs[(errs.len() * 99 / 100).min(errs.len() - 1)]; + eprintln!("{label}: {} particles, mean err {mean:.2e}, p99 {p99:.2e}, max {:.2e}", cpu.len(), errs.last().unwrap()); + assert!(p99 <= tol && mean <= tol / 10.0, "{label}: positions differ (mean {mean}, p99 {p99})"); +} + +#[test] +fn particle_world_matches_the_cpu() { + let Some(g) = gpu() else { return }; + let host = Host(g); + for anim in [0u32, 1, 2, 3, 4, 5, 6, 7, 8, 9] { + let p = params( + "ec.sim.ccparticleworld", + &[ + ("physics/animation", Value::Enum(anim)), + ("birthRate", n(2.0)), + ("longevity", n(1.2)), + ("physics/extra", n(0.6)), + ("physics/resistance", n(0.5)), + ("extras/randomSeed", n(7.0)), + ], + ); + let (cpu, gpu) = states("ec.sim.ccparticleworld", &p, 1.5, &host); + // Fractal / fire animations sample value noise along the path: rounding differences can + // grow, so they get a looser bound. + let tol = if matches!(anim, 6 | 8 | 9) { 1e-3 } else { 1e-4 }; + agree(&format!("particle world anim {anim}"), &cpu, &gpu, tol, cpu.len() / 100 + 1); + } +} + +#[test] +fn particle_systems_ii_matches_the_cpu() { + let Some(g) = gpu() else { return }; + let host = Host(g); + for anim in [0u32, 1, 2, 4, 5, 8] { + let p = params( + "ec.sim.ccparticlesystems2", + &[("physics/animation", Value::Enum(anim)), ("birthRate", n(3.0)), ("longevity", n(1.5)), ("physics/gravity", n(0.8)), ("randomSeed", n(3.0))], + ); + let (cpu, gpu) = states("ec.sim.ccparticlesystems2", &p, 2.0, &host); + let tol = if anim == 8 { 1e-3 } else { 1e-4 }; + agree(&format!("particle systems ii anim {anim}"), &cpu, &gpu, tol, cpu.len() / 100 + 1); + } +} + +#[test] +fn playground_cannon_matches_the_cpu_and_checkpoints_reuse() { + let Some(g) = gpu() else { return }; + let host = Host(g); + for (radius, spread) in [(4.0, 0.0), (-10.0, 50.0)] { + let p = params( + "ec.sim.particleplayground", + &[ + ("grid/particlesAcross", n(0.0)), + ("cannon/barrelRadius", n(radius)), + ("cannon/particlesPerSecond", n(90.0)), + ("cannon/directionRandomSpread", n(30.0)), + ("cannon/velocityRandomSpread", n(40.0)), + ("gravity/gravityForceRandomSpread", n(spread)), + ], + ); + // Advance through checkpoints (0.5 → 1.0 → 2.0), seek back (1.5), compare each. + for t in [0.5, 1.0, 2.0, 1.5] { + let (cpu, gpu) = states("ec.sim.particleplayground", &p, t, &host); + // Positions are layer pixels here. + agree(&format!("cannon r {radius} spread {spread} t {t}"), &cpu, &gpu, 0.01, cpu.len() / 100 + 1); + } + } +} + +/// Through the renderer: the GPU backend's effect host hands the simulation to the GPU, and +/// the composited frames agree with the CPU render. +#[test] +fn particle_effects_render_like_the_cpu() { + use effectcraft_project::BitDepth; + use effectcraft_time::Tick; + + use crate::tests::{Scene, check, compare_at, opts}; + for (id, vals) in [ + ("ec.sim.ccparticleworld", vec![("birthRate", n(1.0)), ("physics/animation", Value::Enum(4))]), + ("ec.sim.ccparticlesystems2", vec![("birthRate", n(2.0))]), + ("ec.sim.particleplayground", vec![("grid/particlesAcross", n(0.0)), ("cannon/particlesPerSecond", n(60.0))]), + ] { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let mut l = s.solid([0.1, 0.1, 0.15], 97, 61); + s.effect(&mut l, id, &vals); + s.push(l); + check(&format!("{id} {depth:?}"), compare_at(&s, opts(), Tick::from_seconds_f64(1.0)), 0.002); + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ffb55936..44f4876a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -82,6 +82,9 @@ struct FxHost<'r, 'a, 'c> { const MAX_FX_DEPTH: usize = 8; impl EffectHost for FxHost<'_, '_, '_> { + fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> { + self.r.active_accel().and_then(|a| a.particles()) + } fn layer(&self, id: u64, masks_and_effects: bool) -> Option { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH { @@ -240,6 +243,11 @@ pub trait Accelerator: Send + Sync { fn raster_3d(&self, _scene: &three_d::adv::Scene) -> Option { None } + /// A particle simulation backend (GPU particles) for the stepped particle effects, with + /// the CPU simulation's semantics. `None` = they simulate on the CPU. + fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> { + None + } } /// Where an effect stack runs (see [`Renderer::run_effects_on`]): a CPU buffer, or an image From c8623b1864e0c9a04b09bae8427e3f75feebdfa7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:46:09 -0400 Subject: [PATCH 6/9] M12.7: GPU blur/transition/generate effects (radial, camera lens, CC radial fast, wipes, venetian, cell pattern, checkerboard, grid, 4-color, noise, add grain) Kernels in fx_generate.wgsl mirror the CPU effects; wipe edges use f64 per-column/per-row terms uploaded as (hi, lo) pairs. CPU helpers shared with the GPU are now public (camera_lens_spans, gen_mode, pattern_kind, GrainLook, place_layer). GPU tests in tests_fx_generate.rs. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/blur2.rs | 18 +- crates/effects/src/generate.rs | 2 +- crates/effects/src/generate2.rs | 2 +- crates/effects/src/lib.rs | 20 + crates/effects/src/noise.rs | 4 +- crates/effects/src/transition.rs | 2 +- crates/gpu/src/fx_generate.rs | 400 ++++++++++++++- crates/gpu/src/lib.rs | 2 + crates/gpu/src/shaders/fx_generate.wgsl | 629 ++++++++++++++++++++++++ crates/gpu/src/tests_fx_generate.rs | 200 ++++++++ docs/effects.md | 28 +- 11 files changed, 1275 insertions(+), 32 deletions(-) create mode 100644 crates/gpu/src/tests_fx_generate.rs diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index 529a8dee..3b84d283 100644 --- a/crates/effects/src/blur2.rs +++ b/crates/effects/src/blur2.rs @@ -277,11 +277,9 @@ fn iris_spans(r: f64, sides: u32, rotation_deg: f64, roundness: f64) -> Vec<(i64 spans } -fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { - let r = ctx.params.f("blurRadius").max(0.0) * b.scale; - if r < 0.5 { - return b; - } +/// Camera Lens Blur's iris spans at blur radius `r` pixels (shape, rotation, roundness and +/// aspect ratio applied) and the iris' horizontal reach (shared with the GPU kernel). +pub fn camera_lens_spans(ctx: &EffectCtx, r: f64) -> (Vec<(i64, i64, i64)>, f64) { let shape = ctx.params.e("irisProperties/irisShape"); let sides = if shape >= 8 { 0 } else { shape + 3 }; let mut spans = iris_spans(r, sides, ctx.params.f("irisProperties/irisRotation"), ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0); @@ -293,7 +291,15 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { s.2 = (s.2 as f64 * aspect).round() as i64; } } - let reach = r * aspect.max(1.0); + (spans, r * aspect.max(1.0)) +} + +fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { + let r = ctx.params.f("blurRadius").max(0.0) * b.scale; + if r < 0.5 { + return b; + } + let (spans, reach) = camera_lens_spans(ctx, r); let repeat = ctx.params.b("repeatEdge") || ctx.adjustment; if !repeat { b.pad(reach.ceil() as u32 + 1); diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index 511a8fea..09b25a27 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -75,7 +75,7 @@ pub(crate) const GEN_BLEND_MODES: [&str; 20] = [ "Silhouette Alpha", ]; -fn gen_mode(i: u32) -> Option { +pub fn gen_mode(i: u32) -> Option { Some(match i { 0 => return None, 2 => BlendMode::Add, diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 19a9b6a3..ddde36d7 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -61,7 +61,7 @@ const CELL_PATTERNS: [&str; 12] = [ ]; /// Shaping used for Cell Pattern option `i` (see [`cell_value`]). -fn pattern_kind(i: u32) -> u32 { +pub fn pattern_kind(i: u32) -> u32 { match i { 0..=5 => i, 6 => 1, diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a5b4f346..4f347231 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -67,6 +67,12 @@ use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::{AuxChannels, Image}; pub use misc::{INVERT_ALPHA, INVERT_CHANNELS}; +// CPU helpers the GPU kernels share (effectcraft-gpu). +pub use blur2::camera_lens_spans; +pub use generate::gen_mode; +pub use generate2::pattern_kind as cell_pattern_kind; +pub use noise::GrainLook; +pub use transition::place_layer; /// Effect categories in Effects & Presets order. pub const CATEGORIES: &[&str] = &[ @@ -641,6 +647,20 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.color.exposure", "ec.channel.invert", "ec.distort.transform", + // Blur, transition and generate family (effectcraft-gpu `fx_generate`). + "ec.blur.radial", + "ec.blur.cameralens", + "ec.blur.ccradialfast", + "ec.transition.venetian", + "ec.transition.linearwipe", + "ec.transition.radialwipe", + "ec.transition.gradientwipe", + "ec.generate.cellpattern", + "ec.generate.checkerboard", + "ec.generate.grid", + "ec.generate.fourcolor", + "ec.noise.noise", + "ec.noise.addgrain", ]; /// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index 4555d16a..4181c070 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -41,7 +41,7 @@ pub(crate) const GRAIN_BLEND_MODES: [&str; 5] = ["Film", "Multiply", "Add", "Scr /// The Tweaking / Color / Application / Animation controls Add Grain and Match Grain share /// (twirl-down groups with the same ids in both effects). -pub(crate) struct GrainLook { +pub struct GrainLook { pub intensity: f32, pub channel_intensity: [f32; 3], pub size: f32, @@ -62,7 +62,7 @@ pub(crate) struct GrainLook { } impl GrainLook { - pub(crate) fn from_params(ctx: &EffectCtx) -> GrainLook { + pub fn from_params(ctx: &EffectCtx) -> GrainLook { let pr = ctx.params; let ch = |pre: &str, a: &str, b: &str, c: &str| [a, b, c].map(|k| pr.get(&format!("{pre}{k}")).map(Value::as_f64).unwrap_or(1.0) as f32); let ft = ctx.time * 24.0 * pr.f("animation/animationSpeed"); diff --git a/crates/effects/src/transition.rs b/crates/effects/src/transition.rs index c41c6449..9701f0c4 100644 --- a/crates/effects/src/transition.rs +++ b/crates/effects/src/transition.rs @@ -30,7 +30,7 @@ fn scale_px(px: &mut Px, k: f32) { /// Resample `other` into `b`'s pixel grid with placement `mode` (Gradient Wipe's Gradient /// Placement, Texturize's Texture Placement): 0 tile from the top-left, 1 centre once, /// 2 stretch to fit. -pub(crate) fn place_layer(ctx: &EffectCtx, b: &Buf, other: &crate::LayerPixels, mode: u32) -> effectcraft_raster::Image { +pub fn place_layer(ctx: &EffectCtx, b: &Buf, other: &crate::LayerPixels, mode: u32) -> effectcraft_raster::Image { let (ls, os) = (ctx.layer_size, other.size); let inv = 1.0 / b.scale.max(1e-9); crate::util::gen_image(b.img.width, b.img.height, |x, y| { diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index 61c18def..976bfc8e 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -1,18 +1,404 @@ //! GPU effects, blur, transition and generate family (kernels in `shaders/fx_generate.wgsl`): the CPU effects' exact //! steps with the pixel loops as compute kernels. +//! +//! Every effect here covers all of its parameters. Where the CPU works in f64 around a hard +//! edge (the wipes), the per-column and per-row terms are computed in f64 here and uploaded as +//! (hi, lo) f32 pairs so the GPU puts the edge where the CPU does. -use effectcraft_effects::EffectCtx; +use effectcraft_color::BlendMode; +use effectcraft_effects::{EffectCtx, GrainLook}; -use crate::context::Enc; -use crate::effects::GBuf; +use crate::context::{Enc, Params}; +use crate::effects::{GBuf, gaussian_blur}; /// Compute entry points in `fx_generate.wgsl`. -pub(crate) const KERNELS: &[&str] = &[]; +pub(crate) const KERNELS: &[&str] = &[ + "gen_radial_blur", + "gen_ccradialfast", + "gen_lens_pre", + "gen_row_prefix", + "gen_lens_blur", + "gen_linear_wipe", + "gen_venetian", + "gen_radial_wipe", + "gen_gradient_wipe", + "gen_cell_pattern", + "gen_checker", + "gen_grid", + "gen_fourcolor", + "gen_noise", + "gen_grain_planes", + "gen_grain_apply", +]; /// Effect ids implemented here. -pub(crate) const IDS: &[&str] = &[]; +pub(crate) const IDS: &[&str] = &[ + "ec.blur.radial", + "ec.blur.cameralens", + "ec.blur.ccradialfast", + "ec.transition.venetian", + "ec.transition.linearwipe", + "ec.transition.radialwipe", + "ec.transition.gradientwipe", + "ec.generate.cellpattern", + "ec.generate.checkerboard", + "ec.generate.grid", + "ec.generate.fourcolor", + "ec.noise.noise", + "ec.noise.addgrain", +]; /// 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 { - None +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + match id { + "ec.blur.radial" => radial_blur(e, ctx, b), + "ec.blur.cameralens" => camera_lens(e, ctx, b), + "ec.blur.ccradialfast" => radial_fast(e, ctx, b), + "ec.transition.venetian" => venetian(e, ctx, b), + "ec.transition.linearwipe" => linear_wipe(e, ctx, b), + "ec.transition.radialwipe" => radial_wipe(e, ctx, b), + "ec.transition.gradientwipe" => gradient_wipe(e, ctx, b), + "ec.generate.cellpattern" => cell_pattern(e, ctx, b), + "ec.generate.checkerboard" => checkerboard(e, ctx, b), + "ec.generate.grid" => grid(e, ctx, b), + "ec.generate.fourcolor" => four_color(e, ctx, b), + "ec.noise.noise" => noise(e, ctx, b), + "ec.noise.addgrain" => add_grain(e, ctx, b), + _ => None, + } +} + +/// Run a per-pixel kernel over the buffer. +fn run(e: &mut Enc, entry: &str, p: &Params, b: GBuf, aux: Option<&crate::context::GpuImage>, data: Option<&[f32]>) -> Option { + let buf = data.map(|d| e.data(d)); + let out = e.image(b.img.width, b.img.height); + e.pixels(entry, p, &b.img, aux, &out, buf.as_ref()); + Some(GBuf { img: out, ..b }) +} + +/// `v` as an f32 (hi, lo) pair. +fn hl(v: f64) -> [f32; 2] { + let h = v as f32; + [h, (v - h as f64) as f32] +} + +/// Generate blending mode index → `BlendMode::ALL` index (255 = None: replace). +fn gen_mode(i: u32) -> u32 { + match effectcraft_effects::gen_mode(i) { + None => 255, + Some(m) => BlendMode::ALL.iter().position(|&x| x == m).unwrap_or(0) as u32, + } +} + +// ---------------------------------------------------------------- blurs + +fn radial_blur(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let amt = ctx.params.f("amount"); + let zoom = ctx.params.e("type") == 1; + let c = b.to_px(ctx.params.v2("center")); + let amount = if zoom { amt / 100.0 } else { amt }; + if amount.abs() < 1e-6 { + return Some(b); + } + let n = 32usize; + let mut data = Vec::with_capacity(2 * n); + for i in 0..n { + let t = i as f64 / (n - 1) as f64 - 0.5; + if zoom { + data.push((1.0 + t * amount) as f32); + } else { + let (s, c) = (t * amount).to_radians().sin_cos(); + data.extend([s as f32, c as f32]); + } + } + let mut p = Params::default(); + p.u[0] = [zoom as u32, n as u32, 0, 0]; + p.f[0] = [c.0 as f32, c.1 as f32, 0.0, 0.0]; + run(e, "gen_radial_blur", &p, b, None, Some(&data)) +} + +fn radial_fast(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let s = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) * 0.5; + if s <= 0.0 { + return Some(b); + } + let c = b.to_px(ctx.params.v2("center")); + let mut p = Params::default(); + p.u[0][0] = ctx.params.e("zoom"); + p.f[0] = [c.0 as f32, c.1 as f32, s as f32, 0.0]; + run(e, "gen_ccradialfast", &p, b, None, None) +} + +fn camera_lens(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let r = ctx.params.f("blurRadius").max(0.0) * b.scale; + if r < 0.5 { + return Some(b); + } + let (spans, reach) = effectcraft_effects::camera_lens_spans(ctx, r); + let repeat = ctx.params.b("repeatEdge") || ctx.adjustment; + if !repeat { + b.pad(e, reach.ceil() as u32 + 1)?; + } + let linear = ctx.params.b("useLinear"); + let thr = ctx.params.f("highlight/specularThreshold") as f32 / 255.0; + let boost = 1.0 + ctx.params.f("highlight/specularBrightness").max(0.0) as f32 / 100.0 * 4.0; + let sat = (ctx.params.f("highlight/highlightSaturation") / 100.0).clamp(0.0, 1.0) as f32; + let (w, h) = (b.img.width, b.img.height); + let mut src = b.img.clone(); + if linear || boost > 1.0 { + let mut p = Params::default(); + p.u[0] = [linear as u32, (boost > 1.0) as u32, 0, 0]; + p.f[0] = [thr, boost, sat, 0.0]; + let out = e.image(w, h); + e.pixels("gen_lens_pre", &p, &src, None, &out, None); + src = out; + } + let count: i64 = spans.iter().map(|(_, l, r)| r - l + 1).sum(); + let norm = 1.0 / count as f64; + // Large irises sum each span from per-row prefix sums; small ones add the pixels directly. + let prefix = count > 256; + let pre = if prefix { + if !e.g.fits(w + 1, h) { + return None; + } + let pre = e.image(w + 1, h); + e.dispatch("gen_row_prefix", &Params::default(), &src, None, &pre, None, (h.div_ceil(64), 1)); + Some(pre) + } else { + None + }; + let data: Vec = spans.iter().flat_map(|&(dy, l, r)| [dy as f32, l as f32, r as f32]).collect(); + let mut p = Params::default(); + p.u[0] = [spans.len() as u32, repeat as u32, linear as u32, prefix as u32]; + p.f[0][0] = norm as f32; + let buf = e.data(&data); + let out = e.image(w, h); + e.pixels("gen_lens_blur", &p, &src, pre.as_ref(), &out, Some(&buf)); + b.img = out; + Some(b) +} + +// ---------------------------------------------------------------- transitions + +fn linear_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = ctx.params.f("completion") / 100.0; + let ang = ctx.params.f("angle").to_radians(); + let feather = (ctx.params.f("feather") * b.scale).max(0.001); + let (w, h) = (b.img.width as f64, b.img.height as f64); + let (dx, dy) = (ang.sin(), -ang.cos()); + let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)]; + let proj: Vec = corners.iter().map(|(x, y)| x * dx + y * dy).collect(); + let (lo, hi) = (proj.iter().cloned().fold(f64::INFINITY, f64::min), proj.iter().cloned().fold(f64::NEG_INFINITY, f64::max)); + let edge = lo - feather + (hi - lo + 2.0 * feather) * done; + let mut data = Vec::with_capacity(2 * (b.img.width + b.img.height) as usize); + for x in 0..b.img.width { + data.extend(hl((x as f64 + 0.5) * dx - edge)); + } + for y in 0..b.img.height { + data.extend(hl((y as f64 + 0.5) * dy)); + } + let mut p = Params::default(); + p.u[0][0] = 2 * b.img.width; + p.f[0][0] = feather as f32; + run(e, "gen_linear_wipe", &p, b, None, Some(&data)) +} + +fn venetian(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = ctx.params.f("completion") / 100.0; + let ang = ctx.params.f("direction").to_radians(); + let width = (ctx.params.f("width") * b.scale).max(1.0); + let feather = (ctx.params.f("feather") * b.scale).max(0.001); + let (dx, dy) = (ang.cos(), ang.sin()); + let mut data = Vec::with_capacity((6 * b.img.width + 2 * b.img.height) as usize); + for x in 0..b.img.width { + let cx = ((x as f64 + 0.5) * dx).rem_euclid(width); + data.extend(hl(cx - done * width)); + data.extend(hl(cx - width - done * width)); + data.extend(hl(cx - width)); + } + for y in 0..b.img.height { + data.extend(hl(((y as f64 + 0.5) * dy).rem_euclid(width))); + } + let mut p = Params::default(); + p.u[0][0] = 6 * b.img.width; + p.f[0][0] = feather as f32; + run(e, "gen_venetian", &p, b, None, Some(&data)) +} + +fn radial_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = ctx.params.f("completion") / 100.0; + let start = ctx.params.f("startAngle"); + let c = b.to_px(ctx.params.v2("center")); + let dir = ctx.params.e("wipe"); + let feather = ctx.params.f("feather").max(0.01); + let edge = if dir == 2 { done * 180.0 } else { done * 360.0 }; + // Rotations: the start angle, and the start angle ± the edge (where the wipe's edge lies). + let rots = [start, start + edge, start - edge].map(|r| r.to_radians().sin_cos()); + let mut data = Vec::with_capacity(9 * (b.img.width + b.img.height) as usize); + for x in 0..b.img.width { + let ux = x as f64 + 0.5 - c.0; + for (s, co) in rots { + data.extend(hl(ux * co)); + data.push((ux * s) as f32); + } + } + for y in 0..b.img.height { + let uy = -(y as f64 + 0.5 - c.1); + for (s, co) in rots { + data.extend(hl(uy * s)); + data.push((uy * co) as f32); + } + } + let mut p = Params::default(); + p.u[0] = [9 * b.img.width, dir.min(2), 0, 0]; + p.f[0] = [edge as f32, feather as f32, 0.0, 0.0]; + run(e, "gen_radial_wipe", &p, b, None, Some(&data)) +} + +fn gradient_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0) as f32; + if done <= 0.0 { + return Some(b); + } + let soft = (ctx.params.f("softness") / 100.0) as f32; + // The gradient layer is fetched and placed on the CPU (as the CPU effect does). + let grad = match ctx.layer_param("gradientLayer", false) { + Some(o) => { + // place_layer reads only the buffer's size, offset and scale. + let img = effectcraft_raster::Image { width: b.img.width, height: b.img.height, data: vec![] }; + let cpu = effectcraft_effects::Buf { img, offset: b.offset, scale: b.scale }; + let img = effectcraft_effects::place_layer(ctx, &cpu, &o, ctx.params.e("gradientPlacement")); + Some(e.g.upload_image(&img)?) + } + None => None, + }; + let mut p = Params::default(); + p.u[0][0] = ctx.params.b("invert") as u32; + p.f[0] = [done, soft, 0.0, 0.0]; + let out = e.image(b.img.width, b.img.height); + e.pixels("gen_gradient_wipe", &p, &b.img, Some(grad.as_ref().unwrap_or(&b.img)), &out, None); + Some(GBuf { img: out, ..b }) +} + +// ---------------------------------------------------------------- generate + +fn cell_pattern(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let size = (pr.f("size") * b.scale).max(1.0); + let off = b.to_px(pr.v2("offset")); + let mut evo = pr.f("evolution") / 360.0; + if pr.b("evolutionOptions/cycleEvolution") { + evo = evo.rem_euclid(pr.f("evolutionOptions/cycle").round().max(1.0)); + } + let seed = (pr.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(7919) ^ 0xce11; + let tile = pr.b("tilingOptions/enableTiling"); + let nx = pr.f("tilingOptions/cellsHorizontal").round().max(1.0) as i64; + let ny = pr.f("tilingOptions/cellsVertical").round().max(1.0) as i64; + if nx > i32::MAX as i64 || ny > i32::MAX as i64 { + return None; + } + let mut p = Params::default(); + p.u[0] = [effectcraft_effects::cell_pattern_kind(pr.e("cellPattern")), pr.e("overflow"), pr.b("invert") as u32, seed]; + p.u[1] = [tile as u32, nx as u32, ny as u32, 0]; + // cos / sin of 2π·(h + evo) only see evo modulo 1. + p.f[0] = [pr.f("contrast") as f32 / 100.0, pr.f("disperse").clamp(0.0, 1.5) as f32, size as f32, evo.rem_euclid(1.0) as f32]; + p.f[1] = [(0.5 - off.0) as f32, (0.5 - off.1) as f32, 0.0, 0.0]; + run(e, "gen_cell_pattern", &p, b, None, None) +} + +/// generate::cell_size. +fn cell_size(ctx: &EffectCtx, b: &GBuf, anchor: (f64, f64)) -> (f64, f64) { + let (w, h) = match ctx.params.e("sizeFrom") { + 0 => { + let c = b.to_px(ctx.params.v2("corner")); + ((c.0 - anchor.0).abs(), (c.1 - anchor.1).abs()) + } + 1 => (ctx.params.f("width") * b.scale, ctx.params.f("width") * b.scale), + _ => (ctx.params.f("width") * b.scale, ctx.params.f("height") * b.scale), + }; + (w.max(1.0), h.max(1.0)) +} + +fn checkerboard(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let anchor = b.to_px(ctx.params.v2("anchor")); + let (w, h) = cell_size(ctx, &b, anchor); + let fw = (ctx.params.f("feather/featherWidth") * b.scale).max(1.0); + let fh = (ctx.params.f("feather/featherHeight") * b.scale).max(1.0); + let mut p = Params::default(); + p.u[0][0] = gen_mode(ctx.params.e("blendingMode")); + p.f[0] = [(0.5 - anchor.0) as f32, (0.5 - anchor.1) as f32, w as f32, h as f32]; + p.f[1] = [fw as f32, fh as f32, ctx.params.f("opacity") as f32 / 100.0, 0.0]; + p.f[2] = ctx.params.color("color"); + run(e, "gen_checker", &p, b, None, None) +} + +fn grid(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let anchor = b.to_px(ctx.params.v2("anchor")); + let (w, h) = cell_size(ctx, &b, anchor); + let border = (ctx.params.f("border") * b.scale).max(0.0); + let fw = (ctx.params.f("feather/featherWidth") * b.scale).max(1.0); + let fh = (ctx.params.f("feather/featherHeight") * b.scale).max(1.0); + let mut p = Params::default(); + p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.params.b("invertGrid") as u32, 0, 0]; + p.f[0] = [(0.5 - anchor.0) as f32, (0.5 - anchor.1) as f32, w as f32, h as f32]; + p.f[1] = [fw as f32, fh as f32, ctx.params.f("opacity") as f32 / 100.0, border as f32]; + p.f[2] = ctx.params.color("color"); + run(e, "gen_grid", &p, b, None, None) +} + +fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let mut p = Params::default(); + for i in 0..4 { + let q = b.to_px(ctx.params.v2(&format!("positionsColors/point{}", i + 1))); + p.f[i] = [q.0 as f32, q.1 as f32, 0.0, 0.0]; + p.f[4 + i] = ctx.params.color(&format!("positionsColors/color{}", i + 1)); + } + let blend = ctx.params.f("blend").max(1.0); + let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32; + p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.seed, 0, 0]; + p.f[8] = [(blend * 100.0) as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0]; + run(e, "gen_fourcolor", &p, b, None, None) +} + +// ---------------------------------------------------------------- noise + +fn noise(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let amt = ctx.params.f("amount") as f32 / 100.0; + let clip = ctx.params.get("clip").is_none_or(|v| v.as_bool()); + let frame_seed = (ctx.time * 1000.0) as u32 ^ ctx.seed; + let mut p = Params::default(); + p.u[0] = [frame_seed, ctx.params.b("color") as u32, clip as u32, b.img.width]; + p.f[0][0] = amt; + run(e, "gen_noise", &p, b, None, None) +} + +fn add_grain(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let look = GrainLook::from_params(ctx); + if look.intensity <= 0.0 { + return Some(b); + } + let (w, h) = (b.img.width, b.img.height); + // GrainLook::planes. + let size = look.size * b.scale as f32; + let s = |k: usize| (size * if look.mono { 1.0 } else { look.channel_size[k].max(0.05) }).max(0.05); + let mut p = Params::default(); + p.u[0] = [look.mono as u32, look.seed, 101, 0]; + p.f[0] = [s(0) * look.aspect, s(1) * look.aspect, s(2) * look.aspect, look.frame]; + p.f[1] = [s(0), s(1), s(2), 0.0]; + let mut planes = e.image(w, h); + e.pixels("gen_grain_planes", &p, &b.img, None, &planes, None); + let soft = look.softness * b.scale; + if soft > 0.05 { + planes = gaussian_blur(e, &planes, soft * look.aspect as f64, soft, true); + } + // GrainLook::color's tint factors. + let l = effectcraft_color::luminance(look.tint[0], look.tint[1], look.tint[2]).max(1e-3); + let tint = [0, 1, 2].map(|k| (1.0 - look.tint_amount) + look.tint_amount * look.tint[k] / l); + let mut p = Params::default(); + p.u[0] = [look.mono as u32, look.mode, (look.tint_amount > 0.0) as u32, 0]; + p.f[0] = [look.intensity * 0.25, look.saturation, look.midpoint, 0.0]; + p.f[1] = [tint[0], tint[1], tint[2], 0.0]; + p.f[2] = [look.channel_intensity[0], look.channel_intensity[1], look.channel_intensity[2], 0.0]; + p.f[3] = [look.tones[0], look.tones[1], look.tones[2], 0.0]; + run(e, "gen_grain_apply", &p, b, Some(&planes), None) } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 9ebce209..2da1af20 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -149,3 +149,5 @@ mod tests_3d; mod tests_adjust; #[cfg(test)] mod tests_adv3d; +#[cfg(test)] +mod tests_fx_generate; diff --git a/crates/gpu/src/shaders/fx_generate.wgsl b/crates/gpu/src/shaders/fx_generate.wgsl index bc67b93a..e5e2f411 100644 --- a/crates/gpu/src/shaders/fx_generate.wgsl +++ b/crates/gpu/src/shaders/fx_generate.wgsl @@ -1,2 +1,631 @@ // GPU effects (generate family): see src/fx_generate.rs. Each entry point mirrors the CPU effect in // effectcraft-effects operation for operation; parameter layouts are documented per entry. +// +// Hard edges (wipes) take per-column and per-row terms computed in f64 on the CPU and uploaded +// as (hi, lo) f32 pairs in `data`: near an edge the hi parts cancel exactly, so the edge sits +// where the CPU's f64 arithmetic puts it. + +const GEN_DEG: f32 = 57.29577951308232; + +// util::unpremul: (straight colour, alpha). +fn gen_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)); +} + +// util::smoothstep. +fn gen_smoothstep(e0: f32, e1: f32, x: f32) -> f32 { + if (abs(e1 - e0) < 1e-9) { + return select(1.0, 0.0, x < e0); + } + let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} + +// generate::gen_blend: mode = index into BlendMode::ALL, 255 = None (replace). +fn gen_blend(o: vec4, g: vec4, mode: u32) -> vec4 { + if (mode == 255u) { + return g; + } + return blend_pixel(mode, o, g, 0.5); +} + +// A (hi, lo) pair from `data`. +fn gen_hl(i: u32) -> vec2 { + return vec2(data[i], data[i + 1u]); +} + +// (a + b) for (hi, lo) pairs whose hi parts cancel near zero. +fn gen_sum(a: vec2, b: vec2) -> f32 { + return (a.x + b.x) + (a.y + b.y); +} + +// ---------------------------------------------------------------- Radial Blur (raster::radial_blur) + +// u[0] = (zoom, samples); f[0] = (cx, cy); data = per sample (sin, cos) (spin) or k (zoom). +@compute @workgroup_size(16, 16) +fn gen_radial_blur(@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 zoom = P.u[0].x != 0u; + let n = P.u[0].y; + let c = P.f[0].xy; + let v = vec2(f32(p.x) + 0.5 - c.x, f32(p.y) + 0.5 - c.y); + var acc = vec4(0.0); + for (var i = 0u; i < n; i++) { + var s: vec2; + if (zoom) { + let k = data[i]; + s = vec2(c.x + v.x * k, c.y + v.y * k); + } else { + let sn = data[2u * i]; + let cs = data[2u * i + 1u]; + s = vec2(c.x + v.x * cs - v.y * sn, c.y + v.x * sn + v.y * cs); + } + acc += sample_bilinear(src, s.x, s.y); + } + textureStore(out, p, acc / f32(n)); +} + +// ---------------------------------------------------------------- CC Radial Fast Blur + +// u[0].x = zoom mode (0 standard, 1 brightest, 2 darkest); f[0] = (cx, cy, s). +@compute @workgroup_size(16, 16) +fn gen_ccradialfast(@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 mode = P.u[0].x; + let c = P.f[0].xy; + let s = P.f[0].z; + let d = vec2(f32(p.x) + 0.5 - c.x, f32(p.y) + 0.5 - c.y); + let len = sqrt(d.x * d.x + d.y * d.y) * s; + let n = clamp(u32(ceil(len)), 2u, 64u); + var acc = vec4(0.0); + if (mode == 1u) { + acc = vec4(-3.0e38); + } else if (mode == 2u) { + acc = vec4(3.0e38); + } + for (var i = 0u; i < n; i++) { + let k = 1.0 - s * f32(i) / f32(n - 1u); + let q = sample_bilinear(src, c.x + d.x * k, c.y + d.y * k); + if (mode == 1u) { + acc = max(acc, q); + } else if (mode == 2u) { + acc = min(acc, q); + } else { + acc += q / f32(n); + } + } + let a = acc.w; + textureStore(out, p, vec4(max(min(acc.xyz, vec3(a)), vec3(0.0)), clamp(a, 0.0, 1.0))); +} + +// ---------------------------------------------------------------- Camera Lens Blur + +// Linear working space and highlight boost before the blur. +// u[0] = (linear, boost); f[0] = (threshold, boost, saturation). +@compute @workgroup_size(16, 16) +fn gen_lens_pre(@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; + } + var px = textureLoad(src, p, 0); + if (P.u[0].x != 0u) { + let u = gen_unpremul(px); + px = vec4(srgb_to_linear(u.x) * u.w, srgb_to_linear(u.y) * u.w, srgb_to_linear(u.z) * u.w, u.w); + } + if (P.u[0].y != 0u) { + let u = gen_unpremul(px); + let c = u.xyz; + let a = u.w; + if (a > 0.0 && luminance(c) >= P.f[0].x) { + let boost = P.f[0].y; + let sat = P.f[0].z; + let m = max(max(c.x, c.y), c.z); + let tinted = c + (m - c) * (1.0 - sat); + px = vec4((c + tinted * (boost - 1.0)) * a, px.w); + } + } + textureStore(out, p, px); +} + +// Per-row prefix sums: out(x + 1, y) = sum of src(0..=x, y), out(0, y) = 0. One thread per row. +@compute @workgroup_size(64, 1) +fn gen_row_prefix(@builtin(global_invocation_id) gid: vec3) { + let dims = vec2(textureDimensions(src)); + let y = i32(gid.x); + if (y >= dims.y) { + return; + } + var acc = vec4(0.0); + textureStore(out, vec2(0, y), acc); + for (var x = 0; x < dims.x; x++) { + acc += textureLoad(src, vec2(x, y), 0); + textureStore(out, vec2(x + 1, y), acc); + } +} + +// The iris sum. data = spans (dy, left, right); aux = row prefix sums (when u[0].w). +// u[0] = (spans, repeat, linear, prefix); f[0].x = 1 / pixel count. +@compute @workgroup_size(16, 16) +fn gen_lens_blur(@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 n = P.u[0].x; + let repeat = P.u[0].y != 0u; + let prefix = P.u[0].w != 0u; + let w = dims.x; + let h = dims.y; + var acc = vec4(0.0); + for (var s = 0u; s < n; s++) { + var yy = p.y + i32(data[3u * s]); + if (yy < 0 || yy >= h) { + if (!repeat) { + continue; + } + yy = clamp(yy, 0, h - 1); + } + var a = p.x + i32(data[3u * s + 1u]); + var bnd = p.x + i32(data[3u * s + 2u]); + if (!prefix) { + for (var i = a; i <= bnd; i++) { + if (repeat) { + acc += textureLoad(src, vec2(clamp(i, 0, w - 1), yy), 0); + } else if (i >= 0 && i < w) { + acc += textureLoad(src, vec2(i, yy), 0); + } + } + continue; + } + if (repeat) { + if (a < 0) { + let k = min(-a, bnd - a + 1); + acc += textureLoad(src, vec2(0, yy), 0) * f32(k); + a = 0; + } + if (bnd >= w) { + let k = min(bnd - (w - 1), bnd - a + 1); + acc += textureLoad(src, vec2(w - 1, yy), 0) * f32(k); + bnd = w - 1; + } + } else { + a = max(a, 0); + bnd = min(bnd, w - 1); + } + if (a <= bnd) { + acc += textureLoad(aux, vec2(bnd + 1, yy), 0) - textureLoad(aux, vec2(a, yy), 0); + } + } + var px = acc * P.f[0].x; + px.w = clamp(px.w, 0.0, 1.0); + if (P.u[0].z != 0u) { + let u = gen_unpremul(px); + px = vec4(linear_to_srgb(max(u.x, 0.0)) * u.w, linear_to_srgb(max(u.y, 0.0)) * u.w, linear_to_srgb(max(u.z, 0.0)) * u.w, u.w); + } + textureStore(out, p, px); +} + +// ---------------------------------------------------------------- wipes + +// Linear Wipe. data = per column ((x + 0.5)·dx − edge) (hi, lo), then per row ((y + 0.5)·dy) +// (hi, lo); u[0].x = row table offset; f[0].x = feather. +@compute @workgroup_size(16, 16) +fn gen_linear_wipe(@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 d = gen_sum(gen_hl(2u * u32(p.x)), gen_hl(P.u[0].x + 2u * u32(p.y))); + let k = clamp(d / P.f[0].x + 0.5, 0.0, 1.0); + textureStore(out, p, textureLoad(src, p, 0) * k); +} + +// Venetian Blinds. Per column (6 floats): (cx − done·w), (cx − w − done·w), (cx − w) as +// (hi, lo), with cx = ((x + 0.5)·dx) mod w; per row (2 floats): ((y + 0.5)·dy) mod w. +// u[0].x = row table offset; f[0].x = feather. +@compute @workgroup_size(16, 16) +fn gen_venetian(@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 col = 6u * u32(p.x); + let row = gen_hl(P.u[0].x + 2u * u32(p.y)); + // (d mod w) wraps once the column and row terms reach w. + let wrapped = gen_sum(gen_hl(col + 4u), row) >= 0.0; + var t: f32; + if (wrapped) { + t = gen_sum(gen_hl(col + 2u), row); + } else { + t = gen_sum(gen_hl(col), row); + } + let k = clamp(t / P.f[0].x + 0.5, 0.0, 1.0); + textureStore(out, p, textureLoad(src, p, 0) * k); +} + +// Angle (degrees, clockwise from up) of the pixel vector rotated by rotation `r` (0 = start, +// 1 = start + edge, 2 = start − edge), from the per-column / per-row tables of Radial Wipe. +fn gen_psi(r: u32, x: u32, y: u32) -> f32 { + let c = 9u * x + 3u * r; + let w = P.u[0].x + 9u * y + 3u * r; + // x' = ux·cos R − uy·sin R (cancels at the edge), y' = uy·cos R + ux·sin R. + let xr = (data[c] - data[w]) + (data[c + 1u] - data[w + 1u]); + let yr = data[w + 2u] + data[c + 2u]; + return atan2(xr, yr) * GEN_DEG; +} + +// Radial Wipe. data: per column 9 floats (for each rotation: ux·cos R (hi, lo), ux·sin R), +// per row 9 floats (uy·sin R (hi, lo), uy·cos R); u[0] = (row offset, wipe); +// f[0] = (edge, feather). +@compute @workgroup_size(16, 16) +fn gen_radial_wipe(@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 x = u32(p.x); + let y = u32(p.y); + let dir = P.u[0].y; + let edge = P.f[0].x; + var a = gen_psi(0u, x, y); + if (a < 0.0) { + a += 360.0; + } + var base: f32; + if (dir == 0u || (dir == 2u && a <= 180.0)) { + base = a - edge; + if (abs(base) < 90.0) { + base = gen_psi(1u, x, y); + } + } else { + base = (360.0 - a) - edge; + if (abs(base) < 90.0) { + base = -gen_psi(2u, x, y); + } + } + let k = clamp(base / P.f[0].y + 0.5, 0.0, 1.0); + textureStore(out, p, textureLoad(src, p, 0) * k); +} + +// Gradient Wipe: aux = the placed gradient layer. u[0].x = invert; f[0] = (done, softness). +@compute @workgroup_size(16, 16) +fn gen_gradient_wipe(@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 done = P.f[0].x; + let soft = P.f[0].y; + let g = gen_unpremul(textureLoad(aux, p, 0)); + var t = clamp(luminance(g.xyz), 0.0, 1.0); + if (P.u[0].x != 0u) { + t = 1.0 - t; + } + var k = 0.0; + if (done >= 1.0) { + k = 0.0; + } else if (soft > 0.0) { + k = clamp((t - (done * (1.0 + soft) - soft)) / soft, 0.0, 1.0); + } else if (t >= done) { + k = 1.0; + } + textureStore(out, p, textureLoad(src, p, 0) * k); +} + +// ---------------------------------------------------------------- Cell Pattern (generate2) + +// Worley distances for (u, v) in cell units: (F1, F2, hash of the nearest cell). +fn gen_worley(u: f32, v: f32, disperse: f32, evo: f32, seed: u32) -> vec3 { + let tile = P.u[1].x != 0u; + let nx = i32(P.u[1].y); + let ny = i32(P.u[1].z); + let cx = i32(floor(u)); + let cy = i32(floor(v)); + var f1 = 3.0e38; + var f2 = 3.0e38; + var id = 0.0; + for (var j = -1; j <= 1; j++) { + for (var i = -1; i <= 1; i++) { + let gx = cx + i; + let gy = cy + j; + var tx = gx; + var ty = gy; + if (tile) { + tx = ((gx % nx) + nx) % nx; + ty = ((gy % ny) + ny) % ny; + } + let hx = bitcast(tx); + let hy = bitcast(ty); + let h1 = hash_noise(hx, hy, seed); + let h2 = hash_noise(hx, hy, seed + 1u); + let h3 = hash_noise(hx, hy, seed + 2u); + let ang = 6.283185307179586 * (h3 + evo); + let px = f32(gx) + 0.5 + (h1 - 0.5) * disperse + 0.15 * disperse * cos(ang); + let py = f32(gy) + 0.5 + (h2 - 0.5) * disperse + 0.15 * disperse * sin(ang); + let d = sqrt((px - u) * (px - u) + (py - v) * (py - v)); + if (d < f1) { + f2 = f1; + f1 = d; + id = hash_noise(hx, hy, seed + 3u); + } else if (d < f2) { + f2 = d; + } + } + } + return vec3(f1, f2, id); +} + +fn gen_cell_value(pattern: u32, f1: f32, f2: f32, id: f32) -> f32 { + let e = f2 - f1; + switch pattern { + case 0u: { return clamp(1.0 - f1 * 1.3, 0.0, 1.0); } + case 1u: { return id * 0.8 + 0.2 * clamp(1.0 - f1, 0.0, 1.0); } + case 2u: { return clamp(e * 2.0, 0.0, 1.0); } + case 3u: { return id * gen_smoothstep(0.0, 0.06, e); } + case 4u: { return id; } + case 5u: { return clamp(1.0 - f1 * f1 * 2.0, 0.0, 1.0); } + case 6u: { return id * clamp(e * 3.0, 0.0, 1.0); } + default: { return 1.0 - clamp(abs(e - 0.15) * 5.0, 0.0, 1.0); } + } +} + +fn gen_overflow(v: f32, mode: u32) -> f32 { + if (mode == 1u) { + return 0.5 + 0.5 * tanh((v - 0.5) * 2.0); + } + if (mode == 2u) { + let t = v - 2.0 * floor(v * 0.5); + return select(t, 2.0 - t, t > 1.0); + } + return clamp(v, 0.0, 1.0); +} + +// u[0] = (pattern kind, overflow, invert, seed); u[1] = (tile, columns, rows); +// f[0] = (contrast, disperse, size, evolution mod 1); f[1] = (0.5 − offset x, 0.5 − offset y). +@compute @workgroup_size(16, 16) +fn gen_cell_pattern(@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 size = P.f[0].z; + let u = (f32(p.x) + P.f[1].x) / size; + let v = (f32(p.y) + P.f[1].y) / size; + let w = gen_worley(u, v, P.f[0].y, P.f[0].w, P.u[0].w); + var val = gen_cell_value(P.u[0].x, w.x, w.y, w.z); + val = gen_overflow((val - 0.5) * P.f[0].x + 0.5, P.u[0].y); + if (P.u[0].z != 0u) { + val = 1.0 - val; + } + textureStore(out, p, vec4(val, val, val, 1.0)); +} + +// ---------------------------------------------------------------- Checkerboard / Grid / 4-Color + +// Signed distance (in feather units) to the nearest cell edge, positive in even cells. +fn gen_axis(q: f32, size: f32, f: f32) -> f32 { + let u = q / size; + let fl = floor(u); + let fr = u - fl; + let d = min(fr, 1.0 - fr) * size; + let n = i32(fl); + let s = select(-1.0, 1.0, ((n % 2) + 2) % 2 == 0); + return s * min(d / (f * 0.5), 1.0); +} + +// u[0].x = mode; f[0] = (0.5 − anchor x, 0.5 − anchor y, cell w, cell h); +// f[1] = (feather w, feather h, opacity); f[2] = colour. +@compute @workgroup_size(16, 16) +fn gen_checker(@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 sx = gen_axis(f32(p.x) + P.f[0].x, P.f[0].z, P.f[1].x); + let sy = gen_axis(f32(p.y) + P.f[0].y, P.f[0].w, P.f[1].y); + let cov = clamp(0.5 + 0.5 * sx * sy, 0.0, 1.0) * P.f[1].z; + let c = P.f[2].xyz; + textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4(c * cov, cov), P.u[0].x)); +} + +// f32::rem_euclid. +fn gen_rem(x: f32, m: f32) -> f32 { + let r = x - m * trunc(x / m); + return select(r, r + m, r < 0.0); +} + +// u[0] = (mode, invert); f[0] = (0.5 − anchor x, 0.5 − anchor y, cell w, cell h); +// f[1] = (feather w, feather h, opacity, border); f[2] = colour. +@compute @workgroup_size(16, 16) +fn gen_grid(@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 = P.f[0].z; + let h = P.f[0].w; + let border = P.f[1].w; + let gx = gen_rem(f32(p.x) + P.f[0].x, w); + let gy = gen_rem(f32(p.y) + P.f[0].y, h); + let dx = min(gx, w - gx); + let dy = min(gy, h - gy); + let cx = clamp((border / 2.0 - dx) / P.f[1].x + 0.5, 0.0, 1.0); + let cy = clamp((border / 2.0 - dy) / P.f[1].y + 0.5, 0.0, 1.0); + var cov = 0.0; + if (border > 0.0) { + cov = max(cx, cy); + } + if (P.u[0].y != 0u) { + cov = 1.0 - cov; + } + let a = cov * P.f[1].z; + textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4(P.f[2].xyz * a, a), P.u[0].x)); +} + +// u[0] = (mode, seed); f[0..4] = points (x, y); f[4..8] = colours; +// f[8] = (blend · 100, jitter, opacity). +@compute @workgroup_size(16, 16) +fn gen_fourcolor(@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 bl = P.f[8].x; + var acc = vec3(0.0); + var wsum = 0.0; + for (var i = 0; i < 4; i++) { + let q = P.f[i].xy; + let dx = f32(p.x) + 0.5 - q.x; + let dy = f32(p.y) + 0.5 - q.y; + let d2 = dx * dx + dy * dy; + let w = 1.0 / pow(d2 / bl + 1e-6, 1.5); + acc += P.f[4 + i].xyz * w; + wsum += w; + } + var c = acc / wsum; + let jitter = P.f[8].y; + if (jitter > 0.0) { + let n = (hash_noise(u32(p.x), u32(p.y), P.u[0].y) - 0.5) * jitter * (8.0 / 255.0); + c += vec3(n); + } + let op = P.f[8].z; + textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4(c * op, op), P.u[0].x)); +} + +// ---------------------------------------------------------------- Noise / Add Grain + +// Noise (misc::noise). u[0] = (frame seed, colour, clip, width); f[0].x = amount. +@compute @workgroup_size(16, 16) +fn gen_noise(@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; + } + var px = textureLoad(src, p, 0); + let a = px.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + let seed = P.u[0].x; + let w = P.u[0].w; + let amt = P.f[0].x; + let x = u32(p.x); + let y = u32(p.y); + var n = vec3((hash_noise(x, y, seed) - 0.5) * amt); + if (P.u[0].y != 0u) { + n.y = (hash_noise(x + w, y, seed) - 0.5) * amt; + n.z = (hash_noise(x + 2u * w, y, seed) - 0.5) * amt; + } + if (P.u[0].z != 0u) { + px = vec4(clamp(px.xyz + n * a, vec3(0.0), vec3(a)), a); + } else { + // Unclipped: values past black / white wrap around. + px = vec4(fract_euclid(px.x / a + n.x) * a, fract_euclid(px.y / a + n.y) * a, fract_euclid(px.z / a + n.z) * a, a); + } + textureStore(out, p, px); +} + +// Add Grain's noise planes (GrainLook::planes before Softness): rgb = per-channel zero-mean +// value noise. u[0] = (mono, seed, salt); f[0].xyz = x divisors (size · aspect), f[0].w = frame; +// f[1].xyz = y divisors (size). +@compute @workgroup_size(16, 16) +fn gen_grain_planes(@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 x = f32(p.x); + let y = f32(p.y); + let frame = P.f[0].w; + let seed = P.u[0].y; + let salt = P.u[0].z; + let n0 = value_noise(x / P.f[0].x, y / P.f[1].x, frame, seed) - 0.5; + var g = vec3(n0); + if (P.u[0].x == 0u) { + g.y = value_noise(x / P.f[0].y, y / P.f[1].y, frame, seed + salt) - 0.5; + g.z = value_noise(x / P.f[0].z, y / P.f[1].z, frame, seed + 2u * salt) - 0.5; + } + textureStore(out, p, vec4(g, 0.0)); +} + +// GrainLook::blend. +fn gen_grain_blend(mode: u32, c: f32, n: f32) -> f32 { + var v: f32; + switch mode { + case 1u: { v = c * (1.0 + n); } + case 2u: { v = c + n; } + case 3u: { v = 1.0 - (1.0 - c) * (1.0 - abs(n)); } + case 4u: { v = c + n * 4.0 * c * (1.0 - c); } + default: { v = c + n * (1.0 - 0.5 * c); } + } + return max(v, 0.0); +} + +// Add Grain applied: aux = noise planes. u[0] = (mono, mode, tint); f[0] = (amp, saturation, +// midpoint); f[1].xyz = tint factors; f[2].xyz = channel intensities; f[3].xyz = tone weights. +@compute @workgroup_size(16, 16) +fn gen_grain_apply(@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 u = gen_unpremul(px); + let a = u.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + let c = u.xyz; + var g = textureLoad(aux, p, 0).xyz; + if (P.u[0].x != 0u) { + g = vec3(g.x); + } else { + let gm = (g.x + g.y + g.z) / 3.0; + g = gm + (g - gm) * P.f[0].y; + } + if (P.u[0].z != 0u) { + g = g * P.f[1].xyz; + } + let mid = P.f[0].z; + let l = clamp(luminance(c), 0.0, 1.0); + let s = 1.0 - gen_smoothstep(0.0, mid, l); + let hi = gen_smoothstep(mid, 1.0, l); + let tones = P.f[3].xyz; + let weight = tones.x * s + tones.y * (1.0 - s - hi) + tones.z * hi; + let amp = P.f[0].x; + let ci = P.f[2].xyz; + let mode = P.u[0].y; + let o = vec3( + gen_grain_blend(mode, c.x, g.x * amp * ci.x * weight), + gen_grain_blend(mode, c.y, g.y * amp * ci.y * weight), + gen_grain_blend(mode, c.z, g.z * amp * ci.z * weight), + ); + textureStore(out, p, vec4(o * a, a)); +} diff --git a/crates/gpu/src/tests_fx_generate.rs b/crates/gpu/src/tests_fx_generate.rs new file mode 100644 index 00000000..e15abb37 --- /dev/null +++ b/crates/gpu/src/tests_fx_generate.rs @@ -0,0 +1,200 @@ +//! GPU vs CPU for the blur, transition and generate family (`fx_generate`). + +use effectcraft_keyframe::Value; +use effectcraft_project::BitDepth; +use effectcraft_render::RenderOpts; +use effectcraft_time::Tick; + +use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn b(v: bool) -> Value { + Value::Bool(v) +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +#[test] +fn radial_blur() { + effect_case("ec.blur.radial", &[("amount", n(12.0))]); + effect_case("ec.blur.radial", &[("amount", n(30.0)), ("type", e(1)), ("center", pt(20.3, 14.7))]); + effect_case("ec.blur.radial", &[("amount", n(-45.0)), ("center", pt(50.0, 30.0))]); +} + +#[test] +fn cc_radial_fast_blur() { + effect_case("ec.blur.ccradialfast", &[("amount", n(40.0))]); + effect_case("ec.blur.ccradialfast", &[("amount", n(70.0)), ("zoom", e(1)), ("center", pt(22.4, 16.1))]); + effect_case("ec.blur.ccradialfast", &[("amount", n(25.0)), ("zoom", e(2))]); +} + +#[test] +fn camera_lens_blur() { + effect_case("ec.blur.cameralens", &[("blurRadius", n(5.0))]); + effect_case( + "ec.blur.cameralens", + &[ + ("blurRadius", n(7.5)), + ("irisProperties/irisShape", e(2)), + ("irisProperties/irisRotation", n(20.0)), + ("irisProperties/irisAspectRatio", n(1.6)), + ("repeatEdge", b(true)), + ], + ); + effect_case( + "ec.blur.cameralens", + &[ + ("blurRadius", n(4.0)), + ("irisProperties/irisShape", e(8)), + ("highlight/specularBrightness", n(60.0)), + ("highlight/specularThreshold", n(150.0)), + ("highlight/highlightSaturation", n(40.0)), + ("useLinear", b(true)), + ], + ); + // Large iris: per-row prefix sums. + effect_case("ec.blur.cameralens", &[("blurRadius", n(14.0)), ("irisProperties/irisRoundness", n(60.0))]); + effect_case("ec.blur.cameralens", &[("blurRadius", n(12.0)), ("repeatEdge", b(true)), ("useLinear", b(true))]); +} + +#[test] +fn wipes() { + effect_case("ec.transition.linearwipe", &[("completion", n(40.0)), ("angle", n(30.0)), ("feather", n(12.0))]); + effect_case("ec.transition.linearwipe", &[("completion", n(55.0)), ("angle", n(-120.0))]); + effect_case("ec.transition.linearwipe", &[("completion", n(20.0)), ("angle", n(90.0)), ("feather", n(3.0))]); + effect_case("ec.transition.venetian", &[("completion", n(35.0)), ("direction", n(30.0)), ("width", n(11.0)), ("feather", n(2.0))]); + effect_case("ec.transition.venetian", &[("completion", n(60.0)), ("width", n(17.0))]); + effect_case("ec.transition.venetian", &[("completion", n(25.0)), ("direction", n(-70.0)), ("width", n(9.0)), ("feather", n(0.5))]); + effect_case("ec.transition.radialwipe", &[("completion", n(30.0)), ("startAngle", n(25.0)), ("feather", n(10.0))]); + effect_case("ec.transition.radialwipe", &[("completion", n(45.0)), ("wipe", e(1)), ("center", pt(30.2, 19.6))]); + effect_case("ec.transition.radialwipe", &[("completion", n(60.0)), ("wipe", e(2)), ("startAngle", n(-40.0)), ("feather", n(3.0))]); + effect_case("ec.transition.radialwipe", &[("completion", n(0.0)), ("feather", n(20.0))]); + effect_case("ec.transition.gradientwipe", &[("completion", n(40.0))]); + effect_case("ec.transition.gradientwipe", &[("completion", n(55.0)), ("softness", n(30.0)), ("invert", b(true))]); + effect_case("ec.transition.gradientwipe", &[("completion", n(100.0))]); +} + +/// Gradient Wipe driven by another layer (fetched through the effect host, placed on the CPU). +#[test] +fn gradient_wipe_with_a_gradient_layer() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for placement in 0..3 { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let grad = s.footage(40, 30); + let gid = s.push(grad); + let mut l = s.footage(70, 44); + s.effect( + &mut l, + "ec.transition.gradientwipe", + &[("completion", n(45.0)), ("softness", n(20.0)), ("gradientLayer", Value::Layer(Some(gid.0))), ("gradientPlacement", e(placement))], + ); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let label = format!("gradient wipe layer {placement} {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} + +#[test] +fn generators() { + effect_case("ec.generate.cellpattern", &[("size", n(14.0))]); + effect_case("ec.generate.cellpattern", &[("cellPattern", e(2)), ("size", n(11.0)), ("evolution", n(130.0)), ("overflow", e(1)), ("contrast", n(150.0))]); + effect_case( + "ec.generate.cellpattern", + &[ + ("cellPattern", e(11)), + ("size", n(9.0)), + ("tilingOptions/enableTiling", b(true)), + ("tilingOptions/cellsHorizontal", n(4.0)), + ("tilingOptions/cellsVertical", n(3.0)), + ("overflow", e(2)), + ("contrast", n(250.0)), + ("invert", b(true)), + ("evolutionOptions/randomSeed", n(17.0)), + ], + ); + effect_case("ec.generate.cellpattern", &[("cellPattern", e(5)), ("size", n(16.0)), ("disperse", n(0.6)), ("offset", pt(3.3, -7.1))]); + effect_case("ec.generate.checkerboard", &[("width", n(9.0))]); + effect_case( + "ec.generate.checkerboard", + &[ + ("sizeFrom", e(2)), + ("width", n(13.0)), + ("height", n(7.0)), + ("feather/featherWidth", n(4.0)), + ("anchor", pt(5.3, 2.2)), + ("blendingMode", e(5)), + ("opacity", n(70.0)), + ], + ); + effect_case("ec.generate.checkerboard", &[("sizeFrom", e(0)), ("corner", pt(16.0, 11.0)), ("color", c(0.9, 0.3, 0.1)), ("blendingMode", e(1))]); + effect_case("ec.generate.grid", &[("width", n(12.0)), ("border", n(3.0))]); + effect_case( + "ec.generate.grid", + &[ + ("sizeFrom", e(2)), + ("width", n(15.0)), + ("height", n(9.0)), + ("border", n(2.5)), + ("feather/featherWidth", n(3.0)), + ("feather/featherHeight", n(2.0)), + ("invertGrid", b(true)), + ("blendingMode", e(4)), + ], + ); + effect_case( + "ec.generate.grid", + &[("sizeFrom", e(0)), ("corner", pt(11.0, 8.0)), ("anchor", pt(2.5, 3.5)), ("blendingMode", e(14)), ("color", c(0.2, 0.8, 0.4))], + ); + effect_case("ec.generate.fourcolor", &[]); + effect_case("ec.generate.fourcolor", &[("blend", n(40.0)), ("jitter", n(50.0)), ("opacity", n(80.0)), ("blendingMode", e(3))]); + effect_case("ec.generate.fourcolor", &[("blend", n(600.0)), ("blendingMode", e(16)), ("positionsColors/point2", pt(30.0, 20.0))]); +} + +#[test] +fn noise_and_grain() { + effect_case("ec.noise.noise", &[("amount", n(30.0))]); + effect_case("ec.noise.noise", &[("amount", n(50.0)), ("color", b(false))]); + effect_case("ec.noise.noise", &[("amount", n(40.0)), ("clip", b(false))]); + effect_case("ec.noise.addgrain", &[("tweaking/intensity", n(2.0))]); + effect_case( + "ec.noise.addgrain", + &[ + ("tweaking/intensity", n(3.0)), + ("tweaking/size", n(2.5)), + ("tweaking/softness", n(1.5)), + ("tweaking/aspectRatio", n(1.4)), + ("tweaking/channelSize/redSize", n(0.6)), + ("color/saturation", n(0.5)), + ("color/tintAmount", n(0.4)), + ("color/tintColor", c(1.0, 0.6, 0.2)), + ("application/blendingMode", e(4)), + ], + ); + effect_case( + "ec.noise.addgrain", + &[ + ("tweaking/intensity", n(1.5)), + ("color/monochromatic", b(true)), + ("application/blendingMode", e(3)), + ("application/shadows", n(0.3)), + ("application/highlights", n(1.8)), + ("application/midpoint", n(0.35)), + ("animation/randomSeed", n(42.0)), + ("animation/animationSpeed", n(2.0)), + ], + ); + effect_case( + "ec.noise.addgrain", + &[("tweaking/intensity", n(2.0)), ("application/blendingMode", e(1)), ("tweaking/channelIntensities/blueIntensity", n(2.5))], + ); +} diff --git a/docs/effects.md b/docs/effects.md index 75d32478..a69f91b6 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -5,7 +5,7 @@ EffectCraft ships 303 effects with 2434 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**: 24 effects also run on the GPU compositor with identical results. +- **GPU**: 37 effects also run on the GPU compositor with identical results. - **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping. - **Status**: 261 implemented in full, 42 partial (what is missing is listed). @@ -49,16 +49,16 @@ Effects are addressed by id (`ec..`) or display name in commands | Bilateral Blur | `ec.blur.bilateral` | 3 | | 32 | Implemented | | CC Cross Blur | `ec.blur.cccross` | 3 | | 32 | Implemented | | CC Radial Blur | `ec.blur.ccradial` | 4 | | 32 | Implemented | -| CC Radial Fast Blur | `ec.blur.ccradialfast` | 3 | | 32 | Implemented | +| CC Radial Fast Blur | `ec.blur.ccradialfast` | 3 | GPU | 32 | Implemented | | CC Vector Blur | `ec.blur.ccvector` | 4 | | 32 | Implemented | -| Camera Lens Blur | `ec.blur.cameralens` | 10 | | 32 | Partial: no Diffraction Fringe or Blur Map layer | +| Camera Lens Blur | `ec.blur.cameralens` | 10 | GPU | 32 | Partial: no Diffraction Fringe or Blur Map layer | | Camera-Shake Deblur | `ec.blur.camerashakedeblur` | 8 | | 32 | Implemented | | Channel Blur | `ec.blur.channel` | 6 | | 32 | Implemented | | Compound Blur | `ec.blur.compound` | 4 | | 32 | Implemented | | Directional Blur | `ec.blur.directional` | 2 | GPU | 32 | Implemented | | Fast Box Blur | `ec.blur.fastbox` | 4 | GPU | 32 | Implemented | | Gaussian Blur | `ec.blur.gaussian` | 3 | GPU | 32 | Implemented | -| Radial Blur | `ec.blur.radial` | 4 | | 32 | Implemented | +| Radial Blur | `ec.blur.radial` | 4 | GPU | 32 | Implemented | | Sharpen | `ec.blur.sharpen` | 1 | | 32 | Implemented | | Smart Blur | `ec.blur.smart` | 3 | | 32 | Implemented | | Unsharp Mask | `ec.blur.unsharp` | 3 | | 32 | Implemented | @@ -187,7 +187,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| 4-Color Gradient | `ec.generate.fourcolor` | 12 | | 32 | Implemented | +| 4-Color Gradient | `ec.generate.fourcolor` | 12 | GPU | 32 | Implemented | | Advanced Lightning | `ec.generate.advancedlightning` | 16 | | 32 | Partial: no Alpha Obstacle; most Expert Settings missing | | Audio Spectrum | `ec.generate.audiospectrum` | 23 | | 32 | Implemented | | Audio Waveform | `ec.generate.audiowaveform` | 17 | | 32 | Implemented | @@ -197,15 +197,15 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Light Rays | `ec.generate.cclightrays` | 8 | | 32 | Implemented | | CC Light Sweep | `ec.generate.cclightsweep` | 9 | | 32 | Implemented | | CC Threads | `ec.generate.ccthreads` | 8 | | 32 | Implemented | -| Cell Pattern | `ec.generate.cellpattern` | 14 | | 32 | Partial: HQ variants render like the standard ones | -| Checkerboard | `ec.generate.checkerboard` | 10 | | 32 | Implemented | +| Cell Pattern | `ec.generate.cellpattern` | 14 | GPU | 32 | Partial: HQ variants render like the standard ones | +| Checkerboard | `ec.generate.checkerboard` | 10 | GPU | 32 | Implemented | | Circle | `ec.generate.circle` | 11 | | 32 | Implemented | | Ellipse | `ec.generate.ellipse` | 8 | | 32 | Implemented | | Eyedropper Fill | `ec.generate.eyedropperfill` | 5 | | 32 | Implemented | | Fill | `ec.generate.fill` | 3 | GPU | 32 | Partial: no Fill Mask / All Masks / feather | | Fractal | `ec.generate.fractal` | 21 | | 32 | Implemented | | Gradient Ramp | `ec.generate.gradientramp` | 7 | GPU | 32 | Implemented | -| Grid | `ec.generate.grid` | 12 | | 32 | Implemented | +| Grid | `ec.generate.grid` | 12 | GPU | 32 | Implemented | | Lens Flare | `ec.generate.lensflare` | 4 | | 32 | Implemented | | Paint Bucket | `ec.generate.paintbucket` | 11 | | 32 | Implemented | | Radio Waves | `ec.generate.radiowaves` | 17 | | 32 | Partial: no Image Contours or Mask wave types, Parameters Are Set At, reflection or stroke profile | @@ -263,14 +263,14 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Add Grain | `ec.noise.addgrain` | 22 | | 32 | Partial: no preview region, presets, channel balance or temporal controls | +| Add Grain | `ec.noise.addgrain` | 22 | GPU | 32 | Partial: no preview region, presets, channel balance or temporal controls | | Curl Noise | `ec.noise.curlnoise` | 10 | | 32 | Implemented | | Dust & Scratches | `ec.noise.dustscratches` | 3 | | 32 | Implemented | | Fractal Noise | `ec.noise.fractal` | 26 | GPU | 32 | Implemented | | Match Grain | `ec.noise.matchgrain` | 25 | | 32 | Partial: no preview region, presets, sampling or temporal controls | | Median | `ec.noise.median` | 2 | | 32 | Implemented | | Median (Legacy) | `ec.noise.medianlegacy` | 2 | | 32 | Implemented | -| Noise | `ec.noise.noise` | 3 | | 32 | Implemented | +| Noise | `ec.noise.noise` | 3 | GPU | 32 | Implemented | | Noise Alpha | `ec.noise.noisealpha` | 8 | | 32 | Implemented | | Noise HLS | `ec.noise.noisehls` | 6 | | 32 | Implemented | | Noise HLS Auto | `ec.noise.noisehlsauto` | 6 | | 32 | Implemented | @@ -401,11 +401,11 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Twister | `ec.transition.cctwister` | 5 | | 32 | Implemented | | CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | | 32 | Implemented | | Card Wipe | `ec.transition.cardwipe` | 12 | | 32 | Partial: 2D flip only; no back layer picker, camera, lighting or jitter | -| Gradient Wipe | `ec.transition.gradientwipe` | 5 | | 32 | Implemented | +| Gradient Wipe | `ec.transition.gradientwipe` | 5 | GPU | 32 | Implemented | | Iris Wipe | `ec.transition.iriswipe` | 7 | | 32 | Implemented | -| Linear Wipe | `ec.transition.linearwipe` | 3 | | 32 | Implemented | -| Radial Wipe | `ec.transition.radialwipe` | 5 | | 32 | Implemented | -| Venetian Blinds | `ec.transition.venetian` | 4 | | 32 | Implemented | +| Linear Wipe | `ec.transition.linearwipe` | 3 | GPU | 32 | Implemented | +| Radial Wipe | `ec.transition.radialwipe` | 5 | GPU | 32 | Implemented | +| Venetian Blinds | `ec.transition.venetian` | 4 | GPU | 32 | Implemented | ## Utility From 91ab3a3fc56feb1943e70970043658ff08839ca9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:46:52 -0400 Subject: [PATCH 7/9] M12.7: GPU colour effects (Color Balance, Vibrance, Lumetri, Black & White, Tritone, Colorama, Channel Mixer, Selective Color, Linear Color Key, Key Light) fx_color kernels mirror the CPU effects operation for operation: one per-pixel pass for the point effects, Lumetri with its Sharpen luminance blur, and Key Light's matte pipeline (pre-blur, clip, rollback, despot, shrink/grow, softness, edge band) as plane passes. CPU fallback: Key Light with Inside/Outside masks or Hard Colour after Screen Softness, Colorama with Interpolate Palette off. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 11 + crates/gpu/src/fx_color.rs | 390 ++++++++++++++- crates/gpu/src/lib.rs | 2 + crates/gpu/src/shaders/fx_color.wgsl | 705 +++++++++++++++++++++++++++ crates/gpu/src/tests_fx_color.rs | 163 +++++++ docs/effects.md | 22 +- 6 files changed, 1276 insertions(+), 17 deletions(-) create mode 100644 crates/gpu/src/tests_fx_color.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a5b4f346..b5a1c9d2 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -641,6 +641,17 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.color.exposure", "ec.channel.invert", "ec.distort.transform", + // effectcraft-gpu fx_color + "ec.color.colorbalance", + "ec.color.vibrance", + "ec.color.lumetri", + "ec.color.blackwhite", + "ec.color.tritone", + "ec.color.colorama", + "ec.color.channelmixer", + "ec.color.selectivecolor", + "ec.key.linearcolor", + "ec.keying.keylight", ]; /// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated diff --git a/crates/gpu/src/fx_color.rs b/crates/gpu/src/fx_color.rs index 8205f042..824f9a50 100644 --- a/crates/gpu/src/fx_color.rs +++ b/crates/gpu/src/fx_color.rs @@ -1,18 +1,396 @@ //! GPU effects, colour family (kernels in `shaders/fx_color.wgsl`): the CPU effects' exact //! steps with the pixel loops as compute kernels. +//! +//! Colour Balance, Vibrance, Black & White, Tritone, Colorama, Channel Mixer, Selective Color +//! and Linear Color Key are one per-pixel pass (`fxc_point`); Lumetri Color is a per-pixel pass +//! plus, with Sharpen, a luminance plane blurred like `util::gauss_plane`; Key Light runs its +//! matte pipeline (pre-blur, clip, rollback, despot, shrink / grow, softness, edge band) as +//! plane passes. Key Light with an Inside / Outside mask, or Hard Colour replacement after +//! Screen Softness, and Colorama with Interpolate Palette off run on the CPU. +use effectcraft_color::{luminance, rgb_to_hsl}; use effectcraft_effects::EffectCtx; -use crate::context::Enc; -use crate::effects::GBuf; +use crate::context::{Enc, GpuImage, Params}; +use crate::effects::{GBuf, gaussian_blur}; /// Compute entry points in `fx_color.wgsl`. -pub(crate) const KERNELS: &[&str] = &[]; +pub(crate) const KERNELS: &[&str] = &[ + "fxc_point", + "fxc_lumetri", + "fxc_luma", + "fxc_sharpen", + "fxc_morph", + "fxc_lerp", + "fxc_kl_raw", + "fxc_kl_rollback", + "fxc_kl_pack", + "fxc_kl_band", + "fxc_kl_edge", + "fxc_kl_final", +]; /// Effect ids implemented here. -pub(crate) const IDS: &[&str] = &[]; +pub(crate) const IDS: &[&str] = &[ + "ec.color.colorbalance", + "ec.color.vibrance", + "ec.color.lumetri", + "ec.color.blackwhite", + "ec.color.tritone", + "ec.color.colorama", + "ec.color.channelmixer", + "ec.color.selectivecolor", + "ec.key.linearcolor", + "ec.keying.keylight", +]; /// 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 { - None +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + match id { + "ec.color.lumetri" => lumetri(e, ctx, b), + "ec.keying.keylight" => key_light(e, ctx, b), + _ => point(e, id, ctx, b), + } +} + +fn rgb(c: [f32; 4]) -> [f32; 4] { + [c[0], c[1], c[2], 0.0] +} + +/// Same selective colour ids as `color2::SC_IDS`. +const SC_IDS: [[&str; 4]; 9] = [ + ["redsCyan", "redsMagenta", "redsYellow", "redsBlack"], + ["yellowsCyan", "yellowsMagenta", "yellowsYellow", "yellowsBlack"], + ["greensCyan", "greensMagenta", "greensYellow", "greensBlack"], + ["cyansCyan", "cyansMagenta", "cyansYellow", "cyansBlack"], + ["bluesCyan", "bluesMagenta", "bluesYellow", "bluesBlack"], + ["magentasCyan", "magentasMagenta", "magentasYellow", "magentasBlack"], + ["whitesCyan", "whitesMagenta", "whitesYellow", "whitesBlack"], + ["neutralsCyan", "neutralsMagenta", "neutralsYellow", "neutralsBlack"], + ["blacksCyan", "blacksMagenta", "blacksYellow", "blacksBlack"], +]; + +fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let f = |k: &str| pr.f(k) as f32; + let mut p = Params::default(); + let mut data = None; + match id { + "ec.color.colorbalance" => { + let g = |k: &str| f(k) / 100.0; + p.u[0] = [1, pr.b("preserveLuminosity") as u32, 0, 0]; + p.f[0] = [g("shadowRed"), g("shadowGreen"), g("shadowBlue"), 0.0]; + p.f[1] = [g("midRed"), g("midGreen"), g("midBlue"), 0.0]; + p.f[2] = [g("hiRed"), g("hiGreen"), g("hiBlue"), 0.0]; + } + "ec.color.vibrance" => { + p.u[0][0] = 2; + p.f[0] = [f("vibrance") / 100.0, f("saturation") / 100.0, 0.0, 0.0]; + } + "ec.color.blackwhite" => { + let w = ["reds", "yellows", "greens", "cyans", "blues", "magentas"].map(|k| f(k) / 100.0); + let tc = pr.color("tintColor"); + let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]); + p.u[0] = [3, pr.b("tint") as u32, 0, 0]; + p.f[0] = [w[0], w[1], w[2], w[3]]; + p.f[1] = [w[4], w[5], 0.0, 0.0]; + p.f[2] = [th, ts, 0.0, 0.0]; + } + "ec.color.tritone" => { + p.u[0][0] = 4; + p.f[0] = rgb(pr.color("highlights")); + p.f[1] = rgb(pr.color("midtones")); + p.f[2] = rgb(pr.color("shadows")); + p.f[3][0] = 1.0 - f("blend") / 100.0; + } + "ec.color.colorama" => { + let smooth = pr.get("outputCycle/interpolatePalette").is_none_or(|v| v.as_bool()); + // The posterized palette is discontinuous: phases on a step (common with 8 bpc + // input) can round to the other side in the GPU's float maths, so it runs on the CPU. + if !smooth { + return None; + } + p.u[0] = [5, pr.e("inputPhase/mode"), smooth as u32, 0]; + p.f[0] = [pr.f("outputCycle/cycles").max(0.0) as f32, f("inputPhase/phaseShift") / 360.0, 1.0 - f("blend") / 100.0, 0.0]; + } + "ec.color.channelmixer" => { + let g = |k: &str| f(k) / 100.0; + p.u[0] = [6, pr.b("monochrome") as u32, 0, 0]; + p.f[0] = [g("rr"), g("rg"), g("rb"), g("rc")]; + p.f[1] = [g("gr"), g("gg"), g("gb"), g("gc")]; + p.f[2] = [g("br"), g("bg"), g("bb"), g("bc")]; + } + "ec.color.selectivecolor" => { + let adj: Vec = SC_IDS.iter().flat_map(|ids| ids.map(|k| f(k) / 100.0)).collect(); + if adj.iter().all(|v| *v == 0.0) { + return Some(b); + } + p.u[0] = [7, (pr.e("method") == 0) as u32, 0, 0]; + data = Some(e.data(&adj)); + } + "ec.key.linearcolor" => { + let view = pr.e("view"); + if view == 1 { + return Some(b); + } + let k = pr.color("keyColor"); + let s = k[0] + k[1] + k[2]; + let kc = if s <= 1e-6 { [1.0 / 3.0, 1.0 / 3.0] } else { [k[0] / s, k[1] / s] }; + let (kh, _, _) = rgb_to_hsl(k[0], k[1], k[2]); + p.u[0] = [8, pr.e("matchColors"), (pr.e("keyOperation") == 1) as u32, view]; + p.f[0] = [k[0], k[1], k[2], f("tolerance") / 100.0]; + p.f[1] = [f("softness") / 100.0, kc[0], kc[1], kh]; + } + _ => return None, + } + let out = e.image(b.img.width, b.img.height); + e.pixels("fxc_point", &p, &b.img, None, &out, data.as_ref()); + Some(GBuf { img: out, ..b }) +} + +/// color3::tint_offset. +fn tint_offset(c: [f32; 4]) -> [f32; 3] { + let l = (c[0] + c[1] + c[2]) / 3.0; + [c[0] - l, c[1] - l, c[2] - l] +} + +fn lumetri(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let f = |k: &str| (pr.f(k) / 100.0) as f32; + let mut d: Vec = vec![ + f("basicCorrection/whiteBalance/temperature"), + f("basicCorrection/whiteBalance/tint"), + 2f32.powf(pr.f("basicCorrection/tone/exposure") as f32), + f("basicCorrection/tone/contrast"), + f("basicCorrection/tone/highlights"), + f("basicCorrection/tone/shadows"), + f("basicCorrection/tone/whites"), + f("basicCorrection/tone/blacks"), + f("basicCorrection/saturation"), + f("creative/lookIntensity"), + f("creative/adjustments/fadedFilm"), + f("creative/adjustments/vibrance"), + f("creative/adjustments/creativeSaturation"), + (0.5 + 0.25 * f("creative/adjustments/tintBalance")).clamp(0.05, 0.95), + ]; + d.extend(tint_offset(pr.color("creative/adjustments/shadowTint"))); + d.extend(tint_offset(pr.color("creative/adjustments/highlightTint"))); + for c in ["Master", "Red", "Green", "Blue"] { + for part in ["Shadows", "Midtones", "Highlights"] { + d.push(f(&format!("curves/curve{c}{part}"))); + } + } + for w in ["shadowsWheel", "midtonesWheel", "highlightsWheel"] { + d.extend(tint_offset(pr.color(&format!("colorWheels/{w}")))); + } + let (lx, ly) = (b.offset[0], b.offset[1]); + let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + d.extend([ + pr.f("vignette/vignetteAmount") as f32, + f("vignette/vignetteMidpoint") * 0.9, + f("vignette/vignetteRoundness"), + f("vignette/vignetteFeather"), + (lx + lw * 0.5) as f32, + (ly + lh * 0.5) as f32, + lw as f32, + lh as f32, + (lw / lh.max(1e-6)) as f32, + ]); + debug_assert_eq!(d.len(), 50); + let buf = e.data(&d); + let out = e.image(b.img.width, b.img.height); + e.pixels("fxc_lumetri", &Params::default(), &b.img, None, &out, Some(&buf)); + let sharpen = f("creative/adjustments/sharpen"); + if sharpen == 0.0 { + return Some(GBuf { img: out, ..b }); + } + let luma = e.image(out.width, out.height); + e.pixels("fxc_luma", &Params::default(), &out, None, &luma, None); + let blurred = gaussian_blur(e, &luma, b.scale, b.scale, true); + let mut p = Params::default(); + p.f[0][0] = sharpen * 2.0; + let sharp = e.image(out.width, out.height); + e.pixels("fxc_sharpen", &p, &out, Some(&blurred), &sharp, None); + Some(GBuf { img: sharp, ..b }) +} + +// ---------------------------------------------------------------- planes + +/// util::morph_plane with the same radius on both axes; `modes` = per-channel operation +/// (0 erode, 1 dilate, 2 keep). +fn morph(e: &mut Enc, img: &GpuImage, r: u32, modes: [u32; 4]) -> GpuImage { + if r == 0 { + return img.clone(); + } + let mut cur = img.clone(); + for vertical in [0, 1] { + let mut p = Params::default(); + p.u[0] = [r, vertical, 0, 0]; + p.u[1] = modes; + let out = e.image(cur.width, cur.height); + e.pixels("fxc_morph", &p, &cur, None, &out, None); + cur = out; + } + cur +} + +/// util::morph_frac (blend between the two nearest integer radii). +fn morph_frac(e: &mut Enc, img: &GpuImage, radius: f64, modes: [u32; 4]) -> GpuImage { + if radius <= 0.0 { + return img.clone(); + } + let r0 = radius.floor() as u32; + let t = (radius - r0 as f64) as f32; + let a = morph(e, img, r0, modes); + if t < 1e-4 { + return a; + } + let b = morph(e, img, r0 + 1, modes); + let mut p = Params::default(); + p.f[0][0] = t; + let out = e.image(img.width, img.height); + e.pixels("fxc_lerp", &p, &a, Some(&b), &out, None); + out +} + +const ERODE_X: [u32; 4] = [0, 2, 2, 2]; +const DILATE_X: [u32; 4] = [1, 2, 2, 2]; + +// ---------------------------------------------------------------- Key Light + +/// keylight::primary. +fn primary(s: [f32; 3]) -> [u32; 3] { + let pi: usize = if s[1] >= s[0] && s[1] >= s[2] { + 1 + } else if s[2] >= s[0] { + 2 + } else { + 0 + }; + let (a, b) = match pi { + 0 => (1, 2), + 1 => (0, 2), + _ => (0, 1), + }; + let ix = if s[a] >= s[b] { [pi, a, b] } else { [pi, b, a] }; + ix.map(|i| i as u32) +} + +fn neutralise(c: [f32; 3], bias: [f32; 3]) -> [f32; 3] { + let l = luminance(bias[0], bias[1], bias[2]).max(1e-4); + [0, 1, 2].map(|i| c[i] * l / bias[i].max(1e-4)) +} + +fn key_light(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let view = pr.e("view"); + if view == 0 { + return Some(b); + } + // Inside / Outside masks (polygon coverage) run on the CPU. + let has_mask = |id: &str| pr.e(id).checked_sub(1).is_some_and(|i| ctx.env.masks.get(i as usize).is_some()); + if has_mask("insideMask/insideMask") || has_mask("outsideMask/outsideMask") { + return None; + } + let sc = pr.color("screenColour"); + let screen = [sc[0], sc[1], sc[2]]; + let ix = primary(screen); + let (pi, o1, o2) = (ix[0] as usize, ix[1] as usize, ix[2] as usize); + let gain = pr.f("screenGain") as f32 / 100.0; + let bal = pr.f("screenBalance") as f32 / 100.0; + let dbias = pr.color("despillBias"); + let abias = if pr.b("lockBiasesTogether") { dbias } else { pr.color("alphaBias") }; + let (dbias, abias) = ([dbias[0], dbias[1], dbias[2]], [abias[0], abias[1], abias[2]]); + let s_alpha = neutralise(screen, abias); + let sd = (s_alpha[pi] - (bal * s_alpha[o1] + (1.0 - bal) * s_alpha[o2])).max(1e-3); + let cb = pr.f("screenMatte/clipBlack") as f32 / 100.0; + let cw = pr.f("screenMatte/clipWhite") as f32 / 100.0; + let rollback = pr.f("screenMatte/clipRollback") * b.scale; + let preblur = pr.f("screenPreblur") * b.scale; + let sg = pr.f("screenMatte/screenShrinkGrow") * b.scale; + let soft = pr.f("screenMatte/screenSoftness") * b.scale; + let despot_b = pr.f("screenMatte/screenDespotBlack") * b.scale; + let despot_w = pr.f("screenMatte/screenDespotWhite") * b.scale; + // Hard Colour replaces wherever the screen matte exceeds the raw matte by any amount; after + // Screen Softness's blur that compares float-rounded sums, so it runs on the CPU. + if pr.e("screenMatte/replaceMethod") == 2 && soft > 0.0 { + return None; + } + let (w, h) = (b.img.width, b.img.height); + + let key_src = if preblur > 0.0 { gaussian_blur(e, &b.img, preblur * 0.5, preblur * 0.5, true) } else { b.img.clone() }; + let mut p = Params::default(); + p.u[0] = [ix[0], ix[1], ix[2], 0]; + p.f[0] = [abias[0], abias[1], abias[2], sd]; + p.f[1] = [gain, bal, cb, cw]; + let raw = e.image(w, h); + e.pixels("fxc_kl_raw", &p, &key_src, None, &raw, None); + let mut m = raw.clone(); + if rollback > 0.0 { + let sg_img = morph_frac(e, &m, rollback, [0, 1, 2, 2]); + let out = e.image(w, h); + e.pixels("fxc_kl_rollback", &Params::default(), &sg_img, Some(&raw), &out, None); + m = out; + } + if despot_b > 0.0 { + let d = morph_frac(e, &m, despot_b, DILATE_X); + m = morph_frac(e, &d, despot_b, ERODE_X); + } + if despot_w > 0.0 { + let d = morph_frac(e, &m, despot_w, ERODE_X); + m = morph_frac(e, &d, despot_w, DILATE_X); + } + if sg < 0.0 { + m = morph_frac(e, &m, -sg, ERODE_X); + } else if sg > 0.0 { + m = morph_frac(e, &m, sg, DILATE_X); + } + if soft > 0.0 { + m = gaussian_blur(e, &m, soft * 0.5, soft * 0.5, true); + } + let mut planes = e.image(w, h); + e.pixels("fxc_kl_pack", &Params::default(), &m, Some(&raw), &planes, None); + let src_alpha_mode = pr.e("insideMask/sourceAlpha"); + if pr.b("edgeColourCorrection/enableEdgeColourCorrection") { + let grow = pr.f("edgeColourCorrection/edgeGrow") * b.scale; + let hard = (pr.f("edgeColourCorrection/edgeHardness") / 100.0) as f32; + let esoft = pr.f("edgeColourCorrection/edgeSoftness") * b.scale; + let mut p = Params::default(); + p.u[0][0] = src_alpha_mode; + let mut band = e.image(w, h); + e.pixels("fxc_kl_band", &p, &b.img, Some(&planes), &band, None); + if grow > 0.0 { + band = morph_frac(e, &band, grow, DILATE_X); + } + if esoft > 0.0 { + band = gaussian_blur(e, &band, esoft * 0.5, esoft * 0.5, true); + } + let mut p = Params::default(); + p.f[0][0] = hard; + let out = e.image(w, h); + e.pixels("fxc_kl_edge", &p, &planes, Some(&band), &out, None); + planes = out; + } + let pc = |k: &str| pr.f(k) as f32 / 100.0; + let l0 = (luminance(dbias[0], dbias[1], dbias[2]) / dbias[pi].max(1e-4)).max(1e-4); + let mut p = Params::default(); + p.u[0] = [ix[0], ix[1], ix[2], view]; + p.u[1] = [ + pr.e("screenMatte/replaceMethod"), + pr.b("foregroundColourCorrection/enableColourCorrection") as u32, + pr.b("unpremultiplyResult") as u32, + src_alpha_mode, + ]; + p.f[0] = [screen[0], screen[1], screen[2], bal]; + p.f[1] = [dbias[0], dbias[1], dbias[2], l0]; + p.f[2] = pr.color("screenMatte/replaceColour"); + p.f[3] = + [pc("foregroundColourCorrection/saturation"), pc("foregroundColourCorrection/contrast") + 1.0, pc("foregroundColourCorrection/brightness") + 1.0, 0.0]; + p.f[4] = [pc("edgeColourCorrection/edgeSaturation"), pc("edgeColourCorrection/edgeContrast") + 1.0, pc("edgeColourCorrection/edgeBrightness") + 1.0, 0.0]; + p.f[5] = ["sourceCrops/cropLeft", "sourceCrops/cropRight", "sourceCrops/cropTop", "sourceCrops/cropBottom"].map(|k| (pr.f(k) / 100.0) as f32); + p.f[6] = [b.offset[0] as f32, b.offset[1] as f32, b.scale as f32, 0.0]; + p.f[7] = [ctx.layer_size[0].max(1e-9) as f32, ctx.layer_size[1].max(1e-9) as f32, 0.0, 0.0]; + let out = e.image(w, h); + e.pixels("fxc_kl_final", &p, &b.img, Some(&planes), &out, None); + Some(GBuf { img: out, ..b }) } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 9ebce209..ce872460 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -149,3 +149,5 @@ mod tests_3d; mod tests_adjust; #[cfg(test)] mod tests_adv3d; +#[cfg(test)] +mod tests_fx_color; diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl index 3e42688d..97771a46 100644 --- a/crates/gpu/src/shaders/fx_color.wgsl +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -1,2 +1,707 @@ // GPU effects (color family): see src/fx_color.rs. Each entry point mirrors the CPU effect in // effectcraft-effects operation for operation; parameter layouts are documented per entry. +// Every name here is prefixed `fxc_` (the family files share one module). + +// ---------------------------------------------------------------- helpers + +// util::unpremul: straight colour (0 when alpha ≤ 1e-6). +fn fxc_unpremul(px: vec4) -> vec3 { + if (px.w > 1e-6) { + return px.xyz / px.w; + } + return vec3(0.0); +} + +// util::smoothstep. +fn fxc_smoothstep(e0: f32, e1: f32, x: f32) -> f32 { + if (abs(e1 - e0) < 1e-9) { + return select(1.0, 0.0, x < e0); + } + let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} + +fn fxc_mix(a: vec3, b: vec3, t: f32) -> vec3 { + return a + (b - a) * t; +} + +// color::hsv_to_rgb. +fn fxc_hsv_to_rgb(h: f32, s: f32, v: f32) -> vec3 { + let h6 = fract_euclid(h) * 6.0; + let i = floor(h6); + let f = h6 - i; + let p = v * (1.0 - s); + let q = v * (1.0 - s * f); + let t = v * (1.0 - s * (1.0 - f)); + switch i32(i) { + case 0: { return vec3(v, t, p); } + case 1: { return vec3(q, v, p); } + case 2: { return vec3(p, v, t); } + case 3: { return vec3(p, q, v); } + case 4: { return vec3(t, p, v); } + default: { return vec3(v, p, q); } + } +} + +fn fxc_pixel(gid: vec3) -> vec2 { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return vec2(-1); + } + return p; +} + +// ---------------------------------------------------------------- per-pixel colour effects + +// Colorama's input phase (color_fx::colorama_phase). +fn fxc_colorama_phase(c: vec3, a: f32, mode: u32) -> f32 { + var v: f32; + switch mode { + case 1u: { v = c.x; } + case 2u: { v = c.y; } + case 3u: { v = c.z; } + case 4u: { v = rgb_to_hsl(c).x; } + case 5u: { v = rgb_to_hsl(c).z; } + case 6u: { v = rgb_to_hsl(c).y; } + case 7u: { v = max(max(c.x, c.y), c.z); } + case 8u: { v = a; } + case 9u: { v = 0.0; } + default: { v = luminance(c); } + } + return clamp(v, 0.0, 1.0); +} + +// Selective Color range weights (color2::sc_weights). +fn fxc_sc_weights(c0: vec3) -> array { + let c = clamp(c0, vec3(0.0), vec3(1.0)); + // Stable descending sort of the channel indices. + var idx = vec3(0u, 1u, 2u); + for (var i = 1u; i < 3u; i++) { + var j = i; + while (j > 0u && c[idx[j]] > c[idx[j - 1u]]) { + let t = idx[j]; + idx[j] = idx[j - 1u]; + idx[j - 1u] = t; + j--; + } + } + let mx = c[idx.x]; + let md = c[idx.y]; + let mn = c[idx.z]; + var w = array(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + var prim = array(0u, 2u, 4u); + var sec = array(3u, 5u, 1u); + w[prim[idx.x]] = mx - md; + w[sec[idx.z]] = md - mn; + w[6] = max((mn - 0.5) * 2.0, 0.0); + w[8] = max((0.5 - mx) * 2.0, 0.0); + w[7] = clamp(1.0 - (abs(mx - 0.5) + abs(mn - 0.5)), 0.0, 1.0); + return w; +} + +// u[0].x = op: +// 1 Color Balance f[0] = shadows, f[1] = midtones, f[2] = highlights; u[0].y = preserve luminosity +// 2 Vibrance f[0] = (vibrance, saturation) +// 3 Black & White f[0] = (reds, yellows, greens, cyans), f[1] = (blues, magentas); +// u[0].y = tint; f[2] = (tint hue, tint saturation) +// 4 Tritone f[0] = highlights, f[1] = midtones, f[2] = shadows; f[3].x = 1 − blend +// 5 Colorama u[0].y = phase mode, u[0].z = interpolate; f[0] = (cycles, shift, 1 − blend) +// 6 Channel Mixer f[0..3] = output rows (r, g, b, const); u[0].y = monochrome +// 7 Selective Color data = 9 × (cyan, magenta, yellow, black); u[0].y = relative +// 8 Linear Color Key f[0] = (key rgb, tolerance), f[1] = (softness, key chroma xy, key hue); +// u[0] = (_, match mode, keep colours, view) +@compute @workgroup_size(16, 16) +fn fxc_point(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + var px = textureLoad(src, p, 0); + let a = px.w; + switch P.u[0].x { + case 1u: { + if (a > 0.0) { + let c = px.xyz / a; + let l = clamp(luminance(c), 0.0, 1.0); + let ws = clamp(1.0 - l * 2.0, 0.0, 1.0); + let wh = clamp(l * 2.0 - 1.0, 0.0, 1.0); + let wm = 1.0 - ws - wh; + var o = max(c + (P.f[0].xyz * ws + P.f[1].xyz * wm + P.f[2].xyz * wh) * 0.5, vec3(0.0)); + if (P.u[0].y != 0u) { + let d = luminance(c) - luminance(o); + o = max(o + d, vec3(0.0)); + } + px = vec4(o * a, a); + } + } + case 2u: { + if (a > 0.0) { + let c = px.xyz / a; + let hsl = rgb_to_hsl(c); + let vib = P.f[0].x; + let sat = P.f[0].y; + let s = clamp(hsl.y * (1.0 + sat) + vib * (1.0 - hsl.y) * min(hsl.y, 0.5), 0.0, 1.0); + px = vec4(hsl_to_rgb(hsl.x, s, hsl.z) * a, a); + } + } + case 3u: { + if (a > 0.0) { + let c = px.xyz / a; + var w = array(P.f[0].x, P.f[0].y, P.f[0].z, P.f[0].w, P.f[1].x, P.f[1].y); + let hsl = rgb_to_hsl(c); + let hp = hsl.x * 6.0; + let i = u32(max(floor(hp), 0.0)) % 6u; + let f = hp - floor(hp); + let wt = w[i] + (w[(i + 1u) % 6u] - w[i]) * f; + let base = luminance(c); + let gray = max(base + (wt - 0.5) * hsl.y * 0.6, 0.0); + var o = vec3(gray); + if (P.u[0].y != 0u) { + o = hsl_to_rgb(P.f[2].x, P.f[2].y, min(gray, 1.0)); + } + px = vec4(o * a, a); + } + } + case 4u: { + if (a > 0.0) { + let c = px.xyz / a; + let l = clamp(luminance(c), 0.0, 1.0); + var t: vec3; + if (l < 0.5) { + t = fxc_mix(P.f[2].xyz, P.f[1].xyz, l * 2.0); + } else { + t = fxc_mix(P.f[1].xyz, P.f[0].xyz, (l - 0.5) * 2.0); + } + px = vec4(fxc_mix(c, t, P.f[3].x) * a, a); + } + } + case 5u: { + if (a > 0.0) { + let c = px.xyz / a; + var ph = fract_euclid(fxc_colorama_phase(c, a, P.u[0].y) * P.f[0].x + P.f[0].y); + if (P.u[0].z == 0u) { + ph = floor(ph * 6.0) / 6.0; + } + let o = fxc_mix(c, fxc_hsv_to_rgb(ph, 1.0, 1.0), P.f[0].z); + px = vec4(o * a, a); + } + } + case 6u: { + if (a > 0.0) { + let c = px.xyz / a; + var o: vec3; + for (var i = 0; i < 3; i++) { + let r = P.f[i]; + o[i] = max(c.x * r.x + c.y * r.y + c.z * r.z + r.w, 0.0); + } + if (P.u[0].y != 0u) { + o = vec3(o.x); + } + px = vec4(o * a, a); + } + } + case 7u: { + // color2::map_ca: fully transparent black stays. + if (a <= 0.0 && px.x == 0.0 && px.y == 0.0 && px.z == 0.0) { + textureStore(out, p, px); + return; + } + let c = fxc_unpremul(px); + var w = fxc_sc_weights(c); + var o = c; + let relative = P.u[0].y != 0u; + for (var r = 0u; r < 9u; r++) { + let wr = w[r]; + if (wr <= 0.0) { + continue; + } + let k = data[r * 4u + 3u]; + for (var i = 0u; i < 3u; i++) { + let ink = 1.0 - c[i]; + var delta = data[r * 4u + i] + k; + if (relative) { + delta = delta * max(ink, 0.0); + } + o[i] -= wr * delta; + } + } + let na = clamp(max(a, 0.0), 0.0, 1.0); + px = vec4(max(o, vec3(0.0)) * na, na); + } + case 8u: { + let c = fxc_unpremul(px); + let key = P.f[0].xyz; + let tol = P.f[0].w; + let soft = P.f[1].x; + var d: f32; + switch P.u[0].y { + case 1u: { + let hsl = rgb_to_hsl(c); + if (hsl.y < 0.02) { + d = 1.0; + } else { + let dh = abs(hsl.x - P.f[1].w); + d = min(dh, 1.0 - dh) * 2.0; + } + } + case 2u: { + let s = c.x + c.y + c.z; + var pc = vec2(1.0 / 3.0, 1.0 / 3.0); + if (s > 1e-6) { + pc = vec2(c.x / s, c.y / s); + } + let dx = pc.x - P.f[1].y; + let dy = pc.y - P.f[1].z; + d = sqrt(dx * dx + dy * dy) / 1.4142135; + } + default: { + let e = c - key; + d = sqrt(e.x * e.x + e.y * e.y + e.z * e.z) / 1.7320508; + } + } + var v: f32; + if (soft > 1e-6) { + v = clamp((d - tol) / soft, 0.0, 1.0); + } else if (d > tol) { + v = 1.0; + } else { + v = 0.0; + } + if (P.u[0].z != 0u) { + v = 1.0 - v; + } + let k = clamp(v, 0.0, 1.0); + if (P.u[0].w == 2u) { + let m = px.w * k; + px = vec4(m, m, m, 1.0); + } else { + px = px * k; + } + } + default: {} + } + textureStore(out, p, px); +} + +// ---------------------------------------------------------------- Lumetri Color (color3) + +fn fxc_tone_curve(x: f32, s: f32, m: f32, h: f32) -> f32 { + if (s == 0.0 && m == 0.0 && h == 0.0) { + return x; + } + let t = clamp(x, 0.0, 1.0); + let d0 = (t - 0.25) / 0.25; + let d1 = (t - 0.5) / 0.25; + let d2 = (t - 0.75) / 0.25; + let b0 = max(1.0 - d0 * d0, 0.0); + let b1 = max(1.0 - d1 * d1, 0.0); + let b2 = max(1.0 - d2 * d2, 0.0); + return x + 0.25 * (s * (b0 * b0) + m * (b1 * b1) + h * (b2 * b2)); +} + +fn fxc_d3(i: u32) -> vec3 { + return vec3(data[i], data[i + 1u], data[i + 2u]); +} + +// data: 0 temperature, 1 tint, 2 exposure gain, 3 contrast, 4 highlights, 5 shadows, 6 whites, +// 7 blacks, 8 saturation, 9 look intensity, 10 faded film, 11 vibrance, 12 creative saturation, +// 13 tint balance pivot, 14..17 shadow tint, 17..20 highlight tint, 20..32 curves (master, red, +// 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. +@compute @workgroup_size(16, 16) +fn fxc_lumetri(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + var px = textureLoad(src, p, 0); + let a = px.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + var c = fxc_unpremul(px); + let temp = data[0]; + let tint = data[1]; + if (temp != 0.0 || tint != 0.0) { + c = vec3(c.x * (1.0 + 0.25 * temp), c.y * (1.0 - 0.25 * tint), c.z * (1.0 - 0.25 * temp)); + } + let expo = data[2]; + if (expo != 1.0) { + c = c * expo; + } + let contrast = data[3]; + if (contrast != 0.0) { + c = 0.5 + (c - 0.5) * (1.0 + contrast); + } + let hi = data[4]; + let sh = data[5]; + let wh = data[6]; + let bl = data[7]; + if (hi != 0.0 || sh != 0.0 || wh != 0.0 || bl != 0.0) { + let l = max(luminance(c), 0.0); + let lt = min(l, 1.0); + let il = 1.0 - lt; + let d = 0.25 * hi * fxc_smoothstep(0.5, 1.0, lt) + 0.25 * sh * (1.0 - fxc_smoothstep(0.0, 0.5, lt)) + 0.2 * wh * lt * lt + 0.2 * bl * (il * il); + c = c + d; + } + let l0 = luminance(c); + let sat = data[8]; + if (sat != 1.0) { + c = l0 + (c - l0) * sat; + } + let intensity = data[9]; + let faded = data[10]; + let vib = data[11]; + let csat = data[12]; + 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))) { + var d = c; + if (faded != 0.0) { + d = d * (1.0 - 0.25 * faded) + 0.12 * faded; + } + let l = luminance(d); + if (vib != 0.0) { + let mx = max(max(d.x, d.y), d.z); + let mn = min(min(d.x, d.y), d.z); + var s = 0.0; + if (mx > 1e-6) { + s = (mx - mn) / mx; + } + let k = 1.0 + vib * (1.0 - clamp(s, 0.0, 1.0)); + d = l + (d - l) * k; + } + if (csat != 1.0) { + d = l + (d - l) * csat; + } + let pivot = data[13]; + let lt = clamp(l, 0.0, 1.0); + let wsh = 1.0 - fxc_smoothstep(0.0, pivot, lt); + let whi = fxc_smoothstep(pivot, 1.0, lt); + d = d + 0.5 * (st * wsh + ht * whi); + c = c + (d - c) * intensity; + } + for (var i = 0u; i < 3u; i++) { + c[i] = fxc_tone_curve(c[i], data[20], data[21], data[22]); + } + for (var i = 0u; i < 3u; i++) { + let b = 23u + i * 3u; + c[i] = fxc_tone_curve(c[i], data[b], data[b + 1u], data[b + 2u]); + } + let ws = fxc_d3(32u); + let wm = fxc_d3(35u); + let wh3 = fxc_d3(38u); + if (any(ws != zero) || any(wm != zero) || any(wh3 != zero)) { + let lt = clamp(luminance(c), 0.0, 1.0); + let ka = 1.0 - fxc_smoothstep(0.0, 0.5, lt); + let kz = fxc_smoothstep(0.5, 1.0, lt); + let km = 1.0 - ka - kz; + c = c + 0.5 * (ws * ka + wm * km + wh3 * kz); + } + let vig = data[41]; + if (vig != 0.0) { + let x = f32(p.x) + 0.5; + let y = f32(p.y) + 0.5; + let lw = data[47]; + let lh = data[48]; + var nx = (x - data[45]) / max(lw * 0.5, 1e-6); + var ny = (y - data[46]) / max(lh * 0.5, 1e-6); + let vround = data[43]; + let aspect = data[49]; + if (vround > 0.0) { + nx = nx * (1.0 + (max(aspect, 1.0) - 1.0) * vround); + ny = ny * (1.0 + (max(1.0 / aspect, 1.0) - 1.0) * vround); + } + let pw = 2.0 + max(-vround, 0.0) * 6.0; + let r = powz(powz(abs(nx), pw) + powz(abs(ny), pw), 1.0 / pw) / 1.4142135; + let start = data[42]; + let f = fxc_smoothstep(start, start + 0.05 + data[44] * (1.05 - start), r); + if (vig > 0.0) { + c = c + (1.0 - c) * min(vig * 0.2 * f, 1.0); + } else { + let k = 1.0 + vig * 0.2 * f; + c = c * max(k, 0.0); + } + } + c = max(c, zero); + textureStore(out, p, vec4(c * a, a)); +} + +// Straight-colour luminance plane (util::Plane::luma) in x. +@compute @workgroup_size(16, 16) +fn fxc_luma(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let px = textureLoad(src, p, 0); + textureStore(out, p, vec4(luminance(fxc_unpremul(px)), 0.0, 0.0, 0.0)); +} + +// Lumetri Sharpen: `src` + (luma − blurred luma `aux`.x) × amount. f[0].x = sharpen × 2. +@compute @workgroup_size(16, 16) +fn fxc_sharpen(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let px = textureLoad(src, p, 0); + let a = px.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + let c = fxc_unpremul(px); + let d = (luminance(c) - textureLoad(aux, p, 0).x) * P.f[0].x; + textureStore(out, p, vec4(max(c + d, vec3(0.0)) * a, a)); +} + +// ---------------------------------------------------------------- planes (util) + +// One morphology pass (util::minmax_row, edges repeated) of radius u[0].x along rows (u[0].y = 0) +// or columns; u[1] = per-channel operation: 0 min, 1 max, 2 copy. +@compute @workgroup_size(16, 16) +fn fxc_morph(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let dims = out_dims(); + let r = i32(P.u[0].x); + let vertical = P.u[0].y != 0u; + let modes = P.u[1]; + let centre = textureLoad(src, p, 0); + var lo = centre; + var hi = centre; + for (var k = -r; k <= r; k++) { + var q = p; + if (vertical) { + q.y = clamp(p.y + k, 0, dims.y - 1); + } else { + q.x = clamp(p.x + k, 0, dims.x - 1); + } + let v = textureLoad(src, q, 0); + lo = min(lo, v); + hi = max(hi, v); + } + var o = centre; + for (var i = 0; i < 4; i++) { + if (modes[i] == 0u) { + o[i] = lo[i]; + } else if (modes[i] == 1u) { + o[i] = hi[i]; + } + } + textureStore(out, p, o); +} + +// `src` + (`aux` − `src`) × f[0].x (Plane::zip_map lerp). +@compute @workgroup_size(16, 16) +fn fxc_lerp(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let x = textureLoad(src, p, 0); + let y = textureLoad(aux, p, 0); + textureStore(out, p, x + (y - x) * P.f[0].x); +} + +// ---------------------------------------------------------------- Key Light (keylight.rs) + +fn fxc_neutralise(c: vec3, bias: vec3) -> vec3 { + let l = max(luminance(bias), 1e-4); + return vec3(c.x * l / max(bias.x, 1e-4), c.y * l / max(bias.y, 1e-4), c.z * l / max(bias.z, 1e-4)); +} + +fn fxc_screen_diff(c: vec3, ix: vec3, bal: f32) -> f32 { + return c[ix.x] - (bal * c[ix.y] + (1.0 - bal) * c[ix.z]); +} + +fn fxc_correct(c0: vec3, sat: f32, contrast: f32, bright: f32) -> vec3 { + let l = luminance(c0); + let c = l + (c0 - l) * sat; + return ((c - 0.5) * contrast + 0.5) * bright; +} + +// Raw screen matte of `src` (the key source): out = (clipped, clipped, raw, 0). +// u[0].xyz = primary, other channels; f[0] = (alpha bias, screen difference); +// f[1] = (gain, balance, clip black, clip white). +@compute @workgroup_size(16, 16) +fn fxc_kl_raw(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let c = fxc_unpremul(textureLoad(src, p, 0)); + let d = fxc_screen_diff(fxc_neutralise(c, P.f[0].xyz), P.u[0].xyz, P.f[1].y); + let raw = clamp(1.0 - P.f[1].x * d / P.f[0].w, 0.0, 1.0); + let cb = P.f[1].z; + let m = clamp((raw - cb) / max(P.f[1].w - cb, 1e-4), 0.0, 1.0); + textureStore(out, p, vec4(m, m, raw, 0.0)); +} + +// Clip Rollback: `src` = (shrunk, grown) clipped matte, `aux` = raw planes (clipped, _, raw). +@compute @workgroup_size(16, 16) +fn fxc_kl_rollback(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let sg = textureLoad(src, p, 0); + let r = textureLoad(aux, p, 0); + var m = r.x; + if (sg.y > sg.x + 1e-4) { + m = min(max(m, r.z), sg.y); + } + textureStore(out, p, vec4(m, m, r.z, 0.0)); +} + +// Planes for the final pass: (screen matte from `src`.x, raw from `aux`.z, edge 0, 0). +@compute @workgroup_size(16, 16) +fn fxc_kl_pack(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + textureStore(out, p, vec4(textureLoad(src, p, 0).x, textureLoad(aux, p, 0).z, 0.0, 0.0)); +} + +// Combined matte from the screen matte and the source alpha (no inside / outside masks). +fn fxc_kl_combined(sm: f32, src_a: f32, mode: u32) -> f32 { + var a = sm; + let sa = clamp(src_a, 0.0, 1.0); + var ins = 0.0; + if (mode == 1u) { + ins = max(ins, sa); + } + a = max(a, ins); + if (mode == 2u) { + a = a * sa; + } + return clamp(a, 0.0, 1.0); +} + +// Edge band of the combined matte: `src` = source, `aux` = planes; u[0].x = source alpha mode. +@compute @workgroup_size(16, 16) +fn fxc_kl_band(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let a = fxc_kl_combined(textureLoad(aux, p, 0).x, textureLoad(src, p, 0).w, P.u[0].x); + textureStore(out, p, vec4(select(0.0, 1.0, a > 1e-3 && a < 0.999), 0.0, 0.0, 0.0)); +} + +// Planes `src` with the edge weight from the processed band `aux`.x; f[0].x = hardness. +@compute @workgroup_size(16, 16) +fn fxc_kl_edge(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let pl = textureLoad(src, p, 0); + let e = min(textureLoad(aux, p, 0).x * (1.0 + P.f[0].x * 4.0), 1.0); + textureStore(out, p, vec4(pl.x, pl.y, e, 0.0)); +} + +fn fxc_kl_replace(fg: vec3, c: vec3, method: u32, rc: vec3, amt: f32) -> vec3 { + if (amt <= 0.0) { + return fg; + } + switch method { + case 1u: { return fg + (c - fg) * amt; } + case 2u: { return rc; } + case 3u: { + let l = luminance(c) / max(luminance(rc), 1e-4); + return fg + (rc * l - fg) * amt; + } + default: { return fg; } + } +} + +// Final pass: `src` = source, `aux` = planes (screen matte, raw, edge). +// u[0] = (primary, others, view); u[1] = (replace method, foreground cc, unpremultiply, +// source alpha mode); f[0] = (screen, balance); f[1] = (despill bias, l0); f[2] = replace colour; +// f[3] = fg cc (sat, contrast, bright); f[4] = edge cc; f[5] = crops (l, r, t, b); +// f[6] = (offset, scale, _); f[7] = (layer w, layer h). +@compute @workgroup_size(16, 16) +fn fxc_kl_final(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let s = textureLoad(src, p, 0); + let pl = textureLoad(aux, p, 0); + let ix = P.u[0].xyz; + let view = P.u[0].w; + let c = fxc_unpremul(s); + let sm = pl.x; + let raw = pl.y; + let edge = pl.z; + let a = fxc_kl_combined(sm, s.w, P.u[1].w); + let screen = P.f[0].xyz; + let bal = P.f[0].w; + let dbias = P.f[1].xyz; + let fg_cc = P.u[1].y != 0u; + let ar = max(raw, 1e-4); + var fg = max((c - (1.0 - ar) * screen) / ar, vec3(0.0)); + let spill = max(fxc_screen_diff(fxc_neutralise(fg, dbias), ix, bal), 0.0); + fg[ix.x] -= spill / P.f[1].w; + let intermediate = fg; + fg = fxc_kl_replace(fg, c, P.u[1].x, P.f[2].xyz, max(sm - raw, 0.0)); + if (fg_cc) { + fg = fxc_correct(fg, P.f[3].x, P.f[3].y, P.f[3].z); + } + if (edge > 0.0) { + let ce = fxc_correct(fg, P.f[4].x, P.f[4].y, P.f[4].z); + fg = fg + (ce - fg) * edge; + } + var o: vec4; + switch view { + case 1u: { o = vec4(s.w, s.w, s.w, 1.0); } + case 2u: { + var cs = fxc_neutralise(c, dbias); + let s2 = max(fxc_screen_diff(cs, ix, bal), 0.0); + cs[ix.x] -= s2; + if (fg_cc) { + cs = fxc_correct(cs, P.f[3].x, P.f[3].y, P.f[3].z); + } + o = vec4(cs, 1.0); + } + case 3u: { o = vec4(edge, edge, edge, 1.0); } + case 4u: { o = vec4(sm, sm, sm, 1.0); } + case 5u, 6u: { o = vec4(0.0, 0.0, 0.0, 1.0); } + case 7u: { o = vec4(a, a, a, 1.0); } + case 8u: { + var v = 0.5; + if (a <= 1e-3) { + v = 0.0; + } else if (a >= 0.999) { + v = 1.0; + } + o = vec4(v, v, v, 1.0); + } + case 9u: { o = vec4(intermediate * sm, sm); } + default: { + let x = f32(p.x) + 0.5; + let y = f32(p.y) + 0.5; + let lx = (x - P.f[6].x) / P.f[6].z / P.f[7].x; + let ly = (y - P.f[6].y) / P.f[6].z / P.f[7].y; + let cr = P.f[5]; + let cropped = lx < cr.x || lx > 1.0 - cr.y || ly < cr.z || ly > 1.0 - cr.w; + if (cropped) { + o = vec4(0.0); + } else if (P.u[1].z != 0u) { + o = vec4(fg * a, a); + } else { + o = vec4(fg * a * a, a); + } + } + } + textureStore(out, p, o); +} diff --git a/crates/gpu/src/tests_fx_color.rs b/crates/gpu/src/tests_fx_color.rs new file mode 100644 index 00000000..8fb455b8 --- /dev/null +++ b/crates/gpu/src/tests_fx_color.rs @@ -0,0 +1,163 @@ +//! GPU colour-family effects vs the CPU effects (the oracle): direct on a buffer at full and +//! half resolution, as adjustment, and composited at 8 and 32 bpc. + +use effectcraft_keyframe::Value; + +use crate::tests::{c, effect_case, n}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +#[test] +fn color_balance_vibrance_tritone_mixer() { + effect_case("ec.color.colorbalance", &[("shadowRed", n(40.0)), ("midGreen", n(-30.0)), ("hiBlue", n(60.0))]); + effect_case("ec.color.colorbalance", &[("shadowBlue", n(-50.0)), ("midRed", n(70.0)), ("hiGreen", n(25.0)), ("preserveLuminosity", on())]); + effect_case("ec.color.vibrance", &[("vibrance", n(60.0)), ("saturation", n(-20.0))]); + effect_case("ec.color.vibrance", &[("vibrance", n(-40.0)), ("saturation", n(35.0))]); + effect_case("ec.color.tritone", &[("midtones", c(0.6, 0.3, 0.5)), ("shadows", c(0.0, 0.1, 0.3)), ("blend", n(30.0))]); + effect_case("ec.color.tritone", &[("highlights", c(1.0, 0.9, 0.6))]); + effect_case("ec.color.channelmixer", &[("rr", n(80.0)), ("rg", n(30.0)), ("gb", n(-40.0)), ("bc", n(10.0)), ("br", n(50.0))]); + effect_case("ec.color.channelmixer", &[("rr", n(40.0)), ("rg", n(40.0)), ("rb", n(20.0)), ("rc", n(-5.0)), ("monochrome", on())]); +} + +#[test] +fn black_white_colorama_selective_color() { + effect_case("ec.color.blackwhite", &[]); + effect_case("ec.color.blackwhite", &[("reds", n(120.0)), ("blues", n(-50.0)), ("cyans", n(200.0)), ("tint", on())]); + effect_case("ec.color.colorama", &[]); + effect_case("ec.color.colorama", &[("inputPhase/mode", e(4)), ("inputPhase/phaseShift", n(75.0)), ("outputCycle/cycles", n(2.5)), ("blend", n(40.0))]); + effect_case("ec.color.colorama", &[("inputPhase/mode", e(8)), ("outputCycle/cycles", n(1.3))]); + effect_case("ec.color.selectivecolor", &[("redsCyan", n(60.0)), ("bluesYellow", n(-40.0)), ("neutralsBlack", n(20.0)), ("whitesMagenta", n(30.0))]); + effect_case( + "ec.color.selectivecolor", + &[("method", e(1)), ("greensMagenta", n(50.0)), ("yellowsCyan", n(-30.0)), ("blacksYellow", n(40.0)), ("magentasBlack", n(25.0))], + ); +} + +#[test] +fn lumetri() { + effect_case( + "ec.color.lumetri", + &[ + ("basicCorrection/whiteBalance/temperature", n(30.0)), + ("basicCorrection/whiteBalance/tint", n(-20.0)), + ("basicCorrection/tone/exposure", n(0.5)), + ("basicCorrection/tone/contrast", n(25.0)), + ("basicCorrection/tone/highlights", n(-40.0)), + ("basicCorrection/tone/shadows", n(30.0)), + ("basicCorrection/tone/whites", n(10.0)), + ("basicCorrection/tone/blacks", n(-15.0)), + ("basicCorrection/saturation", n(130.0)), + ], + ); + effect_case( + "ec.color.lumetri", + &[ + ("creative/lookIntensity", n(80.0)), + ("creative/adjustments/fadedFilm", n(30.0)), + ("creative/adjustments/vibrance", n(40.0)), + ("creative/adjustments/creativeSaturation", n(70.0)), + ("creative/adjustments/shadowTint", c(0.3, 0.5, 0.7)), + ("creative/adjustments/highlightTint", c(0.7, 0.55, 0.4)), + ("creative/adjustments/tintBalance", n(-30.0)), + ("curves/curveMasterMidtones", n(30.0)), + ("curves/curveRedShadows", n(-20.0)), + ("curves/curveBlueHighlights", n(40.0)), + ], + ); + effect_case( + "ec.color.lumetri", + &[ + ("colorWheels/shadowsWheel", c(0.4, 0.5, 0.6)), + ("colorWheels/highlightsWheel", c(0.6, 0.5, 0.45)), + ("vignette/vignetteAmount", n(-2.0)), + ("vignette/vignetteMidpoint", n(30.0)), + ("vignette/vignetteRoundness", n(40.0)), + ("creative/adjustments/sharpen", n(50.0)), + ], + ); + effect_case( + "ec.color.lumetri", + &[ + ("vignette/vignetteAmount", n(1.5)), + ("vignette/vignetteRoundness", n(-60.0)), + ("vignette/vignetteFeather", n(20.0)), + ("creative/adjustments/sharpen", n(-40.0)), + ], + ); +} + +#[test] +fn linear_color_key() { + effect_case("ec.key.linearcolor", &[("keyColor", c(0.6, 0.5, 0.4)), ("tolerance", n(15.0)), ("softness", n(20.0))]); + effect_case("ec.key.linearcolor", &[("keyColor", c(0.2, 0.8, 0.3)), ("matchColors", e(1)), ("keyOperation", e(1)), ("view", e(2))]); + effect_case("ec.key.linearcolor", &[("keyColor", c(0.7, 0.3, 0.6)), ("matchColors", e(2)), ("tolerance", n(5.0)), ("softness", n(30.0))]); +} + +#[test] +fn key_light() { + let screen = ("screenColour", c(0.3, 0.75, 0.35)); + effect_case("ec.keying.keylight", std::slice::from_ref(&screen)); + effect_case( + "ec.keying.keylight", + &[ + // A low gain against a pure screen keeps the raw matte away from 0 (the screen + // removal divides by it; near 0 it amplifies the blurs' float rounding). + ("screenColour", c(0.0, 1.0, 0.0)), + ("screenGain", n(60.0)), + ("screenBalance", n(30.0)), + ("screenPreblur", n(3.0)), + ("screenMatte/clipBlack", n(10.0)), + ("screenMatte/clipWhite", n(80.0)), + ("screenMatte/clipRollback", n(2.5)), + ("screenMatte/screenSoftness", n(3.0)), + ("screenMatte/replaceMethod", e(3)), + ("screenMatte/replaceColour", c(0.6, 0.4, 0.3)), + ("unpremultiplyResult", Value::Bool(false)), + ], + ); + effect_case( + "ec.keying.keylight", + &[ + ("screenColour", c(0.2, 0.4, 0.8)), + ("lockBiasesTogether", Value::Bool(false)), + ("alphaBias", c(0.5, 0.45, 0.6)), + ("despillBias", c(0.55, 0.5, 0.45)), + ("screenMatte/clipWhite", n(70.0)), + ("screenMatte/screenDespotBlack", n(2.0)), + ("screenMatte/screenDespotWhite", n(1.5)), + ("screenMatte/screenShrinkGrow", n(-1.5)), + ("screenMatte/replaceMethod", e(2)), + ("foregroundColourCorrection/enableColourCorrection", on()), + ("foregroundColourCorrection/saturation", n(120.0)), + ("foregroundColourCorrection/contrast", n(15.0)), + ("edgeColourCorrection/enableEdgeColourCorrection", on()), + ("edgeColourCorrection/edgeSoftness", n(2.0)), + ("edgeColourCorrection/edgeHardness", n(30.0)), + ("edgeColourCorrection/edgeBrightness", n(-20.0)), + ("sourceCrops/cropLeft", n(13.0)), + ("sourceCrops/cropBottom", n(7.0)), + ("insideMask/sourceAlpha", e(1)), + ], + ); + // Soft Colour without Screen Softness (after the blur, the screen-removed foreground of + // nearly transparent pixels, divided by a raw matte near 1e-4, amplifies float rounding). + effect_case( + "ec.keying.keylight", + &[ + screen.clone(), + ("screenMatte/clipBlack", n(15.0)), + ("screenMatte/clipRollback", n(1.5)), + ("screenMatte/replaceColour", c(0.6, 0.4, 0.3)), + ("insideMask/sourceAlpha", e(0)), + ], + ); + for view in [1, 2, 4, 7, 9] { + effect_case("ec.keying.keylight", &[screen.clone(), ("view", e(view)), ("screenMatte/screenShrinkGrow", n(1.0)), ("screenMatte/replaceMethod", e(1))]); + } +} diff --git a/docs/effects.md b/docs/effects.md index 75d32478..dd479f0b 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -5,7 +5,7 @@ EffectCraft ships 303 effects with 2434 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**: 24 effects also run on the GPU compositor with identical results. +- **GPU**: 34 effects also run on the GPU compositor with identical results. - **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping. - **Status**: 261 implemented in full, 42 partial (what is missing is listed). @@ -88,7 +88,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Auto Color | `ec.color.autocolor` | 6 | | 32 | Implemented | | Auto Contrast | `ec.color.autocontrast` | 5 | | 32 | Implemented | | Auto Levels | `ec.color.autolevels` | 5 | | 32 | Implemented | -| Black & White | `ec.color.blackwhite` | 8 | | 32 | Implemented | +| Black & White | `ec.color.blackwhite` | 8 | GPU | 32 | Implemented | | Brightness & Contrast | `ec.color.brightnesscontrast` | 3 | GPU | 32 | Implemented | | Broadcast Colors | `ec.color.broadcast` | 3 | | 32 | Implemented | | CC Color Neutralizer | `ec.color.cccolorneutralizer` | 11 | | 32 | Implemented | @@ -97,12 +97,12 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Toner | `ec.color.cctoner` | 7 | | 32 | Implemented | | Change Color | `ec.color.changecolor` | 9 | | 32 | Implemented | | Change to Color | `ec.color.changetocolor` | 9 | | 32 | Implemented | -| Channel Mixer | `ec.color.channelmixer` | 13 | | 32 | Implemented | -| Color Balance | `ec.color.colorbalance` | 10 | | 32 | Implemented | +| Channel Mixer | `ec.color.channelmixer` | 13 | GPU | 32 | Implemented | +| Color Balance | `ec.color.colorbalance` | 10 | GPU | 32 | Implemented | | Color Balance (HLS) | `ec.color.colorbalancehls` | 3 | | 32 | Implemented | | Color Link | `ec.color.colorlink` | 6 | | 32 | Implemented | | Color Stabilizer | `ec.color.colorstabilizer` | 6 | | 32 | Implemented | -| Colorama | `ec.color.colorama` | 5 | | 32 | Partial: no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking | +| Colorama | `ec.color.colorama` | 5 | GPU | 32 | Partial: no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking | | Curves | `ec.color.curves` | 0 | GPU | 32 | Partial: no Channel popup | | Equalize | `ec.color.equalize` | 2 | | 32 | Implemented | | Exposure | `ec.color.exposure` | 14 | GPU | 32 | Implemented | @@ -111,7 +111,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Leave Color | `ec.color.leavecolor` | 5 | | 32 | Implemented | | Levels | `ec.color.levels` | 7 | GPU | 32 | Partial: master channel only (no Channel popup) | | Levels (Individual Controls) | `ec.color.levelsic` | 27 | | 32 | Implemented | -| Lumetri Color | `ec.color.lumetri` | 38 | | 32 | Partial: no HSL Secondary, hue/saturation curves, HDR mode or look files | +| Lumetri Color | `ec.color.lumetri` | 38 | GPU | 32 | Partial: no HSL Secondary, hue/saturation curves, HDR mode or look files | | OCIO CDL Transform | `ec.color.ociocdl` | 12 | | 32 | Implemented | | OCIO Color Space Transform | `ec.color.ociocolorspace` | 4 | | 32 | Implemented | | OCIO Display Transform | `ec.color.ociodisplay` | 5 | | 32 | Implemented | @@ -119,11 +119,11 @@ Effects are addressed by id (`ec..`) or display name in commands | OCIO Look Transform | `ec.color.ociolook` | 6 | | 32 | Implemented | | PS Arbitrary Map | `ec.color.psarbitrarymap` | 2 | | 32 | Implemented | | Photo Filter | `ec.color.photofilter` | 4 | | 32 | Implemented | -| Selective Color | `ec.color.selectivecolor` | 37 | | 32 | Partial: simplified Colors / Details layout | +| Selective Color | `ec.color.selectivecolor` | 37 | GPU | 32 | Partial: simplified Colors / Details layout | | Shadow/Highlight | `ec.color.shadowhighlight` | 14 | | 32 | Implemented | | Tint | `ec.color.tint` | 3 | GPU | 32 | Implemented | -| Tritone | `ec.color.tritone` | 4 | | 32 | Implemented | -| Vibrance | `ec.color.vibrance` | 2 | | 32 | Implemented | +| Tritone | `ec.color.tritone` | 4 | GPU | 32 | Implemented | +| Vibrance | `ec.color.vibrance` | 2 | GPU | 32 | Implemented | | Video Limiter | `ec.color.videolimiter` | 5 | | 32 | Implemented | ## Distort @@ -243,8 +243,8 @@ Effects are addressed by id (`ec..`) or display name in commands | Extract | `ec.key.extract` | 6 | | 32 | Implemented | | Inner/Outer Key | `ec.key.innerouter` | 10 | | 32 | Partial: one additional mask per side; no cleanup strokes | | Key Cleaner | `ec.key.keycleaner` | 4 | | 32 | Implemented | -| Key Light | `ec.keying.keylight` | 42 | | 32 | Partial: no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing | -| Linear Color Key | `ec.key.linearcolor` | 6 | | 32 | Implemented | +| Key Light | `ec.keying.keylight` | 42 | GPU | 32 | Partial: no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing | +| Linear Color Key | `ec.key.linearcolor` | 6 | GPU | 32 | Implemented | | Screen Key | `ec.key.screen` | 11 | | 32 | Implemented | | Unmult | `ec.key.unmult` | 6 | | 32 | Implemented | From c4a8c141b563cad36405448f4c0e0379067d4558 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:59:57 -0400 Subject: [PATCH 8/9] M12.7: bench --gpu adds an adjustment-layer comp, speedup and GPU-vs-CPU agreement columns Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft-cli/src/main.rs | 55 +++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 9e1a4a8b..8aa1381b 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -749,6 +749,43 @@ fn time_ms(n: usize, mut f: impl FnMut()) -> f64 { median(&mut v) } +/// `bench --gpu`'s adjustment-layer comp: a copy of the first comp with a full-frame adjustment +/// layer on top running Gaussian Blur, Levels, Vibrance and Glow (all GPU effects). +fn adjustment_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option { + use effectcraft_engine::effects; + use effectcraft_engine::keyframe::Value; + use effectcraft_engine::project::build::{self, Ids}; + use effectcraft_engine::project::{ItemKind, LayerSource, Solid}; + let (_, base) = p.comps().next().map(|(id, c)| (*id, c.clone()))?; + let mut comp = base.clone(); + let (w, h) = (comp.width, comp.height); + let sid = p.add_item( + "Adjustment (bench)", + effectcraft_engine::color::Label::None, + None, + ItemKind::Solid(Solid { color: [1.0; 3], width: w, height: h, pixel_aspect: 1.0 }), + ); + let mut l = build::layer(p, &comp, "Adjustment (bench)", LayerSource::Solid { item: sid }, (w, h), None); + l.switches.adjustment = true; + for (id, vals) in [ + ("ec.blur.gaussian", vec![("blurriness", Value::Scalar(12.0))]), + ("ec.color.levels", vec![("gamma", Value::Scalar(1.3))]), + ("ec.color.vibrance", vec![("vibrance", Value::Scalar(40.0))]), + ("ec.stylize.glow", vec![("threshold", Value::Scalar(55.0))]), + ] { + let spec = effects::find(id)?; + let mut next = p.next_id; + let mut g = effects::instantiate(spec, &mut Ids(&mut next), spec.name, [w as f64, h as f64]); + p.next_id = next; + for (k, v) in vals { + g.prop_mut(k)?.value = v; + } + l.props.sub_mut("effects")?.children.push(g.into()); + } + comp.layers.insert(0, l); + Some(p.add_item("Adjustment Layers (bench)", effectcraft_engine::color::Label::None, None, ItemKind::Comp(comp.into()))) +} + /// `bench --gpu`: CPU vs GPU ms/frame for every comp (or `--comp`) at Full and Half. "cold" /// renders everything (no layer cache); "warm" reuses the layer cache (playback / scrubbing of /// unchanged layers: compositing cost); "viewer" is the GPU display path without readback. @@ -756,22 +793,30 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> { use effectcraft_render::Backend; let gpu = effectcraft_gpu::Gpu::headless().ok_or_else(|| Failure::Error("--gpu: no usable GPU adapter".into()))?; let n = args.num("--n")?.unwrap_or(10.0).max(1.0) as usize; + // Every comp, plus an adjustment-layer comp built for the benchmark (the main comp under a + // full-frame adjustment layer with a GPU effect stack). + let mut project = (*s.project).clone(); let comps: Vec = match args.opt("--comp") { Some(_) => s.active_comp_id().into_iter().collect(), - None => s.project.comps().map(|(id, _)| *id).collect(), + None => { + let mut v: Vec = s.project.comps().map(|(id, _)| *id).collect(); + v.extend(adjustment_bench_comp(&mut project)); + v + } }; + let project = &project; eprintln!("GPU: {} — median of {n} runs (ms/frame)", effectcraft_render::Accelerator::name(&gpu)); eprintln!( "{:<28} {:>5} {:>10} {:>9} {:>9} {:>9} {:>9} {:>9} {:>8} {:>9}", "comp", "res", "size", "cpu cold", "gpu cold", "cpu warm", "gpu warm", "gpu view", "speedup", "gpu≠cpu" ); for cid in comps { - let Some(comp) = s.project.comp(cid) else { continue }; - let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); + let Some(comp) = project.comp(cid) else { continue }; + let name = project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); let t = Tick::from_seconds_f64(args.num("--time")?.unwrap_or(3.0).min(comp.duration.seconds() * 0.5)); for (label, scale) in [("Full", 1.0), ("Half", 0.5)] { let mk = |backend: Backend, cache: Option<&LayerCache>| { - let mut r = Renderer::new(&s.project, s.footage.as_ref(), RenderOpts { scale, backend, ..Default::default() }); + let mut r = Renderer::new(project, s.footage.as_ref(), RenderOpts { scale, backend, ..Default::default() }); r.expr = s.expr.as_deref(); r.cache = cache; r.accel = Some(&gpu); @@ -792,7 +837,7 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> { std::hint::black_box(mk(Backend::Gpu, Some(&gcache))); }); let view = time_ms(n, || { - let mut r = Renderer::new(&s.project, s.footage.as_ref(), RenderOpts { scale, backend: Backend::Gpu, ..Default::default() }); + let mut r = Renderer::new(project, s.footage.as_ref(), RenderOpts { scale, backend: Backend::Gpu, ..Default::default() }); r.expr = s.expr.as_deref(); r.cache = Some(&gcache); r.accel = Some(&gpu); From f85468c6948d1a9a6e20f7234899131b4955c440 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:00:29 -0400 Subject: [PATCH 9/9] M12.7: docs (architecture, parity, gpu crate docs) Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/context.rs | 2 +- crates/gpu/src/lib.rs | 10 ++++++++-- crates/gpu/src/walk.rs | 5 +++-- docs/architecture.md | 26 ++++++++++++++++++++------ docs/parity.md | 13 ++++++------- 5 files changed, 38 insertions(+), 18 deletions(-) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 7dd14ba8..d6f38883 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -289,7 +289,7 @@ impl GpuContext { let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() })) .ok()?; - device.on_uncaptured_error(Arc::new(|e| eprintln!("wgpu: {e}"))); + device.on_uncaptured_error(Arc::new(|e| log::error!("wgpu: {e}"))); GpuContext::new(&adapter, device, queue).map_err(|e| log::info!("gpu: {e}")).ok() } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 2241560b..bf47e3c5 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -6,8 +6,10 @@ //! layer buffers (once per buffer), applies the layer transforms with the CPU's sampling //! (nearest / bilinear / bicubic, minification pre-filter), motion-blur sub-samples, track //! mattes, Preserve Transparency, layer styles' passes, all 38 blend modes and the 8/16 bpc -//! clamping and quantisation, and converts colour spaces. 3D runs, adjustment layers and -//! wireframes run on the CPU between GPU steps (read back, draw, upload). +//! clamping and quantisation, and converts colour spaces. Classic 3D runs composite here too +//! (`classic3d`: per-pixel fragment sort, lights, ray-cast shadows), and adjustment layers run +//! their effect stacks on the GPU-resident comp. Advanced 3D compositing and wireframes run on +//! the CPU between GPU steps (read back, draw, upload). //! //! Advanced 3D comps rasterise on a render pipeline (`advanced3d.wgsl`: depth buffer, PBR, //! image-based light, shadow maps), see [`Accelerator::raster_3d`]. @@ -16,6 +18,10 @@ //! effect's exact steps (padding, box-blur radii, parameter conversions); chains of them are //! uploaded and read back once. //! +//! GPU particles (`particles`): the stepped particle effects hand their simulation to +//! [`effectcraft_effects::psim::ParticleSim`], implemented here with one invocation per particle +//! and GPU-resident checkpoints. +//! //! Plug a [`Gpu`] into [`Renderer::accel`] (it implements [`Accelerator`]); renders then use it //! when [`RenderOpts::backend`](effectcraft_render::RenderOpts) asks for it. The viewer can //! skip readback entirely with [`Gpu::render_display`], which leaves an RGBA8 texture for diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index cfec880d..64a1d2e7 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -1,8 +1,9 @@ //! The per-frame compositing walk on the GPU: the same steps as `Renderer::draw_comp` (layer //! order, 3D runs, collapsed precomps, track mattes, Preserve Transparency, layer styles, motion //! blur, bit-depth quantisation, colour conversions), with layer pixels taken from the CPU -//! renderer and its layer cache. Steps without a GPU implementation (3D runs, adjustment and -//! wireframe layers) read the canvas back, run on the CPU and upload the result. +//! renderer and its layer cache. Classic 3D runs and adjustment layers run on the GPU; steps +//! without a GPU implementation (Advanced 3D runs, wireframes, anything a kernel declines) read +//! the canvas back, run on the CPU and upload the result. use std::sync::Arc; diff --git a/docs/architecture.md b/docs/architecture.md index 39acd396..83c3f3ea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -157,17 +157,31 @@ accelerator is attached and the project's renderer is the GPU). A GPU frame walk sampling (nearest, bilinear, Catmull-Rom bicubic, the same minification pre-filter), motion-blur sub-samples are accumulated, and track mattes, Preserve Transparency, layer style passes, knockout, all 38 blend modes (a WGSL port of `color::blend`), the 8/16 bpc clamp-and-quantise steps -and colour-space conversions run on the GPU. 3D runs, adjustment layers and wireframes run on the -CPU between GPU steps (read back, draw, upload). GPU effects (`effects::GPU_EFFECTS`: Gaussian, -Fast Box and Directional Blur, Glow, Levels, Curves, Hue/Saturation, Tint, Fill, Gradient Ramp, -Fractal Noise, Drop Shadow, Brightness & Contrast, Exposure, Invert, Transform) repeat the CPU -effect's steps (padding, box radii, parameters) as kernels; consecutive GPU effects run as one +and colour-space conversions run on the GPU. Classic 3D runs composite on the GPU too +(`gpu::classic3d`): the render crate prepares the planes (`Renderer::prepare_3d_run`: layer buffers +with their bokeh depth of field, homographies per motion-blur sub-sample, materials, lights, track +mattes), the GPU packs the buffers into one atlas texture and one kernel does the per-pixel +fragment sort (intersecting planes, coplanar stack order), Blinn-Phong lighting, ray-cast shadows +against the caster planes (Shadow Diffusion, Light Transmission) and blending. Adjustment layers +run their effect stacks on the GPU-resident comp (`Renderer::run_effects_on` with an `FxTarget`): +runs of GPU effects stay on the device and only non-GPU effects read back and upload. Advanced 3D +compositing and wireframes still run on the CPU between GPU steps (read back, draw, upload). GPU +effects (`effects::GPU_EFFECTS`, 58 of them: blurs, colour, keying incl. Key Light, distortion, +transitions, generators, noise and grain; see [effects.md](effects.md)) repeat the CPU effect's +steps (padding, box radii, parameters, hashes) as kernels; consecutive GPU effects run as one chain with one upload and one readback. Tests render scenes on both paths and compare them (≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc); they skip without an adapter. The desktop viewer builds the `Gpu` on egui-wgpu's device and shows frames from GPU textures without reading them back (`ui-egui::frames`); headless renders, the CLI (unless `--gpu`) and CI use the CPU. On the web (WebGPU) the GPU composites viewer frames; steps that need a readback fall back to the CPU (the -Info panel's pixel readout reads GPU frames back asynchronously). +Info panel's pixel readout reads GPU frames back asynchronously). **GPU particles** +(`effects::psim`, `gpu::particles`): CC Particle World, CC Particle Systems II and Particle +Playground cannons without interacting forces evolve every particle independently, so the GPU +backend (reached through `EffectHost::particles` when the GPU compositor is active) simulates one +particle per invocation from the CPU's birth schedule and keeps per-key state checkpoints on the +GPU, like `SimCache`; the CPU simulation stays the oracle (tests compare ids and positions). +`effectcraft-cli bench --gpu` reports CPU vs GPU ms/frame, speed-up and pixel agreement for every +comp plus an adjustment-layer comp. **Colour and bit depth** (`crates/render/src/color.rs`, `crates/color/src/space.rs`). Pixels are `f32`, but 8 and 16 bpc projects clamp and quantise each layer after its source and masks and diff --git a/docs/parity.md b/docs/parity.md index fbe12a5f..f703644a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -20,13 +20,12 @@ Animation 94%, Masks 94%, Preview 94%, Interface 93%, Shapes 93%, 3D 91%, Audio Tracking 88%, Effects 85%, Web 65%. What is left, in priority order: the web app's depth (threads, storage, audio, non-blocking -renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; GPU -rendering of 3D runs and adjustment layers; Render Queue field render, crop/resize and templates; +renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; Render Queue field render, crop/resize and templates; approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; Advanced 3D motion blur and blend modes; a few viewer and colour-management menu items; OpenType features and variable font axes; AI/EPS/PDF import; more codecs; 59 preferences not yet wired; ScriptUI; Lumetri Scopes, Footage, Media Browser and Metadata panels; Content-Aware Fill; face tracking; and -the "better than After Effects" items (a plug-in API, branching history, GPU particles). +the "better than After Effects" items (a plug-in API, branching history; GPU particles landed in M12.7). The sections below are the original audit (morning of 2 October, ≈ 64%) and its updates, kept for history. @@ -121,11 +120,11 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Text | ≈ 91% | 0.8 | no OpenType feature panel, no extruded strokes, variable-axis animation changes outlines but not advances (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 | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) | | 3D | 78% | 9.3 | multi-view layouts, Advanced 3D motion blur and blend modes (and iris shapes in Advanced 3D's depth of field), cameras/lights from models; 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 (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 | -| Effects | ≈ 80% | 4.5 | GPU versions of more effects 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 | ≈ 80% | 4.0 | GPU versions of the remaining effects (58 run on the GPU since M12.7) 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 | 74% | 5.0 | a richer Learn area (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 | ≈ 60% | 6.5 | 8/16/32-bit pipeline and colour management, auto-save, folder moves (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) | -| Preview | 62% | 5.0 | GPU 3D and adjustment layers (persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | +| Preview | 66% | 4.0 | GPU bokeh depth of field, wireframes and Advanced 3D compositing (Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | | Tracking | ≈ 85% | 2.5 | face tracking, Subspace Warp's mesh warp, lens distortion in the camera solve (Rolling Shutter Repair landed in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker landed in M6.x / M12.5 / M12.6) | | Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser | @@ -172,8 +171,8 @@ taper/wave, Advanced 3D's DOF has no iris shapes, and variable axes don't change 1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10). 2. Effect Controls widgets: angle dial, point crosshair, eyedropper, curves and levels editors. 3. ~~Viewer basics: snapping, rulers, channel view, snapshots, exposure, a drawable region of interest.~~ (M0.13) -4. ~~A GPU (wgpu) compositor, then GPU effects~~ (M12.2: 2D compositing and 16 GPU effects; 3D runs - and adjustment layers still composite on the CPU). +4. ~~A GPU (wgpu) compositor, then GPU effects~~ (M12.2: 2D compositing and 16 GPU effects; M12.7: + Classic 3D runs, adjustment layers, 58 GPU effects and GPU particles). 5. ~~Pen tool for shape paths, shape vertex editing, free transform~~ (M6.5); the Layer viewer. 6. ~~Motion-path handles in the viewer; graph editor transform box and snapping.~~ (M5.8) 7. Point tracking and stabilization (in progress).