概要
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圧縮をするコードです。
constglob=require('glob');constfs=require('fs-extra');constpath=require('path');constgltfPipeline=require('gltf-pipeline');constsrcDir='src';constdistDir='dist';/**
* glTFをDRACO圧縮
* @param {string | string[]} globs
*/constcompressGltfWithDraco=(globs)=>{glob(globs,async(err,files)=>{if(err)return;for(constfileoffiles){constfilePath=path.resolve(file);constgltf=fs.readJsonSync(filePath);// gltfのJSONを読み込むconstoptions={resourceDirectory:path.dirname(filePath),// gltfのリソースディレクトリ(親フォルダ)dracoOptions:{compressionLevel:10}// DRACO圧縮率MAX};const{glb}=awaitgltfPipeline.gltfToGlb(gltf,options);// gltf -> glbconstoutFilePath=filePath.replace('.gltf','-draco.glb').replace(srcDir,distDir);// 出力先awaitfs.mkdirp(path.dirname(outFilePath));// distディレクトリがなかったら作成awaitfs.writeFileSync(outFilePath,glb);// glbファイル出力console.log(`[draco] ${outFilePath}`);}});};compressGltfWithDraco(`./${srcDir}/**/*.gltf`);
glob
でsrc
フォルダ内にある.gltf
ファイルの配列を取得し、.gltf
をJSON
として読み込ませたものとオプションをgltfPipeline.gltfToGlb()
に渡して圧縮します。
glTFを圧縮しているのは以下の部分です。
constoptions={resourceDirectory:path.dirname(filePath),// gltfのリソースディレクトリ(親フォルダ)dracoOptions:{compressionLevel:10}// DRACO圧縮率MAX};const{glb}=awaitgltfToGlb(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';constdracoLoader=newDRACOLoader();dracoLoader.setDecoderPath('/path/to/draco_decoder/');constloader=newGLTFLoader();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圧縮をするコードです。
constcp=require('child_process');constglob=require('glob');constgltfPack=require('gltfpack');constfs=require('fs-extra');constpath=require('path');constsrcDir='src';constdistDir='dist';constpaths={'basisu':process.env['BASISU_PATH'],'toktx':process.env['TOKTX_PATH']};/**
* gltfpack用インターフェース
*/constgltfPackInterface={read:(path)=>{returnfs.readFileSync(path);},write:(path,data)=>{fs.writeFileSync(path,data);},execute:(command)=>{// perform substitution of command executable with environment-specific pathsconstpkv=Object.entries(paths);for(const[k,v]ofpkv){if(command.startsWith(k+'')){command=v+command.substr(k.length);break;}}constret=cp.spawnSync(command,[],{shell:true});returnret.status==null?256:ret.status;},unlink:(path)=>{fs.unlinkSync(path);}};/**
* compress gltf -> glb
* @param {string} inputPath
* @param {string} outputPath
* @return {Promise<string>}
*/constpackGltf=(inputPath,outputPath)=>{constoutput=outputPath||inputPath.replace('.gltf','.glb');constcommand=`-i ${inputPath} -o ${output} -cc`;// コマンドライン引数(必要に応じてオプションを追加)constargs=command.split(/\s/g);// コマンドライン引数の配列returngltfPack.pack(args,gltfPackInterface).catch(err=>{console.log(err.message);});};/**
* glTFをmeshoptimizer圧縮
* @param {string | string[]} globs
*/constcompressGltfWithMeshopt=(globs)=>{glob(globs,async(err,files)=>{if(err)return;for(constfileoffiles){constfilePath=path.resolve(file);constoutFilePath=filePath.replace('.gltf','-meshopt.glb').replace(srcDir,distDir);// 保存先awaitfs.mkdirp(path.dirname(outFilePath));// distディレクトリがなかったら作成awaitpackGltf(filePath,outFilePath);// gltf -> glbconsole.log(`[meshopt] ${outFilePath}`);}});};compressGltfWithMeshopt(`./${srcDir}/**/*.gltf`);
glTFひとつを圧縮するpackGltf
関数を用意して、glob
で取得したファイルリストに対して回しています。gltfPackInterface
の部分はgltfpack/cli.jsの実装から引っ張ってきたので、なぜこういう実装になっているかわかりません。(拡張性をもたせるため?)
constpackGltf=(inputPath,outputPath)=>{constoutput=outputPath||inputPath.replace('.gltf','.glb');constcommand=`-i ${inputPath} -o ${output} -cc`;// コマンドライン引数(必要に応じてオプションを追加)constargs=command.split(/\s/g);// コマンドライン引数の配列returngltfPack.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';constloader=newGLTFLoader();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圧縮をすると一部のメッシュが破綻することがあったので、どちらか一択という訳にはいきませんでした。
使用するモデルの中身に応じて圧縮形式を適宜使い分ける必要がありそうです。
Links
gltf-pipeline
gltfpack
gltf-transform(試してないが多機能そうなnpmパッケージ)
ポリゴン数やテクスチャサイズなどを自動で最適化してくれるオンラインサービス(月20回までは無料)