Merge main into M13.28 worktree

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-04 02:57:55 -04:00
commit c59e0a6407
32 changed files with 2536 additions and 430 deletions

View File

@ -25,14 +25,14 @@ once.
## How far from full parity
Measured feature by feature in [docs/parity.md](docs/parity.md) (3 October 2026, evening):
Measured feature by feature in [docs/parity.md](docs/parity.md) (4 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, 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
on the Home screen, and learned-model quality for Roto Brush and face tracking.
- **≈ 99% of After Effects' features, weighted by importance**, counting partial features as half
done (≈ 99.8% with per-feature fractions): 89 of 92 features done, 3 partial, none missing; every
essential (P0) feature done; all 306 effects implemented in full, 280 of them on the GPU.
- **≈ 8.5 agent-hours of work remain**: about **2–3 hours of wall-clock time** with five Claude
Opus 5.5 agents in parallel, ≈ 1 hour without learned models for Roto Brush and face tracking.
- What is left: GPU kernels for the remaining visual effects, and learned-model quality for Roto
Brush and face tracking.
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).

View File

@ -91,36 +91,12 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[
];
/// Whether the GPU kernel of effect `id` covers the instance's current settings (see
/// [`CPU_ONLY_CONTROLS`]; plus Cell Pattern's HQ variants, Turbulent Displace's locked
/// pinning, Warp's strong Fisheye / Twist and Timewarp with a Matte Layer).
/// [`CPU_ONLY_CONTROLS`]; plus Cell Pattern's HQ variants and Turbulent Displace's locked
/// pinning).
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 /
// Vertical Distortion: Newton's inverse wanders chaotically before converging near the
// fold, so f32 lands on other pixels than the CPU's f64.
"ec.distort.warp"
if matches!(ctx.params.e("warpStyle"), 11 | 14)
&& (ctx.params.f("bend").abs() > 50.0 || ctx.params.f("horizontalDistortion") != 0.0 || ctx.params.f("verticalDistortion") != 0.0) =>
{
return false;
}
_ => {}
}
let Some((_, controls)) = CPU_ONLY_CONTROLS.iter().find(|(e, _)| *e == id) else { return true };

View File

@ -663,13 +663,24 @@ fn warp(ctx: &EffectCtx, mut b: Buf) -> Buf {
if k == 0.0 && hd == 0.0 && vd == 0.0 {
return b;
}
let style = ctx.params.e("warpStyle");
let vertical = ctx.params.e("warpAxis") == 1;
let (hx, hy) = (ctx.layer_size[0] * b.scale * 0.5, ctx.layer_size[1] * b.scale * 0.5);
if !ctx.adjustment {
let pad = ((k.abs() + hd.abs() + vd.abs()) * 0.6 * hx.max(hy)).ceil().min(4096.0);
b.pad(pad as u32 + 1);
}
b.img = remap(&b.img, false, warp_inverse(ctx, &b));
b
}
/// Warp's inverse map for buffer geometry `b` (padded): the source point of an output point
/// (buffer px), by Newton iterations on the forward warp; `None` = transparent.
fn warp_inverse(ctx: &EffectCtx, b: &Buf) -> impl Fn(f64, f64) -> Option<(f64, f64)> + Sync + use<> {
let k = ctx.params.f("bend") / 100.0;
let hd = ctx.params.f("horizontalDistortion") / 100.0;
let vd = ctx.params.f("verticalDistortion") / 100.0;
let style = ctx.params.e("warpStyle");
let vertical = ctx.params.e("warpAxis") == 1;
let (hx, hy) = (ctx.layer_size[0] * b.scale * 0.5, ctx.layer_size[1] * b.scale * 0.5);
let c = b.to_px([ctx.layer_size[0] * 0.5, ctx.layer_size[1] * 0.5]);
let (hx, hy) = (hx.max(1e-6), hy.max(1e-6));
let fwd = move |x: f64, y: f64| -> (f64, f64) {
@ -682,7 +693,7 @@ fn warp(ctx: &EffectCtx, mut b: Buf) -> Buf {
x1 *= 1.0 + 0.5 * vd * y1;
(c.0 + x1 * hx, c.1 + y1 * hy)
};
b.img = remap(&b.img, false, |x, y| {
move |x, y| {
let (mut sx, mut sy) = (x, y);
for _ in 0..12 {
let (fx, fy) = fwd(sx, sy);
@ -706,8 +717,19 @@ fn warp(ctx: &EffectCtx, mut b: Buf) -> Buf {
return None;
}
Some((sx, sy))
});
b
}
}
/// Warp's inverse map over a `w` × `h` buffer with geometry `b` (its pixels are not read), as
/// an image of (source x, source y, 1, 0) per pixel centre, transparent where the warp leaves
/// the pixel empty: the plan of the GPU compositor's Warp where the Newton iterations must run
/// in f64 to land on the CPU's pixels (strong Fisheye / Twist bends).
pub fn warp_inverse_map(ctx: &EffectCtx, b: &Buf, w: u32, h: u32) -> Image {
let f = warp_inverse(ctx, b);
crate::util::gen_image(w, h, |x, y| match f(x as f64 + 0.5, y as f64 + 0.5) {
Some((sx, sy)) => [sx as f32, sy as f32, 1.0, 0.0],
None => [0.0; 4],
})
}
// ---------------------------------------------------------------- Detail-preserving Upscale

View File

@ -75,7 +75,7 @@ pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sampl
pub use color3::OffsetCurve;
pub use distort::transform_shutter;
pub use distort2::{MAGNIFY_MODES, bezier_grid, parse_mesh};
pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, reshape_setup, smear_setup};
pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, reshape_setup, smear_setup, warp_inverse_map};
pub use distort4::liquify_mesh;
use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids;
@ -83,7 +83,7 @@ use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property};
pub use effectcraft_raster::{AuxChannels, Image};
pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation};
pub use sim::particle_state;
pub use sim3::playground_state;
pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state};
pub use stylize2::strobe_on;
// CPU helpers the GPU kernels share (effectcraft-gpu).
pub use blur2::{camera_lens_plain, camera_lens_spans};
@ -109,7 +109,10 @@ pub use ocio::color_stabilizer_maps;
pub use channel3d::{CRYPTO_LAYERS, id_color, selection_hashes};
pub use ocio::{ColorOp, Straight, Tf, color_program};
pub use 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 sim2::{
CausticsSetup, FoamDisc, FoamEnv, FoamPlan, Piece, PiecePlan, PieceTex, PixelPlan, WavePlan, Waves, caustics_setup, foam_full_plan, piece_plan, pixel_plan,
wave_world_plan,
};
pub use utility::{Lut, load_lut};
pub use vr::{equi_dir as vr_equi_dir, rotation as vr_rotation};
@ -990,6 +993,14 @@ pub const GPU_EFFECTS: &[&str] = &[
"ec.sim.shatter",
"ec.sim.carddance",
"ec.transition.cardwipe",
// effectcraft-gpu fx_particles (particle render passes)
"ec.sim.ccparticleworld",
"ec.sim.ccparticlesystems2",
"ec.sim.particleplayground",
"ec.sim.ccballaction",
"ec.sim.ccpixelpolly",
"ec.sim.ccscatterize",
"ec.noise.curlnoise",
// effectcraft-gpu fx_light (the CC light family)
"ec.generate.cclightrays",
"ec.generate.cclightburst",

View File

@ -888,6 +888,88 @@ pub enum ColorOp {
/// 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 },
/// `m × (c + pre) + post` (a custom config's MatrixTransform and RangeTransform).
Affine { m: [[f32; 3]; 3], pre: [f32; 3], post: [f32; 3] },
/// `max(c, 0) ^ p` per channel (ExponentTransform).
Pow([f32; 3]),
/// LogTransform / LogAffineTransform, forward or inverse (as `ocio_config::Xf::apply`).
LogAffine { base: f32, log_slope: [f32; 3], log_offset: [f32; 3], lin_slope: [f32; 3], lin_offset: [f32; 3], inverse: bool },
}
/// The ops of a LUT / CDL file transform (as [`FileXform::apply`]).
fn file_ops(x: &FileXform, interp: u32, inverse: bool, ccc_id: &str, ops: &mut Vec<ColorOp>) {
match x {
FileXform::Cdl(list) => {
let cdl = list.iter().find(|(i, _)| !ccc_id.is_empty() && i == ccc_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 });
}
}
}
/// The ops of a custom config's transform `x` (as `Xf::apply`).
fn config_xf_ops(x: &crate::ocio_config::Xf, inverse: bool, ops: &mut Vec<ColorOp>) {
use crate::ocio_config::Xf;
let f3 = |v: &[f64; 3]| v.map(|x| x as f32);
match x {
Xf::Inverse(x) => config_xf_ops(x, !inverse, ops),
Xf::Matrix { m, offset } => {
if !inverse {
let m = [0, 1, 2].map(|r| [m[r * 4] as f32, m[r * 4 + 1] as f32, m[r * 4 + 2] as f32]);
ops.push(ColorOp::Affine { m, pre: [0.0; 3], post: [offset[0] as f32, offset[1] as f32, offset[2] as f32] });
} else if let Some(inv) = crate::ocio_config::mat3_inverse(m) {
let m = inv.map(|r| r.map(|v| v as f32));
ops.push(ColorOp::Affine { m, pre: [-offset[0] as f32, -offset[1] as f32, -offset[2] as f32], post: [0.0; 3] });
}
}
Xf::File { xf: Some(x), interp, ccc, .. } => file_ops(x, *interp, inverse, ccc, ops),
Xf::File { xf: None, .. } | Xf::Unsupported(_) => {}
Xf::Exponent(e) => ops.push(ColorOp::Pow([0, 1, 2].map(|k| (if inverse { 1.0 / e[k].max(1e-9) } else { e[k] }) as f32))),
Xf::Log { base } => {
ops.push(ColorOp::LogAffine { base: *base as f32, log_slope: [1.0; 3], log_offset: [0.0; 3], lin_slope: [1.0; 3], lin_offset: [0.0; 3], inverse })
}
Xf::LogAffine { base, log_slope, log_offset, lin_slope, lin_offset } => ops.push(ColorOp::LogAffine {
base: *base as f32,
log_slope: f3(log_slope),
log_offset: f3(log_offset),
lin_slope: f3(lin_slope),
lin_offset: f3(lin_offset),
inverse,
}),
Xf::Cdl(cdl) => ops.push(ColorOp::Cdl { cdl: *cdl, clamp: true, inverse }),
Xf::Range { min_in, max_in, min_out, max_out } => {
let (a0, a1, b0, b1) = if inverse { (*min_out, *max_out, *min_in, *max_in) } else { (*min_in, *max_in, *min_out, *max_out) };
let k = if (a1 - a0).abs() > 1e-12 { (b1 - b0) / (a1 - a0) } else { 0.0 };
let k = k as f32;
ops.push(ColorOp::Affine { m: [[k, 0.0, 0.0], [0.0, k, 0.0], [0.0, 0.0, k]], pre: [-a0 as f32; 3], post: [b0 as f32; 3] });
}
Xf::Group(list) => {
if inverse {
list.iter().rev().for_each(|x| config_xf_ops(x, true, ops));
} else {
list.iter().for_each(|x| config_xf_ops(x, false, ops));
}
}
}
}
/// The ops of a custom config's conversion from space `a` to space `z` (as `Config::convert`).
fn config_ops(a: &crate::ocio_config::ConfigSpace, z: &crate::ocio_config::ConfigSpace, ops: &mut Vec<ColorOp>) {
if a.is_data || z.is_data || a.name == z.name {
return;
}
match (&a.to_ref, &a.from_ref) {
(Some(x), _) => config_xf_ops(x, false, ops),
(None, Some(x)) => config_xf_ops(x, true, ops),
_ => {}
}
match (&z.from_ref, &z.to_ref) {
(Some(x), _) => config_xf_ops(x, false, ops),
(None, Some(x)) => config_xf_ops(x, true, ops),
_ => {}
}
}
/// How a colour program meets premultiplied pixels.
@ -912,8 +994,8 @@ fn xform_ops(x: &Xform, ops: &mut Vec<ColorOp>) {
}
/// 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).
/// (empty = the layer passes through; custom OCIO configs compile their transforms too). `None`
/// for another effect.
pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec<ColorOp>, Straight)> {
let pr = ctx.params;
let mut ops = vec![];
@ -926,7 +1008,16 @@ pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec<ColorOp>, Straigh
}
"ec.color.ociocolorspace" => {
if pr.e("config") == 1 {
return custom_config(ctx).is_none().then_some((ops, Straight::Unpremul));
// A custom config (one that can't be read, or names unknown spaces: the pixels
// pass through).
if let Some(cfg) = custom_config(ctx)
&& let (Some(a), Some(z)) =
(custom_space(&cfg, pr.s("sourceName"), pr.e("source")), custom_space(&cfg, pr.s("destinationName"), pr.e("destination")))
{
let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) };
config_ops(a, z, &mut ops);
}
return 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) };
@ -942,7 +1033,17 @@ pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec<ColorOp>, Straigh
}
"ec.color.ociodisplay" => {
if pr.e("config") == 1 {
return custom_config(ctx).is_none().then_some((ops, Straight::Unpremul));
if let Some(cfg) = custom_config(ctx) {
let display = DISPLAYS.get(pr.e("display") as usize).map_or("", |d| d.0);
let view = VIEWS.get(pr.e("view") as usize).copied().unwrap_or("");
let dn = if pr.s("displayName").is_empty() { display } else { pr.s("displayName") };
let vn = if pr.s("viewName").is_empty() { view } else { pr.s("viewName") };
if let (Some(a), Some(z)) = (custom_space(&cfg, pr.s("sourceName"), pr.e("source")), cfg.view_space(dn, vn)) {
let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) };
config_ops(a, z, &mut ops);
}
}
return 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);
@ -974,17 +1075,7 @@ pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec<ColorOp>, Straigh
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 });
}
}
file_ops(&x, interp, pr.e("direction") == 1, pr.s("cccId"), &mut ops);
}
}
"ec.color.ociolook" => {

View File

@ -439,7 +439,7 @@ fn mat_apply(m: &[f64; 16], off: &[f64; 4], c: [f64; 3]) -> [f64; 3] {
[0, 1, 2].map(|r| m[r * 4] * c[0] + m[r * 4 + 1] * c[1] + m[r * 4 + 2] * c[2] + off[r])
}
fn mat3_inverse(m: &[f64; 16]) -> Option<[[f64; 3]; 3]> {
pub(crate) fn mat3_inverse(m: &[f64; 16]) -> Option<[[f64; 3]; 3]> {
let a = [[m[0], m[1], m[2]], [m[4], m[5], m[6]], [m[8], m[9], m[10]]];
let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);

View File

@ -268,6 +268,8 @@ pub fn sprite_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
crate::sim2::WavePlan::Height { .. } => None,
}),
"ec.sim.foam" => crate::sim2::foam_plan(ctx, b),
"ec.sim.ccparticleworld" => particle_world_plan(ctx, b),
"ec.sim.ccparticlesystems2" => Some(particle_systems2_plan(ctx, b)),
_ => None,
}
}
@ -1059,12 +1061,20 @@ fn pw_phys(ctx: &EffectCtx) -> Phys {
}
}
fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf {
fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf {
match particle_world_plan(ctx, &b) {
Some(plan) => plan.finish(b),
None => b,
}
}
/// CC Particle World's sprites (far to near), `None` before anything is born.
fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
let pr = ctx.params;
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let phys = pw_phys(ctx);
if phys.rate <= 0.0 && ctx.time <= 0.0 {
return b;
return None;
}
let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1);
let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PW_CACHE, key, ctx.time, &phys));
@ -1097,20 +1107,24 @@ fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf {
.collect();
list.sort_by(|a, b| b.0.total_cmp(&a.0));
let sprites: Vec<Sprite> = list.into_iter().map(|(_, s)| s).collect();
let mode = pr.e("particle/transferMode");
let fx = splat(b.img.width, b.img.height, &sprites, if mode == 2 { Acc::Add } else { Acc::Over });
b.img = combine(
&b.img,
&fx,
match mode {
Some(particle_sprites(sprites, pr.e("particle/transferMode")))
}
/// The CC particle systems' sprites with their Transfer Mode (Composite, Screen, Add, Black
/// Background).
fn particle_sprites(sprites: Vec<Sprite>, mode: u32) -> SpritePlan {
SpritePlan {
sprites,
tints: vec![],
acc: if mode == 2 { Acc::Add } else { Acc::Over },
post: Post::Combine(match mode {
0 => 0,
1 => 1,
2 => 2,
3 => 5,
_ => 0,
},
);
b
}),
}
}
// ------------------------------------------------------------------ CC Particle Systems II
@ -1143,7 +1157,12 @@ fn ps2_phys(ctx: &EffectCtx) -> Phys {
}
}
fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf {
fn particle_systems2(ctx: &EffectCtx, b: Buf) -> Buf {
particle_systems2_plan(ctx, &b).finish(b)
}
/// CC Particle Systems II's sprites.
fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
let pr = ctx.params;
let lh = ctx.layer_size[1] as f32;
let unit = lh.max(1.0);
@ -1167,20 +1186,7 @@ fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf {
type_sprite(kind, bx as f32, by as f32, r, c, q.v[0] * unit * s, q.v[1] * unit * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0)
})
.collect();
let mode = pr.e("particle/transferMode");
let fx = splat(b.img.width, b.img.height, &sprites, if mode == 2 { Acc::Add } else { Acc::Over });
b.img = combine(
&b.img,
&fx,
match mode {
0 => 0,
1 => 1,
2 => 2,
3 => 5,
_ => 0,
},
);
b
particle_sprites(sprites, pr.e("particle/transferMode"))
}
// ------------------------------------------------------------------ CC Mr. Mercury

View File

