第1部 WebGL2 の基本 — 第3章

最初の三角形

この章では、WebGL2 の最小構成で三角形を 1 枚描きます。 たった三角形 1 枚のためにおよそ 100 行のコードを書くことになりますが、 ここにはWebGL の描画パイプラインの全要素が詰まっています。 以降の章の多くは、この章で書く流れの変奏にすぎません。

three.js で言えば、new Mesh(geometry, material)を作ってrenderer.render(scene, camera)を呼んだときに内部で起きていることを、 すべて自分の手で書きます。

この章の成果物。クリップ空間に置いた 3 つの頂点がラスタライザによってピクセルへ分解され、 頂点ごとに指定した色が補間されてグラデーションになります。これは画像ではなく、 いまあなたのブラウザの GPU が描画したものです。なお、頂点座標上は左右対称の三角形ですが、 この canvas は横長 (3:2) のため横に伸びて表示されています(理由は「2. クリップ空間」で説明します)。

この章で学ぶこと:

1. 描画パイプラインの全体像

GPU が三角形を描くまでの流れは、次のような一本のパイプラインになっています。 まずこの絵を頭に入れてください。この後のコードはすべて、 このパイプラインの各段に「材料」と「プログラム」を供給する作業です。

頂点データ(VBO)CPU から転送頂点シェーダー頂点ごとに実行ラスタライザ固定機能(自動)フラグメントシェーダーピクセルごとに実行フレームバッファ画面へ= 自分が GLSL で書く GPU プログラム(シェーダー)
WebGL2 の描画パイプライン(簡略図)。頂点シェーダーは「頂点 1 つにつき 1 回」、フラグメントシェーダーは「塗る候補になったピクセル 1 つにつき 1 回」実行されます。中間のラスタライザは GPU の固定機能で、プログラムできません。実際にはフラグメントシェーダーの後に深度テストやブレンドといった段もあり、後の章(第13章・第30章)で図に描き足していきます。

それぞれの段の役割は次のとおりです。

つまり自分で書くのは「2 つの小さな GPU プログラム(GLSL)」と、 「それらに材料を配線する CPU 側のコード(TypeScript)」の 2 種類です。

2. クリップ空間 — WebGL の座標系

頂点シェーダーの仕事は「頂点をクリップ空間のどこに置くか」を出力することです。 クリップ空間は、canvas のピクセルサイズとは無関係に、横も縦も -1.0 から +1.0の範囲で表される座標系です。 中央が原点で、y 軸は上向きです(2D の canvas API やスクリーン座標とは逆)。

(0.0, 0.8)(-0.8, -0.8)(0.8, -0.8)(-1, -1)(+1, +1)+x+y(0, 0)
クリップ空間と、今回置く 3 頂点。この正方形が canvas 全体に引き伸ばされて表示されます(そのため canvas が正方形でないと図形は歪みます — アスペクト比の補正は後の章で扱います)。

3. 2 つのシェーダーを書く

シェーダーは GLSL ES 3.00 という C に似た言語で書き、別ファイル(.vert/ .frag)として管理します。 言語仕様の全体像は第5章でまとめて扱うので、ここでは登場した要素だけ理解すれば十分です。 まず頂点シェーダーです。

src/lessons/03-first-triangle/triangle.vert
#version 300 es

// 頂点属性: CPU 側から VBO 経由で渡されるデータ。
// layout(location = N) で「配線番号」を固定している。
layout(location = 0) in vec2 a_position;
layout(location = 1) in vec3 a_color;

// フラグメントシェーダーへ渡す値。ラスタライザが頂点間を補間する。
out vec3 v_color;

void main() {
  v_color = a_color;

  // gl_Position はクリップ空間の座標 (vec4)。
  // z = 0.0(奥行きなし)、w = 1.0(同次座標。詳細は第12章)
  gl_Position = vec4(a_position, 0.0, 1.0);
}

次にフラグメントシェーダーです。

src/lessons/03-first-triangle/triangle.frag
#version 300 es

// フラグメントシェーダーでは float の精度指定が必須
precision highp float;

// 頂点シェーダーの out と同じ名前・型で受け取る(補間済みの値が入る)
in vec3 v_color;

// 出力先。WebGL1 の gl_FragColor は廃止され、自分で out を宣言する
out vec4 fragColor;

void main() {
  fragColor = vec4(v_color, 1.0);
}

