Subterranean Flower

WebGL2入門 基礎編

Author
古都こと
ふよんとえんよぷよぐやま!!!

WebGLもずいぶんと普及し、そろそろWebGL2の足音も聞こえ始めています。一部のブラウザでは既に実装が進み、限定的ではありますが、実際に利用することも可能になっています。

しかしWebGL2に関する情報は少なく、あったとしてもWebGLとの差異を説明したにとどまるものが多いのが現状です。そこで、この記事ではWebGL2について、基本的な使い方を包括的に説明していきたいと思います。

WebGL2入門 記事一覧

WebGL2とは

WebGL2はブラウザ上で3DCGを扱うための標準規格です。OpenGL ES 3.0がベースになっており、全体的な仕様もそれに準拠します。WebGL2を使えば、GPUの恩恵を受けた高速なレンダリングが可能になります。レンダリングはcanvas要素に対して行われます。

WebGL2の前身であるWebGLは、Open GL ES 2.0がベースになっていました。しかしOpenGL ES 2.0はかなり古い規格で、機能としても時代遅れと言わざるをえませんでした。それがWebGL2ではES 3.0まで引き上げられ、ある程度モダンな機能が使えるようになりました。もちろん最新規格であるOpenGL ES 3.2やVulkanには及びませんが、WebGL2はWebGLに比べていくつかの便利な機能が追加されています。

WebGL2をはじめよう

WebGL2は複雑なAPIです。まずは簡単なところから始めていきましょう。とりあえずは以下の図のような四角形を描画するところを目指します。

webgl2

前準備

はじめに前準備をします。

// Canvasを作成してbodyに追加します。
const canvas = document.createElement('canvas');
canvas.width = 500;
canvas.height = 500;
document.body.appendChild(canvas);

// WebGL2のコンテキストを取得します。
const gl = canvas.getContext('webgl2');

まずはcanvas要素を作成してbody要素に追加します(HTMLに直接書いても構いません)。canvasを作成したらWebGL2RenderingContextオブジェクト(以下、コンテキスト)を取得します。コンテキストが取得できない場合、そのブラウザはWebGL2には未対応です。

これで前準備は整いました。あとはコンテキストを操作することで、WebGL2の利用が可能になります。

シェーダの準備

前準備が終わったのでさっそく描画……と行きたいところですが、WebGL2には固定機能パイプラインが用意されておらず、描画のための仕組みをプログラマブルシェーダで記述しなければいけません。描画の仕組みをプログラマブルにすることで、柔軟なグラフィック描画を可能にしているのです。自分で描画の仕組みを用意すると言っても、凝ったことをしなければあまり複雑にはなりません。

WebGL2のレンダリングパイプライン(描画の流れ)は以下のようになっています:

webgl_pipeline

この図の赤い部分、つまりバーテックスシェーダフラグメントシェーダが、我々が記述するシェーダになります。ふたつありますが、どちらも簡単なものなので、用意するのは大きな手間にはならないはずです。

このうちバーテックスシェーダ(頂点シェーダ)は頂点処理を担当するシェーダです。3D空間上にある頂点を、移動したり回転したりして、画面上の座標へと変換します。

フラグメントシェーダ(ピクセルシェーダ)はピクセルレベルの処理を担当します。ピクセルに色をつけたり、テクスチャを貼り付けたりするのが主な仕事になります。

ふたつのシェーダはGLSL(OpenGL Shader Language)で記述します。GLSLはC言語に似た文法を持つので、JavaScriptプログラマには馴染みの薄い書き方になりますが、シンプルなのですぐに飲み込めると思います。

まずはバーテックスシェーダを書きましょう。vertex_shader.glslという名前で保存しておきます。

#version 300 es
// ↑ 一行目に必ず書きます。
// OpenGL ES 3.0(WebGL2)の機能を使うことを明示しています。

// JavaScriptから入力される値を、inで宣言します。
// 今回は「頂点座標」と「頂点色」のふたつを用います。
// 頂点座標:x,y,zの3要素のベクトル
// 頂点色:r,g,b,alphaの4要素のベクトル
in vec3 vertexPosition;
in vec4 color;

// このシェーダからフラグメントシェーダに対して出力する変数を
// outで宣言します。
out vec4 vColor;

