Files
effectcraft/crates/effects/src/generate.rs
T
Brandon ThomasandClaude Opus 5.5 c8623b1864 M12.7: GPU blur/transition/generate effects (radial, camera lens, CC radial fast, wipes, venetian, cell pattern, checkerboard, grid, 4-color, noise, add grain)
Kernels in fx_generate.wgsl mirror the CPU effects; wipe edges use f64
per-column/per-row terms uploaded as (hi, lo) pairs. CPU helpers shared
with the GPU are now public (camera_lens_spans, gen_mode, pattern_kind,
GrainLook, place_layer). GPU tests in tests_fx_generate.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 02:46:09 -04:00

478 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Generate and Noise effects that synthesise images.
use effectcraft_color::{BlendMode, blend_pixel};
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::Px;
use rayon::prelude::*;
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
/// Checkerboard / Grid "Size From" options.
pub(crate) const SIZE_FROM: [&str; 3] = ["Corner Point", "Width Slider", "Width & Height Sliders"];
fn spec(id: &'static str, name: &'static str, category: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category, params, render, gpu: false, float: true }
}
/// Blend a generated straight colour `g` (with alpha `ga`) over the original pixel.
fn put(px: &mut Px, g: [f32; 3], ga: f32, blend_orig: f32) {
let g2 = [g[0] * ga, g[1] * ga, g[2] * ga, ga];
let k = 1.0 - blend_orig;
for c in 0..4 {
px[c] = px[c] * blend_orig + g2[c] * k;
}
}
fn gradient_ramp(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = b.to_px(ctx.params.v2("start"));
let e = b.to_px(ctx.params.v2("end"));
let c0 = ctx.params.color("startColor");
let c1 = ctx.params.color("endColor");
let radial = ctx.params.e("shape") == 1;
let blend = ctx.params.f("blend") as f32 / 100.0;
let scatter = ctx.params.f("scatter") as f32 / 512.0;
let (dx, dy) = (e.0 - s.0, e.1 - s.1);
let len2 = (dx * dx + dy * dy).max(1e-9);
let seed = ctx.seed;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (vx, vy) = (x as f64 + 0.5 - s.0, y as f64 + 0.5 - s.1);
let mut t = if radial { ((vx * vx + vy * vy) / len2).sqrt() } else { (vx * dx + vy * dy) / len2 } as f32;
if scatter > 0.0 {
t += (effectcraft_raster::hash_noise(x as u32, y as u32, seed) - 0.5) * scatter;
}
let t = t.clamp(0.0, 1.0);
let c = [c0[0] + (c1[0] - c0[0]) * t, c0[1] + (c1[1] - c0[1]) * t, c0[2] + (c1[2] - c0[2]) * t];
put(px, c, 1.0, blend);
}
});
b
}
/// Blending Mode options of the shape generators (Checkerboard, Grid, Circle): "None" shows the
/// generated pattern only; the others composite it onto the layer like layer blending modes.
pub(crate) const GEN_BLEND_MODES: [&str; 20] = [
"None",
"Normal",
"Add",
"Multiply",
"Screen",
"Overlay",
"Soft Light",
"Hard Light",
"Color Dodge",
"Color Burn",
"Darken",
"Lighten",
"Difference",
"Exclusion",
"Hue",
"Saturation",
"Color",
"Luminosity",
"Stencil Alpha",
"Silhouette Alpha",
];
pub fn gen_mode(i: u32) -> Option<BlendMode> {
Some(match i {
0 => return None,
2 => BlendMode::Add,
3 => BlendMode::Multiply,
4 => BlendMode::Screen,
5 => BlendMode::Overlay,
6 => BlendMode::SoftLight,
7 => BlendMode::HardLight,
8 => BlendMode::ColorDodge,
9 => BlendMode::ColorBurn,
10 => BlendMode::Darken,
11 => BlendMode::Lighten,
12 => BlendMode::Difference,
13 => BlendMode::Exclusion,
14 => BlendMode::Hue,
15 => BlendMode::Saturation,
16 => BlendMode::Color,
17 => BlendMode::Luminosity,
18 => BlendMode::StencilAlpha,
19 => BlendMode::SilhouetteAlpha,
_ => BlendMode::Normal,
})
}
/// Composite a generated premultiplied pixel `g` onto the original `o` with blending mode
/// index `mode` (into [`GEN_BLEND_MODES`]).
#[inline]
pub(crate) fn gen_blend(o: Px, g: Px, mode: u32) -> Px {
match gen_mode(mode) {
None => g,
Some(m) => blend_pixel(m, o, g, 0.5),
}
}
fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pts: Vec<((f64, f64), [f32; 4])> =
(1..=4).map(|i| (b.to_px(ctx.params.v2(&format!("positionsColors/point{i}"))), ctx.params.color(&format!("positionsColors/color{i}")))).collect();
let blend = ctx.params.f("blend").max(1.0);
let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32;
let op = ctx.params.f("opacity") as f32 / 100.0;
let mode = ctx.params.e("blendingMode");
let seed = ctx.seed;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let mut acc = [0.0f64; 3];
let mut wsum = 0.0;
for ((qx, qy), c) in &pts {
let d2 = (x as f64 + 0.5 - qx).powi(2) + (y as f64 + 0.5 - qy).powi(2);
let w = 1.0 / (d2 / (blend * 100.0) + 1e-6).powf(1.5);
for i in 0..3 {
acc[i] += c[i] as f64 * w;
}
wsum += w;
}
let mut c = acc.map(|v| (v / wsum) as f32);
if jitter > 0.0 {
// Dither against banding: a few code values of noise.
let n = (effectcraft_raster::hash_noise(x as u32, y as u32, seed) - 0.5) * jitter * (8.0 / 255.0);
c = c.map(|v| v + n);
}
*px = gen_blend(*px, [c[0] * op, c[1] * op, c[2] * op, op], mode);
}
});
b
}
/// Cell size of Checkerboard / Grid from Size From (Corner Point, Width Slider, Width &
/// Height Sliders), in pixels.
fn cell_size(ctx: &EffectCtx, b: &Buf, anchor: (f64, f64)) -> (f64, f64) {
let (w, h) = match ctx.params.e("sizeFrom") {
0 => {
let c = b.to_px(ctx.params.v2("corner"));
((c.0 - anchor.0).abs(), (c.1 - anchor.1).abs())
}
1 => (ctx.params.f("width") * b.scale, ctx.params.f("width") * b.scale),
_ => (ctx.params.f("width") * b.scale, ctx.params.f("height") * b.scale),
};
(w.max(1.0), h.max(1.0))
}
fn checkerboard(ctx: &EffectCtx, mut b: Buf) -> Buf {
let anchor = b.to_px(ctx.params.v2("anchor"));
let (w, h) = cell_size(ctx, &b, anchor);
// Feather softens the transitions; 0 keeps a half-pixel antialiased edge.
let fw = (ctx.params.f("feather/featherWidth") * b.scale).max(1.0);
let fh = (ctx.params.f("feather/featherHeight") * b.scale).max(1.0);
let c = ctx.params.color("color");
let op = ctx.params.f("opacity") as f32 / 100.0;
let mode = ctx.params.e("blendingMode");
// Signed distance (in feather units) to the nearest cell edge, positive in even cells.
let axis = |p: f64, size: f64, f: f64| -> f64 {
let u = p / size;
let fr = u - u.floor();
let d = fr.min(1.0 - fr) * size;
let s = if (u.floor() as i64).rem_euclid(2) == 0 { 1.0 } else { -1.0 };
s * (d / (f * 0.5)).min(1.0)
};
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let sx = axis(x as f64 + 0.5 - anchor.0, w, fw);
let sy = axis(y as f64 + 0.5 - anchor.1, h, fh);
let cov = ((0.5 + 0.5 * sx * sy) as f32).clamp(0.0, 1.0) * op;
*px = gen_blend(*px, [c[0] * cov, c[1] * cov, c[2] * cov, cov], mode);
}
});
b
}
fn grid(ctx: &EffectCtx, mut b: Buf) -> Buf {
let anchor = b.to_px(ctx.params.v2("anchor"));
let (w, h) = cell_size(ctx, &b, anchor);
let border = (ctx.params.f("border") * b.scale).max(0.0);
let fw = (ctx.params.f("feather/featherWidth") * b.scale).max(1.0);
let fh = (ctx.params.f("feather/featherHeight") * b.scale).max(1.0);
let invert = ctx.params.b("invertGrid");
let c = ctx.params.color("color");
let op = ctx.params.f("opacity") as f32 / 100.0;
let mode = ctx.params.e("blendingMode");
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let gx = (x as f64 + 0.5 - anchor.0).rem_euclid(w);
let gy = (y as f64 + 0.5 - anchor.1).rem_euclid(h);
let (dx, dy) = (gx.min(w - gx), gy.min(h - gy));
let cx = ((border / 2.0 - dx) / fw + 0.5).clamp(0.0, 1.0);
let cy = ((border / 2.0 - dy) / fh + 0.5).clamp(0.0, 1.0);
let mut cov = if border <= 0.0 { 0.0 } else { cx.max(cy) as f32 };
if invert {
cov = 1.0 - cov;
}
let a = cov * op;
*px = gen_blend(*px, [c[0] * a, c[1] * a, c[2] * a, a], mode);
}
});
b
}
/// Circle Edge options.
pub(crate) const CIRCLE_EDGES: [&str; 5] = ["None", "Edge Radius", "Thickness", "Thickness * Radius", "Thickness & Feather * Radius"];
fn circle(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = b.to_px(ctx.params.v2("center"));
let r = ctx.params.f("radius") * b.scale;
let thick = ctx.params.f("thickness").max(0.0);
let mut f_out = ctx.params.f("feather/feather").max(0.0) * b.scale;
let mut f_in = ctx.params.f("feather/featherInner").max(0.0) * b.scale;
// Inner radius of the ring (None = solid disk).
let inner = match ctx.params.e("edge") {
1 => Some(ctx.params.f("edgeRadius") * b.scale),
2 => Some(r - thick * b.scale),
3 => Some(r - thick / 100.0 * r),
4 => {
f_out = ctx.params.f("feather/feather").max(0.0) / 100.0 * r;
f_in = ctx.params.f("feather/featherInner").max(0.0) / 100.0 * r;
Some(r - thick / 100.0 * r)
}
_ => None,
};
let (ro, ri) = match inner {
Some(i) => (r.max(i), Some(r.min(i).max(0.0))),
None => (r, None),
};
let (f_out, f_in) = (f_out.max(0.5), f_in.max(0.5));
let color = ctx.params.color("color");
let op = ctx.params.f("opacity") as f32 / 100.0;
let invert = ctx.params.b("invert");
let mode = ctx.params.e("blendingMode");
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let d = ((x as f64 + 0.5 - c.0).powi(2) + (y as f64 + 0.5 - c.1).powi(2)).sqrt();
let mut cov = ((ro - d) / f_out + 0.5).clamp(0.0, 1.0) as f32;
if let Some(ri) = ri {
cov *= ((d - ri) / f_in + 0.5).clamp(0.0, 1.0) as f32;
}
if invert {
cov = 1.0 - cov;
}
let a = cov * op;
*px = gen_blend(*px, [color[0] * a, color[1] * a, color[2] * a, a], mode);
}
});
b
}
// ---- value noise / fBm (our own lattice noise with quintic fade) ----
#[inline]
fn lattice(ix: i32, iy: i32, iz: i32, seed: u32) -> f32 {
let mut h =
(ix as u32).wrapping_mul(0x27d4_eb2d) ^ (iy as u32).wrapping_mul(0x1656_67b1) ^ (iz as u32).wrapping_mul(0x9e37_79b9) ^ seed.wrapping_mul(0x85eb_ca6b);
h ^= h >> 15;
h = h.wrapping_mul(0x2c1b_3c6d);
h ^= h >> 12;
h = h.wrapping_mul(0x297a_2d39);
h ^= h >> 15;
(h & 0xffff) as f32 / 65535.0
}
#[inline]
fn fade(t: f32) -> f32 {
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
}
/// 3D value noise in 0..1 (z = evolution).
pub fn value_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 {
let (x0, y0, z0) = (x.floor(), y.floor(), z.floor());
let (fx, fy, fz) = (fade(x - x0), fade(y - y0), fade(z - z0));
let (ix, iy, iz) = (x0 as i32, y0 as i32, z0 as i32);
let l = |a: f32, b: f32, t: f32| a + (b - a) * t;
let c = |dx: i32, dy: i32, dz: i32| lattice(ix + dx, iy + dy, iz + dz, seed);
let a = l(l(c(0, 0, 0), c(1, 0, 0), fx), l(c(0, 1, 0), c(1, 1, 0), fx), fy);
let b = l(l(c(0, 0, 1), c(1, 0, 1), fx), l(c(0, 1, 1), c(1, 1, 1), fx), fy);
l(a, b, fz)
}
// Fractal Noise lives in `noise3` (with Turbulent Noise).
pub fn specs() -> Vec<EffectSpec> {
let pt = |x, y| Value::Vec2([x, y]);
vec![
spec(
"ec.generate.gradientramp",
"Gradient Ramp",
"Generate",
vec![
p("start", "Start of Ramp", pt(0.5, 0.0), ParamUi::Point),
p("startColor", "Start Color", col(0.0, 0.0, 0.0), ParamUi::Color),
p("end", "End of Ramp", pt(0.5, 1.0), ParamUi::Point),
p("endColor", "End Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("shape", "Ramp Shape", Value::Enum(0), popup(&["Linear Ramp", "Radial Ramp"])),
p("scatter", "Ramp Scatter", num(0.0), slider(0.0, 512.0, 0.0, 512.0, 1)),
p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
gradient_ramp,
),
spec(
"ec.generate.fourcolor",
"4-Color Gradient",
"Generate",
vec![
p("positionsColors/point1", "Point 1", pt(0.1, 0.1), ParamUi::Point),
p("positionsColors/color1", "Color 1", col(1.0, 1.0, 0.0), ParamUi::Color),
p("positionsColors/point2", "Point 2", pt(0.9, 0.1), ParamUi::Point),
p("positionsColors/color2", "Color 2", col(0.0, 1.0, 0.0), ParamUi::Color),
p("positionsColors/point3", "Point 3", pt(0.1, 0.9), ParamUi::Point),
p("positionsColors/color3", "Color 3", col(1.0, 0.0, 1.0), ParamUi::Color),
p("positionsColors/point4", "Point 4", pt(0.9, 0.9), ParamUi::Point),
p("positionsColors/color4", "Color 4", col(0.0, 0.0, 1.0), ParamUi::Color),
p("blend", "Blend", num(100.0), slider(1.0, 1000.0, 1.0, 1000.0, 1)),
p("jitter", "Jitter", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("blendingMode", "Blending Mode", Value::Enum(0), popup(&GEN_BLEND_MODES[..18])),
],
four_color,
),
spec(
"ec.generate.checkerboard",
"Checkerboard",
"Generate",
vec![
p("anchor", "Anchor", pt(0.0, 0.0), ParamUi::Point),
p("sizeFrom", "Size From", Value::Enum(1), popup(&SIZE_FROM)),
p("corner", "Corner", pt(0.1, 0.1), ParamUi::Point),
p("width", "Width", num(64.0), slider(1.0, 4000.0, 1.0, 400.0, 1)),
p("height", "Height", num(64.0), slider(1.0, 4000.0, 1.0, 400.0, 1)),
p("feather/featherWidth", "Width", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 1)),
p("feather/featherHeight", "Height", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 1)),
p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("blendingMode", "Blending Mode", Value::Enum(0), popup(&GEN_BLEND_MODES)),
],
checkerboard,
),
spec(
"ec.generate.grid",
"Grid",
"Generate",
vec![
p("anchor", "Anchor", pt(0.0, 0.0), ParamUi::Point),
p("sizeFrom", "Size From", Value::Enum(1), popup(&SIZE_FROM)),
p("corner", "Corner", pt(0.1, 0.1), ParamUi::Point),
p("width", "Width", num(64.0), slider(1.0, 4000.0, 1.0, 400.0, 1)),
p("height", "Height", num(64.0), slider(1.0, 4000.0, 1.0, 400.0, 1)),
p("border", "Border", num(2.0), slider(0.0, 400.0, 0.0, 40.0, 1)),
p("feather/featherWidth", "Width", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 1)),
p("feather/featherHeight", "Height", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 1)),
p("invertGrid", "Invert Grid", Value::Bool(false), ParamUi::Checkbox),
p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("blendingMode", "Blending Mode", Value::Enum(0), popup(&GEN_BLEND_MODES)),
],
grid,
),
spec(
"ec.generate.circle",
"Circle",
"Generate",
vec![
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
p("radius", "Radius", num(75.0), slider(0.0, 4000.0, 0.0, 1000.0, 1)),
p("edge", "Edge", Value::Enum(0), popup(&CIRCLE_EDGES)),
p("edgeRadius", "Edge Radius", num(0.0), slider(0.0, 4000.0, 0.0, 1000.0, 1)),
p("thickness", "Thickness", num(10.0), slider(0.0, 4000.0, 0.0, 100.0, 1)),
p("feather/feather", "Feather Outer Edge", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("feather/featherInner", "Feather Inner Edge", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("invert", "Invert Circle", Value::Bool(false), ParamUi::Checkbox),
p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("blendingMode", "Blending Mode", Value::Enum(0), popup(&GEN_BLEND_MODES)),
],
circle,
),
]
}
#[cfg(test)]
mod generator_tests {
use super::*;
use crate::{EffectEnv, run_fx};
use effectcraft_raster::Image;
fn red() -> Image {
Image::filled(32, 32, [1.0, 0.0, 0.0, 1.0])
}
#[test]
fn checkerboard_blending_none_shows_pattern_only_and_size_from_width() {
let set = [("width", num(8.0)), ("height", num(2.0))];
// None (default): the off squares are transparent; Width Slider makes 8×8 squares.
let o = run_fx("ec.generate.checkerboard", &set, red(), 0.0, EffectEnv::default());
assert!(o.img.get(3, 3)[3] > 0.99 && o.img.get(3, 3)[1] > 0.99, "{:?}", o.img.get(3, 3));
assert!(o.img.get(11, 3)[3] < 0.01);
assert!(o.img.get(3, 6)[3] > 0.99, "height ignored with Width Slider");
// Width & Height Sliders: 8×2 cells; Normal keeps the layer under the off squares.
let o = run_fx(
"ec.generate.checkerboard",
&[set[0].clone(), set[1].clone(), ("sizeFrom", Value::Enum(2)), ("blendingMode", Value::Enum(1))],
red(),
0.0,
EffectEnv::default(),
);
assert!(o.img.get(3, 2)[0] > 0.99 && o.img.get(3, 2)[1] < 0.01, "second row of cells is off");
// Corner Point: cell size from the anchor-corner rectangle.
let o = run_fx("ec.generate.checkerboard", &[("sizeFrom", Value::Enum(0)), ("corner", Value::Vec2([4.0, 4.0]))], red(), 0.0, EffectEnv::default());
assert!(o.img.get(1, 1)[3] > 0.99 && o.img.get(5, 1)[3] < 0.01);
}
#[test]
fn grid_invert_and_stencil() {
let set = [("width", num(10.0)), ("border", num(2.0))];
let o = run_fx("ec.generate.grid", &set, red(), 0.0, EffectEnv::default());
assert!(o.img.get(0, 5)[3] > 0.9, "line at the anchor");
assert!(o.img.get(5, 5)[3] < 0.01, "cell interior is empty with None");
let o = run_fx("ec.generate.grid", &[set[0].clone(), set[1].clone(), ("invertGrid", Value::Bool(true))], red(), 0.0, EffectEnv::default());
assert!(o.img.get(5, 5)[3] > 0.99 && o.img.get(0, 5)[3] < 0.1);
// Stencil Alpha: the layer shows only through the grid lines.
let o = run_fx("ec.generate.grid", &[set[0].clone(), set[1].clone(), ("blendingMode", Value::Enum(18))], red(), 0.0, EffectEnv::default());
assert!(o.img.get(0, 5)[0] > 0.9 && o.img.get(5, 5)[3] < 0.01);
}
#[test]
fn circle_edge_makes_a_ring() {
let c = ("center", Value::Vec2([16.0, 16.0]));
let disk = run_fx("ec.generate.circle", &[c.clone(), ("radius", num(10.0))], red(), 0.0, EffectEnv::default());
assert!(disk.img.get(16, 16)[3] > 0.99 && disk.img.get(30, 16)[3] < 0.01);
let ring = run_fx(
"ec.generate.circle",
&[c.clone(), ("radius", num(10.0)), ("edge", Value::Enum(2)), ("thickness", num(3.0))],
red(),
0.0,
EffectEnv::default(),
);
assert!(ring.img.get(16, 16)[3] < 0.01, "hole in the middle");
assert!(ring.img.get(24, 16)[3] > 0.99, "{:?}", ring.img.get(24, 16));
let er =
run_fx("ec.generate.circle", &[c, ("radius", num(10.0)), ("edge", Value::Enum(1)), ("edgeRadius", num(5.0))], red(), 0.0, EffectEnv::default());
assert!(er.img.get(16, 16)[3] < 0.01 && er.img.get(23, 16)[3] > 0.99);
}
#[test]
fn four_color_blending_mode_and_opacity() {
let pts: Vec<(&str, Value)> = (1..=4)
.map(|i| match i {
1 => ("positionsColors/point1", Value::Vec2([0.0, 0.0])),
2 => ("positionsColors/point2", Value::Vec2([32.0, 0.0])),
3 => ("positionsColors/point3", Value::Vec2([0.0, 32.0])),
_ => ("positionsColors/point4", Value::Vec2([32.0, 32.0])),
})
.collect();
let mut half = pts.clone();
half.push(("opacity", num(50.0)));
// None: the gradient alone at half opacity.
let o = run_fx("ec.generate.fourcolor", &half, red(), 0.0, EffectEnv::default());
assert!((o.img.get(16, 16)[3] - 0.5).abs() < 1e-3);
// Normal: over the opaque layer.
half.push(("blendingMode", Value::Enum(1)));
let o = run_fx("ec.generate.fourcolor", &half, red(), 0.0, EffectEnv::default());
assert!((o.img.get(16, 16)[3] - 1.0).abs() < 1e-3);
}
}