[Astro] #212 スマホVR焚き火 BONFIRE: 森と炎のモデル・火の粉と煙・座るVRM(骨をコードで曲げる)・外部VRMの保存

[Astro] #212 スマホVR焚き火 BONFIRE: 森と炎のモデル・火の粉と煙・座るVRM(骨をコードで曲げる)・外部VRMの保存

はじめに

スマホと Cardboard 型ゴーグルで、夜の森の焚き火を見つめるアプリ BONFIRE の実装記録です。PC のブラウザでも動きます。

  • 森と焚き火: Sketchfab の CC BY モデル(炎のアニメ入り)
  • 星空: Sky Today の部品をそのまま流用
  • 火の粉と煙: コードで生成(外部の素材なし)
  • 焚き火の音: ループ音源を Web Audio で再生
  • 注視メニュー: 足元の MENU を見つめると、正面にボタンが並ぶ
  • 焚き火のそばに座る VRM: 骨をコードで曲げて座らせる。外部の VRM に差し替えて、IndexedDB に保存できる

スクリーンショット

起動画面

起動画面

スマホ用VR画面

スマホ用VR画面

星空と焚き火 PC(STEREOオフ)

星空の下の森と、燃える焚き火

丸太に座る VRM

焚き火のそばの丸太に、膝を抱えて座る VRM

動画

動画

1. 構成

public/tool/mobile/bonfire/ に置いています。

ファイル役割
bonfire.js本体。調整値(CONFIG)、シーン、メニュー、音、VRM
bonfire-place.jsモデルの置き方(視点の位置と向きの計算)
bonfire-fx.js火の粉と煙
bonfire-pose.jsVRM の座りポーズ(骨の角度の表と、変換の関数)
../common/cardboard.jsスマホ VR の共通部品(傾きセンサー、STEREO、注視ボタン)
../../sky-today/星空(createSkyScene など)

素材は、public/models/item/camping_buscraft_ambience.glb、public/models/lain_sleep4.vrm(既定の VRM)、public/assets/se/焚き火ループ.mp3 です。

座標は、原点が立っている足元、START を押した時に向いていた方が −z で、そこに焚き火が見えます。

2. 森と焚き火のモデル

モデルは、次の条件でした。

  • 材質はすべて KHR_materials_unlit(光は焼き込み済み。シーンのライトの影響を受けない)
  • Draco 圧縮、テクスチャは WebP
  • アニメは 1 本(葉と、炎の形の変化)
  • 単位は 1 m

Draco のデコーダー

DRACOLoader は、自分のサイトの /tool/draco/ から読みます(外部の CDN に頼らない)。

const draco = new THREE.DRACOLoader(); draco.setDecoderPath('/tool/draco/');
const loader = new THREE.GLTFLoader(); loader.setDRACOLoader(draco);

VRM の読み込みでは、draco.setDecoderConfig({ type: 'js' }) も指定します(draco_decoder.js を使う方式)。

視点の置き方

モデルの座標で、炎 fire とテントの中心 tent を持ち、「炎から見てテントの反対側へ stand m」の場所を、立つ場所にします。

  1. 立つ場所の真上から、下へレイを飛ばして、地面の高さを出す
  2. 立つ場所から炎を見る向きが −z になるように、モデルを回す
  3. 立つ場所が原点に来るように、モデルを動かす

placeModel は、これらを済ませた rig、地面の高さ ground、炎の世界座標 fireWorld を返します。目の高さは eyeHeight(既定 1.3 m。座った高さに近い)です。

炎のアニメ

AnimationMixer で、含まれるクリップを全部再生します。そのままの速さでは速すぎたので、mixer.timeScale を animSpeeds(1 / 0.5 / 0.25)から選べます(既定は 0.5 倍)。

炎の板は動いて見切れ判定からはみ出すので、名前に fire を含むメッシュの frustumCulled を切っています。

落ちてくる葉

