Three.js を使用して 100 万文字をアニメーション化する

Ilmari Heikkinen

はじめに

この記事の目標は、スムーズなフレームレートで画面に 100 万文字のアニメーション文字を描画することです。このタスクは、最新の GPU では非常に可能です。各文字は 2 つのテクスチャ付き三角形で構成されているため、1 フレームあたり 200 万個の三角形しか扱えません。

従来の JavaScript アニメーションを扱っている方は、こんな風に思われるかもしれません。1 フレームごとに 200 万個の三角形が更新されることは、今の JavaScript では実現できません。幸いなことに、WebGL によって最新の GPU の優れた機能を活用できます。最新の GPU とシェーダー マジックを使用すれば、200 万個の三角形をアニメーション化できます。

効率的な WebGL コードの記述

効率的な WebGL コードを記述するには、一定の考え方が必要です。WebGL を使用して描画する一般的な方法は、オブジェクトごとにユニフォーム、バッファ、シェーダーを設定してから、オブジェクトを描画する呼び出しです。この描画方法は、少数のオブジェクトを描画する場合に有効です。多数のオブジェクトを描画するには、WebGL の状態の変化量を最小限に抑える必要があります。まず、同じシェーダーを使用してすべてのオブジェクトを順番に描画します。これにより、オブジェクト間でシェーダーを変更する必要がなくなります。粒子のような単純なオブジェクトの場合は、複数のオブジェクトを 1 つのバッファにバンドルし、JavaScript を使用して編集することができます。こうすると、パーティクルごとにシェーダー ユニフォームを変更するのではなく、頂点バッファを再アップロードするだけですみます。

しかし、本当に高速に処理するためには、ほとんどの計算をシェーダーにプッシュする必要があります。それが、私がここでやろうとしていることです。シェーダーを使用して 100 万文字をアニメーション化します。

この記事のコードでは、Three.js ライブラリを使用しています。これにより、面倒なボイラープレートをすべて抽象化して WebGL コードを記述する必要がなくなります。Three.js を使用すれば、何百行もの WebGL の状態設定やエラー処理を記述する必要はなく、わずか数行のコードを記述するだけで済みます。また、Three.js から WebGL シェーダー システムを利用するのも簡単です。

1 回の描画呼び出しで複数のオブジェクトを描画する

次に、1 回の描画呼び出しで複数のオブジェクトを描画する方法の簡単な擬似コード例を示します。従来は、次のようにオブジェクトを 1 つずつ描画します。

for (var i=0; i<objects.length; i++) {
  // each added object requires a separate WebGL draw call
  scene.add(createNewObject(objects[i]));
}
renderer.render(scene, camera);

ただし、上記のメソッドでは、オブジェクトごとに個別の描画呼び出しが必要です。複数のオブジェクトを一度に描画するには、オブジェクトを 1 つのジオメトリにバンドルして、描画呼び出しを 1 回行うだけで済みます。

var geo = new THREE.Geometry();
for (var i=0; i<objects.length; i++) {
  // bundle the objects into a single geometry
  // so that they can be drawn with a single draw call
  addObjectToGeometry(geo, objects[i]);
}
// GOOD! Only one object to add to the scene!
scene.add(new THREE.Mesh(geo, material));
renderer.render(scene, camera);

基本的なアイデアがわかったところで、デモの作成に戻り、100 万文字のアニメーション化を開始しましょう。

ジオメトリとテクスチャのセットアップ

最初のステップとして、文字のビットマップを含むテクスチャを作成します。ここでは 2D キャンバスを使用します。結果として得られるテクスチャには、描画したいすべての文字が含まれています。次のステップでは、テクスチャ座標を含むバッファを文字スプライト シートに作成します。これは文字を設定する簡単でわかりやすい方法ですが、テクスチャ座標のために頂点ごとに 2 つの浮動小数点数を使用するため、少し無駄になります。簡潔な方法としては、レター インデックスとコーナー インデックスを 1 つの数値にまとめて、頂点シェーダーでテクスチャ座標に戻す方法があります。

Canvas 2D を使用して文字テクスチャを作成する方法は次のとおりです。

var fontSize = 16;

// The square letter texture will have 16 * 16 = 256 letters, enough for all 8-bit characters.
var lettersPerSide = 16;

var c = document.createElement('canvas');
c.width = c.height = fontSize*lettersPerSide;
var ctx = c.getContext('2d');
ctx.font = fontSize+'px Monospace';

