M9.13: Caustics Sky group and light types, Wave World wireframe controls, ground and dry areas

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 05:48:50 -04:00
parent aae00dfaca
commit 863ff22dc4
4 changed files with 179 additions and 23 deletions

View File

@ -7,12 +7,7 @@ use crate::{CATEGORIES, registry};
/// Effects whose implementation is known to fall short of the reference behaviour:
/// `(effect id, what is missing)`. Everything else in the registry is implemented in full as far
/// as the public behaviour documentation describes it.
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.waveworld", "no wireframe controls, dry-area rendering or ground group"),
];
pub const PARTIAL: &[(&str, &str)] = &[("ec.vr.digitalglitch", "reduced control set"), ("ec.vr.converter", "common layouts only")];
/// Whether Effect Controls shows parameter (or twirl-down group) `param` (spec id path,
/// `group/param`) of an instance of effect `effect`, given the instance's current values

View File

@ -589,6 +589,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("hairColor", "Hair Color"),
("bottom", "Bottom"),
("water", "Water"),
("sky", "Sky"),
("lighting", "Lighting"),
("material", "Material"),
("shape", "Shape"),
@ -598,6 +599,8 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("rendering", "Rendering"),
("flowMap", "Flow Map"),
("heightMapControls", "Height Map Controls"),
("wireframeControls", "Wireframe Controls"),
("ground", "Ground"),
("simulation", "Simulation"),
("producer1", "Producer 1"),
("producer2", "Producer 2"),

View File

