Merge pull request #1610 from marcoarnulfo/fix/particle-world-texture-layer

fix(effects): let CC Particle World's Textured Square use a Texture Layer
This commit is contained in:
BFlat authored and GitHub committed 2026-10-30 09:53:57 +07:00
commit 701e78bbc0
7 files changed
+173 -10

No files matched your search

+8 -2
View File
@@ -92,8 +92,8 @@ 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 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,
@@ -101,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 };
@@ -224,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))]));
+1
View File
@@ -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};
+55 -4
View File
@@ -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,20 +35,44 @@ pub(super) fn pw_phys(ctx: &EffectCtx) -> Phys {
}
}
/// 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: `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()
}
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. 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),
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<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);
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);
}
@@ -60,11 +84,38 @@ pub(super) fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf {
};
}
match particle_world_plan(ctx, &b) {
Some(plan) => plan.finish(b),
Some(plan) => {
let fx = draw(&plan);
plan.finish_with(b, fx)
}
None => 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;
+2 -1
View File
@@ -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<EffectSpec> {
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()),
+1 -1
View File
@@ -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;
}
+104
View File
@@ -383,6 +383,110 @@ fn particle_world_effect_camera() {
assert_ne!(key(&params), key(&moved));
}
/// A host whose every layer is `img`.
struct TexHost(Image);
impl crate::EffectHost for TexHost {
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
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<Vec<f32>> {
None
}
}
/// 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] });
}
}
tex
}
/// CC Particle World drawing red Textured Squares with Texture Layer `layer`.
fn pw_textured_vals(layer: Option<u64>) -> 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();
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]));
}
/// #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");
}
/// 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<crate::LayerPixels> {
Some(self.0.clone())
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
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![
+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 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.<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` | 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 |