[Astro] #145 pixi-live2d-display と PixiJS で構築するブラウザ完結型 Live2D Model Viewer — 実装記録

[Astro] #145 pixi-live2d-display と PixiJS で構築するブラウザ完結型 Live2D Model Viewer — 実装記録

はじめに

ブラウザ上でサーバーを一切介さず、Live2D Cubism モデル(.moc3 / .moc ベース)を ZIP ファイルごとドロップして即座に表示・操作できる Web アプリケーション「Live2D Model Viewer v1」を構築しました。

Live2D のランタイムエンジンである Cubism Core を直接利用し、pixi-live2d-display ライブラリが Cubism 2.1 / 3 / 4 の差異を吸収。モーション再生、表情切り替え、マウス追従、パラメータスライダーによるリアルタイム制御、ヒットエリアの可視化、スクリーンショット保存、背景画像合成まで、モデル検証に必要な機能をワンページに集約しています。

モジュール No.モジュール名主要機能・処理内容
01ZIP EXTRACTORJSZip によるクライアントサイド ZIP 展開、入れ子フォルダ自動探索、.model3.json 検出
02MODEL LOADERpixi-live2d-display の Live2DModel.from(files) によるモデル生成、File.webkitRelativePath パス解決
03DISPLAY ENGINEPixiJS v6 Application + Cubism Core (WASM) によるリアルタイムレンダリング
04SUBSYSTEM CONTROLfocusController / eyeBlink / breath のダミーオブジェクト差し替えによる個別トグル
05PARAMETER EDITORCubism 4 _model.parameters TypedArray 直接操作、128+ パラメータのスライダー生成
06INTERACTIONモーション・表情のリスト再生、ヒットエリアオーバーレイ (HitAreaFrames)、マウス追従
07EXPORT & BGCanvas→PNG スクリーンショット、PIXI.Sprite による背景画像のカバーフィット合成

スクリーンショット

メイン UI & 変換画面

Live2D Model Viewer メイン UI

背景画像合成

Live2D Model Viewer 背景画像合成

動画(GIF)

model読み込み

Live2D Model Viewer 操作デモ

モーション再生

Live2D Model Viewer モーション再生

背景変更

Live2D Model Viewer 背景変更

1. 技術スタックと外部依存

ブラウザ完結を維持しつつ、Live2D の描画に必要なランタイムを CDN / 自前ホストで読み込んでいます。

ライブラリバージョン役割ホスト形態
live2dcubismcore.min.js5.1.0Cubism 4 Core ランタイム (WASM)/libs/ 自前ホスト
live2d.min.jsCubism 2.1 Core ランタイム/libs/ 自前ホスト
pixi.js6.5.102D WebGL レンダリングエンジンjsdelivr CDN
pixi-live2d-display0.4.0PixiJS 向け Live2D 統合プラグインjsdelivr CDN
pixi-live2d-display/extra0.4.0HitAreaFrames デバッグオーバーレイjsdelivr CDN
JSZip3.10.1クライアントサイド ZIP 展開jsdelivr CDN

ライセンスに関する注意

Cubism Core(live2dcubismcore.min.js)は Live2D 社の配布物であり、利用には「無償提供マテリアルの使用許諾契約書」への同意が必要です。個人・非商用利用であれば Free Material License の範囲で利用可能ですが、公式 SDK ページからの個別ダウンロードが前提となります。CDN 直接参照(cubism.live2d.com)は公式ながら「unreliable, don’t use in production」と pixi-live2d-display の README に明記されており、本プロジェクトでは自前ホストとしています。


2. ZIP 展開とモデルファイル検出

Live2D の公式サンプル(初音ミク等)は ZIP 内にエディタ用ファイル(.can3 / .cmo3)とランタイム用ファイルが入れ子フォルダで同居する構造になっています。pixi-live2d-display の組み込み ZIP ローダーはこの入れ子を正しく解決できず「Not implemented」エラーを出すため、JSZip で自前展開するパイプラインを実装しました。

ZIP 展開フロー

[ user.zip ]
  └── miku_free/
        ├── miku_t01.can3     ← エディタ用(スキップ)
        ├── miku_t01.cmo3     ← エディタ用(スキップ)
        └── runtime/
              ├── miku_sample_t04.model3.json  ← ★ 検出
              ├── miku_sample_t04.moc3
              ├── textures/
              │     └── texture_00.png
              └── motions/
                    └── idle_01.motion3.json

実装: model3.json の自動検出とベースパス解決

