🐍

p5.jsでgltfを再生する

2024/12/10に公開

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