[Three.js #27] MMDのMorph検証UIを実装 — Rigパネルで表情を直接確認

はじめに

前回までで、Three.js + MMD ベースの時計アプリに、複数 Actor の表示や順番トークの仕組みを作成。

今回は、未実装だった「 Morph 」を実装してみたのでそのメモです。

実装するにあたり、一覧に名前が出ていても、実際には見た目の変化がかなり弱いことや、Morph が効いていないように見えるときに、

  • 実装が悪いのか
  • 適用先が違うのか
  • そもそもモデル側の差分が弱いのか

この切り分けがしづらい。

そこで今回は、MMD モデルの Rig 情報を確認するための Rig パネル を実装し、 Bone / Morph の一覧表示に加えて、検索、Bones / Morphs の切り替え、Morph value の入力、Apply / Reset まで揃え、表情確認用の検証 UI として使える状態にしています。

動画(YouTube):

動画(PC):

今回作ったもの

今回追加したのは、MMD モデルの Bone / Morph 情報をまとめて確認するための Rig パネル だ。

実装した主な機能は次の通り。

  • Rigパネルの追加
  • Bone数 / Morph数の表示
  • Search
  • Bones / Morphs の切り替え
  • Selection 表示
  • Morph value 入力
  • Apply / Reset

単に一覧を表示するだけではなく、選択した Morph をその場で適用して表情変化を確認できる ところまで実装している。 これによって、どの Morph が実際に使えそうかを UI 上で直接確認できるようになった。

今回の UI はこんな形になった。

右側に Rig パネルを配置し、Bone / Morph の件数、検索、モード切り替え、Selection、Apply / Reset をまとめている。 左上 HUD でも Mesh / Vert / Tri / Bone / Morph / Mat を確認できるようにしてあるので、モデルの状態をざっくり把握しやすい。

なぜ Rig パネルが必要だったのか

この UI が必要だった理由はかなり実務的だ。

まず、モデルによっては Morph 名が存在していても、見た目の変化がかなり弱いものがある。 一覧だけ見えても、本当に効いているのかどうか が分からない。

また、PMX / MMD 系のモデルは個体差が大きい。 笑顔やウィンクのように分かりやすい Morph もあれば、差分が小さくてほとんど視認できないものもある。 実際に適用してみないと、どれが実用的か判断しづらい。

さらに Bone 情報もデバッグ用には見たかった。 Rig 周りを触るときは、表情だけでなく Bone 名の確認も必要になる。 後でボーンマーカーや姿勢制御に広げることを考えると、一覧 UI を持っておく意味は大きい。

加えて、今回は単一モデルではなく 複数 Actor 管理 が前提になってきている。 Actor ごとに情報を把握しながら実験していくには、手元に確認パネルがないとかなり厳しい。

つまり今回の Rig パネルは、見た目の機能追加というより、 今後の表情・姿勢・デバッグを進めるための足場作り に近い。

実装の全体方針

今回の実装で中心になったのは、Morph を UI から直接適用する処理と、それを支える Rig パネル側の UI だ。

構成としては大きく次の 2 つに分かれる。

  1. mmd.js 側で Morph の適用とリセットを受け持つ
  2. ui.js 側で Rig パネルを描画し、選択状態や Apply / Reset のイベントをつなぐ

この分離にしておくと、UI 側は「何を選んだか」に集中できて、 MMD 側は「どう適用するか」に集中できるので整理しやすい。

applyActorMorphByName の実装

まず、Morph 適用の中核が applyActorMorphByName だ。

この関数では最初に actorId を元に対象 Actor を取得する。 複数 Actor 管理になっているので、どのモデルへ適用するのかを明確にするために id ベースで引く形にしている。

export function applyActorMorphByName(actorId, morphName, value = 1) {
  const actor = actors.find((a) => a.id === actorId);
  if (!actor) {
    console.warn("[morph] actor not found");
    return false;
  }

  const target = actor.mesh ?? actor.root ?? actor.model ?? actor.scene;
  if (!target) {
    console.warn("[morph] actor target not found", actor);
    return false;
  }

  let applied = false;

  target.traverse?.((obj) => {
    if (!obj?.morphTargetDictionary || !obj?.morphTargetInfluences) return;

    const idx = obj.morphTargetDictionary[morphName];
    if (idx == null) return;

    console.log("[morph before]", obj.name, morphName, idx, obj.morphTargetInfluences[idx]);
    obj.morphTargetInfluences[idx] = value;
    console.log("[morph after ]", obj.name, morphName, idx, obj.morphTargetInfluences[idx]);

    applied = true;
  });

  if (!applied && target.morphTargetDictionary && target.morphTargetInfluences) {
    const idx = target.morphTargetDictionary[morphName];
    if (idx != null) {
      console.log("[morph before]", target.name, morphName, idx, target.morphTargetInfluences[idx]);
      target.morphTargetInfluences[idx] = value;
      console.log("[morph after ]", target.name, morphName, idx, target.morphTargetInfluences[idx]);
      applied = true;
    }
  }

  return applied;
}

ここで重要なのはこの行だ。

const target = actor.mesh ?? actor.root ?? actor.model ?? actor.scene;

今回、Morph が効かないように見えた原因のひとつがこの適用先だった。 最初は actor.root 側を見ていたが、実際には actor.mesh を優先して見る必要があった。

一覧は取得できているのに表情が変わらない、という状態だったので、 この適用先を見直したことで前進できた。

実際の適用では、traverse() を使って対象以下のオブジェクトを走査し、 morphTargetDictionarymorphTargetInfluences を持つものを探している。

target.traverse?.((obj) => {
  if (!obj?.morphTargetDictionary || !obj?.morphTargetInfluences) return;

  const idx = obj.morphTargetDictionary[morphName];
  if (idx == null) return;

  obj.morphTargetInfluences[idx] = value;
  applied = true;
});

Morph 名から index を引き、その index に対して value を代入するだけのシンプルな処理だが、 どのオブジェクトにその辞書と influence がぶら下がっているか を正しく見極める必要がある。

さらに、traverse() を持たない単体 mesh 用のフォールバックも入れている。

if (!applied && target.morphTargetDictionary && target.morphTargetInfluences) {
  const idx = target.morphTargetDictionary[morphName];
  if (idx != null) {
    target.morphTargetInfluences[idx] = value;
    applied = true;
  }
}

この形にしておくと、モデル構造の差に対して多少強くなる。

resetActorMorphs の実装

Morph の確認作業では、適用できることと同じくらい すべて元に戻せること が大事だ。 そのために resetActorMorphs を用意した。

export function resetActorMorphs(actorId) {
  const actor = actors.find((a) => a.id === actorId);
  if (!actor) return false;

  const target = actor.mesh ?? actor.root ?? actor.model ?? actor.scene;
  if (!target) {
    console.warn("[morph] actor target not found", actor);
    return false;
  }

  let reset = false;

  target.traverse?.((obj) => {
    if (!obj?.morphTargetInfluences) return;
    for (let i = 0; i < obj.morphTargetInfluences.length; i++) {
      obj.morphTargetInfluences[i] = 0;
    }
    reset = true;
  });

  if (!reset && target.morphTargetInfluences) {
    for (let i = 0; i < target.morphTargetInfluences.length; i++) {
      target.morphTargetInfluences[i] = 0;
    }
    reset = true;
  }

  return reset;
}

やっていることは単純で、対象内の morphTargetInfluences を順番に走査して全部 0 に戻している。

target.traverse?.((obj) => {
  if (!obj?.morphTargetInfluences) return;
  for (let i = 0; i < obj.morphTargetInfluences.length; i++) {
    obj.morphTargetInfluences[i] = 0;
  }
  reset = true;
});

Morph の棚卸しをするときは、ある Morph を試して、戻して、次を試す、という流れになる。 この Reset がないと作業効率がかなり落ちる。

Rig パネル側の UI 実装

UI 側では renderRigPanel() で Rig パネルの描画とイベントバインドをまとめている。

まず、Rig パネルでは Bone数 / Morph数を表示し、検索欄と表示モードを持たせた。 表示モードは bonesmorphs を切り替えられるようにしてあり、必要に応じてリスト内容を更新する。

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

検索は入力値を受け取り、Bone 名または Morph 名でフィルタしている。 件数が多いモデルでも、目的の名前に辿り着きやすい。

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

リスト選択では、クリックした項目が Bone なのか Morph なのかを見て、選択状態を切り替える。

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

  const boneName = btn.dataset.bone;
  const morphName = btn.dataset.morph;

  selectedRigBoneName = null;
  selectedRigMorphName = null;

  if (selectionName) {
    selectionName.textContent = boneName || morphName || "-";
  }

  if (boneName) {
    selectedRigBoneName = boneName;
    setSelectedBone(boneName);
  } else if (morphName) {
    selectedRigMorphName = morphName;
    setSelectedBone(null);
  }

  list.querySelectorAll(".list__item").forEach((b) => b.classList.remove("is-selected"));
  btn.classList.add("is-selected");

  syncRigActionState();
});

