第3部 3D ラスタライズパイプライン — 第22章

PBR — 物理ベースレンダリングと IBL

第17章で Blinn-Phong を組み立てたとき、はっきりこう書きました — これは物理法則から導いたものではなく、「それらしく見える」ように作られた経験則だと。 第18章ではstruct Materialに拡散色・鏡面色・shininess を並べましたが、その値をどう決めればよいのかは 「見ながら調整する」以外にありませんでした。第16章はキューブマップを「第22章で」と送り、 第19章は glTF のpbrMetallicRoughnessを「構造だけ見せて中身は第22章で」と送りました。

この章で、それらをまとめて回収します。物理ベースレンダリング (PBR: physically based rendering)は、「表面での光の振る舞いを測定・理論に合わせて記述し、パラメータを物理量に対応させる」 という考え方の総称です。three.js でいえばMeshStandardMaterialmetalness/roughness/envMapが何をしているのかを、自分の手で書く章です。 このサイトでいちばん数式の多い章になりますが、 最後に残るのはGLSL で 3 つの短い関数と、2 つのパラメータだけです。

5×5 の球のグリッド。横軸が roughness(左 0.05 → 右 1.0)、metallic下の行が 0(誘電体)、上の行が 1(金属)です。基本色は全部同じ。 背景はキューブマップの環境マップで、金属の球にはそれが映り込みます。ドラッグで回り込み、 ホイールで寄れます(第15章の軌道カメラの簡略版)。読み出し行には、初期化時に 1 回だけ走った事前計算(放射照度マップ・プレフィルタ環境マップ・BRDF LUT)の内容が出ます。

この章で学ぶこと:

1. Blinn-Phong の何が足りないのか

第17章のモデルは、動いていました。球は立体に見えたし、ハイライトも出た。では何が問題なのか — 3 つあります。

1 つめ、エネルギー保存を満たしません。面が返す光の総量は、受け取った光を 超えられないはずです。ところが Blinn-Phong では拡散色と鏡面色を別々に足すだけなので、 どちらも 1.0 を指定すれば、受け取った光の 2 倍を返す面が普通に作れます。しかも shininess を上げてもピークの高さは変わらないので、つるつるにするほど総量が減るという 別の誤りもあります。物理では、ハイライトが狭くなればそのぶん鋭く明るくなり、総量は保たれます。

2 つめ、パラメータが物理量に対応していませんshininess = 64は何の 64 なのか。手元にある実際のプラスチックの表面は shininess いくつなのか。 答えようがありません。だから素材ごとの値を勘で決めるしかなく、測定した値のライブラリを作れない。 第18章のstruct Materialが「拡散色と鏡面色を独立に決めてよいのか」という宿題を残していたのも同じ話です。現実には、 この 2 つは独立ではありません(5 節)。

3 つめ、入射角による反射率の変化を無視しています。どんな素材でも、 かすめる角度から見れば鏡のように反射します。窓ガラスを正面から見れば向こうが透けて見えるのに、 浅い角度では空が映り込む — あの現象(フレネル効果)が Blinn-Phong にはまったく入っていません。 デモを「Blinn-Phong で比較」に切り替えると、球の縁が妙にのっぺりしているのが分かります。

加えて、第17章の環境光は「どこからでも同じだけ光が来ていることにする」一律の底上げでした。 現実の物体は、周囲にあるものの色と明るさで照らされています。それを環境マップから計算するのが この章の IBL(7〜9 節)です。

2. レンダリング方程式 — 何を足し合わせているのか

PBR の式は、突き詰めると 1 つの考え方から出てきます。「面上のある点から、ある向きへ出ていく光の量」は、「その点にあらゆる向きから入ってくる光」を 重み付きで足し合わせたものです。重みは 2 つ。

これを半球全体について足し合わせたものがレンダリング方程式 (rendering equation)です。式で書けば積分記号が出てきますが、やっていることは「半球をたくさんの方向に刻んで、 各方向について入ってくる光 × BRDF × cos θを計算し、全部足す」だけです。

N入ってくる光(あらゆる向きから)出ていく光θ出ていく光 =Σ(入ってくる光× BRDF× cos θ)半球のすべての向きについて足し合わせる
レンダリング方程式の中身。難しいのは式ではなく、この足し算を実時間で終わらせる方法のほうです。 光源が「1 方向だけ」なら足し算は 1 項で済み(3〜5 節)、環境全体が光源なら本当に全方向を 足すことになります(7〜9 節)。

BRDF (bidirectional reflectance distribution function、双方向反射率分布関数)という名前はいかめしいですが、中身は「入射方向と出射方向を渡すと、返す割合が返ってくる関数」 です。第17章の Lambert 拡散は、この関数が方向によらない定数だという主張にあたります。 その定数はalbedo / πです。π で割るのは、半球全体についてcos θを足し合わせると π になるからで、こうしておけば「受け取った光をちょうど全部返す面」の albedo が 1 になります。第17章の式には1 / πがありませんでした。あれはπ 倍明るい拡散反射だったわけです (three.js のシェーダーにはBRDF_Lambertという関数があり、こちらはちゃんとRECIPROCAL_PIを掛けています)。

この章で組み立てる BRDF は、拡散と鏡面の 2 項です。

f = kD * baseColor / π + D * G * F / (4 * NdotV * NdotL)

右側がCook-Torrance の鏡面項です。分子の D・G・F を 3 節と 4 節で 1 つずつ見ていきます。

3. D — 微小面モデルと GGX

Cook-Torrance の出発点は微小面モデル (microfacet model)という見立てです。 どんなに滑らかに見える表面も、拡大すれば無数の小さな面の集まりで、その 1 枚 1 枚は完全な鏡だと考えます。表面がざらついているとは、その小さな鏡の向きが ばらばらだということです。

roughness が小さい小さな鏡の向きがそろう → 反射もそろうroughness が大きい向きがばらつく → 反射も散らばる
微小面モデル。1 枚 1 枚は完全な鏡なので、反射のルールは単純です。roughness が制御しているのは 「反射の鋭さ」ではなく、その手前にある微小面の法線がどれだけそろっているかです。

この見立てのうまいところは、「面から見て、光をカメラへ返せる微小面はどれか」が 1 つに決まることです。 完全な鏡が光をまっすぐカメラへ返すには、その鏡の法線がちょうどL と V の中間を向いていなければなりません。第17章のハーフベクトルH = normalize(L + V)です。Blinn-Phong では「理想の法線にどれだけ近いか」を経験的な pow で測っていましたが、微小面モデルではH を向いている微小面が実際に何割あるかを問います。 その割合の密度を与えるのがD (normal distribution function)です。

分布の形として広く使われているのが、一般にGGXあるいはTrowbridge-Reitzと呼ばれるものです。

src/lessons/22-pbr/pbr.frag(抜粋)
/**
 * D: 微小面の法線分布 (GGX / Trowbridge-Reitz)。
 * 「N から角度のずれた向きを向いている微小面が、どれだけの割合であるか」。
 * α = roughness² と置くのは、roughness のスライダーを動かしたときの
 * 見た目の変化を一様にするための再パラメータ化(3 節)
 */
float distributionGGX(float NdotH, float roughness) {
  float a = roughness * roughness;
  float a2 = a * a;
  float d = NdotH * NdotH * (a2 - 1.0) + 1.0;
  return a2 / (PI * d * d);
}

この式が「分布」を名乗れるのは、半球全体で足すと 1 になるように正規化されているからです。 具体的には∫ D(h) (n·h) dω = 1が成り立ちます(手元で数値積分すると、α = 0.05 から 1.0 まで 6 桁一致して 1.000000 になりました)。 微小面の面積の合計が、平らに均したときの面積と一致する、という意味です。

10°20°30°N と H のなす角10r = 1.0r = 0.5r = 0.25r = 0.1縦軸は各曲線の最大値で正規化。実際のピーク値はr=0.1 → 3183r=0.25 → 81.5r=0.5 → 5.09r=1.0 → 0.318
GGX の分布の形。roughness が小さいほど N のまわりの狭い範囲に集中し、ピークは高くなります (面積は常に 1 なので、狭くなれば高くなる)。roughness = 1.0 では完全に平らで、 値は 1/π = 0.318 の定数になります — どの向きの微小面も同じ割合で存在する、ということです。

式の中のa = roughness * roughnessは、単なる二乗ではなく意味のある再パラメータ化です。分布の広がりを直接 表すのは α のほうなのですが、α を 0 から 1 へ動かすと変化が最初の 0.2 あたりに集中してしまい、 スライダーとして扱いにくい。roughness を入力にしてα = roughness²と置くと、見た目の変化がほぼ一様になります。この慣行は glTF も three.js も共有しているので、roughness と α を取り違えないことが移植時の注意点です。

4. G と F — 遮蔽とフレネル

D だけでは足りません。微小面はでこぼこしているので、手前の山が奥の谷を隠すことが起こります。光が届かない(shadowing)、あるいは反射してもカメラから見えない(masking)。 その「隠されずに済んでいる割合」がG (geometry function)です。

Smithの方法は、この遮蔽を「光源側」と「視線側」に分け、それぞれ独立に見積もって掛け合わせます。 片側の見積もりに使うのが Schlick-GGX で、パラメータ k が遮蔽の強さを決めます。

src/lessons/22-pbr/pbr.frag(抜粋)
/** G の片側。k は遮蔽の強さを決めるパラメータ */
float geometrySchlickGGX(float NdotX, float k) {
  return NdotX / (NdotX * (1.0 - k) + k);
}

/**
 * G: 微小面どうしの遮蔽 (Smith + Schlick-GGX)。
 * 視線側と光源側で 1 回ずつ「隠されずに見えている割合」を掛ける。
 * 直接光の k = (roughness + 1)² / 8(IBL では k = roughness² / 2 を使う。brdf.frag 参照)
 */
float geometrySmith(float NdotV, float NdotL, float roughness) {
  float k = (roughness + 1.0) * (roughness + 1.0) / 8.0;
  return geometrySchlickGGX(NdotV, k) * geometrySchlickGGX(NdotL, k);
}

/**
 * F: フレネル (Schlick の近似)。
 * 正面から見たときの反射率 F0 から、かすめる角度での 1.0 へ 5 乗で立ち上がる
 */
