Three.jsで3Dモデル(GLTF)を読み込む方法|箱を「自分のモデル」に差し替える

約20分
Three.jsで3Dモデル(GLTF)を読み込む方法|箱を「自分のモデル」に差し替える

みなさんこんにちは。フロントエンドエンジニアのしゅん(@shun_webdesign)です。

前回のThree.jsのマテリアル・テクスチャ・ライティング入門で、箱に色と質感と光を与えるところまでやりました。ここまでで「形を置く・光を当てる・見た目を決める」は一通り揃っています。

ただ、正直こう思いませんでしたか。「で、この箱はいつまで箱なんだ」 と。

今回でそこを卒業します。BlenderやSketchfabで用意した3Dモデルを読み込んで、箱と差し替える回です。世界(土台)はそのままで、置くモノだけが変わります。

その前に:毎回同じ準備を書くのをやめる

本題に入る前に、1回だけ寄り道させてください。

ここまでの記事では、Scene・Camera・Renderer・ライト・床を毎回 main.js の先頭に書いていました。短いから許容できていましたが、これから作るものが増えるほど、毎回20行の同じ準備を書くのは無駄です。

しかも、これまでの最小コードにはウィンドウをリサイズすると絵が歪むという弱点がありました(試すとわかります)。入門の邪魔になるので黙っていましたが、そろそろ直したい。

なので、土台を base.js に逃がします

// src/base.js ── シリーズ共通の土台
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";

export function createWorld({ environment = true, floor = true } = {}) {
  // --- 3要素(A2でやったところ) ---
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x15171c);

  const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 1000);
  camera.position.set(0, 1, 3); // 少し上から見下ろすと、床の影が見える

  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(innerWidth, innerHeight);
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); // 高解像度でも重くしすぎない
  renderer.shadowMap.enabled = true;
  document.body.appendChild(renderer.domElement);

  // --- マウスで視点を回せるように ---
  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;

  // --- ライト2灯(A3でやったところ) ---
  const ambient = new THREE.AmbientLight(0xffffff, 1.5);
  const directional = new THREE.DirectionalLight(0xffffff, 2);
  directional.position.set(2, 3, 4);
  directional.castShadow = true;
  directional.shadow.mapSize.set(1024, 1024);
  scene.add(ambient, directional);

  // --- 環境マップ(映り込み) ---
  if (environment) {
    const pmrem = new THREE.PMREMGenerator(renderer);
    scene.environment = pmrem.fromScene(new RoomEnvironment()).texture;
  }

  // --- 影を受ける床 ---
  if (floor) {
    const ground = new THREE.Mesh(
      new THREE.PlaneGeometry(20, 20),
      new THREE.MeshStandardMaterial({ color: 0x2a2d34, roughness: 1 })
    );
    ground.rotation.x = -Math.PI / 2;
    ground.position.y = -1;
    ground.receiveShadow = true;
    scene.add(ground);
  }

  // --- リサイズへの追従(毎回書くのが面倒だったやつ) ---
  addEventListener("resize", () => {
    camera.aspect = innerWidth / innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(innerWidth, innerHeight);
  });

  // --- アニメーションループ ---
  const clock = new THREE.Clock();
  const run = (update) => {
    renderer.setAnimationLoop(() => {
      controls.update();
      update?.(clock.getElapsedTime()); // 経過秒を渡す
      renderer.render(scene, camera);
    });
  };

  return { scene, camera, renderer, controls, run };
}

新しい話は何ひとつありません。A1〜A3でやったことを関数にまとめ、リサイズ対応とOrbitControlsを足しただけです。

これで main.js は「その回に置くモノ」だけになります。

import * as THREE from "three";
import { createWorld } from "./base.js";

const { scene, run } = createWorld();

// ここから下が、その回のテーマ
const cube = new THREE.Mesh(
  new THREE.BoxGeometry(),
  new THREE.MeshStandardMaterial({ color: 0x4a90d9, roughness: 0.3, metalness: 0.6 })
);
cube.castShadow = true;
scene.add(cube);

run((time) => {
  cube.rotation.y = time * 0.5;
});

今回やるのは、この cube の部分を読み込んだモデルに差し替えることだけです。

GLTFは「3D界のjpg」だと思っていい

3Dモデルのファイル形式は.obj.fbx.stlなど山ほどありますが、Webで使うならGLTF一択です。

Khronos Group(WebGLと同じ団体)が策定した、Web配信のために設計された形式で、Three.jsが最も素直に読める形でもあります。しかも運べる情報が多い。