デモの三角形がグラデーションになっているのは、このパイプラインの補間のおかげです。 頂点で指定したのは赤・緑・青の 3 色だけですが、ラスタライザが三角形内部の位置に応じて 3 色を混ぜた値を v_color に入れてくれるため、 フラグメントシェーダーはそれを出力するだけで滑らかなグラデーションになります。

4. シェーダーをコンパイル・リンクする

GLSL のソースコードは実行時に GPU 向けにコンパイルされます。ここからは CPU 側 (TypeScript) の仕事です。シェーダー 1 つのコンパイルは、決まりきった手順の連続です。

src/lessons/03-first-triangle/main.ts(抜粋)
function compileShader(gl: WebGL2RenderingContext, type: GLenum, source: string): WebGLShader {
  const shader = gl.createShader(type);
  if (!shader) {
    throw new Error('シェーダーオブジェクトを作成できませんでした');
  }
  gl.shaderSource(shader, source); // ソースコードを渡す
  gl.compileShader(shader); // コンパイル
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    const log = gl.getShaderInfoLog(shader); // 人間向けのエラーメッセージ
    gl.deleteShader(shader);
    throw new Error(`シェーダーのコンパイルに失敗しました:\n${log ?? '(ログなし)'}`);
  }
  return shader;
}

重要なのはエラーチェックです。シェーダーのコンパイルに失敗しても、JavaScript の例外は発生しません。チェックを書かないと「エラーも出ないが何も描かれない」という最悪のデバッグ体験になります。getShaderParameter(COMPILE_STATUS)で成否を確認し、失敗時はgetShaderInfoLogでエラーメッセージ(行番号付き)を取り出す — この作法を必ずセットで書きます。

コンパイルした頂点・フラグメントシェーダーの 2 つは、プログラムという 1 つのオブジェクトにリンクして初めて使えるようになります。

src/lessons/03-first-triangle/main.ts(抜粋・チェックを簡略化)
const program = gl.createProgram();
gl.attachShader(program, vertexShader); // 頂点シェーダーを取り付け
gl.attachShader(program, fragmentShader); // フラグメントシェーダーを取り付け
gl.linkProgram(program); // 2 つを 1 本のパイプラインに結合

if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
  throw new Error(gl.getProgramInfoLog(program) ?? '');
}

リンク時には「頂点シェーダーの out とフラグメントシェーダーのinがつながるか」などが検証されます。これも同様にLINK_STATUSのチェックが必須です。three.js のShaderMaterialがやってくれていたのは、まさにこの一連の処理です。 なお、リンクが完了すればシェーダーオブジェクト単体はもう使わないので、deleteShaderで削除して構いません(コード全文ではリンク直後に削除しています)。

5. 頂点データを GPU へ送る — VBO

次は材料の準備です。頂点データは JavaScript の配列のままでは GPU から見えません。VBO (Vertex Buffer Object)と呼ばれる GPU 側のメモリ領域を作り、型付き配列 (Float32Array) で転送します。

src/lessons/03-first-triangle/main.ts(抜粋)
// クリップ空間 (-1〜+1) での xy 座標。3 頂点 = 三角形 1 枚
const positions = new Float32Array([
  0.0, 0.8, // 上
  -0.8, -0.8, // 左下
  0.8, -0.8, // 右下
]);

const positionBuffer = gl.createBuffer(); // ① GPU 側にバッファを作る
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); // ② 「作業対象」としてバインドする
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW); // ③ データを転送する

// 各頂点の RGB 色も、まったく同じ ①②③ の手順で別のバッファへ
const colors = new Float32Array([
  1.0, 0.25, 0.35, // 上: 赤
  0.25, 0.9, 0.5, // 左下: 緑
  0.3, 0.5, 1.0, // 右下: 青
]);

const colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW);

②で一度「バインド」してから③で転送している点に注目してください。bufferDataの引数にバッファ自体を渡していないのに、②でバインドしたバッファへデータが入ります。 これが第1章で説明した状態機械としての WebGL の書き味です。ほとんどの WebGL API は「いまバインドされているモノ」に対して作用します。three.js の宣言的なスタイルとの一番大きな違いなので、常に「いま何がバインドされているか」を意識してください。

STATIC_DRAWは「データは一度書いたらほぼ変更しない」というヒントで、GPU がメモリの置き場所を最適化するために使われます(毎フレーム書き換えるならDYNAMIC_DRAW)。