void main() {
  // 頂点色を何も処理せずにそのままフラグメントシェーダへ出力します。
  vColor = color;

  // 頂点座標を決定するには、gl_Position変数へ書き込みます。
  // 今回は特別な処理は行わず、受け取った値をそのまま素直に出力しています。
  // 頂点座標はx,y,z,wの4つになるので、vec3からvec4へと変換しています。
  // wの値については後ほど説明しますが、ここでは1.0固定にしておいてください。
  gl_Position = vec4(vertexPosition, 1.0);
}

バーテックスシェーダの処理は簡単です。まずJavaScriptから「頂点の座標」と「頂点の色」を受け取ります。次に、色の処理はフラグメントシェーダの担当なので、そのまま何もせずにフラグメントシェーダへ頂点色を渡します。最後に頂点座標を決定しますが、ここでは難しい変換処理は行わず、受け取った頂点座標をそのまま書き込んでいます。これが頂点ごとに呼び出されます。

GLSLではJavaScriptと違い、変数の型を明示的に指定します。例えば今回の場合、頂点座標vertexPosition変数はvec3型の変数として宣言しています。vec3は要素数3のベクトルを表します。同様にvec4は要素数4のベクトルを表します。また、3×3の行列を表すmat3や4×4の行列を表すmat4など、さまざまな型が存在します。用途に合わせて適切な型を指定しましょう。

変数には、基本的にはinout、uniformの3種類があります。uniformについては後ほど解説します。inはJavaScriptから受け取る値を表します。入力なのでin、ということです。outは逆にフラグメントシェーダへ出力する値を表します。これも出力するのでoutという名前になっています。

glsl-variables

次にフラグメントシェーダを書きましょう。fragment_shader.glslに以下の内容を書き込みます。

#version 300 es

// float(単制度浮動小数点)の精度を指定します。
// これは必須です。
// lowp, midiump, highpなどありますが、
// 特別な理由がない限りhighpでいいでしょう。
precision highp float;

// バーテックスシェーダから受け取る変数を
// inで宣言します。
in vec4 vColor;

// 画面に出力する色の変数を宣言しておく。
// r,g,b,alphaのvec4。
out vec4 fragmentColor;

void main() {
  // 特に何も処理せずそのまま色を出力する。
  fragmentColor = vColor;
}

こちらはもっとシンプルです。バーテックスシェーダから頂点色を受け取り、ピクセルに書き込むだけです。ピクセルに色を書き込むには、outでvec4型の変数を宣言しておく必要があります。今回はfragmentColorという名前で宣言しています。

フラグメントシェーダでは変数の精度を指定する必要があります。今回はfloat型をhighp(高精度)に設定しています。float型というのは単制度浮動小数点型のことです。簡単に言うと、精度の低い実数を表せる変数ということになります。

JavaScriptでは浮動小数点型はNumberのみですが、多くの言語ではfloat(単精度浮動小数点)double(倍精度浮動小数点)に分かれています。一般にfloat型は32bit程度の精度を持ち、その場合double型は64bitなります。つまりJavaScriptのNumber型はdoubleと言えます。科学計算用途ではdoubleが使われますが、グラフィック処理では一般にはfloatが用いられ、GPUもfloatの処理に最適化された設計になっています。

また、細かい問題として、頂点でない場所の色がどういう扱いになるのかというものがあります。フラグメントシェーダが処理するのは全てのピクセルなので、頂点座標以外の場所も処理するわけです。そのときの色はどうなるのでしょうか。nullが入る?いいえ、違います。デフォルトの設定では、頂点でない場所の色は、頂点の色から自動的に補間されて渡され、色が決定します。もちろん補間をオフにすることもできます。

シェーダプログラムの作成

シェーダプログラムを実際に利用するにはコンパイル(変換)する必要があるのですが、WebGL2ではコンパイル済みバイナリをサポートしていません。つまり、プログラムを実行する前に動的にコンパイルしなければなりません。

まず、外部ファイルに記述したシェーダを読み込みましょう。

const loadVertexShader = fetch('vertex_shader.glsl');
const loadFragmentShader = fetch('fragment_shader.glsl');

Promise.all([loadVertexShader, loadFragmentShader])
    .then((responses) => Promise.all([responses[0].text(), responses[1].text()]))
    .then((shaderSources) => {
        const vertexShaderSource = shaderSources[0];
        const fragmentShaderSource = shaderSources[1];

        // ここに追加していく
    });