async extractZip(zipFile) {
  const zip = await JSZip.loadAsync(zipFile);
  const entries = [];

  // エディタ専用ファイル (.can3 / .cmo3) をスキップ
  const skipExts = ['.can3', '.cmo3'];
  for (const [path, entry] of Object.entries(zip.files)) {
    if (entry.dir) continue;
    if (skipExts.some(ext => path.toLowerCase().endsWith(ext))) continue;
    entries.push({ path, entry });
  }

  // .model3.json または .model.json を検索
  const settingsFiles = entries.filter(e => {
    const lower = e.path.toLowerCase();
    return lower.endsWith('.model3.json') || lower.endsWith('.model.json');
  });

  if (settingsFiles.length === 0) {
    throw new Error('No .model3.json or .model.json found in ZIP');
  }

  // 最初の設定ファイルのディレクトリをベースパスとして採用
  const settingsPath = settingsFiles[0].path;
  const baseDir = settingsPath.substring(0, settingsPath.lastIndexOf('/') + 1);

  // ベースパス以下のファイルを File オブジェクトに変換
  const modelFiles = [];
  for (const { path, entry } of entries) {
    if (!path.startsWith(baseDir) && path !== settingsPath) continue;

    const blob = await entry.async('blob');
    const relativePath = baseDir ? path.substring(baseDir.length) : path;

    const file = new File([blob], relativePath.split('/').pop(), {
      type: this.guessMime(relativePath),
    });

    // pixi-live2d-display が内部参照の解決に使用するプロパティ
    Object.defineProperty(file, 'webkitRelativePath', {
      value: relativePath,
      writable: false,
    });
    modelFiles.push(file);
  }

  return { files: modelFiles, modelCount: settingsFiles.length, settingsPath };
}

webkitRelativePath は通常ブラウザの <input webkitdirectory> でフォルダ選択した際にのみ設定されるプロパティですが、pixi-live2d-display の内部 FileLoader がこの値を使ってリソースファイル(テクスチャ、モーション JSON 等)のパスを解決するため、ZIP 展開時にも手動で設定する必要があります。


3. pixi-live2d-display によるモデル生成

ZIP から展開した File 配列を Live2DModel.from() に渡すだけで、pixi-live2d-display が .model3.json の解析 → .moc3 のロード → テクスチャバインド → 物理演算初期化 → モーション / 表情のプリロードまでを自動的に行います。

const model = await PIXI.live2d.Live2DModel.from(files, {
  autoInteract: true,   // マウス座標による自動視線追従
  autoUpdate: true,      // PIXI.Ticker による毎フレーム自動更新
});

model.interactive = true;
model.buttonMode = true;
app.stage.addChild(model);

ライフサイクルイベント

Live2DModel.from() は内部で以下の順にミドルウェアを実行します:

  1. settingsJSONLoaded — .model3.json の JSON 解析完了
  2. settingsLoaded — ModelSettings オブジェクト生成
  3. textureLoaded — 全テクスチャのバインド完了
  4. modelLoaded — InternalModel(Cubism4InternalModel)生成完了
  5. ready — 描画可能状態

4. InternalModel サブシステムのトグル制御

pixi-live2d-display の Cubism4InternalModel は毎フレームの update(dt) 内で以下のサブシステムを順に実行します:

  • focusController — マウス座標を追跡し、ParamAngleX / ParamAngleY / ParamEyeBallX / ParamEyeBallY を自動設定
  • eyeBlink — 定期的にまばたきパラメータ(ParamEyeLOpen / ParamEyeROpen)を更新
  • breath — 呼吸パラメータ(ParamBreath)を正弦波で更新

これらを UI トグルで個別に ON / OFF するために、ダミーオブジェクト差し替えパターンを採用しました。

なぜ null 代入ではなく ダミーオブジェクトか

pixi-live2d-display の内部コードは this.focusController.update(dt) のように null ガードなし でサブシステムのメソッドを呼び出します。null を代入すると TypeError: Cannot read properties of null が即座に発生します。

さらに、focusController.x / .y プロパティ(現在の補間値)を update() の外でも直接参照されるため、これらが undefined になると NaN がパラメータに伝播し、モデルの描画が崩壊します。

ダミーオブジェクトの定義

this._noop = {
  focus: {
    update() {},
    focus() {},
    x: 0, y: 0,
    targetX: 0, targetY: 0,
    scaleFactor: 1,
    faceTargetX: 0, faceTargetY: 0,
  },
  eyeBlink: { updateParameters() {} },
  breath:   { updateParameters() {} },
};

トグル実装(Mouse Tracking の例)

document.getElementById('opt-tracking').addEventListener('change', (e) => {
  const im = model.internalModel;

  if (e.target.checked) {
    // 保存していた本物の focusController を復元
    if (this._saved.focus) {
      im.focusController = this._saved.focus;
      this._saved.focus = null;
    }
  } else {
    // 本物を退避し、ダミーに差し替え
    if (im.focusController && im.focusController !== this._noop.focus) {
      this._saved.focus = im.focusController;
    }
    im.focusController = this._noop.focus;
  }
});

Pause Auto(一括停止)

3 つのサブシステムを同時にダミーに差し替え、さらに motionManager.stopAllMotions() でアイドルモーションも停止します。これにより全パラメータが自動更新されなくなり、右パネルのスライダーで手動操作した値がそのまま反映されます。

