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

ライティング基礎 — 法線・拡散反射・鏡面反射

第14章で球やトーラスを作ったとき、頂点には位置と UV のほかに法線を持たせました。 第15章でカメラを動かせるようにしたあとも、画面に出ていたのは「法線をそのまま色にした」デバッグ表示の ままです。この章で、その法線がようやく本来の仕事をします —光の当たり方を計算して、面に陰影を付けることです。

three.js でいえばMeshLambertMaterialMeshPhongMaterialDirectionalLightを 1 つ足したときに起きていることを、自分の手で書く章です。式そのものは短く、フラグメント シェーダーで 5 行ほど。ただし、その 5 行にたどり着くまでに片付けておくべきことが 2 つあります。法線を正しくワールド空間へ運ぶことと、補間で崩れた法線を直すことです。どちらも、第14・15章が「第17章で」と先送りにしてきた宿題でした。

方向光 1 つで照らした楕円体とトーラス。小さな明るい球が、光が来ている向きを示します(方向光には 位置がないので、その向きの遠くに置いた目印です)。ドラッグで回り込み、ホイールで寄れます (第15章の軌道カメラの簡略版)。左の楕円体は球を x 方向に 1.7 倍・y 方向に 0.62 倍した非一様スケールで、法線行列(2 節)がないと陰影が崩れる形です。「Gouraud vs Phong」だけは粗い球 2 つに切り替わり、同じ式を頂点で計算したもの(左)とフラグメントで計算したもの(右)を並べます。

この章で学ぶこと:

1. ライティングとは何を計算することか

リアルタイム CG のライティングは、乱暴に言えば「この点から、カメラの方へどれだけの光が返ってくるか」を面の 1 点ごとに見積もる計算です。光源から出た光が面に当たり、面の性質に応じて散らばり、その一部がカメラへ届く。 その量を色として書き出せば、立体に見えます。

この章で組み立てるモデルは、3 つの項の足し算です。

先に、この章がやらないことをはっきりさせておきます。ここで計算するのは 「面と光の向きの関係」だけで、光が途中で何かに遮られるかどうかは一切見ていません。 つまり、物体は互いに影を落としません。デモの楕円体とトーラスがどれだけ近づいても、片方が もう片方の上に影を作ることはありません。遮蔽を扱うには「光源から見て手前に何かあるか」を 別に調べる必要があり、それがシャドウマッピング(第21章)です。

もう 1 つ。この章の Blinn-Phong は物理法則から導いたものではなく、「それらしく見える」ように作られた経験則 (empirical model)です。エネルギー保存も入射角による反射率の変化も考えていません。実測に基づく物理ベースの モデル(GGX・metallic-roughness など)は第22章で扱います。素朴な RGB の足し算がそもそも色として正しいのか、という話も第27章に取ってあります。

どの空間で計算するか

ライティングの式には、法線 N・光の向き L・視線の向き V という 3 つのベクトルが出てきます。 これらの角度関係さえ合っていれば、どの座標空間で計算しても結果は同じです。実際、three.js はビュー空間で計算します。このサイトではワールド空間に統一します。 理由は単純で、そのほうが読者が値を確かめやすいからです — 「光は右上から」「カメラは (0, 2, 6) にいる」といったシーンの言葉が、そのまま uniform の数値になります。ビュー空間だと、光の向きを 毎フレームビュー行列で変換してから渡すことになり、デバッグのたびに頭の中で座標を戻す必要が 出てきます。

この決定に合わせて、第3部で使う名前も固定します。u_normalMatrix(mat3)、u_cameraPosition(vec3・ワールド)、varying はv_normal(ワールド)とv_worldPosition。第18章以降もこの名前で通します。

2. 法線行列 — なぜ「逆転置」なのか

第14章の頂点シェーダーには、こう書いてありました。

src/lessons/14-procedural-geometry/geometry.vert(第14章・抜粋)
v_normal = mat3(u_model) * a_normal;

そして「これが通用するのは回転だけのとき。正しい一般解は第17章で」と書き添えてありました。 第15章のscene.vertも同じ手抜きをしています。宿題を回収しましょう。

まず、なぜ位置と同じ行列では駄目なのか。法線は「位置」ではなく「向き」であり、 しかもただの向きでもありません。面と垂直であるという条件が本質です。 位置を動かす変換をかけたあとも、法線は動いたあとの面と垂直でなければ 意味がありません。ところが、x 方向だけを引き伸ばすような非一様スケール (non-uniform scale)では、面の傾きの変わり方と、法線をそのまま同じ行列で変換したときの 変わり方が食い違います。

