【WebGL2】GPU Instancing x Transform Feedback で大量のインスタンスの計算と描画をGPUで行う

~ このエントリは 【カヤック】面白法人グループ Advent Calendar 2023 の22日目の記事です。~

こんにちは!ハイパーカジュアルゲームチームの深澤です。

WebGL2において GPU Instancing でメッシュを大量に表示しつつ、Transform Feedback を使ってインスタンスごとの情報計算もGPUに任せてみたいと思います。

↓ デモはこちらになります。画像かURLから飛ぶことができます

デモ: https://takumifukasawa.github.io/webgl-transform-feedback-gpu-instancing/

↓ リポジトリのURL

github.com

メッシュ1つあたりの頂点数は24です。描画色は、インスタンスごとの色をふまえて平行光源の拡散光だけ計算しています。 GPU Instancing を使っていて、ドローコールは1回です。

端末負荷的には、

  • MacBookPro 2020製 M1 Memory16GB Chrome では100,000インスタンス以上は60FPSを保ちながら描画できていそう

  • 私物の iPhoneX safari でも近いぐらいは出ていそう

  • ハイカジ開発でよく使う私物の Android S4-KC では 15,000 インスタンスぐらいまでは30FPSは出ていそうだが、それ以上は徐々にFPSが下がった

という具合になっています。


約3,000インスタンス

約30,000インスタンス

約100,000インスタンス


実装方針を考える

GPU Instancing

メッシュのデータは Vertex Array Object(以下、VAO)に格納することにします。
GPU Instancing は 1つの VAO をもとに 1回のドローコールで複数のインスタンスを表示することができます。
Cubeの形状が1つ入ったVAOなのにCubeを複数個描画できる、というイメージですね。
具体的には、VAOにインスタンスごとの情報(位置や速度、色など)が入った頂点バッファも持たせ、メッシュのシェーダー側でインスタンスごとの情報を受け取り処理していくという流れになります。

問題は「インスタンスごとの情報の更新」をどこでどう処理するか、です。
位置や速度などは毎フレーム計算を行いたいので、インスタンス数によっては1フレームあたりの計算量が相当な量になることが想定されます。

Javascript で Web Worker などを駆使しつつ計算すること自体はもちろんできますが、数万以上のインスタンスの情報の更新を Javascript 側で毎フレーム行うのはループ数的にあまり現実的ではありません。特にモバイル端末ではなお厳しいでしょう。

Transform Feedback

そこで GPU の出番です。WebGL2では Compute Shader が使えないので、かわりに Transform Feedback を使って近うアプローチをとります。Transform Feedback という機能は WebGL2 から標準化されました。

Transform Feedback の利点は頂点シェーダーの結果を頂点バッファに直接書き込むことができる点です。かつ、ラスタライズ処理をスキップできるので、テクスチャに情報を書き込み、シェーダーでテクスチャをフェッチして情報を抜き出して...というような方法が必要なくなるわけですね。
以下の図のイメージです。

また、頂点バッファに書いてしまえば Javascript 側から getBufferSubData 関数で読み込むことができるのも便利な点です。
(ex. 重い計算をシェーダーで行い Javascript で頂点バッファからデータを取り出すという、計算機のような使い方ができる)

つまり、Transform Feedback を使って、描画は行わず、大量の計算を GPU に任せてしまうというGPGPU的なアプローチが可能になります。

今回は、Transform Feedback を使ってインスタンスごとの情報を持った頂点バッファを毎フレーム更新し、メッシュのVAOにバインドしなおす、ということをします。
また、徐々に速度を上げたり下げたりするなど、前フレームの情報を参照して速度を調整することで連続的な速度の変化をつけていきます。

GPU Instancing 対応を考える

データ構造

