[WebGPU] Add instrumented tests for GPUHardwareBuffer & Sync Bug: b/518640970 Change-Id: Ie391c271033d81f951ac702b1f088cb3e4bcc4ae Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/312255 Commit-Queue: Mridul Goyal <mridulgoyal@google.com> Reviewed-by: Alex Benton <bentonian@google.com> Reviewed-by: Tarun Saini <sainitarun@google.com>
diff --git a/tools/android/webgpu/src/androidTest/java/androidx/webgpu/GPUHardwareBufferTest.kt b/tools/android/webgpu/src/androidTest/java/androidx/webgpu/GPUHardwareBufferTest.kt new file mode 100644 index 0000000..bd03d02 --- /dev/null +++ b/tools/android/webgpu/src/androidTest/java/androidx/webgpu/GPUHardwareBufferTest.kt
@@ -0,0 +1,833 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.webgpu + +import android.hardware.HardwareBuffer +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.webgpu.helper.GPUAndroidHardwareBufferUtil +import androidx.webgpu.helper.WebGpu +import androidx.webgpu.helper.createWebGpu +import androidx.webgpu.GPU.createInstance +import androidx.webgpu.helper.initLibrary +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import java.util.concurrent.Executor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Assume +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.Executors + +@RunWith(AndroidJUnit4::class) +@MediumTest +class GPUHardwareBufferTest { + + companion object { + private const val FENCE_TIMEOUT_MS = 5000L + } + + private val dispatcher: CoroutineDispatcher = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "Test-WebGPU-Thread") + }.asCoroutineDispatcher() + private val testScope = CoroutineScope(dispatcher) + + private lateinit var webGpu: WebGpu + private lateinit var device: GPUDevice + + @get:Rule + val apiSkipRule = ApiLevelSkipRule() + + @Before + fun setup() = runBlocking { + // 1. Check features BEFORE requesting the device + initLibrary() + val instance = createInstance( + GPUInstanceDescriptor().apply { + dawnTogglesDescriptor = GPUDawnTogglesDescriptor( + enabledToggles = arrayOf("allow_unsafe_apis") // Required to enable experimental SharedTextureMemoryAHardwareBuffer features + ) + } + ) + val adapter = instance.requestAdapter() + val hasRequiredFeatures = with(adapter) { + hasFeature(FeatureName.SharedTextureMemoryAHardwareBuffer) && + hasFeature(FeatureName.SharedFenceSyncFD) && + hasFeature(FeatureName.YCbCrVulkanSamplers) && + hasFeature(FeatureName.OpaqueYCbCrAndroidForExternalTexture) + } + adapter.close() + instance.close() + + // 2. Skip gracefully if features are missing + Assume.assumeTrue( + "Adapter does not support required features for hardware buffer tests", + hasRequiredFeatures + ) + + // 3. Now it is safe to create the device with these features + webGpu = createWebGpu( + dispatcher = dispatcher, + deviceDescriptor = GPUDeviceDescriptor( + deviceLostCallback = DeviceLostCallback { _, _, _ -> }, + deviceLostCallbackExecutor = Executor(Runnable::run), + uncapturedErrorCallback = UncapturedErrorCallback { _, _, _ -> }, + uncapturedErrorCallbackExecutor = Executor(Runnable::run), + dawnTogglesDescriptor = GPUDawnTogglesDescriptor( + enabledToggles = arrayOf("allow_unsafe_apis") + ), + requiredFeatures = intArrayOf( + FeatureName.SharedTextureMemoryAHardwareBuffer, + FeatureName.SharedFenceSyncFD, + FeatureName.YCbCrVulkanSamplers, + FeatureName.OpaqueYCbCrAndroidForExternalTexture + ) + ) + ) + testScope.launch { + webGpu.processEventsLoop() + } + device = webGpu.device + } + + @After + fun teardown() { + if (::device.isInitialized) { + runCatching { device.destroy() } + } + if (::webGpu.isInitialized) { + runCatching { webGpu.close() } + } + } + + private fun createHardwareBuffer( + width: Int, height: Int, + format: Int = HardwareBuffer.RGBA_8888, + usage: Long = HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE + ): HardwareBuffer { + return HardwareBuffer.create(width, height, format, 1, usage) + } + + private data class PixelColor(val r: Int, val g: Int, val b: Int, val a: Int) + + private fun getCenterPixelColor(hardwareBuffer: HardwareBuffer): PixelColor { + val hardwareBitmap = android.graphics.Bitmap.wrapHardwareBuffer( + hardwareBuffer, + android.graphics.ColorSpace.get(android.graphics.ColorSpace.Named.SRGB) + ) + requireNotNull(hardwareBitmap) { "Hardware bitmap must not be null" } + + try { + val softwareBitmap = hardwareBitmap.copy(android.graphics.Bitmap.Config.ARGB_8888, false) + requireNotNull(softwareBitmap) { "Software bitmap must not be null" } + + try { + val centerPixel = softwareBitmap.getPixel(softwareBitmap.width / 2, softwareBitmap.height / 2) + return PixelColor( + r = android.graphics.Color.red(centerPixel), + g = android.graphics.Color.green(centerPixel), + b = android.graphics.Color.blue(centerPixel), + a = android.graphics.Color.alpha(centerPixel) + ) + } finally { + softwareBitmap.recycle() + } + } finally { + hardwareBitmap.recycle() + } + } + + private fun assertPixelColor( + hardwareBuffer: HardwareBuffer, + expectedRed: Int, expectedGreen: Int, expectedBlue: Int, + expectedAlpha: Int = 255 + ) { + val color = getCenterPixelColor(hardwareBuffer) + + assertEquals(expectedAlpha, color.a) + assertEquals(expectedRed, color.r) + assertEquals(expectedGreen, color.g) + assertEquals(expectedBlue, color.b) + } + + private fun verifyColorSpaceConversion( + pipeline: GPURenderPipeline, sampler: GPUSampler, + srcBuffer: HardwareBuffer, dstBuffer: HardwareBuffer, dstWrapper: GPUHardwareBufferTexture, + width: Int, height: Int, + primary: Int, transfer: Int, range: Int, matrix: Int, + ) { + val extDescriptor = createExternalTextureDescriptor( + cropWidth = width, cropHeight = height, + primary = primary, transfer = transfer, range = range, matrix = matrix + ) + + val srcExtWrapper = GPUAndroidHardwareBufferUtil.createExternalTexture( + device, + srcBuffer, + extDescriptor + ) + + assertNotNull(srcExtWrapper) + + val bindGroup = device.createBindGroup( + GPUBindGroupDescriptor( + layout = pipeline.getBindGroupLayout(0), + entries = arrayOf( + GPUBindGroupEntry(binding = 0, sampler = sampler), + GPUBindGroupEntry( + binding = 1, + externalTextureBindingEntry = GPUExternalTextureBindingEntry(srcExtWrapper.externalTexture) + ) + ) + ) + ) + + // Begin access on both wrappers + srcExtWrapper.beginAccess(null) + dstWrapper.beginAccess(null) + + val view = dstWrapper.texture.createView() + val encoder = device.createCommandEncoder() + val pass = encoder.beginRenderPass( + GPURenderPassDescriptor( + colorAttachments = arrayOf( + GPURenderPassColorAttachment( + view = view, + loadOp = LoadOp.Clear, + storeOp = StoreOp.Store, + clearValue = GPUColor(0.0, 0.0, 0.0, 1.0) + ) + ) + ) + ) + pass.setPipeline(pipeline) + pass.setBindGroup(0, bindGroup) + pass.draw(3) + pass.end() + + device.queue.submit(arrayOf(encoder.finish())) + webGpu.instance.processEvents() + + val fenceSrc = requireNotNull(srcExtWrapper.endAccess()) { "Expected a valid fence" } + assertTrue("GPU execution did not complete in time", fenceSrc.await(FENCE_TIMEOUT_MS)) + fenceSrc.close() + + val fenceDst = requireNotNull(dstWrapper.endAccess()) { "Expected a valid fence" } + assertTrue("GPU execution did not complete in time", fenceDst.await(FENCE_TIMEOUT_MS)) + fenceDst.close() + + // 4. Verify transformed color components + val color = getCenterPixelColor(dstBuffer) + + // Assert color conversion preserves primarily Red format + assertEquals(255, color.a) + assertTrue("Expected Red component to be high (got ${color.r})", color.r > 200) + assertTrue("Expected Green component to be low (got ${color.g})", color.g < 50) + assertTrue("Expected Blue component to be low (got ${color.b})", color.b < 50) + + view.close() + bindGroup.close() + srcExtWrapper.close() + } + + // ========================================================================= + // 1. MEMORY SAFETY & OOM TESTS + // ========================================================================= + + /** + * Verifies prolonged loop of texture creation/destruction does not leak memory. + */ + @Test + @MediumTest + @ApiRequirement(minApi = 29) + fun testImportHardwareBufferLoop_noLeaks() { + runBlocking { + webGpu.execute { + for (i in 0 until 50) { + val (rgbBuffer, wrapper) = createWrappedHardwareBuffer( + 128, 128, + textureUsage = TextureUsage.TextureBinding + ) + + wrapper.beginAccess(null) + val fence = wrapper.endAccess() + fence?.close() + + wrapper.close() + rgbBuffer.close() + } + webGpu.instance.processEvents() + } + } + } + + /** + * Verifies WebGPU OOM error scopes capture errors gracefully. + */ + @Test + @MediumTest + @ApiRequirement(minApi = 29) + fun testWebGpuOOMScope_handlesErrorGracefully() { + runBlocking { + webGpu.execute { + device.pushErrorScope(ErrorFilter.Validation) + + // Attempt to allocate an absolutely massive texture to trigger validation error + val descriptor = GPUTextureDescriptor( + size = GPUExtent3D(width = 1000000, height = 1000000, depthOrArrayLayers = 1), + format = TextureFormat.RGBA8Unorm, + usage = TextureUsage.None // Invalid usage + ) + val unusedTexture = device.createTexture(descriptor) + + // Popping the error scope should throw a ValidationException due to validation failure + assertThrows(ValidationException::class.java) { + runBlocking { device.popErrorScope() } + } + } + } + } + + // ========================================================================= + // 2. RGB & YUV EXTERNAL TEXTURE IMPORT LIFE-CYCLE TESTS + // ========================================================================= + + /** + * Verifies RGB HardwareBuffers can be wrapped as a standard Dawn GPUTexture. + */ + @Test + @MediumTest + @ApiRequirement(minApi = 29) + fun testRGBHardwareBuffer_asTexture() { + runBlocking { + webGpu.execute { + val (rgbBuffer, wrapper) = createWrappedHardwareBuffer( + textureUsage = TextureUsage.TextureBinding + ) + + assertNotNull(wrapper) + assertNotNull(wrapper.texture) + + wrapper.beginAccess(null) + val fence = wrapper.endAccess() + fence?.close() + + wrapper.close() + rgbBuffer.close() + } + } + } + + /** + * Verifies RGB HardwareBuffers can be wrapped as a WebGPU GPUExternalTexture. + */ + @Test + @MediumTest + @ApiRequirement(minApi = 29) + fun testRGBHardwareBuffer_asExternalTexture() { + runBlocking { + webGpu.execute { + val rgbBuffer = createHardwareBuffer(64, 64) + + val extDescriptor = createExternalTextureDescriptor() + + validateExternalTextureImport(rgbBuffer, extDescriptor) + } + } + } + + /** + * Verifies YUV HardwareBuffers can be wrapped as a WebGPU GPUExternalTexture. + */ + @Test + @MediumTest + @ApiRequirement(minApi = 30) + fun testYUVHardwareBuffer_asExternalTexture() { + runBlocking { + webGpu.execute { + val yuvBuffer = createHardwareBuffer(64, 64, format = HardwareBuffer.YCBCR_420_888) + + val extDescriptor = createExternalTextureDescriptor() + + validateExternalTextureImport(yuvBuffer, extDescriptor) + } + } + } + + // ========================================================================= + // 3. RACE CONDITIONS & CONCURRENT ACCESS + // ========================================================================= + + private fun createAndAccessTexture() { + val rgbBuffer = createHardwareBuffer(32, 32) + val wrapper = GPUAndroidHardwareBufferUtil.createTexture( + device, + rgbBuffer, + usage = TextureUsage.TextureBinding + ) + wrapper.beginAccess(null) + val fence = wrapper.endAccess() + fence?.close() + wrapper.close() + rgbBuffer.close() + } + + /** + * Verifies concurrent texture access across threads avoids race conditions. + */ + @Test + @MediumTest + @ApiRequirement(minApi = 29) + fun testConcurrentAccess_raceCondition() { + runBlocking(Dispatchers.IO) { + val numThreads = 2 + val barrier = java.util.concurrent.CyclicBarrier(numThreads) + + val jobs = List(numThreads) { + launch { + for (i in 0 until 20) { + createAndAccessTexture() + // Gate after every single iteration to guarantee threads strictly interleave + barrier.await() + } + } + } + + jobs.joinAll() + } + } + + /** + * Verifies CPU-to-GPU zero-copy memory visibility via writeTexture. + */ + @Test + @MediumTest + @ApiRequirement(minApi = 29) + fun testZeroCopyPipeline_writeAndVerify() { + runBlocking { + webGpu.execute { + val width = 64 + val height = 64 + val (rgbBuffer, wrapper) = createWrappedHardwareBuffer( + width, height, + textureUsage = TextureUsage.TextureBinding or TextureUsage.CopyDst or TextureUsage.CopySrc + ) + + assertNotNull(wrapper) + assertNotNull(wrapper.texture) + + // Write green pixels to the imported WebGPU texture + populateTextureWithSolidColor(wrapper, width, height, r = 0, g = 255.toByte(), b = 0, a = 255.toByte()) + + assertPixelColor(rgbBuffer, 0, 255, 0) + wrapper.close() + rgbBuffer.close() + } + } + } + + /** + * Verifies GPU-to-CPU zero-copy memory visibility after rendering. + */ + @Test + @MediumTest + @ApiRequirement(minApi = 29) + fun testZeroCopyPipeline_gpuRenderWriteAndVerify() { + runBlocking { + webGpu.execute { + val width = 64 + val height = 64 + + val (rgbBuffer, wrapper) = createWrappedHardwareBuffer( + width, height, + textureUsage = TextureUsage.RenderAttachment or TextureUsage.CopySrc + ) + + assertNotNull(wrapper) + assertNotNull(wrapper.texture) + + // Shader outputs solid red color (R=255, G=0, B=0, A=255) + val shaderModule = device.createShaderModule( + GPUShaderModuleDescriptor( + shaderSourceWGSL = GPUShaderSourceWGSL( + code = """ + @vertex fn vertexMain(@builtin(vertex_index) i : u32) -> @builtin(position) vec4f { + const pos = array(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0)); + return vec4f(pos[i], 0.0, 1.0); + } + + @fragment fn fragmentMain() -> @location(0) vec4f { + return vec4f(1.0, 0.0, 0.0, 1.0); + } + """.trimIndent() + ) + ) + ) + + val pipeline = device.createRenderPipeline( + GPURenderPipelineDescriptor( + vertex = GPUVertexState( + module = shaderModule, + entryPoint = "vertexMain" + ), + fragment = GPUFragmentState( + module = shaderModule, + entryPoint = "fragmentMain", + targets = arrayOf(GPUColorTargetState(format = TextureFormat.RGBA8Unorm)) + ) + ) + ) + + wrapper.beginAccess(null) + + // Render directly into the imported HardwareBuffer texture view + val encoder = device.createCommandEncoder() + val pass = encoder.beginRenderPass( + GPURenderPassDescriptor( + colorAttachments = arrayOf( + GPURenderPassColorAttachment( + view = wrapper.texture.createView(), + loadOp = LoadOp.Clear, + storeOp = StoreOp.Store, + clearValue = GPUColor(0.0, 0.0, 0.0, 1.0) + ) + ) + ) + ) + pass.setPipeline(pipeline) + pass.draw(3) + pass.end() + + val commandBuffer = encoder.finish() + device.queue.submit(arrayOf(commandBuffer)) + + webGpu.instance.processEvents() + + val fence = requireNotNull(wrapper.endAccess()) { "Expected a valid fence" } + assertTrue("GPU execution did not complete in time", fence.await(FENCE_TIMEOUT_MS)) + fence.close() + + assertPixelColor(rgbBuffer, 255, 0, 0) + wrapper.close() + rgbBuffer.close() + } + } + } + + /** + * Verifies full CPU-to-GPU-to-CPU zero-copy sampling pipeline. + */ + @Test + @MediumTest + @ApiRequirement(minApi = 29) + fun testZeroCopyPipeline_gpuSamplingAndCopyVerify() { + runBlocking { + webGpu.execute { + val width = 64 + val height = 64 + + // Clear source texture with solid blue color (R=0, G=0, B=255, A=255) + val sourceTexture = device.createTexture( + GPUTextureDescriptor( + size = GPUExtent3D(width = width, height = height, depthOrArrayLayers = 1), + format = TextureFormat.RGBA8Unorm, + usage = TextureUsage.CopySrc or TextureUsage.RenderAttachment + ) + ) + + val clearEncoder = device.createCommandEncoder() + val clearPass = clearEncoder.beginRenderPass( + GPURenderPassDescriptor( + colorAttachments = arrayOf( + GPURenderPassColorAttachment( + view = sourceTexture.createView(), + loadOp = LoadOp.Clear, + storeOp = StoreOp.Store, + clearValue = GPUColor(0.0, 0.0, 1.0, 1.0) + ) + ) + ) + ) + clearPass.end() + device.queue.submit(arrayOf(clearEncoder.finish())) + + // Create imported HardwareBuffer texture + val (rgbBuffer, wrapper) = createWrappedHardwareBuffer( + width, height, + textureUsage = TextureUsage.CopyDst or TextureUsage.TextureBinding + ) + + wrapper.beginAccess(null) + + // Copy directly from source texture to zero-copy texture + val copyEncoder = device.createCommandEncoder() + copyEncoder.copyTextureToTexture( + source = GPUTexelCopyTextureInfo(texture = sourceTexture), + destination = GPUTexelCopyTextureInfo(texture = wrapper.texture), + copySize = GPUExtent3D(width = width, height = height) + ) + device.queue.submit(arrayOf(copyEncoder.finish())) + + webGpu.instance.processEvents() + + val fence = requireNotNull(wrapper.endAccess()) { "Expected a valid fence" } + assertTrue("GPU execution did not complete in time", fence.await(FENCE_TIMEOUT_MS)) + fence.close() + + assertPixelColor(rgbBuffer, 0, 0, 255) + wrapper.close() + rgbBuffer.close() + sourceTexture.destroy() + } + } + } + + /** + * Verifies YUV external texture descriptors are parsed correctly across the JNI boundary without error. + */ + @Test + @MediumTest + @ApiRequirement(minApi = 30) + fun testYUVExternalTexture_descriptorTransform_validatesWithoutError() { + runBlocking { + webGpu.execute { + val yuvBuffer = createHardwareBuffer(128, 128, format = HardwareBuffer.YCBCR_420_888) + + val extDescriptor = createExternalTextureDescriptor( + cropOriginX = 16, cropOriginY = 16, cropWidth = 96, cropHeight = 96, + mirrored = true, rotation = ExternalTextureRotation.Rotate180Degrees + ) + + validateExternalTextureImport(yuvBuffer, extDescriptor) + } + } + } + + /** + * Verifies RGB external texture descriptors are parsed correctly across the JNI boundary without error. + */ + @Test + @MediumTest + @ApiRequirement(minApi = 29) + fun testRGBExternalTexture_descriptorTransform_validatesWithoutError() { + runBlocking { + webGpu.execute { + val width = 64 + val height = 64 + + val rgbBuffer = createHardwareBuffer(width, height) + + val extDescriptor = createExternalTextureDescriptor( + cropOriginX = 8, cropOriginY = 8, cropWidth = 48, cropHeight = 48, + mirrored = true, rotation = ExternalTextureRotation.Rotate90Degrees + ) + + validateExternalTextureImport(rgbBuffer, extDescriptor) + } + } + } + + /** + * Verifies ColorSpace enum variants map correctly between Kotlin and Dawn C++. + */ + @Test + @MediumTest + @ApiRequirement(minApi = 29) + fun testColorSpaceEnumVariants() { + runBlocking { + webGpu.execute { + val width = 64 + val height = 64 + + // 1. Allocate source HardwareBuffer and populate it with solid Red + val (srcBuffer, srcWrapper) = createWrappedHardwareBuffer( + width, height, + textureUsage = TextureUsage.TextureBinding or TextureUsage.CopyDst or TextureUsage.CopySrc + ) + + populateTextureWithSolidColor(srcWrapper, width, height, r = 255.toByte(), g = 0, b = 0, a = 255.toByte()) + + // 2. Allocate destination HardwareBuffer (render target) + val (dstBuffer, dstWrapper) = createWrappedHardwareBuffer( + width, height, + textureUsage = TextureUsage.RenderAttachment or TextureUsage.CopySrc + ) + + val (pipeline, sampler) = createExternalTextureSamplingPipeline() + + val primariesToTest = intArrayOf( + ColorSpacePrimariesDawn.Rec709, + ColorSpacePrimariesDawn.Rec601 + ) + + val transfersToTest = intArrayOf( + ColorSpaceTransferDawn.SRGB, + ColorSpaceTransferDawn.Identity + ) + + val matricesToTest = intArrayOf( + ColorSpaceYCbCrMatrixDawn.Rec709, + ColorSpaceYCbCrMatrixDawn.Rec601 + ) + + val rangesToTest = intArrayOf( + ColorSpaceYCbCrRangeDawn.Narrow, + ColorSpaceYCbCrRangeDawn.Full + ) + + // 3. Permute, draw, and verify output color for all combinations on Main thread + runBlocking(Dispatchers.Main) { + for (primary in primariesToTest) { + for (transfer in transfersToTest) { + val matrix = matricesToTest[primary % matricesToTest.size] + val range = rangesToTest[transfer % rangesToTest.size] + + verifyColorSpaceConversion( + pipeline, sampler, srcBuffer, dstBuffer, dstWrapper, width, height, primary, transfer, range, matrix + ) + } + } + } + + // 5. Clean up shared wrappers + srcWrapper.close() + dstWrapper.close() + srcBuffer.close() + dstBuffer.close() + } + } + } + + private fun createWrappedHardwareBuffer( + width: Int = 64, height: Int = 64, textureUsage: Int + ): Pair<HardwareBuffer, GPUHardwareBufferTexture> { + val buffer = createHardwareBuffer( + width, height, + usage = HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or HardwareBuffer.USAGE_GPU_COLOR_OUTPUT + ) + val wrapper = GPUAndroidHardwareBufferUtil.createTexture(device, buffer, usage = textureUsage) + return Pair(buffer, wrapper) + } + + private fun populateTextureWithSolidColor( + wrapper: GPUHardwareBufferTexture, + width: Int, height: Int, + r: Byte, g: Byte, b: Byte, a: Byte + ) { + val byteBuffer = java.nio.ByteBuffer.allocateDirect(width * height * 4).apply { + order(java.nio.ByteOrder.nativeOrder()) + for (i in 0 until width * height) { + put(r) + put(g) + put(b) + put(a) + } + rewind() + } + + wrapper.beginAccess(null) + device.queue.writeTexture( + dataLayout = GPUTexelCopyBufferLayout(bytesPerRow = width * 4, rowsPerImage = height), + data = byteBuffer, + destination = GPUTexelCopyTextureInfo(texture = wrapper.texture), + writeSize = GPUExtent3D(width = width, height = height) + ) + webGpu.instance.processEvents() + val fence = requireNotNull(wrapper.endAccess()) { "Expected a valid fence" } + assertTrue("GPU execution did not complete in time", fence.await(FENCE_TIMEOUT_MS)) + fence.close() + } + + private fun createExternalTextureDescriptor( + cropWidth: Int = 64, cropHeight: Int = 64, + cropOriginX: Int = 0, cropOriginY: Int = 0, + mirrored: Boolean = false, + rotation: Int = ExternalTextureRotation.Rotate0Degrees, + primary: Int = ColorSpacePrimariesDawn.Rec709, + transfer: Int = ColorSpaceTransferDawn.SRGB, + range: Int = ColorSpaceYCbCrRangeDawn.Narrow, + matrix: Int = ColorSpaceYCbCrMatrixDawn.Rec709 + ): GPUExternalTextureDescriptor { + return GPUExternalTextureDescriptor( + cropOrigin = GPUOrigin2D(cropOriginX, cropOriginY), + cropSize = GPUExtent2D(cropWidth, cropHeight), + mirrored = mirrored, + rotation = rotation, + srcColorSpace = GPUColorSpaceDawn( + primaries = primary, transfer = transfer, yCbCrRange = range, yCbCrMatrix = matrix + ), + dstColorSpace = PredefinedColorSpace.SRGB + ) + } + + private fun validateExternalTextureImport(buffer: HardwareBuffer, extDescriptor: GPUExternalTextureDescriptor) { + val wrapper = GPUAndroidHardwareBufferUtil.createExternalTexture(device, buffer, extDescriptor) + assertNotNull(wrapper) + assertNotNull(wrapper.externalTexture) + + wrapper.beginAccess(null) + val fence = wrapper.endAccess() + fence?.close() + + wrapper.close() + buffer.close() + } + + private fun createExternalTextureSamplingPipeline(): Pair<GPURenderPipeline, GPUSampler> { + val sampler = device.createSampler(GPUSamplerDescriptor()) + val shaderModule = device.createShaderModule( + GPUShaderModuleDescriptor( + shaderSourceWGSL = GPUShaderSourceWGSL( + code = """ + @vertex fn vertexMain(@builtin(vertex_index) i : u32) -> @builtin(position) vec4f { + const pos = array(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0)); + return vec4f(pos[i], 0.0, 1.0); + } + @group(0) @binding(0) var mySampler: sampler; + @group(0) @binding(1) var myExternalTexture: texture_external; + @fragment fn fragmentMain(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f { + return textureSampleBaseClampToEdge(myExternalTexture, mySampler, vec2f(0.5, 0.5)); + } + """.trimIndent() + ) + ) + ) + + val pipeline = device.createRenderPipeline( + GPURenderPipelineDescriptor( + vertex = GPUVertexState(module = shaderModule, entryPoint = "vertexMain"), + fragment = GPUFragmentState( + module = shaderModule, + entryPoint = "fragmentMain", + targets = arrayOf(GPUColorTargetState(format = TextureFormat.RGBA8Unorm)) + ) + ) + ) + return Pair(pipeline, sampler) + } +}
diff --git a/tools/android/webgpu/src/androidTest/java/androidx/webgpu/GPUSyncFenceTest.kt b/tools/android/webgpu/src/androidTest/java/androidx/webgpu/GPUSyncFenceTest.kt new file mode 100644 index 0000000..a00108a --- /dev/null +++ b/tools/android/webgpu/src/androidTest/java/androidx/webgpu/GPUSyncFenceTest.kt
@@ -0,0 +1,260 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.webgpu + +import android.hardware.HardwareBuffer +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.webgpu.helper.GPUAndroidHardwareBufferUtil +import androidx.webgpu.helper.WebGpu +import androidx.webgpu.helper.createWebGpu +import androidx.webgpu.GPU.createInstance +import androidx.webgpu.helper.initLibrary +import androidx.webgpu.helper.toSyncFence +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assume +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class GPUSyncFenceTest { + + companion object { + private const val FENCE_TIMEOUT_MS = 5000L + } + + private val dispatcher: CoroutineDispatcher = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "Test-WebGPU-Thread") + }.asCoroutineDispatcher() + private val testScope = CoroutineScope(dispatcher) + + private lateinit var webGpu: WebGpu + private lateinit var device: GPUDevice + + @get:Rule + val apiSkipRule = ApiLevelSkipRule() + + @Before + fun setup() = runBlocking { + // 1. Check features BEFORE requesting the device + initLibrary() + val instance = createInstance( + GPUInstanceDescriptor().apply { + dawnTogglesDescriptor = GPUDawnTogglesDescriptor( + enabledToggles = arrayOf("allow_unsafe_apis") // Required to enable experimental SharedTextureMemoryAHardwareBuffer features + ) + } + ) + val adapter = instance.requestAdapter() + val hasRequiredFeatures = with(adapter) { + hasFeature(FeatureName.SharedTextureMemoryAHardwareBuffer) && + hasFeature(FeatureName.SharedFenceSyncFD) + } + adapter.close() + instance.close() + + // 2. Skip gracefully if features are missing + Assume.assumeTrue( + "Adapter does not support required features for hardware buffer tests", + hasRequiredFeatures + ) + + // 3. Now it is safe to create the device with these features + webGpu = createWebGpu( + dispatcher = dispatcher, + deviceDescriptor = GPUDeviceDescriptor( + deviceLostCallback = DeviceLostCallback { _, _, _ -> }, + deviceLostCallbackExecutor = Executor(Runnable::run), + uncapturedErrorCallback = UncapturedErrorCallback { _, _, _ -> }, + uncapturedErrorCallbackExecutor = Executor(Runnable::run), + dawnTogglesDescriptor = GPUDawnTogglesDescriptor( + enabledToggles = arrayOf("allow_unsafe_apis") + ), + requiredFeatures = intArrayOf( + FeatureName.SharedTextureMemoryAHardwareBuffer, + FeatureName.SharedFenceSyncFD + ) + ) + ) + testScope.launch { + webGpu.processEventsLoop() + } + device = webGpu.device + } + + @After + fun teardown() { + if (::device.isInitialized) { + runCatching { device.destroy() } + } + if (::webGpu.isInitialized) { + runCatching { webGpu.close() } + } + } + + private fun setupPipelineAndDraw(wrapper: GPUHardwareBufferTexture): GPURenderPipeline { + val shaderModule = device.createShaderModule( + GPUShaderModuleDescriptor( + shaderSourceWGSL = GPUShaderSourceWGSL( + code = """ + @vertex fn vertexMain(@builtin(vertex_index) i : u32) -> @builtin(position) vec4f { + const pos = array(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0)); + return vec4f(pos[i], 0.0, 1.0); + } + + @fragment fn fragmentMain() -> @location(0) vec4f { + return vec4f(1.0, 0.0, 0.0, 1.0); + } + """.trimIndent() + ) + ) + ) + + val pipeline = device.createRenderPipeline( + GPURenderPipelineDescriptor( + vertex = GPUVertexState( + module = shaderModule, + entryPoint = "vertexMain" + ), + fragment = GPUFragmentState( + module = shaderModule, + entryPoint = "fragmentMain", + targets = arrayOf(GPUColorTargetState(format = TextureFormat.RGBA8Unorm)) + ) + ) + ) + + drawToTexture(wrapper, pipeline) + // Process events to flush the queue to Vulkan before extracting the Sync FD + webGpu.instance.processEvents() + + return pipeline + } + + private fun drawToTexture( + wrapper: GPUHardwareBufferTexture, + pipeline: GPURenderPipeline, + fences: Array<GPUSyncFence>? = null + ) { + wrapper.beginAccess(fences) + val encoder = device.createCommandEncoder() + val pass = encoder.beginRenderPass( + GPURenderPassDescriptor( + colorAttachments = arrayOf( + GPURenderPassColorAttachment( + view = wrapper.texture.createView(), + loadOp = LoadOp.Clear, + storeOp = StoreOp.Store, + clearValue = GPUColor(0.0, 0.0, 0.0, 1.0) + ) + ) + ) + ) + pass.setPipeline(pipeline) + pass.draw(3) + pass.end() + + device.queue.submit(arrayOf(encoder.finish())) + } + + /** + * Verifies exporting and awaiting a GPUSyncFence. + */ + @Test + @MediumTest + @ApiRequirement(minApi = 29) + fun testSyncFence_lifecycleAndAwaiting() = runBlocking { + webGpu.execute { + val (rgbBuffer, wrapper) = createTestTextureWrapper() + + val pipeline = setupPipelineAndDraw(wrapper) + val fence = requireNotNull(wrapper.endAccess()) { "Sync fence should not be null" } + + try { + // Await signaling of the sync fence + val signaled = fence.await(FENCE_TIMEOUT_MS) + assertTrue(signaled) + + // Re-access the texture. + drawToTexture(wrapper, pipeline, arrayOf(fence)) + fence.close() + + webGpu.instance.processEvents() + + val nextFence = requireNotNull(wrapper.endAccess()) { "Sync fence should not be null" } + assertTrue(nextFence.await(FENCE_TIMEOUT_MS)) + nextFence.close() + } finally { + wrapper.close() + rgbBuffer.close() + } + } + } + + /** + * Verifies importing a GPUSyncFence from an Android SyncFence. + */ + @Test + @MediumTest + @ApiRequirement(minApi = 33) + fun testSyncFence_fromPlatformSyncFence() = runBlocking { + webGpu.execute { + val (rgbBuffer, wrapper) = createTestTextureWrapper() + + setupPipelineAndDraw(wrapper) + val fence = requireNotNull(wrapper.endAccess()) { "Sync fence should not be null" } + + try { + // Convert to platform SyncFence using extension method + val platformSyncFence = requireNotNull(fence.toSyncFence()) { "Platform SyncFence should not be null" } + + // Await signaling of the platform SyncFence + val signaled = platformSyncFence.await(java.time.Duration.ofMillis(FENCE_TIMEOUT_MS)) + assertTrue(signaled) + + platformSyncFence.close() + } finally { + wrapper.close() + rgbBuffer.close() + } + } + } + + private fun createTestTextureWrapper(): Pair<HardwareBuffer, GPUHardwareBufferTexture> { + val rgbBuffer = HardwareBuffer.create( + 64, 64, HardwareBuffer.RGBA_8888, 1, + HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or HardwareBuffer.USAGE_GPU_COLOR_OUTPUT + ) + val wrapper = GPUAndroidHardwareBufferUtil.createTexture( + device, rgbBuffer, + usage = TextureUsage.RenderAttachment or TextureUsage.CopySrc + ) + return Pair(rgbBuffer, wrapper) + } +}