// This is a magic number for aligning the letters on rows. YMMV.
var yOffset = -0.25;

// Draw all the letters to the canvas.
for (var i=0,y=0; y<lettersPerSide; y++) {
  for (var x=0; x<lettersPerSide; x++,i++) {
    var ch = String.fromCharCode(i);
    ctx.fillText(ch, x*fontSize, yOffset*fontSize+(y+1)*fontSize);
  }
}

// Create a texture from the letter canvas.
var tex = new THREE.Texture(c);
// Tell Three.js not to flip the texture.
tex.flipY = false;
// And tell Three.js that it needs to update the texture.
tex.needsUpdate = true;

三角形配列も GPU にアップロードします。これらの頂点は、頂点シェーダーで文字を画面上に配置するために使用されます。頂点はテキスト内の文字の位置に設定されているため、三角形配列をそのままレンダリングすると、テキストの基本的なレイアウト レンダリングが得られます。

書籍のジオメトリの作成:

var geo = new THREE.Geometry();

var i=0, x=0, line=0;
for (i=0; i<BOOK.length; i++) {
  var code = BOOK.charCodeAt(i); // This is the character code for the current letter.
  if (code > lettersPerSide * lettersPerSide) {
    code = 0; // Clamp character codes to letter map size.
  }
  var cx = code % lettersPerSide; // Cx is the x-index of the letter in the map.
  var cy = Math.floor(code / lettersPerSide); // Cy is the y-index of the letter in the map.

  // Add letter vertices to the geometry.
  var v,t;
  geo.vertices.push(
    new THREE.Vector3( x*1.1+0.05, line*1.1+0.05, 0 ),
    new THREE.Vector3( x*1.1+1.05, line*1.1+0.05, 0 ),
    new THREE.Vector3( x*1.1+1.05, line*1.1+1.05, 0 ),
    new THREE.Vector3( x*1.1+0.05, line*1.1+1.05, 0 )
  );
  // Create faces for the letter.
  var face = new THREE.Face3(i*4+0, i*4+1, i*4+2);
  geo.faces.push(face);
  face = new THREE.Face3(i*4+0, i*4+2, i*4+3);
  geo.faces.push(face);

  // Compute texture coordinates for the letters.
  var tx = cx/lettersPerSide, 
      ty = cy/lettersPerSide,
      off = 1/lettersPerSide;
  var sz = lettersPerSide*fontSize;
  geo.faceVertexUvs[0].push([
    new THREE.Vector2( tx, ty+off ),
    new THREE.Vector2( tx+off, ty+off ),
    new THREE.Vector2( tx+off, ty )
  ]);
  geo.faceVertexUvs[0].push([
    new THREE.Vector2( tx, ty+off ),
    new THREE.Vector2( tx+off, ty ),
    new THREE.Vector2( tx, ty )
  ]);

  // On newline, move to the line below and move the cursor to the start of the line.
  // Otherwise move the cursor to the right.
  if (code == 10) {
    line--;
    x=0;
  } else {
    x++;
  }
}

文字をアニメーション化する頂点シェーダー

シンプルな頂点シェーダーでは、テキストのフラットビューが表示されます。特別なことは一切ありません。その動作は問題ありませんが、アニメーション化したい場合は、JavaScript でアニメーション化する必要があります。また、600 万個の頂点をアニメーション化するには、JavaScript の処理に少し時間がかかります。フレームごとに処理する場合はなおさらです。もっと速くできる方法があるかもしれません。

なぜなら手続き型アニメーションも可能です。つまり、位置と回転の計算はすべて頂点シェーダーで行われます。これで、JavaScript を実行して頂点の位置を更新する必要がなくなります。頂点シェーダーは非常に高速に動作し、100 万個の三角形がフレームごとに個別にアニメーション化されていても、スムーズなフレームレートが得られます。個々の三角形に対処するために、頂点座標を切り捨てて、文字のクワッドの 4 つのポイントがすべて単一の一意の座標にマッピングされるようにします。この座標を使用して、対象の文字のアニメーション パラメータを設定できます。

座標を切り捨てるために、2 つの異なる文字の座標が重ならないようにしてください。最も簡単な方法は、四角形の四角形を使用して、小さなオフセットで文字を右側の線とその上の線を区切ることです。たとえば、文字の幅と高さを 0.5 にし、文字を整数座標で揃えることができます。文字頂点の座標を切り捨てると、その文字の左下の座標が得られます。

