JavaScript Advent Calendar 2011/WebGLコース初日、WebGLの基本的な考え方

↑まだまだ参加者募集中です。

WebGL駅伝は、もし25人に満たなかったら、最後の人の次の日から僕が一人で走りたいと思います。ネタと時間的余裕が続く限り…

なんてことを言っておきながら、実は僕自身はWebGLも3Dプログラミングも初めて2ヶ月の初心者なので、自分がWebGLを始めるときにどういう解説が欲しかったかということを考えながら入門編を書いてみたいと思います。(WebGL駅伝の他の参加者の方々にはつまらない内容だと思いますが)

WebGLプログラムの流れはおおまかに言ってこれだけです。

  • シェーダーを用意する
  • 頂点座標をシェーダーに投げる
  • フレームごとにパラメータを変えてシェーダープログラムを走らせる

シェーダーは頂点(vertex)シェーダーとフラグメント(fragment)シェーダーの2つで1組になっていて、頂点シェーダーは「座標変換」を担当し、後者は各ピクセルの色付けを担当します。

頂点シェーダーと座標変換

ある物体が空間内のどこかの座標に置かれているとします。そして、それを撮影するカメラがあります。(↓のサイトの絵が分かりやすかったので引用)


上の図のような配置で実際にカメラの視点から見たのが下の図になります

http://sorceryforce.com/xna/tips_abouttransform3d.html

最終的に描画するのはカメラが撮影した画像だけです。

ここで座標系というものを考えてみます。

描画したい物体の頂点は、例えばサイコロだったら8つの頂点 (1,1,1),(1,1,-1),(1,-1,1),(-1,1,1),(1,-1,-1),(-1,1,-1),(-1,-1,1),(-1,-1,-1) で表すことが多いと思います。この座標は「物体自身の原点」を基準にした座標です。(この場合はサイコロの中心)

次に、カメラの位置も座標で与えます。例えば (0,0,10) など。これは何を基準にした座標かというと、自分が勝手に決めた「世界の中心」を原点としたものです。

また、物体の位置もこの世界の座標で表すことができます。物体が一つだけで、止まっているなら、世界の中心に物体の原点を置くのが普通です。

そうでない場合は物体基準の座標と世界基準の座標が存在することになります。この時、「物体の頂点の位置を世界の中心基準の座標で表すと何になるか?」というのが座標変換です。

さらにカメラ基準の座標を考えます。カメラの位置を原点として、カメラの上方向をy軸、見つめる方向をz軸としたものです。(あとパースペクティブとかありますが省略)

頂点シェーダーの目的は「物体の頂点をカメラ基準の座標系で表す」ことです。言い換えれば、物体座標を世界座標に変換し、さらにカメラ座標に変換することです。

フラグメントシェーダー

fragmentというのは断片という意味です。

3Dのポリゴンは、頂点の集まりと言えるとともに、三角形の断片の集まりとも言えます。

カメラ座標で物体を見ると、どの面がこちらを向いているかとか、ある面は別の面の後ろにあるから見えないということがわかります。すなわち、カメラが撮影した画像のあるピクセルにどの面のどの部分が相当するのかが分かります。ここはWebGLがやってくれるので計算する必要はありません。

また、頂点オブジェクトが持ってる法線やら基本色やらテクスチャーの座標やらの情報を、ピクセルごとに補間(interpolate)して与えてくれます。これもWebGLがやってくれます。

フラグメントシェーダーの役割は、それぞれのピクセルにどの色を置くかを決めることです。

つまり、受け取った法線や基本色やテクスチャー座標を組み合わせて、最終的な色を決定します。(例えば法線が光源の方向と逆を向いていたら光があたってないと判断して暗くするなど)

ちなみにshaderというのは「影をつけるもの」という意味ですね。

一番簡単なシェーダーの例

頂点シェーダーの一番簡単な例はこんな感じです。

attribute vec3 aPosition;
attribute vec4 aColor;
varying vec4 vColor;
uniform mat4 uMVPMatrix;

void main() {
  vColor = aColor;
  gl_Position = uMVPMatrix * vec4(aPosition, 0.0);
}

main関数が各頂点について実行されます。ここでは各頂点オブジェクトはメンバーとして位置と色を持っています。attributeとして宣言された値は「頂点オブジェクトの属性(attribute)」という意味です。

varyingとして宣言された変数は、頂点シェーダーからフラグメントシェーダーへの受け渡しのために使われます。ここでは、各頂点の色をフラグメントシェーダーで使えるようにしています。

uniformとして宣言された値は定数です。attributeは各頂点が持つ値であるのに対し、uniformはすべての頂点に画一(uniform)な定数です。上のコードでは座標変換に相当する行列を指定しています。MVPという名前の意味はあとで説明します。