@ -947,11 +947,38 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf {
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();
// Light Type: Distant Source (one direction, from the light position above the layer
// centre), Point Source (from the light position to each pixel) or First Comp Light.
let light_type = pr.e("lighting/lightType");
let comp_light = if light_type == 2 { ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.light) } else { None };
let (lc, li, lx, ly, lheight, point) = match (light_type, comp_light) {
(2, Some(l)) => {
let c = [l.color[0], l.color[1], l.color[2], 1.0];
// Comp space: z away from the viewer; the light above the water is at −z.
let (px, py) = (l.pos[0] * s + b.offset[0], l.pos[1] * s + b.offset[1]);
(c, li, px, py, (-l.pos[2] / ctx.layer_size[0].max(1.0)).max(0.01), l.kind == 1)
}
(2, None) => (lc, 0.0, lx, ly, lheight, false),
(1, _) => (lc, li, lx, ly, lheight, true),
_ => (lc, li, lx, ly, lheight, false),
};
let norm3 = |v: [f64; 3]| {
let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12);
[(v[0] / n) as f32, (v[1] / n) as f32, (v[2] / n) as f32]
};
let lv_at = |x: f64, y: f64| {
if point {
norm3([(lx - x) / lw.max(1.0), (ly - y) / lw.max(1.0), lheight])
} else {
norm3([(lx - cx) / lw.max(1.0), (ly - cy) / lw.max(1.0), lheight])
}
};
// Sky: the layer the water surface reflects (Surface Opacity 1 = a mirror of it).
let sky = ctx.layer_param("sky/sky", true).map(|o| crate::util::fit_layer(ctx, &b, &o, pr.e("sky/skySizeDiffers") == 1));
let sky_scaling = pr.get("sky/scaling").map(Value::as_f64).unwrap_or(1.0).max(0.01);
let sky_repeat = pr.e("sky/repeatMode");
let sky_int = pr.get("sky/intensity").map(Value::as_f64).unwrap_or(0.3) as f32;
let convergence = pr.get("sky/convergence").map(Value::as_f64).unwrap_or(0.5).clamp(0.0, 1.0);
let grad = |x: usize, y: usize| -> (f64, f64) {
match &height {
Some(pl) => {
@ -991,6 +1018,7 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf {
let m = (v[0] * v[0] + v[1] * v[1] + 1.0).sqrt();
[(v[0] / m) as f32, (v[1] / m) as f32, (v[2] / m) as f32]
};
let lv = lv_at(x as f64 + 0.5, y as f64 + 0.5);
let nl = (n[0] * lv[0] + n[1] * lv[1] + n[2] * lv[2]).max(0.0);
let hv = {
let v = [lv[0], lv[1], lv[2] + 1.0];
@ -999,9 +1027,21 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf {
};
let spec = (n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]).max(0.0).powf(sharp) * specular * li;
let light = ambient + diffuse * li * nl + cstr * focus * li;
// The surface: its colour lit like the water, or the sky it reflects (sampled along
// the surface normal; Convergence pulls the reflection towards the pixel itself).
let surface: [f32; 3] = match &sky {
Some(sk) => {
let reach = (1.0 - convergence) * lw * 0.5;
let (rx, ry) = (x as f64 + 0.5 + n[0] as f64 * reach, y as f64 + 0.5 + n[1] as f64 * reach);
let (rx, ry) = (cx + (rx - cx) / sky_scaling, cy + (ry - cy) / sky_scaling);
let (sc, _) = unpremul(sample_repeat(sk, rx, ry, sky_repeat));
std::array::from_fn(|q| sc[q] * sky_int * 3.0 * surf[q])
}
None => std::array::from_fn(|q| surf[q] * (ambient + diffuse * li * nl)),
};
let cc: [f32; 3] = std::array::from_fn(|q| {
let base = c[q] * light * lc[q];
base * (1.0 - surf_op) + surf[q] * surf_op * (ambient + diffuse * li * nl) + spec * lc[q]
base * (1.0 - surf_op) + surface[q] * surf_op + spec * lc[q]
});
let a = (a + surf_op * (1.0 - a)).min(1.0);
*px = [cc[0] * a, cc[1] * a, cc[2] * a, a];
@ -1085,7 +1125,36 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf {
let dt = 1.0 / (sps * sub as f32);
let cell = 1.0 / (nx - 1) as f32;
let weights: Vec<Vec<f32>> = prods.iter().map(|p| (0..nx * ny).map(|i| p.weight((i % nx) as f32 * cell, (i / nx) as f32 * cell)).collect()).collect();
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);
// Ground: the ground layer's first frame, its luminance an elevation (× Steepness). Where
// it rises above Height the grid is dry (a wall the waves reflect from); shallow water
// slows the waves by Wave Strength.
let ground = pr.get("ground/ground").and_then(Value::as_layer).and_then(|id| {
let host = ctx.env.host?;
host.layer_at(id, ctx.env.comp_time - ctx.time, true)
});
let steep = pr.get("ground/steepness").map(Value::as_f64).unwrap_or(0.25) as f32;
let level = pr.get("ground/height").map(Value::as_f64).unwrap_or(0.25) as f32;
let wave_strength = pr.f("ground/waveStrength").clamp(0.0, 1.0) as f32;
// Water depth per grid point (None without ground: deep everywhere).
let depth: Option<Vec<f32>> = ground.as_ref().map(|lp| {
let (gw, gh) = (lp.size[0].max(1.0), lp.size[1].max(1.0));
(0..nx * ny)
.map(|i| {
let (fx, fy) = ((i % nx) as f64 / (nx - 1) as f64, (i / nx) as f64 / (ny - 1) as f64);
let (c, a) = unpremul(lp.buf.img.sample_bilinear_clamped(fx * gw * lp.buf.scale + lp.buf.offset[0], fy * gh * lp.buf.scale + lp.buf.offset[1]));
level - steep * effectcraft_color::luminance(c[0], c[1], c[2]) * a
})
.collect()
});
let speed_k: Vec<f32> = match &depth {
Some(d) => d.iter().map(|&v| if v <= 0.0 { 0.0 } else { 1.0 - wave_strength * (1.0 - (v / level.max(1e-3)).clamp(0.0, 1.0).sqrt()) }).collect(),
None => vec![1.0; nx * ny],
};
let mut key =
params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: if pr.b("simulation/gridResDownsamples") { b.scale } else { 1.0 } }, 4);
if let Some(d) = &depth {
key ^= d.iter().fold(0u64, |a, v| a.rotate_left(3) ^ v.to_bits() as u64);
}
let steps = crate::sim::steps_at(ctx.time + preroll);
let st = WAVE_CACHE.run(
key,
@ -1101,7 +1170,7 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf {
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];
acc[i] = c2 * speed_k[i] * speed_k[i] * l - damping * 4.0 * w.v[i];
}
}
for i in 0..nx * ny {
@ -1121,6 +1190,13 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
}
}
// Dry ground holds the surface flat.
for i in 0..nx * ny {
if speed_k[i] <= 0.0 {
w.u[i] = 0.0;
w.v[i] = 0.0;
}
}
// Edges: reflective ones mirror; the others absorb in a thin sponge layer.
let refl = |side: u32| reflect == 5 || reflect == side;
let sponge = 4usize.min(nx / 4).max(1);
@ -1168,22 +1244,38 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf {
let contrast = pr.f("heightMapControls/contrast") as f32;
let gamma = pr.f("heightMapControls/gamma").max(0.01) as f32;
let alpha = 1.0 - pr.f("heightMapControls/transparency") as f32;
let dry_transparent = pr.e("heightMapControls/renderDryAreasAs") == 1;
let dry_at = |x: f64, y: f64| -> bool {
let Some(d) = &depth else { return false };
let gx = (x / lw.max(1.0) * (st.nx - 1) as f64).round().clamp(0.0, (st.nx - 1) as f64) as usize;
let gy = (y / lw.max(1.0) * (st.nx - 1) as f64).round().clamp(0.0, (st.ny - 1) as f64) as usize;
d[gy * st.nx + gx] <= 0.0
};
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];
let al = if dry_transparent && dry_at(lx, ly) { 0.0 } else { alpha };
*px = [v * al, v * al, v * al, al];
}
});
} else {
// Wireframe preview: the grid seen at an angle, heights lifting the lines.
let lift = lh as f32 * 0.25;
// Wireframe preview: the grid turned by Horizontal Rotation about the vertical axis,
// tilted by Vertical Rotation, heights scaled by Vertical Scale.
let hr = pr.f("wireframeControls/horizontalRotation").to_radians();
let vr = pr.get("wireframeControls/verticalRotation").map(Value::as_f64).unwrap_or(53.0).to_radians();
let vs = pr.get("wireframeControls/verticalScale").map(Value::as_f64).unwrap_or(0.5);
let (sh, ch) = hr.sin_cos();
let (sv, cv) = vr.sin_cos();
let to = |x: usize, y: usize| -> [f32; 2] {
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]
// Grid point relative to the layer centre (layer px), height in layer px.
let gx = x as f64 / (st.nx - 1) as f64 * lw - lw * 0.5;
let gy = y as f64 / (st.nx - 1) as f64 * lw - (st.ny - 1) as f64 / (st.nx - 1) as f64 * lw * 0.5;
let hgt = st.u[y * st.nx + x] as f64 * vs * lh * 0.5;
let (rx, ry) = (gx * ch - gy * sh, gx * sh + gy * ch);
let (px, py) = b.to_px([lw * 0.5 + rx, lh * 0.5 + ry * cv - hgt * sv]);
[px as f32, py as f32]
};
let mut lines = Vec::new();
for y in 0..st.ny {
@ -1680,6 +1772,13 @@ pub fn specs() -> Vec<EffectSpec> {
p("water/surfaceColor", "Surface Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("water/surfaceOpacity", "Surface Opacity", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("water/causticsStrength", "Caustics Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("sky/sky", "Sky", Value::Layer(None), ParamUi::Layer),
p("sky/scaling", "Scaling", num(1.0), slider(0.01, 10.0, 0.1, 3.0, 3)),
p("sky/repeatMode", "Repeat Mode", Value::Enum(2), popup(&["Once", "Tiled", "Reflected"])),
p("sky/skySizeDiffers", "If Layer Size Differs", Value::Enum(1), popup(&["Center", "Stretch to Fit"])),
p("sky/intensity", "Intensity", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("sky/convergence", "Convergence", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("lighting/lightType", "Light Type", Value::Enum(0), popup(&crate::card3d::LIGHT_TYPES)),
p("lighting/lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)),
p("lighting/lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("lighting/lightPosition", "Light Position", pt(0.0, 0.0), ParamUi::Point),
@ -1697,9 +1796,13 @@ pub fn specs() -> Vec<EffectSpec> {
{
let mut v = vec![
p("view", "View", Value::Enum(0), popup(&["Wireframe Preview", "Height Map"])),
p("wireframeControls/horizontalRotation", "Horizontal Rotation", num(0.0), ParamUi::Angle),
p("wireframeControls/verticalRotation", "Vertical Rotation", num(53.0), slider(0.0, 90.0, 0.0, 90.0, 1)),
p("wireframeControls/verticalScale", "Vertical Scale", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)),
p("heightMapControls/brightness", "Brightness", num(0.5), slider(-1.0, 2.0, 0.0, 1.0, 3)),
p("heightMapControls/contrast", "Contrast", num(0.75), slider(0.0, 4.0, 0.0, 2.0, 3)),
p("heightMapControls/gamma", "Gamma Adjustment", num(1.0), slider(0.01, 10.0, 0.2, 5.0, 3)),
p("heightMapControls/renderDryAreasAs", "Render Dry Areas As", Value::Enum(0), popup(&["Solid", "Transparent"])),
p("heightMapControls/transparency", "Transparency", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("simulation/gridResolution", "Grid Resolution", num(60.0), slider(1.0, 400.0, 1.0, 200.0, 0)),
p("simulation/gridResDownsamples", "Grid Res Downsamples", Value::Bool(false), ParamUi::Checkbox),
@ -1707,6 +1810,10 @@ pub fn specs() -> Vec<EffectSpec> {
p("simulation/damping", "Damping", num(0.05), slider(0.0, 5.0, 0.0, 1.0, 3)),
p("simulation/reflectEdges", "Reflect Edges", Value::Enum(0), popup(&["None", "Left", "Top", "Right", "Bottom", "All"])),
p("simulation/preRoll", "Pre-roll (seconds)", num(0.0), slider(0.0, 30.0, 0.0, 10.0, 2)),
p("ground/ground", "Ground", Value::Layer(None), ParamUi::Layer),
p("ground/steepness", "Steepness", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 3)),
p("ground/height", "Height", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 3)),
p("ground/waveStrength", "Wave Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)),
];
v.extend(producer_params(1, 0.5, (0.5, 0.5)));
v.extend(producer_params(2, 0.0, (0.25, 0.25)));
@ -1941,6 +2048,57 @@ mod tests {
assert!(sum_diff(&b, &src) > 0.1);
}
#[test]
fn caustics_sky_and_light_types() {
let src = Image::filled(32, 32, [0.5, 0.5, 0.5, 1.0]);
let env = EffectEnv { host: Some(&FoamHost), ..Default::default() };
let run_h = |vals: &[(&str, Value)]| run_fx("ec.sim.caustics", vals, src.clone(), 0.0, env).img;
// A mirror-like surface shows the (green) sky.
let mirror = [("water/surfaceOpacity", num(1.0)), ("sky/sky", Value::Layer(Some(2))), ("sky/intensity", num(1.0))];
let p = run_h(&mirror).get(16, 16);
assert!(p[1] > p[0] + 0.3 && p[1] > p[2] + 0.3, "{p:?}");
// Point Source lights pixels by their own direction to the light: the image is no
// longer uniform; Distant Source lights the flat water evenly.
let lit = |t: u32| {
run_h(&[
("lighting/lightType", Value::Enum(t)),
("lighting/lightPosition", pt(0.0, 0.0)),
("lighting/lightHeight", num(0.2)),
("water/surfaceOpacity", num(0.0)),
])
};
let (distant, point) = (lit(0), lit(1));
assert!((distant.get(2, 2)[0] - distant.get(30, 30)[0]).abs() < 1e-5);
assert!(point.get(2, 2)[0] > point.get(30, 30)[0] + 0.02, "{:?} {:?}", point.get(2, 2), point.get(30, 30));
// First Comp Light without a comp light: ambient only.
let none = run_h(&[("lighting/lightType", Value::Enum(2)), ("water/surfaceOpacity", num(0.0))]);
assert!(none.get(16, 16)[0] < distant.get(16, 16)[0]);
}
#[test]
fn wave_world_wireframe_controls_ground_and_dry_areas() {
let base = [("producer1/producer1Position", pt(8.0, 16.0)), ("simulation/gridResolution", num(24.0))];
let wire = |extra: &[(&str, Value)]| run("ec.sim.waveworld", &[base.as_slice(), extra].concat(), Image::new(32, 32), 0.5);
assert_ne!(wire(&[]), wire(&[("wireframeControls/horizontalRotation", num(40.0))]));
assert_ne!(wire(&[]), wire(&[("wireframeControls/verticalScale", num(3.0))]));
// Ground (left dark, right light): the light side rises above the water and is dry.
let env = EffectEnv { host: Some(&FoamHost), ..Default::default() };
let hm = |extra: &[(&str, Value)]| {
let mut v: Vec<(&str, Value)> = base.to_vec();
v.extend([("view", Value::Enum(1)), ("ground/ground", Value::Layer(Some(1))), ("ground/steepness", num(0.5)), ("ground/height", num(0.25))]);
v.extend_from_slice(extra);
run_fx("ec.sim.waveworld", &v, Image::new(32, 32), 1.0, env).img
};
let solid = hm(&[]);
let clear = hm(&[("heightMapControls/renderDryAreasAs", Value::Enum(1))]);
assert!(solid.get(30, 16)[3] > 0.99 && clear.get(30, 16)[3] == 0.0);
assert!(clear.get(4, 16)[3] > 0.99, "wet side stays");
// Dry ground stays flat: mid grey (brightness 0.5) there.
assert!((solid.get(30, 16)[0] - 0.5).abs() < 1e-4);
// Wave Strength slows waves over shallow water: the surface differs.
assert_ne!(hm(&[]), hm(&[("ground/waveStrength", num(1.0))]));
}
#[test]
fn wave_world_seek_and_waves() {
let v = vec![("view", Value::Enum(1)), ("producer1/producer1Position", pt(16.0, 16.0)), ("simulation/gridResolution", num(24.0))];

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 2927 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 2942 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**: 299 implemented in full, 4 partial (what is missing is listed).
- **Status**: 301 implemented in full, 2 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`).
@ -329,11 +329,11 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| CC Snowfall | `ec.sim.ccsnowfall` | 19 | | 32 | Implemented |
| 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 |
| Caustics | `ec.sim.caustics` | 28 | | 32 | Implemented |
| 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 |
| Wave World | `ec.sim.waveworld` | 35 | | 32 | Implemented |
## Stylize