第5部 大量描画・GPGPU・統合 — 第34章

UBO と抽象化 — ミニエンジンを設計する

この章は 2 つの話を 1 つに入れてあります。前半(1〜4 節)はUBO (uniform buffer object)という具体的な道具、後半(5〜8 節)は「ここまでに散らばったものを、どこまで部品にするか」という 設計の話です。

別々に見えて、これは同じ 1 つの話です。UBO はブロック単位で uniform をまとめる仕組みですが、どうブロックを切るかは「何がどれくらいの頻度で変わるか」で決まります— フレームに 1 回だけ変わるもの、カメラを動かすたびに変わるもの、光源が動くたびに変わるもの、 オブジェクトごとに変わるもの。この層の切り方が、そのままエンジンの部品の切り方になります。Cameraが view と projection を持ち、Sceneが光源を持ち、Materialがマテリアル固有の値を持つ、というのは UBO の層と同じ線引きです。だから 4 節で UBO の層を決めたら、5 節の部品はもう半分決まっています。

そしてこの章は、この本がここまで共通化を見送ってきた判断の総まとめでもあります。第6章 1 節で「読者がまだ中身を知らないコードは、隠さない」というルールを置いて以来、 共通化を見送るたびにその理由をページに書き残してきました。7 節でそれを 1 枚の表にします。

この章のデモ 3 本。ドラッグで回転、ホイールで寄り引きできます。「uniform を数える」はまったく同じ絵を「素の uniform」と「UBO」で描き分けるもので、 違うのは読み出し行の呼び出し回数だけです。「多光源」は 4 / 16 / 64 灯を UBO 1 本で配ります。「ミニエンジン」はノード階層で自転と公転をするシーンで、「階層を見る」を押すと 親子を結ぶ腕が数珠で見えます。読み出し行には時間ではなく数えられる量だけが出ます — メッシュ数・ドローコール数・gl.uniform*の呼び出し回数・アプリが WebGL に渡したバイト数・UBO のバイト数と std140 のパディング、そして手計算した std140 のオフセットと GL が報告するオフセットが一致したかどうか。

この章で学ぶこと:

1. uniform を配る手間を数える — 第18章・第23章で何が起きていたか

「UBO を使うと速い」から始めると、何が速いのかが分からないまま終わります。まず数えます。第18章と第23章のmain.tsを開いて、1 フレームでgl.uniform*を何回呼んでいたかを数えてください。

数え方はこうです。各関数の本体に現れる「1 回で 1 本の uniform を送る呼び出し」を数え、その関数が 1 フレームに何回呼ばれるかを掛けます。呼ばれる回数は ソースの定数(第18章のMODES、第23章のGRID)から決まります。第18章のuploadLightは 1 灯につき 8 本です。

src/lessons/18-lights-and-materials/main.ts(抜粋)
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);
}

第23章は光源をvec4の配列 2 本にパックしたので、光源の本数によらず 3 本で済みます。第18章のstructの配列との対比は第18章 4 節に書いてあるとおりです。

src/lessons/23-deferred-rendering/main.ts(抜粋)
function uploadLights(
  positionRadius: WebGLUniformLocation | null,
  colorIntensity: WebGLUniformLocation | null,
  count: WebGLUniformLocation | null,
): void {
  // プレーンな配列は配列名だけでロケーションが取れ、要素をまとめて送れる(第18章)
  gl.uniform4fv(positionRadius, lightPositionRadius.subarray(0, lightCount * 4));
  gl.uniform4fv(colorIntensity, lightColorIntensity.subarray(0, lightCount * 4));
  gl.uniform1i(count, lightCount);
}
章とモードシーン1 フレームの gl.uniform*ドローコール
第18章(既定の「全部」)光源 3 灯 / メッシュ 6(床・球・トーラス + 光源マーカー 3)72 回(共有 6 + 光源 24 + メッシュ 42)drawElements 6 回
第23章 フォワード光源 64 灯 / メッシュ 37(床 + 6×6)192 回(共有 4 + 光源 3 + メッシュ 185)drawElements 37 回
第23章 ディファード同上(2 パス)201 回(2 パスの共有 13 + 光源 3 + メッシュ 185)drawElements 37 回 + drawArrays 1 回

数えたのはsrc/lessons/18-lights-and-materials/main.tsとsrc/lessons/23-deferred-rendering/main.ts。数え方は上の段落のとおりで、 再現スクリプトの出力をそのまま載せています(スクリプトの置き場は作業報告に書いてあります)。

見てほしいのは合計の大きさではなく、内訳です。第23章のディファードで 201 回のうち 185 回は「メッシュごと」= モデル行列と材質で、これはオブジェクトの数だけ必要な、 減らしようのないぶんです。UBO が手を出せるのは残りの 16 回のほう— カメラ行列・環境光・光源の配列といった、シーン全体で 1 組しかない値です。

ただしこの 16 回が丸ごと消えるわけではありません。そのうち 3 回は G-buffer のサンプラ(gBaseColor/gNormal/gDepthへユニット番号を渡すuniform1i)で、サンプラは uniform ブロックのメンバーにできません(3 節)。実際に UBO へ移せるのは16 回のうち 13 回です。第18章側の共有 6 回にもu_diffuseMapが 1 本含まれているので、事情は同じです。 13 回は小さく見えますが、この共有ぶんには性質の悪いところが 3 つあります。

  1. プログラムごとに別の入れ物である。uniform の値はプログラムオブジェクトの状態なので、同じカメラ行列でもプログラムの数だけ送り直すことになります。第23章がジオメトリパスとライティングパスでu_view/u_projectionや光源の配列を別々に送っているのはこのためで、2 パスで 13 + 3 本になっているのはそれが理由です。 直前の第33章もこの形でした — ポストプロセスのチェーンが、6 種類のプログラムへ毎フレームu_resolutionを配っています。同じ 1 つの値なのに、プログラムの本数だけ送り口が要りました
  2. ロケーションはリンクのたびに変わる。名前で引いたWebGLUniformLocationは、そのプログラムを再リンクすると無効になります。だから第4章以来ずっと「初期化時に 1 回引いて覚える」という書き方をしてきました
  3. 送るのは 1 個ずつ。素の配列ならuniform4fvで一括できますが(第23章がそれ)、structの配列にすると要素ごと・フィールドごとにロケーションが要ります — 第18章 4 節の pitfall がそう書いています。第18章の 8 本 × 3 灯という数はそこから出ています

ここから出てくる要求は 1 つです。「プログラムに属さない、共有できる入れ物」が欲しい。値をバッファに置いておいて、プログラムのほうがそれを見にいく形にできれば、(1) も (3) も 消えます。それが uniform ブロックです。ただしバッファに置くということは、GPU がその中身をどう読むかを、バイト単位で知っていなければならないということでもあります。次の節がそれです。

2. std140 — GPU から見た uniform ブロックのメモリ配置

uniform ブロックの中身は、バッファオブジェクトの中にただのバイト列として 置かれます。uniform3fのように「この uniform にこの値」と名指しで送るのではなく、CPU が並べたバイト列を GPU が決められた位置から読む、という形です。だから並べ方の規則を CPU と GPU が共有していなければなりません。 その規則がstd140です。

出典は OpenGL ES 3.0.6 の§2.12.6.4 "Standard Uniform Block Layout"で、規則が 1 から 10 まで並んでいます。節の本文はまずこう書いています — 「std140 レイアウトが指定されているとき、uniform ブロック内の各 uniform のオフセットは、ブロックの定義から、以下に述べる規則の集合を適用することで導ける」。 導けるということは、問い合わせなくても計算できるということです。 §2.12.6 のGetActiveUniformBlockiv(WebGL2 ではgetActiveUniformBlockParameter)の説明にあるUNIFORM_BLOCK_DATA_SIZEの項も、そこを名指しで例外扱いしています — 「実装が uniform の値をバッファの中にぎっしり詰めるとは保証されないし期待もされていない。その例外が std140 レイアウトで、これは具体的なパッキングの挙動を保証し、 アプリケーションがオフセットやストライドを問い合わせることを要求しない」。

この章で使う型についてだけ、規則を表にします。

型基本アライメント占めるバイト数配列にしたときの 1 要素規則
float / int4416規則 1(配列は規則 4)
vec28816規則 2(配列は規則 4)
vec3161216規則 3(配列は規則 4)
vec4161616規則 2(配列は規則 4)
mat4(列優先)1664(列 vec4 4 本)64規則 5 → 規則 4(配列は規則 6)
構造体メンバーの最大値を 16 へ切り上げ末尾にパディングが付く同左規則 9(構造体の配列は規則 10)

「基本アライメント」は、そのメンバーの開始位置がその倍数でなければならない、という値です。 規則 4 が「配列の基本アライメントと配列ストライドは、1 要素の基本アライメントをvec4の基本アライメントへ切り上げた値にする」と定めているので、配列にすると 1 要素は必ず 16 バイト刻みになります。

オフセットを手で数えて定数に書くと、ブロックの宣言を 1 行変えるたびに全部ずれます。だから規則のほうを関数にします。uniform-buffer.tsのcomputeStd140Layout()がそれで、中身は上の表をそのままコードにしたものです。

src/lessons/34-ubo-and-engine/uniform-buffer.ts(抜粋)
function baseAlignmentOf(type: Std140Type): number {
  switch (type) {
    case 'float':
    case 'int':
      return SCALAR_BYTES; // 規則 1
    case 'vec2':
      return 2 * SCALAR_BYTES; // 規則 2
    case 'vec3':
      return 4 * SCALAR_BYTES; // 規則 3
    case 'vec4':
      return 4 * SCALAR_BYTES; // 規則 2
    case 'mat4':
      return VEC4_ALIGNMENT; // 規則 5 → 規則 4
  }
}
src/lessons/34-ubo-and-engine/uniform-buffer.ts(抜粋)
// 規則 4: 配列の基本アライメントと配列ストライドは、1 要素の基本アライメントを
// vec4 の基本アライメントへ切り上げた値になる。だから float[64] は 256 バイトではなく
// 1024 バイト(1 要素 4 バイトの中身に、12 バイトの詰め物が付く)。
// 規則 6: mat4 の配列は S×C 本の列ベクトルの並びなので、1 要素は 4 列 × 16 = 64 バイト
const alignment = roundUp(elementAlignment, VEC4_ALIGNMENT);
const arrayStride = member.type === 'mat4' ? 4 * VEC4_ALIGNMENT : alignment;
const offset = roundUp(cursor, alignment);

このエンジンが使うブロックは 3 本です。宣言はメンバーの並びをそのまま書くだけで、 オフセットは関数が出します。

src/lessons/34-ubo-and-engine/renderer.ts(抜粋)
export const FRAME_LAYOUT: Std140Layout = computeStd140Layout('Frame', [
  { name: 'u_ambientColor', type: 'vec3' },
  { name: 'u_exposure', type: 'float' },
  { name: 'u_fogRange', type: 'vec2' },
  { name: 'u_fogColor', type: 'vec3' },
]);
ブロックメンバー型オフセットストライド中身
Frameu_ambientColorvec30—12 B
u_exposurefloat12—4 B
u_fogRangevec216—8 B
u_fogColorvec332—12 B
Camerau_viewmat40列 16 B64 B
u_projectionmat464列 16 B64 B
u_cameraPositionvec3128—12 B
Lightsu_lightPositionRadiusvec4[64]016 B1,024 B
u_lightColorIntensityvec4[64]102416 B1,024 B
u_lightCountint2048—4 B

ブロックのサイズはFrame48 B(中身 36 B・パディング 12 B)、Camera144 B(中身 140 B・パディング 4 B)、Lights2,064 B(中身 2,052 B・パディング 12 B)。3 本の合計は 2,256 B で、そのうち 28 B がパディングです。同じ値がデモの読み出し行にも出ます。この 3 つは末尾を 16 の倍数へ切り上げたあとの値で、UNIFORM_BLOCK_DATA_SIZEが同じ値を返すとはかぎりません — この節の後半で実測します。

Frameブロックが分かりやすい例です。u_ambientColor(vec3)のあとのu_exposure(float)は 12 バイト目に詰まりますが、u_fogRange(vec2)のあとのu_fogColor(vec3)は 24 バイト目には入れません — vec3の基本アライメントが 16 だからです。24〜31 の 8 バイトは誰も使わない穴になります。

Frame ブロック 48 B(16 B ずつ 3 段)01632u_ambientColor (vec3) 0–11u_exposure 12–15u_fogRange (vec2) 16–23穴 24–31(8 B)u_fogColor (vec3) 32–43末尾 44–47実線 = 値が入るバイト(36 B) / 破線 = std140 のパディング(12 B)vec2 の直後に vec3 を置くと 8 B の穴があく。並べ替えれば埋められる
Frameブロックのメモリ配置。vec3の基本アライメントが 16 であることが、そのまま穴の位置を決めています。

穴の代金は、詰め方しだいで大きく変わります。第23章の光源データ(位置vec3+ 半径float+ 色vec3+ 強さfloat)× 64 灯を、2 通りの置き方で計算してみます。

どちらの置き方にも、本数を持つint u_lightCountが 1 本ずつ付きます(下の数字はそれを含んだブロック全体の大きさです)。

差は2,048 B— 配列のぶんだけを見ればちょうど 2 倍(4,096 B 対 2,048 B)です。原因は規則 4 —float[64]もvec3[64]も、1 要素 16 バイト刻みになるからです。「4 つの数を 4 本の配列に分けて置く」という、CPU 側では何でもない書き方が、std140 では倍のバッファになります。第23章がvec4にパックしていたのはuniform4fvで一括送信するためでしたが、UBO に移してもそのパックがそのまま効く、というのは覚えておく価値があります。

手計算を、GL に検算させる

ここまでは全部「仕様を読んで計算した値」です。合っている保証はありません。幸い、WebGL2 には実際のオフセットを問い合わせる APIがあるので、突き合わせられます。getActiveUniformBlockParameterでブロックのサイズを、getUniformIndices→getActiveUniformsで各メンバーのオフセット・配列ストライド・行列ストライドを引きます。

src/lessons/34-ubo-and-engine/uniform-buffer.ts(抜粋)
const reportedSize: number = gl.getActiveUniformBlockParameter(
  program,
  blockIndex,
  gl.UNIFORM_BLOCK_DATA_SIZE,
);
src/lessons/34-ubo-and-engine/uniform-buffer.ts(抜粋)
const list = wanted.map((entry) => entry.index);
const offsets: number[] = gl.getActiveUniforms(program, list, gl.UNIFORM_OFFSET);
const arrayStrides: number[] = gl.getActiveUniforms(program, list, gl.UNIFORM_ARRAY_STRIDE);
const matrixStrides: number[] = gl.getActiveUniforms(program, list, gl.UNIFORM_MATRIX_STRIDE);

main.tsは起動時に 1 回これを呼び、食い違いがあれば読み出し行とコンソールの両方に出します。 読み出し行の末尾にstd140 検算 OKと出ていれば、この章が計算したオフセットとストライドが、いまあなたのブラウザの実装と 1 バイトも 違わないということです。

src/lessons/34-ubo-and-engine/main.ts(抜粋)
const checks = renderer.checkLayouts(litShader);
const mismatches = checks.flatMap((check) => check.mismatches);
const inactive = checks.flatMap((check) => check.inactive);
const tailPadding = checks.flatMap((check) => check.tailPaddingNote ?? []);

実測: ブロックの末尾は、切り上げられないことがある

その検算で、実際に 1 つ食い違いが出ました。メンバーのオフセット・配列ストライド・行列ストライドは 1 つも違わないのに、 ブロックのサイズだけが手計算より小さいのです。

ブロック最後のメンバーの終端手計算(16 の倍数へ切り上げ)UNIFORM_BLOCK_DATA_SIZE
Frame44 B48 B44 B
Camera140 B144 B140 B
Lights2,052 B2,064 B2,052 B

測定条件: Chrome /ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)/ 2026-08-08。これは 1 環境の観測であって、仕様ではありません。末尾を切り上げて返す実装もあります(そちらのほうが多いという主張はしません — この本は そこまで測っていません)。

最小の形でも同じでした。vec2+float+float+vec3のブロックは、メンバーが0 / 8 / 12 / 16に並んで最後のvec3が 28 バイト目で終わりますが、UNIFORM_BLOCK_DATA_SIZEは 32 ではなく28を返しました。上の落とし穴で「規則 9 が定めているのは後続メンバーの基本オフセットまで」と書いたことが、 そのまま実装の振る舞いになっているわけです。仕様に無い切り上げを、実装がしていないだけです。

実害はありません。オフセットが全部合っている以上、値を書き込む位置は 1 バイトもずれないからです。 ただしブロックのサイズを知りたいときは GL に聞くことになります。そしてCPU 側のバッファは、切り上げたほうの大きさで確保しておきます。

なので、この章のcomputeStd140Layout()はsize(切り上げ済み)とunpaddedSize(切り上げ前)の両方を返し、検算はこの 2 つのどちらと 一致したかで見分けます。末尾のパディングぶんの差だけなら「食い違い」として扱いません— 読み出し行にはstd140 検算 OK(… オフセット・ストライドが手計算と一致)/ ブロック末尾のパディングを除いて一致: Frame のサイズ 手計算 48 / GL 44 …のように出ます。オフセットやストライドが 1 つでもずれていれば、そちらは今までどおりstd140 検算 NGです。

src/lessons/34-ubo-and-engine/uniform-buffer.ts(抜粋)
const roundsUpTail = reportedSize === layout.size;
const omitsTail = reportedSize === layout.unpaddedSize;
let tailPaddingNote: string | null = null;
if (!roundsUpTail && omitsTail) {
  tailPaddingNote = sizeNote; // 末尾のパディングぶんだけの差。並びは合っている
} else if (!roundsUpTail) {
  mismatches.push(sizeNote); // それ以外の差は本物の食い違い
}

3. UBO を作って配線する — bindBufferBase と uniformBlockBinding

GLSL 側は、uniform を波括弧でくくって名前を付けるだけです。