attributeとuniformについてはプログラム(シェーダーをコンパイルしたものをシェーダープログラムとかプログラムと呼びます)を走らせる前に登録してやります。

gl_Positionは組み込み変数で、カメラ基準の座標における頂点の位置を与えてあげなければいけません。頂点シェーダーでは唯一必須の操作です。

次に一番簡単なフラグメントシェーダーです。

//↓おまじない。気にする必要はない
precision mediump float;

varying vec4 vColor;

void main() {
  gl_FragColor = vColor;
}

これだけです。最初の数行は実数の精度を上げるためのものだそうです。よくわかってませんがとりあえず書いとくものみたいです。

gl_FragColorという組み込み変数は、ピクセルの色を指定します。これは実は必須ではないけど面倒なので割愛。

varyingで頂点シェーダーから送った値は、ピクセルごとに補間されてます。なので、両方のシェーダーで同じように宣言してるけど微妙に違うものです。

プログラムの準備

上のシェーダーを使うとしてJavaScriptのコードを見ていきます。シェーダーは<script type="text/vs-shader">とかの中に書いておくことが多いようです。(CoffeeScriptの複数行文字列リテラルのほうが便利)

まずシェーダーをコンパイルします。本当はエラー処理をきちんとしないといけませんが、今回は省略します。

var canvas = document.getElementById('canvas');
var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');

var vshader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vshader, document.getElementById('vs').textContent);
gl.compileShader(vshader);

var fshader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fshader, document.getElementById('fs').textContent);
gl.compileShader(fshader);

var program = gl.createProgram();
gl.attachShader(program, vshader);
gl.attachShader(program, fshader);
gl.linkProgram(program); // 頂点シェーダーとフラグメントシェーダーをリンク

vshaderfshaderはもう変数としては用無しなので忘れてください。glprogramが重要です。

次に、コンパイルしたプログラムからattribute/uniform変数の場所を得ます。attributeはaPositionaColor、uniformはuMVPMatrixでした。

gl.useProgram(program); // このプログラムを使う。複数プログラムがあるときは使い分けるため

var aPositionLocation = gl.getAttribLocation(program, 'aPosition');
gl.enableVertexAttribArray(aPositionLocation); // ←おまじない

var aColorLocation = gl.getAttribLocation(program, 'aColor');
gl.enableVertexAttribArray(aColorLocation); // ←おまじない

var uMVPMatrixLocation = gl.getUniformLocation(program, 'uMVPMatrix');

aPositionLocationとかはあとで使います。Learning WebGLのサンプルとかだと、グローバル変数を作らずにprogramのメンバーにしています。それでもかまいません。programは元々何もメンバーを持ってないオブジェクトなので混乱することは無いと思います。

(どうでもいいですが、なぜかgetAttibLocationのほうは返り値が数字で、getUniformLocationのほうはWebGLUniformLocationとかいうオブジェクトなんですよね…なんか非対称で美しくない…)

頂点を登録

3D物体(モデル)の頂点座標をattributeとして登録します。これは普通は最初の1回だけです。繰り返しになりますがモデルの頂点はモデル基準の座標のままで登録します。

頂点は何万とか何十万とかいう単位になってくるので、一つ一つの位置をJavaScriptで動かすことはありません。(まあ数万ぐらいのループなら今は一瞬だけど)

ここでは数万の頂点ではなく、Learning WebGL Lesson 4から借りてピラミッドを作ってみます。

var pyramidVertexPositionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, pyramidVertexPositionBuffer);
var vertices = [
    // Front face
     0.0,  1.0,  0.0,
    -1.0, -1.0,  1.0,
     1.0, -1.0,  1.0,
    // Right face
     0.0,  1.0,  0.0,
     1.0, -1.0,  1.0,
     1.0, -1.0, -1.0,
    // Back face
     0.0,  1.0,  0.0,
     1.0, -1.0, -1.0,
    -1.0, -1.0, -1.0,
    // Left face
     0.0,  1.0,  0.0,
    -1.0, -1.0, -1.0,
    -1.0, -1.0,  1.0
];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
pyramidVertexPositionBuffer.itemSize = 3;
pyramidVertexPositionBuffer.numItems = 12;

createBufferで頂点バッファーオブジェクトを作り、bindBufferで「今からこのバッファーについての操作をします」とWebGLコンテクストに宣言し、bufferDataでバッファーの中身を登録しています。STATIC_DRAWはおまじないです(一回登録したら変えないよとかなんとか)。

目につくのは、頂点の座標 (x,y,z) を全部まとめて一つの大きな配列にしてることです。Front face とある上から3行が3つの頂点、つまり一つの断片になります。この配列は4つの断片からなる物体を表しています。