// ここから先には何も追加しない

ここではFetch APIを使用していますが、Fetch APIに馴染みがないのならば、XHRを利用しても構いません。

次に、バーテックスシェーダとフラグメントシェーダをそれぞれコンパイルします。

        // バーテックスシェーダをコンパイルします。
        const vertexShader = gl.createShader(gl.VERTEX_SHADER);
        gl.shaderSource(vertexShader, vertexShaderSource);
        gl.compileShader(vertexShader);

        // コンパイル成功したか否かをチェックします。
        const vShaderCompileStatus = gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS);
        if(!vShaderCompileStatus) {
            const info = gl.getShaderInfoLog(vertexShader);
            console.log(info);
        }

        // フラグメントシェーダについても同様にします。
        const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
        gl.shaderSource(fragmentShader, fragmentShaderSource);
        gl.compileShader(fragmentShader);

        const fShaderCompileStatus = gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS);
        if(!fShaderCompileStatus) {
            const info = gl.getShaderInfoLog(fragmentShader);
            console.log(info);
        }

コンパイルの成功が確認できたら、シェーダプログラムを作成します。シェーダプログラムを作成するには、バーテックスシェーダとフラグメントシェーダをリンクします。

        // シェーダプログラムを作成します。
        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);

これでシェーダの準備は終わりです。

バッファの準備

シェーダの準備が終わったので、あとはGPUにデータを流し込んで処理してもらうだけです。GPUにデータを転送するにはバッファという仕組みを使います。今回はバーテックスシェーダで頂点座標と頂点色のふたつのデータを使用するので、バッファもふたつ用意します。

        // データを転送するためのバッファを作成します。
        const vertexBuffer = gl.createBuffer();
        const colorBuffer = gl.createBuffer();

バッファの準備ができたら、次は各バッファをバーテックスシェーダのin変数へ結び付けます。バッファをin変数に結びつけるためには、JavaScript側で変数の位置を取得するか、シェーダ側で変数の位置を固定する必要があります。今回は前者の方法で位置を取得します。

        // バーテックスシェーダのin変数の位置を取得します。
        const vertexAttribLocation = gl.getAttribLocation(program, 'vertexPosition');
        const colorAttribLocation  = gl.getAttribLocation(program, 'color');

in変数の位置が取得できたら、次にバッファをバインドして、in変数を有効化します。有効化したら実際にバッファとin変数を結び付けます。

        const VERTEX_SIZE = 3; // vec3
        const COLOR_SIZE  = 4; // vec4

        // バッファ操作前には必ずバインドします。
        gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
        // in変数を有効化します
        gl.enableVertexAttribArray(vertexAttribLocation);
        // 現在バインドしているバッファと変数を結びつけます。
        // サイズはvec3を指定してるので3にします。型はFLOATを使用します。
        // うしろ3つの引数は今は気にしないでください。
        gl.vertexAttribPointer(vertexAttribLocation, VERTEX_SIZE, gl.FLOAT, false, 0, 0);

        // 頂点色についても同様にします。
        gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
        gl.enableVertexAttribArray(colorAttribLocation);
        gl.vertexAttribPointer(colorAttribLocation, COLOR_SIZE, gl.FLOAT, false, 0, 0);

バッファのバインドの形式にはARRAY_BUFFERELEMENT_ARRAY_BUFFERの2種類がありますが、通常はARRAY_BUFFERを用います。

vertexAttribPointerメソッドには引数が6つあり、後半の3つについては触れていませんが、今は気にしないでください。また、第2引数でサイズを指定しますが、これがシェーダ側のin変数のサイズと食い違わないように注意しましょう。

データの描画

シェーダプログラムの準備ができ、バッファに書き込む準備ができたので、次は実際にバッファにデータを書き込んでみましょう。

一般に3Dグラフィックスのデータ形式は、三角形(ポリゴン)の集合として表します。三角形を組み合わせれば、いかなる図形も表現することができるからです。四角形などを用いる場合もありますが、普通は三角形のみを用います。今回は四角形を書きたいので、三角形をふたつ用意します。三角形ひとつにつき3頂点なので、全部で6頂点になります。

WebGL2では一般に慣習として右手座標系を用います。右手座標系はZ軸のプラス方向が手前となる座標系です。

