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 を使用しているモデルは、ライトがないと真っ黒になります。

  • 対策
    強い AmbientLightDirectionalLight を追加して、まずは見えるかどうかを確認しましょう。

サイトを重くしないための「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サイトへと進化させていきましょう。

それでは、また次回!