vec3 fresnelSchlick(float cosTheta, vec3 F0) {
  // clamp するのは、丸め誤差で cosTheta が 1 をわずかに超えると
  // pow() の底が負になり、GLSL では結果が未定義になるため
  return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

G の効き方は、NdotVNdotLが小さいとき — つまり面をかすめる角度 — に強く出ます。数値で見ると、roughness = 0.5 のときNdotV = NdotL = 1では G = 1.0 ですが、0.5では 0.609、0.1では 0.080 まで落ちます。この項がないと、球の輪郭が不自然に明るく光ります。

F (Fresnel)は、入射角によって反射率が変わる現象です。F0が「正面から見たときの反射率」で、そこから角度が浅くなるにつれて 1.0 へ向かいます。Schlick の近似は、その立ち上がりを (1 − cos θ)⁵ で表したものです。F0 = 0.04 の面なら、 正面 (cos θ = 1) で 0.040、cos θ = 0.5 で 0.070、cos θ = 0.25 で 0.268、cos θ = 0 で ちょうど 1.0 になります。どんな素材でも、真横から見れば鏡になるというのがこの式の主張です。

D向きの合う微小面が何割あるか×Gそのうち隠されずに見えるのは何割か×FF01そのうち反射されるのは何割か3 つを掛けて 4·NdotV·NdotL で割ったものが、Cook-Torrance の鏡面項
D・G・F の役割分担。「向きの合う微小面のうち、隠されずに見えていて、しかも反射されたぶん」 という 3 段の絞り込みです。最後に割る4 · NdotV · NdotLは、微小面の向きの空間から実際の入射・出射方向の空間へ換算するときに出てくる係数です。

5. metallic-roughness ワークフロー

ここまで F0 を天下りに使ってきました。この値をどう決めるか、というのがマテリアルの設計そのものです。答えは、物質を 2 つに分けることです。

誘電体の F0 が「弱い」というのは、どのくらいでしょうか。屈折率 n の物質について、正面からの 反射率は((n − 1) / (n + 1))²です。ガラスやプラスチックの屈折率はおおむね 1.5 なので、(0.5 / 2.5)² = 0.04ちょうど。水 (n = 1.33) なら 0.020、ダイヤモンド (n = 2.4) で 0.170 です。ダイヤモンドのような 例外を除けば、身のまわりの誘電体はおおむね 0.02〜0.05 に収まります。 そこで、これを 0.04 の定数で済ませてしまおう、というのが metallic-roughness ワークフローの割り切りです。

こうして、素材を決めるパラメータが基本色・metallic・roughness の 3 つだけになります。F0 と拡散色は、そこから導かれます。

src/lessons/22-pbr/pbr.frag(抜粋)
    // 金属は拡散反射を持たず、F0 が baseColor そのものになる(5 節)
    vec3 F0 = mix(vec3(0.04), u_baseColor, u_metallic);

metallic = 0なら F0 = 0.04 の灰色、metallic = 1なら F0 = 基本色。拡散のほうは逆に(1 - metallic)倍されて、金属では消えます。第18章の宿題 — 「拡散色と鏡面色を独立に決めてよいのか」への答えはノーで、この 2 つは metallic 1 つで連動します。第17章の aside で「金属でない物体のハイライトは光源の色のまま」 と書いたのも、いまF0 = 0.04という具体的な値になりました。

そしてエネルギー保存です。反射に回ったぶんは中へ入れないのだから、拡散に 使えるのは残りだけ。それがkD = (1.0 - F) * (1.0 - u_metallic)という 1 行です。フレネル F がそのまま「表面で跳ね返った割合」なので、その補数を拡散に回す。 Blinn-Phong にはこの連動がありませんでした。

src/lessons/22-pbr/pbr.frag(抜粋)
      vec3 H = normalize(L + V);
      float NdotH = max(dot(N, H), 0.0);
      float HdotV = max(dot(H, V), 0.0);

      float D = distributionGGX(NdotH, u_roughness);
      float G = geometrySmith(NdotV, NdotL, u_roughness);
      vec3 F = fresnelSchlick(HdotV, F0);

      vec3 specular = D * G * F / (4.0 * NdotV * NdotL + 0.0001);
      // 反射に回らなかったぶんだけが中へ入って拡散になる = エネルギー保存(5 節)
      vec3 kD = (1.0 - F) * (1.0 - u_metallic);
      vec3 diffuse = kD * u_baseColor / PI;

      color += (diffuse + specular) * u_lightColor * NdotL;

第21章で作った影が掛かるのは、この最後の 1 行です。shadowFactor()が返す 0〜1 を(diffuse + specular) * u_lightColor * NdotLに掛ければよく、光のモデルを Blinn-Phong から Cook-Torrance に差し替えても、影の側は 何も変わりません。影は「その点に光が届くか」だけの話で、届いた光をどう反射するかとは 独立だからです。ただし掛ける相手は直接光の項だけです。8〜9 節で足す IBL は環境光の正式版、つまり遮蔽されていても回り込んでくる光にあたるので、 そちらに影を掛けると、影の中が第17章の「環境光を足す前」と同じ真っ黒に落ちてしまいます。

6. リニア空間・HDR・トーンマッピング

ここまでの式は、すべて光の量の足し算・掛け算です。ところが PNG や JPEG の 画素値は、光の量に比例していません。人間の目の感度に合わせて暗い側に細かく目盛りを振った尺度(sRGB)で記録されています。この値をそのまま足し算すると、明るさの計算が狂います。

だから PBR では、入口でリニアに直し、出口で目盛りに戻すのが鉄則です。 この章では最小限の近似で済ませます — 読んだ直後にpow(c, vec3(2.2))、出す直前にpow(c, vec3(1.0/2.2))。正確な sRGB 変換(0.0031308 を境にした分岐と 1/2.4 乗)と 色空間の体系は第27章の担当です。

もう 1 つ、リニアで計算した結果は平気で 1.0 を超えます。太陽の当たった金属の ハイライトが 1.0 に収まる理由はどこにもありません。そのまま出力すると 1.0 で切り落とされ、明るい部分が真っ白な塊になります(白飛び)。0〜∞ の値を 0〜1 へ押し込む写像がトーンマッピング (tone mapping)で、この章は いちばん単純な Reinhard を 1 つだけ実装します。

src/lessons/22-pbr/pbr.frag(抜粋)
/** Reinhard。x / (1 + x) は 0 → 0、∞ → 1 の単調増加なので、必ず 1 未満に収まる */
vec3 toneMapReinhard(vec3 color) {
  return color / (1.0 + color);
}
src/lessons/22-pbr/pbr.frag(抜粋)
  if (u_toneMap == 1) {
    color = toneMapReinhard(color);
  }
  // ここまでリニア。画面へ出す直前にだけガンマを掛ける(6 節。正確な話は第27章)
  fragColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0);

デモの「トーンマップ: なし」と切り替えると、金属のハイライトが白い平面になって階調を失うのが 分かります。x / (1 + x)は 0 → 0、1 → 0.5、∞ → 1 の単調増加なので、どんなに明るくても 1 を超えません。

同時に気づくのは、トーンマップを掛けると背景の空まで暗くなることです。 リニアで 1.0 だった空が 0.5 に押し込まれるのだから当然で(このあと出口のガンマが掛かるので、 画面に出る値は 0.73 です)、これは Reinhard の欠点ではなく 「明るさ全体を写像した」結果です。実務ではこの手前に露出 (exposure)の 掛け算を挟んで、絵の中心にしたい明るさを 1.0 付近へ持ってきます — three.js のrenderer.toneMappingExposureがそれです。もっと絵作りに寄せたカーブ(ACES、AgX など)もありますが、比較と使い分けは第27章に取ってあります。

7. キューブマップ — 環境を 6 枚の画像で持つ

ここからが後半、IBL (image-based lighting)です。考え方は単純で、環境全体を光源とみなす。第17章の環境光が「どこからでも同じ明るさ」という 1 色の近似だったのに対し、IBL は「どの向きからどんな色の光が来るか」を実際の画像から読みます。

その画像の入れ物がキューブマップ (cube map)です。立方体の 6 面に貼った画像で 全方位を覆い、方向ベクトルでサンプリングします。GLSL 側はsamplerCube型で、texture(u_envMap, dir)と書けば dir の指す向きの色が返ります。dir は正規化されていなくても構いません(長さは面の選択と (s, t) の計算で割られて消えます)。

src/lessons/22-pbr/main.ts(抜粋)
  for (const [i, image] of images.entries()) {
    gl.texImage2D(gl.TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
  }
  // プレフィルタでは textureLod でぼけた段を読む(ファイアフライ対策)のでミップを作る
  gl.generateMipmap(gl.TEXTURE_CUBE_MAP);
  gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);

面のターゲットはTEXTURE_CUBE_MAP_POSITIVE_Xから +X, −X, +Y, −Y, +Z, −Z の順に 6 連番なので、番号を足すだけで回せます。 テクスチャパラメータはキューブマップ全体に 1 回設定します(6 面それぞれに設定するのではありません)。

面の向きは仕様が決めています。OpenGL ES 3.0 の §3.8.9「Cube Map Texture Selection」に、 方向ベクトルの絶対値がいちばん大きい成分(major axis)で面を選び、残りの 2 成分を sc / tc に割り当てる表(Table 3.21)があります。+X 面ならsc = −rz、tc = −ry、+Y 面ならsc = rx、tc = rzといった具合で、そこからs = ½(sc/|ma| + 1)、t = ½(tc/|ma| + 1)でテクスチャ座標が決まります。この章の事前計算では、逆向きの変換 — 面番号と (s, t) から方向ベクトルを作る関数 — が要ります。

src/lessons/22-pbr/irradiance.frag(抜粋)
// キューブマップの面と (s, t) から方向ベクトルを作る。
// OpenGL ES 3.0 の「面 → major axis と sc / tc」の対応表をそのまま逆に解いたもの。
// sc / |ma| = 2s - 1、tc / |ma| = 2t - 1 なので、|ma| = 1 と置けばこの式になる
vec3 directionFromFace(int face, vec2 uv) {
  float sc = uv.x * 2.0 - 1.0;
  float tc = uv.y * 2.0 - 1.0;
  if (face == 0) return vec3(1.0, -tc, -sc);  // +X
  if (face == 1) return vec3(-1.0, -tc, sc);  // -X
  if (face == 2) return vec3(sc, 1.0, tc);    // +Y
  if (face == 3) return vec3(sc, -1.0, -tc);  // -Y
  if (face == 4) return vec3(sc, -tc, 1.0);   // +Z
  return vec3(-sc, -tc, -1.0);                // -Z
}

8. 放射照度マップ — 拡散の IBL

まず拡散から片付けます。拡散反射は見る向きによらないので、必要なのは 「法線 N の面が、環境全体からどれだけ光を受け取るか」だけです。これはN という 1 つの方向だけの関数なので — キューブマップに焼けます。

焼く中身は、半球全体についての入ってくる光 × cos θの足し算(=放射照度 E)です。ただしこの章では E / π を格納します。拡散の出射は(baseColor / π) × Eなので、π で割った値を持っておけば、シェーダー側は baseColor を掛けるだけで済むからです。検算しやすい性質もあります — 一様な明るさ L の環境ならE = πLなので、格納される値は L そのものに戻るはずです。実際、φ を 128、θ を 32 に刻んだ 中点則で数値を出すと 1.0004 になりました(誤差 0.04%)。

src/lessons/22-pbr/irradiance.frag(抜粋)
  vec3 sum = vec3(0.0);
  for (int i = 0; i < PHI_STEPS; i++) {
    float phi = (float(i) + 0.5) * dPhi;
    for (int j = 0; j < THETA_STEPS; j++) {
      float theta = (float(j) + 0.5) * dTheta;
      // 接空間の方向 → ワールドの方向
      vec3 local = vec3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta));
      vec3 dir = local.x * tangent + local.y * bitangent + local.z * N;
      // cos θ は「斜めから来た光は薄まる」ぶん(第17章の余弦則)、
      // sin θ は球面座標の面積要素 dω = sin θ dθ dφ のぶん
      sum += toLinear(texture(u_envMap, dir).rgb) * cos(theta) * sin(theta);
    }
  }

  // Σ に dθ dφ を掛けて積分にし、π で割って E / π にする
  fragColor = vec4(sum * dPhi * dTheta / PI, 1.0);

