WebGL & Three.js

Three.jsでglTF・GLBを読み込む|GLTFLoaderの表示・サイズ調整・アニメーション

GLTFLoaderでモデルを読み込み、Box3と親Groupでサイズと中心を調整。AnimationMixerの再生・切替、Dracoなどの設定、モデルが見えないときの確認順を実行例で解説します。

この記事の目次
  1. GLBとglTFを、参照先ごと配置する
  2. 元の座標を保ちながら、モデルを中央へ収める
  3. ライト・カメラ・動きの選択を含む画面を作る
  4. ロード完了と進捗表示を混同しない
  5. clipをplayするだけでは、時間は進まない
  6. Draco・KTX2・Meshoptは、必要な拡張に合わせて追加する
  7. モデルが見えないときは、止まった段階を確認する

GLBの取得は成功しているのに、Three.jsの画面に何も見えない。表示できたと思ったら、Blenderで付けた動きが止まったまま。モデルを読み込むときは、取得、Sceneへの追加、カメラへの収め方、アニメーションの更新を分けると原因を追えます。

glTF・GLBの入口はGLTFLoaderです。返り値そのものではなくgltf.sceneを表示用Sceneへ追加します。動きはgltf.animationsから選び、AnimationMixerを毎フレーム進めます。

import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const loader = new GLTFLoader();
const gltf = await loader.loadAsync('/models/demo.glb');
scene.add(gltf.scene);

この短い部分には、カメラやRendererの準備は含まれていません。以下ではThree.js 0.186.0を使い、一つのモデルを中央へ収め、動きを切り替える画面まで作ります。複製したモデル同士の扱いはObject3D・glTFの複製方法で扱っています。

GLBとglTFを、参照先ごと配置する

まず、表示する権利のある手元のモデルを用意します。最初は圧縮拡張を使わないGLBから始めると、デコーダー設定を分けて確認できます。次の例ではファイル名をdemo.glbとし、public/models/へ置きます。

gltf-viewer/
  index.html
  main.js
  model-utils.js
  public/
    models/
      demo.glb

JSON形式の.gltfを使う場合は、参照される.binや画像も一緒に配置します。ファイル名や相対パスを勝手に変えると、JSONだけ取得できても必要なデータが見つからなくなります。GLBも、拡張子だけを見て外部参照が一切ないとは判断せず、書き出し設定とNetworkの要求を確認してください。KhronosのglTF仕様でも、GLBから外部リソースを参照できる形が定義されています。

Viteを使う空の作業ディレクトリで、以下を実行します。ここではNode.js 24.13.0で確認しました。

npm init -y
npm install --save-exact three@0.186.0
npm install --save-dev --save-exact vite@7.3.6
npx vite --host 127.0.0.1 --port 5211

HTMLはファイルとして直接開かず、表示されたHTTPのURLから開きます。public/models/demo.glbのURLは/models/demo.glbです。URLへpublicは付けません。この例はサイトのルート配下への配置を前提にしています。サブディレクトリへ公開するときは、モデルとデコーダーのURLも公開先に合わせてください。

スポンサーリンク

元の座標を保ちながら、モデルを中央へ収める

モデルが見えない原因の一つは、カメラから遠い座標や想定外の大きさです。そこでBox3で初期状態の範囲を測り、最長辺を2にそろえます。

注意したいのは、アニメーション対象のpositionやscaleを直接補正しないことです。Mixerがその値を更新すると、補正が上書きされる場合があります。以下は元モデルの外側に二つのGroupを置き、内側で中心を引き、外側で縮尺を変えます。

model-utils.jsを作成してください。後半の解放処理は、この画面だけが所有するモデル向けです。

import * as THREE from 'three';

export function normalizeModel(root) {
  root.updateMatrixWorld(true);
  const box = new THREE.Box3().setFromObject(root);
  const size = box.getSize(new THREE.Vector3());
  const longest = Math.max(size.x, size.y, size.z);
  if (box.isEmpty() || !Number.isFinite(longest) || longest <= 0) {
    throw new Error('表示できる大きさのモデルがありません');
  }
  const center = box.getCenter(new THREE.Vector3());
  const offset = new THREE.Group();
  offset.position.copy(center).multiplyScalar(-1);
  offset.add(root);
  const normalized = new THREE.Group();
  normalized.scale.setScalar(2 / longest);
  normalized.add(offset);
  return normalized;
}

