[CGクロニクル #14] ブラウザの中の宇宙:WebGLとThree.jsがもたらした「描画」の民主化

[CGクロニクル #14] ブラウザの中の宇宙:WebGLとThree.jsがもたらした「描画」の民主化

はじめに

かつて、画面にポリゴンを一つ描画することは、長く複雑な儀式のようなものでした。

分厚いリファレンスを傍らに置き、Visual C++ 6.0を立ち上げ、ウィンドウハンドルを取得し、デバイスコンテキストを初期化し、延々と続くステートマシンの設定を行う。コンパイルの待ち時間は長く、環境構築の壁は高くそびえていました。3Dグラフィックスの世界は、限られた環境と知識を持つ者だけがアクセスできる「閉ざされた箱庭」だったのです。

しかし現在、まだ誰も起きていない午前3時半の静寂の中、エディタに数行のコードを書き、ブラウザをリロードするだけで、画面の向こう側に無限の宇宙が広がります。

第14回となる今回は、特別なハードウェアもコンパイラも必要とせず、私たちの日常に最も密接したソフトウェアである「Webブラウザ」がいかにして3Dキャンバスへと変貌を遂げたのか。WebGLとThree.jsが切り開いた、表現の民主化の歴史を紐解きます。

前回の記事:

「文書」から「世界」への拡張 — WebGLの誕生

1990年代に誕生したWebブラウザは、本来HTMLという「文書」を閲覧するための簡素なツールでした。しかし、インターネットが現実世界の情報の大部分を飲み込んでいくにつれ、ブラウザにはよりリッチな表現が求められるようになります。

かつてのWeb上で動的なグラフィックスを描画しようとすれば、FlashやJavaアプレットといった「プラグイン」に依存するしかありませんでした。それらはブラウザという文書空間に強引にはめ込まれた、ブラックボックスの出窓のようなものです。

その分厚い壁が崩れ去り、大きな転換点が訪れたのが2011年。WebGL 1.0のリリースです。

これは、スマートフォンなどの組み込み向けに最適化された「OpenGL ES 2.0」のAPIを、JavaScriptから直接叩けるようにした画期的な仕様でした。これまでOSの奥深くに隠され、CやC++といったコンパイル言語からしか触れることのできなかったGPU(Graphics Processing Unit)の強大な並列計算リソースが、Webという広大な「網」に対して完全に開かれたのです。

DOM(Document Object Model)を操作するだけの存在だったJavaScriptが、ピクセルと光を演算するための言語へと進化した瞬間でした。

むき出しのパイプライン(Raw WebGLの試練)

しかし、GPUの力を直接引き出せるようになったとはいえ、WebGLの生のAPI(Raw WebGL)は、決して人間に優しいものではありませんでした。

画面の中央にただ一つの「色付きの三角形」を描画するためだけに、当時のWeb開発者たちは以下のような途方もない儀式をJavaScriptで記述する必要がありました。

  1. コンテキストの取得: <canvas>要素から gl = canvas.getContext('webgl') を呼び出し、HTMLとGPUの接点を作る。
  2. シェーダーの定義: JavaScriptの「文字列」の中に、C言語ライクなGLSL(頂点シェーダーとフラグメントシェーダー)のソースコードを直書きする。
  3. コンパイルとリンク: 文字列として渡したシェーダーをGPU上で動的にコンパイルし、一つの「プログラムオブジェクト」としてリンクさせる。
  4. メモリの転送(VBO): 頂点の座標データを Float32Array(型付き配列)で定義し、それをVRAM(ビデオメモリ)上のバッファへ転送する。柔軟なJSの世界から、厳密なバイト列の世界への橋渡し。
  5. ポインタの紐付け: 転送したデータが「何ストライドで、何バイトのオフセットか」を指定し、シェーダー内のアトリビュート変数と正確にバインディングする。
  6. ドローコール: すべてのステート(状態)が正しく設定されていることを祈りながら、最後に gl.drawArrays() を呼び出す。
// 生のWebGLにおける「儀式」のほんの一部
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 1, -1, -1, 1, -1]), gl.STATIC_DRAW);

