WebGL
adventcalendar2017

WebGLで三角形を描画するまでの長すぎる道のり(もう沼)

WebGLといえばThree.jsやPixi.jsなどのライブラリが有名ですが、「生のWebGLは一体どうやって書くの?」と疑問に思ったので、1から勉強することにしたのです。今から…2〜3ヶ月前のことでしょうか。

そう…当時の私は無知だったのです。
この先に待ち受けるとてつもないコード量とそれらが如何に難解なのかを…。

やめよう、この文体。

生のWebGLでHello Worldまで書いてみよう!

WebGLの最初の入り口としてThree.jsを触っていたんですが、冒頭で述べた通り、Three.jsで実行しているメソッドの中身を理解したいなあ…というかそういう細かいところまで理解しないとこの先複雑な実装とかできなさそうな雰囲気だなあ…と思い、1から勉強してみることにしたのでした。

公式ドキュメントを読み込むのが一番ですよね

ネットでぐぐってみると、当然Khronos Group(WebGLを策定している団体)のサイトが出てきたので、早速公式ドキュメントをダウンロード。

……

全然何を言っているのか分からん。
しかもサンプルがなさすぎる。

なんかこう…公式ドキュメントだから非常に詳細に説明してくれていると思うんだけど、それぞれが具体的にどのように実装されているのかが一番知りたい情報だったりするので、一旦ストップ…。

おうおうにして公式ドキュメントの言っていることが一番正しいですし、深いところまでちゃんと説明してくれていることが多いので、本来ならこれを頼るべきです。ですが、まず最初にやらなきゃいけないことは「おおざっぱでも良いので全体像をなるべく早く把握すること」です。一旦最初から最後までひと通り実装してみて、何がどうなってるのか分からんことには先に進みようがないわけです。

奇跡の日本語チュートリアルサイトを見つける

いろいろと探しているうちに https://wgld.org/ という奇跡のサイトを発見しました。
非常に詳細にわたっていろいろな機能が紹介されているものの、まずは使えるようにすることを念頭において技術的に深いところはあえて端折っているのもありがたいです。

かなりのページがあったので試しに計算してみると、なんと139ページ!こんな量を日本語で解説してくださっているなんて…いったいどんだけ時間がかかったんでしょうか…。頭があがりません…。
スクリーンショット 2017-12-20 11.04.23.png

wgld.orgをひと通り終わるまで1ヶ月半程度かかりました。全てを理解するというわけではなく、いったん最後までやり切ることを最優先としました。行列式や拡張機能、クォータニオンなどについて詳細に調べていたらもっと時間がかかっていたと思います。

canvasにポリゴンを一つ描画するまでの流れ

wgld.orgの中から、頂点3つからなる三角形(ポリゴン)を描画するところまでをかいつまんでまとめてみます。wgld.orgには他にも面白い表現がたくさんあるので、それらも紹介したいのですが…興味のある方は実際にサイトに行って見てみると良いと思います!

  1. WebGLコンテキストを定義
  2. 頂点シェーダ・フラグメントシェーダを定義
  3. 頂点シェーダ・フラグメントシェーダをコンパイル
  4. WebGLProgramオブジェクトを定義
  5. ポリゴンの頂点座標・色情報を定義
  6. 頂点バッファを登録
  7. attributeに頂点座標・色情報を登録
  8. model/view/projection変換
  9. uniformにmodel/view/projection変換した座標を登録
  10. ドローコールを実行してcanvasに描画

ポリゴンを一枚描画するだけでかなりの工程が必要なのが分かると思います。このあたりを裏側で処理してくれているThree.jsがいかに頑張っているか…。これらを一つ一つ見ていきたいと思います。

1. WebGLコンテキストを定義

let gl = canvas.getContext( 'webgl' ) || canvas.getContext( 'experimental-webgl' );

すべてはここから始まります。DOMから取り出してきたcanvas要素にgetContext()メソッドが用意されているので、これでコンテキストを定義します。このglに対して各種WebGLに必要なメソッドが全て用意されていて、それぞれ必要に応じて呼び出していくといった流れです。

