diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 93e3598b..a3d07eec 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -100,6 +100,8 @@ pub use ocio::color_stabilizer_maps; // GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes). pub use channel3d::{CRYPTO_LAYERS, id_color, selection_hashes}; pub use ocio::{ColorOp, Straight, Tf, color_program}; +pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan}; +pub use sim2::{CausticsSetup, Piece, PiecePlan, PieceTex, WavePlan, Waves, caustics_setup, piece_plan, wave_world_plan}; pub use utility::{Lut, load_lut}; pub use vr::{equi_dir as vr_equi_dir, rotation as vr_rotation}; @@ -966,6 +968,20 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.vr.rotatesphere", "ec.vr.sharpen", "ec.vr.spheretoplane", + // Simulation render passes. + "ec.sim.ccrainfall", + "ec.sim.ccsnowfall", + "ec.sim.ccstarburst", + "ec.sim.ccbubbles", + "ec.sim.ccdrizzle", + "ec.sim.cchair", + "ec.sim.ccmrmercury", + "ec.sim.caustics", + "ec.sim.waveworld", + "ec.sim.foam", + "ec.sim.shatter", + "ec.sim.carddance", + "ec.transition.cardwipe", ]; /// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 18beeb76..1745dccf 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -48,7 +48,7 @@ const BAND: usize = 16; /// How rasterised items combine with what is already in the target. #[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(crate) enum Acc { +pub enum Acc { /// Premultiplied "over" (later items on top). Over, /// Additive (glows, light). @@ -128,7 +128,7 @@ pub(crate) fn raster( // ------------------------------------------------------------------ sprites #[derive(Clone, Copy, PartialEq, Debug)] -pub(crate) enum Shape { +pub enum Shape { /// Soft round dot (Gaussian-like falloff). Soft, /// Hard anti-aliased disc. @@ -151,7 +151,7 @@ pub(crate) enum Shape { /// A particle sprite in buffer pixels; `c` is straight colour + opacity. #[derive(Clone, Copy, Debug)] -pub(crate) struct Sprite { +pub struct Sprite { pub x: f32, pub y: f32, pub r: f32, @@ -166,6 +166,112 @@ impl Sprite { } } +/// Where a sprite's colour comes from when it depends on the layer's pixels (resolved by +/// [`SpritePlan::resolve`] on the CPU, per pixel by the GPU compositor). Positions are buffer +/// pixels. +#[derive(Clone, Copy, Debug)] +pub enum Tint { + /// The sprite's own colour. + Fixed, + /// CC Rainfall's refracting drops: where the layer shows (alpha > 0), colour × (layer + /// colour × 0.6 + 0.5) sampled at (x, y). + Refract { x: f64, y: f64 }, + /// CC Star Burst: the layer's colour at (x, y), opacity × its alpha (none where clear). + Source { x: f64, y: f64 }, + /// CC Hair: the root's colour at (x, y) mixed with the hair colour (`inherit` = 1 keeps the + /// root's), × `shade`, + `spec`; no strand where the root's alpha is below ½. + Root { x: f64, y: f64, hair: [f32; 3], inherit: f32, shade: f32, spec: f32 }, +} + +/// How a sprite layer meets the original layer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Post { + /// [`combine`] with this mode (0 Composite … 5 sprites only). + Combine(u32), + /// The sprites alone. + Replace, + /// The sprites, mixed toward the original by this fraction. + Lerp(f32), + /// The sprites over opaque black. + OverBlack, +} + +/// What a sprite-drawing effect draws this frame: built on the CPU (closed-form or from the +/// simulation state), rasterised by [`SpritePlan::finish`] or the GPU compositor. +#[derive(Clone, Debug)] +pub struct SpritePlan { + pub sprites: Vec, + /// Per sprite (empty = all [`Tint::Fixed`]). + pub tints: Vec, + pub acc: Acc, + pub post: Post, +} + +impl SpritePlan { + /// Resolve layer-dependent colours against the layer `src`. + pub fn resolve(&mut self, src: &Image) { + let tints = std::mem::take(&mut self.tints); + self.sprites.par_iter_mut().zip(tints.par_iter()).for_each(|(sp, t)| match *t { + Tint::Fixed => {} + Tint::Refract { x, y } => { + let (sc, sa) = unpremul(src.sample_bilinear(x, y)); + if sa > 0.0 { + let c = sp.c; + sp.c = [(sc[0] * 0.6 + 0.5) * c[0], (sc[1] * 0.6 + 0.5) * c[1], (sc[2] * 0.6 + 0.5) * c[2], c[3]]; + } + } + Tint::Source { x, y } => { + let (c, a) = unpremul(src.sample_bilinear(x, y)); + sp.c = if a <= 0.0 { [0.0; 4] } else { [c[0], c[1], c[2], a * sp.c[3]] }; + } + Tint::Root { x, y, hair, inherit, shade, spec } => { + let root = src.sample_bilinear(x, y); + if root[3] < 0.5 { + sp.c = [0.0; 4]; + return; + } + let (rc, _) = unpremul(root); + let base: [f32; 3] = std::array::from_fn(|k| rc[k] + (hair[k] - rc[k]) * (1.0 - inherit)); + sp.c = [base[0] * shade + spec, base[1] * shade + spec, base[2] * shade + spec, sp.c[3]]; + } + }); + } + + /// Rasterise (after [`SpritePlan::resolve`]) and meet the original layer `b`. + pub fn finish(&self, mut b: Buf) -> Buf { + let fx = splat(b.img.width, b.img.height, &self.sprites, self.acc); + b.img = match self.post { + Post::Combine(m) => combine(&b.img, &fx, m), + Post::Replace => fx, + Post::Lerp(t) => { + let mut o = fx; + o.data.par_iter_mut().zip(b.img.data.par_iter()).for_each(|(o, s0)| *o = crate::util::lerp4(*o, *s0, t)); + o + } + Post::OverBlack => combine(&Image::filled(b.img.width, b.img.height, [0.0, 0.0, 0.0, 1.0]), &fx, 0), + }; + b + } +} + +/// The sprite plan of a sprite-drawing simulation effect for buffer geometry `b` (its pixels +/// are not read), `None` for other effects or settings drawn otherwise (the GPU compositor's +/// render pass). +pub fn sprite_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { + match id { + "ec.sim.ccrainfall" => Some(rainfall_plan(ctx, b)), + "ec.sim.ccsnowfall" => Some(snowfall_plan(ctx, b)), + "ec.sim.ccstarburst" => Some(star_burst_plan(ctx, b)), + "ec.sim.cchair" => crate::sim3::hair_plan(ctx, b), + "ec.sim.waveworld" => crate::sim2::wave_world_plan(ctx, b).and_then(|p| match p { + crate::sim2::WavePlan::Wire(sp) => Some(sp), + crate::sim2::WavePlan::Height { .. } => None, + }), + "ec.sim.foam" => crate::sim2::foam_plan(ctx, b), + _ => None, + } +} + fn sprite_bbox(s: &Sprite) -> Option<[f32; 4]> { if s.c[3] <= 1e-5 || s.r <= 0.0 { return None; @@ -316,6 +422,12 @@ fn wrap(v: f32, lo: f32, span: f32) -> f32 { } fn rainfall(ctx: &EffectCtx, b: Buf) -> Buf { + let mut plan = rainfall_plan(ctx, &b); + plan.resolve(&b.img); + plan.finish(b) +} + +fn rainfall_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { let pr = ctx.params; let n = pr.f("drops").clamp(0.0, 100_000.0) as u32; let size = pr.f("size").max(0.0) as f32; @@ -334,11 +446,10 @@ fn rainfall(ctx: &EffectCtx, b: Buf) -> Buf { let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); let s = b.scale as f32; let t = ctx.time as f32; - let src = &b.img; let (ox, oy) = (off[0] as f32 - lw * 0.5, off[1] as f32 - lh * 0.5); - let sprites: Vec = (0..n) + let (sprites, tints): (Vec, Vec) = (0..n) .into_par_iter() - .filter_map(|i| { + .map(|i| { let z = h(i, 1, seed); let k = depth_k(z, depth); let vy = speed / 4000.0 * lh * 2.2 * k; @@ -349,29 +460,25 @@ fn rainfall(ctx: &EffectCtx, b: Buf) -> Buf { let y = wrap(h(i, 3, seed) * (lh + 2.0 * margin) + vy * t + oy, -margin, lh + 2.0 * margin); let x = wrap(h(i, 4, seed) * (lw + 2.0 * margin) + w * t + ox, -margin, lw + 2.0 * margin); let (bx, by) = b.to_px([x as f64, y as f64]); - let mut c = [color[0], color[1], color[2], opacity * (0.35 + 0.65 * k)]; - if refract { - // Refracting drops show the scene behind them, brightened and offset. - let (sc, sa) = unpremul(src.sample_bilinear(bx + 2.0 * s as f64, by)); - let m = |a: f32, cc: f32| (a * 0.6 + 0.5) * cc; - if sa > 0.0 { - c = [m(sc[0], color[0]), m(sc[1], color[1]), m(sc[2], color[2]), c[3]]; - } - } + let c = [color[0], color[1], color[2], opacity * (0.35 + 0.65 * k)]; + // Refracting drops show the scene behind them, brightened and offset. + let tint = if refract { Tint::Refract { x: bx + 2.0 * s as f64, y: by } } else { Tint::Fixed }; let r = (size * 0.6 * k * s).max(0.25); - Some(Sprite::new(bx as f32, by as f32, r, c, Shape::Line { dx: -lenx * s, dy: -len * s })) + (Sprite::new(bx as f32, by as f32, r, c, Shape::Line { dx: -lenx * s, dy: -len * s }), tint) }) - .collect(); - weather_out(b, &sprites, mode, with_orig) + .unzip(); + SpritePlan { sprites, tints, acc: Acc::Over, post: weather_post(mode, with_orig) } } -fn weather_out(mut b: Buf, sprites: &[Sprite], mode: u32, with_orig: bool) -> Buf { - let fx = splat(b.img.width, b.img.height, sprites, Acc::Over); - b.img = if with_orig { combine(&b.img, &fx, if mode == 1 { 3 } else { 0 }) } else { fx }; - b +fn weather_post(mode: u32, with_orig: bool) -> Post { + if with_orig { Post::Combine(if mode == 1 { 3 } else { 0 }) } else { Post::Replace } } fn snowfall(ctx: &EffectCtx, b: Buf) -> Buf { + snowfall_plan(ctx, &b).finish(b) +} + +fn snowfall_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { let pr = ctx.params; let n = pr.f("flakes").clamp(0.0, 200_000.0) as u32; let size = pr.f("size").max(0.0) as f32; @@ -414,35 +521,36 @@ fn snowfall(ctx: &EffectCtx, b: Buf) -> Buf { Sprite::new(bx as f32, by as f32, r * s, [color[0], color[1], color[2], opacity * (0.4 + 0.6 * k)], Shape::Soft) }) .collect(); - weather_out(b, &sprites, mode, with_orig) + SpritePlan { sprites, tints: vec![], acc: Acc::Over, post: weather_post(mode, with_orig) } } // ------------------------------------------------------------------ CC Bubbles -struct Bubble { - x: f32, - y: f32, - r: f32, +/// A CC Bubbles bubble in buffer pixels. +#[derive(Clone, Copy, Debug)] +pub struct Bubble { + pub x: f32, + pub y: f32, + pub r: f32, /// Source sample centre (buffer px). - sx: f32, - sy: f32, + pub sx: f32, + pub sy: f32, } -fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// CC Bubbles' bubbles this frame (closed-form in time) for buffer geometry `b`. +pub fn bubble_list(ctx: &EffectCtx, b: &Buf) -> Vec { let pr = ctx.params; let n = pr.f("bubbleAmount").clamp(0.0, 10_000.0) as u32; let speed = pr.f("bubbleSpeed") as f32; let wamp = pr.f("wobbleAmplitude") as f32; let wfreq = pr.f("wobbleFrequency") as f32; let bsize = pr.f("bubbleSize").max(0.0) as f32; - let metal = pr.e("reflectionType") == 1; - let shading = pr.e("shadingType"); let seed = ctx.seed.wrapping_mul(0x27d4); let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); let s = b.scale as f32; let t = ctx.time as f32; let base = lw.min(lh) * 0.035 * bsize; - let list: Vec = (0..n) + (0..n) .map(|i| { let r = base * (0.5 + h(i, 1, seed)); let travel = lh + 2.0 * r; @@ -454,7 +562,14 @@ fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf { let (sx, sy) = b.to_px([x0 as f64, y as f64]); Bubble { x: bx as f32, y: by as f32, r: r * s, sx: sx as f32, sy: sy as f32 } }) - .collect(); + .collect() +} + +fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let metal = pr.e("reflectionType") == 1; + let shading = pr.e("shadingType"); + let list = bubble_list(ctx, &b); let src = b.img.clone(); let mut out = Image::new(src.width, src.height); raster( @@ -495,28 +610,20 @@ fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf { // ------------------------------------------------------------------ CC Drizzle -fn drizzle(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// CC Drizzle's live drops this frame (buffer px: centre x, y, ring radius, amplitude); +/// `None` = no drops yet (the layer passes through). +pub fn drizzle_drops(ctx: &EffectCtx, b: &Buf) -> Option> { let pr = ctx.params; let rate = pr.f("dripRate").max(0.0); let life = pr.f("longevity").max(0.01); - let rippling = (pr.f("rippling") / 360.0).max(0.05) as f32; - let disp = pr.f("displacement") as f32; - let height = pr.f("rippleHeight") as f32 / 100.0; let spreading = pr.f("spreading") as f32; - let li = pr.f("light/lightIntensity") as f32 / 100.0; - let lc = pr.color("light/lightColor"); - let lheight = pr.f("light/lightHeight") as f32 / 100.0; - let ldir = (pr.f("light/lightDirection") as f32).to_radians(); - let ambient = pr.f("shading/ambient") as f32 / 100.0; - let diffuse = pr.f("shading/diffuse") as f32 / 100.0; - let specular = pr.f("shading/specular") as f32 / 100.0; - let rough = pr.f("shading/roughness").max(0.001) as f32; + let height = pr.f("rippleHeight") as f32 / 100.0; let seed = ctx.seed.wrapping_mul(0x165667b1); let t = ctx.time; let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); let s = b.scale as f32; if rate <= 0.0 || t < 0.0 { - return b; + return None; } // Drops born at k / rate with hashed positions; alive while younger than the longevity. let k0 = ((t - life) * rate).floor().max(0.0) as u32; @@ -531,13 +638,31 @@ fn drizzle(ctx: &EffectCtx, mut b: Buf) -> Buf { let (x, y) = b.to_px([(h(k, 1, seed) * lw) as f64, (h(k, 2, seed) * lh) as f64]); let radius = spreading * age * lh / 400.0 * s; let amp = height * (1.0 - age / life as f32) * (0.6 + 0.4 * h(k, 3, seed)); - drops.push((x as f32, y as f32, radius, amp)); + drops.push([x as f32, y as f32, radius, amp]); } + Some(drops) +} + +fn drizzle(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let Some(drops) = drizzle_drops(ctx, &b) else { return b }; + let rippling = (pr.f("rippling") / 360.0).max(0.05) as f32; + let disp = pr.f("displacement") as f32; + let li = pr.f("light/lightIntensity") as f32 / 100.0; + let lc = pr.color("light/lightColor"); + let lheight = pr.f("light/lightHeight") as f32 / 100.0; + let ldir = (pr.f("light/lightDirection") as f32).to_radians(); + let ambient = pr.f("shading/ambient") as f32 / 100.0; + let diffuse = pr.f("shading/diffuse") as f32 / 100.0; + let specular = pr.f("shading/specular") as f32 / 100.0; + let rough = pr.f("shading/roughness").max(0.001) as f32; + let lh = ctx.layer_size[1] as f32; + let s = b.scale as f32; let wavelen = (lh * 0.04 * s).max(2.0) / rippling.clamp(0.25, 4.0); let width = wavelen * (1.0 + rippling * 2.0); let field = |x: f32, y: f32| -> f32 { let mut v = 0.0; - for &(dx, dy, r, a) in &drops { + for &[dx, dy, r, a] in &drops { let d = ((x - dx).powi(2) + (y - dy).powi(2)).sqrt(); let u = (d - r) / width; if u.abs() < 1.0 { @@ -586,7 +711,13 @@ fn drizzle(ctx: &EffectCtx, mut b: Buf) -> Buf { // ------------------------------------------------------------------ CC Star Burst -fn star_burst(ctx: &EffectCtx, mut b: Buf) -> Buf { +fn star_burst(ctx: &EffectCtx, b: Buf) -> Buf { + let mut plan = star_burst_plan(ctx, &b); + plan.resolve(&b.img); + plan.finish(b) +} + +fn star_burst_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { let pr = ctx.params; let scatter = pr.f("scatter") as f32; let speed = pr.f("speed") as f32; @@ -600,17 +731,12 @@ fn star_burst(ctx: &EffectCtx, mut b: Buf) -> Buf { let t = ctx.time as f32; let nx = (lw / spacing).ceil().max(1.0) as u32; let ny = (lh / spacing).ceil().max(1.0) as u32; - let src = b.img.clone(); let (cx, cy) = (lw * 0.5, lh * 0.5); - let mut list: Vec<(f32, Sprite)> = (0..nx * ny) + let mut list: Vec<(f32, Sprite, Tint)> = (0..nx * ny) .into_par_iter() - .filter_map(|i| { + .map(|i| { let (gx, gy) = ((i % nx) as f32 * spacing + spacing * 0.5, (i / nx) as f32 * spacing + spacing * 0.5); let (sx, sy) = b.to_px([gx as f64, gy as f64]); - let (c, a) = unpremul(src.sample_bilinear(sx, sy)); - if a <= 0.0 { - return None; - } // Depth cycles towards the viewer: z in [0, 1), perspective 1 / (1 - 0.9 z). let z = (h(i, 1, seed) + t * speed * 0.25 + phase).rem_euclid(1.0); let persp = 1.0 / (1.0 - 0.9 * z); @@ -621,19 +747,13 @@ fn star_burst(ctx: &EffectCtx, mut b: Buf) -> Buf { let (bx, by) = b.to_px([x as f64, y as f64]); let r = spacing * 0.5 * size * persp * s; let fade = (z * 8.0).min(1.0) * ((1.0 - z) * 6.0).min(1.0); - Some((z, Sprite::new(bx as f32, by as f32, r, [c[0], c[1], c[2], a * fade], Shape::Disc))) + // The layer's colour at the grid point (none where it is clear). + (z, Sprite::new(bx as f32, by as f32, r, [0.0, 0.0, 0.0, fade], Shape::Disc), Tint::Source { x: sx, y: sy }) }) .collect(); list.sort_by(|a, b| a.0.total_cmp(&b.0)); - let sprites: Vec = list.into_iter().map(|(_, s)| s).collect(); - let fx = splat(src.width, src.height, &sprites, Acc::Over); - b.img = fx; - if blend > 0.0 { - b.img.data.par_iter_mut().zip(src.data.par_iter()).for_each(|(o, s0)| { - *o = crate::util::lerp4(*o, *s0, blend); - }); - } - b + let (sprites, tints) = list.into_iter().map(|(_, s, t)| (s, t)).unzip(); + SpritePlan { sprites, tints, acc: Acc::Over, post: if blend > 0.0 { Post::Lerp(blend) } else { Post::Replace } } } // ------------------------------------------------------------------ particle engine @@ -1067,13 +1187,16 @@ fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf { static MERCURY_CACHE: SimCache = SimCache::new(6); -struct Blob { - x: f32, - y: f32, - r: f32, +/// A CC Mr. Mercury blob (buffer px). +#[derive(Clone, Copy, Debug)] +pub struct Blob { + pub x: f32, + pub y: f32, + pub r: f32, } -fn mr_mercury(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`). +pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec { let pr = ctx.params; let lh = ctx.layer_size[1] as f32; let unit = lh.max(1.0); @@ -1097,10 +1220,8 @@ fn mr_mercury(ctx: &EffectCtx, mut b: Buf) -> Buf { }; let st = simulate(&MERCURY_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 3), ctx.time, &phys); let (bs, ds) = (pr.f("blobBirthSize") as f32, pr.f("blobDeathSize") as f32); - let influence = pr.f("blobInfluence") as f32 / 100.0; let s = b.scale as f32; - let blobs: Vec = st - .parts + st.parts .iter() .map(|q| { let u = (q.age / q.life.max(1e-4)).clamp(0.0, 1.0); @@ -1109,7 +1230,14 @@ fn mr_mercury(ctx: &EffectCtx, mut b: Buf) -> Buf { Blob { x: bx as f32, y: by as f32, r: (size * unit * 0.08 * s).max(0.0) } }) .filter(|q| q.r > 0.1) - .collect(); + .collect() +} + +fn mr_mercury(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let blobs = mercury_blobs(ctx, &b); + let influence = pr.f("blobInfluence") as f32 / 100.0; + let s = b.scale as f32; let (w, hh) = (b.img.width, b.img.height); // Field and gradient accumulate additively: [f, gx, gy, _]. let mut field = Image::new(w, hh); diff --git a/crates/effects/src/sim2.rs b/crates/effects/src/sim2.rs index 9e4f092e..d682a310 100644 --- a/crates/effects/src/sim2.rs +++ b/crates/effects/src/sim2.rs @@ -80,31 +80,78 @@ impl Cam { } /// A planar textured piece ready to draw. -#[derive(Clone, Copy)] -pub(crate) struct Piece { +#[derive(Clone, Copy, Debug)] +pub struct Piece { /// Screen (buffer px, homogeneous) → piece-local (a, b, w). - inv: M3, + pub inv: [[f64; 3]; 3], /// Rest centre of the piece in texture pixels. - c0: [f32; 2], + pub c0: [f32; 2], /// Convex polygon (texture pixels), counter-clockwise in y-down space. - poly: [[f32; 2]; 6], - n: u8, - bbox: [f32; 4], - shade: f32, + pub poly: [[f32; 2]; 6], + pub n: u8, + pub bbox: [f32; 4], + pub shade: f32, /// Lighting: colour multiplier and additive specular highlight. - tint: [f32; 3], - spec: [f32; 3], - pub(crate) alpha: f32, + pub tint: [f32; 3], + pub spec: [f32; 3], + pub alpha: f32, /// Flat straight colour instead of the texture. - pub(crate) flat: Option<[f32; 3]>, + pub flat: Option<[f32; 3]>, /// Facing away from the camera (draw the back texture). - pub(crate) back: bool, + pub back: bool, /// Back faces sample their texture mirrored about the piece centre: 0 no, 1 vertically /// (flipped about the X axis), 2 horizontally (about the Y axis). - pub(crate) back_mirror: u8, + pub back_mirror: u8, depth: f64, } +/// A piece texture: the layer itself or another image (buffer-aligned). +#[derive(Clone, Debug)] +pub enum PieceTex { + Layer, + Image(Image), +} + +/// What a piece-drawing effect (Card Dance, Shatter, Card Wipe) draws this frame: pieces sorted +/// far to near over a transparent frame, textured by `front` (and `back` for pieces facing +/// away; none = they take `front`). +#[derive(Clone, Debug)] +pub struct PiecePlan { + pub pieces: Vec, + pub front: PieceTex, + pub back: Option, +} + +impl PiecePlan { + /// Rasterise over a transparent frame of the layer `b`'s size. + pub fn finish(&self, mut b: Buf) -> Buf { + let mut out = Image::new(b.img.width, b.img.height); + let front = match &self.front { + PieceTex::Layer => &b.img, + PieceTex::Image(i) => i, + }; + let back = self.back.as_ref().map(|t| match t { + PieceTex::Layer => &b.img, + PieceTex::Image(i) => i, + }); + draw_pieces(&mut out, &self.pieces, front, back); + b.img = out; + b + } +} + +/// The piece plan of Card Dance, Shatter (Rendered view) or Card Wipe for layer `b` (its pixels +/// may be read: gradient maps and textures default to the layer), `None` for other effects or +/// views drawn otherwise. +pub fn piece_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { + match id { + "ec.sim.carddance" => Some(card_dance_plan(ctx, b)), + "ec.sim.shatter" if ctx.params.e("view") == 0 => shatter_impl(ctx, b, true).err(), + "ec.transition.cardwipe" => crate::transition::card_wipe_plan(ctx, b), + _ => None, + } +} + impl Piece { /// Place polygon `poly` (texture px, around rest centre `c0`) with rotation `r` and its /// centre at 3D `pos` (buffer px; z away from the camera), seen through `cam`. @@ -462,22 +509,25 @@ fn card_source(src: u32, g1: [f32; 4], g2: [f32; 4]) -> f32 { } } -fn card_dance(ctx: &EffectCtx, mut b: Buf) -> Buf { +fn card_dance(ctx: &EffectCtx, b: Buf) -> Buf { + card_dance_plan(ctx, &b).finish(b) +} + +fn card_dance_plan(ctx: &EffectCtx, b: &Buf) -> PiecePlan { let pr = ctx.params; let rows = pr.f("rows").clamp(1.0, 1000.0) as u32; let cols = if pr.e("rowsColumns") == 1 { rows } else { pr.f("columns").clamp(1.0, 1000.0) as u32 }; - let g1 = layer_or_self(ctx, &b, "gradientLayer1", true, true); - let g2 = layer_or_self(ctx, &b, "gradientLayer2", true, true); - let back = ctx.layer_param("backLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true)); + let g1 = layer_or_self(ctx, b, "gradientLayer1", true, true); + let g2 = layer_or_self(ctx, b, "gradientLayer2", true, true); + let back = ctx.layer_param("backLayer", true).map(|o| crate::util::fit_layer(ctx, b, &o, true)); let [lw, lh] = ctx.layer_size; let s = b.scale; let (cw, ch) = (lw / cols as f64, lh / rows as f64); - let cam = crate::card3d::projection(ctx, &b); + let cam = crate::card3d::projection(ctx, b); let eye = cam.eye(); - let light = Lighting::from(ctx, &b); + let light = Lighting::from(ctx, b); let props: Vec<(u32, f64, f64)> = CD_PROPS.iter().map(|(id, g)| (pr.e(&format!("{g}/{id}Source")), pr.f(&format!("{g}/{id}Multiplier")), pr.f(&format!("{g}/{id}Offset")))).collect(); - let src = b.img.clone(); let mut pieces: Vec = (0..rows * cols) .into_par_iter() .filter_map(|i| { @@ -502,10 +552,7 @@ fn card_dance(ctx: &EffectCtx, mut b: Buf) -> Buf { }) .collect(); sort_far_first(&mut pieces); - let mut out = Image::new(src.width, src.height); - draw_pieces(&mut out, &pieces, &src, back.as_ref()); - b.img = out; - b + PiecePlan { pieces, front: PieceTex::Layer, back: back.map(PieceTex::Image) } } // ------------------------------------------------------------------ Shatter @@ -638,7 +685,15 @@ fn map_key(c: [f32; 4]) -> u32 { q(c[0]) << 8 | q(c[1]) << 4 | q(c[2]) } -fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { +fn shatter(ctx: &EffectCtx, b: Buf) -> Buf { + match shatter_impl(ctx, &b, false) { + Ok(img) => Buf { img, ..b }, + Err(plan) => plan.finish(b), + } +} + +/// Shatter: the frame (`Ok`), or with `plan_only` in the Rendered view its pieces (`Err`). +fn shatter_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result { let pr = ctx.params; let view = pr.e("view"); let render = pr.e("render"); @@ -666,21 +721,21 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { let unit = lw * 0.1; let cell = lw / reps; let extrude = pr.f("shape/extrusionDepth").max(0.0) * cell * s; - let cam = crate::card3d::projection(ctx, &b); + let cam = crate::card3d::projection(ctx, b); let eye = cam.eye(); - let light = Lighting::from(ctx, &b); + let light = Lighting::from(ctx, b); // Gradient: with a gradient layer only pieces at least as bright as 1 − Shatter Threshold // (Invert Gradient: as dark) break. - let gradient = ctx.layer_param("gradient/gradientLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true)); + let gradient = ctx.layer_param("gradient/gradientLayer", true).map(|o| crate::util::fit_layer(ctx, b, &o, true)); let threshold = pr.get("gradient/shatterThreshold").map(Value::as_f64).unwrap_or(0.0) / 100.0; let invert_gradient = pr.b("gradient/invertGradient"); // Textures. let tex_color = pr.get("textures/color").map(|v| v.as_color()).unwrap_or([1.0; 4]); let tex_opacity = pr.get("textures/opacity").map(Value::as_f64).unwrap_or(1.0).clamp(0.0, 1.0) as f32; - let front = Face::from(ctx, &b, "textures/frontMode", "textures/frontLayer", 1); - let side = Face::from(ctx, &b, "textures/sideMode", "textures/sideLayer", 0); - let backf = Face::from(ctx, &b, "textures/backMode", "textures/backLayer", 1); - let custom_map = custom.then(|| layer_or_self(ctx, &b, "shape/customShatterMap", true, true)); + let front = Face::from(ctx, b, "textures/frontMode", "textures/frontLayer", 1); + let side = Face::from(ctx, b, "textures/sideMode", "textures/sideLayer", 0); + let backf = Face::from(ctx, b, "textures/backMode", "textures/backLayer", 1); + let custom_map = custom.then(|| layer_or_self(ctx, b, "shape/customShatterMap", true, true)); let white_fixed = pr.b("shape/whiteTilesFixed"); let (sd, cd) = dir.sin_cos(); let to_layer = |q: [f64; 2]| [origin[0] + q[0] * cd - q[1] * sd, origin[1] + q[0] * sd + q[1] * cd]; @@ -843,12 +898,18 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { }) .collect(); sort_far_first(&mut pieces); - let mut out = Image::new(src.width, src.height); - let front_img = front.img.as_ref().unwrap_or(&src); - let back_img = backf.img.as_ref().unwrap_or(front_img); if view == 0 { - draw_pieces(&mut out, &pieces, front_img, Some(back_img)); - } else { + let tex = |i: &Option| i.clone().map(PieceTex::Image); + let front_t = tex(&front.img).unwrap_or(PieceTex::Layer); + let back_t = tex(&backf.img).unwrap_or_else(|| front_t.clone()); + let plan = PiecePlan { pieces, front: front_t, back: Some(back_t) }; + if plan_only { + return Err(plan); + } + return Ok(plan.finish(Buf { img: src, offset: b.offset, scale: b.scale }).img); + } + let out; + { // Wireframes: piece outlines (front view: at rest; otherwise animated), plus forces. let animated = view == 2 || view == 4; let mut lines = Vec::new(); @@ -889,8 +950,7 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { } out = splat(src.width, src.height, &lines, Acc::Over); } - b.img = out; - b + Ok(out) } // ------------------------------------------------------------------ Caustics @@ -911,35 +971,62 @@ fn sample_repeat(img: &Image, x: f64, y: f64, mode: u32) -> Px { } } -fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// Everything Caustics' per-pixel pass needs, prepared on the CPU (the other layers it reads +/// and the parameter conversions); the layer's own pixels are not read. +pub struct CausticsSetup { + /// The Bottom layer fitted to the buffer, `None` = the layer itself. + pub bottom: Option, + /// Gaussian σ (buffer px) blurring the bottom (transparent edges), 0 = none. + pub bottom_sigma: f64, + /// The water surface's height field (smoothed luminance), `None` = flat. + pub height: Option, + /// The Sky layer fitted to the buffer. + pub sky: Option, + pub scaling: f64, + pub repeat: u32, + pub wave_h: f64, + /// Displacement per unit of height gradient. + pub k: f64, + /// Layer centre (buffer px). + pub cx: f64, + pub cy: f64, + /// Layer width (buffer px). + pub lw: f64, + pub surf: [f32; 4], + pub surf_op: f32, + pub cstr: f32, + pub lc: [f32; 4], + pub li: f32, + /// Light position (buffer px) and height (layer widths); `point` = from the position to + /// each pixel, else one direction from above the layer centre. + pub lx: f64, + pub ly: f64, + pub lheight: f64, + pub point: bool, + pub ambient: f32, + pub diffuse: f32, + pub specular: f32, + pub sharp: f32, + pub sky_scaling: f64, + pub sky_repeat: u32, + pub sky_int: f32, + pub convergence: f64, +} + +/// Caustics' setup for layer geometry `b` (see [`CausticsSetup`]). +pub fn caustics_setup(ctx: &EffectCtx, b: &Buf) -> CausticsSetup { let pr = ctx.params; let s = b.scale; - let mut bottom = layer_or_self(ctx, &b, "bottom/bottom", true, pr.e("bottom/bottomSizeDiffers") == 1); + let bottom = ctx.layer_param("bottom/bottom", true).map(|o| crate::util::fit_layer(ctx, b, &o, pr.e("bottom/bottomSizeDiffers") == 1)); let blur = pr.f("bottom/blur") * s; - if blur > 0.05 { - bottom = effectcraft_raster::gaussian_blur(&bottom, blur * 0.5, blur * 0.5, false); - } - let scaling = pr.f("bottom/scaling").max(0.01); - let repeat = pr.e("bottom/repeatMode"); let wave_h = pr.f("water/waveHeight"); let smoothing = pr.f("water/smoothing") * s; let depth = pr.f("water/waterDepth"); let ior = pr.f("water/refractiveIndex").max(1.0); - let surf = pr.color("water/surfaceColor"); - let surf_op = pr.f("water/surfaceOpacity") as f32; - let cstr = pr.f("water/causticsStrength") as f32; - let li = pr.f("lighting/lightIntensity") as f32; - let lc = pr.color("lighting/lightColor"); let lpos = pr.v2("lighting/lightPosition"); - let lheight = pr.f("lighting/lightHeight").max(0.01); - let ambient = pr.f("lighting/ambientLight") as f32; - let diffuse = pr.f("material/diffuse") as f32; - let specular = pr.f("material/specular") as f32; - let sharp = pr.f("material/highlightSharpness").max(1.0) as f32; - let (w, hh) = (b.img.width as usize, b.img.height as usize); // Height field of the water surface (luminance of the chosen layer; none = flat). let height = ctx.layer_param("water/waterSurface", true).map(|o| { - let img = crate::util::fit_layer(ctx, &b, &o, true); + let img = crate::util::fit_layer(ctx, b, &o, true); let pl = Plane::luma(&img); if smoothing > 0.05 { gauss_plane(&pl, smoothing * 0.5, smoothing * 0.5) } else { pl } }); @@ -947,6 +1034,7 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { let k = wave_h * depth * (1.0 - 1.0 / ior) * lw * 2.0; let (cx, cy) = (b.offset[0] + ctx.layer_size[0] * 0.5 * s, b.offset[1] + ctx.layer_size[1] * 0.5 * s); let (lx, ly) = b.to_px(lpos); + let (lc, li, lheight) = (pr.color("lighting/lightColor"), pr.f("lighting/lightIntensity") as f32, pr.f("lighting/lightHeight").max(0.01)); // Light Type: Distant Source (one direction, from the light position above the layer // centre), Point Source (from the light position to each pixel) or First Comp Light. let light_type = pr.e("lighting/lightType"); @@ -962,6 +1050,75 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { (1, _) => (lc, li, lx, ly, lheight, true), _ => (lc, li, lx, ly, lheight, false), }; + // Sky: the layer the water surface reflects (Surface Opacity 1 = a mirror of it). + let sky = ctx.layer_param("sky/sky", true).map(|o| crate::util::fit_layer(ctx, b, &o, pr.e("sky/skySizeDiffers") == 1)); + CausticsSetup { + bottom, + bottom_sigma: if blur > 0.05 { blur * 0.5 } else { 0.0 }, + height, + sky, + scaling: pr.f("bottom/scaling").max(0.01), + repeat: pr.e("bottom/repeatMode"), + wave_h, + k, + cx, + cy, + lw, + surf: pr.color("water/surfaceColor"), + surf_op: pr.f("water/surfaceOpacity") as f32, + cstr: pr.f("water/causticsStrength") as f32, + lc, + li, + lx, + ly, + lheight, + point, + ambient: pr.f("lighting/ambientLight") as f32, + diffuse: pr.f("material/diffuse") as f32, + specular: pr.f("material/specular") as f32, + sharp: pr.f("material/highlightSharpness").max(1.0) as f32, + sky_scaling: pr.get("sky/scaling").map(Value::as_f64).unwrap_or(1.0).max(0.01), + sky_repeat: pr.e("sky/repeatMode"), + sky_int: pr.get("sky/intensity").map(Value::as_f64).unwrap_or(0.3) as f32, + convergence: pr.get("sky/convergence").map(Value::as_f64).unwrap_or(0.5).clamp(0.0, 1.0), + } +} + +fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { + let st = caustics_setup(ctx, &b); + let mut bottom = st.bottom.clone().unwrap_or_else(|| b.img.clone()); + if st.bottom_sigma > 0.0 { + bottom = effectcraft_raster::gaussian_blur(&bottom, st.bottom_sigma, st.bottom_sigma, false); + } + let CausticsSetup { + scaling, + repeat, + wave_h, + k, + cx, + cy, + lw, + surf, + surf_op, + cstr, + lc, + li, + lx, + ly, + lheight, + point, + ambient, + diffuse, + specular, + sharp, + sky_scaling, + sky_repeat, + sky_int, + convergence, + .. + } = st; + let (height, sky) = (&st.height, &st.sky); + let (w, hh) = (b.img.width as usize, b.img.height as usize); let norm3 = |v: [f64; 3]| { let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); [(v[0] / n) as f32, (v[1] / n) as f32, (v[2] / n) as f32] @@ -973,14 +1130,8 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { norm3([(lx - cx) / lw.max(1.0), (ly - cy) / lw.max(1.0), lheight]) } }; - // Sky: the layer the water surface reflects (Surface Opacity 1 = a mirror of it). - let sky = ctx.layer_param("sky/sky", true).map(|o| crate::util::fit_layer(ctx, &b, &o, pr.e("sky/skySizeDiffers") == 1)); - let sky_scaling = pr.get("sky/scaling").map(Value::as_f64).unwrap_or(1.0).max(0.01); - let sky_repeat = pr.e("sky/repeatMode"); - let sky_int = pr.get("sky/intensity").map(Value::as_f64).unwrap_or(0.3) as f32; - let convergence = pr.get("sky/convergence").map(Value::as_f64).unwrap_or(0.5).clamp(0.0, 1.0); let grad = |x: usize, y: usize| -> (f64, f64) { - match &height { + match height { Some(pl) => { let gx = (pl.get_clamped(x as i64 + 1, y as i64) - pl.get_clamped(x as i64 - 1, y as i64)) as f64 * 0.5; let gy = (pl.get_clamped(x as i64, y as i64 + 1) - pl.get_clamped(x as i64, y as i64 - 1)) as f64 * 0.5; @@ -1052,12 +1203,13 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { // ------------------------------------------------------------------ Wave World +/// Wave World's simulated surface: an `nx` × `ny` grid of heights `u` and velocities `v`. #[derive(Clone, Debug)] -struct Waves { - nx: usize, - ny: usize, - u: Vec, - v: Vec, +pub struct Waves { + pub nx: usize, + pub ny: usize, + pub u: Vec, + pub v: Vec, } struct Producer { @@ -1091,7 +1243,27 @@ impl Producer { static WAVE_CACHE: SimCache = SimCache::new(4); -fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// What Wave World draws: the Height Map view of the simulated surface (grid heights and, +/// with a ground layer, water depths per grid point), or the wireframe preview's lines. +pub enum WavePlan { + Height { st: std::sync::Arc, depth: Option> }, + Wire(crate::sim::SpritePlan), +} + +fn wave_world(ctx: &EffectCtx, b: Buf) -> Buf { + match wave_world_impl(ctx, &b, false) { + Ok(img) => Buf { img, ..b }, + Err(WavePlan::Wire(plan)) => plan.finish(b), + Err(WavePlan::Height { .. }) => b, + } +} + +/// Wave World's simulation state for the frame, as a [`WavePlan`] (its pixels are not read). +pub fn wave_world_plan(ctx: &EffectCtx, b: &Buf) -> Option { + wave_world_impl(ctx, b, true).err() +} + +fn wave_world_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result { let pr = ctx.params; let [lw, lh] = ctx.layer_size; let res = pr.f("simulation/gridResolution").clamp(1.0, 400.0) as usize; @@ -1239,7 +1411,11 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { let c = g(x0, y1) + (g(x1, y1) - g(x0, y1)) * tx; a + (c - a) * ty }; + if plan_only && pr.e("view") == 1 { + return Err(WavePlan::Height { st, depth }); + } if pr.e("view") == 1 { + let mut img = Image::new(bw, bh); let bright = pr.f("heightMapControls/brightness") as f32; let contrast = pr.f("heightMapControls/contrast") as f32; let gamma = pr.f("heightMapControls/gamma").max(0.01) as f32; @@ -1251,7 +1427,7 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { let gy = (y / lw.max(1.0) * (st.nx - 1) as f64).round().clamp(0.0, (st.ny - 1) as f64) as usize; d[gy * st.nx + gx] <= 0.0 }; - b.img.rows_mut().for_each(|(y, row)| { + img.rows_mut().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { let lx = (x as f64 + 0.5 - b.offset[0]) / s; let ly = (y as f64 + 0.5 - b.offset[1]) / s; @@ -1260,6 +1436,7 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { *px = [v * al, v * al, v * al, al]; } }); + Ok(img) } else { // Wireframe preview: the grid turned by Horizontal Rotation about the vertical axis, // tilted by Vertical Rotation, heights scaled by Vertical Scale. @@ -1291,10 +1468,12 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { } } } - let wire = splat(bw, bh, &lines, Acc::Over); - b.img = crate::sim::combine(&Image::filled(bw, bh, [0.0, 0.0, 0.0, 1.0]), &wire, 0); + let plan = crate::sim::SpritePlan { sprites: lines, tints: vec![], acc: Acc::Over, post: crate::sim::Post::OverBlack }; + if plan_only { + return Err(WavePlan::Wire(plan)); + } + Ok(plan.finish(Buf { img: Image::new(bw, bh), offset: b.offset, scale: b.scale }).img) } - b } // ------------------------------------------------------------------ Foam @@ -1336,7 +1515,29 @@ fn foam_pop_age(id: u32, lifespan: f32, frailty: f32, seed: u32) -> f32 { /// other, wobble, and pop at the end of their (Strength-shortened) lifespan, pushing their /// neighbours away at Pop Velocity. Rendered bubbles use a built-in texture or the Bubble /// Texture Layer, oriented by Bubble Orientation, and reflect the Environment Map. -fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { +fn foam(ctx: &EffectCtx, b: Buf) -> Buf { + match foam_impl(ctx, &b, false) { + Ok(img) => Buf { img, ..b }, + Err(plan) => plan.finish(b), + } +} + +/// Foam's bubbles as sprites, `None` when it draws more than sprites (a User Defined bubble +/// texture, an Environment Map reflection or the Draft + Flow Map view's flow map). +pub(crate) fn foam_plan(ctx: &EffectCtx, b: &Buf) -> Option { + let pr = ctx.params; + let set = |id: &str| matches!(pr.get(id), Some(Value::Layer(Some(_)))); + let view = pr.e("view"); + if (view == 2 && pr.e("rendering/bubbleTexture") == 5) + || (view == 2 && pr.f("rendering/reflectionStrength") > 0.0 && set("rendering/environmentMap")) + || (view == 1 && set("flowMap/flowMap")) + { + return None; + } + foam_impl(ctx, b, true).err() +} + +fn foam_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result { let pr = ctx.params; let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); let unit = lw * 0.01; @@ -1558,7 +1759,11 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { sp }) .collect(); - let mut out = splat(b.img.width, b.img.height, &sprites, if blend == 0 && view == 2 { Acc::Add } else { Acc::Over }); + let acc = if blend == 0 && view == 2 { Acc::Add } else { Acc::Over }; + if plan_only { + return Err(crate::sim::SpritePlan { sprites, tints: vec![], acc, post: crate::sim::Post::Replace }); + } + let mut out = splat(b.img.width, b.img.height, &sprites, acc); // User Defined: the texture layer fills each bubble's disc. if let Some(tex) = &user_tex { for q in &order { @@ -1581,7 +1786,7 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { && strength > 0.0 && let Some(env) = ctx.layer_param("rendering/environmentMap", true) { - let env_img = crate::util::fit_layer(ctx, &b, &env, true); + let env_img = crate::util::fit_layer(ctx, b, &env, true); let conv = pr.f("rendering/reflectionConvergence").clamp(0.0, 1.0); let (w, h) = (b.img.width as f64, b.img.height as f64); for q in &order { @@ -1602,7 +1807,7 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { if view == 1 && let Some(lp) = &flow { - let fm = crate::util::fit_layer(ctx, &b, lp, true); + let fm = crate::util::fit_layer(ctx, b, lp, true); out.data.par_iter_mut().zip(fm.data.par_iter()).for_each(|(o, f)| { let k = 1.0 - o[3]; for c in 0..4 { @@ -1610,8 +1815,7 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { } }); } - b.img = out; - b + Ok(out) } /// Add `f(u, v)` (premultiplied; u, v ∈ −1..1 across the disc) over the disc of radius `r` at diff --git a/crates/effects/src/sim3.rs b/crates/effects/src/sim3.rs index 888ebd7d..cf0f5823 100644 --- a/crates/effects/src/sim3.rs +++ b/crates/effects/src/sim3.rs @@ -27,7 +27,7 @@ use effectcraft_project::ParamUi; use effectcraft_raster::{Image, Px}; use rayon::prelude::*; -use crate::sim::{Acc, SPS, Shape, Sprite, combine, splat, steps_at}; +use crate::sim::{Acc, Post, SPS, Shape, Sprite, SpritePlan, Tint, combine, splat, steps_at}; use crate::util::{SimCache, hash1, params_key, point_in_poly, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; @@ -897,12 +897,20 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---------------------------------------------------------------- CC Hair -fn cc_hair(ctx: &EffectCtx, mut b: Buf) -> Buf { +fn cc_hair(ctx: &EffectCtx, b: Buf) -> Buf { + let Some(mut plan) = hair_plan(ctx, &b) else { return b }; + plan.resolve(&b.img); + plan.finish(b) +} + +/// CC Hair's strands as line sprites (each coloured from its root, see [`Tint::Root`]); +/// `None` = no hair (the layer passes through). +pub(crate) fn hair_plan(ctx: &EffectCtx, b: &Buf) -> Option { let pr = ctx.params; let len = pr.f("length").max(0.0) as f32; let density = pr.f("density").max(0.0) as f32 / 100.0; if len <= 0.0 || density <= 0.0 { - return b; + return None; } let thick = pr.f("thickness").max(0.05) as f32; let weight = pr.f("weight") as f32; @@ -924,19 +932,13 @@ fn cc_hair(ctx: &EffectCtx, mut b: Buf) -> Buf { let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); // Roots: a jittered grid over the layer, density per 1 000 px². let count = ((lw * lh / 1000.0) * density * 10.0).min(200_000.0) as u32; - let src = b.img.clone(); let segs = 6u32; - let strands: Vec> = (0..count) + let strands: Vec> = (0..count) .into_par_iter() - .filter_map(|i| { + .map(|i| { let rx = hash1(i, 1, seed) * lw; let ry = hash1(i, 2, seed) * lh; let (bx, by) = b.to_px([rx as f64, ry as f64]); - let root = src.sample_bilinear(bx, by); - if root[3] < 0.5 { - return None; - } - let (rc, _) = unpremul(root); let mut l = len * (0.75 + 0.5 * hash1(i, 3, seed)); // Lean: random, plus the map's local gradient (hairfall map). let mut lean = [(hash1(i, 4, seed) * 2.0 - 1.0) * 0.6, -1.0 + hash1(i, 5, seed) * 0.4]; @@ -957,7 +959,6 @@ fn cc_hair(ctx: &EffectCtx, mut b: Buf) -> Buf { let dir = [lean[0] / ll, lean[1] / ll]; // Droop: heavier (or thinner with Constant Mass off) hair bends more. let droop = weight * if const_mass { 1.0 } else { 1.0 / thick.max(0.2) } * l; - let base = [rc[0] + (hc[0] - rc[0]) * (1.0 - inherit), rc[1] + (hc[1] - rc[1]) * (1.0 - inherit), rc[2] + (hc[2] - rc[2]) * (1.0 - inherit)]; let pt = |s: f32| [rx + dir[0] * l * s, ry + dir[1] * l * s + droop * s * s]; let mut out = Vec::with_capacity(segs as usize); for k in 0..segs { @@ -974,24 +975,23 @@ fn cc_hair(ctx: &EffectCtx, mut b: Buf) -> Buf { let spec = (1.0 - tl.abs()).powf(1.0 / rough) * spe; let shade = (amb + dif * diff * light_i) * bright; let tip = 1.0 - 0.6 * s1; - let c3 = [base[0] * shade + spec * light_i, base[1] * shade + spec * light_i, base[2] * shade + spec * light_i]; let (pa, pc) = (b.to_px([a[0] as f64, a[1] as f64]), b.to_px([c[0] as f64, c[1] as f64])); - out.push(Sprite { + let sp = Sprite { x: pa.0 as f32, y: pa.1 as f32, r: thick * 0.5 * b.scale as f32 * tip, - c: [c3[0], c3[1], c3[2], opacity], + c: [0.0, 0.0, 0.0, opacity], shape: Shape::Line { dx: (pc.0 - pa.0) as f32, dy: (pc.1 - pa.1) as f32 }, rot: 0.0, - }); + }; + // The root's colour mixed with the hair colour (no strand where it is clear). + out.push((sp, Tint::Root { x: bx, y: by, hair: [hc[0], hc[1], hc[2]], inherit, shade, spec: spec * light_i })); } - Some(out) + out }) .collect(); - let sprites: Vec = strands.into_iter().flatten().collect(); - let fx = splat(b.img.width, b.img.height, &sprites, Acc::Over); - b.img = combine(&b.img, &fx, 0); - b + let (sprites, tints) = strands.into_iter().flatten().unzip(); + Some(SpritePlan { sprites, tints, acc: Acc::Over, post: Post::Combine(0) }) } pub fn specs() -> Vec { diff --git a/crates/effects/src/transition.rs b/crates/effects/src/transition.rs index 65876aa8..23e54bb6 100644 --- a/crates/effects/src/transition.rs +++ b/crates/effects/src/transition.rs @@ -9,7 +9,7 @@ use effectcraft_project::ParamUi; use effectcraft_raster::Px; use rayon::prelude::*; -use crate::sim2::{Piece, draw_pieces, rot_axis, sort_far_first}; +use crate::sim2::{Piece, PiecePlan, PieceTex, rot_axis, sort_far_first}; use crate::util::{Plane, gauss_plane, hash1, layer_rect, remap, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, col, num, p, popup, slider}; @@ -175,7 +175,15 @@ fn iris_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { /// through a 180° turn about Flip Axis, as textured planes in 3D seen through the Camera /// System and lit by Lighting / Material. The flipped side shows Back Layer (none: the cards /// vanish as they turn away). Position / Rotation Jitter wobble the cards over time. -fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { +fn card_wipe(ctx: &EffectCtx, b: Buf) -> Buf { + match card_wipe_plan(ctx, &b) { + Some(plan) => plan.finish(b), + None => b, + } +} + +/// Card Wipe's cards (`None` = nothing has moved: the layer as is). +pub(crate) fn card_wipe_plan(ctx: &EffectCtx, b: &Buf) -> Option { let done = completion(ctx); let pr = ctx.params; let f = |id: &str| pr.get(id).map(Value::as_f64).unwrap_or(0.0); @@ -199,7 +207,7 @@ fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { // Nothing has moved: the layer as is (exactly). let xy = pr.get("cameraPosition/xyPosition").map(|v| v.as_vec2()); if xy.is_none_or(|p| (p[0] - ctx.layer_size[0] * 0.5).abs() < 1e-9 && (p[1] - ctx.layer_size[1] * 0.5).abs() < 1e-9) { - return b; + return None; } } let tw = (pr.f("transitionWidth") / 100.0).clamp(0.01, 1.0); @@ -212,25 +220,25 @@ fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { let order = pr.e("flipOrder"); let jitter = pr.f("timingRandomness").clamp(0.0, 1.0); let seed = pr.f("randomSeed") as u32 ^ 0xca7d; - let (x0, y0, w, h) = layer_rect(ctx, &b); + let (x0, y0, w, h) = layer_rect(ctx, b); let (cw, ch) = (w / cols as f64, h / rows as f64); let nx = |i: i64| if cols > 1 { i as f64 / (cols - 1) as f64 } else { 0.0 }; let ny = |j: i64| if rows > 1 { j as f64 / (rows - 1) as f64 } else { 0.0 }; // Flip Order "Gradient": cards flip first where the gradient layer (default: this layer) is dark. let grad = (order == 8).then(|| match ctx.layer_param("gradientLayer", false) { - Some(o) => place_layer(ctx, &b, &o, 2), + Some(o) => place_layer(ctx, b, &o, 2), None => b.img.clone(), }); // Back Layer: a chosen layer (stretched to fit), or this layer for projects saved with the // old "Self" choice; none hides the cards' backs. let back = match ctx.layer_param("backLayer", true) { - Some(o) => Some(crate::util::fit_layer(ctx, &b, &o, true)), - None if pr.b("backSelf") => Some(b.img.clone()), + Some(o) => Some(PieceTex::Image(crate::util::fit_layer(ctx, b, &o, true))), + None if pr.b("backSelf") => Some(PieceTex::Layer), None => None, }; - let cam = crate::card3d::projection(ctx, &b); + let cam = crate::card3d::projection(ctx, b); let eye = cam.eye(); - let light = crate::card3d::Lighting::from(ctx, &b); + let light = crate::card3d::Lighting::from(ctx, b); let t = ctx.time; let jit = |i: i64, j: i64, k: u32, speed: f64| -> f64 { let ph = hash1(i as u32, j as u32, seed ^ k) as f64 * std::f64::consts::TAU; @@ -303,10 +311,7 @@ fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { }) .collect(); sort_far_first(&mut pieces); - let mut out = effectcraft_raster::Image::new(b.img.width, b.img.height); - draw_pieces(&mut out, &pieces, &b.img, back.as_ref()); - b.img = out; - b + Some(PiecePlan { pieces, front: PieceTex::Layer, back }) } fn grid_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 832545af..d5c13004 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -316,6 +316,7 @@ impl GpuContext { include_str!("shaders/fx_extra.wgsl"), include_str!("shaders/fx_depth.wgsl"), include_str!("shaders/fx_lut.wgsl"), + include_str!("shaders/fx_sim.wgsl"), include_str!("shaders/fx_vr.wgsl"), ] .concat(); @@ -378,6 +379,7 @@ impl GpuContext { .chain(crate::fx_extra::KERNELS) .chain(crate::fx_depth::KERNELS) .chain(crate::fx_lut::KERNELS) + .chain(crate::fx_sim::KERNELS) .chain(crate::fx_vr::KERNELS) .map(|e| (e, &layout)) .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index f6f73a8f..c5275477 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -135,6 +135,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { _ if crate::fx_extra::IDS.contains(&id) => crate::fx_extra::apply(e, id, ctx, b), _ if crate::fx_depth::IDS.contains(&id) => crate::fx_depth::apply(e, id, ctx, b), _ if crate::fx_lut::IDS.contains(&id) => crate::fx_lut::apply(e, id, ctx, b), + _ if crate::fx_sim::IDS.contains(&id) => crate::fx_sim::apply(e, id, ctx, b), _ if crate::fx_vr::IDS.contains(&id) => crate::fx_vr::apply(e, id, ctx, b), _ => pointwise(e, id, ctx, b), } diff --git a/crates/gpu/src/fx_sim.rs b/crates/gpu/src/fx_sim.rs new file mode 100644 index 00000000..517c5328 --- /dev/null +++ b/crates/gpu/src/fx_sim.rs @@ -0,0 +1,424 @@ +//! GPU effects, simulation render passes (kernels in `shaders/fx_sim.wgsl`, every entry point +//! prefixed `fxm_`): CC Rainfall, CC Snowfall, CC Star Burst, CC Bubbles, CC Drizzle, CC Hair, +//! CC Mr. Mercury, Caustics, Wave World, Foam, Shatter, Card Dance and Card Wipe. +//! +//! The simulation (or closed-form layout) of every frame stays on the CPU, shared with the CPU +//! effect: sprite plans (`effects::sprite_plan`: rain drops, snow flakes, star-burst discs, hair +//! strands, Foam's bubbles, Wave World's wireframe), piece plans (`effects::piece_plan`: Card +//! Dance's cards, Shatter's shards, Card Wipe's cards, sorted far to near), CC Bubbles' and CC +//! Mr. Mercury's lists, CC Drizzle's drops, Wave World's height grid and Caustics' prepared +//! layers. The raster / render pass runs here: items are binned into 16 × 16 tiles on the CPU +//! and `fxm_raster` composites each pixel's items in the CPU's order with the CPU's shading +//! (colours that depend on the layer's own pixels — refracting rain, star-burst and hair-root +//! colours, bubble refraction — are sampled per pixel from the layer texture). Piece plans read +//! the layer's pixels on the CPU (gradient maps and textures default to the layer), so those +//! effects read the frame back once. Settings drawn otherwise (Shatter's wireframe views, +//! Foam's User Defined texture, Environment Map and flow-map preview) render on the CPU. + +use effectcraft_effects::{Acc, EffectCtx, PieceTex, Post, Shape, SpritePlan, Tint}; +use effectcraft_raster::Image; + +use crate::context::{Enc, GpuImage, Params}; +use crate::effects::{GBuf, gaussian_blur}; + +/// Compute entry points in `fx_sim.wgsl`. +pub(crate) const KERNELS: &[&str] = &["fxm_raster", "fxm_post", "fxm_mercury", "fxm_drizzle", "fxm_wave", "fxm_caustics"]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[ + "ec.sim.ccrainfall", + "ec.sim.ccsnowfall", + "ec.sim.ccstarburst", + "ec.sim.ccbubbles", + "ec.sim.ccdrizzle", + "ec.sim.cchair", + "ec.sim.ccmrmercury", + "ec.sim.caustics", + "ec.sim.waveworld", + "ec.sim.foam", + "ec.sim.shatter", + "ec.sim.carddance", + "ec.transition.cardwipe", +]; + +/// Largest item table (floats) the raster kernel takes; bigger plans render on the CPU. +const MAX_FLOATS: usize = 1 << 24; +const TILE: i32 = 16; + +/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + let geo = effectcraft_effects::Buf { img: Image::new(0, 0), offset: b.offset, scale: b.scale }; + match id { + "ec.sim.ccbubbles" => bubbles(e, ctx, b, &geo), + "ec.sim.ccdrizzle" => drizzle(e, ctx, b, &geo), + "ec.sim.ccmrmercury" => mercury(e, ctx, b, &geo), + "ec.sim.caustics" => caustics(e, ctx, b, &geo), + "ec.sim.waveworld" => match effectcraft_effects::wave_world_plan(ctx, &geo)? { + effectcraft_effects::WavePlan::Wire(plan) => sprites(e, b, &plan), + effectcraft_effects::WavePlan::Height { st, depth } => wave_height(e, ctx, b, &st, depth.as_deref()), + }, + "ec.sim.shatter" | "ec.sim.carddance" | "ec.transition.cardwipe" => pieces(e, id, ctx, b), + "ec.sim.cchair" => match effectcraft_effects::sprite_plan(id, ctx, &geo) { + Some(plan) => sprites(e, b, &plan), + None => Some(b), + }, + _ => { + let plan = effectcraft_effects::sprite_plan(id, ctx, &geo)?; + sprites(e, b, &plan) + } + } +} + +// ---------------------------------------------------------------- tiled item raster + +/// Items with their integer pixel bounds, binned into tiles (sim::raster's bands, finer). +struct Items { + stride: usize, + recs: Vec, + w: i32, + h: i32, + tiles: Vec>, + tx: i32, +} + +impl Items { + fn new(stride: usize, w: u32, h: u32) -> Items { + let (w, h) = (w as i32, h as i32); + let tx = (w + TILE - 1) / TILE; + let ty = (h + TILE - 1) / TILE; + Items { stride, recs: vec![], w, h, tiles: vec![vec![]; (tx * ty).max(0) as usize], tx } + } + + /// Add an item (`rec` = its fields, without the bounds) covering float bounds `bb` + /// (`None` or empty = skipped, as sim::raster). + fn push(&mut self, bb: Option<[f32; 4]>, rec: &[f32]) { + let Some(b) = bb else { return }; + if !(b[0].is_finite() && b[1].is_finite() && b[2].is_finite() && b[3].is_finite()) { + return; + } + let x0 = b[0].floor().max(0.0) as i32; + let y0 = b[1].floor().max(0.0) as i32; + let x1 = (b[2].ceil() as i32).min(self.w - 1); + let y1 = (b[3].ceil() as i32).min(self.h - 1); + if x1 < x0 || y1 < y0 || b[2] < 0.0 || b[3] < 0.0 { + return; + } + let i = (self.recs.len() / self.stride) as u32; + let start = self.recs.len(); + self.recs.extend_from_slice(rec); + self.recs.resize(start + self.stride - 4, 0.0); + self.recs.extend([x0 as f32, y0 as f32, x1 as f32, y1 as f32]); + for ty in y0 / TILE..=y1 / TILE { + for tx in x0 / TILE..=x1 / TILE { + self.tiles[(ty * self.tx + tx) as usize].push(i); + } + } + } + + /// Kernel data: tile offsets (tiles + 1), item indices, items. `None` when too large. + fn data(self) -> Option<(Vec, u32, u32)> { + let n_tiles = self.tiles.len(); + let n_idx: usize = self.tiles.iter().map(Vec::len).sum(); + let total = n_tiles + 1 + n_idx + self.recs.len(); + if total > MAX_FLOATS { + return None; + } + let mut d = Vec::with_capacity(total); + let base = (n_tiles + 1) as f32; + let mut at = 0usize; + for t in &self.tiles { + d.push(base + at as f32); + at += t.len(); + } + d.push(base + at as f32); + for t in &self.tiles { + d.extend(t.iter().map(|&i| i as f32)); + } + let items_at = d.len() as u32; + d.extend(self.recs); + Some((d, items_at, self.stride as u32)) + } +} + +/// Rasterise items over a transparent frame. `kind`: 0 sprites, 1 CC Bubbles, 2 Mr. Mercury +/// field, 3 pieces; `src` / `aux` = the textures the items sample. +#[allow(clippy::too_many_arguments)] +fn raster(e: &mut Enc, items: Items, kind: u32, add: bool, src: &GpuImage, aux: Option<&GpuImage>, mut p: Params, w: u32, h: u32) -> Option { + let tx = items.tx as u32; + let (data, items_at, stride) = items.data()?; + let buf = e.data(&data); + p.u[0] = [kind, add as u32, items_at, stride]; + p.u[1][0] = tx; + let out = e.scratch(w, h); + e.pixels("fxm_raster", &p, src, aux, &out, Some(&buf)); + Some(out) +} + +/// sim::sprite_bbox. +fn sprite_bbox(x: f32, y: f32, r: f32, c3: f32, shape: Shape) -> Option<[f32; 4]> { + if c3 <= 1e-5 || r <= 0.0 { + return None; + } + let r = r.max(0.5) + 1.0; + Some(match shape { + Shape::Line { dx, dy } => [x.min(x + dx) - r, y.min(y + dy) - r, x.max(x + dx) + r, y.max(y + dy) + r], + Shape::Star => [x - r * 2.0, y - r * 2.0, x + r * 2.0, y + r * 2.0], + _ => [x - r, y - r, x + r, y + r], + }) +} + +fn sprites(e: &mut Enc, b: GBuf, plan: &SpritePlan) -> Option { + let (w, h) = (b.img.width, b.img.height); + let mut items = Items::new(24, w, h); + for (i, s) in plan.sprites.iter().enumerate() { + let (kind, dx, dy) = match s.shape { + Shape::Soft => (0.0, 0.0, 0.0), + Shape::Disc => (1.0, 0.0, 0.0), + Shape::Sphere => (2.0, 0.0, 0.0), + Shape::Faded => (3.0, 0.0, 0.0), + Shape::Bubble => (4.0, 0.0, 0.0), + Shape::Star => (5.0, 0.0, 0.0), + Shape::Square => (6.0, 0.0, 0.0), + Shape::Tri => (7.0, 0.0, 0.0), + Shape::Line { dx, dy } => (8.0, dx, dy), + }; + let mut rec = [kind, s.x, s.y, s.r, s.c[0], s.c[1], s.c[2], s.c[3], s.rot, dx, dy, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + match plan.tints.get(i).copied().unwrap_or(Tint::Fixed) { + Tint::Fixed => {} + Tint::Refract { x, y } => rec[11..14].copy_from_slice(&[1.0, x as f32, y as f32]), + Tint::Source { x, y } => rec[11..14].copy_from_slice(&[2.0, x as f32, y as f32]), + Tint::Root { x, y, hair, inherit, shade, spec } => { + rec[11..20].copy_from_slice(&[3.0, x as f32, y as f32, hair[0], hair[1], hair[2], inherit, shade, spec]); + } + } + items.push(sprite_bbox(s.x, s.y, s.r, s.c[3], s.shape), &rec); + } + let fx = raster(e, items, 0, plan.acc == Acc::Add, &b.img, None, Params::default(), w, h)?; + let mut p = Params::default(); + match plan.post { + Post::Combine(m) => p.u[0] = [0, m, 0, 0], + Post::Replace => p.u[0] = [0, 5, 0, 0], + Post::Lerp(t) => { + p.u[0][0] = 1; + p.f[0][0] = t; + } + Post::OverBlack => p.u[0][0] = 2, + } + let out = e.scratch(w, h); + e.pixels("fxm_post", &p, &b.img, Some(&fx), &out, None); + Some(GBuf { img: out, ..b }) +} + +// ---------------------------------------------------------------- CC Bubbles / Mr. Mercury + +fn bubbles(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &effectcraft_effects::Buf) -> Option { + let (w, h) = (b.img.width, b.img.height); + let mut items = Items::new(12, w, h); + for q in effectcraft_effects::bubble_list(ctx, geo) { + items.push(Some([q.x - q.r - 1.0, q.y - q.r - 1.0, q.x + q.r + 1.0, q.y + q.r + 1.0]), &[9.0, q.x, q.y, q.r, q.sx, q.sy]); + } + let mut p = Params::default(); + p.u[1] = [0, (ctx.params.e("reflectionType") == 1) as u32, ctx.params.e("shadingType"), 0]; + let img = raster(e, items, 1, false, &b.img, None, p, w, h)?; + Some(GBuf { img, ..b }) +} + +fn mercury(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &effectcraft_effects::Buf) -> Option { + let pr = ctx.params; + let (w, h) = (b.img.width, b.img.height); + let infl = 1.0 + pr.f("blobInfluence") as f32 / 100.0; + let mut items = Items::new(8, w, h); + for q in effectcraft_effects::mercury_blobs(ctx, geo) { + let ex = q.r * 2.0 * infl; + items.push(Some([q.x - ex, q.y - ex, q.x + ex, q.y + ex]), &[10.0, q.x, q.y, q.r]); + } + let mut p = Params::default(); + p.f[0][0] = infl; + let field = raster(e, items, 2, true, &b.img, None, p, w, h)?; + let ldir = (pr.f("light/lightDirection") as f32).to_radians(); + let lheight = pr.f("light/lightHeight") as f32 / 100.0; + let l = norm3([ldir.sin(), -ldir.cos(), lheight.max(0.05)]); + let lc = pr.color("light/lightColor"); + let mut p = Params::default(); + p.f[0] = [ + pr.f("shading/ambient") as f32 / 100.0, + pr.f("shading/diffuse") as f32 / 100.0, + pr.f("shading/specular") as f32 / 100.0, + pr.f("shading/roughness").max(0.001) as f32, + ]; + p.f[1] = [pr.f("shading/metal") as f32 / 100.0, pr.f("light/lightIntensity") as f32 / 100.0, b.scale as f32, 0.0]; + p.f[2] = [l[0], l[1], l[2], 0.0]; + p.f[3] = [lc[0], lc[1], lc[2], 0.0]; + let out = e.scratch(w, h); + e.pixels("fxm_mercury", &p, &b.img, Some(&field), &out, None); + Some(GBuf { img: out, ..b }) +} + +/// sim::norm3. +fn norm3(v: [f32; 3]) -> [f32; 3] { + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + if l > 1e-9 { [v[0] / l, v[1] / l, v[2] / l] } else { [0.0, -1.0, 0.0] } +} + +// ---------------------------------------------------------------- CC Drizzle + +fn drizzle(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &effectcraft_effects::Buf) -> Option { + let pr = ctx.params; + let Some(drops) = effectcraft_effects::drizzle_drops(ctx, geo) else { return Some(b) }; + let rippling = (pr.f("rippling") / 360.0).max(0.05) as f32; + let lh = ctx.layer_size[1] as f32; + let s = b.scale as f32; + let wavelen = (lh * 0.04 * s).max(2.0) / rippling.clamp(0.25, 4.0); + let width = wavelen * (1.0 + rippling * 2.0); + let ldir = (pr.f("light/lightDirection") as f32).to_radians(); + let lheight = pr.f("light/lightHeight") as f32 / 100.0; + let (ls, lcs) = ldir.sin_cos(); + let l = { + let v = [lcs, ls, lheight.max(0.05)]; + let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + [v[0] / n, v[1] / n, v[2] / n] + }; + let hz = (l[2] + 1.0) * 0.5; + let hv = { + let v = [l[0] * 0.5, l[1] * 0.5, hz]; + let m = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + [v[0] / m, v[1] / m, v[2] / m] + }; + let lc = pr.color("light/lightColor"); + let d: Vec = drops.iter().flatten().copied().collect(); + let buf = e.data(&d); + let mut p = Params::default(); + p.u[0][0] = drops.len() as u32; + p.f[0] = [wavelen, width, pr.f("displacement") as f32 * s * 4.0, pr.f("shading/roughness").max(0.001) as f32]; + p.f[1] = [l[0], l[1], l[2], pr.f("light/lightIntensity") as f32 / 100.0]; + p.f[2] = [hv[0], hv[1], hv[2], pr.f("shading/specular") as f32 / 100.0]; + p.f[3] = [lc[0], lc[1], lc[2], 0.0]; + p.f[4] = [pr.f("shading/ambient") as f32 / 100.0, pr.f("shading/diffuse") as f32 / 100.0, 0.0, 0.0]; + let out = e.scratch(b.img.width, b.img.height); + e.pixels("fxm_drizzle", &p, &b.img, None, &out, Some(&buf)); + Some(GBuf { img: out, ..b }) +} + +// ---------------------------------------------------------------- Wave World (Height Map) + +fn wave_height(e: &mut Enc, ctx: &EffectCtx, b: GBuf, st: &effectcraft_effects::Waves, depth: Option<&[f32]>) -> Option { + let pr = ctx.params; + let mut d = st.u.clone(); + if let Some(dp) = depth { + d.extend_from_slice(dp); + } + let buf = e.data(&d); + let mut p = Params::default(); + let dry_transparent = pr.e("heightMapControls/renderDryAreasAs") == 1 && depth.is_some(); + p.u[0] = [st.nx as u32, st.ny as u32, dry_transparent as u32, 0]; + p.f[0] = [ + pr.f("heightMapControls/brightness") as f32, + pr.f("heightMapControls/contrast") as f32, + pr.f("heightMapControls/gamma").max(0.01) as f32, + 1.0 - pr.f("heightMapControls/transparency") as f32, + ]; + p.f[1] = [b.offset[0] as f32, b.offset[1] as f32, b.scale as f32, ctx.layer_size[0].max(1.0) as f32]; + let out = e.scratch(b.img.width, b.img.height); + e.pixels("fxm_wave", &p, &b.img, None, &out, Some(&buf)); + Some(GBuf { img: out, ..b }) +} + +// ---------------------------------------------------------------- pieces + +fn pieces(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + // Planning reads the layer (gradient maps and textures default to it). + let img = e.download(&b.img)?; + let cpu = effectcraft_effects::Buf { img, offset: b.offset, scale: b.scale }; + let Some(plan) = effectcraft_effects::piece_plan(id, ctx, &cpu) else { + // Card Wipe before anything moves: the layer as is; Shatter's wireframes: the CPU. + return (id == "ec.transition.cardwipe").then_some(b); + }; + let (w, h) = (b.img.width, b.img.height); + let tex = |t: &PieceTex| -> Option { + match t { + PieceTex::Layer => Some(b.img.clone()), + PieceTex::Image(i) => e.g.upload_image(i), + } + }; + let front = tex(&plan.front)?; + let back = match &plan.back { + Some(t) => Some(tex(t)?), + None => None, + }; + let mut items = Items::new(48, w, h); + for q in &plan.pieces { + let m = q.inv; + let mut rec = vec![11.0]; + for r in m { + rec.extend(r.map(|v| v as f32)); + } + rec.extend(q.c0); + for v in q.poly { + rec.extend(v); + } + rec.extend([q.n as f32, q.shade]); + rec.extend(q.tint); + rec.extend(q.spec); + rec.push(q.alpha); + match q.flat { + Some(c) => rec.extend([1.0, c[0], c[1], c[2]]), + None => rec.extend([0.0; 4]), + } + rec.extend([q.back as u32 as f32, q.back_mirror as f32]); + items.push(Some(q.bbox), &rec); + } + let mut p = Params::default(); + p.u[1][1] = back.is_some() as u32; + let img = raster(e, items, 3, false, &front, back.as_ref(), p, w, h)?; + Some(GBuf { img, ..b }) +} + +// ---------------------------------------------------------------- Caustics + +fn caustics(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &effectcraft_effects::Buf) -> Option { + let st = effectcraft_effects::caustics_setup(ctx, geo); + let (w, h) = (b.img.width, b.img.height); + let mut bottom = match &st.bottom { + Some(i) => e.g.upload_image(i)?, + None => b.img.clone(), + }; + if st.bottom_sigma > 0.0 { + bottom = gaussian_blur(e, &bottom, st.bottom_sigma, st.bottom_sigma, false); + } + let height = match &st.height { + Some(pl) => { + let mut img = Image::new(pl.w as u32, pl.h as u32); + for (px, v) in img.data.iter_mut().zip(&pl.data) { + *px = [*v, 0.0, 0.0, 0.0]; + } + Some(e.g.upload_image(&img)?) + } + None => None, + }; + let sky = match &st.sky { + Some(i) => { + let g = e.g.upload_image(i)?; + Some((e.image_rows(&g), g.width, g.height)) + } + None => None, + }; + let distant = { + let lw = st.lw.max(1.0); + let v = [(st.lx - st.cx) / lw, (st.ly - st.cy) / lw, st.lheight]; + let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); + [(v[0] / n) as f32, (v[1] / n) as f32, (v[2] / n) as f32] + }; + let mut p = Params::default(); + p.u[0] = [height.is_some() as u32, st.repeat, st.point as u32, st.sky_repeat]; + p.u[1] = [sky.is_some() as u32, sky.as_ref().map_or(0, |s| s.0.1), sky.as_ref().map_or(0, |s| s.1), sky.as_ref().map_or(0, |s| s.2)]; + p.f[0] = [st.scaling as f32, st.wave_h as f32, st.k as f32, st.lw as f32]; + p.f[1] = [st.cx as f32, st.cy as f32, st.lx as f32, st.ly as f32]; + p.f[2] = [st.surf[0], st.surf[1], st.surf[2], st.surf_op]; + p.f[3] = [st.lc[0], st.lc[1], st.lc[2], st.li]; + p.f[4] = [st.lheight as f32, st.cstr, st.ambient, st.diffuse]; + p.f[5] = [st.specular, st.sharp, st.sky_scaling as f32, st.sky_int]; + p.f[6] = [distant[0], distant[1], distant[2], st.convergence as f32]; + let out = e.scratch(w, h); + e.pixels("fxm_caustics", &p, &bottom, height.as_ref(), &out, sky.as_ref().map(|s| &s.0.0)); + Some(GBuf { img: out, ..b }) +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index ca82a5a3..59daf966 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -55,6 +55,7 @@ mod fx_generate; mod fx_key; mod fx_lut; mod fx_noise; +mod fx_sim; mod fx_stylize; mod fx_tone; mod fx_vr; @@ -218,6 +219,8 @@ mod tests_fx_lut; #[cfg(test)] mod tests_fx_noise; #[cfg(test)] +mod tests_fx_sim; +#[cfg(test)] mod tests_fx_stylize; #[cfg(test)] mod tests_fx_tone; diff --git a/crates/gpu/src/shaders/fx_sim.wgsl b/crates/gpu/src/shaders/fx_sim.wgsl new file mode 100644 index 00000000..d936861d --- /dev/null +++ b/crates/gpu/src/shaders/fx_sim.wgsl @@ -0,0 +1,653 @@ +// ---------------------------------------------------------------- simulation render passes (fx_sim.rs) +// +// Items (sprites, CC Bubbles bubbles, Mr. Mercury blobs, textured pieces) are binned into 16 × 16 +// tiles: data = tile offsets, item indices, then the item records (u[0].w floats each, the last +// four the item's integer pixel bounds). Every shading step mirrors effects::sim / sim2. + +fn fxm_unpremul(p: vec4) -> vec4 { + if (p.w > 1e-6) { + return vec4(p.xyz / p.w, p.w); + } + return vec4(0.0, 0.0, 0.0, max(p.w, 0.0)); +} + +fn fxm_aa(edge: f32) -> f32 { + return clamp(edge + 0.5, 0.0, 1.0); +} + +struct FxmShade { + ok: bool, + c: vec4, +} + +fn fxm_none() -> FxmShade { + return FxmShade(false, vec4(0.0)); +} + +// sim::sprite_shade, after resolving the sprite's tint (sim::SpritePlan::resolve) against `src`. +fn fxm_sprite(at: u32, x: f32, y: f32) -> FxmShade { + let kind = u32(data[at]); + let sx = data[at + 1u]; + let sy = data[at + 2u]; + let r0 = data[at + 3u]; + var c = vec4(data[at + 4u], data[at + 5u], data[at + 6u], data[at + 7u]); + let tk = u32(data[at + 11u]); + if (tk != 0u) { + let smp = sample_bilinear(src, data[at + 12u], data[at + 13u]); + let s = fxm_unpremul(smp); + if (tk == 1u) { + if (s.w > 0.0) { + c = vec4((s.xyz * 0.6 + 0.5) * c.xyz, c.w); + } + } else if (tk == 2u) { + if (s.w <= 0.0) { + c = vec4(0.0); + } else { + c = vec4(s.xyz, s.w * c.w); + } + } else { + if (smp.w < 0.5) { + c = vec4(0.0); + } else { + let hair = vec3(data[at + 14u], data[at + 15u], data[at + 16u]); + let inherit = data[at + 17u]; + let base = s.xyz + (hair - s.xyz) * (1.0 - inherit); + c = vec4(base * data[at + 18u] + data[at + 19u], c.w); + } + } + } + if (c.w <= 1e-5) { + return fxm_none(); + } + var r = r0; + var fade = 1.0; + if (r0 < 0.5) { + r = 0.5; + fade = r0 / 0.5; + } + let dx = x - sx; + let dy = y - sy; + var rgb = c.xyz; + var cov = 0.0; + switch (kind) { + case 0u: { + let d2 = (dx * dx + dy * dy) / (r * r); + if (d2 >= 1.0) { + return fxm_none(); + } + let q = 1.0 - d2; + cov = q * q; + } + case 1u: { + cov = fxm_aa(r - sqrt(dx * dx + dy * dy)); + } + case 2u: { + let d = sqrt(dx * dx + dy * dy); + let cv = fxm_aa(r - d); + if (cv <= 0.0) { + return fxm_none(); + } + let nx = dx / r; + let ny = dy / r; + let nz = sqrt(max(1.0 - nx * nx - ny * ny, 0.0)); + let diff = max(nx * -0.48 + ny * -0.58 + nz * 0.66, 0.0); + let rz = 2.0 * diff * nz - 0.66; + let spec = powz(max(rz, 0.0), 20.0) * 0.6; + let k = 0.2 + 0.8 * diff; + rgb = rgb * k + spec; + cov = cv; + } + case 3u: { + let d = sqrt(dx * dx + dy * dy) / r; + cov = powz(max(1.0 - d, 0.0), 1.5); + } + case 4u: { + let d = sqrt(dx * dx + dy * dy) / r; + if (d > 1.0 + 1.0 / r) { + return fxm_none(); + } + let rr = (d - 0.9) / 0.12; + let ring = max(1.0 - rr * rr, 0.0); + let hx = dx / r + 0.4; + let hy = dy / r + 0.4; + let hl = max(1.0 - (hx * hx + hy * hy) / 0.04, 0.0); + cov = min(0.12 * select(0.0, 1.0, d <= 1.0) + ring * 0.8 + hl, 1.0); + } + case 5u: { + let sn = sin(data[at + 8u]); + let cs = cos(data[at + 8u]); + let ux = (dx * cs + dy * sn) / r; + let uy = (-dx * sn + dy * cs) / r; + let d = sqrt(ux * ux + uy * uy); + let core = max(1.0 - d, 0.0) * max(1.0 - d, 0.0); + let stx = max(1.0 - abs(ux) / 2.0, 0.0) * max(1.0 - abs(uy) * 6.0, 0.0); + let sty = max(1.0 - abs(uy) / 2.0, 0.0) * max(1.0 - abs(ux) * 6.0, 0.0); + cov = min(core + stx + sty, 1.0); + } + case 6u: { + let sn = sin(data[at + 8u]); + let cs = cos(data[at + 8u]); + let ux = dx * cs + dy * sn; + let uy = -dx * sn + dy * cs; + let e = r * 0.75; + cov = fxm_aa(e - abs(ux)) * fxm_aa(e - abs(uy)); + } + case 7u: { + let sn = sin(data[at + 8u]); + let cs = cos(data[at + 8u]); + let ux = dx * cs + dy * sn; + let uy = -dx * sn + dy * cs; + let e = r * 0.5; + var m = 3.4e38; + for (var k = 0; k < 3; k++) { + let a = 1.5707964 + f32(k) * 6.2831855 / 3.0; + m = min(m, e - (ux * cos(a) + uy * sin(a))); + } + cov = fxm_aa(m); + } + default: { + let lx = data[at + 9u]; + let ly = data[at + 10u]; + let l2 = lx * lx + ly * ly; + var t = 0.0; + if (l2 > 0.0) { + t = clamp((dx * lx + dy * ly) / l2, 0.0, 1.0); + } + let px = dx - lx * t; + let py = dy - ly * t; + cov = fxm_aa(r - sqrt(px * px + py * py)); + } + } + let a = cov * c.w * fade; + if (a <= 1e-6) { + return fxm_none(); + } + return FxmShade(true, vec4(rgb * a, a)); +} + +// CC Bubbles' bubble (sim::bubbles). u[1].y = metal, u[1].z = shading type. +fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade { + let qx = data[at + 1u]; + let qy = data[at + 2u]; + let qr = data[at + 3u]; + let dx = (x - qx) / max(qr, 0.5); + let dy = (y - qy) / max(qr, 0.5); + let d2 = dx * dx + dy * dy; + let d = sqrt(d2); + let cov = fxm_aa((1.0 - d) * qr); + if (cov <= 0.0) { + return fxm_none(); + } + let k = select(-0.7, 1.6, P.u[1].y != 0u); + let nz = sqrt(max(1.0 - d2, 0.0)); + let s = fxm_unpremul(sample_bilinear(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k)); + var c = s.xyz; + if (s.w <= 0.0) { + c = vec3(0.6, 0.7, 0.8); + } + var a = cov; + switch (P.u[1].z) { + case 1u: { + c = c * 0.7 + 0.3 * (1.0 - nz); + } + case 2u: { + c = c * (0.5 + 0.5 * nz); + } + case 3u: { + a *= powz(nz, 0.5); + } + case 4u: { + a *= 1.0 - nz * 0.8; + } + default: {} + } + let hx = dx + 0.35; + let hy = dy + 0.35; + let hl = max(1.0 - (hx * hx + hy * hy) / 0.05, 0.0) * 0.8; + c = c + hl; + return FxmShade(true, vec4(c * a, a)); +} + +// Mr. Mercury's blob field (f, ∂f/∂x, ∂f/∂y). f[0].x = 1 + influence. +fn fxm_blob(at: u32, x: f32, y: f32) -> FxmShade { + let dx = x - data[at + 1u]; + let dy = y - data[at + 2u]; + let rr = data[at + 3u] * 2.0 * P.f[0].x; + let d2 = (dx * dx + dy * dy) / (rr * rr); + if (d2 >= 1.0) { + return fxm_none(); + } + let k = 1.0 - d2; + let g = -6.0 * k * k / (rr * rr); + return FxmShade(true, vec4(k * k * k, g * dx, g * dy, 0.0)); +} + +// A textured piece (sim2::draw_pieces): src = front texture, aux = back (u[1].y = 1 when set). +fn fxm_piece(at: u32, x: f32, y: f32) -> FxmShade { + let m0 = vec3(data[at + 1u], data[at + 2u], data[at + 3u]); + let m1 = vec3(data[at + 4u], data[at + 5u], data[at + 6u]); + let m2 = vec3(data[at + 7u], data[at + 8u], data[at + 9u]); + let w = m2.x * x + m2.y * y + m2.z; + if (abs(w) < 1e-12) { + return fxm_none(); + } + let c0 = vec2(data[at + 10u], data[at + 11u]); + var u = (m0.x * x + m0.y * y + m0.z) / w + c0.x; + var v = (m1.x * x + m1.y * y + m1.z) / w + c0.y; + let n = u32(data[at + 24u]); + var md = 3.4e38; + for (var i = 0u; i < n; i++) { + let j = (i + 1u) % n; + let ax = data[at + 12u + i * 2u]; + let ay = data[at + 13u + i * 2u]; + let ex = data[at + 12u + j * 2u] - ax; + let ey = data[at + 13u + j * 2u] - ay; + let l = max(sqrt(ex * ex + ey * ey), 1e-6); + md = min(md, (ex * (v - ay) - ey * (u - ax)) / l); + } + let cov = clamp(md + 1.0, 0.0, 1.0); + if (cov <= 0.0) { + return fxm_none(); + } + var c: vec3; + var a = 1.0; + if (data[at + 33u] != 0.0) { + c = vec3(data[at + 34u], data[at + 35u], data[at + 36u]); + } else { + let back = data[at + 37u] != 0.0; + let mirror = u32(data[at + 38u]); + if (back && mirror == 1u) { + v = 2.0 * c0.y - v; + } else if (back && mirror == 2u) { + u = 2.0 * c0.x - u; + } + var t: vec4; + if (back && P.u[1].y != 0u) { + t = sample_bilinear(aux, u, v); + } else { + t = sample_bilinear(src, u, v); + } + let s = fxm_unpremul(t); + c = s.xyz; + a = s.w; + } + a = a * cov * data[at + 32u]; + if (a <= 1e-6) { + return FxmShade(false, vec4(0.0)); + } + let tint = vec3(data[at + 26u], data[at + 27u], data[at + 28u]); + let spec = vec3(data[at + 29u], data[at + 30u], data[at + 31u]); + let o = c * data[at + 25u] * tint + spec; + return FxmShade(true, vec4(o * a, a)); +} + +// sim::raster over a transparent frame. u[0] = (kind: 0 sprites, 1 bubbles, 2 blob field, +// 3 pieces; additive; items offset; item stride); u[1].x = tiles across. +@compute @workgroup_size(16, 16) +fn fxm_raster(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let tile = u32(p.y / 16) * P.u[1].x + u32(p.x / 16); + let start = u32(data[tile]); + let end = u32(data[tile + 1u]); + let stride = P.u[0].w; + let add = P.u[0].y != 0u; + let x = f32(p.x) + 0.5; + let y = f32(p.y) + 0.5; + var d = vec4(0.0); + for (var k = start; k < end; k++) { + let at = P.u[0].z + u32(data[k]) * stride; + let bb = at + stride - 4u; + if (f32(p.x) < data[bb] || f32(p.y) < data[bb + 1u] || f32(p.x) > data[bb + 2u] || f32(p.y) > data[bb + 3u]) { + continue; + } + var s: FxmShade; + switch (P.u[0].x) { + case 0u: { + s = fxm_sprite(at, x, y); + } + case 1u: { + s = fxm_bubble(at, x, y); + } + case 2u: { + s = fxm_blob(at, x, y); + } + default: { + s = fxm_piece(at, x, y); + } + } + if (!s.ok) { + continue; + } + let c = s.c; + if (add) { + d = vec4(d.xyz + c.xyz, min(d.w + c.w, 1.0)); + } else { + d = c + d * (1.0 - c.w); + } + } + textureStore(out, p, d); +} + +// The sprite layer (aux) meets the original (src). u[0].x = 0: sim::combine with mode u[0].y; +// 1: mixed toward the original by f[0].x; 2: over opaque black. +@compute @workgroup_size(16, 16) +fn fxm_post(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let s = textureLoad(src, p, 0); + let f = textureLoad(aux, p, 0); + var o = f; + if (P.u[0].x == 1u) { + o = f + (s - f) * P.f[0].x; + } else if (P.u[0].x == 2u) { + o = vec4(f.xyz, f.w + (1.0 - f.w)); + } else { + switch (P.u[0].y) { + case 0u: { + o = f + s * (1.0 - f.w); + } + case 1u: { + o = s + f - s * f; + } + case 2u: { + o = vec4(s.xyz + f.xyz, min(s.w + f.w, 1.0)); + } + case 3u: { + o = vec4(max(s.xyz, f.xyz), s.w + f.w - s.w * f.w); + } + case 4u: { + let fu = fxm_unpremul(f); + let m = s.xyz - (s.xyz - min(s.xyz, fu.xyz * s.w)) * fu.w; + o = vec4(m, s.w); + } + default: {} + } + } + textureStore(out, p, o); +} + +fn fxm_norm3(v: vec3) -> vec3 { + let l = sqrt(v.x * v.x + v.y * v.y + v.z * v.z); + if (l > 1e-9) { + return v / l; + } + return vec3(0.0, -1.0, 0.0); +} + +// Mr. Mercury's shading (src = layer, aux = blob field). f[0] = (ambient, diffuse, specular, +// roughness); f[1] = (metal, light intensity, render scale); f[2] = light direction; f[3] = colour. +@compute @workgroup_size(16, 16) +fn fxm_mercury(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let fv = textureLoad(aux, p, 0); + let f = fv.x; + let thr = 0.35; + if (f <= thr * 0.8) { + textureStore(out, p, vec4(0.0)); + return; + } + let cov = clamp((f - thr * 0.8) / (thr * 0.4), 0.0, 1.0); + let n = fxm_norm3(vec3(fv.y * 3.0, fv.z * 3.0, 1.0 / (1.0 + f))); + let s = P.f[1].z; + let smp = fxm_unpremul(sample_bilinear_clamped(src, f32(p.x) + 0.5 - n.x * 12.0 * s, f32(p.y) + 0.5 - n.y * 12.0 * s)); + var base = smp.xyz; + if (smp.w <= 0.0) { + base = vec3(0.7, 0.7, 0.75); + } + let l = P.f[2].xyz; + let nl = max(dot(n, l), 0.0); + let hv = fxm_norm3(vec3(l.x, l.y, l.z + 1.0)); + let li = P.f[1].y; + let spec = powz(max(dot(n, hv), 0.0), 1.0 / P.f[0].w) * P.f[0].z * li; + let shade = P.f[0].x + P.f[0].y * nl * li; + let metal = P.f[1].x; + let cc = base * shade + spec * (P.f[3].xyz * (1.0 - metal) + base * metal); + textureStore(out, p, vec4(cc * cov, cov)); +} + +// CC Drizzle's ripple height at (x, y): data = drops (x, y, ring radius, amplitude). +fn fxm_ripples(x: f32, y: f32) -> f32 { + var v = 0.0; + for (var i = 0u; i < P.u[0].x; i++) { + let dx = data[i * 4u]; + let dy = data[i * 4u + 1u]; + let ex = x - dx; + let ey = y - dy; + let d = sqrt(ex * ex + ey * ey); + let r = data[i * 4u + 2u]; + let u = (d - r) / P.f[0].y; + if (abs(u) < 1.0) { + let env = 0.5 + 0.5 * cos(u * 3.1415927); + v += data[i * 4u + 3u] * env * cos((d - r) / P.f[0].x * 6.2831855); + } + } + return v; +} + +// CC Drizzle. u[0].x = drops; f[0] = (wavelength, ring width, displacement, roughness); +// f[1] = (light direction, intensity); f[2] = (half vector, specular); f[3] = light colour; +// f[4] = (ambient, diffuse). +@compute @workgroup_size(16, 16) +fn fxm_drizzle(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let fx = f32(p.x) + 0.5; + let fy = f32(p.y) + 0.5; + let gx = fxm_ripples(fx + 1.0, fy) - fxm_ripples(fx - 1.0, fy); + let gy = fxm_ripples(fx, fy + 1.0) - fxm_ripples(fx, fy - 1.0); + if (gx == 0.0 && gy == 0.0) { + textureStore(out, p, textureLoad(src, p, 0)); + return; + } + let smp = sample_bilinear_clamped(src, fx - gx * P.f[0].z, fy - gy * P.f[0].z); + let v = vec3(-gx * 6.0, -gy * 6.0, 1.0); + let n = v / sqrt(v.x * v.x + v.y * v.y + 1.0); + let l = P.f[1].xyz; + let li = P.f[1].w; + let nl = max(dot(n, l), 0.0); + let sp = powz(max(dot(n, P.f[2].xyz), 0.0), 1.0 / P.f[0].w) * P.f[2].w * li; + let shade = 1.0 + (P.f[4].x + P.f[4].y * nl - P.f[4].y * l.z) * li; + let a = smp.w; + textureStore(out, p, vec4(smp.xyz * shade + sp * P.f[3].xyz * a, a)); +} + +// Wave World's Height Map view. data = grid heights (nx × ny), then water depths when +// u[0].z (dry areas transparent); f[0] = (brightness, contrast, gamma, alpha); +// f[1] = (buffer offset, render scale, layer width). +@compute @workgroup_size(16, 16) +fn fxm_wave(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let nx = P.u[0].x; + let ny = P.u[0].y; + let lw = P.f[1].w; + let lx = (f32(p.x) + 0.5 - P.f[1].x) / P.f[1].z; + let ly = (f32(p.y) + 0.5 - P.f[1].y) / P.f[1].z; + let gx = clamp(lx / lw * f32(nx - 1u), 0.0, f32(nx - 1u)); + let gy = clamp(ly / lw * f32(nx - 1u), 0.0, f32(ny - 1u)); + let x0 = u32(floor(gx)); + let y0 = u32(floor(gy)); + let x1 = min(x0 + 1u, nx - 1u); + let y1 = min(y0 + 1u, ny - 1u); + let tx = gx - f32(x0); + let ty = gy - f32(y0); + let g00 = data[y0 * nx + x0]; + let g10 = data[y0 * nx + x1]; + let g01 = data[y1 * nx + x0]; + let g11 = data[y1 * nx + x1]; + let a = g00 + (g10 - g00) * tx; + let c = g01 + (g11 - g01) * tx; + let h = a + (c - a) * ty; + let v = powz(clamp(P.f[0].x + P.f[0].y * h, 0.0, 1.0), 1.0 / P.f[0].z); + var al = P.f[0].w; + if (P.u[0].z != 0u) { + let rx = u32(clamp(round_away(lx / lw * f32(nx - 1u)), 0.0, f32(nx - 1u))); + let ry = u32(clamp(round_away(ly / lw * f32(nx - 1u)), 0.0, f32(ny - 1u))); + if (data[nx * ny + ry * nx + rx] <= 0.0) { + al = 0.0; + } + } + textureStore(out, p, vec4(v * al, v * al, v * al, al)); +} + +// ---- Caustics + +// The water surface's height gradient at (x, y) (aux = height plane, clamped; none = flat). +fn fxm_grad(x: i32, y: i32) -> vec2 { + if (P.u[0].x == 0u) { + return vec2(0.0); + } + let gx = (tex_get_clamped(aux, x + 1, y).x - tex_get_clamped(aux, x - 1, y).x) * 0.5; + let gy = (tex_get_clamped(aux, x, y + 1).x - tex_get_clamped(aux, x, y - 1).x) * 0.5; + return vec2(gx, gy); +} + +fn fxm_rem(v: f32, n: f32) -> f32 { + return v - n * floor(v / n); +} + +fn fxm_mirror(v: f32, n: f32) -> f32 { + let r = fxm_rem(v, 2.0 * n); + return select(r, 2.0 * n - r, r > n); +} + +// sim2::sample_repeat of `src`. +fn fxm_sample_repeat(x: f32, y: f32, mode: u32) -> vec4 { + let d = vec2(textureDimensions(src)); + if (mode == 1u) { + return sample_bilinear_clamped(src, fxm_rem(x, d.x), fxm_rem(y, d.y)); + } + if (mode == 2u) { + return sample_bilinear_clamped(src, fxm_mirror(x, d.x), fxm_mirror(y, d.y)); + } + return sample_bilinear(src, x, y); +} + +// A sky texel from `data` (RGBA f32 rows of u[1].y pixels; u[1].zw = size). +fn fxm_sky_texel(x: i32, y: i32, clamped: bool) -> vec4 { + let w = i32(P.u[1].z); + let h = i32(P.u[1].w); + var xi = x; + var yi = y; + if (clamped) { + xi = clamp(x, 0, w - 1); + yi = clamp(y, 0, h - 1); + } else if (x < 0 || y < 0 || x >= w || y >= h) { + return vec4(0.0); + } + let i = (u32(yi) * P.u[1].y + u32(xi)) * 4u; + return vec4(data[i], data[i + 1u], data[i + 2u], data[i + 3u]); +} + +fn fxm_sky_bilinear(x: f32, y: f32, clamped: bool) -> vec4 { + let fx = x - 0.5; + let fy = y - 0.5; + let x0 = floor(fx); + let y0 = floor(fy); + let tx = fx - x0; + let ty = fy - y0; + let xi = i32(x0); + let yi = i32(y0); + let a = fxm_sky_texel(xi, yi, clamped); + let b = fxm_sky_texel(xi + 1, yi, clamped); + let c = fxm_sky_texel(xi, yi + 1, clamped); + let d = fxm_sky_texel(xi + 1, yi + 1, clamped); + let top = a + (b - a) * tx; + let bot = c + (d - c) * tx; + return top + (bot - top) * ty; +} + +fn fxm_sky_repeat(x: f32, y: f32, mode: u32) -> vec4 { + let w = f32(P.u[1].z); + let h = f32(P.u[1].w); + if (mode == 1u) { + return fxm_sky_bilinear(fxm_rem(x, w), fxm_rem(y, h), true); + } + if (mode == 2u) { + return fxm_sky_bilinear(fxm_mirror(x, w), fxm_mirror(y, h), true); + } + return fxm_sky_bilinear(x, y, false); +} + +// Caustics (sim2::caustics; src = the blurred bottom, aux = height plane, data = sky rows). +// u[0] = (height, repeat, point light, sky repeat); u[1] = (sky, row, width, height); +// f[0] = (scaling, wave height, displacement k, layer width); f[1] = (centre, light position); +// f[2] = (surface colour, opacity); f[3] = (light colour, intensity); f[4] = (light height, +// caustics strength, ambient, diffuse); f[5] = (specular, sharpness, sky scaling, sky +// intensity); f[6] = (distant light direction, convergence). +@compute @workgroup_size(16, 16) +fn fxm_caustics(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let k = P.f[0].z; + let x = p.x; + let y = p.y; + let dd = -fxm_grad(x, y) * k; + let dxr = -fxm_grad(min(x + 1, dims.x - 1), y).x * k; + let dyd = -fxm_grad(x, min(y + 1, dims.y - 1)).y * k; + let dxl = -fxm_grad(max(x - 1, 0), y).x * k; + let dyu = -fxm_grad(x, max(y - 1, 0)).y * k; + let jac = (1.0 + (dxr - dxl) * 0.5) * (1.0 + (dyd - dyu) * 0.5); + let focus = clamp(1.0 / max(abs(jac), 0.2) - 1.0, -1.0, 4.0); + let cx = P.f[1].x; + let cy = P.f[1].y; + let fx = f32(x) + 0.5; + let fy = f32(y) + 0.5; + let sx = cx + (fx + dd.x - cx) / P.f[0].x; + let sy = cy + (fy + dd.y - cy) / P.f[0].x; + let bot = fxm_unpremul(fxm_sample_repeat(sx, sy, P.u[0].y)); + let g = fxm_grad(x, y); + let wh = P.f[0].y; + let v = vec3(-g.x * wh * 20.0, -g.y * wh * 20.0, 1.0); + let n = v / sqrt(v.x * v.x + v.y * v.y + 1.0); + var lv = P.f[6].xyz; + if (P.u[0].z != 0u) { + let lw = max(P.f[0].w, 1.0); + let q = vec3((P.f[1].z - fx) / lw, (P.f[1].w - fy) / lw, P.f[4].x); + lv = q / max(sqrt(q.x * q.x + q.y * q.y + q.z * q.z), 1e-12); + } + let li = P.f[3].w; + let nl = max(dot(n, lv), 0.0); + let hq = vec3(lv.x, lv.y, lv.z + 1.0); + let hv = hq / sqrt(hq.x * hq.x + hq.y * hq.y + hq.z * hq.z); + let spec = powz(max(dot(n, hv), 0.0), P.f[5].y) * P.f[5].x * li; + let ambient = P.f[4].z; + let diffuse = P.f[4].w; + let light = ambient + diffuse * li * nl + P.f[4].y * focus * li; + let surf = P.f[2].xyz; + let surf_op = P.f[2].w; + var surface: vec3; + if (P.u[1].x != 0u) { + let reach = (1.0 - P.f[6].w) * P.f[0].w * 0.5; + let rx = cx + (fx + n.x * reach - cx) / P.f[5].z; + let ry = cy + (fy + n.y * reach - cy) / P.f[5].z; + let sc = fxm_unpremul(fxm_sky_repeat(rx, ry, P.u[0].w)).xyz; + surface = sc * P.f[5].w * 3.0 * surf; + } else { + surface = surf * (ambient + diffuse * li * nl); + } + let lc = P.f[3].xyz; + let cc = bot.xyz * light * lc * (1.0 - surf_op) + surface * surf_op + spec * lc; + let a = min(bot.w + surf_op * (1.0 - bot.w), 1.0); + textureStore(out, p, vec4(cc * a, a)); +} diff --git a/crates/gpu/src/tests_fx_sim.rs b/crates/gpu/src/tests_fx_sim.rs new file mode 100644 index 00000000..55dc62c4 --- /dev/null +++ b/crates/gpu/src/tests_fx_sim.rs @@ -0,0 +1,154 @@ +//! GPU simulation render passes vs the CPU effects (the oracle): the frame's plan (sprites, +//! pieces, blobs, drops, height grid) is shared, the raster / shading pass runs on each side. +//! Direct on a buffer (full and half resolution) and composited at 8 and 32 bpc, at a time +//! where the simulations have something to show. + +use effectcraft_keyframe::Value; +use effectcraft_project::BitDepth; +use effectcraft_render::RenderOpts; +use effectcraft_time::Tick; + +use crate::tests::{Scene, c, check, compare_at, diff, gpu, n, opts, pattern, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +fn off() -> Value { + Value::Bool(false) +} + +/// The effect alone on a buffer at layer time `t` (`allow` = fraction of pixels over 1e-3). +fn direct(id: &str, vals: &[(&str, Value)], t: f64, allow: f64) { + let Some(g) = gpu() else { return }; + let spec = effectcraft_effects::find(id).unwrap(); + let size = [70.0, 44.0]; + let mut params = + effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() }; + for (k, v) in vals { + params.values.insert(k.to_string(), v.clone()); + } + for scale in [1.0, 0.5] { + let img = pattern(7, (size[0] * scale) as u32, (size[1] * scale) as u32); + let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale }; + let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: t, layer_size: size, seed: 11, adjustment: false, env: Default::default() }; + let cpu = (spec.render)(&ctx(), buf.clone()); + let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect"); + assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "{id}: geometry"); + let d = diff(&cpu.img, &out.img, 1e-3); + assert!( + d.over as f64 <= allow * d.total as f64, + "{id} {vals:?} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}", + d.over, + d.total, + d.max, + d.worst + ); + } +} + +/// A footage layer with the effect over a background and a second footage layer (the target of +/// `Value::Layer(Some(0))` parameters), composited at 8 and 32 bpc at comp time `t`. +fn composited(id: &str, vals: &[(&str, Value)], t: f64, allow: f64) { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let other = s.footage(60, 50); + let other_id = s.push(other); + let mut l = s.footage(70, 44); + let vals: Vec<(&str, Value)> = + vals.iter().map(|(k, v)| (*k, if *v == Value::Layer(Some(0)) { Value::Layer(Some(other_id.0)) } else { v.clone() })).collect(); + s.effect(&mut l, id, &vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let at = Tick::from_seconds_f64(t); + check(&format!("{id} {vals:?} {depth:?}"), compare_at(&s, opts(), at), allow); + check(&format!("{id} {vals:?} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, at), allow); + } +} + +fn case(id: &str, vals: &[(&str, Value)], t: f64, allow: f64) { + direct(id, vals, t, allow); + composited(id, vals, t, allow); +} + +#[test] +fn weather_and_star_burst() { + case("ec.sim.ccrainfall", &[("drops", n(800.0)), ("size", n(3.0))], 0.5, 0.0); + case("ec.sim.ccrainfall", &[("drops", n(500.0)), ("extras/appearance", e(1)), ("transferMode", e(0)), ("wind", n(800.0))], 0.7, 0.0); + case("ec.sim.ccrainfall", &[("drops", n(300.0)), ("compositeWithOriginal", off()), ("size", n(6.0))], 0.3, 0.0); + case("ec.sim.ccsnowfall", &[("flakes", n(600.0)), ("size", n(6.0))], 0.5, 0.0); + case("ec.sim.ccsnowfall", &[("flakes", n(400.0)), ("transferMode", e(1)), ("wiggle/wiggleAmount", n(8.0))], 1.2, 0.0); + case("ec.sim.ccstarburst", &[("gridSpacing", n(6.0))], 0.4, 0.0); + case("ec.sim.ccstarburst", &[("gridSpacing", n(4.0)), ("blendWithOriginal", n(40.0)), ("size", n(160.0))], 0.9, 0.0); +} + +#[test] +fn bubbles_drizzle_mercury() { + case("ec.sim.ccbubbles", &[("bubbleAmount", n(60.0)), ("bubbleSize", n(3.0))], 0.5, 0.0); + for shading in 1..5 { + case( + "ec.sim.ccbubbles", + &[("bubbleAmount", n(40.0)), ("bubbleSize", n(4.0)), ("shadingType", e(shading)), ("reflectionType", e(shading % 2))], + 0.8, + 0.0, + ); + } + case("ec.sim.ccdrizzle", &[("dripRate", n(12.0)), ("spreading", n(300.0))], 0.6, 0.0); + case("ec.sim.ccdrizzle", &[("dripRate", n(20.0)), ("shading/diffuse", n(40.0)), ("shading/ambient", n(-20.0)), ("light/lightHeight", n(60.0))], 1.1, 0.0); + case("ec.sim.ccmrmercury", &[("birthRate", n(4.0)), ("blobBirthSize", n(1.5)), ("blobDeathSize", n(1.0))], 1.0, 0.0); + case("ec.sim.ccmrmercury", &[("birthRate", n(3.0)), ("blobInfluence", n(60.0)), ("shading/metal", n(80.0)), ("gravity", n(2.0))], 1.5, 0.0); +} + +#[test] +fn hair_wave_world_foam() { + case("ec.sim.cchair", &[("density", n(300.0)), ("length", n(10.0))], 0.0, 0.0); + case("ec.sim.cchair", &[("density", n(200.0)), ("length", n(14.0)), ("hairColor/colorInheritance", n(30.0)), ("thickness", n(1.5))], 0.0, 0.0); + case("ec.sim.waveworld", &[], 0.5, 0.0); + case("ec.sim.waveworld", &[("view", e(1))], 0.8, 0.0); + composited("ec.sim.waveworld", &[("view", e(1)), ("ground/ground", Value::Layer(Some(0))), ("heightMapControls/renderDryAreasAs", e(1))], 0.6, 0.0); + case("ec.sim.foam", &[], 1.0, 0.0); + case("ec.sim.foam", &[("view", e(2))], 1.5, 0.0); + case("ec.sim.foam", &[("view", e(2)), ("rendering/bubbleTexture", e(4)), ("rendering/blendMode", e(2))], 1.2, 0.0); +} + +#[test] +fn pieces() { + case("ec.sim.carddance", &[("rows", n(5.0)), ("columns", n(7.0)), ("zRotation/zRotMultiplier", n(20.0))], 0.0, 0.0); + composited("ec.sim.carddance", &[("rows", n(4.0)), ("backLayer", Value::Layer(Some(0))), ("yRotation/yRotMultiplier", n(150.0))], 0.0, 0.0); + case("ec.sim.shatter", &[("view", e(0))], 1.0, 0.0); + case("ec.sim.shatter", &[("view", e(0)), ("shape/pattern", e(2)), ("physics/gravity", n(4.0))], 1.6, 0.0); + case("ec.transition.cardwipe", &[("completion", n(40.0))], 0.0, 0.0); + composited("ec.transition.cardwipe", &[("completion", n(60.0)), ("backLayer", Value::Layer(Some(0))), ("flipAxis", e(1))], 0.0, 0.0); +} + +#[test] +fn caustics() { + case("ec.sim.caustics", &[], 0.0, 0.0); + composited( + "ec.sim.caustics", + &[("water/waterSurface", Value::Layer(Some(0))), ("water/waveHeight", n(0.3)), ("water/smoothing", n(4.0)), ("lighting/lightType", e(1))], + 0.0, + 0.0, + ); + composited( + "ec.sim.caustics", + &[ + ("water/waterSurface", Value::Layer(Some(0))), + ("sky/sky", Value::Layer(Some(0))), + ("water/surfaceOpacity", n(0.6)), + ("bottom/repeatMode", e(2)), + ("bottom/scaling", n(1.4)), + ("bottom/blur", n(3.0)), + ("sky/repeatMode", e(1)), + ], + 0.0, + 0.0, + ); + let _ = (c(0.0, 0.0, 0.0), on()); +} diff --git a/docs/effects.md b/docs/effects.md index 26310ace..6adea08e 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -5,7 +5,7 @@ EffectCraft ships 306 effects with 3009 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**: 193 effects also run on the GPU compositor with identical results. +- **GPU**: 206 effects also run on the GPU compositor with identical results. - **32**: 306 effects process 32-bit float (HDR, overbright) pixels without clamping. - **Status**: 306 implemented in full, 0 partial (what is missing is listed). @@ -318,23 +318,23 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| | CC Ball Action | `ec.sim.ccballaction` | 8 | | 32 | Implemented | -| CC Bubbles | `ec.sim.ccbubbles` | 7 | | 32 | Implemented | -| CC Drizzle | `ec.sim.ccdrizzle` | 14 | | 32 | Implemented | -| CC Hair | `ec.sim.cchair` | 20 | | 32 | Implemented | -| CC Mr. Mercury | `ec.sim.ccmrmercury` | 23 | | 32 | Implemented | +| CC Bubbles | `ec.sim.ccbubbles` | 7 | GPU | 32 | Implemented | +| CC Drizzle | `ec.sim.ccdrizzle` | 14 | GPU | 32 | Implemented | +| 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 | | 32 | Implemented | | CC Particle World | `ec.sim.ccparticleworld` | 30 | | 32 | Implemented | | CC Pixel Polly | `ec.sim.ccpixelpolly` | 10 | | 32 | Implemented | -| CC Rainfall | `ec.sim.ccrainfall` | 14 | | 32 | Implemented | +| CC Rainfall | `ec.sim.ccrainfall` | 14 | GPU | 32 | Implemented | | CC Scatterize | `ec.sim.ccscatterize` | 4 | | 32 | Implemented | -| CC Snowfall | `ec.sim.ccsnowfall` | 19 | | 32 | Implemented | -| CC Star Burst | `ec.sim.ccstarburst` | 6 | | 32 | Implemented | -| Card Dance | `ec.sim.carddance` | 53 | | 32 | Implemented | -| Caustics | `ec.sim.caustics` | 28 | | 32 | Implemented | -| Foam | `ec.sim.foam` | 36 | | 32 | Implemented | +| CC Snowfall | `ec.sim.ccsnowfall` | 19 | GPU | 32 | Implemented | +| CC Star Burst | `ec.sim.ccstarburst` | 6 | GPU | 32 | Implemented | +| Card Dance | `ec.sim.carddance` | 53 | GPU | 32 | Implemented | +| Caustics | `ec.sim.caustics` | 28 | GPU | 32 | Implemented | +| Foam | `ec.sim.foam` | 36 | GPU | 32 | Implemented | | Particle Playground | `ec.sim.particleplayground` | 92 | | 32 | Implemented | -| Shatter | `ec.sim.shatter` | 60 | | 32 | Implemented | -| Wave World | `ec.sim.waveworld` | 35 | | 32 | Implemented | +| Shatter | `ec.sim.shatter` | 60 | GPU | 32 | Implemented | +| Wave World | `ec.sim.waveworld` | 35 | GPU | 32 | Implemented | ## Stylize @@ -401,7 +401,7 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Scale Wipe | `ec.transition.ccscalewipe` | 3 | | 32 | Implemented | | CC Twister | `ec.transition.cctwister` | 5 | | 32 | Implemented | | CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | | 32 | Implemented | -| Card Wipe | `ec.transition.cardwipe` | 44 | | 32 | Implemented | +| Card Wipe | `ec.transition.cardwipe` | 44 | GPU | 32 | Implemented | | Gradient Wipe | `ec.transition.gradientwipe` | 5 | GPU | 32 | Implemented | | Iris Wipe | `ec.transition.iriswipe` | 7 | GPU | 32 | Implemented | | Linear Wipe | `ec.transition.linearwipe` | 3 | GPU | 32 | Implemented |