@ -12,7 +12,7 @@ use rayon::prelude::*;
use crate::card3d::{Lighting, Proj};
use crate::generate::value_noise;
use crate::sim::{Acc, Shape, Sprite, h, hs, pct, pt, raster, spec, splat};
use crate::sim::{Acc, Post, Shape, Sprite, SpritePlan, h, hs, pct, pt, raster, spec, splat};
use crate::util::{Plane, SimCache, gauss_plane, layer_or_self, params_key, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
@ -146,7 +146,10 @@ impl PiecePlan {
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.sim.shatter" if ctx.params.e("view") == 0 => match shatter_impl(ctx, b) {
ShatterPlan::Pieces(plan) => Some(plan),
ShatterPlan::Wire(_) => None,
},
"ec.transition.cardwipe" => crate::transition::card_wipe_plan(ctx, b),
_ => None,
}
@ -290,7 +293,12 @@ pub(crate) fn sort_far_first(pieces: &mut [Piece]) {
// ------------------------------------------------------------------ CC Ball Action
fn ball_action(ctx: &EffectCtx, mut b: Buf) -> Buf {
fn ball_action(ctx: &EffectCtx, b: Buf) -> Buf {
ball_action_plan(ctx, &b).finish(b)
}
/// CC Ball Action's balls (far to near), coloured by the layer's pixels.
fn ball_action_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
let pr = ctx.params;
let scatter = pr.f("scatter") as f32;
let axis = pr.e("rotationAxis");
@ -307,7 +315,7 @@ fn ball_action(ctx: &EffectCtx, mut b: Buf) -> Buf {
let ny = (lh / spacing).ceil().max(1.0) as u32;
let d = 2.0 * lw.max(lh) as f64;
let (cx, cy) = (lw as f64 * 0.5, lh as f64 * 0.5);
let src = b.img.clone();
let src = &b.img;
let ax: [f64; 3] = match axis {
0 => [1.0, 0.0, 0.0],
1 => [0.0, 1.0, 0.0],
@ -353,17 +361,24 @@ fn ball_action(ctx: &EffectCtx, mut b: Buf) -> Buf {
.collect();
balls.sort_by(|a, b| b.0.total_cmp(&a.0));
let sprites: Vec<Sprite> = balls.into_iter().map(|(_, s)| s).collect();
b.img = splat(src.width, src.height, &sprites, Acc::Over);
b
SpritePlan { sprites, tints: vec![], acc: Acc::Over, post: Post::Replace }
}
// ------------------------------------------------------------------ CC Pixel Polly
fn pixel_polly(ctx: &EffectCtx, mut b: Buf) -> Buf {
fn pixel_polly(ctx: &EffectCtx, b: Buf) -> Buf {
match pixel_polly_plan(ctx, &b) {
Some(plan) => plan.finish(b),
None => b,
}
}
/// CC Pixel Polly's pieces, `None` before Start Time (the layer as is).
fn pixel_polly_plan(ctx: &EffectCtx, b: &Buf) -> Option<PiecePlan> {
let pr = ctx.params;
let t = ctx.time - pr.f("startTime");
if t <= 0.0 {
return b;
return None;
}
let force = pr.f("force");
let gravity = pr.f("gravity");
@ -383,7 +398,7 @@ fn pixel_polly(ctx: &EffectCtx, mut b: Buf) -> Buf {
let tri = object <= 1;
let textured = object == 1 || object == 3;
let maxd = lw.max(lh);
let src = b.img.clone();
let src = &b.img;
let per = if tri { 2 } else { 1 };
let mut pieces: Vec<Piece> = (0..nx * ny * per)
.into_par_iter()
@ -425,21 +440,26 @@ fn pixel_polly(ctx: &EffectCtx, mut b: Buf) -> Buf {
if depth_sort {
sort_far_first(&mut pieces);
}
let mut out = Image::new(src.width, src.height);
draw_pieces(&mut out, &pieces, &src, None);
b.img = out;
b
Some(PiecePlan { pieces, front: PieceTex::Layer, back: None })
}
// ------------------------------------------------------------------ CC Scatterize
fn scatterize(ctx: &EffectCtx, mut b: Buf) -> Buf {
fn scatterize(ctx: &EffectCtx, b: Buf) -> Buf {
match scatterize_plan(ctx, &b) {
Some(plan) => plan.finish(b),
None => b,
}
}
/// CC Scatterize's pixel dots, `None` when nothing moves (the layer as is).
fn scatterize_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
let pr = ctx.params;
let scatter = pr.f("scatter") as f32;
let right = pr.f("rightTwist").to_radians();
let left = pr.f("leftTwist").to_radians();
if scatter == 0.0 && right == 0.0 && left == 0.0 {
return b;
return None;
}
let add = pr.e("transferMode") == 1;
let seed = ctx.seed.wrapping_mul(0x7feb352d);
@ -471,8 +491,29 @@ fn scatterize(ctx: &EffectCtx, mut b: Buf) -> Buf {
Some(Sprite::new(sx as f32, sy as f32, 0.6 * persp as f32, [c[0], c[1], c[2], a], Shape::Disc))
})
.collect();
b.img = splat(w, hh, &sprites, if add { Acc::Add } else { Acc::Over });
b
Some(SpritePlan { sprites, tints: vec![], acc: if add { Acc::Add } else { Acc::Over }, post: Post::Replace })
}
/// What a particle effect that reads the layer's pixels draws this frame (CC Ball Action, CC
/// Pixel Polly, CC Scatterize, Shatter's wireframe views), for layer `b`; `None` for other
/// effects or a frame showing the layer as is.
pub enum PixelPlan {
Sprites(SpritePlan),
Pieces(PiecePlan),
}
/// The [`PixelPlan`] of effect `id` for layer `b` (its pixels are read).
pub fn pixel_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option<PixelPlan> {
match id {
"ec.sim.ccballaction" => Some(PixelPlan::Sprites(ball_action_plan(ctx, b))),
"ec.sim.ccpixelpolly" => pixel_polly_plan(ctx, b).map(PixelPlan::Pieces),
"ec.sim.ccscatterize" => scatterize_plan(ctx, b).map(PixelPlan::Sprites),
"ec.sim.shatter" => Some(match shatter_impl(ctx, b) {
ShatterPlan::Pieces(plan) => PixelPlan::Pieces(plan),
ShatterPlan::Wire(plan) => PixelPlan::Sprites(plan),
}),
_ => None,
}
}
// ------------------------------------------------------------------ Card Dance
@ -686,14 +727,19 @@ fn map_key(c: [f32; 4]) -> u32 {
}
fn shatter(ctx: &EffectCtx, b: Buf) -> Buf {
match shatter_impl(ctx, &b, false) {
Ok(img) => Buf { img, ..b },
Err(plan) => plan.finish(b),
match shatter_impl(ctx, &b) {
ShatterPlan::Pieces(plan) => plan.finish(b),
ShatterPlan::Wire(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> {
/// What Shatter draws: the pieces (Rendered view) or the wireframe lines.
enum ShatterPlan {
Pieces(PiecePlan),
Wire(SpritePlan),
}
fn shatter_impl(ctx: &EffectCtx, b: &Buf) -> ShatterPlan {
let pr = ctx.params;
let view = pr.e("view");
let render = pr.e("render");
@ -770,7 +816,6 @@ fn shatter_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result<Image, Piec
e.1 += 1.0;
}
let g3 = [gdir.sin() * ginc.cos() * gravity * unit, -gdir.cos() * ginc.cos() * gravity * unit, ginc.sin() * gravity * unit];
let src = b.img.clone();
let mut pieces: Vec<Piece> = polys
.par_iter()
.zip(keys.par_iter())
@ -902,13 +947,8 @@ fn shatter_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result<Image, Piec
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);
return ShatterPlan::Pieces(PiecePlan { pieces, front: front_t, back: Some(back_t) });
}
let out;
{
// Wireframes: piece outlines (front view: at rest; otherwise animated), plus forces.
let animated = view == 2 || view == 4;
@ -948,9 +988,8 @@ fn shatter_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result<Image, Piec
}
}
}
out = splat(src.width, src.height, &lines, Acc::Over);
ShatterPlan::Wire(SpritePlan { sprites: lines, tints: vec![], acc: Acc::Over, post: Post::Replace })
}
Ok(out)
}
// ------------------------------------------------------------------ Caustics
@ -1516,22 +1555,97 @@ fn foam_pop_age(id: u32, lifespan: f32, frailty: f32, seed: u32) -> f32 {
/// 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, b: Buf) -> Buf {
match foam_impl(ctx, &b, false) {
Ok(img) => Buf { img, ..b },
Err(plan) => plan.finish(b),
}
foam_full_plan(ctx, &b).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()
let plan = foam_full_plan(ctx, b);
(plan.user.is_none() && plan.env.is_none() && plan.flow.is_none()).then_some(plan.sprites)
}
fn foam_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result<Image, crate::sim::SpritePlan> {
/// A bubble disc filled by Foam's User Defined texture (buffer px; `rot` radians).
#[derive(Clone, Copy, Debug)]
pub struct FoamDisc {
pub x: f64,
pub y: f64,
pub r: f32,
pub rot: f32,
pub fade: f32,
}
/// Foam's Environment Map reflection: the map fitted to the buffer, Reflection Convergence and
/// Strength, and the bubble discs reflecting it (`rot` and `fade` unused).
pub struct FoamEnv {
pub img: Image,
pub conv: f64,
pub strength: f32,
pub discs: Vec<FoamDisc>,
}
/// What Foam draws this frame over a transparent frame of the layer's size: the bubble sprites,
/// then the User Defined texture's discs, the Environment Map's reflections and (Draft + Flow
/// Map) the flow map under it all, each [`foam_disc`]-composited in bubble order.
pub struct FoamPlan {
pub sprites: crate::sim::SpritePlan,
pub user: Option<(crate::LayerPixels, Vec<FoamDisc>)>,
pub env: Option<FoamEnv>,
/// The flow map fitted to the buffer.
pub flow: Option<Image>,
}
impl FoamPlan {
pub fn finish(&self, b: Buf) -> Buf {
let mut out = splat(b.img.width, b.img.height, &self.sprites.sprites, self.sprites.acc);
// User Defined: the texture layer fills each bubble's disc.
if let Some((tex, discs)) = &self.user {
for d in discs {
let (rot, fade) = (d.rot, d.fade);
foam_disc(&mut out, d.x, d.y, d.r as f64, |u, v| {
// u, v in −1..1 across the bubble, rotated into the texture.
let (sr, cr) = rot.sin_cos();
let (tu, tv) = (u as f32 * cr + v as f32 * sr, -u as f32 * sr + v as f32 * cr);
let (x, y) = ((tu as f64 * 0.5 + 0.5) * tex.size[0], (tv as f64 * 0.5 + 0.5) * tex.size[1]);
let px = tex.buf.img.sample_bilinear(x * tex.buf.scale + tex.buf.offset[0], y * tex.buf.scale + tex.buf.offset[1]);
px.map(|c| c * fade)
});
}
}
// Environment Map: each bubble reflects the map along its sphere normal (Reflection
// Convergence pulls the reflection towards the bubble's own spot of the map).
if let Some(env) = &self.env {
let (conv, strength) = (env.conv, env.strength);
let (w, h) = (b.img.width as f64, b.img.height as f64);
for d in &env.discs {
let (bx, by) = (d.x, d.y);
foam_disc(&mut out, bx, by, d.r as f64, |u, v| {
// Sphere normal; the reflection looks across the whole map, or (convergence 1)
// just behind the bubble.
let (ex, ey) =
if conv >= 1.0 { (bx, by) } else { (bx * conv + (u * 0.5 + 0.5) * w * (1.0 - conv), by * conv + (v * 0.5 + 0.5) * h * (1.0 - conv)) };
let e = env.img.sample_bilinear(ex, ey);
let rim = ((u * u + v * v) as f32).min(1.0);
let k = strength * (0.3 + 0.7 * rim);
[e[0] * k, e[1] * k, e[2] * k, 0.0]
});
}
}
// Draft + Flow Map shows the flow map under the bubbles.
if let Some(fm) = &self.flow {
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 {
o[c] += f[c] * 0.5 * k;
}
});
}
Buf { img: out, ..b }
}
}
/// Foam's [`FoamPlan`] for layer geometry `b` (its size; its pixels are not read).
pub fn foam_full_plan(ctx: &EffectCtx, b: &Buf) -> FoamPlan {
let pr = ctx.params;
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let unit = lw * 0.01;
@ -1754,62 +1868,34 @@ fn foam_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result<Image, crate::
})
.collect();
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 {
let (bx, by, r, rot) = place(q);
let fade = ((foam_pop_age(q.id, lifespan, frailty, seed) - q.age) / 5.0).clamp(0.0, 1.0);
foam_disc(&mut out, bx, by, r as f64, |u, v| {
// u, v in −1..1 across the bubble, rotated into the texture.
let (sr, cr) = rot.sin_cos();
let (tu, tv) = (u as f32 * cr + v as f32 * sr, -u as f32 * sr + v as f32 * cr);
let (x, y) = ((tu as f64 * 0.5 + 0.5) * tex.size[0], (tv as f64 * 0.5 + 0.5) * tex.size[1]);
let px = tex.buf.img.sample_bilinear(x * tex.buf.scale + tex.buf.offset[0], y * tex.buf.scale + tex.buf.offset[1]);
px.map(|c| c * fade)
});
}
}
// Environment Map: each bubble reflects the map along its sphere normal (Reflection
// Convergence pulls the reflection towards the bubble's own spot of the map).
let disc = |q: &&FoamBubble| {
let (x, y, r, rot) = place(q);
let fade = ((foam_pop_age(q.id, lifespan, frailty, seed) - q.age) / 5.0).clamp(0.0, 1.0);
FoamDisc { x, y, r, rot, fade }
};
let user = user_tex.map(|tex| (tex, order.iter().map(disc).collect()));
let strength = pr.f("rendering/reflectionStrength").clamp(0.0, 1.0) as f32;
if view == 2
let env = if view == 2
&& strength > 0.0
&& let Some(env) = ctx.layer_param("rendering/environmentMap", 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 {
let (bx, by, r, _) = place(q);
foam_disc(&mut out, bx, by, r as f64, |u, v| {
// Sphere normal; the reflection looks across the whole map, or (convergence 1)
// just behind the bubble.
let (ex, ey) =
if conv >= 1.0 { (bx, by) } else { (bx * conv + (u * 0.5 + 0.5) * w * (1.0 - conv), by * conv + (v * 0.5 + 0.5) * h * (1.0 - conv)) };
let e = env_img.sample_bilinear(ex, ey);
let rim = ((u * u + v * v) as f32).min(1.0);
let k = strength * (0.3 + 0.7 * rim);
[e[0] * k, e[1] * k, e[2] * k, 0.0]
});
}
}
// Draft + Flow Map shows the flow map under the bubbles.
if view == 1
Some(FoamEnv {
img: crate::util::fit_layer(ctx, b, &env, true),
conv: pr.f("rendering/reflectionConvergence").clamp(0.0, 1.0),
strength,
discs: order.iter().map(disc).collect(),
})
} else {
None
};
let flow = if view == 1
&& let Some(lp) = &flow
{
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 {
o[c] += f[c] * 0.5 * k;
}
});
}
Ok(out)
Some(crate::util::fit_layer(ctx, b, lp, true))
} else {
None
};
FoamPlan { sprites: crate::sim::SpritePlan { sprites, tints: vec![], acc, post: crate::sim::Post::Replace }, user, env, flow }
}
/// Add `f(u, v)` (premultiplied; u, v ∈ −1..1 across the disc) over the disc of radius `r` at

View File