src/lessons/34-ubo-and-engine/lit.vert(抜粋)
layout(std140) uniform Camera {
  mat4 u_view;
  mat4 u_projection;
  vec3 u_cameraPosition;
};

Cameraがブロック名 (block name)、中の 3 つがメンバーです。 閉じ括弧の前に識別子を書くこともでき、それをインスタンス名 (instance name)と呼びます。この 2 つの使い分けが、最初にかならず引っかかるところです。

CPU 側の手順は 2 本に分かれます。まず、バッファを作ってバインディングポイントに付ける。

src/lessons/34-ubo-and-engine/uniform-buffer.ts(抜粋)
this.buffer = gl.createBuffer();
gl.bindBuffer(gl.UNIFORM_BUFFER, this.buffer);
// 中身は毎フレーム書き換える前提。DYNAMIC_DRAW は「CPU が書いて GPU が読む」の意思表示。
// 確保するのは切り上げ済みの layout.size。GL が報告するブロックのサイズがこれより
// 小さいことがある(末尾を切り上げない実装)が、バッファが大きいぶんには問題にならない
gl.bufferData(gl.UNIFORM_BUFFER, layout.size, gl.DYNAMIC_DRAW);
// バッファをコンテキストのバインディングポイントに付ける。プログラム側の
// uniformBlockBinding と、この番号で待ち合わせる(本文 3 節)
gl.bindBufferBase(gl.UNIFORM_BUFFER, bindingPoint, this.buffer);
gl.bindBuffer(gl.UNIFORM_BUFFER, null);

もう 1 本は、プログラムのブロックを同じバインディングポイントに向ける。

src/lessons/34-ubo-and-engine/engine.ts(抜粋)
bindBlock(blockName: string, bindingPoint: number): void {
  if (!this.hasBlock(blockName)) return;
  const index = this.gl.getUniformBlockIndex(this.program, blockName);
  this.gl.uniformBlockBinding(this.program, index, bindingPoint);
}

この 2 本は互いを知りません。バッファは「0 番に付いた」としか知らず、 プログラムは「うちのFrameブロックは 0 番を見る」としか知らない。番号が待ち合わせ場所になっていて、そこで初めて つながります。テクスチャユニットとまったく同じ構図です(第16章 6 節)。 テクスチャユニットではactiveTexture+bindTextureでユニットにテクスチャを置き、uniform1iでサンプラにユニット番号を教えました。ここではbindBufferBaseでバインディングポイントにバッファを置き、uniformBlockBindingでブロックに番号を教えます。

番号を GLSL 側に書かないのではなく、書けません。これは第4章 2 節で見たのと同じ事情です — あそこは「attribute のlayout(location = 0)のように番号をシェーダー側で固定する方法は、GLSL ES 3.00 の uniform にはありません」と 書いて、この章を予告していました。ブロックにしても事情は変わりません。GLSL ES 3.00 §4.3.8.3 が uniform ブロックに許す layout-qualifier-id はshared/packed/std140/row_major/column_majorの 5 つだけで、バインディングポイントを指定するlayout(binding = N)はこの一覧にありません(これが入るのは GLSL ES 3.10 以降です)。だからuniformBlockBindingは選択肢の 1 つではなく、待ち合わせ番号を決める唯一の手段です。 attribute だけが番号をシェーダー側に書ける、という第4章の非対称は、UBO まで来ても そのまま残っています。

バッファ(値の置き場)バインディングポイント(待ち合わせ場所)プログラムFrame 48 BbindBufferBase0uniformBlockBindingCamera 144 BbindBufferBase1uniformBlockBindingLights 2,064 BbindBufferBase2uniformBlockBindinglit(UBO 版)Frame ブロックCamera ブロックLights ブロックplain(ブロックを持たない)
バッファとプログラムは互いを知らず、番号で待ち合わせます。plainのようにブロックを持たないプログラムは、この配線に参加できないので、共有の値を 1 本ずつ受け取ることになります。

エンジンのShaderは、リンクしたあとに「この program がどのブロックを持っているか」を名前で控えます。Rendererはその情報で配線の仕方を変えます。

src/lessons/34-ubo-and-engine/engine.ts(抜粋)
const names = new Set<string>();
const count: number = gl.getProgramParameter(this.program, gl.ACTIVE_UNIFORM_BLOCKS);
for (let i = 0; i < count; i++) {
  const name = gl.getActiveUniformBlockName(this.program, i);
  if (name) names.add(name);
}
this.blockNames = names;

上限は仕様が決めている

バインディングポイントも、1 プログラムが使えるブロックの数も、ブロック 1 本の大きさも上限があります。OpenGL ES 3.0.6 の状態テーブルから最小要求値を拾い、 ある 1 環境で実際に返ってきた値を並べます。

問い合わせ最小要求値ある環境の実測出典意味
MAX_UNIFORM_BUFFER_BINDINGS2432Table 6.33コンテキストのバインディングポイントの本数
MAX_VERTEX_UNIFORM_BLOCKS1216Table 6.311 プログラムの頂点シェーダーが使えるブロック数
MAX_FRAGMENT_UNIFORM_BLOCKS1216Table 6.32同じくフラグメントシェーダー
MAX_COMBINED_UNIFORM_BLOCKS2432Table 6.331 プログラムの合計。両方のシェーダーが宣言したブロックは 2 つに数える(§2.12.6.2)
MAX_UNIFORM_BLOCK_SIZE16384 B16384 BTable 6.33ブロック 1 本の上限。超えるとリンクに失敗することがある(§2.12.6)
UNIFORM_BUFFER_OFFSET_ALIGNMENT256 が上限16Table 6.33 の脚注 †bindBufferRange のオフセットの倍数。この行だけは「最小要求値」ではなく最大値

出典は OpenGL ES 3.0.6 の Table 6.31(頂点シェーダーの上限)・Table 6.32(フラグメント シェーダーの上限)・Table 6.33(集約された上限)。「ある環境の実測」は Chrome /ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)/ 2026-08-08 にgl.getParameterで引いた値で、1 環境の観測であって仕様ではありません(MAX_COMBINED_UNIFORM_BLOCKSは測っていないので空けてあります)。UNIFORM_BUFFER_OFFSET_ALIGNMENTの行だけ読み方が違い、Table 6.33 の脚注が「この値は許される最大値であって最小値ではない」と明記しています。つまり実装はもっと小さい値を返してよく、256 より大きくはできません。 あわせて WebGL 2.0 仕様が「この値は 4 で割り切れなければならない」と追加で定めています (ArrayBufferをbindBufferRangeでアップロードするのが非現実的になるため、というのが添えられた理由です)。

この章のブロックは 3 本で、最小要求値のMAX_VERTEX_UNIFORM_BLOCKS12 に対して十分な余裕があります。ただしMAX_COMBINED_UNIFORM_BLOCKSの数え方には注意が要ります — §2.12.6.2 が「あるブロックが複数のシェーダーから使われている場合、その使用は 1 つずつ別々に この合算の上限に数えられる」と定めています。Cameraブロックはlit.vertとlit.fragの両方が宣言しているので、合算では 2 つぶんです。

同じ §2.12.6.2 は、同じ名前のブロックを複数のシェーダーが宣言するなら、宣言が同一でなければならないとも定めています — 「uniform は同じ名前・同じ型で、同じ順序で宣言されていなければならない。異なる宣言があればプログラムはリンクに失敗する」。lit.vertとlit.fragに同じ 5 行が書いてあるのはそのためで、片方だけ直すとリンクエラーになります。

置き場所の上限が変わる — 第23章の 128 vec4

上限の話には、もう 1 つ実利のある側面があります。素の uniform が食うのは「既定の uniform ブロック」の容量です。ES 3.0.6 §3.9.1 がMAX_FRAGMENT_UNIFORM_COMPONENTSを「既定の uniform ブロックにおけるフラグメントシェーダーの uniform 変数のための格納量」と定義していて、MAX_FRAGMENT_UNIFORM_VECTORSはその 4 分の 1 です。最小要求値は224 ベクトル(Table 6.32)。

第23章 7 節は、そこで止まっていました — 64 灯 × 2 =128 個の vec4で、224 の半分以上をこれだけで使っている、と。同じデータを uniform ブロックへ移すと、数えられる先が変わります。ブロックの中身はMAX_UNIFORM_BLOCK_SIZE(最小 16,384 B = vec4で 1,024 個ぶん / Table 6.33)とMAX_COMBINED_FRAGMENT_UNIFORM_COMPONENTSのほうで数えられ、既定ブロックの 224 ベクトルとは別勘定になります。128vec4は、最小要求値に対して既定ブロックなら 57%、ブロックへ移せば 12.5%です。

第23章が UBO へ送っていたのは、呼び出し回数の話ではなく、この置き場所の上限の話でした。1 節で数えた「共有ぶんの 13 回」は 1 フレームの手間の話ですが、光源を増やしていくと、 手間より先に既定ブロックの容量のほうで詰まります。UBO はそちらの天井も一緒に上げてくれる、というのがこの節のもう 1 つの取り分です。

1 本のバッファを分けて使う — bindBufferRange

bindBufferBaseはバッファ全体をバインディングポイントに付けますが、bindBufferRange(target, index, buffer, offset, size)ならその一部だけを付けられます。オブジェクトごとのブロックを 1 本の大きなバッファに並べて おき、オフセットを変えながら描く、という使い方ができます。

ただしオフセットはUNIFORM_BUFFER_OFFSET_ALIGNMENTの倍数でなければなりません。§2.12.6.5 が「offset が実装依存のアライメント要件 (UNIFORM_BUFFER_OFFSET_ALIGNMENTの値)の倍数でなければINVALID_VALUEエラーを生成する」と定めています。上の表のとおりこの値は最大 256 なので、最悪の場合、144 バイトのブロックを詰めて並べることはできず、256 バイト刻みにする ことになります。112 バイトが毎回捨てられる計算です。この章がその手を採らない 理由は 4 節で書きます。

ただし「最大 256」は最悪の場合の話です。手元でgl.getParameter(gl.UNIFORM_BUFFER_OFFSET_ALIGNMENT)を実行したら、返ってきたのは16でした(Chrome /ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)/ 2026-08-08)。Cameraブロックの 144 バイトはちょうど 16 の倍数なので、この環境なら隙間なしで並べられます。表の脚注が「この値は許される最大値であって最小値ではない」と わざわざ書いているのは、こういう差が出るからです。他の項目と同じ読み方をしていたら、256 バイト刻みだと思い込んだままになります。これも 1 環境の観測なので、実際に刻み幅を決めるときはその場で問い合わせてください— 定数に書いてよい値ではありません。

変わったところだけ送る

bufferSubDataはバッファの一部だけを書き換えられます。この章のUniformBlockBufferは CPU 側に同じ大きさのArrayBufferを持っていて、書き込みのたびに「未アップロードのバイト範囲」を広げ、upload()でその範囲だけを送ります。値が 1 つも変わらなかったフレームは、bufferSubDataを 1 回も呼びません。

src/lessons/34-ubo-and-engine/uniform-buffer.ts(抜粋)
upload(gl: WebGL2RenderingContext): number {
  if (this.dirtyStart >= this.dirtyEnd) return 0;
  const start = this.dirtyStart;
  const bytes = this.dirtyEnd - start;
  gl.bindBuffer(gl.UNIFORM_BUFFER, this.buffer);
  gl.bufferSubData(gl.UNIFORM_BUFFER, start, new Uint8Array(this.words, start, bytes));
  gl.bindBuffer(gl.UNIFORM_BUFFER, null);
  this.dirtyStart = 0;
  this.dirtyEnd = 0;
  return bytes;
}

範囲は「最小のオフセットから最大の終端まで」の 1 本にまとめています。飛び地を書いたときは 間の触っていないバイトも一緒に送ることになりますが、範囲を複数持つとbufferSubDataの回数が増えるので、この規模なら 1 本のほうが素直です。どちらが得かは「回数」と「バイト数」のどちらを減らしたいかで変わります— 読み出し行にはその両方が出ます。

4. 何を UBO にまとめるか — 更新頻度で層を分ける

ブロックの切り方に唯一の正解はありませんが、判断の軸ははっきりしています —いつ変わるかです。同じブロックに入れたものは、bufferSubDataの範囲で一緒に運ばれます。更新頻度の違うものを混ぜると、変わらない値まで毎フレーム運ぶことに なります。このエンジンは 3 層に切りました。

4 つ目の層、オブジェクトごと(モデル行列・法線行列・マテリアル)はUBO に入れていません。理由は単純で、オブジェクトごとにbufferSubDataを呼ぶなら、素の uniform を呼ぶのと手数が変わらないからです。17 個のオブジェクトを描くのに 17 回bufferSubDataを呼ぶのでは、共有できる入れ物が欲しいという 1 節の要求に何も答えていません。

3 節に書いたbindBufferRangeを使えば、1 本のバッファにオブジェクトごとのブロックを並べておいて、描くたびにオフセットを 変える、という手が採れます。bufferSubDataは 1 回で済み、描画のたびにbindBufferRangeを呼ぶだけになります。この章がそれを採らないのは 3 つの理由からです。

  1. アライメントの縛りが重い。3 節のとおりオフセットは最大 256 バイト刻みなので、100 バイトのブロックでも 1 個あたり 256 バイト使います
  2. 呼び出し回数は結局オブジェクトの数だけ要る。bindBufferRangeがuniformMatrix4fvに置き換わるだけで、「オブジェクトの数に比例する呼び出し」は消えません
  3. これを消す道具はもう別にある。同じ形を大量に出すならインスタンシング (第31章)で、per-instance 属性にモデル行列を積むほうが素直です

いちばんの利点は「複数のプログラムが同じ入れ物を見られる」こと

1 節で挙げた素の uniform の性質 (1) — プログラムごとに別の入れ物 — が、UBO では消えます。バッファはコンテキストのバインディングポイントに付いていて、プログラムのブロックはそこを見にいくだけなので、 プログラムが何本あっても送る回数は変わりません。

デモ 1 の読み出し行がその差です。「素の uniform」を選ぶと共有ぶんのuniform*が 10 回出ますが、「UBO」では 0 回になります。このデモはプログラムが 1 本なので、差はその 10 回ぶんです。プログラムが P 本あるシーンなら、素の uniform 側は 10 × P 回になり、UBO 側は 0 回のままです。 読み出し行の「共有」の数字を P 倍すれば、その規模での差がそのまま出ます。

エンジンのRendererはこの分岐を 1 行で書いています — ブロックを持つプログラムには何もせず、 持たないプログラムにだけ 1 本ずつ送る。

