【WebGL2】アニメーション付きの草を大量に生やす ~ Skinning Mesh と GPU Instancing の連携 ~

この記事はTech KAYAC Advent Calendar 2022 22日目の記事です。

こんにちは!

ハイパーカジュアルゲームチームのフロントエンドエンジニアの深澤です。

WebGL2で、スキニング処理をしているGLTFのモデルを GPU Instancing で大量に表示するデモを作ってみました。影を落とす処理も合わせて、会社の Mac Chrome では30,000個ほど、私物 iPhoneX Safari では2,000-3,000個ほどは60FPSで表示できていそうです。

URLはこちらです。

デモサイト: https://takumifukasawa.github.io/PaleGL/demos/grass-skinning-gpu-instancing/

デモのgithub: https://github.com/takumifukasawa/PaleGL/tree/master/demos/grass-skinning-gpu-instancing

インスタンス数: 約700

インスタンス数: 約25,000


この記事では、スキニング処理の実装方法と GPU Instancing の組み合わせに焦点を当てて、実装を分解したサンプルを交えながら解説していきたいと思います。サンプルは章ごとに計4つ用意しました。

サンプルのリポジトリ: https://github.com/takumifukasawa/webgl-skinning-gpu-instancing

完成系サンプル: https://takumifukasawa.github.io/webgl-skinning-gpu-instancing/skinning-gpu-instancing.html

目次

動機

UnityにはGPU Skinningという仕組みがあります。ドキュメントを見ると対応端末/APIは DX11, DX12, OpenGL ES 3.1, Xbox One, PS4, Nintendo Switch and Vulkan (Windows, Mac and Linux) となっており、内部的には ComputeShader でスキニング処理を実現する方法です。

PlayerSettings-gpuSkinning - Unity スクリプトリファレンス

また、こちらの記事では GPU Skinning で Skinned Mesh を組み合わせて大量に表示するトピックが書かれていました。

アニメーションインスタンシング – SkinnedMeshRenderer 向けのインスタンシング | Unity Blog

この記事を読んで「WebGLでもスキニングなメッシュを大量に表示してみたい」と思ったのがきっかけです。

今回はWebGL2を使うのですが、そもそもWebGL1/WebGL2にはComputeShaderは存在していません。WebGPUではComputeShaderが標準で使えるようになるみたいですが、WebGPU自体の標準的なブラウザ対応はまだ当分先になりそうです。

そのためUnityのGPUSkinningとアプローチを変える必要が必ずあるのですが、「インスタンシングを踏まえつつ、WebGLでもスキニング処理をできるだけGPU側に任せる/CPU側から離す」という実装を探ってみたいなと思ったので、今回のデモを作ってみました。

実装方法

毎フレームのスキニング情報をRGBA32Fフォーマットのテクスチャにあらかじめ埋めておき、頂点シェーダーでそのテクスチャからスキニング用の情報を取り出し、シェーダーでスキニング処理をしています。

また、スキニングのアニメーションはインスタンスごとにずらしたいので、アニメーションのフレームをどれぐらいずらすかをattributeで渡すようにしています。

「アニメーションするものを大量に表示する」やり方としては「毎フレームの頂点情報(座標や法線)をテクスチャに埋めて頂点シェーダーでそのテクスチャを読む」ような方法もあるのですが、頂点数・フレーム数によってはロード時にだいぶ重い処理になる可能性が高いのでスキニング用の情報を埋めることにしました。

1. スキニング処理の実装

サンプルでは以下のような形のモデルを用意しました。 箱が5段分、ボーンの数は5個です。箱の辺の長さは1で、ボーンは箱の中心に置いていて、根本のボーンからY軸に1ずつ離れています。

正面から見た頂点とボーンの位置です。

最後に、目指す動きのイメージです。

モデルデータに書き出して・・・となると実装が増えますので、上の形になるようにjs側でモデルの情報を手打ちで記述しています。

const boxPositions = [
    // 0段目
    [-0.5, 0, 0.5],     // p0
    [0.5, 0, 0.5],      // p1
    [-0.5, 0, -0.5],    // p2
    [0.5, 0, -0.5],     // p3
    // 1段目
    ...
];

const boxIndices = [
    // 底面
    [0, 2, 1, 1, 2, 3],         // bottom
    // 1段目
    [4, 0, 5, 5, 0, 1],         // front
    [5, 1, 7, 7, 1, 3],         // right
    [7, 3, 6, 6, 3, 2],         // back
    [6, 2, 4, 4, 2, 0],         // left
    // 2段目
    ...
];

...

少し余談で、Blenderではボーンの根本と先をぐりぐり動かしてボーンの位置や回転を定めることができるのですが、骨の根本の部分(関節)が実装における「ボーン」になります。上の図で言うオレンジの丸の部分です。Blenderで根本と先を動かすことができるのはざっくりいうと「そういう編集方法をとるツール」で、それを踏まえた実装をする場合もあるはずですが、今回の実装では「根本がボーン」としそれ以外は考えないものとします。

