[Astro] #201 VRM と一緒に寝る添い寝モード(部屋・暗転・パジャマ・撫でる・寝息の合成・VR 設定パネル)

[Astro] #201 VRM と一緒に寝る添い寝モード(部屋・暗転・パジャマ・撫でる・寝息の合成・VR 設定パネル)

はじめに

TOP ページに、マスコットと一緒に寝る「添い寝モード」を足しました。ターミナルの sleep、または VR メニューの sleep で入ります。

  • 部屋(GLB)に入って、ベッドの横にいるマスコットに話しかけると、暗くなって寝る
  • 寝る前から、マスコットはパジャマを着ている(複数から選べる。既定はランダム)
  • 寝ている間、胸が上下し、その動きに合わせた寝息が鳴る
  • 頭を撫でると、笑顔になる(PC はドラッグ、VR は素手・コントローラー)
  • VR では、本当に横になっても視点が枕の高さに戻る
  • 設定は、PC は T キーのパネル、VR は左コントローラーの X で開くパネル

横向きの寝姿・寝返りと、ポーズファイルの変換は #202 に分けています。この記事は、土台の部分です。

使ったバージョンは、次のとおりです。

項目バージョン
three0.183
@pixiv/three-vrm3.5.5
@react-three/fiber9.x
@react-three/xr6.x
@react-three/uikitVR パネル用

スクリーンショット

VRM と一緒に寝る添い寝モード VRM と一緒に寝る添い寝モード VRM と一緒に寝る添い寝モード

設定UI

VRM と一緒に寝る添い寝モード - 設定UI VRM と一緒に寝る添い寝モード - 設定UI VRM と一緒に寝る添い寝モード - 設定UI

1. 構成

モードの本体は SLEEP/SleepManager.tsx です。入口(WiredScene / WiredTerminal / WiredVRMenu)には、モードを切り替える数行だけを足しています。

ファイル役割
SleepManager.tsx部屋・マスコット・歩く・寝る・撫でる・VR の位置合わせ
SleepSettings.ts設定の定義(範囲・既定値・保存・換算)。DOM と VR のパネルで共有
SleepVRPanel.tsxVR の設定パネル(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.8Q
こもり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 にまとめています。

  • 横向きの寝姿・寝返り・ポーズ変換: #202
  • VR で素手が触れる実装: #200