src/lessons/34-ubo-and-engine/renderer.ts(抜粋)
private bindShared(shader: Shader, camera: Camera): void {
  if (shader.hasBlock('Camera')) return;
  const frame = this.frameUniforms;
  this.writer.mat4(shader.location('u_view'), camera.view);
  this.writer.mat4(shader.location('u_projection'), camera.projection);
  this.writer.vec3(shader.location('u_cameraPosition'), camera.position);

UBO にしても減らないもの

第31章 1 節が「インスタンシングで減るのは呼び出し回数だけで、頂点の仕事も、アプリが WebGL に渡すバイト数も減らない」と書いたのと同じ書き分けが、ここでも要ります。UBO が減らすのは呼び出し回数であって、バイト数ではありません。同じ値を送るなら、uniform4fvで送ってもbufferSubDataで送っても同じバイト数です。

光源素の uniform で 1 フレームに渡すバイト数UBO で 1 フレームに渡すバイト数
4 灯2,128 B(共有 308 + 個別 1,820)2,024 B(UBO 204 + 個別 1,820)
16 灯2,512 B(共有 692 + 個別 1,820)2,216 B(UBO 396 + 個別 1,820)
64 灯4,048 B(共有 2,228 + 個別 1,820)2,984 B(UBO 1,164 + 個別 1,820)

走査条件: デモ 1・2 のシーン(床 1 + 4×4 = 17 メッシュ / マテリアル 3 種 / プログラム 1 本)。 「個別」はオブジェクトごとのu_model(64 B)+ u_normalMatrix(36 B)× 17 と、マテリアル 3 種 × 40 B(u_baseColor/ u_specularColor / u_shininess /u_emissive)。UBO 側はCameraの140 B(144 B のブロックのうち、実際に書き込むのはu_cameraPositionの終端まで。末尾 4 B のパディングは一度も書かないので送られません)に、光源の位置ぶん (本数 × 16 B)を足したもの。Frameは値が変わらないので 0 B。プログラムが 1 本のときの値なので、素の uniform 側の「共有」はプログラムの本数に比例して増えます。「素の uniform」の列のうち、読み出し行で確かめられるのは 4 灯の 2,128 B だけです— デモ 2(多光源)は UBO 側しか用意していないので、16 灯・64 灯の 2,512 B / 4,048 B は上と同じ数え方で計算した値です。

64 灯でも 4,048 B と 2,984 B、比にして 0.74 倍です。バイト数の差は「共有の値を送り直さなくて済む」ぶんだけで、劇的には減りません。頂点の仕事もフラグメントの仕事も、UBO にしたところで 1 つも減りません。UBO が本当に効くのは、プログラムが何十本もあるシーンで、同じカメラ行列を何十回も送り直していた場合です。

5. 部品に切る — Shader / Material / Mesh / Camera / Scene / Renderer

ここからが後半です。いきなりクラスを並べる前に、ここまでの章のmain.tsに何が重複して書かれていたかを数えます。第6章 1 節が置いたルールは「読者がまだ中身を知らないコードは、隠さない」で、 その裏返しとして「すでに 3 回自分の手で書いたコードを隠しても、失われるものはありません」と 書いてありました。3 回どころではない、というのがこの表です。

何が何章にどれくらい同じか
function resizeIfNeeded17 章(4・11〜23・30〜32)コメントと空白を除くと 5 通り。最大のかたまりは 12 章(12〜19・22・30〜32)で完全一致
linkProgram(gl, compileShader(gl, gl.VERTEX_SHADER, …), compileShader(…))19 章・のべ 25 回3 行 1 組。中身は同じで、渡すソースだけが違う(うち 1 回は第32章の linkProgramWithVaryings。7 節)
軌道カメラ(pointerdown / pointermove / pointerup / wheel + endDrag)11 章(15〜19・21〜23・30〜32)endDrag の中身は 2 通り。10 章が完全一致で、違うのは第19章だけ
function createMesh(VAO + interleave + IBO)11 章(14〜23・30)中身は 3 通り。9 章(15・17〜23・30)が完全一致
const FOVY = (45 * Math.PI) / 180;15 章(12〜23・30〜32)1 文字も違わない
document.createElement('button') による切替ボタン26 章(7〜32)aria-pressed の付け替えまで含めて同じ形

走査範囲はsrc/lessons/*/main.tsの 32 本(第1〜32章)。「どれくらい同じか」は、関数の本体から行コメントと空白を取り除いた 文字列を比較して数えたものです。再現スクリプトの置き場は作業報告にあります。

これが「共通化する理由が読者に見えている」の実体です。第6章の時点では「まだ 3 回しか書いていない」から抽出できるものが 3 つしかありませんでした。 いまは 32 本のmain.tsがあって、同じ形が 10 回以上繰り返されているものが 6 種類ある。ここまで来て初めて、 「部品にする」という判断に根拠が付きます。

部品を設計するときに効くのは、何を持つかよりも何を持たないかです。 持たせすぎた部品は、あとから必ず「この場合だけ違う」に負けます。

Renderer1 フレームの手順と UBO 3 本Camera球面座標 → view / projectionScene (Node)親子のモデル行列 + Light の配列Nodelocal / world + 参照 2 本MaterialShader + 固有の値 + 描画状態MeshVAO + 個数Shaderprogram + ロケーション表
矢印は「指している」関係です。NodeはMeshとMaterialを指すだけで持ち物にはせず、同じMeshを何個のNodeが指してもかまいません。
部品持つもの持たないもの
Shaderリンク済みのプログラム / uniform ロケーションのキャッシュ / ブロック名の集合ソースの管理(?rawで読むのは呼び出し側)。#defineを差し替えたバリアントの生成。uniform の「値」
Materialシェーダーへの参照 / そのマテリアル固有の uniform の値 /描画状態(深度テスト・深度書き込み・カリング)モデル行列(Node の役)。ジオメトリ(Mesh の役)
MeshVAO / インデックス数 / 描画モード。src/lib/geometry.tsのGeometry(第14章)から作る位置も色も材質も持たない。だから同じ Mesh を何個でも共有できる
Node / Sceneローカル行列 / ワールド行列 / 親子 / 描くものがあればMeshとMaterialへの参照。Sceneはさらに光源の配列描き方。ノードは自分を描かない。カメラも持たない(描くときに渡す)
Camera注視点と球面座標の状態 / 視野角・near・far / そこから作る view と projection入力。pointer と wheel をどう繋ぐかはmain.tsの仕事
RendererUBO 3 本 / 1 フレームの手順 / 数えた量シーンもカメラも持たない。描くたびに受け取る

Nodeの中身は、glTF のノード階層(第19章 7 節)とまったく同じ構図です。親のワールド行列に 自分のローカル行列を掛けたものが自分のワールド行列で、それを根から葉へ 1 回のたどりで配ります。

src/lessons/34-ubo-and-engine/engine.ts(抜粋)
updateWorldMatrix(parentWorld: ReadonlyMat4 | null): void {
  if (parentWorld) {
    mat4.multiply(this.world, parentWorld, this.local);
  } else {
    mat4.copy(this.world, this.local);
  }
  for (const child of this.children) {
    child.updateWorldMatrix(this.world);
  }
}

境界の引き方こそが設計

迷ったところを 2 つ書いておきます。どちらも「正解」ではなく「この章はこう決めた」です。

① 深度テストは Material か Renderer か。この章はMaterialに持たせました。Rendererに「不透明パスでは深度オン、半透明パスではオフ」と持たせるほうが呼び出し回数は減りますが、 「このメッシュだけ深度書き込みを切りたい」が表現できなくなります。深度書き込みを切るかどうかは 材質と一緒に決まる性質(第30章 4 節)なので、材質側に置きました。代償は、マテリアルが 切り替わるたびにgl.enable/gl.depthMask/gl.cullFaceを呼び直していることです。同じ状態でも呼び直しているので、状態のキャッシュは持っていません— three.js のWebGLStateがやっているのはそれで、この章には無いものの 1 つです(8 節)。

② ワールド行列の再計算は誰が呼ぶか。Nodeに「自分が汚れたら親に知らせる」を持たせる設計もありますが、この章はRenderer.render()の先頭で木を丸ごと 1 回たどることにしました。ノードの数がこの規模なら 差が出ませんし、「いつワールド行列が正しくなるのか」が 1 か所を見れば分かるほうが 読みやすいからです。ただし副作用があります —光源をノードに追従させたい場合、Renderer が UBO を書く前にワールド行列が できていないといけません。デモ 3 の太陽の灯りがそれで、main.tsがrender()の前にもう 1 回だけ木をたどっています。境界を引くと、こういう「順序の依存」が必ずどこかに出ます。

6. 描く順番は誰が決めるのか — Renderer の中身

Renderer.render() の 1 フレームは、6 段です。

  1. ワールド行列の更新 — 根から 1 回たどる
  2. カメラの更新— aspect は描画先の幅 / 高さから。 画面へ描くのでgl.drawingBufferWidth / gl.drawingBufferHeight(第3部の規約)
  3. 描くものを集める— 木をたどってMeshとMaterialの両方を持つノードを拾う
  4. 並べ替え — プログラム → マテリアルの順
  5. UBO の更新— 最大 3 本ぶんのbufferSubData。値の変わらなかったブロックは送らないので、ふだんはCameraとLightsの 2 回です(4 節)。ここでしか UBO を触りません
  6. 描く — 状態が変わったときだけ切り替えて drawElements

順番に意味があります。③ を ⑤ より先に置いているのは、「このフレームにブロックを読むプログラムが 1 本もなければ、UBO の更新ごと飛ばす」ためです。デモ 1 の「素の uniform」側が、使ってもいない UBO の更新を数えてしまわないように、集めてから決めています。

不透明を前から、半透明を後ろからという並べ替えは、このエンジンには 入れていません。この章のシーンに半透明が 1 つも無いからで、深度と描画順の理屈は第30章 4 節がすでに扱っています。入れるとしたらMaterialに「半透明か」の印を足して、④ の比較の第 1 キーにすることになります。

状態の切り替えを減らす並べ替え

④ はプログラム → マテリアルの順に並べ替えます。同じプログラムを使う メッシュが固まるので、useProgramの回数がプログラムの本数まで落ちます。

src/lessons/34-ubo-and-engine/renderer.ts(抜粋)
if (this.sortBatches) {
  this.items.sort((a, b) => {
    const shaderDiff = sortId(a.material.shader) - sortId(b.material.shader);
    if (shaderDiff !== 0) return shaderDiff;
    return sortId(a.material) - sortId(b.material);
  });
}
src/lessons/34-ubo-and-engine/renderer.ts(抜粋)
if (material.shader !== currentShader) {
  currentShader = material.shader;
  usedPrograms.add(currentShader);
  gl.useProgram(currentShader.program);
  programSwitches++;
  this.registerShader(currentShader);
  const before = this.writer.calls;
  this.bindShared(currentShader, camera);
  sharedUniformCalls += this.writer.calls - before;
  currentMaterial = null;
}

useProgramの回数は、このデモではプログラムが 1 本しかないので並べ替えても 1 回のままです。動くのはマテリアルの切り替えのほうで、こちらははっきり差が 出ます。デモ 1・2 のシーンは 4×4 の格子に球とトーラスを市松に並べてあるので、木のたどり順ではマテリアルがほぼ 1 個おきに入れ替わるからです。

走査条件は 4 節の表と同じシーン(床 1 + 4×4 = 17 メッシュ / マテリアル 3 種 / プログラム 1 本 / 光源 4 灯)。sortBatchesをfalseにして読み出し行の「マテリアル切替」と「uniform*」を見比べれば、同じ数字が 出ます。切替が 17 回ではなく 14 回で止まるのは、行の折り返しのところで同じマテリアルが 2 個続く箇所が 3 か所あるためです。

ただしこの差を「速くなった」とは書けません。減ったのはgl.uniform*の呼び出し回数であって、GPU が実際にどれだけ楽になったかは測っていないからです。GPU の時間を測る道具は第35章 2 節が出しますが、状態の切り替えのコストそのものは、この本では最後まで測りません。並べ替えそのもののコスト(17 要素のソート)も毎フレーム払っていて、そちらも測っていません。「呼び出しが半分になった」までが、この章が言えることです。

エンジンを書くと、何が見えなくなるか

ここまでの 33 章で、あなたはgl.useProgramもgl.bindVertexArrayもgl.drawElementsも、毎回自分の手で書いてきました。この章のmain.tsにはその 3 つが 1 つも出てきません。全部Renderer.render()の中に入ったからです。これは便利さと引き換えに、「いま GPU に何を命じているか」が見えなくなるということです。第6章 1 節のルール — 「読者がまだ中身を知らないコードは、隠さない」 — がここで効きます。この章のエンジンを隠してよいのは、隠されるgl.*の呼び出しを、あなたがすでに全部自分で書いたことがあるからです。逆に言えば、第3章でこのエンジンを渡されていたら、この本の意味はありませんでした。

7. 抽象化しなかったもの — 上げなかった判断の一覧

第6章以降、共通化を見送るたびに理由をページに書いてきました。ここで 1 枚にまとめます。src/lib/に上げなかったもの、という意味です。

何をどの章で上げなかった理由
OBJ / glTF のローダー第19章外から来たバイト列をパースする層。外部モデルを読むのはこの章だけで、フォーマットごとに形も違う。第19章 1 節がローダーとシーングラフの整理を第34章へ送っていたが、Scene / Node のほうだけをこの章の部品にして、ローダーは入れなかった
gbuffer.ts(G-buffer の生成・バインド・バイト数)第23章「何を、どの精度で、何枚持つか」はシーン設計そのもので、章をまたいで同じ形にならない
sdf.glsl(2D の距離関数と合成)第24章章の中の 4 本のデモへ配るためのチャンク。第4部の各章が同じ形の章ローカルチャンクを持つ
noise.glsl(ハッシュとノイズ)の複製第25章 → 第26章読者が壊して遊ぶ対象。第25章のファイルを直したら第26章のデモまで壊れる、という結合を作らない
color.glsl(sRGB の伝達関数)の複製第27章 → 第28〜36章(この章を含む)同上。正本 1 本 + 複製 9 本。複製である以上、片方を直したら他も直す。一致はバイト単位で確かめる
feedback.ts(ping-pong のハーネス)第29章第4部の標準ハーネスとはループの構造そのものが違う。オプションを足す差ではないので同居させない
linkProgramWithVaryings第32章transformFeedbackVaryings はリンクの前に呼ぶ必要があり、共通の linkProgram には差し込む隙間が無い
ポストプロセスのチェーン第33章第33章 1 節が挙げた理由は「チェーンの形そのものを見せたいので、パスの生成をクラスに畳まない」。この章の Renderer にも入れない — こちらの担当は「シーンを 1 枚描く」までで、チェーンはその後ろに置く別のパイプライン
このミニエンジンそのもの(engine.ts / renderer.ts / uniform-buffer.ts)この章この本の残りの章がこれを使わないから。第35章と第36章はエンジンより下の層の話で、抽象化が間に入ると見たいものが見えなくなる

最後の行がこの章の判断です。このミニエンジンはsrc/lib/へ上げません。第35章(計測とコンテキストロスト)と第36章(入力と書き出し)は、エンジンより下の層の話です。「どこで時間を食っているか」「コンテキストが失われたら何を作り直すか」「ポインタの イベントをどう束ねるか」を見るのに、抽象化が間に入ると見たいものが見えなくなります。使う章が 1 つも無いものを共通モジュールに置くのは、共通化ではなく置き場所の移動です。このエンジンは「読者が自分の作品を作るときの出発点」として、この章の中に置いておきます。

同じ理由で、第33・35・36章は UBO を使いません。素の uniform のままです。 既存の章を書き換えないのも同じ判断で、第18章や第23章を UBO に書き換えてしまうと、1 節で数えた「配る手間」がページから消えてしまいます。この本にとって、あの 72 回や 201 回は残しておく価値のある数字です。

早すぎる抽象化と、遅すぎる抽象化

早すぎる抽象化は、共通点を見誤ります。第20章のRenderTargetが共通化に値したのは、「カラー 1 枚 + 深度」がどんな用途でも同じ形だったからでした。 もし第20章の時点で「G-buffer も同じヘルパーで扱えるようにしよう」と欲張っていたら、 アタッチメントの枚数と形式を引数で受け取る関数になり、第21章の深度だけの FBO でも第23章の MRT でも「使わない引数」を渡し続けることになったはずです。まだ 1 例しか見ていないうちに一般形を決めると、2 例目が来たときに引数が増えるだけの 関数になります。

遅すぎる抽象化は、5 節の表がその証拠です。resizeIfNeededが 17 章に散らばり、しかも 12 章ではコメントと空白を除けば 1 文字も違わない(生バイトで比べると、この 12 章はコメント行の有無で 7 つのかたまりに割れます — 最大のかたまりは第16・18・19・22・31章の 5 章)。viewportをセットで呼ぶ行を忘れた章が実際に出ました(第32章)。同じものを 12 回書き写すと、11 回目まで正しくても 12 回目に間違えます。しかも直すときは 12 か所を直すことになります。

それでも、この章の main.ts はその 13 本目を書いています。resizeIfNeededはエンジンに入れず、gl.viewportを呼んでいるのはこの章でもmain.tsの中だけです(renderer.tsにもengine.tsにもviewportは 1 行もありません)。canvas の大きさと rAF の面倒を見るのはmain.tsの仕事、というのが 5 節で引いた境界だからです。Rendererが 1 か所にまとめたのはviewportではなくaspectのほうで、camera.update(gl.drawingBufferWidth / gl.drawingBufferHeight)という 1 行に閉じています(6 節の手順②)。境界を引くというのは、「入れたもの」と同じだけ「入れなかったもの」を決めることです。resizeIfNeededの 13 本目は、この章が引いた境界の外側に残った重複です。

src/lib/ へ上げた 3 度の共通化は、何が違ったのか

共通化を実施したのは3 度だけです — 第6章・第13〜14章・第20章(第20章 6 節の見出しがそのまま「3 度目の共通化」です)。上がったファイルはこの 3 度で合わせて5 本です。

この 3 度に共通するのは、次の 3 つです。

  1. 隠す API を、その章で全部自分の手で書いたあとだった。第20章 6 節は 「新しい API は出尽くしました」と 7 つ数えあげてから抽出しています。第14章 1 節は 「このファイルがルールに反しないのは、中に新しい WebGL API が 1 つもないから」と書いています
  2. 形が章をまたいで変わらなかった。Geometryは「頂点の属性の束 + 三角形のインデックス」で、第14章から第31章までずっと同じ形でした。RenderTargetも「カラー 1 枚 + 深度」で変わりませんでした。fullscreen-shader.tsにいたっては、startFullscreenShader(canvas, fragmentSource, options?)という呼び口のまま第2部(第7〜10章)と第4部(第24〜28章)の 9 章が使っています。逆にgbuffer.tsは「何を何ビットで何枚」が中身そのものなので、形が定まりません
  3. 忘れても叱ってもらえない手順を抱えていた。completeness チェック、 テクスチャのフィルタ設定、bindFramebufferとviewportをセットで切り替えること。「間違えても静かに壊れる」手順は、関数に閉じ込める価値が特に大きい— 第20章 6 節はそれを共通化の理由として明示しています

このミニエンジンは (1) と (3) を満たしますが、(2) を満たしません。 第35・36章が使わない以上、「章をまたいで同じ形で使われる」ことを確かめようがないからです。 だからこの章の中に置きます。

8. これは three.js の何にあたるのか

第1章の three.js 対応表で第34章に割り当てられている行は 1 行だけです — 「WebGLRendererとrender()」→「第3章(最小形) → 第34章(ミニエンジン)」。第3章の三角形 1 枚から始まって、この章でようやく「シーンを渡すと 1 枚描いてくれるもの」になった、という 地図です。表の他の行(BufferGeometry/PerspectiveCamera/OrbitControlsなど)はそれぞれ第3・13・14章、第11・12章、第15章に割り当てられていて、この章はそれらを組み合わせただけです。

対応の一覧はこのあとの「three.js との対応」にまとめました。1 つだけ先に補足します — three.js にも UBO の仕組みがあり、src/renderers/webgl/WebGLUniformsGroups.jsのprepareUniformsGroup()が、この章のcomputeStd140Layout()とほぼ同じことをしています — 16 バイトのチャンクを単位にしてメンバーごとの境界とサイズを引き、末尾を 16 の倍数へ 切り上げる(この章が CPU 側のバッファを切り上げた大きさで確保しているのと同じ判断です — 2 節)。ソースにはconst chunkSize = 16;という行があり、vec3のところにはinfo.boundary = 16; info.storage = 12;と「evil: vec3 must start on a 16-byte boundary but it only consumes 12 bytes」という コメントが付いています。2 節の 1 つ目の落とし穴と同じものを、同じ言い方で踏んでいるわけです。 同じファイルにはHint: STD140 is the only supported layout in WebGL 2というコメントもあり、3 節の pitfall で読んだ WebGL 2.0 仕様の規定と一致します (2026-08-08 に npm のthree@0.185.1を展開して確認)。

ただしthree.js の組み込みマテリアルは UBO を使いません。WebGLRenderer.jsがuniformsGroups.update()を呼ぶのはmaterial.uniformsGroupsがある場合だけで、それを持つのはShaderMaterialです(src/materials/ShaderMaterial.jsのthis.uniformsGroups = [];)。つまりUBO は「使いたい人が自分で組む」機能で、既定の経路はいまも素の uniform です。

行数で比べる

このエンジン(engine.ts+renderer.ts+uniform-buffer.ts)は1,135 行、空行と行コメントを除くと772 行です(main.tsはデモの組み立てなので数えていません)。同じ責務にあたる three.js 0.185.1 のファイル 13 本(WebGLRenderer.js/WebGLUniforms.js/WebGLState.js/WebGLUniformsGroups.js/UniformsGroup.js/Object3D.js/Scene.js/BufferGeometry.js/Material.js/Mesh.js/Camera.js/PerspectiveCamera.js/examples/jsm/controls/OrbitControls.js)は14,457 行、同じ数え方で6,312 行。総行数で 12.7 倍、コード行で 8.2 倍です。

数えたのは npm のthree@0.185.1を展開したもの(2026-08-08)。数え方は「総行数」と「空行と///*//*で始まる行を除いた行数」の 2 通りで、両方に同じ規則を当てています。再現スクリプトの 置き場は作業報告にあります。

このエンジンに無いもの

8 倍の差は、そのまま「無い機能」の一覧です。実際に無いものだけを挙げます。

コード全文

src/lessons/34-ubo-and-engine/uniform-buffer.ts
// 第34章のローカルモジュール: std140 のオフセット計算と、UBO のラッパ。
//
// src/lib/ ではなく章のディレクトリに置いている(第23章 gbuffer.ts・第29章 feedback.ts と
// 同じ判断。理由の一覧は本文 7 節)。
//
// このファイルの主役は computeStd140Layout() で、OpenGL ES 3.0.6 §2.12.6.4
// "Standard Uniform Block Layout" の規則 1〜10 をそのまま関数にしたもの。
// オフセットを手で数えて定数に書くと、ブロックの宣言を 1 行変えるたびに全部ずれる。
// そして verifyStd140Layout() で「自分の計算」と「GL が報告するオフセット」を突き合わせる。
// 手計算が合っていることを GL に確認させる、というのが本文 2 節の眼目。

/** この章で使う型だけ。ES 3.0.6 §2.12.6.4 の規則は他の型にも同じ形で書いてある */
export type Std140Type = 'float' | 'int' | 'vec2' | 'vec3' | 'vec4' | 'mat4';

export interface Std140Member {
  name: string;
  type: Std140Type;
  /** 配列なら要素数。省略したら配列ではない */
  length?: number;
}

export interface Std140Field {
  name: string;
  type: Std140Type;
  /** 0 = 配列ではない */
  length: number;
  /** ブロック先頭からのバイト位置 */
  offset: number;
  /** 規則 1〜3(配列は規則 4 で vec4 境界へ切り上げ) */
  baseAlignment: number;
  /** 配列でなければ 0 */
  arrayStride: number;
  /** 行列でなければ 0。mat4 は列 vec4 4 本なので 16 */
  matrixStride: number;
  /** 実際に値が入るバイト数(パディングを含まない) */
  usefulBytes: number;
}

export interface Std140Layout {
  blockName: string;
  fields: Std140Field[];
  byName: Map<string, Std140Field>;
  /** ブロック全体のバイト数(末尾を 16 の倍数へ切り上げた値。CPU 側はこの大きさで確保する) */
  size: number;
  /** 末尾を切り上げる前、最後のメンバーの終端まで。実装が返すサイズはこちらのことがある */
  unpaddedSize: number;
  /** 値が入っているバイト数の合計 */
  usefulBytes: number;
  /** size - usefulBytes。std140 のパディングで無駄になったバイト数 */
  paddingBytes: number;
}

/** basic machine unit = 1 バイト。float / int / uint はいずれも 4 バイト(ES 3.0.6 §2.12.6.3) */
const SCALAR_BYTES = 4;
/** vec4 の基本アライメント。規則 4・9 の「切り上げ先」 */
const VEC4_ALIGNMENT = 16;

/** 型 1 つが占めるバイト数(パディング抜き) */
function sizeOf(type: Std140Type): number {
  switch (type) {
    case 'float':
    case 'int':
      return SCALAR_BYTES;
    case 'vec2':
      return 2 * SCALAR_BYTES;
    case 'vec3':
      return 3 * SCALAR_BYTES;
    case 'vec4':
      return 4 * SCALAR_BYTES;
    case 'mat4':
      return 16 * SCALAR_BYTES;
  }
}

/**
 * 配列でない 1 要素の基本アライメント。
 * 規則 1: スカラーは N バイト。規則 2: 2 成分・4 成分ベクトルは 2N / 4N。
 * 規則 3: **3 成分ベクトルは 4N**(ここが std140 でいちばん踏まれる)。
 * 規則 5: 列優先の mat4 は「列 vec4 4 本の配列」と同じ扱いなので、規則 4 経由で 16。
 */
function baseAlignmentOf(type: Std140Type): number {
  switch (type) {
    case 'float':
    case 'int':
      return SCALAR_BYTES; // 規則 1
    case 'vec2':
      return 2 * SCALAR_BYTES; // 規則 2
    case 'vec3':
      return 4 * SCALAR_BYTES; // 規則 3
    case 'vec4':
      return 4 * SCALAR_BYTES; // 規則 2
    case 'mat4':
      return VEC4_ALIGNMENT; // 規則 5 → 規則 4
  }
}

function roundUp(value: number, alignment: number): number {
  return Math.ceil(value / alignment) * alignment;
}

/**
 * uniform ブロックのメンバー宣言から、std140 のオフセットとブロックのサイズを計算する。
 * 出典は OpenGL ES 3.0.6 §2.12.6.4 "Standard Uniform Block Layout" の規則 1〜10。
 *
 * ブロック全体は「基本オフセット 0 の構造体」として並べる、と §2.12.6.4 の本文が定めている。
 * 末尾のパディングについて仕様が明記しているのは「後続メンバーの基本オフセット」だけなので
 * (規則 9)、最上位ブロックの末尾を切り上げるかどうかは実装しだいになる。
 * そこで size(切り上げ済み)と unpaddedSize(切り上げ前)の両方を持たせ、
 * verifyStd140Layout() が UNIFORM_BLOCK_DATA_SIZE をどちらと一致するかで見分ける。
 * CPU 側のバッファは大きいほうの size で確保する — 本文 2 節。
 */
export function computeStd140Layout(blockName: string, members: Std140Member[]): Std140Layout {
  const fields: Std140Field[] = [];
  let cursor = 0;
  let usefulBytes = 0;

  for (const member of members) {
    const length = member.length ?? 0;
    const elementAlignment = baseAlignmentOf(member.type);

    if (length === 0) {
      const offset = roundUp(cursor, elementAlignment);
      const bytes = sizeOf(member.type);
      fields.push({
        name: member.name,
        type: member.type,
        length: 0,
        offset,
        baseAlignment: elementAlignment,
        arrayStride: 0,
        matrixStride: member.type === 'mat4' ? VEC4_ALIGNMENT : 0,
        usefulBytes: bytes,
      });
      cursor = offset + bytes;
      usefulBytes += bytes;
      continue;
    }

    // 規則 4: 配列の基本アライメントと配列ストライドは、1 要素の基本アライメントを
    // vec4 の基本アライメントへ切り上げた値になる。だから float[64] は 256 バイトではなく
    // 1024 バイト(1 要素 4 バイトの中身に、12 バイトの詰め物が付く)。
    // 規則 6: mat4 の配列は S×C 本の列ベクトルの並びなので、1 要素は 4 列 × 16 = 64 バイト
    const alignment = roundUp(elementAlignment, VEC4_ALIGNMENT);
    const arrayStride = member.type === 'mat4' ? 4 * VEC4_ALIGNMENT : alignment;
    const offset = roundUp(cursor, alignment);
    fields.push({
      name: member.name,
      type: member.type,
      length,
      offset,
      baseAlignment: alignment,
      arrayStride,
      matrixStride: member.type === 'mat4' ? VEC4_ALIGNMENT : 0,
      usefulBytes: sizeOf(member.type) * length,
    });
    // 規則 4: 配列の後ろのメンバーの基本オフセットは、配列の基本アライメントの倍数へ切り上げる
    cursor = roundUp(offset + arrayStride * length, alignment);
    usefulBytes += sizeOf(member.type) * length;
  }

  const unpaddedSize = cursor;
  const size = roundUp(cursor, VEC4_ALIGNMENT);
  return {
    blockName,
    fields,
    byName: new Map(fields.map((field) => [field.name, field])),
    size,
    unpaddedSize,
    usefulBytes,
    paddingBytes: size - usefulBytes,
  };
}

// ---------------------------------------------------------------------------
// GL への問い合わせによる検算
// ---------------------------------------------------------------------------

export interface Std140Check {
  /** オフセット・ストライドが GL とすべて一致したか(照合できなかったメンバーは判定に入れない) */
  ok: boolean;
  /** 手計算と GL の報告が食い違った箇所。**ブロック末尾のパディングぶんの差は含めない** */
  mismatches: string[];
  /**
   * GL が返したブロックのサイズが「末尾を切り上げる前の値」だったときの説明。
   * 食い違いではないので mismatches とは別枠にする(本文 2 節)。一致していたら null
   */
  tailPaddingNote: string | null;
  /** シェーダーが 1 度も読んでいないため GL からオフセットを引けなかったメンバー */
  inactive: string[];
  /** GL が報告したブロックのバイト数(UNIFORM_BLOCK_DATA_SIZE) */
  reportedSize: number;
}

/** getUniformIndices が「そんな uniform は無い」を表すために返す値(GL の INVALID_INDEX) */
const INVALID_INDEX = 0xffffffff;

/**
 * computeStd140Layout() の結果を、GL が報告する実際のオフセットと突き合わせる。
 *
 * 引くのは getUniformIndices → getActiveUniforms(UNIFORM_OFFSET / UNIFORM_ARRAY_STRIDE /
 * UNIFORM_MATRIX_STRIDE) と、getActiveUniformBlockParameter(UNIFORM_BLOCK_DATA_SIZE)。
 * 配列メンバーの名前は GL 側では `name[0]` になっていることがあるので両方試す。
 */
export function verifyStd140Layout(
  gl: WebGL2RenderingContext,
  program: WebGLProgram,
  layout: Std140Layout,
): Std140Check {
  const mismatches: string[] = [];
  const inactive: string[] = [];

  const blockIndex = gl.getUniformBlockIndex(program, layout.blockName);
  if (blockIndex === INVALID_INDEX) {
    return {
      ok: false,
      mismatches: [`ブロック ${layout.blockName} がアクティブではありません`],
      tailPaddingNote: null,
      inactive,
      reportedSize: 0,
    };
  }

  const reportedSize: number = gl.getActiveUniformBlockParameter(
    program,
    blockIndex,
    gl.UNIFORM_BLOCK_DATA_SIZE,
  );
  // ブロック末尾を 16 の倍数へ切り上げるかどうかは §2.12.6.4 に明文が無く、実装によって割れる。
  // 切り上げ前の値が返ってきたのなら「食い違い」ではない(メンバーのオフセットは
  // 1 つもずれていない)ので、mismatches ではなく別枠に出す。本文 2 節
  const sizeNote = `${layout.blockName} のサイズ 手計算 ${layout.size} / GL ${reportedSize}`;
  const roundsUpTail = reportedSize === layout.size;
  const omitsTail = reportedSize === layout.unpaddedSize;
  let tailPaddingNote: string | null = null;
  if (!roundsUpTail && omitsTail) {
    tailPaddingNote = sizeNote; // 末尾のパディングぶんだけの差。並びは合っている
  } else if (!roundsUpTail) {
    mismatches.push(sizeNote); // それ以外の差は本物の食い違い
  }

  // 配列は `name[0]` で登録されている実装があるので、素の名前と両方を並べて 1 回で引く
  const names = layout.fields.flatMap((field) =>
    field.length > 0 ? [field.name, `${field.name}[0]`] : [field.name],
  );
  const indices = gl.getUniformIndices(program, names);
  if (!indices) {
    return {
      ok: false,
      mismatches: [...mismatches, `${layout.blockName}: getUniformIndices が null を返しました`],
      tailPaddingNote,
      inactive,
      reportedSize,
    };
  }

  const wanted: { field: Std140Field; index: number }[] = [];
  let cursor = 0;
  for (const field of layout.fields) {
    const candidates = field.length > 0 ? 2 : 1;
    let found = INVALID_INDEX;
    for (let i = 0; i < candidates; i++) {
      if (indices[cursor + i] !== INVALID_INDEX) found = indices[cursor + i];
    }
    cursor += candidates;
    if (found === INVALID_INDEX) {
      // ここは仕様上は通らないはずの保険。ES 3.0.6 §2.12.6 が「shared または std140 の
      // ブロックのメンバーは、どのシェーダーからも参照されていなくても全部アクティブ」と
      // 定めていて、WebGL2 のブロックは必ず std140 だから(本文 2 節)。通ったら前提の
      // ほうが崩れているので、名前を控えて main.ts が警告する。
      // なお std140 のオフセットは宣言だけで決まるので、手計算のほうは引けなくても出せる
      inactive.push(field.name);
      continue;
    }
    wanted.push({ field, index: found });
  }

  if (wanted.length > 0) {
    const list = wanted.map((entry) => entry.index);
    const offsets: number[] = gl.getActiveUniforms(program, list, gl.UNIFORM_OFFSET);
    const arrayStrides: number[] = gl.getActiveUniforms(program, list, gl.UNIFORM_ARRAY_STRIDE);
    const matrixStrides: number[] = gl.getActiveUniforms(program, list, gl.UNIFORM_MATRIX_STRIDE);
    for (const [i, entry] of wanted.entries()) {
      const { field } = entry;
      if (offsets[i] !== field.offset) {
        mismatches.push(`${field.name}: offset 手計算 ${field.offset} / GL ${offsets[i]}`);
      }
      if (field.length > 0 && arrayStrides[i] !== field.arrayStride) {
        mismatches.push(
          `${field.name}: arrayStride 手計算 ${field.arrayStride} / GL ${arrayStrides[i]}`,
        );
      }
      if (field.matrixStride > 0 && matrixStrides[i] !== field.matrixStride) {
        mismatches.push(
          `${field.name}: matrixStride 手計算 ${field.matrixStride} / GL ${matrixStrides[i]}`,
        );
      }
    }
  }

  return { ok: mismatches.length === 0, mismatches, tailPaddingNote, inactive, reportedSize };
}

// ---------------------------------------------------------------------------
// UBO のラッパ
// ---------------------------------------------------------------------------

/**
 * 1 本の uniform ブロックに対応するバッファと、その CPU 側の写し。
 *
 * 書き込みは CPU 側の ArrayBuffer に対して行い、変更のあったバイト範囲だけを
 * upload() で bufferSubData する(本文 3 節の部分更新)。値が変わらなかったフレームは
 * bufferSubData を 1 回も呼ばない。
 */
export class UniformBlockBuffer {
  readonly layout: Std140Layout;
  readonly bindingPoint: number;
  readonly buffer: WebGLBuffer;
  private readonly words: ArrayBuffer;
  private readonly asFloat: Float32Array;
  private readonly asInt: Int32Array;
  /** 未アップロードの範囲 [dirtyStart, dirtyEnd) バイト。空なら dirtyStart >= dirtyEnd */
  private dirtyStart = 0;
  private dirtyEnd = 0;

  constructor(gl: WebGL2RenderingContext, layout: Std140Layout, bindingPoint: number) {
    this.layout = layout;
    this.bindingPoint = bindingPoint;
    this.words = new ArrayBuffer(layout.size);
    this.asFloat = new Float32Array(this.words);
    this.asInt = new Int32Array(this.words);

    this.buffer = gl.createBuffer();
    gl.bindBuffer(gl.UNIFORM_BUFFER, this.buffer);
    // 中身は毎フレーム書き換える前提。DYNAMIC_DRAW は「CPU が書いて GPU が読む」の意思表示。
    // 確保するのは切り上げ済みの layout.size。GL が報告するブロックのサイズがこれより
    // 小さいことがある(末尾を切り上げない実装)が、バッファが大きいぶんには問題にならない
    gl.bufferData(gl.UNIFORM_BUFFER, layout.size, gl.DYNAMIC_DRAW);
    // バッファをコンテキストのバインディングポイントに付ける。プログラム側の
    // uniformBlockBinding と、この番号で待ち合わせる(本文 3 節)
    gl.bindBufferBase(gl.UNIFORM_BUFFER, bindingPoint, this.buffer);
    gl.bindBuffer(gl.UNIFORM_BUFFER, null);
  }

  private field(name: string): Std140Field {
    const field = this.layout.byName.get(name);
    if (!field) {
      throw new Error(`${this.layout.blockName} に ${name} というメンバーはありません`);
    }
    return field;
  }

  private touch(offset: number, bytes: number): void {
    if (this.dirtyStart >= this.dirtyEnd) {
      this.dirtyStart = offset;
      this.dirtyEnd = offset + bytes;
      return;
    }
    this.dirtyStart = Math.min(this.dirtyStart, offset);
    this.dirtyEnd = Math.max(this.dirtyEnd, offset + bytes);
  }

  setFloat(name: string, value: number): void {
    const { offset } = this.field(name);
    this.asFloat[offset / 4] = value;
    this.touch(offset, 4);
  }

  setInt(name: string, value: number): void {
    const { offset } = this.field(name);
    this.asInt[offset / 4] = value;
    this.touch(offset, 4);
  }

  /** vec2 / vec3 / vec4 / mat4 をまとめて。source の長さぶんだけ書く */
  setFloats(name: string, source: ArrayLike<number>): void {
    const { offset } = this.field(name);
    this.asFloat.set(source, offset / 4);
    this.touch(offset, source.length * 4);
  }

  /**
   * vec4 の配列へ count 要素ぶん書く。std140 の vec4 配列はストライド 16 バイト =
   * 隙間なしなので、Float32Array をそのまま流し込める。
   * vec3 の配列だとここに 1 要素ずつのループが要る(ストライドが 16 で中身が 12 だから)
   */
  setVec4Array(name: string, source: Float32Array, count: number): void {
    const field = this.field(name);
    this.asFloat.set(source.subarray(0, count * 4), field.offset / 4);
    this.touch(field.offset, count * field.arrayStride);
  }

  /**
   * 変更のあった範囲だけを GPU へ送る。送ったバイト数を返す(0 なら bufferSubData を
   * 1 回も呼んでいない)。
   */
  upload(gl: WebGL2RenderingContext): number {
    if (this.dirtyStart >= this.dirtyEnd) return 0;
    const start = this.dirtyStart;
    const bytes = this.dirtyEnd - start;
    gl.bindBuffer(gl.UNIFORM_BUFFER, this.buffer);
    gl.bufferSubData(gl.UNIFORM_BUFFER, start, new Uint8Array(this.words, start, bytes));
    gl.bindBuffer(gl.UNIFORM_BUFFER, null);
    this.dirtyStart = 0;
    this.dirtyEnd = 0;
    return bytes;
  }

  dispose(gl: WebGL2RenderingContext): void {
    gl.bindBufferBase(gl.UNIFORM_BUFFER, this.bindingPoint, null);
    gl.deleteBuffer(this.buffer);
  }
}
src/lessons/34-ubo-and-engine/engine.ts
// 第34章のミニエンジン: 部品の定義(Shader / Material / Mesh / Node / Scene / Camera)。
// 1 フレームを描く Renderer は renderer.ts にある。
//
// このファイルも src/lib/ ではなく章のディレクトリに置いている。理由は本文 7 節。
// 中身は第13〜23章の main.ts に毎回書いてきたものを、責務ごとに名前を付けて並べ直しただけで、
// 新しい WebGL の API は 1 つも出てこない(UBO 関連は uniform-buffer.ts と renderer.ts)。

import { mat4, type ReadonlyMat3, type ReadonlyMat4, type ReadonlyVec3, vec3 } from 'gl-matrix';
import { type Geometry, interleave } from '../../lib/geometry';
import { compileShader, linkProgram } from '../../lib/shader';

const FLOAT_BYTES = Float32Array.BYTES_PER_ELEMENT;

// ---------------------------------------------------------------------------
// uniform の書き込み口 — 何回・何バイト渡したかを数えながら送る
// ---------------------------------------------------------------------------

/**
 * gl.uniform* の薄い包み。呼び出し回数と「アプリが WebGL に渡したバイト数」を数える。
 * この章の読み出し行はこの 2 つが主役なので、隠さずここに置いてある
 * (GPU 側で実際に何バイト動いたかは数えられない。数えているのは CPU 側が渡した量)。
 */
export class UniformWriter {
  calls = 0;
  bytes = 0;

  constructor(private readonly gl: WebGL2RenderingContext) {}

  reset(): void {
    this.calls = 0;
    this.bytes = 0;
  }

  int(location: WebGLUniformLocation | null, value: number): void {
    this.gl.uniform1i(location, value);
    this.calls++;
    this.bytes += 4;
  }

  float(location: WebGLUniformLocation | null, value: number): void {
    this.gl.uniform1f(location, value);
    this.calls++;
    this.bytes += 4;
  }

  vec2(location: WebGLUniformLocation | null, x: number, y: number): void {
    this.gl.uniform2f(location, x, y);
    this.calls++;
    this.bytes += 8;
  }

  vec3(location: WebGLUniformLocation | null, v: ReadonlyVec3): void {
    this.gl.uniform3f(location, v[0], v[1], v[2]);
    this.calls++;
    this.bytes += 12;
  }

  vec4Array(location: WebGLUniformLocation | null, data: Float32Array, count: number): void {
    const view = data.subarray(0, count * 4);
    this.gl.uniform4fv(location, view);
    this.calls++;
    this.bytes += view.byteLength;
  }

  mat3(location: WebGLUniformLocation | null, m: ReadonlyMat3): void {
    this.gl.uniformMatrix3fv(location, false, m); // transpose は常に false
    this.calls++;
    this.bytes += 9 * FLOAT_BYTES;
  }

  mat4(location: WebGLUniformLocation | null, m: ReadonlyMat4): void {
    this.gl.uniformMatrix4fv(location, false, m);
    this.calls++;
    this.bytes += 16 * FLOAT_BYTES;
  }
}

// ---------------------------------------------------------------------------
// Shader — プログラムと、名前から引いたロケーションのキャッシュ
// ---------------------------------------------------------------------------

/**
 * 持つもの: リンク済みのプログラム / uniform ロケーションのキャッシュ /
 * この program が持っている uniform ブロックの名前。
 * 持たないもの: シェーダーのソースの管理(`?raw` で読むのは呼び出し側の仕事)、
 * `#define` を差し替えたバリアントの生成、uniform の「値」。
 */
export class Shader {
  readonly program: WebGLProgram;
  readonly blockNames: ReadonlySet<string>;
  private readonly locations = new Map<string, WebGLUniformLocation | null>();

  constructor(
    private readonly gl: WebGL2RenderingContext,
    vertexSource: string,
    fragmentSource: string,
    readonly label: string,
  ) {
    this.program = linkProgram(
      gl,
      compileShader(gl, gl.VERTEX_SHADER, vertexSource),
      compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource),
    );
    // この program が持っている uniform ブロックを名前で控えておく。
    // Renderer は「Camera ブロックを持っているか」で配線の仕方を切り替える(本文 6 節)
    const names = new Set<string>();
    const count: number = gl.getProgramParameter(this.program, gl.ACTIVE_UNIFORM_BLOCKS);
    for (let i = 0; i < count; i++) {
      const name = gl.getActiveUniformBlockName(this.program, i);
      if (name) names.add(name);
    }
    this.blockNames = names;
  }

  /** ロケーションは名前の文字列検索なので 1 回だけ引いて覚える(第4章) */
  location(name: string): WebGLUniformLocation | null {
    const cached = this.locations.get(name);
    if (cached !== undefined) return cached;
    const location = this.gl.getUniformLocation(this.program, name);
    this.locations.set(name, location);
    return location;
  }

  hasBlock(blockName: string): boolean {
    return this.blockNames.has(blockName);
  }

  /** ブロックをバインディングポイントへ結ぶ。プログラム側の待ち合わせ場所の指定(本文 3 節) */
  bindBlock(blockName: string, bindingPoint: number): void {
    if (!this.hasBlock(blockName)) return;
    const index = this.gl.getUniformBlockIndex(this.program, blockName);
    this.gl.uniformBlockBinding(this.program, index, bindingPoint);
  }

  dispose(): void {
    this.gl.deleteProgram(this.program);
  }
}

