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",
R3Fで3Dモデル横スライドビューアを実装デモ #R3F #nextjs #threejs
Next.js + React Three Fiberで、GLTFモデルを横スライド切替できる3Dビューアを実装・Box3による自動正規化・indexベース配置・lerp補間・UIレイヤー分離▼モデルデータ・Jelvehkar https://sketchfab.com/alihoseini13000・Unity...
https://www.youtube.com/shorts/rm9x93eCcy0前回の記事:
[Next.js #15] Next.js + R3F で 5万本の草原を生成する – InstancedMesh + GPUシェーダー
Next.js + React Three Fiber を使い、InstancedMesh とカスタムシェーダーで 5万本の草を描画。GPUでの風アニメーション、FBMによる地形生成、疑似法線ライティングまで解説。
https://humanxai.info/posts/nextjs-15-r3f-gpu-grass-system/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レイヤーに渡す。 -
urlGLTFの読み込みパス。 -
scaleモデルごとのサイズ差を吸収するための補正値。 -
offsetY特殊なモデルの接地微調整。 -
creator / webクレジット表示用。ショーケースとして成立させるために必要。
なぜデータ駆動にするのか
もしモデルを直接 <Model /> に書いていたら、
- 追加するたびに JSX を編集
- 並び順を変更するたびに構造を書き換え
- UI表示との整合も面倒
になる。
しかし配列にしておけば、
- モデルを追加するだけで拡張できる
indexで現在表示モデルを管理できる- map で横並び配置が可能になる
ビューアの設計が「拡張可能」になる。
この構造にしているからこそ、
次章で実装する
offsetX={(i - index) * 8}
という横スライド設計が成立する。
データが整っていなければ、 ビューアは整わない。
3. モデルの正規化(超重要)
この記事の山はここ。
GLTF は “同じ世界座標で作られていない”。
- 原点がズレている
- 地面の基準が違う
- モデルの中心がバラバラ
そのまま表示すると、ビューアは成立しない。
だから必要なのが 正規化(Normalization) だ。
目的は3つ。
- 中心を原点に揃える(センタリング)
- 床に接地させる(ground align)
- 見た目を統一する(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[] に
scaleoffsetY
を持たせている。
これは「最後の微調整」として足す。
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つ。
- 描画のオンオフを切り替えない
- 状態管理が index だけで済む
- 拡張しても構造が変わらない
モデルを追加しても、
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ギャラリーの基盤。
次は、
- モデルの自動正規化をさらに汎用化するか
- カメラ制御を加えるか
- スワイプの慣性を実装するか
設計は整った。
あとは拡張するだけだ。
💬 コメント