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の欠落では、成功表示にならずエラーを表示しています。まず手元のモデルで「取得」「中央に表示」「最初のクリップ」の三つを順に確認すれば、次に調べる箇所を絞れます。