🧚

Live2DをWebで動かしたい!

2024/12/20に公開

Live2Dを動かしたい...Webで!
という訳で動かすまでの軌跡をまとめました。
Next.js上で動作することを確認していますが、他のフレームワークや純粋なHTML/JS環境でも応用は可能です(多分)。

方法としては次の二つを順にご紹介します。

今回は、まずは公式SDKをざっと触ってみて、その後にpixi-live2d-displayというライブラリを使ってよりカジュアルにモデルを動かしていく流れを見ていきます。

公式SDK

こちらからダウンロードできます。

https://www.live2d.com/sdk/download/web/
https://github.com/Live2D

ダウンロードした後

  • npm install
  • npm run build
  • npm run serve

すると動きます。やったね!コードに変更を加える度に npm run build が必要っぽいです。

右上の歯車をクリックするとキャラが切り替わります。
次に自分のモデルに差し替えるのを試してみます。

自分で作ったモデルで動かす

自分で作ったモデルを動かすにはまず Samples/Resouces 内にディレクトリを作ってmodel3.json、textures、motionsなどの諸々を入れます。この時注意なのが Samples/TypeScript/Demo/public/Resouces の方に追加しないことです。npm run build 時にSamples/Resoucesからこちらにコピーしてきてるので、初めにこちらに入れてしまうと消されます。

用意できたら、Demo/src/lappdefine.ts 内の ModelDir に作ったディレクトリ名を入れます。

- export const ModelDir: string[] = ["Haru", "Hiyori", "Mark", "Natori", "Rice", "Mao", "Wanko"];
+ export const ModelDir: string[] = ["Seya"];

また、model3.json内にMotionsが書かれている必要があります。
こんな感じに Motions が定義されている必要があります。

{
  "Version": 3,
  "FileReferences": {
    "Moc": "Seya.moc3",
    "Textures": ["Seya.1024/texture_00.png"],
    "DisplayInfo": "Seya.cdi3.json"
  },
  "Motions": {
    "Idle": [
      {
        "File": "motions/listening.motion3.json"
      }
    ],
    "TapBody": [
      {
        "File": "motions/speaking.motion3.json"
      }
    ]
  },
  "Groups": [
    {
      "Target": "Parameter",
      "Name": "EyeBlink",
      "Ids": []
    },
    {
      "Target": "Parameter",
      "Name": "LipSync",
      "Ids": []
    }
  ]
}

手動で足してもいいかもしれませんが、Editorとは別に付属してくるLive2D Cubism Viewerにてmoc3を読み込んでから motions を足すというやり方の方が良さそうです。
(原因はわかってないですが、私が手動でmodel3.jsonを書き換えただけでは動きませんでした。)

motions足した後「ファイル -> 書き出し -> モデル設定」と選んでいきます。
こうするとモーション付きで動くようになっているはずです。


以上、ここまでで公式SDKについて見てきました。
なんですが、公式SDKは学習や調査には有用ですが、実際に自分のWebアプリに組み込もうとすると、サンプルからコードを切り出したり、依存関係を整えたりと若干手間がかかる印象です。

本格的にアプリケーションへ組み込むには、SDK内部の実装を理解した上でサンプルコードを自前プロジェクトへ移植する必要があるでしょう。
これはこれで勉強にはなりますが、もう少し「手軽」にLive2DモデルをWeb上でレンダリングしたい、という場合に選択肢となるのが次の pixi-live2d-display です。

Live2Dのファイルたちがなんなのかちゃんと理解しよう

pixi-live2d-display の話に移る前に、Live2Dと付き合っていく上では多様なファイルが必要になるので、それらがそれぞれどんな役割かをざっと知っておくとトラブルシューティングに役立ちます。
私も最初雰囲気でやっててもらったLive2Dモデルが動かなくて「なんでやね〜ん」となってましたが、モーションファイル群が書き出されていなかったというオチがあったりしました。

公式ドキュメントを読むと、これらのファイル構成や拡張子について詳しく解説されています。
https://docs.live2d.com/cubism-editor-manual/export-moc3-motion3-files/
https://docs.live2d.com/cubism-editor-manual/file-type-and-extension/

.moc3, .can3 あたりはエディタ用のファイルで、コードで動かす際に重要なのはテクスチャのpngファイルと、色んなファイルへのパスをまとめあげている .model3.json、そして .motion3.json あたりです。大まかにこういうファイル群があるんだなぁと知っておくと良いでしょう。

pixi-live2d-display

pixi-live2d-displayは、PIXI.js(WebGLを用いた2Dレンダリング用ライブラリ)上でLive2Dモデルを動かせるようにしたライブラリです。
公式SDKほど低レベルに踏み込まず、比較的かんたんにモデルを読み込んで描画できるので、Webアプリケーションへの組み込みが楽になります。

https://github.com/guansss/pixi-live2d-display

