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

最初の 3D — インデックス描画・深度テスト・カリング

第11章でベクトルと行列を、第12章でモデル・ビュー・射影という座標変換の連鎖を手に入れました。 この章でついに、立体をひとつ画面に出します。題材は立方体です。 球でもトーラスでもなく立方体から始めるのは、頂点データをすべて手で書ける最小の 立体だからです。手で書くからこそ、「角は 8 つなのに頂点は 24 個要る」という 3D データの本質的な事情が骨身に染みます。

もう 1 つのテーマは「2D では起きなかった問題」です。立体には前後関係裏側があります。何もしないと面は描いた順に上書きされるだけで、立方体は破綻して見えます。 その解決策である深度テストカリングは、シェーダーではなく GPU の固定機能で、第1章以来の「WebGL は状態機械」という感覚がそのまま活きる場所です。このデモの切り替えボタンは、シェーダーを 差し替えているのではなく、gl.enable/gl.disableでスイッチ盤を掛け替えているだけです。

回転する立方体。カメラは固定で、モデル行列だけが回っています。「深度テストなし」では 奥の面が手前に透けて破綻し、「深度テストあり」で正しい立体になります。「カリングで裏面だけ」はcullFace(gl.FRONT)で表面を捨てて立方体の内側(裏面)だけを描き、gl_FrontFacingによる塗り分けで、見えているのがすべて裏(赤)であることを確かめるモードです。

この章で学ぶこと:

1. 立方体を手書きする — 角は 8 つ、頂点は 24 個

立方体の角は 8 つです。それなら頂点 8 個と思いたくなりますが、この章のデータは24 頂点です。理由は、頂点が「位置」だけの存在ではないからです。

GPU にとっての頂点とは、すべての属性(位置・色・…)の値の組です。 この章では面ごとに色を変えます。すると、たとえば立方体の右上手前の角には、前面(赤)・右面(青)・ 上面(紫)という 3 つの面が集まっていて、位置は同じでも色が 3 通りあります。 属性の組が違えば、それは GPU にとって別の頂点です。したがって角 1 つにつき頂点が 3 個、8 × 3 = 24 頂点になります。「6 面 × 4 頂点」と数えても同じ 24 です。

データ本体がこちらです。1 行が 1 頂点で、行の前半 3 つが位置、後半 3 つが色です。 この「1 本の配列に交互に詰める」形が次節のインターリーブです。

src/lessons/13-first-3d/main.ts(抜粋)
  // 1 行 = 1 頂点で、[位置 x, y, z, 色 r, g, b] を 1 本の配列に交互に詰める(インターリーブ)。
  // 立方体の角は 8 つだが、面ごとに色が違う = 属性の組が違うので、
  // 6 面 × 4 頂点 = 24 頂点が必要になる(本文 1 節)。
  // 各面の 4 頂点は「外から見て反時計回り(CCW)」の順に並べてある(本文 5 節のワインディング順)。
  // biome-ignore format: 1 頂点 = 1 行の並びを保つ
  const vertices = new Float32Array([
    // 前面 (+z): 赤
    -0.5, -0.5,  0.5,  0.86, 0.30, 0.36,
     0.5, -0.5,  0.5,  0.86, 0.30, 0.36,
     0.5,  0.5,  0.5,  0.86, 0.30, 0.36,
    -0.5,  0.5,  0.5,  0.86, 0.30, 0.36,
    // 背面 (-z): 緑
     0.5, -0.5, -0.5,  0.30, 0.75, 0.45,
    -0.5, -0.5, -0.5,  0.30, 0.75, 0.45,
    -0.5,  0.5, -0.5,  0.30, 0.75, 0.45,
     0.5,  0.5, -0.5,  0.30, 0.75, 0.45,
    // 右面 (+x): 青
     0.5, -0.5,  0.5,  0.32, 0.55, 0.95,
     0.5, -0.5, -0.5,  0.32, 0.55, 0.95,
     0.5,  0.5, -0.5,  0.32, 0.55, 0.95,
     0.5,  0.5,  0.5,  0.32, 0.55, 0.95,
    // 左面 (-x): 黄
    -0.5, -0.5, -0.5,  0.92, 0.78, 0.30,
    -0.5, -0.5,  0.5,  0.92, 0.78, 0.30,
    -0.5,  0.5,  0.5,  0.92, 0.78, 0.30,
    -0.5,  0.5, -0.5,  0.92, 0.78, 0.30,
    // 上面 (+y): 紫
    -0.5,  0.5,  0.5,  0.62, 0.44, 0.92,
     0.5,  0.5,  0.5,  0.62, 0.44, 0.92,
     0.5,  0.5, -0.5,  0.62, 0.44, 0.92,
    -0.5,  0.5, -0.5,  0.62, 0.44, 0.92,
    // 下面 (-y): シアン
    -0.5, -0.5, -0.5,  0.30, 0.80, 0.85,
     0.5, -0.5, -0.5,  0.30, 0.80, 0.85,
     0.5, -0.5,  0.5,  0.30, 0.80, 0.85,
    -0.5, -0.5,  0.5,  0.30, 0.80, 0.85,
  ]);

