| /* | |
| * Copyright 2023 Rive | |
| */ | |
| #include "rive/renderer/vulkan/vkutil.hpp" | |
| #include "rive/rive_types.hpp" | |
| #include "rive/renderer/vulkan/vulkan_context.hpp" | |
| #include <vk_mem_alloc.h> | |
| namespace rive::gpu::vkutil | |
| { | |
| #define STR_CASE(result) \ | |
| case VK_##result: \ | |
| return #result | |
| const char* string_from_vk_result(VkResult result) | |
| { | |
| switch (result) | |
| { | |
| STR_CASE(SUCCESS); | |
| STR_CASE(NOT_READY); | |
| STR_CASE(TIMEOUT); | |
| STR_CASE(EVENT_SET); | |
| STR_CASE(EVENT_RESET); | |
| STR_CASE(INCOMPLETE); | |
| STR_CASE(ERROR_OUT_OF_HOST_MEMORY); | |
| STR_CASE(ERROR_OUT_OF_DEVICE_MEMORY); | |
| STR_CASE(ERROR_INITIALIZATION_FAILED); | |
| STR_CASE(ERROR_DEVICE_LOST); | |
| STR_CASE(ERROR_MEMORY_MAP_FAILED); | |
| STR_CASE(ERROR_LAYER_NOT_PRESENT); | |
| STR_CASE(ERROR_EXTENSION_NOT_PRESENT); | |
| STR_CASE(ERROR_FEATURE_NOT_PRESENT); | |
| STR_CASE(ERROR_INCOMPATIBLE_DRIVER); | |
| STR_CASE(ERROR_TOO_MANY_OBJECTS); | |
| STR_CASE(ERROR_FORMAT_NOT_SUPPORTED); | |
| STR_CASE(ERROR_SURFACE_LOST_KHR); | |
| STR_CASE(SUBOPTIMAL_KHR); | |
| STR_CASE(ERROR_OUT_OF_DATE_KHR); | |
| STR_CASE(ERROR_INCOMPATIBLE_DISPLAY_KHR); | |
| STR_CASE(ERROR_NATIVE_WINDOW_IN_USE_KHR); | |
| STR_CASE(ERROR_VALIDATION_FAILED_EXT); | |
| STR_CASE(ERROR_OUT_OF_POOL_MEMORY); | |
| default: | |
| return "<unknown>"; | |
| } | |
| } | |
| Resource::Resource(rcp<VulkanContext> vk) : GPUResource(std::move(vk)) {} | |
| VulkanContext* Resource::vk() const | |
| { | |
| return static_cast<VulkanContext*>(m_manager.get()); | |
| } | |
| Buffer::Buffer(rcp<VulkanContext> vk, | |
| const VkBufferCreateInfo& info, | |
| Mappability mappability) : | |
| Resource(std::move(vk)), m_mappability(mappability), m_info(info) | |
| { | |
| m_info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; | |
| init(); | |
| } | |
| Buffer::~Buffer() { resizeImmediately(0); } | |
| void Buffer::resizeImmediately(VkDeviceSize sizeInBytes) | |
| { | |
| if (m_info.size != sizeInBytes) | |
| { | |
| if (m_vmaAllocation != VK_NULL_HANDLE) | |
| { | |
| if (m_mappability != Mappability::none) | |
| { | |
| vmaUnmapMemory(vk()->allocator(), m_vmaAllocation); | |
| } | |
| vmaDestroyBuffer(vk()->allocator(), m_vkBuffer, m_vmaAllocation); | |
| } | |
| m_info.size = sizeInBytes; | |
| init(); | |
| } | |
| } | |
| VmaAllocationCreateFlags vma_flags_for_mappability(Mappability mappability) | |
| { | |
| switch (mappability) | |
| { | |
| case Mappability::none: | |
| return 0; | |
| case Mappability::writeOnly: | |
| return VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT; | |
| case Mappability::readWrite: | |
| return VMA_ALLOCATION_CREATE_HOST_ACCESS_RANDOM_BIT; | |
| } | |
| RIVE_UNREACHABLE(); | |
| } | |
| void Buffer::init() | |
| { | |
| // Cleared up front so a failed allocation leaves nothing for ~Buffer() to | |
| // free; vmaCreateBuffer() doesn't define its outputs when it fails. | |
| m_vkBuffer = VK_NULL_HANDLE; | |
| m_vmaAllocation = VK_NULL_HANDLE; | |
| m_contents = nullptr; | |
| if (m_info.size > 0) | |
| { | |
| VmaAllocationCreateInfo allocInfo = { | |
| .flags = vma_flags_for_mappability(m_mappability), | |
| .usage = VMA_MEMORY_USAGE_AUTO, | |
| }; | |
| VkBuffer buffer; | |
| VmaAllocation allocation; | |
| if (!VK_SUCCEEDED(vmaCreateBuffer(vk()->allocator(), | |
| &m_info, | |
| &allocInfo, | |
| &buffer, | |
| &allocation, | |
| nullptr), | |
| "a buffer")) | |
| { | |
| vk()->reportAllocationFailure(); | |
| return; | |
| } | |
| m_vkBuffer = buffer; | |
| m_vmaAllocation = allocation; | |
| if (m_mappability != Mappability::none) | |
| { | |
| // Leave the buffer constantly mapped and let the OS/drivers handle | |
| // the rest. | |
| void* contents; | |
| if (!VK_SUCCEEDED( | |
| vmaMapMemory(vk()->allocator(), m_vmaAllocation, &contents), | |
| "a buffer mapping")) | |
| { | |
| // Throw the buffer away too. A mappable buffer we can't map is | |
| // useless, and callers test the handle to decide whether we | |
| // allocated anything at all. | |
| vmaDestroyBuffer(vk()->allocator(), | |
| m_vkBuffer, | |
| m_vmaAllocation); | |
| m_vkBuffer = VK_NULL_HANDLE; | |
| m_vmaAllocation = VK_NULL_HANDLE; | |
| vk()->reportAllocationFailure(); | |
| return; | |
| } | |
| m_contents = contents; | |
| } | |
| } | |
| } | |
| void Buffer::flushContents(VkDeviceSize updatedSizeInBytes) | |
| { | |
| vmaFlushAllocation(vk()->allocator(), | |
| m_vmaAllocation, | |
| 0, | |
| updatedSizeInBytes); | |
| } | |
| void Buffer::invalidateContents(VkDeviceSize updatedSizeInBytes) | |
| { | |
| vmaInvalidateAllocation(vk()->allocator(), | |
| m_vmaAllocation, | |
| 0, | |
| updatedSizeInBytes); | |
| } | |
| BufferPool::BufferPool(rcp<VulkanContext> vk, | |
| VkBufferUsageFlags usageFlags, | |
| VkDeviceSize size) : | |
| GPUResourcePool(std::move(vk), MAX_POOL_SIZE), | |
| m_usageFlags(usageFlags), | |
| m_targetSize(size) | |
| {} | |
| inline VulkanContext* BufferPool::vk() const | |
| { | |
| return static_cast<VulkanContext*>(m_manager.get()); | |
| } | |
| void BufferPool::setTargetSize(VkDeviceSize size) | |
| { | |
| // Buffers always get bound, even if unused, so make sure they aren't empty | |
| // and we get a valid Vulkan handle. | |
| size = std::max<VkDeviceSize>(size, 1); | |
| if (m_usageFlags & VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT) | |
| { | |
| // Uniform blocks must be multiples of 256 bytes in size. | |
| size = std::max<VkDeviceSize>(size, 256); | |
| assert(size % 256 == 0); | |
| } | |
| m_targetSize = size; | |
| } | |
| rcp<vkutil::Buffer> BufferPool::acquire() | |
| { | |
| auto buffer = static_rcp_cast<vkutil::Buffer>(GPUResourcePool::acquire()); | |
| if (buffer == nullptr) | |
| { | |
| buffer = vk()->makeBuffer( | |
| { | |
| .size = m_targetSize, | |
| .usage = m_usageFlags, | |
| }, | |
| Mappability::writeOnly); | |
| } | |
| else if (buffer->info().size != m_targetSize) | |
| { | |
| buffer->resizeImmediately(m_targetSize); | |
| } | |
| return buffer; | |
| } | |
| Image::Image(rcp<VulkanContext> vulkanContext, | |
| const VkImageCreateInfo& info, | |
| const char* name) : | |
| Resource(std::move(vulkanContext)), m_info(info) | |
| { | |
| m_info = info; | |
| m_info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; | |
| if (m_info.mipLevels == 0) | |
| { | |
| m_info.mipLevels = 1; | |
| } | |
| else if (m_info.mipLevels > 1) | |
| { | |
| // We generate mipmaps internally with image blits. | |
| m_info.usage |= VK_IMAGE_USAGE_TRANSFER_SRC_BIT; | |
| } | |
| if (m_info.arrayLayers == 0) | |
| { | |
| m_info.arrayLayers = 1; | |
| } | |
| if (m_info.samples == 0) | |
| { | |
| m_info.samples = VK_SAMPLE_COUNT_1_BIT; | |
| } | |
| if (m_info.usage & VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT) | |
| { | |
| // Attempt to create transient attachments with lazily-allocated memory. | |
| // This should succeed on mobile. Otherwise, use normal memory. | |
| VmaAllocationCreateInfo allocInfo = { | |
| .usage = VMA_MEMORY_USAGE_GPU_LAZILY_ALLOCATED, | |
| }; | |
| if (vmaCreateImage(vk()->allocator(), | |
| &m_info, | |
| &allocInfo, | |
| &m_vkImage, | |
| &m_vmaAllocation, | |
| nullptr) == VK_SUCCESS) | |
| { | |
| vk()->setDebugNameIfEnabled(uint64_t(m_vkImage), | |
| VK_OBJECT_TYPE_IMAGE, | |
| name); | |
| return; | |
| } | |
| } | |
| VmaAllocationCreateInfo allocInfo = { | |
| .usage = VMA_MEMORY_USAGE_AUTO, | |
| }; | |
| VkImage image; | |
| VmaAllocation allocation; | |
| if (!VK_SUCCEEDED(vmaCreateImage(vk()->allocator(), | |
| &m_info, | |
| &allocInfo, | |
| &image, | |
| &allocation, | |
| nullptr), | |
| name)) | |
| { | |
| vk()->reportAllocationFailure(); | |
| return; | |
| } | |
| m_vkImage = image; | |
| m_vmaAllocation = allocation; | |
| vk()->setDebugNameIfEnabled(uint64_t(m_vkImage), | |
| VK_OBJECT_TYPE_IMAGE, | |
| name); | |
| } | |
| Image::Image(rcp<VulkanContext> vulkanContext, | |
| VkImage externalImage, | |
| const VkImageCreateInfo& info, | |
| const char* name) : | |
| Resource(std::move(vulkanContext)), m_info(info) | |
| { | |
| m_info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; | |
| m_vkImage = externalImage; | |
| vk()->setDebugNameIfEnabled(uint64_t(m_vkImage), | |
| VK_OBJECT_TYPE_IMAGE, | |
| name); | |
| } | |
| Image::~Image() | |
| { | |
| if (m_vmaAllocation != VK_NULL_HANDLE) | |
| { | |
| vmaDestroyImage(vk()->allocator(), m_vkImage, m_vmaAllocation); | |
| } | |
| } | |
| ImageView::ImageView(rcp<VulkanContext> vulkanContext, | |
| rcp<Image> textureRef, | |
| const VkImageViewCreateInfo& info, | |
| const char* name) : | |
| Resource(std::move(vulkanContext)), | |
| m_textureRefOrNull(std::move(textureRef)), | |
| m_info(info) | |
| { | |
| if (m_info.image == VK_NULL_HANDLE) | |
| { | |
| assert(m_textureRefOrNull != nullptr); | |
| m_info.image = *m_textureRefOrNull; | |
| } | |
| else | |
| { | |
| assert(m_textureRefOrNull == nullptr || | |
| m_info.image == *m_textureRefOrNull); | |
| } | |
| if (m_info.image == VK_NULL_HANDLE) | |
| { | |
| // The image we're viewing failed to allocate. Stay null as well. | |
| return; | |
| } | |
| m_info.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; | |
| m_vkImageView = VK_CREATE_HANDLE(vk(), CreateImageView, &m_info); | |
| vk()->setDebugNameIfEnabled(uint64_t(m_vkImageView), | |
| VK_OBJECT_TYPE_IMAGE_VIEW, | |
| name); | |
| } | |
| ImageView::~ImageView() | |
| { | |
| vk()->DestroyImageView(vk()->device, m_vkImageView, nullptr); | |
| } | |
| Texture2D::Texture2D(rcp<VulkanContext> vk, | |
| VkImageCreateInfo info, | |
| const char* name) : | |
| rive::gpu::Texture(info.extent.width, info.extent.height), | |
| m_lastAccess({ | |
| .pipelineStages = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, | |
| .accessMask = VK_ACCESS_NONE, | |
| .layout = VK_IMAGE_LAYOUT_UNDEFINED, | |
| }) | |
| { | |
| assert(info.imageType == 0 || info.imageType == VK_IMAGE_TYPE_2D); | |
| if (info.imageType == 0) | |
| { | |
| info.imageType = VK_IMAGE_TYPE_2D; | |
| } | |
| if (info.extent.depth == 0) | |
| { | |
| info.extent.depth = 1; | |
| } | |
| if (info.usage == 0) | |
| { | |
| info.usage = | |
| VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT; | |
| } | |
| m_image = vk->makeImage(info, name); | |
| m_imageView = vk->makeImageView(m_image, name); | |
| } | |
| Texture2D::Texture2D(rcp<VulkanContext> vk, | |
| rcp<Image> existingImage, | |
| const char* name) : | |
| rive::gpu::Texture(existingImage->info().extent.width, | |
| existingImage->info().extent.height), | |
| m_image(std::move(existingImage)), | |
| m_lastAccess({ | |
| .pipelineStages = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, | |
| .accessMask = VK_ACCESS_NONE, | |
| .layout = VK_IMAGE_LAYOUT_UNDEFINED, | |
| }) | |
| { | |
| m_imageView = vk->makeImageView(m_image, name); | |
| } | |
| void Texture2D::scheduleUpload(const void* imageDataRGBAPremul, | |
| size_t imageDataSizeInBytes) | |
| { | |
| rcp<vkutil::Buffer> imageBufferRGBAPremul = m_image->vk()->makeBuffer( | |
| { | |
| .size = imageDataSizeInBytes, | |
| .usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT, | |
| }, | |
| vkutil::Mappability::writeOnly); | |
| if (!imageBufferRGBAPremul->hasContents()) | |
| { | |
| // Nothing to upload into; the init path that owns us will bail shortly. | |
| return; | |
| } | |
| memcpy(imageBufferRGBAPremul->contents(), | |
| imageDataRGBAPremul, | |
| imageDataSizeInBytes); | |
| imageBufferRGBAPremul->flushContents(); | |
| scheduleUpload(std::move(imageBufferRGBAPremul)); | |
| } | |
| void Texture2D::scheduleUpload(rcp<vkutil::Buffer> imageBufferRGBAPremul) | |
| { | |
| m_imageUploadBuffer = std::move(imageBufferRGBAPremul); | |
| m_imageUploadRegions.clear(); | |
| } | |
| void Texture2D::scheduleUpload(rcp<vkutil::Buffer> stagingBuffer, | |
| std::vector<VkBufferImageCopy> regions) | |
| { | |
| m_imageUploadBuffer = std::move(stagingBuffer); | |
| m_imageUploadRegions = std::move(regions); | |
| } | |
| void Texture2D::applyImageUploadBuffer(VkCommandBuffer commandBuffer) | |
| { | |
| assert(m_imageUploadBuffer != nullptr); | |
| barrier(commandBuffer, | |
| { | |
| .pipelineStages = VK_PIPELINE_STAGE_TRANSFER_BIT, | |
| .accessMask = VK_ACCESS_TRANSFER_WRITE_BIT, | |
| .layout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, | |
| }, | |
| vkutil::ImageAccessAction::invalidateContents); | |
| if (!m_imageUploadRegions.empty()) | |
| { | |
| // Caller-supplied per-level regions. No automatic mip generation — | |
| // every level present in the texture must have a region. | |
| m_image->vk()->CmdCopyBufferToImage( | |
| commandBuffer, | |
| *m_imageUploadBuffer, | |
| *m_image, | |
| VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, | |
| static_cast<uint32_t>(m_imageUploadRegions.size()), | |
| m_imageUploadRegions.data()); | |
| // All mips already written — transition straight to shader-read. | |
| barrier(commandBuffer, | |
| { | |
| .pipelineStages = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, | |
| .accessMask = VK_ACCESS_SHADER_READ_BIT, | |
| .layout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, | |
| }); | |
| m_imageUploadRegions.clear(); | |
| } | |
| else | |
| { | |
| // Single-region upload (mip 0 full extent). Caller relies on | |
| // generateMipmaps to fill remaining levels. | |
| VkBufferImageCopy bufferImageCopy = { | |
| .imageSubresource = | |
| { | |
| .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, | |
| .layerCount = 1, | |
| }, | |
| .imageExtent = {width(), height(), 1}, | |
| }; | |
| m_image->vk()->CmdCopyBufferToImage( | |
| commandBuffer, | |
| *m_imageUploadBuffer, | |
| *m_image, | |
| VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, | |
| 1, | |
| &bufferImageCopy); | |
| generateMipmaps( | |
| commandBuffer, | |
| { | |
| .pipelineStages = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, | |
| .accessMask = VK_ACCESS_SHADER_READ_BIT, | |
| .layout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, | |
| }); | |
| } | |
| m_imageUploadBuffer = nullptr; | |
| } | |
| void Texture2D::barrier(VkCommandBuffer commandBuffer, | |
| const vkutil::ImageAccess& dstAccess, | |
| vkutil::ImageAccessAction imageAccessAction, | |
| VkDependencyFlags dependencyFlags) | |
| { | |
| // Always perform the barrier, even if m_lastAccess == dstAccess, because | |
| // a pipeline barrier may still be needed even if we aren't transitioning | |
| // the image or interacting with other stages. | |
| m_lastAccess = m_image->vk()->simpleImageMemoryBarrier(commandBuffer, | |
| m_lastAccess, | |
| dstAccess, | |
| *m_image, | |
| imageAccessAction, | |
| dependencyFlags); | |
| } | |
| void Texture2D::generateMipmaps(VkCommandBuffer commandBuffer, | |
| const ImageAccess& dstAccess) | |
| { | |
| VulkanContext* vk = m_image->vk(); | |
| uint32_t mipLevels = m_image->info().mipLevels; | |
| if (mipLevels <= 1) | |
| { | |
| barrier(commandBuffer, dstAccess); | |
| return; | |
| } | |
| barrier(commandBuffer, | |
| { | |
| .pipelineStages = VK_PIPELINE_STAGE_TRANSFER_BIT, | |
| .accessMask = VK_ACCESS_TRANSFER_WRITE_BIT, | |
| .layout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, | |
| }); | |
| int2 dstSize, srcSize = {static_cast<int32_t>(width()), | |
| static_cast<int32_t>(height())}; | |
| for (uint32_t level = 1; level < mipLevels; ++level, srcSize = dstSize) | |
| { | |
| dstSize = simd::max(srcSize >> 1, int2(1)); | |
| VkImageBlit imageBlit = { | |
| .srcSubresource = | |
| { | |
| .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, | |
| .mipLevel = level - 1, | |
| .layerCount = 1, | |
| }, | |
| .dstSubresource = | |
| { | |
| .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, | |
| .mipLevel = level, | |
| .layerCount = 1, | |
| }, | |
| }; | |
| imageBlit.srcOffsets[0] = {0, 0, 0}; | |
| imageBlit.srcOffsets[1] = {srcSize.x, srcSize.y, 1}; | |
| imageBlit.dstOffsets[0] = {0, 0, 0}; | |
| imageBlit.dstOffsets[1] = {dstSize.x, dstSize.y, 1}; | |
| vk->imageMemoryBarrier( | |
| commandBuffer, | |
| VK_PIPELINE_STAGE_TRANSFER_BIT, | |
| VK_PIPELINE_STAGE_TRANSFER_BIT, | |
| 0, | |
| { | |
| .srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, | |
| .dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT, | |
| .oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, | |
| .newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, | |
| .image = *m_image, | |
| .subresourceRange = | |
| { | |
| .baseMipLevel = level - 1, | |
| .levelCount = 1, | |
| }, | |
| }); | |
| vk->CmdBlitImage(commandBuffer, | |
| *m_image, | |
| VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, | |
| *m_image, | |
| VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, | |
| 1, | |
| &imageBlit, | |
| VK_FILTER_LINEAR); | |
| } | |
| VkImageMemoryBarrier barriers[] = { | |
| { | |
| // The first N - 1 layers are in TRANSFER_READ. | |
| .srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT, | |
| .dstAccessMask = dstAccess.accessMask, | |
| .oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, | |
| .newLayout = dstAccess.layout, | |
| .image = *m_image, | |
| .subresourceRange = | |
| { | |
| .baseMipLevel = 0, | |
| .levelCount = mipLevels - 1, | |
| }, | |
| }, | |
| { | |
| // The final layer is still in TRANSFER_WRITE. | |
| .srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, | |
| .dstAccessMask = dstAccess.accessMask, | |
| .oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, | |
| .newLayout = dstAccess.layout, | |
| .image = *m_image, | |
| .subresourceRange = | |
| { | |
| .baseMipLevel = mipLevels - 1, | |
| .levelCount = 1, | |
| }, | |
| }, | |
| }; | |
| vk->imageMemoryBarriers(commandBuffer, | |
| VK_PIPELINE_STAGE_TRANSFER_BIT, | |
| dstAccess.pipelineStages, | |
| 0, | |
| std::size(barriers), | |
| barriers); | |
| m_lastAccess = dstAccess; | |
| } | |
| Framebuffer::Framebuffer(rcp<VulkanContext> vulkanContext, | |
| const VkFramebufferCreateInfo& info) : | |
| Resource(std::move(vulkanContext)), m_info(info) | |
| { | |
| m_info.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; | |
| m_vkFramebuffer = VK_CREATE_HANDLE(vk(), CreateFramebuffer, &m_info); | |
| } | |
| Framebuffer::~Framebuffer() | |
| { | |
| vk()->DestroyFramebuffer(vk()->device, m_vkFramebuffer, nullptr); | |
| } | |
| } // namespace rive::gpu::vkutil |