Bone を選んだ場合はボーン選択処理へ、Morph を選んだ場合は Morph 適用用の状態へ入る。 選択中の名前は Selection 欄に表示されるようにしてあり、今どれを触っているのか分かりやすい。

Morph 適用用には Morph value の入力欄も用意し、0〜1 の範囲で指定できるようにした。

valueInput?.addEventListener("input", () => {
  const v = Number(valueInput.value);
  rigMorphValue = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1.0;
});

Apply ボタンでは選択中の Morph を現在の Actor に適用し、Reset ボタンでは全 Morph をリセットする。

btnApply?.addEventListener("click", () => {
  if (!selectedRigMorphName) return;

  const actor = actors[0];
  if (!actor) return;

  const ok = applyActorMorphByName(actor.id, selectedRigMorphName, rigMorphValue);
  console.log("[Rig] apply morph:", selectedRigMorphName, rigMorphValue, ok);
});
btnReset?.addEventListener("click", () => {
  const actor = actors[0];
  if (!actor) return;

  resetActorMorphs(actor.id);

  syncRigActionState();

  console.log("[Rig] reset morphs");
});

今回はまず 先頭 Actor に対して動かす シンプルな形にしている。 今後 Actor 個別選択を足していく余地はあるが、検証ツールとしてはまず十分だ。

