From 06bd16c942cc8ac4a3bae2eb399091118955c285 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Sat, 7 Nov 2026 15:16:10 -0400 Subject: [PATCH] feat(napi): texImage2D and texSubImage2D take a WebGPU canvas A WebGPU canvas used as a WebGL texture source threw (texImage2D) or, before #188, panicked (texSubImage2D). Both now read the canvas's frame (the current texture if one is out, else the last presented frame) as unpremultiplied RGBA8 and upload it, like a 2D canvas source. canvas-c: toDataURL's copy-and-map step is now with_mapped_texture, shared with the new read_rgba8; canvas_native_webgl_tex_{,sub_}image2d_webgpu return false when there is no 8-bit frame, which the binding throws for. Specs: toDataURL pixel check for a WebGPU canvas (all platforms), and texImage2D/texSubImage2D from a WebGPU canvas (Windows for now). --- crates/canvas-c/src/webgl/gl.rs | 73 +++++ .../canvas-c/src/webgpu/gpu_canvas_context.rs | 291 ++++++++++-------- crates/canvas-napi/src/gl/base.rs | 26 +- .../CanvasNative/include/canvas_native.h | 23 ++ tools/demo/canvas/spec/canvassource.ts | 57 +++- tools/demo/canvas/spec/webgpu.ts | 29 +- 6 files changed, 361 insertions(+), 138 deletions(-) diff --git a/crates/canvas-c/src/webgl/gl.rs b/crates/canvas-c/src/webgl/gl.rs index 7b7f916e7..bea2b2fe1 100644 --- a/crates/canvas-c/src/webgl/gl.rs +++ b/crates/canvas-c/src/webgl/gl.rs @@ -4064,6 +4064,42 @@ pub extern "C" fn canvas_native_webgl_tex_image2d_canvas2d( }); } +/// `false` when the WebGPU canvas has no frame to read (not configured, or not an 8-bit format). +#[no_mangle] +pub extern "C" fn canvas_native_webgl_tex_image2d_webgpu( + target: i32, + level: i32, + internalformat: i32, + format: i32, + image_type: i32, + webgpu: *const crate::webgpu::gpu_canvas_context::CanvasGPUCanvasContext, + state: *mut WebGLState, +) -> bool { + assert!(!state.is_null()); + assert!(!webgpu.is_null()); + let state = unsafe { &*state }; + let Some((width, height, pixels)) = + crate::webgpu::gpu_canvas_context::read_rgba8(unsafe { &*webgpu }) + else { + return false; + }; + state.post(move |state| { + canvas_webgl::webgl::canvas_native_webgl_tex_image2d( + target, + level, + internalformat, + width as i32, + height as i32, + 0, + format, + image_type, + pixels.as_slice(), + state, + ) + }); + true +} + #[no_mangle] pub extern "C" fn canvas_native_webgl_tex_image2d_webgl( target: i32, @@ -4273,6 +4309,43 @@ pub extern "C" fn canvas_native_webgl_tex_sub_image2d_canvas2d( }); } +/// `false` when the WebGPU canvas has no frame to read; see `canvas_native_webgl_tex_image2d_webgpu`. +#[no_mangle] +pub extern "C" fn canvas_native_webgl_tex_sub_image2d_webgpu( + target: u32, + level: i32, + xoffset: i32, + yoffset: i32, + format: u32, + image_type: i32, + webgpu: *const crate::webgpu::gpu_canvas_context::CanvasGPUCanvasContext, + state: *mut WebGLState, +) -> bool { + assert!(!state.is_null()); + assert!(!webgpu.is_null()); + let state = unsafe { &*state }; + let Some((width, height, pixels)) = + crate::webgpu::gpu_canvas_context::read_rgba8(unsafe { &*webgpu }) + else { + return false; + }; + state.post(move |state| { + canvas_webgl::webgl::canvas_native_webgl_tex_sub_image2d( + target, + level, + xoffset, + yoffset, + width as i32, + height as i32, + format, + image_type, + pixels.as_slice(), + state, + ) + }); + true +} + #[no_mangle] pub extern "C" fn canvas_native_webgl_tex_sub_image2d_webgl( target: u32, diff --git a/crates/canvas-c/src/webgpu/gpu_canvas_context.rs b/crates/canvas-c/src/webgpu/gpu_canvas_context.rs index 5ad7192b9..54688ecda 100644 --- a/crates/canvas-c/src/webgpu/gpu_canvas_context.rs +++ b/crates/canvas-c/src/webgpu/gpu_canvas_context.rs @@ -482,10 +482,6 @@ fn round_up_to_256(value: u32) -> u32 { (value + 255) & !255 } -fn round_up_to_256_u64(value: u64) -> u64 { - (value + 255) & !255 -} - fn to_data_url_with_texture( context: &CanvasGPUCanvasContext, @@ -497,33 +493,99 @@ fn to_data_url_with_texture( quality: u32, is_bgra: bool, ) -> Option { - let queue = &device.queue; + let _ = context; + with_mapped_texture(device, texture, width, height, |bytes, row_bytes| { + #[cfg(feature = "2d")] + let encoded = Some(canvas_2d::bytes_to_data_rgba8_url( + width as i32, + height as i32, + bytes, + row_bytes, + is_bgra, + format, + quality, + )); + + #[cfg(not(feature = "2d"))] + let encoded = { + let image_data: Vec = if is_bgra { + let mut data = bytes.to_vec(); + canvas_core::image_asset::ImageAsset::bgra_to_rgba_in_place(&mut data); + data + } else { + bytes.to_vec() + }; - unsafe { - let output_buffer_size = round_up_to_256_u64((width as u64) * 4) * (height as u64); - let label = Cow::Borrowed("ToDataURL:Buffer"); - let output_buffer = device.device.create_buffer( - &wgt::BufferDescriptor { - label: wgpu_core::Label::from(label), - size: output_buffer_size, - usage: wgt::BufferUsages::COPY_DST | wgt::BufferUsages::MAP_READ, - mapped_at_creation: false, - }, - ); + match image::ImageBuffer::, Vec>::from_raw( + width, + height, + image_data, + ) { + None => None, + Some(img) => { + let dyn_image = image::DynamicImage::ImageRgba8(img); + let mut output = std::io::Cursor::new(Vec::new()); + let mime = match format { + "image/jpg" | "image/jpeg" => { + let _ = dyn_image + .write_to(&mut output, image::ImageFormat::Jpeg); + "image/jpeg" + } + "image/webp" => { + let _ = dyn_image + .write_to(&mut output, image::ImageFormat::WebP); + "image/webp" + } + "image/gif" => { + let _ = dyn_image + .write_to(&mut output, image::ImageFormat::Gif); + "image/gif" + } + "image/heif" + | "image/heif-sequence" + | "image/heic" + | "image/heic-sequence" => { + let _ = dyn_image + .write_to(&mut output, image::ImageFormat::Avif); + "image/heif" + } + _ => { + let _ = dyn_image + .write_to(&mut output, image::ImageFormat::Png); + "image/png" + } + }; + let encoded = base64::engine::general_purpose::STANDARD + .encode(output.get_ref()); + Some(format!("data:{};base64,{}", mime, encoded)) + } + } + }; + encoded + }) + .flatten() +} - macro_rules! bail { - () => {{ - output_buffer.destroy(); - return None; - }}; - } +/// Copies a 4-byte-per-pixel `texture` into a buffer and hands `f` the mapped bytes and their +/// row stride (rows are padded to 256 bytes). +fn with_mapped_texture( + device: &CanvasGPUDevice, + texture: Arc, + width: u32, + height: u32, + f: impl FnOnce(&[u8], usize) -> R, +) -> Option { + let queue = &device.queue; + let row_bytes = round_up_to_256(4 * width); - let texture_extent = wgt::Extent3d { - width, - height, - depth_or_array_layers: 1, - }; + unsafe { + let output_buffer = device.device.create_buffer(&wgt::BufferDescriptor { + label: wgpu_core::Label::from(Cow::Borrowed("ReadBack:Buffer")), + size: row_bytes as u64 * height as u64, + usage: wgt::BufferUsages::COPY_DST | wgt::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); let texture_copy = wgt::TexelCopyTextureInfo { texture, @@ -531,121 +593,44 @@ fn to_data_url_with_texture( origin: wgt::Origin3d::ZERO, aspect: wgt::TextureAspect::All, }; - let buffer_copy = wgt::TexelCopyBufferInfo { buffer: Arc::clone(&output_buffer), layout: wgt::TexelCopyBufferLayout { offset: 0, - bytes_per_row: Some(round_up_to_256(4 * width)), + bytes_per_row: Some(row_bytes), rows_per_image: Some(height), }, }; - - let label = Cow::Borrowed("ToDataURL:Encoder"); - - let encoder = device.device.create_command_encoder( - &wgt::CommandEncoderDescriptor { - label: wgpu_core::Label::from(label), - }, + let encoder = device.device.create_command_encoder(&wgt::CommandEncoderDescriptor { + label: wgpu_core::Label::from(Cow::Borrowed("ReadBack:Encoder")), + }); + encoder.copy_texture_to_buffer( + &texture_copy, + &buffer_copy, + &wgt::Extent3d { width, height, depth_or_array_layers: 1 }, ); - - encoder.copy_texture_to_buffer(&texture_copy, &buffer_copy, &texture_extent); - - let desc = wgt::CommandBufferDescriptor { label: None }; - - let id = encoder.finish(&desc); - + let id = encoder.finish(&wgt::CommandBufferDescriptor { label: None }); queue.queue.id.submit(&[id]); - let op = wgpu_core::resource::BufferMapOperation { - host: wgpu_core::device::HostMap::Read, - callback: None, - }; - - output_buffer.map_async(0, None, op); + output_buffer.map_async( + 0, + None, + wgpu_core::resource::BufferMapOperation { + host: wgpu_core::device::HostMap::Read, + callback: None, + }, + ); - if device - .device - .poll(wgt::PollType::wait_indefinitely()) - .is_err() - { - bail!(); + if device.device.poll(wgt::PollType::wait_indefinitely()).is_err() { + output_buffer.destroy(); + return None; } let result = match output_buffer.get_mapped_range(0, None) { - Ok((ptr, size)) => { - let bytes = std::slice::from_raw_parts(ptr.as_ptr(), size as usize); - let row_bytes = round_up_to_256_u64((4 * width) as u64) as usize; - - #[cfg(feature = "2d")] - let encoded = Some(canvas_2d::bytes_to_data_rgba8_url( - width as i32, - height as i32, - bytes, - row_bytes, - is_bgra, - format, - quality, - )); - - #[cfg(not(feature = "2d"))] - let encoded = { - let image_data: Vec = if is_bgra { - let mut data = bytes.to_vec(); - canvas_core::image_asset::ImageAsset::bgra_to_rgba_in_place(&mut data); - data - } else { - bytes.to_vec() - }; - - match image::ImageBuffer::, Vec>::from_raw( - width, - height, - image_data, - ) { - None => None, - Some(img) => { - let dyn_image = image::DynamicImage::ImageRgba8(img); - let mut output = std::io::Cursor::new(Vec::new()); - let mime = match format { - "image/jpg" | "image/jpeg" => { - let _ = dyn_image - .write_to(&mut output, image::ImageFormat::Jpeg); - "image/jpeg" - } - "image/webp" => { - let _ = dyn_image - .write_to(&mut output, image::ImageFormat::WebP); - "image/webp" - } - "image/gif" => { - let _ = dyn_image - .write_to(&mut output, image::ImageFormat::Gif); - "image/gif" - } - "image/heif" - | "image/heif-sequence" - | "image/heic" - | "image/heic-sequence" => { - let _ = dyn_image - .write_to(&mut output, image::ImageFormat::Avif); - "image/heif" - } - _ => { - let _ = dyn_image - .write_to(&mut output, image::ImageFormat::Png); - "image/png" - } - }; - let encoded = base64::engine::general_purpose::STANDARD - .encode(output.get_ref()); - Some(format!("data:{};base64,{}", mime, encoded)) - } - } - }; - - encoded - } + Ok((ptr, size)) => Some(f( + std::slice::from_raw_parts(ptr.as_ptr(), size as usize), + row_bytes as usize, + )), Err(_) => None, }; @@ -655,6 +640,56 @@ fn to_data_url_with_texture( } } +/// The canvas's frame as tightly packed, unpremultiplied RGBA8: the current texture if one is +/// out, else the last presented frame. `None` before `configure` or for a non-8-bit format. +pub(crate) fn read_rgba8(context: &CanvasGPUCanvasContext) -> Option<(u32, u32, Vec)> { + let (device, format, premultiplied) = { + let data = context.data.lock(); + let data = data.as_ref()?; + ( + Arc::clone(&data.device), + data.texture_data.format, + data.previous_configuration.alpha_mode == wgt::CompositeAlphaMode::PreMultiplied, + ) + }; + let is_bgra = match format { + TextureFormat::Bgra8Unorm | TextureFormat::Bgra8UnormSrgb => true, + TextureFormat::Rgba8Unorm | TextureFormat::Rgba8UnormSrgb => false, + _ => return None, + }; + + let current = context.current_texture.lock().as_ref().map(Arc::clone); + let (texture, width, height) = match current { + Some(current) => (Arc::clone(¤t.texture), current.width, current.height), + None => { + let read_back = context.read_back_texture.lock(); + let read_back = read_back.as_ref()?; + (Arc::clone(&read_back.texture), read_back.data.size.width, read_back.data.size.height) + } + }; + + let pixels = with_mapped_texture(&device, texture, width, height, |bytes, row_bytes| { + let row = width as usize * 4; + let mut pixels = Vec::with_capacity(row * height as usize); + for y in 0..height as usize { + pixels.extend_from_slice(&bytes[y * row_bytes..y * row_bytes + row]); + } + for px in pixels.chunks_exact_mut(4) { + if is_bgra { + px.swap(0, 2); + } + if premultiplied && px[3] != 0 && px[3] != 255 { + let a = px[3] as u32; + for c in &mut px[..3] { + *c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8; + } + } + } + pixels + })?; + Some((width, height, pixels)) +} + /// No surface: getCurrentTexture hands out an offscreen texture. #[no_mangle] pub unsafe extern "C" fn canvas_native_webgpu_context_create_offscreen( diff --git a/crates/canvas-napi/src/gl/base.rs b/crates/canvas-napi/src/gl/base.rs index 942a055b9..ea1ecce1d 100644 --- a/crates/canvas-napi/src/gl/base.rs +++ b/crates/canvas-napi/src/gl/base.rs @@ -1160,8 +1160,12 @@ macro_rules! impl_webgl_context { Either4::C(gl2) => canvas_c::canvas_native_webgl_tex_image2d_webgl( target, level, internalformat, format_or_width, type_or_height, gl2.state, self.state, ), - Either4::D(_) => { - return Err(Error::from_reason("texImage2D from a WebGPU canvas is not supported")); + Either4::D(gpu) => { + if !canvas_c::canvas_native_webgl_tex_image2d_webgpu( + target, level, internalformat, format_or_width, type_or_height, gpu.context, self.state, + ) { + return Err(Error::from_reason("texImage2D: the WebGPU canvas has no 8-bit frame to read")); + } } }, } @@ -1360,11 +1364,19 @@ macro_rules! impl_webgl_context { self.state, ) } - Either4::D(_) => { - // Same as texImage2D: a WebGPU canvas is not a WebGL texture source - // here. This was a todo!(), so the call panicked inside the Node-API - // callback (crashing the host) instead of throwing. - return Err(Error::from_reason("texSubImage2D from a WebGPU canvas is not supported")); + Either4::D(gpu) => { + if !canvas_c::canvas_native_webgl_tex_sub_image2d_webgpu( + target, + level, + xoffset, + yoffset, + format_or_width as u32, + type_or_height, + gpu.context, + self.state, + ) { + return Err(Error::from_reason("texSubImage2D: the WebGPU canvas has no 8-bit frame to read")); + } } } } diff --git a/packages/canvas/src-native/canvas-ios/CanvasNative/include/canvas_native.h b/packages/canvas/src-native/canvas-ios/CanvasNative/include/canvas_native.h index ba536c39f..20c7df0d8 100644 --- a/packages/canvas/src-native/canvas-ios/CanvasNative/include/canvas_native.h +++ b/packages/canvas/src-native/canvas-ios/CanvasNative/include/canvas_native.h @@ -5216,6 +5216,17 @@ void canvas_native_webgl_tex_image2d_canvas2d(int32_t target, struct CanvasRenderingContext2D *canvas, struct WebGLState *state); +/** + * `false` when the WebGPU canvas has no frame to read (not configured, or not an 8-bit format). + */ +bool canvas_native_webgl_tex_image2d_webgpu(int32_t target, + int32_t level, + int32_t internalformat, + int32_t format, + int32_t image_type, + const struct CanvasGPUCanvasContext *webgpu, + struct WebGLState *state); + void canvas_native_webgl_tex_image2d_webgl(int32_t target, int32_t level, int32_t internalformat, @@ -5282,6 +5293,18 @@ void canvas_native_webgl_tex_sub_image2d_canvas2d(uint32_t target, struct CanvasRenderingContext2D *canvas, struct WebGLState *state); +/** + * `false` when the WebGPU canvas has no frame to read; see `canvas_native_webgl_tex_image2d_webgpu`. + */ +bool canvas_native_webgl_tex_sub_image2d_webgpu(uint32_t target, + int32_t level, + int32_t xoffset, + int32_t yoffset, + uint32_t format, + int32_t image_type, + const struct CanvasGPUCanvasContext *webgpu, + struct WebGLState *state); + void canvas_native_webgl_tex_sub_image2d_webgl(uint32_t target, int32_t level, int32_t xoffset, diff --git a/tools/demo/canvas/spec/canvassource.ts b/tools/demo/canvas/spec/canvassource.ts index 798f56687..9ba1c2701 100644 --- a/tools/demo/canvas/spec/canvassource.ts +++ b/tools/demo/canvas/spec/canvassource.ts @@ -17,6 +17,12 @@ const RED: [number, number, number, number] = [255, 0, 0, 255]; /** Opaque green, used for WebGL clears so the two sources cannot be confused. */ const GREEN: [number, number, number, number] = [0, 255, 0, 255]; +/** Opaque blue, used for WebGPU clears. */ +const BLUE: [number, number, number, number] = [0, 0, 255, 255]; + +declare const __WINDOWS__: boolean; +const WINDOWS = typeof __WINDOWS__ !== 'undefined' && __WINDOWS__; + /** A canvas whose 2d context has been filled with a solid colour. */ function make2DSource(size = 32, colour = 'red') { const { canvas, ctx } = make2D(size, size); @@ -122,7 +128,7 @@ export function registerCanvasSourceSpec() { suite('canvassource.webgl', () => { /** Upload `source` as a texture and read back the pixel it renders to. */ - function uploadAndReadBack(source: any, size = 32) { + function uploadAndReadBack(source: any, size = 32, upload?: (gl: any) => void) { const canvas = makeCanvas(size, size); const gl = canvas.getContext('webgl') as any; if (!gl) { @@ -135,7 +141,11 @@ export function registerCanvasSourceSpec() { gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); + if (upload) { + upload(gl); + } else { + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); + } const error = gl.getError(); if (error !== gl.NO_ERROR) { @@ -204,6 +214,49 @@ void main() { gl_FragColor = texture2D(tex, uv); }`, ok(!result.error, `texImage2D raised GL error ${result.error}`); ok(pixelNear(result.pixels, GREEN), `expected green, got ${describePixel(result.pixels)}`); }); + + // Node-API hosts only so far; the iOS and Android bindings do not take a WebGPU source yet. + if (WINDOWS) { + /** A canvas whose WebGPU context has been cleared to opaque blue. */ + async function makeWebGPUSource(size = 32) { + const adapter = await navigator.gpu?.requestAdapter(); + const device = await adapter?.requestDevice(); + if (!device) { + return null; + } + const canvas = makeCanvas(size, size); + const ctx = canvas.getContext('webgpu') as any; + ctx.configure({ device, format: navigator.gpu.getPreferredCanvasFormat(), alphaMode: 'opaque' }); + const encoder = device.createCommandEncoder(); + const pass = encoder.beginRenderPass({ + colorAttachments: [{ view: ctx.getCurrentTexture().createView(), clearValue: { r: 0, g: 0, b: 1, a: 1 }, loadOp: 'clear', storeOp: 'store' }], + }); + pass.end(); + device.queue.submit([encoder.finish()]); + return canvas; + } + + test('texImage2D accepts a webgpu canvas', async () => { + const source = await makeWebGPUSource(32); + ok(source, 'no webgpu device'); + const result = uploadAndReadBack(source); + ok(result, 'could not create a webgl context'); + ok(!result.error, `texImage2D raised GL error ${result.error}`); + ok(pixelNear(result.pixels, BLUE), `expected blue, got ${describePixel(result.pixels)}`); + }); + + test('texSubImage2D accepts a webgpu canvas', async () => { + const source = await makeWebGPUSource(32); + ok(source, 'no webgpu device'); + const result = uploadAndReadBack(source, 32, (gl) => { + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 32, 32, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, source); + }); + ok(result, 'could not create a webgl context'); + ok(!result.error, `texSubImage2D raised GL error ${result.error}`); + ok(pixelNear(result.pixels, BLUE), `expected blue, got ${describePixel(result.pixels)}`); + }); + } }); suite('canvassource.webgpu', () => { diff --git a/tools/demo/canvas/spec/webgpu.ts b/tools/demo/canvas/spec/webgpu.ts index 139035c3f..077013daf 100644 --- a/tools/demo/canvas/spec/webgpu.ts +++ b/tools/demo/canvas/spec/webgpu.ts @@ -3,7 +3,8 @@ * passes read back through a buffer. Every test is async and needs a device. */ -import { suite, test, ok, equal, notEqual, arrayEqual, rejects, makeCanvas, getPageCanvas } from './harness'; +import { ImageAsset } from '@nativescript/canvas'; +import { suite, test, ok, equal, notEqual, arrayEqual, rejects, makeCanvas, getPageCanvas, make2D, pixelAt } from './harness'; declare const navigator: any; @@ -122,6 +123,32 @@ export function registerWebGPUSpec() { equal(texture.height, (canvas as any).height, 'height'); ok(texture.createView(), 'createView'); }); + + test('toDataURL reads back the current frame', async () => { + const { device } = await getDevice(); + const canvas = surfaceCanvas() as any; + const ctx = canvas.getContext('webgpu') as any; + ctx.configure({ device, format: navigator.gpu.getPreferredCanvasFormat(), alphaMode: 'opaque' }); + const encoder = device.createCommandEncoder(); + const pass = encoder.beginRenderPass({ + colorAttachments: [{ view: ctx.getCurrentTexture().createView(), clearValue: { r: 0, g: 0, b: 1, a: 1 }, loadOp: 'clear', storeOp: 'store' }], + }); + pass.end(); + device.queue.submit([encoder.finish()]); + + const url: string = canvas.toDataURL('image/png'); + const comma = url.indexOf(','); + ok(url.startsWith('data:image/png') && comma > 0 && comma < url.length - 1, `not a png: ${url.substring(0, 32)}`); + const [, buffer] = (global as any).CanvasModule.__base64Decode(url.substring(comma + 1)); + const asset = new ImageAsset(); + ok(asset.loadFromEncodedBytesSync(new Uint8Array(buffer)), 'the data URL did not decode'); + equal(asset.width, canvas.width, 'width'); + equal(asset.height, canvas.height, 'height'); + const { ctx: c2d } = make2D(canvas.width, canvas.height); + c2d.drawImage(asset, 0, 0); + const [r, g, b, a] = pixelAt(c2d, canvas.width >> 1, canvas.height >> 1); + ok(r < 8 && g < 8 && b > 247 && a > 247, `expected blue, got [${r}, ${g}, ${b}, ${a}]`); + }); }); suite('webgpu.device', () => {