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

[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 ごとの細かい制御に広げていきたい。


COMM_LOG: nextjs-27-mmd-rig-panel-morph-ui-threejs

NO DATA FOUND IN THIS SECTOR.