Claude CodeでThree.jsの3D商品ビューアを作る|白画面とメモリリークの直し方
「かっこいい3Dにして」では真っ白なcanvasが返ってくる。Claude CodeにThree.jsを任せて回る商品ビューアを作り、白画面とdispose漏れを潰すまでを実体験で書きました。
「ECサイトに、くるっと回せる3Dの商品ビューア入れたいんだよね」
そう言われて、僕はClaude Codeに「Three.jsでかっこいい3D商品ビューア作って」と投げました。返ってきたコードを貼って、ブラウザを開く。真っ白でした。エラーも出ていない。ただ、何も映らない。
あのときの「動いてるのに見えない」気持ち悪さを、たぶん3Dをやった人は一度は味わっています。今日はその落とし穴を全部踏んだ僕が、回る商品ビューアをコピペで動く形まで持っていって、白画面とメモリリークを潰すところまで書きます。
この記事の要点
- Three.jsの3Dは「格好よさ」より「どの端末で・何を確認させるか」で勝負が決まる。要件を決めずにClaude Codeへ投げると、真っ白なcanvasかスマホで落ちるビューアが返ってくる。
- 白画面の9割は親要素の高さが0。CSSで高さを固定するだけで映る。
- ページを行き来すると重くなるのはdispose漏れ。Three.jsはReactが後始末してくれないので、自分で
geometry/material/renderer/controlsを解放する。 - Claude Codeは「きれいにして」ではなくチェック項目を渡すとレビュー担当に化ける。resize・dispose・SSR・スマホ負荷を観点で指定する。
- この記事の
src/App.tsxはそのまま貼れば回る商品ビューアが動く。Vite + React + TypeScript構成。
「かっこいい3D」と頼むと、なぜ真っ白になるのか
最初に言い訳させてください。Claude Codeのコードは正しかったんです。シーンもカメラもライトもメッシュも、ちゃんと置かれていた。なのに白画面。
原因は3Dの外側、CSSにありました。Three.jsはcanvasの大きさを「親要素の表示サイズ」から決めます。ところが親のdivに高さを指定し忘れると、中身の高さは0px。レンダラーは正しく動いているのに、描画する場所が縦0pxだから、結果として何も見えない。エラーが出ないぶん、これがいちばんタチが悪い。
ここで僕が学んだのは、3DをAIに任せるときの本当の難所はThree.jsのAPIじゃなくて、その手前と後始末だということです。canvasをどう置くか、リサイズにどう追従するか、ページを離れたときにどう片付けるか。派手な回転より、この地味な足場で成否が決まります。
だから依頼の仕方も変わりました。「かっこよく」ではなく、確認したい情報と運用条件を先に言葉にする。たとえばECの商品ビューアなら、欲しいのは芸術性ではなく、素材感・サイズ感・色違い・背面や底面が見えること。教育用なら、自由に回せること以上に「説明したい部品に自然と目が向く」ことが大事です。
Claude Codeに投げる前に決める6つの条件
僕がいま使っている依頼テンプレートがこれです。「条件を明文化してから渡す」だけで、返ってくるコードの完成度がまるで変わりました。
Vite + React + TypeScript + three で3D商品ビューアを作ってください。
必須条件:
- canvasは親要素いっぱいに表示する
- window resizeに追従する
- OrbitControlsで回転とズームを可能にする
- unmount時にgeometry、material、renderer、controlsをdisposeする
- スマホではpixelRatioを2以下に制限する
- コードはsrc/App.tsxにそのまま貼って動く形にする
ポイントは、機能(回る・ズームできる)だけでなく、後始末(dispose)とスマホ負荷(pixelRatio)まで条件に書いていること。ここを言わないと、AIは「画面に映ればOK」のコードを書きます。映りはするけど、ページを何度か行き来すると重くなる、あのコードです。
公式APIの細部はThree.js公式ドキュメントで確認できます。とくにレンダラーまわりはWebGLRendererの設定が、見た目の品質とGPU負荷に直結します。antialiasやsetPixelRatioはここを読んでから触ると、雰囲気で設定しなくて済みます。
Vite + React + Three.jsの最小構成から始める
いきなり外部の3Dモデル(.glbなど)を読み込もうとすると、今度はアセットのパス間違いや404で詰まります。僕はまず、立方体と床だけのいちばん簡単な状態で、カメラ・ライト・操作・resize・disposeが全部通ることを確認します。
React Three Fiberという便利なラッパーもありますが、最初は生のThree.jsを薦めます。白画面やメモリリークの原因を追うとき、ライフサイクルが自分の目で見えていたほうが断然はやいからです。慣れたら乗り換えればいい。
# プロジェクトを作る(React + TypeScriptテンプレート)
npm create vite@latest three-claude-demo -- --template react-ts
cd three-claude-demo
npm i three
npm run dev
触るファイルは src/App.tsx と src/App.css の2つだけ。構造をざっくり図にするとこうです。Reactのマウント先divの中に、レンダラーがcanvasを生み、その中にシーン・カメラ・ライト・メッシュが乗り、操作と後始末が外側を固める。
flowchart LR
A["React component"] --> B["mount div"]
B --> C["WebGLRenderer canvas"]
C --> D["Scene"]
D --> E["Camera and lights"]
D --> F["Mesh and material"]
C --> G["OrbitControls"]
G --> H["resize and dispose"]
コピペで動く3D商品ビューア
ここが本題です。次のコードを src/App.tsx に丸ごと貼ると、ドラッグで回せてスクロールでズームできる、ゆっくり自転する商品ビューアが動きます。床に影も落ちます。
僕が地雷を踏んで覚えたポイントは2つ。ひとつはclientWidth/clientHeightで親要素を基準に描画サイズを決めていること(window基準にするとレイアウト次第でズレる)。もうひとつは、クリーンアップでThree.jsのリソースを手で解放していること。ここが今日いちばん伝えたい部分です。
import { useEffect, useRef } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import "./App.css";
export default function App() {
// Reactがcanvasを差し込む先のdivを参照する
const mountRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const mount = mountRef.current;
if (!mount) return;
// シーン(3D空間)と背景色
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf6f7fb);
// カメラ。aspectは後でresizeが正しい値に直す
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(3.5, 2.2, 4.5);
// レンダラー。pixelRatioは2以下に制限してスマホの発熱を防ぐ
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = true;
mount.appendChild(renderer.domElement);
// マウス操作(回転・ズーム)。dampingで動きを滑らかにする
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.minDistance = 2.5;
controls.maxDistance = 8;
controls.target.set(0, 0.4, 0);
// 環境光と主光源。影は主光源から落とす
const ambient = new THREE.HemisphereLight(0xffffff, 0x7c8594, 1.6);
scene.add(ambient);
const keyLight = new THREE.DirectionalLight(0xffffff, 2.4);
keyLight.position.set(3, 5, 4);
keyLight.castShadow = true;
scene.add(keyLight);
// 商品本体(ここでは箱で代用。実物は.glbなどに差し替える)
const productGeometry = new THREE.BoxGeometry(1.8, 1.2, 1.1, 4, 4, 4);
const productMaterial = new THREE.MeshStandardMaterial({
color: 0x2f6f73,
roughness: 0.42,
metalness: 0.08,
});
const product = new THREE.Mesh(productGeometry, productMaterial);
product.castShadow = true;
product.position.y = 0.75;
scene.add(product);
// 影を受ける床
const floorGeometry = new THREE.CircleGeometry(2.2, 64);
const floorMaterial = new THREE.MeshStandardMaterial({
color: 0xd9dee8,
roughness: 0.7,
});
const floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
// リサイズ追従。親要素の実サイズからcanvasを作り直す
const resize = () => {
const width = mount.clientWidth;
const height = mount.clientHeight;
if (width === 0 || height === 0) return; // 高さ0のときは描画しない
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
};
let frameId = 0;
const clock = new THREE.Clock();
// 毎フレーム呼ばれるループ。ゆっくり回す
const animate = () => {
const elapsed = clock.getElapsedTime();
product.rotation.y = elapsed * 0.45;
product.rotation.x = Math.sin(elapsed * 0.8) * 0.08;
controls.update();
renderer.render(scene, camera);
frameId = window.requestAnimationFrame(animate);
};
resize();
animate();
window.addEventListener("resize", resize);
// ここが後始末。unmount時にループ停止&GPUリソースを手で解放する
return () => {
window.removeEventListener("resize", resize);
window.cancelAnimationFrame(frameId);
controls.dispose();
// シーンを走査して、すべてのgeometry/materialをdispose
scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry.dispose();
const materials = Array.isArray(object.material)
? object.material
: [object.material];
materials.forEach((material) => material.dispose());
}
});
renderer.dispose();
renderer.domElement.remove();
};
}, []);
return (
<main className="viewerShell">
<div className="copy">
<p className="eyebrow">Three.js + Claude Code</p>
<h1>3D product viewer</h1>
<p>
ドラッグで回転、スクロールでズーム。ウィンドウを伸び縮みさせて、
canvasが親要素に追従するか確かめてください。
</p>
</div>
<div ref={mountRef} className="viewerStage" />
</main>
);
}
src/App.css はこうします。さっき話した白画面の正体がここ。.viewerStageに高さがないと、レンダラーをどれだけ正しく初期化しても表示領域が0になって何も映りません。まず疑うのはこのCSSの高さです。
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
background: #eef2f7;
color: #17202a;
}
.viewerShell {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(260px, 0.8fr) minmax(320px, 1.2fr);
gap: 32px;
align-items: center;
padding: 40px;
box-sizing: border-box;
}
.copy {
max-width: 520px;
}
.eyebrow {
margin: 0 0 10px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #2f6f73;
}
.copy h1 {
margin: 0 0 16px;
font-size: clamp(36px, 5vw, 64px);
line-height: 1;
}
.copy p {
font-size: 17px;
line-height: 1.7;
}
/* ここが肝。高さを明示しないとcanvasが0pxになり白画面になる */
.viewerStage {
height: min(62vh, 560px);
min-height: 360px;
border: 1px solid #ccd5df;
background: #f6f7fb;
}
.viewerStage canvas {
display: block;
}
@media (max-width: 760px) {
.viewerShell {
grid-template-columns: 1fr;
padding: 24px;
}
.viewerStage {
min-height: 300px;
}
}
Claude Codeを「レビュー担当」に変える指示
コードができたら、僕はもう一度Claude Codeに渡します。ただし「きれいにして」とは言わない。それだと変数名を整えるくらいで終わります。代わりに、3D特有のチェック項目を渡す。すると生成役からレビュー担当に役割が切り替わります。
このThree.js実装をレビューしてください。
観点:
1. canvasが親要素のサイズ変更に追従しているか
2. geometry、material、renderer、controlsがunmount時にdisposeされているか
3. requestAnimationFrameが停止されるか
4. devicePixelRatioが高いスマホで過剰なGPU負荷にならないか
5. Next.jsやAstroのSSR環境でwindow参照が壊れないか
6. 色、ライト、カメラ距離が商品確認に向いているか
修正案は差分で示し、動作確認手順も書いてください。
とくに2番のdispose漏れは、画面を一度見ただけでは絶対に気づけません。ページ遷移を何度も繰り返したときにじわじわ効いてくる。だから公開前に必ず、この観点でレビューさせます。レビュー観点をテンプレ化しておくと、毎回ゼロから考えなくて済むのも大きい。チームで使うなら、この6項目をそのままチェックリストにすると話がはやいです。
実務で3Dが効く場面(と効かない場面)
3Dは入れれば偉いわけではありません。むしろ2Dで十分なところに入れると、読みにくくなって逆効果です。僕が関わった範囲で、効くケースをユースケース別にまとめます。
| ユースケース | 3Dにする意味 | Claude Codeに頼む作業 |
|---|---|---|
| 3D商品ビューア | 色・角度・奥行き・素材感を購入前に確認できる | OrbitControls、色切り替え、ライト調整、モバイル負荷の確認 |
| データ可視化 | 平面グラフでは見えにくい分布や時間変化を直感的に見せる | 点群、棒グラフ、カメラ遷移、凡例UIの実装 |
| ポートフォリオ・教育用シーン | 建築・機械・人体・教材を回しながら説明できる | 注釈ラベル、部品ハイライト、順番に見せるアニメーション |
商品ビューアでいちばん大事なのは、形状そのものより「買う前の不安を減らす情報」です。家具なら設置面、家電なら背面の端子、アパレルなら素材の光り方。ここをClaude Codeに伝えないと、意味のある3Dではなく、ただ回転する箱が出てきます。実際、僕の最初の試作はまさに「回るだけの箱」でした。
データ可視化は要注意です。3Dにした瞬間、奥の棒が手前に隠れて値が読めない、凡例の色が足りない、カメラ操作が分析の邪魔になる、といった事故が起きます。Claude Codeには「2Dで足りる部分」と「3Dにする部分」を分けて設計させたほうがいい。
教育・ポートフォリオ用では、自由操作より「見せる順番」が効きます。決まった角度へカメラを動かすボタン、注釈の表示、説明中だけ自動回転を止める処理。これを足すと、デモがいきなり実務っぽくなります。
僕がやらかした白画面とメモリリーク
正直に失敗を3つ書きます。どれも一度はハマりました。
ひとつ目が冒頭の白画面。原因はCSSの高さ未指定でした。調査のコツは、まずブラウザのコンソールを開くこと。importパスの間違い、canvasサイズ0、WebGL context lost、モデルの404はここで一発でわかります。それから背景色だけ明るくして立方体だけ置く最小状態に戻す。複雑なモデルとHDR環境とポストエフェクトを同時に疑うと、確実に時間を溶かします。
ふたつ目がページ遷移を繰り返すと重くなる問題。これがdispose漏れです。目で見ても絶対わからない。Chrome DevToolsのPerformanceとMemoryでページ遷移を何度も繰り返して、GPUメモリとJSヒープが増え続けないか確認します。Three.jsはReactの仮想DOMが勝手に片付けてくれる世界ではないので、ここは人間とClaude Codeの両方の目で見ます。上のコードのscene.traverse(...)で解放しているのが、その答えです。
みっつ目がスマホでの発熱。devicePixelRatioが高い端末でそのまま描くと、GPUが過剰に働いて本体が熱くなります。setPixelRatio(Math.min(window.devicePixelRatio, 2))で上限を2に切る、影とセグメント数を削る。これだけでだいぶ落ち着きました。
よくある失敗を一覧にしておきます。詰まったらここから疑ってください。
| 失敗例 | 原因 | 修正 |
|---|---|---|
| canvasが真っ白 | 親要素の高さが0、カメラが物体を向いていない、ライトがない | CSSで高さを固定し、camera positionとcontrols targetを確認する |
| resize後に歪む | camera.aspectとrenderer.setSizeを更新していない | resize関数でcamera.updateProjectionMatrixを呼ぶ |
| ページ遷移後に重くなる | geometry/material/renderer/controlsのdispose漏れ | unmount時にtraverseしてdisposeする |
| スマホで発熱する | devicePixelRatioが高すぎる、影やポリゴン数が重い | pixelRatioを2以下にし、影とセグメント数を削る |
| SSRで落ちる | render時にwindowやdocumentへ直接アクセスしている | useEffect内で初期化し、必要ならクライアント専用に分離する |
本番に出す前のチェックリスト
見た目が動いたら終わり、ではありません。僕が公開前に最低限見るのがこれです。
- 主要スマホで30fps以上を維持できるか
- 影・反射・テクスチャ解像度を落とした軽量モードがあるか
- 3Dが読み込めないときに代替画像を出せるか
- キーボード操作やスクリーンリーダー向けの説明テキストがあるか
- モデルファイルの容量が初回表示を妨げていないか
3Dは「動く人には動くが、動かない人には何も映らない」技術です。だからこそ代替画像とアクセシビリティは外せません。canvas全般の設計の考え方はClaude CodeでCanvas開発を進めるガイドに、回転や演出の作り込みはClaude Codeでアニメーション実装を効率化する方法にまとめています。描画が重いと感じたらClaude Codeでパフォーマンス最適化を進める方法も合わせて読むと、どこを削るか判断しやすくなります。
よくある質問
Q. React Three Fiberとどっちを使えばいいですか? A. 最初は生のThree.jsを薦めます。白画面やメモリリークの原因を追うとき、ライフサイクルが自分の目で見えているほうがはやく直せるからです。仕組みを理解してからR3Fに移ると、便利さの中身がわかった状態で使えます。
Q. 自分の.glbモデルを表示するには?
A. 上のコードの箱(BoxGeometryの部分)をGLTFLoaderでの読み込みに差し替えます。ただし最初から外部モデルでやると、パス間違いや404で詰まりがちなので、まず箱で全体が通ることを確認してから差し替えるのが安全です。
Q. Next.jsやAstroでwindow is not definedと出ます。
A. SSR(サーバー側描画)でwindowを触っているのが原因です。Three.jsの初期化は必ずuseEffectの中に置き、必要ならクライアント専用コンポーネントに分離します。レビュー指示の5番がこれを拾ってくれます。
Q. スマホでカクつきます。何から削ればいいですか?
A. まずsetPixelRatioの上限を2、つらければ1.5まで下げます。次に影(shadowMap)を切る、ジオメトリのセグメント数を減らす、の順。一気に全部やらず、1つずつfpsを見ながら削ると効き目がわかります。
Q. Claude Codeに3Dを任せて、どこまで自動でできますか? A. 雛形の生成とレビューはかなり任せられます。ただし「何を確認させる3Dか」という設計の意思決定だけは人間が持つべきです。そこを丸投げすると、技術的には正しいけど目的に合わない「回るだけの箱」が返ってきます。
実際に試した結果
この記事のコードは、ViteのReact + TypeScriptテンプレートに貼り付けて、デスクトップ幅とスマホ幅でresizeを実際に確認しています。確かめたのは3つ。canvasの高さをCSSで明示しないと白画面に見えること、renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))を入れると高解像度端末でGPU負荷を抑えやすいこと、そしてunmount時のdisposeをClaude Codeにレビューさせると漏れを見つけやすいこと。
冒頭の真っ白なcanvas以来、僕は3Dを「映るか」で見るのをやめました。代わりに見るのは、resize・dispose・スマホ負荷という地味な足場です。Claude Codeに「かっこよく」と頼むのではなく、この足場を条件で渡す。それだけで、回るだけの箱が、ちゃんと売り場で使える商品ビューアに変わりました。
3D商品ビューアやWebGLデータ可視化を実務に入れたい方は、設計レビューやチーム向けの進め方を研修・相談で扱っています。手を動かして学べる教材は教材一覧からどうぞ。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。