1use crate::chain::{Chain, Pass};
2use crate::hash::{grain_hash, mixed};
3use crate::image::Image;
4
5pub const WGSL: &str = include_str!("tape.wgsl");
6
7pub const SPLIT: [f32; 2] = [0.0034, 0.0039];
8pub const MIX: [[f32; 3]; 3] = [[0.41, 0.59, 0.0], [0.92, 0.06, 0.02], [0.45, 0.08, 0.47]];
9pub const ECHOES: [(f32, f32); 2] = [(0.007, 0.25), (0.016, 0.10)];
10pub const SOFT: f32 = 0.5;
11pub const BLEED: f32 = 0.75 / 1920.0;
12pub const LIFT: f32 = 0.07;
13pub const DESATURATE: f32 = 0.22;
14pub const GRAIN: f32 = 0.008;
15pub const SCANLINES: f32 = 0.04;
16pub const SHOVE: f32 = 0.0025;
17pub const JITTER: f32 = 0.0003;
18pub const BANDS: [Band; 3] = [
19 Band {
20 turn: 0.618_034,
21 start: 0.0,
22 speed: 0.21,
23 width: 0.05,
24 gain: 0.9,
25 },
26 Band {
27 turn: 0.414_213_56,
28 start: 0.37,
29 speed: 0.34,
30 width: 0.022,
31 gain: 1.0,
32 },
33 Band {
34 turn: 0.732_050_8,
35 start: 0.71,
36 speed: 0.13,
37 width: 0.09,
38 gain: 0.6,
39 },
40];
41
42#[derive(Clone, Copy, Debug, PartialEq)]
43pub struct Band {
44 pub turn: f32,
45 pub start: f32,
46 pub speed: f32,
47 pub width: f32,
48 pub gain: f32,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum Direction {
53 Forward,
54 Rewind,
55}
56
57impl Direction {
58 pub fn name(self) -> &'static str {
59 match self {
60 Self::Forward => "forward",
61 Self::Rewind => "rewind",
62 }
63 }
64
65 pub fn parse(name: &str) -> Option<Self> {
66 [Self::Forward, Self::Rewind]
67 .into_iter()
68 .find(|direction| direction.name() == name)
69 }
70
71 pub fn lead(self) -> f32 {
72 match self {
73 Self::Forward => -1.0,
74 Self::Rewind => 1.0,
75 }
76 }
77}
78
79#[derive(Clone, Copy, Debug, PartialEq)]
80pub struct Tape {
81 pub strength: f32,
82 pub direction: Direction,
83 pub split: Option<f32>,
84 pub echo: Option<f32>,
85 pub wash: Option<f32>,
86 pub smear: Option<f32>,
87 pub grain: Option<f32>,
88 pub bands: Option<f32>,
89}
90
91#[derive(Clone, Copy, Debug, PartialEq)]
92pub struct Amounts {
93 pub split: f32,
94 pub echo: f32,
95 pub wash: f32,
96 pub smear: f32,
97 pub grain: f32,
98 pub bands: f32,
99}
100
101impl Default for Tape {
102 fn default() -> Self {
103 Self::OFF
104 }
105}
106
107impl Tape {
108 pub const OFF: Self = Self::new(0.0, Direction::Forward);
109
110 pub const fn new(strength: f32, direction: Direction) -> Self {
111 Self {
112 strength,
113 direction,
114 split: None,
115 echo: None,
116 wash: None,
117 smear: None,
118 grain: None,
119 bands: None,
120 }
121 }
122
123 pub const fn forward(strength: f32) -> Self {
124 Self::new(strength, Direction::Forward)
125 }
126
127 pub const fn rewind(strength: f32) -> Self {
128 Self::new(strength, Direction::Rewind)
129 }
130
131 pub fn strength(&self) -> f32 {
132 if self.strength.is_finite() {
133 self.strength.clamp(0.0, 1.0)
134 } else {
135 0.0
136 }
137 }
138
139 pub fn is_off(&self) -> bool {
140 self.strength() <= 0.0
141 }
142
143 pub fn amounts(&self) -> Amounts {
144 let strength = self.strength();
145 let amount = |value: Option<f32>, default: f32| {
146 let value = value.unwrap_or(default);
147 if value.is_finite() {
148 strength * value.max(0.0)
149 } else {
150 0.0
151 }
152 };
153 Amounts {
154 split: amount(self.split, 1.0),
155 echo: amount(self.echo, 0.0).min(2.0),
156 wash: amount(self.wash, 1.0).min(1.0),
157 smear: amount(self.smear, 0.0).min(1.0),
158 grain: amount(self.grain, 1.0),
159 bands: amount(self.bands, 1.0),
160 }
161 }
162
163 pub fn params(&self) -> [f32; 16] {
164 let a = self.amounts();
165 [
166 SPLIT[0] * a.split,
167 SPLIT[1] * a.split,
168 0.0,
169 self.direction.lead(),
170 ECHOES[0].1 * a.echo,
171 ECHOES[1].1 * a.echo,
172 SOFT * a.smear,
173 BLEED * a.smear,
174 LIFT * a.wash,
175 1.0 - DESATURATE * a.smear,
176 GRAIN * a.grain,
177 SCANLINES * a.grain,
178 a.bands,
179 SHOVE,
180 0.0,
181 self.strength(),
182 ]
183 }
184
185 pub fn split_pixels(&self, width: u32) -> [f32; 2] {
186 let p = self.params();
187 [p[0] * width as f32, p[1] * width as f32]
188 }
189
190 pub fn echo_pixels(&self, width: u32) -> [f32; 2] {
191 ECHOES.map(|(offset, _)| offset * width as f32)
192 }
193
194 pub fn band_centres(&self, time: f32, seed: u32) -> [f32; 3] {
195 let roll = -self.direction.lead();
196 BANDS.map(|band| centre(roll, time, &band, seed))
197 }
198
199 pub fn envelope(&self, y: f32, time: f32, seed: u32) -> f32 {
200 envelope(-self.direction.lead(), y, time, seed)
201 }
202}
203
204fn centre(roll: f32, time: f32, band: &Band, seed: u32) -> f32 {
205 let spun = (seed & 0xffff) as f32 * band.turn + band.start;
206 let travel = spun - spun.floor() + roll * band.speed * time;
207 (travel - travel.floor()) * (1.0 + 2.0 * band.width) - band.width
208}
209
210fn envelope(roll: f32, y: f32, time: f32, seed: u32) -> f32 {
211 BANDS
212 .iter()
213 .map(|band| {
214 let c = centre(roll, time, band, seed);
215 let d = (1.0 - (y - c).abs() / band.width).max(0.0);
216 d * d * band.gain
217 })
218 .fold(0.0, f32::max)
219}
220
221fn hash(x: u32, y: u32, seed: u32) -> u32 {
222 let mut h =
223 x.wrapping_mul(0x8da6_b343) ^ y.wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f);
224 h ^= h >> 15;
225 h = h.wrapping_mul(0x2c1b_3c6d);
226 h ^= h >> 12;
227 h
228}
229
230impl Chain {
231 pub fn tape(&self) -> Option<Tape> {
232 self.passes.iter().find_map(|pass| match pass {
233 Pass::Tape(tape) => Some(*tape),
234 _ => None,
235 })
236 }
237
238 pub fn set_tape(&mut self, tape: Tape) {
239 if let Some(Pass::Tape(slot)) = self
240 .passes
241 .iter_mut()
242 .find(|pass| matches!(pass, Pass::Tape(_)))
243 {
244 *slot = tape;
245 return;
246 }
247 let at = self
248 .passes
249 .iter()
250 .rposition(|pass| !matches!(pass, Pass::Encode))
251 .map_or(0, |index| index + 1);
252 self.passes.insert(at, Pass::Tape(tape));
253 }
254}
255
256const TO_I: [f32; 3] = [0.596, -0.274, -0.322];
257const TO_Q: [f32; 3] = [0.211, -0.523, 0.312];
258
259fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
260 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
261}
262
263fn root(c: [f32; 3]) -> [f32; 3] {
264 c.map(|v| v.max(0.0).sqrt())
265}
266
267pub fn apply(image: &Image, tape: &Tape, frame: u32, seed: u32) -> Image {
268 if tape.is_off() || image.width == 0 || image.height == 0 {
269 return image.clone();
270 }
271 let p = tape.params();
272 let fw = image.width as f32;
273 let lead = p[3];
274 let sr = lead * p[0] * fw;
275 let sb = lead * p[1] * fw;
276 let mix = mixed(seed, frame);
277 let t = frame as f32 / 60.0;
278 let roll = -lead;
279 let bands = p[12];
280 let d = p[7] * fw;
281 let (lift, saturation, grain, scan_depth) = (p[8], p[9], p[10], p[11]);
282 let period = (image.height as f32 / 540.0).round_ties_even().max(2.0);
283 let speck_scale = (image.width / 960).max(1);
284 let mut out = image.clone();
285 for row in 0..image.height {
286 let v = (row as f32 + 0.5) / image.height as f32;
287 let env = envelope(roll, v, t, seed);
288 let rh = hash(row, 17, mix);
289 let shove =
290 lead * bands * fw * (p[13] * env + JITTER * env * ((rh >> 16) as f32 / 32768.0 - 1.0));
291 let scan =
292 1.0 - scan_depth * (0.5 + 0.5 * (std::f32::consts::TAU * row as f32 / period).cos());
293 let at = |x: f32| image.sample(x - 0.5, row as f32);
294 for col in 0..image.width {
295 let x = col as f32 + 0.5 + shove + p[6];
296 let here = at(x);
297 let (left, right) = if d > 0.0 {
298 let pair = |p: f32| {
299 let a = at(p - d);
300 let b = at(p + d);
301 std::array::from_fn::<f32, 4, _>(|c| 0.5 * (a[c] + b[c]))
302 };
303 (pair(x + sr), pair(x - sb))
304 } else {
305 (at(x + sr), at(x - sb))
306 };
307 let mut split: [f32; 3] = std::array::from_fn(|c| {
308 MIX[c][0] * here[c] + MIX[c][1] * left[c] + MIX[c][2] * right[c]
309 });
310 if p[4] > 0.0 || p[5] > 0.0 {
311 let e1 = at(x + lead * ECHOES[0].0 * fw);
312 let e2 = at(x + lead * ECHOES[1].0 * fw);
313 let keep = 1.0 - p[4] - p[5];
314 split = std::array::from_fn(|c| split[c] * keep + p[4] * e1[c] + p[5] * e2[c]);
315 }
316 let split = root(split);
317 let mut luma = lift + (1.0 - lift) * dot(split, [0.299, 0.587, 0.114]);
318 let mut chroma = [dot(split, TO_I) * saturation, dot(split, TO_Q) * saturation];
319 if env > 0.0 {
320 let n = grain_hash(col / speck_scale, row, mix ^ 0x51);
321 let lo = 1.0 - 0.12 * env;
322 let u = ((n - lo) / (1.0 - lo)).clamp(0.0, 1.0);
323 luma += bands * env * 0.1 * u * u * (3.0 - 2.0 * u);
324 }
325 let h = hash(col, row, mix ^ 7);
326 let n = (h & 0x3ff) as f32 / 1024.0 + ((h >> 10) & 0x3ff) as f32 / 1024.0 - 1.0;
327 luma = (luma + n * grain) * scan;
328 chroma[0] += (((h >> 20) & 0x3f) as f32 / 64.0 - 0.5) * grain * 0.6;
329 chroma[1] += ((h >> 26) as f32 / 64.0 - 0.5) * grain * 0.6;
330 let g = [
331 luma + 0.956_170_7 * chroma[0] + 0.621_432_6 * chroma[1],
332 luma - 0.272_688_6 * chroma[0] - 0.646_813_2 * chroma[1],
333 luma - 1.103_744_1 * chroma[0] + 1.700_623_1 * chroma[1],
334 ]
335 .map(|v| v.clamp(0.0, 1.0));
336 let i = image.index(col, row);
337 out.pixels[i] = [g[0] * g[0], g[1] * g[1], g[2] * g[2], here[3]];
338 }
339 }
340 out
341}
342
343pub const KEYS: &[&str] = &[
344 "bands",
345 "direction",
346 "echo",
347 "grain",
348 "smear",
349 "split",
350 "strength",
351 "wash",
352];
353
354pub fn read(value: &toml::Value, current: Option<Tape>) -> Result<Tape, String> {
355 let mut tape = current.unwrap_or(Tape::forward(1.0));
356 let number = |value: &toml::Value, key: &str| {
357 value
358 .as_float()
359 .or_else(|| value.as_integer().map(|n| n as f64))
360 .map(|n| n as f32)
361 .filter(|n| n.is_finite() && *n >= 0.0)
362 .ok_or_else(|| format!("{key} is a number of at least 0"))
363 };
364 let direction = |value: &toml::Value| {
365 value
366 .as_str()
367 .and_then(Direction::parse)
368 .ok_or_else(|| "tape.direction is \"forward\" or \"rewind\"".to_string())
369 };
370 match value {
371 toml::Value::String(_) => tape.direction = direction(value)?,
372 toml::Value::Table(table) => {
373 if let Some(bad) = table.keys().find(|key| !KEYS.contains(&key.as_str())) {
374 return Err(format!(
375 "unknown tape key {bad}; allowed: {}",
376 KEYS.join(", ")
377 ));
378 }
379 for (key, value) in table {
380 let name = format!("tape.{key}");
381 match key.as_str() {
382 "strength" => tape.strength = number(value, &name)?.min(1.0),
383 "direction" => tape.direction = direction(value)?,
384 "split" => tape.split = Some(number(value, &name)?),
385 "echo" => tape.echo = Some(number(value, &name)?),
386 "wash" => tape.wash = Some(number(value, &name)?),
387 "smear" => tape.smear = Some(number(value, &name)?),
388 "grain" => tape.grain = Some(number(value, &name)?),
389 _ => tape.bands = Some(number(value, &name)?),
390 }
391 }
392 }
393 _ => tape.strength = number(value, "tape")?.min(1.0),
394 }
395 Ok(tape)
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use crate::gpu::GpuChain;
402 use crate::gpu::tests::half;
403 use crate::preset::Style;
404 use pfx_gpu::{Gpu, OffscreenTarget};
405
406 const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
407
408 fn float(value: u16) -> f32 {
409 let sign = if value & 0x8000 != 0 { -1.0 } else { 1.0 };
410 let exponent = ((value >> 10) & 31) as i32;
411 let mantissa = (value & 1023) as f32;
412 if exponent == 0 {
413 return sign * mantissa * 2.0f32.powi(-24);
414 }
415 sign * (1.0 + mantissa / 1024.0) * 2.0f32.powi(exponent - 15)
416 }
417
418 fn scene(width: u32, height: u32) -> Image {
419 Image::from_fn(width, height, |x, y| {
420 let u = x as f32 / width as f32;
421 let v = y as f32 / height as f32;
422 let mut c = [0.004, 0.004, 0.004, 1.0];
423 if (0.1..0.12).contains(&u) && (0.1..0.8).contains(&v) {
424 c = [1.0, 1.0, 1.0, 1.0];
425 }
426 if (0.3..0.5).contains(&u) && (0.55..0.85).contains(&v) {
427 c = [0.02, 0.3, 0.55, 1.0];
428 }
429 if (0.6..0.9).contains(&u) && (0.2..0.24).contains(&v) {
430 c = [0.9, 0.12, 0.05, 1.0];
431 }
432 if (0.15..0.95).contains(&v) && x % 23 == 4 {
433 c = [0.05, 0.35, 0.4, 1.0];
434 }
435 c.map(|value| float(half(value)))
436 })
437 }
438
439 const LINE: u32 = 5000;
440
441 fn line() -> Image {
442 Image::from_fn(2 * LINE, 2, |x, _| {
443 if x == LINE {
444 [1.0, 1.0, 1.0, 1.0]
445 } else {
446 [0.0, 0.0, 0.0, 1.0]
447 }
448 })
449 }
450
451 fn check_split(image: &Image, out: &[[f32; 4]], direction: Direction, tolerance: f32) {
452 let tape = only_split(direction);
453 let [sr, sb] = tape.split_pixels(image.width);
454 assert_eq!((sr, sb), (34.0, 39.0));
455 let lead = direction.lead() as i32;
456 let at = |x: i32, c: usize| out[(image.width as i32 + x) as usize][c];
457 let line = LINE as i32;
458 let red = line - lead * 34;
459 let blue = line + lead * 39;
460 for (c, weights) in MIX.iter().enumerate() {
461 for (x, want) in [(line, weights[0]), (red, weights[1]), (blue, weights[2])] {
462 let got = at(x, c);
463 assert!(
464 (got - want).abs() < tolerance,
465 "{direction:?} channel {c} at {x}: {got} vs {want}"
466 );
467 }
468 for x in [line - 1, line + 1, red - 1, red + 1, blue - 1, blue + 1] {
469 assert!(
470 at(x, c) < tolerance,
471 "{direction:?} channel {c} spreads to {x}"
472 );
473 }
474 }
475 }
476
477 fn only_split(direction: Direction) -> Tape {
478 Tape {
479 echo: Some(0.0),
480 wash: Some(0.0),
481 grain: Some(0.0),
482 bands: Some(0.0),
483 ..Tape::new(1.0, direction)
484 }
485 }
486
487 fn output_target(gpu: &Gpu, width: u32, height: u32) -> OffscreenTarget {
488 let texture = gpu.device.create_texture(&wgpu::TextureDescriptor {
489 label: Some("tape test output"),
490 size: wgpu::Extent3d {
491 width,
492 height,
493 depth_or_array_layers: 1,
494 },
495 mip_level_count: 1,
496 sample_count: 1,
497 dimension: wgpu::TextureDimension::D2,
498 format: FORMAT,
499 usage: wgpu::TextureUsages::STORAGE_BINDING
500 | wgpu::TextureUsages::TEXTURE_BINDING
501 | wgpu::TextureUsages::COPY_SRC,
502 view_formats: &[],
503 });
504 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
505 OffscreenTarget {
506 texture,
507 view,
508 format: FORMAT,
509 width,
510 height,
511 }
512 }
513
514 struct Bench {
515 gpu: Gpu,
516 input: OffscreenTarget,
517 output: OffscreenTarget,
518 }
519
520 impl Bench {
521 fn new(image: &Image) -> Self {
522 let gpu = pollster::block_on(Gpu::headless()).unwrap();
523 let input = gpu.offscreen(image.width, image.height, FORMAT).unwrap();
524 let output = output_target(&gpu, image.width, image.height);
525 let pixels: Vec<u16> = image
526 .pixels
527 .iter()
528 .flat_map(|p| p.iter().map(|v| half(*v)))
529 .collect();
530 gpu.upload_rgba16(&input, &pixels).unwrap();
531 Self { gpu, input, output }
532 }
533
534 fn chain(&self, chain: Chain) -> GpuChain {
535 GpuChain::new(chain, &self.gpu.device, self.input.width, self.input.height)
536 }
537
538 fn run(&self, post: &GpuChain, frame: u32, seed: u32) -> Vec<u16> {
539 let mut encoder =
540 self.gpu
541 .device
542 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
543 label: Some("tape test"),
544 });
545 post.run(
546 &mut encoder,
547 &self.input.view,
548 None,
549 None,
550 &self.output.view,
551 frame,
552 seed,
553 );
554 self.gpu.queue.submit(Some(encoder.finish()));
555 self.gpu.readback_rgba16(&self.output).unwrap()
556 }
557 }
558
559 fn rows(pixels: &[[f32; 4]], clean: &Image) -> Vec<f32> {
560 (0..clean.height)
561 .map(|y| {
562 (0..clean.width)
563 .map(|x| {
564 let i = clean.index(x, y);
565 (pixels[i][1] - clean.pixels[i][1]).abs()
566 })
567 .sum::<f32>()
568 / clean.width as f32
569 })
570 .collect()
571 }
572
573 fn shift(a: &[f32], b: &[f32]) -> i32 {
574 let n = a.len() as i32;
575 let mut best = (f32::MIN, 0);
576 for d in -n / 4..=n / 4 {
577 let mut score = 0.0;
578 for y in 0..n {
579 let z = y - d;
580 if (0..n).contains(&z) {
581 score += b[y as usize] * a[z as usize];
582 }
583 }
584 if score > best.0 {
585 best = (score, d);
586 }
587 }
588 best.1
589 }
590
591 fn pixels(bytes: &[u16]) -> Vec<[f32; 4]> {
592 bytes
593 .chunks_exact(4)
594 .map(|c| [float(c[0]), float(c[1]), float(c[2]), float(c[3])])
595 .collect()
596 }
597
598 #[test]
599 fn wgsl_validates() {
600 let module = naga::front::wgsl::parse_str(WGSL).unwrap();
601 naga::valid::Validator::new(
602 naga::valid::ValidationFlags::all(),
603 naga::valid::Capabilities::empty(),
604 )
605 .validate(&module)
606 .unwrap();
607 }
608
609 #[test]
610 fn strength_zero_is_the_chain_without_it() {
611 let image = scene(96, 48);
612 let mut plain = Style::Crt.chain();
613 plain.passes.push(Pass::Encode);
614 plain.frame = 9;
615 plain.seed = 4;
616 let mut taped = plain.clone();
617 taped.set_tape(Tape::rewind(0.0));
618 assert!(matches!(
619 taped.passes[taped.passes.len() - 2..],
620 [Pass::Tape(_), Pass::Encode]
621 ));
622 assert_eq!(
623 plain.apply(&image, None, None),
624 taped.apply(&image, None, None)
625 );
626 assert_eq!(apply(&image, &Tape::OFF, 3, 5), image);
627 assert_eq!(Tape::forward(f32::NAN).params()[15], 0.0);
628 }
629
630 #[test]
631 fn same_time_and_seed_give_the_same_image() {
632 let image = scene(96, 48);
633 let tape = Tape::rewind(1.0);
634 assert_eq!(apply(&image, &tape, 12, 7), apply(&image, &tape, 12, 7));
635 assert_ne!(apply(&image, &tape, 12, 7), apply(&image, &tape, 13, 7));
636 assert_ne!(apply(&image, &tape, 12, 7), apply(&image, &tape, 12, 8));
637 }
638
639 #[test]
640 fn the_split_moves_red_and_blue_the_stated_pixels() {
641 for direction in [Direction::Forward, Direction::Rewind] {
642 check_split(
643 &line(),
644 &apply(&line(), &only_split(direction), 0, 0).pixels,
645 direction,
646 1e-4,
647 );
648 }
649 }
650
651 #[test]
652 fn offsets_scale_with_width() {
653 let tape = Tape::rewind(1.0);
654 let hd = tape.split_pixels(1920);
655 let uhd = tape.split_pixels(3840);
656 assert!((uhd[0] - 2.0 * hd[0]).abs() < 1e-4 && (uhd[1] - 2.0 * hd[1]).abs() < 1e-4);
657 assert!((hd[0] - 6.528).abs() < 1e-3 && (hd[1] - 7.488).abs() < 1e-3);
658 let [a, b] = tape.echo_pixels(3840);
659 assert!((a - 2.0 * tape.echo_pixels(1920)[0]).abs() < 1e-3 && b > a);
660 assert!((Tape::rewind(0.5).split_pixels(1920)[0] - hd[0] * 0.5).abs() < 1e-4);
661 let custom = Tape {
662 split: Some(2.0),
663 ..Tape::rewind(0.5)
664 };
665 assert!((custom.split_pixels(1920)[1] - hd[1]).abs() < 1e-4);
666 let a = Tape::rewind(1.0).amounts();
667 assert_eq!((a.echo, a.smear, a.wash), (0.0, 0.0, 1.0));
668 }
669
670 #[test]
671 fn bands_roll_down_forward_and_up_in_rewind() {
672 for (tape, sign) in [(Tape::forward(1.0), 1.0), (Tape::rewind(1.0), -1.0)] {
673 let a = tape.band_centres(0.5, 3);
674 let b = tape.band_centres(0.6, 3);
675 for (k, band) in BANDS.iter().enumerate() {
676 let moved = b[k] - a[k];
677 let expected = sign * band.speed * 0.1 * (1.0 + 2.0 * band.width);
678 if moved.abs() < 0.5 {
679 assert!((moved - expected).abs() < 1e-4, "{tape:?} band {k}");
680 }
681 }
682 }
683 }
684
685 #[test]
686 fn a_chain_reads_tape_from_toml() {
687 let chain = crate::parse::parse_str(
688 "style = \"noir\"\nencode = true\ntape = { strength = 0.8, direction = \"rewind\" }\n",
689 )
690 .unwrap();
691 let tape = chain.tape().unwrap();
692 assert_eq!(tape, Tape::rewind(0.8));
693 assert!(matches!(
694 chain.passes[chain.passes.len() - 2..],
695 [Pass::Tape(_), Pass::Encode]
696 ));
697 let plain = crate::parse::parse_str("tape = 0.5\n").unwrap();
698 assert_eq!(plain.tape(), Some(Tape::forward(0.5)));
699 let named = crate::parse::parse_str("tape = \"rewind\"\n").unwrap();
700 assert_eq!(named.tape(), Some(Tape::rewind(1.0)));
701 let tuned = crate::parse::parse_str(
702 "tape = { strength = 1, split = 0.5, echo = 1, wash = 2, smear = 0.5, grain = 0.25, bands = 1.5 }\n",
703 )
704 .unwrap()
705 .tape()
706 .unwrap();
707 assert_eq!(
708 tuned.amounts(),
709 Amounts {
710 split: 0.5,
711 echo: 1.0,
712 wash: 1.0,
713 smear: 0.5,
714 grain: 0.25,
715 bands: 1.5
716 }
717 );
718 assert!(crate::parse::parse_str("tape = { speed = 1 }\n").is_err());
719 assert!(crate::parse::parse_str("tape = \"sideways\"\n").is_err());
720 assert!(crate::parse::parse_str("tape = -1\n").is_err());
721 }
722
723 #[test]
724 fn set_tape_replaces_the_slot() {
725 let mut chain = Chain::new();
726 chain.set_tape(Tape::forward(0.3));
727 chain.passes.push(Pass::Encode);
728 chain.set_tape(Tape::rewind(0.9));
729 assert_eq!(chain.passes.len(), 2);
730 assert_eq!(chain.tape(), Some(Tape::rewind(0.9)));
731 }
732
733 #[test]
734 #[ignore = "needs a GPU; run under pgpu"]
735 fn gpu_strength_zero_is_byte_identical() {
736 let image = scene(200, 72);
737 let bench = Bench::new(&image);
738 for style in [Style::Noir, Style::Film, Style::Neon, Style::Crt] {
739 let mut plain = style.chain();
740 plain.passes.push(Pass::Encode);
741 let want = bench.run(&bench.chain(plain.clone()), 7, 23);
742 let mut slot = plain.clone();
743 slot.set_tape(Tape::OFF);
744 assert_eq!(bench.run(&bench.chain(slot), 7, 23), want, "{style:?}");
745 let mut ramp = bench.chain(plain.clone());
746 ramp.set_tape(Tape::OFF);
747 assert_eq!(bench.run(&ramp, 7, 23), want, "{style:?} unset");
748 ramp.set_tape(Tape::rewind(1.0));
749 assert_ne!(bench.run(&ramp, 7, 23), want, "{style:?} on");
750 ramp.set_tape(Tape::rewind(0.0));
751 assert_eq!(bench.run(&ramp, 7, 23), want, "{style:?} ramped out");
752 }
753 let mut alone = Chain::new();
754 alone.set_tape(Tape::OFF);
755 let got = bench.run(&bench.chain(alone), 7, 23);
756 let want: Vec<u16> = image
757 .pixels
758 .iter()
759 .flat_map(|p| p.iter().map(|v| half(*v)))
760 .collect();
761 assert_eq!(got, want);
762 }
763
764 #[test]
765 #[ignore = "needs a GPU; run under pgpu"]
766 fn gpu_same_time_and_seed_give_the_same_bytes() {
767 let image = scene(1100, 64);
768 let bench = Bench::new(&image);
769 let mut chain = Chain::new();
770 chain.set_tape(Tape::rewind(1.0));
771 let post = bench.chain(chain);
772 let a = bench.run(&post, 30, 11);
773 let b = bench.run(&post, 30, 11);
774 assert_eq!(a, b);
775 assert_ne!(a, bench.run(&post, 31, 11));
776 assert_ne!(a, bench.run(&post, 30, 12));
777 }
778
779 #[test]
780 #[ignore = "needs a GPU; run under pgpu"]
781 fn gpu_split_moves_red_and_blue_the_stated_pixels() {
782 let image = line();
783 let bench = Bench::new(&image);
784 for direction in [Direction::Forward, Direction::Rewind] {
785 let mut chain = Chain::new();
786 chain.set_tape(only_split(direction));
787 let out = pixels(&bench.run(&bench.chain(chain), 0, 0));
788 check_split(&image, &out, direction, 2e-3);
789 }
790 }
791
792 #[test]
793 #[ignore = "needs a GPU; run under pgpu"]
794 fn gpu_bands_move_opposite_ways() {
795 let image = Image::from_fn(1280, 360, |x, _| {
796 let v = if x % 16 < 8 { 0.75 } else { 0.0625 };
797 [v, v, v, 1.0]
798 });
799 let bench = Bench::new(&image);
800 let mut shifts = Vec::new();
801 for direction in [Direction::Forward, Direction::Rewind] {
802 let tape = Tape {
803 split: Some(0.0),
804 echo: Some(0.0),
805 wash: Some(0.0),
806 grain: Some(0.0),
807 ..Tape::new(1.0, direction)
808 };
809 let mut chain = Chain::new();
810 chain.set_tape(tape);
811 let post = bench.chain(chain);
812 let a = rows(&pixels(&bench.run(&post, 60, 5)), &image);
813 let b = rows(&pixels(&bench.run(&post, 66, 5)), &image);
814 assert!(a.iter().any(|v| *v > 0.01), "{direction:?} shows no band");
815 let moved = shift(&a, &b);
816 let centres = tape.band_centres(1.0, 5);
817 let later = tape.band_centres(1.1, 5);
818 println!("{direction:?}: rows moved {moved}, centres {centres:?} -> {later:?}");
819 shifts.push(moved);
820 }
821 assert!(shifts[0] > 0, "forward rolls down: {shifts:?}");
822 assert!(shifts[1] < 0, "rewind rolls up: {shifts:?}");
823 }
824
825 #[test]
826 #[ignore = "needs a GPU; run under pgpu"]
827 fn gpu_matches_cpu() {
828 let image = scene(1300, 90);
829 let bench = Bench::new(&image);
830 for (tape, frame) in [
831 (Tape::rewind(1.0), 40),
832 (Tape::forward(0.6), 3),
833 (
834 Tape {
835 echo: Some(1.0),
836 smear: Some(1.0),
837 ..Tape::forward(1.0)
838 },
839 9,
840 ),
841 (
842 Tape {
843 split: Some(3.0),
844 wash: Some(0.3),
845 bands: Some(2.0),
846 ..Tape::rewind(0.9)
847 },
848 77,
849 ),
850 ] {
851 let mut chain = Chain::new();
852 chain.set_tape(tape);
853 chain.frame = frame;
854 chain.seed = 13;
855 let want = chain.apply(&image, None, None);
856 let got = pixels(&bench.run(&bench.chain(chain), frame, 13));
857 let mut worst = 0.0f32;
858 for (g, w) in got.iter().zip(&want.pixels) {
859 for c in 0..4 {
860 worst = worst.max((g[c] - w[c]).abs());
861 }
862 }
863 assert!(worst <= 0.006, "{tape:?}: {worst}");
864 }
865 }
866
867 #[test]
868 #[ignore = "needs a GPU; run under pgpu"]
869 fn gpu_folds_the_encode_into_the_tape() {
870 let image = scene(640, 48);
871 let bench = Bench::new(&image);
872 for style in [Style::Film, Style::Crt, Style::Noir] {
873 let mut chain = style.chain();
874 chain.passes.insert(0, Pass::Exposure(1.0));
875 chain.passes.push(Pass::Encode);
876 chain.set_tape(Tape::rewind(0.8));
877 chain.frame = 21;
878 chain.seed = 3;
879 let want = chain.apply(&image, None, None);
880 let got = pixels(&bench.run(&bench.chain(chain), 21, 3));
881 let mut worst = 0.0f32;
882 for (g, w) in got.iter().zip(&want.pixels) {
883 for c in 0..3 {
884 worst = worst.max((g[c] - w[c]).abs());
885 }
886 }
887 assert!(worst <= 0.03, "{style:?}: {worst}");
888 }
889 }
890
891 #[test]
892 #[ignore = "needs a GPU; run under pgpu"]
893 fn benchmark_4k() {
894 let gpu = pollster::block_on(Gpu::headless()).unwrap();
895 let input = gpu.offscreen(3840, 2160, FORMAT).unwrap();
896 let output = output_target(&gpu, 3840, 2160);
897 let mut timer = pfx_gpu::GpuProfiler::new(&gpu.device, &gpu.queue);
898 let mut turns = pfx_gpu::pace::Turns::default();
899 let mut chain = Chain::new();
900 chain.set_tape(Tape::rewind(1.0));
901 let post = turns.cpu(|| GpuChain::new(chain, &gpu.device, 3840, 2160));
902 let mut samples = Vec::new();
903 for attempt in 0..24u32 {
904 let mut encoder = gpu
905 .device
906 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
907 label: Some("tape benchmark"),
908 });
909 post.run_timed(
910 &mut encoder,
911 &input.view,
912 None,
913 None,
914 &output.view,
915 attempt,
916 23,
917 Some(&mut timer),
918 );
919 let slot = timer.finish(&mut encoder);
920 gpu.queue.submit(Some(encoder.finish()));
921 if let Some(slot) = slot {
922 timer.submitted(slot);
923 }
924 gpu.device
925 .poll(wgpu::PollType::wait_indefinitely())
926 .unwrap();
927 let total: f64 = timer
928 .collect(&gpu.device)
929 .iter()
930 .flatten()
931 .map(|pass| pass.milliseconds)
932 .sum();
933 turns.add(total);
934 if attempt > 3 {
935 samples.push(total);
936 }
937 }
938 samples.sort_by(f64::total_cmp);
939 let median = samples[samples.len() / 2];
940 println!(
941 "tape at 3840x2160: median {median:.3} ms, max {:.3} ms",
942 samples[samples.len() - 1]
943 );
944 for tape in [None, Some(Tape::rewind(1.0))] {
945 let mut finish = Style::Noir.chain();
946 finish.passes.insert(0, Pass::Exposure(1.0));
947 finish.passes.push(Pass::Encode);
948 if let Some(tape) = tape {
949 finish.set_tape(tape);
950 }
951 let post = turns.cpu(|| GpuChain::new(finish, &gpu.device, 3840, 2160));
952 let mut totals = Vec::new();
953 for attempt in 0..24u32 {
954 let mut encoder =
955 gpu.device
956 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
957 label: Some("tape benchmark finish"),
958 });
959 post.run_timed(
960 &mut encoder,
961 &input.view,
962 None,
963 None,
964 &output.view,
965 attempt,
966 23,
967 Some(&mut timer),
968 );
969 let slot = timer.finish(&mut encoder);
970 gpu.queue.submit(Some(encoder.finish()));
971 if let Some(slot) = slot {
972 timer.submitted(slot);
973 }
974 gpu.device
975 .poll(wgpu::PollType::wait_indefinitely())
976 .unwrap();
977 let passes: Vec<_> = timer.collect(&gpu.device).into_iter().flatten().collect();
978 let total: f64 = passes.iter().map(|pass| pass.milliseconds).sum();
979 turns.add(total);
980 if attempt == 23 {
981 for pass in &passes {
982 println!(" {} {:.3} ms", pass.label, pass.milliseconds);
983 }
984 }
985 if attempt > 3 {
986 totals.push(total);
987 }
988 }
989 totals.sort_by(f64::total_cmp);
990 println!(
991 "noir finish with tape {:?}: median {:.3} ms",
992 tape.map(|t| t.strength),
993 totals[totals.len() / 2]
994 );
995 }
996 assert!(median < 0.5, "{median:.3} ms");
997 }
998}