Transform Feedback 対応の前に、まず単純な三角形を GPU Instancing でインスタンスごとに別の位置で描画する際のVAOのデータ構造を整理してみます。
仮に、VAOを作る処理を以下とします。

    ...

    const vao = gl.createVertexArray();
    gl.bindVertexArray(vao);

    attributes.forEach(attribute => {
        const {data, size, location, divisor, usage} = attribute;
        const vbo = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
        gl.bufferData(gl.ARRAY_BUFFER, data, usage);
        gl.enableVertexAttribArray(location);

        gl.vertexAttribPointer(location, size, gl.FLOAT, false, 0, 0); // 今回は頂点データはfloat32限定

        if (divisor) {
            gl.vertexAttribDivisor(location, divisor);
        }
    });

    gl.bindBuffer(gl.ARRAY_BUFFER, null);

    gl.bindVertexArray(null);

    ...

三角形の各頂点をp0,p1,p2とします。今はまだUVや法線は考えず、頂点座標だけに絞ります。インターリーブ、インデックスも考えません。

インスタンスごとの座標は instance0_position, instance1_position ... と続いていくものとします。

// 頂点属性群
attributes = [
    // 三角形の頂点の位置
    {
        data: new Float32Array([
            p0.x, p0.y, p0.z,
            p1.x, p1.y, p1.z,
            p2.x, p2.y, p2.z
        ]),
        size: 3,
        location: 0,
        usage: gl.STATIC_DRAW,
    },
    // インスタンスごとの位置をまとめて格納
    {
        data: new Float32Array([
            instance0_position.x, instance0_position.y, instance0_position.z,
            instance1_position.x, instance1_position.y, instance1_position.z,
            instance2_position.x, instance2_position.y, instance2_position.z,
            ...
        ]),
        size: 3,
        location: 1,
        usage: gl.STATIC_DRAW
        divisor: 1,
    }
];

一つのVAOの中に、三角形の頂点の座標が入った頂点バッファ、インスタンスごとのデータがまとまって入った頂点バッファが同居する形になっていますね。

VAOに頂点バッファをバインドする際に gl.vertexAttribDivisor(location, divisor); を呼ぶことで頂点バッファをインスタンスごとの情報に分割することができます(詳しくは後述)。

GPU Instancing を使って描画するメソッドは

gl.drawArraysInstanced(glPrimitiveType, startOffset, drawCount, instanceCount);

の形になります。さきほどのVertexArrayObjectで100個のインスタンスを表示する場合、

gl.drawArraysInstanced(gl.TRIANGLES, 0, 3, 100);

となりますね。
頂点数は3つなのでdrawCountは3、インスタンス数は100なのでinstanceCountの部分が100になる、というわけです。

シェーダー側

シェーダー側では GPU Instancing への特別な対応は必要ありません。なぜなら gl.vertexAttribDivisor 関数によって「頂点シェーダーにデータが渡される時点で、インスタンシング用の情報が入った頂点バッファがインスタンスごとに分割済みの状態」が作られているからです。詳しく見ていきます。

たとえば以下のようにローカル座標をオフセットしてインスタンスごとの位置を決定する頂点シェーダーを書くことができます。

#version 300 es

layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aInstancePosition;