right-handed-coord

また、デフォルトの設定では三角形の頂点は反時計回りに配置します。

以上のことを意識しながらデータを用意しましょう。四角形を描くため、三角形ふたつで6頂点分の座標と色を用意します。

        // 頂点情報。vec3で宣言しているので、xyzxyzxyz…と並べていきます。
        // WebGL2では基本的にfloat型を使うので、Float32Arrayを使用します。
        const vertices = new Float32Array([
            -0.5, 0.5,  0.0,
            -0.5, -0.5, 0.0,
            0.5,  0.5,  0.0,
            -0.5, -0.5, 0.0,
            0.5,  -0.5, 0.0,
            0.5,  0.5,  0.0
        ]);

        // 色情報。vec4で宣言してるのでrgbargbargba…と並べていきます。
        // すべて0.0〜1.0の範囲で指定します。
        // 頂点と同じ数だけ(今回は6つ)必要です。
        const colors = new Float32Array([
            1.0, 0.0, 0.0, 1.0,
            0.0, 1.0, 0.0, 1.0,
            0.0, 0.0, 1.0, 1.0,
            0.0, 1.0, 0.0, 1.0,
            0.0, 0.0, 0.0, 1.0,
            0.0, 0.0, 1.0, 1.0
        ]);

頂点座標はxyzの3つ組、頂点色はrgbaの4つ組であることを考慮してデータを用意してください。それぞれ6頂点分用意します。

準備ができたらバッファへ転送していきます。

        // バインドしてデータを転送します。
        gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

        gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW);

bufferDataメソッドの第三引数ではバッファの使用方法を指定します。{STATIC, DYNAMIC, STREAM}_{DRAW, READ, COPY}の9つが選択できます。ただの指標なので厳密に考える必要はありませんが、実際に用途に近いものを選べば、WebGL2がうまく最適化してくれる可能性が高まります。

データの転送が終わったら描画命令を出します。

        // 四角形を描画します。
        const VERTEX_NUMS = 6;
        gl.drawArrays(gl.TRIANGLES, 0, VERTEX_NUMS);

        // WebGLに描画を促します。
        gl.flush();

drawArraysメソッドの第一引数では、頂点情報をどのような図形で描画するかを指定するようになっています。ここで指定できる基本図形のことをプリミティブと言います。プリミティブにはPOINTS,  LINES,  LINE_LOOP,  LINE_STRIP,  TRIANGLES,  TRIANGLE_STRIP,  TRIANGLE_FANがあります。今回は三角形で描画したいので、TRIANGLESを指定します。

flushメソッドはWebGL2に描画を強制されるメソッドです。drawArraysを呼び出しただけでは描画までしてくれないことがあるので、ここで一度強制的に描画させておきましょう。

ここまでできれば、あとはブラウザで実行すれば四角形が描画されるはずです。

webgl2

WebGL2入門 最適化編へ続く

続き:WebGL2入門 最適化編

ここまでのコード全体

#version 300 es
// ↑ 一行目に必ず書きます。
// OpenGL ES 3.0(WebGL2)の機能を使うことを明示しています。

// JavaScriptから入力される値を、inで宣言します。
// 今回は「頂点座標」と「頂点色」のふたつを用います。
// 頂点座標:x,y,zの3要素のベクトル
// 頂点色:r,g,b,alphaの4要素のベクトル
in vec3 vertexPosition;
in vec4 color;

// このシェーダからフラグメントシェーダに対して出力する変数を
// outで宣言します。
out vec4 vColor;

void main() {
  // 頂点色を何も処理せずにそのままフラグメントシェーダへ出力します。
  vColor = color;

  // 頂点座標を決定するには、gl_Position変数へ書き込みます。
  // 今回は特別な処理は行わず、受け取った値をそのまま素直に出力しています。
  // 頂点座標はx,y,z,wの4つになるので、vec3からvec4へと変換しています。
  // wの値については後ほど説明しますが、ここでは1.0固定にしておいてください。
  gl_Position = vec4(vertexPosition, 1.0);
}
#version 300 es

// float(単制度浮動小数点)の精度を指定します。
// これは必須です。
// lowp, midiump, highpなどありますが、
// 特別な理由がない限りhighpでいいでしょう。
precision highp float;

// バーテックスシェーダから受け取る変数を
// inで宣言します。
in vec4 vColor;