このループはフラグメント 1 つあたり 4096 回のテクスチャ読み込みです。毎フレームやったら 到底間に合いませんが、環境が変わらないかぎり結果も変わらないので、 初期化時に 1 回だけ計算して結果を取っておけばよい。それが「事前計算 (precompute)」で、 第20章の FBO の出番です。

描き先がキューブマップの 1 面になるだけで、やることは第20章とまったく同じです。framebufferTexture2Dの第 3 引数にTEXTURE_2Dではなく面のターゲットを渡します(第 5 引数でミップ段も選べます)。

src/lessons/22-pbr/main.ts(抜粋)
  /**
   * キューブマップの「1 面のミップ 1 段」を、いまバインドされている FBO の
   * カラーアタッチメントにする。面のターゲットは POSITIVE_X からの 6 連番
   */
  function attachCubeFace(texture: WebGLTexture, face: number, level: number): void {
    gl.framebufferTexture2D(
      gl.FRAMEBUFFER,
      gl.COLOR_ATTACHMENT0,
      gl.TEXTURE_CUBE_MAP_POSITIVE_X + face,
      texture,
      level,
    );
  }

放射照度マップの解像度は 32×32 で足ります。半球ぶんを平均した結果なので、 もともと隣り合う方向どうしの差がほとんどないからです。読み出し行に出ている事前計算の時間の 大半は、次のプレフィルタのほうが占めています。

9. 鏡面の IBL — split-sum 近似

鏡面は拡散のようにはいきません。返る光がN と V の両方、それに roughness にも依存するからです。方向 2 つ + roughness を全部テーブルにしたら、次元が多すぎて焼けません。 そこで使われるのがsplit-sum 近似です。積分を2 つの平均の積に分けてしまいます。

本来の積分Σ(環境の光 × BRDF)毎フレームは無理① プレフィルタ環境マップr=0.25.5roughness ごとにぼかした環境×② BRDF LUTNdotV →roughness →環境に依存しない 2 次元表①(方向 + roughness の関数)× ②(NdotV + roughness の関数)= 鏡面の IBL
split-sum 近似の分解。積分を「環境をぼかした平均」と「BRDF の平均」の積に分けると、 どちらも事前計算できる次元に落ちます。厳密には等号ではありませんが、実用上ほとんど気づけません。

第 1 項(プレフィルタ環境マップ)は、roughness ごとに GGX の形で環境をぼかしたキューブマップです。デモは 128×128 の 5 段のミップに、roughness = 0, 0.25, 0.5, 0.75, 1.0 を焼いています。焼き込みはtextureLodでそのまま読めるので、シェーダー側はu_roughness * u_prefilterMaxLod(= roughness × 4)を LOD に渡すだけです。

「GGX の形でぼかす」の中身は、やはり積分です。解析的には解けないので、方向をたくさん撒いて 平均するしかありません。ここで一様に撒くと、D が尖っている低い roughness ではほとんどのサンプルがゼロに近い寄与しか返さず、無駄になります。そこで寄与の大きい向きを優先して引き、そのぶん確率密度で割って重みを戻すのが重点サンプリング (importance sampling)で、少ない本数で収束します。 プレフィルタは 1 テクセルあたり 128 本、次の BRDF LUT は 1024 本を、GGX の分布D(h)·(n·h)に沿って撒いています(importanceSampleGGX)。

その 128 本をどう並べるかにも決まりがあります。乱数だと偏りができるので、Hammersley 列という規則的な準乱数列(低差異列)を使います。i 番目の値は(i / n, radicalInverse(i))で、radicalInverse32 ビットの並びを逆順にして 0〜1 に読み替える操作です。 i の下位ビットが出力の上位ビットになるので、i が 1 増えるたびに値が区間の反対側へ飛びます。おかげで何本で打ち切っても区間が均等に埋まりますuintとビット演算は WebGL2 (GLSL ES 3.00) で追加されたもので、WebGL1 では書けません(第5章 11 節)。

ここで 1 つ大きな割り切りがあります — N = V = R とみなすことです。本当は 見る角度によってぼけ方が変わり(斜めから見た反射は縦に伸びます)、その自由度があるかぎり 焼けません。そこで「常に正面から見ている」ことにして、方向 1 つと roughness 1 つだけの関数に落とします。代償として、かすめる角度での反射の伸びは再現できません。

src/lessons/22-pbr/main.ts(抜粋)
    // 2. プレフィルタ環境マップ: 6 面 × 5 段。段が下がるごとに roughness を上げる
    gl.useProgram(prefilterProgram);
    gl.uniform1i(prefilterLoc.envMap, 0);
    gl.uniform1f(prefilterLoc.envResolution, ENV_SIZE);
    for (let level = 0; level < PREFILTER_LEVELS; level++) {
      // viewport は描画先のミップ段の大きさに合わせる(第20章)
      gl.viewport(0, 0, PREFILTER_SIZE >> level, PREFILTER_SIZE >> level);
      gl.uniform1f(prefilterLoc.roughness, level / (PREFILTER_LEVELS - 1)); // 0, .25, .5, .75, 1
      for (let face = 0; face < 6; face++) {
        attachCubeFace(prefilterMap, face, level);
        gl.uniform1i(prefilterLoc.face, face);
        gl.drawArrays(gl.TRIANGLES, 0, 3);
      }
    }

第 2 項(BRDF LUT)は、環境の中身にまったく依存しません。BRDF の形だけで 決まるので、環境マップを差し替えても作り直す必要がない2 次元テーブルです。 横軸に NdotV、縦軸に roughness を取り、各点に「F0 に掛ける係数 (scale)」と「F0 に足す下駄 (bias)」を入れておきます。フレネルがF = F0 + (1 − F0)·fcという F0 についての 1 次式なので、積分の中から F0 をくくり出せる — というのがこの分解の 仕組みです。

src/lessons/22-pbr/brdf.frag(抜粋)
    // 重点サンプリングの pdf で割った残り。D が約分で消えるのがこの形の利点
    float g = geometrySmithIBL(NdotV, NdotL, roughness);
    float gVis = g * VdotH / (NdotH * NdotV);
    // フレネルを F0 * scale + bias の形に分解する。
    // F = F0 + (1 - F0) * fc なので、F0 の係数が (1 - fc)、定数項が fc
    float fc = pow(1.0 - VdotH, 5.0);

    scale += (1.0 - fc) * gVis;
    bias += fc * gVis;

焼き上がった LUT をreadPixelsで読み返すと、roughness = 0 では (scale, bias) が NdotV = 0.9 で (1.000, 0.000)、 NdotV = 0.1 で (0.408, 0.592) — 足すとほぼちょうど 1.0 になります。roughness → 0 の極限では scale = 1 − (1 − NdotV)⁵、bias = (1 − NdotV)⁵ に収束するので、F0 · scale + biasF0 + (1 − F0)(1 − NdotV)⁵— Schlick のフレネルそのものに戻ります。完全な鏡ならフレネルがそのまま出る、という理屈が 数字で確かめられるわけです。roughness = 1 では NdotV = 0.5 で (0.407, 0.002) まで落ちます。 4 節の「取りこぼし」がここに表れています。

あとは使うだけです。拡散と鏡面を合わせて 5 行になります。

src/lessons/22-pbr/pbr.frag(抜粋)
      // 拡散の IBL: 放射照度マップは E / π を持っているので baseColor を掛けるだけ(8 節)
      vec3 F = fresnelSchlickRoughness(NdotV, F0, u_roughness);
      vec3 kD = (1.0 - F) * (1.0 - u_metallic);
      color += kD * u_baseColor * texture(u_irradianceMap, N).rgb;

      // 鏡面の IBL: split-sum 近似(9 節)。
      // 第 1 項 = roughness に応じてぼかした環境、第 2 項 = BRDF だけで決まる係数。
      // LUT に掛けるのは角度依存の F ではなく F0。積分から F0 をくくり出したのが
      // この LUT なので、F を掛けるとフレネルを二重に数えることになる
      vec3 R = reflect(-V, N);
      vec3 prefiltered = textureLod(u_prefilterMap, R, u_roughness * u_prefilterMaxLod).rgb;
      vec2 ab = texture(u_brdfLUT, vec2(NdotV, u_roughness)).rg;
      color += prefiltered * (F0 * ab.x + ab.y);

10. グリッドの読み方

5×5 の球は、metallic と roughness の効き方を一度に見るための定番の並べ方です。デモを回しながら 確かめてほしいのは、次の点です。

この章で扱わなかったものも、正直に並べておきます。法線マップ・接空間(面の 細かい凹凸を法線として持たせる手法)はこのサイトでは扱いません面光源 (area light)屈折・透過サブサーフェススキャタリングは名前だけ — 後者 2 つは three.js のMeshPhysicalMaterialtransmission/thicknessあたりが担当する領域です。そして。この章のデモは物体が互いに影を 落としません。第21章のシャドウマッピングです。

コード全文

src/lessons/22-pbr/main.ts
// 第22章: PBR — 物理ベースレンダリングと IBL
//
// 第17〜18章の Blinn-Phong を、実測に基づく反射モデル(Cook-Torrance BRDF)へ置き換える。
// マテリアルは glTF と同じ metallic-roughness の 2 パラメータだけ。
//
// 1 フレームの流れ:
//   スカイボックス(全画面三角形にキューブマップを貼る) → 5×5 の球のグリッド
//
// 初期化時に 1 回だけ走る事前計算が 3 つある(毎フレームではない):
//   1. 放射照度マップ  32×32×6 面    — 拡散の IBL
//   2. プレフィルタ環境マップ 128×128×6 面・5 段 — 鏡面の IBL(split-sum の第 1 項)
//   3. BRDF LUT       256×256        — 鏡面の IBL(split-sum の第 2 項)
// どれも第20章の FBO への描画で、描き先がキューブマップの 1 面 / ミップ 1 段になるだけ。

import { mat3, mat4, type ReadonlyVec3, vec3 } from 'gl-matrix';
import { bindRenderTarget, createRenderTarget, framebufferStatusName } from '../../lib/framebuffer';
import { createSphere, type Geometry, interleave } from '../../lib/geometry';
import { compileShader, linkProgram } from '../../lib/shader';
import brdfFragmentSource from './brdf.frag?raw';
import irradianceFragmentSource from './irradiance.frag?raw';
import pbrFragmentSource from './pbr.frag?raw';
import pbrVertexSource from './pbr.vert?raw';
import precomputeVertexSource from './precompute.vert?raw';
import prefilterFragmentSource from './prefilter.frag?raw';
import skyboxFragmentSource from './skybox.frag?raw';
import skyboxVertexSource from './skybox.vert?raw';

// ---------------------------------------------------------------------------
// 定数
// ---------------------------------------------------------------------------

// カメラ定数(第3部の標準: fovy 45°・near 0.1・far 100)
const FOVY = (45 * Math.PI) / 180;
const NEAR = 0.1;
const FAR = 100;

const UP: ReadonlyVec3 = vec3.fromValues(0, 1, 0);
const TARGET: ReadonlyVec3 = vec3.fromValues(0, 0, 0);

