Skip to content

CameraPose.kt

src/jvmMain/kotlin/afield/graphics/vulkan/util/CameraPose.kt · 205 lines · cited by Keyframes: capturing, persisting and replaying live state

package afield.graphics.vulkan.util

Rigid camera poses for blending between viewpoints: CameraPose stores a Camera's placement as a dual quaternion plus a focus distance.

pose reads one from a camera and applyPose writes it back. sclerpTo moves a camera toward a target pose by screw interpolation and nlerpTo by normalised linear interpolation.

import afield.math.linalg.DualQuaternionf
import afield.math.linalg.Matrix3f
import afield.math.linalg.Quaternionf
import afield.math.linalg.Vector3f
import afield.math.linalg.Vector3fc
import kotlin.math.abs

data class CameraPose

A camera body transform plus the look-at focus distance needed to reconstruct Field's traditional at/target/up camera state.

data class CameraPose(
    /** Where the camera is and which way it faces, as one rigid transform — rotation and translation
     * together in a dual quaternion, which is what makes two poses interpolable as a unit rather
     * than axis by axis. */
    val transform: DualQuaternionf,
    /** How far down [forward] the look-at target sits, in world units. A pose carries this because
     * [transform] alone cannot say it, and Field's camera is steered by a target point rather than
     * by a rotation. */
    val focusDistance: Float
) {

constructor

    constructor(
        rotation: Quaternionf,
        position: Vector3fc,
        focusDistance: Float
    ) : this(DualQuaternionf(rotation, position), focusDistance)

val position: Vector3f

The eye point, read out of transform as its translation.

    val position: Vector3f
        get() = transform.getTranslation()

val rotation: Quaternionf

The camera's orientation, read out of transform.

    val rotation: Quaternionf
        get() = transform.getRotation()

val forward: Vector3f

The viewing direction: the OpenGL-style -Z axis turned by rotation, and a unit vector.

    val forward: Vector3f
        get() = rotation.transform(Vector3f(0f, 0f, -1f))

val up: Vector3f

The camera's up axis: +Y turned by rotation. Already perpendicular to forward, since a rotation is applied to both.

    val up: Vector3f
        get() = rotation.transform(Vector3f(0f, 1f, 0f))

val target: Vector3f

The point the camera looks at, focusDistance along forward from position. This is what a Camera is actually steered by, so it is the value applyPose writes.

    val target: Vector3f
        get() = Vector3f(position).fma(focusDistance, forward)

fun nlerp(other: CameraPose, t: Float): CameraPose

Blends toward other by normalised linear interpolation of the two dual quaternions, with focusDistance interpolated linearly alongside.

Cheaper than sclerp and indistinguishable from it over the small steps a per-frame ease takes. Over a large step it does not hold angular speed constant — both ends land exactly, and the turn is fastest in the middle — so a slow deliberate move between two distant viewpoints is the case for sclerp.

    fun nlerp(other: CameraPose, t: Float): CameraPose =
        CameraPose(
            transform.nlerp(other.transform, t),
            focusDistance + (other.focusDistance - focusDistance) * t
        )

fun sclerp(other: CameraPose, t: Float): CameraPose

Blends toward other by screw interpolation, so the camera turns and travels at a constant rate about a single screw axis. focusDistance is interpolated linearly alongside.

This is the one to reach for when the whole move is visible — a long dolly between two set-ups reads as one motion rather than as a turn that speeds up halfway. At t of 0 and 1 it agrees with nlerp, so the two are interchangeable at the endpoints.

    fun sclerp(other: CameraPose, t: Float): CameraPose =
        CameraPose(
            transform.sclerp(other.transform, t),
            focusDistance + (other.focusDistance - focusDistance) * t
        )

companion object

fun lookAt

        fun lookAt(
            eye: Vector3fc,
            center: Vector3fc,
            up: Vector3fc = Vector3f(0f, 1f, 0f)
        ): CameraPose {
            val forward = Vector3f(center).sub(eye)
            val distance = forward.length()
            if (distance < 1e-6f) {
                return CameraPose(Quaternionf(), eye, 1f)
            }
            forward.normalize()
            val rotation = rotationFromForwardUp(forward, up)
            return CameraPose(rotation, eye, distance)
        }

fun lookAlong

        fun lookAlong(
            position: Vector3fc,
            direction: Vector3fc,
            up: Vector3fc = Vector3f(0f, 1f, 0f),
            focusDistance: Float = 1f
        ): CameraPose {
            val forward = Vector3f(direction)
            if (forward.lengthSquared() < 1e-12f) {
                return CameraPose(Quaternionf(), position, focusDistance)
            }
            forward.normalize()
            return CameraPose(rotationFromForwardUp(forward, up), position, focusDistance)
        }
    }
}