モデル依存の機能検出

モデルによっては .model3.json に Breath や EyeBlink のパラメータ定義がなく、対応するサブシステムが生成されない場合があります(im.breath === null 等)。ロード完了後に各サブシステムの有無を検出し、存在しないものは UI 上でグレーアウト + disabled とすることで、「トグルが壊れている」という誤解を防いでいます。

const hasBreath = !!im.breath;
document.getElementById('opt-breath').checked = hasBreath;
document.getElementById('opt-breath').disabled = !hasBreath;
document.getElementById('opt-breath').closest('.toggle-row').style.opacity = hasBreath ? '1' : '0.3';

5. Cubism 4 パラメータの TypedArray 直接操作

Cubism 4 の coreModel(pixi-live2d-display が内部に保持する CubismModel インスタンス)は、パラメータ値を _model.parameters.values という Float32Array で管理しています。公式 Cubism Core の C++ WASM API(getParameterId() 等)は Framework ラッパー越しには直接呼べないため、_model プロパティ経由で生データにアクセスします。

const rawModel = core._model;
const p = rawModel.parameters;

for (let i = 0; i < p.count; i++) {
  const id  = p.ids[i];              // "ParamAngleX" 等
  const min = p.minimumValues[i];     // -30
  const max = p.maximumValues[i];     // 30
  const def = p.defaultValues[i];     // 0
  const cur = p.values[i];            // 現在値

  addParamSlider(id, min, max, def, cur, (val) => {
    p.values[i] = val;  // TypedArray への直接書き込み
  });
}

スライダーのダブルクリックでデフォルト値にリセットする機能も実装しています。パラメータ数が 128 を超えるモデルもあるため、フィルター入力欄でパラメータ名の部分一致検索が可能です。


6. 背景画像合成と スクリーンショット

背景画像

ユーザーがアップロードした画像を PIXI.Sprite としてステージの最背面(addChildAt(sprite, 0))に配置します。Math.max(scaleX, scaleY) によるカバーフィットで、ビューポート全体を隙間なく覆います。

const tex = PIXI.Texture.from(url);
this.bgSprite = new PIXI.Sprite(tex);
this.app.stage.addChildAt(this.bgSprite, 0);

// カバーフィット
const scaleX = viewportWidth / tex.width;
const scaleY = viewportHeight / tex.height;
const scale = Math.max(scaleX, scaleY);
this.bgSprite.scale.set(scale);

スクリーンショット

PixiJS の renderer.view<canvas> 要素)から toDataURL('image/png') で PNG を生成し、ダイレクトダウンロードします。背景透過モードと組み合わせれば、透過 PNG としてモデルを切り出すことも可能です。


7. デバッグ記録

開発過程で遭遇した問題とその解決を記録します。

7.1 pixi-live2d-display の ZIP ローダー問題

公式サンプル ZIP(初音ミク等)は miku_free/runtime/ のような入れ子構造を持ちますが、pixi-live2d-display の内蔵 ZIP ローダーはトップレベルに .model3.json がない場合に「Not implemented」エラーを発生させました。JSZip による自前展開に切り替え、入れ子の深さに関係なく .model3.json を検索するロジックで解決。

7.2 focusController の null 代入による描画崩壊

Mouse Tracking を OFF にするため im.focusController = null としたところ、Cubism4InternalModel.update() 内の this.focusController.update(dt) で TypeError が発生。さらに this.focusController.xundefinedNaN となりパラメータが汚染され、モデルが画面から消失。同じインターフェースを持つダミーオブジェクト(全プロパティ 0、全メソッド no-op)への差し替えで解決。

7.3 Cubism 4 パラメータ API の不一致

初期実装では coreModel.getParameterId(i) を呼び出そうとしましたが、pixi-live2d-display の Framework ラッパー(CubismModel)にはこのメソッドが存在せず TypeError が発生。coreModel._model.parameters.ids[i] という TypedArray 直接アクセスに変更して解決。


8. まとめ

  • ブラウザ完結の Live2D ビューア: Cubism Core (WASM) + pixi-live2d-display により、サーバーレスで Cubism 2.1 / 3 / 4 の全バージョンに対応。
  • 堅牢な ZIP ハンドリング: JSZip による自前展開で、公式サンプルの入れ子構造やエディタ専用ファイルの混在を透過的に処理。
  • 内部サブシステムの非破壊的トグル: ダミーオブジェクト差し替えパターンにより、pixi-live2d-display の内部コードを一切改変せずに機能の個別 ON / OFF を実現。
  • 128 パラメータのリアルタイム編集: Cubism 4 の TypedArray 直接操作により、全パラメータをスライダーで制御可能。Pause Auto モードで自動更新を停止すれば手動値が維持される。

今後の拡張として、パラメータ値のリアルタイム同期表示(モーション再生中のスライダー自動追従)、複数モデルの同時表示、タイムラインベースのモーションシーケンサーなどを検討しています。