最初の三角形
この章では、WebGL2 の最小構成で三角形を 1 枚描きます。 たった三角形 1 枚のためにおよそ 100 行のコードを書くことになりますが、 ここにはWebGL の描画パイプラインの全要素が詰まっています。 以降の章の多くは、この章で書く流れの変奏にすぎません。
three.js で言えば、new Mesh(geometry, material)を作ってrenderer.render(scene, camera)を呼んだときに内部で起きていることを、 すべて自分の手で書きます。
この章で学ぶこと:
- 描画パイプラインの全体像と、クリップ空間という座標系
- GLSL ES 3.00 で書く最小の頂点シェーダー / フラグメントシェーダー
- シェーダーのコンパイル・リンクと、エラーチェックの作法
- VBO によるデータ転送と、VAO による頂点属性の配線
drawArraysによるドローコール
1. 描画パイプラインの全体像
GPU が三角形を描くまでの流れは、次のような一本のパイプラインになっています。 まずこの絵を頭に入れてください。この後のコードはすべて、 このパイプラインの各段に「材料」と「プログラム」を供給する作業です。
それぞれの段の役割は次のとおりです。
- 頂点データ (VBO)— 描きたい形の元になる数値の列。今回は三角形の頂点 3 つ分の座標と色。CPU (TypeScript) から GPU のメモリへ転送します。
- 頂点シェーダー— 各頂点をクリップ空間(次節)のどこに置くかを計算する、自分で書く小さなプログラム。
- ラスタライザ— 3 頂点が作る三角形の内側を、ピクセルの粒 (フラグメント)に分解する固定機能。
- フラグメントシェーダー— 各フラグメントの色を決める、自分で書くもう 1 つのプログラム。
- フレームバッファ — 描画結果の書き込み先。既定では canvas の表示内容。
つまり自分で書くのは「2 つの小さな GPU プログラム(GLSL)」と、 「それらに材料を配線する CPU 側のコード(TypeScript)」の 2 種類です。
2. クリップ空間 — WebGL の座標系
頂点シェーダーの仕事は「頂点をクリップ空間のどこに置くか」を出力することです。 クリップ空間は、canvas のピクセルサイズとは無関係に、横も縦も -1.0 から +1.0の範囲で表される座標系です。 中央が原点で、y 軸は上向きです(2D の canvas API やスクリーン座標とは逆)。
3. 2 つのシェーダーを書く
シェーダーは GLSL ES 3.00 という C に似た言語で書き、別ファイル(.vert/ .frag)として管理します。 言語仕様の全体像は第5章でまとめて扱うので、ここでは登場した要素だけ理解すれば十分です。 まず頂点シェーダーです。
#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);
}in— 頂点属性。CPU 側から頂点ごとに渡されるデータの受け口です。layout(location = 0)は「0 番の配線につなぐ」という宣言で、後で CPU 側から同じ番号を指定してバッファと結び付けます。out— 次の段(フラグメントシェーダー)へ渡す値。ラスタライザが頂点間の値を滑らかに補間して各フラグメントへ届けます。WebGL1 のvaryingに相当します。gl_Position— 頂点シェーダーの必須出力で、クリップ空間での位置です。型はvec4で、zは奥行き(今回は 0.0)、wは同次座標と呼ばれる仕組みのための値です。遠近法を実現する鍵ですが、2D のうちは常に 1.0 と書く、で問題ありません(第12章で詳しく扱います)。正確に言うと、-1〜+1 という範囲は座標を w で割った後の NDC(正規化デバイス座標)の話で、割る前の 4 次元座標がクリップ空間です。この章は w = 1.0 固定なので両者は一致しており、この区別は w が 1 でなくなる第12章で効いてきます。
次にフラグメントシェーダーです。
#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);
}precision highp float;— フラグメントシェーダーでは float の既定精度が未定義のため、精度の宣言が必須です。書き忘れると、float 系の型(float/vec3など)を使った時点でコンパイルエラーになります。in vec3 v_color;— 頂点シェーダーのoutと同じ名前・同じ型で受け取ります。ここに入ってくるのは補間済みの値です。out vec4 fragColor;— 出力する色 (RGBA、各成分 0.0〜1.0)。WebGL1 のgl_FragColorは GLSL ES 3.00 で廃止され、自分で出力変数を宣言します。
デモの三角形がグラデーションになっているのは、このパイプラインの補間のおかげです。 頂点で指定したのは赤・緑・青の 3 色だけですが、ラスタライザが三角形内部の位置に応じて 3 色を混ぜた値を v_color に入れてくれるため、 フラグメントシェーダーはそれを出力するだけで滑らかなグラデーションになります。
4. シェーダーをコンパイル・リンクする
GLSL のソースコードは実行時に GPU 向けにコンパイルされます。ここからは CPU 側 (TypeScript) の仕事です。シェーダー 1 つのコンパイルは、決まりきった手順の連続です。
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 つのオブジェクトにリンクして初めて使えるようになります。
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) で転送します。
// クリップ空間 (-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)です。
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)の場合:
| 引数 | 値 | 意味 |
|---|---|---|
index | 0 | 配線先の属性番号。シェーダーの layout(location = 0) と一致させる |
size | 2 | 1 頂点あたりの成分数。vec2 なので 2 |
type | gl.FLOAT | 各成分の型。Float32Array で送ったので FLOAT |
normalized | false | 整数型を 0〜1 に正規化して渡すか。FLOAT では無視される |
stride | 0 | 次の頂点の読み出し開始までのバイト間隔。0 は「密に詰まっている」の意(= 自動計算) |
offset | 0 | バッファ先頭から読み出しを始める位置(バイト) |
今回は位置と色を別々のバッファに入れたので stride も offset も 0 ですが、1 つのバッファに「位置, 色, 位置, 色, …」と交互に詰めるインターリーブという置き方をする場合に、この 2 つの引数が意味を持ちます(3D メッシュを扱う後の章で使います)。
もう 1 つ、忘れやすいのが enableVertexAttribArray です。属性は既定では 「バッファから読む」がオフになっていて、有効化を忘れてもエラーは出ません。 その属性は全頂点で同じ定数(既定値 (0, 0, 0, 1))として扱われるため、 位置なら何も描かれず、色なら真っ黒になる — という「黙って失敗する」典型パターンです。
7. 描画する
材料(VBO・VAO)とプログラムが揃いました。あとは画面をクリアして、ドローコールを 1 回発行するだけです。
// クリップ空間 (-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章の内容)が入っています。このページのデモは、このファイルがそのまま動いています。
// 第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 + BufferAttribute | Float32Array + VBO + vertexAttribPointer + VAO |
ShaderMaterial | compileShader ×2 + linkProgram + エラーチェック |
Mesh | 「プログラム + VAO」の組を自分で管理する |
renderer.render(scene, camera) | clear→ useProgram → bindVertexArray →drawArrays |
camera、object.position など | まだ登場しない — 第11〜12章で行列として自作する |
手元で動かして、壊してみる
この章のコードはsrc/lessons/03-first-triangle/にあります。開発サーバーを起動した状態で ファイルを編集すると、このページに即座に反映されます。眺めるだけでなく、 実際に値を変えて予想どおりに壊れるかを確かめるのが一番の近道です。
triangle.fragの出力をvec4(1.0, 0.5, 0.0, 1.0)のような固定色に変えて、補間が消えることを確認するmain.tsのpositionsの値を変えて頂点を動かす。±1.0 を超えた部分がどうなるかも見てみる(クリップ空間の「クリップ」の意味がわかります)drawArraysのモードをgl.LINE_LOOPやgl.POINTSに変える(POINTS の場合は頂点シェーダーでgl_PointSize = 20.0;の指定も必要です)triangle.vertの 1 行目の前に空行を入れたり、triangle.fragのprecision行を消したりして、わざとコンパイルエラーを起こし、コンソールに出るエラーメッセージを読んでみる
まとめ
- 描画とは「頂点データ → 頂点シェーダー → ラスタライザ → フラグメントシェーダー → フレームバッファ」というパイプラインに材料を流すこと
- 頂点シェーダーはクリップ空間 (-1〜+1) の座標を出力する
- シェーダーのコンパイル・リンクは失敗しても例外が出ないため、状態チェックが必須
- データは VBO で GPU へ転送し、VAO +
vertexAttribPointerで属性へ配線する - WebGL は状態機械 — API は「いまバインドされているモノ」に作用する
次章では、この三角形に uniformで時間を渡してアニメーションさせ、毎フレーム描画するループとリサイズ処理を整えます。