Springするアニメーションライブラリをnpmで公開しました


シミュレーションです。バネです。設計を考えるのが楽しくて3, 4回は書き直してました。

https://chrono-kinesis.yyyoichi.com

デモもあるよ。

https://chrono-kinesis.yyyoichi.com/guides/example

ざっくり仕様

  • なんでもバネにできる
  • ベクトルは無限に拡張可能
  • アニメーション不要時のrequestAnimationFrameの停止
  • 依存関係なし
  • Springパラメータの動的調整
  • DOMでもCanvasでもWebGLでも設計上動作可能

大雑把ですが、軽量物理シミュレーションUIライブラリ、という感じを目指しています。

利用例

これはマウスを追従するスクリプトです。

import {DefaultSimulatorService, DomPhysics, DomSource, DomVisualizer, Kinetics, MousePositionClock} from "@yyyoichi/chrono-kinesis"
const simulation = new DefaultSimulatorService({tickRate: 0.5});
const mouse = new MousePositionClock(document.getElementById("mouse-hover-box")!);
const pointer = new DomSource(document.getElementById("mouse-hover-pointer")!);
simulation.add({
  clock: mouse,
  target: mouse,
  kinetics: new Kinetics(pointer.position()),
  physics: new DomPhysics(pointer, new DomVisualizer())
});
simulation.run();

宣言的で読みやすい気がしています。

内部仕様

Simulatorがオーケストラレーターとして、clock, target, kinetics, physics を動作させます。

  • clockは入力検知です。
    • イベントがあればシミュレーションが開始されていきます。
    • 「マウスが乗った」とか「クリックした」とかなんでもコールバックをimplすれば良いことになっています。
    • 最後にいつイベントが発生したかを内部にもっているので、しばらくイベントがなければシミュレーターは停止します。
  • targetは向かうべきベクトルを指します。
    • 基本的にベクトルはnumber[]で表現される任意の文脈の値です。
    • 座標系の場合、ライブラリとして一貫してドキュメントルートからの座標を扱います。
    • 次元数は問いません。
  • kineticsは演算装置です。
    • targetからの値を受け取り決められた速度で目指します。
    • 初期値を受け取り演算の状態を保持します。
    • 十分に減退したかどうかの状態を内部に持ちます。(シミュレーターが聞きに来て停止するかどうかの判断をします。)
  • physicsは演算結果を当てる要素です。
    • 受け取ったベクトルの意味を解釈して、基本DOMにスタイルを当てます。

simulator.addを繰り返し作成することで複雑なアニメーションが理論上可能です。 target, kineticsの次元数は一致させないといけないです。暗黙的な規約になるのでそこは注意です。

基本的にこのaddできるcontextの各プロパティはインターフェースで「なんでも」スタイルを当てられる、というのはそういうことです。

type SimulationContext = {
  clock: ClockPort;
  kinetics: KineticsPort;
  target: VectorReadablePort;
  physics: PhysicsPort | PhysicsPort[];
};

interface ClockPort extends ActivityPort, DisposablePort {
  onHeartbeat(cb: () => void): void;
}
interface KineticsPort extends ActivityPort, SnapshotPort {
  compute(dt: number, vector: Readonly<number[]>): void;
  readonly state: SimulationState;
}
interface VectorReadablePort extends SnapshotPort {
  vector(): Readonly<number[]>;
}
interface PhysicsPort {
  apply(state: SimulationState): void;
}

ポイント

ここにたどり着くのに時間かかりました、というちょっと難易度の高かったポイントです。

インターフェース

Clockでもあるし位置情報も持っている実態は、複数の意味を持つインターフェースを実装することで表現しました。

たとえばマウスは下のように、シミュレーションを開始できるし位置を供給することもできるようになってます。 そのため、最初のコードではclocktargetにマウスを割り当てられるようになっています。

class MousePositionClock
  extends BaseClock
  implements ClockPort, PositionReadablePort, VectorReadablePort, DisposablePort {
    //...
    onHeartbeat(cb): void{}
    snapshot(): void {}
    vector(): Readonly<number[]> {}
  }
simulation.add({
  clock: mouse,
  target: mouse,
  // ...
});

追従対象

シミュレーションで追従する目標が固定されているとは限らないため、移動するkinetics自体もtargetに指定可能にするため、vector()を持っています。

// 内部に位置情報をもっているので`vector()`で返せるようにする
class Kinetics implements KineticsPort, VectorReadablePort {}
// DOMも同じように`vector()`を持つ
class CenteredSource
  implements PositionReadablePort, VectorReadablePort {}

オーケストラレーターであるSimulatortargetそのものが何であるかは知らずに処理していきます。

CPU節約

演算が多いのでできるだけ省エネに動かしたいのです。

オーケストラレーターのSimulatorは一つのcontextにたいして、clockが起動していてかつkineticsが十分なエネルギーをもっている場合に限って演算を実行しスタイルをあてます。「エネルギー」というのは、1回の処理にどのくらいの速度でどのくらい移動したかを適当な式で算出したものです。

addされたすべてのcontextが停止した場合、requestAnimationFrame自体の呼び出しが停止し、次になにかのclockが起動するまで待機ます。

また、new Simulator({tickRate: 0.5})などで、1フレームを何秒で実行するかを制御できるようにしています。(デフォルトで1 = 1/60 sec) 将来的には、全体のエネルギーを見つつtickRateを上げ下げするとより良いかもしれません。

requestAnimationFrame((now) => {
    for(const context of this.contexts) {
        if(!context.clock.isActive() && !context.kinetics.isActive()) {
            // エネルギーがなくて、とくにクロックも最近更新されていないなら
            // ほとんど移動がないと推定されるので演算スキップ。
            return;
        }
    }
    if(this.clockIsActive() || this.kineticsIsActive()) {
        this.step(); // 再帰
        return;
    } else {
        // クロックが動いていないかつ、運動も十分に減退したなら、止める。
        this.stop();
        return;
    }
});

setTickRate(0.5) // 1/30 sec に一回の演算

説明しきれない!

適当ですが、Springシミュレーションライブラリを作っている話でした。 楽しい!以上!