WebGPUではじめる3DCGアニメーション入門 13

新たに「ボーン(骨)」を実装したボーンモデルクラスを作成してみよう

第13回の今回は、ボーンアニメーションするためにキャラクターに「ボーン(骨)」を埋め込む解説をします。

大西 武 (オオニシ タケシ)

1月7日 6:30

はじめに

今回は、キャラクターモデルとしてダウンロードした「webgpu.zip」に入っている「models/Character.js」データを使います。まずは「ボーン(骨)」をセットしますが、今回は図1のように初期位置のままでアニメーションはせず、T字ポーズをとるだけです。

T字ポーズのボーンキャラクター

アニメーションなしのボーンを実装する

今回は、新たに「BoneModel3D」クラスが登場します。「Model3D」クラスにボーン機能を肉付けしたものです。便宜上、できるだけ省略せずにサンプルコードを書いたため、今までと比べてもかなり長くなりましたが、追加するコードはそれほど多くないと思います。BoneModel3DクラスはModel3Dクラスをコピペしたところも多いです。

パイプラインを実装する

前回までのようにメインコードとシェーダーとを綱渡しするパイプラインで、ボーン番号を表す4byte*1個も追加します。またユニフォームバッファに16(4×4行列の要素数)*32ボーンのオフセットも足します。32ボーンという数は体・左右手足・頭など骨の数を適当に最大32個と決めました。

・サンプルコード「lib」→「UltraMotion3D.js」
var _lastTime = 0;
var _elapsed = 0;
var _num = 0;
var _camera = new Matrix3D();
var _fov = 45.0;
const MAX_NUM = 10000;
const BONE_MAX = 32;
const UNIFORM_BUFFER_SIZE = 4 * 16 * (4 + BONE_MAX); // 4byte(float32) * 4x4 matrix * (4 + 32)
const OFFSET_SIZE = UNIFORM_BUFFER_SIZE; //256の倍数
var _passEncoder = null;

async function initWebGPU(canvas) {
  if (!navigator.gpu) {
    throw Error('WebGPU not supported.');
  }
  const adapter = await navigator.gpu.requestAdapter();
  if (!adapter) {
    throw Error('Could not request WebGPU adapter.');
  }
  _device = await adapter.requestDevice();
  _canvas = document.getElementById(canvas);
  _context = _canvas.getContext('webgpu');
  _presentationFormat = navigator.gpu.getPreferredCanvasFormat();
  _context.configure({
    device: _device,
    format: _presentationFormat,
    alphaMode: 'premultiplied',
  });
  _pipeline = setPipeline(vertWGSL,fragWGSL);
  _pipelineTexture = setPipeline(vertWGSL,fragTextureWGSL);
  _pipelineBone = setPipeline(vertBoneWGSL,fragWGSL);
  _pipelineBoneTexture = setPipeline(vertBoneWGSL,fragTextureWGSL);
  _uniformBuffer = _device.createBuffer({
    size: UNIFORM_BUFFER_SIZE + OFFSET_SIZE * MAX_NUM,
    usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
  });
  window.addEventListener("resize", resize, false);
	resize();
  init();
  requestAnimationFrame(everyFrame.bind(everyFrame));
}

function setPipeline(vertexWGSL,fragmentWGSL) {
  const pipeline = _device.createRenderPipeline({
  layout: 'auto',
  vertex: {
    module: _device.createShaderModule({
      code: vertexWGSL,
    }),
    entryPoint: 'main',
    buffers: [{
      attributes: [
        {// position
          shaderLocation: 0,
          offset: 0,
          format: 'float32x4',
        },{// normal
          shaderLocation: 1,
          offset: 4*4,
          format: 'float32x3',
        },{// color
          shaderLocation: 2,
          offset: 4*(4+3),
          format: 'float32x4',
        },{// specular
          shaderLocation: 3,
          offset: 4*(4+3+4),
          format: 'float32',
        },{// uv
          shaderLocation: 4,
          offset: 4*(4+3+4+1),
          format: 'float32x2',
        },{// bone
          shaderLocation: 5,
          offset: 4*(4+3+4+1+2),
          format: 'float32',
        },
      ],
      arrayStride: 4*(4+3+4+1+2+1),
      stepMode: "vertex",
    },],
  },
  fragment: {
    module: _device.createShaderModule({
      code: fragmentWGSL,
    }),
    entryPoint: 'main',
    targets: [// 0
      {
        format: _presentationFormat,
      },
    ],
  },
  primitive: {
    topology: 'triangle-list',
  },
  depthStencil: {
    depthWriteEnabled: true,
    depthCompare: 'less',
    format: 'depth24plus',
  },
});
  return pipeline;
}
(後略)

