GLSL だけでレンダリングする

実行結果

今回のサンプルの実行結果

GLSL only

さて始まりました、GLSL オンリーでレンダリングを行っていこうという本章。第一回目となる今回は、ベースとなる HTML や javascript のパーツを実際に作っていきつつ、必要な最低限の知識を身につけられる内容にしたいと思っています。

そもそも、GLSL だけで描画するってどういうことなの? というところからのスタートになりますので上級者の方には物足りない内容になるかもしれませんが、ひとつひとつ丁寧に書いていきたいと思います。というのも、けして最初から脅しをかけるわけではないのですが、この手の分野は理解に苦労するような内容が多いように思うからです。

また、あらかじめある程度は 3D プログラミングに関する知識があったほうが確実に有利です。頭の中でロジックを組み立てるためには、優れた 2D や 3D のグラフィック処理に関する予備知識があって困ることはありません。ですが無いと恐らく理解できない内容が多くなるでしょう。

しかし当サイトはあくまでも入門サイト、開発支援サイトという謳い文句で運営しています。できるだけ、どんな人でも理解できるような内容になるように努力してテキストを書いていきます。わからないことがあったときは、メールでも、または twitter などで質問していただいても大丈夫です。一緒にがんばりましょう。

GLSL だけでレンダリング

従来の WebGL のプログラムの場合、あらかじめツールを用いるかプログラム内で動的に生成するなどして、複数の頂点からなるモデルをスクリーン上にレンダリングしていました。

モデルをレンダリングする上では、ライトが当たっている状態や影が落ちている状態、あるいはテクスチャから色情報を抜き出すためなどにフラグメントシェーダを使っていましたね。

フラグメントシェーダでキューブ環境マッピングを施した例

フラグメントシェーダでキューブ環境マッピングを施した例

そして、当サイトのテキストでも何度か登場しているように、フラグメントシェーダを用いた特殊なエフェクト処理などもありました。たとえば、ガウシアンブラーを適用してみたり、エッジ検出のために sobel フィルタを適用してみたり、フラグメントシェーダを用いることで、レンダリング結果に対して多種多様なエフェクトをかけることが可能でした。

レンダリング結果に対して sobel フィルタを適用した例

レンダリング結果に対して sobel フィルタを適用した例

GLSL だけでレンダリングを行うということは、このフラグメントシェーダでエフェクトをかける処理と本質的には同じです。スクリーン上には、スクリーンにぴったりと収まる一枚の板ポリゴンだけを描画します。そして画面全体に対して GLSL によって記述されたフラグメントシェーダを走らせることで何かを描き出していきます。

さて、ここでちょっと考えてみてください。

これをご覧の皆さんは、ここまでを読んで[ GLSL だけでレンダリングすること ]に対してどんなイメージが浮かぶでしょうか。

フラグメントシェーダだけで描画するというところから、恐らく二次元のエフェクトや幾何学模様の生成などをイメージされた方が多いのではないでしょうか。

もちろん、そういった処理も非常に手軽に行えます。たとえば、フラクタル図形の一種であるジュリア集合は、ほんの数行の GLSL コードで以下のように美しくレンダリングされます。

フラグメントシェーダだけで描いたジュリア集合

フラグメントシェーダだけで描いたジュリア集合

このジュリア集合のような、二次元平面上で処理できるものは GLSL によって表現のしやすいものの代表例と言っていいでしょう。また、GLSL のフラグメントシェーダを使って描くということは、当然ながら各種演算には GPU が使われることになります。このことから、一般に計算コストが高いと言われているものであっても高速にレンダリングすることができる場合が多いです。

そして、フラグメントシェーダによる描画では、工夫次第で三次元空間を扱うことも可能です。

これは、従来の WebGL プログラムのようにモデルの頂点データをプログラム側からシェーダに送る仕組みを使うのではなく、あくまでもフラグメントシェーダだけで三次元を扱うことができるという意味です。

ここは具体例を出しましょう。

たとえば以下の画像はフラグメントシェーダだけでレンダリングしたもので、地形に関する頂点データなどを javascript からシェーダに送ったりは一切していません。

