Skip to content

IOSurfaceHostBuffer.kt

src/jvmMain/kotlin/afield/graphics/texture/IOSurfaceHostBuffer.kt · 147 lines

IOSurfaceHostBuffer maps an IOSurface's memory into a Vulkan buffer, so pixels move between an IOSurface and a Vulkan image with one GPU copy on macOS drivers that cannot back an image with an IOSurface.

package afield.graphics.texture

import afield.graphics.vulkan.device.Device
import afield.graphics.vulkan.device.vkLayout
import afield.graphics.vulkan.util.vkCheck
import org.lwjgl.system.MemoryStack
import org.lwjgl.vulkan.EXTExternalMemoryHost.VK_EXTERNAL_MEMORY_HANDLE_TYPE_HOST_ALLOCATION_BIT_EXT
import org.lwjgl.vulkan.EXTExternalMemoryHost.nvkGetMemoryHostPointerPropertiesEXT
import org.lwjgl.vulkan.VK10.*
import org.lwjgl.vulkan.VkBufferCreateInfo
import org.lwjgl.vulkan.VkBufferImageCopy
import org.lwjgl.vulkan.VkExternalMemoryBufferCreateInfo
import org.lwjgl.vulkan.VkImportMemoryHostPointerInfoEXT
import org.lwjgl.vulkan.VkMemoryAllocateInfo
import org.lwjgl.vulkan.VkMemoryHostPointerPropertiesEXT
import org.lwjgl.vulkan.VkMemoryRequirements
import org.lwjgl.vulkan.VkPhysicalDeviceMemoryProperties

class IOSurfaceHostBuffer

An IOSurface's own pages as a VkBuffer, through VK_EXT_external_memory_host.

This is how an IOSurface reaches Vulkan on KosmicKrisp. That driver imports image memory only as an MTLHeap (measured, SDK 1.4.363: MTLTEXTURE import is VK_ERROR_FORMAT_NOT_SUPPORTED for every format and usage, and there is no heap-over-IOSurface in Metal), and it has no VK_EXT_metal_objects, so an IOSurface cannot back a VkImage there. It can back a buffer: on Apple silicon an IOSurface is ordinary unified memory, so one GPU copy per frame between this buffer and an ordinary image moves the pixels without the CPU touching them — copyToImage for a surface someone else fills (a browser frame), copyFromImage for one someone else reads (the field-encode ring). Verified both directions on KosmicKrisp and MoltenVK at 1920×1080 and at 1366×768, whose 5504-byte row pitch is not width × 4.

The surface stays locked for this object's lifetime: IOSurfaceGetBaseAddress is only defined while it is. The caller keeps its own retain on the IOSurfaceRef and releases it after close. close must run only once the GPU has finished with the buffer.

