[JavaScript] 3D ROOM制作 10日目 ブラウザだけでGLBファイルを圧縮する(依存ゼロ・150行)

[JavaScript] 3D ROOM制作 10日目 ブラウザだけでGLBファイルを圧縮する(依存ゼロ・150行)

今日やったこと

  • GLBファイルのテクスチャをWebPに変換する圧縮モジュール glb-compress.js を実装
  • WIRED ROOMのExport ZIP機能に組み込み
  • 外部ライブラリ依存ゼロ、約150行の純粋なJavaScript

背景:GLBファイルのサイズ問題

WIRED ROOMでは、Sketchfabからダウンロードした3Dモデル(GLB形式)を部屋として使用している。

これらのGLBファイルはそのままだと10MB〜30MBになることが珍しくない。ファイルサイズの大半はテクスチャ画像(PNG/JPEG)で、メッシュのジオメトリデータは全体の1〜2割程度しかない。

これまではコマンドラインツール gltf-transform でデプロイ前に手動圧縮していた。

gltf-transform webp input.glb output.glb

20MBのGLBが3MB程度になる。効果は絶大だが、Export ZIPのたびに手動でコマンドを叩く必要があり、手順が一つ余計に増えていた。

旧フロー: Export ZIP → ZIPを展開 → GLBを手動圧縮 → デプロイ

新フロー: Export ZIP(自動圧縮済み)→ デプロイ

これをブラウザ内で完結させたい。


なぜ gltf-transform を使わないのか

WIRED ROOMは「No build tools」が設計原則。importmap + CDNで動いていて、npm installもWebpackもない。

gltf-transform をブラウザで使うには以下の問題がある。

  • @gltf-transform/core@gltf-transform/extensions@gltf-transform/functions の3パッケージが必要
  • Draco圧縮にはWASMファイルの配置が必要
  • テクスチャ圧縮の textureCompress() はNode.js専用の sharp に依存
  • ESMのimportmap解決が複雑

GLBファイルのバイナリフォーマットは仕様が公開されている。テクスチャWebP変換だけなら自前で書ける。


GLBバイナリフォーマット

GLB(GL Binary)はglTF 2.0のバイナリコンテナ形式。構造はシンプル。

┌──────────────────────────────────────┐
│ Header (12 bytes)                    │
│   magic: 0x46546C67 ('glTF')         │
│   version: 2                         │
│   length: ファイル全体のバイト数       │
├──────────────────────────────────────┤
│ JSON Chunk                           │
│   chunkLength: JSONのバイト数         │
│   chunkType: 0x4E4F534A ('JSON')     │
│   data: glTFのJSON(4バイト境界パッド)│
├──────────────────────────────────────┤
│ BIN Chunk                            │
│   chunkLength: バイナリのバイト数     │
│   chunkType: 0x004E4942 ('BIN\0')    │
│   data: 頂点・テクスチャ等のバイナリ  │
└──────────────────────────────────────┘

JSONチャンクにはシーングラフ、マテリアル、アクセサ、bufferViews(バイナリの読み取り位置)、images(テクスチャの参照先)が入っている。

BINチャンクには頂点座標、法線、UV、インデックス、テクスチャ画像のバイナリデータがすべて連続して格納されている。

テクスチャ画像は json.images[i].bufferViewjson.bufferViews[n]byteOffset + byteLength で、BINチャンク内の位置を特定できる。


圧縮の仕組み

やることは4ステップ。

1. GLBをパース

ヘッダーの12バイトからmagicとversionを検証し、JSONチャンクとBINチャンクを分離する。

function parseGLB(buffer) {
  const view = new DataView(buffer);
  const magic = view.getUint32(0, true);
  if (magic !== 0x46546C67) return null; // 'glTF'

  const jsonChunkLength = view.getUint32(12, true);
  const jsonBytes = new Uint8Array(buffer, 20, jsonChunkLength);
  const json = JSON.parse(new TextDecoder().decode(jsonBytes));

  const binChunkOffset = 20 + jsonChunkLength;
  const binChunkLength = view.getUint32(binChunkOffset, true);
  const bin = buffer.slice(binChunkOffset + 8, binChunkOffset + 8 + binChunkLength);

  return { json, bin };
}

2. テクスチャをWebPに変換

各テクスチャのバイト列をBINチャンクから取り出し、ImageBitmap にデコードしてから OffscreenCanvas に描画、convertToBlob('image/webp') でWebPに変換する。

async function imageToWebP(imageBytes, mimeType, quality, maxSize) {
  const blob = new Blob([imageBytes], { type: mimeType });
  const bitmap = await createImageBitmap(blob);

  let { width, height } = bitmap;
  if (width > maxSize || height > maxSize) {
    const scale = maxSize / Math.max(width, height);
    width = Math.round(width * scale);
    height = Math.round(height * scale);
  }

  const canvas = new OffscreenCanvas(width, height);
  const ctx = canvas.getContext('2d');
  ctx.drawImage(bitmap, 0, 0, width, height);
  bitmap.close();

  const webpBlob = await canvas.convertToBlob({ type: 'image/webp', quality });
  return await webpBlob.arrayBuffer();
}

ポイント:

  • OffscreenCanvas を使うことでDOM操作なしで完結
  • maxSize でテクスチャの最大解像度を制限(デフォルト2048px)
  • WebPに変換した結果が元より大きい場合は元のまま保持

3. バイナリバッファを再構築