/** 環境マップ 1 面の解像度(public/textures/env/*.png はすべて 256×256) */
const ENV_SIZE = 256;
/** 放射照度マップの 1 面の解像度。ほぼ真っ平らな絵になるので小さくてよい */
const IRRADIANCE_SIZE = 32;
/** プレフィルタ環境マップの mip 0 の解像度 */
const PREFILTER_SIZE = 128;
/** プレフィルタ環境マップのミップ段数。roughness 0, 0.25, 0.5, 0.75, 1.0 の 5 段 */
const PREFILTER_LEVELS = 5;
/** BRDF LUT の解像度(横 = NdotV、縦 = roughness) */
const BRDF_LUT_SIZE = 256;

/** キューブマップの 6 面。この順で 0..5 を u_face に渡す */
const CUBE_FACES = ['px', 'nx', 'py', 'ny', 'pz', 'nz'] as const;

/**
 * 球のグリッド。横軸 = roughness、縦軸 = metallic。
 * roughness の下限を 0 にしないのは、完全な鏡は点光源をまったく映さず真っ黒に見えるため
 */
const GRID = 5;
const SPHERE_RADIUS = 0.44;
const SPACING = 1.1;
const MIN_ROUGHNESS = 0.05;

/**
 * 基本色。リニア空間の値(第3部の PBR の約束)。
 * sRGB の (0.90, 0.62, 0.42) ≒ #E69E6B を pow(2.2) でリニアへ戻したもの。
 * 金属の行では銅のような色に、誘電体の行ではオレンジの塗装に見える
 */
const BASE_COLOR: ReadonlyVec3 = vec3.fromValues(0.7891, 0.3479, 0.1499);

/**
 * 直接光。向きは環境マップに描かれている太陽の重心方向に合わせてある
 * (白飛びしているテクセルの方向を平均して求めた)。第17章の約束どおり「面から光源へ」
 */
const LIGHT_DIRECTION: ReadonlyVec3 = vec3.normalize(vec3.create(), [0.511, 0.614, -0.602]);
const LIGHT_COLOR: ReadonlyVec3 = vec3.fromValues(3.0, 2.85, 2.6);

const MODE_DIRECT = 0;
const MODE_IBL = 1;
const MODE_BOTH = 2;
const MODE_BLINN_PHONG = 3;

const MODES = [
  {
    label: '直接光のみ',
    value: MODE_DIRECT,
    note: '方向光 1 つだけ。環境からの光がないので、光の当たらない側は真っ黒に落ちる',
  },
  {
    label: 'IBL のみ',
    value: MODE_IBL,
    note: '環境マップだけを光源にする。金属の行に空と地面が映り込み、右へ行くほどぼける',
  },
  {
    label: '両方',
    value: MODE_BOTH,
    note: '直接光 + IBL。太陽のハイライトと環境の映り込みが同時に乗る',
  },
  {
    label: 'Blinn-Phong で比較',
    value: MODE_BLINN_PHONG,
    note: '第17〜18章の経験則モデル。metallic は効かず、縦の 5 行がすべて同じ見た目になる',
  },
] as const;

// ---------------------------------------------------------------------------
// メッシュ(第13・14章の手順そのまま)
// ---------------------------------------------------------------------------

interface Mesh {
  vao: WebGLVertexArrayObject;
  indexCount: number;
}

function createMesh(gl: WebGL2RenderingContext, geometry: Geometry): Mesh {
  const vao = gl.createVertexArray();
  gl.bindVertexArray(vao);

  const FLOAT_BYTES = Float32Array.BYTES_PER_ELEMENT;
  const stride = 8 * FLOAT_BYTES; // 1 頂点 = 位置 3 + 法線 3 + UV 2
  const vbo = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
  gl.bufferData(gl.ARRAY_BUFFER, interleave(geometry), gl.STATIC_DRAW);
  gl.enableVertexAttribArray(0); // a_position
  gl.vertexAttribPointer(0, 3, gl.FLOAT, false, stride, 0);
  gl.enableVertexAttribArray(1); // a_normal
  gl.vertexAttribPointer(1, 3, gl.FLOAT, false, stride, 3 * FLOAT_BYTES);
  gl.enableVertexAttribArray(2); // a_uv(この章では使わないが、配置は第3部で共通)
  gl.vertexAttribPointer(2, 2, gl.FLOAT, false, stride, 6 * FLOAT_BYTES);

  const ibo = gl.createBuffer();
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geometry.indices, gl.STATIC_DRAW);

  gl.bindVertexArray(null);
  return { vao, indexCount: geometry.indices.length };
}

// ---------------------------------------------------------------------------
// キューブマップ
// ---------------------------------------------------------------------------

/**
 * 6 枚の画像から TEXTURE_CUBE_MAP を作る。
 * 面のターゲットは TEXTURE_CUBE_MAP_POSITIVE_X から 6 連番になっているので、
 * +X, -X, +Y, -Y, +Z, -Z の順に +i すれば全面を回せる。
 *
 * UNPACK_FLIP_Y_WEBGL は既定の false のまま触らない。WebGL 仕様にキューブマップ固有の
 * 禁止条項があるわけではないが、面の (s, t) は OpenGL ES 3.0 の Table 3.21 が定める
 * sc / tc の割り当てで決まっていて、そこへ上下反転を足すと面の向きが噛み合わなくなる
 */
function createEnvCubeMap(gl: WebGL2RenderingContext, images: readonly HTMLImageElement[]) {
  const texture = gl.createTexture();
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_CUBE_MAP, texture);
  for (const [i, image] of images.entries()) {
    gl.texImage2D(gl.TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
  }
  // プレフィルタでは textureLod でぼけた段を読む(ファイアフライ対策)のでミップを作る
  gl.generateMipmap(gl.TEXTURE_CUBE_MAP);
  gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
  gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.bindTexture(gl.TEXTURE_CUBE_MAP, null);
  return texture;
}

/**
 * 描き込み先にするキューブマップを作る。第20章の createRenderTarget は 2D 用なので、
 * キューブマップぶんはここで組み立てる(FBO を 1 つ使い回し、描くたびに
 * framebufferTexture2D で「どの面のどのミップ段か」を差し替える)
 */
function createCubeMapTarget(
  gl: WebGL2RenderingContext,
  size: number,
  levels: number,
  internalFormat: GLenum,
): WebGLTexture {
  const texture = gl.createTexture();
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_CUBE_MAP, texture);
  // texStorage2D は 6 面ぶんをまとめて確保する(第16章の不変ストレージ)
  gl.texStorage2D(gl.TEXTURE_CUBE_MAP, levels, internalFormat, size, size);
  gl.texParameteri(
    gl.TEXTURE_CUBE_MAP,
    gl.TEXTURE_MIN_FILTER,
    levels > 1 ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR,
  );
  gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.bindTexture(gl.TEXTURE_CUBE_MAP, null);
  return texture;
}

// ---------------------------------------------------------------------------
// デモ本体
// ---------------------------------------------------------------------------

