💙

VSCode上で好きなVRMアバターと一緒に作業する

2024/12/21に公開

この記事は、フラー株式会社 Advent Calendar 2024 の 21 日目の記事です。20 日目は、@yoRyuuuuu さんの 「ISUCON14 に参加しました」でした。

動機

静的サイトジェネレータ Astro のチームが提供している、Houston という VSCode の拡張機能があります。この拡張機能はカラーテーマに加えて、エクスプローラ欄の下に Astro マスコットの Houston 君を表示する機能があります。

癒やし要素として、Houston 君は編集中のファイルのエラー数に応じて表情を変えてくれます、kawaii〜

こちらに影響を受け、自分で選んだキャラクターでも同じことができたらと思い拡張機能を作り出しました。

デモ

アバター表示

ファイルのエラー数が少ない順に、happy > neutral > angry > sad の順で表情が切り替わります。アバターの位置調整はマウス操作で行います。現状は位置調整は再起動でリセットされます。

https://www.youtube.com/watch?v=CHSEI1i6-d0

(デモ用 VRM アバターにずんだもん W-Type を使用させていただいております)

設定

設定画面
VSCode の設定にて、ローカルの .vrm ファイルパスを指定してアバターを読み込みます。

実装方法

(細かいところは割愛しています、以下で載せるサンプルコードなどを参考にしてもらえると嬉しいです 🙏)

技術構成

vrm-companion-vscodeの構成図

おおまかには、Vite + React で作ったアバター表示部分を静的ページにビルドし、それを VSCode の WebView API を使って表示させる形を取っています。

VRM ファイルの読み取りは VSCode で行い、WebView API 内の postMessage という関数を通じて、アバター表示部分に VRM データを送信します。テキストエディタのエラー数も postMessage を使って同期しています。

プロジェクト作成

公式 docs及び公式の VSCode + React サンプルを参考に VSCode プロジェクトを作成します。

npx --package yo --package generator-code -- yo code

その後、packages/webview のディレクトリを切り、Vite で React プロジェクトを作成します。

mkdir packages
cd packages
pnpm create vite@latest webview -- --template react

また Webview API を通じて拡張機能と Webview でデータをやり取りするため、packages/webview 配下にて Webview API 用のパッケージをインストールします。

ni @vscode/webview-ui-toolkit @types/vscode-webview

構成の都合上、開発中の画面更新は、React プロジェクトでビルド → VSCode のデバッグツールを更新という手順を踏む必要があります。

アバター表示部分 (/packages/webview)

VRM 読み込み & 表示

@pixiv/three-vrm というパッケージを使うことで、Three.js 上で VRM アバターを簡単に読み込んだり、操作することができます。加えて VRM の型定義である @pixiv/types-vrmc-vrm-1.0 も利用します。こちらと React Three Fiber を組み合わせてアバター表示を実装しています。

packages/webview/src/components/model.tsx
export default function Model() {
  const avatar = useRef<VRM>();
  // VRMファイルをDataURLに変換したもの
  const [dataUrl, setDataUrl] = useState<string | null>(null);

  useEffect(
    function loadVrm() {
      if (gltf || !dataUrl) {
      return;
      }

      const loader = new GLTFLoader();

      loader.register((parser) => {
      return new VRMLoaderPlugin(parser);
      });

      loader.load(
      dataUrl,
      (gltf) => {
          setGltf(gltf);
          const vrm: VRM = gltf.userData.vrm;
          avatar.current = vrm;
          if (vrm.lookAt) {
          vrm.lookAt.target = camera;
          }
      },
      ...
      );
    },
    [dataUrl]
  );
}

VRM のモデル部分をgltfstate に、VRM を操作するためのデータをavatarにそれぞれ格納しています。

そして、<primitive />要素を配置して GLTF データを渡すことでモデルを表示します。

packages/webview/src/components/model.tsx
export default function Model() {
  ...

  return (
    <Float
      speed={1.5}
      rotation={[0, Math.PI, 0]}
      position={[0, -4, 2.3]}
      rotationIntensity={0.3}
      floatIntensity={0.5}
    >
      <mesh scale={[5, 5, 5]}>{gltf && <primitive object={gltf.scene} />}</mesh>
    </Float>
  );
}

表情を変更する

VRM オブジェクトの expressionManager を参照することで、アバターの表情を操作できます。
VRM 1.0 の場合 expression の種類は 5 つほどありますが、今回はその中から happy/sad/angry を操作します。

参考(仕様書)

packages/webview/src/components/model.tsx
function getExpression(issuesCount: number): {
  happy: number;
  angry: number;
  sad: number;
} {
  if (issuesCount < 2) {
    return { happy: 1.0, angry: 0, sad: 0 };
  } else if (issuesCount < 4) {
    return { happy: 0, angry: 0, sad: 0 };
  } else if (issuesCount < 8) {
    return { happy: 0, angry: 1.0, sad: 0 };
  } else {
    return { happy: 0, angry: 0, sad: 1.0 };
  }
}

