生 WebGL と TypeScript で Vercel のロゴを作る

15 min read読了の目安(約14000字

はじめに

完全に釣りタイトルです。Three.js などを使わずに生の WebGL で ▲ を描くというだけの話です。いわゆる WebGL の「Hello World」です。VercelNext.js とは一切関係ありません。

WebGL を詳細に解説しているサイトはいくつかあって、内容に関してはどれもすばらしいものです。ただ、何年も前のものなのでソースコードにまだ var が使われているものも多く、また独自関数で処理をまとめていることで、個人的には全体の流れが少しわかりにくくなっている気がします。さらに TypeScript を使っている日本語記事は見当たりませんでしたので、それらを補う形でまとめてみたいと思います。

WebGL では「Hello World」するだけでも利用するメソッドが多く、メソッド名も長くて紛らわしいものばかりなので、TypeScript の静的解析とコード補完の活用はとてもおすすめです。

なお、本記事では全体的な流れに焦点を当てているので、詳細な解説は下記サイトなどをご参考ください。

HTML と canvas

WebGL を一言でいうと、3D グラフィックスを 2D である HTML の canvas 内に描画するための API です。ということでまずは HTML と canvas を用意します。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Hello WebGL</title>
</head>
<body>
  <canvas id="canvas" width="500" height="500"></canvas>
</body>
</html>

制御コードとシェーダーコード

WebGL のプログラムは JavaScript で記述する制御コードと、GPU で実行される GLSL という言語で記述するシェーダーコードで構成されます。

制御コード

まずはメインの制御コードを記述するための JS ファイルを用意して HTML に読み込ませます。今回実際に記述するのは TypeScript ですが、main.jsmain.ts からコンパイルされたものと思ってください。TypeScript の設定は割愛させていただきますが、"strict": true に対応した解説になっています。

index.html
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Hello WebGL</title>
+  <script src="main.js" defer></script>
 </head>
 <body>
   <canvas id="canvas" width="500" height="500"></canvas>

シェーダーコード

シェーダーコードは、script タグを利用するなど、いくつか記述方法がありますが、文字列としてプログラムに渡すことができればなんでもいいので、直接上記 main.ts 内に文字列として記述してしまいます。ということでここでは何も用意する必要はありません。

準備

メイン関数

まずはメイン関数を用意してページの読み込みが完了したら実行されるようにしておきます。

main.ts
const main = () => {
  // 以降はすべてこの中に記述します。
}
window.onload = main;

#canvas 要素の取得

#canvas 要素を取得するだけでは、TypeScript に Element | null として型推論されて getContext() メソッドなどを使おうとするとコンパイルエラーになります。

コンパイルを通すだけなら document.querySelector('#canvas') as HTMLCanvasElement のように型アサーションしてもいいんですが、ここは一応丁寧に型ガードしておきます。もし #canvas<div> などに変更したら、エラーが出力されてプログラムは停止するようになります。

main.ts
const canvas = document.querySelector('#canvas');

if (!(canvas instanceof HTMLCanvasElement)) {
  throw new Error('<canvas> 要素がありません。');
}

WebGL レンダリングコンテキスト

getContext()'webgl' 引数を与えて WebGL レンダリングコンテキストを取得します。もしブラウザが WebGL に対応していないなど、取得に失敗した場合はコンソールにエラーを出力してプログラムを停止します。

main.ts
const gl = canvas.getContext('webgl');

if (!gl) {
  throw new Error('WebGL の初期化に失敗しました。');
}

画面の初期化

プリセット値の指定

初期化する前にまず初期値をセットする必要があります。色のセットは clearColor(red, green, blue, alpha) メソッドを使います。引数はすべて 0 から 1 の値をとり、1, 1, 1, 1 が白、0, 0, 0, 1 が黒。

main.ts
gl.clearColor(0, 0, 0, 1);

カラーバッファの初期化

初期化は clear() メソッドを使います。色を初期化したい場合は引数に gl.COLOR_BUFFER_BIT を与えることで、先ほどセットした色で初期化します。

main.ts
gl.clear(gl.COLOR_BUFFER_BIT);

これで画面上に500*500ピクセルの黒い四角が現れます。

黒で初期化された canvas

頂点シェーダーとフラグメントシェーダー

前述のように WebGL コンテンツを描画するには GLSL で記述されたシェーダーコードが必要です。渡された座標や色情報などをどのように処理するかを決めます。

シェーダーには頂点シェーダーフラグメントシェーダーの2種類があります。

頂点シェーダー (vertex shader)

名前のとおり頂点に関する情報を処理します。頂点なので位置情報は必須です。それ以外に色・法線・テクスチャなどの情報を持つこともできます。例えば三角形の頂点がそれぞれ赤・緑・青だった場合、グラデーションになって描画されるとイメージしてください (フラグメントシェーダーを一旦無視したとして) 。

頂点シェーダーの初期化

シェーダーの作成は createShader() メソッドを使います。引数は gl.VERTEX_SHADERgl.FRAGMENT_SHADER のどちらかを取ります。今回は頂点シェーダーなので gl.VERTEX_SHADER を入れます。

createShader() の戻り値の型は WebGLShadernull なので、TypeScript の strickNullChecks が有効になっている場合は null チェックをしてあげないとコンパイルエラーになります。

main.ts
const vertexShader = gl.createShader(gl.VERTEX_SHADER);

if (!vertexShader) {
  throw new Error('シェーダーの作成に失敗しました。');
}

頂点シェーダーソースコード (GLSL) の設定

頂点シェーダーを作成したあとは GLSL のソースコードを設定する必要があります。shaderSource() メソッドの第1引数に作成したシェーダーを、第2引数に GLSL ソースを渡します。

GLSL のソースコードは文字列であればいいので、改行を記述できるテンプレートリテラルを利用すると見やすいです。

main.ts
gl.shaderSource(vertexShader, `
  attribute vec4 a_position;

  void main() {
    gl_Position = a_position;
  }
`);

シェーダーコードには必ず main() 関数を定義し、頂点の場合は特別な変数 gl_Position に頂点情報を保存する必要があります。

attribute は、頂点属性であるという意味。頂点データを受け取るための変数宣言と思ってください。ほかに uniformvarying などがありますが、ここでは割愛。

vec4 はデータ型の1種で、4つの浮動小数点の値を持つベクトルデータという意味です。3D なので x, y, z の3座標が必要ですが、効率よく行列計算するために4つ目の w 要素も必要になってきます。これの初期値は 0, 0, 0, 1 です。

a_position は変数名。aattribute であるという意味。後述するバッファから取り出されたデータがここに入ります。今回はこのまま gl_Position に渡していますが、通常は行列計算を行うのでこのまま gl_Position に渡すことはほとんどありません。

頂点シェーダーのコンパイル

シェーダーをコンパイルするには、作成したシェーダーを compileShader() メソッドに引数として渡します。

getShaderParameter() の第1引数にシェーダー、第2引数に gl.COMPILE_STATUS を与えることで、コンパイルに成功したかどうかを確認することができます。失敗した場合は getShaderInfoLog() メソッドの引数に対象シェーダーを渡してログをエラーメッセージに出力します。

main.ts
gl.compileShader(vertexShader);

if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
  throw new Error(`シェーダーのコンパイルでエラーが発生しました: ${gl.getShaderInfoLog(vertexShader)}`);
}