@ -716,7 +716,12 @@ pub fn playground_state(ctx: &EffectCtx) -> Option<Vec<crate::psim::SimParticle>
)
}
fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf {
fn particle_playground(ctx: &EffectCtx, b: Buf) -> Buf {
playground_plan(ctx, &b).finish(b)
}
/// Particle Playground's [`PgPlan`] for buffer geometry `b` (its pixels are not read).
pub fn playground_plan(ctx: &EffectCtx, b: &Buf) -> PgPlan {
let pr = ctx.params;
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let pg = pg_of(ctx);
@ -825,7 +830,6 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf {
let offset_type = pr.e("layerMap/timeOffsetType");
let offset = pr.f("layerMap/timeOffset");
let fps = ctx.fps();
let mut frames: std::collections::HashMap<i64, Option<crate::LayerPixels>> = std::collections::HashMap::new();
let mut sprites: Vec<Sprite> = vec![];
let mut blits: Vec<(PgParticle, i64)> = vec![];
for q in &parts {
@ -869,30 +873,64 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf {
None => sprites.push(Sprite::new(x as f32, y as f32, q.r * sc * s, q.c, Shape::Disc)),
}
}
let mut fx = splat(b.img.width, b.img.height, &sprites, Acc::Over);
let mut plan = PgPlan { sprites: SpritePlan { sprites, tints: vec![], acc: Acc::Over, post: Post::Combine(5) }, blits: vec![], frames: vec![] };
if let (Some(id), Some(host)) = (map_id, ctx.env.host) {
let me = pr.get(&crate::layer_source_id("layerMap/layerMapLayer")).is_none_or(|v| v.as_enum() != 0);
// Fetched frames by frame number: their index in `plan.frames` (`None` = unavailable).
let mut frames: std::collections::HashMap<i64, Option<usize>> = std::collections::HashMap::new();
for (q, frame) in &blits {
if !frames.contains_key(frame) && frames.len() >= 32 {
let at = if !frames.contains_key(frame) && frames.len() >= 32 {
// Too many distinct times: reuse the nearest fetched frame.
if let Some(k) = frames.keys().min_by_key(|k| (*k - frame).abs()).copied()
&& let Some(Some(lp)) = frames.get(&k)
{
let (x, y) = b.to_px([q.p[0] as f64, q.p[1] as f64]);
blit_layer(&mut fx, lp, x, y, q.angle as f64, [q.scale[0] as f64, q.scale[1] as f64], b.scale, q.c[3]);
match frames.keys().min_by_key(|k| (*k - frame).abs()).copied() {
Some(k) => frames.get(&k).copied().flatten(),
None => None,
}
continue;
}
let lp = frames.entry(*frame).or_insert_with(|| host.layer_at(id, *frame as f64 / fps, me));
if let Some(lp) = lp {
} else {
*frames.entry(*frame).or_insert_with(|| {
let lp = host.layer_at(id, *frame as f64 / fps, me)?;
plan.frames.push(lp);
Some(plan.frames.len() - 1)
})
};
if let Some(i) = at {
let (x, y) = b.to_px([q.p[0] as f64, q.p[1] as f64]);
blit_layer(&mut fx, lp, x, y, q.angle as f64, [q.scale[0] as f64, q.scale[1] as f64], b.scale, q.c[3]);
plan.blits.push(PgBlit { frame: i, x, y, angle: q.angle as f64, scale: [q.scale[0] as f64, q.scale[1] as f64], alpha: q.c[3] });
}
}
}
// Particles replace the layer's own pixels.
b.img = combine(&b.img, &fx, 5);
b
plan
}
/// A Layer Map particle: frame `frame` of [`PgPlan::frames`] centred on (`x`, `y`) buffer px,
/// rotated `angle` degrees, scaled and faded to `alpha`.
#[derive(Clone, Copy, Debug)]
pub struct PgBlit {
pub frame: usize,
pub x: f64,
pub y: f64,
pub angle: f64,
pub scale: [f64; 2],
pub alpha: f32,
}
/// What Particle Playground draws this frame: the dot and text particles as sprites, then the
/// Layer Map particles over them in order; the result replaces the layer's own pixels.
pub struct PgPlan {
pub sprites: SpritePlan,
pub blits: Vec<PgBlit>,
pub frames: Vec<crate::LayerPixels>,
}
impl PgPlan {
pub fn finish(&self, mut b: Buf) -> Buf {
let mut fx = splat(b.img.width, b.img.height, &self.sprites.sprites, self.sprites.acc);
for q in &self.blits {
blit_layer(&mut fx, &self.frames[q.frame], q.x, q.y, q.angle, q.scale, b.scale, q.alpha);
}
// Particles replace the layer's own pixels.
b.img = combine(&b.img, &fx, 5);
b
}
}
// ---------------------------------------------------------------- CC Hair

View File

@ -680,18 +680,22 @@ fn fetch_layer(ctx: &EffectCtx, id: &str, times: &[f64], grid: &Buf) -> Option<V
)
}
/// One output instant of a Timewarp without a Matte Layer: a fetched frame, a cross-fade of two,
/// or the in-between frame built along a motion field ([`TimewarpPlan::flows`]).
/// One output instant of a Timewarp: a frame, a cross-fade of two, or the in-between frame
/// built along a motion field ([`TimewarpPlan::flows`]); with a Matte Layer's Pixel Motion, the
/// foreground and background pairs (`[frame a, frame b, flow]`) each built on their own vectors
/// and shown as Show asks.
#[derive(Clone, Copy, Debug)]
pub enum TwStep {
Whole(usize),
Mix(usize, usize, f32),
Motion { a: usize, b: usize, w: f32, flow: usize },
Layered { fg: [usize; 3], bg: [usize; 3], w: f32, show: u32 },
}
/// Timewarp without a Matte Layer, prepared for the GPU compositor (effectcraft-gpu `fx_time`):
/// the fetched frames on one grid, the instants across the shutter (with their weights) and the
/// motion fields (block matching on the CPU). [`TimewarpPlan::render_cpu`] is the CPU effect.
/// Timewarp prepared for the GPU compositor (effectcraft-gpu `fx_time`): the fetched frames on
/// one grid (plus the matte-masked ones a Matte Layer needs), the instants across the shutter
/// (with their weights) and the motion fields (block matching on the CPU).
/// [`TimewarpPlan::render_cpu`] is the CPU effect.
pub struct TimewarpPlan {
pub grid: Buf,
pub frames: Vec<Image>,
@ -710,6 +714,11 @@ impl TimewarpPlan {
TwStep::Whole(i) => f[i].clone(),
TwStep::Mix(i, j, w) => effectcraft_raster::flow::mix(&f[i], &f[j], w),
TwStep::Motion { a, b, w, flow } => timewarp_interpolate(&f[a], &f[b], w, &self.flows[flow], &self.pm),
TwStep::Layered { fg, bg, w, show } => {
let fgi = timewarp_interpolate(&f[fg[0]], &f[fg[1]], w, &self.flows[fg[2]], &self.pm);
let bgi = timewarp_interpolate(&f[bg[0]], &f[bg[1]], w, &self.flows[bg[2]], &self.pm);
layered(fgi, bgi, show)
}
})
.collect();
let weights: Vec<f32> = self.steps.iter().map(|(_, w)| *w).collect();
@ -758,48 +767,102 @@ fn tw_split(s: f64, fps: f64) -> (f64, f32) {
(f0, ((f - f0) as f32).max(0.0))
}
/// [`TimewarpPlan`] of a Timewarp instance without a Matte Layer. `None` without the effect host
/// (the layer passes through) or with a Matte Layer.
/// [`TimewarpPlan`] of a Timewarp instance. `None` without the effect host or source frames (the
/// layer passes through). With a Matte Layer the frames the instants need (masked by the matte,
/// or the matte itself for Show Matte) are prepared here and join [`TimewarpPlan::frames`].
pub fn timewarp_plan(ctx: &EffectCtx) -> Option<TimewarpPlan> {
ctx.env.host?;
if ctx.params.get("matteLayer").and_then(Value::as_layer).is_some() {
return None;
}
// Instants of source time (with weights) across the shutter; each needs the frame at or
// before it and the next one.
let (instants, times, fps) = timewarp_instants(ctx);
let method = ctx.params.e("method");
let one = method > 0 && ctx.params.b("tuning/buildFromOneImage");
let fr = fetch(ctx, &times)?;
let pm = PixelMotion::from(ctx, fr.grid.scale);
let mattes = fetch_layer(ctx, "matteLayer", &times, &fr.grid);
let warps = fetch_layer(ctx, "warpLayer", &times, &fr.grid);
let matte_ch = ctx.params.e("matteChannel");
let show = ctx.params.e("show");
let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0);
let mut flows: Vec<(i64, Flow)> = vec![];
let mut frames = fr.imgs;
let add = |frames: &mut Vec<Image>, img: Image| {
frames.push(img);
frames.len() - 1
};
// Motion fields: per source frame pair (keyed), or per masked pair (unkeyed).
let mut flows: Vec<(Option<i64>, Flow)> = vec![];
let mut steps = vec![];
for &(s, wgt) in &instants {
let (f0, frac) = tw_split(s, fps);
let (i0, i1) = (idx(f0 / fps), idx((f0 + 1.0) / fps));
let ma = mattes.as_ref().map(|m| matte_of(&m[i0], matte_ch));
let mb = mattes.as_ref().map(|m| matte_of(&m[i1], matte_ch));
let step = if method == 0 || frac < 1e-4 || (one && method == 1) {
// Whole frames (Build From One Image with Frame Mix: the nearest frame).
let pick = if method == 0 || frac < 1e-4 { 0.0 } else { frac.round() };
TwStep::Whole(if pick < 0.5 { i0 } else { i1 })
let i = if pick < 0.5 { i0 } else { i1 };
let base = &frames[i];
let derived = match (show, &ma, &mb) {
(1, Some(m), _) if pick < 0.5 => Some(times_mask(base, m, false)),
(2, Some(m), _) if pick < 0.5 => Some(times_mask(base, m, true)),
(1, _, Some(m)) => Some(times_mask(base, m, false)),
(2, _, Some(m)) => Some(times_mask(base, m, true)),
(3, Some(m), _) => Some(matte_image(if pick < 0.5 { m } else { mb.as_ref().unwrap_or(m) }, base)),
_ => None,
};
TwStep::Whole(match derived {
Some(img) => add(&mut frames, img),
None => i,
})
} else if method == 1 {
TwStep::Mix(i0, i1, frac)
match (&ma, &mb, show) {
(Some(m0), Some(m1), 3) => {
let (a, b) = (matte_image(m0, &frames[i0]), matte_image(m1, &frames[i1]));
TwStep::Mix(add(&mut frames, a), add(&mut frames, b), frac)
}
(Some(m0), Some(m1), 1 | 2) => {
let (a, b) = (times_mask(&frames[i0], m0, show == 2), times_mask(&frames[i1], m1, show == 2));
TwStep::Mix(add(&mut frames, a), add(&mut frames, b), frac)
}
_ => TwStep::Mix(i0, i1, frac),
}
} else {
// Pixel Motion: vectors from the warp layer if any, else from the layer.
let key = f0 as i64;
let flow = match flows.iter().position(|(k, _)| *k == key) {
let flow = match flows.iter().position(|(k, _)| *k == Some(key)) {
Some(k) => k,
None => {
let f = match &warps {
Some(w) => timewarp_flow(&w[i0], &w[i1], &pm),
None => timewarp_flow(&fr.imgs[i0], &fr.imgs[i1], &pm),
None => timewarp_flow(&frames[i0], &frames[i1], &pm),
};
flows.push((key, f));
flows.push((Some(key), f));
flows.len() - 1
}
};
TwStep::Motion { a: i0, b: i1, w: frac, flow }
match (&ma, &mb) {
(Some(m0), Some(m1)) => {
// Foreground and background each move on their own vectors.
let (fa, fb) = (times_mask(&frames[i0], m0, false), times_mask(&frames[i1], m1, false));
let (ga, gb) = (times_mask(&frames[i0], m0, true), times_mask(&frames[i1], m1, true));
let mut own = |a: &Image, b: &Image| {
if warps.is_some() {
return flow;
}
flows.push((None, timewarp_flow(a, b, &pm)));
flows.len() - 1
};
let (fg_flow, bg_flow) = (own(&fa, &fb), own(&ga, &gb));
let fg = [add(&mut frames, fa), add(&mut frames, fb), fg_flow];
let bg = [add(&mut frames, ga), add(&mut frames, gb), bg_flow];
TwStep::Layered { fg, bg, w: frac, show }
}
_ => TwStep::Motion { a: i0, b: i1, w: frac, flow },
}
};
steps.push((step, wgt));
}
Some(TimewarpPlan { grid: fr.grid, frames: fr.imgs, steps, flows: flows.into_iter().map(|(_, f)| f).collect(), pm })
Some(TimewarpPlan { grid: fr.grid, frames, steps, flows: flows.into_iter().map(|(_, f)| f).collect(), pm })
}
/// Timewarp. Whole Frames shows the nearest source frame, Frame Mix cross-fades the two
@ -809,108 +872,30 @@ pub fn timewarp_plan(ctx: &EffectCtx) -> Option<TimewarpPlan> {
/// Warp Layer supplies the motion instead of the layer itself. Motion Blur averages Shutter
/// Samples instants across the shutter.
fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf {
if ctx.env.host.is_none() {
return b;
match timewarp_plan(ctx) {
Some(plan) => plan.render_cpu(),
None => b,
}
if ctx.params.get("matteLayer").and_then(Value::as_layer).is_none() {
return match timewarp_plan(ctx) {
Some(plan) => plan.render_cpu(),
None => b,
};
}
// Instants of source time (with weights) across the shutter; each needs the frame at or
// before it and the next one.
let (instants, times, fps) = timewarp_instants(ctx);
let split = |s: f64| tw_split(s, fps);
let method = ctx.params.e("method");
let one = method > 0 && ctx.params.b("tuning/buildFromOneImage");
let Some(fr) = fetch(ctx, &times) else { return b };
let grid = fr.grid.clone();
let frame = |t: f64| -> &Image {
let k = times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0);
&fr.imgs[k]
};
let pm = PixelMotion::from(ctx, grid.scale);
let mattes = fetch_layer(ctx, "matteLayer", &times, &grid);
let warps = fetch_layer(ctx, "warpLayer", &times, &grid);
let matte_ch = ctx.params.e("matteChannel");
let show = ctx.params.e("show");
let mut flows: Vec<((i64, bool), Flow)> = vec![];
let mut out_frames = vec![];
for &(s, wgt) in &instants {
let (f0, frac) = split(s);
let (t0, t1) = (f0 / fps, (f0 + 1.0) / fps);
let (a, bb) = (frame(t0), frame(t1));
let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0);
let ma = mattes.as_ref().map(|m| matte_of(&m[idx(t0)], matte_ch));
let mb = mattes.as_ref().map(|m| matte_of(&m[idx(t1)], matte_ch));
let img = if method == 0 || frac < 1e-4 || (one && method == 1) {
// Whole frames (Build From One Image with Frame Mix: the nearest frame).
let pick = if method == 0 || frac < 1e-4 { 0.0 } else { frac.round() };
let base = if pick < 0.5 { a } else { bb };
match (show, &ma, &mb) {
(1, Some(m), _) if pick < 0.5 => times_mask(base, m, false),
(2, Some(m), _) if pick < 0.5 => times_mask(base, m, true),
(1, _, Some(m)) => times_mask(base, m, false),
(2, _, Some(m)) => times_mask(base, m, true),
(3, Some(m), _) => matte_image(if pick < 0.5 { m } else { mb.as_ref().unwrap_or(m) }, base),
_ => base.clone(),
}
} else if method == 1 {
match (&ma, &mb, show) {
(Some(m0), Some(m1), 3) => effectcraft_raster::flow::mix(&matte_image(m0, a), &matte_image(m1, bb), frac),
(Some(m0), Some(m1), 1 | 2) => effectcraft_raster::flow::mix(&times_mask(a, m0, show == 2), &times_mask(bb, m1, show == 2), frac),
_ => effectcraft_raster::flow::mix(a, bb, frac),
}
} else {
// Pixel Motion: vectors from the warp layer if any, else from the layer.
let key = ((f0 as i64), false);
let flow_ab = |fa: &Image, fb: &Image| -> Flow { timewarp_flow(fa, fb, &pm) };
let flow = match flows.iter().find(|(k, _)| *k == key) {
Some((_, f)) => f.clone(),
None => {
let f = match &warps {
Some(w) => flow_ab(&w[idx(t0)], &w[idx(t1)]),
None => flow_ab(a, bb),
};
flows.push((key, f.clone()));
f
}
/// Pixel Motion with a Matte Layer: the foreground and background frames built on their own
/// vectors, shown as Show asks (0 Normal: foreground over background).
fn layered(fg: Image, bg: Image, show: u32) -> Image {
match show {
1 => fg,
2 => bg,
3 => matte_image(&fg.data.iter().map(|p| p[3]).collect::<Vec<_>>(), &fg),
_ => {
let mut o = bg;
o.data.par_iter_mut().zip(fg.data.par_iter()).for_each(|(p, q)| {
let k = 1.0 - q[3];
for c in 0..4 {
p[c] = q[c] + p[c] * k;
}
};
match (&ma, &mb) {
(Some(m0), Some(m1)) => {
// Foreground and background each move on their own vectors.
let (fa, fb) = (times_mask(a, m0, false), times_mask(bb, m1, false));
let (ga, gb) = (times_mask(a, m0, true), times_mask(bb, m1, true));
let fg_flow = if warps.is_some() { flow.clone() } else { flow_ab(&fa, &fb) };
let bg_flow = if warps.is_some() { flow.clone() } else { flow_ab(&ga, &gb) };
let fg = timewarp_interpolate(&fa, &fb, frac, &fg_flow, &pm);
let bg = timewarp_interpolate(&ga, &gb, frac, &bg_flow, &pm);
match show {
1 => fg,
2 => bg,
3 => matte_image(&fg.data.iter().map(|p| p[3]).collect::<Vec<_>>(), &fg),
_ => {
let mut o = bg;
o.data.par_iter_mut().zip(fg.data.par_iter()).for_each(|(p, q)| {
let k = 1.0 - q[3];
for c in 0..4 {
p[c] = q[c] + p[c] * k;
}
});
o
}
}
}
_ => timewarp_interpolate(a, bb, frac, &flow, &pm),
}
};
out_frames.push((img, wgt));
});
o
}
}
let imgs: Vec<Image> = out_frames.iter().map(|(i, _)| i.clone()).collect();
let weights: Vec<f32> = out_frames.iter().map(|(_, w)| *w).collect();
let img = if imgs.len() == 1 { imgs.into_iter().next().unwrap_or_default() } else { average(&imgs, &weights) };
with_img(grid, img)
}
/// A matte shown as opaque grey.

View File

@ -0,0 +1,661 @@
//! Golden hashes of the CPU particle effects (M13.29).
//!
//! The sibling of `sim_golden.rs` for the particle systems and the per-pixel "particle" effects
//! that the GPU compositor draws from a shared per-frame plan: CC Particle World, CC Particle
//! Systems II, Particle Playground (cannon, grid, text, layer exploder, layer map, property
//! mappers, repel), CC Ball Action, CC Pixel Polly, CC Scatterize and Curl Noise. Same set-up,
//! hashing and re-pinning (`SIM_GOLDEN_PRINT=1`, `SIM_GOLDEN_OUT=<dir>`) as `sim_golden.rs`: any
//! change to the CPU output of these effects, however small, fails the test.
use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Params, apply, default_value, find};
use effectcraft_keyframe::Value;
use effectcraft_raster::Image;
const W: u32 = 64;
const H: u32 = 48;
/// Layer ids served by [`Host`]: a texture and a luminance map.
const TEX: u64 = 7;
const MAP: u64 = 8;
/// The effect's own layer: a colour ramp with a clear hole and a half-transparent band
/// (`hdr` makes part of it overbright).
fn layer(hdr: bool) -> Image {
let mut img = Image::new(W, H);
for y in 0..H {
for x in 0..W {
let (u, v) = (x as f32 / W as f32, y as f32 / H as f32);
let (dx, dy) = (x as f32 - 40.0, y as f32 - 20.0);
let a = if dx * dx + dy * dy < 64.0 {
0.0
} else if (30..36).contains(&y) {
0.45
} else {
1.0
};
let gain = if hdr && x < 20 { 1.6 } else { 1.0 };
let c = [u * gain, v * gain, (0.3 + 0.4 * (u * 9.0).sin().abs()) * gain];
img.set(x, y, [c[0] * a, c[1] * a, c[2] * a, a]);
}
}
img
}
/// Another layer: a checker texture (`TEX`) or a radial luminance map (`MAP`).
fn other(id: u64) -> Image {
let mut img = Image::new(W, H);
for y in 0..H {
for x in 0..W {
let p = if id == TEX {
let k = ((x / 8 + y / 8) % 2) as f32;
[0.9 * k + 0.1, 0.3 + 0.5 * (1.0 - k), 0.6, 1.0]
} else {
let d = ((x as f32 - 32.0).powi(2) + (y as f32 - 24.0).powi(2)).sqrt() / 40.0;
let l = (1.0 - d).clamp(0.0, 1.0);
[l, l, l, 1.0]
};
img.set(x, y, p);
}
}
img
}
struct Host;
impl EffectHost for Host {
fn layer(&self, id: u64, _: bool) -> Option<LayerPixels> {
let img = other(id);
Some(LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [W as f64, H as f64] })
}
fn layer_at(&self, id: u64, comp_time: f64, _: bool) -> Option<LayerPixels> {
// A frame that changes with time (Particle Playground's Layer Map times).
let mut img = other(id);
let k = ((comp_time * 30.0).round() as i64).rem_euclid(5) as f32 * 0.15;
for p in img.data.iter_mut() {
p[0] = (p[0] + k).min(1.0) * p[3];
}
Some(LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [W as f64, H as f64] })
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
}
fn n(v: f64) -> Value {
Value::Scalar(v)
}
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn l(id: u64) -> Value {
Value::Layer(Some(id))
}
type Set = Vec<(&'static str, Value)>;
fn b(v: bool) -> Value {
Value::Bool(v)
}
fn v2(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
fn s(v: &str) -> Value {
Value::Str(v.into())
}
/// A Particle Playground grid (cannon off) plus `more`.
fn grid(more: Set) -> Set {
let mut g: Set = vec![
("cannonEnabled", b(false)),
("gridEnabled", b(true)),
("grid/gridPosition", v2(32.0, 20.0)),
("grid/gridWidth", n(40.0)),
("grid/gridHeight", n(20.0)),
("grid/particlesAcross", n(5.0)),
("grid/particlesDown", n(3.0)),
("grid/gridParticleRadius", n(3.0)),
("gravity/gravityForce", n(20.0)),
];
g.extend(more);
g
}
/// (effect id, parameter sets) — the first set of each is the defaults.
fn cases() -> Vec<(&'static str, Vec<Set>)> {
vec![
(
"ec.sim.ccparticleworld",
vec![
vec![],
vec![("birthRate", n(2.0)), ("particle/particleType", e(1)), ("particle/transferMode", e(2)), ("physics/animation", e(1))],
vec![("particle/particleType", e(4)), ("physics/animation", e(2)), ("particle/transferMode", e(1)), ("producer/radiusZ", n(0.3))],
vec![("particle/particleType", e(8)), ("physics/animation", e(5)), ("particle/transferMode", e(3)), ("particle/birthSize", n(0.4))],
vec![("particle/particleType", e(5)), ("physics/animation", e(8)), ("particle/sizeVariation", n(50.0)), ("physics/resistance", n(1.0))],
vec![("particle/particleType", e(3)), ("physics/animation", e(6)), ("particle/maxOpacity", n(100.0))],
vec![("particle/particleType", e(6)), ("physics/animation", e(4)), ("particle/deathSize", n(0.6))],
vec![("particle/particleType", e(7)), ("physics/animation", e(3))],
vec![("particle/particleType", e(2)), ("physics/animation", e(9))],
],
),
(
"ec.sim.ccparticlesystems2",
vec![
vec![],
vec![("particle/particleType", e(1)), ("particle/transferMode", e(2)), ("physics/animation", e(2)), ("birthRate", n(4.0))],
vec![("particle/particleType", e(5)), ("physics/animation", e(6)), ("particle/transferMode", e(1))],
vec![("particle/particleType", e(7)), ("particle/transferMode", e(3)), ("physics/animation", e(9)), ("producer/radiusX", n(10.0))],
vec![("particle/particleType", e(3)), ("physics/animation", e(4)), ("particle/sizeVariation", n(40.0))],
vec![("particle/particleType", e(4)), ("physics/animation", e(5)), ("physics/direction", n(60.0))],
],
),
(
"ec.sim.particleplayground",
vec![
vec![],
vec![
("cannon/cannonPosition", v2(20.0, 40.0)),
("cannon/particlesPerSecond", n(120.0)),
("cannon/velocity", n(80.0)),
("gravity/gravityForce", n(40.0)),
],
grid(vec![]),
grid(vec![("options/gridText", s("Ab C")), ("grid/gridParticleRadius", n(12.0)), ("options/autoOrientRotation", b(true))]),
vec![
("cannonEnabled", b(false)),
("exploderEnabled", b(true)),
("layerExploder/explodeLayer", l(TEX)),
("layerExploder/radiusOfNewParticles", n(3.0)),
],
grid(vec![
("layerMapEnabled", b(true)),
("layerMap/layerMapLayer", l(TEX)),
("layerMap/timeOffsetType", e(2)),
("layerMap/timeOffset", n(0.5)),
]),
grid(vec![
("mapperEnabled", b(true)),
("persistentPropertyMapper/useLayerAsMap", l(MAP)),
("persistentPropertyMapper/mapRedTo", e(8)),
("persistentPropertyMapper/redMin", n(-50.0)),
("persistentPropertyMapper/redMax", n(50.0)),
]),
grid(vec![
("ephemeralPropertyMapper/useLayerAsMap", l(MAP)),
("ephemeralPropertyMapper/mapRedTo", e(1)),
("ephemeralPropertyMapper/mapGreenTo", e(5)),
("ephemeralPropertyMapper/greenOperator", e(1)),
("ephemeralPropertyMapper/greenMax", n(4.0)),
]),
grid(vec![("repel/repelForce", n(5.0)), ("repel/repelForceRadius", n(12.0)), ("options/cannonText", s("xy")), ("cannonEnabled", b(true))]),
],
),
(
"ec.sim.ccballaction",
vec![
vec![],
vec![("scatter", n(20.0)), ("rotationAxis", e(6)), ("rotation", n(40.0)), ("twistProperty", e(3)), ("twistAngle", n(90.0))],
vec![("gridSpacing", n(2.0)), ("ballSize", n(80.0)), ("instabilityState", n(45.0)), ("twistProperty", e(2)), ("twistAngle", n(30.0))],
],
),
(
"ec.sim.ccpixelpolly",
vec![
vec![],
vec![("object", e(1)), ("gridSpacing", n(6.0))],
vec![("object", e(2)), ("spinning", n(90.0))],
vec![("object", e(3)), ("enableDepthSort", b(false)), ("gravity", n(2.0)), ("startTime", n(0.3))],
],
),
(
"ec.sim.ccscatterize",
vec![vec![], vec![("scatter", n(10.0))], vec![("rightTwist", n(60.0)), ("leftTwist", n(-30.0)), ("transferMode", e(1)), ("scatter", n(4.0))]],
),
(
"ec.noise.curlnoise",
vec![
vec![],
vec![("view", e(1))],
vec![
("edgeBehavior", e(1)),
("steps", n(8.0)),
("displacementAmount", n(60.0)),
("scale", n(20.0)),
("rotation", n(30.0)),
("evolution", n(90.0)),
],
],
),
]
}
/// Layer times, in order (the third seeks backwards).
const TIMES: [f64; 4] = [0.5, 1.5, 0.8, 2.2];
fn fnv(img: &Image) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325u64;
for b in [img.width, img.height].iter().flat_map(|v| v.to_le_bytes()) {
h = (h ^ b as u64).wrapping_mul(0x100_0000_01b3);
}
for p in &img.data {
for c in p {
for b in c.to_bits().to_le_bytes() {
h = (h ^ b as u64).wrapping_mul(0x100_0000_01b3);
}
}
}
h
}
fn render(id: &str, set: &Set, time: f64, bpc8: bool, half: bool) -> Image {
let spec = find(id).unwrap_or_else(|| panic!("no effect {id}"));
let size = [W as f64, H as f64];
let mut params = Params { values: spec.params.iter().map(|p| (p.id.to_string(), default_value(p, size))).collect() };
for (k, v) in set {
assert!(params.values.contains_key(*k), "{id}: unknown param {k}");
params.values.insert(k.to_string(), v.clone());
}
let mut img = layer(!bpc8);
if bpc8 {
img.clamp01();
img.quantize(255.0);
}
let mut buf = Buf { img, offset: [0.0; 2], scale: 1.0 };
if half {
// A half-resolution preview buffer padded by 3 px.
let (w2, h2) = (W / 2, H / 2);
let mut small = Image::new(w2, h2);
for y in 0..h2 {
for x in 0..w2 {
small.set(x, y, buf.img.sample_bilinear(x as f64 * 2.0 + 1.0, y as f64 * 2.0 + 1.0));
}
}
buf = Buf { img: small, offset: [0.0; 2], scale: 0.5 };
buf.pad(3);
}
let host = Host;
let env = EffectEnv { host: Some(&host), frame_rate: 30.0, comp_time: time, ..Default::default() };
let ctx = EffectCtx { params: &params, time, layer_size: size, seed: 1, adjustment: false, env };
let mut out = apply(spec, &ctx, buf).img;
if bpc8 {
out.clamp01();
out.quantize(255.0);
}
out
}
/// Every case's name and hash, in a fixed order.
fn hashes() -> Vec<(String, u64)> {
let out_dir = std::env::var_os("SIM_GOLDEN_OUT").map(std::path::PathBuf::from);
if let Some(d) = &out_dir {
std::fs::create_dir_all(d).unwrap();
}
let mut v = Vec::new();
for (id, sets) in cases() {
for (si, set) in sets.iter().enumerate() {
for (bpc8, half) in [(true, false), (false, false), (true, true)] {
if half && si > 0 {
continue;
}
for t in TIMES {
let img = render(id, set, t, bpc8, half);
let name = format!("{id}/{si}/{}{}/t{t}", if bpc8 { "8bpc" } else { "32bpc" }, if half { "/half" } else { "" });
if let Some(d) = &out_dir {
let bytes: Vec<u8> = img.data.iter().flat_map(|p| p.iter().flat_map(|c| c.to_le_bytes())).collect();
let file = format!("{}_{}x{}.f32", name.replace('/', "_"), img.width, img.height);
std::fs::write(d.join(file), bytes).unwrap();
}
v.push((name, fnv(&img)));
}
}
}
}
v
}
#[test]
fn particle_effects_golden_hashes() {
let got = hashes();
if std::env::var_os("SIM_GOLDEN_PRINT").is_some() {
for (name, h) in &got {
println!(" (\"{name}\", 0x{h:016x}),");
}
}
// Deterministic: a second pass (warm simulation caches) renders the same frames.
assert_eq!(got, hashes(), "particle effects render deterministically");
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
let bad: Vec<String> = got
.iter()
.zip(GOLDEN)
.filter(|((n, h), (gn, gh))| n != gn || h != gh)
.map(|((n, h), (_, gh))| format!("{n}: 0x{h:016x} (pinned 0x{gh:016x})"))
.collect();
assert_eq!(got.len(), GOLDEN.len(), "case count");
assert!(bad.is_empty(), "CPU output changed:\n{}", bad.join("\n"));
}
}
/// Pinned hashes (aarch64 macOS).
const GOLDEN: &[(&str, u64)] = &[
("ec.sim.ccparticleworld/0/8bpc/t0.5", 0x263d8f88e7ae651b),
("ec.sim.ccparticleworld/0/8bpc/t1.5", 0xb7697683e3cc6d43),
("ec.sim.ccparticleworld/0/8bpc/t0.8", 0xb5065326c8bd674a),
("ec.sim.ccparticleworld/0/8bpc/t2.2", 0x7f8eb5f2e8d38506),
("ec.sim.ccparticleworld/0/32bpc/t0.5", 0x6141abad89cd53ca),
("ec.sim.ccparticleworld/0/32bpc/t1.5", 0x2b5682e32bb3e916),
("ec.sim.ccparticleworld/0/32bpc/t0.8", 0xe6aaed9716d15c42),
("ec.sim.ccparticleworld/0/32bpc/t2.2", 0xa6e9a6f2acad94c0),
("ec.sim.ccparticleworld/0/8bpc/half/t0.5", 0xbb0b6803512a6b99),
("ec.sim.ccparticleworld/0/8bpc/half/t1.5", 0x70c578ea73992099),
("ec.sim.ccparticleworld/0/8bpc/half/t0.8", 0x2d26cb3f73a26b4f),
("ec.sim.ccparticleworld/0/8bpc/half/t2.2", 0x2b474438a48ee0e4),
("ec.sim.ccparticleworld/1/8bpc/t0.5", 0x294502dc827b0326),
("ec.sim.ccparticleworld/1/8bpc/t1.5", 0xf7874709f4012aaa),
("ec.sim.ccparticleworld/1/8bpc/t0.8", 0x87664ce9a05e67f9),
("ec.sim.ccparticleworld/1/8bpc/t2.2", 0x46e1b5162a0284f4),
("ec.sim.ccparticleworld/1/32bpc/t0.5", 0x02d8499e93133cb9),
("ec.sim.ccparticleworld/1/32bpc/t1.5", 0x97218172dec765c6),
("ec.sim.ccparticleworld/1/32bpc/t0.8", 0xee7da06e0b16e3b7),
("ec.sim.ccparticleworld/1/32bpc/t2.2", 0x6b9da0aa7d2a6abb),
("ec.sim.ccparticleworld/2/8bpc/t0.5", 0x71468ce82bba3cb2),
("ec.sim.ccparticleworld/2/8bpc/t1.5", 0x48595f55e8741a7e),
("ec.sim.ccparticleworld/2/8bpc/t0.8", 0x5ebc35e9eb02c8d7),
("ec.sim.ccparticleworld/2/8bpc/t2.2", 0xf9a0f63c72bd35f0),
("ec.sim.ccparticleworld/2/32bpc/t0.5", 0x757f80d45a2159b1),
("ec.sim.ccparticleworld/2/32bpc/t1.5", 0x7924f1cb9c8bf8c7),
("ec.sim.ccparticleworld/2/32bpc/t0.8", 0x22f507420a062c87),
("ec.sim.ccparticleworld/2/32bpc/t2.2", 0x41171121719a307a),
("ec.sim.ccparticleworld/3/8bpc/t0.5", 0xbca7e3d574cf5e37),
("ec.sim.ccparticleworld/3/8bpc/t1.5", 0x5c4347a6c70b48ec),
("ec.sim.ccparticleworld/3/8bpc/t0.8", 0xc79131dbaa8ff129),
("ec.sim.ccparticleworld/3/8bpc/t2.2", 0xc08d0c5e8f87189d),
("ec.sim.ccparticleworld/3/32bpc/t0.5", 0x36635fe45324f86d),
("ec.sim.ccparticleworld/3/32bpc/t1.5", 0xf9751b9225a373a4),
("ec.sim.ccparticleworld/3/32bpc/t0.8", 0xb6026343f72dfaeb),
("ec.sim.ccparticleworld/3/32bpc/t2.2", 0x2f0e149de9623753),
("ec.sim.ccparticleworld/4/8bpc/t0.5", 0xd53dad7a75976197),
("ec.sim.ccparticleworld/4/8bpc/t1.5", 0x2cd0f3cbed038289),
("ec.sim.ccparticleworld/4/8bpc/t0.8", 0x46c089373ed50088),
("ec.sim.ccparticleworld/4/8bpc/t2.2", 0xfb17c5c3ba44657c),
("ec.sim.ccparticleworld/4/32bpc/t0.5", 0x80b338b7bb9794b1),
("ec.sim.ccparticleworld/4/32bpc/t1.5", 0x86811f65519e9790),
("ec.sim.ccparticleworld/4/32bpc/t0.8", 0x6aad5efc09bc64ff),
("ec.sim.ccparticleworld/4/32bpc/t2.2", 0x9957090d6fc97293),
("ec.sim.ccparticleworld/5/8bpc/t0.5", 0x56f989c5d7fc1eeb),
("ec.sim.ccparticleworld/5/8bpc/t1.5", 0x128313333a936e16),
("ec.sim.ccparticleworld/5/8bpc/t0.8", 0xa0efb8bdc0157788),
("ec.sim.ccparticleworld/5/8bpc/t2.2", 0x4af31455e2fe2631),
("ec.sim.ccparticleworld/5/32bpc/t0.5", 0xf3057d8cf6540f4a),
("ec.sim.ccparticleworld/5/32bpc/t1.5", 0x28203947d78e5ee3),
("ec.sim.ccparticleworld/5/32bpc/t0.8", 0xa6d8a9a848443344),
("ec.sim.ccparticleworld/5/32bpc/t2.2", 0xade682a437a4cba1),
("ec.sim.ccparticleworld/6/8bpc/t0.5", 0xa65b30a36baee78a),
("ec.sim.ccparticleworld/6/8bpc/t1.5", 0xbf7e4675fec18c48),
("ec.sim.ccparticleworld/6/8bpc/t0.8", 0xf98c7d9af9d6c9e2),
("ec.sim.ccparticleworld/6/8bpc/t2.2", 0xdf090357b8e4f471),
("ec.sim.ccparticleworld/6/32bpc/t0.5", 0x1721a9f149c922de),
("ec.sim.ccparticleworld/6/32bpc/t1.5", 0xf378db3b55dff048),
("ec.sim.ccparticleworld/6/32bpc/t0.8", 0x72a5d8dc0ea744ad),
("ec.sim.ccparticleworld/6/32bpc/t2.2", 0xfbb8e14088bb51ef),
("ec.sim.ccparticleworld/7/8bpc/t0.5", 0xc56801146ef15811),
("ec.sim.ccparticleworld/7/8bpc/t1.5", 0x3ca9b694e5e55f82),
("ec.sim.ccparticleworld/7/8bpc/t0.8", 0x2d72c095e0be6f47),
("ec.sim.ccparticleworld/7/8bpc/t2.2", 0x135b14387e6ea480),
("ec.sim.ccparticleworld/7/32bpc/t0.5", 0xad79b2f2d63d0c45),
("ec.sim.ccparticleworld/7/32bpc/t1.5", 0x902c6b68c6ed615d),
("ec.sim.ccparticleworld/7/32bpc/t0.8", 0x45045d7931c23788),
("ec.sim.ccparticleworld/7/32bpc/t2.2", 0xd8f8883374a3784f),
("ec.sim.ccparticleworld/8/8bpc/t0.5", 0xb681fe6e20dccbf3),
("ec.sim.ccparticleworld/8/8bpc/t1.5", 0xfa78174cb4fa5cd4),
("ec.sim.ccparticleworld/8/8bpc/t0.8", 0xd4f486333d843f09),
("ec.sim.ccparticleworld/8/8bpc/t2.2", 0xbee37590ce17b8b4),
("ec.sim.ccparticleworld/8/32bpc/t0.5", 0xccf4339bc42c3c01),
("ec.sim.ccparticleworld/8/32bpc/t1.5", 0xc51a8ee4e3c4a826),
("ec.sim.ccparticleworld/8/32bpc/t0.8", 0xee3d66e0c4ffb0ec),
("ec.sim.ccparticleworld/8/32bpc/t2.2", 0x449fc6d506e837fb),
("ec.sim.ccparticlesystems2/0/8bpc/t0.5", 0x3edb24bfeab4b00a),
("ec.sim.ccparticlesystems2/0/8bpc/t1.5", 0x78cb639f211cbd5b),
("ec.sim.ccparticlesystems2/0/8bpc/t0.8", 0x634c71924e4c68f1),
("ec.sim.ccparticlesystems2/0/8bpc/t2.2", 0xe09d41dcb6286423),
("ec.sim.ccparticlesystems2/0/32bpc/t0.5", 0xacdb6aec643a9bba),
("ec.sim.ccparticlesystems2/0/32bpc/t1.5", 0x4c50991034336999),
("ec.sim.ccparticlesystems2/0/32bpc/t0.8", 0xaacc2ea722f2e9e3),
("ec.sim.ccparticlesystems2/0/32bpc/t2.2", 0x1a5f7fc66db90a83),
("ec.sim.ccparticlesystems2/0/8bpc/half/t0.5", 0x8c89f83a2829dba1),
("ec.sim.ccparticlesystems2/0/8bpc/half/t1.5", 0x9239cd8895757d17),
("ec.sim.ccparticlesystems2/0/8bpc/half/t0.8", 0x91a16fb0a382d8e1),
("ec.sim.ccparticlesystems2/0/8bpc/half/t2.2", 0xc86d8250fe1402c5),
("ec.sim.ccparticlesystems2/1/8bpc/t0.5", 0xabade8d131837f05),
("ec.sim.ccparticlesystems2/1/8bpc/t1.5", 0x6a3566257b9a0bad),
("ec.sim.ccparticlesystems2/1/8bpc/t0.8", 0xc1199f209bad5c33),
("ec.sim.ccparticlesystems2/1/8bpc/t2.2", 0xaf337959d8264a5f),
("ec.sim.ccparticlesystems2/1/32bpc/t0.5", 0xb1816c2e5562be88),
("ec.sim.ccparticlesystems2/1/32bpc/t1.5", 0x02413f0ec8284e39),
("ec.sim.ccparticlesystems2/1/32bpc/t0.8", 0xd9bc645b90055a9a),
("ec.sim.ccparticlesystems2/1/32bpc/t2.2", 0x2c788bb5816167a3),
("ec.sim.ccparticlesystems2/2/8bpc/t0.5", 0xe18fd350f7155fc8),
("ec.sim.ccparticlesystems2/2/8bpc/t1.5", 0x956e03e2c83c3899),
("ec.sim.ccparticlesystems2/2/8bpc/t0.8", 0x7d16738562170622),
("ec.sim.ccparticlesystems2/2/8bpc/t2.2", 0x4e5838737bda7317),
("ec.sim.ccparticlesystems2/2/32bpc/t0.5", 0x27d909b24d165f6c),
("ec.sim.ccparticlesystems2/2/32bpc/t1.5", 0x47fb5492c0bcb748),
("ec.sim.ccparticlesystems2/2/32bpc/t0.8", 0x7dd63be8a11f4d71),
("ec.sim.ccparticlesystems2/2/32bpc/t2.2", 0x139259fc5f942375),
("ec.sim.ccparticlesystems2/3/8bpc/t0.5", 0xf14aad048c3b4181),
("ec.sim.ccparticlesystems2/3/8bpc/t1.5", 0xc261983ac6805eda),
("ec.sim.ccparticlesystems2/3/8bpc/t0.8", 0xf93ad1d6a9c493a5),
("ec.sim.ccparticlesystems2/3/8bpc/t2.2", 0x88cf7156f9f8ec54),
("ec.sim.ccparticlesystems2/3/32bpc/t0.5", 0xdc1df37cb3b92146),
("ec.sim.ccparticlesystems2/3/32bpc/t1.5", 0x1c415c633baec738),
("ec.sim.ccparticlesystems2/3/32bpc/t0.8", 0x81a765f6e435e762),
("ec.sim.ccparticlesystems2/3/32bpc/t2.2", 0x7810e7f2ae55f619),
("ec.sim.ccparticlesystems2/4/8bpc/t0.5", 0xd246be64fea5db5f),
("ec.sim.ccparticlesystems2/4/8bpc/t1.5", 0x78ca37c0d19e87cf),
("ec.sim.ccparticlesystems2/4/8bpc/t0.8", 0xa5eacfbc0cc28a7c),
("ec.sim.ccparticlesystems2/4/8bpc/t2.2", 0xa527d1d2cdce5c96),
("ec.sim.ccparticlesystems2/4/32bpc/t0.5", 0x41c8b38886ae9652),
("ec.sim.ccparticlesystems2/4/32bpc/t1.5", 0x1e6d16247bb9c7a7),
("ec.sim.ccparticlesystems2/4/32bpc/t0.8", 0x05b125bdcd9a1794),
("ec.sim.ccparticlesystems2/4/32bpc/t2.2", 0x4752ed7777528cc8),
("ec.sim.ccparticlesystems2/5/8bpc/t0.5", 0x8ff7adb3b1720dbe),
("ec.sim.ccparticlesystems2/5/8bpc/t1.5", 0x2bb835296a78fccb),
("ec.sim.ccparticlesystems2/5/8bpc/t0.8", 0x674810581e32f80a),
("ec.sim.ccparticlesystems2/5/8bpc/t2.2", 0xecba1a3a740f4325),
("ec.sim.ccparticlesystems2/5/32bpc/t0.5", 0x30f53c7cb869066c),
("ec.sim.ccparticlesystems2/5/32bpc/t1.5", 0x1eb455f68b9decc8),
("ec.sim.ccparticlesystems2/5/32bpc/t0.8", 0xf396c8bdd516ef26),
("ec.sim.ccparticlesystems2/5/32bpc/t2.2", 0x7d0a6e30425ea5c3),
("ec.sim.particleplayground/0/8bpc/t0.5", 0x1842226399d43e19),
("ec.sim.particleplayground/0/8bpc/t1.5", 0xd5676389299c50e9),
("ec.sim.particleplayground/0/8bpc/t0.8", 0x30517e3b37e099d5),
("ec.sim.particleplayground/0/8bpc/t2.2", 0xae4bc0c296e03d75),
("ec.sim.particleplayground/0/32bpc/t0.5", 0x7af5da92e477f075),
("ec.sim.particleplayground/0/32bpc/t1.5", 0xb51f5a6d27dc1d85),
("ec.sim.particleplayground/0/32bpc/t0.8", 0x8da8efac055a67e5),
("ec.sim.particleplayground/0/32bpc/t2.2", 0x7ad7e7a36e107a6d),
("ec.sim.particleplayground/0/8bpc/half/t0.5", 0xe262aec0816c3ccd),
("ec.sim.particleplayground/0/8bpc/half/t1.5", 0x87e664a185ad2ca1),
("ec.sim.particleplayground/0/8bpc/half/t0.8", 0x34e36782ea9592fd),
("ec.sim.particleplayground/0/8bpc/half/t2.2", 0x0cf9289dd2950f89),
("ec.sim.particleplayground/1/8bpc/t0.5", 0x5dfbd4b5eb4382b9),
("ec.sim.particleplayground/1/8bpc/t1.5", 0xac3e8f2a9d4df855),
("ec.sim.particleplayground/1/8bpc/t0.8", 0x847292a51edbf41d),
("ec.sim.particleplayground/1/8bpc/t2.2", 0x58d9f45b4b984099),
("ec.sim.particleplayground/1/32bpc/t0.5", 0x1a3069f5d33c287d),
("ec.sim.particleplayground/1/32bpc/t1.5", 0x08e920bd85cd88ad),
("ec.sim.particleplayground/1/32bpc/t0.8", 0x692b0c490c09ac51),
("ec.sim.particleplayground/1/32bpc/t2.2", 0x21491c337ddd9f75),
("ec.sim.particleplayground/2/8bpc/t0.5", 0xb04e21f3c9ee8955),
("ec.sim.particleplayground/2/8bpc/t1.5", 0x3e9b871d87e66255),
("ec.sim.particleplayground/2/8bpc/t0.8", 0x9052ebc3a39db605),
("ec.sim.particleplayground/2/8bpc/t2.2", 0x4b2129a0221866d5),
("ec.sim.particleplayground/2/32bpc/t0.5", 0xb59ff67f1e4fdd55),
("ec.sim.particleplayground/2/32bpc/t1.5", 0x1ac6b864503401c5),
("ec.sim.particleplayground/2/32bpc/t0.8", 0x16a5d9d93e9c95c5),
("ec.sim.particleplayground/2/32bpc/t2.2", 0x4b2129a0221866d5),
("ec.sim.particleplayground/3/8bpc/t0.5", 0xbf818a3d83f45f7d),
("ec.sim.particleplayground/3/8bpc/t1.5", 0x02ee29d54f2f2ca5),
("ec.sim.particleplayground/3/8bpc/t0.8", 0x212121b5d3ff40f5),
("ec.sim.particleplayground/3/8bpc/t2.2", 0x4b2129a0221866d5),
("ec.sim.particleplayground/3/32bpc/t0.5", 0x94901b9480a11505),
("ec.sim.particleplayground/3/32bpc/t1.5", 0x18d82bebd984986d),
("ec.sim.particleplayground/3/32bpc/t0.8", 0x59b910231e182a8d),
("ec.sim.particleplayground/3/32bpc/t2.2", 0x4b2129a0221866d5),
("ec.sim.particleplayground/4/8bpc/t0.5", 0xb20c9c68c9e45a88),
("ec.sim.particleplayground/4/8bpc/t1.5", 0x4b2129a0221866d5),
("ec.sim.particleplayground/4/8bpc/t0.8", 0xdeb3ee1a1a0382d9),
("ec.sim.particleplayground/4/8bpc/t2.2", 0x4b2129a0221866d5),
("ec.sim.particleplayground/4/32bpc/t0.5", 0x7678784655ad24ef),
("ec.sim.particleplayground/4/32bpc/t1.5", 0x4b2129a0221866d5),
("ec.sim.particleplayground/4/32bpc/t0.8", 0x2fcf78fef85dc79b),
("ec.sim.particleplayground/4/32bpc/t2.2", 0x4b2129a0221866d5),
("ec.sim.particleplayground/5/8bpc/t0.5", 0x11c85638b23ca1ad),
("ec.sim.particleplayground/5/8bpc/t1.5", 0x0430aedb0bfe9b1d),
("ec.sim.particleplayground/5/8bpc/t0.8", 0x848b60fed97e4755),
("ec.sim.particleplayground/5/8bpc/t2.2", 0xe06dc84db33e7429),
("ec.sim.particleplayground/5/32bpc/t0.5", 0x0195318b4db583a1),
("ec.sim.particleplayground/5/32bpc/t1.5", 0x0f5e6c80a182d47d),
("ec.sim.particleplayground/5/32bpc/t0.8", 0xfe3ce304bf789bcd),
("ec.sim.particleplayground/5/32bpc/t2.2", 0x8dd7ed29d632643d),
("ec.sim.particleplayground/6/8bpc/t0.5", 0xb04e21f3c9ee8955),
("ec.sim.particleplayground/6/8bpc/t1.5", 0x3e9b871d87e66255),
("ec.sim.particleplayground/6/8bpc/t0.8", 0x9052ebc3a39db605),
("ec.sim.particleplayground/6/8bpc/t2.2", 0x4b2129a0221866d5),
("ec.sim.particleplayground/6/32bpc/t0.5", 0xb59ff67f1e4fdd55),
("ec.sim.particleplayground/6/32bpc/t1.5", 0x1ac6b864503401c5),
("ec.sim.particleplayground/6/32bpc/t0.8", 0x16a5d9d93e9c95c5),
("ec.sim.particleplayground/6/32bpc/t2.2", 0x4b2129a0221866d5),
("ec.sim.particleplayground/7/8bpc/t0.5", 0x5587b4a20830fbe1),
("ec.sim.particleplayground/7/8bpc/t1.5", 0xcdf7191b5cad91f5),
("ec.sim.particleplayground/7/8bpc/t0.8", 0x6e4571273b2d8959),
("ec.sim.particleplayground/7/8bpc/t2.2", 0x4b2129a0221866d5),
("ec.sim.particleplayground/7/32bpc/t0.5", 0x5080db75b8825039),
("ec.sim.particleplayground/7/32bpc/t1.5", 0x9070b3c2bfa25b29),
("ec.sim.particleplayground/7/32bpc/t0.8", 0xd406da6427e4b62d),
("ec.sim.particleplayground/7/32bpc/t2.2", 0x4b2129a0221866d5),
("ec.sim.particleplayground/8/8bpc/t0.5", 0x160e670965dffcd1),
("ec.sim.particleplayground/8/8bpc/t1.5", 0xd3a908531f629a05),
("ec.sim.particleplayground/8/8bpc/t0.8", 0x70d54b3670a26555),
("ec.sim.particleplayground/8/8bpc/t2.2", 0xbd22bfa694d189fd),
("ec.sim.particleplayground/8/32bpc/t0.5", 0x8630bbc5c1602bb9),
("ec.sim.particleplayground/8/32bpc/t1.5", 0xb5b7f0281f26baad),
("ec.sim.particleplayground/8/32bpc/t0.8", 0x8b6934363ef4dcfd),
("ec.sim.particleplayground/8/32bpc/t2.2", 0x0a31c17e8e34b9f5),
("ec.sim.ccballaction/0/8bpc/t0.5", 0x12698ec8353be38c),
("ec.sim.ccballaction/0/8bpc/t1.5", 0x12698ec8353be38c),
("ec.sim.ccballaction/0/8bpc/t0.8", 0x12698ec8353be38c),
("ec.sim.ccballaction/0/8bpc/t2.2", 0x12698ec8353be38c),
("ec.sim.ccballaction/0/32bpc/t0.5", 0x3fb733487e120261),
("ec.sim.ccballaction/0/32bpc/t1.5", 0x3fb733487e120261),
("ec.sim.ccballaction/0/32bpc/t0.8", 0x3fb733487e120261),
("ec.sim.ccballaction/0/32bpc/t2.2", 0x3fb733487e120261),
("ec.sim.ccballaction/0/8bpc/half/t0.5", 0x4946070917cc0945),
("ec.sim.ccballaction/0/8bpc/half/t1.5", 0x4946070917cc0945),
("ec.sim.ccballaction/0/8bpc/half/t0.8", 0x4946070917cc0945),
("ec.sim.ccballaction/0/8bpc/half/t2.2", 0x4946070917cc0945),
("ec.sim.ccballaction/1/8bpc/t0.5", 0xf34b3be38a4dd468),
("ec.sim.ccballaction/1/8bpc/t1.5", 0xf34b3be38a4dd468),
("ec.sim.ccballaction/1/8bpc/t0.8", 0xf34b3be38a4dd468),
("ec.sim.ccballaction/1/8bpc/t2.2", 0xf34b3be38a4dd468),
("ec.sim.ccballaction/1/32bpc/t0.5", 0x503b7238edf2912c),
("ec.sim.ccballaction/1/32bpc/t1.5", 0x503b7238edf2912c),
("ec.sim.ccballaction/1/32bpc/t0.8", 0x503b7238edf2912c),
("ec.sim.ccballaction/1/32bpc/t2.2", 0x503b7238edf2912c),
("ec.sim.ccballaction/2/8bpc/t0.5", 0x0b6bf26753ee4582),
("ec.sim.ccballaction/2/8bpc/t1.5", 0x0b6bf26753ee4582),
("ec.sim.ccballaction/2/8bpc/t0.8", 0x0b6bf26753ee4582),
("ec.sim.ccballaction/2/8bpc/t2.2", 0x0b6bf26753ee4582),
("ec.sim.ccballaction/2/32bpc/t0.5", 0x845e2c5c2719c336),
("ec.sim.ccballaction/2/32bpc/t1.5", 0x845e2c5c2719c336),
("ec.sim.ccballaction/2/32bpc/t0.8", 0x845e2c5c2719c336),
("ec.sim.ccballaction/2/32bpc/t2.2", 0x845e2c5c2719c336),
("ec.sim.ccpixelpolly/0/8bpc/t0.5", 0x793db4214db81d01),
("ec.sim.ccpixelpolly/0/8bpc/t1.5", 0xf69cf7f742757382),
("ec.sim.ccpixelpolly/0/8bpc/t0.8", 0xa81ebe4ddeab9b83),
("ec.sim.ccpixelpolly/0/8bpc/t2.2", 0x8d6af96aa12b70ac),
("ec.sim.ccpixelpolly/0/32bpc/t0.5", 0x3d03f23d9a915b5e),
("ec.sim.ccpixelpolly/0/32bpc/t1.5", 0x1c6a52023b0e21d0),
("ec.sim.ccpixelpolly/0/32bpc/t0.8", 0x6041c4d2944492e3),
("ec.sim.ccpixelpolly/0/32bpc/t2.2", 0x589763c3342ee81d),
("ec.sim.ccpixelpolly/0/8bpc/half/t0.5", 0x3d7bf2e96b64f5a5),
("ec.sim.ccpixelpolly/0/8bpc/half/t1.5", 0x24da849dfc0c1700),
("ec.sim.ccpixelpolly/0/8bpc/half/t0.8", 0x67992ff4eb32c1c5),
("ec.sim.ccpixelpolly/0/8bpc/half/t2.2", 0xdf434cacd5ad0471),
("ec.sim.ccpixelpolly/1/8bpc/t0.5", 0xf953bd43d910c59e),
("ec.sim.ccpixelpolly/1/8bpc/t1.5", 0x7fa196f56e470219),
("ec.sim.ccpixelpolly/1/8bpc/t0.8", 0x1f545b2172052197),
("ec.sim.ccpixelpolly/1/8bpc/t2.2", 0xd5eb9e120765f877),
("ec.sim.ccpixelpolly/1/32bpc/t0.5", 0xdb57e39c1f6df9f2),
("ec.sim.ccpixelpolly/1/32bpc/t1.5", 0x9710c951b521ccd2),
("ec.sim.ccpixelpolly/1/32bpc/t0.8", 0x1cc528553c49dfa9),
("ec.sim.ccpixelpolly/1/32bpc/t2.2", 0x8f73c69e50a6ce05),
("ec.sim.ccpixelpolly/2/8bpc/t0.5", 0x81720809859326c3),
("ec.sim.ccpixelpolly/2/8bpc/t1.5", 0x6bd1dee807745400),
("ec.sim.ccpixelpolly/2/8bpc/t0.8", 0x41d97e82c0554490),
("ec.sim.ccpixelpolly/2/8bpc/t2.2", 0x80def64ff166e725),
("ec.sim.ccpixelpolly/2/32bpc/t0.5", 0x10ab39b8f37c10dd),
("ec.sim.ccpixelpolly/2/32bpc/t1.5", 0xe66c3cc61794a13d),
("ec.sim.ccpixelpolly/2/32bpc/t0.8", 0x8a5fc3d42b56d274),
("ec.sim.ccpixelpolly/2/32bpc/t2.2", 0x9b493296a9819722),
("ec.sim.ccpixelpolly/3/8bpc/t0.5", 0x12b4b0832fc06783),
("ec.sim.ccpixelpolly/3/8bpc/t1.5", 0xe0095bedb7b3b17d),
("ec.sim.ccpixelpolly/3/8bpc/t0.8", 0x073b454632a54acc),
("ec.sim.ccpixelpolly/3/8bpc/t2.2", 0x5e0f61b3400d0b2e),
("ec.sim.ccpixelpolly/3/32bpc/t0.5", 0x014913b7c76771d3),
("ec.sim.ccpixelpolly/3/32bpc/t1.5", 0x7117b63447e076c3),
("ec.sim.ccpixelpolly/3/32bpc/t0.8", 0xb4c7fe2497dcf95c),
("ec.sim.ccpixelpolly/3/32bpc/t2.2", 0xd9fd060aaf88f744),
("ec.sim.ccscatterize/0/8bpc/t0.5", 0x8ad88caae775ea83),
("ec.sim.ccscatterize/0/8bpc/t1.5", 0x8ad88caae775ea83),
("ec.sim.ccscatterize/0/8bpc/t0.8", 0x8ad88caae775ea83),
("ec.sim.ccscatterize/0/8bpc/t2.2", 0x8ad88caae775ea83),
("ec.sim.ccscatterize/0/32bpc/t0.5", 0xe2b8332b96a1c76e),
("ec.sim.ccscatterize/0/32bpc/t1.5", 0xe2b8332b96a1c76e),
("ec.sim.ccscatterize/0/32bpc/t0.8", 0xe2b8332b96a1c76e),
("ec.sim.ccscatterize/0/32bpc/t2.2", 0xe2b8332b96a1c76e),
("ec.sim.ccscatterize/0/8bpc/half/t0.5", 0x730351fffb20e0c8),
("ec.sim.ccscatterize/0/8bpc/half/t1.5", 0x730351fffb20e0c8),
("ec.sim.ccscatterize/0/8bpc/half/t0.8", 0x730351fffb20e0c8),
("ec.sim.ccscatterize/0/8bpc/half/t2.2", 0x730351fffb20e0c8),
("ec.sim.ccscatterize/1/8bpc/t0.5", 0x675147db65b68b95),
("ec.sim.ccscatterize/1/8bpc/t1.5", 0x675147db65b68b95),
("ec.sim.ccscatterize/1/8bpc/t0.8", 0x675147db65b68b95),
("ec.sim.ccscatterize/1/8bpc/t2.2", 0x675147db65b68b95),
("ec.sim.ccscatterize/1/32bpc/t0.5", 0x6d822bb814b2dbe8),
("ec.sim.ccscatterize/1/32bpc/t1.5", 0x6d822bb814b2dbe8),
("ec.sim.ccscatterize/1/32bpc/t0.8", 0x6d822bb814b2dbe8),
("ec.sim.ccscatterize/1/32bpc/t2.2", 0x6d822bb814b2dbe8),
("ec.sim.ccscatterize/2/8bpc/t0.5", 0xd1f4144884af1673),
("ec.sim.ccscatterize/2/8bpc/t1.5", 0xd1f4144884af1673),
("ec.sim.ccscatterize/2/8bpc/t0.8", 0xd1f4144884af1673),
("ec.sim.ccscatterize/2/8bpc/t2.2", 0xd1f4144884af1673),
("ec.sim.ccscatterize/2/32bpc/t0.5", 0x95f58900dc6d6f58),
("ec.sim.ccscatterize/2/32bpc/t1.5", 0x95f58900dc6d6f58),
("ec.sim.ccscatterize/2/32bpc/t0.8", 0x95f58900dc6d6f58),
("ec.sim.ccscatterize/2/32bpc/t2.2", 0x95f58900dc6d6f58),
("ec.noise.curlnoise/0/8bpc/t0.5", 0xce5b0d9a6bbe529c),
("ec.noise.curlnoise/0/8bpc/t1.5", 0xce5b0d9a6bbe529c),
("ec.noise.curlnoise/0/8bpc/t0.8", 0xce5b0d9a6bbe529c),
("ec.noise.curlnoise/0/8bpc/t2.2", 0xce5b0d9a6bbe529c),
("ec.noise.curlnoise/0/32bpc/t0.5", 0x18de53fb08fbec6c),
("ec.noise.curlnoise/0/32bpc/t1.5", 0x18de53fb08fbec6c),
("ec.noise.curlnoise/0/32bpc/t0.8", 0x18de53fb08fbec6c),
("ec.noise.curlnoise/0/32bpc/t2.2", 0x18de53fb08fbec6c),
("ec.noise.curlnoise/0/8bpc/half/t0.5", 0x00124f8f5a0bb582),
("ec.noise.curlnoise/0/8bpc/half/t1.5", 0x00124f8f5a0bb582),
("ec.noise.curlnoise/0/8bpc/half/t0.8", 0x00124f8f5a0bb582),
("ec.noise.curlnoise/0/8bpc/half/t2.2", 0x00124f8f5a0bb582),
("ec.noise.curlnoise/1/8bpc/t0.5", 0x73e04e5ca0589de4),
("ec.noise.curlnoise/1/8bpc/t1.5", 0x73e04e5ca0589de4),
("ec.noise.curlnoise/1/8bpc/t0.8", 0x73e04e5ca0589de4),
("ec.noise.curlnoise/1/8bpc/t2.2", 0x73e04e5ca0589de4),
("ec.noise.curlnoise/1/32bpc/t0.5", 0x111d2c248e7b334b),
("ec.noise.curlnoise/1/32bpc/t1.5", 0x111d2c248e7b334b),
("ec.noise.curlnoise/1/32bpc/t0.8", 0x111d2c248e7b334b),
("ec.noise.curlnoise/1/32bpc/t2.2", 0x111d2c248e7b334b),
("ec.noise.curlnoise/2/8bpc/t0.5", 0xe62ab777e41bf94e),
("ec.noise.curlnoise/2/8bpc/t1.5", 0xe62ab777e41bf94e),
("ec.noise.curlnoise/2/8bpc/t0.8", 0xe62ab777e41bf94e),
("ec.noise.curlnoise/2/8bpc/t2.2", 0xe62ab777e41bf94e),
("ec.noise.curlnoise/2/32bpc/t0.5", 0xd477a194b7a2d123),
("ec.noise.curlnoise/2/32bpc/t1.5", 0xd477a194b7a2d123),
("ec.noise.curlnoise/2/32bpc/t0.8", 0xd477a194b7a2d123),
("ec.noise.curlnoise/2/32bpc/t2.2", 0xd477a194b7a2d123),
];

View File

@ -0,0 +1,239 @@
//! Golden hashes of the CPU Timewarp (M13.29), with and without a Matte Layer.
//!
//! Timewarp's frame building is shared with the GPU compositor as a plan
//! (`effectcraft_effects::timewarp_plan`); this pins the CPU output of every method / Show /
//! Matte Channel combination, with motion blur and a Warp Layer, so refactoring the plan keeps
//! it bit-identical. Same hashing and re-pinning (`SIM_GOLDEN_PRINT=1`) as `sim_golden.rs`;
//! pinned for aarch64 macOS.
use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Params, apply, default_value, find};
use effectcraft_keyframe::Value;
use effectcraft_raster::Image;
const W: u32 = 48;
const H: u32 = 32;
const FPS: f64 = 10.0;
/// Layer ids served by [`Host`]: the same texture standing still (a Warp Layer), a matte white
/// on its left part with a soft edge, and a luminance matte.
const STILL: u64 = 7;
const MATTE: u64 = 8;
const LUMA: u64 = 9;
/// A texture shifted right by `dx` px (premultiplied, partly transparent).
fn texture(dx: f64) -> Image {
let mut img = Image::new(W, H);
for y in 0..H {
for x in 0..W {
let u = x as f64 - dx;
let a = if (10..14).contains(&y) { 0.5 } else { 1.0 };
let c = [(0.5 + 0.4 * (u * 0.4).sin()) as f32, (0.5 + 0.4 * (y as f64 * 0.3 + u * 0.1).cos()) as f32, ((u * 0.05).rem_euclid(1.0)) as f32];
img.set(x, y, [c[0] * a, c[1] * a, c[2] * a, a]);
}
}
img
}
struct Host;
impl EffectHost for Host {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
Some(Buf { img: texture((t * FPS).round() * 2.0), offset: [0.0; 2], scale: 1.0 })
}
fn layer_at(&self, id: u64, t: f64, _: bool) -> Option<LayerPixels> {
let img = match id {
STILL => texture(0.0),
LUMA => {
let mut m = Image::new(W, H);
for y in 0..H {
for x in 0..W {
let l = ((x + y) as f32 / (W + H) as f32 + (t * 0.3) as f32).fract();
m.set(x, y, [l, l, l, 1.0]);
}
}
m
}
_ => {
let mut m = Image::new(W, H);
let edge = 20.0 + (t * FPS).round() * 1.5;
for y in 0..H {
for x in 0..W {
let a = ((edge - x as f64) / 3.0).clamp(0.0, 1.0) as f32;
m.set(x, y, [a, a, a, a]);
}
}
m
}
};
Some(LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [W as f64, H as f64] })
}
}
fn n(v: f64) -> Value {
Value::Scalar(v)
}
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn l(id: u64) -> Value {
Value::Layer(Some(id))
}
type Set = Vec<(&'static str, Value)>;
fn cases() -> Vec<Set> {
let mut v: Vec<Set> = vec![vec![], vec![("method", e(0))], vec![("method", e(1))]];
for method in 0..3 {
for show in 0..4 {
v.push(vec![("method", e(method)), ("matteLayer", l(MATTE)), ("show", e(show)), ("speed", n(37.0))]);
}
}
for ch in 1..4 {
v.push(vec![("matteLayer", l(LUMA)), ("matteChannel", e(ch)), ("speed", n(63.0))]);
}
v.push(vec![("matteLayer", l(MATTE)), ("warpLayer", l(STILL)), ("speed", n(41.0))]);
v.push(vec![("matteLayer", l(MATTE)), ("motionBlur/enableMotionBlur", Value::Bool(true)), ("motionBlur/shutterSamples", n(3.0)), ("show", e(1))]);
v.push(vec![("matteLayer", l(MATTE)), ("method", e(1)), ("tuning/buildFromOneImage", Value::Bool(true)), ("show", e(2))]);
v.push(vec![("matteLayer", l(MATTE)), ("tuning/buildFromOneImage", Value::Bool(true)), ("speed", n(28.0))]);
v.push(vec![("matteLayer", l(MATTE)), ("adjustTimeBy", e(1)), ("sourceFrame", n(4.4)), ("show", e(3))]);
v
}
const TIMES: [f64; 3] = [0.55, 1.23, 0.8];
fn fnv(img: &Image) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325u64;
for b in [img.width, img.height].iter().flat_map(|v| v.to_le_bytes()) {
h = (h ^ b as u64).wrapping_mul(0x100_0000_01b3);
}
for p in &img.data {
for c in p {
for b in c.to_bits().to_le_bytes() {
h = (h ^ b as u64).wrapping_mul(0x100_0000_01b3);
}
}
}
h
}
fn render(set: &Set, time: f64) -> Image {
let spec = find("ec.time.timewarp").unwrap();
let size = [W as f64, H as f64];
let mut params = Params { values: spec.params.iter().map(|p| (p.id.to_string(), default_value(p, size))).collect() };
for (k, v) in set {
assert!(params.values.contains_key(*k), "unknown param {k}");
params.values.insert(k.to_string(), v.clone());
}
let host = Host;
let env = EffectEnv { host: Some(&host), frame_rate: FPS, comp_time: time, ..Default::default() };
let ctx = EffectCtx { params: &params, time, layer_size: size, seed: 1, adjustment: false, env };
apply(spec, &ctx, Buf { img: texture(0.0), offset: [0.0; 2], scale: 1.0 }).img
}
fn hashes() -> Vec<(String, u64)> {
let mut v = Vec::new();
for (si, set) in cases().iter().enumerate() {
for t in TIMES {
v.push((format!("timewarp/{si}/t{t}"), fnv(&render(set, t))));
}
}
v
}
#[test]
fn timewarp_golden_hashes() {
let got = hashes();
if std::env::var_os("SIM_GOLDEN_PRINT").is_some() {
for (name, h) in &got {
println!(" (\"{name}\", 0x{h:016x}),");
}
}
assert_eq!(got, hashes(), "Timewarp renders deterministically");
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
let bad: Vec<String> = got
.iter()
.zip(GOLDEN)
.filter(|((n, h), (gn, gh))| n != gn || h != gh)
.map(|((n, h), (_, gh))| format!("{n}: 0x{h:016x} (pinned 0x{gh:016x})"))
.collect();
assert_eq!(got.len(), GOLDEN.len(), "case count");
assert!(bad.is_empty(), "CPU output changed:\n{}", bad.join("\n"));
}
}
/// Pinned hashes (aarch64 macOS).
const GOLDEN: &[(&str, u64)] = &[
("timewarp/0/t0.55", 0x85905ea4083d5052),
("timewarp/0/t1.23", 0x6499c6f962fed8e7),
("timewarp/0/t0.8", 0x48493cd23ed32a5f),
("timewarp/1/t0.55", 0x9eca0f14f7cb2d95),
("timewarp/1/t1.23", 0xd0c16d4df51315b4),
("timewarp/1/t0.8", 0x48493cd23ed32a5f),
("timewarp/2/t0.55", 0x9d3703747d8e5b7f),
("timewarp/2/t1.23", 0xa9ea3d660b9fd38b),
("timewarp/2/t0.8", 0x48493cd23ed32a5f),
("timewarp/3/t0.55", 0x9eca0f14f7cb2d95),
("timewarp/3/t1.23", 0x48493cd23ed32a5f),
("timewarp/3/t0.8", 0x9eca0f14f7cb2d95),
("timewarp/4/t0.55", 0x91cbdc02063b9105),
("timewarp/4/t1.23", 0x6060a22b299bc92d),
("timewarp/4/t0.8", 0x91cbdc02063b9105),
("timewarp/5/t0.55", 0xc2e2170269f47324),
("timewarp/5/t1.23", 0x3cea3a307e21500d),
("timewarp/5/t0.8", 0xc2e2170269f47324),
("timewarp/6/t0.55", 0xe55ce2acace7ba15),
("timewarp/6/t1.23", 0x6a90b9e251de6d15),
("timewarp/6/t0.8", 0xe55ce2acace7ba15),
("timewarp/7/t0.55", 0x54020055b7d248c6),
("timewarp/7/t1.23", 0x48d89e15e1f7801c),
("timewarp/7/t0.8", 0x628bc4aba205a8a8),
("timewarp/8/t0.55", 0x467f6294e2c06d6c),
("timewarp/8/t1.23", 0xfd442392677e46ad),
("timewarp/8/t0.8", 0xc26b69bf0e79c9c3),
("timewarp/9/t0.55", 0xd2fddb3350b55277),
("timewarp/9/t1.23", 0x16807a7efa8f989a),
("timewarp/9/t0.8", 0xb6a045bcaccdc2a2),
("timewarp/10/t0.55", 0xa86ae0542d872015),
("timewarp/10/t1.23", 0x86dba3262d03f3d5),
("timewarp/10/t0.8", 0xfc37965f9c2e51d5),
("timewarp/11/t0.55", 0x240ee936695325a8),
("timewarp/11/t1.23", 0x7fe2ff24fedcd3f1),
("timewarp/11/t0.8", 0xd57ed4c5bfca415e),
("timewarp/12/t0.55", 0xad462efd17ecf4be),
("timewarp/12/t1.23", 0xd05b6e67e92eaf90),
("timewarp/12/t0.8", 0xcc3f297acfa6b646),
("timewarp/13/t0.55", 0x80821631b420f5fc),
("timewarp/13/t1.23", 0x69ef153147214217),
("timewarp/13/t0.8", 0xe81297c3aa0752fe),
("timewarp/14/t0.55", 0x79ff11a84d24d2f4),
("timewarp/14/t1.23", 0x54fccecd7a87efb5),
("timewarp/14/t0.8", 0x0055f9cce950e80f),
("timewarp/15/t0.55", 0x193233cd7098d460),
("timewarp/15/t1.23", 0x0171866bfbe232b3),
("timewarp/15/t0.8", 0x5a15d4a6e85931d7),
("timewarp/16/t0.55", 0x0d4df26b38ca39a0),
("timewarp/16/t1.23", 0x7502f65ccf41ce0e),
("timewarp/16/t0.8", 0x39b6e04e7935b0fe),
("timewarp/17/t0.55", 0x99ae36e79014f5dc),
("timewarp/17/t1.23", 0xa844571a39e97251),
("timewarp/17/t0.8", 0x2e6961e16f50eec6),
("timewarp/18/t0.55", 0x9d6d36c8d9303a6e),
("timewarp/18/t1.23", 0x63794786e758ad00),
("timewarp/18/t0.8", 0x2bc9775fc6f5723e),
("timewarp/19/t0.55", 0x11c1925988c75ab0),
("timewarp/19/t1.23", 0x19c13bccca289b25),
("timewarp/19/t0.8", 0x7f5ec6ec4c3a2aeb),
("timewarp/20/t0.55", 0x386ca6db13d4675b),
("timewarp/20/t1.23", 0xaee266701f5f9936),
("timewarp/20/t0.8", 0x3cea3a307e21500d),
("timewarp/21/t0.55", 0x21f0abd02bad8359),
("timewarp/21/t1.23", 0xe8c75365a1d6505a),
("timewarp/21/t0.8", 0x3ee1c9c17c7760a1),
("timewarp/22/t0.55", 0xdc518b5e0fc85047),
("timewarp/22/t1.23", 0xdc518b5e0fc85047),
("timewarp/22/t0.8", 0xdc518b5e0fc85047),
];