変換前(面の断面)TN90°N · T = 0A = diag(2, 1)x だけ 2 倍変換後(x だけ 2 倍)T' = A TA⁻ᵀ N(垂直)A N(ずれる)(A N) · T' ≠ 0 / (A⁻ᵀ N) · T' = 0
45° 傾いた面を x 方向に 2 倍引き伸ばすと、面は寝ます。面に沿った接ベクトル T は行列をそのままかければ正しく追随しますが(T' = A T)、法線に同じ行列をかけると x 寄りに傾いてしまい、面と垂直でなくなります。正しい法線は逆に y 寄りへ倒れます。

図の状況を数で確かめます。元の面の法線を N = (1, 1)、面に沿った接ベクトルを T = (1, −1) とします(N · T = 0)。x だけ 2 倍する行列 A = diag(2, 1) をかけると、接ベクトルは T' = A T = (2, −1) になります。接ベクトルは 2 点の差なので、位置と同じ行列で正しく変換されます。ここが出発点です。一方、法線に同じ A をかけると A N = (2, 1) で、(2, 1) · (2, −1) = 3 ≠ 0 — 垂直ではありません。正しい答えは (1, 2) 方向で、(1, 2) · (2, −1) = 0 になります。

では、その「正しい行列」B をどう求めるか。求める条件は 1 つだけです。変換後も、法線が面のすべての接ベクトルと垂直であること

ここで dot を行列の積で書き直します(第11章の dot と、第12章で使った転置)。ベクトルを縦に 並べた列と見れば、a · b = aᵀ b です。転置にはもう 1 つ、積の転置は順序がひっくり返るという性質があります: (B N)ᵀ = Nᵀ Bᵀ。これだけ認めれば次の式は追えます。

N' · T' = (B N)ᵀ (A T) = Nᵀ Bᵀ A T

これがすべての T について 0 になってほしい。もともと Nᵀ T = 0は成り立っているので、真ん中の Bᵀ A が単位行列 I になれば、式は Nᵀ T = 0 に戻って必ず 0 になります。つまり:

Bᵀ A = I  ⟹  Bᵀ = A⁻¹  ⟹  B = (A⁻¹)ᵀ = A⁻ᵀ

厳密には、Bᵀ A が単位行列の定数倍(Bᵀ A = cI)でも条件は満たされます。Nᵀ(cI)T = c(Nᵀ T) = 0 だからです。長さの違いは正規化で消えるので、いちばん素直な Bᵀ A = I を採ります。この「定数倍の自由度」が、次の aside でmat3(u_model)が通用する理由になります。

これが法線行列 (normal matrix)です。モデル行列の左上 3×3 を取り出し、逆行列にして転置する — 順番はどちらが先でも同じ結果になります((A⁻¹)ᵀ = (Aᵀ)⁻¹)。 gl-matrix にはmat3.normalFromMat4が用意されていて、mat4 から直接この 3×3 を作ってくれます。

src/lessons/17-lighting-basics/main.ts(抜粋)
// 法線行列 = モデル行列の左上 3×3 の逆転置(2 節)。
// モデル行列が変わるたびに作り直す。ここを mat3.fromMat4 にすると、
// 非一様スケールの楕円体だけ陰影が崩れる(「壊してみる」で試せる)
mat3.normalFromMat4(normalMatrix, model);

実際に mat3.normalFromMat4 に diag(2, 1, 1) を渡すと diag(0.5, 1, 1) が返ります。法線の x 成分を縮める行列です。面が x 方向へ引き伸ばされて寝るのだから、 法線は逆に y 寄りへ倒れる — 図と一致します。

頂点シェーダー側は、この行列を受け取って法線を変換し、位置と一緒にワールド空間で出力するだけです。

src/lessons/17-lighting-basics/lighting.vert(抜粋)
void main() {
  vec4 worldPosition = u_model * vec4(a_position, 1.0);

  v_worldPosition = worldPosition.xyz;
  // 法線には平行移動を効かせたくないので 3×3。非一様スケールでも面と垂直を保つのが法線行列
  v_normal = u_normalMatrix * a_normal;

  gl_Position = u_projection * u_view * worldPosition;
}

3. 補間された法線は、単位長ではない

第14章のフラグメントシェーダーに、こんなコメントを残していました — 「補間で長さが 1 からずれるので normalize してから使う(理由は第17章で詳しく)」。その理由です。

ラスタライザは、頂点シェーダーの出力(varying)を三角形の内部で線形補間します。 ここで補間されるのはベクトルの成分であって、向きや長さではありません。長さ 1 のベクトル 2 本の成分を平均すると、結果の長さは 1 より短くなります。2 本の間の角が開くほど短くなり、ちょうど反対を向いた 2 本なら長さ 0 です。

n₀n₁mix(n₀, n₁, 0.5)長さ 0.87normalize で円周へ戻す補間されるのは「成分」長さ 1 のベクトル 2 本の成分を平均すると、結果は円の内側に落ちる= 三角形の中ほどだけ法線が短くなる→ 拡散反射がそのぶん暗くなる
2 つの頂点法線が 60° 離れている場合、その中点の補間結果は長さ cos30° ≒ 0.87 になります。 正規化を忘れると、三角形の中ほどが不自然に暗い帯になって現れます。

dot(N, L)|N||L|cosθですから(第11章)、N が 0.87 倍に縮めば明るさもそのまま 0.87 倍になります。三角形の頂点付近だけ明るく、中ほどが暗い — 分割が粗いほど目立つ、独特の汚れ方です。 だからフラグメントシェーダーで使う直前に必ず normalize するのが定石です。 三角形の中では長さが変わっても向きはおおむね保たれるので、正規化だけで実用上は十分な精度に戻ります。

4. 光の向き L — 符号を先に決めておく

ライティングでいちばん多いバグは、おそらく「光の向きの符号」です。「光の方向」という日本語には、光が進んでいく向き面から見て光源がある向きという正反対の 2 つの意味があり得ます。どちらでも式は書けますが、途中で混ざると絵が破綻します。そこでこのサイトでは、 第3部を通して一貫してこう決めます。

u_lightDirectionは「面から光源へ向かう」単位ベクトルとする。

こう決める理由は、式に出てくるのがつねにこちら側だからです。dot(N, L)の L も、ハーフベクトルnormalize(L + V)の L も、「面から光源へ」の向きです。視線ベクトル V も同じ流儀で「面からカメラへ」に揃えておくと、 N・L・V の 3 本がすべて面から外向きに生えた形になり、頭の中の絵と式が一致します。

方向光 (directional light)は、太陽のように「無限に遠くにある光源」の近似です。どの点から見ても光源は同じ方向にあるので、L はシーン全体で 1 つの定数になります。距離による減衰もありません(光源が無限遠なので、距離の違いが 相対的に無視できる、という理屈です)。この章はこれ 1 つだけで進めます。位置を持つ点光源と、 その距離減衰は第18章です。

src/lessons/17-lighting-basics/main.ts(抜粋)
// 光の向き: 「面から光源へ向かう」単位ベクトル(4 節)。仰角 LIGHT_ELEVATION で
// 水平にゆっくり 1 周させる。cos/sin の組み立てなので、もともと長さは 1
const lightAngle = time * 0.35;
vec3.set(
  lightDirection,
  Math.cos(LIGHT_ELEVATION) * Math.sin(lightAngle),
  Math.sin(LIGHT_ELEVATION),
  Math.cos(LIGHT_ELEVATION) * Math.cos(lightAngle),
);

5. 拡散反射 — なぜ cos に比例するのか

ざらざらした面(石膏、紙、つや消し塗装)に光が当たると、光はあらゆる方向へほぼ均等に散らばります。 こうした面をランバート面 (Lambertian surface)と呼び、その明るさは見る方向によらず一定です。真横から見ても正面から見ても、白い壁の明るさは変わりません。では何で決まるかというと、面が光をどれだけ受け取ったかだけです。

ここが cos の出どころです。太さの決まった光の束を考えます。面に真正面から当たれば、その束は 面の狭い範囲に集中します。斜めに当たれば、同じ束が引き伸ばされて広い範囲に散らばります。 面積あたりで受け取るエネルギーは、傾けたぶんだけ薄まる — その薄まり方がちょうど cosθ です。

真正面から当たる(θ = 0)照らされる幅 = w面積あたりの光 = 1斜めに当たる(θ = 55°)Nθ照らされる幅 = w / cos θ ≒ 1.7 w面積あたりの光 = cos θ ≒ 0.57
同じ太さの光の束でも、斜めに当たると面の上では広い範囲に引き伸ばされます。単位面積あたりが 受け取る光は cos θ 倍に薄まる — これがランバートの余弦則です。

あとは cosθ をどう計算するかですが、答えは第11章で出ています。N も L も単位ベクトルなら、dot(N, L) がそのまま cosθです。第11章で「dot は向きの一致度メーター」と呼び、「第17章の Lambert 拡散はこの 1 行の dot から始まります」と予告した、その 1 行がこれです。

src/lessons/17-lighting-basics/lighting.frag(抜粋)
// 補間された法線は単位長ではない(3 節)。使う前に必ず正規化する
vec3 N = normalize(v_normal);
vec3 L = u_lightDirection;

// Lambert の余弦則。面が光に背を向けた側(dot が負)は 0 で止める(5 節)
float diffuse = max(dot(N, L), 0.0);
vec3 color = u_baseColor * u_lightColor * diffuse;

max(..., 0.0)が要るのは、面が光に背を向けている(θ > 90°、dot が負)ときです。負の明るさに意味はないので 0 で止めます。これを忘れると、裏側で色がマイナス方向に効いてしまい、他の項と 足したときに妙に暗い場所ができます。

デモを「拡散のみ」にすると、光の当たらない側が完全な黒になります。形が まったく見えません。現実にはこうはならない — なぜなら、現実の物体は床や壁で跳ね返った光にも 照らされているからです。その跳ね返りをまじめに計算するのが大域照明 (global illumination) ですが、リアルタイムでは重すぎます。そこで登場するのが、次の乱暴な近似です。

6. 環境光 — 正しくはないが、ないと困る

環境光 (ambient light)のモデルは、身も蓋もないほど単純です。「どこからでも同じだけ光が来ていることにして、一定値を足す」。 向きも距離も関係ありません。

src/lessons/17-lighting-basics/lighting.frag(抜粋)
if (u_mode >= 1) {
  // 環境光: あらゆる方向から一様に来ることにした、乱暴だが便利な近似(6 節)
  color += u_baseColor * u_ambientColor;
}

デモで「拡散のみ」と「拡散 + 環境光」を切り替えると、黒く潰れていた側にうっすら形が戻るのが 分かります。たったこれだけで絵は見られるようになる。しかし、これが物理的にどれだけ乱暴かも 正直に見ておきましょう。

より真面目な代替として、上からの光と下からの光を別の色にする半球ライト(three.js のHemisphereLight)や、周囲の環境を撮ったキューブマップから拡散成分を求める方法 (image-based lighting)があります。後者は第22章の PBR で扱います。

7. 鏡面反射 — Blinn-Phong とハーフベクトル

つるつるした面には、光源が映り込んだハイライトができます。拡散反射と違い、 ハイライトは見る位置で動きます。カメラを動かすと、りんごの照りは表面を滑るように 移動する。だから鏡面反射の式には、拡散反射になかった視線ベクトル Vが入ります。

素直に考えると、こうなります。「光が完全な鏡のように反射する向き R を求め、それが視線 V とどれだけ一致しているかを測る」。これが 1975 年のPhong の反射モデルです。

specular = pow(max(dot(R, V), 0.0), shininess)、ただしR = reflect(-L, N)

powの指数shininessが「つるつる具合」です。大きいほど、R と V がぴったり合ったときだけ光る = ハイライトが小さく 鋭くなります。

1977 年に Jim Blinn が示した改良が、いま広く使われているBlinn-Phongです。R を計算するのをやめて、代わりに L と V のちょうど中間を向くベクトルを 使います。

H = normalize(L + V)(ハーフベクトル、half vector)

H が意味するのは「面がどちらを向いていれば、光がまっすぐカメラへ跳ね返るか」 という理想の法線です。実際の法線 N がその理想 H にどれだけ近いかを測れば、鏡面反射の強さが 決まります。

specular = pow(max(dot(N, H), 0.0), shininess)

NL(面 → 光源)V(面 → カメラ)HRαθL + V(正規化する前)N・L・V が同一平面にあるとき α = θ / 2
Blinn-Phong のハーフベクトル。L と V を足して正規化した H は、「この向きに面が向いていれば 光がまっすぐカメラへ返る」という理想の法線です。Phong が測る角 θ(R と V)に対し、Blinn-Phong が測る角 α(N と H)はその半分になります。

2 つの式は別物です。図のとおり、Blinn-Phong が測る角 α は Phong が測る角 θ のちょうど半分になります(N・L・V が同一平面にある場合。手元で数値検算もできます)。cos は 角度が小さいほど 1 に近いので、同じ shininess なら Blinn-Phong のほうがハイライトが 広くなります。Phong の見た目に近づけたければ、指数をおおよそ 4 倍に します。cos^p(θ) と cos^q(θ/2) が同じ広がりになる q を、θ が小さい範囲で見積もると q ≒ 4p になるからです(cos^p θ ≒ exp(−pθ²/2)、cos^q(θ/2) ≒ exp(−qθ²/8) と近似して指数部を揃えます)。

では、なぜ Blinn-Phong のほうが広く使われているのでしょうか。

src/lessons/17-lighting-basics/lighting.frag(抜粋)
if (u_mode >= 2) {
  // Blinn-Phong の鏡面反射(7 節)。H は L と V のちょうど中間を向く単位ベクトル
  vec3 V = normalize(u_cameraPosition - v_worldPosition);
  vec3 H = normalize(L + V);
  float specular = pow(max(dot(N, H), 0.0), u_shininess);
  // 光が当たっていない裏側にハイライトを乗せない(diffuse が 0 の面は鏡面も 0)
  color += u_specularColor * u_lightColor * specular * step(0.0001, diffuse);
}

最後のstep(0.0001, diffuse)は、光が当たっていない面にハイライトを乗せないための掛け算です。dot(N, L) が 0 以下でも dot(N, H) は正になり得るので、これがないと物体の裏側にうっすら光の輪が出ます。なおstepは二値のスイッチなので、shininess を小さくすると明暗の境目に段差が出ることがあります(既定の 64 では見えませんが、「壊してみる」で 4 に落とすと分かります)。three.js はstepの代わりに、拡散項と同じdot(N, L)を鏡面項にも掛けていて(lights_phong_pars_fragment.glsl.jsirradiance = dotNL * directLight.colorを拡散・鏡面の両方に適用)、そちらは境目でも滑らかに 0 へ消えます。

8. Gouraud か Phong か — どこで計算するか

ここまでの式は、すべてフラグメントシェーダーに書きました。しかし、同じ式は 頂点シェーダーにも書けます。頂点で色を計算してしまい、あとはラスタライザにその色を補間させる — これがGouraud シェーディング (Gouraud shading)です。対して、法線を補間して フラグメントごとに式を評価するのがPhong シェーディング (Phong shading)です。

違いは計算量と品質のトレードオフです。頂点数がフラグメント数より圧倒的に少ないなら、Gouraud のほうが速い。しかし品質は落ちます。頂点で拾えなかった明るさの変化は、二度と戻ってきません。とくに鏡面反射のハイライトは狭い範囲で急激に変化するので、頂点の間にすっぽり収まってしまうと、 そのハイライトは消えます

Gouraud(頂点で計算)頂点: 暗い頂点: 暗い頂点: 暗い→ 補間しても暗いまま。本来この中にあったハイライトは消えるPhong(フラグメントで計算)頂点: 法線だけ渡す→ 補間するのは法線。ハイライトは三角形の内部でも計算される
Gouraud は頂点で「色」を確定させてしまうため、頂点と頂点の間にしか存在しないハイライトを 表現できません。Phong は「法線」を補間し、色の計算をフラグメントまで遅らせます。

デモの「Gouraud vs Phong」で、14 × 7 分割の粗い球を左右に並べています。マテリアルも光も式もまったく同じで、違うのは計算する場所だけです。 左の Gouraud 側では、ハイライトが多角形のように角張り、球が回るとちらついたり、消えたり 現れたりします。三角形の稜線が色の折れ目として見えるのも Gouraud の特徴です。右の Phong 側は、粗い分割にもかかわらずハイライトが丸く滑らかです。

コードの違いも見ておきましょう。gouraud.vertlighting.fragと同じ式を1 行も変えずに頂点シェーダーへ移し、出力を法線ではなく色にしたものです。

src/lessons/17-lighting-basics/gouraud.vert(抜粋)
// 補間されるのは法線ではなく、計算済みの色
out vec3 v_color;
src/lessons/17-lighting-basics/gouraud.frag(抜粋)
void main() {
  fragColor = vec4(v_color, 1.0);
}

フラグメントシェーダーには、もう計算するものが残っていません。「シェーディングモデルを どこで評価するか」が、これほど絵を変えるという実例です。

なお、第14章で「平面はどれだけ分割しても形は変わらないのに、なぜ分割数を受け取るのか」という 話をしました。答えは「分割数は形の細かさではなく計算の細かさを買うパラメータだから」。Gouraud シェーディングは、その意味がいちばんはっきり出る例です。頂点を増やせば Gouraud の品質も上がります(「壊してみる」で確かめられます)。

コード全文

Phong シェーディング用の 2 ファイル、Gouraud 比較用の 2 ファイル、そして CPU 側の main.ts です。

src/lessons/17-lighting-basics/lighting.vert
#version 300 es

// 第3部の共通の属性配置(第14章から): 0 = 位置, 1 = 法線, 2 = UV
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
// UV はこの章では使わない(インターリーブの配置を崩さないために宣言だけしておく)
layout(location = 2) in vec2 a_uv;

uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;
// 法線行列: モデル行列の左上 3×3 の逆転置(2 節)。CPU 側の mat3.normalFromMat4 が作る
uniform mat3 u_normalMatrix;

// ライティングはワールド空間で行う(1 節)ので、法線も位置もワールド空間のまま渡す
out vec3 v_normal;
out vec3 v_worldPosition;

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

  v_worldPosition = worldPosition.xyz;
  // 法線には平行移動を効かせたくないので 3×3。非一様スケールでも面と垂直を保つのが法線行列
  v_normal = u_normalMatrix * a_normal;

  gl_Position = u_projection * u_view * worldPosition;
}
src/lessons/17-lighting-basics/lighting.frag
#version 300 es
precision highp float;

