[Next.js #25] Three.js×MMD:モデル情報スナップショット→UI反映→デバッグ機能まで一気に固める

はじめに

前回、UIのモックを実装して、一部を機能するように作りこみ、MMDモデルの切り替え実装までやりました。

今回は、サンプル情報としてしか、表示してなかったUI情報を、正しいモデル情報などに置き換える実装と、残りのUIの実装をしていきます。

具体的な内容としては

  • MMDロード時に モデル内部情報をスナップショット化して保持
  • UIに Rig / Info パネルを追加し、ボーン/モーフを検索表示
  • デバッグ用に ボーンマーカー追従と スクショ保存(preserveDrawingBuffer)を実装

など。

余談ですが、過去記事の中でも、少し前のトレンドワードでもある、SaaSの死を取り上げて対話ログを書きましたが、

Three.jsや、Babylon.js、R3Fなど、3D実装ばかりやっていて、 HTML、CSS、DOM操作という、WEBのフロントエンド処理を全くと言っていい程やってなかったのですが、今回実装内容を見て分かる通り、昨日からUIの実装をして久しぶりに、WEB制作らしいことをやってます。

私が、CSSなどのフロントエンド系の記事を書いてたのは、カードゲームを実装した去年の夏頃。

当時のAIはまだ、CSSやDOMの実装が不完全なところがあり、それで仕方なく、勉強をする過程で記事を書いてましたが、今はもう、そういう必要がなくなってますね…。

AIの進化の速度がほんと凄まじいです。

動画(YouTube):

動画(PC):

1. MMDロード時に「モデル情報スナップショット」を生成する

この時計アプリは UI(DOM) と 3D(Three.js + MMD) が同居していて、モデル切替もある。 UI側から mmdMesh を直接触り始めると、依存が絡まって崩れやすい。

そこで、MMDロード完了時に モデルの内部情報を1回だけ抽出して“スナップショット化”し、UIにはそのスナップショットだけ渡す。


目的

  • UI側から mmdMesh を直接参照しない(疎結合)
  • reload(モデル切替)でも同じ仕組みで更新できる
  • 後で「Rig」「Info」「Debug」などのUIを増やしても、情報源は一箇所で済む

設計方針

  • MMD側(mmd.js)

    • mmdInfo を持つ(モデル情報の唯一のソース)
    • ロード/リロード完了時に mmdInfo を更新
    • UIには CustomEvent(“mmd:info”) で通知する(pullでもpushでもいける)
  • UI側(ui.js)

    • window.addEventListener(“mmd:info”, …) で受け取って表示するだけ
    • mmdMesh / skeleton に触れない

この分離が効くと、UI実装が「DOM更新」だけになり速度が出る。


実装

1) mmdInfo と getter(任意)

// mmd.js
let mmdInfo = null;

export function getMMDInfo() {
  return mmdInfo;
}

UI側は基本イベントで受け取るが、Debugの「Copy JSON」などで pull したい場合に便利。


2) UIへ通知する emitMMDInfo()

function emitMMDInfo(info) {
  window.dispatchEvent(new CustomEvent("mmd:info", { detail: info }));
}

ポイント:

  • detail に “スナップショットそのもの” を入れる
  • UIは detail を読むだけで良い

3) スナップショット生成 buildMMDInfo(mesh)

モデルの内部構造は直接UIに渡すと重いし依存が濃くなるので、必要な情報だけ抽出する。

今回の最小セット:

  • counts(meshes / verts / tris / bones / morphs / materials)
  • bones[](name / parent / index)
  • morphs[](名前一覧)
  • materials[](name / type / transparent / skinning)
function buildMMDInfo(mesh, { pmxPath, vmdPath } = {}) {
  if (!mesh) return null;

  const skinned = [];
  const meshes = [];
  mesh.traverse((o) => {
    if (o.isSkinnedMesh) skinned.push(o);
    if (o.isMesh) meshes.push(o);
  });

  // bones(代表の skeleton から抽出)
  let bones = [];
  if (skinned[0]?.skeleton?.bones) {
    const b = skinned[0].skeleton.bones;
    bones = b.map((bone, i) => ({
      index: i,
      name: bone.name || `(bone_${i})`,
      parent: bone.parent?.isBone ? bone.parent.name : null,
    }));
  }

  // morphs(dictionary優先)
  const morphs = [];
  for (const sm of skinned) {
    if (sm.morphTargetDictionary) {
      for (const k of Object.keys(sm.morphTargetDictionary)) morphs.push(k);
      break;
    }
  }

  // materials(ユニーク化)
  const materialSet = new Map();
  for (const m of meshes) {
    const mats = Array.isArray(m.material) ? m.material : [m.material];
    for (const mat of mats) {
      if (!mat) continue;
      if (!materialSet.has(mat.uuid)) {
        materialSet.set(mat.uuid, {
          uuid: mat.uuid,
          name: mat.name || "",
          type: mat.type,
          transparent: !!mat.transparent,
          skinning: !!mat.skinning,
        });
      }
    }
  }

  // geometry stats
  let vertexCount = 0;
  let triangleCount = 0;
  for (const m of meshes) {
    const g = m.geometry;
    if (!g) continue;

    const pos = g.attributes?.position;
    if (pos) vertexCount += pos.count;

    const idx = g.index;
    if (idx) triangleCount += Math.floor(idx.count / 3);
    else if (pos) triangleCount += Math.floor(pos.count / 3);
  }

  return {
    loadedAt: Date.now(),
    pmxPath: pmxPath ?? null,
    vmdPath: vmdPath ?? null,

    root: { name: mesh.name || "", uuid: mesh.uuid },

    counts: {
      skinnedMeshes: skinned.length,
      meshes: meshes.length,
      bones: bones.length,
      morphs: morphs.length,
      materials: materialSet.size,
      vertexCount,
      triangleCount,
    },

    bones,
    morphs,
    materials: [...materialSet.values()],
  };
}

