🐍
p5.jsでgltfを再生する
p5.jsでgltfのアニメーション部分だけを再生したいと思ってコードを書きました。
リンク:OpenProcessing
内容的にはbase64形式の部分のパース、それによるメッシュデータとアニメーションデータの解析、さらにp5.MatrixによるjointMatricesの生成、さらにそれをshaderに落とし込んでattributeを追加してそれによりアニメーションを再生するというものです。
コード全文
// baseMaterialShader.
/*
参考にしたサイト
gltf形式の基本:https://qiita.com/hyaguchi947d/items/d68e3d08d34951102d0a
mebiusboxさんの覚書:https://zenn.dev/mebiusbox/articles/78d84deeb6e531
gltfをライブラリを使わずに読み込んでみよう:https://qiita.com/cx20/items/2b86cb5052cd7c36038a
atobとbtoaについての分かりやすい記事:https://qiita.com/mouseofmicky/items/ab6340a2a4634bee723d
公式によるvertexShaderの処理概要:https://github.com/KhronosGroup/glTF-Tutorials/blob/main/gltfTutorial/gltfTutorial_020_Skins.md#the-joint-matrices
vertex skinningの覚書:https://tkaaad97.hatenablog.com/entry/2019/07/28/175737
スキンメッシュアニメーション:http://marupeke296.com/DXG_No27_SkinMeshAnimation.html
クォータニオンから回転行列を作る:https://qiita.com/inaba_darkfox/items/8b215c4703be198f7c4a
baseMaterialShaderの使い方:https://p5js.org/reference/p5/baseMaterialShader/
カスタムアトリビュートの設定方法:https://openprocessing.org/sketch/1336931
スキンメッシュアニメーションの基本:https://blender3d.biz/simpleanimation3dcg_deformed_armatures.html
画像データをbase64から取得する:https://blog.deepblue-ts.co.jp/web/binary-to-image/
この場を借りて感謝申し上げます(自分の記事以外に対して)
*/
// 2024-12-02 若干リファクタリング
// 情報の追加がしやすくなっただけ。
// 出来ないのはキーフレームアニメくらい
let myGLTF;
let geom;
let myShader;
let deltaMatricesArray; // 差分tf用
let jointMatricesArray; // フレーム数分の行列配列の配列
let FRAME_NUM;
let BONE_NUM;
function preload(){
// myGLTF = loadJSON("boneCross_4.gltf");
myGLTF = loadJSON("https://inaridarkfox4231.github.io/resources/testSkinMesh_0.gltf");
//myGLTF = loadJSON("https://inaridarkfox4231.github.io/resources/soldier_0.gltf");
}
function setup() {
createCanvas(640, 640, WEBGL);
pixelDensity(1);
// 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,...
// 今回はapplyを活用します。
const {
nodes, arrayBuffers, objs, animations, skins
} = parseGLTF(myGLTF);
FRAME_NUM = animations[0].transform.frameNum;
// skinからboneを取得
const bones = skins[0];
//console.log(bones);
const tfData = animations[0].transform.data;
//console.log(tfData);
BONE_NUM = bones.length;
// 名称はjointMatricesの配列:jointMatricesArrayとします
// 各フレームにはjointの個数だけのjointMatrixが入ります
// aJointの値の変更はしません
deltaMatricesArray = new Array(FRAME_NUM); // 差分tf
jointMatricesArray = new Array(FRAME_NUM); // 累積*IBM
for(let i=0; i<FRAME_NUM; i++){
deltaMatricesArray[i] = [];
jointMatricesArray[i] = [];
}
// じゃあ手始めにdeltaMatricesArray作りますね
for(let i=0; i<BONE_NUM; i++){
const bone = bones[i];
const nd = 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)
}));
}
}
//console.log(deltaMatricesArray);
// node -> joint
const jointMap = new Array(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 = 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 = nodes[currentNode.parent];
const jointIndex = jointMap[currentNode.parent];
if(jointIndex === undefined) break;
// ここmultMat4じゃなくてmultなの??(そうだよ)
// だから最後に掛けるのは大元のdeltaMatrixなんだわ
m.apply(deltaMatrices[jointIndex]);
currentNode = nextNode;
}
//m.multMat4(ibm);
jointMatricesArray[k].push(...m.mat4);
}
}
// つまりイメージ的にはibm掛けて自分掛けて親掛けて...最後に大元を掛けるらしい
//console.log(jointMatricesArray[0]);
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 mesh = objs[0].primitives[0];
const positionData = mesh.POSITION.data;
const normalData = mesh.NORMAL.data;
const faceData = mesh.indices.data;
const jointData = mesh.JOINTS_0.data;
const weightData = mesh.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)));
}
// 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);
// あとはカスタムアトリビュートでいじるだけ
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;
}`
});
}
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);
}
// パーサー
function parseGLTF(data){
const nodes = setParents(data.nodes);
const arrayBuffers = getArrayBuffers(data);
const objs = getObjData(data, arrayBuffers);
const animations = getAnimationData(data, arrayBuffers);
const skins = getSkinsData(data, arrayBuffers);
return {nodes, arrayBuffers, objs, animations, skins};
}
// nodesに付加情報としてparentを付加する
function setParents(nodes){
// nodesの各オブジェクトにparentを設定する
for(let i=0; i<nodes.length; i++){
const eachNode = nodes[i];
if(eachNode.children === undefined)continue;
for(const child of eachNode.children){
nodes[child].parent = i;
}
}
return nodes;
}
// 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;
}
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;
}
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;
}
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;
}
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;
}
// 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そのままでもいいと思う
// 適宜内容変更して
function getInfo(accessor){
const result = {
type:accessor.type,
count:accessor.count,
componentType:getComponentType(accessor.componentType)
}
if (accessor.max !== undefined) { result.max = accessor.max; }
if (accessor.min !== undefined) { result.min = accessor.min; }
return result;
}
function createTransform(tf={}){
const s = createMatFromScale(tf.s);
const r = createMatFromRotation(tf.r);
const t = createMatFromTranslate(tf.t);
// p5.Matrixのapplyは左から掛ける演算なので
// 並べた順に作用する
// s,r,tの順で作用させるんでこれでいけますね
return s.apply(r).apply(t);
}
// スケール変換行列
function createMatFromScale(q){
let x,y,z;
if(typeof q === 'number'){
const args = arguments;
x = args[0] || 1;
y = args[1] || 1;
z = args[2] || 1;
}else if(Array.isArray(q)){
x = q[0] || 1;
y = q[1] || 1;
z = q[2] || 1;
}else{
x = q.x || 1;
y = q.y || 1;
z = q.z || 1;
}
return new p5.Matrix([
x, 0, 0, 0,
0, y, 0, 0,
0, 0, z, 0,
0, 0, 0, 1
]);
}
// 回転行列をクォータニオンから出す
function createMatFromRotation(q){
// まあそうね
// あれをそのまま、でいいよ。とりあえず。
let x,y,z,w;
if(typeof q === 'number'){
const args = arguments;
x = args[0] || 0;
y = args[1] || 0;
z = args[2] || 0;
w = args[3] || 1;
}else if(Array.isArray(q)){
x = q[0] || 0;
y = q[1] || 0;
z = q[2] || 0;
w = q[3] || 1;
}else{
x = q.x || 0;
y = q.y || 0;
z = q.z || 0;
w = q.w || 1;
}
return new p5.Matrix([
w*w+x*x-y*y-z*z, 2.0*(x*y-z*w), 2.0*(x*z+y*w), 0.0,
2.0*(x*y+z*w), w*w+y*y-x*x-z*z, 2.0*(y*z-x*w), 0.0,
2.0*(x*z-y*w), 2.0*(y*z+x*w), w*w+z*z-x*x-y*y, 0.0,
0, 0, 0, 1
]);
}
// 平行移動
function createMatFromTranslate(q){
let x,y,z;
if(typeof q === 'number'){
const args = arguments;
x = args[0] || 0;
y = args[1] || 0;
z = args[2] || 0;
}else if(Array.isArray(q)){
x = q[0] || 0;
y = q[1] || 0;
z = q[2] || 0;
}else{
x = q.x || 0;
y = q.y || 0;
z = q.z || 0;
}
return new p5.Matrix([
1, 0, 0, x,
0, 1, 0, y,
0, 0, 1, z,
0, 0, 0, 1
]);
}
Discussion