Subterranean Flower

WebGL2で2Dグラフィックスを扱う

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

WebGL2は基本的には3DグラフィックスのためのAPIですが、2Dグラフィックスにも応用することができます。WebGL2を使って2Dグラフィックスを描画する方法を学んでみましょう。

WebGL2の基礎について

WebGL2は複雑なAPIを持っています。全てを説明していると非常に長くなります。WebGL2のAPIの使い方に関しては、本ブログの「WebGL2入門」の記事を読んでください。

2Dグラフィックスを描画する方法

WebGLは3DグラフィックスのためのAPIです。2Dグラフィックスを描画するには、ほんの少しだけ遠回りが必要になります。

2Dグラフィックスの描画は、三角形ふたつで四角形を作り、そこにテクスチャを貼ることで実現できます。

これだけです。以上!終わり!

描画の最適化

たったこれだけで終わるのも悪い気がするので、もうちょっと話を続けましょうか。

WebGL2はそのままでも高速に描画できる仕組みですが、いくつかの最適化手法を使うとこで、より効率的に実行できるようになります。

テクスチャアトラス(スプライトシート)

一般に、WebGLを扱うときは、テクスチャ1枚だけを貼って終わり、ということはありません。複数の絵柄を、様々な位置に描画したいはずです。しかしそれぞれの絵柄を別のテクスチャにしていると、描画コストもかさみますし、なによりWebGL2で扱えるテクスチャの上限は最低で32枚なので、すぐに上限に達してしまいます。

よって複数の絵柄を一枚のテクスチャに納めるテクスチャアトラス(またはスプライトシートとも言う)という手法がよく使われます。テクスチャアトラスを用いることで、描画コストを抑えることができ、テクスチャ枚数の節約にもなります。

テクスチャアトラスの例

描画の際には、テクスチャアトラスの一部だけを切り出して使うことになります。

TRIANGLE_STRIP

テクスチャを貼り付けるための四角形の描画方法も様々です。WebGL2にはQUAD(四角形)はありませんが、TRIANGLES(三角形)はありますし、TRIANGLESで三角形を2つくっつければ四角形が完成します。

もちろんTRIANGLESでも十分な速度は出ます。しかし一般的にはTRIANGLE_STRIPの方が描画が速いと言われています。TRIANGLE_STRIPは、1つの頂点と、前の描画に利用した2つの頂点の、3頂点を利用して三角形を描画するプリミティブです。

縮退三角形(縮退ポリゴン)

しかしTRIANGLE_STRIPには問題があります。前の2頂点を使うために、離れたところの三角形を描画できません。素直に実装すると、全部の四角形が繋がって描画されてしまいます。

そこで縮退三角形(縮退ポリゴン)というものを使うことで、四角形を切り離して描画することができます。縮退三角形は面積ゼロの三角形のことで、これは面積がないので画面に描画されません。つまり見えない糸のようなものです。

この縮退三角形で四角形を同士をつないでいくことで、TRIANGLE_STRIPの弱点である、「連続している三角形しか描画できない」を克服することができます。

例えば2つの四角形、「0-1-2-3」と「4-5-6-7」があった場合、インデックスバッファを「0-1-2-3-3-4-4-5-6-7」にすれば、「3-3-4」の部分で縮退三角形ができ、2つの四角形をつなぐことができます。

実際に組んでみる

ここまでの知識を活かして、実際に2Dグラフィックスを描画するプログラムを組んでみましょう!

デモ

試しに、以下のような簡単なグラフィックを描画するプログラムを組んでみましょう。

ここをクリックでデモを開きます

バーテックスシェーダ

まずはバーテックスシェーダから手をつけてみます。バーテックスシェーダでやることは、頂点座標とテクスチャ座標を受け取り、適切に処理するだけです。しかしこれだけでは少し面白みがないので、もうちょっと工夫しましょう。

WebGLは、そのままでは-1.0から1.0までの値を取る座標になります。

これはなかなか扱いにくいです。2Dグラフィックスで一般に使われる、0から始まり、横幅・縦幅の整数で終わるような座標で記述できるようにしてみましょう。これは平行移動と拡大縮小で実現できます。

#version 300 es

in vec2 vertexPosition;
in vec2 textureCoord;

uniform mat4 scaling;
uniform mat4 translation;

out vec2 vTextureCoord;