座標は原点を中心に ±0.5、つまり一辺 1 の立方体です。第3章と違ってこれはクリップ空間の座標ではありません。ローカル座標で好きな大きさに作っておき、スクリーンへの写し方は行列に任せる — 第12章で作った流れです。頂点シェーダーは MVP の定型そのままなので、先に見せてしまいます。

src/lessons/13-first-3d/cube.vert
#version 300 es

// 第13章: 立方体の頂点シェーダー。
// 属性は位置 (vec3) と面ごとの色 (vec3)。法線と UV は第14章で加わる。

layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_color;

// モデル・ビュー・射影行列(第12章)。transpose は常に false で渡す
uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;

out vec3 v_color;

void main() {
  v_color = a_color;
  gl_Position = u_projection * u_view * u_model * vec4(a_position, 1.0);
}

第3章から変わったのは、a_positionvec2からvec3になったことと、gl_Positionの手前に行列 3 連発が挟まったことだけです。行列の中身とこの掛け算の順序は第12章で説明済みなので、 ここでは繰り返しません。

2. インターリーブ属性 — stride と offset の出番

第3章では位置と色を別々の VBOに入れ、vertexAttribPointerstrideoffsetには 0 を渡して「意味を持つのはインターリーブのとき(3D メッシュを扱う後の章で使います)」と予告しました。ここで回収します。

インターリーブ (interleaved)とは、上のデータのように 1 本のバッファへ「位置, 色, 位置, 色, …」と頂点単位で交互に詰める置き方です。頂点 1 個ぶんのデータがメモリ上でひとかたまりになるため、GPU がキャッシュを効かせやすく、バッファの管理も 1 本で済みます。実際の 3D データはほぼこの形で流通しています(第19章で読む glTF もそうです)。

頂点 0(24 バイト)頂点 1(24 バイト)xyzrgbxyzrgb0122436stride = 24 バイト(次の頂点の読み出し開始まで)位置 (location = 0): offset 0 バイト色 (location = 1): offset 12 バイト(float 3 つ分を読み飛ばす)
インターリーブしたバッファのメモリレイアウト。float は 4 バイトなので、1 頂点 = 6 float = 24 バイト。位置も色もstride は同じ 24で、読み始めの位置(offset)だけが 0 と 12 で異なります。

配線のコードがこちらです。第3章では属性ごとにbindBufferし直していましたが、今回は 1 本のバッファに両方の属性があるので、バインドは 1 回で済みます。

src/lessons/13-first-3d/main.ts(抜粋)
  const vbo = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
  gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

  // stride / offset はバイト単位。float は 4 バイトなので BYTES_PER_ELEMENT で数える
  const stride = 6 * Float32Array.BYTES_PER_ELEMENT; // 1 頂点 = 6 float = 24 バイト
  gl.enableVertexAttribArray(0);
  gl.vertexAttribPointer(0, 3, gl.FLOAT, false, stride, 0); // 位置: 各頂点の先頭から 3 つ
  gl.enableVertexAttribArray(1);
  gl.vertexAttribPointer(1, 3, gl.FLOAT, false, stride, 3 * Float32Array.BYTES_PER_ELEMENT); // 色: 位置 3 つ分(12 バイト)の後ろ