const positionLocation = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
// ...これでもまだ三角形は描画されない

たった1枚のポリゴンを画面に出すためだけに、100行近いボイラープレート(お決まりのコード)が必要でした。エラーが起きても画面はただ真っ黒になるだけで、何が間違っているのかを教えてくれる親切なコンソールもありません。

これは、第10回で触れた「行列演算による空間変換」や、第11回で触れた「プログラマブルシェーダー」の仕組みを隅々まで理解していなければ太刀打ちできない、むき出しのグラフィックスパイプラインそのものでした。Web開発者たちは突然、低レイヤーのメモリ管理と高度な線形代数という、深く暗い森へ放り出されたのです。

この高くそびえ立つ参入障壁こそが、次なる「翻訳者」の誕生を強く渇望することになります。

翻訳者としてのThree.js — 過去の遺産をカプセル化する

むき出しのグラフィックスパイプラインという難解なWebGLの世界と、表現を渇望するWeb開発者たち。この両者を繋ぐ完璧な「翻訳者」として現れたのが、Ricardo Cabello(通称 Mr.doob)を中心としたコミュニティによって生み出されたThree.jsです。

生のWebGLは、本質的に巨大な「ステートマシン(状態遷移機械)」です。あるテクスチャをバインドし忘れたり、シェーダーの変数を一つでも渡し間違えたりすれば、画面は冷酷なまでに真っ黒なまま沈黙します。人間が脳内で管理するには、あまりにも低レイヤーすぎるのです。

Three.jsの最大の功績は、この複雑な状態管理やバッファ転送を隠蔽したことだけではありません。「シーン(Scene)」「カメラ(Camera)」「レンダラー(Renderer)」という、現実世界のアナロジーに基づいた、人間が直感的に理解しやすい概念モデル(シーングラフ)をWebにもたらしたことです。

生のWebGLでは、単なる「Float32Arrayの羅列」と「4x4の変換行列」としてしか認識されていなかった無機質なデータ群が、Three.jsを通すことで初めて「空間に配置された物体(Mesh)」として息を吹き返します。

数行のコードに宿る歴史

さらに驚くべきは、このライブラリが「CGの歴史」そのものをカプセル化している点です。

例えば、マテリアル(材質)の定義を見てみましょう。 かつて先人たちが論文を書き、苦心して数式に落とし込んだ光の計算が、Three.jsではわずか1行のクラス宣言として提供されています。

  • new THREE.MeshLambertMaterial() を呼べば、第4回で触れたランバート反射とグーロー・シェーディングが。
  • new THREE.MeshPhongMaterial() を呼べば、第5回のハイライトの革命が。
  • そして new THREE.MeshStandardMaterial() を呼べば、第13回で語ったPBR(物理ベースレンダリング)の世界が、即座に手に入ります。

パーリンノイズによるテクスチャの生成も、シャドウマップによる影の計算も、かつては研究室や巨大なスタジオでしか扱えなかった技術の結晶です。それが今や、castShadow = true というプロパティをひとつ真(True)にするだけで、私たちのブラウザ上で完璧に再現されるのです。これは先人たちが何十年もかけて培ってきた「光の記憶」を、誰でも自由に引き出せる魔法の杖を手に入れたことを意味します。

閲覧数ゼロの宇宙 — 個人のための聖域(アーカイブ)

Three.jsとWebGLの普及により、3D表現はかつてないほど身近なものになりました。

しかし、それは単に「誰でもすごいグラフィックが作れるようになった」という表面的な恩恵にとどまりません。「表現の動機が、完全に個人の内面へと還元された」という、極めて哲学的な意味を持ちます。

