[Astro] #191 VRM が粒子になって溶け、別の VRM が渦から組み上がる — 切り替え演出の実装と実測

[Astro] #191 VRM が粒子になって溶け、別の VRM が渦から組み上がる — 切り替え演出の実装と実測

はじめに

VRM の表面から粒子を取り出して、モデルが粒子に溶け、また元の姿に戻る演出を作りました。さらに、トップページの VRM 切り替えに組み込みました。メニューでモデルを替えると、今のモデルが足元の渦になって消え、次のモデルがその渦から組み上がります。

この記事は、その実装の記録です。

結論から言うと、PC では動きます。ただし、Quest 3 では切り替えの最初に約 2 秒固まったため、既定を OFF にし、メニューで ON にする形にしました。 固まる時間の大半は、粒子の作成(メッシュから点を取る処理)でした。

スクリーンショット

単体ページ(vortex)

VRM MELT — 足元の渦(vortex) VRM MELT — 足元の渦(vortex)

トップページで、VRM を切り替えている途中

VRM MELT — トップページでの切り替え

SYS タブの VRM_MELT_FX(ON / OFF)

VRM MELT — メニューの ON/OFF

動画

VRM MELT — 足元の渦(vortex) VRM MELT — 足元の渦(vortex)

作ったもの

場所内容
public/tool/vrm-melt/vrm-melt.js粒子化の本体。createVrmMelt(THREE, root, opts)。VRM のモデルを渡すと、粒子と、溶かす仕組みを返します
public/tool/vrm-melt/index.html単体の確認ページ。VRM をドロップして、MELT のスライダーで動かします
src/components/PROJECT_LAIN/WiredVrmMelt.tsトップページ用のフック useWiredVrmMelt と、ON / OFF の関数
WiredAvatar.tsx切り替えの順番管理を追加
hooks/useWiredVRM.ts読み込み設定の切り出しと、先読み preloadWiredVRM を追加
WiredAvatarMenu.tsx / WiredVRMenu.tsxメニューに ON / OFF(VRM_MELT_FX)を追加

WiredScene.tsx は、変えていません。

粒子の作り方

点を取る

モデルの表面から、点を一様に取ります。手順は次のとおりです。

  1. 見えているメッシュだけを集めます。サムネイル用の板(icon など)は、アバターの一部ではないので、粒子にせず、非表示にします。
  2. 各メッシュの頂点を、今のポーズのワールド座標にします。スキンメッシュは、mesh.getVertexPosition(i, v) でスキン計算後の位置を取り、matrixWorld をかけます。
  3. 三角形ごとの面積を求めて、累積の配列を作ります。面積が大きい三角形ほど、点がたくさん取れます。顔と目の材質は、重みを 3 倍にします。
  4. 乱数を累積配列に二分探索で当てて三角形を選び、三角形の中の点を、重心座標の乱数で決めます。
  5. 点の色は、UV からテクスチャを読んで決めます。テクスチャの alpha が alphaTest 未満の点は捨てて、取り直します。
const x = rnd() * totalW;
let lo = 0, hi = nCum - 1;
while (lo < hi) { const m = (lo + hi) >> 1; if (cum[m] < x) lo = m + 1; else hi = m; }
// lo が三角形の番号
let u = rnd(), v = rnd();
if (u + v > 1) { u = 1 - u; v = 1 - v; }
const w = 1 - u - v;  // 頂点 a, b, c の重みは w, u, v

点ごとに、次の値を持たせます。

  • rest: 今のポーズでの位置(ワールド座標)
  • bind: バインドポーズでの位置(ジオメトリの座標)
  • スキンの骨番号と重み: 三角形の 3 頂点のうち、重みが一番大きい頂点のものを借ります
  • 乱数 8 個: 渦の中での位置や、粒の大きさのばらつきに使います
  • 色

テクスチャの色は、OffscreenCanvas に描いて、getImageData で読みます。4096px を丸ごと読むと重いので、長辺 1024px までに縮めて読みます(粒の色を取るだけなので、これで足ります)。

