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;