はじめに
VRM を扱うライブラリとして、UniVRM や VRM4U、three-vrmなどがありますが、
Elixir で VRM を扱うライブラリがなかったため、今回は Elixir で VRM のパースして中身を見ていこうと思います。
VRM とは
VRM は glTF-2.0 のバイナリ形式である glb をベースとして作られています。この glb をヒューマノイドとして扱うのに必要なものを定義したフォーマットになります。
VRM は glb を拡張して作られているため、拡張子を .vrm から .glb に変えるとglTF対応のソフトで読み込むことも可能です(但し、VRM で拡張された情報は読み込まれません)。
glb とは
KhronosGroup が使用策定をしてる JSON 形式で 3D モデルのを表現するフォーマットとして glTF があります。
glTF はメッシュやテスクチャなどのバイナリデータは別ファイルに分かれており、URL やパスで参照する形になっています。一方、glb ではメッシュやテスクチャなどの情報をひとつのファイルにまとめたバイナリ形式のフォーマットです。
glTF では読み込み時に外部ファイルの参照が必要になりますが、glb では外部ファイルの参照をせずにバイナリファイル内の参照で済みます。
glb 形式
glb は Header 部 + JSON Chunk 部 + BINARY Chunk 部 の3つからできています。また glb の値は Little Endian で保存されています。
glTF仕様書から引用
Header 部
Header 部は合計12バイトの長さがあります。
Magic は ASCII で glTF、Version は glTF バージョンである2固定です。Length は Header部を含むファイル全体のデータサイズを表しています。
| バイト数 | 内容 | 型 | 値 | 
|---|---|---|---|
| 4 | magic | ascii | "glTF" | 
| 4 | version | int32 | 2 | 
| 4 | length | int32 | 
Chunk 部
Chunk 部の chunk size は chunk data のバイトサイズを表しています。また、chunk type でこの Chunk が Json Chunk と Binary Chunk のどちらかを示しています。
| バイト数 | 内容 | 型 | 値 | 
|---|---|---|---|
| 4 | chunk size | int32 | |
| 4 | chunk type | int32 | “JSON” or “BIN\x00” | 
| 4 | chunk data | バイト列 | 
LiveBook で VRM をパースしてみる
iex でもできますが、今回は LiveBook を使って VRM のパースしてみようと思います。
セットアップ
今回、Json のデコードに Jason、画像の表示に Kino を使うので、ライブラリ追加を追加します。
Mix.install([
  {:jason, "~> 1.4"},
  {:kino, "~> 0.8"}
])
VRM のパース
今回はサンプルとして AIcia Solid の VRM モデルを使います。
https://3d.nicovideo.jp/alicia/
alicia_solid = File.read!("alicia_solid.vrm")
glb 形式 で紹介した VRM のバイナリの構造をパターンマッチを使ってパースしていきます。
binary = IO.iodata_to_binary(alicia_solid)
<<"glTF"::binary, 2::32-little-integer, length::32-little-integer,
  rest::size((length - 12) * 8)-bits>> = binary
<<length::32-little-integer, "JSON"::binary, json_chunk_data::size(length)-bytes, rest::bits>> =
  rest
<<length::32-little-integer, "BIN\0"::binary, binary_chunk_data::size(length)-bytes>> = rest
Header、Json Chunk、Binary Chunk の順にパターンマッチでパースしていきます。このとき、glb は Little Endian のため、int の修飾子は 32-little-integer を指定する必要があります。また、size(length)-bytes で chunk size で指定された長さ分 chunk data を取得します。
UniVRM のドキュメントに Python を使ったパース例がのってますが、Elixir のバイナリのパターンマッチを利用するとかなり短く書けます。
https://vrm-c.github.io/UniVRM/en/implementation/format.html#python3
Json Chunk のデータはエンコードされた状態なので Jason でデコードをする必要があります。
alicia_data = Jason.decode!(json_chunk_data)
VRM の画像一覧
VRM のパースができたので VRM の中に保存されている画像一覧を取得してみようと思います。
画像に関する情報がどこに入っているかというと、"images" の中に入っています。
alicia_data["images"]

ファイル名に加えて MIME Type と bufferView というカラムが入っています。 glb には bufferViews というカラムがあり、bufferViewはこの index の値になります。
bufferViewsですが、対象のデータが Buffer 領域のどこからどこまでかを示す値になります。
alicia_data["bufferViews"]
画像一覧を取得して Kino.Image を使って表示させると下のようになります。
%{"images" => images, "bufferViews" => buffer_views} = alicia_data
images
|> Enum.map(fn image ->
  %{"bufferView" => index, "mimeType" => mime_type} = image
  %{"byteOffset" => offset, "byteLength" => length} = Enum.at(buffer_views, index)
  <<_::size(offset)-bytes, content::size(length)-bytes, _::bits>> = binary_chunk_data
  %{mime_type: mime_type, content: content}
end)
|> Enum.map(& struct!(Kino.Image, &1))
|> Kino.Layout.grid(columns: 3)
UniVRM で VRM を読み込んだ場合、Textures は以下で、今回読み込んだ VRM のテスクチャ画像と一致してそうです。

おわりに
今回は Elixir で VRM をパースして画像一覧を取得してみました。
AIcia Solid の VRM モデルを使いましたが、VRoid Studio のサンプルキャラも同様にテクスチャ一覧が出せます。

今回出力した画像一覧の中にキャラクタのサムネイル画像が含まれていますが、
次回は VRM の Meta ファイルを読んでサムネイルを取得しようと思います。

