M9.13: Foam flow map, wobble, pop velocity, bubble texture layer, orientation, environment map

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 03:50:57 -04:00
1 parent 3c586e2dba
commit bb384fbc9c
4 files changed
+310 -59

No files matched your search

-1
View File
@@ -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"),
+1
View File
@@ -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"),
+306 -55
View File
@@ -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<FoamState> = SimCache::new(4);
/// Foam steps once per frame at 30 frames per second.
const FOAM_FPS: f64 = 30.0;
/// Foam's Bubble Texture options (the last uses Bubble Texture Layer).
const FOAM_TEXTURES: [&str; 6] = ["Default Bubble", "Spit", "Bubblegum", "Dishwater", "Milky", "User Defined"];
/// Age (in simulation steps) at which bubble `id` pops: its lifespan, shortened at random for
/// frail bubbles (`frailty` 0 = every bubble reaches `lifespan`, 1 = uniformly early).
fn foam_pop_age(id: u32, lifespan: f32, frailty: f32, seed: u32) -> f32 {
lifespan * (1.0 - frailty * (hs(id, 11, seed) * 0.5 + 0.5))
}
/// Foam: bubbles born in the producer rectangle grow to their size, drift with the wind,
/// turbulence and the Flow Map (bubbles run downhill on its luminance), repel and stick to each
/// other, wobble, and pop at the end of their (Strength-shortened) lifespan, pushing their
/// neighbours away at Pop Velocity. Rendered bubbles use a built-in texture or the Bubble
/// Texture Layer, oriented by Bubble Orientation, and reflect the Environment Map.
fn foam(ctx: &EffectCtx, 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<usize>> = std::collections::HashMap::new();
for (i, q) in st.b.iter().enumerate() {
grid.entry(((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32)).or_default().push(i);
}
let mut push = vec![[0.0f32; 2]; st.b.len()];
for (i, q) in st.b.iter().enumerate() {
let (gx, gy) = ((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32);
for dy in -1..=1 {
for dx in -1..=1 {
let Some(list) = grid.get(&(gx + dx, gy + dy)) else { continue };
for &j in list {
if j <= i {
continue;
}
let o = &st.b[j];
let (ddx, ddy) = (o.p[0] - q.p[0], o.p[1] - q.p[1]);
let d = (ddx * ddx + ddy * ddy).sqrt().max(1e-4);
let overlap = rad(q) + rad(o) - d;
if overlap > 0.0 {
let f = overlap * 0.5 * repulsion.min(4.0) * (1.0 - sticky * 0.5) / d;
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<usize>> = std::collections::HashMap::new();
for (i, q) in st.b.iter().enumerate() {
grid.entry(((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32)).or_default().push(i);
}
let mut push = vec![[0.0f32; 2]; st.b.len()];
for (i, q) in st.b.iter().enumerate() {
let (gx, gy) = ((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32);
for dy in -1..=1 {
for dx in -1..=1 {
let Some(list) = grid.get(&(gx + dx, gy + dy)) else { continue };
for &j in list {
if j <= i {
continue;
}
let o = &st.b[j];
let (ddx, ddy) = (o.p[0] - q.p[0], o.p[1] - q.p[1]);
let d = (ddx * ddx + ddy * ddy).sqrt().max(1e-4);
let overlap = rad(q) + rad(o) - d;
if overlap > 0.0 {
let f = overlap * 0.5 * repulsion.min(4.0) * (1.0 - sticky * 0.5) / d * k;
push[i][0] -= ddx * f;
push[i][1] -= ddy * f;
push[j][0] += ddx * f;
push[j][1] += ddy * f;
}
}
}
}
}
for (q, d) in st.b.iter_mut().zip(&push) {
q.p[0] += d[0].clamp(-maxr, maxr);
q.p[1] += d[1].clamp(-maxr, maxr);
}
}
for (q, 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<Sprite> = 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<crate::ParamSpec> {
@@ -1609,6 +1777,7 @@ pub fn specs() -> Vec<EffectSpec> {
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<EffectSpec> {
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<crate::LayerPixels> {
let img = if id == 1 { ramp(32, 32) } else { Image::filled(32, 32, [0.0, 1.0, 0.0, 1.0]) };
Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [32.0, 32.0] })
}
fn layer_at(&self, id: u64, _: f64, me: bool) -> Option<crate::LayerPixels> {
self.layer(id, me)
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
}
#[test]
fn foam_flow_map_pop_velocity_wobble_textures_and_reflection() {
let base = vec![
("producer/producerPoint", pt(16.0, 16.0)),
("producer/productionRate", num(2.0)),
("physics/windSpeed", num(0.0)),
("physics/turbulence", num(0.0)),
("view", Value::Enum(2)),
];
let with = |extra: &[(&str, Value)], t: f64| {
let mut v = base.clone();
v.extend_from_slice(extra);
run_fx("ec.sim.foam", &v, Image::new(32, 32), t, EffectEnv { host: Some(&FoamHost), ..Default::default() }).img
};
let cx = |img: &Image| {
let (mut s, mut n) = (0.0, 0.0);
for y in 0..32 {
for x in 0..32 {
let a = img.get(x, y)[3];
s += a * x as f32;
n += a;
}
}
s / n.max(1e-6)
};
// Flow map: bubbles run downhill (towards the dark left side).
let still = with(&[], 1.0);
let flowing = with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0))], 1.0);
assert!(cx(&flowing) < cx(&still) - 1.0, "{} vs {}", cx(&flowing), cx(&still));
let hq =
with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0)), ("flowMap/simulationQuality", Value::Enum(2))], 1.0);
assert_eq!(
hq,
with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0)), ("flowMap/simulationQuality", Value::Enum(2))], 1.0)
);
// Pop velocity scatters the survivors when short-lived bubbles pop.
let short = [("bubbles/lifespan", num(8.0)), ("bubbles/strength", num(0.0))];
let a = with(&[short.as_slice(), &[("physics/popVelocity", num(0.0))]].concat(), 1.0);
let b2 = with(&[short.as_slice(), &[("physics/popVelocity", num(20.0))]].concat(), 1.0);
assert_ne!(a, b2);
// Wobble changes the drawn bubbles.
assert_ne!(with(&[("physics/wobbleAmount", num(0.0))], 1.0), with(&[("physics/wobbleAmount", num(3.0))], 1.0));
// User Defined texture: bubbles show the texture layer (green).
let tex = with(&[("rendering/bubbleTexture", Value::Enum(5)), ("rendering/bubbleTextureLayer", Value::Layer(Some(2)))], 1.0);
assert!(tex.data.iter().any(|p| p[1] > 0.5 && p[0] < 0.1));
// Environment map: reflections tint the bubbles.
let refl = with(&[("rendering/environmentMap", Value::Layer(Some(2))), ("rendering/reflectionStrength", num(1.0))], 1.0);
let g = |i: &Image| i.data.iter().map(|p| p[1] - p[0]).sum::<f32>();
assert!(g(&refl) > g(&still) + 1.0);
// Zoom Producer Point zooms about the producer instead of the layer centre.
let off = [("producer/producerPoint", pt(8.0, 8.0)), ("zoom", num(2.0))];
let z1 = with(&off, 1.0);
let z2 = with(&[off.as_slice(), &[("producer/zoomProducerPoint", Value::Bool(true))]].concat(), 1.0);
assert!((cx(&z2) - 8.0).abs() < 2.0 && cx(&z1) < 4.0, "{} vs {}", cx(&z2), cx(&z1));
}
}
+3 -3
View File
@@ -3,11 +3,11 @@
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
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.<category>.<name>`) 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.<category>.<name>`) 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 |