使用 Three.js 呈現一百萬個字母動畫

Ilmari Heikkinen

簡介

本文的目標是在螢幕上以流暢的幀率繪製一百萬個動畫字母。這項工作應該可以使用現代 GPU 執行。每個字母都由兩個紋理三角形組成,因此每個影格只有兩百萬個三角形。

如果您是傳統 JavaScript 動畫背景人士,這一切都會讓您覺得瘋狂。每個影格更新兩百萬個三角形,絕對不是您今天想用 JavaScript 執行的操作。不過,幸好我們有 WebGL,可讓我們運用現代 GPU 的強大效能。使用現代 GPU 和一些著色器魔法,兩百萬個動畫三角形是相當可行的。

編寫高效的 WebGL 程式碼

編寫高效的 WebGL 程式碼需要具備特定心態。使用 WebGL 繪圖的一般方式,是為每個物件設定統一變數、緩衝區和著色器,然後呼叫繪圖物件。這種繪製方式適用於繪製少量物件時。如要繪製大量物件,請盡量減少 WebGL 狀態變更的次數。首先,請使用相同的著色器依序繪製所有物件,這樣您就不必在物件之間變更著色器。針對粒子等簡單物件,您可以將多個物件組合成單一緩衝區,然後使用 JavaScript 進行編輯。這樣一來,您只需要重新上傳頂點緩衝區,而不需要為每個粒子變更著色器統一變數。

不過,如要加快速度,您必須將大部分運算推送至著色器。我正在嘗試解決這個問題。使用著色器為數百萬個字母製作動畫。

本文的程式碼使用 Three.js 程式庫,可將所有繁瑣的樣板從 WebGL 程式碼中抽象化。使用 Three.js 時,您只需編寫幾行程式碼,而不需要編寫數百行的 WebGL 狀態設定和錯誤處理程式碼。您也可以輕鬆透過 Three.js 使用 WebGL 著色器系統。

使用單一繪圖呼叫繪製多個物件

以下是小型程式碼範例,說明如何使用單一繪圖呼叫繪製多個物件。傳統方法是一次繪製一個物件,如下所示:

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);

但上述方法需要為每個物件分別呼叫繪圖。如要一次繪製多個物件,您可以將物件綁定至單一幾何圖形,並透過單一繪圖呼叫完成繪製:

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);

好的,現在您已掌握基本概念,讓我們回到撰寫示範內容,並開始為數百萬個字母製作動畫!

設定幾何圖形和紋理

首先,我要建立包含字母位圖的紋理。我會使用 2D 畫布來完成這項操作。產生的紋理包含所有要繪製的字母。接下來,我們要建立一個緩衝區,其中包含字母圖像片段表的紋理座標。雖然這是設定字母的簡單方法,但每個頂點都使用兩個浮點值做為紋理座標,因此有點浪費。較簡單的方法 (留給讀者練習) 是將字母索引和角落索引塞入一個數字,然後將其轉換回頂點著色器中的紋理座標。

以下是使用 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 製作動畫。而 JavaScript 在為六百萬個頂點製作動畫時速度較慢,尤其是如果您想在每個影格上執行此操作。或許有更快的方法。

是的,我們可以使用程序動畫。也就是說,我們會在頂點著色器中執行所有位置和旋轉運算。這樣一來,我不需要執行任何 JavaScript 即可更新頂點的位置。頂點著色器執行速度非常快,即使每個影格有百萬個三角形個別動畫,也能獲得流暢的幀率。為了處理個別的三角形,我將頂點座標四捨五入,讓字母四邊形的所有四個點對應至單一不重複的座標。我現在可以使用這個座標,為問題中的字母設定動畫參數。

為了順利將座標四捨五入,兩個不同字母的座標不得重疊。最簡單的方法是使用方形字母四邊形,並在字母與右側字母和上方線條之間加上一點偏移,舉例來說,您可以為字母使用 0.5 的寬度和高度,並將字母對齊於整數座標。這樣一來,當您將任何字母頂點的座標捨入時,就會取得該字母的左下方座標。

將頂點座標四捨五入,找出字母的左上角。
將頂點座標四捨五入,找出字母的左上角。

為了進一步瞭解動畫頂點著色器,我將先介紹一個簡單的頂點著色器。這是您在螢幕上繪製 3D 模型時通常會發生的情況。模型的頂點會透過一對轉換矩陣轉換,將每個 3D 頂點投射到 2D 畫面上。每當由這三個頂點定義的三角形落在檢視區內時,片段著色器就會處理其涵蓋的像素,為其著色。無論如何,以下是簡單的頂點著色器:

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 中複製頂點著色器邏輯,並在需要位置時使用網路工作者重新計算頂點位置。這樣一來,轉譯執行緒就不會等待運算,您也可以繼續以流暢的影格速率製作動畫。

如要進一步控管動畫,您可以使用轉譯至紋理的功能,在 JavaScript 提供的兩組位置之間顯示動畫。首先,將目前的位置算繪至紋理,然後在 JavaScript 提供的獨立紋理中定義位置,以便進行動畫效果。這樣做的好處是,您可以更新每個影格中 JavaScript 提供位置的一小部分,並繼續使用頂點轉譯器來處理位置,以便在每個影格中為所有字母製作動畫。

另一個問題是,256 個字元太少,無法處理非 ASCII 文字。如果將紋理對應圖的大小設為 4096x4096,並將字型大小調降至 8 像素,即可將整個 UCS-2 字元集納入紋理對應圖。不過,8 像素的字型大小不易閱讀。如要使用較大的字型,您可以為字型使用多個紋理。如需範例,請參閱這個圖塊集示範。另一個有助於改善的做法,是只建立文字中使用的字母。

摘要

在本文中,我將逐步說明如何使用 Three.js 實作以頂點著色器為基礎的動畫示範。這個示範影片會在 2010 年款 MacBook Air 上即時顯示百萬個字母的動畫。實作內容將整本書籍整合為單一幾何圖形物件,以便有效繪製。我們透過找出哪些頂點屬於哪個字母,並根據書籍文字中字母的索引為頂點製作動畫,為個別字母製作動畫。

參考資料