[CGクロニクル #10] シリコンに刻まれた行列 — 固定パイプラインの時代

[CGクロニクル #10] シリコンに刻まれた行列 — 固定パイプラインの時代

はじめに

夜明け前の静寂の中、エディタの黒い画面に向かっていると、思考が研ぎ澄まされていくのを感じることがある。不確実な現実世界とは対照的に、コードの世界はどこまでも論理的だ。

1990年代後半から2000年代初頭。PCの傍らで巨大なブラウン管モニタが熱を帯びていた時代、3Dグラフィックスの世界に革命が起きた。CPUによるソフトウェア・レンダリングの限界を突破するため、グラフィックス処理専用のハードウェア、すなわち「3Dアクセラレータ」が産声を上げたのだ。

Voodoo、RIVA TNT、そして初代GeForce。彼らの心臓部であるシリコンチップには、ある特定の計算式が物理的な回路として焼き付けられていた。それが「固定機能パイプライン(Fixed-Function Pipeline)」の時代である。

前回の記事:

魔法の16個の数字:世界を定義する4x4のマトリクス

固定パイプラインの時代、世界を支配していたのは「4x4行列」だった。

画面上に描かれるすべての頂点は、最終的にモニターのピクセルへと変換されるまでに、いくつもの次元と空間を旅する。モデリングソフトで作られたままの「ローカル空間」から、すべてのオブジェクトが配置される「ワールド空間」へ。そこからカメラの視点を基準とした「ビュー空間」へ移行し、最後に遠近法を適用して2Dの画面に押し潰す「プロジェクション空間」へと至る。

この果てしなく複雑な空間の変換劇を、ただの「掛け算」として一挙に処理してしまうのが、同次座標系(Homogeneous Coordinates)と4x4行列がもたらした最大の魔法だ。

(xyzw)=(m00m01m02m03m10m11m12m13m20m21m22m23m30m31m32m33)(xyz1)\begin{pmatrix} x' \\ y' \\ z' \\ w' \end{pmatrix} = \begin{pmatrix} m_{00} & m_{01} & m_{02} & m_{03} \\ m_{10} & m_{11} & m_{12} & m_{13} \\ m_{20} & m_{21} & m_{22} & m_{23} \\ m_{30} & m_{31} & m_{32} & m_{33} \end{pmatrix} \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix}

4次元目のハック:なぜ「W」が必要だったのか

3D空間は x,y,zx, y, z の3つの数値で表される。スケール(拡大縮小)や回転といった操作は、3x3の行列の「掛け算」で表現できる。しかし、平行移動(位置をずらすこと)だけは「足し算」でしか表現できない。

もし計算式に掛け算と足し算が混在していれば、ハードウェアの回路は複雑化し、処理速度は致命的に落ちてしまう。すべてを「一回の行列の掛け算」で統一したい。この純粋な論理的要請から採用されたのが、4つ目の要素 ww を追加する同次座標系という数学的ハックだった。

w=1w=1 というダミーの次元を足すことで、平行移動という本来は足し算であるはずの操作が、4x4行列の右端の列(m03,m13,m23m_{03}, m_{13}, m_{23})との掛け算としてエレガントに吸収される。ハードウェアはただ愚直に、流れてくる何万もの頂点に対して、この16個の数字を掛け合わせるだけでよくなったのだ。

MVP行列:空間を重ね合わせる掛け算

この統一された計算手法により、先ほどの「空間の旅」は劇的に簡略化された。ローカル(Model)、ビュー(View)、プロジェクション(Projection)の各変換行列をあらかじめ掛け合わせて一つの「MVP行列」を作ってしまえば、どんなに複雑な視点変更も、頂点への1回の掛け算で終わる。

vclip=MprojectionMviewMmodelvlocalv_{clip} = M_{projection} \cdot M_{view} \cdot M_{model} \cdot v_{local}

遠くのものほど小さく見える「パースペクティブ(遠近法)」すらも、この計算の終盤で ww の値(Z深度に比例する)で x,y,zx, y, z を割り算する(Perspective Divide)という仕組みとして組み込まれている。現実の物理法則すら、この16マスの格子の中に内包されているのだ。

glPushMatrixとglPopMatrixの哲学

当時のプログラマたちは、この行列計算を制御するために glPushMatrix()glPopMatrix() というAPIを駆使した。

たとえば、人間の腕を描画するとしよう。肩を回せば、それにくっついている肘も手首も一緒に動かなければならない。プログラマは、まず肩の回転行列を「スタックに積む(Push)」。次に肘の回転を加え、腕を描く。描き終わったらスタックから一つ前の状態を「取り出す(Pop)」。すると、空間の基準は再び肩に戻り、今度はそこから胸や頭を描き始めることができる。

これは単なる描画の命令ではない。コードの構造そのものが、空間の階層関係(シーングラフ)を物理的に記述していたのだ。

用意された16個の枠組みと、それを積み上げるスタック構造。これら全く性質の異なる操作が、限られた数字の並びの中に封じ込められている。そこにあるのは、与えられた制約の中でいかにして複雑な現実の構造を模倣するかという、純粋で美しい論理のパズルだった。

それは、極限まで無駄を削ぎ落としたマルチスレッド処理のコードを組む時のように、あるいは何十回も試行錯誤を繰り返しながらノイズアルゴリズムの最適解を探し当てる時のように。ハードウェアの理(ことわり)を深く理解し、その上で自分だけのロジックを完璧なタイミングで回し切ったときの、あのゾクゾクするような知的な興奮。当時の技術者たちは、シリコンの限界に挑むその過程自体を、心から面白がり、ワクワクしながら楽しんでいたに違いない。

状態機械という名の小宇宙:制限が生んだ創造性

当時のOpenGLやDirectXのアーキテクチャは、現代の視点から見れば巨大で厳格な「ステートマシン(状態機械)」だった。

システム全体がひとつの大きな状態空間を持っており、プログラマはコントロールパネルのスイッチをパチパチと切り替えていくようにコードを記述する。glEnable(GL_LIGHTING) で光の計算をオンにし、glLightfv で光源(GL_LIGHT0など)の位置や色を指定し、マテリアルの反射率をセットする。これらの「状態」をすべて完璧に整えた上で、最後に頂点データを一気にパイプラインへと流し込む。

すると、ビデオカード上のシリコンにハードワイヤード(物理的に結線)された専用回路が目を覚まし、T&L(Transform & Lighting:座標変換と光源計算)と呼ばれる固定の演算を猛烈な勢いで処理していく。かつてCPUが悲鳴を上げながら処理していた膨大な行列計算を、専用ハードウェアが力技でねじ伏せたのだ。これにより、リアルタイムで描画できるポリゴン数は文字通り桁違いに跳ね上がった。

しかし、そこには後年のプログラマブルシェーダーがもたらすような「自由」はなかった。計算式そのものをプログラマが直接書き換えることはできず、ハードウェア側で用意されたスイッチのON/OFFと、あらかじめ決められたパラメータの調整しか許されていなかった。

だが、当時のエンジニアたちはその強固な壁に絶望したわけではない。むしろ、そのガチガチに制限されたルールの中で「どうすればシリコンの限界を引き出し、誰も見たことのない絵を出せるか」という純粋な実験精神に突き動かされていた。半透明のポリゴンを何度も重ね描きするマルチパス・レンダリングで本来できないはずの表現を捏造し、テクスチャ・コンバイナのレジスタをアナログシンセサイザーのパッチケーブルのように複雑に繋ぎ合わせて、未知の質感を絞り出す。

それは、限られたコンロの数と食材というリソースを極限まで最適化し、完璧なタイミングで非同期処理を回し切る「料理」のプロセスにもどこか似ている。ハードウェアの仕様という絶対的な法則を深く理解し、その上で自分だけのロジックをギリギリまで組み上げる。

そこには、何か重苦しい困難を克服して「勝ち誇る」ような感情はない。ただただ純粋な知的好奇心と、コードとして記述した論理が、画面の中で物理的な「光」へと変わる瞬間の、圧倒的な面白さがあった。好きだからこそ、ワクワクするからこそ、彼らは夜を徹してマニュアルの隅を読み込み、数々のハックを生み出し続けたのだ。

シリコンに刻まれた行列と固定パイプラインは、決してクリエイティビティの足枷などではなかった。それは、3Dグラフィックスがリアルタイムの世界へ飛び立つための強靭な骨格であり、当時の開発者たちが遊び尽くした至高の箱庭だったのだ。


一分間の数式美:マトリクスの万華鏡

固定パイプラインの時代、行列は世界を構築するための絶対的な法則だった。その純粋な力へのオマージュとして、今回は行列演算(Matrix operations)の反復のみを用いて空間を幾重にも折り畳む、幾何学的な万華鏡をGLSLで表現する。

ここで使うのは、4x4の巨大な行列ではなく、最もミニマルな2x2の回転行列だ。

空間の座標(p)に対して、絶対値をとる関数 abs(p) で空間のマイナス領域をプラス領域へと反転させ、中心へ向かって「折り紙」のように畳み込む。そして、時間が経過するごとに角度を変える回転行列を掛け合わせ、空間ごと回す。

// CG Chronicle #10: Matrix Kaleidoscope
precision highp float;
uniform vec2 resolution;
uniform float time;

// 2x2の回転行列を生成する関数
mat2 rot(float a) {
    float s = sin(a), c = cos(a);
    return mat2(c, -s, s, c);
}

void main() {
    // 画面中央を原点とし、アスペクト比を補正
    vec2 p = (gl_FragCoord.xy * 2.0 - resolution.xy) / min(resolution.x, resolution.y);

    // 時間経過によるスケールと回転のベース
    float t = time * 0.2;
    vec3 color = vec3(0.0);

    // 空間の折り畳みと行列の乗算ループ
    for(int i = 0; i < 6; i++) {
        // 空間の絶対値をとることで「鏡」のようにはね返る
        p = abs(p) - 0.5;

        // 毎回異なる角度で回転行列を掛ける(空間を回す)
        p *= rot(t + float(i) * 0.7853);

        // ベクトルの長さに応じて色を蓄積
        float d = length(p);
        color += vec3(
            exp(-d * 3.0),
            exp(-d * 4.0) * 0.8,
            exp(-d * 5.0) * 2.0
        ) * 0.4;
    }

    // コントラスト調整とヴィネット
    color = pow(color, vec3(1.2));
    float vignette = 1.0 - length(gl_FragCoord.xy / resolution.xy - 0.5) * 1.5;

    gl_FragColor = vec4(color * vignette, 1.0);
}

たった数回の行列演算のループが、予測不可能なカオスの手前で踏みとどまり、鋭利で美しいシンメトリーを生み出していく。ベクトルの長さ(length(p))に応じて指数関数的(exp)に減衰する光を蓄積させることで、空間の歪みがネオンのような熱を帯びて浮かび上がる。

このコードを実行すると、暗闇の中に幾何学的な模様が無限に分裂と結合を繰り返す光景が現れるだろう。それはまるで、かつてのエンジニアたちが操っていた空間変換の魔法を、万華鏡の底に閉じ込めたかのような体験だ。空間を直接ねじ曲げ、折り畳むという「行列」の最も純粋な面白さを、視覚として感じてみてほしい。

サンプル

import React, { useRef, useEffect } from 'react';

// 頂点シェーダー(画面全体に1枚のポリゴンを貼るだけ)
const vertexShaderSource = `
  attribute vec2 position;
  void main() {
    gl_Position = vec4(position, 0.0, 1.0);
  }
`;

// フラグメントシェーダー(先ほどの数式美のコード)
const fragmentShaderSource = `
  precision highp float;
  uniform vec2 resolution;
  uniform float time;

  mat2 rot(float a) {
      float s = sin(a), c = cos(a);
      return mat2(c, -s, s, c);
  }

  void main() {
      vec2 p = (gl_FragCoord.xy * 2.0 - resolution.xy) / min(resolution.x, resolution.y);
      float t = time * 0.2;
      vec3 color = vec3(0.0);

      for(int i = 0; i < 6; i++) {
          p = abs(p) - 0.5;
          p *= rot(t + float(i) * 0.7853);
          float d = length(p);
          color += vec3(
              exp(-d * 3.0),
              exp(-d * 4.0) * 0.8,
              exp(-d * 5.0) * 2.0
          ) * 0.4;
      }

      color = pow(color, vec3(1.2));
      float vignette = 1.0 - length(gl_FragCoord.xy / resolution.xy - 0.5) * 1.5;
      gl_FragColor = vec4(color * vignette, 1.0);
  }
`;

const MatrixKaleidoscope = ({ width = "100%", height = "400px" }) => {
  const canvasRef = useRef(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
    if (!gl) return;

    // シェーダーのコンパイル関数
    const compileShader = (type, source) => {
      const shader = gl.createShader(type);
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
        console.error(gl.getShaderInfoLog(shader));
        gl.deleteShader(shader);
        return null;
      }
      return shader;
    };

    const vertexShader = compileShader(gl.VERTEX_SHADER, vertexShaderSource);
    const fragmentShader = compileShader(gl.FRAGMENT_SHADER, fragmentShaderSource);

    // プログラムの作成とリンク
    const program = gl.createProgram();
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);
    gl.linkProgram(program);
    gl.useProgram(program);

    // 画面全体を覆う矩形(2つの三角形)の頂点データ
    const vertices = new Float32Array([
      -1.0, -1.0,   1.0, -1.0,  -1.0,  1.0,
      -1.0,  1.0,   1.0, -1.0,   1.0,  1.0
    ]);
    const buffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

    // 頂点属性の紐付け
    const positionLocation = gl.getAttribLocation(program, "position");
    gl.enableVertexAttribArray(positionLocation);
    gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);

    // uniform変数のロケーション取得
    const resolutionLocation = gl.getUniformLocation(program, "resolution");
    const timeLocation = gl.getUniformLocation(program, "time");

    let animationFrameId;
    const startTime = performance.now();

    // 描画ループ
    const render = () => {
      // キャンバスのピクセルサイズを実際の表示サイズに合わせる
      const rect = canvas.getBoundingClientRect();
      canvas.width = rect.width * window.devicePixelRatio;
      canvas.height = rect.height * window.devicePixelRatio;

      gl.viewport(0, 0, canvas.width, canvas.height);

      const currentTime = (performance.now() - startTime) / 1000.0;
      gl.uniform2f(resolutionLocation, canvas.width, canvas.height);
      gl.uniform1f(timeLocation, currentTime);

      gl.drawArrays(gl.TRIANGLES, 0, 6);
      animationFrameId = requestAnimationFrame(render);
    };

    render();

    // クリーンアップ処理
    return () => {
      cancelAnimationFrame(animationFrameId);
      gl.deleteProgram(program);
      gl.deleteShader(vertexShader);
      gl.deleteShader(fragmentShader);
      gl.deleteBuffer(buffer);
    };
  }, []);

  return (
    <div style={{ width, height, overflow: 'hidden', borderRadius: '8px', margin: '2rem 0' }}>
      <canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block' }} />
    </div>
  );
};

export default MatrixKaleidoscope;