Skip to content

ElectronRTTView.kt

src/jvmMain/kotlin/afield/media/stream/ElectronRTTView.kt · 416 lines

package afield.media.stream

import afield.graphics.vulkan.targets.HasRenderToTexture
import afield.app.protocol.Handled
import afield.app.protocol.Message
import afield.runtime.lifecycle.ReleasePolicy
import afield.runtime.lifecycle.RunScope
import afield.document.context.fromContext
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.Target
import afield.media.browser.MacMachSurfaceInbox
import afield.media.browser.locateFieldProjectRoot
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.LinuxDmaBufExport
import afield.graphics.texture.UnixFdPass
import org.lwjgl.system.MemoryStack
import org.lwjgl.vulkan.VK10.*
import org.lwjgl.vulkan.VK11.VK_QUEUE_FAMILY_EXTERNAL
import org.lwjgl.vulkan.VkImageMemoryBarrier
import org.lwjgl.vulkan.VkImageCopy
import afield.graphics.vulkan.util.OS
import afield.graphics.vulkan.util.OSType
import afield.app.protocol.handlers
import afield.app.diagnostics.logging.lprintln
import afield.app.protocol.payload
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
import org.lwjgl.vulkan.VK10.VK_FORMAT_B8G8R8A8_SRGB
import java.io.File
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger

Shows a Field 4 render target live in a box's output area in the Field Electron app, sharing GPU surfaces with Electron instead of reading pixels back.

ElectronRTTView is the entry point: render into its ElectronRTTView.rtt and display its ElectronRTTView.html. Works on macOS and on Linux under native Wayland; outside Electron the widget says it needs the app.

class ElectronRTTView

A Field 4 render target drawn straight into a box's output area in the Field Electron app.

No readback and no encode: Electron allocates a ring of IOSurfaces, keeps each imported in Chromium's GPU process, and sends each here over Mach. Each tick this renders rtt; when a ring slot is free, it copies the finished image into that slot's surface on the same command buffer (one GPU copy, IOSurfaceHostBuffer — on KosmicKrisp an IOSurface cannot back a VkImage), and once the frame's fence passes it tells the page which slot is ready. The page asks Electron's main process for that slot, sharedTexture.sendSharedTexture hands it to the renderer, the preload draws it into the view's canvas, and the page hands the slot back.

val view = ElectronRTTView(640, 360)
val module = moduleHere { +view }
module += "mesh" to m
view += "content" to module
display("main", view.html())

A box has one view: re-running it replaces the previous one, stopping it leaves the view drawing, and deleting the box (or closing its document) releases it. Another box that needs the view takes it as @exported val view (read as _view in a child) or prop<ElectronRTTView>(view).

macOS (KosmicKrisp) and Linux (native Wayland, verified on NVIDIA). Linux allocates a Vulkan dma-buf image ring here, transfers FDs to Electron, copies image-to-image, and returns a slot only after Chromium's GPU-release callback. Windows is unsupported. Outside Electron the widget says it needs the app. Measured latencies accumulate in stats. The other halves: electron/rtt-view-host.js, electron/field-preload.js, and createIOSurface in native/field-browser-bridge/macos_bridge.cc.