function setup(
  gl: WebGL2RenderingContext,
  canvas: HTMLCanvasElement,
  modeControls: HTMLParagraphElement,
  toneControls: HTMLParagraphElement,
  readout: HTMLParagraphElement,
  envImages: readonly HTMLImageElement[],
): void {
  // --- プログラム -------------------------------------------------------------

  function buildProgram(vertexSource: string, fragmentSource: string): WebGLProgram {
    return linkProgram(
      gl,
      compileShader(gl, gl.VERTEX_SHADER, vertexSource),
      compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource),
    );
  }

  const pbrProgram = buildProgram(pbrVertexSource, pbrFragmentSource);
  const skyboxProgram = buildProgram(skyboxVertexSource, skyboxFragmentSource);
  const irradianceProgram = buildProgram(precomputeVertexSource, irradianceFragmentSource);
  const prefilterProgram = buildProgram(precomputeVertexSource, prefilterFragmentSource);
  const brdfProgram = buildProgram(precomputeVertexSource, brdfFragmentSource);

  const at = (program: WebGLProgram, name: string) => gl.getUniformLocation(program, name);
  const pbrLoc = {
    model: at(pbrProgram, 'u_model'),
    view: at(pbrProgram, 'u_view'),
    projection: at(pbrProgram, 'u_projection'),
    normalMatrix: at(pbrProgram, 'u_normalMatrix'),
    cameraPosition: at(pbrProgram, 'u_cameraPosition'),
    baseColor: at(pbrProgram, 'u_baseColor'),
    metallic: at(pbrProgram, 'u_metallic'),
    roughness: at(pbrProgram, 'u_roughness'),
    lightDirection: at(pbrProgram, 'u_lightDirection'),
    lightColor: at(pbrProgram, 'u_lightColor'),
    irradianceMap: at(pbrProgram, 'u_irradianceMap'),
    prefilterMap: at(pbrProgram, 'u_prefilterMap'),
    brdfLUT: at(pbrProgram, 'u_brdfLUT'),
    prefilterMaxLod: at(pbrProgram, 'u_prefilterMaxLod'),
    mode: at(pbrProgram, 'u_mode'),
    toneMap: at(pbrProgram, 'u_toneMap'),
  };
  const skyboxLoc = {
    inverseViewProjection: at(skyboxProgram, 'u_inverseViewProjection'),
    cameraPosition: at(skyboxProgram, 'u_cameraPosition'),
    envMap: at(skyboxProgram, 'u_envMap'),
    toneMap: at(skyboxProgram, 'u_toneMap'),
  };

  // --- リソース ---------------------------------------------------------------

  const sphereMesh = createMesh(gl, createSphere(SPHERE_RADIUS, 48, 24));
  // 頂点属性を 1 つも使わないパス(全画面三角形)用の空の VAO
  const emptyVao = gl.createVertexArray();

  const envMap = createEnvCubeMap(gl, envImages);

  // 16 ビット浮動小数のテクスチャへ「描く」には拡張が要る(第20章 8 節)。
  // 使えない環境では RGBA8 に落とす。今回の環境マップは 0〜1 に収まっているので
  // 破綻はしないが、暗部の階調は粗くなる
  const hasFloatColor = gl.getExtension('EXT_color_buffer_float') !== null;
  const hdrFormat = hasFloatColor ? gl.RGBA16F : gl.RGBA8;
  const formatName = hasFloatColor ? 'RGBA16F' : 'RGBA8(EXT_color_buffer_float なし)';

  const irradianceMap = createCubeMapTarget(gl, IRRADIANCE_SIZE, 1, hdrFormat);
  const prefilterMap = createCubeMapTarget(gl, PREFILTER_SIZE, PREFILTER_LEVELS, hdrFormat);
  // BRDF LUT だけは 2D なので第20章のヘルパーがそのまま使える。深度は不要
  const brdfTarget = createRenderTarget(gl, BRDF_LUT_SIZE, BRDF_LUT_SIZE, {
    depth: false,
    internalFormat: hdrFormat,
  });

  // --- 事前計算(初期化時に 1 回だけ) -----------------------------------------

  const irradianceLoc = {
    envMap: at(irradianceProgram, 'u_envMap'),
    face: at(irradianceProgram, 'u_face'),
  };
  const prefilterLoc = {
    envMap: at(prefilterProgram, 'u_envMap'),
    face: at(prefilterProgram, 'u_face'),
    roughness: at(prefilterProgram, 'u_roughness'),
    envResolution: at(prefilterProgram, 'u_envResolution'),
  };

  /**
   * キューブマップの「1 面のミップ 1 段」を、いまバインドされている FBO の
   * カラーアタッチメントにする。面のターゲットは POSITIVE_X からの 6 連番
   */
  function attachCubeFace(texture: WebGLTexture, face: number, level: number): void {
    gl.framebufferTexture2D(
      gl.FRAMEBUFFER,
      gl.COLOR_ATTACHMENT0,
      gl.TEXTURE_CUBE_MAP_POSITIVE_X + face,
      texture,
      level,
    );
  }

  function precompute(): number {
    const started = performance.now();

    // 事前計算パスは全画面三角形 1 枚。深度もカリングも要らない
    gl.disable(gl.DEPTH_TEST);
    gl.disable(gl.CULL_FACE);
    gl.bindVertexArray(emptyVao);

    // FBO は 1 つを使い回し、描くたびにアタッチメントだけ差し替える
    const framebuffer = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);

    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_CUBE_MAP, envMap);

    // 1. 放射照度マップ: 6 面をそれぞれ 1 回描く
    gl.useProgram(irradianceProgram);
    gl.uniform1i(irradianceLoc.envMap, 0);
    gl.viewport(0, 0, IRRADIANCE_SIZE, IRRADIANCE_SIZE);
    for (let face = 0; face < 6; face++) {
      attachCubeFace(irradianceMap, face, 0);
      if (face === 0) {
        // 不完全な FBO への描画はエラーにならず「何も描かれない」だけ(第20章)。
        // 内部フォーマットが color-renderable かどうかはここで初めて分かる
        const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
        if (status !== gl.FRAMEBUFFER_COMPLETE) {
          throw new Error(`キューブマップの面に描けません: ${framebufferStatusName(gl, status)}`);
        }
      }
      gl.uniform1i(irradianceLoc.face, face);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    }

    // 2. プレフィルタ環境マップ: 6 面 × 5 段。段が下がるごとに roughness を上げる
    gl.useProgram(prefilterProgram);
    gl.uniform1i(prefilterLoc.envMap, 0);
    gl.uniform1f(prefilterLoc.envResolution, ENV_SIZE);
    for (let level = 0; level < PREFILTER_LEVELS; level++) {
      // viewport は描画先のミップ段の大きさに合わせる(第20章)
      gl.viewport(0, 0, PREFILTER_SIZE >> level, PREFILTER_SIZE >> level);
      gl.uniform1f(prefilterLoc.roughness, level / (PREFILTER_LEVELS - 1)); // 0, .25, .5, .75, 1
      for (let face = 0; face < 6; face++) {
        attachCubeFace(prefilterMap, face, level);
        gl.uniform1i(prefilterLoc.face, face);
        gl.drawArrays(gl.TRIANGLES, 0, 3);
      }
    }

    // 3. BRDF LUT: 環境マップに依存しないので、環境を差し替えても作り直さなくてよい。
    // こちらは 2D なので第20章のヘルパーで切り替えられる(FBO と viewport がセットで動く)
    bindRenderTarget(gl, brdfTarget);
    gl.useProgram(brdfProgram);
    gl.drawArrays(gl.TRIANGLES, 0, 3);

    // viewport は FBO のバインドでは戻らない GL の状態(第20章)。null を渡すと
    // 画面へ戻すのと同時に viewport も画面のサイズへ戻る。手書きで戻し忘れると、
    // 最初のフレームが FBO のサイズで描かれる
    bindRenderTarget(gl, null);
    gl.deleteFramebuffer(framebuffer);
    gl.bindTexture(gl.TEXTURE_CUBE_MAP, null);
    gl.bindVertexArray(null);
    gl.enable(gl.DEPTH_TEST);
    gl.enable(gl.CULL_FACE);

    // finish() を挟んでから測る。ただしブラウザによっては GPU の完了を厳密には
    // 待たないので、この数字は目安。正確な GPU 時間はタイマークエリ(第35章)
    gl.finish();
    return performance.now() - started;
  }

  const precomputeMs = precompute();

  // 以降、テクスチャユニットの割り当ては固定する(第16章)
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_CUBE_MAP, envMap);
  gl.activeTexture(gl.TEXTURE1);
  gl.bindTexture(gl.TEXTURE_CUBE_MAP, irradianceMap);
  gl.activeTexture(gl.TEXTURE2);
  gl.bindTexture(gl.TEXTURE_CUBE_MAP, prefilterMap);
  gl.activeTexture(gl.TEXTURE3);
  gl.bindTexture(gl.TEXTURE_2D, brdfTarget.texture);

  gl.enable(gl.DEPTH_TEST);
  gl.enable(gl.CULL_FACE);
  gl.clearColor(0.06, 0.07, 0.09, 1.0);

  // --- 軌道カメラ(第15章の簡略版) -------------------------------------------

  const orbit = { theta: 0, phi: 0.12, radius: 7.4 };
  const PHI_LIMIT = Math.PI / 2 - 0.05;
  const MIN_RADIUS = 3.5;
  const MAX_RADIUS = 14;

  let dragging = false;
  let activePointerId: number | null = null;
  let lastX = 0;
  let lastY = 0;

  canvas.addEventListener('pointerdown', (event) => {
    if (event.button !== 0) return;
    dragging = true;
    activePointerId = event.pointerId;
    lastX = event.clientX;
    lastY = event.clientY;
    canvas.setPointerCapture(event.pointerId);
  });

  canvas.addEventListener('pointermove', (event) => {
    if (!dragging || event.pointerId !== activePointerId) return;
    const speed = (2 * Math.PI) / canvas.clientHeight;
    orbit.theta -= (event.clientX - lastX) * speed;
    orbit.phi += (event.clientY - lastY) * speed;
    orbit.phi = Math.min(PHI_LIMIT, Math.max(-PHI_LIMIT, orbit.phi));
    lastX = event.clientX;
    lastY = event.clientY;
  });

  function endDrag(event: PointerEvent): void {
    if (event.pointerId !== activePointerId) return;
    dragging = false;
    activePointerId = null;
    if (canvas.hasPointerCapture(event.pointerId)) {
      canvas.releasePointerCapture(event.pointerId);
    }
  }
  canvas.addEventListener('pointerup', endDrag);
  canvas.addEventListener('pointercancel', endDrag);

  canvas.addEventListener(
    'wheel',
    (event) => {
      event.preventDefault();
      const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 1;
      orbit.radius *= Math.exp(event.deltaY * scale * 0.001);
      orbit.radius = Math.min(MAX_RADIUS, Math.max(MIN_RADIUS, orbit.radius));
    },
    { passive: false },
  );

  // --- 切替ボタン -------------------------------------------------------------

  let mode: number = MODE_BOTH;
  let toneMap = true;

  function addButtons<T>(
    container: HTMLParagraphElement,
    entries: readonly { label: string; value: T }[],
    isActive: (value: T) => boolean,
    onSelect: (value: T) => void,
  ): void {
    for (const entry of entries) {
      const button = document.createElement('button');
      button.type = 'button';
      button.textContent = entry.label;
      button.setAttribute('aria-pressed', isActive(entry.value) ? 'true' : 'false');
      button.addEventListener('click', () => {
        onSelect(entry.value);
        for (const other of container.querySelectorAll('button')) {
          other.setAttribute('aria-pressed', 'false');
        }
        button.setAttribute('aria-pressed', 'true');
        updateReadout();
      });
      container.append(button);
    }
  }

  addButtons(
    modeControls,
    MODES.map((entry) => ({ label: entry.label, value: entry.value })),
    (value) => value === mode,
    (value) => {
      mode = value;
    },
  );

  addButtons(
    toneControls,
    [
      { label: 'トーンマップ: Reinhard', value: true },
      { label: 'なし(1 で切り捨て)', value: false },
    ],
    (value) => value === toneMap,
    (value) => {
      toneMap = value;
    },
  );

  // --- 読み出し行 -------------------------------------------------------------

  function updateReadout(): void {
    const note = MODES.find((entry) => entry.value === mode)?.note ?? '';
    readout.textContent =
      `事前計算(初期化時に 1 回・${precomputeMs.toFixed(1)} ms): ` +
      `放射照度 ${IRRADIANCE_SIZE}²×6 面 / プレフィルタ ${PREFILTER_SIZE}²×6 面×${PREFILTER_LEVELS} 段 / ` +
      `BRDF LUT ${BRDF_LUT_SIZE}² / ${formatName}${note}`;
  }
  updateReadout();

  // --- 毎フレーム使い回す入れ物 -----------------------------------------------

  const eye = vec3.create();
  const model = mat4.create();
  const view = mat4.create();
  const projection = mat4.create();
  const viewProjection = mat4.create();
  const inverseViewProjection = mat4.create();
  const normalMatrix = mat3.create();

  // --- リサイズ(第4章と同じ) --------------------------------------------------

  function resizeIfNeeded(): void {
    const dpr = Math.min(window.devicePixelRatio, 2);
    const width = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const height = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width;
      canvas.height = height;
      gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
    }
  }

  // --- 描画ループ -------------------------------------------------------------

  function frame(): void {
    resizeIfNeeded();

    eye[0] = TARGET[0] + orbit.radius * Math.cos(orbit.phi) * Math.sin(orbit.theta);
    eye[1] = TARGET[1] + orbit.radius * Math.sin(orbit.phi);
    eye[2] = TARGET[2] + orbit.radius * Math.cos(orbit.phi) * Math.cos(orbit.theta);
    mat4.lookAt(view, eye, TARGET, UP);
    // 画面へ描くのでアスペクト比は描画バッファから(第3部の規約)
    const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
    mat4.perspective(projection, FOVY, aspect, NEAR, FAR);

    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    // 1. 背景。深度を書かない・読まないので、あとから描く球に必ず上書きされる
    gl.disable(gl.DEPTH_TEST);
    gl.useProgram(skyboxProgram);
    mat4.multiply(viewProjection, projection, view);
    mat4.invert(inverseViewProjection, viewProjection);
    gl.uniformMatrix4fv(skyboxLoc.inverseViewProjection, false, inverseViewProjection);
    gl.uniform3fv(skyboxLoc.cameraPosition, eye);
    gl.uniform1i(skyboxLoc.envMap, 0);
    gl.uniform1i(skyboxLoc.toneMap, toneMap ? 1 : 0);
    gl.bindVertexArray(emptyVao);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
    gl.enable(gl.DEPTH_TEST);

    // 2. 球のグリッド。横軸 = roughness、縦軸 = metallic
    gl.useProgram(pbrProgram);
    gl.uniformMatrix4fv(pbrLoc.view, false, view); // transpose は常に false
    gl.uniformMatrix4fv(pbrLoc.projection, false, projection);
    gl.uniform3fv(pbrLoc.cameraPosition, eye);
    gl.uniform3fv(pbrLoc.baseColor, BASE_COLOR);
    gl.uniform3fv(pbrLoc.lightDirection, LIGHT_DIRECTION);
    gl.uniform3fv(pbrLoc.lightColor, LIGHT_COLOR);
    gl.uniform1i(pbrLoc.irradianceMap, 1); // テクスチャユニット(第16章)
    gl.uniform1i(pbrLoc.prefilterMap, 2);
    gl.uniform1i(pbrLoc.brdfLUT, 3);
    gl.uniform1f(pbrLoc.prefilterMaxLod, PREFILTER_LEVELS - 1);
    gl.uniform1i(pbrLoc.mode, mode);
    gl.uniform1i(pbrLoc.toneMap, toneMap ? 1 : 0);

    gl.bindVertexArray(sphereMesh.vao);
    for (let row = 0; row < GRID; row++) {
      for (let column = 0; column < GRID; column++) {
        mat4.fromTranslation(model, [
          (column - (GRID - 1) / 2) * SPACING,
          (row - (GRID - 1) / 2) * SPACING,
          0,
        ]);
        // 平行移動だけのモデル行列なので法線行列は単位行列になるが、
        // 「モデル行列が変わったら作り直す」という手順自体は変えない(第17章)
        mat3.normalFromMat4(normalMatrix, model);
        gl.uniformMatrix4fv(pbrLoc.model, false, model);
        gl.uniformMatrix3fv(pbrLoc.normalMatrix, false, normalMatrix);
        gl.uniform1f(pbrLoc.metallic, row / (GRID - 1));
        gl.uniform1f(pbrLoc.roughness, MIN_ROUGHNESS + (1 - MIN_ROUGHNESS) * (column / (GRID - 1)));
        gl.drawElements(gl.TRIANGLES, sphereMesh.indexCount, gl.UNSIGNED_SHORT, 0);
      }
    }

    requestAnimationFrame(frame);
  }

  // このページのデモはページと寿命を共にするので、rAF ループの停止もリスナーの解除も
  // していない。事前計算で作ったキューブマップ・レンダーターゲットも解放していない
  // (deleteTexture / deleteRenderTarget。解放の一般論は第35章)
  requestAnimationFrame(frame);
}

