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()
}
}