[Astro] #191 VRM が粒子になって溶け、別の VRM が渦から組み上がる — 切り替え演出の実装と実測
はじめに
VRM の表面から粒子を取り出して、モデルが粒子に溶け、また元の姿に戻る演出を作りました。さらに、トップページの VRM 切り替えに組み込みました。メニューでモデルを替えると、今のモデルが足元の渦になって消え、次のモデルがその渦から組み上がります。
この記事は、その実装の記録です。
結論から言うと、PC では動きます。ただし、Quest 3 では切り替えの最初に約 2 秒固まったため、既定を OFF にし、メニューで ON にする形にしました。 固まる時間の大半は、粒子の作成(メッシュから点を取る処理)でした。
スクリーンショット
単体ページ(vortex)
トップページで、VRM を切り替えている途中
SYS タブの VRM_MELT_FX(ON / OFF)
動画
作ったもの
| 場所 | 内容 |
|---|---|
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 は、変えていません。
粒子の作り方
点を取る
モデルの表面から、点を一様に取ります。手順は次のとおりです。
- 見えているメッシュだけを集めます。サムネイル用の板(
iconなど)は、アバターの一部ではないので、粒子にせず、非表示にします。 - 各メッシュの頂点を、今のポーズのワールド座標にします。スキンメッシュは、
mesh.getVertexPosition(i, v)でスキン計算後の位置を取り、matrixWorldをかけます。 - 三角形ごとの面積を求めて、累積の配列を作ります。面積が大きい三角形ほど、点がたくさん取れます。顔と目の材質は、重みを 3 倍にします。
- 乱数を累積配列に二分探索で当てて三角形を選び、三角形の中の点を、重心座標の乱数で決めます。
- 点の色は、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)。アバターのルートが動けば、行き先と高さの基準も、毎フレーム追従します。
トップページへの組み込み
切り替えの順番
素直に作ると、「今のモデルが溶ける → 次のモデルを読み込む・組み立てる → 組み上がる」の順になります。ところが、次のモデルを最初に描くときに、テクスチャの転送とシェーダーのコンパイルで固まります。そのため、アニメーションの途中で固まってしまいます。
そこで、重い準備を、先に済ませる順番にしました。
- 次のモデル(incoming)を、見えない状態で組み立てます。粒子の作成、シェーダーのコンパイル(
gl.compile)、見えない状態での数フレームの描画(テクスチャの転送)まで済ませます。今のモデルは、普通に見えたままです。固まるのは、この間です。 - 準備ができたら、今のモデルが溶けます(2.6 秒)。
- 銀河になりきったら、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_SEC | 2.6 | 溶けるまで |
MELT_IN_SEC | 3.0 | 組み上がるまで |
HOLD_SEC | 0.25 | 銀河になりきってから、次へ渡すまで |
MAX_DT | 1/20 | 1 フレームで進める時間の上限 |
READY_FRAMES | 3 | 見えない状態で描くフレーム数 |
COUNT_DESKTOP / COUNT_XR | 80000 / 40000 | 粒子の数 |
SIZE_GALAXY | 0.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 は、他のファイルに依存しない形なので、ゲームのキャラクターの演出(ワープ、竜巻、蛇のような攻撃)にも、持ち込めます。