BINチャンクの中身は「頂点データ」と「テクスチャデータ」が混在している。bufferViews の一覧を走査して、テクスチャ用のbufferViewだけを圧縮後のWebPデータに差し替え、それ以外はそのままコピーする。

すべてのbufferViewの byteOffset を再計算し、4バイト境界にアラインメントする。

4. GLBを再組み立て

更新されたJSONとBINチャンクから新しいGLBバイナリを構築する。

function buildGLB(json, binBuffer) {
  const jsonBytes = new TextEncoder().encode(JSON.stringify(json));
  // 4バイト境界にパディング(JSONはスペース、BINはゼロ)
  const totalLength = 12 + 8 + jsonBytes.length + 8 + binBuffer.byteLength;
  // ... ヘッダー + JSONチャンク + BINチャンク を書き込み
  return glb;
}

使い方

単体で使う場合

import { compressGLB } from './js/glb-compress.js';

// GLBファイルを読み込み
const response = await fetch('model.glb');
const glbBuffer = await response.arrayBuffer();

// 圧縮
const compressed = await compressGLB(glbBuffer, {
  quality: 0.75,    // WebP品質(0.0〜1.0)
  maxSize: 2048,    // テクスチャ最大サイズ
  onProgress: (stage, pct) => console.log(stage, pct + '%'),
});

// ダウンロード
const blob = new Blob([compressed], { type: 'model/gltf-binary' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'compressed.glb';
a.click();

WIRED ROOMのExportに組み込む場合

menu.jsexportRoom() 内で、GLBデータをZIPに入れる前に圧縮を挟む。

// ── 2. Room GLB (with texture compression) ──
let glbData = null;
if (config.isCustomGLB && config.customGLBKey) {
  glbData = await db.assets.get(config.customGLBKey);
} else {
  const glbPath = `./assets/room/${config.roomFile || 'room_blank.glb'}`;
  glbData = await fetchBinary(glbPath);
}
if (glbData) {
  if (statusEl) statusEl.textContent = 'Compressing GLB...';
  try {
    const { compressGLB } = await import('./js/glb-compress.js');
    const compressed = await compressGLB(glbData, {
      quality: 0.75,
      maxSize: 2048,
      onProgress: (stage, pct) => {
        if (statusEl) statusEl.textContent = `Compressing: ${stage} ${pct}%`;
      },
    });
    zip.file('public/room.glb', compressed);
  } catch (e) {
    console.warn('[EXPORT] GLB compression failed, using original:', e);
    zip.file('public/room.glb', glbData);
  }
}

圧縮に失敗した場合は元のGLBがそのまま使われる。


圧縮結果

実際にWIRED ROOMで使用しているモデルでの圧縮結果。

モデル元サイズ圧縮後削減率
Room Blank2.2 MB0.5 MB-77%
Living Room Interior3.6 MB1.1 MB-69%
duplex14 MB3.2 MB-77%
comfy living interior8.3 MB2.1 MB-75%
loft interior7.6 MB2.0 MB-74%

※ 数値は実測に合わせて調整してください。

コマンドラインの gltf-transform webp とほぼ同等の圧縮率。


gltf-transform との比較

項目gltf-transformglb-compress.js
動作環境Node.js / ブラウザ(要設定)ブラウザのみ
依存3パッケージ + sharp + Draco WASMなし
テクスチャ圧縮○(sharp経由)○(Canvas API)
Draco圧縮
テクスチャリサイズ
コード量ライブラリ全体で数万行約150行
ビルドツール推奨不要

Draco(メッシュジオメトリ圧縮)には対応していないが、ファイルサイズの大半を占めるテクスチャの圧縮だけで十分な効果がある。ジオメトリは全体の1〜2割程度なので、Dracoなしでもコマンドラインツールと同等の圧縮率になる。


技術的なポイント

OffscreenCanvas

通常の <canvas> ではなく OffscreenCanvas を使うことで、DOMに要素を追加せずにテクスチャの描画と変換ができる。Export処理中にUIをブロックしない。

WebP判定

既にWebPのテクスチャを二重変換しないように、RIFFヘッダーのマジックバイトでチェックしている。

function isWebP(bytes) {
  return bytes[0] === 0x52 && bytes[1] === 0x49 &&  // 'RI'
         bytes[2] === 0x46 && bytes[3] === 0x46 &&  // 'FF'
         bytes[8] === 0x57 && bytes[9] === 0x45 &&  // 'WE'
         bytes[10] === 0x42 && bytes[11] === 0x50;   // 'BP'
}

サイズ比較フォールバック

WebPへの変換が常にサイズ削減になるとは限らない。既に高圧縮なJPEGや、非常に小さいテクスチャではWebPの方が大きくなることがある。変換後のサイズが元より大きい場合は元のデータをそのまま使う。

4バイトアラインメント

GLBの仕様でBINチャンク内のデータは4バイト境界にアラインする必要がある。bufferViewの再構築時にパディングを挿入している。


ソースコード

完全なソースコードは以下。


まとめ

GLBのバイナリフォーマットはシンプルで、テクスチャの差し替えだけなら外部ライブラリは要らない。OffscreenCanvasconvertToBlob('image/webp') があればブラウザだけで完結する。

150行のコードで、20MBのGLBを3MBに圧縮できる。依存ゼロなので、Three.jsプロジェクトに限らず、GLBを扱うあらゆるWebアプリに組み込める。