ただ、上記はリップシンクに対応しておらず、今回はリップシンク用のパッチを当てたForkであるこちらを使わせていただこうと思います
https://github.com/RaSan147/pixi-live2d-display

セットアップとして必要なのはまずはパッケージのインストール

npm install pixi-live2d-display-lipsyncpatch pixi.js@7

注意点としてPIXI.jsはv7(Fork元の方を使っている場合はv6)でインストールしてください。
私は最初うっかり最新(v8系)を使ってエラー出て沼りました。

そしてこのパッケージとは別に live2d.min.jslive2dcubismcore.min.js というスクリプトを読み込む必要があります。在処はREADMEに書いてあるので探してみてください。

モデルの召喚

初めに全体像をお見せするとざっくりこんな感じで動きます。簡単!
initApp でPixi.jsのstage(描画対象のオブジェクトを入れるところ)をセットアップ、initLive2DでLive2Dモデルを読み込んでポジションを整えたりしています。

"use client";

// Fork元の方を使う場合は import * as PIXI from 'pixi.js'; と呼び方、などなど変わるので、そちらのREADMEを参照して変えましょう
import { Application, Ticker, DisplayObject } from "pixi.js";
import { useEffect, useRef, useState } from "react";
import { Live2DModel } from "pixi-live2d-display-lipsyncpatch/cubism4";

const setModelPosition = (
  app: Application,
  model: InstanceType<typeof Live2DModel>
) => {
  const scale = (app.renderer.width * 1) / model.width;
  model.scale.set(scale);
  model.x = app.renderer.width - model.width * scale - 20;
  model.y = app.renderer.height - model.height * scale;
};

export default function Live2D() {
  const canvasContainerRef = useRef<HTMLCanvasElement>(null);
  const [app, setApp] = useState<Application | null>(null);
  const [model, setModel] = useState<InstanceType<typeof Live2DModel> | null>(
    null
  );

  const initApp = () => {
    if (!canvasContainerRef.current) return;

    const app = new Application({
      width: canvasContainerRef.current.clientWidth,
      height: canvasContainerRef.current.clientHeight,
      view: canvasContainerRef.current,
      backgroundAlpha: 0,
    });

    setApp(app);
    initLive2D(app);
  };

  const initLive2D = async (currentApp: Application) => {
    if (!canvasContainerRef.current) return;

    try {
      const model = await Live2DModel.from(
        "/live2d/hiyori/runtime/hiyori_pro_t11.model3.json",
        { ticker: Ticker.shared }
      );

      currentApp.stage.addChild(model as unknown as DisplayObject);

      model.anchor.set(0.5, 0.5);
      setModelPosition(currentApp, model);

      model.on("hit", (hitAreas) => {
        if (hitAreas.includes("Body")) {
          model.motion("Tap@Body");
        }
      });

      setModel(model);
    } catch (error) {
      console.error("Failed to load Live2D model:", error);
    }
  };

  useEffect(() => {
    if (!app || !model) return;

    const onResize = () => {
      if (!canvasContainerRef.current) return;

      app.renderer.resize(
        canvasContainerRef.current.clientWidth,
        canvasContainerRef.current.clientHeight
      );

      setModelPosition(app, model);
    };
    window.addEventListener("resize", onResize);

    return () => {
      window.removeEventListener("resize", onResize);
    };
  }, [app, model]);

  useEffect(() => {
    initApp();
  }, []);

  return <canvas ref={canvasContainerRef} className="w-full h-full" />;
}

細かい点について触れていきます。

ポジションを整える

いい感じの位置に配置しましょう。
重要なパラメータとしてはまずは Canvas のサイズです。どんな感じで表示したいかによるのですが、私はとりあえず入れるcanvas要素の幅・高さと同じになるようにしました。

const app = new PIXI.Application({
  width: canvasContainerRef.current.clientWidth,
  height: canvasContainerRef.current.clientHeight,
  view: canvasContainerRef.current,
  backgroundAlpha: 0,
});

Live2Dモデルは、anchorを使って基準点を決めたり、scaleで拡大縮小、xとyで位置調整できます。
anchor はデフォルトで(0, 0)で要するに左上が基準点になっています。(0.5, 0.5)だとモデルの真ん中、(1, 1)で右下となってます。

例として、私の場合は「Canvas内の右下に画面幅の右下に常にいて欲しいなぁ」と考えたので、次のように

  • scaleを画面幅の0.6倍との比率で求める
  • 座標を全体の幅・高さからモデルの幅・高さにそのscaleをかけた分を引く

これで右下にピッタリいてくれるようになります。

const setModelPosition = (
  app: PIXI.Application,
  model: InstanceType<typeof Live2DModel>
) => {
  const scale = (app.renderer.width * 0.6) / model.width;
  model.scale.set(scale);
  model.x = app.renderer.width - model.width * scale;
  model.y = app.renderer.height - model.height * scale;
};


※自分のモデルの余白?大き過ぎたので0.6倍にしましたが、サンプルとして表示しているひよりちゃんは小さめだったの実は1倍にしてます

