[CGクロニクル #05] ハイライトの革命:ピクセルに宿る「艶」とフォン・シェーディング

[CGクロニクル #05] ハイライトの革命:ピクセルに宿る「艶」とフォン・シェーディング

はじめに

光を浴びたビリヤードの球や、磨き上げられたプラスチック。現実世界の滑らかな物体には、必ず鋭く輝く光の点「ハイライト(鏡面反射)」が存在します。

前回のグーロー・シェーディングは、頂点ごとに計算した光の色を、ポリゴンの面に向かって「塗り広げる(補間する)」手法でした。処理は高速でしたが、もしハイライトとなるべき強い光の反射が、巨大なポリゴンの「頂点と頂点の間(ど真ん中)」に落ちた場合、どうなるでしょうか?

答えは「完全に無視される」です。頂点の色だけを見て間を推測するグーローの嘘は、鋭く局所的な光の変化を捉えることができず、結果としてCGはどこか石膏やゴムのような、つや消しの鈍い質感にとどまっていました。

前回の記事:

視点をマクロからミクロへ:Bui Tuong Phongの気づきと「法線」の再構築

1973年。当時のコンピュータ・グラフィックスの聖地であったユタ大学において、Bui Tuong Phong(ブイ・トゥオン・フォン)は、グーロー・シェーディングが抱える「のっぺりとした質感」という問題を解決するため、根本的なパラダイムシフトをもたらしました。

前回のグーロー・シェーディングは、いわば「結果(色)の引き延ばし」でした。頂点で計算し終わった光の色を、ポリゴンの面に向かってグラデーションのように塗り広げる手法です。しかし、フォンはこのアプローチの限界を見抜いていました。色が補間されるだけでは、ポリゴンの内側にあるはずの「曲面の膨らみ」という物理的な情報が完全に失われてしまうからです。

彼は「色」を補間するのではなく、「法線(表面の向き)」そのものを補間すべきだと考えました。

偽装されたグラデーションから、形状の復元へ

フォンのアイデアは、次のようなプロセスを辿ります。

  1. 頂点の向きを調べる(ここはグーローと同じ)
  2. 面の中の「向き」を推測する:頂点の法線ベクトルから、ポリゴン内部のピクセル(フラグメント)一つ一つの細かな「向き(補間法線)」を割り出す。
  3. ピクセルごとに光を再計算する:割り出した無数のピクセルの向きに対して、光源と視点の計算をもう一度すべてやり直す。

グーローが「この頂点は明るい、あの頂点は暗い。だから間は中くらいの明るさだろう」と2次元的に妥協したのに対し、フォンは「この頂点は上を向いている、あの頂点は右を向いている。ならばその間のピクセルは、少しずつ右斜め上に向きを変えながら隆起しているはずだ」と、失われた3次元の曲面をピクセル単位で疑似的に復元しようとしたのです。

計算コストの壁と「ピクセルの自律」

現代の我々から見れば当たり前のアプローチに思えますが、1973年当時のハードウェア制約を考えれば、これは狂気とも言える計算量でした。

画面の解像度が上がるほど、そして描画する面積が広がるほど、膨大な数のピクセルすべてに対してベクトル計算(正規化や内積)を行う必要があります。当時の計算機にとっては、あまりにも重すぎる処理でした。事実、この手法がリアルタイムCGで当たり前に使われるようになるには、プログラマブル・シェーダーを備えたGPUの登場(1990年代後半〜2000年代)を待たねばなりません。

しかし、この「ピクセル単位の執念」とも言える計算によって、CGは劇的な進化を遂げます。

ポリゴンの中心であろうと端であろうと、ピクセルの向きと光の角度がピタリと一致した瞬間にのみ、白く鋭い光が立ち上がる。CGが初めて、視点と光源の位置関係から生まれる鋭利なハイライト(鏡面反射)を手に入れ、あの独特の「プラスチックのような艶」を獲得した瞬間でした。

描画の主役が「頂点(ポリゴン)」というマクロな構造から、「フラグメント(ピクセル)」というミクロな粒子へと移り変わった歴史的な転換点。それが、フォン・シェーディングの真の功績なのです。

フォンの反射モデル:数式が削り出す「艶」の正体

ピクセルごとに法線を補間するという土台を手に入れたフォンは、次なる課題に取り組みました。「では、そのピクセルは、具体的にどのような色になるべきか?」という光学的な問いです。

現実世界の光の挙動は、レイトレーシングや現代の物理ベースレンダリング(PBR)が示すように、途方もなく複雑です。しかし1970年代の計算資源において、真面目に物理シミュレーションを行うことは不可能でした。そこでフォンは、人間の目に「それらしく」見えるための、非常に実用的かつエレガントな経験的モデル(Empirical Model)を考案します。

それが、現代の3DCGの基礎教養とも言える「環境光(Ambient)」「拡散反射(Diffuse)」、そしてハイライトを生み出す「鏡面反射(Specular)」の3要素を足し合わせる手法です。

この中で、物体にプラスチックや金属のような「艶(つや)」を与え、CGの質感を劇的に引き上げた鏡面反射の美しさは、以下の数式に集約されています。

Is=ksis(max(0,RV))αI_s = k_s i_s (\max(0, \vec{R} \cdot \vec{V}))^\alpha

  • IsI_s:最終的な鏡面反射の強さ
  • ks,isk_s, i_s:材質の鏡面反射率と、光源の強度
  • V\vec{V}:ピクセルから視点(カメラ)へ向かうベクトル
  • R\vec{R}:光源からの光が、表面で反射したベクトル
  • α\alpha:Shininess(光沢度)、ハイライトの鋭さを決める累乗

内積と累乗が織りなす「光の彫刻」

ここでの主役は、内積 RV\vec{R} \cdot \vec{V} と、累乗 α\alpha です。この2つの数学的な組み合わせが、見事なまでに「ハイライトの鋭さ」を表現しています。

鏡面反射の基本原理はシンプルです。「光が反射していく方向(R\vec{R})」と「カメラが見ている方向(V\vec{V})」がピタリと一致したとき、最も眩しい光が目に飛び込んでくる、というものです。ベクトルの内積 RV\vec{R} \cdot \vec{V} は、この2つの方向が完全に一致したときに最大値の 11 となり、直角になれば 00 になります。(※背後からの光を除外するために max(0,...)\max(0, ...) でマイナス値を切り捨てています)。

しかし、内積をそのまま明るさとして出力すると、ハイライトはぼんやりと広がった「白い染み」のようになってしまいます。艶のある硬い材質を表現するには、ハイライトは針のように鋭く、ピンポイントでなければなりません。

そこでフォンが持ち込んだのが、累乗 α\alpha(Shininess)という魔法です。

例えば、反射ベクトルと視点ベクトルが少しだけズレていて、内積が 0.90.9 だったとします。 もし α=1\alpha = 1 なら、明るさは 0.90.9 のままです。 しかし、α=64\alpha = 64(光沢度が高い状態)に設定するとどうなるでしょうか?

0.9640.001170.9^{64} \approx 0.00117

ほんの少し視点がズレただけで、明るさは 11 からほぼ 00 へと急激に落下します。この「内積の累乗」という単純な計算操作が、緩やかな光の山から、不要な部分を削り落とし、鋭く切り立った「光の針」を数学的に削り出しているのです。

頂点への依存からの脱却

前回のグーロー・シェーディングは、ポリゴンという「全体」の都合に合わせ、頂点の色だけで面全体をごまかしていました。これは処理こそ軽いものの、光の表現を頂点の密度(ポリゴンの細かさ)に依存してしまうという致命的な弱点がありました。

対してフォンは、画面上に描画される無数のピクセル一つ一つに対し独立して、「あなたの法線はどちらを向いているか?」「反射ベクトルと視点ベクトルの内積はいくつか?」と問いかけました。

数学とアルゴリズムの力によって、描画の解像度が「ポリゴンの頂点」から「画面の画素(フラグメント)」へとブレイクスルーを果たした瞬間です。このミクロな視点への移行こそが、その後のプログラマブル・シェーダーの時代へと直結する、歴史的な転換点となったのです。

一分間の数式美:dot(normal, light) が生む光の針

1973年当時、この「ピクセルごとに光を再計算する」という処理は、計算機にとってあまりにも重厚な負荷でした。限られたメモリと貧弱なクロック周波数の時代において、すべての画素に対して正規化や内積を求めることは、まさにハードウェアの限界への挑戦だったと言えます。

しかし、その重苦しい制約の時代を抜け出し、プログラマブル・シェーダーによって画素の魂を直接弄れるようになった現代において、このフォンの反射モデルは、フラグメントシェーダー(GLSL)の中でわずか数行の洗練されたコードとして記述されます。

pow(累乗)と max(最大値)、そして dot(内積)という極めてプリミティブな数学関数たちが連携し、何もない空間に「プラスチックの艶」という物理的な説得力を浮かび上がらせる瞬間です。

// フラグメントシェーダー内でのハイライト計算
vec3 N = normalize(vNormal);              // 補間されたピクセルの法線
vec3 L = normalize(lightDirection);       // 光源へのベクトル
vec3 V = normalize(cameraPos - vPos);     // 視点へのベクトル

// 光の反射ベクトルを計算
vec3 R = reflect(-L, N);

// フォン鏡面反射(ハイライト)の計算
float shininess = 64.0; // この数値を上げるほど、光は鋭く硬くなる
float specularFactor = pow(max(dot(R, V), 0.0), shininess);

// 白く鋭いハイライトを最終出力に加算
vec3 finalColor = diffuseColor + vec3(1.0) * specularFactor;

gl_FragColor = vec4(finalColor, 1.0);

コードに潜む「光の彫刻」の解剖

この短いコードの背後で起きている計算の美しさを、少しだけ解きほぐしてみましょう。

  • reflect(-L, N) 入ってくる光(-L)が、表面の傾き(N)にぶつかってどちらへ跳ね返るか。物理法則の基本である「入射角=反射角」をたった1行で解決します。
  • dot(R, V) 跳ね返った光のベクトル(R)と、カメラが見つめるベクトル(V)の「内積」を取ります。ベクトル同士がどれだけ同じ方向を向いているかを 1.0-1.0 から 1.01.0 の範囲で測る、極めて効率的な一致度の判定です。
  • pow(..., shininess) 内積で得られた緩やかな光の広がりを、累乗の力によって一気に削り落とします。この shininess(光沢度:α\alpha)の数値こそが、ぼんやりとした光を「鋭利な針」へと変える魔法のパラメータです。

この数式によって世界に「艶」が生まれた過程を、当時のパラメータ調整の感覚と共に触れることができるウィジェットを用意しました。Shininess(α\alpha 乗の数値)が光の鋭さをどう削り出すか、コードという論理がもたらした視覚的な革命を確認してみてください。

サンプル

フォン反射モデル(Phong Reflection)

※値が高いほどハイライトが鋭く(プラスチック調に)なります

実装のポイント

今回実装した meshPhongMaterial は、現代のGPUパワーを背景にしていますが、1973年当時はこれを「1ピクセルずつ」計算していたという事実が、改めてこのアルゴリズムの狂気と情熱を感じさせますね。

  • Shininess の魔法: スライダーを動かした時に、ボヤッとした光がキュッと収束していく様子は、まさに数式(累乗)による「光の彫刻」そのものです。
  • カラーパレット: ネイビーのベースカラーに白のスペキュラは、最もフォンの質感が分かりやすく、美しい組み合わせです。
// src/components/CGCchronicle/05.tsx
import React, { useState } from 'react';
import { Canvas } from '@react-three/fiber';
import * as THREE from 'three';

export default function PhongShadingDemo() {
  // フォン・シェーディングのパラメータステート
  const [shininess, setShininess] = useState(64);
  const [lightColor, setLightColor] = useState('#ffffff');
  const [baseColor, setBaseColor] = useState('#1e3a8a'); // 深い青
  const [lightPosX, setLightPosX] = useState(5);
  const [lightPosY, setLightPosY] = useState(5);

  return (
    <div style={{ position: 'relative', width: '100%', height: '500px', backgroundColor: '#0f172a', borderRadius: '8px', overflow: 'hidden', color: '#fff', fontFamily: 'sans-serif' }}>

      {/* UIコントロールパネル */}
      <div style={{ position: 'absolute', top: '10px', left: '10px', zIndex: 10, background: 'rgba(0, 0, 0, 0.6)', padding: '15px', borderRadius: '8px', backdropFilter: 'blur(4px)' }}>
        <h3 style={{ margin: '0 0 10px 0', fontSize: '14px', borderBottom: '1px solid #444', paddingBottom: '5px' }}>
          フォン反射モデル(Phong Reflection)
        </h3>

        {/* Shininess (α) の制御 */}
        <div style={{ marginBottom: '10px' }}>
          <label style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px' }}>
            <span>光沢度 (Shininess: α)</span>
            <span>{shininess}</span>
          </label>
          <input
            type="range" min="1" max="256" value={shininess}
            onChange={(e) => setShininess(Number(e.target.value))}
            style={{ width: '100%' }}
          />
          <div style={{ fontSize: '10px', color: '#aaa', marginTop: '2px' }}>
            ※値が高いほどハイライトが鋭く(プラスチック調に)なります
          </div>
        </div>

        {/* 光源の位置制御 */}
        <div style={{ marginBottom: '10px' }}>
          <label style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px' }}>
            <span>光源の位置 (X)</span>
          </label>
          <input
            type="range" min="-10" max="10" step="0.1" value={lightPosX}
            onChange={(e) => setLightPosX(Number(e.target.value))}
            style={{ width: '100%' }}
          />
          <label style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px', marginTop: '5px' }}>
            <span>光源の位置 (Y)</span>
          </label>
          <input
            type="range" min="-10" max="10" step="0.1" value={lightPosY}
            onChange={(e) => setLightPosY(Number(e.target.value))}
            style={{ width: '100%' }}
          />
        </div>

        {/* カラーピッカー */}
        <div style={{ display: 'flex', gap: '10px', fontSize: '12px' }}>
          <label style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
            光源の色 (Specular)
            <input type="color" value={lightColor} onChange={(e) => setLightColor(e.target.value)} />
          </label>
          <label style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
            物体の色 (Diffuse)
            <input type="color" value={baseColor} onChange={(e) => setBaseColor(e.target.value)} />
          </label>
        </div>
      </div>

      {/* R3F Canvas */}
      <Canvas camera={{ position: [0, 0, 8], fov: 45 }}>
        {/* 環境光(Ambient): 暗闇にならないためのベースの光 */}
        <ambientLight intensity={0.2} color="#ffffff" />

        {/* 指向性光源(Directional Light): これがハイライトを生み出す主光源 */}
        <directionalLight
          position={[lightPosX, lightPosY, 5]}
          intensity={1.5}
          color={lightColor}
        />

        {/* 光源の位置を視覚化するための小さな球体(解説用) */}
        <mesh position={[lightPosX, lightPosY, 5]}>
          <sphereGeometry args={[0.2, 16, 16]} />
          <meshBasicMaterial color={lightColor} />
        </mesh>

        {/* メインの球体 */}
        <mesh>
          <sphereGeometry args={[2.5, 64, 64]} />
          {/* MeshPhongMaterial:
            内部のWebGLシェーダーで、ピクセル単位の法線補間と
            dot(R, V) ^ shininess の計算を行ってくれるマテリアル。
          */}
          <meshPhongMaterial
            color={baseColor}
            specular={new THREE.Color(lightColor)} // 鏡面反射の色(通常は光源の色に合わせる)
            shininess={shininess}                  // ハイライトの鋭さ(α)
          />
        </mesh>
      </Canvas>
    </div>
  );
}