fun Camera.pose(): CameraPose

Reads this camera's current placement as a CameraPose, turning its eye, target and up vectors into a rotation, a translation and a focus distance.

The distance between eye and target becomes CameraPose.focusDistance, so a pose captured here and applied later restores the target point and not only the direction. A camera whose eye and target coincide has no direction to read, and comes back as an unrotated pose at a focus distance of 1.

fun Camera.pose(): CameraPose =
    CameraPose.lookAt(at, target, up)

fun Camera.applyPose(pose: CameraPose)

Moves this camera to pose, writing its eye, target and up vectors in place.

The up vector is re-squared against the new view direction afterwards, so a pose produced by interpolation cannot leave the camera with a skewed basis. Nothing else about the camera — its projection, its controls — is touched.

fun Camera.applyPose(pose: CameraPose) {
    at.set(pose.position)
    target.set(pose.target)
    up.set(pose.up)
    reorthogonalizeUp()
}

fun Camera.sclerpTo(targetPose: CameraPose, t: Float)

Moves this camera a fraction t of the way toward targetPose by screw interpolation, reading its current pose and writing the blended one back.

Called once a frame with a small t this is an exponential ease: the remaining distance is cut by the same proportion every frame, so the camera approaches without ever overshooting and the approach slows as it arrives. t of 1 jumps straight there.

fun Camera.sclerpTo(targetPose: CameraPose, t: Float) {
    applyPose(pose().sclerp(targetPose, t))
}

fun Camera.nlerpTo(targetPose: CameraPose, t: Float)

Moves this camera a fraction t of the way toward targetPose by normalised linear interpolation.

The same per-frame ease as sclerpTo and cheaper; at the step sizes an ease uses, the two are hard to tell apart.

fun Camera.nlerpTo(targetPose: CameraPose, t: Float) {
    applyPose(pose().nlerp(targetPose, t))
}

internal fun rotationFromForwardUp(forward: Vector3fc, up: Vector3fc): Quaternionf

internal fun rotationFromForwardUp(forward: Vector3fc, up: Vector3fc): Quaternionf {
    val f = Vector3f(forward)
    if (f.lengthSquared() < 1e-12f) {
        return Quaternionf()
    }
    f.normalize()

    val upHint = Vector3f(up)
    if (upHint.lengthSquared() < 1e-12f) {
        upHint.set(0f, 1f, 0f)
    }
    upHint.normalize()

    val right = Vector3f(f).cross(upHint)
    if (right.lengthSquared() < 1e-12f) {
        if (abs(f.y) > 0.9f) {
            right.set(1f, 0f, 0f)
        } else {
            right.set(Vector3f(f).cross(Vector3f(0f, 1f, 0f)))
        }
    }
    right.normalize()

    val trueUp = Vector3f(right).cross(f).normalize()
    val back = Vector3f(f).negate()

    val basis = Matrix3f()
    basis.m00(right.x)
    basis.m10(trueUp.x)
    basis.m20(back.x)
    basis.m01(right.y)
    basis.m11(trueUp.y)
    basis.m21(back.y)
    basis.m02(right.z)
    basis.m12(trueUp.z)
    basis.m22(back.z)
    return Quaternionf().setFromNormalized(basis).normalize()
}