BinocularGazeIntersection.kt
src/jvmMain/kotlin/afield/graphics/vulkan/targets/BinocularGazeIntersection.kt · 173 lines
package afield.graphics.vulkan.targets
import afield.math.linalg.Vec3
import kotlin.math.acos
import kotlin.math.sin
import kotlin.math.sqrt
Estimates where the two eyes converge from a pair of per-eye gaze rays.
BinocularGazeIntersection takes the closest approach of the left and right rays and rejects
depths it cannot trust, so a caller can place an object at the point the viewer is looking at.
BinocularGazeIntersectionTest covers it.
class BinocularGazeIntersection
Closest approach of two forward eye rays, in metres, with explicit rejection of unreliable depth. Real rays can be skew: position is the midpoint of leftPoint and rightPoint, and separation is their gap. Read valid before placing an object there.
update consumes HTC poses already located in OpenXR LOCAL space. It rotates each eye's -Z axis by that eye's orientation; it does NOT apply a head pose. Results stay in LOCAL space. Apply your chosen worldFromLocal transform once to move them into application world space. All vectors are reused; copy them when retaining a sample across frames.
class BinocularGazeIntersection(
/**
* The smallest angle between the two rays that still gives a depth, in degrees. Nearer to
* parallel than this the closest-approach solve is ill-conditioned, so [status] becomes
* [Status.PARALLEL] and no point is written. Must be above 0 and below 90.
*/
val minimumAngleDegrees: Double = 0.25,
/**
* How far [leftPoint] and [rightPoint] may be apart, in metres, before the rays count as
* missing each other and [status] becomes [Status.RAYS_MISS]. Skew rays always leave some gap;
* this is how much of it to forgive.
*/
val maximumSeparation: Double = 0.03,
/** Nearest accepted depth along either ray, in metres. Closer gives [Status.OUT_OF_RANGE]. */
val minimumDistance: Double = 0.05,
/** Farthest accepted depth along either ray, in metres. Beyond it, [Status.OUT_OF_RANGE]. */
val maximumDistance: Double = 8.0,
) {
enum class Status
enum class Status { NO_TRACKING, INVALID_RAY, PARALLEL, BEHIND_EYES, OUT_OF_RANGE, RAYS_MISS, VALID }
init
init {
require(minimumAngleDegrees > 0 && minimumAngleDegrees < 90)
require(maximumSeparation.isFinite() && maximumSeparation >= 0)
require(minimumDistance.isFinite() && minimumDistance >= 0)
require(maximumDistance.isFinite() && maximumDistance > minimumDistance)
}
var status
Why the last update or solve accepted or rejected a depth. Status.NO_TRACKING until the first call.
var status = Status.NO_TRACKING
private set
val valid: Boolean get() = status == Status.VALID
True only for Status.VALID — the one check to make before using position.
var raysValid
True when both rays were finite, long enough to normalize and had distinct origins, so leftDirection and rightDirection are meaningful. The convergence tests come after this, so a pair of usable rays that converge badly leaves this true while valid is false.
var raysValid = false
private set
val position = Vec3()
Midpoint of leftPoint and rightPoint — where to put an object the viewer is looking at.
val leftPoint = Vec3()
The point on the left eye's ray closest to the right eye's ray.
val rightPoint = Vec3()
The point on the right eye's ray closest to the left eye's ray.
val leftDirection = Vec3()
Unit forward direction of the left eye's ray, as the last solve normalized it.
val rightDirection = Vec3()
Unit forward direction of the right eye's ray, as the last solve normalized it.
var leftDistance
Distance from the left eye to leftPoint, in metres. Negative means the convergence is behind the eye.
var leftDistance = Double.NaN
private set
var rightDistance
Distance from the right eye to rightPoint, in metres. Negative means behind the eye.
var rightDistance = Double.NaN
private set
var separation
Gap between leftPoint and rightPoint, in metres — how far the two rays missed by.
var separation = Double.NaN
private set
var angleDegrees
Angle between the two rays, in degrees. Written before the convergence tests, so it is readable even when the rays were too close to parallel to intersect.
var angleDegrees = Double.NaN
private set
private val minimumSinSquared = sin(Math.toRadians(minimumAngleDegrees)).let { it * it }
fun invalidate()
fun invalidate() {
status = Status.NO_TRACKING
raysValid = false
leftDistance = Double.NaN
rightDistance = Double.NaN
separation = Double.NaN
angleDegrees = Double.NaN
}
fun update(eyes: HtcEyeTrackingState?): Boolean
fun update(eyes: HtcEyeTrackingState?): Boolean {
if (eyes == null || !eyes.left.gazeValid || !eyes.right.gazeValid) {
invalidate()
return false
}
eyes.left.orientation.transform(leftDirection.set(0f, 0f, -1f))
eyes.right.orientation.transform(rightDirection.set(0f, 0f, -1f))
return solve(eyes.left.position, leftDirection, eyes.right.position, rightDirection)
}
fun solve(leftOrigin: Vec3, leftRay: Vec3, rightOrigin: Vec3, rightRay: Vec3): Boolean
Both origins and directions must use the same coordinate system; directions may be unnormalized.
body — 50 lines, from line 123
fun solve(leftOrigin: Vec3, leftRay: Vec3, rightOrigin: Vec3, rightRay: Vec3): Boolean {
invalidate()
status = Status.INVALID_RAY
if (!finite(leftOrigin) || !finite(rightOrigin) || !finite(leftRay) || !finite(rightRay)) return false
val leftLength = sqrt(lengthSquared(leftRay))
val rightLength = sqrt(lengthSquared(rightRay))
if (leftLength < 1e-12 || rightLength < 1e-12) return false
val ux = leftRay.x / leftLength; val uy = leftRay.y / leftLength; val uz = leftRay.z / leftLength
val vx = rightRay.x / rightLength; val vy = rightRay.y / rightLength; val vz = rightRay.z / rightLength
val wx = leftOrigin.x.toDouble() - rightOrigin.x
val wy = leftOrigin.y.toDouble() - rightOrigin.y
val wz = leftOrigin.z.toDouble() - rightOrigin.z
if (wx * wx + wy * wy + wz * wz < 1e-12) return false
leftDirection.set(ux.toFloat(), uy.toFloat(), uz.toFloat())
rightDirection.set(vx.toFloat(), vy.toFloat(), vz.toFloat())
raysValid = true
val b = (ux * vx + uy * vy + uz * vz).coerceIn(-1.0, 1.0)
angleDegrees = Math.toDegrees(acos(b))
val denominator = 1.0 - b * b
if (denominator < minimumSinSquared) {
status = Status.PARALLEL
return false
}
val d = ux * wx + uy * wy + uz * wz
val e = vx * wx + vy * wy + vz * wz
leftDistance = (b * e - d) / denominator
rightDistance = (e - b * d) / denominator
val lx = leftOrigin.x + leftDistance * ux
val ly = leftOrigin.y + leftDistance * uy
val lz = leftOrigin.z + leftDistance * uz
val rx = rightOrigin.x + rightDistance * vx
val ry = rightOrigin.y + rightDistance * vy
val rz = rightOrigin.z + rightDistance * vz
leftPoint.set(lx.toFloat(), ly.toFloat(), lz.toFloat())
rightPoint.set(rx.toFloat(), ry.toFloat(), rz.toFloat())
position.set(((lx + rx) * 0.5).toFloat(), ((ly + ry) * 0.5).toFloat(), ((lz + rz) * 0.5).toFloat())
separation = sqrt((lx - rx) * (lx - rx) + (ly - ry) * (ly - ry) + (lz - rz) * (lz - rz))
status = when {
leftDistance <= 0 || rightDistance <= 0 -> Status.BEHIND_EYES
leftDistance < minimumDistance || rightDistance < minimumDistance ||
leftDistance > maximumDistance || rightDistance > maximumDistance -> Status.OUT_OF_RANGE
separation > maximumSeparation -> Status.RAYS_MISS
else -> Status.VALID
}
return valid
}
private fun finite(v: Vec3) = v.x.isFinite() && v.y.isFinite() && v.z.isFinite()
private fun lengthSquared(v: Vec3) = v.x.toDouble() * v.x + v.y.toDouble() * v.y + v.z.toDouble() * v.z
}