結構な量のメソッドが定義されているので、興味のある方は見てみると良いかもしれません。
https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext

2. 頂点シェーダ・フラグメントシェーダを定義

<!- 頂点シェーダ ->
<script id="vs" type="x-shader/x-vertex">
attribute vec3 position;
attribute vec4 color;
uniform mat4 mvpMatrix;
varying vec4 vColor;

void main() {
  vColor = color;
  gl_Position = mvpMatrix * vec4( position, 1.0 );
}
</script>

<!- フラグメントシェーダ ->
<script id="fs" type="x-shader/x-fragment">
precision mediump float;
varying vec4 vColor;
void main() {
  gl_FragColor = vColor;
}
</script>

シェーダ…?

  • 頂点シェーダ…頂点の座標情報を処理するシェーダ
  • フラグメントシェーダ…頂点の色情報を処理するシェーダ

シェーダという単語自体に見慣れない方(というか自分がそうだった)はWikiを読むと割とイメージしやすいかと思います。シェーダ専用の言語としてGLSL(OpenGL Shading Language)という言語があり、上記もGLSLで記述されています。

シェーダー(英: shader)とは、3次元コンピュータグラフィックスにおいて、シェーディング(陰影処理)を行うコンピュータプログラムのこと。「shade」とは「次第に変化させる」「陰影・グラデーションを付ける」という意味で、「shader」は頂点色やピクセル色などを次々に変化させるもの(より具体的に、狭義の意味で言えば関数)を意味する。Wikiより

GLSLにおける変数たち(attribute、uniform、varying)

main()関数の前にattributeやuniformやvaryingなどが書かれていますが、これらはすべていわゆる変数宣言です。

  • attribute変数は各頂点で使われる変数
  • uniform変数はフレームごとに描画するすべての頂点に対して使われる変数
  • varying変数は頂点シェーダとフラグメントシェーダで共有する変数

とおぼえておけばおおかた問題ないかと(…問題ないよな…)。

3. 頂点シェーダ・フラグメントシェーダをコンパイル

// 頂点シェーダの場合
let scriptElement = document.getElementById( "vs" ); // DOMを持ってくる
let shader = gl.createShader( gl.VERTEX_SHADER ); // WebGLShaderオブジェクト(要は箱)を定義
gl.shaderSource( shader, scriptElement.text ); // シェーダ内のテキストを読み込み
gl.compileShader( shader ); // 読み込んだシェーダをコンパイル
if ( gl.getShaderParameter( shader, gl.COMPILE_STATUS ) ) {
  return shader; // エラーが出なかったらWebGLShaderオブジェクトを返却
} else {
  alert( gl.getShaderInfoLog( shader ) );
}

1.で書いたシェーダのscriptタグにid属性をつけていたと思います。どのid属性で頂点シェーダ/フラグメントシェーダを判別して※、WebGLShaderオブジェクトという名の箱にシェーダ内のコードを突っ込んでいきます。
※type属性で判別してもいいと思います。

4. WebGLProgramオブジェクトを定義

let program = gl.createProgram(); // WebGLProgramオブジェクトという名の箱を定義
gl.attachShader( program, vertex_shader ); // コンパイルしたWebGLShaderオブジェクトを紐付け
gl.attachShader( program, fragment_shader ); // コンパイルしたWebGLShaderオブジェクトを紐付け

gl.linkProgram( program ); // (この工程自動でやれば良いと思うんだけど)ここでシェーダ同士をリンク

if ( gl.getProgramParameter( program, gl.LINK_STATUS ) ) {
  gl.useProgram( program ); // リンクが上手くいったら、晴れてこのWebGLProgram使います!宣言を実行
  return program;
} else {
  alert( gl.getProgramInfoLog( program ) );
  console.log( gl.getProgramInfoLog( program ) );
}

コメントアウトの通りなんですが、WebGLShaderオブジェクトの時と同様に、まずWebGLProgramオブジェクトという名の箱を定義します。さきほどコンパイルしておいたWebGLShaderオブジェクトたちを紐付けしていき、リンクがうまくいけばOKです。

