Skip to content

Commit

Permalink
Add closed-form damped harmonic oscillator algorithm to Animated.spring
Browse files Browse the repository at this point in the history
  • Loading branch information
Adam Miskiewicz committed Sep 3, 2017
1 parent cb1b1e5 commit 957c77d
Show file tree
Hide file tree
Showing 10 changed files with 474 additions and 303 deletions.
222 changes: 151 additions & 71 deletions Libraries/Animated/src/AnimatedImplementation.js
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,9 @@ type SpringAnimationConfig = AnimationConfig & {
speed?: number,
tension?: number,
friction?: number,
stiffness?: number,
damping?: number,
mass?: number,
delay?: number,
};

Expand All @@ -472,6 +475,9 @@ type SpringAnimationConfigSingle = AnimationConfig & {
speed?: number,
tension?: number,
friction?: number,
stiffness?: number,
damping?: number,
mass?: number,
delay?: number,
};

Expand All @@ -492,11 +498,14 @@ class SpringAnimation extends Animation {
_lastPosition: number;
_fromValue: number;
_toValue: any;
_tension: number;
_friction: number;
_stiffness: ?number;
_damping: ?number;
_mass: ?number;
_delay: number;
_timeout: any;
_startTime: number;
_lastTime: number;
_frameTime: number;
_onUpdate: (value: number) => void;
_animationFrame: any;
_useNativeDriver: bool;
Expand All @@ -509,32 +518,50 @@ class SpringAnimation extends Animation {
this._overshootClamping = withDefault(config.overshootClamping, false);
this._restDisplacementThreshold = withDefault(config.restDisplacementThreshold, 0.001);
this._restSpeedThreshold = withDefault(config.restSpeedThreshold, 0.001);
this._initialVelocity = config.velocity;
this._initialVelocity = withDefault(config.velocity, 0);
this._lastVelocity = withDefault(config.velocity, 0);
this._toValue = config.toValue;
this._delay = withDefault(config.delay, 0);
this._useNativeDriver = shouldUseNativeDriver(config);
this.__isInteraction = config.isInteraction !== undefined ? config.isInteraction : true;
this.__iterations = config.iterations !== undefined ? config.iterations : 1;

var springConfig;
if (config.bounciness !== undefined || config.speed !== undefined) {
if (config.stiffness !== undefined || config.damping !== undefined || config.mass !== undefined) {
invariant(
config.bounciness === undefined && config.speed === undefined &&
config.tension === undefined && config.friction === undefined,
'You can only define bounciness/speed or tension/friction but not both',
'You can define one of bounciness/speed, tension/friction, or stiffness/damping/mass, but not more than one',
);
this._stiffness = withDefault(config.stiffness, 100);
this._damping = withDefault(config.damping, 10);
this._mass = withDefault(config.mass, 1);
} else if (config.bounciness !== undefined || config.speed !== undefined) {
// Convert the origami bounciness/speed values to stiffness/damping
// We assume mass is 1.
invariant(
config.tension === undefined && config.friction === undefined &&
config.stiffness === undefined && config.damping === undefined &&
config.mass === undefined,
'You can define one of bounciness/speed, tension/friction, or stiffness/damping/mass, but not more than one',
);
springConfig = SpringConfig.fromBouncinessAndSpeed(
var springConfig = SpringConfig.fromBouncinessAndSpeed(
withDefault(config.bounciness, 8),
withDefault(config.speed, 12),
);
this._stiffness = springConfig.stiffness;
this._damping = springConfig.damping;
this._mass = 1;
} else {
springConfig = SpringConfig.fromOrigamiTensionAndFriction(
// Convert the origami tension/friction values to stiffness/damping
// We assume mass is 1.
var springConfig = SpringConfig.fromOrigamiTensionAndFriction(
withDefault(config.tension, 40),
withDefault(config.friction, 7),
);
this._stiffness = springConfig.stiffness;
this._damping = springConfig.damping;
this._mass = 1;
}
this._tension = springConfig.tension;
this._friction = springConfig.friction;
}

__getNativeAnimationConfig() {
Expand All @@ -543,8 +570,9 @@ class SpringAnimation extends Animation {
overshootClamping: this._overshootClamping,
restDisplacementThreshold: this._restDisplacementThreshold,
restSpeedThreshold: this._restSpeedThreshold,
tension: this._tension,
friction: this._friction,
stiffness: this._stiffness,
damping: this._damping,
mass: this._mass,
initialVelocity: withDefault(this._initialVelocity, this._lastVelocity),
toValue: this._toValue,
iterations: this.__iterations,
Expand All @@ -565,17 +593,16 @@ class SpringAnimation extends Animation {
this._onUpdate = onUpdate;
this.__onEnd = onEnd;
this._lastTime = Date.now();
this._frameTime = 0.0;

if (previousAnimation instanceof SpringAnimation) {
var internalState = previousAnimation.getInternalState();
this._lastPosition = internalState.lastPosition;
this._lastVelocity = internalState.lastVelocity;
// Set the initial velocity to the last velocity
this._initialVelocity = this._lastVelocity;
this._lastTime = internalState.lastTime;
}
if (this._initialVelocity !== undefined &&
this._initialVelocity !== null) {
this._lastVelocity = this._initialVelocity;
}

var start = () => {
if (this._useNativeDriver) {
Expand All @@ -601,13 +628,28 @@ class SpringAnimation extends Animation {
};
}

/**
* This spring model is based off of a damped harmonic oscillator
* (https://en.wikipedia.org/wiki/Harmonic_oscillator#Damped_harmonic_oscillator).
*
* We use the closed form of the second order differential equation:
*
* x'' + (2ζ⍵_0)x' + ⍵^2x = 0
*
* where
* ⍵_0 = √(k / m) (undamped angular frequency of the oscillator),
* ζ = c / 2√mk (damping ratio),
* c = damping constant
* k = stiffness
* m = mass
*
* The derivation of the closed form is described in detail here:
* http://planetmath.org/sites/default/files/texpdf/39745.pdf
*
* This algorithm happens to match the algorithm used by CASpringAnimation,
* a QuartzCore (iOS) API that creates spring animations.
*/
onUpdate(): void {
var position = this._lastPosition;
var velocity = this._lastVelocity;

var tempPosition = this._lastPosition;
var tempVelocity = this._lastVelocity;

// If for some reason we lost a lot of frames (e.g. process large payload or
// stopped in the debugger), we only advance by 4 frames worth of
// computation and will continue on the next frame. It's better to have it
Expand All @@ -618,47 +660,54 @@ class SpringAnimation extends Animation {
now = this._lastTime + MAX_STEPS;
}

// We are using a fixed time step and a maximum number of iterations.
// The following post provides a lot of thoughts into how to build this
// loop: http://gafferongames.com/game-physics/fix-your-timestep/
var TIMESTEP_MSEC = 1;
var numSteps = Math.floor((now - this._lastTime) / TIMESTEP_MSEC);

for (var i = 0; i < numSteps; ++i) {
// Velocity is based on seconds instead of milliseconds
var step = TIMESTEP_MSEC / 1000;

// This is using RK4. A good blog post to understand how it works:
// http://gafferongames.com/game-physics/integration-basics/
var aVelocity = velocity;
var aAcceleration = this._tension *
(this._toValue - tempPosition) - this._friction * tempVelocity;
var tempPosition = position + aVelocity * step / 2;
var tempVelocity = velocity + aAcceleration * step / 2;

var bVelocity = tempVelocity;
var bAcceleration = this._tension *
(this._toValue - tempPosition) - this._friction * tempVelocity;
tempPosition = position + bVelocity * step / 2;
tempVelocity = velocity + bAcceleration * step / 2;

var cVelocity = tempVelocity;
var cAcceleration = this._tension *
(this._toValue - tempPosition) - this._friction * tempVelocity;
tempPosition = position + cVelocity * step / 2;
tempVelocity = velocity + cAcceleration * step / 2;

var dVelocity = tempVelocity;
var dAcceleration = this._tension *
(this._toValue - tempPosition) - this._friction * tempVelocity;
tempPosition = position + cVelocity * step / 2;
tempVelocity = velocity + cAcceleration * step / 2;

var dxdt = (aVelocity + 2 * (bVelocity + cVelocity) + dVelocity) / 6;
var dvdt = (aAcceleration + 2 * (bAcceleration + cAcceleration) + dAcceleration) / 6;

position += dxdt * step;
velocity += dvdt * step;
var deltaTime = 0.0;
if (now > this._lastTime) {
deltaTime = (now - this._lastTime) / 1000;
}
this._frameTime += deltaTime;

var c: number = this._damping || 0;
var m: number = this._mass || 0;
var k: number = this._stiffness || 0;
var v0: number = -(this._initialVelocity || 0);

invariant(m > 0, 'Mass value must be greater than 0');
invariant(k > 0, 'Stiffness value must be greater than 0');
invariant(c > 0, 'Damping value must be greater than 0');

var zeta = c / (2 * Math.sqrt(k * m)); // damping ratio
var omega0 = Math.sqrt(k / m); // undamped angular frequency of the oscillator (rad/ms)
var omega1 = omega0 * Math.sqrt(1.0 - (zeta * zeta)); // exponential decay
var x0 = this._toValue - this._startPosition; // calculate the oscillation from x0 = 1 to x = 0

var position = 0.0;
var velocity = 0.0;
var t = this._frameTime;
if (zeta < 1) {
// Under damped
const envelope = Math.exp(-zeta * omega0 * t);
position =
this._toValue -
envelope *
((v0 + zeta * omega0 * x0) / omega1 * Math.sin(omega1 * t) +
x0 * Math.cos(omega1 * t));
// This looks crazy -- it's actually just the derivative of the
// oscillation function
velocity =
zeta *
omega0 *
envelope *
(Math.sin(omega1 * t) * (v0 + zeta * omega0 * x0) / omega1 +
x0 * Math.cos(omega1 * t)) -
envelope *
(Math.cos(omega1 * t) * (v0 + zeta * omega0 * x0) -
omega1 * x0 * Math.sin(omega1 * t));
} else {
// Critically damped
const envelope = Math.exp(-omega0 * t);
position = this._toValue - envelope * (x0 + (v0 + omega0 * x0) * t);
velocity =
envelope * (v0 * (t * omega0 - 1) + t * x0 * (omega0 * omega0));
}

this._lastTime = now;
Expand All @@ -672,7 +721,7 @@ class SpringAnimation extends Animation {

// Conditions for stopping the spring animation
var isOvershooting = false;
if (this._overshootClamping && this._tension !== 0) {
if (this._overshootClamping && this._stiffness !== 0) {
if (this._startPosition < this._toValue) {
isOvershooting = position > this._toValue;
} else {
Expand All @@ -681,13 +730,15 @@ class SpringAnimation extends Animation {
}
var isVelocity = Math.abs(velocity) <= this._restSpeedThreshold;
var isDisplacement = true;
if (this._tension !== 0) {
if (this._stiffness !== 0) {
isDisplacement = Math.abs(this._toValue - position) <= this._restDisplacementThreshold;
}

if (isOvershooting || (isVelocity && isDisplacement)) {
if (this._tension !== 0) {
if (this._stiffness !== 0) {
// Ensure that we end up with a round value
this._lastPosition = this._toValue;
this._lastVelocity = 0;
this._onUpdate(this._toValue);
}

Expand Down Expand Up @@ -2743,6 +2794,7 @@ module.exports = {
*
* - `velocity`: Initial velocity. Required.
* - `deceleration`: Rate of decay. Default 0.997.
* - `isInteraction`: Whether or not this animation creates an "interaction handle" on the `InteractionManager`. Default true.
* - `useNativeDriver`: Uses the native driver when true. Default false.
*/
decay,
Expand All @@ -2757,21 +2809,49 @@ module.exports = {
* - `easing`: Easing function to define curve.
* Default is `Easing.inOut(Easing.ease)`.
* - `delay`: Start the animation after delay (milliseconds). Default 0.
* - `isInteraction`: Whether or not this animation creates an "interaction handle" on the `InteractionManager`. Default true.
* - `useNativeDriver`: Uses the native driver when true. Default false.
*/
timing,
/**
* Spring animation based on Rebound and
* [Origami](https://facebook.github.io/origami/). Tracks velocity state to
* create fluid motions as the `toValue` updates, and can be chained together.
* Animates a value according to an analytical spring model based on
* [damped harmonic oscillation](https://en.wikipedia.org/wiki/Harmonic_oscillator#Damped_harmonic_oscillator).
* Tracks velocity state to create fluid motions as the `toValue` updates, and
* can be chained together.
*
* Config is an object that may have the following options. Note that you can
* only define bounciness/speed or tension/friction but not both:
* Config is an object that may have the following options.
*
* Note that you can only define one of bounciness/speed, tension/friction, or
* stiffness/damping/mass, but not more than one:
*
* The friction/tension or bounciness/speed options match the spring model in
* [Facebook Pop](https://github.com/facebook/pop), [Rebound](http://facebook.github.io/rebound/),
* and [Origami](http://origami.design/).
*
* - `friction`: Controls "bounciness"/overshoot. Default 7.
* - `tension`: Controls speed. Default 40.
* - `speed`: Controls speed of the animation. Default 12.
* - `bounciness`: Controls bounciness. Default 8.
*
* Specifying stiffness/damping/mass as parameters makes `Animated.spring` use an
* analytical spring model based on the motion equations of a [damped harmonic
* oscillator](https://en.wikipedia.org/wiki/Harmonic_oscillator#Damped_harmonic_oscillator).
* This behavior is slightly more precise and faithful to the physics behind
* spring dynamics, and closely mimics the implementation in iOS's
* CASpringAnimation primitive.
*
* - `stiffness`: The spring stiffness coefficient. Default 100.
* - `damping`: Defines how the spring’s motion should be damped due to the forces of friction. Default 10.
* - `mass`: The mass of the object attached to the end of the spring. Default 1.
*
* Other configuration options are as follows:
*
* - `velocity`: The initial velocity of the object attached to the spring. Default 0 (object is at rest).
* - `overshootClamping`: Boolean indiciating whether the spring should be clamped and not bounce. Default false.
* - `restDisplacementThreshold`: The threshold of displacement from rest below which the spring should be considered at rest. Default 0.001.
* - `restSpeedThreshold`: The speed at which the spring should be considered at rest in pixels per second. Default 0.001.
* - `delay`: Start the animation after delay (milliseconds). Default 0.
* - `isInteraction`: Whether or not this animation creates an "interaction handle" on the `InteractionManager`. Default true.
* - `useNativeDriver`: Uses the native driver when true. Default false.
*/
spring,
Expand Down
16 changes: 8 additions & 8 deletions Libraries/Animated/src/SpringConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@
'use strict';

type SpringConfigType = {
tension: number,
friction: number,
stiffness: number,
damping: number,
};

function tensionFromOrigamiValue(oValue) {
function stiffnessFromOrigamiValue(oValue) {
return (oValue - 30) * 3.62 + 194;
}

function frictionFromOrigamiValue(oValue) {
function dampingFromOrigamiValue(oValue) {
return (oValue - 8) * 3 + 25;
}

Expand All @@ -30,8 +30,8 @@ function fromOrigamiTensionAndFriction(
friction: number,
): SpringConfigType {
return {
tension: tensionFromOrigamiValue(tension),
friction: frictionFromOrigamiValue(friction)
stiffness: stiffnessFromOrigamiValue(tension),
damping: dampingFromOrigamiValue(friction),
};
}

Expand Down Expand Up @@ -91,8 +91,8 @@ function fromBouncinessAndSpeed(
);

return {
tension: tensionFromOrigamiValue(bouncyTension),
friction: frictionFromOrigamiValue(bouncyFriction)
stiffness: stiffnessFromOrigamiValue(bouncyTension),
damping: dampingFromOrigamiValue(bouncyFriction),
};
}

Expand Down
Loading

0 comments on commit 957c77d

Please sign in to comment.