[CGクロニクル #13] 物理学への回帰 — PBRが綴る「嘘のない」光の記憶

[CGクロニクル #13] 物理学への回帰 — PBRが綴る「嘘のない」光の記憶

はじめに

グーローの補間も、フォンのハイライトも、バンプマッピングの凹凸も、突き詰めれば「それっぽく見せる」ための巧妙なハック(嘘)であった。しかし、時代が下りハードウェアの計算能力が狂気的な進化を遂げると、先人たちは小手先の偽装を捨て、現実世界の光の物理法則そのものを数式でシミュレートする道を選んだ。

PBRを根底で支えるのは、決して曲げられない宇宙のルールである。

前回の記事:

1. エネルギー保存則(Energy Conservation)

閲覧数ゼロの静寂の中で、この「エネルギー保存則」という冷徹なルールをさらに解剖していきましょう。

CGが長年抱えていた「プラスチックのような不自然なテカリ」や「白飛び」の元凶は、この法則を無視してDiffuse(拡散反射)とSpecular(鏡面反射)を単純に足し合わせていたことにあります。光のエネルギーが100しかないのに、Diffuseで80の明るさを計算し、そこにSpecularで50のハイライトを足せば、合計は130になります。これでは物体が自ら発光しているのと同じです。

PBR(物理ベースレンダリング)は、この等価交換の法則をアルゴリズムの根底に据えることで、世界に「説得力」をもたらしました。

光の二つの運命:散乱か、反射か

光の正体たるフォトン(光子)が物質の表面に衝突したとき、その運命は厳密に2つに分岐します。

  1. Specular(鏡面反射) 物質の表面で弾き返される光です。表面が滑らかであればあるほど、一定の方向へ鋭く反射し「ハイライト」や「周囲の映り込み」として網膜に届きます。この反射光は、物質の内部に入り込んでいないため、基本的には「光源の色のまま」跳ね返ります(※金属を除く)。
  2. Diffuse(拡散反射 / サブサーフェス・スキャタリング) 表面で弾かれず、物質の内部へと潜り込んだ光です。光は物質を構成する分子に衝突しながら内部で乱反射(散乱)を繰り返し、特定の波長(色)が吸収されます。そして、吸収されずに生き残った波長だけが、再び表面から無秩序な方向へと飛び出してきます。これが私たちが「物体の固有色(アルベド)」として認識している光です。

不条理な世界にあっても、この二つの光の合計エネルギーは、絶対に入射光(100%)を超えることはありません。 表面で多くの光を跳ね返せば(Specularが高ければ)、内部に入り込んで色となる光(Diffuse)は物理的に減るのです。

金属(Conductor)と非金属(Dielectric)の決定的な違い

エネルギー保存則を最も劇的に体現しているのが「Metallic(金属度)」というパラメータです。

非金属(木材、コンクリート、プラスチック、肌など)は、入射光の約4%〜5%程度しか表面で反射(Specular)しません。残りの大部分の光は内部に浸透し、Diffuseとなります。 一方、金属は内部に自由電子を豊富に持っているため、内部に潜り込もうとした光(電磁波)を瞬時に打ち消してしまいます。つまり、金属にはDiffuse(固有色)が存在しません。 入射した光の70%〜100%を表面で反射(Specular)し、残りは完全に吸収されて熱になります。

純粋な金属をPBRのシェーダーで表現する際、アルベド(BaseColor)を真っ黒(vec3(0.0))に設定するのはこのためです。私たちが「金」や「銅」と呼んでいる色は、Diffuseの色ではなく、特定の波長だけを強く反射する「色付きのSpecular」を見ているに過ぎません。

1分間の数式美:GLSLが記述する等価交換

これをフラグメントシェーダー(GLSL)で記述すると、非常にシンプルで美しい数式に帰着します。先ほどのコードのこの部分です。

// F はフレネル反射率(表面で跳ね返る光の割合 = Specular)
vec3 kS = F;

// 入射光(1.0)から、跳ね返った分(kS)を引いた残りが、内部に浸透する光(Diffuse)になる
vec3 kD = vec3(1.0) - kS;

// ただし、金属は光を内部で吸収してしまうため、金属度が高いほどDiffuseを強制的に0に近づける
kD *= 1.0 - metallic;

// 最終的な光の計算
vec3 finalColor = (kD * albedo / PI) + specular;

kD = 1.0 - kS というたった一行のコード。 これこそが、CGが作り物の嘘から抜け出し、現実世界の物理学と和解した瞬間の記憶です。

2. 微小面理論(Microfacet Theory)

見えない山脈と光の散乱

マクロな視点で見れば平らで滑らかに見えるプラスチックの板や、磨き上げられた木の床。しかし、顕微鏡レベルのミクロな視点まで潜り込めば、そこには無数の険しい山脈と谷間が延々と広がっている。完全な平面など、イデア界にしか存在しない。

光(フォトン)がこの微小な面(Microfacet)に衝突するとき、光は「マクロな表面の向き」ではなく、「衝突した微小面の向き」に従って反射する。 表面が荒れていればいるほど、微小面の向きはカオスのように乱れ、光は四方八方へと散乱していく。私たちが「つや消し(マット)」だと感じる質感は、光が微小なノイズによってバラバラに砕き散らされた結果なのだ。

魔法のパラメータ「Roughness」

かつてのフォン・シェーディングは、ハイライトの鋭さ(Shininess)と強さ(Specular Level)を、アーティストが勘で調整する恣意的なパラメータとして分離していた。そこに物理的な根拠はなかった。

しかしPBRは、これを「Roughness(粗さ)」という 0.00.0 から 1.01.0 までの単一の変数へと統合した。 Roughnessの値が変われば、表面のミクロな荒れ具合が変化する。荒れ具合が変われば、反射する光の散乱具合も自動的に決まる。つまり、「質感の違い」とは、根本を正せば「ミクロな凹凸の統計的分布」の違いに過ぎないのだ。

確率が導く真実:法線分布関数(NDF)

とはいえ、数百万、数千万もの微細な面の傾きをピクセル単位で毎フレーム計算することは、目の前で稼働している8台のモニタを支える強力なGPUをもってしても不可能だ。

そこで先人たちは、微小面の向きがどのように分布しているかを「確率的」に近似する道を選んだ。それが法線分布関数(Normal Distribution Function: NDF)である。現在、PBRの標準として最も広く使われているGGX(Trowbridge-Reitz)の数式は、次のように記述される。

D(n,h,α)=α2π((nh)2(α21)+1)2D(n, h, \alpha) = \frac{\alpha^2}{\pi ((n \cdot h)^2 (\alpha^2 - 1) + 1)^2}

ここで、α\alpha はRoughnessの二乗(roughness2\text{roughness}^2)、nn はマクロな法線ベクトル、hh は視線と光源の中間にあるハーフベクトルを表す。

この一見すると無機質な数式が示しているのは、特定の方向(ハーフベクトルの方向)へ正しく光を反射してくれる都合の良い微小面が、「全体の何パーセント存在するか」という確率である。

  • Roughnessが 00 に近いとき: ほとんどの微小面が同じ方向を向いている確率が高まる。光は散乱せず一方向に束ねられ、鋭く目を刺すような、鏡のようなハイライトを生む。
  • Roughnessが 11 に近いとき: 微小面の向きの確率は平準化され、完全にランダムになる。光の束は空間全体へ均等に霧散し、ハイライトは広大で鈍い光の染みへと変わる。

質感とは「色」ではない。光が微細なカオスと衝突し、確率論的に散乱していく分布図なのだ。そこにはアーティストの嘘が介入する余地はない。ただ、冷徹な数式によって叩き出される統計という名の真実が、画面の向こう側に確かな「触り心地」を立ち上がらせるのである。

3. フレネル反射(Fresnel Effect)

水面を真上から見下ろせば、視線は水を突き抜けて川底の石を映し出す。しかし、遠くの水面を極端に浅い角度(Grazing Angle)から眺めると、水面は鏡のように周囲の風景や空を反射する。19世紀の物理学者オーギュスタン・ジャン・フレネルが定式化したこの光学現象は、水だけでなく、木材、コンクリート、布、そして人間の肌まで、この世界に存在する「すべての物質」に適用される。

視線と法線のなす角度が90度に近づくにつれ、いかなる物質もその反射率は100%(1.01.0)へと収束していく。これが物理法則の真実だ。

初期のCGはこの現象を無視するか、あるいはアーティストが手作業で輪郭を明るく塗る(リムライト)ことで「それっぽさ」を捏造してきた。しかしPBRでは、これを逃れられない数式としてパイプラインの深部に組み込む。リアルタイムレンダリングの世界では、計算コストの最適化のためにクリストフ・シュリック(Christophe Schlick)によるエレガントな近似式が用いられる。

F(v,h)=F0+(1F0)(1(vh))5F(v, h) = F_0 + (1 - F_0)(1 - (v \cdot h))^5

F0F_0 は正面から見たときの基礎反射率、vv は視線ベクトル、hh はハーフベクトルを表す。このシンプルな5乗の減衰曲線が適用された瞬間、CGは「のっぺりとした絵」から、「空間に確かに存在する物質」へと変貌を遂げた。輪郭の際(きわ)に宿る微かな光の反射が、オブジェクトの立体感と実在感を脳に直接刻み込むのだ。

冷徹な数式が宿す「温もり」

これまでに見てきた微小面の分布(DD)、そしてこのフレネル反射(FF)、さらに微小面同士が互いの光を遮る幾何減衰(GG)。これらを統合したのが、現代PBRの心臓部とも言える「Cook-Torrance BRDF」の冷徹な方程式である。

fr=DFG4(nl)(nv)f_r = \frac{D F G}{4 (n \cdot l) (n \cdot v)}

この数式に、人間の感情やアーティストの都合が入り込む余地はない。あるのはただ、光と物質の相互作用という、宇宙が定めた絶対的な真理だけだ。パラメータを弄って「見栄えを良くする」というかつての甘やかしは許されず、私たちはただ物理法則に隷属し、正しい数値を入力することしかできない。

しかし、その嘘のない冷たい数式に従い切ったとき、皮肉なことに、ディスプレイの向こう側にはこれまでで最も美しく、現実の温もりを帯びた世界が立ち上がる。『テクノライズ』の地下都市のように無機質な0と1の羅列が、なぜか触れられそうなほどの生々しさを放つ瞬間がある。

真実とは、時に残酷でひどく冷たいものだ。失われた命が二度と戻らないという絶対的な事実と同じように。どれほど嘆いても、物理のルールが覆ることはない。それでも、その冷徹な真実(数式)から目を逸らさず、ただ静かに記述し続けることでしか到達できない祈りがある。

PBRが描く光は、ただそこにあるがままの現実を映し出す。嘘がないからこそ、それは深く、静かに美しい。そして今日も私は、その光の記憶を、誰も見ないこのアーカイブにひたすら刻み続けていく。

一分間の数式美:Roughnessが分かつ境界線

連載の各回の最後に添えてきたこの「一分間の数式美」も、今回でついに現実の物理法則と接続される。

今回のシェーダーは、完全な物理ベースレンダリングの心臓部のみを抽出し、簡略化した片鱗である。ジオメトリ(立体)すら必要ない。ただの平坦なキャンバスの上で、Roughness(粗さ)とMetallic(金属度)というたった2つの変数が、空間の性質を根本から書き換えていく。

画面のX軸を右に進むほどミクロの荒れ具合(Roughness)が増し、ハイライトが霧散していく。 画面のY軸を上に進むほど自由電子(Metallic)が増し、Diffuse(固有色)が死滅して完全な鏡面反射へと近づく。プラスチックの鈍い光沢から、冷たく輝く金属の鏡面まで、この世界を構成する無数の物質の定義が、この数百文字のコードの中に内包されている。

// PBRのエッセンス:微小面とエネルギー保存の片鱗
// 画面のX軸でRoughness(粗さ)が、Y軸でMetallic(金属度)が変化する空間

precision highp float;
uniform vec2 u_resolution;

const float PI = 3.14159265359;

// フレネル(Schlickの近似式)
vec3 fresnelSchlick(float cosTheta, vec3 F0) {
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

// 法線分布関数(GGX)- 微小面の荒れ具合
float DistributionGGX(vec3 N, vec3 H, float roughness) {
    float a = roughness * roughness;
    float a2 = a * a;
    float NdotH = max(dot(N, H), 0.0);
    float NdotH2 = NdotH * NdotH;

    float num = a2;
    float denom = (NdotH2 * (a2 - 1.0) + 1.0);
    denom = PI * denom * denom;

    return num / denom;
}

void main() {
    vec2 uv = gl_FragCoord.xy / u_resolution.xy;

    // UV座標をPBRパラメータにマッピング
    float roughness = clamp(uv.x, 0.05, 1.0);
    float metallic  = clamp(uv.y, 0.0, 1.0);

    vec3 albedo = vec3(0.8, 0.1, 0.2); // 深い真紅

    // 視線、法線、光源の定義
    vec3 N = vec3(0.0, 0.0, 1.0);
    vec3 V = normalize(vec3(0.0, 0.0, 1.0));
    vec3 L = normalize(vec3(1.0, 1.0, 1.0));
    vec3 H = normalize(V + L);

    // 金属か非金属かで基礎反射率(F0)を決定
    vec3 F0 = vec3(0.04);
    F0 = mix(F0, albedo, metallic);

    // PBR計算
    float NDF = DistributionGGX(N, H, roughness);
    vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);

    // エネルギー保存則に基づく拡散と反射の分配
    vec3 kS = F;
    vec3 kD = vec3(1.0) - kS;
    kD *= 1.0 - metallic;

    // 最終的な輝度の計算(簡略化されたBRDF)
    float NdotL = max(dot(N, L), 0.0);
    vec3 specular = (NDF * F) / (4.0 * max(dot(N, V), 0.0) * NdotL + 0.0001);
    vec3 color = (kD * albedo / PI + specular) * NdotL;

    // トーンマッピングとガンマ補正
    color = color / (color + vec3(1.0));
    color = pow(color, vec3(1.0/2.2));

    gl_FragColor = vec4(color, 1.0);
}

color = (kD * albedo / PI + specular) * NdotL;

この一文が導き出すのは、決して変わることのない、そして誰にも曲げることのできない物理のルールだ。

人間の記憶は曖昧で、時間とともに劣化し、やがては色褪せてノイズの中に消えてしまう。しかし、数式だけは違う。どれほどの時間が過ぎ去ろうとも、この数式を実行する限り、光は正しく反射し、正しい影を落とし続ける。

二度と触れることのできない失われた記憶の断片も、せめてこのキャンバスの上でだけは、永遠に変わらない最も美しい現実の光を浴びて存在し続けてほしい。そう願いながら、私は今日もコードをコンパイルする。

サンプル

Roughness ➡
Metallic ➡
import React, { useRef, useEffect } from 'react';

// 記事の末尾で定義したPBRのエッセンス(フラグメントシェーダー)
const fragmentShaderSource = `
precision highp float;
uniform vec2 u_resolution;

const float PI = 3.14159265359;

vec3 fresnelSchlick(float cosTheta, vec3 F0) {
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

float DistributionGGX(vec3 N, vec3 H, float roughness) {
    float a = roughness * roughness;
    float a2 = a * a;
    float NdotH = max(dot(N, H), 0.0);
    float NdotH2 = NdotH * NdotH;

    float num = a2;
    float denom = (NdotH2 * (a2 - 1.0) + 1.0);
    denom = PI * denom * denom;

    return num / denom;
}

void main() {
    // ピクセル座標を0.0〜1.0に正規化
    vec2 uv = gl_FragCoord.xy / u_resolution.xy;

    // 画面のX軸をRoughness、Y軸をMetallicに割り当て
    float roughness = clamp(uv.x, 0.05, 1.0);
    float metallic  = clamp(uv.y, 0.0, 1.0);

    vec3 albedo = vec3(0.8, 0.1, 0.2); // 深い真紅

    // ベクトルの定義
    vec3 N = vec3(0.0, 0.0, 1.0);
    vec3 V = normalize(vec3(0.0, 0.0, 1.0));
    vec3 L = normalize(vec3(1.0, 1.0, 1.0));
    vec3 H = normalize(V + L);

    vec3 F0 = vec3(0.04);
    F0 = mix(F0, albedo, metallic);

    // 物理法則の計算
    float NDF = DistributionGGX(N, H, roughness);
    vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);

    vec3 kS = F;
    vec3 kD = vec3(1.0) - kS;
    kD *= 1.0 - metallic;

    float NdotL = max(dot(N, L), 0.0);
    vec3 specular = (NDF * F) / (4.0 * max(dot(N, V), 0.0) * NdotL + 0.0001);
    vec3 color = (kD * albedo / PI + specular) * NdotL;

    // トーンマッピングとガンマ補正
    color = color / (color + vec3(1.0));
    color = pow(color, vec3(1.0/2.2));

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

// 画面全体を覆う板ポリゴン用の頂点シェーダー
const vertexShaderSource = `
attribute vec2 a_position;
void main() {
    gl_Position = vec4(a_position, 0.0, 1.0);
}
`;

export default function PBRShaderCanvas({ height = '400px', className = '' }) {
  const canvasRef = useRef(null);

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

    if (!gl) {
      console.error('WebGLがサポートされていません。');
      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);

    // 全画面を描画するための三角形ストリップ(2つの三角形)を設定
    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 render = () => {
      // デバイスピクセル比を考慮してキャンバスの解像度を調整し、ぼやけを防ぐ
      const displayWidth = canvas.clientWidth * window.devicePixelRatio;
      const displayHeight = canvas.clientHeight * window.devicePixelRatio;

      if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
        canvas.width = displayWidth;
        canvas.height = displayHeight;
        gl.viewport(0, 0, canvas.width, canvas.height);
      }

      // シェーダーに解像度を渡す
      gl.uniform2f(resolutionLocation, canvas.width, canvas.height);

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

    render();

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

  return (
    <div style={{ height, width: '100%', position: 'relative' }} className={className}>
      <canvas
        ref={canvasRef}
        style={{
          width: '100%',
          height: '100%',
          display: 'block',
          borderRadius: '8px',
          boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)'
        }}
      />
      {/* 軸のラベル(オプション) */}
      <div style={{ position: 'absolute', bottom: '8px', right: '16px', color: '#fff', fontSize: '12px', fontFamily: 'monospace', textShadow: '1px 1px 2px #000' }}>
        Roughness ➡
      </div>
      <div style={{ position: 'absolute', top: '16px', left: '16px', color: '#fff', fontSize: '12px', fontFamily: 'monospace', writingMode: 'vertical-rl', textShadow: '1px 1px 2px #000' }}>
        Metallic ➡
      </div>
    </div>
  );
}