Skip to content

MetalIOSurfaceExport.kt

src/jvmMain/kotlin/afield/graphics/texture/MetalIOSurfaceExport.kt · 155 lines

MetalIOSurface reaches the macOS IOSurface framework from Kotlin, so a Field render-to-texture image can be shared with another process by IOSurfaceID or Mach port.

It looks up, locks, measures and releases IOSurfaces, and MetalIOSurface.ioSurfaceIds lists the ids behind a afield.graphics.vulkan.targets.RenderToTexture created for IOSurface export. macOS only.

package afield.graphics.texture

import afield.graphics.vulkan.targets.RenderToTexture
import org.lwjgl.system.APIUtil.apiCreateLibrary
import org.lwjgl.system.JNI
import org.lwjgl.system.SharedLibrary

object MetalIOSurface

Bridges a Field RTT color image to a global IOSurfaceID that the field-encode co-process can attach to with IOSurfaceLookup (see native/field-encode/ and dev_docs/vision_frontend.md -> "The sender seam").

The chain is: render into an RTT created with exportMetalIOSurface = true -> Image.exportMetalIOSurfaceRef (VK_EXT_metal_objects) yields the IOSurfaceRef -> ioSurfaceId returns its process-global IOSurfaceID. Pass that id to FieldEncodeProcess.open(surfaces = ...). macOS / MoltenVK only.

IOSurfaceGetID is reached via a tiny dynamic-lookup call (LWJGL JNI) into the already process-loaded IOSurface framework — no JNI shim library of our own.

object MetalIOSurface {

    private val ioSurfaceLibrary: SharedLibrary by lazy {
        // The framework is already loaded in-process by MoltenVK; open it for symbol lookup.
        // Absolute path first (robust), bare name as a fallback.
        runCatching { apiCreateLibrary("/System/Library/Frameworks/IOSurface.framework/IOSurface") }
            .getOrElse { apiCreateLibrary("IOSurface") }
    }

    private val pIOSurfaceGetID: Long by lazy { ioSurfaceLibrary.getFunctionAddress("IOSurfaceGetID") }
    private val pIOSurfaceLookup: Long by lazy { ioSurfaceLibrary.getFunctionAddress("IOSurfaceLookup") }
    private val pIOSurfaceLookupFromMachPort: Long by lazy {
        ioSurfaceLibrary.getFunctionAddress("IOSurfaceLookupFromMachPort")
    }
    private val pIOSurfaceCreateMachPort: Long by lazy {
        ioSurfaceLibrary.getFunctionAddress("IOSurfaceCreateMachPort")
    }

    private val pIOSurfaceLock: Long by lazy { ioSurfaceLibrary.getFunctionAddress("IOSurfaceLock") }
    private val pIOSurfaceUnlock: Long by lazy { ioSurfaceLibrary.getFunctionAddress("IOSurfaceUnlock") }
    private val pIOSurfaceGetBaseAddress: Long by lazy { ioSurfaceLibrary.getFunctionAddress("IOSurfaceGetBaseAddress") }
    private val pIOSurfaceGetBytesPerRow: Long by lazy { ioSurfaceLibrary.getFunctionAddress("IOSurfaceGetBytesPerRow") }
    private val pIOSurfaceGetAllocSize: Long by lazy { ioSurfaceLibrary.getFunctionAddress("IOSurfaceGetAllocSize") }
    private val pIOSurfaceGetWidth: Long by lazy { ioSurfaceLibrary.getFunctionAddress("IOSurfaceGetWidth") }
    private val pIOSurfaceGetHeight: Long by lazy { ioSurfaceLibrary.getFunctionAddress("IOSurfaceGetHeight") }

const val LOCK_READ_ONLY = 0x1

kIOSurfaceLockReadOnly: the lock's owner promises not to write through the CPU mapping.

fun lock(ioSurfaceRef: Long, options: Int = 0)

IOSurfaceLock(ref, options, NULL): pin the surface's CPU mapping so baseAddress is valid. The address is only defined while the surface is locked. Balance with unlock.

    fun lock(ioSurfaceRef: Long, options: Int = 0) {
        val result = JNI.callPPI(ioSurfaceRef, options, 0L, pIOSurfaceLock)
        check(result == 0) { "IOSurfaceLock failed: $result" }
    }

fun unlock(ioSurfaceRef: Long, options: Int = 0)

