Files
effectcraft/crates/gpu/src/fx_tone.rs
T
Brandon ThomasandClaude Opus 5.5 9ff0e5aa3c M13.13: gpu texture pool, Auto per-comp backend choice, warp-family GPU ports
- gpu: pooled working textures (released once every encoder that could read
  them is submitted), shared zero images, reused readback staging buffers,
  direct readback into Image; NaN-poisoned scratch images under test and
  NaN-aware diffs; validation errors panic under test
- render: AutoPick (per comp / scale / path EWMA of CPU vs GPU frame times,
  warm-up and periodic re-probe) behind Backend::Auto; viewer uses it too;
  cached adjustment footprints (uploaded once)
- gpu fx_warp: Warp, Bezier Warp, CC Bend It, CC Page Turn, Smear, Reshape,
  Color Emboss, Cartoon; sliding-histogram median for large radii; CC Power
  Pin perspective < 100
- cli: bench --gpu reports Auto and per-frame upload / readback MB

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

382 lines
17 KiB
Rust

//! GPU effects, tonal and colour correction family (kernels in `shaders/fx_tone.wgsl`, every
//! entry point prefixed `fxt_`): one per-pixel pass each (`fxt_point`) with the CPU effect's
//! exact maths.
//!
//! Levels (Individual Controls), Gamma/Pedestal/Gain, Photo Filter, Change Color, Change to
//! Color, Leave Color, Broadcast Colors, Color Balance (HLS), Video Limiter, PS Arbitrary Map,
//! CC Toner, CC Color Offset and CC Kernel, plus Hue/Saturation's colour ranges and Levels'
//! per-channel controls (the parts the base kernels in `kernels.wgsl` leave to the CPU). Auto
//! Levels / Contrast / Color and Equalize measure the frame's histograms on the CPU (the image
//! is read back for that) and apply the correction on the GPU.
use effectcraft_color::rgb_to_hsl;
use effectcraft_effects::EffectCtx;
use crate::context::{Enc, Params};
use crate::effects::{GBuf, gaussian_blur};
/// Compute entry points in `fx_tone.wgsl`.
pub(crate) const KERNELS: &[&str] = &["fxt_point"];
/// Effect ids implemented here.
pub(crate) const IDS: &[&str] = &[
"ec.color.levelsic",
"ec.color.gammapedestalgain",
"ec.color.photofilter",
"ec.color.changecolor",
"ec.color.changetocolor",
"ec.color.leavecolor",
"ec.color.broadcast",
"ec.color.colorbalancehls",
"ec.color.videolimiter",
"ec.color.psarbitrarymap",
"ec.color.cctoner",
"ec.color.cccoloroffset",
"ec.color.cckernel",
"ec.color.autolevels",
"ec.color.autocontrast",
"ec.color.autocolor",
"ec.color.equalize",
"ec.color.shadowhighlight",
"ec.color.cccolorneutralizer",
"ec.color.colorstabilizer",
];
/// 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.color.shadowhighlight" => shadow_highlight(e, ctx, b),
_ => point(e, id, ctx, b),
}
}
/// Invert in the HLS and YIQ channels (the base `pointwise` kernel does RGB and Alpha).
pub(crate) fn invert(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let mut p = op(18);
p.u[0][1] = ctx.params.e("channel");
p.f[0][0] = 1.0 - ctx.params.f("blend") as f32 / 100.0;
run(e, &p, b, None)
}
/// Shadow/Highlight: the amounts (Auto Amounts) and the Black / White Clip ranges are measured
/// on the CPU; the luminance bases are blurred and the image adjusted on the GPU.
fn shadow_highlight(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let pr = ctx.params;
let src = e.download(&b.img)?;
let (s_amt, h_amt) = effectcraft_effects::shadow_highlight_amounts(ctx, &src);
let (bc, wc) = (pr.f("moreOptions/blackClip").clamp(0.0, 49.0) / 100.0, pr.f("moreOptions/whiteClip").clamp(0.0, 49.0) / 100.0);
let clip0 = (bc > 0.0 || wc > 0.0).then(|| effectcraft_effects::luma_clip_points(&src, bc, wc));
let s_r = pr.f("moreOptions/shadowRadius").max(0.0) * b.scale / 2.0;
let h_r = pr.f("moreOptions/highlightRadius").max(0.0) * b.scale / 2.0;
let (w, h) = (b.img.width, b.img.height);
let luma = e.image(w, h);
e.pixels("fxt_point", &op(19), &b.img, None, &luma, None);
let base_s = gaussian_blur(e, &luma, s_r, s_r, true);
let base_h = if (h_r - s_r).abs() < 1e-9 { base_s.clone() } else { gaussian_blur(e, &luma, h_r, h_r, true) };
let bases = e.image(w, h);
e.pixels("fxt_point", &op(21), &base_s, Some(&base_h), &bases, None);
let mut p = op(20);
p.f[0] =
[s_amt, h_amt, (pr.f("moreOptions/shadowTonalWidth") as f32 / 100.0).max(0.01), (pr.f("moreOptions/highlightTonalWidth") as f32 / 100.0).max(0.01)];
p.f[1] = [
pr.f("moreOptions/colorCorrection") as f32 / 100.0,
pr.f("moreOptions/midtoneContrast") as f32 / 100.0,
(pr.f("blend") / 100.0).clamp(0.0, 1.0) as f32,
0.0,
];
let out = e.scratch(w, h);
e.pixels("fxt_point", &p, &b.img, Some(&bases), &out, None);
let Some((lo0, hi0)) = clip0 else { return Some(GBuf { img: out, ..b }) };
let adjusted = e.download(&out)?;
let (lo1, hi1) = effectcraft_effects::luma_clip_points(&adjusted, bc, wc);
if (lo0, hi0) == (lo1, hi1) || hi1 - lo1 <= 1e-3 {
return Some(GBuf { img: out, ..b });
}
let mut p = op(22);
p.f[0] = [lo0, lo1, (hi0 - lo0) / (hi1 - lo1), 0.0];
run(e, &p, GBuf { img: out, ..b }, None)
}
fn rgb(c: [f32; 4]) -> [f32; 4] {
[c[0], c[1], c[2], 0.0]
}
/// Hue/Saturation with colour ranges (Channel Control); Master and Colorize alone run in the
/// base `pointwise` kernel.
pub(crate) fn hue_saturation(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let pr = ctx.params;
let f = |id: String, d: f64| pr.get(&id).map(|v| v.as_f64()).unwrap_or(d) as f32;
let mut data = vec![];
for (r, _, c) in effectcraft_effects::HUESAT_RANGES {
let range = [
f(format!("{r}RangeStart"), c - 15.0),
f(format!("{r}RangeEnd"), c + 15.0),
f(format!("{r}StartFalloff"), 30.0).max(0.0),
f(format!("{r}EndFalloff"), 30.0).max(0.0),
f(format!("{r}Hue"), 0.0) / 360.0,
f(format!("{r}Saturation"), 0.0) / 100.0,
f(format!("{r}Lightness"), 0.0) / 100.0,
];
if range[4] != 0.0 || range[5] != 0.0 || range[6] != 0.0 {
data.extend(range);
}
}
let g = |k: &str| pr.f(k) as f32;
let mut p = Params::default();
p.u[0] = [14, pr.b("colorize") as u32, (data.len() / 7) as u32, 0];
p.f[0] = [g("hue") / 360.0, g("saturation") / 100.0, g("lightness") / 100.0, 0.0];
p.f[1] = [g("colorizeHue") / 360.0, g("colorizeSaturation") / 100.0, g("colorizeLightness") / 100.0, 0.0];
run(e, &p, b, Some(data))
}
/// Levels with Red / Green / Blue / Alpha controls (the RGB controls alone run in the base
/// `pointwise` kernel).
pub(crate) fn levels(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let pr = ctx.params;
let (cb, cw) = effectcraft_effects::levels_clip(ctx);
let mut data = vec![
pr.f("inBlack") as f32,
pr.f("inWhite") as f32,
pr.f("gamma").max(0.01) as f32,
pr.f("outBlack") as f32,
pr.f("outWhite") as f32,
cb as u32 as f32,
cw as u32 as f32,
];
for (v, (cb, cw)) in effectcraft_effects::levels_channel_settings(ctx) {
data.extend(v);
data.extend([cb as u32 as f32, cw as u32 as f32]);
}
run(e, &op(15), b, Some(data))
}
fn op(code: u32) -> Params {
let mut p = Params::default();
p.u[0][0] = code;
p
}
fn run(e: &mut Enc, p: &Params, b: GBuf, data: Option<Vec<f32>>) -> Option<GBuf> {
let buf = data.map(|d| e.data(&d));
let out = e.scratch(b.img.width, b.img.height);
e.pixels("fxt_point", p, &b.img, None, &out, buf.as_ref());
Some(GBuf { img: out, ..b })
}
/// color3::parse_map.
fn parse_map(s: &str) -> Vec<f32> {
let vals: Vec<f32> = s.split(|ch: char| ch == ',' || ch.is_whitespace()).filter(|t| !t.is_empty()).filter_map(|t| t.parse::<f32>().ok()).collect();
if vals.len() < 2 {
return (0..256).map(|i| i as f32 / 255.0).collect();
}
let max = vals.iter().cloned().fold(0.0f32, f32::max);
let k = if max > 1.0 { 1.0 / 255.0 } else { 1.0 };
vals.iter().map(|v| (v * k).clamp(0.0, 1.0)).collect()
}
fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let pr = ctx.params;
let f = |k: &str| pr.f(k) as f32;
let mut p = Params::default();
let mut data = None;
match id {
"ec.color.levelsic" => {
const IDS: [[&str; 5]; 5] = [
["rgb/rgbInBlack", "rgb/rgbInWhite", "rgb/rgbGamma", "rgb/rgbOutBlack", "rgb/rgbOutWhite"],
["red/redInBlack", "red/redInWhite", "red/redGamma", "red/redOutBlack", "red/redOutWhite"],
["green/greenInBlack", "green/greenInWhite", "green/greenGamma", "green/greenOutBlack", "green/greenOutWhite"],
["blue/blueInBlack", "blue/blueInWhite", "blue/blueGamma", "blue/blueOutBlack", "blue/blueOutWhite"],
["alpha/alphaInBlack", "alpha/alphaInWhite", "alpha/alphaGamma", "alpha/alphaOutBlack", "alpha/alphaOutWhite"],
];
let lv: Vec<[f32; 5]> = IDS.iter().map(|ids| ids.map(f)).collect();
let ident = |l: &[f32; 5]| *l == [0.0, 1.0, 1.0, 0.0, 1.0];
if lv.iter().all(ident) {
return Some(b);
}
let (cb, cw) = effectcraft_effects::levels_clip(ctx);
let mut d = vec![];
for l in &lv {
d.extend(l);
d.push(ident(l) as u32 as f32);
}
p.u[0] = [1, cb as u32, cw as u32, 0];
data = Some(d);
}
"ec.color.gammapedestalgain" => {
let ch = |n: &str| [f(&format!("{n}Gamma")).max(0.01), f(&format!("{n}Pedestal")), f(&format!("{n}Gain")), 0.0];
p.u[0][0] = 2;
p.f[0] = ch("red");
p.f[1] = ch("green");
p.f[2] = ch("blue");
p.f[3][0] = f("blackStretch").max(0.0);
}
"ec.color.photofilter" => {
let fi = pr.e("filter");
let c = match effectcraft_effects::PHOTO_FILTERS.get(fi as usize) {
Some((_, rgb)) if fi != effectcraft_effects::PHOTO_FILTER_CUSTOM => rgb.map(|v| v as f32 / 255.0),
_ => {
let c = pr.color("color");
[c[0], c[1], c[2]]
}
};
p.u[0] = [3, pr.b("preserveLuminosity") as u32, 0, 0];
p.f[0] = [c[0], c[1], c[2], f("density") / 100.0];
}
"ec.color.changetocolor" => {
let from = pr.color("from");
let to = pr.color("to");
let (fh, fs, fl) = rgb_to_hsl(from[0], from[1], from[2]);
let (th, ts, tl) = rgb_to_hsl(to[0], to[1], to[2]);
let change = pr.e("change");
let flags = matches!(change, 1 | 3) as u32
| (matches!(change, 2 | 3) as u32) << 1
| ((pr.e("changeBy") == 1) as u32) << 2
| (pr.b("viewCorrectionMatte") as u32) << 3;
p.u[0] = [4, flags, 0, 0];
p.f[0] = [fh, fs, fl, f("softness") / 100.0];
p.f[1] = [th, ts, tl, 0.0];
p.f[2] = [f("toleranceGroup/hue") / 100.0, f("toleranceGroup/lightness") / 100.0, f("toleranceGroup/saturation") / 100.0, 0.0];
}
"ec.color.leavecolor" => {
let amt = f("amount") / 100.0;
if amt <= 0.0 {
return Some(b);
}
let k = pr.color("color");
p.u[0] = [5, (pr.e("matchColors") == 1) as u32, 0, 0];
p.f[0] = [k[0], k[1], k[2], rgb_to_hsl(k[0], k[1], k[2]).0];
p.f[1] = [amt, f("tolerance") / 100.0, f("edgeSoftness") / 100.0, 0.0];
}
"ec.color.changecolor" => {
let view_mask = pr.e("view") == 1;
let (dh, dl, ds) = (f("hueTransform") / 360.0, f("lightnessTransform") / 100.0, f("saturationTransform") / 100.0);
if !view_mask && dh == 0.0 && dl == 0.0 && ds == 0.0 {
return Some(b);
}
let k = pr.color("colorToChange");
p.u[0] = [6, pr.e("matchColors"), view_mask as u32 | (pr.b("invertColorCorrectionMask") as u32) << 1, 0];
p.f[0] = [k[0], k[1], k[2], rgb_to_hsl(k[0], k[1], k[2]).0];
p.f[1] = [dh, dl, ds, 0.0];
p.f[2] = [f("matchingTolerance") / 100.0, f("matchingSoftness") / 100.0, 0.0, 0.0];
}
"ec.color.broadcast" => {
let pal = pr.e("locale") == 1;
let (setup, span) = if pal { (0.0, 100.0) } else { (7.5, 92.5) };
p.u[0] = [7, pal as u32, pr.e("howToMakeSafe"), 0];
p.f[0][0] = (f("maxSignal") - setup) / span;
}
"ec.color.colorbalancehls" => {
let (dh, dl, ds) = (f("hue") / 360.0, f("lightness") / 100.0, f("saturation") / 100.0);
if dh == 0.0 && dl == 0.0 && ds == 0.0 {
return Some(b);
}
p.u[0][0] = 8;
p.f[0] = [dh, dl, ds, 0.0];
}
"ec.color.cctoner" => {
let g = |id: &str| rgb(pr.color(id));
let stops = match pr.e("tones") {
0 => vec![g("shadows"), g("highlights")],
2 => vec![g("shadows"), g("darktones"), g("midtones"), g("brights"), g("highlights")],
_ => vec![g("shadows"), g("midtones"), g("highlights")],
};
p.u[0] = [9, stops.len() as u32 - 1, 0, 0];
p.f[0][0] = (f("blend") / 100.0).clamp(0.0, 1.0);
for (i, s) in stops.iter().enumerate() {
p.f[1 + i] = *s;
}
}
"ec.color.cccoloroffset" => {
let offs = ["redPhase", "greenPhase", "bluePhase"].map(|id| (pr.f(id) / 360.0).rem_euclid(1.0) as f32);
if offs.iter().all(|o| *o == 0.0) {
return Some(b);
}
p.u[0] = [10, pr.e("overflow"), 0, 0];
p.f[0] = [offs[0], offs[1], offs[2], 0.0];
}
"ec.color.cckernel" => {
let k = ["k1", "k2", "k3", "k4", "k5", "k6", "k7", "k8", "k9"].map(f);
let (scale, offset) = (f("scale"), f("offset"));
let identity = k.iter().enumerate().all(|(i, v)| if i == 4 { *v == 1.0 } else { *v == 0.0 });
if identity && scale == 1.0 && offset == 0.0 {
return Some(b);
}
p.u[0] = [11, pr.b("absolute") as u32, 0, 0];
p.f[0] = [k[0], k[1], k[2], k[3]];
p.f[1] = [k[4], k[5], k[6], k[7]];
p.f[2] = [k[8], scale, offset, 0.0];
}
"ec.color.psarbitrarymap" => {
let map = parse_map(pr.s("map"));
p.u[0] = [12, pr.b("applyPhaseMapToAlpha") as u32, map.len() as u32, 0];
p.f[0][0] = (pr.f("phase") * (map.len() - 1) as f64 / 255.0) as f32;
data = Some(map);
}
"ec.color.videolimiter" => {
const CLIP_LEVELS: [f32; 5] = [0.9, 0.95, 1.0, 1.05, 1.09];
let level = CLIP_LEVELS[(pr.e("clipLevel") as usize).min(4)];
let comp = [0.0, 0.03, 0.05, 0.1, 0.2][(pr.e("compressionBeforeClipping") as usize).min(4)];
p.u[0] = [13, pr.e("clipMethod"), pr.b("gamutWarning") as u32, 0];
p.f[0] = [level, level * (1.0 - comp), 0.0, 0.0];
p.f[1] = rgb(pr.color("gamutWarningColor"));
}
"ec.color.colorstabilizer" => {
// The sample means are measured on the CPU (the frame is read back).
if ctx.env.host.is_none() {
return Some(b);
}
let img = e.download(&b.img)?;
let cpu = effectcraft_effects::Buf { img, offset: b.offset, scale: b.scale };
let Some(maps) = effectcraft_effects::color_stabilizer_maps(ctx, &cpu) else { return Some(b) };
let mut d = vec![];
for m in &maps {
d.push(m.len() as f32);
for i in 0..5 {
let (x, y) = m.get(i).copied().unwrap_or((0.0, 0.0));
d.extend([x as f32, y as f32]);
}
}
p.u[0][0] = 24;
data = Some(d);
}
"ec.color.cccolorneutralizer" => {
let pairs = [("shadowsUnbalance", "shadowsBalance"), ("midtonesUnbalance", "midtonesBalance"), ("highlightsUnbalance", "highlightsBalance")];
for (i, (u, bal)) in pairs.iter().enumerate() {
let (u, v) = (pr.color(u), pr.color(bal));
p.f[i] = [v[0] - u[0], v[1] - u[1], v[2] - u[2], 0.0];
}
p.u[0][0] = 23;
p.f[3] = [f("pinning") / 100.0, f("blendWOriginal") / 100.0, f("contrast") / 100.0, 0.0];
p.f[4] = [f("darks") / 100.0, f("brights") / 100.0, 0.0, 0.0];
}
"ec.color.autolevels" | "ec.color.autocontrast" | "ec.color.autocolor" | "ec.color.equalize" => {
// The histograms are measured on the CPU.
let img = e.download(&b.img)?;
if id == "ec.color.equalize" {
let amt = (pr.f("amount") / 100.0).clamp(0.0, 1.0) as f32;
if amt <= 0.0 {
return Some(b);
}
let style = pr.e("style");
let tables = effectcraft_effects::equalize_tables(&img, style);
p.u[0] = [17, style, tables.first().map_or(0, |t| t.len()) as u32, 0];
p.f[0][0] = amt;
data = Some(tables.concat());
} else {
let kind = match id {
"ec.color.autolevels" => 0,
"ec.color.autocontrast" => 1,
_ => 2,
};
let (ranges, gam) = effectcraft_effects::auto_correct_settings(ctx, &img, kind);
p.u[0][0] = 16;
p.f[0] = [ranges[0].0, ranges[1].0, ranges[2].0, 0.0];
p.f[1] = [ranges[0].1, ranges[1].1, ranges[2].1, 0.0];
p.f[2] = [gam[0], gam[1], gam[2], (pr.f("blend") / 100.0).clamp(0.0, 1.0) as f32];
}
}
_ => return None,
}
run(e, &p, b, data)
}