// この画面だけが所有する一つのモデルを解放する。
// 他の画面・cloneと共有しているresourceには、そのまま使わない。
export function disposeModel(root) {
  const geometries = new Set(), materials = new Set(), textures = new Set();
  const skeletons = new Set(), images = new Set();
  root.traverse(object => {
    if (object.geometry) geometries.add(object.geometry);
    if (object.skeleton) skeletons.add(object.skeleton);
    for (const material of [].concat(object.material ?? [])) {
      materials.add(material);
      for (const value of Object.values(material)) if (value?.isTexture) textures.add(value);
    }
  });
  for (const texture of textures) {
    for (const image of [].concat(texture.source?.data ?? [])) images.add(image);
    texture.dispose();
  }
  for (const image of images) if (typeof image.close === 'function') image.close();
  for (const material of materials) material.dispose();
  for (const geometry of geometries) geometry.dispose();
  for (const skeleton of skeletons) skeleton.dispose();
}

自作の検証モデルは、元の位置が(100, 20, -30)で、寸法の大きいモデルです。上の処理を通すと表示範囲は2 × 2 × 2、中心は原点になり、元のpositionは変わりませんでした。さらにアニメーションを進めても、元の座標系の動きがラッパーの中で保たれました。

測るのは読み込み直後の姿勢です。大きく歩き回る、腕を広く伸ばすなど、後の動きの全範囲まで保証する処理ではありません。実際のクリップを再生して余白を調整してください。実寸が必要な建築・商品比較の場面では、この縮尺正規化をそのまま使わず、単位とカメラを合わせます。

ライト・カメラ・動きの選択を含む画面を作る

index.htmlに、描画先と動きを選ぶ操作を置きます。