// 頂点シェーダーが線形補間して届けたワールド空間の法線と位置
in vec3 v_normal;
in vec3 v_worldPosition;

// 0 = 拡散反射のみ / 1 = 拡散 + 環境光 / 2 = Blinn-Phong(拡散 + 環境光 + 鏡面)
uniform int u_mode;
// 1.0 でライティングを一切かけず u_baseColor をそのまま出す(光源の向きを示す小球用)
uniform float u_unlit;

// 光源は方向光 1 つだけ(点光源・複数光源・減衰は第18章)。
// u_lightDirection は「面から光源へ向かう」単位ベクトル。光の進む向きではない(4 節)
uniform vec3 u_lightDirection;
uniform vec3 u_lightColor;
uniform vec3 u_ambientColor;

// マテリアル(構造体にまとめるのは第18章)
uniform vec3 u_baseColor;
uniform vec3 u_specularColor;
uniform float u_shininess;

// カメラのワールド座標。視線ベクトル V を作るために要る
uniform vec3 u_cameraPosition;

out vec4 fragColor;

void main() {
  // 補間された法線は単位長ではない(3 節)。使う前に必ず正規化する
  vec3 N = normalize(v_normal);
  vec3 L = u_lightDirection;

  // Lambert の余弦則。面が光に背を向けた側(dot が負)は 0 で止める(5 節)
  float diffuse = max(dot(N, L), 0.0);
  vec3 color = u_baseColor * u_lightColor * diffuse;

  if (u_mode >= 1) {
    // 環境光: あらゆる方向から一様に来ることにした、乱暴だが便利な近似(6 節)
    color += u_baseColor * u_ambientColor;
  }

  if (u_mode >= 2) {
    // Blinn-Phong の鏡面反射(7 節)。H は L と V のちょうど中間を向く単位ベクトル
    vec3 V = normalize(u_cameraPosition - v_worldPosition);
    vec3 H = normalize(L + V);
    float specular = pow(max(dot(N, H), 0.0), u_shininess);
    // 光が当たっていない裏側にハイライトを乗せない(diffuse が 0 の面は鏡面も 0)
    color += u_specularColor * u_lightColor * specular * step(0.0001, diffuse);
  }

  // 光源マーカーの小球だけはライティングを通さず、そのままの色で描く
  fragColor = vec4(mix(color, u_baseColor, u_unlit), 1.0);
}
src/lessons/17-lighting-basics/gouraud.vert
#version 300 es