GLSL のみで描いた溶岩地帯を上から見た景観

GLSL のみで描いた溶岩地帯を上から見た景観

このように、本来スクリーン上に表示されるピクセルの色情報を扱うことが役割のフラグメントシェーダを使って、様々なものを描き出すことができるのですね。そしてその内容は、2D だけでなく、工夫次第で 3D 化することもできるのです。

実装について考える

さて、フラグメントシェーダだけでその気になれば 3D レンダリングが行えると言っても、具体的にはどのようにプログラムを組んでいけばいいのかを考えていきましょう。

まず、javascript 側のコードと HTML から見ていきます。

javascript では、先にも触れたように板ポリゴンを一枚だけレンダリングするための、ベースとなる WebGL のプログラムを組みます。従来までの当サイトのサンプルと同じように window.onload に初期化処理を入れておきます。

初期化処理では、シェーダのコンパイルや VBO の生成などを行っておき、画面に板ポリゴンをレンダリングするための最低限の下準備を行います。

javascript 実装

// global
var c, cw, ch, mx, my, gl, run, eCheck;
var startTime;
var time = 0.0;
var tempTime = 0.0;
var fps = 1000 / 30;
var uniLocation = new Array();

// onload
window.onload = function(){
	// canvas エレメントを取得
	c = document.getElementById('canvas');
	
	// canvas サイズ
	cw = 512; ch = 512;
	c.width = cw; c.height = ch;
	
	// エレメントを取得
	eCheck = document.getElementById('check');
	
	// イベントリスナー登録
	c.addEventListener('mousemove', mouseMove, true);
	eCheck.addEventListener('change', checkChange, true);
	
	// WebGL コンテキストを取得
	gl = c.getContext('webgl') || c.getContext('experimental-webgl');
	
	// シェーダ周りの初期化
	var prg = create_program(create_shader('vs'), create_shader('fs'));
	run = (prg != null); if(!run){eCheck.checked = false;}
	uniLocation[0] = gl.getUniformLocation(prg, 'time');
	uniLocation[1] = gl.getUniformLocation(prg, 'mouse');
	uniLocation[2] = gl.getUniformLocation(prg, 'resolution');
	
	// 頂点データ回りの初期化
	var position = [
		-1.0,  1.0,  0.0,
		 1.0,  1.0,  0.0,
		-1.0, -1.0,  0.0,
		 1.0, -1.0,  0.0
	];
	var index = [
		0, 2, 1,
		1, 2, 3
	];
	var vPosition = create_vbo(position);
	var vIndex = create_ibo(index);
	var vAttLocation = gl.getAttribLocation(prg, 'position');
	gl.bindBuffer(gl.ARRAY_BUFFER, vPosition);
	gl.enableVertexAttribArray(vAttLocation);
	gl.vertexAttribPointer(vAttLocation, 3, gl.FLOAT, false, 0, 0);
	gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, vIndex);
	
	// その他の初期化
	gl.clearColor(0.0, 0.0, 0.0, 1.0);
	mx = 0.5; my = 0.5;
	startTime = new Date().getTime();
	
	// レンダリング関数呼出
	render();
};

いくつか自前の関数が使われていますが、やっていることはそれほど難しくありません。

サンプルでは、canvas のサイズは 512px の正方形とし、このスクリーンサイズはマウスイベントなどでも利用するためグローバルな変数に確保するようにしています。また後述しますが、HTML にはチェックボックスを一つ設置してあり、これを捕捉するためのイベントリスナーの登録なども行っています。

生成するシェーダは一組だけです。また、uniform 変数は三つだけ GLSL 側で定義しますので、初期化時にロケーションを取得しておきます。頂点データは、板ポリゴン用に 4 頂点だけ配列を使って定義しておき、VBO を生成・登録しておきます。頂点数が少ないので IBO を使うまでもないのですが、一応実装としてはインデックスバッファを用いる形になっています。

スクリーンは、ループ時に黒でクリアされるように設定。すべての初期化が終わったらレンダリング関数を呼び出す前に、今現在のタイムスタンプを変数に入れておきます。該当箇所だけを抜粋したのが以下のコード。

