Skip to main content

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, &current, 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, &current, 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, &current, 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, &current, 8), 0.0);
604    }
605}