フラグメントシェーダー (fragment shader)

フラグメントシェーダーは、頂点シェーダーによって画面上に描画されるはずのピクセル1つ1つに対し、照明などを適用して最終的にどんな色で描画されるかを決めます。

シェーダー作成の関数化

フラグメントシェーダーも頂点シェーダーと同じように書いていってもいいんですが、シェーダータイプとシェーダーソースの部分だけが違うので、頂点シェーダーの一連のコードを関数にして、フラグメントシェーダーでも使えるようにしておきます。

第1引数は 'VERTEX_SHADER''FRAGMENT_SHADER' のみを指定でき、第2引数にはシェーダーソースの文字列を渡します。

main.ts
// シェーダーを作成する関数
const initShader = (type: 'VERTEX_SHADER' | 'FRAGMENT_SHADER', source: string) => {
  const shader = gl.createShader(gl[type]);

  if (!shader) {
    throw new Error('シェーダーの作成に失敗しました。');
  }

  gl.shaderSource(shader, source);

  gl.compileShader(shader);

  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    throw new Error(`シェーダーのコンパイルでエラーが発生しました: ${gl.getShaderInfoLog(shader)}`);
  }

  return shader;
}

// 頂点シェーダーの作成
const vertexShader = initShader('VERTEX_SHADER', `
  attribute vec4 a_position;

  void main() {
    gl_Position = a_position;
  }
`);

フラグメントシェーダーの作成

先ほど作成した initShader() を使って、シェーダータイプを 'FRAGMENT_SHADER' にして、フラグメントシェーダーのソースを渡します。

main.ts
const fragmentShader = initShader('FRAGMENT_SHADER', `
  void main() {
    gl_FragColor = vec4(1, 1, 1, 1);
  }
`);