葉の板(pPlane184 〜 pPlane196)は、雰囲気を壊すので、既定で非表示にして、メニューの LEAVES で切り替えられるようにしました。

3. 星空

Sky Today の createSkyScene を、そのまま使います。

sky = createSkyScene(THREE, {
  calc: createCalc(Astronomy), data, S, pixelRatio: cb.renderer.getPixelRatio(),
  decor: CONFIG.skyDecor, labelScale: 0.8, bodyScale: 0.8,
});
sky.root.rotation.y = HEADINGS[CONFIG.heading];   // 正面の向き(0 北 / 1 東 / 2 南 / 3 西)
  • 時刻: 起動した日の startHourUTC(14 = 日本の 23 時)から、rate 倍で進める
  • 星座線・星座名は、既定で消す。天の川と流れ星は出す
  • 地平線の飾り(decor)は、森の地面と重なるので切る
  • 島の下には、暗い円盤(半径 98)を敷く。空の半径は 100 なので、その内側

4. 火の粉と煙

粒は、世界の大きさを持つ板(ビルボード)を、InstancedBufferGeometry で 1 回の描画にまとめて出します。点の大きさ(ピクセル)を使わないので、STEREO にしても、左右の目で大きさがずれません。

  • 1 粒ごとに、iPos(xyz + 大きさ)と iCol(rgb + 濃さ)を持つ
  • 頂点シェーダーは、視点座標の xy に大きさを足して、常にカメラの方を向く板にする
vec4 mv = modelViewMatrix * vec4(iPos.xyz, 1.0);
mv.xy += position.xy * iPos.w;
gl_Position = projectionMatrix * mv;
火の粉煙
数6422
合成加算通常
動きゆらぎながら昇り、冷めて消える昇りながら広がり、薄くなる
色橙 → 赤、またたく低い所は橙、昇ると青灰色
濃さ—最大 0.26

風は、ゆっくり向きが揺れる値(wind)を、両方に足しています。生まれる位置は、炎の位置 fireWorld からの高さ(sparkY / smokeY)で決めます。

5. 焚き火の音

ループ音源を、Web Audio の AudioBufferSourceNode(loop = true)で再生します。<audio loop> では、ループのつなぎ目に隙間が出ます。

  • iPhone は、ユーザー操作の中でしか音を始められないので、AudioContext の作成と再生を、START ボタンの中(onStart)で行う
  • 音量は GainNode。メニューの SOUND で、0 と volume(既定 0.6)を切り替える
  • ページが隠れたら suspend()、戻ったら resume()

ファイル名の正規化

ファイル名の「プ」が、「フ」+ 半濁点(分解形)で保存されていて、/assets/se/焚き火ループ.mp3(結合形)では 404 になりました。コードでは プ と書いています。ファイル名を ASCII にすれば、この問題は起きません。

se: '/assets/se/焚き火ループ.mp3',

6. 注視メニュー

足元のボタンは、木に隠れたり、雰囲気を壊したりしたので、足元には小さな MENU だけを置きました。

  • MENU を見つめる(dwell 1.2 秒)と、いま向いている方の正面に、ボタンが横に並ぶ(LEAVES / SPEED / FX / SOUND / CREDIT / RETURN / CLOSE)
  • 何も押さずに 15 秒たつと、閉じる
  • 並べる向きは、水平に近く見ている間の向きを覚えておき、開いた時にそこへ出す
  • 画面の形(片目の縦横比)に合わせて、ボタンを縮める。縦長の窓は、片目の横の視野が狭い
  • ボタンの文字は、前後に空行を入れて小さくし、丸の中に収める

7. 座っている VRM

VRM は、座りポーズ専用のモーションを使わず、骨を直接曲げて座らせます。ポーズの数字は bonfire-pose.js の SIT_POSE に、度で書いています。