実装上のポイント:

  • mesh.traverse を 1回で必要情報を拾う
  • bones/morphs/materials は UIで扱いやすい形に正規化して返す
  • rawな Bone や Material の実体参照は渡さない(UIがThreeに依存し始める)

4) createMMD() / reloadMMD() のロード完了後に更新&通知

モデルロードの最後にこれを入れるだけで、UIが自動的に更新される。

mmdInfo = buildMMDInfo(mesh, { pmxPath: currentPmxPath, vmdPath });
emitMMDInfo(mmdInfo);

載せ方の例(createMMDの末尾付近):

// mmd.js: createMMD()
mmdInfo = buildMMDInfo(mesh, { pmxPath: currentPmxPath, vmdPath });
emitMMDInfo(mmdInfo);
return mesh;

reloadMMDでも同様。


この方式のメリット(後で効く)

  • UIは mmd:info を受け取るだけなので、Rig/Info/Debugが増えてもUI側が壊れにくい
  • reloadしても同じイベントが飛ぶので、モデル切替の反映が自然
  • Debugで Copy mmdInfo JSON を実装するときも、情報源が確定している

2. UI側:mmd:info を受けて HUD/Stats をリアルデータに置換

MMD側で mmdInfo を生成して mmd:info を投げられるようになったら、UI側は 受け取って表示するだけになる。 ここでやることは「モック文字列を消して、実データに差し替える」こと。


目的

  • それっぽく固定表示していた HUD/Stats を、実データで上書き
  • モデル切替(reload)でも、イベントが飛ぶたびに自動更新される

実装ポイント

1) lastInfo を保持する

Rig/Info/Debug など、パネル描画時に「最新の情報」が必要になるので、UI側で最後に受け取った mmdInfo を保持しておく。

let lastInfo = null;

window.addEventListener("mmd:info", (e) => {
  lastInfo = e.detail;
  // ここでHUD/Statsも更新する
});

2) HUD/Stats の DOM を掴む(id を付ける)

index.html の HUD/Stats 側に id を付与しておくと、UI更新が簡単になる。

例(HUD):

<span class="hud__text" id="hudModelText">-</span>
<span class="hud__text" id="hudRigText">-</span>

例(Quick Stats):

<div class="stat__v" id="statMeshes">-</div>
<div class="stat__v" id="statVerts">-</div>
<div class="stat__v" id="statTris">-</div>
<div class="stat__v" id="statBones">-</div>

UI側では一度だけ querySelector して参照を保持する。

const hudModelText = document.querySelector("#hudModelText");
const hudRigText   = document.querySelector("#hudRigText");

const statMeshes = document.querySelector("#statMeshes");
const statVerts  = document.querySelector("#statVerts");
const statTris   = document.querySelector("#statTris");
const statBones  = document.querySelector("#statBones");

3) 数値の見やすい表記(fmtK)

頂点数・三角形数はそのままだと桁が長いので、HUD用途は 66k のように丸める。

function fmtK(n) {
  if (!Number.isFinite(n)) return "-";
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
  if (n >= 1_000) return Math.round(n / 1_000) + "k";
  return String(n);
}

実装(mmd:info 受信 → HUD/Stats更新)

window.addEventListener("mmd:info", (e) => {
  const info = e.detail;
  lastInfo = info;
  if (!info?.counts) return;

  const c = info.counts;

  // HUD(左上)
  if (hudModelText) {
    hudModelText.textContent =
      `Mesh ${c.meshes} · Vert ${fmtK(c.vertexCount)} · Tri ${fmtK(c.triangleCount)}`;
  }
  if (hudRigText) {
    hudRigText.textContent =
      `Bone ${c.bones} · Morph ${c.morphs} · Mat ${c.materials}`;
  }

  // Quick Stats(Modelパネル内)
  if (statMeshes) statMeshes.textContent = c.meshes ?? "-";
  if (statVerts)  statVerts.textContent  = fmtK(c.vertexCount);
  if (statTris)   statTris.textContent   = fmtK(c.triangleCount);
  if (statBones)  statBones.textContent  = c.bones ?? "-";
});