フラグメントシェーダーの場合は gl_FragColor という特別な変数に色情報を 0 から 1 の RGBA の4要素で保存する必要があります。ここではハードコーディングしているので、すべてのピクセルは白で描画されることになります。

WebGL プログラム

2つのシェーダーを定義したあとは、WebGL プログラムを作成してリンクする必要があります。

プログラムの初期化

プログラムの作成は createProgram() を使います。返り値の型は WebGLProgram | null なので、null チェックをしてあげます。

main.ts
const program = gl.createProgram();

if (!program) {
  throw new Error('プログラムの作成に失敗しました。');
}

シェーダーをプログラムに接続

attachShader() メソッドの第1引数にプログラム、第2引数にシェーダーを渡し、それぞれのシェーダーを先ほど作成したプログラムに接続します。

main.ts
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);

シェーダーのリンク

linkProgram() メソッドを使ってプログラムに接続されたシェーダーをリンクします。引数にはシェーダーと接続済みのプログラムを渡します。

getProgramParameter() の第1引数にプログラム、第2引数に gl.LINK_STATUS を与え、リンクに成功したかどうかを確認します。失敗した場合は getProgramInfoLog() メソッドの引数にプログラムを渡してログをエラーメッセージに出力します。

main.ts
gl.linkProgram(program);

if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
  throw new Error(`シェーダーのリンクに失敗しました: ${gl.getProgramInfoLog(program)}`);
}

プログラムの起動

プログラムの準備が一通りできたので、useProgram() メソッドで使用するプログラムを WebGL に伝えます。

main.ts
gl.useProgram(program);

頂点バッファ

プログラムができたら、データを与える必要があります。そのデータを保存するのに使うのが頂点バッファです。

バッファの作成

createBuffer() メソッドで頂点バッファを作成できますが、位置法線など、それぞれバッファを用意する必要があります。今回は位置情報のみですが、一応 positionBuffer という変数に入れておきます。

main.ts
const positionBuffer = gl.createBuffer();

バッファのバインド

バッファができたらバインドする必要があります。データを書き込むために、ディスクをドライブに入れるイメージです。

bindBuffer() メソッドの第1引数にターゲット、第2引数にバインドするバッファを指定します。頂点座標や頂点色などのデータは gl.ARRAY_BUFFER をターゲットに指定することになっています。プレステのディスクはプレステにセットする必要がある、みたいなものです。

main.ts
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);

バッファデータの格納

バッファの準備ができたところで、bufferData() メソッドを使って実際に頂点座標を与えていきます。第1引数にターゲット、第2引数にデータ、第3引数に用途を指定します。

positionBuffer という「ディスク」は今 gl.ARRAY_BUFFER という「ドライブ」にセットされているので、第1引数に gl.ARRAY_BUFFER を指定することで positionBuffer にデータを保存することができます。

第2引数は型付き配列型である必要があるので、予め作成した変数 positions を32ビット浮動小数点数配列にして渡します。

第3引数はデータをどのように使うかのヒントです。データがあまり変更されない場合は gl.STATIC_DRAW を指定します。

main.ts
const positions = [
  0, 1,
  0.866, -0.5,
  -0.866, -0.5,
];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);

positions という配列には各頂点の座標を記述しています。わかりやすく頂点ごとで改行しており、各行の1つ目の数値が X 座標、2つ目が Y 座標、Z 座標は省略しています。

見てわかるように、わかりやすく改行していますが、これは単なる1次元配列です。どこからどこまでが1つの頂点の座標なのかは、下記データの取り出し方として指定する必要があります。

バッファからデータの取り出し方法の設定

vertexAttribPointer() メソッドにさまざまな引数を与えることで、現在 gl.ARRAY_BUFFER にバインドされているバッファからどのようにデータを取り出すかを指定できます。

main.ts
const index = gl.getAttribLocation(program, 'a_position');
const size = 2;
const type = gl.FLOAT;
const normalized = false;
const stride = 0;
const offset = 0;
gl.vertexAttribPointer(index, size, type, normalized, stride, offset);

index

WebGL プログラムでの頂点属性の場所を指定します。getAttribLocation() メソッドの第1引数にプログラム、第2引数に頂点シェーダーでの変数名を指定することで取得できます。

size

頂点ごとの要素数を 1, 2, 3, 4 から指定します。先ほどの positions 配列が各頂点の XY 座標ということなので、2 を指定します。もし Z 座標も記述するなら、ここは 3 になります。