void main() {
    gl_Position = projectionMatrix * viewMatrix * worldMatrix * vec4(aPosition + aInstancePosition, 1.);

    ...

以下は先程のインスタンスごとのデータの再掲です。location = 1, size = 3, divisor = 1 です。インターリーブは考慮しません。

まず gl.vertexAttribPointer(location, size, gl.FLOAT, false, 0, 0); を呼び、location = 1 の頂点属性を vec3 として認識させます。

ここで gl.vertexAttribDivisor(location, divisor); を呼ぶと divisor = 1 によって size * divisor = 3 ずつ頂点バッファが分割されるので、
上記頂点シェーダーで処理される頂点0番目では aInstancePositioninstance0_position.x, instance0_position.y, instance0_position.z の3つが vec3 の各要素として渡されるようになります。

このように gl.vertexAttribDivisor によって頂点バッファを分割する機能を使い、「頂点シェーダーにデータが渡される時点で、インスタンシング用の情報が入った頂点バッファがインスタンスごとに分割済みの状態」を作ることができるので、シェーダー側で GPU Instancing への特別な対応は必要がないというわけですね 。

    {
        data: new Float32Array([
            instance0_position.x, instance0_position.y, instance0_position.z,
            instance1_position.x, instance1_position.y, instance1_position.z,
            instance2_position.x, instance2_position.y, instance2_position.z,
            ...
        ]),
        size: 3,
        location: 1,
        usage: gl.STATIC_DRAW
        divisor: 1,
    },

これで GPU Instancing を使用する際のデータ構造 / シェーダー側での処理は整理されてきました。

あとはVAOにバインドされている「インスタンスごとの頂点データ」をなんらかの方法で更新していくことができればインスタンスの位置を動的に変えていくことができそうです。

いよいよ Transform Feedback の出番です。

Transform Feedback でインスタンスごとの情報を更新

前述のように、徐々に速度を上げ下げするなど、前フレームの情報を参照して速度を調整することで連続的な速度の変化を実現できます。

ここで、前フレームの情報を利用するにあたって考慮の必要な問題が一つ出てきます。それは「1つの頂点バッファを同時に書き込み先/参照元に使うことはできない」というWebGLの仕様の存在です。
具体的には Transform Feedback で更新する際の、参照用にVAOにバインドする頂点バッファと、更新する頂点バッファにおいて、同時に同じものを使うことができません。つまり、1つの頂点バッファだけでフレームをまたぎながらのインスタンシング用の情報の更新はできないということになります。

それを回避する方法が Double Buffer 的な考え方です。

Double Buffer

いわゆる Double Buffer はテクスチャ/レンダーターゲットを2枚使うものがメジャーかなと思います。片方のテクスチャを元に情報を読み込んでからもう片方のテクスチャに情報を書き込み、次のフレームではその役割を入れ替えることで逐次的に情報を更新していくものです。
テクスチャ/レンダーターゲットも、書き込み先/参照元に同時に同じものを使うことができないが故の工夫ですね。

これと同じように、Transform Feedback と頂点バッファを2つずつ用意して、フレームごとに読み書きを入れ替えながら頂点バッファに位置と速度を更新していくという方法をとります。

そして Transform Feedback で更新した頂点バッファをメッシュのVAOにバインドし直すことで、メッシュのインスタンシング用の情報が更新されていく、というわけですね。
下図のイメージです。

実装

これで準備が整いました。いよいよ具体的な実装方法を見ていきます。
以下、サンプル実装から抜粋してきたコードになります。

VAO関連

VAOをラップした関数を用意し、頂点バッファを取得する関数などを追加しています。

また、サンプルでは Index Buffer Object を使用しているのでその実装も入っています。

function createVertexArrayObjectWrapper(gl, attributes, indicesData) {
    const vao = gl.createVertexArray();
    let ibo;
    let indices = null;

    const vertices = []; // { name, vbo, usage, location, size, divisor } の配列 

    gl.bindVertexArray(vao);

    const getBuffers = () => {
        return vertices.map(({vbo}) => vbo);
    }

    const findBuffer = (name) => {
        return vertices.find(elem => elem.name === name).vbo;
    }

    attributes.forEach(attribute => {
        const {name, data, size, location, divisor, usage} = attribute;
        const vbo = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
        gl.bufferData(gl.ARRAY_BUFFER, data, usage);
        gl.enableVertexAttribArray(location);

        gl.vertexAttribPointer(location, size, gl.FLOAT, false, 0, 0); // 今回は頂点データはfloat32限定

        if (divisor) {
            gl.vertexAttribDivisor(location, divisor);
        }

        vertices.push({
            name,
            vbo,
            usage,
            location,
            size,
            divisor
        });
    });

    if (indicesData) {
        ibo = gl.createBuffer();
        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
        gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indicesData), gl.STATIC_DRAW);
        indices = indicesData;
    }

    gl.bindBuffer(gl.ARRAY_BUFFER, null); // unbind array buffer

    gl.bindVertexArray(null); // unbind vertex array to webgl context

    if (ibo) {
        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null); // unbind index buffer
    }

    return {
        vao,
        indices,
        vertices,
        getBuffers,
        findBuffer
    }
}

function setBufferToVAO(vaoWrapper, name, newBuffer) {
    const target = vaoWrapper.vertices.find(elem => elem.name === name);
    target.buffer = newBuffer;
    gl.bindVertexArray(vaoWrapper.vao);
    gl.bindBuffer(gl.ARRAY_BUFFER, newBuffer);
    gl.enableVertexAttribArray(target.location);
    gl.vertexAttribPointer(target.location, target.size, gl.FLOAT, false, 0, 0);
    gl.bindVertexArray(null);
}

