summaryrefslogtreecommitdiff
path: root/src/animation.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/animation.zig')
-rw-r--r--src/animation.zig80
1 files changed, 80 insertions, 0 deletions
diff --git a/src/animation.zig b/src/animation.zig
index 948a6679..ca933b82 100644
--- a/src/animation.zig
+++ b/src/animation.zig
@@ -586,6 +586,86 @@ pub const frame_ms: u32 = 16;
pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms;
pub const transition_steps: u16 = 10;
+/// A value that follows its target on a critically damped spring, in closed
+/// form (docs/render-pipeline.md §7.2): no overshoot, and a new target taken
+/// partway keeps the value AND its velocity, so a quick run of changes never
+/// snaps or starts over. `omega` sets the pace: it is within 1% of a jump
+/// after about 6.6 / omega seconds, and settles (half a percent, nearly
+/// still) at about 8.6 / omega. Settled, it is exactly its target and asks
+/// for no frames.
+pub const Spring = struct {
+ /// Within 1% of a jump in 6.6 / 36 ≈ 180 ms, settled by 240 ms: a
+ /// state change (§8.1).
+ pub const state_change: f32 = 36;
+
+ omega: f32 = state_change,
+ target: f32 = 0,
+ /// The value and velocity (per second) at `from_ns`.
+ from: f32 = 0,
+ velocity: f32 = 0,
+ from_ns: u64 = 0,
+ settled: bool = true,
+
+ const State = struct { value: f32, velocity: f32 };
+
+ fn at(spring: *const Spring, now_ns: u64) State {
+ if (spring.settled) return .{ .value = spring.target, .velocity = 0 };
+ const t: f32 = @floatCast(@as(f64, @floatFromInt(now_ns -| spring.from_ns)) / std.time.ns_per_s);
+ const w = spring.omega;
+ const x0 = spring.from - spring.target;
+ const c = spring.velocity + w * x0;
+ const decay = @exp(-w * t);
+ return .{ .value = spring.target + (x0 + c * t) * decay, .velocity = (spring.velocity - w * t * c) * decay };
+ }
+
+ pub fn value(spring: *const Spring, now_ns: u64) f32 {
+ return spring.at(now_ns).value;
+ }
+
+ /// Heads for `target` from wherever it is at `now_ns`, moving as it was.
+ pub fn retarget(spring: *Spring, target: f32, now_ns: u64) void {
+ if (target == spring.target) return;
+ const state = spring.at(now_ns);
+ spring.* = .{ .omega = spring.omega, .target = target, .from = state.value, .velocity = state.velocity, .from_ns = now_ns, .settled = false };
+ }
+
+ /// Settles once it is within half a percent of its target and barely
+ /// moving (no pixel of a shadow tells the rest): it is its target from
+ /// then on. True while it still moves.
+ pub fn step(spring: *Spring, now_ns: u64) bool {
+ if (spring.settled) return false;
+ const state = spring.at(now_ns);
+ if (@abs(state.value - spring.target) < 5e-3 and @abs(state.velocity) < 5e-2) spring.settled = true;
+ return !spring.settled;
+ }
+};
+
+test "a spring settles without overshoot and keeps its velocity when retargeted" {
+ var spring: Spring = .{};
+ const ms = std.time.ns_per_ms;
+ spring.retarget(1, 0);
+ var last: f32 = 0;
+ var t: u64 = 0;
+ while (spring.step(t)) : (t += frame_ns) {
+ const v = spring.value(t);
+ try std.testing.expect(v >= last and v <= 1);
+ last = v;
+ }
+ // Settled within a frame or two of 6.6 / omega.
+ try std.testing.expect(t >= 180 * ms and t <= 260 * ms);
+ try std.testing.expectEqual(@as(f32, 1), spring.value(t + 5 * ms));
+ // Back the other way halfway up: it carries on up for a moment, then
+ // turns, never jumping.
+ spring = .{};
+ spring.retarget(1, 0);
+ const before = spring.at(60 * ms);
+ spring.retarget(0, 60 * ms);
+ const after = spring.at(60 * ms);
+ try std.testing.expectApproxEqAbs(before.value, after.value, 1e-6);
+ try std.testing.expectApproxEqAbs(before.velocity, after.velocity, 1e-4);
+ try std.testing.expect(spring.value(64 * ms) > before.value);
+}
+
/// A displayed value that fades from one target to the next over
/// `transition_steps` frames; the chrome colours are one.
pub fn Fade(comptime Value: type) type {