TypeScript Springs
- Andy Mikulski
- Technology , Data
- September 23, 2023
Nemo vel ad consectetur namut rutrum ex, venenatis sollicitudin urna. Aliquam erat volutpat. Integer eu ipsum sem. Ut bibendum lacus vestibulum maximus suscipit. Quisque vitae nibh iaculis neque blandit euismod.
Lorem ipsum dolor sit amet consectetur adipisicing elit. Nemo vel ad consectetur ut aperiam. Itaque eligendi natus aperiam? Excepturi repellendus consequatur quibusdam optio expedita praesentium est adipisci dolorem ut eius!
Inspired by https://archive.is/a40u5
Usage
// Create a new spring, defining its settings
const spring = new FloatSpring(0.5, 0.5);
// The 'goal' is where the spring wants to come to rest
spring.setGoal(123);
// The 'value' is where the spring is currrently
spring.setValue(0);
// The spring must be ticked in milliseconds to update its `value`
spring.update(deltaTime);
// Reading the value after updating will reflect the spring's natural motion
// as it attempts to reach its goal value.
myThing.y = spring.value;
Implementation (Boilerplate)
export interface ISpring<TValue> {
Value: TValue;
GoalValue: TValue;
GoalVelocity: TValue;
Velocity: TValue;
update(deltaTime: number): TValue;
setGoal(target: TValue): void;
setValue(value: TValue): void;
predict(futureDeltaTime: number): TValue;
}
export default class SpringMath {
private constructor() { }
public static halflifeToDamping(halflife: number): number {
return 4 * 0.69314718056 / (halflife + 1e-5);
}
public static dampingRatioToStiffness(ratio: number, damping: number): number {
return this.square(damping / (ratio * 2));
}
public static square(val: number): number {
return val * val;
}
public static fastNegExp(x: number): number {
return 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
}
public static copysign(a: number, b: number): number {
if (b <= 0.01 && b >= -0.01) { return 0; }
return Math.abs(a) * Math.sign(b);
}
public static fastAtan(x: number): number {
var z = Math.abs(x);
var w = z > 1 ? 1 / z : z;
var y = Math.PI / 4 * w - w * (w - 1) * (0.2447 + 0.0663 * w);
return this.copysign(z > 1 ? Math.PI / 2 - y : y, x);
}
public static magnitude(x:number, y:number):number {
return Math.sqrt(x*x + y*y);
}
}
Implementation (FloatSpring)
export default class FloatSpring implements ISpring<number> {
private halflife: number;
private dampingRatio: number;
private _value: number = 0;
public get value(): number {
return this._value;
}
private set value(value: number) {
this._value = value;
}
private _goalValue: number = 0;
public get goalValue(): number {
return this._goalValue;
}
private set goalValue(value: number) {
this._goalValue = value;
}
public get distanceToGoal(): number {
return this._goalValue - this._value;
}
public get goalVelocity(): number {
return 0;
}
public setDamping = (value:number) => {
this.dampingRatio = value;
}
public setHalfLife = (value:number) => {
this.halflife = value;
}
public velocity: number;
constructor(halflife: number = 1, dampingRatio: number = 0.5) {
this.halflife = halflife;
this.dampingRatio = dampingRatio;
}
public setGoal(target: number): void {
this.goalValue = target;
}
public setValue(value: number): void {
this.value = value;
this.velocity = 0;
}
public update(deltaTime: number): number {
const result = FloatSpring.spring_damper_exact_ratio(
this.value,
this.velocity,
this.goalValue,
0,
this.dampingRatio,
this.halflife,
deltaTime
);
this.value = result.x;
this.velocity = result.y;
return result.x;
}
public predict(time: number): number {
const result = FloatSpring.spring_damper_exact_ratio(
this.value,
this.velocity,
this.goalValue,
0,
this.dampingRatio,
this.halflife,
time
);
return result.x;
}
private static spring_damper_exact_ratio(
x: number,
v: number,
xGoal: number,
vGoal: number,
dampingRatio: number,
halflife: number,
dt: number,
eps: number = 1e-5
): { x: number; y: number; } {
const d = SpringMath.halflifeToDamping(halflife);
const s = SpringMath.dampingRatioToStiffness(dampingRatio, d);
const c = xGoal + d * vGoal / (s + eps);
const y = d / 2;
if (Math.abs(s - d * d / 4) < eps) {
// Critically Damped
const j0 = x - c;
const j1 = v + j0 * y;
const eydt = SpringMath.fastNegExp(y * dt);
x = j0 * eydt + dt * j1 * eydt + c;
v = -y * j0 * eydt - y * dt * j1 * eydt + j1 * eydt;
} else if (s - d * d / 4 > 0) {
// Under Damped
const w = Math.sqrt(s - d * d / 4);
let j = Math.sqrt(
Math.pow(v + y * (x - c), 2) / (w * w + eps) + Math.pow(x - c, 2)
);
const p = SpringMath.fastAtan((v + (x - c) * y) / (-(x - c) * w + eps));
j = x - c > 0 ? j : -j;
const eydt = SpringMath.fastNegExp(y * dt);
x = j * eydt * Math.cos(w * dt + p) + c;
v = -y * j * eydt * Math.cos(w * dt + p) - w * j * eydt * Math.sin(w * dt + p);
} else if (s - d * d / 4 < 0) {
// Over Damped
const y0 = (d + Math.sqrt(d * d - 4 * s)) / 2;
const y1 = (d - Math.sqrt(d * d - 4 * s)) / 2;
const j1 = (c * y0 - x * y0 - v) / (y1 - y0);
const j0 = x - j1 - c;
const ey0dt = SpringMath.fastNegExp(y0 * dt);
const ey1dt = SpringMath.fastNegExp(y1 * dt);
x = j0 * ey0dt + j1 * ey1dt + c;
v = -y0 * j0 * ey0dt - y1 * j1 * ey1dt;
}
return { x, y: v };
}
}
Implementation (Vector2)
export default class Vector2Spring implements ISpring<Vector2Like>
{
private _xSpring: FloatSpring;
private _ySpring: FloatSpring;
constructor(halflife: number, dampingRatio: number);
constructor(xHalflife: number, xDamping: number, yHalflife: number, yDamping: number);
constructor(arg1: number = 1, arg2: number = 0.5, arg3?: number, arg4?: number) {
const xHalflife = arg1;
const yHalflife = arg3 ?? arg1;
const xDamping = arg2;
const yDamping = arg4 ?? arg2;
this._xSpring = new FloatSpring(xHalflife, xDamping);
this._ySpring = new FloatSpring(yHalflife, yDamping);
this.setValue({ x: 0, y: 0 });
this.setGoal({ x: 0, y: 0 });
}
// Fields ----
private _value: Vector2Like = { x: 0, y: 0 };
public get value(): Vector2Like {
return this._value;
}
public set value(value: Vector2Like) {
this._value = value;
}
private _goalValue: Vector2Like = { x: 0, y: 0 };
public get goalValue(): Vector2Like {
return this._goalValue;
}
public set goalValue(value: Vector2Like) {
this._goalValue = value;
}
private _goalVelocity: Vector2Like = { x: 0, y: 0 };
public get goalVelocity(): Vector2Like {
return this._goalVelocity;
}
public set goalVelocity(value: Vector2Like) {
this._goalVelocity = value;
}
private _velocity: Vector2Like = { x: 0, y: 0 };
public get velocity(): Vector2Like {
return this._velocity;
}
public set velocity(value: Vector2Like) {
this._velocity = value;
}
public get distanceToGoal(): number {
return SpringMath.magnitude(
this._goalValue.x - this._value.x,
this._goalValue.y - this._value.y
);
}
public setDamping = (x: number, y?: number) => {
this._xSpring.setDamping(x);
this._ySpring.setDamping(typeof y === 'undefined' ? x : y);
}
public setHalfLife = (x: number, y?: number) => {
this._xSpring.setHalfLife(x);
this._ySpring.setHalfLife(typeof y === 'undefined' ? x : y);
}
// --------
public update(deltaTime: number): Vector2Like {
this._value.x = this._xSpring.update(deltaTime);
this._value.y = this._ySpring.update(deltaTime);
this._velocity.x = this._xSpring.velocity;
this._velocity.y = this._ySpring.velocity;
this._goalVelocity.x = this._xSpring.goalVelocity;
this._goalVelocity.y = this._ySpring.goalVelocity;
return this._value;
}
public setGoal(target: Vector2Like) {
this._xSpring.setGoal(target.x);
this._ySpring.setGoal(target.y);
this._goalValue.x = target.x;
this._goalValue.y = target.y;
}
public setValue(value: Vector2Like) {
this._xSpring.setValue(value.x);
this._ySpring.setValue(value.y);
}
public predict(futureDeltaTime: number): Vector2Like {
return { x: this._xSpring.predict(futureDeltaTime), y: this._ySpring.predict(futureDeltaTime) };
}
}