Subterranean Flower

WebGL2入門 3D描画編

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

WebGL2入門 3D知識編では3D描画に関する知識について紹介しました。今度は実際にWebGL2を用いて描画してみましょう。

WebGL2入門 記事一覧

3D描画プログラムの骨組み

プログラムの大きな流れは基礎編のときとほとんど変わりません。ある程度整理した骨組みとなるコードを以下に示しておきます。

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

const gl = canvas.getContext('webgl2');

// シェーダを読み込みPromiseを返します。
function loadShaders() {
    const loadVertexShader = fetch('vertex_shader.glsl').then((res) => res.text());
    const loadFragmentShader = fetch('fragment_shader.glsl').then((res) => res.text());
    return Promise.all([loadVertexShader, loadFragmentShader]);
}

// シェーダのソースからシェーダプログラムを作成し、
// Programを返します。
function createShaderProgram(vsSource, fsSource) {
    // バーテックスシェーダをコンパイルします。
    const vertexShader = gl.createShader(gl.VERTEX_SHADER);
    gl.shaderSource(vertexShader, vsSource);
    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, fsSource);
    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);

    return program
}

// バッファを作成し返します。
function createBuffer(type, typedDataArray) {
    const buffer = gl.createBuffer();
    gl.bindBuffer(type, buffer);
    gl.bufferData(type, typedDataArray, gl.STATIC_DRAW);
    gl.bindBuffer(type, null); // バインド解除

    return buffer;
}

// シェーダを読み込み終わったら開始します。
loadShaders().then((shaderSources) => {
    //
    // プログラムの作成
    //
    const vertexShaderSource = shaderSources[0];
    const fragmentShaderSource = shaderSources[1];

    const program = createShaderProgram(vertexShaderSource, fragmentShaderSource);

    //
    // 設定の有効化
    //

    //
    // uniform変数の設定
    //

    //
    // 描画データ
    //
    const vertices = new Float32Array([]);
    const indices = new Uint16Array([]);

    //
    // バッファの設定
    //
    const vertexBuffer = createBuffer(gl.ARRAY_BUFFER, vertices);
    const indexBuffer = createBuffer(gl.ELEMENT_ARRAY_BUFFER, indices);

    const vertexAttribLocation = gl.getAttribLocation(program, 'vertexPosition');
    const colorAttribLocation  = gl.getAttribLocation(program, 'color');

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

    const STRIDE = (3 + 4) * Float32Array.BYTES_PER_ELEMENT;
    const POSITION_OFFSET = 0;
    const COLOR_OFFSET = 3 * Float32Array.BYTES_PER_ELEMENT;

    gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);

    gl.enableVertexAttribArray(vertexAttribLocation);
    gl.enableVertexAttribArray(colorAttribLocation);

    gl.vertexAttribPointer(vertexAttribLocation, VERTEX_SIZE, gl.FLOAT, false, STRIDE, POSITION_OFFSET);
    gl.vertexAttribPointer(colorAttribLocation, COLOR_SIZE, gl.FLOAT, false, STRIDE, COLOR_OFFSET);

    //
    // 描画処理
    //
    const indexSize = indices.length;
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
    gl.drawElements(gl.TRIANGLES, indexSize, gl.UNSIGNED_SHORT, 0);
    gl.flush();
});

ここに追加していきましょう。

シェーダを書き換える

今回の変更点は、すべてバーテックスシェーダ側です。WebGL2入門 3D知識編で説明した通り、いくつかの変換行列が必要になります。基本的にはmodel, view, projectionの3つだけあればいいのですが、人によってはもっと細かく分けたり、逆にmvp行列としてひとつにまとめたりする人もいます。この辺りは好きにしていいでしょう。

さて変換行列ですが、これは頂点ごとに異なる値を使うのではなく、すべての頂点で共通の値です。そういった場合はin変数ではなく、uniform変数を使います。uniform変数は、どのシェーダからも読み出すことができる、定数のような変数です。

変換行列は4行4列の行列(Matrix)ですので、mat4型を使います。

#version 300 es

in vec3 vertexPosition;
in vec4 color;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

out vec4 vColor;

void main() {
  vColor = color;
  gl_Position = projection * view * model * vec4(vertexPosition, 1.0);
}

ここで変換行列をかける順番に注意してください。model, view, projectionの順でかけていきます。

フラグメントシェーダに関してはそのままです。

#version 300 es
precision highp float;

in vec4 vColor;
out vec4 fragmentColor;

void main() {
  fragmentColor = vColor;
}

設定を変更する

