[Astro] #201 VRM と一緒に寝る添い寝モード(部屋・暗転・パジャマ・撫でる・寝息の合成・VR 設定パネル)
はじめに
TOP ページに、マスコットと一緒に寝る「添い寝モード」を足しました。ターミナルの sleep、または VR メニューの sleep で入ります。
- 部屋(GLB)に入って、ベッドの横にいるマスコットに話しかけると、暗くなって寝る
- 寝る前から、マスコットはパジャマを着ている(複数から選べる。既定はランダム)
- 寝ている間、胸が上下し、その動きに合わせた寝息が鳴る
- 頭を撫でると、笑顔になる(PC はドラッグ、VR は素手・コントローラー)
- VR では、本当に横になっても視点が枕の高さに戻る
- 設定は、PC は T キーのパネル、VR は左コントローラーの X で開くパネル
横向きの寝姿・寝返りと、ポーズファイルの変換は #202 に分けています。この記事は、土台の部分です。
使ったバージョンは、次のとおりです。
| 項目 | バージョン |
|---|---|
| three | 0.183 |
| @pixiv/three-vrm | 3.5.5 |
| @react-three/fiber | 9.x |
| @react-three/xr | 6.x |
| @react-three/uikit | VR パネル用 |
スクリーンショット
設定UI
1. 構成
モードの本体は SLEEP/SleepManager.tsx です。入口(WiredScene / WiredTerminal / WiredVRMenu)には、モードを切り替える数行だけを足しています。
| ファイル | 役割 |
|---|---|
SleepManager.tsx | 部屋・マスコット・歩く・寝る・撫でる・VR の位置合わせ |
SleepSettings.ts | 設定の定義(範囲・既定値・保存・換算)。DOM と VR のパネルで共有 |
SleepVRPanel.tsx | VR の設定パネル(uikit) |
BreathSound.ts | 寝息の合成 |
BreathInspector.ts | 寝息の調整パネル(DOM) |
BreathStore.ts | ユーザーの音声ファイルの保存(IndexedDB) |
SleepCreditBoard.tsx | クレジット看板 |
2. 部屋の寸法の決め方
部屋は、RPG と共通の two_bedroom.glb です。次の 2 つを、GLB から測って決めました。
- 倍率: ベッドの大きさではなく、天井のメッシュの高さ(
mesh_68、y = 1.65)から決めます。天井が 3 単位 = 2.4m になるので、ROOM_SCALE = 0.8です - マットレスの高さ: 家具全体の外形ではなく、マットレスの面に光線(
Raycaster)を当てて測ります。上面は 0.38m でした
ベッドの中心を世界の原点にしています。マスコットは、ベッドの左側(BED_AVATAR_X = -0.45)、自分は右側(BED_ME_X = 0.45)、頭は枕側の -z(PILLOW_Z = -1.0)です。調整値は、SleepManager.tsx の先頭に定数として並べています。
3. 寝る遷移
進み具合を s(0〜1)で持ち、ease(s) を e として、全部の動きを e で混ぜます。
sl.s = Math.min(1, Math.max(0, sl.s + (sl.want ? 1 : -1) * dt / TRANSITION_SEC)); // 2.8 秒
const e = ease(sl.s); // t*t*(3-2t)
- 位置は、立ち位置からベッドの上へ
lerp(..., e)、向きはslerp(..., e) - 仰向けの傾きは、
Q_TILT(後ろへ 90°)に、モデルの前の補正を掛けた値 - 腕は、立ち姿のアイドルアニメーションから、体の横へ
slerp - 目を閉じる量は
blink = e
モデルの前と腕の向きは、VRM 0 と 1 で軸が違います。読み込み時に、腕の位置から前を自動判定して、rig.facing に持ちます。決め打ちにすると、逆向き・万歳になります。
applyPose(立ち位置の適用)は、rotation.y だけでなく、先に rotation.set(0, 0, 0) を入れます。寝る途中の傾き(x, z)が残ったまま別のアバターに切り替えると、寝た向きが残るためです。
4. 部屋を暗くする
部屋側のライトだけを絞っても、暗くなりません。TOP 側のライトと環境光が残るためです。シーン全体を走査して、すべてのライトと environmentIntensity を絞ります。
const ed = ease(Math.min(1, sl.s * 2)); // 暗くなるのは前半
const mul = 1 + (cfg.light - 1) * ed;
dimRef.current.apply(scene, mul);
他のコンポーネントが intensity を書き換えても壊れないように、「自分が最後に書いた値」と比べて、変わっていたら基準値を取り直します。戻すときは apply(scene, 1)、モードを出るときは restore() です。
設定のスライダーは 0〜100% で、つまみ位置の 2 乗を明るさにします。既定は 6% です。
5. パジャマ
普段着とパジャマの 2 体を別々に読み込みます(avs.day / avs.pj)。models.json の category: "pajama" の項目が候補です。
- 候補が複数あれば、既定はランダム。衣装タブで 1 つ選べる
- 普段着が
models.jsonにある内蔵モデルのときだけ有効(アップロードしたモデルは対象外) - 選び直したときは、新しいパジャマが読めてから、その場で入れ替える(読み込み中に普段着を挟むと、一瞬、黒い服が出る)
- 添い寝モードの間は、立っているときも、常にパジャマを出す。読み込み前とパジャマが無いモデルは、普段着
if (!swap('pj')) swap('day'); // swap は、未読み込みなら false
入れ替えは、root.remove / root.add と、vrmRef などの参照の付け替えです。
6. 頭を撫でる
PC は、頭をドラッグします。VR は、#200 の素手の球(指先 5 点と手のひら)と、コントローラーのグリップの半径 5cm の球を使います。
- 判定: 頭のボーンの上 7cm を中心にした半径 14cm の中に球が入っていて、球が 6cm/秒(
PAT_SPEED)より速く動いている間 - 寝きったあと(
e > 0.99)だけ - 反応: 笑顔が 1 秒で最大になる。やめて 3 秒で消え始め、2.5 秒で消える。目は閉じたまま。頭が少し揺れる(
PAT_NOD = 0.05)。字幕が ON なら、短いセリフ - 笑顔の表情は、VRoid の「楽しい」の
relaxed(happyは口が大きく開く)
球は VRMSpringBoneCollider として、全揺れものの共有グループに登録します。three-vrm 3.5 では、衝突の計算に colliderMatrix が使われるため、更新は updateWorldMatrix(true, false) です(updateMatrixWorld では動きません。詳細は #200)。
7. VR で本当に横になる
VR の視点(頭)は、人の体の動きなので、実際に横になると、頭の高さが変わります。寝た瞬間の頭の位置で部屋を固定すると、横になったときに、頭が下がってベッドを見上げる形になります。
寝ている間は、部屋の位置を、頭の位置に追従させます。
const A = vr.current.anchor;
// 寝る途中は、頭の位置にそのまま合わせる。寝きったあとは、ゆっくり追う
if (sl.s < 1) A.copy(target); else A.lerp(target, 1 - Math.exp(-dt / VR_RECENTER_SEC)); // 2.0 秒
root.position.set(wX + (A.x - wX) * e, wY + (A.y - wY) * e, wZ + (A.z - wZ) * e);
ゆっくり動く分は追うので、横になれば、頭が枕の高さに戻ります。素早い頭の動き(のぞき込む)は追わないので、残ります。起きるときの部屋の高さは床基準なので、横になったまま起きると、視点が低いままです。先に体を起こす必要があります。
8. 寝息の合成
音は、胸の動きと同じ位相から作ります。
- 胸の上下 =
sin(phase) - 空気の流れ = その傾き
cos(phase)。cos > 0を吸う、cos < 0を吐くとする - ピンクノイズを、バンドパスとローパスで整えて、音量を
|cos|で動かす(間・山の形つき) - 位相は、毎フレーム
dt * 2π / periodを足していく(呼吸の長さを変えても、位相が飛ばない)
胸と音が、同じ位相で動くので、必ず同期します。パラメーターは、次のとおりです。
| パラメーター | 既定 | 内容 |
|---|---|---|
| 呼吸の長さ | 4.2 秒 | 1 周期 |
| 胸の動き | 0.03 rad | 振幅 |
| 吸う音・吐く音 | 0.27・0.40 | 音量 |
| 吸う音の高さ・吐く音の高さ | 1100 Hz・410 Hz | バンドパスの中心 |
| 音の鋭さ | 2.8 | Q |
| こもり | 4100 Hz | ローパス |
| 山の形・間・ゆらぎ・胸とのずれ | 1.0・0.12・0.11・0 | 包絡の整形 |
音源は、合成のほかに、se.json の mp3(ループ再生。胸とは同期しない)と、ユーザーが追加した音声ファイルです。
- 追加した音声は IndexedDB(DB 名
sleep-se)に保存します。サーバーには送りません。1 ファイル 15MB、20 個まで - 音源の id は、内蔵が数字の文字列、ユーザーのファイルが
user:で始まる。消したファイルをプリセットが指していても、合成として鳴る AudioContextは操作のあとでないと鳴らないので、マウント時と、話しかけて寝るときにstart()を呼ぶ- 現在の値は
sleep-breath、プリセット 10 個はsleep-breath-presets(localStorage)
調整用のインスペクターは、波形・試聴・全パラメーターのスライダー・プリセットを持ちます。SleepManager との依存は、胸の位相を渡すことだけにしています。
9. 設定パネル(DOM と VR)
設定の定義(範囲・既定値・保存・換算)を SleepSettings.ts に集めて、DOM の T パネルと VR パネルが同じものを読みます。
- T パネル(DOM): 位置 / 環境 / 衣装 の 3 タブ。localStorage に保存
- VR パネル(uikit): 左コントローラーの X で開閉。開いたときの視線の 0.85m 先に置いて、以後は動かない。POSITION / ENV / OUTFIT / BREATH の 4 タブ
VR パネルの作りで、つまずいた点は、次のとおりです。
- ドラッグのスライダーは使わず、
−/+のボタンにしました(コントローラーのレイでは、細かい操作が難しいため) - uikit のフォントには日本語がありません。表示は英語にして、衣装は
labelから.vrmを除いた名前と、creator_enを出します - スクロールする縦の列の中で、行がつぶれて重なりました。行ごとに
flexShrink={0}を付けて直しました
<Container flexDirection="row" alignItems="center" justifyContent="space-between" flexShrink={0}>
10. 間違いの記録
- 倍率の決め方: ベッドの大きさから「2 単位 = 1m」と決めて、天井が目の高さに来ました。天井のメッシュの高さから決めるのが正解でした
- マットレスの高さ: 家具全体の外形から決めて、体が浮きました。光線で面を測りました
- モデルの前と腕の軸: VRM 0 のつもりで決め打ちして、逆向き・万歳になりました。読み込み時に自動判定します
- 暗さが足りなかった: 部屋側のライトだけを絞って、TOP 側のライトを触っていませんでした
- 起きても寝たままの向き:
applyPoseがrotation.yだけを書いて、x と z の傾きが残っていました - パジャマを選び直すと、普段着が一瞬出る: いったん普段着へ戻す作りでした。読み込みが終わってから、その場で入れ替えます
- React の key 重複:
models.jsonの 2 つのパジャマのidが同じでした
11. 制約
| 項目 | 内容 |
|---|---|
| 対象モデル | パジャマは内蔵モデルのみ。アップロードしたモデルは、普段着のまま |
| VR パネル | ファイルの追加・削除は不可(ファイルダイアログが出ない可能性があるため) |
| VR の表示 | 英語のみ |
| 寝息の mp3 | ループ再生。胸とは同期しない |
| 確認した環境 | PC のブラウザと Quest 3。パジャマは 3 種類 |
関連
実装メモは、docs/sleep-together.md にまとめています。