[Next.js #23] Three.js×MMD:ランダム歩行・ランダム会話・VPDポーズで“動く3D時計”へ進化

はじめに

前回 #22 では、Three.js を Next.js プロジェクトに統合し、

  • ベベル付き 3Dアナログ時計
  • TextGeometry による 3DデジタルUI
  • onBeforeCompile を使った ノイズ背景シェーダ
  • lil-gui による露出・カラー・シェーダのライブ調整
  • MMD(PMX) のロードと 簡易歩行

まで実装。

今回はこの続きとして、MMDキャラクターを“単なるモデル”ではなく キャラクターとして振る舞わせるための振る舞いロジックを追加実装した。

具体的には以下の機能を新規に追加している:

  • ランダム歩行(XZ 平面) ─ 画面内の床サイズに応じて歩行範囲を自動調整し、適当な地点へ歩き回る。

  • ランダムメッセージ(頭上スプライト吹き出し) ─ CanvasTexture で描く軽量スプライト。3〜5秒表示して消える。

  • 会話中はカメラ方向を向く(クォータニオン補間)

  • 会話中だけ VPD ポーズを適用 ─ 例:talk_front.vpd

  • ポーズ中は VMD を停止 ─ mmdHelper.update() を会話中だけ止めることで、歩きモーションに上書きされないようにする。

  • トーク終了後は自然に歩きへ復帰 ─ VPD解除 → VMD再生 → ランダム歩行の続きへ。

動画(Youtube):

動画(PC):

1. 仕様:キャラクター挙動の全体像

この章では、今回追加した「MMDキャラクターの挙動」を、コード詳細に入る前の仕様レベルで整理しておく。


ランダム歩行

まずは「とりあえず画面内をうろうろしている NPC」を目標にした。

  • 画面下部に敷いた 床用 BoxGeometry(config.floor)を基準に、 minX / maxX / minZ / maxZ の歩行エリアを定義

  • resizeFloorMMD() の中で

    • カメラの FOV + Z 位置から「その深度での見える幅・高さ」を取得
    • 画面サイズに応じて床のスケールを変更
    • それに合わせて歩行エリア(min/max)も再計算 → レスポンシブな画面サイズでも、必ず“見えている床の中だけ”を歩く

ランダム歩行はシンプルで、

  1. walk.target にランダムな XZ 座標(床の内側)をセット
  2. 毎フレーム target - position で方向ベクトルを出して正規化
  3. speed * delta 分だけ前進(距離が足りなければ目標位置でクランプ)

というステアリングの最小構成になっている。

進行方向への向きは mmdMesh.lookAt() を使い、

const lookTarget = new THREE.Vector3(target.x, pos.y, target.z);
mmdMesh.lookAt(lookTarget);

と Y 高さだけ現在位置に合わせて lookAt することで、 不自然に首が傾いたりしないようにしている。

歩いているモーション自体は、あくまで VMD(walk.vmd) に任せており、 こちら側では「移動と向き」だけを制御する。


ランダム会話

キャラクターがただ歩いているだけだと「背景オブジェクト感」が強いので、 たまに話しかけてくるような 軽い会話イベントを入れた。

  • 一定時間ごとに自動発話

    • talkState.nextDelay をランダム秒数(例:8〜20 秒)で設定
    • 毎フレーム delta で減算し、0 を切ったらトーク開始
  • メッセージは単純な文字列配列からランダムに選択

const talkMessages = [
  "ねむい…",
  "もうこんな時間?",
  "今日は集中できてる?",
  "休憩しよう。",
  "コーヒー淹れてきていい?",
];

表示は Three.js のお馴染みのパターンで、

  1. canvas に 2D コンテキストで吹き出し UI を描く
  2. CanvasTextureSpriteMaterialSprite でメッシュ化
  3. sprite.position をキャラクター頭上に追従させる

吹き出しのデザインは、時計 UI に合わせて 黒背景+白枠+白文字 の シンプルなものにしている。


会話中の挙動制御

「しゃべっている間」は、単にテキストを出すだけではなく、 キャラクターの挙動そのものを会話モードに切り替える。

具体的には会話中フラグ talkState.isSpeaking を立てて、 以下を制御している:

  • 歩行停止

    • updateWalk() 冒頭で if (talkState.isSpeaking) return;
  • カメラ方向を向く(水平回転のみ)

    • faceCameraSmooth()

      • lookAt(カメラX, 自分のY, カメラZ) な回転行列を作成
      • 現在のクォータニオンから slerp で滑らかに補間
  • VMD アニメーションを pause

    • walkAction.paused = true;
  • talkFront.vpd を一度だけ適用予定

    • 基本方針:

      • 「会話開始時に VPD を 1 回だけ適用」
      • その後は mmdHelper.update() 停止でポーズ維持

最終的に一番効いたのは、

// ★ トーク中は Helper.update を止める
if (!talkState.isSpeaking) {
  mmdHelper.update(delta);
}

という 「会話中は VMD 更新そのものを止める」シンプルな制御だった。

VPD を mmdHelper.pose() で適用する場合、 mmdHelper.update() が動いていると VMD のポーズに上書きされ続けるため、 「VPD は効いているようで効かない」という状態になりやすい。 そこで、「しゃべっている間だけ MMDAnimationHelper 側の更新を止める」 という割り切りをしている。


会話終了後

会話が終わったタイミングでは、以下の復帰処理を行う:

  • スプライトを scene から削除し、Texture / Material を破棄
  • VPD で固めていたポーズを解除(必要に応じて T ポーズ経由で復帰)
  • 歩き VMD を reset()play() で復帰し、再びランダム歩行へ

イメージとしては、

  1. てくてくランダム歩行(VMD)
  2. ランダム会話トリガー → 歩行停止 & カメラを見る & ポーズ
  3. 数秒しゃべる
  4. 元の歩きモーションに戻って、またうろうろし始める

という 「生活リズムのような動き」 を実現している。

2. PMX / VMD / VPD のロード

今回の時計アプリでは、MMDまわりの構成を

  • PMX … モデル本体
  • VMD … 常時ループさせる「歩きモーション」
  • VPD … 特定イベント(会話中など)で一瞬だけ切り替える「ポーズ」

という役割分担にしている。

まずは MMDAnimationHelperMMDLoader、各種パスと VPD のキャッシュ用オブジェクトを用意する:

const mmdHelper = new MMDAnimationHelper({ afterglow: 0.0, physics: false });
export const mmdLoader = new MMDLoader();

const pmxPath = "/models/lain/lain.pmx";
const vmdPath = "/models/lain/lain.vmd";

const vpdFiles = {
  talkFront: "/models/lain/poses/talk_front.vpd",
};

const vpdPoses = {};

2.1 PMX + VMD のロード

PMX と VMD は、これまでどおり MMDLoaderMMDAnimationHelper の組み合わせでロードする。

  • PMX 読み込み → mmdMesh へ保持
  • VMD が存在する場合は mmdHelper.add() で登録
  • AnimationMixer から clipAction() を取り出して walkAction として保持 → あとで「会話中だけ pause / reset / play」するため

(ここは #22 の実装とほぼ同じなので、詳細は割愛)

2.2 VPD をまとめてプリロードする

ポーズ用の VPD は、後から何度も使い回す ので、 起動時にまとめて読み込んで vpdPoses にキャッシュしておく。

await Promise.all(
  Object.keys(vpdFiles).map(name =>
    new Promise((resolve, reject) => {
      mmdLoader.loadVPD(
        vpdFiles[name],
        false,        // useBonesAndMorphs(通常 false でOK)
        (vpd) => {
          vpdPoses[name] = vpd;  // キャッシュ
          resolve();
        },
        undefined,
        reject
      );
    })
  )
);

ポイントは:

  • Object.keys(vpdFiles) で「ポーズ名」の配列を作る
  • 各ポーズ名ごとに loadVPD() を呼び、読み込めたら vpdPoses[name] に格納
  • Promise.all(...) で全部読み込み終わるのを待つ

これで、vpdPoses.talkFront のようにして、 いつでも即座にポーズデータにアクセスできる状態になる。

2.3 ポーズ適用のヘルパー

VPD の適用は MMDAnimationHelper.pose() 経由で行う。 毎回同じ書き方をするのは面倒なので、名前で指定できる薄いラッパー関数を用意した。

function applyPose(
  name: string,
  opt: { resetPose?: boolean; ik?: boolean } = { resetPose: true, ik: true }
) {
  const vpd = vpdPoses[name];
  if (!vpd || !mmdMesh) return;

  mmdHelper.pose(mmdMesh, vpd, opt);
}
  • name: "talkFront" のようなキー文字列

  • resetPose:

    • true … 一度デフォルトポーズ(T ポーズなど)に戻してから適用
    • false … 現在のポーズの上に上書き(微妙な調整用)
  • ik:

    • IK を有効にするかどうか(足IKなどが効いているモデルは基本 true

実際の運用では、

  • 会話開始時に applyPose("talkFront", { resetPose: true, ik: true })
  • その直後から mmdHelper.update() を停止して VPD の結果を固定
  • 会話終了時に VPD を解除 → 歩き VMD を reset()play() で復帰

という流れにしている。

(VPD の適用と「Helper の更新停止」が噛み合っていないと、 せっかくのポーズが VMD に上書きされてしまうので、 そこは次章で挙動制御ロジックとセットで整理する。)

3. 会話中だけ MMDAnimationHelper を止める

今回いちばん効いているのはここ。

VPD でポーズを適用しても、次のフレームで MMDAnimationHelper.update() が VMD を進めてしまうため、その結果として

  • 一瞬だけポーズになる
  • すぐ歩きモーションに上書きされる

という現象が起きる。

なので発想を逆にして、

「しゃべっている間だけ Helper を止める」

という方式にした。

export function updateMMD(delta) {

  // ★ 会話中は VMD を進めない(VPD が保持される)
  if (!talkState.isSpeaking) {
    mmdHelper.update(delta);
  }

  updateWalk(delta);
  faceDirSmooth(delta);
  updateTalk(delta);
}

talkState.isSpeaking は、ランダムトーク処理側で

  • 吹き出し生成と同時に true
  • 表示タイマーが切れたら false

にしているだけの単純なフラグ。

ここで

  • 会話開始時

    • applyPose("talkFront", …) で VPD を一度適用
    • talkState.isSpeaking = truemmdHelper.update() が止まる
    • → VPD のポーズがそのまま維持される
  • 会話終了時

    • talkState.isSpeaking = falsemmdHelper.update() 再開
    • 歩き VMD を reset()play() して自然に歩行モーションへ復帰

という流れになる。

結果として、

  • 「歩く → 立ち止まってこっちを見る → 一言しゃべる → 何事もなかったように歩きに戻る」

という、“MMD らしい自然な切り替え”を、 わずか 1 行のガードで実現できた。

4. ランダム会話処理

キャラの「しゃべり」は、超シンプルなステートマシンで回している。

まずは、候補メッセージと状態を用意:

const talkMessages = [
  "ねむい…",
  "もうこんな時間?",
  "今日は集中できてる?",
  "休憩しよう。",
  "コーヒー淹れてきていい?",
];

const talkState = {
  sprite: null,                       // 吹き出し Sprite
  isSpeaking: false,                  // 会話中フラグ
  speakTimer: 0,                      // この会話の残り時間(秒)
  nextDelay: 8 + Math.random() * 12,  // 次にしゃべるまでの待ち時間
};

4.1 会話開始:VMD 止めて VPD + 吹き出し生成

一定時間ごとに startRandomTalk() を呼び出し、 その中で「歩き停止 → ポーズ適用 → 吹き出し生成」をまとめて行う:

function startRandomTalk() {
  if (!walkAction) return;        // VMD が再生できない状態なら何もしない

  walkAction.paused = true;       // ★ 歩き VMD を一時停止
  applyPose("talkFront");         // ★ トーク用 VPD を一度だけ適用

  const msg = talkMessages[(Math.random() * talkMessages.length) | 0];

  talkState.sprite = createTextSprite(msg);      // CanvasTexture → Sprite
  talkState.speakTimer = 3 + msg.length * 0.05;  // 文字数に応じて表示時間を少し延ばす

  talkState.isSpeaking = true;
  config.scene.add(talkState.sprite);
}

ポイントは:

  • 歩きモーションを paused = true で止める
  • VPD は「しゃべり開始時に一度だけ」適用する
  • 吹き出しは Canvas で描画 → CanvasTextureSprite として頭上に配置

あとは updateTalk(delta) の中で speakTimer をデクリメントしていくだけでよい。


4.2 会話終了:ポーズ解除 → 歩き VMD 再開

speakTimer が 0 以下になったら会話終了とみなし、 ポーズを解除して歩行アニメへ戻す:

if (talkState.speakTimer <= 0) {
  clearPose();          // ★ VPD ポーズ解除(Tポーズ経由で初期姿勢へ)

  walkAction.reset();   // ★ いったん頭からにして
  walkAction.paused = false;
  walkAction.play();    // ★ 再生再開(自然に歩きへ復帰)
}

実際の処理ではこれに加えて:

  • 吹き出し Sprite を scene から削除
  • talkState.sprite = null
  • talkState.isSpeaking = false
  • talkState.nextDelay = 8 + Math.random() * 12 で次回会話までのインターバルを再セット

といった「後片付け」と「次回の予約」も行っている。


このステートを updateTalk(delta) 内で

  • isSpeaking === false のとき → nextDelay をカウントダウンし、0 になったら startRandomTalk()
  • isSpeaking === true のとき → speakTimer を減らし、0 になったら終了処理

というシンプルな分岐で回しているだけで、

  • しばらく歩き回る
  • ふと立ち止まってこちらを見て一言
  • また何事もなかったかのように歩き出す

という、常駐系の「ゆるい NPC 感」が出せるようになった。

5. 吹き出しスプライト(CanvasTexture)

会話テキストは、<canvas> に直接描いてから CanvasTextureSprite にする、 いわゆる「なんちゃってビットマップフォント UI」にしている。

function createTextSprite(text) {
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");
  canvas.width = 750;
  canvas.height = 170;

  // 背景
  roundRect(ctx, 10, 10, canvas.width - 20, canvas.height - 20, 20);
  ctx.fillStyle = "rgba(0,0,0,0.65)";
  ctx.fill();
  ctx.strokeStyle = "rgba(255,255,255,0.6)";
  ctx.stroke();

  // 文字
  ctx.fillStyle = "#fff";
  ctx.font = "26px sans-serif";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillText(text, canvas.width / 2, canvas.height / 2);

  const texture = new THREE.CanvasTexture(canvas);
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true }));
  sprite.scale.set(5.4, 1.5, 1);

  return sprite;
}