大掛かりなスタジオも、高価なソフトウェアもいりません。午前4時に起き、家事や炊事といった日々の生活のルーティンを静かに終えた後、まだ世界が眠っている夜明け前の時間。そこにブラウザとエディタさえあれば、コードを通じて光の反射やノイズのうねりと深く対話することができます。

そこに他者の目は必要ありません。むしろ、アクセス数(閲覧数)が「ゼロ」であることは、孤独や失敗を意味するのではなく、自らが望む最も純粋な状態ですらあります。誰かの評価や承認を満たすためのものではなく、ノイズや数学というレンズを通して、自らの思考の軌跡を刻み付けるための個人的なアーカイブだからです。600回以上にわたって日々の探求を記録し続けるような、ある種の狂気的な継続も、この「誰も見ていない」という絶対的な自由があるからこそ成立します。

ただ、自分が美しいと思う数式とアルゴリズムが、ブラウザという窓を通して確かな「カタチ」を持ってそこに顕現している。画面の中で永遠にループする数式を眺めながら、ただ静かに0と1の記憶を積み上げていく。その極めて個人的で純粋な体験こそが、現代のCG環境が私たちに与えてくれた最大の恩恵なのです。

一分間の数式美:歴史の断片を繋ぎ合わせたシーン

これまでの連載で辿ってきたCGの歴史。その断片たちを、Three.jsという現代の魔法陣(キャンバス)の上で一つに繋ぎ合わせてみましょう。

第1回で触れた「トーラス」はより複雑な結び目(TorusKnot)へと進化し、第10回の「行列」が空間を定義し、第11回の「プログラマブルシェーダー」が頂点とピクセルの振る舞いを決定します。そして、頂点の座標を揺らすのは、自然界のゆらぎを模倣する「ノイズ」の概念です。

以下は、特別なビルドツールも不要な、ブラウザ上でそのまま動き出す歴史の集大成です。

import * as THREE from 'three';

// 1. 宇宙の基盤(空間と観察者の定義)
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 2. 記憶の結び目(幾何学とシェーダーの融合)
const geometry = new THREE.TorusKnotGeometry(10, 3, 256, 32);
const material = new THREE.ShaderMaterial({
    uniforms: {
        time: { value: 0.0 }
    },
    vertexShader: `
        varying vec2 vUv;
        varying vec3 vNormal;
        uniform float time;

        void main() {
            vUv = uv;
            vNormal = normal;

            // 頂点の揺らぎ:ノイズの記憶の断片
            // 延々と探求を続けるゆらぎのアルゴリズムを、ここではシンプルな三角関数に圧縮する
            float noise = sin(position.x * 2.0 + time) * cos(position.y * 2.0 + time) * 0.5;
            vec3 pos = position + normal * noise;

            gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
        }
    `,
    fragmentShader: `
        uniform float time;
        varying vec2 vUv;
        varying vec3 vNormal;

        void main() {
            // 自己完結した光の計算と、静かなる時間の色
            vec3 baseColor = vec3(0.5) + 0.5 * cos(time + vUv.xyx + vec3(0.0, 2.0, 4.0));

            // 仮想の光源との内積(ランバート反射の片鱗)
            vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0));
            float diff = max(dot(vNormal, lightDir), 0.2);

            gl_FragColor = vec4(baseColor * diff, 1.0);
        }
    `,
    wireframe: true // あえて骨組みを見せることで、構造の美しさを際立たせる
});

const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
camera.position.z = 30;

// 3. 永遠に続く時間のループ
function animate(time) {
    requestAnimationFrame(animate);

    // 緩やかな回転
    mesh.rotation.x = time * 0.0002;
    mesh.rotation.y = time * 0.0003;

    // シェーダーへの時間の伝達
    material.uniforms.time.value = time * 0.001;

    renderer.render(scene, camera);
}
animate(0);