// ---------------------------------------------------------------------------
// Material — シェーダー + そのマテリアル固有の値 + 描画状態
// ---------------------------------------------------------------------------

/** 深度とカリングの状態。ブレンドはこの章のシーンに半透明が無いので持たせていない */
export interface RenderState {
  depthTest: boolean;
  depthWrite: boolean;
  /** 裏面カリング(gl.BACK)を有効にするか。第13章 5 節の CCW 前提 */
  cullBackFace: boolean;
}

export const OPAQUE_STATE: RenderState = {
  depthTest: true,
  depthWrite: true,
  cullBackFace: true,
};

export interface MaterialUniforms {
  /** リニア空間の基本色 */
  baseColor: ReadonlyVec3;
  specularColor: ReadonlyVec3;
  shininess: number;
  /** 自己発光(リニア)。光が当たらなくても見える量 */
  emissive: ReadonlyVec3;
}

/**
 * 持つもの: どのシェーダーで描くか / そのマテリアル固有の uniform / 描画状態。
 * 持たないもの: モデル行列(それは Node の役)。ジオメトリ(それは Mesh の役)。
 *
 * 深度テストを Material に持たせたのは、「半透明は深度書き込みを切る」のように
 * 状態が材質と一緒に決まるからで、Renderer 側に持たせると
 * 「このメッシュだけ深度を切る」が表現できなくなる(本文 5 節)。
 */