export const SIT_POSE = {
  spine: [8, 0, 0], chest: [6, 0, 0], neck: [10, 0, 0], head: [8, 0, 0],
  leftUpperLeg: [-135, 0, 8], rightUpperLeg: [-135, 0, -8],
  leftLowerLeg: [130, 0, 0], rightLowerLeg: [130, 0, 0],
  leftUpperArm: [50, -83, 0], rightUpperArm: [50, 83, 0],
  leftLowerArm: [0, -20, 0], rightLowerArm: [0, 20, 0],
};

[x, y, z] は、Euler(順序 XYZ)です。「+Z を向いた T ポーズの VRM」を前提に、太ももと体は、マイナスで前へ曲がり、ひざは、プラスで曲がります。

モデルの向きの検出

VRM 0.x は rotateVRM0 で 180° 回すのが普通ですが、このモデルは VRM 0.x の印がありながら、元々 +Z を向いていました。向きを決め打ちせず、骨から求めます。

// 前 = (左の太もも − 右の太もも) × 上
const f = new THREE.Vector3().subVectors(a, b).cross(new THREE.Vector3(0, 1, 0));
return Math.atan2(f.x, f.z);   // f0: +Z からの角度

骨の回転を、モデルの向きに合わせる

正規化ヒューマノイドの骨は、回転なしの状態が、シーンの軸に揃っています。f0 の分だけ回っているモデルでは、ポーズの回転 q を、軸ごと回す必要があります。

// Q = 縦軸まわりに f0 回す。node.quaternion = Q · q · Q⁻¹
node.quaternion.copy(q.setFromEuler(e.set(x * D, y * D, z * D, 'XYZ'))).premultiply(Q).multiply(Qi);

前向きと後ろ向きの骨格(左右が逆)を模擬で作り、同じポーズ表で、同じ座り方になることを確かめました。

足を地面につける

座る場所は、CONFIG.vrm.at([x, z])です。その場所の上から下へレイを飛ばして、最初に当たる上向きの面の高さ gy を出します(丸太の上にも座れる)。

vrm.scene.position.set(sx, 0, sz); vrm.humanoid.update(); vrm.scene.updateMatrixWorld(true);
vrm.scene.position.y = gy - lowestFootY(THREE, vrm.humanoid) + C.footLift;

lowestFootY は、足首とつま先の骨(raw)のうち、いちばん低い所の高さです。

足の高さが、再読み込みでずれた

骨の姿勢は、vrm.humanoid.update() を呼ぶまで、実際の(raw の)骨に反映されません。読み込み直後は、ポーズを正規化骨にだけ設定した状態で、足の高さを測っていたので、立った状態の足の位置になっていました。クリックで置き直した時は、すでにフレームが回っていて座った状態で測れていたので、同じ at でも高さが違いました。測る前に humanoid.update() を呼ぶようにして、そろいました。

場所の決め方

PC で、+ を座らせたい所に合わせて Shift + クリックすると、そこへ移ります。画面と console に at: [x, z] が出るので、CONFIG.vrm.at に書きます。向きは、常に炎の方です。

光と動き

  • MToon の材質は光が要るので、炎の位置に暖色の PointLight(強さを、複数の sin で明滅させる)と、弱い HemisphereLight を足す。モデルの森は unlit なので、影響を受けない
  • 呼吸: 背骨と胸を、わずかに前後させる
  • 首: ゆっくり左右に振る
  • まばたき: 2 〜 6 秒おきに、0.18 秒で閉じて開く。表情 happy を 0.25 に保つ
  • vrm.update(dt) を毎フレーム呼ぶ(スプリングボーンが動く)

8. 外部の VRM を読み込む

ゴーグルを付けたあとは、ファイルを選べないので、事前にスマホで選んで、保存しておく形にしました。

  • 上のバー(STEREO などと同じ並び)に、「VRM」と「VRM初期化」を追加する(addMenuButton)
  • 注視メニューには置けない。ファイル選択は、タップなどのユーザー操作の中でしか開けず、注視による選択はそれに当たらない
  • 選んだファイルが、VRM として読めた時だけ、古いモデルと入れ替え、Blob を IndexedDB に保存する。読めない時は、何も変えない
  • 起動時は、保存があればそれを読み、なければ CONFIG.vrm.path。保存が壊れていたら、消して既定に戻す
  • 「VRM初期化」で、保存を消して、既定の VRM に戻す