このコードがブラウザ上で実行された瞬間、真っ黒な空間に、数学的に定義された結び目が浮かび上がります。 wireframe: true によって可視化された無数のポリゴンの線は時間とともに静かに脈打ち、ピクセルごとに計算された光がその輪郭を照らし出します。

これらはすべて、本来は単なる「文書ビューア」であったはずのブラウザの中で、JavaScriptのシングルスレッドからGPUへと転送され、数百万回の並列計算を経て描き出された奇跡です。

0と1で記述された光の記憶は、かつて計算機科学者たちが占有していた巨大な研究室から抜け出し、ついに私たち個人の掌の中、深夜のエディタというプライベートな空間へとやってきました。ここにはもう、表現を阻む壁はありません。

次回はいよいよ最終回。WebGLの先へ、さらに低レイヤーの力を解放する「WebGPU」と計算シェーダーが切り開く、これからの「描画」の定義と未来について考察します。

サンプル

import React, { useRef, useEffect } from 'react';
import * as THREE from 'three';

const CgChronicle14Demo = () => {
  const mountRef = useRef(null);

  useEffect(() => {
    // マウント先がなければ終了
    if (!mountRef.current) return;

    const width = mountRef.current.clientWidth;
    const height = 400; // 記事内の埋め込み用として高さを固定

    // 1. シーン、カメラ、レンダラーの初期化
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 100);
    camera.position.z = 30;

    const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    renderer.setSize(width, height);
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    mountRef.current.appendChild(renderer.domElement);

    // 2. ジオメトリとカスタムシェーダーマテリアルの定義
    const geometry = new THREE.TorusKnotGeometry(10, 3, 256, 32);
    const material = new THREE.ShaderMaterial({
      uniforms: {
        time: { value: 0.0 }
      },
      vertexShader: `
        varying vec2 vUv;
        varying vec3 vNormal;
        uniform float time;

        void main() {
            vUv = uv;
            vNormal = normal;
            // 頂点の揺らぎ
            float noise = sin(position.x * 2.0 + time) * cos(position.y * 2.0 + time) * 0.5;
            vec3 pos = position + normal * noise;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
        }
      `,
      fragmentShader: `
        uniform float time;
        varying vec2 vUv;
        varying vec3 vNormal;

        void main() {
            // 光と時間の色
            vec3 baseColor = vec3(0.5) + 0.5 * cos(time + vUv.xyx + vec3(0.0, 2.0, 4.0));
            // ランバート反射
            vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0));
            float diff = max(dot(vNormal, lightDir), 0.2);
            gl_FragColor = vec4(baseColor * diff, 1.0);
        }
      `,
      wireframe: true
    });

    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);

    // 3. リサイズハンドラー
    const handleResize = () => {
      if (!mountRef.current) return;
      const newWidth = mountRef.current.clientWidth;
      camera.aspect = newWidth / height;
      camera.updateProjectionMatrix();
      renderer.setSize(newWidth, height);
    };
    window.addEventListener('resize', handleResize);

    // 4. アニメーションループ
    let animationFrameId;
    const animate = (time) => {
      animationFrameId = requestAnimationFrame(animate);

      mesh.rotation.x = time * 0.0002;
      mesh.rotation.y = time * 0.0003;
      material.uniforms.time.value = time * 0.001;

      renderer.render(scene, camera);
    };
    animate(0);

    // 5. クリーンアップ処理(MDXでのページ遷移対策)
    return () => {
      cancelAnimationFrame(animationFrameId);
      window.removeEventListener('resize', handleResize);
      if (mountRef.current) {
        mountRef.current.removeChild(renderer.domElement);
      }
      geometry.dispose();
      material.dispose();
      renderer.dispose();
    };
  }, []);

  return (
    <div
      ref={mountRef}
      style={{
        width: '100%',
        height: '400px',
        borderRadius: '8px',
        overflow: 'hidden',
        backgroundColor: '#000'
      }}
    />
  );
};

export default CgChronicle14Demo;