export class Material {
  constructor(
    readonly shader: Shader,
    readonly uniforms: MaterialUniforms,
    readonly state: RenderState = OPAQUE_STATE,
  ) {}

  /** 自分の uniform を送る。Renderer が「マテリアルが変わったとき」だけ呼ぶ */
  apply(writer: UniformWriter): void {
    writer.vec3(this.shader.location('u_baseColor'), this.uniforms.baseColor);
    writer.vec3(this.shader.location('u_specularColor'), this.uniforms.specularColor);
    writer.float(this.shader.location('u_shininess'), this.uniforms.shininess);
    writer.vec3(this.shader.location('u_emissive'), this.uniforms.emissive);
  }
}

// ---------------------------------------------------------------------------
// Mesh — VAO + インデックス数 + 描画モード
// ---------------------------------------------------------------------------

/**
 * 持つもの: VAO / インデックス数 / 描画モード。
 * 持たないもの: 位置も色も材質も持たない。同じ Mesh を何個の Node が指してもよい。
 */
export class Mesh {
  constructor(
    private readonly gl: WebGL2RenderingContext,
    readonly vao: WebGLVertexArrayObject,
    readonly indexCount: number,
    readonly buffers: WebGLBuffer[],
    readonly mode: GLenum,
  ) {}

  /** src/lib/geometry.ts の Geometry(第14章)から作る。手順は第13章のまま */
  static fromGeometry(gl: WebGL2RenderingContext, geometry: Geometry): Mesh {
    const vao = gl.createVertexArray();
    gl.bindVertexArray(vao);

    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);

    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);
    gl.bindBuffer(gl.ARRAY_BUFFER, null);
    return new Mesh(gl, vao, geometry.indices.length, [vbo, ibo], gl.TRIANGLES);
  }

  dispose(): void {
    this.gl.deleteVertexArray(this.vao);
    for (const buffer of this.buffers) this.gl.deleteBuffer(buffer);
  }
}

// ---------------------------------------------------------------------------
// Node / Scene — 親子のモデル行列
// ---------------------------------------------------------------------------

/**
 * 持つもの: ローカル行列 / ワールド行列 / 親子関係 / 描くものがあれば Mesh と Material。
 * 持たないもの: 描き方。ノードは自分を描かない(描くのは Renderer)。
 *
 * 構図は glTF のノード階層(第19章 7 節)と同じ。親のワールド行列に自分のローカル行列を
 * 掛けたものが自分のワールド行列で、それを根から葉へ 1 回のたどりで配る。
 */
export class Node {
  readonly local = mat4.create();
  readonly world = mat4.create();
  readonly children: Node[] = [];
  parent: Node | null = null;
  mesh: Mesh | null = null;
  material: Material | null = null;

  constructor(readonly name: string) {}

  add(child: Node): Node {
    child.parent = this;
    this.children.push(child);
    return child;
  }

  /**
   * 根から呼ぶと、木全体のワールド行列が揃う。
   * gl-matrix の mat4.multiply(out, a, b) は out = a × b なので、親が左。
   * 逆にすると「子の座標系で親を動かす」ことになり、公転が自転にすり替わる
   */
  updateWorldMatrix(parentWorld: ReadonlyMat4 | null): void {
    if (parentWorld) {
      mat4.multiply(this.world, parentWorld, this.local);
    } else {
      mat4.copy(this.world, this.local);
    }
    for (const child of this.children) {
      child.updateWorldMatrix(this.world);
    }
  }
}

export interface Light {
  /** ワールド座標。ノードに追従させたければ毎フレーム書き換える */
  position: vec3;
  /** 影響半径。これより遠いフラグメントは計算しない(第23章と同じパック) */
  radius: number;
  /** リニア空間の色 */
  color: ReadonlyVec3;
  intensity: number;
}

/**
 * シーンは「根のノード + 光源のリスト」。カメラは持たない(描くたびに渡す)。
 * 環境光やフォグは Frame ブロックの持ち物にしてある — シーンごとではなく
 * 「そのフレームの見え方」の設定だから(本文 4 節)
 */
export class Scene extends Node {
  readonly lights: Light[] = [];
}

// ---------------------------------------------------------------------------
// Camera — 第15章の軌道カメラを「状態 + 更新ルール」にしたもの
// ---------------------------------------------------------------------------

/**
 * 持つもの: 注視点と球面座標の状態、視野角・near・far、そこから作る view / projection。
 * 持たないもの: 入力。pointer と wheel をどう繋ぐかは main.ts の仕事で、
 * この部品は「orbit の 3 つの数を書き換えられたら行列を作り直す」だけ。
 * 軌道カメラそのものの解説は第15章。
 */
export class Camera {
  readonly orbit = { theta: 0.7, phi: 0.35, radius: 12 };
  readonly target = vec3.fromValues(0, 0, 0);
  readonly position = vec3.create();
  readonly view = mat4.create();
  readonly projection = mat4.create();

  constructor(
    readonly fovy: number,
    readonly near: number,
    readonly far: number,
  ) {}

  /** aspect は毎フレーム描画先の幅 / 高さから渡す(第3部の規約) */
  update(aspect: number): void {
    const { theta, phi, radius } = this.orbit;
    this.position[0] = this.target[0] + radius * Math.cos(phi) * Math.sin(theta);
    this.position[1] = this.target[1] + radius * Math.sin(phi);
    this.position[2] = this.target[2] + radius * Math.cos(phi) * Math.cos(theta);
    mat4.lookAt(this.view, this.position, this.target, UP);
    mat4.perspective(this.projection, this.fovy, aspect, this.near, this.far);
  }
}

const UP: ReadonlyVec3 = vec3.fromValues(0, 1, 0);
src/lessons/34-ubo-and-engine/renderer.ts
// 第34章のミニエンジン: 1 フレームを描く Renderer と、UBO の 3 層の定義。
//
// engine.ts から分けてあるのは、部品の定義(何を持つか)と 1 フレームの手順(いつ何をするか)が
// 別の話だから。ファイルはこの 2 本までにしてある。
//
// UBO は更新頻度で 3 層に切ってある(本文 4 節)。
//   Frame  (binding 0) — 環境光・露出・フォグ。値が変わったときだけ書く
//   Camera (binding 1) — view / projection / カメラ位置。毎フレーム書く
//   Lights (binding 2) — 光源の配列。位置だけ毎フレーム、色と本数は変わったときだけ
// オブジェクトごとの値(モデル行列・材質)は UBO に入れていない。理由は本文 4 節。

import { mat3, type ReadonlyVec3, vec3 } from 'gl-matrix';
import {
  type Camera,
  type Material,
  type Node,
  type Scene,
  type Shader,
  UniformWriter,
} from './engine';
import {
  computeStd140Layout,
  type Std140Check,
  type Std140Layout,
  UniformBlockBuffer,
  verifyStd140Layout,
} from './uniform-buffer';

/** lit.frag / plain.frag の #define MAX_LIGHTS と必ず同じ値にする */
export const MAX_LIGHTS = 64;

/**
 * バインディングポイントの番号。GLSL 側には書けない — GLSL ES 3.00 の uniform ブロックに
 * layout(binding = N) は無い(§4.3.8.3 が許すのはレイアウトの指定だけ)。
 * だから配線は CPU 側の uniformBlockBinding でやるしかない(本文 3 節)
 */
export const FRAME_BINDING = 0;
export const CAMERA_BINDING = 1;
export const LIGHTS_BINDING = 2;

// ブロックの宣言と、この 3 つのメンバー表は 1 対 1 に対応していること。
// 順番が 1 つでも違えばオフセットがずれる(そしてそれは verifyStd140Layout が見つける)
export const FRAME_LAYOUT: Std140Layout = computeStd140Layout('Frame', [
  { name: 'u_ambientColor', type: 'vec3' },
  { name: 'u_exposure', type: 'float' },
  { name: 'u_fogRange', type: 'vec2' },
  { name: 'u_fogColor', type: 'vec3' },
]);

export const CAMERA_LAYOUT: Std140Layout = computeStd140Layout('Camera', [
  { name: 'u_view', type: 'mat4' },
  { name: 'u_projection', type: 'mat4' },
  { name: 'u_cameraPosition', type: 'vec3' },
]);

export const LIGHTS_LAYOUT: Std140Layout = computeStd140Layout('Lights', [
  { name: 'u_lightPositionRadius', type: 'vec4', length: MAX_LIGHTS },
  { name: 'u_lightColorIntensity', type: 'vec4', length: MAX_LIGHTS },
  { name: 'u_lightCount', type: 'int' },
]);

export const ALL_LAYOUTS: readonly Std140Layout[] = [FRAME_LAYOUT, CAMERA_LAYOUT, LIGHTS_LAYOUT];

/** 1 フレームで数えられた量。時間は測らない(GPU 時間は第35章) */
export interface FrameStats {
  meshes: number;
  drawCalls: number;
  /** gl.uniform* の呼び出し回数の合計 */
  uniformCalls: number;
  /** そのうち、プログラムごとに払う共有ぶん */
  sharedUniformCalls: number;
  /** アプリが gl.uniform* に渡したバイト数 */
  uniformBytes: number;
  bufferSubDataCalls: number;
  bufferSubDataBytes: number;
  /** このフレームで useProgram を呼んだ回数 */
  programSwitches: number;
  /** マテリアルの uniform を送り直した回数 */
  materialSwitches: number;
  /** このフレームで使ったプログラムの本数 */
  programs: number;
}

export interface FrameUniforms {
  ambientColor: ReadonlyVec3;
  exposure: number;
  fogNear: number;
  fogFar: number;
  fogColor: ReadonlyVec3;
}

interface DrawItem {
  node: Node;
  material: Material;
}

// 並べ替えの比較キーに使う、生成順の通し番号。オブジェクトそのものは順序を持たないので
let nextSortId = 1;
const sortIds = new WeakMap<object, number>();
function sortId(target: object): number {
  let id = sortIds.get(target);
  if (id === undefined) {
    id = nextSortId++;
    sortIds.set(target, id);
  }
  return id;
}

export class Renderer {
  readonly frameBlock: UniformBlockBuffer;
  readonly cameraBlock: UniformBlockBuffer;
  readonly lightsBlock: UniformBlockBuffer;
  /** プログラム → マテリアル → メッシュの順に並べ替えるか(本文 6 節) */
  sortBatches = true;