// Gouraud シェーディング用の頂点シェーダー(8 節)。
// lighting.frag と同じ Blinn-Phong の式を、フラグメントではなく頂点で 1 回だけ計算し、
// 結果の「色」を補間して渡す。式は 1 行も変えていない — 変えたのは計算する場所だけ。

layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in vec2 a_uv;

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

uniform int u_mode;

uniform vec3 u_lightDirection;
uniform vec3 u_lightColor;
uniform vec3 u_ambientColor;

uniform vec3 u_baseColor;
uniform vec3 u_specularColor;
uniform float u_shininess;

uniform vec3 u_cameraPosition;

// 補間されるのは法線ではなく、計算済みの色
out vec3 v_color;

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

  // 頂点の法線は補間を経ていないので本来 normalize は不要だが、
  // 法線行列が非一様スケールを含むと長さが変わるのでここでも揃えておく
  vec3 N = normalize(u_normalMatrix * a_normal);
  vec3 L = u_lightDirection;

  float diffuse = max(dot(N, L), 0.0);
  vec3 color = u_baseColor * u_lightColor * diffuse;

  if (u_mode >= 1) {
    color += u_baseColor * u_ambientColor;
  }

  if (u_mode >= 2) {
    vec3 V = normalize(u_cameraPosition - worldPosition.xyz);
    vec3 H = normalize(L + V);
    float specular = pow(max(dot(N, H), 0.0), u_shininess);
    color += u_specularColor * u_lightColor * specular * step(0.0001, diffuse);
  }

  v_color = color;

  gl_Position = u_projection * u_view * worldPosition;
}
src/lessons/17-lighting-basics/gouraud.frag
#version 300 es
precision highp float;