スキニング処理の仕組み

そもそもスキニング処理とはどういう仕組みになっているのでしょうか。

まず、WebGLでなにかモノを画面に表示するためにはローカル座標系にある点をクリッピング座標に直す必要があります。クリッピング座標系の-1~1の範囲が画面に表示されます。

以下のように、ローカル座標系にある頂点位置をワールド変換、ビュー変換、投影変換 を通すことでクリッピング座標系へと変換していくのが基本となります。頂点シェーダーでローカル座標系の頂点をクリッピング座標系に変換する場合は大抵このようなコードになると思います。

gl_Position = uProjectionMatrix * uViewMatrix * uWorldMatrix * vec4(aPosition, 1.);

ここにスキニングの変換を加えていくのですが、まず考え方を「ローカル座標系の頂点をどう表現するか」と置き換えてみます

なぜなら「人間や動物のメッシュなど、何かしらのスキニング処理をするメッシュをワールド空間のどこにどう置いても成立するようにしたい」という実際の使い方を考えると、スキニング処理そのものはワールド空間への変換とは切って離して考えることができるからです。

つまり、スキニング処理の実装を考える場合は先ほどのクリッピング座標系への変換は以下のように置き換えることができます。

gl_Position = uProjectionMatrix * uViewMatrix * uWorldMatrix * skinMatrix * vec4(aPosition, 1.);

先ほどのコードとの違いは、スキニング処理をする何かしらの変換行列 skinMatrix を足したのみです。このように「ローカル座標系にある頂点 -> スキニング処理をかける -> ローカル座標系の新しい頂点情報に直す」というのがスキニングの考え方です。

今はシェーダー側でのスキニング処理を想定していますが。aPositionが既にCPU側でスキニング処理を施されたローカル座標系の頂点であれば skinMatrix はもちろん必要ありません。負荷のことを一旦無視すれば、CPU側で計算するかGPU側で計算するかの違いになります。

スキニング処理用の行列を作成する

先ほどの skinMatrix、つまりスキニング処理をする変換行列について考えていきます。頂点の動きは考えず、ボーンの動きだけに絞って考えてみましょう。

姿勢の決め方は、 Forward Kinematics(FK)の考えに沿って骨を動かしていくのを基本とします。FKとは、根本から末端まで順々に骨の回転や移動などを適用していく方法です。肩を根本とすると「肩->肘->手首...」のようなイメージですね。なので、末端にいけばいくほど影響する先祖のボーンが多くなります。逆に末端から骨を動かしていくのが Inverse Kinematics(IK)です。

FKに基づくので、サンプルのボーン5個のうち一番根本の b0 の姿勢を表す4x4行列は b0 だけを考慮し、b1は b0 * b1、b2は b0 * b1 * b2 となります。かける順番に注意が必要で、右から左にかけます。

つまり、ローカル空間におけるとあるボーンの姿勢は、影響する全ての先祖ボーンの「先祖ボーン自身の姿勢」を表す行列と、「対象のボーン自身の姿勢」を表す行列をかけていったものです。あえて「ボーン自身の姿勢」と書いたのは、ボーンの位置や向きは親ボーンに対して相対的な姿勢(移動や回転)を適用したものだからです。

ここで、初期位置での各ボーンの姿勢(位置や向き)を 初期姿勢 と呼ぶことにします。アニメーションをつけていない状態の姿勢のことで、ボーンそれぞれが持っています。スキニング処理をするメッシュですから、ほとんどの場合で何かしらのアニメーションが入っていることが期待されると思います。つまり、「初期姿勢」から「とあるフレームにおける姿勢」へどう変換するかを考えていく必要があります。それぞれ、pose0, pose1と呼ぶことにします。

先に方法を書くと、 「ローカル座標におけるボーンの初期姿勢(pose0)」の逆行列をかける -> ローカル座標におけるボーンの現在の姿勢(pose1)へ移動という変換を経ることで各フレームでのボーンの姿勢計算を実現できます。

なぜこういう方法を取るのかというと、ローカル座標の原点に戻す処理を挟むことで一度 (0,0,0) になるのでpose1への姿勢計算がわかりやすくなるからです。もし原点へ戻す処理をしない場合、毎フレームで (pose1 - pose0) の差分を持つ必要があります。前述の方法であれば毎フレームで持っておくデータは「ボーン空間における姿勢」となるのでわかりやすくなります(という理解です)。たとえばGLTFのデータをパースして中身を見るとこのような仕様になっています。

改めて各ボーンの毎フレームにおける姿勢計算の手順をまとめると、

