[Astro] #212 スマホVR焚き火 BONFIRE: 森と炎のモデル・火の粉と煙・座るVRM(骨をコードで曲げる)・外部VRMの保存
はじめに
スマホと Cardboard 型ゴーグルで、夜の森の焚き火を見つめるアプリ BONFIRE の実装記録です。PC のブラウザでも動きます。
- 森と焚き火: Sketchfab の CC BY モデル(炎のアニメ入り)
- 星空: Sky Today の部品をそのまま流用
- 火の粉と煙: コードで生成(外部の素材なし)
- 焚き火の音: ループ音源を Web Audio で再生
- 注視メニュー: 足元の MENU を見つめると、正面にボタンが並ぶ
- 焚き火のそばに座る VRM: 骨をコードで曲げて座らせる。外部の VRM に差し替えて、IndexedDB に保存できる
スクリーンショット
起動画面
スマホ用VR画面
星空と焚き火 PC(STEREOオフ)
丸太に座る VRM
動画
1. 構成
public/tool/mobile/bonfire/ に置いています。
| ファイル | 役割 |
|---|---|
bonfire.js | 本体。調整値(CONFIG)、シーン、メニュー、音、VRM |
bonfire-place.js | モデルの置き方(視点の位置と向きの計算) |
bonfire-fx.js | 火の粉と煙 |
bonfire-pose.js | VRM の座りポーズ(骨の角度の表と、変換の関数) |
../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」の場所を、立つ場所にします。
- 立つ場所の真上から、下へレイを飛ばして、地面の高さを出す
- 立つ場所から炎を見る向きが −z になるように、モデルを回す
- 立つ場所が原点に来るように、モデルを動かす
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;
| 火の粉 | 煙 | |
|---|---|---|
| 数 | 64 | 22 |
| 合成 | 加算 | 通常 |
| 動き | ゆらぎながら昇り、冷めて消える | 昇りながら広がり、薄くなる |
| 色 | 橙 → 赤、またたく | 低い所は橙、昇ると青灰色 |
| 濃さ | — | 最大 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 を見つめる(
dwell1.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.dist | at が 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 の広場)
[Mobile tool] PLANETARIUM — 見上げる星空を、時間も場所も変えて見る(スマホVR・Cardboard 対応) // PROTOCOL.LAIN
スマホの傾きで見回す、プラネタリウム PLANETARIUM の案内。Cardboard 型ゴーグルでの見方、足元のボタン(時間・場所・±1 か月・表示の切り替え・正面)、星を見つめると出る名前、太陽の通り道、PC での見方、クレジットをまとめています。
lain-lab.com
[Mobile Tool] PLAZA — 見つめると近づく VR の広場ゲーム(スマホVR・Cardboard 対応) // PROTOCOL.LAIN
スマホの傾きで見回し、顔を見つめると VRM が寄ってくる、注視ゲーム PLAZA の案内。Cardboard 型ゴーグルで遊ぶ手順、ハートの仕組み、ステージごとの人数と制限時間、足元のボタン(RETURN / SKIP / CREDIT / CONTINUE)、PC での遊び方、クレジットをまとめています。
lain-lab.com
[Web Tool] Sky Today — 日の出・日の入り・月・星を、全天の 3D と VR で見る ブラウザ完結型 アプリ // PROTOCOL.LAIN
lain-lab.com の「Sky Today」(ブラウザ完結)の使い方ガイド。場所と日付の入れ方、数字の見方、空の図、1 年の日の出・日の入りの図、全天 3D の操作とキーボードショートカット、天の川・星雲・星団・流れ星、日付と 1 年のアニメ、Quest の VR・MR、時間帯の違い、対応していないもの、うまくいかないときの対処、よくある質問を解説。計算も位置情報も、外へは送信されません。
lain-lab.com