unlit_wgpu_test_util/snapshot.rs
1//! Perceptual snapshot assertions.
2//!
3//! A snapshot is named, and where its bytes come from depends on where the
4//! test runs. Natively they are read from the asset submodule beside the test
5//! file, so a mismatch can be re-stored in place with `SNAPSHOT_UPDATE=1`. In a
6//! browser there is no filesystem to read or write, so
7//! [`snapshot!`](macro@crate::snapshot) embeds the baseline into the wasm binary at
8//! the call site instead — and the comparison itself, a real SSIMULACRA2 score,
9//! runs there unchanged.
10//!
11//! Both paths decode the same WebP and score it the same way; only the origin
12//! of the bytes differs.
13
14use image::ImageEncoder;
15use ssimulacra2::{ColorPrimaries, Rgb, TransferCharacteristic, compute_frame_ssimulacra2};
16
17/// Where [`Snapshot::on_disk`] looks a snapshot up by name, relative to the
18/// process's working directory — which for a test is its package root.
19#[cfg(not(target_arch = "wasm32"))]
20const SNAPSHOT_DIR: &str = "tests/snapshots";
21
22/// The perceptual score a frame must reach to match its snapshot.
23pub const DEFAULT_MIN_SCORE: f64 = 85.0;
24
25/// How closely a frame must match its snapshot.
26///
27/// Every gate this names must pass. A gate left as `None` is one whose verdict
28/// would be meaningless for this snapshot's content, and leaving it out says so
29/// rather than naming a threshold that either never passes or never fails.
30///
31/// The two gates catch different mistakes. The perceptual score notices a
32/// change spread thinly over the whole frame, which moves every pixel a little.
33/// The outlier allowance notices a change confined to a few pixels, which moves
34/// those a lot — the kind a second implementation of the same pipeline produces
35/// where it resolves an edge or an occlusion differently, and the kind an
36/// average over the frame barely registers.
37#[derive(Clone, Copy)]
38pub struct Tolerance {
39 /// The perceptual score the frame must reach, on SSIMULACRA2's 0–100 scale,
40 /// or `None` to judge the frame only by its outlier pixels.
41 pub min_score: Option<f64>,
42 /// The fraction of pixels that may differ from the baseline by more than
43 /// [`channel_delta`](Tolerance::channel_delta), or `None` to judge the frame
44 /// only by its score.
45 pub max_outliers: Option<f64>,
46 /// The per-channel difference, in 0–255 units, within which a pixel counts
47 /// as matching however far the frame's score falls.
48 pub channel_delta: u8,
49}
50
51/// The tolerance a snapshot gets when it names none: the perceptual floor
52/// [`DEFAULT_MIN_SCORE`], with no bound on how many pixels may stand out.
53///
54/// The outlier allowance is off by default because it is the blunter gate: a
55/// frame can stay inside it while differing over exactly the few pixels that
56/// matter. A snapshot that needs it has content the two gates disagree about —
57/// hard edges that a second implementation may place differently — and should
58/// carry the measurements that justify its bound, as the instanced-cube test
59/// does.
60pub const DEFAULT_TOLERANCE: Tolerance = Tolerance {
61 min_score: Some(DEFAULT_MIN_SCORE),
62 max_outliers: None,
63 channel_delta: 8,
64};
65
66/// A snapshot baseline, and where its bytes come from.
67///
68/// Built by [`snapshot!`](macro@crate::snapshot), which is the only way to get one:
69/// the macro is what turns a literal name into an embedded byte array on the
70/// web, and it has to see the name as a literal to do it.
71#[derive(Clone, Copy)]
72pub struct Snapshot {
73 /// The baseline's name, such as `lit_cube.webp`.
74 name: &'static str,
75 /// The baseline's bytes where the target has no filesystem to read them
76 /// from, and `None` where it does.
77 embedded: Option<&'static [u8]>,
78}
79
80impl Snapshot {
81 /// A snapshot to be read from the asset submodule at compare time.
82 #[cfg(not(target_arch = "wasm32"))]
83 pub const fn on_disk(name: &'static str) -> Self {
84 Self {
85 name,
86 embedded: None,
87 }
88 }
89
90 /// A snapshot whose bytes were embedded into the binary.
91 #[cfg(target_arch = "wasm32")]
92 pub const fn embedded(name: &'static str, bytes: &'static [u8]) -> Self {
93 Self {
94 name,
95 embedded: Some(bytes),
96 }
97 }
98
99 /// The baseline's name.
100 pub fn name(&self) -> &'static str {
101 self.name
102 }
103
104 /// The baseline's bytes, where they were embedded.
105 pub fn embedded_bytes(&self) -> Option<&'static [u8]> {
106 self.embedded
107 }
108}
109
110/// Name a snapshot baseline, embedding its bytes where there is no filesystem.
111///
112/// Natively this is the name alone and the file is read when the frame is
113/// compared, so `SNAPSHOT_UPDATE=1` can rewrite it. In a browser the bytes are
114/// read out of the repository at compile time and travel inside the wasm
115/// binary, resolved relative to the file the macro is invoked from.
116///
117/// The name must be a literal, because it is also the path `include_bytes!`
118/// resolves.
119///
120/// # Example
121///
122/// ```rust
123/// use unlit_wgpu_test_util::snapshot;
124///
125/// let baseline = snapshot!("lit_cube.webp");
126/// assert_eq!(baseline.name(), "lit_cube.webp");
127/// ```
128#[macro_export]
129macro_rules! snapshot {
130 ($name:literal) => {{
131 #[cfg(target_arch = "wasm32")]
132 {
133 $crate::Snapshot::embedded($name, include_bytes!(concat!("snapshots/", $name)))
134 }
135 #[cfg(not(target_arch = "wasm32"))]
136 {
137 $crate::Snapshot::on_disk($name)
138 }
139 }};
140}
141
142/// The snapshots one scene's frames verify against, in frame order.
143///
144/// A scene that freezes a sequence of frames stores each as its own snapshot
145/// under a directory named for the scene. Listing them here rather than
146/// building the names at run time is what lets a web build carry them in the
147/// binary: the wasm arm embeds every listed file, the way [`snapshot!`] embeds
148/// one, and the host arm resolves them under the snapshot directory instead.
149///
150/// The list is the scene's own frame table, so it is written out in full
151/// rather than derived — a frame whose snapshot is missing from the list is a
152/// frame the test does not compare, and one named here that has no file fails
153/// to compile on the web.
154///
155/// ```rust
156/// use unlit_wgpu_test_util::snapshots;
157///
158/// // A scene storing two frames under its own directory.
159/// let frames = snapshots![
160/// "animated/frame_00_alloc3_free0_live3.webp",
161/// "animated/frame_01_alloc2_free0_live5.webp",
162/// ];
163/// assert_eq!(frames.len(), 2);
164/// assert_eq!(frames[0].name(), "animated/frame_00_alloc3_free0_live3.webp");
165/// ```
166#[macro_export]
167macro_rules! snapshots {
168 ($($name:literal),+ $(,)?) => {
169 [$($crate::snapshot!($name)),+]
170 };
171}
172
173/// What went wrong storing or comparing a snapshot.
174///
175/// The paths and scores a caller reports come from here rather than from a
176/// panic, so a tool can turn a mismatch into its own exit code.
177#[derive(Debug, thiserror::Error)]
178pub enum SnapshotError {
179 /// The frame's bytes do not describe `width` x `height` RGBA pixels.
180 #[error("frame size mismatch: {got} bytes, expected {expected}")]
181 FrameSize {
182 /// Bytes the frame holds.
183 got: usize,
184 /// Bytes the dimensions require.
185 expected: usize,
186 },
187 /// The snapshot file could not be read or written.
188 #[error(transparent)]
189 Io(#[from] std::io::Error),
190 /// The snapshot could not be decoded as WebP.
191 #[error("decoding the snapshot failed: {0}")]
192 Decode(#[from] image::ImageError),
193 /// The snapshot's dimensions differ from the frame's.
194 #[error("the snapshot is {}x{} but the frame is {}x{}", .snapshot.0, .snapshot.1, .frame.0, .frame.1)]
195 Dimensions {
196 /// The snapshot's dimensions.
197 snapshot: (u32, u32),
198 /// The frame's dimensions.
199 frame: (u32, u32),
200 },
201 /// The two images could not be scored.
202 #[error("scoring the frames failed: {0}")]
203 Score(#[from] ssimulacra2::Ssimulacra2Error),
204}
205
206/// Turn an RGBA8 frame into the RGB the metric reads.
207///
208/// The metric works in XYB, which it derives from *linear* RGB, so the
209/// values handed over are the sRGB ones the frame holds: the transfer
210/// characteristic named here is what linearizes them, and naming it is also
211/// what says the frame is sRGB rather than something else.
212fn rgb_frame(rgba: &[u8], width: u32, height: u32) -> Rgb {
213 let data: Vec<[f32; 3]> = rgba
214 .as_chunks::<4>()
215 .0
216 .iter()
217 .map(|px| {
218 [
219 f32::from(px[0]) / 255.0,
220 f32::from(px[1]) / 255.0,
221 f32::from(px[2]) / 255.0,
222 ]
223 })
224 .collect();
225 Rgb::new(
226 data,
227 width as usize,
228 height as usize,
229 TransferCharacteristic::SRGB,
230 ColorPrimaries::BT709,
231 )
232 .expect("the frame's dimensions and pixel count agree")
233}
234
235/// Reject a frame whose bytes do not describe `width` x `height` RGBA pixels.
236fn check_frame_size(rgba: &[u8], width: u32, height: u32) -> Result<(), SnapshotError> {
237 let expected = (width as usize) * (height as usize) * 4;
238 if rgba.len() == expected {
239 Ok(())
240 } else {
241 Err(SnapshotError::FrameSize {
242 got: rgba.len(),
243 expected,
244 })
245 }
246}
247
248/// What comparing a frame against its baseline found.
249///
250/// Both numbers are carried to the assertion rather than judged here, because
251/// which of them decides the frame is the snapshot's [`Tolerance`] to say.
252#[derive(Clone, Copy)]
253struct Comparison {
254 /// The perceptual score, on SSIMULACRA2's 0–100 scale.
255 score: f64,
256 /// The fraction of pixels differing from the baseline by more than the
257 /// tolerance's `channel_delta`.
258 outliers: f64,
259}
260
261/// Measure `rgba` against a WebP baseline held in memory.
262///
263/// This is the whole comparison, and the only difference between the two
264/// targets is where `reference_bytes` was read from.
265fn compare_frame_bytes(
266 reference_bytes: &[u8],
267 rgba: &[u8],
268 width: u32,
269 height: u32,
270 channel_delta: u8,
271) -> Result<Comparison, SnapshotError> {
272 check_frame_size(rgba, width, height)?;
273 let reference = image::load_from_memory_with_format(reference_bytes, image::ImageFormat::WebP)
274 .map_err(SnapshotError::Decode)?
275 .to_rgba8();
276 if (reference.width(), reference.height()) != (width, height) {
277 return Err(SnapshotError::Dimensions {
278 snapshot: (reference.width(), reference.height()),
279 frame: (width, height),
280 });
281 }
282
283 let reference_frame = rgb_frame(reference.as_raw(), width, height);
284 let current_frame = rgb_frame(rgba, width, height);
285 let score =
286 compute_frame_ssimulacra2(reference_frame, current_frame).map_err(SnapshotError::Score)?;
287 Ok(Comparison {
288 score,
289 outliers: outlier_fraction(reference.as_raw(), rgba, channel_delta),
290 })
291}
292
293/// The fraction of pixels whose largest per-channel difference from the
294/// baseline exceeds `channel_delta`.
295///
296/// A pixel the two implementations resolve differently moves far, so it lands
297/// outside `channel_delta`; one they agree on, or which differs only by the
298/// rounding of an interpolated value, stays inside it. Counting them is what
299/// separates a difference confined to a few edges — which the perceptual score
300/// is too coarse to see past — from one spread over the frame.
301fn outlier_fraction(reference: &[u8], rgba: &[u8], channel_delta: u8) -> f64 {
302 let pixels = rgba.len() / 4;
303 if pixels == 0 {
304 return 0.0;
305 }
306 let outliers = reference
307 .as_chunks::<4>()
308 .0
309 .iter()
310 .zip(rgba.as_chunks::<4>().0)
311 .filter(|(reference, frame)| {
312 (0..3).any(|channel| reference[channel].abs_diff(frame[channel]) > channel_delta)
313 })
314 .count();
315 outliers as f64 / pixels as f64
316}
317
318/// The environment variable that collects mismatched frames on the host.
319///
320/// A mismatch is often the platform showing through rather than a regression:
321/// the stored frames come from one GPU stack, and another driver's rounding can
322/// put a frame below the bar with nothing wrong. Writing the frame that did not
323/// match is what lets CI upload it as an artifact, so the difference can be
324/// looked at instead of guessed at from a score.
325///
326/// Unset, frames go under [`DEFAULT_MISMATCH_DIR`]. Set to a path, they go there
327/// instead — which is how a task that makes several passes gives each one its
328/// own directory.
329#[cfg(not(target_arch = "wasm32"))]
330pub const MISMATCH_DIR_ENV: &str = "UNLIT3D_SNAPSHOT_MISMATCH_DIR";
331
332/// Where mismatched frames are written when [`MISMATCH_DIR_ENV`] is unset.
333#[cfg(not(target_arch = "wasm32"))]
334pub const DEFAULT_MISMATCH_DIR: &str = "target/snapshot-mismatches";
335
336/// Assert that `rgba` matches `snapshot` within [`DEFAULT_TOLERANCE`].
337///
338/// Natively a missing snapshot is written from this frame rather than failed
339/// on, so the first run of a new test records its baseline, and
340/// `SNAPSHOT_UPDATE=1` rewrites one that exists. On the web the baseline is
341/// embedded in the binary, so there is nothing to write and a missing one
342/// could not have compiled.
343pub fn assert_image_snapshot(snapshot: Snapshot, rgba: &[u8], width: u32, height: u32) {
344 assert_image_snapshot_with_tolerance(snapshot, rgba, width, height, DEFAULT_TOLERANCE);
345}
346
347/// Assert that `rgba` matches `snapshot` within `tolerance`.
348///
349/// Like [`assert_image_snapshot`], a missing snapshot is written rather than
350/// failed on — natively, where there is somewhere to write it. A freshly
351/// written snapshot is not judged at all, since it was just made from this
352/// frame.
353///
354/// A frame that does not match is kept before the assertion fails, so a run that
355/// fails in CI leaves it behind for the job to upload: natively it is written
356/// under [`DEFAULT_MISMATCH_DIR`] (or [`MISMATCH_DIR_ENV`]), and on the web it is
357/// handed to the test runner, which is the process that has a filesystem.
358/// Keeping it is a courtesy to whoever reads the result, so a failure to do so
359/// is reported and dropped rather than replacing the mismatch it was meant to
360/// explain.
361pub fn assert_image_snapshot_with_tolerance(
362 snapshot: Snapshot,
363 rgba: &[u8],
364 width: u32,
365 height: u32,
366 tolerance: Tolerance,
367) {
368 let name = snapshot.name();
369 let comparison = compare(snapshot, rgba, width, height, tolerance.channel_delta)
370 .unwrap_or_else(|error| panic!("snapshot {name}: {error}"));
371
372 let failure = if let Some(min_score) = tolerance.min_score
373 && comparison.score < min_score
374 {
375 Some(format!(
376 "perceptual mismatch: SSIMULACRA2 score {:.2} < {min_score}",
377 comparison.score,
378 ))
379 } else if let Some(max_outliers) = tolerance.max_outliers
380 && comparison.outliers > max_outliers
381 {
382 Some(format!(
383 "differs over {:.2}% of its pixels, above the {:.2}% allowed \
384 beyond a channel difference of {}",
385 comparison.outliers * 100.0,
386 max_outliers * 100.0,
387 tolerance.channel_delta,
388 ))
389 } else {
390 None
391 };
392
393 if let Some(failure) = failure {
394 store_mismatch(name, rgba, width, height);
395 panic!(
396 "snapshot `{name}` {failure}
397 if the change is intentional, re-store with SNAPSHOT_UPDATE=1"
398 );
399 }
400}
401
402/// Keep a frame that did not match, so a failing run leaves it behind.
403///
404/// Natively, where a test owns its own filesystem: the snapshot's name is a path
405/// relative to the snapshot directory, so it keeps each frame under the test it
406/// belongs to rather than colliding on a file name.
407#[cfg(not(target_arch = "wasm32"))]
408fn store_mismatch(name: &str, rgba: &[u8], width: u32, height: u32) {
409 let dir = std::env::var_os(MISMATCH_DIR_ENV)
410 .map(std::path::PathBuf::from)
411 .unwrap_or_else(|| std::path::PathBuf::from(DEFAULT_MISMATCH_DIR));
412 let path = dir.join(name);
413 if let Err(error) = store_frame_webp(&path, rgba, width, height) {
414 // Reported, not raised: the mismatch is the result worth reporting, and
415 // losing this frame must not replace it with a write error.
416 log::warn!(
417 "writing the mismatched frame to {}: {error}",
418 path.display()
419 );
420 }
421}
422
423/// Keep a frame that did not match, by handing it to the runner.
424///
425/// A page has no filesystem, so the encoded frame is passed to the process that
426/// has one — the Node test runner, which writes it under its own mismatch
427/// directory. The encoding happens here because the frame's bytes only exist in
428/// the browser.
429#[cfg(target_arch = "wasm32")]
430fn store_mismatch(name: &str, rgba: &[u8], width: u32, height: u32) {
431 let webp = encode_frame_webp(rgba, width, height);
432 crate::browser::record_mismatch(name, &webp);
433}
434
435/// Measure a frame against its baseline, storing one where none exists yet.
436#[cfg(not(target_arch = "wasm32"))]
437fn compare(
438 snapshot: Snapshot,
439 rgba: &[u8],
440 width: u32,
441 height: u32,
442 channel_delta: u8,
443) -> Result<Comparison, SnapshotError> {
444 let path = snapshot_path(snapshot.name());
445 let update = std::env::var_os("SNAPSHOT_UPDATE").is_some();
446
447 if !path.exists() || update {
448 // A snapshot name may carry subdirectories. `create_dir_all` is a
449 // no-op for the directories that already exist, including the
450 // `tests/snapshots` symlink into the asset repository.
451 store_frame_webp(&path, rgba, width, height)?;
452 log::info!(
453 "snapshot `{}` {}",
454 snapshot.name(),
455 if update { "updated" } else { "stored" }
456 );
457 // A frame just stored from itself is by definition identical to its
458 // baseline, so it is not scored — and `INFINITY` is what says so, since
459 // no tolerance can exceed it.
460 return Ok(Comparison {
461 score: f64::INFINITY,
462 outliers: 0.0,
463 });
464 }
465
466 compare_frame_webp(&path, rgba, width, height, channel_delta)
467}
468
469/// Measure a frame against the baseline embedded in this binary.
470///
471/// A browser has nowhere to store a baseline, so an absent one is not a case
472/// that can arise: `include_bytes!` would not have compiled.
473#[cfg(target_arch = "wasm32")]
474fn compare(
475 snapshot: Snapshot,
476 rgba: &[u8],
477 width: u32,
478 height: u32,
479 channel_delta: u8,
480) -> Result<Comparison, SnapshotError> {
481 let bytes = snapshot
482 .embedded_bytes()
483 .expect("a snapshot built by `snapshot!` carries its bytes on the web");
484 compare_frame_bytes(bytes, rgba, width, height, channel_delta)
485}
486
487/// Where a snapshot named `name` lives, under the snapshots directory.
488#[cfg(not(target_arch = "wasm32"))]
489pub fn snapshot_path(name: &str) -> std::path::PathBuf {
490 std::path::Path::new(SNAPSHOT_DIR).join(name)
491}
492
493/// Encode `rgba` as a lossless WebP.
494///
495/// Available on the web too: a frame that fails a comparison is encoded there
496/// and handed to the runner, since the bytes only exist in the browser.
497pub fn encode_frame_webp(rgba: &[u8], width: u32, height: u32) -> Vec<u8> {
498 let mut webp = Vec::new();
499 image::codecs::webp::WebPEncoder::new_lossless(&mut webp)
500 .write_image(rgba, width, height, image::ExtendedColorType::Rgba8)
501 .expect("webp encode failed");
502 webp
503}
504
505/// Write `rgba` to `path` as a lossless WebP, creating its directory.
506#[cfg(not(target_arch = "wasm32"))]
507pub fn store_frame_webp(
508 path: &std::path::Path,
509 rgba: &[u8],
510 width: u32,
511 height: u32,
512) -> Result<(), SnapshotError> {
513 check_frame_size(rgba, width, height)?;
514 if let Some(parent) = path.parent() {
515 std::fs::create_dir_all(parent).map_err(SnapshotError::Io)?;
516 }
517 std::fs::write(path, encode_frame_webp(rgba, width, height)).map_err(SnapshotError::Io)
518}
519
520/// Score the frame `rgba` against the snapshot at `path`, on SSIMULACRA2's
521/// 0–100 scale where higher is closer.
522///
523/// The snapshot must exist; a missing one is an [`std::io::Error`] rather
524/// than a cue to store the frame, so a caller that wants to store it says
525/// so itself.
526#[cfg(not(target_arch = "wasm32"))]
527pub fn score_frame_webp(
528 path: &std::path::Path,
529 rgba: &[u8],
530 width: u32,
531 height: u32,
532) -> Result<f64, SnapshotError> {
533 compare_frame_webp(path, rgba, width, height, DEFAULT_TOLERANCE.channel_delta)
534 .map(|comparison| comparison.score)
535}
536
537/// Measure the frame `rgba` against the snapshot at `path`.
538#[cfg(not(target_arch = "wasm32"))]
539fn compare_frame_webp(
540 path: &std::path::Path,
541 rgba: &[u8],
542 width: u32,
543 height: u32,
544 channel_delta: u8,
545) -> Result<Comparison, SnapshotError> {
546 let reference_bytes = std::fs::read(path).map_err(SnapshotError::Io)?;
547 compare_frame_bytes(&reference_bytes, rgba, width, height, channel_delta)
548}
549
550#[cfg(test)]
551mod tests {
552 use super::outlier_fraction;
553
554 /// A frame of `pixels` mid-grey pixels.
555 fn frame(pixels: usize) -> Vec<u8> {
556 vec![128; pixels * 4]
557 }
558
559 #[test]
560 fn an_identical_frame_has_no_outliers() {
561 let baseline = frame(16);
562 assert_eq!(outlier_fraction(&baseline, &frame(16), 8), 0.0);
563 }
564
565 #[test]
566 fn a_difference_inside_the_channel_delta_is_not_an_outlier() {
567 let baseline = frame(16);
568 let mut current = frame(16);
569 // Every pixel is 8 away, which is the delta itself and not beyond it.
570 for pixel in current.as_chunks_mut::<4>().0 {
571 pixel[0] = 136;
572 }
573 assert_eq!(outlier_fraction(&baseline, ¤t, 8), 0.0);
574 }
575
576 #[test]
577 fn a_difference_beyond_the_channel_delta_is_an_outlier() {
578 let baseline = frame(16);
579 let mut current = frame(16);
580 for pixel in current.as_chunks_mut::<4>().0 {
581 pixel[0] = 137;
582 }
583 assert_eq!(outlier_fraction(&baseline, ¤t, 8), 1.0);
584 }
585
586 #[test]
587 fn only_the_differing_pixels_are_counted() {
588 let baseline = frame(16);
589 let mut current = frame(16);
590 // One pixel of sixteen, in a channel other than the first.
591 current[2 * 4 + 2] = 200;
592 assert_eq!(outlier_fraction(&baseline, ¤t, 8), 1.0 / 16.0);
593 }
594
595 #[test]
596 fn the_alpha_channel_is_not_compared() {
597 let baseline = frame(16);
598 let mut current = frame(16);
599 // Every alpha differs, but the frames still draw the same picture.
600 for pixel in current.as_chunks_mut::<4>().0 {
601 pixel[3] = 0;
602 }
603 assert_eq!(outlier_fraction(&baseline, ¤t, 8), 0.0);
604 }
605}