View File

@ -319,6 +319,7 @@ impl GpuContext {
include_str!("shaders/fx_depth.wgsl"),
include_str!("shaders/fx_lut.wgsl"),
include_str!("shaders/fx_sim.wgsl"),
include_str!("shaders/fx_particles.wgsl"),
include_str!("shaders/fx_vr.wgsl"),
include_str!("shaders/fx_light.wgsl"),
include_str!("shaders/fx_transition.wgsl"),
@ -389,6 +390,7 @@ impl GpuContext {
.chain(crate::fx_depth::KERNELS)
.chain(crate::fx_lut::KERNELS)
.chain(crate::fx_sim::KERNELS)
.chain(crate::fx_particles::KERNELS)
.chain(crate::fx_vr::KERNELS)
.chain(crate::fx_light::KERNELS)
.chain(crate::fx_transition::KERNELS)

View File

@ -170,6 +170,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
_ 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_particles::IDS.contains(&id) => crate::fx_particles::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),

View File

@ -10,7 +10,8 @@
//! 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.
//! Custom `.ocio` configurations compile their colour spaces' transforms (matrix and offset,
//! exponent, log / log-affine, range, CDL, file LUTs, groups, inverses) to the same program.
use effectcraft_effects::{ColorOp, EffectCtx, Lut, Straight, Tf};
@ -105,6 +106,23 @@ fn encode(ops: &[ColorOp]) -> Option<Vec<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::Affine { m, pre, post } => {
prog.push(9.0);
for r in m {
prog.extend(r);
}
prog.extend(pre);
prog.extend(post);
}
ColorOp::Pow(e) => {
prog.push(10.0);
prog.extend(e);
}
ColorOp::LogAffine { base, log_slope, log_offset, lin_slope, lin_offset, inverse } => {
prog.extend([11.0, *base]);
prog.extend(log_slope.iter().chain(log_offset).chain(lin_slope).chain(lin_offset));
prog.extend([*inverse as u32 as f32, f64::MIN_POSITIVE.ln() as f32]);
}
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![];

View File

@ -0,0 +1,284 @@
//! GPU effects, particle render passes (kernels in `shaders/fx_particles.wgsl`, every entry
//! point prefixed `fxp_`): CC Particle World, CC Particle Systems II, Particle Playground, CC
//! Ball Action, CC Pixel Polly, CC Scatterize and Curl Noise; plus Foam's textured discs,
//! reflections and flow-map preview (`fx_sim` draws Foam's bubble sprites).
//!
//! As in `fx_sim`, each frame's plan is built on the CPU and shared with the CPU effect, which
//! stays the oracle: the particle systems' sprites (`effects::sprite_plan`, from the stepped
//! simulation — on the GPU's `ParticleSim` when the host offers it — sorted as the CPU sorts
//! them), Particle Playground's dots, text strokes and Layer Map frames
//! (`effects::playground_plan`), and the plans of the effects that read the layer's pixels to
//! place or colour their particles (`effects::pixel_plan`: CC Ball Action's balls, CC
//! Scatterize's pixel dots, CC Pixel Polly's shards; the frame is read back once). Rasterising
//! runs here: items are tiled like `fx_sim`'s and `fxp_raster` composites each pixel's items in
//! the CPU's order over a base image, so a plan too large for one item table is drawn in
//! several passes, each over the previous one ([`Chunked`]; the per-pixel arithmetic is the
//! same). Particle Playground's Layer Map frames are packed into one atlas texture.
//!
//! Curl Noise runs fully here: the fBm potential, its curl (central differences, edges
//! clamped) and the backwards streamline trace, each a kernel mirroring the CPU's passes.
use effectcraft_effects::{Buf, EffectCtx, PixelPlan};
use effectcraft_raster::Image;
use crate::context::{Enc, GpuImage, Params};
use crate::effects::GBuf;
use crate::fx_sim::{Items, piece_pass, sprite_layer, sprites};
/// Compute entry points in `fx_particles.wgsl`.
pub(crate) const KERNELS: &[&str] = &["fxp_raster", "fxp_under", "fxp_curl_pot", "fxp_curl_vel", "fxp_curl_view", "fxp_curl_flow"];
/// Effect ids implemented here.
pub(crate) const IDS: &[&str] = &[
"ec.sim.ccparticleworld",
"ec.sim.ccparticlesystems2",
"ec.sim.particleplayground",
"ec.sim.ccballaction",
"ec.sim.ccpixelpolly",
"ec.sim.ccscatterize",
"ec.noise.curlnoise",
];
/// 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 = Buf { img: Image::new(0, 0), offset: b.offset, scale: b.scale };
match id {
"ec.sim.ccparticleworld" | "ec.sim.ccparticlesystems2" => match effectcraft_effects::sprite_plan(id, ctx, &geo) {
Some(plan) => sprites(e, b, &plan),
None => Some(b),
},
"ec.sim.particleplayground" => playground(e, ctx, b, &geo),
"ec.noise.curlnoise" => curl_noise(e, ctx, b),
_ => {
// The plan reads the layer's pixels.
let img = e.download(&b.img)?;
let cpu = Buf { img, offset: b.offset, scale: b.scale };
match effectcraft_effects::pixel_plan(id, ctx, &cpu) {
Some(PixelPlan::Sprites(plan)) => sprites(e, b, &plan),
Some(PixelPlan::Pieces(plan)) => piece_pass(e, b, &plan),
None => Some(b),
}
}
}
}
// ---------------------------------------------------------------- chunked raster
/// Items rasterised by `fxp_raster` over a base image (transparent at first), flushed into a
/// new pass whenever the item table would outgrow one kernel's data. `kind`: 0 sprites, 1
/// Layer Map frames, 2 Foam texture discs, 3 Foam reflection discs, 4 pieces without a back
/// texture; `mode`: 0 over, 1 add, 2 Foam's disc compositing.
pub(crate) struct Chunked {
kind: u32,
mode: u32,
stride: usize,
w: u32,
h: u32,
items: Items,
base: Option<GpuImage>,
ok: bool,
/// Kernel parameters besides `u[0]` and `u[1].x`.
pub p: Params,
}
impl Chunked {
/// `stride` = floats per item, its four bounds included.
pub(crate) fn new(kind: u32, mode: u32, stride: usize, w: u32, h: u32) -> Chunked {
Chunked { kind, mode, stride, w, h, items: Items::new(stride, w, h), base: None, ok: true, p: Params::default() }
}
/// Start from `base` instead of a transparent frame.
pub(crate) fn over(mut self, base: GpuImage) -> Chunked {
self.base = Some(base);
self
}
/// Add an item (`rec` without its bounds) covering float bounds `bb`; `src` is the texture
/// the items sample.
pub(crate) fn push(&mut self, e: &mut Enc, src: &GpuImage, bb: Option<[f32; 4]>, rec: &[f32]) {
if !self.items.fits(bb) && !self.items.is_empty() {
self.flush(e, src);
}
self.items.push(bb, rec);
}
fn flush(&mut self, e: &mut Enc, src: &GpuImage) {
let items = std::mem::replace(&mut self.items, Items::new(self.stride, self.w, self.h));
let tx = items.tx as u32;
let Some((data, items_at, stride)) = items.data() else {
self.ok = false;
return;
};
let buf = e.data(&data);
let base = self.base.take().unwrap_or_else(|| e.zeros(self.w, self.h));
let mut p = self.p;
p.u[0] = [self.kind, self.mode, items_at, stride];
p.u[1][0] = tx;
let out = e.scratch(self.w, self.h);
e.pixels("fxp_raster", &p, src, Some(&base), &out, Some(&buf));
self.base = Some(out);
}
/// The result (`None` when an item alone outgrew a kernel's data).
pub(crate) fn finish(mut self, e: &mut Enc, src: &GpuImage) -> Option<GpuImage> {
if !self.items.is_empty() {
self.flush(e, src);
}
if !self.ok {
return None;
}
Some(match self.base {
Some(b) => b,
None => e.image(self.w, self.h),
})
}
}
/// Bounds of a disc drawn over `[floor(x − r − 1), ceil(x + r + 1))` (sim2::foam_disc), as
/// inclusive float bounds for [`Items`].
fn disc_bounds(x: f64, y: f64, r: f64) -> Option<[f32; 4]> {
(r > 0.0).then(|| [(x - r - 1.0) as f32, (y - r - 1.0) as f32, ((x + r + 1.0).ceil() - 1.0) as f32, ((y + r + 1.0).ceil() - 1.0) as f32])
}
// ---------------------------------------------------------------- Foam's extras
/// Foam: the bubble sprites, then the User Defined texture's discs, the Environment Map's
/// reflections and the flow-map preview (sim2::FoamPlan::finish).
pub(crate) fn foam(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let (w, h) = (b.img.width, b.img.height);
// The plan fits other layers to the buffer: it needs the buffer's size (not its pixels).
let geo = Buf { img: Image::new(w, h), offset: b.offset, scale: b.scale };
let plan = effectcraft_effects::foam_full_plan(ctx, &geo);
let mut fx = sprite_layer(e, &b.img, &plan.sprites)?;
if let Some((tex, discs)) = &plan.user {
let t = e.g.upload_image(&tex.buf.img)?;
let mut ch = Chunked::new(2, 2, 16, w, h).over(fx);
for d in discs {
let (s, c) = d.rot.sin_cos();
let rec = [2.0, d.x as f32, d.y as f32, d.r, s, c, d.fade, tex.size[0] as f32, tex.size[1] as f32, tex.buf.scale as f32];
let rec = [&rec[..], &[tex.buf.offset[0] as f32, tex.buf.offset[1] as f32]].concat();
ch.push(e, &t, disc_bounds(d.x, d.y, d.r as f64), &rec);
}
fx = ch.finish(e, &t)?;
}
if let Some(env) = &plan.env {
let t = e.g.upload_image(&env.img)?;
let mut ch = Chunked::new(3, 2, 12, w, h).over(fx);
for d in &env.discs {
let rec = [3.0, d.x as f32, d.y as f32, d.r, env.conv as f32, env.strength, w as f32, h as f32];
ch.push(e, &t, disc_bounds(d.x, d.y, d.r as f64), &rec);
}
fx = ch.finish(e, &t)?;
}
if let Some(fm) = &plan.flow {
let f = e.g.upload_image(fm)?;
let out = e.scratch(w, h);
e.pixels("fxp_under", &Params::default(), &fx, Some(&f), &out, None);
fx = out;
}
Some(GBuf { img: fx, ..b })
}
// ---------------------------------------------------------------- Particle Playground
fn playground(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &Buf) -> Option<GBuf> {
let plan = effectcraft_effects::playground_plan(ctx, geo);
let (w, h) = (b.img.width, b.img.height);
let mut fx = sprite_layer(e, &b.img, &plan.sprites)?;
if !plan.blits.is_empty() {
// The Layer Map frames, packed into one atlas on a grid of equal cells.
let cw = plan.frames.iter().map(|f| f.buf.img.width).max().unwrap_or(1).max(1);
let chh = plan.frames.iter().map(|f| f.buf.img.height).max().unwrap_or(1).max(1);
let max = e.g.max_dim;
let cols = (max / cw).max(1).min(plan.frames.len() as u32);
let rows = (plan.frames.len() as u32).div_ceil(cols);
if !e.g.fits(cw * cols, chh * rows) {
return None;
}
let mut atlas = Image::new(cw * cols, chh * rows);
let mut at = vec![];
for (i, f) in plan.frames.iter().enumerate() {
let (ax, ay) = ((i as u32 % cols) * cw, (i as u32 / cols) * chh);
let img = &f.buf.img;
for y in 0..img.height {
let row = &img.data[(y * img.width) as usize..((y + 1) * img.width) as usize];
let o = ((ay + y) * atlas.width + ax) as usize;
atlas.data[o..o + row.len()].copy_from_slice(row);
}
at.push((ax, ay));
}
let t = e.g.upload_image(&atlas)?;
let mut ch = Chunked::new(1, 0, 24, w, h).over(fx);
for q in &plan.blits {
let lp = &plan.frames[q.frame];
if q.scale[0] == 0.0 || q.scale[1] == 0.0 {
continue;
}
// sim3::blit_layer's pixel range.
let (fw, fh) = (lp.size[0] * q.scale[0].abs() * b.scale, lp.size[1] * q.scale[1].abs() * b.scale);
let r = (fw * fw + fh * fh).sqrt() * 0.5 + 1.0;
let bb = [(q.x - r) as f32, (q.y - r) as f32, ((q.x + r).ceil() - 1.0) as f32, ((q.y + r).ceil() - 1.0) as f32];
let (sa, ca) = q.angle.to_radians().sin_cos();
let (ax, ay) = at[q.frame];
let rec = [
1.0,
q.x as f32,
q.y as f32,
sa as f32,
ca as f32,
q.scale[0] as f32,
q.scale[1] as f32,
b.scale as f32,
q.alpha,
lp.size[0] as f32,
lp.size[1] as f32,
lp.buf.scale as f32,
lp.buf.offset[0] as f32,
lp.buf.offset[1] as f32,
ax as f32,
ay as f32,
lp.buf.img.width as f32,
lp.buf.img.height as f32,
];
ch.push(e, &t, Some(bb), &rec);
}
fx = ch.finish(e, &t)?;
}
// The particles replace the layer's own pixels.
Some(GBuf { img: fx, ..b })
}
// ---------------------------------------------------------------- Curl Noise
/// noise2::curl_noise: the potential, its curl and the streamline trace.
fn curl_noise(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let pr = ctx.params;
let amount = pr.f("displacementAmount") * b.scale;
let view = pr.e("view");
if amount.abs() < 1e-6 && view == 0 {
return Some(b);
}
let scale = (pr.f("scale").max(1.0) * b.scale) as f32;
let rot = pr.f("rotation").to_radians() as f32;
let off = pr.v2("offset");
let (ox, oy) = ((off[0] * b.scale + b.offset[0]) as f32, (off[1] * b.scale + b.offset[1]) as f32);
let evo = (pr.f("evolution") / 360.0) as f32;
let seed = pr.f("randomSeed") as i64 as u32 ^ 0x51_7cc1;
let steps = pr.f("steps").clamp(1.0, 16.0) as usize;
let (w, h) = (b.img.width, b.img.height);
let mut p = Params::default();
p.u[0] = [seed, steps as u32, (pr.e("edgeBehavior") == 1) as u32, 0];
p.f[0] = [ox, oy, scale, pr.f("complexity") as f32];
p.f[1] = [rot.cos(), rot.sin(), evo * 4.0, amount as f32 / steps as f32];
let pot = e.scratch(w, h);
e.pixels("fxp_curl_pot", &p, &b.img, None, &pot, None);
let vel = e.scratch(w, h);
e.pixels("fxp_curl_vel", &p, &pot, None, &vel, None);
let out = e.scratch(w, h);
if view == 1 {
e.pixels("fxp_curl_view", &p, &vel, None, &out, None);
} else {
e.pixels("fxp_curl_flow", &p, &b.img, Some(&vel), &out, None);
}
Some(GBuf { img: out, ..b })
}

