diff options
Diffstat (limited to 'src/animation.zig')
| -rw-r--r-- | src/animation.zig | 80 |
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 { |
