Skip to content

PuppetBrowserVrPanel.kt

src/jvmMain/kotlin/afield/media/browser/PuppetBrowserVrPanel.kt · 207 lines

package afield.media.browser

import afield.graphics.vulkan.Renderer
import afield.graphics.render.Mesh
import afield.graphics.render.Module
import afield.graphics.render.parameters
import afield.graphics.render.pointer
import afield.graphics.vulkan.targets.MultiviewStereoTarget
import afield.graphics.texture.TextureSampler
import afield.graphics.shader.fromViews
import afield.math.linalg.Matrix4f
import afield.math.linalg.Vector3f
import afield.math.linalg.Vector4f
import org.lwjgl.vulkan.VK10.VK_FORMAT_B8G8R8A8_SRGB
import kotlin.math.abs

Shows a puppet browser page as a clickable, scrollable quad in VR or in the desktop stereo stand-in.

class PuppetBrowserVrPanel

Presents a PuppetBrowser as an aspect-correct, interactive world-space quad.

MultiviewStereoTarget supplies either real OpenXR poses/controllers or its mouse-driven desktop stand-in. Trigger/select becomes a primary click; the touched thumbstick or trackpad scrolls the page. Keyboard events can independently be routed with PuppetBrowserWindowInput.

class PuppetBrowserVrPanel(
    val renderer: Renderer,
    val stereo: MultiviewStereoTarget,
    val browser: PuppetBrowser,
    val widthMeters: Float = 1.6f,
    val distanceMeters: Float = 1.7f,
    val name: String = "puppet-browser-panel",
    val automaticInteraction: Boolean = true,
) {

val aspect: Float = browser.spec.width.toFloat() / browser.spec.height.toFloat()

val panel = PuppetBrowserPanel(browser, widthMeters * 0.5f, widthMeters * 0.5f / aspect)

var lastHit: PuppetBrowserPanel.Hit?

    @Volatile
    var lastHit: PuppetBrowserPanel.Hit? = null
        private set

    @Volatile
    private var pointerPressed = false

    private val sampler = TextureSampler(renderer, mipLevels = 1, anisotropyEnable = true)
    private var removeDesktopScroll: (() -> Unit)? = null
    private var removeDesktopMouse: (() -> Unit)? = null

init

    init {
        require(widthMeters > 0f) { "widthMeters must be positive" }
        require(distanceMeters > 0f) { "distanceMeters must be positive" }
    }

fun build()

body — 63 lines, from line 56
    fun build() {
        stereo.stereoCamera.base.at.set(0f, 0f, 0f)
        stereo.stereoCamera.base.target.set(0f, 0f, 1f)
        // Match the desktop camera to each eye layer. OpenXR supplies its own projection.
        val eyeAspect = stereo.eyeWidth.toFloat() / stereo.eyeHeight.toFloat()
        stereo.stereoCamera.base.p.auto = {
            stereo.stereoCamera.base.p.matchDepth = stereo.stereoCamera.base.p.autoMatchDistance()
            stereo.stereoCamera.base.p.setAspect(eyeAspect)
        }
        stereo.stereoCamera.base.setAspect(eyeAspect)
        placeInitialPanel(panel, stereo.stereoCamera.base.at, distanceMeters)

        val halfWidth = panel.halfWidth
        val halfHeight = panel.halfHeight
        val positions = floatArrayOf(
            -halfWidth, +halfHeight, 0f,
            -halfWidth, -halfHeight, 0f,
            +halfWidth, -halfHeight, 0f,
            +halfWidth, +halfHeight, 0f,
        )
        val uvs = floatArrayOf(0f, 0f, 0f, 1f, 1f, 1f, 1f, 0f)
        val indices = intArrayOf(0, 1, 2, 0, 2, 3)

        stereo.registerModule(name) {
            val module = Module(parameters(renderer) {
                this.name = this@PuppetBrowserVrPanel.name
                stereo.configure(this)
                color = 1
                depth = 1
                depthTest = true
                colorFormats = listOf(VK_FORMAT_B8G8R8A8_SRGB)
                vertex = vertexShader
                fragment = fragmentShader
                textureResolver = { texture ->
                    if (texture.name == "browserTexture") {
                        fromViews(listOf(browser), texture.name, sampler)
                    } else null
                }
            })

            val pointer = module.pointer()
            val mesh = Mesh(renderer, pointer)
            mesh.resize(4, 2)
            pointer.current.meshes[name] = mesh
            mesh.v[0]!!.mapAndWrite { positions.forEach(::put); rewind() }
            mesh.v[1]!!.mapAndWrite { uvs.forEach(::put); rewind() }
            mesh.t!!.mapAndWrite_ints { indices.forEach(::put); rewind() }

            // Update sequences are prefetched; input must run at an actual draw boundary instead.
            module.before["browser-input"] = afield.graphics.vulkan.Target { _, _ ->
                if (automaticInteraction) updateInteraction()
                mesh.push["model"] = Matrix4f(panel.model) // panel local-to-world transform
                val hit = lastHit
                mesh.push["pointer"] = Vector4f(
                    hit?.u?.toFloat() ?: -1f,
                    hit?.v?.toFloat() ?: -1f,
                    if (hit?.onPanel == true) 1f else 0f,
                    if (pointerPressed) 1f else 0f,
                ) // pointer UV, hover and pressed state
            }
            pointer
        }
    }

fun enableDesktopInteraction()

Install the desktop preview's mouse ray. A headset controller automatically supersedes it.

    fun enableDesktopInteraction() {
        removeDesktopMouse?.invoke()
        removeDesktopMouse = stereo.enableMouseController(name = "$name-mouse", distanceMeters = 0.001f)
        removeDesktopScroll?.invoke()
        removeDesktopScroll = renderer.window.addScrollListener { deltaX, deltaY ->
            lastHit?.takeIf { it.onPanel }?.let { hit ->
                panel.uv.wheel(-deltaX * 40.0, -deltaY * 40.0, hit.u, hit.v)
            }
        }
    }

    private fun updateInteraction() {
        val controller = stereo.controllerState()
        val axis = when {
            controller.thumbstickTouched || abs(controller.thumbstickY) > 0.08f -> controller.thumbstickY
            controller.trackpadTouched || abs(controller.trackpadY) > 0.08f -> controller.trackpadY
            else -> 0f
        }
        pointerPressed = controller.selectPressed || controller.triggerPressed
        lastHit = panel.interact(controller, scrollY = -axis * 22.0,
            localForward = Vector3f(0f, 0f, if (stereo.openXR == null) 1f else -1f))
    }

fun showPointer(hit: PuppetBrowserPanel.Hit?, pressed: Boolean)

Publish a cursor driven by an external input arbiter, shared by both eye passes.

    fun showPointer(hit: PuppetBrowserPanel.Hit?, pressed: Boolean) {
        lastHit = hit
        pointerPressed = pressed
    }

fun cleanup()

    fun cleanup() {
        panel.releasePointer()
        removeDesktopMouse?.invoke()
        removeDesktopMouse = null
        removeDesktopScroll?.invoke()
        removeDesktopScroll = null
        sampler.cleanup()
    }

companion object

internal fun placeInitialPanel(panel: PuppetBrowserPanel, eye: Vector3f, distanceMeters: Float)

body — 47 lines, from line 160
        internal fun placeInitialPanel(panel: PuppetBrowserPanel, eye: Vector3f, distanceMeters: Float) {
            // The initial camera looks along +Z, so its screen-right is -X. A +X panel basis
            // faces away from the viewer and mirrors browser text. Orient the model, not the UVs,
            // so texture sampling and raycast input continue to share the same coordinates.
            panel.faceToward(Vector3f(eye).add(0f, 0f, distanceMeters), eye)
        }

        private val vertexShader = """
            #version 460
            ${MultiviewStereoTarget.VIEWS_GLSL}
            layout(location = 0) in vec3 inPosition;
            layout(location = 1) in vec2 inUV;
            layout(location = 0) out vec2 fragUV;
            layout(push_constant) uniform Push {
                mat4 model; // panel local-to-world transform
                vec4 pointer; // cursor UV, visibility and pressed state
            } push;
            void main() {
                fragUV = inUV;
                gl_Position = stereoViewProjection() * push.model * vec4(inPosition, 1.0);
            }
        """.trimIndent()

        private val fragmentShader = """
            #version 460
            layout(location = 0) in vec2 fragUV;
            layout(location = 0) out vec4 outColor;
            layout(set = 0, binding = 0) uniform sampler2D browserTexture;
            layout(push_constant) uniform Push {
                mat4 model; // panel local-to-world transform
                vec4 pointer; // cursor UV, visibility and pressed state
            } push;
            void main() {
                vec4 browser = texture(browserTexture, fragUV);
                float edge = min(min(fragUV.x, 1.0 - fragUV.x), min(fragUV.y, 1.0 - fragUV.y));
                vec3 rgb = edge < 0.0025 ? vec3(0.18) : browser.rgb;
                vec2 texSize = vec2(textureSize(browserTexture, 0));
                vec2 delta = (fragUV - push.pointer.xy) * vec2(texSize.x / texSize.y, 1.0);
                float ringDistance = abs(length(delta) - 0.012);
                float ring = 1.0 - smoothstep(0.0015, 0.0035, ringDistance);
                vec3 pointerColor = mix(vec3(0.95), vec3(0.25, 0.65, 1.0), push.pointer.w);
                rgb = mix(rgb, pointerColor, ring * push.pointer.z);
                outColor = vec4(rgb, 1.0);
            }
        """.trimIndent()
    }
}