From 0c35a7a889de94f8c02c2a5921a7af4d719f2af5 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 02:43:47 +0200 Subject: [PATCH 1/4] fix(effects): let CC Particle World's Textured Square use a Texture Layer (fixes #1465) --- crates/effects/src/catalog.rs | 1 + crates/effects/src/sim/particle_fx.rs | 52 ++++++++++++++++++++++++--- crates/effects/src/sim/registry.rs | 3 +- crates/effects/src/sim/sprites.rs | 2 +- crates/effects/src/sim/tests.rs | 47 ++++++++++++++++++++++++ docs/effects.md | 4 +-- 6 files changed, 100 insertions(+), 9 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 85be739ed..bac2bb065 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -89,6 +89,7 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[ ("ec.distort.ccpowerpin", &["unstretch"]), ("ec.matte.refinesoft", &["reduceChatter", "useMotionBlur", "invert"]), ("ec.matte.refinehard", &["reduceChatter", "useMotionBlur", "invert"]), + ("ec.sim.ccparticleworld", &["particle/texture/textureLayer"]), ]; /// Whether the GPU kernel of effect `id` covers the instance's current settings (see diff --git a/crates/effects/src/sim/particle_fx.rs b/crates/effects/src/sim/particle_fx.rs index 440951c83..ca5c3c279 100644 --- a/crates/effects/src/sim/particle_fx.rs +++ b/crates/effects/src/sim/particle_fx.rs @@ -1,5 +1,5 @@ use super::engine::{MERCURY, PS2, PState, PW_EFFECT_CAMERA, Phys, norm3, particle_look, pw_state, type_sprite}; -use super::sprites::{Acc, Post, Sprite, SpritePlan, raster, splat}; +use super::sprites::{Acc, Post, Sprite, SpritePlan, aa, raster, splat, sprite_bbox}; use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, unpremul}; // ------------------------------------------------------------------ CC Particle World @@ -35,7 +35,18 @@ pub(super) fn pw_phys(ctx: &EffectCtx) -> Phys { } } -pub(super) fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf { +/// CC Particle World's Particle Type ▸ Textured Square. +const PW_TEXTURED_SQUARE: u32 = 8; +/// CC Particle World's Particle ▸ Texture ▸ Texture Layer. +pub(super) const PW_TEXTURE_LAYER: &str = "particle/texture/textureLayer"; + +/// Whether CC Particle World draws its Texture Layer on the particles (Textured Square with a +/// layer chosen). Only the CPU does: the Texture Layer is a CPU-only control in `catalog`. +fn particle_world_textured(ctx: &EffectCtx) -> bool { + ctx.params.e("particle/particleType") == PW_TEXTURED_SQUARE && ctx.params.get(PW_TEXTURE_LAYER).and_then(Value::as_layer).is_some() +} + +pub(super) fn particle_world(ctx: &EffectCtx, mut 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.) @@ -59,12 +70,43 @@ pub(super) fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf { None => b, }; } - match particle_world_plan(ctx, &b) { - Some(plan) => plan.finish(b), - None => b, + let Some(plan) = particle_world_plan(ctx, &b) else { return b }; + // The texture at the current comp time. A layer the host can't give (the effect's own + // layer with its effects, a reference cycle) leaves the squares in the particle colour. + match particle_world_textured(ctx).then(|| ctx.layer_param(PW_TEXTURE_LAYER, true)).flatten() { + // The particles replace the layer ([`particle_sprites`]). + Some(tex) => { + b.img = splat_textured(b.img.width, b.img.height, &plan.sprites, plan.acc, &tex); + b + } + None => plan.finish(b), } } +/// Draw square sprites into a new transparent image of `w`×`h`, each showing the layer `tex` +/// stretched over the square (turned with it) at the sprite's opacity. +fn splat_textured(w: u32, hgt: u32, sprites: &[Sprite], acc: Acc, tex: &crate::LayerPixels) -> Image { + let mut img = Image::new(w, hgt); + let (tw, th) = (tex.size[0], tex.size[1]); + let shade = |s: &Sprite, x: f32, y: f32| { + // As [`sprite_shade`]'s Square, sub-pixel sprites included. + let (r, fade) = if s.r < 0.5 { (0.5, s.r / 0.5) } else { (s.r, 1.0) }; + let (sn, cs) = s.rot.sin_cos(); + let (dx, dy) = (x - s.x, y - s.y); + let (ux, uy) = (dx * cs + dy * sn, -dx * sn + dy * cs); + let e = r * 0.75; + let a = aa(e - ux.abs()) * aa(e - uy.abs()) * s.c[3] * fade; + if a <= 1e-6 { + return None; + } + let (u, v) = (((ux / e + 1.0) * 0.5).clamp(0.0, 1.0) as f64, ((uy / e + 1.0) * 0.5).clamp(0.0, 1.0) as f64); + let (px, py) = tex.buf.to_px([u * tw, v * th]); + Some(tex.buf.img.sample_bilinear_clamped(px, py).map(|c| c * a)) + }; + raster(&mut img, sprites, sprite_bbox, shade, acc); + img +} + /// Effect Camera ▸ FOV's default (degrees). pub(super) const PW_FOV: f64 = 50.0; diff --git a/crates/effects/src/sim/registry.rs b/crates/effects/src/sim/registry.rs index f29eb2653..f55b99e27 100644 --- a/crates/effects/src/sim/registry.rs +++ b/crates/effects/src/sim/registry.rs @@ -1,6 +1,6 @@ use super::common::{pct, pt, spec}; use super::engine::{PARTICLE_TYPES, PW_ANIMS}; -use super::particle_fx::{PW_FOV, mr_mercury, particle_systems2, particle_systems2_plan, particle_world, particle_world_plan}; +use super::particle_fx::{PW_FOV, PW_TEXTURE_LAYER, mr_mercury, particle_systems2, particle_systems2_plan, particle_world, particle_world_plan}; use super::sprites::SpritePlan; use super::weather::{bubbles, drizzle, rainfall, rainfall_plan, snowfall, snowfall_plan, star_burst, star_burst_plan}; use super::{Buf, EffectCtx, EffectSpec, ParamUi, Value, col, num, p, popup, slider}; @@ -151,6 +151,7 @@ pub fn specs() -> Vec { p("physics/gravityVector/gravityY", "Gravity Y", num(1.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), p("physics/gravityVector/gravityZ", "Gravity Z", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), p("particle/particleType", "Particle Type", Value::Enum(0), popup(PARTICLE_TYPES)), + p(PW_TEXTURE_LAYER, "Texture Layer", Value::Layer(None), ParamUi::Layer), p("particle/birthSize", "Birth Size", num(0.2), slider(0.0, 10.0, 0.0, 1.0, 3)), p("particle/deathSize", "Death Size", num(0.2), slider(0.0, 10.0, 0.0, 1.0, 3)), p("particle/sizeVariation", "Size Variation", num(0.0), pct()), diff --git a/crates/effects/src/sim/sprites.rs b/crates/effects/src/sim/sprites.rs index 78c405abd..55ba594b4 100644 --- a/crates/effects/src/sim/sprites.rs +++ b/crates/effects/src/sim/sprites.rs @@ -225,7 +225,7 @@ impl Post { } } -fn sprite_bbox(s: &Sprite) -> Option<[f32; 4]> { +pub(super) fn sprite_bbox(s: &Sprite) -> Option<[f32; 4]> { if s.c[3] <= 1e-5 || s.r <= 0.0 { return None; } diff --git a/crates/effects/src/sim/tests.rs b/crates/effects/src/sim/tests.rs index e368c7bbe..a1d2f14e2 100644 --- a/crates/effects/src/sim/tests.rs +++ b/crates/effects/src/sim/tests.rs @@ -329,6 +329,53 @@ fn particle_world_effect_camera() { assert_ne!(key(¶ms), key(&moved)); } +/// A host whose every layer is `img`. +struct TexHost(Image); +impl crate::EffectHost for TexHost { + fn layer(&self, _: u64, _: bool) -> Option { + let s = [self.0.width as f64, self.0.height as f64]; + Some(crate::LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: s }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } +} + +/// #1465: Textured Square draws the Texture Layer on each particle, not the particle colour. +#[test] +fn particle_world_textured_square_draws_the_texture_layer() { + // Left half green, right half blue; the particles themselves are red. + let mut tex = Image::new(8, 8); + for y in 0..8 { + for x in 0..8 { + tex.set(x, y, if x < 4 { [0.0, 1.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] }); + } + } + let host = TexHost(tex); + let vals = |layer: Option| { + vec![ + ("birthRate", num(1.0)), + ("extras/randomSeed", num(4.0)), + ("particle/particleType", Value::Enum(8)), + ("particle/texture/textureLayer", Value::Layer(layer)), + ("particle/birthSize", num(1.0)), + ("particle/deathSize", num(1.0)), + ("particle/birthColor", col(1.0, 0.0, 0.0)), + ("particle/deathColor", col(1.0, 0.0, 0.0)), + ] + }; + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let out = run_fx("ec.sim.ccparticleworld", &vals(Some(1)), Image::new(48, 48), 1.0, env).img; + let seen: Vec<_> = out.data.iter().filter(|p| p[3] > 0.05).collect(); + assert!(!seen.is_empty(), "particles drawn"); + assert!(seen.iter().all(|p| p[0] < 0.02 * p[3]), "no particle colour: {:?}", seen.iter().find(|p| p[0] >= 0.02 * p[3])); + // Both halves of the texture show: it is mapped across the square, not sampled once. + assert!(seen.iter().any(|p| p[1] > 2.0 * p[2]) && seen.iter().any(|p| p[2] > 2.0 * p[1]), "both texture halves"); + // Without a texture layer the squares keep the particle colour. + let plain = run_fx("ec.sim.ccparticleworld", &vals(None), Image::new(48, 48), 1.0, env).img; + assert!(plain.data.iter().any(|p| p[3] > 0.05 && p[0] > 0.9 * p[3])); +} + #[test] fn particle_systems2_seek_and_direction() { let v = vec![ diff --git a/docs/effects.md b/docs/effects.md index c6fb40922..b3adb46ec 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,7 +3,7 @@ -EffectCraft ships 308 effects with 3033 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 308 effects with 3034 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**: 308 effects process 32-bit float (HDR, overbright) pixels without clamping. @@ -325,7 +325,7 @@ Effects are addressed by id (`ec..`) 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` | 35 | GPU | 32 | Implemented | +| CC Particle World | `ec.sim.ccparticleworld` | 36 | 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 | From 2a5e9066e5e74666776a651b75af5fa83a95586b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 21 Oct 2026 10:33:36 +0200 Subject: [PATCH 2/4] fix(effects): draw Particle World's Texture Layer in the motion-trail samples too With the shutter open the textured squares fell back to the particle colour. Both branches now draw through the same sprite closure, and the sharp frame goes through plan.post like the trails. --- crates/effects/src/sim/particle_fx.rs | 29 ++++++++------ crates/effects/src/sim/tests.rs | 56 +++++++++++++++++++-------- 2 files changed, 56 insertions(+), 29 deletions(-) diff --git a/crates/effects/src/sim/particle_fx.rs b/crates/effects/src/sim/particle_fx.rs index ca5c3c279..a17a9f68b 100644 --- a/crates/effects/src/sim/particle_fx.rs +++ b/crates/effects/src/sim/particle_fx.rs @@ -46,20 +46,29 @@ fn particle_world_textured(ctx: &EffectCtx) -> bool { ctx.params.e("particle/particleType") == PW_TEXTURED_SQUARE && ctx.params.get(PW_TEXTURE_LAYER).and_then(Value::as_layer).is_some() } -pub(super) fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf { +pub(super) fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf { + // The texture at the current comp time, shared by every shutter sample. A layer the host + // can't give (the effect's own layer with its effects, a reference cycle) leaves the + // squares in the particle colour. + let tex = particle_world_textured(ctx).then(|| ctx.layer_param(PW_TEXTURE_LAYER, true)).flatten(); + let (w, h) = (b.img.width, b.img.height); + let draw = |plan: &SpritePlan| match &tex { + Some(t) => splat_textured(w, h, &plan.sprites, plan.acc, t), + None => splat(w, h, &plan.sprites, plan.acc), + }; // #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 acc = Image::new(w, h); let mut post: Option = 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); + let fx = draw(&plan); 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); } @@ -70,16 +79,12 @@ pub(super) fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf { None => b, }; } - let Some(plan) = particle_world_plan(ctx, &b) else { return b }; - // The texture at the current comp time. A layer the host can't give (the effect's own - // layer with its effects, a reference cycle) leaves the squares in the particle colour. - match particle_world_textured(ctx).then(|| ctx.layer_param(PW_TEXTURE_LAYER, true)).flatten() { - // The particles replace the layer ([`particle_sprites`]). - Some(tex) => { - b.img = splat_textured(b.img.width, b.img.height, &plan.sprites, plan.acc, &tex); - b + match particle_world_plan(ctx, &b) { + Some(plan) => { + let fx = draw(&plan); + plan.finish_with(b, fx) } - None => plan.finish(b), + None => b, } } diff --git a/crates/effects/src/sim/tests.rs b/crates/effects/src/sim/tests.rs index a1d2f14e2..59780ca74 100644 --- a/crates/effects/src/sim/tests.rs +++ b/crates/effects/src/sim/tests.rs @@ -341,29 +341,36 @@ impl crate::EffectHost for TexHost { } } -/// #1465: Textured Square draws the Texture Layer on each particle, not the particle colour. -#[test] -fn particle_world_textured_square_draws_the_texture_layer() { - // Left half green, right half blue; the particles themselves are red. +/// A texture whose left half is green and right half blue. +fn two_tone_tex() -> Image { let mut tex = Image::new(8, 8); for y in 0..8 { for x in 0..8 { tex.set(x, y, if x < 4 { [0.0, 1.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] }); } } - let host = TexHost(tex); - let vals = |layer: Option| { - vec![ - ("birthRate", num(1.0)), - ("extras/randomSeed", num(4.0)), - ("particle/particleType", Value::Enum(8)), - ("particle/texture/textureLayer", Value::Layer(layer)), - ("particle/birthSize", num(1.0)), - ("particle/deathSize", num(1.0)), - ("particle/birthColor", col(1.0, 0.0, 0.0)), - ("particle/deathColor", col(1.0, 0.0, 0.0)), - ] - }; + tex +} + +/// CC Particle World drawing red Textured Squares with Texture Layer `layer`. +fn pw_textured_vals(layer: Option) -> Vec<(&'static str, Value)> { + vec![ + ("birthRate", num(1.0)), + ("extras/randomSeed", num(4.0)), + ("particle/particleType", Value::Enum(8)), + ("particle/texture/textureLayer", Value::Layer(layer)), + ("particle/birthSize", num(1.0)), + ("particle/deathSize", num(1.0)), + ("particle/birthColor", col(1.0, 0.0, 0.0)), + ("particle/deathColor", col(1.0, 0.0, 0.0)), + ] +} + +/// #1465: Textured Square draws the Texture Layer on each particle, not the particle colour. +#[test] +fn particle_world_textured_square_draws_the_texture_layer() { + let host = TexHost(two_tone_tex()); + let vals = pw_textured_vals; let env = EffectEnv { host: Some(&host), ..Default::default() }; let out = run_fx("ec.sim.ccparticleworld", &vals(Some(1)), Image::new(48, 48), 1.0, env).img; let seen: Vec<_> = out.data.iter().filter(|p| p[3] > 0.05).collect(); @@ -376,6 +383,21 @@ fn particle_world_textured_square_draws_the_texture_layer() { assert!(plain.data.iter().any(|p| p[3] > 0.05 && p[0] > 0.9 * p[3])); } +/// #1465 with #1195's motion trails: with the shutter open the averaged samples still show +/// the Texture Layer, not the particle colour. +#[test] +fn particle_world_textured_square_motion_trails_draw_the_texture_layer() { + let host = TexHost(two_tone_tex()); + let env = EffectEnv { host: Some(&host), frame_rate: 10.0, shutter: Some((360.0, -180.0, 8)), ..Default::default() }; + let blurred = run_fx("ec.sim.ccparticleworld", &pw_textured_vals(Some(1)), Image::new(48, 48), 1.0, env).img; + let sharp = run_fx("ec.sim.ccparticleworld", &pw_textured_vals(Some(1)), Image::new(48, 48), 1.0, EffectEnv { shutter: None, ..env }).img; + assert_ne!(sharp.data, blurred.data, "the shutter smears the particles"); + let seen: Vec<_> = blurred.data.iter().filter(|p| p[3] > 0.02).collect(); + assert!(!seen.is_empty(), "particles drawn"); + assert!(seen.iter().all(|p| p[0] < 0.02 * p[3]), "no particle colour: {:?}", seen.iter().find(|p| p[0] >= 0.02 * p[3])); + assert!(seen.iter().any(|p| p[1] > 2.0 * p[2]) && seen.iter().any(|p| p[2] > 2.0 * p[1]), "both texture halves"); +} + #[test] fn particle_systems2_seek_and_direction() { let v = vec![ From a76c65bd007defda63dacdc04c82fed3c3d85075 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 21 Oct 2026 10:38:37 +0200 Subject: [PATCH 3/4] fix(effects): keep Particle World on the GPU unless Textured Square draws the Texture Layer A Texture Layer left set with another Particle Type forced the CPU for nothing. gpu_supported now asks the same predicate the CPU path uses. --- crates/effects/src/catalog.rs | 11 ++++++++--- crates/effects/src/sim.rs | 1 + crates/effects/src/sim/particle_fx.rs | 4 ++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index bac2bb065..332968baf 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -89,12 +89,11 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[ ("ec.distort.ccpowerpin", &["unstretch"]), ("ec.matte.refinesoft", &["reduceChatter", "useMotionBlur", "invert"]), ("ec.matte.refinehard", &["reduceChatter", "useMotionBlur", "invert"]), - ("ec.sim.ccparticleworld", &["particle/texture/textureLayer"]), ]; /// Whether the GPU kernel of effect `id` covers the instance's current settings (see -/// `CPU_ONLY_CONTROLS`; plus Cell Pattern's HQ variants and Turbulent Displace's locked -/// pinning). +/// `CPU_ONLY_CONTROLS`; plus Cell Pattern's HQ variants, Turbulent Displace's locked +/// pinning and CC Particle World's motion trails and textured squares). 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, @@ -102,6 +101,8 @@ pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool { // #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, + // #1465: Textured Square's Texture Layer is drawn on the CPU only. + "ec.sim.ccparticleworld" if crate::sim::particle_world_textured(ctx) => return false, _ => {} } let Some((_, controls)) = CPU_ONLY_CONTROLS.iter().find(|(e, _)| *e == id) else { return true }; @@ -225,6 +226,10 @@ mod tests { assert!(!super::gpu_supported("ec.sim.ccparticleworld", &ctx)); } assert!(ok("ec.sim.ccparticleworld", &[])); + // #1465: the Texture Layer keeps CC Particle World on the CPU only when Textured Square draws it. + let tex = ("particle/texture/textureLayer", Value::Layer(Some(1))); + assert!(ok("ec.sim.ccparticleworld", &[tex.clone(), ("particle/particleType", Value::Enum(2))])); + assert!(!ok("ec.sim.ccparticleworld", &[tex, ("particle/particleType", Value::Enum(8))])); 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))])); diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 79a225f75..248a66178 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -32,6 +32,7 @@ use engine::{MERCURY, PS2, PW_EFFECT_CAMERA, Stepped, rate_history, sim_key, sim #[cfg(test)] pub(crate) use engine::{PState, PW_ANIMS, Phys, simulate}; pub(crate) use engine::{SPS, steps_at}; +pub(crate) use particle_fx::particle_world_textured; pub use particle_fx::{Blob, mercury_blobs}; #[cfg(test)] use particle_fx::{PW_RATE, mercury_phys, ps2_phys, pw_phys}; diff --git a/crates/effects/src/sim/particle_fx.rs b/crates/effects/src/sim/particle_fx.rs index a17a9f68b..382913964 100644 --- a/crates/effects/src/sim/particle_fx.rs +++ b/crates/effects/src/sim/particle_fx.rs @@ -41,8 +41,8 @@ const PW_TEXTURED_SQUARE: u32 = 8; pub(super) const PW_TEXTURE_LAYER: &str = "particle/texture/textureLayer"; /// Whether CC Particle World draws its Texture Layer on the particles (Textured Square with a -/// layer chosen). Only the CPU does: the Texture Layer is a CPU-only control in `catalog`. -fn particle_world_textured(ctx: &EffectCtx) -> bool { +/// layer chosen). Only the CPU does: `catalog::gpu_supported` keeps this case off the GPU. +pub(crate) fn particle_world_textured(ctx: &EffectCtx) -> bool { ctx.params.e("particle/particleType") == PW_TEXTURED_SQUARE && ctx.params.get(PW_TEXTURE_LAYER).and_then(Value::as_layer).is_some() } From efd6ab97dc082cf56baa008e17cbf2c2aabf60f0 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 21 Oct 2026 11:37:31 +0200 Subject: [PATCH 4/4] fix(effects): cover Particle World's Texture Layer buffer before sampling it Text and shape layers hand over a buffer that spans only their content; the clamped sampling then stretched its edge pixels across the squares. --- crates/effects/src/sim/particle_fx.rs | 8 ++++-- crates/effects/src/sim/tests.rs | 35 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/sim/particle_fx.rs b/crates/effects/src/sim/particle_fx.rs index 382913964..c2f493ab5 100644 --- a/crates/effects/src/sim/particle_fx.rs +++ b/crates/effects/src/sim/particle_fx.rs @@ -49,8 +49,12 @@ pub(crate) fn particle_world_textured(ctx: &EffectCtx) -> bool { pub(super) fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf { // The texture at the current comp time, shared by every shutter sample. A layer the host // can't give (the effect's own layer with its effects, a reference cycle) leaves the - // squares in the particle colour. - let tex = particle_world_textured(ctx).then(|| ctx.layer_param(PW_TEXTURE_LAYER, true)).flatten(); + // squares in the particle colour. Authored buffers (text, shapes) span only their content: + // covering the whole layer keeps the clamped sampling from stretching their edge pixels. + let tex = particle_world_textured(ctx).then(|| ctx.layer_param(PW_TEXTURE_LAYER, true)).flatten().map(|mut t| { + t.buf.cover(t.size); + t + }); let (w, h) = (b.img.width, b.img.height); let draw = |plan: &SpritePlan| match &tex { Some(t) => splat_textured(w, h, &plan.sprites, plan.acc, t), diff --git a/crates/effects/src/sim/tests.rs b/crates/effects/src/sim/tests.rs index 59780ca74..7166e3f0a 100644 --- a/crates/effects/src/sim/tests.rs +++ b/crates/effects/src/sim/tests.rs @@ -398,6 +398,41 @@ fn particle_world_textured_square_motion_trails_draw_the_texture_layer() { assert!(seen.iter().any(|p| p[1] > 2.0 * p[2]) && seen.iter().any(|p| p[2] > 2.0 * p[1]), "both texture halves"); } +/// A host whose every layer is `0` as given (a buffer that may span only its content). +struct PixelsHost(crate::LayerPixels); +impl crate::EffectHost for PixelsHost { + fn layer(&self, _: u64, _: bool) -> Option { + Some(self.0.clone()) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } +} + +/// #1465: a Texture Layer whose buffer spans only its content (text, shapes) is mapped as +/// the whole layer, transparent around the content, not with its edge pixels stretched. +#[test] +fn particle_world_texture_layer_with_a_content_sized_buffer() { + // An 8×8 layer with a green 4×4 square in the middle: once as the full layer, once as + // just the square's pixels. + let mut full = Image::new(8, 8); + for y in 2..6 { + for x in 2..6 { + full.set(x, y, [0.0, 1.0, 0.0, 1.0]); + } + } + let content = Image::filled(4, 4, [0.0, 1.0, 0.0, 1.0]); + let render = |buf: Buf| { + let host = PixelsHost(crate::LayerPixels { buf, size: [8.0, 8.0] }); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + run_fx("ec.sim.ccparticleworld", &pw_textured_vals(Some(1)), Image::new(48, 48), 1.0, env).img + }; + let want = render(Buf { img: full, offset: [0.0; 2], scale: 1.0 }); + let got = render(Buf { img: content, offset: [-2.0, -2.0], scale: 1.0 }); + assert!(want.data.iter().any(|p| p[3] > 0.05), "particles drawn"); + assert_eq!(want.data, got.data, "the content-sized buffer draws as the full layer"); +} + #[test] fn particle_systems2_seek_and_direction() { let v = vec![