頂点座標を切り捨てて文字の左上隅を見つけます。
頂点座標を切り捨てて文字の左上隅を見つけます。

アニメーション化された頂点シェーダーについて理解を深めるために、まずはシンプルな汎用頂点シェーダーを見ていきましょう。画面に 3D モデルを描画する場合は、通常これが発生します。モデルの頂点は、2 つの変換行列によって変換されて、各 3 次元頂点を 2 次元画面に投影します。これらの頂点のうち 3 つによって定義された三角形がビューポート内にある場合、その三角形がカバーするピクセルはフラグメント シェーダーで処理されて色付けされます。とにかく、シンプルな頂点シェーダーは次のとおりです。

varying float vUv;

void main() {
  // modelViewMatrix, position and projectionMatrix are magical
  // attributes that Three.js defines for us.

  // Transform current vertex by the modelViewMatrix
  // (bundled model world position & camera world position matrix).
  vec4 mvPosition = modelViewMatrix * position;

  // Project camera-space vertex to screen coordinates
  // using the camera's projection matrix.
  vec4 p = projectionMatrix * mvPosition;

  // uv is another magical attribute from Three.js.
  // We're passing it to the fragment shader unchanged.
  vUv = uv;

  gl_Position = p;
}

次は、アニメーション化された頂点シェーダーです。基本的には、単純な頂点シェーダーと同じ処理を行いますが、少しひねりを加えたものです。各頂点を変換行列のみによって変換する代わりに、時間依存のアニメーション変換も適用します。各文字に少し異なるアニメーションを持たせるために、アニメーション化された頂点シェーダーでは、文字の座標に基づいてアニメーションも変更されます。頂点シェーダーは単純なものよりもかなり複雑です。

uniform float uTime;
uniform float uEffectAmount;

varying float vZ;
varying vec2 vUv;

// rotateAngleAxisMatrix returns the mat3 rotation matrix
// for given angle and axis.
mat3 rotateAngleAxisMatrix(float angle, vec3 axis) {
  float c = cos(angle);
  float s = sin(angle);
  float t = 1.0 - c;
  axis = normalize(axis);
  float x = axis.x, y = axis.y, z = axis.z;
  return mat3(
    t*x*x + c,    t*x*y + s*z,  t*x*z - s*y,
    t*x*y - s*z,  t*y*y + c,    t*y*z + s*x,
    t*x*z + s*y,  t*y*z - s*x,  t*z*z + c
  );
}

// rotateAngleAxis rotates a vec3 over the given axis by the given angle and
// returns the rotated vector.
vec3 rotateAngleAxis(float angle, vec3 axis, vec3 v) {
  return rotateAngleAxisMatrix(angle, axis) * v;
}

void main() {
  // Compute the index of the letter (assuming 80-character max line length).
  float idx = floor(position.y/1.1)*80.0 + floor(position.x/1.1);

  // Round down the vertex coords to find the bottom-left corner point of the letter.
  vec3 corner = vec3(floor(position.x/1.1)*1.1, floor(position.y/1.1)*1.1, 0.0);

  // Find the midpoint of the letter.
  vec3 mid = corner + vec3(0.5, 0.5, 0.0);

  // Rotate the letter around its midpoint by an angle and axis dependent on
  // the letter's index and the current time.
  vec3 rpos = rotateAngleAxis(idx+uTime,
    vec3(mod(idx,16.0), -8.0+mod(idx,15.0), 1.0), position - mid) + mid;

  // uEffectAmount controls the amount of animation applied to the letter.
  // uEffectAmount ranges from 0.0 to 1.0.
  float effectAmount = uEffectAmount;

  vec4 fpos = vec4( mix(position,rpos,effectAmount), 1.0 );
  fpos.x += -35.0;

  // Apply spinning motion to individual letters.
  fpos.z += ((sin(idx+uTime*2.0)))*4.2*effectAmount;
  fpos.y += ((cos(idx+uTime*2.0)))*4.2*effectAmount;

  vec4 mvPosition = modelViewMatrix * fpos;

  // Apply wavy motion to the entire text.
  mvPosition.y += 10.0*sin(uTime*0.5+mvPosition.x/25.0)*effectAmount;
  mvPosition.x -= 10.0*cos(uTime*0.5+mvPosition.y/25.0)*effectAmount;

  vec4 p = projectionMatrix * mvPosition;

  // Pass texture coordinates and the vertex z-coordinate to the fragment shader.
  vUv = uv;
  vZ = p.z;

  // Send the final vertex position to WebGL.
  gl_Position = p;
}