ポイント:

  • info?.counts が無い場合はスキップ(未ロード時の安全策)
  • HUDは 一行で要点だけ(Mesh/Vert/Tri、Bone/Morph/Mat)
  • Quick Statsは パネル内でじっくり見る用(値だけ表示)

これで得られる効果

  • モデル切替しても mmd:info が飛ぶたびに HUD/Stats が更新され、表示が常に正しい
  • UI側が mmdMesh を触っていないので、Three.js/MMD側の変更に引きずられない
  • 次の Rig/Info/Debug 実装の前提(lastInfo)が揃う

3. Rigパネル:ボーン/モーフ一覧+検索(template方式)

Rigパネルは「見た目を作る」より 調査導線を作るのが目的。 MMDモデルはボーン数・モーフ数が多く、名前も独特なので、UIで検索できるだけで作業効率が跳ねる。

ここでは HTMLテンプレ(固定UI)+JS描画(可変データ)に分けて、保守しやすい構造にする。


目的

  • ボーン / モーフを UIで一覧確認できるようにする
  • 検索(フィルタ)で欲しい名前にすぐ到達できるようにする
  • 数が多いので 表示上限(slice 300)で重くならないようにする

実装方針(重要)

  • 固定UI(Stats / Search / List枠)は <template> に置く → デザイン変更はHTML/CSS側で完結できる
  • 可変(bones/morphs のリスト項目)は JS で生成 → 検索・切替で中身だけ更新できる

3.1 index.html:tplRigPanel を追加

ツールバーやパネルの近く(tplInfoPanel と同じ場所)に置けばOK。

<template id="tplRigPanel">
  <div class="panel__section">
    <div class="section__title">Rig Stats</div>
    <div class="stats">
      <div class="stat">
        <div class="stat__k">Bones</div>
        <div class="stat__v" data-bind="bones">-</div>
      </div>
      <div class="stat">
        <div class="stat__k">Morphs</div>
        <div class="stat__v" data-bind="morphs">-</div>
      </div>
    </div>
  </div>

  <div class="panel__section">
    <div class="section__title">Search</div>
    <input class="input" data-bind="search" placeholder="bone / morph name…" />
    <div class="grid" style="margin-top: 8px">
      <label class="check"><input type="radio" name="rigMode" value="bones" checked /> Bones</label>
      <label class="check"><input type="radio" name="rigMode" value="morphs" /> Morphs</label>
    </div>
  </div>

  <div class="panel__section">
    <div class="section__title" data-bind="listTitle">Bones</div>
    <div class="list" data-bind="list"></div>
  </div>
</template>

ポイント:

  • data-bind="…" で JS 側が要素を拾えるようにする
  • list は空のまま(JSが中身を入れる)

3.2 ui.js:Rig表示関数 renderRigPanel(lastInfo)

1) エスケープ(安全&崩れ防止)

ボーン名は外部データなので、innerHTML に入れる場合はHTMLエスケープする。

function escapeHtml(s) {
  return String(s).replace(/[&<>"']/g, (c) => ({
    "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
  }[c]));
}

2) template を clone → bind を拾う

function renderRigPanel(info) {
  const body = panel?.querySelector(".panel__body");
  if (!body) return;

  body.innerHTML = "";

  const tpl = document.querySelector("#tplRigPanel");
  if (!tpl) {
    body.textContent = "(tplRigPanel not found)";
    return;
  }

  body.appendChild(tpl.content.cloneNode(true));

  const elBones   = body.querySelector('[data-bind="bones"]');
  const elMorphs  = body.querySelector('[data-bind="morphs"]');
  const input     = body.querySelector('[data-bind="search"]');
  const list      = body.querySelector('[data-bind="list"]');
  const listTitle = body.querySelector('[data-bind="listTitle"]');
  const radios    = [...body.querySelectorAll('input[name="rigMode"]')];

  if (!info?.counts) {
    if (list) list.innerHTML = `<div style="opacity:.7;">(no model loaded)</div>`;
    return;
  }

  const bones = info.bones ?? [];
  const morphs = info.morphs ?? [];
  const c = info.counts;

  if (elBones)  elBones.textContent  = c.bones ?? bones.length;
  if (elMorphs) elMorphs.textContent = c.morphs ?? morphs.length;

  // 以降:検索と表示更新
}

3.3 検索と表示更新(bones/morphs切替)

let mode = "bones";
let q = "";

