M9.3: Stylize effects (CC Block Load, CC Burn Film, CC Glass, CC HexTile, CC Mr. Smoothie, CC Plastic)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 21:56:13 -04:00
parent 685d6bcd04
commit 8503f816ae
1 changed files with 655 additions and 3 deletions

View File

@ -1,7 +1,659 @@
//! Stylize effects, batch 3.
//! Stylize effects, batch 3: CC Block Load, CC Burn Film, CC Glass, CC HexTile, CC Mr. Smoothie
//! and CC Plastic.
//!
//! Written from the public descriptions of what these effects do (progressive block loading,
//! burning film, bump-mapped glass / plastic surfaces lit by an effect light, hexagonal tiling,
//! a colour map sampled from the image itself); the maths is our own.
use crate::EffectSpec;
use effectcraft_color::rgb_to_hsl;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::noise::fbm;
use crate::util::{Plane, gauss_plane, layer_or_self, lerp4, premul, smoothstep, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Stylize", params, render, gpu: false, float: true }
}
fn pct() -> ParamUi {
slider(0.0, 100.0, 0.0, 100.0, 1)
}
fn pt(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
// ------------------------------------------------------------------ CC Block Load
/// Mean colour of every `s`×`s` block (grid of `ceil(w/s)`×`ceil(h/s)`).
fn block_means(img: &Image, s: usize) -> (usize, usize, Vec<Px>) {
let (w, h) = (img.width as usize, img.height as usize);
let gw = w.div_ceil(s).max(1);
let gh = h.div_ceil(s).max(1);
let mut out = vec![[0.0f32; 4]; gw * gh];
out.par_chunks_mut(gw).enumerate().for_each(|(by, row)| {
for (bx, o) in row.iter_mut().enumerate() {
let mut acc = [0.0f32; 4];
let mut n = 0.0f32;
for y in by * s..((by + 1) * s).min(h) {
for x in bx * s..((bx + 1) * s).min(w) {
let p = img.data[y * w + x];
for c in 0..4 {
acc[c] += p[c];
}
n += 1.0;
}
}
if n > 0.0 {
*o = acc.map(|v| v / n);
}
}
});
(gw, gh, out)
}
/// Value of pixel (x, y) at block size `s` (nearest block, or bilinear between block centres).
fn block_at(g: &(usize, usize, Vec<Px>), s: usize, x: usize, y: usize, bilinear: bool) -> Px {
let (gw, gh, d) = g;
if !bilinear || s == 1 {
return d[(y / s).min(gh - 1) * gw + (x / s).min(gw - 1)];
}
let fx = (x as f32 + 0.5) / s as f32 - 0.5;
let fy = (y as f32 + 0.5) / s as f32 - 0.5;
let x0 = fx.floor();
let y0 = fy.floor();
let (tx, ty) = (fx - x0, fy - y0);
let gx = |v: f32| (v as i64).clamp(0, *gw as i64 - 1) as usize;
let gy = |v: f32| (v as i64).clamp(0, *gh as i64 - 1) as usize;
let a = d[gy(y0) * gw + gx(x0)];
let b = d[gy(y0) * gw + gx(x0 + 1.0)];
let c = d[gy(y0 + 1.0) * gw + gx(x0)];
let e = d[gy(y0 + 1.0) * gw + gx(x0 + 1.0)];
lerp4(lerp4(a, b, tx), lerp4(c, e, tx), ty)
}
fn block_load(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0);
if c >= 1.0 {
return b;
}
let scan = ctx.params.b("scanlines");
let smooth = ctx.params.b("smoothing");
let cleared = ctx.params.b("startCleared");
let bilinear = ctx.params.b("bilinear");
let (w, h) = (b.img.width as usize, b.img.height as usize);
let nlev = (w.max(h).max(2) as f64).log2().ceil() as u32;
// Levels 0..=nlev with block sizes 2^(nlev-k); level k is "loading" while p is in [k, k+1).
let p = c * (nlev + 1) as f64;
let done = p.floor() as u32;
let frac = (p - done as f64) as f32;
let size = |k: u32| 1usize << (nlev - k.min(nlev));
let s_new = size(done);
let new = block_means(&b.img, s_new);
let old = if done > 0 { Some((size(done - 1), block_means(&b.img, size(done - 1)))) } else { None };
let rows_new = h.div_ceil(s_new) as f32;
let soft = if smooth { 1.5f32 } else { 0.0 };
let mut out = Image::new(w as u32, h as u32);
out.rows_mut().for_each(|(y, row)| {
// Fraction of the new level present on this row.
let t = if scan {
let front = frac * (rows_new + soft) - soft * 0.5;
let r = (y / s_new) as f32 + 0.5;
if soft > 0.0 {
smoothstep(r - soft * 0.5, r + soft * 0.5, front)
} else if r < front {
1.0
} else {
0.0
}
} else if smooth {
frac
} else {
0.0
};
for (x, o) in row.iter_mut().enumerate() {
let n = if t > 0.0 { block_at(&new, s_new, x, y, bilinear) } else { [0.0; 4] };
let prev = match &old {
Some((s, g)) => block_at(g, *s, x, y, bilinear),
None if cleared => [0.0; 4],
None => block_at(&new, s_new, x, y, bilinear),
};
*o = if t >= 1.0 {
n
} else if t <= 0.0 {
prev
} else {
lerp4(prev, n, t)
};
}
});
b.img = out;
b
}
// ------------------------------------------------------------------ CC Burn Film
fn burn_film(ctx: &EffectCtx, mut b: Buf) -> Buf {
let burn = (ctx.params.f("burn") / 100.0).clamp(0.0, 1.0) as f32;
if burn <= 0.0 {
return b;
}
let (cx, cy) = b.to_px(ctx.params.v2("center"));
let seed = ctx.params.f("randomSeed").max(0.0) as u32;
let s = b.scale;
let diag = (ctx.layer_size[0].hypot(ctx.layer_size[1]) * s).max(1.0);
let feat = (60.0 * s).max(1e-3);
let th = 1.0 - burn * 1.25;
let band = 0.12 * (burn * 10.0).min(1.0);
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
let d = ((fx - cx).hypot(fy - cy) / diag) as f32;
let n = fbm((fx / feat) as f32, (fy / feat) as f32, 0.0, seed.wrapping_add(17), 4.0);
// High near the centre and where the noise is high; burning eats from the top down.
let f = n * 0.65 + (1.0 - d.min(1.0)) * 0.35;
if f > th + band {
*px = [0.0; 4];
continue;
}
if f > th - band * 2.0 {
let (c, a) = unpremul(*px);
// Charred rim (scorched dark) with a hot orange edge right next to the hole.
let k = smoothstep(th - band * 2.0, th, f);
let hot = smoothstep(th, th + band, f);
let char = 1.0 - k * 0.85;
let mut o = [c[0] * char, c[1] * char, c[2] * char];
o = [o[0] + (1.0 - o[0]) * hot * 0.9, o[1] + (0.45 - o[1]) * hot * 0.9, o[2] * (1.0 - hot)];
let na = a * (1.0 - hot * 0.6);
*px = premul(o, na);
}
}
});
b
}
// ------------------------------------------------------------------ bump-map surfaces (Glass, Plastic)
const BUMP_PROPS: [&str; 6] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness"];
/// Height field (0..1) from the bump-map layer (or the layer itself), softened.
fn height_field(ctx: &EffectCtx, b: &Buf) -> Plane {
let src = layer_or_self(ctx, b, "bumpMap", true, false);
let prop = ctx.params.e("property");
let mut pl = Plane::from_image(&src, |p| {
let (c, a) = unpremul(p);
let v = match prop {
0 => c[0],
1 => c[1],
2 => c[2],
3 => return a,
4 => effectcraft_color::luminance(c[0], c[1], c[2]),
_ => rgb_to_hsl(c[0], c[1], c[2]).2,
};
v * a
});
let soft = ctx.params.f("softness") * b.scale * 0.5;
if soft > 0.05 {
pl = gauss_plane(&pl, soft, soft);
}
pl
}
struct Light {
intensity: f32,
color: [f32; 3],
point: bool,
pos: (f64, f64),
/// Distant light direction (towards the light).
dir: [f32; 3],
height: f32,
ambient: f32,
diffuse: f32,
specular: f32,
shininess: f32,
metal: f32,
}
impl Light {
fn from(ctx: &EffectCtx, b: &Buf) -> Light {
let pr = &ctx.params;
let elev = (pr.f("lightHeight").clamp(-100.0, 100.0) / 100.0 * 90.0).to_radians();
let az = pr.f("lightDirection").to_radians();
let dir = [(az.sin() * elev.cos()) as f32, (-az.cos() * elev.cos()) as f32, elev.sin() as f32];
let c = pr.color("lightColor");
let rough = pr.f("roughness").clamp(0.001, 1.0) as f32;
Light {
intensity: (pr.f("lightIntensity") / 100.0) as f32,
color: [c[0], c[1], c[2]],
point: pr.e("lightType") == 1,
pos: b.to_px(pr.v2("lightPosition")),
dir,
height: (pr.f("lightHeight") / 100.0 * ctx.layer_size[0].max(ctx.layer_size[1]) * b.scale * 0.5) as f32,
ambient: (pr.f("ambient") / 100.0) as f32,
diffuse: (pr.f("diffuse") / 100.0) as f32,
specular: (pr.f("specular") / 100.0) as f32,
shininess: 1.0 / rough,
metal: (pr.f("metal") / 100.0) as f32,
}
}
fn to_light(&self, x: f64, y: f64) -> [f32; 3] {
if self.point {
let v = [(self.pos.0 - x) as f32, (self.pos.1 - y) as f32, self.height.max(1.0)];
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-6);
[v[0] / l, v[1] / l, v[2] / l]
} else {
self.dir
}
}
/// Lit straight colour for surface normal `n`. Diffuse is normalised so that a flat surface
/// keeps its colour; highlights add on top.
fn shade(&self, c: [f32; 3], n: [f32; 3], l: [f32; 3]) -> [f32; 3] {
let ndl = (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]).max(0.0);
let flat = l[2].max(0.0);
let base = self.ambient + self.diffuse * flat;
let lit = if base > 1e-4 { (self.ambient + self.diffuse * ndl) / base } else { 1.0 };
let lit = 1.0 + (lit - 1.0) * self.intensity;
// Blinn half vector with the viewer straight above.
let hv = [l[0], l[1], l[2] + 1.0];
let hl = (hv[0] * hv[0] + hv[1] * hv[1] + hv[2] * hv[2]).sqrt().max(1e-6);
let ndh = ((n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]) / hl).max(0.0);
let spec = self.specular * self.intensity * ndh.powf(self.shininess);
let mut o = [0.0f32; 3];
for i in 0..3 {
let hl_col = self.color[i] * (1.0 - self.metal) + c[i] * self.color[i] * self.metal;
o[i] = c[i] * lit * (1.0 + (self.color[i] - 1.0) * self.intensity.min(1.0)) + spec * hl_col;
}
o
}
}
/// Surface normal from a height field at pixel (x, y) with height scale `k`.
#[inline]
fn normal(h: &Plane, x: usize, y: usize, k: f32) -> [f32; 3] {
let (xi, yi) = (x as i64, y as i64);
let gx = (h.get_clamped(xi + 1, yi) - h.get_clamped(xi - 1, yi)) * 0.5 * k;
let gy = (h.get_clamped(xi, yi + 1) - h.get_clamped(xi, yi - 1)) * 0.5 * k;
let l = (gx * gx + gy * gy + 1.0).sqrt();
[-gx / l, -gy / l, 1.0 / l]
}
fn glass(ctx: &EffectCtx, mut b: Buf) -> Buf {
let hf = height_field(ctx, &b);
let height = ctx.params.f("height") as f32 / 100.0;
let k = height * 25.0;
let disp = ctx.params.f("displacement") * b.scale * 0.25;
let light = Light::from(ctx, &b);
let src = b.img.clone();
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let n = normal(&hf, x, y, k);
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
let s = if disp != 0.0 && height != 0.0 {
src.sample_bilinear_clamped(fx + n[0] as f64 * disp, fy + n[1] as f64 * disp)
} else {
src.data[y * src.width as usize + x]
};
let a = src.data[y * src.width as usize + x][3];
if a <= 0.0 {
*px = [0.0; 4];
continue;
}
let (c, sa) = unpremul(s);
let c = if sa > 0.0 { c } else { unpremul(src.data[y * src.width as usize + x]).0 };
let o = light.shade(c, n, light.to_light(fx, fy));
*px = premul(o, a);
}
});
b
}
fn plastic(ctx: &EffectCtx, mut b: Buf) -> Buf {
let mut hf = height_field(ctx, &b);
let lo = (ctx.params.f("cutMin") / 100.0) as f32;
let hi = (ctx.params.f("cutMax") / 100.0) as f32;
if lo > 0.0 || hi < 1.0 {
hf = hf.map(|v| v.clamp(lo.min(hi), hi.max(lo)));
}
let k = ctx.params.f("height") as f32 / 100.0 * 25.0;
let light = Light::from(ctx, &b);
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
if px[3] <= 0.0 {
continue;
}
let (c, a) = unpremul(*px);
let n = normal(&hf, x, y, k);
let o = light.shade(c, n, light.to_light(x as f64 + 0.5, y as f64 + 0.5));
*px = premul(o, a);
}
});
b
}
// ------------------------------------------------------------------ CC HexTile
/// Axial hex cell (pointy-top) containing (x, y) for circumradius `r`; returns the cell centre
/// and integer cell coordinates.
fn hex_cell(x: f64, y: f64, r: f64) -> ((f64, f64), (i64, i64)) {
let q = (3f64.sqrt() / 3.0 * x - y / 3.0) / r;
let rr = (2.0 / 3.0 * y) / r;
// Cube rounding.
let (cx, cz) = (q, rr);
let cy = -cx - cz;
let (mut rx, ry, mut rz) = (cx.round(), cy.round(), cz.round());
let (dx, dy, dz) = ((rx - cx).abs(), (ry - cy).abs(), (rz - cz).abs());
if dx > dy && dx > dz {
rx = -ry - rz;
} else if dy <= dz {
rz = -rx - ry;
}
let px = r * 3f64.sqrt() * (rx + rz / 2.0);
let py = r * 1.5 * rz;
((px, py), (rx as i64, rz as i64))
}
fn hextile(ctx: &EffectCtx, mut b: Buf) -> Buf {
let r = (ctx.params.f("radius") * b.scale).max(0.5);
let (cx, cy) = b.to_px(ctx.params.v2("center"));
let rot = ctx.params.f("rotate").to_radians();
let (sn, cs) = rot.sin_cos();
let smear = (ctx.params.f("smearing") / 100.0).clamp(0.0, 1.0);
let mode = ctx.params.e("render");
let src = b.img.clone();
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy);
// Into the rotated tiling frame.
let (u, v) = (dx * cs + dy * sn, -dx * sn + dy * cs);
let ((hx, hy), (qi, ri)) = hex_cell(u, v, r);
let (mut lu, lv) = (u - hx, v - hy);
let odd = (qi + ri).rem_euclid(2) == 1;
match mode {
1 if odd => lu = -lu,
2 if odd => lu = -lu,
_ => {}
}
let lv = if mode == 2 && odd { -lv } else { lv };
// Every tile shows the hexagon around the centre point.
let (ox, oy) = (lu * cs - lv * sn, lu * sn + lv * cs);
let (tx, ty) = (cx + ox, cy + oy);
let (sx, sy) = (tx + (x as f64 + 0.5 - tx) * smear, ty + (y as f64 + 0.5 - ty) * smear);
*px = src.sample_bilinear_clamped(sx, sy);
}
});
b
}
// ------------------------------------------------------------------ CC Mr. Smoothie
const SMOOTHIE_PROPS: [&str; 8] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness", "Hue", "Saturation"];
fn smoothie(ctx: &EffectCtx, mut b: Buf) -> Buf {
let flow = layer_or_self(ctx, &b, "flowLayer", true, false);
let prop = ctx.params.e("property");
let mut pl = Plane::from_image(&flow, |p| {
let (c, a) = unpremul(p);
match prop {
0 => c[0],
1 => c[1],
2 => c[2],
3 => a,
4 => effectcraft_color::luminance(c[0], c[1], c[2]),
5 => rgb_to_hsl(c[0], c[1], c[2]).2,
6 => rgb_to_hsl(c[0], c[1], c[2]).0,
_ => rgb_to_hsl(c[0], c[1], c[2]).1,
}
});
let sm = ctx.params.f("smoothness") * b.scale * 0.5;
if sm > 0.05 {
pl = gauss_plane(&pl, sm, sm);
}
let (ax, ay) = b.to_px(ctx.params.v2("sampleA"));
let (bx, by) = b.to_px(ctx.params.v2("sampleB"));
// Colour palette sampled from the image along A→B.
const N: usize = 256;
let pal: Vec<Px> = (0..N)
.map(|i| {
let t = i as f64 / (N - 1) as f64;
let p = b.img.sample_bilinear_clamped(ax + (bx - ax) * t, ay + (by - ay) * t);
let (c, _) = unpremul(p);
[c[0], c[1], c[2], 1.0]
})
.collect();
let phase = (ctx.params.f("phase") / 360.0) as f32;
let loops = (ctx.params.e("colorLoop") + 1) as f32;
b.img.data.par_iter_mut().zip(pl.data.par_iter()).for_each(|(px, &v)| {
if px[3] <= 0.0 {
return;
}
let t = (v * loops + phase).rem_euclid(1.0);
// Ping-pong so the palette loops without seams.
let t = if t < 0.5 { t * 2.0 } else { 2.0 - t * 2.0 };
let f = t * (N - 1) as f32;
let i = (f as usize).min(N - 2);
let c = lerp4(pal[i], pal[i + 1], f - i as f32);
*px = premul([c[0], c[1], c[2]], px[3]);
});
b
}
// ------------------------------------------------------------------ specs
fn light_params() -> Vec<crate::ParamSpec> {
vec![
p("using", "Using", Value::Enum(0), popup(&["Effect Light", "AE Lights"])),
p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("lightType", "Light Type", Value::Enum(0), popup(&["Distant Light", "Point Light"])),
p("lightHeight", "Light Height", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
p("lightPosition", "Light Position", pt(0.25, 0.25), ParamUi::Point),
p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle),
]
}
fn shading_params(ambient: f64, diffuse: f64, specular: f64, roughness: f64, metal: f64) -> Vec<crate::ParamSpec> {
vec![
p("ambient", "Ambient", num(ambient), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("diffuse", "Diffuse", num(diffuse), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("specular", "Specular", num(specular), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("roughness", "Roughness", num(roughness), slider(0.001, 0.5, 0.001, 0.5, 3)),
p("metal", "Metal", num(metal), pct()),
]
}
pub fn specs() -> Vec<EffectSpec> {
Vec::new()
let mut glass_p = vec![
p("bumpMap", "Bump Map", Value::Layer(None), ParamUi::Layer),
p("property", "Property", Value::Enum(3), popup(&BUMP_PROPS)),
p("softness", "Softness", num(10.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("height", "Height", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
p("displacement", "Displacement", num(100.0), slider(-500.0, 500.0, -200.0, 200.0, 1)),
];
glass_p.extend(light_params());
glass_p.extend(shading_params(75.0, 32.0, 26.0, 0.05, 100.0));
let mut plastic_p = vec![
p("bumpLayer", "Bump Layer", Value::Layer(None), ParamUi::Layer),
p("property", "Property", Value::Enum(3), popup(&BUMP_PROPS)),
p("softness", "Softness", num(10.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("height", "Height", num(25.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
p("cutMin", "Cut Min", num(0.0), pct()),
p("cutMax", "Cut Max", num(100.0), pct()),
];
plastic_p.extend(light_params());
plastic_p.extend(shading_params(100.0, 70.0, 50.0, 0.05, 50.0));
vec![
spec(
"ec.stylize.ccblockload",
"CC Block Load",
vec![
p("completion", "Completion", num(0.0), pct()),
p("scanlines", "Scanlines", Value::Bool(true), ParamUi::Checkbox),
p("smoothing", "Smoothing", Value::Bool(true), ParamUi::Checkbox),
p("startCleared", "Start Cleared", Value::Bool(true), ParamUi::Checkbox),
p("bilinear", "Bilinear", Value::Bool(false), ParamUi::Checkbox),
],
block_load,
),
spec(
"ec.stylize.ccburnfilm",
"CC Burn Film",
vec![
p("burn", "Burn", num(0.0), pct()),
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 100.0, 0)),
],
burn_film,
),
spec("ec.stylize.ccglass", "CC Glass", glass_p, glass),
spec(
"ec.stylize.cchextile",
"CC HexTile",
vec![
p("render", "Render", Value::Enum(0), popup(&["Normal", "Mirrored", "Rotated"])),
p("radius", "Radius", num(50.0), slider(1.0, 4000.0, 2.0, 500.0, 1)),
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
p("rotate", "Rotate", num(0.0), ParamUi::Angle),
p("smearing", "Smearing", num(0.0), pct()),
],
hextile,
),
spec(
"ec.stylize.ccmrsmoothie",
"CC Mr. Smoothie",
vec![
p("flowLayer", "Flow Layer", Value::Layer(None), ParamUi::Layer),
p("property", "Property", Value::Enum(4), popup(&SMOOTHIE_PROPS)),
p("smoothness", "Smoothness", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("sampleA", "Sample A", pt(0.25, 0.5), ParamUi::Point),
p("sampleB", "Sample B", pt(0.75, 0.5), ParamUi::Point),
p("phase", "Phase", num(0.0), ParamUi::Angle),
p("colorLoop", "Color Loop", Value::Enum(0), popup(&["1x", "2x", "3x", "4x"])),
],
smoothie,
),
spec("ec.stylize.ccplastic", "CC Plastic", plastic_p, plastic),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, run_fx};
fn ramp(w: u32, h: u32) -> Image {
let mut img = Image::new(w, h);
for y in 0..h {
for x in 0..w {
img.set(x, y, [x as f32 / w as f32, y as f32 / h as f32, 0.3, 1.0]);
}
}
img
}
fn bumpy(w: u32, h: u32) -> Image {
let mut img = ramp(w, h);
for y in 0..h {
for x in 0..w {
let a = if (x as i32 - 20).pow(2) + (y as i32 - 16).pow(2) < 64 { 1.0 } else { 0.5 };
let p = img.data[(y * w + x) as usize];
img.set(x, y, [p[0] * a, p[1] * a, p[2] * a, a]);
}
}
img
}
fn run(id: &str, vals: &[(&str, Value)], img: Image) -> Buf {
run_fx(id, vals, img, 0.0, EffectEnv::default())
}
fn max_diff(a: &Image, b: &Image) -> f32 {
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
}
const ALL: [&str; 6] =
["ec.stylize.ccblockload", "ec.stylize.ccburnfilm", "ec.stylize.ccglass", "ec.stylize.cchextile", "ec.stylize.ccmrsmoothie", "ec.stylize.ccplastic"];
#[test]
fn all_deterministic() {
for id in ALL {
let vals: &[(&str, Value)] = match id {
"ec.stylize.ccblockload" => &[("completion", num(40.0))],
"ec.stylize.ccburnfilm" => &[("burn", num(50.0))],
_ => &[],
};
let a = run(id, vals, bumpy(40, 32));
let b = run(id, vals, bumpy(40, 32));
assert_eq!(a.img.data, b.img.data, "{id}");
}
}
#[test]
fn block_load_complete_is_identity_and_start_cleared() {
let img = ramp(40, 30);
assert_eq!(run("ec.stylize.ccblockload", &[("completion", num(100.0))], img.clone()).img.data, img.data);
let o = run("ec.stylize.ccblockload", &[("completion", num(0.0))], img.clone());
assert!(o.img.data.iter().all(|p| p[3] == 0.0));
// Midway: blocky (fewer distinct colours than the source).
let o = run("ec.stylize.ccblockload", &[("completion", num(50.0)), ("smoothing", Value::Bool(false))], img.clone());
let mut cols: Vec<u32> = o.img.data.iter().map(|p| (p[0] * 1000.0) as u32).collect();
cols.sort();
cols.dedup();
assert!(cols.len() < 40, "{}", cols.len());
}
#[test]
fn burn_film_zero_identity_full_burns() {
let img = ramp(40, 30);
assert_eq!(run("ec.stylize.ccburnfilm", &[], img.clone()).img.data, img.data);
let o = run("ec.stylize.ccburnfilm", &[("burn", num(100.0))], img);
assert!(o.img.data.iter().all(|p| p[3] == 0.0));
}
#[test]
fn glass_flat_without_highlight_is_identity() {
let img = bumpy(40, 32);
let o = run("ec.stylize.ccglass", &[("height", num(0.0)), ("specular", num(0.0))], img.clone());
assert!(max_diff(&o.img, &img) < 1e-4, "{}", max_diff(&o.img, &img));
let o = run("ec.stylize.ccglass", &[], img.clone());
assert!(max_diff(&o.img, &img) > 0.01);
}
#[test]
fn plastic_shades_bumps() {
let img = bumpy(40, 32);
let o = run("ec.stylize.ccplastic", &[("height", num(0.0)), ("specular", num(0.0))], img.clone());
assert!(max_diff(&o.img, &img) < 1e-4);
let o = run("ec.stylize.ccplastic", &[("height", num(80.0))], img.clone());
assert!(max_diff(&o.img, &img) > 0.01);
// Alpha is untouched.
assert!(o.img.data.iter().zip(&img.data).all(|(a, b)| (a[3] - b[3]).abs() < 1e-6));
}
#[test]
fn hextile_repeats_centre() {
let img = ramp(64, 64);
let o = run("ec.stylize.cchextile", &[("radius", num(8.0)), ("center", pt(32.0, 32.0))], img.clone());
assert!(max_diff(&o.img, &img) > 0.1);
// Pixels at the centre are unchanged (inside the central tile).
let c = (32 * 64 + 32) as usize;
assert!((0..4).all(|i| (o.img.data[c][i] - img.data[c][i]).abs() < 1e-4));
// Full smearing restores the source.
let o = run("ec.stylize.cchextile", &[("radius", num(8.0)), ("smearing", num(100.0))], img.clone());
assert!(max_diff(&o.img, &img) < 1e-4);
}
#[test]
fn smoothie_maps_to_sampled_palette() {
let img = ramp(40, 30);
let o = run("ec.stylize.ccmrsmoothie", &[("smoothness", num(0.0)), ("sampleA", pt(10.0, 15.0)), ("sampleB", pt(30.0, 15.0))], img.clone());
// Every output colour lies on the palette line between A and B (blue stays 0.3, green ≈ 0.5).
assert!(o.img.data.iter().all(|p| (p[2] - 0.3).abs() < 1e-3 && (p[1] - 0.5).abs() < 0.03));
assert!(max_diff(&o.img, &img) > 0.1);
}
}