// 頂点で計算された色を、三角形の内部へ線形補間したもの。
// フラグメントシェーダーには、もう計算するものが残っていない
in vec3 v_color;

out vec4 fragColor;

void main() {
  fragColor = vec4(v_color, 1.0);
}
src/lessons/17-lighting-basics/main.ts
// 第17章: ライティング基礎 — 法線・拡散反射・鏡面反射
// 第14章で生成した法線が、ようやく主役になる。この章の光源は「方向光 1 つ」だけに絞り、
// 点光源・複数光源・距離減衰は第18章、影(光が物体に遮られること)は第21章に送る。
//
// 見どころ:
// - u_normalMatrix(mat3.normalFromMat4)= モデル行列の左上 3×3 の逆転置。
//   第14・15章が mat3(u_model) で済ませていた手抜きの一般解。左の楕円体は
//   非一様スケール(1.7, 0.62, 1.0)なので、ここを間違えると陰影が目に見えて崩れる
// - ライティングはワールド空間で行う。u_cameraPosition もワールド座標の eye をそのまま渡す
// - Gouraud(頂点で計算)と Phong(フラグメントで計算)の比較は、同じ式を持つ
//   プログラムを 2 つ用意し、描くときに useProgram で切り替える

import { mat3, mat4, type ReadonlyVec3, vec3 } from 'gl-matrix';
import { createSphere, createTorus, type Geometry, interleave } from '../../lib/geometry';
import { compileShader, linkProgram } from '../../lib/shader';
import gouraudFragmentSource from './gouraud.frag?raw';
import gouraudVertexSource from './gouraud.vert?raw';
import fragmentSource from './lighting.frag?raw';
import vertexSource from './lighting.vert?raw';

