| /* | |
| * Copyright 2025 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 androidx.test.filters.MediumTest | |
| import androidx.test.filters.SmallTest | |
| import androidx.webgpu.helper.WebGpu | |
| import androidx.webgpu.helper.createWebGpu | |
| import java.nio.ByteBuffer | |
| import java.nio.ByteOrder | |
| import kotlinx.coroutines.CoroutineDispatcher | |
| import java.util.concurrent.Executors | |
| import kotlinx.coroutines.CoroutineScope | |
| import kotlinx.coroutines.ExecutorCoroutineDispatcher | |
| import kotlinx.coroutines.asCoroutineDispatcher | |
| import kotlinx.coroutines.cancel | |
| import kotlinx.coroutines.launch | |
| import kotlinx.coroutines.runBlocking | |
| import org.junit.After | |
| import org.junit.Assert.assertEquals | |
| import org.junit.Before | |
| import org.junit.Test | |
| @Suppress("UNUSED_VARIABLE") | |
| @SmallTest | |
| class RenderPassEncoderTest { | |
| private val dispatcher: CoroutineDispatcher = Executors.newSingleThreadExecutor { runnable -> | |
| Thread(runnable, "Test-WebGPU-Thread") | |
| }.asCoroutineDispatcher() | |
| private val testScope = CoroutineScope(dispatcher) | |
| private var webGpu: WebGpu? = null | |
| private lateinit var device: GPUDevice | |
| private lateinit var defaultColorPipeline: GPURenderPipeline | |
| private lateinit var renderTarget: GPUTexture | |
| private lateinit var renderTargetDepth: GPUTexture | |
| private lateinit var renderTargetView: GPUTextureView | |
| private lateinit var renderTargetDepthView: GPUTextureView | |
| private lateinit var shaderModule: GPUShaderModule | |
| private lateinit var layout: GPUPipelineLayout | |
| private val kDepthFormat = TextureFormat.Depth24Plus | |
| @Before | |
| fun setup(): Unit = runBlocking { | |
| val gpu = createWebGpu(dispatcher) | |
| webGpu = gpu | |
| device = gpu.device | |
| testScope.launch { | |
| gpu.processEventsLoop() | |
| } | |
| gpu.execute { | |
| renderTarget = device.createTexture( | |
| GPUTextureDescriptor( | |
| size = GPUExtent3D(1, 1, 1), | |
| format = TextureFormat.RGBA8Unorm, | |
| usage = TextureUsage.RenderAttachment | |
| ) | |
| ) | |
| renderTargetView = renderTarget.createView() | |
| renderTargetDepth = device.createTexture( | |
| GPUTextureDescriptor( | |
| size = GPUExtent3D(1, 1, 1), | |
| format = kDepthFormat, | |
| usage = TextureUsage.RenderAttachment | |
| ) | |
| ) | |
| renderTargetDepthView = renderTargetDepth.createView() | |
| shaderModule = device.createShaderModule( | |
| GPUShaderModuleDescriptor( | |
| shaderSourceWGSL = GPUShaderSourceWGSL( | |
| """@vertex fn vsMain() -> @builtin(position) vec4<f32> { | |
| return vec4<f32>(0.0, 0.0, 0.0, 1.0); | |
| } | |
| @fragment fn fsMain() -> @location(0) vec4<f32> { | |
| return vec4<f32>(1.0, 0.0, 0.0, 1.0); | |
| } | |
| """.trimIndent() | |
| ) | |
| ) | |
| ) | |
| layout = device.createPipelineLayout(GPUPipelineLayoutDescriptor()) | |
| defaultColorPipeline = device.createRenderPipeline( | |
| GPURenderPipelineDescriptor( | |
| layout = layout, | |
| vertex = GPUVertexState(module = shaderModule, entryPoint = "vsMain"), | |
| fragment = GPUFragmentState( | |
| module = shaderModule, | |
| entryPoint = "fsMain", | |
| targets = arrayOf( | |
| GPUColorTargetState(format = TextureFormat.RGBA8Unorm) | |
| ) | |
| ), | |
| primitive = GPUPrimitiveState(topology = PrimitiveTopology.TriangleList), | |
| ) | |
| ) | |
| } | |
| } | |
| @After | |
| fun teardown() { | |
| val gpu = webGpu | |
| if (::device.isInitialized && gpu != null) { | |
| runCatching { | |
| runBlocking { | |
| gpu.execute { device.destroy() } | |
| } | |
| } | |
| } | |
| gpu?.close() | |
| testScope.cancel() | |
| (dispatcher as? ExecutorCoroutineDispatcher)?.close() | |
| } | |
| /** | |
| * Helper function to begin a standard (color-only) render pass. | |
| */ | |
| private fun beginDefaultRenderPass(encoder: GPUCommandEncoder): GPURenderPassEncoder { | |
| return encoder.beginRenderPass( | |
| GPURenderPassDescriptor( | |
| colorAttachments = arrayOf( | |
| GPURenderPassColorAttachment( | |
| view = renderTargetView, | |
| loadOp = LoadOp.Clear, | |
| storeOp = StoreOp.Store, | |
| clearValue = GPUColor(0.0, 0.0, 0.0, 1.0) | |
| ) | |
| ) | |
| ) | |
| ) | |
| } | |
| /** | |
| * Helper to create a buffer for index data with 4-byte padding. | |
| */ | |
| private fun createIndexBuffer(indices: ShortArray): GPUBuffer { | |
| val dataSize = (indices.size * Short.SIZE_BYTES).toLong() | |
| val paddedSize = (dataSize + 3) and -4L // Aligns to 4 bytes. | |
| val buffer = device.createBuffer( | |
| GPUBufferDescriptor( | |
| size = paddedSize, | |
| usage = BufferUsage.Index or BufferUsage.CopyDst | |
| ) | |
| ) | |
| val data = ByteBuffer.allocateDirect(paddedSize.toInt()).order(ByteOrder.nativeOrder()) | |
| data.asShortBuffer().put(indices) | |
| device.queue.writeBuffer(buffer, 0, data) | |
| return buffer | |
| } | |
| /** | |
| * Helper to create a buffer for indirect draw calls. | |
| */ | |
| private fun createIndirectBuffer(data: IntArray): GPUBuffer { | |
| val byteBuffer = ByteBuffer.allocateDirect(data.size * Int.SIZE_BYTES) | |
| .order(ByteOrder.nativeOrder()) | |
| byteBuffer.asIntBuffer().put(data) | |
| val buffer = device.createBuffer( | |
| GPUBufferDescriptor( | |
| size = byteBuffer.capacity().toLong(), | |
| usage = BufferUsage.Indirect or BufferUsage.CopyDst | |
| ) | |
| ) | |
| device.queue.writeBuffer(buffer, 0, byteBuffer) | |
| return buffer | |
| } | |
| @Test | |
| fun testDebugMarker() { | |
| runBlocking { | |
| val unused = webGpu!!.execute { | |
| val encoder = device.createCommandEncoder() | |
| val passEncoder = beginDefaultRenderPass(encoder) | |
| passEncoder.insertDebugMarker("Drawing background") | |
| passEncoder.end() | |
| device.pushErrorScope(ErrorFilter.Validation) | |
| val unusedCommandBuffer = encoder.finish() | |
| val error = device.popErrorScope() | |
| assertEquals(ErrorType.NoError, error) | |
| } | |
| } | |
| } | |
| @Test | |
| fun testPopDebugGroupWithoutPushFails() { | |
| runBlocking { | |
| val unused = webGpu!!.execute { | |
| val encoder = device.createCommandEncoder() | |
| val passEncoder = beginDefaultRenderPass(encoder) | |
| passEncoder.popDebugGroup() // Invalid call. | |
| passEncoder.end() | |
| device.pushErrorScope(ErrorFilter.Validation) | |
| val unusedCommandBuffer = encoder.finish() | |
| val unusedAssert = assertThrowsSuspend(ValidationException::class.java) { | |
| val unusedPop = device.popErrorScope() | |
| } | |
| } | |
| } | |
| } | |
| @Test | |
| fun testDrawWithoutPipelineFails() { | |
| runBlocking { | |
| val unused = webGpu!!.execute { | |
| val encoder = device.createCommandEncoder() | |
| val passEncoder = beginDefaultRenderPass(encoder) | |
| passEncoder.draw(3) // Invalid: pipeline not set. | |
| passEncoder.end() | |
| device.pushErrorScope(ErrorFilter.Validation) | |
| val unusedCommandBuffer = encoder.finish() | |
| val unusedAssert = assertThrowsSuspend(ValidationException::class.java) { | |
| val unusedPop = device.popErrorScope() | |
| } | |
| } | |
| } | |
| } | |
| @Test | |
| fun testSetVertexBufferInvalidUsageFails() { | |
| runBlocking { | |
| val unused = webGpu!!.execute { | |
| val invalidBuffer = device.createBuffer( | |
| GPUBufferDescriptor(size = 16, usage = BufferUsage.CopyDst) | |
| ) | |
| val encoder = device.createCommandEncoder() | |
| val passEncoder = beginDefaultRenderPass(encoder) | |
| passEncoder.setVertexBuffer(0, invalidBuffer) // Invalid. | |
| passEncoder.end() | |
| device.pushErrorScope(ErrorFilter.Validation) | |
| val unusedCommandBuffer = encoder.finish() | |
| val unusedAssert = assertThrowsSuspend(ValidationException::class.java) { | |
| val unusedPop = device.popErrorScope() | |
| } | |
| invalidBuffer.destroy() | |
| } | |
| } | |
| } | |
| @Test | |
| fun testDrawIndexedWithoutIndexBufferFails() { | |
| runBlocking { | |
| val unused = webGpu!!.execute { | |
| val encoder = device.createCommandEncoder() | |
| val passEncoder = beginDefaultRenderPass(encoder) | |
| passEncoder.setPipeline(defaultColorPipeline) | |
| passEncoder.drawIndexed(3) // Invalid: index buffer not set. | |
| passEncoder.end() | |
| device.pushErrorScope(ErrorFilter.Validation) | |
| val unusedCommandBuffer = encoder.finish() | |
| val unusedAssert = assertThrowsSuspend(ValidationException::class.java) { | |
| val unusedPop = device.popErrorScope() | |
| } | |
| } | |
| } | |
| } | |
| @Test | |
| fun testDrawIndexedValidSucceeds() { | |
| runBlocking { | |
| val unused = webGpu!!.execute { | |
| val indexBuffer = createIndexBuffer(shortArrayOf(0, 1, 2)) | |
| val encoder = device.createCommandEncoder() | |
| val passEncoder = beginDefaultRenderPass(encoder) | |
| passEncoder.setPipeline(defaultColorPipeline) | |
| passEncoder.setIndexBuffer(indexBuffer, IndexFormat.Uint16) // Valid. | |
| passEncoder.drawIndexed(3) // Valid. | |
| passEncoder.end() | |
| device.pushErrorScope(ErrorFilter.Validation) | |
| val unusedCommandBuffer = encoder.finish() | |
| val errorType = device.popErrorScope() | |
| assertEquals(ErrorType.NoError, errorType) | |
| indexBuffer.destroy() | |
| } | |
| } | |
| } | |
| @Test | |
| fun testDrawIndirectInvalidBufferFails() { | |
| runBlocking { | |
| val unused = webGpu!!.execute { | |
| val invalidBuffer = device.createBuffer( | |
| GPUBufferDescriptor(size = 16, usage = BufferUsage.CopyDst) // 4 * Int. | |
| ) | |
| val encoder = device.createCommandEncoder() | |
| val passEncoder = beginDefaultRenderPass(encoder) | |
| passEncoder.setPipeline(defaultColorPipeline) | |
| passEncoder.drawIndirect(invalidBuffer, 0) // Invalid. | |
| passEncoder.end() | |
| device.pushErrorScope(ErrorFilter.Validation) | |
| val unusedCommandBuffer = encoder.finish() | |
| val unusedAssert = assertThrowsSuspend(ValidationException::class.java) { | |
| val unusedPop = device.popErrorScope() | |
| } | |
| invalidBuffer.destroy() | |
| } | |
| } | |
| } | |
| @Test | |
| fun testDrawIndexedIndirectWithoutIndexBufferFails() { | |
| runBlocking { | |
| val unused = webGpu!!.execute { | |
| val indirectBuffer = createIndirectBuffer(intArrayOf(3, 1, 0, 0, 0)) | |
| val encoder = device.createCommandEncoder() | |
| val passEncoder = beginDefaultRenderPass(encoder) | |
| passEncoder.setPipeline(defaultColorPipeline) | |
| passEncoder.drawIndexedIndirect(indirectBuffer, 0) // Invalid. | |
| passEncoder.end() | |
| device.pushErrorScope(ErrorFilter.Validation) | |
| val unusedCommandBuffer = encoder.finish() | |
| val unusedAssert = assertThrowsSuspend(ValidationException::class.java) { | |
| val unusedPop = device.popErrorScope() | |
| } | |
| indirectBuffer.destroy() | |
| } | |
| } | |
| } | |
| @Test | |
| @MediumTest | |
| fun testDrawIndexedIndirectValidSucceeds() { | |
| runBlocking { | |
| val unused = webGpu!!.execute { | |
| val indirectBuffer = createIndirectBuffer(intArrayOf(3, 1, 0, 0, 0)) | |
| val indexBuffer = createIndexBuffer(shortArrayOf(0, 1, 2)) | |
| val encoder = device.createCommandEncoder() | |
| val passEncoder = beginDefaultRenderPass(encoder) | |
| passEncoder.setPipeline(defaultColorPipeline) | |
| passEncoder.setIndexBuffer(indexBuffer, IndexFormat.Uint16) // Valid. | |
| passEncoder.drawIndexedIndirect(indirectBuffer, 0) // Valid. | |
| passEncoder.end() | |
| device.pushErrorScope(ErrorFilter.Validation) | |
| val unusedCommandBuffer = encoder.finish() | |
| val error = device.popErrorScope() | |
| assertEquals(ErrorType.NoError, error) | |
| indirectBuffer.destroy() | |
| indexBuffer.destroy() | |
| } | |
| } | |
| } | |
| @Test | |
| fun testOcclusionQueryValidSucceeds() { | |
| runBlocking { | |
| val unused = webGpu!!.execute { | |
| // This test needs its OWN depth-enabled pipeline. | |
| // We can't use the class-level 'defaultColorPipeline' because it's color-only. | |
| val depthPipeline = device.createRenderPipeline( | |
| GPURenderPipelineDescriptor( | |
| layout = layout, | |
| vertex = GPUVertexState(module = shaderModule, entryPoint = "vsMain"), | |
| fragment = GPUFragmentState( | |
| module = shaderModule, | |
| entryPoint = "fsMain", | |
| targets = arrayOf( | |
| GPUColorTargetState(format = TextureFormat.RGBA8Unorm) | |
| ) | |
| ), | |
| primitive = GPUPrimitiveState(topology = PrimitiveTopology.TriangleList), | |
| depthStencil = GPUDepthStencilState( | |
| format = kDepthFormat, | |
| depthWriteEnabled = OptionalBool.True, | |
| depthCompare = CompareFunction.Always | |
| ) | |
| ) | |
| ) | |
| val querySet = device.createQuerySet( | |
| GPUQuerySetDescriptor(type = QueryType.Occlusion, count = 1) | |
| ) | |
| val encoder = device.createCommandEncoder() | |
| val passEncoder = encoder.beginRenderPass( | |
| GPURenderPassDescriptor( | |
| colorAttachments = arrayOf( | |
| GPURenderPassColorAttachment( | |
| view = renderTargetView, | |
| loadOp = LoadOp.Clear, | |
| storeOp = StoreOp.Store, | |
| clearValue = GPUColor(0.0, 0.0, 0.0, 1.0) | |
| ) | |
| ), | |
| depthStencilAttachment = GPURenderPassDepthStencilAttachment( | |
| view = renderTargetDepthView, | |
| depthLoadOp = LoadOp.Clear, | |
| depthStoreOp = StoreOp.Store, | |
| depthClearValue = 1.0f | |
| ), | |
| occlusionQuerySet = querySet | |
| ) | |
| ) | |
| passEncoder.beginOcclusionQuery(0) | |
| passEncoder.setPipeline(depthPipeline) // Use the local depth-pipeline. | |
| passEncoder.draw(3) | |
| passEncoder.endOcclusionQuery() | |
| passEncoder.end() | |
| device.pushErrorScope(ErrorFilter.Validation) | |
| val unusedCommandBuffer = encoder.finish() | |
| val error = device.popErrorScope() | |
| assertEquals(ErrorType.NoError, error) | |
| querySet.destroy() | |
| depthPipeline.close() | |
| } | |
| } | |
| } | |
| @Test | |
| fun testSetPassPropertiesSucceeds() { | |
| runBlocking { | |
| val unused = webGpu!!.execute { | |
| val encoder = device.createCommandEncoder() | |
| val passEncoder = beginDefaultRenderPass(encoder) | |
| passEncoder.setViewport(0f, 0f, 1f, 1f, 0f, 1f) | |
| passEncoder.setScissorRect(0, 0, 1, 1) | |
| passEncoder.setBlendConstant(GPUColor(0.0, 0.0, 0.0, 0.0)) | |
| passEncoder.setStencilReference(0) | |
| passEncoder.end() | |
| device.pushErrorScope(ErrorFilter.Validation) | |
| val unusedCommandBuffer = encoder.finish() | |
| val error = device.popErrorScope() | |
| assertEquals(ErrorType.NoError, error) | |
| } | |
| } | |
| } | |
| @Test | |
| fun testEndCalledTwiceFails() { | |
| runBlocking { | |
| val unused = webGpu!!.execute { | |
| val encoder = device.createCommandEncoder() | |
| val passEncoder = beginDefaultRenderPass(encoder) | |
| passEncoder.end() // First call (valid). | |
| device.pushErrorScope(ErrorFilter.Validation) | |
| passEncoder.end() // Second call (invalid). | |
| val unusedAssert = assertThrowsSuspend(ValidationException::class.java) { | |
| val unusedPop = device.popErrorScope() | |
| } | |
| } | |
| } | |
| } | |
| @Test | |
| fun testExecuteBundlesSucceeds() { | |
| runBlocking { | |
| val unused = webGpu!!.execute { | |
| val bundleEncoder = device.createRenderBundleEncoder( | |
| GPURenderBundleEncoderDescriptor( | |
| colorFormats = intArrayOf(TextureFormat.RGBA8Unorm) | |
| ) | |
| ) | |
| bundleEncoder.setPipeline(defaultColorPipeline) // Use the color-only pipeline. | |
| bundleEncoder.draw(3) | |
| val bundle = bundleEncoder.finish() | |
| val encoder = device.createCommandEncoder() | |
| val passEncoder = beginDefaultRenderPass(encoder) // Use the color-only pass. | |
| passEncoder.executeBundles(arrayOf(bundle)) // Valid. | |
| passEncoder.end() | |
| device.pushErrorScope(ErrorFilter.Validation) | |
| val unusedCommandBuffer = encoder.finish() | |
| val error = device.popErrorScope() | |
| assertEquals(ErrorType.NoError, error) | |
| bundle.close() | |
| } | |
| } | |
| } | |
| } |