Three.jsをはじめよう。GLTFLoaderによる外部モデルの読み込み【第5回】
これまで私たちは、Three.jsの中で立方体や球体といった「基本図形」を組み合わせて学んできました。しかし、WEB制作の現場では、「自社の新製品の靴を表示させたい」「マスコットキャラクターを歩かせたい」といった、より複雑で魅力的な3Dモデルを扱うことがほとんどです。
「Blenderなどの3Dソフトで作ったモデルを、どうやってブラウザに持ってくるのか?」
一見、非常に高い壁に見えるかもしれませんが、安心してください。Three.jsはローダーを使うことで、多くのモデルデータ形式の外部モデルを読み込むことが可能です。
第5回となる今回は、3D業界の標準形式である「GLTF」の扱い方をマスターしましょう。アヒルやハンバーガー、あるいはあなたがデザインしたオリジナルのモデルが、ブラウザに表示できるまで、一緒に一歩ずつ進んでいきましょう。
3D界のJPEG?「GLTF / GLB」を理解する
まず最初に知っておくべきは、ファイル形式についてです。かつてはOBJやFBXといった形式が使われてきましたが、現在のWEB制作において、特別な理由がない限り選ぶべきは「GLTF(ジーエルティーエフ)」です。
なぜGLTFが選ばれるのか
GLTFは「3DにおけるJPEG」を目指して開発された形式です。
Webやモバイル表示に最適化された軽量性や高速性、PBR(物理ベースレンダリング)の対応、オープン標準と高い相互運用性により選ばれています。
- 軽量
- データの構造がWEB向けに最適化されており、読み込みが速い。
- 多機能
- 形状だけでなく、質感(マテリアル)やアニメーション情報も一つのファイルにまとめられます。
.gltf と .glb の違い
- .gltf
- テキスト形式(JSON)のファイルと、テクスチャ画像などがバラバラになった構成。中身を書き換えやすいのが特徴です。
- .glb
- すべてを一つに凝縮したバイナリ形式。ファイルが一つで済むため、WEBサイトでの管理が非常に楽です。プロの現場では、この <strong>.glb</strong> 形式が最も多用されます。
実際にGLTFLoaderでモデルを読み込む
それでは、モデルを画面に登場させましょう。今回は、Three.js公式が提供している「GLTFLoader」というツールを使います。
ローダーの準備
まずはローダーをインポートし、インスタンス(実体)を作成します。
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
const loader = new GLTFLoader();
モデルをロードする
次に、モデルのパスを指定して読み込みます。ここで重要なのは、ロード処理は「非同期(あとから完了する)」であるという点です。
loader.load(
'/models/my_model.glb', // モデルのパス
(gltf) => {
// 読み込みが成功した時の処理
const model = gltf.scene;
scene.add(model);
console.log('モデルが読み込まれました!');
},
(xhr) => {
// 読み込み中の進捗(%)を表示
console.log((xhr.loaded / xhr.total * 100) + '% loaded');
},
(error) => {
// エラーが発生した時の処理
console.error('エラーが発生しました', error);
}
);
「モデルが表示されない!」を解決する
いざコードを書いても、画面が真っ暗なまま……という状況によく遭遇しました。そんな時にチェックすべき「3つのチェックリスト」を一緒に確認しましょう。
サイズ(Scale)が極端ではないか
Blenderでの「1」とThree.jsでの「1」は同じ単位(通常は1メートル)ですが、書き出し時の設定によっては、モデルが巨大すぎてカメラを突き抜けたり、逆に米粒よりも小さくて見えないことがあります。
- 対策:
model.scale.set(0.1, 0.1, 0.1)などでサイズを調整してみましょう。
位置(Position)がズレていないか
モデルの中心(原点)がカメラの視界の外にある可能性があります。
- 対策:
model.position.set(0, 0, 0)で中央にリセットし、前回学んだOrbitControlsで周囲をよく探してみましょう。
ライト(Light)は足りているか
第3回で学んだ通り、MeshStandardMaterial を使用しているモデルは、ライトがないと真っ黒になります。
- 対策:
強いAmbientLightやDirectionalLightを追加して、まずは見えるかどうかを確認しましょう。
サイトを重くしないための「Draco圧縮」
企業サイトにおいて、3Dモデルの読み込みに10秒もかかってはユーザーは離脱してしまいます。そこで必須となるのが「Draco(ドラコ)」という圧縮技術です。
Draco圧縮の効果
Googleが開発したこの技術を使うと、3Dモデルのメッシュデータを劇的に(時には10分の1以下に)軽量化できます。
実装方法
Dracoで圧縮されたモデルを読み込むには、専用のデコーダーをセットする必要があります。
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/'); // デコーダーファイルの場所を指定
loader.setDRACOLoader(dracoLoader);
制作会社としての腕の見せ所は、このように「美しさを保ちつつ、いかに軽く実装するか」という最適化の技術にあります。
応用編:モデルの中身(Scene)を操作する
読み込んだ gltf.scene は、実はたくさんのパーツが組み合わさったグループのようなものです。
特定のパーツだけ色を変える
例えば「車のモデルを読み込んで、タイヤだけ黒くしたい」といった場合、traverse という命令を使って中身を一つずつ調べることができます。
gltf.scene.traverse((child) => {
if (child.isMesh && child.name === 'Tire') {
child.material.color.set(0x000000);
}
});
このようにプログラム側からモデルを操作できるようになると、WEBサイト上でのカラーシミュレーター(車の色を選べる機能など)が作れるようになります。
実際に表示してみる
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { DRACOLoader } from "three/addons/loaders/DRACOLoader.js";
import { GUI } from "lil-gui";
// シーン設定
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x333333);
scene.fog = new THREE.Fog(0x333333, 10, 15);
// カメラ設定
const camera = new THREE.PerspectiveCamera(
40,
window.innerWidth / window.innerHeight,
0.1,
100
);
camera.position.set(4.25, 1.4, -4.5);
// レンダラー設定
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.85;
renderer.outputEncoding = THREE.sRGBEncoding;
document.body.appendChild(renderer.domElement);
// ライト設定
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xffffff, 2.0);
sunLight.position.set(5, 8, 5);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(2048, 2048);
sunLight.shadow.camera.near = 0.5;
sunLight.shadow.camera.far = 50;
sunLight.shadow.camera.left = -10;
sunLight.shadow.camera.right = 10;
sunLight.shadow.camera.top = 10;
sunLight.shadow.camera.bottom = -10;
scene.add(sunLight);
const fillLight = new THREE.DirectionalLight(0xffffff, 0.8);
fillLight.position.set(-5, 5, -5);
scene.add(fillLight);
// グリッドヘルパー
const grid = new THREE.GridHelper(20, 40, 0xffffff, 0xffffff);
grid.material.opacity = 0.2;
grid.material.depthWrite = false;
grid.material.transparent = true;
scene.add(grid);
// DRACOLoaderの設定
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(
"https://www.gstatic.com/draco/versioned/decoders/1.5.6/"
);
// GLTFLoaderの設定
const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);
let loadedModel;
let carBody;
const wheels = [];
// マテリアル定義
const bodyMaterial = new THREE.MeshPhysicalMaterial({
color: 0xff0000,
metalness: 1.0,
roughness: 0.5,
clearcoat: 1.0,
clearcoatRoughness: 0.03,
});
const detailsMaterial = new THREE.MeshStandardMaterial({
color: 0xffffff,
metalness: 1.0,
roughness: 0.5,
});
const glassMaterial = new THREE.MeshPhysicalMaterial({
color: 0xffffff,
metalness: 0.25,
roughness: 0,
transmission: 1.0,
});
const modelPath = "https://threejs.org/examples/models/gltf/ferrari.glb";
loader.load(modelPath, (gltf) => {
const carModel = gltf.scene.children[0];
carBody = carModel.getObjectByName("body");
if (carBody) {
carBody.material = bodyMaterial;
}
const rimNames = ["rim_fl", "rim_fr", "rim_rr", "rim_rl"];
rimNames.forEach((name) => {
const rim = carModel.getObjectByName(name);
if (rim) {
rim.material = detailsMaterial;
}
});
const trim = carModel.getObjectByName("trim");
if (trim) {
trim.material = detailsMaterial;
}
const glass = carModel.getObjectByName("glass");
if (glass) {
glass.material = glassMaterial;
}
const wheelNames = ["wheel_fl", "wheel_fr", "wheel_rl", "wheel_rr"];
wheelNames.forEach((name) => {
const wheel = carModel.getObjectByName(name);
if (wheel) {
wheels.push(wheel);
}
});
carModel.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
loadedModel = carModel;
scene.add(carModel);
setupGUI();
});
// GUI設定
function setupGUI() {
const gui = new GUI();
const folderTransform = gui.addFolder("モデル操作");
folderTransform
.add(loadedModel.position, "y", -1, 3, 0.01)
.name("高さ");
folderTransform
.add(loadedModel.rotation, "y", 0, Math.PI * 2, 0.01)
.name("回転");
folderTransform
.add(loadedModel.scale, "x", 0.1, 2, 0.1)
.name("スケール")
.onChange((value) => {
loadedModel.scale.set(value, value, value);
});
folderTransform.open();
if (carBody) {
const colorFolder = gui.addFolder("車体カラー");
const colorParams = {
bodyColor: bodyMaterial.color.getHex(),
detailsColor: detailsMaterial.color.getHex(),
glassColor: glassMaterial.color.getHex(),
};
colorFolder
.addColor(colorParams, "bodyColor")
.name("ボディカラー")
.onChange((value) => {
bodyMaterial.color.set(value);
});
colorFolder
.addColor(colorParams, "detailsColor")
.name("リム・トリムカラー")
.onChange((value) => {
detailsMaterial.color.set(value);
});
colorFolder
.addColor(colorParams, "glassColor")
.name("ガラスカラー")
.onChange((value) => {
glassMaterial.color.set(value);
});
colorFolder.open();
}
const lightFolder = gui.addFolder("ライト設定");
lightFolder
.add(sunLight, "intensity", 0, 5, 0.1)
.name("太陽光の強さ");
lightFolder
.add(ambientLight, "intensity", 0, 2, 0.1)
.name("環境光の強さ");
const renderFolder = gui.addFolder("レンダー設定");
renderFolder
.add(renderer, "toneMappingExposure", 0, 3, 0.1)
.name("露出");
renderFolder.add(grid, "visible").name("グリッド表示");
const animFolder = gui.addFolder("アニメーション");
const animParams = {
wheelSpeed: 2.0,
gridSpeed: 1.0,
autoRotate: false,
};
animFolder
.add(animParams, "wheelSpeed", 0, 10, 0.1)
.name("ホイール回転速度");
animFolder
.add(animParams, "gridSpeed", 0, 5, 0.1)
.name("グリッド移動速度");
animFolder
.add(animParams, "autoRotate")
.name("車体を自動回転")
.onChange((value) => {
controls.autoRotate = value;
controls.autoRotateSpeed = value ? 2.0 : 0;
});
window.animParams = animParams;
}
// OrbitControls設定
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.target.set(0, 0.5, 0);
controls.maxDistance = 9;
controls.maxPolarAngle = THREE.MathUtils.degToRad(90);
controls.update();
// ウィンドウリサイズ対応
window.addEventListener("resize", () => {
renderer.setSize(window.innerWidth, window.innerHeight);
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
});
// アニメーションループ
function animate() {
requestAnimationFrame(animate);
const time = -performance.now() / 1000;
if (window.animParams) {
const speed = window.animParams.wheelSpeed;
for (let i = 0; i < wheels.length; i++) {
wheels[i].rotation.x = time * Math.PI * speed;
}
grid.position.z = -(time * window.animParams.gridSpeed) % 1;
} else {
for (let i = 0; i < wheels.length; i++) {
wheels[i].rotation.x = time * Math.PI * 2;
}
grid.position.z = -time % 1;
}
controls.update();
renderer.render(scene, camera);
}
animate();
</script>
では、前回までの知識を元に、表示してみましょう。
3Dモデルで一気に拡張される使い道
お疲れ様でした!ついにあなたのWEBサイトに、立方体や球体といった「基本図形」ではない、オリジナルの3Dアセットが登場しました。 基本図形の組み合わせとは違い、外部モデルやオリジナルのモデルを使うことで、サイトの表現力は一気に「使えるクオリティ」へと昇華されます。
今回の学びのポイント
- GLB形式
- WEBに最適な、一つにまとまった3Dファイル。
- GLTFLoader
- 外部ファイルを読み込むための必須ツール。
- 非同期処理
- ロード完了を待ってから scene.add する
- 軽量化
- Draco圧縮などを使い、ユーザーを待たせない工夫
さて、モデルが表示できるようになったら、次はそれに「触れて」みたくなりますよね。 画面上のモデルをクリックしたら色が変わる、あるいは解説が表示される……。
次回、第6回では「クリック判定とインタラクション(Raycaster)」について学びます。眺めているだけの3Dから、反応のある3Dサイトへと進化させていきましょう。
それでは、また次回!