[Tauri #11] WiredFrame v0.3.0 公開:ぷにぷにゼリーの物理演算を、スクリーンセーバーのモードに追加

[Tauri #11] WiredFrame v0.3.0 公開:ぷにぷにゼリーの物理演算を、スクリーンセーバーのモードに追加

はじめに

前回(#10)で、v0.2.0を公開しました。今回は、v0.3.0 の記録です。追加したのは、モードが1つだけ。ゼリーです。

ゼリーの物理演算は、先にNOTEで試作していました(下にリンクがあります)。それを、スクリーンセーバーのモードとして、WiredFrameに載せました。

今回は、Rustのコードを変えていません。TypeScriptだけです。前回作った「モードを足す型」に、そのまま載りました。この記事に書くのは、次の3つです。

  • ゼリーが、跳ね回る・合体する・光る・分裂する、をくり返す仕組み
  • モニターの縦横比に、ゼリーの箱を合わせる方法
  • 分裂フェーズが止まってしまった不具合の記録

スクリーンショット

[Tauri #11] ゼリーモード。色とりどりのゼリーが、暗い背景で跳ね回る

設定パネルの「詳細な調整」にも、ゼリーのスライダーが出ます(これは前回の仕組みのままです)。

[Tauri #11] ゼリーモードの設定パネル。詳細な調整に12個のスライダーが並ぶ

動画

[Tauri #11] ゼリーモード。色とりどりのゼリーが、暗い背景で跳ね回る

1. 何を足したか

ファイル役割
src/modes/jelly/JellyWorld.ts物理。three.jsに依存しない
src/modes/JellyMode.tsCanvas2Dに描いて、テクスチャとして画面に貼る。IMode の実装
src/modes/params.tsJELLY_PARAMS(スライダー12個の定義)
src/main.ts / src/settings.ts / index.htmlモード名の登録(1行ずつ)
docs/jelly-mode.md仕様と調整のメモ

前回の「足す場所は4つ」のとおりです。パネルのコードは、1行も書いていません。

物理を、three.jsから切り離したのは、あとで書く検証のためです。JellyWorld.ts は、import が1つもありません。Nodeで、そのまま動きます。

2. ゼリー1個は、点の輪

仕組みは、NOTEの記事と同じです。詳しくはそちらに書いたので、ここでは要点だけにします。

  • ゼリー1個は、点が輪になったもの。点の数は、ゼリーの大きさで変えます(18〜110点)
  • 計算は PBD(Position Based Dynamics)。力から加速度を求めず、「この条件を満たすように点を動かす」を繰り返します
  • 点の移動は Verlet 積分。1フレームを6回に分けて計算します
  • 条件は4つ。辺の長さ、面積(圧力)、1つ飛ばしの距離、元の形に戻る弱い力
  • ゼリー同士のぶつかりは、「点が相手の内側に入ったら、いちばん近い辺から押し出す」

NOTEの記事で書いた、いちばん大事な発見は、これでした。辺の長さと面積を両方固定すると、円しか残らない(等周不等式)。そのため、周の長さは、面積の目標に合わせて縮めます。

const press = Math.min(1.1, Math.max(0.5, this.P.press));
const perim = 1.02 * Math.sqrt(press);   // 周の長さは √(面積比) で縮める
// …
const f = ((l - j.L0 * perim) / l) * 0.5 * kEdge;

この Math.sqrt(press) を外すと、輪郭が余って、折れます。

今回の新しい点は、元の形をいびつにしたことです。ただの円だと、ボールに見えます。元の形に、2次と3次の波を足して、その波をゆっくり動かしています。アメーバのように、ゆらぐ形になります。

const k = 1 + A * (Math.cos(2 * a + this.ph + t * this.w2 * 0.03) * 0.8
                 + Math.cos(3 * a - this.ph * 1.7 + t * this.w3 * 0.04) * 0.6);

3. くり返しの仕組み

スクリーンセーバーなので、止まっては困ります。見ていて飽きない動きを、次の3段階にしました。

  1. 成長: ゼリーが跳ね回ります。同じ色(同じ段)どうしが重なると、合体して、1段大きくなります。大きくなるたびに、色が変わります
  2. 臨界点: いちばん大きい段ができたら、光って、揺れて、輪が広がって、大きく脈打ちます。数秒間です
  3. 分裂: ぶつかったゼリーが、半分に分かれます。下限の段まで小さくなったら、1に戻ります

止まらない仕掛け

重力は、なしです。そのままだと、ぶつかるたびに速さが落ちて、止まります。そこで、ゼリー全体の速さを、一定の範囲に戻しています。

private regulate() {
  const lo = this.P.speed * 0.7;
  const hi = this.P.speed * 1.5;
  // 各ゼリーの速さが [lo, hi] から外れたら、少しずつ戻す
  const s = 1 + (want / sp - 1) * 0.12;
  // …
}

壁では、点ごとに跳ね返します。壁に当たった点だけが跳ね返るので、ゼリーが壁で、むにゅっと潰れて、戻ります。

面積を保つ分裂

分裂は、長い軸の向きで、2つに分けます。点の座標の共分散から、長い軸の向きを求めています。

ここで、1つ、小さな罠がありました。分裂のたびに、面積が増えるのです。1段小さいゼリーを2つ作ると、元より面積が約6%大きくなります。これを繰り返すと、箱が、だんだん混み合います。そこで、子の半径を0.968倍にして、面積をほぼ保っています。

作ってから直したもの

試作のたびに、次の問題が出ました。

問題直し方
大きいゼリーが壁際で潰れて、輪郭がねじれる合体したゼリーは、壁から離れた位置に作る
別の色のゼリーが、壁際でくっついて離れなくなる点が3個以上入りこんだら、ゼリーごと離して、近づいていたら跳ね返す(separate)
分裂を最小の段まで続けたら、約400個になって重くなった下限の段を設ける(初期値は3段目)。個数の上限も付けた
分裂のあと、小さいゼリーが一斉に合体して、一瞬で元に戻った生まれてから1.5秒は、合体しない(mergeDelay)

最後の mergeDelay は、自分で気づいた問題でした。小さいゼリーが箱いっぱいに増えたあと、合体モードに戻ると、一瞬で大きくなってしまいます。そうなると、大きなゼリーを見ている時間のほうが長くなります。「待ち時間を作る」で、ましになりました。

4. モニターの縦横比に合わせる

ここは、スクリーンセーバー化で、新しく考えたところです。試作は、640×700の固定の箱でした。モニターは、横長も、縦置きも、3840×1600もあります。

方針は、箱の高さを固定して、幅を画面の縦横比で決めることにしました。

export const BOX_H = 700;
// 幅 = BOX_H × (画面の幅 / 高さ)
  • 横長のモニターは、箱が広くなる。ゼリーの数が増える(大きさは変わらない)
  • 縦置きのモニターは、箱が狭くなる

縦置きのままだと、いちばん大きいゼリーが、箱に収まりません。そこで、いちばん大きい段が、箱の短辺の34%を超えないように、大きさの倍率へ上限を掛けました。

private calcK(): number {
  const limit = (0.34 * Math.min(this.W, this.H)) / CLASSES[TOP].r;
  return Math.max(0.3, Math.min(this.P.sizeScale, limit));
}

縦置きの1200×1600では、倍率が自動で下がります。スライダーで大きさを上げても、箱に収まる大きさで止まります。

Canvas2Dを、テクスチャにして貼る

描画は、Canvas2Dです。ゼリーの輪郭を、2次ベジェ曲線でなめらかにつないで、グラデーションで光沢を付けます。これを CanvasTexture にして、前回のノイズ4〜6と同じ、カメラのない全画面の板に貼っています。

  • 物理は、60Hz固定刻みで進める。経過時間をためて、1回に最大3ステップまで
  • 物理が進んだフレームだけ、描き直して、テクスチャを送る。モニターが144Hzでも、無駄に送らない
  • 描画の解像度は、スライダー(quality)で下げられる。高さ1440が上限で、それ以上は引き伸ばす

5. 「足す型」に載せる

前回作った仕組みに、ほぼそのまま載りました。スライダーは、こう書くだけです。

export const JELLY_PARAMS: ParamDef[] = [
  { key: 'fill', label: 'ゼリーの量(箱を埋める割合。多いほど重い)', min: 0.15, max: 0.7, step: 0.01, default: 0.45 },
  { key: 'sizeScale', label: 'ゼリーの大きさ(変えると初めからやり直す。…)', min: 0.4, max: 1.6, step: 0.05, default: 1 },
  { key: 'mergeDelay', label: '合体できるまでの待ち(秒。生まれてから)', min: 0, max: 10, step: 0.1, default: 1.5 },
  // …全部で12個
];

ただ、1つだけ、前回の仕組みでは足りないところがありました。変えると、初めからやり直したい項目があることです。ゼリーの大きさを途中で変えると、いまいるゼリーの大きさが、中途半端になります。

そこで、物理側の setParams が「大きさが変わったか」を返すようにしました。変わったときだけ、作り直します。

const sizeChanged = this.world.setParams({ /* … */ sizeScale: p.sizeScale });
if (sizeChanged) this.world.reset();   // 大きさが変わったら、初めからやり直す

画面の縦横比が変わったとき(モニターの構成が変わったとき)も、箱の幅が変わるので、同じように作り直します。それ以外のスライダー(速さ、やわらかさ、待ち時間など)は、動かしながら、その場で変わります。

6. 検証と、止まってしまった不具合

物理を、three.jsから切り離したので、Nodeで長時間回せます。乱数を固定して、次の3つの箱で、2万フレーム(約5分半)ずつ動かしました。

箱NaN折れたサンプル1ステップ周期
横長(W=1244)05 / 400平均 4.5 ms7周
標準(W=640)05 / 400平均 1.9 ms8周
縦置き(W=394)02 / 400平均 2.2 ms10周

「折れ」は、輪郭が自己交差した状態です。50フレームごとに、全ゼリーを調べています。測ったのは、描画を除いた物理だけで、Nodeの1コアです。ブラウザでの値ではありません。

横長で、分裂フェーズが止まった

この検証で、不具合が1つ見つかりました。最初の実行で、横長の箱だけ、分裂フェーズから戻らなくなったのです。

10000 split n 150 cap 150 …  splits:216
15000 split n 150 cap 150 …  splits:216
20000 split n 150 cap 150 …  splits:216

分裂の数が、10000フレームから、ずっと216のままです。原因は、個数の上限(150)でした。

  • 横長は箱が広いので、小さくなりきったときのゼリーが、150個を超える
  • 上限に当たると、もう分裂できない
  • 成長に戻る条件は「全部が下限の段以下」なので、永遠に満たせない

標準の箱では、個数が約100で収まるので、出ませんでした。3つの箱で回さなかったら、気づけなかった不具合です。

直し方は、「10秒間、1度も分裂しなかったら、成長に戻す」にしました。

// 個数の上限などで分裂が進まなくなったら(10 秒)、成長に戻す
if (this.mode === 'split' && !this.ev) this.splitIdle++;
else this.splitIdle = 0;
if (this.mode === 'split' && !this.ev
    && (this.splitIdle > 600 || this.jellies.every((j) => j.cls <= P.minCls))) {
  this.mode = 'grow';
}

直したあとの再実行が、上の表の横長の行です。7周まわりました。

実機で分かったこと

実機(6枚のモニター、うち1枚が3840×1600、1枚が縦置き)では、動かした感じでは、重くありませんでした。画像タイルのほうが、はるかに重いです。リリースビルドにして、インストールしても、問題なく動きました。

ただし、まだ分かっていないことがあります。

  • 数分以上、放置したときの、臨界点と分裂の見え方
  • ごくまれに、輪郭が一瞬だけ折れる(上の表のとおり、サンプルの約1%以下)。数十フレームで戻ることがほとんどですが、原因は分かっていません

7. 開発の進め方

前回と同じ流れです。実装はClaude Codeに書かせて、自分は実機で見る係でした。

今回、自分が決めたのは、動きの筋書きです。「成長して、臨界点で光って、そのあと分裂して、また育つ」という循環は、自分の案です。分裂の際に、2つに分けるか、1つにするか、という案も出しました(今回は、2つのままにしました)。

AIは、その筋書きを実装して、Nodeで長時間回して、折れや止まりを探しました。ただ、「見た目がいいか」「動きが気持ちいいか」は、実機で見ないと分かりません。1つずつ作って、1つずつ実機で見る、を繰り返しました。

決めたことと、試して分かったことは、docs/jelly-mode.md に残しています。

まとめ

  • ゼリーは、点の輪+PBD。周の長さは √(面積比) で縮めないと、折れる
  • 重力なしで、速さを範囲に戻せば、止まらない。壁は点ごとに跳ね返す
  • 分裂は、面積を保つ(子の半径を0.968倍)。保たないと、周期のたびに混み合う
  • 箱は、高さ固定・幅は縦横比。いちばん大きい段が、短辺の34%に収まるように、倍率へ上限を掛ける
  • 物理を、three.jsから切り離すと、Nodeで長時間回せて、不具合を探せる。3つの箱で回して、横長だけ止まる不具合が見つかった
  • 「変えたら作り直す」項目は、setParams の戻り値で知らせる

v0.3.0はGitHubのReleasesから入手できます。使い方は、紹介ページにまとめています。

参考