itemSizenumItemsは、attribute vec3に相当する変数を12個用意したという意味です。ここではグローバル変数を用意するのが嫌だったので単にメンバーとして登録してあるだけで、実際に使われるのは描画の時です。

同じように頂点の色(rgba)を登録します。

pyramidVertexColorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, pyramidVertexColorBuffer);
var colors = [
    // Front face
    1.0, 0.0, 0.0, 1.0,
    0.0, 1.0, 0.0, 1.0,
    0.0, 0.0, 1.0, 1.0,
    // Right face
    1.0, 0.0, 0.0, 1.0,
    0.0, 0.0, 1.0, 1.0,
    0.0, 1.0, 0.0, 1.0,
    // Back face
    1.0, 0.0, 0.0, 1.0,
    0.0, 1.0, 0.0, 1.0,
    0.0, 0.0, 1.0, 1.0,
    // Left face
    1.0, 0.0, 0.0, 1.0,
    0.0, 0.0, 1.0, 1.0,
    0.0, 1.0, 0.0, 1.0
];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.STATIC_DRAW);
pyramidVertexColorBuffer.itemSize = 4;
pyramidVertexColorBuffer.numItems = 12;

ここではattribute vec4に相当する変数を12個用意しました。

頂点の色を指定しただけで、面の真ん中の色は指定していませんが、間の色に補間されます。

座標変換

座標はベクトル、座標変換は行列です。(数学的な説明は↓あたりに任せますが、数学的にもちゃんと理解出来ないと3Dプログラミングは難しいでしょう)

OpenGLでは物体(モデル)を基準とした座標から世界の中心を基準とした座標への変換をモデル変換と呼ぶようです。他にワールド変換とも言うみたいです。

世界の中心を基準とした座標からカメラを基準とした座標への変換をビュー変換と呼ぶようです。

最後に、遠くのものは小さく見えて近くのものは大きく見えるという効果を出すパースペクティブ変換というのを施します。

これらの変換行列をかけ合わせたものがMVPMatrixです。(MVPMatrix = PMatrix * VMatrix * MMatrix

この行列を計算するのには行列演算用のライブラリを使わないと厳しいでしょう。glMatrixとかclosure vecとかmjsとかEWGLとかいっぱいあります。

各フレームごとに変換行列を動かすことで、頂点の座標を動かさずにモデルを動かすことができます。直立姿勢の人型モデルでも、体の各パーツ(に付随する頂点のグループ)を別々の変換行列にかけるとモーションができます。

MVPMatrixを作ったということにして先へ進みたいと思います。

描画

頂点バッファーをattributeの変数と結びつけます。

gl.bindBuffer(gl.ARRAY_BUFFER, pyramidVertexPositionBuffer);
gl.vertexAttribPointer(aPositionLocation, pyramidVertexPositionBuffer.itemSize, gl.FLOAT, false, 0, 0);

gl.bindBuffer(gl.ARRAY_BUFFER, pyramidVertexColorBuffer);
gl.vertexAttribPointer(aColorLocation, pyramidVertexColorBuffer.itemSize, gl.FLOAT, false, 0, 0);

itemSizeはvec3かvec4かという意味でした。bindBufferがさっきも書いたように「今からこのバッファーについての操作をします」という宣言で、vertexAttribPointerは、指定した位置にあるattribute変数をそのバッファーへのポインターとする、という意味です。後ろの方の引数は…覚えてません。

次に、uniformを登録します。uniform変数の型によってuniform1iuniform3funiform4fvMatrixなどの関数を使い分けないといけません。

gl.uniformMatrix4fv(uMVPMatrixLocation, false, MVPMatrix);

MVPMatrixはJavaScriptでは16要素の配列かFloat32Arrayです。シェーダーからは4x4の行列になります。第二引数のfalseは転置するかどうか。

最後にdrawArraysを呼びます。

gl.drawArrays(gl.TRIANGLES, 0, pyramidVertexPositionBuffer.numItems);

numItemsはいくつ頂点があるかということでした。12個の頂点を「最初から3つずつ取って一つの断片とする」というのがTRIANGLESの意味です。他にもTRIANGLE_STRIPとか色々ありますが省略。

また、drawArraysじゃなくてdrawElementsというのを使う場合もあります。それは頂点の並びが最初から3つずつという順番になってない場合で、並び順を表す配列を別に登録してやる必要があります。

最後に

長かったですが12月1日に間に合わせないといけないので十分推敲してません…

これを読んだおかげでLearning WebGLのLesson 4がすんなり理解できるようになった!という人がいれば嬉しいです。