View File

@ -12,8 +12,11 @@
//! (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.
//! effects read the frame back once. Shatter's wireframe views draw their lines as sprites
//! (`effects::pixel_plan`); Foam's User Defined texture, Environment Map and flow-map preview
//! are drawn by `fx_particles` over the bubble sprites (`effects::foam_full_plan`). Sprite
//! layers and back-less piece plans too large for one item table are drawn in several passes
//! (`fx_particles::Chunked`).
use effectcraft_effects::{Acc, EffectCtx, PieceTex, Post, Shape, SpritePlan, Tint};
use effectcraft_raster::Image;
@ -58,6 +61,7 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<G
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.foam" => crate::fx_particles::foam(e, ctx, b),
"ec.sim.cchair" => match effectcraft_effects::sprite_plan(id, ctx, &geo) {
Some(plan) => sprites(e, b, &plan),
None => Some(b),
@ -72,37 +76,56 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<G
// ---------------------------------------------------------------- tiled item raster
/// Items with their integer pixel bounds, binned into tiles (sim::raster's bands, finer).
struct Items {
pub(crate) struct Items {
stride: usize,
recs: Vec<f32>,
w: i32,
h: i32,
tiles: Vec<Vec<u32>>,
tx: i32,
pub(crate) tx: i32,
/// Item indices in all tiles.
n_idx: usize,
}
impl Items {
fn new(stride: usize, w: u32, h: u32) -> Items {
pub(crate) 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 }
Items { stride, recs: vec![], w, h, tiles: vec![vec![]; (tx * ty).max(0) as usize], tx, n_idx: 0 }
}
/// 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 };
/// The integer pixel bounds of float bounds `bb` (`None` = skipped, as sim::raster).
fn bounds(&self, bb: Option<[f32; 4]>) -> Option<[i32; 4]> {
let b = bb?;
if !(b[0].is_finite() && b[1].is_finite() && b[2].is_finite() && b[3].is_finite()) {
return;
return None;
}
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;
return None;
}
Some([x0, y0, x1, y1])
}
pub(crate) fn is_empty(&self) -> bool {
self.recs.is_empty()
}
/// Whether adding an item covering `bb` keeps the kernel data within [`MAX_FLOATS`].
pub(crate) fn fits(&self, bb: Option<[f32; 4]>) -> bool {
let Some([x0, y0, x1, y1]) = self.bounds(bb) else { return true };
let tiles = ((x1 / TILE - x0 / TILE + 1) * (y1 / TILE - y0 / TILE + 1)) as usize;
self.tiles.len() + 1 + self.n_idx + tiles + self.recs.len() + self.stride <= MAX_FLOATS
}
/// Add an item (`rec` = its fields, without the bounds) covering float bounds `bb`
/// (`None` or empty = skipped, as sim::raster).
pub(crate) fn push(&mut self, bb: Option<[f32; 4]>, rec: &[f32]) {
let Some([x0, y0, x1, y1]) = self.bounds(bb) else { return };
let i = (self.recs.len() / self.stride) as u32;
let start = self.recs.len();
self.recs.extend_from_slice(rec);
@ -111,12 +134,13 @@ impl Items {
for ty in y0 / TILE..=y1 / TILE {
for tx in x0 / TILE..=x1 / TILE {
self.tiles[(ty * self.tx + tx) as usize].push(i);
self.n_idx += 1;
}
}
}
/// Kernel data: tile offsets (tiles + 1), item indices, items. `None` when too large.
fn data(self) -> Option<(Vec<f32>, u32, u32)> {
pub(crate) 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();
@ -167,9 +191,32 @@ fn sprite_bbox(x: f32, y: f32, r: f32, c3: f32, shape: Shape) -> Option<[f32; 4]
})
}
fn sprites(e: &mut Enc, b: GBuf, plan: &SpritePlan) -> Option<GBuf> {
/// Draw a sprite plan and meet the layer `b` (sim::SpritePlan::finish).
pub(crate) 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);
let fx = sprite_layer(e, &b.img, plan)?;
let mut p = Params::default();
match plan.post {
Post::Combine(m) => p.u[0] = [0, m, 0, 0],
Post::Replace => return Some(GBuf { img: fx, ..b }),
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 })
}
/// sim::splat of a sprite plan over a transparent frame of `src`'s size (tints sample `src`),
/// in as many passes as the item tables need.
pub(crate) fn sprite_layer(e: &mut Enc, src: &GpuImage, plan: &SpritePlan) -> Option<GpuImage> {
let (w, h) = (src.width, src.height);
// Without tints the record stops after the (zero) tint kind.
let stride = if plan.tints.is_empty() { 16 } else { 24 };
let mut ch = crate::fx_particles::Chunked::new(0, (plan.acc == Acc::Add) as u32, stride, 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),
@ -191,22 +238,9 @@ fn sprites(e: &mut Enc, b: GBuf, plan: &SpritePlan) -> Option<GBuf> {
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);
ch.push(e, src, sprite_bbox(s.x, s.y, s.r, s.c[3], s.shape), &rec[..stride - 4]);
}
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 })
ch.finish(e, src)
}
// ---------------------------------------------------------------- CC Bubbles / Mr. Mercury
@ -329,10 +363,22 @@ 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 };
if id == "ec.sim.shatter" {
// The rendered pieces or the wireframe views' lines.
return match effectcraft_effects::pixel_plan(id, ctx, &cpu)? {
effectcraft_effects::PixelPlan::Pieces(plan) => piece_pass(e, b, &plan),
effectcraft_effects::PixelPlan::Sprites(plan) => sprites(e, b, &plan),
};
}
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.
// Card Wipe before anything moves: the layer as is.
return (id == "ec.transition.cardwipe").then_some(b);
};
piece_pass(e, b, &plan)
}
/// Draw a piece plan over a transparent frame of `b`'s size (sim2::PiecePlan::finish).
pub(crate) fn piece_pass(e: &mut Enc, b: GBuf, plan: &effectcraft_effects::PiecePlan) -> Option<GBuf> {
let (w, h) = (b.img.width, b.img.height);
let tex = |t: &PieceTex| -> Option<GpuImage> {
match t {
@ -346,6 +392,9 @@ fn pieces(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
None => None,
};
let mut items = Items::new(48, w, h);
// Without a back texture the pieces can be drawn in passes over a base (CC Pixel Polly's
// fine grids).
let mut ch = back.is_none().then(|| crate::fx_particles::Chunked::new(4, 0, 48, w, h));
for q in &plan.pieces {
let m = q.inv;
let mut rec = vec![11.0];
@ -365,7 +414,13 @@ fn pieces(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
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);
match &mut ch {
Some(ch) => ch.push(e, &front, Some(q.bbox), &rec),
None => items.push(Some(q.bbox), &rec),
}
}
if let Some(ch) = ch {
return Some(GBuf { img: ch.finish(e, &front)?, ..b });
}
let mut p = Params::default();
p.u[1][1] = back.is_some() as u32;

View File

@ -10,8 +10,9 @@
//! from its frame. Timewarp's motion vectors (block matching and smoothing,
//! `effectcraft_raster::flow`) are estimated on the CPU (`effectcraft_effects::timewarp_plan`);
//! the frame building (Whole Frames, Frame Mix, Pixel Motion's warp along the vectors with its
//! error fallback, the shutter samples' average) runs on the GPU. Timewarp with a Matte Layer
//! renders on the CPU (`catalog::gpu_supported`).
//! error fallback, the shutter samples' average) runs on the GPU. With a Matte Layer the plan
//! also carries the matte-masked frames (and the foreground's and background's own vectors);
//! the GPU builds both layers and shows them as Show asks (`ftm_layer`).
use effectcraft_effects::util::{fit_layer, unpremul};
use effectcraft_effects::{Buf, EffectCtx, TwStep};
@ -21,7 +22,7 @@ use crate::context::{Enc, GpuImage, Params};
use crate::effects::GBuf;
/// Compute entry points in `fx_time.wgsl`.
pub(crate) const KERNELS: &[&str] = &["ftm_acc", "ftm_mix", "ftm_difference", "ftm_displace", "ftm_motion"];
pub(crate) const KERNELS: &[&str] = &["ftm_acc", "ftm_mix", "ftm_layer", "ftm_difference", "ftm_displace", "ftm_motion"];
/// Effect ids implemented here.
pub(crate) const IDS: &[&str] =
@ -177,11 +178,19 @@ fn timewarp(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
if ctx.env.host.is_none() {
return Some(b);
}
// A Matte Layer splits foreground and background: the CPU's path.
if ctx.params.get("matteLayer").and_then(Value::as_layer).is_some() {
return None;
}
let Some(plan) = effectcraft_effects::timewarp_plan(ctx) else { return Some(b) };
// Pixel Motion's in-between frame of frames a, b along motion field `flow`.
let motion = |e: &mut Enc, a: usize, bi: usize, w: f32, flow: usize| -> Option<GpuImage> {
let pa = e.g.upload_image(&effectcraft_effects::timewarp_crop(&plan.frames[a], &plan.pm))?;
let pb = e.g.upload_image(&effectcraft_effects::timewarp_crop(&plan.frames[bi], &plan.pm))?;
let f = &plan.flows[flow];
let v: Vec<f32> = f.v.iter().flat_map(|q| [q[0], q[1]]).collect();
let data = e.data(&v);
let mut p = Params::default();
p.u[0] = [if f.v.is_empty() { 0 } else { f.cols as u32 }, f.rows as u32, plan.pm.extreme as u32, plan.pm.build_from_one as u32];
p.f[0] = [f.block as f32, w, plan.pm.error_threshold, 0.0];
Some(kernel(e, "ftm_motion", &p, &pa, Some(&pb), Some(&data)))
};
let mut up: Vec<Option<GpuImage>> = vec![None; plan.frames.len()];
let mut frame = |e: &mut Enc, i: usize| -> Option<GpuImage> {
if up[i].is_none() {
@ -199,16 +208,13 @@ fn timewarp(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
p.f[0][0] = w;
kernel(e, "ftm_mix", &p, &a, Some(&bb), None)
}
TwStep::Motion { a, b: bi, w, flow } => {
let pa = e.g.upload_image(&effectcraft_effects::timewarp_crop(&plan.frames[a], &plan.pm))?;
let pb = e.g.upload_image(&effectcraft_effects::timewarp_crop(&plan.frames[bi], &plan.pm))?;
let f = &plan.flows[flow];
let v: Vec<f32> = f.v.iter().flat_map(|q| [q[0], q[1]]).collect();
let data = e.data(&v);
TwStep::Motion { a, b: bi, w, flow } => motion(e, a, bi, w, flow)?,
TwStep::Layered { fg, bg, w, show } => {
let f = motion(e, fg[0], fg[1], w, fg[2])?;
let g = motion(e, bg[0], bg[1], w, bg[2])?;
let mut p = Params::default();
p.u[0] = [if f.v.is_empty() { 0 } else { f.cols as u32 }, f.rows as u32, plan.pm.extreme as u32, plan.pm.build_from_one as u32];
p.f[0] = [f.block as f32, w, plan.pm.error_threshold, 0.0];
kernel(e, "ftm_motion", &p, &pa, Some(&pb), Some(&data))
p.u[0][0] = show;
kernel(e, "ftm_layer", &p, &f, Some(&g), None)
}
};
imgs.push(img);

View File

@ -6,8 +6,10 @@
//! Smear's moved outline, Reshape's outline correspondence, Bend It's arc geometry) and goes to
//! the kernels as parameters or tables; the kernels do the per-pixel work in f32. Warp inverts
//! its forward map per pixel with the CPU's Newton iterations, relative to the warp centre so
//! f32 keeps sub-pixel precision; Bend It's arc distance is evaluated in a cancellation-free
//! form so large radii (small bends) keep their precision too.
//! f32 keeps sub-pixel precision (strong Fisheye / Twist bends, where the iterations are chaotic
//! near the fold, sample the CPU's f64 inverse map, `effects::warp_inverse_map`); Bend It's arc
//! distance is evaluated in a cancellation-free form so large radii (small bends) keep their
//! precision too.
use effectcraft_effects::{Buf, EffectCtx, Image};
@ -17,6 +19,7 @@ use crate::effects::{GBuf, gaussian_blur};
/// Compute entry points in `fx_warp.wgsl`.
pub(crate) const KERNELS: &[&str] = &[
"fxw_warp",
"fxw_remap",
"fxw_bendit",
"fxw_pageturn",
"fxw_smear",
@ -93,6 +96,16 @@ fn warp(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let pad = ((k.abs() + hd.abs() + vd.abs()) * 0.6 * hx.max(hy)).ceil().min(4096.0);
b.pad(e, pad as u32 + 1)?;
}
// Fisheye / Twist (a crease at the unit circle) bent past 50 % or with Horizontal /
// Vertical Distortion: Newton's inverse wanders chaotically before converging near the
// fold, so f32 would land on other pixels than the CPU's f64. The inverse map is solved on
// the CPU (the CPU effect's own solver) and sampled here.
if matches!(style, 11 | 14) && (k.abs() > 0.5 || hd != 0.0 || vd != 0.0) {
let geo = Buf { img: Image::new(0, 0), offset: b.offset, scale: b.scale };
let map = e.g.upload_image(&effectcraft_effects::warp_inverse_map(ctx, &geo, b.img.width, b.img.height))?;
b.img = run(e, "fxw_remap", &Params::default(), &b.img, Some(&map), None);
return Some(b);
}
let c = b.to_px([ctx.layer_size[0] * 0.5, ctx.layer_size[1] * 0.5]);
let mut p = Params::default();
p.u[0] = [0, style.min(14), vertical as u32, 0];

View File

@ -23,14 +23,15 @@
//! 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_depth`, `fx_distort`, `fx_extra`, `fx_gen2`, `fx_generate`, `fx_key`,
//! `fx_light`, `fx_lut`, `fx_noise`, `fx_pixel2`, `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`).
//! `fx_light`, `fx_lut`, `fx_noise`, `fx_particles`, `fx_pixel2`, `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`), simulations keep their state on the CPU and rasterise its per-frame plan here
//! (`fx_sim`), and so do the geometry generators (`fx_gen2`: bolts, strokes, glyphs, audio
//! marks, waves).
//! (`fx_sim`, `fx_particles`), and so do the geometry generators (`fx_gen2`: bolts, strokes,
//! glyphs, audio marks, waves).
//!
//! 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
@ -66,6 +67,7 @@ mod fx_key;
mod fx_light;
mod fx_lut;
mod fx_noise;
mod fx_particles;
mod fx_pixel2;
mod fx_sim;
mod fx_stylize;
@ -337,6 +339,8 @@ mod tests_fx_lut;
#[cfg(test)]
mod tests_fx_noise;
#[cfg(test)]
mod tests_fx_particles;
#[cfg(test)]
mod tests_fx_pixel2;
#[cfg(test)]
mod tests_fx_sim;

View File

@ -444,6 +444,47 @@ fn fxl_run(c0: vec3<f32>) -> vec3<f32> {
c = fxl_file(o, sh, c, interp, inv);
pc += 5u;
}
case 9u: {
// Affine: matrix × (c + pre) + post (a config's MatrixTransform, RangeTransform).
let r0 = fxl_d3(pc + 1u);
let r1 = fxl_d3(pc + 4u);
let r2 = fxl_d3(pc + 7u);
let d = c + fxl_d3(pc + 10u);
c = vec3<f32>(r0.x * d.x + r0.y * d.y + r0.z * d.z, r1.x * d.x + r1.y * d.y + r1.z * d.z, r2.x * d.x + r2.y * d.y + r2.z * d.z)
+ fxl_d3(pc + 13u);
pc += 16u;
}
case 10u: {
// ExponentTransform: max(c, 0) ^ p.
let e = fxl_d3(pc + 1u);
c = vec3<f32>(powz(max(c.x, 0.0), e.x), powz(max(c.y, 0.0), e.y), powz(max(c.z, 0.0), e.z));
pc += 4u;
}
case 11u: {
// Log / LogAffineTransform: base, log slope, log offset, lin slope, lin offset,
// inverse, ln of f64's smallest normal (the CPU's floor).
let base = data[pc + 1u];
let ls = fxl_d3(pc + 2u);
let lo = fxl_d3(pc + 5u);
let lis = fxl_d3(pc + 8u);
let lio = fxl_d3(pc + 11u);
let lnb = log(base);
if (data[pc + 14u] != 0.0) {
for (var i = 0u; i < 3u; i++) {
c[i] = (exp((c[i] - lo[i]) / ls[i] * lnb) - lio[i]) / lis[i];
}
} else {
for (var i = 0u; i < 3u; i++) {
let v = lis[i] * c[i] + lio[i];
var ln = data[pc + 15u];
if (v >= 1.1754944e-38) {
ln = log(v);
}
c[i] = ls[i] * (ln / lnb) + lo[i];
}
}
pc += 16u;
}
default: {
return c;
}

View File

@ -0,0 +1,255 @@
// ---------------------------------------------------------------- particle render passes (fx_particles.rs)
//
// fxp_raster composites tiled items (the layout of fxm_raster: data = tile offsets, item
// indices, then the records, u[0].w floats each, the last four the item's integer pixel bounds)
// over a base image (aux), so large plans draw in several passes. Every shading step mirrors
// effects::sim / sim2 / sim3.
// One texel of a Layer Map frame in the atlas (src): transparent outside the frame, as
// Image::sample_bilinear reads outside an image. Record fields 14..17 = atlas x, y, frame w, h.
fn fxp_atlas_get(at: u32, x: i32, y: i32) -> vec4<f32> {
if (x < 0 || y < 0 || x >= i32(data[at + 16u]) || y >= i32(data[at + 17u])) {
return vec4<f32>(0.0);
}
return textureLoad(src, vec2<i32>(x + i32(data[at + 14u]), y + i32(data[at + 15u])), 0);
}
// Image::sample_bilinear of a Layer Map frame.
fn fxp_atlas_bilinear(at: u32, x: f32, y: f32) -> 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 = fxp_atlas_get(at, xi, yi);
let b = fxp_atlas_get(at, xi + 1, yi);
let c = fxp_atlas_get(at, xi, yi + 1);
let d = fxp_atlas_get(at, xi + 1, yi + 1);
let top = a + (b - a) * tx;
let bot = c + (d - c) * tx;
return top + (bot - top) * ty;
}
// sim3::blit_layer at a pixel centre. Record: kind, x, y, sin, cos (rotation), scale x, scale y,
// buffer scale, alpha, frame layer size w, h, frame buffer scale, offset x, y, atlas x, y,
// frame pixels w, h.
fn fxp_blit(at: u32, x: f32, y: f32) -> FxmShade {
let bs = data[at + 7u];
let dx = (x - data[at + 1u]) / bs;
let dy = (y - data[at + 2u]) / bs;
let sa = data[at + 3u];
let ca = data[at + 4u];
let sw = data[at + 9u];
let sh = data[at + 10u];
let u = (dx * ca + dy * sa) / data[at + 5u] + sw * 0.5;
let v = (-dx * sa + dy * ca) / data[at + 6u] + sh * 0.5;
if (u < 0.0 || v < 0.0 || u >= sw || v >= sh) {
return fxm_none();
}
let fs = data[at + 11u];
let s = fxp_atlas_bilinear(at, u * fs + data[at + 12u], v * fs + data[at + 13u]);
if (s.w <= 0.0) {
return fxm_none();
}
return FxmShade(true, s * data[at + 8u]);
}
// sim2::foam_disc's coverage and disc coordinates (u, v clamped to −1..1) at a pixel centre for
// the disc (x, y, r) in record fields 1..3: (u, v, coverage); coverage ≤ 0 = outside.
fn fxp_disc(at: u32, x: f32, y: f32) -> vec3<f32> {
let r = data[at + 3u];
let u = (x - data[at + 1u]) / r;
let v = (y - data[at + 2u]) / r;
let d = sqrt(u * u + v * v);
let cov = clamp((1.0 - d) * r + 0.5, 0.0, 1.0);
return vec3<f32>(clamp(u, -1.0, 1.0), clamp(v, -1.0, 1.0), cov);
}
// Foam's User Defined texture filling a bubble (src = the texture layer's buffer). Record:
// kind, x, y, r, sin, cos (rotation), fade, texture layer size w, h, buffer scale, offset x, y.
fn fxp_foam_tex(at: u32, x: f32, y: f32) -> FxmShade {
let q = fxp_disc(at, x, y);
if (q.z <= 0.0) {
return fxm_none();
}
let sr = data[at + 4u];
let cr = data[at + 5u];
let tu = q.x * cr + q.y * sr;
let tv = -q.x * sr + q.y * cr;
let tx = (tu * 0.5 + 0.5) * data[at + 7u];
let ty = (tv * 0.5 + 0.5) * data[at + 8u];
let ts = data[at + 9u];
let px = sample_bilinear(src, tx * ts + data[at + 10u], ty * ts + data[at + 11u]);
return FxmShade(true, px * data[at + 6u] * q.z);
}
// Foam's Environment Map reflection on a bubble (src = the map fitted to the buffer). Record:
// kind, x, y, r, convergence, strength, buffer w, h.
fn fxp_foam_env(at: u32, x: f32, y: f32) -> FxmShade {
let q = fxp_disc(at, x, y);
if (q.z <= 0.0) {
return fxm_none();
}
let bx = data[at + 1u];
let by = data[at + 2u];
let conv = data[at + 4u];
var ex = bx;
var ey = by;
if (conv < 1.0) {
ex = bx * conv + (q.x * 0.5 + 0.5) * data[at + 6u] * (1.0 - conv);
ey = by * conv + (q.y * 0.5 + 0.5) * data[at + 7u] * (1.0 - conv);
}
let e = sample_bilinear(src, ex, ey);
let rim = min(q.x * q.x + q.y * q.y, 1.0);
let k = data[at + 5u] * (0.3 + 0.7 * rim);
return FxmShade(true, vec4<f32>(e.xyz * k, 0.0) * q.z);
}
// Items over the base (aux). u[0] = (kind: 0 sprites, 1 Layer Map frames, 2 Foam texture discs,
// 3 Foam reflection discs, 4 pieces without a back texture; mode: 0 over, 1 add, 2 Foam's disc
// compositing; items offset; item stride); u[1].x = tiles across.
@compute @workgroup_size(16, 16)
fn fxp_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 x = f32(p.x) + 0.5;
let y = f32(p.y) + 0.5;
var d = textureLoad(aux, p, 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 = fxp_blit(at, x, y);
}
case 2u: {
s = fxp_foam_tex(at, x, y);
}
case 3u: {
s = fxp_foam_env(at, x, y);
}
default: {
s = fxm_piece(at, x, y);
}
}
if (!s.ok) {
continue;
}
let c = s.c;
switch (P.u[0].y) {
case 1u: {
d = vec4<f32>(d.xyz + c.xyz, min(d.w + c.w, 1.0));
}
case 2u: {
let kk = 1.0 - c.w;
d = vec4<f32>(c.xyz + d.xyz * kk, min(c.w + d.w * kk, 1.0));
}
default: {
d = c + d * (1.0 - c.w);
}
}
}
textureStore(out, p, d);
}
// Foam's Draft + Flow Map view: the flow map (aux) at half strength under the bubbles (src).
@compute @workgroup_size(16, 16)
fn fxp_under(@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 o = textureLoad(src, p, 0);
let f = textureLoad(aux, p, 0);
textureStore(out, p, o + f * 0.5 * (1.0 - o.w));
}
// ---------------------------------------------------------------- Curl Noise (noise2::curl_noise)
//
// u[0] = (seed, flow steps, wrap edges); f[0] = (offset x, offset y, scale, complexity);
// f[1] = (cos rotation, sin rotation, evolution · 4, step length).
// The fBm potential at each pixel (in .x).
@compute @workgroup_size(16, 16)
fn fxp_curl_pot(@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 dx = f32(p.x) + 0.5 - P.f[0].x;
let dy = f32(p.y) + 0.5 - P.f[0].y;
let cr = P.f[1].x;
let sr = P.f[1].y;
let u = (dx * cr + dy * sr) / P.f[0].z;
let v = (-dx * sr + dy * cr) / P.f[0].z;
textureStore(out, p, vec4<f32>(fxs_fbm(u, v, P.f[1].z, P.u[0].x, P.f[0].w), 0.0, 0.0, 0.0));
}
// The curl of the potential (src): (∂ψ/∂y, −∂ψ/∂x) · scale, edges clamped.
@compute @workgroup_size(16, 16)
fn fxp_curl_vel(@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 gx = (tex_get_clamped(src, p.x + 1, p.y).x - tex_get_clamped(src, p.x - 1, p.y).x) * 0.5;
let gy = (tex_get_clamped(src, p.x, p.y + 1).x - tex_get_clamped(src, p.x, p.y - 1).x) * 0.5;
textureStore(out, p, vec4<f32>(gy * P.f[0].z, -gx * P.f[0].z, 0.0, 0.0));
}
// The Flow Field view of the velocities (src).
@compute @workgroup_size(16, 16)
fn fxp_curl_view(@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 v = textureLoad(src, p, 0);
textureStore(out, p, vec4<f32>(clamp(0.5 + v.x * 0.5, 0.0, 1.0), clamp(0.5 + v.y * 0.5, 0.0, 1.0), 0.5, 1.0));
}
// The layer (src) advected backwards along the velocities (aux).
@compute @workgroup_size(16, 16)
fn fxp_curl_flow(@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;
}
var sx = f32(p.x) + 0.5;
var sy = f32(p.y) + 0.5;
let dt = P.f[1].w;
for (var i = 0u; i < P.u[0].y; i++) {
let vel = sample_bilinear_clamped(aux, sx, sy);
sx -= vel.x * dt;
sy -= vel.y * dt;
}
if (P.u[0].z != 0u) {
let w = f32(dims.x);
let h = f32(dims.y);
sx = sx - w * floor(sx / w);
sy = sy - h * floor(sy / h);
}
textureStore(out, p, sample_bilinear_clamped(src, sx, sy));
}

View File

@ -35,6 +35,33 @@ fn ftm_mix(@builtin(global_invocation_id) gid: vec3<u32>) {
textureStore(out, p, a + (textureLoad(aux, p, 0) - a) * P.f[0].x);
}
// Timewarp's Matte Layer with Pixel Motion (time_fx::layered): src = foreground, aux =
// background; u[0].x = Show (1 foreground, 2 background, 3 the foreground's alpha as a matte,
// else foreground over background).
@compute @workgroup_size(16, 16)
fn ftm_layer(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!ftm_inside(p)) {
return;
}
let f = textureLoad(src, p, 0);
let b = textureLoad(aux, p, 0);
var o = f + b * (1.0 - f.w);
switch (P.u[0].x) {
case 1u: {
o = f;
}
case 2u: {
o = b;
}
case 3u: {
o = vec4<f32>(f.w, f.w, f.w, 1.0);
}
default: {}
}
textureStore(out, p, o);
}
// ---------------------------------------------------------------- Time Difference (time_fx::time_difference)
// src = the layer now, aux = the target. u[0] = (absolute difference, alpha channel);
// f[0].x = contrast gain.

View File

@ -530,3 +530,20 @@ fn fxw_cartoon(@builtin(global_invocation_id) gid: vec3<u32>) {
}
textureStore(out, p, vec4<f32>(o * a, a));
}
// Warp along an inverse map solved on the CPU (aux: source x, y, valid), util::remap without
// edge clamping.
@compute @workgroup_size(16, 16)
fn fxw_remap(@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 m = textureLoad(aux, p, 0);
var o = vec4<f32>(0.0);
if (m.z > 0.0) {
o = sample_bilinear(src, m.x, m.y);
}
textureStore(out, p, o);
}