現在の時刻を変数に確保

startTime = new Date().getTime();

この変数 startTime は、サンプルが動作する際にどの程度時間が経過しているのかを uniform 変数としてシェーダに渡す際に利用します。

少々駆け足ですが、実際にレンダリングを行う関数の中身も見てみましょう。

render 関数

// レンダリングを行う関数
function render(){
	// フラグチェック
	if(!run){return;}
	
	// 時間管理
	time = (new Date().getTime() - startTime) * 0.001;
	
	// カラーバッファをクリア
	gl.clear(gl.COLOR_BUFFER_BIT);
	
	// uniform 関連
	gl.uniform1f(uniLocation[0], time + tempTime);
	gl.uniform2fv(uniLocation[1], [mx, my]);
	gl.uniform2fv(uniLocation[2], [cw, ch]);
	
	// 描画
	gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
	gl.flush();
	
	// 再帰
	setTimeout(render, fps);
}

レンダリング関数の中では、引き続き継続してレンダリングを行うかどうかのフラグ(変数 run で管理。これはチェックボックスの状態を真偽値で取得したもの)をチェックしてから、経過時間を取得していますね。

Date().getTime() によって得られるのはミリ秒単位の時間なので、これをそのまま渡すと非常に大きな数字になってしまうため千分の一にしてシェーダに送ります。

また、同様にマウスカーソルの座標位置、そしてスクリーンのサイズをシェーダに送っているのもわかりますね。uniform 変数としてシェーダに送るのは、経過時間、マウスカーソルの位置、そしてスクリーンのサイズの三つということになります。

スクリーンのサイズ

GLSL のみでレンダリングする本章の内容ですが、サンプルではスクリーンサイズを縦横ともに 512px としました。

これにはいくつか理由がありますが、負荷が高くなりすぎないようにするため、というのが最大の理由です。GPU を使って高速に動作するとは言っても、シェーダの内容如何ではとんでもなく高負荷なものが出来上がる場合があります。

フラグメントシェーダでスクリーン全体を走査する本章のサンプルでは、このスクリーンサイズは負荷に直結する要素です。ある程度幅広い動作環境に対応する意味も込めて、特別な理由がない限りはスクリーンサイズはこの大きさに統一することにしました。

当然、このスクリーンサイズでなければいけないとか、正方形でなければいけないとか、そういうことではありません。自分の実装に合わせて適宜修正しつつコードを書いてみてください。

チェックボックスは、先ほども少し触れましたがループを継続するかどうかのフラグとして利用します。そして、マウス座標に関しても、イベントリスナーを登録して常に捕捉できるようにしておきます。

イベント系関数

// checkbox
function checkChange(e){
	run = e.currentTarget.checked;
	if(run){
		startTime = new Date().getTime();
		render();
	}else{
		tempTime += time;
	}
}

// mouse
function mouseMove(e){
	mx = e.offsetX / cw;
	my = e.offsetY / ch;
}

チェックボックスのオンオフが変更された際には、フラグの状態を変更するだけでなく、場合によってはそこまでの経過時間をグローバルな変数に保存する処理も入っています。これは、上記のコードを見ればなんとなく何をやっているのかはわかると思います。

マウスカーソルの座標は、スクリーンの幅で正規化して 0 ~ 1 の範囲でシェーダに送るようにしている感じですね。また、実際のソースファイルにはここで掲載した以外にもいくつかの自作関数などが含まれています。いままで使ってきたものとほとんど同じものですが、詳細を知りたい場合はサンプルのページからソースを参照してみてください。

HTML と GLSL ソース

さて続いては HTML と、それに含まれるサンプルの GLSL コードを見てみます。こちらは、いきなり全文を掲載してしまいましょう。

サンプルの HTML

<html><head><!-- fragment shader --><script id="fs" type="x-shader/x-fragment">precision mediump float;
uniform float time;
uniform vec2  mouse;
uniform vec2  resolution;