function update() {
  if (!list) return;
  const query = q.trim().toLowerCase();

  if (mode === "bones") {
    if (listTitle) listTitle.textContent = `Bones (${bones.length})`;

    const items = bones
      .filter((b) => !query || (b.name ?? "").toLowerCase().includes(query))
      .slice(0, 300); // ★ 上限で軽量化

    list.innerHTML = items.map((b) => `
      <button class="list__item" data-bone="${escapeHtml(b.name)}">
        ${escapeHtml(b.name)} <span style="opacity:.6;">#${b.index}</span>
      </button>
    `).join("") || `<div style="opacity:.7;">no match</div>`;

  } else {
    if (listTitle) listTitle.textContent = `Morphs (${morphs.length})`;

    const items = morphs
      .filter((name) => !query || String(name).toLowerCase().includes(query))
      .slice(0, 300);

    list.innerHTML = items.map((name) => `
      <button class="list__item" data-morph="${escapeHtml(name)}">
        ${escapeHtml(name)}
      </button>
    `).join("") || `<div style="opacity:.7;">no match</div>`;
  }
}

input?.addEventListener("input", () => {
  q = input.value;
  update();
});

radios.forEach((r) => r.addEventListener("change", () => {
  mode = radios.find((x) => x.checked)?.value ?? "bones";
  update();
}));

update();

ポイント:

  • slice(0, 300) を入れるだけで、巨大モデルでもUIが粘りにくい
  • update() で list だけ差し替えるので、検索が軽い

3.4 どこで呼ぶ?

ツールバーの Rig ボタンで呼ぶ。

if (key === "rig") renderRigPanel(lastInfo);

lastInfo は #2 で mmd:info を受けたときに更新されるので、「最新情報で表示」が保証される。


スクショを貼るなら(見せ場)

Rigパネルは 成果が見た目で伝わるので、記事ではここにスクショを置くと強い:

  • ボーン一覧がズラッと出る
  • 検索で絞れる
  • モーフ切替できる

「UIで調べられるようになった」感が一瞬で伝わる。

4. 選択ボーンのハイライト(3Dマーカー追従)

Rigパネルでボーン名を見ても、「実際にモデルのどこ?」が分からないと調査が止まる。 そこで 選択したボーン位置にマーカー(小さい球)を表示して追従させる。

これは“作品の機能”というより デバッグ能力をUIに組み込む実装で、モデル差・データ差の調査に強い。


目的

  • Rigで選択した骨が モデルのどこか一瞬で分かる
  • アニメーション中でも追従するので、動いてる部位の確認ができる
  • モデル切替時も同じ仕組みで復元できる(同名ボーンがあれば)

実装の考え方

UIから Bone 実体を触らせない。 mmd.js 側に 「選択ボーン名を渡すAPI」を生やし、内部で Bone を引いて追従させる。

  • UI:setSelectedBone(“Head”) を呼ぶだけ
  • mmd:name→Bone を解決、ワールド座標にマーカーを置く

4.1 mmd.js:状態(選択ボーン/マーカー)を用意する

// ===== Bone highlight (debug) =====
let selectedBoneName = null;
let selectedBone = null;          // THREE.Bone 実体
const boneMap = new Map();        // name -> Bone
let boneMarker = null;            // THREE.Mesh
let showBoneMarker = true;        // UIトグル用
const _boneWp = new THREE.Vector3();

4.2 マーカー生成:ensureBoneMarker()

マーカーは1個で十分(全ボーンに出すと重くなる)。 必要になった瞬間に作る方式にする。

function ensureBoneMarker() {
  if (boneMarker) return boneMarker;

  const geo = new THREE.SphereGeometry(0.06, 16, 16);
  const mat = new THREE.MeshBasicMaterial({ color: 0xffff00 }); // デバッグ色
  boneMarker = new THREE.Mesh(geo, mat);

  boneMarker.visible = false;
  boneMarker.renderOrder = 999;

  // 隠れて見えない問題を避けたいなら(オプション)
  // boneMarker.material.depthTest = false;
  // boneMarker.material.depthWrite = false;

  config.scene.add(boneMarker);
  return boneMarker;
}

depthTest を切ると「体の中に埋まっても見える」ので、デバッグ用途なら便利。


4.3 boneMap 構築:ロード時に name->Bone を作る

モデルロード後、mesh.traverse で SkinnedMesh.skeleton.bones から集める。 ここは毎フレームやらず、ロード時に1回が重要。

createMMD() で config.scene.add(mesh) の直後などに入れる:

boneMap.clear();
mesh.traverse((o) => {
  if (o.isSkinnedMesh && o.skeleton?.bones) {
    for (const b of o.skeleton.bones) {
      if (b?.name && !boneMap.has(b.name)) boneMap.set(b.name, b);
    }
  }
});

// 既に選択があるなら復元(同名ボーンが存在すれば追従再開)
if (selectedBoneName) {
  selectedBone = boneMap.get(selectedBoneName) ?? null;
  if (boneMarker) boneMarker.visible = showBoneMarker && !!selectedBone;
}

reloadMMD() でも同じ処理を入れる(モデル切替で骨Mapが変わるため)。


4.4 mmd.js:UIから呼ぶAPIを追加

表示ON/OFF(UIの Show Bones トグルに連動)