粒子は、骨に付いていく

アニメーションしている体に、粒子が付いていくようにします。SkinnedMesh のボーンテクスチャ(skeleton.boneTexture)を、粒子のシェーダーに渡します。シェーダーは、点ごとの骨番号と重みで、骨の行列を texelFetch で読み、bind の位置を今のポーズに動かします。

動きは、uMelt だけで決める

粒子の位置は、シェーダーの中で、uMelt(0〜1)という 1 つの値だけから計算します。粒子の状態を、CPU 側に持ちません。

// 0..1。小さいほど早く溶ける(頭 + ノイズ)
float meltT(vec3 rest){
  float hn = clamp((rest.y - uYMin) / max(uYMax - uYMin, 1e-4), 0.0, 1.0);
  return clamp(((1.0 - hn) * uOrder + mvn(rest * uNoiseF) * uJitter) / max(uOrder + uJitter, 1e-4), 0.0, 1.0);
}
// <=0: まだ元の位置 / 0..1: 移動中 / >=1: 銀河
float meltP(vec3 rest){ return (uMelt * (1.0 + uSpan) - meltT(rest)) / uSpan; }
  • meltT は、その点が溶け始める順番です。高さ(頭から)とノイズで決まります。
  • meltP は、uMelt と順番から決まる、その点の進み具合です。0 以下はまだ元の位置、0 から 1 は移動中、1 以上は渦に着いた状態です。

動いている最中は、体の中心軸のまわりの円筒座標(半径・高さ・角度)で、元の位置から渦の位置へ補間します。途中で外に膨らみ、ねじれます。

戻すのは、uMelt を下げるだけです。同じ道を逆に通って、足元から組み上がります。途中で止めても、絵が決まります。

元のモデルを、同じ式で消す

元のモデルは、粒子と同じ meltP で、溶け始めた場所から消します。材質の onBeforeCompile で、シェーダーに差し込みます。

mat.onBeforeCompile = (sh, r) => {
  if (prev) prev(sh, r);
  Object.assign(sh.uniforms, uniforms);
  sh.vertexShader = sh.vertexShader
    .replace('void main() {', 'varying vec3 vMeltPos;\nvoid main() {')
    .replace('#include <project_vertex>',
      '#include <project_vertex>\n vMeltPos = (modelMatrix * vec4(transformed, 1.0)).xyz;');
  sh.fragmentShader = sh.fragmentShader
    .replace('void main() {', `varying vec3 vMeltPos;\n${GLSL_COMMON}\n${edgeCommon}\nvoid main() {`)
    .replace('#include <alphatest_fragment>',
      `#include <alphatest_fragment>
       { float mp = meltP(vMeltPos);
         if (mp > 0.0) discard;
         float e = smoothstep(-uEdgeW, 0.0, mp) * step(0.0001, uMelt);
         diffuseColor.rgb = mix(diffuseColor.rgb, uEdge, e * 0.85); }`);
};
  • 溶けた所は discard で消し、縁だけ青く光らせます。
  • @pixiv/three-vrm の MToonMaterial でも、同じ差し込みが通ります。
  • 元の onBeforeCompile は、呼んでから差し込みます。元の customProgramCacheKey に、インスタンスごとの印を足して、プログラムの使い回しで混ざらないようにします。
  • dispose() で、材質を元に戻します。

点の大きさ

点の大きさは、カメラから見て同じ大きさに見えるように、画面の高さと視野から決めます。

gl_PointSize = clamp(sz * uPx / max(-mv.z, 1e-3), 1.5, uMaxPx);
melt.uniforms.uPx.value = hPx * 0.5 * cam.projectionMatrix.elements[5];
melt.uniforms.uMaxPx.value = Math.max(3, hPx * 0.003);
  • uPx は、画面の高さ(px)と、投影行列の縦の倍率から出します。
  • VR では、片目の画面(getCamera().cameras[0] の viewport)で計算します。
  • uMaxPx は、点の大きさの上限です。近づいた粒が大きく重なると、塗りつぶしの負荷が跳ね上がるので、画面の高さに対する割合(0.3%、最小 3px)で、上限を決めます。