// ---------------------------------------------------------------------------
// 要素とコンテキストの取得
// ---------------------------------------------------------------------------

const canvas = document.querySelector<HTMLCanvasElement>('#demo');
const modeControls = document.querySelector<HTMLParagraphElement>('#mode-buttons');
const toneControls = document.querySelector<HTMLParagraphElement>('#tone-buttons');
const readout = document.querySelector<HTMLParagraphElement>('#readout');
if (!canvas || !modeControls || !toneControls || !readout) {
  throw new Error('デモに必要な要素が見つかりません');
}

const gl = canvas.getContext('webgl2');
if (!gl) {
  throw new Error('このブラウザは WebGL2 に対応していません');
}

/**
 * 画像を 1 枚読み込む。
 * 第16章では `decode()` を使いましたが、ここでは load イベントを待ちます。
 * `decode()` の Promise は「次のフレームで描画に使える状態」になってから解決する仕様で、
 * この章のようにページの DOM が大きいと、レイアウトの都合で解決が何十秒も遅れることがあります。
 * texImage2D に渡すだけなら load で十分です(GPU へ送るときに改めてデコードされます)
 */
function loadImage(url: string): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const image = new Image();
    image.addEventListener('load', () => resolve(image), { once: true });
    image.addEventListener('error', () => reject(new Error(`画像を読み込めません: ${url}`)), {
      once: true,
    });
    image.src = url;
  });
}

// 環境マップの 6 面を読み込んでから初期化する。
// 画像が揃う前にキューブマップを作ると、面の一部が空のまま「不完全なテクスチャ」になる
readout.textContent = '環境マップ(6 面)を読み込み中…';
Promise.all(CUBE_FACES.map((face) => loadImage(`/textures/env/${face}.png`)))
  .then((images) => {
    setup(gl, canvas, modeControls, toneControls, readout, images);
  })
  .catch((error: unknown) => {
    readout.textContent = `環境マップを読み込めませんでした: ${String(error)}`;
  });
src/lessons/22-pbr/pbr.vert
#version 300 es

// 第17章のライティング用頂点シェーダーと中身は同じ。
// 位置と法線をワールド空間へ運び、フラグメントシェーダーへ渡すだけ。
// PBR になっても、頂点側でやることは変わらない。

// 第3部の共通の属性配置(第14章と同じ): 0 = 位置, 1 = 法線, 2 = UV
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
// この章は色を uniform で渡すのでテクスチャを貼らず、UV は使わない。宣言だけ残しているのは、
// 頂点バッファのインターリーブ配置(位置 3 + 法線 3 + UV 2)をシェーダー側からも
// 読み取れるようにするため(第21章と同じ)
layout(location = 2) in vec2 a_uv;

uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;
uniform mat3 u_normalMatrix;

out vec3 v_normal;
out vec3 v_worldPosition;

void main() {
  vec4 worldPosition = u_model * vec4(a_position, 1.0);

  v_worldPosition = worldPosition.xyz;
  v_normal = u_normalMatrix * a_normal; // 法線行列 = モデル行列の逆転置(第17章)

  gl_Position = u_projection * u_view * worldPosition;
}
src/lessons/22-pbr/pbr.frag
#version 300 es

// 第22章の本体: Cook-Torrance BRDF + metallic-roughness + IBL。
//
//   f = kD * baseColor / π  +  D * G * F / (4 * NdotV * NdotL)
//        ~~~~~~~~~~~~~~~~~     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//        拡散(Lambert)          鏡面(Cook-Torrance)
//
// 計算はすべてリニア空間。出力の直前でだけガンマを掛けて画面用に戻す(6 節)。
// この 3 つの関数(distributionGGX / geometrySmith / fresnelSchlick)と
// F0 / kD の作り方は、第23章のディファードでもそのまま同じものを使う。

precision highp float;

in vec3 v_normal;
in vec3 v_worldPosition;
out vec4 fragColor;

// --- マテリアル(metallic-roughness ワークフロー・5 節) ----------------------
uniform vec3 u_baseColor; // リニア空間の基本色
uniform float u_metallic; // 0 = 誘電体 / 1 = 金属
uniform float u_roughness; // 0 = 鏡 / 1 = つや消し

// --- 直接光(方向光 1 つ。第17章の約束どおり「面から光源へ」) ----------------
uniform vec3 u_lightDirection;
uniform vec3 u_lightColor;

// --- カメラと IBL ------------------------------------------------------------
uniform vec3 u_cameraPosition;
// サンプラの既定精度は lowp なので、float テクスチャを読むものには highp を明示する
// (precision highp float; はサンプラには効かない。第21章 6 節)
uniform highp samplerCube u_irradianceMap; // 拡散の IBL(8 節)
uniform highp samplerCube u_prefilterMap; // プレフィルタ環境マップ(9 節)
uniform highp sampler2D u_brdfLUT; // BRDF 積分マップ(9 節)
uniform float u_prefilterMaxLod; // プレフィルタ環境マップの最終ミップ番号

// --- 表示モード --------------------------------------------------------------
#define MODE_DIRECT 0
#define MODE_IBL 1
#define MODE_BOTH 2
#define MODE_BLINN_PHONG 3
uniform int u_mode;
uniform int u_toneMap;

const float PI = 3.141592653589793;

// ---------------------------------------------------------------------------
// Cook-Torrance の 3 つの項
// ---------------------------------------------------------------------------

/**
 * D: 微小面の法線分布 (GGX / Trowbridge-Reitz)。
 * 「N から角度のずれた向きを向いている微小面が、どれだけの割合であるか」。
 * α = roughness² と置くのは、roughness のスライダーを動かしたときの
 * 見た目の変化を一様にするための再パラメータ化(3 節)
 */
float distributionGGX(float NdotH, float roughness) {
  float a = roughness * roughness;
  float a2 = a * a;
  float d = NdotH * NdotH * (a2 - 1.0) + 1.0;
  return a2 / (PI * d * d);
}

/** G の片側。k は遮蔽の強さを決めるパラメータ */
float geometrySchlickGGX(float NdotX, float k) {
  return NdotX / (NdotX * (1.0 - k) + k);
}

/**
 * G: 微小面どうしの遮蔽 (Smith + Schlick-GGX)。
 * 視線側と光源側で 1 回ずつ「隠されずに見えている割合」を掛ける。
 * 直接光の k = (roughness + 1)² / 8(IBL では k = roughness² / 2 を使う。brdf.frag 参照)
 */
float geometrySmith(float NdotV, float NdotL, float roughness) {
  float k = (roughness + 1.0) * (roughness + 1.0) / 8.0;
  return geometrySchlickGGX(NdotV, k) * geometrySchlickGGX(NdotL, k);
}

/**
 * F: フレネル (Schlick の近似)。
 * 正面から見たときの反射率 F0 から、かすめる角度での 1.0 へ 5 乗で立ち上がる
 */
