WebGL2を生で書いてみよう
WebGL2とは、ブラウザ上で3DCGを扱うための標準規格であり
OpenGL ES3.0がベースとなっております。(WebGLはOpenGL ES2.0)
クロスプラットフォームであり、ロイヤリティフリーなため、適合試験にパスすれば誰でも実装を歌えるためのもので、Androidなどにも実装されています。
ES2.0では、vertex shader(頂点シェーダー)とfragment shader(ピクセルシェーダー)をサポートしております。
それに加え
ES3.0は後方互換性がありDirectX10世代の機能をサポートしたものが現れました
マルチレンダリングターゲット
マルチサンプルアンチエイリアス(MSAA)
Transform Feedback
Uniform Block
マルチレンダリングターゲット
一つのフラグメントシェーダーで、複数のフレームバッファを描画することが可能になりました。
深度(距離感)
色
ライティング(シェーディング)
法線
を合成して、よりリアルに描画したりなんかも可能になるわけですね
印象としてはグリザイユ技法を想像してもらえるとわかりやすいかと思います。
マルチサンプルアンチエイリアス
ゲームの設定でよく見かけるやつですね。
Transform Feedback
以前のES2.0時代は、一つのFunctionでしかありませんでした。
その頂点データが状態を持てる様になった機能です。
頂点をマウスに追従させたい場合、今まではJS上で
現在の位置 = (マウスの位置 - 現在の位置) * 追従度
みたいな式が必要だったりし、大量にあるとCPUリソースを食います。
これらの状態保持をGPU内で完結させることが可能というわけです。
他にもGPGPUやるときに使ったりします。
Uniform Block (UBO)
過去のES2.0ではUniformというモノに関しては、直接値を指定しなくてはいけませんでした。
直接値を指定するということは複数のプログラム(シェーダーをかけ合わせたもので、レンダリングに用いる)を描画する再、値を再定義する必要がありました。
そんななか、UBOがあれば、それらの処理を共通化を図れます。
すごく嫌な予感はしますが、時間やカメラの位置情報などを扱うときに楽になると思います。
実制作
ぶっちゃけ今回は上記は使用しません。
が、今回は足がかりとなる部分のレンダリングまで、を行っていきたいと思います。
行列などは面倒だし、自分がしっかりと理解していないので省略します。(ライブラリに頼りっぱなし)
以下長くなるのでVertexShader(頂点シェーダー)はVSとし、FragmentShader(ピクセルシェーダー)はFSとします。
尚処理は、手続き的なものが多いため、混乱しないように、フラットに記述しますので冗長になります。
FSとVSを記載する
OpenGL ES 3.0から
attribute -> in
varying -> out
と記載するようになりました
bufferDataでデータを転送
VSのInに搬入
VSのoutに代入のち、FSのinに入る
FSのinを使用しfragmentColorを更新
と言う処理順を踏みます。
下記に今回使用するソースを記述します
code:vertex.glsl
// ファイルの行頭に、ES 3.0 を使用することを明記する
in vec4 color; // 色情報 rgba の配列が来る
out vec4 oColor; // フラグメントシェーダに搬入するための色情報
out vec3 oPosition;
void main() {
oColor = color; // FragmentShaderに渡すため変数に代入する
gl_Position = vec4(vertexPosition, 1.0); // 頂点データを描画するためgl_Positionという組み込み変数に値を渡してGPUへ処理を委任する
}
code:fragment.glsl
precision highp float;
in vec4 oColor;
out vec4 fragmentColor;
void main() {
fragmentColor = oColor;
}
シェーダーをコンパイルする
コンテクストの取得
code:ts
const gl = canvas.getContext('webgl2');
if (gl == null) return;
code:typescript
const vertexShaderSource = ; // 方法は問わないが上で作ったバーテックスシェーダーのソースを入れる
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertexShaderSource);
gl.compileShader(vertexShader);
// コンパイル結果でエラーが出てたらログを出す
const vertexShaderCompileStatus = gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS);
if (!vertexShaderCompileStatus) {
const info = gl.getShaderInfoLog(vertexShader);
console.log(info);
}
これでVSのコンパイルは完了しました。
次にFSのコンパイルですね、概ね違いはありません。
code:typescript
const fragmentShaderSource = ; // 方法は問わないが上で作ったバーテックスシェーダーのソースを入れる
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER); // フラグメントシェーダーをコンパイルする旨を記載
gl.shaderSource(fragmentShader, fragmentShaderSource);
gl.compileShader(fragmentShader);
const fragmentShaderCompileStatus = gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS);
if (!fragmentShaderCompileStatus) {
const info = gl.getShaderInfoLog(fragmentShader);
console.log(info);
}
こういうの外だしして処理の共通化とかやってもよさそうですね。
code:ts
const _createShader = (gl: WebGL2RenderingContext, shaderSource:string, type: SHADER_TYPE): WebGLShader => {
const shaderType = ((gl: WebGL2RenderingContext, type: SHADER_TYPE) => {
switch (type) {
case SHADER_TYPE.VERTEX:
return gl.VERTEX_SHADER;
case SHADER_TYPE.FRAGMENT:
return gl.FRAGMENT_SHADER;
}
})(gl, type)
const shader = gl.createShader(shaderType);
gl.shaderSource(shader, shaderSource);
gl.compileShader(shader);
const shaderCompileStatus = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
if (!shaderCompileStatus) {
const info = gl.getShaderInfoLog(shader);
console.log(info);
}
return shader;
}
const gl = canvas.getContext('webgl2');
if (gl == null) return;
const vertexShaderSource = ; // 方法は問わないが上で作ったVSのソースを入れる
const fragmentShaderSource = ; // 方法は問わないが上で作ったFSのソースを入れる
cosnt createShader = (shaderSource:string, type: SHADER_TYPE) => _createShader(gl, shaderSource, type);
const vertexShader = createShader(vertexShaderSource, SHADER_TYPE.VERTEX);
const fragmentShader = createShader(fragmentShaderSource, SHADER_TYPE.FRAGMENT);
所感ですが、基本的にシェーダーのコンパイルはasyncを使用したほうが良いと思います。
というのも、FSとVSはその性質上別ファイルとして管理したいことが多いです。
CSS的な感じですね。
url-loaderなどを用いて文字列として読み込むのも全然アリだとは思いますが、個人的にはfetchしたほうが好きです。
描画するためのプログラムを作成する(データ搬入前の下準備)
以前、Shaderでinやoutなどの変数を実装しましたが、こいつらをどうやってShaderへ渡すのかは謎でした。
それらのデータを搬入するためにプログラムオブジェクトというものを作成する必要があります。
このプログラムに対し、データを渡し、それらを描画する、というのが一連の流れとなります。
早速プログラムを書いていきたいと思います。
プログラムって名前、ググっても出ないので体系的に勉強しないと何だこれ?ってなってました
code:ts
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
// リンクできたかどうか確認
const linkStatus = gl.getProgramParameter(program, gl.LINK_STATUS);
if (!linkStatus) {
const info = gl.getProgramInfoLog(program);
console.log(info);
}
// 問題がなければプログラムを使用します
gl.useProgram(program);
上記のcreateProgramなんかも関数化したりなんやりすると楽に記述できるかなと思います。
useProgramは複数のプログラムを使用する場合があるので、別途外に出しますが
次に値の作り方を説明していきたいと思います。
Shaderに渡す値を作ろう
ここが一番ダルいまであります。
メモリインタリーブという手法を用いて最適化をしたものを今回は提示します。
GPUのメモリアクセスは近い番地にデータがまとまっていた方が良かったりします。
要は配列を使って値を固めてあげるわけです。
https://gyazo.com/08a7458f1582baa310f2d3e63e3b6bcf
配列が別のほうが管理はしやすいのですが下記の様な形になります。
https://gyazo.com/499da5e962a7ab571ef4d3c447937019
ポイントを見に行って、カラーへ飛んで、Hogeに飛んで
2番めのポイントを見に行って、カラーへ飛んで、Hogeに飛んで、、、みたいな
上記を踏まえた上で値を作っていきたいと思います。
今回は色付きの四角形を描画したいので下記のようにします。
code:ts
const VERTICES = new Float32Array([
-0.5, 0.5, 0.0, // xyz
1.0, 0.0, 0.0, 1.0, // rgba
-0.5, -0.5, 0.0,
0.0, 1.0, 0.0, 1.0,
0.5, 0.5, 0.0,
0.0, 0.0, 1.0, 1.0,
0.5, -0.5, 0.0,
0.0, 0.0, 0.0, 1.0
]);
図解すると下記の様な感じです。
https://gyazo.com/bc993c323150c80b323c405302337c7a
Z軸は0なので省略します
ここで数値を振っているのですがこれには理由があります。
この頂点同士を三角形でつなげてあげて描画する必要があります。
実は三角形の平面を描画できるだけで、すべての形を表現が可能なのです。
数値を用いて三角形を描画するのがインデックスバッファといいます。
パーティクル芸とかするときは実は必要なかったりするのですが、今回は四角形の描画のため、一旦説明します
インデックスは名前の通り0スタートのものですので、
code:ts
const INDICES = new Uint16Array([
0, 1, 2,
1, 3, 2
]);
と線をつなぎます
これが面となります
https://gyazo.com/2a5e56d05de391221bcde8324c3d9313
https://gyazo.com/c0acee2aadf15872ae0d36510f197ee9
この段階でレンダリングエンジン詳しい方ならご存知かと思いますが、WebGLは最適化する際基本的には時計回りに描画することにより、背面か前面かを判定できます。
背面は透過し、前面は描画される
逆に描画してしまった際は透過されるため、何もないかのように見えることがあります。
上記のようなことを
カリング
と呼びます。
デフォルトでは有効になっておらず
gl.enable(gl.CULL_FACE);とすることで有効になり。
gl.frontFace(gl.CW) 時計回り方式
gl.frontFace(gl.CWW) 反時計回り方式
がありますので、適宜設定すると良いかと思います
純粋に、見えてない位置は描画しないようにしたら処理の負荷が減るよねみたいな感じかと思います。
3D系のゲーム好きな人は馴染みがあると思いますが、キャラクターの中身を見ると変に透明になってたりするアレですね。
Programを用いて頂点情報をバインディングしよう
code:typescript
// VSのinに当たる変数を抽出
const vertexAttribLocation = gl.getAttribLocation(program, 'vertexPosition');
const colorAttribLocation = gl.getAttribLocation(program, 'color');
// In変数を有効化
gl.enableVertexAttribArray(vertexAttribLocation);
gl.enableVertexAttribArray(colorAttribLocation);
// in変数とバッファを結び付けます。
gl.vertexAttribPointer(
vertexAttribLocation,
3,
gl.FLOAT,
false,
7,
0
);
gl.vertexAttribPointer(
colorAttribLocation,
4,
gl.FLOAT,
false,
7,
3
);
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, INDICES, gl.STATIC_DRAW);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
レンダリングしよう!
code:typescript
gl.drawElements(gl.TRIANGLES, INDICES.length, gl.UNSIGNED_SHORT, 0);
gl.flush();
単純な静的描画はここでおわりです!
アニメーションを行う
時間をもとにアニメーショを行うシーンも多いと思いますが、今回は一つのシェーダーに対して全体の共通の値、時間を用いてアニメーションを行います。
その際にUniformというGLSLのuniformという機能を使用し、作成していきたいと思います。前回のソースのレンダリングしよう、の前にこちらを挿入してください。
code:ts
// ユニフォームの番地を取得
const timeLocation = gl.getUniformLocation(program, 'time');
// ユニフォームをアップデート
gl.uniform1f(timeLocation, time);
シェーダーも下記のように変更しましょう
code:vertex.glsl
in vec3 vertexPosition;
in vec4 color;
uniform float time;
out vec4 oColor;
void main() {
oColor = color;
vec3 position = vertexPosition;
position.x += sin(time / 1000.0) * 0.5;
position.y += cos(time / 1000.0) * 0.5;
gl_Position = vec4(position, 1.0);
}
code:fragment.glsl
precision highp float;
in vec4 oColor;
out vec4 fragmentColor;
void main() {
fragmentColor = oColor;
}
バッファを更新するのはバインドしたりなどの手間も必要でしたが、今回は、バインドするまでもなくアップデートをかけれます。
こちらをrequestAnimationFrameを含め回してあげるとアニメーションが可能になります。
メインループってやつですね。
上記まで定義をしたら下記最終行に追記しましょう
code:typescript
const loop = (time: number) => {
requestAnimationFrame(loop);
gl.uniform1f(timeLocation, time);
// 描画セット
gl.drawElements(gl.TRIANGLES, INDICES.length, gl.UNSIGNED_SHORT, 0);
// 描画
gl.flush();
}
requestAnimationFrame(loop);
requestAnimationFrameは第一引数に対し、timeが与えられます、performance.now()とさほど変わりがないですが、関数を読んでいるわけではないのでパフォーマンスは高いです。
これで無事一般的なシェーディングはマスターできたかと思います。
あとはプログラムを複数持たせたり、uniformを更新しやすくしたり、など、プログラミング的な表現がメインとなっていきますので各々頑張ってください!
WebGL2は主にパフォーマンス・チューニングに寄っているため、拡張性を保ちつつ、追記していけばそこそこ重たい処理でも難なくさばけるかなと思いました。