TypeScript Springs

TypeScript Springs

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) };
  }
}

Related Posts

TS: Casting to Interfaces at Runtime

TS: Casting to Interfaces at Runtime

In C#/Unity, there is an excellent affordance of using GetComponent to retrieve components based on their interface.

Read More
Heatmap

Heatmap

Simple Heatmap class used to find areas of interest for real-time applications, like games.

Read More
Coroutines

Coroutines

Nemo vel ad consectetur namut rutrum ex, venenatis sollicitudin urna. Aliquam erat volutpat.

Read More