6. 属性を配線する — VAO と vertexAttribPointer

VBO はただのバイト列で、GPU はこのままでは「どこからどこまでが 1 頂点分で、それをどのin変数に渡すのか」を知りません。 その対応関係(配線)を設定するのがvertexAttribPointerで、配線の設定一式を記録しておくオブジェクトがVAO (Vertex Array Object)です。

src/lessons/03-first-triangle/main.ts(抜粋)
const vao = gl.createVertexArray();
gl.bindVertexArray(vao); // 以降の属性設定はこの VAO に記録される

// 位置: layout(location = 0) in vec2 a_position へ
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.enableVertexAttribArray(0); // location 0 を有効化
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);

// 色: layout(location = 1) in vec3 a_color へ
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 0, 0);

gl.bindVertexArray(null); // 記録し終わったので解除しておく

vertexAttribPointerは WebGL で最初につまずきやすい関数なので、6 つの引数を整理しておきます。位置属性(0, 2, gl.FLOAT, false, 0, 0)の場合:

引数意味
index0配線先の属性番号。シェーダーの layout(location = 0) と一致させる
size21 頂点あたりの成分数。vec2 なので 2
typegl.FLOAT各成分の型。Float32Array で送ったので FLOAT
normalizedfalse整数型を 0〜1 に正規化して渡すか。FLOAT では無視される
stride0次の頂点の読み出し開始までのバイト間隔。0 は「密に詰まっている」の意(= 自動計算)
offset0バッファ先頭から読み出しを始める位置(バイト)

今回は位置と色を別々のバッファに入れたので strideoffset も 0 ですが、1 つのバッファに「位置, 色, 位置, 色, …」と交互に詰めるインターリーブという置き方をする場合に、この 2 つの引数が意味を持ちます(3D メッシュを扱う後の章で使います)。

もう 1 つ、忘れやすいのが enableVertexAttribArray です。属性は既定では 「バッファから読む」がオフになっていて、有効化を忘れてもエラーは出ません。 その属性は全頂点で同じ定数(既定値 (0, 0, 0, 1))として扱われるため、 位置なら何も描かれず、色なら真っ黒になる — という「黙って失敗する」典型パターンです。

7. 描画する

材料(VBO・VAO)とプログラムが揃いました。あとは画面をクリアして、ドローコールを 1 回発行するだけです。

src/lessons/03-first-triangle/main.ts(抜粋)
// クリップ空間 (-1〜+1) と描画バッファのピクセルの対応を設定
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);

// 画面をクリア
gl.clearColor(0.06, 0.07, 0.09, 1.0); // クリア色を状態として設定
gl.clear(gl.COLOR_BUFFER_BIT); // 実際に塗りつぶす

// 「このプログラム」と「この配線」で 3 頂点を三角形として描画
gl.useProgram(program);
gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);

drawArrays(gl.TRIANGLES, 0, 3)は「バインド中の VAO の配線で頂点を 0 番から 3 個読み、3 個ごとに三角形として組み立てて描け」という命令です。 この 1 行が引き金になって、冒頭のパイプライン図の全段が GPU 上で走ります。 頂点シェーダーが 3 回、フラグメントシェーダーが三角形の面積分(表示サイズにもよりますが、このデモでは数十万回)実行されます。

コード全文

ここまでの断片を、実際に動く 1 本のファイルとして整えた全文です。本文とは構成が少し違うので 見比べてみてください: コンパイルとリンクは関数に切り出し、バッファの生成・転送は VAO の配線と同じ場所に属性ごとにまとめています。また、冒頭には canvas の取得と解像度の設定(第2章の内容)が入っています。このページのデモは、このファイルがそのまま動いています。

src/lessons/03-first-triangle/main.ts
// 第3章: 最初の三角形
// シェーダーのコンパイル → 頂点データの転送 → 属性の配線 → 描画、という
// WebGL2 のコア描画パスを、抽象化なしで一通り書く。

import fragmentSource from './triangle.frag?raw';
import vertexSource from './triangle.vert?raw';

// ---------------------------------------------------------------------------
// シェーダーのコンパイルとプログラムのリンク
// 失敗しても JavaScript の例外は発生しないため、必ず自分でチェックする
// ---------------------------------------------------------------------------