<!doctype html>
<html lang="ja"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>glTF表示の確認</title>
<style>*{box-sizing:border-box}body{margin:0;padding:20px;font-family:sans-serif;background:#f5f7fa;color:#172336}main{max-width:900px;margin:auto}#stage{height:440px;width:100%;margin:16px 0}canvas{display:block;width:100%;height:100%}select,button{font:inherit;padding:8px;max-width:100%}#status{min-height:3em;overflow-wrap:anywhere}h1{font-size:1.5rem}</style></head>
<body><main><h1>glTFモデルを表示する</h1><p id="status" role="status">準備中</p><label>動き <select id="clip" disabled><option>読み込み前</option></select></label> <button id="stop" disabled>停止</button><div id="stage"></div><p>ドラッグで回転、スクロールで拡大できます。</p></main><script type="module" src="/main.js"></script></body></html>

次がmain.jsの全体です。PBR系の材質を見られるようライトを置き、縦長の画面では横方向の画角も考えてカメラを離します。動きがないモデルも表示し、選択欄を無効にします。

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { normalizeModel, disposeModel } from './model-utils.js';

const stage = document.querySelector('#stage');
const status = document.querySelector('#status');
const select = document.querySelector('#clip');
const stop = document.querySelector('#stop');
const scene = new THREE.Scene();
scene.background = new THREE.Color('#152337');
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 100);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
stage.append(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
scene.add(new THREE.HemisphereLight(0xffffff, 0x334455, 2));
const light = new THREE.DirectionalLight(0xffffff, 3);
light.position.set(3, 5, 4);
scene.add(light);
let active = true, root = null, normalized = null, mixer = null;
let clips = [], radius = 1.8, previous = null;

function resize() {
  const { width, height } = stage.getBoundingClientRect();
  if (!width || !height) return;
  renderer.setSize(width, height, false);
  camera.aspect = width / height;
  const vertical = THREE.MathUtils.degToRad(camera.fov);
  const horizontal = 2 * Math.atan(Math.tan(vertical / 2) * camera.aspect);
  const distance = radius / Math.sin(Math.min(vertical, horizontal) / 2) * 1.6;
  camera.position.set(1, 0.6, 1.3).normalize().multiplyScalar(distance);
  camera.near = 0.01;
  camera.far = Math.max(100, distance * 10);
  camera.updateProjectionMatrix();
  controls.target.set(0, 0, 0);
  controls.update();
}
const observer = new ResizeObserver(resize);
observer.observe(stage);
resize();

const manager = new THREE.LoadingManager();
manager.onProgress = (_url, loaded, total) => {
  if (active) status.textContent = `取得したリソース ${loaded} / ${total} 件`;
};
const loader = new GLTFLoader(manager);
// 自分のモデルへ替えるときは、このURLを変更する。
const model = new URLSearchParams(location.search).get('model') ?? 'demo.glb';
const allowed = ['demo.glb', 'demo.gltf', 'static.glb', 'missing.glb'];
const url = '/models/' + (allowed.includes(model) ? model : 'demo.glb');

function playClip() {
  if (!mixer) return;
  mixer.stopAllAction();
  const clip = clips[Number(select.value)];
  if (clip) mixer.clipAction(clip).reset().play();
}
function stopClip() { mixer?.stopAllAction(); }
select.addEventListener('change', playClip);
stop.addEventListener('click', stopClip);

export const ready = loader.loadAsync(url).then(gltf => {
  if (!active) { disposeModel(gltf.scene); return; }
  root = gltf.scene;
  normalized = normalizeModel(root);
  scene.add(normalized);
  const sphere = new THREE.Box3().setFromObject(normalized).getBoundingSphere(new THREE.Sphere());
  radius = sphere.radius;
  resize();
  clips = gltf.animations;
  mixer = new THREE.AnimationMixer(root);
  select.replaceChildren(...clips.map((clip, index) => new Option(clip.name || `動き ${index + 1}`, String(index))));
  select.disabled = stop.disabled = clips.length === 0;
  if (clips.length) { select.value = '0'; playClip(); }
  else select.add(new Option('アニメーションなし', ''));
  status.textContent = `表示しました。アニメーション ${clips.length} 件`;
}).catch(error => {
  if (active) status.textContent = '読み込めませんでした。モデルと関連ファイルのURLを確認してください。';
  console.error(error);
});

renderer.setAnimationLoop(time => {
  const delta = previous === null ? 0 : Math.min((time - previous) / 1000, 0.05);
  previous = time;
  mixer?.update(delta);
  renderer.render(scene, camera);
});

export function dispose() {
  if (!active) return;
  active = false;
  renderer.setAnimationLoop(null);
  observer.disconnect();
  controls.dispose();
  select.removeEventListener('change', playClip);
  stop.removeEventListener('click', stopClip);
  mixer?.stopAllAction();
  if (root) { mixer?.uncacheRoot(root); disposeModel(root); }
  if (normalized) scene.remove(normalized);
  renderer.dispose();
  renderer.domElement.remove();
}
// 戻る/進むで復帰した場合は、破棄済み画面を再初期化する。
addEventListener('pagehide', dispose, { once: true });
addEventListener('pageshow', event => { if (event.persisted) location.reload(); });

ロード完了と進捗表示を混同しない

LoadingManagerのonProgressで表示しているのは、管理対象リソースの件数です。ダウンロード済みバイトの割合でも、描画準備の進み具合でもありません。関連ファイルが分かるにつれて総件数が変わる場合もあるため、この例ではパーセントにせず件数を表示しています。取得管理の仕様はLoadingManagerの公式説明を参照してください。

個別ファイルの転送量を出すならloadAsync(url, onProgress)のコールバックでProgressEventを扱えますが、総量が得られないレスポンスもあります。lengthComputableやtotalを確認し、総量不明のときに割り算しないようにします。

clipをplayするだけでは、時間は進まない

clipAction(clip).play()は再生を有効にする操作です。実際の動きには、描画ループ内のmixer.update(delta)が必要です。deltaは秒なので、この例ではミリ秒の差を1,000で割っています。AnimationMixerの公式説明にも、更新間隔を秒で渡す形が示されています。

クリップを切り替えるときは、前のactionを止めてから新しいactionをリセットして再生しています。複数の動きを混ぜるクロスフェードは含めていません。「停止」ではactionを止め、アニメーションで変えた値が元の状態へ戻ります。一時停止して同じ姿勢を維持したい場合とは動作が異なります。

この画面をSPAの部品として使うときは、部品を外す際にdispose()を呼びます。今回の例は一つのモデルを一度読む構成です。モデルを連続で選び直す画面へ拡張する場合は、古い読み込み結果の取り扱いと、共有リソースの所有者も設計してください。

Draco・KTX2・Meshoptは、必要な拡張に合わせて追加する

GLTFLoaderを使えば、すべての圧縮データを追加設定なしで読めるわけではありません。モデルが使う拡張に合わせ、読み込みを始める前に設定します。

モデルが使うもの 設定 役割
KHR_draco_mesh_compression setDRACOLoader Dracoで圧縮したメッシュを復号する
KHR_texture_basisu setKTX2Loader 対応するKTX2テクスチャーを扱う。rendererでdetectSupportを先に呼ぶ
EXT_meshopt_compression setMeshoptDecoder Meshoptで圧縮したバッファーを復号する

compressed-loaders.jsとして分けるなら、次の形です。使わないものはfalseのままにします。

import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';

// 必要な拡張だけを有効にし、loadAsyncより前に一度呼ぶ。
export function configureCompression(loader, renderer, manager, { draco = false, ktx2 = false, meshopt = false } = {}) {
  const dracoLoader = draco ? new DRACOLoader(manager).setDecoderPath('/decoders/draco/') : null;
  const ktx2Loader = ktx2 ? new KTX2Loader(manager).setTranscoderPath('/decoders/basis/').detectSupport(renderer) : null;
  if (dracoLoader) loader.setDRACOLoader(dracoLoader);
  if (ktx2Loader) loader.setKTX2Loader(ktx2Loader);
  if (meshopt) loader.setMeshoptDecoder(MeshoptDecoder);
  return () => { dracoLoader?.dispose(); ktx2Loader?.dispose(); };
}

Dracoだけ必要な場合は、main.jsのimportへ次を追加します。

import { configureCompression } from './compressed-loaders.js';

const loader = new GLTFLoader(manager);の直後、loadAsyncを呼ぶ前に設定します。

const releaseDecoders = configureCompression(loader, renderer, manager, {
  draco: true,
  ktx2: false,
  meshopt: false,
});

必要なデコーダーは、同じバージョンのThree.jsパッケージから、次の場所へコピーします。Dracoのディレクトリはgltf配下を使い、WASMとラッパーをセットで配置します。KTX2を使う場合だけbasis側も必要です。

node_modules/three/examples/jsm/libs/draco/gltf/
  → public/decoders/draco/ (中のファイルを一式)
node_modules/three/examples/jsm/libs/basis/
  → public/decoders/basis/ (中のファイルを一式)

画面を破棄するときは、モデルの読み込み・復号が終わった後にreleaseDecoders()でworkerなどを解放します。今回のdispose()の末尾へ、void ready.finally(releaseDecoders);を追加する形なら、途中で画面を離れた場合もreadyが落ち着くまで待ってから解放できます。再利用中のloaderに対して毎回disposeしないでください。

自作モデルをDraco圧縮した検証では、未設定時にNo DRACOLoader instance providedが出て、設定後は同じ2クリップを含めて読み込めました。KTX2とMeshoptの上のコードは、公式APIとimportを確認した設定例です。この検証モデルには含まれておらず、圧縮テクスチャーやMeshoptの実データ復号は今回の実測範囲に入っていません。

対応拡張と設定方法はGLTFLoader、デコーダーの配置と解放はDRACOLoaderとKTX2Loaderの公式説明で確認できます。Dracoによる転送サイズの圧縮と、描画のフレームレート改善は別の問題です。

モデルが見えないときは、止まった段階を確認する

症状 先に見るもの 切り分け方
モデルURLが404 NetworkのURL publicの有無、ファイル名の大文字小文字、公開先のベースパスを確認
.gltfは200だが失敗 .bin・画像の要求 参照される相対パスのまま配置できているかを見る
200なのにJSON/バイナリ解析で失敗 レスポンスの実体 存在しないURLへHTMLのトップページが返っていないかを見る
別ドメインでのみ失敗 CORSのエラー モデルと関連ファイルの配信元が読み取りを許可しているか確認
読み込めたが画面外 Box3・camera・near/far 大きさと中心、カメラの向き、切り取られる距離を確認
黒い・材質の見え方が違う ライト・環境・材質 PBR材質へ光が当たっているか、必要な環境マップがあるかを見る
モデルはあるが動かない animationsとMixer クリップが書き出されているか、playと毎フレームのupdateがあるかを見る
圧縮版だけ読めない 使う拡張・decoderのNetwork 必要なloaderとWASM/ラッパーの配置を確認

GLTFLoaderはglTFの材質情報を扱うので、暗く見えるからといって全テクスチャーを一律にsRGBへ書き換えないでください。色を表す画像と、法線・粗さなどのデータでは扱いが異なります。手動で画像を貼る基本はTextureLoaderで画像や動画を設定する方法へ分けています。

今回の確認では、Chromeの390px・1440px幅でGLBの表示と動きの切替・停止ができ、外部.binを使うglTF、アニメーションなしのGLBも読み込めました。モデル自体の404と.binの欠落では、成功表示にならずエラーを表示しています。まず手元のモデルで「取得」「中央に表示」「最初のクリップ」の三つを順に確認すれば、次に調べる箇所を絞れます。

スポンサーリンク