export function setShowBoneMarker(on) {
  showBoneMarker = !!on;
  if (boneMarker) boneMarker.visible = showBoneMarker && !!selectedBone;
}

ボーン選択(Rigクリックで呼ぶ)

export function setSelectedBone(name) {
  selectedBoneName = name || null;
  selectedBone = selectedBoneName ? (boneMap.get(selectedBoneName) ?? null) : null;

  ensureBoneMarker();
  boneMarker.visible = showBoneMarker && !!selectedBone;

  if (selectedBoneName && !selectedBone) {
    console.warn("bone not found:", selectedBoneName);
  }
}

ポイント:

  • UIは “名前” を渡すだけ
  • 実体解決は mmd.js 内だけで完結

4.5 毎フレーム追従:updateMMD() の末尾で更新

アニメーションでボーンが動くので、追従は毎フレームで行う。

// updateMMD(delta) の末尾
updateBoneMarker();
function updateBoneMarker() {
  if (!boneMarker || !showBoneMarker || !selectedBone) return;

  selectedBone.getWorldPosition(_boneWp);
  boneMarker.position.copy(_boneWp);
}

負荷は非常に軽い(Bone 1個のワールド座標取得+代入だけ)。


4.6 UI側:Rigクリックで setSelectedBone を呼ぶ

Rigの list item は data-bone を持っているので、そのままAPIへ渡す。

list?.addEventListener("click", (ev) => {
  const btn = ev.target.closest(".list__item");
  if (!btn) return;

  const boneName = btn.dataset.bone;
  if (boneName) setSelectedBone(boneName);

  // UI側の選択ハイライト
  list.querySelectorAll(".list__item").forEach((b) => b.classList.remove("is-selected"));
  btn.classList.add("is-selected");
});

Show Bones チェックも連動させる:

const cbBones = document.querySelector("#displayBones");
cbBones?.addEventListener("change", () => {
  setShowBoneMarker(cbBones.checked);
});

ここまでで得られる効果

  • Rigでボーン名をクリック → 3D上で位置が一発で分かる
  • アニメ中でも追従する → 動きの原因調査が速い
  • depthTest を切れば、体に埋まっても “見失わない”

この時点で、Rig/UIは「単なる表示」から 実用デバッグツールになります。

5. Infoパネル:PMX/VMDパス+Counts+Materials上位表示

Rigが「骨とモーフを調べる導線」なら、Infoは「今ロードされているモデル状態を確認する導線」。 モデル切替を繰り返すアプリでは、パス・カウント・材質が見えるだけでトラブルシュートが速くなる。


目的

  • モデル切替後に「どのPMX/VMDを読み込んだか」をUIで確認できる
  • counts(Mesh/Verts/Tris/Bones/Morphs/Mats)を ひと目で比較できる
  • Materials は多すぎるので 上位12件だけ表示して情報量を制御する

5.1 index.html:tplInfoPanel を追加

Infoは「長い文字列(ファイルパス)」が混ざるので、最初から “詰めて表示” できる構造にしておく。

<template id="tplInfoPanel">
  <div class="panel__section">
    <div class="section__title">Model</div>
    <div class="stats">
      <div class="stat">
        <div class="stat__k">PMX</div>
        <div class="stat__v" data-bind="pmx">-</div>
      </div>
      <div class="stat">
        <div class="stat__k">VMD</div>
        <div class="stat__v" data-bind="vmd">-</div>
      </div>
    </div>
    <div style="margin-top: 8px; opacity: 0.75" data-bind="loadedAt">-</div>
  </div>

  <div class="panel__section">
    <div class="section__title">Counts</div>
    <div class="stats">
      <div class="stat"><div class="stat__k">Meshes</div><div class="stat__v" data-bind="meshes">-</div></div>
      <div class="stat"><div class="stat__k">Verts</div><div class="stat__v" data-bind="verts">-</div></div>
      <div class="stat"><div class="stat__k">Tris</div><div class="stat__v" data-bind="tris">-</div></div>
      <div class="stat"><div class="stat__k">Bones</div><div class="stat__v" data-bind="bones">-</div></div>
      <div class="stat"><div class="stat__k">Morphs</div><div class="stat__v" data-bind="morphs">-</div></div>
      <div class="stat"><div class="stat__k">Mats</div><div class="stat__v" data-bind="mats">-</div></div>
    </div>
  </div>

  <div class="panel__section">
    <div class="section__title">Materials (top 12)</div>
    <div class="list" data-bind="matList"></div>
  </div>
</template>

ポイント:

  • data-bind で JS 側の差し込み先を固定
  • Materials は list 枠だけ作っておき、JSで中身を生成する

5.2 ui.js:renderInfoPanel(lastInfo) を作る

Rigと同じく template clone 方式で、差し込みだけ行う。 パスや材質名は長いので、escapeHtml も使用する。