// ---------------------------------------------------------------------------
// メッシュ: ジオメトリを VAO に配線する(第13・14章の手順そのまま)
// ---------------------------------------------------------------------------

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

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

  // 1 頂点 = [位置 3, 法線 3, UV 2] の 8 float。stride / offset はバイト単位なので
  // 数値を直書きせず BYTES_PER_ELEMENT から組み立てる(第13章)
  const FLOAT_BYTES = Float32Array.BYTES_PER_ELEMENT;
  const stride = 8 * FLOAT_BYTES;
  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(この章では使わないが配置は共通のまま)
  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 };
}

// ---------------------------------------------------------------------------
// シェーディング用プログラム: uniform ロケーションをまとめて持っておく
// ---------------------------------------------------------------------------

interface ShadingProgram {
  program: WebGLProgram;
  model: WebGLUniformLocation | null;
  view: WebGLUniformLocation | null;
  projection: WebGLUniformLocation | null;
  normalMatrix: WebGLUniformLocation | null;
  cameraPosition: WebGLUniformLocation | null;
  lightDirection: WebGLUniformLocation | null;
  lightColor: WebGLUniformLocation | null;
  ambientColor: WebGLUniformLocation | null;
  baseColor: WebGLUniformLocation | null;
  specularColor: WebGLUniformLocation | null;
  shininess: WebGLUniformLocation | null;
  mode: WebGLUniformLocation | null;
  unlit: WebGLUniformLocation | null;
}

function createShadingProgram(
  gl: WebGL2RenderingContext,
  vertex: string,
  fragment: string,
): ShadingProgram {
  const program = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, vertex),
    compileShader(gl, gl.FRAGMENT_SHADER, fragment),
  );
  // ロケーションは初期化時に 1 回だけ取る(第4章)。
  // Gouraud 版は u_unlit を持たないので、そこだけ null が返る。
  // null のロケーションに uniform を設定する呼び出しは黙って無視される
  // (WebGL 1.0 仕様 5.14.10「If the passed location is null, the data passed in
  //  will be silently ignored」)ので、描画側で場合分けしなくてよい
  const at = (name: string): WebGLUniformLocation | null => gl.getUniformLocation(program, name);
  return {
    program,
    model: at('u_model'),
    view: at('u_view'),
    projection: at('u_projection'),
    normalMatrix: at('u_normalMatrix'),
    cameraPosition: at('u_cameraPosition'),
    lightDirection: at('u_lightDirection'),
    lightColor: at('u_lightColor'),
    ambientColor: at('u_ambientColor'),
    baseColor: at('u_baseColor'),
    specularColor: at('u_specularColor'),
    shininess: at('u_shininess'),
    mode: at('u_mode'),
    unlit: at('u_unlit'),
  };
}

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

// カメラ定数(第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); // 軌道カメラの注視点

// 光とマテリアル。色は 0〜1 の RGB をそのまま足し算する素朴な扱い(リニア空間の話は第27章)
const LIGHT_COLOR: ReadonlyVec3 = vec3.fromValues(1.0, 0.95, 0.88);
const AMBIENT_COLOR: ReadonlyVec3 = vec3.fromValues(0.09, 0.11, 0.15);
const SPECULAR_COLOR: ReadonlyVec3 = vec3.fromValues(1.0, 1.0, 1.0);
const SHININESS = 64;
const ELLIPSOID_COLOR: ReadonlyVec3 = vec3.fromValues(0.62, 0.34, 0.28);
const TORUS_COLOR: ReadonlyVec3 = vec3.fromValues(0.28, 0.45, 0.64);
const FACETED_COLOR: ReadonlyVec3 = vec3.fromValues(0.52, 0.5, 0.46);
const MARKER_COLOR: ReadonlyVec3 = vec3.fromValues(1.0, 0.94, 0.72);

const LIGHT_ELEVATION = 0.62; // 光源の仰角(ラジアン)。水平面から上を正にとる
const LIGHT_MARKER_DISTANCE = 4.6; // マーカーの小球を置く距離。方向光なので明るさには影響しない

