ライティング基礎 — 法線・拡散反射・鏡面反射
第14章で球やトーラスを作ったとき、頂点には位置と UV のほかに法線を持たせました。 第15章でカメラを動かせるようにしたあとも、画面に出ていたのは「法線をそのまま色にした」デバッグ表示の ままです。この章で、その法線がようやく本来の仕事をします —光の当たり方を計算して、面に陰影を付けることです。
three.js でいえばMeshLambertMaterialやMeshPhongMaterialにDirectionalLightを 1 つ足したときに起きていることを、自分の手で書く章です。式そのものは短く、フラグメント シェーダーで 5 行ほど。ただし、その 5 行にたどり着くまでに片付けておくべきことが 2 つあります。法線を正しくワールド空間へ運ぶことと、補間で崩れた法線を直すことです。どちらも、第14・15章が「第17章で」と先送りにしてきた宿題でした。
この章で学ぶこと:
- ライティングをどの空間で計算するか。このサイトはワールド空間に統一する
- 法線行列 (normal matrix)—
mat3(u_model)が非一様スケールで壊れる理由と、逆転置 M⁻ᵀ が出てくる導出(第14・15章の宿題の回収) - 補間された法線が単位長でなくなる理由と、フラグメントでの再正規化(第14章の宿題)
- Lambert の拡散反射—
max(dot(N, L), 0.0)。なぜ cos に比例するのか(第11章の「dot = 向きの一致度メーター」の回収) - 環境光 (ambient) を足す理由と、それが物理的には乱暴な近似であること
- Blinn-Phong の鏡面反射— ハーフベクトル
H = normalize(L + V)。Phong(反射ベクトル R と V の dot)との違いと、Blinn-Phong が使われる理由 - Gouraud シェーディング(頂点で計算)とPhong シェーディング(フラグメントで計算)の違い。粗い球でハイライトが壊れる様子
1. ライティングとは何を計算することか
リアルタイム CG のライティングは、乱暴に言えば「この点から、カメラの方へどれだけの光が返ってくるか」を面の 1 点ごとに見積もる計算です。光源から出た光が面に当たり、面の性質に応じて散らばり、その一部がカメラへ届く。 その量を色として書き出せば、立体に見えます。
この章で組み立てるモデルは、3 つの項の足し算です。
- 拡散反射 (diffuse reflection)— ざらざらした面が光をあらゆる方向へ散らす成分。見る角度によらず明るさが同じ(5 節)
- 鏡面反射 (specular reflection)— つるつるした面が光をほぼ鏡のように返す成分。見る角度で位置が変わるハイライト(7 節)
- 環境光 (ambient light)— 「他の物に反射して回り込んできた光」の代役。方向を持たない一律の底上げ(6 節)
先に、この章がやらないことをはっきりさせておきます。ここで計算するのは 「面と光の向きの関係」だけで、光が途中で何かに遮られるかどうかは一切見ていません。 つまり、物体は互いに影を落としません。デモの楕円体とトーラスがどれだけ近づいても、片方が もう片方の上に影を作ることはありません。遮蔽を扱うには「光源から見て手前に何かあるか」を 別に調べる必要があり、それがシャドウマッピング(第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章の頂点シェーダーには、こう書いてありました。
v_normal = mat3(u_model) * a_normal;そして「これが通用するのは回転だけのとき。正しい一般解は第17章で」と書き添えてありました。 第15章のscene.vertも同じ手抜きをしています。宿題を回収しましょう。
まず、なぜ位置と同じ行列では駄目なのか。法線は「位置」ではなく「向き」であり、 しかもただの向きでもありません。面と垂直であるという条件が本質です。 位置を動かす変換をかけたあとも、法線は動いたあとの面と垂直でなければ 意味がありません。ところが、x 方向だけを引き伸ばすような非一様スケール (non-uniform scale)では、面の傾きの変わり方と、法線をそのまま同じ行列で変換したときの 変わり方が食い違います。
図の状況を数で確かめます。元の面の法線を 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 つだけです。変換後も、法線が面のすべての接ベクトルと垂直であること。
- 変換前: N · T = 0(N と、面に沿うあらゆる T について)
- 変換後の接ベクトル: T' = A T(A はモデル行列の左上 3×3)
- 変換後の法線を N' = B N と置き、B を決めたい
ここで 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 を作ってくれます。
// 法線行列 = モデル行列の左上 3×3 の逆転置(2 節)。
// モデル行列が変わるたびに作り直す。ここを mat3.fromMat4 にすると、
// 非一様スケールの楕円体だけ陰影が崩れる(「壊してみる」で試せる)
mat3.normalFromMat4(normalMatrix, model);実際に mat3.normalFromMat4 に diag(2, 1, 1) を渡すと diag(0.5, 1, 1) が返ります。法線の x 成分を縮める行列です。面が x 方向へ引き伸ばされて寝るのだから、 法線は逆に y 寄りへ倒れる — 図と一致します。
頂点シェーダー側は、この行列を受け取って法線を変換し、位置と一緒にワールド空間で出力するだけです。
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 です。
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章です。
// 光の向き: 「面から光源へ向かう」単位ベクトル(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θ です。
あとは cosθ をどう計算するかですが、答えは第11章で出ています。N も L も単位ベクトルなら、dot(N, L) がそのまま cosθです。第11章で「dot は向きの一致度メーター」と呼び、「第17章の Lambert 拡散はこの 1 行の dot から始まります」と予告した、その 1 行がこれです。
// 補間された法線は単位長ではない(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)のモデルは、身も蓋もないほど単純です。「どこからでも同じだけ光が来ていることにして、一定値を足す」。 向きも距離も関係ありません。
if (u_mode >= 1) {
// 環境光: あらゆる方向から一様に来ることにした、乱暴だが便利な近似(6 節)
color += u_baseColor * u_ambientColor;
}デモで「拡散のみ」と「拡散 + 環境光」を切り替えると、黒く潰れていた側にうっすら形が戻るのが 分かります。たったこれだけで絵は見られるようになる。しかし、これが物理的にどれだけ乱暴かも 正直に見ておきましょう。
- 本来の跳ね返りは周囲に何があるかで決まります。赤い床の上の白い球の下側は 赤っぽくなるはずですが、この近似では一律の色です
- くぼんだ場所や物どうしの隙間は、跳ね返りの光も入りにくいので暗くなるはずです。この近似では 平等に明るくなります(この「隙間は暗い」を安く再現する手法がアンビエントオクルージョンで、 レイマーチングでの実装は第28章で扱います)
- 値を上げすぎるとコントラストが失われ、立体感のない「のっぺりした」絵になります
より真面目な代替として、上からの光と下からの光を別の色にする半球ライト(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)
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 のほうが広く使われているのでしょうか。
- H を定数にできる場合に軽い— 「方向光 + 視線もシーン全体で平行と見なす」割り切りをすると、H はシーンで 1 つの定数になります。フラグメント(あるいは頂点)ごとに残る仕事は
dot(N, H)とpowだけで、当時の計算コストではこれが大きな意味を持ちました(OpenGL 固定機能のGL_LIGHT_MODEL_LOCAL_VIEWERが既定で false = 視線を −z 方向に固定してしまうのも、同じ割り切りです)。逆に、この章のように視線をフラグメントごとに正直に計算する場合は、Phong のほうが軽いこともあります。GLSL ES 3.00 のreflect(I, N)はI − 2 · dot(N, I) · Nと定義されていて(§8.5)、N が単位なら結果も単位になるので正規化が要りません。 対するnormalize(L + V)には平方根の逆数が 1 つ余分に入ります - かすめる角度でも打ち切られない— 面から見て光もカメラも上側にある(dot(N, L) > 0 かつ dot(N, V) > 0)かぎり、
dot(N, H)は必ず正になります(H は L + V の向きなので、N との内積は 2 つの内積の和に比例するからです)。一方dot(R, V)は、光もカメラも面すれすれにあるときに負になり、Phong の鏡面項はそこでぴたりと 0 に切り落とされます - 実装のデファクト— three.js の
MeshPhongMaterialも、名前に反して中身は Blinn-Phong です(BRDF_BlinnPhongがhalfDir = normalize( lightDir + viewDir )を計算しています)。「Phong マテリアル」と呼ばれているものが実は Blinn-Phong、というのは よくある話です
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.jsのirradiance = dotNL * directLight.colorを拡散・鏡面の両方に適用)、そちらは境目でも滑らかに 0 へ消えます。
8. Gouraud か Phong か — どこで計算するか
ここまでの式は、すべてフラグメントシェーダーに書きました。しかし、同じ式は 頂点シェーダーにも書けます。頂点で色を計算してしまい、あとはラスタライザにその色を補間させる — これがGouraud シェーディング (Gouraud shading)です。対して、法線を補間して フラグメントごとに式を評価するのがPhong シェーディング (Phong shading)です。
違いは計算量と品質のトレードオフです。頂点数がフラグメント数より圧倒的に少ないなら、Gouraud のほうが速い。しかし品質は落ちます。頂点で拾えなかった明るさの変化は、二度と戻ってきません。とくに鏡面反射のハイライトは狭い範囲で急激に変化するので、頂点の間にすっぽり収まってしまうと、 そのハイライトは消えます。
デモの「Gouraud vs Phong」で、14 × 7 分割の粗い球を左右に並べています。マテリアルも光も式もまったく同じで、違うのは計算する場所だけです。 左の Gouraud 側では、ハイライトが多角形のように角張り、球が回るとちらついたり、消えたり 現れたりします。三角形の稜線が色の折れ目として見えるのも Gouraud の特徴です。右の Phong 側は、粗い分割にもかかわらずハイライトが丸く滑らかです。
コードの違いも見ておきましょう。gouraud.vertはlighting.fragと同じ式を1 行も変えずに頂点シェーダーへ移し、出力を法線ではなく色にしたものです。
// 補間されるのは法線ではなく、計算済みの色
out vec3 v_color;void main() {
fragColor = vec4(v_color, 1.0);
}フラグメントシェーダーには、もう計算するものが残っていません。「シェーディングモデルを どこで評価するか」が、これほど絵を変えるという実例です。
なお、第14章で「平面はどれだけ分割しても形は変わらないのに、なぜ分割数を受け取るのか」という 話をしました。答えは「分割数は形の細かさではなく計算の細かさを買うパラメータだから」。Gouraud シェーディングは、その意味がいちばんはっきり出る例です。頂点を増やせば Gouraud の品質も上がります(「壊してみる」で確かめられます)。
コード全文
Phong シェーディング用の 2 ファイル、Gouraud 比較用の 2 ファイル、そして CPU 側の main.ts です。
#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;
}#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);
}#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;
}#version 300 es
precision highp float;
// 頂点で計算された色を、三角形の内部へ線形補間したもの。
// フラグメントシェーダーには、もう計算するものが残っていない
in vec3 v_color;
out vec4 fragColor;
void main() {
fragColor = vec4(v_color, 1.0);
}// 第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 つの関数を変えるだけで絵が激変する」ので、 壊して覚えるのに向いた題材です。
main.tsのmat3.normalFromMat4(normalMatrix, model)をmat3.fromMat4(normalMatrix, model)にする — この章の主題そのものの実験です。左の楕円体だけ陰影が崩れ、明るい面と 暗い面の境目がおかしな位置に移ります。x 方向に引き伸ばした部分ほど法線が寝すぎるためです。回転だけのトーラスは何も変わりませんlighting.fragのvec3 N = normalize(v_normal);からnormalizeを外す —vec3 N = v_normal;。粗い球ほど、三角形の中ほどが暗い帯になって見えます。楕円体では 法線行列が長さを変えるので、全体が別の明るさになりますlighting.fragのmax(dot(N, L), 0.0)をdot(N, L)にする — 裏側で拡散項が負になります。「拡散のみ」では負の色が 0 に丸められて見た目は ほぼ変わりませんが、「拡散 + 環境光」にすると、負の拡散が環境光を食って裏側が真っ黒に なるのが分かりますmain.tsのSHININESSを4や512にする — 4 では鈍く広いテカり、512 では針の先ほどの点になります。4 のときは明暗の境目に細い段差が出ることもあります —step(0.0001, diffuse)が鏡面項を二値で切っているためです(7 節)。あわせて「Gouraud vs Phong」を見ると、鋭い ハイライトほど Gouraud が破綻することが分かりますlighting.fragのvec3 H = normalize(L + V);とdot(N, H)を Phong 版vec3 R = reflect(-L, N);/dot(R, V)に差し替える — 同じ shininess ではハイライトが小さくなります。u_shininessを 4 分の 1(16)にすると、Blinn-Phong とほぼ同じ広がりに戻ります(7 節)main.tsのcreateSphere(0.95, 14, 7)の分割数を64, 32にする — Gouraud と Phong の差がほとんど見えなくなります。「頂点を増やせば Gouraud でも足りる」の確認です。逆に8, 4まで落とすと、Phong 側でも球の輪郭がカクカクになります(輪郭は法線ではなく頂点の位置で決まるからです)main.tsのAMBIENT_COLORをvec3.fromValues(0.5, 0.5, 0.5)にする — 環境光を上げすぎたときの「のっぺり」を体感できます。立体感は陰影のコントラストが 作っている、という確認ですlighting.fragのstep(0.0001, diffuse)を消す — 光が当たっていないはずの裏側に、うっすらとハイライトの輪が出ます
まとめ
- ライティングは「面 1 点からカメラへどれだけ光が返るか」の見積もり。この章では拡散 + 環境光 + 鏡面の 3 項を足した。計算する空間はワールド空間に統一する(three.js はビュー空間)
- 法線行列 = モデル行列の左上 3×3 の逆転置。導出は「変換後も法線が接ベクトルと 垂直」という条件 1 つから: (BN)ᵀ(AT) = Nᵀ(BᵀA)T なので BᵀA = I、つまり B = A⁻ᵀ。回転 + 一様スケールだけなら向きが変わらないので
mat3(u_model)で足りる(第14・15章の手抜きが動いていた理由) - 補間されるのはベクトルの成分なので、単位法線を補間しても長さは 1 に戻らない。フラグメントで使う直前に
normalizeする u_lightDirectionは面から光源へ向かう単位ベクトル。V も「面からカメラへ」。3 本とも面から外向きに揃えると符号で悩まなくなる- 拡散反射は
max(dot(N, L), 0.0)。cos に比例するのは、斜めに当たった光束が広い面積に 薄まるから(第11章の dot の回収) - 環境光は「回り込んだ光」の一律な代役。安上がりだが、周囲の色も遮蔽も反映しない乱暴な近似
- 鏡面反射は Blinn-Phong の
pow(max(dot(N, H), 0.0), shininess)、H = normalize(L + V)。Phong の R·V よりかすめる角度で破綻しにくく、H を定数にできる場面では軽い(フラグメントごとに正直に 計算するならreflect側が軽いこともある)。同じ見た目にするには指数を約 4 倍 - Gouraud(頂点で計算)は速いが、頂点間の変化を落とす。ハイライトのような急な変化には Phong(フラグメントで計算)が要る
光源が 1 つで、しかも向きしか持たない方向光では、できることは限られています。次章第18章「複数光源とマテリアル — 点光源・スポットライト・減衰」では、位置を持つ光源を導入します。点光源とスポットライト、そして 距離による減衰。光源が複数になると、それらをstructとuniform 配列でまとめて渡す必要が出てきます — 第4章の uniform の表で「複数光源のパラメータ(第18章)」と予告していた話です。マテリアルのほうもstruct Materialに整理し、第16章のテクスチャを diffuse map として組み合わせます。