  private readonly writer: UniformWriter;
  private readonly registered = new Set<Shader>();
  private readonly items: DrawItem[] = [];
  private readonly normalMatrix = mat3.create();
  // ブロックへ書く前の中継。ブロックを持たないシェーダーにも同じ値を配るので、
  // 「UBO の中身」ではなく「エンジンが持っている値」としてここに置いてある
  private readonly lightPositionRadius = new Float32Array(MAX_LIGHTS * 4);
  private readonly lightColorIntensity = new Float32Array(MAX_LIGHTS * 4);
  private lightCount = 0;
  private frameUniforms: FrameUniforms = {
    ambientColor: vec3.fromValues(0.03, 0.035, 0.045),
    exposure: 1,
    fogNear: 20,
    fogFar: 60,
    fogColor: vec3.fromValues(0.06, 0.07, 0.09),
  };

  constructor(private readonly gl: WebGL2RenderingContext) {
    this.writer = new UniformWriter(gl);
    this.frameBlock = new UniformBlockBuffer(gl, FRAME_LAYOUT, FRAME_BINDING);
    this.cameraBlock = new UniformBlockBuffer(gl, CAMERA_LAYOUT, CAMERA_BINDING);
    this.lightsBlock = new UniformBlockBuffer(gl, LIGHTS_LAYOUT, LIGHTS_BINDING);
  }

  /** UBO 3 本のバイト数の合計 */
  get uboBytes(): number {
    return FRAME_LAYOUT.size + CAMERA_LAYOUT.size + LIGHTS_LAYOUT.size;
  }

  /** そのうち std140 のパディングで無駄になったバイト数 */
  get uboPaddingBytes(): number {
    return FRAME_LAYOUT.paddingBytes + CAMERA_LAYOUT.paddingBytes + LIGHTS_LAYOUT.paddingBytes;
  }

  /**
   * 値が変わったときだけ呼ぶ。毎フレーム呼ばないので、Frame ブロックの bufferSubData は
   * 「変わったフレーム」にしか出ない — 更新頻度で層を分けた効果がそのまま読み出し行に出る
   */
  setFrameUniforms(uniforms: FrameUniforms): void {
    this.frameUniforms = uniforms;
    this.frameBlock.setFloats('u_ambientColor', uniforms.ambientColor);
    this.frameBlock.setFloat('u_exposure', uniforms.exposure);
    this.frameBlock.setFloats('u_fogRange', [uniforms.fogNear, uniforms.fogFar]);
    this.frameBlock.setFloats('u_fogColor', uniforms.fogColor);
  }

  /** 光源の色と本数。位置は毎フレーム render() の中で書き直す */
  setLights(scene: Scene): void {
    this.lightCount = Math.min(scene.lights.length, MAX_LIGHTS);
    for (let i = 0; i < this.lightCount; i++) {
      const light = scene.lights[i];
      this.lightColorIntensity[i * 4] = light.color[0];
      this.lightColorIntensity[i * 4 + 1] = light.color[1];
      this.lightColorIntensity[i * 4 + 2] = light.color[2];
      this.lightColorIntensity[i * 4 + 3] = light.intensity;
    }
    this.lightsBlock.setVec4Array(
      'u_lightColorIntensity',
      this.lightColorIntensity,
      this.lightCount,
    );
    this.lightsBlock.setInt('u_lightCount', this.lightCount);
  }

  /** 手計算した std140 のオフセットを、GL が報告する値と突き合わせる(本文 2 節) */
  checkLayouts(shader: Shader): Std140Check[] {
    const checks: Std140Check[] = [];
    for (const layout of ALL_LAYOUTS) {
      if (!shader.hasBlock(layout.blockName)) continue;
      this.registerShader(shader);
      checks.push(verifyStd140Layout(this.gl, shader.program, layout));
    }
    return checks;
  }

  render(scene: Scene, camera: Camera, clearColor: ReadonlyVec3): FrameStats {
    const gl = this.gl;
    this.writer.reset();

    // ① ワールド行列の更新。根から 1 回たどるだけで木全体が揃う
    scene.updateWorldMatrix(null);

    // ② カメラ。aspect は描画先の幅 / 高さ(第3部の規約)
    camera.update(gl.drawingBufferWidth / gl.drawingBufferHeight);

    // ③ 描くものを集める(木のたどり順のまま)
    this.items.length = 0;
    collect(scene, this.items);

    // ④ 状態の切り替えを減らす並べ替え。プログラム → マテリアル の順(本文 6 節)。
    // sort は安定なので、同じマテリアルの中では木のたどり順が残る
    if (this.sortBatches) {
      this.items.sort((a, b) => {
        const shaderDiff = sortId(a.material.shader) - sortId(b.material.shader);
        if (shaderDiff !== 0) return shaderDiff;
        return sortId(a.material) - sortId(b.material);
      });
    }

    // ⑤ UBO の更新。Camera は毎フレーム、Lights は位置だけ、Frame は変わったときだけ。
    // このフレームにブロックを読むプログラムが 1 本も無ければ、更新そのものを飛ばす
    // (デモ 1 の「素の uniform」側が、使わない UBO の更新まで数えてしまわないように)
    let bufferSubDataCalls = 0;
    let bufferSubDataBytes = 0;
    if (this.items.some((item) => item.material.shader.hasBlock('Camera'))) {
      this.cameraBlock.setFloats('u_view', camera.view);
      this.cameraBlock.setFloats('u_projection', camera.projection);
      this.cameraBlock.setFloats('u_cameraPosition', camera.position);
      for (let i = 0; i < this.lightCount; i++) {
        const light = scene.lights[i];
        this.lightPositionRadius[i * 4] = light.position[0];
        this.lightPositionRadius[i * 4 + 1] = light.position[1];
        this.lightPositionRadius[i * 4 + 2] = light.position[2];
        this.lightPositionRadius[i * 4 + 3] = light.radius;
      }
      this.lightsBlock.setVec4Array(
        'u_lightPositionRadius',
        this.lightPositionRadius,
        this.lightCount,
      );
      for (const block of [this.frameBlock, this.cameraBlock, this.lightsBlock]) {
        const bytes = block.upload(gl);
        if (bytes > 0) {
          bufferSubDataCalls++;
          bufferSubDataBytes += bytes;
        }
      }
    } else {
      // 素の uniform で送る側にも、同じ光源の値が要る
      for (let i = 0; i < this.lightCount; i++) {
        const light = scene.lights[i];
        this.lightPositionRadius[i * 4] = light.position[0];
        this.lightPositionRadius[i * 4 + 1] = light.position[1];
        this.lightPositionRadius[i * 4 + 2] = light.position[2];
        this.lightPositionRadius[i * 4 + 3] = light.radius;
      }
    }

    // ⑥ 描く
    gl.clearColor(clearColor[0], clearColor[1], clearColor[2], 1);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    let currentShader: Shader | null = null;
    let currentMaterial: Material | null = null;
    let programSwitches = 0;
    let materialSwitches = 0;
    let sharedUniformCalls = 0;
    let drawCalls = 0;
    const usedPrograms = new Set<Shader>();

    for (const item of this.items) {
      const { material, node } = item;
      const mesh = node.mesh;
      if (!mesh) continue;

      if (material.shader !== currentShader) {
        currentShader = material.shader;
        usedPrograms.add(currentShader);
        gl.useProgram(currentShader.program);
        programSwitches++;
        this.registerShader(currentShader);
        const before = this.writer.calls;
        this.bindShared(currentShader, camera);
        sharedUniformCalls += this.writer.calls - before;
        currentMaterial = null;
      }

      if (material !== currentMaterial) {
        currentMaterial = material;
        materialSwitches++;
        applyState(gl, material);
        material.apply(this.writer);
      }

      mat3.normalFromMat4(this.normalMatrix, node.world); // 法線行列(第17章)
      this.writer.mat4(material.shader.location('u_model'), node.world);
      this.writer.mat3(material.shader.location('u_normalMatrix'), this.normalMatrix);

      gl.bindVertexArray(mesh.vao);
      gl.drawElements(mesh.mode, mesh.indexCount, gl.UNSIGNED_SHORT, 0);
      drawCalls++;
    }
    gl.bindVertexArray(null);

    return {
      meshes: this.items.length,
      drawCalls,
      uniformCalls: this.writer.calls,
      sharedUniformCalls,
      uniformBytes: this.writer.bytes,
      bufferSubDataCalls,
      bufferSubDataBytes,
      programSwitches,
      materialSwitches,
      programs: usedPrograms.size,
    };
  }

  /**
   * プログラムのブロックをバインディングポイントへ結ぶ。リンクのたびに 1 回でよい
   * (逆に言えば、リンクし直したら必ずやり直す — 再リンクでバインディングは 0 に
   * リセットされる。ES 3.0.6 §2.12.6.5)
   */
  private registerShader(shader: Shader): void {
    if (this.registered.has(shader)) return;
    for (const layout of ALL_LAYOUTS) {
      shader.bindBlock(layout.blockName, bindingOf(layout));
    }
    this.registered.add(shader);
  }

  /**
   * 共有の値をシェーダーへ届ける。
   * UBO を持つプログラムには何もしなくてよい — バッファはコンテキストのバインディング
   * ポイントに付いていて、プログラムのブロックはそこを見にいくから。
   * ブロックを持たないプログラム(plain.vert / plain.frag)には、ここで 1 本ずつ送る。
   * この 2 本の差が、本文 1 節で数えた「配る手間」そのもの
   */
  private bindShared(shader: Shader, camera: Camera): void {
    if (shader.hasBlock('Camera')) return;
    const frame = this.frameUniforms;
    this.writer.mat4(shader.location('u_view'), camera.view);
    this.writer.mat4(shader.location('u_projection'), camera.projection);
    this.writer.vec3(shader.location('u_cameraPosition'), camera.position);
    this.writer.vec3(shader.location('u_ambientColor'), frame.ambientColor);
    this.writer.float(shader.location('u_exposure'), frame.exposure);
    this.writer.vec2(shader.location('u_fogRange'), frame.fogNear, frame.fogFar);
    this.writer.vec3(shader.location('u_fogColor'), frame.fogColor);
    this.writer.vec4Array(
      shader.location('u_lightPositionRadius'),
      this.lightPositionRadius,
      this.lightCount,
    );
    this.writer.vec4Array(
      shader.location('u_lightColorIntensity'),
      this.lightColorIntensity,
      this.lightCount,
    );
    this.writer.int(shader.location('u_lightCount'), this.lightCount);
  }

  dispose(): void {
    this.frameBlock.dispose(this.gl);
    this.cameraBlock.dispose(this.gl);
    this.lightsBlock.dispose(this.gl);
    this.registered.clear();
  }
}

function bindingOf(layout: Std140Layout): number {
  if (layout === FRAME_LAYOUT) return FRAME_BINDING;
  if (layout === CAMERA_LAYOUT) return CAMERA_BINDING;
  return LIGHTS_BINDING;
}

function collect(node: Node, out: DrawItem[]): void {
  if (node.mesh && node.material) {
    out.push({ node, material: node.material });
  }
  for (const child of node.children) collect(child, out);
}

function applyState(gl: WebGL2RenderingContext, material: Material): void {
  const state = material.state;
  if (state.depthTest) gl.enable(gl.DEPTH_TEST);
  else gl.disable(gl.DEPTH_TEST);
  gl.depthMask(state.depthWrite);
  if (state.cullBackFace) {
    gl.enable(gl.CULL_FACE);
    gl.cullFace(gl.BACK);
  } else {
    gl.disable(gl.CULL_FACE);
  }
}
src/lessons/34-ubo-and-engine/main.ts
// 第34章: UBO と抽象化 — ミニエンジンを設計する
//
// このファイルは「デモの組み立てとボタン」だけを持つ。描画そのものは
// engine.ts(部品)と renderer.ts(1 フレームの手順)にある。
//
// デモは 3 本。
//   ① uniform を数える — 同じ絵を「素の uniform」と「UBO」で描き分ける。
//      違うのはシェーダーの uniform の受け取り方だけで、main() は 1 文字も同じ
//   ② 多光源 — 光源を 4 / 16 / 64 灯に増やす。UBO 1 本で配る
//   ③ ミニエンジン — ノード階層で自転・公転するシーンを、部品を組んで描く
//
// 読み出し行に出るのは全部「数えられる量」。GPU 時間は測っていない(第35章)。

import { mat4, type ReadonlyVec3, vec3 } from 'gl-matrix';
import { createPlane, createSphere, createTorus } from '../../lib/geometry';
import colorChunkSource from './color.glsl?raw';
import { Camera, type Light, Material, Mesh, Node, OPAQUE_STATE, Scene, Shader } from './engine';
import litFragmentTemplate from './lit.frag?raw';
import litVertexSource from './lit.vert?raw';
import plainFragmentTemplate from './plain.frag?raw';
import plainVertexSource from './plain.vert?raw';
import { type FrameStats, MAX_LIGHTS, Renderer } from './renderer';

// #include の解決は第23章からの文字列置換。置換文字列を関数で渡しているのは、
// String.replace が `$&` などを特別扱いするため
function resolveIncludes(source: string): string {
  return source.replace('#include "color.glsl"', () => colorChunkSource.trim());
}

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

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

// canvas の CSS 背景色と同じ。フォグの行き先もこの色にしてある
const BACKGROUND_COLOR: ReadonlyVec3 = vec3.fromValues(0.06, 0.07, 0.09);
const AMBIENT_COLOR: ReadonlyVec3 = vec3.fromValues(0.03, 0.035, 0.045);

const GRID = 4; // 4 × 4 = 16 個 + 床 = 17 メッシュ
const GRID_SPACING = 2.6;
const FLOOR_Y = -1.15;

/** 自己発光しないマテリアル用 */
const NO_EMISSION: ReadonlyVec3 = vec3.fromValues(0, 0, 0);

/** 「階層を見る」の数珠 1 粒の大きさ */
const BEAD_SCALE: ReadonlyVec3 = vec3.fromValues(0.4, 0.4, 0.4);

/** ワールド行列の平行移動成分 = そのノードのワールド原点(列優先なので 12・13・14 番) */
function worldOrigin(node: Node, out: vec3): vec3 {
  return vec3.set(out, node.world[12], node.world[13], node.world[14]);
}

const LIGHT_COUNTS = [4, 16, 64] as const;
// 光源を増やすと画面全体が明るくなるので、露出で釣り合いを取る。
// 露出は Frame ブロックの値なので、ここを変えたフレームだけ Frame の bufferSubData が出る
const LIGHT_EXPOSURES = [1.0, 0.62, 0.34] as const;

// ---------------------------------------------------------------------------
// デモ
// ---------------------------------------------------------------------------

interface Demo {
  id: 'count' | 'lights' | 'engine';
  label: string;
  options: readonly string[];
  camera: { theta: number; phi: number; radius: number };
}

const demos: Demo[] = [
  {
    id: 'count',
    label: 'uniform を数える',
    options: ['素の uniform', 'UBO'],
    camera: { theta: 0.62, phi: 0.38, radius: 13.5 },
  },
  {
    id: 'lights',
    label: '多光源',
    options: ['4 灯', '16 灯', '64 灯'],
    camera: { theta: 0.62, phi: 0.42, radius: 15 },
  },
  {
    id: 'engine',
    label: 'ミニエンジン',
    options: ['通常', '階層を見る'],
    // 公転の外周(6.2)が canvas(3:2)の横幅に収まり、球の見かけの大きさが
    // デモ 1・2 と揃う距離。half-height = radius × tan(fovy/2) = 13.5 × 0.414 ≒ 5.6
    camera: { theta: 0.8, phi: 0.42, radius: 13.5 },
  },
];

/** 見た目を毎回同じにするための、ごく小さい線形合同法の擬似乱数(第25章のハッシュとは別物) */
function createRandom(seed: number): () => number {
  let state = seed >>> 0;
  return () => {
    state = (state * 1664525 + 1013904223) >>> 0;
    return state / 4294967296;
  };
}

/** 格子のシーンに散らす光源。位置は毎フレーム動かすので、ここでは公転の設定だけ作る */
interface OrbitingLight extends Light {
  origin: vec3;
  orbitRadius: number;
  speed: number;
  phase: number;
}

function createGridLights(count: number): OrbitingLight[] {
  const random = createRandom(34340821);
  const lights: OrbitingLight[] = [];
  for (let i = 0; i < count; i++) {
    const hue = (i / count) * Math.PI * 2;
    lights.push({
      position: vec3.create(),
      radius: 5.5,
      color: vec3.fromValues(
        0.5 + 0.5 * Math.sin(hue),
        0.5 + 0.5 * Math.sin(hue + 2.094),
        0.5 + 0.5 * Math.sin(hue + 4.189),
      ),
      intensity: 5.5,
      origin: vec3.fromValues(
        (random() - 0.5) * 10,
        FLOOR_Y + 0.5 + random() * 2.2,
        (random() - 0.5) * 10,
      ),
      orbitRadius: 0.7 + random() * 1.3,
      speed: 0.25 + random() * 0.6,
      phase: random() * Math.PI * 2,
    });
  }
  return lights;
}

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

