FieldVrWorkspace.kt
src/jvmMain/kotlin/afield/media/browser/FieldVrWorkspace.kt · 253 lines
package afield.media.browser
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.Target
import afield.graphics.vulkan.targets.OpenXRHmdTarget.Hand
import afield.graphics.vulkan.targets.MultiviewStereoTarget
import afield.math.linalg.Quaternionf
import afield.math.linalg.Vector3f
import java.util.concurrent.atomic.AtomicBoolean
class FieldVrWorkspace
A live Field document in a world-locked Chromium panel. Construct after the renderer's initial frame, then drive the renderer normally. The desktop preview uses the same page and input path. The supplied URL should come from FieldUiBrowserTexture.fieldDocumentUrl.
class FieldVrWorkspace(
val renderer: Renderer,
val url: String,
width: Int = 2560,
height: Int = 1600,
readyBoxUid: String? = null,
val widthMeters: Float = 2.0f,
val distanceMeters: Float = 1.35f,
cpuBitmapFallback: Boolean = FieldUiBrowserTexture.cpuBitmapFallbackRequired,
headsetInput: Boolean = defaultHeadsetInput(renderer.openXRHmdTarget != null),
pairedInput: Boolean = defaultPairedInput(),
) : AutoCloseable {
companion object
companion object {
internal fun defaultHeadsetInput
ALVR has no WiVRn HID devices; its launcher selects the desktop focus path.
internal fun defaultHeadsetInput(
openXR: Boolean,
mode: String? = System.getenv("FIELD_VR_INPUT"),
osName: String = System.getProperty("os.name"),
): Boolean = when (mode?.trim()?.lowercase()) {
null, "", "auto" -> openXR && osName.startsWith("Linux")
"desktop", "paired" -> false
"wivrn" -> {
require(openXR && osName.startsWith("Linux")) { "WiVRn input requires Linux/OpenXR" }
true
}
else -> error("FIELD_VR_INPUT must be auto, desktop, wivrn or paired (got '$mode')")
}
internal fun defaultPairedInput
FIELD_VR_INPUT=paired: keyboard and mouse sent by a paired headset app (the patched visionOS
ALVR client) to a port this panel opens; see PairedInputSource. Never chosen automatically.
internal fun defaultPairedInput(
mode: String? = System.getenv("FIELD_VR_INPUT"),
osName: String = System.getProperty("os.name"),
): Boolean = (mode?.trim()?.lowercase() == "paired").also {
// The layout path is the same XKB keymap WiVRn input uses.
if (it) require(osName.startsWith("Linux")) { "Paired headset input requires Linux (libxkbcommon)" }
}
}
private val closed = AtomicBoolean(false)
private val recenterRequested = AtomicBoolean(false)
private val keyboardFocusRequested = AtomicBoolean(false)
private var previousTime = System.nanoTime()
private var installedGeneration = -1L
private var desktopPointer = renderer.openXRHmdTarget == null
private var headsetPointer = false
private var reportedInputStatus: String? = null
var headsetDevices: WiVRnBrowserInput?
var headsetDevices: WiVRnBrowserInput? = null
private set
private var previousPresses = emptySet<String>()
private var previousGrips = emptySet<String>()
private val rayAnchors = mutableMapOf<String, Pair<Vector3f, Vector3f>>()
@Volatile var keyboardReady
@Volatile var keyboardReady = false
private set
@Volatile var keyboardError: String?
@Volatile var keyboardError: String? = null
private set
init
init {
require(widthMeters > 0f && distanceMeters > 0f) { "Panel dimensions must be positive" }
check(renderer.openXRHmdTarget?.hasEyeViews != false) {
"Render the initial OpenXR frame before opening the VR workspace"
}
}
val browser
val browser = FieldUiBrowserTexture(renderer, url, width, height,
readyBoxUid = readyBoxUid, cpuBitmapFallback = cpuBitmapFallback)
val stereo: MultiviewStereoTarget
val panel: PuppetBrowserVrPanel
val input: PuppetBrowserVrInput
val grab: PuppetBrowserVrGrab
val keyboard
val keyboard = FieldVrKeyboard(browser,
recenter = { recenterRequested.set(true) },
focusKeyboard = { keyboardFocusRequested.set(true) })
val companion: PuppetBrowserWindowPanel
init
body — 47 lines, from line 93
init {
val xr = renderer.openXRHmdTarget
// Insert before the multiview scene: upload and input belong to the frame, once for both eyes.
renderer.renderList.preTargets["field-vr-browser"] = Target { command, _ ->
browser.prepareSampledImagesFor(command)
update()
}
val eye = xr?.let { renderer.runOnQueueThread { it.sideBySideRTTParameters() } }
stereo = MultiviewStereoTarget(renderer, eyeWidth = eye?.width?.div(2) ?: 1200,
eyeHeight = eye?.height ?: 1200, openXR = xr)
panel = PuppetBrowserVrPanel(renderer, stereo, browser, widthMeters, distanceMeters,
name = "field-vr-ide", automaticInteraction = false)
panel.build()
input = PuppetBrowserVrInput(panel.panel)
grab = PuppetBrowserVrGrab(panel.panel)
stereo.stereoCamera.base.lookAt(Vector3f(), Vector3f(0f, 0f, -1f))
recenter()
companion = PuppetBrowserWindowPanel(renderer, browser, name = "field-vr-companion")
companion.input.focusFollowsWindow = true
companion.input.onPointerActivity = {
input.cancel()
grab.cancel()
headsetDevices?.releasePointer()
headsetPointer = false
desktopPointer = true
rayAnchors.clear()
}
companion.build()
require(!(headsetInput && pairedInput)) { "Choose WiVRn or paired headset input, not both" }
val pointerActivity = {
if (!headsetPointer) {
val hit = panel.lastHit
input.cancel()
grab.cancel()
companion.input.releasePointer()
if (hit?.onPanel == true) headsetDevices?.placeCursor(hit.u, hit.v)
headsetPointer = true
desktopPointer = false
rayAnchors.clear()
}
}
val otherModifiers = { companion.input.uv.modifiers }
if (headsetInput) headsetDevices = WiVRnBrowserInput(browser, browser.spec,
pointerActivity = pointerActivity, otherModifiers = otherModifiers)
else if (pairedInput) headsetDevices = WiVRnBrowserInput(browser, browser.spec,
PairedInputSource.fromEnvironment(), pointerActivity, otherModifiers)
}
fun recenter()
Keep the panel in the room; call this to bring it in front of the current head position.
body — 93 lines, from line 142
fun recenter() {
// A toolbar recenter ends any current attachment before setting a new room transform.
grab.cancel()
val eye = Vector3f()
val orientation = Quaternionf()
val direction = Vector3f(0f, 0f, -1f)
if (renderer.openXRHmdTarget?.headPose(eye, orientation) == true) orientation.transform(direction)
// An upright panel remains usable after glancing down at the keyboard or tilting the head.
direction.y = 0f
if (direction.lengthSquared() < 1e-6f) direction.set(0f, 0f, -1f)
direction.normalize()
val scale = panel.panel.model.transformDirection(Vector3f(1f, 0f, 0f)).length()
panel.panel.faceToward(Vector3f(eye).fma(distanceMeters, direction), eye).scale(scale)
}
private fun update() {
if (closed.get()) return
if (keyboardFocusRequested.getAndSet(false)) {
if (headsetDevices != null) browser.focus() else focusPhysicalKeyboard()
}
if (recenterRequested.getAndSet(false)) recenter()
val now = System.nanoTime()
val elapsed = (now - previousTime) / 1e9
previousTime = now
headsetDevices?.poll(deliver = browser.pageReady && !browser.state.loading)
val xr = renderer.openXRHmdTarget
val pointers = if (xr == null) emptyList() else {
// Controller aim only. Tracked hands resting on a keyboard/mouse must not
// reclaim its page cursor through hover movement or an incidental pinch.
listOf(Hand.RIGHT, Hand.LEFT).mapNotNull { hand ->
input.controller("$hand-controller", xr.controllerState(hand), elapsed)
}
}
val worldPress = pointers.any {
((it.pressed && it.id !in previousPresses) || (it.gripPressed && it.id !in previousGrips)) &&
panel.panel.raycast(it.origin, it.direction)?.onPanel == true
}
val worldMotion = pointers.any { pointer ->
val anchor = rayAnchors[pointer.id]
val moved = anchor != null && (anchor.first.distance(pointer.origin) > 0.02f ||
anchor.second.dot(pointer.direction) < 0.9994f)
if (anchor == null || moved) rayAnchors[pointer.id] = Vector3f(pointer.origin) to Vector3f(pointer.direction)
moved && panel.panel.raycast(pointer.origin, pointer.direction)?.onPanel == true
}
// A moving controller may resume hover, but cannot interrupt a physical mouse drag.
if ((worldPress || (worldMotion && companion.input.uv.buttons == 0 && headsetDevices?.uv?.buttons.orZero() == 0)) &&
(desktopPointer || headsetPointer)) {
companion.input.releasePointer()
headsetDevices?.releasePointer()
desktopPointer = false
headsetPointer = false
}
previousPresses = pointers.filter { it.pressed }.map { it.id }.toSet()
previousGrips = pointers.filter { it.gripPressed }.map { it.id }.toSet()
val worldPointer = !desktopPointer && !headsetPointer
val dragging = grab.update(pointers, enabled = worldPointer)
// Physical Shift/Ctrl also apply to controller clicks (selection, links and canvas tools).
panel.panel.uv.modifiers = companion.input.uv.modifiers + (headsetDevices?.modifiers ?: emptySet())
val hit = input.update(pointers, enabled = worldPointer && !dragging)
val pageCursor = if (headsetPointer) headsetDevices?.uv?.position else if (desktopPointer) companion.input.lastPointerUv else null
val cursor = pageCursor?.let { (u, v) ->
val world = panel.panel.model.transformPosition(Vector3f(
((u - 0.5) * 2 * panel.panel.halfWidth).toFloat(),
((0.5 - v) * 2 * panel.panel.halfHeight).toFloat(), 0f))
PuppetBrowserPanel.Hit(world, u, v, u in 0.0..1.0 && v in 0.0..1.0)
} ?: hit
val pressed = when {
headsetPointer -> headsetDevices?.uv?.buttons.orZero() != 0
desktopPointer -> companion.input.uv.buttons != 0
else -> panel.panel.uv.buttons != 0
}
panel.showPointer(cursor, pressed)
val generation = browser.state.navigationGeneration
if (browser.pageReady && !browser.state.loading && installedGeneration != generation) {
installedGeneration = generation
keyboardReady = false
keyboard.install().whenComplete { _, error ->
keyboardError = error?.message
reportedInputStatus = null
keyboardReady = error == null
}
}
val status = headsetDevices?.status ?: "Desktop companion keyboard / mouse"
if (keyboardReady && status != reportedInputStatus) {
reportedInputStatus = status
keyboard.reportInputStatus(status)
}
}
private fun focusPhysicalKeyboard() {
org.lwjgl.glfw.GLFW.glfwFocusWindow(renderer.window.windowHandle)
companion.input.takeFocus()
}
override fun close()
override fun close() {
if (!closed.compareAndSet(false, true)) return
renderer.renderList.preTargets.remove("field-vr-browser")
renderer.renderList.preTargets.remove("stereo-multiview", stereo)
renderer.openXRHmdTarget?.clearEyeSource(stereo.layered)
input.cancel()
grab.cancel()
headsetDevices?.close()
keyboard.close()
renderer.runOnQueueThread { org.lwjgl.vulkan.VK10.vkDeviceWaitIdle(renderer.device.vkDevice) }
companion.close()
panel.cleanup()
browser.close()
}
private fun Int?.orZero(): Int = this ?: 0
}