// モードごとの説明(読み出し行に出す)
const MODES = [
  { label: '拡散のみ', note: '拡散反射 max(dot(N, L), 0) だけ。光の当たらない側は真っ黒' },
  { label: '拡散 + 環境光', note: '環境光を一律に足した。黒く潰れていた側にも形が見える' },
  {
    label: 'Blinn-Phong',
    note: 'ハーフベクトル H = normalize(L + V) による鏡面反射を追加(shininess 64)',
  },
  {
    label: 'Gouraud vs Phong',
    note: '同じ Blinn-Phong の式。左 = 頂点で計算(Gouraud)/ 右 = フラグメントで計算(Phong)。球は 14 × 7 分割',
  },
] as const;

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

function setup(
  gl: WebGL2RenderingContext,
  canvas: HTMLCanvasElement,
  controls: HTMLParagraphElement,
  readout: HTMLParagraphElement,
): void {
  // --- プログラムとメッシュ ---------------------------------------------------

  // 式は同じで、計算する場所だけが違う 2 つのプログラム(8 節)
  const phongProgram = createShadingProgram(gl, vertexSource, fragmentSource);
  const gouraudProgram = createShadingProgram(gl, gouraudVertexSource, gouraudFragmentSource);

  const ellipsoidMesh = createMesh(gl, createSphere(0.85, 64, 32));
  const torusMesh = createMesh(gl, createTorus(0.62, 0.26, 64, 32));
  // Gouraud の弱点は分割が粗いほど露骨に出る。あえて 14 × 7 の球を用意する
  const facetedMesh = createMesh(gl, createSphere(0.95, 14, 7));
  const markerMesh = createMesh(gl, createSphere(0.09, 12, 8));

  gl.enable(gl.DEPTH_TEST); // 深度テスト(第13章)
  gl.enable(gl.CULL_FACE); // 裏面カリング
  gl.clearColor(0.06, 0.07, 0.09, 1.0);

  // --- 軌道カメラ(第15章の簡略版。減衰と追跡モードは外してある) --------------

  const orbit = { theta: 0.62, phi: 0.34, radius: 6.0 };
  const PHI_LIMIT = Math.PI / 2 - 0.05;
  const MIN_RADIUS = 3.2;
  const MAX_RADIUS = 12;

  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 modeIndex = 2; // 最初は Blinn-Phong

  function updateReadout(): void {
    readout.textContent = MODES[modeIndex].note;
  }

  for (const [index, entry] of MODES.entries()) {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = entry.label;
    button.setAttribute('aria-pressed', index === modeIndex ? 'true' : 'false');
    button.addEventListener('click', () => {
      modeIndex = index;
      updateReadout();
      for (const b of controls.querySelectorAll('button')) {
        b.setAttribute('aria-pressed', 'false');
      }
      button.setAttribute('aria-pressed', 'true');
    });
    controls.append(button);
  }
  updateReadout();

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

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

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

  function resizeIfNeeded(): void {
    const dpr = Math.min(window.devicePixelRatio, 2);
    // 表示サイズが 0 でも 0 除算・NaN 行列にならないよう 1px を下限にする(第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);
    }
  }

  // --- uniform の設定 ---------------------------------------------------------

  // vec3 の uniform。gl-matrix の vec3 型をそのまま uniform3fv に渡すと
  // 型が合わないので、成分を展開して uniform3f で送る
  function setVec3(location: WebGLUniformLocation | null, v: ReadonlyVec3): void {
    gl.uniform3f(location, v[0], v[1], v[2]);
  }

  // フレーム内で変わらない uniform(カメラ・光・鏡面の設定)をプログラムに流し込む。
  // uniform はカレントプログラムに対してしか設定できないので、まず useProgram する
  function beginProgram(p: ShadingProgram): void {
    gl.useProgram(p.program);
    gl.uniformMatrix4fv(p.view, false, view); // transpose は常に false
    gl.uniformMatrix4fv(p.projection, false, projection);
    setVec3(p.cameraPosition, eye);
    setVec3(p.lightDirection, lightDirection);
    setVec3(p.lightColor, LIGHT_COLOR);
    setVec3(p.ambientColor, AMBIENT_COLOR);
    setVec3(p.specularColor, SPECULAR_COLOR);
    gl.uniform1f(p.shininess, SHININESS);
  }

  function drawMesh(
    p: ShadingProgram,
    mesh: Mesh,
    baseColor: ReadonlyVec3,
    mode: number,
    unlit: number,
  ): void {
    // 別のプログラムのロケーションに書き込むと INVALID_OPERATION になるので、
    // 描くたびに「これから使うプログラム」を現在のプログラムにしておく
    gl.useProgram(p.program);

    // 法線行列 = モデル行列の左上 3×3 の逆転置(2 節)。
    // モデル行列が変わるたびに作り直す。ここを mat3.fromMat4 にすると、
    // 非一様スケールの楕円体だけ陰影が崩れる(「壊してみる」で試せる)
    mat3.normalFromMat4(normalMatrix, model);

    gl.uniformMatrix4fv(p.model, false, model);
    gl.uniformMatrix3fv(p.normalMatrix, false, normalMatrix);
    setVec3(p.baseColor, baseColor);
    gl.uniform1i(p.mode, mode);
    gl.uniform1f(p.unlit, unlit);

    gl.bindVertexArray(mesh.vao);
    gl.drawElements(gl.TRIANGLES, mesh.indexCount, gl.UNSIGNED_SHORT, 0);
  }

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

  function frame(timestamp: DOMHighResTimeStamp): void {
    resizeIfNeeded();
    const time = timestamp / 1000;

    // カメラ: 球面座標 → eye(第15章)
    eye[0] = orbit.radius * Math.cos(orbit.phi) * Math.sin(orbit.theta);
    eye[1] = orbit.radius * Math.sin(orbit.phi);
    eye[2] = orbit.radius * Math.cos(orbit.phi) * Math.cos(orbit.theta);
    mat4.lookAt(view, eye, TARGET, UP);
    const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
    mat4.perspective(projection, FOVY, aspect, NEAR, FAR);

    // 光の向き: 「面から光源へ向かう」単位ベクトル(4 節)。仰角 LIGHT_ELEVATION で
    // 水平にゆっくり 1 周させる。cos/sin の組み立てなので、もともと長さは 1
    const lightAngle = time * 0.35;
    vec3.set(
      lightDirection,
      Math.cos(LIGHT_ELEVATION) * Math.sin(lightAngle),
      Math.sin(LIGHT_ELEVATION),
      Math.cos(LIGHT_ELEVATION) * Math.cos(lightAngle),
    );

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

    const mode = modeIndex;
    beginProgram(phongProgram);

    if (mode < 3) {
      // 左: 非一様スケール(1.7, 0.62, 1.0)をかけた楕円体。法線行列の見せ場
      mat4.fromTranslation(model, [-1.45, 0, 0]);
      mat4.rotateY(model, model, time * 0.3);
      mat4.scale(model, model, [1.7, 0.62, 1.0]);
      drawMesh(phongProgram, ellipsoidMesh, ELLIPSOID_COLOR, mode, 0);

      // 右: 回転だけのトーラス。こちらは mat3(u_model) でも同じ絵になる
      mat4.fromTranslation(model, [1.5, 0, 0]);
      mat4.rotateX(model, model, time * 0.35);
      mat4.rotateY(model, model, time * 0.5);
      drawMesh(phongProgram, torusMesh, TORUS_COLOR, mode, 0);
    } else {
      // 右: Phong シェーディング(フラグメントで計算)
      mat4.fromTranslation(model, [1.35, 0, 0]);
      mat4.rotateY(model, model, time * 0.25);
      drawMesh(phongProgram, facetedMesh, FACETED_COLOR, 2, 0);

      // 左: Gouraud シェーディング(頂点で計算)。プログラムだけ差し替える
      beginProgram(gouraudProgram);
      mat4.fromTranslation(model, [-1.35, 0, 0]);
      mat4.rotateY(model, model, time * 0.25);
      drawMesh(gouraudProgram, facetedMesh, FACETED_COLOR, 2, 0);
      beginProgram(phongProgram);
    }

    // 光源マーカー: 方向光には位置がないので、「その向きの遠く」に自己発光の小球を置く。
    // unlit = 1 なのでライティングは通らない
    mat4.fromTranslation(model, [
      lightDirection[0] * LIGHT_MARKER_DISTANCE,
      lightDirection[1] * LIGHT_MARKER_DISTANCE,
      lightDirection[2] * LIGHT_MARKER_DISTANCE,
    ]);
    drawMesh(phongProgram, markerMesh, MARKER_COLOR, mode, 1);

    requestAnimationFrame(frame);
  }

  // このページのデモはページと寿命を共にするので、rAF ループの停止も、canvas に付けた
  // 5 つのリスナーの解除もしていない。リソース解放の一般論は第35章
  requestAnimationFrame(frame);
}

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

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

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