void main() {
  vTextureCoord = textureCoord;

  gl_Position = translation * scaling * vec4(vertexPosition, 0.0, 1.0);
}

平行移動と拡大縮小の行列は、プログラム側から転送することにします。

フラグメントシェーダ

フラグメントシェーダ側でやることは、特にありません。愚直にテクスチャを出力しましょう。

#version 300 es

precision highp float;

uniform sampler2D tex;

in vec2 vTextureCoord;
out vec4 fragmentColor;

void main() {
  fragmentColor = texture(tex, vTextureCoord);
}

テクスチャの用意

描画を行うには、テクスチャが必要です。これはあらかじめこちらで用意しておきました。

クリックで画像ファイルを開く

クリックして画像ファイルを開き、保存してください。ファイル名は「atlas.png」です。

プログラム

プログラム側では特に難しいことは必要ありません。必要な情報を用意し、転送するだけです。

先述のとおり、四角形を描画し、そこにテクスチャを貼り付けます。TRIANGLE_STRIPで描画し、四角形同士は縮退三角形で繋ぎます。

詳しいことはコメントに書いてあります。よくわからない場合は「WebGL2入門」を読みましょう。

'use strict';

(async function main() {
    // Canvasの準備。
    const CANVAS_WIDTH = 500;
    const CANVAS_HEIGHT = 500;
    const canvas = document.createElement('canvas');
    canvas.width = CANVAS_WIDTH;
    canvas.height = CANVAS_HEIGHT;
    document.body.appendChild(canvas);
    
    const gl = canvas.getContext('webgl2');

    //
    // リソースの読み込み。
    //

    // シェーダソースの読み込み。
    const vertexShaderSource = await fetch('vertex_shader.glsl').then((response) => response.text());
    const fragmentShaderSource = await fetch('fragment_shader.glsl').then((response) => response.text());

    // テクスチャの読み込み。
    const textureData = await fetch('atlas.png')
        .then((response) => response.blob())
        .then((blob) => createImageBitmap(blob));

    const textureWidth = textureData.width;
    const textureHeight = textureData.height;

    //
    // シェーダプログラムの用意。
    //

    // シェーダのコンパイル。
    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);

    //
    // バッファの作成。
    //

    // 頂点バッファ:[座標(vec2)][テクスチャ座標(vec2)]
    const vertexBuffer = gl.createBuffer();

    // インデックスバッファ
    const indexBuffer = gl.createBuffer();

    // 頂点バッファ。頂点ごとの情報を保存する。
    // 今回は2次元座標しか扱わないので座標のsizeは2。
    // strideは頂点情報のサイズなのでvec2+vec2で4。
    // offsetはstride内での位置なので各々計算する。
    // strideもoffsetもバイト数で指定する。
    const STRIDE               = Float32Array.BYTES_PER_ELEMENT * 4;
    const TEXTURE_OFFSET       = Float32Array.BYTES_PER_ELEMENT * 2;

    const POSITION_SIZE      = 2;
    const TEXTURE_SIZE       = 2;

    // 操作対象のバッファをbindしてから作業をする。
    gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);

    // 各頂点情報の位置。
    const vtxAttrLocation      = gl.getAttribLocation(program, 'vertexPosition');
    const textureCoordLocation = gl.getAttribLocation(program, 'textureCoord');

    // 各頂点情報の有効化。
    gl.enableVertexAttribArray(vtxAttrLocation);
    gl.enableVertexAttribArray(textureCoordLocation);

    // 各頂点情報の情報指定。
    gl.vertexAttribPointer(vtxAttrLocation, POSITION_SIZE, gl.FLOAT, false, STRIDE, 0);
    gl.vertexAttribPointer(textureCoordLocation, TEXTURE_SIZE, gl.FLOAT, false, STRIDE, TEXTURE_OFFSET);

    //
    // シェーダのuniform変数の設定。
    //

    // デフォルトの状態だとxy座標ともに[-1.0, 1.0]が表示されるので、
    // 2Dグラフィックスで一般的な[0.0, width or height]座標に変換する。
    const xScale = 2.0/CANVAS_WIDTH;
    const yScale = -2.0/CANVAS_HEIGHT; // 上下逆

    const scalingMatrix = new Float32Array([
        xScale, 0.0   , 0.0, 0.0,
        0.0   , yScale, 0.0, 0.0,
        0.0   , 0.0   , 1.0, 0.0,
        0.0   , 0.0   , 0.0, 1.0
    ]);

    // (0, 0)が中心になってしまうので左上に持ってきてやる。
    const translationMatrix = new Float32Array([
        1.0, 0.0 , 0.0, 0.0,
        0.0, 1.0 , 0.0, 0.0,
        0.0, 0.0 , 1.0, 0.0,
        -1.0, 1.0 , 0.0, 1.0
    ]);

    const scalingLocation = gl.getUniformLocation(program, 'scaling');
    const translationLocation = gl.getUniformLocation(program, 'translation');
    gl.uniformMatrix4fv(translationLocation, false, translationMatrix);
    gl.uniformMatrix4fv(scalingLocation, false, scalingMatrix);

    //
    // テクスチャの転送。
    //

    const texture = gl.createTexture();     // テクスチャの作成
    gl.bindTexture(gl.TEXTURE_2D, texture); // テクスチャのバインド
    gl.texImage2D(gl.TEXTURE_2D, 0,
                  gl.RGBA, gl.RGBA,
                  gl.UNSIGNED_BYTE, textureData); // テクスチャデータの転送
    gl.generateMipmap(gl.TEXTURE_2D); // ミップマップの作成

    // 使用するテクスチャの指定。
    const textureLocation = gl.getUniformLocation(program, 'tex');
    gl.uniform1i(textureLocation, 0);

    //
    // アルファブレンドを有効にする。
    //
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
    gl.enable(gl.BLEND);

    //
    // 描画に用いるデータ。
    //

    // スプライトファイル上のどの領域か。
    const mikoSpriteRect = { x:32, y:0, width:16, height:16 };
    const fairySpriteRect = { x:48, y:0, width:16, height:16 };

    // 各キャラクターの情報。
    const miko = { x:250, y:300, sprite:mikoSpriteRect };
    const fairy = { x:250, y:100, sprite:fairySpriteRect };

    const actors = [miko, fairy];

    //
    // 描画をループさせてアニメーションを作る。
    //

    let mikoVelocity = 2;

    // ループ。
    function loop(timestamp) {
        // 巫女の移動。
        // 端に到達したら反転。
        miko.x += mikoVelocity;
        if(miko.x < 0 ) {
            miko.x = 0;
            mikoVelocity = -mikoVelocity;
        } else if(miko.x > CANVAS_WIDTH) {
            miko.x = CANVAS_WIDTH;
            mikoVelocity = -mikoVelocity;
        }

        //
        // 描画。
        //

        // 画面をクリア。
        gl.clearColor(0.0, 0.0, 0.0, 1.0);
        gl.clear(gl.COLOR_BUFFER_BIT);

        // 描画に使う頂点情報。
        const vertices = [];
        const indices = [];

        for(let i = 0; i < actors.length; i++) {
            const a = actors[i];
            const s = a.sprite;

            // 4頂点で四角形を描く。
            // テクスチャ座標は0.0〜1.0に正規化しておく。
            vertices.push(
                a.x, a.y,                                         // 座標
                s.x/textureWidth, s.y/textureHeight,              // テクスチャ座標
                a.x, a.y + s.height,                              // 座標
                s.x/textureWidth, (s.y + s.height)/textureHeight, // テクスチャ座標
                a.x + s.width, a.y,
                (s.x + s.width)/textureWidth, s.y/textureHeight,
                a.x + s.width, a.y + s.height,
                (s.x + s.width)/textureWidth, (s.y + s.height)/textureHeight
            );

            // 4頂点分のインデックス。
            // 縮退三角形で繋ぐ。
            const offset = i * 4;
            if(i > 0) { indices.push(offset); }
            indices.push(offset, offset+1, offset+2, offset+3);
            if(i < actors.length - 1) { indices.push(offset+3); }
        }

        // 描画する。
        gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW);

        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
        gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.DYNAMIC_DRAW);

        gl.drawElements(gl.TRIANGLE_STRIP, indices.length, gl.UNSIGNED_SHORT, 0);

        gl.flush();

        // 次のフレームを要求。
        window.requestAnimationFrame((ts) => loop(ts));
    }

    window.requestAnimationFrame((ts) => loop(ts));
})();

HTMLファイル

HTML側の書き方がわからない人はいないとは思いますが、一応HTMLファイルも置いておきます。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>WebGL2 2D</title>
    <script src="main.js" defer></script>
</head>
<body>

</body>
</html>

これで完成です!実際に動かして確かめてみましょう。