【サンプルコードの解説】
「_lastTime」変数は1フレーム前の時間、「_elapsed」変数は経過時間です。60FPSでだいたい1/60の値になります。
「BONE_MAX」定数は32個のボーン数です。
「UNIFORM_BUFFER_SIZE」は1つのモデル(Model3Dクラス)、または1つのボーンモデル(BoneModel3Dクラス)ごとに必要となるユニフォームバッファのサイズです。
パイプラインでは「shaderLocation」の5番目にオフセットを4*(4+3+4+1+2)に設定し、ボーン番号を渡します。
「arrayStride」で1頂点ごとに「4*(4+3+4+1+2+1)」の要素数だけ頂点データを渡します。

BoneModel3Dクラスを実装する

新たに「lib/BoneModel3D.js」ファイルを作成します。BoneModel3DクラスはModel3Dクラスを継承したクラスで、Model3Dクラスのモデルデータにボーンとアニメーションデータを追加します。

キャラクターデータの「Character」クラスはBoneModel3Dクラスを継承したクラスで、新たに「setB」メソッド、「setBI」メソッド、「setMatrix」メソッド、「setMatri2」メソッド、「setMatrix3」メソッドが出てきます。

・サンプルコード「lib」→「BoneModel3D.js」
class BoneModel3D extends Model3D {
  constructor() {
    super();
  }
  setB(x,y,z,nx,ny,nz,material,u,v,bone,parent,weight) {
    this.preVertices.push(x,y,z,nx,ny,nz,material,u,v,bone);
  }
  setBI(i0,i1,i2) {
    var indices = [];
    indices.push(i0,i1,i2);
    for ( let i = 0; i < indices.length; i++ ) {
      const  j = indices[i];
      const  x = this.preVertices[j*10  ];
      const  y = this.preVertices[j*10+1];
      const  z = this.preVertices[j*10+2];
      const nx = this.preVertices[j*10+3];
      const ny = this.preVertices[j*10+4];
      const nz = this.preVertices[j*10+5];
      const  m = this.preVertices[j*10+6];
      const  r = this.materials[m].r;
      const  g = this.materials[m].g;
      const  b = this.materials[m].b;
      const  a = this.materials[m].a;
      const  s = this.materials[m].spc;
      const  u = this.preVertices[j*10+7];
      const  v = this.preVertices[j*10+8];
      const bn = this.preVertices[j*10+9];
      this.vertices.push(x,y,z,1,nx,ny,nz,r,g,b,a,s,u,v,bn);
    }
  }
  setMatrix(matrix) {
  }
  setMatrix2() {
  }
  setMatrix3(materials) {
  }
  initBuffers() {
    this.vertexArray = new Float32Array(this.vertices);
    this.verticesBuffer = _device.createBuffer({
      size: this.vertexArray.byteLength,
      usage: GPUBufferUsage.VERTEX,
      mappedAtCreation: true,
    });
    new Float32Array(this.verticesBuffer.getMappedRange()).set(this.vertexArray);
    this.verticesBuffer.unmap();
    if ( this.materials[0].texture ) this.initTexture();
    else this.init();
  }
  async init() {
    this.uniformBindGroup = _device.createBindGroup({
      layout: _pipelineBone.getBindGroupLayout(0),
      entries: [
        {
          binding: 0,
          resource: {
            buffer: _uniformBuffer,
            offset: OFFSET_SIZE*this.num,
            size: UNIFORM_BUFFER_SIZE,
          },
        },
      ],
    });
  }
  async initTexture() {
    let imageTexture = await loadTexture(this.materials[0].texture);
    const sampler = _device.createSampler({
      magFilter: 'linear',
      minFilter: 'linear',
    });
    this.uniformBindGroup = _device.createBindGroup({
      layout: _pipelineBoneTexture.getBindGroupLayout(0),
      entries: [
        {
          binding: 0,
          resource: {
            buffer: _uniformBuffer,
            offset: OFFSET_SIZE*this.num,
            size: UNIFORM_BUFFER_SIZE,
          },
        },{
          binding: 1,
          resource: sampler,
        },{
          binding: 2,
          resource: imageTexture.createView(),
        },
      ],
    });
  }
  draw() {
    if (this.uniformBindGroup) {
      if ( this.materials[0].texture ) _passEncoder.setPipeline(_pipelineBoneTexture);
      else _passEncoder.setPipeline(_pipelineBone);
      this.setTransform();
      this.setAnimation();
      _passEncoder.setBindGroup(0,this.uniformBindGroup);
      _passEncoder.setVertexBuffer(0,this.verticesBuffer);
      _passEncoder.draw(this.getVertexCount());
    }
  }
  setAnimation() {
    let matrices = new Float32Array(BONE_MAX*16);
    let matrix = new Matrix3D();
    for (let i = 0; i < 16*BONE_MAX; i++ ) matrices[i] = matrix.e[i%16];
    _device.queue.writeBuffer(
      _uniformBuffer,
      4 * 16 * 4 + OFFSET_SIZE*this.num,
      matrices.buffer,
      0,
      4 * 16 * BONE_MAX
    );
  }
}