3D描画をするためには、まずいくつかの設定を有効にしておきます。

    //
    // 設定の有効化
    //
    gl.enable(gl.DEPTH_TEST);
    gl.enable(gl.CULL_FACE);

ここではデプステスト背面カリングを有効化しておきます。これらの内容については、次の項目を読んでください。

3D描画のための設定

WebGL2には、3D描画のためのいくつかの設定があります。そのうちの主要なものだけ紹介したいと思います。

ワインディングオーダー(Winding order)

基礎編では、三角形は反時計回りに頂点を配置すると言いました。しかしこれは設定で変更することができます。この設定のことをワインディングオーダーと言います。今回は設定は変更しませんが、変更できるということは覚えておきましょう。

背面カリング(Backface culling)

負担を軽減するために無駄なポリゴン(三角形)を省く作業をカリングと言います。背面カリングはそのうちの代表的なもので、後ろを向いてるポリゴンを描画しないというものです。3Dグラフィックスを扱うならば有効化しておきましょう。

デプステスト(Depth test)

デプステスト、あるいは深度テストは、その名の通り描画対象の「深さ」を調べるテストです。デフォルトでは奥行きを無視して後に描いたほうが手前にくる表示になりますが、普通は奥のものほど後ろに表示されて欲しいでしょう。デプステストを使えばこれを実現できます。

WebGL2ではあらかじめデプスバッファと呼ばれるバッファが用意されており、フラグメント(ピクセル)ごとに深度値がここに書き込まれます。デプステストが有効化されていると、書き込もうとしているフラグメントの深度と現在の深度を比較して、デプステストに合格したフラグメントのみが書き込まれます。ここでの比較関数は自由に設定することができます。

ステンシルテスト(Stencil test)

ステンシルテストでは、 型抜きマスクと呼ばれる機能が実現でききます。あらかじめステンシルバッファというマスクデータ用のバッファを用意しておき、ステンシルバッファと比較することで、ピクセルを描画する・しないを決定します。一般的に、画面の部分的な合成や、3Dモデルの縁取りなどに使われます。

シザーテスト(Scissor test)

シザーテストは描画可能領域を設定します。デフォルトではビューポートと同一の範囲になっています。画面の一部のみをクリアしたい場合などに使用するといいでしょう。

変換行列の用意

次にuniform変数用の変換行列を用意しましょう。しかし何もないところから行列データを用意するのは大変なので、既存のライブラリを使用します。ここではglMatrixというライブラリを使用します。以下のURLからダウンロードしてください。

http://glmatrix.net/

ダウンロードしたらscript要素に追加します。

<script src="gl-matrix-min.js"></script>
<script src="webgl2.js" defer></script>

できたら変換行列を作成します。変換行列の値は適当で構いません。

    const scale = mat4.create();
    mat4.scale(scale, scale, [1, 1, 1]);
    const rotation = mat4.create();
    mat4.rotateZ(rotation, rotation, Math.PI / 8);
    const translation = mat4.create();
    mat4.translate(translation, translation, [40, 0, -20]);
    const model = mat4.create();
    mat4.multiply(model, model, translation);
    mat4.multiply(model, model, rotation);
    mat4.multiply(model, model, scale);

    const cameraPosition = [0, 60, 90];
    const lookAtPosition = [0, 0, 0];
    const upDirection    = [0, 1, 0];
    const view  = mat4.create();
    mat4.lookAt(view, cameraPosition, lookAtPosition, upDirection);

    const left   = -40;
    const right  = 40;
    const top    = 40;
    const bottom = -40;
    const near   = 30;    // nearとfarは「Z座標」ではなく「距離」を表す。
    const far    = 150;  // つまり、0 < near < far を満たす値を設定する。
    const projection = mat4.create();
    mat4.frustum(projection, left, right, bottom, top, near, far);

model行列、view行列、projection行列の用意がそれぞれできました。次はこれをuniform変数に書き込んでいきましょう。

uniform変数の設定

uniform変数はすべての頂点およびフラグメントで共通の値となる変数です。主に変換行列などはuniform変数を使用します。

設定方法はin変数とそこまで変わりません。ですが、使用するメソッドが少々異なります。

    const modelLocation      = gl.getUniformLocation(program, 'model');
    const viewLocation       = gl.getUniformLocation(program, 'view');
    const projectionLocation = gl.getUniformLocation(program, 'projection');
    gl.uniformMatrix4fv(modelLocation, false, model);
    gl.uniformMatrix4fv(viewLocation, false, view);
    gl.uniformMatrix4fv(projectionLocation, false, projection);