3. インデックス描画 — drawElements と VAO の非対称

24 頂点はまだ「4 頂点の面が 6 枚」というだけで、三角形になっていません。drawArrays(gl.TRIANGLES, ...)は頂点を先頭から 3 個ずつ機械的に組むので、四角い面を三角形 2 枚に割るには、対角線上の 2 頂点をもう一度並べた「3 + 3 = 6 頂点 × 6 面 = 36 頂点」の配列を作り直すことになります。 位置と色で 6 float もある頂点データを、行ごと複製するのはあまりに無駄です。

そこでインデックス描画です。頂点データは 24 個のまま置いておき、別途 「どの頂点を、どの順で組むか」だけを軽い整数の列として渡します。これが第3章の VAO の補足で名前だけ登場したインデックスバッファ (IBO / ELEMENT_ARRAY_BUFFER)です。

src/lessons/13-first-3d/main.ts(抜粋)
  // 各面の 4 頂点(0〜3 番)を、対角線で三角形 2 枚に割る。
  // 24 頂点を使い回すので、値は 0〜23 の「頂点番号」になる。
  // biome-ignore format: 1 面 = 1 行の並びを保つ
  const indices = new Uint16Array([
     0,  1,  2,   0,  2,  3, // 前面
     4,  5,  6,   4,  6,  7, // 背面
     8,  9, 10,   8, 10, 11, // 右面
    12, 13, 14,  12, 14, 15, // 左面
    16, 17, 18,  16, 18, 19, // 上面
    20, 21, 22,  20, 22, 23, // 下面
  ]);

対角線で使い回される 2 頂点(各行の0, 2にあたる番号)が 2 回ずつ現れているのがわかります。複製されるのは 2 バイトの整数だけで、6 float の頂点本体はコピーされません。転送は VBO とまったく同じ手順で、ターゲットにgl.ELEMENT_ARRAY_BUFFERを指定するだけです。

src/lessons/13-first-3d/main.ts(抜粋)
  // インデックスバッファ (IBO)。作り方は VBO と同じで、ターゲットだけが違う。
  // ELEMENT_ARRAY_BUFFER のバインドは「VAO の状態」として記録される(本文 3 節の非対称)
  const ibo = gl.createBuffer();
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);

  // VAO を先に解除してから IBO を外す。VAO バインド中に null を入れると
  // 「この VAO は IBO なし」が記録されてしまう
  gl.bindVertexArray(null);
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);

描画側は、drawArraysdrawElements に変わります。

src/lessons/13-first-3d/main.ts(抜粋)
    gl.bindVertexArray(vao);
    // drawArrays の代わりに drawElements。VAO に記録された IBO からインデックスを
    // 36 個読み、Uint16Array に合わせて gl.UNSIGNED_SHORT として解釈する
    gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0);

引数は「モード、読むインデックスの個数(バッファに置いた 24 頂点の数ではなく、インデックス配列の長さ 36)、インデックスの型、 IBO 先頭からのバイトオフセット」です。型のgl.UNSIGNED_SHORTUint16Arrayで送ったことと対応します。Uint16Arrayで指せる頂点番号は 0〜65534、つまり65,535 頂点までです(65535 という値は WebGL2 では「プリミティブ再開」の予約値に取られていて、頂点番号として使えません — 詳しくは第14章)。それを超える頂点を扱う日が来たらUint32Array+gl.UNSIGNED_INTに替えます。

4. 深度テスト — 奥行きの記憶

デモを「深度テストなし」にしてしばらく眺めてください。面が透けたり、奥の面が手前に かぶさったりして、立体として破綻する瞬間があるはずです。原因は単純で、ラスタライズされたフラグメントは、何もしなければ描いた順に上書きされるからです。インデックス配列の後ろにある面ほど後から描かれて勝つ — 立体の前後関係とは 何の関係もない順序です。

