Skip to content

BitmapBrowserSurface.kt

src/jvmMain/kotlin/afield/media/browser/BitmapBrowserSurface.kt · 225 lines

package afield.media.browser

import afield.graphics.vulkan.device.vkLayout
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.texture.image_layout_transition
import afield.graphics.vulkan.util.vkCheck
import org.lwjgl.system.MemoryStack
import org.lwjgl.system.MemoryUtil
import org.lwjgl.vulkan.VK10.*
import org.lwjgl.vulkan.VK13.*
import org.lwjgl.vulkan.VkBufferCreateInfo
import org.lwjgl.vulkan.VkBufferImageCopy
import org.lwjgl.vulkan.VkImageCreateInfo
import org.lwjgl.vulkan.VkImageSubresourceRange
import org.lwjgl.vulkan.VkMemoryAllocateInfo
import org.lwjgl.vulkan.VkMemoryRequirements
import org.lwjgl.vulkan.VkPhysicalDeviceMemoryProperties

A browser-frame receiver that uploads CPU pixels to the GPU, as a fallback where Chromium's shared-texture export delivers an empty frame.

class BitmapBrowserSurface

The non-zero-copy browser receiver: frames arrive as CPU pixels and are uploaded, rather than arriving as a shared GPU allocation and being imported.

This exists because Chromium's Linux offscreen shared-texture export hands over a dma-buf it never fills (electron#53271), which leaves the accelerated path with a correct handle and a black picture. Conflation, the stable resident texture, the fence-delayed release and stream resets are all shared with the accelerated backends; only the way pixels reach the GPU differs, and CopiedExternalBrowserSurface.recordPreCopy is where that difference is recorded.

It is opt-in and should stay that way. A 1280x720 BGRA frame is 3.7 MB, so 30 fps is about 110 MB/s crossing the process boundary and the PCIe bus that the accelerated path does not spend. That is affordable for a UI panel and wrong for anything full-screen or high-rate, and a fallback that selects itself silently is one nobody notices paying for.

