Skip to content

SideBySideStereoTarget.kt

src/jvmMain/kotlin/afield/graphics/vulkan/targets/SideBySideStereoTarget.kt · 466 lines · cited by Field on Apple Vision Pro

package afield.graphics.vulkan.targets

import afield.graphics.vulkan.device.vkLayout
import afield.math.linalg.Matrix4f
import afield.math.linalg.Quaternionf

import afield.app.protocol.Rect
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.render.ModulePointer
import afield.document.context.fromContext
import afield.graphics.vulkan.targets.OpenXRHmdTarget.ControllerState
import afield.graphics.vulkan.targets.OpenXRHmdTarget.Hand
import afield.graphics.texture.HasImageView
import afield.graphics.vulkan.util.Camera
import afield.graphics.vulkan.util.DownResult
import afield.graphics.vulkan.util.MouseEvent
import afield.graphics.vulkan.util.MouseEvents
import afield.graphics.vulkan.util.Options
import afield.data.collections.times
import afield.math.autodiff.times
import afield.math.linalg.times
import afield.math.units.times
import afield.math.linalg.vec
import afield.math.linalg.Vector2f
import afield.math.linalg.Vector3f
import afield.math.linalg.Vector4f
import afield.math.linalg.*
import org.lwjgl.system.MemoryStack
import org.lwjgl.vulkan.VK10.*
import org.lwjgl.vulkan.VkImageCopy

Stereo rendering into one packed image: SideBySideStereoTarget draws the scene once per eye and lays the two views side by side, left eye on the left half.

With an OpenXRHmdTarget attached, the per-eye view and projection matrices come from the headset and the packed image can feed its swapchains; without one, an internal stereo camera supplies them, so the same box code renders a stereo pair on a desktop window.

class SideBySideStereoTarget

Side-by-side stereo helper that renders two eye passes into a single RTT (left half / right half).

  • Acts as a sideBySideSource for OpenXR via packed image view.
  • Renders registered modules twice (left/right) using separate RenderToTexture passes, then copies into a packed side-by-side image.
  • Exposes per-eye view/projection matrices. If an OpenXR target is attached, delegates to it; otherwise uses an internal stereo camera.