解決策が深度テスト (depth test)です。フレームバッファには、ピクセルごとの色に加えて深度バッファ (depth buffer)という「そのピクセルで直近にテストに通ったフラグメントの深度値」を覚えておく 領域があります。フラグメントシェーダーが色を計算した後、GPU はそのフラグメントの深度値(クリップ空間の z から作られる 0〜1 の値)を深度バッファの値と比較し、テストに通ったときだけ色を書き込み、深度バッファも更新します。「何をもって通すか」はgl.depthFuncで決められる比較関数しだいで、既定はgl.LESS— 「記録より小さい(= 手前)なら通す」です。だから結果として、描く順序がどうであれ 「いちばん手前が勝つ」になります。

実は、この深度バッファはずっと前から確保されていました。第2章のコンテキスト属性の表でdepthの既定値はtrue— 「3D の前後関係の判定に必須(第13章)」と書いたのがこれです。getContext('webgl2')と書くだけで深度バッファは付いてきます。ただしテスト自体は既定で無効なので、 スイッチを入れるのは自分の仕事です。

src/lessons/13-first-3d/main.ts(抜粋・状態切り替え部)
    if (mode.depthTest) {
      gl.enable(gl.DEPTH_TEST); // 比較関数は既定の gl.LESS(小さい = 手前が勝つ)
    } else {
      gl.disable(gl.DEPTH_TEST);
    }

もう 1 つ、忘れてはならないのがクリアです。深度バッファは「いちばん手前の記録」を溜め込む メモリなので、色と同じく毎フレーム消してから描き始める必要があります。 第4章から書いてきたgl.clear(gl.COLOR_BUFFER_BIT)に、深度のビットを OR で足します。

src/lessons/13-first-3d/main.ts(抜粋)
    // 色と深度を両方クリアする。深度バッファを消し忘れると
    // 前フレームの「奥行きの記憶」が残り、以降のフレームがほぼ描けなくなる(本文 4 節)
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

5. カリングとワインディング順 — 描かなくていい面

深度テストで正しい絵は出ました。しかし少し考えると、立方体の 6 面のうち画面に見えているのは 常に高々 3 面です。裏側を向いた面は、ラスタライズしてフラグメントシェーダーを実行しても、 最後は必ず手前の面に負けます。結果に寄与しない計算です。これを ラスタライズの前に丸ごと捨てるのがカリング (face culling)です。

では GPU は三角形の「表裏」をどう判定するのか。頂点に裏表の印があるわけではなく、スクリーンに投影した 3 頂点の並び順(ワインディング順、winding order)で決めます。既定では反時計回り (CCW) に見える面が表です。同じ三角形でも 裏側から見れば巻き方向は逆に見えるので、「どちらから見ているか」がそのまま表裏になる — それだけの仕組みです。第11章で「cross は面の表裏の判定(第13章)でも顔を出す」と 書いたのはここで、GPU がスクリーン上で 3 頂点の作る符号付き面積(= 2D の cross、第11章)を計算し、その符号で表裏を決めている、と読み替えることもできます。

0120 → 1 → 2 が反時計回り = 表(既定の frontFace(gl.CCW))012裏側から見ると同じ 0 → 1 → 2 が時計回り = 裏cullFace(gl.BACK) ならここで捨てられる
三角形の表裏はワインディング順で決まります。頂点データの各面を「外から見て反時計回り」に 並べておいたのは、このためです。

カリングも深度テストと同じく、gl.enableで入れるスイッチ + 設定値という形です。

src/lessons/13-first-3d/main.ts(抜粋・状態切り替え部)
    if (mode.cull === 'none') {
      gl.disable(gl.CULL_FACE);
    } else {
      gl.enable(gl.CULL_FACE);
      gl.cullFace(mode.cull === 'back' ? gl.BACK : gl.FRONT); // 捨てる側を指定する
    }

立方体のような閉じた不透明の形なら、裏面は定義上必ず他の面に覆われて見えないので、cullFace(gl.BACK)を入れても絵は 1 ピクセルも変わらず、ラスタライズとフラグメントシェーダーの仕事だけがおよそ半分に 減ります。入れ得です。一方、1 枚きりの板や、内側も見せたい器のような形では、消えてほしくない面が 消えます。「モデルの一部が透明人間のように欠けたら、まずカリングとワインディング順を疑う」は 3D プログラミングの定番のデバッグ知識です。

6. gl_FrontFacing — 表裏をシェーダーから見る

