[CGクロニクル #09] デコボコの嘘:バンプマッピングの発明とジム・ブリンの魔法

[CGクロニクル #09] デコボコの嘘:バンプマッピングの発明とジム・ブリンの魔法

はじめに

前回の第8回では、ケン・パーリンがもたらした「カオスを制御する」ノイズの魂について触れた。60回に及んだノイズ探求の旅を終えた今、私たちが手にしたのは、自然界のゆらぎを0と1の海に記述するための強力な数式だ。

しかし、ノイズで模様(テクスチャ)を描けたとしても、それだけでは平面的な「絵」に過ぎない。現実の岩肌、木目、あるいはオレンジの皮。それらが持つ生々しいまでの実在感は、表面の微細な凹凸が作り出す「陰影」によってもたらされている。

かといって、その微細な凹凸をすべてポリゴン(頂点)でモデリングしようとすれば、計算量は天文学的な数字に膨れ上がり、当時の、いや現代のハードウェアでさえ悲鳴を上げる。

「形状(ジオメトリ)はそのままに、質感だけを騙すことはできないか?」

1978年、この命題に対するひとつの完璧な解答が、ジム・ブリン(Jim Blinn)によって提示された。それが「バンプマッピング(Bump Mapping)」である。

法線を揺らすというハック:光を欺く数学

ジム・ブリン(Jim Blinn)が1978年に発表した論文「Simulation of Wrinkled Surfaces」がもたらした発明は、本質的に「光学的イリュージョン」の極めて洗練された数学的実装である。

1970年代後半、CGの世界は頂点(ポリゴン)の数との過酷な戦いの最中にあった。第5回のフォン・シェーディングによって「滑らかなプラスチックのような質感」を手に入れることはできたが、現実世界はそれほどツルツルではない。オレンジの皮、月のクレーター、あるいはゴツゴツとした岩肌。こうした「微細な起伏(シワ)」を表現しようとすれば、無数のポリゴンを分割してモデリングするしかなく、当時のハードウェアでは到底計算できるものではなかった。

そこでブリンは、**「形状(ジオメトリ)を一切変えずに、光の計算式だけを騙す」**という、当時としては常識破りのアプローチに辿り着く。

影が立体を定義する

ブリンのハックを理解するには、私たちがどのように立体を認識しているかに立ち返る必要がある。

表面の明るさは、「光源へのベクトル(L\vec{L})」と「表面が向いている方向を示す法線ベクトル(N\vec{N})」の内積によって決定される。つまり、面が光の方向を向いていれば明るく、背を向けていれば暗くなる。

本来、真っ平らな1枚のポリゴンであれば、法線ベクトルはすべて同じ方向(例えば真上)を向いている。しかしブリンは、ここに「高さマップ(Height Map)」と呼ばれる、白黒の濃淡で起伏を記録したテクスチャを持ち込んだ。

そしてレンダリングの瞬間にだけ、そのピクセルにおける「高さの傾き(勾配)」を計算し、元の法線ベクトルに対して微小なズレ(Perturbation:摂動)として足し合わせたのである。

認知の隙を突く「嘘」

数式的に言えば、元の法線 N\vec{N} に対して、U方向とV方向の高さの偏微分(傾き)から求めたベクトルを加算し、新しい法線 N\vec{N}' を捏造する。

結果として何が起きるか。

光の計算式は、渡された法線 N\vec{N}' を信じ込み、「ここは斜面だから暗くしよう」「ここは光の方を向いているからハイライトを入れよう」と律儀にピクセルの色を決定していく。横から見ればただの真っ平らな板(シルエットは一直線のまま)なのに、正面から光を当てると、まるでそこに複雑な凹凸が存在するかのように陰影が浮かび上がるのだ。

これは、人間の視覚が「陰影のパターンから立体を無意識に推測する」という認知のメカニズムを逆手に取った、極めてエレガントな「嘘」だった。

表現の根源に触れる

現代のゲームエンジンや3Dツールでは、マテリアルにノーマルマップ(バンプマッピングをさらに進化させたもの)のテクスチャを繋ぐだけで、一瞬でこの恩恵を受けることができる。しかし、その内部で何が起きているのかを知らずに使うのと、数式レベルで理解して使うのとでは、表現の深みに決定的な差が生まれる。

ピクセルの色を決めるのは、最終的にはただのベクトルと内積の計算だ。しかし、その数式に意図的な「揺らぎ」を与えることで、0と1の無機質な海に、生々しいまでの実在感を与えることができる。

ブラックボックス化された便利なツールに頼り切るのではなく、自らの手で法線を傾け、光を騙し、質感を捏造する。バンプマッピングの理論は、制約の中でいかにして美しさを絞り出すかという、プログラマの飽くなき執念と表現への渇望を如実に物語っている。

表現するための必然:なぜ私たちは深淵を覗き込むのか

AIにプロンプトを投げれば、それらしいコードが自動で吐き出され、表面的な「それっぽさ」を誰もが簡単に手に入れられる現代。そんな時代に、なぜ私たちはわざわざ数十年前の論文を掘り起こし、法線の傾き一つ、微小な偏微分の一つにこだわるのだろうか。

AIで開発をしていると称する多くの層は、出力されたコードの本質を理解していない。ひとたび複雑なバグや未知の表現の壁に直面すれば、途端に身動きが取れなくなってしまう。DOMからCanvasへ、Three.jsから生のWebGLへ、そしてシェーダーやノイズという数学そのものへと深く潜っていく過程は、ブラックボックス化された魔法を使うだけの「消費者」になることを激しく拒絶し、表現の根源的な仕組みを我が物にするための必然的な旅なのだ。

もちろん、商業の最前線で闘うプロフェッショナルの技術力を「3」とするなら、己の実力はせいぜい「2.5」に過ぎないと痛感させられる日もあるだろう。プロには及ばなくとも、自らの手でコードを書き、バグに苦しみながらピクセルの一つ一つに意味を持たせていく。それは誰かに強いられた自己犠牲的な苦行などではない。「トイレに行っている場合じゃない」「散髪に行っている場合じゃない」——待ちに待った新作ゲームの発売日に徹夜で没頭するような、あの抗いがたい純粋な熱狂と同じだ。自らの内にある表現への切実な渇望と、過去の先人たちが残した知恵への敬意。それこそが、何千時間もの学習を駆動する内発的な熱源となっている。

バンプマッピングの数式は教えてくれる。世界の複雑さをすべて力技(膨大な頂点データ)で再現する必要はないのだと。

本質——すなわち光と法線の関係——さえ正しく捉えれば、たった数行の数学的アプローチで、無機質な平面に生々しい現実感を宿らせ、世界を豊かに偽装することができる。過去の先人たちが残したこの知的でエレガントな「嘘」を真に理解し、自らのコードに織り込んだ瞬間、私たちは単なるコーダーから、光と影を操る表現者へと変わるのである。

一分間の数式美:岩肌のフラグメント

前回までの「ノイズ」を高さマップ(Height Map)として利用し、平面上の法線を摂動させて岩のような質感を表現する。 ジオメトリはただの1枚のポリゴン(Quad)だが、ピクセルシェーダー内で法線を計算し直すことで、重厚な立体感が生まれる。

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

// シンプルなハッシュ関数
float hash(vec2 p) {
    p = fract(p * vec2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

// 簡易的なバリューノイズ
float noise(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix(mix(hash(i + vec2(0.0,0.0)), hash(i + vec2(1.0,0.0)), u.x),
               mix(hash(i + vec2(0.0,1.0)), hash(i + vec2(1.0,1.0)), u.x), u.y);
}

// フラクタルブラウン運動 (fBM) による疑似的な高さマップ
float map(vec2 p) {
    float f = 0.0;
    f += 0.5000 * noise(p); p = p * 2.02;
    f += 0.2500 * noise(p); p = p * 2.03;
    f += 0.1250 * noise(p); p = p * 2.01;
    f += 0.0625 * noise(p);
    return f;
}

// 高さを元に法線を計算(バンプマッピングのコア)
vec3 calcNormal(vec2 p) {
    vec2 e = vec2(0.01, 0.0); // 偏微分用の微小値
    // 周囲の高さの差分(勾配)から法線を算出
    vec3 n = normalize(vec3(
        map(p + e.xy) - map(p - e.xy),
        map(p + e.yx) - map(p - e.yx),
        0.15 // 凹凸の強さ(Z軸成分)
    ));
    return n;
}

void main() {
    vec2 uv = gl_FragCoord.xy / u_resolution.xy;
    uv = uv * 2.0 - 1.0;
    uv.x *= u_resolution.x / u_resolution.y;

    uv *= 5.0; // スケール調整

    // 動的な光源の位置
    vec3 lightPos = vec3(sin(u_time) * 2.0, cos(u_time) * 2.0, 1.5);
    vec3 surfacePos = vec3(uv, 0.0);
    vec3 lightDir = normalize(lightPos - surfacePos);

    // 平面の法線をノイズによって摂動(Perturbation)させる
    vec3 normal = calcNormal(uv);

    // ランバート反射 (拡散反射)
    float diff = max(dot(normal, lightDir), 0.0);

    // アンビエント(環境光)と影の補正
    vec3 color = vec3(0.6, 0.5, 0.4) * diff + vec3(0.1);

    // 中心からの距離でヴィネット効果
    color *= 1.0 - length(uv / 5.0) * 0.5;

    gl_FragColor = vec4(color, 1.0);
}

このコードを実行すると、一枚の板であるはずの空間に、光源の移動に合わせて陰影を落とす荒々しい岩肌が浮かび上がる。 形状への執着を捨て、光の挙動そのものを支配下に置いた時、私たちは初めて真の意味で「ピクセル」を自在に操る力を手に入れたのだ。

サンプル

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

const vertexShaderSource = `
  attribute vec2 a_position;
  void main() {
    gl_Position = vec4(a_position, 0.0, 1.0);
  }
`;

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

// シンプルなハッシュ関数
float hash(vec2 p) {
    p = fract(p * vec2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

// 簡易的なバリューノイズ
float noise(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix(mix(hash(i + vec2(0.0,0.0)), hash(i + vec2(1.0,0.0)), u.x),
               mix(hash(i + vec2(0.0,1.0)), hash(i + vec2(1.0,1.0)), u.x), u.y);
}

// フラクタルブラウン運動 (fBM) による疑似的な高さマップ
float map(vec2 p) {
    float f = 0.0;
    f += 0.5000 * noise(p); p = p * 2.02;
    f += 0.2500 * noise(p); p = p * 2.03;
    f += 0.1250 * noise(p); p = p * 2.01;
    f += 0.0625 * noise(p);
    return f;
}

// 高さを元に法線を計算(バンプマッピングのコア)
vec3 calcNormal(vec2 p) {
    vec2 e = vec2(0.01, 0.0); // 偏微分用の微小値
    // 周囲の高さの差分(勾配)から法線を算出
    vec3 n = normalize(vec3(
        map(p + e.xy) - map(p - e.xy),
        map(p + e.yx) - map(p - e.yx),
        0.15 // 凹凸の強さ(Z軸成分)
    ));
    return n;
}

void main() {
    vec2 uv = gl_FragCoord.xy / u_resolution.xy;
    uv = uv * 2.0 - 1.0;
    uv.x *= u_resolution.x / u_resolution.y;

    uv *= 5.0; // スケール調整

    // 動的な光源の位置
    vec3 lightPos = vec3(sin(u_time) * 2.0, cos(u_time) * 2.0, 1.5);
    vec3 surfacePos = vec3(uv, 0.0);
    vec3 lightDir = normalize(lightPos - surfacePos);

    // 平面の法線をノイズによって摂動(Perturbation)させる
    vec3 normal = calcNormal(uv);

    // ランバート反射 (拡散反射)
    float diff = max(dot(normal, lightDir), 0.0);

    // アンビエント(環境光)と影の補正
    vec3 color = vec3(0.6, 0.5, 0.4) * diff + vec3(0.1);

    // 中心からの距離でヴィネット効果
    color *= 1.0 - length(uv / 5.0) * 0.5;

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

export default function BumpMapShader() {
  const canvasRef = useRef(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    const gl = canvas.getContext('webgl');
    if (!gl) {
      console.error('WebGL not supported');
      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);

    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
      console.error(gl.getProgramInfoLog(program));
      return;
    }

    gl.useProgram(program);

    // フルスクリーンQuadsの頂点データセットアップ
    const positions = 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 positionBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);

    const positionLocation = gl.getAttribLocation(program, 'a_position');
    gl.enableVertexAttribArray(positionLocation);
    gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);

    const resolutionLocation = gl.getUniformLocation(program, 'u_resolution');
    const timeLocation = gl.getUniformLocation(program, 'u_time');

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

    const render = () => {
      // キャンバスのサイズを親要素に合わせる
      const width = canvas.clientWidth;
      const height = canvas.clientHeight;
      if (canvas.width !== width || canvas.height !== height) {
        canvas.width = width;
        canvas.height = height;
        gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
      }

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

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

    render();

    return () => {
      cancelAnimationFrame(animationFrameId);
      gl.deleteProgram(program);
      gl.deleteShader(vertexShader);
      gl.deleteShader(fragmentShader);
      gl.deleteBuffer(positionBuffer);
    };
  }, []);

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