上記の setBufferToVAO が「Tranform Feedback で計算した頂点バッファをメッシュのVAOにバインドし直す」処理になります。

VAOをバインド → 頂点バッファをバインドする という順番になりますが、divisorの指定を再度する必要がないという点以外は、VAOを生成するときにバッファを生成してバインドする際の処理とほぼ同じに流れになっていますね。

※もしかすると、バインドしなおすのではなく頂点バッファにbufferSubDataなどを使ってデータをコピーする方が早い可能性もありそうですが、今回は試していません。

Transform Feedback 関連

function createTransformFeedback(gl, buffers) {
    const transformFeedback = gl.createTransformFeedback();
    gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, transformFeedback);
    for (let i = 0; i < buffers.length; i++) {
        const buffer = buffers[i];
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
        gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, i, buffer);
        gl.bindBuffer(gl.ARRAY_BUFFER, null);
    }
    gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null);

    return transformFeedback;
}

function createTransformFeedbackDoubleBuffer(gl, vertexShader, fragmentShader, attributes, varyings, count) {
    let shader;
    const buffers = [];
    let drawCount;

    // 前フレームで更新されたVAO
    const getReadVAOWrapper = () =>  buffers[0].vertexArrayObjectWrapper; 

    // 現在フレームで更新するVAOと、現在フレームで使用する Transform Feedback
    const getWriteTargets = () => {
        return {
            vertexArrayObjectWrapper: buffers[1].vertexArrayObjectWrapper,
            transformFeedback: buffers[1].transformFeedback,
        }
    }

    const swap = () => {
        buffers.reverse();
    }

    shader = createShader(gl, vertexShader, fragmentShader, varyings);
    drawCount = count;

    attributes.forEach((attribute, i) => {
        attribute.location = i;
        attribute.divisor = 0;
    });

    const attributes1 = attributes;
    const attributes2 = attributes.map(attribute => ({...attribute}));

    const vertexArrayObjectWrapper1 = createVertexArrayObjectWrapper(
        gl,
        attributes1,
    );
    const vertexArrayObjectWrapper2 = createVertexArrayObjectWrapper(
        gl,
        attributes2,
    );

    const transformFeedback1 = createTransformFeedback(
        gl,
        vertexArrayObjectWrapper1.getBuffers()
    );
    const transformFeedback2 = createTransformFeedback(
        gl,
        vertexArrayObjectWrapper2.getBuffers()
    );

    buffers.push({
        vertexArrayObjectWrapper: vertexArrayObjectWrapper1,
        transformFeedback: transformFeedback1,
    })
    buffers.push({
        vertexArrayObjectWrapper: vertexArrayObjectWrapper2,
        transformFeedback: transformFeedback2,
    });

    return {
        getReadVAOWrapper,
        getWriteTargets,
        swap,
        shader,
        drawCount
    }
}

...

// 呼び出し例
const transformFeedbackDoubleBuffer = createTransformFeedbackDoubleBuffer(
    gl,
    vertexShader,
    fragmentShader,
    varyingNames,
    count
);

Transform Feedback 用のシェーダー

位置や速度を更新していく Transform Feedback の頂点シェーダーです。
※ もしかするとデモのURL先では数値調整など異なった内容になっているかもしれませんが、大枠は同じのはずです。

マウス/タップの位置に向かってなんとなく近づくようにしています。インスタンスごとに速度に変化を持たせたいので gl_VertexID で変化をつけます。
Transform Feedback における頂点ごとのデータなので gl_InstanceID ではなく gl_VertexID を使います。

gl_VertexID を使って乱数を生成し、その乱数を用いて速度だったり動く幅だったりを調整している、という感じになります。

#version 300 es

precision mediump float;

layout (location = 0) in vec3 aPosition;
layout (location = 1) in vec3 aVelocity;

out vec3 vPosition;
out vec3 vVelocity;

uniform vec3 uChaseTargetPosition;
uniform float uTime;
uniform float uDeltaTime;
uniform float uBaseSpeed;
uniform float uBaseAttractRate;