表裏の判定結果は、フラグメントシェーダーからも読めます。第5章の組み込み変数カタログで 「第13章のカリングとセット」とだけ紹介したgl_FrontFacingです。型はboolで、いま塗っているフラグメントが表向きの三角形のものならtrueになります。

src/lessons/13-first-3d/cube.frag
#version 300 es

precision highp float;

in vec3 v_color;

// 表裏の可視化モード。false なら面ごとの色をそのまま出力する
uniform bool u_showFacing;

out vec4 fragColor;

void main() {
  if (u_showFacing) {
    // gl_FrontFacing は、いま塗っている面が「表」なら true になる組み込み変数(第5章)。
    // 表裏はワインディング順(頂点の巻き方向)から決まる
    fragColor = gl_FrontFacing
      ? vec4(0.3, 0.8, 0.55, 1.0) // 表: 緑
      : vec4(0.9, 0.35, 0.45, 1.0); // 裏: 赤
  } else {
    fragColor = vec4(v_color, 1.0);
  }
}

デモの「カリングで裏面だけ」モードは、この塗り分けとcullFace(gl.FRONT)の合わせ技です。表面が全部捨てられるので、見えるのは立方体の内側の壁 = 裏面だけ。 画面のすべてが裏の色(赤)で塗られることが、「捨てられたのは本当に表だった」ことの証明になります。 凹んだ形の内側だけ色を変える、紙のような薄いオブジェクトの裏に別の模様を出す、といった表現が この 1 変数でできます(three.js のDoubleSideなマテリアルで裏面の描き分けをするときに内部で使われているのも、これです)。

7. パイプライン図に「深度テスト」の段を足す

第3章のパイプライン図の図注に「実際にはフラグメントシェーダーの後に深度テストやブレンドといった 段もあり、後の章で図に描き足していきます」と書きました。約束どおり、この章で学んだ 2 つを描き足した最新版がこちらです。

頂点データ(VBO + IBO)CPU から転送頂点シェーダー頂点ごとに実行ラスタライザカリングはここフラグメントシェーダーピクセルごとに実行深度テスト負けたら捨てるこの章で追加ブレンド半透明の合成第30章で追記フレームバッファ色 + 深度= 自分が GLSL で書く GPU プログラム= まだ扱っていない段(点線)
更新版のパイプライン図。カリングはラスタライザの段(正確にはラスタライズ直前の組み立て時)で、 深度テストはフラグメントシェーダーので行われる「フラグメントごとのテスト」です。 テストに負けたフラグメントは、色を計算し終わっていても捨てられます。点線のブレンド(半透明の 合成)の段は第30章で追記します。

フレームバッファの中身も「色」から「色 + 深度」に増えたことに注目してください。 深度テストは、フレームバッファ側に深度の記憶があって初めて成立します。第2章のコンテキスト属性depthが制御していたのは、この既定フレームバッファの深度領域の有無でした。第20章で自作のフレームバッファ (FBO) を作るときには、この深度領域も自分でアタッチすることになります。

コード全文

CPU 側は第4章以来のループ + リサイズ構成に、第8章の切り替えボタンを合わせた形です。 ボタンはシェーダーを差し替えるのではなく、applyModeで深度テストとカリングのスイッチを掛け替えるだけです。カメラが固定なのでビュー行列は初期化時に 1 回だけ送り、モデル行列と射影行列(アスペクト比が変わりうる)は毎フレーム送り直しています。

src/lessons/13-first-3d/main.ts
// 第13章: 最初の 3D — インデックス描画・深度テスト・カリング
// 立方体を「24 頂点 + 36 インデックス」で組み、インターリーブした 1 本の VBO と
// IBO を VAO に配線して drawElements で描く。
// 深度テストとカリングはコンテキストの状態(第1章の状態機械)なので、
// ボタンでの切り替えは gl.enable / gl.disable の掛け替えとして実装する。

import { mat4 } from 'gl-matrix';
import { compileShader, linkProgram } from '../../lib/shader';
import fragmentSource from './cube.frag?raw';
import vertexSource from './cube.vert?raw';

// ---------------------------------------------------------------------------
// 描画状態のプリセット(ボタンで切り替える)
// ---------------------------------------------------------------------------