class BitmapBrowserSurface(
    renderer: Renderer,
    spec: ExternalBrowserSurfaceSpec,
    releaseListener: BrowserFrameReleaseListener = BrowserFrameReleaseListener { }
) : CopiedExternalBrowserSurface(renderer, spec, releaseListener) {

One slot's staging buffer and the device-local image the resident copy reads from.

source — 84 lines, from line 46
    private inner class Slot {
        val bytes: Long = spec.width.toLong() * spec.height.toLong() * bytesPerPixel
        val buffer: Long
        val bufferMemory: Long
        val mapped: java.nio.ByteBuffer
        val image: Long
        val imageMemory: Long

        init {
            MemoryStack.stackPush().use { stack ->
                val device = renderer.device
                val bufferInfo = VkBufferCreateInfo.calloc(stack)
                    .sType(VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO)
                    .size(bytes)
                    .usage(VK_BUFFER_USAGE_TRANSFER_SRC_BIT)
                    .sharingMode(VK_SHARING_MODE_EXCLUSIVE)
                val out = stack.mallocLong(1)
                vkCheck(vkCreateBuffer(device.vkDevice, bufferInfo, null, out), "vkCreateBuffer(browser bitmap staging)")
                buffer = out[0]

                val requirements = VkMemoryRequirements.calloc(stack)
                vkGetBufferMemoryRequirements(device.vkDevice, buffer, requirements)
                val allocate = VkMemoryAllocateInfo.calloc(stack)
                    .sType(VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO)
                    .allocationSize(requirements.size())
                    .memoryTypeIndex(
                        memoryType(
                            requirements.memoryTypeBits(),
                            VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT or VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
                        )
                    )
                vkCheck(vkAllocateMemory(device.vkDevice, allocate, null, out), "vkAllocateMemory(browser bitmap staging)")
                bufferMemory = out[0]
                vkCheck(vkBindBufferMemory(device.vkDevice, buffer, bufferMemory, 0), "vkBindBufferMemory(browser bitmap staging)")

                // Mapped once for the slot's lifetime: a per-frame map/unmap would be pure overhead
                // on a path that is already paying for the copy.
                val address = stack.mallocPointer(1)
                vkCheck(
                    vkMapMemory(device.vkDevice, bufferMemory, 0, bytes, 0, address),
                    "vkMapMemory(browser bitmap staging)"
                )
                mapped = MemoryUtil.memByteBuffer(address[0], bytes.toInt())

                val imageInfo = VkImageCreateInfo.calloc(stack)
                    .sType(VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO)
                    .imageType(VK_IMAGE_TYPE_2D)
                    .format(importVkFormat)
                    .extent { it.set(spec.width, spec.height, 1) }
                    .mipLevels(1)
                    .arrayLayers(1)
                    .samples(VK_SAMPLE_COUNT_1_BIT)
                    .tiling(VK_IMAGE_TILING_OPTIMAL)
                    .usage(VK_IMAGE_USAGE_TRANSFER_DST_BIT or VK_IMAGE_USAGE_TRANSFER_SRC_BIT)
                    .sharingMode(VK_SHARING_MODE_EXCLUSIVE)
                    .initialLayout(VK_IMAGE_LAYOUT_UNDEFINED)
                vkCheck(vkCreateImage(device.vkDevice, imageInfo, null, out), "vkCreateImage(browser bitmap)")
                image = out[0]
                vkGetImageMemoryRequirements(device.vkDevice, image, requirements)
                val imageAllocate = VkMemoryAllocateInfo.calloc(stack)
                    .sType(VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO)
                    .allocationSize(requirements.size())
                    .memoryTypeIndex(memoryType(requirements.memoryTypeBits(), VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT))
                vkCheck(vkAllocateMemory(device.vkDevice, imageAllocate, null, out), "vkAllocateMemory(browser bitmap)")
                imageMemory = out[0]
                vkCheck(vkBindImageMemory(device.vkDevice, image, imageMemory, 0), "vkBindImageMemory(browser bitmap)")
            }
        }

        fun cleanup() {
            val device = renderer.device
            vkUnmapMemory(device.vkDevice, bufferMemory)
            vkDestroyBuffer(device.vkDevice, buffer, null)
            vkFreeMemory(device.vkDevice, bufferMemory, null)
            vkDestroyImage(device.vkDevice, image, null)
            vkFreeMemory(device.vkDevice, imageMemory, null)
        }
    }

    private val bytesPerPixel = when (spec.pixelFormat) {
        BrowserSurfacePixelFormat.RGBA16_FLOAT -> 8
        else -> 4
    }
    private val slots = mutableMapOf<Int, Slot>()

Set by importFrame and consumed by the very next recordPreCopy; prepare is not reentrant.

    private var pendingUpload: Slot? = null

override fun importFrame(frame: ExternalBrowserFrame): ImportedImage?

    override fun importFrame(frame: ExternalBrowserFrame): ImportedImage? {
        val handle = frame.handle as? ExternalBrowserImageHandle.CpuBitmap ?: return null
        val slot = slots.getOrPut(frame.slot) { Slot() }
        val expected = slot.bytes
        require(handle.pixels.remaining().toLong() == expected) {
            "browser bitmap frame is ${handle.pixels.remaining()} bytes, expected $expected"
        }

        // Straight into the mapped staging allocation, then the producer's copy is dead to us: the
        // bytes belong to the GPU now, so free them here rather than leaving a 3.7 MB buffer alive
        // until whatever else might have closed the handle gets round to it.
        slot.mapped.clear()
        MemoryUtil.memCopy(handle.pixels, slot.mapped)
        handle.close()

        pendingUpload = slot
        return ImportedImage(
            identity = frame.slot,
            image = slot.image,
            cleanupAction = { },
            layout = VK_IMAGE_LAYOUT_UNDEFINED
        )
    }

override fun recordPreCopy(command: CommandBuffer, source: ImportedImage)

body — 47 lines, from line 158
    override fun recordPreCopy(command: CommandBuffer, source: ImportedImage) {
        val slot = pendingUpload ?: return
        pendingUpload = null
        MemoryStack.stackPush().use { stack ->
            val range = VkImageSubresourceRange.calloc(stack)
                .aspectMask(VK_IMAGE_ASPECT_COLOR_BIT)
                .baseMipLevel(0)
                .levelCount(1)
                .baseArrayLayer(0)
                .layerCount(1)

            // The previous frame left this image in GENERAL; the first leaves it UNDEFINED. Either
            // way its contents are about to be entirely overwritten, so discarding them is correct.
            image_layout_transition(
                command.vkCommandBuffer,
                slot.image,
                VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT,
                VK_PIPELINE_STAGE_2_COPY_BIT,
                0,
                VK_ACCESS_2_TRANSFER_WRITE_BIT,
                VK_IMAGE_LAYOUT_UNDEFINED,
                VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
                range
            )

            val region = VkBufferImageCopy.calloc(1, stack)
            region[0]
                .bufferOffset(0)
                .bufferRowLength(0)
                .bufferImageHeight(0)
                .imageSubresource {
                    it.aspectMask(VK_IMAGE_ASPECT_COLOR_BIT).mipLevel(0).baseArrayLayer(0).layerCount(1)
                }
                .imageOffset { it.set(0, 0, 0) }
                .imageExtent { it.set(spec.width, spec.height, 1) }
            vkCmdCopyBufferToImage(
                command.vkCommandBuffer,
                slot.buffer,
                slot.image,
                vkLayout(VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL),
                region
            )
        }
        // recordCopy transitions from here to TRANSFER_SRC_OPTIMAL for the copy into the resident
        // texture, so tell it where the image actually is rather than where importFrame guessed.
        source.layout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL
    }

override fun cleanupImports()

    override fun cleanupImports() {
        pendingUpload = null
        slots.values.forEach { it.cleanup() }
        slots.clear()
    }

    private fun memoryType(bits: Int, properties: Int): Int {
        MemoryStack.stackPush().use { stack ->
            val available = VkPhysicalDeviceMemoryProperties.calloc(stack)
            vkGetPhysicalDeviceMemoryProperties(renderer.device.physicalDevice.vkPhysicalDevice, available)
            for (index in 0 until available.memoryTypeCount()) {
                if ((bits and (1 shl index)) != 0 &&
                    (available.memoryTypes(index).propertyFlags() and properties) == properties
                ) return index
            }
        }
        error("no Vulkan memory type for browser bitmap staging: bits=0x${bits.toString(16)}")
    }
}