Skip to content

MotionItems.kt

src/jvmMain/kotlin/afield/graphics/vulkan/targets/MotionItems.kt · 112 lines

package afield.graphics.vulkan.targets

import kotlin.math.cos
import kotlin.math.sin

Per-item motion for 2D items drawn by stable identity, for passes that need each item's previous pose.

MotionItems keeps this frame's and last frame's MotionPose for each key and writes the difference, falling back to no motion when the item is new, changed revision or was cleared. MotionItemsTest covers it.

class MotionPose

Mutable 2D affine pose. Angles are radians; scale is positive and uniform.

class MotionPose(
    /** Centre of the item on the x axis, in whatever units the pass draws in. */
    var x: Float = 0f,
    /** Centre of the item on the y axis. */
    var y: Float = 0f,
    /** Rotation about that centre, in radians. Only the difference between two frames is written out. */
    var angle: Float = 0f,
    /** Uniform scale about that centre. Must be finite and above zero; [MotionItems.write] requires it. */
    var scale: Float = 1f,
    /**
     * How much this item should count for. [MotionItems.write] treats a previous energy at or below
     * `1e-7` as no usable history, so a fading item stops contributing motion before it disappears.
     */
    var energy: Float = 1f,
    /** How soft the item's edge is. Like [energy], what reaches the pass is the previous frame's value. */
    var softness: Float = 0f
) {

fun set(other: MotionPose)

    fun set(other: MotionPose) {
        x = other.x
        y = other.y
        angle = other.angle
        scale = other.scale
        energy = other.energy
        softness = other.softness
    }
}

class MotionItems

Correspondence by stable item identity, not draw-list index. Call begin once per rendered frame (duplicate calls are harmless). Topology or material changes use a different revision; camera cuts and world resets call clear. Retains only this frame and its immediate predecessor. Owner-thread only.

class MotionItems {
    private class Item {
        val current = MotionPose()
        val previous = MotionPose()
        var frame = Long.MIN_VALUE
        var revision: Any? = null
        var previousRevision: Any? = null
        var valid = false
    }
    private val items = HashMap<Any, Item>()
    private var frame: Long? = null

val retained: Int get() = items.size

How many keys are being held, which is this frame's items plus any from the frame before that have not been written again yet. begin drops the rest, and clear takes it to zero.

fun begin(serial: Long)

    fun begin(serial: Long) {
        val last = frame
        if (last == serial) return
        if (last == null || last == Long.MAX_VALUE || serial != last + 1) items.clear()
        else items.entries.removeIf { it.value.frame != last }
        frame = serial
    }

fun write(key: Any, pose: MotionPose, out: FloatArray, at: Int = 0, allow: Boolean = true, revision: Any? = null)

Writes two vec4s: previous centre xy, cosine/sine scaled by previous/current scale; then valid, previous energy, previous softness, reserved. Use FrameHistory.available as allow when camera history is required.

    fun write(key: Any, pose: MotionPose, out: FloatArray, at: Int = 0, allow: Boolean = true, revision: Any? = null) {
        val serial = checkNotNull(frame) { "Call MotionItems.begin before writing" }
        require(at >= 0 && at.toLong() + 8 <= out.size)
        require(pose.x.isFinite() && pose.y.isFinite() && pose.angle.isFinite())
        require(pose.scale.isFinite() && pose.scale > 0 && pose.energy.isFinite() && pose.softness.isFinite())
        val item = items.getOrPut(key) { Item() }
        if (item.frame != serial) {
            item.valid = serial != Long.MIN_VALUE && item.frame == serial - 1
            item.previous.set(item.current)
            item.previousRevision = item.revision
            item.frame = serial
        }
        val valid = allow && item.valid && item.previousRevision == revision && item.previous.energy > 1e-7f
        val before = if (valid) item.previous else pose
        val delta = before.angle - pose.angle
        val ratio = before.scale / pose.scale
        out[at] = before.x
        out[at + 1] = before.y
        out[at + 2] = cos(delta) * ratio
        out[at + 3] = sin(delta) * ratio
        out[at + 4] = if (valid) 1f else 0f
        out[at + 5] = before.energy
        out[at + 6] = before.softness
        out[at + 7] = 0f
        item.current.set(pose)
        item.revision = revision
    }

fun clear()

    fun clear() {
        items.clear()
        frame = null
    }
}