movement.kt
src/jvmMain/kotlin/afield/animation/motion/movement.kt · 465 lines
package and imports — 49 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Simulation")
package afield.animation.motion
import afield.data.collections.get
import afield.data.collections.invoke
import afield.data.collections.s
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.document.workspace.path
import afield.geometry.mesh.rotated
import afield.graphics.render.rangeTo
import afield.graphics.vulkan.buffers.add
import afield.math.autodiff.clamp
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.product
import afield.math.autodiff.round
import afield.math.functions.behaviour
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.units.div
import afield.media.image.close
import afield.runtime.execution.animate
import afield.runtime.execution.go
import afield.runtime.execution.start
import afield.runtime.lifecycle.once
import afield.simulation.agents.step
import afield.simulation.automata.step
import afield.audio.synth.*
import afield.data.collections.r
import afield.math.autodiff.dot
import afield.math.control.params
import afield.math.linalg.x
import afield.math.linalg.Vector2f
import afield.math.linalg.Vector2fc
import afield.math.linalg.minus
import afield.math.linalg.plus
import afield.math.linalg.times
import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.max
import kotlin.math.sin
Springs, dampers, and a steerable character built out of four of them.
Two layers, and the lower one is useful alone. Springs holds the primitives as one-line
functions over Float and Vector2f: exponential dampers, critically damped springs, an angle
spring that takes the short way round, and the decay that hides a jump. MovementState stacks
four of those into a character with a position, a velocity and a facing, driven by a desired
velocity and tuned through MovementParams.
Half-life is the parameter throughout: the time a spring takes to close half the distance to its
goal. That is what makes these tunable by eye, rather than by bisecting a stiffness against a
damping ratio. Every step is the analytic solution of the spring's equation rather than a
numerical integration, so one step of dt and ten steps of dt / 10 land in the same place and
the motion does not change with frame rate.
All of it is plain arithmetic on the CPU, with no renderer and no solver underneath. Both layers follow Daniel Holden's "Spring-It-On" and "A New Movement Model".
object Springs
Critically damped springs and dampers — the pieces MovementState is built from, and worth having on their own for anything that should arrive at a value without a hard edge.
Half-life is the parameter throughout: the time the spring takes to close half the distance to its goal. It is the one spring parameter that describes something you can see, and the reason to reach for these rather than tuning a stiffness against a damping.
Every step here is exact — the analytic solution of the spring's differential equation rather
than a numerical integration — so one step of dt and ten steps of dt / 10 land in the same
place, and behaviour does not drift with frame rate. Holden's fast_negexp polynomial is the one
thing not carried across: a real exp is what makes that exactness true, and nothing here is
counting cycles.
After Daniel Holden, "Spring-It-On: The Game Developer's Spring-Roll-Call"
. The names there are damper_exact,
simple_spring_damper_exact, decay_spring_damper_exact and double_spring_damper_exact.
object Springs {
const val LN2: Float = 0.69314718056f
Natural log of two: what turns a half-life into a decay rate.
data class Step(val x: Float, val v: Float)
x and v after a spring step — position and velocity, or whatever pair is being sprung.
data class Step2(val x: Vector2f, val v: Vector2f)
Two-dimensional Step: each component is sprung on its own.
data class DoubleStep(val x: Float, val v: Float, val intermediateX: Float, val intermediateV: Float)
A Step for a double spring, which carries an intermediate spring along with the visible one.
data class DoubleStep(val x: Float, val v: Float, val intermediateX: Float, val intermediateV: Float)
fun halflifeToDamping(halflife: Float, eps: Float = 1e-5f): Float
The damping coefficient that gives a critically damped spring this halflife.
fun halflifeToDamping(halflife: Float, eps: Float = 1e-5f): Float = (4f * LN2) / (halflife + eps)
fun dampingToHalflife(damping: Float, eps: Float = 1e-5f): Float = (4f * LN2) / (damping + eps)
The half-life of a critically damped spring with this damping coefficient.
fun halflifeToLag(halflife: Float): Float = halflife / LN2
How far behind a constant-speed goal a damper of this halflife ends up trailing, in seconds.
A damper chasing something that keeps moving never catches it, and settles into a fixed lag
instead. This is that lag, which is what lets it be cancelled: aim at where the goal will be
in halflifeToLag(halflife) seconds and the output lands on the goal rather than behind it.
MovementState's accelerationCompensation is exactly this trick.
fun lagToHalflife(lag: Float): Float = lag * LN2
The half-life whose trailing lag is lag seconds — the inverse of halflifeToLag.
fun damper(x: Float, goal: Float, halflife: Float, dt: Float, eps: Float = 1e-5f): Float
Moves x a fraction of the way to goal, closing half the remaining distance every halflife.
The plain exponential damper, with no velocity of its own: it leaves at full speed and slows as it arrives, so it eases out but not in. Use spring when the start should be smooth too.
fun damper(x: Float, goal: Float, halflife: Float, dt: Float, eps: Float = 1e-5f): Float =
x + (goal - x) * (1f - exp(-(LN2 * dt) / (halflife + eps)))
fun damper(x: Vector2fc, goal: Vector2fc, halflife: Float, dt: Float, eps: Float = 1e-5f): Vector2f
damper on both components of a vector.
fun damper(x: Vector2fc, goal: Vector2fc, halflife: Float, dt: Float, eps: Float = 1e-5f): Vector2f {
val blend = 1f - exp(-(LN2 * dt) / (halflife + eps))
return Vector2f(
x.x() + (goal.x() - x.x()) * blend,
x.y() + (goal.y() - x.y()) * blend,
)
}
fun spring(x: Float, v: Float, goal: Float, halflife: Float, dt: Float): Step
Steps a critically damped spring at x moving at v toward goal.
Critically damped means it never overshoots and never oscillates: the fastest approach that
still arrives cleanly. Because it carries v, the motion is smooth through a change of goal
rather than restarting, which is what makes it the usual choice for following something.
fun spring(x: Float, v: Float, goal: Float, halflife: Float, dt: Float): Step {
val y = halflifeToDamping(halflife) / 2f
val j0 = x - goal
val j1 = v + j0 * y
val eydt = exp(-y * dt)
return Step(eydt * (j0 + j1 * dt) + goal, eydt * (v - j1 * y * dt))
}
fun spring(x: Vector2fc, v: Vector2fc, goal: Vector2fc, halflife: Float, dt: Float): Step2
spring on both components of a vector, each on its own curve.
fun spring(x: Vector2fc, v: Vector2fc, goal: Vector2fc, halflife: Float, dt: Float): Step2 {
val sx = spring(x.x(), v.x(), goal.x(), halflife, dt)
val sy = spring(x.y(), v.y(), goal.y(), halflife, dt)
return Step2(Vector2f(sx.x, sy.x), Vector2f(sx.v, sy.v))
}
fun springAngle(x: Float, v: Float, goal: Float, halflife: Float, dt: Float): Step
spring on an angle in radians, taking the short way round.
The only difference from spring is that the distance to the goal is measured as a wrapped angle, so a spring at 3 radians chasing -3 turns the short way, onward through π, rather than sweeping back across zero.
The result is continuous with x: consecutive steps never jump by 2π, even where the short way
crosses ±π, so it can be compared against the previous value directly. It is not wrapped into
-π..π; use wrapAngle where that matters.
fun springAngle(x: Float, v: Float, goal: Float, halflife: Float, dt: Float): Step {
val y = halflifeToDamping(halflife) / 2f
val j0 = wrapAngle(x - goal)
val j1 = v + j0 * y
val eydt = exp(-y * dt)
val next = eydt * (j0 + j1 * dt) + goal
// Continuous with [x]: the spring is solved relative to the goal along the short way, and
// reporting it there would jump by 2π on the step where the short way crosses ±π.
return Step(x + wrapAngle(next - x), eydt * (v - j1 * y * dt))
}
fun doubleSpringAngle
Two springAngles in series, each on half of halflife — the visible one chasing an
intermediate one that chases goal.
A single spring leaves at once when the goal jumps, which reads as a flick. Chaining two costs a little more lag and removes the flick, because the visible spring's goal is itself something that accelerates. Worth it on anything a viewer watches directly, like a character's facing.
fun doubleSpringAngle(
x: Float,
v: Float,
intermediateX: Float,
intermediateV: Float,
goal: Float,
halflife: Float,
dt: Float
): DoubleStep {
val intermediate = springAngle(intermediateX, intermediateV, goal, 0.5f * halflife, dt)
val visible = springAngle(x, v, intermediate.x, 0.5f * halflife, dt)
return DoubleStep(visible.x, visible.v, intermediate.x, intermediate.v)
}
fun decay(x: Float, v: Float, halflife: Float, dt: Float): Step
Decays x and v toward zero with the shape of a critically damped spring.
The offset half of an inertialization: hold the difference between where something was and where it has jumped to, decay it with this, and add it back to hide the jump.
fun decay(x: Float, v: Float, halflife: Float, dt: Float): Step {
val y = halflifeToDamping(halflife) / 2f
val j1 = v + x * y
val eydt = exp(-y * dt)
return Step(eydt * (x + j1 * dt), eydt * (v - j1 * y * dt))
}
fun wrapAngle(a: Float): Float
a wrapped into (-π, π].
fun wrapAngle(a: Float): Float {
val twoPi = (2.0 * PI).toFloat()
var r = a % twoPi
if (r > PI) r -= twoPi
if (r <= -PI) r += twoPi
return r
}
}
data class MovementParams
How a character's movement responds to input — the dials on MovementState.update.
The defaults are the ones Holden's demo settles on, in pixels and seconds. The four that matter most, in the order worth touching them:
- acceleration and
decelerationcap how hard the character can change speed. Deceleration higher than acceleration is what makes a stop feel deliberate rather than floaty. accelerationHalflifeanddecelerationHalflifeare how much smoothing sits on top of that cap. Zero gives you the raw, responsive, slightly robotic version; larger values round the corners off at the cost of responsiveness.accelerationCompensationbuys back the lag that smoothing costs, by aiming the smoothing at where the movement is heading rather than where it is. At1the cancellation is exact for as long as the movement is changing at a steady rate — the smoothing keeps its shape and stops costing anything (see Springs.halflifeToLag). Above1it anticipates, which reads as keenness.turnStrengthis how hard the direction of travel is rotated toward the requested direction, as a damper of half-life1 / turnStrength. It buys sharpness: at0the direction changes only as a side effect of accelerating at the velocity error, which is slow, and at highdirectionalAccelerationbarely turns at all. It costs a little speed through the turn, since rotating a velocity by damping it cuts the chord rather than following the arc. A reversal is not a turn and still stops: there is no short way round 180°.
directionalAcceleration is the other side of that trade. At 0 all acceleration goes at the
velocity error, which turns readily but loses speed doing it — Holden's quad-copter. At 1 it can
only push along the requested direction, so it holds its speed through a turn and drifts, an
airboat, and it leans on turnStrength for any turn at all. In between, with turn strength up, is
a character that turns sharply and keeps its speed.
rotationHalflife and rotationDoubleSpring control the facing only, and have no effect on where
the character goes.
data class MovementParams(
val acceleration: Float = 800f,
val deceleration: Float = 1400f,
val directionalAcceleration: Float = 0f,
val turnStrength: Float = 1f,
val accelerationHalflife: Float = 0.1f,
val decelerationHalflife: Float = 0.05f,
val accelerationCompensation: Float = 1f,
val decelerationCompensation: Float = 0f,
val rotationHalflife: Float = 0.15f,
val rotationDoubleSpring: Boolean = true,
) {
companion object
companion object {
val DEFAULT = MovementParams()
The default dials, so that MovementState.update can default without allocating.
class MovementState
A character's position, velocity and facing, moved by a desired velocity — a game movement model that stays responsive while still looking like it has mass.
Feed it where the player is asking to go, as a velocity, and it produces motion that neither snaps to the input nor lags behind it:
val character = MovementState()
val params = MovementParams(turnStrength = 2f)
// every frame
character.update(stick * 400f, dt, params)
drawAt(character.position, character.rotation)
The shape of it, in the order update does the work: the requested direction pulls the current direction of travel round, so a turn is sharper than acceleration alone would make it; acceleration toward the requested velocity is clamped to a rate, which is where the weight comes from; that clamped velocity is smoothed by a spring; and the spring is aimed slightly into the future to cancel the lag the smoothing would otherwise cost. Facing is a separate spring on rotation that does not affect the path.
velocity is what to trust for animation, and position integrates it. immediateVelocity is
the unsmoothed velocity the spring is chasing, exposed because it is occasionally the more useful
thing to compare against. Every field is a plain var: set position to teleport, zero
velocity and acceleration to stop dead.
After Daniel Holden, "A New Movement Model".
class MovementState {
var position: Vector2f = Vector2f()
Where the character is, integrated from velocity.
var velocity: Vector2f = Vector2f()
The smoothed velocity — the one to animate from.
var acceleration: Vector2f = Vector2f()
velocity's own rate of change, carried by the smoothing spring.
var immediateVelocity: Vector2f = Vector2f()
The clamped but unsmoothed velocity that velocity is chasing.
var rotation: Float = 0f
Facing, in radians. Sprung separately, and not what decides where the character goes.
var angularVelocity: Float = 0f
rotation's rate of change, carried by the facing spring.
var immediateRotation: Float = 0f
The intermediate facing, used only when rotationDoubleSpring is on.
var immediateAngularVelocity: Float = 0f
The intermediate facing's rate of change, used only when rotationDoubleSpring is on.
val speed: Float get() = velocity.length()
How fast the character is actually moving.
val heading: Vector2f get() = Vector2f(cos(rotation), sin(rotation))
The unit vector rotation is pointing along.
fun update
Advances the character by dt seconds toward desiredVelocity.
desiredVelocity is an instruction, not a value to be reached exactly: it carries both a
direction and a speed, so a half-deflected stick should be a shorter vector and not a slower
approach to a long one. A zero vector is a request to stop.
desiredRotation defaults to facing along desiredVelocity, holding the current facing while
stopped, which is what a top-down character usually wants. Pass it explicitly for anything that
aims independently of where it is going.
body — 95 lines, from line 351
fun update(
desiredVelocity: Vector2fc,
dt: Float,
params: MovementParams = MovementParams.DEFAULT,
desiredRotation: Float = if (desiredVelocity.lengthSquared() > 1e-16f)
atan2(desiredVelocity.y(), desiredVelocity.x()) else rotation,
) {
val desiredSpeed = desiredVelocity.length()
// Rotate the current direction of travel toward the requested one. This is what makes a turn
// sharp: without it the direction changes only as a by-product of accelerating at the error.
if (params.turnStrength > 1e-8f && desiredSpeed > 1e-8f) {
immediateVelocity = Springs.damper(
immediateVelocity,
direction(desiredVelocity) * immediateVelocity.length(),
1f / params.turnStrength,
dt,
)
}
val velocityError = desiredVelocity - immediateVelocity
val errorMagnitude = velocityError.length()
// Speeding up and slowing down get their own rates: a stop can be much more abrupt than a
// start without looking wrong.
val isAccelerating = desiredSpeed + 1e-4f > immediateVelocity.length()
val lateralRate =
if (isAccelerating) (1f - params.directionalAcceleration) * params.acceleration
else params.deceleration
val directionalRate =
if (isAccelerating) params.directionalAcceleration * params.acceleration else 0f
// Close the remaining error exactly if a full step would overshoot it, otherwise move at the
// allowed rate. This is the clamp that gives the model its weight. (`* (1 / dt)` rather than
// `/ dt`: Vector2f's div operator is the in-place one, and would scale the error vector that
// the rest of this function still needs.)
val lateral =
if (errorMagnitude < lateralRate * dt) velocityError * (1f / dt)
else direction(velocityError) * lateralRate
val directional = direction(desiredVelocity) * directionalRate
val rate = lateral + directional
val smoothingHalflife =
if (isAccelerating) params.accelerationHalflife else params.decelerationHalflife
val compensation =
if (isAccelerating) params.accelerationCompensation else params.decelerationCompensation
// Aim the smoothing spring at where the unsmoothed velocity will be once the spring's own lag
// has passed, so the smoothing costs shape without costing responsiveness.
val lookahead = dt + compensation * Springs.halflifeToLag(smoothingHalflife)
// Extrapolating past the desired velocity would be worse than arriving at it, so stop there.
val errorSquared = velocityError.dot(velocityError)
var next =
if (velocityError.dot(rate * dt) < errorSquared) immediateVelocity + rate * dt
else Vector2f(desiredVelocity)
var tracked =
if (velocityError.dot(rate * lookahead) < errorSquared) immediateVelocity + rate * lookahead
else Vector2f(desiredVelocity)
// Neither the step nor the lookahead may invent speed the character was not already asking
// for: without this the directional and lateral terms together can exceed the input.
val speedCap = max(immediateVelocity.length(), desiredSpeed)
if (next.length() > speedCap) next = direction(next) * speedCap
if (tracked.length() > speedCap) tracked = direction(tracked) * speedCap
val sprung = Springs.spring(velocity, acceleration, tracked, smoothingHalflife, dt)
velocity = sprung.x
acceleration = sprung.v
immediateVelocity = next
position = position + velocity * dt
if (params.rotationDoubleSpring) {
val turned = Springs.doubleSpringAngle(
rotation,
angularVelocity,
immediateRotation,
immediateAngularVelocity,
desiredRotation,
params.rotationHalflife,
dt,
)
rotation = turned.x
angularVelocity = turned.v
immediateRotation = turned.intermediateX
immediateAngularVelocity = turned.intermediateV
} else {
val turned =
Springs.springAngle(rotation, angularVelocity, desiredRotation, params.rotationHalflife, dt)
rotation = turned.x
angularVelocity = turned.v
}
}
fun placeAt(position: Vector2fc, facing: Float = rotation)
Teleports the character, leaving it at rest and facing facing.
fun placeAt(position: Vector2fc, facing: Float = rotation) {
this.position = Vector2f(position)
velocity = Vector2f()
acceleration = Vector2f()
immediateVelocity = Vector2f()
rotation = facing
angularVelocity = 0f
immediateRotation = facing
immediateAngularVelocity = 0f
}
The unit vector along v, or zero when there is no direction to speak of.
private fun direction(v: Vector2fc): Vector2f {
val length = v.length()
return if (length > 1e-8f) Vector2f(v.x() / length, v.y() / length) else Vector2f()
}
}