// ref: https://thebookofshaders.com/10/
float rand(vec2 co){
    return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453); // 0 ~ 1
}

void main() {
    float fid = float(gl_VertexID);

    float hashA = rand(vec2(fid * .2, fid * .3));
    float hashB = rand(vec2(fid * .3, fid * .4));
    float hashC = rand(vec2(fid * .4, fid * .5));

    vec3 targetPositionOffset = vec3(  // 追従させたい方向を少しずらす
        cos(uTime * (hashA + hashA * 1.) + hashA * 10.) * (.6 + hashA * .2),
        sin(uTime * (hashB + hashB * 1.1) + hashB * 20.) * (.6 + hashB * .2),
        sin(uTime * (hashC + hashC * 1.2) + hashC * 30.) * (1. + hashC * .2) + 2.2
    );
    vec3 targetPosition = uChaseTargetPosition + targetPositionOffset;

    vVelocity = mix( // 追従させたい方向へmixを使って近づける
        aVelocity,
        normalize(targetPosition - aPosition) * (uBaseSpeed + hashA * hashB),
        uBaseAttractRate + hashC * .01
    );

    vPosition = aPosition + aVelocity * uDeltaTime;
}

ループ内での処理

以下、ループ内での Transform Feedback の更新まわりの抜粋になります。

        ...

        // 書き込み用の Transform Feedback と VAO を取得
        const writeBufferTargets = transformFeedbackDoubleBuffer.getWriteTargets();
        // 前フレームの vertex array object を取得
        const readVAO = transformFeedbackDoubleBuffer.getReadVAOWrapper().vao;

        gl.bindVertexArray(writeBufferTargets.vertexArrayObjectWrapper.vao);

        gl.useProgram(transformFeedbackDoubleBuffer.shader);

        // 各種 uniform を更新していく
        gl.uniform3fv(
            gl.getUniformLocation(transformFeedbackDoubleBuffer.shader, 'uChaseTargetPosition'),
            chaseTargetPosition.elements
        );

        ...

        gl.enable(gl.RASTERIZER_DISCARD); // ラスタライズ処理をスキップ

        gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, writeBufferTargets.transformFeedback);
        gl.beginTransformFeedback(gl.POINTS);
        gl.drawArrays(gl.POINTS, 0, debuggerStates.instanceCount.currentValue);
        gl.endTransformFeedback();
        gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null);

        gl.disable(gl.RASTERIZER_DISCARD); // ラスタライズ処理のスキップを解除

        gl.useProgram(null);

        gl.bindVertexArray(null);

        // transform feedback で更新したバッファを、描画するメッシュのバッファに割り当て
        setBufferToVAO(
            boxVertexArrayObjectWrapper,
            "instancePosition",
            writeBufferTargets.vertexArrayObjectWrapper.findBuffer("position")
        );
        setBufferToVAO(
            boxVertexArrayObjectWrapper,
            "instanceVelocity",
            writeBufferTargets.vertexArrayObjectWrapper.findBuffer("velocity")
        );

        transformFeedbackDoubleBuffer.swap();  // 書き込みと読み込みをしたのでswap

        ...

        // 描画: サンプルではインデックス描画を使用しているので drawElementsInstanced を呼ぶ
        gl.drawElementsInstanced(gl.TRIANGLES, meshDrawCount, gl.UNSIGNED_SHORT, 0, debuggerStates.instanceCount.currentValue);

最後に

いかがだったでしょうか。ブラウザでも数万個単位のメッシュが描画できるのは魅力的かつ、大量描画は情報量が圧倒的で純粋に楽しいなと思います。

ここまで読んでいただきありがとうございました!

↓ 改めて、デモはこちらになります。画像かURLから飛ぶことができます

デモ: https://takumifukasawa.github.io/webgl-transform-feedback-gpu-instancing/

↓ リポジトリのURL

github.com

カヤックでは、WebGL が好きなエンジニアも募集しております!

hubspot.kayac.com

参考

wgld.org | WebGL: VAO(vertex array object) |

wgld.org | WebGL2: Transform Feedback の基礎 |

WebGL2 GPGPU