[CGクロニクル #04] カクカクからの脱却:グーロー・シェーディングがもたらした「滑らかさ」の嘘

[CGクロニクル #04] カクカクからの脱却:グーロー・シェーディングがもたらした「滑らかさ」の嘘

はじめに

画家のアルゴリズムやZバッファ法によって「前後の重なり」を正しく計算できるようになったCGは、ようやく三次元空間としての整合性を手に入れた。しかし、当時の画面に映し出されていたのは、依然として無数の角張った多面体だった。

現実世界の林檎は滑らかな曲面を持っているが、コンピュータの内部では有限の頂点を持つポリゴンの集合体に過ぎない。これを「フラットシェーディング」で描画すると、面ごとにくっきりと明るさが分かれ、まるでミラーボールのようなカクカクとした見た目になってしまう。

計算資源が極端に限られていた1970年代。頂点数を増やす(ポリゴンを細かくする)ことによる物理的な解決策が不可能だった時代に、アンリ・グーロー(Henri Gouraud)は、視覚の錯覚と数学的補間を利用した「滑らかさの捏造」を考案した。

前回の記事:

面から頂点へ:法線の平均化

フラットシェーディングの限界は、光の計算(ライティング)を「ポリゴン面(Face)」単位で完結させていたことに起因する。コンピュータ内部の3Dモデルは、本質的に離散的な座標の集合である。面に対して単一の法線ベクトル N\vec{N} を定義するフラットシェーディングでは、隣り合うポリゴンが全く同じ平面上にない限り、境界線で法線が不連続に変化してしまう。これが、面全体が均一な色で塗りつぶされ、結果として「カクカクとした」人工的な輪郭を際立たせる原因だった。

アンリ・グーローの画期的なアイデアは、この不連続性を解消するために、光の計算の主戦場を「面」から「頂点(Vertex)」へと移したことにある。ポリゴンの境界ではなく、ポリゴン同士を接続する「結節点」に滑らかさの根拠を求めたのだ。

まず、ある頂点に対して、それを共有するすべての隣接ポリゴンの面法線 Ni\vec{N}_i を取得する。これらをすべて足し合わせ、その結果のベクトルを自身の長さ(ノルム)で割って正規化(Normalize)することで、その頂点における「平均化された向き」である「頂点法線(Vertex Normal)」 Nv\vec{N}_v を導き出す。

Nv=i=1kNii=1kNi\vec{N}_v = \frac{\sum_{i=1}^{k} \vec{N}_i}{\left\| \sum_{i=1}^{k} \vec{N}_i \right\|}

この数式は非常にシンプルだが、幾何学的には「周囲の面の傾きの平均を取る」という強力な意味を持つ。これにより、本来は角張っているはずのポリゴンの頂点上に、滑らかな仮想の曲面が存在しているかのような法線ベクトルが定義される。

法線が定まれば、次はその頂点がどれだけの光を反射するかを計算する。ここで用いられるのが、ランバートの余弦則(Lambert’s cosine law)に基づく古典的な拡散反射モデルだ。光源へ向かう単位ベクトルを L\vec{L} としたとき、頂点における光の強度 IvI_v は、頂点法線 Nv\vec{N}_vL\vec{L} の内積(Dot Product)によって求められる。

Iv=max(0,NvL)I_v = \max(0, \vec{N}_v \cdot \vec{L})

内積 NvL\vec{N}_v \cdot \vec{L} は、2つのベクトルがなす角を θ\theta とすると cosθ\cos\theta と等価である。つまり、光が正面(法線と同じ向き)から当たれば強度は 1.01.0 となり、斜めになるほど 0.00.0 に近づいて暗くなる。

ここで数式を max(0,)\max(0, \dots) でラップしているのは、光が面の裏側から当たった場合(角度が90度を超え、内積がマイナスになる場合)の強度を 00 にクランプ(制限)するためだ。これは、裏側からの光が物理学的に透過してしまうというバグを防ぐ、当時の制約下における実装上の知恵でもある。

こうして、面ごとではなく「頂点ごとの光の強度」が算出された。しかし、計算されたのはあくまで点の情報であり、これだけでは頂点に色が点在している状態に過ぎない。この「点」の情報を「面」へと広げるアプローチこそが、グーローの真の魔法である。

線形補間という魔法

頂点という「点」で光の強度を定義しただけでは、画面上に像を結ぶことはできない。3D空間のポリゴンを2Dのモニター画面に描画するためには、三角形の内部をピクセル単位で塗りつぶしていく「ラスタライズ(Rasterization)」という工程が不可欠だ。

フラットシェーディングでは、この三角形の内部をすべて同じ色で単一に塗りつぶしていた。しかしグーロー・シェーディングでは、ここで「補間(Interpolation)」という数学的な魔法を介入させる。3つの頂点それぞれで計算された異なる明るさ(色)を始点とし、ポリゴンの内部に向かって滑らかに色を混ぜ合わせていくのだ。

ここで用いられるのが、三角形の内部の任意の位置を表現する「重心座標系(Barycentric coordinates)」である。三角形の3つの頂点色を C1,C2,C3C_1, C_2, C_3 とし、各頂点からの影響度(重み)を (u,v,w)(u, v, w) とすると、ポリゴン内部の任意のピクセルの色 CpC_p は以下の数式で導き出される。

Cp=uC1+vC2+wC3(u+v+w=1)C_p = uC_1 + vC_2 + wC_3 \quad (u + v + w = 1)

この (u,v,w)(u, v, w) は、対象となるピクセルがどの頂点に近いかを示す比率である。ある頂点に近づけば近づくほどその頂点の色の影響が強くなり、3つの頂点から等距離にある重心では、すべての色が均等に混ざり合う。

この「頂点でのみ真面目に光を計算し、面の中身は単なるグラデーションでごまかす」というアプローチこそが、アンリ・グーローの仕掛けた最もエレガントな「嘘」である。

当時の貧弱な計算資源では、スクリーン上の数万から数十万に及ぶすべてのピクセルに対して、逐一法線や光源へのベクトルを算出してライティングを行うこと(後のフォン・シェーディングのアプローチ)など到底不可能だった。しかし、頂点(数千〜数万程度)でのみ高コストな光の計算を行い、ピクセルへの描画プロセスは極めて計算負荷の低い「線形補間(加算と乗算のみ)」に落とし込むことで、当時のハードウェアの限界を鮮やかに突破したのだ。

結果として、実際のポリゴン数は少ないままであっても、画面上にはマシュマロのような滑らかなグラデーションが描かれる。人間の目は、この連続的な色の変化を「滑らかな曲面を持つ立体」として勝手に錯覚してくれるのである。ハードウェアの制約と人間の視覚特性の隙間を突いた、まさに計算機科学の芸術とも呼べるハックであった。

「嘘」が暴かれるとき:マッハバンドと失われたハイライト

しかし、計算機と視覚の隙間を突いたこの魔法も、決して完璧なものではなかった。観察者が目を凝らしたとき、あるいは特定の光の条件下において、グーローが隠したはずの「ポリゴンの境界」は再びその姿を現してしまう。

第一の欠陥は、「マッハバンド(Mach bands)」と呼ばれる視覚の錯覚現象である。 グーロー・シェーディングによる線形補間は、色の「値」そのものはポリゴンの境界で連続している(C0C^0連続)。しかし、色の「変化の割合(傾き)」は境界を境にして急激に折れ曲がる。人間の網膜に備わっている側抑制(Lateral inhibition)というエッジ強調機能は、この微分値の不連続性を極めて敏感に察知し、物理的には存在しないはずの明暗の縞模様(バンド)を脳内に描き出してしまうのだ。滑らかさを捏造したはずのグラデーションが、皮肉にも人間の視覚特性そのものによって暴かれてしまうのである。

第二の致命的な弱点は、鋭い鏡面反射(Specular highlight)が描けないという点にあった。 プラスチックや金属の表面に見られるハイライトは、特定の角度から見たときにのみ発生する、極めて局所的で非線形な光の振る舞いである。もし、この鋭いハイライトが巨大なポリゴンの「面の中央」にピンポイントで落ちた場合、何が起きるか。 グーロー・シェーディングにおける光の計算は「頂点」でしか行われない。つまり、ポリゴンの頂点にハイライトが当たっていなければ、頂点での光の強度は低いと判定される。その結果、面の中央で本来ギラリと輝くはずの光は、暗い頂点同士の線形補間に飲み込まれ、完全に消失してしまうのだ。これは情報工学的に言えば、頂点という粗い解像度によるサンプリング不足(エイリアシング)に他ならない。

グーローの魔法は、マットな質感(拡散反射)という緩やかな光の振る舞いにおいてのみ成立する、限定的な幻術であった。

ポリゴンの呪縛から真に逃れ、光が一点に収束するようなリアリティを手に入れるためには、「色」ではなく「法線」そのものを補間し、スクリーン上の1ピクセルごとに光の数式を解き直すという、当時のハードウェアには到底不可能と思われた途方もない計算量——すなわち「フォン・シェーディング(Phong shading)」という次なるブレイクスルーの到来を待たねばならなかったのである。

【一分間の数式美】頂点で計算され、フラグメントでただ受け取る光

グーロー・シェーディングの真の美学は、ピクセルを処理する「フラグメントシェーダー」の極端なまでの軽さにある。

現代のプログラマブル・シェーダーのパラダイムに当てはめると、その分業の構造が際立つ。複雑な光の計算はすべて頂点側(Vertex Shader)で完了させてしまい、フラグメント側(Fragment Shader)はGPUのラスタライザが自動で行った線形補間の結果をただ受け取り、最終的な色を出力するだけなのだ。

ローポリゴンでありながら滑らかに見える、その極限まで削ぎ落とされた分業の美しさを、GLSLのコードとして表現してみよう。

Vertex Shader(頂点での光の計算):

attribute vec3 position;
attribute vec3 normal; // 平均化された頂点法線

uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform vec3 lightDirection;

// フラグメントシェーダーへ渡す補間用変数
varying float vIntensity;

void main() {
    // 頂点法線をワールド空間に変換
    vec3 transformedNormal = normalize(mat3(modelViewMatrix) * normal);
    vec3 L = normalize(lightDirection);

    // 頂点単位でのライティング計算(ここで光の強度が決定する)
    vIntensity = max(dot(transformedNormal, L), 0.0);

    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

ここで重要なのは、vIntensity = max(dot(transformedNormal, L), 0.0); という一行である。前項で解説したランバートの余弦則に基づく内積計算が、ここで実行されている。この計算は、画面の解像度(ピクセル数)に関わらず、モデルが持つ「頂点の数」だけしか実行されない。

そして、計算された光の強度は varying 変数としてフラグメントシェーダーへと引き継がれる。

Fragment Shader(ピクセルへの着色):

precision mediump float;

// 頂点から渡され、ハードウェアによって滑らかに補間された値
varying float vIntensity;

void main() {
    // ベースカラーに補間された光の強度を掛けるだけ
    vec3 baseColor = vec3(0.8, 0.8, 0.8);
    vec3 finalColor = baseColor * vIntensity;

    gl_FragColor = vec4(finalColor, 1.0);
}

フラグメントシェーダーは驚くほど簡素だ。ここでは法線も、光源の向きも計算されない。 受け取る vIntensity は、Vertex Shaderで計算された値を始点とし、ラスタライザが重心座標系を用いてピクセルごとに線形補間(グラデーション化)した後の値である。あとはベースとなる色にその強度を掛け合わせるだけで、処理は完結する。

頂点での、たった一度の内積 dot() 計算。 それがハードウェアの補間器を通り抜け、画面という格子の中で無数のピクセルへと分配されたとき——そこには確かに、ポリゴンの集合体を超越した「曲面」という実体のないカタチが浮かび上がっていたのである。ハードウェアの限界と数学的アプローチが見事に調和した、CG黎明期における一つの到達点と言えるだろう。

サンプル

React Three Fiber(R3F)による動作サンプルコードです。

記事の文脈に合わせて、あえて分割数を下げた(ローポリゴンの)球体を使用しています。頂点数は少ないにもかかわらず、表面の明暗が線形補間によって滑らかに描画される「グーロー・シェーディングの魔法(とマッハバンド)」をブラウザ上で直接観察できる構成にしました。

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

// --- Shader Definitions ---
const vertexShader = `
uniform vec3 uLightDirection;
varying float vIntensity;

void main() {
  // 法線をワールド空間へ変換
  vec3 worldNormal = normalize(mat3(modelMatrix) * normal);
  vec3 L = normalize(uLightDirection);

  // 頂点単位のライティング(ランバート反射)
  // 完全に真っ暗にならないよう、ベースの環境光成分を0.2ほど足しています
  float dotNL = max(dot(worldNormal, L), 0.0);
  vIntensity = dotNL * 0.8 + 0.2;

  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;

const fragmentShader = `
precision mediump float;
uniform vec3 uBaseColor;
varying float vIntensity;

void main() {
  // 頂点から線形補間された vIntensity を受け取り、ベースカラーに乗算
  vec3 finalColor = uBaseColor * vIntensity;
  gl_FragColor = vec4(finalColor, 1.0);
}
`;

// --- 3D Object Component ---
const GouraudObject = () => {
  const meshRef = useRef(null);

  // カスタムシェーダーマテリアルの生成
  const material = useMemo(() => {
    return new THREE.ShaderMaterial({
      vertexShader,
      fragmentShader,
      uniforms: {
        uLightDirection: { value: new THREE.Vector3(1.0, 1.0, 1.0).normalize() },
        uBaseColor: { value: new THREE.Color('#a8a8a8') }, // 無機質なグレー
      },
    });
  }, []);

  // ゆっくり回転させて補間の様子(とマッハバンド)を観察しやすくする
  useFrame((state, delta) => {
    if (meshRef.current) {
      meshRef.current.rotation.y += delta * 0.2;
      meshRef.current.rotation.x += delta * 0.1;
    }
  });

  return (
    <mesh ref={meshRef} material={material}>
      {/* 意図的に分割数を下げた球体 (16x12) */}
      {/* 頂点は角張っているのに、表面の光は滑らかに補間されるのがわかります */}
      <sphereGeometry args={[2, 16, 12]} />
    </mesh>
  );
};

// --- MDX Embed Wrapper ---
export default function GouraudDemo() {
  return (
    // 記事のレイアウトに合わせて幅と高さを調整してください
    <div style={{ width: '100%', height: '400px', background: '#000', borderRadius: '8px', overflow: 'hidden' }}>
      <Canvas camera={{ position: [0, 0, 5], fov: 45 }}>
        <GouraudObject />
        {/* ユーザーがマウスでドラッグして光の当たり方を直接確認できるようにする */}
        <OrbitControls enablePan={false} enableZoom={true} />
      </Canvas>
    </div>
  );
}

実装のポイント

  • useMemo によるマテリアル管理: 毎フレームの再レンダリングを防ぐため、THREE.ShaderMaterial は useMemo でキャッシュしています。

  • 環境光の擬似加算: vIntensity = dotNL * 0.8 + 0.2; とすることで、光の当たらない裏側が完全な漆黒(0.0)に潰れてしまうのを防ぎ、形状を視認しやすくしています。

  • インタラクション: <OrbitControls> を入れているため、読者が自由に視点を回して「面の境界部分の不自然さ(マッハバンドの兆候)」を探ることができます。技術記事として非常に説得力のあるデモになるはずです。