// 画面に出力する色の変数を宣言しておく。
// r,g,b,alphaのvec4。
out vec4 fragmentColor;

void main() {
  // 特に何も処理せずそのまま色を出力する。
  fragmentColor = vColor;
}
// Canvasを作成してbodyに追加します。
const canvas = document.createElement('canvas');
canvas.width = 500;
canvas.height = 500;
document.body.appendChild(canvas);

// WebGL2のコンテキストを取得します。
const gl = canvas.getContext('webgl2');

const loadVertexShader = fetch('vertex_shader.glsl');
const loadFragmentShader = fetch('fragment_shader.glsl');

Promise.all([loadVertexShader, loadFragmentShader])
    .then((responses) => Promise.all([responses[0].text(), responses[1].text()]))
    .then((shaderSources) => {
        const vertexShaderSource = shaderSources[0];
        const fragmentShaderSource = shaderSources[1];

        // バーテックスシェーダをコンパイルします。
        const vertexShader = gl.createShader(gl.VERTEX_SHADER);
        gl.shaderSource(vertexShader, vertexShaderSource);
        gl.compileShader(vertexShader);

        // コンパイル成功したか否かをチェックします。
        const vShaderCompileStatus = gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS);
        if(!vShaderCompileStatus) {
            const info = gl.getShaderInfoLog(vertexShader);
            console.log(info);
        }

        // フラグメントシェーダについても同様にします。
        const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
        gl.shaderSource(fragmentShader, fragmentShaderSource);
        gl.compileShader(fragmentShader);

        const fShaderCompileStatus = gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS);
        if(!fShaderCompileStatus) {
            const info = gl.getShaderInfoLog(fragmentShader);
            console.log(info);
        }

        // シェーダプログラムを作成します。
        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);

        // データを転送するためのバッファを作成します。
        const vertexBuffer = gl.createBuffer();
        const colorBuffer = gl.createBuffer();

        // バーテックスシェーダのin変数の位置を取得します。
        const vertexAttribLocation = gl.getAttribLocation(program, 'vertexPosition');
        const colorAttribLocation  = gl.getAttribLocation(program, 'color');

        const VERTEX_SIZE = 3; // vec3
        const COLOR_SIZE  = 4; // vec4

        // バッファ操作前には必ずバインドします。
        gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
        // in変数を有効化します
        gl.enableVertexAttribArray(vertexAttribLocation);
        // 現在バインドしているバッファと変数を結びつけます。
        // サイズはvec3を指定してるので3にします。型はFLOATを使用します。
        // うしろ3つの引数は今は気にしないでください。
        gl.vertexAttribPointer(vertexAttribLocation, VERTEX_SIZE, gl.FLOAT, false, 0, 0);

        // 頂点色についても同様にします。
        gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
        gl.enableVertexAttribArray(colorAttribLocation);
        gl.vertexAttribPointer(colorAttribLocation, COLOR_SIZE, gl.FLOAT, false, 0, 0);


        // 頂点情報。vec3で宣言しているので、xyzxyzxyz…と並べていきます。
        // WebGL2では基本的にfloat型を使うので、Float32Arrayを使用します。
        const vertices = new Float32Array([
            -0.5, 0.5,  0.0,
            -0.5, -0.5, 0.0,
            0.5,  0.5,  0.0,
            -0.5, -0.5, 0.0,
            0.5,  -0.5, 0.0,
            0.5,  0.5,  0.0
        ]);

        // 色情報。vec4で宣言してるのでrgbargbargba…と並べていきます。
        // すべて0.0〜1.0の範囲で指定します。
        // 頂点と同じ数だけ(今回は6つ)必要です。
        const colors = new Float32Array([
            1.0, 0.0, 0.0, 1.0,
            0.0, 1.0, 0.0, 1.0,
            0.0, 0.0, 1.0, 1.0,
            0.0, 1.0, 0.0, 1.0,
            0.0, 0.0, 0.0, 1.0,
            0.0, 0.0, 1.0, 1.0
        ]);

        // バインドしてデータを転送します。
        gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

        gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW);

        // 四角形を描画します。
        const VERTEX_NUMS = 6;
        gl.drawArrays(gl.TRIANGLES, 0, VERTEX_NUMS);

        // WebGLに描画を促します。
        gl.flush();
    });