Skip to content

SyphonGpu.kt

src/jvmMain/kotlin/afield/io/syphon/SyphonGpu.kt · 120 lines

@file:afield.runtime.completion.CompletionCategory("Graphics")
package afield.io.syphon

import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.Target
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.vulkan.targets.RenderToTexture
import afield.graphics.texture.IOSurfaceHostBuffer
import afield.graphics.texture.MetalIOSurface
import afield.graphics.texture.StreamingTexture
import org.lwjgl.vulkan.VK10.VK_FORMAT_R8G8B8A8_SRGB
import java.lang.foreign.MemorySegment
import java.nio.ByteBuffer
import afield.app.diagnostics.logging.lprintln
import org.lwjgl.vulkan.VK10.VK_FORMAT_B8G8R8A8_SRGB
import org.lwjgl.vulkan.VK10.VK_FORMAT_B8G8R8A8_UNORM
import java.util.concurrent.atomic.AtomicBoolean

Publishes every frame a RenderToTexture draws to a SyphonServer without the CPU touching the pixels: the frame is copied GPU-side into an IOSurface (on the same command buffer that drew it), and once that frame's GPU work has completed Syphon blits the IOSurface into its own shared one.

Made by SyphonServer.followOnGpu; a Target in the renderer's list, placed after the source so it runs after the source has drawn. The IOSurface reaches Vulkan as host memory (VK_EXT_external_memory_host, see IOSurfaceHostBuffer), which both KosmicKrisp and MoltenVK offer on Apple silicon. Two surfaces alternate; if Syphon is still copying the one due next, the frame is skipped rather than overwritten under it.

class SyphonGpuPublisher internal constructor

A Target that copies source's frame into an IOSurface and hands it to server each frame.

class SyphonGpuPublisher internal constructor(
    val server: SyphonServer,
    val source: RenderToTexture,
    private val renderer: Renderer,
) : Target, afield.graphics.vulkan.targets.RendersAfter, AutoCloseable {

override val rendersAfter: Target get() = source

Renders after source whatever order the two were added in.

    private class Slot(val ref: Long, val host: IOSurfaceHostBuffer) {
        val busy = AtomicBoolean(false)
    }

    private val width = source.parameters.width
    private val height = source.parameters.height
    private val slots: List<Slot>
    @Volatile private var closed = false
    private var next = 0

var published: Long

Frames handed to Syphon, and frames skipped because the next surface was still in use.

    @Volatile
    var published: Long = 0
        private set

var skipped: Long

    @Volatile
    var skipped: Long = 0
        private set

init

    init {
        require(source.parameters.format == VK_FORMAT_B8G8R8A8_SRGB || source.parameters.format == VK_FORMAT_B8G8R8A8_UNORM) {
            "followOnGpu needs a B8G8R8A8 RenderToTexture (the default); this one is format ${source.parameters.format}. Use follow() instead."
        }
        require(source.parameters.colorTransferSrc) { "followOnGpu needs colorTransferSrc = true (the default)" }
        check(renderer.device.supportsExternalMemoryHost) {
            "this Vulkan driver has no VK_EXT_external_memory_host, so an IOSurface cannot be imported; use follow() instead"
        }
        slots = List(2) {
            val ref = SyphonNative.ioSurfaceCreate(width, height)
            try {
                Slot(ref, IOSurfaceHostBuffer(renderer.device, ref, readOnly = false))
            } catch (t: Throwable) {
                MetalIOSurface.release(ref)
                throw t
            }
        }
    }

override fun render(command: CommandBuffer, index: Int)

    override fun render(command: CommandBuffer, index: Int) {
        if (closed) return
        val slot = slots[next]
        if (!slot.busy.compareAndSet(false, true)) {
            skipped++
            return
        }
        next = (next + 1) % slots.size
        source.recordColorTransferSource(command) { image -> slot.host.copyFromImage(command.vkCommandBuffer, image, 4) }
        renderer.afterCurrentGraphicsFrameComplete {
            try {
                if (!closed) {
                    server.publishIOSurface(slot.ref)
                    published++
                }
            } catch (t: Throwable) {
                lprintln("error,syphon", "Syphon publish failed: ${t.message}")
            } finally {
                slot.busy.set(false)
            }
        }
    }

override fun close()

Stop publishing. The surfaces are freed once the GPU and Syphon are done with them, which is at most a frame or two later.

    override fun close() {
        if (closed) return
        closed = true
        renderer.renderList.others.entries.removeIf { it.value === this }
        Thread({
            val deadline = System.nanoTime() + 2_000_000_000L
            while (slots.any { it.busy.get() } && System.nanoTime() < deadline) Thread.sleep(5)
            // A frame recorded just before close may not have completed; wait a little longer.
            Thread.sleep(100)
            slots.forEach { runCatching { it.host.close() }; MetalIOSurface.release(it.ref) }
        }, "syphon-gpu-publisher-release").apply { isDaemon = true }.start()
    }

override fun toString(): String

    override fun toString(): String = "SyphonGpuPublisher(${server.name}, ${width}x$height, $published published, $skipped skipped)"
}