diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 85be739ed..332968baf 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -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))])); diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 333549e7d..ca5bc5126 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 440951c83..c2f493ab5 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,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 = 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; 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 06ce973b2..4c708dae4 100644 --- a/crates/effects/src/sim/tests.rs +++ b/crates/effects/src/sim/tests.rs @@ -383,6 +383,110 @@ 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 + } +} + +/// 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) -> 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 { + 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![ 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 |