全てのボーンにおいて、
1. ローカル座標系における初期姿勢の逆行列(「ボーンオフセット行列」と呼ぶ)を作成しておく
2. ローカル座標系の原点に戻すために、ボーンオフセット行列をかける
3. ローカル座標系における各ボーンの姿勢に移動
4. 2と3を毎フレーム繰り返す

となります。下図はイメージ図です。

ボーンオフセット行列 という言葉が新しく出てきました。改めて「初期姿勢」についてのイメージを膨らませると、例えば人間のモデルを使っているとよく耳にする T-Pose などのボーンの初期姿勢はこのボーンオフセット行列を計算するために必要なもの、ということになります。

初期姿勢からローカル空間の原点に戻す処理なので、読み込み時に一回だけ計算をしてあげれば問題ありません。例えばサンプルのモデルのボーンb2であればローカル空間における姿勢を表す行列は b0 * b1 * b2 なので、この逆行列がボーンオフセット行列となります。

下図はボーンオフセット行列のイメージです。

影響するボーン

各ボーンの動きを見てきました。次はいよいよ頂点の変換を考えていきましょう。

ボーンの動かし方が決まったので、頂点を何かしらの方法でボーンに連携させることが必要です。まず方法を見ていきましょう。

1. ボーンにインデックスを振る
2. 全てのボーンの「ローカル座標における現在の姿勢への変換行列 x ボーンオフセット行列」をuniformの配列でシェーダーに送る
  - 順番は1のインデックス順
  - 行列のかける順番に注意。右からかける。
3. 頂点ごとに、影響するボーンのインデックスを列挙し、頂点データで送る
4. 影響するボーンそれぞれにどれぐらい影響するかの重み(ブレンド率)をつけ、頂点データで送る。ブレンド率の合計値は1にしておく。
5. 影響するボーンそれぞれの現在の姿勢への変換行列とブレンド率をかけ、合計する

頂点が「どのボーンに影響するか」を指定したいのでボーンにインデックスを貼ります。今回のサンプルでは根本から0,1,2,3,4と振っています。

3番の「影響するボーンのインデックスを列挙」ですが、頂点ごとに影響するボーンは複数にしています。これは、極端に折れ曲がったり伸びたりするのを防ぐのが主な理由です。

例えば人間の肘など動く角度の範囲が広いボーンを想像した時に、複数のボーンに影響する想定であれば影響するボーンと重みの調整で伸び縮みをうまく調整することができます。今回のサンプルのような手打ちのモデルだとなかなか難しいですが、Blenderではブラシを使って重み(Weight)を変えることができます。基本的には影響するボーンの重みの合計値は1にしておくのが定石ですが、そうでないといけないということはありません。

ex) 各ボーンの現在の姿勢への変換行列をpose[bone_index]とし、とある頂点がbone0とbone1に0.5ずつ影響する場合、以下のようになります。

mat4 skinMatrix = pose[0] * 0.5 + pose[1] * 0.5;

さて、影響するボーンの指定と重みづけの指定の仕方を見てきました。では「影響させるボーンをいくつまで許容するか」というと、これも決まったものはありません。サンプルでは4つとしています。4つにしている理由は、影響するボーンのインデックスをuvec4のbufferで、重みづけをvec4のbufferで送ることで、それぞれ一つの型の中でデータを4つまで埋めることができるためです。ちなみにunityでは影響するボーン数を1個、2個、4個、autoから選ぶことができます。

動作デモはこちらになります。

https://takumifukasawa.github.io/webgl-skinning-gpu-instancing/skinning-uniform-matrix.html

以下、jsとglslの抜粋・記事用に調整したものです。

まず、Boneクラスを作成しました。インデックスはclassに持たせてもよいですし、親側で管理しても良いと思います。今回はサンプルのためわかりやすさ優先で親側で管理しています。

import {Matrix4} from "./Matrix4.js";

export class Bone {
    parent = null;
    children = [];

    offsetMatrix; // 親ボーンに対する相対的な位置
    #poseMatrix; // ローカル空間における初期姿勢行列
    #boneOffsetMatrix; // ローカル空間における初期姿勢行列の逆行列(= ボーンオフセット行列)
    #jointMatrix = Matrix4.identity(); // ローカル空間における現在の姿勢行列
   
    get childCount() {
        return this.children.length;
    }

    get hasChild() {
        return this.childCount > 0;
    }

    addChild(child) {
        this.children.push(child);
        child.parent = this;
    }
   
    get boneOffsetMatrix() {
        return this.#boneOffsetMatrix;
    }
    
    get poseMatrix() {
        return this.#poseMatrix;
    }
    
    get jointMatrix() {
        return this.#jointMatrix;
    }
    
