[CGクロニクル #11] 解き放たれた計算:プログラマブルシェーダーの到来と画素の魂

[CGクロニクル #11] 解き放たれた計算:プログラマブルシェーダーの到来と画素の魂

はじめに

前回の第10回では、4x4の変換行列が世界を支配していた「固定パイプライン」の時代を振り返った。そこはハードウェアの配線に焼き付けられた、絶対的で不変のルールが支配する世界だった。開発者は用意されたスイッチをON/OFFすることしか許されず、あらかじめ決められた「質感」のカタログからそれらしいものを選ぶことしかできなかった。

しかし2000年代初頭、グラフィックスの歴史において最も劇的で、最も暴力的なまでのパラダイムシフトが起こる。「プログラマブルシェーダー」の誕生である。

シリコンに刻まれた鎖は断ち切られ、GPUというブラックボックスの蓋が開かれた。我々はついに、ピクセル一つひとつの振る舞いを自分たちの手で直接プログラムする自由を手に入れたのだ。

ピクセルの実存主義 — 与えられた運命から、自ら選び取る「色」へ

「実存は本質に先立つ」。ジャン=ポール・サルトルが提唱したこの哲学の命題は、人間の自由と主体性を説いたものだが、奇しくも2000年代初頭のグラフィックス・ハードウェアに起きた革命を完璧に言い表している。

固定パイプラインの時代、ピクセルの「本質」はあらかじめ決定されていた。グラフィックスボードのシリコンには、「光はこのように計算され、ポリゴンはこのように塗りつぶされるべきだ」という絶対的なルールが物理的な配線として焼き付けられていた。我々開発者は、APIを通じて用意されたスイッチ(例えば GL_LIGHT0GL_FOG)をONにし、パラメータという名の「お伺い」を立てることしかできなかった。そこでは、画面上の点は描画される前から「すでにどのように振る舞うべきか」という決定論的な運命を背負わされていたのだ。

しかし、プログラマブルシェーダー(特にフラグメントシェーダー)の登場によって、その運命の鎖はついに断ち切られた。

シェーダーの時代において、画面上に敷き詰められた数百万のピクセルは、ただそこに gl_FragCoord として存在するだけの「無地のキャンバス」、すなわち純粋な「実存」としてまず世界に投げ出される。そこにプリセットの質感は存在しない。

それが鈍く光る削り出しの金属になるのか、深海のように光を透過する屈折体になるのか、あるいはカミュが描くような世界の不条理を体現した歪な空間への窓になるのか。その振る舞いのすべては、我々が void main() の中に記述するGLSLという「自由意志」に完全に委ねられている。何でも描けるという究極の自由は、同時に「自ら光の振る舞いを数式で一から定義しなければならない」という圧倒的な責任を伴うものだった。

だからこそ、我々はこの空白を満たすために数式を書き、光をハックする。無機質なピクセルの配列に、自然界の持つ有機的な「ゆらぎ」を持ち込むために、ノイズという強力な表現手段を極めようとする。単なる乱数ではない、計算し尽くされたノイズの波脈は、数式を通じて画面に感情を宿らせるための最大の武器となるからだ。

与えられた設定値を調整するだけの「座標の変換者」から、数式によって画素一つひとつに魂を吹き込む「創造主」へ。プログラマブルシェーダーがもたらした真の革命とは、単なる描画表現の向上ではなく、0と1の世界における「ピクセルはどうあるべきか」という哲学的なパラダイムシフトそのものだったのだ。

極限の非同期処理と、構築の悦び — 午前4時の厨房とSIMDアーキテクチャ

シェーダープログラミングの世界に足を踏み入れると、これまでのCPU向けのプログラミングとは根本的に異なるパラダイムに直面する。それは「SIMD(Single Instruction, Multiple Data)」と呼ばれる実行モデルだ。

CPUのプログラムが、一つの作業を順番にこなしていく優秀な単独の職人だとすれば、GPUのフラグメントシェーダーは、画面上の数百万のピクセルすべてに対して、全く同じ指示(カーネル)を同時多発的に実行する巨大な軍団である。フルHDの画面であれば、毎フレーム約200万回の計算が並列で走る。

この壮大な並列処理は、例えるならば、いくつものプロセスを同時に並行して進める「極限の非同期処理」に他ならない。それはどこか、日々の料理という営みによく似ている。まだ世界が眠りについている午前4時過ぎの静寂の中、火口を複数同時に操り、食材の下ごしらえを進めながら、オーブンの時間を計算する。そして数時間後の決まった瞬間に、数々の品数を完璧な温度とタイミングで完成させる。そこには無駄な動線は許されず、全体を見渡す「高度な最適化」が常に要求される。

シェーダーの記述も全く同じだ。GPU内部の演算ユニットは驚異的な速度を持つが、同時に非常にシビアである。条件分岐(if文)による処理の乱れや、テクスチャの読み込み待ち、あるいは不要な数学関数の呼び出しといった「たった1行の無駄」が、数百万倍に増幅されて遅延となって跳ね返ってくる。処理落ち(フレームドロップ)という形で即座に結果が突きつけられる、一切の甘えが許されない厳格な世界だ。

しかし、我々エンジニアはこの厳しさに「追い込まれている」わけでは決してない。息苦しい義務感やプレッシャーから、ストイックにコードを削り詰めているわけではないのだ。

複雑に絡み合う非同期のパズルを解き明かし、GPUというハードウェアの演算リソースを限界の限界まで引き出す。そして、数式がピタッと噛み合った瞬間に、無機質なディスプレイの上に自分だけの美しい光景が立ち上がる。そのプロセス自体が、ただひたすらに面白く、ワクワクするからだ。

「やりたいから、やっている」。その純粋で強烈な好奇心と、自分の手で世界を構築していく抗いがたい悦び。それこそが、誰に強いられるでもなく毎朝厨房に立つ理由と同じように、現代のリアルタイムCGを限界の先へと押し上げてきた最も強力なエンジンなのである。

ポリゴンの死:レイマーチングという究極の自由 — 数式が削り出す無限の宇宙

ピクセル単位で空間を計算する圧倒的な力を得た我々は、やがてグラフィックスにおける最も根源的な構成要素、「ポリゴン(多角形)」という概念さえも捨て去るという極端な遊びに行き着いた。その極北に位置する手法が「レイマーチング(Sphere Tracing)」である。

これまでCGの世界を構築する絶対的な基本単位は、頂点を結んで作られる三角形のメッシュだった。しかしレイマーチングにおいて、GPUに転送される頂点データは実質的に一切存在しない。画面全体を覆う、たった2枚の三角形(Quad)というキャンバスさえ用意すれば、それで十分なのだ。

外部から読み込んだ3Dモデルデータに頼るのではなく、何もないゼロの状態から、すべてを自らの手で計算して描き出す。それは、限られたリソースしか持たない初期のハードウェアを与えられ、そこから己の頭脳とコードだけを武器に世界のロジックから描画までを自力で組み上げていった、かつてのハッカー的な精神性の究極の延長線上にあるのかもしれない。

描画の手法はこうだ。視点(カメラ)から画面の各ピクセルに向かって、光の線(レイ)を放つ。そして、空間上の任意の座標から「最も近い物体までの距離」を返す関数、「距離関数(SDF: Signed Distance Field)」を用いて、レイを少しずつ前進させていく。レイが数式上の物体との距離ゼロ(衝突)を検出するまで、この計算ループを延々と繰り返すのだ。

そこに幾何学的なメッシュの「実体」はない。あるのは、純粋な数学的空間だけである。

このアプローチがもたらす自由は桁外れだ。例えば、空間の座標に対して mod(剰余)演算を一つ適用するだけで、たった一つのオブジェクトは無限に反復する柱の回廊となる。空間そのものを数学的に折りたたみ(Fold)、ねじ曲げ、あるいは「Smooth Min(滑らかな最小値)」関数を用いて、異なる物体同士を水滴のようにぬらぬらと融合させることもできる。

そして、この純粋な数式の世界に「ノイズ」というゆらぎを介入させることで、冷たい幾何学は一瞬にして有機的な地形や、不定形の雲海へと姿を変える。かつてケン・パーリンがカオスを制御したように、我々はフラグメントシェーダーという言語を用いて、無限に広がるフラクタルや宇宙そのものを、ブラウザという窓の中に直接記述できるようになったのである。

計算がハードウェアの制約から完全に解き放たれたとき、我々のキャンバスはついに「無限大」へと拡張されたのだ。

【一分間の数式美】レイマーチングで描く、無限のフラクタル

今回の作品は、外部の3Dモデルを一切使用しない。GLSLのフラグメントシェーダーのみで空間の距離を計算し、無限に折り畳まれるフラクタル構造の内部を探索する。数行のループと空間の絶対値をとる計算だけで、圧倒的なディテールが立ち上がる「解き放たれた計算」の証明である。

precision highp float;
uniform vec2 u_resolution;
uniform float u_time;

// 空間の回転行列
mat2 rot(float a) {
    float s = sin(a), c = cos(a);
    return mat2(c, -s, s, c);
}

// 距離関数:空間を折り畳み、フラクタル構造を生成
float map(vec3 p) {
    vec3 q = p;
    float res = 0.0;

    // 空間の反復と折りたたみ
    for (int i = 0; i < 4; i++) {
        q = abs(q) - 1.5;
        q.xy *= rot(u_time * 0.2 + float(i));
        q.xz *= rot(u_time * 0.3);
    }

    // 最終的な形状(箱)との距離
    vec3 d = abs(q) - vec3(0.5);
    return length(max(d, 0.0)) + min(max(d.x, max(d.y, d.z)), 0.0);
}

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

    // カメラとレイの設定
    vec3 ro = vec3(0.0, 0.0, -5.0); // レイの起点
    vec3 rd = normalize(vec3(uv, 1.0)); // レイの方向

    float t = 0.0; // 進んだ距離
    float d = 0.0; // 最短距離

    // レイマーチングのループ
    for(int i = 0; i < 64; i++) {
        vec3 p = ro + rd * t;
        d = map(p);
        if(d < 0.001 || t > 20.0) break;
        t += d;
    }

    // 深度ベースのシンプルなカラーリング
    vec3 col = vec3(0.0);
    if(d < 0.001) {
        float glow = 1.0 - (t / 20.0);
        // ノイズ的な揺らぎを内包した光の表現
        col = vec3(0.2, 0.5, 0.8) * glow + vec3(0.8, 0.3, 0.2) * smoothstep(0.5, 1.0, glow);
    }

    gl_FragColor = vec4(col, 1.0);
}

このコードが実行される瞬間、GPUの内部では数百万のコアが一斉にこの数式を評価し、沈黙の中で壮大な構造を削り出している。

次回、第12回「影を落とす恐怖(シャドウマップの苦闘)」では、この自由を手に入れたがゆえに直面した、リアルタイムCGにおける最も重く、最も難解なテーマである「影」との戦いに焦点を当てる。

サンプル

mod 関数による座標のループと、abs と回転を組み合わせた空間の折りたたみ(Fold)が、いかにしてメッシュ(頂点)なしで無限の構造を生み出すか。ページをスクロールした瞬間に、その非同期計算の暴力的なまでの美しさがブラウザ上で直接動き出すはずです。

// src/components/CGCchronicle/RaymarchingCanvas.tsx

import React, { useRef, useMemo } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import * as THREE from 'three';

const fragmentShader = `
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;

// 空間の回転行列
mat2 rot(float a) {
    float s = sin(a), c = cos(a);
    return mat2(c, -s, s, c);
}

// 距離関数:空間の反復と折りたたみによる無限フラクタル
float map(vec3 p) {
    vec3 q = p;

    // 空間を無限にループさせる(mod演算の魔法)
    q.xz = mod(q.xz + 2.0, 4.0) - 2.0;

    // 空間の折りたたみ(Fold)によるフラクタル生成
    for (int i = 0; i < 4; i++) {
        q = abs(q) - 1.0;
        q.xy *= rot(u_time * 0.2 + float(i));
        q.xz *= rot(u_time * 0.3);
    }

    // 最終的なプリミティブ(箱)との距離
    vec3 d = abs(q) - vec3(0.5);
    return length(max(d, 0.0)) + min(max(d.x, max(d.y, d.z)), 0.0);
}

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

    // カメラの設定:Z軸奥へと延々と前進し続ける
    vec3 ro = vec3(0.0, 0.0, -u_time * 2.0);
    vec3 rd = normalize(vec3(uv, 1.0));

    // カメラの揺らぎ
    rd.xy *= rot(sin(u_time * 0.2) * 0.5);

    float t = 0.0; // レイが進んだ距離
    float d = 0.0; // 最短距離

    // レイマーチングの心臓部:極限のループ計算
    for(int i = 0; i < 64; i++) {
        vec3 p = ro + rd * t;
        d = map(p);
        // 衝突、あるいは遠方まで到達したらループを抜ける
        if(d < 0.001 || t > 30.0) break;
        t += d;
    }

    // 深度ベースの色付け
    vec3 col = vec3(0.0);
    if(d < 0.001) {
        float glow = 1.0 - (t / 30.0);
        // 青からマゼンタへ、深海のような発光表現
        col = vec3(0.1, 0.4, 0.9) * glow + vec3(0.9, 0.2, 0.5) * smoothstep(0.4, 1.0, glow);
    }

    gl_FragColor = vec4(col, 1.0);
}
`;

const vertexShader = `
void main() {
    gl_Position = vec4(position, 1.0);
}
`;

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

  const uniforms = useMemo(
    () => ({
      u_time: { value: 0 },
      u_resolution: { value: new THREE.Vector2(size.width, size.height) },
    }),
    [size]
  );

  useFrame((state) => {
    if (materialRef.current) {
      materialRef.current.uniforms.u_time.value = state.clock.elapsedTime;
    }
  });

  return (
    <mesh>
      {/* 画面全体を覆う板ポリゴン */}
      <planeGeometry args={[2, 2]} />
      <shaderMaterial
        ref={materialRef}
        vertexShader={vertexShader}
        fragmentShader={fragmentShader}
        uniforms={uniforms}
        depthWrite={false}
        depthTest={false}
      />
    </mesh>
  );
};

export default function RaymarchingCanvas() {
  return (
    <div style={{ width: '100%', height: '400px', borderRadius: '8px', overflow: 'hidden' }}>
      <Canvas orthographic camera={{ position: [0, 0, 1], zoom: 1 }}>
        <RaymarchingMaterial />
      </Canvas>
    </div>
  );
}