はじめに
前回 #23 では、MMD のランダム歩行・ランダム会話・VPDポーズを統合して「動く3D時計」へ進化。
[Next.js #23] Three.js×MMD:ランダム歩行・ランダム会話・VPDポーズで“動く3D時計”へ進化
Next.js×Three.jsで構築した3D時計に、MMD(PMX/VMD/VPD)のランダム歩行・ランダム会話・カメラ追従・ポーズ適用を統合。レスポンシブ床スケール、吹き出しCanvasスプライト、VMD停止制御、talk中のVPD適用など“キャラクターと …
https://humanxai.info/posts/nextjs-23-threejs-mmd-random-walk-talk-vpd/今回はそこに UI(DOM)レイヤーを追加し、モデル切替 / 描画設定(Shadow・Bloom)切替 / デバッグ(Inspector)を操作できるようにして、時計アプリから MMDモデル表示アプリへ一段進化。
MMDの実装方法は過去に何度か記事にしているのと、今回の実装はUI作成とDOM操作が主体で、それに関しても過去記事でかなりやってきた内容なので、軽く実装内容をふれる程度にします。
動画(Youtube):
3D時計UIを“作品”に変えるタイポグラフィ設計(Three.js×MMD)#short
Next.js×Three.js×MMDで作っている“動く3D時計”を、UIパネル実装でモデルビューア化。モデル切替(クリック即反映)/Shadow・Bloom切替/Inspectorトグル/モデル別セリフJSON読み込みまで。記事(#24)https://humanxai.info/posts/nextjs-2...
https://youtube.com/shorts/Wwx2Bhec4AE?feature=share動画(PC):
今回やったこと
-
上部ツールバー + 右パネル(
- frosted glass 風の半透明UI(backdrop-filter)
- パネル開閉、外側クリック/ESCで閉じる(DOMイベント)
-
Inspector(lil-gui)の表示/非表示をUIトグルで制御
- 「デバッグは黒のまま」方針で、位置だけUIと干渉しないよう調整
-
Shadow / Bloom をUIから切替できるように配線
- Shadowは renderer.shadowMap.enabled だけだと残像が残るケースがあるため、 影を落とすライト(keyLight)の castShadow を切る方式にして確実にOFFできるようにした
- Bloomは enabled が効かないケースを避けて、strength退避→0 の方式で確実にOFF
-
モデルチェンジ(クリックで即時切替)を実装
- public/models/ 配下の lain_v1~lain_v5 等をクリックで即時ロード
- 右パネルのリストから選択 → reloadMMD() で差し替え
-
キャラ別セリフを JSON で外出し
- モデルフォルダに共通ファイル名(例:dialog.json)を置き、 モデル切替と同時に読み込んで talk 内容を差し替え
- models/common/dialog.json などのフォールバックも用意して「無いモデルでも落ちない」運用
UI設計:ドロワーより“右上ボタン+右パネル”
なぜドロワーを避けたか(この画面固有の理由)
このアプリのUIは「左上:巨大な日時」が強い。 左からスライドするドロワーは、視線の起点(日時)と操作領域が衝突しやすく、体験として“主役を削る”方向に働く。
- 左上の巨大テキストは「情報」ではなく、作品の“看板”
- ドロワーは「情報構造の入口」なので、看板と同じ場所を奪い合う
- 結果、UIが増えるほど“時計アプリ感”が薄れる
右上ボタン列がハマった理由(ゲームUIの文脈)
右上のボタン列は、RTS / タワーディフェンス系の「ユーティリティ帯」に近い。 3Dシーンが主役で、UIは必要な時だけ介入する——この関係性が自然に作れる。
- 常時表示しても邪魔にならない(画面の空きやすい角)
- 押す→右に情報が出るが直感的(インベントリ/設定パネルの文法)
- 「操作UIが作品を支える」形になる(作品UIのヒエラルキーが崩れない)
“右パネル(aside)”にした設計的な利点
右パネルは「主役ではないが強い補助情報」を入れる器。
<aside> を使うのも、構造として自然。
- モデル切替、表示切替、統計、デバッグなど “補助機能の集約” ができる
- 右側は時計オブジェクトと重なるが、UIはCSSレイヤで前面に置けるので実害が少ない
- 将来的に “Info/Rig/Debug” を増やしても、パネルという器が受け止める
情報設計:4〜6ボタンに抑える(増やしすぎ対策)
ボタン列は便利だが増やすと破綻する。 上限を決めて「よく使うものだけ常時」がおすすめ。
- Model(モデル切替)
- Info(統計/HUD)
- Rig(ボーン/モーフ)
- Display(影/Bloom/ワイヤ等)
- Debug(開発用)
- Inspector(lil-guiは“黒い裏側”としてトグル)
※ Inspector を “本番UI” と混ぜないのがコツ。黒は黒の器。
レスポンシブの考え方(スマホにした時どうする)
右上ボタン列+右パネルは、スマホでも意外と崩れにくい。 ただし縦画面では「幅」が厳しいので、以下のどちらかに寄せる。
- ボタンはアイコンのみ(ラベルはツールチップ/長押し)
- 右パネルは「下からのボトムシート」に切り替え(同じ中身を再利用)
“ドロワーを採用しない”というより、画面サイズごとに最適な開き方を変えるのが正解。
結論:このアプリの主役を守るUI配置
この作品は「巨大な時刻」が主役で、キャラと時計は舞台装置。 だからUIは主役の周辺を侵食しない場所(右上)に寄せ、必要な時だけ情報を出す。
右上ボタン列 → 右パネル(aside)は、 “時計アプリ”を壊さずに“モデルビューア化”できる、最小の設計だった。
DOM操作
Three.js(やBabylon.js)を触っていると、どうしても思考が「毎フレーム更新」に寄る。
animate() の中で時間を進めて、姿勢を更新して、最後に描画する――この“エンジン思考”は3Dでは正しい。
一方でUI(DOM)の世界は、基本的に「必要な瞬間だけ更新する」。 今回のUI実装は、その差がはっきり出た。
エンジン思考 vs DOM思考
エンジン思考(毎フレーム)
- 状態は毎フレーム変化する(時間、姿勢、物理、アニメ)
- 更新は
deltaを使って積み上げる - 描画はループの最後に必ず走る(render/composer.render)
DOM思考(イベント駆動)
- 状態は“操作が起きた時”だけ変化する(ON/OFF、選択、設定)
- 更新は
change/clickのタイミングで1回反映すれば十分 - ループは不要(むしろ入れるとバグの温床になる)
UI実装は「一本道の配線」で終わる
UI側は次の3ステップだけで成立する。
- DOM要素を拾う
const cbShadows = document.querySelector("#displayShadows");
- イベントで状態を取る
cbShadows.addEventListener("change", () => {
const on = cbShadows.checked;
// …
});
- Three.jsのパラメータへ反映する
config.renderer.shadowMap.enabled = on;
config.keyLight.castShadow = on;
config.renderer.shadowMap.needsUpdate = true;
この「DOM → 状態 → 反映」が一直線なので、読みやすいし壊れにくい。 フレームループに混ぜないことで、ロジックの責務も明確になる。
“動いた”が増えると、アプリになる
Three.js側の実装はコード量が増えがちだが、UI側は「配線」が増えるだけで価値が上がる。
- Inspectorの表示/非表示(トグル)
- Shadow/BloomのON/OFF(チェック)
- モデル切替(ボタン)
いずれも「イベント駆動で一回反映」するだけで、アプリとしての操作感が一気に立ち上がる。
コツ:初期状態も同じ関数で反映する
UIは「イベント時」だけでなく「起動時」も同じロジックで揃えると、ズレが消える。
applyShadows(on)を作る- 起動時に
applyShadows(cb.checked)を1回呼ぶ - changeでも同じ
applyShadowsを呼ぶ
この型にしておくと、チェック項目が増えても破綻しない。
結論:JavaScriptの“本来の顔”に戻る
3Dエンジンは「毎フレーム」、DOMは「必要な時だけ」。 今回のShadow/Bloom/Inspectorの配線は、まさに イベント駆動のJavaScriptらしい実装で、UIを“作品の操作面”に変えられた。
Shadow切替:rendererだけでは影が残る問題と対策
ShadowのON/OFFは一見 renderer.shadowMap.enabled だけで済みそうに見える。
しかし実際には、これだけだと「影が消えない(残って見える)」ケースが出る。
なぜ renderer.shadowMap.enabled = false だけだと残るのか
shadowMap.enabled は「影用のレンダリングを止める」スイッチに近い。 つまり “次フレーム以降にシャドウマップを更新しない” のであって、 “直前に生成された影テクスチャを必ず消す” とは限らない。
結果として、
- OFFにしたのに床に影のパターンが残る
- うっすら残像のように見える
- ポストプロセス(Bloom等)を使っていると特に残りやすい
といった現象が起きる。
対策:影の生成元(ライト)も切る
このアプリの影は主に DirectionalLight(keyLight)が作っている。 そこで ライト側の castShadow を切ると、影の生成そのものが止まり、体感として確実に消える。
実装:configにkeyLightを保持して、3点セットで切替
ライト生成時に config.keyLight を保存しておく。
// createLight() 内
config.keyLight = key;
UI側では次の3つをセットで切り替える。
- renderer.shadowMap.enabled(影システム全体のON/OFF)
- keyLight.castShadow(影の生成元を止める)
- renderer.shadowMap.needsUpdate(更新を促し残像を潰す)
function applyShadows(on) {
config.renderer.shadowMap.enabled = on;
if (config.keyLight) config.keyLight.castShadow = on;
// 残像対策:影マップ更新を促す
config.renderer.shadowMap.needsUpdate = true;
}
cbShadows.addEventListener("change", () => {
applyShadows(cbShadows.checked);
});
補足:受ける側(receiveShadow)まで切る必要は?
多くの場合は 生成元(castShadow) を切るだけで十分。 もし床や壁に残り続けるように見える場合は、受け側の receiveShadow もOFFにする手がある。
- floor.receiveShadow = false
- floorTop.receiveShadow = false
ただし、これは「床の材質表現」も変わるので、最後の手段で良い。
まとめ
shadowMap.enabled は“更新停止”であって“消去保証”ではない。 確実に影のON/OFFを体感させるには、影を作るライト(keyLight)も一緒に切るのが正解。
モデル切替:reloadMMD()で差し替え
最初は pmxPath が固定で、起動時に lain_v5.pmx を読むだけだった。
今回のUI実装で「モデルビューア化」したかったので、選択したモデルをクリックで即時ロードできるように reloadMMD() を用意した。
方針:フォルダ規約でパスを組み立てる
ブラウザは public/models/ の中身を自動列挙できない(ディレクトリ一覧取得ができない)ので、 「モデルキー(lain_v1〜v5)」をUIから渡し、規約に従ってパスを生成する設計にした。
例(規約):
- /models/lain_v1/lain_v1.pmx
- /models/lain_v2/lain_v2.pmx
- /models/lain_v3/lain_v3.pmx
- /models/lain_v4/lain_v4.pmx
- /models/lain_v5/lain_v5.pmx
UI側は data-model="lain_v3" のようなキーだけ持てば良い。
UI側:ボタンの選択状態を管理して、reloadMMD() を呼ぶ
- 選択状態(is-selected)を付け替える
- モデルキーから pmxPath を組み立てる
reloadMMD(pmxPath)を呼ぶ
この流れが揃うと、クリック→即切替の気持ちよさが出る。
mmd.js 側:差し替えのための最低限の責務
reloadMMD() は大雑把に言うと次をやる。
- 既存モデルをシーンから外す
- MMDAnimationHelper から外す(可能なら)
- geometry/material を dispose してリークを防ぐ
- 新しいPMXをロードして scene に追加
- VMDがあれば再アタッチして walkAction を再生
ここで重要なのは「動くように差し替える」だけでなく、 切り替えを繰り返しても重くならない(解放ができている)こと。
“クリックで即切替”がアプリ感を作る
Three.jsやMMDの実装はどうしてもコード量が増えるが、 モデル切替が一瞬で通るようになると、体験が「デモ」から「アプリ」に変わる。
- キャラを選ぶ → 即反映される
- モデルごとの差が体感できる
- “ビューア”として成立する
この1機能で、時計アプリは「MMDモデル表示アプリ」へ進化。
セリフのJSON化:モデル別キャラクター性を持たせる
ランダム会話の仕組み自体は #23 で作ったが、セリフがコードにベタ書きだと
- キャラごとの差が作りにくい
- 文章を調整するたびにビルド/コミットが重い
- 「モデル=人格」の拡張性が弱い
という問題が出る。
そこで今回は、モデルフォルダに dialog.json を置く方式にして、モデル切替と同時にセリフセットを差し替えるようにした。
方針:モデルの隣に人格データを置く
モデルを差し替えるときに、同じキー(モデル名)で dialog.json を探す。
- models/lain_v1/dialog.json
- models/marisa/dialog.json
もしそのモデルに dialog.json が無ければ、共通の保険に落とす。
- models/common/dialog.json
この“フォルダ規約”にしておくと、モデルを追加する時は
- PMXを置く
- dialog.jsonを置く(任意)
だけでキャラクター性まで追加できる。
dialog.json の例
{
"messages": [
"ねむい…",
"もうこんな時間?",
"休憩しよう。",
"コーヒー淹れてきていい?"
],
"minDelay": 8,
"maxDelay": 20,
"baseDuration": 3.0,
"perChar": 0.05
}
- messages:セリフ候補
- minDelay/maxDelay:次に喋るまでの間隔(秒)
- baseDuration/perChar:表示時間(ベース+文字数補正)
※ 最初は messages だけでも十分。パラメータは後から増やせる。
実装:モデル切替後にfetchして差し替え
モデル切替が成功した後に fetch でJSONを読み、talkMessages を差し替える。
- モデル固有 → common → それでも無ければ既定のまま
という順で探すと、どのモデルでも落ちない。
これが効くポイント:キャラが“追加可能な資産”になる
コードに人格が埋まっている状態だと、キャラを増やすほどソースが汚れる。 JSON化すると、人格は モデルと同じレイヤのアセットになり、
- 文章調整が軽い(コードを触らない)
- キャラごとに口調が作れる
- 将来的に時間帯/イベント/季節で分岐も作れる
と、作品としての伸びしろが増える。
まとめ
dialog.json は「モデル=見た目」だけで終わらせず、 「モデル=キャラクター」にするための最小の仕組み。
モデル切替が一瞬でできるようになった今、 セリフのJSON化は“ビューア”から“マスコットアプリ”へ戻るための強い武器になった。
次にやりたいこと(候補)
-
Quick Statsを実データで更新
- Mesh/Vert/Tri/Mat/Tex/Bone/Morph をロードしたモデルから集計してHUD表示
-
Show Bones / Wireframe の実装
- Show Bones(SkeletonHelper)は特にモデルビューア感が上がる
- Wireframeは素材/シェーダによっては制約があるので“対応モデルだけ”でも良い
-
最後に選んだモデルを保存して次回復元(localStorage)
💬 コメント