    calcBoneOffsetMatrix(parentBone) {
        this.#poseMatrix = !!parentBone
            ? Matrix4.multiplyMatrices(parentBone.poseMatrix, this.offsetMatrix)
            : this.offsetMatrix;
        this.#boneOffsetMatrix = this.#poseMatrix.clone().invert();
        this.children.forEach(childBone => childBone.calcBoneOffsetMatrix(this));
    }
    
    calcJointMatrix(parentBone) {
        this.#jointMatrix = !!parentBone
            ? Matrix4.multiplyMatrices(parentBone.jointMatrix, this.offsetMatrix)
            : this.offsetMatrix;
        this.children.forEach(childBone => childBone.calcJointMatrix(this));
    }
}

続いて、ボーンの生成や重みづけの処理、webglとのバインドなどです。

// ------------------------------------
// 頂点情報
// ------------------------------------

// 影響するboneのindex  
// 4つまで指定可能にする
const boneIndices = new Uint16Array[
    // 0段目
    [0, 0, 0, 0], // b0
    [0, 0, 0, 0], // b0
    [0, 0, 0, 0], // b0
    [0, 0, 0, 0], // b0
    // 1段目
    [0, 1, 0, 0], // b0, b1
    [0, 1, 0, 0], // b0, b1
    [0, 1, 0, 0], // b0, b1
    [0, 1, 0, 0], // b0, b1
    ...
].flat());

// 影響するboneのindexごとの重さ
// Float32Arrayで送る
const boneWeights = new Float32Array([
    // 0段目
    [1, 0, 0, 0],       // b0 * 1
    [1, 0, 0, 0],       // b0 * 1
    [1, 0, 0, 0],       // b0 * 1
    [1, 0, 0, 0],       // b0 * 1
    // 1段目
    [0.5, 0.5, 0, 0],   // b0 * 0.5, b1 * 0.5
    [0.5, 0.5, 0, 0],   // b0 * 0.5, b1 * 0.5
    [0.5, 0.5, 0, 0],   // b0 * 0.5, b1 * 0.5
    [0.5, 0.5, 0, 0],   // b0 * 0.5, b1 * 0.5
    ...
].flat());

...

// ------------------------------------
// boneの生成
// Bone Classで親子関係を管理
// offsetMatrix ... 親ボーンからの相対的な姿勢
// ------------------------------------

const bone0 = new Bone();
bone0.offsetMatrix = Matrix4.translationMatrix(new Vector3(0, 0.5, 0)); // offset
   
const bone1 = new Bone();
bone1.offsetMatrix = Matrix4.translationMatrix(new Vector3(0, 1, 0));; // offset from parent (bone0)
bone0.addChild(bone1);

const bone2 = new Bone();
bone2.offsetMatrix = Matrix4.translationMatrix(new Vector3(0, 1, 0)); // offset from parent (bone1)
bone1.addChild(bone2);

const bone3 = new Bone();
bone3.offsetMatrix = Matrix4.translationMatrix(new Vector3(0, 1, 0)); // offset from parent (bone2)
bone2.addChild(bone3);

const bone4 = new Bone();
bone4.offsetMatrix = Matrix4.translationMatrix(new Vector3(0, 1, 0)); // offset from parent (bone3)
bone3.addChild(bone4);

// uniform配列の順番は親側で管理
const bones = [
    bone0,
    bone1,
    bone2,
    bone3,
    bone4,
];   

// 全てのボーンのオフセット行列を再帰的に生成
bones[0].calcBoneOffsetMatrix();
    
...

// アニメーションループ内で呼ぶ。bone1,bone2,bone3を回転させている   
const updateBone = (time) => {
    const rot = Math.sin(Math.PI * 2 * (time / 2)) * 30 * Math.PI / 180;
    bone1.offsetMatrix = Matrix4.multiplyMatrices(
        Matrix4.translationMatrix(new Vector3(0, 1, 0)),
        Matrix4.rotationZMatrix(rot)
    );
    bone2.offsetMatrix = Matrix4.multiplyMatrices(
        Matrix4.translationMatrix(new Vector3(0, 1, 0)),
        Matrix4.rotationZMatrix(rot)
    );
    bone3.offsetMatrix = Matrix4.multiplyMatrices(
        Matrix4.translationMatrix(new Vector3(0, 1, 0)),
        Matrix4.rotationZMatrix(rot)
    );
}

