[CGクロニクル #12] 影を落とす恐怖:シャドウマップの苦闘と光の裏側

[CGクロニクル #12] 影を落とす恐怖:シャドウマップの苦闘と光の裏側

はじめに

光を計算できるようになると、世界は突如として不自然なものになった。

フォン・シェーディングによってプラスチックのような美しい艶を手に入れ、プログラマブルシェーダーによってピクセル単位の質感を操れるようになっても、初期のリアルタイムCGには決定的に欠けているものがあった。 それが「影」である。

影がない世界では、どんなに精緻なモデリングを施されたティーポットも、重力を持たずに宙に浮いているように見えてしまう。影とは、オブジェクトが「そこに存在している」ことを世界に証明するための錨(アンカー)なのだ。

しかし、リアルタイムレンダリングにおける影の計算は、長らくエンジニアたちを苦しめる「恐怖」の種であった。今回は、光の裏側を描き出すための苦闘の歴史、シャドウマップの進化を紐解いていく。

前回の記事:

光の視点に立つという発想

現実世界の光は光源から放たれ、無数の反射を繰り返して私たちの目に届く。しかし初期のCGにおいて、この「光の旅」を愚直にシミュレーションすること(レイトレーシング的なアプローチ)は、当時の非力な計算機にとっては永遠とも思える時間を必要とする「重すぎる処理」だった。リアルタイムで動くインタラクティブな世界を夢見るエンジニアたちにとって、正確な影の計算は長らくハードウェアの壁に阻まれた理想郷だったのだ。

そこにブレイクスルーをもたらしたのが、1978年にランス・ウィリアムズ(Lance Williams)が発表した論文『Casting Curved Shadows on Curved Surfaces』である。これが、現在でもあらゆる3Dゲームエンジンや描画システムの根底で動き続けている「シャドウマップ」という概念の産声だった。

彼のアプローチは、計算機科学的というよりは、非常に哲学的でスマートな「視点の転換」であった。 「光の視点から見て、見えないものはすべて影である」

視点(カメラ)から見えないものを消すためにZバッファ(深度バッファ)を使うなら、同じことを「光源」をカメラに見立てて行えばいいではないか。この逆転の発想により、複雑な幾何学計算(ポリゴンの交差判定など)を一切行わずに影を落とす、革命的なアルゴリズムが誕生した。

このアルゴリズムは、世界を2回描画する(2パスの処理)ことで完成する。

  1. 光源からのレンダリング(深度の記録:シャドウマップの生成) まず、主観カメラのことは一旦忘れ、光源の座標に「仮想のカメラ」を配置してシーンを描画する。ただし、ここで欲しいのは表面の色(RGB)やテクスチャではない。純粋に「仮想カメラ(光源)から、最初に衝突したオブジェクトまでの距離(Z値)」だけを計算し、白黒のグラデーション画像のようなデータとしてメモリに保存するのだ。これが「シャドウマップ」あるいは「デプス(深度)マップ」と呼ばれる、光から見た世界の「地形図」となる。

  2. カメラからのレンダリング(空間の照合と判定) 次に、本来の視点(プレイヤーのカメラ)から通常通りにシーンを描画する。このとき、画面に色を塗ろうとしている「ある1つのピクセル」について、以下の計算を行う。

  • 空間の変換: そのピクセルの3D空間上の座標を、行列演算によって「先ほど光源から見たときの座標系」へと変換する。
  • 深度の比較: 変換した座標の深さ(光源からそのピクセルまでの実際の距離)と、先ほど保存した「シャドウマップ」に記録されている深さ(光源から見て一番手前にあった物体までの最短距離)を比較する。

もし、現在のピクセルの距離のほうが、シャドウマップに記録された値よりも遠ければ、それは何を意味するのか? 答えは「光源と現在のピクセルの間に、何かしらの遮蔽物が存在している」である。つまり、光の直線経路は断たれており、そこは「影」として暗く塗りつぶされるべき領域だと判定できる。

複雑なポリゴンの交差を律儀に計算するのではなく、「距離(深度)」という1次元の数値をテクスチャに焼き付け、行列演算で空間をねじ曲げてピクセル単位で照合する。 数式とテクスチャを用いたこの鮮やかな「空間のハッキング」は、当時のハードウェアの制約をすり抜け、リアルタイムCGの世界に劇的な進化——すなわち「物体がそこに存在しているという定着感」をもたらしたのである。

シャドウアクネとピーターパンのジレンマ

光の視点に立つというエレガントな発想は、いざ実装の段階になると、泥臭い現実と直面することになる。空間という「連続した無限」を、ピクセルという「区切られた有限」の格子に落とし込む際の代償——すなわち、解像度の限界と浮動小数点演算の精度という「デジタルの業」が牙を剥いたのだ。

シャドウマップは、本質的にはただのテクスチャ(画像データ)である。そのため、光源から見た深度情報を記録する際、滑らかに傾斜するポリゴンの表面であっても、テクスチャの解像度不足によって深度値は「階段状の離散的な値(ギザギザ)」として保存されてしまう。

この「階段状の深度」と「実際の連続的なポリゴン表面」を比較するとどうなるか。本来は光が当たって明るくなるはずの表面上で、局所的に「シャドウマップの深度値のほうが、自分自身の表面より手前にある」という誤判定が頻発してしまうのだ。 その結果、モデルの表面には、等高線のような縞模様や、ノイズのような醜い自己干渉の影が一面に浮かび上がる。これが、当時のCGエンジニアたちを絶望させた「シャドウアクネ(Shadow Acne:影のニキビ)」という呪いである。

この不快なノイズを消し去るための対抗策は、ある意味で原始的なハックだった。深度を比較して影かどうかを判定する際、数値にわずかな「下駄(オフセット値:Bias)」を履かせるのだ。つまり、「ほんの少しだけ判定を奥にずらす(甘くする)」ことで、自身の表面による誤判定を強引にねじ伏せたのである。

しかし、空間の計算に「嘘(バイアス)」を混ぜ込めば、必ず別の場所で綻びが生じる。 アクネを完全に消し去ろうとしてバイアス値を大きく設定しすぎると、今度は「オブジェクトが地面と接している部分」の影までが奥へと押し込まれ、後退してしまう。結果として影が本体の接地点から切り離され、重厚なはずの石柱やキャラクターが、まるで重力を失って数センチ宙に浮いているかのように見えてしまうのだ。

自分の足元から影が逃げ出してしまうこの滑稽な現象は、童話になぞらえて「ピーターパン現象(Peter Panning)」と名付けられた。

シャドウアクネを消そうとすればピーターパンが空を飛び、ピーターパンを地面に引きずり下ろそうとすれば表面がアクネに覆われる。これは純粋なトレードオフのジレンマだった。エンジニアたちは、光と影の間に横たわるデジタルの限界に頭を抱えながら、シーンの広さや光源の角度に合わせて「0.005」といった最適なバイアス値(Magic Number)を探し続けるという、終わりのない微調整の泥沼へ足を踏み入れていったのである。

境界を滲ませる:二値からの脱却

アクネとピーターパンの苦難を乗り越え、ようやく地面に定着した影を手に入れたエンジニアたちだったが、そこにはもう一つの「デジタルの呪縛」が待ち構えていた。それが、影の境界線(エッジ)の硬さである。

シャドウマップによる影の判定は、本質的に if (depth > shadowMapDepth) という冷酷な条件分岐で行われる。つまり、「完全に光が当たっている(1)」か、「完全に影に落ちている(0)」かという、純粋な二値(バイナリ)の選択しか存在しないのだ。その結果として描画されるのは、ナイフで切り取ったかのように鋭利で、なおかつピクセルの格子によるジャギー(エイリアシング)がそのまま露呈した、不自然な「硬い影(Hard Shadow)」であった。

しかし、私たちが普段目にしている現実世界の光は、もっと曖昧で複雑だ。太陽であれ部屋の照明であれ、現実の光源には必ず「面積」が存在する。面積を持つ光源から放たれた光は、遮蔽物のエッジを回り込むように届くため、影の境界には必ずグラデーションのような柔らかい滲みが生じる。物理学において「半影(Penumbra)」と呼ばれるこの曖昧なグラデーションこそが、空間の空気感や物体のスケール感を脳に伝える重要な視覚情報なのだ。

では、デジタルの残酷な「0か1か」で描かれたカクカクの境界を、どうやって現実のような「連続的な滲み」へと変換すればいいのか。ここで考案されたのが、**PCF(Percentage-Closer Filtering)**という巧妙なフィルタリング技術である。

PCFの根底にあるのは、「自分自身の1点だけを見るな」というアプローチだ。「現在のピクセルが影に落ちているか」という1回の判定で白黒をつけるのではなく、シャドウマップ上で自分の周囲にある複数のピクセル(例えば2x2の4点や、3x3の9点)をサンプリングして、それぞれで深度判定を行う。そして、**「周囲の領域のうち、何パーセントが影の判定になったか」**を平均化し、それを最終的な影の濃さ(アルファ値)として出力するのだ。

例えば、周囲4点をサンプリングした結果、3点が影判定で1点が光判定だった場合、そのピクセルの明るさは「25%」となる。本来は存在しないはずの0と1の間に、「擬似的なグレーの階調」を計算によって捏造した瞬間である。

このPCFによるアプローチを皮切りに、影の境界を美しくぼかすための探求はさらに加速していく。後に登場する分散シャドウマップ(VSM:Variance Shadow Maps)などは、深度の平均値と分散(ばらつき)という確率分布の統計学的な数学アプローチを用いることで、テクスチャフィルタリングの恩恵を受けながら、さらに滑らかで計算コストの低い影のブラーを実現した。

リアルタイムCGにおける影の進化の歴史。それは決して単なるハードウェアの性能向上の歴史ではない。デジタル特有の冷酷な「0と1の境界線」を、いかにして数学の力でアナログな「連続値」へと騙し、現実の持つ美しい「滲み」に近づけていくかという、エンジニアたちの執念の歴史でもあったのだ。

【一分間の数式美】滲みゆく半影の数学

テクスチャの解像度というデジタルの足かせから逃れ、純粋な数式だけで影の「滲み」を表現することはできないだろうか。今回は、固定パイプライン時代のテクスチャベースのシャドウマップではなく、レイマーチング(距離関数:SDF)のロジックを用いた、純粋な数式によるソフトシャドウの表現を添えよう。

イニゴ・キレス(Inigo Quilez)らによって広められたこのアルゴリズムは、光線を進める過程で「遮蔽物までの最短距離」を評価し、光の強さを連続的に減衰させる。ピクセルの格子に依存しない、無限の精度を持った影のグラデーションだ。

// レイマーチングにおける数式制御のソフトシャドウ
// ro: 光線の起点 (Ray Origin)
// rd: 光線の向き (Ray Direction)
// mint: 最小進行距離, maxt: 最大進行距離, k: 影の硬さ(係数)

float calcSoftShadow(vec3 ro, vec3 rd, float mint, float maxt, float k) {
    float res = 1.0;  // 初期状態は完全に光が当たっている(1.0)
    float t = mint;   // 光線を進める距離

    for(int i = 0; i < 32; i++) {
        // 現在地から最も近いオブジェクトまでの距離(SDF)を取得
        float h = map(ro + rd * t);

        // 遮蔽物に衝突したら影(0.0)を返す
        if(h < 0.001) return 0.0;

        // 光線の進んだ距離(t)に対して、オブジェクトがどれだけ近いか(h)で
        // 影の「濃さ」を決定する。kは半影の広がり具合を調整する。
        res = min(res, k * h / t);

        t += h; // 光線を安全な距離だけ進める
        if(t > maxt) break;
    }

    // 0.0(完全な影) から 1.0(光) まで滑らかに補間された値を返す
    return clamp(res, 0.0, 1.0);
}

コードの核心は、たった一行の計算式にある。

res=min(res,kht)res = \min\left(res, k \frac{h}{t}\right)

光線が空間を進む過程で、何かの物体を「かすめる」時、その物体までの最短距離 hh が小さくなる。しかし、光線が遠くまで進んでいる(tt が大きい)ほど、その物体の影響力は小さく評価される。この比率 ht\frac{h}{t} こそが、影の境界をじわじわと滲ませる魔法の正体である。係数 kk を調整することで、鋭利な硬い影から、広大な光源がもたらす柔らかい半影までを自在に操ることができる。

テクスチャのピクセルに縛られていた影は、数式という制約のない空間へと解き放たれ、ついに現実の「光の回り込み」に肉薄する滑らかさを手に入れたのだ。

(次回、[CGクロニクル #13] 物理学への回帰 — 現実の光を数式で模倣する へ続く)

サンプル

レイマーチングを用いて数式のみで空間を構築し、光源が移動することで「影の境界が滑らかに滲む様子(ソフトシャドウ)」をリアルタイムに観察できるデモです。

// src/components/CGCchronicle/12.tsx
import React, { useEffect, useRef } from 'react';

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

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

  // 1. 距離関数 (SDF)
  float sdSphere(vec3 p, float s) { return length(p) - s; }
  float sdPlane(vec3 p, vec3 n, float h) { return dot(p, n) + h; }

  // 2. シーンの構築(球体と床)
  float map(vec3 p) {
      float sphere = sdSphere(p - vec3(0.0, 1.0, 0.0), 1.0);
      float plane = sdPlane(p, vec3(0.0, 1.0, 0.0), 0.0);
      return min(sphere, plane);
  }

  // 法線の計算
  vec3 calcNormal(vec3 p) {
      vec2 e = vec2(1.0, -1.0) * 0.0005;
      return normalize(
          e.xyy * map(p + e.xyy) +
          e.yyx * map(p + e.yyx) +
          e.yxy * map(p + e.yxy) +
          e.xxx * map(p + e.xxx)
      );
  }

  // 3. 数式制御のソフトシャドウ (今回の主役)
  float calcSoftShadow(vec3 ro, vec3 rd, float mint, float maxt, float k) {
      float res = 1.0;
      float t = mint;
      for(int i = 0; i < 32; i++) {
          float h = map(ro + rd * t);
          if(h < 0.001) return 0.0;
          // 光線が進む距離(t)に対して、遮蔽物がどれくらい近いか(h)で影を滲ませる
          res = min(res, k * h / t);
          t += h;
          if(t > maxt) break;
      }
      return clamp(res, 0.0, 1.0);
  }

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

      // カメラ設定
      vec3 ro = vec3(0.0, 2.5, 6.0);
      vec3 ta = vec3(0.0, 1.0, 0.0);
      vec3 ww = normalize(ta - ro);
      vec3 uu = normalize(cross(ww, vec3(0.0, 1.0, 0.0)));
      vec3 vv = normalize(cross(uu, ww));
      vec3 rd = normalize(uv.x * uu + uv.y * vv + 1.5 * ww);

      // レイマーチングによる衝突判定
      float t = 0.0;
      for(int i = 0; i < 100; i++) {
          vec3 p = ro + rd * t;
          float d = map(p);
          if(d < 0.001 || t > 20.0) break;
          t += d;
      }

      vec3 col = vec3(0.0);

      // オブジェクトに衝突した場合
      if(t < 20.0) {
          vec3 p = ro + rd * t;
          vec3 n = calcNormal(p);

          // 光源(時間経過で円を描くように上空を移動)
          vec3 lightPos = vec3(sin(u_time) * 3.0, 4.0, cos(u_time) * 3.0);
          vec3 lig = normalize(lightPos - p);

          // 拡散反射
          float dif = clamp(dot(n, lig), 0.0, 1.0);

          // ★ソフトシャドウの適用(k=8.0 で半影の広がり具合を調整)
          float shadow = calcSoftShadow(p, lig, 0.02, 10.0, 8.0);

          // マテリアル色
          vec3 mate = vec3(0.9); // 基本は白
          if(p.y < 0.01) mate = vec3(0.4); // 床は少し暗く

          // 影と光の合成
          col = mate * dif * shadow;

          // 環境光(完全に真っ暗にならないように底上げ)
          col += mate * vec3(0.1, 0.12, 0.15);
      }

      // ガンマ補正
      col = pow(col, vec3(1.0/2.2));
      gl_FragColor = vec4(col, 1.0);
  }
`;

export default function CGChronicle12Scene() {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const gl = canvas.getContext('webgl');
    if (!gl) {
      console.error('WebGL is not supported');
      return;
    }

    // シェーダーのコンパイルヘルパー
    const compileShader = (type: number, source: string) => {
      const shader = gl.createShader(type);
      if (!shader) return null;
      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);
    if (!vertexShader || !fragmentShader) return;

    const program = gl.createProgram();
    if (!program) return;
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);
    gl.linkProgram(program);
    gl.useProgram(program);

    // 画面全体を覆う板ポリゴン(Quad)
    const vertices = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]);
    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);

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

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

    const resize = () => {
      // Retinaディスプレイ等のピクセル比を考慮
      const displayWidth = canvas.clientWidth;
      const displayHeight = canvas.clientHeight;
      if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
        canvas.width = displayWidth;
        canvas.height = displayHeight;
        gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
      }
    };

    const render = (now: number) => {
      resize();
      gl.uniform2f(resolutionLocation, gl.canvas.width, gl.canvas.height);
      gl.uniform1f(timeLocation, (now - startTime) * 0.001);

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

    render(startTime);

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

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

client:load ディレクティブによって、記事が読み込まれた瞬間にWebGLコンテキストが初期化され、ブラウザのキャンバス上で直接ピクセルが「画素の魂」として躍動し始めます。シャドウの係数 k の値や光源の軌道を微調整することで、様々な半影の表現をテストすることも可能です。