class ElectronRTTView(
    val width: Int,
    val height: Int,
    private val ringSize: Int = 3,
    format: Int = VK_FORMAT_B8G8R8A8_SRGB,
    private val renderer: Renderer = fromContext(),
    val name: String = "electron-rtt-${counter.incrementAndGet()}",
) : Target, HasRenderToTexture, AutoCloseable {

init

Validate before allocating render targets or native transports.

    init {
        require(OS == OSType.MACOS || OS == OSType.LINUX) {
            "ElectronRTTView needs the Field Electron app on macOS or Linux; this is $OS"
        }
    }

init

    init {
        require(width > 0 && height > 0 && ringSize > 0)
        if (OS == OSType.LINUX) {
            require(ringSize <= 4) { "Linux FD transport supports at most four ring slots" }
            require(format == VK_FORMAT_B8G8R8A8_SRGB) { "Linux RTT view requires BGRA8 sRGB" }
            check(UnixFdPass.available) { "Build native/field-fdpass before using Linux ElectronRTTView" }
        }
    }

override val rtt

Render your content into this; it is drawn to the canvas as is. +view and view += reach it too.

    override val rtt = RenderToTexture(renderer) {
        this.width = this@ElectronRTTView.width
        this.height = this@ElectronRTTView.height
        this.format = format
        useDepth = true
        colorSampled = true
        colorTransferSrc = true
    }

val viewId = "$name-${ProcessHandle.current().pid()}-${System.nanoTime()}"

    private val linux = OS == OSType.LINUX
    private val service = if (linux) "/tmp/field4-rtt-${ProcessHandle.current().pid()}-${counter.incrementAndGet()}.sock"
        else "com.field4.rtt-view.$viewId"
    private val inbox = if (linux) null else MacMachSurfaceInbox(
        File(locateFieldProjectRoot(), "build/field-browser-bridge/macos/libfield_browser_bridge.dylib"), service
    )

    private class Slot(val ref: Long = 0, val host: IOSurfaceHostBuffer? = null,
        val target: RenderToTexture? = null, @Volatile var free: Boolean = true) {
        var initialized = false
    }

    private val linuxRing = if (linux) List(ringSize) {
        Slot(target = RenderToTexture(renderer) {
            this.width = this@ElectronRTTView.width; this.height = this@ElectronRTTView.height
            this.format = format; useDepth = false; exportDmaBuf = true
            colorTransferSrc = false; colorTransferDst = true
        })
    } else emptyList()

    private fun linuxMetadata(): String = linuxRing.joinToString(prefix = "[", postfix = "]") { slot ->
        val image = slot.target!!.colorImageRefs.single()!!
        val plane = image.drmPlaneLayouts.single()
        """{"stride":${plane.rowPitch},"offset":${plane.offset},"size":${image.memorySize},"modifier":"${java.lang.Long.toUnsignedString(image.drmFormatModifier)}"}"""
    }

    @Volatile private var slots: List<Slot> = emptyList()
    @Volatile private var generation = 0
    @Volatile private var reply: ((Message) -> Unit)? = null
    private var seq = 0L

val stats = Stats()

    @Volatile private var closed = false

init

    init {
        installHandlers()
        views[viewId] = this
        renderer.renderList.others[name] = this
        // One view per box: a re-run replaces the box's previous view, and the box's delete or its
        // document's close releases the last one (a keyed registration, so a stop keeps it drawing).
        RunScope.executingBox()?.let { box -> byBox.put(box.id, this)?.close() }
        RunScope.current()?.own(this, ReleasePolicy.KEYED)
    }

fun html(): String

The output-area widget: a canvas the preload draws into, and the script that wires it up.

body — 50 lines, from line 159
    fun html(): String = """
        <div class="field-rtt-view">
          <canvas data-field-rtt-view="$viewId" width="$width" height="$height" style="width:100%;display:block;background:#000"></canvas>
          <div data-field-rtt-stats="$viewId" style="font:11px monospace;opacity:.6;padding:2px 0"></div>
        </div>
        <script type="module">
          import { send, sendWithReply } from "/afield.mjs";
          const viewId = "$viewId";
          const canvas = document.querySelector(`canvas[data-field-rtt-view="${'$'}{viewId}"]`);
          const label = document.querySelector(`[data-field-rtt-stats="${'$'}{viewId}"]`);
          const host = window.fieldElectron && window.fieldElectron.rttView;
          if (!host) {
            label.textContent = "RTT view: needs the Field Electron app on macOS or Linux";
          } else {
            let hostReady = false;
            const queued = [];
            const present = p => host.present(viewId, p.slot, p.seq, p.readyAt, p.generation);
            let drawn = 0, since = performance.now(), worst = 0, sum = 0;
            const releaseTarget = host.gpuRelease ? window : canvas;
            const onDrawn = (e) => {
              const d = e.detail;
              if (d.viewId !== viewId) return;
              send("electron-rtt-release", { viewId, slot: d.slot, seq: d.seq, generation: d.generation,
                readyAt: d.readyAt, mainReceivedAt: d.mainReceivedAt, receivedAt: d.receivedAt, drawnAt: d.drawnAt });
              const latency = d.drawnAt - d.readyAt;
              drawn++; sum += latency; worst = Math.max(worst, latency);
              const elapsed = performance.now() - since;
              if (elapsed > 1000) {
                label.textContent = `${'$'}{(drawn * 1000 / elapsed).toFixed(1)} fps · fence→canvas ${'$'}{(sum / drawn).toFixed(2)} ms avg, ${'$'}{worst.toFixed(2)} max`;
                drawn = 0; sum = 0; worst = 0; since = performance.now();
              }
            };
            releaseTarget.addEventListener("field-rtt-drawn", onDrawn);
            const observer = new MutationObserver(() => {
              if (!canvas.isConnected) {
                observer.disconnect(); releaseTarget.removeEventListener("field-rtt-drawn", onDrawn);
                host.close(viewId);
              }
            });
            observer.observe(document.body, {childList:true, subtree:true});
            sendWithReply("electron-rtt-subscribe", { viewId }, "electron-rtt-" + viewId, (m, p) => {
              if (p.type === "frame") { if (hostReady) present(p); else queued.push(p); }
            });
            host.open({ viewId, service: "$service", width: $width, height: $height, count: $ringSize, slots: ${if (linux) linuxMetadata() else "null"} }).then((r) => {
              if (!r.ok) label.textContent = "RTT view: " + r.error;
              else { hostReady = true; queued.splice(0).forEach(present); }
            });
          }
        </script>
    """.trimIndent()

A fresh subscription (the widget re-ran) gets a fresh ring: receive it off the render thread, then swap it in; the old ring is closed once the GPU has finished with it.

source — 66 lines, from line 212
    private fun subscribe(replyChannel: (Message) -> Unit) {
        reply = replyChannel
        if (linux) {
            Thread({
                val handles = mutableListOf<afield.graphics.texture.DmaBufHandle>()
                try {
                    linuxRing.forEach { handles += LinuxDmaBufExport.handles(it.target!!).single() }
                    val deadline = System.nanoTime() + 10_000_000_000L
                    while (!UnixFdPass.sendFds(service, handles.map { it.fd }.toIntArray())) {
                        check(!closed && System.nanoTime() < deadline) { "Electron dma-buf listener did not open" }
                        Thread.sleep(20)
                    }
                    synchronized(this) {
                        // Re-subscribing must not make an in-flight slot writable.
                        if (slots.isEmpty()) { generation++; slots = linuxRing }
                    }
                } catch (t: Throwable) {
                    lprintln("error,electron-rtt", "$name: dma-buf ring handoff failed: ${t.message}")
                } finally { handles.forEach { UnixFdPass.closeFd(it.fd) } }
            }, "electron-rtt-send-$name").apply { isDaemon = true }.start()
            return
        }
        Thread({
            val received = mutableListOf<Slot>()
            try {
                repeat(ringSize) {
                    val surface = inbox!!.receive(10_000)
                    val ref = MetalIOSurface.lookupFromMachPort(surface.port)
                    MetalIOSurface.deallocateMachPort(surface.port)
                    check(ref != 0L) { "IOSurfaceLookupFromMachPort failed for slot ${surface.token}" }
                    check(MetalIOSurface.width(ref) == width && MetalIOSurface.height(ref) == height) {
                        "slot ${surface.token} is ${MetalIOSurface.width(ref)}x${MetalIOSurface.height(ref)}, wanted ${width}x$height"
                    }
                    received += Slot(ref, IOSurfaceHostBuffer(renderer.device, ref, readOnly = false))
                }
            } catch (t: Throwable) {
                lprintln("error,electron-rtt", "$name: receiving the surface ring failed: ${t.message}")
                received.forEach { it.host?.close(); if (it.ref != 0L) MetalIOSurface.release(it.ref) }
                return@Thread
            }
            val old = slots
            synchronized(this) {
                generation++
                slots = received
            }
            retire(old)
            lprintln("info,electron-rtt", "$name: ${received.size} surface(s) ${width}x$height, bytesPerRow ${received[0].host!!.bytesPerRow}")
        }, "electron-rtt-receive-$name").apply { isDaemon = true }.start()
    }

    private fun retire(old: List<Slot>) {
        if (old.isEmpty()) return
        // The renderer's deferred cleanup runs once the frames in flight retire, and on shutdown it
        // drains before the device is destroyed. A detached sleeping thread raced that teardown and
        // called vkDestroyBuffer on a dead device. Once the device is gone its buffers went with it.
        fun release(destroyHost: Boolean) =
            old.forEach { if (destroyHost) it.host?.close(); if (it.ref != 0L) MetalIOSurface.release(it.ref) }
        if (renderer.isClosed) release(destroyHost = false)
        else renderer.cleanup += Runnable { release(destroyHost = true) }
    }

    private fun released(slot: Int, gen: Int, sample: Sample) {
        if (gen != generation) return
        slots.getOrNull(slot)?.free = true
        stats.add(sample)
    }

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

    override fun render(command: CommandBuffer, index: Int) {
        rtt.render(command, index)
        val ring = slots
        val out = reply ?: return
        val slot = ring.indexOfFirst { it.free }
        if (slot < 0) {
            stats.skipped++
            return
        }
        ring[slot].free = false
        val entry = ring[slot]
        if (linux) {
            val target = entry.target!!
            if (entry.initialized) linuxOwnership(command, target, acquire = true)
            else { target.render(command, index); entry.initialized = true }
            rtt.recordColorTransferSource(command) { source ->
                target.recordColorTransferDestination(command) { destination ->
                    MemoryStack.stackPush().use { stack ->
                        val copy = VkImageCopy.calloc(1, stack)
                        copy.srcSubresource().aspectMask(VK_IMAGE_ASPECT_COLOR_BIT).layerCount(1)
                        copy.dstSubresource().aspectMask(VK_IMAGE_ASPECT_COLOR_BIT).layerCount(1)
                        copy.extent().set(width, height, 1)
                        vkCmdCopyImage(command.vkCommandBuffer, source, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
                            destination, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, copy)
                    }
                }
            }
            linuxOwnership(command, target, acquire = false)
        } else rtt.recordColorTransferSource(command) { image -> entry.host!!.copyFromImage(command.vkCommandBuffer, image, 4) }
        val s = seq++
        val gen = generation
        renderer.afterCurrentGraphicsFrameComplete {
            out(Message("electron-rtt-$viewId").payload(FrameReady("frame", slot, s, gen, epochMs())))
        }
    }

The RTT keeps its local sampled layout. The external lease brackets that state with GENERAL.

    private fun linuxOwnership(command: CommandBuffer, target: RenderToTexture, acquire: Boolean) {
        MemoryStack.stackPush().use { stack ->
            val barrier = VkImageMemoryBarrier.calloc(1, stack).sType(VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER)
                .srcAccessMask(if (acquire) 0 else VK_ACCESS_MEMORY_READ_BIT or VK_ACCESS_MEMORY_WRITE_BIT)
                .dstAccessMask(if (acquire) VK_ACCESS_MEMORY_READ_BIT or VK_ACCESS_MEMORY_WRITE_BIT else 0)
                .oldLayout(if (acquire) VK_IMAGE_LAYOUT_GENERAL else VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)
                .newLayout(if (acquire) VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL else VK_IMAGE_LAYOUT_GENERAL)
                .srcQueueFamilyIndex(if (acquire) VK_QUEUE_FAMILY_EXTERNAL else renderer.graphQueue.queueFamilyIndex)
                .dstQueueFamilyIndex(if (acquire) renderer.graphQueue.queueFamilyIndex else VK_QUEUE_FAMILY_EXTERNAL)
                .image(target.colorImageHandles.single())
            barrier.subresourceRange().aspectMask(VK_IMAGE_ASPECT_COLOR_BIT).levelCount(1).layerCount(1)
            vkCmdPipelineBarrier(command.vkCommandBuffer,
                if (acquire) VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT else VK_PIPELINE_STAGE_ALL_COMMANDS_BIT,
                if (acquire) VK_PIPELINE_STAGE_ALL_COMMANDS_BIT else VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT,
                0, null, null, barrier)
        }
    }

override fun close()

    override fun close() {
        if (closed) return
        closed = true
        byBox.values.remove(this)
        renderer.renderList.others.remove(name)
        views.remove(viewId)
        reply = null
        retire(slots)
        slots = emptyList()
        inbox?.close()
    }

    @Serializable
    private data class FrameReady(val type: String, val slot: Int, val seq: Long, val generation: Int, val readyAt: Double)

    @Serializable
    private data class Subscribe(val viewId: String)

    @Serializable
    private data class Release(
        val viewId: String, val slot: Int, val seq: Long, val generation: Int,
        val readyAt: Double, val mainReceivedAt: Double, val receivedAt: Double, val drawnAt: Double,
    )

data class Sample(val toMain: Double, val toRenderer: Double, val toDrawn: Double)

One frame's timeline, in ms after its fence completed.

class Stats

    class Stats {
        private val samples = ArrayDeque<Sample>()

var frames = 0L; private set

var skipped = 0L; internal set

@Synchronized fun add(sample: Sample)

        @Synchronized fun add(sample: Sample) {
            frames++
            samples.addLast(sample)
            while (samples.size > 600) samples.removeFirst()
        }

override fun toString(): String

Percentiles over the last 600 frames: page→main IPC, main→renderer sendSharedTexture, draw.

        @Synchronized
        override fun toString(): String {
            if (samples.isEmpty()) return "no frames yet (skipped $skipped)"
            fun line(label: String, f: (Sample) -> Double): String {
                val v = samples.map(f).sorted()
                fun p(q: Double) = "%.2f".format(v[((v.size - 1) * q).toInt()])
                return "$label p50 ${p(0.5)} p90 ${p(0.9)} p99 ${p(0.99)} max ${p(1.0)} ms"
            }
            return listOf(
                "frames $frames, skipped (ring full) $skipped, window ${samples.size}",
                line("fence→main     ") { it.toMain },
                line("fence→renderer ") { it.toRenderer },
                line("fence→drawn    ") { it.toDrawn },
            ).joinToString("\n")
        }
    }

companion object

    companion object {
        private val counter = AtomicInteger()
        private val views = ConcurrentHashMap<String, ElectronRTTView>()
        private var installed = false

        private val byBox = ConcurrentHashMap<String, ElectronRTTView>()

        private fun epochMs(): Double = Instant.now().let { it.epochSecond * 1000.0 + it.nano / 1_000_000.0 }

        @Synchronized private fun installHandlers() {
            if (installed) return
            installed = true
            handlers["electron-rtt-subscribe"] = mutableSetOf({ message, reply ->
                val request = Json.decodeFromJsonElement<Subscribe>(message.payload!!)
                views[request.viewId]?.subscribe(reply)
                Handled.yes
            })
            handlers["electron-rtt-release"] = mutableSetOf({ message, _ ->
                val r = Json.decodeFromJsonElement<Release>(message.payload!!)
                views[r.viewId]?.released(r.slot, r.generation,
                    Sample(r.mainReceivedAt - r.readyAt, r.receivedAt - r.readyAt, r.drawnAt - r.readyAt))
                Handled.yes
            })
        }
    }
}