View File

@ -173,3 +173,71 @@ fn lumetri_luts_and_looks() {
&[("basicCorrection/inputLut", e(1)), ("basicCorrection/inputLutFile", s(&cube_1d())), ("creative/look", e(1)), ("creative/lookFile", s(&cube_3d()))],
);
}
/// A custom config: exponent, matrix + offset, group (matrix, log), log-affine (inverse),
/// range, CDL and data spaces, and a display.
const CONFIG: &str = r#"ocio_profile_version: 2
roles:
scene_linear: lin
displays:
Monitor:
- !<View> {name: Video, colorspace: gamma22}
- !<View> {name: Log, colorspace: log2}
colorspaces:
- !<ColorSpace>
name: lin
- !<ColorSpace>
name: gamma22
to_scene_reference: !<ExponentTransform> {value: [2.2, 2.2, 2.2, 1]}
- !<ColorSpace>
name: half
from_reference: !<MatrixTransform> {matrix: [0.5, 0.1, 0, 0, 0, 0.5, 0, 0, 0.05, 0, 0.5, 0, 0, 0, 0, 1], offset: [0.1, 0.1, 0.1, 0]}
- !<ColorSpace>
name: log2
from_reference: !<GroupTransform>
children:
- !<MatrixTransform> {matrix: [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]}
- !<LogTransform> {base: 2}
- !<ColorSpace>
name: affine
to_reference: !<LogAffineTransform> {base: 10, logSideSlope: 0.5, logSideOffset: 0.2, linSideSlope: 2, linSideOffset: 0.01, direction: inverse}
- !<ColorSpace>
name: range
from_reference: !<RangeTransform> {min_in_value: 0.1, max_in_value: 0.9, min_out_value: 0, max_out_value: 1}
- !<ColorSpace>
name: graded
to_reference: !<CDLTransform> {slope: [1.2, 1, 0.9], offset: [0.02, 0, -0.01], power: [1.1, 0.9, 1], sat: 1.2}
- !<ColorSpace>
name: raw
isdata: true
"#;
#[test]
fn ocio_custom_config() {
let cfg = [("config", e(1)), ("configFile", s(CONFIG))];
for (a, z) in [
("gamma22", "lin"),
("lin", "gamma22"),
("gamma22", "half"),
("half", "lin"),
("lin", "log2"),
("log2", "graded"),
("affine", "lin"),
("lin", "affine"),
("range", "gamma22"),
("lin", "range"),
("graded", "lin"),
("raw", "half"),
("lin", "missing"),
] {
for dir in 0..2 {
let vals = [cfg.as_slice(), &[("sourceName", s(a)), ("destinationName", s(z)), ("direction", e(dir))]].concat();
effect_case("ec.color.ociocolorspace", &vals);
}
}
for (view, dir) in [("Video", 0), ("Log", 0), ("Log", 1)] {
let vals = [cfg.as_slice(), &[("sourceName", s("half")), ("displayName", s("Monitor")), ("viewName", s(view)), ("direction", e(dir))]].concat();
effect_case("ec.color.ociodisplay", &vals);
}
effect_case("ec.color.ociocolorspace", &[("config", e(1)), ("configFile", s("/missing.ocio"))]);
}

