概要
WebGL表現でハイポリ(30K ~ 140K Poly)のglTFモデルを複数使用した際に、転送量やローディングパフォーマンスの観点からできる限りファイルサイズを落としたくて、Node.jsでglTFファイルを圧縮する環境を作ったのでその紹介です。
glTFのファイルサイズを軽量化する方法はいくつかありますが、本記事ではDRACOとmeshoptimizerによる2通りの圧縮方法を紹介します。
環境構築
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からダウンロードしたアニメーション付きXBOTのモデルがどのくらい軽量化できるか比較してみます。
mixamoからダウンロードできるのは.fbx
か.dae
形式なので、BlenderでglTF
にエクスポートしておきます。
アニメーション | マテリアル | 頂点 | ポリゴン |
---|---|---|---|
1 | 2 | 28,312 | 49,112 |
gltf-compressor
├── package.json
└── + src
└── + Capoeira
├── + Capoeira.bin
└── + Capoeira.gltf
圧縮前のglTFは2.3MBありました。
DRACO圧縮
DRACO圧縮には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
を追加します。
{
"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圧縮をするコードです。
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を圧縮しているのは以下の部分です。
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% |
three.jsで読み込む
GLTFLoader.setDracoLoader()
にDRACOLoader
のインスタンスを渡して読み込みます。
DRACO圧縮のデコードに必要なdraco_decoder.js
draco_decoder.wasm
がthree/examples/js/libs/dracoに用意されているので、自分のサーバーにコピーしてDRACOLoader.setDecoderPath()
でパスを指定する必要があります。
参考:https://threejs.org/docs/index.html#examples/en/loaders/GLTFLoader
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パッケージを使用します。
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
を追加します。
{
"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圧縮をするコードです。
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の実装から引っ張ってきたので、なぜこういう実装になっているかわかりません。(拡張性をもたせるため?)
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% |
three.jsで読み込む
GLTFLoaderのDocumentationには記載されていませんが、GLTFLoader.setMeshoptDecoder()というメンバ関数が用意されていて、デコーダーもthree.js内に含まれています。
こちらはGLTFLoader.setMeshoptDecoder()
にMeshoptDecoder
をそのまま渡します。
参考:https://threejs.org/examples/#webgl_loader_gltf_compressed
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
gltfpack
gltf-transform(試してないが多機能そうなnpmパッケージ)
RapidCompact(ポリゴン数やテクスチャサイズなどを自動で最適化してくれるオンラインサービス)