Skip to content

Commit d5ad40f

Browse files
dbismutaleclarson
authored andcommitted
feat: reworked FrameLoop logic
- Calculate velocity for "decay" and "easing" animations - When "config.clamp" is a number, it becomes the coefficient of restitution - Account for dropped frames in "decay" and "easing" animations - Fixed "config.velocity" to be per ms instead of per sec - Implement variable timesteps as described in #678 - Bounce animations can be done with "config.clamp" > 0 - The default value of "config.precision" is now derived from the distance between "to" and "from"
1 parent 9c19e36 commit d5ad40f

File tree

5 files changed

+107
-84
lines changed

5 files changed

+107
-84
lines changed

packages/animated/src/AnimatedValue.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,16 @@ export class AnimatedValue<T = unknown> extends Animated
1313
value: T
1414
startPosition!: number
1515
lastPosition!: number
16-
lastVelocity?: number
17-
startTime?: number
18-
lastTime?: number
19-
done = false
16+
lastVelocity!: number | null
17+
startTime!: number
18+
elapsedTime!: number
19+
done!: boolean
2020

2121
constructor(value: T) {
2222
super()
2323
this.value = value
2424
this.payload = new Set([this])
25-
if (is.num(value)) {
26-
this.startPosition = value
27-
this.lastPosition = value
28-
}
25+
this.reset(false)
2926
}
3027

