複数光源とマテリアル — 点光源・スポットライト・減衰
第17章のライティングは、方向光 1 つだけでした。太陽のように無限に遠い光源で、シーンのどこにいても 光の向きは同じ。式には距離がまったく出てきませんでした。しかし部屋の中を照らしているのは電球や スポットライトで、それらは位置を持ちます。近づけば明るく、離れれば暗い。 向きを絞れば円錐状の光になる。そして光源はたいてい 1 つではありません。
この章では、光源に位置と減衰と円錐を与え、複数の光源をstructと uniform 配列でまとめて GPU へ渡します。第4章で uniform の型を一覧にしたとき、「同じ型の uniform 配列 → 複数光源のパラメータ(第18章)」と予告した、 その回収です。マテリアル側もstruct Materialに整理して、第16章のテクスチャをdiffuse mapとして組み合わせます。
反射モデルそのものは第17章で完成しています。この章がやるのは、「第17章の Blinn-Phong を、光源ごとに計算して足す」という一段上の組み立てです。
smoothstepでぼけています。ドラッグで回り込み、ホイールで寄れます(第15章の軌道カメラの簡略版)。 物体が互いに影を落とさないのは、この章でも遮蔽を計算していないからです(影は第21章)。この章で学ぶこと:
- 点光源 (point light)— 位置から光の向き L をフラグメントごとに計算する。方向光との違いは「L が定数か、点ごとに変わるか」
- 距離減衰 (attenuation)— 物理的な逆二乗則と、実用でよく使う
1 / (kc + kl·d + kq·d²)。なぜ逆二乗そのままを使わないことがあるのか - スポットライト (spot light)— 円錐の内外を角度の cos で判定し、
smoothstep(第7〜8章)でふちをぼかす - uniform 配列と構造体—
struct Light+uniform Light u_lights[MAX_LIGHTS]。ロケーションはu_lights[0].positionのように要素ごとに取る必要があること - マテリアルの設計—
struct Material(拡散色・鏡面色・shininess)と、第16章のテクスチャを diffuse map として掛けること - 光源を増やしたときのコストと、その先(第23章のディファードレンダリング)への布石
1. 位置を持つ光源 — 点光源
方向光と点光源の違いは、実装上はたった 1 か所です。L(面から光源へ向かう単位ベクトル)を どこから得るか。
- 方向光 — L は uniform でもらった定数。シーンのどの点でも同じ
- 点光源— L =
normalize(光源の位置 − 面の位置)。面の点ごとに違う
if (light.type == LIGHT_DIRECTIONAL) {
// 無限に遠いので、どの点でも同じ向き・減衰なし
L = light.direction;
} else {
vec3 toLight = light.position - v_worldPosition;
float dist = length(toLight);
L = toLight / dist;lengthで距離を測ってから、そのスカラーで割って正規化しています。normalizeを呼んでもよいのですが、距離は減衰の計算でどのみち必要なので、割り算を 1 回で済ませています。
2. 距離減衰 — 逆二乗則と、実用の式
点光源から出た光は、球面状に広がります。半径 d の球の表面積は 4πd² なので、同じ量の光が d² に比例して広い面積に薄まる。これが逆二乗則 (inverse-square law)です。 物理的にはこれが正解で、明るさは1 / d²に比例します。
ところが、リアルタイム CG では長らく次の式が使われてきました(OpenGL の固定機能パイプライン 以来の形です)。
attenuation = 1 / (kc + kl·d + kq·d²)
kc が定数項、kl が 1 次(linear)、kq が 2 次(quadratic)の係数です。kc = 1, kl = 0, kq = 1 にすれば1/(1 + d²)となり、ほぼ逆二乗です。ではなぜ、わざわざ項を増やすのでしょうか。
- d → 0 で発散する— 数学的な「点」光源に近づくと
1/d²は無限大になります。実際の電球には大きさがあるので、そんなことは起きません。定数項 kc = 1 を入れておけば、どれだけ近づいても減衰は 1 で頭打ちになります - 広すぎるダイナミックレンジを扱いきれない— 逆二乗の明るさをそのまま 0〜1 の色に書き出すと、光源の近くは真っ白に飛び、少し離れると 真っ黒に潰れます。まともに見せるには露出調整(トーンマッピング)とリニア空間での計算が 要ります。この章はまだ素朴に 0〜1 の RGB を足しているので、飛びにくい緩やかな減衰のほうが 扱いやすい — 色を正しく扱う話は第27章です
- 調整しやすい— 「この光源はだいたい半径 5 くらいまで届いてほしい」という要求に対し、kl と kq を触るほうが直感的です
// 距離減衰 1 / (kc + kl·d + kq·d²)
attenuation =
1.0 / (light.attenuation.x + light.attenuation.y * dist + light.attenuation.z * dist * dist);3. スポットライト — 円錐と smoothstep
スポットライトは、点光源に「向き」と「広がり」を足したものです。光源から見て円錐の内側にある点だけを 照らします。判定はこうです。
- 面から光源へ向かうベクトル L を、点光源と同じように求める
- L と円錐の軸との角度を測る。真ん中にいるほど角度が小さい
- 角度が外側の限界を超えていたら 0、内側なら 1、その間はなめらかに繋ぐ
角度そのものをacosで求める必要はありません。両方が単位ベクトルならdotがそのまま cos なので(第11章)、cos どうしで比べれば済みます。cos は角度が 大きいほど小さい値になるので、不等号の向きだけ注意します。
ここで軸の向きをどう定義するかが問題になります。このサイトは「L は面から光源へ」で統一すると 決めました(第17章)。比較相手の軸も同じ流儀に揃えておくと、内積が素直に「円錐の中心度」に なります。そこでdirectionは照らす向きの逆、つまり「狙う先から光源へ向かうベクトル」として持ちます。 こうすると円錐の中心でdot(L, direction) = 1です。three.js のSpotLightの uniform もまったく同じ持ち方をしています。
if (light.type == LIGHT_SPOT) {
// 円錐の中心からどれだけ外れているか。中心で 1、外れるほど小さくなる
float angleCos = dot(L, light.direction);
// 外側の角(coneCos)で 0、内側の角(penumbraCos)で 1。第7〜8章の smoothstep
attenuation *= smoothstep(light.coneCos, light.penumbraCos, angleCos);
}smoothstep(edge0, edge1, x)はxがedge0のとき 0、edge1のとき 1 を返し、間をなめらかに補間します(第7〜8章)。ここではedge0 = coneCos(外側・値が小さい)、edge1 = penumbraCos(内側・値が大きい)なので、円錐の外で 0、内で 1 になります。
CPU 側では、位置と「狙う先」から軸を作っています。円錐の向きを対象に追従させたいときは、 この狙う先を差し替えるだけです。
// スポットライト: 位置は固定で、狙う先だけを床の上で動かす。
// direction は「円錐の軸を光源へ向かう向きに揃えたもの」= 狙う先から光源へのベクトル
vec3.set(spotAim, Math.sin(time * 0.45) * 2.4, FLOOR_Y, Math.cos(time * 0.7) * 1.2);
vec3.subtract(lights[2].direction, lights[2].position, spotAim);
vec3.normalize(lights[2].direction, lights[2].direction);4. 光源をまとめて渡す — struct と uniform 配列
光源が 3 種類あり、しかも複数ある。u_light0Position、u_light1Color…と平らな uniform を並べていくと、すぐに破綻します。GLSL には構造体 (struct)と配列があるので、素直にそれを使います。まず、3 種類を 1 つの型でまかなえるよう、 使わないフィールドがあることを承知のうえで合併した構造体を定義します。
// 光源 1 つぶんのパラメータ。3 種類を 1 つの構造体でまかない、type で振り分ける
struct Light {
int type;
// 点光源・スポットライトのワールド座標(方向光では使わない)
vec3 position;
// 方向光: 面から光源へ向かう単位ベクトル(第17章の約束)
// スポットライト: 円錐の軸を「光源へ向かう向き」に揃えたもの
vec3 direction;
vec3 color;
float intensity;
// 距離減衰の係数 (kc, kl, kq) = 定数項・1 次・2 次
vec3 attenuation;
// 円錐の外側の角の cos(これより外は真っ暗)
float coneCos;
// 円錐の内側の角の cos(これより内は減衰なし)。外と内の間が smoothstep のぼかし
float penumbraCos;
};種類ごとに別の配列(方向光の配列・点光源の配列…)を持つ設計もあり、three.js はそちらです。今回は「1 つの配列に何でも入る」ほうが切り替えデモに向くので、typeで分岐する形にしました。どちらの設計でも、使わないフィールドの無駄と分岐の複雑さのどちらを取るか、というトレードオフになります。
uniform Light u_lights[MAX_LIGHTS];
uniform int u_lightCount;
uniform Material u_material;MAX_LIGHTSは#defineで与えた定数です。GLSL ES 3.00 の仕様は「配列のサイズは定数の整数式でなければならない」と定めているので(4.1.9 Arrays)、uniform で配列の長さを変えることはできません。上限を固定で確保し、実際に使う数は別の uniform(u_lightCount)で渡すのが定石です。
そしてこの章のいちばんの落とし穴が、ロケーションの取り方です。
そこで、光源 1 つぶんのロケーションを束にして取る関数を書いておきます。テンプレートリテラルで 名前を組み立てるだけです。
const at = (field: string): WebGLUniformLocation | null =>
gl.getUniformLocation(program, `u_lights[${index}].${field}`);
return {
type: at('type'),
position: at('position'),
direction: at('direction'),
color: at('color'),
intensity: at('intensity'),
attenuation: at('attenuation'),
coneCos: at('coneCos'),
penumbraCos: at('penumbraCos'),
};あとは、有効な光源を配列の前から詰めて送るだけです。「どの光源を使うか」をボタンで切り替えても、 シェーダーはu_lightCountの数だけ回ればよいことになります。
// 有効な光源だけを前から詰めて送る。u_lightCount がループの回数になる。
// 上限を超えるぶんは CPU 側で切り捨てる。GPU に任せると、範囲外の要素を読んだ結果は
// エラーにもクラッシュにもならず「値が未定義」になるだけ(GLSL ES 3.00 仕様 4.1.9)
const active = MODES[modeIndex].lights.slice(0, MAX_LIGHTS);
for (const [slot, lightIndex] of active.entries()) {
uploadLight(lightLocations[slot], lights[lightIndex]);
}
gl.uniform1i(locations.lightCount, active.length);5. 光源ごとに足す — ループの中身は第17章のまま
シェーダー側は、u_lightCount回のループになります。
// GLSL ES 3.00 ではループ回数が定数でなくてよく、配列も非定数インデックスで引ける。
// 光源が増えるほどフラグメントあたりの仕事が増える(コストの話は本文 7 節)
for (int i = 0; i < u_lightCount; i++) {
Light light = u_lights[i];さらりと書きましたが、この 2 行は WebGL1 (GLSL ES 1.00) ではどちらも書けませんでした。
- ループ回数が uniform で決まる— GLSL ES 1.00 の仕様には「Appendix A: Limitations for ES 2.0」があり、「制御フローは前方分岐と、繰り返し回数がコンパイル時に決められるループに限る」と書かれていました。だから当時のコードは
#define NUM_LIGHTS 4で固定回数のループを書き、中でifを使って間引いていました - 配列を非定数インデックスで引ける— 同じ Appendix A(§5)には「頂点シェーダーではあらゆる形の配列添字の サポートが必須。フラグメントシェーダーでは『定数インデックス式』の 添字だけが必須」とあります。制限があったのはフラグメントシェーダー側です。しかも同じ Appendix の定義では、定数インデックス式にはループインデックスが含まれます(§4 の「繰り返し回数がコンパイル時に決まるループ」の変数)。ですから
#define NUM_LIGHTS 4の固定回数ループの中でlights[i]と書くのは、GLSL ES 1.00 のフラグメントシェーダーでも合法でした。3.00 で外れたのは「ループ回数が非定数でも、そのループ変数を添字に使える」ところです。仕様の Issues 12.30 が「サンプラー配列・フラグメント出力配列・uniform ブロック配列を除き、動的インデックスのサポートを必須とする」と決着させています
ループの中身は、光源から L・減衰・色を作ったあと、第17章の Blinn-Phong をそのまま適用して足し込むだけです。新しい式は 1 つもありません。albedoはこのあと 6 節で作る「マテリアルの拡散色 × diffuse map」、radianceは「光源の色 × 強度 × 減衰」で、その点に届く光の量です。
vec3 radiance = light.color * light.intensity * attenuation;
// ここから先は第17章の Blinn-Phong そのまま。それを光源ごとに足していく
float diffuse = max(dot(N, L), 0.0);
color += albedo * radiance * diffuse;
vec3 H = normalize(L + V);
float specular = pow(max(dot(N, H), 0.0), u_material.shininess);
color += u_material.specularColor * radiance * specular * step(0.0001, diffuse);ループの外でalbedo * u_ambientColorを色の初期値にしているのは、第17章 6 節の環境光をそのまま持ち込んだものです。環境光は方向を持たないので、光源の数に関係なく 1 回だけ足します。
光は足し算でよい— 2 つの光源に照らされた面の明るさは、それぞれで照らしたときの明るさの和になります。これは 近似ではなく、光の物理がそもそも線形だからです。ただし「0〜1 の RGB 値をそのまま足す」ことが色として正しいかは別の話で、そこは第27章(リニア空間とガンマ補正)の 担当です。デモで「全部」を選ぶと、橙と青の光が重なった場所が白っぽくなります。この合成の 正しさが問われるのが、まさに第27章の話題です。
6. マテリアル — 光源とは別の側の設計
ライティングの式には、光源側のパラメータ(色・強さ・位置)と、面側のパラメータ(拡散色・鏡面色・ つやの鋭さ)が両方出てきます。光源を構造体にまとめたのだから、面のほうもまとめておきます。
struct Material {
vec3 diffuseColor;
vec3 specularColor;
float shininess;
};この 3 つが、three.js のMeshPhongMaterialでいうcolor/specular/shininessにあたります。デモでは同じ光の下で、床(shininess 16)・球(24)・トーラス(128)と 変えてあります。トーラスだけハイライトが小さく鋭いのが分かるはずです。
const TORUS_MATERIAL: Material = {
diffuseColor: vec3.fromValues(0.3, 0.42, 0.6),
specularColor: vec3.fromValues(0.9, 0.9, 0.9),
shininess: 128, // つやあり。同じ光でもハイライトが小さく鋭い
uvScale: [4, 1],
};そして、第16章で学んだテクスチャの出番です。マテリアルの拡散色を1 色ではなく、面の 位置ごとに変えるには、テクスチャを掛けます。この用途のテクスチャをdiffuse map(あるいはアルベドマップ、three.js ではmaterial.map)と呼びます。
第16章では役割が 1 つしかなかったのでu_textureという名前でしたが、マテリアルには複数のテクスチャが付きうるので、役割を名前に入れてu_diffuseMapとします。中身も使い方(ユニット番号をuniform1iで渡す)も、第16章とまったく同じです。
// マテリアルの拡散色 × diffuse map。ここが「テクスチャとライティングの合流点」
vec3 albedo = u_material.diffuseColor * texture(u_diffuseMap, v_uv * u_uvScale).rgb;掛け算 1 つですが、これで「テクスチャの模様」と「光の当たり方」が別々の要因として合成されます。 模様は物体に貼り付いたまま、陰影だけが光と一緒に動く — デモで球を眺めると、格子模様の上を ハイライトが滑っていくのが見えます。u_uvScaleは、16 × 16 の広い床に UV が 0〜1 しかない(第14章)ため、模様を何回繰り返すかを調整する係数です。テクスチャのラップをREPEATにしてあるので、UV が 1 を超えても繰り返されます(第16章)。
テクスチャそのものは、画像を読み込まずに 128 × 128 の格子模様をUint8Arrayで組み立てて渡しています(createDiffuseMap)。第16章で使ったpublic/textures/uv-grid.pngのような画像を貼ることもできますが、色の付いたテスト画像は光の色と混ざって陰影が読み取りにくく なるので、この章では明度だけの地味な模様にしてあります(画像から作る手順は第16章のとおりです)。
7. 光源が増えるとどうなるか
このシェーダーの構造を、コストの目で見直してみます。ループはフラグメントシェーダーの中にあります。 つまり「画面に描かれるフラグメントの数 × 光源の数」だけライティングの計算が 走ります。1920 × 1080 のフルスクリーンで光源が 10 個なら、それだけで 2000 万回です。
さらに、素朴な実装には無駄が積み重なります。
- 遠くて影響のない光源も計算する— 減衰でほぼ 0 になる光源でも、ループは回り、pow も呼ばれます
- あとで上書きされるフラグメントも計算する— 手前に別の物体が描かれて見えなくなる部分にも、ライティングを済ませてしまうことがあります (第13章で触れた early-Z が効けば軽減されますが、描く順番次第です)
- 光源の数を変えるとプログラムを作り直す設計もある— three.js はシェーダーソース中の
NUM_POINT_LIGHTSというトークンを実際の数に置換してからコンパイルする(WebGLProgram.jsのreplaceLightNums)ので、シーンの光源構成が変わるとシェーダーが 再生成されます。この章のように上限固定 +u_lightCountにすると再コンパイルは要りませんが、常に上限ぶんの分岐コストを抱えます
数十・数百の光源を扱うために考え出されたのが、ジオメトリの描画とライティングを 2 パスに分けるディファードレンダリングです。1 パス目で法線・材質・深度を画面サイズの テクスチャ(G-buffer)に書き出し、2 パス目で「画面の各ピクセルについて、影響する光源だけ」を 計算します。フラグメント数 × 光源数という掛け算を切り離す方法で、第23章の主題です (その前段として、テクスチャへ描画する仕組みが第20章のフレームバッファです)。
もう 1 つ、この章のuploadLightは光源 1 つにつき 8 回のgl.uniform*を呼んでいます。光源が増えるほど、毎フレームの API 呼び出しも増えます。共有するデータを バッファにまとめて 1 回で結びつけるUBO (Uniform Buffer Object)を使えば、この呼び出しの束をまとめて置き換えられます。UBO は第34章で扱います。
コード全文
#version 300 es
// 第3部の共通の属性配置(第14章から): 0 = 位置, 1 = 法線, 2 = UV
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; // モデル行列の左上 3×3 の逆転置(第17章)
// 第17章と同じく、ライティングはワールド空間で行う
out vec3 v_normal;
out vec3 v_worldPosition;
out vec2 v_uv;
void main() {
vec4 worldPosition = u_model * vec4(a_position, 1.0);
v_worldPosition = worldPosition.xyz;
v_normal = u_normalMatrix * a_normal;
v_uv = a_uv;
gl_Position = u_projection * u_view * worldPosition;
}#version 300 es
precision highp float;
in vec3 v_normal;
in vec3 v_worldPosition;
in vec2 v_uv;
// 配列のサイズはコンパイル時定数でなければならない(GLSL ES 3.00 仕様 4.1.9)。
// 光源の「実際の数」は u_lightCount で毎フレーム変える
#define MAX_LIGHTS 4
const int LIGHT_DIRECTIONAL = 0;
const int LIGHT_POINT = 1;
const int LIGHT_SPOT = 2;
// 光源 1 つぶんのパラメータ。3 種類を 1 つの構造体でまかない、type で振り分ける
struct Light {
int type;
// 点光源・スポットライトのワールド座標(方向光では使わない)
vec3 position;
// 方向光: 面から光源へ向かう単位ベクトル(第17章の約束)
// スポットライト: 円錐の軸を「光源へ向かう向き」に揃えたもの
vec3 direction;
vec3 color;
float intensity;
// 距離減衰の係数 (kc, kl, kq) = 定数項・1 次・2 次
vec3 attenuation;
// 円錐の外側の角の cos(これより外は真っ暗)
float coneCos;
// 円錐の内側の角の cos(これより内は減衰なし)。外と内の間が smoothstep のぼかし
float penumbraCos;
};
struct Material {
vec3 diffuseColor;
vec3 specularColor;
float shininess;
};
uniform Light u_lights[MAX_LIGHTS];
uniform int u_lightCount;
uniform Material u_material;
uniform vec3 u_ambientColor;
uniform vec3 u_cameraPosition;
// 拡散色に掛けるテクスチャ(第16章)。u_uvScale は 1 枚の板に何回繰り返すかの調整
uniform sampler2D u_diffuseMap;
uniform vec2 u_uvScale;
// 1.0 で光源マーカー用の自己発光表示(ライティングを通さない)
uniform float u_unlit;
out vec4 fragColor;
void main() {
vec3 N = normalize(v_normal); // 補間された法線の再正規化(第17章)
vec3 V = normalize(u_cameraPosition - v_worldPosition);
// マテリアルの拡散色 × diffuse map。ここが「テクスチャとライティングの合流点」
vec3 albedo = u_material.diffuseColor * texture(u_diffuseMap, v_uv * u_uvScale).rgb;
vec3 color = albedo * u_ambientColor;
// GLSL ES 3.00 ではループ回数が定数でなくてよく、配列も非定数インデックスで引ける。
// 光源が増えるほどフラグメントあたりの仕事が増える(コストの話は本文 7 節)
for (int i = 0; i < u_lightCount; i++) {
Light light = u_lights[i];
// L はどの光源でも「面から光源へ向かう単位ベクトル」に揃える(第17章)
vec3 L;
float attenuation = 1.0;
if (light.type == LIGHT_DIRECTIONAL) {
// 無限に遠いので、どの点でも同じ向き・減衰なし
L = light.direction;
} else {
vec3 toLight = light.position - v_worldPosition;
float dist = length(toLight);
L = toLight / dist;
// 距離減衰 1 / (kc + kl·d + kq·d²)
attenuation =
1.0 / (light.attenuation.x + light.attenuation.y * dist + light.attenuation.z * dist * dist);
if (light.type == LIGHT_SPOT) {
// 円錐の中心からどれだけ外れているか。中心で 1、外れるほど小さくなる
float angleCos = dot(L, light.direction);
// 外側の角(coneCos)で 0、内側の角(penumbraCos)で 1。第7〜8章の smoothstep
attenuation *= smoothstep(light.coneCos, light.penumbraCos, angleCos);
}
}
vec3 radiance = light.color * light.intensity * attenuation;
// ここから先は第17章の Blinn-Phong そのまま。それを光源ごとに足していく
float diffuse = max(dot(N, L), 0.0);
color += albedo * radiance * diffuse;
vec3 H = normalize(L + V);
float specular = pow(max(dot(N, H), 0.0), u_material.shininess);
color += u_material.specularColor * radiance * specular * step(0.0001, diffuse);
}
fragColor = vec4(mix(color, u_material.diffuseColor, u_unlit), 1.0);
}// 第18章: 複数光源とマテリアル — 点光源・スポットライト・減衰
// 第17章のライティングは方向光 1 つだけだった。この章では位置を持つ光源(点光源・
// スポットライト)を足し、光源とマテリアルを GLSL の構造体 + uniform 配列で渡す。
//
// 見どころ:
// - 構造体の配列のロケーションは「u_lights[0].position」のように要素ごと・フィールドごとに
// 取る。配列や構造体をまるごと指すロケーションは存在しない
// - 距離減衰 1 / (kc + kl·d + kq·d²)。点光源のマーカーが遠ざかると床の明るさが落ちる
// - スポットライトの円錐は smoothstep(coneCos, penumbraCos, angleCos)(第7〜8章)
// - マテリアルは struct Material + diffuse map(第16章)。同じ光でも
// shininess を変えるだけで、つや消しとつやありに描き分けられる
import { mat3, mat4, type ReadonlyVec3, vec3 } from 'gl-matrix';
import {
createPlane,
createSphere,
createTorus,
type Geometry,
interleave,
} from '../../lib/geometry';
import { compileShader, linkProgram } from '../../lib/shader';
import fragmentSource from './scene.frag?raw';
import vertexSource from './scene.vert?raw';
// ---------------------------------------------------------------------------
// メッシュ(第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
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 };
}
// ---------------------------------------------------------------------------
// diffuse map: 画像を読み込まず、その場で作る格子模様(テクスチャの扱いは第16章)
// ---------------------------------------------------------------------------
function createDiffuseMap(gl: WebGL2RenderingContext): WebGLTexture {
const SIZE = 128;
const pixels = new Uint8Array(SIZE * SIZE * 4);
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
// 32 テクセルごとの市松 + 境目の細い線。値は 0.6〜1.0 の範囲に収め、
// 「マテリアルの色を少し暗くする模様」として使う
const checker = ((x >> 5) + (y >> 5)) % 2 === 0 ? 1.0 : 0.86;
const line = x % 32 < 2 || y % 32 < 2 ? 0.62 : 1.0;
const value = Math.round(255 * checker * line);
const offset = (y * SIZE + x) * 4;
pixels[offset] = value;
pixels[offset + 1] = value;
pixels[offset + 2] = value;
pixels[offset + 3] = 255;
}
}
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, SIZE, SIZE, 0, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
return texture;
}
// ---------------------------------------------------------------------------
// 光源とマテリアル
// ---------------------------------------------------------------------------
// scene.frag の #define MAX_LIGHTS と必ず同じ値にする
const MAX_LIGHTS = 4;
const LIGHT_DIRECTIONAL = 0;
const LIGHT_POINT = 1;
const LIGHT_SPOT = 2;
interface Light {
type: number;
/** ワールド座標(点光源・スポットライト) */
position: vec3;
/** 面から光源へ向かう向き(方向光)/ 円錐の軸(スポットライト) */
direction: vec3;
color: ReadonlyVec3;
intensity: number;
/** (kc, kl, kq) */
attenuation: ReadonlyVec3;
coneCos: number;
penumbraCos: number;
}
interface Material {
diffuseColor: ReadonlyVec3;
specularColor: ReadonlyVec3;
shininess: number;
/** diffuse map を何回繰り返すか */
uvScale: readonly [number, number];
}
/** 1 つの光源に対応する uniform ロケーションの束 */
interface LightLocations {
type: WebGLUniformLocation | null;
position: WebGLUniformLocation | null;
direction: WebGLUniformLocation | null;
color: WebGLUniformLocation | null;
intensity: WebGLUniformLocation | null;
attenuation: WebGLUniformLocation | null;
coneCos: WebGLUniformLocation | null;
penumbraCos: WebGLUniformLocation | null;
}
// 構造体の配列は「まるごと」のロケーションを取れない。要素番号とフィールド名まで
// 書いた文字列で 1 つずつ取る。OpenGL ES 3 リファレンスページの glGetUniformLocation は
// 「name は構造体・構造体の配列・ベクトルや行列の一部であってはならない」と定めている
// (素の配列は例外で、配列名だけで先頭要素のロケーションが取れる)。
// 連続した要素のロケーションが連番である保証もないので、+1 で済ませてはいけない
function getLightLocations(
gl: WebGL2RenderingContext,
program: WebGLProgram,
index: number,
): LightLocations {
const at = (field: string): WebGLUniformLocation | null =>
gl.getUniformLocation(program, `u_lights[${index}].${field}`);
return {
type: at('type'),
position: at('position'),
direction: at('direction'),
color: at('color'),
intensity: at('intensity'),
attenuation: at('attenuation'),
coneCos: at('coneCos'),
penumbraCos: at('penumbraCos'),
};
}
// ---------------------------------------------------------------------------
// 定数
// ---------------------------------------------------------------------------
// カメラ定数(第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.2, 0);
const AMBIENT_COLOR: ReadonlyVec3 = vec3.fromValues(0.07, 0.08, 0.11);
const FLOOR_Y = -1.25;
const FLOOR_MATERIAL: Material = {
diffuseColor: vec3.fromValues(0.5, 0.51, 0.55),
specularColor: vec3.fromValues(0.12, 0.12, 0.12),
shininess: 16, // ざらついた床。ハイライトは広くぼんやり
uvScale: [6, 6],
};
const SPHERE_MATERIAL: Material = {
diffuseColor: vec3.fromValues(0.78, 0.72, 0.62),
specularColor: vec3.fromValues(0.25, 0.25, 0.25),
shininess: 24, // つや消し
uvScale: [2, 1],
};
const TORUS_MATERIAL: Material = {
diffuseColor: vec3.fromValues(0.3, 0.42, 0.6),
specularColor: vec3.fromValues(0.9, 0.9, 0.9),
shininess: 128, // つやあり。同じ光でもハイライトが小さく鋭い
uvScale: [4, 1],
};
const NO_ATTENUATION: ReadonlyVec3 = vec3.fromValues(1, 0, 0); // 方向光は減衰しない
// 切替ボタン: どの光源を有効にするか
const MODES = [
{
label: '方向光',
lights: [0],
note: '方向光 1 つ。第17章と同じで、どの点でも向きが同じ・距離で減衰しない',
},
{
label: '点光源',
lights: [1],
note: '点光源 1 つ。位置から向きを毎フラグメント計算し、1 / (1 + 0.18d + 0.22d²) で減衰する',
},
{
label: 'スポットライト',
lights: [2],
note: 'スポットライト 1 つ。円錐の外 24°〜内 15° を smoothstep でぼかしている',
},
{
label: '全部',
lights: [0, 1, 2],
note: '3 灯を同時に。色の違う光がフラグメントごとに足される',
},
] as const;
// ---------------------------------------------------------------------------
// デモ本体
// ---------------------------------------------------------------------------
function setup(
gl: WebGL2RenderingContext,
canvas: HTMLCanvasElement,
controls: HTMLParagraphElement,
readout: HTMLParagraphElement,
): void {
const program = linkProgram(
gl,
compileShader(gl, gl.VERTEX_SHADER, vertexSource),
compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource),
);
// --- uniform ロケーション ---------------------------------------------------
const at = (name: string): WebGLUniformLocation | null => gl.getUniformLocation(program, name);
const locations = {
model: at('u_model'),
view: at('u_view'),
projection: at('u_projection'),
normalMatrix: at('u_normalMatrix'),
cameraPosition: at('u_cameraPosition'),
ambientColor: at('u_ambientColor'),
lightCount: at('u_lightCount'),
diffuseMap: at('u_diffuseMap'),
uvScale: at('u_uvScale'),
unlit: at('u_unlit'),
// 構造体のフィールドも「.」でつないだ名前で取る
materialDiffuse: at('u_material.diffuseColor'),
materialSpecular: at('u_material.specularColor'),
materialShininess: at('u_material.shininess'),
};
// 光源は要素ごとに束で持つ
const lightLocations = Array.from({ length: MAX_LIGHTS }, (_, i) =>
getLightLocations(gl, program, i),
);
// --- メッシュとテクスチャ ---------------------------------------------------
const floorMesh = createMesh(gl, createPlane(16, 16));
const sphereMesh = createMesh(gl, createSphere(0.95, 48, 24));
const torusMesh = createMesh(gl, createTorus(0.62, 0.26, 64, 32));
const markerMesh = createMesh(gl, createSphere(0.1, 12, 8));
const diffuseMap = createDiffuseMap(gl);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, diffuseMap);
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.CULL_FACE);
gl.clearColor(0.06, 0.07, 0.09, 1.0);
// --- 光源の定義 -------------------------------------------------------------
const lights: Light[] = [
{
type: LIGHT_DIRECTIONAL,
position: vec3.create(), // 使わない
direction: vec3.normalize(vec3.create(), [0.45, 0.75, 0.4]),
color: vec3.fromValues(1.0, 0.96, 0.9),
intensity: 0.55,
attenuation: NO_ATTENUATION,
coneCos: -1,
penumbraCos: -1,
},
{
type: LIGHT_POINT,
position: vec3.create(), // 毎フレーム動かす
direction: vec3.create(), // 使わない
color: vec3.fromValues(1.0, 0.5, 0.22),
intensity: 3.5,
attenuation: vec3.fromValues(1.0, 0.18, 0.22),
coneCos: -1,
penumbraCos: -1,
},
{
type: LIGHT_SPOT,
position: vec3.fromValues(0, 3.4, 0.8),
direction: vec3.create(), // 毎フレーム狙う先から計算する
color: vec3.fromValues(0.45, 0.7, 1.0),
intensity: 5.0,
attenuation: vec3.fromValues(1.0, 0.05, 0.06),
coneCos: Math.cos((24 * Math.PI) / 180), // 外側 24°
penumbraCos: Math.cos((15 * Math.PI) / 180), // 内側 15°
},
];
// 光源マーカー(自己発光の小球)用のマテリアル。色は光源そのものの色
const markerMaterials: Material[] = lights.map((light) => ({
diffuseColor: light.color,
specularColor: vec3.fromValues(0, 0, 0),
shininess: 1,
uvScale: [1, 1],
}));
const spotAim = vec3.create();
// --- 軌道カメラ(第15章の簡略版) -------------------------------------------
const orbit = { theta: 0.55, phi: 0.36, radius: 8.2 };
const PHI_LIMIT = Math.PI / 2 - 0.05;
const MIN_RADIUS = 4;
const MAX_RADIUS = 16;
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;
// 床の下に潜らないよう、仰角の下限は 0 手前で止める
orbit.phi = Math.min(PHI_LIMIT, Math.max(0.03, 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 = 3; // 最初は全部
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 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);
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 の設定 ---------------------------------------------------------
function setVec3(location: WebGLUniformLocation | null, v: ReadonlyVec3): void {
gl.uniform3f(location, v[0], v[1], v[2]);
}
function uploadLight(slot: LightLocations, light: Light): void {
gl.uniform1i(slot.type, light.type);
setVec3(slot.position, light.position);
setVec3(slot.direction, light.direction);
setVec3(slot.color, light.color);
gl.uniform1f(slot.intensity, light.intensity);
setVec3(slot.attenuation, light.attenuation);
gl.uniform1f(slot.coneCos, light.coneCos);
gl.uniform1f(slot.penumbraCos, light.penumbraCos);
}
function drawMesh(mesh: Mesh, material: Material, unlit: number): void {
mat3.normalFromMat4(normalMatrix, model); // 法線行列(第17章)
gl.uniformMatrix4fv(locations.model, false, model);
gl.uniformMatrix3fv(locations.normalMatrix, false, normalMatrix);
setVec3(locations.materialDiffuse, material.diffuseColor);
setVec3(locations.materialSpecular, material.specularColor);
gl.uniform1f(locations.materialShininess, material.shininess);
gl.uniform2f(locations.uvScale, material.uvScale[0], material.uvScale[1]);
gl.uniform1f(locations.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;
// カメラ(第15章)
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);
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
mat4.perspective(projection, FOVY, aspect, NEAR, FAR);
// 点光源: 中央のまわりを公転しながら上下する。距離減衰が目で見えるように大きく動かす
vec3.set(
lights[1].position,
Math.cos(time * 0.55) * 2.7,
-0.35 + Math.sin(time * 0.9) * 0.35,
Math.sin(time * 0.55) * 2.7,
);
// スポットライト: 位置は固定で、狙う先だけを床の上で動かす。
// direction は「円錐の軸を光源へ向かう向きに揃えたもの」= 狙う先から光源へのベクトル
vec3.set(spotAim, Math.sin(time * 0.45) * 2.4, FLOOR_Y, Math.cos(time * 0.7) * 1.2);
vec3.subtract(lights[2].direction, lights[2].position, spotAim);
vec3.normalize(lights[2].direction, lights[2].direction);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.useProgram(program);
gl.uniformMatrix4fv(locations.view, false, view); // transpose は常に false
gl.uniformMatrix4fv(locations.projection, false, projection);
setVec3(locations.cameraPosition, eye);
setVec3(locations.ambientColor, AMBIENT_COLOR);
gl.uniform1i(locations.diffuseMap, 0); // テクスチャユニット 0(第16章)
// 有効な光源だけを前から詰めて送る。u_lightCount がループの回数になる。
// 上限を超えるぶんは CPU 側で切り捨てる。GPU に任せると、範囲外の要素を読んだ結果は
// エラーにもクラッシュにもならず「値が未定義」になるだけ(GLSL ES 3.00 仕様 4.1.9)
const active = MODES[modeIndex].lights.slice(0, MAX_LIGHTS);
for (const [slot, lightIndex] of active.entries()) {
uploadLight(lightLocations[slot], lights[lightIndex]);
}
gl.uniform1i(locations.lightCount, active.length);
// 床: XY 平面の板を X 軸まわりに -90° 回して床にする(第14章)
mat4.fromTranslation(model, [0, FLOOR_Y, 0]);
mat4.rotateX(model, model, -Math.PI / 2);
drawMesh(floorMesh, FLOOR_MATERIAL, 0);
// つや消しの球
mat4.fromTranslation(model, [-1.75, FLOOR_Y + 0.95, 0.3]);
mat4.rotateY(model, model, time * 0.15);
drawMesh(sphereMesh, SPHERE_MATERIAL, 0);
// つやのあるトーラス
mat4.fromTranslation(model, [1.7, FLOOR_Y + 0.95, -0.2]);
mat4.rotateX(model, model, time * 0.3);
mat4.rotateY(model, model, time * 0.4);
drawMesh(torusMesh, TORUS_MATERIAL, 0);
// 光源マーカー: 位置のある光源はその場所に、方向光は「その向きの遠く」に置く
for (const lightIndex of active) {
const light = lights[lightIndex];
if (light.type === LIGHT_DIRECTIONAL) {
mat4.fromTranslation(model, [
light.direction[0] * 6,
light.direction[1] * 6,
light.direction[2] * 6,
]);
} else {
mat4.fromTranslation(model, light.position);
}
drawMesh(markerMesh, markerMaterials[lightIndex], 1);
}
requestAnimationFrame(frame);
}
// このページのデモはページと寿命を共にするので、rAF ループの停止もリスナーの解除も
// していない。リソース解放(テクスチャの deleteTexture を含む)の一般論は第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 | この章 |
|---|---|
new THREE.PointLight(color, intensity, distance = 0, decay = 2) | type = LIGHT_POINT+position+attenuation(kc, kl, kq)。減衰の式は別物(この章は1/(kc + kl·d + kq·d²)、three.js は1/max(pow(d, decay), 0.01)+ distance の窓) |
new THREE.SpotLight(color, intensity, distance = 0, angle = Math.PI/3, penumbra = 0, decay = 2) | coneCos = cos(angle)、penumbraCos = cos(angle × (1 − penumbra))。three.js のWebGLLights.jsがやっている換算と同じで、円錐の減衰もsmoothstep(coneCos, penumbraCos, angleCos)で一致します |
DirectionalLight | type = LIGHT_DIRECTIONAL。減衰なし、directionは面から光源へ(第17章 4 節) |
light.target(SpotLight / DirectionalLight) | spotAim。direction = normalize(position − 狙う先)として毎フレーム作る |
ライトの uniform 化(WebGLLights.js) | uploadLight()。three.js は種類ごとに配列を分け、ソース中のNUM_POINT_LIGHTSというトークンを実際の数に置換してからコンパイルする(数が変わると再コンパイル)。この章は 1 本の配列 +u_lightCount |
material.map | u_diffuseMap(拡散色に掛ける。テクスチャの扱いは第16章) |
texture.repeat.set(6, 6) | u_uvScale を UV に掛ける(ラップは REPEAT) |
MeshPhongMaterialのcolor/specular/shininess | struct Material の 3 つのフィールド |
light.intensity | intensity(色に掛ける係数)。three.js の物理単位(カンデラなど)の話には踏み込まない |
手元で動かして、壊してみる
main.tsの点光源のattenuationをvec3.fromValues(1.0, 0.0, 0.0)にする — 減衰がなくなり、光源が遠ざかっても明るさが変わりません。距離減衰が「奥行きの手がかり」 としてどれだけ効いているかが分かります。逆にvec3.fromValues(0.05, 0.0, 1.0)にすると、ほぼ逆二乗になり、光源に近い床だけが白く飛びます(2 節)coneCosとpenumbraCosの角度を入れ替える(外 15°・内 24°)— 多くの環境では円錐の内側が真っ暗になり、外側の全面が 明るくなります。ただしこれはedge0 ≧ edge1のsmoothstepで、仕様上は未定義動作です。環境によって結果が変わり得ます(3 節)penumbraCosをMath.cos((23.5 * Math.PI) / 180)にする — 内側と外側がほとんど同じ角度になり、ふちのぼけが消えてジャギーの目立つ固い円になります。smoothstepが何をしているかがよく分かりますscene.fragのMAX_LIGHTSを2にする(main.ts のMAX_LIGHTSも同じ値に)— 「全部」モードで 3 つ目の光源(スポットライト)が消えます。上限を超えたぶんはsliceで黙って捨てられるだけでエラーにならない、という怖さの確認ですmain.tsのgetLightLocationsで、at('position')をat('Position')に書き間違えてみる — ロケーションがnullになり、点光源の位置が原点に固定されて、マーカーの小球だけが空回りします。エラーは 1 つも出ません(null のロケーションへの設定は黙って無視されるからです)。uniform 名のタイプミスがいかに気づきにくいかの実演ですTORUS_MATERIALのshininessを8に、FLOOR_MATERIALを256にする — つやの割り当てが入れ替わり、床が濡れたように見えます。マテリアルが「質感」を 決めていることの確認ですscene.fragのalbedoからtexture(...)を外してu_material.diffuseColorだけにする — diffuse map の寄与が消えて、のっぺりした単色に戻りますcreateDiffuseMapの代わりに、第16章の手順でpublic/textures/uv-grid.pngを読み込んで貼る — UV の向きは分かりやすくなりますが、色の付いたテクスチャが光の色と混ざり、 陰影が読み取りにくくなります。diffuse map の選び方が、確かめたいことに直結するという話ですu_uvScaleを床だけ[1, 1]にする — 16 × 16 の床に模様が 1 回だけ引き伸ばされ、UV とワールドのスケールが無関係であることが分かります
まとめ
- 点光源は
L = normalize(position − 面の位置)をフラグメントごとに計算する。方向光との違いは実質そこだけ - 距離減衰は物理的には
1/d²。実用では1/(kc + kl·d + kq·d²)が使われる — d → 0 の発散を止め、ダイナミックレンジを扱いやすくするため。three.js は逆二乗 + カットオフ窓 - スポットライトは円錐。角度は cos のまま比較し、外側 (
coneCos) と内側 (penumbraCos) をsmoothstepでつないでふちをぼかす。軸も「面から光源へ」の流儀に揃える - 光源は
struct Light+uniform Light u_lights[MAX_LIGHTS]+u_lightCount。配列サイズはコンパイル時定数、ロケーションはu_lights[i].fieldと要素ごとに取得する(連番の保証はない) - GLSL ES 3.00 ではループ回数も配列の添字も定数でなくてよい。WebGL1 (GLSL ES 1.00) の Appendix A では、定数回ループと(フラグメントシェーダーでの)定数インデックス式に縛られていた
- ループの中身は第17章の Blinn-Phong そのまま。光は線形なので、光源ごとの結果を足すだけ
- マテリアルは
struct Material(拡散色・鏡面色・shininess)。拡散色に diffuse map(第16章)を掛けると、模様と陰影が別々の要因として合成される - コストは「フラグメント数 × 光源数」。多数の光源にはディファードレンダリング(第23章)、uniform の送り方には UBO(第34章)という続きがある
これで、自分で作った形(第14章)に、自分で決めたカメラ(第15章)から、自分で組んだ光 (第17〜18章)を当てられるようになりました。残る大きな穴は「自分で作れない形」です。次章第19章「モデル読み込み — OBJ と glTF 2.0」では、外部で作られた 3D モデルを読み込みます。まずはテキスト形式の OBJ を自分でパースし、そのあと業界標準の glTF 2.0 の構造 — バッファビューとアクセサ、ノード階層、そして PBR マテリアル — を読み解きます。第13章で 「実データもインターリーブで流通している」と予告した話が、そこで実物として出てきます。