mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 03:28:22 -06:00
CC Particle World: motion blur smears particles across the shutter (#1195)
With the comp's and the layer's motion-blur switches on, the renderer hands effects the shutter (angle, phase, samples) — CC Particle World now uses it: eight to sixty-four sprite fields sampled across the shutter are averaged into the transfer, so fast particles draw the motion trails After Effects shows instead of one sharp frozen field. The GPU particle pass defers to the CPU while the shutter is open (catalog::gpu_supported), keeping the oracle identical on both backends. Tests: sim::particle_world_motion_blur_trails (blurred differs from sharp, lights more pixels, keeps alpha energy) and a catalog assertion for the CPU gate; effects 450 and gpu 212 lib tests pass; fmt and clippy -D warnings clean.
This commit is contained in:
@@ -98,6 +98,9 @@ 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,
|
||||
// #1195: CC Particle World's motion trails (the averaged shutter samples) render on the
|
||||
// CPU kernel until the GPU particle pass supports them.
|
||||
"ec.sim.ccparticleworld" if ctx.env.shutter.filter(|(a, _, _)| *a > 0.0).is_some() => return false,
|
||||
_ => {}
|
||||
}
|
||||
let Some((_, controls)) = CPU_ONLY_CONTROLS.iter().find(|(e, _)| *e == id) else { return true };
|
||||
@@ -199,6 +202,14 @@ mod tests {
|
||||
assert!(ok("ec.color.lumetri", &[]));
|
||||
assert!(!ok("ec.color.lumetri", &[("hslSecondary/correction/saturation", Value::Scalar(50.0))]));
|
||||
assert!(!ok("ec.noise.addgrain", &[("preset", Value::Enum(2))]));
|
||||
// #1195: CC Particle World's motion trails (shutter samples) render on the CPU.
|
||||
{
|
||||
let params = ctx_with("ec.sim.ccparticleworld", &[]);
|
||||
let env = crate::EffectEnv { frame_rate: 30.0, shutter: Some((180.0, -90.0, 16)), ..Default::default() };
|
||||
let ctx = crate::EffectCtx { params: ¶ms, time: 0.0, layer_size: [64.0, 64.0], seed: 0, adjustment: false, env };
|
||||
assert!(!super::gpu_supported("ec.sim.ccparticleworld", &ctx));
|
||||
}
|
||||
assert!(ok("ec.sim.ccparticleworld", &[]));
|
||||
assert!(ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(3))]));
|
||||
assert!(!ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(9))]));
|
||||
assert!(!ok("ec.distort.turbulentdisplace", &[("pinning", Value::Enum(9))]));
|
||||
|
||||
@@ -238,9 +238,22 @@ impl SpritePlan {
|
||||
}
|
||||
|
||||
/// Rasterise (after [`SpritePlan::resolve`]) and meet the original layer `b`.
|
||||
pub fn finish(&self, mut b: Buf) -> Buf {
|
||||
pub fn finish(&self, b: Buf) -> Buf {
|
||||
let fx = splat(b.img.width, b.img.height, &self.sprites, self.acc);
|
||||
b.img = match self.post {
|
||||
self.finish_with(b, fx)
|
||||
}
|
||||
|
||||
/// [`finish`](Self::finish) with a prepared sprite image (the motion-blurred average of
|
||||
/// several frames, #1195).
|
||||
pub fn finish_with(&self, b: Buf, fx: Image) -> Buf {
|
||||
self.post.apply(b, fx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Post {
|
||||
/// Meet the sprite image `fx` with the original layer `b` (see [`SpritePlan::finish`]).
|
||||
fn apply(self, mut b: Buf, fx: Image) -> Buf {
|
||||
b.img = match self {
|
||||
Post::Combine(m) => combine(&b.img, &fx, m),
|
||||
Post::Replace => fx,
|
||||
Post::Lerp(t) => {
|
||||
@@ -1069,6 +1082,29 @@ fn pw_phys(ctx: &EffectCtx) -> Phys {
|
||||
}
|
||||
|
||||
fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
// #1195: with the comp's motion-blur shutter open, particles draw as motion trails: the
|
||||
// sprite fields of samples across the shutter are averaged, as Transform averages its
|
||||
// matrix samples. (The GPU compositor runs this case on the CPU: catalog::gpu_supported.)
|
||||
if let Some((angle, phase, n)) = ctx.env.shutter.filter(|(a, _, _)| *a > 0.0) {
|
||||
let n = (n.clamp(2, 64) as usize).max(2);
|
||||
let fd = 1.0 / ctx.fps().max(0.001);
|
||||
let mut acc = Image::new(b.img.width, b.img.height);
|
||||
let mut post: Option<Post> = None;
|
||||
for k in 0..n {
|
||||
let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd;
|
||||
let sub = EffectCtx { params: ctx.params, time: t, layer_size: ctx.layer_size, seed: ctx.seed, adjustment: ctx.adjustment, env: ctx.env };
|
||||
if let Some(plan) = particle_world_plan(&sub, &b) {
|
||||
let fx = splat(b.img.width, b.img.height, &plan.sprites, plan.acc);
|
||||
acc.data.iter_mut().zip(&fx.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
|
||||
post.get_or_insert(plan.post);
|
||||
}
|
||||
}
|
||||
acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= n as f32));
|
||||
return match post {
|
||||
Some(p) => p.apply(b, acc),
|
||||
None => b,
|
||||
};
|
||||
}
|
||||
match particle_world_plan(ctx, &b) {
|
||||
Some(plan) => plan.finish(b),
|
||||
None => b,
|
||||
@@ -1715,6 +1751,31 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
/// #1195: with the comp's shutter open, the particles smear across it: the average of the
|
||||
/// sampled sprite fields differs from the sharp frame, touches more pixels and keeps the
|
||||
/// alpha energy.
|
||||
#[test]
|
||||
fn particle_world_motion_blur_trails() {
|
||||
let v = pw_vals(21.0);
|
||||
let blank = Image::new(40, 40);
|
||||
let sharp = run("ec.sim.ccparticleworld", &v, blank.clone(), 2.0);
|
||||
let blurred = crate::run_fx(
|
||||
"ec.sim.ccparticleworld",
|
||||
&v,
|
||||
blank.clone(),
|
||||
2.0,
|
||||
EffectEnv { frame_rate: 10.0, shutter: Some((360.0, -180.0, 8)), ..Default::default() },
|
||||
)
|
||||
.img;
|
||||
assert!(sharp.data.iter().any(|p| p[3] > 0.0), "sharp frame has particles");
|
||||
assert_ne!(sharp.data, blurred.data, "the shutter smears the particles");
|
||||
let lit = |img: &Image| img.data.iter().filter(|p| p[3] > 0.005).count();
|
||||
let sum = |img: &Image| img.data.iter().map(|p| p[3]).sum::<f32>();
|
||||
assert!(lit(&blurred) > lit(&sharp), "trails light more pixels: {} vs {}", lit(&blurred), lit(&sharp));
|
||||
let (sa, ba) = (sum(&sharp), sum(&blurred));
|
||||
assert!((ba - sa).abs() < sa * 0.6 + 1.0, "alpha energy roughly kept: {ba} vs {sa}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_world_seek_consistent() {
|
||||
let v = pw_vals(11.0);
|
||||
|
||||
Reference in New Issue
Block a user