const tick = (time) => {

    ...
    updateBone(time / 1000);

    // 全てのボーンの、ローカル空間における現在の姿勢を表す行列を作成
    // 再帰的に子ボーンも計算
    bones[0].calcJointMatrix();

    // 現在の姿勢行列(jointMatrix) x ボーンオフセット行列
    // multiplyMatricesは右からかける。ボーンオフセット行列を適用してから姿勢行列をかけるので、ボーンオフセット行列が右
    // uniformにセットする際Float32Arrayの一次元配列にする
    const jointMatrices = [
        Matrix4.multiplyMatrices(bones[0].jointMatrix, bones[0].boneOffsetMatrix), // b0
        Matrix4.multiplyMatrices(bones[1].jointMatrix, bones[1].boneOffsetMatrix), // b1
        Matrix4.multiplyMatrices(bones[2].jointMatrix, bones[2].boneOffsetMatrix), // b2
        Matrix4.multiplyMatrices(bones[3].jointMatrix, bones[3].boneOffsetMatrix), // b3
        Matrix4.multiplyMatrices(bones[4].jointMatrix, bones[4].boneOffsetMatrix), // b4
    ];

    ...
}

...

// ------------------------------------
// 頂点用のbufferをセットする関数の中身
// ------------------------------------

...
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); // static draw 固定。data ... 頂点にセットするデータ
gl.enableVertexAttribArray(location);
switch(data.constructor) {
    case Float32Array:
        gl.vertexAttribPointer(location, size, gl.FLOAT, false, 0, 0);
        break;
    case Uint16Array:
        gl.vertexAttribIPointer(location, size, gl.UNSIGNED_SHORT, 0, 0);
        break;
}

...

// ------------------------------------
// uniformにボーンの行列の配列をセットする部分
// ------------------------------------

...
const shader = gl.createProgram();
gl.useProgram(shader);
...
const location = gl.getUniformLocation(shader, "uJointMatrices");
gl.uniformMatrix4fv(location, false, data);  // data ... 各ボーンの変換行列がFloat32Arrayで埋まっている。サンプルの場合は行列5個分
...

glsl

#version 300 es
    
layout (location = 0) in vec3 aPosition;   
layout (location = 1) in vec3 aColor; 
layout (location = 2) in uvec4 aBoneIndices;
layout (location = 3) in vec4 aBoneWeights;

uniform mat4 uWorldMatrix;
uniform mat4 uViewMatrix;
uniform mat4 uProjectionMatrix;

uniform mat4[5] uJointMatrices;

out vec3 vColor;

void main() {
    vColor = aColor;
    
    mat4 skinMatrix =
        uJointMatrices[aBoneIndices.x] * aBoneWeights.x + 
        uJointMatrices[aBoneIndices.y] * aBoneWeights.y +
        uJointMatrices[aBoneIndices.z] * aBoneWeights.z +
        uJointMatrices[aBoneIndices.w] * aBoneWeights.w;
    
    gl_Position = uProjectionMatrix * uViewMatrix * uWorldMatrix * skinMatrix * vec4(aPosition, 1.);
}

影響するボーンが増えれば増えるほど頂点シェーダーの負荷も上がるので、例えば影響するボーンを2つまでとして一つのvec4の中に vec4(boneIndex0, boneIndex1, boneWeight0, boneWeight1); と埋めることで、必要とするbufferも2つから1つに減るのでより最適化につながるでしょう。

2. 頂点テクスチャフェッチを使う

Uniformの制限

さて、ここまでで基本的なスキニングの実装は出来ました。

しかしこの実装だとモデルデータによってはシェーダーエラーになる場合があります。それはUniformにまつわる制限です。

具体的には、 MAX_VERTEX_UNIFORM_VECTORS という制限があります。これは頂点シェーダーで扱うことのできるベクトルの許容数にあたり、各端末によって異なります。私物のiPhoneX safariでは256で、会社のMacのChromeでは1024でした。この教習は、端末のGPUやOS、ブラウザごとの設定・仕様によって組み合わせ的に異なります。(Uniform Buffer Object を使うともっとたくさんのベクトル数を扱うことができるはずですが Uniform Buffer Object については知識不足ですので今回は割愛させていただきます)

WebGL Report というサイトを開くと MAX_VERTEX_UNIFORM_VECTORS に限らず現在の端末におけるWebGLのサポート状況周りを一括して出力してくれるので便利です。余談ですが、まだWebGL1が主流だった時代、拡張機能である float texture を使いたいけれども対応していない端末がそこそこあり、いろんな端末で float texture の対応状況がどうなっているかを確認するのに重宝していました。

WebGL Report

...話を戻しますと、uniformで扱うことのできるベクトル数に制限があります。mat4はベクトル4つとして扱われます。つまり、MAX_VERTEX_UNIFORM_VECTORS が256の場合、渡すことのできる mat4 は 特別な工夫をしない場合は 256 / 4 = 64 個までということになります。

スキニング用の行列以外にも渡したいuniformはほぼ何かしらはあるとはいえ、これぐらいのボーンの数をスマホのwebサイトで扱うことができれば十分なケースは多そうです。ボーンの数を減らすという手もあります。

しかし、「シェーダーでuniformの配列の数をボーン数と同一に固定すると、ボーンの数が異なるメッシュではシェーダーを別にする必要がある」という問題があります。同じ見た目であれば、ボーン数が違っても同じシェーダーを使えた方がより便利です。

