physics-animator 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/dist/AnimationSequencer.d.ts +36 -0
- package/dist/AnimationSequencer.js +151 -0
- package/dist/Animator.d.ts +125 -0
- package/dist/Animator.js +306 -0
- package/dist/Spring.d.ts +47 -0
- package/dist/Spring.js +117 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.js +3 -0
- package/dist/react/useAnimator.d.ts +12 -0
- package/dist/react/useAnimator.js +26 -0
- package/dist/react/useSpringState.d.ts +15 -0
- package/dist/react/useSpringState.js +13 -0
- package/dist/react/useSpringValue.d.ts +15 -0
- package/dist/react/useSpringValue.js +56 -0
- package/dist/three/ThreeAnimator.d.ts +56 -0
- package/dist/three/ThreeAnimator.js +450 -0
- package/dist/three/index.d.ts +1 -0
- package/dist/three/index.js +1 -0
- package/package.json +50 -0
- package/src/AnimationSequencer.ts +193 -0
- package/src/Animator.ts +377 -0
- package/src/Spring.ts +162 -0
- package/src/index.ts +3 -0
- package/src/react/index.ts +3 -0
- package/src/react/useAnimator.ts +45 -0
- package/src/react/useSpringState.ts +22 -0
- package/src/react/useSpringValue.ts +81 -0
- package/src/three/ThreeAnimator.ts +605 -0
- package/src/three/index.ts +1 -0
- package/tsconfig.json +14 -0
package/src/Animator.ts
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { EventSignal } from "@haxiomic/event-signal";
|
|
2
|
+
import { Spring, SpringParameters } from "./Spring.js";
|
|
3
|
+
|
|
4
|
+
enum AnimationType {
|
|
5
|
+
Spring = 0,
|
|
6
|
+
Tween,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Physically based animation of numeric properties of objects
|
|
11
|
+
*
|
|
12
|
+
* Designed to avoid discontinuities for smooth animation in all conditions
|
|
13
|
+
*/
|
|
14
|
+
export class Animator {
|
|
15
|
+
|
|
16
|
+
onBeforeStep = new EventSignal<{dt_s: number}>();
|
|
17
|
+
onAfterStep = new EventSignal<{dt_s: number}>();
|
|
18
|
+
protected _onAnimationComplete = new EventSignal<{object: any, field: string | number | symbol}>();
|
|
19
|
+
protected _onObjectAnimationsComplete = new EventSignal<{object: any}>();
|
|
20
|
+
|
|
21
|
+
animations = new Map<any, Map<string | number | symbol, {
|
|
22
|
+
target: number,
|
|
23
|
+
type: AnimationType,
|
|
24
|
+
springParams: Spring.PhysicsParameters | null,
|
|
25
|
+
tweenParams: Tween.Parameters | null,
|
|
26
|
+
step: TweenStepFn | null,
|
|
27
|
+
velocity: number,
|
|
28
|
+
}>>();
|
|
29
|
+
|
|
30
|
+
constructor(onBeforeStep?: (dt_s: number) => void, onAfterStep?: (dt_s: number) => void) {
|
|
31
|
+
if (onBeforeStep) {
|
|
32
|
+
this.onBeforeStep.addListener(e => onBeforeStep(e.dt_s));
|
|
33
|
+
}
|
|
34
|
+
if (onAfterStep) {
|
|
35
|
+
this.onAfterStep.addListener(e => onAfterStep(e.dt_s));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
springTo<Obj, Name extends keyof Obj>(object: Obj, field: Name, target: Obj[Name] & number, params: SpringParameters | null = { duration_s: 0.5 }) {
|
|
40
|
+
if (params != null) {
|
|
41
|
+
let spring = this.getAnimationOrCreate(object, field, AnimationType.Spring);
|
|
42
|
+
// update the target and parameters
|
|
43
|
+
spring.type = AnimationType.Spring;
|
|
44
|
+
spring.target = target;
|
|
45
|
+
spring.springParams = Spring.getPhysicsParameters(params);
|
|
46
|
+
spring.step = null;
|
|
47
|
+
} else {
|
|
48
|
+
this.setTo(object, field, target);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
customTweenTo<Obj, Name extends keyof Obj>(object: Obj, field: Name, target: Obj[Name] & number, duration_s: number, step: TweenStepFn) {
|
|
53
|
+
let animation = this.getAnimationOrCreate(object, field, AnimationType.Tween);
|
|
54
|
+
animation.type = AnimationType.Tween;
|
|
55
|
+
animation.target = target;
|
|
56
|
+
animation.tweenParams = {
|
|
57
|
+
x0: object[field] as number,
|
|
58
|
+
t0_ms: performance.now(),
|
|
59
|
+
duration_s: duration_s,
|
|
60
|
+
}
|
|
61
|
+
animation.step = step;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
linearTo<Obj, Name extends keyof Obj>(object: Obj, field: Name, target: Obj[Name] & number, duration_s: number) {
|
|
65
|
+
this.customTweenTo(object, field, target, duration_s, Tween.linearStep);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
easeInOutTo<Obj, Name extends keyof Obj>(object: Obj, field: Name, target: Obj[Name] & number, duration_s: number) {
|
|
69
|
+
this.customTweenTo(object, field, target, duration_s, Tween.easeInOutStep);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
easeInTo<Obj, Name extends keyof Obj>(object: Obj, field: Name, target: Obj[Name] & number, duration_s: number) {
|
|
73
|
+
this.customTweenTo(object, field, target, duration_s, Tween.easeInStep);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
easeOutTo<Obj, Name extends keyof Obj>(object: Obj, field: Name, target: Obj[Name] & number, duration_s: number) {
|
|
77
|
+
this.customTweenTo(object, field, target, duration_s, Tween.easeOutStep);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Remove animation from the object and set the field to the target value
|
|
82
|
+
*/
|
|
83
|
+
setTo<Obj, Name extends keyof Obj, T extends Obj[Name]>(object: Obj, field: Name, target: T) {
|
|
84
|
+
this.remove(object, field);
|
|
85
|
+
object[field] = target;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
onComplete<Obj, Name extends keyof Obj>(object: Obj, field: Name, callback: (object: Obj, field: Name) => void) {
|
|
89
|
+
return this._onAnimationComplete.addListener(e => {
|
|
90
|
+
if (e.object === object && e.field === field) {
|
|
91
|
+
callback(object, field);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
onAllComplete<Obj>(object: Obj, callback: (object: Obj) => void, once?: 'once') {
|
|
97
|
+
let listener = this._onObjectAnimationsComplete.addListener(e => {
|
|
98
|
+
if (e.object === object) {
|
|
99
|
+
callback(object);
|
|
100
|
+
if (once) {
|
|
101
|
+
listener.remove();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
return listener;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private _springState = { x: 0, targetX: 0, v: 0 };
|
|
109
|
+
step(dt_s: number) {
|
|
110
|
+
if (this.onBeforeStep.hasListeners()) {
|
|
111
|
+
this.onBeforeStep.dispatch({dt_s});
|
|
112
|
+
}
|
|
113
|
+
let springState = this._springState
|
|
114
|
+
|
|
115
|
+
// step all animations
|
|
116
|
+
this.animations.forEach((objectAnims, object) => {
|
|
117
|
+
objectAnims.forEach((animation, field) => {
|
|
118
|
+
switch (animation.type) {
|
|
119
|
+
case AnimationType.Spring: {
|
|
120
|
+
// step the spring
|
|
121
|
+
springState.x = object[field];
|
|
122
|
+
springState.targetX = animation.target;
|
|
123
|
+
springState.v = animation.velocity;
|
|
124
|
+
if (animation.springParams != null) {
|
|
125
|
+
Spring.stepSpring(dt_s, springState, animation.springParams);
|
|
126
|
+
} else {
|
|
127
|
+
// instant transition: set to the target
|
|
128
|
+
springState.x = springState.targetX;
|
|
129
|
+
springState.v = 0;
|
|
130
|
+
}
|
|
131
|
+
// update the object
|
|
132
|
+
object[field] = springState.x;
|
|
133
|
+
animation.velocity = springState.v;
|
|
134
|
+
|
|
135
|
+
// remove the spring if it's close enough to the target and velocity is close to 0
|
|
136
|
+
if (Math.abs(springState.x - springState.targetX) < 0.0001 && Math.abs(springState.v) < 0.0001) {
|
|
137
|
+
object[field] = animation.target;
|
|
138
|
+
objectAnims.delete(field);
|
|
139
|
+
this._onAnimationComplete.dispatch({object, field});
|
|
140
|
+
}
|
|
141
|
+
} break;
|
|
142
|
+
case AnimationType.Tween: {
|
|
143
|
+
// step the tween
|
|
144
|
+
let x = object[field];
|
|
145
|
+
animation.step!(object, field, animation.target, animation.tweenParams!, dt_s);
|
|
146
|
+
let x_new = object[field];
|
|
147
|
+
animation.velocity = (x_new - x) / dt_s;
|
|
148
|
+
|
|
149
|
+
// remove the tween if it's complete
|
|
150
|
+
let deltaTime_s = (performance.now() - animation.tweenParams!.t0_ms) / 1000;
|
|
151
|
+
if (deltaTime_s >= animation.tweenParams!.duration_s) {
|
|
152
|
+
object[field] = animation.target;
|
|
153
|
+
objectAnims.delete(field);
|
|
154
|
+
this._onAnimationComplete.dispatch({object, field});
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// remove the object if it has no more springs
|
|
162
|
+
if (objectAnims.size == 0) {
|
|
163
|
+
this.animations.delete(object);
|
|
164
|
+
this._onObjectAnimationsComplete.dispatch({object});
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
this.onAfterStep.dispatch({dt_s});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private t_last = -1;
|
|
172
|
+
tick() {
|
|
173
|
+
let t_s = performance.now() / 1000;
|
|
174
|
+
let dt_s = this.t_last >= 0 ? t_s - this.t_last : 1/60;
|
|
175
|
+
this.t_last = t_s;
|
|
176
|
+
this.step(dt_s);
|
|
177
|
+
return dt_s;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
protected _currentLoopControl: { stop: () => void, start: () => void } | null = null;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Start the animation loop using requestAnimationFrame
|
|
184
|
+
*
|
|
185
|
+
* This will stop any existing animation loop
|
|
186
|
+
*/
|
|
187
|
+
startAnimationFrameLoop() {
|
|
188
|
+
this.stop();
|
|
189
|
+
|
|
190
|
+
let frameLoopHandle = -1;
|
|
191
|
+
let frameLoop = () => {
|
|
192
|
+
this.tick();
|
|
193
|
+
frameLoopHandle = window.requestAnimationFrame(frameLoop);
|
|
194
|
+
};
|
|
195
|
+
frameLoop();
|
|
196
|
+
|
|
197
|
+
this._currentLoopControl = {
|
|
198
|
+
stop: () => {
|
|
199
|
+
window.cancelAnimationFrame(frameLoopHandle);
|
|
200
|
+
},
|
|
201
|
+
start: () => {
|
|
202
|
+
frameLoop();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Start the animation loop using setTimeout
|
|
209
|
+
*
|
|
210
|
+
* This will stop any existing animation loop
|
|
211
|
+
*/
|
|
212
|
+
startIntervalLoop(interval_ms: number = 1000 / 240) {
|
|
213
|
+
this.stop();
|
|
214
|
+
|
|
215
|
+
let intervalHandle = -1;
|
|
216
|
+
let intervalLoop = () => {
|
|
217
|
+
this.tick();
|
|
218
|
+
intervalHandle = window.setTimeout(intervalLoop, interval_ms);
|
|
219
|
+
};
|
|
220
|
+
intervalLoop();
|
|
221
|
+
|
|
222
|
+
this._currentLoopControl = {
|
|
223
|
+
stop: () => {
|
|
224
|
+
window.clearTimeout(intervalHandle);
|
|
225
|
+
},
|
|
226
|
+
start: () => {
|
|
227
|
+
intervalLoop();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
stop() {
|
|
233
|
+
if (this._currentLoopControl != null) {
|
|
234
|
+
this._currentLoopControl.stop();
|
|
235
|
+
this._currentLoopControl = null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Remove animation for this object and field if it exists
|
|
241
|
+
* Does not change the value of the field
|
|
242
|
+
*/
|
|
243
|
+
remove<T>(object: T, field: keyof T) {
|
|
244
|
+
let objectSprings = this.animations.get(object);
|
|
245
|
+
if (objectSprings != null) {
|
|
246
|
+
objectSprings.delete(field);
|
|
247
|
+
}
|
|
248
|
+
// if there are no more springs for this object, remove it from the map
|
|
249
|
+
if (objectSprings != null && objectSprings.size == 0) {
|
|
250
|
+
this.animations.delete(object);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Remove all animations for this object
|
|
256
|
+
*/
|
|
257
|
+
removeObject(object: any) {
|
|
258
|
+
this.animations.delete(object);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Remove all animations
|
|
263
|
+
*/
|
|
264
|
+
removeAll() {
|
|
265
|
+
this.animations.clear();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
getVelocity<Obj, Name extends keyof Obj>(object: Obj, field: Name) {
|
|
269
|
+
let spring = this.getObjectAnimations(object).get(field);
|
|
270
|
+
return spring?.velocity ?? 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Creates a new map if one doesn't already exist for the given object
|
|
275
|
+
*/
|
|
276
|
+
private getObjectAnimations(object: any) {
|
|
277
|
+
let objectAnimations = this.animations.get(object);
|
|
278
|
+
if (objectAnimations == null) {
|
|
279
|
+
// create
|
|
280
|
+
objectAnimations = new Map();
|
|
281
|
+
this.animations.set(object, objectAnimations);
|
|
282
|
+
}
|
|
283
|
+
return objectAnimations;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Creates a new spring if one doesn't already exist for the given object and field
|
|
288
|
+
*/
|
|
289
|
+
private getAnimationOrCreate(object: any, field: string | number | symbol, type: AnimationType) {
|
|
290
|
+
let objectAnimations = this.getObjectAnimations(object);
|
|
291
|
+
let animation = objectAnimations.get(field);
|
|
292
|
+
if (animation == null) {
|
|
293
|
+
// create
|
|
294
|
+
animation = {
|
|
295
|
+
target: 0,
|
|
296
|
+
type: type,
|
|
297
|
+
springParams: null,
|
|
298
|
+
tweenParams: null,
|
|
299
|
+
velocity: 0,
|
|
300
|
+
step: null
|
|
301
|
+
};
|
|
302
|
+
objectAnimations.set(field, animation);
|
|
303
|
+
}
|
|
304
|
+
animation.type = type;
|
|
305
|
+
return animation;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export type TweenStepFn = (object: any, field: string | number | symbol, target: number, params: Tween.Parameters, dt_s: number) => void;
|
|
311
|
+
|
|
312
|
+
export namespace Tween {
|
|
313
|
+
|
|
314
|
+
export type Parameters = {
|
|
315
|
+
x0: number,
|
|
316
|
+
t0_ms: number,
|
|
317
|
+
duration_s: number,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function linearStep(
|
|
321
|
+
object: any,
|
|
322
|
+
field: string | number | symbol,
|
|
323
|
+
target: number,
|
|
324
|
+
params: Tween.Parameters,
|
|
325
|
+
dt_s: number
|
|
326
|
+
) {
|
|
327
|
+
let dx = target - params.x0;
|
|
328
|
+
let t = (performance.now() - params.t0_ms) / 1000;
|
|
329
|
+
let u = t / params.duration_s;
|
|
330
|
+
let x_new = params.x0 + dx * u;
|
|
331
|
+
object[field] = x_new;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// cubic ease in out
|
|
335
|
+
export function easeInOutStep(
|
|
336
|
+
object: any,
|
|
337
|
+
field: string | number | symbol,
|
|
338
|
+
target: number,
|
|
339
|
+
params: Tween.Parameters,
|
|
340
|
+
dt_s: number
|
|
341
|
+
) {
|
|
342
|
+
let dx = target - params.x0;
|
|
343
|
+
let t = (performance.now() - params.t0_ms) / 1000;
|
|
344
|
+
let u = t / params.duration_s;
|
|
345
|
+
let x_new = params.x0 + dx * u * u * (3 - 2 * u);
|
|
346
|
+
object[field] = x_new;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function easeInStep(
|
|
350
|
+
object: any,
|
|
351
|
+
field: string | number | symbol,
|
|
352
|
+
target: number,
|
|
353
|
+
params: Tween.Parameters,
|
|
354
|
+
dt_s: number
|
|
355
|
+
) {
|
|
356
|
+
let dx = target - params.x0;
|
|
357
|
+
let t = (performance.now() - params.t0_ms) / 1000;
|
|
358
|
+
let u = t / params.duration_s;
|
|
359
|
+
let x_new = params.x0 + dx * u * u * u;
|
|
360
|
+
object[field] = x_new;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function easeOutStep(
|
|
364
|
+
object: any,
|
|
365
|
+
field: string | number | symbol,
|
|
366
|
+
target: number,
|
|
367
|
+
params: Tween.Parameters,
|
|
368
|
+
dt_s: number
|
|
369
|
+
) {
|
|
370
|
+
let dx = target - params.x0;
|
|
371
|
+
let t = (performance.now() - params.t0_ms) / 1000;
|
|
372
|
+
let u = t / params.duration_s;
|
|
373
|
+
let x_new = params.x0 + dx * (1 - Math.pow(1 - u, 3));
|
|
374
|
+
object[field] = x_new;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
}
|
package/src/Spring.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spring
|
|
3
|
+
*
|
|
4
|
+
* @author George Corney (haxiomic)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
type ExponentialParameters = {
|
|
8
|
+
/** Defined as the point in time we'll reach within 0.01% of target from 0 velocity start */
|
|
9
|
+
duration_s: number,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type UnderdampedParameters = {
|
|
13
|
+
/** Defined as the point in time we'll reach within 0.01% of target from 0 velocity start */
|
|
14
|
+
duration_s: number,
|
|
15
|
+
/**
|
|
16
|
+
* How soft / bouncy the spring, at 0 there is no bounce and decay is exponential, from 0 to infinity the spring will overshoot its target while decaying
|
|
17
|
+
* It can be loosely through of roughly the number of oscillations it will take to reach the target
|
|
18
|
+
*/
|
|
19
|
+
bounce: number,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type SpringParameters = ExponentialParameters | UnderdampedParameters | Spring.PhysicsParameters;
|
|
23
|
+
|
|
24
|
+
export namespace Spring {
|
|
25
|
+
|
|
26
|
+
export type PhysicsParameters = {
|
|
27
|
+
strength: number,
|
|
28
|
+
damping: number,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Starting with 0 velocity, this parameter describes how long it would take to reach half-way to the target
|
|
33
|
+
*
|
|
34
|
+
* `damping = 3.356694 / approxHalfLife_s`
|
|
35
|
+
*
|
|
36
|
+
* `strength = damping * damping / 4`
|
|
37
|
+
*/
|
|
38
|
+
export function Exponential(options: ExponentialParameters): PhysicsParameters {
|
|
39
|
+
// solved numerically
|
|
40
|
+
const halfLifeConstant = 3.356694; // from solve (1+u)*exp(-u)=0.5 for u, and constant = 2u
|
|
41
|
+
const pointOnePercentConstant = 18.46682; // from solve (1+u)*exp(-u)=0.001 for u, and constant = 2u
|
|
42
|
+
const damping = pointOnePercentConstant / options.duration_s;
|
|
43
|
+
|
|
44
|
+
let strength = damping * damping / 4;
|
|
45
|
+
return { damping, strength };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function Underdamped(options: UnderdampedParameters): PhysicsParameters {
|
|
49
|
+
const { duration_s, bounce } = options;
|
|
50
|
+
// -2ln(0.001) = b t
|
|
51
|
+
const durationTarget = 0.001; // 0.1% of target
|
|
52
|
+
let damping = -2 * Math.log(durationTarget) / duration_s;
|
|
53
|
+
// 4k - b^2 > 0
|
|
54
|
+
let bSq = damping * damping;
|
|
55
|
+
const criticalStrength = bSq / 4;
|
|
56
|
+
let strength = criticalStrength + (bounce * bounce + 1);
|
|
57
|
+
return { damping, strength };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getPhysicsParameters(
|
|
61
|
+
parameters: SpringParameters
|
|
62
|
+
): PhysicsParameters {
|
|
63
|
+
if ('duration_s' in parameters) {
|
|
64
|
+
if ('bounce' in parameters) {
|
|
65
|
+
return Underdamped(parameters);
|
|
66
|
+
} else {
|
|
67
|
+
return Exponential(parameters);
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
// assume physics parameters
|
|
71
|
+
return parameters as PhysicsParameters;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Analytic spring integration
|
|
77
|
+
* @param dt_s
|
|
78
|
+
* @param state
|
|
79
|
+
* @param parameters
|
|
80
|
+
*/
|
|
81
|
+
export function stepSpring(
|
|
82
|
+
dt_s: number,
|
|
83
|
+
state: {
|
|
84
|
+
x: number,
|
|
85
|
+
targetX: number,
|
|
86
|
+
v: number,
|
|
87
|
+
},
|
|
88
|
+
parameters: PhysicsParameters
|
|
89
|
+
) {
|
|
90
|
+
// analytic integration (unconditionally stable)
|
|
91
|
+
// visualization: https://www.desmos.com/calculator/c2iug0kerh
|
|
92
|
+
// references:
|
|
93
|
+
// https://mathworld.wolfram.com/OverdampedSimpleHarmonicMotion.html
|
|
94
|
+
// https://mathworld.wolfram.com/CriticallyDampedSimpleHarmonicMotion.html
|
|
95
|
+
// https://mathworld.wolfram.com/UnderdampedSimpleHarmonicMotion.html
|
|
96
|
+
|
|
97
|
+
let k = parameters.strength;
|
|
98
|
+
let b = parameters.damping;
|
|
99
|
+
let t = dt_s;
|
|
100
|
+
let v0 = state.v;
|
|
101
|
+
let dx0 = state.x - state.targetX;
|
|
102
|
+
|
|
103
|
+
// nothing will change; exit early
|
|
104
|
+
if (dx0 === 0 && v0 === 0) return;
|
|
105
|
+
if (dt_s === 0) return;
|
|
106
|
+
|
|
107
|
+
let critical = k * 4 - b * b;
|
|
108
|
+
|
|
109
|
+
if (critical > 0) {
|
|
110
|
+
// under damped
|
|
111
|
+
let q = 0.5 * Math.sqrt(critical); // γ
|
|
112
|
+
|
|
113
|
+
let A = dx0;
|
|
114
|
+
let B = ((b * dx0) * 0.5 + v0) / q;
|
|
115
|
+
|
|
116
|
+
let m = Math.exp(-b * 0.5 * t);
|
|
117
|
+
let c = Math.cos(q * t);
|
|
118
|
+
let s = Math.sin(q * t);
|
|
119
|
+
|
|
120
|
+
let dx1 = m * (A*c + B*s);
|
|
121
|
+
let v1 = m * (
|
|
122
|
+
( B*q - 0.5*A*b) * c +
|
|
123
|
+
(-A*q - 0.5*b*B) * s
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
state.v = v1;
|
|
127
|
+
state.x = dx1 + state.targetX;
|
|
128
|
+
} else if (critical < 0) {
|
|
129
|
+
// over damped
|
|
130
|
+
let u = 0.5 * Math.sqrt(-critical);
|
|
131
|
+
let p = -0.5 * b + u;
|
|
132
|
+
let n = -0.5 * b - u;
|
|
133
|
+
let B = -(n*dx0 - v0)/(2*u);
|
|
134
|
+
let A = dx0 - B;
|
|
135
|
+
|
|
136
|
+
let ep = Math.exp(p * t);
|
|
137
|
+
let en = Math.exp(n * t);
|
|
138
|
+
|
|
139
|
+
let dx1 = A * en + B * ep;
|
|
140
|
+
let v1 = A * n * en + B * p * ep;
|
|
141
|
+
|
|
142
|
+
state.v = v1;
|
|
143
|
+
state.x = dx1 + state.targetX;
|
|
144
|
+
} else {
|
|
145
|
+
// critically damped
|
|
146
|
+
let w = Math.sqrt(k); // ω
|
|
147
|
+
|
|
148
|
+
let A = dx0;
|
|
149
|
+
let B = v0 + w * dx0;
|
|
150
|
+
let e = Math.exp(-w * t);
|
|
151
|
+
|
|
152
|
+
let dx1 = (A + B * t) * e;
|
|
153
|
+
let v1 = (B - w * (A + B * t)) * e;
|
|
154
|
+
|
|
155
|
+
state.v = v1;
|
|
156
|
+
state.x = dx1 + state.targetX;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return 0.5 * k * state.x * state.x;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { Animator } from "../Animator.js";
|
|
3
|
+
import { useInitializer } from "use-initializer";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns an instance of Animator running an interval loop
|
|
7
|
+
* @param interval_ms interval between animation steps, pass explicit `null` to disable / stop. Defaults to `animationFrame`
|
|
8
|
+
* @returns { Animator } instance of Animator
|
|
9
|
+
*/
|
|
10
|
+
export function useAnimator(interval_ms?: number | null | 'animationFrame'): Animator;
|
|
11
|
+
/**
|
|
12
|
+
* Returns the same instance of Animator that is passed in, or creates a new one if not provided.
|
|
13
|
+
* @param animator an instance of Animator to use, will not create a new one
|
|
14
|
+
*/
|
|
15
|
+
export function useAnimator(animator?: Animator): Animator;
|
|
16
|
+
export function useAnimator(input: number | null | 'animationFrame' | Animator = 'animationFrame'): Animator {
|
|
17
|
+
if (input instanceof Animator) {
|
|
18
|
+
return input; // return the animator instance directly
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const interval_ms = input;
|
|
22
|
+
|
|
23
|
+
const animator = useInitializer(
|
|
24
|
+
() => new Animator(),
|
|
25
|
+
(animator) => {
|
|
26
|
+
animator.stop();
|
|
27
|
+
animator.removeAll();
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// react to change of interval handling
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
animator.stop();
|
|
34
|
+
|
|
35
|
+
if (interval_ms !== null) {
|
|
36
|
+
if (interval_ms === 'animationFrame') {
|
|
37
|
+
animator.startAnimationFrameLoop();
|
|
38
|
+
} else {
|
|
39
|
+
animator.startIntervalLoop(interval_ms);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}, [animator, interval_ms])
|
|
43
|
+
|
|
44
|
+
return animator;
|
|
45
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useSpringValue } from "./useSpringValue.js";
|
|
3
|
+
import { Animator } from "../Animator.js";
|
|
4
|
+
import { SpringParameters } from "../Spring.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A value that animates to a target value using a spring animation.
|
|
8
|
+
* This **will** cause a re-render when the value changes.
|
|
9
|
+
*
|
|
10
|
+
* See {@link useSpringValue} for a version that does not cause re-renders.
|
|
11
|
+
*/
|
|
12
|
+
export function useSpringState<T extends number | number[] | { [field: PropertyKey]: number }>(
|
|
13
|
+
options: {
|
|
14
|
+
animator?: Animator,
|
|
15
|
+
initial: T;
|
|
16
|
+
target: T;
|
|
17
|
+
} & SpringParameters,
|
|
18
|
+
) {
|
|
19
|
+
const [state, setState] = useState(options.initial);
|
|
20
|
+
useSpringValue(options, setState);
|
|
21
|
+
return state;
|
|
22
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
import { Animator } from "../Animator.js";
|
|
3
|
+
import { Spring, SpringParameters } from "../Spring.js";
|
|
4
|
+
import { useAnimator } from "./useAnimator.js";
|
|
5
|
+
import { useInitializer } from "use-initializer";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A value that animates to a target value using a spring animation.
|
|
9
|
+
* This will **not** cause a re-render when the value changes.
|
|
10
|
+
*
|
|
11
|
+
* See {@link useSpringState} for a version that does cause re-renders.
|
|
12
|
+
*/
|
|
13
|
+
export function useSpringValue<T extends number | number[] | { [field: PropertyKey]: number }>(
|
|
14
|
+
options: {
|
|
15
|
+
animator?: Animator,
|
|
16
|
+
initial: T;
|
|
17
|
+
target: T;
|
|
18
|
+
} & SpringParameters,
|
|
19
|
+
onChange: (value: T) => void
|
|
20
|
+
) {
|
|
21
|
+
const animator = useAnimator(options.animator);
|
|
22
|
+
|
|
23
|
+
const springValue = useInitializer(() => {
|
|
24
|
+
let value = structuredClone(options.initial);
|
|
25
|
+
return {
|
|
26
|
+
get value() {
|
|
27
|
+
return value;
|
|
28
|
+
},
|
|
29
|
+
set value(newValue: T) {
|
|
30
|
+
value = newValue;
|
|
31
|
+
onChange(value);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const afterStepListener = useRef<{ remove:() => void } | null>(null);
|
|
37
|
+
|
|
38
|
+
switch (typeof options.initial) {
|
|
39
|
+
case 'number': {
|
|
40
|
+
animator.springTo(
|
|
41
|
+
springValue,
|
|
42
|
+
'value',
|
|
43
|
+
options.target as any,
|
|
44
|
+
options
|
|
45
|
+
);
|
|
46
|
+
} break;
|
|
47
|
+
default: {
|
|
48
|
+
if (Array.isArray(options.initial)) {
|
|
49
|
+
for (let i = 0; i < options.initial.length; i++) {
|
|
50
|
+
animator.springTo(
|
|
51
|
+
springValue.value as number[],
|
|
52
|
+
i,
|
|
53
|
+
(options.target as number[])[i],
|
|
54
|
+
options
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
// assume object, iterate over keys
|
|
59
|
+
for (const key in options.initial) {
|
|
60
|
+
animator.springTo(
|
|
61
|
+
springValue.value,
|
|
62
|
+
key,
|
|
63
|
+
(options.target as any)[key],
|
|
64
|
+
options
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!afterStepListener.current) {
|
|
70
|
+
afterStepListener.current = animator.onAfterStep.addListener(() => {
|
|
71
|
+
onChange(springValue.value);
|
|
72
|
+
});
|
|
73
|
+
animator.onAllComplete(springValue.value, () => {
|
|
74
|
+
afterStepListener.current?.remove();
|
|
75
|
+
afterStepListener.current = null;
|
|
76
|
+
}, 'once');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
} break;
|
|
80
|
+
}
|
|
81
|
+
}
|