diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 102bd84e..36479261 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -94,12 +94,14 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[ ]; /// Whether the GPU kernel of effect `id` covers the instance's current settings (see -/// [`CPU_ONLY_CONTROLS`]; plus Cell Pattern's HQ variants and Turbulent Displace's locked -/// pinning). +/// [`CPU_ONLY_CONTROLS`]; plus Cell Pattern's HQ variants, Turbulent Displace's locked +/// pinning, Warp's strong Fisheye / Twist and Timewarp with a Matte Layer). pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool { match id { "ec.generate.cellpattern" if (6..=10).contains(&ctx.params.e("cellPattern")) => return false, "ec.distort.turbulentdisplace" if ctx.params.e("pinning") >= 8 => return false, + // Timewarp's Matte Layer (foreground and background on their own vectors). + "ec.time.timewarp" if ctx.params.get("matteLayer").and_then(effectcraft_keyframe::Value::as_layer).is_some() => return false, // Fisheye / Twist (a crease at the unit circle) bent past 50 % or with Horizontal / // Vertical Distortion: Newton's inverse wanders chaotically before converging near the // fold, so f32 lands on other pixels than the CPU's f64. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index feed53bf..e8126fb2 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -94,7 +94,8 @@ pub use transition::place_layer; // effectcraft-gpu fx_noise. pub use noise::remove_grain_level; pub use noise3::{FractalGpu, fractal_gpu}; -pub use time_fx::{posterized_time, time_frames}; +pub use textfx::{TextLayer, text_layer}; +pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, posterized_time, time_frames, timewarp_crop, timewarp_plan}; // effectcraft-gpu fx_tone. pub use ocio::color_stabilizer_maps; @@ -931,6 +932,40 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.perspective.bevelalpha", "ec.perspective.beveledges", "ec.obsolete.gaussianlegacy", + // effectcraft-gpu fx_light (the CC light family) + "ec.generate.cclightrays", + "ec.generate.cclightburst", + "ec.generate.cclightsweep", + "ec.transition.cclightwipe", + // effectcraft-gpu fx_transition (transitions, perspective) + "ec.transition.blockdissolve", + "ec.transition.ccglasswipe", + "ec.transition.ccgridwipe", + "ec.transition.ccimagewipe", + "ec.transition.ccjaws", + "ec.transition.cclinesweep", + "ec.transition.ccradialscalewipe", + "ec.transition.ccscalewipe", + "ec.transition.cctwister", + "ec.transition.ccwarpomatic", + "ec.perspective.radialshadow", + "ec.distort.ccbender", + "ec.distort.ccblobbylize", + "ec.perspective.cccylinder", + "ec.perspective.ccsphere", + "ec.perspective.ccspotlight", + "ec.perspective.ccenvironment", + "ec.perspective.3dglasses", + // effectcraft-gpu fx_text (glyph coverage rasterised on the CPU) + "ec.text.numbers", + "ec.text.timecode", + // effectcraft-gpu fx_time (frames fetched by the host, combined on the GPU) + "ec.time.timedifference", + "ec.time.timedisplacement", + "ec.time.ccforcemotionblur", + "ec.time.ccwidetime", + "ec.time.pixelmotionblur", + "ec.time.timewarp", ]; /// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated diff --git a/crates/effects/src/perspective2.rs b/crates/effects/src/perspective2.rs index a698ed5b..13846607 100644 --- a/crates/effects/src/perspective2.rs +++ b/crates/effects/src/perspective2.rs @@ -90,8 +90,10 @@ fn cc_environment(ctx: &EffectCtx, mut b: Buf) -> Buf { let ex = u.rem_euclid(1.0) * esz[0] * es + eo[0]; let ey = v.clamp(0.0, 1.0) * esz[1] * es + eo[1]; let e = env_img.sample_bilinear_clamped(ex, ey); - let (ec, _) = unpremul(e); - *px = premul(ec, a); + // Straight colour, with nearly transparent texels fading to black continuously + // (a hard 1e-6 cut-off would turn the float noise of the filtered map into colour). + let k = 1.0 / e[3].max(1e-3); + *px = premul([e[0] * k, e[1] * k, e[2] * k], a); } }); b diff --git a/crates/effects/src/textfx.rs b/crates/effects/src/textfx.rs index 87364f6b..8ffd686e 100644 --- a/crates/effects/src/textfx.rs +++ b/crates/effects/src/textfx.rs @@ -192,9 +192,9 @@ fn tint(c: [f32; 4], a: f32) -> Px { [c[0] * a, c[1] * a, c[2] * a, a] } -/// Draw text polylines with the given look (`r` = glyph stroke radius in px). -fn draw_text(b: &mut Buf, polys: &[Vec<[f64; 2]>], r: f64, look: &TextLook) { - let (w, h) = (b.img.width as usize, b.img.height as usize); +/// Coverage of text polylines (`r` = glyph stroke radius in px): the fill and, when the look +/// strokes, the stroke ring. +fn text_coverage(w: usize, h: usize, polys: &[Vec<[f64; 2]>], r: f64, look: &TextLook) -> (Plane, Option) { let segs_at = |rad: f64| { let mut s = Vec::new(); for pl in polys { @@ -211,24 +211,35 @@ fn draw_text(b: &mut Buf, polys: &[Vec<[f64; 2]>], r: f64, look: &TextLook) { let inner = if r - sw * 0.5 > 0.05 { raster_segs(w, h, &segs_at(r - sw * 0.5), 1.0) } else { Plane::new(w, h) }; Some(outer.zip_map(&inner, |a, b| (a - b).max(0.0))) }; + (fill, ring) +} + +/// One pixel of drawn text: `px` under the fill / stroke coverages `f`, `s` (opacity applied). +fn text_px(px: Px, f: f32, s: f32, look: &TextLook) -> Px { + let mut out = if look.on_original { px } else { [0.0; 4] }; + match look.display { + 1 => out = over(out, tint(look.stroke, s)), + 2 => { + out = over(out, tint(look.stroke, s)); + out = over(out, tint(look.fill, f)); + } + 3 => { + out = over(out, tint(look.fill, f)); + out = over(out, tint(look.stroke, s)); + } + _ => out = over(out, tint(look.fill, f)), + } + out +} + +/// Draw text polylines with the given look (`r` = glyph stroke radius in px). +fn draw_text(b: &mut Buf, polys: &[Vec<[f64; 2]>], r: f64, look: &TextLook) { + let (fill, ring) = text_coverage(b.img.width as usize, b.img.height as usize, polys, r, look); let op = look.opacity; b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { - let mut out = if look.on_original { *px } else { [0.0; 4] }; let f = fill.data[i] * op; let s = ring.as_ref().map(|r| r.data[i]).unwrap_or(0.0) * op; - match look.display { - 1 => out = over(out, tint(look.stroke, s)), - 2 => { - out = over(out, tint(look.stroke, s)); - out = over(out, tint(look.fill, f)); - } - 3 => { - out = over(out, tint(look.fill, f)); - out = over(out, tint(look.stroke, s)); - } - _ => out = over(out, tint(look.fill, f)), - } - *px = out; + *px = text_px(*px, f, s, look); }); } @@ -337,7 +348,18 @@ fn numbers_text(ctx: &EffectCtx) -> String { } } -fn numbers(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// What Numbers or Timecode draws: glyph polylines, stroke radius and look, whether the layer +/// stays under the text, and Timecode's box (`[x0, x1, y0, y1]` in buffer pixels, colour, +/// opacity × colour alpha). +struct TextPlan { + polys: Vec>, + r: f64, + look: TextLook, + keep: bool, + boxed: Option<([f64; 4], [f32; 4], f32)>, +} + +fn numbers_plan(ctx: &EffectCtx, b: &Buf) -> TextPlan { let flat = ungroup(ctx.params); let ctx = &EffectCtx { params: &flat, time: ctx.time, layer_size: ctx.layer_size, seed: ctx.seed, adjustment: ctx.adjustment, env: ctx.env }; let pr = ctx.params; @@ -349,11 +371,81 @@ fn numbers(ctx: &EffectCtx, mut b: Buf) -> Buf { let wdt = line_width(&text, size, tracking, prop); let mut polys = Vec::new(); layout_line(&text, [pos.0 - wdt * 0.5, pos.1 + unit(size) * 3.0], size, tracking, prop, &mut polys); - let look = look_from(ctx, &b, "compositeOnOriginal"); - draw_text(&mut b, &polys, weight(size), &look); + let mut look = look_from(ctx, b, "compositeOnOriginal"); + let keep = look.on_original; + look.on_original = true; + TextPlan { polys, r: weight(size), look, keep, boxed: None } +} + +/// Timecode's box over pixel (`x`, `y`). +fn box_px(px: Px, x: usize, y: usize, (r, bc, a): ([f64; 4], [f32; 4], f32)) -> Px { + let [x0, x1, y0, y1] = r; + let fy = y as f64 + 0.5; + let cy = ((fy - y0 + 0.5).clamp(0.0, 1.0) * (y1 - fy + 0.5).clamp(0.0, 1.0)) as f32; + if cy <= 0.0 { + return px; + } + let fx = x as f64 + 0.5; + let cx = ((fx - x0 + 0.5).clamp(0.0, 1.0) * (x1 - fx + 0.5).clamp(0.0, 1.0)) as f32; + let k = cx * cy * a; + if k > 0.0 { over(px, [bc[0] * k, bc[1] * k, bc[2] * k, k]) } else { px } +} + +fn draw_plan(mut b: Buf, plan: &TextPlan) -> Buf { + let w = b.img.width as usize; + let (fill, ring) = text_coverage(w, b.img.height as usize, &plan.polys, plan.r, &plan.look); + let op = plan.look.opacity; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let mut o = if plan.keep { *px } else { [0.0; 4] }; + if let Some(bx) = plan.boxed { + o = box_px(o, i % w.max(1), i / w.max(1), bx); + } + let f = fill.data[i] * op; + let s = ring.as_ref().map(|r| r.data[i]).unwrap_or(0.0) * op; + *px = text_px(o, f, s, &plan.look); + }); b } +fn numbers(ctx: &EffectCtx, b: Buf) -> Buf { + let plan = numbers_plan(ctx, &b); + draw_plan(b, &plan) +} + +/// Numbers and Timecode for the GPU compositor (effectcraft-gpu `fx_text`): the glyph +/// coverage is rasterised here (the fill in x, the stroke ring in y, opacity applied), the +/// kernel composites it. +#[derive(Clone, Debug)] +pub struct TextLayer { + pub coverage: Image, + /// Display Options (0 fill only, 1 stroke only, 2 fill over stroke, 3 stroke over fill). + pub display: u32, + pub fill: [f32; 4], + pub stroke: [f32; 4], + /// The layer stays under the text (else the text is drawn on transparency). + pub keep: bool, + /// Timecode's box: `[x0, x1, y0, y1]` (buffer pixels), colour, opacity × colour alpha. + pub boxed: Option<([f64; 4], [f32; 4], f32)>, +} + +/// [`TextLayer`] of Numbers (`ec.text.numbers`) or Timecode (`ec.text.timecode`) on a buffer +/// of `b`'s geometry. +pub fn text_layer(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { + let plan = match id { + "ec.text.numbers" => numbers_plan(ctx, b), + "ec.text.timecode" => timecode_plan(ctx, b), + _ => return None, + }; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let (fill, ring) = text_coverage(w, h, &plan.polys, plan.r, &plan.look); + let op = plan.look.opacity; + let mut coverage = Image::new(b.img.width, b.img.height); + coverage.data.par_iter_mut().enumerate().for_each(|(i, px)| { + *px = [fill.data[i] * op, ring.as_ref().map(|r| r.data[i]).unwrap_or(0.0) * op, 0.0, 0.0]; + }); + Some(TextLayer { coverage, display: plan.look.display, fill: plan.look.fill, stroke: plan.look.stroke, keep: plan.keep, boxed: plan.boxed }) +} + // ---------------------------------------------------------------- Timecode /// SMPTE drop-frame label for nominal 30/60 fps rates. @@ -368,7 +460,7 @@ fn drop_frame(frames: i64, nominal: i64) -> String { format!("{:02};{:02};{:02};{:02}", adj / (nominal * 3600), (adj / (nominal * 60)) % 60, (adj / nominal) % 60, adj % nominal) } -fn timecode_fx(ctx: &EffectCtx, mut b: Buf) -> Buf { +fn timecode_plan(ctx: &EffectCtx, b: &Buf) -> TextPlan { let pr = ctx.params; let src = pr.e("timeSource"); let (t, fps) = match src { @@ -397,36 +489,20 @@ fn timecode_fx(ctx: &EffectCtx, mut b: Buf) -> Buf { let pos = b.to_px(pr.v2("textPosition")); let wdt = line_width(&text, size, 0.0, false); let op = (pr.f("opacity") / 100.0) as f32; - if !pr.b("renderOnOriginal") { - b.img = Image::new(b.img.width, b.img.height); - } - if pr.b("showBox") { + let boxed = pr.b("showBox").then(|| { let bc = pr.color("boxColor"); let pad = size * 0.25; - let (x0, x1) = (pos.0 - wdt * 0.5 - pad, pos.0 + wdt * 0.5 + pad); - let (y0, y1) = (pos.1 - size * 0.5, pos.1 + size * 0.5); - let a = op * bc[3]; - b.img.rows_mut().for_each(|(y, row)| { - let fy = y as f64 + 0.5; - let cy = ((fy - y0 + 0.5).clamp(0.0, 1.0) * (y1 - fy + 0.5).clamp(0.0, 1.0)) as f32; - if cy <= 0.0 { - return; - } - for (x, px) in row.iter_mut().enumerate() { - let fx = x as f64 + 0.5; - let cx = ((fx - x0 + 0.5).clamp(0.0, 1.0) * (x1 - fx + 0.5).clamp(0.0, 1.0)) as f32; - let k = cx * cy * a; - if k > 0.0 { - *px = over(*px, [bc[0] * k, bc[1] * k, bc[2] * k, k]); - } - } - }); - } + ([pos.0 - wdt * 0.5 - pad, pos.0 + wdt * 0.5 + pad, pos.1 - size * 0.5, pos.1 + size * 0.5], bc, op * bc[3]) + }); let mut polys = Vec::new(); layout_line(&text, [pos.0 - wdt * 0.5, pos.1 + unit(size) * 3.0], size, 0.0, false, &mut polys); let look = TextLook { display: 0, fill: pr.color("textColor"), stroke: [0.0; 4], stroke_w: 0.0, on_original: true, opacity: op }; - draw_text(&mut b, &polys, weight(size), &look); - b + TextPlan { polys, r: weight(size), look, keep: pr.b("renderOnOriginal"), boxed } +} + +fn timecode_fx(ctx: &EffectCtx, b: Buf) -> Buf { + let plan = timecode_plan(ctx, &b); + draw_plan(b, &plan) } // ---------------------------------------------------------------- Basic Text diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs index e01283b9..a151e90d 100644 --- a/crates/effects/src/time_fx.rs +++ b/crates/effects/src/time_fx.rs @@ -680,23 +680,56 @@ fn fetch_layer(ctx: &EffectCtx, id: &str, times: &[f64], grid: &Buf) -> Option Buf { - if ctx.env.host.is_none() { - return b; +/// One output instant of a Timewarp without a Matte Layer: a fetched frame, a cross-fade of two, +/// or the in-between frame built along a motion field ([`TimewarpPlan::flows`]). +#[derive(Clone, Copy, Debug)] +pub enum TwStep { + Whole(usize), + Mix(usize, usize, f32), + Motion { a: usize, b: usize, w: f32, flow: usize }, +} + +/// Timewarp without a Matte Layer, prepared for the GPU compositor (effectcraft-gpu `fx_time`): +/// the fetched frames on one grid, the instants across the shutter (with their weights) and the +/// motion fields (block matching on the CPU). [`TimewarpPlan::render_cpu`] is the CPU effect. +pub struct TimewarpPlan { + pub grid: Buf, + pub frames: Vec, + pub steps: Vec<(TwStep, f32)>, + pub flows: Vec, + pub pm: PixelMotion, +} + +impl TimewarpPlan { + pub fn render_cpu(&self) -> Buf { + let f = &self.frames; + let imgs: Vec = self + .steps + .iter() + .map(|(s, _)| match *s { + TwStep::Whole(i) => f[i].clone(), + TwStep::Mix(i, j, w) => effectcraft_raster::flow::mix(&f[i], &f[j], w), + TwStep::Motion { a, b, w, flow } => timewarp_interpolate(&f[a], &f[b], w, &self.flows[flow], &self.pm), + }) + .collect(); + let weights: Vec = self.steps.iter().map(|(_, w)| *w).collect(); + let img = if imgs.len() == 1 { imgs.into_iter().next().unwrap_or_default() } else { average(&imgs, &weights) }; + with_img(self.grid.clone(), img) } +} + +/// Source-crop the edges of a frame (Pixel Motion works on cropped frames). +pub fn timewarp_crop(img: &Image, pm: &PixelMotion) -> Image { + crop_edges(img, pm.crops) +} + +/// The source instants Timewarp shows at the frame (with their weights) and the layer times +/// they need: (instants, frame times, frames per second). +fn timewarp_instants(ctx: &EffectCtx) -> (Vec<(f64, f32)>, Vec, f64) { let fps = ctx.fps(); let by_frame = ctx.params.e("adjustTimeBy") == 1; let speed = ctx.params.f("speed"); let src = timewarp_source_time(ctx.time, by_frame, speed, ctx.params.f("sourceFrame"), fps); - let method = ctx.params.e("method"); - let one = method > 0 && ctx.params.b("tuning/buildFromOneImage"); - // Instants of source time (with weights) across the shutter. let mut instants: Vec<(f64, f32)> = vec![]; if ctx.params.b("motionBlur/enableMotionBlur") { let angle = if ctx.params.e("motionBlur/shutterControl") == 1 { ctx.params.f("motionBlur/shutterAngle") } else { 180.0 }; @@ -709,18 +742,88 @@ fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf { } else { instants.push((src, 1.0)); } - // Each instant needs the frame at or before it and the next one. - let split = |s: f64| { - let f = s * fps; - let f0 = (f + 1e-6).floor(); - (f0, ((f - f0) as f32).max(0.0)) - }; let mut times: Vec = vec![]; for &(s, _) in &instants { - let (f0, _) = split(s); + let (f0, _) = tw_split(s, fps); times.push(f0 / fps); times.push((f0 + 1.0) / fps); } + (instants, times, fps) +} + +/// The frame at or before source time `s` and the fraction to the next one. +fn tw_split(s: f64, fps: f64) -> (f64, f32) { + let f = s * fps; + let f0 = (f + 1e-6).floor(); + (f0, ((f - f0) as f32).max(0.0)) +} + +/// [`TimewarpPlan`] of a Timewarp instance without a Matte Layer. `None` without the effect host +/// (the layer passes through) or with a Matte Layer. +pub fn timewarp_plan(ctx: &EffectCtx) -> Option { + ctx.env.host?; + if ctx.params.get("matteLayer").and_then(Value::as_layer).is_some() { + return None; + } + let (instants, times, fps) = timewarp_instants(ctx); + let method = ctx.params.e("method"); + let one = method > 0 && ctx.params.b("tuning/buildFromOneImage"); + let fr = fetch(ctx, ×)?; + let pm = PixelMotion::from(ctx, fr.grid.scale); + let warps = fetch_layer(ctx, "warpLayer", ×, &fr.grid); + let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0); + let mut flows: Vec<(i64, Flow)> = vec![]; + let mut steps = vec![]; + for &(s, wgt) in &instants { + let (f0, frac) = tw_split(s, fps); + let (i0, i1) = (idx(f0 / fps), idx((f0 + 1.0) / fps)); + let step = if method == 0 || frac < 1e-4 || (one && method == 1) { + let pick = if method == 0 || frac < 1e-4 { 0.0 } else { frac.round() }; + TwStep::Whole(if pick < 0.5 { i0 } else { i1 }) + } else if method == 1 { + TwStep::Mix(i0, i1, frac) + } else { + let key = f0 as i64; + let flow = match flows.iter().position(|(k, _)| *k == key) { + Some(k) => k, + None => { + let f = match &warps { + Some(w) => timewarp_flow(&w[i0], &w[i1], &pm), + None => timewarp_flow(&fr.imgs[i0], &fr.imgs[i1], &pm), + }; + flows.push((key, f)); + flows.len() - 1 + } + }; + TwStep::Motion { a: i0, b: i1, w: frac, flow } + }; + steps.push((step, wgt)); + } + Some(TimewarpPlan { grid: fr.grid, frames: fr.imgs, steps, flows: flows.into_iter().map(|(_, f)| f).collect(), pm }) +} + +/// Timewarp. Whole Frames shows the nearest source frame, Frame Mix cross-fades the two +/// neighbouring frames and Pixel Motion builds the in-between frame along estimated motion +/// vectors (block-matching optical flow, [`effectcraft_raster::flow`]) tuned by the Tuning +/// group. A Matte Layer splits foreground and background so each moves on its own vectors; a +/// Warp Layer supplies the motion instead of the layer itself. Motion Blur averages Shutter +/// Samples instants across the shutter. +fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf { + if ctx.env.host.is_none() { + return b; + } + if ctx.params.get("matteLayer").and_then(Value::as_layer).is_none() { + return match timewarp_plan(ctx) { + Some(plan) => plan.render_cpu(), + None => b, + }; + } + // Instants of source time (with weights) across the shutter; each needs the frame at or + // before it and the next one. + let (instants, times, fps) = timewarp_instants(ctx); + let split = |s: f64| tw_split(s, fps); + let method = ctx.params.e("method"); + let one = method > 0 && ctx.params.b("tuning/buildFromOneImage"); let Some(fr) = fetch(ctx, ×) else { return b }; let grid = fr.grid.clone(); let frame = |t: f64| -> &Image { diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index 1b0e8dee..e0836e9f 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -358,7 +358,7 @@ pub(crate) struct Post { dummy_out: wgpu::TextureView, } -const KERNELS: &[&str] = &["resolve", "radius_of", "boost", "prefix_rows", "gather", "finish", "wire"]; +const KERNELS: &[&str] = &["resolve", "radius_of", "boost", "prefix_rows", "gather", "finish", "wire", "occlude"]; impl Post { fn new(device: &wgpu::Device) -> Post { @@ -544,7 +544,7 @@ fn read_buffer(e: &mut Enc, buf: &wgpu::Buffer, size: u64) -> Option> { /// A resolved run on the GPU: premultiplied linear (or working-space) colour and camera depth /// (`BIG` where nothing was drawn), one entry per output pixel. -struct Resolved { +pub(crate) struct Resolved { acc: wgpu::Buffer, depth: wgpu::Buffer, width: u32, @@ -557,7 +557,7 @@ const BIG: f32 = 3.0e38; /// Rasterise every motion-blur sub-sample of a prepared run, resolve and average them, then /// apply the depth of field (`render_prepared`'s steps). `None` = can't run here (sizes, /// limits, or a depth of field without readback); `Some(None)` = nothing drawn. -fn resolve_run(e: &mut Enc, prep: &Prepared) -> Option> { +pub(crate) fn resolve_run(e: &mut Enc, prep: &Prepared) -> Option> { let (w, h) = prep.out; let g = e.g; if !g.fits(w, h) || (prep.dof.is_some() && !g.can_wait()) { @@ -661,7 +661,7 @@ fn depth_of_field(e: &mut Enc, acc: &wgpu::Buffer, depth: &wgpu::Buffer, (w, h): } /// The resolved run as an image in the canvas's encoding, composited over `canvas` when given. -fn finish(e: &mut Enc, r: &Resolved, encode: bool, canvas: Option<&GpuImage>) -> GpuImage { +pub(crate) fn finish(e: &mut Enc, r: &Resolved, encode: bool, canvas: Option<&GpuImage>) -> GpuImage { let out = e.image(r.width, r.height); let ov = out.texture.create_view(&Default::default()); let cv = canvas.map(|c| c.texture.create_view(&Default::default())); @@ -704,6 +704,38 @@ pub(crate) fn draw_run(e: &mut Enc, prep: &Prepared, canvas: &GpuImage) -> Optio } } +/// A layer rendered on its own (`iso`), finished, and hidden where the run's main scene is +/// nearer (`adv::draw_run`'s 2D compositing path). +pub(crate) fn occluded(e: &mut Enc, iso: &Resolved, main: &Resolved, encode: bool) -> GpuImage { + let img = finish(e, iso, encode, None); + let (w, h) = (iso.width, iso.height); + let out = e.image(w, h); + let (iv, ov) = (img.texture.create_view(&Default::default()), out.texture.create_view(&Default::default())); + let b = + Binds { color: Some(&iv), bufs: [None, Some(&iso.depth), Some(&main.acc), None, Some(&main.depth), None, None], out: Some(&ov), ..Default::default() }; + dispatch(e, "occlude", [w, h, 0, 0], [0.0; 4], b, pixel_groups(w, h)); + out +} + +/// Kernels of `sky.wgsl` (the shared compute module). +pub(crate) const SKY_KERNELS: &[&str] = &["adv_sky"]; + +/// An Environment Light Background layer drawn over `canvas` (`three_d::compose::draw_sky`). +pub(crate) fn sky(e: &mut Enc, sky: &effectcraft_render::three_d::SkyDraw, canvas: &GpuImage) -> Option { + let img = e.g.upload_buf(&sky.buf)?; + let f3 = |v: [f64; 3], w: f64| [v[0] as f32, v[1] as f32, v[2] as f32, w as f32]; + let mut p = crate::context::Params::default(); + p.f[0] = f3(sky.fwd, sky.zoom); + p.f[1] = f3(sky.right, sky.ortho as u32 as f64); + p.f[2] = [sky.down[0] as f32, sky.down[1] as f32, sky.down[2] as f32, sky.rotation]; + let s = sky.scale; + p.f[3] = [(sky.roi.0 / s - sky.view.0 / 2.0) as f32, (sky.roi.1 / s - sky.view.1 / 2.0) as f32, (1.0 / s) as f32, sky.opacity]; + p.f[4] = [img.width as f32, img.height as f32, 0.0, 0.0]; + let out = e.scratch(canvas.width, canvas.height); + e.pixels("adv_sky", &p, canvas, Some(&img), &out, None); + Some(out) +} + /// Wireframe-quality layer outlines: `canvas` with `pixels` set to opaque white /// ([`effectcraft_render::Renderer::wireframe_pixels`]). pub(crate) fn wireframe(e: &mut Enc, canvas: &GpuImage, pixels: &[(u32, u32)]) -> GpuImage { diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 3781b5ee..34b981c6 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -316,6 +316,11 @@ impl GpuContext { include_str!("shaders/fx_tone.wgsl"), include_str!("shaders/fx_warp.wgsl"), include_str!("shaders/fx_extra.wgsl"), + include_str!("shaders/fx_light.wgsl"), + include_str!("shaders/fx_transition.wgsl"), + include_str!("shaders/fx_text.wgsl"), + include_str!("shaders/fx_time.wgsl"), + include_str!("shaders/sky.wgsl"), ] .concat(); let module = @@ -375,6 +380,11 @@ impl GpuContext { .chain(crate::fx_tone::KERNELS) .chain(crate::fx_warp::KERNELS) .chain(crate::fx_extra::KERNELS) + .chain(crate::fx_light::KERNELS) + .chain(crate::fx_transition::KERNELS) + .chain(crate::fx_text::KERNELS) + .chain(crate::fx_time::KERNELS) + .chain(crate::adv3d::SKY_KERNELS) .map(|e| (e, &layout)) .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) .map(|(e, layout)| { diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index ef18e17e..20cc5189 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -167,6 +167,10 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { _ if crate::fx_tone::IDS.contains(&id) => crate::fx_tone::apply(e, id, ctx, b), _ if crate::fx_warp::IDS.contains(&id) => crate::fx_warp::apply(e, id, ctx, b), _ if crate::fx_extra::IDS.contains(&id) => crate::fx_extra::apply(e, id, ctx, b), + _ if crate::fx_light::IDS.contains(&id) => crate::fx_light::apply(e, id, ctx, b), + _ if crate::fx_transition::IDS.contains(&id) => crate::fx_transition::apply(e, id, ctx, b), + _ if crate::fx_text::IDS.contains(&id) => crate::fx_text::apply(e, id, ctx, b), + _ if crate::fx_time::IDS.contains(&id) => crate::fx_time::apply(e, id, ctx, b), _ => pointwise(e, id, ctx, b), } } diff --git a/crates/gpu/src/fx_light.rs b/crates/gpu/src/fx_light.rs new file mode 100644 index 00000000..6d96d0e7 --- /dev/null +++ b/crates/gpu/src/fx_light.rs @@ -0,0 +1,126 @@ +//! GPU effects, the CC light family (kernels in `shaders/fx_light.wgsl`, every entry point +//! prefixed `flt_`): CC Light Rays, CC Light Burst 2.5, CC Light Sweep and CC Light Wipe, with +//! the CPU effects' exact steps. Centres, radii, the wipe's corner distance and ramp are +//! computed on the CPU in f64 as the CPU effect does; the kernels work relative to the centre. +//! Light Sweep's softened alpha plane is the CPU's `gauss_plane` (box passes, edges repeated). + +use effectcraft_effects::EffectCtx; + +use crate::context::{Enc, GpuImage, Params}; +use crate::effects::{GBuf, gaussian_blur}; + +/// Compute entry points in `fx_light.wgsl`. +pub(crate) const KERNELS: &[&str] = &["flt_rays", "flt_burst", "flt_sweep", "flt_wipe"]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &["ec.generate.cclightrays", "ec.generate.cclightburst", "ec.generate.cclightsweep", "ec.transition.cclightwipe"]; + +/// 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 { + match id { + "ec.generate.cclightrays" => light_rays(e, ctx, b), + "ec.generate.cclightburst" => light_burst(e, ctx, b), + "ec.generate.cclightsweep" => light_sweep(e, ctx, b), + "ec.transition.cclightwipe" => light_wipe(e, ctx, b), + _ => None, + } +} + +/// A same-size per-pixel kernel over the buffer. +fn run(e: &mut Enc, entry: &str, p: &Params, mut b: GBuf, aux: Option<&GpuImage>) -> Option { + let out = e.scratch(b.img.width, b.img.height); + e.pixels(entry, p, &b.img, aux, &out, None); + b.img = out; + Some(b) +} + +fn rgb(c: [f32; 4]) -> [f32; 4] { + [c[0], c[1], c[2], 0.0] +} + +// ---------------------------------------------------------------- CC Light Rays / Burst (generate2.rs) + +fn light_rays(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let c = b.to_px(pr.v2("center")); + let k = (pr.f("intensity") / 100.0 * 0.5).clamp(0.0, 0.95); + let gain = (pr.f("intensity") / 100.0 * 2.0) as f32; + let rad = (pr.f("radius") * b.scale).max(0.5); + let ws = (pr.f("warpSoftness") / 50.0).max(0.0); + let mut p = Params::default(); + p.u[0] = [(pr.e("shape") == 1) as u32, pr.b("colorFromSource") as u32, pr.e("transferMode"), 0]; + p.f[0] = [c.0 as f32, c.1 as f32, k as f32, gain]; + p.f[1] = [rad as f32, (rad * (1.0 + ws)) as f32 + 1e-3, 0.0, 0.0]; + p.f[2] = rgb(pr.color("color")); + run(e, "flt_rays", &p, b, None) +} + +fn light_burst(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let c = b.to_px(pr.v2("center")); + let inten = (pr.f("intensity") / 100.0) as f32; + let k = (pr.f("rayLength") / 100.0).clamp(0.0, 1.0); + let set_color = pr.b("setColor"); + if k <= 0.0 && (inten - 1.0).abs() < 1e-6 && !set_color { + return Some(b); + } + let mut p = Params::default(); + p.u[0] = [pr.e("burst"), set_color as u32, 0, 0]; + p.f[0] = [c.0 as f32, c.1 as f32, k as f32, inten]; + p.f[1] = rgb(pr.color("color")); + run(e, "flt_burst", &p, b, None) +} + +// ---------------------------------------------------------------- CC Light Sweep (generate2.rs) + +fn light_sweep(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let c = b.to_px(pr.v2("center")); + let dir = pr.f("direction").to_radians(); + let half = (pr.f("width") * b.scale * 0.5).max(0.5); + let edge_t = pr.f("edgeThickness") * b.scale; + // The alpha plane (x), softened by util::gauss_plane (edges repeated) when thick enough. + let alpha = e.scratch(b.img.width, b.img.height); + e.pixels("fxe_alpha", &Params::default(), &b.img, None, &alpha, None); + let soft = if edge_t > 0.05 { gaussian_blur(e, &alpha, edge_t * 0.5, edge_t * 0.5, true) } else { alpha }; + let mut p = Params::default(); + p.u[0] = [pr.e("shape"), pr.e("lightReceptionMode"), 0, 0]; + p.f[0] = [c.0 as f32, c.1 as f32, dir.cos() as f32, dir.sin() as f32]; + p.f[1] = [half as f32, (pr.f("sweepIntensity") / 100.0) as f32, (pr.f("edgeIntensity") / 100.0) as f32, 0.0]; + p.f[2] = rgb(pr.color("lightColor")); + run(e, "flt_sweep", &p, b, Some(&soft)) +} + +// ---------------------------------------------------------------- CC Light Wipe (transition.rs) + +fn light_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let done = (pr.f("completion") / 100.0).clamp(0.0, 1.0); + if done <= 0.0 { + return Some(b); + } + let c = b.to_px(pr.v2("center")); + let shape = pr.e("shape"); + let (s, co) = pr.f("direction").to_radians().sin_cos(); + let reverse = pr.b("reverse"); + let metric = |dx: f64, dy: f64| { + let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co); + match shape { + 0 => qx.abs(), + 2 => qx.abs().max(qy.abs()), + _ => qx.hypot(qy), + } + }; + // transition::max_corner_dist. + let (w, h) = (b.img.width as f64, b.img.height as f64); + let maxd = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|(x, y)| metric(x - c.0, y - c.1)).fold(1e-6, f64::max); + let edge = if reverse { (1.0 - done) * maxd } else { done * maxd }; + let band = (maxd * 0.06).max(2.0); + let ramp = (done * 20.0).min(1.0) as f32 * ((1.0 - done) * 20.0).min(1.0) as f32; + let mut p = Params::default(); + p.u[0] = [shape, pr.b("colorFromSource") as u32, reverse as u32, 0]; + p.f[0] = [c.0 as f32, c.1 as f32, s as f32, co as f32]; + p.f[1] = [edge as f32, band as f32, pr.f("intensity") as f32 / 100.0 * ramp, 0.0]; + p.f[2] = rgb(pr.color("color")); + run(e, "flt_wipe", &p, b, None) +} diff --git a/crates/gpu/src/fx_text.rs b/crates/gpu/src/fx_text.rs new file mode 100644 index 00000000..a5196957 --- /dev/null +++ b/crates/gpu/src/fx_text.rs @@ -0,0 +1,34 @@ +//! GPU effects, Numbers and Timecode (kernel `ftx_text` in `shaders/fx_text.wgsl`). The text +//! is laid out and its glyph strokes rasterised on the CPU (`effectcraft_effects::text_layer`, +//! the CPU effect's own coverage: fill and stroke ring), uploaded as one image and composited +//! on the GPU with the layer, Timecode's box and the fill / stroke colours. + +use effectcraft_effects::{Buf, EffectCtx}; +use effectcraft_raster::Image; + +use crate::context::{Enc, Params}; +use crate::effects::GBuf; + +/// Compute entry points in `fx_text.wgsl`. +pub(crate) const KERNELS: &[&str] = &["ftx_text"]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &["ec.text.numbers", "ec.text.timecode"]; + +/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + let shape = Buf { img: Image { width: b.img.width, height: b.img.height, data: vec![] }, offset: b.offset, scale: b.scale }; + let t = effectcraft_effects::text_layer(id, ctx, &shape)?; + let cov = e.g.upload_image(&t.coverage)?; + let mut p = Params::default(); + p.u[0] = [t.display, t.keep as u32, t.boxed.is_some() as u32, 0]; + p.f[0] = t.fill; + p.f[1] = t.stroke; + if let Some((r, c, a)) = t.boxed { + p.f[2] = r.map(|v| v as f32); + p.f[3] = [c[0], c[1], c[2], a]; + } + let out = e.scratch(b.img.width, b.img.height); + e.pixels("ftx_text", &p, &b.img, Some(&cov), &out, None); + Some(GBuf { img: out, ..b }) +} diff --git a/crates/gpu/src/fx_time.rs b/crates/gpu/src/fx_time.rs new file mode 100644 index 00000000..e94fb094 --- /dev/null +++ b/crates/gpu/src/fx_time.rs @@ -0,0 +1,223 @@ +//! GPU effects, time (kernels in `shaders/fx_time.wgsl`, every entry point prefixed `ftm_`): +//! Time Difference, Time Displacement, CC Force Motion Blur, CC Wide Time, Pixel Motion Blur and +//! Timewarp. Like Echo (`fx_noise`), the other frames come from the effect host on the CPU +//! (`effectcraft_effects::time_frames`: fetched at the CPU effect's layer times and placed on +//! one grid) and are uploaded; the kernels combine them with the CPU effect's arithmetic in its +//! order (weighted sums one frame per pass, so the float rounding matches). +//! +//! Time Displacement's per-pixel time (the map's luminance, quantised to the time resolution) +//! is computed on the CPU, since it decides which frames to fetch; the GPU gathers each pixel +//! from its frame. Timewarp's motion vectors (block matching and smoothing, +//! `effectcraft_raster::flow`) are estimated on the CPU (`effectcraft_effects::timewarp_plan`); +//! the frame building (Whole Frames, Frame Mix, Pixel Motion's warp along the vectors with its +//! error fallback, the shutter samples' average) runs on the GPU. Timewarp with a Matte Layer +//! renders on the CPU (`catalog::gpu_supported`). + +use effectcraft_effects::util::{fit_layer, unpremul}; +use effectcraft_effects::{Buf, EffectCtx, TwStep}; +use effectcraft_keyframe::Value; + +use crate::context::{Enc, GpuImage, Params}; +use crate::effects::GBuf; + +/// Compute entry points in `fx_time.wgsl`. +pub(crate) const KERNELS: &[&str] = &["ftm_acc", "ftm_mix", "ftm_difference", "ftm_displace", "ftm_motion"]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = + &["ec.time.timedifference", "ec.time.timedisplacement", "ec.time.ccforcemotionblur", "ec.time.ccwidetime", "ec.time.pixelmotionblur", "ec.time.timewarp"]; + +/// 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 { + match id { + "ec.time.timedifference" => time_difference(e, ctx, b), + "ec.time.timedisplacement" => time_displacement(e, ctx, b), + "ec.time.ccforcemotionblur" => { + let n = ctx.params.f("motionBlurLevels").round().clamp(2.0, 64.0) as usize; + let angle = if ctx.params.b("overrideShutterAngle") { ctx.params.f("shutterAngle") } else { 180.0 }; + let span = angle.clamp(0.0, 3600.0) / 360.0 / ctx.fps(); + mean_of(e, ctx, b, &(0..n).map(|i| ctx.time + span * i as f64 / n as f64).collect::>()) + } + "ec.time.ccwidetime" => { + let fwd = ctx.params.f("forwardSteps").round().clamp(0.0, 45.0) as i64; + let back = ctx.params.f("backwardSteps").round().clamp(0.0, 45.0) as i64; + let fd = 1.0 / ctx.fps(); + mean_of(e, ctx, b, &(-back..=fwd).map(|k| ctx.time + k as f64 * fd).collect::>()) + } + "ec.time.pixelmotionblur" => { + let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 }; + let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize; + let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps(); + let times: Vec = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect(); + mean_of(e, ctx, b, ×) + } + "ec.time.timewarp" => timewarp(e, ctx, b), + _ => None, + } +} + +fn kernel(e: &mut Enc, entry: &str, p: &Params, src: &GpuImage, aux: Option<&GpuImage>, data: Option<&wgpu::Buffer>) -> GpuImage { + let out = e.scratch(src.width, src.height); + e.pixels(entry, p, src, aux, &out, data); + out +} + +/// time_fx::average: Σ frame × weight, one frame per pass in the CPU's order. +fn average(e: &mut Enc, frames: &[GpuImage], weights: &[f32]) -> Option { + let first = frames.first()?; + let mut acc = e.zeros(first.width, first.height); + for (f, &k) in frames.iter().zip(weights) { + let mut p = Params::default(); + p.f[0][0] = k; + acc = kernel(e, "ftm_acc", &p, &acc, Some(f), None); + } + Some(acc) +} + +fn on_grid(img: GpuImage, grid: &Buf) -> GBuf { + GBuf { img, offset: grid.offset, scale: grid.scale } +} + +/// time_fx::mean_of: the plain average of the layer at `times`. +fn mean_of(e: &mut Enc, ctx: &EffectCtx, b: GBuf, times: &[f64]) -> Option { + if times.is_empty() { + return Some(b); + } + let Some((grid, imgs)) = effectcraft_effects::time_frames(ctx, times) else { return Some(b) }; + let frames = imgs.iter().map(|i| e.g.upload_image(i)).collect::>>()?; + let k = 1.0 / times.len() as f32; + let img = average(e, &frames, &vec![k; times.len()])?; + Some(on_grid(img, &grid)) +} + +// ---------------------------------------------------------------- Time Difference + +fn time_difference(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let Some(host) = ctx.env.host else { return Some(b) }; + let pr = ctx.params; + let off = pr.f("timeOffset"); + let target_id = pr.get("targetLayer").and_then(Value::as_layer); + let (grid, cur, target) = match target_id.and_then(|id| host.layer_at(id, ctx.env.comp_time + off, false)) { + Some(other) => { + let Some((grid, mut imgs)) = effectcraft_effects::time_frames(ctx, &[ctx.time]) else { return Some(b) }; + let cur = imgs.pop().unwrap_or_default(); + let tgt = fit_layer(ctx, &Buf { img: cur.clone(), ..grid.clone() }, &other, true); + (grid, cur, tgt) + } + None => { + let Some((grid, mut imgs)) = effectcraft_effects::time_frames(ctx, &[ctx.time, ctx.time + off]) else { return Some(b) }; + let tgt = imgs.pop().unwrap_or_default(); + let cur = imgs.pop().unwrap_or_default(); + (grid, cur, tgt) + } + }; + let (cur, target) = (e.g.upload_image(&cur)?, e.g.upload_image(&target)?); + let mut p = Params::default(); + p.u[0] = [pr.b("absoluteDifference") as u32, pr.e("alphaChannel"), 0, 0]; + p.f[0][0] = 1.0 + pr.f("contrast") as f32 / 25.0; + let img = kernel(e, "ftm_difference", &p, &cur, Some(&target), None); + Some(on_grid(img, &grid)) +} + +// ---------------------------------------------------------------- Time Displacement + +fn time_displacement(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + if ctx.env.host.is_none() { + return Some(b); + } + let pr = ctx.params; + let max = pr.f("maxDisplacementTime"); + let res = pr.f("timeResolution").clamp(1.0, 999.0); + let Some((grid, mut imgs)) = effectcraft_effects::time_frames(ctx, &[ctx.time]) else { return Some(b) }; + let grid = Buf { img: imgs.pop().unwrap_or_default(), ..grid }; + let map = match ctx.layer_param("displacementMapLayer", true) { + Some(o) => fit_layer(ctx, &grid, &o, pr.b("stretchMap")), + None => grid.img.clone(), + }; + // The CPU effect's per-pixel time step (luminance → −max..+max, quantised). + let span = 2.0 * max.abs() * res; + let step = if span > 63.0 { 2.0 * max.abs() / 63.0 } else { 1.0 / res }; + let idx: Vec = map + .data + .iter() + .map(|p| { + let (c, a) = unpremul(*p); + let l = if a > 0.0 { effectcraft_color::luminance(c[0], c[1], c[2]) } else { 0.5 }; + (((l as f64 - 0.5) * 2.0 * max) / step).round() as i32 + }) + .collect(); + let mut keys = idx.clone(); + keys.sort_unstable(); + keys.dedup(); + if keys == [0] { + return Some(on_grid(e.g.upload_image(&grid.img)?, &grid)); + } + let times: Vec = keys.iter().map(|k| ctx.time + *k as f64 * step).collect(); + let Some((fgrid, frames)) = effectcraft_effects::time_frames(ctx, ×) else { return Some(on_grid(e.g.upload_image(&grid.img)?, &grid)) }; + let dx = (fgrid.offset[0] - grid.offset[0]).round() as i32; + let dy = (fgrid.offset[1] - grid.offset[1]).round() as i32; + let which: Vec = idx.iter().flat_map(|k| (keys.binary_search(k).unwrap_or(0) as u32).to_le_bytes()).collect(); + let data = e.bytes(which); + let (w, h) = (grid.img.width, grid.img.height); + let mut acc = e.zeros(w, h); + for (fi, f) in frames.iter().enumerate() { + let frame = e.g.upload_image(f)?; + let mut p = Params::default(); + p.u[0] = [fi as u32, w, dx as u32, dy as u32]; + let out = e.scratch(w, h); + e.pixels("ftm_displace", &p, &acc, Some(&frame), &out, Some(&data)); + acc = out; + } + Some(on_grid(acc, &grid)) +} + +// ---------------------------------------------------------------- Timewarp + +fn timewarp(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + if ctx.env.host.is_none() { + return Some(b); + } + // A Matte Layer splits foreground and background: the CPU's path. + if ctx.params.get("matteLayer").and_then(Value::as_layer).is_some() { + return None; + } + let Some(plan) = effectcraft_effects::timewarp_plan(ctx) else { return Some(b) }; + let mut up: Vec> = vec![None; plan.frames.len()]; + let mut frame = |e: &mut Enc, i: usize| -> Option { + if up[i].is_none() { + up[i] = Some(e.g.upload_image(&plan.frames[i])?); + } + up[i].clone() + }; + let mut imgs = vec![]; + for (step, _) in &plan.steps { + let img = match *step { + TwStep::Whole(i) => frame(e, i)?, + TwStep::Mix(i, j, w) => { + let (a, bb) = (frame(e, i)?, frame(e, j)?); + let mut p = Params::default(); + p.f[0][0] = w; + kernel(e, "ftm_mix", &p, &a, Some(&bb), None) + } + TwStep::Motion { a, b: bi, w, flow } => { + let pa = e.g.upload_image(&effectcraft_effects::timewarp_crop(&plan.frames[a], &plan.pm))?; + let pb = e.g.upload_image(&effectcraft_effects::timewarp_crop(&plan.frames[bi], &plan.pm))?; + let f = &plan.flows[flow]; + let v: Vec = f.v.iter().flat_map(|q| [q[0], q[1]]).collect(); + let data = e.data(&v); + let mut p = Params::default(); + p.u[0] = [if f.v.is_empty() { 0 } else { f.cols as u32 }, f.rows as u32, plan.pm.extreme as u32, plan.pm.build_from_one as u32]; + p.f[0] = [f.block as f32, w, plan.pm.error_threshold, 0.0]; + kernel(e, "ftm_motion", &p, &pa, Some(&pb), Some(&data)) + } + }; + imgs.push(img); + } + let img = if imgs.len() == 1 { + imgs.pop()? + } else { + let weights: Vec = plan.steps.iter().map(|(_, w)| *w).collect(); + average(e, &imgs, &weights)? + }; + Some(on_grid(img, &plan.grid)) +} diff --git a/crates/gpu/src/fx_transition.rs b/crates/gpu/src/fx_transition.rs new file mode 100644 index 00000000..b74607a0 --- /dev/null +++ b/crates/gpu/src/fx_transition.rs @@ -0,0 +1,595 @@ +//! GPU effects, transitions and perspective (kernels in `shaders/fx_transition.wgsl`, every +//! entry point prefixed `ftr_`): Block Dissolve, CC Glass Wipe, CC Grid Wipe, CC Image Wipe, +//! CC Jaws, CC Line Sweep, CC Radial ScaleWipe, CC Scale Wipe, CC Twister, CC WarpoMatic, +//! Radial Shadow, CC Bender, CC Blobbylize, CC Cylinder, CC Sphere, CC Spotlight, CC +//! Environment and 3D Glasses, with the CPU effects' exact steps. +//! +//! Scalars (corner distances, projected extents, light vectors, rotation matrices) are computed +//! on the CPU in f64 as the CPU effect does; Block Dissolve's block indices too (one per column +//! and row sub-sample), so the hash sees the CPU's exact integers. Other layers (gradient, +//! reveal, backside, environment, stereo views) are fetched and fitted on the CPU +//! (`util::fit_layer`) and uploaded. Gradient planes are blurred with the CPU's +//! `util::gauss_plane` steps (box passes, edges repeated). Card Wipe (a 3D card renderer +//! shared with Card Dance and Shatter) stays on the CPU. + +use effectcraft_effects::{Buf, EffectCtx}; +use effectcraft_raster::Image; + +use crate::context::{Enc, GpuImage, Params}; +use crate::effects::{GBuf, gaussian_blur}; + +/// Compute entry points in `fx_transition.wgsl`. +pub(crate) const KERNELS: &[&str] = &[ + "ftr_plane", + "ftr_mul", + "ftr_under", + "ftr_blocks", + "ftr_grid", + "ftr_radial_scale", + "ftr_scale", + "ftr_glass", + "ftr_image_wipe", + "ftr_jaws", + "ftr_line_sweep", + "ftr_twister", + "ftr_react_contrast", + "ftr_react_local", + "ftr_warpo", + "ftr_rshadow", + "ftr_bender", + "ftr_blob", + "ftr_cylinder", + "ftr_sphere", + "ftr_spot", + "ftr_env", + "ftr_shift", + "ftr_glasses", +]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[ + "ec.transition.blockdissolve", + "ec.transition.ccglasswipe", + "ec.transition.ccgridwipe", + "ec.transition.ccimagewipe", + "ec.transition.ccjaws", + "ec.transition.cclinesweep", + "ec.transition.ccradialscalewipe", + "ec.transition.ccscalewipe", + "ec.transition.cctwister", + "ec.transition.ccwarpomatic", + "ec.perspective.radialshadow", + "ec.distort.ccbender", + "ec.distort.ccblobbylize", + "ec.perspective.cccylinder", + "ec.perspective.ccsphere", + "ec.perspective.ccspotlight", + "ec.perspective.ccenvironment", + "ec.perspective.3dglasses", +]; + +/// 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 { + match id { + "ec.transition.blockdissolve" => block_dissolve(e, ctx, b), + "ec.transition.ccglasswipe" => glass_wipe(e, ctx, b), + "ec.transition.ccgridwipe" => grid_wipe(e, ctx, b), + "ec.transition.ccimagewipe" => image_wipe(e, ctx, b), + "ec.transition.ccjaws" => jaws(e, ctx, b), + "ec.transition.cclinesweep" => line_sweep(e, ctx, b), + "ec.transition.ccradialscalewipe" => radial_scale_wipe(e, ctx, b), + "ec.transition.ccscalewipe" => scale_wipe(e, ctx, b), + "ec.transition.cctwister" => twister(e, ctx, b), + "ec.transition.ccwarpomatic" => warpomatic(e, ctx, b), + "ec.perspective.radialshadow" => radial_shadow(e, ctx, b), + "ec.distort.ccbender" => bender(e, ctx, b), + "ec.distort.ccblobbylize" => blobbylize(e, ctx, b), + "ec.perspective.cccylinder" => cylinder(e, ctx, b), + "ec.perspective.ccsphere" => sphere(e, ctx, b), + "ec.perspective.ccspotlight" => spotlight(e, ctx, b), + "ec.perspective.ccenvironment" => environment(e, ctx, b), + "ec.perspective.3dglasses" => glasses(e, ctx, b), + _ => None, + } +} + +// ---------------------------------------------------------------- helpers + +/// A same-size per-pixel kernel over `src` (the buffer's size). +fn kernel(e: &mut Enc, entry: &str, p: &Params, src: &GpuImage, aux: Option<&GpuImage>, data: Option<&wgpu::Buffer>) -> GpuImage { + let out = e.scratch(src.width, src.height); + e.pixels(entry, p, src, aux, &out, data); + out +} + +fn run(e: &mut Enc, entry: &str, p: &Params, mut b: GBuf, aux: Option<&GpuImage>, data: Option<&wgpu::Buffer>) -> Option { + b.img = kernel(e, entry, p, &b.img, aux, data); + Some(b) +} + +fn f4(v: [f64; 4]) -> [f32; 4] { + v.map(|x| x as f32) +} + +fn rgb(c: [f32; 4], w: f32) -> [f32; 4] { + [c[0], c[1], c[2], w] +} + +/// The CPU buffer geometry of `b` (no pixels: `fit_layer` reads only size, offset and scale). +fn shape(b: &GBuf) -> Buf { + Buf { img: Image { width: b.img.width, height: b.img.height, data: vec![] }, offset: b.offset, scale: b.scale } +} + +/// The layer in layer parameter `id` fitted to `b` (`util::fit_layer`) and uploaded; `Some(None)` +/// when none is chosen. +fn fitted(e: &mut Enc, ctx: &EffectCtx, b: &GBuf, id: &str, masks_and_effects: bool, stretch: bool) -> Option> { + match ctx.layer_param(id, masks_and_effects) { + Some(o) => Some(Some(e.g.upload_image(&effectcraft_effects::util::fit_layer(ctx, &shape(b), &o, stretch))?)), + None => Some(None), + } +} + +/// `util::layer_or_self`: the fitted layer, or the buffer itself. +fn layer_or_self(e: &mut Enc, ctx: &EffectCtx, b: &GBuf, id: &str) -> Option { + Some(fitted(e, ctx, b, id, true, false)?.unwrap_or_else(|| b.img.clone())) +} + +/// A plane (x) of `img`: `util::Src` index `src` times alpha (alpha itself unscaled); 11 is CC +/// Environment's relief. +fn plane(e: &mut Enc, img: &GpuImage, src: u32) -> GpuImage { + let mut p = Params::default(); + p.u[0][0] = src; + kernel(e, "ftr_plane", &p, img, None, None) +} + +/// transition2::prop_plane's property → `util::Src` index. +fn prop_src(prop: u32) -> u32 { + match prop { + 5 => 6, + 6 => 7, + p => p.min(4), + } +} + +/// `img` as RGBA rows for the kernels' third image (`P.u[3]` = stride, width, height). +fn rows(e: &mut Enc, p: &mut Params, img: &GpuImage) -> wgpu::Buffer { + let (buf, stride) = e.image_rows(img); + p.u[3] = [stride, img.width, img.height, 0]; + buf +} + +fn completion(ctx: &EffectCtx) -> f64 { + (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0) +} + +// ---------------------------------------------------------------- Block Dissolve (transition.rs) + +fn block_dissolve(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = completion(ctx) as f32; + if done <= 0.0 { + return Some(b); + } + let pr = ctx.params; + let bw = (pr.f("blockWidth") * b.scale).max(1e-3); + let bh = (pr.f("blockHeight") * b.scale).max(1e-3); + let feather = pr.f("feather") * b.scale; + let subs: Vec = if pr.b("softEdges") { (0..4).map(|k| (k as f64 + 0.5) / 4.0).collect() } else { vec![0.5] }; + // Block indices per column / row sub-sample, as the CPU computes them (f64, then u32). + let index = + |n: u32, o: f64, size: f64| (0..n).flat_map(|i| subs.iter().map(move |s| (((i as f64 + s - o) / size).floor() as i64) as u32)).collect::>(); + let cols = index(b.img.width, b.offset[0], bw); + let table: Vec = cols.iter().chain(&index(b.img.height, b.offset[1], bh)).flat_map(|v| v.to_le_bytes()).collect(); + let data = e.bytes(table); + let mut p = Params::default(); + p.u[0] = [subs.len() as u32, ctx.seed ^ 0xb10c, cols.len() as u32, 0]; + p.f[0][0] = done; + let mut mask = kernel(e, "ftr_blocks", &p, &b.img, None, Some(&data)); + if feather > 0.0 { + mask = gaussian_blur(e, &mask, feather * 0.5, feather * 0.5, true); + } + run(e, "ftr_mul", &Params::default(), b, Some(&mask), None) +} + +// ---------------------------------------------------------------- CC Grid Wipe / Radial ScaleWipe / Scale Wipe (transition.rs) + +/// transition::max_corner_dist. +fn max_corner_dist(b: &GBuf, c: (f64, f64), metric: impl Fn(f64, f64) -> f64) -> f64 { + let (w, h) = (b.img.width as f64, b.img.height as f64); + [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|(x, y)| metric(x - c.0, y - c.1)).fold(1e-6, f64::max) +} + +fn grid_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = completion(ctx); + if done <= 0.0 { + return Some(b); + } + let pr = ctx.params; + let c = b.to_px(pr.v2("center")); + let rot = pr.f("rotation").to_radians(); + let border = pr.f("border").max(0.0) * b.scale; + let tiles = pr.f("tiles").round().clamp(1.0, 500.0); + let shape = pr.e("shape"); + let cs = (ctx.layer_size[0] * b.scale / tiles).max(1.0); + let (s, co) = rot.sin_cos(); + let metric = |dx: f64, dy: f64| match shape { + 0 => dx.abs(), + 2 => dx.abs().max(dy.abs()), + _ => dx.hypot(dy), + }; + let maxd = max_corner_dist(&b, c, |dx, dy| metric(dx * co + dy * s, -dx * s + dy * co)); + let mut p = Params::default(); + p.u[0] = [shape, pr.b("reverse") as u32, 0, 0]; + p.f[0] = f4([c.0, c.1, s, co]); + p.f[1] = f4([cs, maxd, done, border]); + run(e, "ftr_grid", &p, b, None, None) +} + +fn radial_scale_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = completion(ctx); + if done <= 0.0 { + return Some(b); + } + let c = b.to_px(ctx.params.v2("center")); + let maxd = max_corner_dist(&b, c, f64::hypot); + let mut p = Params::default(); + p.u[0][0] = ctx.params.b("reverse") as u32; + p.f[0] = f4([c.0, c.1, maxd, done]); + run(e, "ftr_radial_scale", &p, b, None, None) +} + +fn scale_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let stretch = ctx.params.f("stretch").max(0.0); + if stretch <= 0.0 { + return Some(b); + } + let c = b.to_px(ctx.params.v2("center")); + let a = ctx.params.f("direction").to_radians(); + let mut p = Params::default(); + p.f[0] = f4([c.0, c.1, a.sin(), -a.cos()]); + p.f[1][0] = stretch as f32; + run(e, "ftr_scale", &p, b, None, None) +} + +// ---------------------------------------------------------------- CC Glass Wipe / Image Wipe / WarpoMatic (transition2.rs) + +fn glass_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let c = completion(ctx) as f32; + if c <= 0.0 { + return Some(b); + } + let pr = ctx.params; + let gimg = layer_or_self(e, ctx, &b, "gradientLayer")?; + let mut g = plane(e, &gimg, 4); + let sm = pr.f("softness") * b.scale * 0.2; + if sm > 0.05 { + g = gaussian_blur(e, &g, sm, sm, true); + } + let soft = (pr.f("softness") / 100.0) as f32; + let rev = fitted(e, ctx, &b, "layerToReveal", true, false)?; + let mut p = Params::default(); + let data = rev.as_ref().map(|r| rows(e, &mut p, r)); + p.u[0][0] = rev.is_some() as u32; + p.f[0] = [c, soft * 0.5 + 0.01, (pr.f("displacementAmount") * b.scale * 4.0) as f32, 0.0]; + run(e, "ftr_glass", &p, b, Some(&g), data.as_ref()) +} + +fn image_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let c = completion(ctx) as f32; + if c <= 0.0 { + return Some(b); + } + let pr = ctx.params; + let gimg = layer_or_self(e, ctx, &b, "layer")?; + let mut g = plane(e, &gimg, prop_src(pr.e("property"))); + let bl = pr.f("blur") * b.scale * 0.5; + if bl > 0.05 { + g = gaussian_blur(e, &g, bl, bl, true); + } + let mut w = (pr.f("borderSoftness") / 100.0) as f32 * 0.5; + if pr.b("autoSoftness") { + w = w.max(0.02); + } + let mut p = Params::default(); + p.u[0][0] = pr.b("inverseGradient") as u32; + p.f[0] = [c, w, 0.0, 0.0]; + run(e, "ftr_image_wipe", &p, b, Some(&g), None) +} + +fn warpomatic(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let c = completion(ctx) as f32; + if c <= 0.0 { + return Some(b); + } + let pr = ctx.params; + let rimg = layer_or_self(e, ctx, &b, "reactorLayer")?; + let mut m = plane(e, &rimg, 4); + let sm = pr.f("smoothness") * b.scale * 0.5; + if sm > 0.05 { + m = gaussian_blur(e, &m, sm, sm, true); + } + m = match pr.e("reactor") { + 1 => { + let mean = gaussian_blur(e, &m, 8.0 * b.scale, 8.0 * b.scale, true); + kernel(e, "ftr_react_contrast", &Params::default(), &m, Some(&mean), None) + } + 2 => kernel(e, "ftr_react_local", &Params::default(), &m, Some(&m), None), + _ => m, + }; + let rev = fitted(e, ctx, &b, "layerToReveal", true, false)?; + let mut p = Params::default(); + let data = rev.as_ref().map(|r| rows(e, &mut p, r)); + p.u[0] = [pr.e("warpDirection"), rev.is_some() as u32, 0, 0]; + p.f[0] = [c, (pr.f("blendSpan") / 100.0) as f32 * 0.5, (pr.f("warpAmount") * b.scale) as f32 * 20.0, 0.0]; + run(e, "ftr_warpo", &p, b, Some(&m), data.as_ref()) +} + +// ---------------------------------------------------------------- CC Jaws / Line Sweep / Twister (transition2.rs) + +fn jaws(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let c = completion(ctx); + if c <= 0.0 { + return Some(b); + } + let pr = ctx.params; + let (cx, cy) = b.to_px(pr.v2("center")); + let (sn, cs) = pr.f("direction").to_radians().sin_cos(); + let width = (pr.f("width") * b.scale).max(1.0); + let amp = pr.f("height") / 100.0 * width * 0.5; + let (bw, bh) = (b.img.width as f64, b.img.height as f64); + let vmax = [(0.0, 0.0), (bw, 0.0), (0.0, bh), (bw, bh)].iter().map(|&(x, y)| (-(x - cx) * sn + (y - cy) * cs).abs()).fold(0.0, f64::max); + let d = c * (vmax + 2.0 * amp + 2.0); + let mut p = Params::default(); + p.u[0][0] = pr.e("shape"); + p.f[0] = f4([cx, cy, sn, cs]); + p.f[1] = f4([width, amp, d, 0.0]); + run(e, "ftr_jaws", &p, b, None, None) +} + +fn line_sweep(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let c = completion(ctx); + if c <= 0.0 { + return Some(b); + } + let pr = ctx.params; + let (sn, cs) = pr.f("direction").to_radians().sin_cos(); + let thick = (pr.f("thickness") * b.scale).max(1.0); + let slant = pr.f("slant") * b.scale; + let (w, h) = (b.img.width as f64, b.img.height as f64); + let (mut u0, mut u1, mut v0, mut v1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN); + for (x, y) in [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)] { + let (u, v) = (x * cs + y * sn, -x * sn + y * cs); + u0 = u0.min(u); + u1 = u1.max(u); + v0 = v0.min(v); + v1 = v1.max(v); + } + let nstr = ((v1 - v0) / thick).ceil().max(1.0); + let total = (u1 - u0) + slant.abs() * nstr + 1.0; + let mut p = Params::default(); + p.u[0] = [pr.b("flipDirection") as u32, (slant >= 0.0) as u32, 0, 0]; + p.f[0] = f4([sn, cs, v0, u0]); + p.f[1] = f4([thick, slant.abs(), nstr, total]); + p.f[2][0] = c as f32; + run(e, "ftr_line_sweep", &p, b, None, None) +} + +fn twister(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let c = completion(ctx); + if c <= 0.0 { + return Some(b); + } + let pr = ctx.params; + let (cx, cy) = b.to_px(pr.v2("center")); + let (sn, cs) = pr.f("axis").to_radians().sin_cos(); + let half = (b.img.width as f64).hypot(b.img.height as f64) * 0.5; + let back = fitted(e, ctx, &b, "backside", true, false)?; + let mut p = Params::default(); + p.u[0] = [back.is_some() as u32, pr.b("shading") as u32, 0, 0]; + p.f[0] = f4([cx, cy, sn, cs]); + p.f[1] = f4([half, c, 0.0, 0.0]); + run(e, "ftr_twister", &p, b, back.as_ref(), None) +} + +// ---------------------------------------------------------------- Radial Shadow / CC Cylinder / CC Sphere / CC Spotlight (perspective.rs) + +fn radial_shadow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let pr = ctx.params; + let color = pr.color("color"); + let soft = pr.f("softness").max(0.0) * b.scale; + let k = 1.0 + pr.f("projectionDistance").max(0.0) / 100.0; + let lp0 = pr.v2("lightSource"); + if pr.b("resizeLayer") && !ctx.adjustment { + let l = b.to_px(lp0); + let (bw, bh) = (b.img.width as f64, b.img.height as f64); + let grow = [(0.0, 0.0), (bw, 0.0), (0.0, bh), (bw, bh)].iter().map(|(cx, cy)| ((cx - l.0).hypot(cy - l.1)) * (k - 1.0)).fold(0.0, f64::max); + b.pad(e, (grow + soft * 1.5).ceil() as u32 + 1)?; + } + let l = b.to_px(lp0); + let mut p = Params::default(); + p.u[0][0] = (pr.e("render") == 1) as u32; + p.f[0] = [l.0 as f32, l.1 as f32, k as f32, pr.f("opacity") as f32 / 100.0 * color[3]]; + p.f[1] = rgb(color, pr.f("colorInfluence") as f32 / 100.0); + let mut sh = kernel(e, "ftr_rshadow", &p, &b.img, None, None); + if soft > 0.0 { + sh = gaussian_blur(e, &sh, soft * 0.5, soft * 0.5, false); + } + if !pr.b("shadowOnly") { + sh = kernel(e, "ftr_under", &Params::default(), &sh, Some(&b.img), None); + } + b.img = sh; + Some(b) +} + +fn norm3(v: [f64; 3]) -> [f64; 3] { + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); + [v[0] / l, v[1] / l, v[2] / l] +} + +/// perspective::Shading into f[3..7]; the layer rectangle into f[1]. +fn shading(ctx: &EffectCtx, b: &GBuf, p: &mut Params) { + let pr = ctx.params; + let (a, el) = (pr.f("lightDirection").to_radians(), pr.f("lightHeight").to_radians()); + let l = norm3([a.sin() * el.cos(), -a.cos() * el.cos(), el.sin()]); + let hv = norm3([l[0], l[1], l[2] + 1.0]); + p.f[1] = f4([b.offset[0], b.offset[1], ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale]); + p.f[3] = [l[0] as f32, l[1] as f32, l[2] as f32, pr.f("lightIntensity") as f32 / 100.0]; + p.f[4] = rgb(pr.color("lightColor"), pr.f("ambient") as f32 / 100.0); + p.f[5] = [pr.f("diffuse") as f32 / 100.0, pr.f("specular") as f32 / 100.0, 1.0 / (pr.f("roughness") as f32).max(0.001), 0.0]; + p.f[6] = [hv[0] as f32, hv[1] as f32, hv[2] as f32, 0.0]; +} + +fn cylinder(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let (w, h) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + let r = (pr.f("radius") / 100.0 * w / std::f64::consts::TAU).max(0.5); + let c = b.to_px(pr.v2("position")); + let mut p = Params::default(); + shading(ctx, &b, &mut p); + p.u[0][0] = pr.e("render"); + p.f[0] = f4([c.0, c.1, r, h]); + p.f[2][0] = pr.f("rotation").to_radians() as f32; + run(e, "ftr_cylinder", &p, b, None, None) +} + +/// perspective::rot_xyz (R = Rz · Ry · Rx). +fn rot_xyz(rx: f64, ry: f64, rz: f64) -> [[f64; 3]; 3] { + let (sx, cx) = rx.to_radians().sin_cos(); + let (sy, cy) = ry.to_radians().sin_cos(); + let (sz, cz) = rz.to_radians().sin_cos(); + let mx = [[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]]; + let my = [[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]]; + let mz = [[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]]; + let mul = |a: [[f64; 3]; 3], b: [[f64; 3]; 3]| { + let mut o = [[0.0; 3]; 3]; + for i in 0..3 { + for j in 0..3 { + o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum(); + } + } + o + }; + mul(mz, mul(my, mx)) +} + +fn sphere(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let r = (pr.f("radius") * b.scale).max(0.5); + let c = b.to_px(pr.v2("offset")); + let m = rot_xyz(pr.f("rotationX"), pr.f("rotationY"), pr.f("rotationZ")); + let mut p = Params::default(); + shading(ctx, &b, &mut p); + p.u[0][0] = pr.e("render"); + p.f[0] = f4([c.0, c.1, r, 0.0]); + // Rows of M: the kernel computes Mᵀ · q as q.x·row0 + q.y·row1 + q.z·row2. + for (i, row) in m.iter().enumerate() { + p.f[7 + i] = f4([row[0], row[1], row[2], 0.0]); + } + run(e, "ftr_sphere", &p, b, None, None) +} + +fn spotlight(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let (w, h) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + let from = b.to_px(pr.v2("from")); + let to = b.to_px(pr.v2("to")); + let height = (pr.f("height") / 100.0 * w.max(h)).max(1e-3); + let cone = pr.f("coneAngle").clamp(0.1, 89.9).to_radians(); + let soft = (pr.f("edgeSoftness") / 100.0).clamp(0.0, 1.0); + let axis = norm3([to.0 - from.0, to.1 - from.1, -height]); + let mut p = Params::default(); + p.u[0][0] = (pr.e("render") == 1) as u32; + p.f[0] = [from.0 as f32, from.1 as f32, height as f32, pr.f("intensity") as f32 / 100.0]; + p.f[1] = f4([axis[0], axis[1], axis[2], cone * (1.0 - soft)]); + p.f[2] = rgb(pr.color("color"), cone as f32); + run(e, "ftr_spot", &p, b, None, None) +} + +// ---------------------------------------------------------------- CC Environment / 3D Glasses (perspective2.rs) + +fn environment(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let (mut env, es, eo, esz) = match ctx.layer_param("environment", true) { + Some(o) => (e.g.upload_image(&o.buf.img)?, o.buf.scale, o.buf.offset, o.size), + None => (b.img.clone(), b.scale, b.offset, ctx.layer_size), + }; + if pr.b("filterEnvironment") { + env = gaussian_blur(e, &env, 2.0 * es, 2.0 * es, true); + } + let relief = plane(e, &b.img, 11); + let soft = 1.5 * b.scale.max(0.25); + let hmap = gaussian_blur(e, &relief, soft, soft, true); + let mut p = Params::default(); + let data = rows(e, &mut p, &env); + p.u[0][0] = pr.e("mapping"); + p.f[0] = f4([1.0 / b.scale.max(1e-6), pr.f("height") / 100.0 * 20.0, es, 0.0]); + p.f[1] = f4([eo[0], eo[1], esz[0], esz[1]]); + run(e, "ftr_env", &p, b, Some(&hmap), Some(&data)) +} + +fn glasses(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let mut l = fitted(e, ctx, &b, "leftView", true, false)?.unwrap_or_else(|| b.img.clone()); + let mut r = fitted(e, ctx, &b, "rightView", true, false)?.unwrap_or_else(|| b.img.clone()); + if pr.b("leftRightSwap") { + std::mem::swap(&mut l, &mut r); + } + let unit = if pr.e("units") == 1 { ctx.layer_size[0] / 100.0 } else { 1.0 } * b.scale; + let conv = pr.f("sceneConvergence") * unit; + let va = pr.f("verticalAlignment") * unit; + let mut shift = |img: GpuImage, dx: f64, dy: f64| { + if dx == 0.0 && dy == 0.0 { + return img; + } + let mut p = Params::default(); + p.f[0] = f4([dx, dy, 0.0, 0.0]); + kernel(e, "ftr_shift", &p, &img, None, None) + }; + let l = shift(l, conv * 0.5, 0.0); + let r = shift(r, -conv * 0.5, va); + let mut p = Params::default(); + p.u[0][0] = pr.e("view3d"); + p.f[0] = [b.img.width as f32, b.img.height as f32, (pr.f("balance") / 20.0).clamp(0.0, 1.0) as f32, 0.0]; + let out = kernel(e, "ftr_glasses", &p, &l, Some(&r), None); + Some(GBuf { img: out, ..b }) +} + +// ---------------------------------------------------------------- CC Bender / CC Blobbylize (distort3.rs) + +fn bender(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let amount = pr.f("amount") / 100.0; + if amount == 0.0 { + return Some(b); + } + let top = b.to_px(pr.v2("top")); + let base = b.to_px(pr.v2("base")); + let (ax, ay) = (top.0 - base.0, top.1 - base.1); + let len = (ax * ax + ay * ay).sqrt().max(1e-6); + let (ux, uy) = (ax / len, ay / len); + let reference = if pr.b("adjustToDistance") { len } else { ctx.layer_size[1] * b.scale }; + let mut p = Params::default(); + p.u[0][0] = pr.e("style"); + p.f[0] = f4([base.0, base.1, ux, uy]); + p.f[1] = f4([len, -uy, ux, amount * reference]); + run(e, "ftr_bender", &p, b, None, None) +} + +fn blobbylize(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let soft = pr.f("softness").max(0.0) * b.scale; + let blob = layer_or_self(e, ctx, &b, "blobLayer")?; + let raw = plane(e, &blob, pr.e("property").min(10)); + let h = gaussian_blur(e, &raw, soft, soft, true); + let lpos = b.to_px(pr.v2("lightPosition")); + let lheight = pr.f("lightHeight"); + let (d, el) = (pr.f("lightDirection").to_radians(), (lheight / 100.0).clamp(0.0, 1.0) * std::f64::consts::FRAC_PI_2); + let ldir = [d.sin() * el.cos(), -d.cos() * el.cos(), el.sin()]; + let span = (b.img.width.max(b.img.height) as f64).max(1.0); + let mut p = Params::default(); + p.f[0] = f4([(soft * 0.5).max(1.0), (pr.f("cutAway") / 100.0).clamp(0.0, 0.99), pr.f("lightIntensity") / 100.0, pr.f("ambient") / 100.0]); + p.f[1] = f4([pr.f("diffuse") / 100.0, pr.f("specular") / 100.0, (1.0 / pr.f("roughness").clamp(0.001, 1.0)).min(500.0), pr.f("metal") / 100.0]); + p.f[2] = f4([lpos.0, lpos.1, lheight / 100.0 * span, (pr.e("lightType") == 1) as u32 as f64]); + p.f[3] = f4([ldir[0], ldir[1], ldir[2], 0.0]); + p.f[4] = rgb(pr.color("lightColor"), 0.0); + run(e, "ftr_blob", &p, b, Some(&h), None) +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 9d687342..c67cb295 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -13,15 +13,18 @@ //! Advanced 3D comps rasterise on a render pipeline (`advanced3d.wgsl`: depth buffer, PBR, //! image-based light, shadow maps) and finish in compute kernels (`adv3d.wgsl`: supersampling //! resolve, motion-blur sub-samples, iris depth of field, encoding, compositing), see -//! [`Accelerator::render_3d`] and `Renderer::prepare_adv_run`. Advanced 3D layers with blend -//! modes, track mattes or Preserve Transparency still composite on the CPU (their scenes -//! render here through [`Accelerator::render_3d`]). +//! [`Accelerator::render_3d`] and `Renderer::prepare_adv_run`. Runs with layers on the 2D +//! compositing path (blend modes, track mattes, Preserve Transparency: +//! `Renderer::split_adv_run`) render each such layer alone, hide it behind the nearer main +//! scene and composite it through the 2D path here too; environment backgrounds (Classic and +//! Advanced 3D) draw their sky in a kernel (`Renderer::sky_draw`). //! //! GPU effects ([`effectcraft_effects::GPU_EFFECTS`]) run as compute kernels with the CPU //! effect's exact steps (padding, box-blur radii, parameter conversions); chains of them are //! uploaded and read back once. Each family lives in its own module with its own WGSL file -//! (`fx_color`, `fx_distort`, `fx_extra`, `fx_generate`, `fx_key`, `fx_noise`, `fx_stylize`, -//! `fx_tone`, `fx_warp`); settings a kernel cannot match fall back to the CPU +//! (`fx_color`, `fx_distort`, `fx_extra`, `fx_generate`, `fx_key`, `fx_light`, `fx_noise`, +//! `fx_stylize`, `fx_text`, `fx_time`, `fx_tone`, `fx_transition`, `fx_warp`); settings a +//! kernel cannot match fall back to the CPU //! (`catalog::gpu_supported`, or `None` from the family's `apply`). //! //! Working textures come from a pool (`context::Pool`): a frame allocates several full-frame @@ -53,9 +56,13 @@ mod fx_distort; mod fx_extra; mod fx_generate; mod fx_key; +mod fx_light; mod fx_noise; mod fx_stylize; +mod fx_text; +mod fx_time; mod fx_tone; +mod fx_transition; mod fx_warp; mod ops; mod particles; @@ -309,12 +316,20 @@ mod tests_fx_generate; #[cfg(test)] mod tests_fx_key; #[cfg(test)] +mod tests_fx_light; +#[cfg(test)] mod tests_fx_noise; #[cfg(test)] mod tests_fx_stylize; #[cfg(test)] +mod tests_fx_text; +#[cfg(test)] +mod tests_fx_time; +#[cfg(test)] mod tests_fx_tone; #[cfg(test)] +mod tests_fx_transition; +#[cfg(test)] mod tests_fx_warp; #[cfg(test)] mod tests_particles; diff --git a/crates/gpu/src/shaders/adv3d.wgsl b/crates/gpu/src/shaders/adv3d.wgsl index 35e5a1bc..1a2d3c81 100644 --- a/crates/gpu/src/shaders/adv3d.wgsl +++ b/crates/gpu/src/shaders/adv3d.wgsl @@ -255,3 +255,24 @@ fn wire(@builtin(global_invocation_id) id: vec3) { let p = table[id.x]; textureStore(out, vec2(i32(p.x), i32(p.y)), vec4(1.0)); } + +// A layer rendered on its own (color_in, finished) hidden where the run's main scene is nearer +// (`adv::draw_run`): depth = its depth, src = the main scene's colour, radius = the main +// scene's depth. Pixels it didn't draw (BIG) are never hidden, as ∞ compares on the CPU. +// P.u0 = (width, height) +@compute @workgroup_size(16, 16) +fn occlude(@builtin(global_invocation_id) id: vec3) { + let w = P.u0.x; + if (id.x >= w || id.y >= P.u0.y) { + return; + } + let i = id.y * w + id.x; + let q = vec2(i32(id.x), i32(id.y)); + var p = textureLoad(color_in, q, 0); + let z = depth[i]; + let mz = radius[i]; + if (z < BIG * 0.5 && mz < z - 0.01 * max(abs(z), 1.0)) { + p *= 1.0 - clamp(src[i].w, 0.0, 1.0); + } + textureStore(out, q, p); +} diff --git a/crates/gpu/src/shaders/fx_light.wgsl b/crates/gpu/src/shaders/fx_light.wgsl new file mode 100644 index 00000000..d957ab39 --- /dev/null +++ b/crates/gpu/src/shaders/fx_light.wgsl @@ -0,0 +1,231 @@ +// GPU effects, the CC light family: see src/fx_light.rs. CC Light Rays, CC Light Burst 2.5, +// CC Light Sweep and CC Light Wipe, each mirroring the CPU effect operation for operation. +// Coordinates are pixel centres relative to the effect's centre, so f32 keeps its precision. + +fn flt_inside(p: vec2) -> bool { + let d = out_dims(); + return p.x < d.x && p.y < d.y; +} + +fn flt_rel(p: vec2, c: vec2) -> vec2 { + return vec2(f32(p.x) + 0.5, f32(p.y) + 0.5) - c; +} + +// util::smoothstep. +fn flt_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); +} + +// util::unpremul's colour. +fn flt_unpremul(p: vec4) -> vec3 { + if (p.w > 1e-6) { + return p.xyz / p.w; + } + return vec3(0.0); +} + +// ---------------------------------------------------------------- CC Light Rays (generate2::light_rays) +// u[0] = (square, colour from source, transfer mode); f[0] = (cx, cy, k, gain); +// f[1] = (radius, outer radius + 1e-3); f[2] = colour. + +fn flt_ray_mask(q: vec2) -> f32 { + let d = abs(q); + var r = length(d); + if (P.u[0].x != 0u) { + r = max(d.x, d.y); + } + return 1.0 - flt_smoothstep(P.f[1].x, P.f[1].y, r); +} + +@compute @workgroup_size(16, 16) +fn flt_rays(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!flt_inside(p)) { + return; + } + let c = P.f[0].xy; + let rel = flt_rel(p, c); + var acc = vec4(0.0); + for (var i = 0u; i < 32u; i++) { + let s = 1.0 - P.f[0].z * f32(i) / 32.0; + let q = rel * s; + let m = flt_ray_mask(q); + if (m <= 0.0) { + continue; + } + acc += sample_bilinear(src, c.x + q.x, c.y + q.y) * m; + } + var ray = acc / 32.0 * P.f[0].w; + if (P.u[0].y == 0u) { + let l = luminance(flt_unpremul(ray)) * ray.w; + ray = vec4(P.f[2].xyz * l, ray.w); + } + let la = min(ray.w, 1.0); + let px = textureLoad(src, p, 0); + var o: vec4; + switch P.u[0].z { + case 1u: { + o = vec4(max(px.xyz, ray.xyz), max(px.w, la)); + } + case 2u: { + let r4 = vec4(ray.xyz, la); + o = px + r4 - px * r4; + } + case 3u: { + o = vec4(ray.xyz, la); + } + default: { + let a = clamp(la, 0.0, 1.0); + o = vec4(px.xyz + ray.xyz, clamp(px.w + a * (1.0 - px.w), 0.0, 1.0)); + } + } + textureStore(out, p, o); +} + +// ---------------------------------------------------------------- CC Light Burst 2.5 (generate2::light_burst) +// u[0] = (burst, set colour); f[0] = (cx, cy, ray length k, intensity); f[1] = colour. + +@compute @workgroup_size(16, 16) +fn flt_burst(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!flt_inside(p)) { + return; + } + let c = P.f[0].xy; + let rel = flt_rel(p, c); + let mode = P.u[0].x; + var acc = vec4(0.0); + var wsum = 0.0; + var best = vec4(0.0); + var best_l = -1.0; + for (var i = 0u; i < 32u; i++) { + let f = f32(i) / 32.0; + let s = 1.0 - P.f[0].z * f; + let q = rel * s; + let v = sample_bilinear(src, c.x + q.x, c.y + q.y); + if (mode == 0u) { + let l = luminance(v.xyz) + v.w * 1e-3; + if (l > best_l) { + best_l = l; + best = v; + } + } else if (mode == 1u) { + let w = 1.0 - f; + acc += v * w; + wsum += w; + } else { + acc += v; + wsum += 1.0; + } + } + var o = best; + if (mode != 0u) { + o = acc / max(wsum, 1e-6); + } + if (P.u[0].y != 0u) { + let a = max(o.w, 0.0); + let l = luminance(flt_unpremul(o)) * a; + o = vec4(P.f[1].xyz * l, a); + } + let k = P.f[0].w; + textureStore(out, p, vec4(o.xyz * k, clamp(o.w * k, 0.0, 1.0))); +} + +// ---------------------------------------------------------------- CC Light Sweep (generate2::light_sweep) +// aux = the (softened) alpha plane in x. u[0] = (shape, reception mode); f[0] = (cx, cy, nx, ny); +// f[1] = (half width, sweep, edge intensity); f[2] = light colour. + +@compute @workgroup_size(16, 16) +fn flt_sweep(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!flt_inside(p)) { + return; + } + let px = textureLoad(src, p, 0); + let rel = flt_rel(p, P.f[0].xy); + let d = abs(rel.x * P.f[0].z + rel.y * P.f[0].w); + let half = P.f[1].x; + let mode = P.u[0].y; + var band: f32; + switch P.u[0].x { + case 0u: { + band = max(1.0 - d / half, 0.0); + } + case 2u: { + band = clamp(half - d + 0.5, 0.0, 1.0); + } + default: { + band = flt_smoothstep(half, 0.0, d); + } + } + if (band <= 0.0) { + textureStore(out, p, select(px, vec4(0.0), mode == 2u)); + return; + } + let gx = tex_get_clamped(aux, p.x + 1, p.y).x - tex_get_clamped(aux, p.x - 1, p.y).x; + let gy = tex_get_clamped(aux, p.x, p.y + 1).x - tex_get_clamped(aux, p.x, p.y - 1).x; + let edge = min(sqrt(gx * gx + gy * gy) * 2.0, 1.0); + let light = band * P.f[1].y + band * edge * P.f[1].z; + let lc = P.f[2].xyz; + let a = px.w; + var o: vec4; + if (mode == 1u) { + let la = min(band * P.f[1].y, 1.0); + o = vec4(px.xyz + lc * light, min(a + la * (1.0 - a), 1.0)); + } else if (mode == 2u) { + let la = min(light * a, 1.0); + o = vec4(lc * la, la); + } else { + o = vec4(px.xyz + lc * light * a, a); + } + textureStore(out, p, o); +} + +// ---------------------------------------------------------------- CC Light Wipe (transition::light_wipe) +// u[0] = (shape, colour from source, reverse); f[0] = (cx, cy, sin, cos); +// f[1] = (edge, band, intensity × ramp); f[2] = colour. + +@compute @workgroup_size(16, 16) +fn flt_wipe(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!flt_inside(p)) { + return; + } + var px = textureLoad(src, p, 0); + let d = flt_rel(p, P.f[0].xy); + let s = P.f[0].z; + let co = P.f[0].w; + let q = vec2(d.x * co + d.y * s, -d.x * s + d.y * co); + var m: f32; + switch P.u[0].x { + case 0u: { + m = abs(q.x); + } + case 2u: { + m = max(abs(q.x), abs(q.y)); + } + default: { + m = length(q); + } + } + let edge = P.f[1].x; + let visible = select(m > edge, m < edge, P.u[0].z != 0u); + if (!visible) { + textureStore(out, p, vec4(0.0)); + return; + } + let z = (m - edge) / P.f[1].y; + let g = exp(-(z * z)) * P.f[1].z; + if (g > 0.0) { + var lc = P.f[2].xyz; + if (P.u[0].y != 0u) { + lc = flt_unpremul(px); + } + px = vec4(px.xyz + lc * g * px.w, px.w); + } + textureStore(out, p, px); +} diff --git a/crates/gpu/src/shaders/fx_text.wgsl b/crates/gpu/src/shaders/fx_text.wgsl new file mode 100644 index 00000000..5ec3232a --- /dev/null +++ b/crates/gpu/src/shaders/fx_text.wgsl @@ -0,0 +1,59 @@ +// GPU effects, Numbers and Timecode: see src/fx_text.rs. The glyph coverage is rasterised on +// the CPU (aux: fill in x, stroke ring in y, opacity applied); this kernel composites it as +// textfx::draw_plan does: the layer or transparency, Timecode's box, then fill and stroke. +// u[0] = (display options, keep the layer, box); f[0] = fill colour; f[1] = stroke colour; +// f[2] = box (x0, x1, y0, y1); f[3] = (box colour, box opacity). + +fn ftx_over(dst: vec4, s: vec4) -> vec4 { + return s + dst * (1.0 - s.w); +} + +// textfx::tint. +fn ftx_tint(c: vec4, a0: f32) -> vec4 { + let a = clamp(a0 * c.w, 0.0, 1.0); + return vec4(c.xyz * a, a); +} + +@compute @workgroup_size(16, 16) +fn ftx_text(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + let dims = out_dims(); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + var o = vec4(0.0); + if (P.u[0].y != 0u) { + o = textureLoad(src, p, 0); + } + if (P.u[0].z != 0u) { + let r = P.f[2]; + let fy = f32(p.y) + 0.5; + let cy = clamp(fy - r.z + 0.5, 0.0, 1.0) * clamp(r.w - fy + 0.5, 0.0, 1.0); + if (cy > 0.0) { + let fx = f32(p.x) + 0.5; + let cx = clamp(fx - r.x + 0.5, 0.0, 1.0) * clamp(r.y - fx + 0.5, 0.0, 1.0); + let k = cx * cy * P.f[3].w; + if (k > 0.0) { + o = ftx_over(o, vec4(P.f[3].xyz * k, k)); + } + } + } + let cov = textureLoad(aux, p, 0); + let fill = ftx_tint(P.f[0], cov.x); + let stroke = ftx_tint(P.f[1], cov.y); + switch P.u[0].x { + case 1u: { + o = ftx_over(o, stroke); + } + case 2u: { + o = ftx_over(ftx_over(o, stroke), fill); + } + case 3u: { + o = ftx_over(ftx_over(o, fill), stroke); + } + default: { + o = ftx_over(o, fill); + } + } + textureStore(out, p, o); +} diff --git a/crates/gpu/src/shaders/fx_time.wgsl b/crates/gpu/src/shaders/fx_time.wgsl new file mode 100644 index 00000000..c65dccae --- /dev/null +++ b/crates/gpu/src/shaders/fx_time.wgsl @@ -0,0 +1,176 @@ +// GPU effects, time: see src/fx_time.rs. The frames come from the effect host (fetched and +// placed on one grid on the CPU, as Echo's); these kernels combine them as the CPU effects do. + +fn ftm_inside(p: vec2) -> bool { + let d = out_dims(); + return p.x < d.x && p.y < d.y; +} + +// util::unpremul: (straight colour, alpha). +fn ftm_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)); +} + +// Weighted sum, one frame per pass (time_fx::average): src + aux × f[0].x. +@compute @workgroup_size(16, 16) +fn ftm_acc(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftm_inside(p)) { + return; + } + textureStore(out, p, textureLoad(src, p, 0) + textureLoad(aux, p, 0) * P.f[0].x); +} + +// Cross-fade (raster::flow::mix): src + (aux − src) × f[0].x. +@compute @workgroup_size(16, 16) +fn ftm_mix(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftm_inside(p)) { + return; + } + let a = textureLoad(src, p, 0); + textureStore(out, p, a + (textureLoad(aux, p, 0) - a) * P.f[0].x); +} + +// ---------------------------------------------------------------- Time Difference (time_fx::time_difference) +// src = the layer now, aux = the target. u[0] = (absolute difference, alpha channel); +// f[0].x = contrast gain. + +@compute @workgroup_size(16, 16) +fn ftm_difference(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftm_inside(p)) { + return; + } + let cu = ftm_unpremul(textureLoad(src, p, 0)); + let tu = ftm_unpremul(textureLoad(aux, p, 0)); + let c = cu.xyz; + let d = tu.xyz; + let ca = cu.w; + let da = tu.w; + let gain = P.f[0].x; + let diff = c - d; + var rgb = clamp(0.5 + diff * 0.5 * gain, vec3(0.0), vec3(1.0)); + if (P.u[0].x != 0u) { + rgb = clamp(abs(diff) * gain, vec3(0.0), vec3(1.0)); + } + let mx = max(max(rgb.x, rgb.y), rgb.z); + let light = (mx + min(min(rgb.x, rgb.y), rgb.z)) * 0.5; + var a: f32; + switch P.u[0].y { + case 1u: { + a = da; + } + case 2u: { + a = (ca + da) * 0.5; + } + case 3u: { + a = max(ca, da); + } + case 4u: { + a = 1.0; + } + case 5u: { + a = light; + } + case 6u: { + a = mx; + } + case 7u: { + a = abs(ca - da) * gain; + } + case 8u: { + a = clamp(abs(ca - da) * gain, 0.0, 1.0); + rgb = vec3(1.0); + } + default: { + a = ca; + } + } + a = clamp(a, 0.0, 1.0); + textureStore(out, p, vec4(rgb * a, a)); +} + +// ---------------------------------------------------------------- Time Displacement (time_fx::time_displacement) +// One pass per sampled time: pixels whose frame index (data, u32 per pixel, row length u[0].y) +// is u[0].x take frame aux at (x + dx, y + dy) (u[0].zw, i32), the others keep src. + +@compute @workgroup_size(16, 16) +fn ftm_displace(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftm_inside(p)) { + return; + } + let k = bitcast(data[u32(p.y) * P.u[0].y + u32(p.x)]); + if (k == P.u[0].x) { + textureStore(out, p, tex_get(aux, p.x + bitcast(P.u[0].z), p.y + bitcast(P.u[0].w))); + } else { + textureStore(out, p, textureLoad(src, p, 0)); + } +} + +// ---------------------------------------------------------------- Timewarp Pixel Motion (time_fx::timewarp_interpolate) +// src = frame a, aux = frame b (both source-cropped); data = the motion vectors (x, y pairs). +// u[0] = (columns, rows, extreme filtering, build from one image); f[0] = (vector spacing, +// fraction w, error threshold). + +// raster::flow::Flow::at. +fn ftm_flow_at(x: f32, y: f32) -> vec2 { + let cols = P.u[0].x; + let rows = P.u[0].y; + if (cols == 0u || rows == 0u) { + return vec2(0.0); + } + let b = P.f[0].x; + let fx = clamp(x / b - 0.5, 0.0, f32(cols - 1u)); + let fy = clamp(y / b - 0.5, 0.0, f32(rows - 1u)); + let x0 = u32(floor(fx)); + let y0 = u32(floor(fy)); + let x1 = min(x0 + 1u, cols - 1u); + let y1 = min(y0 + 1u, rows - 1u); + let tx = fx - f32(x0); + let ty = fy - f32(y0); + let g00 = vec2(data[(y0 * cols + x0) * 2u], data[(y0 * cols + x0) * 2u + 1u]); + let g10 = vec2(data[(y0 * cols + x1) * 2u], data[(y0 * cols + x1) * 2u + 1u]); + let g01 = vec2(data[(y1 * cols + x0) * 2u], data[(y1 * cols + x0) * 2u + 1u]); + let g11 = vec2(data[(y1 * cols + x1) * 2u], data[(y1 * cols + x1) * 2u + 1u]); + let top = g00 + (g10 - g00) * tx; + let bot = g01 + (g11 - g01) * tx; + return top + (bot - top) * ty; +} + +fn ftm_sample(t: texture_2d, x: f32, y: f32) -> vec4 { + if (P.u[0].z != 0u) { + return sample_bicubic(t, x, y); + } + return sample_bilinear_clamped(t, x, y); +} + +@compute @workgroup_size(16, 16) +fn ftm_motion(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftm_inside(p)) { + return; + } + let cx = f32(p.x) + 0.5; + let cy = f32(p.y) + 0.5; + let f = ftm_flow_at(cx, cy); + let w = P.f[0].y; + var pa = ftm_sample(src, cx - w * f.x, cy - w * f.y); + var pb = ftm_sample(aux, cx + (1.0 - w) * f.x, cy + (1.0 - w) * f.y); + if (P.u[0].w != 0u) { + textureStore(out, p, select(pb, pa, w < 0.5)); + return; + } + let d = abs(pa - pb); + let err = max(max(d.x, d.y), max(d.z, d.w)); + let thr = P.f[0].z; + if (thr > 0.0 && err > thr && thr < 1.0) { + pa = textureLoad(src, p, 0); + pb = textureLoad(aux, p, 0); + } + textureStore(out, p, pa + (pb - pa) * w); +} diff --git a/crates/gpu/src/shaders/fx_transition.wgsl b/crates/gpu/src/shaders/fx_transition.wgsl new file mode 100644 index 00000000..2b9a0b9f --- /dev/null +++ b/crates/gpu/src/shaders/fx_transition.wgsl @@ -0,0 +1,883 @@ +// GPU effects, transitions and perspective: see src/fx_transition.rs. Block Dissolve, the CC +// transitions (Glass Wipe, Grid Wipe, Image Wipe, Jaws, Line Sweep, Radial ScaleWipe, Scale +// Wipe, Twister, WarpoMatic), Radial Shadow, CC Bender, CC Blobbylize, CC Cylinder, CC Sphere, +// CC Spotlight, CC Environment and 3D Glasses, each mirroring the CPU effect operation for +// operation. Coordinates are pixel centres relative to the effect's centre where it has one. + +fn ftr_inside(p: vec2) -> bool { + let d = out_dims(); + return p.x < d.x && p.y < d.y; +} + +fn ftr_centre(p: vec2) -> vec2 { + return vec2(f32(p.x) + 0.5, f32(p.y) + 0.5); +} + +// util::smoothstep. +fn ftr_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); +} + +// util::unpremul's colour. +fn ftr_unpremul(p: vec4) -> vec3 { + if (p.w > 1e-6) { + return p.xyz / p.w; + } + return vec3(0.0); +} + +// The third image, copied into `data` as RGBA f32 rows: P.u[3] = (row stride, width, height). +fn ftr_row(x: i32, y: i32) -> vec4 { + if (x < 0 || y < 0 || x >= i32(P.u[3].y) || y >= i32(P.u[3].z)) { + return vec4(0.0); + } + let i = (u32(y) * P.u[3].x + u32(x)) * 4u; + return vec4(data[i], data[i + 1u], data[i + 2u], data[i + 3u]); +} + +fn ftr_row_clamped(x: i32, y: i32) -> vec4 { + return ftr_row(clamp(x, 0, i32(P.u[3].y) - 1), clamp(y, 0, i32(P.u[3].z) - 1)); +} + +// Image::sample_bilinear on the third image. +fn ftr_row_bilinear(x: f32, y: f32) -> vec4 { + let fx = x - 0.5; + let fy = y - 0.5; + let x0 = floor(fx); + let y0 = floor(fy); + let tx = fx - x0; + let ty = fy - y0; + let xi = i32(x0); + let yi = i32(y0); + let a = ftr_row(xi, yi); + let b = ftr_row(xi + 1, yi); + let c = ftr_row(xi, yi + 1); + let d = ftr_row(xi + 1, yi + 1); + let top = a + (b - a) * tx; + let bot = c + (d - c) * tx; + return top + (bot - top) * ty; +} + +// Image::sample_bilinear_clamped on the third image. +fn ftr_row_bilinear_clamped(x: f32, y: f32) -> vec4 { + let fx = x - 0.5; + let fy = y - 0.5; + let x0 = floor(fx); + let y0 = floor(fy); + let tx = fx - x0; + let ty = fy - y0; + let xi = i32(x0); + let yi = i32(y0); + let a = ftr_row_clamped(xi, yi); + let b = ftr_row_clamped(xi + 1, yi); + let c = ftr_row_clamped(xi, yi + 1); + let d = ftr_row_clamped(xi + 1, yi + 1); + let top = a + (b - a) * tx; + let bot = c + (d - c) * tx; + return top + (bot - top) * ty; +} + +// Central differences of the aux plane (x), edges clamped (transition2::grad). +fn ftr_grad(p: vec2) -> vec2 { + let gx = (tex_get_clamped(aux, p.x + 1, p.y).x - tex_get_clamped(aux, p.x - 1, p.y).x) * 0.5; + let gy = (tex_get_clamped(aux, p.x, p.y + 1).x - tex_get_clamped(aux, p.x, p.y - 1).x) * 0.5; + return vec2(gx, gy); +} + +// transition2::wipe_t. +fn ftr_wipe_t(g: f32, c: f32, w0: f32) -> f32 { + let w = max(w0, 1e-3); + let edge = c * (1.0 + 2.0 * w) - w; + return 1.0 - ftr_smoothstep(edge - w, edge + w, g); +} + +// ---------------------------------------------------------------- planes + +// A plane (x) from the source: u[0].x = util::Src index (Red, Green, Blue, Alpha, Luminance, +// Hue, Lightness, Saturation, Full, Half, Off) times alpha except for Alpha itself +// (transition2::prop_plane, CC Blobbylize); 11 = CC Environment's relief. +@compute @workgroup_size(16, 16) +fn ftr_plane(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let px = textureLoad(src, p, 0); + let c = ftr_unpremul(px); + let a = max(px.w, 0.0); + var v: f32; + switch P.u[0].x { + case 0u: { + v = c.x; + } + case 1u: { + v = c.y; + } + case 2u: { + v = c.z; + } + case 3u: { + v = a; + } + case 4u: { + v = luminance(c); + } + case 5u: { + v = rgb_to_hsl(c).x; + } + case 6u: { + v = rgb_to_hsl(c).z; + } + case 7u: { + v = rgb_to_hsl(c).y; + } + case 8u: { + v = 1.0; + } + case 9u: { + v = 0.5; + } + case 11u: { + v = luminance(c) * 0.5 + 0.5; + } + default: { + v = 0.0; + } + } + if (P.u[0].x != 3u) { + v *= a; + } + textureStore(out, p, vec4(v, 0.0, 0.0, 0.0)); +} + +// src × aux.x (Image::mul_mask). +@compute @workgroup_size(16, 16) +fn ftr_mul(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + textureStore(out, p, textureLoad(src, p, 0) * textureLoad(aux, p, 0).x); +} + +// aux + src × (1 − aux.a): src composited under aux. +@compute @workgroup_size(16, 16) +fn ftr_under(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let o = textureLoad(aux, p, 0); + textureStore(out, p, o + textureLoad(src, p, 0) * (1.0 - o.w)); +} + +// ---------------------------------------------------------------- Block Dissolve (transition::block_dissolve) +// The kept-block mask (x). u[0] = (sub-samples per axis, seed, column table length); +// f[0].x = completion; data = block columns (width × n, u32) then block rows (height × n). + +@compute @workgroup_size(16, 16) +fn ftr_blocks(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let n = P.u[0].x; + var hits = 0u; + for (var j = 0u; j < n; j++) { + let by = bitcast(data[P.u[0].z + u32(p.y) * n + j]); + for (var i = 0u; i < n; i++) { + let bx = bitcast(data[u32(p.x) * n + i]); + if (hash_noise(bx, by, P.u[0].y) >= P.f[0].x) { + hits += 1u; + } + } + } + textureStore(out, p, vec4(f32(hits) / f32(n * n), 0.0, 0.0, 0.0)); +} + +// ---------------------------------------------------------------- CC Grid Wipe (transition::grid_wipe) +// u[0] = (shape, reverse); f[0] = (cx, cy, sin, cos); f[1] = (cell size, max distance, +// completion, border). + +fn ftr_metric(shape: u32, d: vec2) -> f32 { + switch shape { + case 0u: { + return abs(d.x); + } + case 2u: { + return max(abs(d.x), abs(d.y)); + } + default: { + return length(d); + } + } +} + +@compute @workgroup_size(16, 16) +fn ftr_grid(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let px = textureLoad(src, p, 0); + let d = ftr_centre(p) - P.f[0].xy; + let s = P.f[0].z; + let co = P.f[0].w; + let q = vec2(d.x * co + d.y * s, -d.x * s + d.y * co); + let cs = P.f[1].x; + let cc = (floor(q / cs) + 0.5) * cs; + var o = clamp(ftr_metric(P.u[0].x, cc) / P.f[1].y, 0.0, 1.0); + if (P.u[0].y != 0u) { + o = 1.0 - o; + } + let spread = 0.25; + let pr = clamp((P.f[1].z * (1.0 + spread) - o) / spread, 0.0, 1.0); + if (pr <= 0.0) { + textureStore(out, p, px); + return; + } + let half = cs * 0.5 * (1.0 - pr); + let m = max(abs(q.x - cc.x), abs(q.y - cc.y)); + var k = select(0.0, 1.0, m < half); + if (P.f[1].w > 0.0) { + k = clamp((half - m) / P.f[1].w + 0.5, 0.0, 1.0); + } + textureStore(out, p, px * k); +} + +// ---------------------------------------------------------------- CC Radial ScaleWipe / CC Scale Wipe (transition.rs) +// Radial: u[0].x = reverse; f[0] = (cx, cy, max distance, completion). + +@compute @workgroup_size(16, 16) +fn ftr_radial_scale(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let c = P.f[0].xy; + let maxd = P.f[0].z; + let done = P.f[0].w; + let d = ftr_centre(p) - c; + let r = length(d); + var o = vec4(0.0); + if (P.u[0].x != 0u) { + let lim = (1.0 - done) * maxd; + if (!(r >= lim || lim <= 1e-9)) { + let k = maxd / lim; + o = sample_bilinear(src, c.x + d.x * k, c.y + d.y * k); + } + } else { + let hole = done * maxd; + if (!(r <= hole || maxd - hole <= 1e-9)) { + let rs = (r - hole) * maxd / (maxd - hole); + o = sample_bilinear(src, c.x + d.x / r * rs, c.y + d.y / r * rs); + } + } + textureStore(out, p, o); +} + +// Scale Wipe: f[0] = (cx, cy, direction x, direction y); f[1].x = stretch. +@compute @workgroup_size(16, 16) +fn ftr_scale(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let f = ftr_centre(p); + let dir = P.f[0].zw; + let u = dot(f - P.f[0].xy, dir); + var q = f; + if (u > 0.0) { + let back = u - u / (1.0 + P.f[1].x); + q = f - dir * back; + } + textureStore(out, p, sample_bilinear_clamped(src, q.x, q.y)); +} + +// ---------------------------------------------------------------- CC Glass Wipe (transition2::glass_wipe) +// aux = the gradient plane (x); data = the layer to reveal (rows). u[0].x = has reveal; +// f[0] = (completion, band half-width, displacement). + +@compute @workgroup_size(16, 16) +fn ftr_glass(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let t = ftr_wipe_t(textureLoad(aux, p, 0).x, P.f[0].x, P.f[0].y); + let band = 4.0 * t * (1.0 - t); + let g = ftr_grad(p); + let f = ftr_centre(p); + let o = g * P.f[0].z * band * 10.0; + var a = textureLoad(src, p, 0); + var r = vec4(0.0); + if (band > 1e-4) { + a = sample_bilinear(src, f.x + o.x, f.y + o.y); + if (P.u[0].x != 0u) { + r = ftr_row_bilinear(f.x - o.x, f.y - o.y); + } + } else if (P.u[0].x != 0u) { + r = ftr_row(p.x, p.y); + } + textureStore(out, p, a + (r - a) * t); +} + +// ---------------------------------------------------------------- CC Image Wipe (transition2::image_wipe) +// aux = the gradient plane (x). u[0].x = inverse; f[0] = (completion, band half-width). + +@compute @workgroup_size(16, 16) +fn ftr_image_wipe(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + var g = textureLoad(aux, p, 0).x; + if (P.u[0].x != 0u) { + g = 1.0 - g; + } + let k = 1.0 - ftr_wipe_t(g, P.f[0].x, P.f[0].y); + textureStore(out, p, textureLoad(src, p, 0) * k); +} + +// ---------------------------------------------------------------- CC Jaws (transition2::jaws) +// u[0].x = shape; f[0] = (cx, cy, sin, cos); f[1] = (tooth width, amplitude, travel). + +@compute @workgroup_size(16, 16) +fn ftr_jaws(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let c = P.f[0].xy; + let sn = P.f[0].z; + let cs = P.f[0].w; + let width = P.f[1].x; + let amp = P.f[1].y; + let dd = P.f[1].z; + let d = ftr_centre(p) - c; + let u = d.x * cs + d.y * sn; + let v = -d.x * sn + d.y * cs; + let ph = fract_euclid(u / width); + var f: f32; + switch P.u[0].x { + case 0u: { + f = amp * (1.0 - 4.0 * abs(ph - 0.5)); + } + case 1u: { + f = amp * clamp((1.0 - 4.0 * abs(ph - 0.5)) * 2.0, -1.0, 1.0); + } + case 2u: { + f = select(-amp, amp, ph < 0.5); + } + default: { + f = amp * sin(ph * 6.283185307179586); + } + } + var vs = 0.0; + var hit = true; + if (v + dd < f) { + vs = v + dd; + } else if (v - dd >= f) { + vs = v - dd; + } else { + hit = false; + } + var o = vec4(0.0); + if (hit) { + o = sample_bilinear(src, u * cs - vs * sn + c.x, u * sn + vs * cs + c.y); + } + textureStore(out, p, o); +} + +// ---------------------------------------------------------------- CC Line Sweep (transition2::line_sweep) +// u[0] = (flip, slant ≥ 0); f[0] = (sin, cos, v0, u0); f[1] = (thickness, |slant|, strips, +// total travel); f[2].x = completion. + +@compute @workgroup_size(16, 16) +fn ftr_line_sweep(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let f = ftr_centre(p); + let sn = P.f[0].x; + let cs = P.f[0].y; + let u = f.x * cs + f.y * sn; + let v = -f.x * sn + f.y * cs; + let nstr = P.f[1].z; + var k = floor((v - P.f[0].z) / P.f[1].x); + if (P.u[0].x != 0u) { + k = nstr - 1.0 - k; + } + let stagger = select(nstr - 1.0 - k, k, P.u[0].y != 0u); + let front = P.f[0].w + P.f[2].x * P.f[1].w - P.f[1].y * stagger; + textureStore(out, p, select(textureLoad(src, p, 0), vec4(0.0), u < front)); +} + +// ---------------------------------------------------------------- CC Twister (transition2::twister) +// aux = the backside layer (u[0].x = has one); u[0].y = shading; f[0] = (cx, cy, sin, cos); +// f[1] = (half diagonal, completion). + +@compute @workgroup_size(16, 16) +fn ftr_twister(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let c = P.f[0].xy; + let sn = P.f[0].z; + let cs = P.f[0].w; + let d = ftr_centre(p) - c; + let u = d.x * cs + d.y * sn; + let v = -d.x * sn + d.y * cs; + let s01 = clamp((u / P.f[1].x) * 0.5 + 0.5, 0.0, 1.0); + let th = 3.141592653589793 * clamp(P.f[1].y * 2.0 - s01, 0.0, 1.0); + let k = cos(th); + if (abs(k) < 1e-4) { + textureStore(out, p, vec4(0.0)); + return; + } + let vs = v / k; + var o: vec4; + if (k > 0.0 || P.u[0].x == 0u) { + o = sample_bilinear(src, u * cs - vs * sn + c.x, u * sn + vs * cs + c.y); + } else { + o = sample_bilinear(aux, u * cs + vs * sn + c.x, u * sn - vs * cs + c.y); + } + if (P.u[0].y != 0u) { + let l = 0.4 + 0.6 * abs(k); + o = vec4(o.xyz * l, o.w); + } + textureStore(out, p, o); +} + +// ---------------------------------------------------------------- CC WarpoMatic (transition2::warpomatic) + +// Reactor "Contrast Differences": src = the plane, aux = its wide mean. +@compute @workgroup_size(16, 16) +fn ftr_react_contrast(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let a = textureLoad(src, p, 0).x; + let b = textureLoad(aux, p, 0).x; + textureStore(out, p, vec4(min(abs(a - b) * 4.0, 1.0), 0.0, 0.0, 0.0)); +} + +// Reactor "Local Differences": aux = the plane; its gradient magnitude. +@compute @workgroup_size(16, 16) +fn ftr_react_local(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let g = ftr_grad(p); + textureStore(out, p, vec4(min(sqrt(g.x * g.x + g.y * g.y) * 8.0, 1.0), 0.0, 0.0, 0.0)); +} + +// aux = the reactor plane (x); data = the layer to reveal (rows). u[0] = (warp direction, +// has reveal); f[0] = (completion, blend span, warp amount). +@compute @workgroup_size(16, 16) +fn ftr_warpo(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let q = ftr_wipe_t(textureLoad(aux, p, 0).x, P.f[0].x, P.f[0].y); + let g = ftr_grad(p); + let f = ftr_centre(p); + let amt = P.f[0].z; + let o = g * amt * q; + var i = g * amt * (1.0 - q); + if (P.u[0].x == 1u) { + i = -i; + } else if (P.u[0].x == 2u) { + i = vec2(-i.y, i.x); + } + var a = vec4(0.0); + if (q < 1.0) { + a = sample_bilinear(src, f.x + o.x, f.y + o.y); + } + var inc = vec4(0.0); + if (P.u[0].y != 0u && q > 0.0) { + inc = ftr_row_bilinear(f.x + i.x, f.y + i.y); + } + textureStore(out, p, a + (inc - a) * q); +} + +// ---------------------------------------------------------------- Radial Shadow (perspective::radial_shadow) +// The shadow before its blur. u[0].x = glass edge; f[0] = (light x, y, projection k, +// opacity × colour alpha); f[1] = (colour, colour influence). + +@compute @workgroup_size(16, 16) +fn ftr_rshadow(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let l = P.f[0].xy; + let q = ftr_centre(p); + let s = sample_bilinear(src, l.x + (q.x - l.x) / P.f[0].z, l.y + (q.y - l.y) / P.f[0].z); + let sc = ftr_unpremul(s); + let sa = max(s.w, 0.0); + var a = sa; + if (P.u[0].x != 0u) { + a = clamp(sa * (1.0 - sa) * 4.0, 0.0, 1.0); + } + a *= P.f[0].w; + let col = P.f[1].xyz; + let c = col + (sc - col) * P.f[1].w; + textureStore(out, p, vec4(c * a, a)); +} + +// ---------------------------------------------------------------- CC Bender (distort3::cc_bender) +// u[0].x = style; f[0] = (base x, y, axis x, y); f[1] = (axis length, normal x, y, amount × +// reference). + +@compute @workgroup_size(16, 16) +fn ftr_bender(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let f = ftr_centre(p); + let r = f - P.f[0].xy; + let t = clamp(dot(r, P.f[0].zw) / P.f[1].x, 0.0, 1.0); + let pi = 3.141592653589793; + var b: f32; + switch P.u[0].x { + case 1u: { + b = 0.5 * sin(pi * t) * t; + } + case 2u: { + b = 0.5 * t; + } + case 3u: { + b = 0.25 * (1.0 - cos(pi * t)) - 0.1 * sin(2.0 * pi * t); + } + default: { + b = 0.5 * t * t; + } + } + let d = P.f[1].w * b; + textureStore(out, p, sample_bilinear(src, f.x - P.f[1].y * d, f.y - P.f[1].z * d)); +} + +// ---------------------------------------------------------------- CC Blobbylize (distort3::cc_blobbylize) +// aux = the blurred blob plane (x). f[0] = (depth, cut away, intensity, ambient); +// f[1] = (diffuse, specular, shininess, metal); f[2] = (light x, y, point light height, +// point light); f[3] = distant light direction; f[4] = light colour. + +@compute @workgroup_size(16, 16) +fn ftr_blob(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let depth = P.f[0].x; + let cut = P.f[0].y; + let hv = textureLoad(aux, p, 0).x; + let g = ftr_grad(p); + let f = ftr_centre(p); + let s = sample_bilinear(src, f.x - g.x * depth * 2.0, f.y - g.y * depth * 2.0); + let c = ftr_unpremul(s); + let a = max(s.w, 0.0); + let m = clamp((hv - cut) / max((1.0 - cut) * 0.5, 1e-3), 0.0, 1.0); + let alpha = max(a, s.w) * m; + if (alpha <= 0.0) { + textureStore(out, p, vec4(0.0)); + return; + } + let n = normalize(vec3(-g.x * depth, -g.y * depth, 1.0)); + var l = P.f[3].xyz; + if (P.f[2].w != 0.0) { + l = normalize(vec3(P.f[2].x - f32(p.x), P.f[2].y - f32(p.y), P.f[2].z)); + } + let intensity = P.f[0].z; + let ndl = max(dot(n, l), 0.0); + let h3 = normalize(vec3(l.x, l.y, l.z + 1.0)); + let sp = powz(max(dot(n, h3), 0.0), P.f[1].z) * P.f[1].y; + let lc = P.f[4].xyz; + let metal = P.f[1].w; + let lit = (P.f[0].w + P.f[1].x * ndl * intensity) * lc; + let spec_col = (1.0 - metal) * lc + metal * c * lc; + let o = c * lit + (sp * intensity) * spec_col; + textureStore(out, p, vec4(o * alpha, alpha)); +} + +// 0.5 + atan2(x, z) / 2π, kept on the side of the ±π seam that the sign of `x` puts it on (a +// GPU's approximate atan2 can land just across it, which wraps to the opposite edge of the +// texture). x = ±0 takes the CPU's −π (its zero is negative there). +fn ftr_lon_u(x: f32, z: f32) -> f32 { + let u = 0.5 + atan2(x, z) / 6.283185307179586; + if (x <= 0.0) { + return clamp(u, 0.0, 0.5); + } + return clamp(u, 0.5, 0.99999994); +} + +// ---------------------------------------------------------------- CC Cylinder / CC Sphere (perspective.rs) +// Shading (perspective::Shading): f[3] = (light direction, intensity); f[4] = (light colour, +// ambient); f[5] = (diffuse, specular, shininess); f[6] = half vector. The layer rectangle in +// f[1] = (x0, y0, width, height). + +fn ftr_shade(px: vec4, n: vec3) -> vec4 { + let c = ftr_unpremul(px); + let a = max(px.w, 0.0); + if (a <= 0.0) { + return vec4(0.0); + } + let nl = max(dot(n, P.f[3].xyz), 0.0); + var spec = 0.0; + if (nl > 0.0) { + spec = powz(max(dot(n, P.f[6].xyz), 0.0), P.f[5].z) * P.f[5].y; + } + let lc = P.f[4].xyz * P.f[3].w; + let o = c * (P.f[4].w + P.f[5].x * nl * lc) + spec * lc; + return vec4(o * a, a); +} + +// perspective::texel: the layer as a texture at (u, v), wrapping horizontally. +fn ftr_texel(u0: f32, v: f32) -> vec4 { + let u = fract_euclid(u0); + if (v < 0.0 || v > 1.0) { + return vec4(0.0); + } + let r = P.f[1]; + return sample_bilinear_clamped(src, r.x + u * r.z, r.y + v * r.w); +} + +fn ftr_over(top: vec4, bottom: vec4) -> vec4 { + return top + bottom * (1.0 - top.w); +} + +fn ftr_faces(mode: u32, front: vec4, back: vec4) -> vec4 { + if (mode == 1u) { + return front; + } + if (mode == 2u) { + return back; + } + return ftr_over(front, back); +} + +// Cylinder: u[0].x = render; f[0] = (cx, cy, radius, layer height); f[2].x = rotation. +@compute @workgroup_size(16, 16) +fn ftr_cylinder(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let d = ftr_centre(p) - P.f[0].xy; + let v = d.y / P.f[0].w + 0.5; + let s = d.x / P.f[0].z; + if (abs(s) >= 1.0) { + textureStore(out, p, vec4(0.0)); + return; + } + let tau = 6.283185307179586; + let th = asin(s); + let cz = cos(th); + let spin = P.f[2].x; + let front = ftr_shade(ftr_texel((th + spin) / tau + 0.5, v), vec3(s, 0.0, cz)); + let back = ftr_shade(ftr_texel((3.141592653589793 - th + spin) / tau + 0.5, v), vec3(-s, 0.0, cz)); + textureStore(out, p, ftr_faces(P.u[0].x, front, back)); +} + +// Sphere: u[0].x = render; f[0] = (cx, cy, radius); f[7..10] = the rotation matrix rows. +fn ftr_sphere_tex(q: vec3) -> vec4 { + let m0 = P.f[7].xyz; + let m1 = P.f[8].xyz; + let m2 = P.f[9].xyz; + let t = m0 * q.x + m1 * q.y + m2 * q.z; + let lat = asin(clamp(t.y, -1.0, 1.0)); + return ftr_texel(ftr_lon_u(t.x, t.z), lat / 3.141592653589793 + 0.5); +} + +@compute @workgroup_size(16, 16) +fn ftr_sphere(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let sxy = (ftr_centre(p) - P.f[0].xy) / P.f[0].z; + let d2 = sxy.x * sxy.x + sxy.y * sxy.y; + if (d2 >= 1.0) { + textureStore(out, p, vec4(0.0)); + return; + } + let z = sqrt(1.0 - d2); + let front = ftr_shade(ftr_sphere_tex(vec3(sxy, z)), vec3(sxy, z)); + let back = ftr_shade(ftr_sphere_tex(vec3(sxy, -z)), vec3(-sxy, z)); + textureStore(out, p, ftr_faces(P.u[0].x, front, back)); +} + +// ---------------------------------------------------------------- CC Spotlight (perspective::spotlight) +// u[0].x = light only; f[0] = (from x, y, height, intensity); f[1] = (axis, inner angle); +// f[2] = (colour, cone angle). + +@compute @workgroup_size(16, 16) +fn ftr_spot(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let d = ftr_centre(p) - P.f[0].xy; + let v = normalize(vec3(d, -P.f[0].z)); + let axis = P.f[1].xyz; + // acos(v · axis), well conditioned near the axis. + let ang = atan2(length(cross(v, axis)), dot(v, axis)); + let m = (1.0 - ftr_smoothstep(P.f[1].w, P.f[2].w, ang)) * P.f[0].w; + let px = textureLoad(src, p, 0); + if (P.u[0].x != 0u) { + let a = clamp(m, 0.0, 1.0); + textureStore(out, p, vec4(P.f[2].xyz * a, a)); + } else { + textureStore(out, p, vec4(px.xyz * (m * P.f[2].xyz), px.w)); + } +} + +// ---------------------------------------------------------------- CC Environment (perspective2::cc_environment) +// aux = the relief height map (x); data = the environment image (rows). u[0].x = mapping; +// f[0] = (1 / buffer scale, height, environment scale); f[1] = (environment offset, size). + +fn ftr_env_uv(mapping: u32, r: vec3) -> vec2 { + if (mapping == 1u) { + let m = 2.0 * max(sqrt(r.x * r.x + r.y * r.y + (r.z + 1.0) * (r.z + 1.0)), 1e-9); + return vec2(0.5 + r.x / m, 0.5 + r.y / m); + } + if (mapping == 2u) { + let ax = abs(r.x); + let ay = abs(r.y); + let az = abs(r.z); + var cr: vec4; + if (ax >= ay && ax >= az) { + if (r.x > 0.0) { + cr = vec4(2.0, 1.0, -r.z / ax, r.y / ax); + } else { + cr = vec4(0.0, 1.0, r.z / ax, r.y / ax); + } + } else if (ay >= az) { + if (r.y < 0.0) { + cr = vec4(1.0, 0.0, r.x / ay, -r.z / ay); + } else { + cr = vec4(1.0, 2.0, r.x / ay, r.z / ay); + } + } else if (r.z > 0.0) { + cr = vec4(1.0, 1.0, r.x / az, r.y / az); + } else { + cr = vec4(1.0, 3.0, r.x / az, -r.y / az); + } + return vec2((cr.x + (cr.z + 1.0) * 0.5) / 3.0, (cr.y + (cr.w + 1.0) * 0.5) / 4.0); + } + let lat = asin(clamp(r.y, -1.0, 1.0)); + return vec2(ftr_lon_u(r.x, r.z), 0.5 + lat / 3.141592653589793); +} + +@compute @workgroup_size(16, 16) +fn ftr_env(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let a = textureLoad(src, p, 0).w; + if (a <= 0.0) { + textureStore(out, p, vec4(0.0)); + return; + } + let g = ftr_grad(p) * P.f[0].x; + let height = P.f[0].y; + let n = normalize(vec3(-g.x * height, -g.y * height, 1.0)); + let d = -n.z; + let r = vec3(-2.0 * d * n.x, -2.0 * d * n.y, -(-1.0 - 2.0 * d * n.z)); + let uv = ftr_env_uv(P.u[0].x, r); + let es = P.f[0].z; + let ex = fract_euclid(uv.x) * P.f[1].z * es + P.f[1].x; + let ey = clamp(uv.y, 0.0, 1.0) * P.f[1].w * es + P.f[1].y; + let env = ftr_row_bilinear_clamped(ex, ey); + let ec = env.xyz * (1.0 / max(env.w, 1e-3)); + textureStore(out, p, vec4(ec * a, a)); +} + +// ---------------------------------------------------------------- 3D Glasses (perspective2::glasses_3d) + +// Translate by f[0].xy (perspective2::shift). +@compute @workgroup_size(16, 16) +fn ftr_shift(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let f = ftr_centre(p) - P.f[0].xy; + textureStore(out, p, sample_bilinear(src, f.x, f.y)); +} + +// src = left view, aux = right view. u[0].x = 3D view; f[0] = (width, height, balance). +@compute @workgroup_size(16, 16) +fn ftr_glasses(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftr_inside(p)) { + return; + } + let f = ftr_centre(p); + let w = P.f[0].x; + let h = P.f[0].y; + let k = P.f[0].z; + let view = P.u[0].x; + var o: vec4; + if (view == 0u) { + if (f.x < w * 0.5) { + o = sample_bilinear(src, f.x * 2.0, f.y); + } else { + o = sample_bilinear(aux, (f.x - w * 0.5) * 2.0, f.y); + } + } else if (view == 1u) { + if (f.y < h * 0.5) { + o = sample_bilinear(src, f.x, f.y * 2.0); + } else { + o = sample_bilinear(aux, f.x, (f.y - h * 0.5) * 2.0); + } + } else if (view == 2u) { + if (p.y % 2 == 0) { + o = textureLoad(src, p, 0); + } else { + o = textureLoad(aux, p, 0); + } + } else { + let lp = textureLoad(src, p, 0); + let rp = textureLoad(aux, p, 0); + let lc = ftr_unpremul(lp); + let rc = ftr_unpremul(rp); + let a = max(max(lp.w, 0.0), max(rp.w, 0.0)); + let ll = luminance(lc); + let rl = luminance(rc); + var c: vec3; + switch view { + case 3u: { + c = vec3(ll, rl, 0.0); + } + case 4u: { + c = vec3(ll, 0.0, rl); + } + case 5u: { + c = vec3(lc.x + (ll - lc.x) * k, rc.y, rc.z); + } + case 6u: { + c = vec3(ll * (1.0 - 0.5 * k) + lc.x * 0.5 * k, rl, 0.0); + } + case 7u: { + c = vec3(ll * (1.0 - 0.5 * k) + lc.x * 0.5 * k, 0.0, rl); + } + default: { + c = abs(lc - rc); + } + } + o = vec4(c * a, a); + } + textureStore(out, p, o); +} diff --git a/crates/gpu/src/shaders/sky.wgsl b/crates/gpu/src/shaders/sky.wgsl new file mode 100644 index 00000000..4ee6b460 --- /dev/null +++ b/crates/gpu/src/shaders/sky.wgsl @@ -0,0 +1,32 @@ +// Environment Light Background layers (`three_d::compose::draw_sky`): the layer's +// equirectangular image looked up by each pixel's view ray through the camera, composited over +// the canvas. src = the canvas, aux = the layer's buffer. f[0] = (forward, zoom); f[1] = +// (right, orthographic); f[2] = (down, environment rotation); f[3] = (x offset, y offset, +// 1 / scale, opacity) with the offsets ROI / scale − view size / 2; f[4] = (image width, height). + +@compute @workgroup_size(16, 16) +fn adv_sky(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + let dims = out_dims(); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let fwd = P.f[0].xyz; + var dir = fwd; + if (P.f[1].w == 0.0) { + let cx = (f32(p.x) + 0.5) * P.f[3].z + P.f[3].x; + let cy = (f32(p.y) + 0.5) * P.f[3].z + P.f[3].y; + dir = normalize(fwd * P.f[0].w + P.f[1].xyz * cx + P.f[2].xyz * cy); + } + // shade::equirect_uv. + let pi = 3.141592653589793; + var u = 0.5 + (atan2(dir.x, dir.z) + P.f[2].w) / (2.0 * pi); + let v = acos(clamp(-dir.y, -1.0, 1.0)) / pi; + u = u - floor(u); + let iw = P.f[4].x; + let ih = P.f[4].y; + let s = sample_bilinear(aux, clamp(u * iw, 0.5, iw - 0.5), clamp(v * ih, 0.5, ih - 0.5)); + let op = P.f[3].w; + let d = textureLoad(src, p, 0); + textureStore(out, p, s * op + d * (1.0 - s.w * op)); +} diff --git a/crates/gpu/src/tests_adv3d.rs b/crates/gpu/src/tests_adv3d.rs index 596c41a4..736931af 100644 --- a/crates/gpu/src/tests_adv3d.rs +++ b/crates/gpu/src/tests_adv3d.rs @@ -356,7 +356,7 @@ fn gpu_compositor_draws_advanced_3d_runs_on_the_device() { let (share, max) = off_share(&cpu.data, &gimg.data); eprintln!("walk: {:.3} % beyond 1/255 (max {max:.4})", share * 100.0); assert!(share <= 0.01, "{:.3} % of pixels differ", share * 100.0); - // A blend mode in the run takes the CPU's 2D path (its scene still renders on the GPU). + // A blend mode in the run takes the 2D compositing path (`SplitRun`, also on the GPU). p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.switches.three_d && !l.is_light() && l.source.is_av()).unwrap().blend_mode = effectcraft_color::BlendMode::Screen; let (ctx, run) = run_of(&p, cid); @@ -440,3 +440,90 @@ fn gpu_environment_light_on_flat_extrusions_has_no_nan() { assert_eq!(nan, 0, "non-finite pixels"); compare(&s, &g); } + +/// Advanced 3D runs whose layers take the 2D compositing path (blend modes, a 2D and a 3D track +/// matte, Preserve Transparency) and environment backgrounds composite on the GPU: against the +/// CPU compositor on the same (GPU) rasters they agree to float rounding; against the CPU +/// rasteriser within the raster tolerance. +#[test] +fn gpu_compositor_draws_split_advanced_3d_runs_and_skies() { + let Some(g) = gpu() else { return }; + for case in 0..10 { + let (mut p, cid) = scene_project(); + if case >= 4 { + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; + } + let classic = case >= 8; + let case = if classic { 3 } else { case % 4 }; + if classic { + // Classic 3D: the sky on the GPU too, then the run's planes. + p.comp_mut(cid).unwrap().renderer = R3::Classic3D; + } + let bg = p.add_item("B", Default::default(), None, ItemKind::Solid(Solid { color: [0.1, 0.1, 0.15], width: 240, height: 160, pixel_aspect: 1.0 })); + let comp = p.comp(cid).unwrap().clone(); + let l = build::layer(&mut p, &comp, "B", LayerSource::Solid { item: bg }, (240, 160), None); + p.comp_mut(cid).unwrap().layers.push(l); + // A varying environment image (so the sky's lookup shows; a CPU effect, so the frame's + // only readback is its own). + let mut next = p.next_id; + let spec = effectcraft_effects::find("ec.generate.lensflare").unwrap(); + let ramp = effectcraft_effects::instantiate(spec, &mut build::Ids(&mut next), spec.name, [32.0, 16.0]); + p.next_id = next; + if case == 3 { + // A 2D track matte layer above the run. + let sid = p.add_item("M", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 120, height: 90, pixel_aspect: 1.0 })); + add(&mut p, cid, LayerSource::Solid { item: sid }, |m| { + m.switches.video = false; + set(m, "transform/rotation", Value::Scalar(20.0)); + }); + } + let c = p.comp_mut(cid).unwrap(); + let env = c.layers.iter_mut().find(|l| l.environment).unwrap(); + env.props.sub_mut("effects").unwrap().children.push(ramp.into()); + let ids: Vec<_> = c.layers.iter().filter(|l| l.switches.three_d && !l.is_light() && !l.environment && l.source.is_av()).map(|l| l.id).collect(); + let top = c.layers[0].id; + fn by_id(c: &mut Comp, id: effectcraft_project::LayerId) -> &mut effectcraft_project::Layer { + c.layers.iter_mut().find(|l| l.id == id).unwrap() + } + match case { + 0 => by_id(c, ids[1]).blend_mode = effectcraft_color::BlendMode::Screen, + 1 => { + // The card is a 3D track matte (luma) for the sphere; the torus multiplies. + let card = ids[0]; + by_id(c, ids[1]).blend_mode = effectcraft_color::BlendMode::Multiply; + by_id(c, ids[2]).track_matte = Some(effectcraft_project::TrackMatte { layer: card, kind: effectcraft_project::MatteKind::Luma }); + by_id(c, ids[0]).switches.video = false; + } + 2 => { + // Preserve Transparency and an Overlay layer. + by_id(c, ids[0]).preserve_transparency = true; + by_id(c, ids[1]).blend_mode = effectcraft_color::BlendMode::Overlay; + } + _ => { + // An environment background behind the run, and the 2D track matte. + c.layers.iter_mut().find(|l| l.environment).unwrap().environment_background = true; + by_id(c, ids[0]).track_matte = Some(effectcraft_project::TrackMatte { layer: top, kind: effectcraft_project::MatteKind::Alpha }); + } + } + let (ctx, run) = run_of(&p, cid); + let r0 = Renderer::new(&p, &NoFootage, RenderOpts::default()); + if case < 3 { + assert!(r0.split_adv_run(&ctx, &run, (240, 160)).is_some(), "case {case}: split"); + } + let cpu = r0.comp_frame(cid, Tick::ZERO); + let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() }); + r.accel = Some(&g); + // The CPU compositor on the GPU's rasters. + let hybrid = r.comp_frame_cpu(cid, Tick::ZERO); + let before = g.context().transfer_stats().readbacks; + let gimg = g.render(&r, cid, Tick::ZERO).expect("gpu walk"); + // On the device end to end: the frame's own readback only (no CPU fallback). + assert_eq!(g.context().transfer_stats().readbacks - before, 1, "case {case}: readbacks"); + let (share, max) = off_share(&hybrid.data, &gimg.data); + eprintln!("split case {case}: gpu walk vs cpu compositing {:.4} % beyond 1/255 (max {max:.4})", share * 100.0); + assert!(share <= 0.001, "case {case}: {:.3} % of pixels differ (max {max})", share * 100.0); + let (share, max) = off_share(&cpu.data, &gimg.data); + eprintln!("split case {case}: gpu vs cpu {:.3} % beyond 1/255 (max {max:.4})", share * 100.0); + assert!(share <= 0.015, "case {case}: {:.3} % of pixels differ from the CPU", share * 100.0); + } +} diff --git a/crates/gpu/src/tests_fx_light.rs b/crates/gpu/src/tests_fx_light.rs new file mode 100644 index 00000000..152f0e5a --- /dev/null +++ b/crates/gpu/src/tests_fx_light.rs @@ -0,0 +1,56 @@ +//! GPU CC light effects (CC Light Rays, CC Light Burst 2.5, CC Light Sweep, CC Light Wipe) 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) +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +#[test] +fn light_rays() { + effect_case("ec.generate.cclightrays", &[("center", pt(30.0, 20.0))]); + effect_case( + "ec.generate.cclightrays", + &[("center", pt(40.0, 18.0)), ("intensity", n(250.0)), ("radius", n(15.0)), ("shape", e(1)), ("transferMode", e(1))], + ); + effect_case( + "ec.generate.cclightrays", + &[("intensity", n(80.0)), ("colorFromSource", Value::Bool(false)), ("color", c(1.0, 0.6, 0.2)), ("transferMode", e(2)), ("warpSoftness", n(120.0))], + ); + effect_case("ec.generate.cclightrays", &[("intensity", n(150.0)), ("transferMode", e(3)), ("radius", n(25.0))]); +} + +#[test] +fn light_burst() { + effect_case("ec.generate.cclightburst", &[("center", pt(30.0, 20.0)), ("burst", e(1))]); + effect_case("ec.generate.cclightburst", &[("rayLength", n(80.0)), ("burst", e(2)), ("intensity", n(150.0))]); + effect_case("ec.generate.cclightburst", &[("rayLength", n(30.0)), ("setColor", on()), ("color", c(0.3, 0.7, 1.0)), ("burst", e(1))]); + effect_case("ec.generate.cclightburst", &[("rayLength", n(40.0))]); +} + +#[test] +fn light_sweep() { + effect_case("ec.generate.cclightsweep", &[("center", pt(30.0, 20.0))]); + effect_case("ec.generate.cclightsweep", &[("shape", e(0)), ("width", n(30.0)), ("edgeThickness", n(4.0)), ("lightReceptionMode", e(1))]); + effect_case("ec.generate.cclightsweep", &[("shape", e(2)), ("direction", n(60.0)), ("lightReceptionMode", e(2)), ("lightColor", c(1.0, 0.8, 0.4))]); + effect_case("ec.generate.cclightsweep", &[("edgeThickness", n(0.0)), ("sweepIntensity", n(120.0))]); +} + +#[test] +fn light_wipe() { + effect_case("ec.transition.cclightwipe", &[("completion", n(40.0)), ("center", pt(30.0, 20.0))]); + effect_case("ec.transition.cclightwipe", &[("completion", n(25.0)), ("shape", e(0)), ("direction", n(30.0)), ("colorFromSource", on())]); + effect_case("ec.transition.cclightwipe", &[("completion", n(60.0)), ("shape", e(2)), ("reverse", on()), ("color", c(0.2, 0.9, 0.5))]); +} diff --git a/crates/gpu/src/tests_fx_text.rs b/crates/gpu/src/tests_fx_text.rs new file mode 100644 index 00000000..ab165e80 --- /dev/null +++ b/crates/gpu/src/tests_fx_text.rs @@ -0,0 +1,44 @@ +//! GPU Numbers and Timecode 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 pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +#[test] +fn numbers() { + effect_case("ec.text.numbers", &[("format/value", n(42.5)), ("size", n(14.0))]); + effect_case( + "ec.text.numbers", + &[("format/value", n(-7.0)), ("size", n(12.0)), ("fillAndStroke/displayOptions", e(2)), ("fillAndStroke/strokeWidth", n(1.5)), ("tracking", n(2.0))], + ); + effect_case( + "ec.text.numbers", + &[ + ("format/value", n(3.25)), + ("size", n(16.0)), + ("fillAndStroke/displayOptions", e(3)), + ("fillAndStroke/fillColor", c(0.2, 0.6, 1.0)), + ("compositeOnOriginal", Value::Bool(false)), + ], + ); + effect_case("ec.text.numbers", &[("size", n(10.0)), ("fillAndStroke/displayOptions", e(1)), ("fillAndStroke/position", pt(20.0, 15.0))]); +} + +#[test] +fn timecode() { + effect_case("ec.text.timecode", &[("textSize", n(10.0)), ("textPosition", pt(35.0, 22.0))]); + effect_case( + "ec.text.timecode", + &[("textSize", n(9.0)), ("displayFormat", e(1)), ("renderOnOriginal", Value::Bool(false)), ("textColor", c(1.0, 0.8, 0.1))], + ); + effect_case("ec.text.timecode", &[("textSize", n(8.0)), ("showBox", Value::Bool(true)), ("boxColor", c(0.1, 0.1, 0.4)), ("opacity", n(70.0))]); +} diff --git a/crates/gpu/src/tests_fx_time.rs b/crates/gpu/src/tests_fx_time.rs new file mode 100644 index 00000000..bb29f737 --- /dev/null +++ b/crates/gpu/src/tests_fx_time.rs @@ -0,0 +1,116 @@ +//! GPU time effects (Time Difference, Time Displacement, CC Force Motion Blur, CC Wide Time, +//! Pixel Motion Blur, Timewarp) vs the CPU effects (the oracle). They fetch other frames through +//! the effect host: a precomp whose content moves, at a nonzero time, composited at 8 and +//! 32 bpc (the direct cases have no host and pass the layer through). + +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::{BitDepth, Comp, ItemKind, LayerSource, build}; +use effectcraft_render::RenderOpts; +use effectcraft_time::{FrameRate, Tick}; + +use crate::tests::{Scene, check, compare_at, effect_case, n, opts, set, v3}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +/// `id` with `vals` on a moving precomp; `map` adds a hidden footage layer and sets parameter +/// `map` to it. +fn moving(id: &str, vals: &[(&str, Value)], map: Option<&str>) { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut vals = vals.to_vec(); + if let Some(param) = map { + let mut m = s.footage(50, 36); + m.switches.video = false; + let mid = s.push(m); + vals.push((param, Value::Layer(Some(mid.0)))); + } + let inner = Comp::new(60, 40, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let inner_id = s.p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut a = s.footage(30, 24); + let pos = a.props.prop_mut("transform/position").unwrap(); + pos.keys = vec![Keyframe::new(Tick::ZERO, v3(10.0, 12.0)), Keyframe::new(Tick::from_seconds_f64(1.0), v3(50.0, 30.0))]; + let rot = a.props.prop_mut("transform/rotation").unwrap(); + rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0))]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); + s.effect(&mut l, id, &vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let t = Tick::from_seconds_f64(0.4); + let label = format!("{id} {vals:?} {depth:?}"); + check(&label, compare_at(&s, opts(), t), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), 0.0); + } +} + +#[test] +fn without_a_host() { + for id in + ["ec.time.timedifference", "ec.time.timedisplacement", "ec.time.ccforcemotionblur", "ec.time.ccwidetime", "ec.time.pixelmotionblur", "ec.time.timewarp"] + { + effect_case(id, &[]); + } +} + +#[test] +fn frame_averages() { + moving("ec.time.ccforcemotionblur", &[], None); + moving("ec.time.ccforcemotionblur", &[("motionBlurLevels", n(5.0)), ("shutterAngle", n(400.0))], None); + moving("ec.time.ccwidetime", &[], None); + moving("ec.time.ccwidetime", &[("forwardSteps", n(1.0)), ("backwardSteps", n(4.0))], None); + moving("ec.time.pixelmotionblur", &[], None); + moving("ec.time.pixelmotionblur", &[("shutterControl", e(1)), ("shutterAngle", n(300.0)), ("shutterSamples", n(5.0))], None); +} + +#[test] +fn time_difference() { + moving("ec.time.timedifference", &[("timeOffset", n(-0.1))], None); + for alpha in 0..9 { + moving( + "ec.time.timedifference", + &[("timeOffset", n(0.2)), ("contrast", n(40.0)), ("absoluteDifference", Value::Bool(alpha % 2 == 0)), ("alphaChannel", e(alpha))], + None, + ); + } + moving("ec.time.timedifference", &[("timeOffset", n(0.1)), ("alphaChannel", e(2))], Some("targetLayer")); +} + +#[test] +fn time_displacement() { + moving("ec.time.timedisplacement", &[("maxDisplacementTime", n(0.3)), ("timeResolution", n(10.0))], None); + moving("ec.time.timedisplacement", &[("maxDisplacementTime", n(0.5)), ("timeResolution", n(30.0))], Some("displacementMapLayer")); + moving("ec.time.timedisplacement", &[("maxDisplacementTime", n(2.0)), ("stretchMap", Value::Bool(false))], Some("displacementMapLayer")); +} + +#[test] +fn timewarp() { + for method in 0..3 { + moving("ec.time.timewarp", &[("method", e(method)), ("speed", n(37.0))], None); + } + moving("ec.time.timewarp", &[("method", e(1)), ("adjustTimeBy", e(1)), ("sourceFrame", n(7.4))], None); + moving("ec.time.timewarp", &[("method", e(2)), ("speed", n(43.0)), ("motionBlur/enableMotionBlur", on()), ("motionBlur/shutterSamples", n(3.0))], None); + moving( + "ec.time.timewarp", + &[ + ("speed", n(61.0)), + ("tuning/filtering", e(1)), + ("tuning/errorThreshold", n(30.0)), + ("sourceCrops/leftCrop", n(4.0)), + ("sourceCrops/topCrop", n(3.0)), + ], + None, + ); + moving("ec.time.timewarp", &[("speed", n(55.0)), ("tuning/buildFromOneImage", on())], None); + moving("ec.time.timewarp", &[("speed", n(47.0))], Some("warpLayer")); + // A Matte Layer renders on the CPU (catalog::gpu_supported). + moving("ec.time.timewarp", &[("speed", n(47.0)), ("show", e(0))], Some("matteLayer")); +} diff --git a/crates/gpu/src/tests_fx_transition.rs b/crates/gpu/src/tests_fx_transition.rs new file mode 100644 index 00000000..ad6ebdf5 --- /dev/null +++ b/crates/gpu/src/tests_fx_transition.rs @@ -0,0 +1,157 @@ +//! GPU transition and perspective effects (Block Dissolve, the CC transitions, Radial Shadow, +//! CC Bender, CC Blobbylize, CC Cylinder, CC Sphere, CC Spotlight, CC Environment, 3D Glasses) +//! vs the CPU effects (the oracle): direct on a buffer at full and half resolution, as +//! adjustment, and composited at 8 and 32 bpc; effects reading other layers also with a layer +//! chosen. + +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 on() -> Value { + Value::Bool(true) +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +#[test] +fn block_dissolve() { + effect_case("ec.transition.blockdissolve", &[("completion", n(40.0))]); + effect_case("ec.transition.blockdissolve", &[("completion", n(55.0)), ("blockWidth", n(7.0)), ("blockHeight", n(5.5)), ("feather", n(3.0))]); + effect_case("ec.transition.blockdissolve", &[("completion", n(30.0)), ("blockWidth", n(4.3)), ("blockHeight", n(9.0)), ("softEdges", Value::Bool(false))]); +} + +#[test] +fn grid_and_scale_wipes() { + effect_case("ec.transition.ccgridwipe", &[("completion", n(40.0)), ("center", pt(33.0, 21.0))]); + effect_case("ec.transition.ccgridwipe", &[("completion", n(60.0)), ("rotation", n(20.0)), ("border", n(3.0)), ("tiles", n(7.0)), ("shape", e(0))]); + effect_case("ec.transition.ccgridwipe", &[("completion", n(35.0)), ("shape", e(2)), ("reverse", on()), ("tiles", n(5.0))]); + effect_case("ec.transition.ccradialscalewipe", &[("completion", n(40.0)), ("center", pt(30.0, 20.0))]); + effect_case("ec.transition.ccradialscalewipe", &[("completion", n(30.0)), ("center", pt(40.0, 25.0)), ("reverse", on())]); + effect_case("ec.transition.ccscalewipe", &[("stretch", n(2.0)), ("center", pt(30.0, 20.0))]); + effect_case("ec.transition.ccscalewipe", &[("stretch", n(0.7)), ("direction", n(200.0))]); +} + +#[test] +fn gradient_driven_wipes() { + effect_case("ec.transition.ccglasswipe", &[("completion", n(45.0))]); + effect_case("ec.transition.ccglasswipe", &[("completion", n(30.0)), ("softness", n(40.0)), ("displacementAmount", n(25.0))]); + effect_case("ec.transition.ccimagewipe", &[("completion", n(50.0))]); + effect_case( + "ec.transition.ccimagewipe", + &[("completion", n(40.0)), ("property", e(5)), ("blur", n(4.0)), ("inverseGradient", on()), ("borderSoftness", n(20.0))], + ); + effect_case("ec.transition.ccimagewipe", &[("completion", n(60.0)), ("property", e(6)), ("autoSoftness", Value::Bool(false))]); + effect_case("ec.transition.ccwarpomatic", &[("completion", n(40.0))]); + effect_case("ec.transition.ccwarpomatic", &[("completion", n(55.0)), ("reactor", e(1)), ("warpDirection", e(1))]); + effect_case("ec.transition.ccwarpomatic", &[("completion", n(35.0)), ("reactor", e(2)), ("warpDirection", e(2)), ("blendSpan", n(30.0))]); +} + +#[test] +fn geometric_transitions() { + effect_case("ec.transition.ccjaws", &[("completion", n(30.0)), ("center", pt(33.0, 21.0))]); + for shape in 1..4 { + effect_case("ec.transition.ccjaws", &[("completion", n(20.0)), ("shape", e(shape)), ("direction", n(25.0)), ("width", n(13.0))]); + } + effect_case("ec.transition.cclinesweep", &[("completion", n(40.0))]); + effect_case( + "ec.transition.cclinesweep", + &[("completion", n(50.0)), ("direction", n(35.0)), ("slant", n(-12.0)), ("thickness", n(7.0)), ("flipDirection", on())], + ); + effect_case("ec.transition.cctwister", &[("completion", n(30.0)), ("center", pt(33.0, 21.0))]); + effect_case("ec.transition.cctwister", &[("completion", n(60.0)), ("axis", n(10.0)), ("shading", Value::Bool(false))]); +} + +#[test] +fn perspective() { + effect_case("ec.perspective.radialshadow", &[("lightSource", pt(20.0, 10.0)), ("projectionDistance", n(15.0))]); + effect_case( + "ec.perspective.radialshadow", + &[ + ("lightSource", pt(50.0, 15.0)), + ("softness", n(6.0)), + ("render", e(1)), + ("colorInfluence", n(60.0)), + ("resizeLayer", on()), + ("color", c(0.3, 0.0, 0.5)), + ], + ); + effect_case("ec.perspective.radialshadow", &[("shadowOnly", on()), ("opacity", n(80.0)), ("resizeLayer", on())]); + effect_case("ec.perspective.cccylinder", &[("position", pt(35.0, 22.0)), ("rotation", n(30.0))]); + effect_case("ec.perspective.cccylinder", &[("radius", n(60.0)), ("render", e(1)), ("lightDirection", n(70.0)), ("roughness", n(0.2))]); + effect_case("ec.perspective.cccylinder", &[("render", e(2)), ("lightHeight", n(20.0)), ("specular", n(60.0))]); + effect_case("ec.perspective.ccsphere", &[("offset", pt(35.0, 22.0)), ("radius", n(18.0)), ("rotationX", n(20.0)), ("rotationY", n(35.0))]); + effect_case("ec.perspective.ccsphere", &[("radius", n(20.0)), ("rotationZ", n(50.0)), ("render", e(2))]); + effect_case("ec.perspective.ccspotlight", &[("from", pt(15.0, 8.0)), ("to", pt(35.0, 22.0))]); + effect_case( + "ec.perspective.ccspotlight", + &[("from", pt(50.0, 5.0)), ("to", pt(30.0, 30.0)), ("coneAngle", n(35.0)), ("edgeSoftness", n(60.0)), ("render", e(1))], + ); + effect_case("ec.perspective.ccspotlight", &[("to", pt(35.0, 22.0)), ("edgeSoftness", n(0.0)), ("color", c(1.0, 0.7, 0.3)), ("intensity", n(150.0))]); + for mapping in 0..3 { + effect_case("ec.perspective.ccenvironment", &[("mapping", e(mapping)), ("height", n(40.0))]); + } + effect_case("ec.perspective.ccenvironment", &[("filterEnvironment", Value::Bool(false))]); + for view in 0..9 { + effect_case("ec.perspective.3dglasses", &[("view3d", e(view)), ("sceneConvergence", n(4.0)), ("verticalAlignment", n(1.5))]); + } + effect_case("ec.perspective.3dglasses", &[("units", e(1)), ("sceneConvergence", n(3.0)), ("leftRightSwap", on()), ("balance", n(12.0))]); +} + +#[test] +fn distort() { + effect_case("ec.distort.ccbender", &[("amount", n(40.0)), ("top", pt(35.0, 0.0)), ("base", pt(35.0, 44.0))]); + for style in 1..4 { + effect_case( + "ec.distort.ccbender", + &[("amount", n(-30.0)), ("style", e(style)), ("top", pt(20.0, 5.0)), ("base", pt(40.0, 40.0)), ("adjustToDistance", on())], + ); + } + effect_case("ec.distort.ccblobbylize", &[]); + effect_case( + "ec.distort.ccblobbylize", + &[("property", e(4)), ("softness", n(3.0)), ("cutAway", n(30.0)), ("lightType", e(1)), ("lightPosition", pt(20.0, 10.0))], + ); + effect_case("ec.distort.ccblobbylize", &[("property", e(5)), ("metal", n(30.0)), ("roughness", n(0.2))]); +} + +/// Effects reading another layer (gradient, reveal, backside, environment, stereo views). +#[test] +fn with_other_layers() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let cases: Vec<(&str, Vec<(&str, Value)>)> = vec![ + ("ec.transition.ccglasswipe", vec![("completion", n(40.0)), ("gradientLayer", Value::Layer(None)), ("layerToReveal", Value::Layer(None))]), + ("ec.transition.ccimagewipe", vec![("completion", n(45.0)), ("layer", Value::Layer(None))]), + ("ec.transition.ccwarpomatic", vec![("completion", n(40.0)), ("reactorLayer", Value::Layer(None)), ("layerToReveal", Value::Layer(None))]), + ("ec.transition.cctwister", vec![("completion", n(70.0)), ("backside", Value::Layer(None))]), + ("ec.perspective.ccenvironment", vec![("environment", Value::Layer(None))]), + ("ec.perspective.3dglasses", vec![("view3d", e(5)), ("leftView", Value::Layer(None)), ("sceneConvergence", n(3.0))]), + ("ec.distort.ccblobbylize", vec![("blobLayer", Value::Layer(None))]), + ]; + for (id, vals) in cases { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut other = s.footage(50, 36); + other.switches.video = false; + let oid = s.push(other); + let vals: Vec<(&str, Value)> = vals.into_iter().map(|(k, v)| (k, if v == Value::Layer(None) { Value::Layer(Some(oid.0)) } else { v })).collect(); + let mut l = s.footage(70, 44); + s.effect(&mut l, id, &vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let label = format!("{id} with a layer {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); + } + } +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index a8075e10..b5bd166c 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -1,9 +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. 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. +//! renderer and its layer cache. Classic and Advanced 3D runs (environment skies included) and +//! adjustment layers run on the GPU; steps without a GPU implementation (anything a kernel +//! declines) read the canvas back, run on the CPU and upload the result. use std::sync::Arc; @@ -99,6 +99,17 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut /// 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<()> { + // Environment Light Background layers: the sky behind the rest of the run (drawn first, as + // `three_d::compose::draw_run` does). + if run.iter().any(|l| l.environment_background) { + for l in run.iter().filter(|l| l.environment_background) { + if let Some(sky) = r.sky_draw(ctx, l) { + *canvas = crate::adv3d::sky(e, &sky, canvas)?; + } + } + let rest: Vec<&'a Layer> = run.iter().copied().filter(|l| !l.environment_background).collect(); + return draw_3d(e, r, ctx, &rest, canvas); + } 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)?; @@ -115,6 +126,15 @@ fn draw_3d<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Laye } return Some(()); } + // Advanced 3D with layers on the 2D compositing path (blend modes, track mattes, Preserve + // Transparency). + if let Some(split) = r.split_adv_run(ctx, run, (canvas.width, canvas.height)) { + match draw_split(e, r, ctx, &split, canvas) { + Some(img) => *canvas = img, + None => on_cpu(e, canvas, |img| r.draw_3d_run(ctx, run, img))?, + } + 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))?, @@ -122,6 +142,44 @@ fn draw_3d<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Laye Some(()) } +/// `three_d::adv::draw_run`'s split path on the GPU canvas: the plain layers as one scene over +/// the canvas, then each special layer (far to near) rendered alone, hidden where the main +/// scene is nearer, matted (a 3D matte through the camera, else the 2D placement), Preserve +/// Transparency and blended. `None` = render the run on the CPU. +fn draw_split(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, split: &effectcraft_render::three_d::adv::SplitRun, canvas: &GpuImage) -> Option { + let (w, h) = (canvas.width, canvas.height); + let main_prep = split.main(); + if main_prep.out != (w, h) { + return None; + } + let encode = !main_prep.linear; + let main = crate::adv3d::resolve_run(e, &main_prep)?; + let mut cv = match &main { + Some(m) => crate::adv3d::finish(e, m, encode, Some(canvas)), + None => canvas.clone(), + }; + for i in 0..split.len() { + let Some(iso) = crate::adv3d::resolve_run(e, &split.special(i))? else { continue }; + let iso = match &main { + Some(m) => crate::adv3d::occluded(e, &iso, m, encode), + None => crate::adv3d::finish(e, &iso, encode, None), + }; + let matte3d = split.with_matte3d(i, |p| match p { + None => Some(e.zeros(w, h)), + Some(p) => Some(match crate::adv3d::resolve_run(e, p)? { + Some(m) => crate::adv3d::finish(e, &m, encode, None), + None => e.zeros(w, h), + }), + }); + let matte3d = match matte3d { + Some(m) => Some(m?), + None => None, + }; + composite_iso_with(e, r, ctx, split.layer(i), iso, &mut cv, 1.0, matte3d, None)?; + } + Some(cv) +} + 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 { @@ -225,21 +283,44 @@ fn composite_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, buf: } /// Track matte / Preserve Transparency on an isolated layer render, then blend. -fn composite_iso(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, mut iso: GpuImage, canvas: &mut GpuImage, opacity: f32) -> Option<()> { +fn composite_iso(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, iso: GpuImage, canvas: &mut GpuImage, opacity: f32) -> Option<()> { + composite_iso_with(e, r, ctx, layer, iso, canvas, opacity, None, r.pipe().levels) +} + +/// `Renderer::composite_iso_with`: the matte from `matte_img` when given (a 3D matte drawn +/// through the camera), else the matte layer placed in 2D; the blend quantised to `levels`. +#[allow(clippy::too_many_arguments)] +fn composite_iso_with( + e: &mut Enc, + r: &Renderer, + ctx: &EvalCtx, + layer: &Layer, + mut iso: GpuImage, + canvas: &mut GpuImage, + opacity: f32, + matte_img: Option, + levels: Option, +) -> Option<()> { if let Some((m, kind)) = r.track_matte(ctx, layer) { - let mut mimg = e.zeros(canvas.width, canvas.height); - if m.is_active_at(ctx.time) - && let Some(mb) = r.layer_buf(ctx, m) - { - let mo = ctx.opacity(m) as f32; - mimg = place(e, r, ctx, m, &mb, &mimg, BlendMode::Normal, mo, None)?; - } + let mimg = match matte_img.filter(|i| (i.width, i.height) == (canvas.width, canvas.height)) { + Some(i) => i, + None => { + let mut mimg = e.zeros(canvas.width, canvas.height); + if m.is_active_at(ctx.time) + && let Some(mb) = r.layer_buf(ctx, m) + { + let mo = ctx.opacity(m) as f32; + mimg = place(e, r, ctx, m, &mb, &mimg, BlendMode::Normal, mo, None)?; + } + mimg + } + }; iso = ops::matte(e, &iso, &mimg, kind); } if layer.preserve_transparency { iso = ops::preserve(e, &iso, canvas); } - *canvas = ops::blend_full(e, canvas, &iso, layer.blend_mode, opacity, layer.id.0 as u32, r.pipe().levels); + *canvas = ops::blend_full(e, canvas, &iso, layer.blend_mode, opacity, layer.id.0 as u32, levels); Some(()) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 461e3e4b..fa261ade 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1623,6 +1623,25 @@ impl<'a> Renderer<'a> { three_d::adv::prepare_run(self, ctx, run, out) } + /// A run of consecutive 3D layers of an Advanced 3D comp whose layers need the 2D + /// compositing path (blend mode, track matte, Preserve Transparency), split for an + /// accelerator's compositor ([`three_d::adv::SplitRun`]). `None` = [`Self::prepare_adv_run`] + /// covers it, or it isn't an Advanced 3D run. + pub fn split_adv_run<'p>(&'p self, ctx: &'p EvalCtx<'a>, run: &'p [&'p Layer], out: (u32, u32)) -> Option> { + three_d::adv::split_run(self, ctx, run, out) + } + + /// Whether the comp's 3D layers draw with the Advanced 3D renderer. + pub fn is_adv3d(&self, ctx: &EvalCtx) -> bool { + three_d::adv::active(self, ctx) + } + + /// An Environment Light Background layer ready to draw on an accelerator + /// ([`three_d::SkyDraw`]; the CPU draws it in `draw_3d_run`). `None` = nothing to draw. + pub fn sky_draw(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + three_d::compose::sky_draw(self, ctx, layer) + } + /// The pixels a Wireframe-quality layer sets (white, opaque) on a `size` canvas: its /// bounds' outline, one pixel wide. Empty when the layer has no bounds. pub fn wireframe_pixels(&self, ctx: &EvalCtx, layer: &Layer, size: (u32, u32)) -> Vec<(u32, u32)> { diff --git a/crates/render/src/three_d/adv/mod.rs b/crates/render/src/three_d/adv/mod.rs index 0fae1be7..a5685c3a 100644 --- a/crates/render/src/three_d/adv/mod.rs +++ b/crates/render/src/three_d/adv/mod.rs @@ -7,7 +7,9 @@ //! [`crate::Accelerator::raster_3d`], otherwise by the software rasteriser ([`raster`]), at 2×2 //! supersampling. The resolve (box filter), depth of field (a gather blur by each pixel's //! circle of confusion, shaped by the camera's iris: [`dof`]) and the conversion back to the working encoding run on the CPU for -//! both paths, and the result is composited over the layers below the run. +//! both paths, and the result is composited over the layers below the run. An accelerator's +//! compositor runs all of it on the device: [`Prepared`] for whole runs, [`SplitRun`] for runs +//! with layers on the 2D compositing path (`draw_run`'s steps as data). pub mod dof; pub mod raster; @@ -217,7 +219,7 @@ pub(crate) fn render_layers(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer], out: /// The run as one prepared scene for an accelerator's compositor when it can be composited /// directly over the canvas (Normal-mode layers without track mattes or Preserve Transparency; -/// those go through [`draw_run`]'s 2D path; environment backgrounds draw their sky first). `None` +/// those go through `draw_run`'s 2D path; environment backgrounds draw their sky first). `None` /// when the comp isn't Advanced 3D. pub(crate) fn prepare_run<'p, 'a>(r: &'p Renderer<'a>, ctx: &'p EvalCtx<'a>, run: &'p [&'p Layer], out: (u32, u32)) -> Option> { if !active(r, ctx) || run.iter().any(|l| l.environment_background || needs_2d_composite(ctx, l)) { @@ -226,6 +228,102 @@ pub(crate) fn prepare_run<'p, 'a>(r: &'p Renderer<'a>, ctx: &'p EvalCtx<'a>, run Some(Prepared::new(r, ctx, run, out)) } +/// A run of an Advanced 3D comp whose layers need the 2D compositing path (blend modes, track +/// mattes, Preserve Transparency), prepared for an accelerator's compositor: `draw_run`'s +/// steps as data. The plain layers render as one scene ([`SplitRun::main`]) composited over the +/// canvas; then each special layer, from the farthest to the nearest, renders on its own +/// ([`SplitRun::special`]), is hidden where the main scene is nearer, and goes through the 2D +/// path with its track matte (a 3D matte layer drawn through the camera: +/// [`SplitRun::with_matte3d`]). +pub struct SplitRun<'p, 'a> { + r: &'p Renderer<'a>, + ctx: &'p EvalCtx<'a>, + out: (u32, u32), + plain: Vec<&'p Layer>, + /// Far to near. + special: Vec<&'p Layer>, + /// Per special layer: its 3D track matte drawn solo (and whether it is active now). + mattes: Vec>, +} + +impl<'p, 'a> SplitRun<'p, 'a> { + /// The plain layers as one scene. + pub fn main(&self) -> Prepared<'_, 'a> { + Prepared::new(self.r, self.ctx, &self.plain, self.out) + } + + /// Number of special layers. + pub fn len(&self) -> usize { + self.special.len() + } + + pub fn is_empty(&self) -> bool { + self.special.is_empty() + } + + /// Special layer `i` (far to near). + pub fn layer(&self, i: usize) -> &'p Layer { + self.special[i] + } + + /// Special layer `i` rendered on its own. + pub fn special(&self, i: usize) -> Prepared<'_, 'a> { + Prepared::new(self.r, self.ctx, std::slice::from_ref(&self.special[i]), self.out) + } + + /// `f` with special layer `i`'s 3D track matte as a prepared run (`None` when the matte + /// layer isn't active: a transparent matte). `None` when the layer has no 3D matte (its + /// matte, if any, is placed in 2D as usual). + pub fn with_matte3d(&self, i: usize, f: impl FnOnce(Option<&Prepared<'_, 'a>>) -> R) -> Option { + let (solo, active) = self.mattes[i].as_ref()?; + if !*active { + return Some(f(None)); + } + let layers = [solo]; + let p = Prepared::new(self.r, self.ctx, &layers, self.out); + Some(f(Some(&p))) + } +} + +/// The run split for an accelerator when some layer needs the 2D compositing path (`None` when +/// the comp isn't Advanced 3D, a layer is an environment background, or no layer needs it: +/// `prepare_run` then covers the run). +pub(crate) fn split_run<'p, 'a>(r: &'p Renderer<'a>, ctx: &'p EvalCtx<'a>, run: &'p [&'p Layer], out: (u32, u32)) -> Option> { + if !active(r, ctx) || run.iter().any(|l| l.environment_background) || !run.iter().any(|l| needs_2d_composite(ctx, l)) { + return None; + } + let (special, plain): (Vec<&Layer>, Vec<&Layer>) = run.iter().copied().partition(|l| needs_2d_composite(ctx, l)); + let special = far_to_near(r, ctx, special); + let mattes = special.iter().map(|l| solo_matte(ctx, l)).collect(); + Some(SplitRun { r, ctx, out, plain, special, mattes }) +} + +/// Special layers sorted by their anchor's camera depth, farthest first. +fn far_to_near<'p>(r: &Renderer, ctx: &EvalCtx, special: Vec<&'p Layer>) -> Vec<&'p Layer> { + let cam = camera_for(r, ctx); + let mut order: Vec<(f64, &Layer)> = special + .into_iter() + .map(|l| { + let anchor = l.transform().map_or([0.0; 3], |tr| ctx.v3(l, tr, "anchor", [0.0; 3])); + (cam.depth(ctx.world_matrix(l).apply(effectcraft_geom::Vec3::from(anchor))), l) + }) + .collect(); + order.sort_by(|a, b| b.0.total_cmp(&a.0)); + order.into_iter().map(|(_, l)| l).collect() +} + +/// A layer's 3D track matte, drawn through the camera on its own (no matte, Normal mode, no +/// Preserve Transparency, visible), and whether it is active now. +fn solo_matte(ctx: &EvalCtx, l: &Layer) -> Option<(Layer, bool)> { + let m = l.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != l.id && m.is_3d()))?; + let mut solo = m.clone(); + solo.track_matte = None; + solo.blend_mode = effectcraft_color::BlendMode::Normal; + solo.preserve_transparency = false; + solo.switches.video = true; + Some((solo, m.is_active_at(ctx.time))) +} + /// Layers whose blend mode, track matte or Preserve Transparency needs the 2D compositing path. fn needs_2d_composite(ctx: &EvalCtx, l: &Layer) -> bool { l.blend_mode != effectcraft_color::BlendMode::Normal @@ -252,16 +350,7 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut if special.is_empty() { return; } - let cam = camera_for(r, ctx); - let mut order: Vec<(f64, &Layer)> = special - .into_iter() - .map(|l| { - let anchor = l.transform().map_or([0.0; 3], |tr| ctx.v3(l, tr, "anchor", [0.0; 3])); - (cam.depth(ctx.world_matrix(l).apply(effectcraft_geom::Vec3::from(anchor))), l) - }) - .collect(); - order.sort_by(|a, b| b.0.total_cmp(&a.0)); - for (_, l) in order { + for l in far_to_near(r, ctx, special) { let Some((mut iso, depth)) = render_layers(r, ctx, &[l], out) else { continue }; if let Some((m, md)) = &main { iso.data.par_iter_mut().zip(depth.par_iter()).zip(m.data.par_iter().zip(md.par_iter())).for_each(|((p, z), (q, mz))| { @@ -274,17 +363,8 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut }); } // A 3D matte layer is drawn through the camera too. - let matte3d = l.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != l.id && m.is_3d())).map(|m| { - let mut solo = m.clone(); - solo.track_matte = None; - solo.blend_mode = effectcraft_color::BlendMode::Normal; - solo.preserve_transparency = false; - solo.switches.video = true; - if m.is_active_at(ctx.time) { - render_layers(r, ctx, &[&solo], out).map(|(i, _)| i).unwrap_or_else(|| Image::new(out.0, out.1)) - } else { - Image::new(out.0, out.1) - } + let matte3d = solo_matte(ctx, l).map(|(solo, active)| { + if active { render_layers(r, ctx, &[&solo], out).map(|(i, _)| i).unwrap_or_else(|| Image::new(out.0, out.1)) } else { Image::new(out.0, out.1) } }); r.composite_iso_with(ctx, l, iso, canvas, 1.0, matte3d); } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 5ef271be..0d70b32f 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -971,30 +971,71 @@ pub(crate) fn environment_rotation(ctx: &EvalCtx) -> f32 { .to_radians() as f32 } -/// Draw an Environment Light Background layer: its (equirectangular) image looked up by each -/// pixel's view direction through the camera, as an infinitely distant backdrop. -pub(crate) fn draw_sky(r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) { - let Some(buf) = r.blend_layer_buf(ctx, layer) else { return }; - let img = &buf.img; - if img.width == 0 || img.height == 0 { - return; +/// An Environment Light Background layer ready to draw (see [`draw_sky`]): the layer's +/// buffer in the blending space, its opacity and the camera's view rays. Also handed to an +/// accelerator's compositor (`Renderer::sky_draw`). +#[derive(Clone)] +pub struct SkyDraw { + pub buf: Arc, + pub opacity: f32, + /// Camera basis: forward, right, down. + pub fwd: [f64; 3], + pub right: [f64; 3], + pub down: [f64; 3], + pub zoom: f64, + pub ortho: bool, + /// View size (comp pixels), region-of-interest offset (output pixels), output scale. + pub view: (f64, f64), + pub roi: (f64, f64), + pub scale: f64, + /// The Environment light's rotation (radians). + pub rotation: f32, +} + +pub(crate) fn sky_draw(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> Option { + let buf = r.blend_layer_buf(ctx, layer)?; + if buf.img.width == 0 || buf.img.height == 0 { + return None; } let op = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0); if op <= 0.0 { - return; + return None; } let cam = camera_for(r, ctx); - let (fwd, right, down) = (cam.forward(), cam.right(), cam.down()); - let s = r.opts.scale.max(1e-9); - let (cw, ch) = view_size(r, ctx); - let (ox, oy) = r.roi_offset().map_or((0.0, 0.0), |(x, y, _, _)| (x, y)); - let rot = environment_rotation(ctx); + let v3 = |v: Vec3| [v.x, v.y, v.z]; + Some(SkyDraw { + buf, + opacity: op, + fwd: v3(cam.forward()), + right: v3(cam.right()), + down: v3(cam.down()), + zoom: cam.zoom, + ortho: cam.ortho, + view: view_size(r, ctx), + roi: r.roi_offset().map_or((0.0, 0.0), |(x, y, _, _)| (x, y)), + scale: r.opts.scale.max(1e-9), + rotation: environment_rotation(ctx), + }) +} + +/// Draw an Environment Light Background layer: its (equirectangular) image looked up by each +/// pixel's view direction through the camera, as an infinitely distant backdrop. +pub(crate) fn draw_sky(r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) { + let Some(sky) = sky_draw(r, ctx, layer) else { return }; + let img = &sky.buf.img; + let op = sky.opacity; + let v3 = |v: [f64; 3]| vec3(v[0], v[1], v[2]); + let (fwd, right, down) = (v3(sky.fwd), v3(sky.right), v3(sky.down)); + let s = sky.scale; + let (cw, ch) = sky.view; + let (ox, oy) = sky.roi; + let rot = sky.rotation; let (iw, ih) = (img.width as f64, img.height as f64); canvas.rows_mut().for_each(|(y, row)| { let cy = (y as f64 + 0.5 + oy) / s; for (x, d) in row.iter_mut().enumerate() { let cx = (x as f64 + 0.5 + ox) / s; - let dir = if cam.ortho { fwd } else { (fwd * cam.zoom + right * (cx - cw / 2.0) + down * (cy - ch / 2.0)).normalize() }; + let dir = if sky.ortho { fwd } else { (fwd * sky.zoom + right * (cx - cw / 2.0) + down * (cy - ch / 2.0)).normalize() }; let (u, v) = super::adv::shade::equirect_uv([dir.x as f32, dir.y as f32, dir.z as f32], rot); // Wrap horizontally across the seam. let sx = (u as f64 * iw).clamp(0.5, iw - 0.5); diff --git a/crates/render/src/three_d/mod.rs b/crates/render/src/three_d/mod.rs index 80d5dd19..bccfc3e0 100644 --- a/crates/render/src/three_d/mod.rs +++ b/crates/render/src/three_d/mod.rs @@ -17,7 +17,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, PlaneDof, Run3d}; +pub use compose::{Geo as PlaneGeo, Plane3d, PlaneDof, Run3d, SkyDraw}; pub use light::{LightState, Material, lights_at}; use effectcraft_geom::Mat3; diff --git a/docs/architecture.md b/docs/architecture.md index ff10181f..deb4d83e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -199,21 +199,35 @@ runs render on the GPU end to end (`gpu::adv3d`, `Renderer::prepare_adv_run` / then compute kernels (`adv3d.wgsl`) resolve the 2×2 supersampling, average the sub-samples (nearest depth), apply the depth-based iris depth of field with the Classic 3D bokeh spans (`three_d::bokeh::kernel_spans`, highlight boost, progressive blur levels) and composite over the -GPU canvas; only the depth of field reads back its 8-byte radius range. Advanced 3D layers with -blend modes, track mattes or Preserve Transparency and environment backgrounds take the CPU's -2D compositing path (their scenes still render through `render_3d`). Wireframe outlines draw on -the GPU from the CPU's pixel list (`Renderer::wireframe_pixels`). GPU -effects (`effects::GPU_EFFECTS`, 166 of them: blurs, colour correction, keying incl. Key Light, -mattes, channel, stylize, distortion and warps (Warp, Bezier Warp, Smear, Reshape, CC Bend It, CC -Page Turn), Cartoon, bevels, shapes, transitions, generators, noise, grain and time; see +GPU canvas; only the depth of field reads back its 8-byte radius range. Runs whose layers need +the 2D compositing path (blend modes, track mattes, Preserve Transparency) come to the GPU as a +`three_d::adv::SplitRun` (`Renderer::split_adv_run`, `draw_run`'s steps as data): the plain +layers render as one scene over the canvas, then each special layer, farthest first, renders on +its own, is hidden where the main scene is nearer (`occlude`), and goes through the 2D matte / +Preserve Transparency / blend kernels (a 3D track matte renders solo through the camera). +Environment Light Background layers draw their sky in a kernel (`adv_sky`, from +`Renderer::sky_draw`) before the rest of the run, in Classic and Advanced 3D comps. Wireframe +outlines draw on the GPU from the CPU's pixel list (`Renderer::wireframe_pixels`). GPU effects +(`effects::GPU_EFFECTS`, 196 of them: blurs, colour correction, keying incl. Key Light, mattes, +channel, stylize, distortion and warps (Warp, Bezier Warp, Smear, Reshape, CC Bend It, CC Page +Turn, CC Bender, CC Blobbylize), Cartoon, bevels, shapes, the transitions (CC light family, CC +transitions, Block Dissolve), perspective (Radial Shadow, CC Cylinder / Sphere / Spotlight / +Environment, 3D Glasses), generators, noise, grain, text (Numbers, Timecode) and time; see [effects.md](effects.md)) repeat the CPU effect's steps (padding, box radii, parameters, hashes) as kernels, in one module per family (`gpu::fx_*` with `shaders/fx_*.wgsl`); consecutive GPU effects run as one chain with one upload and one readback. Statistics that need the whole frame (Auto Levels / Contrast / Color, Equalize, Shadow/Highlight, Color Stabilizer, Remove Grain's noise level) are measured on the CPU from one readback and applied on the GPU; effects reading -other frames (Echo, Posterize Time) upload the frames the host renders. Settings a kernel cannot -match render on the CPU (`effects::catalog::gpu_supported`; e.g. Warp's Fisheye and Twist past -50 % or with a distortion, whose Newton inverse wanders chaotically near the crease). Tests render +other frames (Echo, Posterize Time, Time Difference, Time Displacement, CC Force Motion Blur, CC +Wide Time, Pixel Motion Blur, Timewarp) upload the frames the host renders (`time_frames`) and +combine them on the GPU; Time Displacement's per-pixel times and Timewarp's motion vectors +(`time_fx::timewarp_plan`) are computed on the CPU. Numbers and Timecode rasterise their glyph +coverage on the CPU (`textfx::text_layer`) and composite it on the GPU. Other layers an effect +reads (gradients, reveals, environments) are fitted on the CPU (`util::fit_layer`) and uploaded. +Settings a kernel cannot match render on the CPU (`effects::catalog::gpu_supported`; e.g. Warp's +Fisheye and Twist past 50 % or with a distortion, whose Newton inverse wanders chaotically near +the crease, and Timewarp with a Matte Layer); Card Wipe (the 3D card renderer of Card Dance and +Shatter) stays on the CPU. Tests render scenes on both paths and compare them (≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc); they skip without an adapter. Working textures (premultiplied RGBA f32) come from a pool: a released texture is reused once every encoder that could still read it has been submitted, transparent inputs share one diff --git a/docs/effects.md b/docs/effects.md index 1ad51686..ce11f72f 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -5,7 +5,7 @@ EffectCraft ships 306 effects with 3009 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. -- **GPU**: 166 effects also run on the GPU compositor with identical results. +- **GPU**: 196 effects also run on the GPU compositor with identical results. - **32**: 306 effects process 32-bit float (HDR, overbright) pixels without clamping. - **Status**: 306 implemented in full, 0 partial (what is missing is listed). @@ -133,8 +133,8 @@ Effects are addressed by id (`ec..`) or display name in commands | Bezier Warp | `ec.distort.bezierwarp` | 13 | GPU | 32 | Implemented | | Bulge | `ec.distort.bulge` | 6 | GPU | 32 | Implemented | | CC Bend It | `ec.distort.ccbendit` | 4 | GPU | 32 | Implemented | -| CC Bender | `ec.distort.ccbender` | 5 | | 32 | Implemented | -| CC Blobbylize | `ec.distort.ccblobbylize` | 15 | | 32 | Implemented | +| CC Bender | `ec.distort.ccbender` | 5 | GPU | 32 | Implemented | +| CC Blobbylize | `ec.distort.ccblobbylize` | 15 | GPU | 32 | Implemented | | CC Flo Motion | `ec.distort.ccflomotion` | 7 | GPU | 32 | Implemented | | CC Griddler | `ec.distort.ccgriddler` | 5 | GPU | 32 | Implemented | | CC Lens | `ec.distort.cclens` | 3 | GPU | 32 | Implemented | @@ -194,9 +194,9 @@ Effects are addressed by id (`ec..`) or display name in commands | Audio Waveform | `ec.generate.audiowaveform` | 17 | | 32 | Implemented | | Beam | `ec.generate.beam` | 11 | | 32 | Implemented | | CC Glue Gun | `ec.generate.ccgluegun` | 7 | | 32 | Implemented | -| CC Light Burst 2.5 | `ec.generate.cclightburst` | 6 | | 32 | Implemented | -| CC Light Rays | `ec.generate.cclightrays` | 8 | | 32 | Implemented | -| CC Light Sweep | `ec.generate.cclightsweep` | 9 | | 32 | Implemented | +| CC Light Burst 2.5 | `ec.generate.cclightburst` | 6 | GPU | 32 | Implemented | +| CC Light Rays | `ec.generate.cclightrays` | 8 | GPU | 32 | Implemented | +| CC Light Sweep | `ec.generate.cclightsweep` | 9 | GPU | 32 | Implemented | | CC Threads | `ec.generate.ccthreads` | 8 | | 32 | Implemented | | Cell Pattern | `ec.generate.cellpattern` | 14 | GPU | 32 | Implemented | | Checkerboard | `ec.generate.checkerboard` | 10 | GPU | 32 | Implemented | @@ -303,15 +303,15 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| | 3D Camera Tracker | `ec.perspective.cameratracker` | 12 | | 32 | Implemented | -| 3D Glasses | `ec.perspective.3dglasses` | 8 | | 32 | Implemented | +| 3D Glasses | `ec.perspective.3dglasses` | 8 | GPU | 32 | Implemented | | Bevel Alpha | `ec.perspective.bevelalpha` | 4 | GPU | 32 | Implemented | | Bevel Edges | `ec.perspective.beveledges` | 4 | GPU | 32 | Implemented | -| CC Cylinder | `ec.perspective.cccylinder` | 12 | | 32 | Implemented | -| CC Environment | `ec.perspective.ccenvironment` | 4 | | 32 | Implemented | -| CC Sphere | `ec.perspective.ccsphere` | 14 | | 32 | Implemented | -| CC Spotlight | `ec.perspective.ccspotlight` | 8 | | 32 | Implemented | +| CC Cylinder | `ec.perspective.cccylinder` | 12 | GPU | 32 | Implemented | +| CC Environment | `ec.perspective.ccenvironment` | 4 | GPU | 32 | Implemented | +| CC Sphere | `ec.perspective.ccsphere` | 14 | GPU | 32 | Implemented | +| CC Spotlight | `ec.perspective.ccspotlight` | 8 | GPU | 32 | Implemented | | Drop Shadow | `ec.perspective.dropshadow` | 6 | GPU | 32 | Implemented | -| Radial Shadow | `ec.perspective.radialshadow` | 9 | | 32 | Implemented | +| Radial Shadow | `ec.perspective.radialshadow` | 9 | GPU | 32 | Implemented | ## Simulation @@ -370,37 +370,37 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Numbers | `ec.text.numbers` | 14 | | 32 | Implemented | -| Timecode | `ec.text.timecode` | 12 | | 32 | Implemented | +| Numbers | `ec.text.numbers` | 14 | GPU | 32 | Implemented | +| Timecode | `ec.text.timecode` | 12 | GPU | 32 | Implemented | ## Time | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| CC Force Motion Blur | `ec.time.ccforcemotionblur` | 4 | | 32 | Implemented | -| CC Wide Time | `ec.time.ccwidetime` | 3 | | 32 | Implemented | +| CC Force Motion Blur | `ec.time.ccforcemotionblur` | 4 | GPU | 32 | Implemented | +| CC Wide Time | `ec.time.ccwidetime` | 3 | GPU | 32 | Implemented | | Echo | `ec.time.echo` | 5 | GPU | 32 | Implemented | -| Pixel Motion Blur | `ec.time.pixelmotionblur` | 4 | | 32 | Implemented | +| Pixel Motion Blur | `ec.time.pixelmotionblur` | 4 | GPU | 32 | Implemented | | Posterize Time | `ec.time.posterizetime` | 1 | GPU | 32 | Implemented | -| Time Difference | `ec.time.timedifference` | 5 | | 32 | Implemented | -| Time Displacement | `ec.time.timedisplacement` | 4 | | 32 | Implemented | -| Timewarp | `ec.time.timewarp` | 28 | | 32 | Implemented | +| Time Difference | `ec.time.timedifference` | 5 | GPU | 32 | Implemented | +| Time Displacement | `ec.time.timedisplacement` | 4 | GPU | 32 | Implemented | +| Timewarp | `ec.time.timewarp` | 28 | GPU | 32 | Implemented | ## Transition | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Block Dissolve | `ec.transition.blockdissolve` | 5 | | 32 | Implemented | -| CC Glass Wipe | `ec.transition.ccglasswipe` | 5 | | 32 | Implemented | -| CC Grid Wipe | `ec.transition.ccgridwipe` | 7 | | 32 | Implemented | -| CC Image Wipe | `ec.transition.ccimagewipe` | 7 | | 32 | Implemented | -| CC Jaws | `ec.transition.ccjaws` | 6 | | 32 | Implemented | -| CC Light Wipe | `ec.transition.cclightwipe` | 8 | | 32 | Implemented | -| CC Line Sweep | `ec.transition.cclinesweep` | 5 | | 32 | Implemented | -| CC Radial ScaleWipe | `ec.transition.ccradialscalewipe` | 3 | | 32 | Implemented | -| CC Scale Wipe | `ec.transition.ccscalewipe` | 3 | | 32 | Implemented | -| CC Twister | `ec.transition.cctwister` | 5 | | 32 | Implemented | -| CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | | 32 | Implemented | +| Block Dissolve | `ec.transition.blockdissolve` | 5 | GPU | 32 | Implemented | +| CC Glass Wipe | `ec.transition.ccglasswipe` | 5 | GPU | 32 | Implemented | +| CC Grid Wipe | `ec.transition.ccgridwipe` | 7 | GPU | 32 | Implemented | +| CC Image Wipe | `ec.transition.ccimagewipe` | 7 | GPU | 32 | Implemented | +| CC Jaws | `ec.transition.ccjaws` | 6 | GPU | 32 | Implemented | +| CC Light Wipe | `ec.transition.cclightwipe` | 8 | GPU | 32 | Implemented | +| CC Line Sweep | `ec.transition.cclinesweep` | 5 | GPU | 32 | Implemented | +| CC Radial ScaleWipe | `ec.transition.ccradialscalewipe` | 3 | GPU | 32 | Implemented | +| CC Scale Wipe | `ec.transition.ccscalewipe` | 3 | GPU | 32 | Implemented | +| CC Twister | `ec.transition.cctwister` | 5 | GPU | 32 | Implemented | +| CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | GPU | 32 | Implemented | | Card Wipe | `ec.transition.cardwipe` | 44 | | 32 | Implemented | | Gradient Wipe | `ec.transition.gradientwipe` | 5 | GPU | 32 | Implemented | | Iris Wipe | `ec.transition.iriswipe` | 7 | GPU | 32 | Implemented | diff --git a/docs/parity.md b/docs/parity.md index 3e6c4385..7c4d2432 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,7 +10,7 @@ How close EffectCraft is to After Effects 2026, feature by feature, and how much | Unweighted | ≈ 97% (half-credit) / 99% (fractions) | | P0 / P1 / P2 | ≈ 99% / 96% / 90% (half-credit); 99.8% / 99.2% / 97.5% (fractions) | | Features done / partial / missing | 87 / 5 / 0 of 92 | -| **Effects** | **306** effects (all After Effects 2026 effects), every one implemented in full ([effects.md](effects.md)); 166 run on the GPU | +| **Effects** | **306** effects (all After Effects 2026 effects), every one implemented in full ([effects.md](effects.md)); 196 run on the GPU | | Disabled menu entries left | 1, on purpose: Import ▸ Vanishing Point (.vpe), whose format has no public specification | | Remaining work | ≈ 9–11 agent-hours at the pace measured so far (≈ 29 on the conservative audit scale); ≈ 6–7 without the learned-model items | | **Wall-clock estimate** | **≈ 2–2.5 hours** with five agents in parallel; ≈ 1.5 hours for 100% of P0 + P1 | @@ -156,11 +156,11 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Text | ≈ 96% | 0.3 | no extruded strokes (M13.12: the Variable Font Axes animator re-spaces the text — advances follow the animated axes; M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | | Web | 97% | 0.2 | GPU effects in Render Queue / analysis workers (job workers render on their CPU), layer buffers in the browser's disk cache (frames only), no shared-memory threads inside one engine instance (the decided design is one engine instance per worker; a threaded build needs nightly `build-std`) (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10; WEB-1 in M13.24: GPU effects and the GPU compositor in the frame workers on their own WebGPU devices with deferred readbacks (frames render in passes; Backend Auto per comp), the disk cache in the Origin Private File System (written by the workers with sync access handles, LRU under the settings' limit, served after a reload), the storage manager (Settings ▸ Disk ▸ Browser Storage, `storage.info` / `storage.persist` / `storage.clear`), and a WGSL constant Chrome rejected (it disabled every GPU kernel in Chrome) fixed) | | 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 | -| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (166 run on the GPU since M13.13, EFF-5: Warp, Bezier Warp, Reshape, Smear, CC Bend It, CC Page Turn, Cartoon, Color Emboss, Circle, Ellipse, Iris Wipe, Bevel Alpha / Edges and Gaussian Blur (Legacy) joined the 152 of M13.9; simulations, 3D channel, VR, audio-driven, OCIO, text and the CC light / transition families still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | +| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (196 run on the GPU since M13.22, EFF-5: the CC light family, the CC transitions, Block Dissolve, Radial Shadow, CC Bender / Blobbylize / Cylinder / Sphere / Spotlight / Environment, 3D Glasses, Numbers, Timecode and the time effects joined the 166 of M13.13; simulations, 3D channel, VR, audio-driven, OCIO and Card Wipe still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | | Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) | | Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | -| Preview | 77% | 2.8 | GPU compositing of Advanced 3D layers with blend modes / track mattes and of environment backgrounds (pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; 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) | +| Preview | 77% | 2.8 | GPU effects for simulations, 3D channel, VR, OCIO and Card Wipe (Advanced 3D layers with blend modes / track mattes / Preserve Transparency and environment backgrounds composite on the GPU since M13.22; pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; 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 | ≈ 96% | 0.3 | face tracking is a classical (skin model + feature components + shape model) fitter, not a learned detector: profile views and occluded faces are weak; Rolling Shutter Ripple is approximated by Subspace Warp's mesh density (face tracking (Outline Only / Detailed Features with Face Track Points and Extract & Copy Face Measurements), Subspace Warp's content-preserving mesh warp on subspace-smoothed trajectories, and radial lens distortion (k1, k2) in the camera bundle adjustment with Undistort Footage landed in M13.3; Rolling Shutter Repair in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker in M6.x / M12.5 / M12.6) | | Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser | @@ -224,6 +224,43 @@ axes since M13.6; Advanced 3D's DOF has iris shapes since M13.8). as real geometry lit by the parent (2D collapsed precomps draw nested 3D layers with the parent's renderer); text and shape strokes extrude as bevelled meshes in paint order. +## Update: M13.22 GPU effects, part A + +- **GPU ports (EFF-5)**: 30 more effects run on the GPU (196 GPU effects), each matching the + CPU oracle within 1/255 (8 bpc) / 1e-3 (32 bpc) on every tested pixel, directly on a buffer + (full and half resolution, as adjustment) and composited at 8 and 32 bpc: + - the CC light family: CC Light Rays, CC Light Burst 2.5, CC Light Sweep, CC Light Wipe + (`gpu::fx_light`); + - transitions and perspective (`gpu::fx_transition`): Block Dissolve (block indices computed + on the CPU per column / row sub-sample, so the hash sees the CPU's integers), CC Glass Wipe, + CC Grid Wipe, CC Image Wipe, CC Jaws, CC Line Sweep, CC Radial ScaleWipe, CC Scale Wipe, CC + Twister, CC WarpoMatic, Radial Shadow, CC Bender, CC Blobbylize, CC Cylinder, CC Sphere, CC + Spotlight, CC Environment, 3D Glasses; other layers they read (gradient, reveal, backside, + environment, stereo views) are fitted on the CPU and uploaded; + - Numbers and Timecode (`gpu::fx_text`): the glyph coverage (fill and stroke ring) is + rasterised on the CPU with the effect's own stroke font and composited on the GPU with the + box, fill and stroke; + - time (`gpu::fx_time`): Time Difference, Time Displacement, CC Force Motion Blur, CC Wide + Time, Pixel Motion Blur and Timewarp fetch their frames through the host as Echo does and + combine them on the GPU (weighted sums one frame per pass, in the CPU's order); Time + Displacement's per-pixel times and Timewarp's motion vectors (block matching and smoothing) + are computed on the CPU, Timewarp's Whole Frames / Frame Mix / Pixel Motion frame building + and shutter average on the GPU. +- **Fallbacks and deviations**: Timewarp with a Matte Layer renders on the CPU + (`gpu_supported`); Card Wipe (the 3D card renderer shared with Card Dance and Shatter) and the + 3D Camera Tracker stay on the CPU. CC Environment now fades nearly transparent environment + texels to black continuously (colour / max(alpha, 1e-3), on both paths) instead of a hard + 1e-6 un-premultiply cut-off, which turned the filtered map's float noise into colour. CC + Sphere / CC Environment keep their longitude on the side of the ±180° seam the ray's sign + puts it (a GPU's approximate `atan2` can land across it). +- **Advanced 3D compositing on the GPU (3D-3, PRV-3)**: runs with blend modes, track mattes + (2D, or 3D mattes drawn through the camera) or Preserve Transparency no longer read back: + `Renderer::split_adv_run` hands `draw_run`'s split path to the GPU (main scene, then each + special layer far to near, hidden behind the nearer main scene and composited through the 2D + kernels), and Environment Light Background skies draw in a kernel in Classic and Advanced 3D + comps. The GPU walk matches the CPU compositor on the same rasters exactly at 8 and 32 bpc, + with only the frame's own readback. + ## Update: M13.13 GPU performance and the remaining GPU ports - **Small comps on the GPU (PRV-3)**: the GPU frame allocated (and zero-initialised) several