effectcraft/crates/effects/src/sim2.rs

2699 lines
130 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Simulation effects, part 2: layer-shattering and surface simulations — CC Ball Action,
//! CC Pixel Polly, CC Scatterize, Card Dance, Shatter (planar pieces in perspective), Caustics,
//! Wave World (stepped 2D wave equation) and Foam (stepped bubble sim).
//!
//! Written from the public descriptions of the effects' behaviour. Pieces are textured convex
//! polygons placed in 3D and drawn through a per-piece inverse homography, far to near.
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::card3d::{Lighting, Proj};
use crate::generate::value_noise;
use crate::sim::{Acc, Post, Shape, Sprite, SpritePlan, h, hs, pct, pt, raster, spec, splat};
use crate::util::{Plane, SimCache, gauss_plane, layer_or_self, params_key, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
// ------------------------------------------------------------------ 3D pieces
pub(crate) type M3 = [[f64; 3]; 3];
fn mat_mul(a: &M3, b: &M3) -> M3 {
std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| a[i][k] * b[k][j]).sum()))
}
/// Rotation from Euler angles (radians), applied X then Y then Z.
fn rot_xyz(x: f64, y: f64, z: f64) -> M3 {
let (sx, cx) = x.sin_cos();
let (sy, cy) = y.sin_cos();
let (sz, cz) = z.sin_cos();
let rx = [[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]];
let ry = [[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]];
let rz = [[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]];
mat_mul(&rz, &mat_mul(&ry, &rx))
}
/// Rotation by `angle` about unit `axis` (Rodrigues).
pub(crate) fn rot_axis(axis: [f64; 3], angle: f64) -> M3 {
let l = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
if l < 1e-12 || angle == 0.0 {
return [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
}
let (x, y, z) = (axis[0] / l, axis[1] / l, axis[2] / l);
let (s, c) = angle.sin_cos();
let t = 1.0 - c;
[
[t * x * x + c, t * x * y - s * z, t * x * z + s * y],
[t * x * y + s * z, t * y * y + c, t * y * z - s * x],
[t * x * z - s * y, t * y * z + s * x, t * z * z + c],
]
}
fn inv3(m: &M3) -> Option<M3> {
let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
if det.abs() < 1e-12 {
return None;
}
let d = 1.0 / det;
Some([
[(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * d, (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * d, (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * d],
[(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * d, (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * d, (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * d],
[(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * d, (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * d, (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * d],
])
}
/// Perspective camera looking down +z at the layer plane z = 0, `d` pixels away, centred on
/// `c` (buffer pixels): screen = c + (P - c) · d / (d + z).
#[derive(Clone, Copy)]
struct Cam {
c: [f64; 2],
d: f64,
}
impl Cam {
fn proj(self) -> Proj {
Proj::simple(self.c, self.d)
}
}
/// A planar textured piece ready to draw.
#[derive(Clone, Copy, Debug)]
pub struct Piece {
/// Screen (buffer px, homogeneous) → piece-local (a, b, w).
pub inv: [[f64; 3]; 3],
/// Rest centre of the piece in texture pixels.
pub c0: [f32; 2],
/// Convex polygon (texture pixels), counter-clockwise in y-down space.
pub poly: [[f32; 2]; 6],
pub n: u8,
pub bbox: [f32; 4],
pub shade: f32,
/// Lighting: colour multiplier and additive specular highlight.
pub tint: [f32; 3],
pub spec: [f32; 3],
pub alpha: f32,
/// Flat straight colour instead of the texture.
pub flat: Option<[f32; 3]>,
/// Facing away from the camera (draw the back texture).
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 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 => match shatter_impl(ctx, b) {
ShatterPlan::Pieces(plan) => Some(plan),
ShatterPlan::Wire(_) => None,
},
"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`.
pub(crate) fn new(poly: &[[f32; 2]], c0: [f32; 2], r: &M3, pos: [f64; 3], cam: impl Into<Proj>) -> Option<Piece> {
let cam: Proj = cam.into();
let n = poly.len().min(6);
if n < 3 {
return None;
}
let m = &cam.m;
let row = |i: usize| -> [f64; 3] {
[
m[i][0] * r[0][0] + m[i][1] * r[1][0] + m[i][2] * r[2][0],
m[i][0] * r[0][1] + m[i][1] * r[1][1] + m[i][2] * r[2][1],
m[i][0] * pos[0] + m[i][1] * pos[1] + m[i][2] * pos[2] + m[i][3],
]
};
let hm: M3 = [row(0), row(1), row(2)];
let mut bb = [f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY];
for v in &poly[..n] {
let (a, b) = ((v[0] - c0[0]) as f64, (v[1] - c0[1]) as f64);
let w = hm[2][0] * a + hm[2][1] * b + hm[2][2];
if w < cam.near {
return None;
}
let sx = ((hm[0][0] * a + hm[0][1] * b + hm[0][2]) / w) as f32;
let sy = ((hm[1][0] * a + hm[1][1] * b + hm[1][2]) / w) as f32;
bb = [bb[0].min(sx), bb[1].min(sy), bb[2].max(sx), bb[3].max(sy)];
}
let inv = inv3(&hm)?;
let mut pp = [[0.0f32; 2]; 6];
pp[..n].copy_from_slice(&poly[..n]);
// Normalise winding so "inside" is cross >= 0 for every edge.
let area: f32 = (0..n).map(|i| pp[i][0] * pp[(i + 1) % n][1] - pp[(i + 1) % n][0] * pp[i][1]).sum();
if area < 0.0 {
pp[..n].reverse();
}
// The front faces -z at rest; it faces the camera when its normal points at the eye.
let eye = cam.eye();
let nrm = [-r[0][2], -r[1][2], -r[2][2]];
let to_eye = [eye[0] - pos[0], eye[1] - pos[1], eye[2] - pos[2]];
let facing = nrm[0] * to_eye[0] + nrm[1] * to_eye[1] + nrm[2] * to_eye[2];
let depth = hm[2][2];
Some(Piece {
inv,
c0,
poly: pp,
n: n as u8,
bbox: [bb[0] - 1.0, bb[1] - 1.0, bb[2] + 1.0, bb[3] + 1.0],
shade: 1.0,
tint: [1.0; 3],
spec: [0.0; 3],
alpha: 1.0,
flat: None,
back: facing < 0.0,
back_mirror: 0,
depth,
})
}
/// Lambert factor of the piece normal against a light shining along +z.
fn facing(r: &M3) -> f32 {
r[2][2].abs() as f32
}
/// Light the piece (rotation `r`, centre `pos`) with Lighting / Material.
pub(crate) fn light(&mut self, l: &Lighting, r: &M3, pos: [f64; 3], eye: [f64; 3]) {
let (t, s) = l.shade([-r[0][2], -r[1][2], -r[2][2]], pos, eye);
self.tint = t;
self.spec = s;
}
}
impl From<Cam> for Proj {
fn from(c: Cam) -> Proj {
c.proj()
}
}
/// Draw pieces (already sorted far → near) over `out`.
pub(crate) fn draw_pieces(out: &mut Image, pieces: &[Piece], front: &Image, back: Option<&Image>) {
raster(
out,
pieces,
|q| Some(q.bbox),
|q, x, y| {
let (xf, yf) = (x as f64, y as f64);
let m = &q.inv;
let w = m[2][0] * xf + m[2][1] * yf + m[2][2];
if w.abs() < 1e-12 {
return None;
}
let u = ((m[0][0] * xf + m[0][1] * yf + m[0][2]) / w) as f32 + q.c0[0];
let v = ((m[1][0] * xf + m[1][1] * yf + m[1][2]) / w) as f32 + q.c0[1];
let n = q.n as usize;
let mut md = f32::INFINITY;
for i in 0..n {
let a = q.poly[i];
let b = q.poly[(i + 1) % n];
let (ex, ey) = (b[0] - a[0], b[1] - a[1]);
let l = (ex * ex + ey * ey).sqrt().max(1e-6);
md = md.min((ex * (v - a[1]) - ey * (u - a[0])) / l);
}
// Coverage extends half a pixel past the edge so abutting pieces leave no seams.
let cov = (md + 1.0).clamp(0.0, 1.0);
if cov <= 0.0 {
return None;
}
let (c, a) = match q.flat {
Some(c) => (c, 1.0),
None => {
let tex = if q.back { back.unwrap_or(front) } else { front };
let (u, v) = match (q.back, q.back_mirror) {
(true, 1) => (u, 2.0 * q.c0[1] - v),
(true, 2) => (2.0 * q.c0[0] - u, v),
_ => (u, v),
};
unpremul(tex.sample_bilinear(u as f64, v as f64))
}
};
let a = a * cov * q.alpha;
if a <= 1e-6 {
return None;
}
let k = q.shade;
let o: [f32; 3] = std::array::from_fn(|i| c[i] * k * q.tint[i] + q.spec[i]);
Some([o[0] * a, o[1] * a, o[2] * a, a])
},
Acc::Over,
);
}
pub(crate) fn sort_far_first(pieces: &mut [Piece]) {
pieces.sort_by(|a, b| b.depth.total_cmp(&a.depth));
}
// ------------------------------------------------------------------ CC Ball Action
fn ball_action(ctx: &EffectCtx, b: Buf) -> Buf {
ball_action_plan(ctx, &b).finish(b)
}
/// CC Ball Action's balls (far to near), coloured by the layer's pixels.
fn ball_action_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
let pr = ctx.params;
let scatter = pr.f("scatter") as f32;
let axis = pr.e("rotationAxis");
let rot = pr.f("rotation").to_radians();
let twist_prop = pr.e("twistProperty");
let twist = pr.f("twistAngle").to_radians();
let spacing = pr.f("gridSpacing").max(1.0) as f32;
let size = pr.f("ballSize") as f32 / 100.0;
let instab = pr.f("instabilityState").to_radians() as f32;
let seed = ctx.seed.wrapping_mul(0x2545f491);
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let s = b.scale as f32;
let nx = (lw / spacing).ceil().max(1.0) as u32;
let ny = (lh / spacing).ceil().max(1.0) as u32;
let d = 2.0 * lw.max(lh) as f64;
let (cx, cy) = (lw as f64 * 0.5, lh as f64 * 0.5);
let src = &b.img;
let ax: [f64; 3] = match axis {
0 => [1.0, 0.0, 0.0],
1 => [0.0, 1.0, 0.0],
2 => [0.0, 0.0, 1.0],
3 => [1.0, 1.0, 0.0],
4 => [1.0, 0.0, 1.0],
5 => [0.0, 1.0, 1.0],
_ => [1.0, 1.0, 1.0],
};
let maxr = ((lw * lw + lh * lh).sqrt() * 0.5).max(1.0);
let mut balls: Vec<(f64, Sprite)> = (0..nx * ny)
.into_par_iter()
.filter_map(|i| {
let gx = (i % nx) as f32 * spacing + spacing * 0.5;
let gy = (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 || size <= 0.0 {
return None;
}
let tp = match twist_prop {
0 => gx / lw.max(1.0),
1 => gy / lh.max(1.0),
2 => ((gx - cx as f32).hypot(gy - cy as f32)) / maxr,
_ => crate::util::rgb3([c[0], c[1], c[2], 0.0]).iter().sum::<f32>() / 3.0,
} as f64;
let ang = rot + twist * tp;
// Scatter: random 3D offsets whose direction drifts with the instability state.
let ph = h(i, 1, seed) * std::f32::consts::TAU + instab;
let sc = scatter * h(i, 2, seed);
let off = [(sc * ph.cos()) as f64, (sc * ph.sin()) as f64, (scatter * hs(i, 3, seed)) as f64];
let p0 = [gx as f64 - cx + off[0], gy as f64 - cy + off[1], off[2]];
let m = rot_axis(ax, ang);
let q: [f64; 3] = std::array::from_fn(|k| m[k][0] * p0[0] + m[k][1] * p0[1] + m[k][2] * p0[2]);
if d + q[2] <= d * 0.05 {
return None;
}
let persp = d / (d + q[2]);
let (bx, by) = b.to_px([cx + q[0] * persp, cy + q[1] * persp]);
let r = spacing * 0.5 * size * persp as f32 * s;
Some((q[2], Sprite::new(bx as f32, by as f32, r, [c[0], c[1], c[2], a], Shape::Sphere)))
})
.collect();
balls.sort_by(|a, b| b.0.total_cmp(&a.0));
let sprites: Vec<Sprite> = balls.into_iter().map(|(_, s)| s).collect();
SpritePlan { sprites, tints: vec![], acc: Acc::Over, post: Post::Replace }
}
// ------------------------------------------------------------------ CC Pixel Polly
fn pixel_polly(ctx: &EffectCtx, b: Buf) -> Buf {
match pixel_polly_plan(ctx, &b) {
Some(plan) => plan.finish(b),
None => b,
}
}
/// CC Pixel Polly's pieces, `None` before Start Time (the layer as is).
fn pixel_polly_plan(ctx: &EffectCtx, b: &Buf) -> Option<PiecePlan> {
let pr = ctx.params;
let t = ctx.time - pr.f("startTime");
if t <= 0.0 {
return None;
}
let force = pr.f("force");
let gravity = pr.f("gravity");
let spin = pr.f("spinning").to_radians();
let fc = pr.v2("forceCenter");
let dir_rand = pr.f("directionRandomness") / 100.0;
let spd_rand = pr.f("speedRandomness") / 100.0;
let g = pr.f("gridSpacing").max(1.0);
let object = pr.e("object");
let depth_sort = pr.b("enableDepthSort");
let seed = ctx.seed.wrapping_mul(0x632be5ab);
let [lw, lh] = ctx.layer_size;
let s = b.scale;
let cam = Cam { c: [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s], d: 2.0 * lw.max(lh) * s };
let nx = (lw / g).ceil().max(1.0) as u32;
let ny = (lh / g).ceil().max(1.0) as u32;
let tri = object <= 1;
let textured = object == 1 || object == 3;
let maxd = lw.max(lh);
let src = &b.img;
let per = if tri { 2 } else { 1 };
let mut pieces: Vec<Piece> = (0..nx * ny * per)
.into_par_iter()
.filter_map(|i| {
let cell = i / per;
let (x0, y0) = ((cell % nx) as f64 * g, (cell / nx) as f64 * g);
let poly: Vec<[f64; 2]> = if !tri {
vec![[x0, y0], [x0 + g, y0], [x0 + g, y0 + g], [x0, y0 + g]]
} else if i % 2 == 0 {
vec![[x0, y0], [x0 + g, y0], [x0, y0 + g]]
} else {
vec![[x0 + g, y0], [x0 + g, y0 + g], [x0, y0 + g]]
};
let c = [poly.iter().map(|v| v[0]).sum::<f64>() / poly.len() as f64, poly.iter().map(|v| v[1]).sum::<f64>() / poly.len() as f64];
let (dx, dy) = (c[0] - fc[0], c[1] - fc[1]);
let dist = (dx * dx + dy * dy).sqrt().max(1e-6);
let ang = dy.atan2(dx) + dir_rand * std::f64::consts::PI * hs(i, 1, seed) as f64;
let speed = force / 100.0 * 0.6 * maxd / (1.0 + dist / (0.5 * maxd)) * (1.0 + spd_rand * hs(i, 2, seed) as f64);
let vz = -speed * 0.5 * h(i, 3, seed) as f64;
let pos = [c[0] + ang.cos() * speed * t, c[1] + ang.sin() * speed * t + 0.5 * gravity * 0.5 * lh * t * t, vz * t];
let axis = [hs(i, 4, seed) as f64, hs(i, 5, seed) as f64, hs(i, 6, seed) as f64];
let r = rot_axis(axis, t * (std::f64::consts::PI * h(i, 7, seed) as f64 + spin));
let tex: Vec<[f32; 2]> = poly.iter().map(|v| [(v[0] * s + b.offset[0]) as f32, (v[1] * s + b.offset[1]) as f32]).collect();
let c0 = [(c[0] * s + b.offset[0]) as f32, (c[1] * s + b.offset[1]) as f32];
let posb = [pos[0] * s + b.offset[0], pos[1] * s + b.offset[1], pos[2] * s];
let mut pc = Piece::new(&tex, c0, &r, posb, cam)?;
pc.shade = 0.35 + 0.65 * Piece::facing(&r);
if !textured {
let (cc, a) = unpremul(src.sample_bilinear(c0[0] as f64, c0[1] as f64));
if a <= 0.0 {
return None;
}
pc.flat = Some(cc);
pc.alpha = a;
}
Some(pc)
})
.collect();
if depth_sort {
sort_far_first(&mut pieces);
}
Some(PiecePlan { pieces, front: PieceTex::Layer, back: None })
}
// ------------------------------------------------------------------ CC Scatterize
fn scatterize(ctx: &EffectCtx, b: Buf) -> Buf {
match scatterize_plan(ctx, &b) {
Some(plan) => plan.finish(b),
None => b,
}
}
/// CC Scatterize's pixel dots, `None` when nothing moves (the layer as is).
fn scatterize_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
let pr = ctx.params;
let scatter = pr.f("scatter") as f32;
let right = pr.f("rightTwist").to_radians();
let left = pr.f("leftTwist").to_radians();
if scatter == 0.0 && right == 0.0 && left == 0.0 {
return None;
}
let add = pr.e("transferMode") == 1;
let seed = ctx.seed.wrapping_mul(0x7feb352d);
let s = b.scale;
let (w, hh) = (b.img.width, b.img.height);
let (cx, cy) = (b.offset[0] + ctx.layer_size[0] * 0.5 * s, b.offset[1] + ctx.layer_size[1] * 0.5 * s);
let half = (ctx.layer_size[0] * 0.5 * s).max(1.0);
let d = 2.0 * ctx.layer_size[0].max(ctx.layer_size[1]) * s;
let src = &b.img;
let sprites: Vec<Sprite> = (0..w * hh)
.into_par_iter()
.filter_map(|i| {
let px = src.data[i as usize];
if px[3] <= 0.0 {
return None;
}
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
// Twist about the horizontal axis through the centre, growing towards each side.
let k = ((x - cx) / half).abs().min(1.0);
let ang = if x < cx { left } else { right } * k;
let (sa, ca) = ang.sin_cos();
let (yy, zz) = ((y - cy) * ca, (y - cy) * sa);
let persp = d / (d + zz).max(d * 0.05);
let jx = hs(i, 1, seed) * scatter * s as f32;
let jy = hs(i, 2, seed) * scatter * s as f32;
let sx = cx + (x - cx) * persp + jx as f64;
let sy = cy + yy * persp + jy as f64;
let (c, a) = unpremul(px);
Some(Sprite::new(sx as f32, sy as f32, 0.6 * persp as f32, [c[0], c[1], c[2], a], Shape::Disc))
})
.collect();
Some(SpritePlan { sprites, tints: vec![], acc: if add { Acc::Add } else { Acc::Over }, post: Post::Replace })
}
/// What a particle effect that reads the layer's pixels draws this frame (CC Ball Action, CC
/// Pixel Polly, CC Scatterize, Shatter's wireframe views), for layer `b`; `None` for other
/// effects or a frame showing the layer as is.
pub enum PixelPlan {
Sprites(SpritePlan),
Pieces(PiecePlan),
}
/// The [`PixelPlan`] of effect `id` for layer `b` (its pixels are read).
pub fn pixel_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option<PixelPlan> {
match id {
"ec.sim.ccballaction" => Some(PixelPlan::Sprites(ball_action_plan(ctx, b))),
"ec.sim.ccpixelpolly" => pixel_polly_plan(ctx, b).map(PixelPlan::Pieces),
"ec.sim.ccscatterize" => scatterize_plan(ctx, b).map(PixelPlan::Sprites),
"ec.sim.shatter" => Some(match shatter_impl(ctx, b) {
ShatterPlan::Pieces(plan) => PixelPlan::Pieces(plan),
ShatterPlan::Wire(plan) => PixelPlan::Sprites(plan),
}),
_ => None,
}
}
// ------------------------------------------------------------------ Card Dance
const CD_SOURCES: &[&str] = &["None", "Intensity 1", "Red 1", "Green 1", "Blue 1", "Alpha 1", "Intensity 2", "Red 2", "Green 2", "Blue 2", "Alpha 2"];
/// (param id prefix, twirl-down group match id) per transformed card property.
const CD_PROPS: [(&str, &str); 8] = [
("xPos", "xPosition"),
("yPos", "yPosition"),
("zPos", "zPosition"),
("xRot", "xRotation"),
("yRot", "yRotation"),
("zRot", "zRotation"),
("xScale", "xScale"),
("yScale", "yScale"),
];
fn card_source(src: u32, g1: [f32; 4], g2: [f32; 4]) -> f32 {
let (c1, a1) = unpremul(g1);
let (c2, a2) = unpremul(g2);
let lum = |c: [f32; 3]| effectcraft_color::luminance(c[0], c[1], c[2]);
match src {
1 => lum(c1),
2 => c1[0],
3 => c1[1],
4 => c1[2],
5 => a1,
6 => lum(c2),
7 => c2[0],
8 => c2[1],
9 => c2[2],
10 => a2,
_ => 0.0,
}
}
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 [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 eye = cam.eye();
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 mut pieces: Vec<Piece> = (0..rows * cols)
.into_par_iter()
.filter_map(|i| {
let (cx0, cy0) = ((i % cols) as f64 * cw, (i / cols) as f64 * ch);
let c = [cx0 + cw * 0.5, cy0 + ch * 0.5];
let (bx, by) = (c[0] * s + b.offset[0], c[1] * s + b.offset[1]);
let (gp1, gp2) = (g1.sample_bilinear(bx, by), g2.sample_bilinear(bx, by));
let val = |k: usize| {
let (sr, m, o) = props[k];
o + m * card_source(sr, gp1, gp2) as f64
};
let (sx, sy) = (val(6), val(7));
let r = rot_xyz(val(3).to_radians(), val(4).to_radians(), val(5).to_radians());
let rs: M3 = std::array::from_fn(|k| [r[k][0] * sx, r[k][1] * sy, r[k][2]]);
let pos = [bx + val(0) * cw * s, by + val(1) * ch * s, val(2) * cw.max(ch) * s];
let (tx0, ty0) = ((cx0 * s + b.offset[0]) as f32, (cy0 * s + b.offset[1]) as f32);
let (tw, th) = ((cw * s) as f32, (ch * s) as f32);
let poly = [[tx0, ty0], [tx0 + tw, ty0], [tx0 + tw, ty0 + th], [tx0, ty0 + th]];
let mut pc = Piece::new(&poly, [bx as f32, by as f32], &rs, pos, cam)?;
pc.light(&light, &r, pos, eye);
Some(pc)
})
.collect();
sort_far_first(&mut pieces);
PiecePlan { pieces, front: PieceTex::Layer, back: back.map(PieceTex::Image) }
}
// ------------------------------------------------------------------ Shatter
/// Shatter's Pattern options; the last, Custom, takes its pieces from Custom Shatter Map.
const SHATTER_PATTERNS: &[&str] = &["Bricks", "Glass", "Hexagons", "Squares", "Triangles", "Custom"];
/// Pattern cells (polygons in pattern space, before rotation) covering `[x0, x1]×[y0, y1]`.
fn pattern_cells(kind: u32, cell: f64, x0: f64, y0: f64, x1: f64, y1: f64, seed: u32) -> Vec<Vec<[f64; 2]>> {
let mut out = Vec::new();
let i0 = (x0 / cell).floor() as i64 - 1;
let i1 = (x1 / cell).ceil() as i64 + 1;
let j0 = (y0 / cell).floor() as i64 - 1;
let j1 = (y1 / cell).ceil() as i64 + 1;
match kind {
0 => {
let bh = cell * 0.5;
let r0 = (y0 / bh).floor() as i64 - 1;
let r1 = (y1 / bh).ceil() as i64 + 1;
for r in r0..=r1 {
let sh = if r.rem_euclid(2) == 1 { cell * 0.5 } else { 0.0 };
for i in i0..=i1 {
let (x, y) = (i as f64 * cell + sh, r as f64 * bh);
out.push(vec![[x, y], [x + cell, y], [x + cell, y + bh], [x, y + bh]]);
}
}
}
1 => {
// Jittered lattice split into triangles along a random diagonal.
let jit = |i: i64, j: i64| -> [f64; 2] {
let (a, b) = (i as u32, j as u32);
[i as f64 * cell + hs(a, b.wrapping_add(17), seed) as f64 * cell * 0.35, j as f64 * cell + hs(a, b.wrapping_add(91), seed) as f64 * cell * 0.35]
};
for j in j0..=j1 {
for i in i0..=i1 {
let (p00, p10, p11, p01) = (jit(i, j), jit(i + 1, j), jit(i + 1, j + 1), jit(i, j + 1));
if h(i as u32, j as u32, seed ^ 0x55) < 0.5 {
out.push(vec![p00, p10, p11]);
out.push(vec![p00, p11, p01]);
} else {
out.push(vec![p00, p10, p01]);
out.push(vec![p10, p11, p01]);
}
}
}
}
2 => {
let r = cell / 3f64.sqrt();
let dx = 1.5 * r;
let dy = 3f64.sqrt() * r;
let c0 = (x0 / dx).floor() as i64 - 1;
let c1 = (x1 / dx).ceil() as i64 + 1;
let rr0 = (y0 / dy).floor() as i64 - 1;
let rr1 = (y1 / dy).ceil() as i64 + 1;
for ci in c0..=c1 {
for rj in rr0..=rr1 {
let cx = ci as f64 * dx;
let cy = (rj as f64 + if ci.rem_euclid(2) == 1 { 0.5 } else { 0.0 }) * dy;
out.push(
(0..6)
.map(|k| {
let a = k as f64 * std::f64::consts::PI / 3.0;
[cx + r * a.cos(), cy + r * a.sin()]
})
.collect(),
);
}
}
}
3 => {
for j in j0..=j1 {
for i in i0..=i1 {
let (x, y) = (i as f64 * cell, j as f64 * cell);
out.push(vec![[x, y], [x + cell, y], [x + cell, y + cell], [x, y + cell]]);
}
}
}
_ => {
for j in j0..=j1 {
for i in i0..=i1 {
let (x, y) = (i as f64 * cell, j as f64 * cell);
if (i + j).rem_euclid(2) == 0 {
out.push(vec![[x, y], [x + cell, y], [x, y + cell]]);
out.push(vec![[x + cell, y], [x + cell, y + cell], [x, y + cell]]);
} else {
out.push(vec![[x, y], [x + cell, y], [x + cell, y + cell]]);
out.push(vec![[x, y], [x + cell, y + cell], [x, y + cell]]);
}
}
}
}
}
out
}
/// `∫0^t e^{-ks} ds` and `∫0^t ∫0^u e^{-ks} ds du` (drag-damped ballistic factors).
fn drag_factors(k: f64, t: f64) -> (f64, f64) {
if k < 1e-6 {
(t, 0.5 * t * t)
} else {
let e = (-k * t).exp();
((1.0 - e) / k, t / k - (1.0 - e) / (k * k))
}
}
/// Shatter's Front / Side / Back Mode options.
const SHATTER_TEXTURE_MODES: [&str; 3] = ["Color", "Layer", "Tinted Layer"];
/// One face's texture: a flat colour, a layer, or a layer tinted by the Textures colour.
struct Face {
mode: u32,
img: Option<Image>,
}
impl Face {
fn from(ctx: &EffectCtx, b: &Buf, mode: &str, layer: &str, default_mode: u32) -> Face {
let mode = ctx.params.get(mode).map(|v| v.as_enum()).unwrap_or(default_mode);
// A face layer of None is the effect's own layer.
let img = (mode != 0).then(|| layer_or_self(ctx, b, layer, true, true));
Face { mode, img }
}
}
/// Group key of a Custom Shatter Map colour (pieces of one colour break as one).
fn map_key(c: [f32; 4]) -> u32 {
let (c, a) = unpremul(c);
if a < 0.5 {
return u32::MAX;
}
let q = |v: f32| ((v.clamp(0.0, 1.0) * 15.0).round() as u32) & 15;
q(c[0]) << 8 | q(c[1]) << 4 | q(c[2])
}
fn shatter(ctx: &EffectCtx, b: Buf) -> Buf {
match shatter_impl(ctx, &b) {
ShatterPlan::Pieces(plan) => plan.finish(b),
ShatterPlan::Wire(plan) => plan.finish(b),
}
}
/// What Shatter draws: the pieces (Rendered view) or the wireframe lines.
enum ShatterPlan {
Pieces(PiecePlan),
Wire(SpritePlan),
}
fn shatter_impl(ctx: &EffectCtx, b: &Buf) -> ShatterPlan {
let pr = ctx.params;
let view = pr.e("view");
let render = pr.e("render");
let kind = pr.e("shape/pattern");
let custom = kind as usize == SHATTER_PATTERNS.len() - 1;
let reps = pr.f("shape/repetitions").clamp(1.0, 500.0);
let dir = pr.f("shape/direction").to_radians();
let origin = pr.v2("shape/origin");
let forces = [
(pr.v2("force1/force1Position"), pr.f("force1/force1Depth"), pr.f("force1/force1Radius"), pr.f("force1/force1Strength")),
(pr.v2("force2/force2Position"), pr.f("force2/force2Depth"), pr.f("force2/force2Radius"), pr.f("force2/force2Strength")),
];
let rot_speed = pr.f("physics/rotationSpeed");
let tumble = pr.e("physics/tumbleAxis");
let randomness = pr.f("physics/randomness");
let k = pr.f("physics/viscosity").max(0.0) * 5.0;
let mass_var = pr.f("physics/massVariance") / 100.0;
let gravity = pr.f("physics/gravity");
let gdir = pr.f("physics/gravityDirection").to_radians();
let ginc = pr.f("physics/gravityInclination").to_radians();
let seed = (pr.f("randomSeed") as u32).wrapping_mul(0x9e3779b1) ^ ctx.seed;
let [lw, lh] = ctx.layer_size;
let s = b.scale;
let t = ctx.time.max(0.0);
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 eye = cam.eye();
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 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 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];
// Pattern-space bounds of the layer rectangle.
let corners = [[0.0, 0.0], [lw, 0.0], [lw, lh], [0.0, lh]].map(|c: [f64; 2]| {
let (dx, dy) = (c[0] - origin[0], c[1] - origin[1]);
[dx * cd + dy * sd, -dx * sd + dy * cd]
});
let (px0, px1) = (corners.iter().map(|c| c[0]).fold(f64::INFINITY, f64::min), corners.iter().map(|c| c[0]).fold(f64::NEG_INFINITY, f64::max));
let (py0, py1) = (corners.iter().map(|c| c[1]).fold(f64::INFINITY, f64::min), corners.iter().map(|c| c[1]).fold(f64::NEG_INFINITY, f64::max));
// Custom Shatter Map: square cells grouped by the map's colour.
let cells = pattern_cells(if custom { 3 } else { kind }, cell, px0, py0, px1, py1, seed);
let polys: Vec<Vec<[f64; 2]>> = cells.iter().map(|poly| poly.iter().map(|&q| to_layer(q)).collect()).collect();
let centre = |lp: &[[f64; 2]]| [lp.iter().map(|v| v[0]).sum::<f64>() / lp.len() as f64, lp.iter().map(|v| v[1]).sum::<f64>() / lp.len() as f64];
let keys: Vec<u32> = match &custom_map {
Some(m) => polys
.iter()
.map(|lp| {
let c = centre(lp);
map_key(m.sample_bilinear(c[0] * s + b.offset[0], c[1] * s + b.offset[1]))
})
.collect(),
None => (0..polys.len() as u32).collect(),
};
// Group centres (custom map) — each group moves as one rigid piece.
let mut groups: std::collections::HashMap<u32, ([f64; 2], f64)> = std::collections::HashMap::new();
for (lp, &key) in polys.iter().zip(&keys) {
let c = centre(lp);
let e = groups.entry(key).or_insert(([0.0; 2], 0.0));
e.0[0] += c[0];
e.0[1] += c[1];
e.1 += 1.0;
}
let g3 = [gdir.sin() * ginc.cos() * gravity * unit, -gdir.cos() * ginc.cos() * gravity * unit, ginc.sin() * gravity * unit];
let mut pieces: Vec<Piece> = polys
.par_iter()
.zip(keys.par_iter())
.flat_map_iter(|(lp, &key)| {
let mut out: Vec<Piece> = vec![];
let (mnx, mxx) = (lp.iter().map(|v| v[0]).fold(f64::INFINITY, f64::min), lp.iter().map(|v| v[0]).fold(f64::NEG_INFINITY, f64::max));
let (mny, mxy) = (lp.iter().map(|v| v[1]).fold(f64::INFINITY, f64::min), lp.iter().map(|v| v[1]).fold(f64::NEG_INFINITY, f64::max));
if mxx < 0.0 || mxy < 0.0 || mnx > lw || mny > lh || key == u32::MAX {
return out.into_iter();
}
let pc_c = centre(lp);
let (gsum, gn) = groups[&key];
let gc = [gsum[0] / gn, gsum[1] / gn];
let i = key;
// Broken when inside a force sphere (centred `depth` in front of the layer).
let mut v = [0.0f64; 3];
let mut broken = false;
for (fp, fd, fr, fs) in forces {
let rad = fr * lw;
if rad <= 0.0 {
continue;
}
let dv = [gc[0] - fp[0], gc[1] - fp[1], fd * lw];
let dist = (dv[0] * dv[0] + dv[1] * dv[1] + dv[2] * dv[2]).sqrt();
if dist < rad {
broken = true;
let f = fs * (1.0 - dist / rad) * unit / dist.max(1e-6);
for q in 0..3 {
v[q] += dv[q] * f;
}
}
}
if broken && let Some(g) = &gradient {
let (c, _) = unpremul(g.sample_bilinear(gc[0] * s + b.offset[0], gc[1] * s + b.offset[1]));
let mut l = effectcraft_color::luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) as f64;
if invert_gradient {
l = 1.0 - l;
}
broken = threshold > 0.0 && l >= 1.0 - threshold;
}
if broken && custom && white_fixed && key == 0xfff {
broken = false;
}
if (broken && render == 1) || (!broken && render == 2) {
return out.into_iter();
}
let mut gpos = [gc[0], gc[1], 0.0];
let mut r = rot_axis([1.0, 0.0, 0.0], 0.0);
let moving = broken && t > 0.0;
if moving {
let mass = 1.0 + mass_var * hs(i, 1, seed) as f64;
for q in 0..3 {
v[q] = v[q] / mass + randomness * unit * hs(i, 2 + q as u32, seed) as f64 * 2.0;
}
let (f1, f2) = drag_factors(k, t);
for q in 0..3 {
gpos[q] += v[q] * f1 + g3[q] * f2;
}
let axis = match tumble {
1 => [0.0, 0.0, 0.0],
2 => [1.0, 0.0, 0.0],
3 => [0.0, 1.0, 0.0],
4 => [0.0, 0.0, 1.0],
5 => [hs(i, 6, seed) as f64, hs(i, 7, seed) as f64, 0.0],
6 => [hs(i, 6, seed) as f64, 0.0, hs(i, 8, seed) as f64],
7 => [0.0, hs(i, 7, seed) as f64, hs(i, 8, seed) as f64],
_ => [hs(i, 6, seed) as f64, hs(i, 7, seed) as f64, hs(i, 8, seed) as f64],
};
let speed = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt() / unit;
let omega = rot_speed * std::f64::consts::TAU * (0.5 + h(i, 9, seed) as f64) * speed.min(10.0);
r = rot_axis(axis, omega * f1);
}
// The piece's centre: its offset from the group centre, rotated with the group.
let off = [pc_c[0] - gc[0], pc_c[1] - gc[1], 0.0];
let pos: [f64; 3] = std::array::from_fn(|q| gpos[q] + (0..3).map(|k| r[q][k] * off[k]).sum::<f64>());
let tex: Vec<[f32; 2]> = lp.iter().map(|v| [(v[0] * s + b.offset[0]) as f32, (v[1] * s + b.offset[1]) as f32]).collect();
let c0 = [(pc_c[0] * s + b.offset[0]) as f32, (pc_c[1] * s + b.offset[1]) as f32];
let posb = [pos[0] * s + b.offset[0], pos[1] * s + b.offset[1], pos[2] * s];
let Some(mut pc) = Piece::new(&tex, c0, &r, posb, cam) else { return out.into_iter() };
pc.light(&light, &r, posb, eye);
pc.alpha = tex_opacity;
let face = if pc.back { &backf } else { &front };
if face.mode == 0 {
pc.flat = Some([tex_color[0], tex_color[1], tex_color[2]]);
} else if face.mode == 2 {
pc.tint = std::array::from_fn(|q| pc.tint[q] * tex_color[q]);
}
// Extrusion: the sides of a moving piece, from its front outline back by the depth.
if moving && extrude > 0.0 {
let nb = [r[0][2], r[1][2], r[2][2]];
let n = tex.len();
for e in 0..n {
let (a, bv) = (tex[e], tex[(e + 1) % n]);
let ed = [(bv[0] - a[0]) as f64, (bv[1] - a[1]) as f64];
let len = (ed[0] * ed[0] + ed[1] * ed[1]).sqrt();
if len < 1e-6 {
continue;
}
let ew: [f64; 3] = std::array::from_fn(|q| (r[q][0] * ed[0] + r[q][1] * ed[1]) / len);
let third = [ew[1] * nb[2] - ew[2] * nb[1], ew[2] * nb[0] - ew[0] * nb[2], ew[0] * nb[1] - ew[1] * nb[0]];
let rs: M3 = std::array::from_fn(|q| [ew[q], nb[q], third[q]]);
// Side centre in world: edge midpoint (world) plus half the depth backwards.
let mid = [((a[0] + bv[0]) * 0.5 - c0[0]) as f64, ((a[1] + bv[1]) * 0.5 - c0[1]) as f64];
let mw: [f64; 3] = std::array::from_fn(|q| posb[q] + r[q][0] * mid[0] + r[q][1] * mid[1] + nb[q] * extrude * 0.5);
let (hl, hd) = ((len * 0.5) as f32, (extrude * 0.5) as f32);
let cs = [(a[0] + bv[0]) * 0.5, (a[1] + bv[1]) * 0.5];
let quad = [[cs[0] - hl, cs[1] - hd], [cs[0] + hl, cs[1] - hd], [cs[0] + hl, cs[1] + hd], [cs[0] - hl, cs[1] + hd]];
if let Some(mut sp) = Piece::new(&quad, cs, &rs, mw, cam) {
sp.light(&light, &rs, mw, eye);
sp.alpha = tex_opacity;
let colour = match (&side.img, side.mode) {
(Some(img), m) if m != 0 => {
let (c, _) = unpremul(img.sample_bilinear(cs[0] as f64, cs[1] as f64));
if m == 2 { std::array::from_fn(|q| c[q] * tex_color[q]) } else { c }
}
_ => [tex_color[0], tex_color[1], tex_color[2]],
};
sp.flat = Some(colour);
out.push(sp);
}
}
}
out.push(pc);
out.into_iter()
})
.collect();
sort_far_first(&mut pieces);
if view == 0 {
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());
return ShatterPlan::Pieces(PiecePlan { pieces, front: front_t, back: Some(back_t) });
}
{
// Wireframes: piece outlines (front view: at rest; otherwise animated), plus forces.
let animated = view == 2 || view == 4;
let mut lines = Vec::new();
for pc in &pieces {
let n = pc.n as usize;
let pts: Vec<[f32; 2]> = if animated {
let fw = inv3(&pc.inv);
match fw {
Some(m) => pc.poly[..n]
.iter()
.map(|v| {
let (a, bb) = ((v[0] - pc.c0[0]) as f64, (v[1] - pc.c0[1]) as f64);
let w = m[2][0] * a + m[2][1] * bb + m[2][2];
[((m[0][0] * a + m[0][1] * bb + m[0][2]) / w) as f32, ((m[1][0] * a + m[1][1] * bb + m[1][2]) / w) as f32]
})
.collect(),
None => continue,
}
} else {
pc.poly[..n].to_vec()
};
for e in 0..n {
let (a, c) = (pts[e], pts[(e + 1) % n]);
lines.push(Sprite::new(a[0], a[1], 0.5, [0.75, 0.85, 1.0, 1.0], Shape::Line { dx: c[0] - a[0], dy: c[1] - a[1] }));
}
}
if view >= 3 {
for (fp, _, fr, _) in forces {
let (fx, fy) = b.to_px(fp);
let rad = fr * lw * s;
for k in 0..64 {
let (a0, a1) = (k as f64 / 64.0 * std::f64::consts::TAU, (k + 1) as f64 / 64.0 * std::f64::consts::TAU);
let (x0, y0) = (fx + rad * a0.cos(), fy + rad * a0.sin());
let (x1, y1) = (fx + rad * a1.cos(), fy + rad * a1.sin());
lines.push(Sprite::new(x0 as f32, y0 as f32, 0.5, [1.0, 0.3, 0.3, 1.0], Shape::Line { dx: (x1 - x0) as f32, dy: (y1 - y0) as f32 }));
}
}
}
ShatterPlan::Wire(SpritePlan { sprites: lines, tints: vec![], acc: Acc::Over, post: Post::Replace })
}
}
// ------------------------------------------------------------------ Caustics
/// Sample with repeat mode: 0 Once (transparent outside), 1 Tiled, 2 Reflected.
fn sample_repeat(img: &Image, x: f64, y: f64, mode: u32) -> Px {
let (w, hh) = (img.width as f64, img.height as f64);
match mode {
1 => img.sample_bilinear_clamped(x.rem_euclid(w), y.rem_euclid(hh)),
2 => {
let m = |v: f64, n: f64| {
let r = v.rem_euclid(2.0 * n);
if r > n { 2.0 * n - r } else { r }
};
img.sample_bilinear_clamped(m(x, w), m(y, hh))
}
_ => img.sample_bilinear(x, y),
}
}
/// 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 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;
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 lpos = pr.v2("lighting/lightPosition");
// 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 pl = Plane::luma(&img);
if smoothing > 0.05 { gauss_plane(&pl, smoothing * 0.5, smoothing * 0.5) } else { pl }
});
let lw = ctx.layer_size[0] * s;
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");
let comp_light = if light_type == 2 { ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.light) } else { None };
let (lc, li, lx, ly, lheight, point) = match (light_type, comp_light) {
(2, Some(l)) => {
let c = [l.color[0], l.color[1], l.color[2], 1.0];
// Comp space: z away from the viewer; the light above the water is at −z.
let (px, py) = (l.pos[0] * s + b.offset[0], l.pos[1] * s + b.offset[1]);
(c, li, px, py, (-l.pos[2] / ctx.layer_size[0].max(1.0)).max(0.01), l.kind == 1)
}
(2, None) => (lc, 0.0, lx, ly, lheight, false),
(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]
};
let lv_at = |x: f64, y: f64| {
if point {
norm3([(lx - x) / lw.max(1.0), (ly - y) / lw.max(1.0), lheight])
} else {
norm3([(lx - cx) / lw.max(1.0), (ly - cy) / lw.max(1.0), lheight])
}
};
let grad = |x: usize, y: usize| -> (f64, f64) {
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;
(gx, gy)
}
None => (0.0, 0.0),
}
};
// Displacement and its Jacobian (caustic focusing) per pixel.
let disp: Vec<(f64, f64)> = (0..w * hh)
.into_par_iter()
.map(|i| {
let (gx, gy) = grad(i % w, i / w);
(-gx * k, -gy * k)
})
.collect();
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let i = y * w + x;
let (dx, dy) = disp[i];
let d = |xx: usize, yy: usize| disp[yy.min(hh - 1) * w + xx.min(w - 1)];
let (dxr, _) = d(x + 1, y);
let (_, dyd) = d(x, y + 1);
let (dxl, _) = d(x.saturating_sub(1), y);
let (_, dyu) = d(x, y.saturating_sub(1));
let jac = (1.0 + (dxr - dxl) * 0.5) * (1.0 + (dyd - dyu) * 0.5);
let focus = ((1.0 / jac.abs().max(0.2)) as f32 - 1.0).clamp(-1.0, 4.0);
let sx = cx + (x as f64 + 0.5 + dx - cx) / scaling;
let sy = cy + (y as f64 + 0.5 + dy - cy) / scaling;
let p = sample_repeat(&bottom, sx, sy, repeat);
let (c, a) = unpremul(p);
let (gx, gy) = grad(x, y);
let n = {
let v = [-gx * wave_h * 20.0, -gy * wave_h * 20.0, 1.0];
let m = (v[0] * v[0] + v[1] * v[1] + 1.0).sqrt();
[(v[0] / m) as f32, (v[1] / m) as f32, (v[2] / m) as f32]
};
let lv = lv_at(x as f64 + 0.5, y as f64 + 0.5);
let nl = (n[0] * lv[0] + n[1] * lv[1] + n[2] * lv[2]).max(0.0);
let hv = {
let v = [lv[0], lv[1], lv[2] + 1.0];
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 spec = (n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]).max(0.0).powf(sharp) * specular * li;
let light = ambient + diffuse * li * nl + cstr * focus * li;
// The surface: its colour lit like the water, or the sky it reflects (sampled along
// the surface normal; Convergence pulls the reflection towards the pixel itself).
let surface: [f32; 3] = match &sky {
Some(sk) => {
let reach = (1.0 - convergence) * lw * 0.5;
let (rx, ry) = (x as f64 + 0.5 + n[0] as f64 * reach, y as f64 + 0.5 + n[1] as f64 * reach);
let (rx, ry) = (cx + (rx - cx) / sky_scaling, cy + (ry - cy) / sky_scaling);
let (sc, _) = unpremul(sample_repeat(sk, rx, ry, sky_repeat));
std::array::from_fn(|q| sc[q] * sky_int * 3.0 * surf[q])
}
None => std::array::from_fn(|q| surf[q] * (ambient + diffuse * li * nl)),
};
let cc: [f32; 3] = std::array::from_fn(|q| {
let base = c[q] * light * lc[q];
base * (1.0 - surf_op) + surface[q] * surf_op + spec * lc[q]
});
let a = (a + surf_op * (1.0 - a)).min(1.0);
*px = [cc[0] * a, cc[1] * a, cc[2] * a, a];
}
});
b
}
// ------------------------------------------------------------------ Wave World
/// Wave World's simulated surface: an `nx` × `ny` grid of heights `u` and velocities `v`.
#[derive(Clone, Debug)]
pub struct Waves {
pub nx: usize,
pub ny: usize,
pub u: Vec<f32>,
pub v: Vec<f32>,
}
struct Producer {
ring: bool,
pos: [f32; 2],
len: f32,
width: f32,
angle: f32,
amp: f32,
freq: f32,
phase: f32,
}
impl Producer {
/// Weight of a grid point (layer-fraction coordinates, x in widths, y in widths).
fn weight(&self, x: f32, y: f32) -> f32 {
let (dx, dy) = (x - self.pos[0], y - self.pos[1]);
let (sa, ca) = self.angle.sin_cos();
let (u, v) = (dx * ca + dy * sa, -dx * sa + dy * ca);
let d = if self.ring {
let (a, bb) = ((self.len * 0.5).max(1e-3), (self.width * 0.5).max(1e-3));
((u / a).powi(2) + (v / bb).powi(2)).sqrt()
} else {
let l = (self.len * 0.5).max(1e-3);
let wd = (self.width * 0.5).max(1e-3);
(u.abs() / l).max(v.abs() / wd)
};
(1.0 - d).clamp(0.0, 1.0).min(1.0)
}
}
static WAVE_CACHE: SimCache<Waves> = SimCache::new(4);
/// 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;
let res = if pr.b("simulation/gridResDownsamples") { ((res as f64) * b.scale).round().max(1.0) as usize } else { res };
let nx = res.max(2) + 1;
let ny = ((res as f64 * lh / lw.max(1.0)).round() as usize).max(2) + 1;
let speed = pr.f("simulation/waveSpeed").max(0.0) as f32;
let damping = pr.f("simulation/damping").max(0.0) as f32;
let reflect = pr.e("simulation/reflectEdges");
let preroll = pr.f("simulation/preRoll").max(0.0);
let prods: Vec<Producer> = [1, 2]
.iter()
.map(|k| {
let pos = pr.v2(&format!("producer{k}/producer{k}Position"));
Producer {
ring: pr.e(&format!("producer{k}/producer{k}Type")) == 0,
pos: [(pos[0] / lw.max(1.0)) as f32, (pos[1] / lw.max(1.0)) as f32],
len: pr.f(&format!("producer{k}/producer{k}Length")) as f32,
width: pr.f(&format!("producer{k}/producer{k}Width")) as f32,
angle: (pr.f(&format!("producer{k}/producer{k}Angle")) as f32).to_radians(),
amp: pr.f(&format!("producer{k}/producer{k}Amplitude")) as f32,
freq: pr.f(&format!("producer{k}/producer{k}Frequency")) as f32,
phase: (pr.f(&format!("producer{k}/producer{k}Phase")) as f32).to_radians(),
}
})
.collect();
let sps = crate::sim::SPS as f32;
// Wave speed in layer widths per second → cells per second; sub-steps keep it stable.
let cps = speed * (nx - 1) as f32;
let sub = ((cps * 2.0 / sps).ceil() as usize).clamp(1, 64);
let dt = 1.0 / (sps * sub as f32);
let cell = 1.0 / (nx - 1) as f32;
let weights: Vec<Vec<f32>> = prods.iter().map(|p| (0..nx * ny).map(|i| p.weight((i % nx) as f32 * cell, (i / nx) as f32 * cell)).collect()).collect();
// Ground: the ground layer's first frame, its luminance an elevation (× Steepness). Where
// it rises above Height the grid is dry (a wall the waves reflect from); shallow water
// slows the waves by Wave Strength.
let ground = pr.get("ground/ground").and_then(Value::as_layer).and_then(|id| {
let host = ctx.env.host?;
host.layer_at(id, ctx.env.comp_time - ctx.time, true)
});
let steep = pr.get("ground/steepness").map(Value::as_f64).unwrap_or(0.25) as f32;
let level = pr.get("ground/height").map(Value::as_f64).unwrap_or(0.25) as f32;
let wave_strength = pr.f("ground/waveStrength").clamp(0.0, 1.0) as f32;
// Water depth per grid point (None without ground: deep everywhere).
let depth: Option<Vec<f32>> = ground.as_ref().map(|lp| {
let (gw, gh) = (lp.size[0].max(1.0), lp.size[1].max(1.0));
(0..nx * ny)
.map(|i| {
let (fx, fy) = ((i % nx) as f64 / (nx - 1) as f64, (i / nx) as f64 / (ny - 1) as f64);
let (c, a) = unpremul(lp.buf.img.sample_bilinear_clamped(fx * gw * lp.buf.scale + lp.buf.offset[0], fy * gh * lp.buf.scale + lp.buf.offset[1]));
level - steep * effectcraft_color::luminance(c[0], c[1], c[2]) * a
})
.collect()
});
let speed_k: Vec<f32> = match &depth {
Some(d) => d.iter().map(|&v| if v <= 0.0 { 0.0 } else { 1.0 - wave_strength * (1.0 - (v / level.max(1e-3)).clamp(0.0, 1.0).sqrt()) }).collect(),
None => vec![1.0; nx * ny],
};
let mut key =
params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: if pr.b("simulation/gridResDownsamples") { b.scale } else { 1.0 } }, 4);
if let Some(d) = &depth {
key ^= d.iter().fold(0u64, |a, v| a.rotate_left(3) ^ v.to_bits() as u64);
}
let steps = crate::sim::steps_at(ctx.time + preroll);
let st = WAVE_CACHE.run(
key,
steps,
|| Waves { nx, ny, u: vec![0.0; nx * ny], v: vec![0.0; nx * ny] },
|w, step| {
for k in 0..sub {
let t = (step as f32 + k as f32 / sub as f32) / sps - preroll as f32;
let c2 = cps * cps;
let mut acc = vec![0.0f32; nx * ny];
for y in 0..ny {
for x in 0..nx {
let i = y * nx + x;
let g = |xx: usize, yy: usize| w.u[yy * nx + xx];
let l = g(x.saturating_sub(1), y) + g((x + 1).min(nx - 1), y) + g(x, y.saturating_sub(1)) + g(x, (y + 1).min(ny - 1)) - 4.0 * w.u[i];
acc[i] = c2 * speed_k[i] * speed_k[i] * l - damping * 4.0 * w.v[i];
}
}
for i in 0..nx * ny {
w.v[i] += acc[i] * dt;
w.u[i] += w.v[i] * dt;
}
// Producers drive the surface.
for (p, wt) in prods.iter().zip(&weights) {
if p.amp == 0.0 {
continue;
}
let target = p.amp * (std::f32::consts::TAU * p.freq * t + p.phase).sin();
for i in 0..nx * ny {
if wt[i] > 0.0 {
w.u[i] += (target - w.u[i]) * wt[i];
w.v[i] *= 1.0 - wt[i];
}
}
}
// Dry ground holds the surface flat.
for i in 0..nx * ny {
if speed_k[i] <= 0.0 {
w.u[i] = 0.0;
w.v[i] = 0.0;
}
}
// Edges: reflective ones mirror; the others absorb in a thin sponge layer.
let refl = |side: u32| reflect == 5 || reflect == side;
let sponge = 4usize.min(nx / 4).max(1);
for y in 0..ny {
for x in 0..nx {
let i = y * nx + x;
let mut f = 1.0f32;
if !refl(1) && x < sponge {
f = f.min(x as f32 / sponge as f32);
}
if !refl(3) && nx - 1 - x < sponge {
f = f.min((nx - 1 - x) as f32 / sponge as f32);
}
if !refl(2) && y < sponge {
f = f.min(y as f32 / sponge as f32);
}
if !refl(4) && ny - 1 - y < sponge {
f = f.min((ny - 1 - y) as f32 / sponge as f32);
}
if f < 1.0 {
let k = 0.85 + 0.15 * f;
w.u[i] *= k;
w.v[i] *= k;
}
}
}
}
},
);
let s = b.scale;
let (bw, bh) = (b.img.width, b.img.height);
let sample = |x: f64, y: f64| -> f32 {
let gx = (x / lw.max(1.0) * (st.nx - 1) as f64).clamp(0.0, (st.nx - 1) as f64);
let gy = (y / lw.max(1.0) * (st.nx - 1) as f64).clamp(0.0, (st.ny - 1) as f64);
let (x0, y0) = (gx.floor() as usize, gy.floor() as usize);
let (x1, y1) = ((x0 + 1).min(st.nx - 1), (y0 + 1).min(st.ny - 1));
let (tx, ty) = ((gx - x0 as f64) as f32, (gy - y0 as f64) as f32);
let g = |xx: usize, yy: usize| st.u[yy * st.nx + xx];
let a = g(x0, y0) + (g(x1, y0) - g(x0, y0)) * tx;
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;
let alpha = 1.0 - pr.f("heightMapControls/transparency") as f32;
let dry_transparent = pr.e("heightMapControls/renderDryAreasAs") == 1;
let dry_at = |x: f64, y: f64| -> bool {
let Some(d) = &depth else { return false };
let gx = (x / lw.max(1.0) * (st.nx - 1) as f64).round().clamp(0.0, (st.nx - 1) as f64) as usize;
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
};
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;
let v = (bright + contrast * sample(lx, ly)).clamp(0.0, 1.0).powf(1.0 / gamma);
let al = if dry_transparent && dry_at(lx, ly) { 0.0 } else { alpha };
*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.
let hr = pr.f("wireframeControls/horizontalRotation").to_radians();
let vr = pr.get("wireframeControls/verticalRotation").map(Value::as_f64).unwrap_or(53.0).to_radians();
let vs = pr.get("wireframeControls/verticalScale").map(Value::as_f64).unwrap_or(0.5);
let (sh, ch) = hr.sin_cos();
let (sv, cv) = vr.sin_cos();
let to = |x: usize, y: usize| -> [f32; 2] {
// Grid point relative to the layer centre (layer px), height in layer px.
let gx = x as f64 / (st.nx - 1) as f64 * lw - lw * 0.5;
let gy = y as f64 / (st.nx - 1) as f64 * lw - (st.ny - 1) as f64 / (st.nx - 1) as f64 * lw * 0.5;
let hgt = st.u[y * st.nx + x] as f64 * vs * lh * 0.5;
let (rx, ry) = (gx * ch - gy * sh, gx * sh + gy * ch);
let (px, py) = b.to_px([lw * 0.5 + rx, lh * 0.5 + ry * cv - hgt * sv]);
[px as f32, py as f32]
};
let mut lines = Vec::new();
for y in 0..st.ny {
for x in 0..st.nx {
let a = to(x, y);
if x + 1 < st.nx {
let c = to(x + 1, y);
lines.push(Sprite::new(a[0], a[1], 0.4, [0.4, 0.9, 0.4, 1.0], Shape::Line { dx: c[0] - a[0], dy: c[1] - a[1] }));
}
if y + 1 < st.ny {
let c = to(x, y + 1);
lines.push(Sprite::new(a[0], a[1], 0.4, [0.4, 0.9, 0.4, 1.0], Shape::Line { dx: c[0] - a[0], dy: c[1] - a[1] }));
}
}
}
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)
}
}
// ------------------------------------------------------------------ Foam
#[derive(Clone, Debug)]
struct FoamBubble {
p: [f32; 2],
v: [f32; 2],
age: f32,
size: f32,
/// Spin (radians) for Physical Orientation.
spin: f32,
id: u32,
}
#[derive(Clone, Debug, Default)]
struct FoamState {
b: Vec<FoamBubble>,
carry: f32,
next: u32,
}
static FOAM_CACHE: SimCache<FoamState> = SimCache::new(4);
/// Foam steps once per frame at 30 frames per second.
const FOAM_FPS: f64 = 30.0;
/// Foam's Bubble Texture options (the last uses Bubble Texture Layer).
const FOAM_TEXTURES: [&str; 6] = ["Default Bubble", "Spit", "Bubblegum", "Dishwater", "Milky", "User Defined"];
/// Age (in simulation steps) at which bubble `id` pops: its lifespan, shortened at random for
/// frail bubbles (`frailty` 0 = every bubble reaches `lifespan`, 1 = uniformly early).
fn foam_pop_age(id: u32, lifespan: f32, frailty: f32, seed: u32) -> f32 {
lifespan * (1.0 - frailty * (hs(id, 11, seed) * 0.5 + 0.5))
}
/// Foam: bubbles born in the producer rectangle grow to their size, drift with the wind,
/// turbulence and the Flow Map (bubbles run downhill on its luminance), repel and stick to each
/// 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, b: Buf) -> Buf {
foam_full_plan(ctx, &b).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 plan = foam_full_plan(ctx, b);
(plan.user.is_none() && plan.env.is_none() && plan.flow.is_none()).then_some(plan.sprites)
}
/// A bubble disc filled by Foam's User Defined texture (buffer px; `rot` radians).
#[derive(Clone, Copy, Debug)]
pub struct FoamDisc {
pub x: f64,
pub y: f64,
pub r: f32,
pub rot: f32,
pub fade: f32,
}
/// Foam's Environment Map reflection: the map fitted to the buffer, Reflection Convergence and
/// Strength, and the bubble discs reflecting it (`rot` and `fade` unused).
pub struct FoamEnv {
pub img: Image,
pub conv: f64,
pub strength: f32,
pub discs: Vec<FoamDisc>,
}
/// What Foam draws this frame over a transparent frame of the layer's size: the bubble sprites,
/// then the User Defined texture's discs, the Environment Map's reflections and (Draft + Flow
/// Map) the flow map under it all, each [`foam_disc`]-composited in bubble order.
pub struct FoamPlan {
pub sprites: crate::sim::SpritePlan,
pub user: Option<(crate::LayerPixels, Vec<FoamDisc>)>,
pub env: Option<FoamEnv>,
/// The flow map fitted to the buffer.
pub flow: Option<Image>,
}
impl FoamPlan {
pub fn finish(&self, b: Buf) -> Buf {
let mut out = splat(b.img.width, b.img.height, &self.sprites.sprites, self.sprites.acc);
// User Defined: the texture layer fills each bubble's disc.
if let Some((tex, discs)) = &self.user {
for d in discs {
let (rot, fade) = (d.rot, d.fade);
foam_disc(&mut out, d.x, d.y, d.r as f64, |u, v| {
// u, v in −1..1 across the bubble, rotated into the texture.
let (sr, cr) = rot.sin_cos();
let (tu, tv) = (u as f32 * cr + v as f32 * sr, -u as f32 * sr + v as f32 * cr);
let (x, y) = ((tu as f64 * 0.5 + 0.5) * tex.size[0], (tv as f64 * 0.5 + 0.5) * tex.size[1]);
let px = tex.buf.img.sample_bilinear(x * tex.buf.scale + tex.buf.offset[0], y * tex.buf.scale + tex.buf.offset[1]);
px.map(|c| c * fade)
});
}
}
// Environment Map: each bubble reflects the map along its sphere normal (Reflection
// Convergence pulls the reflection towards the bubble's own spot of the map).
if let Some(env) = &self.env {
let (conv, strength) = (env.conv, env.strength);
let (w, h) = (b.img.width as f64, b.img.height as f64);
for d in &env.discs {
let (bx, by) = (d.x, d.y);
foam_disc(&mut out, bx, by, d.r as f64, |u, v| {
// Sphere normal; the reflection looks across the whole map, or (convergence 1)
// just behind the bubble.
let (ex, ey) =
if conv >= 1.0 { (bx, by) } else { (bx * conv + (u * 0.5 + 0.5) * w * (1.0 - conv), by * conv + (v * 0.5 + 0.5) * h * (1.0 - conv)) };
let e = env.img.sample_bilinear(ex, ey);
let rim = ((u * u + v * v) as f32).min(1.0);
let k = strength * (0.3 + 0.7 * rim);
[e[0] * k, e[1] * k, e[2] * k, 0.0]
});
}
}
// Draft + Flow Map shows the flow map under the bubbles.
if let Some(fm) = &self.flow {
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 {
o[c] += f[c] * 0.5 * k;
}
});
}
Buf { img: out, ..b }
}
}
/// Foam's [`FoamPlan`] for layer geometry `b` (its size; its pixels are not read).
pub fn foam_full_plan(ctx: &EffectCtx, b: &Buf) -> FoamPlan {
let pr = ctx.params;
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let unit = lw * 0.01;
let prod = pr.v2("producer/producerPoint");
let (psx, psy) = (pr.f("producer/producerXSize") as f32 * lw, pr.f("producer/producerYSize") as f32 * lh);
let porient = (pr.f("producer/producerOrientation") as f32).to_radians();
let rate = pr.f("producer/productionRate").max(0.0) as f32;
let size = pr.f("bubbles/size").max(0.0) as f32;
let size_var = pr.f("bubbles/sizeVariance") as f32;
let lifespan = pr.f("bubbles/lifespan").max(1.0) as f32;
let growth = pr.f("bubbles/growthSpeed").max(0.0001) as f32;
// Strength: weaker bubbles tend to pop before their lifespan (at 20 or more every bubble
// lives the full lifespan); each bubble gets its own pop age.
let frailty = 1.0 - (pr.f("bubbles/strength") as f32 / 20.0).clamp(0.0, 1.0);
let init_speed = pr.f("physics/initialSpeed") as f32;
let init_dir = (pr.f("physics/initialDirection") as f32).to_radians();
let wind = pr.f("physics/windSpeed") as f32;
let wind_dir = (pr.f("physics/windDirection") as f32).to_radians();
let turb = pr.f("physics/turbulence") as f32;
let wobble = pr.f("physics/wobbleAmount").max(0.0) as f32;
let repulsion = pr.f("physics/repulsion") as f32;
let pop_velocity = pr.f("physics/popVelocity") as f32;
let viscosity = pr.f("physics/viscosity").clamp(0.0, 1.0) as f32;
let sticky = pr.f("physics/stickiness").clamp(0.0, 1.0) as f32;
let universe = pr.f("universeSize").max(0.01) as f32;
let seed = (pr.f("randomSeed") as u32).wrapping_mul(0x68e31da4) ^ ctx.seed;
let wvec = [wind_dir.sin() * wind * unit, -wind_dir.cos() * wind * unit];
// Flow Map: the map layer's first frame, its luminance a height field (Fits the layer or
// the whole universe).
let flow = pr.get("flowMap/flowMap").and_then(Value::as_layer).and_then(|id| {
let host = ctx.env.host?;
let me = pr.get(&crate::layer_source_id("flowMap/flowMap")).is_none_or(|v| v.as_enum() != 0);
host.layer_at(id, ctx.env.comp_time - ctx.time, me)
});
let steep = pr.f("flowMap/flowMapSteepness") as f32;
let fits_universe = pr.e("flowMap/flowMapFits") == 1;
let substeps = match pr.e("flowMap/simulationQuality") {
1 => 2,
2 => 4,
_ => 1,
};
let height = flow.as_ref().map(|lp| {
let img = &lp.buf.img;
let pl = Plane::from_image(img, |px| {
let (c, a) = unpremul(px);
effectcraft_color::luminance(c[0], c[1], c[2]) * a
});
(pl, lp.buf.scale, lp.buf.offset, lp.size)
});
let (ux0, uy0) = (-(universe - 1.0) * 0.5 * lw, -(universe - 1.0) * 0.5 * lh);
let (ux1, uy1) = (lw - ux0, lh - uy0);
// Height at a layer point (0..1).
let h_at = |x: f32, y: f32| -> f32 {
let Some((pl, s, off, size)) = &height else { return 0.0 };
let (fx, fy) = if fits_universe { ((x - ux0) / (ux1 - ux0), (y - uy0) / (uy1 - uy0)) } else { (x / lw, y / lh) };
pl.sample(fx as f64 * size[0] * s + off[0], fy as f64 * size[1] * s + off[1])
};
let mut key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 5);
if let Some((pl, ..)) = &height {
key ^= pl.data.iter().step_by(5).fold(0u64, |a, v| a.rotate_left(3) ^ v.to_bits() as u64);
}
let steps = if ctx.time <= 0.0 { 0 } else { (ctx.time * FOAM_FPS + 1e-6).floor() as u64 };
let rad = |q: &FoamBubble| q.size * unit * 3.0 * (q.age * growth).min(1.0);
let st = FOAM_CACHE.run(key, steps, FoamState::default, |st, step| {
let t = step as f32 / FOAM_FPS as f32;
// Births.
st.carry += rate;
let n = st.carry.floor() as u32;
st.carry -= n as f32;
for _ in 0..n {
if st.b.len() >= 3000 {
break;
}
let id = st.next;
st.next = st.next.wrapping_add(1);
let (ux, uy) = (hs(id, 1, seed) * 0.5 * psx, hs(id, 2, seed) * 0.5 * psy);
let (so, co) = porient.sin_cos();
let p = [prod[0] as f32 + ux * co - uy * so, prod[1] as f32 + ux * so + uy * co];
let v = [init_dir.sin() * init_speed * unit, -init_dir.cos() * init_speed * unit];
let sz = (size * (1.0 + size_var * hs(id, 3, seed))).max(0.01);
st.b.push(FoamBubble { p, v, age: 0.0, size: sz, spin: hs(id, 12, seed) * std::f32::consts::PI, id });
}
for _ in 0..substeps {
let k = 1.0 / substeps as f32;
// Forces: drift towards the wind, turbulence, viscosity, the flow map's slope.
for q in st.b.iter_mut() {
let n1 = value_noise(q.p[0] / (lw * 0.15), q.p[1] / (lw * 0.15), t * 0.5, seed) - 0.5;
let n2 = value_noise(q.p[0] / (lw * 0.15) + 19.0, q.p[1] / (lw * 0.15), t * 0.5, seed) - 0.5;
for c in 0..2 {
q.v[c] += (wvec[c] - q.v[c]) * 0.1 * k;
q.v[c] *= 1.0 - viscosity * 0.5 * k;
}
q.v[0] += n1 * turb * unit * k;
q.v[1] += n2 * turb * unit * k;
if height.is_some() && steep != 0.0 {
let e = unit.max(1.0);
let gx = (h_at(q.p[0] + e, q.p[1]) - h_at(q.p[0] - e, q.p[1])) / (2.0 * e);
let gy = (h_at(q.p[0], q.p[1] + e) - h_at(q.p[0], q.p[1] - e)) / (2.0 * e);
// Downhill: from light (high) to dark (low).
q.v[0] -= gx * steep * lw * 0.5 * k;
q.v[1] -= gy * steep * lw * 0.5 * k;
}
// Physical Orientation: bubbles roll with their sideways motion.
q.spin += q.v[0] / (rad(q).max(0.5)) * k;
}
// Repulsion between overlapping bubbles (grid hashed, deterministic order).
if repulsion > 0.0 && st.b.len() > 1 {
let maxr = st.b.iter().map(rad).fold(0.0f32, f32::max).max(1.0);
let cs = maxr * 2.0;
let mut grid: std::collections::HashMap<(i32, i32), Vec<usize>> = std::collections::HashMap::new();
for (i, q) in st.b.iter().enumerate() {
grid.entry(((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32)).or_default().push(i);
}
let mut push = vec![[0.0f32; 2]; st.b.len()];
for (i, q) in st.b.iter().enumerate() {
let (gx, gy) = ((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32);
for dy in -1..=1 {
for dx in -1..=1 {
let Some(list) = grid.get(&(gx + dx, gy + dy)) else { continue };
for &j in list {
if j <= i {
continue;
}
let o = &st.b[j];
let (ddx, ddy) = (o.p[0] - q.p[0], o.p[1] - q.p[1]);
let d = (ddx * ddx + ddy * ddy).sqrt().max(1e-4);
let overlap = rad(q) + rad(o) - d;
if overlap > 0.0 {
let f = overlap * 0.5 * repulsion.min(4.0) * (1.0 - sticky * 0.5) / d * k;
push[i][0] -= ddx * f;
push[i][1] -= ddy * f;
push[j][0] += ddx * f;
push[j][1] += ddy * f;
}
}
}
}
}
for (q, d) in st.b.iter_mut().zip(&push) {
q.p[0] += d[0].clamp(-maxr, maxr);
q.p[1] += d[1].clamp(-maxr, maxr);
}
}
for q in st.b.iter_mut() {
q.p[0] += q.v[0] * k;
q.p[1] += q.v[1] * k;
}
}
for q in st.b.iter_mut() {
q.age += 1.0;
}
// Pops: bubbles past their pop age burst, pushing their neighbours away.
let popped: Vec<([f32; 2], f32)> = st.b.iter().filter(|q| q.age >= foam_pop_age(q.id, lifespan, frailty, seed)).map(|q| (q.p, rad(q))).collect();
if pop_velocity != 0.0 {
for (pp, pr_) in &popped {
let reach = (pr_ * 3.0).max(unit);
for q in st.b.iter_mut() {
let (dx, dy) = (q.p[0] - pp[0], q.p[1] - pp[1]);
let d = (dx * dx + dy * dy).sqrt();
if d > 1e-4 && d < reach {
let f = pop_velocity * unit * (1.0 - d / reach) / d;
q.v[0] += dx * f;
q.v[1] += dy * f;
}
}
}
}
st.b.retain(|q| q.age < foam_pop_age(q.id, lifespan, frailty, seed) && q.p[0] > ux0 && q.p[0] < ux1 && q.p[1] > uy0 && q.p[1] < uy1);
});
let view = pr.e("view");
let zoom = pr.f("zoom").max(0.01) as f32;
let zoom_producer = pr.b("producer/zoomProducerPoint");
let texture = pr.e("rendering/bubbleTexture");
let orient = pr.e("rendering/bubbleOrientation");
let blend = pr.e("rendering/blendMode");
let s = b.scale as f32;
// Zoom centres on the layer, or on the producer with Zoom Producer Point.
let (cx, cy) = if zoom_producer { (prod[0] as f32, prod[1] as f32) } else { (lw * 0.5, lh * 0.5) };
let mut order: Vec<&FoamBubble> = st.b.iter().collect();
if blend == 2 {
// Solid New on Top: oldest first.
order.sort_by(|a, c| c.age.total_cmp(&a.age).then(a.id.cmp(&c.id)));
} else if blend == 1 {
order.sort_by(|a, c| a.age.total_cmp(&c.age).then(a.id.cmp(&c.id)));
}
// Where a bubble is drawn: buffer centre, radius, rotation (radians).
let place = |q: &FoamBubble| {
let wob = if wobble > 0.0 { 1.0 + wobble * 0.15 * ((q.age * 0.35) + hs(q.id, 13, seed) * 6.0).sin() } else { 1.0 };
let r = rad(q) * wob * zoom * s;
let (bx, by) = b.to_px([(cx + (q.p[0] - cx) * zoom) as f64, (cy + (q.p[1] - cy) * zoom) as f64]);
let rot = match orient {
1 => q.spin,
2 => q.v[1].atan2(q.v[0]),
_ => 0.0,
};
(bx, by, r, rot)
};
let user_tex = (view == 2 && texture == 5).then(|| ctx.layer_param("rendering/bubbleTextureLayer", true)).flatten();
let sprites: Vec<Sprite> = order
.iter()
.filter(|_| user_tex.is_none())
.map(|q| {
let (bx, by, r, rot) = place(q);
let fade = ((foam_pop_age(q.id, lifespan, frailty, seed) - q.age) / 5.0).clamp(0.0, 1.0);
// Draft and Draft + Flow Map show the wireframe-style bubbles.
let mut sp = if view != 2 {
Sprite::new(bx as f32, by as f32, r, [0.55, 0.75, 1.0, fade], Shape::Bubble)
} else {
let (c, shape) = match texture {
1 => ([0.95, 0.95, 0.92], Shape::Faded),
2 => ([1.0, 0.6, 0.8], Shape::Bubble),
3 => ([0.8, 0.85, 0.75], Shape::Bubble),
4 => ([0.97, 0.97, 0.95], Shape::Sphere),
_ => ([1.0, 1.0, 1.0], Shape::Bubble),
};
Sprite::new(bx as f32, by as f32, r, [c[0], c[1], c[2], fade], shape)
};
sp.rot = rot;
sp
})
.collect();
let acc = if blend == 0 && view == 2 { Acc::Add } else { Acc::Over };
let disc = |q: &&FoamBubble| {
let (x, y, r, rot) = place(q);
let fade = ((foam_pop_age(q.id, lifespan, frailty, seed) - q.age) / 5.0).clamp(0.0, 1.0);
FoamDisc { x, y, r, rot, fade }
};
let user = user_tex.map(|tex| (tex, order.iter().map(disc).collect()));
let strength = pr.f("rendering/reflectionStrength").clamp(0.0, 1.0) as f32;
let env = if view == 2
&& strength > 0.0
&& let Some(env) = ctx.layer_param("rendering/environmentMap", true)
{
Some(FoamEnv {
img: crate::util::fit_layer(ctx, b, &env, true),
conv: pr.f("rendering/reflectionConvergence").clamp(0.0, 1.0),
strength,
discs: order.iter().map(disc).collect(),
})
} else {
None
};
let flow = if view == 1
&& let Some(lp) = &flow
{
Some(crate::util::fit_layer(ctx, b, lp, true))
} else {
None
};
FoamPlan { sprites: crate::sim::SpritePlan { sprites, tints: vec![], acc, post: crate::sim::Post::Replace }, user, env, flow }
}
/// Add `f(u, v)` (premultiplied; u, v ∈ −1..1 across the disc) over the disc of radius `r` at
/// (`x`, `y`) with anti-aliased edges ("over" for opaque texels, additive for alpha 0).
fn foam_disc(out: &mut Image, x: f64, y: f64, r: f64, f: impl Fn(f64, f64) -> Px) {
if r <= 0.0 {
return;
}
let (x0, x1) = ((x - r - 1.0).floor().max(0.0) as i64, (x + r + 1.0).ceil().min(out.width as f64) as i64);
let (y0, y1) = ((y - r - 1.0).floor().max(0.0) as i64, (y + r + 1.0).ceil().min(out.height as f64) as i64);
for py in y0..y1 {
for px in x0..x1 {
let (u, v) = ((px as f64 + 0.5 - x) / r, (py as f64 + 0.5 - y) / r);
let d = (u * u + v * v).sqrt();
let cov = ((1.0 - d) * r + 0.5).clamp(0.0, 1.0) as f32;
if cov <= 0.0 {
continue;
}
let s = f(u.clamp(-1.0, 1.0), v.clamp(-1.0, 1.0)).map(|c| c * cov);
let o = out.get(px, py);
let k = 1.0 - s[3];
out.set(px as u32, py as u32, [s[0] + o[0] * k, s[1] + o[1] * k, s[2] + o[2] * k, (s[3] + o[3] * k).min(1.0)]);
}
}
}
// ------------------------------------------------------------------ specs
fn producer_params(k: u32, amp: f64, pos: (f64, f64)) -> Vec<crate::ParamSpec> {
let ids: [&'static str; 8] = match k {
1 => [
"producer1/producer1Type",
"producer1/producer1Position",
"producer1/producer1Length",
"producer1/producer1Width",
"producer1/producer1Angle",
"producer1/producer1Amplitude",
"producer1/producer1Frequency",
"producer1/producer1Phase",
],
_ => [
"producer2/producer2Type",
"producer2/producer2Position",
"producer2/producer2Length",
"producer2/producer2Width",
"producer2/producer2Angle",
"producer2/producer2Amplitude",
"producer2/producer2Frequency",
"producer2/producer2Phase",
],
};
vec![
p(ids[0], "Type", Value::Enum(0), popup(&["Ring", "Line"])),
p(ids[1], "Position", pt(pos.0, pos.1), ParamUi::Point),
p(ids[2], "Height/Length", num(0.1), slider(0.0, 2.0, 0.0, 1.0, 3)),
p(ids[3], "Width", num(0.1), slider(0.0, 2.0, 0.0, 1.0, 3)),
p(ids[4], "Angle", num(0.0), ParamUi::Angle),
p(ids[5], "Amplitude", num(amp), slider(-5.0, 5.0, -1.0, 1.0, 3)),
p(ids[6], "Frequency", num(1.0), slider(0.0, 30.0, 0.0, 5.0, 3)),
p(ids[7], "Phase", num(0.0), ParamUi::Angle),
]
}
fn card_dance_params() -> Vec<crate::ParamSpec> {
let mut v = vec![
p("rowsColumns", "Rows & Columns", Value::Enum(0), popup(&["Independent", "Columns Follows Rows"])),
p("rows", "Rows", num(10.0), slider(1.0, 1000.0, 1.0, 100.0, 0)),
p("columns", "Columns", num(10.0), slider(1.0, 1000.0, 1.0, 100.0, 0)),
p("backLayer", "Back Layer", Value::Layer(None), ParamUi::Layer),
p("gradientLayer1", "Gradient Layer 1", Value::Layer(None), ParamUi::Layer),
p("gradientLayer2", "Gradient Layer 2", Value::Layer(None), ParamUi::Layer),
];
let names: [[&'static str; 4]; 8] = [
["xPosition/xPosSource", "xPosition/xPosMultiplier", "xPosition/xPosOffset", "X Position"],
["yPosition/yPosSource", "yPosition/yPosMultiplier", "yPosition/yPosOffset", "Y Position"],
["zPosition/zPosSource", "zPosition/zPosMultiplier", "zPosition/zPosOffset", "Z Position"],
["xRotation/xRotSource", "xRotation/xRotMultiplier", "xRotation/xRotOffset", "X Rotation"],
["yRotation/yRotSource", "yRotation/yRotMultiplier", "yRotation/yRotOffset", "Y Rotation"],
["zRotation/zRotSource", "zRotation/zRotMultiplier", "zRotation/zRotOffset", "Z Rotation"],
["xScale/xScaleSource", "xScale/xScaleMultiplier", "xScale/xScaleOffset", "X Scale"],
["yScale/yScaleSource", "yScale/yScaleMultiplier", "yScale/yScaleOffset", "Y Scale"],
];
debug_assert_eq!(names.len(), CD_PROPS.len());
for (i, n) in names.iter().enumerate() {
let rot = (3..6).contains(&i);
let scale = i >= 6;
let (lo, hi) = if rot { (-3600.0, 3600.0) } else { (-100.0, 100.0) };
v.push(p(n[0], "Source", Value::Enum(0), popup(CD_SOURCES)));
v.push(p(n[1], "Multiplier", num(0.0), slider(lo, hi, if rot { -360.0 } else { -10.0 }, if rot { 360.0 } else { 10.0 }, 2)));
v.push(p(n[2], "Offset", num(if scale { 1.0 } else { 0.0 }), slider(lo, hi, if rot { -360.0 } else { -10.0 }, if rot { 360.0 } else { 10.0 }, 2)));
}
v.extend(crate::card3d::camera_params(2.0));
v.extend(crate::card3d::lighting_params(1.0, 0.25));
v.extend(crate::card3d::material_params(0.75));
v
}
pub fn specs() -> Vec<EffectSpec> {
vec![
spec(
"ec.sim.ccballaction",
"CC Ball Action",
vec![
p("scatter", "Scatter", num(0.0), slider(0.0, 2000.0, 0.0, 300.0, 1)),
p("rotationAxis", "Rotation Axis", Value::Enum(0), popup(&["X Axis", "Y Axis", "Z Axis", "XY Axis", "XZ Axis", "YZ Axis", "XYZ Axis"])),
p("rotation", "Rotation", num(0.0), ParamUi::Angle),
p("twistProperty", "Twist Property", Value::Enum(0), popup(&["X Axis", "Y Axis", "Radius", "Brightness"])),
p("twistAngle", "Twist Angle", num(0.0), ParamUi::Angle),
p("gridSpacing", "Grid Spacing", num(4.0), slider(1.0, 200.0, 1.0, 50.0, 0)),
p("ballSize", "Ball Size", num(40.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
p("instabilityState", "Instability State", num(0.0), ParamUi::Angle),
],
ball_action,
),
spec(
"ec.sim.ccpixelpolly",
"CC Pixel Polly",
vec![
p("force", "Force", num(100.0), slider(-1000.0, 1000.0, -300.0, 300.0, 1)),
p("gravity", "Gravity", num(1.0), slider(-20.0, 20.0, -5.0, 5.0, 2)),
p("spinning", "Spinning", num(0.0), ParamUi::Angle),
p("forceCenter", "Force Center", pt(0.5, 0.5), ParamUi::Point),
p("directionRandomness", "Direction Randomness", num(10.0), pct()),
p("speedRandomness", "Speed Randomness", num(10.0), pct()),
p("gridSpacing", "Grid Spacing", num(10.0), slider(1.0, 500.0, 2.0, 100.0, 0)),
p("object", "Object", Value::Enum(0), popup(&["Polygon", "Textured Polygon", "Square", "Textured Square"])),
p("enableDepthSort", "Enable Depth Sort", Value::Bool(true), ParamUi::Checkbox),
p("startTime", "Start Time (sec)", num(0.0), slider(-1000.0, 1000.0, 0.0, 10.0, 2)),
],
pixel_polly,
),
spec(
"ec.sim.ccscatterize",
"CC Scatterize",
vec![
p("scatter", "Scatter", num(0.0), slider(0.0, 1000.0, 0.0, 200.0, 1)),
p("rightTwist", "Right Twist", num(0.0), ParamUi::Angle),
p("leftTwist", "Left Twist", num(0.0), ParamUi::Angle),
p("transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Add"])),
],
scatterize,
),
spec("ec.sim.carddance", "Card Dance", card_dance_params(), card_dance),
spec(
"ec.sim.caustics",
"Caustics",
vec![
p("bottom/bottom", "Bottom", Value::Layer(None), ParamUi::Layer),
p("bottom/scaling", "Scaling", num(1.0), slider(0.01, 10.0, 0.1, 3.0, 3)),
p("bottom/repeatMode", "Repeat Mode", Value::Enum(2), popup(&["Once", "Tiled", "Reflected"])),
p("bottom/bottomSizeDiffers", "If Layer Size Differs", Value::Enum(1), popup(&["Center", "Stretch to Fit"])),
p("bottom/blur", "Blur", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
p("water/waterSurface", "Water Surface", Value::Layer(None), ParamUi::Layer),
p("water/waveHeight", "Wave Height", num(0.2), slider(-1.0, 1.0, -1.0, 1.0, 3)),
p("water/smoothing", "Smoothing", num(5.0), slider(0.0, 100.0, 0.0, 50.0, 1)),
p("water/waterDepth", "Water Depth", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("water/refractiveIndex", "Refractive Index", num(1.2), slider(1.0, 3.0, 1.0, 2.0, 3)),
p("water/surfaceColor", "Surface Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("water/surfaceOpacity", "Surface Opacity", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("water/causticsStrength", "Caustics Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("sky/sky", "Sky", Value::Layer(None), ParamUi::Layer),
p("sky/scaling", "Scaling", num(1.0), slider(0.01, 10.0, 0.1, 3.0, 3)),
p("sky/repeatMode", "Repeat Mode", Value::Enum(2), popup(&["Once", "Tiled", "Reflected"])),
p("sky/skySizeDiffers", "If Layer Size Differs", Value::Enum(1), popup(&["Center", "Stretch to Fit"])),
p("sky/intensity", "Intensity", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("sky/convergence", "Convergence", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("lighting/lightType", "Light Type", Value::Enum(0), popup(&crate::card3d::LIGHT_TYPES)),
p("lighting/lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)),
p("lighting/lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("lighting/lightPosition", "Light Position", pt(0.0, 0.0), ParamUi::Point),
p("lighting/lightHeight", "Light Height", num(1.0), slider(0.0, 10.0, 0.0, 4.0, 2)),
p("lighting/ambientLight", "Ambient Light", num(0.35), slider(0.0, 2.0, 0.0, 1.0, 2)),
p("material/diffuse", "Diffuse Reflection", num(0.75), slider(0.0, 2.0, 0.0, 1.0, 2)),
p("material/specular", "Specular Reflection", num(0.2), slider(0.0, 2.0, 0.0, 1.0, 2)),
p("material/highlightSharpness", "Highlight Sharpness", num(15.0), slider(1.0, 100.0, 1.0, 100.0, 1)),
],
caustics,
),
spec(
"ec.sim.waveworld",
"Wave World",
{
let mut v = vec![
p("view", "View", Value::Enum(0), popup(&["Wireframe Preview", "Height Map"])),
p("wireframeControls/horizontalRotation", "Horizontal Rotation", num(0.0), ParamUi::Angle),
p("wireframeControls/verticalRotation", "Vertical Rotation", num(53.0), slider(0.0, 90.0, 0.0, 90.0, 1)),
p("wireframeControls/verticalScale", "Vertical Scale", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)),
p("heightMapControls/brightness", "Brightness", num(0.5), slider(-1.0, 2.0, 0.0, 1.0, 3)),
p("heightMapControls/contrast", "Contrast", num(0.75), slider(0.0, 4.0, 0.0, 2.0, 3)),
p("heightMapControls/gamma", "Gamma Adjustment", num(1.0), slider(0.01, 10.0, 0.2, 5.0, 3)),
p("heightMapControls/renderDryAreasAs", "Render Dry Areas As", Value::Enum(0), popup(&["Solid", "Transparent"])),
p("heightMapControls/transparency", "Transparency", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("simulation/gridResolution", "Grid Resolution", num(60.0), slider(1.0, 400.0, 1.0, 200.0, 0)),
p("simulation/gridResDownsamples", "Grid Res Downsamples", Value::Bool(false), ParamUi::Checkbox),
p("simulation/waveSpeed", "Wave Speed", num(0.2), slider(0.0, 5.0, 0.0, 1.0, 3)),
p("simulation/damping", "Damping", num(0.05), slider(0.0, 5.0, 0.0, 1.0, 3)),
p("simulation/reflectEdges", "Reflect Edges", Value::Enum(0), popup(&["None", "Left", "Top", "Right", "Bottom", "All"])),
p("simulation/preRoll", "Pre-roll (seconds)", num(0.0), slider(0.0, 30.0, 0.0, 10.0, 2)),
p("ground/ground", "Ground", Value::Layer(None), ParamUi::Layer),
p("ground/steepness", "Steepness", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 3)),
p("ground/height", "Height", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 3)),
p("ground/waveStrength", "Wave Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)),
];
v.extend(producer_params(1, 0.5, (0.5, 0.5)));
v.extend(producer_params(2, 0.0, (0.25, 0.25)));
v
},
wave_world,
),
spec(
"ec.sim.shatter",
"Shatter",
vec![
p(
"view",
"View",
Value::Enum(1),
popup(&["Rendered", "Wireframe Front View", "Wireframe", "Wireframe Front View + Forces", "Wireframe + Forces"]),
),
p("render", "Render", Value::Enum(0), popup(&["All", "Layer", "Pieces"])),
p("shape/pattern", "Pattern", Value::Enum(0), popup(SHATTER_PATTERNS)),
p("shape/repetitions", "Repetitions", num(10.0), slider(1.0, 500.0, 1.0, 100.0, 2)),
p("shape/direction", "Direction", num(0.0), ParamUi::Angle),
p("shape/origin", "Origin", pt(0.5, 0.5), ParamUi::Point),
p("shape/customShatterMap", "Custom Shatter Map", Value::Layer(None), ParamUi::Layer),
p("shape/whiteTilesFixed", "White Tiles Fixed", Value::Bool(false), ParamUi::Checkbox),
p("shape/extrusionDepth", "Extrusion Depth", num(0.05), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("force1/force1Position", "Position", pt(0.5, 0.5), ParamUi::Point),
p("force1/force1Depth", "Depth", num(0.1), slider(-1.0, 1.0, -1.0, 1.0, 3)),
p("force1/force1Radius", "Radius", num(0.4), slider(0.0, 4.0, 0.0, 2.0, 3)),
p("force1/force1Strength", "Strength", num(5.0), slider(-20.0, 20.0, -10.0, 10.0, 2)),
p("force2/force2Position", "Position", pt(0.25, 0.25), ParamUi::Point),
p("force2/force2Depth", "Depth", num(0.1), slider(-1.0, 1.0, -1.0, 1.0, 3)),
p("force2/force2Radius", "Radius", num(0.0), slider(0.0, 4.0, 0.0, 2.0, 3)),
p("force2/force2Strength", "Strength", num(5.0), slider(-20.0, 20.0, -10.0, 10.0, 2)),
p("physics/rotationSpeed", "Rotation Speed", num(0.2), slider(0.0, 5.0, 0.0, 1.0, 3)),
p("physics/tumbleAxis", "Tumble Axis", Value::Enum(0), popup(&["Free", "None", "X", "Y", "Z", "XY", "XZ", "YZ"])),
p("physics/randomness", "Randomness", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("physics/viscosity", "Viscosity", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("physics/massVariance", "Mass Variance", num(30.0), pct()),
p("physics/gravity", "Gravity", num(3.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("physics/gravityDirection", "Gravity Direction", num(180.0), ParamUi::Angle),
p("physics/gravityInclination", "Gravity Inclination", num(0.0), slider(-90.0, 90.0, -90.0, 90.0, 1)),
p("gradient/shatterThreshold", "Shatter Threshold", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("gradient/gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer),
p("gradient/invertGradient", "Invert Gradient", Value::Bool(false), ParamUi::Checkbox),
p("textures/color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("textures/opacity", "Opacity", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("textures/frontMode", "Front Mode", Value::Enum(1), popup(&SHATTER_TEXTURE_MODES)),
p("textures/sideMode", "Side Mode", Value::Enum(0), popup(&SHATTER_TEXTURE_MODES)),
p("textures/backMode", "Back Mode", Value::Enum(1), popup(&SHATTER_TEXTURE_MODES)),
p("textures/frontLayer", "Front Layer", Value::Layer(None), ParamUi::Layer),
p("textures/sideLayer", "Side Layer", Value::Layer(None), ParamUi::Layer),
p("textures/backLayer", "Back Layer", Value::Layer(None), ParamUi::Layer),
]
.into_iter()
.chain(crate::card3d::camera_params(4.0))
.chain(crate::card3d::lighting_params(1.0, 0.25))
.chain(crate::card3d::material_params(0.75))
.chain([p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0))])
.collect(),
shatter,
),
spec(
"ec.sim.foam",
"Foam",
vec![
p("view", "View", Value::Enum(0), popup(&["Draft", "Draft + Flow Map", "Rendered"])),
p("producer/producerPoint", "Producer Point", pt(0.5, 0.5), ParamUi::Point),
p("producer/producerXSize", "Producer X Size", num(0.05), slider(0.0, 2.0, 0.0, 1.0, 3)),
p("producer/producerYSize", "Producer Y Size", num(0.05), slider(0.0, 2.0, 0.0, 1.0, 3)),
p("producer/producerOrientation", "Producer Orientation", num(0.0), ParamUi::Angle),
p("producer/zoomProducerPoint", "Zoom Producer Point", Value::Bool(false), ParamUi::Checkbox),
p("producer/productionRate", "Production Rate", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 3)),
p("bubbles/size", "Size", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)),
p("bubbles/sizeVariance", "Size Variance", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("bubbles/lifespan", "Lifespan", num(300.0), slider(1.0, 30_000.0, 1.0, 1000.0, 1)),
p("bubbles/growthSpeed", "Bubble Growth Speed", num(0.1), slider(0.0001, 10.0, 0.0001, 1.0, 3)),
p("bubbles/strength", "Strength", num(10.0), slider(0.0, 100.0, 0.0, 20.0, 2)),
p("physics/initialSpeed", "Initial Speed", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 3)),
p("physics/initialDirection", "Initial Direction", num(0.0), ParamUi::Angle),
p("physics/windSpeed", "Wind Speed", num(0.5), slider(-100.0, 100.0, -10.0, 10.0, 3)),
p("physics/windDirection", "Wind Direction", num(90.0), ParamUi::Angle),
p("physics/turbulence", "Turbulence", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)),
p("physics/wobbleAmount", "Wobble Amount", num(0.05), slider(0.0, 10.0, 0.0, 2.0, 3)),
p("physics/repulsion", "Repulsion", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 3)),
p("physics/popVelocity", "Pop Velocity", num(1.0), slider(-100.0, 100.0, -10.0, 10.0, 3)),
p("physics/viscosity", "Viscosity", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("physics/stickiness", "Stickiness", num(0.75), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("zoom", "Zoom", num(1.0), slider(0.01, 50.0, 0.1, 5.0, 3)),
p("universeSize", "Universe Size", num(1.0), slider(0.01, 10.0, 0.5, 3.0, 3)),
p("rendering/blendMode", "Blend Mode", Value::Enum(0), popup(&["Transparent", "Solid Old on Top", "Solid New on Top"])),
p("rendering/bubbleTexture", "Bubble Texture", Value::Enum(0), popup(&FOAM_TEXTURES)),
p("rendering/bubbleTextureLayer", "Bubble Texture Layer", Value::Layer(None), ParamUi::Layer),
p("rendering/bubbleOrientation", "Bubble Orientation", Value::Enum(0), popup(&["Fixed", "Physical Orientation", "Bubble Velocity"])),
p("rendering/environmentMap", "Environment Map", Value::Layer(None), ParamUi::Layer),
p("rendering/reflectionStrength", "Reflection Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("rendering/reflectionConvergence", "Reflection Convergence", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("flowMap/flowMap", "Flow Map", Value::Layer(None), ParamUi::Layer),
p("flowMap/flowMapSteepness", "Flow Map Steepness", num(0.05), slider(-100.0, 100.0, -1.0, 1.0, 3)),
p("flowMap/flowMapFits", "Flow Map Fits", Value::Enum(0), popup(&["Background", "Universe"])),
p("flowMap/simulationQuality", "Simulation Quality", Value::Enum(0), popup(&["Normal", "High", "Intense"])),
p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)),
],
foam,
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, run_fx};
fn ramp(w: u32, hh: u32) -> Image {
let mut img = Image::new(w, hh);
for y in 0..hh {
for x in 0..w {
img.set(x, y, [x as f32 / w as f32, y as f32 / hh as f32, 0.4, 1.0]);
}
}
img
}
fn run(id: &str, vals: &[(&str, Value)], img: Image, t: f64) -> Image {
run_fx(id, vals, img, t, EffectEnv::default()).img
}
fn max_diff(a: &Image, b: &Image) -> f32 {
a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max)
}
fn sum_diff(a: &Image, b: &Image) -> f32 {
a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).sum::<f32>()).sum()
}
#[test]
fn piece_at_rest_is_identity() {
let src = ramp(16, 16);
let cam = Cam { c: [8.0, 8.0], d: 64.0 };
let id = rot_axis([1.0, 0.0, 0.0], 0.0);
let pc = Piece::new(&[[0.0, 0.0], [16.0, 0.0], [16.0, 16.0], [0.0, 16.0]], [8.0, 8.0], &id, [8.0, 8.0, 0.0], cam).unwrap();
let mut out = Image::new(16, 16);
draw_pieces(&mut out, &[pc], &src, None);
assert!(max_diff(&out, &src) < 1e-4);
}
#[test]
fn ball_action_draws_balls() {
let v = [("ballSize", num(80.0))];
let a = run("ec.sim.ccballaction", &v, ramp(32, 32), 0.0);
assert_eq!(a, run("ec.sim.ccballaction", &v, ramp(32, 32), 0.0));
assert!(a.data.iter().any(|p| p[3] > 0.9) && a.data.iter().any(|p| p[3] == 0.0), "balls with gaps");
let none = run("ec.sim.ccballaction", &[("ballSize", num(0.0))], ramp(32, 32), 0.0);
assert!(none.data.iter().all(|p| p[3] == 0.0));
let sc = run("ec.sim.ccballaction", &[("ballSize", num(80.0)), ("scatter", num(20.0))], ramp(32, 32), 0.0);
assert!(sum_diff(&a, &sc) > 1.0);
}
#[test]
fn pixel_polly_breaks_after_start() {
let v = [("forceCenter", pt(16.0, 16.0)), ("gridSpacing", num(4.0))];
let before = run("ec.sim.ccpixelpolly", &v, ramp(32, 32), 0.0);
assert_eq!(before, ramp(32, 32), "intact before the start time");
let a = run("ec.sim.ccpixelpolly", &v, ramp(32, 32), 0.5);
assert_eq!(a, run("ec.sim.ccpixelpolly", &v, ramp(32, 32), 0.5));
assert!(sum_diff(&a, &ramp(32, 32)) > 10.0);
let tex = run("ec.sim.ccpixelpolly", &[("forceCenter", pt(16.0, 16.0)), ("gridSpacing", num(4.0)), ("object", Value::Enum(3))], ramp(32, 32), 0.5);
assert!(sum_diff(&a, &tex) > 0.1);
}
#[test]
fn scatterize_identity_and_scatter() {
assert_eq!(run("ec.sim.ccscatterize", &[], ramp(24, 24), 0.0), ramp(24, 24));
let a = run("ec.sim.ccscatterize", &[("scatter", num(5.0))], ramp(24, 24), 0.0);
assert_eq!(a, run("ec.sim.ccscatterize", &[("scatter", num(5.0))], ramp(24, 24), 0.0));
assert!(sum_diff(&a, &ramp(24, 24)) > 1.0);
let tw = run("ec.sim.ccscatterize", &[("leftTwist", num(80.0)), ("rightTwist", num(80.0))], ramp(24, 24), 0.0);
assert!(sum_diff(&tw, &ramp(24, 24)) > 1.0);
}
#[test]
fn card_dance_identity_and_rotation() {
let src = ramp(30, 20);
let a = run("ec.sim.carddance", &[], src.clone(), 0.0);
assert!(max_diff(&a, &src) < 0.02, "flat cards reproduce the layer: {}", max_diff(&a, &src));
let r = run("ec.sim.carddance", &[("yRotation/yRotSource", Value::Enum(1)), ("yRotation/yRotMultiplier", num(90.0))], src.clone(), 0.0);
assert_eq!(r, run("ec.sim.carddance", &[("yRotation/yRotSource", Value::Enum(1)), ("yRotation/yRotMultiplier", num(90.0))], src.clone(), 0.0));
assert!(sum_diff(&r, &src) > 5.0);
let z = run("ec.sim.carddance", &[("zPosition/zPosOffset", num(-1.0))], src.clone(), 0.0);
assert!(sum_diff(&z, &src) > 5.0, "cards pulled towards the camera grow");
}
#[test]
fn shatter_at_rest_then_breaks() {
let v = [("view", Value::Enum(0)), ("force1/force1Position", pt(16.0, 16.0)), ("shape/origin", pt(16.0, 16.0))];
let src = ramp(32, 32);
let t0 = run("ec.sim.shatter", &v, src.clone(), 0.0);
assert!(max_diff(&t0, &src) < 0.03, "{}", max_diff(&t0, &src));
let t1 = run("ec.sim.shatter", &v, src.clone(), 0.5);
assert_eq!(t1, run("ec.sim.shatter", &v, src.clone(), 0.5));
assert!(sum_diff(&t1, &src) > 10.0);
for pat in 0..SHATTER_PATTERNS.len() as u32 {
let vv =
[("view", Value::Enum(0)), ("shape/pattern", Value::Enum(pat)), ("force1/force1Position", pt(16.0, 16.0)), ("shape/origin", pt(16.0, 16.0))];
let r = run("ec.sim.shatter", &vv, src.clone(), 0.0);
assert!(max_diff(&r, &src) < 0.05, "pattern {pat} tiles the layer: {}", max_diff(&r, &src));
}
let wire = run("ec.sim.shatter", &[("shape/repetitions", num(3.0))], src.clone(), 0.0);
assert!(wire.data.iter().any(|p| p[3] > 0.5) && wire.data.iter().any(|p| p[3] == 0.0));
let layer_only =
run("ec.sim.shatter", &[("view", Value::Enum(0)), ("render", Value::Enum(1)), ("force1/force1Position", pt(16.0, 16.0))], src.clone(), 0.5);
assert!(layer_only.get(16, 16)[3] == 0.0, "broken centre removed");
}
#[test]
fn caustics_flat_water() {
let src = ramp(24, 24);
let a = run(
"ec.sim.caustics",
&[
("water/surfaceOpacity", num(0.0)),
("lighting/ambientLight", num(0.0)),
("material/diffuse", num(1.0)),
("lighting/lightHeight", num(1000.0)),
("material/specular", num(0.0)),
],
src.clone(),
0.0,
);
assert!(max_diff(&a, &src) < 0.01, "flat water, light overhead = bottom: {}", max_diff(&a, &src));
let b = run("ec.sim.caustics", &[], src.clone(), 0.0);
assert_eq!(b, run("ec.sim.caustics", &[], src.clone(), 0.0));
assert!(sum_diff(&b, &src) > 0.1);
}
#[test]
fn caustics_sky_and_light_types() {
let src = Image::filled(32, 32, [0.5, 0.5, 0.5, 1.0]);
let env = EffectEnv { host: Some(&FoamHost), ..Default::default() };
let run_h = |vals: &[(&str, Value)]| run_fx("ec.sim.caustics", vals, src.clone(), 0.0, env).img;
// A mirror-like surface shows the (green) sky.
let mirror = [("water/surfaceOpacity", num(1.0)), ("sky/sky", Value::Layer(Some(2))), ("sky/intensity", num(1.0))];
let p = run_h(&mirror).get(16, 16);
assert!(p[1] > p[0] + 0.3 && p[1] > p[2] + 0.3, "{p:?}");
// Point Source lights pixels by their own direction to the light: the image is no
// longer uniform; Distant Source lights the flat water evenly.
let lit = |t: u32| {
run_h(&[
("lighting/lightType", Value::Enum(t)),
("lighting/lightPosition", pt(0.0, 0.0)),
("lighting/lightHeight", num(0.2)),
("water/surfaceOpacity", num(0.0)),
])
};
let (distant, point) = (lit(0), lit(1));
assert!((distant.get(2, 2)[0] - distant.get(30, 30)[0]).abs() < 1e-5);
assert!(point.get(2, 2)[0] > point.get(30, 30)[0] + 0.02, "{:?} {:?}", point.get(2, 2), point.get(30, 30));
// First Comp Light without a comp light: ambient only.
let none = run_h(&[("lighting/lightType", Value::Enum(2)), ("water/surfaceOpacity", num(0.0))]);
assert!(none.get(16, 16)[0] < distant.get(16, 16)[0]);
}
#[test]
fn wave_world_wireframe_controls_ground_and_dry_areas() {
let base = [("producer1/producer1Position", pt(8.0, 16.0)), ("simulation/gridResolution", num(24.0))];
let wire = |extra: &[(&str, Value)]| run("ec.sim.waveworld", &[base.as_slice(), extra].concat(), Image::new(32, 32), 0.5);
assert_ne!(wire(&[]), wire(&[("wireframeControls/horizontalRotation", num(40.0))]));
assert_ne!(wire(&[]), wire(&[("wireframeControls/verticalScale", num(3.0))]));
// Ground (left dark, right light): the light side rises above the water and is dry.
let env = EffectEnv { host: Some(&FoamHost), ..Default::default() };
let hm = |extra: &[(&str, Value)]| {
let mut v: Vec<(&str, Value)> = base.to_vec();
v.extend([("view", Value::Enum(1)), ("ground/ground", Value::Layer(Some(1))), ("ground/steepness", num(0.5)), ("ground/height", num(0.25))]);
v.extend_from_slice(extra);
run_fx("ec.sim.waveworld", &v, Image::new(32, 32), 1.0, env).img
};
let solid = hm(&[]);
let clear = hm(&[("heightMapControls/renderDryAreasAs", Value::Enum(1))]);
assert!(solid.get(30, 16)[3] > 0.99 && clear.get(30, 16)[3] == 0.0);
assert!(clear.get(4, 16)[3] > 0.99, "wet side stays");
// Dry ground stays flat: mid grey (brightness 0.5) there.
assert!((solid.get(30, 16)[0] - 0.5).abs() < 1e-4);
// Wave Strength slows waves over shallow water: the surface differs.
assert_ne!(hm(&[]), hm(&[("ground/waveStrength", num(1.0))]));
}
#[test]
fn wave_world_seek_and_waves() {
let v = vec![("view", Value::Enum(1)), ("producer1/producer1Position", pt(16.0, 16.0)), ("simulation/gridResolution", num(24.0))];
let _ = run("ec.sim.waveworld", &v, Image::new(32, 32), 0.5);
let a = run("ec.sim.waveworld", &v, Image::new(32, 32), 1.0);
let mut v2 = v.clone();
v2.push(("heightMapControls/transparency", num(0.0)));
assert_eq!(a, run("ec.sim.waveworld", &v2, Image::new(32, 32), 1.0));
// Waves spread: some pixels differ from the flat-water grey.
assert!(a.data.iter().any(|p| (p[0] - 0.5).abs() > 0.05), "waves");
let flat = run("ec.sim.waveworld", &[("view", Value::Enum(1)), ("producer1/producer1Amplitude", num(0.0))], Image::new(32, 32), 1.0);
assert!(flat.data.iter().all(|p| (p[0] - 0.5).abs() < 1e-5));
let wire =
run("ec.sim.waveworld", &[("producer1/producer1Position", pt(16.0, 16.0)), ("simulation/gridResolution", num(10.0))], Image::new(32, 32), 0.3);
assert!(wire.data.iter().any(|p| p[1] > 0.3));
}
#[test]
fn wave_world_steps_match_direct() {
// Fresh caches: resumed simulation equals simulation from scratch.
let v = vec![
("view", Value::Enum(1)),
("producer1/producer1Position", pt(16.0, 16.0)),
("simulation/gridResolution", num(20.0)),
("simulation/damping", num(0.07)),
];
let a = run("ec.sim.waveworld", &v, Image::new(32, 32), 1.3);
let mut v2 = v.clone();
v2.push(("simulation/damping", num(0.07000001)));
// Different key: computed from scratch at 1.3 directly vs via 0.4 (both fresh keys).
let _ = run("ec.sim.waveworld", &v2, Image::new(32, 32), 0.4);
let b = run("ec.sim.waveworld", &v2, Image::new(32, 32), 1.3);
assert!(max_diff(&a, &b) < 1e-3, "{}", max_diff(&a, &b));
}
#[test]
fn foam_bubbles_drift_and_seek() {
let v = vec![("producer/producerPoint", pt(16.0, 16.0)), ("view", Value::Enum(2)), ("producer/productionRate", num(2.0))];
let none = run("ec.sim.foam", &v, Image::new(32, 32), 0.0);
assert!(none.data.iter().all(|p| p[3] == 0.0));
let _ = run("ec.sim.foam", &v, Image::new(32, 32), 0.6);
let a = run("ec.sim.foam", &v, Image::new(32, 32), 1.2);
let mut v2 = v.clone();
v2.push(("randomSeed", num(1.0)));
assert_eq!(a, run("ec.sim.foam", &v2, Image::new(32, 32), 1.2));
assert!(a.data.iter().any(|p| p[3] > 0.05), "bubbles");
let draft = run("ec.sim.foam", &[("producer/producerPoint", pt(16.0, 16.0)), ("producer/productionRate", num(2.0))], Image::new(32, 32), 1.0);
assert!(draft.data.iter().any(|p| p[3] > 0.05));
}
#[test]
fn foam_strength_controls_early_popping() {
// Strong bubbles all live their full lifespan; weak ones pop at random earlier.
assert_eq!(foam_pop_age(7, 300.0, 0.0, 1), 300.0);
let early = (0..200).filter(|&id| foam_pop_age(id, 300.0, 1.0, 1) < 150.0).count();
assert!(early > 60 && early < 140, "{early}");
let count = |strength: f64| {
let v = vec![
("producer/producerPoint", pt(16.0, 16.0)),
("producer/productionRate", num(3.0)),
("bubbles/lifespan", num(40.0)),
("bubbles/strength", num(strength)),
("physics/windSpeed", num(0.0)),
];
run("ec.sim.foam", &v, Image::new(32, 32), 1.5).data.iter().map(|p| p[3]).sum::<f32>()
};
assert!(count(0.0) < count(20.0) * 0.9, "{} vs {}", count(0.0), count(20.0));
}
/// A host with a comp camera (identity, or tilted) and a first comp light.
struct Scene {
camera: Option<[[f64; 4]; 3]>,
light: Option<crate::CompLight>,
layer: Option<Image>,
}
impl crate::EffectHost for Scene {
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
let img = self.layer.clone()?;
let size = [img.width as f64, img.height as f64];
Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size })
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn comp_scene(&self) -> Option<crate::CompScene> {
Some(crate::CompScene { camera: self.camera, camera_layer: true, light: self.light })
}
}
fn run_env(id: &str, vals: &[(&str, Value)], img: Image, t: f64, host: &Scene) -> Image {
run_fx(id, vals, img, t, EffectEnv { host: Some(host), ..Default::default() }).img
}
#[test]
fn card_dance_camera_systems() {
let src = ramp(40, 30);
// Camera Position: rotating the camera changes the view; zero rotation is the layer.
let r = run("ec.sim.carddance", &[("cameraPosition/yRotation", num(30.0))], src.clone(), 0.0);
assert!(sum_diff(&r, &src) > 10.0);
let order = run("ec.sim.carddance", &[("cameraPosition/yRotation", num(30.0)), ("cameraPosition/xRotation", num(30.0))], src.clone(), 0.0);
let order2 = run(
"ec.sim.carddance",
&[("cameraPosition/yRotation", num(30.0)), ("cameraPosition/xRotation", num(30.0)), ("cameraPosition/transformOrder", Value::Enum(5))],
src.clone(),
0.0,
);
assert!(sum_diff(&order, &order2) > 1.0, "Transform Order matters");
// X, Y Position moves the view: the layer shifts by the offset.
let moved = run("ec.sim.carddance", &[("cameraPosition/xyPosition", pt(30.0, 15.0))], src.clone(), 0.0);
assert!((moved.get(25, 10)[0] - src.get(15, 10)[0]).abs() < 0.03, "{:?} {:?}", moved.get(25, 10), src.get(15, 10));
// Corner Pins: the layer's corners land on the pins.
let pins = [
("cameraSystem", Value::Enum(1)),
("cornerPins/upperLeftCorner", pt(10.0, 5.0)),
("cornerPins/upperRightCorner", pt(30.0, 5.0)),
("cornerPins/lowerLeftCorner", pt(10.0, 25.0)),
("cornerPins/lowerRightCorner", pt(30.0, 25.0)),
];
let cp = run("ec.sim.carddance", &pins, src.clone(), 0.0);
assert_eq!(cp.get(3, 3)[3], 0.0, "outside the pinned quad");
assert!(cp.get(20, 15)[3] > 0.99);
assert!((cp.get(20, 15)[0] - src.get(20, 15)[0]).abs() < 0.05, "centre maps to centre");
// Comp Camera: the identity camera reproduces the layer; a camera that doubles x moves it.
let ident = Scene { camera: Some([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.002, 1.0]]), light: None, layer: None };
let cc = run_env("ec.sim.carddance", &[("cameraSystem", Value::Enum(2))], src.clone(), 0.0, &ident);
assert!(max_diff(&cc, &src) < 0.02, "{}", max_diff(&cc, &src));
let half = Scene { camera: Some([[0.5, 0.0, 0.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.0, 0.002, 1.0]]), ..ident };
let cc = run_env("ec.sim.carddance", &[("cameraSystem", Value::Enum(2))], src.clone(), 0.0, &half);
assert_eq!(cc.get(35, 25)[3], 0.0, "half-size view leaves the far corner empty");
}
#[test]
fn card_dance_lighting_and_material() {
let src = Image::filled(40, 30, [0.5, 0.5, 0.5, 1.0]);
let tilt = [("rows", num(1.0)), ("columns", num(1.0)), ("yRotation/yRotSource", Value::Enum(0)), ("yRotation/yRotOffset", num(60.0))];
let lit = |extra: &[(&str, Value)]| {
let mut v: Vec<(&str, Value)> = tilt.to_vec();
v.extend_from_slice(extra);
run("ec.sim.carddance", &v, src.clone(), 0.0).get(20, 15)
};
let base = lit(&[]);
// Tilted cards facing away from a frontal light get darker than flat ones.
assert!(base[0] < 0.45, "{base:?}");
// Light colour tints; intensity brightens; ambient alone lights evenly.
let red = lit(&[("lighting/lightColor", col(1.0, 0.0, 0.0))]);
assert!(red[0] > red[1] + 0.05);
assert!(lit(&[("lighting/lightIntensity", num(2.0))])[0] > base[0]);
let amb = lit(&[("lighting/ambientLight", num(1.0)), ("material/diffuse", num(0.0))]);
assert!((amb[0] - 0.5).abs() < 1e-3);
// A light placed to the side (Point Source) changes the shading of the tilted card.
let side = lit(&[("lighting/lightType", Value::Enum(1)), ("lighting/lightPosition", pt(200.0, 15.0)), ("lighting/lightDepth", num(0.2))]);
assert!((side[0] - base[0]).abs() > 0.02, "{side:?} vs {base:?}");
// Specular adds a highlight on flat, frontally lit cards.
let flat = run("ec.sim.carddance", &[("material/specular", num(0.5))], src.clone(), 0.0).get(20, 15);
assert!(flat[0] > 0.9, "{flat:?}");
// First Comp Light: no light in the comp → ambient only; a light from the host is used.
let none = Scene { camera: None, light: None, layer: None };
let a = run_env("ec.sim.carddance", &[("lighting/lightType", Value::Enum(2))], src.clone(), 0.0, &none).get(20, 15);
assert!((a[0] - 0.5 * (0.25 + 0.75)).abs() < 1e-3, "{a:?}");
let blue = Scene { light: Some(crate::CompLight { pos: [20.0, 15.0, -100.0], dir: [0.0, 0.0, 1.0], color: [0.0, 0.0, 1.0], kind: 0 }), ..none };
let a = run_env("ec.sim.carddance", &[("lighting/lightType", Value::Enum(2))], src.clone(), 0.0, &blue).get(20, 15);
assert!(a[2] > a[0] + 0.2, "{a:?}");
}
#[test]
fn shatter_custom_map_gradient_textures_and_extrusion() {
let src = ramp(32, 32);
let base = [("view", Value::Enum(0)), ("force1/force1Position", pt(16.0, 16.0)), ("force1/force1Radius", num(2.0)), ("shape/origin", pt(16.0, 16.0))];
let with = |extra: &[(&str, Value)], t: f64, host: Option<&Scene>| {
let mut v: Vec<(&str, Value)> = base.to_vec();
v.extend_from_slice(extra);
match host {
Some(h) => run_env("ec.sim.shatter", &v, src.clone(), t, h),
None => run("ec.sim.shatter", &v, src.clone(), t),
}
};
// Custom Shatter Map: a map white on the left and black on the right; with White Tiles
// Fixed the left half stays put while the right half breaks away.
let mut map = Image::new(32, 32);
for y in 0..32 {
for x in 0..32 {
map.set(x, y, if x < 16 { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] });
}
}
let host = Scene { camera: None, light: None, layer: Some(map) };
let custom = [("shape/pattern", Value::Enum(5)), ("shape/customShatterMap", Value::Layer(Some(1))), ("shape/whiteTilesFixed", Value::Bool(true))];
let at_rest = with(&custom, 0.0, Some(&host));
assert!(max_diff(&at_rest, &src) < 0.05, "custom pieces tile the layer: {}", max_diff(&at_rest, &src));
let broke = with(&custom, 1.0, Some(&host));
assert!(max_diff_region(&broke, &src, 0..12) < 0.05, "white tiles fixed");
assert!(sum_diff_region(&broke, &src, 20..32) > 5.0, "black group moved");
// Gradient: a black gradient layer with threshold 50 % keeps everything together.
let black = Scene { camera: None, light: None, layer: Some(Image::filled(32, 32, [0.0, 0.0, 0.0, 1.0])) };
let g = with(&[("gradient/gradientLayer", Value::Layer(Some(1))), ("gradient/shatterThreshold", num(50.0))], 1.0, Some(&black));
assert!(max_diff(&g, &src) < 0.05);
let gi = with(
&[("gradient/gradientLayer", Value::Layer(Some(1))), ("gradient/shatterThreshold", num(50.0)), ("gradient/invertGradient", Value::Bool(true))],
1.0,
Some(&black),
);
assert!(sum_diff(&gi, &src) > 10.0);
// Textures: Front Mode Color paints the pieces flat; Opacity fades them.
let flat = with(&[("textures/frontMode", Value::Enum(0)), ("textures/color", col(0.0, 1.0, 0.0))], 0.0, None);
let p = flat.get(16, 16);
assert!(p[1] > 0.95 && p[0] < 0.05, "{p:?}");
let half = with(&[("textures/opacity", num(0.5)), ("shape/repetitions", num(2.0))], 0.0, None);
assert!((half.get(8, 4)[3] - 0.5).abs() < 0.02, "{:?}", half.get(8, 4));
// Extrusion: thicker pieces cover more once they tumble.
let cover = |d: f64| {
with(&[("shape/extrusionDepth", num(d)), ("textures/sideMode", Value::Enum(0)), ("textures/color", col(1.0, 1.0, 1.0))], 0.6, None)
.data
.iter()
.map(|p| p[3])
.sum::<f32>()
};
assert!(cover(1.0) > cover(0.0) + 1.0, "{} vs {}", cover(1.0), cover(0.0));
// Deterministic and seek-consistent.
assert_eq!(with(&custom, 0.7, Some(&host)), with(&custom, 0.7, Some(&host)));
}
fn max_diff_region(a: &Image, b: &Image, xs: std::ops::Range<u32>) -> f32 {
let mut m = 0.0f32;
for y in 0..a.height {
for x in xs.clone() {
let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64));
m = (0..4).map(|k| (p[k] - q[k]).abs()).fold(m, f32::max);
}
}
m
}
fn sum_diff_region(a: &Image, b: &Image, xs: std::ops::Range<u32>) -> f32 {
let mut s = 0.0;
for y in 0..a.height {
for x in xs.clone() {
let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64));
s += (0..4).map(|k| (p[k] - q[k]).abs()).sum::<f32>();
}
}
s
}
/// Layer 1 = a left-dark, right-light ramp; layer 2 = solid green.
struct FoamHost;
impl crate::EffectHost for FoamHost {
fn layer(&self, id: u64, _: bool) -> Option<crate::LayerPixels> {
let img = if id == 1 { ramp(32, 32) } else { Image::filled(32, 32, [0.0, 1.0, 0.0, 1.0]) };
Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [32.0, 32.0] })
}
fn layer_at(&self, id: u64, _: f64, me: bool) -> Option<crate::LayerPixels> {
self.layer(id, me)
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
}
#[test]
fn foam_flow_map_pop_velocity_wobble_textures_and_reflection() {
let base = vec![
("producer/producerPoint", pt(16.0, 16.0)),
("producer/productionRate", num(2.0)),
("physics/windSpeed", num(0.0)),
("physics/turbulence", num(0.0)),
("view", Value::Enum(2)),
];
let with = |extra: &[(&str, Value)], t: f64| {
let mut v = base.clone();
v.extend_from_slice(extra);
run_fx("ec.sim.foam", &v, Image::new(32, 32), t, EffectEnv { host: Some(&FoamHost), ..Default::default() }).img
};
let cx = |img: &Image| {
let (mut s, mut n) = (0.0, 0.0);
for y in 0..32 {
for x in 0..32 {
let a = img.get(x, y)[3];
s += a * x as f32;
n += a;
}
}
s / n.max(1e-6)
};
// Flow map: bubbles run downhill (towards the dark left side).
let still = with(&[], 1.0);
let flowing = with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0))], 1.0);
assert!(cx(&flowing) < cx(&still) - 1.0, "{} vs {}", cx(&flowing), cx(&still));
let hq =
with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0)), ("flowMap/simulationQuality", Value::Enum(2))], 1.0);
assert_eq!(
hq,
with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0)), ("flowMap/simulationQuality", Value::Enum(2))], 1.0)
);
// Pop velocity scatters the survivors when short-lived bubbles pop.
let short = [("bubbles/lifespan", num(8.0)), ("bubbles/strength", num(0.0))];
let a = with(&[short.as_slice(), &[("physics/popVelocity", num(0.0))]].concat(), 1.0);
let b2 = with(&[short.as_slice(), &[("physics/popVelocity", num(20.0))]].concat(), 1.0);
assert_ne!(a, b2);
// Wobble changes the drawn bubbles.
assert_ne!(with(&[("physics/wobbleAmount", num(0.0))], 1.0), with(&[("physics/wobbleAmount", num(3.0))], 1.0));
// User Defined texture: bubbles show the texture layer (green).
let tex = with(&[("rendering/bubbleTexture", Value::Enum(5)), ("rendering/bubbleTextureLayer", Value::Layer(Some(2)))], 1.0);
assert!(tex.data.iter().any(|p| p[1] > 0.5 && p[0] < 0.1));
// Environment map: reflections tint the bubbles.
let refl = with(&[("rendering/environmentMap", Value::Layer(Some(2))), ("rendering/reflectionStrength", num(1.0))], 1.0);
let g = |i: &Image| i.data.iter().map(|p| p[1] - p[0]).sum::<f32>();
assert!(g(&refl) > g(&still) + 1.0);
// Zoom Producer Point zooms about the producer instead of the layer centre.
let off = [("producer/producerPoint", pt(8.0, 8.0)), ("zoom", num(2.0))];
let z1 = with(&off, 1.0);
let z2 = with(&[off.as_slice(), &[("producer/zoomProducerPoint", Value::Bool(true))]].concat(), 1.0);
assert!((cx(&z2) - 8.0).abs() < 2.0 && cx(&z1) < 4.0, "{} vs {}", cx(&z2), cx(&z1));
}
}