Effects: CC Particle World follows the comp camera (#397)

Cause: CC Particle World projected its particles through a fixed camera
(distance 2 layer widths, looking at the layer centre) and never asked the
host for the comp camera, so moving or orbiting a camera layer left the
particles flat on the layer. It also had no Extras > Effect Camera. Separately,
the layer cache key ignored the comp camera and lights, so the effects that
read them (Card Dance / Shatter / Card Wipe Comp Camera, First Comp Light,
Caustics) kept a stale frame when the camera, a light or the layer moved.

Fix: as the Cycore manual describes, Particle World sees its particles through
the comp's active camera layer when there is one (EffectHost::comp_scene now
says whether its camera is a camera layer's, CompScene::camera_layer), through
card3d's comp-camera projection (factored out of Card Dance as comp_proj, with
Proj::view for depth, size scale and screen velocity). Otherwise it uses the
new Extras > Effect Camera: Distance (2), Rotation X / Y / Z (0) and FOV (50)
orbit the world origin; FOV moves the camera so the origin's plane keeps the
scale Distance gives it, so the defaults reproduce the old fixed camera bit for
bit (golden hashes unchanged) and old projects render as before. The Effect
Camera is left out of the simulation cache key, so animating it doesn't
restart the simulation. The plan is shared, so the GPU compositor follows too.
The layer cache folds the comp scene into the key of layers running an effect
in effects::READS_COMP_SCENE (render::comp_scene is shared with the host).

Tests: sim particle_world_follows_the_comp_camera (panning a camera layer moves
the particles, zooming spreads them, the Effect Camera is ignored then, the
default view keeps the Effect Camera, cameras facing away / zero / NaN draw
nothing) and particle_world_effect_camera (missing params = defaults, rotation,
distance and FOV change the view, extremes stay finite, the sim key ignores the
camera); render particle_world_follows_the_comp_camera (a real camera layer
panned 40 px moves the particles left, and the cached render after the move
equals an uncached one: it failed with the old cache key); gpu
particle_world_through_the_comp_camera (CPU vs GPU at 8 / 32 bpc, full and
half resolution). particle_golden / sim_golden unchanged. effects.md
regenerated; parity.md and gaps.md describe the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-10 00:35:25 +07:00
1 parent e9032c5ce2
commit 923d978119
12 files changed
+426 -68

No files matched your search