5. ポリゴンの頂点座標・色情報を定義

let vertex_position = [
  0.0, 1.0, 0.0,
  1.0, 0.0, 0.0,
  -1.0, 0.0, 0.0
];

let vertex_color = [
  0.0, 1.0, 1.0, 1.0,
  1.0, 0.0, 1.0, 1.0,
  1.0, 1.0, 0.0, 1.0
];

これも何か特別な処理が必要なのかな?と思ったんですが、単純に1次元配列を用意すれば良いようです。ただ、頂点はxyz、色はrgbaで表現するので、それぞれ個数に注意が必要です。

6. 頂点バッファ(座標、色)を登録

// 頂点座標の場合
let vbo = gl.createBuffer();// WebGLBufferオブジェクトという名の箱を定義
gl.bindBuffer( gl.ARRAY_BUFFER, vbo ); // どの種類のバッファに登録するかを予約
gl.bufferData( gl.ARRAY_BUFFER, new Float32Array( vertex_position ), gl.STATIC_DRAW ); // 実際にバッファに溜めるデータを挿入
gl.bindBuffer( gl.ARRAY_BUFFER, null ); // 使用していたバッファの予約を解除 

バッファというとBusiness上「余裕」とか「ゆとり」といった意味で使われることが多いですが、ここでは「データを一時的にためておく領域」といった意味合いで使われます。

また、これも同様に、まずWebGLBufferオブジェクトという名の箱を用意して、そこに頂点に必要な情報を登録していきます。頂点を頂点たらしめるものとして一番イメージしやすいですが、他にも頂点色や法線などもこのバッファに登録することができます。

上記の例は頂点座標の場合ですが、同じ要領で色情報(vertex_color)もバッファに登録しておきます。

7. attributeに頂点座標・色情報を登録

//
attLocation[ 0 ] = gl.getAttribLocation( program, 'position' );  // attribute変数の保存場所を取得
attLocation[ 1 ] = gl.getAttribLocation( program, 'color' ); // attribute変数の保存場所を取得

gl.bindBuffer( gl.ARRAY_BUFFER, position_vbo );  // どの種類のバッファに登録するかを予約
gl.enableVertexAttribArray( attLocation[ 0 ] );  // 対象のattribute変数を有効化
gl.vertexAttribPointer( attLocation[ 0 ], 3, gl.FLOAT, false, 0, 0 ); // attribute変数の保存形式を定義

gl.bindBuffer( gl.ARRAY_BUFFER, position_vbo );
gl.enableVertexAttribArray( attLocation[ 1 ] );
gl.vertexAttribPointer( attLocation[ 1 ], 4, gl.FLOAT, false, 0, 0 );

WebGLProgramオブジェクト(program変数)に登録しておいたシェーダから、attribute変数の保存場所を取り出してきます。もういろいろありすぎて追うのが大変ですが、attribute変数は冒頭でGLSLで書いた頂点シェーダ内で宣言している変数です。ここにポリゴンの座標や色情報を入れていく感じですね。

特にvertexAttribPointer()メソッドが意味不明過ぎたのですが、MDNの説明でザックリ理解できました。

With this method, gl.vertexAttribPointer(), we specify in what order the attributes are stored, 
and what data type they are in. In addition, we need to include the stride, which is the total byte 
length of all attributes for one vertex. 

https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/vertexAttribPointer
登録する頂点情報は単なる1次元配列なので、どこからどこまでは頂点1つあたりの情報かとか、データ型は何なの?などはちゃんと明示的に定義しないと行けないですよね。なるほどなるほど…。

8. model/view/projection変換

// 行列変換処理
let m = new matIV();

let mMatrix = m.initialize( m.create() );
let vMatrix = m.initialize( m.create() );
let pMatrix = m.initialize( m.create() );
let mvpMatrix = m.initialize( m.create() );

// ビュー座標変換行列
m.lookAt( [ 0.0, 1.0, 3.0 ], [ 0.0, 0.0, 0.0 ], [ 0.0, 1.0, 0.0 ], vMatrix );
// プロジェクション座標変換行列
m.perspective( 90, canvas.width / canvas.height, 0.1, 100.0, pMatrix );

