LoginSignup
18
11

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-03-13

概要

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

リポジトリはこちら

glTFのファイルサイズを軽量化する方法はいくつかありますが、本記事ではDRACOmeshoptimizerによる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.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

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.jsonscriptsを追加します。

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圧縮をするコードです。

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`);

globsrcフォルダ内にある.gltfファイルの配列を取得し、.gltfJSONとして読み込ませたものとオプションを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%

after-draco.png

three.jsで読み込む

GLTFLoader.setDracoLoader()DRACOLoaderのインスタンスを渡して読み込みます。
DRACO圧縮のデコードに必要なdraco_decoder.js draco_decoder.wasmthree/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.jsonscriptsを追加します。

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圧縮をするコードです。

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の実装から引っ張ってきたので、なぜこういう実装になっているかわかりません。(拡張性をもたせるため?)

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

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(ポリゴン数やテクスチャサイズなどを自動で最適化してくれるオンラインサービス)

18
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
11