位置を取得して書き込むのはin変数の時と変わりません。違うのは書き込むメソッドです。ここで使用しているuniformMatrix4fvはuniform変数に4行4列のfloat行列を書き込むメソッドです。他にもuniform3fvとすればvec3型の変数に書き込むメソッドになりますし、int型へ書き込むときはfのかわりにiとします。つまり、uniform + (Matrix) + {1, 2, 3, 4} + {f, i} + vのような命名規則になっています。少々面倒ですが、目的に応じたメソッドを使用してください。

描画データの用意

今回も単純な四角形を用意しましょう。今回は背面カリングを有効化しているので、ワインディングオーダーが反時計回りなことには、今まで以上に気をつけましょう。

    //
    // 描画データ
    //
    const vertices = new Float32Array([
        -30.0, 30.0, 0.0,  // 座標
        0.0, 1.0, 0.0, 1.0,      // 色
        -30.0, -30.0, 0.0,
        1.0, 0.0, 0.0, 1.0,
        30.0, 30.0, 0.0,
        1.0, 0.0, 0.0, 1.0,
        30.0, -30.0, 0.0,
        0.0, 0.0, 1.0, 1.0
    ]);
    const indices = new Uint16Array([0, 1, 2, 1, 3, 2]);

実行結果

さて、ここまで準備できたら後は実行するだけです。実際に実行してみましょう。

WebGL2実行結果

少し立体的になった四角形が表示されたはずです。変換行列の値を様々に変えてみて、どうなるか試してみましょう。

ここまでのコード

#version 300 es

in vec3 vertexPosition;
in vec4 color;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

out vec4 vColor;

void main() {
  vColor = color;
  gl_Position = projection * view * model * vec4(vertexPosition, 1.0);
}
#version 300 es
precision highp float;

in vec4 vColor;
out vec4 fragmentColor;

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

const gl = canvas.getContext('webgl2');

// シェーダを読み込みPromiseを返します。
function loadShaders() {
    const loadVertexShader = fetch('vertex_shader.glsl').then((res) => res.text());
    const loadFragmentShader = fetch('fragment_shader.glsl').then((res) => res.text());
    return Promise.all([loadVertexShader, loadFragmentShader]);
}

// シェーダのソースからシェーダプログラムを作成し、
// Programを返します。
function createShaderProgram(vsSource, fsSource) {
    // バーテックスシェーダをコンパイルします。
    const vertexShader = gl.createShader(gl.VERTEX_SHADER);
    gl.shaderSource(vertexShader, vsSource);
    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, fsSource);
    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);

    return program
}

// バッファを作成し返します。
function createBuffer(type, typedDataArray) {
    const buffer = gl.createBuffer();
    gl.bindBuffer(type, buffer);
    gl.bufferData(type, typedDataArray, gl.STATIC_DRAW);
    gl.bindBuffer(type, null); // バインド解除

    return buffer;
}