座る場所は、入れ替えても、その時の場所を引き継ぎます。

const pos = seatFn ? seatFn.pos : (C.at || [/* angle と dist から */]);

VRM Optimizer と VRM to GLB の Draco

同じ日に、vrm_optimizer.astro と vrm_to_glb.astro の Draco のデコーダーの場所を、CDN から /tool/draco/ に変えました。

dracoLoader.setDecoderPath('/tool/draco/');

9. 調整の値

bonfire.js の CONFIG です。コンソールでは、window.__bonfire.CONFIG から読めます。

値内容既定
model / dracoPathモデルのパス / Draco の場所— / /tool/draco/
fire / tent / stand炎・テントの位置(モデルの座標 m)/ 炎から立つ場所までの距離— / 2.2
eyeHeight目の高さ(m)1.3
startHourUTC / rate空の最初の時刻(UTC の時)/ 時間の進み14 / 1
animSpeeds / animSpeed炎のアニメの速さの候補 / 最初の番号[1, 0.5, 0.25] / 1
heading正面の向き(0 北 1 東 2 南 3 西)2
dwell注視ボタンを押すまでの秒1.2
se / volume焚き火の音のパス / 音量— / 0.6
vrm.path既定の VRM(空文字で座らせない)/models/lain_sleep4.vrm
vrm.at座る場所 [x, z](null なら angle / dist)—
vrm.angle / vrm.distat が null の時の、炎のまわりの角度(度)/ 距離(m)70 / 1.4
vrm.footLift足の高さの微調整(m、マイナスで沈む)—
vrm.light / vrm.lightColor炎が VRM を照らす光の強さ / 色4 / 0xff8a3c

座りポーズの角度は、bonfire-pose.js の SIT_POSE です。値を変えて、再読み込みすれば反映されます。

10. つまずいた点

  • 足元のボタンが、木に隠れて、雰囲気も壊す: MENU だけを足元に置き、見つめると正面にボタンが並ぶ形にした(6 章)
  • クレジットの文字が、枠からあふれる: 1 行を約 30 字までにして、行数を増やし、枠を高くした。データは CREDITS の配列に持つ
  • SE が 404: ファイル名が分解形の半濁点だった(5 章)
  • 注視でファイル選択が開かない: 注視の選択は、ユーザー操作として扱われない。VRM の読み込みは、上のバーに置いた(8 章)
  • 足の高さが、読み込み直後とクリックで違う: 骨の姿勢が raw の骨に反映される前に測っていた(7 章)
  • VRM が光を受けない: MToon はライトが要る。炎の位置に点光源を足した

11. 制約

  • 座る VRM は 1 体です。座りポーズは体育座り 1 種類で、モーション(VRMA)の読み込みは、まだありません
  • 夜だけです。夜明けの空の変化は、まだありません
  • 薪を足す操作は、まだありません
  • 焚き火の音は、方向のない音(ステレオの固定)です
  • 見た目の火の粉と煙は、コードで生成しています

クレジット

  • モデル: camping buscraft ambience / Edgar_koh(CC BY)。WebP 化、Draco 圧縮、星空への差し替えの改変あり
  • 効果音: 焚き火ループ / Springin’
  • VRM 衣装(既定の座る VRM): PANDA衣装 / official-satori(BOOTH)
  • 星空: Sky Today(星のデータ d3-celestial(BSD-3)、天体の計算 Astronomy Engine(MIT))

アプリの CREDIT ボタンにも、同じ内容を出します。

関連

  • Sky Today(今日の空)
  • PLAZA(スマホ VR の広場)