VRMのバイナリをパースしてサムネイルを取得する

13 min read読了の目安(約11800字

初のZenn記事を書いています。
ちょうど、JavaScriptを使ってVRMファイルをパースするという処理を書いていたのでそれをメモがてら書いてみたいと思います。

VRMとは

VRMの公式サイトの説明から引用させてもらうと以下のように説明されています。

VRMでは「人型」の「キャラクター・アバター」を取り扱うことができます。UnityでVRMファイルを読み書きする標準実装(UniVRM)が提供されます が、 フォーマットは GLTF ベースでクロスプラットフォーム であり他のゲームエンジンやWebでも取り扱うことが可能です。

大事な点はGLTFベースという点です。ということで、glTFについて見ていきましょう。
(ちなみに正式な書き方はglTFだと思います)

glTFとは

glTFの仕様はこちらにあります。glTFはKhronosGroupが仕様策定している、スタンダードな3Dモデルフォーマットです。

公式ドキュメントからIntroductionを引用すると以下のように書かれています。

The GL Transmission Format (glTF) is an API-neutral runtime asset delivery format. glTF bridges the gap between 3D content creation tools and modern graphics applications by providing an efficient, extensible, interoperable format for the transmission and loading of 3D content.

要は、インターネット上の3Dの表現において様々な問題を解決するために定義されたフォーマットということですね。

さらに、Wikipediaから引用すると以下のように説明されています。

glTF (GL Transmission Format) はJSONによって3Dモデルやシーンを表現するフォーマットである。「3DにおけるJPEG」と表現されることもある。Khronos Group 3D Formats Working Groupによって開発され、APIを問わないランタイムアセット配布フォーマットとしてHTML5DevConf 2016において発表された。glTFは3Dシーンを圧縮し、WebGLなどのAPIを利用するアプリケーションの実行時処理を最小化する、効率的で相互運用可能なアセット配布フォーマットとなることを意図している。glTFはまた、3Dコンテンツ作成ツールやサービスのための共通発行フォーマットを定義している。

3DにおけるJPEGという表現は言い得て妙ですね。
こうした拡張性などの恩恵にあずかってVRMが出来ているというわけです。
そして実はVRMはglTFそのものではなく、glbという別のフォーマットの拡張になっています。

glbとは

glbは上記glTFの情報と、それに紐づくリソース(メッシュの頂点情報やテクスチャなど)のバイナリデータをひとつにまとめたバイナリデータ・フォーマットです。glTF Binaryでglbです。

これもWikipediaから引用すると以下のように説明されています。

glbフォーマットはglTFのバイナリ形式であり、テクスチャを外部ファイルを参照することなく同梱することができる。これはFacebook 3D Posts(英語版)の標準フォーマットでもある。

前述の通り、VRMも実際はglbファイルなのでglbのフォーマットを知ることができれば必要なデータを抽出することができるようになります。

次はそのフォーマットについて見てみましょう。

glbレイアウト(フォーマット)

glbはバイナリファイルなのでそのままでは当然読めません。
ということで、どういうレイアウトで情報が格納されているかを見てみましょう。
仕様から画像を引用すると以下のようなレイアウトになっています。

glb's Layout

最初の12byteがヘッダです。最初のmagicはそのファイルがglbであることを示すものです。
続くversionがglbファイルのバージョンです。(現時点ではversion 2が最新のようです)
そして3つ目がそのglbファイル自体の長さです。

glbはヘッダとふたつのchunkに分かれており、1chunk目がJSONになります。
chunk自体はどれも同じ構造になっていて、最初にchunkヘッダとしてchunk自体の長さとchunkのタイプ(JSONなのかBinaryなのか)を判別する要素が並んでいます。
そのあとがchunkのデータ本体となります。

JSON部分をパースする

さて、レイアウトが分かったところでまずはJSON部分をパースしてみましょう。
レイアウトを見てみると、glb自体のヘッダサイズが12byte、JSON自体のチャンクヘッダが8byteあります。つまり、先頭から20byteの位置がJSONファイル自体の開始位置となります。

また、チャンクヘッダにはチャンク自体の長さ情報があるので、それを元にbyteを取り出します。
それを踏まえて以下のコードを見てみましょう。

const LE = true; // Binary GLTF is little endian.
const GLB_FILE_HEADER_SIZE = 12;
const GLB_CHUNK_LENGTH_SIZE = 4;
const GLB_CHUNK_TYPE_SIZE = 4;
const GLB_CHUNK_HEADER_SIZE = GLB_CHUNK_LENGTH_SIZE + GLB_CHUNK_TYPE_SIZE;
const GLB_CHUNK_TYPE_JSON = 0x4e4f534a;

function getJSONData(dataView)
{
    const offset = GLB_FILE_HEADER_SIZE;

    let chunkLength = dataView.getUint32(offset, LE);
    console.log("ChunkLen " + chunkLength);

    let chunkType = dataView.getUint32(offset + GLB_CHUNK_LENGTH_SIZE, LE);
    console.log("ChunkType " + chunkType.toString(16));

    if (chunkType !== GLB_CHUNK_TYPE_JSON)
    {
        console.warn("This GLB file doesn't have a JSON part.");
        return;
    }

    const jsonChunk = new Uint8Array(dataView.buffer, offset + GLB_CHUNK_HEADER_SIZE, chunkLength);
    const decoder = new TextDecoder("utf8");
    const jsonText = decoder.decode(jsonChunk);
    const json = JSON.parse(jsonText);
    
    return {
        json: json,
        length: chunkLength,
    };
}

レイアウト情報通りにオフセットしてデータを取り出します。
そして取り出したデータをUint8Arrayとして切り出し、TextDecoderによってテキスト化、さらにそれをJSON.parseでパースして最終的にJSONデータを取得しています。

サムネイル情報を取り出す

glbはglTFフォーマットに加えて各種リソースを格納しているファイルというのは説明しました。ではそのリソース類にはどうやってアクセスすればいいのでしょうか。

そのための仕組がBufferBufferViewです。
Bufferはその名の通り各種リソースを格納するバッファを意味し、BufferViewはバッファのどこに該当データがあるかを示しています。

仕様に記載されているサンプルを引用すると以下のようになっています。

{
    "bufferViews": [
        {
            "buffer": 0,
            "byteLength": 25272,
            "byteOffset": 0,
            "target": 34963
        },
        {
            "buffer": 0,
            "byteLength": 76768,
            "byteOffset": 25272,
            "byteStride": 32,
            "target": 34962
        }
    ]
}

なんとなく推測できると思いますが、bufferがどのバッファに該当のデータがあるかを示し、byteLengthがデータ自体の長さを(byte数)、byteOffsetが該当バッファのオフセットになります。

つまり、バッファにはすべてのリソースがバイナリデータとして連結されて格納されており、そのオフセットと長さを元に該当位置のバイナリデータを取得して適宜変換する、という方法でデータを取り出すことができます。

イメージを図にすると以下のような感じです。

buffer viewからバイナリにアクセスするイメージ

それを踏まえて、サムネイルデータを抽出しているところのコードを抜粋します。

function getThumbnail(jsonData, buffer, offset)
{
    let index = -1;
    let mimeType = "";
    for (var i = 0; i < jsonData.json.images.length; i++)
    {
        if (jsonData.json.images[i].name === "Thumbnail")
        {
            index = jsonData.json.images[i].bufferView;
            mimeType = jsonData.json.images[i].mimeType;
            break;
        }
    }

    if (index === -1)
    {
        console.warn("Thumnail field was not found.");
        return;
    }

    const view = jsonData.json.bufferViews[index];

    let imgBuf = new Uint8Array(
        buffer,
        offset + GLB_CHUNK_HEADER_SIZE + view.byteOffset,
        view.byteLength
    );

    var binaryString = "";
    for (var i = 0, len = imgBuf.byteLength; i < len; i++)
    {
        binaryString += String.fromCharCode(imgBuf[i]);
    }

    let img = new Image();
    img.width = "250";
    img.src = "data:" + mimeType + ";base64," + window.btoa(binaryString);
    return img;
}

パースしたJSONの中にimagesという項目があり、さらにnameThumbnailというものがあるのでその情報を取得します。

ちなみにimages配列の中の要素ひとつは以下のような情報になっています。

images[{
    bufferView: 267,
    mimeType: "image/png",
    name: "Thumbnail",
}]

このことから、画像はPNGで、bufferView配列の267番目の要素が該当データの場所と分かります。
これをindexとして、bufferViews配列からデータを取り出します。

該当データを見てみると以下のような情報を得ることができます。

BufferView
{
    buffer: 0
    byteLength: 1505501
    byteOffset: 13616832,
}

0番目のbufferの、byteOffset分オフセットした位置からbyteLength分のデータを取り出せば、それがサムネイル画像だ、ということが分かりました。
それを踏まえて取り出している処理を見ると、以下のようにUint8Arrayとして取り出します。

let imgBuf = new Uint8Array(
    buffer,
    offset + GLB_CHUNK_HEADER_SIZE + view.byteOffset,
    view.byteLength
);

これで必要なデータを取得することができました。あとはこのArrayBufferを画像として見れるように変換するだけです。

imgBufBlobにして、URL.createObjectURL(blob)とすることでimg.srcに設定することができるようになります。具体的には以下のコードを見てください。

ArrayBufferをImageに変換
let img = new Image();
img.width = "250";
img.src = URL.createObjectURL(new Blob([imgBuf]));
return img;

やっていることは、1byteずつCharCodeに変換し、それをbase64形式でImagesrcに設定しているだけです。
画像タイプに関してはすでに取得済み(前述のMime Type)なのでそれを利用しています。

あとは生成したこのimgを必要な場所に表示してやれば完成となります。

今回のコード全体を載せておきます。
以下のコードを適当なhtmlファイルにコピーしてブラウザで開き、ファイルを指定すると保存されているサムネイルが表示されます。

<html>
<script>
    const LE = true; // Binary GLTF is little endian.
    const MAGIC_glTF = 0x676c5446
    const GLB_FILE_HEADER_SIZE = 12;
    const GLB_CHUNK_LENGTH_SIZE = 4;
    const GLB_CHUNK_TYPE_SIZE = 4;
    const GLB_CHUNK_HEADER_SIZE = GLB_CHUNK_LENGTH_SIZE + GLB_CHUNK_TYPE_SIZE;
    const GLB_CHUNK_TYPE_JSON = 0x4e4f534a;
    const GLB_CHUNK_TYPE_BIN = 0x004e4942;

    function getMagic(dataView)
    {
        const offset = 0;
        return dataView.getUint32(offset);
    }

    function getVersion(dataView)
    {
        const offset = 4;
        let version = dataView.getUint32(offset, LE);
        return version;
    }

    function getTotalLength(dataView)
    {
        const offset = 8;
        let length = dataView.getUint32(offset, LE);
        return length;
    }

    function getGLBMeta(dataView)
    {
        let magic = getMagic(dataView);
        let version = getVersion(dataView);
        let total = getTotalLength(dataView);

        return {
            magic: magic,
            version: version,
            total: total,
        };
    }

    function getJSONData(dataView)
    {
        const offset = GLB_FILE_HEADER_SIZE;

        let chunkLength = dataView.getUint32(offset, LE);
        console.log("ChunkLen " + chunkLength);

        let chunkType = dataView.getUint32(offset + GLB_CHUNK_LENGTH_SIZE, LE);
        console.log("ChunkType " + chunkType.toString(16));

        if (chunkType !== GLB_CHUNK_TYPE_JSON)
        {
            console.warn("This GLB file doesn't have a JSON part.");
            return;
        }

        const jsonChunk = new Uint8Array(dataView.buffer, offset + GLB_CHUNK_HEADER_SIZE, chunkLength);
        const decoder = new TextDecoder("utf8");
        const jsonText = decoder.decode(jsonChunk);
        const json = JSON.parse(jsonText);
        console.log(json);
        
        return {
            json: json,
            length: chunkLength,
        };
    }

    function getThumbnail(jsonData, buffer, offset)
    {
        let index = -1;
        let mimeType = "";
        for (var i = 0; i < jsonData.json.images.length; i++)
        {
            if (jsonData.json.images[i].name === "Thumbnail")
            {
                index = jsonData.json.images[i].bufferView;
                mimeType = jsonData.json.images[i].mimeType;
                break;
            }
        }

        if (index === -1)
        {
            console.warn("Thumnail field was not found.");
            return;
        }

        const view = jsonData.json.bufferViews[index];

        let imgBuf = new Uint8Array(
            buffer,
            offset + GLB_CHUNK_HEADER_SIZE + view.byteOffset,
            view.byteLength
        );

        let img = new Image();
        img.width = "250";
        img.src = URL.createObjectURL(new Blob([imgBuf]));
        return img;
    }

    function onLoadHandler(event)
    {
        let raw = event.target.result;
        let ds = new DataView(raw);

        let glbMeta = getGLBMeta(ds);
        console.log("magic " + glbMeta.magic.toString(16));

        if (glbMeta.magic !== MAGIC_glTF)
        {
            console.warn("This file is not a GLB file.");
            return;
        }

        console.log("Version " + glbMeta.version);
        console.log("Total Length " + glbMeta.total);

        const jsonData = getJSONData(ds);

        const offset = (GLB_FILE_HEADER_SIZE + GLB_CHUNK_HEADER_SIZE) + jsonData.length;
        let dataChunkType = ds.getUint32(offset + GLB_CHUNK_LENGTH_SIZE, LE);

        if (dataChunkType !== GLB_CHUNK_TYPE_BIN)
        {
            console.warn("This GLB file doesn't have a binary buffer.");
            return;
        }

        let img = getThumbnail(jsonData, ds.buffer, offset);
        document.body.appendChild(img);
    }

    function onChangeHandler(e) {
        if (!window.File) {
            return;
        }

        let input = document.querySelector("#file").files[0];
        let reader = new FileReader();
        reader.addEventListener("load", onLoadHandler, true);
        reader.readAsArrayBuffer(input);
    }

    window.addEventListener("DOMContentLoaded", () => {
        document
            .querySelector("#file")
            .addEventListener("change", onChangeHandler, true);
    });
</script>

<body>
    <input type="file" id="file" />
</body>
</html>

まとめ

VRMの内部構造を理解することができました。
基本的にはJSON部とバイナリ部ふたつからなり、バイナリデータはJSONに保存されている情報を元に切り出して使う、というイメージです。

今回はサムネイルだけの話でしたが、モデルに使われているテクスチャや頂点情報なども同様の手順で取り出すことができるので、実際に自分でパーサーを書いて必要な情報を抜き出すこともそんなにむずかしくないと思います。

やはりバイナリデータを直接いじるのは楽しいですね。

その他参考にした記事

この記事に贈られたバッジ