WebGLは三次元APIとして思われることも多い。 「WebGLを使えば魔法のように簡単に三次元の映像を表示出来るだろう」と思ってしまう人も多い。 実はWebGLはただのピクセルを書くエンジンである。WebGLで自分の作成したコードで点、線、 三角形を使って色々なタスクを記述することが出来る。 それ以上描きたければ点と線と三角形を使って自分のコードでWebGLを使うことが必要である。
WebGLはコンピュータのGPUで動く。だからGPUで起動出来るコードを提供しなければいけない。 そのために2つの関数を提供する必要がある。その関数は「頂点シェーダー」と「フラグメントシェーダー」と呼ばれ、 両方厳密なC/C++のような「GLSL」という言語で作成するものだ。その2つの組み合わせを「プログラム」という。
頂点シェーダーの役割は頂点の位置を計算すること。 その関数の導き出した頂点位置でWebGLは点と線と三角形を描く。 描いている最中フラグメントシェーダーを呼び出す。 フラグメントシェーダーの役割は描くピクセルごとに色の計算をすることである。
その2つの関数を起動する前にWebGL API経由でその関数の状況を指定する
ことが必要である。書きたい形ごとにWebGLの色々な状況を設定して、
そしてgl.drawArraysかgl.drawElementsの関数を呼び出したらGPUでシェーダーが起動する。
そのシェーダーの関数に提供したいデータはGPUにアップロードしなければいけない。 それは4つの方法がある。
属性(attribute)とバッファー(buffer)
バッファーはGPUにあるバイナリデータの配列。中身は頂点の位置や、法線や、色や、 テクスチャーの座標などだが、好きなデータを入れること出来る。
属性はバッファーからデータを取ってシェーダーに提供する設定である。 例えばバッファーに位置ごとに三つの32ビット数字が入っている。 ある属性の設定でどのバッファーから位置を取り出すかと、どのようなデータを取り出すか (三つの32ビット数字)とか、バッファーにそのデータは何処から始まるかとか、 一つの位置から次の位置に何バイト飛ぶかとかである。
バッファーは自由にデータを取ることが出来ない。 代わりに頂点シェーダーを呼び出す回数を設定して、 呼び出すごとに次のデータをバッファーから読んで属性にそのデータが入る。
ユニフォーム(uniform)
ユニフォームはシェーダーを起動する前に定義するシェーダーのグローバルの変数です。
テクスチャー(texture)
テクスチャーは自由にデータを読める配列です。 よくテクスチャーにイメージとか写真とかか絵のデータを入れるが、 テクスチャーはただのデータ配列なので色以外のデータを入れることも可能である。
ヴァリイング(varying)
ヴァリイングは頂点シェーダーからフラグメントシェーダーへデータを伝える方法です。 描画する形による(点、線、三角形)頂点シェーダーに定義します。 定義されたヴァリイングはフラグメントシェーダーが呼び出されている間補間される。
WebGLの"Hello World"
WebGLは2つのことしか求めていない。それはクリップ空間と色である。 プログラマーの役目はその2つのことをWebGLに与えることである。 そのため2つのシェーダーを与える。頂点シェーダーでクリップ空間の頂点座標を与えて、 そしてフラグメントシェーダーで色を与える。
クリップ空間座標はキャンバスの要素(canvas)のサイズに関係がなく、いつも−1から+1になる。 以下は一番単純なWebGLの例である。
まず頂点シェーダーから始めよう。
// バッファーからデータを取る属性
attribute vec4 a_position;
// 全てのシェーダーは「main」の関数がある
void main() {
// 特別の変数「gl_Position」を割り当てることは頂点シェーダーの役割である
gl_Position = a_position;
}
このコードを起動したらどのように動くか、 GLSLの代わりにJavaScriptで書かれていたとしたら以下のように動く、と想像してみるとよいだろう。
// *** 擬似コード!! ***
var positionBuffer = [
0, 0, 0, 0,
0, 0.5, 0, 0,
0.7, 0, 0, 0,
];
var attributes = {};
var gl_Position;
drawArrays(..., offset, count) {
var stride = 4;
var size = 4;
for (var i = 0; i < count; ++i) {
// positionBufferから次の4つの数値をa_positionの属性に読み込み
const start = offset + i * stride;
attributes.a_position = positionBuffer.slice(start, start + size);
runVertexShader(); // ⇐ 頂点シェーダーを呼び出す!
...
doSomethingWith_gl_Position();
}
実際にはGLSLのシェーダーでのデータpositionBufferはバイナリに変換しなければならないので、
バッファーからデータを取り込む際の計算方法は異なる。
でも、頂点シェーダーはこのように動くと考えてよい。
次はフラグメントシェーダーが必要だ。
// フラグメントシェーダーは既定の精度がないので選択することが必要である。
// 「mediump」は一般的な既定の設定である。それは「中間の精度」の意味である。
precision mediump float;
void main() {
// 特別の変数「gl_FragColor」を割り当てることは
// フラグメントシェーダーの役割である
gl_FragColor = vec4(1, 0, 0.5, 1); // 赤紫
}
上記ではgl_FragColorに1, 0, 0.5, 1に割り当てている。それは赤=1,緑=0、青=0.5、透明度(アルファ)=1。
WebGLでは、色は0〜1で指定する。
2つのシェーダーを書いたのでWebGLを始めよう!
まずHTMLのCanvas要素が必要である
<canvas id="c"></canvas>
それをJavaScriptで調べられる
var canvas = document.querySelector("#c");
それでWebGLRenderingContextを作成出来る
var gl = canvas.getContext("webgl");
if (!gl) {
// no webgl for you!
...
そして先のシェーダーをコンパイルしてGPUにアップロードすることが必要なのでstringに入れることが必要である。 GLSLのstringをする方法はいくつかある。文字列の連結とか、AJAXでダウンロードすることとか、複数行テンプレートstring(multiline template strings)とか。 今回は、typeがJavaScriptではないscript要素に入れる方法をとる。
<script id="vertex-shader-2d" type="notjs">
// バッファーからデータを取る属性
attribute vec4 a_position;
// 全てのシェーダーは「main」の関数がある
void main() {
// 特別の変数「gl_Position」を割り当てることは頂点シェーダーの役割である
gl_Position = a_position;
}
</script>
<script id="fragment-shader-2d" type="notjs">
// フラグメントシェーダーは既定の精度がないので選択することが必要である。
// 「mediump」は一般的な既定の設定である。それは「中間の精度」の意味である。
precision mediump float;
void main() {
// 特別の変数「gl_FragColor」を割り当てることは
// フラグメントシェーダーの役割である
gl_FragColor = vec4(1, 0, 0.5, 1); // 赤紫
}
</script>
本格的な三次元のエンジンは色々な方法で動的にコードを組み合わせてGLSLシェーダーを生成する。 しかし、このサイトであまり複雑なシェーダーを使わないので、シェーダー・コードを動的に組み合わせて生成する必要はない。
次に、シェーダーを作成し、GLSLのコードをアップロードし、シェーダーをコンパイルする関数が必要である。
function createShader(gl, type, source) {
// シェーダーを作成
var shader = gl.createShader(type);
// GLSLのコードをGPUにアップロード
gl.shaderSource(shader, source);
// シェーダーをコンパイル
gl.compileShader(shader);
// 成功かどうかチェック
var success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
if (success) {
return shader; // 成功。シェーダーを返す
}
// エラーを表示
console.log(gl.getShaderInfoLog(shader));
// シェーダーを削除
gl.deleteShader(shader);
}
出来たら、その関数でシェーダー2つを作成出来る
var vertexShaderSource = document.querySelector("#vertex-shader-2d").text;
var fragmentShaderSource = document.querySelector("#fragment-shader-2d").text;
var vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
var fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
それでその2つのシェーダーをプログラム(program)にリンク(link)する
function createProgram(gl, vertexShader, fragmentShader) {
// プログラムを作成
var program = gl.createProgram();
// プログラムに頂点シェーダーを付ける
gl.attachShader(program, vertexShader);
// プログラムにフラグメントシェーダーを付ける
gl.attachShader(program, fragmentShader);
// プログラムをリンクする
gl.linkProgram(program);
// 成功かどうかチェック
var success = gl.getProgramParameter(program, gl.LINK_STATUS);
if (success) {
return program; // 成功。プログラムを返す
}
// エラーを表示
console.log(gl.getProgramInfoLog(program));
// プログラムを削除
gl.deleteProgram(program);
}
それを呼び出す
var program = createProgram(gl, vertexShader, fragmentShader);
GLSLのプログラムを作成して、GPUにアップロードが出来たら、それにデータを与えることが必要である。
WebGL APIの役割のほとんどはGLSLプログラムにデータを与えることと動きの状況を設定することである。
今回のGLSLプログラムのインプットはa_positionの属性しかない。
作成したプログラムに最初するべきことは属性のロケーションを調べることである
var positionAttributeLocation = gl.getAttribLocation(program, "a_position");
属性のロケーションを調べるのは描画する時ではなく、プログラムの初期化の時に行った方がいい。
属性はバッファーからデータを取るので、バッファーを作成しなければならない。
var positionBuffer = gl.createBuffer();
WebGLのリソース(資源)を操るためグローバル結び点(bind point)に結び付けることが必要である。
結び点はWebGLの中のグローバル変数のようなものである。リソースを結び点に結びつけたら、その後
結び点でリソースを操る。さて、positionBufferを結びつけよう。
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
そして、ARRAY_BUFFERという結び点を参照して、データをバッファーに入れる。
// 三点の二次元頂点
var positions = [
0, 0,
0, 0.5,
0.7, 0,
];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
ここでは色々なことが行われている。まずpositionsというJavaScriptの配列がある。WebGLは強く
型付けされたデータが要るので、 new Float32Array(positions)の部分は32ビット数値配列を
作成して、それにpositionsの内容をコピーする。それでgl.bufferDataはそのデータをGPUに
あるpositionBufferにアップロードする。positionBufferがARRAY_BUFFERに結び付いている
のでpositionBufferはコピーの目標になっている。
gl.bufferDataの最後の引数、gl.STATIC_DRAWは、そのデータをどのように使うのかという、WebGLに対する
ヒントである。gl.STATIC_DRAWは、「このデータはあまり更新しない」という意味である。
今までのコードが初期化のコードである。ウェブページをロードした時、一回だけ実行される。 下記のコードは描画するコードである。描画してほしい時に都度呼び出すコードである。
描画する前にキャンバスを表示されているサイズと同じサイズにした方がいい。キャンバスには 2つのサイズがある。一つは内容の解像度で、それがキャンバスサイズである。それに表示のサイズもあり、 これはCSSで決定されている。他の方法より柔軟なのでキャンバスのサイズをCSSで設定した方がいい。
キャンバスの解像度を表示されているサイズと同じにするため ここで説明しているヘルパー関数を利用している。
ここにあるサンプルでは自分のウインドウで起動する場合、キャンバスのサイズは400x300になるが、 iframeの中で起動する場合iframeのサイズに合わせられる。CSSで決定しているのでどちらにも対処出来る。
webglUtils.resizeCanvasToDisplaySize(gl.canvas);
クリップ空間上の値であるgl_Positionを、画面空間上のピクセルにどうやって変換するのかWebGLに教える必要がある。
そのためキャンバスのサイズをgl.viewportに渡す。
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
これは−1〜+1のクリップ空間から、x軸は「0〜gl.canvas.width」に、y軸は「0〜gl.canvas.height」に変換するように、WebGLに設定するものである。
キャンバスをクリアする。0, 0, 0, 0は赤、緑、青、アルファ(透明度)なので、今回はキャンバスを透明にクリアする。
// キャンバスをクリアする
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
WebGLにどのシェーダー・プログラムを起動してほしいか教える。
// 作成したプログラム(シェーダー2つ)を設定する
gl.useProgram(program);
次にWebGLにどうやってデータを上記で作ったバッファーからシェーダーの属性に読み込むかを教えることが必要である。 まず属性オンにする。
gl.enableVertexAttribArray(positionAttributeLocation);
そしてデータの取り方を設定する。
// positionBufferをARRAY_BUFFERに結び付ける
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
// 属性にどうやってpositionBuffer(ARRAY_BUFFER)からデータを取り込むか。
var size = 2; // 呼び出すごとに2つの数値
var type = gl.FLOAT; // データは32ビットの数値
var normalize = false; // データをnormalizeしない
var stride = 0; // シェーダーを呼び出すごとに進む距離
// 0 = size * sizeof(type)
var offset = 0; // バッファーの頭から取り始める
gl.vertexAttribPointer(
positionAttributeLocation, size, type, normalize, stride, offset)
gl.vertexAttribPointerの隠れている点がARRAY_BUFFERに結び付いているバッファーを属性にも
結び付ける。つまりpositionBufferはこの属性に結び付く。ARRAY_BUFFERに他のバッファーを
結び付けても、属性はまだpositionBufferに結び付いている。
GLSLの頂点シェーダーの立場からa_positionはvec4である。
attribute vec4 a_position;
vec4は4つの値がある。JavaScriptで表現するならa_position = {x: 0, y: 0, z: 0, w: 0}に近い形になる。
上記ではsize = 2とした。属性の規定値は0, 0, 0, 1なので、この属性の最初の2つの値(xとy)はバッファーから取る。
zとwは既定値の0、1になる。
上記の全ての後やっとWebGLにシェーダーを起動することを頼める。
var primitiveType = gl.TRIANGLES;
var offset = 0;
var count = 3;
gl.drawArrays(primitiveType, offset, count);
countは3になっているので頂点シェーダーは三回呼び出される。初回頂点シェーダーの属性の
a_position.xとa_position.yはpositionBufferの最初の2つの値になる。二回目、a_position.xyは
二番目の2つの値になる。三回目は最後の2つの値になる。
primitiveTypeはgl.TRIANGLESにしたので、頂点シェーダーは三回呼び出されるごとに、
WebGLはgl_Positionに割り当てられた3つの値で三角形を描画する。キャンバスがどんなサイズで
あってもその値は-1~+1クリップ空間座標である。
この頂点シェーダーは、ただpositionBufferの値をgl_Positionにコピーしているので、三角形はこのクリップ空間座標に描画される。
0, 0,
0, 0.5,
0.7, 0,
キャンバスのサイズが400x300のピクセルならWebGLはこのように頂点のクリップ空間から画面空間に変化する。
クリップ空間 画面空間
0, 0 -> 200, 150
0, 0.5 -> 200, 225
0.7, 0 -> 340, 150
その座標でWebGLは三角形を描画する。ピクセルごとにフラグメントシェーダーを呼び出す。
フラグメントシェーダーはただgl_FragColorを1, 0, 0.5, 1とする。キャンバスの色はRGBの各チャンネルにつき
8ビットなので、WebGLは[255, 0, 127, 255]をキャンバスに書き込む。
これはライブサンプルである。
上の場合にはこの頂点シェーダーは頂点の位置データを直接渡すだけである。その位置はもう クリップ空間になっているので、何もしていない。三次元の絵を描画したければ自分自身で 三次元データからクリップ空間に変換するシェーダーを作成しなければならない。WebGLはただの描画するAPIだから。
三角形がなぜ真ん中から右上に位置するのかな〜という人もいると思うので、説明しよう。クリップ空間 でX軸は-1〜+1である。だから0は真ん中で正の値はその右になる。 上の方に位置する理由はクリップ空間のY軸が-1=下端で、+1=上端なので、0=真ん中で正の値は その上になるからである。
2次元のものならクリップ空間よりよく使われているピクセル空間の方が楽なので、positionの座標を
ピクセルで与えて、ピクセル座標からクリップ空間に自動で変換するように、シェーダーの計算の仕方を変更しよう。
これは変更されたシェーダー:
<script id="vertex-shader-2d" type="notjs">
- attribute vec4 a_position;
* attribute vec2 a_position;
+ uniform vec2 u_resolution; // キャンバスの解像度
void main() {
+ // positionはピクセルから0〜1に変換
+ vec2 zeroToOne = a_position / u_resolution;
+
+ // 0〜1から0〜2に変換
+ vec2 zeroToTwo = zeroToOne * 2.0;
+
+ // 0〜2から-1〜+1(クリップ空間)に変換
+ vec2 clipSpace = zeroToTwo - 1.0;
+
* gl_Position = vec4(clipSpace, 0, 1);
}
</script>
この変更に関して留意したい点:
xとyしか使ってないのでa_positionをvec2にした。 vec2はvec4に似てるがxとyしかない。
u_resolutionというユニフォームを追加した。ユニフォームを設定するためにそのロケーションを調べることが必要である。
var resolutionUniformLocation = gl.getUniformLocation(program, "u_resolution");
それ以外は上記のコメントでご理解頂けるだろう。u_resolutionをキャンバスの解像度に設定すれば、
この頂点シェーダーが計算してpositionBufferに入っているピクセル座標をクリップ空間に変換する。
これで頂点座標をクリップ空間からピクセルに変更出来る。今回3つの頂点で出来ている三角形2つで四角形を描画する。
var positions = [
* 10, 20,
* 80, 20,
* 10, 30,
* 10, 30,
* 80, 20,
* 80, 30,
];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
どのプログラムを利用するかを定義してから、ユニフォームの値を設定出来る。gl.useProgramは、上記のgl.bindBuffer
と同じように、どのシェーダー・プログラムを使うかを定義する。その後gl.uniform〜の関数の全ては現行のプログラムの
ユニフォームを設定出来る。
gl.useProgram(program);
...
// resolutionを設定する
gl.uniform2f(resolutionUniformLocation, gl.canvas.width, gl.canvas.height);
そして勿論2つの三角形を描画するため、先ほど頂点シェーダーを6回呼び出すことが必要なのでcountは
6にする。
// 描画する
var primitiveType = gl.TRIANGLES;
var offset = 0;
*var count = 6;
gl.drawArrays(primitiveType, offset, count);
そしてこのようになる。
Note: このサンプルとその後の全てのサンプルは、シェーダーのコンパイルとリンクの為の関数を
含んでいるwebgl-utils.jsというライブラリを使っている。
サンプルを煩雑にさせたくないので、ボイラプレート・コード・ライブラリにした。
前回と同じく、目立つのはこの四角形は下の方に位置していることだろう。WebGLは0,0を左下とみなしている。 2次元APIで一般的なように左上を0,0にしたければクリップ空間のy座標をひっくり返す。
* gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
それで四角形は期待通りになる。
四角形を定義している部分を関数にして、呼び出すときにサイズが設定出来るようにしよう。更に、描く色も設定出来る ようにする。
まずフラグメントシェーダーを、色ユニフォームが使えるようにする。
<script id="fragment-shader-2d" type="notjs">
precision mediump float;
+ uniform vec4 u_color;
void main() {
* gl_FragColor = u_color;
}
</script>
そして、次にあるのは50個の四角形をランダムなサイズとランダムな色で描画するコードである。
var colorUniformLocation = gl.getUniformLocation(program, "u_color");
...
// 50個のランダム四角形をランダム色で描画する
for (var ii = 0; ii < 50; ++ii) {
// ランダム四角形の設定する
// 最後にARRAY_BUFFERの結び点に結び付いたバッファーはpositionBufferだから
// positionBufferにアップロードすることになる。
setRectangle(
gl, randomInt(300), randomInt(300), randomInt(300), randomInt(300));
// ランダムな色を設定する
gl.uniform4f(colorUniformLocation, Math.random(), Math.random(), Math.random(), 1);
// 四角形を描画する
var primitiveType = gl.TRIANGLES;
var offset = 0;
var count = 6;
gl.drawArrays(primitiveType, offset, count);
}
}
// 0〜(range - 1)の整数を作成して返す
function randomInt(range) {
return Math.floor(Math.random() * range);
}
// バッファーに四角形の頂点を入れる
function setRectangle(gl, x, y, width, height) {
var x1 = x;
var x2 = x + width;
var y1 = y;
var y2 = y + height;
// NOTE: gl.bufferData(gl.ARRAY_BUFFER, ...)は`ARRAY_BUFFER`の結び点に
// 結び付いているバッファーにアップロードする。今まで一つのバッファーしかないけど、
// 2つ以上あればgl.bufferDataを呼び出す前に変更したいバッファーをARRAY_BUFFERに
// 結び付けることが必要である。
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
x1, y1,
x2, y1,
x1, y2,
x1, y2,
x2, y1,
x2, y2]), gl.STATIC_DRAW);
}
それでこれは50個の四角形である。
気が付いて欲しい点はWebGLが結構単純なAPIということである。 まあ、ここまでの流れは「単純」とは言えないが、WebGLがやっていること自体は単純なことである。 ただプログラマーが書いた2つの関数(頂点シェーダーとフラグメントシェーダー)で三角形、線、 点を描画する。三次元にする為にはもっと複雑になるかもしれないが、その複雑さは、あなた、つまりプログラマーの手で、「より複雑なシェーダー」として追加される。 WebGLはただ単純な描画をするAPIである。
今回のサンプルでは1つの属性と2つのユニフォームでデータを提供した。 一般的には複数の属性と多くのユニフォームを使う。この記事の上の方でヴァリイングとテクスチャーを紹介した。 それらについていずれ説明しよう。
次に行く前に言っていおきたいのだが、ほとんどのアプリケーションでは、このサンプルのsetRectangleのようにバッファーのデータを更新することは一般的ではない。
しかし、GLSLならちょっとだけ数学を使えば出来ることと、データはピクセル座標で提供することで、
簡単に説明出来ると思った。それは駄目な方法ではない。この方法が適切であるあるケースはいくつもあるのだが、
WebGLで形状を移動、回転、拡大縮小する、より一般的な方法についても一読しておくことをお勧めする。
ウェブページの制作経験があまりなければ(あっても)インストールとセット・アップの記事をチェックして、WebGLの開発の秘訣を参照して下さい。
WebGLの知識が全くなくて、GLSLとかシェーダーとか、GPUが何をするものなどか知らなければWebGLの基本的な動き方をチェックしてください。
サンプルが使っているボイラプレート・コード・ライブラリについてをざっと読んだ方がいい。 このサイトに載せてあるサンプルはほとんど一つの形状しか描画してないから、通常のWebGLアプリの構造を理解する為に複数のものを描画する方法の記事 もざっと目を通した方がいい。
いずれにしても、ここから2つの方向がある。画像処理に興味があれば二次元画像処理の仕方を見て下さい。移動、回転、拡大/縮小、そして3次元のことに興味があればここから始めよう。