interface RenderMode {
  id: string;
  label: string;
  /** gl.DEPTH_TEST を有効にするか */
  depthTest: boolean;
  /** カリング設定。none = 無効、back / front = その面を捨てる */
  cull: 'none' | 'back' | 'front';
  /** フラグメントシェーダーで gl_FrontFacing による表裏の塗り分けをするか */
  showFacing: boolean;
}

const modes = [
  { id: 'no-depth', label: '深度テストなし', depthTest: false, cull: 'none', showFacing: false },
  { id: 'depth', label: '深度テストあり', depthTest: true, cull: 'none', showFacing: false },
  { id: 'culling', label: 'カリングで裏面だけ', depthTest: true, cull: 'front', showFacing: true },
] as const satisfies readonly RenderMode[];

// ---------------------------------------------------------------------------
// カメラ定数(第3部で共通の 45°・0.1・100)
// ---------------------------------------------------------------------------

const FOVY = (45 * Math.PI) / 180; // 垂直方向の視野角
const NEAR = 0.1; // 近クリップ面。小さくしすぎると深度の精度が落ちる(本文 4 節)
const FAR = 100; // 遠クリップ面

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

function setup(
  gl: WebGL2RenderingContext,
  canvas: HTMLCanvasElement,
  controls: HTMLParagraphElement,
): void {
  // --- プログラム(コンパイル・リンクは第3章、共通化は第6章) -----------------

  const program = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, vertexSource),
    compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource),
  );

  // --- 立方体の頂点データ(手書き) ------------------------------------------

  // 1 行 = 1 頂点で、[位置 x, y, z, 色 r, g, b] を 1 本の配列に交互に詰める(インターリーブ)。
  // 立方体の角は 8 つだが、面ごとに色が違う = 属性の組が違うので、
  // 6 面 × 4 頂点 = 24 頂点が必要になる(本文 1 節)。
  // 各面の 4 頂点は「外から見て反時計回り(CCW)」の順に並べてある(本文 5 節のワインディング順)。
  // biome-ignore format: 1 頂点 = 1 行の並びを保つ
  const vertices = new Float32Array([
    // 前面 (+z): 赤
    -0.5, -0.5,  0.5,  0.86, 0.30, 0.36,
     0.5, -0.5,  0.5,  0.86, 0.30, 0.36,
     0.5,  0.5,  0.5,  0.86, 0.30, 0.36,
    -0.5,  0.5,  0.5,  0.86, 0.30, 0.36,
    // 背面 (-z): 緑
     0.5, -0.5, -0.5,  0.30, 0.75, 0.45,
    -0.5, -0.5, -0.5,  0.30, 0.75, 0.45,
    -0.5,  0.5, -0.5,  0.30, 0.75, 0.45,
     0.5,  0.5, -0.5,  0.30, 0.75, 0.45,
    // 右面 (+x): 青
     0.5, -0.5,  0.5,  0.32, 0.55, 0.95,
     0.5, -0.5, -0.5,  0.32, 0.55, 0.95,
     0.5,  0.5, -0.5,  0.32, 0.55, 0.95,
     0.5,  0.5,  0.5,  0.32, 0.55, 0.95,
    // 左面 (-x): 黄
    -0.5, -0.5, -0.5,  0.92, 0.78, 0.30,
    -0.5, -0.5,  0.5,  0.92, 0.78, 0.30,
    -0.5,  0.5,  0.5,  0.92, 0.78, 0.30,
    -0.5,  0.5, -0.5,  0.92, 0.78, 0.30,
    // 上面 (+y): 紫
    -0.5,  0.5,  0.5,  0.62, 0.44, 0.92,
     0.5,  0.5,  0.5,  0.62, 0.44, 0.92,
     0.5,  0.5, -0.5,  0.62, 0.44, 0.92,
    -0.5,  0.5, -0.5,  0.62, 0.44, 0.92,
    // 下面 (-y): シアン
    -0.5, -0.5, -0.5,  0.30, 0.80, 0.85,
     0.5, -0.5, -0.5,  0.30, 0.80, 0.85,
     0.5, -0.5,  0.5,  0.30, 0.80, 0.85,
    -0.5, -0.5,  0.5,  0.30, 0.80, 0.85,
  ]);

  // 各面の 4 頂点(0〜3 番)を、対角線で三角形 2 枚に割る。
  // 24 頂点を使い回すので、値は 0〜23 の「頂点番号」になる。
  // biome-ignore format: 1 面 = 1 行の並びを保つ
  const indices = new Uint16Array([
     0,  1,  2,   0,  2,  3, // 前面
     4,  5,  6,   4,  6,  7, // 背面
     8,  9, 10,   8, 10, 11, // 右面
    12, 13, 14,  12, 14, 15, // 左面
    16, 17, 18,  16, 18, 19, // 上面
    20, 21, 22,  20, 22, 23, // 下面
  ]);

  // --- VAO への配線(インターリーブ + IBO) ----------------------------------

  const vao = gl.createVertexArray();
  gl.bindVertexArray(vao);

  const vbo = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
  gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

  // stride / offset はバイト単位。float は 4 バイトなので BYTES_PER_ELEMENT で数える
  const stride = 6 * Float32Array.BYTES_PER_ELEMENT; // 1 頂点 = 6 float = 24 バイト
  gl.enableVertexAttribArray(0);
  gl.vertexAttribPointer(0, 3, gl.FLOAT, false, stride, 0); // 位置: 各頂点の先頭から 3 つ
  gl.enableVertexAttribArray(1);
  gl.vertexAttribPointer(1, 3, gl.FLOAT, false, stride, 3 * Float32Array.BYTES_PER_ELEMENT); // 色: 位置 3 つ分(12 バイト)の後ろ

  // インデックスバッファ (IBO)。作り方は VBO と同じで、ターゲットだけが違う。
  // ELEMENT_ARRAY_BUFFER のバインドは「VAO の状態」として記録される(本文 3 節の非対称)
  const ibo = gl.createBuffer();
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);

  // VAO を先に解除してから IBO を外す。VAO バインド中に null を入れると
  // 「この VAO は IBO なし」が記録されてしまう
  gl.bindVertexArray(null);
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);

  // --- uniform と、フレーム間で使い回す行列 ---------------------------------

  const modelLocation = gl.getUniformLocation(program, 'u_model');
  const viewLocation = gl.getUniformLocation(program, 'u_view');
  const projectionLocation = gl.getUniformLocation(program, 'u_projection');
  const showFacingLocation = gl.getUniformLocation(program, 'u_showFacing');

  const model = mat4.create();
  const view = mat4.create();
  const projection = mat4.create();

  // カメラは固定なので、ビュー行列は初期化時に 1 回だけ送ればよい(uniform はプログラムの状態)
  mat4.lookAt(view, [1.1, 0.9, 1.9], [0, 0, 0], [0, 1, 0]);
  gl.useProgram(program);
  gl.uniformMatrix4fv(viewLocation, false, view);

  gl.clearColor(0.06, 0.07, 0.09, 1.0);

  // --- 描画状態の切り替え ----------------------------------------------------

  function applyMode(mode: RenderMode): void {
    // enable / disable はスイッチ盤の掛け替え。一度切り替えたら、
    // 戻すまで以降のすべてのドローコールに効き続ける
    if (mode.depthTest) {
      gl.enable(gl.DEPTH_TEST); // 比較関数は既定の gl.LESS(小さい = 手前が勝つ)
    } else {
      gl.disable(gl.DEPTH_TEST);
    }

    if (mode.cull === 'none') {
      gl.disable(gl.CULL_FACE);
    } else {
      gl.enable(gl.CULL_FACE);
      gl.cullFace(mode.cull === 'back' ? gl.BACK : gl.FRONT); // 捨てる側を指定する
    }

    // bool の uniform は uniform1i で 0 / 1 を送る
    gl.useProgram(program);
    gl.uniform1i(showFacingLocation, mode.showFacing ? 1 : 0);
  }

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

  function resizeIfNeeded(): void {
    const dpr = Math.min(window.devicePixelRatio, 2);
    // display: none などで表示サイズが 0 のときも、バッファは最低 1px 確保しておく
    // (0 だとアスペクト比が 0 / 0 = NaN になり、射影行列が丸ごと壊れる)
    const width = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const height = Math.max(1, Math.floor(canvas.clientHeight * dpr));

    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width;
      canvas.height = height;
      gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
    }
  }

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

  function frame(timestamp: DOMHighResTimeStamp): void {
    resizeIfNeeded();

    const time = timestamp / 1000;

    // 色と深度を両方クリアする。深度バッファを消し忘れると
    // 前フレームの「奥行きの記憶」が残り、以降のフレームがほぼ描けなくなる(本文 4 節)
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    // モデル行列: 2 軸でゆっくり回す(毎フレーム作り直す)
    mat4.identity(model);
    mat4.rotateY(model, model, time * 0.7);
    mat4.rotateX(model, model, time * 0.4);

    // 射影行列: アスペクト比は描画バッファの実サイズ(viewport と同じ値)から毎フレーム計算する
    const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
    mat4.perspective(projection, FOVY, aspect, NEAR, FAR);

    gl.useProgram(program);
    gl.uniformMatrix4fv(modelLocation, false, model);
    gl.uniformMatrix4fv(projectionLocation, false, projection);

    gl.bindVertexArray(vao);
    // drawArrays の代わりに drawElements。VAO に記録された IBO からインデックスを
    // 36 個読み、Uint16Array に合わせて gl.UNSIGNED_SHORT として解釈する
    gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0);

    requestAnimationFrame(frame);
  }

  // --- 切り替えボタン(第8章と同じ作り) --------------------------------------

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

  applyMode(modes[0]);
  // このページのデモはページと寿命を共にするので、rAF ループの停止もリスナー解除もしていない。
  // 止める必要が出るケースは第6章の stop()、リソース解放の一般論は第35章
  requestAnimationFrame(frame);
}

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

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