function setup(
  gl: WebGL2RenderingContext,
  canvas: HTMLCanvasElement,
  controls: HTMLParagraphElement,
  optionControls: HTMLParagraphElement,
  readout: HTMLParagraphElement,
): void {
  // --- 部品を組む -------------------------------------------------------------

  const litShader = new Shader(
    gl,
    litVertexSource,
    resolveIncludes(litFragmentTemplate),
    'lit(UBO)',
  );
  const plainShader = new Shader(
    gl,
    plainVertexSource,
    resolveIncludes(plainFragmentTemplate),
    'plain(素の uniform)',
  );

  const renderer = new Renderer(gl);

  // 手計算した std140 のオフセットと、GL が報告するオフセットの突き合わせ。
  // 起動時に 1 回だけ。合っていなければ読み出し行とコンソールの両方に出す(本文 2 節)。
  // ブロック末尾のパディングだけの差は「食い違い」ではないので、NG にせず別枠で出す
  const checks = renderer.checkLayouts(litShader);
  const mismatches = checks.flatMap((check) => check.mismatches);
  const inactive = checks.flatMap((check) => check.inactive);
  const tailPadding = checks.flatMap((check) => check.tailPaddingNote ?? []);
  const layoutNote =
    mismatches.length > 0
      ? `std140 検算 NG: ${mismatches.join(' / ')}`
      : tailPadding.length === 0
        ? `std140 検算 OK(${checks.length} ブロックのオフセット・ストライド・サイズが手計算と一致)`
        : `std140 検算 OK(${checks.length} ブロックのオフセット・ストライドが手計算と一致)` +
          `/ ブロック末尾のパディングを除いて一致: ${tailPadding.join('・')}(この実装は末尾を 16 の倍数へ切り上げません)`;
  if (mismatches.length > 0) console.error(layoutNote);
  if (inactive.length > 0) {
    // std140 のブロックのメンバーは全部アクティブなので、ここは通らないはず(本文 2 節)
    console.warn(`GL からオフセットを引けなかったメンバー: ${inactive.join(', ')}`);
  }

  // メッシュは全デモで共有する。Mesh は位置も材質も持たないので、
  // 同じものを何個の Node が指してもよい(本文 5 節)
  const floorMesh = Mesh.fromGeometry(gl, createPlane(30, 30));
  const sphereMesh = Mesh.fromGeometry(gl, createSphere(0.8, 40, 20));
  const torusMesh = Mesh.fromGeometry(gl, createTorus(0.6, 0.24, 48, 24));
  const smallSphereMesh = Mesh.fromGeometry(gl, createSphere(0.28, 20, 12));

  interface MaterialSet {
    floor: Material;
    matte: Material;
    glossy: Material;
    /** デモ 3 の中心の球。自己発光を持つ理由は createMaterials のコメント */
    sun: Material;
    marker: Material[];
  }

  /** 同じ材質を、UBO 版と素の uniform 版の 2 つのシェーダーぶん作る */
  function createMaterials(shader: Shader): MaterialSet {
    // 階層のマーカーは自己発光。光の当たらない場所に置かれても見えないと意味がないので、
    // baseColor ではなく emissive を持たせている
    const marker = [
      vec3.fromValues(0.7, 0.7, 0.75),
      vec3.fromValues(0.85, 0.4, 0.12),
      vec3.fromValues(0.16, 0.6, 0.85),
      vec3.fromValues(0.45, 0.8, 0.3),
    ].map(
      (color) =>
        new Material(
          shader,
          {
            baseColor: vec3.fromValues(0, 0, 0),
            specularColor: vec3.fromValues(0, 0, 0),
            shininess: 1,
            emissive: color,
          },
          OPAQUE_STATE,
        ),
    );
    return {
      floor: new Material(shader, {
        baseColor: vec3.fromValues(0.07, 0.075, 0.085),
        specularColor: vec3.fromValues(0.05, 0.05, 0.05),
        shininess: 12,
        emissive: NO_EMISSION,
      }),
      matte: new Material(shader, {
        baseColor: vec3.fromValues(0.32, 0.42, 0.6),
        specularColor: vec3.fromValues(0.1, 0.1, 0.1),
        shininess: 18,
        emissive: NO_EMISSION,
      }),
      glossy: new Material(shader, {
        baseColor: vec3.fromValues(0.62, 0.45, 0.2),
        specularColor: vec3.fromValues(0.9, 0.85, 0.7),
        shininess: 96,
        emissive: NO_EMISSION,
      }),
      // デモ 3 の中心の球には光源が「自分の中心に」付いている。面から光源へ向かう L が
      // 内向きになるので N·L <= 0 で弾かれ、この球は自分の灯りでは 1 ミリも照らされない。
      // 中心が真っ黒になってしまうので、自己発光で光らせる(第18章 3 節の光源マーカーと同じ手)
      sun: new Material(shader, {
        baseColor: vec3.fromValues(0.5, 0.36, 0.16),
        specularColor: vec3.fromValues(0.2, 0.18, 0.14),
        shininess: 24,
        emissive: vec3.fromValues(0.95, 0.72, 0.4),
      }),
      marker,
    };
  }

  const litMaterials = createMaterials(litShader);
  const plainMaterials = createMaterials(plainShader);

  // --- シーンの組み立て -------------------------------------------------------

  /** デモ 1・2 の格子。床 1 + 4×4 = 17 メッシュ。階層は 1 段だけ */
  function buildGridScene(materials: MaterialSet, lights: OrbitingLight[]): Scene {
    const scene = new Scene('grid');
    scene.lights.push(...lights);

    const floor = scene.add(new Node('floor'));
    floor.mesh = floorMesh;
    floor.material = materials.floor;
    // 床: XY 平面の板を X 軸まわりに -90° 回す(第14章)
    mat4.fromTranslation(floor.local, [0, FLOOR_Y, 0]);
    mat4.rotateX(floor.local, floor.local, -Math.PI / 2);

    for (let row = 0; row < GRID; row++) {
      for (let col = 0; col < GRID; col++) {
        const isTorus = (row + col) % 2 === 1;
        const node = scene.add(new Node(`object-${row}-${col}`));
        node.mesh = isTorus ? torusMesh : sphereMesh;
        node.material = isTorus ? materials.matte : materials.glossy;
        mat4.fromTranslation(node.local, [
          (col - (GRID - 1) / 2) * GRID_SPACING,
          FLOOR_Y + 0.85,
          (row - (GRID - 1) / 2) * GRID_SPACING,
        ]);
        if (isTorus) mat4.rotateX(node.local, node.local, -Math.PI / 2);
      }
    }
    return scene;
  }

  /** デモ 3 の階層シーン。回転させるノードは毎フレーム local を作り直す */
  interface OrbitScene {
    scene: Scene;
    spins: { node: Node; axisY: number; speed: number; offset: ReadonlyVec3 }[];
    sun: Node;
    /** 「階層を見る」で親子を結ぶ数珠。from が null ならシーンの原点から */
    links: { from: Node | null; to: Node }[];
    beads: Node[];
  }

  /** 数珠 1 本あたりの粒の数 */
  const BEADS_PER_LINK = 9;

  function buildOrbitScene(showHierarchy: boolean): OrbitScene {
    const materials = litMaterials;
    const scene = new Scene('solar');

    const spins: OrbitScene['spins'] = [];
    const sun = scene.add(new Node('sun'));
    sun.mesh = sphereMesh;
    sun.material = materials.sun;
    spins.push({ node: sun, axisY: 1, speed: 0.35, offset: vec3.fromValues(0, 0, 0) });

    const orbitA = scene.add(new Node('orbitA'));
    spins.push({ node: orbitA, axisY: 1, speed: 0.42, offset: vec3.fromValues(0, 0, 0) });
    const planetA = orbitA.add(new Node('planetA'));
    planetA.mesh = torusMesh;
    planetA.material = materials.matte;
    spins.push({ node: planetA, axisY: 1, speed: 0.9, offset: vec3.fromValues(4.8, 0, 0) });

    const moonOrbit = planetA.add(new Node('moonOrbit'));
    spins.push({ node: moonOrbit, axisY: 1, speed: 2.1, offset: vec3.fromValues(0, 0, 0) });
    const moon = moonOrbit.add(new Node('moon'));
    moon.mesh = smallSphereMesh;
    moon.material = materials.glossy;
    spins.push({ node: moon, axisY: 1, speed: 1.4, offset: vec3.fromValues(1.7, 0, 0) });

    const orbitB = scene.add(new Node('orbitB'));
    spins.push({ node: orbitB, axisY: -1, speed: 0.22, offset: vec3.fromValues(0, 0, 0) });
    const planetB = orbitB.add(new Node('planetB'));
    planetB.mesh = sphereMesh;
    planetB.material = materials.matte;
    spins.push({ node: planetB, axisY: 1, speed: 0.6, offset: vec3.fromValues(6.2, 1.1, 0) });

    // 光源: 1 灯は太陽ノードに付いていて、毎フレームそのワールド位置を読む。
    // 強さは減衰 window² / (d² + 1)(lit.frag)から逆算してある — 主光源は公転半径 4.8 の
    // 位置で 32 × 1 / (4.8² + 1) ≒ 1.3、外側の 6.2 でも ≒ 0.8 になる。残り 2 灯は
    // 主光源の当たらない側を埋める補助で、原点あたりで 0.3 前後
    scene.lights.push(
      {
        position: vec3.create(),
        radius: 30,
        color: vec3.fromValues(1.0, 0.86, 0.62),
        intensity: 32,
      },
      {
        position: vec3.fromValues(-9, 6, 7),
        radius: 40,
        color: vec3.fromValues(0.35, 0.5, 0.95),
        intensity: 55,
      },
      {
        position: vec3.fromValues(8.5, -4.5, -8),
        radius: 40,
        color: vec3.fromValues(0.9, 0.35, 0.5),
        intensity: 42,
      },
    );

    // 親子の腕。null は「シーンの原点から」。公転が「親の回転に振り回されること」だと
    // 見えるようにするための線で、シーンの中身ではない
    const links: OrbitScene['links'] = [
      { from: null, to: planetA },
      { from: planetA, to: moon },
      { from: null, to: planetB },
    ];
    const beads: Node[] = [];
    if (showHierarchy) {
      // 数珠はシーンの直下に置く。親を持たせないので、ローカル行列に入れたワールド座標が
      // そのままワールド行列になる。位置は毎フレーム、親子のワールド原点から計算する
      for (let index = 0; index < links.length; index++) {
        for (let i = 0; i < BEADS_PER_LINK; i++) {
          const bead = scene.add(new Node(`bead-${index}-${i}`));
          bead.mesh = smallSphereMesh;
          bead.material = materials.marker[index % materials.marker.length];
          beads.push(bead);
        }
      }
    }

    return { scene, spins, sun, links, beads };
  }

  // --- 状態 -------------------------------------------------------------------

  let demoIndex = 0;
  let optionIndex = 1; // デモ 1 は UBO 側から見せる
  let scene: Scene = new Scene('empty');
  let orbitScene: OrbitScene | null = null;
  let gridLights: OrbitingLight[] = [];
  let usesUniformBlocks = true;
  let latestStats: FrameStats | null = null;

  const camera = new Camera(FOVY, NEAR, FAR);

  function rebuild(): void {
    const demo = demos[demoIndex];
    if (demo.id === 'engine') {
      orbitScene = buildOrbitScene(optionIndex === 1);
      scene = orbitScene.scene;
      gridLights = [];
      usesUniformBlocks = true;
      renderer.setFrameUniforms({
        ambientColor: AMBIENT_COLOR,
        exposure: 1,
        // カメラが 13.5 まで寄ったので、フォグの立ち上がりもそれに合わせる
        // (公転の遠い側だけがうっすら背景に沈む程度)
        fogNear: 18,
        fogFar: 50,
        fogColor: BACKGROUND_COLOR,
      });
    } else {
      orbitScene = null;
      const count = demo.id === 'lights' ? LIGHT_COUNTS[optionIndex] : LIGHT_COUNTS[0];
      const exposure = demo.id === 'lights' ? LIGHT_EXPOSURES[optionIndex] : LIGHT_EXPOSURES[0];
      usesUniformBlocks = demo.id === 'lights' || optionIndex === 1;
      gridLights = createGridLights(count);
      scene = buildGridScene(usesUniformBlocks ? litMaterials : plainMaterials, gridLights);
      renderer.setFrameUniforms({
        ambientColor: AMBIENT_COLOR,
        exposure,
        fogNear: 16,
        fogFar: 46,
        fogColor: BACKGROUND_COLOR,
      });
    }
    renderer.setLights(scene);
    // 前のデモのフレームで数えた値が読み出し行に残らないように捨てる。
    // このデモの数値はフレームごとに揺れないが、母集団が混ざる形は作らない(第31章の先例)
    resetFrameSamples();
  }

  // --- 軌道カメラ(第15章の簡略版。状態は Camera が持つ) ------------------------

  const orbit = camera.orbit;
  orbit.theta = demos[0].camera.theta;
  orbit.phi = demos[0].camera.phi;
  orbit.radius = demos[0].camera.radius;
  const PHI_LIMIT = Math.PI / 2 - 0.05;
  const MIN_RADIUS = 6;
  const MAX_RADIUS = 44;

  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 },
  );

  // --- 切替ボタン(第30・31章と同じ 2 段構え) -----------------------------------

  function buildOptionButtons(): void {
    optionControls.textContent = '';
    const demo = demos[demoIndex];
    for (const [index, label] of demo.options.entries()) {
      const button = document.createElement('button');
      button.type = 'button';
      button.textContent = label;
      button.setAttribute('aria-pressed', index === optionIndex ? 'true' : 'false');
      button.addEventListener('click', () => {
        optionIndex = index;
        rebuild();
        for (const other of optionControls.querySelectorAll('button')) {
          other.setAttribute('aria-pressed', 'false');
        }
        button.setAttribute('aria-pressed', 'true');
      });
      optionControls.append(button);
    }
  }

  for (const [index, demo] of demos.entries()) {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = demo.label;
    button.setAttribute('aria-pressed', index === demoIndex ? 'true' : 'false');
    button.addEventListener('click', () => {
      demoIndex = index;
      optionIndex = demo.id === 'count' ? 1 : 0;
      orbit.theta = demo.camera.theta;
      orbit.phi = demo.camera.phi;
      orbit.radius = demo.camera.radius;
      buildOptionButtons();
      rebuild();
      for (const other of controls.querySelectorAll('button')) {
        other.setAttribute('aria-pressed', 'false');
      }
      button.setAttribute('aria-pressed', 'true');
    });
    controls.append(button);
  }

  // --- リサイズ -----------------------------------------------------------------

  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;
      // viewport も必ずセットで切り替える(既定値は canvas の初期サイズ 300×150 のまま)
      gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
    }
  }

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

  function resetFrameSamples(): void {
    latestStats = null;
  }

  function demoNote(): string {
    const demo = demos[demoIndex];
    if (demo.id === 'count') {
      return optionIndex === 1
        ? '同じ絵を UBO で描いた場合。共有 uniform は 1 本も送っていない'
        : '同じ絵を素の uniform で描いた場合。共有 uniform をプログラムごとに毎フレーム送り直す';
    }
    if (demo.id === 'lights') {
      return `光源 ${LIGHT_COUNTS[optionIndex]} 灯を Lights ブロック 1 本で配る(上限は #define MAX_LIGHTS ${MAX_LIGHTS})`;
    }
    return optionIndex === 1
      ? 'ミニエンジン。木のノードのワールド原点に、階層の深さで色を変えた球を置いている'
      : 'ミニエンジン。ノード階層で自転と公転、マテリアル 2 種';
  }

  function updateReadout(): void {
    if (!latestStats) {
      readout.textContent = `${demoNote()} / 計測中 —`;
      return;
    }
    const stats = latestStats;
    const uboPart = usesUniformBlocks
      ? `bufferSubData ${stats.bufferSubDataCalls} 回 ${stats.bufferSubDataBytes.toLocaleString('en-US')} B / ` +
        `UBO ${renderer.uboBytes.toLocaleString('en-US')} B(うち std140 のパディング ${renderer.uboPaddingBytes} B)/ `
      : 'bufferSubData 0 回(このデモは UBO を読まないので更新もしていません)/ ';
    readout.textContent =
      `${demoNote()} / ` +
      `メッシュ ${stats.meshes}・ドローコール ${stats.drawCalls}・プログラム ${stats.programs} 本 / ` +
      `useProgram ${stats.programSwitches} 回・マテリアル切替 ${stats.materialSwitches} 回 / ` +
      `uniform* ${stats.uniformCalls} 回/フレーム(うち共有 ${stats.sharedUniformCalls} 回)/ ` +
      `uniform に渡したバイト数 ${stats.uniformBytes.toLocaleString('en-US')} B / ` +
      uboPart +
      layoutNote;
  }

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

  const linkFrom = vec3.create();
  const linkTo = vec3.create();
  const beadPosition = vec3.create();

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

  let lastReadoutAt = 0;

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

    if (orbitScene) {
      // 回転はローカル行列の作り直し。親のワールド行列と掛け合わせるのは Node の仕事
      for (const spin of orbitScene.spins) {
        mat4.fromTranslation(spin.node.local, spin.offset);
        mat4.rotateY(spin.node.local, spin.node.local, spin.axisY * time * spin.speed);
      }
      // 太陽の灯りはノードに付いている。ワールド行列の平行移動成分がその位置。
      // Renderer も render() の頭で同じことをするが、光源の位置は UBO へ書く前に
      // 決まっていないといけないので、ここで 1 回たどっておく
      orbitScene.scene.updateWorldMatrix(null);
      worldOrigin(orbitScene.sun, scene.lights[0].position);

      // 「階層を見る」の数珠。親子のワールド原点を結ぶ線の上に等間隔で置く
      if (orbitScene.beads.length > 0) {
        let bead = 0;
        for (const link of orbitScene.links) {
          if (link.from) worldOrigin(link.from, linkFrom);
          else vec3.set(linkFrom, 0, 0, 0);
          worldOrigin(link.to, linkTo);
          for (let i = 0; i < BEADS_PER_LINK; i++) {
            const t = (i + 1) / (BEADS_PER_LINK + 1);
            vec3.lerp(beadPosition, linkFrom, linkTo, t);
            mat4.fromTranslation(orbitScene.beads[bead].local, beadPosition);
            mat4.scale(orbitScene.beads[bead].local, orbitScene.beads[bead].local, BEAD_SCALE);
            bead++;
          }
        }
      }
    } else {
      for (const light of gridLights) {
        const angle = time * light.speed + light.phase;
        vec3.set(
          light.position,
          light.origin[0] + Math.cos(angle) * light.orbitRadius,
          light.origin[1] + Math.sin(angle * 1.7) * 0.3,
          light.origin[2] + Math.sin(angle) * light.orbitRadius,
        );
      }
    }

    latestStats = renderer.render(scene, camera, BACKGROUND_COLOR);

    if (timestamp - lastReadoutAt > 250) {
      updateReadout();
      lastReadoutAt = timestamp;
    }
    requestAnimationFrame(frame);
  }

  buildOptionButtons();
  rebuild();
  updateReadout();

  // このページのデモはページと寿命を共にするので、rAF ループの停止もリスナーの解除も
  // していない。GPU リソース解放の一般論は第35章(この章のエンジンは Mesh.dispose /
  // Shader.dispose / Renderer.dispose を持っているが、デモを切り替えても
  // メッシュとシェーダーは作り直さないので呼ぶ場面が無い)
  requestAnimationFrame(frame);
}

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

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

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