GLTFが持っていける情報中身
メッシュ形(ジオメトリ)
マテリアル色・roughness・metalness(A3でやったPBRの値がそのまま入る
テクスチャ貼ってある画像
シーン構造親子関係。腕が体にぶら下がっている、など
アニメーションボーンの動き・位置や回転の変化
カメラ・ライト制作ソフト側で設定したもの

つまり「見た目の設定ごとまるっと持ってこられる」。読み込んだ瞬間にそれっぽく見えるのはこのおかげです。

拡張子が2種類あるので、そこだけ整理しておきます。

形式中身使いどころ
.gltfJSON本体 + 画像やバイナリが別ファイル中身を見たい・テクスチャだけ差し替えたいとき
.glb全部を1ファイルにまとめたバイナリ基本こっち。読み込みリクエストが1回で済む

迷ったら.glbです。ファイルが散らばらないぶん、パスのミスも起きません。

GLTFLoaderで読み込む

モデルはpublic/に置きます(Viteではpublic/の中身がそのままルートに配信されるので、/robot.glbで参照できます)。

import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";

const loader = new GLTFLoader();

loader.load(
  "/robot.glb",
  (gltf) => {
    scene.add(gltf.scene); // 読み込み完了
  },
  (e) => console.log(`${(e.loaded / e.total * 100).toFixed(0)}% 読み込み中`),
  (err) => console.error("読み込み失敗", err)
);

ポイントは2つあります。

  1. GLTFLoaderthree本体ではなくexamples/jsmから読む … Three.jsの追加機能はここに入っています。OrbitControlsと同じ場所です
  2. 読み込みは非同期load()はすぐ戻ってきて、完了したときにコールバックが呼ばれます。だから「読み込んだモデルに何かする」処理は、必ず第2引数の中に書きます

このコールバックの中のgltfが、ファイルの中身をまとめたオブジェクトです。

プロパティ中身
gltf.sceneモデル本体(Group)。ほぼこれしか使わない
gltf.animationsAnimationClipの配列。アニメが入っていれば中身がある
gltf.cameras制作ソフト側で設定されたカメラ
gltf.asset書き出したソフト名やバージョンなどの情報

ちなみにgltf.sceneMeshではなくGroupです。中に複数のMeshがぶら下がった「入れ物」だと思ってください。この事実が、あとで影のところで効いてきます。

「読み込んだのに何も見えない」の3大原因

たぶん最初はここで詰まります。僕も詰まりました。エラーは出ていないのに画面は真っ黒、というやつです。

原因はだいたい次の3つです。

① 大きさが桁違い

3Dモデルの単位はソフトや作者によってバラバラで、カメラの目の前に巨大な壁として鎮座しているか、豆粒すぎて見えないかのどちらかになります。

対処は、決め打ちでscaleをいじるのではなく、測ってから合わせるのがおすすめです。

const box = new THREE.Box3().setFromObject(model); // モデルを囲む箱を測る
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());

const scale = 1.6 / size.y;        // 高さを1.6くらいに揃える
model.scale.setScalar(scale);

Box3は「そのオブジェクトを囲む最小の箱」を計算してくれる道具です。高さ(size.y)を基準に割り算するだけで、どんなモデルが来ても同じ大きさに揃います。モデルを差し替えるたびに数値を探す作業から解放されるので、これは覚えておいて損がないです。

② 原点の位置が思っているところにない

モデルの原点(0,0,0)が足元にあるか、体の中心にあるかは作者次第です。中心が原点のモデルをy = 0に置くと、床にめり込みます。

これもさっき測ったboxを使えば解決します。

model.position.x = -center.x * scale;
model.position.z = -center.z * scale;
model.position.y = -1 - box.min.y * scale; // 床(y=-1)に足を着ける

box.min.yはモデルの一番下の位置なので、それを床の高さに合わせてやる、という計算です。

③ 単純に暗い

これは前回の回収です。GLTFのマテリアルは基本的にMeshStandardMaterial相当なので、光がなければ真っ黒になります。

base.jsを使っていればライトも環境マップも入っているので大丈夫ですが、自前で組んだシーンで「モデルが黒いシルエットになる」ときは、まず光を疑ってください。

影はtraverseで全メッシュに効かせる

さっき触れた「gltf.sceneはGroup」がここで効きます。

model.castShadow = true; // ❌ これでは影は出ない

GroupにcastShadowを立てても、実際に描画されるのは中のMeshなので何も起きません。中身を全部たどって、一つずつ立てる必要があります。

model.traverse((obj) => {
  if (obj.isMesh) {
    obj.castShadow = true;
    obj.receiveShadow = true;
  }
});

traverse()は、自分と子孫を全部まわしてくれるメソッドです。3Dモデルは「胴体・頭・腕…」のようなMeshの集合体なので、モデルに対して何かするときは基本この形になります。

ついでに言うと、traverseはマテリアルの一括差し替えにも使えます。読み込んだモデルを全部ワイヤーフレームにして構造を見る、みたいなデバッグができるので、覚えておくと便利です。

アニメーションを再生する

モデルにアニメーションが入っている場合、読み込んだだけでは動きません。再生装置(AnimationMixer)に繋いであげる必要があります。

let mixer = null;

