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

複数光源とマテリアル — 点光源・スポットライト・減衰

第17章のライティングは、方向光 1 つだけでした。太陽のように無限に遠い光源で、シーンのどこにいても 光の向きは同じ。式には距離がまったく出てきませんでした。しかし部屋の中を照らしているのは電球や スポットライトで、それらは位置を持ちます。近づけば明るく、離れれば暗い。 向きを絞れば円錐状の光になる。そして光源はたいてい 1 つではありません。

この章では、光源に位置と減衰と円錐を与え、複数の光源をstructuniform 配列でまとめて GPU へ渡します。第4章で uniform の型を一覧にしたとき、「同じ型の uniform 配列 → 複数光源のパラメータ(第18章)」と予告した、 その回収です。マテリアル側もstruct Materialに整理して、第16章のテクスチャをdiffuse mapとして組み合わせます。

反射モデルそのものは第17章で完成しています。この章がやるのは、「第17章の Blinn-Phong を、光源ごとに計算して足す」という一段上の組み立てです。

床・つや消しの球・つやのあるトーラスを、色の違う 3 灯で照らしています。小球が光源の位置 (方向光だけは向き)を示します。橙の点光源は回りながら上下し、床に近づくと足元が明るく なります(距離減衰)。青のスポットライトは上から床を舐めるように動き、円のふちがsmoothstepでぼけています。ドラッグで回り込み、ホイールで寄れます(第15章の軌道カメラの簡略版)。 物体が互いに影を落とさないのは、この章でも遮蔽を計算していないからです(影は第21章)。

この章で学ぶこと:

1. 位置を持つ光源 — 点光源

方向光と点光源の違いは、実装上はたった 1 か所です。L(面から光源へ向かう単位ベクトル)を どこから得るか

方向光: L はどこでも同じL = u_lights[i].direction距離の概念がない = 減衰しない点光源: L は点ごとに違うL = normalize(position − v_worldPosition)遠いほど矢印がまばら = 暗くなる
方向光は平行光線で、L はシーン全体で 1 つの定数です。点光源は 1 点から放射状に広がるので、L は面の点ごとに計算する必要があり、 遠いほど光がまばらになります(2 節の減衰)。
src/lessons/18-lights-and-materials/scene.frag(抜粋)
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²)となり、ほぼ逆二乗です。ではなぜ、わざわざ項を増やすのでしょうか。

光源からの距離 d1.000123456d → 0 で無限大1 / d²1 / (1 + 0.18d + 0.22d²)(このデモの点光源)
2 つの減衰曲線。逆二乗(破線)は d → 0 で発散し、少し離れるだけで急激に暗くなります。定数項を 持つ実用式(実線)は d = 0 で 1 に収まり、落ち方も緩やかです。
src/lessons/18-lights-and-materials/scene.frag(抜粋)
  // 距離減衰 1 / (kc + kl·d + kq·d²)
  attenuation =
    1.0 / (light.attenuation.x + light.attenuation.y * dist + light.attenuation.z * dist * dist);

3. スポットライト — 円錐と smoothstep

スポットライトは、点光源に「向き」と「広がり」を足したものです。光源から見て円錐の内側にある点だけを 照らします。判定はこうです。

  1. 面から光源へ向かうベクトル L を、点光源と同じように求める
  2. L と円錐の軸との角度を測る。真ん中にいるほど角度が小さい
  3. 角度が外側の限界を超えていたら 0、内側なら 1、その間はなめらかに繋ぐ

角度そのものをacosで求める必要はありません。両方が単位ベクトルならdotがそのまま cos なので(第11章)、cos どうしで比べれば済みます。cos は角度が 大きいほど小さい値になるので、不等号の向きだけ注意します。

ここで軸の向きをどう定義するかが問題になります。このサイトは「L は面から光源へ」で統一すると 決めました(第17章)。比較相手の軸も同じ流儀に揃えておくと、内積が素直に「円錐の中心度」に なります。そこでdirection照らす向きの逆、つまり「狙う先から光源へ向かうベクトル」として持ちます。 こうすると円錐の中心でdot(L, direction) = 1です。three.js のSpotLightの uniform もまったく同じ持ち方をしています。

内 (penumbraCos)外 (coneCos)床に落ちる光。ふちがぼける減衰の掛け算1coneCospenumbraCosangleCos →cos は「中心度」なので右ほど中心
スポットライトの 2 つの角。内側の角までは減衰なし、外側の角より先は真っ暗、その間を smoothstep でつなぎます。比較はすべて角度の cos で行うので、中心ほど値が大きくなります。
src/lessons/18-lights-and-materials/scene.frag(抜粋)
  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)xedge0のとき 0、edge1のとき 1 を返し、間をなめらかに補間します(第7〜8章)。ここではedge0 = coneCos(外側・値が小さい)、edge1 = penumbraCos(内側・値が大きい)なので、円錐の外で 0、内で 1 になります。

CPU 側では、位置と「狙う先」から軸を作っています。円錐の向きを対象に追従させたいときは、 この狙う先を差し替えるだけです。

src/lessons/18-lights-and-materials/main.ts(抜粋)
// スポットライト: 位置は固定で、狙う先だけを床の上で動かす。
// 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_light0Positionu_light1Color…と平らな uniform を並べていくと、すぐに破綻します。GLSL には構造体 (struct)配列があるので、素直にそれを使います。まず、3 種類を 1 つの型でまかなえるよう、 使わないフィールドがあることを承知のうえで合併した構造体を定義します。