ポイントを整理すると:

  • Canvas をオフスクリーンで生成

    • 解像度は 750 x 170 に固定
    • 背景を一度 roundRect で描いてから、黒半透明+白枠で塗り/ストローク
  • 文字は中央寄せ

    • textAlign = "center", textBaseline = "middle" を使って 1 行ものなら単純に canvas の中央へ描画すればよい
  • Sprite 化

    • CanvasTextureSpriteMaterialSprite という素直な流れ
    • sprite.scale.set(5.4, 1.5, 1) でワールド座標系での見た目サイズを調整 (ここはシーンのスケール感に合わせて調整する)

あとは、updateTalkSpritePosition() 側で

sprite.position.copy(mmdMesh.position);
sprite.position.y += 5.0; // 頭上あたりまで持ち上げる

のように毎フレーム追従させてやれば、 キャラクターの頭上に「UI と統一感のある吹き出し」が自然に乗る。

UI 側のメッセージボックス(黒背景+白枠)と同じトーンにしているので、 時計アプリ全体がひとつの UI デザインポリシーでまとまる、という副作用もある。

6. 歩行範囲の自動調整(レスポンシブ対応)

#22 で実装した「床ジオメトリのレスポンシブ対応」を、そのまま MMD の歩行範囲 にも流用している。

