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}