Merge pull request #1292 from vbasky/fix/1195-particle-mblur

CC Particle World: motion blur (#1195)
This commit is contained in:
BFlat
2026-10-16 19:01:49 +07:00
committed by GitHub
2 changed files with 74 additions and 2 deletions
+11
View File
@@ -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: &params, 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))]));
+63 -2
View File
@@ -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);