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

Ilmari Heikkinen

简介

在本文中,我的目标是以流畅的帧速率在屏幕上绘制 100 万个动画字母。有了现代 GPU,这项任务应该很容易就能完成。每个字母都由两个带纹理的三角形组成,因此我们仅说每帧两百万个三角形。

如果您的背景是传统的 JavaScript 动画背景,那么这些动画听起来很疯狂。每一帧更新两百万个三角形绝对不是您现在想用 JavaScript 进行的操作。但值得庆幸的是,我们采用了 WebGL 技术,让我们能够利用新式 GPU 的强大功能。凭借新型 GPU,再加上一些神奇的着色器,就能轻松处理 200 万个动画三角形。

编写高效的 WebGL 代码

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

但要快速完成任务,您需要将大部分计算推送到着色器。这就是我要做的使用着色器为一百万个字母添加动画效果。

本文的代码使用了 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);

好了,现在您已经掌握了基本思路,接下来我们继续编写演示,并开始为这 00 万个字母添加动画效果!

设置几何图形和纹理

首先,我要创建一个带有字母位图的纹理。为此,我使用的是 2D 画布。生成的纹理包含我要绘制的所有字母。下一步是创建一个缓冲区,其中包含字母精灵工作表的纹理坐标。虽然这是一种简单直接的字母设置方法,但有点浪费,因为每个顶点都使用两个浮点数作为纹理坐标。一种较短的方式(作为练习留给读者)是,将字母索引和角索引打包为一个数字,然后将其转换回顶点着色器中的纹理坐标。

下面展示了如何使用画布 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 在为涉及的 600 万个顶点添加动画效果时,速度会比较慢,尤其是当您想在每一帧上时。也许有更快的方法。

为什么是的,我们可以制作程序化动画。这意味着,我们将在顶点着色器中执行所有位置和旋转数学运算。现在,我不需要运行任何 JavaScript 来更新顶点的位置。顶点着色器运行非常快,即使每一帧都用一百万个三角形单独呈现动画效果,我的帧速率也能保持平稳。为了处理单个三角形,我向下舍入顶点坐标,使字母四边形的所有四个点都映射到单个唯一坐标。现在,我可以使用此坐标来设置相关字母的动画参数。

为了能够成功地向下舍入坐标,两个不同字母的坐标不能重叠。最简单的方法是使用方形字母四边形,用一个小的偏移量将字母与右侧的字母和上面的一行分隔开。例如,您可以为字母使用 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 字符集放入纹理映射中。但是,8px 的字体大小不太好看。如需采用更大的字号,您可以为字体使用多个纹理。有关示例,请查看此精灵图集演示。另一种有用的方法是仅创建文本中使用的字母。

摘要

在本文中,我详细介绍了如何使用 Three.js 实现基于顶点着色器的动画演示。该演示版在 2010 年的 MacBook Air 上实时以动画形式呈现一百万个字母。该实现将整本书捆绑成了一个几何图形对象,以便高效绘制。我们通过计算各个顶点属于哪个字母,并根据字母在书名中的索引为各个顶点添加动画效果,从而为各个字母添加动画效果。

参考编号