SJ blog
frontend
B

信頼度ランク

S 公式ソース確認済み
A 成功実績多数・失敗例少数
B 賛否両論
C 動作未確認・セキュリティリスク高
Z 個人所感

Three.js + WebGLで野菜をアートにする:3Dレンダリングの仕組みと見どころ

黒背景に浮かぶ野菜が回転する没入型サイトの裏側。Three.jsがWebGLの複雑さをどう隠しているか、ライティング・マテリアル・カメラ制御の基本をこのサイトの表現から紐解きます。

一言結論

Three.jsはWebGLのシェーダー・バッファ・行列演算をScene/Camera/Rendererの3概念に抽象化しているが、実際のプロダクションではライティングとマテリアルの選択で見た目の80%が決まる。

Knowledge Graph 3D

ドラッグ: 回転 / ホイール: ズーム

3D記事アーキテクチャ

3D描画・スクロール演出・記事本文を分離し、各レイヤーを契約で接続して既存構成を壊さず拡張する。

Presentation Layer

責務: 記事本文と見出し・注釈など静的表示を担当

障害影響範囲: 3D機能が壊れても本文閲覧とSEOは維持される

  • Markdown本文
  • 見出し/リンク
  • サマリー表示

3D Scene Layer

責務: Three.js/WebGLの初期化と描画のみを担当

障害影響範囲: Canvas停止に限定され、記事ルーティングや一覧には波及しない

  • Canvas生成
  • Renderer/Camera/Scene
  • アニメーションループ

Interaction Layer

責務: GSAP/スクロール進行に応じた状態変化を管理

障害影響範囲: 演出のみ劣化し、コンテンツ表示とページ遷移は継続

  • スクロールトリガー
  • UIイベント
  • 状態同期

境界契約(切り分けポイント)

  • Markdown -> 3D Scene

    契約: 記事はDOMアンカーIDのみ公開し、3D層はアンカーを購読する

    検証: アンカー欠落時は3D初期化をスキップして本文のみ表示

  • 3D Scene -> Interaction

    契約: 公開APIは start/stop/updateProgress の3つに固定

    検証: progress異常値はInteraction層で丸めてSceneに渡す

  • Interaction -> Layout

    契約: Layoutはイベントを受け取るだけでThree.js依存を持たない

    検証: イベント不達でもLayoutは静的レンダリングを維持

ビルド検証

  • npm run test
  • npm run build

絶滅危惧野菜プロジェクトのサイトを開くと、黒背景にきゅうりや植物が浮かんでいます。静止画のようにも見えますが、実際にはThree.js を使ったWebGLの3Dシーンがリアルタイムに描画されています。

JSバンドルの中から THREE, WebGL, webgl のシグネチャが検出されており、3Dレンダリングエンジンとしてのスタックが確認できます。

この記事では、Three.jsがWebGLの複雑さをどう隠しているか、そしてこのサイトの表現がどのような3D技法に支えられているかを解説します。

WebGLの素顔は凶暴

Three.jsなしでWebGLを直接触ると、三角形1つ描くだけでこれだけのコードが必要です。

// WebGL生API(三角形を1つ描く、省略版でもこの量)
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl');

const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, `
  attribute vec4 a_position;
  void main() { gl_Position = a_position; }
`);
gl.compileShader(vertexShader);

const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, `
  precision mediump float;
  void main() { gl_FragColor = vec4(0.0, 1.0, 0.0, 1.0); }
`);
gl.compileShader(fragmentShader);

const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);
// ...まだバッファのバインドと描画呼び出しが続く

Three.jsはこの全体を3つの概念に圧縮します。

// Three.js(同じ三角形を描く)
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, w/h, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();

const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute([
  0, 1, 0,  -1, -1, 0,  1, -1, 0
], 3));
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const mesh = new THREE.Mesh(geometry, material);

scene.add(mesh);
renderer.render(scene, camera);
  • Scene: 3D空間。オブジェクト・光源を配置する入れ物
  • Camera: どこからどう見るか
  • Renderer: Scene + Camera からピクセルを生成する

この3つだけ理解すれば、WebGLのシェーダー言語やGPUバッファ管理を直接触らずに3D表現ができます。

このサイトで使われている3D技法

ライティング:黒背景に浮かぶ効果