src/lessons/18-lights-and-materials/scene.frag(抜粋)
// 光源 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で分岐する形にしました。どちらの設計でも、使わないフィールドの無駄分岐の複雑さのどちらを取るか、というトレードオフになります。

src/lessons/18-lights-and-materials/scene.frag(抜粋)
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 つぶんのロケーションを束にして取る関数を書いておきます。テンプレートリテラルで 名前を組み立てるだけです。

src/lessons/18-lights-and-materials/main.ts(抜粋)
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の数だけ回ればよいことになります。

src/lessons/18-lights-and-materials/main.ts(抜粋)
// 有効な光源だけを前から詰めて送る。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回のループになります。

src/lessons/18-lights-and-materials/scene.frag(抜粋)
// GLSL ES 3.00 ではループ回数が定数でなくてよく、配列も非定数インデックスで引ける。
// 光源が増えるほどフラグメントあたりの仕事が増える(コストの話は本文 7 節)
for (int i = 0; i < u_lightCount; i++) {
  Light light = u_lights[i];

さらりと書きましたが、この 2 行は WebGL1 (GLSL ES 1.00) ではどちらも書けませんでした。

ループの中身は、光源から L・減衰・色を作ったあと、第17章の Blinn-Phong をそのまま適用して足し込むだけです。新しい式は 1 つもありません。albedoはこのあと 6 節で作る「マテリアルの拡散色 × diffuse map」、radianceは「光源の色 × 強度 × 減衰」で、その点に届く光の量です。

src/lessons/18-lights-and-materials/scene.frag(抜粋)
  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. マテリアル — 光源とは別の側の設計

ライティングの式には、光源側のパラメータ(色・強さ・位置)と、面側のパラメータ(拡散色・鏡面色・ つやの鋭さ)が両方出てきます。光源を構造体にまとめたのだから、面のほうもまとめておきます。

src/lessons/18-lights-and-materials/scene.frag(抜粋)
struct Material {
  vec3 diffuseColor;
  vec3 specularColor;
  float shininess;
};

この 3 つが、three.js のMeshPhongMaterialでいうcolor/specular/shininessにあたります。デモでは同じ光の下で、床(shininess 16)・球(24)・トーラス(128)と 変えてあります。トーラスだけハイライトが小さく鋭いのが分かるはずです。

src/lessons/18-lights-and-materials/main.ts(抜粋)
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章とまったく同じです。

src/lessons/18-lights-and-materials/scene.frag(抜粋)
// マテリアルの拡散色 × 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 万回です。

さらに、素朴な実装には無駄が積み重なります。

数十・数百の光源を扱うために考え出されたのが、ジオメトリの描画とライティングを 2 パスに分けるディファードレンダリングです。1 パス目で法線・材質・深度を画面サイズの テクスチャ(G-buffer)に書き出し、2 パス目で「画面の各ピクセルについて、影響する光源だけ」を 計算します。フラグメント数 × 光源数という掛け算を切り離す方法で、第23章の主題です (その前段として、テクスチャへ描画する仕組みが第20章のフレームバッファです)。

もう 1 つ、この章のuploadLightは光源 1 つにつき 8 回のgl.uniform*を呼んでいます。光源が増えるほど、毎フレームの API 呼び出しも増えます。共有するデータを バッファにまとめて 1 回で結びつけるUBO (Uniform Buffer Object)を使えば、この呼び出しの束をまとめて置き換えられます。UBO は第34章で扱います。

コード全文

src/lessons/18-lights-and-materials/scene.vert
#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;
}
src/lessons/18-lights-and-materials/scene.frag
#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);
}
src/lessons/18-lights-and-materials/main.ts
// 第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)で一致します
DirectionalLighttype = LIGHT_DIRECTIONAL。減衰なし、directionは面から光源へ(第17章 4 節)
light.target(SpotLight / DirectionalLight)spotAimdirection = normalize(position − 狙う先)として毎フレーム作る
ライトの uniform 化(WebGLLights.js)uploadLight()。three.js は種類ごとに配列を分け、ソース中のNUM_POINT_LIGHTSというトークンを実際の数に置換してからコンパイルする(数が変わると再コンパイル)。この章は 1 本の配列 +u_lightCount
material.mapu_diffuseMap(拡散色に掛ける。テクスチャの扱いは第16章)
texture.repeat.set(6, 6)u_uvScale を UV に掛ける(ラップは REPEAT)
MeshPhongMaterialcolor/specular/shininessstruct Material の 3 つのフィールド
light.intensityintensity(色に掛ける係数)。three.js の物理単位(カンデラなど)の話には踏み込まない

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

まとめ

これで、自分で作った形(第14章)に、自分で決めたカメラ(第15章)から、自分で組んだ光 (第17〜18章)を当てられるようになりました。残る大きな穴は「自分で作れない形」です。次章第19章「モデル読み込み — OBJ と glTF 2.0」では、外部で作られた 3D モデルを読み込みます。まずはテキスト形式の OBJ を自分でパースし、そのあと業界標準の glTF 2.0 の構造 — バッファビューとアクセサ、ノード階層、そして PBR マテリアル — を読み解きます。第13章で 「実データもインターリーブで流通している」と予告した話が、そこで実物として出てきます。