MultiviewStereoTarget.kt
src/jvmMain/kotlin/afield/graphics/vulkan/targets/MultiviewStereoTarget.kt · 343 lines
package afield.graphics.vulkan.targets
import afield.document.context.fromContext
import afield.graphics.render.Mesh
import afield.graphics.render.Module
import afield.graphics.render.ModulePointer
import afield.graphics.render.Parameters
import afield.graphics.render.parameters
import afield.graphics.render.pointer
import afield.graphics.shader.fromViews
import afield.graphics.texture.HasImageView
import afield.graphics.texture.TextureSampler
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.Target
import afield.graphics.vulkan.buffers.BufferParameters
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.vulkan.targets.OpenXRHmdTarget.ControllerState
import afield.graphics.vulkan.targets.OpenXRHmdTarget.Hand
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.math.linalg.*
class MultiviewStereoTarget
One scene pass into two array layers, with one shared per-frame eye UBO. No packed intermediate.
class MultiviewStereoTarget(
val renderer: Renderer = fromContext(),
val eyeWidth: Int = renderer.window.width / 2,
val eyeHeight: Int = renderer.window.height,
val openXR: OpenXRHmdTarget? = null,
) : Target, HasImageView {
val stereoCamera = StereoCamera(Camera(renderer).apply { p.auto = {} })
val layered
val layered = RenderToTexture(renderer) {
width = eyeWidth
height = eyeHeight
colorAttachments = 1
colorLayerCount = 2
multiviewViewMask = 0b11
colorSampled = true
colorTransferSrc = true
useDepth = true
clearColor = vec(0f, 0f, 0f, 1f)
}
override val imageView
override val imageView
get() = layered.imageView
private var eyeUniforms: BufferParameters? = null
private val mockController = ControllerState(active = true, poseValid = true, position = vec(0.0, 0.0, 0.0))
private var mouseControllerPos: Vector2f? = null
private var mouseControllerDistance = 1f
init
init {
require(renderer.device.supportsMultiview) { "MultiviewStereoTarget requires Vulkan multiview" }
renderer.renderList.preTargets["stereo-multiview"] = this
openXR?.copyLayersToEyes(layered)
}
fun configure(p: Parameters)
Apply the layered pass settings and bind this target's single shared eye buffer.
fun configure(p: Parameters) {
p.run { +layered }
val previous = p.uboResolver
p.uboResolver = { requested ->
if (requested.name == "StereoViews") {
require(requested.source?.size == 224) { "StereoViews must be 224 bytes" }
eyeUniforms
?: requested.also {
it.perFrame = true
it.hosted()
eyeUniforms = it
}
} else previous(requested)
}
}
fun registerModule(name: String, factory: () -> ModulePointer) {
fun registerModule(name: String, factory: () -> ModulePointer) {
layered += name to factory()
}
fun registerModule(name: String, module: ModulePointer)
fun registerModule(name: String, module: ModulePointer) {
layered += name to module
}
operator fun plusAssign(entry: Pair<String, ModulePointer>)
@JvmName("plusAssign_pointer")
operator fun plusAssign(entry: Pair<String, ModulePointer>) = registerModule(entry.first, entry.second)
fun eyeViewMatrix(eye: Int, out: Matrix4f = Matrix4f()): Matrix4f
fun eyeViewMatrix(eye: Int, out: Matrix4f = Matrix4f()): Matrix4f =
openXR?.eyeViewMatrix(eye, out) ?: out.set(stereoCamera.eyeViewMatrices()[eye])
fun eyeProjectionMatrix(eye: Int, out: Matrix4f = Matrix4f()): Matrix4f
fun eyeProjectionMatrix(eye: Int, out: Matrix4f = Matrix4f()): Matrix4f =
openXR?.eyeProjectionMatrix(eye, out) ?: out.set(stereoCamera.projectionMatrices()[eye])
val beforeScene = linkedMapOf<String, afield.graphics.vulkan.Target>()
GPU work that needs the current predicted eyes, recorded once before the stereo scene.
override fun render(command: CommandBuffer, index: Int)
override fun render(command: CommandBuffer, index: Int) {
mouseControllerPos?.let { updateMockControllerFromScreen(it.x, it.y, mouseControllerDistance) }
stereoCamera.base.p.resize(eyeWidth, eyeHeight)
eyeUniforms?.toBuffer(renderer)?.let { buffer ->
buffer.mapAndWrite_bytes {
for (eye in 0..1) {
val projection = eyeProjectionMatrix(eye)
val view = eyeViewMatrix(eye)
Matrix4f(projection).mul(view).get(eye * 64, this)
val inverse = Matrix4f(view).invert()
putFloat(128 + eye * 16, inverse.m00)
putFloat(132 + eye * 16, inverse.m01)
putFloat(136 + eye * 16, inverse.m02)
putFloat(140 + eye * 16, 0f)
putFloat(160 + eye * 16, inverse.m10)
putFloat(164 + eye * 16, inverse.m11)
putFloat(168 + eye * 16, inverse.m12)
putFloat(172 + eye * 16, 0f)
putFloat(192 + eye * 16, projection.m00)
putFloat(196 + eye * 16, projection.m11)
putFloat(200 + eye * 16, eyeWidth.toFloat())
putFloat(204 + eye * 16, eyeHeight.toFloat())
}
}
buffer.clean(false, null)
}
beforeScene.values.forEach { it.render(command, index) }
layered.render(command, index)
}
fun mirrorToScreen
A read-only desktop view of the actual array image, optionally displaying both layers.
fun mirrorToScreen(
windowIndex: Int = 0,
name: String = "stereo-mirror",
stereoPair: () -> Boolean = { false },
): ModulePointer {
val window = renderer.windows[windowIndex]
val sampler = TextureSampler(renderer, mipLevels = 1, anisotropyEnable = false)
val module = Module(
parameters(renderer) {
this.name = name
depth = 1
depthTest = false
depthWrite = false
imageFormat = renderer.windowContext(windowIndex).swapChain.surfaceFormat.imageFormat
vertex = MIRROR_VERTEX
fragment = MIRROR_FRAGMENT
textureResolver = { texture ->
if (texture.name == "scene") fromViews(listOf(layered), texture.name, sampler) else null
}
},
)
val pointer = module.pointer()
val mesh = Mesh(renderer, pointer)
mesh.resize(3, 0)
module.meshes[name] = mesh
module.before["mirror-fit"] = Target { _, _ ->
val ratio =
(window.width.toFloat() / window.height.coerceAtLeast(1)) /
(eyeWidth.toFloat() / eyeHeight * if (stereoPair()) 2f else 1f)
mesh.push["mirrorFit"] = Vector4f(
if (ratio > 1f) ratio else 1f,
if (ratio < 1f) 1f / ratio else 1f,
if (stereoPair()) 1f else 0f,
0f,
) // aspect fit and stereo-pair selection
}
renderer.renderTargetFor(windowIndex) += name to pointer
return pointer
}
fun controllerState(hand: Hand = Hand.RIGHT, out: ControllerState = ControllerState()): ControllerState
fun controllerState(hand: Hand = Hand.RIGHT, out: ControllerState = ControllerState()): ControllerState {
val xr = openXR
if (xr != null) return xr.controllerState(hand, 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)
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)
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
body — 79 lines, from line 211
fun enableMouseController(
mouse: MouseEvents = renderer.window.mouseEvents ?: MouseEvents(renderer),
name: String = "sbs-multiview-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
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
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)
}
companion object
companion object {
val VIEWS_GLSL
Binding 7 is reserved for eye data; the same allocation is shared by scene modules.
body — 50 lines, from line 293
val VIEWS_GLSL =
"""
#extension GL_EXT_multiview : require
layout(std140, set=0, binding=7) uniform StereoViews {
mat4 stereoVpL; // left-eye projection/view
mat4 stereoVpR; // right-eye projection/view
vec4 stereoRightL; // left-eye camera right
vec4 stereoRightR; // right-eye camera right
vec4 stereoUpL; // left-eye camera up
vec4 stereoUpR; // right-eye camera up
vec4 stereoProjectionL; // projection XY and viewport size
vec4 stereoProjectionR; // projection XY and viewport size
};
mat4 stereoViewProjection() { return gl_ViewIndex==0 ? stereoVpL : stereoVpR; }
vec3 stereoCameraRight() { return (gl_ViewIndex==0 ? stereoRightL : stereoRightR).xyz; }
vec3 stereoCameraUp() { return (gl_ViewIndex==0 ? stereoUpL : stereoUpR).xyz; }
vec4 stereoProjection() { return gl_ViewIndex==0 ? stereoProjectionL : stereoProjectionR; }
"""
.trimIndent()
private val MIRROR_VERTEX =
"""
#version 450
layout(location=0) out vec2 uv;
void main() {
vec2 q=vec2((gl_VertexIndex<<1)&2,gl_VertexIndex&2);
uv=vec2(q.x,1.0-q.y);
gl_Position=vec4(q*2.0-1.0,0,1);
}
"""
.trimIndent()
private val MIRROR_FRAGMENT =
"""
#version 450
layout(binding=0) uniform sampler2DArray scene;
layout(push_constant) uniform Push {
vec4 mirrorFit; // aspect fit and stereo pair
} pc;
layout(location=0) in vec2 uv;
layout(location=0) out vec4 color;
void main() {
vec2 q=(uv-0.5)*pc.mirrorFit.xy+0.5;
if(any(lessThan(q,vec2(0))) || any(greaterThan(q,vec2(1)))) { color=vec4(0,0,0,1);return; }
float layer=0.0;
if(pc.mirrorFit.z>0.5) { layer=q.x<0.5?0.0:1.0;q.x=fract(q.x*2.0); }
color=vec4(texture(scene,vec3(q,layer)).rgb,1.0);
}
"""
.trimIndent()
}
}