[CGクロニクル #07] 光の粒子を追いかけて(レイトレーシングの衝撃)

[CGクロニクル #07] 光の粒子を追いかけて(レイトレーシングの衝撃)

はじめに

これまでの連載で見てきたグーローやフォンのシェーディングは、確かにCGに「滑らかさ」と「艶」をもたらした。しかし、それらはあくまで個々のポリゴンが「自分自身をどう塗るか」という局所的な計算(ローカルイルミネーション)に過ぎなかった。

鏡に映る別の物体。
ガラス玉の向こう側で歪む景色。
床に落ちる深い影。

これらを表現しようとした途端、かつてのアルゴリズムは沈黙した。なぜなら、真の写実性を手に入れるためには、物体単体の計算を越え、「空間を飛び交う光の軌跡」そのものをシミュレートしなければならなかったからだ。

1980年、ターナー・ウィテッド(Turner Whitted)という一人の研究者が発表した論文「An Improved Illumination Model for Shaded Display」は、CGの世界に革命をもたらした。再帰的レイトレーシング(Recursive Ray Tracing)の誕生である。

前回の記事:

視点の逆転:世界は「観測者」から始まる

現実の物理世界における光の振る舞いは、あまりにも無駄が多く、そして残酷なまでに無頓着だ。太陽や電球といった光源から放たれた無数の光子(フォトン)は、あらゆる物体に幾度も反射・屈折を繰り返し、エネルギーを減衰させながら空間を飛び交う。その天文学的な数の光子のうち、ごくごく一部が偶然、私たちの網膜(あるいはカメラのレンズ)に到達したとき、初めてそこに「像」が結ばれる。

もし、この自然界のプロセスをそのままコンピュータ上でシミュレート(フォワード・レイトレーシング)しようとすればどうなるか。放たれた大半の光は画面(視点)には永遠に届かず、虚無へと消えていく。それは暗闇の中で偶然の一撃を待つようなものであり、当時の、いや現代の計算機資源をもってしても非現実的なアプローチだった。

そこでレイトレーシングは、物理法則の模倣を捨て、計算幾何学における劇的なパラダイムシフトを行った。それが「視点の逆転」である。

光源から光の終着点を探すのではなく、カメラ(視点)という観測者の位置から、画面を構成するピクセルの一つ一つを通して、空間の奥深くへ向かって「視線(レイ)」を逆再生で撃ち放つのだ。

世界がそこにあるから光を受容して見るのではない。観測者が視線を投射するからこそ、そこに世界が立ち現れる。これは「自己の認識が世界を形作る」という実存主義的な哲学を、計算機科学の領域でそのまま実践したかのようなアプローチと言える。

この空間を切り裂く「視線(レイ)」を定義する数式は、驚くほどシンプルで美しい。 三次元空間において、視点の座標をO\mathbf{O}(Origin)、レイが向かう正規化された方向ベクトルをD\mathbf{D}(Direction)としたとき、レイ上の任意の点P\mathbf{P}は、距離を表す媒介変数ttt>0t>0)を用いて次のように記述される。

P(t)=O+tD\mathbf{P}(t)=\mathbf{O}+t\mathbf{D}

計算機がやるべきことは、この無限に伸びる直線P(t)\mathbf{P}(t)が、空間内に存在する幾何学オブジェクトの表面と「いつ、どこで交差するか」を数学的に解くことだ。

例えば、空間に浮かぶ中心C\mathbf{C}、半径rrの「球(Sphere)」の表面は以下の数式で定義される。

PC2=r2|\mathbf{P}-\mathbf{C}|^2=r^2

この球の式におけるP\mathbf{P}に、先ほどのレイの式P(t)\mathbf{P}(t)を代入して結びつける。

(O+tD)C2=r2|(\mathbf{O}+t\mathbf{D})-\mathbf{C}|^2=r^2

これを展開して整理すると、ttを変数とする美しい二次方程式at2+bt+c=0at^2+bt+c=0が姿を現す。あとは判別式Δ=b24ac\Delta=b^2-4acを評価するだけだ。 判別式が正(Δ>0\Delta>0)であれば、視線は球を貫通しており、得られたttの解のうち最も小さい正の値が、まさに「ピクセルに映る表面の座標」となる。

我々が世界を認識するプロセスそのものを極限まで削ぎ落とし、純粋な代数幾何学へと昇華させたこの解法。これこそが、CGが真の写実性へと歩み出すための最初の、そして最も重要な数式だった。

再帰する光:反射と屈折の数学

視点から放たれた視線(レイ)が、ついに物体の表面と交差する。局所的な光の計算(ローカルイルミネーション)の時代であれば、ここでその表面の色を計算し、処理は終了していたはずだ。しかし、レイトレーシングの真の美しさは、レイが物体に衝突した「その先」の振る舞いにある。

光沢のある鏡面や、透明なガラス玉にレイが衝突したとき、アルゴリズムはそこで沈黙しない。レイは新たなレイを生み出し、幾何学的な法則に従って空間のさらに奥深くへと分岐していくのである。

反射(Reflection) 完璧に磨き上げられた鏡面で光がどのように跳ね返るか。入射する視線ベクトルをI\mathbf{I}、表面の法線ベクトルをN\mathbf{N}(どちらも正規化済みとする)としたとき、反射ベクトルR\mathbf{R}は内積を用いた鮮やかなベクトル演算によって導き出される。

R=I2(IN)N\mathbf{R}=\mathbf{I}-2(\mathbf{I}\cdot\mathbf{N})\mathbf{N}

この数式が意味するのは、「入射ベクトルI\mathbf{I}から、法線方向の成分の2倍を引き算する」という純粋な幾何学的操作だ。ただこれだけの式が、周囲の景色を歪みなく映し出す完璧な鏡を計算機の中に創り出す。

屈折(Refraction) 一方、レイが水やガラスといった透明な媒質に突入したとき、光は曲がる。この透過する光の軌跡は、17世紀に発見された「スネルの法則(Snell’s Law)」という物理法則に完全に支配されている。入射側の媒質の屈折率をη1\eta_1、屈折側の屈折率をη2\eta_2とし、それぞれの角度をθ1\theta_1θ2\theta_2としたとき、その関係は以下の美しい等式で結ばれる。

η1sinθ1=η2sinθ2\eta_1\sin\theta_1=\eta_2\sin\theta_2

空気(η1.0\eta\approx1.0)からガラス(η1.5\eta\approx1.5)へ。媒質の密度が変わる境界で、計算機は三角関数を用いて光の進行方向を正確にねじ曲げる。

レイは鏡に当たれば「反射レイ」を飛ばし、ガラスに当たれば「反射レイ」と「屈折レイ」の2本に分裂する。そして、新しく生まれたレイがまた別の物体に衝突すれば、さらに新たなレイを生む。

プログラム上で表現するならば、それは自分自身を呼び出し続ける再帰関数(Recursive Function)そのものだ。

色 = 表面の固有色 + 反射率 * trace(反射レイ) + 透過率 * trace(屈折レイ)

この「再帰的(Recursive)」な構造こそが、互いに映り込み合う無限の合わせ鏡の世界や、光がガラスの内部で複雑に屈折する透き通った質感を、CG空間に現出させたのである。

しかし、このエレガントな数式の代償は、あまりにも巨大だった。 一度放たれたレイがねずみ算式に分裂し、その度に空間内のすべての物体との交差判定を繰り返す。これは計算機のメモリと演算能力の限界に真正面から殴り込みをかけるようなものであり、当時としては文字通り「狂気の沙汰」だった。

1980年、ターナー・ウィテッドがこの再帰的レイトレーシングを発表した際、論文に添えられた「チェッカーボードの上に浮かぶ、透明なガラス球と鏡面の球体」という歴史的な1枚の画像をレンダリングするためだけに、当時のメインフレーム(VAX-11/780)は74時間もの間、計算リソースを占有され続けたという。

しかし、莫大な時間を代償にして出力されたその静止画は、それまでの「プラスチックのおもちゃ」のようなCGを見慣れていた人々に、「現実の光を計算機で模倣できる」という、写真と見紛う未来を確信させるには十分すぎるほどの衝撃を与えたのである。

一分間の数式美:無限に反射し合う球体

かつては何日もかかった光の追跡。現代のGPUとGLSLを用いれば、レイマーチング(Sphere Tracing)と単純なループの組み合わせによって、ブラウザ上でリアルタイムに「無限の反射」を記述できる。

以下は、空間に配置された球体が互いの姿を無限に映し出し合う、わずか数十行のフラグメントシェーダーである。ピクセルごとに放たれた光の粒子が、世界をどのように観測しているのか、その軌跡を感じてほしい。

// GLSL Fragment Shader: Infinite Reflection Spheres
#version 300 es
precision highp float;

out vec4 fragColor;
uniform vec2 u_resolution;
uniform float u_time;

// 空間の距離関数(レイマーチング用)
float map(vec3 p) {
    // 空間を折り畳み、無限に球体を配置
    vec3 q = mod(p + 2.0, 4.0) - 2.0;
    return length(q) - 1.0; // 半径1.0の球
}

// 法線の計算
vec3 getNormal(vec3 p) {
    vec2 e = vec2(0.001, 0.0);
    return normalize(vec3(
        map(p + e.xyy) - map(p - e.xyy),
        map(p + e.yxy) - map(p - e.yxy),
        map(p + e.yyx) - map(p - e.yyx)
    ));
}

void main() {
    // ピクセル座標の正規化
    vec2 uv = (gl_FragCoord.xy * 2.0 - u_resolution.xy) / min(u_resolution.x, u_resolution.y);

    // カメラのセットアップ
    vec3 ro = vec3(u_time * 2.0, 0.0, u_time * 2.0); // 視点(移動する)
    vec3 rd = normalize(vec3(uv, 1.5)); // レイの方向

    vec3 col = vec3(0.0);
    vec3 attenuation = vec3(1.0); // 光の減衰

    // レイのバウンス(再帰的反射のシミュレーション)
    for(int bounce = 0; bounce < 3; bounce++) {
        float t = 0.0;
        // レイマーチングによる衝突判定
        for(int i = 0; i < 64; i++) {
            vec3 p = ro + rd * t;
            float d = map(p);
            if(d < 0.001) break;
            t += d;
            if(t > 20.0) break;
        }

        if(t < 20.0) {
            vec3 p = ro + rd * t;
            vec3 n = getNormal(p);

            // シンプルな環境光と指向性ライト
            vec3 lightDir = normalize(vec3(1.0, 1.0, -1.0));
            float diff = max(dot(n, lightDir), 0.1);

            // 色の蓄積(バウンスするごとに暗くなる)
            col += attenuation * vec3(0.2, 0.5, 0.8) * diff;

            // 次のレイの準備(反射)
            rd = reflect(rd, n);
            ro = p + n * 0.01; // 表面から少し浮かせる
            attenuation *= 0.6; // 反射率
        } else {
            // 背景色(虚無への光の逃避)
            col += attenuation * vec3(0.05, 0.05, 0.1);
            break;
        }
    }

    // ガンマ補正
    fragColor = vec4(pow(col, vec3(1.0/2.2)), 1.0);
}

解説
レイは物体に衝突するたびに reflect 関数によって進路を変え(反射)、再び空間を飛び回ります。ループ回数(bounce < 3)を増やすほど、合わせ鏡のような深い階層の映り込みが計算されますが、その分GPUへの負荷は跳ね上がります。光の計算とは、常に「美しさ」と「計算コスト」のトレードオフとの戦いなのです。

サンプル

レイマーチングによる「無限の反射」を、Reactのコンポーネント内に閉じ込めた純粋なShaderMaterialとして実装しました。

かつてスーパーコンピュータが何日もかけて解いた光の反射の偏微分方程式を、現代では数行のコンポーネントが1秒間に60回、掌の上で平然と解き続ける。そのハードウェアと計算理論の進化のコントラストも、このコードを通じて表現できるはずです。

// src/components/CGCchronicle/07.tsx
import React, { useRef, useMemo } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import * as THREE from 'three';

const RaytracingShaderMaterial = () => {
  const materialRef = useRef<THREE.ShaderMaterial>(null);
  const { size, viewport } = useThree();

  // Uniformsの初期化
  const uniforms = useMemo(
    () => ({
      uTime: { value: 0 },
      uResolution: { value: new THREE.Vector2(size.width, size.height) },
    }),
    [size]
  );

  // 毎フレームごとの時間更新
  useFrame((state) => {
    if (materialRef.current) {
      materialRef.current.uniforms.uTime.value = state.clock.elapsedTime;
    }
  });

  // 画面リサイズ時の解像度更新
  React.useEffect(() => {
    if (materialRef.current) {
      materialRef.current.uniforms.uResolution.value.set(size.width, size.height);
    }
  }, [size]);

  // 頂点シェーダー(画面全体を覆う板用)
  const vertexShader = `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = vec4(position, 1.0);
    }
  `;

  // フラグメントシェーダー(レイトレーシングのコアロジック)
  const fragmentShader = `
    uniform float uTime;
    uniform vec2 uResolution;
    varying vec2 vUv;

    // 空間の距離関数(無限に連続する球体)
    float map(vec3 p) {
        vec3 q = mod(p + 2.0, 4.0) - 2.0;
        return length(q) - 1.0;
    }

    // 法線の計算
    vec3 getNormal(vec3 p) {
        vec2 e = vec2(0.001, 0.0);
        return normalize(vec3(
            map(p + e.xyy) - map(p - e.xyy),
            map(p + e.yxy) - map(p - e.yxy),
            map(p + e.yyx) - map(p - e.yyx)
        ));
    }

    void main() {
        // ピクセル座標の正規化 (-1.0 ~ 1.0) とアスペクト比の補正
        vec2 uv = (vUv - 0.5) * 2.0;
        uv.x *= uResolution.x / uResolution.y;

        // カメラのセットアップ(斜めにゆっくりと前進)
        vec3 ro = vec3(uTime * 1.0, sin(uTime * 0.5) * 0.5, uTime * 1.0);
        vec3 rd = normalize(vec3(uv, 1.5));

        vec3 col = vec3(0.0);
        vec3 attenuation = vec3(1.0);

        // 再帰的なレイのバウンス(最大3回)
        for(int bounce = 0; bounce < 3; bounce++) {
            float t = 0.0;
            // レイマーチングによる衝突判定
            for(int i = 0; i < 64; i++) {
                vec3 p = ro + rd * t;
                float d = map(p);
                if(d < 0.001) break;
                t += d;
                if(t > 20.0) break;
            }

            if(t < 20.0) {
                vec3 p = ro + rd * t;
                vec3 n = getNormal(p);

                // 光源計算
                vec3 lightDir = normalize(vec3(1.0, 1.0, -1.0));
                float diff = max(dot(n, lightDir), 0.1);

                // 色の蓄積(青みがかったガラスのような色)
                col += attenuation * vec3(0.1, 0.4, 0.7) * diff;

                // 反射ベクトルの計算と次のレイの準備
                rd = reflect(rd, n);
                ro = p + n * 0.01; // 表面の自己交差を防ぐためのオフセット
                attenuation *= 0.6; // 反射のたびに光は減衰する
            } else {
                // 背景色(虚無)
                col += attenuation * vec3(0.02, 0.02, 0.05);
                break;
            }
        }

        // ガンマ補正
        gl_FragColor = vec4(pow(col, vec3(1.0/2.2)), 1.0);
    }
  `;

  return (
    <mesh>
      <planeGeometry args={[2, 2]} />
      <shaderMaterial
        ref={materialRef}
        vertexShader={vertexShader}
        fragmentShader={fragmentShader}
        uniforms={uniforms}
        depthWrite={false}
        depthTest={false}
      />
    </mesh>
  );
};

export default function CGChronicle07Demo() {
  return (
    <div style={{ width: '100%', height: '400px', backgroundColor: '#000', borderRadius: '8px', overflow: 'hidden' }}>
      <Canvas camera={{ position: [0, 0, 1] }}>
        <RaytracingShaderMaterial />
      </Canvas>
    </div>
  );
}