void main(void){
	vec2 p = (gl_FragCoord.xy * 2.0 - resolution) / min(resolution.x, resolution.y);
	vec2 color = (vec2(1.0) + p.xy) * 0.5;
	gl_FragColor = vec4(color, 0.0, 1.0);
}
</script><!-- /fragment shader -->

<!-- vertex shader --><script id="vs" type="x-shader/x-vertex">
attribute vec3 position;

void main(void){
	gl_Position = vec4(position, 1.0);
}
</script><!-- /vertex shader -->

<script src="script.js" type="text/javascript"></script>
<style type="text/css">
	* {
		text-align: center;
		margin: 10px auto;
		padding: 0px;
	}
	canvas {
		box-shadow: 0px 0px 0px 1px gray;
	}
</style>
</head>
<body>
	<canvas id="canvas"></canvas>
	<p><input type="checkbox" id="check" checked><label for="check"> auto run</label></p>
</body></html>

多少の最適化が行われているので、ちょっとぶっ詰まってる感じがありますがよくよく見ればそんなに複雑な構造にはなっていません。

ここで注目してもらいたいのは、フラグメントシェーダが書かれている script タグのブロックです。

本来なら、html タグや head タグのところで改行したほうがキレイに見えると思うのですが、あえて無理やり一行にまとめて書かれています。これにはちゃんと理由があります。

というのも、WebGL の場合、シェーダのコンパイルに失敗するとその失敗してしまった理由を調査することができます。このとき、シェーダのソースの何行目に不備があったのかを行番号で知ることができるわけですが、この行番号はあくまでも GLSL ソースの中での行番号 として取得されてきます。

自分でシェーダを書いている際に、デバッグする上で行番号は非常に重要かつ有用な情報ですよね。この行番号がエディタ上でどこの行を指しているのかがわかりにくいと、デバッグ作業が非常にやりにくくなります。

上記の HTML のように、フラグメントシェーダのソースを一行目から書き始めることで、実際の HTML 上の行番号と GLSL ソース内の行番号が一致するようになります。これで、エラーが起こった際に原因の特定が容易になり、デバッグの助けになるわけです。

すごく地味な部分ですが、上記のような HTML の構成にすることで、GLSL を記述することに集中できるようになるのでオススメです。

さて、続いてはシェーダのソースについて見てみましょう。

まずは、簡単な頂点シェーダのほうから。

頂点シェーダのソース

attribute vec3 position;

void main(void){
	gl_Position = vec4(position, 1.0);
}

めっちゃ簡素!

頂点シェーダでは、attribute 変数として入ってくる頂点情報を vec4 に変換して渡すのみです。行列すら出てきません。なにも難しいことはありませんね。

それではフラグメントシェーダのほうも見てみます。

フラグメントシェーダのソース

precision mediump float;
uniform float time;
uniform vec2  mouse;
uniform vec2  resolution;

void main(void){
	vec2 p = (gl_FragCoord.xy * 2.0 - resolution) / min(resolution.x, resolution.y);
	vec2 color = (vec2(1.0) + p.xy) * 0.5;
	gl_FragColor = vec4(color, 0.0, 1.0);
}

ここは若干わかりにくい部分もあるかもしれませんので詳細に説明します。

まず、先述のとおり、uniform 変数は三つです。

経過時間を表す float 型の変数 time は、javascript 側でミリ秒単位を千分の一にしてから送っているものですね。1.234 秒、といったように、ミリ秒以下が小数点以下になるような数値として送られてきます。

続いて mouse には、マウスカーソルの座標が左上隅を原点として 0 ~ 1 の範囲に正規化されて送られてきます。

聞きなれない名称になっている resolution には、スクリーンの縦横の幅が vec2 として入ってきます。

これらのことを踏まえて main 関数の中でなにをやっているのか考えてみてください。

main 関数の中身だけを抜粋

vec2 p = (gl_FragCoord.xy * 2.0 - resolution) / min(resolution.x, resolution.y);
vec2 color = (vec2(1.0) + p.xy) * 0.5;
gl_FragColor = vec4(color, 0.0, 1.0);

まず、変数 p に対して何かを計算しているところ。ここでは、今から処理しようとしているスクリーン上のピクセルの位置を -1 ~ 1 の範囲に正規化する処理を行っています。