やっていることはシンプルで:

  1. カメラから見た「床の見かけの幅」を求める
  2. それに合わせて BoxGeometry(20, 0.6, 6) を X 方向にスケール
  3. スケール後の実寸から、歩いてよい minX / maxX / minZ / maxZ を自動計算

コード側ではこんな感じ:

const { width: viewW, height: viewH } = getViewSizeAtWorldZ(camera, floorZ);

// 元ジオメトリの幅(BoxGeometry の X サイズ)
const BASE_W = 20;
const targetW = viewW * 0.9;          // 画面幅の 90% くらいまで床を広げる
const sx = targetW / BASE_W;          // X スケール

// スケール後の半幅
const halfW = (BASE_W * sx) / 2;

// 奥行き方向も同様
const BASE_D = 6;
const sz = floor.scale.z || 1;
const halfD = (BASE_D * sz) / 2;

// 歩行可能範囲(床の内側に少しマージンを取る)
walk.minX = -halfW + 1.0;
walk.maxX =  halfW - 1.0;

walk.minZ = floorZ - halfD + 0.5;
walk.maxZ = floorZ + halfD - 0.5;

ポイントは以下の通り:

  • getViewSizeAtWorldZ(camera, floorZ)

    • 「カメラから見たとき、Z = floorZ の平面上でどれくらいの幅・高さが画面に映るか」を返すユーティリティ
    • これを基準にすれば、ウィンドウサイズやアスペクト比が変わっても、床が“画面のどのくらいを占めるか”を一定に保てる
  • sx = (viewW * 0.9) / BASE_W

    • 床は画面幅より少しだけ短く(90%)しておくことで、端っこに若干の余白を残している
    • この sxfloor.scale.x として使うことで、Three.js の画面サイズに追従
  • walk.minX / maxX / minZ / maxZ

    • スケール後の床サイズから “内側に 1.0 / 0.5 ユニットだけマージンを取った矩形” を歩行可能領域として定義している
    • これにより、キャラクターが床のエッジぎりぎりに立たず、多少の安心感(=落ちない感)が出る