class SideBySideStereoTarget(
    /** The renderer whose device owns the eye passes and the packed image. Found in the box graph by default. */
    val renderer: Renderer = fromContext(),
    /** Width of one eye's pass in pixels. Defaults to half the swapchain's, so the pair fills the window. */
    val eyeWidth: Int = renderer.swapChain.swapChainExtent.width() / 2,
    /** Height of one eye's pass in pixels. Defaults to the swapchain's full height. */
    val eyeHeight: Int = renderer.swapChain.swapChainExtent.height(),
    /**
     * The headset to take per-eye view and projection matrices from, and to hand [packed] to. Null
     * renders the same stereo pair from [stereoCamera] instead, which is the desktop-window path.
     */
    var openXR: OpenXRHmdTarget? = null
) : afield.graphics.vulkan.Target, HasImageView {

val stereoCamera

The camera the eye passes use when neither poseSource nor openXR supplies a head.

    val stereoCamera = StereoCamera(Camera(renderer).apply {
        // The target resizes this projection per eye; window auto-sizing would overwrite it.
        p.auto = {}
    })

Per-eye offscreen passes.

    private val left = RenderToTexture(renderer) {
        width = eyeWidth
        height = eyeHeight
        colorAttachments = 1
        colorSampled = true
        colorTransferSrc = true
        useDepth = true
        clearColor = vec(0f, 0f, 0f, 1f)
    }

    private val right = RenderToTexture(renderer) {
        width = eyeWidth
        height = eyeHeight
        colorAttachments = 1
        colorSampled = true
        colorTransferSrc = true
        useDepth = true
        clearColor = vec(0f, 0f, 0f, 1f)
    }

val packed

The finished pair, 2 * eyeWidth by eyeHeight, left eye in the left half. This is what a desktop window samples and what OpenXRHmdTarget.copySideBySideToEyes copies halves out of; it carries both transfer usages so either path works.

    val packed = RenderToTexture(renderer) {
        width = eyeWidth * 2
        height = eyeHeight
        colorAttachments = 1
        colorSampled = true
        colorTransferSrc = true
        colorTransferDst = true
        useDepth = false
        clearColor = vec(0f, 0f, 0f, 1f)
    }

override val imageView = packed.imageView

The packed pair, so a SideBySideStereoTarget is itself usable anywhere a HasImageView is — a desktop window, a postprocess pass, an OpenXR side-by-side source.

var currentEyeIndex: Int

Which eye the modules are being drawn for right now: 0 left, 1 right, -1 between the two passes. Module code reads this to pick per-eye data, and it is the eye currentEyeViewMatrix and currentEyeProjectionMatrix answer for.

    var currentEyeIndex: Int = -1
        private set

var poseSource: StereoPoseSource? = null

A remote head to render from, in preference to openXR and to stereoCamera.

Set this and the eye passes draw from whatever the source last latched — see StereoPoseSource for why a remote pose must be latched rather than read per eye.

var renderedPoseFrameId: Long

Identity of the pose the most recent frame was rendered from, or -1 before either the remote source or OpenXR has located a pose.

This is the frame/pose association: whatever ships the rendered image onward can say which pose it was drawn for, so a later stage can reproject it instead of assuming it is current.

    var renderedPoseFrameId: Long = -1L
        private set

var renderedPoseDisplayTime: Double

Predicted display time for renderedPoseFrameId, in seconds in the selected source's clock.

    var renderedPoseDisplayTime: Double = 0.0
        private set

Simple mock controller fallback when OpenXR is absent.

    private val mockController = ControllerState(active = true, poseValid = true, position = vec(0.0, 0.0, 0.0))
    private var mouseControllerPos: Vector2f? = null
    private var mouseControllerDistance: Float = 1f

init

    init {
        // This target renders its eyes once each, then packs them, before the main swapchain.
        renderer.renderList.preTargets["sbs-pack"] = this

        // If an OpenXR target is provided, wire the packed RTT as its side-by-side source.
        openXR?.copySideBySideToEyes(packed)
    }

fun registerModule(name: String, factory: () -> ModulePointer) {

Register a module factory; it will be instantiated once per eye.

    fun registerModule(name: String, factory: () -> ModulePointer) {
        val leftModule = factory()
        val rightModule = factory()
        left += "$name-left" to leftModule
        right += "$name-right" to rightModule
    }

operator fun plusAssign(entry: Pair<String, () -> ModulePointer>) {

    @JvmName("plusAssign_moduleFactory")
    operator fun plusAssign(entry: Pair<String, () -> ModulePointer>) {
        registerModule(entry.first, entry.second)
    }

fun attachToOpenXR(target: OpenXRHmdTarget)

Convenience: attach this packed RTT as the OpenXR side-by-side source.

    fun attachToOpenXR(target: OpenXRHmdTarget) {
        openXR = target
        target.copySideBySideToEyes(packed)
    }

fun currentEyeViewMatrix(out: Matrix4f = Matrix4f()): Matrix4f

Current eye view matrix (a latched poseSource first, then OpenXR, then stereoCamera).

    fun currentEyeViewMatrix(out: Matrix4f = Matrix4f()): Matrix4f {
        poseSource?.takeIf { it.latchedFrameId >= 0 }?.let {
            return it.eyeViewMatrix(currentEyeIndex.coerceIn(0, 1), out)
        }
        val xr = openXR?.takeIf { it.hasEyeViews }
        return if (xr != null && xr.currentEyeIndex >= 0) {
            xr.currentEyeViewMatrix(out)
        } else {
            if (xr != null) {
                val eye = currentEyeIndex.coerceIn(0, 1)
                xr.eyeViewMatrix(eye, out)
            }
            else {
                val eye = currentEyeIndex.coerceIn(0, 1)
                val views = stereoCamera.eyeViewMatrices()
                out.set(views[eye])
            }
        }
    }

fun currentEyeProjectionMatrix(out: Matrix4f = Matrix4f()): Matrix4f

Current eye projection matrix (a latched poseSource first, then OpenXR, then stereoCamera).

    fun currentEyeProjectionMatrix(out: Matrix4f = Matrix4f()): Matrix4f {
        poseSource?.takeIf { it.latchedFrameId >= 0 }?.let {
            return it.eyeProjectionMatrix(currentEyeIndex.coerceIn(0, 1), out)
        }
        val xr = openXR?.takeIf { it.hasEyeViews }
        return if (xr != null && xr.currentEyeIndex >= 0) {
            xr.currentEyeProjectionMatrix(out)
        } else {
            if (xr != null) {
                val eye = currentEyeIndex.coerceIn(0, 1)
                xr.eyeProjectionMatrix(eye, out)
            }
            else {
                val eye = currentEyeIndex.coerceIn(0, 1)
                val projs = stereoCamera.projectionMatrices()
                out.set(projs[eye])
            }
        }
    }

fun controllerState(hand: Hand = Hand.RIGHT, out: ControllerState = ControllerState()): ControllerState

Controller state passthrough to OpenXR when attached; otherwise returns a simple mock right-hand controller positioned via updateMockControllerFromRay or updateMockControllerFromCamera.

    fun controllerState(hand: Hand = Hand.RIGHT, out: ControllerState = ControllerState()): ControllerState {
        // Same precedence as the eye matrices: a latched remote head first, then OpenXR, then the
        // mouse-driven mock. Taking input from the latched frame rather than live keeps it consistent
        // with the image it will act on.
        poseSource?.takeIf { it.latchedFrameId >= 0 }?.let { source ->
            if (source.controller(hand, out)) return out
        }
        val xr = openXR
        if (xr != null) {
            xr.controllerState(hand, out)
            if (out.active && out.poseValid) return out
        }

        val src = mockController
        out.active = src.active
        out.poseValid = src.poseValid
        out.position = src.position
        out.rotation = src.rotation
        out.selectPressed = src.selectPressed
        out.triggerValue = src.triggerValue; out.triggerPressed = src.triggerPressed
        out.squeezeValue = src.squeezeValue; out.squeezePressed = src.squeezePressed
        out.trackpadX = src.trackpadX; out.trackpadY = src.trackpadY
        out.trackpadTouched = src.trackpadTouched; out.trackpadPressed = src.trackpadPressed
        out.thumbstickX = src.thumbstickX; out.thumbstickY = src.thumbstickY
        out.thumbstickTouched = src.thumbstickTouched; out.thumbstickPressed = src.thumbstickPressed
        out.aPressed = src.aPressed; out.bPressed = src.bPressed; out.menuPressed = src.menuPressed
        return out
    }

fun updateMockControllerFromCamera(distanceMeters: Float = 1f)

Position the mock controller 1m along the current camera forward vector.

    fun updateMockControllerFromCamera(distanceMeters: Float = 1f) {
        val cam = stereoCamera.base
        val origin = cam.at
        val forward = cam.forward
        val dir = Vector3f(forward).normalize()
        mockController.position = origin + dir * distanceMeters
        mockController.rotation = lookAlongZ(dir, cam.up)
        mockController.poseValid = true
    }

fun updateMockControllerFromRay(origin: Vector3f, direction: Vector3f, distanceMeters: Float = 1f)

Position the mock controller along an arbitrary ray (origin + direction * distance).

    fun updateMockControllerFromRay(origin: Vector3f, direction: Vector3f, distanceMeters: Float = 1f) {
        val nx = direction.normalize(Vector3f())
        mockController.position = origin + nx * distanceMeters
        mockController.rotation = lookAlongZ(nx, Vector3f(0f, 1f, 0f))
        mockController.poseValid = true
    }

fun enableMouseController(mouse: MouseEvents = renderer.window.mouseEvents ?: MouseEvents(renderer), name: String = "sbs-mouse-controller", distanceMeters: Floa

Attach mouse handlers to drive the mock controller. Re-registering replaces previous handlers.

body — 62 lines, from line 271
    fun enableMouseController(mouse: MouseEvents = renderer.window.mouseEvents ?: MouseEvents(renderer), name: String = "sbs-mouse-controller", distanceMeters: Float = 1f): () -> Unit {
        mouseControllerDistance = distanceMeters

        val updateFromScreen: (MouseEvent) -> Unit = { ev ->
            updateMockControllerFromScreen(ev.pos.x, ev.pos.y, distanceMeters)
            mouseControllerPos = Vector2f(ev.pos.x, ev.pos.y)
        }

        mouse.onMove(name, callback = { ev -> updateFromScreen(ev); true })
        mouse.onDrag(name, callback = { ev -> updateFromScreen(ev); true })

        mouse.onDown("left", name) { ev ->
            updateFromScreen(ev)
            mockController.selectPressed = true
            mockController.triggerPressed = true
            mockController.triggerValue = 1f
            DownResult.Keep
        }
        mouse.onUp("left", name) { _ ->
            mockController.selectPressed = false
            mockController.triggerPressed = false
            mockController.triggerValue = 0f
            true
        }
        return {
            mouse.remove(name)
            mouseControllerPos = null
            mockController.selectPressed = false
            mockController.triggerPressed = false
            mockController.triggerValue = 0f
        }
    }

    private fun updateMockControllerFromScreen(x: Float, y: Float, distanceMeters: Float) {
        val w = renderer.window.width.toFloat().coerceAtLeast(1f)
        val h = renderer.window.height.toFloat().coerceAtLeast(1f)
        val ndcX = (x / w) * 2f - 1f
        val ndcY = 1f - (y / h) * 2f // flip Y so screen top maps to +Y

        val cam = stereoCamera.base
        val origin = cam.at

        val halfFovRad = (cam.p.fov * 0.5).toFloat() // Projection.fov is radians; see Options.fov
        val tanHalfFov = kotlin.math.tan(halfFovRad)
        val aspect = cam.p.aspect.takeIf { it > 0f } ?: (w / h)

        val scale = 2f // widen rays to match on-screen scale
        val dir = Vector3f(cam.forward)
            .fma(-ndcX * aspect * tanHalfFov * scale, cam.right)
            .fma(ndcY * tanHalfFov * scale, cam.up)
            .normalize()

        mockController.position = origin + dir * distanceMeters
        mockController.rotation = lookAlongZ(dir, cam.up)
        mockController.poseValid = true
    }

    private fun lookAlongZ(direction: Vector3f, up: Vector3f): Quaternionf {
        val dir = Vector3f(direction).normalize()
        val upNorm = Vector3f(up).normalize()
        return Quaternionf().lookAlong(dir, upNorm)
    }

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

body — 46 lines, from line 334
    override fun render(command: CommandBuffer, index: Int) {
        // Keep mock controller aligned with camera even if mouse hasn’t moved.
        mouseControllerPos?.let { updateMockControllerFromScreen(it.x, it.y, mouseControllerDistance) }

        // Keep stereo camera projection matched to eye render size.
        stereoCamera.base.p.resize(eyeWidth, eyeHeight)

        // Latch the remote pose once, here, so both eye passes draw from the same head position. A
        // per-eye read would let the pose move between them, which shows up as the two eyes
        // disagreeing rather than as lag.
        poseSource?.let { source ->
            source.latch()
            renderedPoseFrameId = source.latchedFrameId
            renderedPoseDisplayTime = source.latchedDisplayTime
        }
        if (poseSource == null) {
            renderedPoseFrameId = openXR?.locatedViewFrameId ?: -1L
            renderedPoseDisplayTime = (openXR?.locatedViewDisplayTime ?: 0L) / 1_000_000_000.0
        }

        // Render each eye into its own RTT.
        currentEyeIndex = 0
        left.render(command, index)
        currentEyeIndex = 1
        right.render(command, index)
        currentEyeIndex = -1

        // Pack into side-by-side.
        copyEyesIntoPacked(command)
    }

    private fun copyEyesIntoPacked(command: CommandBuffer) {
        // Let each RTT track the barriers as well as its layout. In particular the packed
        // image starts UNDEFINED, and descriptor preparation must see it initialized after copy.
        packed.recordColorTransferDestination(command) { dstImage ->
            fun copyEye(source: RenderToTexture, eyeIndex: Int) {
                source.recordColorTransferSource(command) { srcImage ->
                    recordStereoEyeCopy(command, srcImage, dstImage, eyeIndex, 0, eyeWidth, eyeHeight)
                }
            }
            copyEye(left, 0)
            copyEye(right, 1)
        }
    }

}

Copy one source layer into its eye's half; the RTT owners bracket this with layout barriers.

private fun recordStereoEyeCopy(
    command: CommandBuffer, source: Long, destination: Long,
    eyeIndex: Int, sourceLayer: Int, eyeWidth: Int, eyeHeight: Int
) {
    MemoryStack.stackPush().use { stack ->
        val region = VkImageCopy.calloc(1, stack)
        region[0].srcSubresource().aspectMask(VK_IMAGE_ASPECT_COLOR_BIT)
            .mipLevel(0).baseArrayLayer(sourceLayer).layerCount(1)
        region[0].dstSubresource().aspectMask(VK_IMAGE_ASPECT_COLOR_BIT)
            .mipLevel(0).baseArrayLayer(0).layerCount(1)
        region[0].srcOffset().set(0, 0, 0)
        region[0].dstOffset().set(eyeIndex * eyeWidth, 0, 0)
        region[0].extent().set(eyeWidth, eyeHeight, 1)
        vkCmdCopyImage(command.vkCommandBuffer, source, vkLayout(VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL),
            destination, vkLayout(VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL), region)
    }
}

class StereoCamera(val base: Camera = Camera())

Minimal stereo camera: offsets along the right vector by +/-IPD/2, converges on a focus point, and supports asymmetric frustum offsets through the base projection.

class StereoCamera(val base: Camera = Camera()) {

var ipd: Float = 0.064f

Interpupillary distance in world units, split half to each side of base along its right vector. The default 0.064 is the usual adult average in metres.

var convergenceDistance: Float = 1f

Distance multiplier for the stereo convergence point, measured from the base camera toward its target along the forward vector.

var asymmetricFrustumOffset: Double = 0.0

Shifts each eye's principal point instead of toeing its view in: the left eye gets -asymmetricFrustumOffset and the right +asymmetricFrustumOffset as the base projection's ox, so the units are half-widths of what that projection frames at its match depth. Zero leaves both frusta centred, and convergence is then convergenceDistance's business alone.

data class EyePose(val eye: Camera)

fun eyes(): Pair<EyePose, EyePose>

    fun eyes(): Pair<EyePose, EyePose> {
        val fwd = base.forward
        val right = base.right
        val up = base.up
        val focus = Vector3f(base.at).fma(convergenceDistance * base.at.distance(base.target), fwd)

        val half = ipd * 0.5f
        val leftEyePos = Vector3f(base.at).fma(-half, right)
        val rightEyePos = Vector3f(base.at).fma(+half, right)

        val leftCam = Camera(base.renderer)
        leftCam.lookAt(leftEyePos, focus, up)

        val rightCam = Camera(base.renderer)
        rightCam.lookAt(rightEyePos, focus, up)

        return EyePose(leftCam) to EyePose(rightCam)
    }

fun eyeViewMatrices(): List<Matrix4f>

    fun eyeViewMatrices(): List<Matrix4f> {
        val (l, r) = eyes()
        return listOf(Matrix4f(l.eye.modelView()), Matrix4f(r.eye.modelView()))
    }

fun projectionMatrices(): List<Matrix4f>

    fun projectionMatrices(): List<Matrix4f> {
        val p = base.p
        val prevOx = p.ox
        val prevOy = p.oy
        try {
            p.ox = -asymmetricFrustumOffset
            val left = Matrix4f(p.evaluateUniform())
            p.ox = asymmetricFrustumOffset
            val right = Matrix4f(p.evaluateUniform())
            return listOf(left, right)
        } finally {
            p.ox = prevOx
            p.oy = prevOy
        }
    }
}