テクスチャに埋める

そこで、各ボーンの変換行列をテクスチャに焼き頂点テクスチャフェッチで読み取る方法をとってみましょう。uniformで送るのは配列ではなくテクスチャになるので、シェーダーを統一することが可能になります。

今回はRGBA32Fフォーマットに埋めることとします。RGBA32Fは、各チャンネルが32bitのfloat x 4チャンネル = 1ピクセルあたり128bitなテクスチャです。

WebGL2ではRGBA16Fを使うこともできます。 精度的には半精度でも十分なのですが、TypedArrayにはFloat16がない のでテクスチャに渡すデータは Float32Arrayを使うことにし、テクスチャはRGBA32Fを使うことにします。 ※ js側でFloat16なデータ配列を実現する方法をどなたかご存知でしたら教えていただけますと幸いです。

埋め方

Float32Arrayに各ボーンの変換行列を全て格納します。テクスチャのフォーマットがRGBA32bit・渡すデータがFloat32Arrayなので、渡すデータの配列の要素がそのまま各チャンネルに格納されます。

テクスチャの横幅を4ピクセルとしてみます。4チャンネル x 4ピクセル = 行列一個分になるので、ボーンごとの行列が縦に並んでいくイメージですね。もちろん、横幅を8ピクセルや12ピクセルと大きくしても問題ありません。ではなぜ4pxにしているのかというと、シェーダーでの取り回しやすさを優先しているからです。冒頭のデモでは横幅のピクセル数を可変できるようにしています。

サンプルではボーンは5個なので、4 x 5 = 20ピクセルのテクスチャを生成します。容量は 20 x 128bit = 2,560bit になります。下図はイメージ図です。

サンプルはこちらになります。見た目の違いはありません。

https://takumifukasawa.github.io/webgl-skinning-gpu-instancing/skinning-joint-texture.html

以下、サンプルのjsとglslの流れを抜粋・調整したものです。

const boneNum = 5;

const width = 4;
const height = width * boneNum;

// create texture
const texture = gl.createTexture();

...

// ループ関数
const tick = () => {
    ...
    // bind
    gl.bindTexture(gl.TEXTURE_2D, texture); 
    // set data
    // data ... サンプルでは、ボーンの現在の姿勢への変換行列 x5 が中に入っている
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, width, height, 0, gl.RGBA, gl.FLOAT, data);
    // unbind
    gl.bindTexture(gl.TEXTURE_2D, null); 
    ...

    // uniformにセット
    ...
}

glsl

#version 300 es

layout (location = 0) in vec3 aPosition;   
layout (location = 1) in uvec4 aBoneIndices;
layout (location = 2) in vec4 aBoneWeights;

...

uniform sampler2D uJointTexture; // 4 x 5 のテクスチャ

mat4 getJointMatrix(uint boneIndex) {
    return mat4(
        texelFetch(uJointTexture, ivec2(0, boneIndex), 0),
        texelFetch(uJointTexture, ivec2(1, boneIndex), 0),
        texelFetch(uJointTexture, ivec2(2, boneIndex), 0),
        texelFetch(uJointTexture, ivec2(3, boneIndex), 0)
    );
}

void main() {
    ...

    mat4 skinMatrix =
        getJointMatrix(aBoneIndices.x) * aBoneWeights.x + 
        getJointMatrix(aBoneIndices.y) * aBoneWeights.y +
        getJointMatrix(aBoneIndices.z) * aBoneWeights.z +
        getJointMatrix(aBoneIndices.w) * aBoneWeights.w;
    
    gl_Position = uProjectionMatrix * uViewMatrix * uWorldMatrix * skinMatrix * vec4(aPosition, 1.);
}

3. アニメーション情報もテクスチャに埋める

方針

ここまでの実装では、CPU側(js)で各ボーンの行列を計算し、テクスチャにデータを埋める方法を取りました。いよいよ GPU Instancing を踏まえた実装を考えていきます。

そもそも GPU Instancing を使いたいのは、描画する数を素直に増やすためにはドローコールを増やしていく必要があるところを、大量の表示をする処理をGPUの機能に任せて一回のドローコールで実現でき、高速化に繋がるからです。また、詳しくは後述するのですがシェーダー側で gl_InstanceID を使って自身のインスタンスIDを参照したり、bufferにインスタンスごとのデータを渡すことも可能です。

ただ、今の実装方法では GPU Instancing を使ってもインスタンスごとのアニメーションが同期されたような見た目になります。なぜなら各ボーンの情報がシェーダーに渡る時点では既にボーンの姿勢は決まっているからです。しかし、スキニング処理をするメッシュを大量に表示する場合はやはりメッシュごとのアニメーションはずらしたいですよね。