【サンプルコードの解説】
setBメソッドでボーン付き頂点データを1頂点ずつ追加します。
setBIメソッドではボーン付き頂点を三角形の3頂点分のインデックスで指定し、それらを「vertices」配列に追加します。
「init()」メソッドでは「createBindGroup」の「layout」に「_pipelineBone」パイプラインを使用します。一方、「initTexture()」メソッドではcreateBindGroupのlayoutに「_pipelineBoneTexture」パイプラインを使用します。
「setMatrix」メソッド、「setMatrix2」メソッド、「setMatrix3」メソッドは暫定的に用意しているだけで何もしません。
「setAnimation」関数では、この回だけ暫定的に「1次元」の「matrices」配列にBONE_MAX(32個)×行列の要素数(16個)のボーン行列を入れます。モデル・ビュー・プロジェクション・法線行列の後ろ(オフセットが4 * 16 * 4 + OFFSET_SIZE*this.num)にボーン行列の配列をセットします。今回のボーン行列は全て単位行列(Matrix3Dクラスのコンストラクタの初期値)です。

Model3Dクラスを実装する

シェーダーのパイプライン設定を使い回せるように、静止モデルのModel3Dクラスでも頂点データにボーン番号0(意味は特にありません)を追加します。これにより、1頂点あたりの要素数が15個(x,y,z,1,nx,ny,nz,r,g,b,a,s,u,v,bn)になるため、「getVertexCount」メソッドでは 「vertexArray」の要素数を15で除算して頂点数を求めます。

・サンプルコード「lib」→「Model3D.js」
class Model3D {
(中略)
  setI(i0,i1,i2) {
    var indices = [];
    indices.push(i0,i1,i2);
    for ( let i = 0; i < indices.length; i++ ) {
      const  j = indices[i];
      const  x = this.preVertices[j*9  ];
      const  y = this.preVertices[j*9+1];
      const  z = this.preVertices[j*9+2];
      const nx = this.preVertices[j*9+3];
      const ny = this.preVertices[j*9+4];
      const nz = this.preVertices[j*9+5];
      const  m = this.preVertices[j*9+6];
      const  r = this.materials[m].r;
      const  g = this.materials[m].g;
      const  b = this.materials[m].b;
      const  a = this.materials[m].a;
      const  s = this.materials[m].spc;
      const  u = this.preVertices[j*9+7];
      const  v = this.preVertices[j*9+8];
      const bn = 0;
      this.vertices.push(x,y,z,1,nx,ny,nz,r,g,b,a,s,u,v,bn);
    }
  }
  initBuffers() {
(中略)
  }
  async init() {
(中略)
  }
  async initTexture() {
(中略)
  }
  getVertexCount() {
    return ~~(this.vertexArray.length/15);
  }
(中略)
}

シェーダーを実装する

Model3Dクラス用の「vertWGSL」定数では、パイプライン定義を揃えるために頂点シェーダーのmain関数へボーン番号の引数だけを形式的に追加します。一方、BoneModel3Dクラス用の「vertBoneWGSL」定数はvertWGSL定数とほぼ同じ構成ですが、ボーン変形のための行列計算を行う点が異なります。「fragWGSL」定数と「fragTextureWGSL」定数は前回までと同じ内容です。