頂点シェーダーを使用するには、マテリアル タイプである THREE.ShaderMaterial を使用します。これにより、カスタム シェーダーを使用してユニフォームを指定できます。このデモで THREE.ShaderMaterial を使用する方法は次のとおりです。

// First, set up uniforms for the shader.
var uniforms = {

  // map contains the letter map texture.
  map: { type: "t", value: 1, texture: tex },

  // uTime is the urrent time.
  uTime: { type: "f", value: 1.0 },

  // uEffectAmount controls the amount of animation applied to the letters.
  uEffectAmount: { type: "f", value: 0.0 }
};

// Next, set up the THREE.ShaderMaterial.
var shaderMaterial = new THREE.ShaderMaterial({
  uniforms: uniforms,

  // I have my shaders inside HTML elements like
  // <script id="vertex" type="text/x-glsl-vert">... shaderSource ... <script>

  // The below gets the contents of the vertex shader script element.
  vertexShader: document.querySelector('#vertex').textContent,

  // The fragment shader is a bit special as well, drawing a rotating
  // rainbow gradient.
  fragmentShader: document.querySelector('#fragment').textContent
});

// I set depthTest to false so that the letters don't occlude each other.
shaderMaterial.depthTest = false;

アニメーション フレームごとに、シェーダー ユニフォームを更新します。

// I'm controlling the uniforms through a proxy control object.
// The reason I'm using a proxy control object is to
// have different value ranges for the controls and the uniforms.
var controller = {
  effectAmount: 0
};

// I'm using <a href="http://code.google.com/p/dat-gui/">DAT.GUI</a> to do a quick & easy GUI for the demo.
var gui = new dat.GUI();
gui.add(controller, 'effectAmount', 0, 100);

var animate = function(t) {
  uniforms.uTime.value += 0.05;
  uniforms.uEffectAmount.value = controller.effectAmount/100;
  bookModel.position.y += 0.03;

  renderer.render(scene, camera);
  requestAnimationFrame(animate, renderer.domElement);
};
animate(Date.now());

これでシェーダー ベースのアニメーションは完成です。かなり複雑に見えますが、実際に機能しているのは、現在の時刻と各文字のインデックスに応じて文字を動かすことだけです。パフォーマンスが問題にならないのであれば、このロジックを JavaScript で実行することもできます。しかし、数万個のアニメーション オブジェクトがある場合、JavaScript は有効なソリューションとは言えません。

その他の懸念事項

ここでの問題の一つは、JavaScript が粒子の位置を認識していないことです。粒子の位置を本当に把握する必要がある場合は、JavaScript で頂点シェーダー ロジックを複製し、位置が必要になるたびに Web Worker を使用して頂点の位置を再計算できます。これにより、レンダリング スレッドが計算を待つ必要がなくなり、スムーズなフレームレートでアニメーションを続行できます。

アニメーションをより制御しやすくするには、レンダリングからテクスチャへの機能を使用して、JavaScript で提供される 2 つの位置セット間でアニメーション化できます。まず、現在位置をテクスチャにレンダリングしてから、JavaScript から提供される別のテクスチャで定義された位置にアニメーション化します。この方法の長所は、JavaScript によって提供される位置のごく一部をフレームごとに更新できる点です。頂点シェーダーで位置をトゥイーンすることで、フレームごとにすべての文字をアニメーション化できます。

もう一つの懸念は、ASCII 以外のテキストでは 256 文字が少なすぎることです。テクスチャ マップのサイズを 4096x4096 に押し、フォントサイズを 8px に減らすと、UCS-2 キャラクタ セット全体をテクスチャ マップに収めることができます。しかし、8px のフォントサイズはあまり読みにくくなっています。フォントサイズを大きくするには、フォントに複数のテクスチャを使用します。例については、スプライト アトラスのデモをご覧ください。テキストで使用する文字だけを作成するのも効果的です。

まとめ

この記事では、Three.js を使用して頂点シェーダー ベースのアニメーション デモを実装する方法について説明しました。このデモでは、2010 年の MacBook Air で 100 万文字をリアルタイムでアニメーション化しています。この実装では、効率的に描画するために、書籍全体を 1 つのジオメトリ オブジェクトにまとめました。個々の文字は、どの頂点がどの文字に属しているのかを判断し、本テキスト内の文字のインデックスに基づいて頂点をアニメーション化することで、アニメーション化されました。

参照