function renderInfoPanel(info) {
  const body = panel?.querySelector(".panel__body");
  if (!body) return;
  body.innerHTML = "";

  const tpl = document.querySelector("#tplInfoPanel");
  if (!tpl) {
    body.textContent = "(tplInfoPanel not found)";
    return;
  }

  body.appendChild(tpl.content.cloneNode(true));
  const bind = (k) => body.querySelector(`[data-bind="${k}"]`);

  // 未ロード時の安全表示
  if (!info?.counts) {
    ["pmx","vmd","loadedAt","meshes","verts","tris","bones","morphs","mats"].forEach((k) => {
      const el = bind(k);
      if (el) el.textContent = "-";
    });
    const ml = bind("matList");
    if (ml) ml.innerHTML = `<div style="opacity:.7;">(no model loaded)</div>`;
    return;
  }

  const c = info.counts;

  // Paths
  const pmx = bind("pmx");
  if (pmx) pmx.textContent = info.pmxPath ?? "-";
  const vmd = bind("vmd");
  if (vmd) vmd.textContent = info.vmdPath ?? "-";

  // Loaded time
  const la = bind("loadedAt");
  if (la) la.textContent = info.loadedAt
    ? `loaded: ${new Date(info.loadedAt).toLocaleString()}`
    : "-";

  // Counts
  const meshes = bind("meshes"); if (meshes) meshes.textContent = c.meshes ?? "-";
  const verts  = bind("verts");  if (verts)  verts.textContent  = fmtK(c.vertexCount);
  const tris   = bind("tris");   if (tris)   tris.textContent   = fmtK(c.triangleCount);
  const bones  = bind("bones");  if (bones)  bones.textContent  = c.bones ?? "-";
  const morphs = bind("morphs"); if (morphs) morphs.textContent = c.morphs ?? "-";
  const mats   = bind("mats");   if (mats)   mats.textContent   = c.materials ?? "-";

  // Materials(上位12件だけ)
  const matList = bind("matList");
  if (matList) {
    const list = (info.materials ?? []).slice(0, 12);
    matList.innerHTML = list.map((m) => `
      <div class="list__item" style="pointer-events:none;">
        ${escapeHtml(m.name || "(no name)")}
        <span style="opacity:.6;">${escapeHtml(m.type)}</span>
      </div>
    `).join("") || `<div style="opacity:.7;">(no materials)</div>`;
  }
}

呼び出しはツールバーの Info ボタンで:

if (key === "info") renderInfoPanel(lastInfo);

5.3 表示崩れ対策:InfoだけCSSを上書きする

Infoはパスが長くて、既存の .stats レイアウト(2列想定など)を使い回すと崩れやすい。 そこで、パネルに data-panel=“info” を付けて InfoだけCSSを上書きする。

JS(openPanel時)

panel?.setAttribute("data-panel", getActivePanelKey(btn));

CSS(ellipsisで詰める)

/* ===== Info panel layout fixes ===== */
.panel[data-panel="info"] .stats {
  display: grid;
  grid-template-columns: 1fr; /* Infoは安全に1列 */
  gap: 10px;
}

.panel[data-panel="info"] .stat {
  display: grid;
  grid-template-columns: 56px 1fr;
  align-items: center;
  gap: 10px;
}

.panel[data-panel="info"] .stat__k {
  opacity: 0.8;
  white-space: nowrap;
}

.panel[data-panel="info"] .stat__v {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap; /* ★ 長いパス対策 */
}

.panel[data-panel="info"] [data-bind="matList"] .list__item {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

これで:

  • PMX/VMDの長いパスがはみ出さない
  • Counts の並びが崩れない
  • Materials 名も横幅を超えて壊れない

このInfoパネルが効く場面

  • モデル切替後に「本当に切り替わってる?」を即確認
  • 404やテクスチャ欠けなどで「あれ?」となった時に、まずパス確認
  • モデル別の重さ(Verts/Tris)を比較して最適化の判断材料にする

6. パネル切替の落とし穴:Modelを押してもInfoが残る問題

Rig/Infoをテンプレ方式で作ると、panel__body の中身を innerHTML="" で作り直す流れになる。 ここで一度ハマるのが 「Modelボタンに戻しても、前のInfo/Rigが残る」問題。

これはDOMの“表示”というより、状態管理(どのbodyを表示しているか)の問題。


原因

  • rig/info は panel__body.innerHTML = "" して、テンプレを clone → 描画する → つまり、panel__body は “その場で別物に作り変わる”
  • 一方 model は、最初にHTMLで書いた panel__body を 復元する処理が無い → 結果として、タイトルだけ「Model」になっても、中身は前のbodyのまま残る

さらに悪い点:

  • Modelパネルには #modelList のクリック、#displayShadows/#displayBloom/#displayBones の change など イベントが付いている
  • もし model も innerHTMLで作り直す設計にすると、イベントが死ぬ/再バインド地獄が起きる

解決方針:ModelのDOMは保持して“差し替え”で管理する

Modelパネルは 最初のDOMをそのまま温存し、 Rig/Infoは 別の div.panel__body を用意して差し替える方式にする。

こうすると:

  • Modelは一度バインドしたイベントがずっと生きる
  • Rig/Infoはテンプレで何度でも描画できる
  • パネル切替が “見た目の切替” ではなく、DOMコンテナの切替になるので破綻しにくい

実装

1) 初期に Model の body を保存する

