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)