Skip to main content

pfx_post/
fx.rs

1use crate::color::{self, REC709};
2use crate::grade;
3use crate::hash::{self, grain_hash};
4use crate::image::{self, Image};
5
6const PALETTE: [[f32; 3]; 16] = [
7    [0.0, 0.0, 0.0],
8    [0.012_286_5, 0.024_157_6, 0.086_500_5],
9    [0.208_637, 0.018_500_2, 0.086_500_5],
10    [0.0, 0.242_281, 0.082_282_7],
11    [0.407_24, 0.084_376_2, 0.036_889_5],
12    [0.114_435, 0.095_307_5, 0.078_187_4],
13    [0.539_479, 0.545_724, 0.571_125],
14    [1.0, 0.879_622, 0.806_952],
15    [1.0, 0.0, 0.074_213_6],
16    [1.0, 0.366_253, 0.0],
17    [1.0, 0.838_799, 0.020_288_6],
18    [0.0, 0.775_822, 0.036_889_5],
19    [0.022_173_9, 0.417_885, 1.0],
20    [0.226_966, 0.181_164, 0.332_452],
21    [1.0, 0.184_475, 0.391_572],
22    [1.0, 0.603_827, 0.401_978],
23];
24
25#[derive(Clone, Copy, Debug)]
26pub struct Cel {
27    pub bands: u32,
28    pub shadow: f32,
29    pub threshold: f32,
30    pub softness: f32,
31    pub spec: f32,
32    pub spec_roughness: f32,
33    pub spec_threshold: f32,
34    pub light: [f32; 3],
35}
36
37#[derive(Clone, Copy, Debug)]
38pub struct Outline {
39    pub enabled: bool,
40    pub thickness: f32,
41    pub color: [f32; 3],
42    pub local_color: bool,
43    pub alpha: f32,
44    pub crease_angle: f32,
45    pub depth_gap: f32,
46}
47
48#[derive(Clone, Copy, Debug)]
49pub struct Cavity {
50    pub strength: f32,
51    pub distance: f32,
52}
53
54#[derive(Clone, Copy, Debug)]
55pub struct Rim {
56    pub strength: f32,
57    pub width: f32,
58    pub color: [f32; 3],
59}
60
61#[derive(Clone, Copy, Debug)]
62pub struct Bw {
63    pub weights: [f32; 3],
64}
65
66#[derive(Clone, Copy, Debug)]
67pub struct Noir {
68    pub contrast: f32,
69    pub crush: f32,
70    pub keep: Option<[f32; 3]>,
71    pub keep_range: f32,
72}
73
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75pub enum NoiseKind {
76    Ordered,
77    Blue,
78}
79
80#[derive(Clone, Copy, Debug)]
81pub struct OneBit {
82    pub kind: NoiseKind,
83}
84
85#[derive(Clone, Copy, Debug)]
86pub struct Halftone {
87    pub cell: f32,
88    pub angle: f32,
89    pub ink: [f32; 3],
90}
91
92#[derive(Clone, Copy, Debug)]
93pub struct Comic {
94    pub levels: u32,
95    pub ink: f32,
96}
97
98#[derive(Clone, Copy, Debug)]
99pub struct Watercolor {
100    pub darkening: f32,
101    pub grain: f32,
102    pub scale: f32,
103}
104
105#[derive(Clone, Copy, Debug)]
106pub struct PaperGrain {
107    pub strength: f32,
108    pub scale: f32,
109}
110
111#[derive(Clone, Copy, Debug)]
112pub struct Pixelate {
113    pub size: u32,
114    pub levels: u32,
115    pub palette: bool,
116}
117
118#[derive(Clone, Copy, Debug)]
119pub struct Duotone {
120    pub shadow: [f32; 3],
121    pub highlight: [f32; 3],
122}
123
124#[derive(Clone, Copy, Debug)]
125pub struct GradientMap {
126    pub stops: [[f32; 4]; 4],
127    pub count: u8,
128}
129
130#[derive(Clone, Copy, Debug)]
131pub struct Neon {
132    pub saturation: f32,
133    pub strength: f32,
134    pub radius: u32,
135}
136
137#[derive(Clone, Copy, Debug)]
138pub struct Scratches {
139    pub count: u32,
140    pub strength: f32,
141}
142
143#[derive(Clone, Copy, Debug)]
144pub struct Halation {
145    pub strength: f32,
146    pub threshold: f32,
147    pub radius: u32,
148}
149
150#[derive(Clone, Copy, Debug)]
151pub struct Scanlines {
152    pub strength: f32,
153    pub period: u32,
154}
155
156#[derive(Clone, Copy, Debug)]
157pub struct TiltShift {
158    pub focus: f32,
159    pub range: f32,
160    pub radius: f32,
161}
162
163pub fn cel(image: &Image, pass: &Cel, normal: Option<&[[f32; 3]]>) -> Image {
164    let light_dir = color::normalize(pass.light);
165    let bands = pass.bands.max(1);
166    image.map_rgb(|x, y, c| {
167        let l = color::luma(c, REC709).max(0.0);
168        let (light, from_normal) = if let Some(normals) = normal {
169            let n = color::normalize(normals[image.index(x, y)]);
170            (color::dot3(n, light_dir).clamp(0.0, 1.0), true)
171        } else {
172            (l.clamp(0.0, 1.0), false)
173        };
174        let shade = band_shade(light, bands, pass.softness, pass.threshold, pass.shadow)
175            .max(if from_normal { 0.55 } else { 0.18 });
176        let gray = c[0] == c[1] && c[1] == c[2];
177        let mut out = if from_normal {
178            [c[0] * shade, c[1] * shade, c[2] * shade]
179        } else if gray || l <= 1e-5 {
180            [shade, shade, shade]
181        } else {
182            let s = shade / l;
183            [c[0] * s, c[1] * s, c[2] * s]
184        };
185        if pass.spec > 0.0 {
186            let rough = pass.spec_roughness.max(0.04);
187            let denom = (1.0 - pass.spec_threshold).max(1e-3);
188            let t = ((light - pass.spec_threshold) / denom).clamp(0.0, 1.0);
189            let s = color::safe_pow(t, 1.0 / rough) * pass.spec;
190            out[0] += s;
191            out[1] += s;
192            out[2] += s;
193        }
194        if from_normal && light < 0.55 {
195            let cool = (0.55 - light) * 0.11;
196            out[0] *= 1.0 - cool;
197            out[1] += cool * 0.35;
198            out[2] += cool;
199        }
200        out = color::saturate(out, 1.12, REC709);
201        out
202    })
203}
204
205fn band_shade(light: f32, bands: u32, softness: f32, threshold: f32, shadow: f32) -> f32 {
206    let bands_f = bands.max(1) as f32;
207    let light = light.clamp(0.0, 1.0);
208    let x = (light * bands_f).min(bands_f - 1e-4);
209    let frac = x.fract();
210    let w = if softness <= 0.0 {
211        0.0
212    } else {
213        color::smoothstep(0.5 - softness * 0.5, 0.5 + softness * 0.5, frac)
214    };
215    let idx = x.floor() + w;
216    let q = if bands_f <= 1.0 {
217        light
218    } else {
219        (idx / (bands_f - 1.0)).clamp(0.0, 1.0)
220    };
221    let gate = if softness <= 1e-5 {
222        if light < threshold { 0.0 } else { 1.0 }
223    } else {
224        color::smoothstep(threshold - softness, threshold + softness, light)
225    };
226    q * (shadow + (1.0 - shadow) * gate)
227}
228
229pub fn outline(
230    image: &Image,
231    pass: &Outline,
232    depth: Option<&[f32]>,
233    normal: Option<&[[f32; 3]]>,
234) -> Image {
235    if !pass.enabled {
236        return image.clone();
237    }
238    let radius = pass.thickness.clamp(1.0, 8.0);
239    let rad = radius.ceil() as i32;
240    let min_dot = pass.crease_angle.to_radians().cos();
241    image.map_rgb(|x, y, c| {
242        let mut edge: f32 = 0.0;
243        let i = image.index(x, y);
244        for dy in -rad..=rad {
245            for dx in -rad..=rad {
246                if dx == 0 && dy == 0 {
247                    continue;
248                }
249                let dist = ((dx * dx + dy * dy) as f32).sqrt();
250                if dist > radius {
251                    continue;
252                }
253                if pass.local_color {
254                    let neighbor = image.get(x as i32 + dx, y as i32 + dy);
255                    let gap = (c[0] - neighbor[0])
256                        .abs()
257                        .max((c[1] - neighbor[1]).abs())
258                        .max((c[2] - neighbor[2]).abs());
259                    edge = edge.max(color::smoothstep(0.08, 0.22, gap));
260                }
261                if let Some(depth) = depth {
262                    let gap =
263                        (depth[i] - depth_at(image, depth, x as i32 + dx, y as i32 + dy)).abs();
264                    if gap > pass.depth_gap {
265                        let t = ((gap - pass.depth_gap) / pass.depth_gap.max(1e-4)).clamp(0.0, 1.0);
266                        edge = edge.max(t);
267                    }
268                }
269                if let Some(normals) = normal {
270                    let n = color::normalize(normals[i]);
271                    let nn =
272                        color::normalize(normal_at(image, normals, x as i32 + dx, y as i32 + dy));
273                    let dots = color::dot3(n, nn).clamp(-1.0, 1.0);
274                    if dots < min_dot {
275                        let t = ((min_dot - dots) / (1.0 + min_dot).max(0.1)).clamp(0.0, 1.0);
276                        edge = edge.max(t);
277                    }
278                }
279            }
280        }
281        if depth.is_none() && normal.is_none() {
282            edge = edge.max(color::sobel_luma(image, x as i32, y as i32).clamp(0.0, 1.0));
283        }
284        let a = (edge * pass.alpha).clamp(0.0, 1.0);
285        let ink = if pass.local_color {
286            let base = image.get(x as i32, y as i32);
287            [
288                (base[0] * 0.15).max(0.035),
289                (base[1] * 0.15).max(0.035),
290                (base[2] * 0.15).max(0.035),
291            ]
292        } else {
293            pass.color
294        };
295        color::lerp3(c, ink, a)
296    })
297}
298
299fn depth_at(image: &Image, depth: &[f32], x: i32, y: i32) -> f32 {
300    if image.width == 0 || image.height == 0 {
301        return 0.0;
302    }
303    let x = x.clamp(0, image.width as i32 - 1) as u32;
304    let y = y.clamp(0, image.height as i32 - 1) as u32;
305    depth[image.index(x, y)]
306}
307
308fn normal_at(image: &Image, normal: &[[f32; 3]], x: i32, y: i32) -> [f32; 3] {
309    if image.width == 0 || image.height == 0 {
310        return [0.0, 0.0, 1.0];
311    }
312    let x = x.clamp(0, image.width as i32 - 1) as u32;
313    let y = y.clamp(0, image.height as i32 - 1) as u32;
314    normal[image.index(x, y)]
315}
316
317pub fn cavity(image: &Image, pass: &Cavity, normal: Option<&[[f32; 3]]>) -> Image {
318    let Some(normals) = normal else {
319        return image.clone();
320    };
321    if pass.strength.abs() < 1e-8 {
322        return image.clone();
323    }
324    let d = pass.distance.max(1.0).round() as i32;
325    image.map_rgb(|x, y, c| {
326        let n = color::normalize(normals[image.index(x, y)]);
327        let mut bend = 0.0;
328        for (dx, dy) in [(d, 0), (-d, 0), (0, d), (0, -d)] {
329            let nn = color::normalize(normal_at(image, normals, x as i32 + dx, y as i32 + dy));
330            bend += (1.0 - color::dot3(n, nn)).max(0.0);
331        }
332        let occ = (bend * 0.25).clamp(0.0, 1.0);
333        let k = (1.0 - pass.strength * occ).max(0.0);
334        [c[0] * k, c[1] * k, c[2] * k]
335    })
336}
337
338pub fn rim(image: &Image, pass: &Rim, normal: Option<&[[f32; 3]]>) -> Image {
339    let Some(normals) = normal else {
340        return image.clone();
341    };
342    if pass.strength.abs() < 1e-8 {
343        return image.clone();
344    }
345    let power = (1.0 / pass.width.max(0.05)).clamp(0.05, 32.0);
346    image.map_rgb(|x, y, c| {
347        let n = color::normalize(normals[image.index(x, y)]);
348        let ndotv = n[2].abs().clamp(0.0, 1.0);
349        let fres = color::safe_pow((1.0 - ndotv).clamp(0.0, 1.0), power);
350        [
351            c[0] + pass.color[0] * fres * pass.strength,
352            c[1] + pass.color[1] * fres * pass.strength,
353            c[2] + pass.color[2] * fres * pass.strength,
354        ]
355    })
356}
357
358pub fn bw(image: &Image, pass: &Bw) -> Image {
359    image.map_rgb(|_, _, c| {
360        let l = color::luma(c, pass.weights);
361        [l, l, l]
362    })
363}
364
365pub fn noir(image: &Image, pass: &Noir) -> Image {
366    image.map_rgb(|_, _, c| {
367        let l = color::luma(c, REC709);
368        let gray = [l, l, l];
369        let mut out = gray;
370        if let Some(keep) = pass.keep {
371            let kl = color::luma(keep, REC709);
372            let kdir = [keep[0] - kl, keep[1] - kl, keep[2] - kl];
373            let chroma = [c[0] - l, c[1] - l, c[2] - l];
374            let kn = color::length3(kdir);
375            let cn = color::length3(chroma);
376            if kn > 1e-4 && cn > 1e-4 {
377                let d = color::dot3(chroma, kdir) / (kn * cn);
378                let gate = color::smoothstep(pass.keep_range, 1.0, d);
379                out = color::lerp3(gray, c, gate);
380            }
381        }
382        out = [
383            (out[0] - 0.5) * pass.contrast + 0.5,
384            (out[1] - 0.5) * pass.contrast + 0.5,
385            (out[2] - 0.5) * pass.contrast + 0.5,
386        ];
387        if pass.crush < 0.999 {
388            let denom = (1.0 - pass.crush).max(1e-4);
389            out = [
390                ((out[0] - pass.crush) / denom).max(0.0),
391                ((out[1] - pass.crush) / denom).max(0.0),
392                ((out[2] - pass.crush) / denom).max(0.0),
393            ];
394        }
395        [out[0].max(0.0), out[1].max(0.0), out[2].max(0.0)]
396    })
397}
398
399pub fn sepia(image: &Image, strength: f32) -> Image {
400    image.map_rgb(|_, _, c| {
401        let tint = [
402            color::dot3(c, [0.393, 0.769, 0.189]),
403            color::dot3(c, [0.349, 0.686, 0.168]),
404            color::dot3(c, [0.272, 0.534, 0.131]),
405        ];
406        color::lerp3(c, tint, strength.clamp(0.0, 1.0))
407    })
408}
409
410pub fn posterize(image: &Image, levels: f32) -> Image {
411    let levels = levels.max(2.0);
412    image.map_rgb(|_, _, c| c.map(|v| quantize_channel(v, levels)))
413}
414
415fn quantize_channel(v: f32, levels: f32) -> f32 {
416    let t = v.clamp(0.0, 1.0);
417    let q = (t * levels).floor().min(levels - 1.0);
418    q / (levels - 1.0)
419}
420
421pub fn one_bit(image: &Image, pass: &OneBit) -> Image {
422    image.map_rgb(|x, y, c| {
423        let l = color::luma(c, REC709).clamp(0.0, 1.0);
424        let t = match pass.kind {
425            NoiseKind::Ordered => hash::bayer(x, y),
426            NoiseKind::Blue => hash::blue_noise(x, y),
427        };
428        let v = if l >= t { 1.0 } else { 0.0 };
429        [v, v, v]
430    })
431}
432
433pub fn halftone(image: &Image, pass: &Halftone) -> Image {
434    let rad = pass.angle.to_radians();
435    let (s, c) = rad.sin_cos();
436    let cell = pass.cell.max(1.0);
437    image.map_rgb(|x, y, color| {
438        let fx = x as f32 * c + y as f32 * s;
439        let fy = -(x as f32) * s + y as f32 * c;
440        let lx = fract(fx / cell) - 0.5;
441        let ly = fract(fy / cell) - 0.5;
442        let dist = (lx * lx + ly * ly).sqrt();
443        let l = color::luma(color, REC709).clamp(0.0, 1.0);
444        let radius = (1.0 - l).sqrt() * 0.5;
445        if dist < radius { pass.ink } else { color }
446    })
447}
448
449fn fract(v: f32) -> f32 {
450    v - v.floor()
451}
452
453pub fn comic(
454    image: &Image,
455    pass: &Comic,
456    depth: Option<&[f32]>,
457    normal: Option<&[[f32; 3]]>,
458) -> Image {
459    let angle = 15.0_f32.to_radians();
460    let (s, cs) = angle.sin_cos();
461    let cell = pass.levels.max(2) as f32 + 2.0;
462    image.map_rgb(|x, y, c| {
463        let light = color::luma(c, REC709).clamp(0.0, 1.0);
464        let peak = c[0].max(c[1]).max(c[2]);
465        let base = if peak - c[0].min(c[1]).min(c[2]) < 0.12 {
466            [0.97, 0.93, 0.78]
467        } else if c[0] >= c[1] && c[0] >= c[2] {
468            if c[1] > c[0] * 0.75 {
469                [0.98, 0.77, 0.12]
470            } else {
471                [0.91, 0.12, 0.16]
472            }
473        } else if c[1] >= c[2] {
474            [0.11, 0.68, 0.39]
475        } else {
476            [0.12, 0.38, 0.82]
477        };
478        let fx = x as f32 * cs + y as f32 * s;
479        let fy = -(x as f32) * s + y as f32 * cs;
480        let dx = fract(fx / cell) - 0.5;
481        let dy = fract(fy / cell) - 0.5;
482        let radius = (1.0 - light).sqrt() * 0.34;
483        let dist = (dx * dx + dy * dy).sqrt();
484        let dot = 1.0 - color::smoothstep(radius - 0.5 / cell, radius + 0.5 / cell, dist);
485        let mut ink =
486            color::smoothstep(0.12, 0.30, color::sobel_luma(image, x as i32, y as i32)) * 0.65;
487        for (ox, oy) in [(-2, 0), (2, 0), (0, -2), (0, 2)] {
488            let n = image.get(x as i32 + ox, y as i32 + oy);
489            let gap = (c[0] - n[0])
490                .abs()
491                .max((c[1] - n[1]).abs())
492                .max((c[2] - n[2]).abs());
493            ink = ink.max(color::smoothstep(0.08, 0.22, gap));
494        }
495        if let Some(d) = depth {
496            let z = d[image.index(x, y)];
497            for (ox, oy) in [(-2, 0), (2, 0), (0, -2), (0, 2)] {
498                ink = ink.max(color::smoothstep(
499                    0.002,
500                    0.012,
501                    (z - depth_at(image, d, x as i32 + ox, y as i32 + oy)).abs(),
502                ));
503            }
504        }
505        if let Some(n) = normal {
506            let a = color::normalize(n[image.index(x, y)]);
507            for (ox, oy) in [(-1, 0), (1, 0), (0, -1), (0, 1)] {
508                let b = color::normalize(normal_at(image, n, x as i32 + ox, y as i32 + oy));
509                ink = ink.max(color::smoothstep(0.3, 0.6, 1.0 - color::dot3(a, b)) * 0.55);
510            }
511        }
512        let covered = ink.max(dot * 0.55) * pass.ink.clamp(0.0, 1.0);
513        color::lerp3(base, [0.004, 0.003, 0.008], covered)
514    })
515}
516
517pub fn rubber_hose(
518    image: &Image,
519    depth: Option<&[f32]>,
520    normal: Option<&[[f32; 3]]>,
521    frame: u32,
522    seed: u32,
523) -> Image {
524    let mixed = hash::mixed(seed, frame);
525    image.map_rgb(|x, y, c| {
526        let xi = x as i32;
527        let yi = y as i32;
528        let wobble = hash::grain_hash(x / 32, y / 32, mixed);
529        let shift = if wobble < 0.33 {
530            -1
531        } else if wobble > 0.66 {
532            1
533        } else {
534            0
535        };
536        let radius = 5 + (hash::grain_hash(x / 48, y / 48, mixed ^ 0x12ab_9921) > 0.5) as i32;
537        let mut edge = 0.0_f32;
538        if let Some(d) = depth {
539            let z = depth_at(image, d, xi + shift, yi);
540            for (dx, dy) in [(radius, 0), (-radius, 0), (0, radius), (0, -radius)] {
541                edge = edge.max(color::smoothstep(
542                    0.002,
543                    0.012,
544                    (z - depth_at(image, d, xi + shift + dx, yi + dy)).abs(),
545                ));
546            }
547        }
548        if let Some(n) = normal {
549            let a = color::normalize(normal_at(image, n, xi + shift, yi));
550            for (dx, dy) in [(2, 0), (-2, 0), (0, 2), (0, -2)] {
551                let b = color::normalize(normal_at(image, n, xi + shift + dx, yi + dy));
552                edge = edge.max(color::smoothstep(0.3, 0.65, 1.0 - color::dot3(a, b)));
553            }
554        }
555        edge =
556            edge.max(color::smoothstep(0.12, 0.32, color::sobel_luma(image, xi + shift, yi)) * 0.7);
557        let center = image.get(xi + shift, yi);
558        for (dx, dy) in [(radius, 0), (-radius, 0), (0, radius), (0, -radius)] {
559            let neighbor = image.get(xi + shift + dx, yi + dy);
560            let gap = (center[0] - neighbor[0])
561                .abs()
562                .max((center[1] - neighbor[1]).abs())
563                .max((center[2] - neighbor[2]).abs());
564            edge = edge.max(color::smoothstep(0.08, 0.22, gap));
565        }
566        let l = color::luma(c, REC709).clamp(0.0, 1.0);
567        let dark = depth.is_none_or(|values| values[image.index(x, y)] < 0.999)
568            && (normal.is_some_and(|normals| {
569                let n = color::normalize(normals[image.index(x, y)]);
570                color::dot3(n, [0.0, 0.0, 1.0]) > 0.1
571                    && color::dot3(n, color::normalize([-0.45, 0.65, 0.61])) < 0.1
572            }) || l < 0.18);
573        let fill = if l > 0.56 { 0.91 } else { 0.77 };
574        let grain = (hash::grain_hash(x, y, mixed) - 0.5) * 0.07;
575        let flicker = (hash::grain_hash(0, 0, mixed ^ 0x55aa_10ef) - 0.5) * 0.08;
576        let u = (x as f32 / image.width.max(1) as f32 - 0.5) * 2.0;
577        let v = (y as f32 / image.height.max(1) as f32 - 0.5) * 2.0;
578        let vignette = 1.0 - 0.25 * (u * u + v * v).min(1.0);
579        let dust = hash::grain_hash(x / 3, y / 3, mixed ^ 0x2983_7781) < 0.0008;
580        let light = ((fill + grain + flicker) * vignette).clamp(0.0, 1.0);
581        let ink = if edge > 0.5 || dust || dark {
582            0.003
583        } else {
584            light
585        };
586        [ink * 1.0, ink * 0.91, ink * 0.73]
587    })
588}
589
590pub fn watercolor(image: &Image, pass: &Watercolor, frame: u32, seed: u32) -> Image {
591    let scale = pass.scale.max(1.0);
592    let mixed = hash::mixed(seed, frame) ^ 0xA9E1_0006;
593    image.map_rgb(|x, y, c| {
594        let e = color::sobel_luma(image, x as i32, y as i32).clamp(0.0, 1.0);
595        let dark = (1.0 - pass.darkening * e).max(0.0);
596        let n = hash::value_noise(x as f32 / scale, y as f32 / scale, mixed);
597        let grain = 1.0 + (n - 0.5) * pass.grain;
598        [
599            (c[0] * dark * grain).max(0.0),
600            (c[1] * dark * grain).max(0.0),
601            (c[2] * dark * grain).max(0.0),
602        ]
603    })
604}
605
606pub fn paper_grain(image: &Image, pass: &PaperGrain, frame: u32, seed: u32) -> Image {
607    let scale = pass.scale.max(1.0);
608    let mixed = hash::mixed(seed, frame) ^ 0xA9E1_0006;
609    image.map_rgb(|x, y, c| {
610        let n = hash::value_noise(x as f32 / scale, y as f32 / scale, mixed);
611        let grain = 1.0 + (n - 0.5) * pass.strength;
612        [
613            (c[0] * grain).max(0.0),
614            (c[1] * grain).max(0.0),
615            (c[2] * grain).max(0.0),
616        ]
617    })
618}
619
620pub fn pixelate(image: &Image, pass: &Pixelate) -> Image {
621    let size = pass.size.max(1);
622    let mut out = image.clone();
623    let mut y = 0;
624    while y < image.height {
625        let y1 = (y + size).min(image.height);
626        let mut x = 0;
627        while x < image.width {
628            let x1 = (x + size).min(image.width);
629            let mut acc = [0.0; 3];
630            let mut n = 0.0;
631            for py in y..y1 {
632                for px in x..x1 {
633                    let p = image.at(px, py);
634                    acc[0] += p[0];
635                    acc[1] += p[1];
636                    acc[2] += p[2];
637                    n += 1.0;
638                }
639            }
640            let mut mean = [acc[0] / n, acc[1] / n, acc[2] / n];
641            if pass.palette {
642                mean = nearest(mean);
643            } else if pass.levels >= 2 {
644                let levels = pass.levels as f32;
645                mean = mean.map(|v| quantize_channel(v, levels));
646            }
647            for py in y..y1 {
648                for px in x..x1 {
649                    let p = image.at(px, py);
650                    out.pixels[image.index(px, py)] = image::px(mean, p[3]);
651                }
652            }
653            x = x1;
654        }
655        y = y1;
656    }
657    out
658}
659
660fn nearest(c: [f32; 3]) -> [f32; 3] {
661    let mut best = PALETTE[0];
662    let mut best_d = f32::MAX;
663    for p in PALETTE {
664        let d = (c[0] - p[0]).powi(2) + (c[1] - p[1]).powi(2) + (c[2] - p[2]).powi(2);
665        if d < best_d {
666            best_d = d;
667            best = p;
668        }
669    }
670    best
671}
672
673pub fn duotone(image: &Image, pass: &Duotone) -> Image {
674    image.map_rgb(|_, _, c| {
675        let l = color::luma(c, REC709).clamp(0.0, 1.0);
676        color::lerp3(pass.shadow, pass.highlight, l)
677    })
678}
679
680pub fn gradient_map(image: &Image, pass: &GradientMap) -> Image {
681    let count = pass.count.clamp(2, 4) as usize;
682    image.map_rgb(|_, _, c| {
683        let l = color::luma(c, REC709).clamp(0.0, 1.0);
684        let mut lower = pass.stops[0];
685        let mut upper = pass.stops[count - 1];
686        for stop in pass.stops.iter().take(count) {
687            if stop[3] <= l {
688                lower = *stop;
689            }
690        }
691        for stop in pass.stops.iter().take(count) {
692            if stop[3] >= l {
693                upper = *stop;
694                break;
695            }
696        }
697        let span = upper[3] - lower[3];
698        let t = if span.abs() < 1e-6 {
699            0.0
700        } else {
701            ((l - lower[3]) / span).clamp(0.0, 1.0)
702        };
703        color::lerp3(
704            [lower[0], lower[1], lower[2]],
705            [upper[0], upper[1], upper[2]],
706            t,
707        )
708    })
709}
710
711pub fn kuwahara(image: &Image, radius: u32) -> Image {
712    let r = radius.min(8) as i32;
713    if r <= 0 {
714        return image.clone();
715    }
716    let quads = [(-r, 0, -r, 0), (0, r, -r, 0), (-r, 0, 0, r), (0, r, 0, r)];
717    image.map_rgb(|x, y, _| {
718        let mut best_mean = [0.0; 3];
719        let mut best_var = f32::MAX;
720        for (x0, x1, y0, y1) in quads {
721            let mut sum = [0.0; 3];
722            let mut sq = 0.0;
723            let mut n = 0.0;
724            for dy in y0..=y1 {
725                for dx in x0..=x1 {
726                    let p = image.get(x as i32 + dx, y as i32 + dy);
727                    sum[0] += p[0];
728                    sum[1] += p[1];
729                    sum[2] += p[2];
730                    let l = color::luma(image::rgb(p), REC709);
731                    sq += l * l;
732                    n += 1.0;
733                }
734            }
735            let inv = 1.0 / n;
736            let mean = [sum[0] * inv, sum[1] * inv, sum[2] * inv];
737            let ml = color::luma(mean, REC709);
738            let var = (sq * inv - ml * ml).max(0.0);
739            if var < best_var {
740                best_var = var;
741                best_mean = mean;
742            }
743        }
744        best_mean
745    })
746}
747
748pub fn neon(image: &Image, pass: &Neon) -> Image {
749    if pass.radius == 0 || pass.strength.abs() < 1e-8 {
750        return image.map_rgb(|_, _, c| color::saturate(c, pass.saturation, REC709));
751    }
752    let width = (image.width / 2).max(1);
753    let height = (image.height / 2).max(1);
754    let edge = Image::from_fn(width, height, |x, y| {
755        let sx = (x * 2 + 1) as i32;
756        let sy = (y * 2 + 1) as i32;
757        let e = color::sobel_luma(image, sx, sy).clamp(0.0, 4.0);
758        let c = color::saturate(image::rgb(image.get(sx, sy)), pass.saturation, REC709);
759        [c[0] * e, c[1] * e, c[2] * e, 1.0]
760    });
761    let blurred = grade::separable(
762        &edge,
763        pass.radius.min(8).div_ceil(2),
764        pass.radius.max(1) as f32 * 1.5,
765    );
766    let saturated = image.map_rgb(|_, _, c| color::saturate(c, pass.saturation, REC709));
767    crate::bloom::add(&saturated, &blurred, pass.strength)
768}
769
770pub fn flicker(image: &Image, amount: f32, frame: u32, seed: u32) -> Image {
771    if amount.abs() < 1e-8 {
772        return image.clone();
773    }
774    let h = grain_hash(frame, 0, seed ^ 0xF11C_E001);
775    let scale = 1.0 + (h - 0.5) * amount;
776    image.map_rgb(|_, _, c| [c[0] * scale, c[1] * scale, c[2] * scale])
777}
778
779pub fn weave(image: &Image, amount: f32, frame: u32, seed: u32) -> Image {
780    if amount.abs() < 1e-8 {
781        return image.clone();
782    }
783    let hx = grain_hash(frame, 1, seed ^ 0x11A0_0001);
784    let hy = grain_hash(frame, 2, seed ^ 0x22B0_0002);
785    let dx = (hx - 0.5) * 2.0 * amount;
786    let dy = (hy - 0.5) * 4.0 * amount;
787    Image::from_fn(image.width, image.height, |x, y| {
788        image.sample(x as f32 - dx, y as f32 - dy)
789    })
790}
791
792pub fn dust(image: &Image, amount: f32, frame: u32, seed: u32) -> Image {
793    if amount <= 0.0 {
794        return image.clone();
795    }
796    let mixed = hash::mixed(seed, frame) ^ 0xD057_0003;
797    image.map_rgb(|x, y, c| {
798        let h = grain_hash(x, y, mixed);
799        if h < amount {
800            let a = 0.55 + 0.45 * grain_hash(x, y, mixed ^ 0x51);
801            [c[0] + a, c[1] + a, c[2] + a]
802        } else {
803            c
804        }
805    })
806}
807
808pub fn scratches(image: &Image, pass: &Scratches, frame: u32, seed: u32) -> Image {
809    if pass.strength.abs() < 1e-8 || pass.count == 0 || image.width == 0 {
810        return image.clone();
811    }
812    let width = image.width as f32;
813    image.map_rgb(|x, y, c| {
814        let mut out = c;
815        for i in 0..pass.count {
816            let x0 = grain_hash(i, frame, seed ^ 0x5C8A_0005) * width;
817            let wobble = (grain_hash(i, y / 8, seed ^ 0x5C8A_0004) - 0.5) * 3.0;
818            let dist = (x as f32 - x0 - wobble).abs();
819            if dist < 0.8 {
820                let gap = grain_hash(i.wrapping_mul(97).wrapping_add(x), y, seed ^ frame);
821                if gap > 0.4 {
822                    let s = pass.strength * (1.0 - dist / 0.8);
823                    out[0] += s;
824                    out[1] += s;
825                    out[2] += s;
826                }
827            }
828        }
829        out
830    })
831}
832
833pub fn halation(image: &Image, pass: &Halation) -> Image {
834    if pass.strength.abs() < 1e-8 {
835        return image.clone();
836    }
837    let small = grade::downsample(image, 4, grade::Downsample::Tent);
838    let hot = small.map_rgb(|_, _, c| {
839        let l = color::luma(c, REC709);
840        let h = (l - pass.threshold).max(0.0);
841        [h, h * 0.25, h * 0.05]
842    });
843    let blurred = grade::separable(
844        &hot,
845        pass.radius.div_ceil(4),
846        (pass.radius.max(1) as f32) * 0.25,
847    );
848    crate::bloom::add(image, &blurred, pass.strength)
849}
850
851pub fn aberration(image: &Image, amount: f32) -> Image {
852    if amount.abs() < 1e-6 {
853        return image.clone();
854    }
855    let cx = (image.width.saturating_sub(1)) as f32 * 0.5;
856    let cy = (image.height.saturating_sub(1)) as f32 * 0.5;
857    Image::from_fn(image.width, image.height, |x, y| {
858        let dx = x as f32 - cx;
859        let dy = y as f32 - cy;
860        let len = (dx * dx + dy * dy).sqrt();
861        let (ux, uy) = if len < 1e-4 {
862            (0.0, 0.0)
863        } else {
864            (dx / len, dy / len)
865        };
866        let r = image.sample(x as f32 + ux * amount, y as f32 + uy * amount);
867        let g = image.at(x, y);
868        let b = image.sample(x as f32 - ux * amount, y as f32 - uy * amount);
869        [r[0], g[1], b[2], g[3]]
870    })
871}
872
873pub fn distort(image: &Image, amount: f32) -> Image {
874    if amount.abs() < 1e-8 {
875        return image.clone();
876    }
877    remap(image, amount)
878}
879
880pub fn curvature(image: &Image, amount: f32) -> Image {
881    distort(image, amount)
882}
883
884fn remap(image: &Image, amount: f32) -> Image {
885    let w = image.width.max(1) as f32;
886    let h = image.height.max(1) as f32;
887    Image::from_fn(image.width, image.height, |x, y| {
888        let nx = (x as f32 + 0.5) / w * 2.0 - 1.0;
889        let ny = (y as f32 + 0.5) / h * 2.0 - 1.0;
890        let r2 = nx * nx + ny * ny;
891        let f = 1.0 + amount * r2;
892        let sx = (nx * f * 0.5 + 0.5) * w - 0.5;
893        let sy = (ny * f * 0.5 + 0.5) * h - 0.5;
894        image.sample(sx, sy)
895    })
896}
897
898pub fn scanlines(image: &Image, pass: &Scanlines) -> Image {
899    if pass.strength.abs() < 1e-8 {
900        return image.clone();
901    }
902    let period = pass.period.max(2);
903    image.map_rgb(|_, y, c| {
904        if y % period == period - 1 {
905            let k = (1.0 - pass.strength).max(0.0);
906            [c[0] * k, c[1] * k, c[2] * k]
907        } else {
908            c
909        }
910    })
911}
912
913pub fn aperture(image: &Image, strength: f32) -> Image {
914    if strength.abs() < 1e-8 {
915        return image.clone();
916    }
917    let s = strength.clamp(0.0, 1.0);
918    image.map_rgb(|x, _, c| match x % 3 {
919        0 => [c[0], c[1] * (1.0 - s), c[2] * (1.0 - s)],
920        1 => [c[0] * (1.0 - s), c[1], c[2] * (1.0 - s)],
921        _ => [c[0] * (1.0 - s), c[1] * (1.0 - s), c[2]],
922    })
923}
924
925pub fn tilt_shift(image: &Image, pass: &TiltShift, depth: Option<&[f32]>) -> Image {
926    let Some(depth) = depth else {
927        return image.clone();
928    };
929    if pass.radius < 0.35 || pass.range.abs() < 1e-6 {
930        return image.clone();
931    }
932    let taps = 8u32;
933    Image::from_fn(image.width, image.height, |x, y| {
934        let z = depth[image.index(x, y)];
935        let coc = ((z - pass.focus).abs() / pass.range).clamp(0.0, 1.0) * pass.radius;
936        if coc < 0.35 {
937            return image.at(x, y);
938        }
939        let center = image.at(x, y);
940        let mut acc = center;
941        let mut weight = 1.0;
942        for k in 0..taps {
943            let a = k as f32 * std::f32::consts::TAU / taps as f32;
944            let d = coc * ((k as f32 + 0.5) / taps as f32).sqrt();
945            let s = image.sample(x as f32 + a.cos() * d, y as f32 + a.sin() * d);
946            for ch in 0..4 {
947                acc[ch] += s[ch];
948            }
949            weight += 1.0;
950        }
951        [
952            acc[0] / weight,
953            acc[1] / weight,
954            acc[2] / weight,
955            acc[3] / weight,
956        ]
957    })
958}