はじめに
前回 #22 では、Three.js を Next.js プロジェクトに統合し、
- ベベル付き 3Dアナログ時計
- TextGeometry による 3DデジタルUI
onBeforeCompileを使った ノイズ背景シェーダ- lil-gui による露出・カラー・シェーダのライブ調整
- MMD(PMX) のロードと 簡易歩行
まで実装。
[Next.js #22] Three.jsで作る理想のWEB時計:3Dアナログ×3Dデジタル×ノイズ背景×lil-gui調整
Next.jsプロジェクトのpublic/index.htmlで孤立起動するThree.js時計を実装。ベベル付きジオメトリでアナログ時計、TextGeometryで3DデジタルUI、onBeforeCompileで壁にノイズ注 …
https://humanxai.info/posts/nextjs-22-threejs-3d-clock-ui-noise-gui/今回はこの続きとして、MMDキャラクターを“単なるモデル”ではなく キャラクターとして振る舞わせるための振る舞いロジックを追加実装した。
具体的には以下の機能を新規に追加している:
-
ランダム歩行(XZ 平面) ─ 画面内の床サイズに応じて歩行範囲を自動調整し、適当な地点へ歩き回る。
-
ランダムメッセージ(頭上スプライト吹き出し) ─ CanvasTexture で描く軽量スプライト。3〜5秒表示して消える。
-
会話中はカメラ方向を向く(クォータニオン補間)
-
会話中だけ VPD ポーズを適用 ─ 例:
talk_front.vpd -
ポーズ中は VMD を停止 ─
mmdHelper.update()を会話中だけ止めることで、歩きモーションに上書きされないようにする。 -
トーク終了後は自然に歩きへ復帰 ─ VPD解除 → VMD再生 → ランダム歩行の続きへ。
動画(Youtube):
Three.js×MMD:ランダム歩行・ランダム会話・VPDポーズで“動く3D時計”へ進化#shorts
Next.js × Three.js で作っている 3D WEB時計の続き。https://www.youtube.com/shorts/2xfAGolH5mE・MMD(PMX)キャラのランダム歩行・一定時間ごとのランダム会話(CanvasTextureの吹き出し)・会話中だけカメラの方を向く・VPDポーズ+VM...
https://www.youtube.com/shorts/e8WKo2stn_8動画(PC):
1. 仕様:キャラクター挙動の全体像
この章では、今回追加した「MMDキャラクターの挙動」を、コード詳細に入る前の仕様レベルで整理しておく。
ランダム歩行
まずは「とりあえず画面内をうろうろしている NPC」を目標にした。
-
画面下部に敷いた 床用 BoxGeometry(
config.floor)を基準に、minX / maxX / minZ / maxZの歩行エリアを定義 -
resizeFloorMMD()の中で- カメラの FOV + Z 位置から「その深度での見える幅・高さ」を取得
- 画面サイズに応じて床のスケールを変更
- それに合わせて歩行エリア(min/max)も再計算 → レスポンシブな画面サイズでも、必ず“見えている床の中だけ”を歩く
ランダム歩行はシンプルで、
walk.targetにランダムな XZ 座標(床の内側)をセット- 毎フレーム
target - positionで方向ベクトルを出して正規化 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 のお馴染みのパターンで、
canvasに 2D コンテキストで吹き出し UI を描くCanvasTexture→SpriteMaterial→Spriteでメッシュ化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()で復帰し、再びランダム歩行へ
イメージとしては、
- てくてくランダム歩行(VMD)
- ランダム会話トリガー → 歩行停止 & カメラを見る & ポーズ
- 数秒しゃべる
- 元の歩きモーションに戻って、またうろうろし始める
という 「生活リズムのような動き」 を実現している。
2. PMX / VMD / VPD のロード
今回の時計アプリでは、MMDまわりの構成を
- PMX … モデル本体
- VMD … 常時ループさせる「歩きモーション」
- VPD … 特定イベント(会話中など)で一瞬だけ切り替える「ポーズ」
という役割分担にしている。
まずは MMDAnimationHelper と MMDLoader、各種パスと 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 は、これまでどおり MMDLoader と MMDAnimationHelper の組み合わせでロードする。
- 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)
- IK を有効にするかどうか(足IKなどが効いているモデルは基本
実際の運用では、
- 会話開始時に
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 = true→mmdHelper.update()が止まる- → VPD のポーズがそのまま維持される
-
会話終了時
talkState.isSpeaking = false→mmdHelper.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で描画 →CanvasTexture→Spriteとして頭上に配置
あとは 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 = nulltalkState.isSpeaking = falsetalkState.nextDelay = 8 + Math.random() * 12で次回会話までのインターバルを再セット
といった「後片付け」と「次回の予約」も行っている。
このステートを updateTalk(delta) 内で
isSpeaking === falseのとき →nextDelayをカウントダウンし、0 になったらstartRandomTalk()isSpeaking === trueのとき →speakTimerを減らし、0 になったら終了処理
というシンプルな分岐で回しているだけで、
- しばらく歩き回る
- ふと立ち止まってこちらを見て一言
- また何事もなかったかのように歩き出す
という、常駐系の「ゆるい NPC 感」が出せるようになった。
5. 吹き出しスプライト(CanvasTexture)
会話テキストは、<canvas> に直接描いてから CanvasTexture → Sprite にする、
いわゆる「なんちゃってビットマップフォント 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 化
CanvasTexture→SpriteMaterial→Spriteという素直な流れ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 の歩行範囲 にも流用している。
やっていることはシンプルで:
- カメラから見た「床の見かけの幅」を求める
- それに合わせて
BoxGeometry(20, 0.6, 6)を X 方向にスケール - スケール後の実寸から、歩いてよい
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%)しておくことで、端っこに若干の余白を残している
- この
sxをfloor.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 への移行検討
あたりを進めていく予定。
💬 コメント