loader.load("/robot.glb", (gltf) => {
  const model = gltf.scene;
  scene.add(model);

  if (gltf.animations.length > 0) {
    mixer = new THREE.AnimationMixer(model);
    mixer.clipAction(gltf.animations[0]).play(); // 最初のクリップを再生
  }
});

構造はこうなっています。

  • AnimationClip … 「歩く」「手を振る」といった動きの単位gltf.animationsに配列で入っている
  • AnimationMixer … そのモデル専用の再生装置。モデル1体につき1つ作る
  • AnimationActionclipAction()で作る再生コントローラplay()stop()fadeIn()などができる

そして、毎フレームmixer.update()を呼ばないと時間が進みません。ここで1つ注意点があります。

let prev = 0;

run((time) => {
  const delta = time - prev; // 前フレームからの差を出す
  prev = time;

  mixer?.update(delta);
});

mixer.update()が欲しいのは経過時間の合計ではなく差分(delta)です。base.jsrun()は「開始からの経過秒」を渡す作りなので、上のように自分で差を取ります。

ここを間違えて合計値を渡すと、アニメーションが猛烈に早送りされて一瞬で終わります(僕は最初これをやりました)。

重いモデルは圧縮する

3Dモデルは平気で数MB〜数十MBになります。ローカルでは一瞬でも、モバイル回線では致命的です。

軽くする手段は2つあり、どちらもGLTFの標準拡張として用意されています。

圧縮何を圧縮するか読み込み側の追加
Draco形(メッシュ)DRACOLoader
KTX2 / Basisテクスチャ画像KTX2Loader

Draco圧縮されたモデルは、デコーダを繋いでおかないと読めません。とはいえ、追加はほぼ2行です。

import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";

const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath("https://www.gstatic.com/draco/versioned/decoders/1.5.7/");

const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);

圧縮そのものは書き出し側の仕事です。Blenderのエクスポート設定でDracoにチェックを入れるか、gltf-transformのようなCLIを通すのが手軽です。数MBが数百KBになることも珍しくないので、公開前には一度かけておくと安心できます。

モデルはどこから持ってくるか

自分で作れなくても素材はあります。まずは既製品で読み込みを練習するのが早いです。

入手先特徴
Poly HavenCC0(クレジット不要)。HDRIも同じ場所で揃う
Sketchfab数が圧倒的。Downloadableかつライセンス表記を必ず確認
Khronosの公式サンプル動作確認用の定番。壊れた実装を切り分けるのに便利
Blender自作するなら。エクスポート時にglTF 2.0を選ぶだけ

ライセンスだけは油断しないでください。作品として公開したりSNSに載せたりするなら、クレジット表記の要否は毎回確認するのが結局いちばん安全です。

よくあるつまずき

症状原因対処
何も表示されない大きさ・位置Box3で測って正規化する
真っ黒なシルエット光がないライト or 環境マップを置く
影が落ちないGroupに設定しているtraverseで中のMeshにcastShadow
アニメが動かないmixer.update()を呼んでいないループ内でdeltaを渡す
アニメが一瞬で終わる経過秒の合計を渡している前フレームとの差分を渡す
404で読めないパスの誤りViteならpublic/に置いて/xxx.glb
妙に重い・カクつく未圧縮Draco / KTX2をかける

まとめ

  • Webで3Dモデルを扱うならGLTF(基本は.glb。形も質感もアニメも1ファイルで運べる
  • 読み込みはGLTFLoader非同期なので、モデルへの処理はコールバックの中に書く
  • 見えないときは大きさ・位置・光の3つを疑う。Box3で測って合わせるのが定石
  • gltf.sceneMeshの集合体。影やマテリアルの操作はtraverseで中身に効かせる
  • アニメはAnimationMixerに繋いで、毎フレームdeltaupdate()
  • 公開前にDraco / KTX2で圧縮する

これで、Three.jsの基礎編は一区切りです。世界を作る → 見た目を決める → 好きなモノを置くまで来たので、あとは「何を作るか」の話になります。

  • 用意されたマテリアルでは出せない表現をしたくなったら → GLSL入門でシェーダーを自分で書く世界へ
  • 全体のロードマップは → Three.js入門ガイド

📕 もっと手を動かして体系的に学びたい人へ

この記事のような「仕組みを理解しながら小さく動かす」を積み重ねて、作品づくりまで導くKindle本を書きました。
『Three.jsでつくる、小さなWebGL表現 ── はじめての”作品づくり”ガイド』
環境づくりから、モデルや質感を組み合わせて自分の作品にまとめ上げるところまで順を追って解説しています。

👉 Amazonで見る »


僕もXでThree.jsの作品や知見を発信しているので、よかったら@shun_webdesignを覗いてもらえると嬉しいです。それでは、よいThree.jsライフを🌊

しゅん

しゅん

フロントエンドエンジニア / Webデザイナー。 アメリカ カリフォルニア州生まれ。 音楽、映画、芸術を中心としたサブカルが大好き。 お仕事のご相談は下記リンクのポートフォリオの連絡先からお願いします。

← 記事一覧に戻る