ui.js の初期化時に、最初の .panel__body を保持しておく。

const panel = document.querySelector(".panel");

// ★ Modelのbody(HTMLで最初から存在するやつ)を保持
const modelBody = panel?.querySelector(".panel__body");

// ★ Rig/Info 用のbodyは別DOMとして作る
const rigBody = document.createElement("div");
rigBody.className = "panel__body";

const infoBody = document.createElement("div");
infoBody.className = "panel__body";

(Debugも同方式にするなら debugBody もここで作る)


2) bodyを差し替える関数 swapBody()

function swapBody(nextBody) {
  if (!panel) return;
  const cur = panel.querySelector(".panel__body");
  if (cur === nextBody) return;   // 既に表示中なら何もしない
  cur?.replaceWith(nextBody);
}

ここが核心:

  • .replaceWith はDOMノード自体を差し替える
  • modelBody は同じノードを使い回すので、イベントが保持される

3) Model / Rig / Info ボタンで swapBody を呼ぶ

ツールバー押下処理で、パネル種別ごとに出すbodyを切り替える。

if (key === "model") swapBody(modelBody);
if (key === "rig")   renderRigPanel(lastInfo);
if (key === "info")  renderInfoPanel(lastInfo);

4) renderRigPanel / renderInfoPanel は “対象bodyを先に swap してから描画” にする

Rig例:

function renderRigPanel(info) {
  swapBody(rigBody);
  const body = rigBody;

  body.innerHTML = "";
  const tpl = document.querySelector("#tplRigPanel");
  body.appendChild(tpl.content.cloneNode(true));

  // ...bindして list を更新...
}

Info例:

function renderInfoPanel(info) {
  swapBody(infoBody);
  const body = infoBody;

  body.innerHTML = "";
  const tpl = document.querySelector("#tplInfoPanel");
  body.appendChild(tpl.content.cloneNode(true));

  // ...bindしてテキスト/リストを更新...
}

この設計のメリット(学び)

イベントが死なない

ModelパネルのUIは最初のHTMLにイベントを付けているが、DOMノードを保持しているので再生成が不要。

“パネル追加”が簡単になる

新しいパネルを足すときは、xxxBody を作って swapBody(xxxBody) して描画するだけ。

UIが大規模化しても破綻しにくい

「表示は切り替わるが内部は同じDOM」になり、状態が追いやすい。

7. Debug:スクショ保存とJSONコピー

Debugは「情報を眺める」より、ワンショット操作(保存・コピー)が価値になる。 とくにモデル差・不具合報告・作業ログ用途では、

  • その瞬間の3D表示(PNG)
  • その瞬間のモデル情報(mmdInfo JSON)

が取れるだけで、調査コストが激減する。


実装したいもの(最小)

  • Save PNG:Three.js の canvas を画像として保存
  • Copy mmdInfo JSON:UIで保持している lastInfo(または getMMDInfo())を JSON でコピー

※ Copy PNG(画像をクリップボード)はブラウザ/アプリ差があるので、最初は Save PNG を主にするのが堅い。


7.1 Debug UI(テンプレ)

index.html に tplDebugPanel を追加。

<template id="tplDebugPanel">
  <div class="panel__section">
    <div class="section__title">Capture</div>

    <div class="grid">
      <button class="btn" data-act="savePng">Save PNG</button>
      <button class="btn" data-act="copyInfo">Copy mmdInfo JSON</button>
    </div>

    <div style="margin-top:10px; opacity:.75;" data-bind="debugStatus">ready</div>
  </div>
</template>

7.2 canvas → Blob でPNGを保存する(Download)

Three.js の描画先は config.renderer.domElement(canvas)なので、ここから toBlob で画像化する。

async function captureCanvasBlob(type = "image/png", quality = 0.92) {
  const canvas = config.renderer?.domElement;
  if (!canvas) throw new Error("renderer canvas not found");

  return await new Promise((resolve, reject) => {
    canvas.toBlob(
      (blob) => (blob ? resolve(blob) : reject(new Error("toBlob failed"))),
      type,
      quality
    );
  });
}

function downloadBlob(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}

Saveボタンの処理はこれで完結する:

const blob = await captureCanvasBlob("image/png");
downloadBlob(blob, `clock_${Date.now()}.png`);

7.3 mmdInfo JSON をクリップボードにコピーする

UI側で保持している lastInfo をそのまま JSON にして navigator.clipboard.writeText() する。

