diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 9e2f1250..ad47275a 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -32,7 +32,6 @@ pub const PARTIAL: &[(&str, &str)] = &[ ("ec.vr.digitalglitch", "reduced control set"), ("ec.vr.converter", "common layouts only"), ("ec.sim.caustics", "no Sky group or light type"), - ("ec.sim.foam", "no wobble, pop velocity, custom bubble texture, environment map or flow map"), ("ec.sim.waveworld", "no wireframe controls, dry-area rendering or ground group"), ("ec.stylize.glow", "no Glow Operation or arbitrary colour map"), ("ec.stylize.cartoon", "no Edge Enhancement"), diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 022e61fd..2bb5e914 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -574,6 +574,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("force2", "Force 2"), ("bubbles", "Bubbles"), ("rendering", "Rendering"), + ("flowMap", "Flow Map"), ("heightMapControls", "Height Map Controls"), ("simulation", "Simulation"), ("producer1", "Producer 1"), diff --git a/crates/effects/src/sim2.rs b/crates/effects/src/sim2.rs index 5d545e52..5994316e 100644 --- a/crates/effects/src/sim2.rs +++ b/crates/effects/src/sim2.rs @@ -1213,6 +1213,8 @@ struct FoamBubble { v: [f32; 2], age: f32, size: f32, + /// Spin (radians) for Physical Orientation. + spin: f32, id: u32, } @@ -1228,12 +1230,20 @@ 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, mut b: Buf) -> Buf { let pr = ctx.params; let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); @@ -1254,14 +1264,50 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { 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]; - let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 5); + // 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. @@ -1279,73 +1325,104 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { 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 }); + st.b.push(FoamBubble { p, v, age: 0.0, size: sz, spin: hs(id, 12, seed) * std::f32::consts::PI, 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; + 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; } - 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; + // 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, 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; } } - 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; } + // 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; - let (cx, cy) = (lw * 0.5, lh * 0.5); + // 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. @@ -1353,14 +1430,27 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { } 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 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 (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. - if view != 2 { + 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 { @@ -1371,13 +1461,91 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { _ => ([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(); - b.img = splat(b.img.width, b.img.height, &sprites, if blend == 0 && view == 2 { Acc::Add } else { Acc::Over }); + let mut out = splat(b.img.width, b.img.height, &sprites, if blend == 0 && view == 2 { Acc::Add } else { Acc::Over }); + // User Defined: the texture layer fills each bubble's disc. + if let Some(tex) = &user_tex { + for q in &order { + 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); + foam_disc(&mut out, bx, by, 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). + let strength = pr.f("rendering/reflectionStrength").clamp(0.0, 1.0) as f32; + if view == 2 + && strength > 0.0 + && let Some(env) = ctx.layer_param("rendering/environmentMap", true) + { + let env_img = crate::util::fit_layer(ctx, &b, &env, true); + let conv = pr.f("rendering/reflectionConvergence").clamp(0.0, 1.0); + let (w, h) = (b.img.width as f64, b.img.height as f64); + for q in &order { + let (bx, by, r, _) = place(q); + foam_disc(&mut out, bx, by, 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 view == 1 + && let Some(lp) = &flow + { + let fm = crate::util::fit_layer(ctx, &b, lp, true); + out.data.par_iter_mut().zip(fm.data.par_iter()).for_each(|(o, f)| { + let k = 1.0 - o[3]; + for c in 0..4 { + o[c] += f[c] * 0.5 * k; + } + }); + } + b.img = out; b } +/// 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 { @@ -1609,6 +1777,7 @@ pub fn specs() -> Vec { 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)), @@ -1620,13 +1789,24 @@ pub fn specs() -> Vec { 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(&["Default Bubble", "Spit", "Bubblegum", "Dishwater", "Milky"])), + 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, @@ -2002,4 +2182,75 @@ mod tests { } 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)); + } } diff --git a/docs/effects.md b/docs/effects.md index 68dc9e86..f728aa9b 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 303 effects with 2730 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. +EffectCraft ships 303 effects with 2742 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. - **GPU**: 24 effects also run on the GPU compositor with identical results. - **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping. -- **Status**: 274 implemented in full, 29 partial (what is missing is listed). +- **Status**: 275 implemented in full, 28 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -330,7 +330,7 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Star Burst | `ec.sim.ccstarburst` | 6 | | 32 | Implemented | | Card Dance | `ec.sim.carddance` | 53 | | 32 | Implemented | | Caustics | `ec.sim.caustics` | 21 | | 32 | Partial: no Sky group or light type | -| Foam | `ec.sim.foam` | 24 | | 32 | Partial: no wobble, pop velocity, custom bubble texture, environment map or flow map | +| Foam | `ec.sim.foam` | 36 | | 32 | Implemented | | Particle Playground | `ec.sim.particleplayground` | 92 | | 32 | Implemented | | Shatter | `ec.sim.shatter` | 60 | | 32 | Implemented | | Wave World | `ec.sim.waveworld` | 27 | | 32 | Partial: no wireframe controls, dry-area rendering or ground group |