//! 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::generate::value_noise; use crate::sim::{Acc, Shape, Sprite, 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 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). 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, } /// A planar textured piece ready to draw. #[derive(Clone, Copy)] struct Piece { /// Screen (buffer px, homogeneous) → piece-local (a, b, w). inv: M3, /// Rest centre of the piece in texture pixels. c0: [f32; 2], /// Convex polygon (texture pixels), counter-clockwise in y-down space. poly: [[f32; 2]; 6], n: u8, bbox: [f32; 4], shade: f32, alpha: f32, /// Flat straight colour instead of the texture. flat: Option<[f32; 3]>, /// Facing away from the camera (draw the back texture). back: bool, depth: f64, } 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). fn new(poly: &[[f32; 2]], c0: [f32; 2], r: &M3, pos: [f64; 3], cam: Cam) -> Option { let n = poly.len().min(6); if n < 3 { return None; } let (cx, cy, d) = (cam.c[0], cam.c[1], cam.d); let zc = pos[2]; let hm: M3 = [ [cx * r[2][0] + d * r[0][0], cx * r[2][1] + d * r[0][1], cx * (d + zc) + d * (pos[0] - cx)], [cy * r[2][0] + d * r[1][0], cy * r[2][1] + d * r[1][1], cy * (d + zc) + d * (pos[1] - cy)], [r[2][0], r[2][1], d + zc], ]; 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 < d * 0.05 { 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(); } // Front normal (0, 0, -1) rotated; facing the camera when its z is negative. let nz = -r[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, alpha: 1.0, flat: None, back: nz > 0.0, depth: zc, }) } /// Lambert factor of the piece normal against a light shining along +z. fn facing(r: &M3) -> f32 { r[2][2].abs() as f32 } } /// Draw pieces (already sorted far → near) over `out`. 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 }; 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; Some([c[0] * k * a, c[1] * k * a, c[2] * k * a, a]) }, Acc::Over, ); } 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, mut b: Buf) -> Buf { 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.clone(); 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(); b.img = splat(src.width, src.height, &sprites, Acc::Over); b } // ------------------------------------------------------------------ CC Pixel Polly fn pixel_polly(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let t = ctx.time - pr.f("startTime"); if t <= 0.0 { return b; } 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.clone(); 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); } let mut out = Image::new(src.width, src.height); draw_pieces(&mut out, &pieces, &src, None); b.img = out; b } // ------------------------------------------------------------------ CC Scatterize fn scatterize(ctx: &EffectCtx, mut b: Buf) -> Buf { 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 b; } 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(); b.img = splat(w, hh, &sprites, if add { Acc::Add } else { Acc::Over }); b } // ------------------------------------------------------------------ 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, mut b: Buf) -> Buf { 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 zpos = pr.f("cameraPosition/cameraZ").max(0.05); let focal = pr.f("cameraPosition/focalLength").max(1.0); let cam = Cam { c: [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s], d: lw.max(lh) * s * zpos * focal / 140.0 }; let ambient = pr.f("lighting/ambientLight") as f32; let diffuse = pr.f("material/diffuse") as f32; let intensity = pr.f("lighting/lightIntensity") as f32; let props: Vec<(u32, f64, f64)> = CD_PROPS.iter().map(|(id, g)| (pr.e(&format!("{g}/{id}Source")), pr.f(&format!("{g}/{id}Multiplier")), pr.f(&format!("{g}/{id}Offset")))).collect(); let src = b.img.clone(); let mut pieces: Vec = (0..rows * cols) .into_par_iter() .filter_map(|i| { 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.shade = ambient + diffuse * intensity * Piece::facing(&r); Some(pc) }) .collect(); sort_far_first(&mut pieces); let mut out = Image::new(src.width, src.height); draw_pieces(&mut out, &pieces, &src, back.as_ref()); b.img = out; b } // ------------------------------------------------------------------ Shatter const SHATTER_PATTERNS: &[&str] = &["Bricks", "Glass", "Hexagons", "Squares", "Triangles"]; /// 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)) } } fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let view = pr.e("view"); let render = pr.e("render"); let kind = pr.e("shape/pattern"); 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 ambient = pr.f("lighting/ambientLight") as f32; let intensity = pr.f("lighting/lightIntensity") as f32; 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 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 (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)); let cells = pattern_cells(kind, cell, px0, py0, px1, py1, seed); let g3 = [gdir.sin() * ginc.cos() * gravity * unit, -gdir.cos() * ginc.cos() * gravity * unit, ginc.sin() * gravity * unit]; let src = b.img.clone(); let mut pieces: Vec = cells .par_iter() .enumerate() .filter_map(|(i, poly)| { let i = i as u32; let lp: Vec<[f64; 2]> = poly.iter().map(|&q| to_layer(q)).collect(); 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 { return None; } let c = [lp.iter().map(|v| v[0]).sum::() / lp.len() as f64, lp.iter().map(|v| v[1]).sum::() / lp.len() as f64]; // 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 = [c[0] - fp[0], c[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 && render == 1) || (!broken && render == 2) { return None; } let mut pos = [c[0], c[1], 0.0]; let mut r = rot_axis([1.0, 0.0, 0.0], 0.0); if broken && t > 0.0 { 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 { pos[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); } 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 = [(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 = ambient + (1.0 - ambient) * intensity * Piece::facing(&r); Some(pc) }) .collect(); sort_far_first(&mut pieces); let mut out = Image::new(src.width, src.height); if view == 0 { draw_pieces(&mut out, &pieces, &src, None); } else { // 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 })); } } } out = splat(src.width, src.height, &lines, Acc::Over); } b.img = out; b } // ------------------------------------------------------------------ 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), } } fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let s = b.scale; let mut bottom = layer_or_self(ctx, &b, "bottom/bottom", true, pr.e("bottom/bottomSizeDiffers") == 1); let blur = pr.f("bottom/blur") * s; if blur > 0.05 { bottom = effectcraft_raster::gaussian_blur(&bottom, blur * 0.5, blur * 0.5, false); } let scaling = pr.f("bottom/scaling").max(0.01); let repeat = pr.e("bottom/repeatMode"); let wave_h = pr.f("water/waveHeight"); let smoothing = pr.f("water/smoothing") * s; let depth = pr.f("water/waterDepth"); let ior = pr.f("water/refractiveIndex").max(1.0); let surf = pr.color("water/surfaceColor"); let surf_op = pr.f("water/surfaceOpacity") as f32; let cstr = pr.f("water/causticsStrength") as f32; let li = pr.f("lighting/lightIntensity") as f32; let lc = pr.color("lighting/lightColor"); let lpos = pr.v2("lighting/lightPosition"); let lheight = pr.f("lighting/lightHeight").max(0.01); let ambient = pr.f("lighting/ambientLight") as f32; let diffuse = pr.f("material/diffuse") as f32; let specular = pr.f("material/specular") as f32; let sharp = pr.f("material/highlightSharpness").max(1.0) as f32; let (w, hh) = (b.img.width as usize, b.img.height as usize); // Height field of the water surface (luminance of the chosen layer; none = flat). let height = ctx.layer_param("water/waterSurface", true).map(|o| { let img = crate::util::fit_layer(ctx, &b, &o, true); let 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 lv = { let v = [(lx - cx) / lw.max(1.0), (ly - cy) / lw.max(1.0), lheight]; let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); [(v[0] / n) as f32, (v[1] / n) as f32, (v[2] / n) as f32] }; 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 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; let cc: [f32; 3] = std::array::from_fn(|q| { let base = c[q] * light * lc[q]; base * (1.0 - surf_op) + surf[q] * surf_op * (ambient + diffuse * li * nl) + 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 #[derive(Clone, Debug)] struct Waves { nx: usize, ny: usize, u: Vec, 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); fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { 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(); let 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); 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 * 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]; } } } // 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 pr.e("view") == 1 { 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; b.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); *px = [v * alpha, v * alpha, v * alpha, alpha]; } }); } else { // Wireframe preview: the grid seen at an angle, heights lifting the lines. let lift = lh as f32 * 0.25; let to = |x: usize, y: usize| -> [f32; 2] { let lx = x as f64 / (st.nx - 1) as f64 * lw; let ly = y as f64 / (st.nx - 1) as f64 * lw; let (px, py) = b.to_px([lx, lh * 0.25 + ly * 0.6]); [px as f32, py as f32 - st.u[y * st.nx + x] * lift * s 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 wire = splat(bw, bh, &lines, Acc::Over); b.img = crate::sim::combine(&Image::filled(bw, bh, [0.0, 0.0, 0.0, 1.0]), &wire, 0); } b } // ------------------------------------------------------------------ Foam #[derive(Clone, Debug)] struct FoamBubble { p: [f32; 2], v: [f32; 2], age: f32, size: 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; /// 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)) } fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { 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 repulsion = pr.f("physics/repulsion") 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]; let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 5); let steps = if ctx.time <= 0.0 { 0 } else { (ctx.time * FOAM_FPS + 1e-6).floor() as u64 }; 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, id }); } // Forces: drift towards the wind, turbulence, viscosity. 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 k in 0..2 { q.v[k] += (wvec[k] - q.v[k]) * 0.1; q.v[k] *= 1.0 - viscosity * 0.5; } q.v[0] += n1 * turb * unit; q.v[1] += n2 * turb * unit; } // Repulsion between overlapping bubbles (grid hashed, deterministic order). let rad = |q: &FoamBubble| q.size * unit * 3.0 * (q.age * growth).min(1.0); 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; 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); } } let (ux0, uy0) = (-(universe - 1.0) * 0.5 * lw, -(universe - 1.0) * 0.5 * lh); let (ux1, uy1) = (lw - ux0, lh - uy0); for q in st.b.iter_mut() { q.p[0] += q.v[0]; q.p[1] += q.v[1]; q.age += 1.0; } 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 texture = pr.e("rendering/bubbleTexture"); let blend = pr.e("rendering/blendMode"); let s = b.scale as f32; let (cx, cy) = (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))); } let sprites: Vec = order .iter() .map(|q| { let r = q.size * unit * 3.0 * (q.age * growth).min(1.0) * 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 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. 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) } }) .collect(); b.img = splat(b.img.width, b.img.height, &sprites, if blend == 0 && view == 2 { Acc::Add } else { Acc::Over }); b } // ------------------------------------------------------------------ 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([ p("cameraPosition/cameraZ", "Z Position", num(2.0), slider(0.05, 10.0, 0.5, 5.0, 2)), p("cameraPosition/focalLength", "Focal Length", num(70.0), slider(1.0, 500.0, 10.0, 200.0, 1)), p("lighting/lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)), p("lighting/ambientLight", "Ambient Light", num(0.25), 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)), ]); 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("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("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/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)), ]; 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/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("lighting/lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)), p("lighting/ambientLight", "Ambient Light", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 2)), p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), ], 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/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/repulsion", "Repulsion", num(1.0), slider(0.0, 10.0, 0.0, 2.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(&["Default Bubble", "Spit", "Bubblegum", "Dishwater", "Milky"])), 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 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)); } }