+66 -19
View File
@@ -4,7 +4,8 @@
//!
//! Pieces live in "buffer world" space: x, y in buffer pixels, z in buffer pixels away from the
//! viewer (the layer plane is z = 0). A [`Proj`] maps buffer-world points to buffer pixels
//! (homogeneous), so every camera system reduces to one 3×4 matrix.
//! (homogeneous), so every camera system reduces to one 3×4 matrix. CC Particle World sees its
//! particles through the comp camera with the same [`comp_proj`].
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
@@ -170,6 +171,29 @@ impl Proj {
(w > self.near).then(|| [r(0) / w, r(1) / w])
}
/// How a buffer-world point `p` moving by `v` is seen: its buffer pixel, its depth W, the
/// buffer pixels per buffer-world pixel of a camera-facing size there, and its velocity in
/// buffer pixels. `None` behind or too close to the camera, or for a degenerate camera.
pub fn view(&self, p: [f64; 3], v: [f64; 3]) -> Option<Seen> {
let row = |i: usize| [self.m[i][0], self.m[i][1], self.m[i][2]];
let (m0, m1, m2) = (row(0), row(1), row(2));
let w = dot(m2, p) + self.m[2][3];
if !(w > self.near && w.is_finite()) {
return None;
}
let at = [(dot(m0, p) + self.m[0][3]) / w, (dot(m1, p) + self.m[1][3]) / w];
// The focal length (pixels per unit at unit depth): |m_i × m2| / |m2|² for K·[R|t]
// scaled by |m2|; the geometric mean of the two image axes.
let l2 = dot(m2, m2);
let scale = (len(cross(m0, m2)) * len(cross(m1, m2))).sqrt() / (l2.sqrt() * w);
if !(scale > 0.0 && scale.is_finite() && at.iter().all(|a| a.is_finite())) {
return None;
}
// d(X / W) = (m_i − at_i · m2) · dp / W.
let vel = [(dot(m0, v) - at[0] * dot(m2, v)) / w, (dot(m1, v) - at[1] * dot(m2, v)) / w];
Some(Seen { at, depth: w, scale, vel })
}
/// The camera's centre in buffer-world space (where the projection collapses), or a point
/// far in front of the layer when it has none (orthographic).
pub fn eye(&self) -> [f64; 3] {
@@ -184,6 +208,15 @@ impl Proj {
}
}
/// A point seen through a [`Proj`] (see [`Proj::view`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Seen {
pub at: [f64; 2],
pub depth: f64,
pub scale: f64,
pub vel: [f64; 2],
}
pub(crate) fn inv3(m: &M3) -> Option<M3> {
let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
@@ -235,24 +268,10 @@ pub(crate) fn projection(ctx: &EffectCtx, b: &Buf) -> Proj {
None => base,
}
}
2 => {
// Comp Camera: the comp's camera looking at this layer (layer px → layer px).
let base = Proj::simple(c, size * 2.0);
let Some(cam) = ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.camera) else { return base };
// buffer world → layer: (p − offset) / s; layer → buffer: · s + offset.
let mut m = [[0.0; 4]; 3];
for i in 0..3 {
for j in 0..3 {
m[i][j] = cam[i][j] / s;
}
m[i][3] = cam[i][3] - (cam[i][0] * b.offset[0] + cam[i][1] * b.offset[1]) / s;
}
let back: M3 = [[s, 0.0, b.offset[0]], [0.0, s, b.offset[1]], [0.0, 0.0, 1.0]];
let p = Proj { m, near: 1e-6 }.then(&back);
// W of the layer centre sets the scale of `near`.
let wc = p.m[2][0] * c[0] + p.m[2][1] * c[1] + p.m[2][3];
Proj { near: (wc.abs() * 0.02).max(1e-9), ..p }
}
2 => match ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.camera) {
Some(cam) => comp_proj(&cam, c, b),
None => Proj::simple(c, size * 2.0),
},
_ => {
let zpos = f("cameraPosition/cameraZ", 2.0).max(0.05);
let focal = f("cameraPosition/focalLength", 70.0).max(1.0);
@@ -282,6 +301,25 @@ pub(crate) fn projection(ctx: &EffectCtx, b: &Buf) -> Proj {
}
}
/// The comp camera `cam` ([`CompScene::camera`], layer px → layer px) looking at buffer `b`,
/// whose layer centre is buffer pixel `c`.
pub(crate) fn comp_proj(cam: &[[f64; 4]; 3], c: [f64; 2], b: &Buf) -> Proj {
let s = b.scale;
// buffer world → layer: (p − offset) / s; layer → buffer: · s + offset.
let mut m = [[0.0; 4]; 3];
for i in 0..3 {
for j in 0..3 {
m[i][j] = cam[i][j] / s;
}
m[i][3] = cam[i][3] - (cam[i][0] * b.offset[0] + cam[i][1] * b.offset[1]) / s;
}
let back: M3 = [[s, 0.0, b.offset[0]], [0.0, s, b.offset[1]], [0.0, 0.0, 1.0]];
let p = Proj { m, near: 1e-6 }.then(&back);
// W of the layer centre sets the scale of `near`.
let wc = p.m[2][0] * c[0] + p.m[2][1] * c[1] + p.m[2][3];
Proj { near: (wc.abs() * 0.02).max(1e-9), ..p }
}
/// The comp's first light as the effect sees it (layer pixels), from [`crate::EffectHost::comp_scene`].
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct CompLight {
@@ -302,6 +340,9 @@ pub struct CompScene {
/// camera's view of the layer brought back into the layer's own pixel grid (identity on the
/// layer plane for the default camera).
pub camera: Option<[[f64; 4]; 3]>,
/// `camera` is an active camera layer's, not the comp's default view (CC Particle World
/// follows only a camera layer).
pub camera_layer: bool,
pub light: Option<CompLight>,
}
@@ -334,6 +375,12 @@ fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn len(a: [f64; 3]) -> f64 {
dot(a, a).sqrt()
}
fn norm(a: [f64; 3]) -> [f64; 3] {
let l = dot(a, a).sqrt();
if l < 1e-12 { [0.0, 0.0, -1.0] } else { [a[0] / l, a[1] / l, a[2] / l] }
+6
View File
@@ -681,6 +681,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("sourceCrops", "Source Crops"),
// Simulation.
("extras", "Extras"),
("effectCamera", "Effect Camera"),
("wiggle", "Wiggle"),
("light", "Light"),
("shading", "Shading"),
@@ -1108,6 +1109,11 @@ pub const GPU_EFFECTS: &[&str] = &[
"ec.obsolete.pathtext",
];
/// Effects that can read [`EffectHost::comp_scene`] (the comp camera or the first comp light):
/// their output changes when the camera, the lights or the layer's own transform move, so the
/// renderer's layer cache folds the comp scene into the key for these.
pub const READS_COMP_SCENE: &[&str] = &["ec.sim.carddance", "ec.sim.shatter", "ec.transition.cardwipe", "ec.sim.caustics", "ec.sim.ccparticleworld"];
/// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated
/// parameters). The renderer's layer cache folds the layer time into the key for these.
/// The `time_dependence_is_declared` test checks the list against every registered effect.
+227 -20
View File
@@ -15,7 +15,7 @@ use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::generate::value_noise;
use crate::util::{SimCache, hash1, params_key, unpremul};
use crate::util::{SimCache, hash1, params_key, params_key_except, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
pub(crate) fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
@@ -989,11 +989,18 @@ pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option<Vec<crate::psim::SimP
"ec.sim.ccparticlesystems2" => (ps2_phys(ctx), &PS2_CACHE, 2),
_ => return None,
};
let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt);
let key = sim_key(ctx, salt);
let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(cache, key, ctx.time, &phys));
Some(st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect())
}
/// The simulation cache key of a stepped particle effect: every parameter but CC Particle World's
/// Effect Camera, which only changes how the particles are seen (animating it doesn't restart
/// the simulation).
fn sim_key(ctx: &EffectCtx, salt: u64) -> u64 {
params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt, &[PW_EFFECT_CAMERA])
}
/// Run a particle system to layer time `t` through `cache`.
pub(crate) fn simulate(cache: &SimCache<PState>, key: u64, t: f64, phys: &Phys) -> std::sync::Arc<PState> {
cache.run(key, steps_at(t), PState::default, |st, i| phys.step(st, i))
@@ -1068,6 +1075,90 @@ fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf {
}
}
/// CC Particle World's Extras ▸ Effect Camera parameters (id prefix).
const PW_EFFECT_CAMERA: &str = "extras/effectCamera/";
/// Effect Camera ▸ FOV's default (degrees).
const PW_FOV: f64 = 50.0;
/// What CC Particle World sees its particles through.
enum PwCamera {
/// Extras ▸ Effect Camera, in world units (layer widths): `dist` from the world origin (the
/// layer centre) along −z, the world turned by `rot` (`None` = not turned) about the origin,
/// `focal` the scale at unit depth; particles nearer than `near` aren't drawn.
Effect { rot: Option<[[f32; 3]; 3]>, dist: f32, focal: f32, near: f32 },
/// The comp's active camera layer, on buffer-world points (After Effects then ignores the
/// Effect Camera).
Comp(crate::card3d::Proj),
}
/// A particle as the camera sees it: depth (larger is farther), buffer pixel, size scale `k`
/// (buffer pixels per world unit = `k · layer width · buffer scale`) and velocity in world units
/// on screen (× the same factor for buffer pixels).
struct PwSeen {
depth: f32,
x: f32,
y: f32,
k: f32,
v: [f32; 2],
}
impl PwCamera {
fn new(ctx: &EffectCtx, b: &Buf) -> PwCamera {
let [lw, lh] = ctx.layer_size;
if let Some(cam) = ctx.env.host.and_then(|h| h.comp_scene()).filter(|sc| sc.camera_layer).and_then(|sc| sc.camera) {
let c = [b.offset[0] + lw * 0.5 * b.scale, b.offset[1] + lh * 0.5 * b.scale];
return PwCamera::Comp(crate::card3d::comp_proj(&cam, c, b));
}
let f = |id: &str, d: f64| ctx.params.get(&format!("{PW_EFFECT_CAMERA}{id}")).map(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d);
// The camera keeps the origin's plane at the scale Distance gives it as FOV changes
// (moving in for a wider view), so FOV sets how strong the perspective is. Distance 2
// shows the origin's plane at the layer's own scale.
let half = |fov: f64| (fov.clamp(1.0, 179.0).to_radians() * 0.5).tan();
let zoom = half(PW_FOV) / half(f("fov", PW_FOV));
let dist = f("distance", 2.0).max(0.01) * zoom;
let a = [f("rotationX", 0.0), f("rotationY", 0.0), f("rotationZ", 0.0)];
let rot = (a != [0.0; 3]).then(|| {
let r = crate::card3d::rot_ordered(a[0].to_radians(), a[1].to_radians(), a[2].to_radians(), 0);
r.map(|row| row.map(|v| v as f32))
});
PwCamera::Effect { rot, dist: dist as f32, focal: (2.0 * zoom) as f32, near: (0.025 * dist) as f32 }
}
/// How the camera sees world point `p` moving at `v`; `None` behind or too close to it.
fn see(&self, p: [f32; 3], v: [f32; 3], lw: f32, lh: f32, b: &Buf) -> Option<PwSeen> {
match self {
PwCamera::Effect { rot, dist, focal, near } => {
let turn = |r: &[[f32; 3]; 3], q: [f32; 3]| r.map(|row| row[0] * q[0] + row[1] * q[1] + row[2] * q[2]);
let (p, v) = match rot {
Some(r) => (turn(r, p), turn(r, v)),
None => (p, v),
};
let zc = dist + p[2];
if zc.is_nan() || zc <= *near {
return None;
}
let k = focal / zc;
let (x, y) = b.to_px([(lw * 0.5 + p[0] * lw * k) as f64, (lh * 0.5 + p[1] * lw * k) as f64]);
Some(PwSeen { depth: zc, x: x as f32, y: y as f32, k, v: [v[0], v[1]] })
}
PwCamera::Comp(proj) => {
let (s, lw, lh) = (b.scale, lw as f64, lh as f64);
let u = lw * s;
let at = [b.offset[0] + lw * 0.5 * s + p[0] as f64 * u, b.offset[1] + lh * 0.5 * s + p[1] as f64 * u, p[2] as f64 * u];
let seen = proj.view(at, [v[0] as f64, v[1] as f64, v[2] as f64])?;
let k = seen.scale;
Some(PwSeen {
depth: seen.depth as f32,
x: seen.at[0] as f32,
y: seen.at[1] as f32,
k: k as f32,
v: [(seen.vel[0] / k) as f32, (seen.vel[1] / k) as f32],
})
}
}
}
}
/// 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;
@@ -1076,7 +1167,7 @@ fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
if phys.rate <= 0.0 && ctx.time <= 0.0 {
return None;
}
let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1);
let key = sim_key(ctx, 1);
let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PW_CACHE, key, ctx.time, &phys));
let kind = pr.e("particle/particleType");
let birth = pr.color("particle/birthColor");
@@ -1085,24 +1176,16 @@ fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
let svar = pr.f("particle/sizeVariation") as f32 / 100.0;
let max_op = pr.f("particle/maxOpacity") as f32 / 100.0;
let s = b.scale as f32;
// Camera at distance 2 (world units = layer width) looking at the origin (layer centre).
let cam = 2.0f32;
let cam = PwCamera::new(ctx, b);
let mut list: Vec<(f32, Sprite)> = st
.parts
.par_iter()
.filter_map(|q| {
let zc = cam + q.p[2];
if zc <= 0.05 {
return None;
}
let k = cam / zc;
let x = lw * 0.5 + q.p[0] * lw * k;
let y = lh * 0.5 + q.p[1] * lw * k;
let (bx, by) = b.to_px([x as f64, y as f64]);
let PwSeen { depth, x, y, k, v } = cam.see(q.p, q.v, lw, lh, b)?;
let (c, size) = particle_look(q, birth, death, bs, ds, svar, max_op);
let r = size * 0.5 * lw * k * s * 0.25;
let sp = type_sprite(kind, bx as f32, by as f32, r, c, q.v[0] * lw * k * s, q.v[1] * lw * k * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0);
Some((zc, sp))
let sp = type_sprite(kind, x, y, r, c, v[0] * lw * k * s, v[1] * lw * k * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0);
Some((depth, sp))
})
.collect();
list.sort_by(|a, b| b.0.total_cmp(&a.0));
@@ -1167,7 +1250,7 @@ fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
let lh = ctx.layer_size[1] as f32;
let unit = lh.max(1.0);
let phys = ps2_phys(ctx);
let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 2);
let key = sim_key(ctx, 2);
let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys));
let kind = pr.e("particle/particleType");
let birth = pr.color("particle/birthColor");
@@ -1440,6 +1523,11 @@ pub fn specs() -> Vec<EffectSpec> {
p("particle/birthColor", "Birth Color", col(1.0, 1.0, 0.0), ParamUi::Color),
p("particle/deathColor", "Death Color", col(0.6, 0.0, 0.0), ParamUi::Color),
p("particle/transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Screen", "Add", "Black Matte"])),
p("extras/effectCamera/distance", "Distance", num(2.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("extras/effectCamera/rotationX", "Rotation X", num(0.0), ParamUi::Angle),
p("extras/effectCamera/rotationY", "Rotation Y", num(0.0), ParamUi::Angle),
p("extras/effectCamera/rotationZ", "Rotation Z", num(0.0), ParamUi::Angle),
p("extras/effectCamera/fov", "FOV", num(PW_FOV), slider(1.0, 179.0, 10.0, 120.0, 1)),
p("extras/randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)),
],
particle_world,
@@ -1532,18 +1620,24 @@ mod tests {
a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).sum::<f32>()).sum()
}
/// Centre of mass (luminance-weighted difference from `base`) in y.
fn mass_y(img: &Image, base: &Image) -> f32 {
let (mut m, mut sy) = (0.0, 0.0);
/// Centre of mass (luminance-weighted difference from `base`), `[-1, -1]` when equal.
fn mass(img: &Image, base: &Image) -> [f32; 2] {
let (mut m, mut sx, mut sy) = (0.0, 0.0, 0.0);
for y in 0..img.height {
for x in 0..img.width {
let i = img.idx(x, y);
let d: f32 = (0..3).map(|k| (img.data[i][k] - base.data[i][k]).abs()).sum();
m += d;
sx += d * x as f32;
sy += d * y as f32;
}
}
if m > 0.0 { sy / m } else { -1.0 }
if m > 0.0 { [sx / m, sy / m] } else { [-1.0; 2] }
}
/// [`mass`] in y.
fn mass_y(img: &Image, base: &Image) -> f32 {
mass(img, base)[1]
}
#[test]
@@ -1695,6 +1789,119 @@ mod tests {
assert!(down > up + 2.0, "{up} {down}");
}
/// A comp whose camera is `camera` ([`crate::CompScene::camera`]); `layer`: a camera layer
/// (else the comp's default view).
struct Comp {
camera: [[f64; 4]; 3],
layer: bool,
}
impl crate::EffectHost for Comp {
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn comp_scene(&self) -> Option<crate::CompScene> {
Some(crate::CompScene { camera: Some(self.camera), camera_layer: self.layer, light: None })
}
}
/// A camera `d` px in front of layer point `c` looking at it, moved right by `dx` px and
/// zoomed by `zoom` (1 = identity on the layer plane): `c + zoom·d·(p − c − dx) / (z + d)`.
fn cam(c: [f64; 2], d: f64, dx: f64, zoom: f64) -> [[f64; 4]; 3] {
let k = zoom * d;
[[k, 0.0, c[0], d * c[0] - k * (c[0] + dx)], [0.0, k, c[1], d * c[1] - k * c[1]], [0.0, 0.0, 1.0, d]]
}
fn pw_in(comp: &Comp, vals: &[(&str, Value)]) -> Image {
run_fx("ec.sim.ccparticleworld", vals, Image::new(48, 48), 1.0, EffectEnv { host: Some(comp), ..Default::default() }).img
}
fn finite(img: &Image) -> bool {
img.data.iter().all(|p| p.iter().all(|c| c.is_finite()))
}
/// #397: with a camera layer in the comp, the particles are seen through it.
#[test]
fn particle_world_follows_the_comp_camera() {
let v = pw_vals(3.0);
let blank = Image::new(48, 48);
let here = Comp { camera: cam([24.0, 24.0], 96.0, 0.0, 1.0), layer: true };
let base = pw_in(&here, &v);
assert!(base.data.iter().any(|p| p[3] > 0.0), "particles alive");
// Moving the camera right moves the particles left.
let moved = pw_in(&Comp { camera: cam([24.0, 24.0], 96.0, 8.0, 1.0), ..here }, &v);
let (a, b) = (mass(&base, &blank), mass(&moved, &blank));
assert!(b[0] < a[0] - 4.0 && (b[1] - a[1]).abs() < 2.0, "{a:?} -> {b:?}");
// Zooming in spreads them out.
let zoomed = pw_in(&Comp { camera: cam([24.0, 24.0], 96.0, 0.0, 2.0), ..here }, &v);
let cover = |i: &Image| i.data.iter().filter(|p| p[3] > 0.01).count();
assert!(cover(&zoomed) > cover(&base), "{} {}", cover(&zoomed), cover(&base));
// The Effect Camera is ignored then.
let mut turned = v.clone();
turned.push(("extras/effectCamera/rotationY", num(60.0)));
assert_eq!(pw_in(&here, &turned), base);
// The comp's default view (no camera layer) leaves it to the Effect Camera.
let default_view = Comp { layer: false, ..here };
assert_eq!(pw_in(&default_view, &v), run("ec.sim.ccparticleworld", &v, blank.clone(), 1.0));
// A camera facing away, or a degenerate one, draws nothing and never NaN.
for camera in [[[96.0, 0.0, 24.0, 0.0], [0.0, 96.0, 24.0, 0.0], [0.0, 0.0, -1.0, -96.0]], [[0.0; 4]; 3], [[f64::NAN; 4]; 3]] {
assert_eq!(pw_in(&Comp { camera, layer: true }, &v), blank);
}
// A camera among the particles draws the ones in front of it.
let inside = pw_in(&Comp { camera: cam([24.0, 24.0], 1.0, 0.0, 1.0), ..here }, &v);
assert!(finite(&inside));
}
/// Extras ▸ Effect Camera: the defaults are the fixed camera Particle World always had.
#[test]
fn particle_world_effect_camera() {
let v = pw_vals(5.0);
let base = run("ec.sim.ccparticleworld", &v, Image::new(48, 48), 1.0);
let with = |extra: &[(&'static str, Value)]| {
let mut w = v.clone();
w.extend_from_slice(extra);
run("ec.sim.ccparticleworld", &w, Image::new(48, 48), 1.0)
};
// Missing parameters (projects saved before) read as the defaults.
let spec = crate::find("ec.sim.ccparticleworld").unwrap();
let mut params = crate::Params { values: spec.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
params.values.retain(|k, _| !k.starts_with(PW_EFFECT_CAMERA));
for (k, val) in &v {
params.values.insert(k.to_string(), val.clone());
}
let ctx = EffectCtx { params: &params, time: 1.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() };
assert_eq!(crate::apply(spec, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img, base);
assert_eq!(with(&[("extras/effectCamera/fov", num(f64::NAN))]), base);
// Turning or pulling back the camera changes the view.
let turned = with(&[("extras/effectCamera/rotationY", num(60.0))]);
assert!(diff(&turned, &base) > 1.0);
let cover = |i: &Image| i.data.iter().filter(|p| p[3] > 0.01).count();
let far = with(&[("extras/effectCamera/distance", num(4.0))]);
assert!(cover(&far) < cover(&base), "{} {}", cover(&far), cover(&base));
assert!(diff(&with(&[("extras/effectCamera/fov", num(120.0))]), &base) > 0.1);
// Extremes never panic or produce NaN.
for extra in [
("extras/effectCamera/distance", num(0.0)),
("extras/effectCamera/distance", num(-5.0)),
("extras/effectCamera/fov", num(0.0)),
("extras/effectCamera/fov", num(180.0)),
("extras/effectCamera/rotationX", num(1e12)),
] {
assert!(finite(&with(std::slice::from_ref(&extra))), "{extra:?}");
}
// Moving the camera doesn't restart the simulation.
let key = |pr: &crate::Params| {
sim_key(&EffectCtx { params: pr, time: 1.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() }, 1)
};
let mut moved = params.clone();
moved.values.insert("extras/effectCamera/rotationZ".into(), num(30.0));
assert_eq!(key(&params), key(&moved));
moved.values.insert("extras/randomSeed".into(), num(9.0));
assert_ne!(key(&params), key(&moved));
}
#[test]
fn particle_systems2_seek_and_direction() {
let v = vec![
+1 -1
View File
@@ -2468,7 +2468,7 @@ mod tests {
None
}
fn comp_scene(&self) -> Option<crate::CompScene> {
Some(crate::CompScene { camera: self.camera, light: self.light })
Some(crate::CompScene { camera: self.camera, camera_layer: true, light: self.light })
}
}
+7 -1
View File
@@ -462,9 +462,15 @@ pub fn layer_or_self(ctx: &crate::EffectCtx, b: &crate::Buf, id: &str, masks_and
/// A stable hash of an effect instance's evaluated parameters, seed, layer size and buffer
/// scale (cache key for simulations).
pub fn params_key(ctx: &crate::EffectCtx, b: &crate::Buf, salt: u64) -> u64 {
params_key_except(ctx, b, salt, &[])
}
/// [`params_key`] without the parameters whose ids start with one of `skip` (ones that only
/// change how the simulation is viewed, so animating them doesn't restart it).
pub fn params_key_except(ctx: &crate::EffectCtx, b: &crate::Buf, salt: u64, skip: &[&str]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
let mut keys: Vec<&String> = ctx.params.values.keys().collect();
let mut keys: Vec<&String> = ctx.params.values.keys().filter(|k| !skip.iter().any(|s| k.starts_with(s))).collect();
keys.sort();
for k in keys {
k.hash(&mut h);
+22 -1
View File
@@ -3,8 +3,11 @@
//! and composited at 8 and 32 bpc (`tests_fx_sim`'s harness).
use effectcraft_keyframe::Value;
use effectcraft_project::{BitDepth, LayerSource, build};
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use crate::tests::n;
use crate::tests::{Scene, check, compare_at, n, opts, set};
use crate::tests_fx_sim::{case, composited, e, off, on};
fn v2(x: f64, y: f64) -> Value {
@@ -28,6 +31,24 @@ fn particle_world_types_and_modes() {
case("ec.sim.ccparticleworld", &[("birthRate", n(0.0))], 0.0, 0.0);
}
/// #397: CC Particle World seen through a camera layer (the plan is shared, so the GPU follows
/// the camera with the CPU).
#[test]
fn particle_world_through_the_comp_camera() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = Scene::new(depth);
let mut l = s.footage(70, 44);
s.effect(&mut l, "ec.sim.ccparticleworld", &[("birthRate", n(1.5)), ("particle/particleType", e(1)), ("particle/birthSize", n(0.3))]);
s.push(l);
let mut cam = build::layer(&mut s.p, &s.comp, "Camera", LayerSource::Camera, (97, 61), None);
set(&mut cam, "transform/position", Value::Vec3([75.0, 15.0, -120.0]));
s.push(cam);
let at = Tick::from_seconds_f64(1.0);
check(&format!("comp camera {depth:?}"), compare_at(&s, opts(), at), 0.0);
check(&format!("comp camera {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, at), 0.0);
}
}
#[test]
fn particle_systems_ii_types_and_modes() {
for kind in 0..9 {
+16
View File
@@ -620,9 +620,25 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo
layer.start_time.0.hash(&mut h);
layer.stretch.to_bits().hash(&mut h);
}
// Effects that see the comp camera or lights (#397) change when they or the layer move.
if reads_comp_scene(layer) {
hash_debug(&mut h, &crate::comp_scene(ctx, layer));
}
Some(h.finish())
}
/// Does the layer run an effect that reads the comp camera or lights (Card Dance, CC Particle
/// World…)?
fn reads_comp_scene(layer: &Layer) -> bool {
layer.switches.effects
&& layer.effects().is_some_and(|fx| {
fx.groups().any(|g| {
g.enabled
&& matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect } if effectcraft_effects::READS_COMP_SCENE.contains(&effect.as_str()))
})
})
}
/// Cache key for the styled layer (content key + the Layer Styles group: structure, eye
/// switches, values, keyframes, expressions and their values at the context time — Global Light
/// included, as every layer mirrors it in its Blending Options).
+29 -22
View File
@@ -106,6 +106,34 @@ impl FxHost<'_, '_, '_> {
}
}
/// The comp's camera and first light relative to `layer`, in its layer pixels (see
/// [`EffectHost::comp_scene`]): the comp camera's view of the layer (as if 3D at its transform),
/// brought back into the layer's own pixel grid through the inverse of how the layer itself
/// composites.
pub(crate) fn comp_scene(ctx: &EvalCtx, layer: &Layer) -> Option<effectcraft_effects::CompScene> {
let world = ctx.world_matrix(layer);
let (cam, _, _) = ctx.camera();
let p = (cam * world).0;
let back = ctx.layer_to_comp(layer).0.inverse()?.0;
let rows = [p[0], p[1], p[3]];
let camera: [[f64; 4]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| back[i][k] * rows[k][j]).sum()));
let inv = world.inverse();
let light = three_d::light::lights_at(ctx).first().and_then(|l| {
let inv = inv.as_ref()?;
let pos = inv.apply(l.pos);
let dir = inv.apply_vec(l.dir);
let len = (dir.x * dir.x + dir.y * dir.y + dir.z * dir.z).sqrt().max(1e-12);
let kind = match l.kind {
effectcraft_project::LightKind::Parallel => 0,
effectcraft_project::LightKind::Ambient => 2,
_ => 1,
};
Some(effectcraft_effects::CompLight { pos: [pos.x, pos.y, pos.z], dir: [dir.x / len, dir.y / len, dir.z / len], color: l.color, kind })
});
let camera_layer = ctx.comp.active_camera(ctx.time).is_some();
Some(effectcraft_effects::CompScene { camera: Some(camera), camera_layer, light })
}
/// Nesting limit for effects that render layers (other layers, other times).
const MAX_FX_DEPTH: usize = 8;
@@ -186,28 +214,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
}
fn comp_scene(&self) -> Option<effectcraft_effects::CompScene> {
// The comp camera's view of the layer (as if 3D at its transform), brought back into the
// layer's own pixel grid through the inverse of how the layer itself composites.
let world = self.ctx.world_matrix(self.layer);
let (cam, _, _) = self.ctx.camera();
let p = (cam * world).0;
let back = self.ctx.layer_to_comp(self.layer).0.inverse()?.0;
let rows = [p[0], p[1], p[3]];
let camera: [[f64; 4]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| back[i][k] * rows[k][j]).sum()));
let inv = world.inverse();
let light = three_d::light::lights_at(self.ctx).first().and_then(|l| {
let inv = inv.as_ref()?;
let pos = inv.apply(l.pos);
let dir = inv.apply_vec(l.dir);
let len = (dir.x * dir.x + dir.y * dir.y + dir.z * dir.z).sqrt().max(1e-12);
let kind = match l.kind {
effectcraft_project::LightKind::Parallel => 0,
effectcraft_project::LightKind::Ambient => 2,
_ => 1,
};
Some(effectcraft_effects::CompLight { pos: [pos.x, pos.y, pos.z], dir: [dir.x / len, dir.y / len, dir.z / len], color: l.color, kind })
});
Some(effectcraft_effects::CompScene { camera: Some(camera), light }.rebased(self.origin))
Some(comp_scene(self.ctx, self.layer)?.rebased(self.origin))
}
fn layer_at(&self, id: u64, comp_time: f64, masks_and_effects: bool) -> Option<LayerPixels> {
+45
View File
@@ -176,6 +176,51 @@ fn card_dance_comp_camera_matches_the_default_view() {
assert!(img.get(10, 50)[3] < 0.01);
}
/// A black 2D solid with CC Particle World, under a camera layer panned right by `pan` px.
fn particle_world_scene(pan: f64) -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100);
let vals = [("birthRate", Value::Scalar(2.0)), ("particle/particleType", Value::Enum(2)), ("particle/birthSize", Value::Scalar(0.1))];
add_effect(&mut p, &mut l, "ec.sim.ccparticleworld", [200.0, 100.0], &vals);
let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (200, 100), None);
for path in ["transform/position", "transform/poi"] {
let pr = cam.props.prop_mut(path).unwrap();
let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) };
pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]);
}
p.comp_mut(cid).unwrap().layers = vec![cam, l];
(p, cid)
}
/// The red-weighted centre of the particles (x).
fn particles_x(img: &crate::Image) -> f32 {
let (mut m, mut sx) = (0.0, 0.0);
for y in 0..img.height {
for x in 0..img.width {
let r = img.get(x as i64, y as i64)[0];
m += r;
sx += r * x as f32;
}
}
assert!(m > 1.0, "particles drawn");
sx / m
}
/// #397: CC Particle World on a 2D layer is seen through the comp's camera layer, and the layer
/// cache notices the camera moving.
#[test]
fn particle_world_follows_the_comp_camera() {
let t = Tick::from_seconds_f64(1.0);
let (p, cid) = particle_world_scene(0.0);
let (q, _) = particle_world_scene(40.0);
let cache = crate::LayerCache::default();
let here = render_cached(&p, cid, t, Some(&cache));
let panned = render_cached(&q, cid, t, Some(&cache));
assert_same(&panned, &render_cached(&q, cid, t, None), "cached render after the camera moved");
let (a, b) = (particles_x(&here), particles_x(&panned));
assert!(b < a - 20.0, "panning the camera right moves the particles left: {a} -> {b}");
}
// ------------------------------------------------------------------------------ layer cache
fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image {
+2 -2
View File
@@ -3,7 +3,7 @@
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
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.
EffectCraft ships 306 effects with 3014 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**: 280 effects also run on the GPU compositor with identical results.
- **32**: 306 effects process 32-bit float (HDR, overbright) pixels without clamping.
@@ -323,7 +323,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| 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 | GPU | 32 | Implemented |
| CC Particle World | `ec.sim.ccparticleworld` | 30 | GPU | 32 | Implemented |
| CC Particle World | `ec.sim.ccparticleworld` | 35 | 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 | GPU | 32 | Implemented |
+1 -1
View File
@@ -34,7 +34,7 @@ of them have been reconciled.
| Behaves like After Effects | largely unmeasured, ≈ 60–80% | One outside contributor found four bugs in features marked done within a day (PRs #6–#10): Hold keyframes eased the motion into them, `keyInSpatialTangent` / `keyOutSpatialTangent` had the wrong names, a zero frame rate crashed, Find and Enter Full Screen shared Ctrl+F off macOS. There are about 10 After Effects reference captures in total. The first live G1 comparison now checks eight scalar Rotation/ease cases (168 samples) against AE 26.3x87 via AEsync 2.0.4: all pass after fixing overlapping temporal influences, with maximum error below 3e-11 degrees ([scores and reproduction](fidelity/README.md)). Other animation behavior and rendered frames remain unmeasured. User reports of 6 October found five more in features marked done (the Render Queue's template menu, angle revolutions, switches wiping the RAM preview, audio playback skipping frames, preview crashing when video memory ran out; all fixed 7 October) |
| Opening existing After Effects work | ≈ 0% | `.aep` / `.aepx` projects cannot be opened. Third-party After Effects plug-ins cannot run. Expressions and the scripting object model are strong, so scripts and expressions carry over |
| Stability | improving | 23 never-crash PRs landed on 4 October; community PRs on 7 October fixed a hang on projects whose parent chains loop (#142), a panic on non-ASCII label colours (#135), and contained GPU initialization and device failures (#139); [AGENTS.md](../AGENTS.md) "Never crash" now binds every crate. On 5 October `cargo xtask ci` failed on main under Rust 1.99's clippy (a fix is in progress) |
| Real-user experience | ≈ 70%, uneven by platform | Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Reports of 8 October (browser, Windows, macOS): an empty project at 1280 × 800 with device pixel ratio 2 crashed on its first frame, because the Project panel's scroll bar clamped its thumb to a track shorter than the thumb (#231, any short panel on every platform); Media Browser ▸ Add Files… dropped a file it couldn't read without a word (#226); after the page's WebGPU device was lost, the browser editor froze on its last frame while edits went on (#225); a press in the viewer always took the topmost layer, so a selected layer behind others couldn't be dragged (#230); the language didn't follow the system's (#229); and a list of missing After Effects behaviour (#227): drawing into, copying, pasting and duplicating shape contents, Tool Creates Shape / Mask and Fill / Stroke Options, Turbulent Displace, Wave Warp and Bulge pinning all but a shape layer's bottom-right quadrant, a Spacebar hold that previewed instead of being the Hand tool, the Effect menu in Effect Controls, dropping items on New Composition and dragging over layer switches. All were fixed with regression tests the same day (#235, #237–#241); multi-monitor support and the Project panel's empty-area menu (community PR #210) remain from #227, and effect points on shape and text layers still sit half a comp off. A list of tweaks from an Intel iMac (#290): the layer bar didn't show where extended footage runs past its source, and Enable Time Remapping then ended at the extended out point; footage items had no Interpret Footage in their menu and the work area bar no menu; Label menus named the labels without their colours; the arrow keys didn't nudge layers; a straight motion path had no Bezier handles; Delete on Time Remap deleted the layer; a mask being drawn with the Pen blacked out its layer until it was closed; a doubleLine truncated
| Real-user experience | ≈ 70%, uneven by platform | Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Reports of 8 October (browser, Windows, macOS): an empty project at 1280 × 800 with device pixel ratio 2 crashed on its first frame, because the Project panel's scroll bar clamped its thumb to a track shorter than the thumb (#231, any short panel on every platform); Media Browser ▸ Add Files… dropped a file it couldn't read without a word (#226); after the page's WebGPU device was lost, the browser editor froze on its last frame while edits went on (#225); a press in the viewer always took the topmost layer, so a selected layer behind others couldn't be dragged (#230); the language didn't follow the system's (#229); and a list of missing After Effects behaviour (#227): drawing into, copying, pasting and duplicating shape contents, Tool Creates Shape / Mask and Fill / Stroke Options, Turbulent Displace, Wave Warp and Bulge pinning all but a shape layer's bottom-right quadrant, a Spacebar hold that previewed instead of being the Hand tool, the Effect menu in Effect Controls, dropping items on New Composition and dragging over layer switches. All were fixed with regression tests the same day (#235, #237–#241); multi-monitor support and the Project panel's empty-area menu (community PR #210) remain from #227, and effect points on shape and text layers still sit half a comp off. A list of tweaks from an Intel iMac (#290): the layer bar didn't show where extended footage runs past its source, and Enable Time Remapping then ended at the extended out point; footage items had no Interpret Footage in their menu and the work area bar no menu; Label menus named the labels without their colours; the arrow keys didn't nudge layers; a straight motion path had no Bezier handles; Delete on Time Remap deleted the layer; a mask being drawn with the Pen blacked out its layer until it was closed; a doubleLine truncated
| Performance | unknown against After Effects | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU, an M4 Pro under load). Nothing benchmarked against After Effects; no large real projects (4K footage, hundreds of layers) tested |
| Media formats | ≈ 80% | H.264, ProRes, HEVC, AV1, image sequences and audio exist. The new HEVC / AV1 encoders have no B-frames, multi-reference or SAO / CDEF, so files are larger than from mature encoders. Camera formats (BRAW, R3D, ProRes RAW, variable-frame-rate phone video) are unverified |
| AI-assisted tools | ≈ 50% | Both tools can use trained models (pure-Rust inference, optional downloads), but nothing compares them with After Effects yet. Roto Brush 2.0 / 3.0 with MobileSAM (M13.35) scores IoU 0.989 on the base frame of our synthetic moving-disc test and ≥ 0.980 over 20 propagated frames (classic: 0.973). Face tracking with MediaPipe Face Landmarker (M13.36) matches Google's own pipeline to 0.85 px on average on a test portrait; on our synthetic clip the eyes and chin stay within 4% of the face height |
+4 -1
View File
@@ -814,7 +814,10 @@ are pinned bit for bit by golden hashes (`crates/effects/tests/particle_golden.r
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.
one, with the same per-pixel arithmetic. Since #397 CC Particle World sees its particles
through the comp's active camera layer, as After Effects does, and through Extras ▸ Effect
Camera (Distance, Rotation X / Y / Z, FOV) when there is none; both compositors draw the same
plan, and the layer cache keys the effects that read the comp camera or lights by them.
- **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