// シェーダを読み込み終わったら開始します。
loadShaders().then((shaderSources) => {
    //
    // プログラムの作成
    //
    const vertexShaderSource = shaderSources[0];
    const fragmentShaderSource = shaderSources[1];

    const program = createShaderProgram(vertexShaderSource, fragmentShaderSource);

    //
    // 設定の有効化
    //
    gl.enable(gl.DEPTH_TEST);
    gl.enable(gl.CULL_FACE);

    //
    // uniform変数の設定
    //
    const scale = mat4.create();
    mat4.scale(scale, scale, [1, 1, 1]);
    const rotation = mat4.create();
    mat4.rotateZ(rotation, rotation, Math.PI / 8);
    const translation = mat4.create();
    mat4.translate(translation, translation, [40, 0, -20]);
    const model = mat4.create();
    mat4.multiply(model, model, translation);
    mat4.multiply(model, model, rotation);
    mat4.multiply(model, model, scale);

    const cameraPosition = [0, 60, 90];
    const lookAtPosition = [0, 0, 0];
    const upDirection    = [0, 1, 0];
    const view  = mat4.create();
    mat4.lookAt(view, cameraPosition, lookAtPosition, upDirection);

    const left   = -40;
    const right  = 40;
    const top    = 40;
    const bottom = -40;
    const near   = 30;    // nearとfarは「Z座標」ではなく「距離」を表す。
    const far    = 150;  // つまり、0 < near < far を満たす値を設定する。
    const projection = mat4.create();
    mat4.frustum(projection, left, right, bottom, top, near, far);

    const modelLocation      = gl.getUniformLocation(program, 'model');
    const viewLocation       = gl.getUniformLocation(program, 'view');
    const projectionLocation = gl.getUniformLocation(program, 'projection');
    gl.uniformMatrix4fv(modelLocation, false, model);
    gl.uniformMatrix4fv(viewLocation, false, view);
    gl.uniformMatrix4fv(projectionLocation, false, projection);

    //
    // 描画データ
    //
    const vertices = new Float32Array([
        -30.0, 30.0, 0.0,  // 座標
        0.0, 1.0, 0.0, 1.0,      // 色
        -30.0, -30.0, 0.0,
        1.0, 0.0, 0.0, 1.0,
        30.0, 30.0, 0.0,
        1.0, 0.0, 0.0, 1.0,
        30.0, -30.0, 0.0,
        0.0, 0.0, 1.0, 1.0
    ]);
    const indices = new Uint16Array([0, 1, 2, 1, 3, 2]);

    //
    // バッファの設定
    //
    const vertexBuffer = createBuffer(gl.ARRAY_BUFFER, vertices);
    const indexBuffer = createBuffer(gl.ELEMENT_ARRAY_BUFFER, indices);

    const vertexAttribLocation = gl.getAttribLocation(program, 'vertexPosition');
    const colorAttribLocation  = gl.getAttribLocation(program, 'color');

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

    const STRIDE = (3 + 4) * Float32Array.BYTES_PER_ELEMENT;
    const POSITION_OFFSET = 0;
    const COLOR_OFFSET = 3 * Float32Array.BYTES_PER_ELEMENT;

    gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);

    gl.enableVertexAttribArray(vertexAttribLocation);
    gl.enableVertexAttribArray(colorAttribLocation);

    gl.vertexAttribPointer(vertexAttribLocation, VERTEX_SIZE, gl.FLOAT, false, STRIDE, POSITION_OFFSET);
    gl.vertexAttribPointer(colorAttribLocation, COLOR_SIZE, gl.FLOAT, false, STRIDE, COLOR_OFFSET);

    //
    // 描画処理
    //
    const indexSize = indices.length;
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
    gl.drawElements(gl.TRIANGLES, indexSize, gl.UNSIGNED_SHORT, 0);
    gl.flush();
});

Uniform Buffer Objectを使ってuniform変数を設定する

uniform変数にはもうひとつ設定方法があります。それはUniform Buffer Object(UBO)を使う方法です。uniform変数は通常ひとつのシェーダープログラムに結び付けられていますが、UBOを使えば複数のシェーダープログラムに結びつけることが可能になります。この利点は今の時点では全く無意味ですが、今の段階で使い方だけ学んでおきましょう。

変数をuniformブロックで宣言する

まずはシェーダーを書き換えます。個別にuniform変数として宣言していたものをブロックとして宣言し直します。

#version 300 es

in vec3 vertexPosition;
in vec4 color;

layout(std140) uniform Matrices {
    mat4 model;
    mat4 view;
    mat4 projection;
};

out vec4 vColor;

void main() {
  vColor = color;
  gl_Position = projection * view * model * vec4(vertexPosition, 1.0);
}

uniformブロックには名前をつける必要があります。ここではMatricesとしています。またメモリレイアウトを指定をlayoutでしておくと便利です。レイアウトに指定できるのはstd140のみです。レイアウトの指定を省略すると、メモリ上の配置がどうなるか環境依存になってしまい、何が起こるかわかりません。

uniformブロックに書き込む

uniform変数への書き込み部分を全く書き換えてしまいます。

    const modelArray = Array.from(model);
    const viewArray = Array.from(view);
    const projArray = Array.from(projection);
    const mvp = new Float32Array(modelArray.concat(viewArray).concat(projArray));

    // ブロックのインデックスを取得します。
    const uniformBlockIndex = gl.getUniformBlockIndex(program, 'Matrices');
    
    // バインディングポイントを好きに指定します。
    const bindingPoint = 0;
    
    // uniformブロックをバインディングポイントにバインドします。
    gl.uniformBlockBinding(program, uniformBlockIndex, bindingPoint);
    
    // バッファを作り、書き込みます。
    const uniformBuffer = createBuffer(gl.UNIFORM_BUFFER, mvp);

    // バッファをバインディングポイントにバインドします。
    gl.bindBufferBase(gl.UNIFORM_BUFFER, bindingPoint, uniformBuffer);

手順はコメントの通りです。uniformブロックへ書き込むときは、個別の変数にではなく、一度にまとめて書き込みます。

実行する

それでは実行してみてください。同じように四角形が表示されるはずです。

WebGL2入門 アニメーション編へ続く

続き:WebGL2入門 アニメーション編