頂点シェーダーでは a_position に対して vec4 型を指定しました。x, y のみを渡した場合、z, w は初期値の 0, 1 になります。

type

データ型を指定します。浮動小数点数なので gl.FLOAT を指定します。

normalized

整数データを浮動小数点数へ型変換するとき、厳密な範囲へ正規化するかどうかの指定。gl.FLOAT 型では効果がないので false にします。

stride, offset

それぞれ頂点始端同士のオフセット数と先頭からのオフセット数。基本的には 0 で大丈夫です。

頂点属性の有効化

デフォルトでは頂点属性は無効化されていますので、使うときは enableVertexAttribArray() メソッドで個別に有効化する必要があります。引数には有効化したい頂点属性の場所を指定します。それはすでに上記で取得済みなので変数 index を渡します。

main.ts
gl.enableVertexAttribArray(index);

描画

上記すべてができたら、やっと drawArrays() メソッドを使って描画することができます。

第1引数は描画するモデルの種類。ほかに点、線などがありますが、今回は三角形なので gl.TRIANGLES を指定します。第2引数は何個目の頂点から始めるか。第3引数は頂点の数。

main.ts
gl.drawArrays(gl.TRIANGLES, 0, 3);

これで画面上に白い三角形が描画されるはずです。

WebGL で白い三角形を描画したスクリーンショット

色変更

Vercel ロゴをライトバージョンからダークバージョンに変更してみます。

clearColor()alpha0 にして、背景を透明にします。

main.ts
-gl.clearColor(0, 0, 0, 1);
+gl.clearColor(0, 0, 0, 0);

フラグメントシェーダーコードの gl_FragColor の RGB を 0, 0, 0 に変更して、描画色を黒にします。

main.ts
 const fragmentShader = initShader('FRAGMENT_SHADER', `
   void main() {
-    gl_FragColor = vec4(1, 1, 1, 1);
+    gl_FragColor = vec4(0, 0, 0, 1);
   }
 `);

以上で大きな Vercel ロゴの出来上がりです 🎉

WebGL で黒い三角形を描画したスクリーンショット

おわりに

ソースコード全文を置いておきます。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Hello WebGL</title>
  <script src="main.js" defer></script>
</head>
<body>
  <canvas id="canvas" width="500" height="500"></canvas>
</body>
</html>
main.ts
const main = () => {
  // #canvas 要素の取得
  const canvas = document.querySelector('#canvas');

  if (!(canvas instanceof HTMLCanvasElement)) {
    throw new Error('<canvas> 要素がありません。');
  }

  // WebGL レンダリングコンテキストの取得
  const gl = canvas.getContext('webgl');

  if (!gl) {
    throw new Error('WebGL の初期化に失敗しました。');
  }

  // 画面の初期化
  gl.clearColor(0, 0, 0, 0);
  gl.clear(gl.COLOR_BUFFER_BIT);

  // シェーダーを作成する関数
  const initShader = (type: 'VERTEX_SHADER' | 'FRAGMENT_SHADER', source: string) => {
    const shader = gl.createShader(gl[type]);

    if (!shader) {
      throw new Error('シェーダーの作成に失敗しました。');
    }

    gl.shaderSource(shader, source);

    gl.compileShader(shader);

    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
      throw new Error(`シェーダーのコンパイルでエラーが発生しました: ${gl.getShaderInfoLog(shader)}`);
    }

    return shader;
  }

  // 頂点シェーダーの作成
  const vertexShader = initShader('VERTEX_SHADER', `
    attribute vec4 a_position;

    void main() {
      gl_Position = a_position;
    }
  `);

  // フラグメントシェーダーの作成
  const fragmentShader = initShader('FRAGMENT_SHADER', `
    void main() {
      gl_FragColor = vec4(0, 0, 0, 1);
    }
  `);

  // WebGL プログラム
  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)) {
    throw new Error(`シェーダーのリンクに失敗しました: ${gl.getProgramInfoLog(program)}`);
  }

  gl.useProgram(program);

  // 頂点バッファ
  const positionBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);

  const positions = [
    0, 1,
    0.866, -0.5,
    -0.866, -0.5,
  ];
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);

  const index = gl.getAttribLocation(program, 'a_position');
  const size = 2;
  const type = gl.FLOAT;
  const normalized = false;
  const stride = 0;
  const offset = 0;
  gl.vertexAttribPointer(index, size, type, normalized, stride, offset);
  gl.enableVertexAttribArray(index);

  // 描画
  gl.drawArrays(gl.TRIANGLES, 0, 3);
}
window.onload = main;

CodePen