[Tauri #11] WiredFrame v0.3.0 公開:ぷにぷにゼリーの物理演算を、スクリーンセーバーのモードに追加
はじめに
前回(#10)で、v0.2.0を公開しました。今回は、v0.3.0 の記録です。追加したのは、モードが1つだけ。ゼリーです。
ゼリーの物理演算は、先にNOTEで試作していました(下にリンクがあります)。それを、スクリーンセーバーのモードとして、WiredFrameに載せました。
今回は、Rustのコードを変えていません。TypeScriptだけです。前回作った「モードを足す型」に、そのまま載りました。この記事に書くのは、次の3つです。
- ゼリーが、跳ね回る・合体する・光る・分裂する、をくり返す仕組み
- モニターの縦横比に、ゼリーの箱を合わせる方法
- 分裂フェーズが止まってしまった不具合の記録
スクリーンショット
設定パネルの「詳細な調整」にも、ゼリーのスライダーが出ます(これは前回の仕組みのままです)。
動画
1. 何を足したか
| ファイル | 役割 |
|---|---|
src/modes/jelly/JellyWorld.ts | 物理。three.jsに依存しない |
src/modes/JellyMode.ts | Canvas2Dに描いて、テクスチャとして画面に貼る。IMode の実装 |
src/modes/params.ts | JELLY_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に戻ります
止まらない仕掛け
重力は、なしです。そのままだと、ぶつかるたびに速さが落ちて、止まります。そこで、ゼリー全体の速さを、一定の範囲に戻しています。
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) | 0 | 5 / 400 | 平均 4.5 ms | 7周 |
| 標準(W=640) | 0 | 5 / 400 | 平均 1.9 ms | 8周 |
| 縦置き(W=394) | 0 | 2 / 400 | 平均 2.2 ms | 10周 |
「折れ」は、輪郭が自己交差した状態です。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から入手できます。使い方は、紹介ページにまとめています。
[Desktop App] WiredFrame v0.3.0 — 画像フォルダの写真と、ノイズやゼリーの映像を、全モニターで動かすスクリーンセーバー
インストール、画像フォルダの登録、モニターごとの設定、起動と終了、ノイズモード、ゼリーモード、よくある質問。
lain-lab.comRelease v0.3.0 · fixtan/WiredFrame
表示モード「ゼリー」を追加。
github.com参考
- sobosubaのぷにぷにゼリーの物理演算を試作(NOTE):ゼリーの物理演算の試作と、失敗の記録
- 80.lv:Godot powered ultra squishy merge game you’ll want to play:本家sobosubaの作りを紹介した記事(開発者のRedditでの説明が元)
- Soft-body dynamics(Wikipedia):PBDの説明がある