Skip to main content

unlit_wgpu_test_util/
lib.rs

1#![doc = include_str!("../README.md")]
2#![cfg_attr(docsrs, feature(doc_cfg))]
3#![forbid(unsafe_code)]
4
5use std::sync::Once;
6
7pub use unlit_wgpu::capabilities::{DeviceCapabilities, DeviceTier};
8
9/// The logging facade the tests record through, re-exported so a test crate
10/// needs no `log` dependency of its own.
11///
12/// Recording is what the harness's backend reports; see [`init_logging`].
13pub use log;
14
15// The catalog a test file declares its tests in, and the two runners that
16// drive it: `native` under `cargo nextest`, `browser` in a wasm build. A test
17// file names only the macros, so which runner is in play is not its concern.
18mod test_catalog;
19
20pub use test_catalog::{TestBody, TestEntry, TestFn};
21
22#[cfg(not(target_arch = "wasm32"))]
23mod native;
24#[cfg(not(target_arch = "wasm32"))]
25pub use native::run;
26
27// The proxy a native build runs in the browser's stead, sending each test to
28// the runner over HTTP. Which of the two the binary is, is decided at run time
29// from the environment.
30#[cfg(not(target_arch = "wasm32"))]
31mod proxy;
32#[cfg(not(target_arch = "wasm32"))]
33pub use proxy::{WASM_TEST_ENV, run_wasm};
34
35#[cfg(target_arch = "wasm32")]
36pub mod browser;
37
38// What the run drew with, described by wgpu itself. Read by both entry points
39// a task prints it from (`cargo xtask print-gpu` and the browser pass), so the
40// two descriptions are the same record serialized the same way.
41pub mod gpu_info;
42
43pub use gpu_info::GpuInfo;
44
45/// Drive a future to completion on the calling thread.
46///
47/// The harness's own entry points already do this, so this is for a
48/// synchronous caller that has to build a [`Ctx`] itself. It cannot be used on
49/// the web, where a browser tab has nothing to block on.
50#[cfg(not(target_arch = "wasm32"))]
51pub use pollster::block_on;
52
53// ---------------------------------------------------------------------------
54// Logging
55// ---------------------------------------------------------------------------
56
57/// Install the harness's logger backend once, for the whole test process.
58///
59/// Called by [`Ctx::headless`], so a test that builds a context logs without
60/// asking.  Tests that never build a context — the ECS ones — call this
61/// directly if they log.
62///
63/// `RUST_LOG` picks the level natively; without it the default is `warn`.
64/// A process holds one logger, so repeated calls are no-ops — and a test that
65/// installed a backend of its own first keeps it.
66pub fn init_logging() {
67    static ONCE: Once = Once::new();
68    ONCE.call_once(|| {
69        #[cfg(not(target_arch = "wasm32"))]
70        {
71            // `try_init`, not `init`: installing over a backend that is already
72            // there would panic, and that backend is the one to keep.
73            env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn"))
74                .try_init()
75                .ok();
76        }
77        #[cfg(target_arch = "wasm32")]
78        {
79            // The browser console is the terminal here.
80            console_log::init_with_level(log::Level::Warn).ok();
81        }
82    });
83}
84
85// ---------------------------------------------------------------------------
86// GPU context
87// ---------------------------------------------------------------------------
88
89/// A ready-to-use GPU context.
90pub struct Ctx {
91    /// The tier the device was requested at.
92    ///
93    /// Kept beside the device because a device reports what it was given, not
94    /// what was asked for: a WebGL2-shaped device on a desktop adapter has the
95    /// adapter's features and limits as lowered by the tier, which is only
96    /// readable as a description when the tier is part of it.
97    pub tier: DeviceTier,
98    /// The device every resource in a test is created on.
99    pub device: wgpu::Device,
100    /// The queue a test's writes and submissions go through.
101    pub queue: wgpu::Queue,
102    /// What the device can do beyond the WebGPU baseline.
103    ///
104    /// A [`DeviceCapabilities`] rather than the adapter it was read from: the
105    /// adapter is dropped once the device exists, and the renderer needs the
106    /// capabilities, not the adapter.
107    pub capabilities: DeviceCapabilities,
108}
109
110impl Ctx {
111    /// Create a headless GPU context with the default adapter.
112    ///
113    /// `UNLIT3D_DEVICE_TIER` narrows the device; see [`Ctx::headless_for`].
114    ///
115    /// Asynchronous because wgpu's adapter and device requests are: on the web
116    /// they wrap promises that only settle in a later task, and a browser has
117    /// no second thread to block on. A synchronous caller drives this through
118    /// [`block_on`].
119    pub async fn headless() -> Ctx {
120        Ctx::headless_for(DeviceTier::from_env()).await
121    }
122
123    /// Create a headless GPU context restricted to `tier`.
124    ///
125    /// The tier's limits are requested from the adapter and its capabilities
126    /// are recorded beside the device, so a test can exercise the paths a
127    /// WebGL2 browser takes — no storage buffers, no `base_vertex` — on
128    /// hardware that has both. The adapter's own limits and capabilities are
129    /// what [`DeviceTier::Native`] asks for; the WebGPU baseline's, which is
130    /// what [`Ctx::headless`] uses, are what [`DeviceTier::WebGpu`] asks for.
131    ///
132    /// Nothing is presented: the context is headless on the web too, where the
133    /// canvas exists only because a browser will not hand out an adapter
134    /// without one.
135    pub async fn headless_for(tier: DeviceTier) -> Ctx {
136        init_logging();
137        let instance = new_instance();
138
139        // A browser creates a GL context from a canvas, so its adapter request
140        // has to name a surface even though nothing is ever drawn into it. The
141        // surface is dropped once the adapter has been chosen: the device
142        // outlives the surface it was chosen for.
143        #[cfg(target_arch = "wasm32")]
144        let surface = Some(create_surface(&instance));
145        #[cfg(not(target_arch = "wasm32"))]
146        let surface: Option<wgpu::Surface<'static>> = None;
147
148        let adapter = instance
149            .request_adapter(&wgpu::RequestAdapterOptions {
150                compatible_surface: surface.as_ref(),
151                ..Default::default()
152            })
153            .await
154            .expect("no graphics adapter available");
155        let capabilities = tier.capabilities_of(&adapter);
156        let (device, queue) = adapter
157            .request_device(&wgpu::DeviceDescriptor {
158                label: Some("unlit_wgpu_test_util"),
159                required_features: wgpu::Features::empty(),
160                required_limits: tier.limits(&adapter.limits()),
161                default_queue: wgpu::QueueDescriptor { label: None },
162                experimental_features: wgpu::ExperimentalFeatures::disabled(),
163                memory_hints: wgpu::MemoryHints::Performance,
164                trace: wgpu::Trace::Off,
165            })
166            .await
167            .expect("failed to request device");
168        drop(surface);
169        Ctx {
170            tier,
171            device,
172            queue,
173            capabilities,
174        }
175    }
176}
177
178/// The instance a context's device comes from.
179///
180/// Natively the environment chooses, so `WGPU_BACKEND` selects a backend the
181/// same way it does in an application — which is how the GLES and WebGL2 paths
182/// are reached on a machine whose default is Vulkan.
183fn new_instance() -> wgpu::Instance {
184    #[cfg(not(target_arch = "wasm32"))]
185    {
186        wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env())
187    }
188    #[cfg(target_arch = "wasm32")]
189    {
190        // A browser has no environment to read, and WebGL2 is what the wasm
191        // suite is for, so the backend is named rather than discovered.
192        let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
193        descriptor.backends = wgpu::Backends::GL;
194        // WebGL cannot report a fence as done on its own, so a `poll(Wait)`
195        // would never resolve: `AutoFinish` is what makes the backend finish
196        // the fence itself and let a readback complete. See wgpu#4589.
197        descriptor.backend_options.gl.fence_behavior = wgpu::GlFenceBehavior::AutoFinish;
198        wgpu::Instance::new(descriptor)
199    }
200}
201
202/// A canvas for the browser to make a WebGL context from.
203///
204/// The canvas is never attached to the document and never sized: the context
205/// is what the adapter request needs, not the drawing surface.
206#[cfg(target_arch = "wasm32")]
207fn create_surface(instance: &wgpu::Instance) -> wgpu::Surface<'static> {
208    use wasm_bindgen::JsCast as _;
209
210    let canvas = web_sys::window()
211        .and_then(|window| window.document())
212        .and_then(|document| document.create_element("canvas").ok())
213        .and_then(|element| element.dyn_into::<web_sys::HtmlCanvasElement>().ok())
214        .expect("a document to create the test canvas in");
215    instance
216        .create_surface(wgpu::SurfaceTarget::Canvas(canvas))
217        .expect("a surface from the test canvas")
218}
219
220/// Pair a binding slot with a resource.
221pub fn bg_entry(binding: u32, resource: wgpu::BindingResource<'_>) -> wgpu::BindGroupEntry<'_> {
222    wgpu::BindGroupEntry { binding, resource }
223}
224
225/// Decode an sRGB-encoded byte to linear light.
226pub fn srgb_to_linear_u8(c: u8) -> f32 {
227    let c = c as f32 / 255.0;
228    if c <= 0.04045 {
229        c / 12.92
230    } else {
231        ((c + 0.055) / 1.055).powf(2.4)
232    }
233}
234
235// ---------------------------------------------------------------------------
236// Offscreen colour target and frame readback
237// ---------------------------------------------------------------------------
238
239/// An offscreen RGBA8 colour target plus its default view.
240///
241/// The views are bare wgpu ones: this crate is the layer *below*
242/// `unlit_wgpu` — which depends on it, not the other way round — so
243/// `unlit_wgpu::resources::TextureView` is not reachable here. A caller that
244/// registers this target's view in a resource graph pairs it with its format
245/// itself.
246#[expect(
247    clippy::disallowed_types,
248    reason = "this crate sits below unlit_wgpu, which depends on it, so the \
249              wrapper type is not reachable here"
250)]
251pub struct ColorTarget {
252    /// The texture the target draws into.
253    pub texture: wgpu::Texture,
254    /// The view passes are opened over.
255    pub view: wgpu::TextureView,
256}
257
258#[expect(
259    clippy::disallowed_methods,
260    reason = "this crate sits below unlit_wgpu, which depends on it, so the \
261              wrapper's constructor is not reachable here"
262)]
263impl ColorTarget {
264    /// `Rgba8UnormSrgb` target sized `(width, height)`.
265    pub fn new(device: &wgpu::Device, label: &str, width: u32, height: u32) -> Self {
266        let texture = device.create_texture(&wgpu::TextureDescriptor {
267            label: Some(label),
268            size: wgpu::Extent3d {
269                width,
270                height,
271                depth_or_array_layers: 1,
272            },
273            mip_level_count: 1,
274            sample_count: 1,
275            dimension: wgpu::TextureDimension::D2,
276            format: wgpu::TextureFormat::Rgba8UnormSrgb,
277            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
278            view_formats: &[],
279        });
280        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
281        Self { texture, view }
282    }
283}
284
285/// A read-back frame: tight RGBA8 bytes plus dimensions.
286pub struct Frame {
287    /// The frame's pixels, row-major and tightly packed, four bytes per texel.
288    pub rgba: Vec<u8>,
289    /// The frame's width, in texels.
290    pub width: u32,
291    /// The frame's height, in texels.
292    pub height: u32,
293}
294
295impl core::ops::Deref for Frame {
296    type Target = [u8];
297    fn deref(&self) -> &[u8] {
298        &self.rgba
299    }
300}
301
302impl Frame {
303    /// The texel at `(x, y)`, as RGBA8.
304    ///
305    /// # Panics
306    ///
307    /// If the coordinates fall outside the frame.
308    pub fn pixel_u8(&self, x: u32, y: u32) -> [u8; 4] {
309        let off = ((y as usize * self.width as usize) + x as usize) * 4;
310        [
311            self.rgba[off],
312            self.rgba[off + 1],
313            self.rgba[off + 2],
314            self.rgba[off + 3],
315        ]
316    }
317}
318
319/// Opaque sRGB clear/draw colour from RGB components (alpha = 1).
320pub fn rgb(r: f64, g: f64, b: f64) -> wgpu::Color {
321    wgpu::Color { r, g, b, a: 1.0 }
322}
323
324/// Bytes per texel block of `texture`'s format.
325pub fn texel_bytes(texture: &wgpu::Texture) -> u32 {
326    texture
327        .format()
328        .block_copy_size(None)
329        .expect("texture format has a block copy size")
330}
331
332/// Count pixels whose colour differs from `background` beyond `tolerance`.
333pub fn count_pixels_off_background(px: &[u8], background: [f64; 3], tolerance: u8) -> usize {
334    px.as_chunks::<4>()
335        .0
336        .iter()
337        .filter(|p| {
338            p[..3]
339                .iter()
340                .zip(background)
341                .any(|(&c, bg)| (c as f64 / 255.0 - bg).abs() * 255.0 > tolerance as f64)
342        })
343        .count()
344}
345
346// ---------------------------------------------------------------------------
347// Snapshot helpers (require the `snapshot` feature)
348// ---------------------------------------------------------------------------
349
350#[cfg(feature = "snapshot")]
351mod snapshot;
352
353#[cfg(feature = "snapshot")]
354pub use snapshot::{
355    DEFAULT_MIN_SCORE, DEFAULT_TOLERANCE, Snapshot, SnapshotError, Tolerance,
356    assert_image_snapshot, assert_image_snapshot_with_tolerance,
357};
358
359// The storing half of the snapshot helpers needs a filesystem, which a browser
360// has not got. What is left on the web still compares against the frames
361// embedded by `snapshot!`.
362#[cfg(all(feature = "snapshot", not(target_arch = "wasm32")))]
363pub use snapshot::{
364    DEFAULT_MISMATCH_DIR, MISMATCH_DIR_ENV, score_frame_webp, snapshot_path, store_frame_webp,
365};
366
367// The encoder is the one piece of the storing half a browser needs: a frame that
368// fails a comparison is encoded there and handed to the runner, which has the
369// filesystem this side has not got.
370#[cfg(all(feature = "snapshot", target_arch = "wasm32"))]
371pub use snapshot::encode_frame_webp;