diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index d11ad975..93e3598b 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -101,6 +101,7 @@ pub use ocio::color_stabilizer_maps; pub use channel3d::{CRYPTO_LAYERS, id_color, selection_hashes}; pub use ocio::{ColorOp, Straight, Tf, color_program}; pub use utility::{Lut, load_lut}; +pub use vr::{equi_dir as vr_equi_dir, rotation as vr_rotation}; /// Effect categories in Effects & Presets order. pub const CATEGORIES: &[&str] = &[ @@ -952,6 +953,19 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.color.ociofile", "ec.color.ociolook", "ec.utility.colorprofileconverter", + // Immersive Video. + "ec.vr.blur", + "ec.vr.chromaticaberrations", + "ec.vr.colorgradients", + "ec.vr.converter", + "ec.vr.denoise", + "ec.vr.digitalglitch", + "ec.vr.fractalnoise", + "ec.vr.glow", + "ec.vr.planetosphere", + "ec.vr.rotatesphere", + "ec.vr.sharpen", + "ec.vr.spheretoplane", ]; /// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 34f3204f..832545af 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -316,6 +316,7 @@ impl GpuContext { include_str!("shaders/fx_extra.wgsl"), include_str!("shaders/fx_depth.wgsl"), include_str!("shaders/fx_lut.wgsl"), + include_str!("shaders/fx_vr.wgsl"), ] .concat(); let module = @@ -377,6 +378,7 @@ impl GpuContext { .chain(crate::fx_extra::KERNELS) .chain(crate::fx_depth::KERNELS) .chain(crate::fx_lut::KERNELS) + .chain(crate::fx_vr::KERNELS) .map(|e| (e, &layout)) .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) .map(|(e, layout)| { diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index e8ee547f..f6f73a8f 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -135,6 +135,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { _ if crate::fx_extra::IDS.contains(&id) => crate::fx_extra::apply(e, id, ctx, b), _ if crate::fx_depth::IDS.contains(&id) => crate::fx_depth::apply(e, id, ctx, b), _ if crate::fx_lut::IDS.contains(&id) => crate::fx_lut::apply(e, id, ctx, b), + _ if crate::fx_vr::IDS.contains(&id) => crate::fx_vr::apply(e, id, ctx, b), _ => pointwise(e, id, ctx, b), } } diff --git a/crates/gpu/src/fx_noise.rs b/crates/gpu/src/fx_noise.rs index a0055e4f..545b7fb4 100644 --- a/crates/gpu/src/fx_noise.rs +++ b/crates/gpu/src/fx_noise.rs @@ -154,7 +154,7 @@ pub(crate) fn fractal(e: &mut Enc, ctx: &EffectCtx, b: GBuf, turbulent: bool) -> // ---------------------------------------------------------------- Median family (noise.rs, noise2.rs) /// noise::median_image (of the straight colour with alpha 1 when `straight`). -fn median_image(e: &mut Enc, img: &GpuImage, r: usize, straight: bool) -> GpuImage { +pub(crate) fn median_image(e: &mut Enc, img: &GpuImage, r: usize, straight: bool) -> GpuImage { let q = run(e, "fxn_quant", &with_u0([straight as u32, 0, 0, 0]), img, None, None); if r <= MEDIAN_BISECT_R { return run(e, "fxn_median", &with_u0([r as u32, 0, 0, 0]), &q, None, None); diff --git a/crates/gpu/src/fx_vr.rs b/crates/gpu/src/fx_vr.rs new file mode 100644 index 00000000..e719d606 --- /dev/null +++ b/crates/gpu/src/fx_vr.rs @@ -0,0 +1,431 @@ +//! GPU effects, Immersive Video family (kernels in `shaders/fx_vr.wgsl`, every entry point +//! prefixed `fxv_`): VR Blur, VR Chromatic Aberrations, VR Color Gradients, VR Converter, VR +//! De-Noise, VR Digital Glitch, VR Fractal Noise, VR Glow, VR Plane to Sphere, VR Rotate Sphere, +//! VR Sharpen and VR Sphere to Plane. +//! +//! The equirectangular maths of `effects::vr` (view directions, the projections and cube-map +//! layouts of VR Converter, seam-wrapping and pole-continuing bilinear sampling, the +//! latitude-widened seam-aware box blurs) is ported to WGSL operation for operation; rotation +//! matrices, per-row blur radii and VR Digital Glitch's per-band offsets are computed on the CPU +//! exactly as the CPU effect does. Stereo layouts run each eye on its own cropped image. Angles +//! between directions use atan2(|a × b|, a · b), the f32-stable form of the CPU's f64 acos; +//! VR Color Gradients' inverse-distance weights are normalised by the nearest point (the same +//! ratios without f32 overflow). VR De-Noise reuses the noise family's guided filter and median. + +use effectcraft_effects::EffectCtx; +use effectcraft_raster::hash_noise; + +use crate::context::{Enc, GpuImage, Params}; +use crate::effects::GBuf; + +/// Compute entry points in `fx_vr.wgsl`. +pub(crate) const KERNELS: &[&str] = &["fxv_convert", "fxv_box_h", "fxv_box_v", "fxv_point", "fxv_gen", "fxv_wrap_pad"]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[ + "ec.vr.blur", + "ec.vr.chromaticaberrations", + "ec.vr.colorgradients", + "ec.vr.converter", + "ec.vr.denoise", + "ec.vr.digitalglitch", + "ec.vr.fractalnoise", + "ec.vr.glow", + "ec.vr.planetosphere", + "ec.vr.rotatesphere", + "ec.vr.sharpen", + "ec.vr.spheretoplane", +]; + +/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + if b.img.width == 0 || b.img.height == 0 { + return Some(b); + } + let img = match id { + "ec.vr.blur" => blur(e, ctx, &b.img, b.scale)?, + "ec.vr.sharpen" => sharpen(e, ctx, &b.img, b.scale)?, + "ec.vr.glow" => glow(e, ctx, &b.img, b.scale)?, + "ec.vr.denoise" => denoise(e, ctx, &b)?, + "ec.vr.rotatesphere" => rotate(e, ctx, &b.img)?, + "ec.vr.converter" => converter(e, ctx, &b.img)?, + "ec.vr.spheretoplane" => { + let m = rot(ctx, "tilt", "pan", "roll"); + let fov = ctx.params.f("horizontalFov") as f32; + per_eye(e, &b.img, layout(ctx), |e, img, _| Some(convert(e, img, (0, 360.0), (5, fov), &m, img.width, img.height, None)))? + } + "ec.vr.planetosphere" => { + let fov = ctx.params.f("scale").clamp(1.0, 179.0); + let m = rot(ctx, "tilt", "pan", "roll"); + let feather = (ctx.params.f("feather") / 100.0).clamp(0.0, 1.0); + let t = (fov / 2.0).to_radians().tan(); + let (w, h) = (b.img.width, b.img.height); + convert(e, &b.img, (5, fov as f32), (0, 360.0), &m, w, h, (feather > 0.0).then_some([feather as f32, t as f32])) + } + "ec.vr.chromaticaberrations" => chromatic(e, ctx, &b.img)?, + "ec.vr.colorgradients" => gradients(e, ctx, &b.img)?, + "ec.vr.fractalnoise" => fractal(e, ctx, &b.img)?, + "ec.vr.digitalglitch" => glitch(e, ctx, &b.img)?, + _ => return None, + }; + Some(GBuf { img, ..b }) +} + +fn layout(ctx: &EffectCtx) -> u32 { + ctx.params.e("frameLayout") +} + +/// vr::eyes. +fn eyes(w: u32, h: u32, layout: u32) -> Vec<(u32, u32, u32, u32)> { + match layout { + 1 if h >= 2 => vec![(0, 0, w, h / 2), (0, h / 2, w, h - h / 2)], + 2 if w >= 2 => vec![(0, 0, w / 2, h), (w / 2, 0, w - w / 2, h)], + _ => vec![(0, 0, w, h)], + } +} + +/// vr::per_eye: run `f` on each eye's own image and reassemble. +fn per_eye(e: &mut Enc, img: &GpuImage, layout: u32, mut f: impl FnMut(&mut Enc, &GpuImage, usize) -> Option) -> Option { + let rects = eyes(img.width, img.height, layout); + if rects.len() == 1 { + return f(e, img, 0); + } + let out = e.scratch(img.width, img.height); + for (i, &(x, y, w, h)) in rects.iter().enumerate() { + let crop = e.scratch(w, h); + let mut p = Params::default(); + p.u[0] = [x, y, 0, 0]; + e.pixels("fxn_crop", &p, img, None, &crop, None); + let r = f(e, &crop, i)?; + e.copy_into(&r, &out, x, y); + } + Some(out) +} + +fn rot(ctx: &EffectCtx, t: &str, p: &str, r: &str) -> [[f32; 3]; 3] { + effectcraft_effects::vr_rotation(ctx.params.f(t), ctx.params.f(p), ctx.params.f(r)).map(|r| r.map(|v| v as f32)) +} + +fn put_matrix(p: &mut Params, at: usize, m: &[[f32; 3]; 3]) { + for (i, r) in m.iter().enumerate() { + p.f[at + i] = [r[0], r[1], r[2], 0.0]; + } +} + +/// vr::convert: re-project `src` (projection `from` = (kind, fov)) into a `w`×`h` frame in `to`, +/// output direction → source direction = mᵀ · d. `feather` = (feather, tan(fov / 2)) for VR +/// Plane to Sphere's edge. +#[allow(clippy::too_many_arguments)] +fn convert(e: &mut Enc, src: &GpuImage, from: (u32, f32), to: (u32, f32), m: &[[f32; 3]; 3], w: u32, h: u32, feather: Option<[f32; 2]>) -> GpuImage { + let mut p = Params::default(); + p.u[0] = [from.0, to.0, feather.is_some() as u32, 0]; + p.f[0] = [from.1, to.1, 0.0, 0.0]; + put_matrix(&mut p, 1, m); + if let Some(f) = feather { + p.f[4] = [f[0], f[1], 0.0, 0.0]; + } + let out = e.scratch(w, h); + e.pixels("fxv_convert", &p, src, None, &out, None); + out +} + +fn rotate(e: &mut Enc, ctx: &EffectCtx, img: &GpuImage) -> Option { + let pr = ctx.params; + if pr.f("tilt") == 0.0 && pr.f("pan") == 0.0 && pr.f("roll") == 0.0 { + return Some(img.clone()); + } + let mut m = rot(ctx, "tilt", "pan", "roll"); + if pr.b("invertRotation") { + m = [[m[0][0], m[1][0], m[2][0]], [m[0][1], m[1][1], m[2][1]], [m[0][2], m[1][2], m[2][2]]]; + } + per_eye(e, img, layout(ctx), |e, img, _| Some(convert(e, img, (0, 360.0), (0, 360.0), &m, img.width, img.height, None))) +} + +fn converter(e: &mut Enc, ctx: &EffectCtx, img: &GpuImage) -> Option { + let pr = ctx.params; + let from = (pr.e("sourceProjection"), pr.f("sourceHorizontalFov")); + let to = (pr.e("targetProjection"), pr.f("targetHorizontalFov")); + if from.0 == to.0 && (!matches!(from.0, 4 | 5) || from.1 == to.1) && pr.f("tilt") == 0.0 && pr.f("pan") == 0.0 && pr.f("roll") == 0.0 { + return Some(img.clone()); + } + let m = rot(ctx, "tilt", "pan", "roll"); + let (from, to) = ((from.0, from.1 as f32), (to.0, to.1 as f32)); + per_eye(e, img, layout(ctx), |e, img, _| Some(convert(e, img, from, to, &m, img.width, img.height, None))) +} + +// ---------------------------------------------------------------- seam-aware blur + +/// vr::sphere_blur: 3 latitude-widened, seam-wrapping horizontal box passes, then 3 +/// pole-continuing vertical ones. +fn sphere_blur(e: &mut Enc, img: &GpuImage, sigma: f64) -> GpuImage { + if sigma < 0.1 { + return img.clone(); + } + let (w, h) = (img.width as usize, img.height as usize); + let radii = effectcraft_effects::util::box_radii(sigma, 3); + let mut cur = img.clone(); + for &r in &radii { + let rows: Vec = (0..h) + .map(|y| { + let lat = (0.5 - (y as f64 + 0.5) / h as f64) * std::f64::consts::PI; + let rr = ((r as f64) / lat.cos().max(1e-3)).round().min((w / 2) as f64) as usize; + // box_row_wrap's own limit. + let rr = if w == 0 { 0 } else { rr.min((w - 1) / 2) }; + rr as f32 + }) + .collect(); + if rows.iter().all(|r| *r == 0.0) { + continue; + } + let buf = e.data(&rows); + let mut p = Params::default(); + p.u[0][0] = 256; + let out = e.scratch(cur.width, cur.height); + e.dispatch("fxv_box_h", &p, &cur, None, &out, Some(&buf), (cur.height.div_ceil(64), cur.width.div_ceil(256))); + cur = out; + } + for &r in &radii { + if r == 0 { + continue; + } + let block = (4 * r).clamp(32, 256) as u32; + let mut p = Params::default(); + p.u[0] = [block, r as u32, 0, 0]; + let out = e.scratch(cur.width, cur.height); + e.dispatch("fxv_box_v", &p, &cur, None, &out, None, (cur.width.div_ceil(64), cur.height.div_ceil(block))); + cur = out; + } + cur +} + +fn op(code: u32) -> Params { + let mut p = Params::default(); + p.u[0][0] = code; + p +} + +fn point(e: &mut Enc, p: &Params, src: &GpuImage, aux: Option<&GpuImage>) -> GpuImage { + let out = e.scratch(src.width, src.height); + e.pixels("fxv_point", p, src, aux, &out, None); + out +} + +fn blur(e: &mut Enc, ctx: &EffectCtx, img: &GpuImage, scale: f64) -> Option { + let s = ctx.params.f("blurriness").max(0.0) * scale * 0.5; + if s < 0.1 { + return Some(img.clone()); + } + per_eye(e, img, layout(ctx), |e, img, _| Some(sphere_blur(e, img, s))) +} + +fn sharpen(e: &mut Enc, ctx: &EffectCtx, img: &GpuImage, scale: f64) -> Option { + let amt = ctx.params.f("sharpenAmount") as f32 / 100.0; + if amt == 0.0 { + return Some(img.clone()); + } + let s = (1.5 * scale).max(0.5); + per_eye(e, img, layout(ctx), |e, img, _| { + let bl = sphere_blur(e, img, s); + let mut p = op(2); + p.f[0][0] = amt; + Some(point(e, &p, img, Some(&bl))) + }) +} + +fn glow(e: &mut Enc, ctx: &EffectCtx, img: &GpuImage, scale: f64) -> Option { + let pr = ctx.params; + let bright = pr.f("glowBrightness") as f32 / 100.0; + if bright <= 0.0 { + return Some(img.clone()); + } + let radius = pr.f("glowRadius").max(0.0) * scale; + let tc = pr.color("tintColor"); + let mut hp = op(0); + hp.u[0][1] = pr.b("useTintColor") as u32; + hp.f[0] = [pr.f("luminanceThreshold") as f32 / 100.0, pr.f("glowSaturation") as f32 / 100.0, 0.0, 0.0]; + hp.f[1] = [tc[0], tc[1], tc[2], 0.0]; + let mut add = op(1); + add.f[0][0] = bright; + per_eye(e, img, layout(ctx), |e, img, _| { + let hi = point(e, &hp, img, None); + let g = sphere_blur(e, &hi, (radius * 0.5).max(0.5)); + Some(point(e, &add, img, Some(&g))) + }) +} + +fn denoise(e: &mut Enc, ctx: &EffectCtx, b: &GBuf) -> Option { + let pr = ctx.params; + let level = pr.f("noiseLevel").max(0.0) / 100.0; + if level <= 0.0 { + return Some(b.img.clone()); + } + let r = ((1.0 + level * 4.0) * b.scale).max(1.0) as usize; + if pr.e("noiseType") == 1 { + let mr = ((level * 3.0 * b.scale).round() as usize).max(1); + return per_eye(e, &b.img, layout(ctx), |e, img, _| Some(crate::fx_noise::median_image(e, img, mr, false))); + } + let eps = (level * level * 0.02) as f32; + let detail = (pr.f("detail") / 100.0).clamp(0.0, 1.0) as f32; + per_eye(e, &b.img, layout(ctx), |e, img, _| { + let pad = (r * 2 + 1) as u32; + let (pw, ph) = (img.width + 2 * pad, img.height + 2 * pad); + if !e.g.fits(pw, ph) { + return None; + } + let padded = e.scratch(pw, ph); + let mut p = Params::default(); + p.u[0][0] = pad; + e.pixels("fxv_wrap_pad", &p, img, None, &padded, None); + let guide = point(e, &op(4), &padded, None); + let f = crate::fx_noise::guided_filter(e, &guide, &padded, r, eps.max(1e-6)); + let crop = e.scratch(img.width, img.height); + let mut p = Params::default(); + p.u[0] = [pad, pad, 0, 0]; + e.pixels("fxn_crop", &p, &f, None, &crop, None); + let mut p = op(3); + p.f[0][0] = detail; + Some(point(e, &p, &crop, Some(img))) + }) +} + +// ---------------------------------------------------------------- generators + +/// BLEND_OPTS → (none, blend mode id). +fn blend(i: u32) -> (u32, u32) { + use effectcraft_color::BlendMode::*; + let m = match i { + 0 => return (1, 0), + 2 => Add, + 3 => Multiply, + 4 => Screen, + 5 => Overlay, + _ => Normal, + }; + (0, crate::ops::mode_id(m)) +} + +fn chromatic(e: &mut Enc, ctx: &EffectCtx, img: &GpuImage) -> Option { + let pr = ctx.params; + let k = [pr.f("aberrationRed"), pr.f("aberrationGreen"), pr.f("aberrationBlue")].map(|v| (v / 100.0 * 0.1) as f32); + if k.iter().all(|v| *v == 0.0) { + return Some(img.clone()); + } + let r = effectcraft_effects::vr_rotation(pr.f("centerTilt"), pr.f("centerPan"), 0.0); + let c = [r[0][2], r[1][2], r[2][2]].map(|v| v as f32); + let mut p = op(0); + p.u[0][1] = pr.b("falloffInvert") as u32; + p.f[0] = [k[0], k[1], k[2], (pr.f("falloff") / 100.0).clamp(0.0, 1.0) as f32]; + p.f[1] = [c[0], c[1], c[2], 0.0]; + per_eye(e, img, layout(ctx), |e, img, _| Some(gen_pass(e, &p, img, None))) +} + +fn gen_pass(e: &mut Enc, p: &Params, img: &GpuImage, data: Option<&wgpu::Buffer>) -> GpuImage { + let out = e.scratch(img.width, img.height); + e.pixels("fxv_gen", p, img, None, &out, data); + out +} + +fn gradients(e: &mut Enc, ctx: &EffectCtx, img: &GpuImage) -> Option { + let pr = ctx.params; + let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); + let mut d = vec![]; + for i in 1..=5 { + if !pr.b(&format!("enablePoint{i}")) { + continue; + } + let pt = pr.v2(&format!("point{i}")); + let dir = effectcraft_effects::vr_equi_dir(pt[0], pt[1], lw, lh); + let c = pr.color(&format!("color{i}")); + d.extend([dir[0] as f32, dir[1] as f32, dir[2] as f32, c[0], c[1], c[2]]); + } + if d.is_empty() { + return Some(img.clone()); + } + let (none, mode) = blend(pr.e("blendingMode")); + let mut p = op(1); + p.u[0] = [1, (d.len() / 6) as u32, none, mode]; + p.f[0] = [pr.f("blend").max(0.1) as f32, (pr.f("opacity") / 100.0) as f32, 0.0, 0.0]; + let buf = e.data(&d); + per_eye(e, img, layout(ctx), |e, img, _| Some(gen_pass(e, &p, img, Some(&buf)))) +} + +fn fractal(e: &mut Enc, ctx: &EffectCtx, img: &GpuImage) -> Option { + let pr = ctx.params; + let g = |id: &str, d: f64| pr.get(id).map(|v| v.as_f64()).unwrap_or(d); + let octaves = pr.f("complexity").clamp(1.0, 20.0); + let scale = (pr.f("transform/scale") / 100.0).max(0.01) as f32; + let (none, mode) = blend(pr.e("blendingMode")); + let mut p = op(2); + p.u[0] = [2, pr.e("fractalType"), none, mode]; + p.u[1] = [octaves.ceil() as u32, pr.f("randomSeed") as u32, pr.b("invert") as u32, 0]; + p.f[0] = [ + pr.f("contrast") as f32 / 100.0, + pr.f("brightness") as f32 / 100.0, + (g("subSettings/subInfluence", 50.0) / 100.0).clamp(0.0, 1.0) as f32, + (g("subSettings/subScaling", 50.0) / 100.0).clamp(0.1, 1.0) as f32, + ]; + p.f[1] = [4.0 / scale, (pr.f("evolution") / 360.0) as f32, (pr.f("opacity") / 100.0) as f32, octaves.fract() as f32]; + put_matrix(&mut p, 2, &rot(ctx, "transform/tilt", "transform/pan", "transform/roll")); + per_eye(e, img, layout(ctx), |e, img, _| Some(gen_pass(e, &p, img, None))) +} + +fn glitch(e: &mut Enc, ctx: &EffectCtx, img: &GpuImage) -> Option { + let pr = ctx.params; + let amp = (pr.f("masterAmplitude") / 100.0).max(0.0); + if amp <= 0.0 { + return Some(img.clone()); + } + let g = |id: &str, d: f64| pr.get(id).map(|v| v.as_f64()).unwrap_or(d); + let rate = pr.f("distortionRate").max(0.0); + let geo = pr.f("geometricDistortion") / 100.0 * amp; + let (hdisp, vdisp) = (g("geometric/horizontalDisplacement", 100.0) / 100.0, g("geometric/verticalDisplacement", 0.0) / 100.0); + let complexity = pr.f("distortionComplexity").clamp(1.0, 100.0); + let rgb = pr.f("colorDistortion") / 100.0 * amp; + let offs = [g("color/redOffset", -1.0), g("color/greenOffset", 0.0), g("color/blueOffset", 1.0)]; + let scan = (pr.f("scanlines") / 100.0 * amp) as f32; + let spacing = g("scanlineSpacing", 2.0).round().max(2.0) as usize; + let seed = (pr.f("randomSeed") as u32) ^ ctx.seed; + let mut evo = g("distortionEvolution", 0.0) / 360.0; + if pr.b("evolutionOptions/cycleEvolution") { + evo = evo.rem_euclid(g("evolutionOptions/cycle", 1.0).round().max(1.0)); + } + let base = if rate > 0.0 { ctx.time * rate } else { 0.0 } + evo; + let slot = base.floor() as i64 as u32; + let frac = base - base.floor(); + let tr = effectcraft_effects::vr_rotation(g("target/tilt", 0.0), g("target/pan", 0.0), 0.0); + let target = [tr[0][2], tr[1][2], tr[2][2]].map(|v| v as f32); + let radius = g("target/radius", 0.0).to_radians() as f32; + let feather = g("target/feather", 0.0).to_radians() as f32; + per_eye(e, img, layout(ctx), |e, img, eye| { + let (w, h) = (img.width as f64, img.height as f64); + let bands = complexity.round() as u32; + // Per row: horizontal / vertical shift, channel split and scan-line darkening. + let mut d = Vec::with_capacity(img.height as usize * 4); + for y in 0..img.height as usize { + let band = ((y as f64 / h) * bands as f64) as u32; + let at = |s: u32| { + let r0 = hash_noise(band, s, seed ^ (eye as u32 * 977)); + let on = r0 < 0.35; + let shift = if on { (hash_noise(band, s + 1, seed) as f64 * 2.0 - 1.0) * geo } else { 0.0 }; + let split = if on { rgb * w * 0.01 * (1.0 + hash_noise(band, s + 2, seed) as f64) } else { 0.0 }; + (shift, split) + }; + let (s0, p0) = at(slot); + let (shift, split) = if frac > 0.0 { + let (s1, p1) = at(slot + 1); + (s0 + (s1 - s0) * frac, p0 + (p1 - p0) * frac) + } else { + (s0, p0) + }; + let dark = if y % spacing == 0 { 1.0 - scan * 0.5 } else { 1.0 }; + d.extend([(shift * w * 0.15 * hdisp) as f32, (shift * h * 0.15 * vdisp) as f32, split as f32, dark]); + } + let buf = e.data(&d); + let mut p = op(3); + p.u[0][0] = 3; + p.f[0] = [offs[0] as f32, offs[1] as f32, offs[2] as f32, 0.0]; + p.f[1] = [target[0], target[1], target[2], 0.0]; + p.f[2] = [radius, feather, 0.0, 0.0]; + Some(gen_pass(e, &p, img, Some(&buf))) + }) +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 4cfd9ebe..ca82a5a3 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -57,6 +57,7 @@ mod fx_lut; mod fx_noise; mod fx_stylize; mod fx_tone; +mod fx_vr; mod fx_warp; mod ops; mod particles; @@ -221,6 +222,8 @@ mod tests_fx_stylize; #[cfg(test)] mod tests_fx_tone; #[cfg(test)] +mod tests_fx_vr; +#[cfg(test)] mod tests_fx_warp; #[cfg(test)] mod tests_particles; diff --git a/crates/gpu/src/shaders/fx_vr.wgsl b/crates/gpu/src/shaders/fx_vr.wgsl new file mode 100644 index 00000000..3a1d06d0 --- /dev/null +++ b/crates/gpu/src/shaders/fx_vr.wgsl @@ -0,0 +1,640 @@ +// ---------------------------------------------------------------- Immersive Video (fx_vr.rs) +// +// Equirectangular frames: longitude across, latitude down, the front (+Z) at the centre, y up +// (effects::vr). Every function mirrors its CPU namesake. + +const FXV_PI: f32 = 3.14159265358979; + +fn fxv_unpremul(p: vec4) -> vec4 { + if (p.w > 1e-6) { + return vec4(p.xyz / p.w, p.w); + } + return vec4(0.0, 0.0, 0.0, max(p.w, 0.0)); +} + +// vr::equi_dir. +fn fxv_equi_dir(x: f32, y: f32, w: f32, h: f32) -> vec3 { + let lon = (x / w - 0.5) * 2.0 * FXV_PI; + let lat = (0.5 - y / h) * FXV_PI; + return vec3(cos(lat) * sin(lon), sin(lat), cos(lat) * cos(lon)); +} + +// vr::equi_px. +fn fxv_equi_px(d: vec3, w: f32, h: f32) -> vec2 { + let lon = atan2(d.x, d.z); + let lat = asin(clamp(d.y, -1.0, 1.0)); + return vec2((lon / (2.0 * FXV_PI) + 0.5) * w, (0.5 - lat / FXV_PI) * h); +} + +// Angle between two unit directions (the CPU's acos of the dot, in its f32-stable form). +fn fxv_angle(a: vec3, b: vec3) -> f32 { + return atan2(length(cross(a, b)), dot(a, b)); +} + +// vr::sample_equi's texel fetch: wraps horizontally, continues over the poles. +fn fxv_equi_at(xi0: i32, yi0: i32, w: i32, h: i32) -> vec4 { + var xi = xi0; + var yi = yi0; + if (yi < 0) { + yi = -1 - yi; + xi += w / 2; + } else if (yi >= h) { + yi = 2 * h - 1 - yi; + xi += w / 2; + } + let xm = ((xi % w) + w) % w; + return textureLoad(src, vec2(xm, clamp(yi, 0, h - 1)), 0); +} + +// vr::sample_equi (bilinear). +fn fxv_sample_equi(x: f32, y: f32) -> vec4 { + let d = vec2(textureDimensions(src)); + let fx = x - 0.5; + let fy = y - 0.5; + let x0 = floor(fx); + let y0 = floor(fy); + let tx = fx - x0; + let ty = fy - y0; + let xi = i32(x0); + let yi = i32(y0); + let a = fxv_equi_at(xi, yi, d.x, d.y); + let b = fxv_equi_at(xi + 1, yi, d.x, d.y); + let c = fxv_equi_at(xi, yi + 1, d.x, d.y); + let e = fxv_equi_at(xi + 1, yi + 1, d.x, d.y); + let top = a + (b - a) * tx; + let bot = c + (e - c) * tx; + return top + (bot - top) * ty; +} + +// ---- VR Converter's projections (vr::Proj) + +fn fxv_face_fw(f: u32) -> vec3 { + var v = array, 6>(vec3(1.0, 0.0, 0.0), vec3(-1.0, 0.0, 0.0), vec3(0.0, 1.0, 0.0), vec3(0.0, -1.0, 0.0), vec3(0.0, 0.0, 1.0), vec3(0.0, 0.0, -1.0)); + return v[f]; +} + +fn fxv_face_r(f: u32) -> vec3 { + var v = array, 6>(vec3(0.0, 0.0, -1.0), vec3(0.0, 0.0, 1.0), vec3(1.0, 0.0, 0.0), vec3(1.0, 0.0, 0.0), vec3(1.0, 0.0, 0.0), vec3(-1.0, 0.0, 0.0)); + return v[f]; +} + +fn fxv_face_u(f: u32) -> vec3 { + var v = array, 6>(vec3(0.0, 1.0, 0.0), vec3(0.0, 1.0, 0.0), vec3(0.0, 0.0, -1.0), vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 0.0), vec3(0.0, 1.0, 0.0)); + return v[f]; +} + +// vr::cube_grid: the grid size. +fn fxv_grid(kind: u32) -> vec2 { + switch (kind) { + case 1u: { + return vec2(4.0, 3.0); + } + case 2u, 6u, 7u: { + return vec2(3.0, 2.0); + } + default: { + return vec2(6.0, 1.0); + } + } +} + +// vr::cube_grid: (column, row) of face f. +fn fxv_cell(kind: u32, f: u32) -> vec2 { + switch (kind) { + case 1u: { + var c = array, 6>(vec2(2.0, 1.0), vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 2.0), vec2(1.0, 1.0), vec2(3.0, 1.0)); + return c[f]; + } + case 2u: { + var c = array, 6>(vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(2.0, 0.0), vec2(0.0, 1.0), vec2(1.0, 1.0), vec2(2.0, 1.0)); + return c[f]; + } + case 6u, 7u: { + var c = array, 6>(vec2(2.0, 0.0), vec2(0.0, 0.0), vec2(2.0, 1.0), vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 1.0)); + return c[f]; + } + default: { + return vec2(f32(f), 0.0); + } + } +} + +fn fxv_is_cube(kind: u32) -> bool { + return (kind >= 1u && kind <= 3u) || kind == 6u || kind == 7u; +} + +// Proj::dir: the direction seen at frame position (x, y); w = 2 when outside the image. +fn fxv_dir(kind: u32, fov: f32, x: f32, y: f32, w: f32, h: f32) -> vec4 { + let none = vec4(0.0, 0.0, 0.0, 2.0); + if (kind == 0u) { + return vec4(fxv_equi_dir(x, y, w, h), 1.0); + } + if (fxv_is_cube(kind)) { + let g = fxv_grid(kind); + let cw = w / g.x; + let ch = h / g.y; + let cx = floor(x / cw); + let cy = floor(y / ch); + var f = 6u; + for (var i = 0u; i < 6u; i++) { + let c = fxv_cell(kind, i); + if (f == 6u && c.x == cx && c.y == cy) { + f = i; + } + } + if (f == 6u) { + return none; + } + var a = (x - cx * cw) / cw * 2.0 - 1.0; + var b = (y - cy * ch) / ch * 2.0 - 1.0; + if (kind == 7u) { + a = tan(a * FXV_PI / 4.0); + b = tan(b * FXV_PI / 4.0); + } + return vec4(normalize(fxv_face_fw(f) + a * fxv_face_r(f) - b * fxv_face_u(f)), 1.0); + } + if (kind == 8u) { + let s = min(w, h) / 2.0; + let u = (x - w / 2.0) / s; + let v = (y - h / 2.0) / s; + let r = sqrt(u * u + v * v); + if (r > 1.0) { + return none; + } + let th = r * FXV_PI / 2.0; + var cu = 0.0; + var cv = 0.0; + if (r > 1e-12) { + cu = u / r; + cv = v / r; + } + return vec4(sin(th) * cu, cos(th), sin(th) * cv, 1.0); + } + if (kind == 4u) { + let half = radians(clamp(fov, 1.0, 360.0) / 2.0); + let s = min(w, h) / 2.0; + let u = (x - w / 2.0) / s; + let v = (h / 2.0 - y) / s; + let r = sqrt(u * u + v * v); + if (r > 1.0) { + return none; + } + let th = r * half; + var cu = 0.0; + var cv = 0.0; + if (r > 1e-12) { + cu = u / r; + cv = v / r; + } + return vec4(sin(th) * cu, sin(th) * cv, cos(th), 1.0); + } + let t = tan(radians(clamp(fov, 1.0, 179.0) / 2.0)); + let u = (x / w * 2.0 - 1.0) * t; + let v = (1.0 - y / h * 2.0) * t * h / w; + return vec4(normalize(vec3(u, v, 1.0)), 1.0); +} + +// Proj::sample of `src` (in projection `kind`) in direction d. +fn fxv_proj_sample(kind: u32, fov: f32, d: vec3) -> vec4 { + let dims = vec2(textureDimensions(src)); + let w = dims.x; + let h = dims.y; + if (kind == 0u) { + let q = fxv_equi_px(d, w, h); + return fxv_sample_equi(q.x, q.y); + } + if (fxv_is_cube(kind)) { + // The face facing d most (the last one on ties, as max_by). + var f = 0u; + var best = dot(d, fxv_face_fw(0u)); + for (var i = 1u; i < 6u; i++) { + let k = dot(d, fxv_face_fw(i)); + if (k >= best) { + best = k; + f = i; + } + } + let fw = fxv_face_fw(f); + let k = dot(d, fw); + var a = dot(d, fxv_face_r(f)) / k; + var b = -dot(d, fxv_face_u(f)) / k; + if (kind == 7u) { + a = atan(a) / (FXV_PI / 4.0); + b = atan(b) / (FXV_PI / 4.0); + } + let g = fxv_grid(kind); + let cw = w / g.x; + let ch = h / g.y; + let c = fxv_cell(kind, f); + let x0 = c.x * cw; + let y0 = c.y * ch; + let x = clamp(x0 + (a + 1.0) * 0.5 * cw, x0 + 0.5, x0 + cw - 0.5); + let y = clamp(y0 + (b + 1.0) * 0.5 * ch, y0 + 0.5, y0 + ch - 0.5); + return sample_bilinear_clamped(src, x, y); + } + if (kind == 8u) { + let th = acos(clamp(d.y, -1.0, 1.0)); + if (th > FXV_PI / 2.0 + 1e-6) { + return vec4(0.0); + } + let s = min(w, h) / 2.0; + let r = th / (FXV_PI / 2.0); + let l = sqrt(d.x * d.x + d.z * d.z); + var cu = 0.0; + var cv = 0.0; + if (l > 1e-12) { + cu = d.x / l; + cv = d.z / l; + } + return sample_bilinear_clamped(src, w / 2.0 + cu * r * s, h / 2.0 + cv * r * s); + } + if (kind == 4u) { + let half = radians(clamp(fov, 1.0, 360.0) / 2.0); + let th = acos(clamp(d.z, -1.0, 1.0)); + if (th > half + 1e-6) { + return vec4(0.0); + } + let s = min(w, h) / 2.0; + let r = th / half; + let l = sqrt(d.x * d.x + d.y * d.y); + var cu = 0.0; + var cv = 0.0; + if (l > 1e-12) { + cu = d.x / l; + cv = d.y / l; + } + return sample_bilinear_clamped(src, w / 2.0 + cu * r * s, h / 2.0 - cv * r * s); + } + if (d.z <= 1e-9) { + return vec4(0.0); + } + let t = tan(radians(clamp(fov, 1.0, 179.0) / 2.0)); + let u = d.x / d.z / t; + let v = d.y / d.z / (t * h / w); + if (abs(u) > 1.0 || abs(v) > 1.0) { + return vec4(0.0); + } + return sample_bilinear_clamped(src, (u + 1.0) * 0.5 * w, (1.0 - v) * 0.5 * h); +} + +// mᵀ · d with m's rows in f[at..at + 3]. +fn fxv_mtv(at: u32, d: vec3) -> vec3 { + let r0 = P.f[at].xyz; + let r1 = P.f[at + 1u].xyz; + let r2 = P.f[at + 2u].xyz; + return r0 * d.x + r1 * d.y + r2 * d.z; +} + +// m · d. +fn fxv_mv(at: u32, d: vec3) -> vec3 { + return vec3(dot(P.f[at].xyz, d), dot(P.f[at + 1u].xyz, d), dot(P.f[at + 2u].xyz, d)); +} + +// vr::convert. u[0] = (from kind, to kind, feather); f[0] = (from fov, to fov); +// f[1..4] = rotation rows; f[4] = (feather, tan(fov / 2)) (VR Plane to Sphere). +@compute @workgroup_size(16, 16) +fn fxv_convert(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let w = f32(dims.x); + let h = f32(dims.y); + let x = f32(p.x) + 0.5; + let y = f32(p.y) + 0.5; + let dd = fxv_dir(P.u[0].y, P.f[0].y, x, y, w, h); + var o = vec4(0.0); + if (dd.w < 1.5) { + o = fxv_proj_sample(P.u[0].x, P.f[0].x, fxv_mtv(1u, dd.xyz)); + } + if (P.u[0].z != 0u) { + let d = fxv_mtv(1u, fxv_equi_dir(x, y, w, h)); + if (d.z > 0.0) { + let t = P.f[4].y; + let u = abs(d.x / d.z / t); + let v = abs(d.y / d.z / (t * h / w)); + let edge = 1.0 - max(u, v); + o = o * clamp(edge / P.f[4].x, 0.0, 1.0); + } + } + textureStore(out, p, o); +} + +// ---- seam-aware box blurs + +// Horizontal wrap-around box mean (vr::box_row_wrap); each row's radius in data[y]. +// u[0].x = block; dispatched over (row, block). +@compute @workgroup_size(64, 1) +fn fxv_box_h(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let y = i32(gid.x); + let block = i32(P.u[0].x); + let b0 = i32(gid.y) * block; + let w = dims.x; + if (y >= dims.y || b0 >= w) { + return; + } + let r = i32(data[y]); + let b1 = min(b0 + block, w); + if (r == 0) { + for (var x = b0; x < b1; x++) { + textureStore(out, vec2(x, y), textureLoad(src, vec2(x, y), 0)); + } + return; + } + let norm = 1.0 / f32(2 * r + 1); + var acc = vec4(0.0); + for (var i = b0 - r; i <= b0 + r; i++) { + acc += textureLoad(src, vec2(((i % w) + w) % w, y), 0); + } + for (var x = b0; x < b1; x++) { + textureStore(out, vec2(x, y), acc * norm); + let add = textureLoad(src, vec2((x + r + 1) % w, y), 0); + let sub = textureLoad(src, vec2(((x - r) % w + w) % w, y), 0); + acc += add - sub; + } +} + +// vr::box_cols_pole's fetch. +fn fxv_pole_at(x0: i32, y0: i32, w: i32, h: i32) -> vec4 { + var x = x0; + var y = y0; + if (y < 0) { + y = -1 - y; + x = (x + w / 2) % w; + } else if (y >= h) { + y = 2 * h - 1 - y; + x = (x + w / 2) % w; + } + return textureLoad(src, vec2(x, clamp(y, 0, h - 1)), 0); +} + +// Vertical box mean continuing over the poles. u[0] = (block, radius); dispatched over +// (column, block). +@compute @workgroup_size(64, 1) +fn fxv_box_v(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let x = i32(gid.x); + let block = i32(P.u[0].x); + let r = i32(P.u[0].y); + let b0 = i32(gid.y) * block; + if (x >= dims.x || b0 >= dims.y) { + return; + } + let norm = 1.0 / f32(2 * r + 1); + var acc = vec4(0.0); + for (var j = b0 - r; j <= b0 + r; j++) { + acc += fxv_pole_at(x, j, dims.x, dims.y); + } + let b1 = min(b0 + block, dims.y); + for (var y = b0; y < b1; y++) { + textureStore(out, vec2(x, y), acc * norm); + acc += fxv_pole_at(x, y + r + 1, dims.x, dims.y) - fxv_pole_at(x, y - r, dims.x, dims.y); + } +} + +// vr::wrap_pad: u[0].x = pad. +@compute @workgroup_size(16, 16) +fn fxv_wrap_pad(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let s = vec2(textureDimensions(src)); + let pad = i32(P.u[0].x); + var sx = p.x - pad; + var sy = p.y - pad; + if (sy < 0) { + sy = -1 - sy; + sx += s.x / 2; + } else if (sy >= s.y) { + sy = 2 * s.y - 1 - sy; + sx += s.x / 2; + } + textureStore(out, p, textureLoad(src, vec2(((sx % s.x) + s.x) % s.x, clamp(sy, 0, s.y - 1)), 0)); +} + +// Per-pixel steps. u[0].x = 0: VR Glow's highlights (u[0].y tint; f[0] = (threshold, +// saturation); f[1] = tint); 1: add the glow (aux) × f[0].x; 2: VR Sharpen (aux = blurred, +// f[0].x = amount); 3: VR De-Noise's detail mix (src = filtered, aux = original, f[0].x); +// 4: the guided filter's guide (straight luminance). +@compute @workgroup_size(16, 16) +fn fxv_point(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let px = textureLoad(src, p, 0); + let q = tex_get(aux, p.x, p.y); + var o = px; + switch (P.u[0].x) { + case 0u: { + let s = fxv_unpremul(px); + let th = P.f[0].x; + let l = luminance(s.xyz); + var k = 0.0; + if (l > th) { + k = min((l - th) / max(1.0 - th, 1e-3), 4.0); + } + var c = s.xyz * k; + let lc = luminance(c); + c = lc + (c - lc) * P.f[0].y; + if (P.u[0].y != 0u) { + c = lc * P.f[1].xyz; + } + o = vec4(c * s.w, s.w * min(k, 1.0)); + } + case 1u: { + let b = P.f[0].x; + o = vec4(px.xyz + q.xyz * b, min(px.w + q.w * b, 1.0)); + } + case 2u: { + let hi = max(px.w, 0.0) * 4.0; + let c = clamp(px.xyz + (px.xyz - q.xyz) * P.f[0].x, vec3(0.0), vec3(hi)); + o = vec4(c, px.w); + } + case 3u: { + var m = px + (q - px) * P.f[0].x; + m.w = clamp(m.w, 0.0, 1.0); + o = vec4(max(m.xyz, vec3(0.0)), m.w); + } + case 4u: { + o = vec4(luminance(fxv_unpremul(px).xyz)); + } + default: {} + } + textureStore(out, p, o); +} + +// vr::composite of a generated straight colour (alpha 1) over the pixel. u[0].z = 1: mode None +// (replace); u[0].w = blend mode id; f[0].y = opacity. +fn fxv_composite(d: vec4, c: vec3, opacity: f32) -> vec4 { + let s = vec4(c * opacity, opacity); + if (P.u[0].z != 0u) { + return s; + } + var r = blend_pixel(P.u[0].w, d, s, 0.5); + let a = d.w; + if (a < 1.0) { + let k = a / max(r.w, 1e-6); + if (k < 1.0) { + r = r * k; + } + } + return r; +} + +// Generated looks. u[0].x = 0: VR Chromatic Aberrations; 1: VR Color Gradients; 2: VR Fractal +// Noise; 3: VR Digital Glitch (parameter layouts in fx_vr.rs). +@compute @workgroup_size(16, 16) +fn fxv_gen(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let w = f32(dims.x); + let h = f32(dims.y); + let fx = f32(p.x) + 0.5; + let fy = f32(p.y) + 0.5; + let px = textureLoad(src, p, 0); + var o = px; + switch (P.u[0].x) { + // f[0] = (aberration R, G, B, falloff); f[1] = centre; u[0].y = invert. + case 0u: { + let d = fxv_equi_dir(fx, fy, w, h); + let center = P.f[1].xyz; + let ax = cross(center, d); + let al = length(ax); + let th = atan2(al, dot(center, d)); + var weight = powz(th / FXV_PI, 1.0 + P.f[0].w * 3.0); + if (P.u[0].y != 0u) { + weight = 1.0 - weight; + } + var res = vec4(0.0); + for (var c = 0; c < 3; c++) { + var s = px; + if (al >= 1e-9) { + let new_th = th * (1.0 - P.f[0][c] * weight); + let perp = normalize(d - center * cos(th)); + let nd = center * cos(new_th) + perp * sin(new_th); + let q = fxv_equi_px(nd, w, h); + s = fxv_sample_equi(q.x, q.y); + } + res[c] = s[c]; + res.w = max(res.w, s.w); + } + o = res; + } + // data: points (direction, colour); u[0].y = count; f[0] = (power, opacity). + case 1u: { + let d = fxv_equi_dir(fx, fy, w, h); + let n = P.u[0].y; + var amin = 10.0; + var exact = -1; + for (var i = 0u; i < n; i++) { + let a = fxv_angle(d, vec3(data[i * 6u], data[i * 6u + 1u], data[i * 6u + 2u])); + if (a < 1e-6 && exact < 0) { + exact = i32(i); + } + amin = min(amin, a); + } + var c: vec3; + if (exact >= 0) { + let e = u32(exact); + c = vec3(data[e * 6u + 3u], data[e * 6u + 4u], data[e * 6u + 5u]); + } else { + var acc = vec3(0.0); + var ws = 0.0; + for (var i = 0u; i < n; i++) { + let a = fxv_angle(d, vec3(data[i * 6u], data[i * 6u + 1u], data[i * 6u + 2u])); + let wgt = pow(amin / a, P.f[0].x); + acc += vec3(data[i * 6u + 3u], data[i * 6u + 4u], data[i * 6u + 5u]) * wgt; + ws += wgt; + } + c = acc / ws; + } + o = fxv_composite(px, c, P.f[0].y); + } + // u[0].y = type; u[1] = (octaves, seed, invert); f[0] = (contrast, brightness, sub + // influence, sub scaling); f[1] = (base frequency, evolution, opacity, octave fraction); + // f[2..5] = rotation rows. + case 2u: { + let d = fxv_mv(2u, fxv_equi_dir(fx, fy, w, h)); + let kind = P.u[0].y; + let n_oct = P.u[1].x; + let evo = P.f[1].y; + var v = 0.0; + var best = 0.0; + var amp = 1.0; + var freq = P.f[1].x; + var norm = 0.0; + for (var oc = 0u; oc < n_oct; oc++) { + var wgt = 1.0; + if (oc + 1u == n_oct && P.f[1].w > 0.0) { + wgt = P.f[1].w; + } + let q = vec3(d.x * freq + evo * 0.37, d.y * freq + evo * 0.71, d.z * freq + evo * 0.59); + var nv = value_noise(q.x + f32(oc) * 17.3, q.y, q.z, P.u[1].y + oc); + if (kind == 1u) { + nv = 1.0 - abs(nv * 2.0 - 1.0); + } else if (kind == 3u) { + let t = clamp(abs(nv * 2.0 - 1.0) / 0.12, 0.0, 1.0); + nv = 1.0 - t * t; + } + best = max(best, nv * amp); + v += nv * amp * wgt; + norm += amp * wgt; + amp *= P.f[0].z; + freq /= P.f[0].w; + } + if (kind == 2u) { + v = best; + } else { + v = v / max(norm, 1e-6); + } + v = (v - 0.5) * P.f[0].x + 0.5 + P.f[0].y; + if (P.u[1].z != 0u) { + v = 1.0 - v; + } + v = clamp(v, 0.0, 1.0); + o = fxv_composite(px, vec3(v), P.f[1].z); + } + // data: per row (dx, dy, split, dark); f[0] = channel offsets; f[1] = target direction; + // f[2] = (radius, feather) in radians. + case 3u: { + let y = u32(p.y); + let dx = data[y * 4u]; + let dy = data[y * 4u + 1u]; + let split = data[y * 4u + 2u]; + let dark = data[y * 4u + 3u]; + let radius = P.f[2].x; + let feather = P.f[2].y; + var k = 1.0; + if (radius > 0.0) { + let a = fxv_angle(fxv_equi_dir(fx, fy, w, h), P.f[1].xyz); + if (a <= radius) { + k = 1.0; + } else if (feather > 0.0) { + k = clamp(1.0 - (a - radius) / feather, 0.0, 1.0); + } else { + k = 0.0; + } + } + if (k > 0.0) { + let sx = fx - dx * k; + let sy = clamp(fy - dy * k, 0.5, h - 0.5); + let c0 = fxv_sample_equi(sx + P.f[0].x * split * k, sy); + let c1 = fxv_sample_equi(sx + P.f[0].y * split * k, sy); + let c2 = fxv_sample_equi(sx + P.f[0].z * split * k, sy); + let dk = 1.0 - (1.0 - dark) * k; + o = vec4(c0.x * dk, c1.y * dk, c2.z * dk, max(max(c0.w, c1.w), c2.w)); + } + } + default: {} + } + textureStore(out, p, o); +} diff --git a/crates/gpu/src/tests_fx_vr.rs b/crates/gpu/src/tests_fx_vr.rs new file mode 100644 index 00000000..1c1db57c --- /dev/null +++ b/crates/gpu/src/tests_fx_vr.rs @@ -0,0 +1,154 @@ +//! GPU Immersive Video family vs the CPU effects (the oracle): direct on a buffer (full and half +//! resolution, as adjustment) and composited at 8 and 32 bpc, in mono and both stereo layouts. +//! +//! Exception: re-projections that cross a cube-map face edge or a fisheye rim may pick the +//! neighbouring face / side for directions within f32 rounding of the edge (the CPU decides in +//! f64), so up to 0.5 % of the pixels of those cases may differ. + +use effectcraft_keyframe::Value; +use effectcraft_project::BitDepth; +use effectcraft_render::RenderOpts; +use effectcraft_time::Tick; + +use crate::tests::{Scene, c, check, compare_at, diff, gpu, n, opts, pattern, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +/// The effect alone on a buffer (`allow` = fraction of pixels allowed over 1e-3). +fn direct(id: &str, vals: &[(&str, Value)], allow: f64) { + let Some(g) = gpu() else { return }; + let spec = effectcraft_effects::find(id).unwrap(); + let size = [96.0, 48.0]; + let mut params = + effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() }; + for (k, v) in vals { + params.values.insert(k.to_string(), v.clone()); + } + for adjustment in [false, true] { + for scale in [1.0, 0.5] { + let img = pattern(7, (size[0] * scale) as u32, (size[1] * scale) as u32); + let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale }; + let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.4, layer_size: size, seed: 11, adjustment, env: Default::default() }; + let cpu = (spec.render)(&ctx(), buf.clone()); + let out = + effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect"); + assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "{id}: geometry"); + let d = diff(&cpu.img, &out.img, 1e-3); + assert!( + d.over as f64 <= allow * d.total as f64, + "{id} {vals:?} adj {adjustment} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}", + d.over, + d.total, + d.max, + d.worst + ); + } + } +} + +fn case_allow(id: &str, vals: &[(&str, Value)], allow: f64) { + direct(id, vals, allow); + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut l = s.footage(96, 48); + s.effect(&mut l, id, vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + check(&format!("{id} {vals:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), allow); + check(&format!("{id} {vals:?} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), allow); + } +} + +fn case(id: &str, vals: &[(&str, Value)]) { + case_allow(id, vals, 0.0); +} + +/// `vals` in mono and in both stereo layouts. +fn layouts(id: &str, vals: &[(&str, Value)]) { + for l in 0..3 { + case(id, &[vals, &[("frameLayout", e(l))]].concat()); + } +} + +#[test] +fn blur_sharpen_glow() { + layouts("ec.vr.blur", &[("blurriness", n(12.0))]); + case("ec.vr.blur", &[("blurriness", n(60.0))]); + layouts("ec.vr.sharpen", &[("sharpenAmount", n(60.0))]); + layouts("ec.vr.glow", &[("luminanceThreshold", n(40.0)), ("glowRadius", n(10.0))]); + case("ec.vr.glow", &[("useTintColor", on()), ("glowSaturation", n(150.0)), ("glowBrightness", n(250.0))]); +} + +#[test] +fn denoise() { + layouts("ec.vr.denoise", &[("noiseLevel", n(40.0))]); + case("ec.vr.denoise", &[("noiseLevel", n(60.0)), ("detail", n(30.0))]); + case("ec.vr.denoise", &[("noiseType", e(1)), ("noiseLevel", n(50.0))]); + case("ec.vr.denoise", &[("noiseType", e(1)), ("noiseLevel", n(80.0)), ("frameLayout", e(1))]); +} + +#[test] +fn rotate_and_planes() { + layouts("ec.vr.rotatesphere", &[("tilt", n(20.0)), ("pan", n(35.0)), ("roll", n(-10.0))]); + case("ec.vr.rotatesphere", &[("tilt", n(-40.0)), ("pan", n(170.0)), ("invertRotation", on())]); + layouts("ec.vr.spheretoplane", &[("horizontalFov", n(100.0)), ("tilt", n(10.0)), ("pan", n(-30.0))]); + case("ec.vr.planetosphere", &[("scale", n(80.0)), ("pan", n(20.0)), ("tilt", n(-15.0))]); + case("ec.vr.planetosphere", &[("scale", n(120.0)), ("feather", n(30.0)), ("roll", n(25.0))]); +} + +#[test] +fn converter() { + for from in 0..9 { + for to in [0u32, 1, 7] { + if from == to { + continue; + } + case_allow( + "ec.vr.converter", + &[("sourceProjection", e(from)), ("targetProjection", e(to)), ("sourceHorizontalFov", n(200.0)), ("pan", n(15.0))], + 0.005, + ); + } + } + for to in [2u32, 3, 4, 5, 6, 8] { + case_allow("ec.vr.converter", &[("targetProjection", e(to)), ("targetHorizontalFov", n(120.0)), ("tilt", n(12.0))], 0.005); + } + case_allow("ec.vr.converter", &[("targetProjection", e(1)), ("frameLayout", e(1))], 0.005); + case_allow("ec.vr.converter", &[("targetProjection", e(6)), ("frameLayout", e(2)), ("roll", n(30.0))], 0.005); +} + +#[test] +fn generated() { + layouts("ec.vr.chromaticaberrations", &[("aberrationRed", n(40.0)), ("aberrationBlue", n(-30.0))]); + case("ec.vr.chromaticaberrations", &[("centerTilt", n(30.0)), ("centerPan", n(-60.0)), ("falloff", n(10.0)), ("falloffInvert", on())]); + layouts("ec.vr.colorgradients", &[]); + case("ec.vr.colorgradients", &[("blend", n(5.0)), ("opacity", n(60.0)), ("blendingMode", e(5)), ("enablePoint5", on())]); + for mode in 1..5 { + case("ec.vr.colorgradients", &[("blendingMode", e(mode)), ("color2", c(0.2, 0.9, 0.4))]); + } + layouts("ec.vr.fractalnoise", &[]); + for kind in 1..4 { + case("ec.vr.fractalnoise", &[("fractalType", e(kind)), ("complexity", n(4.5)), ("evolution", n(70.0)), ("transform/pan", n(30.0))]); + } + case("ec.vr.fractalnoise", &[("blendingMode", e(4)), ("opacity", n(70.0)), ("invert", on()), ("transform/scale", n(250.0))]); + layouts("ec.vr.digitalglitch", &[]); + case( + "ec.vr.digitalglitch", + &[ + ("distortionEvolution", n(100.0)), + ("geometric/verticalDisplacement", n(80.0)), + ("target/radius", n(60.0)), + ("target/feather", n(30.0)), + ("target/pan", n(40.0)), + ("scanlineSpacing", n(3.0)), + ], + ); +} diff --git a/docs/effects.md b/docs/effects.md index 07219d9c..26310ace 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -5,7 +5,7 @@ EffectCraft ships 306 effects with 3009 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. -- **GPU**: 181 effects also run on the GPU compositor with identical results. +- **GPU**: 193 effects also run on the GPU compositor with identical results. - **32**: 306 effects process 32-bit float (HDR, overbright) pixels without clamping. - **Status**: 306 implemented in full, 0 partial (what is missing is listed). @@ -219,18 +219,18 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| VR Blur | `ec.vr.blur` | 2 | | 32 | Implemented | -| VR Chromatic Aberrations | `ec.vr.chromaticaberrations` | 8 | | 32 | Implemented | -| VR Color Gradients | `ec.vr.colorgradients` | 19 | | 32 | Implemented | -| VR Converter | `ec.vr.converter` | 8 | | 32 | Implemented | -| VR De-Noise | `ec.vr.denoise` | 4 | | 32 | Implemented | -| VR Digital Glitch | `ec.vr.digitalglitch` | 21 | | 32 | Implemented | -| VR Fractal Noise | `ec.vr.fractalnoise` | 16 | | 32 | Implemented | -| VR Glow | `ec.vr.glow` | 7 | | 32 | Implemented | -| VR Plane to Sphere | `ec.vr.planetosphere` | 6 | | 32 | Implemented | -| VR Rotate Sphere | `ec.vr.rotatesphere` | 5 | | 32 | Implemented | -| VR Sharpen | `ec.vr.sharpen` | 2 | | 32 | Implemented | -| VR Sphere to Plane | `ec.vr.spheretoplane` | 5 | | 32 | Implemented | +| VR Blur | `ec.vr.blur` | 2 | GPU | 32 | Implemented | +| VR Chromatic Aberrations | `ec.vr.chromaticaberrations` | 8 | GPU | 32 | Implemented | +| VR Color Gradients | `ec.vr.colorgradients` | 19 | GPU | 32 | Implemented | +| VR Converter | `ec.vr.converter` | 8 | GPU | 32 | Implemented | +| VR De-Noise | `ec.vr.denoise` | 4 | GPU | 32 | Implemented | +| VR Digital Glitch | `ec.vr.digitalglitch` | 21 | GPU | 32 | Implemented | +| VR Fractal Noise | `ec.vr.fractalnoise` | 16 | GPU | 32 | Implemented | +| VR Glow | `ec.vr.glow` | 7 | GPU | 32 | Implemented | +| VR Plane to Sphere | `ec.vr.planetosphere` | 6 | GPU | 32 | Implemented | +| VR Rotate Sphere | `ec.vr.rotatesphere` | 5 | GPU | 32 | Implemented | +| VR Sharpen | `ec.vr.sharpen` | 2 | GPU | 32 | Implemented | +| VR Sphere to Plane | `ec.vr.spheretoplane` | 5 | GPU | 32 | Implemented | ## Keying