この仕組みのおかげで、

  • PC の横長ウィンドウ
  • スマホの縦長レイアウト
  • ブラウザリサイズ中の中途半端な比率

どのパターンでも、MMDキャラクターは常に「画面下の床エリアの中だけを、自然にランダム歩行する」 ようになる。

Three.js の「レスポンシブ対応」を ジオメトリのスケールだけで止めず、 その上を歩く AI キャラクターの行動制御にも直結させた のがこの章のポイント。

7. 最終的に得られた挙動

最終的に、この章で追加した要素によって、時計画面は次のような挙動になった。

  • 時計の前を 自然に歩き回る MMDキャラクター

  • 8〜20秒おきにランダム発話

  • 発話中は

    • カメラ方向を向き直り
    • VPDベースのアイドルポーズを取る
  • 吹き出しが消えると、そのまま歩きVMDに復帰

  • 背景・時計UIとデザインを揃えた 黒地+白枠の吹き出し

  • Three.js のビューサイズに追従する 完全レスポンシブな歩行エリア

ここまで来ると、このプロジェクトはもはや「WEB時計」というより、 “3Dデスクトップマスコット付きのミニアプリ” に近い存在感になってきた。

まとめ

今回のアップデートで、 「時計+UI を眺めるページ」から、 キャラクターが“そこにいる”ページ へ一段階進化した。

  • Three.js(シーン構築・レスポンシブ)
  • MMD(PMX / VMD / VPD)
  • Canvas2D(吹き出しUI)
  • GUI(lil-gui調整)
  • ノイズシェーダ(背景・床)
  • レイアウト連動の歩行エリア(レスポンシブ対応)

これらを組み合わせることで、

実用(時計・情報表示) × エンタメ(マスコット挙動)

というハイブリッドな“WEB時計”になっている。

次回は、さらにキャラクター性を強化する方向で、

  • ポーズ種類の追加(座り/手を振る/伸びをする など)
  • 時間帯に応じたモーション切り替え(朝/夜/深夜)
  • メッセージを JSON / 外部ファイル化して差し替え可能に
  • 日付イベント(休日・誕生日・特定日)で専用ポーズ/セリフ
  • レンダリング基盤の WebGL2 → WebGPU への移行検討

あたりを進めていく予定。