m.multiply( pMatrix, vMatrix, mvpMatrix );
m.multiply( mvpMatrix, mMatrix, mvpMatrix );

3次元のものを最終的に2次元のcanvas要素に描画しないといけないので、

  • どのくらいの大きさでどの位置に見えるのか…モデル座標変換
  • どの位置から撮影しているのか…ビュー座標変換
  • どこからどこまで(上下左右奥行き)表示するのか…プロジェクション座標変換

などを定義しないといけません。…このあたり…WebGL側でやってくれないかなあ…と思うんですが。。このあたりの処理は行列の演算が絡み、コードを全て書くと膨大な量になってしまうので、各種ライブラリが有志の方々によって用意されていたりします。もうホント申し訳ないのですが、このあたりはキャパオーバーだったので、とりあえずおまじないとして使っていました。ちゃんと勉強せねば…。
だた、モデル→ビュー→プロジェクション座標変換の概念自体は覚えておいた方が良いかもしれません。

9. uniformにmodel/view/projection変換した座標を登録

let uniLocation = gl.getUniformLocation( program, 'mvpMatrix' );
gl.uniformMatrix4fv( uniLocation, false, mvpMatrix );

冒頭で書いた頂点シェーダを覚えていますでしょうか…?その中にuniform変数を定義していました。

<script id="vs" type="x-shader/x-vertex">
attribute vec3 position;
attribute vec4 color;
uniform mat4 mvpMatrix; // <- コレ!
varying vec4 vColor;

ここに行列処理した座標を登録していきます。getUniformLocationメソッドで保存すべきuniform変数の場所を取得し、uniformMatrix4fvメソッドでmvpMatrixを登録していきます。

ちなみにuniformMatrix4fvは登録するuniform変数の型によってそれぞれメソッド名が変わってきます。変数の値が1つで浮動小数点数の場合は、uniform1fといった感じです。MDNの説明が詳しいので興味のある方は見てみてください。
https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/uniform
https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/uniformMatrix

10. ドローコールを実行してcanvasに描画

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

ドローコールとは実際に今まで処理してきたポリゴンに関する処理をまとめてcanvas要素に描画する命令のことです。…やっと…これで…ポリゴンを描画することができました…。
polygon.png

振り返ってThree.jsのソースを覗いてみると…

ひと通りWebGLを生で実装してみたところで、じゃあThree.jsでは実際どのように実装しているんだ?という疑問が湧かないわけがないのでソースをちょっと覗いてみました。…が、それをここで紹介していると日が暮れてしまうのでまたの機会に…。

取り急ぎ、canvas要素をDOMに突っ込むまでをThree.jsでやると以下のコード量で済みます。

var scene, camera, renderer;

var WIDTH = window.innerWidth;
var HEIGHT = window.innerHeight;

scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(75, WIDTH / HEIGHT, 0.1, 1000);
camera.position.set(0, 0, 10.0);
camera.lookAt(scene.position);

renderer = new THREE.WebGLRenderer();
renderer.setSize(WIDTH, HEIGHT);
renderer.setClearColor(0xffffff, 0.0);
document.body.appendChild(renderer.domElement);

…なんて短いコード量なんだ!!!!!!!!!!!!!

まだまだ穴だらけですが、色々ここまで学んでみて、WebGLってHello Worldレベルを表現するまでにこんなに…こんなに勉強することが多いのか…とビックリしました。でも1万回くらいレンダリングすればネテロ会長と同じ要領でそのうち慣れるんだと思います。

ここまで読んでくださった方がいるとすればそれは嬉しいことなんですが、これを見たからといって「WebGL…やっぱいいや…」と思わないでください!既に世に出ている作品を見てください!!是非!!!全部素晴らしいですから!!ドラクエだって最初はスライムくらいしか倒せないじゃないですかあ…!!!
是非自分もここまで実装してみようかな…と思ってくれる方が増えれば良いなと思いますm(_ _)m