Skip to content

KeyCamera.kt

src/jvmMain/kotlin/afield/graphics/vulkan/util/KeyCamera.kt · 398 lines

package afield.graphics.vulkan.util

Keyboard camera controls for a Vulkan window.

KeyCamera registers a poll callback on the renderer that moves a Camera from held keys; keyCamera and KeyCameraDelegateProvider create one named after the property it is assigned to, so re-running a box replaces its controls rather than adding more.

import afield.document.graph.Box
import afield.graphics.vulkan.Renderer
import afield.document.context.fromContext
import afield.math.linalg.Vector3f
import org.lwjgl.glfw.GLFW
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
import kotlin.math.PI
import kotlin.math.pow

class KeyCamera

Binds keyboard camera controls into the current renderer poll loop.

By default the renderer and camera come from context, and name is used as the stable registration key for the window poll callback.

class KeyCamera(
    /** The renderer whose window is read for held keys and whose poll loop runs the controls. */
    val renderer: Renderer = fromContext(),
    /**
     * The camera the keys move. **R** puts its position, target, up vector and field of view back to
     * what they were when this [KeyCamera] was built, along with [decay], [movementSpeed] and
     * [rotationSpeed].
     */
    val camera: Camera = fromContext(),
    /**
     * The key this registration takes in the window's poll map. Building a second [KeyCamera] under
     * the same name replaces the first rather than adding a second set of controls, which is how
     * re-running a box leaves one.
     */
    val name: String = "__keycamera__",
    private val acceptPhysicalKeys: Boolean = true,
) {

var actions = mutableMapOf<String, Pair<Double, (Double) -> Unit>>()

The motion still owed, as action key to amount and what to do with it. Holding a key sets its amount back to 1 every poll; each poll then decays the amount, calls the action with it, and drops the entry once it falls below 0.01 — which is the glide that carries on after the key comes up. Adding an entry here moves the camera exactly as a key would.

var perActionDecays = mutableMapOf<String, Double>()

The decay in force for each action, for overriding decay on one of them. An action with no entry takes decay the next time it runs, and pressing its key clears the entry, so a value set here governs the glide of a key that is already down.

var decay = 0.95

How much of an action's amount survives one 60 Hz frame, raised to the power of the frame's share of 1/60 s so the glide lasts the same wall-clock time at any frame rate. Lower stops sooner; 1 never stops.

var movementSpeed = 0.01

How far one 60 Hz frame of a movement key travels, as a fraction of the camera's distance to its target, so the same keystroke moves less the closer you are. Turning has its own rotationSpeed.

var rotationSpeed = 0.05

Scale for keyboard yaw, pitch and roll; larger values turn the camera faster.

right-shift double-tap detection

    private var lastRightShiftDownTimeMs: Long = 0L
    private var wasRightShiftDown: Boolean = false
    private var actionsFrozen: Boolean = false
    private var lastTimeNs: Long = System.nanoTime()

snapshot for reset

    private val initialAt = Vector3f(camera.at)
    private val initialTarget = Vector3f(camera.target)
    private val initialUp = Vector3f(camera.up)
    private val initialFov = camera.p.fov
    private val initialDecay = decay
    private val initialMovementSpeed = movementSpeed
    private val initialRotationSpeed = rotationSpeed

init

body — 216 lines, from line 89
    init {
        renderer.window.onPoll[name] = sequence {
            while (true) {
                val now = System.nanoTime()
                var dt = (now - lastTimeNs) / 1_000_000_000.0
                lastTimeNs = now
                // clamp to avoid huge jumps
                dt = dt.coerceIn(0.0, 0.5)
                val frameFactor = (dt / (1.0 / 60.0))

                checkKeys(frameFactor)

                // decay & run actions
                val acted = actions.isNotEmpty()
                actions.entries.retainAll { entry ->
                    var (amount, action) = entry.value
                    val localDecay = perActionDecays.computeIfAbsent(entry.key) { decay }
                    // decay factor scaled by dt so behavior is framerate-independent
                    val decayFactor = localDecay.pow(frameFactor)
                    amount *= decayFactor
                    entry.setValue(amount to action)
                    actions[entry.key] = amount to action
                    action(amount * frameFactor)
                    amount > 0.01
                }

                // Keep up perpendicular after motion. Repeating normalization while idle can
                // oscillate by a few float ULPs and continually invalidate progressive renderers.
                if (acted) camera.reorthogonalizeUp()

                yield(true)
            }
        }
    }

    private fun checkKeys(frameFactor: Double) {
        if (!acceptPhysicalKeys) return
        val window = renderer.window

        // handle right-shift double tap before computing general "shift" state
        handleRightShiftDoubleTap()

        val shift = isShiftDown()

        // base movement scale w/ distance
        val baseDistance = camera.distanceToTarget().toDouble()
        val moveStep = baseDistance * movementSpeed

        // ---------- forward / back (UP / DOWN) ----------
        if (window.isAnyKeyPressed(GLFW.GLFW_KEY_UP, GLFW.GLFW_KEY_W)) {
            enqueue(if (!shift) "up_target" else "up_at") { amount ->
                val step = moveStep * amount
                if (!shift) {
                    // pan: move both camera and target forward
                    camera.moveLocal(forward = step, moveTarget = true)
                } else {
                    // move camera only (change distance to target)
                    camera.moveLocal(forward = step, moveTarget = false)
                }
            }
        }
        if (window.isAnyKeyPressed(GLFW.GLFW_KEY_DOWN, GLFW.GLFW_KEY_S)) {
            enqueue(if (!shift) "down_target" else "down_at") { amount ->
                val step = -moveStep * amount
                if (!shift) {
                    camera.moveLocal(forward = step, moveTarget = true)
                } else {
                    camera.moveLocal(forward = step, moveTarget = false)
                }
            }
        }

        // ---------- strafe left / right (A / D) ----------
        if (window.isKeyPressed(GLFW.GLFW_KEY_A)) {
            enqueue(if (!shift) "strafe_left_target" else "strafe_left_at") { amount ->
                val step = -moveStep * amount
                camera.moveLocal(right = step, moveTarget = !shift)
            }
        }
        if (window.isKeyPressed(GLFW.GLFW_KEY_D)) {
            enqueue(if (!shift) "strafe_right_target" else "strafe_right_at") { amount ->
                val step = moveStep * amount
                camera.moveLocal(right = step, moveTarget = !shift)
            }
        }

        // ---------- local up / down (E / C) ----------
        if (window.isKeyPressed(GLFW.GLFW_KEY_E)) {
            enqueue(if (!shift) "vertical_up_target" else "vertical_up_at") { amount ->
                val step = moveStep * amount
                camera.moveLocal(upAmount = step, moveTarget = !shift)
            }
        }
        if (window.isKeyPressed(GLFW.GLFW_KEY_C)) {
            enqueue(if (!shift) "vertical_down_target" else "vertical_down_at") { amount ->
                val step = -moveStep * amount
                camera.moveLocal(upAmount = step, moveTarget = !shift)
            }
        }

        // ---------- orbit left / right (LEFT / RIGHT) ----------
        if (window.isKeyPressed(GLFW.GLFW_KEY_LEFT)) {
            enqueue(if (!shift) "left_target" else "left_at") { amount ->
                val ang = rotationSpeed * amount
                if (!shift) {
                    // rotate TARGET around CAMERA (yaw in place)
                    camera.yaw(
                        angle = ang,
                        aroundTarget = false,
                        rotateUp = false,
                        reorthogonalizeUp = false
                    )
                } else {
                    // rotate CAMERA around TARGET (orbit)
                    camera.yaw(
                        angle = ang,
                        aroundTarget = true,
                        rotateUp = false,
                        reorthogonalizeUp = false
                    )
                }
            }
        }
        if (window.isKeyPressed(GLFW.GLFW_KEY_RIGHT)) {
            enqueue(if (!shift) "right_target" else "right_at") { amount ->
                val ang = rotationSpeed * -amount
                if (!shift) {
                    camera.yaw(
                        angle = ang,
                        aroundTarget = false,
                        rotateUp = false,
                        reorthogonalizeUp = false
                    )
                } else {
                    camera.yaw(
                        angle = ang,
                        aroundTarget = true,
                        rotateUp = false,
                        reorthogonalizeUp = false
                    )
                }
            }
        }

        // ---------- vertical rotate (PAGE UP / PAGE DOWN) ----------
        if (window.isKeyPressed(GLFW.GLFW_KEY_PAGE_UP)) {
            enqueue(if (!shift) "pageup_target" else "pageup_at") { amount ->
                val ang = rotationSpeed * amount
                camera.pitch(
                    angle = ang,
                    aroundTarget = shift,
                    rotateUp = false,
                    reorthogonalizeUp = false
                )
            }
        }
        if (window.isKeyPressed(GLFW.GLFW_KEY_PAGE_DOWN)) {
            enqueue(if (!shift) "pagedown_target" else "pagedown_at") { amount ->
                val ang = rotationSpeed * -amount
                camera.pitch(
                    angle = ang,
                    aroundTarget = shift,
                    rotateUp = false,
                    reorthogonalizeUp = false
                )
            }
        }

        // ---------- FOV (- / =) ----------
        if (window.isKeyPressed(GLFW.GLFW_KEY_MINUS)) {
            adjustFov(direction = -1.0, frameFactor = frameFactor, fine = shift)
        }
        if (window.isKeyPressed(GLFW.GLFW_KEY_EQUAL)) {
            adjustFov(direction = 1.0, frameFactor = frameFactor, fine = shift)
        }

        // ---------- roll (Z / X, also , / .) ----------
        if (window.isAnyKeyPressed(GLFW.GLFW_KEY_Z, GLFW.GLFW_KEY_COMMA)) {
            enqueue("roll_left") { amount ->
                camera.roll(rotationSpeed * amount)
            }
        }
        if (window.isAnyKeyPressed(GLFW.GLFW_KEY_X, GLFW.GLFW_KEY_PERIOD)) {
            enqueue("roll_right") { amount ->
                camera.roll(rotationSpeed * -amount)
            }
        }

        // ---------- reset (R) ----------
        if (window.isKeyPressed(GLFW.GLFW_KEY_R)) {
            resetToInitial()
            actions.clear()
            perActionDecays.clear()
            actionsFrozen = false
        }
    }

    private fun enqueue(key: String, action: (Double) -> Unit) {
        actions[key] = 1.0 to action
        perActionDecays.remove(key)
    }

    private fun isShiftDown(): Boolean {
        val w = renderer.window
        return w.isKeyPressed(GLFW.GLFW_KEY_LEFT_SHIFT) || w.isKeyPressed(GLFW.GLFW_KEY_RIGHT_SHIFT)
    }

    private fun Window.isAnyKeyPressed(vararg keys: Int): Boolean {
        return keys.any { isKeyPressed(it) }
    }

    private fun adjustFov(direction: Double, frameFactor: Double, fine: Boolean) {
        val step = if (fine) FOV_FINE_STEP_RADIANS else FOV_STEP_RADIANS
        camera.p.fov = (camera.p.fov + direction * step * frameFactor)
            .coerceIn(MIN_FOV_RADIANS, MAX_FOV_RADIANS)
    }

Detect double-tap on RIGHT SHIFT (two key-down events within 500ms). Toggles between: - freezing current actions (perActionDecayskey = 1.0) - clearing perActionDecays (back to global decay)

source — 45 lines, from line 312
    private fun handleRightShiftDoubleTap() {
        val window = renderer.window
        val rightShiftDown = window.isKeyPressed(GLFW.GLFW_KEY_RIGHT_SHIFT)

        // edge detection: key-down event
        if (rightShiftDown && !wasRightShiftDown) {
            val nowMs = System.nanoTime() / 1_000_000L
            val delta = nowMs - lastRightShiftDownTimeMs

            if (delta in 1..DOUBLE_TAP_WINDOW_MS) {
                toggleActionDecayFreeze()
                // reset to avoid triple-tap being counted as 2 double-taps
                lastRightShiftDownTimeMs = 0L
            } else {
                lastRightShiftDownTimeMs = nowMs
            }
        }

        wasRightShiftDown = rightShiftDown
    }

    private fun toggleActionDecayFreeze() {
        if (!actionsFrozen) {
            // Freeze currently active actions (no decay)
            actions.keys.forEach { key ->
                perActionDecays[key] = 1.0
            }
            actionsFrozen = perActionDecays.size>0
        } else {
            // Restore default behavior
            perActionDecays.clear()
            actionsFrozen = false
        }
    }

    private fun resetToInitial() {
        camera.at.set(initialAt)
        camera.target.set(initialTarget)
        camera.up.set(initialUp)
        camera.p.fov = initialFov

        decay = initialDecay
        movementSpeed = initialMovementSpeed
        rotationSpeed = initialRotationSpeed
    }

companion object

    companion object {
        private const val DOUBLE_TAP_WINDOW_MS: Long = 500L
        private const val MIN_FOV_RADIANS = 5.0 * PI / 180.0
        private const val MAX_FOV_RADIANS = 120.0 * PI / 180.0
        private const val FOV_STEP_RADIANS = 1.0 * PI / 180.0
        private const val FOV_FINE_STEP_RADIANS = 0.25 * PI / 180.0
    }
}

class KeyCameraDelegateProvider

Delegate provider that creates a KeyCamera whose name equals the property name.

class KeyCameraDelegateProvider(
    private val renderer: Renderer = fromContext(),
    private val camera: Camera = fromContext(),
    private val box: Box = fromContext()
) {

operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): ReadWriteProperty<Any?, KeyCamera>

    operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): ReadWriteProperty<Any?, KeyCamera> {
        val instance = KeyCamera(
            renderer = renderer,
            camera = camera,
            name = property.name + "__${box.id}"
        )
        return object : ReadWriteProperty<Any?, KeyCamera> {
            private var value: KeyCamera = instance
            override fun getValue(thisRef: Any?, property: KProperty<*>): KeyCamera = value
            override fun setValue(thisRef: Any?, property: KProperty<*>, value: KeyCamera) {
                this.value = value
            }
        }
    }
}

fun keyCamera

Factory function to create a delegate for KeyCamera using the property's name.

fun keyCamera(
    renderer: Renderer = fromContext(),
    camera: Camera = fromContext()
): KeyCameraDelegateProvider = KeyCameraDelegateProvider(renderer, camera)