使用 Three.js 为一百万个字母添加动画效果

Ilmari Heikkinen

简介

本文的目标是在屏幕上以流畅的帧速率绘制 100 万个动画字母。使用新型 GPU 应该可以轻松完成此任务。每个字母由两个纹理三角形组成,因此我们只需要每帧渲染 200 万个三角形。

如果您是传统 JavaScript 动画背景的开发者,这一切听起来都像是疯狂之举。现在,您肯定不想使用 JavaScript 每帧更新 200 万个三角形。但幸运的是,我们有 WebGL,它让我们能够充分利用新型 GPU 的强大性能。借助现代 GPU 和一些着色器魔法,两百万个动画三角形完全可行。

编写高效的 WebGL 代码

编写高效的 WebGL 代码需要具备一定的思维方式。使用 WebGL 绘图的常用方法是为每个对象设置 uniform、缓冲区和着色器,然后调用以绘制对象。在绘制少量对象时,这种绘制方式可行。如需绘制大量对象,您应尽量减少 WebGL 状态更改的数量。首先,使用相同的着色器依次绘制所有对象,这样您就不必在对象之间更改着色器。对于像粒子这样的简单对象,您可以将多个对象捆绑到一个缓冲区中,并使用 JavaScript 对其进行修改。这样一来,您只需重新上传顶点缓冲区,而无需更改每个单个粒子的着色器 uniform。

不过,要想实现极快的速度,您需要将大部分计算推送到着色器。我正在尝试解决这个问题。使用着色器为 100 万个字母添加动画效果。

本文中的代码使用了 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 进行动画处理。在为涉及的 600 万个顶点添加动画时,JavaScript 的速度会比较慢,尤其是在您希望在每个帧上都执行此操作时。或许有更快的方法。

没错,我们可以进行程序化动画。这意味着,我们将在顶点着色器中执行所有位置和旋转计算。现在,我无需运行任何 JavaScript 即可更新顶点的位置。顶点着色器的运行速度非常快,即使每帧有 100 万个三角形单独进行动画处理,也能获得流畅的帧速率。为了解决单个三角形的问题,我会向下舍入顶点坐标,以便将字母四边形的所有四个点映射到单个唯一坐标。现在,我可以使用此坐标为相关字母设置动画参数。

为了能够成功向下舍入坐标,两个不同字母对应的坐标不能重叠。最简单的方法是使用方形字母四边形,并在字母与其右侧的字母和上方的线条之间留出小偏移。例如,您可以为字母使用宽度和高度为 0.5,并将字母对齐到整数坐标。现在,当您向下舍入任何字母顶点的坐标时,都会得到该字母的左下角坐标。

向下舍入顶点坐标,以查找字母的左上角。
向下舍入顶点坐标,以查找字母的左上角。

为了更好地了解动画顶点着色器,我将先介绍一个简单的常规顶点着色器。当您将 3D 模型绘制到屏幕上时,通常会出现这种情况。模型的顶点会通过一对转换矩阵进行转换,以将每个 3D 顶点投影到 2D 屏幕上。每当由其中三个顶点定义的三角形位于视口内时,fragment 着色器都会处理其覆盖的像素,以对其着色。无论如何,下面是简单的顶点着色器:

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,这是一种材质类型,可让您使用自定义着色器并为其指定 uniform。下面是我在演示中使用 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;

在每帧动画中,我都会更新着色器 uniform:

// 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 提供的两组位置之间进行动画处理。首先,将当前位置渲染到纹理,然后以动画形式移动到 JavaScript 提供的单独纹理中定义的位置。这样做的好处在于,您可以每帧更新 JavaScript 提供的少数位置,同时仍可继续使用顶点着色器对位置进行补间,从而每帧为所有字母呈现动画效果。

另一个问题是,256 个字符对于非 ASCII 文本来说太少了。如果将纹理贴图大小推送到 4096x4096,同时将字体大小减小到 8px,则可以将整个 UCS-2 字符集放入纹理贴图中。不过,8 像素的字号不太容易辨认。如需使用更大的字号,您可以为字体使用多个纹理。如需查看示例,请参阅此精灵图集演示。另一个有帮助的方法是,只创建文本中使用的字母。

摘要

在本文中,我向您介绍了如何使用 Three.js 实现基于顶点着色器的动画演示。此演示在 2010 款 MacBook Air 上实时呈现 100 万个字母的动画效果。该实现将整本图书打包到单个几何图形对象中,以便高效绘制。我们通过确定哪些顶点属于哪个字母,并根据图书文本中字母的索引为顶点添加动画效果,从而为每个字母添加了动画效果。

参考