[Next.js #16] R3FでGLTFモデルを横スライド切替する3Dビューアを実装する

1. はじめに

reddit に投稿されていた three.js のカースライドビューアーを見て、 「実装してみたい」と思ったのがきっかけ。

ただモデルを表示するだけではなく、 横にスライドして切り替わる 3D ショーケース。

Web 上で、インタラクティブにモデルを眺められる空間を作る。

今回は Next.js + React Three Fiber を使い、 複数の GLTF モデルを横にスライド切替できる 3D ギャラリービューアを実装。

ポイントは以下の3つ。

  • モデルごとのスケール差を吸収する「正規化」
  • 地面に正しく接地させるバウンディング処理
  • index ベースで横に並べ、lerp で滑らかに移動させる設計

単なるモデル表示ではなく、 「見せるための構造」を意識した実装を行う。

モデルデータは以下からお借りしています。

creator: "Jelvehkar",
web: "https://sketchfab.com/alihoseini13000",

creator: "Unity Fan youtube channel",
web: "https://sketchfab.com/unityfan777",

前回の記事:

2. モデル配列で管理する

今回のビューアでは、モデルを直接ベタ書きしない。

代わりに models[] 配列で管理する。

const models = [
  {
    name: "Peugeot 206",
    url: "/models/car-peugeot_206.glb",
    scale: 1.0,
    offsetY: 0,
    creator: "Jelvehkar",
    web: "https://sketchfab.com/alihoseini13000",
  },
  ...
];

ここで重要なのは、

ロジックとデータを分離していること。

各プロパティの役割

  • name 表示用ラベル。UIレイヤーに渡す。

  • url GLTFの読み込みパス。

  • scale モデルごとのサイズ差を吸収するための補正値。

  • offsetY 特殊なモデルの接地微調整。

  • creator / web クレジット表示用。ショーケースとして成立させるために必要。


なぜデータ駆動にするのか

もしモデルを直接 <Model /> に書いていたら、

  • 追加するたびに JSX を編集
  • 並び順を変更するたびに構造を書き換え
  • UI表示との整合も面倒

になる。

しかし配列にしておけば、

  • モデルを追加するだけで拡張できる
  • index で現在表示モデルを管理できる
  • map で横並び配置が可能になる

ビューアの設計が「拡張可能」になる。


この構造にしているからこそ、

次章で実装する

offsetX={(i - index) * 8}

という横スライド設計が成立する。

データが整っていなければ、 ビューアは整わない。

3. モデルの正規化(超重要)

この記事の山はここ。

GLTF は “同じ世界座標で作られていない”。

  • 原点がズレている
  • 地面の基準が違う
  • モデルの中心がバラバラ

そのまま表示すると、ビューアは成立しない。

だから必要なのが 正規化(Normalization) だ。

目的は3つ。

  1. 中心を原点に揃える(センタリング)
  2. 床に接地させる(ground align)
  3. 見た目を統一する(scale補正)

3.1 Box3 でバウンディングを取得する

Three.js では Box3 を使うと、モデル全体を包む箱(AABB)を計算できる。

const box = new THREE.Box3().setFromObject(scene);

この box から、

  • min(最小座標)
  • max(最大座標)
  • center(中心)

が取れる。


3.2 センタリング(XZを原点に揃える)

モデルの中心を求めて、

それを引く。

const center = box.getCenter(new THREE.Vector3());

scene.position.x -= center.x;
scene.position.z -= center.z;

これで、モデルのXZ中心が常に (0, 0) に揃う。

モデルを切り替えても、 カメラ構図が安定する。


3.3 接地(Y方向を床に揃える)

次に重要なのが接地。

「モデルが床にめり込む / 浮く」問題を確実に潰す。

scene.position.y -= box.min.y;

box.min.y はモデルの最下点。

そこが 0 になるように持ち上げれば、 必ず床に接地する。


3.4 これが無いとどうなるか

正規化が無いと:

  • モデルごとに位置がバラバラ
  • 地面に埋まる / 浮く
  • カメラが毎回調整必須
  • スライドビューアとして破綻する

つまり “ショーケース” にならない。


3.5 scale と offsetY は最後に足す

今回の実装では models[]

  • scale
  • offsetY

を持たせている。

これは「最後の微調整」として足す。

scene.scale.setScalar(config.scale);
scene.position.y += config.offsetY;

Box3 正規化で 8割を統一し、 scale と offsetY で 最後を揃える

この順番が安定する。


ここまでで、モデル表示は「鑑賞できる形」になる。

次はこの正規化済みモデルを、 横に並べてスライドさせる。

4. 横スライドの仕組み

実は、横スライドの実装は極めてシンプルだ。

offsetX={(i - index) * 8}

これが全て。


4.1 モデルは全部描画している

このビューアでは、

「今のモデルだけを描画する」のではない。

全モデルを常に描画している。

{models.map((m, i) => (
  <Model
    key={i}
    config={m}
    offsetX={(i - index) * 8}
  />
))}

描画を切り替えているのではない。

位置をずらしているだけ。


4.2 index が世界の中心

index は現在表示中のモデル番号。

  • i === index → offsetX = 0(中央)
  • i < index → 左へ
  • i > index → 右へ

つまり:

(i - index)

この差分だけで、 空間内の並びが決まる。


4.3 なぜこれが美しいか

この設計の良さは3つ。

  1. 描画のオンオフを切り替えない
  2. 状態管理が index だけで済む
  3. 拡張しても構造が変わらない

モデルを追加しても、 models[] に足すだけ。

ビューアの構造は一切変わらない。


4.4 「切替」ではなく「移動」

一般的なUIは、

  • 表示を消す
  • 次を表示する

という“切替”をする。

しかしこの設計は違う。

世界の中で位置を移動させている。

だから自然に見える。


このシンプルな設計があるからこそ、 次章の lerp による補間が活きる。

5. lerpによるスムーズ移動

モデルの位置は offsetX で決まる。

しかし、そのまま代入するとこうなる。

outer.current.position.x = targetX;

これは瞬間移動。

切り替えた瞬間に、モデルがワープする。

それでは「スライド」にはならない。

5.1 線形補間(lerp)

そこで使うのが lerp

outer.current.position.x =
  THREE.MathUtils.lerp(currentX, targetX, 0.1);

これは、

現在位置 → 目標位置へ、少しずつ近づける

という処理。

毎フレームこれを行うことで、

  • 一気に移動するのではなく
  • 徐々に滑るように移動する

ようになる。


5.2 なぜ自然に見えるのか

lerp(a, b, t) は、

a + (b - a) * t

を計算している。

t = 0.1 なら、

毎フレーム 10% ずつ目標に近づく。

つまり、

速度ではなく「割合」で近づく。

この挙動は、

  • 慣性
  • 減速
  • イージング

のような印象を生む。


5.3 たった一行で体験が変わる

offsetX が構造なら、

lerp は体験。

この1行があるだけで、

  • ただの位置変更が
  • UI体験になる

瞬間移動から、スライドへ。


ここまでで、

  • データ構造
  • 正規化
  • 配置
  • 補間

が揃った。

次は操作。

ドラッグで index を変える。

6. ドラッグで切り替え

モデルの切り替えは、マウスドラッグで行う。

onPointerDown={(e) => {
  dragStartX.current = e.clientX;
}}

onPointerUp={(e) => {
  if (dragStartX.current === null) return;

  const delta = e.clientX - dragStartX.current;

  if (delta > 80 && index > 0) {
    setIndex((i) => i - 1);
  }

  if (delta < -80 && index < models.length - 1) {
    setIndex((i) => i + 1);
  }

  dragStartX.current = null;
}}

6.1 なぜ delta > 80 なのか

重要なのは「距離で判定している」こと。

もし閾値が小さすぎると:

  • 少しマウスが動いただけで切り替わる
  • 意図しないスライドが発生する

逆に大きすぎると:

  • 何度もドラッグしないと反応しない
  • 重く感じる

80px は、

  • 明確に「スワイプした」と分かる距離
  • かつ、ストレスにならない範囲

として設定している。


6.2 index だけを変える

ここでも設計はシンプル。

切り替えているのはモデルではない。

変えているのは index だけ。

index が変わる → offsetX が再計算される → lerp によって滑らかに移動する

つまり、操作はただの数値変更。

空間の再構築は既存ロジックに任せている。


6.3 UIとして成立させる

ドラッグは単なる入力処理ではない。

  • スワイプの距離を検知し
  • 誤操作を防ぎ
  • 端では切り替え不能にする

これらがあるから、ビューアとして成立する。


ここまでで、

  • 表示
  • 正規化
  • 配置
  • 補間
  • 操作

が揃った。

最後に、UIレイヤーとの分離について整理する。

7. UIレイヤー分離

今回の実装では、UIを Canvas の外に置いている。

<div style={{ position: "absolute", ... }}>
  {/* モデル名・クレジット・ボタン */}
</div>

一見、ただのレイアウトの話に見える。

しかしこれは設計上、かなり重要。


7.1 なぜ Canvas 内に置かないのか

もし UI を Three.js 空間内に置くと:

  • 3D空間に依存する
  • カメラ移動で位置がズレる
  • クリック判定が複雑になる
  • レイアウト調整が難しい

UIは2D。

3D空間とは別のレイヤーで扱うべき。


7.2 役割の分離

このビューアは、明確に2層構造になっている。

  • Canvas(3D描画)
  • DOM(UI制御)

3Dは GPU に任せる。 UIは React に任せる。

責務を分離することで、

  • 可読性が上がる
  • 拡張が容易になる
  • バグが減る

7.3 Webアプリとして成立させる

単なる3Dデモではなく、

「Webアプリ」として成立させるには、

  • モデル名表示
  • クレジットリンク
  • Prev / Next ボタン
  • アニメーション

が必要。

Canvas だけではショーケースにならない。

UIがあって初めて、ビューアになる。


これで設計は完成。

データ駆動、 正規化、 横配置、 補間、 操作、 レイヤー分離。

構造は整った。

最後にまとめる。

8. まとめ

今回の到達点は明確だ。

  • データ駆動の GLTF ビューア設計
  • Box3 によるモデル正規化
  • index ベースの横スライド配置
  • lerp による滑らかな移動
  • GLTF アニメーション再生対応
  • R3F と DOM UI のレイヤー分離

単なるモデル表示ではない。

「ショーケースとして成立する構造」を作った。


GLTF を表示するだけなら、数行で終わる。

しかし、

  • モデルの原点がバラバラであること
  • スケールが統一されていないこと
  • 接地が揃わないこと
  • 切り替えが不自然になること

これらを一つずつ解消していくと、 自然に“設計”の話になる。

R3F は描画エンジンだが、 ビューアを作るには構造設計が必要になる。

今回作ったのは、

3Dデモではなく、 Web上で拡張可能な 3Dギャラリーの基盤。


次は、

  • モデルの自動正規化をさらに汎用化するか
  • カメラ制御を加えるか
  • スワイプの慣性を実装するか

設計は整った。

あとは拡張するだけだ。