From c82ddcd2bba1b7544a0c29c292ba5f7a7f713708 Mon Sep 17 00:00:00 2001 From: professorpalmer Date: Mon, 31 Aug 2026 00:06:35 -0500 Subject: [PATCH 1/2] feat(motion): native spring integrator and GELATIN preset --- packages/native/src/motion.rs | 287 ++++++++++++++++-- .../react/src/__tests__/motion-spring.test.ts | 28 ++ packages/react/src/components/index.ts | 100 +++++- packages/react/src/index.ts | 3 + packages/react/src/motion-spring.ts | 41 +++ packages/react/src/reconciler/renderer.ts | 4 + packages/react/src/types/host.ts | 15 +- 7 files changed, 450 insertions(+), 28 deletions(-) create mode 100644 packages/react/src/__tests__/motion-spring.test.ts create mode 100644 packages/react/src/motion-spring.ts diff --git a/packages/native/src/motion.rs b/packages/native/src/motion.rs index eb719b80..ec93b2bb 100644 --- a/packages/native/src/motion.rs +++ b/packages/native/src/motion.rs @@ -1,4 +1,5 @@ //! Native motion tracks resolved during GPUI rendering, outside React. +//! Tween (duration/ease) plus spring (stiffness/damping/mass/velocity) integrators. use std::time::Duration; @@ -38,6 +39,33 @@ impl MotionStyle { } } + fn channels(self) -> [( &'static str, Option); 8] { + [ + ("width", self.width), + ("height", self.height), + ("opacity", self.opacity), + ("top", self.top), + ("right", self.right), + ("bottom", self.bottom), + ("left", self.left), + ("borderRadius", self.border_radius), + ] + } + + fn set(&mut self, name: &str, value: f64) { + match name { + "width" => self.width = Some(value), + "height" => self.height = Some(value), + "opacity" => self.opacity = Some(value), + "top" => self.top = Some(value), + "right" => self.right = Some(value), + "bottom" => self.bottom = Some(value), + "left" => self.left = Some(value), + "borderRadius" => self.border_radius = Some(value), + _ => {} + } + } + pub(crate) fn apply_to(self, style: &mut StyleDesc) { if let Some(value) = self.width { style.width = Some(DimensionValue::Pixels(value)); @@ -82,7 +110,7 @@ enum MotionEase { #[derive(Clone, Debug, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -struct MotionTransition { +struct TweenTransition { #[serde(default = "default_duration")] duration: f64, #[serde(default)] @@ -91,13 +119,37 @@ struct MotionTransition { ease: MotionEase, } +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct SpringTransition { + #[serde(rename = "type")] + kind: String, + #[serde(default = "default_stiffness")] + stiffness: f64, + #[serde(default = "default_damping")] + damping: f64, + #[serde(default = "default_mass")] + mass: f64, + #[serde(default)] + velocity: f64, + #[serde(default)] + delay: f64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(untagged)] +enum MotionTransition { + Spring(SpringTransition), + Tween(TweenTransition), +} + impl Default for MotionTransition { fn default() -> Self { - Self { + Self::Tween(TweenTransition { duration: default_duration(), delay: 0.0, ease: default_ease(), - } + }) } } @@ -109,6 +161,18 @@ fn default_ease() -> MotionEase { MotionEase::Name("easeOut".to_string()) } +fn default_stiffness() -> f64 { + 36.0 +} + +fn default_damping() -> f64 { + 8.0 +} + +fn default_mass() -> f64 { + 1.2 +} + #[derive(Clone, Debug, Deserialize, PartialEq)] struct MotionDescription { #[serde(default)] @@ -124,15 +188,35 @@ pub(crate) struct MotionFrame { pub active: bool, } +#[derive(Clone, Copy, Debug, Default)] +struct SpringTrack { + pos: f64, + vel: f64, +} + pub(crate) struct MotionState { source: serde_json::Value, from: MotionStyle, target: MotionStyle, + current: MotionStyle, transition: MotionTransition, started: Instant, + last: Instant, + springs: [SpringTrack; 8], valid: bool, } +const CHANNELS: [&str; 8] = [ + "width", + "height", + "opacity", + "top", + "right", + "bottom", + "left", + "borderRadius", +]; + impl MotionState { pub(crate) fn new(source: &serde_json::Value, now: Instant) -> Result { let description = parse_description(source)?; @@ -141,13 +225,16 @@ impl MotionState { Some(MotionInitial::Disabled(false)) | None => description.animate, Some(MotionInitial::Disabled(true)) => unreachable!("validated above"), }; - + let kick = spring_kick(&description.transition); Ok(Self { source: source.clone(), from, target: description.animate, + current: from, transition: description.transition, started: now, + last: now, + springs: seed_springs(from, description.animate, kick), valid: true, }) } @@ -157,8 +244,11 @@ impl MotionState { source: source.clone(), from: MotionStyle::default(), target: MotionStyle::default(), + current: MotionStyle::default(), transition: MotionTransition::default(), started: now, + last: now, + springs: [SpringTrack::default(); 8], valid: false, } } @@ -180,7 +270,7 @@ impl MotionState { return Err(error); } }; - self.from = if self.valid { + let visible = if self.valid { self.frame(now).style } else { match description.initial { @@ -189,17 +279,40 @@ impl MotionState { Some(MotionInitial::Disabled(true)) => unreachable!("validated above"), } }; + self.from = visible; + self.current = visible; self.target = description.animate; + let kick = spring_kick(&description.transition); + if matches!(description.transition, MotionTransition::Spring(_)) { + // Keep velocity; retarget in place so overshoot carries. + for (index, name) in CHANNELS.iter().enumerate() { + let pos = channel(visible, name).unwrap_or_else(|| channel(description.animate, name).unwrap_or(0.0)); + self.springs[index].pos = pos; + if self.springs[index].vel.abs() < f64::EPSILON { + self.springs[index].vel = kick; + } + } + } else { + self.springs = seed_springs(visible, description.animate, kick); + } self.transition = description.transition; self.started = now; + self.last = now; self.source = source.clone(); self.valid = true; Ok(()) } - pub(crate) fn frame(&self, now: Instant) -> MotionFrame { - let delay = seconds(self.transition.delay); - let duration = seconds(self.transition.duration); + pub(crate) fn frame(&mut self, now: Instant) -> MotionFrame { + match &self.transition { + MotionTransition::Spring(spring) => self.spring_frame(now, spring.clone()), + MotionTransition::Tween(tween) => self.tween_frame(now, tween.clone()), + } + } + + fn tween_frame(&self, now: Instant, tween: TweenTransition) -> MotionFrame { + let delay = seconds(tween.delay); + let duration = seconds(tween.duration); let elapsed = now.saturating_duration_since(self.started); let raw = if elapsed <= delay { 0.0 @@ -209,13 +322,103 @@ impl MotionState { elapsed.saturating_sub(delay).as_secs_f64() / duration.as_secs_f64() }; let active = self.from != self.target && raw < 1.0; - let progress = ease(raw.clamp(0.0, 1.0), &self.transition.ease); - + let progress = ease(raw.clamp(0.0, 1.0), &tween.ease); MotionFrame { style: self.from.interpolate(self.target, progress), active, } } + + fn spring_frame(&mut self, now: Instant, spring: SpringTransition) -> MotionFrame { + let delay = seconds(spring.delay); + if now.saturating_duration_since(self.started) < delay { + self.last = now; + return MotionFrame { + style: self.current, + active: self.from != self.target, + }; + } + let mut dt = now.saturating_duration_since(self.last).as_secs_f64(); + self.last = now; + if dt <= 0.0 { + return MotionFrame { + style: self.current, + active: !settled(&self.springs, self.target), + }; + } + dt = dt.min(0.032); + let mut style = self.current; + let mut active = false; + for (index, name) in CHANNELS.iter().enumerate() { + let Some(target) = channel(self.target, name) else { + continue; + }; + let rest = if *name == "opacity" { 0.002 } else { 0.05 }; + let next = step_spring(self.springs[index], target, dt, spring.stiffness, spring.damping, spring.mass, rest); + self.springs[index] = next; + style.set(name, next.pos); + if (next.pos - target).abs() > rest || next.vel.abs() > rest { + active = true; + } + } + self.current = style; + MotionFrame { style, active } + } +} + +fn channel(style: MotionStyle, name: &str) -> Option { + match name { + "width" => style.width, + "height" => style.height, + "opacity" => style.opacity, + "top" => style.top, + "right" => style.right, + "bottom" => style.bottom, + "left" => style.left, + "borderRadius" => style.border_radius, + _ => None, + } +} + +fn spring_kick(transition: &MotionTransition) -> f64 { + match transition { + MotionTransition::Spring(spring) => spring.velocity, + MotionTransition::Tween(_) => 0.0, + } +} + +fn seed_springs(from: MotionStyle, target: MotionStyle, kick: f64) -> [SpringTrack; 8] { + let mut tracks = [SpringTrack::default(); 8]; + for (index, name) in CHANNELS.iter().enumerate() { + let pos = channel(from, name).or_else(|| channel(target, name)).unwrap_or(0.0); + tracks[index] = SpringTrack { pos, vel: kick }; + } + tracks +} + +fn settled(tracks: &[SpringTrack; 8], target: MotionStyle) -> bool { + for (index, name) in CHANNELS.iter().enumerate() { + let Some(to) = channel(target, name) else { + continue; + }; + if (tracks[index].pos - to).abs() > 0.05 || tracks[index].vel.abs() > 0.05 { + return false; + } + } + true +} + +fn step_spring(track: SpringTrack, target: f64, dt: f64, stiffness: f64, damping: f64, mass: f64, rest: f64) -> SpringTrack { + let mass = mass.max(0.001); + let x = track.pos - target; + let accel = (-stiffness * x - damping * track.vel) / mass; + let vel = track.vel + accel * dt; + let pos = track.pos + vel * dt; + if (pos - target).abs() < rest && vel.abs() < rest { + SpringTrack { pos: target, vel: 0.0 } + } else { + SpringTrack { pos, vel } + } } fn parse_description(source: &serde_json::Value) -> Result { @@ -229,23 +432,30 @@ fn parse_description(source: &serde_json::Value) -> Result { + validate_seconds(tween.duration, "duration")?; + validate_seconds(tween.delay, "delay")?; + validate_ease(&tween.ease)?; + } + MotionTransition::Spring(spring) => { + if spring.kind != "spring" { + return Err(format!("unknown motion type: {}", spring.kind)); + } + validate_positive(spring.stiffness, "stiffness")?; + validate_positive(spring.damping, "damping")?; + validate_positive(spring.mass, "mass")?; + validate_seconds(spring.delay, "delay")?; + if !spring.velocity.is_finite() { + return Err("motion velocity must be finite".to_string()); + } + } + } Ok(description) } fn validate_style(style: &MotionStyle) -> Result<(), String> { - for (name, value) in [ - ("width", style.width), - ("height", style.height), - ("opacity", style.opacity), - ("top", style.top), - ("right", style.right), - ("bottom", style.bottom), - ("left", style.left), - ("borderRadius", style.border_radius), - ] { + for (name, value) in style.channels() { if value.is_some_and(|value| !value.is_finite() || value.abs() > f32::MAX as f64) { return Err(format!("motion {name} must fit a finite 32-bit float")); } @@ -274,6 +484,13 @@ fn validate_seconds(value: f64, name: &str) -> Result<(), String> { Ok(()) } +fn validate_positive(value: f64, name: &str) -> Result<(), String> { + if !value.is_finite() || value <= 0.0 { + return Err(format!("motion {name} must be a finite number greater than 0")); + } + Ok(()) +} + fn validate_ease(ease: &MotionEase) -> Result<(), String> { match ease { MotionEase::Name(name) @@ -406,10 +623,32 @@ mod tests { "animate": { "width": 100.0 }, "transition": { "duration": 0.2, "ease": "linear" } }); - let state = MotionState::new(&description, started).unwrap(); + let mut state = MotionState::new(&description, started).unwrap(); let frame = state.frame(started + Duration::from_millis(200)); assert_eq!(frame.style.width, Some(100.0)); assert!(!frame.active); } + + #[test] + fn spring_overshoots_then_settles() { + let started = Instant::now(); + let description = serde_json::json!({ + "initial": { "width": 0.0 }, + "animate": { "width": 100.0 }, + "transition": { "type": "spring", "stiffness": 40.0, "damping": 6.0, "mass": 1.0 } + }); + let mut state = MotionState::new(&description, started).unwrap(); + let mut max_width = 0.0; + let mut now = started; + for _ in 0..120 { + now += Duration::from_millis(8); + let frame = state.frame(now); + max_width = max_width.max(frame.style.width.unwrap_or(0.0)); + } + assert!(max_width > 100.0, "gelatinous spring must overshoot, got {max_width}"); + let settled = state.frame(now); + assert!((settled.style.width.unwrap_or(0.0) - 100.0).abs() < 1.0); + assert!(!settled.active); + } } diff --git a/packages/react/src/__tests__/motion-spring.test.ts b/packages/react/src/__tests__/motion-spring.test.ts new file mode 100644 index 00000000..70497f42 --- /dev/null +++ b/packages/react/src/__tests__/motion-spring.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest" +import { GELATIN, stepSpring, type SpringTrack } from "../motion-spring.js" + +describe("stepSpring", () => { + it("snaps to rest when inside the rest window", () => { + const next = stepSpring({ pos: 100.02, vel: 0.01 }, 100, 1 / 60, 28, 8, 1.25) + expect(next).toEqual({ pos: 100, vel: 0 }) + }) + + it("overshoots with GELATIN then settles on the target", () => { + let track: SpringTrack = { pos: 0, vel: 0 } + let max = 0 + for (let i = 0; i < 240; i++) { + track = stepSpring( + track, + 100, + 1 / 60, + GELATIN.stiffness, + GELATIN.damping, + GELATIN.mass + ) + max = Math.max(max, track.pos) + } + expect(max).toBeGreaterThan(100) + expect(track.pos).toBe(100) + expect(track.vel).toBe(0) + }) +}) diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 548e7ea5..1f205a51 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -1,8 +1,16 @@ // GPUIX component definitions and native motion wrappers. -import { createElement, forwardRef } from "react" +import { createElement, forwardRef, useEffect, useRef, useState } from "react" import type { ReactElement, ReactNode } from "react" -import type { MotionProps, Props, PublicInstance, StyleDesc } from "../types/host.js" +import type { + MotionProps, + MotionSpringTransition, + MotionStyle, + Props, + PublicInstance, + StyleDesc, +} from "../types/host.js" +import { GELATIN, onFrame, stepSpring, type SpringTrack } from "../motion-spring.js" export const gpuixComponents = { div: "div", @@ -36,13 +44,99 @@ export interface MotionDivProps extends MotionProps { autoFocus?: boolean } +const SPRING_KEYS = [ + "width", + "height", + "opacity", + "top", + "right", + "bottom", + "left", + "borderRadius", +] as const + +type SpringKey = (typeof SPRING_KEYS)[number] + +function isSpringTransition( + transition: MotionProps["transition"] +): transition is MotionSpringTransition { + return transition != null && transition.type === "spring" +} + +function readStyle(style: MotionStyle | false | undefined, key: SpringKey): number | undefined { + if (style == null || style === false) return undefined + return style[key] +} + const MotionDiv = forwardRef(function MotionDiv( - { initial, animate, transition, ...props }, + { initial, animate, transition, style, ...props }, ref ): ReactElement { + const spring = isSpringTransition(transition) + const animateRef = useRef(animate) + animateRef.current = animate + const [current, setCurrent] = useState(() => { + const seed: MotionStyle = {} + for (const key of SPRING_KEYS) { + const value = readStyle(initial, key) ?? animate[key] + if (value != null) seed[key] = value + } + return seed + }) + const tracks = useRef>>({}) + const transitionRef = useRef(transition) + transitionRef.current = transition + + useEffect(() => { + if (!spring) return + return onFrame((dt) => { + const spec = transitionRef.current + if (!isSpringTransition(spec)) return + const stiffness = spec.stiffness ?? GELATIN.stiffness + const damping = spec.damping ?? GELATIN.damping + const mass = spec.mass ?? GELATIN.mass + const kick = spec.velocity ?? 0 + const target = animateRef.current + let changed = false + const next: MotionStyle = {} + for (const key of SPRING_KEYS) { + const to = target[key] + if (to == null) continue + const rest = key === "opacity" ? 0.002 : 0.05 + let track = tracks.current[key] + if (!track) track = { pos: to, vel: kick } + const stepped = stepSpring(track, to, dt, stiffness, damping, mass, rest) + if (stepped.pos !== track.pos || stepped.vel !== track.vel) changed = true + tracks.current[key] = stepped + next[key] = stepped.pos + } + if (changed) setCurrent(next) + }) + }, [spring]) + + if (spring) { + const hostProps: Props = { + ...props, + ref, + style: { + ...(style ?? {}), + ...(current.width != null ? { width: current.width } : {}), + ...(current.height != null ? { height: current.height } : {}), + ...(current.opacity != null ? { opacity: current.opacity } : {}), + ...(current.top != null ? { top: current.top } : {}), + ...(current.right != null ? { right: current.right } : {}), + ...(current.bottom != null ? { bottom: current.bottom } : {}), + ...(current.left != null ? { left: current.left } : {}), + ...(current.borderRadius != null ? { borderRadius: current.borderRadius } : {}), + }, + } + return createElement("div", hostProps) + } + const hostProps: Props = { ...props, ref, + style, motion: { ...(initial === undefined ? {} : { initial }), animate, diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e3900886..8836e9b1 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -66,6 +66,7 @@ export type { TooltipTriggerProps, } from "./components/tooltip.js" export { motion } from "./components/index.js" +export { onFrame, stepSpring, GELATIN } from "./motion-spring.js" export type { Root, FrameLoop, RenderOptions } from "./reconciler/renderer.js" export type { WindowInsets, @@ -89,6 +90,8 @@ export type { MotionProps, MotionStyle, MotionTransition, + MotionSpringTransition, + MotionTweenTransition, NativeRenderer, NativeWindowInsets, PublicInstance, diff --git a/packages/react/src/motion-spring.ts b/packages/react/src/motion-spring.ts new file mode 100644 index 00000000..0cb10c16 --- /dev/null +++ b/packages/react/src/motion-spring.ts @@ -0,0 +1,41 @@ +/** Semi-implicit Euler spring. Runs on the GPUIX frame loop, not CSS tweens. */ + +export type SpringTrack = { pos: number; vel: number } + +export function stepSpring( + track: SpringTrack, + target: number, + dt: number, + stiffness: number, + damping: number, + mass: number, + rest = 0.05 +): SpringTrack { + const clamped = Math.min(Math.max(dt, 0), 0.032) + const m = Math.max(mass, 0.001) + const x = track.pos - target + const accel = (-stiffness * x - damping * track.vel) / m + const vel = track.vel + accel * clamped + const pos = track.pos + vel * clamped + if (Math.abs(pos - target) < rest && Math.abs(vel) < rest) { + return { pos: target, vel: 0 } + } + return { pos, vel } +} + +export type FrameListener = (dt: number, now: number) => void + +const listeners = new Set() + +export function onFrame(listener: FrameListener): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function pumpFrames(dt: number, now: number): void { + for (const listener of listeners) listener(dt, now) +} + +export const GELATIN = { stiffness: 28, damping: 8, mass: 1.25, velocity: 0 } diff --git a/packages/react/src/reconciler/renderer.ts b/packages/react/src/reconciler/renderer.ts index 00835bed..a41b0162 100644 --- a/packages/react/src/reconciler/renderer.ts +++ b/packages/react/src/reconciler/renderer.ts @@ -7,6 +7,7 @@ import type { NativeRenderer, WindowKeyEventHandlers, } from "../types/host.js" +import { pumpFrames } from "../motion-spring.js" import { handleGpuixEvent } from "./event-registry.js" import { App as AutomationApp, @@ -142,9 +143,12 @@ export function startFrameLoop( timer = null } + let lastFrame = performance.now() const loop = (): void => { if (stopped) return const started = performance.now() + pumpFrames(Math.min((started - lastFrame) / 1000, 0.032), started) + lastFrame = started let running = true try { running = renderer.tick() diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts index b7b3098f..413e5f3e 100644 --- a/packages/react/src/types/host.ts +++ b/packages/react/src/types/host.ts @@ -21,7 +21,8 @@ export type MotionEase = | "easeInOut" | [number, number, number, number] -export interface MotionTransition { +export interface MotionTweenTransition { + type?: "tween" /** Duration in seconds. */ duration?: number /** Delay in seconds. */ @@ -29,6 +30,18 @@ export interface MotionTransition { ease?: MotionEase } +/** Critically-underdamped integrator. Defaults are gelatinous, not snappy. */ +export interface MotionSpringTransition { + type: "spring" + stiffness?: number + damping?: number + mass?: number + velocity?: number + delay?: number +} + +export type MotionTransition = MotionTweenTransition | MotionSpringTransition + export interface MotionProps { initial?: MotionStyle | false animate: MotionStyle From d38c15f3aaad4b5331fac9d791bfb7ac793d315e Mon Sep 17 00:00:00 2001 From: Cary Palmer Date: Wed, 9 Sep 2026 21:24:23 -0500 Subject: [PATCH 2/2] fix(motion): park spring MotionDiv leases at rest Unsubscribe onFrame when every spring channel settles, skip setCurrent for unchanged integer px/opacity, and hard-settle leftover GELATIN crawl so GPUI is not rebuilt every frame. Tween motion.div is unchanged. Co-authored-by: Cary Palmer --- .changeset/spring-lease-parking.md | 5 + .../react/src/__tests__/motion-spring.test.ts | 151 +++++++++++++++- packages/react/src/components/index.ts | 80 +++++---- packages/react/src/motion-spring.ts | 167 +++++++++++++++++- 4 files changed, 364 insertions(+), 39 deletions(-) create mode 100644 .changeset/spring-lease-parking.md diff --git a/.changeset/spring-lease-parking.md b/.changeset/spring-lease-parking.md new file mode 100644 index 00000000..449701e5 --- /dev/null +++ b/.changeset/spring-lease-parking.md @@ -0,0 +1,5 @@ +--- +'@gpuix/react': patch +--- + +Park spring `motion.div` frame leases at rest so a settled spring stops calling `setCurrent` and no longer rebuilds GPUI every frame. A retarget re-subscribes `onFrame`. Soft GELATIN crawl hard-settles between 280ms and 420ms. Tween `motion.div` is unchanged. diff --git a/packages/react/src/__tests__/motion-spring.test.ts b/packages/react/src/__tests__/motion-spring.test.ts index 70497f42..5ae4aa99 100644 --- a/packages/react/src/__tests__/motion-spring.test.ts +++ b/packages/react/src/__tests__/motion-spring.test.ts @@ -1,5 +1,25 @@ -import { describe, expect, it } from "vitest" -import { GELATIN, stepSpring, type SpringTrack } from "../motion-spring.js" +import { afterEach, describe, expect, it } from "vitest" +import { + allSpringChannelsRest, + GELATIN, + isSpringRest, + pumpFrames, + quantizeSpringValue, + resetSpringClockForTests, + SETTLE_HARD_MS, + shouldSnapSpring, + snapSpring, + springClockBusy, + springShouldPublish, + stepSpring, + stepSpringLease, + subscribeSpringTick, + type SpringTrack, +} from "../motion-spring.js" + +afterEach(() => { + resetSpringClockForTests() +}) describe("stepSpring", () => { it("snaps to rest when inside the rest window", () => { @@ -26,3 +46,130 @@ describe("stepSpring", () => { expect(track.vel).toBe(0) }) }) + +describe("spring lease parking", () => { + it("skips no-op and sub-pixel publishes", () => { + expect(springShouldPublish(12, 12)).toBe(false) + expect(springShouldPublish(12, 12.4)).toBe(false) + expect(springShouldPublish(12, 12.6)).toBe(true) + expect(quantizeSpringValue(12.4)).toBe(12) + expect(quantizeSpringValue(12.6)).toBe(13) + expect(springShouldPublish(0.5, 0.5005, "opacity")).toBe(false) + expect(springShouldPublish(0.5, 0.51, "opacity")).toBe(true) + }) + + it("snaps a crawl, not an in-flight travel", () => { + expect(shouldSnapSpring({ pos: 10.3, vel: 0.02 }, 10)).toBe(true) + expect(shouldSnapSpring({ pos: 18, vel: 2.4 }, 10)).toBe(false) + expect(shouldSnapSpring({ pos: 68, vel: 40 }, 10, SETTLE_HARD_MS)).toBe(false) + expect(shouldSnapSpring({ pos: 12.5, vel: 1.2 }, 10, SETTLE_HARD_MS)).toBe(true) + expect(snapSpring(10)).toEqual({ pos: 10, vel: 0 }) + expect(isSpringRest(snapSpring(10), 10)).toBe(true) + }) + + it("parks onFrame when every channel is at rest", () => { + const tracks = { width: { pos: 10.2, vel: 0.08 } } + let painted = { width: 10 } + expect(springClockBusy()).toBe(false) + subscribeSpringTick((dt) => { + const result = stepSpringLease({ + tracks, + target: { width: 10 }, + painted, + dt, + elapsedMs: SETTLE_HARD_MS, + stiffness: GELATIN.stiffness, + damping: GELATIN.damping, + mass: GELATIN.mass, + }) + painted = result.painted + return result.moving + }) + expect(springClockBusy()).toBe(true) + pumpFrames(1 / 60, 0) + expect(isSpringRest(tracks.width, 10)).toBe(true) + expect(allSpringChannelsRest(tracks, { width: 10 })).toBe(true) + expect(springClockBusy()).toBe(false) + }) + + it("wakes on animate retarget", () => { + const tracks = { width: snapSpring(10) } + let painted = { width: 10 } + let target = { width: 10 } + const arm = () => { + if (allSpringChannelsRest(tracks, target)) return + return subscribeSpringTick((dt) => { + const result = stepSpringLease({ + tracks, + target, + painted, + dt, + elapsedMs: 0, + stiffness: GELATIN.stiffness, + damping: GELATIN.damping, + mass: GELATIN.mass, + }) + painted = result.painted + return result.moving + }) + } + + arm() + expect(springClockBusy()).toBe(false) + + target = { width: 80 } + arm() + expect(springClockBusy()).toBe(true) + pumpFrames(1 / 60, 16) + expect(tracks.width.pos).not.toBe(80) + expect(tracks.width.vel).not.toBe(0) + }) + + it("does not publish when the visual is already settled", () => { + const tracks = { width: { pos: 12.2, vel: 0.01 }, opacity: { pos: 1.0004, vel: 0 } } + const result = stepSpringLease({ + tracks, + target: { width: 12, opacity: 1 }, + painted: { width: 12, opacity: 1 }, + dt: 1 / 60, + elapsedMs: 0, + stiffness: GELATIN.stiffness, + damping: GELATIN.damping, + mass: GELATIN.mass, + }) + expect(result.publish).toBe(false) + expect(result.moving).toBe(false) + expect(result.painted).toEqual({ width: 12, opacity: 1 }) + + let publishes = 0 + subscribeSpringTick((dt) => { + const next = stepSpringLease({ + tracks, + target: { width: 12, opacity: 1 }, + painted: result.painted, + dt, + elapsedMs: SETTLE_HARD_MS, + stiffness: GELATIN.stiffness, + damping: GELATIN.damping, + mass: GELATIN.mass, + }) + if (next.publish) publishes += 1 + return next.moving + }) + pumpFrames(1 / 60, 32) + pumpFrames(1 / 60, 48) + pumpFrames(1 / 60, 64) + expect(publishes).toBe(0) + expect(springClockBusy()).toBe(false) + }) + + it("hard-settles a leftover GELATIN crawl inside the 280–420ms budget", () => { + let track = { pos: 0, vel: 0 } + for (let i = 0; i < 55; i += 1) { + const elapsed = (i + 1) * (1000 / 125) + track = stepSpring(track, 8, 8 / 1000, GELATIN.stiffness, GELATIN.damping, GELATIN.mass) + if (shouldSnapSpring(track, 8, elapsed)) track = snapSpring(8) + } + expect(isSpringRest(track, 8)).toBe(true) + }) +}) diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 1f205a51..cf7fe23a 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -10,7 +10,17 @@ import type { PublicInstance, StyleDesc, } from "../types/host.js" -import { GELATIN, onFrame, stepSpring, type SpringTrack } from "../motion-spring.js" +import { + allSpringChannelsRest, + animateSignature, + GELATIN, + seedSpringTrack, + SPRING_KEYS, + stepSpringLease, + subscribeSpringTick, + type SpringKey, + type SpringTrack, +} from "../motion-spring.js" export const gpuixComponents = { div: "div", @@ -44,19 +54,6 @@ export interface MotionDivProps extends MotionProps { autoFocus?: boolean } -const SPRING_KEYS = [ - "width", - "height", - "opacity", - "top", - "right", - "bottom", - "left", - "borderRadius", -] as const - -type SpringKey = (typeof SPRING_KEYS)[number] - function isSpringTransition( transition: MotionProps["transition"] ): transition is MotionSpringTransition { @@ -84,35 +81,46 @@ const MotionDiv = forwardRef(function MotionDiv( return seed }) const tracks = useRef>>({}) + const paintedRef = useRef(current) const transitionRef = useRef(transition) transitionRef.current = transition + const targetSignature = spring ? animateSignature(animate) : "" useEffect(() => { if (!spring) return - return onFrame((dt) => { - const spec = transitionRef.current - if (!isSpringTransition(spec)) return - const stiffness = spec.stiffness ?? GELATIN.stiffness - const damping = spec.damping ?? GELATIN.damping - const mass = spec.mass ?? GELATIN.mass - const kick = spec.velocity ?? 0 - const target = animateRef.current - let changed = false - const next: MotionStyle = {} - for (const key of SPRING_KEYS) { - const to = target[key] - if (to == null) continue - const rest = key === "opacity" ? 0.002 : 0.05 - let track = tracks.current[key] - if (!track) track = { pos: to, vel: kick } - const stepped = stepSpring(track, to, dt, stiffness, damping, mass, rest) - if (stepped.pos !== track.pos || stepped.vel !== track.vel) changed = true - tracks.current[key] = stepped - next[key] = stepped.pos + const spec = transitionRef.current + const kick = isSpringTransition(spec) ? (spec.velocity ?? 0) : 0 + const target = animateRef.current + for (const key of SPRING_KEYS) { + const to = target[key] + if (to == null) continue + seedSpringTrack(tracks.current, key, paintedRef.current[key], to, kick) + } + if (allSpringChannelsRest(tracks.current, target)) return + + let elapsedMs = 0 + return subscribeSpringTick((dt) => { + const live = transitionRef.current + if (!isSpringTransition(live)) return false + elapsedMs += dt * 1000 + const result = stepSpringLease({ + tracks: tracks.current, + target: animateRef.current, + painted: paintedRef.current, + dt, + elapsedMs, + stiffness: live.stiffness ?? GELATIN.stiffness, + damping: live.damping ?? GELATIN.damping, + mass: live.mass ?? GELATIN.mass, + kick: live.velocity ?? 0, + }) + if (result.publish) { + paintedRef.current = result.painted + setCurrent(result.painted) } - if (changed) setCurrent(next) + return result.moving }) - }, [spring]) + }, [spring, targetSignature]) if (spring) { const hostProps: Props = { diff --git a/packages/react/src/motion-spring.ts b/packages/react/src/motion-spring.ts index 0cb10c16..164f155a 100644 --- a/packages/react/src/motion-spring.ts +++ b/packages/react/src/motion-spring.ts @@ -2,6 +2,36 @@ export type SpringTrack = { pos: number; vel: number } +export type SpringChannelKind = "px" | "opacity" + +export const SPRING_KEYS = [ + "width", + "height", + "opacity", + "top", + "right", + "bottom", + "left", + "borderRadius", +] as const + +export type SpringKey = (typeof SPRING_KEYS)[number] + +/** Soft GELATIN crawls; the budget forces rest so `onFrame` can park. */ +export const SETTLE_BUDGET_MS = 280 +export const SETTLE_HARD_MS = 420 + +const SNAP_POS_PX = 1.05 +const SNAP_VEL_PX = 0.35 +const CRAWL_POS_PX = 2.25 +/** Hard settle only kills leftover crawl, not an in-flight travel. */ +const HARD_CRAWL_POS_PX = 8 +const SNAP_POS_OPACITY = 0.002 +const SNAP_VEL_OPACITY = 0.02 +const CRAWL_POS_OPACITY = 0.02 +const HARD_CRAWL_POS_OPACITY = 0.08 +const OPACITY_PUBLISH_EPS = 0.002 + export function stepSpring( track: SpringTrack, target: number, @@ -23,6 +53,122 @@ export function stepSpring( return { pos, vel } } +export function snapSpring(target: number): SpringTrack { + return { pos: target, vel: 0 } +} + +export function isSpringRest(track: SpringTrack, target: number): boolean { + return track.vel === 0 && track.pos === target +} + +export function springChannelKind(key: SpringKey): SpringChannelKind { + return key === "opacity" ? "opacity" : "px" +} + +export function shouldSnapSpring( + track: SpringTrack, + target: number, + elapsedMs = 0, + kind: SpringChannelKind = "px" +): boolean { + const dist = Math.abs(track.pos - target) + const speed = Math.abs(track.vel) + if (kind === "opacity") { + if (speed < SNAP_VEL_OPACITY && dist < SNAP_POS_OPACITY) return true + if (elapsedMs >= SETTLE_BUDGET_MS && dist < CRAWL_POS_OPACITY) return true + return elapsedMs >= SETTLE_HARD_MS && dist < HARD_CRAWL_POS_OPACITY + } + if (speed < SNAP_VEL_PX && dist < SNAP_POS_PX) return true + if (elapsedMs >= SETTLE_BUDGET_MS && dist < CRAWL_POS_PX) return true + return elapsedMs >= SETTLE_HARD_MS && dist < HARD_CRAWL_POS_PX +} + +export function springShouldPublish( + previous: number, + next: number, + kind: SpringChannelKind = "px" +): boolean { + if (kind === "opacity") return Math.abs(next - previous) >= OPACITY_PUBLISH_EPS + return Math.round(next) !== Math.round(previous) +} + +export function quantizeSpringValue(value: number, kind: SpringChannelKind = "px"): number { + if (kind === "opacity") return value + return Math.round(value) +} + +export function animateSignature(style: Partial>): string { + return SPRING_KEYS.map((key) => `${key}:${style[key] ?? ""}`).join("|") +} + +export function seedSpringTrack( + tracks: Partial>, + key: SpringKey, + from: number | undefined, + to: number, + kick = 0 +): SpringTrack { + const existing = tracks[key] + if (existing) return existing + const track = { pos: from ?? to, vel: kick } + tracks[key] = track + return track +} + +export function allSpringChannelsRest( + tracks: Partial>, + target: Partial> +): boolean { + for (const key of SPRING_KEYS) { + const to = target[key] + if (to == null) continue + const track = tracks[key] + if (!track || !isSpringRest(track, to)) return false + } + return true +} + +export function stepSpringLease(opts: { + tracks: Partial> + target: Partial> + painted: Partial> + dt: number + elapsedMs: number + stiffness: number + damping: number + mass: number + kick?: number +}): { + painted: Partial> + moving: boolean + publish: boolean +} { + const { tracks, target, dt, elapsedMs, stiffness, damping, mass, kick = 0 } = opts + let moving = false + let publish = false + const painted = { ...opts.painted } + for (const key of SPRING_KEYS) { + const to = target[key] + if (to == null) continue + const kind = springChannelKind(key) + const rest = kind === "opacity" ? SNAP_POS_OPACITY : 0.05 + let track = seedSpringTrack(tracks, key, painted[key], to, kick) + track = stepSpring(track, to, dt, stiffness, damping, mass, rest) + if (shouldSnapSpring(track, to, elapsedMs, kind)) track = snapSpring(to) + tracks[key] = track + if (!isSpringRest(track, to)) moving = true + const visual = isSpringRest(track, to) ? to : quantizeSpringValue(track.pos, kind) + const previous = painted[key] + if (previous == null || springShouldPublish(previous, visual, kind)) { + if (previous !== visual) { + painted[key] = visual + publish = true + } + } + } + return { painted, moving, publish } +} + export type FrameListener = (dt: number, now: number) => void const listeners = new Set() @@ -34,8 +180,27 @@ export function onFrame(listener: FrameListener): () => void { } } +/** + * Subscribe a spring tick. Unsubscribes itself when the tick returns false + * (every channel at rest), matching Automaton `subscribeSpringTick`. + */ +export function subscribeSpringTick(tick: (dt: number, now: number) => boolean): () => void { + const listener: FrameListener = (dt, now) => { + if (!tick(dt, now)) listeners.delete(listener) + } + return onFrame(listener) +} + +export function springClockBusy(): boolean { + return listeners.size > 0 +} + +export function resetSpringClockForTests(): void { + listeners.clear() +} + export function pumpFrames(dt: number, now: number): void { - for (const listener of listeners) listener(dt, now) + for (const listener of [...listeners]) listener(dt, now) } export const GELATIN = { stiffness: 28, damping: 8, mass: 1.25, velocity: 0 }