gl_FragCoord には、処理する対象となるピクセルの位置がそのままピクセル単位で入っています。これを二倍してスクリーンサイズを引き、それに対してさらにスクリーンサイズによる除算を行います。これを計算すると、対象のピクセルがスクリーン上でどの位置にあるのかを、-1 ~ 1 の範囲に正規化することができるのですね。スクリーンの横幅が 512px で、処理対象ピクセルがの位置が 256 であれば、計算結果が 0 になることがコードをよく見ればわかると思います。

この処理によって、スクリーンの中心を (0.0, 0.0) とした二次元の座標系が出来上がることがわかると思います。

さらに、変数 color に値を代入しているところでは何をやっているのでしょうか。

ここでは、先ほど求めた p に 1 を足して、それを半分にしています。先ほども書いたように p は X と Y のいずれも -1 ~ 1 に正規化されているので、このような処理を行うことで 0 ~ 1 範囲に正規化されます。最終的にはこの値を、そのまま色としてスクリーン上に出力していることになりますね。

スクリーンとなる canvas 上では、右に行けば行くほど赤が強くなり、上に行けば行くほど緑が強くなるはずです。

今回は、正規化のような処理をわざわざ二度行っていて、すごく無駄です(笑)

ですが、最初に p に対して -1 ~ 1 を入れる処理は、今後のサンプルでも基本的に毎回必ず行う正規化処理になります。今回のサンプルは最初なので、それを単に色に変換するために color という変数に再度入れているだけです。

変数 p に最初に代入している正規化処理の仕組みは、ぜひ理解しておいてください。今回のサンプルの最大の肝はここです。

GLSL の色の扱い

先ほどのコードで、変数 p をわざわざ 0 ~ 1 の範囲に正規化しましたね。もし、この正規化を行わずにそのまま色として出力した場合、座標によってはマイナスの数値が色として設定されてしまう可能性があります。

この場合、出力結果はどのようなものになるのでしょうか。

結論を書いてしまうと、マイナスの値を色として代入しても特に問題はありませんし、単に 0.0 と同様に扱われるだけです。

また、たとえば 1 よりも大きな数値を色に設定しようとした場合にも同様のことが起こり、どれほど大きな数値であっても 1.0 に丸められます。つまり、0 ~ 1 の範囲に必ずクランプされてしまうというわけです。

範囲外の数値を渡してしまうことに特別問題はありません。大きな数値を渡したからといって、光があふれるような演出が勝手にかかったりするようなこともありません。今回のサンプルであえて手動で 0 ~ 1 の範囲に正規化したのは、そのほうがサンプルのレンダリング結果がわかりやすいものになるかなと思ったからです。それ以上の、深い意味はありません。

まとめ

今回のサンプルでは最終的に出力されるのはグラデーションの掛かった色だけです。時間の経過も、マウスカーソルの座標位置も、uniform 変数として送ってはいるものの最終的なレンダリング結果には何も影響しない非常に簡素なものです。

しかし、GLSL だけでレンダリングするために必要な、最低限の骨組はほぼすべて含まれています。特に、最後に解説した resolution を利用した処理対象ピクセルの正規化は、今後も毎回使うことになりますのできちんと理解しておいてほしいポイントとなります。

また、これは余談ですが uniform 変数としてシェーダ内で宣言が行われている場合でも、シェーダ内で一度も利用されない uniform 変数については javascript 側でのロケーションの取得に失敗します。これが深刻な問題となるようなことはありませんが、本来は利用しないのであれば宣言しないのが正解です。

今回のサンプルは、今後の解説テキストでずっと利用していくベースとなる部分なのであえてそのままになっています。その点、注意してください。

次回以降は、マウスカーソルや時間の経過についても利用するような、よりインタラクティブなものが徐々に増えていきます。そして最終的には、三次元を扱うものも出てくるでしょう。

今回は基本中の基本を詳細に解説しただけですが、それだけに大事な内容になっています。何事もまずは基本から、しっかり押さえておきましょう。

サンプルはいつものように以下のリンクより。

entry

PR

press Z key