async function copyMmdInfoJson() {
  const info = lastInfo ?? getMMDInfo?.();
  if (!info) throw new Error("mmdInfo is null");

  // compact(軽い)。必要なら pretty にする
  const text = JSON.stringify(info);
  await navigator.clipboard.writeText(text);
  return text.length;
}

7.4 Debugパネル描画:イベント委譲でボタンを配線

function setDebugStatus(body, msg) {
  const el = body.querySelector('[data-bind="debugStatus"]');
  if (el) el.textContent = msg;
}

function renderDebugPanel() {
  swapBody(debugBody);           // swapBody方式に揃えるなら
  const body = debugBody;
  body.innerHTML = "";

  const tpl = document.querySelector("#tplDebugPanel");
  if (!tpl) {
    body.textContent = "(tplDebugPanel not found)";
    return;
  }
  body.appendChild(tpl.content.cloneNode(true));

  body.addEventListener("click", async (ev) => {
    const btn = ev.target.closest("[data-act]");
    if (!btn) return;

    try {
      if (btn.dataset.act === "savePng") {
        setDebugStatus(body, "capturing…");
        const blob = await captureCanvasBlob("image/png");
        downloadBlob(blob, `clock_${Date.now()}.png`);
        setDebugStatus(body, "saved PNG");
      }

      if (btn.dataset.act === "copyInfo") {
        setDebugStatus(body, "copying…");
        const n = await copyMmdInfoJson();
        setDebugStatus(body, `copied mmdInfo JSON (${n} chars)`);
      }
    } catch (err) {
      console.error(err);
      setDebugStatus(body, `error: ${err?.message ?? err}`);
    }
  }, { once: true }); // ★ 多重登録防止(renderのたびに貼り直す設計なら必須)
}

スクショ白紙問題と解決

実装が合っていても、toBlob() で保存したPNGが 透明(白紙)になることがある。 これは環境差ではなく、WebGLの既定挙動に起因する。

原因:WebGLの描画バッファが破棄される

Three.js の WebGLRenderer は既定で

  • preserveDrawingBuffer: false

になっており、描画後にフレームバッファが捨てられる。 その結果、renderer.domElement.toBlob() が 空のキャンバスを返す場合がある。

解決:renderer生成時に preserveDrawingBuffer:true

app.js など renderer を作る箇所で、これを追加するだけで解決する。

config.renderer = new THREE.WebGLRenderer({
  antialias: true,
  preserveDrawingBuffer: true, // ★ スクショ用に必須級
});

注意点

  • preserveDrawingBuffer:true は環境によって若干重くなる場合がある → ただしデバッグ用途・開発用途ならメリットが勝つことが多い
  • “もっと堅い方式”(RenderTarget + readPixels)もあるが、コード量が増える → 今回は最短の正攻法として preserveDrawingBuffer を採用

8. まとめ

今回やったことを一言でまとめると、Three.js×MMDの内部情報を “UIに流して、調べられる形にした” 回です。 3D時計が「動くデモ」から「制作・検証できる作品UI」になりました。


今回の成果(何ができるようになったか)

  • MMDロード時に情報スナップショット(mmdInfo)を生成

    • counts / bones / morphs / materials を抽出して保持
    • mmd:info イベントでUIへ通知(疎結合)
  • UI(HUD/Stats)をモック → 実データへ置換

    • モデル切替してもズレない
    • fmtK() で見やすい数値表記(66kなど)
  • Rigパネル(ボーン/モーフ一覧+検索)を実装

    • 大量データを UI で探索できる導線ができた
    • template方式でUIの骨格を固定し、可変部分だけJS生成
  • 選択ボーンの3Dハイライト(追従マーカー)

    • “このボーンはどこ?” が即解決
    • depthTestを切れば、埋まっても見失わない(デバッグ向け)
  • Infoパネル(PMX/VMDパス+Counts+Materials上位)

    • モデル切替後の状態確認が速い
    • 長いパスはCSSでellipsis、情報量はtop12で制御
  • パネル切替の設計を整理(swapBody)

    • ModelのDOMを保持し、イベントを殺さずに切替できる
    • Rig/Info/Debugの追加がスケールする構造になった
  • Debug(Save PNG / Copy JSON)

    • 「その瞬間の状態」を保存・共有できる
    • preserveDrawingBuffer:true がスクショ白紙問題の最短解だった

設計上の学び(ここが次にも効く)

  • UIは 3D実体に触らない(mmdInfoを唯一の入口にする)
  • “固定UI” と “可変データ” を分ける(template + render)
  • パネルは DOM差し替えで管理すると、イベント地獄を避けられる
  • デバッグ機能は「表示」より「ワンショット操作」が強い(保存・コピー)

次にやると一段“作品”になる候補(軽い順)

  1. Modelパネルの検索(モデル数が増えても運用できる)
  2. ポーズ(VPD)切替UI(座る/見る/手を振る等、作品感が上がる)
  3. 時間帯イベント(朝/夜/特定日でセリフ・ポーズ・移動先を変える)
  4. Morphスライダー(表情テストがUIで完結)