M13.23: GPU simulation render passes

CC Rainfall, Snowfall, Star Burst, Bubbles, Drizzle, Hair, Mr. Mercury,
Caustics, Wave World, Foam, Shatter, Card Dance and Card Wipe render on the GPU
(fx_sim.rs / fx_sim.wgsl). The per-frame simulation / layout stays on the CPU
and is shared with the CPU effect through plans (effects::sprite_plan,
piece_plan, bubble_list, mercury_blobs, drizzle_drops, wave_world_plan,
caustics_setup); a tiled item rasteriser composites sprites, bubbles, blob
fields and textured pieces in the CPU's order and shading. Layer-dependent
sprite colours (refracting rain, star-burst and hair roots) are sampled per
pixel; piece plans read the frame back once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 18:35:22 -04:00
parent 2e5ea051a8
commit 1ec9336d7c
12 changed files with 1796 additions and 206 deletions

View File

@ -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

View File

@ -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<T: Sync>(
// ------------------------------------------------------------------ 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<Sprite>,
/// Per sprite (empty = all [`Tint::Fixed`]).
pub tints: Vec<Tint>,
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<SpritePlan> {
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<Sprite> = (0..n)
let (sprites, tints): (Vec<Sprite>, Vec<Tint>) = (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<Bubble> {
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<Bubble> = (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<Vec<[f32; 4]>> {
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<Sprite> = 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<PState> = 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<Blob> {
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<Blob> = 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);

View File

@ -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<Piece>,
pub front: PieceTex,
pub back: Option<PieceTex>,
}
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<PiecePlan> {
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<Piece> = (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<Image, PiecePlan> {
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<Image>| 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<Image>,
/// 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<Plane>,
/// The Sky layer fitted to the buffer.
pub sky: Option<Image>,
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<f32>,
v: Vec<f32>,
pub struct Waves {
pub nx: usize,
pub ny: usize,
pub u: Vec<f32>,
pub v: Vec<f32>,
}
struct Producer {
@ -1091,7 +1243,27 @@ impl Producer {
static WAVE_CACHE: SimCache<Waves> = 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<Waves>, depth: Option<Vec<f32>> },
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<WavePlan> {
wave_world_impl(ctx, b, true).err()
}
fn wave_world_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result<Image, WavePlan> {
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<crate::sim::SpritePlan> {
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<Image, crate::sim::SpritePlan> {
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

View File

@ -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<SpritePlan> {
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<Vec<Sprite>> = (0..count)
let strands: Vec<Vec<(Sprite, Tint)>> = (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<Sprite> = 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<EffectSpec> {

View File

@ -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<PiecePlan> {
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 {

View File

@ -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)))

View File

@ -135,6 +135,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
_ 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),
}

424
crates/gpu/src/fx_sim.rs Normal file
View File

@ -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<GBuf> {
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<f32>,
w: i32,
h: i32,
tiles: Vec<Vec<u32>>,
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<f32>, 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<GpuImage> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<f32> = 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<GBuf> {
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<GBuf> {
// 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<GpuImage> {
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<GBuf> {
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 })
}

View File

@ -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;

View File

@ -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<f32>) -> vec4<f32> {
if (p.w > 1e-6) {
return vec4<f32>(p.xyz / p.w, p.w);
}
return vec4<f32>(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<f32>,
}
fn fxm_none() -> FxmShade {
return FxmShade(false, vec4<f32>(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<f32>(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<f32>((s.xyz * 0.6 + 0.5) * c.xyz, c.w);
}
} else if (tk == 2u) {
if (s.w <= 0.0) {
c = vec4<f32>(0.0);
} else {
c = vec4<f32>(s.xyz, s.w * c.w);
}
} else {
if (smp.w < 0.5) {
c = vec4<f32>(0.0);
} else {
let hair = vec3<f32>(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<f32>(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<f32>(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<f32>(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<f32>(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<f32>(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<f32>(data[at + 1u], data[at + 2u], data[at + 3u]);
let m1 = vec3<f32>(data[at + 4u], data[at + 5u], data[at + 6u]);
let m2 = vec3<f32>(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<f32>(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<f32>;
var a = 1.0;
if (data[at + 33u] != 0.0) {
c = vec3<f32>(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<f32>;
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<f32>(0.0));
}
let tint = vec3<f32>(data[at + 26u], data[at + 27u], data[at + 28u]);
let spec = vec3<f32>(data[at + 29u], data[at + 30u], data[at + 31u]);
let o = c * data[at + 25u] * tint + spec;
return FxmShade(true, vec4<f32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32>(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<f32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32>(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<f32>(s.xyz + f.xyz, min(s.w + f.w, 1.0));
}
case 3u: {
o = vec4<f32>(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<f32>(m, s.w);
}
default: {}
}
}
textureStore(out, p, o);
}
fn fxm_norm3(v: vec3<f32>) -> vec3<f32> {
let l = sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
if (l > 1e-9) {
return v / l;
}
return vec3<f32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32>(0.0));
return;
}
let cov = clamp((f - thr * 0.8) / (thr * 0.4), 0.0, 1.0);
let n = fxm_norm3(vec3<f32>(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<f32>(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<f32>(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<f32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32>(-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<f32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32>(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<f32> {
if (P.u[0].x == 0u) {
return vec2<f32>(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<f32>(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<f32> {
let d = vec2<f32>(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<f32> {
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<f32>(0.0);
}
let i = (u32(yi) * P.u[1].y + u32(xi)) * 4u;
return vec4<f32>(data[i], data[i + 1u], data[i + 2u], data[i + 3u]);
}
fn fxm_sky_bilinear(x: f32, y: f32, clamped: bool) -> vec4<f32> {
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<f32> {
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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32>(-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<f32>((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<f32>(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<f32>;
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<f32>(cc * a, a));
}

View File

@ -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: &params, 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());
}

View File

@ -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.<category>.<name>`) 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.<category>.<name>`) 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 |