summaryrefslogtreecommitdiff
path: root/src/animation.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/animation.zig')
-rw-r--r--src/animation.zig139
1 files changed, 115 insertions, 24 deletions
diff --git a/src/animation.zig b/src/animation.zig
index ca933b82..b02a242b 100644
--- a/src/animation.zig
+++ b/src/animation.zig
@@ -586,19 +586,58 @@ 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;
+/// How the fx track moves (`Motion <flavour>`, docs/effects.md): one set of
+/// parameters a flavour picks, read by every animation that follows the
+/// classic principles, so a flavour is data and no effect branches on it.
+pub const Motion = struct {
+ pub const Flavour = enum(u8) { off, crisp, smooth, bouncy, playful };
+
+ /// Nothing moves: every change lands at once (reduced motion).
+ instant: bool = false,
+ /// Timing: the spring's natural frequency, rad/s; slow in / slow out
+ /// come from the spring itself.
+ omega: f32 = 36,
+ /// Follow-through: 1 is critically damped (no overshoot); below 1 it
+ /// settles past its target and back.
+ zeta: f32 = 1,
+ /// Anticipation: a move from rest first winds up the other way, by
+ /// this fraction of the distance.
+ anticipate: f32 = 0,
+ /// Squash and stretch: how much a moving thing lengthens along its
+ /// velocity and squashes as it lands (G3's cursor, G7's panes).
+ stretch: f32 = 0,
+ /// Secondary action: how far behind a follower trails its leader (the
+ /// shadow behind a pane, a notice's text behind its chip), as the
+ /// follower's frequency over the leader's; 1 is in step.
+ follow: f32 = 1,
+ /// Arcs: how far a two-dimensional path bows, as a fraction of its
+ /// length (the cursor's glide).
+ arc: f32 = 0,
- omega: f32 = state_change,
+ pub fn of(flavour: Flavour) Motion {
+ return switch (flavour) {
+ .off => .{ .instant = true },
+ // Productivity: quick and exact, nothing past its mark.
+ .crisp => .{ .omega = 44 },
+ // A longer, softer ease in and out, still without a bounce.
+ .smooth => .{ .omega = 26, .follow = 0.85, .arc = 0.04 },
+ // Settles past and back, and stretches with its speed.
+ .bouncy => .{ .omega = 30, .zeta = 0.55, .stretch = 0.12, .follow = 0.8, .arc = 0.08 },
+ // A cartoon's: winds up, overshoots, stretches, follows through.
+ .playful => .{ .omega = 24, .zeta = 0.42, .anticipate = 0.08, .stretch = 0.25, .follow = 0.7, .arc = 0.15 },
+ };
+ }
+};
+
+/// A value that follows its target on a damped spring, in closed form
+/// (docs/render-pipeline.md §7.2), critically damped or under: a new target
+/// taken partway keeps the value AND its velocity, so a quick run of changes
+/// never snaps or starts over. A move from rest can wind up first
+/// (Motion.anticipate). Settled, it is exactly its target and asks for no
+/// frames.
+pub const Spring = struct {
+ omega: f32 = 36,
+ zeta: f32 = 1,
target: f32 = 0,
/// The value and velocity (per second) at `from_ns`.
from: f32 = 0,
@@ -613,20 +652,41 @@ pub const Spring = struct {
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 };
+ const v0 = spring.velocity;
+ if (spring.zeta >= 1) {
+ const c = v0 + w * x0;
+ const decay = @exp(-w * t);
+ return .{ .value = spring.target + (x0 + c * t) * decay, .velocity = (v0 - w * t * c) * decay };
+ }
+ // Underdamped: it rings about its target as it decays.
+ const z = spring.zeta;
+ const wd = w * @sqrt(1 - z * z);
+ const b = (v0 + z * w * x0) / wd;
+ const decay = @exp(-z * w * t);
+ const cos = @cos(wd * t);
+ const sin = @sin(wd * t);
+ return .{
+ .value = spring.target + decay * (x0 * cos + b * sin),
+ .velocity = decay * ((b * wd - z * w * x0) * cos - (x0 * wd + z * w * b) * sin),
+ };
}
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 {
+ /// Heads for `target` from wherever it is at `now_ns`, moving as it was,
+ /// at the pace and damping `motion` gives: at once when it is `off`.
+ pub fn retarget(spring: *Spring, target: f32, now_ns: u64, motion: Motion) void {
if (target == spring.target) return;
+ if (motion.instant) {
+ spring.* = .{ .omega = motion.omega, .zeta = motion.zeta, .target = target, .from = 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 };
+ // From rest, a wind-up: set off the other way, just a little.
+ const kick: f32 = if (spring.settled) -motion.anticipate * (target - state.value) * motion.omega * 2 else 0;
+ spring.* = .{ .omega = motion.omega, .zeta = motion.zeta, .target = target, .from = state.value, .velocity = state.velocity + kick, .from_ns = now_ns, .settled = false };
}
/// Settles once it is within half a percent of its target and barely
@@ -643,7 +703,8 @@ pub const Spring = struct {
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);
+ const crisp = Motion.of(.crisp);
+ spring.retarget(1, 0, crisp);
var last: f32 = 0;
var t: u64 = 0;
while (spring.step(t)) : (t += frame_ns) {
@@ -651,21 +712,51 @@ test "a spring settles without overshoot and keeps its velocity when retargeted"
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.expect(t >= 150 * 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
+ // Back the other way partway up: it carries on up for a moment, then
// turns, never jumping.
spring = .{};
- spring.retarget(1, 0);
+ spring.retarget(1, 0, crisp);
const before = spring.at(60 * ms);
- spring.retarget(0, 60 * ms);
+ spring.retarget(0, 60 * ms, crisp);
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);
}
+test "each flavour is its own motion: off lands at once, bouncy overshoots, playful winds up first" {
+ const ms = std.time.ns_per_ms;
+ var spring: Spring = .{};
+ spring.retarget(1, 0, Motion.of(.off));
+ try std.testing.expect(spring.settled);
+ try std.testing.expectEqual(@as(f32, 1), spring.value(0));
+ for ([_]Motion.Flavour{ .crisp, .smooth, .bouncy, .playful }) |flavour| {
+ spring = .{};
+ spring.retarget(1, 0, Motion.of(flavour));
+ var peak: f32 = 0;
+ var low: f32 = 0;
+ var t: u64 = 0;
+ var last = spring.at(0);
+ while (spring.step(t)) : (t += ms) {
+ const now = spring.at(t);
+ peak = @max(peak, now.value);
+ low = @min(low, now.value);
+ // Continuous, a millisecond at a time: no jump anywhere.
+ try std.testing.expect(@abs(now.value - last.value) < 0.05);
+ last = now;
+ }
+ try std.testing.expect(t < 1200 * ms);
+ switch (flavour) {
+ .crisp, .smooth => try std.testing.expect(peak <= 1.0005 and low >= 0),
+ .bouncy => try std.testing.expect(peak > 1.05 and low >= 0),
+ .playful => try std.testing.expect(peak > 1.05 and low < -0.01),
+ .off => unreachable,
+ }
+ }
+}
+
/// 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 {