そこで、アニメーション情報もテクスチャにあらかじめ埋めておくことにします。つまり、今まではテクスチャに埋められていたのは「とあるフレームでの各ボーンの変換行列」でしたが「全てのフレームでの各ボーンの現在の変換行列」を埋める、ということです。なのでデータがフレーム数分掛け算的に増えます。

テクスチャに全てのフレームの各ボーンの行列を埋める

これまで、テクスチャに埋めるデータのピクセル数は「横:4px(行列一個分のデータ) x 縦:ボーン数」でした。ここからは各フレームの行列もあらかじめ埋めてしまうのでピクセル数は「横:4px(行列一個分のデータ) x 縦:(ボーン数 x フレーム数)」となります。

サンプルではアニメーションのフレーム数を60としました。容量は 4 x 5 x 60 x 128bit = 153,600bit = 約19kb となります。繰り返しになるのですが、横を4pxとしているのはシェーダーでの取り回しやすさのためですので、4pxでなくとも問題ありません。フレーム数が多い場合は横のピクセル数を増やした方が埋めることのできるデータ数は増えます。

ここまでのサンプルはこちらになります。まだ実行時の見た目に違いはありません。

https://takumifukasawa.github.io/webgl-skinning-gpu-instancing/skinning-bake-bone-animation.html

サンプルのシェーダーの抜粋です。

...

// 現在のアニメーションのフレーム番号
uniform int uFrameIndex;

...

// ボーン数は5なので、フレームごとに5ずつずらす
mat4 getJointMatrix(uint boneIndex) {
    int frameNum = 5;
    uint targetIndex = uint(uFrameIndex * frameNum) + boneIndex;
    return mat4(
        texelFetch(uJointTexture, ivec2(0, targetIndex), 0),
        texelFetch(uJointTexture, ivec2(1, targetIndex), 0),
        texelFetch(uJointTexture, ivec2(2, targetIndex), 0),
        texelFetch(uJointTexture, ivec2(3, targetIndex), 0)
    );
}

...

4. GPU Instancing の実装

いよいよ GPU Instancing の実装に入ります。単に Instancing と呼んだり、Geometry Instancing と呼んだりするケースもあるようですが、ここでは GPU Instancing で統一したいと思います。

GPU Instancing は WebGL1 では拡張機能扱いでしたが、WebGL2になってからは標準機能へと格上げされました。GPU Instancing そのものへの対応は意外と簡単に置き換えることができます。

必須: 描画メソッドの置き換え

最低限必ず必要なものです。WebGLでは描画命令のメソッドは drawArraysdrawElements の2種類あります。後者はIndexBufferを使ったものですね。

この描画命令のメソッドをそれぞれ drawArraysInstanceddrawElementsInstanced に置き換えます。また、引数の最後にインスタンス数を指定するようになっています。

gl.drawArrays(mode, first, count, instanceCount);
↓
gl.drawArraysInstanced(mode, first, count, instanceCount);
gl.drawElements(mode, count, type, offset);
↓
gl.drawElementsInstanced(mode, count, type, offset, instanceCount)

パターン1: インスタンスごとのデータをuniformに埋める場合

例えばインスタンス数を10個にする場合、uniformに配列を10個分送ります。そして gl_InstanceID でインスタンスごとのデータを取得する方法になります。

仮に色をインスタンスごとに変える場合、以下のようなコードになります。

const shader = gl.getProgram();

...

const location = gl.getUniformLocation(shader, "uColor");

// 10個分の色を詰める
gl.uniform4fv(location, [
    1, 0, 0, 1, // 赤
    0, 1, 0, 1, // 青
    ...
]);
uniform vec4[10] uColor;

...

void main() {
    ...
    vec4 instanceColor = uColor[gl_InstanceID];
    ...
}

しかし、前述のようにuniformで送ることのできるデータ数はそこまで多くありません。使っている端末の MAX_VERTEX_UNIFORM_VECTORS が256個の場合・インスタンスごとの色の管理を頂点シェーダーのuniformで対応するケースだと最大でも256個までになります。

パターン2: インスタンスごとのデータを頂点に埋める場合

この2つが必要になります。

  1. VertexBufferObject にインスタンスごとに変化させたいデータを追加
  2. vertexAttribDivisorでインスタンスごとのデータがどう並んでいるかを指定

例えばインスタンス数が10でそれぞれ色を変えたい場合、vertexBufferObjectに10個分の色を入れます。

const data = new Float32Array([
    1, 0, 0, 1, // 赤
    0, 1, 0, 1, // 青
    ...
]);

const location = 0;

const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
gl.enableVertexAttribArray(location);
gl.vertexAttribPointer(location, size, gl.FLOAT, false, 0, 0);

if(divisor) {
    // インスタンスごとのデータが1つずつ順番に並んでいることを指定
    gl.vertexAttribDivisor(location, 1);
}