function compileShader(gl: WebGL2RenderingContext, type: GLenum, source: string): WebGLShader {
  const shader = gl.createShader(type);
  if (!shader) {
    throw new Error('シェーダーオブジェクトを作成できませんでした');
  }
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    const log = gl.getShaderInfoLog(shader);
    gl.deleteShader(shader);
    throw new Error(`シェーダーのコンパイルに失敗しました:\n${log ?? '(ログなし)'}`);
  }
  return shader;
}

function linkProgram(
  gl: WebGL2RenderingContext,
  vertexShader: WebGLShader,
  fragmentShader: WebGLShader,
): WebGLProgram {
  const program = gl.createProgram();
  if (!program) {
    throw new Error('プログラムオブジェクトを作成できませんでした');
  }
  gl.attachShader(program, vertexShader);
  gl.attachShader(program, fragmentShader);
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    const log = gl.getProgramInfoLog(program);
    gl.deleteProgram(program);
    throw new Error(`プログラムのリンクに失敗しました:\n${log ?? '(ログなし)'}`);
  }
  // リンクが済めばシェーダーオブジェクト単体は不要になる
  gl.deleteShader(vertexShader);
  gl.deleteShader(fragmentShader);
  return program;
}

// ---------------------------------------------------------------------------
// 1. コンテキストの取得
// ---------------------------------------------------------------------------

const canvas = document.querySelector<HTMLCanvasElement>('#demo');
if (!canvas) {
  throw new Error('canvas 要素 #demo が見つかりません');
}

// 描画バッファの解像度を「CSS 上の表示サイズ × devicePixelRatio」に合わせる(第2章)
const dpr = Math.min(window.devicePixelRatio, 2);
canvas.width = Math.floor(canvas.clientWidth * dpr);
canvas.height = Math.floor(canvas.clientHeight * dpr);

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

// ---------------------------------------------------------------------------
// 2. シェーダーをコンパイルしてプログラムを作る
// ---------------------------------------------------------------------------

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

// ---------------------------------------------------------------------------
// 3. 頂点データを用意して GPU のバッファ(VBO)へ転送する
// ---------------------------------------------------------------------------

// クリップ空間 (-1〜+1) での xy 座標。3 頂点 = 三角形 1 枚
// biome-ignore format: 1 頂点 = 1 行の並びを保つ
const positions = new Float32Array([
  0.0, 0.8, // 上
  -0.8, -0.8, // 左下
  0.8, -0.8, // 右下
]);

// 各頂点の RGB 色
// biome-ignore format: 1 頂点 = 1 行の並びを保つ
const colors = new Float32Array([
  1.0, 0.25, 0.35, // 上: 赤
  0.25, 0.9, 0.5, // 左下: 緑
  0.3, 0.5, 1.0, // 右下: 青
]);

// ---------------------------------------------------------------------------
// 4. VAO を作り、バッファと頂点属性の配線を記録する
// ---------------------------------------------------------------------------

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

// 位置: シェーダーの layout(location = 0) in vec2 a_position へ
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);

// 色: シェーダーの layout(location = 1) in vec3 a_color へ
const colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 0, 0);

gl.bindVertexArray(null);

// ---------------------------------------------------------------------------
// 5. 描画する
// ---------------------------------------------------------------------------

// クリップ空間と描画バッファのピクセルの対応を設定
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);

// 画面を塗りつぶす色を指定してクリア
gl.clearColor(0.06, 0.07, 0.09, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);

// 「このプログラム」と「この VAO(配線)」を使って 3 頂点を三角形として描画
gl.useProgram(program);
gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);

three.js との対応

この章で書いたものを three.js の API に対応付けると、次のようになります。

three.jsこの章の生 WebGL2
BufferGeometry + BufferAttributeFloat32Array + VBO + vertexAttribPointer + VAO
ShaderMaterialcompileShader ×2 + linkProgram + エラーチェック
Mesh「プログラム + VAO」の組を自分で管理する
renderer.render(scene, camera)clearuseProgrambindVertexArraydrawArrays
cameraobject.position などまだ登場しない — 第11〜12章で行列として自作する

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

この章のコードはsrc/lessons/03-first-triangle/にあります。開発サーバーを起動した状態で ファイルを編集すると、このページに即座に反映されます。眺めるだけでなく、 実際に値を変えて予想どおりに壊れるかを確かめるのが一番の近道です。

まとめ

次章では、この三角形に uniformで時間を渡してアニメーションさせ、毎フレーム描画するループとリサイズ処理を整えます。