class IOSurfaceHostBuffer(
    private val device: Device,
    val ioSurfaceRef: Long,
    private val readOnly: Boolean,
) : AutoCloseable {

val width: Int = MetalIOSurface.width(ioSurfaceRef)

val height: Int = MetalIOSurface.height(ioSurfaceRef)

val bytesPerRow: Long = MetalIOSurface.bytesPerRow(ioSurfaceRef)

    private val size: Long = MetalIOSurface.allocSize(ioSurfaceRef)
    private val importSize: Long

val buffer: Long

    private val memory: Long
    private var closed = false

init

body — 49 lines, from line 54
    init {
        check(device.supportsExternalMemoryHost) { "IOSurfaceHostBuffer needs VK_EXT_external_memory_host" }
        MetalIOSurface.lock(ioSurfaceRef, if (readOnly) MetalIOSurface.LOCK_READ_ONLY else 0)
        try {
            val base = MetalIOSurface.baseAddress(ioSurfaceRef)
            val alignment = device.minImportedHostPointerAlignment
            check(base != 0L && alignment > 0 && base % alignment == 0L) {
                "IOSurface ${MetalIOSurface.ioSurfaceId(ioSurfaceRef)} cannot be imported: base 0x${base.toString(16)}, " +
                    "size $size, host-pointer alignment $alignment"
            }
            // Chromium sizes a surface exactly (640x360 BGRA is 921600 bytes, 56.25 pages of 16 KiB).
            // Its pages are whole and it starts on one, so importing up to the next page boundary
            // stays inside its own mapping; the copies only ever touch its rows.
            importSize = (size + alignment - 1) / alignment * alignment
            MemoryStack.stackPush().use { stack ->
                val vkDevice = device.vkDevice
                val external = VkExternalMemoryBufferCreateInfo.calloc(stack).`sType$Default`()
                    .handleTypes(VK_EXTERNAL_MEMORY_HANDLE_TYPE_HOST_ALLOCATION_BIT_EXT)
                val info = VkBufferCreateInfo.calloc(stack).`sType$Default`().pNext(external.address())
                    .size(importSize)
                    .usage(VK_BUFFER_USAGE_TRANSFER_SRC_BIT or VK_BUFFER_USAGE_TRANSFER_DST_BIT)
                    .sharingMode(VK_SHARING_MODE_EXCLUSIVE)
                val out = stack.mallocLong(1)
                vkCheck(vkCreateBuffer(vkDevice, info, null, out), "vkCreateBuffer(IOSurface host buffer)")
                buffer = out[0]

                val pointerProperties = VkMemoryHostPointerPropertiesEXT.calloc(stack).`sType$Default`()
                vkCheck(
                    nvkGetMemoryHostPointerPropertiesEXT(vkDevice, VK_EXTERNAL_MEMORY_HANDLE_TYPE_HOST_ALLOCATION_BIT_EXT,
                        base, pointerProperties.address()),
                    "vkGetMemoryHostPointerPropertiesEXT(IOSurface)"
                )
                val requirements = VkMemoryRequirements.calloc(stack)
                vkGetBufferMemoryRequirements(vkDevice, buffer, requirements)
                val import = VkImportMemoryHostPointerInfoEXT.calloc(stack).`sType$Default`()
                    .handleType(VK_EXTERNAL_MEMORY_HANDLE_TYPE_HOST_ALLOCATION_BIT_EXT)
                    .pHostPointer(base)
                val allocate = VkMemoryAllocateInfo.calloc(stack).`sType$Default`().pNext(import.address())
                    .allocationSize(importSize)
                    .memoryTypeIndex(memoryType(requirements.memoryTypeBits() and pointerProperties.memoryTypeBits()))
                vkCheck(vkAllocateMemory(vkDevice, allocate, null, out), "vkAllocateMemory(IOSurface host import)")
                memory = out[0]
                vkCheck(vkBindBufferMemory(vkDevice, buffer, memory, 0), "vkBindBufferMemory(IOSurface host buffer)")
            }
        } catch (t: Throwable) {
            MetalIOSurface.unlock(ioSurfaceRef, if (readOnly) MetalIOSurface.LOCK_READ_ONLY else 0)
            throw t
        }
    }

fun copyToImage(commandBuffer: org.lwjgl.vulkan.VkCommandBuffer, image: Long, bytesPerPixel: Int)

Record surface → image (in TRANSFER_DST_OPTIMAL), bytesPerPixel per texel.

    fun copyToImage(commandBuffer: org.lwjgl.vulkan.VkCommandBuffer, image: Long, bytesPerPixel: Int) =
        MemoryStack.stackPush().use { stack ->
            vkCmdCopyBufferToImage(commandBuffer, buffer, image, vkLayout(VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL), region(stack, bytesPerPixel))
        }

fun copyFromImage(commandBuffer: org.lwjgl.vulkan.VkCommandBuffer, image: Long, bytesPerPixel: Int)

Record image (in TRANSFER_SRC_OPTIMAL) → surface, bytesPerPixel per texel.

    fun copyFromImage(commandBuffer: org.lwjgl.vulkan.VkCommandBuffer, image: Long, bytesPerPixel: Int) =
        MemoryStack.stackPush().use { stack ->
            vkCmdCopyImageToBuffer(commandBuffer, image, vkLayout(VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL), buffer, region(stack, bytesPerPixel))
        }

Rows at the surface's own pitch, which IOSurface pads (1366 × 4 = 5464 is laid out at 5504).

    private fun region(stack: MemoryStack, bytesPerPixel: Int): VkBufferImageCopy.Buffer {
        check(bytesPerRow % bytesPerPixel == 0L) { "IOSurface row pitch $bytesPerRow is not a whole number of $bytesPerPixel-byte texels" }
        val region = VkBufferImageCopy.calloc(1, stack)
        region[0]
            .bufferOffset(0)
            .bufferRowLength((bytesPerRow / bytesPerPixel).toInt())
            .bufferImageHeight(height)
            .imageSubresource { it.aspectMask(VK_IMAGE_ASPECT_COLOR_BIT).mipLevel(0).baseArrayLayer(0).layerCount(1) }
            .imageOffset { it.set(0, 0, 0) }
            .imageExtent { it.set(width, height, 1) }
        return region
    }

override fun close()

    override fun close() {
        if (closed) return
        closed = true
        vkDestroyBuffer(device.vkDevice, buffer, null)
        vkFreeMemory(device.vkDevice, memory, null)
        MetalIOSurface.unlock(ioSurfaceRef, if (readOnly) MetalIOSurface.LOCK_READ_ONLY else 0)
    }

    private fun memoryType(bits: Int): Int {
        MemoryStack.stackPush().use { stack ->
            val available = VkPhysicalDeviceMemoryProperties.calloc(stack)
            vkGetPhysicalDeviceMemoryProperties(device.physicalDevice.vkPhysicalDevice, available)
            for (index in 0 until available.memoryTypeCount()) if ((bits and (1 shl index)) != 0) return index
        }
        error("no Vulkan memory type can hold an imported IOSurface: bits=0x${bits.toString(16)}")
    }
}