View File

@ -0,0 +1,145 @@
//! GPU particle render passes (`fx_particles`) vs the CPU effects (the oracle): the frame's
//! plan is shared, the raster runs on each side. Direct on a buffer (full and half resolution)
//! and composited at 8 and 32 bpc (`tests_fx_sim`'s harness).
use effectcraft_keyframe::Value;
use crate::tests::n;
use crate::tests_fx_sim::{case, composited, e, off, on};
fn v2(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
#[test]
fn particle_world_types_and_modes() {
for kind in 0..9 {
case(
"ec.sim.ccparticleworld",
&[("birthRate", n(1.5)), ("particle/particleType", e(kind)), ("particle/transferMode", e(kind % 4)), ("particle/birthSize", n(0.3))],
1.0,
0.0,
);
}
for anim in [1, 2, 5, 6, 8] {
case("ec.sim.ccparticleworld", &[("physics/animation", e(anim)), ("particle/particleType", e(1)), ("particle/sizeVariation", n(40.0))], 0.8, 0.0);
}
// Nothing born yet: the layer as is.
case("ec.sim.ccparticleworld", &[("birthRate", n(0.0))], 0.0, 0.0);
}
#[test]
fn particle_systems_ii_types_and_modes() {
for kind in 0..9 {
case("ec.sim.ccparticlesystems2", &[("birthRate", n(3.0)), ("particle/particleType", e(kind)), ("particle/transferMode", e(kind % 4))], 1.0, 0.0);
}
case("ec.sim.ccparticlesystems2", &[("physics/animation", e(4)), ("producer/radiusX", n(8.0)), ("physics/direction", n(70.0))], 1.3, 0.0);
}
/// A Particle Playground grid (cannon off) plus `more`.
fn grid(more: &[(&'static str, Value)]) -> Vec<(&'static str, Value)> {
let mut g = vec![
("cannonEnabled", off()),
("gridEnabled", on()),
("grid/gridPosition", v2(35.0, 22.0)),
("grid/gridWidth", n(40.0)),
("grid/gridHeight", n(20.0)),
("grid/particlesAcross", n(5.0)),
("grid/particlesDown", n(3.0)),
("grid/gridParticleRadius", n(3.0)),
("gravity/gravityForce", n(20.0)),
];
g.extend(more.iter().cloned());
g
}
#[test]
fn particle_playground() {
case("ec.sim.particleplayground", &[], 1.0, 0.0);
case(
"ec.sim.particleplayground",
&[("cannon/cannonPosition", v2(20.0, 40.0)), ("cannon/particlesPerSecond", n(150.0)), ("cannon/velocity", n(90.0)), ("gravity/gravityForce", n(40.0))],
1.2,
0.0,
);
case("ec.sim.particleplayground", &grid(&[]), 0.7, 0.0);
case(
"ec.sim.particleplayground",
&grid(&[("options/gridText", Value::Str("Ab C".into())), ("grid/gridParticleRadius", n(12.0)), ("options/autoOrientRotation", on())]),
0.6,
0.0,
);
case("ec.sim.particleplayground", &grid(&[("repel/repelForce", n(5.0)), ("repel/repelForceRadius", n(12.0))]), 0.9, 0.0);
composited(
"ec.sim.particleplayground",
&[
("cannonEnabled", off()),
("exploderEnabled", on()),
("layerExploder/explodeLayer", Value::Layer(Some(0))),
("layerExploder/radiusOfNewParticles", n(3.0)),
],
0.5,
0.0,
);
// Layer Map: every grid particle shows the other layer, at frames set by the offset type.
for (kind, offset) in [(0, 0.0), (2, 0.5), (3, 1.0)] {
composited(
"ec.sim.particleplayground",
&grid(&[
("layerMapEnabled", on()),
("layerMap/layerMapLayer", Value::Layer(Some(0))),
("layerMap/timeOffsetType", e(kind)),
("layerMap/timeOffset", n(offset)),
("grid/particlesAcross", n(3.0)),
("grid/particlesDown", n(2.0)),
]),
0.8,
0.0,
);
}
composited(
"ec.sim.particleplayground",
&grid(&[
("mapperEnabled", on()),
("persistentPropertyMapper/useLayerAsMap", Value::Layer(Some(0))),
("persistentPropertyMapper/mapRedTo", e(8)),
("persistentPropertyMapper/redMin", n(-50.0)),
("persistentPropertyMapper/redMax", n(50.0)),
]),
0.7,
0.0,
);
}
#[test]
fn ball_action_pixel_polly_scatterize() {
case("ec.sim.ccballaction", &[], 0.0, 0.0);
case(
"ec.sim.ccballaction",
&[("scatter", n(20.0)), ("rotationAxis", e(6)), ("rotation", n(40.0)), ("twistProperty", e(3)), ("twistAngle", n(90.0))],
0.0,
0.0,
);
case("ec.sim.ccballaction", &[("gridSpacing", n(2.0)), ("ballSize", n(80.0)), ("instabilityState", n(45.0)), ("twistProperty", e(2))], 0.0, 0.0);
for object in 0..4 {
case("ec.sim.ccpixelpolly", &[("object", e(object)), ("gridSpacing", n(6.0))], 0.6, 0.0);
}
case("ec.sim.ccpixelpolly", &[("enableDepthSort", off()), ("spinning", n(90.0)), ("gravity", n(2.0))], 1.0, 0.0);
case("ec.sim.ccpixelpolly", &[("startTime", n(1.0))], 0.5, 0.0);
case("ec.sim.ccscatterize", &[("scatter", n(10.0))], 0.0, 0.0);
case("ec.sim.ccscatterize", &[("rightTwist", n(60.0)), ("leftTwist", n(-30.0)), ("transferMode", e(1)), ("scatter", n(4.0))], 0.0, 0.0);
case("ec.sim.ccscatterize", &[], 0.0, 0.0);
}
#[test]
fn curl_noise() {
case("ec.noise.curlnoise", &[], 0.0, 0.0);
case("ec.noise.curlnoise", &[("view", e(1))], 0.0, 0.0);
case(
"ec.noise.curlnoise",
&[("edgeBehavior", e(1)), ("steps", n(8.0)), ("displacementAmount", n(60.0)), ("scale", n(20.0)), ("rotation", n(30.0)), ("evolution", n(90.0))],
0.0,
0.0,
);
case("ec.noise.curlnoise", &[("displacementAmount", n(0.0))], 0.0, 0.0);
}

View File

@ -10,20 +10,20 @@ use effectcraft_time::Tick;
use crate::tests::{Scene, c, check, compare_at, diff, gpu, n, opts, pattern, set};
fn e(v: u32) -> Value {
pub(crate) fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
pub(crate) fn on() -> Value {
Value::Bool(true)
}
fn off() -> Value {
pub(crate) 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) {
pub(crate) 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];
@ -53,7 +53,7 @@ fn direct(id: &str, vals: &[(&str, Value)], t: f64, allow: f64) {
/// 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) {
pub(crate) 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);
@ -72,7 +72,7 @@ fn composited(id: &str, vals: &[(&str, Value)], t: f64, allow: f64) {
}
}
fn case(id: &str, vals: &[(&str, Value)], t: f64, allow: f64) {
pub(crate) fn case(id: &str, vals: &[(&str, Value)], t: f64, allow: f64) {
direct(id, vals, t, allow);
composited(id, vals, t, allow);
}
@ -154,9 +154,31 @@ fn caustics() {
}
#[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);
fn shatter_wireframes_and_foam_extras() {
// Shatter's wireframe views (Wireframe Front View is the default) draw lines as sprites.
for view in 1..5 {
case("ec.sim.shatter", &[("view", e(view))], 1.0, 0.0);
}
case("ec.sim.shatter", &[("view", e(4)), ("force2/force2Radius", n(0.3)), ("shape/pattern", e(3))], 1.5, 0.0);
// Foam's User Defined texture, Environment Map and flow-map preview.
composited("ec.sim.foam", &[("view", e(2)), ("rendering/bubbleTexture", e(5)), ("rendering/bubbleTextureLayer", Value::Layer(Some(0)))], 1.0, 0.0);
composited(
"ec.sim.foam",
&[("view", e(2)), ("rendering/bubbleTexture", e(5)), ("rendering/bubbleTextureLayer", Value::Layer(Some(0))), ("rendering/bubbleOrientation", e(2))],
1.4,
0.0,
);
composited("ec.sim.foam", &[("view", e(2)), ("rendering/environmentMap", Value::Layer(Some(0))), ("rendering/reflectionStrength", n(0.5))], 1.2, 0.0);
composited(
"ec.sim.foam",
&[
("view", e(2)),
("rendering/environmentMap", Value::Layer(Some(0))),
("rendering/reflectionStrength", n(0.8)),
("rendering/reflectionConvergence", n(1.0)),
],
1.2,
0.0,
);
composited("ec.sim.foam", &[("view", e(1)), ("flowMap/flowMap", Value::Layer(Some(0))), ("flowMap/flowMapSteepness", n(0.5))], 1.0, 0.0);
}

View File

@ -111,6 +111,13 @@ fn timewarp() {
);
moving("ec.time.timewarp", &[("speed", n(55.0)), ("tuning/buildFromOneImage", on())], None);
moving("ec.time.timewarp", &[("speed", n(47.0))], Some("warpLayer"));
// A Matte Layer renders on the CPU (catalog::gpu_supported).
moving("ec.time.timewarp", &[("speed", n(47.0)), ("show", e(0))], Some("matteLayer"));
// A Matte Layer: every method and Show, the luminance channels, motion blur.
for method in 0..3 {
for show in 0..4 {
moving("ec.time.timewarp", &[("method", e(method)), ("speed", n(47.0)), ("show", e(show))], Some("matteLayer"));
}
}
moving("ec.time.timewarp", &[("speed", n(39.0)), ("matteChannel", e(2))], Some("matteLayer"));
moving("ec.time.timewarp", &[("speed", n(43.0)), ("matteChannel", e(1)), ("motionBlur/enableMotionBlur", on())], Some("matteLayer"));
moving("ec.time.timewarp", &[("method", e(1)), ("speed", n(52.0)), ("tuning/buildFromOneImage", on()), ("show", e(1))], Some("matteLayer"));
}

View File

@ -28,15 +28,12 @@ fn warp_styles() {
effect_case("ec.distort.warp", &[("warpStyle", e(4)), ("bend", n(80.0)), ("verticalDistortion", n(-35.0))]);
effect_case("ec.distort.warp", &[("warpStyle", e(11)), ("bend", n(-50.0))]);
effect_case("ec.distort.warp", &[("warpStyle", e(14)), ("bend", n(-35.0))]);
// Fisheye / Twist with a distortion render on the CPU (see catalog::gpu_supported).
let spec = effectcraft_effects::find("ec.distort.warp").unwrap();
let mut params = effectcraft_effects::Params {
values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, [70.0, 44.0]))).collect(),
};
params.values.insert("warpStyle".into(), e(14));
params.values.insert("verticalDistortion".into(), n(-35.0));
let ctx = effectcraft_effects::EffectCtx { params: &params, time: 0.0, layer_size: [70.0, 44.0], seed: 0, adjustment: false, env: Default::default() };
assert!(!effectcraft_effects::catalog::gpu_supported("ec.distort.warp", &ctx));
// Strong Fisheye / Twist bends and either with a distortion: the CPU's f64 inverse map.
for (style, bend) in [(11, 90.0), (11, -100.0), (14, 75.0), (14, -100.0)] {
effect_case("ec.distort.warp", &[("warpStyle", e(style)), ("bend", n(bend))]);
}
effect_case("ec.distort.warp", &[("warpStyle", e(14)), ("verticalDistortion", n(-35.0))]);
effect_case("ec.distort.warp", &[("warpStyle", e(11)), ("warpAxis", e(1)), ("bend", n(60.0)), ("horizontalDistortion", n(30.0))]);
effect_case("ec.distort.warp", &[("warpStyle", e(3)), ("bend", n(0.0)), ("horizontalDistortion", n(-40.0)), ("verticalDistortion", n(25.0))]);
}