今回はVertexBufferObjectにインスタンスごとのデータを入れるようにします。

インスタンスごとにアニメーションの時間をずらす

これで準備は整いました。アニメーションの時間をずらすデータは float 一個分なので、 VertexBufferObjectに渡すFloat32Arrayな配列の長さはインスタンスの数そのままになります。

また、インスタンスごとの色と位置調整も頂点に渡すようにしてみました。後述する頂点シェーダー内の aInstanceOffsetPosition, aInstanceFrameOffset, aInstanceColor はインスタンスごとのデータを入れたバッファを送ったものになります。

サンプルはこちらです。

https://takumifukasawa.github.io/webgl-skinning-gpu-instancing/skinning-gpu-instancing.html

こちらが完成サンプルのシェーダーになります。

#version 300 es
    
layout (location = 0) in vec3 aPosition;   
layout (location = 1) in vec3 aColor; 
layout (location = 2) in uvec4 aBoneIndices;
layout (location = 3) in vec4 aBoneWeights;
layout (location = 4) in vec3 aInstanceOffsetPosition;
layout (location = 5) in uint aInstanceFrameOffset;
layout (location = 6) in vec3 aInstanceColor;

uniform mat4 uWorldMatrix;
uniform mat4 uViewMatrix;
uniform mat4 uProjectionMatrix;
uniform sampler2D uJointTexture;
uniform float uTime;

out vec3 vColor;

mat4 getJointMatrix(uint boneIndex) {
    return mat4(
        texelFetch(uJointTexture, ivec2(0, boneIndex), 0),
        texelFetch(uJointTexture, ivec2(1, boneIndex), 0),
        texelFetch(uJointTexture, ivec2(2, boneIndex), 0),
        texelFetch(uJointTexture, ivec2(3, boneIndex), 0)
    );
}

void main() {
    vColor = aInstanceColor;
    
    // インスタンスごとに0~59フレームをループするように
    float fps = 30.;
    float frameCount = 60.;
    int frameIndex = int(mod(floor(uTime * fps) + float(aInstanceFrameOffset), frameCount));
    int boneCount = 5;
    
    uint b0 = uint(frameIndex * boneCount) + aBoneIndices.x;
    uint b1 = uint(frameIndex * boneCount) + aBoneIndices.y;
    uint b2 = uint(frameIndex * boneCount) + aBoneIndices.z;
    uint b3 = uint(frameIndex * boneCount) + aBoneIndices.w;

    mat4 skinMatrix =
        getJointMatrix(b0) * aBoneWeights.x + 
        getJointMatrix(b1) * aBoneWeights.y +
        getJointMatrix(b2) * aBoneWeights.z +
        getJointMatrix(b3) * aBoneWeights.w;
       
    mat4 instanceOffsetMatrix = mat4(
        1, 0, 0, 0,
        0, 1, 0, 0,
        0, 0, 1, 0,
        aInstanceOffsetPosition, 1
    );
        
    gl_Position = uProjectionMatrix * uViewMatrix * instanceOffsetMatrix * uWorldMatrix * skinMatrix * vec4(aPosition, 1.);
}

まとめ

冒頭にちらっとあげた「頂点情報をテクスチャに埋める方式」と比較しつつ、利点/欠点を考えてみます。

利点

テクスチャサイズが頂点数に影響しなくなる点です。ピクセル数は「4px(行列一つ) x ボーン数 x フレーム数」になるので、頂点数が1,000であっても10,000であってもテクスチャサイズが変わることはありません。

仮にボーン数10のモデルでアニメーションが4秒ある場合、30fpsとすると 4px x 10bone x 4sec x 30fps = 4,800 のピクセルが必要になります。ビット数は 4,800 * 32 * 4 = 614,400 で、75KBです。

「頂点情報をテクスチャに埋める方式」の場合、頂点数が1,000とすると 1000vertex * 4sec * 30fps = 120,000 のピクセル数が必要になります。RGB32Fを使う場合、ビット数は 120,000 x 32 x 3 = 11,520,000となり、約1.37MBです。また、渡したい情報(頂点座標、法線など...)の数だけ掛け算式に増えていきます。

欠点

頂点が影響するボーン数に応じて頂点シェーダーでのテクスチャへのアクセス回数が多くなる点です。ボーン数が4であれば最低16回のアクセスが必要になります。「頂点情報をテクスチャに埋める方式」を使って例えば頂点の座標と法線をテクスチャに焼く場合、テクスチャへのアクセスは2回です。

最後に

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

デモサイト: https://takumifukasawa.github.io/PaleGL/demos/grass-skinning-gpu-instancing/

参考

その61 完全ホワイトボックスなスキンメッシュアニメーションの解説

WebGL2 Skinning

wgld.org | WebGL: インスタンシング(instanced arrays) |