・サンプルコード「lib」→「WGSL.js」
const vertWGSL = `
struct Uniforms {
  projectionMatrix : mat4x4f,
  viewMatrix : mat4x4f,
  worldMatrix : mat4x4f,
  normalMatrix : mat4x4f,
}
@group(0) @binding(0) var uniforms : Uniforms;

struct VertexOutput {
  @builtin(position) position : vec4f,
  @location(0) normal : vec3f,
  @location(1) color : vec4f,
  @location(2) specular : f32,
  @location(3) uv : vec2f
}

@vertex
fn main(
  @location(0) position: vec4f,
  @location(1) normal: vec3f,
  @location(2) color: vec4f,
  @location(3) specular: f32,
  @location(4) uv : vec2f,
  @location(5) bone : f32
) -> VertexOutput {
  var output : VertexOutput;
  output.position = uniforms.projectionMatrix *
    uniforms.viewMatrix * uniforms.worldMatrix * position;
  output.normal = (uniforms.normalMatrix * vec4f(normal,1)).xyz;
  output.color = color;
  output.specular = specular;
  output.uv = uv;
  return output;
}
`;
const vertBoneWGSL = `
struct Uniforms {
  projectionMatrix : mat4x4f,
  viewMatrix : mat4x4f,
  worldMatrix : mat4x4f,
  normalMatrix : mat4x4f,
  boneMatrix : array,
}
@group(0) @binding(0) var uniforms : Uniforms;

struct VertexOutput {
  @builtin(position) position : vec4f,
  @location(0) normal : vec3f,
  @location(1) color : vec4f,
  @location(2) specular : f32,
  @location(3) uv : vec2f
}

@vertex
fn main(
  @location(0) position: vec4f,
  @location(1) normal: vec3f,
  @location(2) color: vec4f,
  @location(3) specular: f32,
  @location(4) uv : vec2f,
  @location(5) bone : f32
) -> VertexOutput {
  var output : VertexOutput;
  var matrix = uniforms.boneMatrix[u32(bone)];
  output.position = uniforms.projectionMatrix *
    uniforms.viewMatrix * uniforms.worldMatrix * matrix * position;
  var n = uniforms.normalMatrix;
  var n3x3 = mat3x3f(n[0].xyz,n[1].xyz,n[2].xyz);
  var m3x3 = mat3x3f(matrix[0].xyz,matrix[1].xyz,matrix[2].xyz);
  output.normal = n3x3 * m3x3 * normal;
  output.color = color;
  output.specular = specular;
  output.uv = uv;
  return output;
}
`;
const fragWGSL = `
(中略)
`;
const fragTextureWGSL = `
(中略)
`;

【サンプルコードの解説】
現在のボーン(「bone」引数を整数化したもの)の行列をユニフォームバッファで取得し、モデル・ビュー・プロジェクション行列とともに頂点(position)に乗算して変形します。
法線行列にボーン変形行列も乗算したものを法線(normal)に乗算します。

実行ファイルindex.htmlファイル

それでは、次のサンプルコードをコーディングして「Google Chrome」で「index.html」を実行します。scriptタグで「BoneModel3D.js」ファイルと「Character.js」ファイルを読み込みます。「_model」変数には「Character」クラスのインスタンスを代入します。

・サンプルコード「index.html」
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title>UltraMotion3D</title>
    <meta name="viewport" content="width=device-width">
    <script src="lib/Material.js"></script>
    <script src="lib/Vector3D.js"></script>
    <script src="lib/Matrix3D.js"></script>
    <script src="lib/Model3D.js"></script>
    <script src="lib/BoneModel3D.js"></script>
    <script src="lib/WGSL.js"></script>
    <script src="lib/UltraMotion3D.js"></script>
    <script src="models/Character.js"></script>
    <script type="text/javascript">
var _model;
  
function init() {
  _model = new Character();
}

function draw() {
   _camera.lookAt(new Vector3D(0,200,500),new Vector3D(0,200,0),new Vector3D(0,1,0))
  _model.draw();
}
    </script>
  </head>
  <body onload='initWebGPU("CanvasAnimation");'>
    <canvas id="CanvasAnimation" width="1800" height="900"></canvas>
  </body>
</html>
【コラム】「今やるべきこと」

「今やるべきことは何か」を考えてみました。そうしたら「それを見つけるために考えること」が今やるべきことだと閃きました。

おわりに

今回は、前回と変わり映えしない実行結果ですが、ボーン(骨)を埋め込みました。まだユニフォームバッファに渡すボーン行列がどれも単位行列だったので、ポーズには何の変化もありませんでした。

次回は、ついにこの連載も最終回となります。今回はボーンを配置しただけでT字ポーズを取っただけですが、次回はフレームが進むごとにポーズを切り替えていき、アニメーションしているように見せてみます。

この記事のキーワード

この記事をシェアしてください

人気記事トップ10

人気記事ランキングをもっと見る

企画広告も役立つ情報バッチリ! Sponsored