渦のパターン

行き先の形を、パターンとして切り替えられます。setPattern(name) で選びます。

パターン動き
galaxy渦巻き銀河(腕 3 本・中心のふくらみ・もやの粒)
warp細い光の束になって、上へ抜ける
tornado上が広がる、3 本のらせんの柱
snakeうねりながら進み、行き先の周りにコイルを巻く
dust風で吹き飛ぶ
vortex足元の床に沿った、薄い渦巻き。トップページの既定

vortex の形は、次のとおりです。

  • 半径は、身長の 0.78 倍。腕は 3 本で、ねじれは 3.0、腕の広がりは 0.55
  • 中心ほど少し盛り上がる(0.16)
  • 回転は、中心ほど速い
  • 粒の大きさは、体のときの 0.3 倍(SIZE_GALAXY)。渦になる粒は、かなり細かい

setTarget(Vector3) で、行き先を指定できます(galaxy / tornado / snake)。アバターのルートが動けば、行き先と高さの基準も、毎フレーム追従します。

トップページへの組み込み

切り替えの順番

素直に作ると、「今のモデルが溶ける → 次のモデルを読み込む・組み立てる → 組み上がる」の順になります。ところが、次のモデルを最初に描くときに、テクスチャの転送とシェーダーのコンパイルで固まります。そのため、アニメーションの途中で固まってしまいます。

そこで、重い準備を、先に済ませる順番にしました。

  1. 次のモデル(incoming)を、見えない状態で組み立てます。粒子の作成、シェーダーのコンパイル(gl.compile)、見えない状態での数フレームの描画(テクスチャの転送)まで済ませます。今のモデルは、普通に見えたままです。固まるのは、この間です。
  2. 準備ができたら、今のモデルが溶けます(2.6 秒)。
  3. 銀河になりきったら、0.25 秒待って、次のモデルが渦から組み上がります(3.0 秒)。

アニメーションの間は、重い処理が残りません。

役割

WiredAvatarCore に、役割(MeltRole)を渡します。

役割意味
normal今見えているモデル。準備の間は粒子を作って待ち、meltOut で溶ける
incoming次のモデル。見えない状態で組み立て、準備ができたら onReady を呼ぶ
released入れ替わった後のモデル。渦から組み上がる

外側の WiredAvatar が、shown(今のモデル)と incoming(次のモデル)を持ち、順番を進めます。

  • 2 つの WiredAvatarCore が、一時的に同時に存在します。それぞれ、自分の <Suspense key={path} fallback={null}> の中に置きます。
  • key をモデルごとに付けるので、次のモデルが shown に入れ替わっても、組み立て直しません(組み立て直すと、useWiredVRM の後始末で GPU の資源が消え、また固まります)。
  • 次のモデルを、useLoader.preload(GLTFLoader, url, extender) で先読みします。
  • 次のモデルの準備中は、スポットライト・アクセサリー・ヒットボックスを出しません(二重になるため)。
  • 切り替え中にもう一度変えた場合は、今の溶け具合から続けます。元のモデルに戻した場合は、組み上げ直します。

演出なしで、すぐ入れ替わる場合は、次のとおりです。最初の表示、ROOM 以外のモード、OFF、粒子の読み込み・作成に失敗したとき、準備が 15 秒たっても終わらないとき、モデルの読み込みに失敗したときです。

時間と数の定数

WiredVrmMelt.ts の先頭に、まとめています。

定数値意味
MELT_OUT_SEC2.6溶けるまで
MELT_IN_SEC3.0組み上がるまで
HOLD_SEC0.25銀河になりきってから、次へ渡すまで
MAX_DT1/201 フレームで進める時間の上限
READY_FRAMES3見えない状態で描くフレーム数
COUNT_DESKTOP / COUNT_XR80000 / 40000粒子の数
SIZE_GALAXY0.3渦になったときの粒の大きさ

渦の回転の引き継ぎ