setup(gl, canvas, controls, readout);

three.js との対応

この章で書いたのは、three.js が「マテリアルとライトを置くだけ」で済ませている部分です。

three.jsこの章
new THREE.DirectionalLight(color, intensity)u_lightDirection+u_lightColor。three.js は position → target が光の進む向きなので、JS から見ると符号が逆(4 節)
new THREE.AmbientLight(color, intensity)u_ambientColor(色 × 強度をあらかじめ掛けた 1 つの vec3)
MeshLambertMaterialこの章のモード「拡散 + 環境光」。three.js も現在は拡散反射をフラグメントで計算していて、鏡面反射の項は持ちません
MeshPhongMaterial(shininess既定 30、specular既定0x111111)この章のモード「Blinn-Phong」。u_shininess/ u_specularColorが対応する。名前は Phong でも中身は Blinn-Phong(7 節)
object.normalMatrix(getNormalMatrix() = 逆行列の転置)u_normalMatrix(mat3.normalFromMat4)。ただし three.js はモデルビュー行列から作る = ビュー空間でライティングする。この章はワールド空間なのでモデル行列から作る(1 節)
material.flatShading = trueこの章にはない。three.js はdFdx/dFdyで面の傾きから法線を作り直している(8 節の補足)
Gouraud シェーディング相当のマテリアル現在の three.js のビルトインマテリアルには存在しない(すべてフラグメントで計算する)。この章のgouraud.vertは比較のために自作した
拡散反射の BRDF_Lambert(1/π を掛ける)この章は1/πを掛けていない素朴な式。エネルギー保存を意識した係数の話は第22章
light.castShadow / mesh.receiveShadowこの章では扱わない(物体は互いに影を落とさない)。第21章のシャドウマッピング

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

コードはsrc/lessons/17-lighting-basics/にあります。ライティングは「1 か所の符号や 1 つの関数を変えるだけで絵が激変する」ので、 壊して覚えるのに向いた題材です。

まとめ

光源が 1 つで、しかも向きしか持たない方向光では、できることは限られています。次章第18章「複数光源とマテリアル — 点光源・スポットライト・減衰」では、位置を持つ光源を導入します。点光源とスポットライト、そして 距離による減衰。光源が複数になると、それらをstructuniform 配列でまとめて渡す必要が出てきます — 第4章の uniform の表で「複数光源のパラメータ(第18章)」と予告していた話です。マテリアルのほうもstruct Materialに整理し、第16章のテクスチャを diffuse map として組み合わせます。