vec3 fresnelSchlick(float cosTheta, vec3 F0) {
  // clamp するのは、丸め誤差で cosTheta が 1 をわずかに超えると
  // pow() の底が負になり、GLSL では結果が未定義になるため
  return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

/**
 * IBL 用のフレネル。粗い面ではあらゆる向きの微小面が混ざるので、
 * かすめる角度でも 1.0 まで上がりきらない。上限を (1 - roughness) で抑えた版
 */
vec3 fresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness) {
  vec3 ceiling = max(vec3(1.0 - roughness), F0);
  return F0 + (ceiling - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

// ---------------------------------------------------------------------------
// トーンマッピングと出力変換(6 節)
// ---------------------------------------------------------------------------

/** Reinhard。x / (1 + x) は 0 → 0、∞ → 1 の単調増加なので、必ず 1 未満に収まる */
vec3 toneMapReinhard(vec3 color) {
  return color / (1.0 + color);
}

// ---------------------------------------------------------------------------

void main() {
  vec3 N = normalize(v_normal); // 補間で長さが崩れるので正規化(第17章)
  vec3 V = normalize(u_cameraPosition - v_worldPosition);
  vec3 L = u_lightDirection; // 単位ベクトルと約束したものは再正規化しない
  float NdotV = max(dot(N, V), 0.0001); // 0 除算よけ
  float NdotL = max(dot(N, L), 0.0);

  vec3 color = vec3(0.0);

  if (u_mode == MODE_BLINN_PHONG) {
    // 比較用: 第17〜18章の経験則モデル。metallic は効かず、鏡面色は光源の色のまま。
    // roughness → shininess の対応は「それらしく見える」ための当てずっぽう(1 節)
    float shininess = exp2(10.0 * (1.0 - u_roughness) + 1.0);
    vec3 H = normalize(L + V);
    float specular = pow(max(dot(N, H), 0.0), shininess);
    color = u_baseColor * u_lightColor * NdotL;
    color += vec3(0.25) * u_lightColor * specular * step(0.0001, NdotL);
    color += u_baseColor * vec3(0.12, 0.14, 0.18); // 一律の環境光
  } else {
    // 金属は拡散反射を持たず、F0 が baseColor そのものになる(5 節)
    vec3 F0 = mix(vec3(0.04), u_baseColor, u_metallic);

    if (u_mode == MODE_DIRECT || u_mode == MODE_BOTH) {
      vec3 H = normalize(L + V);
      float NdotH = max(dot(N, H), 0.0);
      float HdotV = max(dot(H, V), 0.0);

      float D = distributionGGX(NdotH, u_roughness);
      float G = geometrySmith(NdotV, NdotL, u_roughness);
      vec3 F = fresnelSchlick(HdotV, F0);

      vec3 specular = D * G * F / (4.0 * NdotV * NdotL + 0.0001);
      // 反射に回らなかったぶんだけが中へ入って拡散になる = エネルギー保存(5 節)
      vec3 kD = (1.0 - F) * (1.0 - u_metallic);
      vec3 diffuse = kD * u_baseColor / PI;

      color += (diffuse + specular) * u_lightColor * NdotL;
    }

    if (u_mode == MODE_IBL || u_mode == MODE_BOTH) {
      // 拡散の IBL: 放射照度マップは E / π を持っているので baseColor を掛けるだけ(8 節)
      vec3 F = fresnelSchlickRoughness(NdotV, F0, u_roughness);
      vec3 kD = (1.0 - F) * (1.0 - u_metallic);
      color += kD * u_baseColor * texture(u_irradianceMap, N).rgb;

      // 鏡面の IBL: split-sum 近似(9 節)。
      // 第 1 項 = roughness に応じてぼかした環境、第 2 項 = BRDF だけで決まる係数。
      // LUT に掛けるのは角度依存の F ではなく F0。積分から F0 をくくり出したのが
      // この LUT なので、F を掛けるとフレネルを二重に数えることになる
      vec3 R = reflect(-V, N);
      vec3 prefiltered = textureLod(u_prefilterMap, R, u_roughness * u_prefilterMaxLod).rgb;
      vec2 ab = texture(u_brdfLUT, vec2(NdotV, u_roughness)).rg;
      color += prefiltered * (F0 * ab.x + ab.y);
    }
  }

  if (u_toneMap == 1) {
    color = toneMapReinhard(color);
  }
  // ここまでリニア。画面へ出す直前にだけガンマを掛ける(6 節。正確な話は第27章)
  fragColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0);
}
src/lessons/22-pbr/skybox.vert
#version 300 es

// 背景(スカイボックス)の頂点シェーダー。
// ジオメトリは全画面三角形 1 枚だけ(第6章)。立方体のメッシュは要らない。
// クリップ空間の far 面(z = 1)の点を逆行列でワールドへ戻し、
// カメラからそこへ向かうベクトルを「そのピクセルが見ている方向」として渡す。

uniform mat4 u_inverseViewProjection;
uniform vec3 u_cameraPosition;

out vec3 v_direction;

void main() {
  float x = gl_VertexID == 1 ? 3.0 : -1.0;
  float y = gl_VertexID == 2 ? 3.0 : -1.0;

  // z = 1 = NDC の奥の面。透視除算を戻すため w で割る(第12章)
  vec4 world = u_inverseViewProjection * vec4(x, y, 1.0, 1.0);
  v_direction = world.xyz / world.w - u_cameraPosition;

  gl_Position = vec4(x, y, 1.0, 1.0);
}
src/lessons/22-pbr/skybox.frag
#version 300 es

// 背景(スカイボックス)。見ている方向でキューブマップを引くだけ。
// 球の鏡面に映り込んでいるものが何なのかを、目で確かめられるようにするために描く。

precision highp float;

in vec3 v_direction;
out vec4 fragColor;

// サンプラの既定精度は lowp なので、highp を明示する(第21章 6 節)
uniform highp samplerCube u_envMap;
uniform int u_toneMap;

vec3 toLinear(vec3 srgb) {
  return pow(srgb, vec3(2.2));
}

// Reinhard のトーンマッピング。0〜∞ を 0〜1 へ単調に押し込む(6 節)
vec3 toneMapReinhard(vec3 color) {
  return color / (1.0 + color);
}

void main() {
  // 背景も球と同じ土俵(リニア空間)に載せてから、最後に同じ出力変換を通す。
  // ここを手抜きしてテクスチャの値をそのまま出すと、背景だけ明るさの扱いが変わってしまう
  vec3 color = toLinear(texture(u_envMap, normalize(v_direction)).rgb);

  if (u_toneMap == 1) {
    color = toneMapReinhard(color);
  }
  fragColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0);
}
src/lessons/22-pbr/precompute.vert
#version 300 es

// 事前計算パス(放射照度マップ・プレフィルタ環境マップ・BRDF LUT)で共有する頂点シェーダー。
// 中身は第6章の全画面三角形そのままで、描画先を 1 枚の三角形で覆うだけ。
// 違いは v_uv を出すことだけで、これが「描画先のテクセル位置を 0〜1 にしたもの」になる。
//
// キューブマップの面に描くときは、この v_uv がそのまま面の (s, t) 座標になる。
// 描画バッファの下端が t = 0 で、テクスチャの 0 行目も t = 0 なので、上下の反転は起きない。

out vec2 v_uv;

void main() {
  // gl_VertexID = 0 → (-1, -1) / 1 → (3, -1) / 2 → (-1, 3)
  float x = gl_VertexID == 1 ? 3.0 : -1.0;
  float y = gl_VertexID == 2 ? 3.0 : -1.0;
  v_uv = vec2(x, y) * 0.5 + 0.5; // クリップ空間 -1〜1 → 0〜1
  gl_Position = vec4(x, y, 0.0, 1.0);
}
src/lessons/22-pbr/irradiance.frag
#version 300 es

// 事前計算パス 1: 放射照度マップ (irradiance map)
// 拡散反射の IBL のために、「この法線 N を持つ面が、環境全体からどれだけ光を受け取るか」を
// 方向ごとに 1 回だけ計算して、小さなキューブマップ(32×32)に焼き込む。
//
// 焼き込むのは放射照度 E そのものではなく E / π。
//   E(N) = ∫ L(ω) cos θ dω           (半球全体の足し算)
//   拡散の出射 = (baseColor / π) × E = baseColor × (E / π)
// なので E / π を持っておけば、シェーダー側は baseColor を掛けるだけで済む。
// 一様な明るさ L の環境なら E = πL、つまり E / π = L に戻る(検算しやすい性質)。

precision highp float;

in vec2 v_uv;
out vec4 fragColor;

// サンプラの既定精度は lowp なので、highp を明示する(第21章 6 節)
uniform highp samplerCube u_envMap;
uniform int u_face; // いま描いている面(0..5 = +X, -X, +Y, -Y, +Z, -Z)

const float PI = 3.141592653589793;

// 半球の刻み。中点則で足すので、φ128 × θ32 = 4096 サンプルで
// 一様環境に対する E / π が 1.0004 になる(誤差 0.04%)
const int PHI_STEPS = 128;
const int THETA_STEPS = 32;

// キューブマップの面と (s, t) から方向ベクトルを作る。
// OpenGL ES 3.0 の「面 → major axis と sc / tc」の対応表をそのまま逆に解いたもの。
// sc / |ma| = 2s - 1、tc / |ma| = 2t - 1 なので、|ma| = 1 と置けばこの式になる
vec3 directionFromFace(int face, vec2 uv) {
  float sc = uv.x * 2.0 - 1.0;
  float tc = uv.y * 2.0 - 1.0;
  if (face == 0) return vec3(1.0, -tc, -sc);  // +X
  if (face == 1) return vec3(-1.0, -tc, sc);  // -X
  if (face == 2) return vec3(sc, 1.0, tc);    // +Y
  if (face == 3) return vec3(sc, -1.0, -tc);  // -Y
  if (face == 4) return vec3(sc, -tc, 1.0);   // +Z
  return vec3(-sc, -tc, -1.0);                // -Z
}

// 環境マップの PNG は sRGB として作られているので、読んだ直後にリニアへ戻す(第27章)
vec3 toLinear(vec3 srgb) {
  return pow(srgb, vec3(2.2));
}

void main() {
  vec3 N = normalize(directionFromFace(u_face, v_uv));

  // N を法線とする接空間の基底。N が真上に近いときだけ up をずらして cross が 0 になるのを避ける
  vec3 up = abs(N.y) < 0.999 ? vec3(0.0, 1.0, 0.0) : vec3(0.0, 0.0, 1.0);
  vec3 tangent = normalize(cross(up, N));
  vec3 bitangent = cross(N, tangent);

  float dPhi = 2.0 * PI / float(PHI_STEPS);
  float dTheta = 0.5 * PI / float(THETA_STEPS);

  vec3 sum = vec3(0.0);
  for (int i = 0; i < PHI_STEPS; i++) {
    float phi = (float(i) + 0.5) * dPhi;
    for (int j = 0; j < THETA_STEPS; j++) {
      float theta = (float(j) + 0.5) * dTheta;
      // 接空間の方向 → ワールドの方向
      vec3 local = vec3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta));
      vec3 dir = local.x * tangent + local.y * bitangent + local.z * N;
      // cos θ は「斜めから来た光は薄まる」ぶん(第17章の余弦則)、
      // sin θ は球面座標の面積要素 dω = sin θ dθ dφ のぶん
      sum += toLinear(texture(u_envMap, dir).rgb) * cos(theta) * sin(theta);
    }
  }

  // Σ に dθ dφ を掛けて積分にし、π で割って E / π にする
  fragColor = vec4(sum * dPhi * dTheta / PI, 1.0);
}
src/lessons/22-pbr/prefilter.frag
#version 300 es

// 事前計算パス 2: プレフィルタ環境マップ (prefiltered environment map)
// split-sum 近似の第 1 項。「roughness ごとに、反射方向 R のまわりを GGX の形にぼかした環境」を
// キューブマップのミップ段に 1 段ずつ焼き込む。mip 0 が roughness 0(そのままの環境)、
// 最終段が roughness 1(ほぼ一様にぼけた環境)。
//
// N = V = R と置いてしまうのが split-sum の割り切りで、このおかげで
// 「見る角度」の自由度が消え、方向 1 つ + roughness 1 つでキャッシュできる。
// 代償として、斜めから見たときに伸びるはずの反射(縦長のハイライト)は再現できない。

precision highp float;

in vec2 v_uv;
out vec4 fragColor;

// サンプラの既定精度は lowp なので、highp を明示する(第21章 6 節)
uniform highp samplerCube u_envMap;
uniform int u_face;
uniform float u_roughness;
/** 入力キューブマップの 1 面の解像度。ミップ段の選択に使う */
uniform float u_envResolution;

const float PI = 3.141592653589793;
const uint SAMPLE_COUNT = 128u;

vec3 directionFromFace(int face, vec2 uv) {
  float sc = uv.x * 2.0 - 1.0;
  float tc = uv.y * 2.0 - 1.0;
  if (face == 0) return vec3(1.0, -tc, -sc);
  if (face == 1) return vec3(-1.0, -tc, sc);
  if (face == 2) return vec3(sc, 1.0, tc);
  if (face == 3) return vec3(sc, -1.0, -tc);
  if (face == 4) return vec3(sc, -tc, 1.0);
  return vec3(-sc, -tc, -1.0);
}

vec3 toLinear(vec3 srgb) {
  return pow(srgb, vec3(2.2));
}

// GGX / Trowbridge-Reitz。本編の pbr.frag とまったく同じ式
float distributionGGX(float NdotH, float roughness) {
  float a = roughness * roughness;
  float a2 = a * a;
  float d = NdotH * NdotH * (a2 - 1.0) + 1.0;
  return a2 / (PI * d * d);
}

// ビット反転で作る低差異列(Hammersley 列)。乱数の代わりに、
// 「区間を等間隔に埋めていく」性質のある並びを使うとサンプル数が少なくて済む
float radicalInverse(uint bits) {
  bits = (bits << 16u) | (bits >> 16u);
  bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);
  bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);
  bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);
  bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);
  return float(bits) * 2.3283064365386963e-10;
}

vec2 hammersley(uint i, uint n) {
  return vec2(float(i) / float(n), radicalInverse(i));
}

