diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 231bd70f2..9147144da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -306,9 +306,27 @@ jobs: test-stable-wasm: name: cargo test (wasm32) + needs: prime-lfs-cache runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + # We intentionally do not use lfs: true here, instead using the caching method to save LFS bandwidth. + + - name: Restore lfs cache + id: lfs-cache + uses: actions/cache/restore@v4 + with: + path: .git/lfs + # The files targeted with git lfs + key: vello-lfs-${{ needs.prime-lfs-cache.outputs.lfs-hash }} + enableCrossOsArchive: true + + - name: Checkout LFS files + # `git lfs checkout` requires that each individual glob is a separate command line argument. + # The string `''' '''` is how you write `' '` in GitHub's expression context (i.e. two quotes separated by a space) + # The quotes are to avoid the shell from evaluating the globs itself. + run: git lfs checkout '${{ join(fromJson(env.LFS_FILES), ''' ''') }}' + continue-on-error: true - name: install stable toolchain uses: dtolnay/rust-toolchain@master @@ -337,6 +355,10 @@ jobs: run: wasm-pack test --headless --chrome working-directory: sparse_strips/vello_hybrid/examples/native_webgl + - name: Run vello_sparse_tests on Chrome + run: wasm-pack test --headless --chrome --features webgl + working-directory: sparse_strips/vello_sparse_tests + check-stable-android: name: cargo check (aarch64-android) diff --git a/Cargo.lock b/Cargo.lock index 6f6c94603..26cfe6492 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3312,6 +3312,9 @@ dependencies = [ "vello_cpu", "vello_dev_macros", "vello_hybrid", + "wasm-bindgen", + "wasm-bindgen-test", + "web-sys", "wgpu", ] diff --git a/sparse_strips/vello_dev_macros/src/test.rs b/sparse_strips/vello_dev_macros/src/test.rs index 1ab50134b..497b70881 100644 --- a/sparse_strips/vello_dev_macros/src/test.rs +++ b/sparse_strips/vello_dev_macros/src/test.rs @@ -61,6 +61,10 @@ pub(crate) fn vello_test_inner(attr: TokenStream, item: TokenStream) -> TokenStr let u8_fn_name = Ident::new(&format!("{}_cpu_u8", input_fn_name), input_fn_name.span()); let f32_fn_name = Ident::new(&format!("{}_cpu_f32", input_fn_name), input_fn_name.span()); let hybrid_fn_name = Ident::new(&format!("{}_hybrid", input_fn_name), input_fn_name.span()); + let webgl_fn_name = Ident::new( + &format!("{}_hybrid_webgl", input_fn_name), + input_fn_name.span(), + ); // TODO: Tests with the same names in different modules can clash, see // https://github.com/linebender/vello/pull/925#discussion_r2070710362. @@ -70,6 +74,7 @@ pub(crate) fn vello_test_inner(attr: TokenStream, item: TokenStream) -> TokenStr let u8_fn_name_str = u8_fn_name.to_string(); let f32_fn_name_str = f32_fn_name.to_string(); let hybrid_fn_name_str = hybrid_fn_name.to_string(); + let webgl_fn_name_str = webgl_fn_name.to_string(); let Arguments { width, @@ -83,6 +88,30 @@ pub(crate) fn vello_test_inner(attr: TokenStream, item: TokenStream) -> TokenStr no_ref, } = parse_args(&attrs); + // Wasm doesn't have access to the filesystem. For wasm, inline the snapshot bytes into the + // binary. + let reference_image_name = Ident::new( + &format!( + "{}_REFERENCE_IMAGE", + input_fn_name.to_string().to_uppercase() + ), + input_fn_name.span(), + ); + let reference_image_const = if !no_ref { + quote! { + #[cfg(target_arch = "wasm32")] + const #reference_image_name: &[u8] = include_bytes!( + concat!(env!("CARGO_MANIFEST_DIR"), "/snapshots/", #input_fn_name_str, ".png") + ); + #[cfg(not(target_arch = "wasm32"))] + const #reference_image_name: &[u8] = &[]; + } + } else { + quote! { + const #reference_image_name: &[u8] = &[]; + } + }; + let cpu_u8_tolerance = cpu_u8_tolerance + DEFAULT_CPU_U8_TOLERANCE; // Since f32 is our gold standard, we always require exact matches for this one. let cpu_f32_tolerance = DEFAULT_CPU_F32_TOLERANCE; @@ -137,7 +166,7 @@ pub(crate) fn vello_test_inner(attr: TokenStream, item: TokenStream) -> TokenStr let mut ctx = get_ctx::(#width, #height, #transparent); #input_fn_name(&mut ctx); if !#no_ref { - check_ref(&ctx, #input_fn_name_str, #fn_name_str, #tolerance, #is_reference, #render_mode); + check_ref(&ctx, #input_fn_name_str, #fn_name_str, #tolerance, #is_reference, #render_mode, #reference_image_name); } } } @@ -161,6 +190,8 @@ pub(crate) fn vello_test_inner(attr: TokenStream, item: TokenStream) -> TokenStr let expanded = quote! { #input_fn + #reference_image_const + #u8_snippet #f32_snippet @@ -177,7 +208,24 @@ pub(crate) fn vello_test_inner(attr: TokenStream, item: TokenStream) -> TokenStr let mut ctx = get_ctx::(#width, #height, #transparent); #input_fn_name(&mut ctx); if !#no_ref { - check_ref(&ctx, #input_fn_name_str, #hybrid_fn_name_str, #hybrid_tolerance, false, RenderMode::OptimizeSpeed); + check_ref(&ctx, #input_fn_name_str, #hybrid_fn_name_str, #hybrid_tolerance, false, RenderMode::OptimizeSpeed, #reference_image_name); + } + } + + #ignore_hybrid + #[cfg(all(target_arch = "wasm32", feature = "webgl"))] + #[wasm_bindgen_test::wasm_bindgen_test] + async fn #webgl_fn_name() { + use crate::util::{ + check_ref, get_ctx + }; + use vello_hybrid::Scene; + use vello_cpu::RenderMode; + + let mut ctx = get_ctx::(#width, #height, #transparent); + #input_fn_name(&mut ctx); + if !#no_ref { + check_ref(&ctx, #input_fn_name_str, #webgl_fn_name_str, #hybrid_tolerance, false, RenderMode::OptimizeSpeed, #reference_image_name); } } }; diff --git a/sparse_strips/vello_sparse_tests/Cargo.toml b/sparse_strips/vello_sparse_tests/Cargo.toml index bfa7d5247..ec5069705 100644 --- a/sparse_strips/vello_sparse_tests/Cargo.toml +++ b/sparse_strips/vello_sparse_tests/Cargo.toml @@ -28,5 +28,24 @@ image = { workspace = true, features = ["png"] } skrifa = { workspace = true } smallvec = { workspace = true } +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen-test = "0.3.50" +web-sys = { version = "0.3.77", features = [ + "HtmlCanvasElement", + "WebGl2RenderingContext", + "Document", + "Window", + "Element", + "HtmlElement", + "HtmlImageElement", + "Blob", + "BlobPropertyBag", + "Url", +] } +wasm-bindgen = "0.2.100" + +[features] +webgl = ["vello_hybrid/webgl"] + [lints] workspace = true diff --git a/sparse_strips/vello_sparse_tests/README.md b/sparse_strips/vello_sparse_tests/README.md new file mode 100644 index 000000000..d8903ddd4 --- /dev/null +++ b/sparse_strips/vello_sparse_tests/README.md @@ -0,0 +1,54 @@ +
+ +# Vello Sparse Tests + +[![Apache 2.0 or MIT license.](https://img.shields.io/badge/license-Apache--2.0_OR_MIT-blue.svg)](#license) +\ +[![Linebender Zulip chat.](https://img.shields.io/badge/Linebender-%23vello-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/channel/197075-vello) +[![GitHub Actions CI status.](https://img.shields.io/github/actions/workflow/status/linebender/vello/ci.yml?logo=github&label=CI)](https://github.com/linebender/vello/actions) + +
+ +This is a development-only crate for testing the sparse_strip renderers across a corpus of reference +images: +- CPU +- WGPU +- WASM32 WebGL + +The `vello_test` proc macro will create a snapshot test for each supported renderer target. See the +below example usage. + +```rs +// Draws a filled triangle into a 125x125 scene. +#[vello_test(width = 125, height = 125)] +fn filled_triangle(ctx: &mut impl Renderer) { + let path = { + let mut path = BezPath::new(); + path.move_to((5.0, 5.0)); + path.line_to((95.0, 50.0)); + path.line_to((5.0, 95.0)); + path.close_path(); + + path + }; + + ctx.set_paint(LIME); + ctx.fill_path(&path); +} +``` + +See all the attributes that can be passed to `vello_test` in `vello_dev_macros/test.rs`. + +## Testing WebGL on the Browser + +Requirements: + - on MacOS, a minimum Clang major version of 20 is required. + +To run the `vello_sparse_tests` suite on WebGL headless: + +```sh +wasm-pack test --headless --chrome --features webgl +``` + +To debug the output images in webgl, run the same command without `--headless`. Any tests that fail +will have their diff image appended to the bottom of the page. diff --git a/sparse_strips/vello_sparse_tests/tests/mod.rs b/sparse_strips/vello_sparse_tests/tests/mod.rs index 81e0d6f05..4d9f627b8 100644 --- a/sparse_strips/vello_sparse_tests/tests/mod.rs +++ b/sparse_strips/vello_sparse_tests/tests/mod.rs @@ -21,6 +21,9 @@ #![allow(missing_docs, reason = "we don't need docs for testing")] #![allow(clippy::cast_possible_truncation, reason = "not critical for testing")] +#[cfg(target_arch = "wasm32")] +wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + mod basic; mod blurred_rounded_rect; mod clip; diff --git a/sparse_strips/vello_sparse_tests/tests/renderer.rs b/sparse_strips/vello_sparse_tests/tests/renderer.rs index c3fb50d52..64b007266 100644 --- a/sparse_strips/vello_sparse_tests/tests/renderer.rs +++ b/sparse_strips/vello_sparse_tests/tests/renderer.rs @@ -219,6 +219,7 @@ impl Renderer for Scene { // This method creates device resources every time it is called. This does not matter much for // testing, but should not be used as a basis for implementing something real. This would be a // very bad example for that. + #[cfg(not(all(target_arch = "wasm32", feature = "webgl")))] fn render_to_pixmap(&self, pixmap: &mut Pixmap, _: RenderMode) { // On some platforms using `cargo test` triggers segmentation faults in wgpu when the GPU // tests are run in parallel (likely related to the number of device resources being @@ -362,6 +363,58 @@ impl Renderer for Scene { texture_copy_buffer.unmap(); } + // vello_hybrid WebGL renderer backend. + #[cfg(all(target_arch = "wasm32", feature = "webgl"))] + fn render_to_pixmap(&self, pixmap: &mut Pixmap, _: RenderMode) { + use wasm_bindgen::JsCast; + use web_sys::{HtmlCanvasElement, WebGl2RenderingContext}; + + let width = self.width(); + let height = self.height(); + + // Create an offscreen HTMLCanvasElement, render the test image to it, and finally read off + // the pixmap for diff checking. + let document = web_sys::window().unwrap().document().unwrap(); + + let canvas = document + .create_element("canvas") + .unwrap() + .dyn_into::() + .unwrap(); + + canvas.set_width(width.into()); + canvas.set_height(height.into()); + + let mut renderer = vello_hybrid::WebGlRenderer::new(&canvas); + let render_size = vello_hybrid::RenderSize { + width: width.into(), + height: height.into(), + }; + + renderer.render(self, &render_size).unwrap(); + + let gl = canvas + .get_context("webgl2") + .unwrap() + .unwrap() + .dyn_into::() + .unwrap(); + let mut pixels = vec![0_u8; (width as usize) * (height as usize) * 4]; + gl.read_pixels_with_opt_u8_array( + 0, + 0, + width.into(), + height.into(), + WebGl2RenderingContext::RGBA, + WebGl2RenderingContext::UNSIGNED_BYTE, + Some(&mut pixels), + ) + .unwrap(); + + let pixmap_data = pixmap.data_as_u8_slice_mut(); + pixmap_data.copy_from_slice(&pixels); + } + fn width(&self) -> u16 { Self::width(self) } diff --git a/sparse_strips/vello_sparse_tests/tests/util.rs b/sparse_strips/vello_sparse_tests/tests/util.rs index 9e726bfe9..26d4573ad 100644 --- a/sparse_strips/vello_sparse_tests/tests/util.rs +++ b/sparse_strips/vello_sparse_tests/tests/util.rs @@ -11,8 +11,7 @@ use skrifa::raw::FileRef; use smallvec::smallvec; use std::cmp::max; use std::io::Cursor; -use std::path::PathBuf; -use std::sync::{Arc, LazyLock}; +use std::sync::Arc; use vello_common::color::DynamicColor; use vello_common::color::palette::css::{BLUE, GREEN, RED, WHITE, YELLOW}; use vello_common::glyph::Glyph; @@ -21,11 +20,17 @@ use vello_common::peniko::{Blob, ColorStop, ColorStops, Font}; use vello_common::pixmap::Pixmap; use vello_cpu::RenderMode; -static REFS_PATH: LazyLock = LazyLock::new(|| { +#[cfg(not(target_arch = "wasm32"))] +use std::path::PathBuf; + +#[cfg(not(target_arch = "wasm32"))] +static REFS_PATH: std::sync::LazyLock = std::sync::LazyLock::new(|| { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../vello_sparse_tests/snapshots") }); -static DIFFS_PATH: LazyLock = - LazyLock::new(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../vello_sparse_tests/diffs")); +#[cfg(not(target_arch = "wasm32"))] +static DIFFS_PATH: std::sync::LazyLock = std::sync::LazyLock::new(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../vello_sparse_tests/diffs") +}); pub(crate) fn get_ctx(width: u16, height: u16, transparent: bool) -> T { let mut ctx = T::new(width, height); @@ -225,6 +230,7 @@ pub(crate) fn pixmap_to_png(pixmap: Pixmap, width: u32, height: u32) -> Vec png_data } +#[cfg(not(target_arch = "wasm32"))] pub(crate) fn check_ref( ctx: &impl Renderer, // The name of the test. @@ -238,6 +244,7 @@ pub(crate) fn check_ref( // for creating reference images. is_reference: bool, render_mode: RenderMode, + _: &[u8], ) { let pixmap = render_pixmap(ctx, render_mode); @@ -286,6 +293,103 @@ pub(crate) fn check_ref( } } +#[cfg(target_arch = "wasm32")] +pub(crate) fn check_ref( + ctx: &impl Renderer, + _test_name: &str, + // The name of the specific instance of the test that is being run + // (e.g. test_gpu, test_cpu_u8, etc.) + specific_name: &str, + // Tolerance for pixel differences. + threshold: u8, + // Must be `false` on `wasm32` as reference image cannot be written to filesystem. + is_reference: bool, + render_mode: RenderMode, + ref_data: &[u8], +) { + assert!(!is_reference, "WASM cannot create new reference images"); + + let pixmap = render_pixmap(ctx, render_mode); + let encoded_image = pixmap_to_png(pixmap, ctx.width() as u32, ctx.height() as u32); + let actual = load_from_memory(&encoded_image).unwrap().into_rgba8(); + + let ref_image = load_from_memory(ref_data).unwrap().into_rgba8(); + + let diff_image = get_diff(&ref_image, &actual, threshold); + if let Some(ref img) = diff_image { + append_diff_image_to_browser_document(specific_name, img); + panic!("test didn't match reference image. Scroll to bottom of browser to view diff."); + } +} + +#[cfg(target_arch = "wasm32")] +fn append_diff_image_to_browser_document(specific_name: &str, diff_image: &RgbaImage) { + use wasm_bindgen::JsCast; + use web_sys::js_sys::{Array, Uint8Array}; + use web_sys::{Blob, BlobPropertyBag, HtmlImageElement, Url, window}; + + let window = window().unwrap(); + let document = window.document().unwrap(); + let body = document.body().unwrap(); + + let container = document.create_element("div").unwrap(); + container + .set_attribute( + "style", + "border: 2px solid red; \ + margin: 20px; \ + padding: 20px; \ + background: #f0f0f0; \ + display: inline-block;", + ) + .unwrap(); + + let title = document.create_element("h3").unwrap(); + title.set_text_content(Some(&format!("Test Failed: {}", specific_name))); + title + .set_attribute("style", "color: red; margin-top: 0;") + .unwrap(); + container.append_child(&title).unwrap(); + + let diff_png = { + let mut png_data = Vec::new(); + let cursor = std::io::Cursor::new(&mut png_data); + let encoder = image::codecs::png::PngEncoder::new(cursor); + encoder + .write_image( + diff_image.as_raw(), + diff_image.width(), + diff_image.height(), + image::ExtendedColorType::Rgba8, + ) + .unwrap(); + png_data + }; + + let uint8_array = Uint8Array::new_with_length(diff_png.len() as u32); + uint8_array.copy_from(&diff_png); + let array = Array::new(); + array.push(&uint8_array.buffer()); + let blob_property_bag = BlobPropertyBag::new(); + blob_property_bag.set_type("image/png"); + let blob = Blob::new_with_u8_array_sequence_and_options(&array, &blob_property_bag).unwrap(); + let url = Url::create_object_url_with_blob(&blob).unwrap(); + + let img = document + .create_element("img") + .unwrap() + .dyn_into::() + .unwrap(); + img.set_src(&url); + img.set_attribute("style", "border: 1px solid #ccc; max-width: 100%;") + .unwrap(); + img.set_attribute("title", "Expected | Diff | Actual") + .unwrap(); + + container.append_child(&img).unwrap(); + body.append_child(&container).unwrap(); +} + fn get_diff( expected_image: &RgbaImage, actual_image: &RgbaImage,