実際に確認できたこと

Rig パネルを作って確認した結果、少なくとも次のことは分かった。

  • Morph一覧表示は動いている
  • Apply / Reset は動作する
  • morphTargetDictionary / morphTargetInfluences は有効
  • 適用先は actor.root より actor.mesh を優先して見る方が正しかった
  • 少なくとも 「ウィンク2右」 は視認できた
  • ただし、すべての Morph が強く効くわけではない

この最後の点はかなり重要だと思う。 Morph が存在していることと、見た目が大きく変わることは別問題だ。

つまり今回の収穫は、 「Morph処理自体は死んでいない。今後は使える Morph を棚卸ししていく段階に入った」 と切り分けられたことにある。

今回の実装でよかった点

今回の Rig パネル実装でよかったのは、単なる UI 追加で終わらず、 開発中の不確定要素を減らすための検証ツール として機能し始めたことだ。

今後の作業では、

  • 表情差分の大きい Morph を選別する
  • モデルごとに使える Morph を整理する
  • Bone 情報と組み合わせて姿勢制御やデバッグを強化する
  • Actor 単位で Rig 操作対象を切り替える

といった方向に広げやすい。

複数 Actor 管理に進んでいくほど、こういう内部確認 UI の価値は上がっていくはずだ。

まとめ

今回は、Three.js + MMD の時計アプリに Rig パネル を追加し、Bone / Morph 情報を UI から直接確認できるようにした。

実装したのは次のような内容だ。

  • Bone数 / Morph数の表示
  • Search
  • Bones / Morphs の切り替え
  • Selection 表示
  • Morph value 入力
  • Apply / Reset

特に大きかったのは、Morph が効かないように見える問題を 「実装が悪いのか」「モデルごとの差なのか」 で切り分けられるようになったことだ。

今回の確認では、morphTargetDictionarymorphTargetInfluences は有効で、 適用先を actor.mesh ベースで見ることで「ウィンク2右」のような表情変化も確認できた。

一方で、すべての Morph が強く効くわけではない。 今後はこの Rig パネルを使いながら、モデルごとの 使える Morph の棚卸し を進めていきたい。

前回の記事では複数 Actor と順番トークの仕組みを整えたが、今回はそれを支えるための 確認 UI 側の土台 が一歩進んだ形になる。 次はこの基盤を使って、表情連動や Actor ごとの細かい制御に広げていきたい。