生 WebGL と TypeScript で Vercel のロゴを作る
はじめに
完全に釣りタイトルです。Three.js などを使わずに生の WebGL で ▲ を描くというだけの話です。いわゆる WebGL の「Hello World」です。Vercel や Next.js とは一切関係ありません。
WebGL を詳細に解説しているサイトはいくつかあって、内容に関してはどれもすばらしいものです。ただ、何年も前のものなのでソースコードにまだ var
が使われているものも多く、また独自関数で処理をまとめていることで、個人的には全体の流れが少しわかりにくくなっている気がします。さらに TypeScript を使っている日本語記事は見当たりませんでしたので、それらを補う形でまとめてみたいと思います。
なお、本記事では全体的な流れに焦点を当てているので、詳細な解説は下記サイトなどをご参考ください。
- WebGLの基本 (日本語)
- wgld.org | WebGL (日本語)
- WebGL 入門 - Web API | MDN (一部日本語)
- TypeScript Playground - TypeScript with WebGL (英語、TypeScript)
canvas
HTML と WebGL を一言でいうと、3D グラフィックスを 2D である HTML の canvas
内に描画するための API です。ということでまずは HTML と canvas
を用意します。
<!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.js
は main.ts
からコンパイルされたものと思ってください。TypeScript の設定は割愛させていただきますが、"strict": true
に対応した解説になっています。
<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
内に文字列として記述してしまいます。ということでここでは何も用意する必要はありません。
準備
メイン関数
まずはメイン関数を用意してページの読み込みが完了したら実行されるようにしておきます。
const main = () => {
// 以降はすべてこの中に記述します。
}
window.onload = main;
#canvas
要素の取得
#canvas
要素を取得するだけでは、TypeScript に Element | null
として型推論されて getContext()
メソッドなどを使おうとするとコンパイルエラーになります。
コンパイルを通すだけなら document.querySelector('#canvas') as HTMLCanvasElement
のように型アサーションしてもいいんですが、ここは一応丁寧に型ガードしておきます。もし #canvas
を <div>
などに変更したら、エラーが出力されてプログラムは停止するようになります。
const canvas = document.querySelector('#canvas');
if (!(canvas instanceof HTMLCanvasElement)) {
throw new Error('<canvas> 要素がありません。');
}
WebGL レンダリングコンテキスト
getContext()
に 'webgl'
引数を与えて WebGL レンダリングコンテキストを取得します。もしブラウザが WebGL に対応していないなど、取得に失敗した場合はコンソールにエラーを出力してプログラムを停止します。
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
が黒。
gl.clearColor(0, 0, 0, 1);
カラーバッファの初期化
初期化は clear()
メソッドを使います。色を初期化したい場合は引数に gl.COLOR_BUFFER_BIT
を与えることで、先ほどセットした色で初期化します。
gl.clear(gl.COLOR_BUFFER_BIT);
これで画面上に500*500ピクセルの黒い四角が現れます。
頂点シェーダーとフラグメントシェーダー
前述のように WebGL コンテンツを描画するには GLSL で記述されたシェーダーコードが必要です。渡された座標や色情報などをどのように処理するかを決めます。
シェーダーには頂点シェーダーとフラグメントシェーダーの2種類があります。
頂点シェーダー (vertex shader)
名前のとおり頂点に関する情報を処理します。頂点なので位置情報は必須です。それ以外に色・法線・テクスチャなどの情報を持つこともできます。例えば三角形の頂点がそれぞれ赤・緑・青だった場合、グラデーションになって描画されるとイメージしてください (フラグメントシェーダーを一旦無視したとして) 。
頂点シェーダーの初期化
シェーダーの作成は createShader()
メソッドを使います。引数は gl.VERTEX_SHADER
か gl.FRAGMENT_SHADER
のどちらかを取ります。今回は頂点シェーダーなので gl.VERTEX_SHADER
を入れます。
createShader()
の戻り値の型は WebGLShader
か null
なので、TypeScript の strickNullChecks
が有効になっている場合は null
チェックをしてあげないとコンパイルエラーになります。
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
if (!vertexShader) {
throw new Error('シェーダーの作成に失敗しました。');
}
頂点シェーダーソースコード (GLSL) の設定
頂点シェーダーを作成したあとは GLSL のソースコードを設定する必要があります。shaderSource()
メソッドの第1引数に作成したシェーダーを、第2引数に GLSL ソースを渡します。
GLSL のソースコードは文字列であればいいので、改行を記述できるテンプレートリテラルを利用すると見やすいです。
gl.shaderSource(vertexShader, `
attribute vec4 a_position;
void main() {
gl_Position = a_position;
}
`);
シェーダーコードには必ず main()
関数を定義し、頂点の場合は特別な変数 gl_Position
に頂点情報を保存する必要があります。
attribute
は、頂点属性であるという意味。頂点データを受け取るための変数宣言と思ってください。ほかに uniform
や varying
などがありますが、ここでは割愛。
vec4
はデータ型の1種で、4つの浮動小数点の値を持つベクトルデータという意味です。3D なので x, y, z
の3座標が必要ですが、効率よく行列計算するために4つ目の w
要素も必要になってきます。これの初期値は 0, 0, 0, 1
です。
a_position
は変数名。a
は attribute
であるという意味。後述するバッファから取り出されたデータがここに入ります。今回はこのまま gl_Position
に渡していますが、通常は行列計算を行うのでこのまま gl_Position
に渡すことはほとんどありません。
頂点シェーダーのコンパイル
シェーダーをコンパイルするには、作成したシェーダーを compileShader()
メソッドに引数として渡します。
getShaderParameter()
の第1引数にシェーダー、第2引数に gl.COMPILE_STATUS
を与えることで、コンパイルに成功したかどうかを確認することができます。失敗した場合は getShaderInfoLog()
メソッドの引数に対象シェーダーを渡してログをエラーメッセージに出力します。
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引数にはシェーダーソースの文字列を渡します。
// シェーダーを作成する関数
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'
にして、フラグメントシェーダーのソースを渡します。
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
チェックをしてあげます。
const program = gl.createProgram();
if (!program) {
throw new Error('プログラムの作成に失敗しました。');
}
シェーダーをプログラムに接続
attachShader()
メソッドの第1引数にプログラム、第2引数にシェーダーを渡し、それぞれのシェーダーを先ほど作成したプログラムに接続します。
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
シェーダーのリンク
linkProgram()
メソッドを使ってプログラムに接続されたシェーダーをリンクします。引数にはシェーダーと接続済みのプログラムを渡します。
getProgramParameter()
の第1引数にプログラム、第2引数に gl.LINK_STATUS
を与え、リンクに成功したかどうかを確認します。失敗した場合は getProgramInfoLog()
メソッドの引数にプログラムを渡してログをエラーメッセージに出力します。
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error(`シェーダーのリンクに失敗しました: ${gl.getProgramInfoLog(program)}`);
}
プログラムの起動
プログラムの準備が一通りできたので、useProgram()
メソッドで使用するプログラムを WebGL に伝えます。
gl.useProgram(program);
頂点バッファ
プログラムができたら、データを与える必要があります。そのデータを保存するのに使うのが頂点バッファです。
バッファの作成
createBuffer()
メソッドで頂点バッファを作成できますが、位置、色、法線など、それぞれバッファを用意する必要があります。今回は位置情報のみですが、一応 positionBuffer
という変数に入れておきます。
const positionBuffer = gl.createBuffer();
バッファのバインド
バッファができたらバインドする必要があります。データを書き込むために、ディスクをドライブに入れるイメージです。
bindBuffer()
メソッドの第1引数にターゲット、第2引数にバインドするバッファを指定します。頂点座標や頂点色などのデータは gl.ARRAY_BUFFER
をターゲットに指定することになっています。プレステのディスクはプレステにセットする必要がある、みたいなものです。
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
を指定します。
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
にバインドされているバッファからどのようにデータを取り出すかを指定できます。
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
を渡します。
gl.enableVertexAttribArray(index);
描画
上記すべてができたら、やっと drawArrays()
メソッドを使って描画することができます。
第1引数は描画するモデルの種類。ほかに点、線などがありますが、今回は三角形なので gl.TRIANGLES
を指定します。第2引数は何個目の頂点から始めるか。第3引数は頂点の数。
gl.drawArrays(gl.TRIANGLES, 0, 3);
これで画面上に白い三角形が描画されるはずです。
色変更
Vercel ロゴをライトバージョンからダークバージョンに変更してみます。
clearColor()
の alpha
を 0
にして、背景を透明にします。
-gl.clearColor(0, 0, 0, 1);
+gl.clearColor(0, 0, 0, 0);
フラグメントシェーダーコードの gl_FragColor
の RGB を 0, 0, 0
に変更して、描画色を黒にします。
const fragmentShader = initShader('FRAGMENT_SHADER', `
void main() {
- gl_FragColor = vec4(1, 1, 1, 1);
+ gl_FragColor = vec4(0, 0, 0, 1);
}
`);
以上で大きな Vercel ロゴの出来上がりです 🎉
おわりに
ソースコード全文を置いておきます。
<!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>
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;
Discussion