黒背景に物体を「浮かせる」には、環境光を極端に暗くして、スポットライトを1〜2方向から当てるのが定石です。

// 想定される構成
scene.background = new THREE.Color(0x000000);

// 環境光を最小限に
const ambient = new THREE.AmbientLight(0xffffff, 0.05);
scene.add(ambient);

// スポットライトを上方から
const spot = new THREE.SpotLight(0xffffff, 1.2);
spot.position.set(0, 5, 3);
spot.angle = Math.PI / 6;
scene.add(spot);

このライティングだと、光が当たっている面だけが明るく、それ以外は完全な闇に沈みます。美術館のスポット照明と同じ原理で、被写体(野菜)に視線が集中します。

マテリアル:質感の再現

野菜の表面質感(きゅうりのざらつき、葉の半透明感)を出すには MeshStandardMaterialMeshPhysicalMaterial が使われている可能性が高いです。

const material = new THREE.MeshPhysicalMaterial({
  map: texture,           // テクスチャ画像
  roughness: 0.7,         // 表面の粗さ(高い=マット、低い=光沢)
  metalness: 0.0,         // 金属感(野菜なので0)
  clearcoat: 0.1,         // 表面の薄膜コート(水滴っぽさ)
  transmission: 0.0,      // 透過度(葉っぱなら少し上げる)
});

roughnessmetalness の2パラメータだけで、マットな野菜肌から光沢のあるトマトまで表現できます。PBR(物理ベースレンダリング)の威力です。

カメラ制御:微細な動き

サイト上で野菜がゆっくり回転しているように見えるのは、カメラまたはオブジェクト自体に微細な回転を requestAnimationFrame ループで加えているためです。

function animate() {
  requestAnimationFrame(animate);
  mesh.rotation.y += 0.003;  // 極小の回転量
  renderer.render(scene, camera);
}
animate();

0.003 rad/frame(≒ 0.17°/frame)程度の微細な回転は、意識されないレベルで「生きている」印象を与えます。静止画との決定的な差はここで生まれます。

パフォーマンスの考慮

Three.jsは便利ですが、何も考えずに使うとモバイルで即死します。このサイトが滑らかに動いている背景には、いくつかの工夫があるはずです。

  • ジオメトリの頂点数を抑える: 野菜のモデルは写真のように見えますが、実際にはポリゴン数を抑えた軽量モデル + 高解像度テクスチャで質感を補っている可能性大
  • renderer.setPixelRatio(): Retinaディスプレイで2x/3xのピクセル比をそのまま使うとGPU負荷が4〜9倍になる。Math.min(window.devicePixelRatio, 2) で上限を2に制限するのが定石
  • 描画の制御: GSAP ScrollTriggerと連動して、ビューポート外のセクションではレンダリングループを停止する
// ビューポート外では描画を止める
ScrollTrigger.create({
  trigger: canvasContainer,
  onEnter: () => { isRendering = true; },
  onLeave: () => { isRendering = false; },
});

function animate() {
  requestAnimationFrame(animate);
  if (!isRendering) return;
  renderer.render(scene, camera);
}

WebGLの限界

Three.jsはWebGLを抽象化しますが、WebGL自体の限界は残ります。

  • シェーダーコンパイル: CSPで unsafe-eval が必要になる原因。Three.jsのシェーダーは実行時にGLSLとしてコンパイルされるため
  • モバイルのGPUメモリ: テクスチャの総量がGPU VRAM を超えると即クラッシュ。圧縮テクスチャ(Basis/KTX2)で軽減可能
  • コンテキスト喪失: バックグラウンドタブでブラウザがWebGLコンテキストを破棄することがある。復帰処理を書いていないと白画面になる

このサイトのこだわり

  • 写真とCGの境界を曖昧にする: プロカメラマン(Janny Suzuki / Terumi Takahashi)の撮影データを3D空間に配置することで、「写真なのかCGなのか分からない」感覚を意図的に作り出している
  • ローディングGIFのプリロード: Three.jsの初期化は重い。その間の黒画面を避けるため、ローディングアニメーションを <link rel="preload"> で最優先読み込みしている
  • ファーストビューの強さ: スクロールしなくても野菜が1つ浮かんでいる。「何だこれ」という引力を初見で作る設計

参考: