Files
effectcraft/crates/gpu/src/fx_generate.rs
T
Valentin KleinandClaude Opus 5.5 7051fc1f58 Color Stabilizer and Linear Wipe: match After Effects (sample positions, feather) (#3135)
* Color Stabilizer: sample the reference where the points were at the reference time (#3125)

After Effects 2026 samples the reference frame at the positions the
Black / Mid / White Points had at the reference time. With keyframed
points it therefore corrects even a static picture: our importer sample
(colstab-43-keys, Levels, Black Point (64, 90) -> (151, 99) over 1 s)
renders at 0.5 s as the Levels map out = a + b*v of the ramp with
b 1.2935..1.2965 and a -0.2365..-0.2350, which is the Black Point's
current sample (107.5, 94.5) sent to the reference sample at (64, 90).

EffectCraft sampled both frames at the current points, so the card
stayed unchanged (slope 1). The samples now read the points at the
reference time through the host (params_at), falling back to the
current points without one. CPU and GPU share the sampler
(color_stabilizer_maps).

Sample Size's unit is not pinned by the samples (every size 1..40 leaves
the card unchanged in After Effects; that only bounds the sampled area)
and is left as it is.

Part of #3125

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Linear Wipe: After Effects' edge position and feather profile (#3125)

Measured on our importer samples (linwipe-*, 2026-11-05, After Effects
2026, 320 x 180):

- The edge sits at lo + completion * (hi - lo) whatever the feather (the
  set-all render's 50 % line is at -5.25, predicted -5.266); 0 % keeps
  the layer and 100 % clears it even with Feather 32000. EffectCraft
  extended the travel by the feather, 13 px off at Feather 16.
- The feathered edge is the hard matte blurred with the layer's edges
  repeated: three box passes of radius 6 per axis fit Feather 16
  (sigma 6.35..6.6, taken as Feather / 2.5), including the extra
  opacity where the edge meets the layer's left side. EffectCraft drew
  a linear ramp Feather px wide.
- Without feather the edge pixel is anti-aliased (143/255; coverage
  gives 0.6). EffectCraft drew a hard step.

set-all alpha: before max 195, 4646 px (8.1 %) off by more than 2/255;
after max 15, 529 px (0.9 %), all along the layer's left side (at most
2 elsewhere). Feather 0 renders: max 112 -> 10 on the edge column.

CPU: coverage matte, raster::gaussian_blur (3 box passes, edges
repeated); GPU: the same matte from gen_linear_wipe (new modes), its
gaussian_blur, then a multiply, so both match. Sigma is capped at twice
the layer's longer side; NaN values leave the layer as it is.

Projects saved before keep the old ramp: a hidden legacyFeather
parameter that migrate::legacy_default sets on upgrade (as for Ripple
and Block Dissolve).

Part of #3125

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Linear Wipe on the GPU: 100 % clears the layer before the angle is read

A NaN angle left the layer unchanged on the GPU while the CPU cleared it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:36:48 +01:00

436 lines
18 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.
//! GPU effects, blur, transition and generate family (kernels in `shaders/fx_generate.wgsl`): the CPU effects' exact
//! steps with the pixel loops as compute kernels.
//!
//! Every effect here covers all of its parameters. Where the CPU works in f64 around a hard
//! edge (the wipes), the per-column and per-row terms are computed in f64 here and uploaded as
//! (hi, lo) f32 pairs so the GPU puts the edge where the CPU does.
use effectcraft_color::BlendMode;
use effectcraft_effects::{EffectCtx, GrainLook};
use crate::context::{Enc, Params};
use crate::effects::{GBuf, gaussian_blur};
/// Compute entry points in `fx_generate.wgsl`.
pub(crate) const KERNELS: &[&str] = &[
"gen_radial_blur",
"gen_ccradialfast",
"gen_lens_pre",
"gen_row_prefix",
"gen_lens_blur",
"gen_linear_wipe",
"gen_venetian",
"gen_radial_wipe",
"gen_gradient_wipe",
"gen_cell_pattern",
"gen_checker",
"gen_grid",
"gen_fourcolor",
"gen_noise",
"gen_grain_planes",
"gen_grain_apply",
];
/// Effect ids implemented here.
pub(crate) const IDS: &[&str] = &[
"ec.blur.radial",
"ec.blur.cameralens",
"ec.blur.ccradialfast",
"ec.transition.venetian",
"ec.transition.linearwipe",
"ec.transition.radialwipe",
"ec.transition.gradientwipe",
"ec.generate.cellpattern",
"ec.generate.checkerboard",
"ec.generate.grid",
"ec.generate.fourcolor",
"ec.noise.noise",
"ec.noise.addgrain",
];
/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU.
pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
match id {
"ec.blur.radial" => radial_blur(e, ctx, b),
"ec.blur.cameralens" => camera_lens(e, ctx, b),
"ec.blur.ccradialfast" => radial_fast(e, ctx, b),
"ec.transition.venetian" => venetian(e, ctx, b),
"ec.transition.linearwipe" => linear_wipe(e, ctx, b),
"ec.transition.radialwipe" => radial_wipe(e, ctx, b),
"ec.transition.gradientwipe" => gradient_wipe(e, ctx, b),
"ec.generate.cellpattern" => cell_pattern(e, ctx, b),
"ec.generate.checkerboard" => checkerboard(e, ctx, b),
"ec.generate.grid" => grid(e, ctx, b),
"ec.generate.fourcolor" => four_color(e, ctx, b),
"ec.noise.noise" => noise(e, ctx, b),
"ec.noise.addgrain" => add_grain(e, ctx, b),
_ => None,
}
}
/// Run a per-pixel kernel over the buffer.
fn run(e: &mut Enc, entry: &str, p: &Params, b: GBuf, aux: Option<&crate::context::GpuImage>, data: Option<&[f32]>) -> Option<GBuf> {
let buf = data.map(|d| e.data(d));
let out = e.scratch(b.img.width, b.img.height);
e.pixels(entry, p, &b.img, aux, &out, buf.as_ref());
Some(GBuf { img: out, ..b })
}
/// `v` as an f32 (hi, lo) pair.
fn hl(v: f64) -> [f32; 2] {
let h = v as f32;
[h, (v - h as f64) as f32]
}
/// Generate blending mode index → `BlendMode::ALL` index (255 = None: replace).
pub(crate) fn gen_mode(i: u32) -> u32 {
match effectcraft_effects::gen_mode(i) {
None => 255,
Some(m) => BlendMode::ALL.iter().position(|&x| x == m).unwrap_or(0) as u32,
}
}
// ---------------------------------------------------------------- blurs
fn radial_blur(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let amt = ctx.params.f("amount");
let zoom = ctx.params.e("type") == 1;
let c = b.to_px(ctx.params.v2("center"));
let amount = if zoom { amt / 100.0 } else { amt };
if amount.abs() < 1e-6 {
return Some(b);
}
let n = 32usize;
let mut data = Vec::with_capacity(2 * n);
for i in 0..n {
let t = i as f64 / (n - 1) as f64 - 0.5;
if zoom {
data.push((1.0 + t * amount) as f32);
} else {
let (s, c) = (t * amount).to_radians().sin_cos();
data.extend([s as f32, c as f32]);
}
}
let mut p = Params::default();
p.u[0] = [zoom as u32, n as u32, 0, 0];
p.f[0] = [c.0 as f32, c.1 as f32, 0.0, 0.0];
run(e, "gen_radial_blur", &p, b, None, Some(&data))
}
fn radial_fast(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let s = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) * 0.5;
if s <= 0.0 {
return Some(b);
}
if !ctx.adjustment {
b.pad(e, effectcraft_effects::radial_fast_pad(b.to_px(ctx.params.v2("center")), b.img.width, b.img.height, s))?;
}
let c = b.to_px(ctx.params.v2("center"));
let mut p = Params::default();
p.u[0][0] = ctx.params.e("zoom");
p.f[0] = [c.0 as f32, c.1 as f32, s as f32, 0.0];
run(e, "gen_ccradialfast", &p, b, None, None)
}
fn camera_lens(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let r = ctx.params.f("blurRadius").max(0.0) * b.scale;
if r < 0.5 {
return Some(b);
}
let (spans, reach) = effectcraft_effects::camera_lens_spans(ctx, r);
let repeat = ctx.params.b("repeatEdge") || ctx.adjustment;
if !repeat {
b.pad(e, reach.ceil() as u32 + 1)?;
}
let linear = ctx.params.b("useLinear");
let thr = ctx.params.f("highlight/specularThreshold") as f32 / 255.0;
let boost = 1.0 + ctx.params.f("highlight/specularBrightness").max(0.0) as f32 / 100.0 * 4.0;
let sat = (ctx.params.f("highlight/highlightSaturation") / 100.0).clamp(0.0, 1.0) as f32;
let (w, h) = (b.img.width, b.img.height);
let mut src = b.img.clone();
if linear || boost > 1.0 {
let mut p = Params::default();
p.u[0] = [linear as u32, (boost > 1.0) as u32, 0, 0];
p.f[0] = [thr, boost, sat, 0.0];
let out = e.scratch(w, h);
e.pixels("gen_lens_pre", &p, &src, None, &out, None);
src = out;
}
let count: i64 = spans.iter().map(|(_, l, r)| r - l + 1).sum();
let norm = 1.0 / count as f64;
// Large irises sum each span from per-row prefix sums; small ones add the pixels directly.
let prefix = count > 256;
let pre = if prefix {
if !e.g.fits(w + 1, h) {
return None;
}
let pre = e.image(w + 1, h);
e.dispatch("gen_row_prefix", &Params::default(), crate::context::KernelImages { src: &src, aux: None, out: &pre }, None, (h.div_ceil(64), 1));
Some(pre)
} else {
None
};
let data: Vec<f32> = spans.iter().flat_map(|&(dy, l, r)| [dy as f32, l as f32, r as f32]).collect();
let mut p = Params::default();
p.u[0] = [spans.len() as u32, repeat as u32, linear as u32, prefix as u32];
p.f[0][0] = norm as f32;
let buf = e.data(&data);
let out = e.scratch(w, h);
e.pixels("gen_lens_blur", &p, &src, pre.as_ref(), &out, Some(&buf));
b.img = out;
Some(b)
}
// ---------------------------------------------------------------- transitions
/// Linear Wipe (`effects::misc::linear_wipe`): the hard matte at `lo + completion·(hi − lo)`
/// blurred by Feather with the layer's edges repeated, or the legacy linear ramp.
fn linear_wipe(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let done = ctx.params.f("completion") / 100.0;
let (w, h) = (b.img.width as f64, b.img.height as f64);
let ([dx, dy], lo, hi) = effectcraft_effects::linear_wipe_axis(ctx.params.f("angle"), w, h);
// Per column ((x + 0.5)·dx − edge), per row ((y + 0.5)·dy), as (hi, lo) pairs.
let table = |edge: f64| {
let mut data = Vec::with_capacity(2 * (b.img.width + b.img.height) as usize);
for x in 0..b.img.width {
data.extend(hl((x as f64 + 0.5) * dx - edge));
}
for y in 0..b.img.height {
data.extend(hl((y as f64 + 0.5) * dy));
}
data
};
let params = |width: f64, mode: u32| {
let mut p = Params::default();
p.u[0][0] = 2 * b.img.width;
p.u[0][1] = mode;
p.f[0][0] = width as f32;
p
};
if ctx.params.b("legacyFeather") {
let feather = (ctx.params.f("feather") * b.scale).max(0.001);
let data = table(lo - feather + (hi - lo + 2.0 * feather) * done);
return run(e, "gen_linear_wipe", &params(feather, 0), b, None, Some(&data));
}
if done.is_nan() || done <= 0.0 {
return Some(b);
}
// 100 % clears the layer whatever the angle (the CPU clears before it reads it).
if done >= 1.0 {
b.img = e.image(b.img.width, b.img.height);
return Some(b);
}
let edge = lo + (hi - lo) * done;
if !(edge.is_finite() && dx.is_finite() && dy.is_finite()) {
return Some(b);
}
let data = table(edge);
let sigma = effectcraft_effects::linear_wipe_sigma(ctx.params.f("feather"), b.scale, w, h);
if sigma <= 1e-3 {
return run(e, "gen_linear_wipe", &params(1.0, 0), b, None, Some(&data));
}
let buf = e.data(&data);
let matte = e.scratch(b.img.width, b.img.height);
e.pixels("gen_linear_wipe", &params(1.0, 1), &b.img, None, &matte, Some(&buf));
let blurred = gaussian_blur(e, &matte, sigma, sigma, true);
run(e, "gen_linear_wipe", &params(1.0, 2), b, Some(&blurred), Some(&data))
}
fn venetian(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0);
if done <= 0.0 {
return Some(b);
}
let ang = ctx.params.f("direction").to_radians();
let width = (ctx.params.f("width") * b.scale).max(1.0);
let sigma = ctx.params.f("feather").max(0.0) * b.scale / 2.0;
// The CPU's even veil and term count (effects::venetian_cover).
if sigma > 8.0 * width {
return None;
}
let (dx, dy) = (ang.cos(), ang.sin());
let (ox, oy) = b.to_px(effectcraft_effects::venetian_origin(ctx.layer_size));
let mut data = Vec::with_capacity((4 * b.img.width + 2 * b.img.height) as usize);
for x in 0..b.img.width {
let cx = ((x as f64 + 0.5 - ox) * dx).rem_euclid(width);
data.extend(hl(cx));
data.extend(hl(cx - width));
}
for y in 0..b.img.height {
data.extend(hl(((y as f64 + 0.5 - oy) * dy).rem_euclid(width)));
}
let mut p = Params::default();
p.u[0][0] = 4 * b.img.width;
p.u[0][1] = if sigma < 1e-3 { 0 } else { ((4.0 * sigma / width).ceil() as u32 + 1).clamp(1, 40) };
p.f[0] = [width as f32, (done * width) as f32, sigma as f32, 0.0];
run(e, "gen_venetian", &p, b, None, Some(&data))
}
fn radial_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0);
if done <= 0.0 {
return Some(b);
}
let start = ctx.params.f("startAngle");
let c = b.to_px(ctx.params.v2("center"));
let (w0, span) = effectcraft_effects::radial_wipe_wedge(ctx.params.e("wipe"), done);
let sigma = effectcraft_effects::radial_wipe_sigma(ctx.params.f("feather")) * b.scale;
let ray0 = start + w0;
let (s0, c0) = ray0.to_radians().sin_cos();
let (s1, c1) = (ray0 + span).to_radians().sin_cos();
let mut p = Params::default();
p.u[0] = [(done >= 1.0) as u32, (sigma < 1e-3) as u32, 0, 0];
p.f[0] = [c.0 as f32, c.1 as f32, sigma.max(1e-3) as f32, ray0.rem_euclid(360.0) as f32];
p.f[1] = [s0 as f32, c0 as f32, s1 as f32, c1 as f32];
p.f[2][0] = span as f32;
run(e, "gen_radial_wipe", &p, b, None, None)
}
fn gradient_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0) as f32;
if done <= 0.0 {
return Some(b);
}
let soft = (ctx.params.f("softness") / 100.0) as f32;
// The gradient layer is fetched and placed on the CPU (as the CPU effect does).
let grad = match ctx.layer_param("gradientLayer", false) {
Some(o) => {
// place_layer reads only the buffer's size, offset and scale.
let img = effectcraft_raster::Image { width: b.img.width, height: b.img.height, data: vec![] };
let cpu = effectcraft_effects::Buf { img, offset: b.offset, scale: b.scale };
let img = effectcraft_effects::place_layer(ctx, &cpu, &o, ctx.params.e("gradientPlacement"));
Some(e.g.upload_image(&img)?)
}
None => None,
};
let mut p = Params::default();
p.u[0][0] = ctx.params.b("invert") as u32;
p.f[0] = [done, soft, 0.0, 0.0];
let out = e.scratch(b.img.width, b.img.height);
e.pixels("gen_gradient_wipe", &p, &b.img, Some(grad.as_ref().unwrap_or(&b.img)), &out, None);
Some(GBuf { img: out, ..b })
}
// ---------------------------------------------------------------- generate
fn cell_pattern(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let pr = ctx.params;
let size = (pr.f("size") * b.scale).max(1.0);
let off = b.to_px(pr.v2("offset"));
let mut evo = pr.f("evolution") / 360.0;
if pr.b("evolutionOptions/cycleEvolution") {
evo = evo.rem_euclid(pr.f("evolutionOptions/cycle").round().max(1.0));
}
let seed = (pr.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(7919) ^ 0xce11;
let tile = pr.b("tilingOptions/enableTiling");
let nx = pr.f("tilingOptions/cellsHorizontal").round().max(1.0) as i64;
let ny = pr.f("tilingOptions/cellsVertical").round().max(1.0) as i64;
if nx > i32::MAX as i64 || ny > i32::MAX as i64 {
return None;
}
let mut p = Params::default();
p.u[0] = [effectcraft_effects::cell_pattern_kind(pr.e("cellPattern")), pr.e("overflow"), pr.b("invert") as u32, seed];
p.u[1] = [tile as u32, nx as u32, ny as u32, 0];
// cos / sin of 2π·(h + evo) only see evo modulo 1.
p.f[0] = [pr.f("contrast") as f32 / 100.0, pr.f("disperse").clamp(0.0, 1.5) as f32, size as f32, evo.rem_euclid(1.0) as f32];
p.f[1] = [(0.5 - off.0) as f32, (0.5 - off.1) as f32, 0.0, 0.0];
run(e, "gen_cell_pattern", &p, b, None, None)
}
/// generate::cell_size.
fn cell_size(ctx: &EffectCtx, b: &GBuf, 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(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let anchor = b.to_px(ctx.params.v2("anchor"));
let (w, h) = cell_size(ctx, &b, anchor);
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 mut p = Params::default();
p.u[0][0] = gen_mode(ctx.params.e("blendingMode"));
p.f[0] = [(0.5 - anchor.0) as f32, (0.5 - anchor.1) as f32, w as f32, h as f32];
p.f[1] = [fw as f32, fh as f32, ctx.params.f("opacity") as f32 / 100.0, 0.0];
p.f[2] = ctx.params.color("color");
run(e, "gen_checker", &p, b, None, None)
}
fn grid(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
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 mut p = Params::default();
p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.params.b("invertGrid") as u32, 0, 0];
p.f[0] = [(0.5 - anchor.0) as f32, (0.5 - anchor.1) as f32, w as f32, h as f32];
p.f[1] = [fw as f32, fh as f32, ctx.params.f("opacity") as f32 / 100.0, border as f32];
p.f[2] = ctx.params.color("color");
run(e, "gen_grid", &p, b, None, None)
}
fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let mut p = Params::default();
for i in 0..4 {
let q = b.to_px(ctx.params.v2(&format!("positionsColors/point{}", i + 1)));
p.f[i] = [q.0 as f32, q.1 as f32, 0.0, 0.0];
p.f[4 + i] = ctx.params.color(&format!("positionsColors/color{}", i + 1));
}
let k = effectcraft_effects::four_color_softening(ctx.params.f("blend"), b.scale);
let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32;
p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.seed, 0, 0];
p.f[8] = [k as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0];
run(e, "gen_fourcolor", &p, b, None, None)
}
// ---------------------------------------------------------------- noise
fn noise(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let amt = ctx.params.f("amount") as f32 / 100.0;
let clip = ctx.params.get("clip").is_none_or(|v| v.as_bool());
let frame_seed = (ctx.time * 1000.0) as u32 ^ ctx.seed;
let mut p = Params::default();
p.u[0] = [frame_seed, ctx.params.b("color") as u32, clip as u32, b.img.width];
p.f[0][0] = amt;
run(e, "gen_noise", &p, b, None, None)
}
fn add_grain(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let look = GrainLook::from_params(ctx);
if look.intensity <= 0.0 {
return Some(b);
}
let (w, h) = (b.img.width, b.img.height);
// GrainLook::planes.
let size = look.size * b.scale as f32;
let s = |k: usize| (size * if look.mono { 1.0 } else { look.channel_size[k].max(0.05) }).max(0.05);
let mut p = Params::default();
p.u[0] = [look.mono as u32, look.seed, 101, 0];
p.f[0] = [s(0) * look.aspect, s(1) * look.aspect, s(2) * look.aspect, look.frame];
p.f[1] = [s(0), s(1), s(2), 0.0];
let mut planes = e.image(w, h);
e.pixels("gen_grain_planes", &p, &b.img, None, &planes, None);
let soft = look.softness * b.scale;
if soft > 0.05 {
planes = gaussian_blur(e, &planes, soft * look.aspect as f64, soft, true);
}
// GrainLook::color's tint factors.
let l = effectcraft_color::luminance(look.tint[0], look.tint[1], look.tint[2]).max(1e-3);
let tint = [0, 1, 2].map(|k| (1.0 - look.tint_amount) + look.tint_amount * look.tint[k] / l);
let mut p = Params::default();
p.u[0] = [look.mono as u32, look.mode, (look.tint_amount > 0.0) as u32, 0];
p.f[0] = [look.intensity * 0.25, look.saturation, look.midpoint, 0.0];
p.f[1] = [tint[0], tint[1], tint[2], 0.0];
p.f[2] = [look.channel_intensity[0], look.channel_intensity[1], look.channel_intensity[2], 0.0];
p.f[3] = [look.tones[0], look.tones[1], look.tones[2], 0.0];
run(e, "gen_grain_apply", &p, b, Some(&planes), None)
}