// D(h)·(n·h) の形に沿って微小面の法線 H を撒く(重点サンプリング)。
// 一様に撒くと、D が尖っている低 roughness ではほとんどのサンプルが無駄になる。
//
// sqrt() の中を max() で 0 止めしているのは、丸め誤差への備えです。
// roughness が 0 に近いと a² がほぼ 0 になり、cos²θ の分母 1 + (a² - 1)ξ は
// 分子 1 - ξ とほとんど同じ値になります。GLSL ES 3.00 §4.5.1 は除算に 2.5 ULP の
// 誤差を許し、丸めモードも未定義としているので、この商は 1 を超え得ます(実測で
// 1 ULP = 約 1.2e-7 の超過)。そのまま 1 - cos²θ を取ると負の数になり、sqrt() に
// 負の値を渡した結果は未定義です。実測では NaN が返り、以降の計算がすべて汚染されます
vec3 importanceSampleGGX(vec2 xi, vec3 N, float roughness) {
  float a = roughness * roughness;
  float phi = 2.0 * PI * xi.x;
  float cosTheta = sqrt((1.0 - xi.y) / (1.0 + (a * a - 1.0) * xi.y));
  float sinTheta = sqrt(max(1.0 - cosTheta * cosTheta, 0.0));

  vec3 local = vec3(sinTheta * cos(phi), sinTheta * sin(phi), cosTheta);
  vec3 up = abs(N.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
  vec3 tangent = normalize(cross(up, N));
  vec3 bitangent = cross(N, tangent);
  return normalize(local.x * tangent + local.y * bitangent + local.z * N);
}

void main() {
  vec3 N = normalize(directionFromFace(u_face, v_uv));
  vec3 V = N; // split-sum の割り切り: 見る向きも反射方向も法線と同じとみなす

  vec3 sum = vec3(0.0);
  float weight = 0.0;

  for (uint i = 0u; i < SAMPLE_COUNT; i++) {
    vec3 H = importanceSampleGGX(hammersley(i, SAMPLE_COUNT), N, u_roughness);
    vec3 L = reflect(-V, H);
    float NdotL = dot(N, L);
    if (NdotL <= 0.0) continue;

    // サンプルが疎な方向ほど環境マップのぼけた段から読む。
    // これをしないと、太陽のような小さく強い光源が点々のノイズ(ファイアフライ)になる
    float NdotH = max(dot(N, H), 0.0);
    float HdotV = max(dot(H, V), 0.0);
    float pdf = distributionGGX(NdotH, u_roughness) * NdotH / (4.0 * HdotV + 0.0001) + 0.0001;
    float texelSolidAngle = 4.0 * PI / (6.0 * u_envResolution * u_envResolution);
    float sampleSolidAngle = 1.0 / (float(SAMPLE_COUNT) * pdf);
    float mip = u_roughness == 0.0 ? 0.0 : 0.5 * log2(sampleSolidAngle / texelSolidAngle);

    sum += toLinear(textureLod(u_envMap, L, mip).rgb) * NdotL;
    weight += NdotL;
  }

  fragColor = vec4(sum / max(weight, 0.001), 1.0);
}
src/lessons/22-pbr/brdf.frag
#version 300 es

// 事前計算パス 3: BRDF 積分マップ (BRDF LUT)
// split-sum 近似の第 2 項。環境の中身にはまったく依存せず、BRDF の形だけで決まるので、
// 一度作れば環境マップを差し替えても使い回せる 2 次元テーブルになる。
//
// 横軸 = NdotV(0〜1)、縦軸 = roughness(0〜1)。出力の R に「F0 に掛ける係数」、
// G に「F0 に足す下駄」が入る。使う側は
//   specular = prefiltered * (F0 * lut.r + lut.g)
// と書くだけでフレネルの効きを復元できる。掛けるのは角度依存の F ではなく F0 —
// 積分から F0 をくくり出したものが lut.r / lut.g だから。

precision highp float;

in vec2 v_uv;
out vec4 fragColor;

const float PI = 3.141592653589793;
const uint SAMPLE_COUNT = 1024u;

float radicalInverse(uint bits) {
  bits = (bits << 16u) | (bits >> 16u);
  bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);
  bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);
  bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);
  bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);
  return float(bits) * 2.3283064365386963e-10;
}

vec2 hammersley(uint i, uint n) {
  return vec2(float(i) / float(n), radicalInverse(i));
}

// prefilter.frag とまったく同じもの。sqrt() の中の max() が要る理由もそちらと同じで、
// roughness が 0 に近いと 1 - cos²θ が丸め誤差で負に振れ、sqrt() が NaN を返します
vec3 importanceSampleGGX(vec2 xi, vec3 N, float roughness) {
  float a = roughness * roughness;
  float phi = 2.0 * PI * xi.x;
  float cosTheta = sqrt((1.0 - xi.y) / (1.0 + (a * a - 1.0) * xi.y));
  float sinTheta = sqrt(max(1.0 - cosTheta * cosTheta, 0.0));

  vec3 local = vec3(sinTheta * cos(phi), sinTheta * sin(phi), cosTheta);
  vec3 up = abs(N.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
  vec3 tangent = normalize(cross(up, N));
  vec3 bitangent = cross(N, tangent);
  return normalize(local.x * tangent + local.y * bitangent + local.z * N);
}

// IBL 用の Smith。直接光の k = (roughness + 1)² / 8 とは違い、k = roughness² / 2 を使う。
// 直接光の k は「1 方向から来る光」に合わせた当てはめで、環境全体を積分するここでは
// 遮蔽を強く見積もりすぎる
float geometrySmithIBL(float NdotV, float NdotL, float roughness) {
  float k = roughness * roughness / 2.0;
  float ggxV = NdotV / (NdotV * (1.0 - k) + k);
  float ggxL = NdotL / (NdotL * (1.0 - k) + k);
  return ggxV * ggxL;
}

void main() {
  float NdotV = max(v_uv.x, 0.001); // NdotV = 0 では 0 除算になるので下限を入れる
  float roughness = v_uv.y;

  // 法線を +Z に固定してよい。等方な BRDF なので、V の方位角は結果に効かない
  vec3 N = vec3(0.0, 0.0, 1.0);
  vec3 V = vec3(sqrt(1.0 - NdotV * NdotV), 0.0, NdotV);

  float scale = 0.0;
  float bias = 0.0;

  for (uint i = 0u; i < SAMPLE_COUNT; i++) {
    vec3 H = importanceSampleGGX(hammersley(i, SAMPLE_COUNT), N, roughness);
    vec3 L = reflect(-V, H);
    float NdotL = L.z;
    if (NdotL <= 0.0) continue;

    float NdotH = max(H.z, 0.0);
    float VdotH = max(dot(V, H), 0.0);

    // 重点サンプリングの pdf で割った残り。D が約分で消えるのがこの形の利点
    float g = geometrySmithIBL(NdotV, NdotL, roughness);
    float gVis = g * VdotH / (NdotH * NdotV);
    // フレネルを F0 * scale + bias の形に分解する。
    // F = F0 + (1 - F0) * fc なので、F0 の係数が (1 - fc)、定数項が fc
    float fc = pow(1.0 - VdotH, 5.0);

    scale += (1.0 - fc) * gVis;
    bias += fc * gVis;
  }

  fragColor = vec4(scale / float(SAMPLE_COUNT), bias / float(SAMPLE_COUNT), 0.0, 1.0);
}

three.js との対応

three.jsこの章
MeshStandardMaterialこの章のpbr.fragそのもの。metallic-roughness の Cook-Torrance
material.metalness(既定 0.0)/material.roughness(既定 1.0)u_metallic/u_roughness。既定値も同じ考え方(何も指定しなければ「つや消しの誘電体」)
material.coloru_baseColor。ただし three.js 側は色空間の変換をエンジンが面倒を見る。この章はリニアの値を直接渡す(6 節)
material.envMap/material.envMapIntensity(既定 1.0)u_prefilterMap(プレフィルタ済み)+u_irradianceMap。強度の uniform は用意していない
scene.environment/scene.environmentIntensityシーン全体に環境マップを効かせる仕組み。material.envMapが優先され、そちらが null のときだけ使われる。この章はシーングラフを持たないので相当物なし
PMREMGenerator(src/extras/PMREMGenerator.js)この章のprefilter.frag+ 事前計算パスに相当。名前のとおり Prefiltered, Mipmapped Radiance Environment Map を作る。出力はWebGLRenderTargetで、キューブマップではなく CubeUV という平面レイアウトに詰める点が違う
D_GGX/V_GGX_SmithCorrelated/F_SchlickdistributionGGX/geometrySmith/fresnelSchlick。ただし three.js の Smith は height-correlated 版で、4·NdotV·NdotLの除算を関数内に畳み込んでいる。F_Schlickpow(x, 5)ではなくexp2を使った近似
BRDF_LambertkD * u_baseColor / PI。three.js もRECIPROCAL_PIを掛けている(第17章にはなかった係数)
BRDF 積分マップこの章のbrdf.frag。three.js は r182 で解析的な近似 (DFGApprox)をやめ、あらかじめ焼いたdfgLUTテクスチャを参照する方式に変わった(EnvironmentBRDF)。使い方も同じで、specularColor * fab.x + specularF90 * fab.y— 第 1 項に掛けているのはspecularColor(= F0)
renderer.toneMapping(既定NoToneMapping)/renderer.toneMappingExposure(既定 1.0)u_toneMapの切替。露出は用意していない。定数はLinear/Reinhard/Cineon/ACESFilmic/Custom/AgX/Neutralの 7 種類 +NoToneMapping(比較は第27章)
THREE.ACESFilmicToneMappingこの章は Reinhard 1 つだけ。three.js の ACES 実装は AP1 色空間への行列変換 + 有理関数のフィットで、さらに露出に/ 0.6の独自スケールが掛かる
sRGB ⇄ リニアの変換この章はpow(2.2)/pow(1/2.2)の近似。three.js は 0.0031308 / 12.92 / 1.055 / 2.4 の正確な分岐付き曲線を使う(第27章)
MeshPhysicalMaterialMeshStandardMaterialを継承し、clearcoat/transmission/ior(既定 1.5)/sheen/iridescence/anisotropyなどを足したもの。この章では扱わない
CubeTextureLoadercreateEnvCubeMap。6 面をTEXTURE_CUBE_MAP_POSITIVE_X + iへ順に転送する(7 節)

手元で動かして、壊してみる

コードはsrc/lessons/22-pbr/にあります。式が多い章ですが、1 項ずつ消してみると、それぞれが何を担っていたかがはっきりします。

まとめ

これで、1 つの物体を「正しく」照らす道具が揃いました。次の問題はです。 デモの球は 25 個ですが、光源は方向光 1 つだけ。第18章で予告したとおり、この方式ではフラグメント数 × 光源数のコストがかかり、光源を数十個に増やすと破綻します。 しかも、画面に出ない部分のフラグメントにまで全部の光源を計算させています。次章第23章「ディファードレンダリング — MRT と G-buffer」では、描画を「ジオメトリを書き出すパス」と「ライティングを解くパス」に分け、光源の計算を画面のピクセル数ぶんに固定します。第20章で予告したdrawBuffersによる MRT(複数のカラーアタッチメントへの同時出力)の出番で、G-buffer に詰めるのはこの章で使った基本色・metallic・roughness・法線 — つまりこの章の BRDF をそのまま持ち込みます