3128
getValue() {
@@ -59,9 +56,11 @@ export class AnimatedValue<T = unknown> extends Animated
5956
if (is.num(this.value)) {
6057
this.startPosition = this.value
6158
this.lastPosition = this.value
62-
this.lastVelocity = isActive ? this.lastVelocity : undefined
63-
this.lastTime = isActive ? this.lastTime : undefined
59+
if (!isActive) {
60+
this.lastVelocity = null
61+
}
6462
this.startTime = G.now()
63+
this.elapsedTime = 0
6564
}
6665
this.done = false
6766
this.views.clear()

packages/core/src/Controller.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,8 @@ export class Controller<State extends Indexable = any> {
633633
(parent && parent.getPayload(key)) ||
634634
toArray(isInterpolated ? 1 : goalValue)
635635

636+
const tension = withDefault(config.tension, 170)
637+
const mass = withDefault(config.mass, 1)
636638
this.animations[key] = {
637639
key,
638640
idle: false,
@@ -645,12 +647,13 @@ export class Controller<State extends Indexable = any> {
645647
duration: config.duration,
646648
easing: withDefault(config.easing, linear),
647649
decay: config.decay,
648-
mass: withDefault(config.mass, 1),
649-
tension: withDefault(config.tension, 170),
650+
w0: Math.sqrt(tension / mass) / 1000, // angular frequency in rad/ms
651+
mass,
652+
tension,
650653
friction: withDefault(config.friction, 26),
651654
initialVelocity: withDefault(config.velocity, 0),
652655
clamp: withDefault(config.clamp, false),
653-
precision: withDefault(config.precision, 0.005),
656+
precision: config.precision,
654657
config,
655658
}
656659
}

packages/core/src/FrameLoop.ts

Lines changed: 89 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { FrameRequestCallback } from 'shared/types'
44
import { Controller, FrameUpdate } from './Controller'
55
import { ActiveAnimation } from './types/spring'
66

7-
type FrameUpdater = (this: FrameLoop) => boolean
7+
type FrameUpdater = (this: FrameLoop, time?: number) => boolean
88
type FrameListener = (this: FrameLoop, updates: FrameUpdate[]) => void
99
type RequestFrameFn = (cb: FrameRequestCallback) => number | void
1010

@@ -38,6 +38,8 @@ export class FrameLoop {
3838
*/
3939
requestFrame: RequestFrameFn
4040

41+
lastTime?: number
42+
4143
constructor({
4244
update,
4345
onFrame,
@@ -62,34 +64,44 @@ export class FrameLoop {
6264

6365
this.update =
6466
(update && update.bind(this)) ||
65-
(() => {
67+
((time?: number) => {
6668
if (this.idle) {
6769
return false
6870
}
6971

70-
// Update the animations.
71-
const updates: FrameUpdate[] = []
72-
for (const id of Array.from(this.controllers.keys())) {
73-
let idle = true
74-
const ctrl = this.controllers.get(id)!
75-
const changes: FrameUpdate[2] = ctrl.props.onFrame ? [] : null
76-
for (const config of ctrl.configs) {
77-
if (config.idle) continue
78-
if (this.advance(config, changes)) {
79-
idle = false
72+
time = time !== void 0 ? time : performance.now()
73+
this.lastTime = this.lastTime !== void 0 ? this.lastTime : time
74+
let dt = time - this.lastTime!
75+
76+
// http://gafferongames.com/game-physics/fix-your-timestep/
77+
if (dt > 64) dt = 64
78+
79+
if (dt > 0) {
80+
// Update the animations.
81+
const updates: FrameUpdate[] = []
82+
for (const id of Array.from(this.controllers.keys())) {
83+
let idle = true
84+
const ctrl = this.controllers.get(id)!
85+
const changes: FrameUpdate[2] = ctrl.props.onFrame ? [] : null
86+
for (const config of ctrl.configs) {
87+
if (config.idle) continue
88+
if (this.advance(dt, config, changes)) {
89+
idle = false
90+
}
91+
}
92+
if (idle || changes) {
93+
updates.push([id, idle, changes])
8094
}
8195
}
82-
if (idle || changes) {
83-
updates.push([id, idle, changes])
84-
}
85-
}
8696

87-
// Notify the controllers!
88-
this.onFrame(updates)
97+
// Notify the controllers!
98+
this.onFrame(updates)
99+
this.lastTime = time
89100

90-
// Are we done yet?
91-
if (!this.controllers.size) {
92-
return !(this.idle = true)
101+
// Are we done yet?
102+
if (!this.controllers.size) {
103+
return !(this.idle = true)
104+
}
93105
}
94106

95107
// Keep going.
@@ -102,6 +114,7 @@ export class FrameLoop {
102114
this.controllers.set(ctrl.id, ctrl)
103115
if (this.idle) {
104116
this.idle = false
117+
this.lastTime = undefined
105118
this.requestFrame(this.update)
106119
}
107120
}
@@ -111,9 +124,11 @@ export class FrameLoop {
111124
}
112125

113126
/** Advance an animation forward one frame. */
114-
advance(config: ActiveAnimation, changes: FrameUpdate[2]): boolean {
115-
const time = G.now()
116-
127+
advance(
128+
dt: number,
129+
config: ActiveAnimation,
130+
changes: FrameUpdate[2]
131+
): boolean {
117132
let active = false
118133
let changed = false
119134
for (let i = 0; i < config.animatedValues.length; i++) {
@@ -125,86 +140,90 @@ export class FrameLoop {
125140
const target: any = to instanceof Animated ? to : null
126141
if (target) to = target.getValue()
127142

143+
const from: any = config.fromValues[i]
144+
128145
// Jump to end value for immediate animations
129-
if (config.immediate) {
146+
if (
147+
config.immediate ||
148+
typeof from === 'string' ||
149+
typeof to === 'string'
150+
) {
130151
animated.setValue(to)
131152
animated.done = true
132153
continue
133154
}
134155

135-
const from: any = config.fromValues[i]
136-
const startTime = animated.startTime!
156+
const startTime = animated.startTime
157+
const elapsed = (animated.elapsedTime += dt)
137158

138-
// Break animation when string values are involved
139-
if (typeof from === 'string' || typeof to === 'string') {
140-
animated.setValue(to)
141-
animated.done = true
142-
continue
143-
}
159+
const v0 = Array.isArray(config.initialVelocity)
160+
? config.initialVelocity[i]
161+
: config.initialVelocity
162+
163+
const precision =
164+
config.precision || Math.min(1, Math.abs(to - from) * 0.001)
144165

145166
let finished = false
146167
let position = animated.lastPosition
147-
let velocity = Array.isArray(config.initialVelocity)
148-
? config.initialVelocity[i]
149-
: config.initialVelocity
168+
169+
let velocity: number
150170

151171
// Duration easing
152172
if (config.duration !== void 0) {
153173
position =
154-
from +
155-
config.easing!((time - startTime) / config.duration) * (to - from)
174+
from + config.easing!(elapsed / config.duration) * (to - from)
175+
176+
velocity = (position - animated.lastPosition) / dt
156177

157-
finished = time >= startTime + config.duration
178+
finished = G.now() >= startTime + config.duration
158179
}
159180
// Decay easing
160181
else if (config.decay) {
161182
const decay = config.decay === true ? 0.998 : config.decay
162-
position =
163-
from +
164-
(velocity / (1 - decay)) *
165-
(1 - Math.exp(-(1 - decay) * (time - startTime)))
183+
const e = Math.exp(-(1 - decay) * elapsed)
184+
185+
position = from + (v0 / (1 - decay)) * (1 - e)
186+
// derivative of position
187+
velocity = v0 * e
166188

167189
finished = Math.abs(animated.lastPosition - position) < 0.1
168190
if (finished) to = position
169191
}
170192
// Spring easing
171193
else {
172-
let lastTime = animated.lastTime !== void 0 ? animated.lastTime : time
173-
if (animated.lastVelocity !== void 0) {
174-
velocity = animated.lastVelocity
175-
}
194+
velocity = animated.lastVelocity == null ? v0 : animated.lastVelocity
195+
196+
const step = 0.05 / config.w0
197+
const numSteps = Math.ceil(dt / step)
176198

177-
// If we lost a lot of frames just jump to the end.
178-
if (time > lastTime + 64) lastTime = time
179-
// http://gafferongames.com/game-physics/fix-your-timestep/
180-
const numSteps = Math.floor(time - lastTime)
181199
for (let n = 0; n < numSteps; ++n) {
182-
const force = -config.tension! * (position - to)
183-
const damping = -config.friction! * velocity
184-
const acceleration = (force + damping) / config.mass!
185-
velocity = velocity + (acceleration * 1) / 1000
186-
position = position + (velocity * 1) / 1000
200+
const springForce = -config.tension! * 0.000001 * (position - to)
201+
const dampingForce = -config.friction! * 0.001 * velocity
202+
const acceleration = (springForce + dampingForce) / config.mass! // pt/ms^2
203+
velocity = velocity + acceleration * step // pt/ms
204+
position = position + velocity * step
187205
}
188206

189-
animated.lastTime = time
190-
animated.lastVelocity = velocity
191-
192207
// Conditions for stopping the spring animation
193-
const isOvershooting =
194-
config.clamp && config.tension !== 0
208+
const isBouncing =
209+
config.clamp !== false && config.tension !== 0
195210
? from < to
196-
? position > to
197-
: position < to
211+
? position > to && velocity > 0
212+
: position < to && velocity < 0
198213
: false
199-
const isVelocity = Math.abs(velocity) <= config.precision!
214+
215+
if (isBouncing) {
216+
velocity =
217+
-velocity * (config.clamp === true ? 0 : (config.clamp as any))
218+
}
219+
220+
const isVelocity = Math.abs(velocity) <= precision
200221
const isDisplacement =
201-
config.tension !== 0
202-
? Math.abs(to - position) <= config.precision!
203-
: true
222+
config.tension !== 0 ? Math.abs(to - position) <= precision : true
204223

205-
finished = isOvershooting || (isVelocity && isDisplacement)
224+
finished =
225+
(isBouncing && velocity === 0) || (isVelocity && isDisplacement)
206226
}
207-
208227
// Trails aren't done until their parents conclude
209228
if (finished && !(target && !target.done)) {
210229
// Ensure that we end up with a round value
@@ -216,6 +235,7 @@ export class FrameLoop {
216235

217236
animated.setValue(position)
218237
animated.lastPosition = position
238+
animated.lastVelocity = velocity
219239
}
220240

221241
if (changes && changed) {

packages/core/src/types/spring.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export interface SpringConfig {
9797
tension?: number
9898
friction?: number
9999
velocity?: number
100-
clamp?: boolean
100+
clamp?: number | boolean
101101
precision?: number
102102
delay?: number
103103
decay?: number | boolean
@@ -177,6 +177,7 @@ export interface ActiveAnimation<T = unknown, P extends string = string>
177177
config: SpringConfig
178178
initialVelocity: number
179179
immediate: boolean
180+
w0: number
180181
toValues: Arrify<T>
181182
fromValues: Arrify<T>
182183
}

packages/shared/src/globals.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export let createStringInterpolator: (
2525

2626
/** Provide a custom `FrameLoop` instance */
2727
export let frameLoop: {
28-
update: () => boolean
28+
update: (time?: number) => boolean
2929
controllers: Map<number, any>
3030
start(controller: any): void
3131
stop(controller: any): void

0 commit comments

Comments
 (0)