setup(gl, canvas, controls, optionControls, readout);
src/lessons/34-ubo-and-engine/lit.vert
#version 300 es

// 第34章: UBO 版の頂点シェーダー。
// plain.vert との違いは「view と projection をどこから受け取るか」だけで、
// main() の中身は 1 文字も同じ(本文 1 節・デモ 1)。

// 第3部の共通の属性配置(第14章から): 0 = 位置, 1 = 法線, 2 = UV(この章では使わない)
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;

// 共有 uniform は uniform ブロックで受け取る(本文 3 節)。
// 同じブロックを複数のシェーダーが宣言するときは、名前・型・順番が 1 つでも違うと
// リンクに失敗する(ES 3.0.6 §2.12.6.2)。だから lit.frag 側にも同じ 5 行が書いてある。
// layout(std140) は WebGL2 では既定だが、何のレイアウトで並んでいるかを
// 読む人に見せるために明示している(本文 3 節の pitfall)
layout(std140) uniform Camera {
  mat4 u_view;
  mat4 u_projection;
  vec3 u_cameraPosition;
};

// オブジェクトごとの値は素の uniform のまま。UBO に向かない理由は本文 4 節
uniform mat4 u_model;
uniform mat3 u_normalMatrix; // モデル行列の左上 3×3 の逆転置(第17章)

// ライティングはワールド空間で行う(第17章の約束)
out vec3 v_normal;
out vec3 v_worldPosition;

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

  v_worldPosition = worldPosition.xyz;
  v_normal = u_normalMatrix * a_normal;

  gl_Position = u_projection * u_view * worldPosition;
}
src/lessons/34-ubo-and-engine/lit.frag
#version 300 es

precision highp float;

// 第34章: UBO 版のフラグメントシェーダー。
// plain.frag との違いは uniform の受け取り方だけで、main() の中身は 1 文字も同じ。

// renderer.ts の MAX_LIGHTS と必ず同じ値にする
#define MAX_LIGHTS 64

// --- 層 1: フレームごとの定数(更新頻度がいちばん低い。本文 4 節) ------------------
// std140 のオフセットは 0 / 12 / 16 / 32。u_fogColor が 24 ではなく 32 から始まるのは
// vec3 の基本アライメントが 16 だから(ES 3.0.6 §2.12.6.4 規則 3)。
// 24〜31 の 8 バイトは誰も使わない穴になる — 本文 2 節で数えている
layout(std140) uniform Frame {
  vec3 u_ambientColor;
  float u_exposure;
  vec2 u_fogRange; // (near, far)
  vec3 u_fogColor; // 画面に出る sRGB 値。リニアで混ぜる前に入口で変換する(第27章)
};

// --- 層 2: カメラ(毎フレーム変わる) -------------------------------------------
// lit.vert と 1 文字も違わないこと(ES 3.0.6 §2.12.6.2)
layout(std140) uniform Camera {
  mat4 u_view;
  mat4 u_projection;
  vec3 u_cameraPosition;
};

// --- 層 3: 光源(位置だけ毎フレーム変わる) -------------------------------------
// xyz = ワールド座標 / w = 影響半径、rgb = 色(リニア) / a = 強さ。第23章と同じパック。
// vec4 の配列はストライド 16 バイト = 隙間なしなので、Float32Array をそのまま流せる
layout(std140) uniform Lights {
  vec4 u_lightPositionRadius[MAX_LIGHTS];
  vec4 u_lightColorIntensity[MAX_LIGHTS];
  int u_lightCount;
};

// --- 層 4: オブジェクトごと(UBO に入れていない。理由は本文 4 節) -----------------
uniform vec3 u_baseColor;
uniform vec3 u_specularColor;
uniform float u_shininess;
uniform vec3 u_emissive; // 自分で光る量(リニア)。光源に照らされなくても見える

in vec3 v_normal;
in vec3 v_worldPosition;

out vec4 fragColor;

#include "color.glsl"

void main() {
  vec3 N = normalize(v_normal);
  vec3 V = normalize(u_cameraPosition - v_worldPosition);

  // 環境光。隙間が真っ黒にならない程度の定数(正しい環境光 = IBL は第22章)
  vec3 color = u_ambientColor * u_baseColor;

  for (int i = 0; i < u_lightCount; i++) {
    vec4 positionRadius = u_lightPositionRadius[i];
    vec3 toLight = positionRadius.xyz - v_worldPosition;
    float dist = length(toLight);
    if (dist > positionRadius.w) {
      continue;
    }

    vec3 L = toLight / dist; // 面から光源へ向かう単位ベクトル(第17章の約束)
    float NdotL = dot(N, L);
    if (NdotL <= 0.0) {
      continue;
    }

    // 逆二乗 + 影響半径で 0 に落とす窓関数(第23章と同じ形。第18章 2 節の減衰)
    float window = clamp(1.0 - pow(dist / positionRadius.w, 4.0), 0.0, 1.0);
    float attenuation = (window * window) / (dist * dist + 1.0);
    vec3 radiance = u_lightColorIntensity[i].rgb * u_lightColorIntensity[i].a * attenuation;

    // Blinn-Phong(第17章)。PBR にしていないのは、この章の主題が uniform の配り方だから
    vec3 H = normalize(L + V);
    float specular = pow(max(dot(N, H), 0.0), u_shininess);
    color += radiance * NdotL * (u_baseColor + u_specularColor * specular);
  }

  // 自己発光は光の当たり方に関係なく足す。階層を見せるマーカーがこれで光る
  color += u_emissive;

  // 露出はリニア値への掛け算 → そのあとで距離フォグ → 最後に sRGB エンコード(第27章の順序)。
  // フォグはカメラからの距離で背景色へ溶かすだけの処理(レイマーチング版は第28章 7 節)
  color *= u_exposure;
  float fog = smoothstep(u_fogRange.x, u_fogRange.y, distance(u_cameraPosition, v_worldPosition));
  color = mix(color, srgbToLinear(u_fogColor), fog);

  fragColor = vec4(linearToSrgb(color), 1.0);
}
src/lessons/34-ubo-and-engine/plain.vert
#version 300 es

// 第34章: 素の uniform 版の頂点シェーダー(デモ 1 の比較対象)。
// lit.vert と違うのは uniform の宣言だけで、main() の中身は 1 文字も同じ。
// u_cameraPosition をここで宣言していないのは、頂点シェーダーが読まないから。
// 読まない uniform は最適化で消えてロケーションが null になるので、
// 「送ったつもりで送っていない」を数えないために、要る側(plain.frag)にだけ書く

// 第3部の共通の属性配置(第14章から): 0 = 位置, 1 = 法線, 2 = UV(この章では使わない)
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;

// 共有の値も、オブジェクトごとの値も、区別なく 1 本ずつ届く。
// この 2 種類が混ざっていることが、第18章・第23章で毎フレーム全部送り直していた理由
uniform mat4 u_view;
uniform mat4 u_projection;
uniform mat4 u_model;
uniform mat3 u_normalMatrix; // モデル行列の左上 3×3 の逆転置(第17章)

// ライティングはワールド空間で行う(第17章の約束)
out vec3 v_normal;
out vec3 v_worldPosition;

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

  v_worldPosition = worldPosition.xyz;
  v_normal = u_normalMatrix * a_normal;

  gl_Position = u_projection * u_view * worldPosition;
}
src/lessons/34-ubo-and-engine/plain.frag
#version 300 es

precision highp float;

// 第34章: 素の uniform 版のフラグメントシェーダー(デモ 1 の比較対象)。
// lit.frag との違いは uniform の受け取り方だけで、main() の中身は 1 文字も同じ。
// 「絵が同じで、呼び出し回数だけが違う」ことを見せるためのペアなので、
// 片方の main() を触ったらもう片方も同じように触ること。

// renderer.ts の MAX_LIGHTS と必ず同じ値にする
#define MAX_LIGHTS 64

// lit.frag では Frame / Camera / Lights の 3 ブロックに分かれていたものが、
// ここでは 10 本の独立した uniform になる。10 本とも、プログラムごとに、
// 毎フレーム送り直すことになる — 第18章・第23章がやっていたのはこれ(本文 1 節)
uniform vec3 u_ambientColor;
uniform float u_exposure;
uniform vec2 u_fogRange; // (near, far)
uniform vec3 u_fogColor; // 画面に出る sRGB 値。リニアで混ぜる前に入口で変換する(第27章)
uniform vec3 u_cameraPosition;
uniform vec4 u_lightPositionRadius[MAX_LIGHTS];
uniform vec4 u_lightColorIntensity[MAX_LIGHTS];
uniform int u_lightCount;

// オブジェクトごとの値。ここは lit.frag と同じで、UBO 版でも素の uniform のまま
uniform vec3 u_baseColor;
uniform vec3 u_specularColor;
uniform float u_shininess;
uniform vec3 u_emissive; // 自分で光る量(リニア)。光源に照らされなくても見える

in vec3 v_normal;
in vec3 v_worldPosition;

out vec4 fragColor;

#include "color.glsl"

void main() {
  vec3 N = normalize(v_normal);
  vec3 V = normalize(u_cameraPosition - v_worldPosition);

  // 環境光。隙間が真っ黒にならない程度の定数(正しい環境光 = IBL は第22章)
  vec3 color = u_ambientColor * u_baseColor;

  for (int i = 0; i < u_lightCount; i++) {
    vec4 positionRadius = u_lightPositionRadius[i];
    vec3 toLight = positionRadius.xyz - v_worldPosition;
    float dist = length(toLight);
    if (dist > positionRadius.w) {
      continue;
    }

    vec3 L = toLight / dist; // 面から光源へ向かう単位ベクトル(第17章の約束)
    float NdotL = dot(N, L);
    if (NdotL <= 0.0) {
      continue;
    }

    // 逆二乗 + 影響半径で 0 に落とす窓関数(第23章と同じ形。第18章 2 節の減衰)
    float window = clamp(1.0 - pow(dist / positionRadius.w, 4.0), 0.0, 1.0);
    float attenuation = (window * window) / (dist * dist + 1.0);
    vec3 radiance = u_lightColorIntensity[i].rgb * u_lightColorIntensity[i].a * attenuation;

    // Blinn-Phong(第17章)。PBR にしていないのは、この章の主題が uniform の配り方だから
    vec3 H = normalize(L + V);
    float specular = pow(max(dot(N, H), 0.0), u_shininess);
    color += radiance * NdotL * (u_baseColor + u_specularColor * specular);
  }

  // 自己発光は光の当たり方に関係なく足す。階層を見せるマーカーがこれで光る
  color += u_emissive;

  // 露出はリニア値への掛け算 → そのあとで距離フォグ → 最後に sRGB エンコード(第27章の順序)。
  // フォグはカメラからの距離で背景色へ溶かすだけの処理(レイマーチング版は第28章 7 節)
  color *= u_exposure;
  float fog = smoothstep(u_fogRange.x, u_fogRange.y, distance(u_cameraPosition, v_worldPosition));
  color = mix(color, srgbToLinear(u_fogColor), fog);

  fragColor = vec4(linearToSrgb(color), 1.0);
}
src/lessons/34-ubo-and-engine/color.glsl
// 第34章 出力の最後の 1 行のための色変換。
// 中身は第27章の src/lessons/27-color-and-palette/color.glsl からの複製(内容は同一)で、
// srgbToLinear / linearToSrgb の 2 関数だけを持ってきている。
// 片方を直したらもう片方も直すこと(第25→26章の noise.glsl と同じ流儀。理由は
// docs/plan.md の抽象化タイムライン: 読者が壊して遊ぶ対象なので章をまたいだ結合を作らない)。
//
// この章のミニエンジンも、この複製を src/lib/ へ上げていない。判断の一覧は本文 7 節。
// 使うのは linearToSrgb のほう。ライティングはリニア値で組み立て、画面へ出す直前にエンコードする。

/**
 * sRGB エンコード値 → リニア値。
 * 出典: OpenGL ES 3.0.6 §3.8.16 式 (3.26)。GPU が sRGB テクスチャを読むときの変換そのもの。
 * 境界 (0.04045) では 2 つの式の差が 2.3e-9 しかないので、等号がどちら側かは問題にならない。
 */
vec3 srgbToLinear(vec3 c) {
  vec3 lo = c / 12.92;
  vec3 hi = pow((c + 0.055) / 1.055, vec3(2.4));
  return mix(lo, hi, step(vec3(0.04045), c));
}

/**
 * リニア値 → sRGB エンコード値。画面へ出す最後の 1 行はこれになる。
 * 出典: OpenGL ES 3.0.6 §4.1.8 式 (4.1)。仕様は指数を 0.41666 と書いているが、
 * これは 1/2.4 = 0.4166666… を打ち切った値で、8 ビット出力での差は最大 1 段。1/2.4 を使う。
 * 仕様の式は 0 以下と 1 以上を切り落とすので、clamp がその 2 本の枝にあたる。
 */
vec3 linearToSrgb(vec3 c) {
  c = clamp(c, 0.0, 1.0);
  vec3 lo = c * 12.92;
  vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
  return mix(lo, hi, step(vec3(0.0031308), c));
}

plain.vert/plain.fragは、litの 2 本とmain() の中身が 1 文字も同じです (バイト単位で一致することを機械的に確かめてあります)。違うのは uniform の宣言だけ。

src/lessons/34-ubo-and-engine/lit.frag(抜粋)
layout(std140) uniform Frame {
  vec3 u_ambientColor;
  float u_exposure;
  vec2 u_fogRange; // (near, far)
  vec3 u_fogColor; // 画面に出る sRGB 値。リニアで混ぜる前に入口で変換する(第27章)
};
src/lessons/34-ubo-and-engine/plain.frag(抜粋)
uniform vec3 u_ambientColor;
uniform float u_exposure;
uniform vec2 u_fogRange; // (near, far)
uniform vec3 u_fogColor; // 画面に出る sRGB 値。リニアで混ぜる前に入口で変換する(第27章)
uniform vec3 u_cameraPosition;
uniform vec4 u_lightPositionRadius[MAX_LIGHTS];
uniform vec4 u_lightColorIntensity[MAX_LIGHTS];
uniform int u_lightCount;
src/lessons/34-ubo-and-engine/engine.ts(抜粋)
apply(writer: UniformWriter): void {
  writer.vec3(this.shader.location('u_baseColor'), this.uniforms.baseColor);
  writer.vec3(this.shader.location('u_specularColor'), this.uniforms.specularColor);
  writer.float(this.shader.location('u_shininess'), this.uniforms.shininess);
  writer.vec3(this.shader.location('u_emissive'), this.uniforms.emissive);
}

three.js との対応

three.jsこの章
WebGLRenderer と render(scene, camera)Renderer.render(scene, camera, clearColor)。1 フレームの手順(6 節)と UBO の更新を持つ
SceneScene extends Node。木の根 + 光源の配列。three.js のSceneが持つbackground/fog/environmentのうち、フォグと環境光にあたるものはFrameブロックに入れてある(シーンごとではなく「そのフレームの見え方」の設定だから)
Object3DNode。matrix/matrixWorld/children/parentにあたるものだけ。position/quaternion/scaleの分解は持たず、local行列を直接書き換える
Mesh(geometry + material)Nodeのmeshとmaterialの 2 本の参照。three.js はMeshがObject3Dの派生ですが、この章は派生させず参照を持たせるだけ
Material(と ShaderMaterial)Material。シェーダー + 固有の uniform + 描画状態。three.js のMaterialが持つside/depthTest/depthWrite/blendingのうち、前の 3 つにあたるものがある
PerspectiveCameraCamera。fov/near/farとprojectionMatrix/matrixWorldInverse(= view)にあたるもの。射影行列そのものは第12章
OrbitControlsCamera.orbitの 3 つの数と、main.tsの pointer / wheel のリスナー。中身は第15章
BufferGeometrysrc/lib/geometry.tsのGeometry(第14章)+ この章のMesh.fromGeometry。属性の宣言も VAO もここで作る
UniformsLib(共有 uniform の定義集)FRAME_LAYOUT/CAMERA_LAYOUT/LIGHTS_LAYOUT。three.js のこれはブロックではなく素の uniform の定義集で、ShaderChunkの#includeと組で使う
WebGLUniforms(値をロケーションへ流し込む層)Shader.location()のキャッシュ +UniformWriter。three.js のこれは型ごとの setter を作り分ける 1,203 行のモジュール
UniformsGroup/WebGLUniformsGroups(UBO)uniform-buffer.ts。ここは同じことをしています(8 節)

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

この章のコードはsrc/lessons/34-ubo-and-engine/にあります。std140 の検算が読み出し行に出るので、レイアウトを壊す実験は その表示を見ながらやってください。何も壊していない状態で「ブロック末尾のパディングを除いて 一致」が出ているなら、それはあなたの環境がブロックの末尾を切り上げていないというだけで、壊れてはいません(2 節)。std140 検算 NGのほうが出たら、オフセットかストライドがずれています。

まとめ

ここまでで「作る」道具は一通り揃いました。次章第35章「パフォーマンスとロバスト性 — 計測・コンテキストロスト」では、視点が「動くもの」から「動き続けるもの」へ移ります。この章が数えたのは呼び出し回数と バイト数だけで、時間は 1 度も測っていません— CPU と GPU のどちらが詰まっているかを切り分ける方法、GPU の時間を測るタイマークエリ、解像度スケール、そしてこの本が積み残してきた 「解放していない GPU リソース」の後始末とコンテキストロストからの復帰を、そこで扱います。