古いモデルの渦の回転(spin)を、次のモデルへ引き継ぎます。渡した瞬間に、渦の向きが変わって見えないようにするためです。色は、モデルごとに違うので、渡す瞬間に変わります。

Vite で、public の JS を読む

vrm-melt.js は、単体ページと共有するため、public/tool/vrm-melt/ に置いています。これを import('/tool/vrm-melt/vrm-melt.js') で読むと、Vite の dev サーバーが拒否します。

Failed to load url /tool/vrm-melt/vrm-melt.js ... This file is in /public ... can only be referenced via HTML tags

public のファイルを JS から import すると、dev サーバーが ?import を付けて、断るためです。fetch で中身を取り、Blob の URL から import() します。

modPromise = fetch(MELT_URL)
  .then((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
  .then((code) => {
    const blobUrl = URL.createObjectURL(new Blob([code], { type: 'text/javascript' }));
    return import(/* @vite-ignore */ blobUrl).finally(() => URL.revokeObjectURL(blobUrl));
  })

vrm-melt.js は、他のファイルを import しないので、この方法が成立します。入ったときだけ読み込み、失敗したときは、演出なしで切り替えます。

素の静的サーバーでの確認では、この不具合は再現しませんでした。Vite 経由でないためです。

詰まったこと

粒が大きな円盤になって、固まった

初めて動かしたとき、画面が、大きな円盤で埋まりました。点の大きさの上限が 48px で、近づいた粒(銀河は身長の 1.4 倍の半径)が、大きく重なっていたためです。塗りつぶしの負荷が跳ね上がります。

  • 点の大きさの上限を、画面の高さの割合にしました(上の uMaxPx)
  • 粒子の数を 15 万から 8 万(VR は 4 万)に減らしました

フリーズの間に、演出の時間だけが進んだ

モデルの切り替えで固まっている間に、演出の時間だけが進み、止まったあとには最後のほうしか見えませんでした。1 フレームで進める時間に上限(MAX_DT)を付けて、止まっていた間の分を飛ばさないようにしました。フリーズ自体は、なくなりません。

2 回目の切り替えで、組み上がりが繰り返された

VRM1 → VRM2 → VRM1 と替えると、VRM1 にならず、VRM2 の組み上がりが何度も繰り返されました。

原因は、組み上がりが終わった VRM2 の役割が released のまま残っていたことです。次の切り替えで、溶ける側として動かず、組み上がる動きを繰り返しました。準備完了を待つ保険の時間(15 秒)が来て、やっと入れ替わりました。

組み上がりが終わったあとは、normal として扱うように直しました。

const r: MeltRole = roleRef.current === 'released' && s.inDone ? 'normal' : roleRef.current;

1 回目の切り替えだけを試していて、2 回目(入れ替わったモデルが、溶ける側になる場合)を試していませんでした。往復の切り替えを、確認項目に入れました。

見えていないメッシュが、粒子になった

アプリ側で非表示にしているメッシュまで、粒子の対象になり、後始末で表示に戻ってしまいました。親をたどって、見えていないメッシュは、粒子にも後始末にも入れないようにしました。

測ったこと

ログは、切り替えのたびに、コンソールに出ます。VR では、メニューの CONSOLE タブにも出ます。

[vrm-melt] 準備: 粒子の作成 312ms / シェーダー 66ms / 最長フレーム 408ms / 組み立てから合計 794ms / 粒 80000 / 作成の内訳: 画像 ○ms 頂点 ○ms 点取り ○ms
[vrm-melt] 溶ける側: 最長フレーム 408ms
[vrm-melt] 組み上がる側: 最長フレーム 408ms

PC

順番を変える前は、組み上がる側の最長フレームが 2075ms でした(粒子の作成 351ms、シェーダー 117ms)。粒子以外の約 1.6 秒は、次のモデルを最初に描くときの負荷でした。

順番を変えたあと(先に準備する形)は、次のとおりです。

値
準備: 粒子の作成312〜516ms
準備: シェーダー66〜220ms
溶ける側・組み上がる側の最長フレーム408〜556ms

アニメーションの間は、滑らかになりました。代わりに、クリックしてから溶け始めるまでの待ちが、準備の時間だけ長くなりました。

Quest 3

粒の数は 40,000 です。

値
粒子の作成1592ms
シェーダー85ms
最長フレーム2211ms
組み立てから準備完了まで4043ms
  • 切り替えの最初に、約 2 秒固まります。そのうち約 1.6 秒は、粒子の作成です。
  • 粒子が動いている間は、滑らかです。ログの「溶ける側・組み上がる側」の最長フレームは、作成のフレームを含むので、アニメーション中の重さではありません。
  • 演出を OFF にすると、Quest 3 でも、切り替えは軽くなります。 固まりの大半は、粒子まわり(作成が約 1.6 秒)です。

ログには、作成の内訳(画像・頂点・点取り)を足してあります。テクスチャを 1024px に縮める変更は、Quest 3 では、まだ測っていません。

既定は OFF

Quest 3 の結果から、既定を OFF にしました。メニューの SYS タブの VRM_MELT_FX で、ON / OFF を切り替えます(デスクトップ用の WiredAvatarMenu.tsx と、VR 用の WiredVRMenu.tsx の両方)。

export const isVrmMeltEnabled = (): boolean => {
  try { return localStorage.getItem('wired_vrm_melt') === 'on'; } catch { return false; }
};
export const setVrmMeltEnabled = (on: boolean): void => {
  try { if (on) localStorage.setItem('wired_vrm_melt', 'on'); else localStorage.removeItem('wired_vrm_melt'); } catch { /* noop */ }
};
  • 保存先は、localStorage の wired_vrm_melt です。'on' のときだけ ON になります。
  • 切り替えは、次の VRM チェンジから効きます。
  • 渦のパターンは、localStorage.setItem('wired_vrm_melt_pattern', 'galaxy') で替えて比べられます(removeItem で vortex に戻ります)。

確認できたこと、できていないこと

確認できたこと

  • PC のブラウザで、単体ページとトップページの両方が動くこと。VRM 1 → VRM 2 → VRM 1 の往復で、切り替えごとに「準備 → 溶ける → 組み上がる」が 1 回ずつ出ること
  • Quest 3 で、演出が動くこと。粒子のアニメーションは滑らかなこと。OFF にすると、切り替えが軽いこと
  • MToonMaterial の VRM でも、溶かす差し込みが通ること
  • 透明テクスチャの点の捨て処理が、働くこと(暗い服の VRM で、捨てた点が 23,516)
  • 手元のヘッドレスブラウザ(ソフトウェア描画)で、次のモデルが、準備中に一瞬見えたりしないこと、エラーが出ないこと

できていないこと

  • テクスチャを縮める変更の、Quest 3 での効果
  • 粒子の作成の内訳(画像・頂点・点取り)の、Quest 3 での値
  • 粒子の作成を、数フレームに分ける方法(合計は同じですが、固まらなくなる見込み)
  • 粒子だけの状態で、顔の目と口が見えないこと(暗い服の VRM。原因は未調査)
  • アクセサリー(WiredAccessory)は、溶けません。切り替えの間、体から離れて見える可能性があります
  • 表情(モーフターゲット)は、粒子に入りません(骨の動きにだけ付いていきます)
  • 走るなどの、大きな動き(確認は、体を揺らす程度)
  • NEBULA(GPGPU 粒子)への受け渡し

おわりに

粒子の位置を、uMelt 1 つで決める作りにすると、戻すのも、途中で止めるのも、簡単でした。モデル切り替えへの組み込みは、重い準備を先に済ませる順番にして、アニメーションの間を滑らかにしました。

一方で、粒子の作成が、Quest 3 では約 1.6 秒かかり、切り替えの最初に固まることが分かりました。そのため、既定を OFF にしています。作成を数フレームに分ける方法は、今後の課題です。

粒子化の本体 vrm-melt.js は、他のファイルに依存しない形なので、ゲームのキャラクターの演出(ワープ、竜巻、蛇のような攻撃)にも、持ち込めます。