そして、画面サイズが変わった時の対応としてresizeのイベントリスナーを仕込んでおきます。

  useEffect(() => {
    if (!app || !model) return;

    const onResize = () => {
      if (!canvasContainerRef.current) return;

      app.renderer.resize(
        canvasContainerRef.current.clientWidth,
        canvasContainerRef.current.clientHeight
      );

      setModelPosition(app, model);
    };
    window.addEventListener("resize", onResize);

    return () => {
      window.removeEventListener("resize", onResize);
    };
  }, [app, model]);

モーションやタップイベントなども扱える

pixi-live2d-displayではモデル上でモーション再生やタップイベントなどもかなりシンプルに扱えます。

例えば次のコードではモデルがクリックされた時に「クリックされた領域が"Body"」の範囲内だったら「"Tap@Body"というモーションを行う」という指定をしています。

  model.on("hit", (hitAreas) => {
    if (hitAreas.includes("Body")) {
      model.motion("Tap@Body");
    }
  });

この当たり判定やモーションはLive2D Editor側で設定して、それがmodel3.jsonに反映されます。
例えば、今回のモーションに関連するところだけ抜き出すと次のような指定があります。

{
	"Version": 3,
	"FileReferences": {
		...
		"Motions": {
			...
			"Tap@Body": [
				{
					"File": "motion/hiyori_m09.motion3.json"
				}
			],
            ...
		}
	},
	"HitAreas": [
		{
			"Id": "HitArea",
			"Name": "Body"
		}
	]
}

余談なんですがFork元のオリジナル方で動かしていた時は Uncaught TypeError: Cannot read properties of undefined (reading '1') というエラーが出続けてインタラクションが行えなかったのですが、乗り換えたらエラーがなくなって上記のようなイベントが拾えるようになりました。原因究明はできていないのですが、このエラーで困っている方がいらっしゃればご参考までに。

リップシンクする

音声に応じて口パクをしていただけます。前提としてmodel3.jsonにリップシンクの紐付けがされている必要があります。

{
    "Target": "Parameter",
    "Name": "LipSync",
    "Ids": [
        "ParamMouthOpenY"
    ]
}

リップシンクも至極簡単で次のようにspeak関数を呼び出すだけです。

model.speak(audioUrl);

ちなみに私の作っているアプリケーションでは、静的な音声ファイルがあるというよりは、バックエンドから返ってくる音声のバイナリを読み上げてもらうのですが、下記のようにBlobを使って作ったURLを上記の関数に渡してもちゃんとリップシンクしてくれました。

const buildAudioUrl = (audioData: Uint8Array) => {
  const audioBlob = new Blob([audioData], { type: "audio/wav" });
  const audioUrl = URL.createObjectURL(audioBlob);
  return audioUrl;
};

Next.jsで組み込む際の注意点

pixi-live2d-displaylive2dcubismcore.min.jslive2d.min.jsなどがwindowオブジェクトに生やすものに依存するスクリプトを使っています。そのためSSR時では、そのままimportするとエラーが発生してしまいます。

これの回避のため、Next.jsではdynamic importでクライアントサイドでのみロードするようにしています。また、ScriptコンポーネントのonLoadを使って、スクリプトが読み込み終わった後にLive2Dコンポーネントをマウントする、といった手順取っています。

なんかもっと上手い方法がある気もしますが、現状私は以下のような形で回避しています。もっといい方法をご存知なフロントエンド強者の方がいらっしゃればコメントください。

const [isCubismCoreLoaded, setIsCubismCoreLoaded] = useState(false);
const [isLive2dLoaded, setIsLive2dLoaded] = useState(false);

const isScriptsLoaded = isCubismCoreLoaded && isLive2dLoaded;

return (
  <>
      <Script
        src="/scripts/live2dcubismcore.min.js"
        onLoad={() => setIsCubismCoreLoaded(true)}
      ></Script>
      <Script
        src="/scripts/live2d.min.js"
        onLoad={() => setIsLive2dLoaded(true)}
      ></Script>

      {isScriptsLoaded && <Live2D />}
  </>
)

上の例では、Scriptタグで/scripts/live2dcubismcore.min.jsと/scripts/live2d.min.jsを読み込んでいます。それぞれロード完了時にsetIsCubismCoreLoadedとsetIsLive2dLoadedをtrueにして、両方読み込み完了したら<Live2D />を表示するようにしています。

おわりに

以上、公式SDKとpixi-live2d-displayでLive2DモデルをWeb上で動かすまでの流れをざっくりまとめました。
これをベースに、ぜひご自身のWebアプリやサイトでLive2Dモデルを自由に動かしてみてください!

参考記事

https://docs.live2d.com/cubism-sdk-manual/cubism-sdk-for-web/
https://zenn.dev/mirainotori/articles/dc1aba9c105dea
https://logical-studio.com/develop/frontend/20220114-live2d-cubismsdk/

Discussion