    fun unlock(ioSurfaceRef: Long, options: Int = 0) {
        val result = JNI.callPPI(ioSurfaceRef, options, 0L, pIOSurfaceUnlock)
        check(result == 0) { "IOSurfaceUnlock failed: $result" }
    }

fun baseAddress(ioSurfaceRef: Long): Long = JNI.callPP(ioSurfaceRef, pIOSurfaceGetBaseAddress)

fun bytesPerRow(ioSurfaceRef: Long): Long = JNI.callPP(ioSurfaceRef, pIOSurfaceGetBytesPerRow)

fun allocSize(ioSurfaceRef: Long): Long = JNI.callPP(ioSurfaceRef, pIOSurfaceGetAllocSize)

fun width(ioSurfaceRef: Long): Int = JNI.callPP(ioSurfaceRef, pIOSurfaceGetWidth).toInt()

fun height(ioSurfaceRef: Long): Int = JNI.callPP(ioSurfaceRef, pIOSurfaceGetHeight).toInt()

    private val coreFoundationLibrary: SharedLibrary by lazy {
        runCatching { apiCreateLibrary("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation") }
            .getOrElse { apiCreateLibrary("CoreFoundation") }
    }
    private val pCFRelease: Long by lazy { coreFoundationLibrary.getFunctionAddress("CFRelease") }

    private val systemLibrary: SharedLibrary by lazy { apiCreateLibrary("/usr/lib/libSystem.B.dylib") }
    private val pMachTaskSelf: Long by lazy { systemLibrary.getFunctionAddress("mach_task_self") }
    private val pMachPortDeallocate: Long by lazy { systemLibrary.getFunctionAddress("mach_port_deallocate") }

fun lookup(id: Long): Long

IOSurfaceRef IOSurfaceLookup(IOSurfaceID) — resolve a process-global id back to a local IOSurfaceRef pointer (0 if not found). The inverse of ioSurfaceId: use it to import a surface another process created (e.g. one the field-encode helper allocated and returned), then pass the pointer to RTTParameters.importMetalIOSurface.

    fun lookup(id: Long): Long {
        if (id == 0L) return 0L
        return JNI.callPP(id and 0xFFFF_FFFFL, pIOSurfaceLookup)
    }

fun lookupFromMachPort(port: Int): Long

Resolve an IOSurface send right that has already been transferred into this process. The returned IOSurfaceRef is retained; release balances it. This does not consume port.

    fun lookupFromMachPort(port: Int): Long {
        if (port == 0) return 0L
        return JNI.callP(port, pIOSurfaceLookupFromMachPort)
    }

fun createMachPort(ioSurfaceRef: Long): Int

Create a send right for ioSurfaceRef. The returned port name is local to this process; transfer the right in a Mach message rather than sending its integer value over ordinary IPC. The caller owns the right and must eventually call deallocateMachPort.

    fun createMachPort(ioSurfaceRef: Long): Int {
        if (ioSurfaceRef == 0L) return 0
        return JNI.callPI(ioSurfaceRef, pIOSurfaceCreateMachPort)
    }

fun deallocateMachPort(port: Int)

Drop this process's send right after lookupFromMachPort retained the IOSurface.

    fun deallocateMachPort(port: Int) {
        if (port == 0) return
        val task = JNI.callI(pMachTaskSelf)
        val result = JNI.callI(task, port, pMachPortDeallocate)
        check(result == 0) { "mach_port_deallocate failed: $result" }
    }

fun release(ioSurfaceRef: Long)

Balances the retain performed by lookup or lookupFromMachPort. Call only after every Vulkan object importing the returned IOSurfaceRef has been destroyed.

    fun release(ioSurfaceRef: Long) {
        if (ioSurfaceRef != 0L) JNI.callPV(ioSurfaceRef, pCFRelease)
    }

fun ioSurfaceId(ioSurfaceRef: Long): Long

IOSurfaceID IOSurfaceGetID(IOSurfaceRef) — returns the process-global id as an unsigned Long (the raw C return is a uint32). 0 for a null ref.

    fun ioSurfaceId(ioSurfaceRef: Long): Long {
        if (ioSurfaceRef == 0L) return 0L
        return JNI.callPI(ioSurfaceRef, pIOSurfaceGetID).toLong() and 0xFFFF_FFFFL
    }

fun ioSurfaceId(image: Image): Long = ioSurfaceId(image.exportMetalIOSurfaceRef())

Export + resolve the global id for a single RTT color image. 0 if export failed.

fun ioSurfaceIds(rtt: RenderToTexture): List<Long>

The global IOSurfaceIDs for all of an RTT's internally-created color attachments, in slot order (0 for any slot backed by an external view or that failed to export). This is the list to hand to FieldEncodeProcess.open(surfaces = ...).

The RTT must have been created with exportMetalIOSurface = true.

    fun ioSurfaceIds(rtt: RenderToTexture): List<Long> =
        rtt.colorImageRefs.map { image -> if (image == null) 0L else ioSurfaceId(image) }
}