[Next.js #24] 3D時計UIを“作品”に変えるタイポグラフィ設計(Three.js×MMD)

はじめに

前回 #23 では、MMD のランダム歩行・ランダム会話・VPDポーズを統合して「動く3D時計」へ進化。

今回はそこに UI(DOM)レイヤーを追加し、モデル切替 / 描画設定(Shadow・Bloom)切替 / デバッグ(Inspector)を操作できるようにして、時計アプリから MMDモデル表示アプリへ一段進化。

MMDの実装方法は過去に何度か記事にしているのと、今回の実装はUI作成とDOM操作が主体で、それに関しても過去記事でかなりやってきた内容なので、軽く実装内容をふれる程度にします。

動画(Youtube):

動画(PC):

今回やったこと

  • 上部ツールバー + 右パネル(

  • 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ステップだけで成立する。

  1. DOM要素を拾う
const cbShadows = document.querySelector("#displayShadows");
  1. イベントで状態を取る
cbShadows.addEventListener("change", () => {
  const on = cbShadows.checked;
  // …
});
  1. 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() は大雑把に言うと次をやる。

  1. 既存モデルをシーンから外す
  2. MMDAnimationHelper から外す(可能なら)
  3. geometry/material を dispose してリークを防ぐ
  4. 新しいPMXをロードして scene に追加
  5. 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)