View File

@ -209,7 +209,7 @@ 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`, 273 of them: blurs, colour correction, keying incl. Key Light, mattes,
(`effects::GPU_EFFECTS`, 280 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 /
@ -217,10 +217,13 @@ Environment, 3D Glasses), generators, noise, grain, text (Numbers, Timecode), ti
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), and the geometry generators (Lightning, Advanced Lightning, Radio Waves, Stroke,
Scribble, Vegas, Write-on, Audio Spectrum / Waveform, Basic / Path Text: bolts, strokes, waves,
audio marks and glyphs are planned on the CPU, shared with the CPU effect, and rasterised in
tiles with the CPU's coverage functions); see
rasteriser; the particle effects too, `gpu::fx_particles`: CC Particle World / Systems II,
Particle Playground with its Layer Map frames in one atlas, CC Ball Action, CC Pixel Polly, CC
Scatterize; plans too large for one item table draw in several passes over the previous one),
and the geometry generators (Lightning, Advanced Lightning, Radio Waves, Stroke, Scribble,
Vegas, Write-on, Audio Spectrum / Waveform, Basic / Path Text: bolts, strokes, waves, audio
marks and glyphs are planned on the CPU, shared with the CPU effect, and rasterised in tiles
with the CPU's coverage functions); 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
@ -232,10 +235,12 @@ combine them on the GPU; Time Displacement's per-pixel times and Timewarp's moti
(`time_fx::timewarp_plan`) are computed on the CPU. Numbers and Timecode rasterise their glyph
coverage on the CPU (`textfx::text_layer`) and composite it on the GPU. Other layers an effect
reads (gradients, reveals, environments) are fitted on the CPU (`util::fit_layer`) and uploaded.
Settings a kernel cannot match render on the CPU (`effects::catalog::gpu_supported`; e.g. Warp's
Fisheye and Twist past 50 % or with a distortion, whose Newton inverse wanders chaotically near
the crease, and Timewarp with a Matte Layer); Card Wipe (the 3D card renderer of Card Dance and
Shatter) stays on the CPU. Tests render
Where f32 cannot follow the CPU's f64, the CPU solves the part that needs it and the GPU uses
the result: Warp's Fisheye and Twist past 50 % or with a distortion (a Newton inverse that
wanders chaotically near the crease) sample the CPU's inverse map (`warp_inverse_map`). Settings
a kernel cannot match still render on the CPU (`effects::catalog::gpu_supported`: Cell
Pattern's HQ variants, Turbulent Displace's locked pinning and the controls in
`CPU_ONLY_CONTROLS`). Tests render
scenes on both paths and compare them (≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc); they skip without an
adapter. Working textures (premultiplied RGBA f32) come from a pool: a released texture is reused
once every encoder that could still read it has been submitted, transparent inputs share one

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**: 273 effects also run on the GPU compositor with identical results.
- **GPU**: 280 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).
@ -265,7 +265,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| Add Grain | `ec.noise.addgrain` | 32 | GPU | 32 | Implemented |
| Curl Noise | `ec.noise.curlnoise` | 10 | | 32 | Implemented |
| Curl Noise | `ec.noise.curlnoise` | 10 | GPU | 32 | Implemented |
| Dust & Scratches | `ec.noise.dustscratches` | 3 | GPU | 32 | Implemented |
| Fractal Noise | `ec.noise.fractal` | 26 | GPU | 32 | Implemented |
| Match Grain | `ec.noise.matchgrain` | 37 | | 32 | Implemented |
@ -317,22 +317,22 @@ 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 Ball Action | `ec.sim.ccballaction` | 8 | GPU | 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 Particle Systems II | `ec.sim.ccparticlesystems2` | 21 | GPU | 32 | Implemented |
| CC Particle World | `ec.sim.ccparticleworld` | 30 | GPU | 32 | Implemented |
| CC Pixel Polly | `ec.sim.ccpixelpolly` | 10 | GPU | 32 | Implemented |
| CC Rainfall | `ec.sim.ccrainfall` | 14 | GPU | 32 | Implemented |
| CC Scatterize | `ec.sim.ccscatterize` | 4 | | 32 | Implemented |
| CC Scatterize | `ec.sim.ccscatterize` | 4 | GPU | 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 |
| Particle Playground | `ec.sim.particleplayground` | 92 | GPU | 32 | Implemented |
| Shatter | `ec.sim.shatter` | 60 | GPU | 32 | Implemented |
| Wave World | `ec.sim.waveworld` | 35 | GPU | 32 | Implemented |

View File

@ -2,46 +2,43 @@
How close EffectCraft is to After Effects 2026, feature by feature, and how much work is left.
## Current status (audit at commit `58163a2`, 3 October 2026, evening)
## Current status (audit at commit `d39c0e8`, 4 October 2026)
| Measure | Value |
|---|---|
| **Feature parity, weighted by tier** (P0 ×3, P1 ×2, P2 ×1) | **≈ 98%** counting every partial feature as half done; ≈ 99.6% using per-feature fractions |
| 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)); 236 run on the GPU |
| **Feature parity, weighted by tier** (P0 ×3, P1 ×2, P2 ×1) | **≈ 99%** counting every partial feature as half done; ≈ 99.8% using per-feature fractions |
| Unweighted | ≈ 98.4% (half-credit) / 99.6% (fractions) |
| P0 / P1 / P2 | 100% / 98% / 90% (half-credit); 100% / 99.7% / 97.5% (fractions) |
| Features done / partial / missing | 89 / 3 / 0 of 92 |
| **Effects** | **306** effects, every one implemented in full ([effects.md](effects.md)); 243 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 |
| Remaining work | ≈ 8.5 agent-hours at the pace measured so far (≈ 26 on the conservative audit scale); ≈ 3.5 without the learned-model items |
| **Wall-clock estimate** | **≈ 2–3 hours** with five agents in parallel; ≈ 1 hour without the learned-model items |
Partial features:
| id | Tier | Done | What is missing |
|---|---|---|---|
| EFF-5 GPU effects | P1 | 0.98 | GPU kernels for the stepped particle systems' sprite pass and Fractal (its escape iteration needs f64: WGSL has none and Metal's fast math defeats double-f32 emulation); custom `.ocio` configs, Shatter's wireframe views and Foam's texture / environment / flow-map extras render on the CPU (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; the remaining pixel effects, the geometry generators and the audio-driven effects in M13.28) |
| 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 |
| EFF-5 GPU effects | P1 | 0.98 | Fractal (its escape iteration needs f64: WGSL has none and Metal's fast math defeats double-f32 emulation) and the analysis / tool effects (Camera-Shake Deblur, Detail-preserving Upscale, Puppet, Rolling Shutter Repair, Warp Stabilizer, Mocha Shape, Roto Brush, Match Grain, Paint, Camera Tracker, Face Tracker) render on the CPU. M13.28 moved the remaining pixel effects (CC blur / glass family, Color Link, Cineon, HDR, Inner/Outer Key, Basic 3D…) and the geometry and audio generators (Lightning, Advanced Lightning, Beam, Lens Flare, Radio Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum / Waveform, Basic / Path Text) onto the GPU; M13.29 the particle effects (CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize) and Curl Noise, and removed the Shatter wireframe, Foam extras, Timewarp matte, strong Warp Fisheye / Twist and custom `.ocio` fallbacks. The 13 audio filters stay on the CPU by design |
| MSK-4 Roto Brush | P2 | 0.85 | Segmentation is classical (graph cut + optical flow), not a learned model |
| TRK-3 Face tracking | P2 | 0.9 | Classical fitter: weak on profile and occluded faces; Rolling Shutter Ripple is approximated |
Since the previous audit, M9.13 completed all 42 simplified effects; M13.4 added HEVC and AV1
export and Opus SILK / hybrid; M13.5 the Learn tab and Extended Viewer; M13.6 and M13.12 text,
images, shadings and transparency groups in PDF/AI/EPS import, variable fonts and PSD warps;
M13.7 ScriptUI resource strings, `onDraw` and `Socket`, Essential Graphics scripting hooks and
the last menu items; M13.8 the Preview panel, Align to Selection / Distribute and Advanced 3D
iris depth of field, collapsed precomps and extruded strokes; M13.9 and M13.13 107 more GPU
effects, a texture pool and per-comp CPU/GPU choice; M13.10 render workers in the browser and
visual editors for curves and palettes; M13.11 Advanced 3D on the GPU and Essential Graphics
mirrors; M13.14–M13.20 puppet pin editing and rigging; M13.15 end-to-end tests through MCP and
the CLI; M13.21 lazy project open, virtualised panels, background auto-save and an audit that
every command is reachable over MCP, the control channel and the CLI; M13.24 GPU effects in the
browser's frame workers, the browser disk cache (OPFS) and a storage manager.
Since the previous audit: M13.22 and M13.23 put 70 more effects on the GPU (236) and composite every
Advanced 3D mode on the GPU; M13.24 runs GPU effects in the browser's render workers with a disk
cache in browser storage and a storage manager; M13.25 added the Home template gallery, Save as
Template and View ▸ Simulate Output ▸ My Custom RGB; M13.26 Premiere Pro interop through FCP7 XML,
FCPXML, OTIO, EDL, AAF and OMF (import and export, with pre-renders for layers a timeline can't
hold); WebM VP9 alpha now imports (FilmCraft); M13.27 pins the CPU simulation effects with golden
hashes.
Out of clean-room scope (no public specification or no permissively licensed data): `.jsxbin`,
Vanishing Point `.vpe`, the predefined CJK CMaps in PDF import, Kodak film emulations. Adobe
service integrations (Team Projects, Libraries, Media Encoder, Dynamic Link, Frame.io, Exchange)
and third-party plug-ins (Cinema 4D, Mocha) are intentionally absent.
Out of clean-room scope (no public specification or no permissively licensed data): native
`.prproj`, `.jsxbin`, Vanishing Point `.vpe`, the predefined CJK CMaps in PDF import, Kodak film
emulations. Adobe service integrations (Team Projects, Libraries, Media Encoder, Dynamic Link,
Frame.io, Exchange) and third-party plug-ins (Cinema 4D, Mocha) are intentionally absent.
### Previous audit (commit `58163a2`, 3 October 2026, evening): ≈ 98%
87 done / 5 partial / 0 missing.
### Previous audit (commit `978e8d7`, 3 October 2026): ≈ 94%
@ -156,7 +153,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
| Text | ≈ 96% | 0.3 | no extruded strokes (M13.12: the Variable Font Axes animator re-spaces the text — advances follow the animated axes; M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
| Web | 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 (273 run on the GPU since M13.28, 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.28 the remaining pixel effects (CC Cross / Radial / Vector Blur, Color Link, Cineon, HDR, CC Block Load / Burn Film / Glass / HexTile / Mr. Smoothie / Plastic, Inner/Outer Key, Basic 3D…) and the generators (Lightning, Advanced Lightning, Beam, Lens Flare, Radio Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum / Waveform, Basic Text, Path Text); 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; the stepped particle systems and Fractal 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 (280 run on the GPU since M13.29, EFF-5: M13.28 added the remaining pixel effects and the geometry and audio generators (Lightning, Advanced Lightning, Beam, Lens Flare, Radio Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum / Waveform, Basic / Path Text); M13.29 added the particle effects (CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize, Curl Noise) and removed the fallbacks of Shatter's wireframe views, Foam's extras, Timewarp's Matte Layer, strong Warp Fisheye / Twist bends and custom `.ocio` configs; 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; Fractal and the analysis / tool effects 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) |
@ -492,6 +489,33 @@ resolution, adjustment) and composited at 8 and 32 bpc:
of the pixels even at low magnification, WGSL has no f64 and Metal's fast math defeats
double-f32 emulation.
## Update: M13.29 GPU effects, part D (EFF-5)
7 more effects run on the GPU (280 in all, with M13.28's 37), and five CPU fallbacks are gone. Each case is
checked against the CPU oracle (≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc) directly on a buffer (full
and half resolution) and composited at 8 and 32 bpc; the CPU refactors behind the shared plans
are pinned bit for bit by golden hashes (`crates/effects/tests/particle_golden.rs`,
`timewarp_golden.rs`, `sim_golden.rs`).
- **Particle render passes** (`gpu::fx_particles`): CC Particle World and CC Particle Systems II
(every particle type and transfer mode; the stepped simulation stays on the CPU or the GPU
`ParticleSim`, sorted and shaded as the CPU does), Particle Playground (cannon, grid, text
strokes, layer exploder, property mappers, repel, wall; the Layer Map particles' frames are
packed into one atlas), CC Ball Action, CC Pixel Polly and CC Scatterize (their plans read the
frame back once: the particles take the layer's colours and, for Ball Action's twist, its
luminance). Plans too large for one item table draw in several passes, each over the previous
one, with the same per-pixel arithmetic.
- **Curl Noise**: the fBm potential, its curl and the streamline trace as three kernels.
- **Former fallbacks**: Shatter's wireframe views (the default view) draw their lines as
sprites; Foam's User Defined texture, Environment Map and flow-map preview draw over the
bubble sprites; Timewarp with a Matte Layer carries the masked frames and the foreground's and
background's own vectors in its plan and builds both layers on the GPU; custom `.ocio` configs
compile to the colour program (matrix and offset, exponent, log / log-affine, range, CDL, file
LUTs, groups, inverses). Warp's Fisheye / Twist past 50 % or with a distortion keeps the CPU's
f64 Newton solve (f32, even with more iterations, lands on other roots near the crease): the
CPU solves the inverse map from the geometry alone and the GPU samples along it, so the chain
stays on the GPU without a readback.
## Highest-value gaps, in order
1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10).