Merge branch 'worktree-agent-a51902829f462d739'

This commit is contained in:
Brandon Thomas 2026-10-04 00:26:41 -04:00
commit 3ee5823429
31 changed files with 5107 additions and 251 deletions

View File

@ -29,7 +29,7 @@ Measured feature by feature in [docs/parity.md](docs/parity.md) (3 October 2026,
- **≈ 98% of After Effects' features, weighted by importance**, counting partial features as half
done (≈ 99.6% with per-feature fractions): 87 of 92 features done, 5 partial, none missing; all
306 effects implemented in full, 166 of them on the GPU.
306 effects implemented in full, 236 of them on the GPU.
- **≈ 9–11 agent-hours of work remain**: about **2–2.5 hours of wall-clock time** with five Claude
Opus 5.5 agents in parallel, ≈ 1.5 hours for the essentials and daily-use features.
- What is left: more GPU effects, GPU effects and a disk cache in the web app, a template gallery

View File

@ -63,10 +63,7 @@ pub fn param_shown(effect: &str, param: &str, value: &dyn Fn(&str) -> Option<eff
/// Controls of GPU-accelerated effects that only the CPU implements: while any of them is away
/// from its default the effect renders on the CPU. (Prefixes ending in `/` cover a whole group.)
const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[
(
"ec.color.lumetri",
&["highDynamicRange", "basicCorrection/inputLut", "creative/look", "curves/rgbCurves/", "curves/hueSaturationCurves/", "hslSecondary/"],
),
("ec.color.lumetri", &["highDynamicRange", "curves/rgbCurves/", "curves/hueSaturationCurves/", "hslSecondary/"]),
(
"ec.color.colorama",
&["inputPhase/addPhase", "outputCycle/usePresetPalette", "modify/", "pixelSelection/matchingMode", "masking/maskingMode", "masking/compositeOverLayer"],
@ -100,6 +97,19 @@ pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool {
match id {
"ec.generate.cellpattern" if (6..=10).contains(&ctx.params.e("cellPattern")) => return false,
"ec.distort.turbulentdisplace" if ctx.params.e("pinning") >= 8 => return false,
// Shatter's wireframe views; Foam's User Defined texture, Environment Map and flow-map
// preview (the GPU draws the rendered pieces and the sprite bubbles only).
"ec.sim.shatter" if ctx.params.e("view") != 0 => return false,
"ec.sim.foam" => {
let set = |id: &str| matches!(ctx.params.get(id), Some(effectcraft_keyframe::Value::Layer(Some(_))));
let view = ctx.params.e("view");
if (view == 2 && ctx.params.e("rendering/bubbleTexture") == 5)
|| (view == 2 && ctx.params.f("rendering/reflectionStrength") > 0.0 && set("rendering/environmentMap"))
|| (view == 1 && set("flowMap/flowMap"))
{
return false;
}
}
// 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 /

View File

@ -53,7 +53,7 @@ fn plane_of(b: &Buf, idx: &[Option<usize>], ch: &[f32], fill: f32) -> Plane {
}
/// Distinct colour for an ID (hash → hue).
fn id_color(id: f32) -> [f32; 3] {
pub fn id_color(id: f32) -> [f32; 3] {
let h = hash1(id.to_bits(), 0x1d, 0x5eed);
let (r, g, b) = effectcraft_color::hsl_to_rgb(h, 0.75, 0.5);
if id == 0.0 { [0.0; 3] } else { [r, g, b] }

View File

@ -98,6 +98,13 @@ 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;
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
pub use channel3d::{CRYPTO_LAYERS, id_color, selection_hashes};
pub use ocio::{ColorOp, Straight, Tf, color_program};
pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan};
pub use sim2::{CausticsSetup, Piece, PiecePlan, PieceTex, WavePlan, Waves, caustics_setup, piece_plan, wave_world_plan};
pub use utility::{Lut, load_lut};
pub use vr::{equi_dir as vr_equi_dir, rotation as vr_rotation};
/// Effect categories in Effects & Presets order.
pub const CATEGORIES: &[&str] = &[
@ -932,6 +939,50 @@ pub const GPU_EFFECTS: &[&str] = &[
"ec.perspective.bevelalpha",
"ec.perspective.beveledges",
"ec.obsolete.gaussianlegacy",
// GPU effects, part B: 3D Channel.
"ec.3d.channelextract",
"ec.3d.cryptomatte",
"ec.3d.depthmatte",
"ec.3d.depthoffield",
"ec.3d.extractor",
"ec.3d.fog3d",
"ec.3d.idmatte",
"ec.3d.identifier",
// Colour management.
"ec.utility.applylut",
"ec.color.ociocdl",
"ec.color.ociocolorspace",
"ec.color.ociodisplay",
"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",
// Simulation render passes.
"ec.sim.ccrainfall",
"ec.sim.ccsnowfall",
"ec.sim.ccstarburst",
"ec.sim.ccbubbles",
"ec.sim.ccdrizzle",
"ec.sim.cchair",
"ec.sim.ccmrmercury",
"ec.sim.caustics",
"ec.sim.waveworld",
"ec.sim.foam",
"ec.sim.shatter",
"ec.sim.carddance",
"ec.transition.cardwipe",
// effectcraft-gpu fx_light (the CC light family)
"ec.generate.cclightrays",
"ec.generate.cclightburst",

View File

@ -865,6 +865,177 @@ fn profile_converter(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
// ---- Colour programs (the GPU compositor's colour-management kernels)
/// One per-pixel step of a colour transform, in the order the CPU effect applies them (see
/// [`color_program`]).
#[derive(Clone, Debug)]
pub enum ColorOp {
/// Transfer function, encoded → linear.
Decode(Tf),
/// Transfer function, linear → encoded.
Encode(Tf),
/// Linear-RGB matrix.
Matrix([[f32; 3]; 3]),
/// ASC CDL (forward or exact inverse; `clamp` = the v1.2 style).
Cdl { cdl: Cdl, clamp: bool, inverse: bool },
/// The Tone Mapped view's roll-off (or its inverse).
Tonemap { inverse: bool },
/// Color Profile Converter's perceptual gamut compression toward luminance `luma`.
Gamut([f64; 3]),
/// Negative channels to 0 (the saturation intent).
Max0,
/// A LUT (0 nearest, 1 trilinear, 2 tetrahedral) with an optional per-channel shaper, forward
/// or inverse (as [`FileXform::apply`]).
Lut { lut: Arc<Lut>, shaper: Option<Arc<[Vec<(f32, f32)>; 3]>>, interp: u32, inverse: bool },
}
/// How a colour program meets premultiplied pixels.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Straight {
/// `unpremul` (colour 0 below alpha 1e-6); fully transparent pixels untouched (OCIO effects).
Unpremul,
/// Colour ÷ alpha; fully transparent pixels untouched (`Image::map_straight`, Apply Color LUT).
Divide,
}
fn xform_ops(x: &Xform, ops: &mut Vec<ColorOp>) {
if x.dec != Tf::Linear {
ops.push(ColorOp::Decode(x.dec));
}
if !x.ident {
ops.push(ColorOp::Matrix(x.m));
}
if x.enc != Tf::Linear {
ops.push(ColorOp::Encode(x.enc));
}
}
/// Effect `id`'s per-pixel transform as a list of [`ColorOp`]s that reproduces the CPU effect
/// (empty = the layer passes through). `None` when it has no such form (a custom OCIO config,
/// another effect).
pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec<ColorOp>, Straight)> {
let pr = ctx.params;
let mut ops = vec![];
match id {
"ec.utility.applylut" => {
if let Some(lut) = crate::utility::load_lut(pr.s("lut")) {
ops.push(ColorOp::Lut { lut, shaper: None, interp: 2, inverse: false });
}
return Some((ops, Straight::Divide));
}
"ec.color.ociocolorspace" => {
if pr.e("config") == 1 {
return custom_config(ctx).is_none().then_some((ops, Straight::Unpremul));
}
let (a, z) = (space_at(pr.e("source")), space_at(pr.e("destination")));
let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) };
if a.name != z.name {
xform_ops(&Xform::new(a, z), &mut ops);
}
}
"ec.color.ociocdl" => {
let cdl = cdl_from_params(pr);
if cdl != Cdl::default() {
ops.push(ColorOp::Cdl { cdl, clamp: pr.e("style") == 0, inverse: pr.e("direction") == 1 });
}
}
"ec.color.ociodisplay" => {
if pr.e("config") == 1 {
return custom_config(ctx).is_none().then_some((ops, Straight::Unpremul));
}
let input = space_at(pr.e("source"));
let (display, view, inverse) = (pr.e("display") as usize, pr.e("view") as usize, pr.e("direction") == 1);
let (_, prims, tf) = DISPLAYS[display.min(DISPLAYS.len() - 1)];
if view != 2 && !input.raw {
let disp = Space { name: "display", prims: Some((prims, D65)), tf: Tf::Linear, raw: false };
let lin = Space { tf: Tf::Linear, ..*input };
if !inverse {
ops.push(ColorOp::Decode(input.tf));
xform_ops(&Xform::new(&lin, &disp), &mut ops);
if view == 1 {
ops.push(ColorOp::Tonemap { inverse: false });
}
ops.push(ColorOp::Encode(tf));
} else {
ops.push(ColorOp::Decode(tf));
if view == 1 {
ops.push(ColorOp::Tonemap { inverse: true });
}
xform_ops(&Xform::new(&disp, &lin), &mut ops);
ops.push(ColorOp::Encode(input.tf));
}
}
}
"ec.color.ociofile" => {
if let Some(x) = load_file(pr.s("file")) {
let interp = match pr.e("interpolation") {
0 => 0,
1 => 1,
_ => 2,
};
let inverse = pr.e("direction") == 1;
match &*x {
FileXform::Cdl(list) => {
let id = pr.s("cccId");
let cdl = list.iter().find(|(i, _)| !id.is_empty() && i == id).or(list.first()).map(|(_, c)| *c).unwrap_or_default();
ops.push(ColorOp::Cdl { cdl, clamp: true, inverse });
}
FileXform::Lut { lut, shaper } => {
ops.push(ColorOp::Lut { lut: Arc::new(lut.clone()), shaper: shaper.clone().map(Arc::new), interp, inverse });
}
}
}
}
"ec.color.ociolook" => {
let mut list = parse_looks(pr.s("looks"));
let pick = pr.e("look");
if pick > 0
&& let Some((_, c)) = looks().get(pick as usize - 1)
{
list.insert(0, (*c, false));
}
if !list.is_empty() {
let (src, dst) = (space_at(pr.e("source")), space_at(pr.e("destination")));
let inv = pr.e("direction") == 1;
let cct = space_by_name("ACEScct").expect("built-in");
let (a, z) = if inv { (dst, src) } else { (src, dst) };
if inv {
list.reverse();
}
xform_ops(&Xform::new(a, cct), &mut ops);
for (cdl, li) in &list {
ops.push(ColorOp::Cdl { cdl: *cdl, clamp: false, inverse: *li != inv });
}
xform_ops(&Xform::new(cct, z), &mut ops);
}
}
"ec.utility.colorprofileconverter" => {
let (inp, in_lin_default) = profile(ctx, pr.e("inputProfile"));
let (out, out_lin_default) = profile(ctx, pr.e("outputProfile"));
let in_lin = pr.b("linearizeInputProfile") || in_lin_default && pr.e("inputProfile") == 0;
let out_lin = pr.b("linearizeOutputProfile") || out_lin_default && pr.e("outputProfile") == 0;
let intent = pr.e("intent");
let dec = if in_lin { Tf::Linear } else { inp.tf };
let enc = if out_lin { Tf::Linear } else { out.tf };
let m = if intent == 3 { mul(&invert(&out.to_ref(false)), &inp.to_ref(false)) } else { matrix(&inp, &out) };
let identity = dec == enc && (0..3).all(|i| (0..3).all(|j| (m[i][j] - IDENTITY[i][j]).abs() < 1e-9));
if !identity {
ops.push(ColorOp::Decode(dec));
ops.push(ColorOp::Matrix(m.map(|r| r.map(|v| v as f32))));
match intent {
0 => ops.push(ColorOp::Gamut(out.to_ref(true)[1])),
2 => ops.push(ColorOp::Max0),
_ => {}
}
ops.push(ColorOp::Encode(enc));
}
}
_ => return None,
}
Some((ops, Straight::Unpremul))
}
// ---- Color Stabilizer
/// Mean straight colour of a square (half-size `r` layer pixels) around layer point `pt`.

View File

@ -48,7 +48,7 @@ const BAND: usize = 16;
/// How rasterised items combine with what is already in the target.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Acc {
pub enum Acc {
/// Premultiplied "over" (later items on top).
Over,
/// Additive (glows, light).
@ -128,7 +128,7 @@ pub(crate) fn raster<T: Sync>(
// ------------------------------------------------------------------ sprites
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) enum Shape {
pub enum Shape {
/// Soft round dot (Gaussian-like falloff).
Soft,
/// Hard anti-aliased disc.
@ -151,7 +151,7 @@ pub(crate) enum Shape {
/// A particle sprite in buffer pixels; `c` is straight colour + opacity.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Sprite {
pub struct Sprite {
pub x: f32,
pub y: f32,
pub r: f32,
@ -166,6 +166,112 @@ impl Sprite {
}
}
/// Where a sprite's colour comes from when it depends on the layer's pixels (resolved by
/// [`SpritePlan::resolve`] on the CPU, per pixel by the GPU compositor). Positions are buffer
/// pixels.
#[derive(Clone, Copy, Debug)]
pub enum Tint {
/// The sprite's own colour.
Fixed,
/// CC Rainfall's refracting drops: where the layer shows (alpha > 0), colour × (layer
/// colour × 0.6 + 0.5) sampled at (x, y).
Refract { x: f64, y: f64 },
/// CC Star Burst: the layer's colour at (x, y), opacity × its alpha (none where clear).
Source { x: f64, y: f64 },
/// CC Hair: the root's colour at (x, y) mixed with the hair colour (`inherit` = 1 keeps the
/// root's), × `shade`, + `spec`; no strand where the root's alpha is below ½.
Root { x: f64, y: f64, hair: [f32; 3], inherit: f32, shade: f32, spec: f32 },
}
/// How a sprite layer meets the original layer.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Post {
/// [`combine`] with this mode (0 Composite … 5 sprites only).
Combine(u32),
/// The sprites alone.
Replace,
/// The sprites, mixed toward the original by this fraction.
Lerp(f32),
/// The sprites over opaque black.
OverBlack,
}
/// What a sprite-drawing effect draws this frame: built on the CPU (closed-form or from the
/// simulation state), rasterised by [`SpritePlan::finish`] or the GPU compositor.
#[derive(Clone, Debug)]
pub struct SpritePlan {
pub sprites: Vec<Sprite>,
/// Per sprite (empty = all [`Tint::Fixed`]).
pub tints: Vec<Tint>,
pub acc: Acc,
pub post: Post,
}
impl SpritePlan {
/// Resolve layer-dependent colours against the layer `src`.
pub fn resolve(&mut self, src: &Image) {
let tints = std::mem::take(&mut self.tints);
self.sprites.par_iter_mut().zip(tints.par_iter()).for_each(|(sp, t)| match *t {
Tint::Fixed => {}
Tint::Refract { x, y } => {
let (sc, sa) = unpremul(src.sample_bilinear(x, y));
if sa > 0.0 {
let c = sp.c;
sp.c = [(sc[0] * 0.6 + 0.5) * c[0], (sc[1] * 0.6 + 0.5) * c[1], (sc[2] * 0.6 + 0.5) * c[2], c[3]];
}
}
Tint::Source { x, y } => {
let (c, a) = unpremul(src.sample_bilinear(x, y));
sp.c = if a <= 0.0 { [0.0; 4] } else { [c[0], c[1], c[2], a * sp.c[3]] };
}
Tint::Root { x, y, hair, inherit, shade, spec } => {
let root = src.sample_bilinear(x, y);
if root[3] < 0.5 {
sp.c = [0.0; 4];
return;
}
let (rc, _) = unpremul(root);
let base: [f32; 3] = std::array::from_fn(|k| rc[k] + (hair[k] - rc[k]) * (1.0 - inherit));
sp.c = [base[0] * shade + spec, base[1] * shade + spec, base[2] * shade + spec, sp.c[3]];
}
});
}
/// Rasterise (after [`SpritePlan::resolve`]) and meet the original layer `b`.
pub fn finish(&self, mut b: Buf) -> Buf {
let fx = splat(b.img.width, b.img.height, &self.sprites, self.acc);
b.img = match self.post {
Post::Combine(m) => combine(&b.img, &fx, m),
Post::Replace => fx,
Post::Lerp(t) => {
let mut o = fx;
o.data.par_iter_mut().zip(b.img.data.par_iter()).for_each(|(o, s0)| *o = crate::util::lerp4(*o, *s0, t));
o
}
Post::OverBlack => combine(&Image::filled(b.img.width, b.img.height, [0.0, 0.0, 0.0, 1.0]), &fx, 0),
};
b
}
}
/// The sprite plan of a sprite-drawing simulation effect for buffer geometry `b` (its pixels
/// are not read), `None` for other effects or settings drawn otherwise (the GPU compositor's
/// render pass).
pub fn sprite_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
match id {
"ec.sim.ccrainfall" => Some(rainfall_plan(ctx, b)),
"ec.sim.ccsnowfall" => Some(snowfall_plan(ctx, b)),
"ec.sim.ccstarburst" => Some(star_burst_plan(ctx, b)),
"ec.sim.cchair" => crate::sim3::hair_plan(ctx, b),
"ec.sim.waveworld" => crate::sim2::wave_world_plan(ctx, b).and_then(|p| match p {
crate::sim2::WavePlan::Wire(sp) => Some(sp),
crate::sim2::WavePlan::Height { .. } => None,
}),
"ec.sim.foam" => crate::sim2::foam_plan(ctx, b),
_ => None,
}
}
fn sprite_bbox(s: &Sprite) -> Option<[f32; 4]> {
if s.c[3] <= 1e-5 || s.r <= 0.0 {
return None;
@ -316,6 +422,12 @@ fn wrap(v: f32, lo: f32, span: f32) -> f32 {
}
fn rainfall(ctx: &EffectCtx, b: Buf) -> Buf {
let mut plan = rainfall_plan(ctx, &b);
plan.resolve(&b.img);
plan.finish(b)
}
fn rainfall_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
let pr = ctx.params;
let n = pr.f("drops").clamp(0.0, 100_000.0) as u32;
let size = pr.f("size").max(0.0) as f32;
@ -334,11 +446,10 @@ fn rainfall(ctx: &EffectCtx, b: Buf) -> Buf {
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let s = b.scale as f32;
let t = ctx.time as f32;
let src = &b.img;
let (ox, oy) = (off[0] as f32 - lw * 0.5, off[1] as f32 - lh * 0.5);
let sprites: Vec<Sprite> = (0..n)
let (sprites, tints): (Vec<Sprite>, Vec<Tint>) = (0..n)
.into_par_iter()
.filter_map(|i| {
.map(|i| {
let z = h(i, 1, seed);
let k = depth_k(z, depth);
let vy = speed / 4000.0 * lh * 2.2 * k;
@ -349,29 +460,25 @@ fn rainfall(ctx: &EffectCtx, b: Buf) -> Buf {
let y = wrap(h(i, 3, seed) * (lh + 2.0 * margin) + vy * t + oy, -margin, lh + 2.0 * margin);
let x = wrap(h(i, 4, seed) * (lw + 2.0 * margin) + w * t + ox, -margin, lw + 2.0 * margin);
let (bx, by) = b.to_px([x as f64, y as f64]);
let mut c = [color[0], color[1], color[2], opacity * (0.35 + 0.65 * k)];
if refract {
// Refracting drops show the scene behind them, brightened and offset.
let (sc, sa) = unpremul(src.sample_bilinear(bx + 2.0 * s as f64, by));
let m = |a: f32, cc: f32| (a * 0.6 + 0.5) * cc;
if sa > 0.0 {
c = [m(sc[0], color[0]), m(sc[1], color[1]), m(sc[2], color[2]), c[3]];
}
}
let c = [color[0], color[1], color[2], opacity * (0.35 + 0.65 * k)];
// Refracting drops show the scene behind them, brightened and offset.
let tint = if refract { Tint::Refract { x: bx + 2.0 * s as f64, y: by } } else { Tint::Fixed };
let r = (size * 0.6 * k * s).max(0.25);
Some(Sprite::new(bx as f32, by as f32, r, c, Shape::Line { dx: -lenx * s, dy: -len * s }))
(Sprite::new(bx as f32, by as f32, r, c, Shape::Line { dx: -lenx * s, dy: -len * s }), tint)
})
.collect();
weather_out(b, &sprites, mode, with_orig)
.unzip();
SpritePlan { sprites, tints, acc: Acc::Over, post: weather_post(mode, with_orig) }
}
fn weather_out(mut b: Buf, sprites: &[Sprite], mode: u32, with_orig: bool) -> Buf {
let fx = splat(b.img.width, b.img.height, sprites, Acc::Over);
b.img = if with_orig { combine(&b.img, &fx, if mode == 1 { 3 } else { 0 }) } else { fx };
b
fn weather_post(mode: u32, with_orig: bool) -> Post {
if with_orig { Post::Combine(if mode == 1 { 3 } else { 0 }) } else { Post::Replace }
}
fn snowfall(ctx: &EffectCtx, b: Buf) -> Buf {
snowfall_plan(ctx, &b).finish(b)
}
fn snowfall_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
let pr = ctx.params;
let n = pr.f("flakes").clamp(0.0, 200_000.0) as u32;
let size = pr.f("size").max(0.0) as f32;
@ -414,35 +521,36 @@ fn snowfall(ctx: &EffectCtx, b: Buf) -> Buf {
Sprite::new(bx as f32, by as f32, r * s, [color[0], color[1], color[2], opacity * (0.4 + 0.6 * k)], Shape::Soft)
})
.collect();
weather_out(b, &sprites, mode, with_orig)
SpritePlan { sprites, tints: vec![], acc: Acc::Over, post: weather_post(mode, with_orig) }
}
// ------------------------------------------------------------------ CC Bubbles
struct Bubble {
x: f32,
y: f32,
r: f32,
/// A CC Bubbles bubble in buffer pixels.
#[derive(Clone, Copy, Debug)]
pub struct Bubble {
pub x: f32,
pub y: f32,
pub r: f32,
/// Source sample centre (buffer px).
sx: f32,
sy: f32,
pub sx: f32,
pub sy: f32,
}
fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf {
/// CC Bubbles' bubbles this frame (closed-form in time) for buffer geometry `b`.
pub fn bubble_list(ctx: &EffectCtx, b: &Buf) -> Vec<Bubble> {
let pr = ctx.params;
let n = pr.f("bubbleAmount").clamp(0.0, 10_000.0) as u32;
let speed = pr.f("bubbleSpeed") as f32;
let wamp = pr.f("wobbleAmplitude") as f32;
let wfreq = pr.f("wobbleFrequency") as f32;
let bsize = pr.f("bubbleSize").max(0.0) as f32;
let metal = pr.e("reflectionType") == 1;
let shading = pr.e("shadingType");
let seed = ctx.seed.wrapping_mul(0x27d4);
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let s = b.scale as f32;
let t = ctx.time as f32;
let base = lw.min(lh) * 0.035 * bsize;
let list: Vec<Bubble> = (0..n)
(0..n)
.map(|i| {
let r = base * (0.5 + h(i, 1, seed));
let travel = lh + 2.0 * r;
@ -454,7 +562,14 @@ fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (sx, sy) = b.to_px([x0 as f64, y as f64]);
Bubble { x: bx as f32, y: by as f32, r: r * s, sx: sx as f32, sy: sy as f32 }
})
.collect();
.collect()
}
fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let metal = pr.e("reflectionType") == 1;
let shading = pr.e("shadingType");
let list = bubble_list(ctx, &b);
let src = b.img.clone();
let mut out = Image::new(src.width, src.height);
raster(
@ -495,28 +610,20 @@ fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf {
// ------------------------------------------------------------------ CC Drizzle
fn drizzle(ctx: &EffectCtx, mut b: Buf) -> Buf {
/// CC Drizzle's live drops this frame (buffer px: centre x, y, ring radius, amplitude);
/// `None` = no drops yet (the layer passes through).
pub fn drizzle_drops(ctx: &EffectCtx, b: &Buf) -> Option<Vec<[f32; 4]>> {
let pr = ctx.params;
let rate = pr.f("dripRate").max(0.0);
let life = pr.f("longevity").max(0.01);
let rippling = (pr.f("rippling") / 360.0).max(0.05) as f32;
let disp = pr.f("displacement") as f32;
let height = pr.f("rippleHeight") as f32 / 100.0;
let spreading = pr.f("spreading") as f32;
let li = pr.f("light/lightIntensity") as f32 / 100.0;
let lc = pr.color("light/lightColor");
let lheight = pr.f("light/lightHeight") as f32 / 100.0;
let ldir = (pr.f("light/lightDirection") as f32).to_radians();
let ambient = pr.f("shading/ambient") as f32 / 100.0;
let diffuse = pr.f("shading/diffuse") as f32 / 100.0;
let specular = pr.f("shading/specular") as f32 / 100.0;
let rough = pr.f("shading/roughness").max(0.001) as f32;
let height = pr.f("rippleHeight") as f32 / 100.0;
let seed = ctx.seed.wrapping_mul(0x165667b1);
let t = ctx.time;
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let s = b.scale as f32;
if rate <= 0.0 || t < 0.0 {
return b;
return None;
}
// Drops born at k / rate with hashed positions; alive while younger than the longevity.
let k0 = ((t - life) * rate).floor().max(0.0) as u32;
@ -531,13 +638,31 @@ fn drizzle(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (x, y) = b.to_px([(h(k, 1, seed) * lw) as f64, (h(k, 2, seed) * lh) as f64]);
let radius = spreading * age * lh / 400.0 * s;
let amp = height * (1.0 - age / life as f32) * (0.6 + 0.4 * h(k, 3, seed));
drops.push((x as f32, y as f32, radius, amp));
drops.push([x as f32, y as f32, radius, amp]);
}
Some(drops)
}
fn drizzle(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let Some(drops) = drizzle_drops(ctx, &b) else { return b };
let rippling = (pr.f("rippling") / 360.0).max(0.05) as f32;
let disp = pr.f("displacement") as f32;
let li = pr.f("light/lightIntensity") as f32 / 100.0;
let lc = pr.color("light/lightColor");
let lheight = pr.f("light/lightHeight") as f32 / 100.0;
let ldir = (pr.f("light/lightDirection") as f32).to_radians();
let ambient = pr.f("shading/ambient") as f32 / 100.0;
let diffuse = pr.f("shading/diffuse") as f32 / 100.0;
let specular = pr.f("shading/specular") as f32 / 100.0;
let rough = pr.f("shading/roughness").max(0.001) as f32;
let lh = ctx.layer_size[1] as f32;
let s = b.scale as f32;
let wavelen = (lh * 0.04 * s).max(2.0) / rippling.clamp(0.25, 4.0);
let width = wavelen * (1.0 + rippling * 2.0);
let field = |x: f32, y: f32| -> f32 {
let mut v = 0.0;
for &(dx, dy, r, a) in &drops {
for &[dx, dy, r, a] in &drops {
let d = ((x - dx).powi(2) + (y - dy).powi(2)).sqrt();
let u = (d - r) / width;
if u.abs() < 1.0 {
@ -586,7 +711,13 @@ fn drizzle(ctx: &EffectCtx, mut b: Buf) -> Buf {
// ------------------------------------------------------------------ CC Star Burst
fn star_burst(ctx: &EffectCtx, mut b: Buf) -> Buf {
fn star_burst(ctx: &EffectCtx, b: Buf) -> Buf {
let mut plan = star_burst_plan(ctx, &b);
plan.resolve(&b.img);
plan.finish(b)
}
fn star_burst_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
let pr = ctx.params;
let scatter = pr.f("scatter") as f32;
let speed = pr.f("speed") as f32;
@ -600,17 +731,12 @@ fn star_burst(ctx: &EffectCtx, mut b: Buf) -> Buf {
let t = ctx.time as f32;
let nx = (lw / spacing).ceil().max(1.0) as u32;
let ny = (lh / spacing).ceil().max(1.0) as u32;
let src = b.img.clone();
let (cx, cy) = (lw * 0.5, lh * 0.5);
let mut list: Vec<(f32, Sprite)> = (0..nx * ny)
let mut list: Vec<(f32, Sprite, Tint)> = (0..nx * ny)
.into_par_iter()
.filter_map(|i| {
.map(|i| {
let (gx, gy) = ((i % nx) as f32 * spacing + spacing * 0.5, (i / nx) as f32 * spacing + spacing * 0.5);
let (sx, sy) = b.to_px([gx as f64, gy as f64]);
let (c, a) = unpremul(src.sample_bilinear(sx, sy));
if a <= 0.0 {
return None;
}
// Depth cycles towards the viewer: z in [0, 1), perspective 1 / (1 - 0.9 z).
let z = (h(i, 1, seed) + t * speed * 0.25 + phase).rem_euclid(1.0);
let persp = 1.0 / (1.0 - 0.9 * z);
@ -621,19 +747,13 @@ fn star_burst(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (bx, by) = b.to_px([x as f64, y as f64]);
let r = spacing * 0.5 * size * persp * s;
let fade = (z * 8.0).min(1.0) * ((1.0 - z) * 6.0).min(1.0);
Some((z, Sprite::new(bx as f32, by as f32, r, [c[0], c[1], c[2], a * fade], Shape::Disc)))
// The layer's colour at the grid point (none where it is clear).
(z, Sprite::new(bx as f32, by as f32, r, [0.0, 0.0, 0.0, fade], Shape::Disc), Tint::Source { x: sx, y: sy })
})
.collect();
list.sort_by(|a, b| a.0.total_cmp(&b.0));
let sprites: Vec<Sprite> = list.into_iter().map(|(_, s)| s).collect();
let fx = splat(src.width, src.height, &sprites, Acc::Over);
b.img = fx;
if blend > 0.0 {
b.img.data.par_iter_mut().zip(src.data.par_iter()).for_each(|(o, s0)| {
*o = crate::util::lerp4(*o, *s0, blend);
});
}
b
let (sprites, tints) = list.into_iter().map(|(_, s, t)| (s, t)).unzip();
SpritePlan { sprites, tints, acc: Acc::Over, post: if blend > 0.0 { Post::Lerp(blend) } else { Post::Replace } }
}
// ------------------------------------------------------------------ particle engine
@ -1067,13 +1187,16 @@ fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf {
static MERCURY_CACHE: SimCache<PState> = SimCache::new(6);
struct Blob {
x: f32,
y: f32,
r: f32,
/// A CC Mr. Mercury blob (buffer px).
#[derive(Clone, Copy, Debug)]
pub struct Blob {
pub x: f32,
pub y: f32,
pub r: f32,
}
fn mr_mercury(ctx: &EffectCtx, mut b: Buf) -> Buf {
/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`).
pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec<Blob> {
let pr = ctx.params;
let lh = ctx.layer_size[1] as f32;
let unit = lh.max(1.0);
@ -1097,10 +1220,8 @@ fn mr_mercury(ctx: &EffectCtx, mut b: Buf) -> Buf {
};
let st = simulate(&MERCURY_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 3), ctx.time, &phys);
let (bs, ds) = (pr.f("blobBirthSize") as f32, pr.f("blobDeathSize") as f32);
let influence = pr.f("blobInfluence") as f32 / 100.0;
let s = b.scale as f32;
let blobs: Vec<Blob> = st
.parts
st.parts
.iter()
.map(|q| {
let u = (q.age / q.life.max(1e-4)).clamp(0.0, 1.0);
@ -1109,7 +1230,14 @@ fn mr_mercury(ctx: &EffectCtx, mut b: Buf) -> Buf {
Blob { x: bx as f32, y: by as f32, r: (size * unit * 0.08 * s).max(0.0) }
})
.filter(|q| q.r > 0.1)
.collect();
.collect()
}
fn mr_mercury(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let blobs = mercury_blobs(ctx, &b);
let influence = pr.f("blobInfluence") as f32 / 100.0;
let s = b.scale as f32;
let (w, hh) = (b.img.width, b.img.height);
// Field and gradient accumulate additively: [f, gx, gy, _].
let mut field = Image::new(w, hh);

View File

@ -80,31 +80,78 @@ impl Cam {
}
/// A planar textured piece ready to draw.
#[derive(Clone, Copy)]
pub(crate) struct Piece {
#[derive(Clone, Copy, Debug)]
pub struct Piece {
/// Screen (buffer px, homogeneous) → piece-local (a, b, w).
inv: M3,
pub inv: [[f64; 3]; 3],
/// Rest centre of the piece in texture pixels.
c0: [f32; 2],
pub c0: [f32; 2],
/// Convex polygon (texture pixels), counter-clockwise in y-down space.
poly: [[f32; 2]; 6],
n: u8,
bbox: [f32; 4],
shade: f32,
pub poly: [[f32; 2]; 6],
pub n: u8,
pub bbox: [f32; 4],
pub shade: f32,
/// Lighting: colour multiplier and additive specular highlight.
tint: [f32; 3],
spec: [f32; 3],
pub(crate) alpha: f32,
pub tint: [f32; 3],
pub spec: [f32; 3],
pub alpha: f32,
/// Flat straight colour instead of the texture.
pub(crate) flat: Option<[f32; 3]>,
pub flat: Option<[f32; 3]>,
/// Facing away from the camera (draw the back texture).
pub(crate) back: bool,
pub back: bool,
/// Back faces sample their texture mirrored about the piece centre: 0 no, 1 vertically
/// (flipped about the X axis), 2 horizontally (about the Y axis).
pub(crate) back_mirror: u8,
pub back_mirror: u8,
depth: f64,
}
/// A piece texture: the layer itself or another image (buffer-aligned).
#[derive(Clone, Debug)]
pub enum PieceTex {
Layer,
Image(Image),
}
/// What a piece-drawing effect (Card Dance, Shatter, Card Wipe) draws this frame: pieces sorted
/// far to near over a transparent frame, textured by `front` (and `back` for pieces facing
/// away; none = they take `front`).
#[derive(Clone, Debug)]
pub struct PiecePlan {
pub pieces: Vec<Piece>,
pub front: PieceTex,
pub back: Option<PieceTex>,
}
impl PiecePlan {
/// Rasterise over a transparent frame of the layer `b`'s size.
pub fn finish(&self, mut b: Buf) -> Buf {
let mut out = Image::new(b.img.width, b.img.height);
let front = match &self.front {
PieceTex::Layer => &b.img,
PieceTex::Image(i) => i,
};
let back = self.back.as_ref().map(|t| match t {
PieceTex::Layer => &b.img,
PieceTex::Image(i) => i,
});
draw_pieces(&mut out, &self.pieces, front, back);
b.img = out;
b
}
}
/// The piece plan of Card Dance, Shatter (Rendered view) or Card Wipe for layer `b` (its pixels
/// may be read: gradient maps and textures default to the layer), `None` for other effects or
/// views drawn otherwise.
pub fn piece_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option<PiecePlan> {
match id {
"ec.sim.carddance" => Some(card_dance_plan(ctx, b)),
"ec.sim.shatter" if ctx.params.e("view") == 0 => shatter_impl(ctx, b, true).err(),
"ec.transition.cardwipe" => crate::transition::card_wipe_plan(ctx, b),
_ => None,
}
}
impl Piece {
/// Place polygon `poly` (texture px, around rest centre `c0`) with rotation `r` and its
/// centre at 3D `pos` (buffer px; z away from the camera), seen through `cam`.
@ -462,22 +509,25 @@ fn card_source(src: u32, g1: [f32; 4], g2: [f32; 4]) -> f32 {
}
}
fn card_dance(ctx: &EffectCtx, mut b: Buf) -> Buf {
fn card_dance(ctx: &EffectCtx, b: Buf) -> Buf {
card_dance_plan(ctx, &b).finish(b)
}
fn card_dance_plan(ctx: &EffectCtx, b: &Buf) -> PiecePlan {
let pr = ctx.params;
let rows = pr.f("rows").clamp(1.0, 1000.0) as u32;
let cols = if pr.e("rowsColumns") == 1 { rows } else { pr.f("columns").clamp(1.0, 1000.0) as u32 };
let g1 = layer_or_self(ctx, &b, "gradientLayer1", true, true);
let g2 = layer_or_self(ctx, &b, "gradientLayer2", true, true);
let back = ctx.layer_param("backLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true));
let g1 = layer_or_self(ctx, b, "gradientLayer1", true, true);
let g2 = layer_or_self(ctx, b, "gradientLayer2", true, true);
let back = ctx.layer_param("backLayer", true).map(|o| crate::util::fit_layer(ctx, b, &o, true));
let [lw, lh] = ctx.layer_size;
let s = b.scale;
let (cw, ch) = (lw / cols as f64, lh / rows as f64);
let cam = crate::card3d::projection(ctx, &b);
let cam = crate::card3d::projection(ctx, b);
let eye = cam.eye();
let light = Lighting::from(ctx, &b);
let light = Lighting::from(ctx, b);
let props: Vec<(u32, f64, f64)> =
CD_PROPS.iter().map(|(id, g)| (pr.e(&format!("{g}/{id}Source")), pr.f(&format!("{g}/{id}Multiplier")), pr.f(&format!("{g}/{id}Offset")))).collect();
let src = b.img.clone();
let mut pieces: Vec<Piece> = (0..rows * cols)
.into_par_iter()
.filter_map(|i| {
@ -502,10 +552,7 @@ fn card_dance(ctx: &EffectCtx, mut b: Buf) -> Buf {
})
.collect();
sort_far_first(&mut pieces);
let mut out = Image::new(src.width, src.height);
draw_pieces(&mut out, &pieces, &src, back.as_ref());
b.img = out;
b
PiecePlan { pieces, front: PieceTex::Layer, back: back.map(PieceTex::Image) }
}
// ------------------------------------------------------------------ Shatter
@ -638,7 +685,15 @@ fn map_key(c: [f32; 4]) -> u32 {
q(c[0]) << 8 | q(c[1]) << 4 | q(c[2])
}
fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf {
fn shatter(ctx: &EffectCtx, b: Buf) -> Buf {
match shatter_impl(ctx, &b, false) {
Ok(img) => Buf { img, ..b },
Err(plan) => plan.finish(b),
}
}
/// Shatter: the frame (`Ok`), or with `plan_only` in the Rendered view its pieces (`Err`).
fn shatter_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result<Image, PiecePlan> {
let pr = ctx.params;
let view = pr.e("view");
let render = pr.e("render");
@ -666,21 +721,21 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf {
let unit = lw * 0.1;
let cell = lw / reps;
let extrude = pr.f("shape/extrusionDepth").max(0.0) * cell * s;
let cam = crate::card3d::projection(ctx, &b);
let cam = crate::card3d::projection(ctx, b);
let eye = cam.eye();
let light = Lighting::from(ctx, &b);
let light = Lighting::from(ctx, b);
// Gradient: with a gradient layer only pieces at least as bright as 1 − Shatter Threshold
// (Invert Gradient: as dark) break.
let gradient = ctx.layer_param("gradient/gradientLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true));
let gradient = ctx.layer_param("gradient/gradientLayer", true).map(|o| crate::util::fit_layer(ctx, b, &o, true));
let threshold = pr.get("gradient/shatterThreshold").map(Value::as_f64).unwrap_or(0.0) / 100.0;
let invert_gradient = pr.b("gradient/invertGradient");
// Textures.
let tex_color = pr.get("textures/color").map(|v| v.as_color()).unwrap_or([1.0; 4]);
let tex_opacity = pr.get("textures/opacity").map(Value::as_f64).unwrap_or(1.0).clamp(0.0, 1.0) as f32;
let front = Face::from(ctx, &b, "textures/frontMode", "textures/frontLayer", 1);
let side = Face::from(ctx, &b, "textures/sideMode", "textures/sideLayer", 0);
let backf = Face::from(ctx, &b, "textures/backMode", "textures/backLayer", 1);
let custom_map = custom.then(|| layer_or_self(ctx, &b, "shape/customShatterMap", true, true));
let front = Face::from(ctx, b, "textures/frontMode", "textures/frontLayer", 1);
let side = Face::from(ctx, b, "textures/sideMode", "textures/sideLayer", 0);
let backf = Face::from(ctx, b, "textures/backMode", "textures/backLayer", 1);
let custom_map = custom.then(|| layer_or_self(ctx, b, "shape/customShatterMap", true, true));
let white_fixed = pr.b("shape/whiteTilesFixed");
let (sd, cd) = dir.sin_cos();
let to_layer = |q: [f64; 2]| [origin[0] + q[0] * cd - q[1] * sd, origin[1] + q[0] * sd + q[1] * cd];
@ -843,12 +898,18 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf {
})
.collect();
sort_far_first(&mut pieces);
let mut out = Image::new(src.width, src.height);
let front_img = front.img.as_ref().unwrap_or(&src);
let back_img = backf.img.as_ref().unwrap_or(front_img);
if view == 0 {
draw_pieces(&mut out, &pieces, front_img, Some(back_img));
} else {
let tex = |i: &Option<Image>| i.clone().map(PieceTex::Image);
let front_t = tex(&front.img).unwrap_or(PieceTex::Layer);
let back_t = tex(&backf.img).unwrap_or_else(|| front_t.clone());
let plan = PiecePlan { pieces, front: front_t, back: Some(back_t) };
if plan_only {
return Err(plan);
}
return Ok(plan.finish(Buf { img: src, offset: b.offset, scale: b.scale }).img);
}
let out;
{
// Wireframes: piece outlines (front view: at rest; otherwise animated), plus forces.
let animated = view == 2 || view == 4;
let mut lines = Vec::new();
@ -889,8 +950,7 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
out = splat(src.width, src.height, &lines, Acc::Over);
}
b.img = out;
b
Ok(out)
}
// ------------------------------------------------------------------ Caustics
@ -911,35 +971,62 @@ fn sample_repeat(img: &Image, x: f64, y: f64, mode: u32) -> Px {
}
}
fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf {
/// Everything Caustics' per-pixel pass needs, prepared on the CPU (the other layers it reads
/// and the parameter conversions); the layer's own pixels are not read.
pub struct CausticsSetup {
/// The Bottom layer fitted to the buffer, `None` = the layer itself.
pub bottom: Option<Image>,
/// Gaussian σ (buffer px) blurring the bottom (transparent edges), 0 = none.
pub bottom_sigma: f64,
/// The water surface's height field (smoothed luminance), `None` = flat.
pub height: Option<Plane>,
/// The Sky layer fitted to the buffer.
pub sky: Option<Image>,
pub scaling: f64,
pub repeat: u32,
pub wave_h: f64,
/// Displacement per unit of height gradient.
pub k: f64,
/// Layer centre (buffer px).
pub cx: f64,
pub cy: f64,
/// Layer width (buffer px).
pub lw: f64,
pub surf: [f32; 4],
pub surf_op: f32,
pub cstr: f32,
pub lc: [f32; 4],
pub li: f32,
/// Light position (buffer px) and height (layer widths); `point` = from the position to
/// each pixel, else one direction from above the layer centre.
pub lx: f64,
pub ly: f64,
pub lheight: f64,
pub point: bool,
pub ambient: f32,
pub diffuse: f32,
pub specular: f32,
pub sharp: f32,
pub sky_scaling: f64,
pub sky_repeat: u32,
pub sky_int: f32,
pub convergence: f64,
}
/// Caustics' setup for layer geometry `b` (see [`CausticsSetup`]).
pub fn caustics_setup(ctx: &EffectCtx, b: &Buf) -> CausticsSetup {
let pr = ctx.params;
let s = b.scale;
let mut bottom = layer_or_self(ctx, &b, "bottom/bottom", true, pr.e("bottom/bottomSizeDiffers") == 1);
let bottom = ctx.layer_param("bottom/bottom", true).map(|o| crate::util::fit_layer(ctx, b, &o, pr.e("bottom/bottomSizeDiffers") == 1));
let blur = pr.f("bottom/blur") * s;
if blur > 0.05 {
bottom = effectcraft_raster::gaussian_blur(&bottom, blur * 0.5, blur * 0.5, false);
}
let scaling = pr.f("bottom/scaling").max(0.01);
let repeat = pr.e("bottom/repeatMode");
let wave_h = pr.f("water/waveHeight");
let smoothing = pr.f("water/smoothing") * s;
let depth = pr.f("water/waterDepth");
let ior = pr.f("water/refractiveIndex").max(1.0);
let surf = pr.color("water/surfaceColor");
let surf_op = pr.f("water/surfaceOpacity") as f32;
let cstr = pr.f("water/causticsStrength") as f32;
let li = pr.f("lighting/lightIntensity") as f32;
let lc = pr.color("lighting/lightColor");
let lpos = pr.v2("lighting/lightPosition");
let lheight = pr.f("lighting/lightHeight").max(0.01);
let ambient = pr.f("lighting/ambientLight") as f32;
let diffuse = pr.f("material/diffuse") as f32;
let specular = pr.f("material/specular") as f32;
let sharp = pr.f("material/highlightSharpness").max(1.0) as f32;
let (w, hh) = (b.img.width as usize, b.img.height as usize);
// Height field of the water surface (luminance of the chosen layer; none = flat).
let height = ctx.layer_param("water/waterSurface", true).map(|o| {
let img = crate::util::fit_layer(ctx, &b, &o, true);
let img = crate::util::fit_layer(ctx, b, &o, true);
let pl = Plane::luma(&img);
if smoothing > 0.05 { gauss_plane(&pl, smoothing * 0.5, smoothing * 0.5) } else { pl }
});
@ -947,6 +1034,7 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf {
let k = wave_h * depth * (1.0 - 1.0 / ior) * lw * 2.0;
let (cx, cy) = (b.offset[0] + ctx.layer_size[0] * 0.5 * s, b.offset[1] + ctx.layer_size[1] * 0.5 * s);
let (lx, ly) = b.to_px(lpos);
let (lc, li, lheight) = (pr.color("lighting/lightColor"), pr.f("lighting/lightIntensity") as f32, pr.f("lighting/lightHeight").max(0.01));
// Light Type: Distant Source (one direction, from the light position above the layer
// centre), Point Source (from the light position to each pixel) or First Comp Light.
let light_type = pr.e("lighting/lightType");
@ -962,6 +1050,75 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf {
(1, _) => (lc, li, lx, ly, lheight, true),
_ => (lc, li, lx, ly, lheight, false),
};
// Sky: the layer the water surface reflects (Surface Opacity 1 = a mirror of it).
let sky = ctx.layer_param("sky/sky", true).map(|o| crate::util::fit_layer(ctx, b, &o, pr.e("sky/skySizeDiffers") == 1));
CausticsSetup {
bottom,
bottom_sigma: if blur > 0.05 { blur * 0.5 } else { 0.0 },
height,
sky,
scaling: pr.f("bottom/scaling").max(0.01),
repeat: pr.e("bottom/repeatMode"),
wave_h,
k,
cx,
cy,
lw,
surf: pr.color("water/surfaceColor"),
surf_op: pr.f("water/surfaceOpacity") as f32,
cstr: pr.f("water/causticsStrength") as f32,
lc,
li,
lx,
ly,
lheight,
point,
ambient: pr.f("lighting/ambientLight") as f32,
diffuse: pr.f("material/diffuse") as f32,
specular: pr.f("material/specular") as f32,
sharp: pr.f("material/highlightSharpness").max(1.0) as f32,
sky_scaling: pr.get("sky/scaling").map(Value::as_f64).unwrap_or(1.0).max(0.01),
sky_repeat: pr.e("sky/repeatMode"),
sky_int: pr.get("sky/intensity").map(Value::as_f64).unwrap_or(0.3) as f32,
convergence: pr.get("sky/convergence").map(Value::as_f64).unwrap_or(0.5).clamp(0.0, 1.0),
}
}
fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf {
let st = caustics_setup(ctx, &b);
let mut bottom = st.bottom.clone().unwrap_or_else(|| b.img.clone());
if st.bottom_sigma > 0.0 {
bottom = effectcraft_raster::gaussian_blur(&bottom, st.bottom_sigma, st.bottom_sigma, false);
}
let CausticsSetup {
scaling,
repeat,
wave_h,
k,
cx,
cy,
lw,
surf,
surf_op,
cstr,
lc,
li,
lx,
ly,
lheight,
point,
ambient,
diffuse,
specular,
sharp,
sky_scaling,
sky_repeat,
sky_int,
convergence,
..
} = st;
let (height, sky) = (&st.height, &st.sky);
let (w, hh) = (b.img.width as usize, b.img.height as usize);
let norm3 = |v: [f64; 3]| {
let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12);
[(v[0] / n) as f32, (v[1] / n) as f32, (v[2] / n) as f32]
@ -973,14 +1130,8 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf {
norm3([(lx - cx) / lw.max(1.0), (ly - cy) / lw.max(1.0), lheight])
}
};
// Sky: the layer the water surface reflects (Surface Opacity 1 = a mirror of it).
let sky = ctx.layer_param("sky/sky", true).map(|o| crate::util::fit_layer(ctx, &b, &o, pr.e("sky/skySizeDiffers") == 1));
let sky_scaling = pr.get("sky/scaling").map(Value::as_f64).unwrap_or(1.0).max(0.01);
let sky_repeat = pr.e("sky/repeatMode");
let sky_int = pr.get("sky/intensity").map(Value::as_f64).unwrap_or(0.3) as f32;
let convergence = pr.get("sky/convergence").map(Value::as_f64).unwrap_or(0.5).clamp(0.0, 1.0);
let grad = |x: usize, y: usize| -> (f64, f64) {
match &height {
match height {
Some(pl) => {
let gx = (pl.get_clamped(x as i64 + 1, y as i64) - pl.get_clamped(x as i64 - 1, y as i64)) as f64 * 0.5;
let gy = (pl.get_clamped(x as i64, y as i64 + 1) - pl.get_clamped(x as i64, y as i64 - 1)) as f64 * 0.5;
@ -1052,12 +1203,13 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf {
// ------------------------------------------------------------------ Wave World
/// Wave World's simulated surface: an `nx` × `ny` grid of heights `u` and velocities `v`.
#[derive(Clone, Debug)]
struct Waves {
nx: usize,
ny: usize,
u: Vec<f32>,
v: Vec<f32>,
pub struct Waves {
pub nx: usize,
pub ny: usize,
pub u: Vec<f32>,
pub v: Vec<f32>,
}
struct Producer {
@ -1091,7 +1243,27 @@ impl Producer {
static WAVE_CACHE: SimCache<Waves> = SimCache::new(4);
fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf {
/// What Wave World draws: the Height Map view of the simulated surface (grid heights and,
/// with a ground layer, water depths per grid point), or the wireframe preview's lines.
pub enum WavePlan {
Height { st: std::sync::Arc<Waves>, depth: Option<Vec<f32>> },
Wire(crate::sim::SpritePlan),
}
fn wave_world(ctx: &EffectCtx, b: Buf) -> Buf {
match wave_world_impl(ctx, &b, false) {
Ok(img) => Buf { img, ..b },
Err(WavePlan::Wire(plan)) => plan.finish(b),
Err(WavePlan::Height { .. }) => b,
}
}
/// Wave World's simulation state for the frame, as a [`WavePlan`] (its pixels are not read).
pub fn wave_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<WavePlan> {
wave_world_impl(ctx, b, true).err()
}
fn wave_world_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result<Image, WavePlan> {
let pr = ctx.params;
let [lw, lh] = ctx.layer_size;
let res = pr.f("simulation/gridResolution").clamp(1.0, 400.0) as usize;
@ -1239,7 +1411,11 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = g(x0, y1) + (g(x1, y1) - g(x0, y1)) * tx;
a + (c - a) * ty
};
if plan_only && pr.e("view") == 1 {
return Err(WavePlan::Height { st, depth });
}
if pr.e("view") == 1 {
let mut img = Image::new(bw, bh);
let bright = pr.f("heightMapControls/brightness") as f32;
let contrast = pr.f("heightMapControls/contrast") as f32;
let gamma = pr.f("heightMapControls/gamma").max(0.01) as f32;
@ -1251,7 +1427,7 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf {
let gy = (y / lw.max(1.0) * (st.nx - 1) as f64).round().clamp(0.0, (st.ny - 1) as f64) as usize;
d[gy * st.nx + gx] <= 0.0
};
b.img.rows_mut().for_each(|(y, row)| {
img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let lx = (x as f64 + 0.5 - b.offset[0]) / s;
let ly = (y as f64 + 0.5 - b.offset[1]) / s;
@ -1260,6 +1436,7 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf {
*px = [v * al, v * al, v * al, al];
}
});
Ok(img)
} else {
// Wireframe preview: the grid turned by Horizontal Rotation about the vertical axis,
// tilted by Vertical Rotation, heights scaled by Vertical Scale.
@ -1291,10 +1468,12 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
}
}
let wire = splat(bw, bh, &lines, Acc::Over);
b.img = crate::sim::combine(&Image::filled(bw, bh, [0.0, 0.0, 0.0, 1.0]), &wire, 0);
let plan = crate::sim::SpritePlan { sprites: lines, tints: vec![], acc: Acc::Over, post: crate::sim::Post::OverBlack };
if plan_only {
return Err(WavePlan::Wire(plan));
}
Ok(plan.finish(Buf { img: Image::new(bw, bh), offset: b.offset, scale: b.scale }).img)
}
b
}
// ------------------------------------------------------------------ Foam
@ -1336,7 +1515,23 @@ fn foam_pop_age(id: u32, lifespan: f32, frailty: f32, seed: u32) -> f32 {
/// other, wobble, and pop at the end of their (Strength-shortened) lifespan, pushing their
/// neighbours away at Pop Velocity. Rendered bubbles use a built-in texture or the Bubble
/// Texture Layer, oriented by Bubble Orientation, and reflect the Environment Map.
fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf {
fn foam(ctx: &EffectCtx, b: Buf) -> Buf {
match foam_impl(ctx, &b, false) {
Ok(img) => Buf { img, ..b },
Err(plan) => plan.finish(b),
}
}
/// Foam's bubbles as sprites, `None` when it draws more than sprites (a User Defined bubble
/// texture, an Environment Map reflection or the Draft + Flow Map view's flow map).
pub(crate) fn foam_plan(ctx: &EffectCtx, b: &Buf) -> Option<crate::sim::SpritePlan> {
if !crate::catalog::gpu_supported("ec.sim.foam", ctx) {
return None;
}
foam_impl(ctx, b, true).err()
}
fn foam_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result<Image, crate::sim::SpritePlan> {
let pr = ctx.params;
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let unit = lw * 0.01;
@ -1558,7 +1753,11 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf {
sp
})
.collect();
let mut out = splat(b.img.width, b.img.height, &sprites, if blend == 0 && view == 2 { Acc::Add } else { Acc::Over });
let acc = if blend == 0 && view == 2 { Acc::Add } else { Acc::Over };
if plan_only {
return Err(crate::sim::SpritePlan { sprites, tints: vec![], acc, post: crate::sim::Post::Replace });
}
let mut out = splat(b.img.width, b.img.height, &sprites, acc);
// User Defined: the texture layer fills each bubble's disc.
if let Some(tex) = &user_tex {
for q in &order {
@ -1581,7 +1780,7 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf {
&& strength > 0.0
&& let Some(env) = ctx.layer_param("rendering/environmentMap", true)
{
let env_img = crate::util::fit_layer(ctx, &b, &env, true);
let env_img = crate::util::fit_layer(ctx, b, &env, true);
let conv = pr.f("rendering/reflectionConvergence").clamp(0.0, 1.0);
let (w, h) = (b.img.width as f64, b.img.height as f64);
for q in &order {
@ -1602,7 +1801,7 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf {
if view == 1
&& let Some(lp) = &flow
{
let fm = crate::util::fit_layer(ctx, &b, lp, true);
let fm = crate::util::fit_layer(ctx, b, lp, true);
out.data.par_iter_mut().zip(fm.data.par_iter()).for_each(|(o, f)| {
let k = 1.0 - o[3];
for c in 0..4 {
@ -1610,8 +1809,7 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
});
}
b.img = out;
b
Ok(out)
}
/// Add `f(u, v)` (premultiplied; u, v ∈ −1..1 across the disc) over the disc of radius `r` at

View File

@ -27,7 +27,7 @@ use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::sim::{Acc, SPS, Shape, Sprite, combine, splat, steps_at};
use crate::sim::{Acc, Post, SPS, Shape, Sprite, SpritePlan, Tint, combine, splat, steps_at};
use crate::util::{SimCache, hash1, params_key, point_in_poly, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
@ -897,12 +897,20 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf {
// ---------------------------------------------------------------- CC Hair
fn cc_hair(ctx: &EffectCtx, mut b: Buf) -> Buf {
fn cc_hair(ctx: &EffectCtx, b: Buf) -> Buf {
let Some(mut plan) = hair_plan(ctx, &b) else { return b };
plan.resolve(&b.img);
plan.finish(b)
}
/// CC Hair's strands as line sprites (each coloured from its root, see [`Tint::Root`]);
/// `None` = no hair (the layer passes through).
pub(crate) fn hair_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
let pr = ctx.params;
let len = pr.f("length").max(0.0) as f32;
let density = pr.f("density").max(0.0) as f32 / 100.0;
if len <= 0.0 || density <= 0.0 {
return b;
return None;
}
let thick = pr.f("thickness").max(0.05) as f32;
let weight = pr.f("weight") as f32;
@ -924,19 +932,13 @@ fn cc_hair(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
// Roots: a jittered grid over the layer, density per 1 000 px².
let count = ((lw * lh / 1000.0) * density * 10.0).min(200_000.0) as u32;
let src = b.img.clone();
let segs = 6u32;
let strands: Vec<Vec<Sprite>> = (0..count)
let strands: Vec<Vec<(Sprite, Tint)>> = (0..count)
.into_par_iter()
.filter_map(|i| {
.map(|i| {
let rx = hash1(i, 1, seed) * lw;
let ry = hash1(i, 2, seed) * lh;
let (bx, by) = b.to_px([rx as f64, ry as f64]);
let root = src.sample_bilinear(bx, by);
if root[3] < 0.5 {
return None;
}
let (rc, _) = unpremul(root);
let mut l = len * (0.75 + 0.5 * hash1(i, 3, seed));
// Lean: random, plus the map's local gradient (hairfall map).
let mut lean = [(hash1(i, 4, seed) * 2.0 - 1.0) * 0.6, -1.0 + hash1(i, 5, seed) * 0.4];
@ -957,7 +959,6 @@ fn cc_hair(ctx: &EffectCtx, mut b: Buf) -> Buf {
let dir = [lean[0] / ll, lean[1] / ll];
// Droop: heavier (or thinner with Constant Mass off) hair bends more.
let droop = weight * if const_mass { 1.0 } else { 1.0 / thick.max(0.2) } * l;
let base = [rc[0] + (hc[0] - rc[0]) * (1.0 - inherit), rc[1] + (hc[1] - rc[1]) * (1.0 - inherit), rc[2] + (hc[2] - rc[2]) * (1.0 - inherit)];
let pt = |s: f32| [rx + dir[0] * l * s, ry + dir[1] * l * s + droop * s * s];
let mut out = Vec::with_capacity(segs as usize);
for k in 0..segs {
@ -974,24 +975,23 @@ fn cc_hair(ctx: &EffectCtx, mut b: Buf) -> Buf {
let spec = (1.0 - tl.abs()).powf(1.0 / rough) * spe;
let shade = (amb + dif * diff * light_i) * bright;
let tip = 1.0 - 0.6 * s1;
let c3 = [base[0] * shade + spec * light_i, base[1] * shade + spec * light_i, base[2] * shade + spec * light_i];
let (pa, pc) = (b.to_px([a[0] as f64, a[1] as f64]), b.to_px([c[0] as f64, c[1] as f64]));
out.push(Sprite {
let sp = Sprite {
x: pa.0 as f32,
y: pa.1 as f32,
r: thick * 0.5 * b.scale as f32 * tip,
c: [c3[0], c3[1], c3[2], opacity],
c: [0.0, 0.0, 0.0, opacity],
shape: Shape::Line { dx: (pc.0 - pa.0) as f32, dy: (pc.1 - pa.1) as f32 },
rot: 0.0,
});
};
// The root's colour mixed with the hair colour (no strand where it is clear).
out.push((sp, Tint::Root { x: bx, y: by, hair: [hc[0], hc[1], hc[2]], inherit, shade, spec: spec * light_i }));
}
Some(out)
out
})
.collect();
let sprites: Vec<Sprite> = strands.into_iter().flatten().collect();
let fx = splat(b.img.width, b.img.height, &sprites, Acc::Over);
b.img = combine(&b.img, &fx, 0);
b
let (sprites, tints) = strands.into_iter().flatten().unzip();
Some(SpritePlan { sprites, tints, acc: Acc::Over, post: Post::Combine(0) })
}
pub fn specs() -> Vec<EffectSpec> {

View File

@ -9,7 +9,7 @@ use effectcraft_project::ParamUi;
use effectcraft_raster::Px;
use rayon::prelude::*;
use crate::sim2::{Piece, draw_pieces, rot_axis, sort_far_first};
use crate::sim2::{Piece, PiecePlan, PieceTex, rot_axis, sort_far_first};
use crate::util::{Plane, gauss_plane, hash1, layer_rect, remap, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, col, num, p, popup, slider};
@ -175,7 +175,15 @@ fn iris_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
/// through a 180° turn about Flip Axis, as textured planes in 3D seen through the Camera
/// System and lit by Lighting / Material. The flipped side shows Back Layer (none: the cards
/// vanish as they turn away). Position / Rotation Jitter wobble the cards over time.
fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
fn card_wipe(ctx: &EffectCtx, b: Buf) -> Buf {
match card_wipe_plan(ctx, &b) {
Some(plan) => plan.finish(b),
None => b,
}
}
/// Card Wipe's cards (`None` = nothing has moved: the layer as is).
pub(crate) fn card_wipe_plan(ctx: &EffectCtx, b: &Buf) -> Option<PiecePlan> {
let done = completion(ctx);
let pr = ctx.params;
let f = |id: &str| pr.get(id).map(Value::as_f64).unwrap_or(0.0);
@ -199,7 +207,7 @@ fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
// Nothing has moved: the layer as is (exactly).
let xy = pr.get("cameraPosition/xyPosition").map(|v| v.as_vec2());
if xy.is_none_or(|p| (p[0] - ctx.layer_size[0] * 0.5).abs() < 1e-9 && (p[1] - ctx.layer_size[1] * 0.5).abs() < 1e-9) {
return b;
return None;
}
}
let tw = (pr.f("transitionWidth") / 100.0).clamp(0.01, 1.0);
@ -212,25 +220,25 @@ fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
let order = pr.e("flipOrder");
let jitter = pr.f("timingRandomness").clamp(0.0, 1.0);
let seed = pr.f("randomSeed") as u32 ^ 0xca7d;
let (x0, y0, w, h) = layer_rect(ctx, &b);
let (x0, y0, w, h) = layer_rect(ctx, b);
let (cw, ch) = (w / cols as f64, h / rows as f64);
let nx = |i: i64| if cols > 1 { i as f64 / (cols - 1) as f64 } else { 0.0 };
let ny = |j: i64| if rows > 1 { j as f64 / (rows - 1) as f64 } else { 0.0 };
// Flip Order "Gradient": cards flip first where the gradient layer (default: this layer) is dark.
let grad = (order == 8).then(|| match ctx.layer_param("gradientLayer", false) {
Some(o) => place_layer(ctx, &b, &o, 2),
Some(o) => place_layer(ctx, b, &o, 2),
None => b.img.clone(),
});
// Back Layer: a chosen layer (stretched to fit), or this layer for projects saved with the
// old "Self" choice; none hides the cards' backs.
let back = match ctx.layer_param("backLayer", true) {
Some(o) => Some(crate::util::fit_layer(ctx, &b, &o, true)),
None if pr.b("backSelf") => Some(b.img.clone()),
Some(o) => Some(PieceTex::Image(crate::util::fit_layer(ctx, b, &o, true))),
None if pr.b("backSelf") => Some(PieceTex::Layer),
None => None,
};
let cam = crate::card3d::projection(ctx, &b);
let cam = crate::card3d::projection(ctx, b);
let eye = cam.eye();
let light = crate::card3d::Lighting::from(ctx, &b);
let light = crate::card3d::Lighting::from(ctx, b);
let t = ctx.time;
let jit = |i: i64, j: i64, k: u32, speed: f64| -> f64 {
let ph = hash1(i as u32, j as u32, seed ^ k) as f64 * std::f64::consts::TAU;
@ -303,10 +311,7 @@ fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
})
.collect();
sort_far_first(&mut pieces);
let mut out = effectcraft_raster::Image::new(b.img.width, b.img.height);
draw_pieces(&mut out, &pieces, &b.img, back.as_ref());
b.img = out;
b
Some(PiecePlan { pieces, front: PieceTex::Layer, back })
}
fn grid_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {

View File

@ -326,7 +326,7 @@ impl Lut {
type LutCache = Mutex<HashMap<String, Option<Arc<Lut>>>>;
/// Load (and cache) a LUT from inline `.cube` text or a file path.
pub(crate) fn load_lut(src: &str) -> Option<Arc<Lut>> {
pub fn load_lut(src: &str) -> Option<Arc<Lut>> {
if src.trim().is_empty() {
return None;
}

View File

@ -316,6 +316,10 @@ impl GpuContext {
include_str!("shaders/fx_tone.wgsl"),
include_str!("shaders/fx_warp.wgsl"),
include_str!("shaders/fx_extra.wgsl"),
include_str!("shaders/fx_depth.wgsl"),
include_str!("shaders/fx_lut.wgsl"),
include_str!("shaders/fx_sim.wgsl"),
include_str!("shaders/fx_vr.wgsl"),
include_str!("shaders/fx_light.wgsl"),
include_str!("shaders/fx_transition.wgsl"),
include_str!("shaders/fx_text.wgsl"),
@ -380,6 +384,10 @@ impl GpuContext {
.chain(crate::fx_tone::KERNELS)
.chain(crate::fx_warp::KERNELS)
.chain(crate::fx_extra::KERNELS)
.chain(crate::fx_depth::KERNELS)
.chain(crate::fx_lut::KERNELS)
.chain(crate::fx_sim::KERNELS)
.chain(crate::fx_vr::KERNELS)
.chain(crate::fx_light::KERNELS)
.chain(crate::fx_transition::KERNELS)
.chain(crate::fx_text::KERNELS)

View File

@ -167,6 +167,10 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
_ if crate::fx_tone::IDS.contains(&id) => crate::fx_tone::apply(e, id, ctx, b),
_ if crate::fx_warp::IDS.contains(&id) => crate::fx_warp::apply(e, id, ctx, b),
_ if crate::fx_extra::IDS.contains(&id) => crate::fx_extra::apply(e, id, ctx, b),
_ if crate::fx_depth::IDS.contains(&id) => crate::fx_depth::apply(e, id, ctx, b),
_ if crate::fx_lut::IDS.contains(&id) => crate::fx_lut::apply(e, id, ctx, b),
_ if crate::fx_sim::IDS.contains(&id) => crate::fx_sim::apply(e, id, ctx, b),
_ if crate::fx_vr::IDS.contains(&id) => crate::fx_vr::apply(e, id, ctx, b),
_ 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),

View File

@ -197,6 +197,17 @@ fn lumetri(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
(lw / lh.max(1e-6)) as f32,
]);
debug_assert_eq!(d.len(), 50);
// Input LUT and Look (data[50..53]: look, input LUT offset, look LUT offset; tables after).
let look = pr.e("creative/look");
let input_lut = if pr.e("basicCorrection/inputLut") == 1 { effectcraft_effects::load_lut(pr.s("basicCorrection/inputLutFile")) } else { None };
let look_lut = if look == 1 { effectcraft_effects::load_lut(pr.s("creative/lookFile")) } else { None };
d.extend([look as f32, -1.0, -1.0]);
if let Some(l) = &input_lut {
d[51] = crate::fx_lut::push_lut(&mut d, l);
}
if let Some(l) = &look_lut {
d[52] = crate::fx_lut::push_lut(&mut d, l);
}
let buf = e.data(&d);
let out = e.scratch(b.img.width, b.img.height);
e.pixels("fxc_lumetri", &Params::default(), &b.img, None, &out, Some(&buf));

335
crates/gpu/src/fx_depth.rs Normal file
View File

@ -0,0 +1,335 @@
//! GPU effects, 3D Channel family (kernels in `shaders/fx_depth.wgsl`, every entry point
//! prefixed `fxd_`): 3D Channel Extract, Cryptomatte, Depth Matte, Depth of Field, EXtractoR,
//! Fog 3D, ID Matte and IDentifier.
//!
//! The layer's auxiliary channels ([`effectcraft_raster::AuxChannels`]) go to the GPU as an
//! extra texture at the aux resolution: the planes an effect reads are packed into its four
//! channels (alpha = 1 marks "inside the aux image"). `fxd_lookup` resamples it nearest-neighbour
//! onto the buffer grid with the CPU's exact index arithmetic (layer position × aux scale,
//! floored), and the effect kernels work on that buffer-sized plane. Cryptomatte packs a
//! per-aux-pixel reduction of its rank layers (selection coverage and the coverage-weighted ID
//! colours, measured on the CPU) instead of the raw ranks. Fog 3D's Gradient Layer is rendered
//! by the host on the CPU (it is another layer) and uploaded.
use effectcraft_effects::{Buf, EffectCtx};
use effectcraft_keyframe::Value;
use effectcraft_raster::channels3d::BACKGROUND_DEPTH;
use effectcraft_raster::{AuxChannels, Image};
use crate::context::{Enc, GpuImage, Params};
use crate::effects::{GBuf, gaussian_blur};
/// Compute entry points in `fx_depth.wgsl`.
pub(crate) const KERNELS: &[&str] = &["fxd_lookup", "fxd_point", "fxd_dof_f", "fxd_dof_level", "fxd_avg"];
/// Effect ids implemented here.
pub(crate) const IDS: &[&str] = &[
"ec.3d.channelextract",
"ec.3d.cryptomatte",
"ec.3d.depthmatte",
"ec.3d.depthoffield",
"ec.3d.extractor",
"ec.3d.fog3d",
"ec.3d.idmatte",
"ec.3d.identifier",
];
/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU.
pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let aux = ctx.env.host.and_then(|h| h.aux());
if id == "ec.3d.extractor" {
return extractor(e, ctx, b, aux.as_deref());
}
let Some(a) = aux else { return Some(b) };
match id {
"ec.3d.channelextract" => channel_extract(e, ctx, b, &a),
"ec.3d.cryptomatte" => cryptomatte(e, ctx, b, &a),
"ec.3d.depthmatte" => depth_matte(e, ctx, b, &a),
"ec.3d.depthoffield" => depth_of_field(e, ctx, b, &a),
"ec.3d.fog3d" => fog(e, ctx, b, &a),
"ec.3d.idmatte" => id_matte(e, ctx, b, &a),
"ec.3d.identifier" => identifier(e, ctx, b, &a),
_ => None,
}
}
fn op(code: u32) -> Params {
let mut p = Params::default();
p.u[0][0] = code;
p
}
/// Pack up to four aux planes (aux resolution; `None` planes are 0, alpha defaults to 1 = inside)
/// and upload them.
fn aux_texture(e: &mut Enc, a: &AuxChannels, planes: [Option<&[f32]>; 4]) -> Option<GpuImage> {
let n = (a.width * a.height) as usize;
if planes.iter().flatten().any(|p| p.len() < n) {
return None;
}
let mut img = Image::new(a.width, a.height);
for (i, px) in img.data.iter_mut().enumerate() {
*px = [0, 1, 2, 3].map(|k| planes[k].map_or(if k == 3 { 1.0 } else { 0.0 }, |p| p[i]));
}
e.g.upload_image(&img)
}
/// Resample an aux texture onto the buffer grid (nearest; `fill` outside the aux image),
/// sampling each buffer pixel at sub-pixel position `at`.
fn lookup(e: &mut Enc, b: &GBuf, a: &AuxChannels, tex: &GpuImage, at: [f64; 2], fill: [f32; 4]) -> GpuImage {
let inv = 1.0 / b.scale.max(1e-9);
let mut p = Params::default();
p.f[0] = [b.offset[0] as f32, b.offset[1] as f32, inv as f32, a.scale as f32];
p.f[1] = [at[0] as f32, at[1] as f32, 0.0, 0.0];
p.f[2] = fill;
let out = e.scratch(b.img.width, b.img.height);
e.pixels("fxd_lookup", &p, tex, None, &out, None);
out
}
fn point(e: &mut Enc, p: &Params, b: GBuf, plane: &GpuImage) -> Option<GBuf> {
let out = e.scratch(b.img.width, b.img.height);
e.pixels("fxd_point", p, &b.img, Some(plane), &out, None);
Some(GBuf { img: out, ..b })
}
// ---------------------------------------------------------------- 3D Channel Extract
fn channel_extract(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option<GBuf> {
let pr = ctx.params;
let get3 = |n: [&[&str]; 3]| -> Option<[&[f32]; 3]> { Some([a.find(n[0])?, a.find(n[1])?, a.find(n[2])?]) };
let u = ["UV.U", "U", "uv.x"];
let v = ["UV.V", "V", "uv.y"];
// (planes, mode): mode 0 grey (lv), 1 UV (lv on R, G; B = 0), 2 normals (lv of ×0.5 + 0.5),
// 3 RGB (lv), 4 unclamped RGB (no mapping).
let (planes, mode): ([&[f32]; 3], u32) = match pr.e("channel") {
0 => (a.depth().map(|z| [z, z, z])?, 0),
1 => (a.object_id().map(|c| [c, c, c])?, 0),
7 => (a.material_id().map(|c| [c, c, c])?, 0),
4 => (a.coverage().map(|c| [c, c, c])?, 0),
2 => (get3([&u, &v, &["UV.W", "W", "uv.z"]]).or_else(|| Some([a.find(&u)?, a.find(&v)?, a.find(&v)?]))?, 1),
3 => (get3([&["N.X", "Normal.X", "normals.x"], &["N.Y", "Normal.Y", "normals.y"], &["N.Z", "Normal.Z", "normals.z"]])?, 2),
5 => (get3([&["BG.R", "Background.R"], &["BG.G", "Background.G"], &["BG.B", "Background.B"]])?, 3),
_ => (get3([&["R"], &["G"], &["B"]])?, 4),
};
let tex = aux_texture(e, a, [Some(planes[0]), Some(planes[1]), Some(planes[2]), None])?;
let mut p = op(0);
p.u[0] = [0, mode, pr.b("clampOutput") as u32, pr.b("invertDepthMap") as u32];
p.f[0] = [pr.f("blackPoint") as f32, pr.f("whitePoint") as f32, 0.0, 0.0];
if !pr.b("antialias") {
let plane = lookup(e, &b, a, &tex, [0.5, 0.5], [0.0; 4]);
return point(e, &p, b, &plane);
}
// Anti-alias: four sub-pixel samples, summed in order and scaled once (as the CPU does).
let (w, h) = (b.img.width, b.img.height);
let mut acc: Option<GpuImage> = None;
for s in [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]] {
let plane = lookup(e, &b, a, &tex, s, [0.0; 4]);
let one = e.scratch(w, h);
e.pixels("fxd_point", &p, &b.img, Some(&plane), &one, None);
acc = Some(match acc {
None => one,
Some(prev) => {
let out = e.scratch(w, h);
e.pixels("fxd_avg", &op(0), &prev, Some(&one), &out, None);
out
}
});
}
let acc = acc?;
let out = e.scratch(w, h);
e.pixels("fxd_avg", &op(1), &acc, None, &out, None);
Some(GBuf { img: out, ..b })
}
// ---------------------------------------------------------------- Depth Matte / Fog 3D
fn depth_plane(e: &mut Enc, b: &GBuf, a: &AuxChannels) -> Option<GpuImage> {
let z = a.depth()?;
let tex = aux_texture(e, a, [Some(z), None, None, None])?;
Some(lookup(e, b, a, &tex, [0.5, 0.5], [BACKGROUND_DEPTH, 0.0, 0.0, 0.0]))
}
fn depth_matte(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option<GBuf> {
let Some(plane) = depth_plane(e, &b, a) else { return if a.depth().is_none() { Some(b) } else { None } };
let mut p = op(1);
p.u[0][1] = ctx.params.b("invert") as u32;
p.f[0] = [ctx.params.f("depth") as f32, ctx.params.f("feather").max(0.0) as f32, 0.0, 0.0];
point(e, &p, b, &plane)
}
fn fog(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option<GBuf> {
let pr = ctx.params;
let Some(plane) = depth_plane(e, &b, a) else { return if a.depth().is_none() { Some(b) } else { None } };
let contrib = (pr.f("layerContribution") / 100.0) as f32;
let grad = if contrib > 0.0 && matches!(pr.get("gradientLayer"), Some(Value::Layer(Some(_)))) {
ctx.layer_param("gradientLayer", true).map(|o| {
let cpu = Buf { img: Image::new(b.img.width, b.img.height), offset: b.offset, scale: b.scale };
effectcraft_effects::util::fit_layer(ctx, &cpu, &o, true)
})
} else {
None
};
let grad = match grad {
Some(g) => Some(e.g.upload_image(&g)?),
None => None,
};
let fc = pr.color("fogColor");
let mut p = op(2);
p.u[0] = [2, pr.b("foggyBackground") as u32, grad.is_some() as u32, 0];
p.f[0] = [pr.f("fogStartDepth") as f32, pr.f("fogEndDepth") as f32, (pr.f("fogOpacity") / 100.0) as f32, (pr.f("scatteringDensity") / 100.0) as f32];
p.f[1] = [fc[0], fc[1], fc[2], contrib];
p.f[2][0] = BACKGROUND_DEPTH;
let Some(g) = grad else { return point(e, &p, b, &plane) };
// The gradient's factor joins the depth plane (G) first: kernels read two images.
let mut q = op(3);
q.f[0][0] = contrib;
let merged = e.scratch(b.img.width, b.img.height);
e.pixels("fxd_point", &q, &g, Some(&plane), &merged, None);
point(e, &p, b, &merged)
}
// ---------------------------------------------------------------- Depth of Field
fn depth_of_field(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option<GBuf> {
let pr = ctx.params;
if a.depth().is_none() {
return Some(b);
}
let max_r = (pr.f("maximumRadius").max(0.0) * b.scale) as f32;
if max_r <= 0.0 {
return Some(b);
}
let plane = depth_plane(e, &b, a)?;
const LEVELS: usize = 5;
let (w, h) = (b.img.width, b.img.height);
let stack: Vec<GpuImage> = (0..LEVELS)
.map(|k| {
let r = max_r * k as f32 / (LEVELS - 1) as f32;
if r < 0.3 { b.img.clone() } else { gaussian_blur(e, &b.img, (r / 2.0) as f64, (r / 2.0) as f64, true) }
})
.collect();
// Per-pixel stack position, then each level pair writes its own pixels.
let mut p = Params::default();
let gamma = 2f32.powf((50.0 - pr.f("focalBias") as f32) / 25.0);
p.f[0] = [pr.f("focalPlane") as f32, pr.f("focalPlaneThickness").max(0.0) as f32, max_r, gamma];
p.u[0][0] = LEVELS as u32;
let fpl = e.scratch(w, h);
e.pixels("fxd_dof_f", &p, &plane, None, &fpl, None);
let (rows, row_len) = e.image_rows(&fpl);
let out = e.scratch(w, h);
for k in 0..LEVELS - 1 {
let mut q = Params::default();
q.u[0] = [k as u32, row_len, LEVELS as u32, 0];
e.pixels("fxd_dof_level", &q, &stack[k], Some(&stack[k + 1]), &out, Some(&rows));
}
Some(GBuf { img: out, ..b })
}
// ---------------------------------------------------------------- ID Matte / IDentifier
fn id_plane(a: &AuxChannels, which: u32) -> Option<&[f32]> {
if which == 1 { a.material_id() } else { a.object_id() }
}
fn id_matte(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option<GBuf> {
let pr = ctx.params;
let Some(ids) = id_plane(a, pr.e("auxChannel")) else { return Some(b) };
let cov = if pr.b("useCoverage") { a.coverage() } else { None };
let tex = aux_texture(e, a, [Some(ids), cov, None, None])?;
let plane = lookup(e, &b, a, &tex, [0.5, 0.5], [0.0; 4]);
let (w, h) = (b.img.width, b.img.height);
let mut p = op(4);
p.u[0][1] = cov.is_some() as u32;
p.f[0][0] = pr.f("idSelection").round() as f32;
let mut m = e.scratch(w, h);
e.pixels("fxd_point", &p, &plane, None, &m, None);
let feather = pr.f("feather").max(0.0) * b.scale;
if feather > 0.0 {
m = gaussian_blur(e, &m, feather * 0.5, feather * 0.5, true);
}
let mut p = op(5);
p.u[0][1] = pr.b("invert") as u32;
point(e, &p, b, &m)
}
fn identifier(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option<GBuf> {
let pr = ctx.params;
let Some(ids) = id_plane(a, pr.e("channelType")) else { return Some(b) };
let max_id = ids.iter().cloned().fold(1.0f32, f32::max);
let tex = aux_texture(e, a, [Some(ids), None, None, None])?;
let plane = lookup(e, &b, a, &tex, [0.5, 0.5], [0.0; 4]);
let mut p = op(6);
p.u[0][1] = pr.e("display");
p.f[0] = [pr.f("id").round() as f32, max_id, 0.0, 0.0];
point(e, &p, b, &plane)
}
// ---------------------------------------------------------------- Cryptomatte
fn cryptomatte(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option<GBuf> {
let pr = ctx.params;
let layers = a.crypto_layers();
if layers.is_empty() {
return Some(b);
}
let want = effectcraft_effects::CRYPTO_LAYERS[(pr.e("layer") as usize).min(2)];
let layer = layers.iter().find(|l| l.as_str() == want || l.ends_with(want)).unwrap_or(&layers[0]).clone();
let manifest = a.manifests.iter().find(|(l, _)| *l == layer).map(|(_, m)| m.as_slice());
let sel = effectcraft_effects::selection_hashes(pr.s("selection"), manifest);
// Per aux pixel: coverage-weighted ID colours (RGB) and the selection's coverage (A).
let n = (a.width * a.height) as usize;
let mut img = Image::new(a.width, a.height);
for (i, px) in img.data.iter_mut().enumerate().take(n) {
let ranks = a.crypto_ranks(&layer, i);
let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::<f32>().clamp(0.0, 1.0);
let mut c = [0.0f32; 3];
for (id, cov) in &ranks {
let k = effectcraft_effects::id_color(*id);
for j in 0..3 {
c[j] += k[j] * cov;
}
}
*px = [c[0], c[1], c[2], m];
}
let tex = e.g.upload_image(&img)?;
let plane = lookup(e, &b, a, &tex, [0.5, 0.5], [0.0; 4]);
let mut p = op(7);
p.u[0] = [7, pr.e("display"), pr.b("matteOnly") as u32, 0];
point(e, &p, b, &plane)
}
// ---------------------------------------------------------------- EXtractoR
fn extractor(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: Option<&AuxChannels>) -> Option<GBuf> {
let pr = ctx.params;
let names = [pr.s("red"), pr.s("green"), pr.s("blue"), pr.s("alpha")];
let (bp, wp) = (pr.f("blackPoint") as f32, pr.f("whitePoint") as f32);
let unmult = pr.b("unMult");
let own = |n: &str| match n.trim().to_ascii_uppercase().as_str() {
"R" | "RED" => Some(0u32),
"G" | "GREEN" => Some(1),
"B" | "BLUE" => Some(2),
"A" | "ALPHA" => Some(3),
_ => None,
};
let src_ch: Vec<Option<&[f32]>> = names.iter().map(|n| a.and_then(|a| if n.trim().is_empty() { None } else { a.get(n.trim()) })).collect();
if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(n) == Some(k as u32)) {
return Some(b);
}
// Per output channel: 0..3 = the layer's own channel, 4 = aux, 5 = default (0, alpha 1).
let sources: [u32; 4] = std::array::from_fn(|k| if src_ch[k].is_some() { 4 } else { own(names[k]).unwrap_or(5) });
let plane = match a {
Some(a) if src_ch.iter().any(Option::is_some) => {
let tex = aux_texture(e, a, [src_ch[0], src_ch[1], src_ch[2], src_ch[3]])?;
Some(lookup(e, &b, a, &tex, [0.5, 0.5], [0.0; 4]))
}
_ => None,
};
let mut p = op(8);
p.u[0] = [8, unmult as u32, pr.b("clip") as u32, 0];
p.u[1] = sources;
p.f[0] = [bp, wp, 0.0, 0.0];
let out = e.scratch(b.img.width, b.img.height);
e.pixels("fxd_point", &p, &b.img, plane.as_ref(), &out, None);
Some(GBuf { img: out, ..b })
}

147
crates/gpu/src/fx_lut.rs Normal file
View File

@ -0,0 +1,147 @@
//! GPU effects, colour management (kernels in `shaders/fx_lut.wgsl`, every entry point
//! prefixed `fxl_`): Apply Color LUT, OCIO CDL / Color Space / Display / File / Look Transform
//! and Color Profile Converter; Lumetri Color's Input LUT and Look also use the LUT helpers.
//!
//! Each effect compiles to a colour program ([`effectcraft_effects::color_program`]: transfer
//! functions, matrices, ASC CDLs, tone mapping, gamut compression, LUTs) built next to the CPU
//! effect, which `fxl_point` interprets per pixel. LUTs (1D tables and 3D lattices, with a
//! cineSpace shaper) travel in the kernel's storage buffer and are interpolated in the kernel
//! exactly as the CPU does (nearest, trilinear or tetrahedral; inverse 1D tables and the
//! iterative 3D inverse): hardware 3D-texture filtering would round the interpolation weights
//! and cannot do tetrahedral interpolation. The CPU evaluates transfer functions, CDLs and
//! Color Profile Converter's matrix in f64; the kernel in f32 (within the 1e-3 tolerance).
//! Custom `.ocio` configurations render on the CPU.
use effectcraft_effects::{ColorOp, EffectCtx, Lut, Straight, Tf};
use crate::context::{Enc, Params};
use crate::effects::GBuf;
/// Compute entry points in `fx_lut.wgsl`.
pub(crate) const KERNELS: &[&str] = &["fxl_point"];
/// Effect ids implemented here.
pub(crate) const IDS: &[&str] = &[
"ec.utility.applylut",
"ec.color.ociocdl",
"ec.color.ociocolorspace",
"ec.color.ociodisplay",
"ec.color.ociofile",
"ec.color.ociolook",
"ec.utility.colorprofileconverter",
];
/// Largest LUT the kernels take (floats in the storage buffer); bigger ones render on the CPU.
const MAX_FLOATS: usize = 1 << 24;
/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU.
pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let (ops, straight) = effectcraft_effects::color_program(id, ctx)?;
if ops.is_empty() {
return Some(b);
}
let data = encode(&ops)?;
let buf = e.data(&data);
let mut p = Params::default();
p.u[0][0] = (straight == Straight::Divide) as u32;
let out = e.scratch(b.img.width, b.img.height);
e.pixels("fxl_point", &p, &b.img, None, &out, Some(&buf));
Some(GBuf { img: out, ..b })
}
fn tf_code(t: Tf) -> [f32; 2] {
match t {
Tf::Linear => [0.0, 0.0],
Tf::Srgb => [1.0, 0.0],
Tf::Gamma(g) => [2.0, g as f32],
Tf::AcesCct => [3.0, 0.0],
Tf::Pq => [4.0, 0.0],
}
}
/// A LUT in the layout `fxl_lut_*` read at the returned offset: n1, n3, domain min (3), domain
/// max (3), the 1D table (n1 × RGB), then the lattice (n3³ × RGB, red fastest).
pub(crate) fn push_lut(d: &mut Vec<f32>, lut: &Lut) -> f32 {
let at = d.len() as f32;
d.extend([lut.n1 as f32, lut.n3 as f32]);
d.extend(lut.dmin);
d.extend(lut.dmax);
for v in lut.d1.iter().chain(&lut.d3) {
d.extend(v);
}
at
}
/// Program layout: a stream of (opcode, arguments…) ending in 0, followed by LUT data.
fn encode(ops: &[ColorOp]) -> Option<Vec<f32>> {
let mut prog: Vec<f32> = vec![];
let mut tables: Vec<Vec<f32>> = vec![];
// LUT offsets are patched once the program's length is known.
let mut patches: Vec<(usize, usize)> = vec![];
for op in ops {
match op {
ColorOp::Decode(Tf::Linear) | ColorOp::Encode(Tf::Linear) => {}
ColorOp::Decode(t) => {
prog.push(1.0);
prog.extend(tf_code(*t));
}
ColorOp::Encode(t) => {
prog.push(2.0);
prog.extend(tf_code(*t));
}
ColorOp::Matrix(m) => {
prog.push(3.0);
for r in m {
prog.extend(r);
}
}
ColorOp::Cdl { cdl, clamp, inverse } => {
prog.push(4.0);
for v in cdl.slope.iter().chain(&cdl.offset).chain(&cdl.power) {
prog.push(*v as f32);
}
prog.extend([cdl.sat as f32, *clamp as u32 as f32, *inverse as u32 as f32]);
}
ColorOp::Tonemap { inverse } => prog.extend([5.0, *inverse as u32 as f32]),
ColorOp::Gamut(l) => prog.extend([6.0, l[0] as f32, l[1] as f32, l[2] as f32]),
ColorOp::Max0 => prog.push(7.0),
ColorOp::Lut { lut, shaper, interp, inverse } => {
prog.extend([8.0, 0.0, *interp as f32, *inverse as u32 as f32, -1.0]);
let mut t = vec![];
push_lut(&mut t, lut);
let lut_at = prog.len() - 4;
patches.push((lut_at, tables.len()));
tables.push(t);
if let Some(s) = shaper {
// Shaper: three point counts, then each channel's (x, y) points.
let mut t: Vec<f32> = s.iter().map(|c| c.len() as f32).collect();
for c in s.iter() {
for (x, y) in c {
t.extend([*x, *y]);
}
}
patches.push((prog.len() - 1, tables.len()));
tables.push(t);
}
}
}
}
prog.push(0.0);
let mut starts = vec![];
let mut at = prog.len();
for t in &tables {
starts.push(at);
at += t.len();
}
if at > MAX_FLOATS {
return None;
}
// Offsets travel as f32: exact below 2^24.
for (slot, table) in patches {
prog[slot] = starts[table] as f32;
}
for t in tables {
prog.extend(t);
}
Some(prog)
}

View File

@ -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);

424
crates/gpu/src/fx_sim.rs Normal file
View File

@ -0,0 +1,424 @@
//! GPU effects, simulation render passes (kernels in `shaders/fx_sim.wgsl`, every entry point
//! prefixed `fxm_`): CC Rainfall, CC Snowfall, CC Star Burst, CC Bubbles, CC Drizzle, CC Hair,
//! CC Mr. Mercury, Caustics, Wave World, Foam, Shatter, Card Dance and Card Wipe.
//!
//! The simulation (or closed-form layout) of every frame stays on the CPU, shared with the CPU
//! effect: sprite plans (`effects::sprite_plan`: rain drops, snow flakes, star-burst discs, hair
//! strands, Foam's bubbles, Wave World's wireframe), piece plans (`effects::piece_plan`: Card
//! Dance's cards, Shatter's shards, Card Wipe's cards, sorted far to near), CC Bubbles' and CC
//! Mr. Mercury's lists, CC Drizzle's drops, Wave World's height grid and Caustics' prepared
//! layers. The raster / render pass runs here: items are binned into 16 × 16 tiles on the CPU
//! and `fxm_raster` composites each pixel's items in the CPU's order with the CPU's shading
//! (colours that depend on the layer's own pixels — refracting rain, star-burst and hair-root
//! colours, bubble refraction — are sampled per pixel from the layer texture). Piece plans read
//! the layer's pixels on the CPU (gradient maps and textures default to the layer), so those
//! effects read the frame back once. Settings drawn otherwise (Shatter's wireframe views,
//! Foam's User Defined texture, Environment Map and flow-map preview) render on the CPU.
use effectcraft_effects::{Acc, EffectCtx, PieceTex, Post, Shape, SpritePlan, Tint};
use effectcraft_raster::Image;
use crate::context::{Enc, GpuImage, Params};
use crate::effects::{GBuf, gaussian_blur};
/// Compute entry points in `fx_sim.wgsl`.
pub(crate) const KERNELS: &[&str] = &["fxm_raster", "fxm_post", "fxm_mercury", "fxm_drizzle", "fxm_wave", "fxm_caustics"];
/// Effect ids implemented here.
pub(crate) const IDS: &[&str] = &[
"ec.sim.ccrainfall",
"ec.sim.ccsnowfall",
"ec.sim.ccstarburst",
"ec.sim.ccbubbles",
"ec.sim.ccdrizzle",
"ec.sim.cchair",
"ec.sim.ccmrmercury",
"ec.sim.caustics",
"ec.sim.waveworld",
"ec.sim.foam",
"ec.sim.shatter",
"ec.sim.carddance",
"ec.transition.cardwipe",
];
/// Largest item table (floats) the raster kernel takes; bigger plans render on the CPU.
const MAX_FLOATS: usize = 1 << 24;
const TILE: i32 = 16;
/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU.
pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let geo = effectcraft_effects::Buf { img: Image::new(0, 0), offset: b.offset, scale: b.scale };
match id {
"ec.sim.ccbubbles" => bubbles(e, ctx, b, &geo),
"ec.sim.ccdrizzle" => drizzle(e, ctx, b, &geo),
"ec.sim.ccmrmercury" => mercury(e, ctx, b, &geo),
"ec.sim.caustics" => caustics(e, ctx, b, &geo),
"ec.sim.waveworld" => match effectcraft_effects::wave_world_plan(ctx, &geo)? {
effectcraft_effects::WavePlan::Wire(plan) => sprites(e, b, &plan),
effectcraft_effects::WavePlan::Height { st, depth } => wave_height(e, ctx, b, &st, depth.as_deref()),
},
"ec.sim.shatter" | "ec.sim.carddance" | "ec.transition.cardwipe" => pieces(e, id, ctx, b),
"ec.sim.cchair" => match effectcraft_effects::sprite_plan(id, ctx, &geo) {
Some(plan) => sprites(e, b, &plan),
None => Some(b),
},
_ => {
let plan = effectcraft_effects::sprite_plan(id, ctx, &geo)?;
sprites(e, b, &plan)
}
}
}
// ---------------------------------------------------------------- tiled item raster
/// Items with their integer pixel bounds, binned into tiles (sim::raster's bands, finer).
struct Items {
stride: usize,
recs: Vec<f32>,
w: i32,
h: i32,
tiles: Vec<Vec<u32>>,
tx: i32,
}
impl Items {
fn new(stride: usize, w: u32, h: u32) -> Items {
let (w, h) = (w as i32, h as i32);
let tx = (w + TILE - 1) / TILE;
let ty = (h + TILE - 1) / TILE;
Items { stride, recs: vec![], w, h, tiles: vec![vec![]; (tx * ty).max(0) as usize], tx }
}
/// Add an item (`rec` = its fields, without the bounds) covering float bounds `bb`
/// (`None` or empty = skipped, as sim::raster).
fn push(&mut self, bb: Option<[f32; 4]>, rec: &[f32]) {
let Some(b) = bb else { return };
if !(b[0].is_finite() && b[1].is_finite() && b[2].is_finite() && b[3].is_finite()) {
return;
}
let x0 = b[0].floor().max(0.0) as i32;
let y0 = b[1].floor().max(0.0) as i32;
let x1 = (b[2].ceil() as i32).min(self.w - 1);
let y1 = (b[3].ceil() as i32).min(self.h - 1);
if x1 < x0 || y1 < y0 || b[2] < 0.0 || b[3] < 0.0 {
return;
}
let i = (self.recs.len() / self.stride) as u32;
let start = self.recs.len();
self.recs.extend_from_slice(rec);
self.recs.resize(start + self.stride - 4, 0.0);
self.recs.extend([x0 as f32, y0 as f32, x1 as f32, y1 as f32]);
for ty in y0 / TILE..=y1 / TILE {
for tx in x0 / TILE..=x1 / TILE {
self.tiles[(ty * self.tx + tx) as usize].push(i);
}
}
}
/// Kernel data: tile offsets (tiles + 1), item indices, items. `None` when too large.
fn data(self) -> Option<(Vec<f32>, u32, u32)> {
let n_tiles = self.tiles.len();
let n_idx: usize = self.tiles.iter().map(Vec::len).sum();
let total = n_tiles + 1 + n_idx + self.recs.len();
if total > MAX_FLOATS {
return None;
}
let mut d = Vec::with_capacity(total);
let base = (n_tiles + 1) as f32;
let mut at = 0usize;
for t in &self.tiles {
d.push(base + at as f32);
at += t.len();
}
d.push(base + at as f32);
for t in &self.tiles {
d.extend(t.iter().map(|&i| i as f32));
}
let items_at = d.len() as u32;
d.extend(self.recs);
Some((d, items_at, self.stride as u32))
}
}
/// Rasterise items over a transparent frame. `kind`: 0 sprites, 1 CC Bubbles, 2 Mr. Mercury
/// field, 3 pieces; `src` / `aux` = the textures the items sample.
#[allow(clippy::too_many_arguments)]
fn raster(e: &mut Enc, items: Items, kind: u32, add: bool, src: &GpuImage, aux: Option<&GpuImage>, mut p: Params, w: u32, h: u32) -> Option<GpuImage> {
let tx = items.tx as u32;
let (data, items_at, stride) = items.data()?;
let buf = e.data(&data);
p.u[0] = [kind, add as u32, items_at, stride];
p.u[1][0] = tx;
let out = e.scratch(w, h);
e.pixels("fxm_raster", &p, src, aux, &out, Some(&buf));
Some(out)
}
/// sim::sprite_bbox.
fn sprite_bbox(x: f32, y: f32, r: f32, c3: f32, shape: Shape) -> Option<[f32; 4]> {
if c3 <= 1e-5 || r <= 0.0 {
return None;
}
let r = r.max(0.5) + 1.0;
Some(match shape {
Shape::Line { dx, dy } => [x.min(x + dx) - r, y.min(y + dy) - r, x.max(x + dx) + r, y.max(y + dy) + r],
Shape::Star => [x - r * 2.0, y - r * 2.0, x + r * 2.0, y + r * 2.0],
_ => [x - r, y - r, x + r, y + r],
})
}
fn sprites(e: &mut Enc, b: GBuf, plan: &SpritePlan) -> Option<GBuf> {
let (w, h) = (b.img.width, b.img.height);
let mut items = Items::new(24, w, h);
for (i, s) in plan.sprites.iter().enumerate() {
let (kind, dx, dy) = match s.shape {
Shape::Soft => (0.0, 0.0, 0.0),
Shape::Disc => (1.0, 0.0, 0.0),
Shape::Sphere => (2.0, 0.0, 0.0),
Shape::Faded => (3.0, 0.0, 0.0),
Shape::Bubble => (4.0, 0.0, 0.0),
Shape::Star => (5.0, 0.0, 0.0),
Shape::Square => (6.0, 0.0, 0.0),
Shape::Tri => (7.0, 0.0, 0.0),
Shape::Line { dx, dy } => (8.0, dx, dy),
};
let mut rec = [kind, s.x, s.y, s.r, s.c[0], s.c[1], s.c[2], s.c[3], s.rot, dx, dy, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
match plan.tints.get(i).copied().unwrap_or(Tint::Fixed) {
Tint::Fixed => {}
Tint::Refract { x, y } => rec[11..14].copy_from_slice(&[1.0, x as f32, y as f32]),
Tint::Source { x, y } => rec[11..14].copy_from_slice(&[2.0, x as f32, y as f32]),
Tint::Root { x, y, hair, inherit, shade, spec } => {
rec[11..20].copy_from_slice(&[3.0, x as f32, y as f32, hair[0], hair[1], hair[2], inherit, shade, spec]);
}
}
items.push(sprite_bbox(s.x, s.y, s.r, s.c[3], s.shape), &rec);
}
let fx = raster(e, items, 0, plan.acc == Acc::Add, &b.img, None, Params::default(), w, h)?;
let mut p = Params::default();
match plan.post {
Post::Combine(m) => p.u[0] = [0, m, 0, 0],
Post::Replace => p.u[0] = [0, 5, 0, 0],
Post::Lerp(t) => {
p.u[0][0] = 1;
p.f[0][0] = t;
}
Post::OverBlack => p.u[0][0] = 2,
}
let out = e.scratch(w, h);
e.pixels("fxm_post", &p, &b.img, Some(&fx), &out, None);
Some(GBuf { img: out, ..b })
}
// ---------------------------------------------------------------- CC Bubbles / Mr. Mercury
fn bubbles(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &effectcraft_effects::Buf) -> Option<GBuf> {
let (w, h) = (b.img.width, b.img.height);
let mut items = Items::new(12, w, h);
for q in effectcraft_effects::bubble_list(ctx, geo) {
items.push(Some([q.x - q.r - 1.0, q.y - q.r - 1.0, q.x + q.r + 1.0, q.y + q.r + 1.0]), &[9.0, q.x, q.y, q.r, q.sx, q.sy]);
}
let mut p = Params::default();
p.u[1] = [0, (ctx.params.e("reflectionType") == 1) as u32, ctx.params.e("shadingType"), 0];
let img = raster(e, items, 1, false, &b.img, None, p, w, h)?;
Some(GBuf { img, ..b })
}
fn mercury(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &effectcraft_effects::Buf) -> Option<GBuf> {
let pr = ctx.params;
let (w, h) = (b.img.width, b.img.height);
let infl = 1.0 + pr.f("blobInfluence") as f32 / 100.0;
let mut items = Items::new(8, w, h);
for q in effectcraft_effects::mercury_blobs(ctx, geo) {
let ex = q.r * 2.0 * infl;
items.push(Some([q.x - ex, q.y - ex, q.x + ex, q.y + ex]), &[10.0, q.x, q.y, q.r]);
}
let mut p = Params::default();
p.f[0][0] = infl;
let field = raster(e, items, 2, true, &b.img, None, p, w, h)?;
let ldir = (pr.f("light/lightDirection") as f32).to_radians();
let lheight = pr.f("light/lightHeight") as f32 / 100.0;
let l = norm3([ldir.sin(), -ldir.cos(), lheight.max(0.05)]);
let lc = pr.color("light/lightColor");
let mut p = Params::default();
p.f[0] = [
pr.f("shading/ambient") as f32 / 100.0,
pr.f("shading/diffuse") as f32 / 100.0,
pr.f("shading/specular") as f32 / 100.0,
pr.f("shading/roughness").max(0.001) as f32,
];
p.f[1] = [pr.f("shading/metal") as f32 / 100.0, pr.f("light/lightIntensity") as f32 / 100.0, b.scale as f32, 0.0];
p.f[2] = [l[0], l[1], l[2], 0.0];
p.f[3] = [lc[0], lc[1], lc[2], 0.0];
let out = e.scratch(w, h);
e.pixels("fxm_mercury", &p, &b.img, Some(&field), &out, None);
Some(GBuf { img: out, ..b })
}
/// sim::norm3.
fn norm3(v: [f32; 3]) -> [f32; 3] {
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if l > 1e-9 { [v[0] / l, v[1] / l, v[2] / l] } else { [0.0, -1.0, 0.0] }
}
// ---------------------------------------------------------------- CC Drizzle
fn drizzle(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &effectcraft_effects::Buf) -> Option<GBuf> {
let pr = ctx.params;
let Some(drops) = effectcraft_effects::drizzle_drops(ctx, geo) else { return Some(b) };
let rippling = (pr.f("rippling") / 360.0).max(0.05) as f32;
let lh = ctx.layer_size[1] as f32;
let s = b.scale as f32;
let wavelen = (lh * 0.04 * s).max(2.0) / rippling.clamp(0.25, 4.0);
let width = wavelen * (1.0 + rippling * 2.0);
let ldir = (pr.f("light/lightDirection") as f32).to_radians();
let lheight = pr.f("light/lightHeight") as f32 / 100.0;
let (ls, lcs) = ldir.sin_cos();
let l = {
let v = [lcs, ls, lheight.max(0.05)];
let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
[v[0] / n, v[1] / n, v[2] / n]
};
let hz = (l[2] + 1.0) * 0.5;
let hv = {
let v = [l[0] * 0.5, l[1] * 0.5, hz];
let m = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
[v[0] / m, v[1] / m, v[2] / m]
};
let lc = pr.color("light/lightColor");
let d: Vec<f32> = drops.iter().flatten().copied().collect();
let buf = e.data(&d);
let mut p = Params::default();
p.u[0][0] = drops.len() as u32;
p.f[0] = [wavelen, width, pr.f("displacement") as f32 * s * 4.0, pr.f("shading/roughness").max(0.001) as f32];
p.f[1] = [l[0], l[1], l[2], pr.f("light/lightIntensity") as f32 / 100.0];
p.f[2] = [hv[0], hv[1], hv[2], pr.f("shading/specular") as f32 / 100.0];
p.f[3] = [lc[0], lc[1], lc[2], 0.0];
p.f[4] = [pr.f("shading/ambient") as f32 / 100.0, pr.f("shading/diffuse") as f32 / 100.0, 0.0, 0.0];
let out = e.scratch(b.img.width, b.img.height);
e.pixels("fxm_drizzle", &p, &b.img, None, &out, Some(&buf));
Some(GBuf { img: out, ..b })
}
// ---------------------------------------------------------------- Wave World (Height Map)
fn wave_height(e: &mut Enc, ctx: &EffectCtx, b: GBuf, st: &effectcraft_effects::Waves, depth: Option<&[f32]>) -> Option<GBuf> {
let pr = ctx.params;
let mut d = st.u.clone();
if let Some(dp) = depth {
d.extend_from_slice(dp);
}
let buf = e.data(&d);
let mut p = Params::default();
let dry_transparent = pr.e("heightMapControls/renderDryAreasAs") == 1 && depth.is_some();
p.u[0] = [st.nx as u32, st.ny as u32, dry_transparent as u32, 0];
p.f[0] = [
pr.f("heightMapControls/brightness") as f32,
pr.f("heightMapControls/contrast") as f32,
pr.f("heightMapControls/gamma").max(0.01) as f32,
1.0 - pr.f("heightMapControls/transparency") as f32,
];
p.f[1] = [b.offset[0] as f32, b.offset[1] as f32, b.scale as f32, ctx.layer_size[0].max(1.0) as f32];
let out = e.scratch(b.img.width, b.img.height);
e.pixels("fxm_wave", &p, &b.img, None, &out, Some(&buf));
Some(GBuf { img: out, ..b })
}
// ---------------------------------------------------------------- pieces
fn pieces(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
// Planning reads the layer (gradient maps and textures default to it).
let img = e.download(&b.img)?;
let cpu = effectcraft_effects::Buf { img, offset: b.offset, scale: b.scale };
let Some(plan) = effectcraft_effects::piece_plan(id, ctx, &cpu) else {
// Card Wipe before anything moves: the layer as is; Shatter's wireframes: the CPU.
return (id == "ec.transition.cardwipe").then_some(b);
};
let (w, h) = (b.img.width, b.img.height);
let tex = |t: &PieceTex| -> Option<GpuImage> {
match t {
PieceTex::Layer => Some(b.img.clone()),
PieceTex::Image(i) => e.g.upload_image(i),
}
};
let front = tex(&plan.front)?;
let back = match &plan.back {
Some(t) => Some(tex(t)?),
None => None,
};
let mut items = Items::new(48, w, h);
for q in &plan.pieces {
let m = q.inv;
let mut rec = vec![11.0];
for r in m {
rec.extend(r.map(|v| v as f32));
}
rec.extend(q.c0);
for v in q.poly {
rec.extend(v);
}
rec.extend([q.n as f32, q.shade]);
rec.extend(q.tint);
rec.extend(q.spec);
rec.push(q.alpha);
match q.flat {
Some(c) => rec.extend([1.0, c[0], c[1], c[2]]),
None => rec.extend([0.0; 4]),
}
rec.extend([q.back as u32 as f32, q.back_mirror as f32]);
items.push(Some(q.bbox), &rec);
}
let mut p = Params::default();
p.u[1][1] = back.is_some() as u32;
let img = raster(e, items, 3, false, &front, back.as_ref(), p, w, h)?;
Some(GBuf { img, ..b })
}
// ---------------------------------------------------------------- Caustics
fn caustics(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &effectcraft_effects::Buf) -> Option<GBuf> {
let st = effectcraft_effects::caustics_setup(ctx, geo);
let (w, h) = (b.img.width, b.img.height);
let mut bottom = match &st.bottom {
Some(i) => e.g.upload_image(i)?,
None => b.img.clone(),
};
if st.bottom_sigma > 0.0 {
bottom = gaussian_blur(e, &bottom, st.bottom_sigma, st.bottom_sigma, false);
}
let height = match &st.height {
Some(pl) => {
let mut img = Image::new(pl.w as u32, pl.h as u32);
for (px, v) in img.data.iter_mut().zip(&pl.data) {
*px = [*v, 0.0, 0.0, 0.0];
}
Some(e.g.upload_image(&img)?)
}
None => None,
};
let sky = match &st.sky {
Some(i) => {
let g = e.g.upload_image(i)?;
Some((e.image_rows(&g), g.width, g.height))
}
None => None,
};
let distant = {
let lw = st.lw.max(1.0);
let v = [(st.lx - st.cx) / lw, (st.ly - st.cy) / lw, st.lheight];
let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12);
[(v[0] / n) as f32, (v[1] / n) as f32, (v[2] / n) as f32]
};
let mut p = Params::default();
p.u[0] = [height.is_some() as u32, st.repeat, st.point as u32, st.sky_repeat];
p.u[1] = [sky.is_some() as u32, sky.as_ref().map_or(0, |s| s.0.1), sky.as_ref().map_or(0, |s| s.1), sky.as_ref().map_or(0, |s| s.2)];
p.f[0] = [st.scaling as f32, st.wave_h as f32, st.k as f32, st.lw as f32];
p.f[1] = [st.cx as f32, st.cy as f32, st.lx as f32, st.ly as f32];
p.f[2] = [st.surf[0], st.surf[1], st.surf[2], st.surf_op];
p.f[3] = [st.lc[0], st.lc[1], st.lc[2], st.li];
p.f[4] = [st.lheight as f32, st.cstr, st.ambient, st.diffuse];
p.f[5] = [st.specular, st.sharp, st.sky_scaling as f32, st.sky_int];
p.f[6] = [distant[0], distant[1], distant[2], st.convergence as f32];
let out = e.scratch(w, h);
e.pixels("fxm_caustics", &p, &bottom, height.as_ref(), &out, sky.as_ref().map(|s| &s.0.0));
Some(GBuf { img: out, ..b })
}

431
crates/gpu/src/fx_vr.rs Normal file
View File

@ -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<GBuf> {
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<GpuImage>) -> Option<GpuImage> {
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<GpuImage> {
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<GpuImage> {
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<f32> = (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<GpuImage> {
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<GpuImage> {
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<GpuImage> {
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<GpuImage> {
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<GpuImage> {
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<GpuImage> {
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<GpuImage> {
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<GpuImage> {
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)))
})
}

View File

@ -22,10 +22,14 @@
//! 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_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
//! (`fx_color`, `fx_depth`, `fx_distort`, `fx_extra`, `fx_generate`, `fx_key`, `fx_light`,
//! `fx_lut`, `fx_noise`, `fx_sim`, `fx_stylize`, `fx_text`, `fx_time`, `fx_tone`,
//! `fx_transition`, `fx_vr`, `fx_warp`); settings a kernel cannot match fall back to the CPU
//! (`catalog::gpu_supported`, or `None` from the family's `apply`).
//! The 3D Channel effects read the layer's aux channels as an extra texture (`fx_depth`), the
//! colour-management effects interpret colour programs built next to the CPU effects
//! (`fx_lut`), and simulations keep their state on the CPU and rasterise its per-frame plan here
//! (`fx_sim`).
//!
//! Working textures come from a pool (`context::Pool`): a frame allocates several full-frame
//! RGBA f32 images per layer, and creating and zeroing those cost more than compositing a small
@ -52,17 +56,21 @@ mod context;
pub mod deferred;
mod effects;
mod fx_color;
mod fx_depth;
mod fx_distort;
mod fx_extra;
mod fx_generate;
mod fx_key;
mod fx_light;
mod fx_lut;
mod fx_noise;
mod fx_sim;
mod fx_stylize;
mod fx_text;
mod fx_time;
mod fx_tone;
mod fx_transition;
mod fx_vr;
mod fx_warp;
mod ops;
mod particles;
@ -308,6 +316,8 @@ mod tests_deferred;
#[cfg(test)]
mod tests_fx_color;
#[cfg(test)]
mod tests_fx_depth;
#[cfg(test)]
mod tests_fx_distort;
#[cfg(test)]
mod tests_fx_extra;
@ -318,8 +328,12 @@ mod tests_fx_key;
#[cfg(test)]
mod tests_fx_light;
#[cfg(test)]
mod tests_fx_lut;
#[cfg(test)]
mod tests_fx_noise;
#[cfg(test)]
mod tests_fx_sim;
#[cfg(test)]
mod tests_fx_stylize;
#[cfg(test)]
mod tests_fx_text;
@ -330,6 +344,8 @@ mod tests_fx_tone;
#[cfg(test)]
mod tests_fx_transition;
#[cfg(test)]
mod tests_fx_vr;
#[cfg(test)]
mod tests_fx_warp;
#[cfg(test)]
mod tests_particles;

View File

@ -310,6 +310,43 @@ fn fxc_d3(i: u32) -> vec3<f32> {
// green, blue × shadows / midtones / highlights), 32..41 wheels (shadows, midtones, highlights),
// 41 vignette amount, 42 vignette start, 43 roundness, 44 feather, 45 cx, 46 cy, 47 lw, 48 lh,
// 49 aspect.
// color3::builtin_look.
fn fxc_builtin_look(look: u32, c: vec3<f32>) -> vec3<f32> {
let l = luminance(c);
switch (look) {
case 2u: {
return vec3<f32>(c.x * 1.06 + 0.015, c.y * 1.01 + 0.01, c.z * 0.88);
}
case 3u: {
return vec3<f32>(c.x * 0.82, c.y * 0.92, c.z * 1.08 + 0.02) * 0.92;
}
case 4u: {
let d = l + (c - l) * 0.45;
var o: vec3<f32>;
if (l < 0.5) {
o = 2.0 * d * l;
} else {
o = 1.0 - 2.0 * (1.0 - d) * (1.0 - l);
}
return d * 0.5 + o * 0.5;
}
case 5u: {
let t = clamp(l - 0.5, -0.5, 0.5);
return vec3<f32>(c.x + 0.18 * t, c.y + 0.03 * t, c.z - 0.16 * t);
}
case 6u: {
let v = 0.08 + c * 0.84;
return l + (v - l) * 0.8;
}
case 7u: {
return vec3<f32>(l);
}
default: {
return c;
}
}
}
@compute @workgroup_size(16, 16)
fn fxc_lumetri(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = fxc_pixel(gid);
@ -323,6 +360,9 @@ fn fxc_lumetri(@builtin(global_invocation_id) gid: vec3<u32>) {
return;
}
var c = fxc_unpremul(px);
if (data[51] >= 0.0) {
c = fxl_lut_apply(u32(data[51]), c, 2u);
}
let temp = data[0];
let tint = data[1];
if (temp != 0.0 || tint != 0.0) {
@ -359,8 +399,15 @@ fn fxc_lumetri(@builtin(global_invocation_id) gid: vec3<u32>) {
let st = fxc_d3(14u);
let ht = fxc_d3(17u);
let zero = vec3<f32>(0.0);
if (intensity != 0.0 && (faded != 0.0 || vib != 0.0 || csat != 1.0 || any(st != zero) || any(ht != zero))) {
let look = u32(data[50]);
let has_look = look >= 2u || data[52] >= 0.0;
if (intensity != 0.0 && (has_look || faded != 0.0 || vib != 0.0 || csat != 1.0 || any(st != zero) || any(ht != zero))) {
var d = c;
if (data[52] >= 0.0) {
d = fxl_lut_apply(u32(data[52]), c, 2u);
} else {
d = fxc_builtin_look(look, c);
}
if (faded != 0.0) {
d = d * (1.0 - 0.25 * faded) + 0.12 * faded;
}

View File

@ -0,0 +1,307 @@
// ---------------------------------------------------------------- 3D Channel family (fx_depth.rs)
//
// `aux` planes are buffer-sized resamplings of the layer's auxiliary channels (fxd_lookup); the
// alpha of a plane is 1 inside the aux image and 0 outside (where the effect's fill applies).
fn fxd_unpremul(p: vec4<f32>) -> vec4<f32> {
if (p.w > 1e-6) {
return vec4<f32>(p.xyz / p.w, p.w);
}
return vec4<f32>(0.0, 0.0, 0.0, max(p.w, 0.0));
}
// f32::signum (+0 → 1).
fn fxd_signum(v: f32) -> f32 {
return select(-1.0, 1.0, v >= 0.0);
}
// Nearest aux texel under each buffer pixel (AuxChannels::index_at), `fill` outside.
// f[0] = (offset x, offset y, 1 / buffer scale, aux scale); f[1].xy = sub-pixel position; f[2] = fill.
@compute @workgroup_size(16, 16)
fn fxd_lookup(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let lx = (f32(p.x) + P.f[1].x - P.f[0].x) * P.f[0].z;
let ly = (f32(p.y) + P.f[1].y - P.f[0].y) * P.f[0].z;
let ax = floor(lx * P.f[0].w);
let ay = floor(ly * P.f[0].w);
let ad = vec2<f32>(textureDimensions(src));
var v = P.f[2];
if (ax >= 0.0 && ay >= 0.0 && ax < ad.x && ay < ad.y) {
v = textureLoad(src, vec2<i32>(i32(ax), i32(ay)), 0);
}
textureStore(out, p, v);
}
// 3D Channel Extract's black / white point mapping.
fn fxd_lv(v: f32) -> f32 {
let bp = P.f[0].x;
let wp = P.f[0].y;
let clampo = P.u[0].z != 0u;
var o: f32;
if (wp >= bp) {
o = (v - bp) / max(abs(wp - bp), 1e-9) * fxd_signum(wp - bp);
if (clampo) {
o = clamp(o, 0.0, 1.0);
}
} else {
var q = (v - wp) / max(bp - wp, 1e-9);
if (clampo) {
q = clamp(q, 0.0, 1.0);
}
o = 1.0 - q;
}
if (P.u[0].w != 0u) {
o = 1.0 - o;
}
return o;
}
// channel3d::id_color.
fn fxd_id_color(id: f32) -> vec3<f32> {
if (id == 0.0) {
return vec3<f32>(0.0);
}
let h = hash_noise(bitcast<u32>(id), 0x1du, 0x5eedu);
return hsl_to_rgb(h, 0.75, 0.5);
}
// u[0].x = op; the effects' per-pixel steps (src = layer, aux = plane unless noted).
@compute @workgroup_size(16, 16)
fn fxd_point(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let px = textureLoad(src, p, 0);
let q = tex_get(aux, p.x, p.y);
var o = px;
switch (P.u[0].x) {
// 3D Channel Extract. u[0] = (op, mode, clamp, invert); f[0] = (black, white).
case 0u: {
if (q.w == 0.0) {
o = vec4<f32>(0.0);
} else {
var c = q.xyz;
switch (P.u[0].y) {
case 1u: {
c = vec3<f32>(fxd_lv(c.x), fxd_lv(c.y), 0.0);
}
case 2u: {
c = vec3<f32>(fxd_lv(c.x * 0.5 + 0.5), fxd_lv(c.y * 0.5 + 0.5), fxd_lv(c.z * 0.5 + 0.5));
}
case 4u: {}
default: {
c = vec3<f32>(fxd_lv(c.x), fxd_lv(c.y), fxd_lv(c.z));
}
}
o = vec4<f32>(c, 1.0);
}
}
// Depth Matte. u[0].y = invert; f[0] = (depth, feather).
case 1u: {
let zv = q.x;
let depth = P.f[0].x;
let feather = P.f[0].y;
var k: f32;
if (feather > 0.0) {
k = clamp((zv - depth) / feather + 0.5, 0.0, 1.0);
} else {
k = select(0.0, 1.0, zv >= depth);
}
if (P.u[0].y != 0u) {
k = 1.0 - k;
}
o = px * k;
}
// Fog 3D. u[0] = (op, foggy background, gradient in aux.y); f[0] = (start, end, opacity,
// density); f[1] = (fog colour, contribution); f[2].x = background depth.
case 2u: {
let zv = q.x;
let bg = zv >= P.f[2].x * 0.5;
let foggy = P.u[0].y != 0u;
var f: f32;
if (bg) {
f = select(0.0, 1.0, foggy);
} else {
let t = clamp((zv - P.f[0].x) / max(abs(P.f[0].y - P.f[0].x), 1e-6), 0.0, 1.0);
f = 1.0 - powz(1.0 - t, 1.0 + P.f[0].w * 3.0);
}
if (P.u[0].z != 0u) {
f *= q.y;
}
f = clamp(f * P.f[0].z, 0.0, 1.0);
let s = fxd_unpremul(px);
var al = s.w;
if (bg && foggy) {
al = s.w + (1.0 - s.w) * f;
}
let c = s.xyz + (P.f[1].xyz - s.xyz) * f;
o = vec4<f32>(c * al, al);
}
// Fog 3D gradient factor (src = fitted gradient layer, aux = depth plane). f[0].x = contribution.
case 3u: {
let g = fxd_unpremul(px);
let l = luminance(g.xyz) * g.w;
o = vec4<f32>(q.x, 1.0 - P.f[0].x + P.f[0].x * l, 0.0, 0.0);
}
// ID Matte's matte (src = plane). u[0].y = use coverage; f[0].x = ID.
case 4u: {
var m = 0.0;
if (px.w != 0.0 && round_away(px.x) == P.f[0].x) {
m = select(1.0, clamp(px.y, 0.0, 1.0), P.u[0].y != 0u);
}
o = vec4<f32>(m);
}
// ID Matte applied (aux = blurred matte). u[0].y = invert.
case 5u: {
var k = q.x;
if (P.u[0].y != 0u) {
k = 1.0 - k;
}
o = px * k;
}
// IDentifier. u[0].y = display; f[0] = (ID, largest ID).
case 6u: {
let display = P.u[0].y;
if (q.w == 0.0) {
if (display != 2u) {
o = vec4<f32>(0.0);
}
} else {
let id = round_away(q.x);
let sel = P.f[0].x;
let hit = id == sel;
switch (display) {
case 0u: {
var c = fxd_id_color(id);
if (hit && sel != 0.0) {
c = vec3<f32>(1.0);
}
o = vec4<f32>(c, 1.0);
}
case 1u: {
let v = select(0.0, 1.0, hit);
o = vec4<f32>(v, v, v, 1.0);
}
case 2u: {
o = select(vec4<f32>(0.0), px, hit);
}
default: {
let v = id / P.f[0].y;
o = vec4<f32>(v, v, v, 1.0);
}
}
}
}
// Cryptomatte (aux = (ID colours, selection coverage)). u[0] = (op, display, matte only).
case 7u: {
let m = q.w;
if (P.u[0].z != 0u) {
o = vec4<f32>(m);
} else if (P.u[0].y == 0u) {
o = vec4<f32>(q.xyz * (1.0 - 0.5 * m) + 0.5 * m, 1.0);
} else if (P.u[0].y == 1u) {
o = px * m;
} else {
o = vec4<f32>(px.x * (1.0 - 0.5 * m) + 0.5 * m * px.w, px.y * (1.0 - 0.5 * m) + 0.5 * m * px.w, px.z * (1.0 - 0.5 * m), px.w);
}
}
// EXtractoR. u[0] = (op, unmult, clip); u[1] = per-channel source (0..3 own, 4 aux, 5 default);
// f[0] = (black, white).
case 8u: {
let s = fxd_unpremul(px);
let straight = vec4<f32>(s.xyz, s.w);
var v = vec4<f32>(0.0);
for (var k = 0; k < 4; k++) {
let kind = P.u[1][k];
var x: f32;
if (kind == 4u) {
x = q[k];
} else if (kind < 4u) {
x = straight[kind];
} else {
x = select(0.0, 1.0, k == 3);
}
if (k < 3) {
let bp = P.f[0].x;
let wp = P.f[0].y;
x = (x - bp) / max(abs(wp - bp), 1e-9) * fxd_signum(wp - bp);
if (P.u[0].z != 0u) {
x = clamp(x, 0.0, 1.0);
}
}
v[k] = x;
}
let al = clamp(v.w, 0.0, 1.0);
var c = v.xyz;
if (P.u[0].y != 0u && al > 1e-6) {
c = v.xyz / al;
}
o = vec4<f32>(c * al, al);
}
default: {}
}
textureStore(out, p, o);
}
// Depth of Field: each pixel's position in the blur stack (src = depth plane).
// u[0].x = levels; f[0] = (focal plane, thickness, maximum radius, bias exponent).
@compute @workgroup_size(16, 16)
fn fxd_dof_f(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let z = textureLoad(src, p, 0).x;
let focal = P.f[0].x;
let max_r = P.f[0].z;
let d = max(abs(z - focal) - P.f[0].y * 0.5, 0.0);
let range = max(abs(focal), 1.0);
let n = min(d / range, 1.0);
let r = max_r * powz(n, P.f[0].w);
let top = f32(P.u[0].x - 1u);
let f = clamp(r / max_r * top, 0.0, top);
textureStore(out, p, vec4<f32>(f, 0.0, 0.0, 0.0));
}
// Depth of Field: pixels between stack levels k (src) and k + 1 (aux); the positions come
// through `data` (rows of RGBA f32). u[0] = (k, row length, levels).
@compute @workgroup_size(16, 16)
fn fxd_dof_level(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let f = data[(u32(p.y) * P.u[0].y + u32(p.x)) * 4u];
let k0 = min(u32(floor(f)), P.u[0].z - 2u);
if (k0 != P.u[0].x) {
return;
}
let t = f - f32(k0);
let a0 = textureLoad(src, p, 0);
let a1 = textureLoad(aux, p, 0);
textureStore(out, p, a0 + (a1 - a0) * t);
}
// u[0].x = 0: src + aux; 1: src × 0.25.
@compute @workgroup_size(16, 16)
fn fxd_avg(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let a = textureLoad(src, p, 0);
if (P.u[0].x == 0u) {
textureStore(out, p, a + textureLoad(aux, p, 0));
} else {
textureStore(out, p, a * 0.25);
}
}

View File

@ -0,0 +1,474 @@
// ---------------------------------------------------------------- colour management (fx_lut.rs)
//
// A colour program in `data`: opcodes with their arguments, ending in 0, then LUT tables
// (layout in fx_lut.rs::push_lut). Every step mirrors effects::ocio / effects::utility.
fn fxl_d3(i: u32) -> vec3<f32> {
return vec3<f32>(data[i], data[i + 1u], data[i + 2u]);
}
// ---- LUTs (utility::Lut)
fn fxl_lut_norm(o: u32, c: vec3<f32>) -> vec3<f32> {
let dmin = fxl_d3(o + 2u);
let dmax = fxl_d3(o + 5u);
return clamp((c - dmin) / max(dmax - dmin, vec3<f32>(1e-9)), vec3<f32>(0.0), vec3<f32>(1.0));
}
fn fxl_lut_3d(base: u32, n: u32, r: u32, g: u32, b: u32) -> vec3<f32> {
return fxl_d3(base + (r + g * n + b * n * n) * 3u);
}
// Lut::apply_interp: 0 nearest, 1 trilinear, otherwise tetrahedral (1D tables linear unless 0).
fn fxl_lut_apply(o: u32, c0: vec3<f32>, interp: u32) -> vec3<f32> {
let n1 = u32(data[o]);
let n3 = u32(data[o + 1u]);
let b1 = o + 8u;
let b3 = b1 + n1 * 3u;
var c = c0;
if (n1 > 1u) {
let t = fxl_lut_norm(o, c);
for (var i = 0u; i < 3u; i++) {
let x = t[i] * f32(n1 - 1u);
if (interp == 0u) {
c[i] = data[b1 + min(u32(round_away(x)), n1 - 1u) * 3u + i];
} else {
let i0 = min(u32(floor(x)), n1 - 2u);
let f = x - f32(i0);
let a = data[b1 + i0 * 3u + i];
let b = data[b1 + (i0 + 1u) * 3u + i];
c[i] = a + (b - a) * f;
}
}
}
if (n3 > 1u) {
let t = fxl_lut_norm(o, c);
let s = f32(n3 - 1u);
if (interp == 0u) {
let ir = min(u32(round_away(t.x * s)), n3 - 1u);
let ig = min(u32(round_away(t.y * s)), n3 - 1u);
let ib = min(u32(round_away(t.z * s)), n3 - 1u);
return fxl_lut_3d(b3, n3, ir, ig, ib);
}
let v = t * s;
let r = min(u32(floor(v.x)), n3 - 2u);
let g = min(u32(floor(v.y)), n3 - 2u);
let b = min(u32(floor(v.z)), n3 - 2u);
let fr = v.x - f32(r);
let fg = v.y - f32(g);
let fb = v.z - f32(b);
if (interp == 1u) {
let c00 = mix_exact(fxl_lut_3d(b3, n3, r, g, b), fxl_lut_3d(b3, n3, r + 1u, g, b), fr);
let c10 = mix_exact(fxl_lut_3d(b3, n3, r, g + 1u, b), fxl_lut_3d(b3, n3, r + 1u, g + 1u, b), fr);
let c01 = mix_exact(fxl_lut_3d(b3, n3, r, g, b + 1u), fxl_lut_3d(b3, n3, r + 1u, g, b + 1u), fr);
let c11 = mix_exact(fxl_lut_3d(b3, n3, r, g + 1u, b + 1u), fxl_lut_3d(b3, n3, r + 1u, g + 1u, b + 1u), fr);
return mix_exact(mix_exact(c00, c10, fg), mix_exact(c01, c11, fg), fb);
}
let c000 = fxl_lut_3d(b3, n3, r, g, b);
let c111 = fxl_lut_3d(b3, n3, r + 1u, g + 1u, b + 1u);
var w1: f32;
var w2: f32;
var w3: f32;
var p1: vec3<f32>;
var p2: vec3<f32>;
if (fr > fg) {
if (fg > fb) {
w1 = fr;
w2 = fg;
w3 = fb;
p1 = fxl_lut_3d(b3, n3, r + 1u, g, b);
p2 = fxl_lut_3d(b3, n3, r + 1u, g + 1u, b);
} else if (fr > fb) {
w1 = fr;
w2 = fb;
w3 = fg;
p1 = fxl_lut_3d(b3, n3, r + 1u, g, b);
p2 = fxl_lut_3d(b3, n3, r + 1u, g, b + 1u);
} else {
w1 = fb;
w2 = fr;
w3 = fg;
p1 = fxl_lut_3d(b3, n3, r, g, b + 1u);
p2 = fxl_lut_3d(b3, n3, r + 1u, g, b + 1u);
}
} else if (fb > fg) {
w1 = fb;
w2 = fg;
w3 = fr;
p1 = fxl_lut_3d(b3, n3, r, g, b + 1u);
p2 = fxl_lut_3d(b3, n3, r, g + 1u, b + 1u);
} else if (fb > fr) {
w1 = fg;
w2 = fb;
w3 = fr;
p1 = fxl_lut_3d(b3, n3, r, g + 1u, b);
p2 = fxl_lut_3d(b3, n3, r, g + 1u, b + 1u);
} else {
w1 = fg;
w2 = fr;
w3 = fb;
p1 = fxl_lut_3d(b3, n3, r, g + 1u, b);
p2 = fxl_lut_3d(b3, n3, r + 1u, g + 1u, b);
}
return c000 + w1 * (p1 - c000) + w2 * (p2 - p1) + w3 * (c111 - p2);
}
return c;
}
// a + (b - a) t, per component (the CPU's lerp, not WGSL's mix).
fn mix_exact(a: vec3<f32>, b: vec3<f32>, t: f32) -> vec3<f32> {
return a + (b - a) * t;
}
// ocio::shape over `n` (x, y) points at `at` (`flip`: the curve with x and y swapped).
fn fxl_shape(at: u32, n: u32, x: f32, flip: bool) -> f32 {
if (n < 2u) {
return x;
}
let kx = select(0u, 1u, flip);
let ky = 1u - kx;
// partition_point(|p| p.x < x).
var lo = 0u;
var hi = n;
while (lo < hi) {
let mid = (lo + hi) / 2u;
if (data[at + mid * 2u + kx] < x) {
lo = mid + 1u;
} else {
hi = mid;
}
}
let i = clamp(lo, 1u, n - 1u);
let ax = data[at + (i - 1u) * 2u + kx];
let ay = data[at + (i - 1u) * 2u + ky];
let bx = data[at + i * 2u + kx];
let by = data[at + i * 2u + ky];
let t = clamp((x - ax) / max(bx - ax, 1e-12), 0.0, 1.0);
return ay + (by - ay) * t;
}
// Shaper channel k's points (three counts, then the channels' (x, y) pairs).
fn fxl_shaper_at(s: u32, k: u32) -> vec2<u32> {
var at = s + 3u;
for (var j = 0u; j < k; j++) {
at += u32(data[s + j]) * 2u;
}
return vec2<u32>(at, u32(data[s + k]));
}
fn fxl_file_fwd(o: u32, sh: i32, c0: vec3<f32>, interp: u32) -> vec3<f32> {
var c = c0;
if (sh >= 0) {
for (var k = 0u; k < 3u; k++) {
let a = fxl_shaper_at(u32(sh), k);
c[k] = fxl_shape(a.x, a.y, c[k], false);
}
}
return fxl_lut_apply(o, c, interp);
}
// The inverse of a 1D table channel (ocio::FileXform::apply: points (table, domain)).
fn fxl_inv_1d(o: u32, i: u32, y: f32) -> f32 {
let n = u32(data[o]);
let dmin = data[o + 2u + i];
let dmax = data[o + 5u + i];
let b1 = o + 8u;
// partition_point over the table's values.
var lo = 0u;
var hi = n;
while (lo < hi) {
let mid = (lo + hi) / 2u;
if (data[b1 + mid * 3u + i] < y) {
lo = mid + 1u;
} else {
hi = mid;
}
}
let k = clamp(lo, 1u, n - 1u);
let ax = data[b1 + (k - 1u) * 3u + i];
let bx = data[b1 + k * 3u + i];
let ay = dmin + (dmax - dmin) * f32(k - 1u) / f32(n - 1u);
let by = dmin + (dmax - dmin) * f32(k) / f32(n - 1u);
let t = clamp((y - ax) / max(bx - ax, 1e-12), 0.0, 1.0);
return ay + (by - ay) * t;
}
fn fxl_file(o: u32, sh: i32, c: vec3<f32>, interp: u32, inverse: bool) -> vec3<f32> {
if (!inverse) {
return fxl_file_fwd(o, sh, c, interp);
}
let n1 = u32(data[o]);
let n3 = u32(data[o + 1u]);
if (n3 == 0u) {
var x = c;
if (n1 > 1u) {
x = vec3<f32>(fxl_inv_1d(o, 0u, c.x), fxl_inv_1d(o, 1u, c.y), fxl_inv_1d(o, 2u, c.z));
}
if (sh >= 0) {
for (var k = 0u; k < 3u; k++) {
let a = fxl_shaper_at(u32(sh), k);
x[k] = fxl_shape(a.x, a.y, x[k], true);
}
}
return x;
}
// Damped fixed point from the target, as the CPU.
var x = c;
for (var it = 0; it < 40; it++) {
let y = fxl_file_fwd(o, sh, x, interp);
let e = c - y;
if (all(abs(e) < vec3<f32>(1e-6))) {
break;
}
x = x + e;
}
return x;
}
// ---- transfer functions (ocio::Tf)
const FXL_PQ_M1: f32 = 0.1593017578125;
const FXL_PQ_M2: f32 = 78.84375;
const FXL_PQ_C1: f32 = 0.8359375;
const FXL_PQ_C2: f32 = 18.8515625;
const FXL_PQ_C3: f32 = 18.6875;
fn fxl_copysign(m: f32, v: f32) -> f32 {
return select(abs(m), -abs(m), v < 0.0 || (v == 0.0 && bitcast<u32>(v) != 0u));
}
fn fxl_decode(tf: u32, g: f32, v: f32) -> f32 {
switch (tf) {
case 1u: {
let a = abs(v);
var l: f32;
if (a <= 0.04045) {
l = a / 12.92;
} else {
l = pow((a + 0.055) / 1.055, 2.4);
}
return fxl_copysign(l, v);
}
case 2u: {
return fxl_copysign(powz(abs(v), g), v);
}
case 3u: {
if (v <= 0.155251141552511) {
return (v - 0.0729055341958355) / 10.5402377416545;
}
if (v < (log2(65504.0) + 9.72) / 17.52) {
return exp2(v * 17.52 - 9.72);
}
return 65504.0;
}
case 4u: {
let e = powz(max(v, 0.0), 1.0 / FXL_PQ_M2);
let l = powz(max(e - FXL_PQ_C1, 0.0) / (FXL_PQ_C2 - FXL_PQ_C3 * e), 1.0 / FXL_PQ_M1);
return l * 100.0;
}
default: {
return v;
}
}
}
fn fxl_encode(tf: u32, g: f32, v: f32) -> f32 {
switch (tf) {
case 1u: {
let a = abs(v);
var e: f32;
if (a <= 0.0031308) {
e = a * 12.92;
} else {
e = 1.055 * pow(a, 1.0 / 2.4) - 0.055;
}
return fxl_copysign(e, v);
}
case 2u: {
return fxl_copysign(powz(abs(v), 1.0 / g), v);
}
case 3u: {
if (v <= 0.0078125) {
return 10.5402377416545 * v + 0.0729055341958355;
}
return (log2(v) + 9.72) / 17.52;
}
case 4u: {
let y = powz(max(v / 100.0, 0.0), FXL_PQ_M1);
return powz((FXL_PQ_C1 + FXL_PQ_C2 * y) / (1.0 + FXL_PQ_C3 * y), FXL_PQ_M2);
}
default: {
return v;
}
}
}
// ---- ASC CDL (ocio::Cdl), args at `a`: slope (3), offset (3), power (3), sat, clamp, inverse.
const FXL_LUMA: vec3<f32> = vec3<f32>(0.2126, 0.7152, 0.0722);
fn fxl_cdl(a: u32, c0: vec3<f32>) -> vec3<f32> {
let slope = fxl_d3(a);
let offset = fxl_d3(a + 3u);
let power = fxl_d3(a + 6u);
let sat = data[a + 9u];
let clampo = data[a + 10u] != 0.0;
let zero = vec3<f32>(0.0);
let one = vec3<f32>(1.0);
if (data[a + 11u] == 0.0) {
var o = vec3<f32>(0.0);
for (var i = 0u; i < 3u; i++) {
let v = c0[i] * slope[i] + offset[i];
if (clampo) {
o[i] = powz(clamp(v, 0.0, 1.0), power[i]);
} else if (v >= 0.0) {
o[i] = powz(v, power[i]);
} else {
o[i] = v;
}
}
let l = o.x * FXL_LUMA.x + o.y * FXL_LUMA.y + o.z * FXL_LUMA.z;
o = l + sat * (o - l);
if (clampo) {
o = clamp(o, zero, one);
}
return o;
}
var c = c0;
if (clampo) {
c = clamp(c, zero, one);
}
let l = c.x * FXL_LUMA.x + c.y * FXL_LUMA.y + c.z * FXL_LUMA.z;
var s = sat;
if (abs(s) < 1e-9) {
s = 1e-9;
}
let o = l + (c - l) / s;
var r = vec3<f32>(0.0);
for (var i = 0u; i < 3u; i++) {
var v = o[i];
if (clampo) {
v = clamp(v, 0.0, 1.0);
}
let pw = max(power[i], 1e-9);
var u = v;
if (v >= 0.0) {
u = powz(v, 1.0 / pw);
}
var sl = slope[i];
if (abs(sl) < 1e-9) {
sl = 1e-9;
}
r[i] = (u - offset[i]) / sl;
}
if (clampo) {
r = clamp(r, zero, one);
}
return r;
}
fn fxl_tonemap(v: f32, inverse: bool) -> f32 {
if (!inverse) {
if (v <= 0.0) {
return v;
}
return v * (1.0 + v / 256.0) / (1.0 + v);
}
if (v <= 0.0) {
return v;
}
let a = 1.0 / 256.0;
let b = 1.0 - v;
let c = -v;
return (-b + sqrt(b * b - 4.0 * a * c)) / (2.0 * a);
}
// Run the colour program at data[0..] on a straight colour.
fn fxl_run(c0: vec3<f32>) -> vec3<f32> {
var c = c0;
var pc = 0u;
for (var guard = 0; guard < 256; guard++) {
let op = u32(data[pc]);
switch (op) {
case 1u, 2u: {
let tf = u32(data[pc + 1u]);
let g = data[pc + 2u];
for (var i = 0u; i < 3u; i++) {
if (op == 1u) {
c[i] = fxl_decode(tf, g, c[i]);
} else {
c[i] = fxl_encode(tf, g, c[i]);
}
}
pc += 3u;
}
case 3u: {
let r0 = fxl_d3(pc + 1u);
let r1 = fxl_d3(pc + 4u);
let r2 = fxl_d3(pc + 7u);
c = vec3<f32>(r0.x * c.x + r0.y * c.y + r0.z * c.z, r1.x * c.x + r1.y * c.y + r1.z * c.z, r2.x * c.x + r2.y * c.y + r2.z * c.z);
pc += 10u;
}
case 4u: {
c = fxl_cdl(pc + 1u, c);
pc += 13u;
}
case 5u: {
let inv = data[pc + 1u] != 0.0;
c = vec3<f32>(fxl_tonemap(c.x, inv), fxl_tonemap(c.y, inv), fxl_tonemap(c.z, inv));
pc += 2u;
}
case 6u: {
let luma = fxl_d3(pc + 1u);
let l = c.x * luma.x + c.y * luma.y + c.z * luma.z;
if (l <= 0.0) {
c = max(c, vec3<f32>(0.0));
} else {
let mn = min(min(c.x, c.y), c.z);
if (mn < 0.0) {
let t = clamp(l / (l - mn), 0.0, 1.0);
c = l + (c - l) * t;
}
}
pc += 4u;
}
case 7u: {
c = max(c, vec3<f32>(0.0));
pc += 1u;
}
case 8u: {
let o = u32(data[pc + 1u]);
let interp = u32(data[pc + 2u]);
let inv = data[pc + 3u] != 0.0;
let sh = i32(data[pc + 4u]);
c = fxl_file(o, sh, c, interp, inv);
pc += 5u;
}
default: {
return c;
}
}
}
return c;
}
// u[0].x = 0: OCIO pixels (unpremul); 1: colour ÷ alpha (Image::map_straight).
@compute @workgroup_size(16, 16)
fn fxl_point(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let px = textureLoad(src, p, 0);
let a = px.w;
if (a <= 0.0) {
textureStore(out, p, px);
return;
}
var c = px.xyz / a;
if (P.u[0].x == 0u && a <= 1e-6) {
c = vec3<f32>(0.0);
}
textureStore(out, p, vec4<f32>(fxl_run(c) * a, a));
}

View File

@ -0,0 +1,653 @@
// ---------------------------------------------------------------- simulation render passes (fx_sim.rs)
//
// Items (sprites, CC Bubbles bubbles, Mr. Mercury blobs, textured pieces) are binned into 16 × 16
// tiles: data = tile offsets, item indices, then the item records (u[0].w floats each, the last
// four the item's integer pixel bounds). Every shading step mirrors effects::sim / sim2.
fn fxm_unpremul(p: vec4<f32>) -> vec4<f32> {
if (p.w > 1e-6) {
return vec4<f32>(p.xyz / p.w, p.w);
}
return vec4<f32>(0.0, 0.0, 0.0, max(p.w, 0.0));
}
fn fxm_aa(edge: f32) -> f32 {
return clamp(edge + 0.5, 0.0, 1.0);
}
struct FxmShade {
ok: bool,
c: vec4<f32>,
}
fn fxm_none() -> FxmShade {
return FxmShade(false, vec4<f32>(0.0));
}
// sim::sprite_shade, after resolving the sprite's tint (sim::SpritePlan::resolve) against `src`.
fn fxm_sprite(at: u32, x: f32, y: f32) -> FxmShade {
let kind = u32(data[at]);
let sx = data[at + 1u];
let sy = data[at + 2u];
let r0 = data[at + 3u];
var c = vec4<f32>(data[at + 4u], data[at + 5u], data[at + 6u], data[at + 7u]);
let tk = u32(data[at + 11u]);
if (tk != 0u) {
let smp = sample_bilinear(src, data[at + 12u], data[at + 13u]);
let s = fxm_unpremul(smp);
if (tk == 1u) {
if (s.w > 0.0) {
c = vec4<f32>((s.xyz * 0.6 + 0.5) * c.xyz, c.w);
}
} else if (tk == 2u) {
if (s.w <= 0.0) {
c = vec4<f32>(0.0);
} else {
c = vec4<f32>(s.xyz, s.w * c.w);
}
} else {
if (smp.w < 0.5) {
c = vec4<f32>(0.0);
} else {
let hair = vec3<f32>(data[at + 14u], data[at + 15u], data[at + 16u]);
let inherit = data[at + 17u];
let base = s.xyz + (hair - s.xyz) * (1.0 - inherit);
c = vec4<f32>(base * data[at + 18u] + data[at + 19u], c.w);
}
}
}
if (c.w <= 1e-5) {
return fxm_none();
}
var r = r0;
var fade = 1.0;
if (r0 < 0.5) {
r = 0.5;
fade = r0 / 0.5;
}
let dx = x - sx;
let dy = y - sy;
var rgb = c.xyz;
var cov = 0.0;
switch (kind) {
case 0u: {
let d2 = (dx * dx + dy * dy) / (r * r);
if (d2 >= 1.0) {
return fxm_none();
}
let q = 1.0 - d2;
cov = q * q;
}
case 1u: {
cov = fxm_aa(r - sqrt(dx * dx + dy * dy));
}
case 2u: {
let d = sqrt(dx * dx + dy * dy);
let cv = fxm_aa(r - d);
if (cv <= 0.0) {
return fxm_none();
}
let nx = dx / r;
let ny = dy / r;
let nz = sqrt(max(1.0 - nx * nx - ny * ny, 0.0));
let diff = max(nx * -0.48 + ny * -0.58 + nz * 0.66, 0.0);
let rz = 2.0 * diff * nz - 0.66;
let spec = powz(max(rz, 0.0), 20.0) * 0.6;
let k = 0.2 + 0.8 * diff;
rgb = rgb * k + spec;
cov = cv;
}
case 3u: {
let d = sqrt(dx * dx + dy * dy) / r;
cov = powz(max(1.0 - d, 0.0), 1.5);
}
case 4u: {
let d = sqrt(dx * dx + dy * dy) / r;
if (d > 1.0 + 1.0 / r) {
return fxm_none();
}
let rr = (d - 0.9) / 0.12;
let ring = max(1.0 - rr * rr, 0.0);
let hx = dx / r + 0.4;
let hy = dy / r + 0.4;
let hl = max(1.0 - (hx * hx + hy * hy) / 0.04, 0.0);
cov = min(0.12 * select(0.0, 1.0, d <= 1.0) + ring * 0.8 + hl, 1.0);
}
case 5u: {
let sn = sin(data[at + 8u]);
let cs = cos(data[at + 8u]);
let ux = (dx * cs + dy * sn) / r;
let uy = (-dx * sn + dy * cs) / r;
let d = sqrt(ux * ux + uy * uy);
let core = max(1.0 - d, 0.0) * max(1.0 - d, 0.0);
let stx = max(1.0 - abs(ux) / 2.0, 0.0) * max(1.0 - abs(uy) * 6.0, 0.0);
let sty = max(1.0 - abs(uy) / 2.0, 0.0) * max(1.0 - abs(ux) * 6.0, 0.0);
cov = min(core + stx + sty, 1.0);
}
case 6u: {
let sn = sin(data[at + 8u]);
let cs = cos(data[at + 8u]);
let ux = dx * cs + dy * sn;
let uy = -dx * sn + dy * cs;
let e = r * 0.75;
cov = fxm_aa(e - abs(ux)) * fxm_aa(e - abs(uy));
}
case 7u: {
let sn = sin(data[at + 8u]);
let cs = cos(data[at + 8u]);
let ux = dx * cs + dy * sn;
let uy = -dx * sn + dy * cs;
let e = r * 0.5;
var m = 3.4e38;
for (var k = 0; k < 3; k++) {
let a = 1.5707964 + f32(k) * 6.2831855 / 3.0;
m = min(m, e - (ux * cos(a) + uy * sin(a)));
}
cov = fxm_aa(m);
}
default: {
let lx = data[at + 9u];
let ly = data[at + 10u];
let l2 = lx * lx + ly * ly;
var t = 0.0;
if (l2 > 0.0) {
t = clamp((dx * lx + dy * ly) / l2, 0.0, 1.0);
}
let px = dx - lx * t;
let py = dy - ly * t;
cov = fxm_aa(r - sqrt(px * px + py * py));
}
}
let a = cov * c.w * fade;
if (a <= 1e-6) {
return fxm_none();
}
return FxmShade(true, vec4<f32>(rgb * a, a));
}
// CC Bubbles' bubble (sim::bubbles). u[1].y = metal, u[1].z = shading type.
fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade {
let qx = data[at + 1u];
let qy = data[at + 2u];
let qr = data[at + 3u];
let dx = (x - qx) / max(qr, 0.5);
let dy = (y - qy) / max(qr, 0.5);
let d2 = dx * dx + dy * dy;
let d = sqrt(d2);
let cov = fxm_aa((1.0 - d) * qr);
if (cov <= 0.0) {
return fxm_none();
}
let k = select(-0.7, 1.6, P.u[1].y != 0u);
let nz = sqrt(max(1.0 - d2, 0.0));
let s = fxm_unpremul(sample_bilinear(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k));
var c = s.xyz;
if (s.w <= 0.0) {
c = vec3<f32>(0.6, 0.7, 0.8);
}
var a = cov;
switch (P.u[1].z) {
case 1u: {
c = c * 0.7 + 0.3 * (1.0 - nz);
}
case 2u: {
c = c * (0.5 + 0.5 * nz);
}
case 3u: {
a *= powz(nz, 0.5);
}
case 4u: {
a *= 1.0 - nz * 0.8;
}
default: {}
}
let hx = dx + 0.35;
let hy = dy + 0.35;
let hl = max(1.0 - (hx * hx + hy * hy) / 0.05, 0.0) * 0.8;
c = c + hl;
return FxmShade(true, vec4<f32>(c * a, a));
}
// Mr. Mercury's blob field (f, ∂f/∂x, ∂f/∂y). f[0].x = 1 + influence.
fn fxm_blob(at: u32, x: f32, y: f32) -> FxmShade {
let dx = x - data[at + 1u];
let dy = y - data[at + 2u];
let rr = data[at + 3u] * 2.0 * P.f[0].x;
let d2 = (dx * dx + dy * dy) / (rr * rr);
if (d2 >= 1.0) {
return fxm_none();
}
let k = 1.0 - d2;
let g = -6.0 * k * k / (rr * rr);
return FxmShade(true, vec4<f32>(k * k * k, g * dx, g * dy, 0.0));
}
// A textured piece (sim2::draw_pieces): src = front texture, aux = back (u[1].y = 1 when set).
fn fxm_piece(at: u32, x: f32, y: f32) -> FxmShade {
let m0 = vec3<f32>(data[at + 1u], data[at + 2u], data[at + 3u]);
let m1 = vec3<f32>(data[at + 4u], data[at + 5u], data[at + 6u]);
let m2 = vec3<f32>(data[at + 7u], data[at + 8u], data[at + 9u]);
let w = m2.x * x + m2.y * y + m2.z;
if (abs(w) < 1e-12) {
return fxm_none();
}
let c0 = vec2<f32>(data[at + 10u], data[at + 11u]);
var u = (m0.x * x + m0.y * y + m0.z) / w + c0.x;
var v = (m1.x * x + m1.y * y + m1.z) / w + c0.y;
let n = u32(data[at + 24u]);
var md = 3.4e38;
for (var i = 0u; i < n; i++) {
let j = (i + 1u) % n;
let ax = data[at + 12u + i * 2u];
let ay = data[at + 13u + i * 2u];
let ex = data[at + 12u + j * 2u] - ax;
let ey = data[at + 13u + j * 2u] - ay;
let l = max(sqrt(ex * ex + ey * ey), 1e-6);
md = min(md, (ex * (v - ay) - ey * (u - ax)) / l);
}
let cov = clamp(md + 1.0, 0.0, 1.0);
if (cov <= 0.0) {
return fxm_none();
}
var c: vec3<f32>;
var a = 1.0;
if (data[at + 33u] != 0.0) {
c = vec3<f32>(data[at + 34u], data[at + 35u], data[at + 36u]);
} else {
let back = data[at + 37u] != 0.0;
let mirror = u32(data[at + 38u]);
if (back && mirror == 1u) {
v = 2.0 * c0.y - v;
} else if (back && mirror == 2u) {
u = 2.0 * c0.x - u;
}
var t: vec4<f32>;
if (back && P.u[1].y != 0u) {
t = sample_bilinear(aux, u, v);
} else {
t = sample_bilinear(src, u, v);
}
let s = fxm_unpremul(t);
c = s.xyz;
a = s.w;
}
a = a * cov * data[at + 32u];
if (a <= 1e-6) {
return FxmShade(false, vec4<f32>(0.0));
}
let tint = vec3<f32>(data[at + 26u], data[at + 27u], data[at + 28u]);
let spec = vec3<f32>(data[at + 29u], data[at + 30u], data[at + 31u]);
let o = c * data[at + 25u] * tint + spec;
return FxmShade(true, vec4<f32>(o * a, a));
}
// sim::raster over a transparent frame. u[0] = (kind: 0 sprites, 1 bubbles, 2 blob field,
// 3 pieces; additive; items offset; item stride); u[1].x = tiles across.
@compute @workgroup_size(16, 16)
fn fxm_raster(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let tile = u32(p.y / 16) * P.u[1].x + u32(p.x / 16);
let start = u32(data[tile]);
let end = u32(data[tile + 1u]);
let stride = P.u[0].w;
let add = P.u[0].y != 0u;
let x = f32(p.x) + 0.5;
let y = f32(p.y) + 0.5;
var d = vec4<f32>(0.0);
for (var k = start; k < end; k++) {
let at = P.u[0].z + u32(data[k]) * stride;
let bb = at + stride - 4u;
if (f32(p.x) < data[bb] || f32(p.y) < data[bb + 1u] || f32(p.x) > data[bb + 2u] || f32(p.y) > data[bb + 3u]) {
continue;
}
var s: FxmShade;
switch (P.u[0].x) {
case 0u: {
s = fxm_sprite(at, x, y);
}
case 1u: {
s = fxm_bubble(at, x, y);
}
case 2u: {
s = fxm_blob(at, x, y);
}
default: {
s = fxm_piece(at, x, y);
}
}
if (!s.ok) {
continue;
}
let c = s.c;
if (add) {
d = vec4<f32>(d.xyz + c.xyz, min(d.w + c.w, 1.0));
} else {
d = c + d * (1.0 - c.w);
}
}
textureStore(out, p, d);
}
// The sprite layer (aux) meets the original (src). u[0].x = 0: sim::combine with mode u[0].y;
// 1: mixed toward the original by f[0].x; 2: over opaque black.
@compute @workgroup_size(16, 16)
fn fxm_post(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let s = textureLoad(src, p, 0);
let f = textureLoad(aux, p, 0);
var o = f;
if (P.u[0].x == 1u) {
o = f + (s - f) * P.f[0].x;
} else if (P.u[0].x == 2u) {
o = vec4<f32>(f.xyz, f.w + (1.0 - f.w));
} else {
switch (P.u[0].y) {
case 0u: {
o = f + s * (1.0 - f.w);
}
case 1u: {
o = s + f - s * f;
}
case 2u: {
o = vec4<f32>(s.xyz + f.xyz, min(s.w + f.w, 1.0));
}
case 3u: {
o = vec4<f32>(max(s.xyz, f.xyz), s.w + f.w - s.w * f.w);
}
case 4u: {
let fu = fxm_unpremul(f);
let m = s.xyz - (s.xyz - min(s.xyz, fu.xyz * s.w)) * fu.w;
o = vec4<f32>(m, s.w);
}
default: {}
}
}
textureStore(out, p, o);
}
fn fxm_norm3(v: vec3<f32>) -> vec3<f32> {
let l = sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
if (l > 1e-9) {
return v / l;
}
return vec3<f32>(0.0, -1.0, 0.0);
}
// Mr. Mercury's shading (src = layer, aux = blob field). f[0] = (ambient, diffuse, specular,
// roughness); f[1] = (metal, light intensity, render scale); f[2] = light direction; f[3] = colour.
@compute @workgroup_size(16, 16)
fn fxm_mercury(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let fv = textureLoad(aux, p, 0);
let f = fv.x;
let thr = 0.35;
if (f <= thr * 0.8) {
textureStore(out, p, vec4<f32>(0.0));
return;
}
let cov = clamp((f - thr * 0.8) / (thr * 0.4), 0.0, 1.0);
let n = fxm_norm3(vec3<f32>(fv.y * 3.0, fv.z * 3.0, 1.0 / (1.0 + f)));
let s = P.f[1].z;
let smp = fxm_unpremul(sample_bilinear_clamped(src, f32(p.x) + 0.5 - n.x * 12.0 * s, f32(p.y) + 0.5 - n.y * 12.0 * s));
var base = smp.xyz;
if (smp.w <= 0.0) {
base = vec3<f32>(0.7, 0.7, 0.75);
}
let l = P.f[2].xyz;
let nl = max(dot(n, l), 0.0);
let hv = fxm_norm3(vec3<f32>(l.x, l.y, l.z + 1.0));
let li = P.f[1].y;
let spec = powz(max(dot(n, hv), 0.0), 1.0 / P.f[0].w) * P.f[0].z * li;
let shade = P.f[0].x + P.f[0].y * nl * li;
let metal = P.f[1].x;
let cc = base * shade + spec * (P.f[3].xyz * (1.0 - metal) + base * metal);
textureStore(out, p, vec4<f32>(cc * cov, cov));
}
// CC Drizzle's ripple height at (x, y): data = drops (x, y, ring radius, amplitude).
fn fxm_ripples(x: f32, y: f32) -> f32 {
var v = 0.0;
for (var i = 0u; i < P.u[0].x; i++) {
let dx = data[i * 4u];
let dy = data[i * 4u + 1u];
let ex = x - dx;
let ey = y - dy;
let d = sqrt(ex * ex + ey * ey);
let r = data[i * 4u + 2u];
let u = (d - r) / P.f[0].y;
if (abs(u) < 1.0) {
let env = 0.5 + 0.5 * cos(u * 3.1415927);
v += data[i * 4u + 3u] * env * cos((d - r) / P.f[0].x * 6.2831855);
}
}
return v;
}
// CC Drizzle. u[0].x = drops; f[0] = (wavelength, ring width, displacement, roughness);
// f[1] = (light direction, intensity); f[2] = (half vector, specular); f[3] = light colour;
// f[4] = (ambient, diffuse).
@compute @workgroup_size(16, 16)
fn fxm_drizzle(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let fx = f32(p.x) + 0.5;
let fy = f32(p.y) + 0.5;
let gx = fxm_ripples(fx + 1.0, fy) - fxm_ripples(fx - 1.0, fy);
let gy = fxm_ripples(fx, fy + 1.0) - fxm_ripples(fx, fy - 1.0);
if (gx == 0.0 && gy == 0.0) {
textureStore(out, p, textureLoad(src, p, 0));
return;
}
let smp = sample_bilinear_clamped(src, fx - gx * P.f[0].z, fy - gy * P.f[0].z);
let v = vec3<f32>(-gx * 6.0, -gy * 6.0, 1.0);
let n = v / sqrt(v.x * v.x + v.y * v.y + 1.0);
let l = P.f[1].xyz;
let li = P.f[1].w;
let nl = max(dot(n, l), 0.0);
let sp = powz(max(dot(n, P.f[2].xyz), 0.0), 1.0 / P.f[0].w) * P.f[2].w * li;
let shade = 1.0 + (P.f[4].x + P.f[4].y * nl - P.f[4].y * l.z) * li;
let a = smp.w;
textureStore(out, p, vec4<f32>(smp.xyz * shade + sp * P.f[3].xyz * a, a));
}
// Wave World's Height Map view. data = grid heights (nx × ny), then water depths when
// u[0].z (dry areas transparent); f[0] = (brightness, contrast, gamma, alpha);
// f[1] = (buffer offset, render scale, layer width).
@compute @workgroup_size(16, 16)
fn fxm_wave(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let nx = P.u[0].x;
let ny = P.u[0].y;
let lw = P.f[1].w;
let lx = (f32(p.x) + 0.5 - P.f[1].x) / P.f[1].z;
let ly = (f32(p.y) + 0.5 - P.f[1].y) / P.f[1].z;
let gx = clamp(lx / lw * f32(nx - 1u), 0.0, f32(nx - 1u));
let gy = clamp(ly / lw * f32(nx - 1u), 0.0, f32(ny - 1u));
let x0 = u32(floor(gx));
let y0 = u32(floor(gy));
let x1 = min(x0 + 1u, nx - 1u);
let y1 = min(y0 + 1u, ny - 1u);
let tx = gx - f32(x0);
let ty = gy - f32(y0);
let g00 = data[y0 * nx + x0];
let g10 = data[y0 * nx + x1];
let g01 = data[y1 * nx + x0];
let g11 = data[y1 * nx + x1];
let a = g00 + (g10 - g00) * tx;
let c = g01 + (g11 - g01) * tx;
let h = a + (c - a) * ty;
let v = powz(clamp(P.f[0].x + P.f[0].y * h, 0.0, 1.0), 1.0 / P.f[0].z);
var al = P.f[0].w;
if (P.u[0].z != 0u) {
let rx = u32(clamp(round_away(lx / lw * f32(nx - 1u)), 0.0, f32(nx - 1u)));
let ry = u32(clamp(round_away(ly / lw * f32(nx - 1u)), 0.0, f32(ny - 1u)));
if (data[nx * ny + ry * nx + rx] <= 0.0) {
al = 0.0;
}
}
textureStore(out, p, vec4<f32>(v * al, v * al, v * al, al));
}
// ---- Caustics
// The water surface's height gradient at (x, y) (aux = height plane, clamped; none = flat).
fn fxm_grad(x: i32, y: i32) -> vec2<f32> {
if (P.u[0].x == 0u) {
return vec2<f32>(0.0);
}
let gx = (tex_get_clamped(aux, x + 1, y).x - tex_get_clamped(aux, x - 1, y).x) * 0.5;
let gy = (tex_get_clamped(aux, x, y + 1).x - tex_get_clamped(aux, x, y - 1).x) * 0.5;
return vec2<f32>(gx, gy);
}
fn fxm_rem(v: f32, n: f32) -> f32 {
return v - n * floor(v / n);
}
fn fxm_mirror(v: f32, n: f32) -> f32 {
let r = fxm_rem(v, 2.0 * n);
return select(r, 2.0 * n - r, r > n);
}
// sim2::sample_repeat of `src`.
fn fxm_sample_repeat(x: f32, y: f32, mode: u32) -> vec4<f32> {
let d = vec2<f32>(textureDimensions(src));
if (mode == 1u) {
return sample_bilinear_clamped(src, fxm_rem(x, d.x), fxm_rem(y, d.y));
}
if (mode == 2u) {
return sample_bilinear_clamped(src, fxm_mirror(x, d.x), fxm_mirror(y, d.y));
}
return sample_bilinear(src, x, y);
}
// A sky texel from `data` (RGBA f32 rows of u[1].y pixels; u[1].zw = size).
fn fxm_sky_texel(x: i32, y: i32, clamped: bool) -> vec4<f32> {
let w = i32(P.u[1].z);
let h = i32(P.u[1].w);
var xi = x;
var yi = y;
if (clamped) {
xi = clamp(x, 0, w - 1);
yi = clamp(y, 0, h - 1);
} else if (x < 0 || y < 0 || x >= w || y >= h) {
return vec4<f32>(0.0);
}
let i = (u32(yi) * P.u[1].y + u32(xi)) * 4u;
return vec4<f32>(data[i], data[i + 1u], data[i + 2u], data[i + 3u]);
}
fn fxm_sky_bilinear(x: f32, y: f32, clamped: bool) -> vec4<f32> {
let fx = x - 0.5;
let fy = y - 0.5;
let x0 = floor(fx);
let y0 = floor(fy);
let tx = fx - x0;
let ty = fy - y0;
let xi = i32(x0);
let yi = i32(y0);
let a = fxm_sky_texel(xi, yi, clamped);
let b = fxm_sky_texel(xi + 1, yi, clamped);
let c = fxm_sky_texel(xi, yi + 1, clamped);
let d = fxm_sky_texel(xi + 1, yi + 1, clamped);
let top = a + (b - a) * tx;
let bot = c + (d - c) * tx;
return top + (bot - top) * ty;
}
fn fxm_sky_repeat(x: f32, y: f32, mode: u32) -> vec4<f32> {
let w = f32(P.u[1].z);
let h = f32(P.u[1].w);
if (mode == 1u) {
return fxm_sky_bilinear(fxm_rem(x, w), fxm_rem(y, h), true);
}
if (mode == 2u) {
return fxm_sky_bilinear(fxm_mirror(x, w), fxm_mirror(y, h), true);
}
return fxm_sky_bilinear(x, y, false);
}
// Caustics (sim2::caustics; src = the blurred bottom, aux = height plane, data = sky rows).
// u[0] = (height, repeat, point light, sky repeat); u[1] = (sky, row, width, height);
// f[0] = (scaling, wave height, displacement k, layer width); f[1] = (centre, light position);
// f[2] = (surface colour, opacity); f[3] = (light colour, intensity); f[4] = (light height,
// caustics strength, ambient, diffuse); f[5] = (specular, sharpness, sky scaling, sky
// intensity); f[6] = (distant light direction, convergence).
@compute @workgroup_size(16, 16)
fn fxm_caustics(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let k = P.f[0].z;
let x = p.x;
let y = p.y;
let dd = -fxm_grad(x, y) * k;
let dxr = -fxm_grad(min(x + 1, dims.x - 1), y).x * k;
let dyd = -fxm_grad(x, min(y + 1, dims.y - 1)).y * k;
let dxl = -fxm_grad(max(x - 1, 0), y).x * k;
let dyu = -fxm_grad(x, max(y - 1, 0)).y * k;
let jac = (1.0 + (dxr - dxl) * 0.5) * (1.0 + (dyd - dyu) * 0.5);
let focus = clamp(1.0 / max(abs(jac), 0.2) - 1.0, -1.0, 4.0);
let cx = P.f[1].x;
let cy = P.f[1].y;
let fx = f32(x) + 0.5;
let fy = f32(y) + 0.5;
let sx = cx + (fx + dd.x - cx) / P.f[0].x;
let sy = cy + (fy + dd.y - cy) / P.f[0].x;
let bot = fxm_unpremul(fxm_sample_repeat(sx, sy, P.u[0].y));
let g = fxm_grad(x, y);
let wh = P.f[0].y;
let v = vec3<f32>(-g.x * wh * 20.0, -g.y * wh * 20.0, 1.0);
let n = v / sqrt(v.x * v.x + v.y * v.y + 1.0);
var lv = P.f[6].xyz;
if (P.u[0].z != 0u) {
let lw = max(P.f[0].w, 1.0);
let q = vec3<f32>((P.f[1].z - fx) / lw, (P.f[1].w - fy) / lw, P.f[4].x);
lv = q / max(sqrt(q.x * q.x + q.y * q.y + q.z * q.z), 1e-12);
}
let li = P.f[3].w;
let nl = max(dot(n, lv), 0.0);
let hq = vec3<f32>(lv.x, lv.y, lv.z + 1.0);
let hv = hq / sqrt(hq.x * hq.x + hq.y * hq.y + hq.z * hq.z);
let spec = powz(max(dot(n, hv), 0.0), P.f[5].y) * P.f[5].x * li;
let ambient = P.f[4].z;
let diffuse = P.f[4].w;
let light = ambient + diffuse * li * nl + P.f[4].y * focus * li;
let surf = P.f[2].xyz;
let surf_op = P.f[2].w;
var surface: vec3<f32>;
if (P.u[1].x != 0u) {
let reach = (1.0 - P.f[6].w) * P.f[0].w * 0.5;
let rx = cx + (fx + n.x * reach - cx) / P.f[5].z;
let ry = cy + (fy + n.y * reach - cy) / P.f[5].z;
let sc = fxm_unpremul(fxm_sky_repeat(rx, ry, P.u[0].w)).xyz;
surface = sc * P.f[5].w * 3.0 * surf;
} else {
surface = surf * (ambient + diffuse * li * nl);
}
let lc = P.f[3].xyz;
let cc = bot.xyz * light * lc * (1.0 - surf_op) + surface * surf_op + spec * lc;
let a = min(bot.w + surf_op * (1.0 - bot.w), 1.0);
textureStore(out, p, vec4<f32>(cc * a, a));
}

View File

@ -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<f32>) -> vec4<f32> {
if (p.w > 1e-6) {
return vec4<f32>(p.xyz / p.w, p.w);
}
return vec4<f32>(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<f32> {
let lon = (x / w - 0.5) * 2.0 * FXV_PI;
let lat = (0.5 - y / h) * FXV_PI;
return vec3<f32>(cos(lat) * sin(lon), sin(lat), cos(lat) * cos(lon));
}
// vr::equi_px.
fn fxv_equi_px(d: vec3<f32>, w: f32, h: f32) -> vec2<f32> {
let lon = atan2(d.x, d.z);
let lat = asin(clamp(d.y, -1.0, 1.0));
return vec2<f32>((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<f32>, b: vec3<f32>) -> 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<f32> {
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<i32>(xm, clamp(yi, 0, h - 1)), 0);
}
// vr::sample_equi (bilinear).
fn fxv_sample_equi(x: f32, y: f32) -> vec4<f32> {
let d = vec2<i32>(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<f32> {
var v = array<vec3<f32>, 6>(vec3<f32>(1.0, 0.0, 0.0), vec3<f32>(-1.0, 0.0, 0.0), vec3<f32>(0.0, 1.0, 0.0), vec3<f32>(0.0, -1.0, 0.0), vec3<f32>(0.0, 0.0, 1.0), vec3<f32>(0.0, 0.0, -1.0));
return v[f];
}
fn fxv_face_r(f: u32) -> vec3<f32> {
var v = array<vec3<f32>, 6>(vec3<f32>(0.0, 0.0, -1.0), vec3<f32>(0.0, 0.0, 1.0), vec3<f32>(1.0, 0.0, 0.0), vec3<f32>(1.0, 0.0, 0.0), vec3<f32>(1.0, 0.0, 0.0), vec3<f32>(-1.0, 0.0, 0.0));
return v[f];
}
fn fxv_face_u(f: u32) -> vec3<f32> {
var v = array<vec3<f32>, 6>(vec3<f32>(0.0, 1.0, 0.0), vec3<f32>(0.0, 1.0, 0.0), vec3<f32>(0.0, 0.0, -1.0), vec3<f32>(0.0, 0.0, 1.0), vec3<f32>(0.0, 1.0, 0.0), vec3<f32>(0.0, 1.0, 0.0));
return v[f];
}
// vr::cube_grid: the grid size.
fn fxv_grid(kind: u32) -> vec2<f32> {
switch (kind) {
case 1u: {
return vec2<f32>(4.0, 3.0);
}
case 2u, 6u, 7u: {
return vec2<f32>(3.0, 2.0);
}
default: {
return vec2<f32>(6.0, 1.0);
}
}
}
// vr::cube_grid: (column, row) of face f.
fn fxv_cell(kind: u32, f: u32) -> vec2<f32> {
switch (kind) {
case 1u: {
var c = array<vec2<f32>, 6>(vec2<f32>(2.0, 1.0), vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 2.0), vec2<f32>(1.0, 1.0), vec2<f32>(3.0, 1.0));
return c[f];
}
case 2u: {
var c = array<vec2<f32>, 6>(vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(2.0, 0.0), vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 1.0), vec2<f32>(2.0, 1.0));
return c[f];
}
case 6u, 7u: {
var c = array<vec2<f32>, 6>(vec2<f32>(2.0, 0.0), vec2<f32>(0.0, 0.0), vec2<f32>(2.0, 1.0), vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0));
return c[f];
}
default: {
return vec2<f32>(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<f32> {
let none = vec4<f32>(0.0, 0.0, 0.0, 2.0);
if (kind == 0u) {
return vec4<f32>(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<f32>(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<f32>(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<f32>(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<f32>(normalize(vec3<f32>(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<f32>) -> vec4<f32> {
let dims = vec2<f32>(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<f32>(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<f32>(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<f32>(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<f32>(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<f32>) -> vec3<f32> {
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<f32>) -> vec3<f32> {
return vec3<f32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32>(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<u32>) {
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<i32>(x, y), textureLoad(src, vec2<i32>(x, y), 0));
}
return;
}
let norm = 1.0 / f32(2 * r + 1);
var acc = vec4<f32>(0.0);
for (var i = b0 - r; i <= b0 + r; i++) {
acc += textureLoad(src, vec2<i32>(((i % w) + w) % w, y), 0);
}
for (var x = b0; x < b1; x++) {
textureStore(out, vec2<i32>(x, y), acc * norm);
let add = textureLoad(src, vec2<i32>((x + r + 1) % w, y), 0);
let sub = textureLoad(src, vec2<i32>(((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<f32> {
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<i32>(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<u32>) {
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<f32>(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<i32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let s = vec2<i32>(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<i32>(((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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32>(c * s.w, s.w * min(k, 1.0));
}
case 1u: {
let b = P.f[0].x;
o = vec4<f32>(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<f32>(0.0), vec3<f32>(hi));
o = vec4<f32>(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<f32>(max(m.xyz, vec3<f32>(0.0)), m.w);
}
case 4u: {
o = vec4<f32>(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<f32>, c: vec3<f32>, opacity: f32) -> vec4<f32> {
let s = vec4<f32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32>(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<f32>(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<f32>;
if (exact >= 0) {
let e = u32(exact);
c = vec3<f32>(data[e * 6u + 3u], data[e * 6u + 4u], data[e * 6u + 5u]);
} else {
var acc = vec3<f32>(0.0);
var ws = 0.0;
for (var i = 0u; i < n; i++) {
let a = fxv_angle(d, vec3<f32>(data[i * 6u], data[i * 6u + 1u], data[i * 6u + 2u]));
let wgt = pow(amin / a, P.f[0].x);
acc += vec3<f32>(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<f32>(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<f32>(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<f32>(c0.x * dk, c1.y * dk, c2.z * dk, max(max(c0.w, c1.w), c2.w));
}
}
default: {}
}
textureStore(out, p, o);
}

View File

@ -0,0 +1,263 @@
//! GPU 3D Channel family vs the CPU effects (the oracle): direct on a buffer (full and half
//! resolution, padded offsets, aux channels at 1× and 2× the layer resolution) and composited
//! at 8 and 32 bpc from footage that carries auxiliary channels.
use std::sync::Arc;
use effectcraft_effects::{EffectEnv, EffectHost, LayerPixels};
use effectcraft_keyframe::Value;
use effectcraft_project::{BitDepth, Footage, ItemId};
use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, crypto_float, crypto_hash};
use effectcraft_raster::{AuxChannels, Image};
use effectcraft_render::{Backend, FootageSource, RenderOpts, Renderer};
use effectcraft_time::Tick;
use crate::tests::{Pattern, Scene, c, check, diff, gpu, n, opts, pattern, set, tolerance};
pub(crate) struct AuxHost(pub(crate) Arc<AuxChannels>);
impl EffectHost for AuxHost {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn aux(&self) -> Option<Arc<AuxChannels>> {
Some(self.0.clone())
}
}
const NAMES: [&str; 5] = ["alpha", "beta", "gamma", "delta", "epsilon"];
/// Aux channels for a `w`×`h` layer at `scale` aux pixels per layer pixel: a depth ramp with a
/// background corner, object / material ID blocks, coverage, UVs, normals, background RGB, raw
/// RGB and a two-rank Cryptomatte layer with a manifest.
pub(crate) fn aux_scene(w: u32, h: u32, scale: f64) -> AuxChannels {
let (aw, ah) = ((w as f64 * scale) as u32, (h as f64 * scale) as u32);
let mut a = AuxChannels::new(aw, ah, scale);
let n = (aw * ah) as usize;
let mut ch: Vec<(String, Vec<f32>)> = [
"Z",
"ObjectID",
"MaterialID",
"Coverage",
"UV.U",
"UV.V",
"N.X",
"N.Y",
"N.Z",
"BG.R",
"BG.G",
"BG.B",
"R",
"G",
"B",
"CryptoObject00.R",
"CryptoObject00.G",
"CryptoObject00.B",
"CryptoObject00.A",
]
.iter()
.map(|s| (s.to_string(), vec![0.0; n]))
.collect();
let hashes: Vec<u32> = NAMES.iter().map(|s| crypto_hash(s)).collect();
for y in 0..ah {
for x in 0..aw {
let i = (y * aw + x) as usize;
let (u, v) = (x as f32 / aw as f32, y as f32 / ah as f32);
let bg = u > 0.8 && v < 0.25;
let obj = ((x as f64 / scale) as u32 / 11 + (y as f64 / scale) as u32 / 9 * 3) % 5;
let vals = [
if bg { BACKGROUND_DEPTH } else { 50.0 + 900.0 * u + 300.0 * (v * 7.0).sin() },
if bg { 0.0 } else { obj as f32 + 1.0 },
((obj * 7) % 3) as f32,
0.5 + 0.5 * (u * 9.0).sin(),
u,
v,
(u - 0.5) * 1.6,
(0.5 - v) * 1.4,
0.7,
0.2 + 0.6 * v,
0.4,
0.9 - 0.5 * u,
u * 1.8,
v * 0.7,
0.3 + u * v * 2.5,
crypto_float(hashes[obj as usize]),
0.7,
crypto_float(hashes[(obj as usize + 1) % 5]),
0.3,
];
for (k, val) in vals.iter().enumerate() {
ch[k].1[i] = *val;
}
}
}
a.channels = ch;
a.manifests = vec![("CryptoObject".into(), NAMES.iter().zip(&hashes).map(|(n, h)| (n.to_string(), *h)).collect())];
a
}
/// The effect alone on a buffer with aux channels (`allow` = fraction of pixels that may
/// differ by more than 1e-3).
pub(crate) fn direct(id: &str, vals: &[(&str, Value)], allow: f64) {
let Some(g) = gpu() else { return };
let spec = effectcraft_effects::find(id).unwrap();
let size = [70.0, 44.0];
let mut params =
effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() };
for (k, v) in vals {
params.values.insert(k.to_string(), v.clone());
}
for aux_scale in [1.0, 2.0] {
let host = AuxHost(Arc::new(aux_scene(70, 44, aux_scale)));
for (scale, offset) in [(1.0, [0.0, 0.0]), (0.5, [0.0, 0.0]), (1.0, [3.0, 2.0])] {
let (w, h) = ((size[0] * scale) as u32, (size[1] * scale) as u32);
let mut img = pattern(7, w + 2 * offset[0] as u32, h + 2 * offset[1] as u32);
if offset[0] > 0.0 {
// A padded buffer: transparent margins.
for (i, px) in img.data.iter_mut().enumerate() {
let (x, y) = (i as u32 % img.width, i as u32 / img.width);
if x < offset[0] as u32 || y < offset[1] as u32 || x >= w + offset[0] as u32 || y >= h + offset[1] as u32 {
*px = [0.0; 4];
}
}
}
let buf = effectcraft_effects::Buf { img, offset, scale };
let ctx = || effectcraft_effects::EffectCtx {
params: &params,
time: 0.25,
layer_size: size,
seed: 11,
adjustment: false,
env: EffectEnv { host: Some(&host), ..Default::default() },
};
let cpu = (spec.render)(&ctx(), buf.clone());
let out =
effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect");
assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "{id}: geometry");
let d = diff(&cpu.img, &out.img, 1e-3);
assert!(
d.over as f64 <= allow * d.total as f64,
"{id} {vals:?} aux {aux_scale} scale {scale} offset {offset:?}: {} of {} pixels over 1e-3 (max {}); worst {:?}",
d.over,
d.total,
d.max,
d.worst
);
}
}
}
/// Footage frames from [`Pattern`] with [`aux_scene`] channels.
struct AuxSource;
impl FootageSource for AuxSource {
fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
Pattern.frame(item, f, t)
}
fn aux(&self, _item: ItemId, f: &Footage, _t: Tick) -> Option<Arc<AuxChannels>> {
Some(Arc::new(aux_scene(f.width, f.height, 1.0)))
}
}
fn compare(s: &Scene, o: RenderOpts) -> Option<crate::tests::Diff> {
let g = gpu()?;
let cpu = Renderer::new(&s.p, &AuxSource, RenderOpts { backend: Backend::Cpu, ..o }).comp_frame_cpu(s.cid, Tick::ZERO);
let mut r = Renderer::new(&s.p, &AuxSource, RenderOpts { backend: Backend::Gpu, ..o });
r.accel = Some(g);
let img = g.render(&r, s.cid, Tick::ZERO).expect("the GPU renders the frame");
let mut d = diff(&cpu, &img, tolerance(s.p.settings.bit_depth));
d.quantized = s.p.settings.bit_depth != BitDepth::Bpc32;
Some(d)
}
/// Direct, then on a rotated footage layer over a background at 8 and 32 bpc (full and half
/// resolution).
fn case(id: &str, vals: &[(&str, Value)]) {
direct(id, vals, 0.0);
composited(id, vals, 0.0);
}
fn composited(id: &str, vals: &[(&str, Value)], allow: f64) {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = Scene::new(depth);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
let bg_id = s.push(bg);
let mut l = s.footage(70, 44);
let mut vals = vals.to_vec();
for v in vals.iter_mut() {
if v.1 == Value::Layer(Some(0)) {
v.1 = Value::Layer(Some(bg_id.0));
}
}
s.effect(&mut l, id, &vals);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
check(&format!("{id} {vals:?} {depth:?}"), compare(&s, opts()), allow);
check(&format!("{id} {vals:?} {depth:?} half"), compare(&s, RenderOpts { scale: 0.5, ..opts() }), allow);
}
}
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
#[test]
fn channel_extract() {
case("ec.3d.channelextract", &[("blackPoint", n(0.0)), ("whitePoint", n(1200.0))]);
case("ec.3d.channelextract", &[("whitePoint", n(400.0)), ("clampOutput", Value::Bool(false)), ("invertDepthMap", on())]);
case("ec.3d.channelextract", &[("blackPoint", n(900.0)), ("whitePoint", n(100.0))]);
case("ec.3d.channelextract", &[("channel", e(1)), ("whitePoint", n(5.0))]);
case("ec.3d.channelextract", &[("channel", e(2)), ("whitePoint", n(1.0))]);
case("ec.3d.channelextract", &[("channel", e(3)), ("whitePoint", n(1.0))]);
case("ec.3d.channelextract", &[("channel", e(4)), ("whitePoint", n(1.0)), ("antialias", on())]);
case("ec.3d.channelextract", &[("channel", e(5)), ("whitePoint", n(1.0))]);
case("ec.3d.channelextract", &[("channel", e(6))]);
case("ec.3d.channelextract", &[("channel", e(7)), ("whitePoint", n(2.0))]);
case("ec.3d.channelextract", &[("blackPoint", n(0.0)), ("whitePoint", n(1000.0)), ("antialias", on())]);
}
#[test]
fn depth_matte_fog_and_dof() {
case("ec.3d.depthmatte", &[("depth", n(500.0))]);
case("ec.3d.depthmatte", &[("depth", n(400.0)), ("feather", n(300.0)), ("invert", on())]);
case("ec.3d.fog3d", &[]);
case("ec.3d.fog3d", &[("fogColor", c(0.3, 0.5, 0.9)), ("fogStartDepth", n(200.0)), ("fogEndDepth", n(900.0)), ("scatteringDensity", n(10.0))]);
case("ec.3d.fog3d", &[("foggyBackground", Value::Bool(false)), ("fogOpacity", n(70.0))]);
composited("ec.3d.fog3d", &[("gradientLayer", Value::Layer(Some(0))), ("layerContribution", n(60.0))], 0.0);
case("ec.3d.depthoffield", &[("focalPlane", n(400.0)), ("maximumRadius", n(8.0))]);
case("ec.3d.depthoffield", &[("focalPlane", n(200.0)), ("maximumRadius", n(5.0)), ("focalPlaneThickness", n(150.0)), ("focalBias", n(80.0))]);
}
#[test]
fn ids_and_cryptomatte() {
case("ec.3d.idmatte", &[("idSelection", n(2.0))]);
case("ec.3d.idmatte", &[("idSelection", n(3.0)), ("feather", n(3.0)), ("useCoverage", on()), ("invert", on())]);
case("ec.3d.idmatte", &[("auxChannel", e(1)), ("idSelection", n(1.0))]);
case("ec.3d.identifier", &[]);
case("ec.3d.identifier", &[("id", n(2.0))]);
case("ec.3d.identifier", &[("display", e(1)), ("id", n(4.0))]);
case("ec.3d.identifier", &[("display", e(2)), ("id", n(1.0))]);
case("ec.3d.identifier", &[("display", e(3)), ("channelType", e(1))]);
case("ec.3d.cryptomatte", &[("selection", Value::Str("beta, delta".into()))]);
case("ec.3d.cryptomatte", &[("selection", Value::Str("*a".into())), ("display", e(0))]);
case("ec.3d.cryptomatte", &[("selection", Value::Str("gamma".into())), ("display", e(1))]);
case("ec.3d.cryptomatte", &[("selection", Value::Str("alpha".into())), ("matteOnly", on())]);
}
#[test]
fn extractor() {
case("ec.3d.extractor", &[("red", Value::Str("Z".into())), ("green", Value::Str("UV.U".into())), ("whitePoint", n(1000.0))]);
case(
"ec.3d.extractor",
&[("red", Value::Str("G".into())), ("blue", Value::Str("Coverage".into())), ("alpha", Value::Str("UV.V".into())), ("unMult", on())],
);
case("ec.3d.extractor", &[("red", Value::Str("A".into())), ("blackPoint", n(0.2)), ("whitePoint", n(0.8)), ("clip", on())]);
case("ec.3d.extractor", &[("green", Value::Str("".into())), ("alpha", Value::Str("nothing".into()))]);
}

View File

@ -0,0 +1,175 @@
//! GPU colour management vs the CPU effects (the oracle): Apply Color LUT, the OCIO effects,
//! Color Profile Converter and Lumetri's Input LUT / Look, direct on a buffer (full and half
//! resolution, as adjustment) and composited at 8 and 32 bpc.
use effectcraft_keyframe::Value;
use effectcraft_project::BitDepth;
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use crate::tests::{Scene, check, compare_at, effect_case, effect_direct, n, opts, set};
/// [`effect_case`] for nearest-neighbour lattice lookups: an 8 bpc input that lands exactly on a
/// rounding boundary between two lattice points may pick the other one (the GPU's division is
/// not correctly rounded), so up to `allow` of the composited pixels may differ.
fn case_allow(id: &str, vals: &[(&str, Value)], allow: f64) {
effect_direct(id, vals, false);
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = Scene::new(depth);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
let mut l = s.footage(70, 44);
s.effect(&mut l, id, vals);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
check(&format!("{id} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), allow);
check(&format!("{id} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), allow);
}
}
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn s(v: &str) -> Value {
Value::Str(v.to_string())
}
/// A graded lattice: `f` over an `n`³ grid (red fastest).
fn lattice(n: usize, f: impl Fn([f32; 3]) -> [f32; 3]) -> String {
let mut out = String::new();
for b in 0..n {
for g in 0..n {
for r in 0..n {
let c = f([r, g, b].map(|i| i as f32 / (n - 1) as f32));
out += &format!("{} {} {}\n", c[0], c[1], c[2]);
}
}
}
out
}
fn grade(c: [f32; 3]) -> [f32; 3] {
[c[0].powf(0.8) * 1.05, c[1] * 0.9 + 0.08 * (c[0] * 5.0).sin(), c[2] * 0.7 + c[0] * 0.2 + 0.03]
}
fn cube_3d() -> String {
format!("TITLE \"grade\"\nLUT_3D_SIZE 9\n{}", lattice(9, grade))
}
fn cube_1d_3d() -> String {
let mut s = String::from("LUT_1D_SIZE 16\nLUT_3D_SIZE 7\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1.2 1.2 1.2\n");
for i in 0..16 {
let v = (i as f32 / 15.0).powf(0.6);
s += &format!("{} {} {}\n", v, v * 0.95, v.powf(1.2));
}
s + &lattice(7, grade)
}
fn cube_1d() -> String {
let mut s = String::from("LUT_1D_SIZE 32\n");
for i in 0..32 {
let v = i as f32 / 31.0;
s += &format!("{} {} {}\n", v.powf(0.7), v.powf(1.3), 0.05 + v * 0.9);
}
s
}
fn csp() -> String {
let mut s = String::from("CSPLUTV100\n3D\n\n3\n0 0.5 1\n0 0.3 1\n2\n0 1\n0 1\n4\n0 0.2 0.6 1\n0 0.4 0.7 1\n\n5 5 5\n");
for b in 0..5 {
for g in 0..5 {
for r in 0..5 {
let c = grade([r, g, b].map(|i| i as f32 / 4.0));
s += &format!("{} {} {}\n", c[0], c[1], c[2]);
}
}
}
s
}
#[test]
fn apply_color_lut() {
effect_case("ec.utility.applylut", &[("lut", s(&cube_3d()))]);
effect_case("ec.utility.applylut", &[("lut", s(&cube_1d_3d()))]);
effect_case("ec.utility.applylut", &[("lut", s(&cube_1d()))]);
effect_case("ec.utility.applylut", &[("lut", s("/nonexistent/grade.cube"))]);
}
#[test]
fn ocio_file_transform() {
for interp in 1..4 {
effect_case("ec.color.ociofile", &[("file", s(&cube_3d())), ("interpolation", e(interp))]);
}
case_allow("ec.color.ociofile", &[("file", s(&cube_3d())), ("interpolation", e(0))], 0.01);
case_allow("ec.color.ociofile", &[("file", s(&cube_1d_3d())), ("interpolation", e(0))], 0.01);
effect_case("ec.color.ociofile", &[("file", s(&cube_1d())), ("direction", e(1))]);
effect_case("ec.color.ociofile", &[("file", s(&cube_3d())), ("direction", e(1))]);
effect_case("ec.color.ociofile", &[("file", s(&csp())), ("interpolation", e(1))]);
effect_case("ec.color.ociofile", &[("file", s(&csp())), ("direction", e(1))]);
let ccc = r#"<ColorCorrectionCollection><ColorCorrection id="a"><SOPNode><Slope>1.2 1 0.9</Slope><Offset>0.02 0 -0.01</Offset><Power>1.1 0.9 1</Power></SOPNode></ColorCorrection>
<ColorCorrection id='b'><SOPNode><Slope>0.8 0.8 0.8</Slope></SOPNode><SatNode><Saturation>1.4</Saturation></SatNode></ColorCorrection></ColorCorrectionCollection>"#;
effect_case("ec.color.ociofile", &[("file", s(ccc))]);
effect_case("ec.color.ociofile", &[("file", s(ccc)), ("cccId", s("b")), ("direction", e(1))]);
}
#[test]
fn ocio_cdl_space_display_look() {
let cdl = [("slopeRed", n(1.3)), ("offsetGreen", n(0.05)), ("powerBlue", n(1.4)), ("saturation", n(1.3))];
effect_case("ec.color.ociocdl", &cdl);
effect_case("ec.color.ociocdl", &[cdl.as_slice(), &[("style", e(1)), ("offsetRed", n(-0.1))]].concat());
effect_case("ec.color.ociocdl", &[cdl.as_slice(), &[("direction", e(1))]].concat());
effect_case("ec.color.ociocdl", &[cdl.as_slice(), &[("direction", e(1)), ("style", e(1))]].concat());
let spaces = effectcraft_effects::find("ec.color.ociocolorspace").unwrap();
let names = match &spaces.params.iter().find(|p| p.id == "source").unwrap().ui {
effectcraft_project::ParamUi::Popup { options } => options.clone(),
_ => vec![],
};
for (a, z) in [("sRGB", "ACEScg"), ("ACEScct", "Display P3"), ("Gamma 2.4 Rec.2020", "ACES2065-1"), ("CIE-XYZ-D65", "sRGB"), ("Raw", "sRGB")] {
let i = |n: &str| e(names.iter().position(|x| x == n).unwrap() as u32);
effect_case("ec.color.ociocolorspace", &[("source", i(a)), ("destination", i(z))]);
effect_case("ec.color.ociocolorspace", &[("source", i(a)), ("destination", i(z)), ("direction", e(1))]);
}
for display in 0..4 {
for view in 0..2 {
effect_case("ec.color.ociodisplay", &[("display", e(display)), ("view", e(view))]);
}
}
// (Inverting PQ and the roll-off together reaches values near 1000 whose matrix mix cancels
// in the small channels: f32 agrees to ~1e-5 of the pixel's largest channel there.)
effect_case("ec.color.ociodisplay", &[("display", e(3)), ("direction", e(1))]);
effect_case("ec.color.ociodisplay", &[("display", e(2)), ("view", e(1)), ("direction", e(1))]);
effect_case("ec.color.ociodisplay", &[("display", e(1)), ("direction", e(1))]);
effect_case("ec.color.ociolook", &[("look", e(3))]);
effect_case("ec.color.ociolook", &[("look", e(1)), ("looks", s("Vivid, -Cool"))]);
effect_case("ec.color.ociolook", &[("looks", s("High Contrast")), ("direction", e(1))]);
}
#[test]
fn color_profile_converter() {
for (inp, out, intent) in [(0, 1, 1), (1, 3, 0), (4, 5, 2), (6, 2, 3), (5, 1, 0), (3, 4, 1)] {
effect_case("ec.utility.colorprofileconverter", &[("inputProfile", e(inp)), ("outputProfile", e(out)), ("intent", e(intent))]);
}
effect_case(
"ec.utility.colorprofileconverter",
&[("inputProfile", e(1)), ("outputProfile", e(3)), ("linearizeInputProfile", Value::Bool(true)), ("linearizeOutputProfile", Value::Bool(true))],
);
}
#[test]
fn lumetri_luts_and_looks() {
for look in 2..8 {
effect_case("ec.color.lumetri", &[("creative/look", e(look))]);
}
effect_case("ec.color.lumetri", &[("creative/look", e(4)), ("creative/lookIntensity", n(60.0)), ("basicCorrection/tone/exposure", n(0.3))]);
effect_case("ec.color.lumetri", &[("creative/look", e(1)), ("creative/lookFile", s(&cube_3d()))]);
effect_case(
"ec.color.lumetri",
&[("basicCorrection/inputLut", e(1)), ("basicCorrection/inputLutFile", s(&cube_1d_3d())), ("basicCorrection/saturation", n(120.0))],
);
effect_case(
"ec.color.lumetri",
&[("basicCorrection/inputLut", e(1)), ("basicCorrection/inputLutFile", s(&cube_1d())), ("creative/look", e(1)), ("creative/lookFile", s(&cube_3d()))],
);
}

View File

@ -0,0 +1,162 @@
//! GPU simulation render passes vs the CPU effects (the oracle): the frame's plan (sprites,
//! pieces, blobs, drops, height grid) is shared, the raster / shading pass runs on each side.
//! Direct on a buffer (full and half resolution) and composited at 8 and 32 bpc, at a time
//! where the simulations have something to show.
use effectcraft_keyframe::Value;
use effectcraft_project::BitDepth;
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use crate::tests::{Scene, c, check, compare_at, diff, gpu, n, opts, pattern, set};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
fn off() -> Value {
Value::Bool(false)
}
/// The effect alone on a buffer at layer time `t` (`allow` = fraction of pixels over 1e-3).
fn direct(id: &str, vals: &[(&str, Value)], t: f64, allow: f64) {
let Some(g) = gpu() else { return };
let spec = effectcraft_effects::find(id).unwrap();
let size = [70.0, 44.0];
let mut params =
effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() };
for (k, v) in vals {
params.values.insert(k.to_string(), v.clone());
}
for scale in [1.0, 0.5] {
let img = pattern(7, (size[0] * scale) as u32, (size[1] * scale) as u32);
let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale };
let ctx = || effectcraft_effects::EffectCtx { params: &params, time: t, layer_size: size, seed: 11, adjustment: false, env: Default::default() };
let cpu = (spec.render)(&ctx(), buf.clone());
let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect");
assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "{id}: geometry");
let d = diff(&cpu.img, &out.img, 1e-3);
assert!(
d.over as f64 <= allow * d.total as f64,
"{id} {vals:?} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}",
d.over,
d.total,
d.max,
d.worst
);
}
}
/// A footage layer with the effect over a background and a second footage layer (the target of
/// `Value::Layer(Some(0))` parameters), composited at 8 and 32 bpc at comp time `t`.
fn composited(id: &str, vals: &[(&str, Value)], t: f64, allow: f64) {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = Scene::new(depth);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
let other = s.footage(60, 50);
let other_id = s.push(other);
let mut l = s.footage(70, 44);
let vals: Vec<(&str, Value)> =
vals.iter().map(|(k, v)| (*k, if *v == Value::Layer(Some(0)) { Value::Layer(Some(other_id.0)) } else { v.clone() })).collect();
s.effect(&mut l, id, &vals);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
let at = Tick::from_seconds_f64(t);
check(&format!("{id} {vals:?} {depth:?}"), compare_at(&s, opts(), at), allow);
check(&format!("{id} {vals:?} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, at), allow);
}
}
fn case(id: &str, vals: &[(&str, Value)], t: f64, allow: f64) {
direct(id, vals, t, allow);
composited(id, vals, t, allow);
}
#[test]
fn weather_and_star_burst() {
case("ec.sim.ccrainfall", &[("drops", n(800.0)), ("size", n(3.0))], 0.5, 0.0);
case("ec.sim.ccrainfall", &[("drops", n(500.0)), ("extras/appearance", e(1)), ("transferMode", e(0)), ("wind", n(800.0))], 0.7, 0.0);
case("ec.sim.ccrainfall", &[("drops", n(300.0)), ("compositeWithOriginal", off()), ("size", n(6.0))], 0.3, 0.0);
case("ec.sim.ccsnowfall", &[("flakes", n(600.0)), ("size", n(6.0))], 0.5, 0.0);
case("ec.sim.ccsnowfall", &[("flakes", n(400.0)), ("transferMode", e(1)), ("wiggle/wiggleAmount", n(8.0))], 1.2, 0.0);
case("ec.sim.ccstarburst", &[("gridSpacing", n(6.0))], 0.4, 0.0);
case("ec.sim.ccstarburst", &[("gridSpacing", n(4.0)), ("blendWithOriginal", n(40.0)), ("size", n(160.0))], 0.9, 0.0);
}
#[test]
fn bubbles_drizzle_mercury() {
case("ec.sim.ccbubbles", &[("bubbleAmount", n(60.0)), ("bubbleSize", n(3.0))], 0.5, 0.0);
for shading in 1..5 {
case(
"ec.sim.ccbubbles",
&[("bubbleAmount", n(40.0)), ("bubbleSize", n(4.0)), ("shadingType", e(shading)), ("reflectionType", e(shading % 2))],
0.8,
0.0,
);
}
case("ec.sim.ccdrizzle", &[("dripRate", n(12.0)), ("spreading", n(300.0))], 0.6, 0.0);
case("ec.sim.ccdrizzle", &[("dripRate", n(20.0)), ("shading/diffuse", n(40.0)), ("shading/ambient", n(-20.0)), ("light/lightHeight", n(60.0))], 1.1, 0.0);
case("ec.sim.ccmrmercury", &[("birthRate", n(4.0)), ("blobBirthSize", n(1.5)), ("blobDeathSize", n(1.0))], 1.0, 0.0);
case("ec.sim.ccmrmercury", &[("birthRate", n(3.0)), ("blobInfluence", n(60.0)), ("shading/metal", n(80.0)), ("gravity", n(2.0))], 1.5, 0.0);
}
#[test]
fn hair_wave_world_foam() {
case("ec.sim.cchair", &[("density", n(300.0)), ("length", n(10.0))], 0.0, 0.0);
case("ec.sim.cchair", &[("density", n(200.0)), ("length", n(14.0)), ("hairColor/colorInheritance", n(30.0)), ("thickness", n(1.5))], 0.0, 0.0);
case("ec.sim.waveworld", &[], 0.5, 0.0);
case("ec.sim.waveworld", &[("view", e(1))], 0.8, 0.0);
composited("ec.sim.waveworld", &[("view", e(1)), ("ground/ground", Value::Layer(Some(0))), ("heightMapControls/renderDryAreasAs", e(1))], 0.6, 0.0);
case("ec.sim.foam", &[], 1.0, 0.0);
case("ec.sim.foam", &[("view", e(2))], 1.5, 0.0);
case("ec.sim.foam", &[("view", e(2)), ("rendering/bubbleTexture", e(4)), ("rendering/blendMode", e(2))], 1.2, 0.0);
}
#[test]
fn pieces() {
case("ec.sim.carddance", &[("rows", n(5.0)), ("columns", n(7.0)), ("zRotation/zRotMultiplier", n(20.0))], 0.0, 0.0);
composited("ec.sim.carddance", &[("rows", n(4.0)), ("backLayer", Value::Layer(Some(0))), ("yRotation/yRotMultiplier", n(150.0))], 0.0, 0.0);
case("ec.sim.shatter", &[("view", e(0))], 1.0, 0.0);
case("ec.sim.shatter", &[("view", e(0)), ("shape/pattern", e(2)), ("physics/gravity", n(4.0))], 1.6, 0.0);
case("ec.transition.cardwipe", &[("completion", n(40.0))], 0.0, 0.0);
composited("ec.transition.cardwipe", &[("completion", n(60.0)), ("backLayer", Value::Layer(Some(0))), ("flipAxis", e(1))], 0.0, 0.0);
}
#[test]
fn caustics() {
case("ec.sim.caustics", &[], 0.0, 0.0);
composited(
"ec.sim.caustics",
&[("water/waterSurface", Value::Layer(Some(0))), ("water/waveHeight", n(0.3)), ("water/smoothing", n(4.0)), ("lighting/lightType", e(1))],
0.0,
0.0,
);
composited(
"ec.sim.caustics",
&[
("water/waterSurface", Value::Layer(Some(0))),
("sky/sky", Value::Layer(Some(0))),
("water/surfaceOpacity", n(0.6)),
("bottom/repeatMode", e(2)),
("bottom/scaling", n(1.4)),
("bottom/blur", n(3.0)),
("sky/repeatMode", e(1)),
],
0.0,
0.0,
);
let _ = (c(0.0, 0.0, 0.0), on());
}
#[test]
fn cpu_only_views_fall_back() {
// Shatter's wireframe views and Foam's User Defined texture render on the CPU; composited
// frames still match.
composited("ec.sim.shatter", &[], 1.0, 0.0);
composited("ec.sim.foam", &[("view", e(2)), ("rendering/bubbleTexture", e(5)), ("rendering/bubbleTextureLayer", Value::Layer(Some(0)))], 1.0, 0.0);
}

View File

@ -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: &params, 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)),
],
);
}

View File

@ -208,11 +208,15 @@ Preserve Transparency / blend kernels (a 3D track matte renders solo through the
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,
(`effects::GPU_EFFECTS`, 236 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
Environment, 3D Glasses), generators, noise, grain, text (Numbers, Timecode), time, 3D Channel (aux channels as an
extra texture), Immersive Video, colour management (colour programs from
`effects::color_program`, LUTs in a storage buffer) and the simulations' render passes (the
simulation stays on the CPU and hands its plan — sprites, pieces, blobs, grids — to a tiled
rasteriser); see
[effects.md](effects.md)) repeat the CPU effect's steps (padding, box radii, parameters, hashes)
as kernels, in one module per family (`gpu::fx_*` with `shaders/fx_*.wgsl`); consecutive GPU
effects run as one chain with one upload and one readback. Statistics that need the whole frame

View File

@ -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**: 196 effects also run on the GPU compositor with identical results.
- **GPU**: 236 effects also run on the GPU compositor with identical results.
- **32**: 306 effects process 32-bit float (HDR, overbright) pixels without clamping.
- **Status**: 306 implemented in full, 0 partial (what is missing is listed).
@ -15,14 +15,14 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| 3D Channel Extract | `ec.3d.channelextract` | 6 | | 32 | Implemented |
| Cryptomatte | `ec.3d.cryptomatte` | 4 | | 32 | Implemented |
| Depth Matte | `ec.3d.depthmatte` | 3 | | 32 | Implemented |
| Depth of Field | `ec.3d.depthoffield` | 4 | | 32 | Implemented |
| EXtractoR | `ec.3d.extractor` | 8 | | 32 | Implemented |
| Fog 3D | `ec.3d.fog3d` | 8 | | 32 | Implemented |
| ID Matte | `ec.3d.idmatte` | 5 | | 32 | Implemented |
| IDentifier | `ec.3d.identifier` | 3 | | 32 | Implemented |
| 3D Channel Extract | `ec.3d.channelextract` | 6 | GPU | 32 | Implemented |
| Cryptomatte | `ec.3d.cryptomatte` | 4 | GPU | 32 | Implemented |
| Depth Matte | `ec.3d.depthmatte` | 3 | GPU | 32 | Implemented |
| Depth of Field | `ec.3d.depthoffield` | 4 | GPU | 32 | Implemented |
| EXtractoR | `ec.3d.extractor` | 8 | GPU | 32 | Implemented |
| Fog 3D | `ec.3d.fog3d` | 8 | GPU | 32 | Implemented |
| ID Matte | `ec.3d.idmatte` | 5 | GPU | 32 | Implemented |
| IDentifier | `ec.3d.identifier` | 3 | GPU | 32 | Implemented |
## Audio
@ -112,11 +112,11 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Levels | `ec.color.levels` | 36 | GPU | 32 | Implemented |
| Levels (Individual Controls) | `ec.color.levelsic` | 27 | GPU | 32 | Implemented |
| Lumetri Color | `ec.color.lumetri` | 66 | GPU | 32 | Implemented |
| OCIO CDL Transform | `ec.color.ociocdl` | 12 | | 32 | Implemented |
| OCIO Color Space Transform | `ec.color.ociocolorspace` | 7 | | 32 | Implemented |
| OCIO Display Transform | `ec.color.ociodisplay` | 9 | | 32 | Implemented |
| OCIO File Transform | `ec.color.ociofile` | 4 | | 32 | Implemented |
| OCIO Look Transform | `ec.color.ociolook` | 6 | | 32 | Implemented |
| OCIO CDL Transform | `ec.color.ociocdl` | 12 | GPU | 32 | Implemented |
| OCIO Color Space Transform | `ec.color.ociocolorspace` | 7 | GPU | 32 | Implemented |
| OCIO Display Transform | `ec.color.ociodisplay` | 9 | GPU | 32 | Implemented |
| OCIO File Transform | `ec.color.ociofile` | 4 | GPU | 32 | Implemented |
| OCIO Look Transform | `ec.color.ociolook` | 6 | GPU | 32 | Implemented |
| PS Arbitrary Map | `ec.color.psarbitrarymap` | 2 | GPU | 32 | Implemented |
| Photo Filter | `ec.color.photofilter` | 4 | GPU | 32 | Implemented |
| Selective Color | `ec.color.selectivecolor` | 38 | GPU | 32 | Implemented |
@ -219,18 +219,18 @@ Effects are addressed by id (`ec.<category>.<name>`) 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
@ -318,23 +318,23 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| CC Ball Action | `ec.sim.ccballaction` | 8 | | 32 | Implemented |
| CC Bubbles | `ec.sim.ccbubbles` | 7 | | 32 | Implemented |
| CC Drizzle | `ec.sim.ccdrizzle` | 14 | | 32 | Implemented |
| CC Hair | `ec.sim.cchair` | 20 | | 32 | Implemented |
| CC Mr. Mercury | `ec.sim.ccmrmercury` | 23 | | 32 | Implemented |
| CC Bubbles | `ec.sim.ccbubbles` | 7 | GPU | 32 | Implemented |
| CC Drizzle | `ec.sim.ccdrizzle` | 14 | GPU | 32 | Implemented |
| CC Hair | `ec.sim.cchair` | 20 | GPU | 32 | Implemented |
| CC Mr. Mercury | `ec.sim.ccmrmercury` | 23 | GPU | 32 | Implemented |
| CC Particle Systems II | `ec.sim.ccparticlesystems2` | 21 | | 32 | Implemented |
| CC Particle World | `ec.sim.ccparticleworld` | 30 | | 32 | Implemented |
| CC Pixel Polly | `ec.sim.ccpixelpolly` | 10 | | 32 | Implemented |
| CC Rainfall | `ec.sim.ccrainfall` | 14 | | 32 | Implemented |
| CC Rainfall | `ec.sim.ccrainfall` | 14 | GPU | 32 | Implemented |
| CC Scatterize | `ec.sim.ccscatterize` | 4 | | 32 | Implemented |
| CC Snowfall | `ec.sim.ccsnowfall` | 19 | | 32 | Implemented |
| CC Star Burst | `ec.sim.ccstarburst` | 6 | | 32 | Implemented |
| Card Dance | `ec.sim.carddance` | 53 | | 32 | Implemented |
| Caustics | `ec.sim.caustics` | 28 | | 32 | Implemented |
| Foam | `ec.sim.foam` | 36 | | 32 | Implemented |
| CC Snowfall | `ec.sim.ccsnowfall` | 19 | GPU | 32 | Implemented |
| CC Star Burst | `ec.sim.ccstarburst` | 6 | GPU | 32 | Implemented |
| Card Dance | `ec.sim.carddance` | 53 | GPU | 32 | Implemented |
| Caustics | `ec.sim.caustics` | 28 | GPU | 32 | Implemented |
| Foam | `ec.sim.foam` | 36 | GPU | 32 | Implemented |
| Particle Playground | `ec.sim.particleplayground` | 92 | | 32 | Implemented |
| Shatter | `ec.sim.shatter` | 60 | | 32 | Implemented |
| Wave World | `ec.sim.waveworld` | 35 | | 32 | Implemented |
| Shatter | `ec.sim.shatter` | 60 | GPU | 32 | Implemented |
| Wave World | `ec.sim.waveworld` | 35 | GPU | 32 | Implemented |
## Stylize
@ -401,7 +401,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| 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 |
| Card Wipe | `ec.transition.cardwipe` | 44 | GPU | 32 | Implemented |
| Gradient Wipe | `ec.transition.gradientwipe` | 5 | GPU | 32 | Implemented |
| Iris Wipe | `ec.transition.iriswipe` | 7 | GPU | 32 | Implemented |
| Linear Wipe | `ec.transition.linearwipe` | 3 | GPU | 32 | Implemented |
@ -412,10 +412,10 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| Apply Color LUT | `ec.utility.applylut` | 0 | | 32 | Implemented |
| Apply Color LUT | `ec.utility.applylut` | 0 | GPU | 32 | Implemented |
| CC Overbrights | `ec.utility.ccoverbrights` | 2 | | 32 | Implemented |
| Cineon Converter | `ec.utility.cineon` | 7 | | 32 | Implemented |
| Color Profile Converter | `ec.utility.colorprofileconverter` | 7 | | 32 | Implemented |
| Color Profile Converter | `ec.utility.colorprofileconverter` | 7 | GPU | 32 | Implemented |
| Face Measurements | `ec.utility.facemeasurements` | 14 | | 32 | Implemented |
| Face Track Points | `ec.utility.facetrackpoints` | 26 | | 32 | Implemented |
| Grow Bounds | `ec.utility.growbounds` | 1 | | 32 | Implemented |

View File

@ -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)); 196 run on the GPU |
| **Effects** | **306** effects (all After Effects 2026 effects), every one implemented in full ([effects.md](effects.md)); 236 run on the GPU |
| Disabled menu entries left | 1, on purpose: Import ▸ Vanishing Point (.vpe), whose format has no public specification |
| Remaining work | ≈ 9–11 agent-hours at the pace measured so far (≈ 29 on the conservative audit scale); ≈ 6–7 without the learned-model items |
| **Wall-clock estimate** | **≈ 2–2.5 hours** with five agents in parallel; ≈ 1.5 hours for 100% of P0 + P1 |
@ -19,7 +19,7 @@ Partial features:
| id | Tier | Done | What is missing |
|---|---|---|---|
| EFF-5 GPU effects | P1 | 0.85 | GPU kernels for the CC light / transition families, Numbers and Timecode, 3D Channel, the VR family, OCIO / LUT, Time effects and the simulations; Advanced 3D runs with blend modes, track mattes or environment backgrounds composite on the CPU |
| EFF-5 GPU effects | P1 | 0.97 | GPU kernels for the audio-driven effects and the stepped particle systems' sprite pass (the CC light / transition families, Numbers, Timecode and Time effects landed in M13.22; 3D Channel, the VR family, OCIO / LUT and the simulations' render passes in M13.23); custom `.ocio` configs, Shatter's wireframe views and Foam's texture / environment / flow-map extras render on the CPU |
| WEB-1 Web app | P0 | 0.97 | GPU effects in Render Queue / analysis job workers (they render on their CPU); layer buffers in the browser's disk cache (frames only); threads inside one engine instance (the decided design is one engine instance per worker; a threaded build needs nightly `build-std`). M13.24 added GPU effects and the GPU compositor in the frame workers (their own WebGPU devices, 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.*`) and fixed a WGSL constant Chrome rejected (it disabled every GPU kernel in Chrome) |
| UI-7 Home screen | P1 | 0.95 | A "start from a template" gallery |
| MSK-4 Roto Brush | P2 | 0.85 | Segmentation is classical (graph cut + optical flow), not a learned model |
@ -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 (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) |
| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (236 run on the GPU since M13.23, EFF-5: M13.23 added the 3D Channel and Immersive Video families, Apply Color LUT, the OCIO effects, Color Profile Converter and the simulations' render passes (Card Wipe included); M13.22 added 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; audio-driven effects and the stepped particle systems 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 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) |
| Preview | 77% | 2.8 | GPU effects for the audio-driven effects and the stepped particle systems (3D channel, VR, OCIO, Card Wipe and the simulations' render passes run on the GPU since M13.23; 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 |
@ -411,6 +411,44 @@ in Settings (`customRgb`) and simulated like the built-in profiles, with Preserv
`form.field.<key>`, `form.ok` and `form.cancel` automation ids. Not yet: LUT-based (`A2B0`)
ICC profiles.
## Update: M13.23 GPU effects, part B (EFF-5)
40 more effects run on the GPU (236 in all, with M13.22's 30), each checked against the CPU oracle (≤ 1/255 at
8 bpc, ≤ 1e-3 at 32 bpc) directly on a buffer (full and half resolution, adjustment) and
composited at 8 and 32 bpc:
- **3D Channel** (`gpu::fx_depth`): 3D Channel Extract (every channel, Anti-alias), Cryptomatte,
Depth Matte, Depth of Field, EXtractoR, Fog 3D (with a Gradient Layer), ID Matte and
IDentifier. The layer's auxiliary channels upload as an extra texture at the aux resolution and
are resampled onto the buffer with the CPU's index arithmetic (tested at 1× and 2× aux
resolution and on padded buffers). Cryptomatte's ranks are reduced per aux pixel on the CPU
(selection coverage and ID colours), then looked up on the GPU.
- **Immersive Video** (`gpu::fx_vr`): VR Blur, Chromatic Aberrations, Color Gradients, Converter
(all nine projections and cube layouts), De-Noise (guided filter and median), Digital Glitch,
Fractal Noise, Glow, Plane to Sphere, Rotate Sphere, Sharpen and Sphere to Plane, in mono and
both stereo layouts: the equirectangular maths (seam wrap, pole continuation, latitude-widened
box blurs) ported operation for operation. Exception: VR Converter re-projections that cross a
cube-face edge or a fisheye rim may pick the neighbouring face for directions within f32
rounding of the edge (the CPU decides in f64): up to 0.5 % of pixels allowed (measured 0).
- **Colour management** (`gpu::fx_lut`): Apply Color LUT, OCIO CDL / Color Space / Display /
File / Look Transform and Color Profile Converter compile to colour programs
(`effects::color_program`, next to the CPU effects) interpreted per pixel; 1D / 3D LUTs and
cineSpace shapers are read from a storage buffer with the CPU's nearest / trilinear /
tetrahedral interpolation and inverses (not a hardware 3D texture: its filtering rounds the
weights and cannot do tetrahedral). Lumetri's Input LUT and Look no longer force the CPU.
Custom `.ocio` configurations render on the CPU. Exception: nearest-neighbour lattice lookups
may pick the other lattice point for 8 bpc inputs exactly on a rounding boundary (up to 1 %
of pixels allowed).
- **Simulation render passes** (`gpu::fx_sim`): CC Rainfall, CC Snowfall, CC Star Burst, CC
Bubbles, CC Drizzle, CC Hair, CC Mr. Mercury, Caustics, Wave World, Foam, Shatter, Card Dance
and Card Wipe. The per-frame simulation / layout stays on the CPU and is shared with the CPU
effect as a plan (sprites, textured pieces, blobs, drops, the wave grid, Caustics' prepared
layers); a tiled rasteriser composites each pixel's items in the CPU's order and shading, with
layer-dependent colours (refracting rain, star-burst and hair roots, bubble refraction)
sampled per pixel. Piece plans read the frame back once (their gradient maps and textures
default to the layer). Shatter's wireframe views and Foam's User Defined texture,
Environment Map and flow-map preview render on the CPU.
## Highest-value gaps, in order
1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10).