//! 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 { 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, pub front: PieceTex, pub back: Option, } impl PiecePlan { /// Rasterise over a transparent frame of the layer `b`'s size. pub fn finish(&self, mut b: Buf) -> Buf { let mut out = Image::new(b.img.width, b.img.height); let front = match &self.front { PieceTex::Layer => &b.img, PieceTex::Image(i) => i, }; let back = self.back.as_ref().map(|t| match t { PieceTex::Layer => &b.img, PieceTex::Image(i) => i, }); draw_pieces(&mut out, &self.pieces, front, back); b.img = out; b } } /// The piece plan of Card Dance, Shatter (Rendered view) or Card Wipe for layer `b` (its pixels /// may be read: gradient maps and textures default to the layer), `None` for other effects or /// views drawn otherwise. pub fn piece_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { match id { "ec.sim.carddance" => Some(card_dance_plan(ctx, b)), "ec.sim.shatter" if ctx.params.e("view") == 0 => 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) -> Option { 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 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::() / 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 = 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 { 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 = (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::() / poly.len() as f64, poly.iter().map(|v| v[1]).sum::() / 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 { 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 = (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 { 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 = (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> { 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, } 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> = 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::() / lp.len() as f64, lp.iter().map(|v| v[1]).sum::() / lp.len() as f64]; let keys: Vec = 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 = 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 = polys .par_iter() .zip(keys.par_iter()) .flat_map_iter(|(lp, &key)| { let mut out: Vec = 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::()); 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| 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, /// Gaussian σ (buffer px) blurring the bottom (transparent edges), 0 = none. pub bottom_sigma: f64, /// The water surface's height field (smoothed luminance), `None` = flat. pub height: Option, /// The Sky layer fitted to the buffer. pub sky: Option, pub scaling: f64, pub repeat: u32, pub wave_h: f64, /// Displacement per unit of height gradient. pub k: f64, /// Layer centre (buffer px). pub cx: f64, pub cy: f64, /// Layer width (buffer px). pub lw: f64, pub surf: [f32; 4], pub surf_op: f32, pub cstr: f32, pub lc: [f32; 4], pub li: f32, /// Light position (buffer px) and height (layer widths); `point` = from the position to /// each pixel, else one direction from above the layer centre. pub lx: f64, pub ly: f64, pub lheight: f64, pub point: bool, pub ambient: f32, pub diffuse: f32, pub specular: f32, pub sharp: f32, pub sky_scaling: f64, pub sky_repeat: u32, pub sky_int: f32, pub convergence: f64, } /// Caustics' setup for layer geometry `b` (see [`CausticsSetup`]). pub fn caustics_setup(ctx: &EffectCtx, b: &Buf) -> CausticsSetup { let pr = ctx.params; let s = b.scale; let 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, pub v: Vec, } 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 = 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, depth: Option> }, Wire(crate::sim::SpritePlan), } fn wave_world(ctx: &EffectCtx, b: Buf) -> Buf { match wave_world_impl(ctx, &b, false) { Ok(img) => Buf { img, ..b }, Err(WavePlan::Wire(plan)) => plan.finish(b), Err(WavePlan::Height { .. }) => b, } } /// Wave World's simulation state for the frame, as a [`WavePlan`] (its pixels are not read). pub fn wave_world_plan(ctx: &EffectCtx, b: &Buf) -> Option { wave_world_impl(ctx, b, true).err() } fn wave_world_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result { let pr = ctx.params; let [lw, lh] = ctx.layer_size; let res = pr.f("simulation/gridResolution").clamp(1.0, 400.0) as usize; 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 = [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> = 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> = 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 = 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, carry: f32, next: u32, } static FOAM_CACHE: SimCache = 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 { 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, } /// 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)>, pub env: Option, /// The flow map fitted to the buffer. pub flow: Option, } 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> = 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 = 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 { 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 { 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 { 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::()).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::() }; 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, layer: Option, } impl crate::EffectHost for Scene { fn layer(&self, _: u64, _: bool) -> Option { 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> { None } fn comp_scene(&self) -> Option { 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::() }; 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) -> 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) -> 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::(); } } 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 { 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 { self.layer(id, me) } fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { 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::(); 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)); } }