// コンテキスト属性 depth は既定で true(第2章)。
// このおかげで、何も指定しなくても深度バッファは最初から確保されている
const gl = canvas.getContext('webgl2');
if (!gl) {
  throw new Error('このブラウザは WebGL2 に対応していません');
}

setup(gl, canvas, controls);

three.js との対応

この章の内容は、three.js では「ジオメトリを渡せば黙って正しく描かれる」の内側にあたります。

three.jsこの章
new BoxGeometry(1, 1, 1)24 頂点 + 36 インデックスの手書き。BoxGeometry も内部でまったく同じ構成(面ごとに属性が違うので 24 頂点)を自動生成している
BufferGeometry.setIndex(...)IBO(ELEMENT_ARRAY_BUFFER)+drawElements。index があるジオメトリは three.js も内部でdrawElementsを呼ぶ
InterleavedBuffer / InterleavedBufferAttribute1 本の Float32Array + stride / offset の配線
Material.side(既定 FrontSide)カリング設定。FrontSide=cullFace(gl.BACK)有効、DoubleSide=disable(gl.CULL_FACE)BackSideについて three.js の実装はcullFaceではなくfrontFace(gl.CW)への切り替えで実現していて(WebGLState.setMaterial)、この章のやり方ではcullFace(gl.FRONT)が等価です。閉じたメッシュなら見た目は同じですが、frontFaceを反転するとgl_FrontFacing(6 節)の値も反転する点だけは違います
Material.depthTest / depthFuncgl.enable(gl.DEPTH_TEST)gl.depthFunc(...)
renderer.render(...) が毎フレーム自動でクリアgl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)を自分で呼ぶ。three.js が深度バッファも毎フレーム消してくれていた(renderer.autoClear)

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

この章のコードはsrc/lessons/13-first-3d/にあります。深度・カリング・バイトレイアウトは「壊れ方」に情報が詰まっているので、 ぜひ一通り壊してみてください。

まとめ

立方体は手書きできましたが、球やトーラスの数百頂点を手で書くわけにはいきません。 次章「ジオメトリをつくる」では、この章とまったく同じ「インターリーブ + インデックス」の形の頂点データをコードで生成します。位置に加えて法線と UV も計算し、以降の章で使い回せるよう共通ヘルパーsrc/lib/geometry.tsに切り出します(第6章で予告した抽象化の第 2 弾です)。