はじめに
この記事はProcessing Advent Calender 2024の7日目の記事です。この記事の目的は、blenderで作ったスキンメッシュアニメーションをp5上で再生することです。使うのはp5のshader周りの仕様と、p5.Matrixクラスです。このクラスにはレファレンスがありませんが、p5と紐付けられているので、ユーザーは自由に生成し、用いることができます。
この記事の目的はあくまでも、Blenderで簡単に作ったアニメーションを走らせることだけです。gltfには非常に豊富な情報が詰め込まれているので、あらゆる場合を想定して実装しなければいけません。大変手間がかかるので、通常はThree.jsなどのloaderに頼るのが普通です。そこまで広範な情報は扱えないので、ここではあくまでもアニメーションを再生するにとどめます。
逆に言うとその程度が限界ということです。ルールから外れたことをすると簡単にエラーが出るきわめて脆弱なものです。きちんと扱いたい場合は、素直にloaderに頼りましょう。
blenderのスキンメッシュアニメーションについて
詳しくはいろんなサイトを参考にするといいんですが(これとか)、簡単に説明すると、メッシュとは別に骨組みのようなものを用意して、それを動かすことで間接的にメッシュの頂点を動かす仕様です。具体的には行列を使って実装します。Blenderでそれを作るまでの簡単な動画を作りました。
Blenderのバージョンは3.1です。4.xでも同じようにできるかは分かんないです。
動画の最後で、これをgltf形式でexportしています。出力方法は3種類あります。全体をバイトコードで出力する、数値データのみバイトコードとし全体の構造についてはjson形式で出力する、バイトコードも含めて1つのjsonに含めて出力するという3つの方法があり、動画では3つ目の方法で出力しています。今回解析するのはこれです。内容についてはこちら:testSkinMesh_0.gltf
全体がひとつのjsonになっているので、p5.jsの便利関数であるloadJSON()で読み取ることができます。これを解析していきます。
コード全文
全文を載せてしまうとQiitaが重くなってしまうので、リンクを張ります(編集がめんどくさい)。
skin action p5
実行した結果がこちらになります。
ちなみに、p5.jsのversionは1.11.2を使用しています。今後の仕様変更により動作しなくなる可能性があります。自分のライブラリを使ったバージョンはp5.jsのwebglに依存していないので問題ありませんが、こちらはバージョン更新で壊れる可能性があります(去年のアドカレのスケッチがそうでした)。ただ、同じ挙動にするための工夫は難しくないと思います。
arrayBuffersの取得
gltfの最後の方のbuffersにあるuriのところに、暗号のような膨大な文字列が並んでいますが、数値データはすべてここに格納されています(全部ではないですが)。これを解読する必要があります。これはbase64という方式で圧縮されています。これを数値に復元するにはatob()という関数を使います。
「,」より後ろが必要なのでsplit()で加工します。
// arrayBufferを全部用意する。
function getArrayBuffers(data){
// arrayBufferの取得
const arrayBuffers = [];
for(let i=0; i<data.buffers.length; i++){
const bin = data.buffers[i].uri.split(',')[1];
arrayBuffers.push(getArrayBuffer(bin));
}
return arrayBuffers;
}
getArrayBuffer()によりarrayBufferを取得します。
function getArrayBuffer(bin){
const byteString = atob(bin);
const byteStringLength = byteString.length;
const arrayBuffer = new ArrayBuffer(byteStringLength);
// 型付配列を使ってarrayBufferにデータを書き込みます
// byteStringは0~255のASCIIコードが並んだ文字列なので
// 1文字ずつ数値に直して入力します
const intArray = new Uint8Array(arrayBuffer);
for (let i = 0; i < byteStringLength; i++) {
intArray[i] = byteString.charCodeAt(i);
}
return arrayBuffer;
}
binはbase64形式の文字列で、これをまずatobでバイト文字(ASCII0~255)の列にします。次にその長さだけのarrayBufferを用意します。これはただのバッファ領域で、配列ではありません。
ArrayBuffer
ここにASCII文字を0~255の数値に変換した値を順に放り込んでいきます。それにはこのように型付配列を使うかDataViewクラスを用いる必要があります。ともあれ、これでデータが放り込まれました。arrayBufferの完成です。ここには整数や小数がバイト列の形で埋め込まれており、この内容をあとで型付配列により取得します。
頂点データを取得する
accessorsとbufferViewsのデータは常に必要となります。arrayBuffersもです。メッシュデータはmeshesに入ってるのでそれも使います。
なお、「data」というのは取得したjsonデータです。埋め込み式のgltfファイルをloadJSONで取得するとjsonデータになるので、それを使っています。
データを解読するところ。bufが先ほど用意したarrayBuffersです。
function getObjData(data, buf){
const {meshes, accessors, bufferViews} = data;
// dataは上記のdataのdata.meshesの他に
// data.accessorsとdata.bufferViewsが入っています
// 加えて解読済みのarrayBufferが入っています(data.buf)
const result = [];
for (const mesh of meshes) {
const resultMesh = {};
resultMesh.name = mesh.name;
resultMesh.primitives = [];
for (const prim of mesh.primitives) {
const primitive = {};
const attrs = prim.attributes;
for (const attrName of Object.keys(attrs)) {
// POSITION:0, NORMAL:1, TEXCOORD_0:2
const attrAccessor = accessors[attrs[attrName]];
const attrArrayData = getArrayData(
buf, attrAccessor, bufferViews[attrAccessor.bufferView]
);
primitive[attrName] = {
data:attrArrayData, info:getInfo(attrAccessor)
}
}
const indexAccessor = data.accessors[prim.indices];
const indexArrayData = getArrayData(
buf, indexAccessor, bufferViews[indexAccessor.bufferView]
);
primitive.indices = {
data:indexArrayData, info:getInfo(indexAccessor)
}
// shapeKeyAnimation用のtarget関連の記述はカット
resultMesh.primitives.push(primitive);
// とりあえずこんなもんで
}
result.push(resultMesh);
}
return result;
}
meshesがメッシュの配列で、その中にprimitivesといってprimitiveの配列があります。ここが長さ2以上になるのがどういう状況なのかは詳しくないので分かんないです。普通に単独のメッシュをつくった場合、いずれも長さ1になります。複数のメッシュが混在している場合、meshesは長さ2以上になります。
attrNameにはPOSITION,NORMAL,TEXCOORD_0などが入ります。これらは頂点や法線のデータです。たとえば法線ならvec3なので数値が3つずつ並んでいます(例:[0,0,1,0,1,0,1,0,0])。
データの取得に使うのがgetArrayData関数です。
getArrayData()
// arrayOutputがfalseの場合はそのまま出力されるけども。
// まあjsのarrayのが便利やろ
function getArrayData(buf, accessor, bufferView, arrayOutput = true){
// それぞれのデータを取得する感じのあれ
const componentType = accessor.componentType;
//const _count = accessor.count; // 24とか30
const _type = accessor.type; // "VEC3"とか"SCALAR"
let _size;
switch(componentType) {
case 5126: _size = 4; break;
case 5123: _size = 2; break;
case 5121: _size = 1; break; // 追加(boneのJOINTが5121)
}
const byteOffset = bufferView.byteOffset;
const byteLength = bufferView.byteLength;
const arrayLength = byteLength / _size;
const resultArray = createArrayData(buf[bufferView.buffer], componentType, byteOffset, arrayLength);
if(arrayOutput){
const outputArray = new Array(resultArray.length);
for(let i=0; i<outputArray.length; i++){
outputArray[i] = resultArray[i];
}
return outputArray;
}
return resultArray;
}
function createArrayData(buf, componentType, byteOffset, arrayLength) {
switch(componentType) {
case 5126:
const f32Array = new Float32Array(buf, byteOffset, arrayLength);
return f32Array;
case 5123:
const i16Array = new Uint16Array(buf, byteOffset, arrayLength);
return i16Array;
case 5121: // 追加
const u8Array = new Uint8Array(buf, byteOffset, arrayLength);
return u8Array;
}
return [];
}
function getComponentType(code){
switch(code){
case 5126: return "float32";
case 5123: return "uint16";
case 5121: return "uint8"; // 追加
}
return "";
}
accessorというのはaccessorsに入っているメタデータです。これを取得時に渡します。中身はバイト単位でのデータなどです。たとえばfloat32の場合は4バイトずつ数値になっているので解読する際にそれを用います。uint8なら1バイトずつ数値になっています。
arrayOutputは中身をjsの配列の形式で出力するために用意しました。これが無いと割り算で0になってしまったり、不便だからです。たとえば色情報はuint16で格納されていますが、32767で割らないと頂点色として利用することができません。Uint16Arrayのまま32767で割ると0と1になってしまいます。
アニメーションデータを取得する
animationを解読するところ:
function getAnimationData(data, buf){
const {animations, accessors, bufferViews} = data;
const result = [];
for (const animation of animations) {
const resultAnimation = {};
resultAnimation.useWeight = false;
resultAnimation.name = animation.name;
// samplersの各成分を解読して放り込んで
// resultAnimation.samplersとする。そのうえで
// nodesとchannelsからsamplersのどこを参照するか、
// あるいはnodesのどのメッシュを参照するか決める感じ。
resultAnimation.data = [];
// animationは複数存在する場合があるので。ここにぶちこんでいく。
for(let k=0; k<animation.samplers.length; k++){
const sampler = animation.samplers[k];
const channel = animation.channels[k];
const resultData = {};
const resultSampler = {};
resultSampler.interpolation = sampler.interpolation; // LINEARなど
const inputAccessor = accessors[sampler.input];
const outputAccessor = accessors[sampler.output];
const inputArrayData = getArrayData(
buf, inputAccessor, bufferViews[inputAccessor.bufferView]
);
const outputArrayData = getArrayData(
buf, outputAccessor, bufferViews[outputAccessor.bufferView]
);
resultSampler.input = {
data:inputArrayData, info:getInfo(inputAccessor)
}
resultSampler.output = {
data:outputArrayData, info:getInfo(outputAccessor)
}
resultData.sampler = resultSampler;
resultData.channel = channel;
resultData.node = channel.target.node; // nodeが分かんないと不便
resultData.path = channel.target.path; // pathが分かんないと不便
// weightが1つでもある場合それはweightアニメーションなのでフラグを立てる
if(resultData.path === "weight"){
resultAnimation.useWeight = true;
}
// weight関連はカット
resultAnimation.data.push(resultData);
}
if(!resultAnimation.useWeight){
// weightでない場合にある程度扱いやすくする必要がある
// フレームを正規化して0~MAX-1としs,r,tそれぞれ配列とする
// まずscaleで[[],[],...] nodeで?jointと同じでnodeの番号に入れちゃえ
// たとえばCubeActionの場合は同じとこに全部入る
// weightとshapeKeyが併用されてる場合でもboneが優先して並ぶので問題ない
const transformData = [];
let maxFrame = -Infinity;
for(const data of resultAnimation.data){
if(transformData[data.node] === undefined){
transformData[data.node] = {scale:[], rotation:[], translation:[]};
}
maxFrame = Math.max(maxFrame, Math.round(24*data.sampler.input.info.max[0]));
}
for(const data of resultAnimation.data){
const normalizedArray = [];
const inputArray = data.sampler.input.data;
const outputArray = data.sampler.output.data;
let dataSize = 1;
switch(data.path){
case "scale": dataSize = 3; break;
case "rotation": dataSize = 4; break;
case "translation": dataSize = 3; break;
}
const minIndex = Math.round(24*inputArray[0]);
const maxIndex = Math.round(24*inputArray[inputArray.length-1]);
for(let i=0; i<=maxFrame; i++){
// ここでclamp処理
if(i < minIndex){
normalizedArray.push(...outputArray.slice(0, dataSize));
continue;
}
if(i > maxIndex){
normalizedArray.push(...outputArray.slice(-dataSize, outputArray.length));
continue;
}
// それ以外の場合
// 面倒だったんですがまあこれでいいかなと
// ratioを出して長さ-1を掛ければ必然的に0~長さ-1にはなる
const properIndex = Math.floor(((i-minIndex)/(maxIndex-minIndex))*(inputArray.length-1));
// これでいいかと
normalizedArray.push(...outputArray.slice(properIndex*dataSize, (properIndex+1)*dataSize));
}
transformData[data.node][data.path] = normalizedArray;
}
// modelMat関連もカット
resultAnimation.transform = {
data:transformData, frameNum: maxFrame+1
}
}
result.push(resultAnimation);
}
return result;
}
animationsの中にanimationが一つずつ入っています。それぞれのanimationはsamplersとchannnelsからなり、これらは同じ長さです。samplerにはタイムライン上の時刻とそのときのtransformが入っています。channelにはアニメーションの操作対象と操作内容(回転、スケール、平行移動、その他)についての情報が入っています。たとえばシェイプキーアニメーションの場合ここ(path)は「weights」になりますが今回それは想定しないので省いています。そういうコードも書いたので参考までに:
flamingo_beta
後半は若干インチキな内容になっています。Blenderはデフォルトのフレームレートが24なので、時刻に関するデータが1/24秒刻みで用意されています。そこで24倍してroundを取って整数とし、フレームごとのトランスフォームを用意して配列としています。開始フレームは0固定で、終了フレームはアニメーションの終わりの最大値を取っています。こうしないとループさせられないからです。
今回はすべてボーンが対象なので、ノード番号を記録しておきます。これは後で使います。
スキンに関するデータを取得する
skinDataの解読
function getSkinsData(data, buf){
const {skins, accessors, bufferViews} = data;
// 若干ここをいじる必要がある
// skinごとにjointsとibmデータが入ってるわけだが
// それを16ずつ分割してibmを作る
// あとnodeでjointsのあれを取得
// それで以ってskinDataとし
// skinDataの配列でもってresultとする
const result = [];
for(const skin of skins){
const skinData = [];
const skinAccessor = accessors[skin.inverseBindMatrices];
const skinArrayData = getArrayData(
buf, skinAccessor, bufferViews[skinAccessor.bufferView]
);
for(let i=0; i<skin.joints.length; i++){
const m = skinArrayData.slice(i*16, (i+1)*16);
// 登録時に転置してしまう
const ibm = new p5.Matrix([
m[0], m[4], m[8], m[12],
m[1], m[5], m[9], m[13],
m[2], m[6], m[10], m[14],
m[3], m[7], m[11], m[15]
]);
skinData.push({
ibm:ibm,
node:skin.joints[i]
});
}
result.push(skinData);
}
return result;
}
ここでいうスキンとはひとつのアーマチュアに属するボーンの集合のことだそうです。互いにparentでつながっているユニオンなので、そこから外れたものは入っていません。jointsに入っているのはノード番号です。それと同じ長さの配列として、inverseBindMatricesという行列データが入っています。これは、そのボーンにトランスフォームの原点を移動させるためのもので、最終的な変換行列の構成の際に重要な役割を果たします。
これを作るのにp5.Matrixを用いています。
p5.Matrixと掛け算の順序について
自分は行ベースで行列を考えないと混乱するので、配列が行列オブジェクトに入った時は行で並ぶものと想定して扱っています(glslは列ベースなので駄目なのはわかってますが)。そこは統一しないと、頭の中で掛け算を実行する際に混乱するので、しっかりと決めておいた方がいいです。
このプログラムに関して言うと、必要なのは対象を左から掛ける演算です。
// p5.Matrixを使う。
// 一応行ベースで考えるとしますね
const m0 = new p5.Matrix([1,1,1,1, 0,1,1,1, 0,0,1,1, 0,0,0,1]);
const m1 = new p5.Matrix([1,0,0,0, 1,1,0,0, 1,1,1,0, 1,1,1,1]);
// multは右から掛ける演算
const m2 = m0.copy().mult(m1); // 4,3,2,1,...
const m3 = m1.copy().mult(m0); // 1,1,1,1,...
// applyは左から掛ける演算
//const m2 = m0.copy().apply(m1);
//const m3 = m1.copy().apply(m0);
//console.log(m2.mat4); // 1,1,1,1,...
//console.log(m3.mat4); // 4,3,2,1,...
ここのパートでは三角行列を使って掛け算の順序を確認しています。頭の中で行ベースと列ベースのどっちなのか決めておかないと結果を解釈できないので注意してください。行ベースで考える場合、mult(m)は m を右から掛ける演算、apply(m)は m を左から掛ける演算ということになります。ほしいのはapplyなのでこっちを採用します。これはイメージ的には、a.apply(b)の作用は a を作用させてから b を作用させる、となるものです。
スキンとアニメーションから行列を構成する
ボーンが頂点を動かす仕組みをざっくり説明します。詳しくは:
スキンメッシュアニメーション(ボーン操作)
頂点ごとに、それを操作するボーンが最大4つ、指定されています。ボーンが1つなら、そのボーンに登録されたトランスフォームと全く同じように変形しますが、注意点が2点あります。まずトランスフォームはそのボーンを原点とするものであること。次に、そのボーンの親のボーンの影響を受けるということです。つまりそのボーンが動かなくても親が動けば動くということです。また複数ある場合は、ウェイトという0~1の係数で重み付き平均を取ります(和が1)。なので、これらを考慮したうえで正確なトランスフォームを計算する必要があります。それがjointMatrixです。
// じゃあ手始めにdeltaMatricesArray作りますね
for(let i=0; i<BONE_NUM; i++){
const bone = bones[i];
const nd = result.nodes[bone.node];
const tf = tfData[bone.node];
let _t, _r, _s;
if(tf === undefined){
_t = [];
_r = [];
_s = [];
}else{
_t = tf.translation;
_r = tf.rotation;
_s = tf.scale;
}
if(_t.length === 0){
for(let k=0; k<FRAME_NUM;k++){
_t.push(...(nd.translation !== undefined ? nd.translation : [0,0,0]));
}
}
if(_r.length === 0){
for(let k=0; k<FRAME_NUM;k++){
_r.push(...(nd.rotation !== undefined ? nd.rotation : [0,0,0,1]));
}
}
if(_s.length === 0){
for(let k=0; k<FRAME_NUM;k++){
_s.push(...(nd.scale !== undefined ? nd.scale : [1,1,1]));
}
}
// あとはスライスしてまとめるだけ
for(let k=0; k<FRAME_NUM; k++){
deltaMatricesArray[k].push(createTransform({
t:_t.slice(k*3,(k+1)*3), r:_r.slice(k*4,(k+1)*4), s:_s.slice(k*3,(k+1)*3)
}));
}
}
ボーンの親子関係を考慮したうえでの、各フレームのトランスフォームを計算しています。これをdeltaMatrixと名付けました。親のボーンを原点とするそのボーンのトランスフォームを決定しているので、イメージ的にデルタ(差分)です。たとえば前のボーンが動いているなら単位行列にはなりません。これらから最終的なトランスフォームを計算するのが次のコードです。
// node -> joint
const jointMap = new Array(result.nodes.length);
for(let i=0; i<BONE_NUM; i++){
jointMap[bones[i].node] = i;
}
// これを元にあれを作る
for(let i=0; i<BONE_NUM; i++){
const bone = bones[i];
const nd = result.nodes[bone.node];
// ibmは転置済みなのでそのまま使う
const ibm = bone.ibm;
for(let k=0; k<FRAME_NUM; k++){
const deltaMatrices = deltaMatricesArray[k];
// 起点はibmで、deltaMatricesを自分から親に向かって掛けていく
const m = ibm.copy().apply(deltaMatrices[i]);
let currentNode = nd;
while(currentNode.parent !== undefined){
const nextNode = result.nodes[currentNode.parent];
const jointIndex = jointMap[currentNode.parent];
if(jointIndex === undefined) break;
// ここmultMat4じゃなくてmultなのね
m.apply(deltaMatrices[jointIndex]);
currentNode = nextNode;
}
jointMatricesArray[k].push(...m.mat4);
}
}
はじめにjointsからnode番号を参照できるようにします。各ボーンの最終的なトランスフォームは次のように計算します。まずibm(inverseBindMatrix)で操作の原点をボーンに移します。次に自分のdeltaMatrixで自分基準でのトランスフォームを適用、それ以降は親、親、と根っこまでさかのぼっていきます。ここの処理は計算が重複していて無駄が多いのですが最適化をサボっています(メインパフォーマンスに影響は無いので)。
メッシュとアトリビュートを用意する
アトリビュートは頂点と法線と面情報の他、影響を受けるボーンのインデックス(最大4つ、vec4)と、ウェイト(該当するボーンのウェイト、同じく4つ)が入っています。これらをp5.Geometryに格納していきます。
const _gl = this._renderer;
// カスタムアトリビュートを使ってaJointとaWeightを登録
_gl.retainedMode.buffers.fill.push(new p5.RenderBuffer(4, "joints", "jointsdst", "aJoint", _gl));
_gl.retainedMode.buffers.fill.push(new p5.RenderBuffer(4, "weights", "weightsdst", "aWeight", _gl));
// primitiveの情報からメッシュの情報を取得する
const positionData = result.obj[0].primitives[0].POSITION.data;
const normalData = result.obj[0].primitives[0].NORMAL.data;
const faceData = result.obj[0].primitives[0].indices.data;
const jointData = result.obj[0].primitives[0].JOINTS_0.data;
const weightData = result.obj[0].primitives[0].WEIGHTS_0.data;
geom = new p5.Geometry();
geom.joints = [];
geom.weights = [];
for(let i=0; i<positionData.length/3; i++){
geom.vertices.push(createVector(...positionData.slice(3*i, 3*i+3)));
geom.vertexNormals.push(createVector(...normalData.slice(3*i, 3*i+3)));
geom.joints.push(...jointData.slice(4*i, 4*i+4));
geom.weights.push(...weightData.slice(4*i, 4*i+4));
}
geom.faces = [];
for(let k=0; k<faceData.length/3; k++){
geom.faces.push(faceData.slice(3*k, 3*(k+1)));
}
スライスしてcreateVector()を使えばベクトルにできますね。ところでp5.Geometryにはjointsやweightsのような配列は入っていません。これらはカスタムアトリビュートなのでデフォルトでは用意されていません。そこで裏技を使います。
additional attr (fill)
// カスタムアトリビュートを使ってaJointとaWeightを登録
_gl.retainedMode.buffers.fill.push(new p5.RenderBuffer(4, "joints", "jointsdst", "aJoint", _gl));
_gl.retainedMode.buffers.fill.push(new p5.RenderBuffer(4, "weights", "weightsdst", "aWeight", _gl));
第一引数にサイズを指定します。vec4なので 4 です。その次はp5.Geometryで使う配列名です。その次の文字列は、直接使用されることはありませんが、後ろにdstを付けておけば問題ないです。次にshader内で使うattribute名を設定します。最後にp5.jsのwebglのレンダラーを指定します。以上です。
(ちなみにもうひとつ指定が可能で、たとえばベクトル列をフラットな数値の配列に変換するようなオプションが指定できます。今回は不要ですが。verticesなどでこれが使われています。sayoさんの次のスケッチが参考になるかと思います:
Morphing Parade
これも誰かがそのうち枠組みを整えてくれることでしょう。)
カメラを用意する
p5.jsのカメラはy軸が逆方向を向いていて、BlenderやUnityの座標系と向きが逆になっています。そのため普通に再生しようとすると上下が逆転してしまいます。これを解消するためにfrustumを使います。
// blenderと同じ見た目にするため、まずy軸正方向を上に取る
camera(12,12,16, 0,0,0, 0,1,0);
const eyeDist = dist(12,12,16, 0,0,0);
// soldierの場合はこっちを使ってください
//camera(200, 200, 200, 0, 0, 100, 0, 0, 1);
//const eyeDist = dist(200,200,200,0,0,100);
// そのうえでfrustumモードを使い、上下を逆転させ、射影行列の-1を殺す。
const nearPlaneHeightHalf = eyeDist*tan(PI/6)*0.1;
const nearPlaneWidthHalf = nearPlaneHeightHalf*width/height;
// ここの第3,4引数。
frustum(-nearPlaneWidthHalf, nearPlaneWidthHalf, nearPlaneHeightHalf, -nearPlaneHeightHalf, 0.1*eyeDist, 10*eyeDist);
perspective()と同じ設定で数値を用意した後、上下だけ逆にすることで、射影行列が補正されて同じ見た目になります。ただこれをやるとorbitControl()の上下の回転が逆になってしまうので、そこをdraw()内で補正します。
// orbitControlはインチキの弊害で上下の操作方向が
// 逆になっているので、補正をかけてごまかす。
orbitControl(1,-1,1);
frustumはnearclipのところの矩形の形でカメラを指定する指定方法です。現状、BlenderやUnityとの射影行列のずれによるゆがみを補正するにはこの方法しかありません(なおProcessingはy軸が逆どころか原点も左上になっているようです)。
描画のためのシェーダーを用意する
シェーダーを用意します。jointMatrixとweightで重み付き平均を取るために、ちょっとだけ細工します。
// あとはカスタムアトリビュートでいじるだけ
const sh = baseMaterialShader();
// vertexDeclarationsでいじる
myShader = sh.modify({
vertexDeclarations:`
mat4 skinMatrix;
uniform mat4 uJointMatrices[${BONE_NUM}];
IN vec4 aJoint;
IN vec4 aWeight;
`,
'void beforeVertex':`(){
skinMatrix = aWeight.x * uJointMatrices[int(aJoint.x)];
skinMatrix += aWeight.y * uJointMatrices[int(aJoint.y)];
skinMatrix += aWeight.z * uJointMatrices[int(aJoint.z)];
skinMatrix += aWeight.w * uJointMatrices[int(aJoint.w)];
}`,
'vec3 getLocalPosition': `(vec3 position) {
return (vec4(position, 1.0) * skinMatrix).xyz;
}`,
'vec3 getLocalNormal': `(vec3 normal){
return (vec4(normal, 0.0) * inverse(transpose(skinMatrix))).xyz;
}`
});
baseMaterialShader()でデフォルトシェーダーを取得できます。vertexDeclarationsを使うとuniformやattributeを追加することができます。uniformとしてはjointMatrices[BONE_NUM]で、attributeとしてはaJointとaWeightです。さっき指定しましたね。
また、グローバルでskinMatrixを用意しておきます。
beforeVertexはその直下の処理です。ここでskinMatrixを設定します。これはトランスフォームの重み付き平均です。厳密にはトランスフォーム行列の属す空間は線形ではないのですが、まあ特に問題ないということでしょう(ふしぎ!)。
これを用いてlocalPositionとlocalNormalを計算します。これらがattributeの代わりとして用いられます。もし描画サイズを変更したい場合、その手の処理はこれ以降となります。すなわち、通常のscale()やtranslate()でサイズや位置を変更できます。
modify()で処理を完結させられるようなので書き換えました。レファレンスにはvertexDeclarationsなんて出てこないんですが。誰かが補足してくれるといいですね。
描画する
draw()
function draw() {
// orbitControlはインチキの弊害で上下の操作方向が
// 逆になっているので、補正をかけてごまかす。
orbitControl(1,-1,1);
background(0);
shader(myShader);
myShader.setUniform()
const phaseIndex = (Math.floor(frameCount*0.5))%FRAME_NUM;
myShader.setUniform("uJointMatrices", jointMatricesArray[phaseIndex]);
lights();
fill(255);
model(geom);
}
元のアニメーションは24fpsなので直接60fpsで実行すると速すぎてしまいます。なので調整を施しています。あとは毎フレーム、行列配列を送り込むだけですね。ほんとは補間とかしないといけないんですがサボっています。そもそもフレームごと取得する際に1/60刻みでやればいいのですが、1/24の方が少ないし、どうせ線形補間でそれっぽくなるんで。その辺りはいくらでも改善できるかと思います。
以上です。
おまけ:Three.jsのsoldierを走らせる
Three.jsのsoldierを動かすことができます。ThreeはMixamoというサイトからこれを利用しているようです。
Mixamo
function preload(){
testGLTF = loadJSON("https://inaridarkfox4231.github.io/resources/soldier_0.gltf");
}
これはThreeのアセット:
Soldier.glb
を自前のBlenderに落としてgltfの埋め込みで落としたものです。カメラはこうします。
// soldierの場合はこっちを使ってください
camera(200, 200, 200, 0, 0, 100, 0, 0, 1);
const eyeDist = dist(200,200,200,0,0,100);
これで動きます。textureについて...gltfにはtexture画像の情報もバイトコードをbase64にする形で格納されています。それを解読する技術が無いので、無理ですね。
一応Blender上で無理やり画像を取得してtextureCoordで貼り付けてみました(インチキ!):
soldier skin
...と思ったんですが、できましたね。これについてはこっちを参照してください。この記事の作成時点ではできてなかったのですが。
p5.jsでgltfファイルを読み込んでテクスチャ画像を貼り付けて表示する
おわりに
gltfのローディングはいくらでも便利な方法があるので、きちんと動かしたい場合はそちらをご利用ください。ここまでお読みいただいてありがとうございました。
Three.jsのgltfのサンプル:webgpu_skinning
蛇足
上に挙げたMichelleさんが踊ってるのを作りました。
Michelle by Mixamo
かなり大きいです。3メガあります。それでも一応動きます(カメラの調整が必要ですが)。ただ、自分のスマホではuniform配列が長すぎて動かなかったので、内容をfloatTextureに焼いてそこから参照して行列を作る方法でVTFと組み合わせて実行しました。現行のp5では厳しいかもしれないです。
参考にしたサイト
gltf形式の基本:3Dモデル表現形式glTFについて
mebiusboxさんのgltf覚え書き:glTF 覚え書き
ライブラリを用いないでgltfを読み込む記事:glTF 2.0 サンプルをライブラリを使わずに読み込んでみるテスト
atobとbtoaについて:JavaScriptのバイナリー文字列とatob、btoaの関係について
vertexShaderにおける処理の概要(公式記事):The joint matrices
vertex skinningの覚え書き:vertex skinning の覚え書き
スキンメッシュアニメーション:スキンメッシュアニメーション(ボーン操作)
クォータニオンから回転行列を作る:blenderのクォータニオンについて
baseMaterialShaderの使い方:baseMaterialShader()
カスタムアトリビュートの設定方法:additional attr (fill)
スキンメッシュアニメーションの基本:アーマチュアによるアニメーション
画像データをbase64から取得する:バイナリデータをHTMLで画像表示する方法