Node.jsでglTFモデルを圧縮してthree.jsで読み込む（DRACO/meshoptimizer）

# 概要
WebGL表現でハイポリ（30K ~ 140K Poly）のglTFモデルを複数使用した際に、転送量やローディングパフォーマンスの観点からできる限りファイルサイズを落としたくて、Node.jsでglTFファイルを圧縮する環境を作ったのでその紹介です。  

[リポジトリはこちら](https://github.com/watab0shi/gltf-compressor)

glTFのファイルサイズを軽量化する方法はいくつかありますが、本記事では[DRACO](https://github.com/KhronosGroup/glTF/blob/master/extensions/2.0/Khronos/KHR_draco_mesh_compression/README.md)と[meshoptimizer](https://github.com/zeux/meshoptimizer)による２通りの圧縮方法を紹介します。

# 環境構築

yarnとNode.jsのバージョンは以下です。

```
yarn@v1.22.5
node@v12.21.0
```

プロジェクトディレクトリを作成、移動して`package.json`を生成します。

```
$ mkdir gltf-compressor
$ cd gltf-compressor
$ yarn init -y
```

`src`ディレクトリを作成しglTFを格納します。

```
$ mkdir src
```

今回はサンプルデータとして、[mixamo](https://www.mixamo.com/)からダウンロードしたアニメーション付きXBOTのモデルがどのくらい軽量化できるか比較してみます。

![mixamo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/86966/6993245e-dd38-44d6-9936-4bba3f7dceba.gif)

mixamoからダウンロードできるのは`.fbx`か`.dae`形式なので、Blenderで`glTF`にエクスポートしておきます。

|アニメーション|マテリアル|頂点|ポリゴン|
|---:|---:|---:|---:|
|1|2|28,312|49,112|

```
gltf-compressor
├── package.json
└── + src
    └── + Capoeira
        ├── + Capoeira.bin
        └── + Capoeira.gltf
```

圧縮前のglTFは2.3MBありました。

![before.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/86966/1459c38a-4183-77af-08a6-ba1065a5a69a.png)

# DRACO圧縮

DRACO圧縮には[gltf-pipeline](https://github.com/CesiumGS/gltf-pipeline)パッケージを使用します。

`gltf-pipeline` `fs-extra` `glob` `path`をインストールし、DRACO圧縮用のJSファイル`compress-draco.js`を作成します。

```
$ yarn add gltf-pipeline fs-extra glob path
$ touch compress-draco.js
```

```
gltf-compressor
├── + compress-draco.js
├── node_modules
│   └── ...
├── package.json
├── src
│   └── ...
└── yarn.lock
```

`yarn draco`コマンドを実行した際に対応するJSが実行されるように、`package.json`に`scripts`を追加します。

```json:package.json
{
  "name": "gltf-compressor",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "draco": "node compress-draco.js"
  },
  "dependencies": {
    "fs-extra": "^9.1.0",
    "glob": "^7.1.6",
    "gltf-pipeline": "^3.0.2",
    "path": "^0.12.7"
  }
}
```

Node.jsでDRACO圧縮をするコードです。

```javascript:compress-draco.js
const glob = require('glob');
const fs = require('fs-extra');
const path = require('path');
const gltfPipeline = require('gltf-pipeline');

const srcDir = 'src';
const distDir = 'dist';

/**
 * glTFをDRACO圧縮
 * @param {string | string[]} globs 
 */
const compressGltfWithDraco = (globs) => {
  glob(globs, async (err, files) => {
    if (err) return;

    for (const file of files) {
      const filePath = path.resolve(file);
      const gltf = fs.readJsonSync(filePath);// gltfのJSONを読み込む
      const options = {
        resourceDirectory: path.dirname(filePath),// gltfのリソースディレクトリ（親フォルダ）
        dracoOptions: { compressionLevel: 10 }// DRACO圧縮率MAX
      };
      const { glb } = await gltfPipeline.gltfToGlb(gltf, options);// gltf -> glb
      const outFilePath = filePath.replace('.gltf', '-draco.glb').replace(srcDir, distDir);// 出力先
      await fs.mkdirp(path.dirname(outFilePath));// distディレクトリがなかったら作成
      await fs.writeFileSync(outFilePath, glb);// glbファイル出力
      console.log(`[draco] ${ outFilePath }`);
    }
  });
};

compressGltfWithDraco(`./${ srcDir }/**/*.gltf`);
```

`glob`で`src`フォルダ内にある`.gltf`ファイルの配列を取得し、`.gltf`を`JSON`として読み込ませたものとオプションを`gltfPipeline.gltfToGlb()`に渡して圧縮します。

glTFを圧縮しているのは以下の部分です。

```javascript
const options = {
  resourceDirectory: path.dirname(filePath),// gltfのリソースディレクトリ（親フォルダ）
  dracoOptions: { compressionLevel: 10 }// DRACO圧縮率MAX
};
const { glb } = await gltfPipeline.gltfToGlb(gltf, options);// gltf -> glb
```

用意しておいたコマンドを実行してDRACO圧縮をかけます。

```
$ yarn draco
```

`dist`ディレクトリにDRACO圧縮後の`.glb`ファイルが出力されます。

```
gltf-compressor
├── compress-draco.js
├── + dist
│   └── + Capoeira
│       └── + Capoeira-draco.glb
├── node_modules
│   └── ...
├── package.json
├── src
│   └── Capoeira
│       ├── Capoeira.bin
│       └── Capoeira.gltf
└── yarn.lock
```

|Before|After|圧縮率|
|:---|---:|---:|
|2.3MB|804KB|-64.79％|

![after-draco.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/86966/6b7d1c30-2456-aa48-b9ce-0cec4842b46c.png)

## three.jsで読み込む

`GLTFLoader.setDracoLoader()`に`DRACOLoader`のインスタンスを渡して読み込みます。
DRACO圧縮のデコードに必要な`draco_decoder.js` `draco_decoder.wasm`が[three/examples/js/libs/draco](https://github.com/mrdoob/three.js/tree/master/examples/js/libs/draco)に用意されているので、自分のサーバーにコピーして`DRACOLoader.setDecoderPath()`でパスを指定する必要があります。

参考：https://threejs.org/docs/index.html#examples/en/loaders/GLTFLoader

```javascript
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader';

const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/path/to/draco_decoder/');

const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);
loader.load('Capoeira-draco.glb', (gltf) => {
  scene.add(gltf.scene);
});
```

# meshopt圧縮

meshopt圧縮には[gltfpack](https://meshoptimizer.org/gltf/)パッケージを使用します。

`gltfpack`をインストールして、meshopt圧縮用のJSファイル`compress-meshopt.js`を作成します。

```
$ yarn add gltf-pack
$ touch compress-meshopt.js
```

```
gltf-compressor
├── compress-draco.js
├── + compress-meshopt.js
├── dist
│   └── ...
├── node_modules
│   └── ...
├── package.json
├── src
│   └── ...
└── yarn.lock
```

`yarn meshopt`コマンドを実行した際に対応するJSが実行されるように、`package.json`に`scripts`を追加します。

```json:package.json
{
  "name": "gltf-compresser",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "draco": "node compress-draco.js",
    "meshopt": "node compress-meshopt.js"
  },
  "dependencies": {
    "fs-extra": "^9.1.0",
    "glob": "^7.1.6",
    "gltf-pipeline": "^3.0.2",
    "gltfpack": "^0.15.2",
    "path": "^0.12.7"
  }
}
```

Node.jsでmeshopt圧縮をするコードです。

```javascript:compress-meshopt.js
const cp = require('child_process');
const glob = require('glob');
const gltfPack = require('gltfpack');
const fs = require('fs-extra');
const path = require('path');

const srcDir = 'src';
const distDir = 'dist';

const paths = {
  'basisu': process.env['BASISU_PATH'],
  'toktx': process.env['TOKTX_PATH']
};

/**
 * gltfpack用インターフェース
 */
const gltfPackInterface = {
  read: (path) => {
    return fs.readFileSync(path);
  },
  write: (path, data) => {
    fs.writeFileSync(path, data);
  },
  execute: (command) => {
    // perform substitution of command executable with environment-specific paths
    const pkv = Object.entries(paths);
    for (const [k, v] of pkv) {
      if (command.startsWith(k + ' ')) {
        command = v + command.substr(k.length);
        break;
      }
    }
    const ret = cp.spawnSync(command, [], { shell: true });
    return ret.status == null ? 256 : ret.status;
  },
  unlink: (path) => {
    fs.unlinkSync(path);
  }
};

/**
 * compress gltf -> glb
 * @param {string} inputPath 
 * @param {string} outputPath 
 * @return {Promise<string>}
 */
const packGltf = (inputPath, outputPath) => {
  const output = outputPath || inputPath.replace('.gltf', '.glb');
  const command = `-i ${ inputPath } -o ${ output } -cc`;// コマンドライン引数（必要に応じてオプションを追加）
  const args = command.split(/\s/g);// コマンドライン引数の配列
  return gltfPack.pack(args, gltfPackInterface).catch(err => { console.log(err.message); });
};

/**
 * glTFをmeshoptimizer圧縮
 * @param {string | string[]} globs 
 */
const compressGltfWithMeshopt = (globs) => {
  glob(globs, async (err, files) => {
    if (err) return;

    for (const file of files) {
      const filePath = path.resolve(file);
      const outFilePath = filePath.replace('.gltf', '-meshopt.glb').replace(srcDir, distDir);// 保存先
      await fs.mkdirp(path.dirname(outFilePath));// distディレクトリがなかったら作成
      await packGltf(filePath, outFilePath);// gltf -> glb
      console.log(`[meshopt] ${ outFilePath }`);
    }
  });
};

compressGltfWithMeshopt(`./${ srcDir }/**/*.gltf`);
```

glTFひとつを圧縮する`packGltf`関数を用意して、`glob`で取得したファイルリストに対して回しています。  
`gltfPackInterface`の部分は[gltfpack/cli.js](https://github.com/zeux/meshoptimizer/blob/master/gltf/cli.js)の実装から引っ張ってきたので、なぜこういう実装になっているかわかりません。（拡張性をもたせるため？）

```javascript
const packGltf = (inputPath, outputPath) => {
  const output = outputPath || inputPath.replace('.gltf', '.glb');
  const command = `-i ${ inputPath } -o ${ output } -cc`;// コマンドライン引数（必要に応じてオプションを追加）
  const args = command.split(/\s/g);// コマンドライン引数の配列
  return gltfPack.pack(args, gltfPackInterface).catch(err => { console.log(err.message); });
};
```

用意しておいたコマンドを実行してmeshopt圧縮をかけます。

```
$ yarn meshopt
```

`dist`ディレクトリにmeshopt圧縮後の`.glb`ファイルが出力されます。

```
gltf-compressor
├── compress-draco.js
├── compress-meshopt.js
├── dist
│   └── Capoeira
│       ├── Capoeira-draco.glb
│       └── + Capoeira-meshopt.glb
├── node_modules
│   └── ...
├── package.json
├── src
│   └── Capoeira
│       ├── Capoeira.bin
│       └── Capoeira.gltf
└── yarn.lock
```

|Before|After|圧縮率|
|---:|---:|---:|
|2.3MB|247KB|-89.16％|

![after-meshopt.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/86966/67358e77-bd85-434f-33c2-ce6343aca3f2.png)

## three.jsで読み込む

[GLTFLoaderのDocumentation](https://threejs.org/docs/index.html#examples/en/loaders/GLTFLoader)には記載されていませんが、[GLTFLoader.setMeshoptDecoder()](https://github.com/mrdoob/three.js/blob/master/examples/jsm/loaders/GLTFLoader.js#L215)というメンバ関数が用意されていて、デコーダーもthree.js内に含まれています。

こちらは`GLTFLoader.setMeshoptDecoder()`に`MeshoptDecoder`をそのまま渡します。

参考：https://threejs.org/examples/#webgl_loader_gltf_compressed

```javascript
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';

const loader = new GLTFLoader();
loader.setMeshoptDecoder(MeshoptDecoder);
loader.load('Capoeira-meshopt.glb', (gltf) => {
  scene.add(gltf.scene);
});
```

# 比較

|圧縮形式|Before|After|圧縮率|
|:---|---:|---:|---:|
|DRACO|2.3MB|804KB|-64.79％|
|meshoptimizer|2.3MB|247KB|-89.16％|

今回のサンプルモデルだけで比較すると`DRACO`より`meshoptimizer`のほうが圧縮率が高かったです。  
元ファイルのサイズが2.3MBもあったのに247KBまで落とせるというのはWebパフォーマンス的にもかなりありがたいですね。

いくつかモデルを圧縮して比較した印象では、モデルの作り方やジオメトリの構造によってはDRACO圧縮のほうがサイズを落とせる場合があったり、meshopt圧縮をすると一部のメッシュが破綻することがあったので、~~どちらか一択という訳にはいきませんでした。
使用するモデルの中身に応じて圧縮形式を適宜使い分ける必要がありそうです。~~
（追記）meshoptの圧縮オプションを追加することでメッシュの破綻は回避できました。

# Links
[gltf-pipeline](https://github.com/CesiumGS/gltf-pipeline)
[gltfpack](https://meshoptimizer.org/gltf/)
[gltf-transform（試してないが多機能そうなnpmパッケージ）](https://gltf-transform.donmccurdy.com/cli.html)
[RapidCompact（ポリゴン数やテクスチャサイズなどを自動で最適化してくれるオンラインサービス）](https://rapidcompact.com/)