export default function Model() {
  const [issuesCount, setIssuesCount] = useState<number>(0);
  ...

  useEffect(function updateExpressionByIssues() {
    if (avatar.current?.expressionManager) {
      const expression = getExpression(issuesCount);
      avatar.current.expressionManager.setValue("happy", expression.happy);
      avatar.current.expressionManager.setValue("angry", expression.angry);
      avatar.current.expressionManager.setValue("sad", expression.sad);
    }
  });
}

拡張機能からのデータ受信

messageというイベントで拡張機能からのメッセージを受け取ることができます。
commandプロパティからはメッセージの種類、stateからはデータ本文を参照できます。(送信側の処理は後に説明します)
webview 側の初期レンダリングまでメッセージ送信を保留してほしいため、ready_for_receives というイベントをこちらから送信しています。

packages/webview/src/components/model.tsx
export default function Model() {
  ...

  useEffect(() => {
    vscode.postMessage({ command: "ready_for_receives" });
    window.addEventListener("message", (event) => {
      const message = event.data; // The JSON data our extension sent
      switch (message.command) {
        case "set_vrm":
          setDataUrl(message.state.vrmFileDataUrl);
          break;
        case "issues_count":
          setIssuesCount(message.state.issuesCount);
      }
    });
  }, []);

  ...
}

拡張機能部分 (/)

エクスプローラへの webview 表示と、VRM ファイルパスの設定項目のための contributes 設定を追加します。

package.json
{
  ...
  "contributes": {
    "views": {
      "explorer": [
        {
          "type": "webview",
          "id": "vrm-companion-vscode.summon",
          "name": "vrm-companion"
        }
      ]
    },
    "configuration": {
      "title": "vrm-companion-vscode",
      "properties": {
        "vrm-companion-vscode.vrmFilePath": {
          "type": "string",
          "default": null,
          "description": "your vrm file path"
        }
      }
    }
  }
  ...
}

VRM 読み込み & Webview への送信

Webview 側でローカルファイル読み込みを行う術が見つけられなかったため、VSCode 側で VRM ファイルを読み取って、 その DataURL を Webview に送信する方法を取っています。

src/extension.ts
// vrmFilePath は VSCode の設定画面での入力値が入る
async function getVrmFileDataUrl(vrmFilePath: string) {
  try {
    const vrmFile = await readFile(vrmFilePath);
    const base64Data = vrmFile.toString("base64");
    return `data:application/octet-stream;base64,${base64Data}`;
  } catch (error) {
    console.error("Error reading VRM file:", error);
    return "";
  }
}

WebviewViewProvider の作成と、送信処理実装

WebviewViewProvider を継承したクラスを作成し、Webview が作成されたときに VRM データを送信する処理を内部に実装します。
先程の Webview 側で ready_for_receives メッセージが送信されたら、今度は拡張機能の方から postMessage で DataURL を送信します。
また、エラー数の送信も同じく postMessage で実装します。

src/webview-provider.ts
export class WebViewProvider implements vscode.WebviewViewProvider {
  constructor(
    private _extensionUri: vscode.Uri,
    private _vrmFileDataUrl: string
  ) {}
  private _view?: vscode.WebviewView;

  public resolveWebviewView(webviewView: vscode.WebviewView) {
    ...
    webviewView.webview.onDidReceiveMessage((message) => {
      switch (message.command) {
        case "ready_for_receives":
          webviewView.webview.postMessage({
            command: "set_vrm",
            state: { vrmFileDataUrl: this._vrmFileDataUrl },
          });
      }
    });
  }

  public postIssuesCount(issuesCount: number) {
    ...
    view.webview.postMessage({
      command: "issues_count",
      state: { issuesCount },
    });
  }

  public postVrmFileDataUrl(vrmFileDataUrl: string) {
    ...
    view.webview.postMessage({
      command: "set_vrm",
      state: { vrmFileDataUrl },
    });
  }
  ...
}

こちらで作成した update 関数は、VSCode に一度だけ呼ばれるactivate関数を通じて実行されます。
取得した DataURL を用いて Webview を初期化し、onDidChangeDiagnostics時にエディタ上のエラー数を送信する処理を登録します。

src/extension.ts
async function update(vrmFilePath: string, context: vscode.ExtensionContext) {
  try {
    const dataUrl = await getVrmFileDataUrl(vrmFilePath);

    const provider = new WebViewProvider(context.extensionUri, dataUrl);

    context.subscriptions.push(
      vscode.window.registerWebviewViewProvider(
        "vrm-companion-vscode.summon",
        provider
      )
    );

    vscode.languages.onDidChangeDiagnostics(() => postIssuesCount(provider));

    ...
  } catch (error) {
    console.error("Error reading VRM file:", error);
    return;
  }
}

おわりに

拡張機能及びリポジトリはまだ公開できていませんが、未実装の機能含めて一区切りついたら publish したいと思います。
断片的な説明となってしまいましたが、読んでいただきありがとうございました。

続いて、フラー株式会社 Advent Calendar 22 日目は @canacel さんの記事です!

Discussion